Skip to main content

cranpose_render_wgpu/
render.rs

1//! GPU rendering implementation using WGPU
2
3use crate::display_clip::{self, DisplayVisibleRegion};
4use crate::effect_renderer::{
5    projective_dest_bounds_rect, CompositeBatchItem, CompositeSampleMode, EffectRenderer,
6    EffectScratchTargetProvider, ProjectiveSurfaceComposite, RoundedCompositeMask,
7    ShaderCompositeBatchItem,
8};
9#[cfg(not(target_arch = "wasm32"))]
10use crate::effect_renderer::{PreparedProjectiveComposite, ProjectiveCompositeItem};
11use crate::frame_graph::{
12    FrameCommandRecorder, FrameTextureDescriptor, WgpuFrameGraph, WgpuFrameGraphExecutor,
13};
14use crate::frame_packet::{
15    CancelReason, FramePacket, PacketRoot, PresentOutcome, RenderReturns, RootSurfacePacket,
16};
17use crate::layer_events::{
18    collect_effect_ranges, collect_layer_events, LayerEvent, LayerEventKind,
19};
20use crate::layer_surface_cache::LayerSurfaceCache;
21#[cfg(not(target_arch = "wasm32"))]
22use crate::lazy_resource::LazyGpuResource;
23use crate::lazy_resource::PassPipeline;
24#[cfg(test)]
25use crate::normalized_scene::{
26    build_scene_window, collect_layer_contents, collect_layer_contents_with_translation_context,
27    filtered_effect_layer_index, scene_bounds, SceneWindowSource,
28};
29#[cfg(test)]
30use crate::normalized_scene::{estimate_layer_surface_rect, motion_stable_capture_bounds};
31use crate::normalized_scene::{translate_quad, ChildLayerComposite, CollectedLayer};
32use crate::offscreen::{capture_root_target_reads, OffscreenTarget};
33use crate::rect_to_quad;
34use crate::scene::{
35    BackdropLayer, CompositorScene, DrawOp, DrawOpKind, DrawShape, EffectLayer, ImageDraw,
36    RetainedDraw, SceneBrush, ShadowDraw, SimilarityTransform, SnapAnchor, TextDraw,
37};
38#[cfg(not(target_arch = "wasm32"))]
39use crate::segment_surface::{
40    Affine2, CaptureRect, SegmentSurfaceCache, SegmentSurfaceDecision, SegmentSurfaceKey,
41    SEGMENT_CAPTURE_SLOTS, SEGMENT_CAPTURE_UNIFORM_STRIDE,
42};
43use crate::shaders;
44#[cfg(test)]
45use crate::surface_executor::surface_target_size;
46use crate::surface_executor::{
47    apply_backdrop_layer_to_target as execute_apply_backdrop_layer_to_target,
48    axis_aligned_quad_rect, backdrop_underlay_is_covered_by_local_content,
49    canonicalize_device_coordinate, canonicalized_scaled_quad, canonicalized_scaled_rect,
50    composite_surface_to_view as execute_composite_surface_to_view, device_pixel_bounds_for_rect,
51    offscreen_byte_size, render_effect_layer_to_target as execute_render_effect_layer_to_target,
52    render_layer_surface as execute_render_layer_surface,
53    render_root_direct as execute_render_root_direct, root_direct_scene_events_are_supported,
54    scaled_quad, snap_delta_for_anchor, snap_motion_stable_dest_quad,
55    translation_stable_anchored_device_pixel_bounds, DevicePixelBounds, LayerSurfaceTexture,
56    SurfaceExecutionBackend,
57};
58#[cfg(test)]
59use crate::surface_executor::{clamp_effect_surface_scale, visible_layer_rect};
60#[cfg(test)]
61use crate::surface_plan::root_can_render_directly_cached;
62#[cfg(test)]
63use crate::surface_plan::{
64    composite_sample_mode_for_effect_layer, composite_sample_mode_for_requirements,
65    direct_translation, effect_layer_target_scale, layer_contains_descendant_backdrop,
66    layer_surface_requirements, layer_surface_requirements_cached, layer_surface_scale,
67    layer_surface_target_scale, layer_uses_external_backdrop_input, TranslatedContentAxes,
68};
69use crate::surface_plan::{LayerSurfaceRequest, TranslationRenderContext};
70#[cfg(test)]
71use crate::surface_requirements::SurfaceRequirement;
72use crate::surface_requirements::SurfaceRequirementSet;
73use crate::DebugCpuAllocationStats;
74use bytemuck::{Pod, Zeroable};
75#[cfg(any(not(target_arch = "wasm32"), test))]
76use cranpose_core::collections::map::HashMap;
77use cranpose_core::{hash::default as default_hash, NodeId};
78use cranpose_render_common::bounded_lru_cache::BoundedLruCache;
79use cranpose_render_common::geometry::blur_extent_margin;
80use cranpose_render_common::graph::quad_bounds;
81#[cfg(test)]
82use cranpose_render_common::graph::{
83    CachePolicy, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform,
84    RenderNode,
85};
86use cranpose_render_common::raster_cache::LayerRasterCacheKey;
87#[cfg(test)]
88use cranpose_render_common::raster_cache::ScaleBucket;
89use cranpose_render_common::software_text_raster::{
90    collect_solid_text_atlas_run, measure_text_with_font,
91    rasterize_annotated_text_to_image_with_glyph_cache, rasterize_text_to_image_with_glyph_cache,
92    SoftwareGlyphAtlasGlyph, SoftwareGlyphAtlasKey, SoftwareGlyphAtlasPlacement,
93    SoftwareGlyphAtlasRunGlyph, SoftwareGlyphRasterCache, SoftwareTextFontSet,
94};
95#[cfg(test)]
96use cranpose_ui_graphics::GraphicsLayer;
97use cranpose_ui_graphics::{
98    BlendMode, Brush, Color, ColorFilter, FxHasher, ImageBitmap, ImageSampling, Point, Rect,
99    RenderEffect, RenderHash, RuntimeShader, StrokeCap, StrokeJoin, TileMode,
100};
101use std::borrow::Cow;
102use std::cell::Cell;
103use std::hash::{Hash, Hasher};
104use std::ops::Range;
105use std::rc::Rc;
106#[cfg(not(target_arch = "wasm32"))]
107use std::sync::atomic::{AtomicUsize, Ordering};
108use std::sync::{mpsc, Arc};
109use std::time::Duration;
110use web_time::Instant;
111
112use crate::gpu_stats;
113use crate::gpu_stats::gpu_stats_enabled;
114use crate::pipeline::push_layer_shadow;
115
116/// Must equal the `array<ShapeData, N>` literal in `shape.wgsl`: on wasm the
117/// shader source is used verbatim, so a larger batch cap here would index past
118/// the declared array. 102 x 160-byte ShapeData = 16320 bytes, the most that
119/// fits WebGL's 16 KiB uniform-binding floor.
120#[cfg(target_arch = "wasm32")]
121const MAX_SHAPES_PER_BATCH: usize = 102;
122#[cfg(not(target_arch = "wasm32"))]
123const MAX_SHAPES_PER_BATCH: usize = 768;
124#[cfg(target_arch = "wasm32")]
125const MAX_GRADIENT_STOPS: usize = 256;
126#[cfg(not(target_arch = "wasm32"))]
127const MAX_GRADIENT_STOPS: usize = 1024;
128
129/// Per-pass ceilings when the shape and gradient arrays live in storage
130/// buffers instead of uniforms. These are not hardware limits — storage
131/// bindings are hundreds of megabytes everywhere — they bound worst-case
132/// buffer growth: 65 536 shapes is a 7 MiB shape buffer and a 12 MiB vertex
133/// buffer, far past any real scene, while still forcing a batch split before
134/// a pathological one can ask for gigabytes.
135#[cfg(not(target_arch = "wasm32"))]
136const MAX_SHAPES_PER_STORAGE_BATCH: usize = 1 << 16;
137#[cfg(not(target_arch = "wasm32"))]
138const MAX_GRADIENT_STOPS_PER_STORAGE_BATCH: usize = 1 << 16;
139
140/// How many shapes/stops the storage-mode buffers start out sized for. In
141/// uniform mode the initial capacity must equal the cap (a uniform binding
142/// smaller than the shader's fixed-length array fails validation), but a
143/// runtime-sized storage array binds at any size, so start small and let
144/// `ensure_capacity` double toward the cap as scenes demand.
145#[cfg(not(target_arch = "wasm32"))]
146const INITIAL_STORAGE_BATCH_CAPACITY: usize = 1024;
147
148/// Shape/gradient batch capacities derived from the actual device limits.
149///
150/// Where storage buffers are available (any real Vulkan/Metal/D3D device, and
151/// GL only when it exposes SSBOs to fragment shaders) the arrays are bound as
152/// read-only storage and a whole scene fits one batch. Otherwise they fall
153/// back to uniform arrays: the compile-time `MAX_*` constants assume
154/// desktop-class 64 KiB uniform bindings, while Android downlevel and
155/// GLES-class devices may only offer the 16 KiB spec minimum; sizing the
156/// buffers (and the matching WGSL array lengths) past
157/// `max_uniform_buffer_binding_size` makes the very first "Shape Bind Group"
158/// fail validation and aborts the app.
159#[derive(Clone, Copy, Debug, Eq, PartialEq)]
160struct ShapeBatchLimits {
161    max_shapes_per_batch: usize,
162    max_gradient_stops: usize,
163    storage: bool,
164}
165
166impl ShapeBatchLimits {
167    fn for_device(device: &wgpu::Device, downlevel: wgpu::DownlevelFlags) -> Self {
168        Self::select(&device.limits(), downlevel)
169    }
170
171    /// Storage mode or uniform mode, from the two things that decide it.
172    ///
173    /// Split out from `for_device` because the interesting case cannot be
174    /// reached with a device in hand: it needs an adapter that reports storage
175    /// buffers and no vertex-stage access, which is every ARM Mali GLES driver
176    /// and no desktop.
177    fn select(limits: &wgpu::Limits, downlevel: wgpu::DownlevelFlags) -> Self {
178        #[cfg(not(target_arch = "wasm32"))]
179        if limits.max_storage_buffers_per_shader_stage >= 2
180            // `max_storage_buffers_per_shader_stage` alone is NOT the question,
181            // even though its name reads like a per-stage minimum. On ARM's
182            // GLES driver it comes back non-zero off the fragment stage while
183            // the vertex stage has no storage at all, so the check passed, the
184            // storage layout was built, and binding 0 -- the shape array, which
185            // is VERTEX_FRAGMENT because `vs_main` pulls quad corners out of it
186            // -- failed validation the moment the layout was created:
187            //
188            //   In Device::create_bind_group_layout, label = 'Shape Bind Group
189            //   Layout'; Binding 0 entry is invalid; Downlevel flags
190            //   DownlevelFlags(VERTEX_STORAGE) are required but not supported
191            //   on the device.
192            //
193            // wgpu treats that as fatal, so the renderer thread panicked and
194            // the app dropped back to the launcher on Mali-G76 (r18p0, 2019)
195            // and Mali-G715 (r54p3, 2024) alike -- driver age is not the
196            // variable. Adreno 650 and Adreno 702 have the flag and are
197            // unaffected. `VERTEX_STORAGE` is the flag that actually answers
198            // the question the layout asks, so ask it.
199            && downlevel.contains(wgpu::DownlevelFlags::VERTEX_STORAGE)
200        {
201            return Self::for_storage_binding_size(limits.max_storage_buffer_binding_size);
202        }
203        Self::for_uniform_binding_size(limits.max_uniform_buffer_binding_size)
204    }
205
206    fn for_uniform_binding_size(max_uniform_buffer_binding_size: u64) -> Self {
207        let binding = max_uniform_buffer_binding_size as usize;
208        Self {
209            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
210                .clamp(1, MAX_SHAPES_PER_BATCH),
211            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
212                .clamp(1, MAX_GRADIENT_STOPS),
213            storage: false,
214        }
215    }
216
217    #[cfg(not(target_arch = "wasm32"))]
218    fn for_storage_binding_size(max_storage_buffer_binding_size: u64) -> Self {
219        let binding = max_storage_buffer_binding_size as usize;
220        Self {
221            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
222                .clamp(1, MAX_SHAPES_PER_STORAGE_BATCH),
223            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
224                .clamp(1, MAX_GRADIENT_STOPS_PER_STORAGE_BATCH),
225            storage: true,
226        }
227    }
228
229    fn initial_shape_capacity(&self) -> usize {
230        #[cfg(not(target_arch = "wasm32"))]
231        if self.storage {
232            return self
233                .max_shapes_per_batch
234                .min(INITIAL_STORAGE_BATCH_CAPACITY);
235        }
236        self.max_shapes_per_batch
237    }
238
239    fn initial_gradient_capacity(&self) -> usize {
240        #[cfg(not(target_arch = "wasm32"))]
241        if self.storage {
242            return self.max_gradient_stops.min(INITIAL_STORAGE_BATCH_CAPACITY);
243        }
244        self.max_gradient_stops
245    }
246
247    fn data_buffer_usage(&self) -> wgpu::BufferUsages {
248        if self.storage {
249            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST
250        } else {
251            wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST
252        }
253    }
254
255    fn data_binding_type(&self) -> wgpu::BufferBindingType {
256        if self.storage {
257            wgpu::BufferBindingType::Storage { read_only: true }
258        } else {
259            wgpu::BufferBindingType::Uniform
260        }
261    }
262
263    #[cfg(test)]
264    fn desktop() -> Self {
265        Self::for_uniform_binding_size(wgpu::Limits::default().max_uniform_buffer_binding_size)
266    }
267}
268#[cfg(target_arch = "wasm32")]
269const HARD_MAX_BUFFER_MB: usize = 64; // Maximum 64MB per buffer (image vertex/index only)
270const MAX_SHADOW_SURFACE_CACHE_ITEMS: usize = 512;
271// Sized for HiDPI: a 4K fractional-scale screen full of shadowed panels needs
272// ~10-15 rasters of 4-12MB each; a 64MB budget made the large entries evict
273// each other every frame during scroll, re-blurring tens of megapixels.
274const MAX_SHADOW_SURFACE_CACHE_BYTES: u64 = 192 * 1024 * 1024;
275const MAX_TEXT_IMAGE_CACHE_ITEMS: usize = 1024;
276const MAX_TEXT_GLYPH_MASK_CACHE_ITEMS: usize = 8192;
277const MAX_TEXT_GLYPH_ATLAS_ITEMS: usize = 8192;
278const MAX_TEXT_GLYPH_RUN_CACHE_ITEMS: usize = 1024;
279#[cfg(not(target_arch = "wasm32"))]
280const MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS: usize = 1024;
281#[cfg(not(target_arch = "wasm32"))]
282const MIN_RETAINED_TEXT_GLYPH_QUADS: usize = 192;
283#[cfg(not(target_arch = "wasm32"))]
284const OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS: f64 = 0.75;
285#[cfg(not(target_arch = "wasm32"))]
286const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES: usize = 2;
287#[cfg(not(target_arch = "wasm32"))]
288const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS: usize = 160;
289#[cfg(not(target_arch = "wasm32"))]
290const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS: usize = 160;
291/// Side length the glyph atlas starts at, and the one it doubles towards.
292///
293/// The atlas is square and `R8Unorm`, so the maximum is a 16 MiB texture. That
294/// was also the starting size until it became the single largest resource the
295/// renderer allocated: a 454x454 watch face draws a couple of hundred distinct
296/// glyphs and needs well under a megabyte of them, but paid the full 16 MiB at
297/// renderer construction, before a single glyph had been rastered. Starting at
298/// `MIN` and doubling on overflow (see `TextGlyphAtlas::reset`) costs at most
299/// three extra resets for a workload that genuinely needs the large atlas —
300/// which then behaves exactly as the fixed 4096 atlas did — and costs a
301/// text-light screen 256 KiB instead of 16 MiB, permanently.
302const TEXT_GLYPH_ATLAS_MIN_SIZE: u32 = 512;
303const TEXT_GLYPH_ATLAS_MAX_SIZE: u32 = 4096;
304const TEXT_GLYPH_ATLAS_PADDING: u32 = 1;
305const MAX_TEXT_LINE_INDEX_CACHE_ITEMS: usize = 512;
306const MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER: usize = 2;
307const MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES: usize = 128;
308const CACHE_MISS_WARMUP_FRAMES: u8 = 1;
309pub(crate) const CLEAR_COLOR: wgpu::Color = wgpu::Color {
310    r: cranpose_render_common::FRAME_CLEAR_COLOR[0] as f64,
311    g: cranpose_render_common::FRAME_CLEAR_COLOR[1] as f64,
312    b: cranpose_render_common::FRAME_CLEAR_COLOR[2] as f64,
313    a: cranpose_render_common::FRAME_CLEAR_COLOR[3] as f64,
314};
315#[cfg(not(target_arch = "wasm32"))]
316const INITIAL_UPLOAD_BUFFER_BYTES: u64 = 4 * 1024;
317#[cfg(not(target_arch = "wasm32"))]
318const INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS: usize = 128;
319const MAX_TEXTURE_CACHE_ITEMS: usize = 256;
320/// Byte ceiling for `image_texture_cache` (see `CachedImageTexture::bytes`).
321/// Generous enough for a screenful of full-page images plus thumbnails;
322/// small enough that a camera preview stream can never pin gigabytes.
323const MAX_IMAGE_TEXTURE_CACHE_BYTES: usize = 256 * 1024 * 1024;
324const RETAINED_STAGED_UPLOAD_BYTES: usize = 256 * 1024;
325const RETAINED_STAGED_UPLOAD_COPIES: usize = 128;
326pub(crate) const RETAINED_LAYER_REQUIREMENTS_CAPACITY: usize = 512;
327const DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS: f64 = 4.0;
328#[cfg(not(target_arch = "wasm32"))]
329static SEGMENT_DIAG_LINES: AtomicUsize = AtomicUsize::new(0);
330// Reclaim oversized text scratch allocations only after a meaningful 4x collapse
331// from a previously large frame; smaller swings are left alone to avoid churn.
332
333fn wgpu_render_stage_telemetry_threshold_ms() -> Option<f64> {
334    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
335    *THRESHOLD_MS.get_or_init(|| {
336        let explicit = std::env::var("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY_MS")
337            .ok()
338            .and_then(|value| value.parse::<f64>().ok())
339            .filter(|value| value.is_finite() && *value >= 0.0);
340        explicit.or_else(|| {
341            std::env::var_os("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY")
342                .is_some()
343                .then_some(DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS)
344        })
345    })
346}
347
348pub(crate) fn instant_ms(start: Instant, end: Instant) -> f64 {
349    end.duration_since(start).as_secs_f64() * 1000.0
350}
351
352pub(crate) fn should_log_wgpu_render_stage(start: Instant, end: Instant) -> Option<f64> {
353    let threshold_ms = wgpu_render_stage_telemetry_threshold_ms()?;
354    let total_ms = instant_ms(start, end);
355    (total_ms >= threshold_ms).then_some(total_ms)
356}
357
358fn admit_layer_surface_cache_miss_impl(
359    key: &LayerRasterCacheKey,
360    observed_scene_range_misses: &mut BoundedLruCache<LayerRasterCacheKey, ()>,
361) -> bool {
362    if !key.is_scene_range() {
363        return true;
364    }
365    if observed_scene_range_misses.contains(key) {
366        return true;
367    }
368    observed_scene_range_misses.put(*key, ());
369    false
370}
371
372#[cfg(test)]
373fn first_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
374    let mut observed_scene_range_misses =
375        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
376    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
377}
378
379#[cfg(test)]
380fn repeated_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
381    let mut observed_scene_range_misses =
382        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
383    let _ = admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses);
384    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
385}
386
387pub static PRESENTED_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
388
389pub fn frames_presented() -> u64 {
390    PRESENTED_FRAMES.load(std::sync::atomic::Ordering::Relaxed)
391}
392
393fn frame_stats_need_warmup_frame(snapshot: &gpu_stats::FrameStatsSnapshot) -> bool {
394    snapshot.layer_cache_misses > 0
395        || snapshot.shadow_shape_cache_misses > 0
396        || snapshot.text_image_cache_misses > 0
397        || snapshot.text_glyph_atlas_misses > 0
398}
399
400fn update_frame_warmup_budget(pending_frames: &mut u8, snapshot: &gpu_stats::FrameStatsSnapshot) {
401    if *pending_frames > 0 {
402        *pending_frames = pending_frames.saturating_sub(1);
403    } else if frame_stats_need_warmup_frame(snapshot) {
404        *pending_frames = CACHE_MISS_WARMUP_FRAMES;
405    }
406}
407
408fn text_atlas_fallback_diag_enabled() -> bool {
409    cranpose_core::env_flag!("CRANPOSE_TEXT_ATLAS_FALLBACK_DIAG")
410}
411
412fn text_glyph_run_diag_enabled() -> bool {
413    cranpose_core::env_flag!("CRANPOSE_TEXT_GLYPH_RUN_DIAG")
414}
415
416fn root_direct_diag_enabled() -> bool {
417    cranpose_core::env_flag!("CRANPOSE_ROOT_DIRECT_DIAG")
418}
419
420fn scene_layer_events_precede_z(scene: &CompositorScene, z_index: usize) -> bool {
421    scene
422        .effect_layers
423        .iter()
424        .any(|layer| layer.z_start < z_index && 0 < layer.z_end)
425        || scene
426            .backdrop_layers
427            .iter()
428            .any(|layer| layer.z_index < z_index)
429}
430
431fn direct_root_child_can_be_replayed_into_later_underlay(child: &ChildLayerComposite) -> bool {
432    child.backdrop.is_none()
433        && !child.has_effect
434        && child.shadow_draws.is_empty()
435        && axis_aligned_quad_rect(child.dest_quad).is_some()
436}
437
438fn rects_overlap(a: Rect, b: Rect) -> bool {
439    let a_right = a.x + a.width;
440    let a_bottom = a.y + a.height;
441    let b_right = b.x + b.width;
442    let b_bottom = b.y + b.height;
443    a.x < b_right && b.x < a_right && a.y < b_bottom && b.y < a_bottom
444}
445
446pub(crate) fn direct_root_child_underlays_are_supported(
447    collected: &CollectedLayer,
448    root_target_reads: bool,
449) -> bool {
450    for (child_index, child) in collected.child_layers.iter().enumerate() {
451        if child.backdrop.is_some() && !root_target_reads {
452            if root_direct_diag_enabled() {
453                log::warn!(
454                    "[root-direct-diag] reject self-backdrop child node={:?}",
455                    child.node_id
456                );
457            }
458            return false;
459        }
460        if child.needs_nested_underlay {
461            let Some(dest_rect) = axis_aligned_quad_rect(child.dest_quad) else {
462                if root_direct_diag_enabled() {
463                    log::warn!(
464                        "[root-direct-diag] reject projective underlay child node={:?}",
465                        child.node_id
466                    );
467                }
468                return false;
469            };
470            let translation_only = (dest_rect.width - child.logical_rect.width).abs() <= 0.001
471                && (dest_rect.height - child.logical_rect.height).abs() <= 0.001;
472            let unsupported_preceding_child_layer = collected.child_layers[..child_index]
473                .iter()
474                .any(|preceding| {
475                    if direct_root_child_can_be_replayed_into_later_underlay(preceding) {
476                        return false;
477                    }
478                    axis_aligned_quad_rect(preceding.dest_quad)
479                        .is_none_or(|preceding_rect| rects_overlap(preceding_rect, dest_rect))
480                });
481            let preceding_scene_events =
482                scene_layer_events_precede_z(&collected.scene, child.z_index);
483            if unsupported_preceding_child_layer || preceding_scene_events || !translation_only {
484                if root_direct_diag_enabled() {
485                    log::warn!(
486                        "[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})",
487                        child.node_id,
488                        unsupported_preceding_child_layer,
489                        preceding_scene_events,
490                        translation_only,
491                        dest_rect.x,
492                        dest_rect.y,
493                        dest_rect.width,
494                        dest_rect.height,
495                        child.logical_rect.x,
496                        child.logical_rect.y,
497                        child.logical_rect.width,
498                        child.logical_rect.height
499                    );
500                }
501                return false;
502            }
503        }
504    }
505    true
506}
507
508#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
509struct ShadowSurfaceCacheKey {
510    content_hash: u64,
511    pixel_size: [u32; 2],
512    root_scale_bits: u32,
513    blur_radius_bits: u32,
514}
515
516struct CachedShadowSurface {
517    target: Rc<OffscreenTarget>,
518    byte_size: u64,
519}
520
521struct CachedShadowComposite {
522    source: Rc<OffscreenTarget>,
523    scissor: Option<(u32, u32, u32, u32)>,
524    rounded_mask: Option<RoundedCompositeMask>,
525    dest_viewport: Option<(f32, f32, f32, f32)>,
526}
527
528impl CachedShadowComposite {
529    fn batch_item(&self) -> CompositeBatchItem<'_> {
530        CompositeBatchItem {
531            source: &self.source,
532            alpha: 1.0,
533            scissor: self.scissor,
534            rounded_mask: self.rounded_mask,
535            blend_mode: BlendMode::SrcOver,
536            dest_viewport: self.dest_viewport,
537            source_viewport: None,
538            sample_mode: CompositeSampleMode::Nearest,
539        }
540    }
541}
542
543#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
544struct TextImageCacheKey(u64);
545
546struct CachedTextImage {
547    image: ImageBitmap,
548}
549
550#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
551struct TextGlyphRunCacheKey(u64);
552
553#[derive(Clone, Copy)]
554struct CachedTextGlyphQuad {
555    x: i32,
556    y: i32,
557    width: usize,
558    height: usize,
559    color: (f32, f32, f32, f32),
560    uv: ImageUvRect,
561}
562
563struct CachedTextGlyphRun {
564    glyphs: Rc<[SoftwareGlyphAtlasPlacement]>,
565    quads: Option<Rc<[CachedTextGlyphQuad]>>,
566    atlas_generation: u64,
567}
568
569const TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER: f32 = 2.0;
570
571#[cfg(not(target_arch = "wasm32"))]
572struct CachedGpuTextGlyphRun {
573    vertex_buffer: wgpu::Buffer,
574    index_buffer: wgpu::Buffer,
575    index_count: u32,
576    atlas_generation: u64,
577}
578
579#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
580struct TextLineIndexCacheKey(usize);
581
582struct CachedTextLineIndex {
583    text: std::sync::Weak<cranpose_ui::text::RenderString>,
584    len: usize,
585    starts: Rc<[usize]>,
586}
587
588struct TextLineIndexCache {
589    entries: BoundedLruCache<TextLineIndexCacheKey, CachedTextLineIndex>,
590}
591
592impl TextLineIndexCache {
593    fn new(capacity: usize) -> Self {
594        Self {
595            entries: BoundedLruCache::with_capacity_at_least_one(capacity),
596        }
597    }
598
599    fn line_starts(&mut self, text: &Arc<cranpose_ui::text::RenderString>) -> Rc<[usize]> {
600        let key = TextLineIndexCacheKey(Arc::as_ptr(text) as usize);
601        if let Some(cached) = self.entries.get(&key) {
602            if cached.len == text.text.len()
603                && cached
604                    .text
605                    .upgrade()
606                    .is_some_and(|cached_text| Arc::ptr_eq(&cached_text, text))
607            {
608                return cached.starts.clone();
609            }
610        }
611
612        let starts = Rc::<[usize]>::from(line_start_offsets(text.text.as_str()));
613        self.entries.put(
614            key,
615            CachedTextLineIndex {
616                text: Arc::downgrade(text),
617                len: text.text.len(),
618                starts: starts.clone(),
619            },
620        );
621        starts
622    }
623}
624
625#[derive(Clone, Copy, Debug, PartialEq)]
626struct ShapeShadowSurfacePlan {
627    source_device_bounds: DevicePixelBounds,
628    processing_scissor: Option<(u32, u32, u32, u32)>,
629    pixel_radius: f32,
630}
631
632/// Shared record of the device's uncaptured errors (validation, OOM,
633/// internal), written by the handler [`GpuRenderer::new`] installs via
634/// `Device::on_uncaptured_error` and read at the head of every
635/// [`GpuRenderer::render`].
636///
637/// wgpu's default handler panics on the reporting thread. Mid-encode that
638/// unwind runs the drop glue of live pass/encoder objects, whose own error
639/// reports re-enter the same panicking handler — a second panic inside the
640/// first's unwind aborts the process, and the tombstone carries neither
641/// message (a real device's validation failure was lost exactly this way).
642/// This handler never panics: it counts, logs the full error, and poisons;
643/// the render path answers with one cancelled packet per poisoning — the
644/// acquire path's give-up-this-frame semantics, not a latch.
645/// `CRANPOSE_SURVIVE_GPU_ERRORS=0` restores the fatal default
646/// ([`survive_gpu_errors_enabled`]).
647#[derive(Default)]
648struct DeviceErrorSentry {
649    /// Lifetime uncaptured errors on this device.
650    errors: std::sync::atomic::AtomicU64,
651    /// Set by the handler, taken (cleared) by the next frame's gate.
652    poisoned: std::sync::atomic::AtomicBool,
653}
654
655impl DeviceErrorSentry {
656    /// Never panics: this runs where the default handler would have
657    /// aborted the process (see the type doc).
658    fn record(&self, error: &wgpu::Error) {
659        use std::sync::atomic::Ordering;
660        self.poisoned.store(true, Ordering::Release);
661        let count = self.errors.fetch_add(1, Ordering::Relaxed) + 1;
662        // The full error every time it prints; rate-limited by count
663        // because one broken frame reports a follow-up error per
664        // subsequent encoder call. Power-of-two occurrences (1, 2, 4,
665        // 8, …) keep the first reports verbatim and decay the repeats
666        // without a clock; the count carries the volume.
667        if count.is_power_of_two() {
668            log::error!("[gpu-device] uncaptured wgpu error #{count}: {error}");
669        }
670    }
671
672    fn take_poison(&self) -> bool {
673        self.poisoned
674            .swap(false, std::sync::atomic::Ordering::AcqRel)
675    }
676
677    fn error_count(&self) -> u64 {
678        self.errors.load(std::sync::atomic::Ordering::Relaxed)
679    }
680}
681
682#[derive(Default)]
683struct RendererWarningState {
684    unsupported_effect_reported: Cell<bool>,
685}
686
687impl RendererWarningState {
688    fn warn_unsupported_effect_once(&self) {
689        if !self.unsupported_effect_reported.replace(true) {
690            log::warn!(
691                "WGPU renderer received an unsupported RenderEffect variant; falling back to passthrough compositing"
692            );
693        }
694    }
695}
696
697fn is_blend_mode_supported(mode: BlendMode) -> bool {
698    matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
699}
700
701fn blend_state_for_mode(mode: BlendMode) -> wgpu::BlendState {
702    match mode {
703        BlendMode::DstOut => wgpu::BlendState {
704            color: wgpu::BlendComponent {
705                src_factor: wgpu::BlendFactor::Zero,
706                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
707                operation: wgpu::BlendOperation::Add,
708            },
709            alpha: wgpu::BlendComponent {
710                src_factor: wgpu::BlendFactor::Zero,
711                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
712                operation: wgpu::BlendOperation::Add,
713            },
714        },
715        _ => wgpu::BlendState::ALPHA_BLENDING,
716    }
717}
718
719fn supported_blend_mode(mode: BlendMode) -> BlendMode {
720    if is_blend_mode_supported(mode) {
721        return mode;
722    }
723
724    BlendMode::SrcOver
725}
726
727fn direct_shader_composite_viewport(
728    alpha: f32,
729    blend_mode: BlendMode,
730    dest_viewport: Option<(f32, f32, f32, f32)>,
731    sample_mode: CompositeSampleMode,
732    source_size: (u32, u32),
733) -> Option<(f32, f32, f32, f32)> {
734    if alpha != 1.0 || supported_blend_mode(blend_mode) != BlendMode::SrcOver {
735        return None;
736    }
737    let viewport = dest_viewport?;
738    if viewport.2 <= 0.0 || viewport.3 <= 0.0 {
739        return None;
740    }
741    match sample_mode {
742        CompositeSampleMode::Linear | CompositeSampleMode::Nearest => Some(viewport),
743        CompositeSampleMode::Box4
744            if shader_composite_preserves_source_pixel_grid(viewport, source_size) =>
745        {
746            Some(viewport)
747        }
748        CompositeSampleMode::Box4 => None,
749    }
750}
751
752fn shader_composite_preserves_source_pixel_grid(
753    viewport: (f32, f32, f32, f32),
754    source_size: (u32, u32),
755) -> bool {
756    const EPSILON: f32 = 0.01;
757    let (x, y, width, height) = viewport;
758    let (source_width, source_height) = source_size;
759    (x - x.round()).abs() <= EPSILON
760        && (y - y.round()).abs() <= EPSILON
761        && (width - source_width as f32).abs() <= EPSILON
762        && (height - source_height as f32).abs() <= EPSILON
763}
764
765type DirectShaderTailComposite<'a> = (&'a RenderEffect, &'a RuntimeShader, (f32, f32, f32, f32));
766
767fn direct_shader_tail_composite(
768    effect: &RenderEffect,
769    alpha: f32,
770    blend_mode: BlendMode,
771    dest_viewport: Option<(f32, f32, f32, f32)>,
772    sample_mode: CompositeSampleMode,
773    source_size: (u32, u32),
774) -> Option<DirectShaderTailComposite<'_>> {
775    let viewport = direct_shader_composite_viewport(
776        alpha,
777        blend_mode,
778        dest_viewport,
779        sample_mode,
780        source_size,
781    )?;
782    let RenderEffect::Chain { first, second } = effect else {
783        return None;
784    };
785    let RenderEffect::Shader { shader } = second.as_ref() else {
786        return None;
787    };
788    Some((first.as_ref(), shader, viewport))
789}
790
791fn hash_f32_for_cache<H: Hasher>(value: f32, state: &mut H) {
792    value.to_bits().hash(state);
793}
794
795fn hash_text_raster_geometry_for_cache<H: Hasher>(
796    rect: Rect,
797    static_text_motion: bool,
798    state: &mut H,
799) {
800    hash_f32_for_cache(rect.width, state);
801    hash_f32_for_cache(rect.height, state);
802    static_text_motion.hash(state);
803    if !static_text_motion {
804        hash_f32_for_cache(rect.x.fract(), state);
805        hash_f32_for_cache(rect.y.fract(), state);
806    }
807}
808
809fn text_raster_geometry_for_draw(
810    text_draw: &TextDraw,
811    root_scale: f32,
812) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
813    if text_draw.text.is_empty()
814        || text_draw.rect.width <= 0.0
815        || text_draw.rect.height <= 0.0
816        || !root_scale.is_finite()
817        || root_scale <= 0.0
818    {
819        return None;
820    }
821
822    let text_scale = text_draw.scale * root_scale;
823    if !text_scale.is_finite() || text_scale <= 0.0 {
824        return None;
825    }
826
827    let static_text_motion = text_draw
828        .text_style
829        .paragraph_style
830        .text_motion
831        .unwrap_or(cranpose_ui::text::TextMotion::Static)
832        == cranpose_ui::text::TextMotion::Static;
833    let snap_delta = text_draw
834        .snap_anchor
835        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
836        .unwrap_or_default();
837    let logical_rect = text_draw.rect.translate(snap_delta.x, snap_delta.y);
838    // Clips are resolved in scene space from their own layer ancestry. A draw
839    // item's raster snap must never move a fixed ancestor clip.
840    let clip = text_draw.clip;
841    let mut raster_rect = Rect {
842        x: logical_rect.x * root_scale,
843        y: logical_rect.y * root_scale,
844        width: logical_rect.width * root_scale,
845        height: logical_rect.height * root_scale,
846    };
847    if text_draw.snap_anchor.is_some() {
848        raster_rect.x = canonicalize_device_coordinate(raster_rect.x);
849        raster_rect.y = canonicalize_device_coordinate(raster_rect.y);
850    }
851    if static_text_motion {
852        raster_rect.x = raster_rect.x.round();
853        raster_rect.y = raster_rect.y.round();
854    }
855    raster_rect.width = raster_rect.width.ceil().max(1.0);
856    raster_rect.height = raster_rect.height.ceil().max(1.0);
857    Some((
858        logical_rect,
859        raster_rect,
860        clip,
861        text_scale,
862        static_text_motion,
863    ))
864}
865
866fn text_draw_is_visible_in_viewport(
867    logical_rect: Rect,
868    clip: Option<Rect>,
869    viewport: ViewportUniformParams,
870    root_scale: f32,
871) -> bool {
872    draw_rect_is_visible_in_viewport(logical_rect, clip, viewport, root_scale)
873}
874
875fn text_draw_should_prewarm_in_viewport(
876    logical_rect: Rect,
877    clip: Option<Rect>,
878    viewport: ViewportUniformParams,
879    root_scale: f32,
880) -> bool {
881    if !root_scale.is_finite() || root_scale <= 0.0 {
882        return false;
883    }
884    let viewport_rect = Rect {
885        x: viewport.offset[0] / root_scale,
886        y: viewport.offset[1] / root_scale,
887        width: viewport.width as f32 / root_scale,
888        height: viewport.height as f32 / root_scale,
889    };
890    let margin_x = viewport_rect.width * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
891    let margin_y = viewport_rect.height * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
892    let prewarm_viewport = expand_rect(viewport_rect, margin_x, margin_y);
893    let prewarm_rect = match clip {
894        Some(clip) => expand_rect(clip, margin_x, margin_y).intersect(prewarm_viewport),
895        None => Some(prewarm_viewport),
896    };
897    prewarm_rect.is_some_and(|rect| logical_rect.intersect(rect).is_some())
898}
899
900fn expand_rect(rect: Rect, margin_x: f32, margin_y: f32) -> Rect {
901    Rect {
902        x: rect.x - margin_x,
903        y: rect.y - margin_y,
904        width: rect.width + margin_x * 2.0,
905        height: rect.height + margin_y * 2.0,
906    }
907}
908
909fn draw_rect_is_visible_in_viewport(
910    rect: Rect,
911    clip: Option<Rect>,
912    viewport: ViewportUniformParams,
913    root_scale: f32,
914) -> bool {
915    if !root_scale.is_finite() || root_scale <= 0.0 {
916        return false;
917    }
918    let viewport_rect = Rect {
919        x: viewport.offset[0] / root_scale,
920        y: viewport.offset[1] / root_scale,
921        width: viewport.width as f32 / root_scale,
922        height: viewport.height as f32 / root_scale,
923    };
924    let visible_rect = match clip {
925        Some(clip) => clip.intersect(viewport_rect),
926        None => Some(viewport_rect),
927    };
928    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
929}
930
931fn shape_draw_is_visible_in_viewport(
932    shape: &DrawShape,
933    viewport: ViewportUniformParams,
934    root_scale: f32,
935) -> bool {
936    let Some(viewport_rect) = viewport_rect_in_logical(viewport, root_scale) else {
937        return false;
938    };
939    shape_draw_is_visible_in_rect(shape, viewport_rect, root_scale)
940}
941
942/// The viewport in logical units, or `None` for a degenerate scale — the
943/// four divides are loop-invariant at every filter call site, so the hot
944/// paths derive this once per batch and test shapes against the result.
945fn viewport_rect_in_logical(viewport: ViewportUniformParams, root_scale: f32) -> Option<Rect> {
946    if !root_scale.is_finite() || root_scale <= 0.0 {
947        return None;
948    }
949    Some(Rect {
950        x: viewport.offset[0] / root_scale,
951        y: viewport.offset[1] / root_scale,
952        width: viewport.width as f32 / root_scale,
953        height: viewport.height as f32 / root_scale,
954    })
955}
956
957/// [`shape_draw_is_visible_in_viewport`] with the logical viewport rect
958/// already derived: identical decision, none of the per-shape divides.
959fn shape_draw_is_visible_in_rect(shape: &DrawShape, viewport_rect: Rect, root_scale: f32) -> bool {
960    let snap_delta = shape
961        .snap_anchor
962        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
963        .unwrap_or_default();
964    let rect = quad_bounds(translate_quad(shape.quad, snap_delta));
965    let visible_rect = match shape.clip {
966        Some(clip) => clip.intersect(viewport_rect),
967        None => Some(viewport_rect),
968    };
969    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
970}
971
972fn cached_text_glyph_quad(
973    glyph: &SoftwareGlyphAtlasPlacement,
974    entry: GlyphAtlasEntry,
975    atlas_size: u32,
976) -> CachedTextGlyphQuad {
977    CachedTextGlyphQuad {
978        x: glyph.x,
979        y: glyph.y,
980        width: glyph.width,
981        height: glyph.height,
982        color: (
983            glyph.color.0.clamp(0.0, 1.0),
984            glyph.color.1.clamp(0.0, 1.0),
985            glyph.color.2.clamp(0.0, 1.0),
986            glyph.color.3.clamp(0.0, 1.0),
987        ),
988        uv: glyph_atlas_uv_rect(entry, atlas_size),
989    }
990}
991
992fn append_cached_text_glyph_quad(
993    source_raster_rect: Rect,
994    quad: &CachedTextGlyphQuad,
995    image_vertices: &mut Vec<Vertex>,
996    image_indices: &mut Vec<u32>,
997) -> bool {
998    if quad.width == 0 || quad.height == 0 || quad.color.3 <= 0.0 {
999        return false;
1000    }
1001
1002    let base_vertex = image_vertices.len() as u32;
1003    image_indices.extend_from_slice(&[
1004        base_vertex,
1005        base_vertex + 1,
1006        base_vertex + 2,
1007        base_vertex + 2,
1008        base_vertex + 1,
1009        base_vertex + 3,
1010    ]);
1011
1012    let x0 = source_raster_rect.x + quad.x as f32;
1013    let y0 = source_raster_rect.y + quad.y as f32;
1014    let x1 = x0 + quad.width as f32;
1015    let y1 = y0 + quad.height as f32;
1016    let color = [quad.color.0, quad.color.1, quad.color.2, quad.color.3];
1017
1018    image_vertices.extend_from_slice(&[
1019        Vertex {
1020            position: [x0, y0],
1021            color,
1022            uv: [quad.uv.min[0], quad.uv.min[1]],
1023            uv_bounds: quad.uv.sample_bounds,
1024        },
1025        Vertex {
1026            position: [x1, y0],
1027            color,
1028            uv: [quad.uv.max[0], quad.uv.min[1]],
1029            uv_bounds: quad.uv.sample_bounds,
1030        },
1031        Vertex {
1032            position: [x0, y1],
1033            color,
1034            uv: [quad.uv.min[0], quad.uv.max[1]],
1035            uv_bounds: quad.uv.sample_bounds,
1036        },
1037        Vertex {
1038            position: [x1, y1],
1039            color,
1040            uv: [quad.uv.max[0], quad.uv.max[1]],
1041            uv_bounds: quad.uv.sample_bounds,
1042        },
1043    ]);
1044    true
1045}
1046
1047fn cached_text_glyph_quad_logical_rect(
1048    source_raster_rect: Rect,
1049    quad: &CachedTextGlyphQuad,
1050    root_scale: f32,
1051) -> Option<Rect> {
1052    if !root_scale.is_finite() || root_scale <= 0.0 {
1053        return None;
1054    }
1055    Some(Rect {
1056        x: (source_raster_rect.x + quad.x as f32) / root_scale,
1057        y: (source_raster_rect.y + quad.y as f32) / root_scale,
1058        width: quad.width as f32 / root_scale,
1059        height: quad.height as f32 / root_scale,
1060    })
1061}
1062
1063fn cached_text_glyph_quad_is_visible_in_viewport(
1064    source_raster_rect: Rect,
1065    quad: &CachedTextGlyphQuad,
1066    clip: Option<Rect>,
1067    viewport: ViewportUniformParams,
1068    root_scale: f32,
1069) -> bool {
1070    cached_text_glyph_quad_logical_rect(source_raster_rect, quad, root_scale)
1071        .is_some_and(|rect| draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale))
1072}
1073
1074#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1075enum TextGlyphDrawAction {
1076    DrawVisible,
1077    PrewarmOffscreen,
1078    Skip,
1079}
1080
1081fn text_glyph_draw_action(
1082    is_visible: bool,
1083    is_prewarm_candidate: bool,
1084    allow_offscreen_prewarm: bool,
1085) -> TextGlyphDrawAction {
1086    if is_visible {
1087        TextGlyphDrawAction::DrawVisible
1088    } else if allow_offscreen_prewarm && is_prewarm_candidate {
1089        TextGlyphDrawAction::PrewarmOffscreen
1090    } else {
1091        TextGlyphDrawAction::Skip
1092    }
1093}
1094
1095#[cfg(not(target_arch = "wasm32"))]
1096fn should_use_retained_text_glyph_run(quads_len: usize, clip: Option<Rect>) -> bool {
1097    clip.is_none() && quads_len >= MIN_RETAINED_TEXT_GLYPH_QUADS
1098}
1099
1100#[cfg(not(target_arch = "wasm32"))]
1101fn offscreen_text_glyph_prewarm_work_is_bounded(
1102    cached_glyphs: Option<usize>,
1103    text_len: usize,
1104) -> bool {
1105    match cached_glyphs {
1106        Some(glyphs) => glyphs <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS,
1107        None => text_len <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
1108    }
1109}
1110
1111#[cfg(not(target_arch = "wasm32"))]
1112fn offscreen_text_glyph_prewarm_budget_exhausted(
1113    start: Instant,
1114    admitted_candidates: usize,
1115) -> bool {
1116    admitted_candidates >= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES
1117        || instant_ms(start, Instant::now()) >= OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS
1118}
1119
1120fn text_draws_for_ordered_range<'a>(
1121    ordered_items: &'a [(usize, SegmentDrawItem)],
1122    texts: &'a [TextDraw],
1123    start: usize,
1124    end: usize,
1125) -> Result<impl Iterator<Item = &'a TextDraw>, String> {
1126    let range_items = ordered_items
1127        .get(start..end)
1128        .ok_or_else(|| format!("text batch range {start}..{end} is outside ordered draw items"))?;
1129    for (_, item) in range_items {
1130        match item {
1131            SegmentDrawItem::Text(text_index) if *text_index < texts.len() => {}
1132            SegmentDrawItem::Text(text_index) => {
1133                return Err(format!(
1134                    "text batch references missing text draw index: {text_index}"
1135                ));
1136            }
1137            _ => return Err(format!("text batch contains non-text draw item: {item:?}")),
1138        }
1139    }
1140
1141    Ok(range_items.iter().filter_map(move |(_, item)| match item {
1142        SegmentDrawItem::Text(text_index) => texts.get(*text_index),
1143        _ => None,
1144    }))
1145}
1146
1147/// Shadow geometry is hashed in device pixels quantized to 1/16 px so rigid
1148/// translations reuse the cached blurred raster. The cached surface is
1149/// composited one-to-one with texel-exact sampling; translation may not change
1150/// either the blur or its sampling phase.
1151const SHADOW_CACHE_DEVICE_QUANT: f32 = 16.0;
1152
1153fn hash_shadow_device_offset<H: Hasher>(value: f32, origin: f32, root_scale: f32, state: &mut H) {
1154    let quantized = ((value - origin) * root_scale * SHADOW_CACHE_DEVICE_QUANT).round();
1155    (quantized as i64).hash(state);
1156}
1157
1158fn hash_shadow_device_rect<H: Hasher>(
1159    rect: Rect,
1160    origin_x: f32,
1161    origin_y: f32,
1162    root_scale: f32,
1163    state: &mut H,
1164) {
1165    hash_shadow_device_offset(rect.x, origin_x, root_scale, state);
1166    hash_shadow_device_offset(rect.y, origin_y, root_scale, state);
1167    hash_shadow_device_offset(rect.width, 0.0, root_scale, state);
1168    hash_shadow_device_offset(rect.height, 0.0, root_scale, state);
1169}
1170
1171fn hash_shape_shadow_item<H: Hasher>(
1172    shape: &DrawShape,
1173    brushes: &[Brush],
1174    blend_mode: BlendMode,
1175    origin_x: f32,
1176    origin_y: f32,
1177    root_scale: f32,
1178    state: &mut H,
1179) {
1180    hash_shadow_device_rect(shape.rect, origin_x, origin_y, root_scale, state);
1181    hash_shadow_device_rect(shape.local_rect, origin_x, origin_y, root_scale, state);
1182    for point in shape.quad {
1183        hash_shadow_device_offset(point[0], origin_x, root_scale, state);
1184        hash_shadow_device_offset(point[1], origin_y, root_scale, state);
1185    }
1186    match shape.snap_anchor {
1187        Some(anchor) => {
1188            1u8.hash(state);
1189            hash_shadow_device_offset(anchor.origin.x, origin_x, root_scale, state);
1190            hash_shadow_device_offset(anchor.origin.y, origin_y, root_scale, state);
1191            hash_f32_for_cache(anchor.device_pixel_step, state);
1192        }
1193        None => 0u8.hash(state),
1194    }
1195    shape.brush.render_hash(brushes).hash(state);
1196    match shape.shape {
1197        Some(corner_shape) => {
1198            1u8.hash(state);
1199            corner_shape.radii().render_hash().hash(state);
1200        }
1201        None => 0u8.hash(state),
1202    }
1203    match shape.clip {
1204        Some(clip) => {
1205            1u8.hash(state);
1206            hash_shadow_device_rect(clip, origin_x, origin_y, root_scale, state);
1207        }
1208        None => 0u8.hash(state),
1209    }
1210    blend_mode.hash(state);
1211    shape.blend_mode.hash(state);
1212}
1213
1214fn shape_shadow_content_hash(
1215    shapes: &[(DrawShape, BlendMode)],
1216    brushes: &[Brush],
1217    root_scale: f32,
1218) -> u64 {
1219    let mut hasher = FxHasher::default();
1220    // Anchor the hash to the shapes' own (unfloored) bounds so rigid translation
1221    // cancels out exactly. Anchoring to floored device-pixel bounds would leak
1222    // the device subpixel phase into the hash and defeat the cache at
1223    // fractional display scales.
1224    let origin = shape_shadow_bounds(shapes).unwrap_or(Rect {
1225        x: 0.0,
1226        y: 0.0,
1227        width: 0.0,
1228        height: 0.0,
1229    });
1230
1231    shapes.len().hash(&mut hasher);
1232    for (shape, blend_mode) in shapes {
1233        hash_shape_shadow_item(
1234            shape,
1235            brushes,
1236            *blend_mode,
1237            origin.x,
1238            origin.y,
1239            root_scale,
1240            &mut hasher,
1241        );
1242    }
1243    hasher.finish()
1244}
1245
1246fn shape_shadow_surface_cache_key(
1247    shapes: &[(DrawShape, BlendMode)],
1248    brushes: &[Brush],
1249    device_bounds: DevicePixelBounds,
1250    pixel_radius: f32,
1251    root_scale: f32,
1252) -> Option<ShadowSurfaceCacheKey> {
1253    (root_scale.is_finite() && root_scale > 0.0).then(|| ShadowSurfaceCacheKey {
1254        content_hash: shape_shadow_content_hash(shapes, brushes, root_scale),
1255        pixel_size: [device_bounds.width, device_bounds.height],
1256        root_scale_bits: root_scale.to_bits(),
1257        blur_radius_bits: pixel_radius.to_bits(),
1258    })
1259}
1260
1261fn shape_shadow_bounds(shapes: &[(DrawShape, BlendMode)]) -> Option<Rect> {
1262    shapes
1263        .iter()
1264        .map(|(shape, _)| shape.rect)
1265        .reduce(|a, b| Rect {
1266            x: a.x.min(b.x),
1267            y: a.y.min(b.y),
1268            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1269            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1270        })
1271}
1272
1273fn shared_shape_shadow_snap_anchor(shapes: &[(DrawShape, BlendMode)]) -> Option<SnapAnchor> {
1274    let anchor = shapes.first()?.0.snap_anchor?;
1275    shapes
1276        .iter()
1277        .all(|(shape, _)| shape.snap_anchor == Some(anchor))
1278        .then_some(anchor)
1279}
1280
1281fn shadow_draw_bounds(shadow: &ShadowDraw) -> Option<Rect> {
1282    shadow
1283        .shapes
1284        .iter()
1285        .map(|(shape, _)| shape.rect)
1286        .chain(shadow.texts.iter().map(|text| text.rect))
1287        .reduce(|a, b| Rect {
1288            x: a.x.min(b.x),
1289            y: a.y.min(b.y),
1290            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1291            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1292        })
1293}
1294
1295fn shadow_draw_may_render(
1296    shadow: &ShadowDraw,
1297    width: u32,
1298    height: u32,
1299    root_scale: f32,
1300    max_texture_dim: u32,
1301) -> bool {
1302    if shadow.texts.is_empty() && !shadow.shapes.is_empty() && shadow.blur_radius > 0.0 {
1303        return shape_shadow_surface_plan(
1304            &shadow.shapes,
1305            shadow.clip,
1306            shadow.blur_radius,
1307            width,
1308            height,
1309            root_scale,
1310            max_texture_dim,
1311        )
1312        .is_some();
1313    }
1314
1315    let Some(bounds) = shadow_draw_bounds(shadow) else {
1316        return false;
1317    };
1318    let blur_margin = blur_extent_margin(shadow.blur_radius);
1319    let mut visible_bounds = Rect {
1320        x: bounds.x - blur_margin,
1321        y: bounds.y - blur_margin,
1322        width: bounds.width + blur_margin * 2.0,
1323        height: bounds.height + blur_margin * 2.0,
1324    };
1325    if let Some(clip) = shadow.clip {
1326        let clip_expanded = Rect {
1327            x: clip.x - blur_margin,
1328            y: clip.y - blur_margin,
1329            width: clip.width + blur_margin * 2.0,
1330            height: clip.height + blur_margin * 2.0,
1331        };
1332        let Some(intersection) = visible_bounds.intersect(clip_expanded) else {
1333            return false;
1334        };
1335        visible_bounds = intersection;
1336    }
1337
1338    scissor_rect_for_rect(visible_bounds, root_scale, width, height).is_some()
1339}
1340
1341fn shape_shadow_surface_plan(
1342    shapes: &[(DrawShape, BlendMode)],
1343    clip: Option<Rect>,
1344    blur_radius: f32,
1345    width: u32,
1346    height: u32,
1347    root_scale: f32,
1348    max_texture_dim: u32,
1349) -> Option<ShapeShadowSurfacePlan> {
1350    let shape_bounds = shape_shadow_bounds(shapes)?;
1351    let blur_margin = blur_extent_margin(blur_radius);
1352    let source_blur_bounds = Rect {
1353        x: shape_bounds.x - blur_margin,
1354        y: shape_bounds.y - blur_margin,
1355        width: shape_bounds.width + blur_margin * 2.0,
1356        height: shape_bounds.height + blur_margin * 2.0,
1357    };
1358
1359    let mut visible_blur_bounds = source_blur_bounds;
1360    if let Some(clip) = clip {
1361        let clip_expanded = Rect {
1362            x: clip.x - blur_margin,
1363            y: clip.y - blur_margin,
1364            width: clip.width + blur_margin * 2.0,
1365            height: clip.height + blur_margin * 2.0,
1366        };
1367        visible_blur_bounds = visible_blur_bounds.intersect(clip_expanded)?;
1368    }
1369
1370    let processing_scissor = scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
1371    processing_scissor?;
1372    let visible_device_bounds =
1373        device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)?;
1374    let source_device_bounds = translation_stable_anchored_device_pixel_bounds(
1375        source_blur_bounds,
1376        shared_shape_shadow_snap_anchor(shapes),
1377        root_scale,
1378        max_texture_dim,
1379    )
1380    .unwrap_or(visible_device_bounds);
1381
1382    Some(ShapeShadowSurfacePlan {
1383        source_device_bounds,
1384        processing_scissor,
1385        pixel_radius: blur_radius * root_scale,
1386    })
1387}
1388
1389fn is_render_effect_supported(effect: &RenderEffect) -> bool {
1390    match effect {
1391        RenderEffect::Blur { .. } => true,
1392        RenderEffect::Offset { .. } => true,
1393        RenderEffect::Shader { .. } => true,
1394        RenderEffect::Chain { first, second } => {
1395            is_render_effect_supported(first) && is_render_effect_supported(second)
1396        }
1397    }
1398}
1399
1400fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
1401    if value.is_finite() {
1402        origin + value
1403    } else if value.is_sign_positive() {
1404        origin + extent
1405    } else {
1406        origin
1407    }
1408}
1409
1410fn gradient_tile_mode_value(tile_mode: TileMode) -> u32 {
1411    match tile_mode {
1412        TileMode::Clamp => 0,
1413        TileMode::Repeated => 1,
1414        TileMode::Mirror => 2,
1415        TileMode::Decal => 3,
1416    }
1417}
1418
1419/// The base text the shape rewrites below start from: `shape.wgsl` alone, or
1420/// — under `CRANPOSE_SOLID_TRIM_VARYINGS` — `shape.wgsl` with the trimmed
1421/// solid entries appended. Appending happens BEFORE the storage/array
1422/// rewrites so the paint-select injection and the batch-limit resizes land
1423/// in the trimmed entries exactly as they land in `vs_main` (the
1424/// substitution tests pin five landings); with the trim off the text is the
1425/// borrowed shipping constant, byte-identical to what always compiled.
1426fn shape_shader_base(solid_trim: bool) -> Cow<'static, str> {
1427    if solid_trim {
1428        return Cow::Owned(format!(
1429            "{}\n{}",
1430            shaders::SHADER,
1431            shaders::SOLID_TRIM_APPENDIX
1432        ));
1433    }
1434    Cow::Borrowed(shaders::SHADER)
1435}
1436
1437#[cfg(not(target_arch = "wasm32"))]
1438fn shape_shader_source(batch_limits: ShapeBatchLimits, solid_trim: bool) -> Cow<'static, str> {
1439    let base = shape_shader_base(solid_trim);
1440    // These literals must stay in sync with `shape.wgsl`; a mismatch makes
1441    // the substitution silently no-op and leaves the shader sized for the
1442    // downlevel floor.
1443    if batch_limits.storage {
1444        return Cow::Owned(
1445            base.replace(
1446                "var<uniform> shape_data: array<ShapeData, 102>;",
1447                "var<storage, read> shape_data: array<ShapeData>;",
1448            )
1449            .replace(
1450                "var<uniform> gradient_stops: array<GradientStop, 256>;",
1451                // Also inject the retained-paint array here: one mutable
1452                // color per shape, read when `similarity.paint_select`
1453                // is set, so recolor patches upload 16-byte colors
1454                // instead of whole ShapeData records. The base text
1455                // never declares it — uniform-mode devices cannot bind
1456                // storage and never host retained slots.
1457                "var<storage, read> gradient_stops: array<GradientStop>;\n\n\
1458                     @group(1) @binding(3)\n\
1459                     var<storage, read> paint: array<vec4<f32>>;",
1460            )
1461            .replace(
1462                "output.color = shape.color;",
1463                "output.color = \
1464                     select(shape.color, paint[shape_idx], similarity.paint_select > 0.5);",
1465            ),
1466        );
1467    }
1468    Cow::Owned(
1469        base.replace(
1470            "array<ShapeData, 102>",
1471            &format!("array<ShapeData, {}>", batch_limits.max_shapes_per_batch),
1472        )
1473        .replace(
1474            "array<GradientStop, 256>",
1475            &format!("array<GradientStop, {}>", batch_limits.max_gradient_stops),
1476        ),
1477    )
1478}
1479
1480#[cfg(target_arch = "wasm32")]
1481fn shape_shader_source(_batch_limits: ShapeBatchLimits, solid_trim: bool) -> Cow<'static, str> {
1482    // wasm keeps the downlevel array lengths verbatim. The trim flag is
1483    // env-driven and a browser has no environment to set it in, but the arm
1484    // stays honest for any embedder that reaches it.
1485    shape_shader_base(solid_trim)
1486}
1487
1488/// Runs one `create_render_pipeline` call under a timer and logs the result.
1489/// First-use creation happens on the render thread behind `get_or_init`,
1490/// where a driver backend compile is whole missed frames on slow devices;
1491/// the tag names the permutation so a stalled launch names its pipelines.
1492pub(crate) fn create_render_pipeline_logged<'a>(
1493    device: &wgpu::Device,
1494    cache: Option<&'a wgpu::PipelineCache>,
1495    tag: &str,
1496    mut descriptor: wgpu::RenderPipelineDescriptor<'a>,
1497) -> wgpu::RenderPipeline {
1498    descriptor.cache = cache;
1499    let started = Instant::now();
1500    let pipeline = device.create_render_pipeline(&descriptor);
1501    log::info!(
1502        "[pipeline-create] {tag} {:.1}ms",
1503        instant_ms(started, Instant::now())
1504    );
1505    pipeline
1506}
1507
1508/// `CRANPOSE_PIPELINE_PREWARM=0` (property `debug.cranpose.pipeline_prewarm`)
1509/// keeps first-use creation as the only compile path.
1510#[cfg(not(target_arch = "wasm32"))]
1511fn pipeline_prewarm_enabled() -> bool {
1512    std::env::var("CRANPOSE_PIPELINE_PREWARM").as_deref() != Ok("0")
1513}
1514
1515#[cfg(not(target_arch = "wasm32"))]
1516struct PipelinePrewarmInputs {
1517    device: Arc<wgpu::Device>,
1518    cache: Option<wgpu::PipelineCache>,
1519    surface_format: wgpu::TextureFormat,
1520    uniform_layout: wgpu::BindGroupLayout,
1521    shape_layout: wgpu::BindGroupLayout,
1522    image_layout: wgpu::BindGroupLayout,
1523    batch_limits: ShapeBatchLimits,
1524    instanced: bool,
1525}
1526
1527/// Builds the pipelines a first frame reaches for — off the render thread,
1528/// concurrent with app startup — and drops them. The point is the shared
1529/// device pipeline cache: the render thread's own `get_or_init` creates then
1530/// find the driver's compiled code instead of paying for it mid-frame
1531/// (measured on a Pixel Watch 3: 661 + 496 + 552 ms for the three shape
1532/// pipelines alone, each one swallowed frame). The set is the framework's
1533/// own base family with the flags the accessors would latch — same inputs,
1534/// same permutations, so the cache keys match. Spawned only when the device
1535/// has a pipeline cache; without one, warming another thread's `wgpu`
1536/// objects would leave nothing behind for the render thread to find.
1537#[cfg(not(target_arch = "wasm32"))]
1538fn spawn_pipeline_prewarm(inputs: PipelinePrewarmInputs) {
1539    if !pipeline_prewarm_enabled() {
1540        return;
1541    }
1542    let spawned = std::thread::Builder::new()
1543        .name("cranpose-pl-warm".into())
1544        .spawn(move || {
1545            let started = Instant::now();
1546            let cache = inputs.cache.as_ref();
1547            let device = &inputs.device;
1548            let solid_trim = solid_trim_varyings_enabled();
1549            let mut built = 0_u32;
1550            if inputs.instanced {
1551                let (vertex_entry, fragment_entry) = if solid_trim {
1552                    ("vs_solid_instanced", "fs_solid_trim")
1553                } else {
1554                    ("vs_shape_instanced", "fs_solid")
1555                };
1556                drop(create_instanced_shape_pipeline(
1557                    device,
1558                    cache,
1559                    inputs.surface_format,
1560                    &inputs.uniform_layout,
1561                    &inputs.shape_layout,
1562                    BlendMode::SrcOver,
1563                    inputs.batch_limits,
1564                    solid_trim,
1565                    vertex_entry,
1566                    fragment_entry,
1567                    false,
1568                ));
1569                drop(create_instanced_shape_pipeline(
1570                    device,
1571                    cache,
1572                    inputs.surface_format,
1573                    &inputs.uniform_layout,
1574                    &inputs.shape_layout,
1575                    BlendMode::SrcOver,
1576                    inputs.batch_limits,
1577                    false,
1578                    "vs_shape_instanced",
1579                    "fs_main",
1580                    false,
1581                ));
1582            } else {
1583                let (vertex_entry, fragment_entry) = if solid_trim {
1584                    ("vs_solid", "fs_solid_trim")
1585                } else {
1586                    ("vs_main", "fs_solid")
1587                };
1588                drop(create_shape_pipeline(
1589                    device,
1590                    cache,
1591                    inputs.surface_format,
1592                    &inputs.uniform_layout,
1593                    &inputs.shape_layout,
1594                    BlendMode::SrcOver,
1595                    inputs.batch_limits,
1596                    solid_trim,
1597                    vertex_entry,
1598                    fragment_entry,
1599                    false,
1600                ));
1601                drop(create_shape_pipeline(
1602                    device,
1603                    cache,
1604                    inputs.surface_format,
1605                    &inputs.uniform_layout,
1606                    &inputs.shape_layout,
1607                    BlendMode::SrcOver,
1608                    inputs.batch_limits,
1609                    false,
1610                    "vs_main",
1611                    "fs_main",
1612                    false,
1613                ));
1614            }
1615            built += 2;
1616            if inputs.batch_limits.storage {
1617                drop(create_mesh_shape_pipeline(
1618                    device,
1619                    cache,
1620                    inputs.surface_format,
1621                    &inputs.uniform_layout,
1622                    &inputs.shape_layout,
1623                    inputs.batch_limits,
1624                    false,
1625                ));
1626                built += 1;
1627            }
1628            drop(create_glyph_atlas_pipeline(
1629                device,
1630                cache,
1631                inputs.surface_format,
1632                &inputs.uniform_layout,
1633                &inputs.image_layout,
1634                false,
1635            ));
1636            built += 1;
1637            log::info!(
1638                "[pipeline-prewarm] {built} pipelines in {:.1} ms",
1639                instant_ms(started, Instant::now())
1640            );
1641        });
1642    if let Err(error) = spawned {
1643        log::warn!("[pipeline-prewarm] thread failed to spawn: {error}");
1644    }
1645}
1646
1647#[allow(clippy::too_many_arguments)]
1648fn create_shape_pipeline(
1649    device: &wgpu::Device,
1650    cache: Option<&wgpu::PipelineCache>,
1651    surface_format: wgpu::TextureFormat,
1652    uniform_layout: &wgpu::BindGroupLayout,
1653    shape_layout: &wgpu::BindGroupLayout,
1654    blend_mode: BlendMode,
1655    batch_limits: ShapeBatchLimits,
1656    solid_trim: bool,
1657    vertex_entry: &'static str,
1658    fragment_entry: &'static str,
1659    depth: bool,
1660) -> wgpu::RenderPipeline {
1661    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1662        label: Some("Shape Shader"),
1663        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1664            shape_shader_source(batch_limits, solid_trim),
1665            depth,
1666        )),
1667    });
1668
1669    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1670        label: Some("Render Pipeline Layout"),
1671        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1672        immediate_size: 0,
1673    });
1674
1675    create_render_pipeline_logged(
1676        device,
1677        cache,
1678        &format!("shape entry={fragment_entry} blend={blend_mode:?} depth={depth}"),
1679        wgpu::RenderPipelineDescriptor {
1680            label: Some("Render Pipeline"),
1681            layout: Some(&pipeline_layout),
1682            vertex: wgpu::VertexState {
1683                module: &shader,
1684                entry_point: Some(vertex_entry),
1685                compilation_options: wgpu::PipelineCompilationOptions::default(),
1686                // No vertex buffer: `vs_main` (and its trimmed twin `vs_solid`)
1687                // pulls quad corners from ShapeData by `vertex_index`.
1688                buffers: &[],
1689            },
1690            fragment: Some(wgpu::FragmentState {
1691                module: &shader,
1692                entry_point: Some(fragment_entry),
1693                compilation_options: wgpu::PipelineCompilationOptions::default(),
1694                targets: &[Some(wgpu::ColorTargetState {
1695                    format: surface_format,
1696                    blend: Some(blend_state_for_mode(blend_mode)),
1697                    write_mask: wgpu::ColorWrites::ALL,
1698                })],
1699            }),
1700            primitive: wgpu::PrimitiveState {
1701                topology: wgpu::PrimitiveTopology::TriangleList,
1702                strip_index_format: None,
1703                front_face: wgpu::FrontFace::Ccw,
1704                cull_mode: None,
1705                unclipped_depth: false,
1706                polygon_mode: wgpu::PolygonMode::Fill,
1707                conservative: false,
1708            },
1709            depth_stencil: display_clip::content_depth_state(depth),
1710            multisample: wgpu::MultisampleState::default(),
1711            multiview_mask: None,
1712            cache: None,
1713        },
1714    )
1715}
1716
1717/// Storage-mode pipeline for retained slots that captured a conservative arc
1718/// mesh: `vs_mesh` consumes `{position, uv, shape_idx}` vertices instead of
1719/// expanding six corners per shape. Fragment stage, bind group layouts
1720/// (including the dynamic-offset similarity binding and the retained paint
1721/// binding) and the SrcOver blend are exactly the ones the quad-expansion retained
1722/// path uses — only the vertex fetch differs.
1723#[cfg(not(target_arch = "wasm32"))]
1724fn create_mesh_shape_pipeline(
1725    device: &wgpu::Device,
1726    cache: Option<&wgpu::PipelineCache>,
1727    surface_format: wgpu::TextureFormat,
1728    uniform_layout: &wgpu::BindGroupLayout,
1729    shape_layout: &wgpu::BindGroupLayout,
1730    batch_limits: ShapeBatchLimits,
1731    depth: bool,
1732) -> wgpu::RenderPipeline {
1733    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1734        label: Some("Shape Mesh Shader"),
1735        // Mesh slots may carry gradients, so this family always compiles the
1736        // full interface — the trimmed entries never pair with `vs_mesh`.
1737        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1738            shape_shader_source(batch_limits, false),
1739            depth,
1740        )),
1741    });
1742
1743    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1744        label: Some("Mesh Render Pipeline Layout"),
1745        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1746        immediate_size: 0,
1747    });
1748
1749    create_render_pipeline_logged(
1750        device,
1751        cache,
1752        &format!("mesh depth={depth}"),
1753        wgpu::RenderPipelineDescriptor {
1754            label: Some("Retained Mesh Pipeline"),
1755            layout: Some(&pipeline_layout),
1756            vertex: wgpu::VertexState {
1757                module: &shader,
1758                entry_point: Some("vs_mesh"),
1759                compilation_options: wgpu::PipelineCompilationOptions::default(),
1760                buffers: &[MeshVertex::desc()],
1761            },
1762            fragment: Some(wgpu::FragmentState {
1763                module: &shader,
1764                entry_point: Some("fs_main"),
1765                compilation_options: wgpu::PipelineCompilationOptions::default(),
1766                targets: &[Some(wgpu::ColorTargetState {
1767                    format: surface_format,
1768                    blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1769                    write_mask: wgpu::ColorWrites::ALL,
1770                })],
1771            }),
1772            primitive: wgpu::PrimitiveState {
1773                topology: wgpu::PrimitiveTopology::TriangleList,
1774                strip_index_format: None,
1775                front_face: wgpu::FrontFace::Ccw,
1776                cull_mode: None,
1777                unclipped_depth: false,
1778                polygon_mode: wgpu::PolygonMode::Fill,
1779                conservative: false,
1780            },
1781            depth_stencil: display_clip::content_depth_state(depth),
1782            multisample: wgpu::MultisampleState::default(),
1783            multiview_mask: None,
1784            cache: None,
1785        },
1786    )
1787}
1788
1789/// Storage-mode pipeline for ordinary shape batches drawn as instanced
1790/// indexed quads (`vs_shape_instanced`): four vertex executions per shape
1791/// through the static `[0, 1, 2, 2, 1, 3]` index buffer instead of six
1792/// unindexed corner expansions. Everything but the vertex entry point is
1793/// exactly `create_shape_pipeline` — same fragment stage, same layouts,
1794/// same blend per mode — so a draw-time fallback to `vs_main` (the
1795/// `CRANPOSE_INSTANCED_QUADS=0` kill switch) changes nothing else.
1796#[cfg(not(target_arch = "wasm32"))]
1797#[allow(clippy::too_many_arguments)]
1798fn create_instanced_shape_pipeline(
1799    device: &wgpu::Device,
1800    cache: Option<&wgpu::PipelineCache>,
1801    surface_format: wgpu::TextureFormat,
1802    uniform_layout: &wgpu::BindGroupLayout,
1803    shape_layout: &wgpu::BindGroupLayout,
1804    blend_mode: BlendMode,
1805    batch_limits: ShapeBatchLimits,
1806    solid_trim: bool,
1807    vertex_entry: &'static str,
1808    fragment_entry: &'static str,
1809    depth: bool,
1810) -> wgpu::RenderPipeline {
1811    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1812        label: Some("Shape Instanced Shader"),
1813        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1814            shape_shader_source(batch_limits, solid_trim),
1815            depth,
1816        )),
1817    });
1818
1819    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1820        label: Some("Instanced Render Pipeline Layout"),
1821        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1822        immediate_size: 0,
1823    });
1824
1825    create_render_pipeline_logged(
1826        device,
1827        cache,
1828        &format!("instanced entry={fragment_entry} blend={blend_mode:?} depth={depth}"),
1829        wgpu::RenderPipelineDescriptor {
1830            label: Some("Instanced Render Pipeline"),
1831            layout: Some(&pipeline_layout),
1832            vertex: wgpu::VertexState {
1833                module: &shader,
1834                entry_point: Some(vertex_entry),
1835                compilation_options: wgpu::PipelineCompilationOptions::default(),
1836                // No vertex buffer: like `vs_main`, the corners come from
1837                // ShapeData; only the shape index source differs
1838                // (`instance_index` instead of `vertex_index / 6`).
1839                buffers: &[],
1840            },
1841            fragment: Some(wgpu::FragmentState {
1842                module: &shader,
1843                entry_point: Some(fragment_entry),
1844                compilation_options: wgpu::PipelineCompilationOptions::default(),
1845                targets: &[Some(wgpu::ColorTargetState {
1846                    format: surface_format,
1847                    blend: Some(blend_state_for_mode(blend_mode)),
1848                    write_mask: wgpu::ColorWrites::ALL,
1849                })],
1850            }),
1851            primitive: wgpu::PrimitiveState {
1852                topology: wgpu::PrimitiveTopology::TriangleList,
1853                strip_index_format: None,
1854                front_face: wgpu::FrontFace::Ccw,
1855                cull_mode: None,
1856                unclipped_depth: false,
1857                polygon_mode: wgpu::PolygonMode::Fill,
1858                conservative: false,
1859            },
1860            depth_stencil: display_clip::content_depth_state(depth),
1861            multisample: wgpu::MultisampleState::default(),
1862            multiview_mask: None,
1863            cache: None,
1864        },
1865    )
1866}
1867
1868fn create_image_pipeline(
1869    device: &wgpu::Device,
1870    cache: Option<&wgpu::PipelineCache>,
1871    surface_format: wgpu::TextureFormat,
1872    uniform_layout: &wgpu::BindGroupLayout,
1873    image_layout: &wgpu::BindGroupLayout,
1874    blend_mode: BlendMode,
1875    depth: bool,
1876) -> wgpu::RenderPipeline {
1877    let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1878        label: Some("Image Shader"),
1879        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1880            shaders::IMAGE_SHADER.into(),
1881            depth,
1882        )),
1883    });
1884
1885    let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1886        label: Some("Image Pipeline Layout"),
1887        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1888        immediate_size: 0,
1889    });
1890
1891    create_render_pipeline_logged(
1892        device,
1893        cache,
1894        &format!("image blend={blend_mode:?} depth={depth}"),
1895        wgpu::RenderPipelineDescriptor {
1896            label: Some("Image Pipeline"),
1897            layout: Some(&image_pipeline_layout),
1898            vertex: wgpu::VertexState {
1899                module: &image_shader,
1900                entry_point: Some("image_vs_main"),
1901                compilation_options: wgpu::PipelineCompilationOptions::default(),
1902                buffers: &[Vertex::desc()],
1903            },
1904            fragment: Some(wgpu::FragmentState {
1905                module: &image_shader,
1906                entry_point: Some("image_fs_main"),
1907                compilation_options: wgpu::PipelineCompilationOptions::default(),
1908                targets: &[Some(wgpu::ColorTargetState {
1909                    format: surface_format,
1910                    blend: Some(blend_state_for_mode(blend_mode)),
1911                    write_mask: wgpu::ColorWrites::ALL,
1912                })],
1913            }),
1914            primitive: wgpu::PrimitiveState {
1915                topology: wgpu::PrimitiveTopology::TriangleList,
1916                strip_index_format: None,
1917                front_face: wgpu::FrontFace::Ccw,
1918                cull_mode: None,
1919                unclipped_depth: false,
1920                polygon_mode: wgpu::PolygonMode::Fill,
1921                conservative: false,
1922            },
1923            depth_stencil: display_clip::content_depth_state(depth),
1924            multisample: wgpu::MultisampleState::default(),
1925            multiview_mask: None,
1926            cache: None,
1927        },
1928    )
1929}
1930
1931fn create_glyph_atlas_pipeline(
1932    device: &wgpu::Device,
1933    cache: Option<&wgpu::PipelineCache>,
1934    surface_format: wgpu::TextureFormat,
1935    uniform_layout: &wgpu::BindGroupLayout,
1936    image_layout: &wgpu::BindGroupLayout,
1937    depth: bool,
1938) -> wgpu::RenderPipeline {
1939    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1940        label: Some("Glyph Atlas Shader"),
1941        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1942            shaders::GLYPH_ATLAS_SHADER.into(),
1943            depth,
1944        )),
1945    });
1946
1947    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1948        label: Some("Glyph Atlas Pipeline Layout"),
1949        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1950        immediate_size: 0,
1951    });
1952
1953    create_render_pipeline_logged(
1954        device,
1955        cache,
1956        &format!("glyph-atlas depth={depth}"),
1957        wgpu::RenderPipelineDescriptor {
1958            label: Some("Glyph Atlas Pipeline"),
1959            layout: Some(&pipeline_layout),
1960            vertex: wgpu::VertexState {
1961                module: &shader,
1962                entry_point: Some("glyph_atlas_vs_main"),
1963                compilation_options: wgpu::PipelineCompilationOptions::default(),
1964                buffers: &[Vertex::desc()],
1965            },
1966            fragment: Some(wgpu::FragmentState {
1967                module: &shader,
1968                entry_point: Some("glyph_atlas_fs_main"),
1969                compilation_options: wgpu::PipelineCompilationOptions::default(),
1970                targets: &[Some(wgpu::ColorTargetState {
1971                    format: surface_format,
1972                    blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1973                    write_mask: wgpu::ColorWrites::ALL,
1974                })],
1975            }),
1976            primitive: wgpu::PrimitiveState {
1977                topology: wgpu::PrimitiveTopology::TriangleList,
1978                strip_index_format: None,
1979                front_face: wgpu::FrontFace::Ccw,
1980                cull_mode: None,
1981                unclipped_depth: false,
1982                polygon_mode: wgpu::PolygonMode::Fill,
1983                conservative: false,
1984            },
1985            depth_stencil: display_clip::content_depth_state(depth),
1986            multisample: wgpu::MultisampleState::default(),
1987            multiview_mask: None,
1988            cache: None,
1989        },
1990    )
1991}
1992
1993/// Pipeline for the display-clip occluder — the tessellated complement
1994/// of the visible region — the first draw of a culled
1995/// fused pass: depth write ON at the near plane, color writes fully masked
1996/// off, trivial fragment stage with no discard — exactly the shape early-Z
1997/// and LRZ hardware accepts as an occluder. The color target must still be
1998/// declared (the pass has a color attachment), which is what the empty
1999/// write mask is for.
2000#[cfg(not(target_arch = "wasm32"))]
2001fn create_display_clip_occluder_pipeline(
2002    device: &wgpu::Device,
2003    cache: Option<&wgpu::PipelineCache>,
2004    surface_format: wgpu::TextureFormat,
2005) -> wgpu::RenderPipeline {
2006    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2007        label: Some("Display Clip Occluder Shader"),
2008        source: wgpu::ShaderSource::Wgsl(display_clip::OCCLUDER_SHADER.into()),
2009    });
2010    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2011        label: Some("Display Clip Occluder Pipeline Layout"),
2012        bind_group_layouts: &[],
2013        immediate_size: 0,
2014    });
2015    create_render_pipeline_logged(
2016        device,
2017        cache,
2018        "occluder",
2019        wgpu::RenderPipelineDescriptor {
2020            label: Some("Display Clip Occluder Pipeline"),
2021            layout: Some(&pipeline_layout),
2022            vertex: wgpu::VertexState {
2023                module: &shader,
2024                entry_point: Some("mask_vs"),
2025                compilation_options: wgpu::PipelineCompilationOptions::default(),
2026                buffers: &[wgpu::VertexBufferLayout {
2027                    array_stride: (std::mem::size_of::<[f32; 2]>()) as wgpu::BufferAddress,
2028                    step_mode: wgpu::VertexStepMode::Vertex,
2029                    attributes: &[wgpu::VertexAttribute {
2030                        offset: 0,
2031                        shader_location: 0,
2032                        format: wgpu::VertexFormat::Float32x2,
2033                    }],
2034                }],
2035            },
2036            fragment: Some(wgpu::FragmentState {
2037                module: &shader,
2038                entry_point: Some("mask_fs"),
2039                compilation_options: wgpu::PipelineCompilationOptions::default(),
2040                targets: &[Some(wgpu::ColorTargetState {
2041                    format: surface_format,
2042                    blend: None,
2043                    write_mask: wgpu::ColorWrites::empty(),
2044                })],
2045            }),
2046            primitive: wgpu::PrimitiveState {
2047                topology: wgpu::PrimitiveTopology::TriangleList,
2048                strip_index_format: None,
2049                front_face: wgpu::FrontFace::Ccw,
2050                cull_mode: None,
2051                unclipped_depth: false,
2052                polygon_mode: wgpu::PolygonMode::Fill,
2053                conservative: false,
2054            },
2055            depth_stencil: Some(wgpu::DepthStencilState {
2056                format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
2057                depth_write_enabled: Some(true),
2058                depth_compare: Some(wgpu::CompareFunction::Always),
2059                stencil: wgpu::StencilState::default(),
2060                bias: wgpu::DepthBiasState::default(),
2061            }),
2062            multisample: wgpu::MultisampleState::default(),
2063            multiview_mask: None,
2064            cache: None,
2065        },
2066    )
2067}
2068
2069#[repr(C)]
2070#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2071struct Vertex {
2072    position: [f32; 2],
2073    color: [f32; 4],
2074    uv: [f32; 2],
2075    uv_bounds: [f32; 4],
2076}
2077
2078impl Vertex {
2079    const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
2080        0 => Float32x2,
2081        1 => Float32x4,
2082        2 => Float32x2,
2083        3 => Float32x4
2084    ];
2085
2086    fn desc() -> wgpu::VertexBufferLayout<'static> {
2087        wgpu::VertexBufferLayout {
2088            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
2089            step_mode: wgpu::VertexStepMode::Vertex,
2090            attributes: &Self::ATTRIBS,
2091        }
2092    }
2093}
2094
2095#[repr(C)]
2096#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2097struct Uniforms {
2098    viewport: [f32; 2],
2099    viewport_offset: [f32; 2],
2100}
2101
2102/// Mirror of `struct ShapeData` in `shape.wgsl`. Field order and sizes must
2103/// match exactly: 10 x 16 bytes = 160 bytes, every member 16-byte aligned as
2104/// the uniform address space requires. The quad corners and vertex color ride
2105/// in here because the shape pipeline has no vertex buffer: the vertex shader
2106/// pulls all six corners of a shape straight from this struct.
2107#[repr(C)]
2108#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2109struct ShapeData {
2110    rect: [f32; 4], // x, y, width, height
2111    /// Rects: top_left, top_right, bottom_left, bottom_right corner radii.
2112    /// Arcs: (sin, cos) of the mid angle and of the half sweep — the shader's
2113    /// per-shape trig, precomputed so `sdf_arc_band` needs none per fragment.
2114    radii: [f32; 4],
2115    gradient_params: [f32; 4], // linear: start.xy,end.xy; radial: center.xy,radius,unused
2116    clip_rect: [f32; 4],       // clip_x, clip_y, clip_width, clip_height (0,0,0,0 = no clip)
2117    /// stroke width, packed flags (see [`pack_shape_flags`]), arc outer radius,
2118    /// arc inner radius. All zero for a plain fill.
2119    stroke_params: [f32; 4],
2120    /// arc center.xy, start angle, sweep angle (radians, 0 = +X, clockwise).
2121    arc_params: [f32; 4],
2122    /// Device-space quad corners 0 (xy) and 1 (zw).
2123    quad01: [f32; 4],
2124    /// Device-space quad corners 2 (xy) and 3 (zw).
2125    quad23: [f32; 4],
2126    /// Vertex color: the solid brush color, or the first gradient stop.
2127    color: [f32; 4],
2128    brush_type: u32,         // 0=solid, 1=linear_gradient, 2=radial_gradient
2129    gradient_start: u32,     // Starting index in gradient buffer
2130    gradient_count: u32,     // Number of gradient stops
2131    gradient_tile_mode: u32, // 0=Clamp, 1=Repeated, 2=Mirror, 3=Decal
2132}
2133
2134/// Shape kinds understood by `shape.wgsl`.
2135const SHAPE_KIND_FILL: u32 = 0;
2136const SHAPE_KIND_STROKE: u32 = 1;
2137const SHAPE_KIND_ARC: u32 = 2;
2138
2139fn stroke_cap_code(cap: StrokeCap) -> u32 {
2140    match cap {
2141        StrokeCap::Butt => 0,
2142        StrokeCap::Round => 1,
2143        StrokeCap::Square => 2,
2144    }
2145}
2146
2147fn stroke_join_code(join: StrokeJoin) -> u32 {
2148    match join {
2149        StrokeJoin::Miter => 0,
2150        StrokeJoin::Round => 1,
2151        StrokeJoin::Bevel => 2,
2152    }
2153}
2154
2155/// Packs kind/cap/join into the single float `ShapeData::stroke_params[1]`.
2156///
2157/// Three 2-bit fields fit in one f32 exactly (integers below 2^24 are exact),
2158/// which keeps `ShapeData` a slot smaller than it would be if each field got
2159/// its own float — batch capacity is set by this size on uniform backends.
2160fn pack_shape_flags(kind: u32, cap: StrokeCap, join: StrokeJoin) -> f32 {
2161    ((kind & 3) | (stroke_cap_code(cap) << 2) | (stroke_join_code(join) << 4)) as f32
2162}
2163
2164/// Whether a batch conversion fans out is decided by measurement — see
2165/// [`crate::cost_tuner::CostTuner`]. The floor of 256 matters: a device
2166/// whose uniform binding caps batches at ~409 shapes never crossed the old
2167/// fixed threshold of 512, so conversion ran serial on exactly the class of
2168/// hardware (watch-grade in-order cores) where fanning out pays most. The
2169/// 400 µs cheap floor keeps a big phone core, which clears such a batch in
2170/// well under that, from ever paying for a spawn wave.
2171#[cfg(not(target_arch = "wasm32"))]
2172static SHAPE_CONVERT_TUNER: crate::cost_tuner::CostTuner =
2173    crate::cost_tuner::CostTuner::new("shape-convert", 256, 400_000);
2174
2175#[cfg(not(target_arch = "wasm32"))]
2176pub(crate) fn shape_convert_worker_count() -> usize {
2177    static WORKERS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
2178    *WORKERS.get_or_init(|| {
2179        let cpus = std::thread::available_parallelism()
2180            .map(|count| count.get())
2181            .unwrap_or(1);
2182        let workers = cpus.clamp(1, 4);
2183        // One line per process: on devices whose scheduler confines the
2184        // process (affinity masks, cpusets), this is the number that
2185        // explains why fan-out stages stayed serial.
2186        log::info!("[shape-convert] fan-out width {workers} (available parallelism {cpus})");
2187        workers
2188    })
2189}
2190
2191#[cfg(target_arch = "wasm32")]
2192pub(crate) fn shape_convert_worker_count() -> usize {
2193    1
2194}
2195
2196fn shape_gradient_stop_count(shape: &DrawShape, brushes: &[Brush]) -> usize {
2197    match shape.brush {
2198        SceneBrush::Solid(_) => 0,
2199        SceneBrush::Gradient(index) => match &brushes[index as usize] {
2200            Brush::Solid(_) => 0,
2201            Brush::LinearGradient { colors, .. }
2202            | Brush::RadialGradient { colors, .. }
2203            | Brush::SweepGradient { colors, .. } => colors.len(),
2204        },
2205    }
2206}
2207
2208/// Converts one [`DrawShape`] into its GPU representation, writing into
2209/// pre-sized slots so a batch can convert in parallel across disjoint
2210/// sub-slices. `gradient_start` is the shape's global offset into the batch
2211/// gradient buffer; `gradient_out` is exactly its span of that buffer.
2212fn convert_shape_into_slots(
2213    shape: &DrawShape,
2214    brushes: &[Brush],
2215    root_scale: f32,
2216    gradient_start: u32,
2217    shape_out: &mut ShapeData,
2218    gradient_out: &mut [GradientStop],
2219) {
2220    let snap_delta = shape
2221        .snap_anchor
2222        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
2223        .unwrap_or_default();
2224    let local_rect = shape.local_rect.translate(snap_delta.x, snap_delta.y);
2225    let quad = translate_quad(shape.quad, snap_delta);
2226    // Clips are resolved in scene space from their own layer ancestry. A draw
2227    // item's raster snap must never move a fixed ancestor clip.
2228    let clip = shape.clip;
2229    let canonicalize = shape.snap_anchor.is_some();
2230    let device_local_rect = if canonicalize {
2231        canonicalized_scaled_rect(local_rect, root_scale)
2232    } else {
2233        Rect {
2234            x: local_rect.x * root_scale,
2235            y: local_rect.y * root_scale,
2236            width: local_rect.width * root_scale,
2237            height: local_rect.height * root_scale,
2238        }
2239    };
2240    let device_quad = if canonicalize {
2241        canonicalized_scaled_quad(quad, root_scale)
2242    } else {
2243        scaled_quad(quad, root_scale)
2244    };
2245    let canonicalize_brush_coordinate = |value| {
2246        if canonicalize {
2247            canonicalize_device_coordinate(value)
2248        } else {
2249            value
2250        }
2251    };
2252
2253    // Clip rect (scaled to physical pixels)
2254    let clip_rect = if let Some(clip) = clip {
2255        let device_clip = if canonicalize {
2256            canonicalized_scaled_rect(clip, root_scale)
2257        } else {
2258            Rect {
2259                x: clip.x * root_scale,
2260                y: clip.y * root_scale,
2261                width: clip.width * root_scale,
2262                height: clip.height * root_scale,
2263            }
2264        };
2265        [
2266            device_clip.x,
2267            device_clip.y,
2268            device_clip.width,
2269            device_clip.height,
2270        ]
2271    } else {
2272        [0.0, 0.0, 0.0, 0.0]
2273    };
2274
2275    // Gradient parameters
2276    let mut fill_gradient_entries = |colors: &[Color], stops: Option<&[f32]>| {
2277        let count = colors.len();
2278        let explicit_stops = stops.filter(|values| values.len() == count);
2279        for (index, color) in colors.iter().enumerate() {
2280            let position = explicit_stops
2281                .map(|values| values[index])
2282                .unwrap_or_else(|| {
2283                    if count <= 1 {
2284                        0.0
2285                    } else {
2286                        index as f32 / (count - 1) as f32
2287                    }
2288                });
2289            gradient_out[index] = GradientStop {
2290                color: [color.r(), color.g(), color.b(), color.a()],
2291                position: [position, 0.0, 0.0, 0.0],
2292            };
2293        }
2294        count as u32
2295    };
2296    let mut gradient_params = [0.0f32; 4];
2297    let (brush_type, gradient_count, gradient_tile_mode) = match &shape.brush {
2298        SceneBrush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
2299        SceneBrush::Gradient(index) => match &brushes[*index as usize] {
2300            Brush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
2301            Brush::LinearGradient {
2302                colors,
2303                stops,
2304                start,
2305                end,
2306                tile_mode,
2307            } => {
2308                let count = fill_gradient_entries(colors, stops.as_deref());
2309                gradient_params = [
2310                    canonicalize_brush_coordinate(resolve_gradient_point(
2311                        device_local_rect.x,
2312                        device_local_rect.width,
2313                        start.x * root_scale,
2314                    )),
2315                    canonicalize_brush_coordinate(resolve_gradient_point(
2316                        device_local_rect.y,
2317                        device_local_rect.height,
2318                        start.y * root_scale,
2319                    )),
2320                    canonicalize_brush_coordinate(resolve_gradient_point(
2321                        device_local_rect.x,
2322                        device_local_rect.width,
2323                        end.x * root_scale,
2324                    )),
2325                    canonicalize_brush_coordinate(resolve_gradient_point(
2326                        device_local_rect.y,
2327                        device_local_rect.height,
2328                        end.y * root_scale,
2329                    )),
2330                ];
2331                (1u32, count, gradient_tile_mode_value(*tile_mode))
2332            }
2333            Brush::RadialGradient {
2334                colors,
2335                stops,
2336                center,
2337                radius,
2338                tile_mode,
2339            } => {
2340                let count = fill_gradient_entries(colors, stops.as_deref());
2341                gradient_params = [
2342                    canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
2343                    canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
2344                    (radius * root_scale).max(f32::EPSILON),
2345                    0.0,
2346                ];
2347                (2u32, count, gradient_tile_mode_value(*tile_mode))
2348            }
2349            Brush::SweepGradient {
2350                colors,
2351                stops,
2352                center,
2353            } => {
2354                let count = fill_gradient_entries(colors, stops.as_deref());
2355                gradient_params = [
2356                    canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
2357                    canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
2358                    0.0,
2359                    0.0,
2360                ];
2361                (3u32, count, gradient_tile_mode_value(TileMode::Clamp))
2362            }
2363        },
2364    };
2365
2366    // A stroked rect/round-rect was emitted with `local_rect` already
2367    // inflated by half the stroke width, so corner radii must resolve
2368    // against the geometry that was actually asked for, not the
2369    // inflated box. The shader shrinks `half_size` by the same amount.
2370    let stroke_outset = shape
2371        .stroke
2372        .map(|stroke| stroke.half_width())
2373        .unwrap_or(0.0);
2374    let geometry_width = (local_rect.width - stroke_outset * 2.0).max(0.0);
2375    let geometry_height = (local_rect.height - stroke_outset * 2.0).max(0.0);
2376
2377    let radii = if let Some(arc) = shape.arc {
2378        // Arcs never carry corner radii, so this slot ships the shader's
2379        // per-shape trig instead: (sin, cos) of the sweep's mid angle and of
2380        // the half sweep. Computing these here — once per shape — is what
2381        // lets `sdf_arc_band` run without a single transcendental per
2382        // fragment. A full ring is the common case (dots, particles) and
2383        // `ArcGeometry::new` normalizes it to start 0 / sweep TAU, whose
2384        // values are exact constants; the half-sweep sine is pinned to
2385        // non-negative just like the shader used to, so a closed ring keeps
2386        // its seam-free (0, -1) form.
2387        if arc.sweep_angle >= cranpose_ui_graphics::TAU && arc.start_angle == 0.0 {
2388            [0.0, -1.0, 0.0, -1.0]
2389        } else {
2390            let half_sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU) * 0.5;
2391            let (mid_sin, mid_cos) = (arc.start_angle + half_sweep).sin_cos();
2392            let (half_sin, half_cos) = half_sweep.sin_cos();
2393            [mid_sin, mid_cos, half_sin.max(0.0), half_cos]
2394        }
2395    } else if let Some(rounded) = shape.shape {
2396        let resolved = rounded.resolve(geometry_width, geometry_height);
2397        [
2398            resolved.top_left * root_scale,
2399            resolved.top_right * root_scale,
2400            resolved.bottom_left * root_scale,
2401            resolved.bottom_right * root_scale,
2402        ]
2403    } else {
2404        [0.0, 0.0, 0.0, 0.0]
2405    };
2406
2407    let device_rect = [
2408        device_local_rect.x,
2409        device_local_rect.y,
2410        device_local_rect.width,
2411        device_local_rect.height,
2412    ];
2413
2414    // Stroke/arc parameters ride in the same ShapeData and the same
2415    // pipeline as fills, so a stroked or arc shape never splits a
2416    // batch.
2417    let (stroke_params, arc_params) = match (shape.arc, shape.stroke) {
2418        (Some(arc), _) => (
2419            [
2420                0.0,
2421                pack_shape_flags(SHAPE_KIND_ARC, arc.cap, StrokeJoin::Miter),
2422                arc.outer_radius * root_scale,
2423                arc.inner_radius * root_scale,
2424            ],
2425            [
2426                (arc.center.x + snap_delta.x) * root_scale,
2427                (arc.center.y + snap_delta.y) * root_scale,
2428                arc.start_angle,
2429                arc.sweep_angle,
2430            ],
2431        ),
2432        (None, Some(stroke)) => (
2433            [
2434                stroke.width.max(0.0) * root_scale,
2435                pack_shape_flags(SHAPE_KIND_STROKE, stroke.cap, stroke.join),
2436                0.0,
2437                0.0,
2438            ],
2439            [0.0; 4],
2440        ),
2441        (None, None) => (
2442            [
2443                0.0,
2444                pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
2445                0.0,
2446                0.0,
2447            ],
2448            [0.0; 4],
2449        ),
2450    };
2451
2452    let color = match &shape.brush {
2453        SceneBrush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2454        SceneBrush::Gradient(index) => match &brushes[*index as usize] {
2455            Brush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2456            Brush::LinearGradient { colors, .. } => {
2457                let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2458                [first.r(), first.g(), first.b(), first.a()]
2459            }
2460            Brush::RadialGradient { colors, .. } | Brush::SweepGradient { colors, .. } => {
2461                let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2462                [first.r(), first.g(), first.b(), first.a()]
2463            }
2464        },
2465    };
2466
2467    *shape_out = ShapeData {
2468        rect: device_rect,
2469        radii,
2470        gradient_params,
2471        clip_rect,
2472        stroke_params,
2473        arc_params,
2474        quad01: [
2475            device_quad[0][0],
2476            device_quad[0][1],
2477            device_quad[1][0],
2478            device_quad[1][1],
2479        ],
2480        quad23: [
2481            device_quad[2][0],
2482            device_quad[2][1],
2483            device_quad[3][0],
2484            device_quad[3][1],
2485        ],
2486        color,
2487        brush_type,
2488        gradient_start,
2489        gradient_count,
2490        gradient_tile_mode,
2491    };
2492}
2493
2494/// `CRANPOSE_QUAD_AREA_DIAG=1` prints, per shape batch, how many device
2495/// pixels the emitted quads cover — split into arc quads, the true arc band
2496/// coverage inside them, and everything else. Fill cost is the product of
2497/// fragment count and shader cost, and this is the fragment-count half: it
2498/// is how the MEGA scene's ~10x overdraw (and the ~50% of arc-quad area that
2499/// the SDF discards) was measured.
2500fn quad_area_diag_enabled() -> bool {
2501    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2502    *ENABLED.get_or_init(|| std::env::var_os("CRANPOSE_QUAD_AREA_DIAG").is_some())
2503}
2504
2505/// Converts a batch of shapes into pre-sized output slices, fanning the work
2506/// across scoped threads when the batch is large enough to pay for spawns.
2507/// The outputs may be scratch vectors or mapped GPU staging memory; each
2508/// shape writes only its own disjoint slots, so chunked `split_at_mut`
2509/// hand-off keeps the parallel path free of any synchronization.
2510fn convert_shapes_into_outputs(
2511    shape_refs: &[&DrawShape],
2512    brushes: &[Brush],
2513    gradient_offsets: &[u32],
2514    root_scale: f32,
2515    shape_data_out: &mut [ShapeData],
2516    gradients_out: &mut [GradientStop],
2517) {
2518    let shape_count = shape_refs.len();
2519    #[cfg(not(target_arch = "wasm32"))]
2520    let convert_started = Instant::now();
2521    #[cfg(not(target_arch = "wasm32"))]
2522    let parallel =
2523        SHAPE_CONVERT_TUNER.choose_parallel(shape_count) && shape_convert_worker_count() > 1;
2524    if quad_area_diag_enabled() {
2525        let quad_area = |q: [[f32; 2]; 4]| {
2526            // Shoelace over the quad polygon TL, TR, BR, BL (corners 0,1,3,2).
2527            let poly = [q[0], q[1], q[3], q[2]];
2528            let mut twice = 0.0f64;
2529            for i in 0..4 {
2530                let a = poly[i];
2531                let b = poly[(i + 1) % 4];
2532                twice += a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64;
2533            }
2534            twice.abs() * 0.5
2535        };
2536        let mut arc_quad = 0.0f64; // quad px of arc shapes
2537        let mut arc_band = 0.0f64; // true band coverage of those arcs
2538        let mut arc_count = 0usize;
2539        let mut ring_count = 0usize;
2540        let mut other_quad = 0.0f64;
2541        let mut other_count = 0usize;
2542        // Largest non-arc quads: (area, index) so the tail of the diag can
2543        // name what the aggregate "other" fill actually is.
2544        let mut top_other: Vec<(f64, usize)> = Vec::new();
2545        for (index, shape) in shape_refs.iter().enumerate() {
2546            let area = quad_area(shape.quad);
2547            if let Some(arc) = shape.arc {
2548                arc_quad += area;
2549                arc_count += 1;
2550                if arc.sweep_angle >= cranpose_ui_graphics::TAU {
2551                    ring_count += 1;
2552                }
2553                let ra = arc.mid_radius() as f64;
2554                let rb = arc.half_thickness() as f64;
2555                arc_band +=
2556                    arc.sweep_angle as f64 * ra * (2.0 * rb) + std::f64::consts::PI * rb * rb;
2557            } else {
2558                other_quad += area;
2559                other_count += 1;
2560                top_other.push((area, index));
2561            }
2562        }
2563        let scale2 = (root_scale as f64) * (root_scale as f64);
2564        eprintln!(
2565            "[quad-area] arcs={arc_count} (rings={ring_count}) arc_quad_px={:.0} arc_band_px={:.0} | other={other_count} other_px={:.0}",
2566            arc_quad * scale2,
2567            arc_band * scale2,
2568            other_quad * scale2,
2569        );
2570        top_other.sort_by(|a, b| b.0.total_cmp(&a.0));
2571        for &(area, index) in top_other.iter().take(4) {
2572            let shape = shape_refs[index];
2573            let brush = match shape.brush.resolve(brushes).as_ref() {
2574                cranpose_ui_graphics::Brush::Solid(color) => format!("solid a={:.2}", color.3),
2575                cranpose_ui_graphics::Brush::LinearGradient { colors, .. } => {
2576                    format!("linear n={}", colors.len())
2577                }
2578                cranpose_ui_graphics::Brush::RadialGradient { colors, .. } => {
2579                    format!("radial n={}", colors.len())
2580                }
2581                cranpose_ui_graphics::Brush::SweepGradient { colors, .. } => {
2582                    format!("sweep n={}", colors.len())
2583                }
2584            };
2585            eprintln!(
2586                "[quad-area]   top other: {:.0}px {}x{} at ({:.0},{:.0}) {} shape={} stroke={} clip={} blend={:?} z={}",
2587                area * scale2,
2588                shape.rect.width.round(),
2589                shape.rect.height.round(),
2590                shape.rect.x,
2591                shape.rect.y,
2592                brush,
2593                shape.shape.is_some(),
2594                shape.stroke.is_some(),
2595                shape.clip.is_some(),
2596                shape.blend_mode,
2597                shape.z_index,
2598            );
2599        }
2600    }
2601    #[cfg(target_arch = "wasm32")]
2602    let parallel = false;
2603    let workers = if parallel {
2604        shape_convert_worker_count()
2605    } else {
2606        1
2607    };
2608    if workers <= 1 {
2609        for (idx, shape) in shape_refs.iter().enumerate() {
2610            let gradient_start = gradient_offsets[idx];
2611            let gradient_end = gradient_offsets[idx + 1];
2612            convert_shape_into_slots(
2613                shape,
2614                brushes,
2615                root_scale,
2616                gradient_start,
2617                &mut shape_data_out[idx],
2618                &mut gradients_out[gradient_start as usize..gradient_end as usize],
2619            );
2620        }
2621        #[cfg(not(target_arch = "wasm32"))]
2622        SHAPE_CONVERT_TUNER.record(
2623            false,
2624            shape_count,
2625            convert_started.elapsed().as_nanos() as u64,
2626        );
2627        return;
2628    }
2629
2630    let chunk_len = shape_count.div_ceil(workers);
2631    let mut shape_data_rest = shape_data_out;
2632    let mut gradients_rest = gradients_out;
2633    std::thread::scope(|scope| {
2634        let mut chunk_start = 0usize;
2635        while chunk_start < shape_count {
2636            let chunk_end = (chunk_start + chunk_len).min(shape_count);
2637            let count = chunk_end - chunk_start;
2638            let gradient_base = gradient_offsets[chunk_start];
2639            let gradient_span = (gradient_offsets[chunk_end] - gradient_base) as usize;
2640            let (shape_data_chunk, rest) = std::mem::take(&mut shape_data_rest).split_at_mut(count);
2641            shape_data_rest = rest;
2642            let (gradient_chunk, rest) =
2643                std::mem::take(&mut gradients_rest).split_at_mut(gradient_span);
2644            gradients_rest = rest;
2645            let chunk_refs = &shape_refs[chunk_start..chunk_end];
2646            let chunk_offsets = &gradient_offsets[chunk_start..=chunk_end];
2647            let mut convert_chunk = move || {
2648                for (j, shape) in chunk_refs.iter().enumerate() {
2649                    let gradient_start = chunk_offsets[j];
2650                    let local_start = (gradient_start - gradient_base) as usize;
2651                    let local_end = (chunk_offsets[j + 1] - gradient_base) as usize;
2652                    convert_shape_into_slots(
2653                        shape,
2654                        brushes,
2655                        root_scale,
2656                        gradient_start,
2657                        &mut shape_data_chunk[j],
2658                        &mut gradient_chunk[local_start..local_end],
2659                    );
2660                }
2661            };
2662            if chunk_end == shape_count {
2663                // The caller would only block at the scope join; converting
2664                // the final chunk inline puts that time to work and saves a
2665                // spawn.
2666                convert_chunk();
2667            } else {
2668                scope.spawn(convert_chunk);
2669            }
2670            chunk_start = chunk_end;
2671        }
2672    });
2673    #[cfg(not(target_arch = "wasm32"))]
2674    SHAPE_CONVERT_TUNER.record(
2675        true,
2676        shape_count,
2677        convert_started.elapsed().as_nanos() as u64,
2678    );
2679}
2680
2681#[repr(C)]
2682#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2683struct GradientStop {
2684    color: [f32; 4],
2685    position: [f32; 4],
2686}
2687
2688/// How many replay slots the shared transform buffer holds. Each slot's
2689/// transform lives at `slot * REPLAY_TRANSFORM_STRIDE`, aligned for the
2690/// strictest uniform-offset requirement any backend reports.
2691#[cfg(not(target_arch = "wasm32"))]
2692const MAX_REPLAY_SLOTS: u32 = 128;
2693#[cfg(not(target_arch = "wasm32"))]
2694const REPLAY_TRANSFORM_STRIDE: u64 = 256;
2695
2696/// One retained replay batch: converted shape slots captured on an earlier
2697/// frame, kept on the GPU and re-drawn each frame under the similarity
2698/// transform staged at `transform_offset`.
2699///
2700/// The immutable `ShapeData` and gradient buffers hold no handle here:
2701/// nothing addresses them after capture, and `bind_group` keeps them alive.
2702#[cfg(not(target_arch = "wasm32"))]
2703struct ReplaySlot {
2704    /// One `vec4<f32>` color per shape — the mutable paint the shader reads
2705    /// under `paint_select`, split out so recolor patches upload 16 bytes
2706    /// per shape while the 160-byte `ShapeData` stays immutable on the GPU
2707    /// from capture to release.
2708    paint_buffer: wgpu::Buffer,
2709    bind_group: wgpu::BindGroup,
2710    shape_count: u32,
2711    /// CPU mirror of the paint buffer. Recolor patches apply here first
2712    /// and upload as one contiguous span per slot per frame — MEGA's
2713    /// twinkle field recolors ~1.7k dots a frame, and that many individual
2714    /// copy commands stall a mobile GPU for longer than the spans' extra
2715    /// bytes ever could.
2716    paint_mirror: Vec<[f32; 4]>,
2717    /// Conservative capture-space arc/ring mesh, built once at capture.
2718    /// `None` when the kill switch is off, the slot meshed no shapes (none
2719    /// over the size gate), or the vertex budget overflowed — those slots
2720    /// replay through the quad-expansion six-vertices-per-shape path.
2721    mesh: Option<ReplaySlotMesh>,
2722    /// Which capture created this slot's buffers, from the store's global
2723    /// monotone counter. Retained bundle keys carry it so a slot id that is
2724    /// released and recaptured — new bind group, new buffers, same id — can
2725    /// never be drawn through a bundle recorded against the old capture.
2726    capture_epoch: u64,
2727    /// Whether any captured shape carries gradient stops. False routes the
2728    /// slot's quad-expansion draws through the `fs_solid` pipelines; fixed
2729    /// for the life of the capture, so bundle keys need nothing beyond the
2730    /// capture epoch they already carry.
2731    has_gradient: bool,
2732    /// Per-shape capture-space fill records for the `CRANPOSE_FILL_DIAG`
2733    /// instrument (`shape_count` entries): submitted area (mesh triangles
2734    /// when this slot replays its arc mesh, bounding quads otherwise),
2735    /// analytic lit area, opacity class and quad AABB. Empty when the
2736    /// diagnostic is off.
2737    fill_diag_shapes: Vec<FillDiagShapeRecord>,
2738    /// Per-shape capture-space quad AABBs (`[min_x, min_y, max_x, max_y]`,
2739    /// `shape_count` entries) — the segment-surface cache's geometry
2740    /// source. Always computed (one min/max pass over corners already in
2741    /// cache at capture), so a slot captured while that cache was off still
2742    /// serves it after an opt-in flip.
2743    shape_aabbs: Vec<[f32; 4]>,
2744    /// Running quad-area prefix sum (`shape_count + 1` entries): shape
2745    /// range `a..b` submits `area_prefix[b] - area_prefix[a]` device px²
2746    /// of quads at capture scale.
2747    area_prefix: Vec<f32>,
2748    /// Ratio of actually-submitted pixels to plain quad pixels when this
2749    /// slot replays its arc mesh (1.0 unmeshed) — the segment-surface
2750    /// economics gate prices the direct path by what it truly rasterizes.
2751    submitted_area_scale: f32,
2752}
2753
2754/// Band geometry a retained slot replays for its MESHED shapes only: arc
2755/// and stroked-circle rim bands over the size gate get trapezoid strips
2756/// covering their antialiasing footprint, while every other shape stays on
2757/// the latched instanced-quad path — the draw walk alternates between the
2758/// two along the shape range ([`GpuRenderer::encode_retained_op`]). The
2759/// buffers never hold passthrough quads: routing them through per-vertex
2760/// `MeshVertex` attributes instead of instancing's shared storage reads is
2761/// what the watch A/B measured as a 2-5 fps LOSS (see
2762/// [`arc_mesh_enabled`]). See [`build_arc_mesh_vertices`].
2763#[cfg(not(target_arch = "wasm32"))]
2764struct ReplaySlotMesh {
2765    vertex_buffer: wgpu::Buffer,
2766    /// `u32` triangle-list indices into `vertex_buffer`: band-boundary
2767    /// vertices are emitted once and shared by both adjacent trapezoids, so
2768    /// per-arc vertex-shader work drops from ~30 executions to the unique
2769    /// boundary vertices (~10-14) — the amplification that made the
2770    /// non-indexed mesh SLOWER than plain quads on the watch's Adreno 702.
2771    index_buffer: wgpu::Buffer,
2772    /// Prefix table, `shape_count + 1` entries: shape `i`'s triangles occupy
2773    /// indices `index_prefix[i]..index_prefix[i + 1]`; an EMPTY range marks
2774    /// a shape the draw walk keeps instanced. A run of meshed shapes draws
2775    /// as one `draw_indexed` over its combined range — identical shape
2776    /// order, z untouched.
2777    index_prefix: Vec<u32>,
2778    /// Capture engagement counts for the test/diagnostic view
2779    /// ([`GpuRenderer::replay_slot_mesh_engagement`]): shapes meshed as arc
2780    /// bands, shapes meshed as stroked-circle rim bands, and shapes that
2781    /// stayed on the instanced-quad path (gate-rejected or non-band).
2782    meshed_arcs: usize,
2783    meshed_rims: usize,
2784    passthrough: usize,
2785}
2786
2787/// Vertex of a retained slot's conservative arc mesh: capture-device-space
2788/// position, the uv reproducing `vs_main`'s affine rect map at that position,
2789/// and the shape index standing in for `vertex_index / 6`.
2790#[cfg(not(target_arch = "wasm32"))]
2791#[repr(C)]
2792#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2793struct MeshVertex {
2794    position: [f32; 2],
2795    uv: [f32; 2],
2796    shape_idx: u32,
2797}
2798
2799#[cfg(not(target_arch = "wasm32"))]
2800impl MeshVertex {
2801    const ATTRIBS: [wgpu::VertexAttribute; 3] =
2802        wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32];
2803
2804    fn desc() -> wgpu::VertexBufferLayout<'static> {
2805        wgpu::VertexBufferLayout {
2806            array_stride: std::mem::size_of::<MeshVertex>() as wgpu::BufferAddress,
2807            step_mode: wgpu::VertexStepMode::Vertex,
2808            attributes: &Self::ATTRIBS,
2809        }
2810    }
2811}
2812
2813/// Kill switch, mirroring `command_feed_enabled`: default ON,
2814/// `CRANPOSE_ARC_MESH=0` (or the `debug.cranpose.arc_mesh` property on
2815/// Android) makes the next capture skip mesh building entirely, so a device
2816/// A/B needs no rebuild. Read per capture — captures are rare.
2817#[cfg(not(target_arch = "wasm32"))]
2818fn arc_mesh_enabled() -> bool {
2819    // OPT-IN by measurement, size gate and all: alternating watch pairs
2820    // (Adreno 702, mega scene, gate at its 16384 px² default — 2 shapes
2821    // meshed, ~550 passthrough per slot) read mesh ON 48.7/43.5 fps vs
2822    // OFF 53.7/45.2 — both pairs lose. The earlier all-arcs regime lost
2823    // 4-11 fps; the gate shrank the loss, never crossed zero. The likely
2824    // mechanism is structural: a slot holding a mesh leaves the latched
2825    // instanced-quad path for EVERY shape in the slot, so its passthrough
2826    // quads pay per-vertex attribute bandwidth where the instanced path
2827    // paid shared storage reads — on a bandwidth-bound part that swamps
2828    // the meshed shapes' fill recovery (fill-truth: 0.45 Mpx/frame of
2829    // retained slack, 86-94% in a handful of huge ring/rim shapes). The
2830    // measured WIN regime stays the DYNAMIC transient rim mesh
2831    // ([`rim_mesh_band`], +9 fps, default on). A retry that could earn
2832    // default-on: split a meshed slot's draw so passthrough shapes stay
2833    // instanced and only gate-passing shapes take the mesh.
2834    matches!(std::env::var("CRANPOSE_ARC_MESH").as_deref(), Ok("1"))
2835}
2836
2837/// Dilation applied to the band's half-thickness before meshing, in capture
2838/// device pixels. The fragment SDF feathers over ±0.5 px
2839/// (`smoothstep(-0.5, 0.5, dist)`), so every pixel the shader keeps sits
2840/// within 0.5 px of the band; the other 0.5 px absorbs f32 slop between this
2841/// builder's trig and the converted shape's precomputed (sin, cos) pairs.
2842#[cfg(not(target_arch = "wasm32"))]
2843const ARC_MESH_MARGIN: f32 = 1.0;
2844
2845/// Chord overshoot budget in pixels: the segment count is chosen so pushing
2846/// outer edges tangent-outside the dilated outer circle overshoots it by
2847/// about this much at the chord ends.
2848#[cfg(not(target_arch = "wasm32"))]
2849const ARC_MESH_OVERSHOOT: f32 = 2.0;
2850
2851#[cfg(not(target_arch = "wasm32"))]
2852const ARC_MESH_MIN_SEGMENTS: usize = 4;
2853#[cfg(not(target_arch = "wasm32"))]
2854const ARC_MESH_MAX_SEGMENTS: usize = 64;
2855
2856/// Per-slot geometry budget in BYTES: 48 vertex-equivalents (~1 KB) per
2857/// shape, floored for tiny slots so a single huge ring still fits. The
2858/// non-indexed mesh spent this entirely on 20-byte vertices; the indexed
2859/// mesh counts vertices AND 4-byte indices against the same byte ceiling,
2860/// which indexed geometry fits with more headroom (MEGA's retained arcs
2861/// drop from ~30 vertices ≈ 600 B to ~12 unique vertices + ~30 indices
2862/// ≈ 360 B). Overflow falls back to whole-slot passthrough WITH a warning —
2863/// truncating silently would break the containment invariant.
2864#[cfg(not(target_arch = "wasm32"))]
2865const ARC_MESH_BUDGET_BYTES_PER_SHAPE: usize = 48 * std::mem::size_of::<MeshVertex>();
2866#[cfg(not(target_arch = "wasm32"))]
2867const ARC_MESH_BUDGET_FLOOR_BYTES: usize = 4096 * std::mem::size_of::<MeshVertex>();
2868
2869/// The budget-relevant size of an indexed mesh: what the GPU buffers will
2870/// actually hold.
2871#[cfg(not(target_arch = "wasm32"))]
2872fn arc_mesh_bytes(vertices: usize, indices: usize) -> usize {
2873    vertices * std::mem::size_of::<MeshVertex>() + indices * std::mem::size_of::<u32>()
2874}
2875
2876/// Ceiling on a capture's maximal runs of consecutive meshed shapes
2877/// ([`ArcMeshBuild::meshed_stretches`]). The draw walk alternates between
2878/// the mesh pipeline and the instanced-quad pipeline along the shape range,
2879/// so every meshed stretch costs an op that covers it two pipeline switches
2880/// plus an index-buffer rebind; a slot whose meshed shapes interleave
2881/// pathologically with passthrough ones would trade the fill win for
2882/// switch thrash. Past this cap the capture keeps NO mesh and the whole
2883/// slot stays on the instanced path — a structural property of the
2884/// captured content, not of any app. Eight stretches bound an op at
2885/// seventeen draws; the measured scene's slots hold two.
2886#[cfg(not(target_arch = "wasm32"))]
2887const MESH_SLOT_MAX_STRETCHES: usize = 8;
2888
2889/// Default size gate for the retained capture mesh, in capture-space px² of
2890/// a shape's bounding quad: shapes below it take the passthrough quad even
2891/// when they qualify geometrically.
2892///
2893/// The default follows from the trade's own economics, not from any one
2894/// scene. A band mesh costs a roughly shape-size-independent overhead — up
2895/// to [`ARC_MESH_MAX_SEGMENTS`] trapezoids of vertex work plus the extra
2896/// primitives' setup and bin-list traffic on a tiling GPU — while what it
2897/// can recover scales with the shape's quad area times its discard-slack
2898/// fraction (an arc or ring band fills only O(perimeter x thickness) of
2899/// its box, so the slack fraction RISES with size: big bands are almost
2900/// all slack, tiny ones barely any). Fixed cost against area-proportional
2901/// benefit crosses zero at some quad size; 16384 px² (a 128 px square)
2902/// puts the gate an order of magnitude above the measured loss regime and
2903/// an order below the measured win regime, so it is margin, not tuning:
2904/// on the Adreno 702 meshing ~14k retained ~100-800 px² shapes lost
2905/// 4-11 fps, while the same mesher over only large shapes wins on the same
2906/// GPU (the shipping [`rim_mesh_band`] path, gated at 65536 px²), and
2907/// fill-truth's top retained slack sits at ~19k px² and up (86-94% slack).
2908/// Any app whose retained content mixes the two populations lands on the
2909/// same split; a device where the crossover measurably differs A/Bs the
2910/// threshold through the override below without a rebuild.
2911#[cfg(not(target_arch = "wasm32"))]
2912const RETAINED_MESH_MIN_PX2_DEFAULT: usize = 16384;
2913/// Clamp for the `CRANPOSE_RETAINED_MESH_PX2` override: below ~1k px² the
2914/// tiny-mesh amplification regime demonstrably returns, and above 256k px²
2915/// the gate exceeds a whole 512x512 quad — both ends are "you no longer
2916/// mean the size gate", not useful A/B settings.
2917#[cfg(not(target_arch = "wasm32"))]
2918const RETAINED_MESH_MIN_PX2_RANGE: std::ops::RangeInclusive<usize> = 1024..=262144;
2919
2920/// The retained capture mesh's size gate in px², default
2921/// [`RETAINED_MESH_MIN_PX2_DEFAULT`], overridable for device A/Bs via
2922/// `CRANPOSE_RETAINED_MESH_PX2` (the `debug.cranpose.retained_mesh_px2`
2923/// property on Android), clamped to [`RETAINED_MESH_MIN_PX2_RANGE`]. Read
2924/// per capture like [`arc_mesh_enabled`] — captures are rare.
2925#[cfg(not(target_arch = "wasm32"))]
2926fn retained_mesh_min_px2() -> f64 {
2927    parse_retained_mesh_min_px2(std::env::var("CRANPOSE_RETAINED_MESH_PX2").ok().as_deref())
2928}
2929
2930#[cfg(not(target_arch = "wasm32"))]
2931fn parse_retained_mesh_min_px2(value: Option<&str>) -> f64 {
2932    value
2933        .and_then(|value| value.trim().parse::<usize>().ok())
2934        .map(|px2| {
2935            px2.clamp(
2936                *RETAINED_MESH_MIN_PX2_RANGE.start(),
2937                *RETAINED_MESH_MIN_PX2_RANGE.end(),
2938            )
2939        })
2940        .unwrap_or(RETAINED_MESH_MIN_PX2_DEFAULT) as f64
2941}
2942
2943/// Band parameters of a captured arc that qualifies for a conservative mesh:
2944/// solid brush, no clip, and a quad that is exactly — tolerance zero — the
2945/// axis-aligned box of its rect. Everything else returns `None` and passes
2946/// through as today's two quad triangles.
2947#[cfg(not(target_arch = "wasm32"))]
2948struct ArcMeshBand {
2949    center: [f32; 2],
2950    inner: f32,
2951    outer: f32,
2952    start: f32,
2953    sweep: f32,
2954}
2955
2956#[cfg(not(target_arch = "wasm32"))]
2957fn arc_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
2958    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
2959    let flags = shape.stroke_params[1].max(0.0) as u32;
2960    if flags & 3 != SHAPE_KIND_ARC {
2961        return None;
2962    }
2963    // Solid brushes only: gradients also derive from `rect_pos` and would
2964    // mesh in principle, but the hot retained scenes are solid and a narrow
2965    // gate keeps the byte-exactness surface small.
2966    if shape.brush_type != 0 {
2967        return None;
2968    }
2969    // A live clip is a hard `world_pos` comparison in the fragment shader.
2970    // Meshed arcs interpolate `world_pos` across different triangles than
2971    // the quad would, and one ulp of difference at the clip boundary flips
2972    // whole pixels — clipped arcs pass through untouched.
2973    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
2974        return None;
2975    }
2976    let [_, _, w, h] = shape.rect;
2977    if !(w > 0.0 && h > 0.0) {
2978        return None;
2979    }
2980    // The quad must be an axis-aligned box, tolerance zero: the mesh is
2981    // clipped to the quad's own corners, so as long as the quad IS a box its
2982    // rasterized pixel set equals the mesh clip region and the tight-AABB
2983    // tangent-point crop is reproduced exactly. (Comparing against `rect`
2984    // instead is an over-tight gate: under a non-dyadic root scale
2985    // `(x + w) * s` differs from `x * s + w * s` by an ulp and every arc
2986    // fell back to passthrough — observed on the Huawei at scale 2.75.)
2987    let [left, top, right, _] = shape.quad01;
2988    let [bl_x, bottom, br_x, br_y] = shape.quad23;
2989    let axis_aligned = shape.quad01[3] == top
2990        && bl_x == left
2991        && br_x == right
2992        && br_y == bottom
2993        && left < right
2994        && top < bottom;
2995    if !axis_aligned {
2996        return None;
2997    }
2998    let center = [shape.arc_params[0], shape.arc_params[1]];
2999    let start = shape.arc_params[2];
3000    let sweep = shape.arc_params[3];
3001    let outer = shape.stroke_params[2];
3002    let inner = shape.stroke_params[3];
3003    let finite = center[0].is_finite()
3004        && center[1].is_finite()
3005        && start.is_finite()
3006        && sweep.is_finite()
3007        && outer.is_finite()
3008        && inner.is_finite();
3009    if !finite || outer <= 0.0 || sweep <= 0.0 {
3010        return None;
3011    }
3012    Some(ArcMeshBand {
3013        center,
3014        inner,
3015        outer,
3016        start,
3017        sweep,
3018    })
3019}
3020
3021/// Kill switch for the transient rim band mesh, mirroring
3022/// [`arc_mesh_enabled`]'s property bridge: `CRANPOSE_RIM_MESH=0` (or the
3023/// `debug.cranpose.rim_mesh` property on Android) makes the fused shape
3024/// prepare skip rim detection entirely, so a device A/B needs no rebuild.
3025/// Default ON — the rim path only ever meshed a handful of huge shapes per
3026/// frame, which is the regime that WINS on the watch GPU (and the proof the
3027/// retained mesh's size gate is built on; see [`arc_mesh_enabled`]).
3028/// Read once per fused-chunk prepare (cheap), not per shape.
3029#[cfg(not(target_arch = "wasm32"))]
3030fn rim_mesh_enabled() -> bool {
3031    !matches!(std::env::var("CRANPOSE_RIM_MESH").as_deref(), Ok("0"))
3032}
3033
3034/// Fixed capacity of the per-frame transient rim mesh vertex buffer, in
3035/// vertices. The buffers are never recreated mid-frame — draws are encoded
3036/// before submit, so a reallocation would orphan already-encoded rims — and
3037/// overflow means "skip the rim, draw it as a quad", never truncation.
3038/// MEGA's arena meshes 2-3 rims per frame at ~80 vertices each (measured on
3039/// the Pixel Watch 3 via the emit log below), so ~100 rims of headroom; the
3040/// rate-limited warn below is the tell if a scene ever exceeds it.
3041#[cfg(not(target_arch = "wasm32"))]
3042const RIM_MESH_VERTEX_CAPACITY: usize = 8192;
3043/// Fixed capacity of the per-frame transient rim mesh index buffer, in
3044/// `u32` indices.
3045#[cfg(not(target_arch = "wasm32"))]
3046const RIM_MESH_INDEX_CAPACITY: usize = 32768;
3047
3048/// A dynamic shape inside a fused chunk that draws as a band mesh instead of
3049/// its full bounding quad: `shape_index` is the shape's position within the
3050/// whole fused upload (the index `vs_mesh` reads into the storage shape
3051/// array), `first_index..first_index + index_count` its span of the frame's
3052/// transient rim index buffer.
3053#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3054#[derive(Clone, Copy, Debug)]
3055struct RimDraw {
3056    shape_index: u32,
3057    first_index: u32,
3058    index_count: u32,
3059}
3060
3061/// Rate-limited overflow warning: silent skipping would hide a scene whose
3062/// rims permanently miss the fast path, while warning every frame would
3063/// flood the watch's logcat.
3064#[cfg(not(target_arch = "wasm32"))]
3065fn rim_mesh_capacity_warn() {
3066    use std::sync::atomic::{AtomicU64, Ordering};
3067    static OVERFLOWS: AtomicU64 = AtomicU64::new(0);
3068    let count = OVERFLOWS.fetch_add(1, Ordering::Relaxed);
3069    if count.is_multiple_of(512) {
3070        log::warn!(
3071            "[rim-mesh] transient buffers full; rim falls back to quad expansion \
3072             (lifetime overflows {})",
3073            count + 1,
3074        );
3075    }
3076}
3077
3078/// Band parameters of a stroked round-rect whose outline is geometrically a
3079/// circle — an arena "rim". Everything else returns `None` and rasterizes
3080/// through the ordinary quad expansion. Two callers, each behind its own
3081/// size gate: the DYNAMIC fused path via [`rim_mesh_band`], and the
3082/// retained capture builder ([`build_arc_mesh_vertices`]) via
3083/// [`retained_mesh_min_px2`] — retained slots hold big static ring circles
3084/// the dynamic path never sees.
3085///
3086/// Derivation: `ShapeData::rect` for a stroked shape is the stroke-inflated
3087/// box (geometry plus half the stroke width on each side), so the geometry
3088/// half-extent is `geom_half = (rect.w - stroke_width) / 2`. When the corner
3089/// radius equals that half-extent the outline is a circle of radius
3090/// `geom_half`, and `sdf_stroked_rounded_rect` degenerates exactly to an
3091/// annulus: its outer offset rounded-rect (`half_size` = `geom_half + hw`,
3092/// radius `geom_half + hw`) is the circle of radius `geom_half + sw/2`, its
3093/// inner offset the circle of radius `geom_half - sw/2` — centerline
3094/// `geom_half`, half-width `sw/2`. The bevel-join chamfer plane can only CUT
3095/// pixels from that annulus (`max(dist, chamfer)`), never add any, so for
3096/// every join style the shader's kept set is a subset of the annulus band.
3097/// [`emit_arc_band_mesh`] adds its own `ARC_MESH_MARGIN`, treats
3098/// `sweep >= TAU` as closed, and clips to the quad box, so containment
3099/// (mesh ⊇ every pixel with `|dist| < 0.5`, mesh ⊆ quad box) follows from
3100/// the same argument the retained arc mesh documents.
3101///
3102/// The CIRCLE gate is what keeps this correct: a false positive on a rounded
3103/// SQUARE ring would under-cover its flat spans and damage pixels, so the
3104/// radius must match `geom_half` to within 0.01 px (a deviation that small
3105/// stays inside the mesh margin's 0.5 px float-slop budget).
3106#[cfg(not(target_arch = "wasm32"))]
3107fn rim_band_geometry(shape: &ShapeData) -> Option<ArcMeshBand> {
3108    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
3109    let flags = shape.stroke_params[1].max(0.0) as u32;
3110    if flags & 3 != SHAPE_KIND_STROKE {
3111        return None;
3112    }
3113    // Solid brushes only — same narrow byte-exactness surface as
3114    // `arc_mesh_band`.
3115    if shape.brush_type != 0 {
3116        return None;
3117    }
3118    // A live clip is a hard `world_pos` comparison in the fragment shader;
3119    // meshed rims interpolate `world_pos` across different triangles and one
3120    // ulp at the clip boundary flips whole pixels.
3121    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
3122        return None;
3123    }
3124    let [x, y, w, h] = shape.rect;
3125    if !(w > 0.0 && h > 0.0) {
3126        return None;
3127    }
3128    // The quad must be an axis-aligned box, tolerance zero — the identical
3129    // check `arc_mesh_band` makes (compare quad corners against each other,
3130    // never against `rect`, which differs by an ulp under non-dyadic root
3131    // scales).
3132    let [left, top, right, _] = shape.quad01;
3133    let [bl_x, bottom, br_x, br_y] = shape.quad23;
3134    let axis_aligned = shape.quad01[3] == top
3135        && bl_x == left
3136        && br_x == right
3137        && br_y == bottom
3138        && left < right
3139        && top < bottom;
3140    if !axis_aligned {
3141        return None;
3142    }
3143    // A circle's box is square, bitwise.
3144    if w.to_bits() != h.to_bits() {
3145        return None;
3146    }
3147    // All four corner radii bitwise equal, finite and positive.
3148    let [r0, r1, r2, r3] = shape.radii;
3149    if r0.to_bits() != r1.to_bits() || r0.to_bits() != r2.to_bits() || r0.to_bits() != r3.to_bits()
3150    {
3151        return None;
3152    }
3153    if !r0.is_finite() || r0 <= 0.0 {
3154        return None;
3155    }
3156    let sw = shape.stroke_params[0];
3157    if !sw.is_finite() || sw <= 0.0 {
3158        return None;
3159    }
3160    // Finiteness before the circle gate: with every operand finite the
3161    // radius comparison below cannot see a NaN.
3162    let geom_half = (w - sw) * 0.5;
3163    let center = [x + w * 0.5, y + h * 0.5];
3164    let inner = geom_half - sw * 0.5;
3165    let outer = geom_half + sw * 0.5;
3166    let finite =
3167        center[0].is_finite() && center[1].is_finite() && inner.is_finite() && outer.is_finite();
3168    if !finite || outer <= 0.0 {
3169        return None;
3170    }
3171    // The circle gate (see the doc comment).
3172    if (r0 - geom_half).abs() > 0.01 {
3173        return None;
3174    }
3175    Some(ArcMeshBand {
3176        center,
3177        inner,
3178        outer,
3179        start: 0.0,
3180        sweep: cranpose_ui_graphics::TAU,
3181    })
3182}
3183
3184/// [`rim_band_geometry`] behind the DYNAMIC path's size gate. Big shapes
3185/// only: the win is proportional to the discarded quad area, and small
3186/// quads are cheaper than the extra pipeline switches.
3187#[cfg(not(target_arch = "wasm32"))]
3188fn rim_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
3189    let [_, _, w, h] = shape.rect;
3190    if w * h < 65536.0 {
3191        return None;
3192    }
3193    rim_band_geometry(shape)
3194}
3195
3196/// Kill switch for the opaque static leading-span cache, mirroring
3197/// [`rim_mesh_enabled`]'s property bridge: `CRANPOSE_STATIC_SPAN=0` (or the
3198/// `debug.cranpose.static_span` property on Android) makes the fused
3199/// partition never skip, capture, or blit — a device A/B needs no rebuild.
3200/// Default ON. Read once per engagement attempt (once per frame), so the
3201/// cost is one `env::var` per frame.
3202#[cfg(not(target_arch = "wasm32"))]
3203fn static_span_enabled() -> bool {
3204    !matches!(std::env::var("CRANPOSE_STATIC_SPAN").as_deref(), Ok("0"))
3205}
3206
3207/// Upper bound on how many leading shapes one span may cover. The target
3208/// span (full-screen background rect + vignette disc) is 2 shapes; the cap
3209/// only bounds the per-frame memcmp (16 x 160 B) and the prev-frame copy.
3210#[cfg(not(target_arch = "wasm32"))]
3211const STATIC_SPAN_MAX_SHAPES: usize = 16;
3212
3213/// Consecutive stable frames an EXTENSION of an already-valid span must
3214/// show before an upgrade recapture — see the hysteresis comment in
3215/// [`StaticSpanCache::engage`].
3216#[cfg(not(target_arch = "wasm32"))]
3217const STATIC_SPAN_UPGRADE_FRAMES: u32 = 30;
3218
3219/// What the engagement check decided for this frame's leading fused
3220/// partition.
3221#[cfg(not(target_arch = "wasm32"))]
3222#[derive(Clone, Copy, Debug, PartialEq)]
3223enum StaticSpanDecision {
3224    /// Not engaged: draw everything live, capture nothing.
3225    Pass,
3226    /// The cached span image is valid: skip the first `skip` shapes of the
3227    /// first batch and draw the cached full-target blit before everything.
3228    Hit { skip: usize },
3229    /// The leading `len` shapes were byte-stable across the last two frames
3230    /// but the cache does not match: draw live, then re-capture the span.
3231    Capture { len: usize, clear: wgpu::Color },
3232}
3233
3234/// Cache of the frame's leading static span — the opaque full-screen
3235/// background rect plus whatever byte-stable draws sit directly on top of it
3236/// (MEGA: the ~176k-px radial-gradient vignette disc) — as one composited
3237/// full-target texture that replaces those draws with a single blit.
3238///
3239/// Byte-exactness by construction, no tolerance anywhere:
3240///
3241/// * The engaged partition is the frame's first content (`load_op` is the
3242///   frame `Clear`, gated to alpha == 1.0), so what the live path would put
3243///   under the span is exactly the opaque clear color — and the capture
3244///   pass clears its offscreen with the SAME color before drawing the SAME
3245///   shape range through the IDENTICAL pipelines (same `ShapeData` bytes,
3246///   same gradient stop bytes, same viewport uniforms, same blend state,
3247///   same `has_gradient` pipeline variant, same surface format, identity
3248///   similarity offset 0). Deterministic pipelines on identical inputs give
3249///   identical bytes, so the cached image IS the bytes the live span render
3250///   would produce this frame.
3251/// * With an opaque clear below and SrcOver-only draws above, every texel of
3252///   that composite has alpha exactly 255: each blend step computes
3253///   `a_out = a_src + (1 - a_src) * 1.0`, whose float error is far inside
3254///   the half-level the unorm8 quantizer absorbs, and 255 reads back as
3255///   exactly 1.0 for the next step. The replacement blit then draws SrcOver
3256///   texels whose `1 - src.a` dst factor is exactly zero — the
3257///   fixed-function blender computes `1*src + 0*dst`, a replace-write — and
3258///   an unorm8 texel survives the sample/write round trip bit-exact
3259///   (`CompositeSampleMode::Nearest` is a `textureLoad`, `alpha` is 1.0).
3260///   Hence `over(rest, over(span, clear)) == over(rest, SPAN_IMAGE)`
3261///   bitwise, whatever `rest` is.
3262/// * Gradient dither cannot diverge between capture and screen: `shape.wgsl`
3263///   keys its ordered-dither matrix off `world_pos` — the device coordinate
3264///   interpolated from the `ShapeData` quad corners, deliberately not
3265///   `@builtin(position)` — so the dither phase is a pure function of the
3266///   memcmp'd bytes (see `gradient_dither` in `shape.wgsl`).
3267/// * Rim-mesh candidates ([`rim_mesh_band`] Some) end the span: the live
3268///   path may draw them through the band-mesh pipeline while the capture
3269///   pass draws plain instanced quads, and this cache refuses to depend on
3270///   that pair being byte-equal.
3271///
3272/// Validity is a memcmp: the leading K converted `ShapeData` records plus
3273/// their gradient stop payloads against the cached copy, ~160 B x few
3274/// shapes, sub-microsecond. The span length K itself comes from a two-frame
3275/// stability probe (`prev_shapes`): a capture only happens once the leading
3276/// run has already repeated byte-identically across two consecutive frames,
3277/// so churning scenes never pay the extra capture pass every frame — and
3278/// only when the span carries at least one gradient record, so scenes whose
3279/// leading static draws are all solid (cheap fill the blit cannot beat)
3280/// never engage at all.
3281#[cfg(not(target_arch = "wasm32"))]
3282#[derive(Default)]
3283struct StaticSpanCache {
3284    /// The captured span composite, same size and format as the frame
3285    /// target. Held out of the offscreen pool across frames; released back
3286    /// through the deferred-release path on resize.
3287    texture: Option<OffscreenTarget>,
3288    /// Validity key: the span's converted `ShapeData` records at capture.
3289    key_shapes: Vec<ShapeData>,
3290    /// Validity key: the span's gradient stop payload at capture.
3291    key_gradients: Vec<GradientStop>,
3292    key_width: u32,
3293    key_height: u32,
3294    /// The frame clear color the capture pass cleared with — pixels the
3295    /// span shapes do not fully cover composite against it, so a different
3296    /// clear invalidates the image even when every shape byte matches.
3297    key_clear: [u64; 4],
3298    /// The live first batch's whole-batch `has_gradient` flag at capture:
3299    /// it selects the `fs_solid` vs gradient pipeline variant for every
3300    /// shape in the batch, so the capture is only valid while the live
3301    /// batch would draw the span through the same variant.
3302    key_has_gradient: bool,
3303    /// Last frame's leading records — the two-frame stability probe that
3304    /// decides the span length at capture time.
3305    prev_shapes: Vec<ShapeData>,
3306    prev_gradients: Vec<GradientStop>,
3307    /// Consecutive hit frames whose stable leading run extended past the
3308    /// current key — the upgrade hysteresis counter.
3309    extension_stable_frames: u32,
3310    /// Set once per frame by [`GpuRenderer::render`], consumed by the first
3311    /// fused partition that carries the frame's opaque clear, so offscreen
3312    /// layer or shadow renders (transparent clears) can never engage and a
3313    /// frame engages at most once.
3314    armed: bool,
3315    hits: u64,
3316    recaptures: u64,
3317}
3318
3319#[cfg(not(target_arch = "wasm32"))]
3320impl StaticSpanCache {
3321    /// One engagement attempt per frame, at fused-partition time.
3322    /// `first_batch` is the chunk's first batch when it is a shape batch:
3323    /// (shape count, blend mode, whole-batch has_gradient). `shapes` /
3324    /// `gradients` are the partition's freshly converted scratch buffers,
3325    /// whose leading records belong to the first batch.
3326    fn engage(
3327        &mut self,
3328        load_op: wgpu::LoadOp<wgpu::Color>,
3329        first_batch: Option<(usize, BlendMode, bool)>,
3330        width: u32,
3331        height: u32,
3332        shapes: &[ShapeData],
3333        gradients: &[GradientStop],
3334    ) -> StaticSpanDecision {
3335        if !self.armed || !static_span_enabled() {
3336            return StaticSpanDecision::Pass;
3337        }
3338        let wgpu::LoadOp::Clear(clear) = load_op else {
3339            return StaticSpanDecision::Pass;
3340        };
3341        // The frame's leading clear is the only opaque one a frame stream
3342        // carries (layer and shadow sources clear transparent); engagement
3343        // happens here or not at all this frame.
3344        if clear.a != 1.0 {
3345            return StaticSpanDecision::Pass;
3346        }
3347        self.armed = false;
3348        let Some((batch_len, blend_mode, has_gradient)) = first_batch else {
3349            self.forget_observation();
3350            return StaticSpanDecision::Pass;
3351        };
3352        // SrcOver only: the alpha == 255 argument above is an SrcOver
3353        // property.
3354        if blend_mode != BlendMode::SrcOver || batch_len == 0 {
3355            self.forget_observation();
3356            return StaticSpanDecision::Pass;
3357        }
3358        let leading = &shapes[..batch_len.min(STATIC_SPAN_MAX_SHAPES).min(shapes.len())];
3359        if leading.is_empty() {
3360            self.forget_observation();
3361            return StaticSpanDecision::Pass;
3362        }
3363        if !static_span_fullscreen_opaque(&leading[0], width, height) {
3364            self.forget_observation();
3365            return StaticSpanDecision::Pass;
3366        }
3367        // The span ends at the first shape the capture pass could not
3368        // reproduce through the plain instanced arm (rim-mesh candidates).
3369        let mut eligible = 1;
3370        while eligible < leading.len() && rim_mesh_band(&leading[eligible]).is_none() {
3371            eligible += 1;
3372        }
3373        let leading = &leading[..eligible];
3374        let clear_key = [
3375            clear.r.to_bits(),
3376            clear.g.to_bits(),
3377            clear.b.to_bits(),
3378            clear.a.to_bits(),
3379        ];
3380
3381        let key_len = self.key_shapes.len();
3382        let valid = self.texture.is_some()
3383            && key_len > 0
3384            && key_len <= leading.len()
3385            && self.key_width == width
3386            && self.key_height == height
3387            && self.key_clear == clear_key
3388            && self.key_has_gradient == has_gradient
3389            && span_records_equal(
3390                &self.key_shapes,
3391                &leading[..key_len],
3392                &self.key_gradients,
3393                gradients,
3394            );
3395
3396        // Stability probe, shared by miss-capture and hit-upgrade: the
3397        // longest leading run whose record AND gradient bytes repeat from
3398        // last frame.
3399        let mut stable = 0;
3400        while stable < leading.len()
3401            && stable < self.prev_shapes.len()
3402            && span_records_equal(
3403                &self.prev_shapes[stable..stable + 1],
3404                &leading[stable..stable + 1],
3405                &self.prev_gradients,
3406                gradients,
3407            )
3408        {
3409            stable += 1;
3410        }
3411        self.remember_observation(leading, gradients);
3412
3413        if valid {
3414            // Upgrade hysteresis: a valid span may EXTEND (a partial
3415            // invalidation — say a vignette-only palette change — shrank an
3416            // earlier capture, and the tail has stabilized again) only after
3417            // the extension repeats for a full window of consecutive
3418            // frames. Without it, a leading shape animating with a period
3419            // of a few frames would alternate upgrade-capture and
3420            // shrink-capture forever — capture-churn instead of caching.
3421            // The initial capture below takes no window because the whole
3422            // span stabilizing at once is the cold-start common case. No
3423            // gradient gate here: the stable prefix contains the key, and
3424            // every stored key carries a gradient record.
3425            if stable > key_len {
3426                self.extension_stable_frames += 1;
3427                if self.extension_stable_frames >= STATIC_SPAN_UPGRADE_FRAMES {
3428                    self.extension_stable_frames = 0;
3429                    return StaticSpanDecision::Capture { len: stable, clear };
3430                }
3431            } else {
3432                self.extension_stable_frames = 0;
3433            }
3434            self.hits += 1;
3435            if self.hits.is_multiple_of(600) {
3436                log::debug!(
3437                    "[static-span] {} hits / {} recaptures lifetime (span {} shapes, {}x{})",
3438                    self.hits,
3439                    self.recaptures,
3440                    key_len,
3441                    width,
3442                    height,
3443                );
3444            }
3445            return StaticSpanDecision::Hit { skip: key_len };
3446        }
3447
3448        self.extension_stable_frames = 0;
3449        // Engagement economics: a candidate span with no gradient records
3450        // would replace the cheapest fill there is (solid quads) with a
3451        // same-size texture blit — a wash at best on a mobile GPU, plus a
3452        // held full-target texture and a capture pass. The fill this stage
3453        // chases is the gradient+dither span, so a capture must carry at
3454        // least one gradient record. This also keeps solid-background-only
3455        // frames (most non-game screens) from ever paying an offscreen
3456        // acquire.
3457        if stable == 0 || span_gradient_len(&leading[..stable]) == 0 {
3458            return StaticSpanDecision::Pass;
3459        }
3460        StaticSpanDecision::Capture { len: stable, clear }
3461    }
3462
3463    /// Stores this frame's leading run for next frame's stability probe.
3464    fn remember_observation(&mut self, leading: &[ShapeData], gradients: &[GradientStop]) {
3465        self.prev_shapes.clear();
3466        self.prev_shapes.extend_from_slice(leading);
3467        let stop_len = span_gradient_len(leading);
3468        self.prev_gradients.clear();
3469        self.prev_gradients
3470            .extend_from_slice(&gradients[..stop_len]);
3471    }
3472
3473    fn forget_observation(&mut self) {
3474        self.prev_shapes.clear();
3475        self.prev_gradients.clear();
3476        self.extension_stable_frames = 0;
3477    }
3478
3479    /// Adopts a freshly captured span as the validity key. The caller has
3480    /// already encoded the capture pass into `texture`.
3481    #[allow(clippy::too_many_arguments)]
3482    fn store_key(
3483        &mut self,
3484        span: &[ShapeData],
3485        gradients: &[GradientStop],
3486        width: u32,
3487        height: u32,
3488        clear: wgpu::Color,
3489        has_gradient: bool,
3490    ) {
3491        self.key_shapes.clear();
3492        self.key_shapes.extend_from_slice(span);
3493        let stop_len = span_gradient_len(span);
3494        self.key_gradients.clear();
3495        self.key_gradients.extend_from_slice(&gradients[..stop_len]);
3496        self.key_width = width;
3497        self.key_height = height;
3498        self.key_clear = [
3499            clear.r.to_bits(),
3500            clear.g.to_bits(),
3501            clear.b.to_bits(),
3502            clear.a.to_bits(),
3503        ];
3504        self.key_has_gradient = has_gradient;
3505        self.recaptures += 1;
3506        if self.recaptures.is_multiple_of(64) || self.recaptures == 1 {
3507            log::debug!(
3508                "[static-span] recapture #{} (span {} shapes, {} stops, {}x{}; {} hits lifetime)",
3509                self.recaptures,
3510                self.key_shapes.len(),
3511                self.key_gradients.len(),
3512                width,
3513                height,
3514                self.hits,
3515            );
3516        }
3517    }
3518}
3519
3520/// Total gradient stops a leading span consumes. The span is a prefix of
3521/// the fused upload, so its stop payload is exactly the leading
3522/// `sum(gradient_count)` entries of the scratch gradient buffer.
3523#[cfg(not(target_arch = "wasm32"))]
3524fn span_gradient_len(span: &[ShapeData]) -> usize {
3525    span.iter().map(|shape| shape.gradient_count as usize).sum()
3526}
3527
3528/// Byte equality of two span record runs INCLUDING their gradient stop
3529/// payloads. Each record's stops live at
3530/// `gradient_start..gradient_start + gradient_count` in its frame's leading
3531/// gradient buffer; `gradient_start`/`gradient_count` are part of the
3532/// memcmp'd record bytes, so matching records address matching stop ranges
3533/// in both buffers.
3534#[cfg(not(target_arch = "wasm32"))]
3535fn span_records_equal(
3536    expected: &[ShapeData],
3537    actual: &[ShapeData],
3538    expected_gradients: &[GradientStop],
3539    actual_gradients: &[GradientStop],
3540) -> bool {
3541    if bytemuck::cast_slice::<ShapeData, u8>(expected)
3542        != bytemuck::cast_slice::<ShapeData, u8>(actual)
3543    {
3544        return false;
3545    }
3546    for shape in expected {
3547        let start = shape.gradient_start as usize;
3548        let end = start + shape.gradient_count as usize;
3549        if end > expected_gradients.len() || end > actual_gradients.len() {
3550            return false;
3551        }
3552        if bytemuck::cast_slice::<GradientStop, u8>(&expected_gradients[start..end])
3553            != bytemuck::cast_slice::<GradientStop, u8>(&actual_gradients[start..end])
3554        {
3555            return false;
3556        }
3557    }
3558    true
3559}
3560
3561/// Whether a converted record is the full-screen opaque base the span
3562/// mechanism keys on: a plain solid fill (no stroke, no arc, no gradient,
3563/// no clip, no corner rounding) whose axis-aligned quad covers the whole
3564/// `width` x `height` target with alpha exactly 1.0. Soundness does not
3565/// strictly need full coverage — the opaque clear already makes the
3566/// composite alpha 255 — but requiring the measured scene shape keeps the
3567/// cache from engaging on frames whose leading draw is not the static
3568/// background this stage was built for.
3569#[cfg(not(target_arch = "wasm32"))]
3570fn static_span_fullscreen_opaque(shape: &ShapeData, width: u32, height: u32) -> bool {
3571    if shape.brush_type != 0 || shape.gradient_count != 0 {
3572        return false;
3573    }
3574    if shape.color[3] != 1.0 {
3575        return false;
3576    }
3577    if shape.clip_rect != [0.0; 4] || shape.stroke_params != [0.0; 4] || shape.radii != [0.0; 4] {
3578        return false;
3579    }
3580    // Same corner layout as `rim_mesh_band`: quad01 = TL.xy, TR.xy;
3581    // quad23 = BL.xy, BR.xy.
3582    let [left, top, right, top_right_y] = shape.quad01;
3583    let [bl_x, bottom, br_x, br_y] = shape.quad23;
3584    let axis_aligned = top_right_y == top
3585        && bl_x == left
3586        && br_x == right
3587        && br_y == bottom
3588        && left < right
3589        && top < bottom;
3590    axis_aligned && left <= 0.0 && top <= 0.0 && right >= width as f32 && bottom >= height as f32
3591}
3592
3593/// One Sutherland–Hodgman pass against an axis-aligned half-plane.
3594///
3595/// Two properties the byte-exactness bar depends on:
3596/// * the clipped coordinate is set to `bound` EXACTLY rather than recomputed
3597///   through `p + t * (q - p)`, so every clipped polygon's boundary lies
3598///   bitwise on the clip line;
3599/// * the intersection is computed on the lexicographically ordered endpoint
3600///   pair, so the shared radial edge of two adjacent trapezoids — traversed
3601///   in opposite directions — clips to bitwise-identical points, keeping the
3602///   strip watertight (no pixel shaded twice or missed along the seam).
3603#[cfg(not(target_arch = "wasm32"))]
3604fn clip_polygon_axis(
3605    input: &[[f32; 2]],
3606    axis: usize,
3607    bound: f32,
3608    keep_at_most: bool,
3609    output: &mut Vec<[f32; 2]>,
3610) {
3611    output.clear();
3612    let inside = |p: [f32; 2]| {
3613        if keep_at_most {
3614            p[axis] <= bound
3615        } else {
3616            p[axis] >= bound
3617        }
3618    };
3619    let intersect = |a: [f32; 2], b: [f32; 2]| {
3620        let (p, q) = if (b[0], b[1]) < (a[0], a[1]) {
3621            (b, a)
3622        } else {
3623            (a, b)
3624        };
3625        let t = (bound - p[axis]) / (q[axis] - p[axis]);
3626        let mut point = [0.0f32; 2];
3627        point[axis] = bound;
3628        point[1 - axis] = p[1 - axis] + t * (q[1 - axis] - p[1 - axis]);
3629        point
3630    };
3631    for (index, &current) in input.iter().enumerate() {
3632        let previous = input[(index + input.len() - 1) % input.len()];
3633        match (inside(previous), inside(current)) {
3634            (true, true) => output.push(current),
3635            (true, false) => output.push(intersect(previous, current)),
3636            (false, true) => {
3637                output.push(intersect(previous, current));
3638                output.push(current);
3639            }
3640            (false, false) => {}
3641        }
3642    }
3643}
3644
3645/// Emits the conservative trapezoid-strip mesh for one qualifying arc band.
3646///
3647/// CONTAINMENT INVARIANT (the byte-exactness bar): the union of emitted
3648/// triangles is a superset of `{ p in the capture quad's box :
3649/// sdf_arc_band(p) <= 0.5 }` — every pixel the fragment shader would keep.
3650/// Over-inclusion is free (the SDF discards those pixels identically to
3651/// today's quad); only under-inclusion can diverge, and
3652/// `arc_mesh_contains_every_band_pixel` checks it never happens.
3653///
3654/// Geometry: outer vertices ride at `Ro / cos(step / 2)` so every chord is
3655/// tangent-outside the dilated outer circle; inner vertices ride at the
3656/// dilated inner radius, whose chords lie inside the hole. Cap coverage is
3657/// bounded by the round-cap disc about the band endpoint (butt/square caps
3658/// only cut that disc with planes — see `sdf_arc_band`), so padding the
3659/// angular range by the disc's angular half-extent contains every cap. Each
3660/// trapezoid is clipped to the quad box and fan-triangulated IN INDEX SPACE:
3661/// a trapezoid the clipper left untouched shares its two boundary vertices
3662/// with each neighbor through the index list (closed rings wrap the sharing
3663/// modulo the boundary count), so the strip is watertight by construction —
3664/// the seam edge is one vertex pair, not two bitwise-equal copies — and the
3665/// per-arc vertex count collapses from three-per-triangle to the unique
3666/// boundary vertices. Clipped trapezoids cannot share boundary vertices (the
3667/// clipper rewrote them), so their fan vertices are appended PRIVATELY after
3668/// the shared block and indexed directly; seams against neighbors still hold
3669/// because a boundary edge either survives the clip on both sides
3670/// bitwise-identically (same input edge, same planes, same float ops — see
3671/// `clip_polygon_axis`) or is cut on both sides identically. Triangles are
3672/// emitted in exact segment order either way, so the indexed mesh's
3673/// primitive stream is triangle-for-triangle the one the non-indexed
3674/// emitter produced.
3675///
3676/// Returns the emitted segment count, or `None` when the mesh came out empty
3677/// — the caller emits the passthrough quad instead (never risk
3678/// under-coverage).
3679#[cfg(not(target_arch = "wasm32"))]
3680fn emit_arc_band_mesh(
3681    shape: &ShapeData,
3682    shape_idx: u32,
3683    band: &ArcMeshBand,
3684    vertices: &mut Vec<MeshVertex>,
3685    indices: &mut Vec<u32>,
3686) -> Option<usize> {
3687    let [cx, cy] = band.center;
3688    let ra = (band.outer + band.inner) * 0.5;
3689    let rb = ((band.outer - band.inner) * 0.5).max(0.0);
3690    let rb_m = rb + ARC_MESH_MARGIN;
3691    let ro = ra + rb_m;
3692    let ri = (ra - rb_m).max(0.0);
3693    let tau = cranpose_ui_graphics::TAU;
3694
3695    let (range_start, range) = if band.sweep >= tau {
3696        (0.0, tau)
3697    } else {
3698        let pad = if rb_m < ra {
3699            (rb_m / ra).asin() + 0.05
3700        } else {
3701            // The cap disc wraps the center; such shapes are tiny, take the
3702            // whole circle.
3703            std::f32::consts::PI
3704        };
3705        let padded = band.sweep + pad + pad;
3706        if padded >= tau {
3707            (0.0, tau)
3708        } else {
3709            (band.start - pad, padded)
3710        }
3711    };
3712    let closed = range >= tau;
3713
3714    let dtheta = (2.0 * (ro / (ro + ARC_MESH_OVERSHOOT)).acos()).clamp(tau / 64.0, tau / 6.0);
3715    let segments =
3716        ((range / dtheta).ceil() as usize).clamp(ARC_MESH_MIN_SEGMENTS, ARC_MESH_MAX_SEGMENTS);
3717    let step = range / segments as f32;
3718    let rc = ro / (step * 0.5).cos();
3719
3720    // Boundary vertices are computed once and shared by both adjacent
3721    // trapezoids: bitwise-equal edge endpoints are what let the rasterizer's
3722    // fill rule shade each seam exactly once.
3723    let boundary_count = if closed { segments } else { segments + 1 };
3724    let mut boundaries = Vec::with_capacity(boundary_count);
3725    for j in 0..boundary_count {
3726        let (sin, cos) = (range_start + step * j as f32).sin_cos();
3727        boundaries.push((
3728            [cx + cos * ri, cy + sin * ri],
3729            [cx + cos * rc, cy + sin * rc],
3730        ));
3731    }
3732
3733    let quad_min = [shape.quad01[0], shape.quad01[1]];
3734    let quad_max = [shape.quad23[2], shape.quad23[3]];
3735
3736    /// One trapezoid's clip outcome (see the function docs): `Shared` means
3737    /// the clip output is bitwise the input quad, so its corners index the
3738    /// shared boundary block; `Fan` carries the clipped polygon for private
3739    /// fan triangulation; `Empty` was clipped away entirely.
3740    enum SegmentGeometry {
3741        Shared,
3742        Fan(Vec<[f32; 2]>),
3743        Empty,
3744    }
3745
3746    // Phase 1: clip every trapezoid and classify it.
3747    let mut polygon: Vec<[f32; 2]> = Vec::with_capacity(8);
3748    let mut scratch: Vec<[f32; 2]> = Vec::with_capacity(8);
3749    let mut segment_geometry = Vec::with_capacity(segments);
3750    let mut boundary_used = vec![false; boundary_count];
3751    for j in 0..segments {
3752        let jb = (j + 1) % boundary_count;
3753        let (inner_a, outer_a) = boundaries[j];
3754        let (inner_b, outer_b) = boundaries[jb];
3755        polygon.clear();
3756        polygon.extend_from_slice(&[inner_a, outer_a, outer_b, inner_b]);
3757        clip_polygon_axis(&polygon, 0, quad_min[0], false, &mut scratch);
3758        clip_polygon_axis(&scratch, 0, quad_max[0], true, &mut polygon);
3759        clip_polygon_axis(&polygon, 1, quad_min[1], false, &mut scratch);
3760        clip_polygon_axis(&scratch, 1, quad_max[1], true, &mut polygon);
3761        // Collapse exact duplicates (an `Ri == 0` pie wedge duplicates the
3762        // center) before fanning.
3763        scratch.clear();
3764        for &point in polygon.iter() {
3765            if scratch.last() != Some(&point) {
3766                scratch.push(point);
3767            }
3768        }
3769        while scratch.len() > 1 && scratch.first() == scratch.last() {
3770            scratch.pop();
3771        }
3772        if scratch.len() < 3 {
3773            segment_geometry.push(SegmentGeometry::Empty);
3774        } else if scratch[..] == [inner_a, outer_a, outer_b, inner_b] {
3775            boundary_used[j] = true;
3776            boundary_used[jb] = true;
3777            segment_geometry.push(SegmentGeometry::Shared);
3778        } else {
3779            segment_geometry.push(SegmentGeometry::Fan(scratch.clone()));
3780        }
3781    }
3782
3783    let push_vertex = |vertices: &mut Vec<MeshVertex>, position: [f32; 2]| -> u32 {
3784        let index = vertices.len() as u32;
3785        vertices.push(MeshVertex {
3786            position,
3787            uv: [
3788                (position[0] - shape.rect[0]) / shape.rect[2],
3789                (position[1] - shape.rect[1]) / shape.rect[3],
3790            ],
3791            shape_idx,
3792        });
3793        index
3794    };
3795
3796    // Shared block: every boundary referenced by a surviving whole trapezoid
3797    // gets its (inner, outer) vertex pair exactly once, in boundary order.
3798    let mut boundary_vertex = vec![[0u32; 2]; boundary_count];
3799    for (j, used) in boundary_used.iter().enumerate() {
3800        if *used {
3801            let (inner, outer) = boundaries[j];
3802            boundary_vertex[j] = [push_vertex(vertices, inner), push_vertex(vertices, outer)];
3803        }
3804    }
3805
3806    // Phase 2: indices in exact segment order — the primitive stream matches
3807    // the non-indexed emitter triangle for triangle.
3808    let start_len = indices.len();
3809    for (j, geometry) in segment_geometry.iter().enumerate() {
3810        match geometry {
3811            SegmentGeometry::Empty => {}
3812            SegmentGeometry::Shared => {
3813                let jb = (j + 1) % boundary_count;
3814                let [in_a, out_a] = boundary_vertex[j];
3815                let [in_b, out_b] = boundary_vertex[jb];
3816                // The fan the non-indexed emitter produced for an untouched
3817                // trapezoid: (in_a, out_a, out_b)(in_a, out_b, in_b) — the
3818                // same quad diagonal.
3819                indices.extend_from_slice(&[in_a, out_a, out_b, in_a, out_b, in_b]);
3820            }
3821            SegmentGeometry::Fan(points) => {
3822                let base = vertices.len() as u32;
3823                for &point in points {
3824                    push_vertex(vertices, point);
3825                }
3826                for i in 1..points.len() as u32 - 1 {
3827                    indices.extend_from_slice(&[base, base + i, base + i + 1]);
3828                }
3829            }
3830        }
3831    }
3832    if indices.len() == start_len {
3833        return None;
3834    }
3835    Some(segments)
3836}
3837
3838/// Unsigned shoelace area of an emitted indexed triangle list, for
3839/// telemetry.
3840#[cfg(not(target_arch = "wasm32"))]
3841fn triangles_shoelace_area(vertices: &[MeshVertex], indices: &[u32]) -> f64 {
3842    indices
3843        .as_chunks::<3>()
3844        .0
3845        .iter()
3846        .map(|tri| {
3847            let [a, b, c] = [
3848                vertices[tri[0] as usize].position,
3849                vertices[tri[1] as usize].position,
3850                vertices[tri[2] as usize].position,
3851            ];
3852            let cross = (b[0] as f64 - a[0] as f64) * (c[1] as f64 - a[1] as f64)
3853                - (b[1] as f64 - a[1] as f64) * (c[0] as f64 - a[0] as f64);
3854            cross.abs() * 0.5
3855        })
3856        .sum()
3857}
3858
3859/// Unsigned area of the two triangles the quad-expansion path would rasterize for
3860/// this shape, for telemetry.
3861#[cfg(not(target_arch = "wasm32"))]
3862fn quad_shoelace_area(shape: &ShapeData) -> f64 {
3863    let corners = [
3864        [shape.quad01[0] as f64, shape.quad01[1] as f64],
3865        [shape.quad01[2] as f64, shape.quad01[3] as f64],
3866        [shape.quad23[0] as f64, shape.quad23[1] as f64],
3867        [shape.quad23[2] as f64, shape.quad23[3] as f64],
3868    ];
3869    let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
3870        ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
3871    };
3872    tri(corners[0], corners[1], corners[2]) + tri(corners[2], corners[1], corners[3])
3873}
3874
3875/// `CRANPOSE_FILL_DIAG` (`debug.cranpose.fill_diag` on Android): per-frame
3876/// CPU-side accounting of the fill area the renderer submits, in device px².
3877/// Off by default; any set value except "0" enables. Read once per process,
3878/// so a disabled hot path pays one static load and a branch.
3879#[cfg(not(target_arch = "wasm32"))]
3880pub(crate) fn fill_area_diag_enabled() -> bool {
3881    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3882    *ENABLED.get_or_init(
3883        || matches!(std::env::var("CRANPOSE_FILL_DIAG").as_deref(), Ok(value) if value != "0"),
3884    )
3885}
3886
3887/// Rendered frames aggregated into one `[fill-diag]` report line.
3888#[cfg(not(target_arch = "wasm32"))]
3889const FILL_DIAG_WINDOW_FRAMES: u32 = 120;
3890
3891#[cfg(not(target_arch = "wasm32"))]
3892const FILL_DIAG_BUCKETS: usize = 9;
3893
3894/// Opacity class of a shape's fill for the `[fill-truth]` histogram, decided
3895/// from the CONVERTED record: a solid brush with vertex alpha exactly 1.0 is
3896/// opaque, any other solid is translucent, and every gradient counts as
3897/// non-solid (its stops can each carry their own alpha). Retained shapes are
3898/// classified from their capture-time colors — a later recolor patch through
3899/// the slot's paint buffer is not re-classified.
3900#[cfg(not(target_arch = "wasm32"))]
3901#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3902enum FillOpacityClass {
3903    Opaque = 0,
3904    Translucent = 1,
3905    NonSolid = 2,
3906}
3907
3908#[cfg(not(target_arch = "wasm32"))]
3909fn fill_opacity_class(shape: &ShapeData) -> FillOpacityClass {
3910    if shape.brush_type != 0 {
3911        FillOpacityClass::NonSolid
3912    } else if shape.color[3] == 1.0 {
3913        FillOpacityClass::Opaque
3914    } else {
3915        FillOpacityClass::Translucent
3916    }
3917}
3918
3919/// The fill-diag bucket of a batched shape quad, decoded from the packed
3920/// flags the way the fragment shader decodes them (`u32(max(x, 0.0)) & 3`).
3921/// Fills keep real corner radii in `radii` (arcs reuse the field for trig,
3922/// but they take the arc arm first).
3923#[cfg(not(target_arch = "wasm32"))]
3924fn fill_diag_bucket(shape: &ShapeData) -> usize {
3925    match shape.stroke_params[1].max(0.0) as u32 & 3 {
3926        SHAPE_KIND_ARC => FillAreaDiag::ARC,
3927        SHAPE_KIND_STROKE => FillAreaDiag::RRECT_STROKE,
3928        _ if shape.radii.iter().any(|radius| *radius > 0.0) => FillAreaDiag::RRECT_FILL,
3929        _ => FillAreaDiag::RECT,
3930    }
3931}
3932
3933#[cfg(not(target_arch = "wasm32"))]
3934fn fill_diag_bucket_name(bucket: usize) -> &'static str {
3935    match bucket {
3936        FillAreaDiag::ARC => "arc",
3937        FillAreaDiag::RRECT_STROKE => "rrect-stroke",
3938        FillAreaDiag::RRECT_FILL => "rrect-fill",
3939        FillAreaDiag::RECT => "rect",
3940        FillAreaDiag::MESH => "mesh",
3941        FillAreaDiag::RETAINED => "retained",
3942        FillAreaDiag::IMAGE_GLYPH => "img+glyph",
3943        FillAreaDiag::EFFECT_COMPOSITE => "effect-comp",
3944        FillAreaDiag::OFFSCREEN_SOURCE => "offscr-src",
3945        _ => "?",
3946    }
3947}
3948
3949/// Analytic covered area of a shape in device px² — the pixels the SDF will
3950/// actually keep, as opposed to the bounding quad it is rasterized with —
3951/// decoded from the same converted `ShapeData` fields the classifier and the
3952/// band-mesh builders read. Deliberately closed-form per class:
3953///
3954/// * arc / annular sector: `sweep · r_mid · thickness` plus the endcap area
3955///   (two half-discs for round caps; square caps rasterize the same pixel
3956///   measure — `sdf_arc_band` cuts the endpoint disc at `plane − rb`, which
3957///   removes nothing but the tangent point; butt caps add nothing; a closed
3958///   ring has no caps).
3959/// * stroked round-rect: centerline perimeter × stroke width — exact while
3960///   every corner radius ≥ half the stroke width (the offset-band identity);
3961///   miter corner spurs at sharp corners are not modeled.
3962/// * round-rect / circle fill: `w·h − (1 − π/4)·Σ rᵢ²`, radii clamped to the
3963///   half-extent (a circle degenerates to exactly `π r²`).
3964/// * plain rect: the submitted quad IS the covered set — priced at the quad
3965///   area by the caller, this function returns `w·h` (equal under any
3966///   similarity).
3967///
3968/// Clips and viewport scissors are not modeled, same as the quad accounting.
3969#[cfg(not(target_arch = "wasm32"))]
3970fn analytic_covered_area(shape: &ShapeData) -> f64 {
3971    let flags = shape.stroke_params[1].max(0.0) as u32;
3972    match flags & 3 {
3973        SHAPE_KIND_ARC => {
3974            let outer = f64::from(shape.stroke_params[2]).max(0.0);
3975            let inner = f64::from(shape.stroke_params[3]).clamp(0.0, outer);
3976            let tau = f64::from(cranpose_ui_graphics::TAU);
3977            let sweep = f64::from(shape.arc_params[3]).clamp(0.0, tau);
3978            let thickness = outer - inner;
3979            let band = sweep * 0.5 * (outer + inner) * thickness;
3980            let caps = if sweep >= tau {
3981                0.0
3982            } else {
3983                match (flags >> 2) & 3 {
3984                    // Round and square: two half-discs of radius t/2 — the
3985                    // shader's square cap keeps the endpoint disc's measure
3986                    // (see the doc comment).
3987                    1 | 2 => std::f64::consts::PI * (thickness * 0.5) * (thickness * 0.5),
3988                    _ => 0.0,
3989                }
3990            };
3991            band + caps
3992        }
3993        SHAPE_KIND_STROKE => {
3994            let stroke_width = f64::from(shape.stroke_params[0]).max(0.0);
3995            // `rect` for a stroked shape is the stroke-inflated box.
3996            let geom_w = (f64::from(shape.rect[2]) - stroke_width).max(0.0);
3997            let geom_h = (f64::from(shape.rect[3]) - stroke_width).max(0.0);
3998            let max_radius = geom_w.min(geom_h) * 0.5;
3999            let radii_sum: f64 = shape
4000                .radii
4001                .iter()
4002                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4003                .sum();
4004            let perimeter =
4005                2.0 * (geom_w + geom_h) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum;
4006            perimeter.max(0.0) * stroke_width
4007        }
4008        _ => {
4009            let width = f64::from(shape.rect[2]).max(0.0);
4010            let height = f64::from(shape.rect[3]).max(0.0);
4011            let max_radius = width.min(height) * 0.5;
4012            let radii_sq: f64 = shape
4013                .radii
4014                .iter()
4015                .map(|radius| {
4016                    let radius = f64::from(*radius).clamp(0.0, max_radius);
4017                    radius * radius
4018                })
4019                .sum();
4020            width * height - (1.0 - std::f64::consts::FRAC_PI_4) * radii_sq
4021        }
4022    }
4023}
4024
4025/// Antialiasing allowance added on top of [`analytic_covered_area`]: the SDF
4026/// feathers over roughly one pixel of boundary, so ~1 px × the covered set's
4027/// perimeter approximates the partially-lit fringe. Plain rects get none
4028/// (their quad is exact); a stroked shape has two boundary curves, whose
4029/// perimeters sum to twice the centerline perimeter for a convex outline.
4030#[cfg(not(target_arch = "wasm32"))]
4031fn aa_perimeter_allowance(shape: &ShapeData) -> f64 {
4032    let flags = shape.stroke_params[1].max(0.0) as u32;
4033    match flags & 3 {
4034        SHAPE_KIND_ARC => {
4035            let outer = f64::from(shape.stroke_params[2]).max(0.0);
4036            let inner = f64::from(shape.stroke_params[3]).clamp(0.0, outer);
4037            let tau = f64::from(cranpose_ui_graphics::TAU);
4038            let sweep = f64::from(shape.arc_params[3]).clamp(0.0, tau);
4039            let ends = if sweep >= tau {
4040                0.0
4041            } else {
4042                2.0 * (outer - inner)
4043            };
4044            sweep * (outer + inner) + ends
4045        }
4046        SHAPE_KIND_STROKE => {
4047            let stroke_width = f64::from(shape.stroke_params[0]).max(0.0);
4048            let geom_w = (f64::from(shape.rect[2]) - stroke_width).max(0.0);
4049            let geom_h = (f64::from(shape.rect[3]) - stroke_width).max(0.0);
4050            let max_radius = geom_w.min(geom_h) * 0.5;
4051            let radii_sum: f64 = shape
4052                .radii
4053                .iter()
4054                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4055                .sum();
4056            let perimeter =
4057                2.0 * (geom_w + geom_h) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum;
4058            2.0 * perimeter.max(0.0)
4059        }
4060        _ if shape.radii.iter().any(|radius| *radius > 0.0) => {
4061            let width = f64::from(shape.rect[2]).max(0.0);
4062            let height = f64::from(shape.rect[3]).max(0.0);
4063            let max_radius = width.min(height) * 0.5;
4064            let radii_sum: f64 = shape
4065                .radii
4066                .iter()
4067                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4068                .sum();
4069            (2.0 * (width + height) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum).max(0.0)
4070        }
4071        _ => 0.0,
4072    }
4073}
4074
4075/// Analytic lit area: covered pixels plus the AA fringe allowance. Callers
4076/// clamp it to the shape's submitted area — the shader cannot light pixels
4077/// its quad never rasterizes.
4078#[cfg(not(target_arch = "wasm32"))]
4079fn analytic_lit_area(shape: &ShapeData) -> f64 {
4080    analytic_covered_area(shape) + aa_perimeter_allowance(shape)
4081}
4082
4083/// Device-space AABB of a shape's submitted quad: min x, min y, max x, max y.
4084#[cfg(not(target_arch = "wasm32"))]
4085fn quad_aabb(shape: &ShapeData) -> [f64; 4] {
4086    let xs = [
4087        f64::from(shape.quad01[0]),
4088        f64::from(shape.quad01[2]),
4089        f64::from(shape.quad23[0]),
4090        f64::from(shape.quad23[2]),
4091    ];
4092    let ys = [
4093        f64::from(shape.quad01[1]),
4094        f64::from(shape.quad01[3]),
4095        f64::from(shape.quad23[1]),
4096        f64::from(shape.quad23[3]),
4097    ];
4098    let fold = |values: [f64; 4], pick: fn(f64, f64) -> f64| {
4099        values.into_iter().reduce(pick).unwrap_or(0.0)
4100    };
4101    [
4102        fold(xs, f64::min),
4103        fold(ys, f64::min),
4104        fold(xs, f64::max),
4105        fold(ys, f64::max),
4106    ]
4107}
4108
4109/// Vertical strips of the midpoint rule used by
4110/// [`area_outside_inscribed_circle`]. 32 strips keep the chord error under
4111/// ~0.5% for a full-viewport quad — plenty for a corner-waste ratio.
4112#[cfg(not(target_arch = "wasm32"))]
4113const CORNER_FILL_STRIPS: usize = 32;
4114
4115/// Area of an axis-aligned box lying inside the viewport but OUTSIDE the
4116/// inscribed circle (diameter `min(w, h)`, centered) — the pixels a round
4117/// watch display physically cannot show. Approximations, deliberate: the
4118/// submitted quad is replaced by its AABB (exact for the axis-aligned quads
4119/// that dominate full-frame scenes), and the circle chord is integrated with
4120/// [`CORNER_FILL_STRIPS`] midpoint strips instead of closed-form segments.
4121/// On a non-square viewport the side bands beyond the circle count as
4122/// outside too, which is the honest answer for a round display.
4123#[cfg(not(target_arch = "wasm32"))]
4124fn area_outside_inscribed_circle(aabb: [f64; 4], viewport: (u32, u32)) -> f64 {
4125    let viewport_w = f64::from(viewport.0);
4126    let viewport_h = f64::from(viewport.1);
4127    if viewport_w <= 0.0 || viewport_h <= 0.0 {
4128        return 0.0;
4129    }
4130    let x0 = aabb[0].max(0.0);
4131    let y0 = aabb[1].max(0.0);
4132    let x1 = aabb[2].min(viewport_w);
4133    let y1 = aabb[3].min(viewport_h);
4134    if x1 <= x0 || y1 <= y0 {
4135        return 0.0;
4136    }
4137    let center_x = viewport_w * 0.5;
4138    let center_y = viewport_h * 0.5;
4139    let radius = viewport_w.min(viewport_h) * 0.5;
4140    let strip = (x1 - x0) / CORNER_FILL_STRIPS as f64;
4141    let mut outside = 0.0;
4142    for index in 0..CORNER_FILL_STRIPS {
4143        let x = x0 + (index as f64 + 0.5) * strip;
4144        let dx = x - center_x;
4145        let chord_sq = radius * radius - dx * dx;
4146        let inside = if chord_sq > 0.0 {
4147            let half_chord = chord_sq.sqrt();
4148            (y1.min(center_y + half_chord) - y0.max(center_y - half_chord)).max(0.0)
4149        } else {
4150            0.0
4151        };
4152        outside += ((y1 - y0) - inside) * strip;
4153    }
4154    outside
4155}
4156
4157/// Per-shape fill-diag record a replay slot retains at capture, so retained
4158/// draws can be priced per range without re-deriving anything per frame.
4159/// Only built while `CRANPOSE_FILL_DIAG` is on.
4160#[cfg(not(target_arch = "wasm32"))]
4161#[derive(Clone, Copy, Debug)]
4162struct FillDiagShapeRecord {
4163    /// Capture-space area actually submitted for this shape: band-mesh
4164    /// triangle area when the slot replays THIS shape's band, bounding-quad
4165    /// area otherwise (instanced passthrough or meshless slot).
4166    drawn_px2: f64,
4167    /// Analytic lit area ([`analytic_lit_area`]), clamped to `drawn_px2`.
4168    lit_px2: f64,
4169    /// SDF-class bucket ([`fill_diag_bucket`]), for the top-slack dump.
4170    bucket: usize,
4171    opacity: FillOpacityClass,
4172    /// Capture-space AABB of the submitted quad, for the corner counter.
4173    aabb: [f64; 4],
4174}
4175
4176/// Builds a capture's fill-diag records. `mesh` carries the kept arc mesh's
4177/// `(vertices, indices, index_prefix)` when the slot will replay it, so each
4178/// shape is priced by its true triangle area.
4179#[cfg(not(target_arch = "wasm32"))]
4180fn fill_diag_capture_records(
4181    shape_data: &[ShapeData],
4182    mesh: Option<(&[MeshVertex], &[u32], &[u32])>,
4183) -> Vec<FillDiagShapeRecord> {
4184    shape_data
4185        .iter()
4186        .enumerate()
4187        .map(|(index, shape)| {
4188            let drawn_px2 = match mesh {
4189                // An empty index range is a shape the draw walk keeps on the
4190                // instanced-quad path — priced at its bounding quad, exactly
4191                // what that path submits.
4192                Some((vertices, indices, index_prefix))
4193                    if index_prefix[index + 1] > index_prefix[index] =>
4194                {
4195                    let start = index_prefix[index] as usize;
4196                    let end = index_prefix[index + 1] as usize;
4197                    triangles_shoelace_area(vertices, &indices[start..end])
4198                }
4199                _ => quad_shoelace_area(shape),
4200            };
4201            FillDiagShapeRecord {
4202                drawn_px2,
4203                lit_px2: analytic_lit_area(shape).clamp(0.0, drawn_px2),
4204                bucket: fill_diag_bucket(shape),
4205                opacity: fill_opacity_class(shape),
4206                aabb: quad_aabb(shape),
4207            }
4208        })
4209        .collect()
4210}
4211
4212/// One entry of the once-per-process top-slack dump: a retained shape whose
4213/// submitted area most exceeds its lit area.
4214#[cfg(not(target_arch = "wasm32"))]
4215#[derive(Clone, Copy, Debug)]
4216struct FillDiagSlackEntry {
4217    slot: u32,
4218    shape: u32,
4219    bucket: usize,
4220    drawn_px2: f64,
4221    lit_px2: f64,
4222}
4223
4224#[cfg(not(target_arch = "wasm32"))]
4225const FILL_DIAG_SLACK_TOP: usize = 10;
4226
4227/// Submitted-fill-area accounting behind [`fill_area_diag_enabled`]. The
4228/// watch's GPU counters are sepolicy-blocked, but the renderer knows every
4229/// quad it emits, so summing their areas per bucket says where the fragment
4230/// work goes; the point is the RATIO between buckets, and several are
4231/// deliberately approximate where exactness would cost the hot path:
4232///
4233/// * `arc` / `rrect-stroke` / `rrect-fill` / `rect` — batched shape quads by
4234///   decoded SDF class: exact shoelace area of the submitted quads, from the
4235///   fused screen pass and the offscreen layer/shadow-source passes alike.
4236///   Scissors and the SDF's own discards are not modeled. The latched
4237///   instanced-quad path draws these same quads (one instance per shape), so
4238///   instanced draws live in these buckets rather than a separate one.
4239/// * `mesh` — transient rim band meshes: exact triangle area, replacing the
4240///   rim's bounding quad (which is subtracted back out of `rrect-stroke`).
4241/// * `retained` — replay-slot draws: exact capture-space area of the drawn
4242///   shape range (mesh triangles when the slot replays its arc mesh, quads
4243///   otherwise) times the draw's similarity scale squared.
4244/// * `img+glyph` — image quads exactly; glyph atlas quads as width x height.
4245///   A retained glyph run counts every quad of its cached buffer (the
4246///   shared path's per-quad viewport cull is not re-run for it).
4247/// * `effect-comp` — effect-renderer draws into a caller-supplied view:
4248///   composites/blits (incl. batched, projective and masked variants) and
4249///   src-over runtime shader passes. Priced per pass at the dest viewport
4250///   area, clamped by the scissor when one is set (min of the two areas
4251///   stands in for their exact intersection).
4252/// * `offscr-src` — passes rendering INTO offscreen chain textures: blur
4253///   ping-pong axis passes, offset passes, replace-mode shader passes, and
4254///   the shadow-source target passes of `encode_shadow_shape_source_passes`
4255///   (the whole bounds-sized target per pass — its load/store round trip —
4256///   on top of the shape quads it draws, which the SDF-class buckets price
4257///   as usual).
4258///
4259/// The `[fill-truth]` line splits every bucket into analytic lit vs slack
4260/// (`lit` per [`analytic_lit_area`], `slack = submitted − lit`, clamped
4261/// non-negative; effect passes are all-lit by definition), histograms lit
4262/// pixels by [`FillOpacityClass`] (shape buckets only — image/glyph and
4263/// effect fill has no CPU-known alpha and is excluded), and prices the
4264/// full-frame corner waste per [`area_outside_inscribed_circle`]. The corner
4265/// counter covers full-frame shape batches and identity-transform retained
4266/// draws; meshed rims stay priced by their bounding AABB there (documented
4267/// overcount), and image/glyph quads are excluded.
4268///
4269/// Not counted: frame-graph layer clears/attachments outside the effect
4270/// renderer's own draw sites.
4271#[cfg(not(target_arch = "wasm32"))]
4272#[derive(Default)]
4273struct FillAreaDiag {
4274    /// Current frame's per-bucket submitted area, device px². `Cell`s
4275    /// because draw encoding accumulates through `&self`, the same pattern
4276    /// as [`gpu_stats::FrameStats`].
4277    frame: [std::cell::Cell<f64>; FILL_DIAG_BUCKETS],
4278    /// Current frame's per-bucket analytic lit area, ≤ the submitted area.
4279    frame_lit: [std::cell::Cell<f64>; FILL_DIAG_BUCKETS],
4280    /// Current frame's lit area by [`FillOpacityClass`], shape buckets only.
4281    frame_opacity: [std::cell::Cell<f64>; 3],
4282    /// Current frame's full-frame fill outside the inscribed circle.
4283    frame_corner: std::cell::Cell<f64>,
4284    /// The frame's surface size, latched by [`Self::reset_frame`] — the
4285    /// full-frame-pass gate and the inscribed circle both derive from it.
4286    viewport: std::cell::Cell<(u32, u32)>,
4287    /// Window totals, folded once per frame by [`Self::finish_frame`].
4288    window: [f64; FILL_DIAG_BUCKETS],
4289    window_lit: [f64; FILL_DIAG_BUCKETS],
4290    window_opacity: [f64; 3],
4291    window_corner: f64,
4292    window_frames: u32,
4293    /// Worst retained shapes by slack, collected at slot capture and dumped
4294    /// once with the first report window that has any (then dropped).
4295    slack_top: Vec<FillDiagSlackEntry>,
4296    slack_dumped: bool,
4297}
4298
4299#[cfg(not(target_arch = "wasm32"))]
4300impl FillAreaDiag {
4301    const ARC: usize = 0;
4302    const RRECT_STROKE: usize = 1;
4303    const RRECT_FILL: usize = 2;
4304    const RECT: usize = 3;
4305    const MESH: usize = 4;
4306    const RETAINED: usize = 5;
4307    const IMAGE_GLYPH: usize = 6;
4308    const EFFECT_COMPOSITE: usize = 7;
4309    const OFFSCREEN_SOURCE: usize = 8;
4310
4311    fn add(&self, bucket: usize, area_px2: f64) {
4312        let cell = &self.frame[bucket];
4313        cell.set(cell.get() + area_px2);
4314    }
4315
4316    fn add_lit(&self, bucket: usize, lit_px2: f64) {
4317        let cell = &self.frame_lit[bucket];
4318        cell.set(cell.get() + lit_px2);
4319    }
4320
4321    fn add_corner(&self, px2: f64) {
4322        self.frame_corner.set(self.frame_corner.get() + px2);
4323    }
4324
4325    /// Whether a batch's viewport IS this frame's surface — the gate for the
4326    /// corner counter (offscreen shadow/layer passes carry their own bounds
4327    /// viewport and never qualify).
4328    fn is_full_frame(&self, viewport: ViewportUniformParams) -> bool {
4329        let (width, height) = self.viewport.get();
4330        width > 0
4331            && height > 0
4332            && viewport.width == width
4333            && viewport.height == height
4334            && viewport.offset == [0.0, 0.0]
4335    }
4336
4337    /// Splits a freshly converted batch's quads by SDF class
4338    /// ([`fill_diag_bucket`]), alongside each bucket's analytic lit area,
4339    /// the opacity histogram and — for full-frame passes — the corner
4340    /// counter.
4341    fn add_shape_quads(&self, shapes: &[ShapeData], viewport: ViewportUniformParams) {
4342        let full_frame = self.is_full_frame(viewport);
4343        let frame_viewport = self.viewport.get();
4344        let mut buckets = [0.0_f64; FILL_DIAG_BUCKETS];
4345        let mut lit_buckets = [0.0_f64; FILL_DIAG_BUCKETS];
4346        let mut opacity = [0.0_f64; 3];
4347        let mut corner = 0.0_f64;
4348        for shape in shapes {
4349            let bucket = fill_diag_bucket(shape);
4350            let quad = quad_shoelace_area(shape);
4351            let lit = analytic_lit_area(shape).clamp(0.0, quad);
4352            buckets[bucket] += quad;
4353            lit_buckets[bucket] += lit;
4354            opacity[fill_opacity_class(shape) as usize] += lit;
4355            if full_frame {
4356                corner += area_outside_inscribed_circle(quad_aabb(shape), frame_viewport);
4357            }
4358        }
4359        for (bucket, area) in buckets.into_iter().enumerate() {
4360            if area > 0.0 {
4361                self.add(bucket, area);
4362            }
4363        }
4364        for (bucket, lit) in lit_buckets.into_iter().enumerate() {
4365            if lit > 0.0 {
4366                self.add_lit(bucket, lit);
4367            }
4368        }
4369        for (class, lit) in self.frame_opacity.iter().zip(opacity) {
4370            class.set(class.get() + lit);
4371        }
4372        if corner > 0.0 {
4373            self.add_corner(corner);
4374        }
4375    }
4376
4377    /// A leading-span cache hit replaced these already-counted quads with
4378    /// one cached-texture blit: subtract their submitted, lit and
4379    /// opacity-class areas back out — those pixels now arrive through the
4380    /// blit, an effect-renderer composite that the effect-comp bucket
4381    /// prices at its own draw site and the opacity histogram excludes by
4382    /// design (no CPU-known alpha). The corner counter stays as priced at
4383    /// batch prepare: the full-target blit writes the very same corner
4384    /// pixels, so the waste that counter exists to expose is unchanged.
4385    fn note_static_span_skip(&self, shapes: &[ShapeData]) {
4386        for shape in shapes {
4387            let bucket = fill_diag_bucket(shape);
4388            let quad = quad_shoelace_area(shape);
4389            let lit = analytic_lit_area(shape).clamp(0.0, quad);
4390            self.add(bucket, -quad);
4391            self.add_lit(bucket, -lit);
4392            let class = &self.frame_opacity[fill_opacity_class(shape) as usize];
4393            class.set(class.get() - lit);
4394        }
4395    }
4396
4397    /// A transient rim replaced its bounding quad with a band mesh: move the
4398    /// quad's area and lit (already counted at batch prepare) out of the
4399    /// stroke bucket and count the mesh triangles instead. The opacity
4400    /// histogram and corner counter stay as priced at batch prepare — the
4401    /// same pixels light up either way, and the corner counter deliberately
4402    /// keeps the quad AABB (documented overcount for meshed rims).
4403    fn note_rim_mesh(&self, shape: &ShapeData, mesh_px2: f64) {
4404        let quad = quad_shoelace_area(shape);
4405        let lit = analytic_lit_area(shape).clamp(0.0, quad);
4406        self.add(Self::RRECT_STROKE, -quad);
4407        self.add_lit(Self::RRECT_STROKE, -lit);
4408        self.add(Self::MESH, mesh_px2);
4409        self.add_lit(Self::MESH, lit.min(mesh_px2));
4410    }
4411
4412    /// One retained replay draw over `first..last` of a slot's capture:
4413    /// capture-space records times the draw's similarity scale squared. The
4414    /// corner counter only accumulates for identity-transform draws (rot 0,
4415    /// scale 1 — the static background/rings case it exists for), because a
4416    /// moved batch's capture-space AABBs no longer say where it lands.
4417    fn add_retained_range(
4418        &self,
4419        records: &[FillDiagShapeRecord],
4420        first: u32,
4421        last: u32,
4422        transform: &SimilarityTransform,
4423    ) {
4424        let Some(range) = records.get(first as usize..last as usize) else {
4425            return;
4426        };
4427        let scale = f64::from(transform.scale);
4428        let factor = scale * scale;
4429        let identity = transform.rot == [1.0, 0.0] && transform.scale == 1.0;
4430        let frame_viewport = self.viewport.get();
4431        let mut drawn = 0.0_f64;
4432        let mut lit = 0.0_f64;
4433        let mut opacity = [0.0_f64; 3];
4434        let mut corner = 0.0_f64;
4435        for record in range {
4436            drawn += record.drawn_px2;
4437            lit += record.lit_px2;
4438            opacity[record.opacity as usize] += record.lit_px2;
4439            if identity {
4440                corner += area_outside_inscribed_circle(record.aabb, frame_viewport);
4441            }
4442        }
4443        self.add(Self::RETAINED, drawn * factor);
4444        self.add_lit(Self::RETAINED, lit * factor);
4445        for (class, value) in self.frame_opacity.iter().zip(opacity) {
4446            class.set(class.get() + value * factor);
4447        }
4448        if corner > 0.0 {
4449            self.add_corner(corner);
4450        }
4451    }
4452
4453    /// Collects top-slack candidates from a fresh capture, keeping the
4454    /// [`FILL_DIAG_SLACK_TOP`] worst across all captures until the first
4455    /// report window dumps them.
4456    fn note_retained_capture(&mut self, slot: u32, records: &[FillDiagShapeRecord]) {
4457        if self.slack_dumped {
4458            return;
4459        }
4460        for (index, record) in records.iter().enumerate() {
4461            if record.drawn_px2 - record.lit_px2 <= 0.0 {
4462                continue;
4463            }
4464            self.slack_top.push(FillDiagSlackEntry {
4465                slot,
4466                shape: index as u32,
4467                bucket: record.bucket,
4468                drawn_px2: record.drawn_px2,
4469                lit_px2: record.lit_px2,
4470            });
4471        }
4472        self.slack_top
4473            .sort_by(|a, b| (b.drawn_px2 - b.lit_px2).total_cmp(&(a.drawn_px2 - a.lit_px2)));
4474        self.slack_top.truncate(FILL_DIAG_SLACK_TOP);
4475    }
4476
4477    /// Area of an image or text-image quad from its four device-space
4478    /// corners (TL, TR, BL, BR — the shared `(0, 1, 2)(2, 1, 3)` pattern).
4479    /// Textures light every pixel of their quad, so lit == submitted.
4480    fn add_image_quad(&self, quad: &[[f32; 2]; 4]) {
4481        let corner = |index: usize| [f64::from(quad[index][0]), f64::from(quad[index][1])];
4482        let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
4483            ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
4484        };
4485        let [a, b, c, d] = [corner(0), corner(1), corner(2), corner(3)];
4486        let area = tri(a, b, c) + tri(c, b, d);
4487        self.add(Self::IMAGE_GLYPH, area);
4488        self.add_lit(Self::IMAGE_GLYPH, area);
4489    }
4490
4491    /// One glyph atlas quad, axis-aligned by construction.
4492    fn add_glyph_quad(&self, quad: &CachedTextGlyphQuad) {
4493        let area = quad.width as f64 * quad.height as f64;
4494        self.add(Self::IMAGE_GLYPH, area);
4495        self.add_lit(Self::IMAGE_GLYPH, area);
4496    }
4497
4498    /// Effect-renderer pass fill drained once per frame from the effect
4499    /// renderer's own counters. Full-target draws: every counted pixel is
4500    /// shaded, so lit == submitted and slack is zero by construction.
4501    fn add_effect_fill(&self, composite_px2: f64, offscreen_px2: f64) {
4502        if composite_px2 > 0.0 {
4503            self.add(Self::EFFECT_COMPOSITE, composite_px2);
4504            self.add_lit(Self::EFFECT_COMPOSITE, composite_px2);
4505        }
4506        if offscreen_px2 > 0.0 {
4507            self.add(Self::OFFSCREEN_SOURCE, offscreen_px2);
4508            self.add_lit(Self::OFFSCREEN_SOURCE, offscreen_px2);
4509        }
4510    }
4511
4512    /// One render pass targeting an offscreen source texture (shadow source
4513    /// passes): the whole target area counts — its clear/load/store round
4514    /// trip — on top of the shape quads the pass draws, which
4515    /// [`Self::add_shape_quads`] prices separately under the pass's own
4516    /// bounds viewport.
4517    fn add_offscreen_target_fill(&self, px2: f64) {
4518        if px2 > 0.0 {
4519            self.add(Self::OFFSCREEN_SOURCE, px2);
4520            self.add_lit(Self::OFFSCREEN_SOURCE, px2);
4521        }
4522    }
4523
4524    /// Restarts the frame counters and latches the surface size — called
4525    /// from the same per-frame reset point as the transient rim mesh
4526    /// scratch.
4527    fn reset_frame(&self, width: u32, height: u32) {
4528        for cell in &self.frame {
4529            cell.set(0.0);
4530        }
4531        for cell in &self.frame_lit {
4532            cell.set(0.0);
4533        }
4534        for cell in &self.frame_opacity {
4535            cell.set(0.0);
4536        }
4537        self.frame_corner.set(0.0);
4538        self.viewport.set((width, height));
4539    }
4540
4541    /// Folds the frame into the window and, every
4542    /// [`FILL_DIAG_WINDOW_FRAMES`] rendered frames, emits the `[fill-diag]`
4543    /// bucket line, the `[fill-truth]` lit/slack + opacity + corner line,
4544    /// and — once per process — the retained top-slack dump.
4545    fn finish_frame(&mut self, width: u32, height: u32) {
4546        for (total, cell) in self.window.iter_mut().zip(&self.frame) {
4547            *total += cell.get();
4548        }
4549        for (total, cell) in self.window_lit.iter_mut().zip(&self.frame_lit) {
4550            *total += cell.get();
4551        }
4552        for (total, cell) in self.window_opacity.iter_mut().zip(&self.frame_opacity) {
4553            *total += cell.get();
4554        }
4555        self.window_corner += self.frame_corner.get();
4556        self.window_frames += 1;
4557        if self.window_frames < FILL_DIAG_WINDOW_FRAMES {
4558            return;
4559        }
4560        let frames = f64::from(self.window_frames);
4561        let mega = |bucket: usize| self.window[bucket] / frames / 1e6;
4562        let total_mega = self.window.iter().sum::<f64>() / frames / 1e6;
4563        let screen_mega = f64::from(width) * f64::from(height) / 1e6;
4564        let overdraw = if screen_mega > 0.0 {
4565            total_mega / screen_mega
4566        } else {
4567            0.0
4568        };
4569        log::warn!(
4570            "[fill-diag] Mpx/frame: arc {:.1}, rrect-stroke {:.1}, rrect-fill {:.1}, \
4571             rect {:.1}, mesh {:.1}, retained {:.1}, img+glyph {:.1}, \
4572             effect-comp {:.1}, offscr-src {:.1}, total {:.1} \
4573             ({:.1}x overdraw of {:.3} Mpx)",
4574            mega(Self::ARC),
4575            mega(Self::RRECT_STROKE),
4576            mega(Self::RRECT_FILL),
4577            mega(Self::RECT),
4578            mega(Self::MESH),
4579            mega(Self::RETAINED),
4580            mega(Self::IMAGE_GLYPH),
4581            mega(Self::EFFECT_COMPOSITE),
4582            mega(Self::OFFSCREEN_SOURCE),
4583            total_mega,
4584            overdraw,
4585            screen_mega,
4586        );
4587        // Lit vs slack per bucket: lit per [`analytic_lit_area`], slack the
4588        // remainder of the submitted area (clamped — negatives are rim-mesh
4589        // rounding, not information).
4590        let lit = |bucket: usize| self.window_lit[bucket] / frames / 1e6;
4591        let slack = |bucket: usize| (mega(bucket) - lit(bucket)).max(0.0);
4592        let truth = |bucket: usize| format!("{:.2}|{:.2}", lit(bucket), slack(bucket));
4593        log::warn!(
4594            "[fill-truth] Mpx/frame lit|slack: arc {}, rrect-stroke {}, rrect-fill {}, \
4595             rect {}, mesh {}, retained {}, img+glyph {}, effect-comp {}, offscr-src {}; \
4596             lit alpha Mpx: opaque {:.2}, translucent {:.2}, nonsolid {:.2}; \
4597             corner-outside {:.2}",
4598            truth(Self::ARC),
4599            truth(Self::RRECT_STROKE),
4600            truth(Self::RRECT_FILL),
4601            truth(Self::RECT),
4602            truth(Self::MESH),
4603            truth(Self::RETAINED),
4604            truth(Self::IMAGE_GLYPH),
4605            truth(Self::EFFECT_COMPOSITE),
4606            truth(Self::OFFSCREEN_SOURCE),
4607            self.window_opacity[FillOpacityClass::Opaque as usize] / frames / 1e6,
4608            self.window_opacity[FillOpacityClass::Translucent as usize] / frames / 1e6,
4609            self.window_opacity[FillOpacityClass::NonSolid as usize] / frames / 1e6,
4610            self.window_corner / frames / 1e6,
4611        );
4612        if !self.slack_dumped && !self.slack_top.is_empty() {
4613            log::warn!("[fill-truth] top retained slack (once per process, capture-space px):");
4614            for (rank, entry) in self.slack_top.iter().enumerate() {
4615                log::warn!(
4616                    "[fill-truth]   #{} slot {} shape {} {}: quad {:.0}, lit {:.0}, \
4617                     slack {:.0}",
4618                    rank + 1,
4619                    entry.slot,
4620                    entry.shape,
4621                    fill_diag_bucket_name(entry.bucket),
4622                    entry.drawn_px2,
4623                    entry.lit_px2,
4624                    entry.drawn_px2 - entry.lit_px2,
4625                );
4626            }
4627            self.slack_dumped = true;
4628            self.slack_top = Vec::new();
4629        }
4630        self.window = [0.0; FILL_DIAG_BUCKETS];
4631        self.window_lit = [0.0; FILL_DIAG_BUCKETS];
4632        self.window_opacity = [0.0; 3];
4633        self.window_corner = 0.0;
4634        self.window_frames = 0;
4635    }
4636}
4637
4638#[cfg(not(target_arch = "wasm32"))]
4639struct ArcMeshBuild {
4640    vertices: Vec<MeshVertex>,
4641    /// Triangle-list indices into `vertices`; see [`ReplaySlotMesh`].
4642    indices: Vec<u32>,
4643    /// `shape_count + 1` entries; shape `i` owns triangles
4644    /// `indices[index_prefix[i]..index_prefix[i + 1]]`. An EMPTY range is a
4645    /// shape that did not mesh: the draw walk keeps it on the latched
4646    /// instanced-quad path (see [`GpuRenderer::encode_retained_op`]) — the
4647    /// mesh buffers hold band geometry only, never passthrough quads.
4648    index_prefix: Vec<u32>,
4649    meshed_arcs: usize,
4650    meshed_rims: usize,
4651    meshed_segments: usize,
4652    passthrough: usize,
4653    /// Maximal runs of CONSECUTIVE meshed shapes. Each stretch costs the
4654    /// draw walk two pipeline switches per op that covers it, so the
4655    /// capture site refuses meshes past [`MESH_SLOT_MAX_STRETCHES`].
4656    meshed_stretches: usize,
4657    quad_area: f64,
4658    /// Capture-space area the new encoding actually submits: band-mesh
4659    /// triangles for meshed shapes, bounding quads for everything else.
4660    mesh_area: f64,
4661}
4662
4663/// Builds a slot's conservative indexed mesh: arc bands and stroked-circle
4664/// rims whose bounding quad reaches `min_mesh_px2` become vertex-sharing
4665/// trapezoid strips; every other shape — including gate-rejected small arcs
4666/// — contributes NO geometry, only an empty `index_prefix` range, and stays
4667/// on the instanced-quad path at draw time. (Putting passthrough quads in
4668/// the mesh buffers was the S3 mistake the watch measured: every quad paid
4669/// per-vertex `MeshVertex` attribute bandwidth where the latched instanced
4670/// path pays shared storage reads, and ~550 passthrough quads per slot
4671/// swamped the two meshed shapes' fill recovery — mesh ON 48.7/43.5 fps vs
4672/// OFF 53.7/45.2 on the Adreno 702.) Returns `None` when the byte budget
4673/// overflows — the caller warns and the whole slot replays through the
4674/// quad-expansion path (silent truncation would break the containment
4675/// invariant).
4676#[cfg(not(target_arch = "wasm32"))]
4677fn build_arc_mesh_vertices(shape_data: &[ShapeData], min_mesh_px2: f64) -> Option<ArcMeshBuild> {
4678    let budget_bytes =
4679        (shape_data.len() * ARC_MESH_BUDGET_BYTES_PER_SHAPE).max(ARC_MESH_BUDGET_FLOOR_BYTES);
4680    let mut build = ArcMeshBuild {
4681        vertices: Vec::new(),
4682        indices: Vec::new(),
4683        index_prefix: Vec::with_capacity(shape_data.len() + 1),
4684        meshed_arcs: 0,
4685        meshed_rims: 0,
4686        meshed_segments: 0,
4687        passthrough: 0,
4688        meshed_stretches: 0,
4689        quad_area: 0.0,
4690        mesh_area: 0.0,
4691    };
4692    build.index_prefix.push(0);
4693    let mut previous_meshed = false;
4694    for (index, shape) in shape_data.iter().enumerate() {
4695        let start = build.indices.len();
4696        let quad_px2 = quad_shoelace_area(shape);
4697        // THE SIZE GATE (see [`arc_mesh_enabled`] for the measured history):
4698        // only shapes whose submitted quad is big enough to carry real
4699        // fill-truth slack are worth a mesh; below the gate the trapezoid
4700        // strip's vertex and binning amplification costs the watch GPU more
4701        // than the discarded fragments ever did. The two band shapes are
4702        // mutually exclusive by kind bits (`SHAPE_KIND_ARC` vs
4703        // `SHAPE_KIND_STROKE`), so the `or_else` never shadows one with the
4704        // other.
4705        let band = if quad_px2 >= min_mesh_px2 {
4706            arc_mesh_band(shape)
4707                .map(|band| (band, false))
4708                .or_else(|| rim_band_geometry(shape).map(|band| (band, true)))
4709        } else {
4710            None
4711        };
4712        let meshed = band.and_then(|(band, is_rim)| {
4713            emit_arc_band_mesh(
4714                shape,
4715                index as u32,
4716                &band,
4717                &mut build.vertices,
4718                &mut build.indices,
4719            )
4720            .map(|segments| (segments, is_rim))
4721        });
4722        match meshed {
4723            Some((segments, is_rim)) => {
4724                if is_rim {
4725                    build.meshed_rims += 1;
4726                } else {
4727                    build.meshed_arcs += 1;
4728                }
4729                build.meshed_segments += segments;
4730                if !previous_meshed {
4731                    build.meshed_stretches += 1;
4732                }
4733                previous_meshed = true;
4734                build.mesh_area +=
4735                    triangles_shoelace_area(&build.vertices, &build.indices[start..]);
4736            }
4737            None => {
4738                build.passthrough += 1;
4739                previous_meshed = false;
4740                build.mesh_area += quad_px2;
4741            }
4742        }
4743        if arc_mesh_bytes(build.vertices.len(), build.indices.len()) > budget_bytes {
4744            return None;
4745        }
4746        build.index_prefix.push(build.indices.len() as u32);
4747        build.quad_area += quad_px2;
4748    }
4749    Some(build)
4750}
4751
4752/// The renderer's registry of live replay slots. The replay cache (scene
4753/// side) owns slot LIFECYCLE decisions; this store owns the GPU resources.
4754#[cfg(not(target_arch = "wasm32"))]
4755struct ReplaySlotStore {
4756    slots: std::collections::HashMap<u32, ReplaySlot, cranpose_ui_graphics::FxBuildHasher>,
4757    transform_buffer: wgpu::Buffer,
4758    free_ids: Vec<u32>,
4759    /// Global capture counter feeding [`ReplaySlot::capture_epoch`]: bumped
4760    /// on every capture, never reused, so an epoch identifies one capture's
4761    /// buffers for the renderer's whole lifetime.
4762    next_capture_epoch: u64,
4763}
4764
4765#[cfg(not(target_arch = "wasm32"))]
4766impl ReplaySlotStore {
4767    fn new(device: &wgpu::Device) -> Self {
4768        let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4769            label: Some("Replay Transform Buffer"),
4770            // The trailing SEGMENT_CAPTURE_SLOTS strides are reserved for
4771            // segment-surface capture passes: each capture binds its
4772            // similarity (the span's own transform, retained paint
4773            // selected) at `(MAX_REPLAY_SLOTS + capture_index) * stride`,
4774            // past every per-draw slot, so captures can never clobber a
4775            // frame's staged draw transforms.
4776            size: (MAX_REPLAY_SLOTS + SEGMENT_CAPTURE_SLOTS) as u64 * REPLAY_TRANSFORM_STRIDE,
4777            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4778            mapped_at_creation: false,
4779        });
4780        Self {
4781            slots: std::collections::HashMap::default(),
4782            transform_buffer,
4783            free_ids: (0..MAX_REPLAY_SLOTS).rev().collect(),
4784            next_capture_epoch: 1,
4785        }
4786    }
4787}
4788
4789/// Kill switch for cached retained render bundles, mirroring
4790/// `command_feed_enabled`: default ON, `CRANPOSE_RETAINED_BUNDLES=0` (or the
4791/// `debug.cranpose.retained_bundles` property on Android) drops the fused
4792/// retained arms back to direct per-op encoding, so a device A/B needs no
4793/// rebuild. Read per partition — the parity harness flips it between passes.
4794#[cfg(not(target_arch = "wasm32"))]
4795fn retained_bundles_enabled() -> bool {
4796    std::env::var("CRANPOSE_RETAINED_BUNDLES").as_deref() != Ok("0")
4797}
4798
4799/// Kill switch for instanced ordinary-shape quads: default ON,
4800/// `CRANPOSE_INSTANCED_QUADS=0` (or the `debug.cranpose.instanced_quads`
4801/// property on Android) reverts every ordinary shape draw to the six-vertex
4802/// `vs_main` expansion. Unlike the per-partition bundle flag this is read
4803/// ONCE per [`GpuRenderer`] construction into a field: cached retained
4804/// bundles encode the selected pipeline, so a flag that moved per draw would
4805/// let a cached bundle replay a selection the direct path no longer makes.
4806#[cfg(not(target_arch = "wasm32"))]
4807fn instanced_quads_enabled() -> bool {
4808    std::env::var("CRANPOSE_INSTANCED_QUADS").as_deref() != Ok("0")
4809}
4810
4811/// Trimmed-varying solid pipelines: default OFF, `CRANPOSE_SOLID_TRIM_VARYINGS=1`
4812/// (or the `debug.cranpose.solid_trim` property on Android) opts in. When on,
4813/// the two `fs_solid` pipeline families compile `vs_solid` /
4814/// `vs_solid_instanced` + `fs_solid_trim` — the inter-stage interface without
4815/// the eight gradient scalars `fs_solid` never reads (see
4816/// `shape_solid_trim.wgsl` for the location discipline). Read at pipeline
4817/// build like every lazy pipeline — the property is seeded into the
4818/// environment before the render loop starts, and the `PassPipeline` slots
4819/// cache the first build, so retained bundles and direct draws always encode
4820/// the same selection. Kill switch first: the previous attempt (16a5d312,
4821/// reverted in 371dd06a) died on a watch undiagnosed, so the trim ships dark
4822/// until a Vulkan-validated device session clears it.
4823fn solid_trim_varyings_enabled() -> bool {
4824    std::env::var("CRANPOSE_SOLID_TRIM_VARYINGS").as_deref() == Ok("1")
4825}
4826
4827/// Kill switch for surviving uncaptured device errors: default ON,
4828/// `CRANPOSE_SURVIVE_GPU_ERRORS=0` (or the
4829/// `debug.cranpose.survive_gpu_errors` property on Android) restores
4830/// wgpu's fatal default handler, which panics with the error message on
4831/// the reporting thread — the pre-fix behavior, kept reachable so a
4832/// debugging session can die loudly at the first error instead of
4833/// logging past it. Read once, at [`GpuRenderer`] construction, where the
4834/// handler is installed; it changes nothing off the error path.
4835fn survive_gpu_errors_enabled() -> bool {
4836    std::env::var("CRANPOSE_SURVIVE_GPU_ERRORS").as_deref() != Ok("0")
4837}
4838
4839/// Kill switch for the display clip region cull: default ON wherever the
4840/// platform reports a cullable visible region
4841/// (`set_display_visible_region`), and `CRANPOSE_ROUND_CULL` (or the
4842/// `debug.cranpose.round_cull` property on Android) gates it — the
4843/// switch keeps the name of the capability's first provider, the round
4844/// display. Read per frame — the parity harness flips it between passes.
4845/// While the region is `Full` the variable is never consulted: the cull
4846/// is structurally off.
4847///
4848/// OPT-IN (=1), not default-on, by measurement: with the span-capture
4849/// depth-leak fixed, the on-watch A/B (Pixel Watch 3, Adreno 702, mega
4850/// scene, alternating pairs) read cull ON 47.0/46.9 fps vs OFF 48.6/46.9 —
4851/// a small loss to a tie, never a win, despite the cull masking 35723 px
4852/// (21% of the buffer). The shape fragment shaders discard, which defeats
4853/// LRZ/early-Z on this GPU: corner fragments still execute, so the depth
4854/// attachment and occluder are pure overhead. The capability stays for
4855/// displays and drivers where early rejection survives discard — a device
4856/// A/B is one env flip, no rebuild — but earning default-on takes a
4857/// measured win on some device class, not an assumption.
4858#[cfg(not(target_arch = "wasm32"))]
4859fn display_clip_cull_enabled() -> bool {
4860    std::env::var("CRANPOSE_ROUND_CULL").as_deref() == Ok("1")
4861}
4862
4863/// The index pattern of one instanced quad: the exact triangle pair
4864/// `vs_main`'s six-slot corner mapping produces — (0, 1, 2)(2, 1, 3), same
4865/// diagonal, same winding — shared by every instance.
4866#[cfg(not(target_arch = "wasm32"))]
4867const INSTANCED_QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3];
4868
4869/// The latched instanced-quad selection: `Some` exactly when the renderer
4870/// was constructed in storage mode with [`instanced_quads_enabled`]. Both
4871/// blend variants exist because ordinary batches draw SrcOver and DstOut;
4872/// the `vs_main` pipelines coexist untouched so the `=0` revert (and the
4873/// uniform-mode path) still has its six-vertex draws.
4874#[cfg(not(target_arch = "wasm32"))]
4875struct InstancedQuadPipelines {
4876    pipeline: PassPipeline,
4877    pipeline_dst_out: PassPipeline,
4878    /// `fs_solid` twin of `pipeline` (SrcOver only): chosen for draws whose
4879    /// shapes carry no gradient stops, which is nearly every draw of an
4880    /// arc-heavy scene.
4881    pipeline_solid: PassPipeline,
4882    /// Static `[0, 1, 2, 2, 1, 3]` u16 index buffer, created once and shared
4883    /// by every instanced draw.
4884    index_buffer: wgpu::Buffer,
4885}
4886
4887/// One command of a retained op's draw walk, emitted by
4888/// [`GpuRenderer::encode_retained_op`] — the SINGLE place the walk exists.
4889/// The two sinks (the fused pass on the direct path, a
4890/// `RenderBundleEncoder` on the cached path) each translate these
4891/// mechanically, one match arm per variant, so the bundle-parity bar ("a
4892/// bundle replays the IDENTICAL command sequence") holds by construction:
4893/// only the translation is duplicated, never the sequence logic. A shared
4894/// generic encoder (`wgpu::util::RenderEncoder`) cannot express this
4895/// instead: `&mut RenderPass<'p>` is invariant in `'p`, so unifying the
4896/// resource lifetime with the pass's would freeze `self` immutably
4897/// borrowed for the whole pass.
4898#[cfg(not(target_arch = "wasm32"))]
4899enum RetainedCmd<'r> {
4900    Pipeline(&'r wgpu::RenderPipeline),
4901    /// Bind group 0, no dynamic offsets.
4902    Uniforms(&'r wgpu::BindGroup),
4903    /// Bind group 1 with the retained draw's dynamic transform offset.
4904    SlotBindings(&'r wgpu::BindGroup, u32),
4905    /// The slot mesh's vertex buffer at slot 0.
4906    MeshVertices(&'r wgpu::Buffer),
4907    Index(&'r wgpu::Buffer, wgpu::IndexFormat),
4908    /// `draw(vertices, 0..1)`.
4909    Draw(Range<u32>),
4910    /// `draw_indexed(indices, 0, instances)`.
4911    DrawIndexed(Range<u32>, Range<u32>),
4912}
4913
4914/// Everything that decides the commands one retained op contributes to a
4915/// cached bundle. Equal op keys imply identical encoded commands:
4916/// `capture_epoch` pins the slot's bind group, buffers AND its mesh's
4917/// meshed/instanced stretch structure to one capture (the alternating walk
4918/// of [`GpuRenderer::encode_retained_op`] is a pure function of the
4919/// capture-fixed `index_prefix` and `first..last`, so no per-stretch state
4920/// belongs in the key), `has_mesh` pins whether that walk runs at all,
4921/// `first..last` is the clamped draw range, and `retained_index` is the
4922/// dynamic transform offset. Transforms and paints are NOT here — they are
4923/// data-buffer contents the bundle reads at execution.
4924#[cfg(not(target_arch = "wasm32"))]
4925#[derive(Clone, Debug, PartialEq, Eq, Hash)]
4926struct RetainedBundleOpKey {
4927    slot: u32,
4928    /// The slot's capture epoch at key time, `None` while the slot is absent
4929    /// from the store (the op encodes nothing). Epochs are globally unique
4930    /// per capture, so a recaptured slot reusing its id can never satisfy a
4931    /// key recorded against the previous capture's buffers.
4932    capture_epoch: Option<u64>,
4933    first: u32,
4934    last: u32,
4935    retained_index: u32,
4936    has_mesh: bool,
4937}
4938
4939/// Key of one maximal consecutive retained stretch: the op keys in draw
4940/// order. Any reorder, count change, range change, recapture, or slot
4941/// release changes the key and forces a rebuild.
4942#[cfg(not(target_arch = "wasm32"))]
4943#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
4944struct RetainedBundleKey {
4945    /// Whether the stretch was encoded for the display-clip culled pass:
4946    /// such a bundle declares the depth attachment and records
4947    /// depth-variant pipelines, so it must never replay into a flat pass
4948    /// (or vice versa) — the flag keys the cache apart.
4949    depth: bool,
4950    ops: Vec<RetainedBundleOpKey>,
4951}
4952
4953#[cfg(not(target_arch = "wasm32"))]
4954struct RetainedBundleCacheEntry<B> {
4955    bundle: B,
4956    last_used_frame: u64,
4957}
4958
4959/// Cache of encoded render bundles for retained stretches, generic over the
4960/// bundle payload so the reuse/invalidation/eviction logic is unit-testable
4961/// without a GPU. The full [`RetainedBundleKey`] is the map key — a fresh
4962/// key can only ever build a fresh bundle, never alias a stale one.
4963///
4964/// The surface format and the group-0 uniform bind group are deliberately
4965/// not part of the key: both are fixed for a `GpuRenderer`'s lifetime (a
4966/// surface reconfigure builds a new renderer, and with it an empty cache).
4967#[cfg(not(target_arch = "wasm32"))]
4968struct RetainedBundleCacheImpl<B> {
4969    entries: HashMap<RetainedBundleKey, RetainedBundleCacheEntry<B>>,
4970    frame: u64,
4971    rebuilds: u64,
4972    cached_executes: u64,
4973    window_rebuilds: u64,
4974    window_executes: u64,
4975}
4976
4977#[cfg(not(target_arch = "wasm32"))]
4978type RetainedBundleCache = RetainedBundleCacheImpl<wgpu::RenderBundle>;
4979
4980#[cfg(not(target_arch = "wasm32"))]
4981impl<B> RetainedBundleCacheImpl<B> {
4982    fn new() -> Self {
4983        Self {
4984            entries: HashMap::default(),
4985            frame: 0,
4986            rebuilds: 0,
4987            cached_executes: 0,
4988            window_rebuilds: 0,
4989            window_executes: 0,
4990        }
4991    }
4992
4993    /// True when a bundle for `key` is cached; marks it used this frame and
4994    /// counts a cached execute.
4995    fn hit(&mut self, key: &RetainedBundleKey) -> bool {
4996        let frame = self.frame;
4997        match self.entries.get_mut(key) {
4998            Some(entry) => {
4999                entry.last_used_frame = frame;
5000                self.cached_executes += 1;
5001                self.window_executes += 1;
5002                true
5003            }
5004            None => false,
5005        }
5006    }
5007
5008    /// Stores a freshly built bundle, counting a rebuild.
5009    fn insert(&mut self, key: RetainedBundleKey, bundle: B) {
5010        self.rebuilds += 1;
5011        self.window_rebuilds += 1;
5012        self.entries.insert(
5013            key,
5014            RetainedBundleCacheEntry {
5015                bundle,
5016                last_used_frame: self.frame,
5017            },
5018        );
5019    }
5020
5021    fn get(&self, key: &RetainedBundleKey) -> Option<&B> {
5022        self.entries.get(key).map(|entry| &entry.bundle)
5023    }
5024
5025    /// Drops every cached bundle. Called whenever a replay slot is released:
5026    /// the key compare already makes stale entries unreachable (their epochs
5027    /// can never recur), so this only releases the dropped capture's GPU
5028    /// resources promptly instead of one frame later via eviction.
5029    fn clear(&mut self) {
5030        self.entries.clear();
5031    }
5032
5033    /// Frame boundary: evicts entries the frame did not use — a bundle
5034    /// holds references on its slot's buffers, so unused entries must not
5035    /// accumulate — and emits the rate-limited rebuild/execute telemetry.
5036    fn end_frame(&mut self) {
5037        let frame = self.frame;
5038        self.entries
5039            .retain(|_, entry| entry.last_used_frame >= frame);
5040        self.frame = self.frame.wrapping_add(1);
5041        // Always-on at a cadence that cannot spam; every perf window (120
5042        // frames) under the replay diagnostics flag so short A/B runs see
5043        // the counts. log::warn because log::info is invisible on the
5044        // desktop console.
5045        let due = self.frame.is_multiple_of(1024)
5046            || (cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG")
5047                && self.frame.is_multiple_of(120));
5048        if due && self.window_rebuilds + self.window_executes > 0 {
5049            log::warn!(
5050                "[retained-bundles] {} stretches, {} rebuilds, {} cached executes ({} live bundles)",
5051                self.window_rebuilds + self.window_executes,
5052                self.window_rebuilds,
5053                self.window_executes,
5054                self.entries.len(),
5055            );
5056            self.window_rebuilds = 0;
5057            self.window_executes = 0;
5058        }
5059    }
5060
5061    /// Lifetime (rebuilds, cached executes) for tests and diagnostics.
5062    fn stats(&self) -> (u64, u64) {
5063        (self.rebuilds, self.cached_executes)
5064    }
5065}
5066
5067struct CachedImageTexture {
5068    _texture: wgpu::Texture,
5069    _view: wgpu::TextureView,
5070    nearest_bind_group: wgpu::BindGroup,
5071    linear_bind_group: wgpu::BindGroup,
5072    /// GPU bytes this entry pins (w×h×4): the cache is bounded by BYTES as
5073    /// well as count. A live camera publishes a new multi-MB bitmap id every
5074    /// frame; 256 count-slots of those is ~1.5GB of dead preview textures —
5075    /// which on iOS unified memory counts straight against the process's
5076    /// jetsam limit (measured: the app died mid-scan under an open camera
5077    /// with exactly that ballast).
5078    bytes: usize,
5079}
5080
5081impl CachedImageTexture {
5082    fn bind_group(&self, sampling: ImageSampling) -> &wgpu::BindGroup {
5083        match sampling {
5084            ImageSampling::Nearest => &self.nearest_bind_group,
5085            ImageSampling::Linear => &self.linear_bind_group,
5086        }
5087    }
5088}
5089
5090#[derive(Clone, Copy)]
5091struct GlyphAtlasEntry {
5092    x: u32,
5093    y: u32,
5094    width: u32,
5095    height: u32,
5096}
5097
5098/// Side length the glyph atlas should be rebuilt at after it overflowed at
5099/// `current`: one doubling, never past `max`.
5100///
5101/// Doubling (rather than jumping straight to `max`) is what makes the atlas
5102/// cost track the workload: an app that overflows once needs a little more
5103/// room, not sixteen times more.
5104fn next_glyph_atlas_size(current: u32, max: u32) -> u32 {
5105    current.saturating_mul(2).clamp(1, max.max(1))
5106}
5107
5108struct TextGlyphAtlas {
5109    texture: wgpu::Texture,
5110    _view: wgpu::TextureView,
5111    bind_group: wgpu::BindGroup,
5112    entries: BoundedLruCache<SoftwareGlyphAtlasKey, GlyphAtlasEntry>,
5113    generation: u64,
5114    /// Side length of `texture`, between `TEXT_GLYPH_ATLAS_MIN_SIZE` and the
5115    /// device's ceiling. Every UV is normalised against it, so it has to travel
5116    /// with the atlas rather than be read back off a constant.
5117    size: u32,
5118    /// Largest side length this atlas may grow to: the smaller of
5119    /// `TEXT_GLYPH_ATLAS_MAX_SIZE` and what the device grants. Mobile devices
5120    /// are requested `downlevel_defaults()` limits raised by `using_resolution`,
5121    /// so a device that only offers 2048 would otherwise fail to create the
5122    /// texture outright.
5123    max_size: u32,
5124    cursor_x: u32,
5125    cursor_y: u32,
5126    row_height: u32,
5127    upload_scratch: Vec<u8>,
5128}
5129
5130impl TextGlyphAtlas {
5131    fn new(
5132        device: &wgpu::Device,
5133        image_layout: &wgpu::BindGroupLayout,
5134        sampler: &wgpu::Sampler,
5135        size: u32,
5136    ) -> Self {
5137        let max_size = TEXT_GLYPH_ATLAS_MAX_SIZE.min(device.limits().max_texture_dimension_2d);
5138        let size = size.clamp(TEXT_GLYPH_ATLAS_MIN_SIZE.min(max_size), max_size);
5139        let texture = Self::create_texture(device, size);
5140        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
5141        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5142            label: Some("Text Glyph Atlas Bind Group"),
5143            layout: image_layout,
5144            entries: &[
5145                wgpu::BindGroupEntry {
5146                    binding: 0,
5147                    resource: wgpu::BindingResource::TextureView(&view),
5148                },
5149                wgpu::BindGroupEntry {
5150                    binding: 1,
5151                    resource: wgpu::BindingResource::Sampler(sampler),
5152                },
5153            ],
5154        });
5155        Self {
5156            texture,
5157            _view: view,
5158            bind_group,
5159            entries: BoundedLruCache::with_capacity_at_least_one(MAX_TEXT_GLYPH_ATLAS_ITEMS),
5160            generation: 0,
5161            size,
5162            max_size,
5163            cursor_x: TEXT_GLYPH_ATLAS_PADDING,
5164            cursor_y: TEXT_GLYPH_ATLAS_PADDING,
5165            row_height: 0,
5166            upload_scratch: Vec::new(),
5167        }
5168    }
5169
5170    fn create_texture(device: &wgpu::Device, size: u32) -> wgpu::Texture {
5171        device.create_texture(&wgpu::TextureDescriptor {
5172            label: Some("Text Glyph Atlas Texture"),
5173            size: wgpu::Extent3d {
5174                width: size,
5175                height: size,
5176                depth_or_array_layers: 1,
5177            },
5178            mip_level_count: 1,
5179            sample_count: 1,
5180            dimension: wgpu::TextureDimension::D2,
5181            format: wgpu::TextureFormat::R8Unorm,
5182            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5183            view_formats: &[],
5184        })
5185    }
5186
5187    /// Throws every cached glyph away and starts over on a texture one doubling
5188    /// larger, up to [`TextGlyphAtlas::max_size`].
5189    ///
5190    /// `allocate` is a one-way shelf cursor with no compaction, so the only
5191    /// recovery from a full atlas is to start again — and starting again at the
5192    /// same size makes a workload whose live glyph set genuinely does not fit
5193    /// re-raster every glyph every frame. Treating each overflow as the signal
5194    /// to double means the atlas converges on the size the workload actually
5195    /// needs: a text-heavy screen reaches the old fixed 4096 after at most three
5196    /// resets and behaves identically from then on, while a watch face that
5197    /// never overflows never pays for space it will not use.
5198    ///
5199    /// Bumping the generation is what invalidates the cached glyph runs, whose
5200    /// UVs are normalised against the previous size and would otherwise sample
5201    /// the wrong part of the new texture.
5202    fn reset(
5203        &mut self,
5204        device: &wgpu::Device,
5205        image_layout: &wgpu::BindGroupLayout,
5206        sampler: &wgpu::Sampler,
5207    ) {
5208        let generation = self.generation.wrapping_add(1);
5209        let grown = next_glyph_atlas_size(self.size, self.max_size);
5210        let mut next = Self::new(device, image_layout, sampler, grown);
5211        next.generation = generation;
5212        *self = next;
5213    }
5214
5215    fn generation(&self) -> u64 {
5216        self.generation
5217    }
5218
5219    fn size(&self) -> u32 {
5220        self.size
5221    }
5222
5223    fn entry(&mut self, key: &SoftwareGlyphAtlasKey) -> Option<GlyphAtlasEntry> {
5224        self.entries.get(key).copied()
5225    }
5226
5227    fn allocate(&mut self, width: u32, height: u32) -> Option<GlyphAtlasEntry> {
5228        if width == 0
5229            || height == 0
5230            || width + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
5231            || height + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
5232        {
5233            return None;
5234        }
5235
5236        if self.cursor_x + width + TEXT_GLYPH_ATLAS_PADDING > self.size {
5237            self.cursor_x = TEXT_GLYPH_ATLAS_PADDING;
5238            self.cursor_y = self
5239                .cursor_y
5240                .saturating_add(self.row_height)
5241                .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
5242            self.row_height = 0;
5243        }
5244        if self.cursor_y + height + TEXT_GLYPH_ATLAS_PADDING > self.size {
5245            return None;
5246        }
5247
5248        let entry = GlyphAtlasEntry {
5249            x: self.cursor_x,
5250            y: self.cursor_y,
5251            width,
5252            height,
5253        };
5254        self.cursor_x = self
5255            .cursor_x
5256            .saturating_add(width)
5257            .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
5258        self.row_height = self.row_height.max(height);
5259        Some(entry)
5260    }
5261
5262    fn upload_glyph(
5263        &mut self,
5264        key: SoftwareGlyphAtlasKey,
5265        glyph: &SoftwareGlyphAtlasGlyph,
5266        queue: &wgpu::Queue,
5267        executor: &mut WgpuFrameGraphExecutor,
5268        frame_stats: &mut gpu_stats::FrameStats,
5269    ) -> Option<GlyphAtlasEntry> {
5270        if let Some(entry) = self.entry(&key) {
5271            frame_stats.record_text_glyph_atlas_hit();
5272            return Some(entry);
5273        }
5274
5275        let width = u32::try_from(glyph.mask.width).ok()?;
5276        let height = u32::try_from(glyph.mask.height).ok()?;
5277        let entry = self.allocate(width, height)?;
5278        self.upload_scratch.clear();
5279        self.upload_scratch.reserve(
5280            glyph
5281                .mask
5282                .alpha
5283                .len()
5284                .saturating_sub(self.upload_scratch.capacity()),
5285        );
5286        self.upload_scratch.extend(
5287            glyph
5288                .mask
5289                .alpha
5290                .iter()
5291                .map(|alpha| (alpha.clamp(0.0, 1.0) * 255.0).round() as u8),
5292        );
5293
5294        let upload_stats = executor.upload_texture(
5295            queue,
5296            wgpu::TexelCopyTextureInfo {
5297                texture: &self.texture,
5298                mip_level: 0,
5299                origin: wgpu::Origin3d {
5300                    x: entry.x,
5301                    y: entry.y,
5302                    z: 0,
5303                },
5304                aspect: wgpu::TextureAspect::All,
5305            },
5306            &self.upload_scratch,
5307            wgpu::TexelCopyBufferLayout {
5308                offset: 0,
5309                bytes_per_row: Some(entry.width),
5310                rows_per_image: Some(entry.height),
5311            },
5312            wgpu::Extent3d {
5313                width: entry.width,
5314                height: entry.height,
5315                depth_or_array_layers: 1,
5316            },
5317        );
5318        frame_stats.record_command_stats(upload_stats);
5319        frame_stats.record_text_glyph_atlas_miss(entry.width, entry.height);
5320        self.entries.put(key, entry);
5321        Some(entry)
5322    }
5323}
5324
5325struct ImageDrawCmd {
5326    index_start: u32,
5327    scissor: (u32, u32, u32, u32),
5328    image_id: u64,
5329    sampling: ImageSampling,
5330}
5331
5332#[derive(Clone, Copy)]
5333enum GlyphDrawSource {
5334    Shared {
5335        index_start: u32,
5336        index_count: u32,
5337    },
5338    #[cfg(not(target_arch = "wasm32"))]
5339    Retained {
5340        cache_key: TextGlyphRunCacheKey,
5341        uniform_slot: usize,
5342    },
5343}
5344
5345#[derive(Clone, Copy)]
5346struct GlyphDrawCmd {
5347    source: GlyphDrawSource,
5348    scissor: (u32, u32, u32, u32),
5349}
5350
5351impl GlyphDrawCmd {
5352    fn shared(index_start: u32, index_count: u32, scissor: (u32, u32, u32, u32)) -> Self {
5353        Self {
5354            source: GlyphDrawSource::Shared {
5355                index_start,
5356                index_count,
5357            },
5358            scissor,
5359        }
5360    }
5361
5362    #[cfg(not(target_arch = "wasm32"))]
5363    fn retained(
5364        cache_key: TextGlyphRunCacheKey,
5365        uniform_slot: usize,
5366        scissor: (u32, u32, u32, u32),
5367    ) -> Self {
5368        Self {
5369            source: GlyphDrawSource::Retained {
5370                cache_key,
5371                uniform_slot,
5372            },
5373            scissor,
5374        }
5375    }
5376}
5377
5378#[derive(Clone, Copy, Debug, PartialEq)]
5379struct ImageUvRect {
5380    min: [f32; 2],
5381    max: [f32; 2],
5382    sample_bounds: [f32; 4],
5383}
5384
5385// Text raster cache is owned by GpuRenderer and backed by software text images
5386// between measurement and rendering to eliminate duplicate text shaping
5387
5388/// Persistent GPU buffers for batched shape rendering. There is no vertex or
5389/// index buffer: the shape shader pulls quad corners straight out of
5390/// `ShapeData` by `vertex_index`, so the batch is drawn unindexed.
5391struct ShapeBatchBuffers {
5392    shape_buffer: wgpu::Buffer,
5393    gradient_buffer: wgpu::Buffer,
5394    bind_group: wgpu::BindGroup,
5395    shape_capacity: usize,
5396    gradient_capacity: usize,
5397    batch_limits: ShapeBatchLimits,
5398}
5399
5400#[cfg(target_arch = "wasm32")]
5401struct UniformBatchBuffer {
5402    buffer: wgpu::Buffer,
5403    bind_group: wgpu::BindGroup,
5404}
5405
5406#[cfg(target_arch = "wasm32")]
5407struct ImageBatchBuffers {
5408    vertex_buffer: wgpu::Buffer,
5409    index_buffer: wgpu::Buffer,
5410    vertex_capacity: usize,
5411    index_capacity: usize,
5412}
5413
5414#[derive(Clone, Copy, Debug, PartialEq)]
5415struct ViewportUniformParams {
5416    width: u32,
5417    height: u32,
5418    offset: [f32; 2],
5419}
5420
5421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5422#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
5423enum UploadTarget {
5424    Uniform,
5425    ShapeData,
5426    ShapeGradient,
5427    ImageVertex,
5428    ImageIndex,
5429    #[cfg(not(target_arch = "wasm32"))]
5430    RetainedGlyphUniform,
5431    /// The shared replay-transform buffer; copies land at each slot's fixed
5432    /// 256-byte-aligned offset.
5433    #[cfg(not(target_arch = "wasm32"))]
5434    ReplayTransform,
5435    /// A replay slot's retained paint buffer (color patches land here).
5436    #[cfg(not(target_arch = "wasm32"))]
5437    ReplayPaintData(u32),
5438}
5439
5440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5441#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
5442struct PendingBufferCopy {
5443    source_offset: u64,
5444    target_offset: u64,
5445    size: u64,
5446    target: UploadTarget,
5447}
5448
5449#[derive(Default)]
5450struct StagedBufferUploads {
5451    bytes: Vec<u8>,
5452    copies: Vec<PendingBufferCopy>,
5453}
5454
5455impl StagedBufferUploads {
5456    fn clear(&mut self) {
5457        self.bytes.clear();
5458        self.copies.clear();
5459    }
5460
5461    fn shrink_retained_capacity(&mut self, max_bytes: usize, max_copies: usize) -> bool {
5462        let mut shrunk = false;
5463        if self.bytes.len() <= max_bytes && self.bytes.capacity() > max_bytes {
5464            self.bytes.shrink_to(max_bytes);
5465            shrunk = true;
5466        }
5467        if self.copies.len() <= max_copies && self.copies.capacity() > max_copies {
5468            self.copies.shrink_to(max_copies);
5469            shrunk = true;
5470        }
5471        shrunk
5472    }
5473
5474    fn is_empty(&self) -> bool {
5475        self.copies.is_empty()
5476    }
5477
5478    #[cfg(test)]
5479    fn payload_for_copy(&self, copy: PendingBufferCopy) -> &[u8] {
5480        let start = copy.source_offset as usize;
5481        let end = start + copy.size as usize;
5482        &self.bytes[start..end]
5483    }
5484
5485    #[cfg(not(target_arch = "wasm32"))]
5486    fn stage(&mut self, target: UploadTarget, bytes: &[u8]) {
5487        self.stage_at(target, 0, bytes);
5488    }
5489
5490    /// Records a GPU copy whose source bytes were already written into the
5491    /// frame upload buffer (via `Queue::write_buffer_with`), so nothing is
5492    /// appended to `bytes`. `source_offset` is relative to the same base the
5493    /// caller later passes to `flush_staged_uploads_at`.
5494    #[cfg(not(target_arch = "wasm32"))]
5495    fn record_upload_copy(
5496        &mut self,
5497        target: UploadTarget,
5498        source_offset: u64,
5499        target_offset: u64,
5500        size: u64,
5501    ) {
5502        if size == 0 {
5503            return;
5504        }
5505        self.copies.push(PendingBufferCopy {
5506            source_offset,
5507            target_offset,
5508            size,
5509            target,
5510        });
5511    }
5512
5513    #[cfg(not(target_arch = "wasm32"))]
5514    fn stage_at(&mut self, target: UploadTarget, target_offset: u64, bytes: &[u8]) {
5515        if bytes.is_empty() {
5516            return;
5517        }
5518
5519        debug_assert_eq!(
5520            bytes.len() % wgpu::COPY_BUFFER_ALIGNMENT as usize,
5521            0,
5522            "buffer uploads must be aligned to copy requirements"
5523        );
5524
5525        let aligned_offset = align_usize_to(self.bytes.len(), wgpu::COPY_BUFFER_ALIGNMENT as usize);
5526        if aligned_offset > self.bytes.len() {
5527            self.bytes.resize(aligned_offset, 0);
5528        }
5529
5530        let source_offset = self.bytes.len() as u64;
5531        self.bytes.extend_from_slice(bytes);
5532        self.copies.push(PendingBufferCopy {
5533            source_offset,
5534            target_offset,
5535            size: bytes.len() as u64,
5536            target,
5537        });
5538    }
5539
5540    fn truncate(&mut self, bytes_len: usize, copies_len: usize) {
5541        self.bytes.truncate(bytes_len);
5542        self.copies.truncate(copies_len);
5543    }
5544}
5545
5546/// The fresh-batch entry list for the shape bind group layout: the batch's
5547/// own data buffers, the shared identity similarity buffer, and — storage
5548/// mode only, where the layout carries the paint entry — the renderer-wide
5549/// dummy paint buffer (fresh draws leave `paint_select` at 0.0).
5550fn shape_batch_bind_group_entries<'a>(
5551    shape_buffer: &'a wgpu::Buffer,
5552    gradient_buffer: &'a wgpu::Buffer,
5553    similarity_buffer: &'a wgpu::Buffer,
5554    paint_buffer: Option<&'a wgpu::Buffer>,
5555) -> Vec<wgpu::BindGroupEntry<'a>> {
5556    let mut entries = vec![
5557        wgpu::BindGroupEntry {
5558            binding: 0,
5559            resource: shape_buffer.as_entire_binding(),
5560        },
5561        wgpu::BindGroupEntry {
5562            binding: 1,
5563            resource: gradient_buffer.as_entire_binding(),
5564        },
5565        wgpu::BindGroupEntry {
5566            binding: 2,
5567            resource: similarity_buffer.as_entire_binding(),
5568        },
5569    ];
5570    if let Some(paint_buffer) = paint_buffer {
5571        entries.push(wgpu::BindGroupEntry {
5572            binding: 3,
5573            resource: paint_buffer.as_entire_binding(),
5574        });
5575    }
5576    entries
5577}
5578
5579impl ShapeBatchBuffers {
5580    fn new(
5581        device: &wgpu::Device,
5582        bind_group_layout: &wgpu::BindGroupLayout,
5583        similarity_buffer: &wgpu::Buffer,
5584        paint_buffer: Option<&wgpu::Buffer>,
5585        batch_limits: ShapeBatchLimits,
5586    ) -> Self {
5587        debug_assert_eq!(
5588            paint_buffer.is_some(),
5589            batch_limits.storage,
5590            "the paint binding exists exactly when the layout is in storage mode"
5591        );
5592        let initial_shape_cap = batch_limits.initial_shape_capacity();
5593        let initial_gradient_cap = batch_limits.initial_gradient_capacity();
5594
5595        let shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5596            label: Some("Shape Data Buffer"),
5597            size: (std::mem::size_of::<ShapeData>() * initial_shape_cap) as u64,
5598            usage: batch_limits.data_buffer_usage(),
5599            mapped_at_creation: false,
5600        });
5601
5602        let gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5603            label: Some("Gradient Buffer"),
5604            size: (std::mem::size_of::<GradientStop>() * initial_gradient_cap) as u64,
5605            usage: batch_limits.data_buffer_usage(),
5606            mapped_at_creation: false,
5607        });
5608
5609        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5610            label: Some("Shape Bind Group"),
5611            layout: bind_group_layout,
5612            entries: &shape_batch_bind_group_entries(
5613                &shape_buffer,
5614                &gradient_buffer,
5615                similarity_buffer,
5616                paint_buffer,
5617            ),
5618        });
5619
5620        Self {
5621            shape_buffer,
5622            gradient_buffer,
5623            bind_group,
5624            shape_capacity: initial_shape_cap,
5625            gradient_capacity: initial_gradient_cap,
5626            batch_limits,
5627        }
5628    }
5629
5630    /// Ensure buffers have enough capacity, resizing if needed.
5631    /// Clamps growth to prevent excessive allocations for huge scenes.
5632    fn ensure_capacity(
5633        &mut self,
5634        device: &wgpu::Device,
5635        bind_group_layout: &wgpu::BindGroupLayout,
5636        similarity_buffer: &wgpu::Buffer,
5637        paint_buffer: Option<&wgpu::Buffer>,
5638        shapes_needed: usize,
5639        gradients_needed: usize,
5640    ) {
5641        let mut need_bind_group_update = false;
5642
5643        // In uniform mode the shape and gradient buffers start at the cap
5644        // (the shader's fixed-size array length) so these never fire; in
5645        // storage mode they double toward the cap as scenes demand.
5646        if shapes_needed > self.shape_capacity
5647            && self.shape_capacity < self.batch_limits.max_shapes_per_batch
5648        {
5649            let new_cap = shapes_needed
5650                .next_power_of_two()
5651                .min(self.batch_limits.max_shapes_per_batch);
5652            self.shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5653                label: Some("Shape Data Buffer"),
5654                size: (std::mem::size_of::<ShapeData>() * new_cap) as u64,
5655                usage: self.batch_limits.data_buffer_usage(),
5656                mapped_at_creation: false,
5657            });
5658            self.shape_capacity = new_cap;
5659            need_bind_group_update = true;
5660        }
5661
5662        if gradients_needed > self.gradient_capacity
5663            && self.gradient_capacity < self.batch_limits.max_gradient_stops
5664        {
5665            let new_cap = gradients_needed
5666                .max(1)
5667                .next_power_of_two()
5668                .min(self.batch_limits.max_gradient_stops);
5669            self.gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5670                label: Some("Gradient Buffer"),
5671                size: (std::mem::size_of::<GradientStop>() * new_cap) as u64,
5672                usage: self.batch_limits.data_buffer_usage(),
5673                mapped_at_creation: false,
5674            });
5675            self.gradient_capacity = new_cap;
5676            need_bind_group_update = true;
5677        }
5678
5679        if need_bind_group_update {
5680            self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5681                label: Some("Shape Bind Group"),
5682                layout: bind_group_layout,
5683                entries: &shape_batch_bind_group_entries(
5684                    &self.shape_buffer,
5685                    &self.gradient_buffer,
5686                    similarity_buffer,
5687                    paint_buffer,
5688                ),
5689            });
5690        }
5691    }
5692}
5693
5694#[cfg(target_arch = "wasm32")]
5695impl UniformBatchBuffer {
5696    fn new(device: &wgpu::Device, bind_group_layout: &wgpu::BindGroupLayout) -> Self {
5697        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
5698            label: Some("Viewport Uniform Batch Buffer"),
5699            size: std::mem::size_of::<Uniforms>() as u64,
5700            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5701            mapped_at_creation: false,
5702        });
5703        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5704            label: Some("Viewport Uniform Batch Bind Group"),
5705            layout: bind_group_layout,
5706            entries: &[wgpu::BindGroupEntry {
5707                binding: 0,
5708                resource: buffer.as_entire_binding(),
5709            }],
5710        });
5711        Self { buffer, bind_group }
5712    }
5713}
5714
5715#[cfg(target_arch = "wasm32")]
5716impl ImageBatchBuffers {
5717    fn new(device: &wgpu::Device) -> Self {
5718        let vertex_capacity = 4;
5719        let index_capacity = 6;
5720        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5721            label: Some("Image Vertex Batch Buffer"),
5722            size: (std::mem::size_of::<Vertex>() * vertex_capacity) as u64,
5723            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5724            mapped_at_creation: false,
5725        });
5726        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5727            label: Some("Image Index Batch Buffer"),
5728            size: (std::mem::size_of::<u32>() * index_capacity) as u64,
5729            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5730            mapped_at_creation: false,
5731        });
5732        Self {
5733            vertex_buffer,
5734            index_buffer,
5735            vertex_capacity,
5736            index_capacity,
5737        }
5738    }
5739
5740    fn ensure_capacity(
5741        &mut self,
5742        device: &wgpu::Device,
5743        vertices_needed: usize,
5744        indices_needed: usize,
5745    ) {
5746        let hard_max_bytes = HARD_MAX_BUFFER_MB * 1024 * 1024;
5747        if vertices_needed > self.vertex_capacity {
5748            let desired = vertices_needed.next_power_of_two();
5749            let max_count = hard_max_bytes / std::mem::size_of::<Vertex>();
5750            let new_cap = desired.min(max_count);
5751            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5752                label: Some("Image Vertex Batch Buffer"),
5753                size: (std::mem::size_of::<Vertex>() * new_cap) as u64,
5754                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5755                mapped_at_creation: false,
5756            });
5757            self.vertex_capacity = new_cap;
5758        }
5759        if indices_needed > self.index_capacity {
5760            let desired = indices_needed.next_power_of_two();
5761            let max_count = hard_max_bytes / std::mem::size_of::<u32>();
5762            let new_cap = desired.min(max_count);
5763            self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5764                label: Some("Image Index Batch Buffer"),
5765                size: (std::mem::size_of::<u32>() * new_cap) as u64,
5766                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5767                mapped_at_creation: false,
5768            });
5769            self.index_capacity = new_cap;
5770        }
5771    }
5772}
5773
5774// Text image cache keys are local to rasterized WGPU text batches
5775
5776pub struct GpuRenderer {
5777    pub(crate) device: Arc<wgpu::Device>,
5778    pub(crate) queue: Arc<wgpu::Queue>,
5779    /// Uncaptured-error record shared with the handler installed on
5780    /// `device` at construction ([`DeviceErrorSentry`];
5781    /// `CRANPOSE_SURVIVE_GPU_ERRORS` kill switch). Poisoned by any
5782    /// uncaptured error; the head of [`Self::render`] answers each
5783    /// poisoning with one cancelled packet.
5784    device_errors: Arc<DeviceErrorSentry>,
5785    /// This instance's renderer epoch, stamped by `init_gpu` at
5786    /// construction. A packet whose `renderer_epoch` differs was built
5787    /// against another instance and is cancelled at the head of
5788    /// [`Self::render`], never drawn.
5789    renderer_epoch: u64,
5790    /// The producer feed generation this store's slot universe belongs to:
5791    /// seeded at construction, advanced by `consume_replay_ops` when a
5792    /// higher-generation batch arrives (the batch itself carries the
5793    /// retirement releases). The store never reads the producer's
5794    /// thread-local — this field is its only generation authority.
5795    #[cfg(not(target_arch = "wasm32"))]
5796    store_feed_generation: u64,
5797    surface_format: wgpu::TextureFormat,
5798    adapter_backend: wgpu::Backend,
5799    shape_batch_limits: ShapeBatchLimits,
5800    /// `Some` exactly when the device granted [`wgpu::Features::PIPELINE_CACHE`]
5801    /// (Vulkan; the platform layer requests it where the adapter offers it).
5802    /// Every pipeline creation in this renderer passes it so the driver can
5803    /// reuse compiled code across creates — and across launches once
5804    /// [`crate::pipeline_disk_cache`] persists the blob.
5805    pipeline_cache: Option<wgpu::PipelineCache>,
5806    pipeline: PassPipeline,
5807    pipeline_dst_out: PassPipeline,
5808    /// `fs_solid` twin of `pipeline` (SrcOver only), for gradient-free draws.
5809    pipeline_solid: PassPipeline,
5810    /// `Some` exactly in storage mode: the retained-mesh pipeline (`vs_mesh`
5811    /// over a vertex buffer) that replay slots with a captured arc mesh draw
5812    /// through. Uniform-mode devices never host retained slots.
5813    #[cfg(not(target_arch = "wasm32"))]
5814    mesh_pipeline: PassPipeline,
5815    /// `Some` exactly when this renderer latched the instanced-quad path at
5816    /// construction (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0). Read
5817    /// ONCE per renderer lifetime — cached retained bundles encode the
5818    /// selection, so it must never move under them (see
5819    /// [`instanced_quads_enabled`]).
5820    #[cfg(not(target_arch = "wasm32"))]
5821    instanced_quads: Option<InstancedQuadPipelines>,
5822    uniform_bind_group_layout: wgpu::BindGroupLayout,
5823    shape_bind_group_layout: wgpu::BindGroupLayout,
5824    /// `Some` exactly in storage mode: the 16-byte stand-in every fresh
5825    /// batch binds at the paint entry (see `shape_batch_bind_group_entries`).
5826    dummy_paint_buffer: Option<wgpu::Buffer>,
5827    /// Shared identity binding for `@group(1) @binding(2)`: every freshly
5828    /// converted shape batch draws untransformed through this one buffer.
5829    identity_similarity_buffer: wgpu::Buffer,
5830    #[cfg(not(target_arch = "wasm32"))]
5831    replay_slots: ReplaySlotStore,
5832    image_pipeline: PassPipeline,
5833    image_pipeline_dst_out: PassPipeline,
5834    glyph_atlas_pipeline: PassPipeline,
5835    #[cfg(not(target_arch = "wasm32"))]
5836    retained_glyph_atlas_pipeline: PassPipeline,
5837    image_bind_group_layout: wgpu::BindGroupLayout,
5838    #[cfg(not(target_arch = "wasm32"))]
5839    retained_glyph_uniform_bind_group_layout: wgpu::BindGroupLayout,
5840    image_nearest_sampler: wgpu::Sampler,
5841    image_linear_sampler: wgpu::Sampler,
5842    text_fonts: SoftwareTextFontSet,
5843    // Persistent GPU buffers (reused across frames)
5844    #[cfg(not(target_arch = "wasm32"))]
5845    upload_buffer: wgpu::Buffer,
5846    #[cfg(not(target_arch = "wasm32"))]
5847    uniform_buffer: wgpu::Buffer,
5848    #[cfg(not(target_arch = "wasm32"))]
5849    uniform_bind_group: wgpu::BindGroup,
5850    #[cfg(not(target_arch = "wasm32"))]
5851    shape_buffers: ShapeBatchBuffers,
5852    #[cfg(not(target_arch = "wasm32"))]
5853    image_vertex_buffer: wgpu::Buffer,
5854    #[cfg(not(target_arch = "wasm32"))]
5855    image_index_buffer: wgpu::Buffer,
5856    #[cfg(not(target_arch = "wasm32"))]
5857    retained_glyph_uniform_buffer: wgpu::Buffer,
5858    #[cfg(not(target_arch = "wasm32"))]
5859    retained_glyph_uniform_bind_group: wgpu::BindGroup,
5860    #[cfg(not(target_arch = "wasm32"))]
5861    retained_glyph_uniform_stride: u64,
5862    #[cfg(not(target_arch = "wasm32"))]
5863    retained_glyph_uniform_capacity: usize,
5864    #[cfg(not(target_arch = "wasm32"))]
5865    retained_glyph_uniform_cursor: usize,
5866    #[cfg(target_arch = "wasm32")]
5867    wasm_uniform_batches: Vec<UniformBatchBuffer>,
5868    #[cfg(target_arch = "wasm32")]
5869    wasm_uniform_batch_cursor: usize,
5870    #[cfg(target_arch = "wasm32")]
5871    wasm_shape_batches: Vec<ShapeBatchBuffers>,
5872    #[cfg(target_arch = "wasm32")]
5873    wasm_shape_batch_cursor: usize,
5874    #[cfg(target_arch = "wasm32")]
5875    wasm_image_batches: Vec<ImageBatchBuffers>,
5876    #[cfg(target_arch = "wasm32")]
5877    wasm_image_batch_cursor: usize,
5878    image_texture_cache: BoundedLruCache<u64, CachedImageTexture>,
5879    /// Total `CachedImageTexture::bytes` currently in the cache.
5880    image_texture_cache_bytes: usize,
5881    text_image_cache: BoundedLruCache<TextImageCacheKey, CachedTextImage>,
5882    text_glyph_atlas: TextGlyphAtlas,
5883    text_glyph_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedTextGlyphRun>,
5884    #[cfg(not(target_arch = "wasm32"))]
5885    text_glyph_gpu_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedGpuTextGlyphRun>,
5886    text_glyph_mask_cache: SoftwareGlyphRasterCache,
5887    text_line_index_cache: TextLineIndexCache,
5888    scratch_shape_data: Vec<ShapeData>,
5889    scratch_gradients: Vec<GradientStop>,
5890    scratch_image_vertices: Vec<Vertex>,
5891    scratch_image_indices: Vec<u32>,
5892    scratch_image_cmds: Vec<ImageDrawCmd>,
5893    scratch_glyph_cmds: Vec<GlyphDrawCmd>,
5894    scratch_text_glyph_run: Vec<SoftwareGlyphAtlasRunGlyph>,
5895    scratch_text_glyph_placements: Vec<SoftwareGlyphAtlasPlacement>,
5896    scratch_text_glyph_quads: Vec<CachedTextGlyphQuad>,
5897    scratch_segment_items: Vec<(usize, SegmentDrawItem)>,
5898    scratch_effect_ranges: Vec<Range<usize>>,
5899    scratch_layer_events: Vec<LayerEvent>,
5900    staged_uploads: StagedBufferUploads,
5901    frame_graph_executor: WgpuFrameGraphExecutor,
5902    deferred_offscreen_releases: Vec<OffscreenTarget>,
5903    effect_renderer: EffectRenderer,
5904    layer_surface_cache: LayerSurfaceCache,
5905    observed_scene_range_cache_misses: BoundedLruCache<LayerRasterCacheKey, ()>,
5906    shadow_surface_cache: BoundedLruCache<ShadowSurfaceCacheKey, CachedShadowSurface>,
5907    shadow_surface_cache_bytes: u64,
5908    frame_stats: gpu_stats::FrameStats,
5909    last_frame_stats: Option<gpu_stats::FrameStatsSnapshot>,
5910    pending_frame_warmup_frames: u8,
5911    frame_count: u64,
5912    gpu_stats_enabled: bool,
5913    warning_state: RendererWarningState,
5914    #[cfg(not(target_arch = "wasm32"))]
5915    replay_upload_stats: ReplayUploadStats,
5916    #[cfg(not(target_arch = "wasm32"))]
5917    segment_encode_stats: SegmentEncodeStats,
5918    /// The frame's replay recolor patches, parked here by
5919    /// `consume_replay_ops` until the retained prepare arms drain them
5920    /// (`stage_replay_patches`). The vec this frame's ops displace is last
5921    /// frame's, already drained empty, and returns to the producer with
5922    /// the ack — capacity ping-pongs planner queue → packet ops → here →
5923    /// ack return, so neither side allocates per frame (P4b).
5924    #[cfg(not(target_arch = "wasm32"))]
5925    replay_color_patches: Vec<crate::scene::ColorPatch>,
5926    /// Drain arena for `replay_color_patches`: `stage_replay_patches`
5927    /// swaps against this instead of `mem::take`, so both keep their
5928    /// high-water capacity across frames. Always empty between drains.
5929    #[cfg(not(target_arch = "wasm32"))]
5930    color_patch_scratch: Vec<crate::scene::ColorPatch>,
5931    /// Capture staging scratch for `capture_replay_slot`: the converted
5932    /// `ShapeData` records and gradient stops are built here, copied into
5933    /// the slot's fresh GPU buffers, and the allocations survive to the
5934    /// next capture — a re-partition frame captures one slot per segment
5935    /// and used to allocate both vectors per slot.
5936    #[cfg(not(target_arch = "wasm32"))]
5937    replay_capture_shape_scratch: Vec<ShapeData>,
5938    /// The gradient-stop half of the capture staging scratch.
5939    #[cfg(not(target_arch = "wasm32"))]
5940    replay_capture_gradient_scratch: Vec<GradientStop>,
5941    /// Recycled confirmations buffer for the next [`crate::frame_packet::ReplayAck`]:
5942    /// `consume_replay_ops` fills it, the planner drains it in `apply_ack`,
5943    /// and the render loop hands the emptied vec (capacity intact) back
5944    /// here — the ack channel's half of the P4b no-allocation contract.
5945    #[cfg(not(target_arch = "wasm32"))]
5946    replay_ack_confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
5947    /// Lifetime count of replay-ops batches dropped whole by the
5948    /// generation check in `consume_replay_ops` — fail-closed against ops
5949    /// planned under a slot universe this store no longer holds.
5950    /// Synchronously impossible today; structural for the pipeline split.
5951    #[cfg(not(target_arch = "wasm32"))]
5952    replay_generation_drops: u64,
5953    /// Cached render bundles for maximal consecutive retained stretches in
5954    /// the fused segment pass (`CRANPOSE_RETAINED_BUNDLES` kill switch).
5955    #[cfg(not(target_arch = "wasm32"))]
5956    retained_bundle_cache: RetainedBundleCache,
5957    /// Per-frame scratch for transient rim band meshes (`rim_mesh_band`):
5958    /// appended per fused chunk, cleared at the top of every frame. Index
5959    /// values are absolute into the frame's vertex list, so later chunks
5960    /// append without rebasing.
5961    #[cfg(not(target_arch = "wasm32"))]
5962    rim_mesh_vertices: Vec<MeshVertex>,
5963    #[cfg(not(target_arch = "wasm32"))]
5964    rim_mesh_indices: Vec<u32>,
5965    /// Fixed-capacity GPU twins of the rim scratch vecs, created lazily on
5966    /// the first rim ([`RIM_MESH_VERTEX_CAPACITY`] /
5967    /// [`RIM_MESH_INDEX_CAPACITY`]). NEVER recreated mid-frame: draws are
5968    /// encoded before submit, so a replacement buffer would orphan every
5969    /// already-encoded rim draw.
5970    #[cfg(not(target_arch = "wasm32"))]
5971    rim_mesh_vertex_buffer: Option<wgpu::Buffer>,
5972    #[cfg(not(target_arch = "wasm32"))]
5973    rim_mesh_index_buffer: Option<wgpu::Buffer>,
5974    /// Counts of scratch vertices/indices already uploaded this frame, so
5975    /// each fused chunk uploads only its newly appended region.
5976    #[cfg(not(target_arch = "wasm32"))]
5977    rim_mesh_uploaded_vertices: usize,
5978    #[cfg(not(target_arch = "wasm32"))]
5979    rim_mesh_uploaded_indices: usize,
5980    /// Lifetime count of rims drawn as band meshes — the test hook behind
5981    /// [`Self::rim_meshes_emitted`].
5982    #[cfg(not(target_arch = "wasm32"))]
5983    rim_meshes_emitted: u64,
5984    /// Submitted fill-area accounting (`CRANPOSE_FILL_DIAG`); idle unless
5985    /// the flag is set.
5986    #[cfg(not(target_arch = "wasm32"))]
5987    fill_area_diag: FillAreaDiag,
5988    /// Opaque static leading-span cache (`CRANPOSE_STATIC_SPAN` kill
5989    /// switch): the frame's byte-stable leading draws as one cached
5990    /// full-target blit.
5991    #[cfg(not(target_arch = "wasm32"))]
5992    static_span: StaticSpanCache,
5993    /// Retained-segment surface cache (`CRANPOSE_SEGMENT_SURFACE` opt-in,
5994    /// see [`crate::segment_surface`]): qualifying retained spans rendered
5995    /// once into pooled offscreens and re-drawn per frame as one rotated/
5996    /// scaled textured quad each.
5997    #[cfg(not(target_arch = "wasm32"))]
5998    segment_surfaces: SegmentSurfaceCache,
5999    /// Display clip region cull (see [`crate::display_clip`]): the
6000    /// platform-provided visible region plus the per-size occluder/depth
6001    /// resources. Inert — nothing beyond the enum is ever populated —
6002    /// while the region is [`DisplayVisibleRegion::Full`].
6003    #[cfg(not(target_arch = "wasm32"))]
6004    display_clip: DisplayClipState,
6005}
6006
6007/// Cache key of the display-clip resources: the surface size and the
6008/// region whose complement the occluder was tessellated for.
6009#[cfg(not(target_arch = "wasm32"))]
6010type DisplayClipResourceKey = ((u32, u32), DisplayVisibleRegion);
6011
6012/// State of the display clip region cull, all renderer-side.
6013#[cfg(not(target_arch = "wasm32"))]
6014struct DisplayClipState {
6015    /// The visible region from
6016    /// [`GpuRenderer::set_display_visible_region`] — platform (or host)
6017    /// truth about the panel, never derived from app content. `Full` for
6018    /// every rectangular display; `InscribedCircle` is the round-display
6019    /// provider's value.
6020    visible_region: DisplayVisibleRegion,
6021    /// The view the current frame's packet renders to, set for the duration
6022    /// of [`GpuRenderer::render`]. The fused pass culls only when its
6023    /// target IS this view — full-frame-sized offscreen layer surfaces
6024    /// must render whole (their content can be transformed into view
6025    /// later), so size alone is not the test.
6026    frame_root_view: Option<wgpu::TextureView>,
6027    /// True exactly while a fused pass that carries the depth attachment is
6028    /// being encoded: every pipeline getter consults it to hand out the
6029    /// depth-tested variant, which keeps the dozens of draw sites (and the
6030    /// retained-bundle builder) untouched.
6031    pass_depth: Cell<bool>,
6032    /// Depth attachment + occluder geometry for the current (size, region)
6033    /// pair, or an inner `None` when the region's complement tessellation
6034    /// failed its conservative verification for this size (cull stays
6035    /// off; never retried until size or region changes).
6036    resources: Option<(DisplayClipResourceKey, Option<DisplayClipResources>)>,
6037    occluder_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
6038}
6039
6040#[cfg(not(target_arch = "wasm32"))]
6041impl DisplayClipState {
6042    fn new() -> Self {
6043        Self {
6044            visible_region: DisplayVisibleRegion::Full,
6045            frame_root_view: None,
6046            pass_depth: Cell::new(false),
6047            resources: None,
6048            occluder_pipeline: LazyGpuResource::new("display-clip/occluder"),
6049        }
6050    }
6051}
6052
6053/// Per-(surface-size, region) GPU resources of the display clip cull.
6054#[cfg(not(target_arch = "wasm32"))]
6055struct DisplayClipResources {
6056    /// `Depth16Unorm`, cleared each culled pass, stored never
6057    /// (`StoreOp::Discard`) — transient GMEM residency on tilers.
6058    depth_view: wgpu::TextureView,
6059    /// The region complement's conservative tessellation, NDC positions,
6060    /// triangle list.
6061    occluder_vertex_buffer: wgpu::Buffer,
6062    occluder_vertex_count: u32,
6063}
6064
6065/// Running totals for retained-slot patch uploads, the paint-bandwidth
6066/// instrument: recolors upload 16-byte paint records (plus gradient stop
6067/// spans), coalesced per slot between the lowest and highest patched
6068/// index, so `bytes` versus `ideal_bytes` (patched colors alone) is just
6069/// the untouched records inside each coalesced span.
6070#[cfg(not(target_arch = "wasm32"))]
6071#[derive(Default)]
6072struct ReplayUploadStats {
6073    calls: u64,
6074    patched_calls: u64,
6075    patches: u64,
6076    slots: u64,
6077    records: u64,
6078    bytes: u64,
6079    ideal_bytes: u64,
6080    max_frame_bytes: u64,
6081}
6082
6083#[cfg(not(target_arch = "wasm32"))]
6084impl ReplayUploadStats {
6085    /// One aggregate line roughly every few seconds: cheap enough to stay
6086    /// on unconditionally, which matters because the watch cannot take
6087    /// setprop-backed diag flags — its logcat is the only channel, and a
6088    /// measurement window must catch several lines. Counts every drain
6089    /// call (the drain runs several times per frame; only the first sees
6090    /// patches) so a target with zero paint traffic still reports an
6091    /// affirmative zero instead of silence, while the averages divide by
6092    /// PATCHED calls so they read as per-frame numbers.
6093    /// warn level: the platform loggers filter info on desktop.
6094    const REPORT_CALLS: u64 = 1024;
6095
6096    fn note_frame(&mut self, patches: u64, slots: u64, records: u64, bytes: u64, ideal: u64) {
6097        self.calls += 1;
6098        if patches > 0 {
6099            self.patched_calls += 1;
6100            self.patches += patches;
6101            self.slots += slots;
6102            self.records += records;
6103            self.bytes += bytes;
6104            self.ideal_bytes += ideal;
6105            self.max_frame_bytes = self.max_frame_bytes.max(bytes);
6106        }
6107        if self.calls >= Self::REPORT_CALLS {
6108            let patched = self.patched_calls.max(1);
6109            log::warn!(
6110                "[replay-upload] {} patched of {} drains: avg {:.1} KB/frame (max {:.1} KB), \
6111                 color-only would be {:.1} KB/frame; avg {} patches over {} records in {} slots",
6112                self.patched_calls,
6113                self.calls,
6114                self.bytes as f64 / patched as f64 / 1024.0,
6115                self.max_frame_bytes as f64 / 1024.0,
6116                self.ideal_bytes as f64 / patched as f64 / 1024.0,
6117                self.patches / patched,
6118                self.records / patched,
6119                self.slots / patched,
6120            );
6121            *self = Self::default();
6122        }
6123    }
6124}
6125
6126/// Aggregate cost of the fused native partition loop — the numbers a
6127/// parallel-encode decision needs: how many partitions each chunk carries
6128/// and how long the serial loop spends encoding them. Always-on for the
6129/// same reason as [`ReplayUploadStats`]: the watch takes no setprop diag
6130/// flags, so the line has to reach logcat on its own, and one warn every
6131/// [`Self::REPORT_CALLS`] chunks is bounded.
6132#[cfg(not(target_arch = "wasm32"))]
6133#[derive(Default)]
6134struct SegmentEncodeStats {
6135    calls: u64,
6136    partitions: u64,
6137    max_partitions: u64,
6138    encode_micros: u64,
6139    max_call_micros: u64,
6140}
6141
6142#[cfg(not(target_arch = "wasm32"))]
6143impl SegmentEncodeStats {
6144    const REPORT_CALLS: u64 = 1024;
6145
6146    fn note_call(&mut self, partitions: u64, micros: u64) {
6147        self.calls += 1;
6148        self.partitions += partitions;
6149        self.max_partitions = self.max_partitions.max(partitions);
6150        self.encode_micros += micros;
6151        self.max_call_micros = self.max_call_micros.max(micros);
6152        if self.calls >= Self::REPORT_CALLS {
6153            log::warn!(
6154                "[segment-encode] {} chunks: avg {:.1} partitions (max {}), \
6155                 avg {:.2} ms encode (max {:.2})",
6156                self.calls,
6157                self.partitions as f64 / self.calls as f64,
6158                self.max_partitions,
6159                self.encode_micros as f64 / self.calls as f64 / 1000.0,
6160                self.max_call_micros as f64 / 1000.0,
6161            );
6162            *self = Self::default();
6163        }
6164    }
6165}
6166
6167fn image_sampler_descriptor(sampling: ImageSampling) -> wgpu::SamplerDescriptor<'static> {
6168    let filter = match sampling {
6169        ImageSampling::Nearest => wgpu::FilterMode::Nearest,
6170        ImageSampling::Linear => wgpu::FilterMode::Linear,
6171    };
6172    wgpu::SamplerDescriptor {
6173        label: Some(match sampling {
6174            ImageSampling::Nearest => "Nearest Image Sampler",
6175            ImageSampling::Linear => "Linear Image Sampler",
6176        }),
6177        address_mode_u: wgpu::AddressMode::ClampToEdge,
6178        address_mode_v: wgpu::AddressMode::ClampToEdge,
6179        address_mode_w: wgpu::AddressMode::ClampToEdge,
6180        mag_filter: filter,
6181        min_filter: filter,
6182        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
6183        ..Default::default()
6184    }
6185}
6186
6187#[cfg(test)]
6188fn layer_raster_cache_candidate(
6189    layer: &LayerNode,
6190    root_scale: f32,
6191    has_backdrop_underlay: bool,
6192    allow_runtime_cache: bool,
6193) -> Option<(LayerRasterCacheKey, Rect)> {
6194    let mut layer_surface_requirements_cache = HashMap::new();
6195    let surface_requirements =
6196        layer_surface_requirements_cached(layer, &mut layer_surface_requirements_cache);
6197    let runtime_cache_is_safe = allow_runtime_cache
6198        && surface_requirements
6199            .surface_requirements
6200            .has_isolating_requirement()
6201        && !surface_requirements.contains_runtime_shader;
6202    let cache_is_allowed = layer.cache_policy == CachePolicy::Auto
6203        || (allow_runtime_cache && surface_requirements.has_renderer_forced_surface())
6204        || runtime_cache_is_safe;
6205    if !cache_is_allowed {
6206        return None;
6207    }
6208    if layer_uses_external_backdrop_input(layer, has_backdrop_underlay) {
6209        return None;
6210    }
6211    // Not just this layer's own effect: a shader anywhere below it makes the
6212    // whole subtree change every frame with nothing in any hash to say so.
6213    if surface_requirements.contains_runtime_shader {
6214        return None;
6215    }
6216
6217    let logical_rect = estimate_layer_surface_rect(layer);
6218    let pixel_size = surface_target_size(logical_rect, root_scale, u32::MAX);
6219    Some((
6220        LayerRasterCacheKey::new(
6221            layer.node_id,
6222            layer.target_content_hash(),
6223            layer.effect_hash(),
6224            logical_rect,
6225            pixel_size,
6226            ScaleBucket::from_scale(root_scale),
6227        ),
6228        logical_rect,
6229    ))
6230}
6231
6232impl GpuRenderer {
6233    #[allow(clippy::too_many_arguments)]
6234    pub fn new(
6235        device: Arc<wgpu::Device>,
6236        queue: Arc<wgpu::Queue>,
6237        surface_format: wgpu::TextureFormat,
6238        adapter_backend: wgpu::Backend,
6239        // Beside the backend because it is the same kind of fact: something
6240        // only the ADAPTER can answer, which the device cannot be asked for
6241        // (wgpu 29 has `Adapter::get_downlevel_capabilities` and no device
6242        // equivalent) and which decides whether the shape arrays can be
6243        // storage buffers at all.
6244        adapter_downlevel: wgpu::DownlevelFlags,
6245        text_fonts: SoftwareTextFontSet,
6246        renderer_epoch: u64,
6247        store_feed_generation: u64,
6248    ) -> Self {
6249        #[cfg(target_arch = "wasm32")]
6250        let _ = store_feed_generation;
6251        // Construction time is worth a line of its own. Before pipelines were
6252        // built lazily this call linked every pipeline the frontend could ever
6253        // need, and on a GL device each link ended in a blocking
6254        // `glGetProgramiv` -- 25 s on an emulator, with nothing on screen. That
6255        // is fixed, but "fixed" is a claim that needs a number on each device,
6256        // and the per-pipeline `[gpu-pipeline]` lines cannot say what the
6257        // renderer costs to build when it builds no pipelines at all.
6258        let construction_started = Instant::now();
6259        // Installed before this renderer's first device call, so even a
6260        // construction-time validation error is survived. Replaces wgpu's
6261        // fatal default handler — see [`DeviceErrorSentry`] for the
6262        // double-panic abort this prevents and
6263        // [`survive_gpu_errors_enabled`] for the kill switch.
6264        let device_errors = Arc::new(DeviceErrorSentry::default());
6265        if survive_gpu_errors_enabled() {
6266            let sentry = Arc::clone(&device_errors);
6267            device.on_uncaptured_error(Arc::new(move |error| sentry.record(&error)));
6268        }
6269        let shape_batch_limits = ShapeBatchLimits::for_device(&device, adapter_downlevel);
6270        let uniform_bind_group_layout =
6271            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6272                label: Some("Uniform Bind Group Layout"),
6273                entries: &[wgpu::BindGroupLayoutEntry {
6274                    binding: 0,
6275                    visibility: wgpu::ShaderStages::VERTEX,
6276                    ty: wgpu::BindingType::Buffer {
6277                        ty: wgpu::BufferBindingType::Uniform,
6278                        has_dynamic_offset: false,
6279                        min_binding_size: None,
6280                    },
6281                    count: None,
6282                }],
6283            });
6284        #[cfg(not(target_arch = "wasm32"))]
6285        let retained_glyph_uniform_bind_group_layout =
6286            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6287                label: Some("Retained Glyph Dynamic Uniform Bind Group Layout"),
6288                entries: &[wgpu::BindGroupLayoutEntry {
6289                    binding: 0,
6290                    visibility: wgpu::ShaderStages::VERTEX,
6291                    ty: wgpu::BindingType::Buffer {
6292                        ty: wgpu::BufferBindingType::Uniform,
6293                        has_dynamic_offset: true,
6294                        min_binding_size: wgpu::BufferSize::new(
6295                            std::mem::size_of::<Uniforms>() as u64
6296                        ),
6297                    },
6298                    count: None,
6299                }],
6300            });
6301
6302        // Read-only storage bindings where the device has them (so a whole
6303        // scene fits one batch); uniform arrays on WebGL-class devices, which
6304        // have no storage buffers in fragment shaders. The shape array is
6305        // visible to the vertex stage as well: the pipeline has no vertex
6306        // buffer and `vs_main` pulls quad corners from ShapeData. Storage mode
6307        // is gated on `DownlevelFlags::VERTEX_STORAGE` as well as on the
6308        // limit -- see `ShapeBatchLimits::select`, where the comment this
6309        // replaces claimed GL reports the limit as the minimum across stages
6310        // and Mali proved otherwise.
6311        let mut shape_bind_group_layout_entries = vec![
6312            wgpu::BindGroupLayoutEntry {
6313                binding: 0,
6314                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
6315                ty: wgpu::BindingType::Buffer {
6316                    ty: shape_batch_limits.data_binding_type(),
6317                    has_dynamic_offset: false,
6318                    min_binding_size: None,
6319                },
6320                count: None,
6321            },
6322            wgpu::BindGroupLayoutEntry {
6323                binding: 1,
6324                visibility: wgpu::ShaderStages::FRAGMENT,
6325                ty: wgpu::BindingType::Buffer {
6326                    ty: shape_batch_limits.data_binding_type(),
6327                    has_dynamic_offset: false,
6328                    min_binding_size: None,
6329                },
6330                count: None,
6331            },
6332            // The similarity transform rides a dynamic offset so
6333            // retained draws sharing one captured batch can each
6334            // apply their own transform; ordinary batches pass
6335            // offset 0 into the identity buffer.
6336            wgpu::BindGroupLayoutEntry {
6337                binding: 2,
6338                visibility: wgpu::ShaderStages::VERTEX,
6339                ty: wgpu::BindingType::Buffer {
6340                    ty: wgpu::BufferBindingType::Uniform,
6341                    has_dynamic_offset: true,
6342                    min_binding_size: wgpu::BufferSize::new(
6343                        std::mem::size_of::<SimilarityTransform>() as u64,
6344                    ),
6345                },
6346                count: None,
6347            },
6348        ];
6349        // Retained-slot paint colors, read by the vertex stage under
6350        // `paint_select` (see `shape_shader_source`). Storage mode only:
6351        // the uniform-variant shader never declares the array, and
6352        // uniform-mode devices never host retained slots, so their layout
6353        // stays exactly the three-entry one the uniform pipeline expects.
6354        if shape_batch_limits.storage {
6355            shape_bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry {
6356                binding: 3,
6357                visibility: wgpu::ShaderStages::VERTEX,
6358                ty: wgpu::BindingType::Buffer {
6359                    ty: wgpu::BufferBindingType::Storage { read_only: true },
6360                    has_dynamic_offset: false,
6361                    min_binding_size: None,
6362                },
6363                count: None,
6364            });
6365        }
6366        let shape_bind_group_layout =
6367            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6368                label: Some("Shape Bind Group Layout"),
6369                entries: &shape_bind_group_layout_entries,
6370            });
6371
6372        let identity_similarity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6373            label: Some("Identity Similarity Buffer"),
6374            size: std::mem::size_of::<SimilarityTransform>() as u64,
6375            usage: wgpu::BufferUsages::UNIFORM,
6376            mapped_at_creation: true,
6377        });
6378        identity_similarity_buffer
6379            .slice(..)
6380            .get_mapped_range_mut()
6381            .copy_from_slice(bytemuck::bytes_of(&SimilarityTransform::IDENTITY));
6382        identity_similarity_buffer.unmap();
6383
6384        // Fresh-batch bind groups need a resource at the paint binding even
6385        // though their draws leave `paint_select` at 0.0 and never use the
6386        // value; one minimal buffer (a single never-read vec4) serves every
6387        // batch. Uniform-mode layouts have no paint entry, so none exists.
6388        let dummy_paint_buffer = shape_batch_limits.storage.then(|| {
6389            device.create_buffer(&wgpu::BufferDescriptor {
6390                label: Some("Dummy Paint Buffer"),
6391                size: std::mem::size_of::<[f32; 4]>() as u64,
6392                usage: wgpu::BufferUsages::STORAGE,
6393                mapped_at_creation: false,
6394            })
6395        });
6396        #[cfg(not(target_arch = "wasm32"))]
6397        let replay_slot_store = ReplaySlotStore::new(&device);
6398
6399        let pipeline = PassPipeline::new("shape/src-over", "shape/src-over-depth");
6400        let pipeline_dst_out = PassPipeline::new("shape/dst-out", "shape/dst-out-depth");
6401        let pipeline_solid =
6402            PassPipeline::new("shape/solid-src-over", "shape/solid-src-over-depth");
6403        #[cfg(not(target_arch = "wasm32"))]
6404        let mesh_pipeline = PassPipeline::new("shape/mesh", "shape/mesh-depth");
6405        // The instanced-quad selection is LATCHED here, once per renderer:
6406        // cached retained bundles encode whichever pipelines this resolves
6407        // to, so a per-draw env read could let a bundle replay a selection
6408        // the direct path no longer makes. Storage mode only — the
6409        // uniform/WebGL path keeps `vs_main` and its plain draws untouched.
6410        #[cfg(not(target_arch = "wasm32"))]
6411        let instanced_quads =
6412            (shape_batch_limits.storage && instanced_quads_enabled()).then(|| {
6413                let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6414                    label: Some("Instanced Quad Index Buffer"),
6415                    size: std::mem::size_of_val(&INSTANCED_QUAD_INDICES) as u64,
6416                    usage: wgpu::BufferUsages::INDEX,
6417                    mapped_at_creation: true,
6418                });
6419                index_buffer
6420                    .slice(..)
6421                    .get_mapped_range_mut()
6422                    .copy_from_slice(bytemuck::cast_slice(&INSTANCED_QUAD_INDICES));
6423                index_buffer.unmap();
6424                InstancedQuadPipelines {
6425                    pipeline: PassPipeline::new(
6426                        "shape/instanced-src-over",
6427                        "shape/instanced-src-over-depth",
6428                    ),
6429                    pipeline_dst_out: PassPipeline::new(
6430                        "shape/instanced-dst-out",
6431                        "shape/instanced-dst-out-depth",
6432                    ),
6433                    pipeline_solid: PassPipeline::new(
6434                        "shape/instanced-solid",
6435                        "shape/instanced-solid-depth",
6436                    ),
6437                    index_buffer,
6438                }
6439            });
6440
6441        let image_bind_group_layout =
6442            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6443                label: Some("Image Texture Bind Group Layout"),
6444                entries: &[
6445                    wgpu::BindGroupLayoutEntry {
6446                        binding: 0,
6447                        visibility: wgpu::ShaderStages::FRAGMENT,
6448                        ty: wgpu::BindingType::Texture {
6449                            multisampled: false,
6450                            view_dimension: wgpu::TextureViewDimension::D2,
6451                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
6452                        },
6453                        count: None,
6454                    },
6455                    wgpu::BindGroupLayoutEntry {
6456                        binding: 1,
6457                        visibility: wgpu::ShaderStages::FRAGMENT,
6458                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
6459                        count: None,
6460                    },
6461                ],
6462            });
6463
6464        let image_pipeline = PassPipeline::new("image/src-over", "image/src-over-depth");
6465        let image_pipeline_dst_out = PassPipeline::new("image/dst-out", "image/dst-out-depth");
6466        let glyph_atlas_pipeline = PassPipeline::new("glyph/shared", "glyph/shared-depth");
6467        #[cfg(not(target_arch = "wasm32"))]
6468        let retained_glyph_atlas_pipeline =
6469            PassPipeline::new("glyph/retained", "glyph/retained-depth");
6470
6471        #[cfg(not(target_arch = "wasm32"))]
6472        let upload_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6473            label: Some("Frame Upload Buffer"),
6474            size: INITIAL_UPLOAD_BUFFER_BYTES,
6475            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
6476            mapped_at_creation: false,
6477        });
6478
6479        #[cfg(not(target_arch = "wasm32"))]
6480        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6481            label: Some("Uniform Buffer"),
6482            size: std::mem::size_of::<Uniforms>() as u64,
6483            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
6484            mapped_at_creation: false,
6485        });
6486
6487        #[cfg(not(target_arch = "wasm32"))]
6488        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
6489            label: Some("Uniform Bind Group"),
6490            layout: &uniform_bind_group_layout,
6491            entries: &[wgpu::BindGroupEntry {
6492                binding: 0,
6493                resource: uniform_buffer.as_entire_binding(),
6494            }],
6495        });
6496
6497        #[cfg(not(target_arch = "wasm32"))]
6498        let shape_buffers = ShapeBatchBuffers::new(
6499            &device,
6500            &shape_bind_group_layout,
6501            &identity_similarity_buffer,
6502            dummy_paint_buffer.as_ref(),
6503            shape_batch_limits,
6504        );
6505
6506        let image_nearest_sampler =
6507            device.create_sampler(&image_sampler_descriptor(ImageSampling::Nearest));
6508        let image_linear_sampler =
6509            device.create_sampler(&image_sampler_descriptor(ImageSampling::Linear));
6510        let text_glyph_atlas = TextGlyphAtlas::new(
6511            &device,
6512            &image_bind_group_layout,
6513            &image_nearest_sampler,
6514            TEXT_GLYPH_ATLAS_MIN_SIZE,
6515        );
6516
6517        #[cfg(not(target_arch = "wasm32"))]
6518        let image_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6519            label: Some("Image Vertex Buffer"),
6520            size: (std::mem::size_of::<Vertex>() * 4) as u64,
6521            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
6522            mapped_at_creation: false,
6523        });
6524
6525        #[cfg(not(target_arch = "wasm32"))]
6526        let image_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6527            label: Some("Image Index Buffer"),
6528            size: (std::mem::size_of::<u32>() * 6) as u64,
6529            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
6530            mapped_at_creation: false,
6531        });
6532        #[cfg(not(target_arch = "wasm32"))]
6533        let retained_glyph_uniform_stride = align_usize_to(
6534            std::mem::size_of::<Uniforms>(),
6535            (device.limits().min_uniform_buffer_offset_alignment as usize)
6536                .max(wgpu::COPY_BUFFER_ALIGNMENT as usize),
6537        ) as u64;
6538        #[cfg(not(target_arch = "wasm32"))]
6539        let retained_glyph_uniform_capacity = INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS;
6540        #[cfg(not(target_arch = "wasm32"))]
6541        let retained_glyph_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6542            label: Some("Retained Glyph Uniform Buffer"),
6543            size: retained_glyph_uniform_stride * retained_glyph_uniform_capacity as u64,
6544            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
6545            mapped_at_creation: false,
6546        });
6547        #[cfg(not(target_arch = "wasm32"))]
6548        let retained_glyph_uniform_bind_group =
6549            device.create_bind_group(&wgpu::BindGroupDescriptor {
6550                label: Some("Retained Glyph Uniform Bind Group"),
6551                layout: &retained_glyph_uniform_bind_group_layout,
6552                entries: &[wgpu::BindGroupEntry {
6553                    binding: 0,
6554                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
6555                        buffer: &retained_glyph_uniform_buffer,
6556                        offset: 0,
6557                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
6558                    }),
6559                }],
6560            });
6561
6562        // The cache handle costs nothing to create and pays on every device:
6563        // in-process, the shape family's permutations share most of their
6564        // compiled code; across launches, the persisted blob turns first-use
6565        // compiles (2.0 s of render thread inside the first six seconds on a
6566        // Pixel Watch 3) into cache hits. `None` where the device lacks the
6567        // feature — every creation site then behaves exactly as before.
6568        #[cfg(not(target_arch = "wasm32"))]
6569        let pipeline_cache = crate::pipeline_disk_cache::load(&device);
6570        #[cfg(target_arch = "wasm32")]
6571        let pipeline_cache: Option<wgpu::PipelineCache> = None;
6572        #[cfg(not(target_arch = "wasm32"))]
6573        if let Some(cache) = pipeline_cache.clone() {
6574            crate::pipeline_disk_cache::spawn_persist_schedule(cache);
6575            spawn_pipeline_prewarm(PipelinePrewarmInputs {
6576                device: Arc::clone(&device),
6577                cache: pipeline_cache.clone(),
6578                surface_format,
6579                uniform_layout: uniform_bind_group_layout.clone(),
6580                shape_layout: shape_bind_group_layout.clone(),
6581                image_layout: image_bind_group_layout.clone(),
6582                batch_limits: shape_batch_limits,
6583                instanced: instanced_quads.is_some(),
6584            });
6585        }
6586
6587        let effects_started = Instant::now();
6588        let effect_renderer = EffectRenderer::new(
6589            &device,
6590            pipeline_cache.clone(),
6591            surface_format,
6592            adapter_backend,
6593        );
6594        let effects_ms = instant_ms(effects_started, Instant::now());
6595
6596        let renderer = Self {
6597            device,
6598            queue,
6599            device_errors,
6600            renderer_epoch,
6601            #[cfg(not(target_arch = "wasm32"))]
6602            store_feed_generation,
6603            surface_format,
6604            adapter_backend,
6605            shape_batch_limits,
6606            pipeline_cache,
6607            pipeline,
6608            pipeline_dst_out,
6609            pipeline_solid,
6610            #[cfg(not(target_arch = "wasm32"))]
6611            mesh_pipeline,
6612            #[cfg(not(target_arch = "wasm32"))]
6613            instanced_quads,
6614            uniform_bind_group_layout,
6615            shape_bind_group_layout,
6616            dummy_paint_buffer,
6617            identity_similarity_buffer,
6618            #[cfg(not(target_arch = "wasm32"))]
6619            replay_slots: replay_slot_store,
6620            image_pipeline,
6621            image_pipeline_dst_out,
6622            glyph_atlas_pipeline,
6623            #[cfg(not(target_arch = "wasm32"))]
6624            retained_glyph_atlas_pipeline,
6625            image_bind_group_layout,
6626            #[cfg(not(target_arch = "wasm32"))]
6627            retained_glyph_uniform_bind_group_layout,
6628            image_nearest_sampler,
6629            image_linear_sampler,
6630            text_fonts,
6631            #[cfg(not(target_arch = "wasm32"))]
6632            upload_buffer,
6633            #[cfg(not(target_arch = "wasm32"))]
6634            uniform_buffer,
6635            #[cfg(not(target_arch = "wasm32"))]
6636            uniform_bind_group,
6637            #[cfg(not(target_arch = "wasm32"))]
6638            shape_buffers,
6639            #[cfg(not(target_arch = "wasm32"))]
6640            image_vertex_buffer,
6641            #[cfg(not(target_arch = "wasm32"))]
6642            image_index_buffer,
6643            #[cfg(not(target_arch = "wasm32"))]
6644            retained_glyph_uniform_buffer,
6645            #[cfg(not(target_arch = "wasm32"))]
6646            retained_glyph_uniform_bind_group,
6647            #[cfg(not(target_arch = "wasm32"))]
6648            retained_glyph_uniform_stride,
6649            #[cfg(not(target_arch = "wasm32"))]
6650            retained_glyph_uniform_capacity,
6651            #[cfg(not(target_arch = "wasm32"))]
6652            retained_glyph_uniform_cursor: 0,
6653            #[cfg(target_arch = "wasm32")]
6654            wasm_uniform_batches: Vec::new(),
6655            #[cfg(target_arch = "wasm32")]
6656            wasm_uniform_batch_cursor: 0,
6657            #[cfg(target_arch = "wasm32")]
6658            wasm_shape_batches: Vec::new(),
6659            #[cfg(target_arch = "wasm32")]
6660            wasm_shape_batch_cursor: 0,
6661            #[cfg(target_arch = "wasm32")]
6662            wasm_image_batches: Vec::new(),
6663            #[cfg(target_arch = "wasm32")]
6664            wasm_image_batch_cursor: 0,
6665            image_texture_cache: BoundedLruCache::with_capacity_at_least_one(
6666                MAX_TEXTURE_CACHE_ITEMS,
6667            ),
6668            image_texture_cache_bytes: 0,
6669            text_image_cache: BoundedLruCache::with_capacity_at_least_one(
6670                MAX_TEXT_IMAGE_CACHE_ITEMS,
6671            ),
6672            text_glyph_atlas,
6673            text_glyph_run_cache: BoundedLruCache::with_capacity_at_least_one(
6674                MAX_TEXT_GLYPH_RUN_CACHE_ITEMS,
6675            ),
6676            #[cfg(not(target_arch = "wasm32"))]
6677            text_glyph_gpu_run_cache: BoundedLruCache::with_capacity_at_least_one(
6678                MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS,
6679            ),
6680            text_glyph_mask_cache: SoftwareGlyphRasterCache::with_capacity_at_least_one(
6681                MAX_TEXT_GLYPH_MASK_CACHE_ITEMS,
6682            ),
6683            text_line_index_cache: TextLineIndexCache::new(MAX_TEXT_LINE_INDEX_CACHE_ITEMS),
6684            scratch_shape_data: Vec::new(),
6685            scratch_gradients: Vec::new(),
6686            scratch_image_vertices: Vec::new(),
6687            scratch_image_indices: Vec::new(),
6688            scratch_image_cmds: Vec::new(),
6689            scratch_glyph_cmds: Vec::new(),
6690            scratch_text_glyph_run: Vec::new(),
6691            scratch_text_glyph_placements: Vec::new(),
6692            scratch_text_glyph_quads: Vec::new(),
6693            scratch_segment_items: Vec::new(),
6694            scratch_effect_ranges: Vec::new(),
6695            scratch_layer_events: Vec::new(),
6696            staged_uploads: StagedBufferUploads::default(),
6697            frame_graph_executor: WgpuFrameGraphExecutor::new(),
6698            deferred_offscreen_releases: Vec::new(),
6699            effect_renderer,
6700            layer_surface_cache: LayerSurfaceCache::new(),
6701            observed_scene_range_cache_misses: BoundedLruCache::with_capacity_at_least_one(
6702                MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES,
6703            ),
6704            shadow_surface_cache: BoundedLruCache::with_capacity_at_least_one(
6705                MAX_SHADOW_SURFACE_CACHE_ITEMS,
6706            ),
6707            shadow_surface_cache_bytes: 0,
6708            frame_stats: gpu_stats::FrameStats::default(),
6709            last_frame_stats: None,
6710            pending_frame_warmup_frames: 0,
6711            frame_count: 0,
6712            gpu_stats_enabled: gpu_stats_enabled(),
6713            warning_state: RendererWarningState::default(),
6714            #[cfg(not(target_arch = "wasm32"))]
6715            replay_upload_stats: ReplayUploadStats::default(),
6716            #[cfg(not(target_arch = "wasm32"))]
6717            segment_encode_stats: SegmentEncodeStats::default(),
6718            #[cfg(not(target_arch = "wasm32"))]
6719            replay_color_patches: Vec::new(),
6720            #[cfg(not(target_arch = "wasm32"))]
6721            color_patch_scratch: Vec::new(),
6722            #[cfg(not(target_arch = "wasm32"))]
6723            replay_capture_shape_scratch: Vec::new(),
6724            #[cfg(not(target_arch = "wasm32"))]
6725            replay_capture_gradient_scratch: Vec::new(),
6726            #[cfg(not(target_arch = "wasm32"))]
6727            replay_ack_confirmations: Vec::new(),
6728            #[cfg(not(target_arch = "wasm32"))]
6729            replay_generation_drops: 0,
6730            #[cfg(not(target_arch = "wasm32"))]
6731            retained_bundle_cache: RetainedBundleCache::new(),
6732            #[cfg(not(target_arch = "wasm32"))]
6733            rim_mesh_vertices: Vec::new(),
6734            #[cfg(not(target_arch = "wasm32"))]
6735            rim_mesh_indices: Vec::new(),
6736            #[cfg(not(target_arch = "wasm32"))]
6737            rim_mesh_vertex_buffer: None,
6738            #[cfg(not(target_arch = "wasm32"))]
6739            rim_mesh_index_buffer: None,
6740            #[cfg(not(target_arch = "wasm32"))]
6741            rim_mesh_uploaded_vertices: 0,
6742            #[cfg(not(target_arch = "wasm32"))]
6743            rim_mesh_uploaded_indices: 0,
6744            #[cfg(not(target_arch = "wasm32"))]
6745            rim_meshes_emitted: 0,
6746            #[cfg(not(target_arch = "wasm32"))]
6747            fill_area_diag: FillAreaDiag::default(),
6748            #[cfg(not(target_arch = "wasm32"))]
6749            static_span: StaticSpanCache::default(),
6750            #[cfg(not(target_arch = "wasm32"))]
6751            segment_surfaces: SegmentSurfaceCache::default(),
6752            #[cfg(not(target_arch = "wasm32"))]
6753            display_clip: DisplayClipState::new(),
6754        };
6755        log::info!(
6756            "[gpu-init] {:?} renderer ready in {:.1} ms (effects {:.1} ms); \
6757             pipelines build on first use",
6758            adapter_backend,
6759            instant_ms(construction_started, Instant::now()),
6760            effects_ms,
6761        );
6762        renderer
6763    }
6764
6765    /// The display's visible region (see [`crate::display_clip`]): the
6766    /// part of the full-screen surface the panel physically shows. Only
6767    /// the platform layer (or a host standing in for it) sets this —
6768    /// never app content. `Full` — the default — keeps the cull machinery
6769    /// structurally inert.
6770    #[cfg(not(target_arch = "wasm32"))]
6771    pub fn set_display_visible_region(&mut self, region: DisplayVisibleRegion) {
6772        self.display_clip.visible_region = region;
6773    }
6774
6775    /// Whether the pass currently being encoded carries the display-clip
6776    /// depth attachment; pipeline getters consult this to hand out the
6777    /// depth-tested variant.
6778    #[cfg(not(target_arch = "wasm32"))]
6779    fn pass_depth(&self) -> bool {
6780        self.display_clip.pass_depth.get()
6781    }
6782
6783    #[cfg(target_arch = "wasm32")]
6784    fn pass_depth(&self) -> bool {
6785        false
6786    }
6787
6788    /// Decides whether the fused pass about to be encoded is the culled
6789    /// one and returns its depth view: the visible region must leave
6790    /// something to cull, the kill switch must be open, and `target_view`
6791    /// must be THIS frame's root target with the pass viewport covering
6792    /// it whole. Offscreen layer passes — even full-frame-sized ones —
6793    /// never qualify: a layer's content can be transformed into view
6794    /// later.
6795    #[cfg(not(target_arch = "wasm32"))]
6796    fn display_clip_pass_depth_view(
6797        &mut self,
6798        target_view: &wgpu::TextureView,
6799        width: u32,
6800        height: u32,
6801    ) -> Option<wgpu::TextureView> {
6802        if !self.display_clip.visible_region.cullable() {
6803            return None;
6804        }
6805        if self.display_clip.frame_root_view.as_ref() != Some(target_view) {
6806            return None;
6807        }
6808        if !display_clip_cull_enabled() {
6809            return None;
6810        }
6811        self.ensure_display_clip_resources(width, height)
6812    }
6813
6814    /// Returns the depth view for the current (size, region) pair,
6815    /// tessellating the region's complement and building its vertex
6816    /// buffer and the depth attachment on first use. A tessellation that
6817    /// fails its conservative verification pins `None` for the pair: the
6818    /// cull stays off rather than ever touching a visible pixel.
6819    #[cfg(not(target_arch = "wasm32"))]
6820    fn ensure_display_clip_resources(
6821        &mut self,
6822        width: u32,
6823        height: u32,
6824    ) -> Option<wgpu::TextureView> {
6825        let region = self.display_clip.visible_region;
6826        let key = ((width, height), region);
6827        if let Some((cached_key, resources)) = &self.display_clip.resources {
6828            if *cached_key == key {
6829                return resources
6830                    .as_ref()
6831                    .map(|resources| resources.depth_view.clone());
6832            }
6833        }
6834        let built = display_clip::tessellate_complement(region, width, height).map(|mesh| {
6835            let occluder_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
6836                label: Some("Display Clip Occluder Vertices"),
6837                size: std::mem::size_of_val(mesh.vertices.as_slice()) as u64,
6838                usage: wgpu::BufferUsages::VERTEX,
6839                mapped_at_creation: true,
6840            });
6841            occluder_vertex_buffer
6842                .slice(..)
6843                .get_mapped_range_mut()
6844                .copy_from_slice(bytemuck::cast_slice(&mesh.vertices));
6845            occluder_vertex_buffer.unmap();
6846            let depth_texture = self.device.create_texture(&wgpu::TextureDescriptor {
6847                label: Some("Display Clip Depth"),
6848                size: wgpu::Extent3d {
6849                    width,
6850                    height,
6851                    depth_or_array_layers: 1,
6852                },
6853                mip_level_count: 1,
6854                sample_count: 1,
6855                dimension: wgpu::TextureDimension::D2,
6856                format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
6857                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6858                view_formats: &[],
6859            });
6860            // Once per (size, region), which is as rate-limited as it
6861            // gets. The round display — the capability's first provider —
6862            // keeps its own line.
6863            match region {
6864                DisplayVisibleRegion::InscribedCircle => log::info!(
6865                    "[display-clip] round display: corner cull active ({} px masked) at {width}x{height}",
6866                    mesh.masked_px,
6867                ),
6868                _ => log::info!(
6869                    "[display-clip] visible-region cull active for {region:?} ({} px masked) at {width}x{height}",
6870                    mesh.masked_px,
6871                ),
6872            }
6873            DisplayClipResources {
6874                depth_view: depth_texture.create_view(&wgpu::TextureViewDescriptor::default()),
6875                occluder_vertex_buffer,
6876                occluder_vertex_count: mesh.vertices.len() as u32,
6877            }
6878        });
6879        let view = built.as_ref().map(|resources| resources.depth_view.clone());
6880        self.display_clip.resources = Some((key, built));
6881        view
6882    }
6883
6884    /// Encodes the region complement's occluder, the first draw of a
6885    /// culled fused pass: depth write at the near plane over the
6886    /// tessellation, color writes off.
6887    #[cfg(not(target_arch = "wasm32"))]
6888    fn draw_display_clip_occluder(
6889        &self,
6890        render_pass: &mut wgpu::RenderPass<'_>,
6891        width: u32,
6892        height: u32,
6893    ) {
6894        let Some((((size_w, size_h), _), Some(resources))) = &self.display_clip.resources else {
6895            return;
6896        };
6897        debug_assert_eq!((*size_w, *size_h), (width, height));
6898        let pipeline =
6899            self.display_clip
6900                .occluder_pipeline
6901                .get_or_init(self.adapter_backend, || {
6902                    create_display_clip_occluder_pipeline(
6903                        &self.device,
6904                        self.pipeline_cache.as_ref(),
6905                        self.surface_format,
6906                    )
6907                });
6908        render_pass.set_scissor_rect(0, 0, width, height);
6909        render_pass.set_pipeline(pipeline);
6910        render_pass.set_vertex_buffer(0, resources.occluder_vertex_buffer.slice(..));
6911        render_pass.draw(0..resources.occluder_vertex_count, 0..1);
6912        self.frame_stats.add_draw_calls(1);
6913    }
6914
6915    fn shape_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
6916        let resource = match blend_mode {
6917            BlendMode::DstOut => &self.pipeline_dst_out,
6918            _ => &self.pipeline,
6919        };
6920        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6921            create_shape_pipeline(
6922                &self.device,
6923                self.pipeline_cache.as_ref(),
6924                self.surface_format,
6925                &self.uniform_bind_group_layout,
6926                &self.shape_bind_group_layout,
6927                blend_mode,
6928                self.shape_batch_limits,
6929                false,
6930                "vs_main",
6931                "fs_main",
6932                depth,
6933            )
6934        })
6935    }
6936
6937    /// The `fs_solid` twin of [`Self::shape_pipeline`], SrcOver only. Callers
6938    /// pick it exactly when the draw's shapes carry zero gradient stops; the
6939    /// coverage math is byte-identical, the gradient machinery is compiled
6940    /// out of the fragment stage. Under `CRANPOSE_SOLID_TRIM_VARYINGS`
6941    /// (re-read per build, see [`solid_trim_varyings_enabled`]) the build
6942    /// compiles the trimmed-interface entries instead; either variant encodes
6943    /// identically — same layouts, same blend, no vertex buffers — so every
6944    /// caller, retained bundles included, is oblivious to the selection.
6945    fn shape_pipeline_solid(&self) -> &wgpu::RenderPipeline {
6946        self.pipeline_solid
6947            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6948                let solid_trim = solid_trim_varyings_enabled();
6949                let (vertex_entry, fragment_entry) = if solid_trim {
6950                    ("vs_solid", "fs_solid_trim")
6951                } else {
6952                    ("vs_main", "fs_solid")
6953                };
6954                create_shape_pipeline(
6955                    &self.device,
6956                    self.pipeline_cache.as_ref(),
6957                    self.surface_format,
6958                    &self.uniform_bind_group_layout,
6959                    &self.shape_bind_group_layout,
6960                    BlendMode::SrcOver,
6961                    self.shape_batch_limits,
6962                    solid_trim,
6963                    vertex_entry,
6964                    fragment_entry,
6965                    depth,
6966                )
6967            })
6968    }
6969
6970    #[cfg(not(target_arch = "wasm32"))]
6971    fn mesh_pipeline(&self) -> &wgpu::RenderPipeline {
6972        self.mesh_pipeline
6973            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6974                create_mesh_shape_pipeline(
6975                    &self.device,
6976                    self.pipeline_cache.as_ref(),
6977                    self.surface_format,
6978                    &self.uniform_bind_group_layout,
6979                    &self.shape_bind_group_layout,
6980                    self.shape_batch_limits,
6981                    depth,
6982                )
6983            })
6984    }
6985
6986    #[cfg(not(target_arch = "wasm32"))]
6987    fn instanced_pipeline<'a>(
6988        &'a self,
6989        instanced: &'a InstancedQuadPipelines,
6990        blend_mode: BlendMode,
6991    ) -> &'a wgpu::RenderPipeline {
6992        let resource = match blend_mode {
6993            BlendMode::DstOut => &instanced.pipeline_dst_out,
6994            _ => &instanced.pipeline,
6995        };
6996        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6997            create_instanced_shape_pipeline(
6998                &self.device,
6999                self.pipeline_cache.as_ref(),
7000                self.surface_format,
7001                &self.uniform_bind_group_layout,
7002                &self.shape_bind_group_layout,
7003                blend_mode,
7004                self.shape_batch_limits,
7005                false,
7006                "vs_shape_instanced",
7007                "fs_main",
7008                depth,
7009            )
7010        })
7011    }
7012
7013    /// The `fs_solid` twin of [`Self::instanced_pipeline`], SrcOver only.
7014    /// Trims its varyings under `CRANPOSE_SOLID_TRIM_VARYINGS` exactly like
7015    /// [`Self::shape_pipeline_solid`].
7016    #[cfg(not(target_arch = "wasm32"))]
7017    fn instanced_pipeline_solid<'a>(
7018        &'a self,
7019        instanced: &'a InstancedQuadPipelines,
7020    ) -> &'a wgpu::RenderPipeline {
7021        instanced
7022            .pipeline_solid
7023            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7024                let solid_trim = solid_trim_varyings_enabled();
7025                let (vertex_entry, fragment_entry) = if solid_trim {
7026                    ("vs_solid_instanced", "fs_solid_trim")
7027                } else {
7028                    ("vs_shape_instanced", "fs_solid")
7029                };
7030                create_instanced_shape_pipeline(
7031                    &self.device,
7032                    self.pipeline_cache.as_ref(),
7033                    self.surface_format,
7034                    &self.uniform_bind_group_layout,
7035                    &self.shape_bind_group_layout,
7036                    BlendMode::SrcOver,
7037                    self.shape_batch_limits,
7038                    solid_trim,
7039                    vertex_entry,
7040                    fragment_entry,
7041                    depth,
7042                )
7043            })
7044    }
7045
7046    fn image_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
7047        let resource = match blend_mode {
7048            BlendMode::DstOut => &self.image_pipeline_dst_out,
7049            _ => &self.image_pipeline,
7050        };
7051        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7052            create_image_pipeline(
7053                &self.device,
7054                self.pipeline_cache.as_ref(),
7055                self.surface_format,
7056                &self.uniform_bind_group_layout,
7057                &self.image_bind_group_layout,
7058                blend_mode,
7059                depth,
7060            )
7061        })
7062    }
7063
7064    fn glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
7065        self.glyph_atlas_pipeline
7066            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7067                create_glyph_atlas_pipeline(
7068                    &self.device,
7069                    self.pipeline_cache.as_ref(),
7070                    self.surface_format,
7071                    &self.uniform_bind_group_layout,
7072                    &self.image_bind_group_layout,
7073                    depth,
7074                )
7075            })
7076    }
7077
7078    #[cfg(not(target_arch = "wasm32"))]
7079    fn retained_glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
7080        self.retained_glyph_atlas_pipeline.get_or_init(
7081            self.adapter_backend,
7082            self.pass_depth(),
7083            |depth| {
7084                create_glyph_atlas_pipeline(
7085                    &self.device,
7086                    self.pipeline_cache.as_ref(),
7087                    self.surface_format,
7088                    &self.retained_glyph_uniform_bind_group_layout,
7089                    &self.image_bind_group_layout,
7090                    depth,
7091                )
7092            },
7093        )
7094    }
7095
7096    fn ensure_image_cached(&mut self, image: &ImageBitmap) -> Result<(), String> {
7097        if self.image_texture_cache.get(&image.id()).is_some() {
7098            return Ok(());
7099        }
7100
7101        let size = wgpu::Extent3d {
7102            width: image.width(),
7103            height: image.height(),
7104            depth_or_array_layers: 1,
7105        };
7106
7107        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
7108            label: Some("Image Texture"),
7109            size,
7110            mip_level_count: 1,
7111            sample_count: 1,
7112            dimension: wgpu::TextureDimension::D2,
7113            format: wgpu::TextureFormat::Rgba8Unorm,
7114            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7115            view_formats: &[],
7116        });
7117
7118        let upload_stats = self.frame_graph_executor.upload_texture(
7119            &self.queue,
7120            wgpu::TexelCopyTextureInfo {
7121                texture: &texture,
7122                mip_level: 0,
7123                origin: wgpu::Origin3d::ZERO,
7124                aspect: wgpu::TextureAspect::All,
7125            },
7126            image.pixels(),
7127            wgpu::TexelCopyBufferLayout {
7128                offset: 0,
7129                bytes_per_row: Some(4 * image.width()),
7130                rows_per_image: Some(image.height()),
7131            },
7132            size,
7133        );
7134        self.frame_stats.record_command_stats(upload_stats);
7135
7136        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
7137        let nearest_bind_group = self.image_bind_group(&view, &self.image_nearest_sampler);
7138        let linear_bind_group = self.image_bind_group(&view, &self.image_linear_sampler);
7139
7140        let bytes = image.width() as usize * image.height() as usize * 4;
7141        if let Some(replaced) = self.image_texture_cache.put(
7142            image.id(),
7143            CachedImageTexture {
7144                _texture: texture,
7145                _view: view,
7146                nearest_bind_group,
7147                linear_bind_group,
7148                bytes,
7149            },
7150        ) {
7151            self.image_texture_cache_bytes = self
7152                .image_texture_cache_bytes
7153                .saturating_sub(replaced.bytes);
7154        }
7155        self.image_texture_cache_bytes += bytes;
7156        // Byte-bounded eviction on top of the count bound: never evict the
7157        // entry just inserted (this frame draws it).
7158        while self.image_texture_cache_bytes > MAX_IMAGE_TEXTURE_CACHE_BYTES
7159            && self.image_texture_cache.len() > 1
7160        {
7161            let Some((_, evicted)) = self.image_texture_cache.pop_lru() else {
7162                break;
7163            };
7164            self.image_texture_cache_bytes =
7165                self.image_texture_cache_bytes.saturating_sub(evicted.bytes);
7166        }
7167        Ok(())
7168    }
7169
7170    fn image_bind_group(
7171        &self,
7172        view: &wgpu::TextureView,
7173        sampler: &wgpu::Sampler,
7174    ) -> wgpu::BindGroup {
7175        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
7176            label: Some("Image Texture Bind Group"),
7177            layout: &self.image_bind_group_layout,
7178            entries: &[
7179                wgpu::BindGroupEntry {
7180                    binding: 0,
7181                    resource: wgpu::BindingResource::TextureView(view),
7182                },
7183                wgpu::BindGroupEntry {
7184                    binding: 1,
7185                    resource: wgpu::BindingResource::Sampler(sampler),
7186                },
7187            ],
7188        })
7189    }
7190
7191    /// Acquire an offscreen target from the pool with stats tracking.
7192    /// Uses split borrows to avoid conflicting borrows on self.
7193    fn max_texture_dim(&self) -> u32 {
7194        self.effect_renderer.max_texture_dim()
7195    }
7196
7197    fn acquire_offscreen(&mut self, width: u32, height: u32) -> OffscreenTarget {
7198        self.effect_renderer
7199            .acquire_offscreen(&self.device, width, height, Some(&self.frame_stats))
7200    }
7201
7202    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
7203        self.acquire_offscreen(width, height)
7204    }
7205
7206    fn transient_offscreen_descriptor(
7207        &self,
7208        label: &'static str,
7209        width: u32,
7210        height: u32,
7211    ) -> FrameTextureDescriptor {
7212        let max_texture_dim = self.max_texture_dim();
7213        FrameTextureDescriptor::render_attachment(
7214            label,
7215            width.min(max_texture_dim),
7216            height.min(max_texture_dim),
7217            self.surface_format,
7218        )
7219    }
7220
7221    fn defer_offscreen_release(&mut self, target: OffscreenTarget) {
7222        self.deferred_offscreen_releases.push(target);
7223    }
7224
7225    fn flush_deferred_offscreen_releases(&mut self) {
7226        for target in self.deferred_offscreen_releases.drain(..) {
7227            self.effect_renderer.release_offscreen(target);
7228        }
7229    }
7230
7231    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
7232        if let LayerSurfaceTexture::Owned(target) = target {
7233            self.defer_offscreen_release(target);
7234        }
7235    }
7236
7237    fn cached_layer_surface(
7238        &mut self,
7239        key: &LayerRasterCacheKey,
7240    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
7241        self.layer_surface_cache.get(key, &self.frame_stats)
7242    }
7243
7244    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
7245        admit_layer_surface_cache_miss_impl(key, &mut self.observed_scene_range_cache_misses)
7246    }
7247
7248    fn insert_cached_layer_surface(
7249        &mut self,
7250        key: LayerRasterCacheKey,
7251        target: OffscreenTarget,
7252        logical_rect: Rect,
7253    ) -> Rc<OffscreenTarget> {
7254        self.layer_surface_cache
7255            .insert(key, target, logical_rect, &self.frame_stats)
7256    }
7257
7258    fn cached_shadow_surface(
7259        &mut self,
7260        key: &ShadowSurfaceCacheKey,
7261    ) -> Option<Rc<OffscreenTarget>> {
7262        self.shadow_surface_cache
7263            .get(key)
7264            .map(|cached| cached.target.clone())
7265    }
7266
7267    fn cached_shape_shadow_composite(
7268        &mut self,
7269        shadow: &ShadowDraw,
7270        width: u32,
7271        height: u32,
7272        root_scale: f32,
7273    ) -> Option<CachedShadowComposite> {
7274        if shadow.blur_radius <= 0.0 || shadow.shapes.is_empty() || !shadow.texts.is_empty() {
7275            return None;
7276        }
7277
7278        let plan = shape_shadow_surface_plan(
7279            &shadow.shapes,
7280            shadow.clip,
7281            shadow.blur_radius,
7282            width,
7283            height,
7284            root_scale,
7285            self.max_texture_dim(),
7286        )?;
7287        let key = shape_shadow_surface_cache_key(
7288            &shadow.shapes,
7289            &shadow.brushes,
7290            plan.source_device_bounds,
7291            plan.pixel_radius,
7292            root_scale,
7293        )?;
7294        let cached = self.cached_shadow_surface(&key)?;
7295        let viewport_offset = [plan.source_device_bounds.x, plan.source_device_bounds.y];
7296        self.frame_stats.record_shadow_shape_cache_hit(
7297            plan.source_device_bounds.width,
7298            plan.source_device_bounds.height,
7299        );
7300
7301        let clip_scissor = shadow
7302            .clip
7303            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
7304        let scissor = clip_scissor.or(plan.processing_scissor);
7305        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
7306            mask.rect[0] -= viewport_offset[0];
7307            mask.rect[1] -= viewport_offset[1];
7308            mask
7309        });
7310        let dest_viewport = Some((
7311            viewport_offset[0],
7312            viewport_offset[1],
7313            plan.source_device_bounds.width as f32,
7314            plan.source_device_bounds.height as f32,
7315        ));
7316
7317        Some(CachedShadowComposite {
7318            source: cached,
7319            scissor,
7320            rounded_mask,
7321            dest_viewport,
7322        })
7323    }
7324
7325    fn insert_cached_shadow_surface(
7326        &mut self,
7327        key: ShadowSurfaceCacheKey,
7328        target: OffscreenTarget,
7329    ) {
7330        let byte_size = offscreen_byte_size(target.width, target.height);
7331        while self.shadow_surface_cache_bytes + byte_size > MAX_SHADOW_SURFACE_CACHE_BYTES {
7332            let Some((_evicted_key, evicted_entry)) = self.shadow_surface_cache.pop_lru() else {
7333                break;
7334            };
7335            self.shadow_surface_cache_bytes = self
7336                .shadow_surface_cache_bytes
7337                .saturating_sub(evicted_entry.byte_size);
7338        }
7339
7340        let cached = CachedShadowSurface {
7341            target: Rc::new(target),
7342            byte_size,
7343        };
7344        if let Some((_replaced_key, replaced_entry)) = self.shadow_surface_cache.push(key, cached) {
7345            self.shadow_surface_cache_bytes = self
7346                .shadow_surface_cache_bytes
7347                .saturating_sub(replaced_entry.byte_size);
7348        }
7349        self.shadow_surface_cache_bytes = self.shadow_surface_cache_bytes.saturating_add(byte_size);
7350    }
7351
7352    fn supports_render_effect(&self, effect: &RenderEffect) -> bool {
7353        is_render_effect_supported(effect)
7354    }
7355}
7356
7357struct RecordingSurfaceBackend<'renderer, 'recorder, C: FrameCommandRecorder> {
7358    renderer: &'renderer mut GpuRenderer,
7359    recorder: &'recorder mut C,
7360}
7361
7362impl<C: FrameCommandRecorder> RecordingSurfaceBackend<'_, '_, C> {
7363    #[allow(clippy::too_many_arguments)]
7364    fn render_range_with_layer_events_to_target_recorded(
7365        &mut self,
7366        target: &OffscreenTarget,
7367        shapes: &[DrawShape],
7368        brushes: &[Brush],
7369        images: &[ImageDraw],
7370        texts: &[TextDraw],
7371        shadow_draws: &[ShadowDraw],
7372        retained_draws: &[RetainedDraw],
7373        draw_ops: &[DrawOp],
7374        effect_layers: &[EffectLayer],
7375        backdrop_layers: &[BackdropLayer],
7376        backdrop_input_hashes: &[u64],
7377        z_start: usize,
7378        z_end: usize,
7379        excluded_effect_layer: Option<usize>,
7380        width: u32,
7381        height: u32,
7382        root_scale: f32,
7383        backdrop_underlay: Option<&OffscreenTarget>,
7384        initial_load_op: wgpu::LoadOp<wgpu::Color>,
7385    ) -> Result<(), String> {
7386        if z_start >= z_end {
7387            if matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
7388                self.clear_target_view_with_load_op(&target.view, initial_load_op);
7389            }
7390            return Ok(());
7391        }
7392
7393        let mut effect_z_ranges = std::mem::take(&mut self.renderer.scratch_effect_ranges);
7394        collect_effect_ranges(
7395            effect_layers,
7396            z_start,
7397            z_end,
7398            excluded_effect_layer,
7399            &mut effect_z_ranges,
7400        );
7401        let mut events = std::mem::take(&mut self.renderer.scratch_layer_events);
7402        collect_layer_events(
7403            effect_layers,
7404            backdrop_layers,
7405            z_start,
7406            z_end,
7407            excluded_effect_layer,
7408            &mut events,
7409        );
7410
7411        let result = (|| -> Result<(), String> {
7412            let mut next_load_op = initial_load_op;
7413            let mut cursor_z = z_start;
7414            for event in &events {
7415                if event.z_index > cursor_z {
7416                    self.render_non_effect_segment(
7417                        &target.view,
7418                        shapes,
7419                        brushes,
7420                        images,
7421                        texts,
7422                        shadow_draws,
7423                        retained_draws,
7424                        draw_ops,
7425                        cursor_z,
7426                        event.z_index,
7427                        &effect_z_ranges,
7428                        width,
7429                        height,
7430                        root_scale,
7431                        next_load_op,
7432                    )?;
7433                    next_load_op = wgpu::LoadOp::Load;
7434                    cursor_z = event.z_index;
7435                } else if event.z_index < cursor_z {
7436                    continue;
7437                }
7438
7439                if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
7440                    self.clear_target_view_with_load_op(&target.view, next_load_op);
7441                    next_load_op = wgpu::LoadOp::Load;
7442                }
7443
7444                match event.kind {
7445                    LayerEventKind::Backdrop(index) => {
7446                        let layer = &backdrop_layers[index];
7447                        let effective_backdrop_underlay = if backdrop_underlay.is_some()
7448                            && backdrop_underlay_is_covered_by_local_content(
7449                                shapes,
7450                                brushes,
7451                                images,
7452                                shadow_draws,
7453                                draw_ops,
7454                                effect_layers,
7455                                backdrop_layers,
7456                                layer,
7457                            ) {
7458                            None
7459                        } else {
7460                            backdrop_underlay
7461                        };
7462                        execute_apply_backdrop_layer_to_target(
7463                            self,
7464                            target,
7465                            layer,
7466                            effective_backdrop_underlay,
7467                            width,
7468                            height,
7469                            root_scale,
7470                            backdrop_input_hashes.get(index).copied(),
7471                        )?;
7472                    }
7473                    LayerEventKind::Effect(index) => {
7474                        let layer = &effect_layers[index];
7475                        if layer.z_start < cursor_z {
7476                            continue;
7477                        }
7478                        execute_render_effect_layer_to_target(
7479                            self,
7480                            target,
7481                            shapes,
7482                            brushes,
7483                            images,
7484                            texts,
7485                            shadow_draws,
7486                            draw_ops,
7487                            effect_layers,
7488                            backdrop_layers,
7489                            index,
7490                            backdrop_underlay,
7491                            width,
7492                            height,
7493                            root_scale,
7494                        )?;
7495                        cursor_z = cursor_z.max(layer.z_end);
7496                    }
7497                }
7498            }
7499
7500            if cursor_z < z_end {
7501                self.render_non_effect_segment(
7502                    &target.view,
7503                    shapes,
7504                    brushes,
7505                    images,
7506                    texts,
7507                    shadow_draws,
7508                    retained_draws,
7509                    draw_ops,
7510                    cursor_z,
7511                    z_end,
7512                    &effect_z_ranges,
7513                    width,
7514                    height,
7515                    root_scale,
7516                    next_load_op,
7517                )?;
7518            } else if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
7519                self.clear_target_view_with_load_op(&target.view, next_load_op);
7520            }
7521
7522            Ok(())
7523        })();
7524
7525        self.renderer.scratch_effect_ranges = effect_z_ranges;
7526        self.renderer.scratch_layer_events = events;
7527        result
7528    }
7529
7530    #[allow(clippy::too_many_arguments)]
7531    fn record_shader_composite(
7532        &mut self,
7533        source: &OffscreenTarget,
7534        shader: &RuntimeShader,
7535        effect_rect: [f32; 4],
7536        dest_view: &wgpu::TextureView,
7537        alpha: f32,
7538        load_op: wgpu::LoadOp<wgpu::Color>,
7539        scissor: Option<(u32, u32, u32, u32)>,
7540        blend_mode: BlendMode,
7541        dest_viewport: Option<(f32, f32, f32, f32)>,
7542        sample_mode: CompositeSampleMode,
7543    ) {
7544        let device = self.renderer.device.clone();
7545        if let Some(viewport) = direct_shader_composite_viewport(
7546            alpha,
7547            blend_mode,
7548            dest_viewport,
7549            sample_mode,
7550            (source.width, source.height),
7551        ) {
7552            let shader_applied = self
7553                .renderer
7554                .effect_renderer
7555                .encode_shader_src_over_to_view(
7556                    self.recorder,
7557                    &device,
7558                    source,
7559                    dest_view,
7560                    shader,
7561                    effect_rect,
7562                    load_op,
7563                    scissor,
7564                    viewport,
7565                );
7566            if shader_applied {
7567                self.renderer
7568                    .effect_renderer
7569                    .debug_effects
7570                    .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7571                self.recorder.record_pass();
7572                self.renderer.effect_renderer.record_composite_pass();
7573                return;
7574            }
7575        }
7576        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7577            "Shader Effect Composite Scratch",
7578            source.width,
7579            source.height,
7580        );
7581        let scratch = self
7582            .recorder
7583            .acquire_transient_offscreen(&device, scratch_descriptor);
7584        let shader_applied = {
7585            self.renderer.effect_renderer.encode_shader(
7586                self.recorder,
7587                &device,
7588                source,
7589                &scratch.view,
7590                shader,
7591                effect_rect,
7592            )
7593        };
7594        let composite_source = if shader_applied {
7595            self.renderer
7596                .effect_renderer
7597                .debug_effects
7598                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7599            self.recorder.record_pass();
7600            &scratch
7601        } else {
7602            source
7603        };
7604        {
7605            self.renderer
7606                .effect_renderer
7607                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
7608                    self.recorder,
7609                    &device,
7610                    composite_source,
7611                    dest_view,
7612                    alpha,
7613                    load_op,
7614                    scissor,
7615                    None,
7616                    supported_blend_mode(blend_mode),
7617                    dest_viewport,
7618                    sample_mode,
7619                );
7620        }
7621        self.recorder.record_pass();
7622        self.renderer.effect_renderer.record_composite_pass();
7623        self.recorder
7624            .release_transient_offscreen(scratch_descriptor, scratch);
7625    }
7626
7627    #[allow(clippy::too_many_arguments)]
7628    fn record_shader_projective_composite(
7629        &mut self,
7630        source: &OffscreenTarget,
7631        shader: &RuntimeShader,
7632        effect_rect: [f32; 4],
7633        dest_view: &wgpu::TextureView,
7634        viewport: (u32, u32),
7635        source_size: (f32, f32),
7636        inverse_matrix: [[f32; 3]; 3],
7637        dest_bounds: [[f32; 2]; 4],
7638        alpha: f32,
7639        load_op: wgpu::LoadOp<wgpu::Color>,
7640        scissor: Option<(u32, u32, u32, u32)>,
7641        blend_mode: BlendMode,
7642        sample_mode: CompositeSampleMode,
7643    ) {
7644        if projective_dest_bounds_rect(dest_bounds).is_none() {
7645            return;
7646        }
7647        let device = self.renderer.device.clone();
7648        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7649            "Shader Projective Composite Scratch",
7650            source.width,
7651            source.height,
7652        );
7653        let scratch = self
7654            .recorder
7655            .acquire_transient_offscreen(&device, scratch_descriptor);
7656        let shader_applied = {
7657            self.renderer.effect_renderer.encode_shader(
7658                self.recorder,
7659                &device,
7660                source,
7661                &scratch.view,
7662                shader,
7663                effect_rect,
7664            )
7665        };
7666        let composite_source = if shader_applied {
7667            self.renderer
7668                .effect_renderer
7669                .debug_effects
7670                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7671            self.recorder.record_pass();
7672            &scratch
7673        } else {
7674            source
7675        };
7676        let composited = {
7677            self.renderer
7678                .effect_renderer
7679                .encode_composite_to_view_projective(
7680                    self.recorder,
7681                    &device,
7682                    composite_source,
7683                    dest_view,
7684                    viewport,
7685                    source_size,
7686                    inverse_matrix,
7687                    dest_bounds,
7688                    alpha,
7689                    load_op,
7690                    scissor,
7691                    supported_blend_mode(blend_mode),
7692                    sample_mode,
7693                )
7694        };
7695        if composited {
7696            self.recorder.record_pass();
7697            self.renderer.effect_renderer.record_composite_pass();
7698        }
7699        self.recorder
7700            .release_transient_offscreen(scratch_descriptor, scratch);
7701    }
7702
7703    #[allow(clippy::too_many_arguments)]
7704    fn record_effect_with_direct_shader_tail_composite(
7705        &mut self,
7706        source: &OffscreenTarget,
7707        first_effect: &RenderEffect,
7708        shader: &RuntimeShader,
7709        effect_rect: [f32; 4],
7710        dest_view: &wgpu::TextureView,
7711        load_op: wgpu::LoadOp<wgpu::Color>,
7712        scissor: Option<(u32, u32, u32, u32)>,
7713        dest_viewport: (f32, f32, f32, f32),
7714    ) -> Result<bool, String> {
7715        let device = self.renderer.device.clone();
7716        let intermediate_descriptor = self.renderer.transient_offscreen_descriptor(
7717            "Render Effect Direct Shader Tail Intermediate",
7718            source.width,
7719            source.height,
7720        );
7721        let intermediate = self
7722            .recorder
7723            .acquire_transient_offscreen(&device, intermediate_descriptor);
7724        let effect_scratch_targets = self
7725            .renderer
7726            .effect_renderer
7727            .acquire_recorded_effect_scratch_targets(
7728                self.recorder,
7729                &device,
7730                first_effect,
7731                source.width,
7732                source.height,
7733                self.renderer.surface_format,
7734            );
7735        let first_passes = {
7736            let mut effect_scratch_refs = effect_scratch_targets.refs();
7737            let pass_count = self.renderer.effect_renderer.encode_effect(
7738                self.recorder,
7739                &device,
7740                source,
7741                &intermediate.view,
7742                first_effect,
7743                effect_rect,
7744                &mut effect_scratch_refs,
7745            );
7746            match pass_count {
7747                Ok(pass_count) => effect_scratch_refs.assert_consumed().map(|()| pass_count),
7748                Err(error) => Err(error),
7749            }
7750        };
7751        let first_passes = match first_passes {
7752            Ok(pass_count) => pass_count,
7753            Err(error) => {
7754                effect_scratch_targets.release_into(self.recorder);
7755                self.recorder
7756                    .release_transient_offscreen(intermediate_descriptor, intermediate);
7757                return Err(error);
7758            }
7759        };
7760        let shader_applied = self
7761            .renderer
7762            .effect_renderer
7763            .encode_shader_src_over_to_view(
7764                self.recorder,
7765                &device,
7766                &intermediate,
7767                dest_view,
7768                shader,
7769                effect_rect,
7770                load_op,
7771                scissor,
7772                dest_viewport,
7773            );
7774        self.recorder
7775            .record_passes(first_passes.saturating_add(u32::from(shader_applied)));
7776        effect_scratch_targets.release_into(self.recorder);
7777        self.recorder
7778            .release_transient_offscreen(intermediate_descriptor, intermediate);
7779        if !shader_applied {
7780            return Ok(false);
7781        }
7782        self.renderer
7783            .effect_renderer
7784            .debug_effects
7785            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7786        self.renderer.effect_renderer.record_composite_pass();
7787        Ok(true)
7788    }
7789
7790    #[allow(clippy::too_many_arguments)]
7791    fn record_effect_composite(
7792        &mut self,
7793        source: &OffscreenTarget,
7794        effect: &RenderEffect,
7795        effect_rect: [f32; 4],
7796        dest_view: &wgpu::TextureView,
7797        alpha: f32,
7798        load_op: wgpu::LoadOp<wgpu::Color>,
7799        scissor: Option<(u32, u32, u32, u32)>,
7800        blend_mode: BlendMode,
7801        dest_viewport: Option<(f32, f32, f32, f32)>,
7802        sample_mode: CompositeSampleMode,
7803    ) -> Result<(), String> {
7804        if let (
7805            RenderEffect::Chain { first, second },
7806            Some(viewport),
7807            BlendMode::SrcOver,
7808            CompositeSampleMode::Linear,
7809        ) = (
7810            effect,
7811            dest_viewport,
7812            supported_blend_mode(blend_mode),
7813            sample_mode,
7814        ) {
7815            if let (
7816                RenderEffect::Blur {
7817                    radius_x,
7818                    radius_y,
7819                    edge_treatment,
7820                },
7821                RenderEffect::Shader { shader },
7822            ) = (first.as_ref(), second.as_ref())
7823            {
7824                if *radius_x > 0.0 || *radius_y > 0.0 {
7825                    let device = self.renderer.device.clone();
7826                    let (scratch_width, scratch_height) = crate::effect_renderer::blur_scratch_size(
7827                        *radius_x,
7828                        *radius_y,
7829                        source.width,
7830                        source.height,
7831                    );
7832                    let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7833                        "Blur Rounded Mask Scratch",
7834                        scratch_width,
7835                        scratch_height,
7836                    );
7837                    let scratch = self
7838                        .recorder
7839                        .acquire_transient_offscreen(&device, scratch_descriptor);
7840                    let fused = self
7841                        .renderer
7842                        .effect_renderer
7843                        .encode_blur_then_rounded_mask_src_over_to_view(
7844                            self.recorder,
7845                            &device,
7846                            source,
7847                            &scratch,
7848                            dest_view,
7849                            *radius_x,
7850                            *radius_y,
7851                            *edge_treatment,
7852                            shader,
7853                            effect_rect,
7854                            load_op,
7855                            scissor,
7856                            viewport,
7857                        );
7858                    if fused {
7859                        self.recorder.record_passes(2);
7860                        self.renderer.effect_renderer.record_blur_pass();
7861                        self.renderer
7862                            .effect_renderer
7863                            .debug_effects
7864                            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7865                        self.renderer.effect_renderer.record_composite_pass();
7866                        self.recorder
7867                            .release_transient_offscreen(scratch_descriptor, scratch);
7868                        return Ok(());
7869                    }
7870                    self.recorder
7871                        .release_transient_offscreen(scratch_descriptor, scratch);
7872                }
7873            }
7874        }
7875        if let Some((first_effect, shader, viewport)) = direct_shader_tail_composite(
7876            effect,
7877            alpha,
7878            blend_mode,
7879            dest_viewport,
7880            sample_mode,
7881            (source.width, source.height),
7882        ) {
7883            if self.record_effect_with_direct_shader_tail_composite(
7884                source,
7885                first_effect,
7886                shader,
7887                effect_rect,
7888                dest_view,
7889                load_op,
7890                scissor,
7891                viewport,
7892            )? {
7893                return Ok(());
7894            }
7895        }
7896        let device = self.renderer.device.clone();
7897        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7898            "Render Effect Composite Scratch",
7899            source.width,
7900            source.height,
7901        );
7902        let scratch = self
7903            .recorder
7904            .acquire_transient_offscreen(&device, scratch_descriptor);
7905        let effect_scratch_targets = self
7906            .renderer
7907            .effect_renderer
7908            .acquire_recorded_effect_scratch_targets(
7909                self.recorder,
7910                &device,
7911                effect,
7912                source.width,
7913                source.height,
7914                self.renderer.surface_format,
7915            );
7916        let effect_passes = {
7917            let mut effect_scratch_refs = effect_scratch_targets.refs();
7918            let pass_count = self.renderer.effect_renderer.encode_effect(
7919                self.recorder,
7920                &device,
7921                source,
7922                &scratch.view,
7923                effect,
7924                effect_rect,
7925                &mut effect_scratch_refs,
7926            )?;
7927            effect_scratch_refs.assert_consumed()?;
7928            Ok(pass_count)
7929        };
7930        let effect_passes = match effect_passes {
7931            Ok(pass_count) => pass_count,
7932            Err(error) => {
7933                effect_scratch_targets.release_into(self.recorder);
7934                self.recorder
7935                    .release_transient_offscreen(scratch_descriptor, scratch);
7936                return Err(error);
7937            }
7938        };
7939        {
7940            self.renderer
7941                .effect_renderer
7942                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
7943                    self.recorder,
7944                    &device,
7945                    &scratch,
7946                    dest_view,
7947                    alpha,
7948                    load_op,
7949                    scissor,
7950                    None,
7951                    supported_blend_mode(blend_mode),
7952                    dest_viewport,
7953                    sample_mode,
7954                );
7955        }
7956        self.recorder.record_passes(effect_passes.saturating_add(1));
7957        self.renderer.effect_renderer.record_composite_pass();
7958        effect_scratch_targets.release_into(self.recorder);
7959        self.recorder
7960            .release_transient_offscreen(scratch_descriptor, scratch);
7961        Ok(())
7962    }
7963
7964    #[allow(clippy::too_many_arguments)]
7965    fn record_effect_projective_composite(
7966        &mut self,
7967        source: &OffscreenTarget,
7968        effect: &RenderEffect,
7969        effect_rect: [f32; 4],
7970        dest_view: &wgpu::TextureView,
7971        viewport: (u32, u32),
7972        source_size: (f32, f32),
7973        inverse_matrix: [[f32; 3]; 3],
7974        dest_bounds: [[f32; 2]; 4],
7975        alpha: f32,
7976        load_op: wgpu::LoadOp<wgpu::Color>,
7977        scissor: Option<(u32, u32, u32, u32)>,
7978        blend_mode: BlendMode,
7979        sample_mode: CompositeSampleMode,
7980    ) -> Result<(), String> {
7981        if projective_dest_bounds_rect(dest_bounds).is_none() {
7982            return Ok(());
7983        }
7984        let device = self.renderer.device.clone();
7985        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7986            "Render Effect Projective Composite Scratch",
7987            source.width,
7988            source.height,
7989        );
7990        let scratch = self
7991            .recorder
7992            .acquire_transient_offscreen(&device, scratch_descriptor);
7993        let effect_scratch_targets = self
7994            .renderer
7995            .effect_renderer
7996            .acquire_recorded_effect_scratch_targets(
7997                self.recorder,
7998                &device,
7999                effect,
8000                source.width,
8001                source.height,
8002                self.renderer.surface_format,
8003            );
8004        let effect_passes = {
8005            let mut effect_scratch_refs = effect_scratch_targets.refs();
8006            let pass_count = self.renderer.effect_renderer.encode_effect(
8007                self.recorder,
8008                &device,
8009                source,
8010                &scratch.view,
8011                effect,
8012                effect_rect,
8013                &mut effect_scratch_refs,
8014            )?;
8015            effect_scratch_refs.assert_consumed()?;
8016            Ok(pass_count)
8017        };
8018        let effect_passes = match effect_passes {
8019            Ok(pass_count) => pass_count,
8020            Err(error) => {
8021                effect_scratch_targets.release_into(self.recorder);
8022                self.recorder
8023                    .release_transient_offscreen(scratch_descriptor, scratch);
8024                return Err(error);
8025            }
8026        };
8027        let composited = {
8028            self.renderer
8029                .effect_renderer
8030                .encode_composite_to_view_projective(
8031                    self.recorder,
8032                    &device,
8033                    &scratch,
8034                    dest_view,
8035                    viewport,
8036                    source_size,
8037                    inverse_matrix,
8038                    dest_bounds,
8039                    alpha,
8040                    load_op,
8041                    scissor,
8042                    supported_blend_mode(blend_mode),
8043                    sample_mode,
8044                )
8045        };
8046        if composited {
8047            self.recorder.record_passes(effect_passes.saturating_add(1));
8048            self.renderer.effect_renderer.record_composite_pass();
8049        } else {
8050            self.recorder.record_passes(effect_passes);
8051        }
8052        effect_scratch_targets.release_into(self.recorder);
8053        self.recorder
8054            .release_transient_offscreen(scratch_descriptor, scratch);
8055        Ok(())
8056    }
8057}
8058
8059impl<C: FrameCommandRecorder> SurfaceExecutionBackend for RecordingSurfaceBackend<'_, '_, C> {
8060    fn max_texture_dim(&self) -> u32 {
8061        self.renderer.max_texture_dim()
8062    }
8063
8064    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
8065        self.renderer.acquire_retained_surface(width, height)
8066    }
8067
8068    fn acquire_frame_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
8069        let descriptor =
8070            self.renderer
8071                .transient_offscreen_descriptor("Frame Surface", width, height);
8072        self.recorder
8073            .acquire_transient_offscreen(&self.renderer.device, descriptor)
8074    }
8075
8076    fn release_frame_surface(&mut self, target: OffscreenTarget) {
8077        let descriptor = self.renderer.transient_offscreen_descriptor(
8078            "Frame Surface",
8079            target.width,
8080            target.height,
8081        );
8082        self.recorder
8083            .release_transient_offscreen(descriptor, target);
8084    }
8085
8086    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
8087        self.renderer.release_layer_surface_target(target);
8088    }
8089
8090    fn cached_layer_surface(
8091        &mut self,
8092        key: &LayerRasterCacheKey,
8093    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
8094        self.renderer.cached_layer_surface(key)
8095    }
8096
8097    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
8098        self.renderer.admit_layer_surface_cache_miss(key)
8099    }
8100
8101    fn insert_cached_layer_surface(
8102        &mut self,
8103        key: LayerRasterCacheKey,
8104        target: OffscreenTarget,
8105        logical_rect: Rect,
8106    ) -> Rc<OffscreenTarget> {
8107        self.renderer
8108            .insert_cached_layer_surface(key, target, logical_rect)
8109    }
8110
8111    fn clear_target_view_with_load_op(
8112        &mut self,
8113        target_view: &wgpu::TextureView,
8114        load_op: wgpu::LoadOp<wgpu::Color>,
8115    ) {
8116        {
8117            let _clear = self
8118                .recorder
8119                .encoder()
8120                .begin_render_pass(&wgpu::RenderPassDescriptor {
8121                    label: Some("Layer Event Clear Pass"),
8122                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8123                        view: target_view,
8124                        resolve_target: None,
8125                        depth_slice: None,
8126                        ops: wgpu::Operations {
8127                            load: load_op,
8128                            store: wgpu::StoreOp::Store,
8129                        },
8130                    })],
8131                    depth_stencil_attachment: None,
8132                    timestamp_writes: None,
8133                    occlusion_query_set: None,
8134                    multiview_mask: None,
8135                });
8136        }
8137        self.recorder.record_pass();
8138    }
8139
8140    #[allow(clippy::too_many_arguments)]
8141    fn render_non_effect_segment(
8142        &mut self,
8143        target_view: &wgpu::TextureView,
8144        shapes: &[DrawShape],
8145        brushes: &[Brush],
8146        images: &[ImageDraw],
8147        texts: &[TextDraw],
8148        shadow_draws: &[ShadowDraw],
8149        retained_draws: &[RetainedDraw],
8150        draw_ops: &[DrawOp],
8151        z_start: usize,
8152        z_end: usize,
8153        effect_z_ranges: &[Range<usize>],
8154        width: u32,
8155        height: u32,
8156        root_scale: f32,
8157        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8158    ) -> Result<(), String> {
8159        self.render_non_effect_segment_with_composites(
8160            target_view,
8161            shapes,
8162            brushes,
8163            images,
8164            texts,
8165            shadow_draws,
8166            retained_draws,
8167            draw_ops,
8168            z_start,
8169            z_end,
8170            effect_z_ranges,
8171            &[],
8172            &[],
8173            width,
8174            height,
8175            root_scale,
8176            initial_load_op,
8177        )
8178    }
8179
8180    #[allow(clippy::too_many_arguments)]
8181    fn render_non_effect_segment_with_composites(
8182        &mut self,
8183        target_view: &wgpu::TextureView,
8184        shapes: &[DrawShape],
8185        brushes: &[Brush],
8186        images: &[ImageDraw],
8187        texts: &[TextDraw],
8188        shadow_draws: &[ShadowDraw],
8189        retained_draws: &[RetainedDraw],
8190        draw_ops: &[DrawOp],
8191        z_start: usize,
8192        z_end: usize,
8193        effect_z_ranges: &[Range<usize>],
8194        composites: &[(usize, CompositeBatchItem<'_>)],
8195        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
8196        width: u32,
8197        height: u32,
8198        root_scale: f32,
8199        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8200    ) -> Result<(), String> {
8201        let mut ordered_items = std::mem::take(&mut self.renderer.scratch_segment_items);
8202        collect_non_effect_segment_items(
8203            shapes,
8204            images,
8205            texts,
8206            shadow_draws,
8207            draw_ops,
8208            z_start,
8209            z_end,
8210            effect_z_ranges,
8211            width,
8212            height,
8213            root_scale,
8214            &mut ordered_items,
8215        );
8216        #[cfg(not(target_arch = "wasm32"))]
8217        let raw_shadow_items = ordered_items
8218            .iter()
8219            .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
8220            .count();
8221        let culled_shadow_items = retain_renderable_shadow_items(
8222            &mut ordered_items,
8223            shadow_draws,
8224            width,
8225            height,
8226            root_scale,
8227            self.renderer.max_texture_dim(),
8228        );
8229        #[cfg(target_arch = "wasm32")]
8230        let _ = culled_shadow_items;
8231        let mut cached_shadow_composites: Vec<(usize, CachedShadowComposite)> = Vec::new();
8232        ordered_items.extend(
8233            composites
8234                .iter()
8235                .enumerate()
8236                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::Composite(index))),
8237        );
8238        ordered_items.extend(
8239            shader_composites
8240                .iter()
8241                .enumerate()
8242                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::ShaderComposite(index))),
8243        );
8244        for (z_index, item) in &mut ordered_items {
8245            let SegmentDrawItem::Shadow(shadow_index) = *item else {
8246                continue;
8247            };
8248            let Some(composite) = self.renderer.cached_shape_shadow_composite(
8249                &shadow_draws[shadow_index],
8250                width,
8251                height,
8252                root_scale,
8253            ) else {
8254                continue;
8255            };
8256            let composite_index = composites.len() + cached_shadow_composites.len();
8257            cached_shadow_composites.push((*z_index, composite));
8258            *item = SegmentDrawItem::Composite(composite_index);
8259        }
8260        let mut merged_composites = Vec::with_capacity(
8261            composites
8262                .len()
8263                .saturating_add(cached_shadow_composites.len()),
8264        );
8265        merged_composites.extend(composites.iter().copied());
8266        merged_composites.extend(
8267            cached_shadow_composites
8268                .iter()
8269                .map(|(z_index, composite)| (*z_index, composite.batch_item())),
8270        );
8271        // Z indices are unique — the scene hands every op its own `next_z` — so an
8272        // unstable sort cannot reorder anything a stable one wouldn't, and it skips
8273        // the stable sort's scratch allocation, paid here once per segment per frame.
8274        ordered_items.sort_unstable_by_key(|(z_index, _)| *z_index);
8275        #[cfg(not(target_arch = "wasm32"))]
8276        maybe_print_segment_diag(
8277            z_start..z_end,
8278            &ordered_items,
8279            shapes,
8280            brushes,
8281            images,
8282            SegmentDiagCounts {
8283                raw_shadow_items,
8284                culled_shadow_items,
8285                cached_shadow_composites: cached_shadow_composites.len(),
8286                composite_items: merged_composites.len(),
8287                shader_composite_items: shader_composites.len(),
8288            },
8289            self.renderer.shape_batch_limits,
8290        );
8291        let result = if ordered_items.is_empty() {
8292            Ok(SegmentCommandEncodeOutcome { first_batch: true })
8293        } else {
8294            self.renderer.encode_non_effect_segment_commands(
8295                self.recorder,
8296                target_view,
8297                &ordered_items,
8298                &merged_composites,
8299                shader_composites,
8300                shapes,
8301                brushes,
8302                images,
8303                texts,
8304                shadow_draws,
8305                retained_draws,
8306                initial_load_op,
8307                width,
8308                height,
8309                root_scale,
8310            )
8311        };
8312        self.renderer.scratch_segment_items = ordered_items;
8313        let outcome = result?;
8314        if outcome.first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
8315            self.clear_target_view_with_load_op(target_view, initial_load_op);
8316        }
8317        Ok(())
8318    }
8319
8320    fn render_range_with_layer_events_to_target(
8321        &mut self,
8322        target: &OffscreenTarget,
8323        shapes: &[DrawShape],
8324        brushes: &[Brush],
8325        images: &[ImageDraw],
8326        texts: &[TextDraw],
8327        shadow_draws: &[ShadowDraw],
8328        retained_draws: &[RetainedDraw],
8329        draw_ops: &[DrawOp],
8330        effect_layers: &[EffectLayer],
8331        backdrop_layers: &[BackdropLayer],
8332        backdrop_input_hashes: &[u64],
8333        z_start: usize,
8334        z_end: usize,
8335        excluded_effect_layer: Option<usize>,
8336        width: u32,
8337        height: u32,
8338        root_scale: f32,
8339        backdrop_underlay: Option<&OffscreenTarget>,
8340        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8341    ) -> Result<(), String> {
8342        self.render_range_with_layer_events_to_target_recorded(
8343            target,
8344            shapes,
8345            brushes,
8346            images,
8347            texts,
8348            shadow_draws,
8349            retained_draws,
8350            draw_ops,
8351            effect_layers,
8352            backdrop_layers,
8353            backdrop_input_hashes,
8354            z_start,
8355            z_end,
8356            excluded_effect_layer,
8357            width,
8358            height,
8359            root_scale,
8360            backdrop_underlay,
8361            initial_load_op,
8362        )
8363    }
8364
8365    fn render_shadow_draw(
8366        &mut self,
8367        target_view: &wgpu::TextureView,
8368        shadow: &ShadowDraw,
8369        width: u32,
8370        height: u32,
8371        root_scale: f32,
8372    ) {
8373        self.renderer.encode_shadow_draw(
8374            self.recorder,
8375            target_view,
8376            shadow,
8377            width,
8378            height,
8379            root_scale,
8380        );
8381    }
8382
8383    fn composite_to_view_projective(
8384        &mut self,
8385        source: &OffscreenTarget,
8386        dest_view: &wgpu::TextureView,
8387        viewport: (u32, u32),
8388        source_size: (f32, f32),
8389        inverse_matrix: [[f32; 3]; 3],
8390        dest_bounds: [[f32; 2]; 4],
8391        alpha: f32,
8392        load_op: wgpu::LoadOp<wgpu::Color>,
8393        scissor: Option<(u32, u32, u32, u32)>,
8394        blend_mode: BlendMode,
8395        sample_mode: CompositeSampleMode,
8396    ) {
8397        let device = self.renderer.device.clone();
8398        let composited = {
8399            self.renderer
8400                .effect_renderer
8401                .encode_composite_to_view_projective(
8402                    self.recorder,
8403                    &device,
8404                    source,
8405                    dest_view,
8406                    viewport,
8407                    source_size,
8408                    inverse_matrix,
8409                    dest_bounds,
8410                    alpha,
8411                    load_op,
8412                    scissor,
8413                    supported_blend_mode(blend_mode),
8414                    sample_mode,
8415                )
8416        };
8417        if composited {
8418            self.recorder.record_pass();
8419            self.renderer.effect_renderer.record_composite_pass();
8420        }
8421    }
8422
8423    fn composite_projective_surfaces_to_view(
8424        &mut self,
8425        dest_view: &wgpu::TextureView,
8426        viewport: (u32, u32),
8427        composites: &[ProjectiveSurfaceComposite<'_>],
8428    ) {
8429        let device = self.renderer.device.clone();
8430        let mut composite_count = 0_u32;
8431        for composite in composites
8432            .iter()
8433            .copied()
8434            .filter(|composite| projective_dest_bounds_rect(composite.dest_bounds).is_some())
8435        {
8436            let composited = {
8437                self.renderer
8438                    .effect_renderer
8439                    .encode_composite_to_view_projective(
8440                        self.recorder,
8441                        &device,
8442                        composite.source,
8443                        dest_view,
8444                        viewport,
8445                        composite.source_size,
8446                        composite.inverse_matrix,
8447                        composite.dest_bounds,
8448                        composite.alpha,
8449                        composite.load_op,
8450                        composite.scissor,
8451                        supported_blend_mode(composite.blend_mode),
8452                        composite.sample_mode,
8453                    )
8454            };
8455            if composited {
8456                composite_count = composite_count.saturating_add(1);
8457            }
8458        }
8459        if composite_count > 0 {
8460            self.recorder.record_passes(composite_count);
8461            self.renderer
8462                .effect_renderer
8463                .debug_composites
8464                .set(self.renderer.effect_renderer.debug_composites.get() + composite_count);
8465        }
8466    }
8467
8468    fn composite_surface_batch_to_view(
8469        &mut self,
8470        dest_view: &wgpu::TextureView,
8471        viewport: (u32, u32),
8472        load_op: wgpu::LoadOp<wgpu::Color>,
8473        composites: &[CompositeBatchItem<'_>],
8474    ) {
8475        if composites.is_empty() {
8476            return;
8477        }
8478        let device = self.renderer.device.clone();
8479        self.renderer
8480            .effect_renderer
8481            .encode_composite_batch_to_view_pass(
8482                self.recorder,
8483                &device,
8484                dest_view,
8485                viewport,
8486                load_op,
8487                composites,
8488            );
8489        self.recorder.record_pass();
8490        self.renderer.effect_renderer.record_composite_pass();
8491    }
8492
8493    fn copy_texture_region_to_target(
8494        &mut self,
8495        source: &OffscreenTarget,
8496        source_origin: (u32, u32),
8497        target: &OffscreenTarget,
8498        size: (u32, u32),
8499    ) -> bool {
8500        let (width, height) = size;
8501        if width == 0 || height == 0 || width > target.width || height > target.height {
8502            return false;
8503        }
8504        let Some(source_right) = source_origin.0.checked_add(width) else {
8505            return false;
8506        };
8507        let Some(source_bottom) = source_origin.1.checked_add(height) else {
8508            return false;
8509        };
8510        if source_right > source.width || source_bottom > source.height {
8511            return false;
8512        }
8513
8514        self.recorder.encoder().copy_texture_to_texture(
8515            wgpu::TexelCopyTextureInfo {
8516                texture: source.texture(),
8517                mip_level: 0,
8518                origin: wgpu::Origin3d {
8519                    x: source_origin.0,
8520                    y: source_origin.1,
8521                    z: 0,
8522                },
8523                aspect: wgpu::TextureAspect::All,
8524            },
8525            wgpu::TexelCopyTextureInfo {
8526                texture: target.texture(),
8527                mip_level: 0,
8528                origin: wgpu::Origin3d::ZERO,
8529                aspect: wgpu::TextureAspect::All,
8530            },
8531            wgpu::Extent3d {
8532                width,
8533                height,
8534                depth_or_array_layers: 1,
8535            },
8536        );
8537        true
8538    }
8539
8540    fn shader_composite_batch_to_view(
8541        &mut self,
8542        dest_view: &wgpu::TextureView,
8543        viewport: (u32, u32),
8544        load_op: wgpu::LoadOp<wgpu::Color>,
8545        composites: &[ShaderCompositeBatchItem<'_>],
8546    ) -> bool {
8547        if composites.is_empty() {
8548            return true;
8549        }
8550        let device = self.renderer.device.clone();
8551        let encoded = self
8552            .renderer
8553            .effect_renderer
8554            .encode_shader_batch_src_over_to_view(
8555                self.recorder,
8556                &device,
8557                dest_view,
8558                viewport,
8559                load_op,
8560                composites,
8561            );
8562        if encoded {
8563            self.recorder.record_pass();
8564            self.renderer.effect_renderer.record_composite_pass();
8565            self.renderer
8566                .effect_renderer
8567                .debug_effects
8568                .set(self.renderer.effect_renderer.debug_effects.get() + composites.len() as u32);
8569        }
8570        encoded
8571    }
8572
8573    fn composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8574        &mut self,
8575        source: &OffscreenTarget,
8576        dest_view: &wgpu::TextureView,
8577        alpha: f32,
8578        load_op: wgpu::LoadOp<wgpu::Color>,
8579        scissor: Option<(u32, u32, u32, u32)>,
8580        rounded_mask: Option<RoundedCompositeMask>,
8581        blend_mode: BlendMode,
8582        dest_viewport: Option<(f32, f32, f32, f32)>,
8583        sample_mode: CompositeSampleMode,
8584    ) {
8585        let device = self.renderer.device.clone();
8586        {
8587            self.renderer
8588                .effect_renderer
8589                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8590                    self.recorder,
8591                    &device,
8592                    source,
8593                    dest_view,
8594                    alpha,
8595                    load_op,
8596                    scissor,
8597                    rounded_mask,
8598                    supported_blend_mode(blend_mode),
8599                    dest_viewport,
8600                    sample_mode,
8601                );
8602        }
8603        self.recorder.record_pass();
8604        self.renderer.effect_renderer.record_composite_pass();
8605    }
8606
8607    fn apply_effect_and_composite_to_view(
8608        &mut self,
8609        source: &OffscreenTarget,
8610        effect: &RenderEffect,
8611        effect_rect: [f32; 4],
8612        dest_view: &wgpu::TextureView,
8613        alpha: f32,
8614        load_op: wgpu::LoadOp<wgpu::Color>,
8615        scissor: Option<(u32, u32, u32, u32)>,
8616        blend_mode: BlendMode,
8617        dest_viewport: Option<(f32, f32, f32, f32)>,
8618        sample_mode: CompositeSampleMode,
8619    ) -> Result<(), String> {
8620        self.record_effect_composite(
8621            source,
8622            effect,
8623            effect_rect,
8624            dest_view,
8625            alpha,
8626            load_op,
8627            scissor,
8628            blend_mode,
8629            dest_viewport,
8630            sample_mode,
8631        )
8632    }
8633
8634    fn apply_shader_and_composite_to_view(
8635        &mut self,
8636        source: &OffscreenTarget,
8637        shader: &RuntimeShader,
8638        effect_rect: [f32; 4],
8639        dest_view: &wgpu::TextureView,
8640        alpha: f32,
8641        load_op: wgpu::LoadOp<wgpu::Color>,
8642        scissor: Option<(u32, u32, u32, u32)>,
8643        blend_mode: BlendMode,
8644        dest_viewport: Option<(f32, f32, f32, f32)>,
8645        sample_mode: CompositeSampleMode,
8646    ) {
8647        self.record_shader_composite(
8648            source,
8649            shader,
8650            effect_rect,
8651            dest_view,
8652            alpha,
8653            load_op,
8654            scissor,
8655            blend_mode,
8656            dest_viewport,
8657            sample_mode,
8658        );
8659    }
8660
8661    fn apply_shader_and_composite_to_view_projective(
8662        &mut self,
8663        source: &OffscreenTarget,
8664        shader: &RuntimeShader,
8665        effect_rect: [f32; 4],
8666        dest_view: &wgpu::TextureView,
8667        viewport: (u32, u32),
8668        source_size: (f32, f32),
8669        inverse_matrix: [[f32; 3]; 3],
8670        dest_bounds: [[f32; 2]; 4],
8671        alpha: f32,
8672        load_op: wgpu::LoadOp<wgpu::Color>,
8673        scissor: Option<(u32, u32, u32, u32)>,
8674        blend_mode: BlendMode,
8675        sample_mode: CompositeSampleMode,
8676    ) {
8677        self.record_shader_projective_composite(
8678            source,
8679            shader,
8680            effect_rect,
8681            dest_view,
8682            viewport,
8683            source_size,
8684            inverse_matrix,
8685            dest_bounds,
8686            alpha,
8687            load_op,
8688            scissor,
8689            blend_mode,
8690            sample_mode,
8691        );
8692    }
8693
8694    fn apply_effect_and_composite_to_view_projective(
8695        &mut self,
8696        source: &OffscreenTarget,
8697        effect: &RenderEffect,
8698        effect_rect: [f32; 4],
8699        dest_view: &wgpu::TextureView,
8700        viewport: (u32, u32),
8701        source_size: (f32, f32),
8702        inverse_matrix: [[f32; 3]; 3],
8703        dest_bounds: [[f32; 2]; 4],
8704        alpha: f32,
8705        load_op: wgpu::LoadOp<wgpu::Color>,
8706        scissor: Option<(u32, u32, u32, u32)>,
8707        blend_mode: BlendMode,
8708        sample_mode: CompositeSampleMode,
8709    ) -> Result<(), String> {
8710        self.record_effect_projective_composite(
8711            source,
8712            effect,
8713            effect_rect,
8714            dest_view,
8715            viewport,
8716            source_size,
8717            inverse_matrix,
8718            dest_bounds,
8719            alpha,
8720            load_op,
8721            scissor,
8722            blend_mode,
8723            sample_mode,
8724        )
8725    }
8726
8727    fn is_render_effect_supported(&self, effect: &RenderEffect) -> bool {
8728        self.renderer.supports_render_effect(effect)
8729    }
8730
8731    fn warn_unsupported_effect_once(&self) {
8732        self.renderer.warning_state.warn_unsupported_effect_once();
8733    }
8734
8735    fn record_layer_cache_miss(&self, width: u32, height: u32) {
8736        self.renderer
8737            .frame_stats
8738            .record_layer_cache_miss(width, height);
8739    }
8740
8741    fn record_isolated_layer_render(
8742        &self,
8743        width: u32,
8744        height: u32,
8745        node_id: Option<NodeId>,
8746        logical_rect: Rect,
8747        requirements: SurfaceRequirementSet,
8748    ) {
8749        self.renderer.frame_stats.record_isolated_layer_render(
8750            width,
8751            height,
8752            node_id,
8753            logical_rect,
8754            requirements.into(),
8755        );
8756    }
8757}
8758
8759impl GpuRenderer {
8760    #[allow(clippy::too_many_arguments)]
8761    pub fn render(
8762        &mut self,
8763        view: &wgpu::TextureView,
8764        root_target: Option<&OffscreenTarget>,
8765        width: u32,
8766        height: u32,
8767        mut packet: FramePacket,
8768        surface_epoch: u64,
8769        returns: &mut RenderReturns,
8770    ) -> Result<(), String> {
8771        // Threaded mode rides the emptied ack-confirmations buffer back to
8772        // the store inside the next packet ([`FramePacket::recycled_confirmations`]);
8773        // adopt it before the validity gate so even a cancelled packet
8774        // cannot leak the capacity. Sync callers always carry `None`.
8775        if let Some(confirmations) = packet.recycled_confirmations.take() {
8776            self.restore_replay_ack_confirmations(confirmations);
8777        }
8778        // Packet validity gate — BEFORE consume_replay_ops and any
8779        // encoding. A packet built against another renderer instance,
8780        // another surface configuration, or another viewport is cancelled
8781        // whole: its buffers travel back through `returns` for re-queue
8782        // and recycling, and nothing of it reaches the GPU.
8783        let cancel_reason = if packet.renderer_epoch != self.renderer_epoch {
8784            Some(CancelReason::RendererEpoch)
8785        } else if packet.surface_epoch != surface_epoch {
8786            Some(CancelReason::SurfaceEpoch)
8787        } else if packet.viewport != (width, height) {
8788            Some(CancelReason::Viewport)
8789        } else {
8790            None
8791        };
8792        if let Some(reason) = cancel_reason {
8793            return Self::cancel_packet(packet, reason, returns);
8794        }
8795        // Device-error gate — same protocol as the validity gate above: an
8796        // uncaptured error recorded since the last frame cancels this
8797        // packet whole, so nothing is encoded on the suspect device. The
8798        // take clears the poison, so the NEXT packet renders — one skipped
8799        // frame per poisoning, the acquire path's give-up-this-frame
8800        // semantics ([`DeviceErrorSentry`]).
8801        if self.device_errors.take_poison() {
8802            return Self::cancel_packet(packet, CancelReason::DeviceError, returns);
8803        }
8804        returns.frame_id = packet.frame_id;
8805        log::trace!("🎨 Rendering graph to {}x{}", width, height);
8806        let render_start = Instant::now();
8807
8808        #[cfg(target_arch = "wasm32")]
8809        {
8810            self.wasm_uniform_batch_cursor = 0;
8811            self.wasm_shape_batch_cursor = 0;
8812            self.wasm_image_batch_cursor = 0;
8813        }
8814        #[cfg(not(target_arch = "wasm32"))]
8815        {
8816            self.retained_glyph_uniform_cursor = 0;
8817            // Transient rim meshes live for exactly one frame: the scratch
8818            // restarts here and every fused chunk appends after the region
8819            // already uploaded (the GPU buffers themselves are fixed-capacity
8820            // and persist).
8821            self.rim_mesh_vertices.clear();
8822            self.rim_mesh_indices.clear();
8823            self.rim_mesh_uploaded_vertices = 0;
8824            self.rim_mesh_uploaded_indices = 0;
8825            if fill_area_diag_enabled() {
8826                self.fill_area_diag.reset_frame(width, height);
8827            }
8828            // One engagement per frame: the first fused partition carrying
8829            // the frame's opaque clear consumes this.
8830            self.static_span.armed = true;
8831            // Segment-surface frame boundary: config refresh, capture-slot
8832            // cursor reset, periodic idle sweep.
8833            self.segment_surfaces.begin_frame();
8834            // The frame's root target, held for the graph walk only: the
8835            // display clip cull compares fused-pass targets against it so
8836            // nothing but the real surface pass is ever culled.
8837            self.display_clip.frame_root_view = Some(view.clone());
8838        }
8839
8840        // Producer-side text layout cache size, carried by the packet — the
8841        // present call tree holds no text layout state, and no layout runs
8842        // between packet build and the stats block below.
8843        let text_cache_len = packet.text_cache_len;
8844        let result = self.render_graph(view, root_target, packet, returns);
8845        #[cfg(not(target_arch = "wasm32"))]
8846        {
8847            self.display_clip.frame_root_view = None;
8848        }
8849        let after_graph = Instant::now();
8850        self.flush_deferred_offscreen_releases();
8851        #[cfg(not(target_arch = "wasm32"))]
8852        {
8853            if fill_area_diag_enabled() {
8854                // Effect/composite fill accumulated during the graph walk
8855                // lives in the effect renderer's own cells; fold it into
8856                // this frame before the window closes over it.
8857                let (composite_px2, offscreen_px2) = self.effect_renderer.take_fill_diag_fill_px2();
8858                self.fill_area_diag
8859                    .add_effect_fill(composite_px2, offscreen_px2);
8860                self.fill_area_diag.finish_frame(width, height);
8861            }
8862        }
8863
8864        #[cfg(target_arch = "wasm32")]
8865        {
8866            const WASM_BATCH_POOL_MARGIN: usize = 4;
8867            self.wasm_uniform_batches.truncate(
8868                self.wasm_uniform_batch_cursor
8869                    .saturating_add(WASM_BATCH_POOL_MARGIN),
8870            );
8871            self.wasm_shape_batches.truncate(
8872                self.wasm_shape_batch_cursor
8873                    .saturating_add(WASM_BATCH_POOL_MARGIN),
8874            );
8875            self.wasm_image_batches.truncate(
8876                self.wasm_image_batch_cursor
8877                    .saturating_add(WASM_BATCH_POOL_MARGIN),
8878            );
8879        }
8880        self.staged_uploads
8881            .shrink_retained_capacity(RETAINED_STAGED_UPLOAD_BYTES, RETAINED_STAGED_UPLOAD_COPIES);
8882
8883        self.layer_surface_cache.finish_frame(&self.frame_stats);
8884        for target in self.layer_surface_cache.take_recycled() {
8885            self.defer_offscreen_release(target);
8886        }
8887        #[cfg(not(target_arch = "wasm32"))]
8888        self.retained_bundle_cache.end_frame();
8889
8890        self.frame_stats.offscreen_pool_size.set(
8891            self.effect_renderer
8892                .retained_offscreen_count()
8893                .saturating_add(self.frame_graph_executor.retained_texture_count())
8894                as u32,
8895        );
8896        self.frame_stats.offscreen_pool_bytes.set(
8897            (self.effect_renderer.retained_offscreen_bytes() as u64)
8898                .saturating_add(self.frame_graph_executor.retained_texture_bytes()),
8899        );
8900        self.frame_stats
8901            .text_pool_size
8902            .set(self.text_image_cache.len() as u32);
8903        self.frame_stats
8904            .image_cache_size
8905            .set(self.image_texture_cache.len() as u32);
8906        self.frame_stats.text_cache_size.set(text_cache_len as u32);
8907        self.effect_renderer
8908            .merge_and_reset_debug_counters(&self.frame_stats);
8909        self.frame_graph_executor.reset_upload_allocators();
8910        let snapshot = self.frame_stats.snapshot();
8911        self.last_frame_stats = Some(snapshot);
8912        PRESENTED_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8913        update_frame_warmup_budget(&mut self.pending_frame_warmup_frames, &snapshot);
8914        self.frame_stats.maybe_print_snapshot(
8915            snapshot,
8916            &mut self.frame_count,
8917            self.gpu_stats_enabled,
8918        );
8919        if self.gpu_stats_enabled && self.frame_count.is_multiple_of(60) {
8920            gpu_stats::print_gpu_memory_report(&self.device, self.frame_count);
8921        }
8922        self.frame_stats.reset();
8923        let after_stats = Instant::now();
8924        if let Some(total_ms) = should_log_wgpu_render_stage(render_start, after_stats) {
8925            log::warn!(
8926                "[wgpu-render-stage:render] total_ms={total_ms:.2} graph_ms={:.2} cleanup_stats_ms={:.2}",
8927                instant_ms(render_start, after_graph),
8928                instant_ms(after_graph, after_stats),
8929            );
8930        }
8931        if result.is_ok() {
8932            // Only a draw that actually ran may report `Presented`; an
8933            // errored draw leaves the default `NotRun`.
8934            returns.outcome = PresentOutcome::Presented;
8935        }
8936        result
8937    }
8938
8939    /// Refuses a packet whole, before any encoding: every buffer it
8940    /// carries travels back through `returns` — the direct scene for the
8941    /// producer pool, the unconsumed replay plan for the planner to
8942    /// re-queue (its releases name still-live store slots; dropping them
8943    /// would leak pool ids forever). A cancel is a protocol outcome, not a
8944    /// draw error, so the render call returns `Ok(())`.
8945    ///
8946    /// `pub(crate)` for the present runtime, which must cancel a packet
8947    /// that cannot render at all (surface dropped) without touching the
8948    /// GPU. Callers that bypass [`render`][Self::render] must take the
8949    /// packet's `recycled_confirmations` first — this refuses the packet
8950    /// without a store to adopt them into.
8951    pub(crate) fn cancel_packet(
8952        packet: FramePacket,
8953        reason: CancelReason,
8954        returns: &mut RenderReturns,
8955    ) -> Result<(), String> {
8956        let FramePacket {
8957            frame_id,
8958            viewport: _,
8959            renderer_epoch: _,
8960            surface_epoch: _,
8961            root_scale: _,
8962            root,
8963            overlay: _,
8964            replay,
8965            text_cache_len: _,
8966            recycled_confirmations: _,
8967            replay_preconsumed,
8968        } = packet;
8969        match root {
8970            PacketRoot::Direct(root) => {
8971                // Destructure: the scene buffers return to the producer
8972                // pool; the rest of the collected layer drops. A Direct
8973                // packet's replay plan came from the planner and must go
8974                // back to it unconsumed — a Surface packet only ever
8975                // carries the empty default plan, which has nothing to
8976                // reclaim. A plan the present stage already consumed
8977                // (`take_replay_ack_early`) is not here to reclaim: the
8978                // store honored it and its ack is on the way to the
8979                // planner, so `replay` holds only the taken-out default.
8980                returns.scene = Some(root.scene);
8981                #[cfg(not(target_arch = "wasm32"))]
8982                if !replay_preconsumed {
8983                    returns.cancelled_replay = Some(replay);
8984                }
8985            }
8986            PacketRoot::Surface(_) => {}
8987        }
8988        #[cfg(target_arch = "wasm32")]
8989        let _ = (replay, replay_preconsumed);
8990        returns.ack = None;
8991        returns.frame_id = frame_id;
8992        returns.outcome = PresentOutcome::Cancelled(reason);
8993        Ok(())
8994    }
8995
8996    pub fn last_frame_stats(&self) -> Option<gpu_stats::FrameStatsSnapshot> {
8997        self.last_frame_stats
8998    }
8999
9000    pub fn needs_frame_warmup(&self) -> bool {
9001        self.pending_frame_warmup_frames > 0
9002    }
9003
9004    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
9005        let layer_surface_cache_stats = self.layer_surface_cache.debug_stats();
9006        DebugCpuAllocationStats {
9007            scene_graph_node_count: 0,
9008            scene_graph_heap_bytes: 0,
9009            scene_hits_len: 0,
9010            scene_hits_cap: 0,
9011            scene_node_index_len: 0,
9012            scene_node_index_cap: 0,
9013            text_renderer_pool_len: self.text_image_cache.len(),
9014            text_renderer_pool_cap: self.text_image_cache.cap().get(),
9015            swash_image_cache_len: 0,
9016            swash_image_cache_cap: 0,
9017            swash_outline_cache_len: 0,
9018            swash_outline_cache_cap: 0,
9019            image_texture_cache_len: self.image_texture_cache.len(),
9020            image_texture_cache_cap: self.image_texture_cache.cap().get(),
9021            scratch_shape_data_cap: self.scratch_shape_data.capacity(),
9022            scratch_gradients_cap: self.scratch_gradients.capacity(),
9023            scratch_image_vertices_cap: self.scratch_image_vertices.capacity(),
9024            scratch_image_indices_cap: self.scratch_image_indices.capacity(),
9025            scratch_image_cmds_cap: self.scratch_image_cmds.capacity(),
9026            scratch_segment_items_cap: self.scratch_segment_items.capacity(),
9027            scratch_effect_ranges_cap: self.scratch_effect_ranges.capacity(),
9028            scratch_layer_events_cap: self.scratch_layer_events.capacity(),
9029            staged_upload_bytes_cap: self.staged_uploads.bytes.capacity(),
9030            staged_upload_copies_cap: self.staged_uploads.copies.capacity(),
9031            layer_surface_cache_len: layer_surface_cache_stats.entries_len,
9032            layer_surface_cache_cap: layer_surface_cache_stats.entries_cap,
9033            layer_surface_cache_identity_len: layer_surface_cache_stats.identity_len,
9034            layer_surface_cache_identity_cap: layer_surface_cache_stats.identity_cap,
9035            // The producer frontend owns the only lowering-memo pair since
9036            // step 6b; the present backend contributes nothing.
9037            layer_surface_rect_cache_len: 0,
9038            layer_surface_rect_cache_cap: 0,
9039            layer_surface_requirements_cache_len: 0,
9040            layer_surface_requirements_cache_cap: 0,
9041            layer_cache_seen_this_frame_len: layer_surface_cache_stats.seen_this_frame_len,
9042            layer_cache_seen_this_frame_cap: layer_surface_cache_stats.seen_this_frame_cap,
9043        }
9044    }
9045
9046    pub fn render_to_rgba_pixels(
9047        &mut self,
9048        width: u32,
9049        height: u32,
9050        packet: FramePacket,
9051        surface_epoch: u64,
9052        returns: &mut RenderReturns,
9053    ) -> Result<Vec<u8>, String> {
9054        if width == 0 || height == 0 {
9055            return Err("Screenshot size must be non-zero".to_string());
9056        }
9057
9058        let output_texture = self.device.create_texture(&wgpu::TextureDescriptor {
9059            label: Some("Screenshot Output Texture"),
9060            size: wgpu::Extent3d {
9061                width,
9062                height,
9063                depth_or_array_layers: 1,
9064            },
9065            mip_level_count: 1,
9066            sample_count: 1,
9067            dimension: wgpu::TextureDimension::D2,
9068            format: self.surface_format,
9069            usage: if capture_root_target_reads() {
9070                wgpu::TextureUsages::RENDER_ATTACHMENT
9071                    | wgpu::TextureUsages::COPY_SRC
9072                    | wgpu::TextureUsages::TEXTURE_BINDING
9073            } else {
9074                wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC
9075            },
9076            view_formats: &[],
9077        });
9078        let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
9079        let root_target = OffscreenTarget::from_readable_texture(&output_texture, &output_view);
9080
9081        self.render(
9082            &output_view,
9083            root_target.as_ref(),
9084            width,
9085            height,
9086            packet,
9087            surface_epoch,
9088            returns,
9089        )?;
9090
9091        let bytes_per_pixel = 4u32;
9092        let unpadded_bytes_per_row = width
9093            .checked_mul(bytes_per_pixel)
9094            .ok_or_else(|| "Screenshot row byte size overflow".to_string())?;
9095        let padded_bytes_per_row =
9096            align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
9097        let output_buffer_size = padded_bytes_per_row as u64 * height as u64;
9098
9099        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9100            label: Some("Screenshot Readback Buffer"),
9101            size: output_buffer_size,
9102            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
9103            mapped_at_creation: false,
9104        });
9105
9106        let device = self.device.clone();
9107        let queue = self.queue.clone();
9108        let mut graph = WgpuFrameGraph::new(Some("Screenshot Copy Encoder"));
9109        let source = graph.import_surface("screenshot-copy-source");
9110        graph.add_fallible_command_pass(Some("Screenshot Copy Pass"), &[source], &[], |context| {
9111            context.encoder.copy_texture_to_buffer(
9112                wgpu::TexelCopyTextureInfo {
9113                    texture: &output_texture,
9114                    mip_level: 0,
9115                    origin: wgpu::Origin3d::ZERO,
9116                    aspect: wgpu::TextureAspect::All,
9117                },
9118                wgpu::TexelCopyBufferInfo {
9119                    buffer: &output_buffer,
9120                    layout: wgpu::TexelCopyBufferLayout {
9121                        offset: 0,
9122                        bytes_per_row: Some(padded_bytes_per_row),
9123                        rows_per_image: Some(height),
9124                    },
9125                },
9126                wgpu::Extent3d {
9127                    width,
9128                    height,
9129                    depth_or_array_layers: 1,
9130                },
9131            );
9132            Ok(())
9133        });
9134        let mut executor = std::mem::take(&mut self.frame_graph_executor);
9135        let execution = executor.execute_recorded_graph(&device, &queue, graph);
9136        self.frame_graph_executor = executor;
9137        let execution = execution.map_err(|error| error.to_string())?;
9138        let submission_index = execution.submission;
9139        let copy_stats = execution.stats;
9140        self.last_frame_stats = self
9141            .last_frame_stats
9142            .map(|snapshot| snapshot.with_command_stats_added(copy_stats));
9143
9144        let buffer_slice = output_buffer.slice(..);
9145        let (tx, rx) = mpsc::channel();
9146        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
9147            let _ = tx.send(result);
9148        });
9149        let _ = self.device.poll(wgpu::PollType::Wait {
9150            submission_index: Some(submission_index),
9151            timeout: None,
9152        });
9153
9154        match rx.recv_timeout(Duration::from_secs(3)) {
9155            Ok(Ok(())) => {}
9156            Ok(Err(err)) => return Err(format!("Screenshot map_async failed: {err:?}")),
9157            Err(err) => return Err(format!("Screenshot readback timed out: {err}")),
9158        }
9159
9160        let mapped = buffer_slice.get_mapped_range();
9161        let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
9162
9163        let src_row_len = padded_bytes_per_row as usize;
9164        let dst_row_len = unpadded_bytes_per_row as usize;
9165        for row in 0..height as usize {
9166            let src_offset = row * src_row_len;
9167            let dst_offset = row * dst_row_len;
9168            pixels[dst_offset..dst_offset + dst_row_len]
9169                .copy_from_slice(&mapped[src_offset..src_offset + dst_row_len]);
9170        }
9171        drop(mapped);
9172        output_buffer.unmap();
9173
9174        self.convert_surface_pixels_to_rgba(&mut pixels)?;
9175        Ok(pixels)
9176    }
9177
9178    fn render_graph(
9179        &mut self,
9180        surface_view: &wgpu::TextureView,
9181        root_target: Option<&OffscreenTarget>,
9182        packet: FramePacket,
9183        returns: &mut RenderReturns,
9184    ) -> Result<(), String> {
9185        let device = self.device.clone();
9186        let queue = self.queue.clone();
9187        let graph_start = Instant::now();
9188
9189        #[cfg(not(target_arch = "wasm32"))]
9190        {
9191            let mut executor = std::mem::take(&mut self.frame_graph_executor);
9192            let mut frame_graph = WgpuFrameGraph::new(Some("Renderer Frame Graph"));
9193            let surface = frame_graph.import_surface("renderer-surface");
9194            frame_graph.add_fallible_recorded_command_pass(
9195                Some("Renderer Frame Pass"),
9196                &[],
9197                &[surface],
9198                |frame_encoder| {
9199                    self.render_graph_recorded(
9200                        surface_view,
9201                        root_target,
9202                        packet,
9203                        returns,
9204                        frame_encoder,
9205                    )
9206                },
9207            );
9208            let after_build = Instant::now();
9209            let execution = executor.execute_recorded_graph(&device, &queue, frame_graph);
9210            let after_execute = Instant::now();
9211            self.frame_graph_executor = executor;
9212            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
9213                log::warn!(
9214                    "[wgpu-render-stage:graph] total_ms={total_ms:.2} build_ms={:.2} execute_ms={:.2}",
9215                    instant_ms(graph_start, after_build),
9216                    instant_ms(after_build, after_execute),
9217                );
9218            }
9219
9220            match execution {
9221                Ok(execution) => {
9222                    if execution.stats.pass_count > 0 {
9223                        self.frame_stats.record_command_stats(execution.stats);
9224                    }
9225                    Ok(())
9226                }
9227                Err(crate::frame_graph::FrameGraphError::NoDeclaredPasses) => Ok(()),
9228                Err(error) => Err(error.to_string()),
9229            }
9230        }
9231
9232        #[cfg(target_arch = "wasm32")]
9233        {
9234            let mut executor = std::mem::take(&mut self.frame_graph_executor);
9235            let (result, execution) = {
9236                let mut frame_encoder =
9237                    executor.begin(&device, &queue, Some("Renderer Frame Encoder"));
9238                let initial_pass_count = frame_encoder.recorded_pass_count();
9239                let result = self.render_graph_recorded(
9240                    surface_view,
9241                    root_target,
9242                    packet,
9243                    returns,
9244                    &mut frame_encoder,
9245                );
9246                let execution =
9247                    if result.is_ok() && frame_encoder.recorded_pass_count() > initial_pass_count {
9248                        Some(frame_encoder.finish())
9249                    } else {
9250                        None
9251                    };
9252                (result, execution)
9253            };
9254            let after_execute = Instant::now();
9255            self.frame_graph_executor = executor;
9256            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
9257                log::warn!("[wgpu-render-stage:graph] total_ms={total_ms:.2}",);
9258            }
9259            if let Some(execution) = execution {
9260                self.frame_stats.record_command_stats(execution.stats);
9261            }
9262            result
9263        }
9264    }
9265
9266    fn render_graph_recorded<C: FrameCommandRecorder>(
9267        &mut self,
9268        surface_view: &wgpu::TextureView,
9269        root_target: Option<&OffscreenTarget>,
9270        packet: FramePacket,
9271        returns: &mut RenderReturns,
9272        frame_encoder: &mut C,
9273    ) -> Result<(), String> {
9274        let recorded_start = Instant::now();
9275
9276        // Present-side consumption of the packet's replay plan, adjacent to
9277        // packet consumption: the store honors the ops just before the
9278        // packet renders. Gated on a Direct root — a Surface packet never
9279        // touched the planner and carries the empty default plan
9280        // (generation 0), which the store must not consume: it would count
9281        // a false generation drop. The ack travels back through `returns`
9282        // and the producer applies it right after this render call —
9283        // equivalent to the in-store drain this replaces, because both
9284        // application points sit after this frame's graph build and before
9285        // the next collect, which is where the bypass gate and `feed_slots`
9286        // are read. The threaded present runtime consumes EARLIER
9287        // (`take_replay_ack_early`, before surface acquire) and marks the
9288        // packet, so this block must not feed the taken-out default plan
9289        // to the store.
9290        #[cfg(not(target_arch = "wasm32"))]
9291        let mut packet = packet;
9292        #[cfg(not(target_arch = "wasm32"))]
9293        if !packet.replay_preconsumed {
9294            if let PacketRoot::Direct(root) = &packet.root {
9295                let ops = std::mem::take(&mut packet.replay);
9296                let (ack, recycled) = self.consume_replay_ops(
9297                    ops,
9298                    &root.scene.shapes,
9299                    &root.scene.brushes,
9300                    packet.root_scale,
9301                );
9302                returns.ack = Some((ack, recycled));
9303            }
9304        }
9305
9306        let FramePacket {
9307            frame_id,
9308            viewport: (width, height),
9309            renderer_epoch: _,
9310            surface_epoch: _,
9311            root_scale,
9312            root,
9313            overlay,
9314            replay: _,
9315            text_cache_len: _,
9316            recycled_confirmations: _,
9317            replay_preconsumed: _,
9318        } = packet;
9319
9320        let mut backend = RecordingSurfaceBackend {
9321            renderer: self,
9322            recorder: frame_encoder,
9323        };
9324
9325        let surface_packet = match root {
9326            PacketRoot::Direct(root) => {
9327                let direct_render_start = Instant::now();
9328                let result = match execute_render_root_direct(
9329                    &mut backend,
9330                    surface_view,
9331                    root_target,
9332                    *root,
9333                    width,
9334                    height,
9335                    root_scale,
9336                    wgpu::LoadOp::Clear(CLEAR_COLOR),
9337                ) {
9338                    // Return the packet's scene buffers to the producer pool
9339                    // in BOTH arms — for a heavy animated frame they are
9340                    // megabytes of Vec, and an errored draw must not leak
9341                    // them.
9342                    Ok(scene) => {
9343                        returns.scene = Some(scene);
9344                        Ok(())
9345                    }
9346                    Err((error, scene)) => {
9347                        returns.scene = Some(scene);
9348                        Err(error)
9349                    }
9350                };
9351                if result.is_ok() {
9352                    if let Some(overlay) = overlay {
9353                        Self::render_overlay_packet(
9354                            &mut backend,
9355                            surface_view,
9356                            overlay,
9357                            width,
9358                            height,
9359                            root_scale,
9360                        )?;
9361                    }
9362                }
9363                let after_direct_render = Instant::now();
9364                if let Some(total_ms) =
9365                    should_log_wgpu_render_stage(recorded_start, after_direct_render)
9366                {
9367                    log::warn!(
9368                        "[wgpu-render-stage:recorded-direct-root] frame={frame_id} total_ms={total_ms:.2} render_ms={:.2}",
9369                        instant_ms(direct_render_start, after_direct_render),
9370                    );
9371                }
9372                return result;
9373            }
9374            PacketRoot::Surface(surface_packet) => surface_packet,
9375        };
9376        let after_root_collect = Instant::now();
9377
9378        let RootSurfacePacket {
9379            lowered,
9380            source,
9381            transform_to_parent,
9382            node_id,
9383            backdrop,
9384            graphics_layer,
9385            local_bounds,
9386            clip_rect,
9387            shadow_clip,
9388        } = *surface_packet;
9389        let mut lowered = lowered;
9390        lowered.source = source;
9391
9392        // The root layer's visible area is always the viewport — content
9393        // outside the screen is invisible regardless of scroll offsets or
9394        // inflated scene bounds.  Pass the viewport rect as an explicit
9395        // surface rect to prevent offscreen inflation on constrained GPUs.
9396        let viewport_rect = Rect {
9397            x: 0.0,
9398            y: 0.0,
9399            width: width as f32 / root_scale,
9400            height: height as f32 / root_scale,
9401        };
9402        let root_surface = execute_render_layer_surface(
9403            &mut backend,
9404            &mut lowered,
9405            LayerSurfaceRequest {
9406                root_scale,
9407                backdrop_underlay: None,
9408                backdrop_underlay_color: None,
9409                allow_runtime_cache: false,
9410                logical_rect_override: Some(viewport_rect),
9411                capture_clip_override: None,
9412                activates_nested_capture: false,
9413                translation_context: TranslationRenderContext::default(),
9414            },
9415        )?;
9416        let root_quad = transform_to_parent.map_rect(root_surface.logical_rect);
9417        let root_dest_quad = scaled_quad(root_quad, root_scale);
9418
9419        let needs_root_composite_target =
9420            backdrop.is_some() || graphics_layer.shadow_elevation > 0.0;
9421
9422        if needs_root_composite_target {
9423            let composite_target = backend.acquire_frame_surface(width, height);
9424            backend.clear_target_view_with_load_op(
9425                &composite_target.view,
9426                wgpu::LoadOp::Clear(CLEAR_COLOR),
9427            );
9428
9429            if let Some(backdrop) = &backdrop {
9430                execute_apply_backdrop_layer_to_target(
9431                    &mut backend,
9432                    &composite_target,
9433                    &BackdropLayer {
9434                        node_id,
9435                        rect: quad_bounds(transform_to_parent.map_rect(local_bounds)),
9436                        clip: clip_rect.map(|clip| quad_bounds(transform_to_parent.map_rect(clip))),
9437                        snap_anchor: None,
9438                        effect: backdrop.clone(),
9439                        z_index: 0,
9440                    },
9441                    None,
9442                    width,
9443                    height,
9444                    root_scale,
9445                    None,
9446                )?;
9447            }
9448
9449            let mut root_shadow_scene = CompositorScene::new();
9450            let root_shadow_clip =
9451                shadow_clip.map(|clip| quad_bounds(transform_to_parent.map_rect(clip)));
9452            push_layer_shadow(
9453                &mut root_shadow_scene,
9454                &graphics_layer,
9455                local_bounds,
9456                quad_bounds(transform_to_parent.map_rect(local_bounds)),
9457                root_shadow_clip,
9458            );
9459            for shadow in &root_shadow_scene.shadow_draws {
9460                backend.render_shadow_draw(
9461                    &composite_target.view,
9462                    shadow,
9463                    width,
9464                    height,
9465                    root_scale,
9466                );
9467            }
9468
9469            let composite_dest_quad =
9470                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
9471            execute_composite_surface_to_view(
9472                &mut backend,
9473                root_surface.target.target(),
9474                &composite_target.view,
9475                (width, height),
9476                composite_dest_quad,
9477                root_surface.composite_alpha,
9478                wgpu::LoadOp::Load,
9479                None,
9480                root_surface.blend_mode,
9481                root_surface.sample_mode,
9482            )?;
9483            backend.composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
9484                &composite_target,
9485                surface_view,
9486                1.0,
9487                wgpu::LoadOp::Clear(CLEAR_COLOR),
9488                None,
9489                None,
9490                BlendMode::SrcOver,
9491                None,
9492                CompositeSampleMode::Linear,
9493            );
9494            backend.release_frame_surface(composite_target);
9495        } else {
9496            let composite_dest_quad =
9497                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
9498            execute_composite_surface_to_view(
9499                &mut backend,
9500                root_surface.target.target(),
9501                surface_view,
9502                (width, height),
9503                composite_dest_quad,
9504                root_surface.composite_alpha,
9505                wgpu::LoadOp::Clear(CLEAR_COLOR),
9506                None,
9507                root_surface.blend_mode,
9508                root_surface.sample_mode,
9509            )?;
9510        }
9511        backend.release_layer_surface_target(root_surface.target);
9512        if let Some(overlay) = overlay {
9513            Self::render_overlay_packet(
9514                &mut backend,
9515                surface_view,
9516                overlay,
9517                width,
9518                height,
9519                root_scale,
9520            )?;
9521        }
9522        let after_layer_render = Instant::now();
9523        if let Some(total_ms) = should_log_wgpu_render_stage(recorded_start, after_layer_render) {
9524            log::warn!(
9525                "[wgpu-render-stage:recorded-layer-root] total_ms={total_ms:.2} collect_ms={:.2} render_ms={:.2}",
9526                instant_ms(recorded_start, after_root_collect),
9527                instant_ms(after_root_collect, after_layer_render),
9528            );
9529        }
9530        Ok(())
9531    }
9532
9533    /// Renders the producer-lowered dev overlay on top of the frame. The
9534    /// packet carries the collected overlay; the backend only validates
9535    /// that it stayed directly renderable and draws it.
9536    fn render_overlay_packet<C: FrameCommandRecorder>(
9537        backend: &mut RecordingSurfaceBackend<'_, '_, C>,
9538        surface_view: &wgpu::TextureView,
9539        overlay: CollectedLayer,
9540        width: u32,
9541        height: u32,
9542        root_scale: f32,
9543    ) -> Result<(), String> {
9544        if !overlay.child_layers.is_empty()
9545            || !root_direct_scene_events_are_supported(&overlay.scene, false)
9546            || !direct_root_child_underlays_are_supported(&overlay, false)
9547        {
9548            return Err("dev overlay graph must stay directly renderable".to_string());
9549        }
9550        execute_render_root_direct(
9551            backend,
9552            surface_view,
9553            None,
9554            overlay,
9555            width,
9556            height,
9557            root_scale,
9558            wgpu::LoadOp::Load,
9559        )
9560        .map(|_overlay_scene| ())
9561        .map_err(|(error, _overlay_scene)| error)
9562    }
9563
9564    #[allow(clippy::too_many_arguments)]
9565    fn encode_non_effect_segment_commands<C: FrameCommandRecorder>(
9566        &mut self,
9567        frame_encoder: &mut C,
9568        target_view: &wgpu::TextureView,
9569        ordered_items: &[(usize, SegmentDrawItem)],
9570        composites: &[(usize, CompositeBatchItem<'_>)],
9571        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9572        shapes: &[DrawShape],
9573        brushes: &[Brush],
9574        images: &[ImageDraw],
9575        texts: &[TextDraw],
9576        shadow_draws: &[ShadowDraw],
9577        retained_draws: &[RetainedDraw],
9578        initial_load_op: wgpu::LoadOp<wgpu::Color>,
9579        width: u32,
9580        height: u32,
9581        root_scale: f32,
9582    ) -> Result<SegmentCommandEncodeOutcome, String> {
9583        let mut first_batch = true;
9584        for command in
9585            SegmentCommandIter::new(ordered_items, shapes, images, self.shape_batch_limits)
9586        {
9587            match command {
9588                SegmentRenderCommand::DrawChunk(chunk) => {
9589                    let load_op = if first_batch {
9590                        initial_load_op
9591                    } else {
9592                        wgpu::LoadOp::Load
9593                    };
9594                    let outcome = self.render_segment_draw_chunk(
9595                        frame_encoder,
9596                        target_view,
9597                        ordered_items,
9598                        composites,
9599                        shader_composites,
9600                        shapes,
9601                        brushes,
9602                        images,
9603                        texts,
9604                        retained_draws,
9605                        chunk,
9606                        width,
9607                        height,
9608                        root_scale,
9609                        load_op,
9610                    )?;
9611                    if outcome.rendered_any {
9612                        frame_encoder.record_passes(outcome.pass_count);
9613                        first_batch = false;
9614                    }
9615                }
9616                SegmentRenderCommand::Shadow(index) => {
9617                    if first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
9618                        {
9619                            let _clear = frame_encoder.encoder().begin_render_pass(
9620                                &wgpu::RenderPassDescriptor {
9621                                    label: Some("Shadow Pre-Clear"),
9622                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9623                                        view: target_view,
9624                                        resolve_target: None,
9625                                        depth_slice: None,
9626                                        ops: wgpu::Operations {
9627                                            load: initial_load_op,
9628                                            store: wgpu::StoreOp::Store,
9629                                        },
9630                                    })],
9631                                    depth_stencil_attachment: None,
9632                                    timestamp_writes: None,
9633                                    occlusion_query_set: None,
9634                                    multiview_mask: None,
9635                                },
9636                            );
9637                        }
9638                        frame_encoder.record_pass();
9639                        first_batch = false;
9640                    }
9641                    let pass_count_before = frame_encoder.recorded_pass_count();
9642                    self.encode_shadow_draw(
9643                        frame_encoder,
9644                        target_view,
9645                        &shadow_draws[index],
9646                        width,
9647                        height,
9648                        root_scale,
9649                    );
9650                    if frame_encoder.recorded_pass_count() > pass_count_before {
9651                        first_batch = false;
9652                    }
9653                }
9654            }
9655        }
9656        Ok(SegmentCommandEncodeOutcome { first_batch })
9657    }
9658
9659    #[cfg(not(target_arch = "wasm32"))]
9660    #[allow(clippy::too_many_arguments)]
9661    fn render_segment_draw_chunk_fused_native<C: FrameCommandRecorder>(
9662        &mut self,
9663        frame_encoder: &mut C,
9664        target_view: &wgpu::TextureView,
9665        ordered_items: &[(usize, SegmentDrawItem)],
9666        composites: &[(usize, CompositeBatchItem<'_>)],
9667        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9668        shapes: &[DrawShape],
9669        brushes: &[Brush],
9670        images: &[ImageDraw],
9671        texts: &[TextDraw],
9672        retained_draws: &[RetainedDraw],
9673        chunk: &SegmentDrawChunkPlan,
9674        width: u32,
9675        height: u32,
9676        root_scale: f32,
9677        load_op: wgpu::LoadOp<wgpu::Color>,
9678    ) -> Result<Option<SegmentRenderOutcome>, String> {
9679        let Some(partitions) = native_segment_fusion_partitions(
9680            ordered_items,
9681            shapes,
9682            brushes,
9683            chunk,
9684            self.shape_batch_limits,
9685        )?
9686        else {
9687            return Ok(None);
9688        };
9689
9690        let mut rendered_any = false;
9691        let mut pass_count = 0_u32;
9692        let mut next_load_op = load_op;
9693        let encode_started = Instant::now();
9694        let mut partition_count = 0_u64;
9695        for partition in partitions {
9696            partition_count += 1;
9697            let outcome = self.render_segment_draw_chunk_fused_native_partition(
9698                frame_encoder,
9699                target_view,
9700                ordered_items,
9701                composites,
9702                shader_composites,
9703                shapes,
9704                brushes,
9705                images,
9706                texts,
9707                retained_draws,
9708                &partition.chunk,
9709                partition.budget,
9710                width,
9711                height,
9712                root_scale,
9713                next_load_op,
9714            )?;
9715            if outcome.rendered_any {
9716                rendered_any = true;
9717                pass_count = pass_count.saturating_add(outcome.pass_count);
9718                next_load_op = wgpu::LoadOp::Load;
9719            }
9720        }
9721
9722        self.segment_encode_stats
9723            .note_call(partition_count, encode_started.elapsed().as_micros() as u64);
9724
9725        Ok(Some(SegmentRenderOutcome {
9726            rendered_any,
9727            pass_count,
9728        }))
9729    }
9730
9731    #[cfg(not(target_arch = "wasm32"))]
9732    #[allow(clippy::too_many_arguments)]
9733    fn render_segment_draw_chunk_fused_native_partition<C: FrameCommandRecorder>(
9734        &mut self,
9735        frame_encoder: &mut C,
9736        target_view: &wgpu::TextureView,
9737        ordered_items: &[(usize, SegmentDrawItem)],
9738        composites: &[(usize, CompositeBatchItem<'_>)],
9739        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9740        shapes: &[DrawShape],
9741        brushes: &[Brush],
9742        images: &[ImageDraw],
9743        texts: &[TextDraw],
9744        retained_draws: &[RetainedDraw],
9745        chunk: &SegmentDrawChunkPlan,
9746        budget: NativeSegmentFusionBudget,
9747        width: u32,
9748        height: u32,
9749        root_scale: f32,
9750        load_op: wgpu::LoadOp<wgpu::Color>,
9751    ) -> Result<SegmentRenderOutcome, String> {
9752        let partition_start = Instant::now();
9753        let mut staged_uploads = self.take_staged_uploads();
9754        staged_uploads.clear();
9755        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
9756        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
9757        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
9758        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
9759        // Moved out like the scratch vecs: the span blit borrows the cached
9760        // texture across the render pass while `self` stays mutably usable.
9761        let mut span_cache = std::mem::take(&mut self.static_span);
9762        // Moved out for the same reason: prepared segment composites borrow
9763        // entry textures across the render pass.
9764        let mut segment_surfaces = std::mem::take(&mut self.segment_surfaces);
9765
9766        image_vertices.clear();
9767        image_indices.clear();
9768        image_cmds.clear();
9769        glyph_cmds.clear();
9770
9771        let result = (|| {
9772            let viewport = ViewportUniformParams {
9773                width,
9774                height,
9775                offset: [0.0, 0.0],
9776            };
9777            self.prewarm_offscreen_text_glyph_draws_in_chunk(
9778                ordered_items,
9779                texts,
9780                chunk,
9781                viewport,
9782                root_scale,
9783                &mut staged_uploads,
9784                &mut image_vertices,
9785                &mut image_indices,
9786                &mut glyph_cmds,
9787            )?;
9788            let mut shape_refs = Vec::with_capacity(budget.shape_count);
9789            for batch in chunk.iter() {
9790                let SegmentBatchPlan::Shape { start, end, .. } = batch else {
9791                    continue;
9792                };
9793                for (_, item) in &ordered_items[start..end] {
9794                    let SegmentDrawItem::Shape(shape_index) = item else {
9795                        return Err(format!(
9796                            "shape batch contains non-shape draw item: {item:?}"
9797                        ));
9798                    };
9799                    shape_refs.push(&shapes[*shape_index]);
9800                }
9801            }
9802            let after_shape_refs = Instant::now();
9803
9804            let mut direct_shape_uploads = StagedBufferUploads::default();
9805            let mut shape_upload_base = 0u64;
9806            if !shape_refs.is_empty() {
9807                let Some((_, upload_base)) = self.prepare_shapes_batch_direct(
9808                    frame_encoder,
9809                    shape_refs.iter().copied(),
9810                    brushes,
9811                    root_scale,
9812                    viewport,
9813                    &mut direct_shape_uploads,
9814                ) else {
9815                    return Err(
9816                        "native fused segment shape preparation produced no draw batch".to_string(),
9817                    );
9818                };
9819                shape_upload_base = upload_base;
9820            }
9821            let after_shape_prepare = Instant::now();
9822
9823            // Segment-surface phase 1 (CRANPOSE_SEGMENT_SURFACE opt-in, see
9824            // `crate::segment_surface`): per retained item of this
9825            // partition, decide cached-composite vs direct, install/refresh
9826            // entries, and stage this frame's capture transforms. Runs
9827            // BEFORE the batch-prepare loop because recolor dirtiness is
9828            // read from the frame's still-parked patch list, which the
9829            // Retained prepare arm drains (`stage_replay_patches`).
9830            let mut segment_captures: Vec<SegmentCaptureJob> = Vec::new();
9831            let mut segment_composite_plans: Vec<(usize, SegmentCompositePlan)> = Vec::new();
9832            if segment_surfaces.enabled() {
9833                self.plan_segment_surfaces(
9834                    &mut segment_surfaces,
9835                    ordered_items,
9836                    chunk,
9837                    retained_draws,
9838                    &mut staged_uploads,
9839                    &mut segment_captures,
9840                    &mut segment_composite_plans,
9841                );
9842            }
9843
9844            // Opaque static leading-span cache: decide once per frame, on
9845            // the partition carrying the frame's opaque clear, whether the
9846            // leading run of converted records matches the cached span
9847            // composite (skip them, blit instead), repeated byte-identically
9848            // from last frame (draw live, then capture), or neither.
9849            let first_batch_info = match chunk.batches.first() {
9850                Some(&SegmentBatchPlan::Shape {
9851                    start,
9852                    end,
9853                    blend_mode,
9854                }) => {
9855                    let mut has_gradient = false;
9856                    for (_, item) in &ordered_items[start..end] {
9857                        if let SegmentDrawItem::Shape(shape_index) = item {
9858                            has_gradient |=
9859                                shape_gradient_stop_count(&shapes[*shape_index], brushes) > 0;
9860                        }
9861                    }
9862                    Some((end - start, blend_mode, has_gradient))
9863                }
9864                _ => None,
9865            };
9866            let span_decision = span_cache.engage(
9867                load_op,
9868                first_batch_info,
9869                width,
9870                height,
9871                &self.scratch_shape_data,
9872                &self.scratch_gradients,
9873            );
9874            let span_skip = match span_decision {
9875                StaticSpanDecision::Hit { skip } => {
9876                    if fill_area_diag_enabled() {
9877                        // The skipped quads were counted at batch prepare;
9878                        // the replacing blit is an effect-renderer
9879                        // composite, which the instrument's policy does not
9880                        // count.
9881                        self.fill_area_diag
9882                            .note_static_span_skip(&self.scratch_shape_data[..skip]);
9883                    }
9884                    skip
9885                }
9886                _ => 0,
9887            };
9888
9889            // Transient rim band meshes: scan the freshly converted shapes
9890            // (still in `scratch_shape_data` after
9891            // `prepare_shapes_batch_direct`) for huge circle rims and give
9892            // each a band mesh covering ring ± AA margin instead of its full
9893            // bounding quad. Kill switch read once per chunk; the mesh
9894            // pipeline exists in storage mode only and blends SrcOver only,
9895            // hence the two extra gates at the batch arm below.
9896            let rim_mesh_on = rim_mesh_enabled();
9897            let mut chunk_rims: Vec<RimDraw> = Vec::new();
9898
9899            let mut fused_batches = Vec::with_capacity(chunk.batches.len());
9900            let mut shape_cursor = 0_u32;
9901            let mut composite_cursor = 0usize;
9902            let mut shader_composite_cursor = 0usize;
9903            for (batch_index, batch) in chunk.iter().enumerate() {
9904                match batch {
9905                    SegmentBatchPlan::Shape {
9906                        start,
9907                        end,
9908                        blend_mode,
9909                    } => {
9910                        let mut has_gradient = false;
9911                        for (_, item) in &ordered_items[start..end] {
9912                            let SegmentDrawItem::Shape(shape_index) = item else {
9913                                return Err(format!(
9914                                    "shape batch contains non-shape draw item: {item:?}"
9915                                ));
9916                            };
9917                            has_gradient |=
9918                                shape_gradient_stop_count(&shapes[*shape_index], brushes) > 0;
9919                        }
9920                        // A span hit skips the leading shapes of the FIRST
9921                        // batch only: they stay in the upload (indices of
9922                        // everything after them are untouched) but the draw
9923                        // range starts past them.
9924                        let skip = if batch_index == 0 { span_skip } else { 0 };
9925                        let shape_count = end - start;
9926                        if shape_count > 0 {
9927                            if rim_mesh_on
9928                                && self.instanced_quads.is_some()
9929                                && blend_mode == BlendMode::SrcOver
9930                            {
9931                                for offset in skip..shape_count {
9932                                    // The index `vs_mesh` reads into the
9933                                    // storage shape array: position within
9934                                    // the whole fused upload (shape_refs
9935                                    // order == scratch_shape_data order).
9936                                    let global_index = shape_cursor + offset as u32;
9937                                    let converted = &self.scratch_shape_data[global_index as usize];
9938                                    let Some(band) = rim_mesh_band(converted) else {
9939                                        continue;
9940                                    };
9941                                    let vertex_mark = self.rim_mesh_vertices.len();
9942                                    let index_mark = self.rim_mesh_indices.len();
9943                                    if emit_arc_band_mesh(
9944                                        converted,
9945                                        global_index,
9946                                        &band,
9947                                        &mut self.rim_mesh_vertices,
9948                                        &mut self.rim_mesh_indices,
9949                                    )
9950                                    .is_none()
9951                                    {
9952                                        // Nothing emitted (fully clipped) —
9953                                        // the quad path draws it as today.
9954                                        self.rim_mesh_vertices.truncate(vertex_mark);
9955                                        self.rim_mesh_indices.truncate(index_mark);
9956                                        continue;
9957                                    }
9958                                    if self.rim_mesh_vertices.len() > RIM_MESH_VERTEX_CAPACITY
9959                                        || self.rim_mesh_indices.len() > RIM_MESH_INDEX_CAPACITY
9960                                    {
9961                                        // Whole-rim rollback, never a
9962                                        // truncation: a partial band would
9963                                        // break the containment invariant.
9964                                        self.rim_mesh_vertices.truncate(vertex_mark);
9965                                        self.rim_mesh_indices.truncate(index_mark);
9966                                        rim_mesh_capacity_warn();
9967                                        continue;
9968                                    }
9969                                    chunk_rims.push(RimDraw {
9970                                        shape_index: global_index,
9971                                        first_index: index_mark as u32,
9972                                        index_count: (self.rim_mesh_indices.len() - index_mark)
9973                                            as u32,
9974                                    });
9975                                    if fill_area_diag_enabled() {
9976                                        self.fill_area_diag.note_rim_mesh(
9977                                            converted,
9978                                            triangles_shoelace_area(
9979                                                &self.rim_mesh_vertices,
9980                                                &self.rim_mesh_indices[index_mark..],
9981                                            ),
9982                                        );
9983                                    }
9984                                    self.rim_meshes_emitted += 1;
9985                                    if self.rim_meshes_emitted % 600 == 1 {
9986                                        log::debug!(
9987                                            "[rim-mesh] {} rims meshed lifetime ({} verts live this frame)",
9988                                            self.rim_meshes_emitted,
9989                                            self.rim_mesh_vertices.len(),
9990                                        );
9991                                    }
9992                                }
9993                            }
9994                            if shape_count > skip {
9995                                fused_batches.push(FusedSegmentBatch::Shape {
9996                                    batch: PreparedShapeBatch {
9997                                        vertex_start: (shape_cursor + skip as u32) * 6,
9998                                        vertex_count: (shape_count - skip) as u32 * 6,
9999                                        has_gradient,
10000                                    },
10001                                    blend_mode,
10002                                });
10003                            }
10004                            shape_cursor += shape_count as u32;
10005                        }
10006                    }
10007                    SegmentBatchPlan::Image {
10008                        start,
10009                        end,
10010                        blend_mode,
10011                    } => {
10012                        let cmd_start = image_cmds.len();
10013                        for (_, item) in &ordered_items[start..end] {
10014                            let SegmentDrawItem::Image(image_index) = item else {
10015                                return Err(format!(
10016                                    "image batch contains non-image draw item: {item:?}"
10017                                ));
10018                            };
10019                            self.append_image_draw_cmd(
10020                                &images[*image_index],
10021                                viewport,
10022                                root_scale,
10023                                &mut image_vertices,
10024                                &mut image_indices,
10025                                &mut image_cmds,
10026                            )?;
10027                        }
10028                        let cmd_end = image_cmds.len();
10029                        if cmd_start < cmd_end {
10030                            fused_batches.push(FusedSegmentBatch::Image {
10031                                cmd_range: cmd_start..cmd_end,
10032                                blend_mode,
10033                            });
10034                        }
10035                    }
10036                    SegmentBatchPlan::Text { start, end } => {
10037                        let glyph_cmd_start = glyph_cmds.len();
10038                        let image_cmd_start = image_cmds.len();
10039                        let text_draws =
10040                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10041                        if !self.append_text_glyph_draws(
10042                            text_draws,
10043                            viewport,
10044                            root_scale,
10045                            false,
10046                            &mut staged_uploads,
10047                            &mut image_vertices,
10048                            &mut image_indices,
10049                            &mut glyph_cmds,
10050                        )? {
10051                            let text_draws =
10052                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10053                            self.append_text_image_draw_cmds(
10054                                text_draws,
10055                                viewport,
10056                                root_scale,
10057                                &mut image_vertices,
10058                                &mut image_indices,
10059                                &mut image_cmds,
10060                            )?;
10061                        }
10062                        let image_cmd_end = image_cmds.len();
10063                        let glyph_cmd_end = glyph_cmds.len();
10064                        if image_cmd_start < image_cmd_end || glyph_cmd_start < glyph_cmd_end {
10065                            fused_batches.push(FusedSegmentBatch::Text {
10066                                image_cmd_range: image_cmd_start..image_cmd_end,
10067                                glyph_cmd_range: glyph_cmd_start..glyph_cmd_end,
10068                            });
10069                        }
10070                    }
10071                    SegmentBatchPlan::Composite { start, end } => {
10072                        for (_, item) in &ordered_items[start..end] {
10073                            if !matches!(item, SegmentDrawItem::Composite(_)) {
10074                                return Err(format!(
10075                                    "composite batch contains non-composite draw item: {item:?}"
10076                                ));
10077                            }
10078                        }
10079                        let draw_count = end - start;
10080                        if draw_count > 0 {
10081                            let draw_start = composite_cursor;
10082                            composite_cursor += draw_count;
10083                            fused_batches.push(FusedSegmentBatch::Composite {
10084                                draw_range: draw_start..composite_cursor,
10085                            });
10086                        }
10087                    }
10088                    SegmentBatchPlan::ShaderComposite { start, end } => {
10089                        for (_, item) in &ordered_items[start..end] {
10090                            if !matches!(item, SegmentDrawItem::ShaderComposite(_)) {
10091                                return Err(format!(
10092                                    "shader composite batch contains non-shader-composite draw item: {item:?}"
10093                                ));
10094                            }
10095                        }
10096                        let draw_count = end - start;
10097                        if draw_count > 0 {
10098                            let draw_start = shader_composite_cursor;
10099                            shader_composite_cursor += draw_count;
10100                            fused_batches.push(FusedSegmentBatch::ShaderComposite {
10101                                draw_range: draw_start..shader_composite_cursor,
10102                            });
10103                        }
10104                    }
10105                    SegmentBatchPlan::Retained { start, end } => {
10106                        self.stage_replay_patches(&mut staged_uploads);
10107                        for (_, item) in &ordered_items[start..end] {
10108                            let SegmentDrawItem::Retained(index) = item else {
10109                                return Err(format!(
10110                                    "retained batch contains non-retained draw item: {item:?}"
10111                                ));
10112                            };
10113                            let retained = retained_draws.get(*index).ok_or_else(|| {
10114                                format!("retained draw index {index} out of bounds")
10115                            })?;
10116                            if (*index as u32) < MAX_REPLAY_SLOTS
10117                                && self.replay_slots.slots.contains_key(&retained.slot)
10118                            {
10119                                let transform = retained.transform.with_retained_paint();
10120                                staged_uploads.stage_at(
10121                                    UploadTarget::ReplayTransform,
10122                                    *index as u64 * REPLAY_TRANSFORM_STRIDE,
10123                                    bytemuck::bytes_of(&transform),
10124                                );
10125                            }
10126                        }
10127                        if end > start {
10128                            fused_batches.push(FusedSegmentBatch::Retained {
10129                                item_range: start..end,
10130                            });
10131                        }
10132                    }
10133                }
10134            }
10135            if !chunk_rims.is_empty() {
10136                self.upload_transient_rim_meshes();
10137            }
10138            let after_batch_prepare = Instant::now();
10139
10140            if !image_indices.is_empty() {
10141                self.stage_native_image_buffers(
10142                    &mut staged_uploads,
10143                    viewport,
10144                    &image_vertices,
10145                    &image_indices,
10146                );
10147            }
10148
10149            // Display clip region cull: engages exactly when this fused
10150            // pass draws the frame's root surface whole and the platform
10151            // reported a cullable visible region (the round display being
10152            // the first provider). The pass then carries a transient depth
10153            // attachment, the region complement's occluder is drawn first,
10154            // and every pipeline below is fetched in its depth-tested
10155            // variant (the getters read `pass_depth`). Offscreen/layer
10156            // passes never reach this branch with a `Some` here.
10157            let display_clip_depth_view =
10158                self.display_clip_pass_depth_view(target_view, width, height);
10159            let pass_depth = display_clip_depth_view.is_some();
10160
10161            let device = self.device.clone();
10162            let composite_items: Vec<_> = chunk
10163                .iter()
10164                .filter_map(|batch| match batch {
10165                    SegmentBatchPlan::Composite { start, end } => Some((start, end)),
10166                    _ => None,
10167                })
10168                .flat_map(|(start, end)| {
10169                    ordered_items[start..end].iter().filter_map(|(_, item)| {
10170                        let SegmentDrawItem::Composite(composite_index) = item else {
10171                            return None;
10172                        };
10173                        composites
10174                            .get(*composite_index)
10175                            .map(|(_, composite)| *composite)
10176                    })
10177                })
10178                .collect();
10179            let prepared_composites = self.effect_renderer.prepare_composite_batch_draws(
10180                frame_encoder,
10181                &device,
10182                load_op,
10183                &composite_items,
10184                pass_depth,
10185            );
10186            let shader_items: Vec<_> = chunk
10187                .iter()
10188                .filter_map(|batch| match batch {
10189                    SegmentBatchPlan::ShaderComposite { start, end } => Some((start, end)),
10190                    _ => None,
10191                })
10192                .flat_map(|(start, end)| {
10193                    ordered_items[start..end].iter().filter_map(|(_, item)| {
10194                        let SegmentDrawItem::ShaderComposite(composite_index) = item else {
10195                            return None;
10196                        };
10197                        shader_composites
10198                            .get(*composite_index)
10199                            .map(|(_, composite)| *composite)
10200                    })
10201                })
10202                .collect();
10203            let prepared_shaders = self
10204                .effect_renderer
10205                .prepare_shader_batch_draws(frame_encoder, &device, &shader_items, pass_depth)
10206                .ok_or_else(|| "shader composite batch preparation failed".to_string())?;
10207            if !shader_items.is_empty() {
10208                self.effect_renderer.record_composite_pass();
10209                self.effect_renderer
10210                    .debug_effects
10211                    .set(self.effect_renderer.debug_effects.get() + shader_items.len() as u32);
10212            }
10213            // Span hit: prepare the cached-texture blit that stands in for
10214            // the skipped shapes. Reuses the effect renderer's composite
10215            // machinery — the same prepared-draw path the Composite arms
10216            // ride — with Nearest sampling (an exact `textureLoad`), alpha
10217            // 1.0, no mask, no viewports: a 1:1 full-target replace-write
10218            // of alpha-255 texels (see `StaticSpanCache`).
10219            let span_blit_items =
10220                span_cache
10221                    .texture
10222                    .as_ref()
10223                    .filter(|_| span_skip > 0)
10224                    .map(|texture| CompositeBatchItem {
10225                        source: texture,
10226                        alpha: 1.0,
10227                        scissor: None,
10228                        rounded_mask: None,
10229                        blend_mode: BlendMode::SrcOver,
10230                        dest_viewport: None,
10231                        source_viewport: None,
10232                        sample_mode: CompositeSampleMode::Nearest,
10233                    });
10234            let span_blit = match &span_blit_items {
10235                Some(item) => self.effect_renderer.prepare_composite_batch_draws(
10236                    frame_encoder,
10237                    &device,
10238                    load_op,
10239                    std::slice::from_ref(item),
10240                    pass_depth,
10241                ),
10242                None => Vec::new(),
10243            };
10244            // Segment-surface phase 2: prepared rotated-quad composites for
10245            // the cached spans. Each is drawn inside the fused pass at its
10246            // span's exact batch position (see the Retained draw arm), so
10247            // interleaved z order is preserved by construction.
10248            let mut prepared_segment_composites: Vec<(usize, PreparedProjectiveComposite<'_>)> =
10249                Vec::with_capacity(segment_composite_plans.len());
10250            for (index, plan) in &segment_composite_plans {
10251                let Some(entry) = segment_surfaces.entry(&plan.key) else {
10252                    continue;
10253                };
10254                let item = ProjectiveCompositeItem {
10255                    source: &entry.texture,
10256                    viewport: (width, height),
10257                    dest_quad: plan.dest_quad,
10258                    inverse: plan.inverse,
10259                    alpha: 1.0,
10260                    blend_mode: BlendMode::SrcOver,
10261                    // An identity effective transform samples through the
10262                    // exact textureLoad path; motion samples bilinear.
10263                    sample_mode: if plan.identity {
10264                        CompositeSampleMode::Nearest
10265                    } else {
10266                        CompositeSampleMode::Linear
10267                    },
10268                };
10269                let prepared = self.effect_renderer.prepare_projective_composite_draw(
10270                    frame_encoder,
10271                    &device,
10272                    &item,
10273                    pass_depth,
10274                );
10275                prepared_segment_composites.push((*index, prepared));
10276            }
10277            let after_composite_prepare = Instant::now();
10278
10279            if fused_batches.is_empty() && span_blit.is_empty() {
10280                return Ok(SegmentRenderOutcome {
10281                    rendered_any: false,
10282                    pass_count: 0,
10283                });
10284            }
10285
10286            // The direct shape copies must be recorded before the staged
10287            // flush: its capacity check may replace `upload_buffer`, and the
10288            // shape payload was written into the buffer that existed at
10289            // prepare time. Recording first binds the copies to that buffer.
10290            self.flush_staged_uploads_at(
10291                frame_encoder.encoder(),
10292                &direct_shape_uploads,
10293                shape_upload_base,
10294            );
10295            let upload_offset =
10296                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10297            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
10298            let after_upload = Instant::now();
10299
10300            // Segment-surface phase 3: encode this frame's capture passes —
10301            // after the staged flush (their transforms and this frame's
10302            // recolor patches ride it), before the fused pass that samples
10303            // the surfaces. A recolored span therefore invalidates,
10304            // recaptures and composites within ONE frame, and the fused
10305            // pass never samples a stale surface. `pass_depth` is not yet
10306            // set on the pipeline getters here, so the capture walk fetches
10307            // the ordinary flat pipeline variants.
10308            let mut segment_capture_passes = 0u32;
10309            for job in &segment_captures {
10310                let Some(entry) = segment_surfaces.entry(&job.key) else {
10311                    continue;
10312                };
10313                let Some(slot) = self.replay_slots.slots.get(&job.key.slot) else {
10314                    continue;
10315                };
10316                let Some(uniform_group) =
10317                    segment_surfaces.capture_uniform_bind_group(job.capture_index)
10318                else {
10319                    continue;
10320                };
10321                let mut capture_pass =
10322                    frame_encoder
10323                        .encoder()
10324                        .begin_render_pass(&wgpu::RenderPassDescriptor {
10325                            label: Some("Segment Surface Capture Pass"),
10326                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10327                                view: &entry.texture.view,
10328                                resolve_target: None,
10329                                depth_slice: None,
10330                                ops: wgpu::Operations {
10331                                    // Transparent clear: the surface holds
10332                                    // the span's premultiplied flattening
10333                                    // and nothing else.
10334                                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
10335                                    store: wgpu::StoreOp::Store,
10336                                },
10337                            })],
10338                            depth_stencil_attachment: None,
10339                            timestamp_writes: None,
10340                            occlusion_query_set: None,
10341                            multiview_mask: None,
10342                        });
10343                let draws = self.encode_retained_op(
10344                    slot,
10345                    job.first,
10346                    job.last,
10347                    MAX_REPLAY_SLOTS + job.capture_index,
10348                    &mut |cmd| match cmd {
10349                        // The capture retargets bind group 0 to its
10350                        // sub-viewport uniforms (viewport_offset maps the
10351                        // capture rect onto the surface); everything else
10352                        // is the IDENTICAL walk the direct draw encodes.
10353                        RetainedCmd::Uniforms(_) => {
10354                            capture_pass.set_bind_group(0, uniform_group, &[])
10355                        }
10356                        RetainedCmd::Pipeline(pipeline) => capture_pass.set_pipeline(pipeline),
10357                        RetainedCmd::SlotBindings(group, offset) => {
10358                            capture_pass.set_bind_group(1, group, &[offset])
10359                        }
10360                        RetainedCmd::MeshVertices(buffer) => {
10361                            capture_pass.set_vertex_buffer(0, buffer.slice(..))
10362                        }
10363                        RetainedCmd::Index(buffer, format) => {
10364                            capture_pass.set_index_buffer(buffer.slice(..), format)
10365                        }
10366                        RetainedCmd::Draw(vertices) => capture_pass.draw(vertices, 0..1),
10367                        RetainedCmd::DrawIndexed(indices, instances) => {
10368                            capture_pass.draw_indexed(indices, 0, instances)
10369                        }
10370                    },
10371                );
10372                self.frame_stats.add_draw_calls(draws);
10373                segment_capture_passes += 1;
10374            }
10375
10376            let use_retained_bundles = retained_bundles_enabled();
10377            let mut retained_encode_ms = 0.0_f64;
10378            {
10379                let mut render_pass =
10380                    frame_encoder
10381                        .encoder()
10382                        .begin_render_pass(&wgpu::RenderPassDescriptor {
10383                            label: Some("Fused Segment Draw Pass"),
10384                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10385                                view: target_view,
10386                                resolve_target: None,
10387                                depth_slice: None,
10388                                ops: wgpu::Operations {
10389                                    load: load_op,
10390                                    store: wgpu::StoreOp::Store,
10391                                },
10392                            })],
10393                            // Clear + Discard: the display-clip depth
10394                            // buffer is born and dies inside this pass — on
10395                            // tiled GPUs it never leaves GMEM.
10396                            depth_stencil_attachment: display_clip_depth_view.as_ref().map(
10397                                |view| wgpu::RenderPassDepthStencilAttachment {
10398                                    view,
10399                                    depth_ops: Some(wgpu::Operations {
10400                                        load: wgpu::LoadOp::Clear(
10401                                            crate::display_clip::DISPLAY_CLIP_DEPTH_CLEAR,
10402                                        ),
10403                                        store: wgpu::StoreOp::Discard,
10404                                    }),
10405                                    stencil_ops: None,
10406                                },
10407                            ),
10408                            timestamp_writes: None,
10409                            occlusion_query_set: None,
10410                            multiview_mask: None,
10411                        });
10412
10413                // The cached span composite replaces the frame's leading
10414                // draws, so it goes down before every fused batch — same
10415                // z position the skipped shapes held.
10416                for draw in &span_blit {
10417                    self.effect_renderer.draw_prepared_composite(
10418                        &mut render_pass,
10419                        (width, height),
10420                        draw,
10421                        pass_depth,
10422                    );
10423                }
10424                if pass_depth {
10425                    // The occluder must be the pass's first draw: everything
10426                    // after it depth-tests against the region it wrote.
10427                    self.draw_display_clip_occluder(&mut render_pass, width, height);
10428                    self.display_clip.pass_depth.set(true);
10429                }
10430
10431                for batch in &fused_batches {
10432                    match batch {
10433                        FusedSegmentBatch::Shape { batch, blend_mode } => {
10434                            self.draw_prepared_shapes(
10435                                &mut render_pass,
10436                                *blend_mode,
10437                                *batch,
10438                                width,
10439                                height,
10440                                &chunk_rims,
10441                            );
10442                        }
10443                        FusedSegmentBatch::Image {
10444                            cmd_range,
10445                            blend_mode,
10446                        } => {
10447                            self.draw_native_prepared_image_cmd_range(
10448                                &mut render_pass,
10449                                &image_cmds,
10450                                cmd_range.clone(),
10451                                *blend_mode,
10452                            )?;
10453                        }
10454                        FusedSegmentBatch::Text {
10455                            image_cmd_range,
10456                            glyph_cmd_range,
10457                        } => {
10458                            if !image_cmd_range.is_empty() {
10459                                self.draw_native_prepared_image_cmd_range(
10460                                    &mut render_pass,
10461                                    &image_cmds,
10462                                    image_cmd_range.clone(),
10463                                    BlendMode::SrcOver,
10464                                )?;
10465                                self.frame_stats.bump_text();
10466                            }
10467                            if !glyph_cmd_range.is_empty() {
10468                                self.draw_native_prepared_glyph_cmd_range(
10469                                    &mut render_pass,
10470                                    &glyph_cmds,
10471                                    glyph_cmd_range.clone(),
10472                                )?;
10473                            }
10474                        }
10475                        FusedSegmentBatch::Composite { draw_range } => {
10476                            for draw in
10477                                prepared_composites.get(draw_range.clone()).ok_or_else(|| {
10478                                    "composite draw range is outside the prepared command buffer"
10479                                        .to_string()
10480                                })?
10481                            {
10482                                self.effect_renderer.draw_prepared_composite(
10483                                    &mut render_pass,
10484                                    (width, height),
10485                                    draw,
10486                                    pass_depth,
10487                                );
10488                            }
10489                        }
10490                        FusedSegmentBatch::ShaderComposite { draw_range } => {
10491                            for draw in prepared_shaders.get(draw_range.clone()).ok_or_else(|| {
10492                                "shader composite draw range is outside the prepared command buffer"
10493                                    .to_string()
10494                            })? {
10495                                self.effect_renderer.draw_prepared_shader_src_over(
10496                                    &device,
10497                                    &mut render_pass,
10498                                    (width, height),
10499                                    draw,
10500                                    pass_depth,
10501                                );
10502                            }
10503                        }
10504                        FusedSegmentBatch::Retained { item_range } => {
10505                            // Each Retained arm is one MAXIMAL consecutive
10506                            // retained stretch — the planner groups adjacent
10507                            // retained items into a single batch — so caching
10508                            // per arm never flattens across the dynamic
10509                            // batches interleaved at their z positions.
10510                            let retained_start = Instant::now();
10511                            // A render bundle cannot encode a segment-surface
10512                            // composite, so a stretch containing one this
10513                            // frame takes the per-item walk: order-identical,
10514                            // and the walk is a handful of binds exactly when
10515                            // the cache is saving the fragment work.
10516                            let stretch_has_composites = !prepared_segment_composites.is_empty()
10517                                && ordered_items[item_range.clone()].iter().any(|(_, item)| {
10518                                    matches!(
10519                                        item,
10520                                        SegmentDrawItem::Retained(index)
10521                                            if prepared_segment_composites
10522                                                .iter()
10523                                                .any(|(prepared_index, _)| prepared_index == index)
10524                                    )
10525                                });
10526                            if use_retained_bundles && !stretch_has_composites {
10527                                self.draw_retained_stretch_bundled(
10528                                    &mut render_pass,
10529                                    ordered_items,
10530                                    retained_draws,
10531                                    item_range.clone(),
10532                                    width,
10533                                    height,
10534                                );
10535                            } else {
10536                                for (_, item) in &ordered_items[item_range.clone()] {
10537                                    if let SegmentDrawItem::Retained(index) = item {
10538                                        if let Some((_, prepared)) = prepared_segment_composites
10539                                            .iter()
10540                                            .find(|(prepared_index, _)| prepared_index == index)
10541                                        {
10542                                            // The cached span's surface, at
10543                                            // the span's exact z position:
10544                                            // SrcOver over premultiplied
10545                                            // alpha is associative, so
10546                                            // flatten-then-composite blends
10547                                            // identically to the inline
10548                                            // member draws it replaces.
10549                                            self.effect_renderer
10550                                                .draw_prepared_projective_composite(
10551                                                    &mut render_pass,
10552                                                    (width, height),
10553                                                    prepared,
10554                                                    pass_depth,
10555                                                );
10556                                            self.frame_stats.add_draw_calls(1);
10557                                        } else if let Some(retained) = retained_draws.get(*index) {
10558                                            self.draw_retained_batch(
10559                                                &mut render_pass,
10560                                                retained,
10561                                                *index,
10562                                                width,
10563                                                height,
10564                                            );
10565                                        }
10566                                    }
10567                                }
10568                            }
10569                            retained_encode_ms += instant_ms(retained_start, Instant::now());
10570                        }
10571                    }
10572                }
10573            }
10574            // The depth attachment died with the fused pass just dropped;
10575            // anything encoded from here to the closure exit (the span
10576            // capture below) is a depth-less pass, so the pipeline getters
10577            // must stop handing out depth variants NOW — the closure-exit
10578            // reset is only the error-path net.
10579            self.display_clip.pass_depth.set(false);
10580            // Span capture (miss frames whose leading run proved stable):
10581            // re-render JUST the span shapes into the pooled offscreen,
10582            // through the IDENTICAL pipelines at identical device
10583            // coordinates — the shapes are already in this partition's
10584            // upload, so the capture is one extra pass drawing instances
10585            // 0..len of the same buffers, cleared with the frame's own
10586            // clear color. Rare by construction: palette drains, shakes,
10587            // and resizes are the only events that invalidate the key.
10588            let mut capture_passes = 0_u32;
10589            if let StaticSpanDecision::Capture { len, clear } = span_decision {
10590                let texture = match span_cache.texture.take() {
10591                    Some(existing) if existing.width == width && existing.height == height => {
10592                        existing
10593                    }
10594                    other => {
10595                        if let Some(stale) = other {
10596                            self.defer_offscreen_release(stale);
10597                        }
10598                        self.acquire_offscreen(width, height)
10599                    }
10600                };
10601                {
10602                    let mut capture_pass =
10603                        frame_encoder
10604                            .encoder()
10605                            .begin_render_pass(&wgpu::RenderPassDescriptor {
10606                                label: Some("Static Span Capture Pass"),
10607                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10608                                    view: &texture.view,
10609                                    resolve_target: None,
10610                                    depth_slice: None,
10611                                    ops: wgpu::Operations {
10612                                        load: wgpu::LoadOp::Clear(clear),
10613                                        store: wgpu::StoreOp::Store,
10614                                    },
10615                                })],
10616                                depth_stencil_attachment: None,
10617                                timestamp_writes: None,
10618                                occlusion_query_set: None,
10619                                multiview_mask: None,
10620                            });
10621                    // `has_gradient` is the LIVE first batch's whole-batch
10622                    // flag: it selects the same fs_solid/gradient pipeline
10623                    // variant the live path draws the span through.
10624                    let has_gradient = first_batch_info
10625                        .map(|(_, _, has_gradient)| has_gradient)
10626                        .unwrap_or(false);
10627                    self.draw_prepared_shapes(
10628                        &mut capture_pass,
10629                        BlendMode::SrcOver,
10630                        PreparedShapeBatch {
10631                            vertex_start: 0,
10632                            vertex_count: len as u32 * 6,
10633                            has_gradient,
10634                        },
10635                        width,
10636                        height,
10637                        &[],
10638                    );
10639                    if fill_area_diag_enabled() {
10640                        // The capture genuinely re-submits the span's fill
10641                        // this frame — submitted, lit, opacity and (the
10642                        // capture target is frame-sized) corner alike.
10643                        self.fill_area_diag
10644                            .add_shape_quads(&self.scratch_shape_data[..len], viewport);
10645                    }
10646                    span_cache.store_key(
10647                        &self.scratch_shape_data[..len],
10648                        &self.scratch_gradients,
10649                        width,
10650                        height,
10651                        clear,
10652                        has_gradient,
10653                    );
10654                }
10655                span_cache.texture = Some(texture);
10656                capture_passes = 1;
10657            }
10658            let after_pass = Instant::now();
10659            if let Some(total_ms) = should_log_wgpu_render_stage(partition_start, after_pass) {
10660                log::warn!(
10661                    "[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={}",
10662                    instant_ms(partition_start, after_shape_refs),
10663                    instant_ms(after_shape_refs, after_shape_prepare),
10664                    instant_ms(after_shape_prepare, after_batch_prepare),
10665                    instant_ms(after_batch_prepare, after_composite_prepare),
10666                    instant_ms(after_composite_prepare, after_upload),
10667                    instant_ms(after_upload, after_pass),
10668                    fused_batches.len(),
10669                    budget.shape_count,
10670                    image_cmds.len(),
10671                    glyph_cmds.len(),
10672                    staged_uploads.bytes.len(),
10673                );
10674            }
10675
10676            Ok(SegmentRenderOutcome {
10677                rendered_any: true,
10678                pass_count: 1 + capture_passes + segment_capture_passes,
10679            })
10680        })();
10681
10682        // The depth flag lives exactly as long as the culled pass's encode;
10683        // resetting here (not inside the closure) covers the error paths
10684        // too, so no later pass can inherit a depth-variant pipeline.
10685        self.display_clip.pass_depth.set(false);
10686        self.scratch_image_vertices = image_vertices;
10687        self.scratch_image_indices = image_indices;
10688        self.scratch_image_cmds = image_cmds;
10689        self.scratch_glyph_cmds = glyph_cmds;
10690        self.restore_staged_uploads(staged_uploads);
10691        self.static_span = span_cache;
10692        if result.is_err() {
10693            // An aborted partition may have installed entries whose capture
10694            // passes never encoded; a later frame must not sample them.
10695            segment_surfaces.clear();
10696        }
10697        self.segment_surfaces = segment_surfaces;
10698        result
10699    }
10700
10701    #[allow(clippy::too_many_arguments)]
10702    fn render_segment_draw_chunk<C: FrameCommandRecorder>(
10703        &mut self,
10704        frame_encoder: &mut C,
10705        target_view: &wgpu::TextureView,
10706        ordered_items: &[(usize, SegmentDrawItem)],
10707        composites: &[(usize, CompositeBatchItem<'_>)],
10708        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
10709        shapes: &[DrawShape],
10710        brushes: &[Brush],
10711        images: &[ImageDraw],
10712        texts: &[TextDraw],
10713        retained_draws: &[RetainedDraw],
10714        chunk: SegmentDrawChunkPlan,
10715        width: u32,
10716        height: u32,
10717        root_scale: f32,
10718        load_op: wgpu::LoadOp<wgpu::Color>,
10719    ) -> Result<SegmentRenderOutcome, String> {
10720        #[cfg(target_arch = "wasm32")]
10721        let _ = retained_draws;
10722        #[cfg(not(target_arch = "wasm32"))]
10723        if let Some(outcome) = self.render_segment_draw_chunk_fused_native(
10724            frame_encoder,
10725            target_view,
10726            ordered_items,
10727            composites,
10728            shader_composites,
10729            shapes,
10730            brushes,
10731            images,
10732            texts,
10733            retained_draws,
10734            &chunk,
10735            width,
10736            height,
10737            root_scale,
10738            load_op,
10739        )? {
10740            return Ok(outcome);
10741        }
10742
10743        let mut staged_uploads = self.take_staged_uploads();
10744        let result = (|| {
10745            let mut rendered_any = false;
10746            let mut pass_count = 0_u32;
10747            let mut next_load_op = load_op;
10748            for batch in chunk.iter() {
10749                staged_uploads.clear();
10750                match batch {
10751                    SegmentBatchPlan::Shape {
10752                        start,
10753                        end,
10754                        blend_mode,
10755                    } => {
10756                        let slice = &ordered_items[start..end];
10757                        if slice.len() > self.shape_batch_limits.max_shapes_per_batch {
10758                            return Err(format!(
10759                                "shape batch contains {} shapes, exceeding the renderer limit of {}",
10760                                slice.len(),
10761                                self.shape_batch_limits.max_shapes_per_batch
10762                            ));
10763                        }
10764                        let viewport = ViewportUniformParams {
10765                            width,
10766                            height,
10767                            offset: [0.0, 0.0],
10768                        };
10769                        for (_, item) in slice {
10770                            if !matches!(item, SegmentDrawItem::Shape(_)) {
10771                                return Err(format!(
10772                                    "shape batch contains non-shape draw item: {item:?}"
10773                                ));
10774                            }
10775                        }
10776                        let Some(prepared) = self.prepare_shapes_batch(
10777                            slice.iter().filter_map(|(_, item)| match item {
10778                                SegmentDrawItem::Shape(shape_index) => Some(&shapes[*shape_index]),
10779                                _ => None,
10780                            }),
10781                            brushes,
10782                            root_scale,
10783                            viewport,
10784                            &mut staged_uploads,
10785                        ) else {
10786                            continue;
10787                        };
10788                        let upload_offset = frame_encoder
10789                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10790                        self.flush_staged_uploads_at(
10791                            frame_encoder.encoder(),
10792                            &staged_uploads,
10793                            upload_offset,
10794                        );
10795                        {
10796                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
10797                                &wgpu::RenderPassDescriptor {
10798                                    label: Some("Segment Shape Pass"),
10799                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10800                                        view: target_view,
10801                                        resolve_target: None,
10802                                        depth_slice: None,
10803                                        ops: wgpu::Operations {
10804                                            load: next_load_op,
10805                                            store: wgpu::StoreOp::Store,
10806                                        },
10807                                    })],
10808                                    depth_stencil_attachment: None,
10809                                    timestamp_writes: None,
10810                                    occlusion_query_set: None,
10811                                    multiview_mask: None,
10812                                },
10813                            );
10814                            self.draw_prepared_shapes(
10815                                &mut render_pass,
10816                                blend_mode,
10817                                prepared,
10818                                width,
10819                                height,
10820                                &[],
10821                            );
10822                        }
10823                        pass_count = pass_count.saturating_add(1);
10824                        rendered_any = true;
10825                        next_load_op = wgpu::LoadOp::Load;
10826                    }
10827                    SegmentBatchPlan::Image {
10828                        start,
10829                        end,
10830                        blend_mode,
10831                    } => {
10832                        let viewport = ViewportUniformParams {
10833                            width,
10834                            height,
10835                            offset: [0.0, 0.0],
10836                        };
10837                        for (_, item) in &ordered_items[start..end] {
10838                            if !matches!(item, SegmentDrawItem::Image(_)) {
10839                                return Err(format!(
10840                                    "image batch contains non-image draw item: {item:?}"
10841                                ));
10842                            }
10843                        }
10844                        let prepared_images = self.prepare_image_draw_cmds(
10845                            ordered_items[start..end]
10846                                .iter()
10847                                .filter_map(|(_, item)| match item {
10848                                    SegmentDrawItem::Image(image_index) => {
10849                                        Some(&images[*image_index])
10850                                    }
10851                                    _ => None,
10852                                }),
10853                            viewport,
10854                            root_scale,
10855                            &mut staged_uploads,
10856                        )?;
10857                        if prepared_images.is_empty() {
10858                            self.scratch_image_cmds = prepared_images.into_cmds();
10859                            continue;
10860                        }
10861                        let upload_offset = frame_encoder
10862                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10863                        self.flush_staged_uploads_at(
10864                            frame_encoder.encoder(),
10865                            &staged_uploads,
10866                            upload_offset,
10867                        );
10868                        let draw_result = {
10869                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
10870                                &wgpu::RenderPassDescriptor {
10871                                    label: Some("Segment Image Pass"),
10872                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10873                                        view: target_view,
10874                                        resolve_target: None,
10875                                        depth_slice: None,
10876                                        ops: wgpu::Operations {
10877                                            load: next_load_op,
10878                                            store: wgpu::StoreOp::Store,
10879                                        },
10880                                    })],
10881                                    depth_stencil_attachment: None,
10882                                    timestamp_writes: None,
10883                                    occlusion_query_set: None,
10884                                    multiview_mask: None,
10885                                },
10886                            );
10887                            self.draw_prepared_images(
10888                                &mut render_pass,
10889                                &prepared_images,
10890                                blend_mode,
10891                            )
10892                        };
10893                        pass_count = pass_count.saturating_add(1);
10894                        self.scratch_image_cmds = prepared_images.into_cmds();
10895                        draw_result?;
10896                        rendered_any = true;
10897                        next_load_op = wgpu::LoadOp::Load;
10898                    }
10899                    SegmentBatchPlan::Text { start, end } => {
10900                        let viewport = ViewportUniformParams {
10901                            width,
10902                            height,
10903                            offset: [0.0, 0.0],
10904                        };
10905                        let text_draws =
10906                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10907                        if let Some(prepared_glyphs) = self.prepare_text_glyph_draw_cmds(
10908                            text_draws,
10909                            viewport,
10910                            root_scale,
10911                            &mut staged_uploads,
10912                        )? {
10913                            if prepared_glyphs.is_empty() {
10914                                self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
10915                                continue;
10916                            }
10917                            let upload_offset = frame_encoder
10918                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10919                            self.flush_staged_uploads_at(
10920                                frame_encoder.encoder(),
10921                                &staged_uploads,
10922                                upload_offset,
10923                            );
10924                            {
10925                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
10926                                    &wgpu::RenderPassDescriptor {
10927                                        label: Some("Segment Text Glyph Atlas Pass"),
10928                                        color_attachments: &[Some(
10929                                            wgpu::RenderPassColorAttachment {
10930                                                view: target_view,
10931                                                resolve_target: None,
10932                                                depth_slice: None,
10933                                                ops: wgpu::Operations {
10934                                                    load: next_load_op,
10935                                                    store: wgpu::StoreOp::Store,
10936                                                },
10937                                            },
10938                                        )],
10939                                        depth_stencil_attachment: None,
10940                                        timestamp_writes: None,
10941                                        occlusion_query_set: None,
10942                                        multiview_mask: None,
10943                                    },
10944                                );
10945                                self.draw_prepared_glyphs(&mut render_pass, &prepared_glyphs)?;
10946                            }
10947                            pass_count = pass_count.saturating_add(1);
10948                            self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
10949                            rendered_any = true;
10950                            next_load_op = wgpu::LoadOp::Load;
10951                        } else {
10952                            let text_draws =
10953                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10954                            let prepared_images = self.prepare_text_image_draw_cmds(
10955                                text_draws,
10956                                viewport,
10957                                root_scale,
10958                                &mut staged_uploads,
10959                            )?;
10960                            if prepared_images.is_empty() {
10961                                self.scratch_image_cmds = prepared_images.into_cmds();
10962                                continue;
10963                            }
10964                            let upload_offset = frame_encoder
10965                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10966                            self.flush_staged_uploads_at(
10967                                frame_encoder.encoder(),
10968                                &staged_uploads,
10969                                upload_offset,
10970                            );
10971                            {
10972                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
10973                                    &wgpu::RenderPassDescriptor {
10974                                        label: Some("Segment Text Pass"),
10975                                        color_attachments: &[Some(
10976                                            wgpu::RenderPassColorAttachment {
10977                                                view: target_view,
10978                                                resolve_target: None,
10979                                                depth_slice: None,
10980                                                ops: wgpu::Operations {
10981                                                    load: next_load_op,
10982                                                    store: wgpu::StoreOp::Store,
10983                                                },
10984                                            },
10985                                        )],
10986                                        depth_stencil_attachment: None,
10987                                        timestamp_writes: None,
10988                                        occlusion_query_set: None,
10989                                        multiview_mask: None,
10990                                    },
10991                                );
10992                                self.draw_prepared_images(
10993                                    &mut render_pass,
10994                                    &prepared_images,
10995                                    BlendMode::SrcOver,
10996                                )?;
10997                            }
10998                            self.frame_stats.bump_text();
10999                            pass_count = pass_count.saturating_add(1);
11000                            self.scratch_image_cmds = prepared_images.into_cmds();
11001                            rendered_any = true;
11002                            next_load_op = wgpu::LoadOp::Load;
11003                        }
11004                    }
11005                    SegmentBatchPlan::Composite { start, end } => {
11006                        let batch_items: Vec<_> = ordered_items[start..end]
11007                            .iter()
11008                            .map(|(_, item)| match item {
11009                                SegmentDrawItem::Composite(composite_index) => composites
11010                                    .get(*composite_index)
11011                                    .map(|(_, composite)| *composite)
11012                                    .ok_or_else(|| {
11013                                        "composite item index is outside the composite buffer"
11014                                            .to_string()
11015                                    }),
11016                                other => Err(format!(
11017                                    "composite batch contains non-composite draw item: {other:?}"
11018                                )),
11019                            })
11020                            .collect::<Result<_, _>>()?;
11021                        let device = self.device.clone();
11022                        self.effect_renderer.encode_composite_batch_to_view_pass(
11023                            frame_encoder,
11024                            &device,
11025                            target_view,
11026                            (width, height),
11027                            next_load_op,
11028                            &batch_items,
11029                        );
11030                        self.effect_renderer.record_composite_pass();
11031                        pass_count = pass_count.saturating_add(1);
11032                        rendered_any = true;
11033                        next_load_op = wgpu::LoadOp::Load;
11034                    }
11035                    SegmentBatchPlan::ShaderComposite { start, end } => {
11036                        let batch_items: Vec<_> = ordered_items[start..end]
11037                            .iter()
11038                            .map(|(_, item)| match item {
11039                                SegmentDrawItem::ShaderComposite(composite_index) => {
11040                                    shader_composites
11041                                        .get(*composite_index)
11042                                        .map(|(_, composite)| *composite)
11043                                        .ok_or_else(|| {
11044                                            "shader composite item index is outside the shader composite buffer"
11045                                                .to_string()
11046                                        })
11047                                }
11048                                other => Err(format!(
11049                                    "shader composite batch contains non-shader-composite draw item: {other:?}"
11050                                )),
11051                            })
11052                            .collect::<Result<Vec<_>, _>>()?;
11053                        let device = self.device.clone();
11054                        let encoded = self.effect_renderer.encode_shader_batch_src_over_to_view(
11055                            frame_encoder,
11056                            &device,
11057                            target_view,
11058                            (width, height),
11059                            next_load_op,
11060                            &batch_items,
11061                        );
11062                        if !encoded {
11063                            return Err("shader composite batch failed to encode".to_string());
11064                        }
11065                        self.effect_renderer.record_composite_pass();
11066                        self.effect_renderer.debug_effects.set(
11067                            self.effect_renderer.debug_effects.get() + batch_items.len() as u32,
11068                        );
11069                        pass_count = pass_count.saturating_add(1);
11070                        rendered_any = true;
11071                        next_load_op = wgpu::LoadOp::Load;
11072                    }
11073                    SegmentBatchPlan::Retained { start, end } => {
11074                        // Reached only when native fusion declined the chunk;
11075                        // retained batches exist on storage-mode native
11076                        // devices, where fusion always accepts, but the arm
11077                        // stays a real draw so that assumption is not load-
11078                        // bearing for correctness. Deliberately direct encode
11079                        // — retained bundle caching AND segment-surface
11080                        // compositing live in the fused path only; this
11081                        // fallback stays the simple reference.
11082                        #[cfg(target_arch = "wasm32")]
11083                        {
11084                            let _ = (start, end);
11085                            return Err("retained shape batches are native-only".to_string());
11086                        }
11087                        #[cfg(not(target_arch = "wasm32"))]
11088                        {
11089                            self.stage_replay_patches(&mut staged_uploads);
11090                            for (_, item) in &ordered_items[start..end] {
11091                                let SegmentDrawItem::Retained(index) = item else {
11092                                    return Err(format!(
11093                                        "retained batch contains non-retained draw item: {item:?}"
11094                                    ));
11095                                };
11096                                let retained = retained_draws.get(*index).ok_or_else(|| {
11097                                    format!("retained draw index {index} out of bounds")
11098                                })?;
11099                                if (*index as u32) < MAX_REPLAY_SLOTS
11100                                    && self.replay_slots.slots.contains_key(&retained.slot)
11101                                {
11102                                    let transform = retained.transform.with_retained_paint();
11103                                    staged_uploads.stage_at(
11104                                        UploadTarget::ReplayTransform,
11105                                        *index as u64 * REPLAY_TRANSFORM_STRIDE,
11106                                        bytemuck::bytes_of(&transform),
11107                                    );
11108                                }
11109                            }
11110                            let upload_offset = frame_encoder
11111                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11112                            self.flush_staged_uploads_at(
11113                                frame_encoder.encoder(),
11114                                &staged_uploads,
11115                                upload_offset,
11116                            );
11117                            {
11118                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
11119                                    &wgpu::RenderPassDescriptor {
11120                                        label: Some("Segment Retained Pass"),
11121                                        color_attachments: &[Some(
11122                                            wgpu::RenderPassColorAttachment {
11123                                                view: target_view,
11124                                                resolve_target: None,
11125                                                depth_slice: None,
11126                                                ops: wgpu::Operations {
11127                                                    load: next_load_op,
11128                                                    store: wgpu::StoreOp::Store,
11129                                                },
11130                                            },
11131                                        )],
11132                                        depth_stencil_attachment: None,
11133                                        timestamp_writes: None,
11134                                        occlusion_query_set: None,
11135                                        multiview_mask: None,
11136                                    },
11137                                );
11138                                for (_, item) in &ordered_items[start..end] {
11139                                    if let SegmentDrawItem::Retained(index) = item {
11140                                        if let Some(retained) = retained_draws.get(*index) {
11141                                            self.draw_retained_batch(
11142                                                &mut render_pass,
11143                                                retained,
11144                                                *index,
11145                                                width,
11146                                                height,
11147                                            );
11148                                        }
11149                                    }
11150                                }
11151                            }
11152                            pass_count = pass_count.saturating_add(1);
11153                            rendered_any = true;
11154                            next_load_op = wgpu::LoadOp::Load;
11155                        }
11156                    }
11157                }
11158            }
11159            Ok(SegmentRenderOutcome {
11160                rendered_any,
11161                pass_count,
11162            })
11163        })();
11164        self.restore_staged_uploads(staged_uploads);
11165        result
11166    }
11167
11168    fn viewport_uniforms(params: ViewportUniformParams) -> Uniforms {
11169        Uniforms {
11170            viewport: [params.width as f32, params.height as f32],
11171            viewport_offset: params.offset,
11172        }
11173    }
11174
11175    #[cfg(not(target_arch = "wasm32"))]
11176    fn stage_viewport_uniforms(
11177        &self,
11178        staged_uploads: &mut StagedBufferUploads,
11179        params: ViewportUniformParams,
11180    ) {
11181        let uniforms = Self::viewport_uniforms(params);
11182        staged_uploads.stage(UploadTarget::Uniform, bytemuck::bytes_of(&uniforms));
11183    }
11184
11185    #[cfg(not(target_arch = "wasm32"))]
11186    fn stage_retained_glyph_viewport_uniforms(
11187        &mut self,
11188        staged_uploads: &mut StagedBufferUploads,
11189        params: ViewportUniformParams,
11190    ) -> usize {
11191        let slot = self.claim_retained_glyph_uniform_slot();
11192        let uniforms = Self::viewport_uniforms(params);
11193        staged_uploads.stage_at(
11194            UploadTarget::RetainedGlyphUniform,
11195            self.retained_glyph_uniform_offset(slot),
11196            bytemuck::bytes_of(&uniforms),
11197        );
11198        slot
11199    }
11200
11201    #[cfg(not(target_arch = "wasm32"))]
11202    fn claim_retained_glyph_uniform_slot(&mut self) -> usize {
11203        let slot = self.retained_glyph_uniform_cursor;
11204        self.retained_glyph_uniform_cursor = self.retained_glyph_uniform_cursor.saturating_add(1);
11205        self.ensure_retained_glyph_uniform_capacity(slot.saturating_add(1));
11206        slot
11207    }
11208
11209    #[cfg(not(target_arch = "wasm32"))]
11210    fn retained_glyph_uniform_offset(&self, slot: usize) -> u64 {
11211        self.retained_glyph_uniform_stride * slot as u64
11212    }
11213
11214    #[cfg(not(target_arch = "wasm32"))]
11215    fn retained_glyph_uniform_dynamic_offset(&self, slot: usize) -> Result<u32, String> {
11216        let offset = self.retained_glyph_uniform_offset(slot);
11217        u32::try_from(offset).map_err(|_| {
11218            "retained glyph uniform offset exceeded WGPU dynamic offset range".to_string()
11219        })
11220    }
11221
11222    #[cfg(not(target_arch = "wasm32"))]
11223    fn ensure_retained_glyph_uniform_capacity(&mut self, required_slots: usize) {
11224        if required_slots <= self.retained_glyph_uniform_capacity {
11225            return;
11226        }
11227        let new_capacity = required_slots
11228            .next_power_of_two()
11229            .max(INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS);
11230        self.retained_glyph_uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11231            label: Some("Retained Glyph Uniform Buffer"),
11232            size: self.retained_glyph_uniform_stride * new_capacity as u64,
11233            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
11234            mapped_at_creation: false,
11235        });
11236        self.retained_glyph_uniform_bind_group =
11237            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
11238                label: Some("Retained Glyph Uniform Bind Group"),
11239                layout: &self.retained_glyph_uniform_bind_group_layout,
11240                entries: &[wgpu::BindGroupEntry {
11241                    binding: 0,
11242                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11243                        buffer: &self.retained_glyph_uniform_buffer,
11244                        offset: 0,
11245                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
11246                    }),
11247                }],
11248            });
11249        self.retained_glyph_uniform_capacity = new_capacity;
11250    }
11251
11252    #[cfg(target_arch = "wasm32")]
11253    fn prepare_wasm_viewport_uniforms(&mut self, params: ViewportUniformParams) -> usize {
11254        let slot = self.claim_wasm_uniform_batch();
11255        let uniforms = Self::viewport_uniforms(params);
11256        let bytes = bytemuck::bytes_of(&uniforms);
11257        let upload_stats = self.frame_graph_executor.upload_buffer(
11258            &self.queue,
11259            &self.wasm_uniform_batches[slot].buffer,
11260            0,
11261            bytes,
11262        );
11263        self.frame_stats.record_command_stats(upload_stats);
11264        slot
11265    }
11266
11267    #[cfg(target_arch = "wasm32")]
11268    fn claim_wasm_uniform_batch(&mut self) -> usize {
11269        let slot = self.wasm_uniform_batch_cursor;
11270        self.wasm_uniform_batch_cursor += 1;
11271        while self.wasm_uniform_batches.len() <= slot {
11272            self.wasm_uniform_batches.push(UniformBatchBuffer::new(
11273                &self.device,
11274                &self.uniform_bind_group_layout,
11275            ));
11276        }
11277        slot
11278    }
11279
11280    #[cfg(target_arch = "wasm32")]
11281    fn claim_wasm_shape_batch(&mut self) -> usize {
11282        let slot = self.wasm_shape_batch_cursor;
11283        self.wasm_shape_batch_cursor += 1;
11284        while self.wasm_shape_batches.len() <= slot {
11285            self.wasm_shape_batches.push(ShapeBatchBuffers::new(
11286                &self.device,
11287                &self.shape_bind_group_layout,
11288                &self.identity_similarity_buffer,
11289                self.dummy_paint_buffer.as_ref(),
11290                self.shape_batch_limits,
11291            ));
11292        }
11293        slot
11294    }
11295
11296    #[cfg(target_arch = "wasm32")]
11297    fn claim_wasm_image_batch(&mut self) -> usize {
11298        let slot = self.wasm_image_batch_cursor;
11299        self.wasm_image_batch_cursor += 1;
11300        while self.wasm_image_batches.len() <= slot {
11301            self.wasm_image_batches
11302                .push(ImageBatchBuffers::new(&self.device));
11303        }
11304        slot
11305    }
11306
11307    #[cfg(target_arch = "wasm32")]
11308    fn write_wasm_buffer(&self, buffer: &wgpu::Buffer, bytes: &[u8]) {
11309        let upload_stats = self
11310            .frame_graph_executor
11311            .upload_buffer(&self.queue, buffer, 0, bytes);
11312        self.frame_stats.record_command_stats(upload_stats);
11313    }
11314
11315    fn take_staged_uploads(&mut self) -> StagedBufferUploads {
11316        let mut staged_uploads = std::mem::take(&mut self.staged_uploads);
11317        debug_assert!(
11318            staged_uploads.is_empty(),
11319            "renderer-owned staged uploads should be restored as empty scratch storage"
11320        );
11321        staged_uploads.clear();
11322        staged_uploads
11323    }
11324
11325    fn restore_staged_uploads(&mut self, mut staged_uploads: StagedBufferUploads) {
11326        staged_uploads.clear();
11327        self.staged_uploads = staged_uploads;
11328    }
11329
11330    #[cfg(not(target_arch = "wasm32"))]
11331    fn ensure_upload_buffer_capacity(&mut self, required_bytes: u64) {
11332        if required_bytes <= self.upload_buffer.size() {
11333            return;
11334        }
11335
11336        let new_size = required_bytes
11337            .next_power_of_two()
11338            .max(INITIAL_UPLOAD_BUFFER_BYTES);
11339        self.upload_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11340            label: Some("Frame Upload Buffer"),
11341            size: new_size,
11342            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
11343            mapped_at_creation: false,
11344        });
11345    }
11346
11347    fn flush_staged_uploads_at(
11348        &mut self,
11349        encoder: &mut wgpu::CommandEncoder,
11350        staged_uploads: &StagedBufferUploads,
11351        upload_buffer_offset: u64,
11352    ) {
11353        if staged_uploads.is_empty() {
11354            return;
11355        }
11356        debug_assert_eq!(
11357            upload_buffer_offset % wgpu::COPY_BUFFER_ALIGNMENT,
11358            0,
11359            "upload-buffer base offset must satisfy copy alignment"
11360        );
11361
11362        #[cfg(target_arch = "wasm32")]
11363        {
11364            let _ = upload_buffer_offset;
11365            let _ = encoder;
11366            debug_assert!(
11367                staged_uploads.is_empty(),
11368                "wasm draw uploads use retained per-batch resource slots"
11369            );
11370            return;
11371        }
11372
11373        #[cfg(not(target_arch = "wasm32"))]
11374        {
11375            self.ensure_upload_buffer_capacity(
11376                upload_buffer_offset + staged_uploads.bytes.len() as u64,
11377            );
11378            let upload_stats = self.frame_graph_executor.upload_buffer(
11379                &self.queue,
11380                &self.upload_buffer,
11381                upload_buffer_offset,
11382                &staged_uploads.bytes,
11383            );
11384            self.frame_stats.record_command_stats(upload_stats);
11385
11386            for copy in &staged_uploads.copies {
11387                let target_buffer = match copy.target {
11388                    UploadTarget::Uniform => &self.uniform_buffer,
11389                    UploadTarget::ShapeData => &self.shape_buffers.shape_buffer,
11390                    UploadTarget::ShapeGradient => &self.shape_buffers.gradient_buffer,
11391                    UploadTarget::ImageVertex => &self.image_vertex_buffer,
11392                    UploadTarget::ImageIndex => &self.image_index_buffer,
11393                    UploadTarget::RetainedGlyphUniform => &self.retained_glyph_uniform_buffer,
11394                    UploadTarget::ReplayTransform => &self.replay_slots.transform_buffer,
11395                    UploadTarget::ReplayPaintData(slot) => {
11396                        // A slot released between staging and flush has
11397                        // nothing left to patch.
11398                        let Some(entry) = self.replay_slots.slots.get(&slot) else {
11399                            continue;
11400                        };
11401                        &entry.paint_buffer
11402                    }
11403                };
11404                encoder.copy_buffer_to_buffer(
11405                    &self.upload_buffer,
11406                    upload_buffer_offset + copy.source_offset,
11407                    target_buffer,
11408                    copy.target_offset,
11409                    copy.size,
11410                );
11411            }
11412        }
11413    }
11414
11415    #[allow(clippy::too_many_arguments)]
11416    fn encode_shadow_draw<C: FrameCommandRecorder>(
11417        &mut self,
11418        frame_encoder: &mut C,
11419        target_view: &wgpu::TextureView,
11420        shadow: &ShadowDraw,
11421        width: u32,
11422        height: u32,
11423        root_scale: f32,
11424    ) {
11425        if shadow.shapes.is_empty() && shadow.texts.is_empty() {
11426            return;
11427        }
11428
11429        let shape_bounds_opt = shadow
11430            .shapes
11431            .iter()
11432            .map(|(shape, _)| shape.rect)
11433            .reduce(|a, b| Rect {
11434                x: a.x.min(b.x),
11435                y: a.y.min(b.y),
11436                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
11437                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
11438            });
11439
11440        let text_bounds_opt = shadow
11441            .texts
11442            .iter()
11443            .map(|text| text.rect)
11444            .reduce(|a, b| Rect {
11445                x: a.x.min(b.x),
11446                y: a.y.min(b.y),
11447                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
11448                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
11449            });
11450
11451        let combined_bounds = match (shape_bounds_opt, text_bounds_opt) {
11452            (Some(s), Some(t)) => Some(Rect {
11453                x: s.x.min(t.x),
11454                y: s.y.min(t.y),
11455                width: (s.x + s.width).max(t.x + t.width) - s.x.min(t.x),
11456                height: (s.y + s.height).max(t.y + t.height) - s.y.min(t.y),
11457            }),
11458            (Some(s), None) => Some(s),
11459            (None, Some(t)) => Some(t),
11460            (None, None) => None,
11461        };
11462
11463        let Some(shape_bounds) = combined_bounds else {
11464            return;
11465        };
11466
11467        let blur_margin = blur_extent_margin(shadow.blur_radius);
11468        let source_blur_bounds = Rect {
11469            x: shape_bounds.x - blur_margin,
11470            y: shape_bounds.y - blur_margin,
11471            width: shape_bounds.width + blur_margin * 2.0,
11472            height: shape_bounds.height + blur_margin * 2.0,
11473        };
11474        let mut visible_blur_bounds = source_blur_bounds;
11475        if let Some(clip) = shadow.clip {
11476            let clip_expanded = Rect {
11477                x: clip.x - blur_margin,
11478                y: clip.y - blur_margin,
11479                width: clip.width + blur_margin * 2.0,
11480                height: clip.height + blur_margin * 2.0,
11481            };
11482            let Some(intersection) = visible_blur_bounds.intersect(clip_expanded) else {
11483                return;
11484            };
11485            visible_blur_bounds = intersection;
11486        }
11487        let processing_scissor =
11488            scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
11489        if processing_scissor.is_none() {
11490            return;
11491        }
11492
11493        // Zero blur: render shapes directly to target (fast path).
11494        if shadow.blur_radius <= 0.0 {
11495            for (shape, blend_mode) in &shadow.shapes {
11496                self.encode_shapes_pass(
11497                    frame_encoder,
11498                    target_view,
11499                    std::iter::once(shape),
11500                    &shadow.brushes,
11501                    *blend_mode,
11502                    width,
11503                    height,
11504                    root_scale,
11505                    wgpu::LoadOp::Load,
11506                    [0.0, 0.0],
11507                );
11508                frame_encoder.record_pass();
11509            }
11510            if !shadow.texts.is_empty() {
11511                let mut staged_uploads = self.take_staged_uploads();
11512                let viewport = ViewportUniformParams {
11513                    width,
11514                    height,
11515                    offset: [0.0, 0.0],
11516                };
11517                match self.prepare_text_image_draw_cmds(
11518                    shadow.texts.iter(),
11519                    viewport,
11520                    root_scale,
11521                    &mut staged_uploads,
11522                ) {
11523                    Ok(prepared_images) if !prepared_images.is_empty() => {
11524                        let upload_offset = frame_encoder
11525                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11526                        self.flush_staged_uploads_at(
11527                            frame_encoder.encoder(),
11528                            &staged_uploads,
11529                            upload_offset,
11530                        );
11531                        let draw_result = {
11532                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
11533                                &wgpu::RenderPassDescriptor {
11534                                    label: Some("Zero Blur Shadow Text Image Pass"),
11535                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11536                                        view: target_view,
11537                                        resolve_target: None,
11538                                        depth_slice: None,
11539                                        ops: wgpu::Operations {
11540                                            load: wgpu::LoadOp::Load,
11541                                            store: wgpu::StoreOp::Store,
11542                                        },
11543                                    })],
11544                                    depth_stencil_attachment: None,
11545                                    timestamp_writes: None,
11546                                    occlusion_query_set: None,
11547                                    multiview_mask: None,
11548                                },
11549                            );
11550                            self.draw_prepared_images(
11551                                &mut render_pass,
11552                                &prepared_images,
11553                                BlendMode::SrcOver,
11554                            )
11555                        };
11556                        self.scratch_image_cmds = prepared_images.into_cmds();
11557                        if let Err(e) = draw_result {
11558                            eprintln!("Failed to draw text for zero-blur shadow: {}", e);
11559                        } else {
11560                            self.frame_stats.bump_text();
11561                            frame_encoder.record_pass();
11562                        }
11563                    }
11564                    Ok(prepared_images) => {
11565                        self.scratch_image_cmds = prepared_images.into_cmds();
11566                    }
11567                    Err(e) => {
11568                        eprintln!("Failed to prepare text image for zero-blur shadow: {}", e);
11569                    }
11570                }
11571                self.restore_staged_uploads(staged_uploads);
11572            }
11573            return;
11574        }
11575
11576        // Compute pixel-space bounds for the offscreen textures, clamped to viewport.
11577        let Some(device_bounds) =
11578            device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)
11579        else {
11580            return;
11581        };
11582        let bounds_x = device_bounds.x;
11583        let bounds_y = device_bounds.y;
11584        let bounds_w = device_bounds.width;
11585        let bounds_h = device_bounds.height;
11586        let pixel_radius = shadow.blur_radius * root_scale;
11587
11588        if shadow.texts.is_empty() && !shadow.shapes.is_empty() {
11589            if let Some(plan) = shape_shadow_surface_plan(
11590                &shadow.shapes,
11591                shadow.clip,
11592                shadow.blur_radius,
11593                width,
11594                height,
11595                root_scale,
11596                self.max_texture_dim(),
11597            ) {
11598                if self.encode_shape_only_blurred_shadow_draw(
11599                    frame_encoder,
11600                    target_view,
11601                    shadow,
11602                    plan.source_device_bounds,
11603                    plan.pixel_radius,
11604                    plan.processing_scissor,
11605                    width,
11606                    height,
11607                    root_scale,
11608                ) {
11609                    return;
11610                }
11611            }
11612        }
11613
11614        if !shadow.texts.is_empty() {
11615            self.frame_stats.record_shadow_text_blur_fallback();
11616        }
11617
11618        let device = self.device.clone();
11619        let source_descriptor =
11620            self.transient_offscreen_descriptor("Shadow Source", bounds_w, bounds_h);
11621        let source = frame_encoder.acquire_transient_offscreen(&device, source_descriptor);
11622        let viewport_offset = [bounds_x, bounds_y];
11623        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
11624        let source_outcome = self.encode_shadow_shape_source_passes(
11625            frame_encoder,
11626            &source.view,
11627            &shadow.shapes,
11628            &shadow.brushes,
11629            bounds_w,
11630            bounds_h,
11631            viewport_offset,
11632            root_scale,
11633            &mut next_load_op,
11634        );
11635        frame_encoder.record_passes(source_outcome.pass_count);
11636        let mut rendered_any = source_outcome.rendered_any;
11637
11638        if !shadow.texts.is_empty() {
11639            let mut shifted_texts = shadow.texts.clone();
11640            for text in &mut shifted_texts {
11641                text.rect.x -= viewport_offset[0] / root_scale;
11642                text.rect.y -= viewport_offset[1] / root_scale;
11643                if let Some(clip) = text.clip.as_mut() {
11644                    clip.x -= viewport_offset[0] / root_scale;
11645                    clip.y -= viewport_offset[1] / root_scale;
11646                }
11647            }
11648
11649            let mut staged_uploads = self.take_staged_uploads();
11650            let viewport = ViewportUniformParams {
11651                width: bounds_w,
11652                height: bounds_h,
11653                offset: [0.0, 0.0],
11654            };
11655            match self.prepare_text_image_draw_cmds(
11656                shifted_texts.iter(),
11657                viewport,
11658                root_scale,
11659                &mut staged_uploads,
11660            ) {
11661                Ok(prepared_images) if !prepared_images.is_empty() => {
11662                    let upload_offset = frame_encoder
11663                        .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11664                    self.flush_staged_uploads_at(
11665                        frame_encoder.encoder(),
11666                        &staged_uploads,
11667                        upload_offset,
11668                    );
11669                    let draw_result = {
11670                        let mut render_pass = frame_encoder.encoder().begin_render_pass(
11671                            &wgpu::RenderPassDescriptor {
11672                                label: Some("Shadow Source Text Image Pass"),
11673                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11674                                    view: &source.view,
11675                                    resolve_target: None,
11676                                    depth_slice: None,
11677                                    ops: wgpu::Operations {
11678                                        load: next_load_op,
11679                                        store: wgpu::StoreOp::Store,
11680                                    },
11681                                })],
11682                                depth_stencil_attachment: None,
11683                                timestamp_writes: None,
11684                                occlusion_query_set: None,
11685                                multiview_mask: None,
11686                            },
11687                        );
11688                        self.draw_prepared_images(
11689                            &mut render_pass,
11690                            &prepared_images,
11691                            BlendMode::SrcOver,
11692                        )
11693                    };
11694                    self.scratch_image_cmds = prepared_images.into_cmds();
11695                    if let Err(e) = draw_result {
11696                        eprintln!("Failed to draw text for shadow: {}", e);
11697                    } else {
11698                        self.frame_stats.bump_text();
11699                        frame_encoder.record_pass();
11700                        rendered_any = true;
11701                    }
11702                }
11703                Ok(prepared_images) => {
11704                    self.scratch_image_cmds = prepared_images.into_cmds();
11705                }
11706                Err(e) => {
11707                    eprintln!("Failed to prepare text image for shadow: {}", e);
11708                }
11709            }
11710            self.restore_staged_uploads(staged_uploads);
11711        }
11712
11713        if !rendered_any {
11714            frame_encoder.release_transient_offscreen(source_descriptor, source);
11715            return;
11716        }
11717
11718        let (scratch_w, scratch_h) = crate::effect_renderer::blur_scratch_size(
11719            pixel_radius,
11720            pixel_radius,
11721            bounds_w,
11722            bounds_h,
11723        );
11724        let scratch_descriptor =
11725            self.transient_offscreen_descriptor("Shadow Blur Scratch", scratch_w, scratch_h);
11726        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
11727        {
11728            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
11729                frame_encoder,
11730                &device,
11731                &source,
11732                &scratch,
11733                &source.view,
11734                pixel_radius,
11735                pixel_radius,
11736                TileMode::Decal,
11737                None, // No scissor needed — the texture is already bounds-sized
11738            );
11739        }
11740        frame_encoder.record_passes(2);
11741
11742        let clip_scissor = shadow
11743            .clip
11744            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
11745        let scissor = clip_scissor.or(processing_scissor);
11746        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
11747            // Adjust mask coordinates from viewport-space to texture-local space,
11748            // since the blit shader computes world_pos = uv * tex_size.
11749            mask.rect[0] -= viewport_offset[0];
11750            mask.rect[1] -= viewport_offset[1];
11751            mask
11752        });
11753        let dest_viewport = Some((
11754            viewport_offset[0],
11755            viewport_offset[1],
11756            bounds_w as f32,
11757            bounds_h as f32,
11758        ));
11759        {
11760            self.effect_renderer
11761                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
11762                    frame_encoder,
11763                    &device,
11764                    &source,
11765                    target_view,
11766                    1.0,
11767                    wgpu::LoadOp::Load,
11768                    scissor,
11769                    rounded_mask,
11770                    BlendMode::SrcOver,
11771                    dest_viewport,
11772                    CompositeSampleMode::Linear,
11773                );
11774        }
11775        frame_encoder.record_pass();
11776        self.effect_renderer.record_blur_pass();
11777        self.effect_renderer.record_composite_pass();
11778        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
11779        frame_encoder.release_transient_offscreen(source_descriptor, source);
11780    }
11781
11782    #[allow(clippy::too_many_arguments)]
11783    fn encode_shadow_shape_source_passes<C: FrameCommandRecorder>(
11784        &mut self,
11785        frame_encoder: &mut C,
11786        source_view: &wgpu::TextureView,
11787        shapes: &[(DrawShape, BlendMode)],
11788        brushes: &[Brush],
11789        width: u32,
11790        height: u32,
11791        viewport_offset: [f32; 2],
11792        root_scale: f32,
11793        next_load_op: &mut wgpu::LoadOp<wgpu::Color>,
11794    ) -> ShadowSourceRenderOutcome {
11795        if shapes.is_empty() {
11796            return ShadowSourceRenderOutcome {
11797                rendered_any: false,
11798                pass_count: 0,
11799            };
11800        }
11801
11802        let mut staged_uploads = self.take_staged_uploads();
11803        let mut rendered_any = false;
11804        let mut pass_count = 0_u32;
11805        let mut start = 0usize;
11806        while start < shapes.len() {
11807            let blend_mode = supported_blend_mode(shapes[start].1);
11808            let mut end = start + 1;
11809            while end < shapes.len()
11810                && end - start < self.shape_batch_limits.max_shapes_per_batch
11811                && supported_blend_mode(shapes[end].1) == blend_mode
11812            {
11813                end += 1;
11814            }
11815
11816            staged_uploads.clear();
11817            let viewport = ViewportUniformParams {
11818                width,
11819                height,
11820                offset: viewport_offset,
11821            };
11822            let viewport_rect_logical = viewport_rect_in_logical(viewport, root_scale);
11823            let Some(prepared_shape) = self.prepare_shapes_batch(
11824                shapes[start..end]
11825                    .iter()
11826                    .map(|(shape, _blend_mode)| shape)
11827                    .filter(|shape| match viewport_rect_logical {
11828                        Some(rect) => shape_draw_is_visible_in_rect(shape, rect, root_scale),
11829                        None => false,
11830                    }),
11831                brushes,
11832                root_scale,
11833                viewport,
11834                &mut staged_uploads,
11835            ) else {
11836                start = end;
11837                continue;
11838            };
11839
11840            let upload_offset =
11841                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11842            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
11843
11844            {
11845                let mut render_pass =
11846                    frame_encoder
11847                        .encoder()
11848                        .begin_render_pass(&wgpu::RenderPassDescriptor {
11849                            label: Some("Shadow Source Shape Pass"),
11850                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11851                                view: source_view,
11852                                resolve_target: None,
11853                                depth_slice: None,
11854                                ops: wgpu::Operations {
11855                                    load: *next_load_op,
11856                                    store: wgpu::StoreOp::Store,
11857                                },
11858                            })],
11859                            depth_stencil_attachment: None,
11860                            timestamp_writes: None,
11861                            occlusion_query_set: None,
11862                            multiview_mask: None,
11863                        });
11864                self.draw_prepared_shapes(
11865                    &mut render_pass,
11866                    blend_mode,
11867                    prepared_shape,
11868                    width,
11869                    height,
11870                    &[],
11871                );
11872            }
11873
11874            #[cfg(not(target_arch = "wasm32"))]
11875            {
11876                if fill_area_diag_enabled() {
11877                    // Each shadow-source pass round-trips the whole
11878                    // bounds-sized offscreen target (clear on the first
11879                    // pass, load/store after); the shape quads inside were
11880                    // already priced by `prepare_shapes_batch` under this
11881                    // pass's bounds viewport.
11882                    self.fill_area_diag
11883                        .add_offscreen_target_fill(f64::from(width) * f64::from(height));
11884                }
11885            }
11886
11887            pass_count = pass_count.saturating_add(1);
11888            rendered_any = true;
11889            *next_load_op = wgpu::LoadOp::Load;
11890            start = end;
11891        }
11892
11893        self.restore_staged_uploads(staged_uploads);
11894        ShadowSourceRenderOutcome {
11895            rendered_any,
11896            pass_count,
11897        }
11898    }
11899
11900    #[allow(clippy::too_many_arguments)]
11901    fn encode_shape_only_blurred_shadow_draw<C: FrameCommandRecorder>(
11902        &mut self,
11903        frame_encoder: &mut C,
11904        target_view: &wgpu::TextureView,
11905        shadow: &ShadowDraw,
11906        device_bounds: DevicePixelBounds,
11907        pixel_radius: f32,
11908        processing_scissor: Option<(u32, u32, u32, u32)>,
11909        width: u32,
11910        height: u32,
11911        root_scale: f32,
11912    ) -> bool {
11913        let bounds_w = device_bounds.width;
11914        let bounds_h = device_bounds.height;
11915        let viewport_offset = [device_bounds.x, device_bounds.y];
11916        let cache_key = shape_shadow_surface_cache_key(
11917            &shadow.shapes,
11918            &shadow.brushes,
11919            device_bounds,
11920            pixel_radius,
11921            root_scale,
11922        );
11923
11924        if let Some(key) = cache_key {
11925            if let Some(cached) = self.cached_shadow_surface(&key) {
11926                self.frame_stats
11927                    .record_shadow_shape_cache_hit(bounds_w, bounds_h);
11928                let clip_scissor = shadow
11929                    .clip
11930                    .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
11931                let scissor = clip_scissor.or(processing_scissor);
11932                let rounded_mask =
11933                    inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
11934                        mask.rect[0] -= viewport_offset[0];
11935                        mask.rect[1] -= viewport_offset[1];
11936                        mask
11937                    });
11938                let dest_viewport = Some((
11939                    viewport_offset[0],
11940                    viewport_offset[1],
11941                    bounds_w as f32,
11942                    bounds_h as f32,
11943                ));
11944                {
11945                    self.effect_renderer
11946                        .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
11947                            frame_encoder,
11948                            &self.device,
11949                            &cached,
11950                            target_view,
11951                            1.0,
11952                            wgpu::LoadOp::Load,
11953                            scissor,
11954                            rounded_mask,
11955                            BlendMode::SrcOver,
11956                            dest_viewport,
11957                            CompositeSampleMode::Nearest,
11958                        );
11959                }
11960                frame_encoder.record_pass();
11961                self.effect_renderer.record_composite_pass();
11962                return true;
11963            }
11964            self.frame_stats
11965                .record_shadow_shape_cache_miss(bounds_w, bounds_h);
11966            self.frame_stats.maybe_print_shadow_shape_cache_miss(
11967                bounds_w,
11968                bounds_h,
11969                key.content_hash,
11970                pixel_radius,
11971                viewport_offset,
11972                shadow.shapes.len(),
11973                shadow.clip,
11974            );
11975        }
11976
11977        let device = self.device.clone();
11978        let source_descriptor =
11979            self.transient_offscreen_descriptor("Shape Shadow Source", bounds_w, bounds_h);
11980        let source_is_cacheable = cache_key.is_some();
11981        let source = if source_is_cacheable {
11982            self.acquire_retained_surface(bounds_w, bounds_h)
11983        } else {
11984            frame_encoder.acquire_transient_offscreen(&device, source_descriptor)
11985        };
11986        let (scratch_w, scratch_h) = crate::effect_renderer::blur_scratch_size(
11987            pixel_radius,
11988            pixel_radius,
11989            bounds_w,
11990            bounds_h,
11991        );
11992        let scratch_descriptor =
11993            self.transient_offscreen_descriptor("Shape Shadow Blur Scratch", scratch_w, scratch_h);
11994        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
11995        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
11996        let source_outcome = self.encode_shadow_shape_source_passes(
11997            frame_encoder,
11998            &source.view,
11999            &shadow.shapes,
12000            &shadow.brushes,
12001            bounds_w,
12002            bounds_h,
12003            viewport_offset,
12004            root_scale,
12005            &mut next_load_op,
12006        );
12007        frame_encoder.record_passes(source_outcome.pass_count);
12008
12009        if !source_outcome.rendered_any {
12010            frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
12011            if source_is_cacheable {
12012                self.defer_offscreen_release(source);
12013            } else {
12014                frame_encoder.release_transient_offscreen(source_descriptor, source);
12015            }
12016            return true;
12017        }
12018
12019        {
12020            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
12021                frame_encoder,
12022                &device,
12023                &source,
12024                &scratch,
12025                &source.view,
12026                pixel_radius,
12027                pixel_radius,
12028                TileMode::Decal,
12029                None,
12030            );
12031        }
12032        frame_encoder.record_passes(2);
12033
12034        let clip_scissor = shadow
12035            .clip
12036            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
12037        let scissor = clip_scissor.or(processing_scissor);
12038        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
12039            mask.rect[0] -= viewport_offset[0];
12040            mask.rect[1] -= viewport_offset[1];
12041            mask
12042        });
12043        let dest_viewport = Some((
12044            viewport_offset[0],
12045            viewport_offset[1],
12046            bounds_w as f32,
12047            bounds_h as f32,
12048        ));
12049        {
12050            self.effect_renderer
12051                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
12052                    frame_encoder,
12053                    &device,
12054                    &source,
12055                    target_view,
12056                    1.0,
12057                    wgpu::LoadOp::Load,
12058                    scissor,
12059                    rounded_mask,
12060                    BlendMode::SrcOver,
12061                    dest_viewport,
12062                    CompositeSampleMode::Nearest,
12063                );
12064        }
12065        frame_encoder.record_pass();
12066
12067        self.effect_renderer.record_blur_pass();
12068        self.effect_renderer.record_composite_pass();
12069        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
12070        if let Some(key) = cache_key {
12071            self.insert_cached_shadow_surface(key, source);
12072        } else {
12073            frame_encoder.release_transient_offscreen(source_descriptor, source);
12074        }
12075        true
12076    }
12077
12078    fn prepare_shapes_batch<'a, I>(
12079        &mut self,
12080        layer_shapes: I,
12081        brushes: &[Brush],
12082        root_scale: f32,
12083        viewport: ViewportUniformParams,
12084        staged_uploads: &mut StagedBufferUploads,
12085    ) -> Option<PreparedShapeBatch>
12086    where
12087        I: Iterator<Item = &'a DrawShape>,
12088    {
12089        #[cfg(target_arch = "wasm32")]
12090        let _ = staged_uploads;
12091
12092        // Build shape data for this subset. Callers hand in only shapes visible in
12093        // `viewport`: the segment paths culled at collect time, and the layer and
12094        // shadow-source paths filter at the call site. Re-checking here would run
12095        // the same quad math a second time on every shape of every frame.
12096        let shape_refs: Vec<&DrawShape> = layer_shapes
12097            .take(self.shape_batch_limits.max_shapes_per_batch)
12098            .collect();
12099        let shape_count = shape_refs.len();
12100        if shape_count == 0 {
12101            return None;
12102        }
12103
12104        // Per-shape gradient spans as a prefix sum, so every output slot is
12105        // known before conversion starts and the shapes can convert in
12106        // parallel into disjoint sub-slices.
12107        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12108        let mut total_gradient_stops = 0u32;
12109        gradient_offsets.push(0);
12110        for shape in &shape_refs {
12111            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12112            gradient_offsets.push(total_gradient_stops);
12113        }
12114
12115        self.scratch_shape_data.clear();
12116        self.scratch_shape_data
12117            .resize(shape_count, ShapeData::zeroed());
12118        self.scratch_gradients.clear();
12119        self.scratch_gradients
12120            .resize(total_gradient_stops as usize, GradientStop::zeroed());
12121
12122        convert_shapes_into_outputs(
12123            &shape_refs,
12124            brushes,
12125            &gradient_offsets,
12126            root_scale,
12127            &mut self.scratch_shape_data,
12128            &mut self.scratch_gradients,
12129        );
12130        #[cfg(not(target_arch = "wasm32"))]
12131        {
12132            if fill_area_diag_enabled() {
12133                self.fill_area_diag
12134                    .add_shape_quads(&self.scratch_shape_data, viewport);
12135            }
12136        }
12137
12138        #[cfg(not(target_arch = "wasm32"))]
12139        {
12140            self.shape_buffers.ensure_capacity(
12141                &self.device,
12142                &self.shape_bind_group_layout,
12143                &self.identity_similarity_buffer,
12144                self.dummy_paint_buffer.as_ref(),
12145                shape_count,
12146                self.scratch_gradients.len().max(1),
12147            );
12148            self.stage_viewport_uniforms(staged_uploads, viewport);
12149            staged_uploads.stage(
12150                UploadTarget::ShapeData,
12151                bytemuck::cast_slice(&self.scratch_shape_data),
12152            );
12153            if !self.scratch_gradients.is_empty() {
12154                staged_uploads.stage(
12155                    UploadTarget::ShapeGradient,
12156                    bytemuck::cast_slice(&self.scratch_gradients),
12157                );
12158            }
12159        }
12160
12161        #[cfg(target_arch = "wasm32")]
12162        let shape_slot = {
12163            let slot = self.claim_wasm_shape_batch();
12164            {
12165                let buffers = &mut self.wasm_shape_batches[slot];
12166                buffers.ensure_capacity(
12167                    &self.device,
12168                    &self.shape_bind_group_layout,
12169                    &self.identity_similarity_buffer,
12170                    self.dummy_paint_buffer.as_ref(),
12171                    shape_count,
12172                    self.scratch_gradients.len().max(1),
12173                );
12174            }
12175            let buffers = &self.wasm_shape_batches[slot];
12176            self.write_wasm_buffer(
12177                &buffers.shape_buffer,
12178                bytemuck::cast_slice(&self.scratch_shape_data),
12179            );
12180            if !self.scratch_gradients.is_empty() {
12181                self.write_wasm_buffer(
12182                    &buffers.gradient_buffer,
12183                    bytemuck::cast_slice(&self.scratch_gradients),
12184                );
12185            }
12186            slot
12187        };
12188
12189        #[cfg(target_arch = "wasm32")]
12190        let uniform_slot = self.prepare_wasm_viewport_uniforms(viewport);
12191
12192        Some(PreparedShapeBatch {
12193            vertex_start: 0,
12194            vertex_count: shape_count as u32 * 6,
12195            has_gradient: total_gradient_stops > 0,
12196            #[cfg(target_arch = "wasm32")]
12197            shape_slot,
12198            #[cfg(target_arch = "wasm32")]
12199            uniform_slot,
12200        })
12201    }
12202
12203    /// Like [`Self::prepare_shapes_batch`], but converts shapes straight into
12204    /// mapped regions of the frame upload buffer instead of scratch vectors —
12205    /// one CPU pass over the data instead of three (convert, stage, upload).
12206    /// Returns the prepared batch and the upload-buffer base offset to pass
12207    /// to `flush_staged_uploads_at`; the GPU copies are recorded into
12208    /// `staged_uploads` while its byte blob stays empty.
12209    #[cfg(not(target_arch = "wasm32"))]
12210    fn prepare_shapes_batch_direct<'a, I, C: FrameCommandRecorder>(
12211        &mut self,
12212        frame_encoder: &mut C,
12213        layer_shapes: I,
12214        brushes: &[Brush],
12215        root_scale: f32,
12216        viewport: ViewportUniformParams,
12217        staged_uploads: &mut StagedBufferUploads,
12218    ) -> Option<(PreparedShapeBatch, u64)>
12219    where
12220        I: Iterator<Item = &'a DrawShape>,
12221    {
12222        let shape_refs: Vec<&DrawShape> = layer_shapes
12223            .take(self.shape_batch_limits.max_shapes_per_batch)
12224            .collect();
12225        let shape_count = shape_refs.len();
12226        if shape_count == 0 {
12227            return None;
12228        }
12229
12230        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12231        let mut total_gradient_stops = 0u32;
12232        gradient_offsets.push(0);
12233        for shape in &shape_refs {
12234            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12235            gradient_offsets.push(total_gradient_stops);
12236        }
12237
12238        self.shape_buffers.ensure_capacity(
12239            &self.device,
12240            &self.shape_bind_group_layout,
12241            &self.identity_similarity_buffer,
12242            self.dummy_paint_buffer.as_ref(),
12243            shape_count,
12244            (total_gradient_stops as usize).max(1),
12245        );
12246
12247        self.scratch_shape_data.clear();
12248        self.scratch_shape_data
12249            .resize(shape_count, ShapeData::zeroed());
12250        self.scratch_gradients.clear();
12251        self.scratch_gradients
12252            .resize(total_gradient_stops as usize, GradientStop::zeroed());
12253        convert_shapes_into_outputs(
12254            &shape_refs,
12255            brushes,
12256            &gradient_offsets,
12257            root_scale,
12258            &mut self.scratch_shape_data,
12259            &mut self.scratch_gradients,
12260        );
12261        if fill_area_diag_enabled() {
12262            self.fill_area_diag
12263                .add_shape_quads(&self.scratch_shape_data, viewport);
12264        }
12265
12266        // Region layout inside the frame upload buffer. Every element type is
12267        // f32/u32-based, so all lengths are multiples of
12268        // `COPY_BUFFER_ALIGNMENT` and back-to-back packing keeps each offset
12269        // copy-aligned. Writing each scratch slice straight into the upload
12270        // buffer skips the intermediate staged-bytes blob (one fewer CPU pass
12271        // over the batch payload).
12272        let uniform_len = std::mem::size_of::<Uniforms>() as u64;
12273        let shape_len = (shape_count * std::mem::size_of::<ShapeData>()) as u64;
12274        let gradient_len = total_gradient_stops as u64 * std::mem::size_of::<GradientStop>() as u64;
12275        let total_len = uniform_len + shape_len + gradient_len;
12276        let upload_base = frame_encoder.allocate_staged_upload_bytes(total_len);
12277        self.ensure_upload_buffer_capacity(upload_base + total_len);
12278
12279        let shape_off = uniform_len;
12280        let gradient_off = shape_off + shape_len;
12281
12282        let uniforms = Self::viewport_uniforms(viewport);
12283        let mut upload_stats = self.frame_graph_executor.upload_buffer(
12284            &self.queue,
12285            &self.upload_buffer,
12286            upload_base,
12287            bytemuck::bytes_of(&uniforms),
12288        );
12289        upload_stats.upload_bytes += self
12290            .frame_graph_executor
12291            .upload_buffer(
12292                &self.queue,
12293                &self.upload_buffer,
12294                upload_base + shape_off,
12295                bytemuck::cast_slice(&self.scratch_shape_data),
12296            )
12297            .upload_bytes;
12298        if !self.scratch_gradients.is_empty() {
12299            upload_stats.upload_bytes += self
12300                .frame_graph_executor
12301                .upload_buffer(
12302                    &self.queue,
12303                    &self.upload_buffer,
12304                    upload_base + gradient_off,
12305                    bytemuck::cast_slice(&self.scratch_gradients),
12306                )
12307                .upload_bytes;
12308        }
12309        self.frame_stats.record_command_stats(upload_stats);
12310
12311        staged_uploads.record_upload_copy(UploadTarget::Uniform, 0, 0, uniform_len);
12312        staged_uploads.record_upload_copy(UploadTarget::ShapeData, shape_off, 0, shape_len);
12313        staged_uploads.record_upload_copy(
12314            UploadTarget::ShapeGradient,
12315            gradient_off,
12316            0,
12317            gradient_len,
12318        );
12319
12320        Some((
12321            PreparedShapeBatch {
12322                vertex_start: 0,
12323                vertex_count: shape_count as u32 * 6,
12324                has_gradient: total_gradient_stops > 0,
12325            },
12326            upload_base,
12327        ))
12328    }
12329
12330    /// Whether retained replay batches can exist on this device: they bind
12331    /// unsized buffers, so they ride the storage-buffer batch mode only.
12332    /// Always `false` on wasm, which has no retained replay path — the
12333    /// method exists on both arches so the packet producer has one
12334    /// architecture.
12335    pub(crate) fn replay_supported(&self) -> bool {
12336        // Deliberately not conditioned on free slot ids: an exhausted pool
12337        // only means new captures fail (handled per capture), while flipping
12338        // this bit would retire every live feed slot.
12339        #[cfg(target_arch = "wasm32")]
12340        {
12341            false
12342        }
12343        #[cfg(not(target_arch = "wasm32"))]
12344        {
12345            self.shape_batch_limits.storage
12346        }
12347    }
12348
12349    /// Return the planner-drained ack confirmations buffer (capacity
12350    /// intact) to the store after the producer applied a frame's
12351    /// [`crate::frame_packet::ReplayAck`] — the ack channel's half of the
12352    /// P4b no-allocation contract, closed by the caller now that ack
12353    /// application lives producer-side. No-op on wasm.
12354    pub(crate) fn restore_replay_ack_confirmations(
12355        &mut self,
12356        confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
12357    ) {
12358        #[cfg(not(target_arch = "wasm32"))]
12359        {
12360            self.replay_ack_confirmations = confirmations;
12361        }
12362        #[cfg(target_arch = "wasm32")]
12363        let _ = confirmations;
12364    }
12365
12366    /// The surface format this renderer was constructed for — the present
12367    /// runtime's offscreen test target must match it.
12368    #[cfg(not(target_arch = "wasm32"))]
12369    pub(crate) fn surface_format(&self) -> wgpu::TextureFormat {
12370        self.surface_format
12371    }
12372
12373    /// Test inspector for the threaded confirmations round-trip: the
12374    /// store-side ack buffer's current capacity.
12375    #[cfg(not(target_arch = "wasm32"))]
12376    pub(crate) fn replay_ack_confirmations_capacity(&self) -> usize {
12377        self.replay_ack_confirmations.capacity()
12378    }
12379
12380    /// EARLY present-side consumption of a validated packet's replay plan
12381    /// (threaded runtime only): identical store work to the render-time
12382    /// block in `render_graph_recorded`, but runnable BEFORE surface
12383    /// acquire, so the [`crate::frame_packet::ReplayAck`] can travel to the
12384    /// producer without waiting out the swapchain — a capture confirmed
12385    /// here is available to the very next frame's planning, the same
12386    /// one-frame latency the synchronous path has. Marks the packet so the
12387    /// render path does not consume the taken-out default plan, and so a
12388    /// later cancel does not reclaim it. `None` for Surface roots, which
12389    /// never touch the planner. The caller must have validated the packet
12390    /// (epochs, viewport) first: this executes against the live store.
12391    #[cfg(not(target_arch = "wasm32"))]
12392    pub(crate) fn take_replay_ack_early(
12393        &mut self,
12394        packet: &mut FramePacket,
12395    ) -> Option<(
12396        crate::frame_packet::ReplayAck,
12397        crate::frame_packet::ReplayFrameOps,
12398    )> {
12399        if packet.replay_preconsumed {
12400            return None;
12401        }
12402        let PacketRoot::Direct(root) = &packet.root else {
12403            return None;
12404        };
12405        let ops = std::mem::take(&mut packet.replay);
12406        let root_scale = packet.root_scale;
12407        let (ack, recycled) =
12408            self.consume_replay_ops(ops, &root.scene.shapes, &root.scene.brushes, root_scale);
12409        packet.replay_preconsumed = true;
12410        Some((ack, recycled))
12411    }
12412
12413    /// Present-side consumption of one frame's [`ReplayFrameOps`]: frees
12414    /// the plan's releases, then honors its capture requests against the
12415    /// scene they were recorded for, answering with a [`ReplayAck`] of
12416    /// (identity, gpu slot) confirmations plus the batch's emptied buffers
12417    /// for recycling. This is the store half of the split — it touches NO
12418    /// planner state: `feed_slots`, confirmation stamping, displaced-slot
12419    /// release, and age eviction all live in the planner
12420    /// (`take_frame_ops`/`apply_ack`).
12421    ///
12422    /// Ordering is what makes slot release safe: a slot the plan releases
12423    /// is never referenced by a retained op of the same frame (misses
12424    /// release before their op would have been pushed, and rebuild frames
12425    /// release at flush start), so freeing it here — before any encoding —
12426    /// cannot orphan a draw.
12427    #[cfg(not(target_arch = "wasm32"))]
12428    fn consume_replay_ops(
12429        &mut self,
12430        mut ops: crate::frame_packet::ReplayFrameOps,
12431        shapes: &[DrawShape],
12432        brushes: &[Brush],
12433        root_scale: f32,
12434    ) -> (
12435        crate::frame_packet::ReplayAck,
12436        crate::frame_packet::ReplayFrameOps,
12437    ) {
12438        // The batch's own staleness ordinal, echoed in the ack so the
12439        // planner purges exactly this batch's unconfirmed requests even
12440        // when another batch is already in flight behind it.
12441        let acked_frame = ops.frame;
12442        if ops.generation < self.store_feed_generation {
12443            // Fail-closed: ops planned under an OLDER slot universe name
12444            // slots this store does not hold. Drop the batch whole —
12445            // captures unconfirmed self-heal (the planner never serves
12446            // them), and stale releases must not free live ids.
12447            // Synchronously impossible today; structural for the split.
12448            self.replay_generation_drops += 1;
12449            log::warn!(
12450                "[command-feed] dropping replay ops of generation {} against store \
12451                 generation {} ({} captures, {} patches, {} releases; lifetime drops {})",
12452                ops.generation,
12453                self.store_feed_generation,
12454                ops.captures.len(),
12455                ops.color_patches.len(),
12456                ops.releases.len(),
12457                self.replay_generation_drops,
12458            );
12459            ops.captures.clear();
12460            ops.color_patches.clear();
12461            ops.releases.clear();
12462            return (
12463                crate::frame_packet::ReplayAck {
12464                    generation: self.store_feed_generation,
12465                    frame: acked_frame,
12466                    confirmations: Vec::new(),
12467                },
12468                ops,
12469            );
12470        }
12471        if ops.generation > self.store_feed_generation {
12472            // Adopt forward: a producer-side bump (scale change,
12473            // `retire_feed`) delivers its whole retirement — the releases
12474            // for every retired slot — THROUGH this very batch, so a
12475            // higher generation is the new universe arriving, not a stale
12476            // one. The store follows the producer's authority; it never
12477            // reads the producer's thread-local.
12478            self.store_feed_generation = ops.generation;
12479        }
12480        let generation = ops.generation;
12481        // Queued releases free first, so their buffers are available before
12482        // this frame's captures ask.
12483        for slot in ops.releases.drain(..) {
12484            self.release_replay_slot(slot);
12485        }
12486        // `take` leaves `Vec::new()` behind (no allocation); the render
12487        // loop restores the vec after the planner drains the ack.
12488        let mut confirmations = std::mem::take(&mut self.replay_ack_confirmations);
12489        debug_assert!(confirmations.is_empty());
12490        // One refs buffer for the whole batch: a re-partition frame carries
12491        // one capture per segment, and `shapes` outlives the loop, so each
12492        // capture's collect reuses a single allocation.
12493        let mut refs: Vec<&DrawShape> = Vec::new();
12494        for capture in ops.captures.drain(..) {
12495            if capture.frame != ops.frame {
12496                // Defensive: a capture that outlived its frame references
12497                // shape indices of a scene that never rendered; honoring it
12498                // against THIS frame's shapes would retain wrong content
12499                // under a confirmed identity. Categorically drop it. Should
12500                // never fire now that ops travel inside the frame's own
12501                // packet.
12502                log::warn!(
12503                    "[command-feed] dropping stale capture for slot {} of {:?} \
12504                     (queued frame {}, ops frame {})",
12505                    capture.key.1,
12506                    capture.key.0,
12507                    capture.frame,
12508                    ops.frame,
12509                );
12510                continue;
12511            }
12512            let end = capture.shape_start + capture.shape_count;
12513            let Some(slice) = shapes.get(capture.shape_start..end) else {
12514                continue;
12515            };
12516            refs.clear();
12517            refs.extend(slice.iter());
12518            let Some(gpu_slot) = self.capture_replay_slot(&refs, brushes, root_scale) else {
12519                continue;
12520            };
12521            confirmations.push((capture.key, gpu_slot));
12522        }
12523        // Park the frame's recolor patches for the retained prepare arms
12524        // (`stage_replay_patches`); the vec swapped out is last frame's,
12525        // already drained empty, and returns to the producer with the ack.
12526        // The defensive clear only bites when no prepare arm ran last
12527        // frame (aborted render): those patches targeted a frame that
12528        // never encoded, and their spans re-queue fresh recolors each
12529        // served frame.
12530        self.replay_color_patches.clear();
12531        std::mem::swap(&mut self.replay_color_patches, &mut ops.color_patches);
12532        (
12533            crate::frame_packet::ReplayAck {
12534                generation,
12535                frame: acked_frame,
12536                confirmations,
12537            },
12538            ops,
12539        )
12540    }
12541
12542    /// Test/diagnostic view of the store's lifetime count of replay-ops
12543    /// batches dropped whole by the generation check — the consume gate's
12544    /// proof that Surface frames (default plans, generation 0) are never
12545    /// fed to the store.
12546    #[cfg(not(target_arch = "wasm32"))]
12547    pub(crate) fn replay_generation_drops(&self) -> u64 {
12548        self.replay_generation_drops
12549    }
12550
12551    /// Test hook for the message protocol: runs one planner→store→planner
12552    /// replay cycle outside a frame, with the batch stamped
12553    /// `store_feed_generation + generation_skew`, and returns how many
12554    /// captures the store confirmed. A skew that lands BELOW the store's
12555    /// generation manufactures the fail-closed drop; a skew above it
12556    /// exercises adopt-forward. Both are synchronously impossible through
12557    /// the public render path today.
12558    #[cfg(not(target_arch = "wasm32"))]
12559    pub(crate) fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
12560        let generation = self.store_feed_generation.wrapping_add(generation_skew);
12561        let ops = crate::shape_replay::SHAPE_REPLAY
12562            .with(|state| state.borrow_mut().take_frame_ops(generation));
12563        let (ack, recycled) = self.consume_replay_ops(ops, &[], &[], 1.0);
12564        let confirmed = ack.confirmations.len();
12565        self.replay_ack_confirmations = crate::shape_replay::SHAPE_REPLAY
12566            .with(|state| state.borrow_mut().apply_ack(ack, recycled));
12567        confirmed
12568    }
12569
12570    /// Stages every queued replay recolor patch. Feed recolors are always
12571    /// solid, so every patch rewrites the shape's 16-byte record in the
12572    /// slot's paint buffer; the captured `ShapeData` itself is immutable, so
12573    /// a recolored frame uploads colors, not geometry. Runs in the retained
12574    /// prepare arms so the writes land in the same staged-upload flush that
12575    /// carries the frame's transforms; draining is idempotent across arms.
12576    #[cfg(not(target_arch = "wasm32"))]
12577    fn stage_replay_patches(&mut self, staged_uploads: &mut StagedBufferUploads) {
12578        // Capacity-retaining drain: swap the frame's parked patch buffer
12579        // (see `consume_replay_ops`) against the scratch arena instead of
12580        // `mem::take`, so both keep their high-water capacity across
12581        // frames. The scratch is cleared before every return, which
12582        // preserves drain idempotence across the retained prepare arms: a
12583        // later drain in the same frame swaps one empty-with-capacity
12584        // arena for another and stages nothing.
12585        std::mem::swap(
12586            &mut self.replay_color_patches,
12587            &mut self.color_patch_scratch,
12588        );
12589        let total_patches = self.color_patch_scratch.len();
12590        if total_patches == 0 {
12591            self.replay_upload_stats.note_frame(0, 0, 0, 0, 0);
12592            return;
12593        }
12594
12595        // Patches land in the slot's CPU mirror and upload as one contiguous
12596        // span per slot. Uploading each patch individually would record one
12597        // copy command per patch, and MEGA's twinkle field recolors ~1.7k
12598        // dots a frame — that many commands stall a mobile GPU for longer
12599        // than the spans' untouched bytes ever cost.
12600        #[derive(Clone, Copy)]
12601        struct DirtySpan {
12602            paint_min: u32,
12603            paint_max: u32,
12604        }
12605        const CLEAN: DirtySpan = DirtySpan {
12606            paint_min: u32::MAX,
12607            paint_max: 0,
12608        };
12609        let mut dirty: std::collections::HashMap<
12610            u32,
12611            DirtySpan,
12612            cranpose_ui_graphics::FxBuildHasher,
12613        > = std::collections::HashMap::default();
12614
12615        // One bare 16-byte write into the slot's paint mirror per patch.
12616        for patch in &self.color_patch_scratch {
12617            let Some(slot) = self.replay_slots.slots.get_mut(&patch.slot) else {
12618                continue;
12619            };
12620            let Some(paint) = slot.paint_mirror.get_mut(patch.shape_index as usize) else {
12621                continue;
12622            };
12623            *paint = patch.color;
12624            let span = dirty.entry(patch.slot).or_insert(CLEAN);
12625            span.paint_min = span.paint_min.min(patch.shape_index);
12626            span.paint_max = span.paint_max.max(patch.shape_index);
12627        }
12628
12629        let mut uploaded_records = 0u64;
12630        let mut uploaded_bytes = 0u64;
12631        let slots_touched = dirty.len() as u64;
12632        for (slot_id, span) in dirty {
12633            let Some(slot) = self.replay_slots.slots.get(&slot_id) else {
12634                continue;
12635            };
12636            if span.paint_min <= span.paint_max {
12637                let range = span.paint_min as usize..span.paint_max as usize + 1;
12638                uploaded_records += range.len() as u64;
12639                uploaded_bytes += (range.len() * std::mem::size_of::<[f32; 4]>()) as u64;
12640                staged_uploads.stage_at(
12641                    UploadTarget::ReplayPaintData(slot_id),
12642                    range.start as u64 * std::mem::size_of::<[f32; 4]>() as u64,
12643                    bytemuck::cast_slice(&slot.paint_mirror[range]),
12644                );
12645            }
12646        }
12647        // A patched color is one 16-byte vec4; the staged bytes exceed this
12648        // only by the untouched records inside each coalesced span.
12649        let ideal_bytes = total_patches as u64 * 16;
12650        self.replay_upload_stats.note_frame(
12651            total_patches as u64,
12652            slots_touched,
12653            uploaded_records,
12654            uploaded_bytes,
12655            ideal_bytes,
12656        );
12657        if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
12658            log::warn!(
12659                "[replay-upload] frame: {} patches -> {} records / {:.1} KB staged \
12660                 across {} slots (color-only {:.1} KB)",
12661                total_patches,
12662                uploaded_records,
12663                uploaded_bytes as f64 / 1024.0,
12664                slots_touched,
12665                ideal_bytes as f64 / 1024.0,
12666            );
12667        }
12668        self.color_patch_scratch.clear();
12669    }
12670
12671    /// Converts `shape_refs` once and retains the result on the GPU as a
12672    /// replay slot. Returns the slot id the scene's retained draws reference.
12673    #[cfg(not(target_arch = "wasm32"))]
12674    pub(crate) fn capture_replay_slot(
12675        &mut self,
12676        shape_refs: &[&DrawShape],
12677        brushes: &[Brush],
12678        root_scale: f32,
12679    ) -> Option<u32> {
12680        if !self.shape_batch_limits.storage || shape_refs.is_empty() {
12681            return None;
12682        }
12683        let id = self.replay_slots.free_ids.pop()?;
12684        let shape_count = shape_refs.len();
12685
12686        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12687        let mut total_gradient_stops = 0u32;
12688        gradient_offsets.push(0);
12689        for shape in shape_refs {
12690            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12691            gradient_offsets.push(total_gradient_stops);
12692        }
12693
12694        // Staging scratch, not fresh vectors: cleared and re-zeroed to this
12695        // capture's exact sizes, capacity kept across captures.
12696        let mut shape_data = std::mem::take(&mut self.replay_capture_shape_scratch);
12697        shape_data.clear();
12698        shape_data.resize(shape_count, ShapeData::zeroed());
12699        let mut gradients = std::mem::take(&mut self.replay_capture_gradient_scratch);
12700        gradients.clear();
12701        gradients.resize(
12702            (total_gradient_stops as usize).max(1),
12703            GradientStop::zeroed(),
12704        );
12705        convert_shapes_into_outputs(
12706            shape_refs,
12707            brushes,
12708            &gradient_offsets,
12709            root_scale,
12710            &mut shape_data,
12711            &mut gradients,
12712        );
12713
12714        let shape_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12715            label: Some("Replay Shape Buffer"),
12716            size: (std::mem::size_of::<ShapeData>() * shape_count) as u64,
12717            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
12718            mapped_at_creation: true,
12719        });
12720        shape_buffer
12721            .slice(..)
12722            .get_mapped_range_mut()
12723            .copy_from_slice(bytemuck::cast_slice(&shape_data));
12724        shape_buffer.unmap();
12725
12726        let gradient_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12727            label: Some("Replay Gradient Buffer"),
12728            size: (std::mem::size_of::<GradientStop>() * gradients.len()) as u64,
12729            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
12730            mapped_at_creation: true,
12731        });
12732        gradient_buffer
12733            .slice(..)
12734            .get_mapped_range_mut()
12735            .copy_from_slice(bytemuck::cast_slice(&gradients));
12736        gradient_buffer.unmap();
12737
12738        // Filled by the mesh arm when the capture keeps its arc mesh, so
12739        // the fill-diag records can price those shapes by their true
12740        // triangle area.
12741        let mut mesh_fill_records: Option<Vec<FillDiagShapeRecord>> = None;
12742        let mut submitted_area_scale = 1.0f32;
12743        let mesh = if arc_mesh_enabled() {
12744            match build_arc_mesh_vertices(&shape_data, retained_mesh_min_px2()) {
12745                Some(build) => {
12746                    let meshed_shapes = build.meshed_arcs + build.meshed_rims;
12747                    // A pathological meshed/instanced interleave would spend
12748                    // more on pipeline switches than the bands recover; the
12749                    // whole slot stays instanced instead (content-conditional
12750                    // — a property of this capture's shape order).
12751                    let within_stretch_cap = build.meshed_stretches <= MESH_SLOT_MAX_STRETCHES;
12752                    let cut = if build.quad_area > 0.0 {
12753                        (1.0 - build.mesh_area / build.quad_area) * 100.0
12754                    } else {
12755                        0.0
12756                    };
12757                    // Always-on warn: `log::info` is invisible on the desktop
12758                    // console, and captures are rare — one line per slot
12759                    // lifetime. The unique-vert/index counts are the
12760                    // vertex-amplification instrument P1b exists for; the
12761                    // meshed/instanced split and the stretch count are the
12762                    // size gate's own engagement instrument.
12763                    log::warn!(
12764                        "[arc-mesh] slot {id}: {} arcs + {} rims meshed ({} segs, \
12765                         {} stretches), {} instanced; {} unique verts / {} indices; \
12766                         quad_px {:.0} -> submit_px {:.0} (-{:.1}%)",
12767                        build.meshed_arcs,
12768                        build.meshed_rims,
12769                        build.meshed_segments,
12770                        build.meshed_stretches,
12771                        build.passthrough,
12772                        build.vertices.len(),
12773                        build.indices.len(),
12774                        build.quad_area,
12775                        build.mesh_area,
12776                        cut,
12777                    );
12778                    if !within_stretch_cap {
12779                        log::warn!(
12780                            "[arc-mesh] slot {id}: {} meshed stretches exceed the \
12781                             {MESH_SLOT_MAX_STRETCHES}-stretch switch cap; slot stays instanced",
12782                            build.meshed_stretches,
12783                        );
12784                    }
12785                    let keep_mesh = meshed_shapes > 0 && within_stretch_cap;
12786                    if keep_mesh && build.quad_area > 0.0 {
12787                        // What this slot's replay actually rasterizes per
12788                        // quad pixel, for the segment-surface economics
12789                        // gate. Clamped away from zero so a degenerate
12790                        // measurement cannot make the direct path look
12791                        // free.
12792                        submitted_area_scale =
12793                            (build.mesh_area / build.quad_area).clamp(0.05, 1.0) as f32;
12794                    }
12795                    if keep_mesh && fill_area_diag_enabled() {
12796                        mesh_fill_records = Some(fill_diag_capture_records(
12797                            &shape_data,
12798                            Some((&build.vertices, &build.indices, &build.index_prefix)),
12799                        ));
12800                    }
12801                    // A slot that meshed nothing gains nothing over the
12802                    // instanced path — skip the buffers.
12803                    keep_mesh.then(|| {
12804                        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12805                            label: Some("Replay Mesh Vertex Buffer"),
12806                            size: (std::mem::size_of::<MeshVertex>() * build.vertices.len()) as u64,
12807                            usage: wgpu::BufferUsages::VERTEX,
12808                            mapped_at_creation: true,
12809                        });
12810                        vertex_buffer
12811                            .slice(..)
12812                            .get_mapped_range_mut()
12813                            .copy_from_slice(bytemuck::cast_slice(&build.vertices));
12814                        vertex_buffer.unmap();
12815                        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12816                            label: Some("Replay Mesh Index Buffer"),
12817                            size: (std::mem::size_of::<u32>() * build.indices.len()) as u64,
12818                            usage: wgpu::BufferUsages::INDEX,
12819                            mapped_at_creation: true,
12820                        });
12821                        index_buffer
12822                            .slice(..)
12823                            .get_mapped_range_mut()
12824                            .copy_from_slice(bytemuck::cast_slice(&build.indices));
12825                        index_buffer.unmap();
12826                        ReplaySlotMesh {
12827                            vertex_buffer,
12828                            index_buffer,
12829                            index_prefix: build.index_prefix,
12830                            meshed_arcs: build.meshed_arcs,
12831                            meshed_rims: build.meshed_rims,
12832                            passthrough: build.passthrough,
12833                        }
12834                    })
12835                }
12836                None => {
12837                    log::warn!(
12838                        "[arc-mesh] slot {id}: geometry byte budget overflowed for \
12839                         {shape_count} shapes; whole slot stays instanced"
12840                    );
12841                    None
12842                }
12843            }
12844        } else {
12845            None
12846        };
12847
12848        let fill_diag_shapes = if fill_area_diag_enabled() {
12849            let records =
12850                mesh_fill_records.unwrap_or_else(|| fill_diag_capture_records(&shape_data, None));
12851            // Feed the once-per-process top-slack dump before the records
12852            // move into the slot.
12853            self.fill_area_diag.note_retained_capture(id, &records);
12854            records
12855        } else {
12856            Vec::new()
12857        };
12858
12859        // Capture-space quad AABBs and the quad-area prefix sum for the
12860        // segment-surface cache: the quads are the exact geometry the
12861        // replay rasterizes, so a range's surface economics and capture
12862        // rect derive from them with no second conversion.
12863        let mut shape_aabbs = Vec::with_capacity(shape_count);
12864        let mut area_prefix = Vec::with_capacity(shape_count + 1);
12865        area_prefix.push(0.0f32);
12866        for shape in &shape_data {
12867            let corners = [
12868                [shape.quad01[0], shape.quad01[1]],
12869                [shape.quad01[2], shape.quad01[3]],
12870                [shape.quad23[0], shape.quad23[1]],
12871                [shape.quad23[2], shape.quad23[3]],
12872            ];
12873            let mut min_x = f32::INFINITY;
12874            let mut min_y = f32::INFINITY;
12875            let mut max_x = f32::NEG_INFINITY;
12876            let mut max_y = f32::NEG_INFINITY;
12877            for corner in corners {
12878                min_x = min_x.min(corner[0]);
12879                min_y = min_y.min(corner[1]);
12880                max_x = max_x.max(corner[0]);
12881                max_y = max_y.max(corner[1]);
12882            }
12883            shape_aabbs.push([min_x, min_y, max_x, max_y]);
12884            // Shoelace over the quad's boundary order (corners 0, 1, 3, 2 —
12885            // the two triangles share the 1-2 diagonal).
12886            let ring = [corners[0], corners[1], corners[3], corners[2]];
12887            let mut doubled = 0.0f32;
12888            for i in 0..4 {
12889                let a = ring[i];
12890                let b = ring[(i + 1) % 4];
12891                doubled += a[0] * b[1] - b[0] * a[1];
12892            }
12893            let area = (doubled * 0.5).abs();
12894            let running = *area_prefix.last().expect("prefix seeded with 0.0");
12895            area_prefix.push(running + area);
12896        }
12897
12898        // Seed the mutable paint from the converted colors, so an unpatched
12899        // replay renders bit-identically to the capture frame.
12900        let paint: Vec<[f32; 4]> = shape_data.iter().map(|shape| shape.color).collect();
12901        let paint_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12902            label: Some("Replay Paint Buffer"),
12903            size: (std::mem::size_of::<[f32; 4]>() * shape_count) as u64,
12904            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
12905            mapped_at_creation: true,
12906        });
12907        paint_buffer
12908            .slice(..)
12909            .get_mapped_range_mut()
12910            .copy_from_slice(bytemuck::cast_slice(&paint));
12911        paint_buffer.unmap();
12912
12913        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
12914            label: Some("Replay Shape Bind Group"),
12915            layout: &self.shape_bind_group_layout,
12916            entries: &[
12917                wgpu::BindGroupEntry {
12918                    binding: 0,
12919                    resource: shape_buffer.as_entire_binding(),
12920                },
12921                wgpu::BindGroupEntry {
12922                    binding: 1,
12923                    resource: gradient_buffer.as_entire_binding(),
12924                },
12925                // The transform slot is selected per draw via the dynamic
12926                // offset, so retained draws sharing this capture can each
12927                // move independently.
12928                wgpu::BindGroupEntry {
12929                    binding: 2,
12930                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
12931                        buffer: &self.replay_slots.transform_buffer,
12932                        offset: 0,
12933                        size: Some(
12934                            std::num::NonZeroU64::new(
12935                                std::mem::size_of::<SimilarityTransform>() as u64
12936                            )
12937                            .expect("similarity transform is non-empty"),
12938                        ),
12939                    }),
12940                },
12941                wgpu::BindGroupEntry {
12942                    binding: 3,
12943                    resource: paint_buffer.as_entire_binding(),
12944                },
12945            ],
12946        });
12947
12948        let capture_epoch = self.replay_slots.next_capture_epoch;
12949        self.replay_slots.next_capture_epoch += 1;
12950        self.replay_slots.slots.insert(
12951            id,
12952            ReplaySlot {
12953                paint_buffer,
12954                bind_group,
12955                shape_count: shape_count as u32,
12956                paint_mirror: paint,
12957                mesh,
12958                capture_epoch,
12959                has_gradient: total_gradient_stops > 0,
12960                fill_diag_shapes,
12961                shape_aabbs,
12962                area_prefix,
12963                submitted_area_scale,
12964            },
12965        );
12966        // The staging buffers return to their scratch slots, contents
12967        // spent, capacity kept for the next capture.
12968        self.replay_capture_shape_scratch = shape_data;
12969        self.replay_capture_gradient_scratch = gradients;
12970        Some(id)
12971    }
12972
12973    /// Frees a replay slot's GPU resources and returns its id to the pool.
12974    #[cfg(not(target_arch = "wasm32"))]
12975    pub(crate) fn release_replay_slot(&mut self, id: u32) {
12976        if self.replay_slots.slots.remove(&id).is_some() {
12977            self.replay_slots.free_ids.push(id);
12978            // A cached bundle keeps references on the slot buffers it binds.
12979            // The epoch in each key already makes entries for this capture
12980            // unreachable — releases are rare (churn, retire_feed), so drop
12981            // the whole cache and free those references now rather than one
12982            // frame later through eviction.
12983            self.retained_bundle_cache.clear();
12984            // Segment death: every surface captured from this slot dies
12985            // with it.
12986            self.segment_surfaces.drop_slot(id);
12987        }
12988    }
12989
12990    /// Test/diagnostic view of the retained-segment surface cache:
12991    /// lifetime (captures, composite draws, dirty recaptures, churn
12992    /// rejections, economics rejections).
12993    #[cfg(not(target_arch = "wasm32"))]
12994    #[doc(hidden)]
12995    pub fn segment_surface_stats(&self) -> (u64, u64, u64, u64, u64) {
12996        let stats = &self.segment_surfaces.stats;
12997        (
12998            stats.captures,
12999            stats.composites,
13000            stats.dirty_recaptures,
13001            stats.rejected_churn,
13002            stats.rejected_economics,
13003        )
13004    }
13005
13006    /// Test/diagnostic view of the latched instanced-quad selection: `true`
13007    /// when this renderer's ordinary shape draws ride `vs_shape_instanced`.
13008    #[cfg(not(target_arch = "wasm32"))]
13009    #[doc(hidden)]
13010    pub fn instanced_quads_active(&self) -> bool {
13011        self.instanced_quads.is_some()
13012    }
13013
13014    /// Test/diagnostic view of retained arc meshes: how many live replay
13015    /// slots hold a mesh, out of all live slots.
13016    #[cfg(not(target_arch = "wasm32"))]
13017    #[doc(hidden)]
13018    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
13019        let meshed = self
13020            .replay_slots
13021            .slots
13022            .values()
13023            .filter(|slot| slot.mesh.is_some())
13024            .count();
13025        (meshed, self.replay_slots.slots.len())
13026    }
13027
13028    /// Test/diagnostic view of the capture size gate, summed over live slots
13029    /// that hold a mesh: (shapes meshed as arc bands, shapes meshed as
13030    /// stroked-circle rim bands, shapes on the passthrough quad).
13031    #[cfg(not(target_arch = "wasm32"))]
13032    #[doc(hidden)]
13033    pub fn replay_slot_mesh_engagement(&self) -> (usize, usize, usize) {
13034        self.replay_slots
13035            .slots
13036            .values()
13037            .filter_map(|slot| slot.mesh.as_ref())
13038            .fold((0, 0, 0), |(arcs, rims, passthrough), mesh| {
13039                (
13040                    arcs + mesh.meshed_arcs,
13041                    rims + mesh.meshed_rims,
13042                    passthrough + mesh.passthrough,
13043                )
13044            })
13045    }
13046
13047    /// Segment-surface phase 1 for one fused partition: walks the chunk's
13048    /// retained items, runs [`SegmentSurfaceCache::decide`] per item, and
13049    /// for each (re)capture acquires the surface, installs the entry and
13050    /// stages the capture similarity at its reserved transform slot. Emits
13051    /// the frame's capture jobs and per-item composite plans.
13052    #[cfg(not(target_arch = "wasm32"))]
13053    #[allow(clippy::too_many_arguments)]
13054    fn plan_segment_surfaces(
13055        &mut self,
13056        segment_surfaces: &mut SegmentSurfaceCache,
13057        ordered_items: &[(usize, SegmentDrawItem)],
13058        chunk: &SegmentDrawChunkPlan,
13059        retained_draws: &[RetainedDraw],
13060        staged_uploads: &mut StagedBufferUploads,
13061        captures: &mut Vec<SegmentCaptureJob>,
13062        composites: &mut Vec<(usize, SegmentCompositePlan)>,
13063    ) {
13064        segment_surfaces.ensure_dirty_map(
13065            self.replay_color_patches
13066                .iter()
13067                .map(|patch| (patch.slot, patch.shape_index)),
13068        );
13069        let max_texture_dim = self.effect_renderer.max_texture_dim();
13070        for batch in chunk.iter() {
13071            let SegmentBatchPlan::Retained { start, end } = batch else {
13072                continue;
13073            };
13074            for (_, item) in &ordered_items[start..end] {
13075                let SegmentDrawItem::Retained(index) = item else {
13076                    continue;
13077                };
13078                // Items past the transform-slot budget stage no transform
13079                // and draw nothing on the direct path either; leave them.
13080                if (*index as u32) >= MAX_REPLAY_SLOTS {
13081                    continue;
13082                }
13083                let Some(retained) = retained_draws.get(*index) else {
13084                    continue;
13085                };
13086                let transform = retained.transform;
13087                let (key, capture_epoch, dirty) = {
13088                    let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
13089                        continue;
13090                    };
13091                    let first = retained.first_shape.min(slot.shape_count);
13092                    let last = retained
13093                        .first_shape
13094                        .saturating_add(retained.shape_count)
13095                        .min(slot.shape_count);
13096                    if first >= last {
13097                        continue;
13098                    }
13099                    (
13100                        SegmentSurfaceKey {
13101                            slot: retained.slot,
13102                            first_shape: first,
13103                            shape_count: last - first,
13104                        },
13105                        slot.capture_epoch,
13106                        segment_surfaces.range_dirty(retained.slot, first, last),
13107                    )
13108                };
13109                let first = key.first_shape;
13110                let last = key.first_shape + key.shape_count;
13111                let slots = &self.replay_slots.slots;
13112                let decision =
13113                    segment_surfaces.decide(key, capture_epoch, dirty, transform.scale, || {
13114                        let slot = slots.get(&key.slot)?;
13115                        plan_segment_capture_geometry(slot, first, last, transform, max_texture_dim)
13116                    });
13117                let SegmentSurfaceDecision::Composite { capture } = decision else {
13118                    continue;
13119                };
13120                if let Some(plan) = capture {
13121                    let texture = segment_surfaces
13122                        .take_texture_for_recapture(&key, &plan.rect)
13123                        .unwrap_or_else(|| {
13124                            let device = self.device.clone();
13125                            self.effect_renderer.acquire_offscreen(
13126                                &device,
13127                                plan.rect.width,
13128                                plan.rect.height,
13129                                Some(&self.frame_stats),
13130                            )
13131                        });
13132                    segment_surfaces.install_entry(
13133                        key,
13134                        capture_epoch,
13135                        transform.center,
13136                        transform.rot,
13137                        transform.scale,
13138                        plan.rect,
13139                        texture,
13140                    );
13141                    // The capture renders the span under ITS OWN current
13142                    // similarity (retained paint selected), staged at the
13143                    // reserved slot past every per-draw transform.
13144                    staged_uploads.stage_at(
13145                        UploadTarget::ReplayTransform,
13146                        (MAX_REPLAY_SLOTS + plan.index) as u64 * REPLAY_TRANSFORM_STRIDE,
13147                        bytemuck::bytes_of(&transform.with_retained_paint()),
13148                    );
13149                    // The capture viewport uniforms are written directly:
13150                    // the cache is moved out of `self` for the partition,
13151                    // so its buffer cannot ride the staged-upload flush
13152                    // (which resolves targets on `self`). Queue writes
13153                    // execute before any later-submitted command buffer —
13154                    // exactly the capture pass's ordering need.
13155                    let uniforms = Self::viewport_uniforms(ViewportUniformParams {
13156                        width: plan.rect.width,
13157                        height: plan.rect.height,
13158                        offset: plan.rect.origin,
13159                    });
13160                    let device = self.device.clone();
13161                    let capture_uniforms =
13162                        segment_surfaces.capture_uniforms(&device, &self.uniform_bind_group_layout);
13163                    let upload_stats = self.frame_graph_executor.upload_buffer(
13164                        &self.queue,
13165                        &capture_uniforms.buffer,
13166                        plan.index as u64 * SEGMENT_CAPTURE_UNIFORM_STRIDE,
13167                        bytemuck::bytes_of(&uniforms),
13168                    );
13169                    self.frame_stats.record_command_stats(upload_stats);
13170                    captures.push(SegmentCaptureJob {
13171                        key,
13172                        first,
13173                        last,
13174                        capture_index: plan.index,
13175                    });
13176                    // Fresh capture: the effective transform is identity by
13177                    // construction, snapped exact so the composite is a 1:1
13178                    // texel mapping.
13179                    composites.push((
13180                        *index,
13181                        SegmentCompositePlan {
13182                            key,
13183                            dest_quad: segment_identity_quad(&plan.rect),
13184                            inverse: segment_identity_inverse(&plan.rect),
13185                            identity: true,
13186                        },
13187                    ));
13188                } else {
13189                    let Some(entry) = segment_surfaces.entry(&key) else {
13190                        continue;
13191                    };
13192                    let t_now =
13193                        Affine2::from_similarity(transform.center, transform.rot, transform.scale);
13194                    let t_cap =
13195                        Affine2::from_similarity(entry.cap_center, entry.cap_rot, entry.cap_scale);
13196                    let Some(cap_inverse) = t_cap.invert() else {
13197                        segment_surfaces.remove(&key);
13198                        continue;
13199                    };
13200                    let effective = t_now.compose(&cap_inverse);
13201                    let rect = entry.rect;
13202                    let plan = if effective.is_identity_for_sampling() {
13203                        // Snap away the compose/invert float noise so the
13204                        // identity frame is a texel-exact mapping.
13205                        SegmentCompositePlan {
13206                            key,
13207                            dest_quad: segment_identity_quad(&rect),
13208                            inverse: segment_identity_inverse(&rect),
13209                            identity: true,
13210                        }
13211                    } else {
13212                        let Some(inverse) = effective.invert() else {
13213                            segment_surfaces.remove(&key);
13214                            continue;
13215                        };
13216                        SegmentCompositePlan {
13217                            key,
13218                            dest_quad: segment_identity_quad(&rect).map(|c| effective.apply(c)),
13219                            inverse: [
13220                                [
13221                                    inverse.l[0][0],
13222                                    inverse.l[0][1],
13223                                    inverse.t[0] - rect.origin[0],
13224                                ],
13225                                [
13226                                    inverse.l[1][0],
13227                                    inverse.l[1][1],
13228                                    inverse.t[1] - rect.origin[1],
13229                                ],
13230                                [0.0, 0.0, 1.0],
13231                            ],
13232                            identity: false,
13233                        }
13234                    };
13235                    composites.push((*index, plan));
13236                }
13237            }
13238        }
13239    }
13240
13241    /// Draws one retained replay batch — `retained`'s shape range of its
13242    /// slot's capture, under the transform staged for this draw's index (see
13243    /// the retained arms of the segment paths).
13244    #[cfg(not(target_arch = "wasm32"))]
13245    fn draw_retained_batch(
13246        &self,
13247        render_pass: &mut wgpu::RenderPass<'_>,
13248        retained: &RetainedDraw,
13249        retained_index: usize,
13250        width: u32,
13251        height: u32,
13252    ) {
13253        let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
13254            return;
13255        };
13256        if retained_index as u32 >= MAX_REPLAY_SLOTS {
13257            return;
13258        }
13259        let first = retained.first_shape.min(slot.shape_count);
13260        let last = retained
13261            .first_shape
13262            .saturating_add(retained.shape_count)
13263            .min(slot.shape_count);
13264        if first >= last {
13265            return;
13266        }
13267        if fill_area_diag_enabled() {
13268            self.fill_area_diag.add_retained_range(
13269                &slot.fill_diag_shapes,
13270                first,
13271                last,
13272                &retained.transform,
13273            );
13274        }
13275        self.frame_stats.bump_shapes();
13276        render_pass.set_scissor_rect(0, 0, width, height);
13277        let draws =
13278            self.encode_retained_op(
13279                slot,
13280                first,
13281                last,
13282                retained_index as u32,
13283                &mut |cmd| match cmd {
13284                    RetainedCmd::Pipeline(pipeline) => render_pass.set_pipeline(pipeline),
13285                    RetainedCmd::Uniforms(group) => render_pass.set_bind_group(0, group, &[]),
13286                    RetainedCmd::SlotBindings(group, offset) => {
13287                        render_pass.set_bind_group(1, group, &[offset])
13288                    }
13289                    RetainedCmd::MeshVertices(buffer) => {
13290                        render_pass.set_vertex_buffer(0, buffer.slice(..))
13291                    }
13292                    RetainedCmd::Index(buffer, format) => {
13293                        render_pass.set_index_buffer(buffer.slice(..), format)
13294                    }
13295                    RetainedCmd::Draw(vertices) => render_pass.draw(vertices, 0..1),
13296                    RetainedCmd::DrawIndexed(indices, instances) => {
13297                        render_pass.draw_indexed(indices, 0, instances)
13298                    }
13299                },
13300            );
13301        self.frame_stats.add_draw_calls(draws);
13302    }
13303
13304    /// Emits one retained op's draw commands into `sink` — the SINGLE
13305    /// encoding shared by the direct pass path
13306    /// ([`Self::draw_retained_batch`]) and the cached-bundle path
13307    /// ([`Self::build_retained_bundle`]), so the two cannot drift. Returns
13308    /// the number of draw calls issued.
13309    ///
13310    /// A slot without a mesh draws its whole range through the latched
13311    /// instanced-quad pipeline (four vertex executions per shape, shape
13312    /// index from the instance index), else the plain six-vertex expansion.
13313    /// A slot WITH a mesh alternates along the range: maximal runs of
13314    /// meshed shapes (non-empty [`ReplaySlotMesh::index_prefix`] ranges)
13315    /// draw their band triangles through the mesh pipeline in one
13316    /// `draw_indexed` each, and every other run STAYS instanced — routing
13317    /// passthrough quads through per-vertex mesh attributes instead was the
13318    /// S3 loss the watch measured (see [`arc_mesh_enabled`]). The walk
13319    /// preserves exact shape order, so z is untouched, and every pipeline
13320    /// involved blends SrcOver. Bind groups are set once up front: all the
13321    /// pipelines share the uniform + shape bind-group layouts, so they stay
13322    /// bound across pipeline switches; the mesh vertex buffer likewise
13323    /// stays bound across instanced stretches because the instanced
13324    /// pipeline declares no vertex buffers — only the index buffer
13325    /// alternates. The alternation is a pure function of the capture-fixed
13326    /// `index_prefix` and `first..last`, which is what lets
13327    /// [`RetainedBundleOpKey`] pin the encoding by capture epoch and range
13328    /// alone.
13329    #[cfg(not(target_arch = "wasm32"))]
13330    fn encode_retained_op<'r>(
13331        &'r self,
13332        slot: &'r ReplaySlot,
13333        first: u32,
13334        last: u32,
13335        retained_index: u32,
13336        sink: &mut impl FnMut(RetainedCmd<'r>),
13337    ) -> u32 {
13338        sink(RetainedCmd::Uniforms(&self.uniform_bind_group));
13339        sink(RetainedCmd::SlotBindings(
13340            &slot.bind_group,
13341            retained_index * REPLAY_TRANSFORM_STRIDE as u32,
13342        ));
13343        let Some(mesh) = slot.mesh.as_ref() else {
13344            self.encode_retained_instanced(slot, first..last, sink);
13345            return 1;
13346        };
13347        sink(RetainedCmd::MeshVertices(&mesh.vertex_buffer));
13348        let prefix = &mesh.index_prefix;
13349        let meshed_at = |shape: u32| prefix[shape as usize + 1] > prefix[shape as usize];
13350        let mut draws = 0;
13351        let mut cursor = first;
13352        while cursor < last {
13353            let run_meshed = meshed_at(cursor);
13354            let mut end = cursor + 1;
13355            while end < last && meshed_at(end) == run_meshed {
13356                end += 1;
13357            }
13358            if run_meshed {
13359                sink(RetainedCmd::Pipeline(self.mesh_pipeline()));
13360                sink(RetainedCmd::Index(
13361                    &mesh.index_buffer,
13362                    wgpu::IndexFormat::Uint32,
13363                ));
13364                sink(RetainedCmd::DrawIndexed(
13365                    prefix[cursor as usize]..prefix[end as usize],
13366                    0..1,
13367                ));
13368            } else {
13369                self.encode_retained_instanced(slot, cursor..end, sink);
13370            }
13371            draws += 1;
13372            cursor = end;
13373        }
13374        draws
13375    }
13376
13377    /// One instanced-quad (or, unlatched, six-vertex expansion) draw over a
13378    /// contiguous shape range of a retained slot — the passthrough arm of
13379    /// [`Self::encode_retained_op`]. The solid-vs-gradient pipeline choice
13380    /// is fixed per capture, so a cached bundle can never encode a stale
13381    /// pipeline for a slot id (the op key carries the capture epoch).
13382    #[cfg(not(target_arch = "wasm32"))]
13383    fn encode_retained_instanced<'r>(
13384        &'r self,
13385        slot: &ReplaySlot,
13386        range: Range<u32>,
13387        sink: &mut impl FnMut(RetainedCmd<'r>),
13388    ) {
13389        match &self.instanced_quads {
13390            Some(instanced) => {
13391                if slot.has_gradient {
13392                    sink(RetainedCmd::Pipeline(
13393                        self.instanced_pipeline(instanced, BlendMode::SrcOver),
13394                    ));
13395                } else {
13396                    sink(RetainedCmd::Pipeline(
13397                        self.instanced_pipeline_solid(instanced),
13398                    ));
13399                }
13400                sink(RetainedCmd::Index(
13401                    &instanced.index_buffer,
13402                    wgpu::IndexFormat::Uint16,
13403                ));
13404                sink(RetainedCmd::DrawIndexed(0..6, range));
13405            }
13406            None => {
13407                if slot.has_gradient {
13408                    sink(RetainedCmd::Pipeline(
13409                        self.shape_pipeline(BlendMode::SrcOver),
13410                    ));
13411                } else {
13412                    sink(RetainedCmd::Pipeline(self.shape_pipeline_solid()));
13413                }
13414                sink(RetainedCmd::Draw(range.start * 6..range.end * 6));
13415            }
13416        }
13417    }
13418
13419    /// Key of the retained stretch at `item_range`: one op key per resolved
13420    /// retained item, in draw order, carrying exactly the state that decides
13421    /// the commands [`Self::draw_retained_batch`] would encode for it —
13422    /// clamped range, dynamic-offset index, whether the mesh-vs-instanced
13423    /// draw walk runs, and the slot's capture epoch, which pins the walk's
13424    /// stretch structure (`None` while the slot is absent, when the op
13425    /// draws nothing on the direct path too).
13426    #[cfg(not(target_arch = "wasm32"))]
13427    fn retained_bundle_key(
13428        &self,
13429        ordered_items: &[(usize, SegmentDrawItem)],
13430        retained_draws: &[RetainedDraw],
13431        item_range: Range<usize>,
13432    ) -> RetainedBundleKey {
13433        let mut ops = Vec::with_capacity(item_range.len());
13434        for (_, item) in &ordered_items[item_range] {
13435            let SegmentDrawItem::Retained(index) = item else {
13436                continue;
13437            };
13438            let Some(retained) = retained_draws.get(*index) else {
13439                continue;
13440            };
13441            let slot = self.replay_slots.slots.get(&retained.slot);
13442            let (first, last) = match slot {
13443                Some(slot) => (
13444                    retained.first_shape.min(slot.shape_count),
13445                    retained
13446                        .first_shape
13447                        .saturating_add(retained.shape_count)
13448                        .min(slot.shape_count),
13449                ),
13450                None => (
13451                    retained.first_shape,
13452                    retained.first_shape.saturating_add(retained.shape_count),
13453                ),
13454            };
13455            ops.push(RetainedBundleOpKey {
13456                slot: retained.slot,
13457                capture_epoch: slot.map(|slot| slot.capture_epoch),
13458                first,
13459                last,
13460                retained_index: *index as u32,
13461                has_mesh: slot.is_some_and(|slot| slot.mesh.is_some())
13462                    && self.shape_batch_limits.storage,
13463            });
13464        }
13465        RetainedBundleKey {
13466            depth: self.pass_depth(),
13467            ops,
13468        }
13469    }
13470
13471    /// Encodes `key`'s stretch into a render bundle: the IDENTICAL command
13472    /// sequence [`Self::draw_retained_batch`] issues on the pass, minus the
13473    /// scissor reset (bundles cannot set scissor; the caller sets the same
13474    /// full-target scissor on the pass before executing). Must only be
13475    /// called with a key built this frame, so every op with an epoch still
13476    /// resolves to its slot.
13477    #[cfg(not(target_arch = "wasm32"))]
13478    fn build_retained_bundle(&self, key: &RetainedBundleKey) -> wgpu::RenderBundle {
13479        let mut encoder =
13480            self.device
13481                .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor {
13482                    label: Some("Retained Stretch Bundle"),
13483                    // Every fused-pass target — the swapchain, screenshot
13484                    // textures, pooled layer surfaces — is created with the
13485                    // renderer's one surface format.
13486                    color_formats: &[Some(self.surface_format)],
13487                    // A display-clip culled pass carries the depth
13488                    // attachment; the bundle only reads it (content
13489                    // pipelines test `Less`, write off), hence read-only on
13490                    // both aspects.
13491                    depth_stencil: key.depth.then_some(wgpu::RenderBundleDepthStencil {
13492                        format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
13493                        depth_read_only: true,
13494                        stencil_read_only: true,
13495                    }),
13496                    sample_count: 1,
13497                    multiview: None,
13498                });
13499        for op in &key.ops {
13500            if op.capture_epoch.is_none()
13501                || op.retained_index >= MAX_REPLAY_SLOTS
13502                || op.first >= op.last
13503            {
13504                continue;
13505            }
13506            let Some(slot) = self.replay_slots.slots.get(&op.slot) else {
13507                continue;
13508            };
13509            // The latched instanced selection is a per-renderer constant,
13510            // so it needs no place in `RetainedBundleOpKey` — every cached
13511            // bundle in this renderer's lifetime encodes the same choice
13512            // the direct path makes.
13513            self.encode_retained_op(
13514                slot,
13515                op.first,
13516                op.last,
13517                op.retained_index,
13518                &mut |cmd| match cmd {
13519                    RetainedCmd::Pipeline(pipeline) => encoder.set_pipeline(pipeline),
13520                    RetainedCmd::Uniforms(group) => encoder.set_bind_group(0, group, &[]),
13521                    RetainedCmd::SlotBindings(group, offset) => {
13522                        encoder.set_bind_group(1, group, &[offset])
13523                    }
13524                    RetainedCmd::MeshVertices(buffer) => {
13525                        encoder.set_vertex_buffer(0, buffer.slice(..))
13526                    }
13527                    RetainedCmd::Index(buffer, format) => {
13528                        encoder.set_index_buffer(buffer.slice(..), format)
13529                    }
13530                    RetainedCmd::Draw(vertices) => encoder.draw(vertices, 0..1),
13531                    RetainedCmd::DrawIndexed(indices, instances) => {
13532                        encoder.draw_indexed(indices, 0, instances)
13533                    }
13534                },
13535            );
13536        }
13537        encoder.finish(&wgpu::RenderBundleDescriptor {
13538            label: Some("Retained Stretch Bundle"),
13539        })
13540    }
13541
13542    /// Draws one maximal consecutive retained stretch through the bundle
13543    /// cache: key the stretch, rebuild on any mismatch (recapture, reorder,
13544    /// range or count change, slot release), then execute the cached bundle.
13545    /// Replays byte-identical commands to the per-op direct path.
13546    /// `stage_replay_patches` and the per-frame transform staging stay in
13547    /// the prepare arms, untouched — bundles bind buffers whose contents are
13548    /// read at execution.
13549    #[cfg(not(target_arch = "wasm32"))]
13550    fn draw_retained_stretch_bundled(
13551        &mut self,
13552        render_pass: &mut wgpu::RenderPass<'_>,
13553        ordered_items: &[(usize, SegmentDrawItem)],
13554        retained_draws: &[RetainedDraw],
13555        item_range: Range<usize>,
13556        width: u32,
13557        height: u32,
13558    ) {
13559        let key = self.retained_bundle_key(ordered_items, retained_draws, item_range);
13560        if !self.retained_bundle_cache.hit(&key) {
13561            let bundle = self.build_retained_bundle(&key);
13562            self.retained_bundle_cache.insert(key.clone(), bundle);
13563        }
13564        // Mirror the direct path's per-op stats for every op the bundle
13565        // draws, so bundling is invisible to the frame counters.
13566        for op in &key.ops {
13567            if op.capture_epoch.is_some()
13568                && op.retained_index < MAX_REPLAY_SLOTS
13569                && op.first < op.last
13570            {
13571                self.frame_stats.bump_shapes();
13572                self.frame_stats.add_draw_calls(1);
13573                if fill_area_diag_enabled() {
13574                    // Mirror the direct path's fill accounting per bundled op.
13575                    let slot = self.replay_slots.slots.get(&op.slot);
13576                    let retained = retained_draws.get(op.retained_index as usize);
13577                    if let (Some(slot), Some(retained)) = (slot, retained) {
13578                        self.fill_area_diag.add_retained_range(
13579                            &slot.fill_diag_shapes,
13580                            op.first,
13581                            op.last,
13582                            &retained.transform,
13583                        );
13584                    }
13585                }
13586            }
13587        }
13588        // Bundles inherit the pass scissor: set the same full-target rect
13589        // the direct path sets before every retained draw. Executing the
13590        // bundle then resets pipeline/bind/vertex state, which is harmless —
13591        // every following fused arm re-binds its own.
13592        render_pass.set_scissor_rect(0, 0, width, height);
13593        if let Some(bundle) = self.retained_bundle_cache.get(&key) {
13594            render_pass.execute_bundles(std::iter::once(bundle));
13595        }
13596    }
13597
13598    /// Test/diagnostic view of the retained bundle cache: lifetime
13599    /// (rebuilds, cached executes).
13600    #[cfg(not(target_arch = "wasm32"))]
13601    #[doc(hidden)]
13602    pub fn retained_bundle_stats(&self) -> (u64, u64) {
13603        self.retained_bundle_cache.stats()
13604    }
13605
13606    /// Test/diagnostic view of the transient rim mesh path: lifetime count
13607    /// of rims drawn as band meshes instead of full bounding quads.
13608    #[cfg(not(target_arch = "wasm32"))]
13609    #[doc(hidden)]
13610    pub fn rim_meshes_emitted(&self) -> u64 {
13611        self.rim_meshes_emitted
13612    }
13613
13614    /// Test/diagnostic view of the device-error sentry: lifetime
13615    /// uncaptured wgpu errors recorded on this renderer's device
13616    /// (`CRANPOSE_SURVIVE_GPU_ERRORS` kill switch).
13617    #[doc(hidden)]
13618    pub fn device_error_count(&self) -> u64 {
13619        self.device_errors.error_count()
13620    }
13621
13622    /// Test/diagnostic view of the static leading-span cache: lifetime
13623    /// (hits, recaptures).
13624    ///
13625    /// Gated like the cache it reads and like every sibling diagnostic here:
13626    /// `static_span` does not exist on wasm, so an ungated accessor compiles
13627    /// everywhere except the one target nothing in `cargo test` builds.
13628    #[cfg(not(target_arch = "wasm32"))]
13629    #[doc(hidden)]
13630    pub fn static_span_stats(&self) -> (u64, u64) {
13631        (self.static_span.hits, self.static_span.recaptures)
13632    }
13633
13634    /// Uploads the region of the transient rim mesh scratch appended since
13635    /// the previous upload — chunks later in the frame append after regions
13636    /// whose draws are already encoded, so earlier bytes are never
13637    /// rewritten and the fixed-capacity buffers are never recreated
13638    /// mid-frame. The executor-owned upload lands at the head of the next
13639    /// submit, which is where this frame's passes execute.
13640    #[cfg(not(target_arch = "wasm32"))]
13641    fn upload_transient_rim_meshes(&mut self) {
13642        let device = self.device.clone();
13643        let mut upload_stats = crate::frame_graph::FrameCommandStats::default();
13644        if self.rim_mesh_vertices.len() > self.rim_mesh_uploaded_vertices {
13645            let vertex_buffer = self.rim_mesh_vertex_buffer.get_or_insert_with(|| {
13646                device.create_buffer(&wgpu::BufferDescriptor {
13647                    label: Some("Rim Mesh Vertex Buffer"),
13648                    size: (RIM_MESH_VERTEX_CAPACITY * std::mem::size_of::<MeshVertex>()) as u64,
13649                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
13650                    mapped_at_creation: false,
13651                })
13652            });
13653            upload_stats.upload_bytes += self
13654                .frame_graph_executor
13655                .upload_buffer(
13656                    &self.queue,
13657                    vertex_buffer,
13658                    (self.rim_mesh_uploaded_vertices * std::mem::size_of::<MeshVertex>()) as u64,
13659                    bytemuck::cast_slice(
13660                        &self.rim_mesh_vertices[self.rim_mesh_uploaded_vertices..],
13661                    ),
13662                )
13663                .upload_bytes;
13664            self.rim_mesh_uploaded_vertices = self.rim_mesh_vertices.len();
13665        }
13666        if self.rim_mesh_indices.len() > self.rim_mesh_uploaded_indices {
13667            let index_buffer = self.rim_mesh_index_buffer.get_or_insert_with(|| {
13668                device.create_buffer(&wgpu::BufferDescriptor {
13669                    label: Some("Rim Mesh Index Buffer"),
13670                    size: (RIM_MESH_INDEX_CAPACITY * std::mem::size_of::<u32>()) as u64,
13671                    usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
13672                    mapped_at_creation: false,
13673                })
13674            });
13675            upload_stats.upload_bytes += self
13676                .frame_graph_executor
13677                .upload_buffer(
13678                    &self.queue,
13679                    index_buffer,
13680                    (self.rim_mesh_uploaded_indices * std::mem::size_of::<u32>()) as u64,
13681                    bytemuck::cast_slice(&self.rim_mesh_indices[self.rim_mesh_uploaded_indices..]),
13682                )
13683                .upload_bytes;
13684            self.rim_mesh_uploaded_indices = self.rim_mesh_indices.len();
13685        }
13686        if upload_stats.upload_bytes > 0 {
13687            self.frame_stats.record_command_stats(upload_stats);
13688        }
13689    }
13690
13691    fn draw_prepared_shapes(
13692        &self,
13693        render_pass: &mut wgpu::RenderPass<'_>,
13694        blend_mode: BlendMode,
13695        batch: PreparedShapeBatch,
13696        width: u32,
13697        height: u32,
13698        rims: &[RimDraw],
13699    ) {
13700        #[cfg(target_arch = "wasm32")]
13701        let _ = rims;
13702        self.frame_stats.bump_shapes();
13703        self.frame_stats.add_draw_calls(1);
13704        render_pass.set_scissor_rect(0, 0, width, height);
13705        #[cfg(not(target_arch = "wasm32"))]
13706        let (uniform_bind_group, shape_buffers) = (&self.uniform_bind_group, &self.shape_buffers);
13707        #[cfg(target_arch = "wasm32")]
13708        let (uniform_bind_group, shape_buffers) = (
13709            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
13710            &self.wasm_shape_batches[batch.shape_slot],
13711        );
13712        // Latched instanced path (storage mode only): one instance per
13713        // shape, four vertices through the static quad index buffer —
13714        // identical triangles, identical bind groups, still one draw call.
13715        // The uniform/WebGL path never latches it and stays on `vs_main`.
13716        #[cfg(not(target_arch = "wasm32"))]
13717        if let Some(instanced) = &self.instanced_quads {
13718            assert!(
13719                batch.vertex_start.is_multiple_of(6) && batch.vertex_count.is_multiple_of(6),
13720                "shape batches are whole shapes: vertex range {}..+{} must be \
13721                 six-aligned to convert to an instance range",
13722                batch.vertex_start,
13723                batch.vertex_count,
13724            );
13725            // The same selection the preamble and every post-rim restore
13726            // make — factored so the two sites cannot disagree.
13727            let set_instanced_pipeline = |render_pass: &mut wgpu::RenderPass<'_>| {
13728                if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
13729                    render_pass.set_pipeline(self.instanced_pipeline_solid(instanced));
13730                } else {
13731                    render_pass.set_pipeline(self.instanced_pipeline(instanced, blend_mode));
13732                }
13733            };
13734            set_instanced_pipeline(render_pass);
13735            render_pass.set_bind_group(0, uniform_bind_group, &[]);
13736            // Dynamic offset 0: ordinary batches read the identity
13737            // similarity transform.
13738            render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
13739            let first_shape = batch.vertex_start / 6;
13740            let shape_count = batch.vertex_count / 6;
13741            render_pass
13742                .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
13743            // Rims arrive in ascending shape order (step 4 walks the fused
13744            // upload front to back), so this batch's rims are one contiguous
13745            // run of the slice.
13746            debug_assert!(
13747                rims.windows(2)
13748                    .all(|pair| pair[0].shape_index < pair[1].shape_index),
13749                "rim draws must arrive in ascending shape order"
13750            );
13751            let rim_start = rims.partition_point(|rim| rim.shape_index < first_shape);
13752            let rim_end = rims.partition_point(|rim| rim.shape_index < first_shape + shape_count);
13753            let batch_rims = &rims[rim_start..rim_end];
13754            let rim_buffers = match (&self.rim_mesh_vertex_buffer, &self.rim_mesh_index_buffer) {
13755                (Some(vertex_buffer), Some(index_buffer)) if !batch_rims.is_empty() => {
13756                    Some((vertex_buffer, index_buffer))
13757                }
13758                _ => None,
13759            };
13760            let Some((rim_vertex_buffer, rim_index_buffer)) = rim_buffers else {
13761                render_pass.draw_indexed(0..6, 0, first_shape..first_shape + shape_count);
13762                return;
13763            };
13764            // Split the instance range around each rim, in exact shape
13765            // order, so z is untouched: instances before the rim, the rim's
13766            // band mesh through `vs_mesh`, instances after. Bind groups
13767            // persist across `set_pipeline` because the mesh and instanced
13768            // pipelines share identical bind group layouts (uniform layout +
13769            // shape layout, dynamic similarity offset included), so only the
13770            // pipeline and index/vertex buffers are re-set per switch.
13771            let mut draw_calls = 0u32;
13772            let mut cursor = first_shape;
13773            for rim in batch_rims {
13774                if cursor < rim.shape_index {
13775                    render_pass.draw_indexed(0..6, 0, cursor..rim.shape_index);
13776                    draw_calls += 1;
13777                }
13778                render_pass.set_pipeline(self.mesh_pipeline());
13779                render_pass.set_vertex_buffer(0, rim_vertex_buffer.slice(..));
13780                render_pass.set_index_buffer(rim_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
13781                render_pass.draw_indexed(
13782                    rim.first_index..rim.first_index + rim.index_count,
13783                    0,
13784                    0..1,
13785                );
13786                draw_calls += 1;
13787                set_instanced_pipeline(render_pass);
13788                render_pass
13789                    .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
13790                cursor = rim.shape_index + 1;
13791            }
13792            if cursor < first_shape + shape_count {
13793                render_pass.draw_indexed(0..6, 0, cursor..first_shape + shape_count);
13794                draw_calls += 1;
13795            }
13796            // One draw call was already counted at the top of the fn.
13797            self.frame_stats
13798                .add_draw_calls(draw_calls.saturating_sub(1));
13799            return;
13800        }
13801        if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
13802            render_pass.set_pipeline(self.shape_pipeline_solid());
13803        } else {
13804            render_pass.set_pipeline(self.shape_pipeline(blend_mode));
13805        }
13806        render_pass.set_bind_group(0, uniform_bind_group, &[]);
13807        // Dynamic offset 0: ordinary batches read the identity similarity
13808        // transform.
13809        render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
13810        // Six unindexed vertices per shape; `vs_main` derives the corner from
13811        // `vertex_index` and pulls the quad out of `ShapeData`.
13812        render_pass.draw(
13813            batch.vertex_start..batch.vertex_start + batch.vertex_count,
13814            0..1,
13815        );
13816    }
13817
13818    /// Stage shape buffer writes and record a shape render pass onto the
13819    /// provided encoder. The caller is responsible for submitting.
13820    #[allow(clippy::too_many_arguments)]
13821    fn encode_shapes_pass<'a, I, C: FrameCommandRecorder>(
13822        &mut self,
13823        frame_encoder: &mut C,
13824        target_view: &wgpu::TextureView,
13825        layer_shapes: I,
13826        brushes: &[Brush],
13827        blend_mode: BlendMode,
13828        width: u32,
13829        height: u32,
13830        root_scale: f32,
13831        load_op: wgpu::LoadOp<wgpu::Color>,
13832        viewport_offset: [f32; 2],
13833    ) where
13834        I: Iterator<Item = &'a DrawShape>,
13835    {
13836        let mut staged_uploads = self.take_staged_uploads();
13837        let viewport = ViewportUniformParams {
13838            width,
13839            height,
13840            offset: viewport_offset,
13841        };
13842        let viewport_rect_logical = viewport_rect_in_logical(viewport, root_scale);
13843        let Some(batch) = self.prepare_shapes_batch(
13844            layer_shapes.filter(|shape| match viewport_rect_logical {
13845                Some(rect) => shape_draw_is_visible_in_rect(shape, rect, root_scale),
13846                None => false,
13847            }),
13848            brushes,
13849            root_scale,
13850            viewport,
13851            &mut staged_uploads,
13852        ) else {
13853            self.restore_staged_uploads(staged_uploads);
13854            return;
13855        };
13856        let upload_offset =
13857            frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
13858        self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
13859        self.restore_staged_uploads(staged_uploads);
13860        let mut render_pass =
13861            frame_encoder
13862                .encoder()
13863                .begin_render_pass(&wgpu::RenderPassDescriptor {
13864                    label: Some("Shape Pass"),
13865                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
13866                        view: target_view,
13867                        resolve_target: None,
13868                        depth_slice: None,
13869                        ops: wgpu::Operations {
13870                            load: load_op,
13871                            store: wgpu::StoreOp::Store,
13872                        },
13873                    })],
13874                    depth_stencil_attachment: None,
13875                    timestamp_writes: None,
13876                    occlusion_query_set: None,
13877                    multiview_mask: None,
13878                });
13879        self.draw_prepared_shapes(&mut render_pass, blend_mode, batch, width, height, &[]);
13880    }
13881
13882    fn draw_prepared_images(
13883        &mut self,
13884        render_pass: &mut wgpu::RenderPass<'_>,
13885        batch: &PreparedImageBatch,
13886        blend_mode: BlendMode,
13887    ) -> Result<(), String> {
13888        if batch.cmds.is_empty() {
13889            return Ok(());
13890        }
13891        self.frame_stats.bump_images();
13892        self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
13893        render_pass.set_pipeline(self.image_pipeline(blend_mode));
13894        #[cfg(not(target_arch = "wasm32"))]
13895        let (uniform_bind_group, vertex_buffer, index_buffer) = (
13896            &self.uniform_bind_group,
13897            &self.image_vertex_buffer,
13898            &self.image_index_buffer,
13899        );
13900        #[cfg(target_arch = "wasm32")]
13901        let (uniform_bind_group, vertex_buffer, index_buffer) = (
13902            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
13903            &self.wasm_image_batches[batch.image_slot].vertex_buffer,
13904            &self.wasm_image_batches[batch.image_slot].index_buffer,
13905        );
13906        render_pass.set_bind_group(0, uniform_bind_group, &[]);
13907        render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
13908        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
13909
13910        for cmd in &batch.cmds {
13911            let (sx, sy, sw, sh) = cmd.scissor;
13912            render_pass.set_scissor_rect(sx, sy, sw, sh);
13913
13914            let cached = self
13915                .image_texture_cache
13916                .get(&cmd.image_id)
13917                .ok_or_else(|| "image texture missing from cache".to_string())?;
13918            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
13919            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
13920        }
13921        Ok(())
13922    }
13923
13924    fn draw_prepared_glyphs(
13925        &mut self,
13926        render_pass: &mut wgpu::RenderPass<'_>,
13927        batch: &PreparedGlyphBatch,
13928    ) -> Result<(), String> {
13929        if batch.cmds.is_empty() {
13930            return Ok(());
13931        }
13932        #[cfg(not(target_arch = "wasm32"))]
13933        {
13934            self.draw_native_prepared_glyph_cmd_range(
13935                render_pass,
13936                &batch.cmds,
13937                0..batch.cmds.len(),
13938            )?;
13939        }
13940        #[cfg(target_arch = "wasm32")]
13941        {
13942            self.frame_stats.bump_text();
13943            self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
13944            render_pass.set_pipeline(self.glyph_atlas_pipeline());
13945            let (uniform_bind_group, vertex_buffer, index_buffer) = (
13946                &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
13947                &self.wasm_image_batches[batch.image_slot].vertex_buffer,
13948                &self.wasm_image_batches[batch.image_slot].index_buffer,
13949            );
13950            render_pass.set_bind_group(0, uniform_bind_group, &[]);
13951            render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
13952            render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
13953            render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
13954
13955            for cmd in &batch.cmds {
13956                let (sx, sy, sw, sh) = cmd.scissor;
13957                render_pass.set_scissor_rect(sx, sy, sw, sh);
13958                let GlyphDrawSource::Shared {
13959                    index_start,
13960                    index_count,
13961                } = cmd.source;
13962                render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
13963            }
13964        }
13965        Ok(())
13966    }
13967
13968    #[cfg(not(target_arch = "wasm32"))]
13969    fn draw_native_prepared_image_cmd_range(
13970        &mut self,
13971        render_pass: &mut wgpu::RenderPass<'_>,
13972        cmds: &[ImageDrawCmd],
13973        cmd_range: Range<usize>,
13974        blend_mode: BlendMode,
13975    ) -> Result<(), String> {
13976        let Some(cmds) = cmds.get(cmd_range) else {
13977            return Err("image command range is outside the prepared command buffer".to_string());
13978        };
13979        if cmds.is_empty() {
13980            return Ok(());
13981        }
13982
13983        self.frame_stats.bump_images();
13984        self.frame_stats.add_draw_calls(cmds.len() as u32);
13985        render_pass.set_pipeline(self.image_pipeline(blend_mode));
13986        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
13987        render_pass.set_index_buffer(self.image_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
13988        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
13989
13990        for cmd in cmds {
13991            let (sx, sy, sw, sh) = cmd.scissor;
13992            render_pass.set_scissor_rect(sx, sy, sw, sh);
13993
13994            let cached = self
13995                .image_texture_cache
13996                .get(&cmd.image_id)
13997                .ok_or_else(|| "image texture missing from cache".to_string())?;
13998            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
13999            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
14000        }
14001        Ok(())
14002    }
14003
14004    #[cfg(not(target_arch = "wasm32"))]
14005    fn draw_native_prepared_glyph_cmd_range(
14006        &mut self,
14007        render_pass: &mut wgpu::RenderPass<'_>,
14008        cmds: &[GlyphDrawCmd],
14009        cmd_range: Range<usize>,
14010    ) -> Result<(), String> {
14011        let Some(cmds) = cmds.get(cmd_range) else {
14012            return Err("glyph command range is outside the prepared command buffer".to_string());
14013        };
14014        if cmds.is_empty() {
14015            return Ok(());
14016        }
14017
14018        self.frame_stats.bump_text();
14019        self.frame_stats.add_draw_calls(cmds.len() as u32);
14020
14021        let mut shared_buffers_bound = false;
14022        let mut retained_pipeline_bound = false;
14023        for cmd in cmds {
14024            let (sx, sy, sw, sh) = cmd.scissor;
14025            render_pass.set_scissor_rect(sx, sy, sw, sh);
14026            match cmd.source {
14027                GlyphDrawSource::Shared {
14028                    index_start,
14029                    index_count,
14030                } => {
14031                    if retained_pipeline_bound || !shared_buffers_bound {
14032                        render_pass.set_pipeline(self.glyph_atlas_pipeline());
14033                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
14034                        retained_pipeline_bound = false;
14035                    }
14036                    if !shared_buffers_bound {
14037                        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
14038                        render_pass.set_index_buffer(
14039                            self.image_index_buffer.slice(..),
14040                            wgpu::IndexFormat::Uint32,
14041                        );
14042                        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
14043                        shared_buffers_bound = true;
14044                    }
14045                    render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
14046                }
14047                GlyphDrawSource::Retained {
14048                    cache_key,
14049                    uniform_slot,
14050                } => {
14051                    shared_buffers_bound = false;
14052                    if !retained_pipeline_bound {
14053                        render_pass.set_pipeline(self.retained_glyph_atlas_pipeline());
14054                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
14055                        retained_pipeline_bound = true;
14056                    }
14057                    let cached = self
14058                        .text_glyph_gpu_run_cache
14059                        .peek(&cache_key)
14060                        .ok_or_else(|| "retained glyph buffer missing from cache".to_string())?;
14061                    let dynamic_offset =
14062                        self.retained_glyph_uniform_dynamic_offset(uniform_slot)?;
14063                    render_pass.set_bind_group(
14064                        0,
14065                        &self.retained_glyph_uniform_bind_group,
14066                        &[dynamic_offset],
14067                    );
14068                    render_pass
14069                        .set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
14070                    render_pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
14071                    render_pass.draw_indexed(0..cached.index_count, 0, 0..1);
14072                }
14073            }
14074        }
14075        Ok(())
14076    }
14077
14078    fn append_image_draw_cmd(
14079        &mut self,
14080        image_draw: &ImageDraw,
14081        viewport: ViewportUniformParams,
14082        root_scale: f32,
14083        image_vertices: &mut Vec<Vertex>,
14084        image_indices: &mut Vec<u32>,
14085        image_cmds: &mut Vec<ImageDrawCmd>,
14086    ) -> Result<(), String> {
14087        let snap_delta = image_draw
14088            .snap_anchor
14089            .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
14090            .unwrap_or_default();
14091        let rect = image_draw.rect.translate(snap_delta.x, snap_delta.y);
14092        if rect.width <= 0.0 || rect.height <= 0.0 || image_draw.alpha <= 0.0 {
14093            return Ok(());
14094        }
14095
14096        let (tint, cpu_filter) = tint_for_image(image_draw.color_filter, image_draw.alpha);
14097        if tint[3] <= 0.0 {
14098            return Ok(());
14099        }
14100
14101        let prepared_image = if let Some(filter) = cpu_filter {
14102            apply_filter_to_bitmap(&image_draw.image, filter)?
14103        } else {
14104            image_draw.image.clone()
14105        };
14106        self.ensure_image_cached(&prepared_image)?;
14107
14108        let mut adjusted_image = ImageDraw {
14109            rect,
14110            local_rect: image_draw.local_rect.translate(snap_delta.x, snap_delta.y),
14111            quad: translate_quad(image_draw.quad, snap_delta),
14112            snap_anchor: image_draw.snap_anchor,
14113            image: image_draw.image.clone(),
14114            alpha: image_draw.alpha,
14115            color_filter: image_draw.color_filter,
14116            sampling: image_draw.sampling,
14117            z_index: image_draw.z_index,
14118            clip: image_draw.clip,
14119            blend_mode: image_draw.blend_mode,
14120            src_rect: image_draw.src_rect,
14121            motion_context_animated: image_draw.motion_context_animated,
14122        };
14123        snap_nearest_image_to_device_pixels(&mut adjusted_image, root_scale);
14124        let Some(scissor) =
14125            scissor_rect_for_image(&adjusted_image, root_scale, viewport.width, viewport.height)
14126        else {
14127            return Ok(());
14128        };
14129
14130        let Some(uv_rect) = image_uv_rect(&image_draw.image, image_draw.src_rect) else {
14131            return Ok(());
14132        };
14133        let device_quad =
14134            nearest_image_device_quad(&adjusted_image, root_scale).unwrap_or_else(|| {
14135                if adjusted_image.snap_anchor.is_some() {
14136                    canonicalized_scaled_quad(adjusted_image.quad, root_scale)
14137                } else {
14138                    scaled_quad(adjusted_image.quad, root_scale)
14139                }
14140            });
14141        #[cfg(not(target_arch = "wasm32"))]
14142        {
14143            if fill_area_diag_enabled() {
14144                self.fill_area_diag.add_image_quad(&device_quad);
14145            }
14146        }
14147
14148        let base_vertex = image_vertices.len() as u32;
14149        let index_start = image_indices.len() as u32;
14150        image_indices.extend_from_slice(&[
14151            base_vertex,
14152            base_vertex + 1,
14153            base_vertex + 2,
14154            base_vertex + 2,
14155            base_vertex + 1,
14156            base_vertex + 3,
14157        ]);
14158        image_vertices.extend_from_slice(&[
14159            Vertex {
14160                position: device_quad[0],
14161                color: tint,
14162                uv: [uv_rect.min[0], uv_rect.min[1]],
14163                uv_bounds: uv_rect.sample_bounds,
14164            },
14165            Vertex {
14166                position: device_quad[1],
14167                color: tint,
14168                uv: [uv_rect.max[0], uv_rect.min[1]],
14169                uv_bounds: uv_rect.sample_bounds,
14170            },
14171            Vertex {
14172                position: device_quad[2],
14173                color: tint,
14174                uv: [uv_rect.min[0], uv_rect.max[1]],
14175                uv_bounds: uv_rect.sample_bounds,
14176            },
14177            Vertex {
14178                position: device_quad[3],
14179                color: tint,
14180                uv: [uv_rect.max[0], uv_rect.max[1]],
14181                uv_bounds: uv_rect.sample_bounds,
14182            },
14183        ]);
14184
14185        image_cmds.push(ImageDrawCmd {
14186            index_start,
14187            scissor,
14188            image_id: prepared_image.id(),
14189            sampling: image_draw.sampling,
14190        });
14191        Ok(())
14192    }
14193
14194    #[cfg(not(target_arch = "wasm32"))]
14195    fn stage_native_image_buffers(
14196        &mut self,
14197        staged_uploads: &mut StagedBufferUploads,
14198        viewport: ViewportUniformParams,
14199        image_vertices: &[Vertex],
14200        image_indices: &[u32],
14201    ) {
14202        if image_indices.is_empty() {
14203            return;
14204        }
14205
14206        self.stage_viewport_uniforms(staged_uploads, viewport);
14207        // Grow to a power of two, as the shape batch and frame upload buffers
14208        // do. Sizing these to the exact byte count instead means one more glyph
14209        // quad than the last frame destroys and recreates both buffers, and a
14210        // caption that grows a character at a time does it on every frame.
14211        let needed_bytes = std::mem::size_of_val(image_vertices) as u64;
14212        if needed_bytes > self.image_vertex_buffer.size() {
14213            self.image_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14214                label: Some("Image Vertex Buffer"),
14215                size: needed_bytes.next_power_of_two(),
14216                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
14217                mapped_at_creation: false,
14218            });
14219        }
14220        let needed_index_bytes = std::mem::size_of_val(image_indices) as u64;
14221        if needed_index_bytes > self.image_index_buffer.size() {
14222            self.image_index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14223                label: Some("Image Index Buffer"),
14224                size: needed_index_bytes.next_power_of_two(),
14225                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
14226                mapped_at_creation: false,
14227            });
14228        }
14229
14230        staged_uploads.stage(
14231            UploadTarget::ImageVertex,
14232            bytemuck::cast_slice(image_vertices),
14233        );
14234        staged_uploads.stage(
14235            UploadTarget::ImageIndex,
14236            bytemuck::cast_slice(image_indices),
14237        );
14238    }
14239
14240    /// Prepare image vertices, indices, ensure caching, and write to GPU buffers.
14241    /// Returns the draw commands needed by `encode_images_pass`.
14242    fn prepare_image_draw_cmds<'a, I>(
14243        &mut self,
14244        layer_images: I,
14245        viewport: ViewportUniformParams,
14246        root_scale: f32,
14247        staged_uploads: &mut StagedBufferUploads,
14248    ) -> Result<PreparedImageBatch, String>
14249    where
14250        I: Iterator<Item = &'a ImageDraw>,
14251    {
14252        #[cfg(target_arch = "wasm32")]
14253        let _ = staged_uploads;
14254
14255        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
14256        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
14257        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
14258        image_vertices.clear();
14259        image_indices.clear();
14260        image_cmds.clear();
14261
14262        for image_draw in layer_images {
14263            self.append_image_draw_cmd(
14264                image_draw,
14265                viewport,
14266                root_scale,
14267                &mut image_vertices,
14268                &mut image_indices,
14269                &mut image_cmds,
14270            )?;
14271        }
14272
14273        #[cfg(not(target_arch = "wasm32"))]
14274        if !image_cmds.is_empty() {
14275            self.stage_native_image_buffers(
14276                staged_uploads,
14277                viewport,
14278                &image_vertices,
14279                &image_indices,
14280            );
14281        }
14282
14283        #[cfg(target_arch = "wasm32")]
14284        let image_slot = if image_cmds.is_empty() {
14285            0
14286        } else {
14287            let slot = self.claim_wasm_image_batch();
14288            {
14289                let buffers = &mut self.wasm_image_batches[slot];
14290                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
14291            }
14292            let buffers = &self.wasm_image_batches[slot];
14293            self.write_wasm_buffer(
14294                &buffers.vertex_buffer,
14295                bytemuck::cast_slice(&image_vertices),
14296            );
14297            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
14298            slot
14299        };
14300
14301        #[cfg(target_arch = "wasm32")]
14302        let uniform_slot = if image_cmds.is_empty() {
14303            0
14304        } else {
14305            self.prepare_wasm_viewport_uniforms(viewport)
14306        };
14307
14308        self.scratch_image_vertices = image_vertices;
14309        self.scratch_image_indices = image_indices;
14310        Ok(PreparedImageBatch {
14311            cmds: image_cmds,
14312            #[cfg(target_arch = "wasm32")]
14313            image_slot,
14314            #[cfg(target_arch = "wasm32")]
14315            uniform_slot,
14316        })
14317    }
14318
14319    fn glyph_atlas_entry_for(
14320        &mut self,
14321        glyph: &SoftwareGlyphAtlasGlyph,
14322    ) -> Result<GlyphAtlasEntry, String> {
14323        if let Some(entry) = self.text_glyph_atlas.upload_glyph(
14324            glyph.key,
14325            glyph,
14326            &self.queue,
14327            &mut self.frame_graph_executor,
14328            &mut self.frame_stats,
14329        ) {
14330            return Ok(entry);
14331        }
14332
14333        self.text_glyph_atlas.reset(
14334            &self.device,
14335            &self.image_bind_group_layout,
14336            &self.image_nearest_sampler,
14337        );
14338        Err("text glyph atlas filled and was reset".to_string())
14339    }
14340
14341    fn glyph_atlas_entry_for_cached(
14342        &mut self,
14343        glyph: &SoftwareGlyphAtlasPlacement,
14344    ) -> Option<GlyphAtlasEntry> {
14345        let entry = self.text_glyph_atlas.entry(&glyph.key)?;
14346        self.frame_stats.record_text_glyph_atlas_hit();
14347        Some(entry)
14348    }
14349
14350    fn glyph_atlas_entry_for_placement(
14351        &mut self,
14352        glyph: &SoftwareGlyphAtlasPlacement,
14353    ) -> Result<GlyphAtlasEntry, String> {
14354        if let Some(entry) = self.glyph_atlas_entry_for_cached(glyph) {
14355            return Ok(entry);
14356        }
14357
14358        let Some(upload_glyph) = self.text_glyph_mask_cache.atlas_glyph_for_placement(glyph) else {
14359            return Err("text glyph placement has no retained raster mask".to_string());
14360        };
14361        self.glyph_atlas_entry_for(&upload_glyph)
14362    }
14363
14364    fn prepare_text_glyph_quads(
14365        &mut self,
14366        run_key: TextGlyphRunCacheKey,
14367        atlas_generation: u64,
14368        cached_glyph_run: Option<&[SoftwareGlyphAtlasPlacement]>,
14369        collected_run: &[SoftwareGlyphAtlasRunGlyph],
14370        generated_quads: &mut Vec<CachedTextGlyphQuad>,
14371    ) -> Result<Rc<[CachedTextGlyphQuad]>, String> {
14372        generated_quads.clear();
14373        if let Some(glyph_run) = cached_glyph_run {
14374            for glyph in glyph_run {
14375                if glyph.width == 0 || glyph.height == 0 || glyph.color.3 <= 0.0 {
14376                    continue;
14377                }
14378                let entry = self.glyph_atlas_entry_for_placement(glyph)?;
14379                // Read the size after the entry is in hand: the only path that
14380                // resizes the atlas is the overflow reset, which returns `Err`
14381                // above, so `entry` is always normalised against the atlas it
14382                // was placed in.
14383                generated_quads.push(cached_text_glyph_quad(
14384                    glyph,
14385                    entry,
14386                    self.text_glyph_atlas.size(),
14387                ));
14388            }
14389        } else {
14390            for run_glyph in collected_run {
14391                let placement = run_glyph.placement();
14392                if placement.width == 0 || placement.height == 0 || placement.color.3 <= 0.0 {
14393                    continue;
14394                }
14395                let entry = match run_glyph {
14396                    SoftwareGlyphAtlasRunGlyph::Cached(placement) => {
14397                        self.glyph_atlas_entry_for_placement(placement)?
14398                    }
14399                    SoftwareGlyphAtlasRunGlyph::New(glyph) => self.glyph_atlas_entry_for(glyph)?,
14400                };
14401                generated_quads.push(cached_text_glyph_quad(
14402                    &placement,
14403                    entry,
14404                    self.text_glyph_atlas.size(),
14405                ));
14406            }
14407        }
14408
14409        let quads: Rc<[CachedTextGlyphQuad]> = Rc::from(generated_quads.clone().into_boxed_slice());
14410        if let Some(cached) = self.text_glyph_run_cache.get_mut(&run_key) {
14411            cached.quads = Some(Rc::clone(&quads));
14412            cached.atlas_generation = atlas_generation;
14413        }
14414        Ok(quads)
14415    }
14416
14417    #[allow(clippy::too_many_arguments)]
14418    fn append_text_glyph_quad_run(
14419        &mut self,
14420        source_raster_rect: Rect,
14421        quads: &[CachedTextGlyphQuad],
14422        clip: Option<Rect>,
14423        viewport: ViewportUniformParams,
14424        root_scale: f32,
14425        image_vertices: &mut Vec<Vertex>,
14426        image_indices: &mut Vec<u32>,
14427        record_cached_hits: bool,
14428    ) -> usize {
14429        let mut appended = 0usize;
14430        for quad in quads {
14431            if !cached_text_glyph_quad_is_visible_in_viewport(
14432                source_raster_rect,
14433                quad,
14434                clip,
14435                viewport,
14436                root_scale,
14437            ) {
14438                continue;
14439            }
14440            if append_cached_text_glyph_quad(
14441                source_raster_rect,
14442                quad,
14443                image_vertices,
14444                image_indices,
14445            ) {
14446                if record_cached_hits {
14447                    self.frame_stats.record_text_glyph_atlas_hit();
14448                }
14449                #[cfg(not(target_arch = "wasm32"))]
14450                {
14451                    if fill_area_diag_enabled() {
14452                        self.fill_area_diag.add_glyph_quad(quad);
14453                    }
14454                }
14455                appended = appended.saturating_add(1);
14456            }
14457        }
14458        appended
14459    }
14460
14461    #[cfg(not(target_arch = "wasm32"))]
14462    fn retained_glyph_viewport(
14463        viewport: ViewportUniformParams,
14464        source_raster_rect: Rect,
14465    ) -> ViewportUniformParams {
14466        ViewportUniformParams {
14467            width: viewport.width,
14468            height: viewport.height,
14469            offset: [
14470                viewport.offset[0] - source_raster_rect.x,
14471                viewport.offset[1] - source_raster_rect.y,
14472            ],
14473        }
14474    }
14475
14476    #[cfg(not(target_arch = "wasm32"))]
14477    fn retained_text_glyph_run_ready(&mut self, cache_key: TextGlyphRunCacheKey) -> bool {
14478        let atlas_generation = self.text_glyph_atlas.generation();
14479        self.text_glyph_gpu_run_cache
14480            .peek(&cache_key)
14481            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
14482    }
14483
14484    #[cfg(not(target_arch = "wasm32"))]
14485    #[allow(clippy::too_many_arguments)]
14486    fn emit_retained_text_glyph_run_if_ready(
14487        &mut self,
14488        cache_key: TextGlyphRunCacheKey,
14489        quads: &[CachedTextGlyphQuad],
14490        clip: Option<Rect>,
14491        viewport: ViewportUniformParams,
14492        source_raster_rect: Rect,
14493        scissor: (u32, u32, u32, u32),
14494        staged_uploads: &mut StagedBufferUploads,
14495        glyph_cmds: &mut Vec<GlyphDrawCmd>,
14496    ) -> bool {
14497        if !should_use_retained_text_glyph_run(quads.len(), clip) {
14498            return false;
14499        }
14500        if !self.retained_text_glyph_run_ready(cache_key)
14501            && !self.ensure_retained_text_glyph_run(cache_key, quads)
14502        {
14503            return false;
14504        }
14505
14506        let uniform_slot = self.stage_retained_glyph_viewport_uniforms(
14507            staged_uploads,
14508            Self::retained_glyph_viewport(viewport, source_raster_rect),
14509        );
14510        if fill_area_diag_enabled() {
14511            // The retained run draws every quad of its cached buffer; the
14512            // shared path's per-quad viewport cull is not re-run for it.
14513            for quad in quads {
14514                self.fill_area_diag.add_glyph_quad(quad);
14515            }
14516        }
14517        glyph_cmds.push(GlyphDrawCmd::retained(cache_key, uniform_slot, scissor));
14518        true
14519    }
14520
14521    #[cfg(not(target_arch = "wasm32"))]
14522    fn ensure_retained_text_glyph_run(
14523        &mut self,
14524        cache_key: TextGlyphRunCacheKey,
14525        quads: &[CachedTextGlyphQuad],
14526    ) -> bool {
14527        let atlas_generation = self.text_glyph_atlas.generation();
14528        if self
14529            .text_glyph_gpu_run_cache
14530            .peek(&cache_key)
14531            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
14532        {
14533            return true;
14534        }
14535
14536        let mut vertices = Vec::with_capacity(quads.len().saturating_mul(4));
14537        let mut indices = Vec::with_capacity(quads.len().saturating_mul(6));
14538        let origin = Rect {
14539            x: 0.0,
14540            y: 0.0,
14541            width: 0.0,
14542            height: 0.0,
14543        };
14544        for quad in quads {
14545            append_cached_text_glyph_quad(origin, quad, &mut vertices, &mut indices);
14546        }
14547        if indices.is_empty() {
14548            return false;
14549        }
14550
14551        let vertex_bytes = bytemuck::cast_slice(&vertices);
14552        let index_bytes = bytemuck::cast_slice(&indices);
14553        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14554            label: Some("Retained Text Glyph Vertex Buffer"),
14555            size: vertex_bytes.len() as u64,
14556            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
14557            mapped_at_creation: false,
14558        });
14559        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14560            label: Some("Retained Text Glyph Index Buffer"),
14561            size: index_bytes.len() as u64,
14562            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
14563            mapped_at_creation: false,
14564        });
14565        let vertex_upload =
14566            self.frame_graph_executor
14567                .upload_buffer(&self.queue, &vertex_buffer, 0, vertex_bytes);
14568        self.frame_stats.record_command_stats(vertex_upload);
14569        let index_upload =
14570            self.frame_graph_executor
14571                .upload_buffer(&self.queue, &index_buffer, 0, index_bytes);
14572        self.frame_stats.record_command_stats(index_upload);
14573
14574        self.text_glyph_gpu_run_cache.put(
14575            cache_key,
14576            CachedGpuTextGlyphRun {
14577                vertex_buffer,
14578                index_buffer,
14579                index_count: indices.len() as u32,
14580                atlas_generation,
14581            },
14582        );
14583        true
14584    }
14585
14586    #[allow(clippy::too_many_arguments)]
14587    fn append_text_glyph_draws<'a, I>(
14588        &mut self,
14589        layer_texts: I,
14590        viewport: ViewportUniformParams,
14591        root_scale: f32,
14592        allow_offscreen_prewarm: bool,
14593        staged_uploads: &mut StagedBufferUploads,
14594        image_vertices: &mut Vec<Vertex>,
14595        image_indices: &mut Vec<u32>,
14596        glyph_cmds: &mut Vec<GlyphDrawCmd>,
14597    ) -> Result<bool, String>
14598    where
14599        I: IntoIterator<Item = &'a TextDraw>,
14600    {
14601        let append_start = Instant::now();
14602        let initial_vertex_len = image_vertices.len();
14603        let initial_index_len = image_indices.len();
14604        let initial_cmd_len = glyph_cmds.len();
14605        let initial_staged_bytes_len = staged_uploads.bytes.len();
14606        let initial_staged_copies_len = staged_uploads.copies.len();
14607        let mut collected_run = std::mem::take(&mut self.scratch_text_glyph_run);
14608        let mut collected_placements = std::mem::take(&mut self.scratch_text_glyph_placements);
14609        let mut generated_quads = std::mem::take(&mut self.scratch_text_glyph_quads);
14610        generated_quads.clear();
14611        let mut visited = 0usize;
14612        let mut emitted_glyphs = 0usize;
14613        let mut prewarmed_glyphs = 0usize;
14614        let mut run_hits = 0usize;
14615        let mut run_misses = 0usize;
14616
14617        for text_draw in layer_texts {
14618            visited = visited.saturating_add(1);
14619            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
14620                self.text_raster_geometry(text_draw, root_scale)
14621            else {
14622                continue;
14623            };
14624            if !static_text_motion {
14625                image_vertices.truncate(initial_vertex_len);
14626                image_indices.truncate(initial_index_len);
14627                glyph_cmds.truncate(initial_cmd_len);
14628                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
14629                self.scratch_text_glyph_run = collected_run;
14630                self.scratch_text_glyph_placements = collected_placements;
14631                self.scratch_text_glyph_quads = generated_quads;
14632                return Ok(false);
14633            }
14634            let is_visible =
14635                text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale);
14636            let draw_action = text_glyph_draw_action(
14637                is_visible,
14638                text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale),
14639                allow_offscreen_prewarm,
14640            );
14641            if draw_action == TextGlyphDrawAction::Skip {
14642                continue;
14643            }
14644
14645            let raster_source = text_glyph_raster_source(text_draw, raster_rect);
14646            let source_draw = raster_source.draw.as_ref();
14647            let source_raster_rect = raster_source.raster_rect;
14648
14649            let run_key = Self::text_glyph_run_cache_key(
14650                source_draw,
14651                source_raster_rect,
14652                text_scale,
14653                static_text_motion,
14654            );
14655            let atlas_generation = self.text_glyph_atlas.generation();
14656            let mut cached_quad_run = None;
14657            let mut miss_collect_ms = None;
14658            let mut miss_cached_glyphs = 0usize;
14659            let mut miss_new_glyphs = 0usize;
14660            let cached_glyph_run = if let Some(cached) = self.text_glyph_run_cache.get(&run_key) {
14661                run_hits = run_hits.saturating_add(1);
14662                if cached.atlas_generation == atlas_generation {
14663                    cached_quad_run = cached.quads.as_ref().map(Rc::clone);
14664                }
14665                Some(Rc::clone(&cached.glyphs))
14666            } else {
14667                run_misses = run_misses.saturating_add(1);
14668                collected_run.clear();
14669                let collect_start = Instant::now();
14670                let collect_result = collect_solid_text_atlas_run(
14671                    source_draw.text.as_ref(),
14672                    source_raster_rect,
14673                    &source_draw.text_style,
14674                    source_draw.color,
14675                    source_draw.font_size,
14676                    text_scale,
14677                    &self.text_fonts,
14678                    &mut self.text_glyph_mask_cache,
14679                    &mut collected_run,
14680                );
14681                miss_collect_ms = Some(instant_ms(collect_start, Instant::now()));
14682                if collect_result.is_none() {
14683                    if text_atlas_fallback_diag_enabled() {
14684                        let preview: String = source_draw.text.text.chars().take(96).collect();
14685                        log::warn!(
14686                            "[text-atlas-fallback] node={:?} visible={} prewarm={} spans={} links={} text_len={} preview={:?} span_style={:?} paragraph_style={:?}",
14687                            source_draw.node_id,
14688                            is_visible,
14689                            draw_action == TextGlyphDrawAction::PrewarmOffscreen,
14690                            source_draw.text.span_styles.len(),
14691                            source_draw.text.links.len(),
14692                            source_draw.text.text.len(),
14693                            preview,
14694                            source_draw.text_style.span_style,
14695                            source_draw.text_style.paragraph_style,
14696                        );
14697                    }
14698                    if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
14699                        continue;
14700                    }
14701                    image_vertices.truncate(initial_vertex_len);
14702                    image_indices.truncate(initial_index_len);
14703                    glyph_cmds.truncate(initial_cmd_len);
14704                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
14705                    self.scratch_text_glyph_run = collected_run;
14706                    self.scratch_text_glyph_placements = collected_placements;
14707                    self.scratch_text_glyph_quads = generated_quads;
14708                    return Ok(false);
14709                }
14710                if text_glyph_run_diag_enabled() {
14711                    miss_cached_glyphs = collected_run
14712                        .iter()
14713                        .filter(|glyph| matches!(glyph, SoftwareGlyphAtlasRunGlyph::Cached(_)))
14714                        .count();
14715                    miss_new_glyphs = collected_run.len().saturating_sub(miss_cached_glyphs);
14716                }
14717                collected_placements.clear();
14718                collected_placements.extend(
14719                    collected_run
14720                        .iter()
14721                        .map(SoftwareGlyphAtlasRunGlyph::placement),
14722                );
14723                let glyphs: Rc<[SoftwareGlyphAtlasPlacement]> =
14724                    Rc::from(collected_placements.clone().into_boxed_slice());
14725                self.text_glyph_run_cache.put(
14726                    run_key,
14727                    CachedTextGlyphRun {
14728                        glyphs,
14729                        quads: None,
14730                        atlas_generation: 0,
14731                    },
14732                );
14733                None
14734            };
14735
14736            if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
14737                let prewarm_quads = if let Some(quad_run) = cached_quad_run {
14738                    quad_run
14739                } else {
14740                    let prepare_start = Instant::now();
14741                    match self.prepare_text_glyph_quads(
14742                        run_key,
14743                        atlas_generation,
14744                        cached_glyph_run.as_deref(),
14745                        &collected_run,
14746                        &mut generated_quads,
14747                    ) {
14748                        Ok(quads) => {
14749                            if let Some(collect_ms) = miss_collect_ms {
14750                                if text_glyph_run_diag_enabled() {
14751                                    log::warn!(
14752                                        "[text-glyph-run-diag] visible=false glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
14753                                        quads.len(),
14754                                        miss_cached_glyphs,
14755                                        miss_new_glyphs,
14756                                        collect_ms,
14757                                        instant_ms(prepare_start, Instant::now()),
14758                                    );
14759                                }
14760                            }
14761                            quads
14762                        }
14763                        Err(_) => continue,
14764                    }
14765                };
14766                #[cfg(not(target_arch = "wasm32"))]
14767                if should_use_retained_text_glyph_run(prewarm_quads.len(), source_draw.clip) {
14768                    self.ensure_retained_text_glyph_run(run_key, prewarm_quads.as_ref());
14769                }
14770                prewarmed_glyphs = prewarmed_glyphs.saturating_add(prewarm_quads.len());
14771                continue;
14772            }
14773
14774            let draw_rect = Rect {
14775                x: source_raster_rect.x / root_scale,
14776                y: source_raster_rect.y / root_scale,
14777                width: source_raster_rect.width / root_scale,
14778                height: source_raster_rect.height / root_scale,
14779            };
14780            let Some(scissor) = scissor_rect_for_layer(
14781                draw_rect,
14782                source_draw.clip,
14783                root_scale,
14784                viewport.width,
14785                viewport.height,
14786            ) else {
14787                continue;
14788            };
14789
14790            #[cfg(not(target_arch = "wasm32"))]
14791            if let Some(quad_run) = cached_quad_run.as_ref() {
14792                if should_use_retained_text_glyph_run(quad_run.len(), source_draw.clip)
14793                    && self.emit_retained_text_glyph_run_if_ready(
14794                        run_key,
14795                        quad_run.as_ref(),
14796                        source_draw.clip,
14797                        viewport,
14798                        source_raster_rect,
14799                        scissor,
14800                        staged_uploads,
14801                        glyph_cmds,
14802                    )
14803                {
14804                    emitted_glyphs = emitted_glyphs.saturating_add(quad_run.len());
14805                    continue;
14806                }
14807            }
14808
14809            let index_start = image_indices.len() as u32;
14810            if let Some(quad_run) = cached_quad_run {
14811                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
14812                    source_raster_rect,
14813                    quad_run.as_ref(),
14814                    source_draw.clip,
14815                    viewport,
14816                    root_scale,
14817                    image_vertices,
14818                    image_indices,
14819                    true,
14820                ));
14821            } else {
14822                let prepare_start = Instant::now();
14823                let Ok(quad_run) = self.prepare_text_glyph_quads(
14824                    run_key,
14825                    atlas_generation,
14826                    cached_glyph_run.as_deref(),
14827                    &collected_run,
14828                    &mut generated_quads,
14829                ) else {
14830                    image_vertices.truncate(initial_vertex_len);
14831                    image_indices.truncate(initial_index_len);
14832                    glyph_cmds.truncate(initial_cmd_len);
14833                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
14834                    self.scratch_text_glyph_run = collected_run;
14835                    self.scratch_text_glyph_placements = collected_placements;
14836                    self.scratch_text_glyph_quads = generated_quads;
14837                    return Ok(false);
14838                };
14839                if let Some(collect_ms) = miss_collect_ms {
14840                    if text_glyph_run_diag_enabled() {
14841                        log::warn!(
14842                            "[text-glyph-run-diag] visible=true glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
14843                            quad_run.len(),
14844                            miss_cached_glyphs,
14845                            miss_new_glyphs,
14846                            collect_ms,
14847                            instant_ms(prepare_start, Instant::now()),
14848                        );
14849                    }
14850                }
14851                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
14852                    source_raster_rect,
14853                    quad_run.as_ref(),
14854                    source_draw.clip,
14855                    viewport,
14856                    root_scale,
14857                    image_vertices,
14858                    image_indices,
14859                    false,
14860                ));
14861            }
14862            let index_count = image_indices.len() as u32 - index_start;
14863            if index_count > 0 {
14864                glyph_cmds.push(GlyphDrawCmd::shared(index_start, index_count, scissor));
14865            }
14866        }
14867
14868        self.scratch_text_glyph_run = collected_run;
14869        self.scratch_text_glyph_placements = collected_placements;
14870        self.scratch_text_glyph_quads = generated_quads;
14871        let append_end = Instant::now();
14872        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
14873            log::warn!(
14874                "[wgpu-render-stage:text-glyph-atlas] total_ms={total_ms:.2} visited={} cmds={} glyphs={} prewarmed={} run_hits={} run_misses={}",
14875                visited,
14876                glyph_cmds.len().saturating_sub(initial_cmd_len),
14877                emitted_glyphs,
14878                prewarmed_glyphs,
14879                run_hits,
14880                run_misses,
14881            );
14882        }
14883        Ok(true)
14884    }
14885
14886    #[cfg(not(target_arch = "wasm32"))]
14887    fn text_glyph_prewarm_decision(
14888        &self,
14889        text_draw: &TextDraw,
14890        viewport: ViewportUniformParams,
14891        root_scale: f32,
14892    ) -> TextGlyphPrewarmDecision {
14893        let Some((logical_rect, _, clip, _, static_text_motion)) =
14894            self.text_raster_geometry(text_draw, root_scale)
14895        else {
14896            return TextGlyphPrewarmDecision::MissingGeometry;
14897        };
14898        if !static_text_motion {
14899            return TextGlyphPrewarmDecision::DynamicMotion;
14900        }
14901        if text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
14902            return TextGlyphPrewarmDecision::Visible;
14903        }
14904        if text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale) {
14905            TextGlyphPrewarmDecision::Candidate
14906        } else {
14907            TextGlyphPrewarmDecision::OutsidePrewarmWindow
14908        }
14909    }
14910
14911    #[cfg(not(target_arch = "wasm32"))]
14912    #[allow(clippy::too_many_arguments)]
14913    fn prewarm_offscreen_text_glyph_draws_in_chunk(
14914        &mut self,
14915        ordered_items: &[(usize, SegmentDrawItem)],
14916        texts: &[TextDraw],
14917        chunk: &SegmentDrawChunkPlan,
14918        viewport: ViewportUniformParams,
14919        root_scale: f32,
14920        staged_uploads: &mut StagedBufferUploads,
14921        image_vertices: &mut Vec<Vertex>,
14922        image_indices: &mut Vec<u32>,
14923        glyph_cmds: &mut Vec<GlyphDrawCmd>,
14924    ) -> Result<(), String> {
14925        let prewarm_start = Instant::now();
14926        let diag_enabled = cranpose_core::env_flag!("CRANPOSE_TEXT_PREWARM_DIAG");
14927        let mut text_items = 0usize;
14928        let mut candidates = 0usize;
14929        let mut missing_geometry = 0usize;
14930        let mut dynamic_motion = 0usize;
14931        let mut visible = 0usize;
14932        let mut outside = 0usize;
14933        let mut already_prepared = 0usize;
14934        let mut admitted_candidates = 0usize;
14935        let mut skipped_unbounded = 0usize;
14936        let mut skipped_budget = 0usize;
14937        let initial_vertex_len = image_vertices.len();
14938        let initial_index_len = image_indices.len();
14939        let initial_cmd_len = glyph_cmds.len();
14940        let initial_staged_bytes_len = staged_uploads.bytes.len();
14941        let initial_staged_copies_len = staged_uploads.copies.len();
14942        'batches: for batch in chunk.iter() {
14943            let SegmentBatchPlan::Text { start, end } = batch else {
14944                continue;
14945            };
14946            for (_, item) in &ordered_items[start..end] {
14947                if offscreen_text_glyph_prewarm_budget_exhausted(prewarm_start, admitted_candidates)
14948                {
14949                    skipped_budget = skipped_budget.saturating_add(1);
14950                    break 'batches;
14951                }
14952                let SegmentDrawItem::Text(text_index) = item else {
14953                    return Err(format!(
14954                        "text prewarm batch contains non-text draw item: {item:?}"
14955                    ));
14956                };
14957                let Some(text_draw) = texts.get(*text_index) else {
14958                    continue;
14959                };
14960                text_items = text_items.saturating_add(1);
14961                match self.text_glyph_prewarm_decision(text_draw, viewport, root_scale) {
14962                    TextGlyphPrewarmDecision::Candidate => {}
14963                    TextGlyphPrewarmDecision::MissingGeometry => {
14964                        missing_geometry = missing_geometry.saturating_add(1);
14965                        continue;
14966                    }
14967                    TextGlyphPrewarmDecision::DynamicMotion => {
14968                        dynamic_motion = dynamic_motion.saturating_add(1);
14969                        continue;
14970                    }
14971                    TextGlyphPrewarmDecision::Visible => {
14972                        visible = visible.saturating_add(1);
14973                        continue;
14974                    }
14975                    TextGlyphPrewarmDecision::OutsidePrewarmWindow => {
14976                        outside = outside.saturating_add(1);
14977                        continue;
14978                    }
14979                }
14980
14981                candidates = candidates.saturating_add(1);
14982                let Some((_, raster_rect, _, text_scale, static_text_motion)) =
14983                    self.text_raster_geometry(text_draw, root_scale)
14984                else {
14985                    missing_geometry = missing_geometry.saturating_add(1);
14986                    continue;
14987                };
14988                let raster_source = text_glyph_raster_source(text_draw, raster_rect);
14989                let source_draw = raster_source.draw.as_ref();
14990                let run_key = Self::text_glyph_run_cache_key(
14991                    source_draw,
14992                    raster_source.raster_rect,
14993                    text_scale,
14994                    static_text_motion,
14995                );
14996                let atlas_generation = self.text_glyph_atlas.generation();
14997                let cached_glyphs = if let Some(cached) = self.text_glyph_run_cache.peek(&run_key) {
14998                    if cached.atlas_generation == atlas_generation && cached.quads.is_some() {
14999                        already_prepared = already_prepared.saturating_add(1);
15000                        continue;
15001                    }
15002                    Some(cached.glyphs.len())
15003                } else {
15004                    None
15005                };
15006                if !offscreen_text_glyph_prewarm_work_is_bounded(
15007                    cached_glyphs,
15008                    source_draw.text.text.len(),
15009                ) {
15010                    skipped_unbounded = skipped_unbounded.saturating_add(1);
15011                    continue;
15012                }
15013                admitted_candidates = admitted_candidates.saturating_add(1);
15014                self.append_text_glyph_draws(
15015                    std::iter::once(text_draw),
15016                    viewport,
15017                    root_scale,
15018                    true,
15019                    staged_uploads,
15020                    image_vertices,
15021                    image_indices,
15022                    glyph_cmds,
15023                )?;
15024                image_vertices.truncate(initial_vertex_len);
15025                image_indices.truncate(initial_index_len);
15026                glyph_cmds.truncate(initial_cmd_len);
15027                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
15028            }
15029        }
15030
15031        if diag_enabled && text_items > 0 {
15032            log::warn!(
15033                "[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}"
15034            );
15035        }
15036        if admitted_candidates > 0 {
15037            if let Some(total_ms) = should_log_wgpu_render_stage(prewarm_start, Instant::now()) {
15038                log::warn!(
15039                    "[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}"
15040                );
15041            }
15042        }
15043        Ok(())
15044    }
15045
15046    fn prepare_text_glyph_draw_cmds<'a, I>(
15047        &mut self,
15048        layer_texts: I,
15049        viewport: ViewportUniformParams,
15050        root_scale: f32,
15051        staged_uploads: &mut StagedBufferUploads,
15052    ) -> Result<Option<PreparedGlyphBatch>, String>
15053    where
15054        I: IntoIterator<Item = &'a TextDraw>,
15055    {
15056        #[cfg(target_arch = "wasm32")]
15057        let _ = staged_uploads;
15058
15059        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
15060        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
15061        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
15062        image_vertices.clear();
15063        image_indices.clear();
15064        glyph_cmds.clear();
15065
15066        if !self.append_text_glyph_draws(
15067            layer_texts,
15068            viewport,
15069            root_scale,
15070            false,
15071            staged_uploads,
15072            &mut image_vertices,
15073            &mut image_indices,
15074            &mut glyph_cmds,
15075        )? {
15076            self.scratch_image_vertices = image_vertices;
15077            self.scratch_image_indices = image_indices;
15078            self.scratch_glyph_cmds = glyph_cmds;
15079            return Ok(None);
15080        }
15081
15082        #[cfg(not(target_arch = "wasm32"))]
15083        if !image_indices.is_empty() {
15084            self.stage_native_image_buffers(
15085                staged_uploads,
15086                viewport,
15087                &image_vertices,
15088                &image_indices,
15089            );
15090        }
15091
15092        #[cfg(target_arch = "wasm32")]
15093        let image_slot = if glyph_cmds.is_empty() {
15094            0
15095        } else {
15096            let slot = self.claim_wasm_image_batch();
15097            {
15098                let buffers = &mut self.wasm_image_batches[slot];
15099                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
15100            }
15101            let buffers = &self.wasm_image_batches[slot];
15102            self.write_wasm_buffer(
15103                &buffers.vertex_buffer,
15104                bytemuck::cast_slice(&image_vertices),
15105            );
15106            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
15107            slot
15108        };
15109
15110        #[cfg(target_arch = "wasm32")]
15111        let uniform_slot = if glyph_cmds.is_empty() {
15112            0
15113        } else {
15114            self.prepare_wasm_viewport_uniforms(viewport)
15115        };
15116
15117        self.scratch_image_vertices = image_vertices;
15118        self.scratch_image_indices = image_indices;
15119        Ok(Some(PreparedGlyphBatch {
15120            cmds: glyph_cmds,
15121            #[cfg(target_arch = "wasm32")]
15122            image_slot,
15123            #[cfg(target_arch = "wasm32")]
15124            uniform_slot,
15125        }))
15126    }
15127
15128    #[allow(clippy::too_many_arguments)]
15129    fn append_image_bitmap_draw_cmd(
15130        &mut self,
15131        image: &ImageBitmap,
15132        rect: Rect,
15133        clip: Option<Rect>,
15134        sampling: ImageSampling,
15135        viewport: ViewportUniformParams,
15136        root_scale: f32,
15137        image_vertices: &mut Vec<Vertex>,
15138        image_indices: &mut Vec<u32>,
15139        image_cmds: &mut Vec<ImageDrawCmd>,
15140    ) -> Result<(), String> {
15141        if rect.width <= 0.0 || rect.height <= 0.0 {
15142            return Ok(());
15143        }
15144
15145        self.ensure_image_cached(image)?;
15146
15147        let (device_quad, scissor_rect) =
15148            if sampling == ImageSampling::Nearest && root_scale.is_finite() && root_scale > 0.0 {
15149                let left_px = (rect.x * root_scale).round();
15150                let top_px = (rect.y * root_scale).round();
15151                let width_px = (rect.width * root_scale).round().max(1.0);
15152                let height_px = (rect.height * root_scale).round().max(1.0);
15153                let snapped_rect = Rect {
15154                    x: left_px / root_scale,
15155                    y: top_px / root_scale,
15156                    width: width_px / root_scale,
15157                    height: height_px / root_scale,
15158                };
15159                let right_px = left_px + width_px;
15160                let bottom_px = top_px + height_px;
15161                (
15162                    [
15163                        [left_px, top_px],
15164                        [right_px, top_px],
15165                        [left_px, bottom_px],
15166                        [right_px, bottom_px],
15167                    ],
15168                    snapped_rect,
15169                )
15170            } else {
15171                (
15172                    rect_to_quad(rect).map(|[x, y]| [x * root_scale, y * root_scale]),
15173                    rect,
15174                )
15175            };
15176
15177        let Some(scissor) = scissor_rect_for_layer(
15178            scissor_rect,
15179            clip,
15180            root_scale,
15181            viewport.width,
15182            viewport.height,
15183        ) else {
15184            return Ok(());
15185        };
15186        let Some(uv_rect) = image_uv_rect(image, None) else {
15187            return Ok(());
15188        };
15189        #[cfg(not(target_arch = "wasm32"))]
15190        {
15191            if fill_area_diag_enabled() {
15192                self.fill_area_diag.add_image_quad(&device_quad);
15193            }
15194        }
15195
15196        let base_vertex = image_vertices.len() as u32;
15197        let index_start = image_indices.len() as u32;
15198        image_indices.extend_from_slice(&[
15199            base_vertex,
15200            base_vertex + 1,
15201            base_vertex + 2,
15202            base_vertex + 2,
15203            base_vertex + 1,
15204            base_vertex + 3,
15205        ]);
15206        let color = [1.0, 1.0, 1.0, 1.0];
15207        image_vertices.extend_from_slice(&[
15208            Vertex {
15209                position: device_quad[0],
15210                color,
15211                uv: [uv_rect.min[0], uv_rect.min[1]],
15212                uv_bounds: uv_rect.sample_bounds,
15213            },
15214            Vertex {
15215                position: device_quad[1],
15216                color,
15217                uv: [uv_rect.max[0], uv_rect.min[1]],
15218                uv_bounds: uv_rect.sample_bounds,
15219            },
15220            Vertex {
15221                position: device_quad[2],
15222                color,
15223                uv: [uv_rect.min[0], uv_rect.max[1]],
15224                uv_bounds: uv_rect.sample_bounds,
15225            },
15226            Vertex {
15227                position: device_quad[3],
15228                color,
15229                uv: [uv_rect.max[0], uv_rect.max[1]],
15230                uv_bounds: uv_rect.sample_bounds,
15231            },
15232        ]);
15233        image_cmds.push(ImageDrawCmd {
15234            index_start,
15235            scissor,
15236            image_id: image.id(),
15237            sampling,
15238        });
15239        Ok(())
15240    }
15241
15242    #[allow(clippy::too_many_arguments)]
15243    fn append_text_image_draw_cmds<'a, I>(
15244        &mut self,
15245        layer_texts: I,
15246        viewport: ViewportUniformParams,
15247        root_scale: f32,
15248        image_vertices: &mut Vec<Vertex>,
15249        image_indices: &mut Vec<u32>,
15250        image_cmds: &mut Vec<ImageDrawCmd>,
15251    ) -> Result<(), String>
15252    where
15253        I: Iterator<Item = &'a TextDraw>,
15254    {
15255        let append_start = Instant::now();
15256        let initial_len = image_cmds.len();
15257        let mut visited = 0usize;
15258        let mut hit_count = 0usize;
15259        let mut miss_count = 0usize;
15260        for text_draw in layer_texts {
15261            visited = visited.saturating_add(1);
15262            let _ = text_draw.node_id;
15263            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
15264                self.text_raster_geometry(text_draw, root_scale)
15265            else {
15266                continue;
15267            };
15268            if !text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
15269                continue;
15270            }
15271
15272            let raster_source = self.text_image_raster_source(
15273                text_draw,
15274                logical_rect,
15275                raster_rect,
15276                clip,
15277                root_scale,
15278                static_text_motion,
15279            );
15280            let source_draw = raster_source.draw.as_ref();
15281            let source_raster_rect = raster_source.raster_rect;
15282
15283            let cache_key = Self::text_image_cache_key(
15284                source_draw,
15285                source_raster_rect,
15286                text_scale,
15287                static_text_motion,
15288            );
15289            let image = if let Some(cached) = self.text_image_cache.get(&cache_key) {
15290                self.frame_stats
15291                    .record_text_image_cache_hit(cached.image.width(), cached.image.height());
15292                hit_count = hit_count.saturating_add(1);
15293                cached.image.clone()
15294            } else {
15295                let Some(image) =
15296                    self.rasterize_text_draw_to_image(source_draw, source_raster_rect, text_scale)
15297                else {
15298                    continue;
15299                };
15300                self.frame_stats
15301                    .record_text_image_cache_miss(image.width(), image.height());
15302                miss_count = miss_count.saturating_add(1);
15303                self.text_image_cache.put(
15304                    cache_key,
15305                    CachedTextImage {
15306                        image: image.clone(),
15307                    },
15308                );
15309                image
15310            };
15311
15312            let draw_origin = if static_text_motion {
15313                Point::new(
15314                    source_raster_rect.x / root_scale,
15315                    source_raster_rect.y / root_scale,
15316                )
15317            } else {
15318                Point::new(logical_rect.x, logical_rect.y)
15319            };
15320            let draw_rect = Rect {
15321                x: draw_origin.x,
15322                y: draw_origin.y,
15323                width: image.width() as f32 / root_scale,
15324                height: image.height() as f32 / root_scale,
15325            };
15326            self.append_image_bitmap_draw_cmd(
15327                &image,
15328                draw_rect,
15329                clip,
15330                ImageSampling::Nearest,
15331                viewport,
15332                root_scale,
15333                image_vertices,
15334                image_indices,
15335                image_cmds,
15336            )?;
15337        }
15338        let append_end = Instant::now();
15339        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
15340            log::warn!(
15341                "[wgpu-render-stage:text-images] total_ms={total_ms:.2} visited={} emitted={} hits={} misses={}",
15342                visited,
15343                image_cmds.len().saturating_sub(initial_len),
15344                hit_count,
15345                miss_count,
15346            );
15347        }
15348        Ok(())
15349    }
15350
15351    fn text_image_raster_source<'a>(
15352        &mut self,
15353        text_draw: &'a TextDraw,
15354        logical_rect: Rect,
15355        raster_rect: Rect,
15356        clip: Option<Rect>,
15357        root_scale: f32,
15358        static_text_motion: bool,
15359    ) -> TextRasterSource<'a> {
15360        let Some(clip) = clip else {
15361            return TextRasterSource {
15362                draw: Cow::Borrowed(text_draw),
15363                raster_rect,
15364            };
15365        };
15366        if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
15367            return TextRasterSource {
15368                draw: Cow::Borrowed(text_draw),
15369                raster_rect,
15370            };
15371        }
15372
15373        let line_starts = self.text_line_index_cache.line_starts(&text_draw.text);
15374        clipped_text_raster_source_with_line_starts(
15375            text_draw,
15376            logical_rect,
15377            raster_rect,
15378            clip,
15379            root_scale,
15380            line_starts.as_ref(),
15381        )
15382    }
15383
15384    fn prepare_text_image_draw_cmds<'a, I>(
15385        &mut self,
15386        layer_texts: I,
15387        viewport: ViewportUniformParams,
15388        root_scale: f32,
15389        staged_uploads: &mut StagedBufferUploads,
15390    ) -> Result<PreparedImageBatch, String>
15391    where
15392        I: Iterator<Item = &'a TextDraw>,
15393    {
15394        #[cfg(target_arch = "wasm32")]
15395        let _ = staged_uploads;
15396
15397        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
15398        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
15399        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
15400        image_vertices.clear();
15401        image_indices.clear();
15402        image_cmds.clear();
15403
15404        self.append_text_image_draw_cmds(
15405            layer_texts,
15406            viewport,
15407            root_scale,
15408            &mut image_vertices,
15409            &mut image_indices,
15410            &mut image_cmds,
15411        )?;
15412
15413        #[cfg(not(target_arch = "wasm32"))]
15414        if !image_cmds.is_empty() {
15415            self.stage_native_image_buffers(
15416                staged_uploads,
15417                viewport,
15418                &image_vertices,
15419                &image_indices,
15420            );
15421        }
15422
15423        #[cfg(target_arch = "wasm32")]
15424        let image_slot = if image_cmds.is_empty() {
15425            0
15426        } else {
15427            let slot = self.claim_wasm_image_batch();
15428            {
15429                let buffers = &mut self.wasm_image_batches[slot];
15430                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
15431            }
15432            let buffers = &self.wasm_image_batches[slot];
15433            self.write_wasm_buffer(
15434                &buffers.vertex_buffer,
15435                bytemuck::cast_slice(&image_vertices),
15436            );
15437            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
15438            slot
15439        };
15440
15441        #[cfg(target_arch = "wasm32")]
15442        let uniform_slot = if image_cmds.is_empty() {
15443            0
15444        } else {
15445            self.prepare_wasm_viewport_uniforms(viewport)
15446        };
15447
15448        self.scratch_image_vertices = image_vertices;
15449        self.scratch_image_indices = image_indices;
15450        Ok(PreparedImageBatch {
15451            cmds: image_cmds,
15452            #[cfg(target_arch = "wasm32")]
15453            image_slot,
15454            #[cfg(target_arch = "wasm32")]
15455            uniform_slot,
15456        })
15457    }
15458
15459    fn text_raster_geometry(
15460        &self,
15461        text_draw: &TextDraw,
15462        root_scale: f32,
15463    ) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
15464        text_raster_geometry_for_draw(text_draw, root_scale)
15465    }
15466
15467    fn text_image_cache_key(
15468        text_draw: &TextDraw,
15469        raster_rect: Rect,
15470        text_scale: f32,
15471        static_text_motion: bool,
15472    ) -> TextImageCacheKey {
15473        let mut state = default_hash::new();
15474        text_draw.text.render_hash().hash(&mut state);
15475        text_draw.text_style.render_hash().hash(&mut state);
15476        text_draw.color.render_hash().hash(&mut state);
15477        hash_text_raster_geometry_for_cache(raster_rect, static_text_motion, &mut state);
15478        text_draw.font_size.to_bits().hash(&mut state);
15479        text_scale.to_bits().hash(&mut state);
15480        text_draw.layout_options.hash(&mut state);
15481        TextImageCacheKey(state.finish())
15482    }
15483
15484    fn text_glyph_run_cache_key(
15485        text_draw: &TextDraw,
15486        raster_rect: Rect,
15487        text_scale: f32,
15488        static_text_motion: bool,
15489    ) -> TextGlyphRunCacheKey {
15490        TextGlyphRunCacheKey(
15491            Self::text_image_cache_key(text_draw, raster_rect, text_scale, static_text_motion).0,
15492        )
15493    }
15494
15495    fn rasterize_text_draw_to_image(
15496        &mut self,
15497        text_draw: &TextDraw,
15498        raster_rect: Rect,
15499        text_scale: f32,
15500    ) -> Option<ImageBitmap> {
15501        if text_draw.text.span_styles.is_empty() {
15502            let font = self.text_fonts.resolve(&text_draw.text_style)?;
15503            return rasterize_text_to_image_with_glyph_cache(
15504                text_draw.text.text.as_str(),
15505                raster_rect,
15506                &text_draw.text_style,
15507                text_draw.color,
15508                text_draw.font_size,
15509                text_scale,
15510                font,
15511                &mut self.text_glyph_mask_cache,
15512            );
15513        }
15514
15515        if let Some(image) = rasterize_annotated_text_to_image_with_glyph_cache(
15516            text_draw.text.as_ref(),
15517            raster_rect,
15518            &text_draw.text_style,
15519            text_draw.color,
15520            text_draw.font_size,
15521            text_scale,
15522            &self.text_fonts,
15523            &mut self.text_glyph_mask_cache,
15524        ) {
15525            return Some(image);
15526        }
15527
15528        rasterize_spanned_text_to_image(
15529            text_draw,
15530            raster_rect,
15531            text_scale,
15532            &self.text_fonts,
15533            &mut self.text_glyph_mask_cache,
15534        )
15535    }
15536}
15537
15538fn rasterize_spanned_text_to_image(
15539    text_draw: &TextDraw,
15540    raster_rect: Rect,
15541    text_scale: f32,
15542    fonts: &SoftwareTextFontSet,
15543    glyph_cache: &mut SoftwareGlyphRasterCache,
15544) -> Option<ImageBitmap> {
15545    let width = raster_rect.width.ceil().max(1.0) as u32;
15546    let height = raster_rect.height.ceil().max(1.0) as u32;
15547    let mut canvas = vec![0_u8; (width as usize) * (height as usize) * 4];
15548    let boundaries = text_draw.text.span_boundaries();
15549    let base_line_height = text_draw
15550        .text_style
15551        .resolve_line_height(14.0, text_draw.font_size)
15552        .max(1.0);
15553    let mut current_line_height = base_line_height;
15554    let mut cursor_x = raster_rect.x;
15555    let mut cursor_y = raster_rect.y;
15556
15557    for window in boundaries.windows(2) {
15558        let start = window[0];
15559        let end = window[1];
15560        if start == end {
15561            continue;
15562        }
15563
15564        let chunk = &text_draw.text.text[start..end];
15565        let mut merged_span = text_draw.text_style.span_style.clone();
15566        for span in &text_draw.text.span_styles {
15567            if span.range.start <= start && span.range.end >= end {
15568                merged_span = merged_span.merge(&span.item);
15569            }
15570        }
15571
15572        let mut chunk_style = text_draw.text_style.clone();
15573        chunk_style.span_style = merged_span;
15574
15575        for part in chunk.split_inclusive('\n') {
15576            let has_newline = part.ends_with('\n');
15577            let content = if has_newline {
15578                &part[..part.len().saturating_sub(1)]
15579            } else {
15580                part
15581            };
15582
15583            if !content.is_empty() {
15584                let chunk_font_size = chunk_style.resolve_font_size(text_draw.font_size);
15585                let Some(font) = fonts.resolve(&chunk_style) else {
15586                    continue;
15587                };
15588                let metrics = measure_text_with_font(content, &chunk_style, chunk_font_size, font);
15589                let segment_rect = Rect {
15590                    x: cursor_x,
15591                    y: cursor_y,
15592                    width: (metrics.width * text_scale).ceil().max(1.0),
15593                    height: (metrics.height * text_scale).ceil().max(1.0),
15594                };
15595                if let Some(segment_image) = rasterize_text_to_image_with_glyph_cache(
15596                    content,
15597                    segment_rect,
15598                    &chunk_style,
15599                    chunk_style.resolve_text_color(text_draw.color),
15600                    chunk_font_size,
15601                    text_scale,
15602                    font,
15603                    glyph_cache,
15604                ) {
15605                    composite_text_segment(
15606                        &mut canvas,
15607                        width,
15608                        height,
15609                        raster_rect,
15610                        segment_rect,
15611                        &segment_image,
15612                    );
15613                }
15614                cursor_x += metrics.width * text_scale;
15615                current_line_height = current_line_height.max(metrics.line_height.max(1.0));
15616            }
15617
15618            if has_newline {
15619                cursor_x = raster_rect.x;
15620                cursor_y += current_line_height * text_scale;
15621                current_line_height = base_line_height;
15622            }
15623        }
15624    }
15625
15626    ImageBitmap::from_rgba8(width, height, canvas).ok()
15627}
15628
15629struct TextRasterSource<'a> {
15630    draw: Cow<'a, TextDraw>,
15631    raster_rect: Rect,
15632}
15633
15634fn text_glyph_raster_source(text_draw: &TextDraw, raster_rect: Rect) -> TextRasterSource<'_> {
15635    TextRasterSource {
15636        draw: Cow::Borrowed(text_draw),
15637        raster_rect,
15638    }
15639}
15640
15641#[cfg(test)]
15642fn clipped_text_raster_source<'a>(
15643    text_draw: &'a TextDraw,
15644    logical_rect: Rect,
15645    raster_rect: Rect,
15646    clip: Option<Rect>,
15647    root_scale: f32,
15648    static_text_motion: bool,
15649) -> TextRasterSource<'a> {
15650    let Some(clip) = clip else {
15651        return TextRasterSource {
15652            draw: Cow::Borrowed(text_draw),
15653            raster_rect,
15654        };
15655    };
15656    if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
15657        return TextRasterSource {
15658            draw: Cow::Borrowed(text_draw),
15659            raster_rect,
15660        };
15661    }
15662    let line_starts = line_start_offsets(text_draw.text.text.as_str());
15663    clipped_text_raster_source_with_line_starts(
15664        text_draw,
15665        logical_rect,
15666        raster_rect,
15667        clip,
15668        root_scale,
15669        &line_starts,
15670    )
15671}
15672
15673fn clipped_text_raster_source_with_line_starts<'a>(
15674    text_draw: &'a TextDraw,
15675    logical_rect: Rect,
15676    raster_rect: Rect,
15677    clip: Rect,
15678    root_scale: f32,
15679    line_starts: &[usize],
15680) -> TextRasterSource<'a> {
15681    if line_starts.len() < MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER {
15682        return TextRasterSource {
15683            draw: Cow::Borrowed(text_draw),
15684            raster_rect,
15685        };
15686    }
15687
15688    let Some(visible_rect) = logical_rect.intersect(clip) else {
15689        return TextRasterSource {
15690            draw: Cow::Borrowed(text_draw),
15691            raster_rect,
15692        };
15693    };
15694
15695    let line_count = line_starts.len().max(1);
15696    let line_height = logical_rect.height / line_count as f32;
15697    if !line_height.is_finite() || line_height <= 0.0 {
15698        return TextRasterSource {
15699            draw: Cow::Borrowed(text_draw),
15700            raster_rect,
15701        };
15702    }
15703
15704    let visible_top = ((visible_rect.y - logical_rect.y) / line_height).floor() as isize;
15705    let visible_bottom =
15706        ((visible_rect.y + visible_rect.height - logical_rect.y) / line_height).ceil() as isize;
15707    let start_line = visible_top.saturating_sub(1).max(0) as usize;
15708    let end_line = (visible_bottom + 1).max(start_line as isize + 1) as usize;
15709    let end_line = end_line.min(line_count);
15710    if start_line == 0 && end_line >= line_count {
15711        return TextRasterSource {
15712            draw: Cow::Borrowed(text_draw),
15713            raster_rect,
15714        };
15715    }
15716
15717    let byte_start = line_starts[start_line];
15718    let byte_end = line_end_offset(text_draw.text.text.as_str(), line_starts, end_line - 1);
15719    if byte_start >= byte_end {
15720        return TextRasterSource {
15721            draw: Cow::Borrowed(text_draw),
15722            raster_rect,
15723        };
15724    }
15725
15726    let slice_y = logical_rect.y + start_line as f32 * line_height;
15727    let slice_height = (end_line - start_line) as f32 * line_height;
15728    let mut slice_raster_rect = Rect {
15729        x: logical_rect.x * root_scale,
15730        y: slice_y * root_scale,
15731        width: logical_rect.width * root_scale,
15732        height: slice_height * root_scale,
15733    };
15734    slice_raster_rect.x = slice_raster_rect.x.round();
15735    slice_raster_rect.y = slice_raster_rect.y.round();
15736    slice_raster_rect.width = slice_raster_rect.width.ceil().max(1.0);
15737    slice_raster_rect.height = slice_raster_rect.height.ceil().max(1.0);
15738
15739    let mut sliced_draw = text_draw.clone();
15740    sliced_draw.rect = Rect {
15741        x: logical_rect.x,
15742        y: slice_y,
15743        width: logical_rect.width,
15744        height: slice_height,
15745    };
15746    sliced_draw.text = Arc::new(text_draw.text.subsequence(byte_start..byte_end));
15747
15748    TextRasterSource {
15749        draw: Cow::Owned(sliced_draw),
15750        raster_rect: slice_raster_rect,
15751    }
15752}
15753
15754fn line_start_offsets(text: &str) -> Vec<usize> {
15755    let mut starts =
15756        Vec::with_capacity(text.as_bytes().iter().filter(|b| **b == b'\n').count() + 1);
15757    starts.push(0);
15758    starts.extend(
15759        text.char_indices()
15760            .filter_map(|(index, ch)| (ch == '\n').then_some(index + ch.len_utf8())),
15761    );
15762    starts
15763}
15764
15765fn line_end_offset(text: &str, line_starts: &[usize], line: usize) -> usize {
15766    line_starts.get(line + 1).copied().unwrap_or(text.len())
15767}
15768
15769fn composite_text_segment(
15770    canvas: &mut [u8],
15771    canvas_width: u32,
15772    canvas_height: u32,
15773    canvas_rect: Rect,
15774    segment_rect: Rect,
15775    segment_image: &ImageBitmap,
15776) {
15777    let offset_x = (segment_rect.x - canvas_rect.x).round() as i32;
15778    let offset_y = (segment_rect.y - canvas_rect.y).round() as i32;
15779    let src = segment_image.pixels();
15780    for sy in 0..segment_image.height() as i32 {
15781        let dy = offset_y + sy;
15782        if dy < 0 || dy >= canvas_height as i32 {
15783            continue;
15784        }
15785        for sx in 0..segment_image.width() as i32 {
15786            let dx = offset_x + sx;
15787            if dx < 0 || dx >= canvas_width as i32 {
15788                continue;
15789            }
15790            let src_index = ((sy as u32 * segment_image.width() + sx as u32) * 4) as usize;
15791            let dst_index = ((dy as u32 * canvas_width + dx as u32) * 4) as usize;
15792            blend_rgba_pixel(
15793                &mut canvas[dst_index..dst_index + 4],
15794                &src[src_index..src_index + 4],
15795            );
15796        }
15797    }
15798}
15799
15800fn blend_rgba_pixel(dst: &mut [u8], src: &[u8]) {
15801    let src_alpha = src[3] as f32 / 255.0;
15802    if src_alpha <= 0.0 {
15803        return;
15804    }
15805    let dst_alpha = dst[3] as f32 / 255.0;
15806    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
15807    if out_alpha <= f32::EPSILON {
15808        dst.copy_from_slice(&[0, 0, 0, 0]);
15809        return;
15810    }
15811
15812    for channel in 0..3 {
15813        let src_channel = src[channel] as f32 / 255.0;
15814        let dst_channel = dst[channel] as f32 / 255.0;
15815        let src_premult = src_channel * src_alpha;
15816        let dst_premult = dst_channel * dst_alpha;
15817        dst[channel] =
15818            (((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0) * 255.0)
15819                .round() as u8;
15820    }
15821    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
15822}
15823
15824fn align_to(value: u32, alignment: u32) -> u32 {
15825    debug_assert!(alignment > 0);
15826    value.div_ceil(alignment) * alignment
15827}
15828
15829#[cfg(not(target_arch = "wasm32"))]
15830fn align_usize_to(value: usize, alignment: usize) -> usize {
15831    debug_assert!(alignment > 0);
15832    value.div_ceil(alignment) * alignment
15833}
15834
15835impl GpuRenderer {
15836    fn convert_surface_pixels_to_rgba(&self, pixels: &mut [u8]) -> Result<(), String> {
15837        match self.surface_format {
15838            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => Ok(()),
15839            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
15840                for pixel in pixels.as_chunks_mut::<4>().0 {
15841                    pixel.swap(0, 2);
15842                }
15843                Ok(())
15844            }
15845            format => Err(format!(
15846                "Screenshot readback unsupported for texture format: {format:?}"
15847            )),
15848        }
15849    }
15850}
15851
15852fn is_in_effect_range(z_index: usize, effect_z_ranges: &[Range<usize>]) -> bool {
15853    effect_z_ranges.iter().any(|range| range.contains(&z_index))
15854}
15855
15856#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15857enum SegmentDrawItem {
15858    Shape(usize),
15859    Image(usize),
15860    Text(usize),
15861    Shadow(usize),
15862    Composite(usize),
15863    ShaderComposite(usize),
15864    Retained(usize),
15865}
15866
15867#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15868enum SegmentBatchPlan {
15869    Shape {
15870        start: usize,
15871        end: usize,
15872        blend_mode: BlendMode,
15873    },
15874    Image {
15875        start: usize,
15876        end: usize,
15877        blend_mode: BlendMode,
15878    },
15879    Text {
15880        start: usize,
15881        end: usize,
15882    },
15883    Composite {
15884        start: usize,
15885        end: usize,
15886    },
15887    ShaderComposite {
15888        start: usize,
15889        end: usize,
15890    },
15891    /// Retained replay batches: each item is one bind + draw of GPU slots
15892    /// captured on an earlier frame, so they never merge and cost no budget.
15893    Retained {
15894        start: usize,
15895        end: usize,
15896    },
15897}
15898
15899#[derive(Clone, Debug, Default, PartialEq, Eq)]
15900struct SegmentDrawChunkPlan {
15901    batches: Vec<SegmentBatchPlan>,
15902}
15903
15904struct SegmentRenderOutcome {
15905    rendered_any: bool,
15906    pass_count: u32,
15907}
15908
15909struct SegmentCommandEncodeOutcome {
15910    first_batch: bool,
15911}
15912
15913#[cfg(not(target_arch = "wasm32"))]
15914#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15915enum TextGlyphPrewarmDecision {
15916    Candidate,
15917    MissingGeometry,
15918    DynamicMotion,
15919    Visible,
15920    OutsidePrewarmWindow,
15921}
15922
15923#[cfg(not(target_arch = "wasm32"))]
15924#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15925struct NativeSegmentFusionBudget {
15926    shape_count: usize,
15927    gradient_stop_count: usize,
15928}
15929
15930#[cfg(not(target_arch = "wasm32"))]
15931#[derive(Clone, Debug, PartialEq, Eq)]
15932struct NativeSegmentFusionPartition {
15933    chunk: SegmentDrawChunkPlan,
15934    budget: NativeSegmentFusionBudget,
15935}
15936
15937#[cfg(not(target_arch = "wasm32"))]
15938#[derive(Clone, Debug, PartialEq, Eq)]
15939enum FusedSegmentBatch {
15940    Shape {
15941        batch: PreparedShapeBatch,
15942        blend_mode: BlendMode,
15943    },
15944    Image {
15945        cmd_range: Range<usize>,
15946        blend_mode: BlendMode,
15947    },
15948    Text {
15949        image_cmd_range: Range<usize>,
15950        glyph_cmd_range: Range<usize>,
15951    },
15952    Composite {
15953        draw_range: Range<usize>,
15954    },
15955    ShaderComposite {
15956        draw_range: Range<usize>,
15957    },
15958    Retained {
15959        item_range: Range<usize>,
15960    },
15961}
15962
15963struct ShadowSourceRenderOutcome {
15964    rendered_any: bool,
15965    pass_count: u32,
15966}
15967
15968/// One segment-surface capture this frame must encode: the entry's key,
15969/// the slot shape range, and the claimed per-frame capture slot (transform
15970/// stride + viewport-uniform slot index).
15971#[cfg(not(target_arch = "wasm32"))]
15972struct SegmentCaptureJob {
15973    key: SegmentSurfaceKey,
15974    first: u32,
15975    last: u32,
15976    capture_index: u32,
15977}
15978
15979/// One retained item's cached-composite plan: the dest quad (device px,
15980/// strip order TL TR BL BR) and the dest-px → source-texel inverse under
15981/// this frame's effective transform.
15982#[cfg(not(target_arch = "wasm32"))]
15983struct SegmentCompositePlan {
15984    key: SegmentSurfaceKey,
15985    dest_quad: [[f32; 2]; 4],
15986    inverse: [[f32; 3]; 3],
15987    identity: bool,
15988}
15989
15990/// The capture rect's corners in capture space — also the dest quad under
15991/// an identity effective transform.
15992#[cfg(not(target_arch = "wasm32"))]
15993fn segment_identity_quad(rect: &CaptureRect) -> [[f32; 2]; 4] {
15994    let [x, y] = rect.origin;
15995    let width = rect.width as f32;
15996    let height = rect.height as f32;
15997    [
15998        [x, y],
15999        [x + width, y],
16000        [x, y + height],
16001        [x + width, y + height],
16002    ]
16003}
16004
16005/// Dest px → source texel for the identity case: a pure integer translate,
16006/// so `textureLoad` sampling is texel-exact.
16007#[cfg(not(target_arch = "wasm32"))]
16008fn segment_identity_inverse(rect: &CaptureRect) -> [[f32; 3]; 3] {
16009    [
16010        [1.0, 0.0, -rect.origin[0]],
16011        [0.0, 1.0, -rect.origin[1]],
16012        [0.0, 0.0, 1.0],
16013    ]
16014}
16015
16016/// Measures a shape range's capture geometry under `transform`: the padded
16017/// integer capture rect (None when degenerate or larger than the device
16018/// allows) and the member-quad pixel sum the economics gate prices the
16019/// direct path at (submitted-area scaled for arc-meshed slots).
16020#[cfg(not(target_arch = "wasm32"))]
16021fn plan_segment_capture_geometry(
16022    slot: &ReplaySlot,
16023    first: u32,
16024    last: u32,
16025    transform: SimilarityTransform,
16026    max_texture_dim: u32,
16027) -> Option<(CaptureRect, f32)> {
16028    let range = first as usize..last as usize;
16029    let aabbs = slot.shape_aabbs.get(range)?;
16030    if aabbs.is_empty() {
16031        return None;
16032    }
16033    let affine = Affine2::from_similarity(transform.center, transform.rot, transform.scale);
16034    let mut min = [f32::INFINITY; 2];
16035    let mut max = [f32::NEG_INFINITY; 2];
16036    for aabb in aabbs {
16037        for corner in [
16038            [aabb[0], aabb[1]],
16039            [aabb[2], aabb[1]],
16040            [aabb[0], aabb[3]],
16041            [aabb[2], aabb[3]],
16042        ] {
16043            let p = affine.apply(corner);
16044            min[0] = min[0].min(p[0]);
16045            min[1] = min[1].min(p[1]);
16046            max[0] = max[0].max(p[0]);
16047            max[1] = max[1].max(p[1]);
16048        }
16049    }
16050    let rect = crate::segment_surface::snap_capture_rect(min, max, max_texture_dim)?;
16051    let base_area = slot.area_prefix.get(last as usize).copied()?
16052        - slot.area_prefix.get(first as usize).copied()?;
16053    let member_px = base_area * transform.scale * transform.scale * slot.submitted_area_scale;
16054    Some((rect, member_px))
16055}
16056
16057impl SegmentDrawChunkPlan {
16058    fn is_empty(&self) -> bool {
16059        self.batches.is_empty()
16060    }
16061
16062    fn push(&mut self, batch: SegmentBatchPlan) {
16063        self.batches.push(batch);
16064    }
16065
16066    fn iter(&self) -> impl Iterator<Item = SegmentBatchPlan> + '_ {
16067        self.batches.iter().copied()
16068    }
16069}
16070
16071#[derive(Clone, Debug, PartialEq, Eq)]
16072enum SegmentRenderCommand {
16073    DrawChunk(SegmentDrawChunkPlan),
16074    Shadow(usize),
16075}
16076
16077struct SegmentCommandIter<'a> {
16078    ordered_items: &'a [(usize, SegmentDrawItem)],
16079    shapes: &'a [DrawShape],
16080    images: &'a [ImageDraw],
16081    cursor: usize,
16082    batch_limits: ShapeBatchLimits,
16083}
16084
16085impl<'a> SegmentCommandIter<'a> {
16086    fn new(
16087        ordered_items: &'a [(usize, SegmentDrawItem)],
16088        shapes: &'a [DrawShape],
16089        images: &'a [ImageDraw],
16090        batch_limits: ShapeBatchLimits,
16091    ) -> Self {
16092        Self {
16093            ordered_items,
16094            shapes,
16095            images,
16096            cursor: 0,
16097            batch_limits,
16098        }
16099    }
16100}
16101
16102impl Iterator for SegmentCommandIter<'_> {
16103    type Item = SegmentRenderCommand;
16104
16105    fn next(&mut self) -> Option<Self::Item> {
16106        if self.cursor >= self.ordered_items.len() {
16107            return None;
16108        }
16109
16110        if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
16111            self.cursor += 1;
16112            return Some(SegmentRenderCommand::Shadow(index));
16113        }
16114
16115        let mut chunk = SegmentDrawChunkPlan::default();
16116        while self.cursor < self.ordered_items.len() {
16117            if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
16118                if chunk.is_empty() {
16119                    self.cursor += 1;
16120                    return Some(SegmentRenderCommand::Shadow(index));
16121                }
16122                break;
16123            }
16124
16125            let Some((batch, next_cursor)) = segment_batch_plan_at_cursor(
16126                self.ordered_items,
16127                self.shapes,
16128                self.images,
16129                self.cursor,
16130                self.batch_limits,
16131            ) else {
16132                break;
16133            };
16134            chunk.push(batch);
16135            self.cursor = next_cursor;
16136        }
16137
16138        Some(SegmentRenderCommand::DrawChunk(chunk))
16139    }
16140}
16141
16142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16143struct PreparedShapeBatch {
16144    /// First vertex and vertex count for the unindexed shape draw; always
16145    /// multiples of 6 so `vs_main`'s `vertex_index / 6` lands on whole shapes.
16146    vertex_start: u32,
16147    vertex_count: u32,
16148    /// Whether any shape in the batch carries gradient stops. False routes
16149    /// a SrcOver draw through the `fs_solid` pipeline.
16150    has_gradient: bool,
16151    #[cfg(target_arch = "wasm32")]
16152    shape_slot: usize,
16153    #[cfg(target_arch = "wasm32")]
16154    uniform_slot: usize,
16155}
16156
16157struct PreparedImageBatch {
16158    cmds: Vec<ImageDrawCmd>,
16159    #[cfg(target_arch = "wasm32")]
16160    image_slot: usize,
16161    #[cfg(target_arch = "wasm32")]
16162    uniform_slot: usize,
16163}
16164
16165impl PreparedImageBatch {
16166    fn is_empty(&self) -> bool {
16167        self.cmds.is_empty()
16168    }
16169
16170    fn into_cmds(self) -> Vec<ImageDrawCmd> {
16171        self.cmds
16172    }
16173}
16174
16175struct PreparedGlyphBatch {
16176    cmds: Vec<GlyphDrawCmd>,
16177    #[cfg(target_arch = "wasm32")]
16178    image_slot: usize,
16179    #[cfg(target_arch = "wasm32")]
16180    uniform_slot: usize,
16181}
16182
16183impl PreparedGlyphBatch {
16184    fn is_empty(&self) -> bool {
16185        self.cmds.is_empty()
16186    }
16187
16188    fn into_cmds(self) -> Vec<GlyphDrawCmd> {
16189        self.cmds
16190    }
16191}
16192
16193#[cfg(not(target_arch = "wasm32"))]
16194fn gradient_stop_count_for_shape(shape: &DrawShape, brushes: &[Brush]) -> usize {
16195    match shape.brush {
16196        SceneBrush::Solid(_) => 0,
16197        SceneBrush::Gradient(index) => match &brushes[index as usize] {
16198            Brush::Solid(_) => 0,
16199            Brush::LinearGradient { colors, .. }
16200            | Brush::RadialGradient { colors, .. }
16201            | Brush::SweepGradient { colors, .. } => colors.len(),
16202        },
16203    }
16204}
16205
16206#[cfg(not(target_arch = "wasm32"))]
16207fn native_segment_fusion_budget(
16208    ordered_items: &[(usize, SegmentDrawItem)],
16209    shapes: &[DrawShape],
16210    brushes: &[Brush],
16211    chunk: &SegmentDrawChunkPlan,
16212    batch_limits: ShapeBatchLimits,
16213) -> Result<Option<NativeSegmentFusionBudget>, String> {
16214    let mut shape_count = 0usize;
16215    let mut gradient_stop_count = 0usize;
16216
16217    for batch in chunk.iter() {
16218        let SegmentBatchPlan::Shape { start, end, .. } = batch else {
16219            continue;
16220        };
16221        for (_, item) in &ordered_items[start..end] {
16222            let SegmentDrawItem::Shape(shape_index) = item else {
16223                return Err(format!(
16224                    "shape batch contains non-shape draw item: {item:?}"
16225                ));
16226            };
16227            let shape = &shapes[*shape_index];
16228            shape_count = shape_count.saturating_add(1);
16229            gradient_stop_count =
16230                gradient_stop_count.saturating_add(gradient_stop_count_for_shape(shape, brushes));
16231        }
16232    }
16233
16234    if shape_count > batch_limits.max_shapes_per_batch
16235        || gradient_stop_count > batch_limits.max_gradient_stops
16236    {
16237        return Ok(None);
16238    }
16239
16240    Ok(Some(NativeSegmentFusionBudget {
16241        shape_count,
16242        gradient_stop_count,
16243    }))
16244}
16245
16246#[cfg(not(target_arch = "wasm32"))]
16247fn push_native_segment_fusion_partition(
16248    partitions: &mut Vec<NativeSegmentFusionPartition>,
16249    current: &mut SegmentDrawChunkPlan,
16250    current_budget: &mut NativeSegmentFusionBudget,
16251) {
16252    if current.is_empty() {
16253        return;
16254    }
16255
16256    partitions.push(NativeSegmentFusionPartition {
16257        chunk: std::mem::take(current),
16258        budget: *current_budget,
16259    });
16260    *current_budget = NativeSegmentFusionBudget {
16261        shape_count: 0,
16262        gradient_stop_count: 0,
16263    };
16264}
16265
16266#[cfg(not(target_arch = "wasm32"))]
16267fn native_segment_fusion_partitions(
16268    ordered_items: &[(usize, SegmentDrawItem)],
16269    shapes: &[DrawShape],
16270    brushes: &[Brush],
16271    chunk: &SegmentDrawChunkPlan,
16272    batch_limits: ShapeBatchLimits,
16273) -> Result<Option<Vec<NativeSegmentFusionPartition>>, String> {
16274    if let Some(budget) =
16275        native_segment_fusion_budget(ordered_items, shapes, brushes, chunk, batch_limits)?
16276    {
16277        return Ok(Some(vec![NativeSegmentFusionPartition {
16278            chunk: chunk.clone(),
16279            budget,
16280        }]));
16281    }
16282
16283    let mut partitions = Vec::new();
16284    let mut current = SegmentDrawChunkPlan::default();
16285    let mut current_budget = NativeSegmentFusionBudget {
16286        shape_count: 0,
16287        gradient_stop_count: 0,
16288    };
16289
16290    for batch in chunk.iter() {
16291        let SegmentBatchPlan::Shape {
16292            start,
16293            end,
16294            blend_mode,
16295        } = batch
16296        else {
16297            current.push(batch);
16298            continue;
16299        };
16300
16301        let mut run_start = start;
16302        for (item_cursor, (_, item)) in ordered_items.iter().enumerate().take(end).skip(start) {
16303            let SegmentDrawItem::Shape(shape_index) = *item else {
16304                return Err(format!(
16305                    "shape batch contains non-shape draw item: {:?}",
16306                    item
16307                ));
16308            };
16309            let gradient_stop_count = gradient_stop_count_for_shape(&shapes[shape_index], brushes);
16310            if gradient_stop_count > batch_limits.max_gradient_stops {
16311                return Ok(None);
16312            }
16313
16314            let fits_shape_count =
16315                current_budget.shape_count.saturating_add(1) <= batch_limits.max_shapes_per_batch;
16316            let fits_gradient_count = current_budget
16317                .gradient_stop_count
16318                .saturating_add(gradient_stop_count)
16319                <= batch_limits.max_gradient_stops;
16320            if !fits_shape_count || !fits_gradient_count {
16321                if run_start < item_cursor {
16322                    current.push(SegmentBatchPlan::Shape {
16323                        start: run_start,
16324                        end: item_cursor,
16325                        blend_mode,
16326                    });
16327                }
16328                push_native_segment_fusion_partition(
16329                    &mut partitions,
16330                    &mut current,
16331                    &mut current_budget,
16332                );
16333                run_start = item_cursor;
16334            }
16335
16336            current_budget.shape_count = current_budget.shape_count.saturating_add(1);
16337            current_budget.gradient_stop_count = current_budget
16338                .gradient_stop_count
16339                .saturating_add(gradient_stop_count);
16340        }
16341
16342        if run_start < end {
16343            current.push(SegmentBatchPlan::Shape {
16344                start: run_start,
16345                end,
16346                blend_mode,
16347            });
16348        }
16349    }
16350
16351    push_native_segment_fusion_partition(&mut partitions, &mut current, &mut current_budget);
16352    Ok(Some(partitions))
16353}
16354
16355fn segment_batch_plan_at_cursor(
16356    ordered_items: &[(usize, SegmentDrawItem)],
16357    shapes: &[DrawShape],
16358    images: &[ImageDraw],
16359    start: usize,
16360    batch_limits: ShapeBatchLimits,
16361) -> Option<(SegmentBatchPlan, usize)> {
16362    match ordered_items[start].1 {
16363        SegmentDrawItem::Shape(index) => {
16364            let blend_mode = supported_blend_mode(shapes[index].blend_mode);
16365            let mut end = start + 1;
16366            let shape_limit = (start + batch_limits.max_shapes_per_batch).min(ordered_items.len());
16367            while end < shape_limit {
16368                match ordered_items[end].1 {
16369                    SegmentDrawItem::Shape(next_index)
16370                        if supported_blend_mode(shapes[next_index].blend_mode) == blend_mode =>
16371                    {
16372                        end += 1;
16373                    }
16374                    _ => break,
16375                }
16376            }
16377            Some((
16378                SegmentBatchPlan::Shape {
16379                    start,
16380                    end,
16381                    blend_mode,
16382                },
16383                end,
16384            ))
16385        }
16386        SegmentDrawItem::Image(index) => {
16387            let blend_mode = supported_blend_mode(images[index].blend_mode);
16388            let mut end = start + 1;
16389            while end < ordered_items.len() {
16390                match ordered_items[end].1 {
16391                    SegmentDrawItem::Image(next_index)
16392                        if supported_blend_mode(images[next_index].blend_mode) == blend_mode =>
16393                    {
16394                        end += 1;
16395                    }
16396                    _ => break,
16397                }
16398            }
16399            Some((
16400                SegmentBatchPlan::Image {
16401                    start,
16402                    end,
16403                    blend_mode,
16404                },
16405                end,
16406            ))
16407        }
16408        SegmentDrawItem::Text(_) => {
16409            let mut end = start + 1;
16410            while end < ordered_items.len() {
16411                if matches!(ordered_items[end].1, SegmentDrawItem::Text(_)) {
16412                    end += 1;
16413                } else {
16414                    break;
16415                }
16416            }
16417            Some((SegmentBatchPlan::Text { start, end }, end))
16418        }
16419        SegmentDrawItem::Composite(_) => {
16420            let mut end = start + 1;
16421            while end < ordered_items.len() {
16422                if matches!(ordered_items[end].1, SegmentDrawItem::Composite(_)) {
16423                    end += 1;
16424                } else {
16425                    break;
16426                }
16427            }
16428            Some((SegmentBatchPlan::Composite { start, end }, end))
16429        }
16430        SegmentDrawItem::ShaderComposite(_) => {
16431            let mut end = start + 1;
16432            while end < ordered_items.len() {
16433                if matches!(ordered_items[end].1, SegmentDrawItem::ShaderComposite(_)) {
16434                    end += 1;
16435                } else {
16436                    break;
16437                }
16438            }
16439            Some((SegmentBatchPlan::ShaderComposite { start, end }, end))
16440        }
16441        SegmentDrawItem::Retained(_) => {
16442            let mut end = start + 1;
16443            while end < ordered_items.len() {
16444                if matches!(ordered_items[end].1, SegmentDrawItem::Retained(_)) {
16445                    end += 1;
16446                } else {
16447                    break;
16448                }
16449            }
16450            Some((SegmentBatchPlan::Retained { start, end }, end))
16451        }
16452        SegmentDrawItem::Shadow(_) => None,
16453    }
16454}
16455
16456#[allow(clippy::too_many_arguments)]
16457fn collect_non_effect_segment_items(
16458    shapes: &[DrawShape],
16459    _images: &[ImageDraw],
16460    _texts: &[TextDraw],
16461    _shadow_draws: &[ShadowDraw],
16462    draw_ops: &[DrawOp],
16463    z_start: usize,
16464    z_end: usize,
16465    effect_z_ranges: &[Range<usize>],
16466    width: u32,
16467    height: u32,
16468    root_scale: f32,
16469    scratch: &mut Vec<(usize, SegmentDrawItem)>,
16470) {
16471    scratch.clear();
16472    let viewport = ViewportUniformParams {
16473        width,
16474        height,
16475        offset: [0.0, 0.0],
16476    };
16477
16478    scratch.extend(draw_ops.iter().filter_map(|op| {
16479        if op.z_index < z_start
16480            || op.z_index >= z_end
16481            || is_in_effect_range(op.z_index, effect_z_ranges)
16482        {
16483            return None;
16484        }
16485        let item = match op.kind {
16486            DrawOpKind::Shape(index) => {
16487                let shape = shapes.get(index)?;
16488                if !shape_draw_is_visible_in_viewport(shape, viewport, root_scale) {
16489                    return None;
16490                }
16491                SegmentDrawItem::Shape(index)
16492            }
16493            DrawOpKind::Image(index) => SegmentDrawItem::Image(index),
16494            DrawOpKind::Text(index) => SegmentDrawItem::Text(index),
16495            DrawOpKind::Shadow(index) => SegmentDrawItem::Shadow(index),
16496            DrawOpKind::Retained(index) => SegmentDrawItem::Retained(index),
16497        };
16498        Some((op.z_index, item))
16499    }));
16500}
16501
16502fn retain_renderable_shadow_items(
16503    ordered_items: &mut Vec<(usize, SegmentDrawItem)>,
16504    shadow_draws: &[ShadowDraw],
16505    width: u32,
16506    height: u32,
16507    root_scale: f32,
16508    max_texture_dim: u32,
16509) -> usize {
16510    let original_len = ordered_items.len();
16511    ordered_items.retain(|(_, item)| match item {
16512        SegmentDrawItem::Shadow(index) => shadow_draws.get(*index).is_some_and(|shadow| {
16513            shadow_draw_may_render(shadow, width, height, root_scale, max_texture_dim)
16514        }),
16515        _ => true,
16516    });
16517    original_len.saturating_sub(ordered_items.len())
16518}
16519
16520#[cfg(not(target_arch = "wasm32"))]
16521#[derive(Clone, Copy)]
16522struct SegmentDiagCounts {
16523    raw_shadow_items: usize,
16524    culled_shadow_items: usize,
16525    cached_shadow_composites: usize,
16526    composite_items: usize,
16527    shader_composite_items: usize,
16528}
16529
16530#[cfg(not(target_arch = "wasm32"))]
16531fn maybe_print_segment_diag(
16532    z_range: Range<usize>,
16533    ordered_items: &[(usize, SegmentDrawItem)],
16534    shapes: &[DrawShape],
16535    brushes: &[Brush],
16536    images: &[ImageDraw],
16537    counts: SegmentDiagCounts,
16538    batch_limits: ShapeBatchLimits,
16539) {
16540    if !cranpose_core::env_flag!("CRANPOSE_SEGMENT_DIAG") {
16541        return;
16542    }
16543    let line = SEGMENT_DIAG_LINES.fetch_add(1, Ordering::Relaxed);
16544    if line >= 64 {
16545        return;
16546    }
16547
16548    let remaining_shadow_items = ordered_items
16549        .iter()
16550        .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
16551        .count();
16552    let commands: Vec<_> =
16553        SegmentCommandIter::new(ordered_items, shapes, images, batch_limits).collect();
16554    let draw_chunks = commands
16555        .iter()
16556        .filter(|command| matches!(command, SegmentRenderCommand::DrawChunk(_)))
16557        .count();
16558    let shadow_commands = commands
16559        .iter()
16560        .filter(|command| matches!(command, SegmentRenderCommand::Shadow(_)))
16561        .count();
16562    let mut native_partitions = 0usize;
16563    let mut native_unfused_chunks = 0usize;
16564    for command in &commands {
16565        let SegmentRenderCommand::DrawChunk(chunk) = command else {
16566            continue;
16567        };
16568        match native_segment_fusion_partitions(ordered_items, shapes, brushes, chunk, batch_limits)
16569        {
16570            Ok(Some(partitions)) => native_partitions += partitions.len(),
16571            Ok(None) | Err(_) => native_unfused_chunks += 1,
16572        }
16573    }
16574
16575    eprintln!(
16576        "[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={}",
16577        z_range.start,
16578        z_range.end,
16579        ordered_items.len(),
16580        counts.raw_shadow_items,
16581        counts.culled_shadow_items,
16582        counts.cached_shadow_composites,
16583        remaining_shadow_items,
16584        counts.composite_items,
16585        counts.shader_composite_items,
16586        draw_chunks,
16587        shadow_commands,
16588        native_partitions,
16589        native_unfused_chunks,
16590    );
16591}
16592
16593pub(crate) fn has_backdrop_layer_in_range(
16594    backdrop_layers: &[BackdropLayer],
16595    z_start: usize,
16596    z_end: usize,
16597) -> bool {
16598    backdrop_layers
16599        .iter()
16600        .any(|layer| layer.z_index >= z_start && layer.z_index < z_end)
16601}
16602
16603pub(crate) fn scissor_rect_for_rect(
16604    rect: Rect,
16605    root_scale: f32,
16606    width: u32,
16607    height: u32,
16608) -> Option<(u32, u32, u32, u32)> {
16609    let mut left = canonicalize_device_coordinate(rect.x * root_scale);
16610    let mut top = canonicalize_device_coordinate(rect.y * root_scale);
16611    let mut right = canonicalize_device_coordinate((rect.x + rect.width) * root_scale);
16612    let mut bottom = canonicalize_device_coordinate((rect.y + rect.height) * root_scale);
16613
16614    left = left.max(0.0).min(width as f32).floor();
16615    top = top.max(0.0).min(height as f32).floor();
16616    right = right.max(0.0).min(width as f32).ceil();
16617    bottom = bottom.max(0.0).min(height as f32).ceil();
16618
16619    if right <= left || bottom <= top {
16620        return None;
16621    }
16622
16623    Some((
16624        left as u32,
16625        top as u32,
16626        (right - left) as u32,
16627        (bottom - top) as u32,
16628    ))
16629}
16630
16631fn scissor_rect_for_layer(
16632    rect: Rect,
16633    clip: Option<Rect>,
16634    root_scale: f32,
16635    width: u32,
16636    height: u32,
16637) -> Option<(u32, u32, u32, u32)> {
16638    let clipped_rect = match clip {
16639        Some(clip_rect) => rect.intersect(clip_rect)?,
16640        None => rect,
16641    };
16642
16643    scissor_rect_for_rect(clipped_rect, root_scale, width, height)
16644}
16645
16646fn tint_for_image(
16647    color_filter: Option<ColorFilter>,
16648    alpha: f32,
16649) -> ([f32; 4], Option<ColorFilter>) {
16650    let alpha = alpha.clamp(0.0, 1.0);
16651    match color_filter {
16652        Some(filter) if filter.supports_gpu_vertex_modulation() => {
16653            let Some(tint) = filter.gpu_vertex_tint() else {
16654                return ([1.0, 1.0, 1.0, alpha], Some(filter));
16655            };
16656            (
16657                [
16658                    tint[0].clamp(0.0, 1.0),
16659                    tint[1].clamp(0.0, 1.0),
16660                    tint[2].clamp(0.0, 1.0),
16661                    (tint[3] * alpha).clamp(0.0, 1.0),
16662                ],
16663                None,
16664            )
16665        }
16666        Some(filter) => ([1.0, 1.0, 1.0, alpha], Some(filter)),
16667        None => ([1.0, 1.0, 1.0, alpha], None),
16668    }
16669}
16670
16671fn image_uv_rect(image: &ImageBitmap, src_rect: Option<Rect>) -> Option<ImageUvRect> {
16672    let Some(src) = src_rect else {
16673        return Some(ImageUvRect {
16674            min: [0.0, 0.0],
16675            max: [1.0, 1.0],
16676            sample_bounds: [0.0, 0.0, 1.0, 1.0],
16677        });
16678    };
16679
16680    let (u_min, u_max, u_bound_min, u_bound_max) =
16681        source_axis_uv(src.x, src.width, image.width() as f32)?;
16682    let (v_min, v_max, v_bound_min, v_bound_max) =
16683        source_axis_uv(src.y, src.height, image.height() as f32)?;
16684
16685    Some(ImageUvRect {
16686        min: [u_min, v_min],
16687        max: [u_max, v_max],
16688        sample_bounds: [u_bound_min, v_bound_min, u_bound_max, v_bound_max],
16689    })
16690}
16691
16692/// Normalises an atlas entry against `atlas_size`, the side length of the
16693/// texture the entry was placed in. The atlas grows on overflow, so the size
16694/// has to be read from the live atlas rather than a constant — a UV computed
16695/// against the wrong size samples the wrong glyph.
16696fn glyph_atlas_uv_rect(entry: GlyphAtlasEntry, atlas_size: u32) -> ImageUvRect {
16697    let atlas_width = atlas_size as f32;
16698    let atlas_height = atlas_size as f32;
16699    let min = [entry.x as f32 / atlas_width, entry.y as f32 / atlas_height];
16700    let max = [
16701        (entry.x + entry.width) as f32 / atlas_width,
16702        (entry.y + entry.height) as f32 / atlas_height,
16703    ];
16704    let center_min = [
16705        (entry.x as f32 + 0.5) / atlas_width,
16706        (entry.y as f32 + 0.5) / atlas_height,
16707    ];
16708    let center_max = [
16709        (entry.x as f32 + entry.width as f32 - 0.5).max(entry.x as f32 + 0.5) / atlas_width,
16710        (entry.y as f32 + entry.height as f32 - 0.5).max(entry.y as f32 + 0.5) / atlas_height,
16711    ];
16712    ImageUvRect {
16713        min,
16714        max,
16715        sample_bounds: [center_min[0], center_min[1], center_max[0], center_max[1]],
16716    }
16717}
16718
16719fn snap_nearest_image_to_device_pixels(image: &mut ImageDraw, root_scale: f32) {
16720    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
16721        return;
16722    }
16723
16724    let Some(rect) = axis_aligned_quad_rect(image.quad) else {
16725        return;
16726    };
16727
16728    let left_px = (rect.x * root_scale).round();
16729    let top_px = (rect.y * root_scale).round();
16730    let width_px = (rect.width * root_scale).round().max(1.0);
16731    let height_px = (rect.height * root_scale).round().max(1.0);
16732    let snapped = Rect {
16733        x: left_px / root_scale,
16734        y: top_px / root_scale,
16735        width: width_px / root_scale,
16736        height: height_px / root_scale,
16737    };
16738
16739    image.rect = snapped;
16740    image.local_rect = Rect {
16741        x: image.local_rect.x + snapped.x - rect.x,
16742        y: image.local_rect.y + snapped.y - rect.y,
16743        width: snapped.width,
16744        height: snapped.height,
16745    };
16746    image.quad = crate::rect_to_quad(snapped);
16747}
16748
16749fn nearest_image_device_quad(image: &ImageDraw, root_scale: f32) -> Option<[[f32; 2]; 4]> {
16750    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
16751        return None;
16752    }
16753
16754    let rect = axis_aligned_quad_rect(image.quad)?;
16755    let left_px = (rect.x * root_scale).round();
16756    let top_px = (rect.y * root_scale).round();
16757    let width_px = (rect.width * root_scale).round().max(1.0);
16758    let height_px = (rect.height * root_scale).round().max(1.0);
16759    let right_px = left_px + width_px;
16760    let bottom_px = top_px + height_px;
16761    Some([
16762        [left_px, top_px],
16763        [right_px, top_px],
16764        [left_px, bottom_px],
16765        [right_px, bottom_px],
16766    ])
16767}
16768
16769fn source_axis_uv(start: f32, extent: f32, image_extent: f32) -> Option<(f32, f32, f32, f32)> {
16770    if !start.is_finite()
16771        || !extent.is_finite()
16772        || !image_extent.is_finite()
16773        || extent == 0.0
16774        || image_extent <= 0.0
16775    {
16776        return None;
16777    }
16778
16779    let end = start + extent;
16780    let edge_min = start.min(end).clamp(0.0, image_extent);
16781    let edge_max = start.max(end).clamp(0.0, image_extent);
16782    if edge_max <= edge_min {
16783        return None;
16784    }
16785
16786    let center_min = edge_min + 0.5;
16787    let center_max = edge_max - 0.5;
16788    let (bound_min, bound_max) = if center_min <= center_max {
16789        (center_min, center_max)
16790    } else {
16791        let center = (edge_min + edge_max) * 0.5;
16792        (center, center)
16793    };
16794
16795    Some((
16796        edge_min / image_extent,
16797        edge_max / image_extent,
16798        bound_min / image_extent,
16799        bound_max / image_extent,
16800    ))
16801}
16802
16803fn apply_filter_to_bitmap(image: &ImageBitmap, filter: ColorFilter) -> Result<ImageBitmap, String> {
16804    let mut filtered = Vec::with_capacity(image.pixels().len());
16805    for pixel in image.pixels().as_chunks::<4>().0 {
16806        let rgba = [
16807            pixel[0] as f32 / 255.0,
16808            pixel[1] as f32 / 255.0,
16809            pixel[2] as f32 / 255.0,
16810            pixel[3] as f32 / 255.0,
16811        ];
16812        let out = filter.apply_rgba(rgba);
16813        filtered.push((out[0].clamp(0.0, 1.0) * 255.0).round() as u8);
16814        filtered.push((out[1].clamp(0.0, 1.0) * 255.0).round() as u8);
16815        filtered.push((out[2].clamp(0.0, 1.0) * 255.0).round() as u8);
16816        filtered.push((out[3].clamp(0.0, 1.0) * 255.0).round() as u8);
16817    }
16818    ImageBitmap::from_rgba8(image.width(), image.height(), filtered)
16819        .map_err(|error| format!("failed to build filtered bitmap: {error}"))
16820}
16821
16822fn scissor_rect_for_image(
16823    image: &ImageDraw,
16824    root_scale: f32,
16825    width: u32,
16826    height: u32,
16827) -> Option<(u32, u32, u32, u32)> {
16828    scissor_rect_for_layer(image.rect, image.clip, root_scale, width, height)
16829}
16830
16831fn inner_shadow_composite_mask(
16832    shadow: &ShadowDraw,
16833    root_scale: f32,
16834) -> Option<RoundedCompositeMask> {
16835    if !shadow
16836        .shapes
16837        .iter()
16838        .any(|(_, mode)| *mode == BlendMode::DstOut)
16839    {
16840        return None;
16841    }
16842    let (fill, _) = shadow.shapes.first()?;
16843    let rect = fill.local_rect;
16844    if rect.width <= 0.0 || rect.height <= 0.0 {
16845        return None;
16846    }
16847
16848    let radii = fill.shape.map_or([0.0; 4], |rounded| {
16849        let resolved = rounded.resolve(rect.width, rect.height);
16850        [
16851            resolved.top_left * root_scale,
16852            resolved.top_right * root_scale,
16853            resolved.bottom_left * root_scale,
16854            resolved.bottom_right * root_scale,
16855        ]
16856    });
16857
16858    Some(RoundedCompositeMask {
16859        rect: [
16860            rect.x * root_scale,
16861            rect.y * root_scale,
16862            rect.width * root_scale,
16863            rect.height * root_scale,
16864        ],
16865        radii,
16866    })
16867}
16868
16869#[cfg(test)]
16870mod shape_batch_limits_tests {
16871    use super::*;
16872
16873    /// A device that reports plenty of storage buffers, as ARM's GLES driver
16874    /// does off the fragment stage.
16875    fn generous_limits() -> wgpu::Limits {
16876        wgpu::Limits {
16877            max_storage_buffers_per_shader_stage: 8,
16878            max_storage_buffer_binding_size: 128 << 20,
16879            max_uniform_buffer_binding_size: 16 << 10,
16880            ..wgpu::Limits::default()
16881        }
16882    }
16883
16884    #[test]
16885    fn a_device_without_vertex_storage_takes_the_uniform_path() {
16886        // The shape array is bound VERTEX_FRAGMENT because `vs_main` reads
16887        // quad corners out of it, so a device that cannot read storage from
16888        // the vertex stage cannot host the storage layout AT ALL -- creating
16889        // it is a validation error and wgpu makes that fatal. The limit alone
16890        // says nothing about it: Mali reports 8 here and zero vertex storage.
16891        let limits = ShapeBatchLimits::select(&generous_limits(), wgpu::DownlevelFlags::empty());
16892        assert!(
16893            !limits.storage,
16894            "no VERTEX_STORAGE must mean uniform mode, whatever the limit says"
16895        );
16896    }
16897
16898    #[test]
16899    fn a_device_with_vertex_storage_still_takes_the_storage_path() {
16900        let limits = ShapeBatchLimits::select(&generous_limits(), wgpu::DownlevelFlags::all());
16901        assert!(
16902            limits.storage,
16903            "the flag must not cost storage mode on a device that has it"
16904        );
16905    }
16906
16907    #[test]
16908    fn the_limit_still_gates_storage_when_the_flag_is_present() {
16909        let mut limits = generous_limits();
16910        limits.max_storage_buffers_per_shader_stage = 1;
16911        let limits = ShapeBatchLimits::select(&limits, wgpu::DownlevelFlags::all());
16912        assert!(!limits.storage, "two bindings are needed, not one");
16913    }
16914}
16915
16916#[cfg(test)]
16917mod tests {
16918    use super::*;
16919    use crate::normalized_scene::visible_draw_rect;
16920    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
16921    use cranpose_render_common::graph::{DrawPrimitiveNode, IsolationReasons, TextPrimitiveNode};
16922    use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
16923    use cranpose_render_common::scene_builder::build_graph_from_applier;
16924    use cranpose_ui::text::{
16925        AnnotatedString, BaselineShift, RangeStyle, Shadow, SpanStyle, TextDecoration,
16926        TextDrawStyle, TextGeometricTransform, TextMotion, TextUnit,
16927    };
16928    use cranpose_ui::{
16929        LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Size, Text, TextLayoutOptions,
16930        TextStyle,
16931    };
16932    use cranpose_ui_graphics::{
16933        Brush, Color, CornerRadii, DrawPrimitive, Rect, RenderEffect, RoundedCornerShape,
16934        RuntimeShader,
16935    };
16936
16937    fn chunk(batches: &[SegmentBatchPlan]) -> SegmentDrawChunkPlan {
16938        let mut chunk = SegmentDrawChunkPlan::default();
16939        for batch in batches {
16940            chunk.push(*batch);
16941        }
16942        chunk
16943    }
16944
16945    fn with_test_app_context<R>(block: impl FnOnce() -> R) -> R {
16946        let app_context = cranpose_ui::AppContext::new();
16947        app_context.enter(block)
16948    }
16949
16950    fn assert_snap_anchor_close(actual: Option<SnapAnchor>, expected_origin: Point, message: &str) {
16951        let Some(actual) = actual else {
16952            panic!("{message}: missing snap anchor");
16953        };
16954        let expected = SnapAnchor::rigid(expected_origin);
16955        assert_eq!(
16956            actual.device_pixel_step, expected.device_pixel_step,
16957            "{message}: device pixel step changed"
16958        );
16959        assert!(
16960            (actual.origin.x - expected.origin.x).abs() <= 1e-4
16961                && (actual.origin.y - expected.origin.y).abs() <= 1e-4,
16962            "{message}: expected origin {:?}, got {:?}",
16963            expected.origin,
16964            actual.origin
16965        );
16966    }
16967
16968    fn effect_layer(z_start: usize, z_end: usize) -> EffectLayer {
16969        EffectLayer {
16970            rect: Rect {
16971                x: 0.0,
16972                y: 0.0,
16973                width: 10.0,
16974                height: 10.0,
16975            },
16976            clip: None,
16977            snap_anchor: None,
16978            effect: Some(RenderEffect::blur(4.0)),
16979            blend_mode: BlendMode::SrcOver,
16980            composite_alpha: 1.0,
16981            z_start,
16982            z_end,
16983            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
16984        }
16985    }
16986
16987    #[test]
16988    fn direct_shader_composite_accepts_box4_when_viewport_preserves_source_pixels() {
16989        assert_eq!(
16990            direct_shader_composite_viewport(
16991                1.0,
16992                BlendMode::SrcOver,
16993                Some((12.0, 18.0, 64.0, 32.0)),
16994                CompositeSampleMode::Box4,
16995                (64, 32),
16996            ),
16997            Some((12.0, 18.0, 64.0, 32.0))
16998        );
16999    }
17000
17001    #[test]
17002    fn direct_shader_composite_rejects_box4_when_viewport_resamples_source() {
17003        assert_eq!(
17004            direct_shader_composite_viewport(
17005                1.0,
17006                BlendMode::SrcOver,
17007                Some((12.0, 18.0, 64.5, 32.0)),
17008                CompositeSampleMode::Box4,
17009                (64, 32),
17010            ),
17011            None
17012        );
17013        assert_eq!(
17014            direct_shader_composite_viewport(
17015                1.0,
17016                BlendMode::SrcOver,
17017                Some((12.25, 18.0, 64.0, 32.0)),
17018                CompositeSampleMode::Box4,
17019                (64, 32),
17020            ),
17021            None
17022        );
17023    }
17024
17025    fn test_text_draw(rect: Rect, text_motion: TextMotion) -> TextDraw {
17026        let mut text_style = TextStyle::default();
17027        text_style.paragraph_style.text_motion = Some(text_motion);
17028        TextDraw {
17029            node_id: 42,
17030            rect,
17031            snap_anchor: None,
17032            translated_content_context: false,
17033            text: Arc::new(AnnotatedString::new("stable markdown row".to_string()).render_string()),
17034            color: Color::WHITE,
17035            text_style,
17036            font_size: 14.0,
17037            scale: 1.0,
17038            layout_options: TextLayoutOptions::default(),
17039            z_index: 0,
17040            clip: None,
17041        }
17042    }
17043
17044    #[test]
17045    fn static_text_image_cache_key_ignores_absolute_scroll_position() {
17046        let base = test_text_draw(
17047            Rect {
17048                x: 12.25,
17049                y: 40.75,
17050                width: 220.0,
17051                height: 24.0,
17052            },
17053            TextMotion::Static,
17054        );
17055        let scrolled = test_text_draw(
17056            Rect {
17057                x: 12.75,
17058                y: -318.5,
17059                width: 220.0,
17060                height: 24.0,
17061            },
17062            TextMotion::Static,
17063        );
17064
17065        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, true);
17066        let scrolled_key = GpuRenderer::text_image_cache_key(&scrolled, scrolled.rect, 1.0, true);
17067
17068        assert_eq!(
17069            base_key, scrolled_key,
17070            "scrolling static text must reuse the same raster cache entry"
17071        );
17072    }
17073
17074    #[test]
17075    fn static_text_glyph_run_cache_key_ignores_absolute_scroll_position() {
17076        let base = test_text_draw(
17077            Rect {
17078                x: 12.25,
17079                y: 40.75,
17080                width: 220.0,
17081                height: 24.0,
17082            },
17083            TextMotion::Static,
17084        );
17085        let scrolled = test_text_draw(
17086            Rect {
17087                x: 12.75,
17088                y: -318.5,
17089                width: 220.0,
17090                height: 24.0,
17091            },
17092            TextMotion::Static,
17093        );
17094
17095        let base_key = GpuRenderer::text_glyph_run_cache_key(&base, base.rect, 1.0, true);
17096        let scrolled_key =
17097            GpuRenderer::text_glyph_run_cache_key(&scrolled, scrolled.rect, 1.0, true);
17098
17099        assert_eq!(
17100            base_key, scrolled_key,
17101            "scrolling static text must reuse the same retained glyph run"
17102        );
17103    }
17104
17105    #[test]
17106    fn static_multiline_text_glyph_source_keeps_full_text_when_image_source_slices() {
17107        let rect = Rect {
17108            x: 8.0,
17109            y: 100.0,
17110            width: 240.0,
17111            height: 1_000.0,
17112        };
17113        let mut draw = test_text_draw(rect, TextMotion::Static);
17114        let lines = (0..100)
17115            .map(|line| format!("line-{line:03}"))
17116            .collect::<Vec<_>>()
17117            .join("\n");
17118        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
17119
17120        let raster_rect = Rect {
17121            x: 16.0,
17122            y: 200.0,
17123            width: 480.0,
17124            height: 2_000.0,
17125        };
17126        let clipped = clipped_text_raster_source(
17127            &draw,
17128            rect,
17129            raster_rect,
17130            Some(Rect {
17131                x: 0.0,
17132                y: 610.0,
17133                width: 800.0,
17134                height: 40.0,
17135            }),
17136            2.0,
17137            true,
17138        );
17139        let glyph = text_glyph_raster_source(&draw, raster_rect);
17140
17141        assert!(
17142            matches!(clipped.draw, Cow::Owned(_)),
17143            "the image source should still slice large clipped multiline text"
17144        );
17145        assert!(
17146            matches!(glyph.draw, Cow::Borrowed(_)),
17147            "the glyph source must keep a stable full-text run key while scrolling"
17148        );
17149
17150        let clipped_key = GpuRenderer::text_glyph_run_cache_key(
17151            clipped.draw.as_ref(),
17152            clipped.raster_rect,
17153            2.0,
17154            true,
17155        );
17156        let glyph_key = GpuRenderer::text_glyph_run_cache_key(
17157            glyph.draw.as_ref(),
17158            glyph.raster_rect,
17159            2.0,
17160            true,
17161        );
17162
17163        assert_ne!(
17164            clipped_key, glyph_key,
17165            "image slicing must not force glyph rendering onto per-scroll line-window cache keys"
17166        );
17167    }
17168
17169    #[cfg(not(target_arch = "wasm32"))]
17170    #[test]
17171    fn retained_glyph_viewport_offsets_relative_vertices_by_source_origin() {
17172        let viewport = ViewportUniformParams {
17173            width: 800,
17174            height: 600,
17175            offset: [10.0, 20.0],
17176        };
17177        let source = Rect {
17178            x: 40.0,
17179            y: 90.0,
17180            width: 120.0,
17181            height: 48.0,
17182        };
17183
17184        let retained = GpuRenderer::retained_glyph_viewport(viewport, source);
17185
17186        assert_eq!(retained.width, viewport.width);
17187        assert_eq!(retained.height, viewport.height);
17188        assert_eq!(retained.offset, [-30.0, -70.0]);
17189    }
17190
17191    #[cfg(not(target_arch = "wasm32"))]
17192    #[test]
17193    fn tiny_text_glyph_runs_stay_in_shared_uploads() {
17194        assert!(
17195            !should_use_retained_text_glyph_run(8, None),
17196            "tiny labels must stay in the shared fused batch"
17197        );
17198    }
17199
17200    #[cfg(not(target_arch = "wasm32"))]
17201    #[test]
17202    fn line_sized_text_glyph_runs_stay_in_shared_uploads() {
17203        assert!(
17204            !should_use_retained_text_glyph_run(64, None),
17205            "Markdown scroll frames contain many line-sized text runs; retaining each one creates per-run buffer binds instead of one shared glyph batch"
17206        );
17207    }
17208
17209    #[cfg(not(target_arch = "wasm32"))]
17210    #[test]
17211    fn large_clipped_text_glyph_runs_stay_in_shared_uploads() {
17212        assert!(
17213            !should_use_retained_text_glyph_run(
17214                MIN_RETAINED_TEXT_GLYPH_QUADS.saturating_mul(2),
17215                Some(Rect {
17216                    x: 0.0,
17217                    y: 0.0,
17218                    width: 200.0,
17219                    height: 100.0,
17220                }),
17221            ),
17222            "clipped lazy-list text must not draw a full retained run outside the viewport"
17223        );
17224    }
17225
17226    #[test]
17227    fn normal_text_glyph_draw_skips_offscreen_prewarm_candidates() {
17228        assert_eq!(
17229            text_glyph_draw_action(false, true, false),
17230            TextGlyphDrawAction::Skip,
17231            "normal draw traversal must not prepare offscreen text"
17232        );
17233    }
17234
17235    #[test]
17236    fn bounded_text_glyph_prewarm_admits_offscreen_candidates() {
17237        assert_eq!(
17238            text_glyph_draw_action(false, true, true),
17239            TextGlyphDrawAction::PrewarmOffscreen,
17240            "only the bounded prewarm path may prepare offscreen text"
17241        );
17242    }
17243
17244    #[test]
17245    fn visible_text_glyph_draws_are_always_admitted() {
17246        assert_eq!(
17247            text_glyph_draw_action(true, false, false),
17248            TextGlyphDrawAction::DrawVisible
17249        );
17250        assert_eq!(
17251            text_glyph_draw_action(true, true, true),
17252            TextGlyphDrawAction::DrawVisible
17253        );
17254    }
17255
17256    #[cfg(not(target_arch = "wasm32"))]
17257    #[test]
17258    fn offscreen_text_prewarm_skips_large_uncached_text_runs() {
17259        assert!(
17260            !offscreen_text_glyph_prewarm_work_is_bounded(
17261                None,
17262                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS + 1,
17263            ),
17264            "offscreen prewarm must not collect large uncached text runs in an input frame"
17265        );
17266    }
17267
17268    #[cfg(not(target_arch = "wasm32"))]
17269    #[test]
17270    fn offscreen_text_prewarm_admits_small_uncached_text_runs() {
17271        assert!(
17272            offscreen_text_glyph_prewarm_work_is_bounded(
17273                None,
17274                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
17275            ),
17276            "small labels can be warmed without risking a frame-budget spike"
17277        );
17278    }
17279
17280    #[cfg(not(target_arch = "wasm32"))]
17281    #[test]
17282    fn offscreen_text_prewarm_skips_large_cached_runs_without_quads() {
17283        assert!(
17284            !offscreen_text_glyph_prewarm_work_is_bounded(
17285                Some(MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS + 1),
17286                0,
17287            ),
17288            "cached glyph placements can still be too large to prepare during input frames"
17289        );
17290    }
17291
17292    #[cfg(not(target_arch = "wasm32"))]
17293    #[test]
17294    fn offscreen_text_prewarm_stops_after_candidate_budget() {
17295        assert!(
17296            offscreen_text_glyph_prewarm_budget_exhausted(
17297                Instant::now(),
17298                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES,
17299            ),
17300            "prewarm must be bounded by candidate count even when each candidate is cheap"
17301        );
17302    }
17303
17304    #[test]
17305    fn clipped_cached_glyph_quads_are_filtered_to_viewport() {
17306        fn quad(y: i32) -> CachedTextGlyphQuad {
17307            CachedTextGlyphQuad {
17308                x: 8,
17309                y,
17310                width: 20,
17311                height: 10,
17312                color: (1.0, 1.0, 1.0, 1.0),
17313                uv: ImageUvRect {
17314                    min: [0.0, 0.0],
17315                    max: [1.0, 1.0],
17316                    sample_bounds: [0.0, 0.0, 1.0, 1.0],
17317                },
17318            }
17319        }
17320
17321        let source = Rect {
17322            x: 0.0,
17323            y: 0.0,
17324            width: 320.0,
17325            height: 400.0,
17326        };
17327        let clip = Some(Rect {
17328            x: 0.0,
17329            y: 0.0,
17330            width: 320.0,
17331            height: 80.0,
17332        });
17333        let viewport = ViewportUniformParams {
17334            width: 320,
17335            height: 80,
17336            offset: [0.0, 0.0],
17337        };
17338
17339        assert!(cached_text_glyph_quad_is_visible_in_viewport(
17340            source,
17341            &quad(40),
17342            clip,
17343            viewport,
17344            1.0,
17345        ));
17346        assert!(
17347            !cached_text_glyph_quad_is_visible_in_viewport(source, &quad(140), clip, viewport, 1.0,),
17348            "glyphs outside the effective clip should not enter the frame command stream"
17349        );
17350    }
17351
17352    #[test]
17353    fn small_scene_range_cache_miss_observes_first_render() {
17354        let key = LayerRasterCacheKey::scene_range(
17355            0xCACE,
17356            Rect {
17357                x: 0.0,
17358                y: 0.0,
17359                width: 120.0,
17360                height: 80.0,
17361            },
17362            (120, 80),
17363            ScaleBucket::from_scale(1.0),
17364        );
17365
17366        assert!(
17367            !first_cache_miss_admission(&key),
17368            "a small scene-range miss should render directly first instead of materializing a tiny one-frame retained target"
17369        );
17370        assert!(
17371            repeated_cache_miss_admission(&key),
17372            "a repeated small scene-range miss is stable enough to materialize into the retained cache"
17373        );
17374    }
17375
17376    #[test]
17377    fn large_scene_range_cache_miss_requires_repeated_stable_key() {
17378        let key = LayerRasterCacheKey::scene_range(
17379            0xCACE,
17380            Rect {
17381                x: 0.0,
17382                y: 0.0,
17383                width: 1200.0,
17384                height: 900.0,
17385            },
17386            (1200, 900),
17387            ScaleBucket::from_scale(1.0),
17388        );
17389
17390        assert!(
17391            !first_cache_miss_admission(&key),
17392            "a large first scene-range miss should render directly instead of materializing a multi-MB one-frame cache entry"
17393        );
17394        assert!(
17395            repeated_cache_miss_admission(&key),
17396            "a repeated scene-range miss is stable enough to materialize into the retained cache"
17397        );
17398    }
17399
17400    #[test]
17401    fn renderer_warmup_frame_is_requested_for_cache_miss_stats_only() {
17402        let stats = gpu_stats::FrameStats::default();
17403        let mut snapshot = stats.snapshot();
17404        assert!(
17405            !frame_stats_need_warmup_frame(&snapshot),
17406            "a clean frame must not keep a static scene redrawing"
17407        );
17408
17409        snapshot.layer_cache_misses = 1;
17410        assert!(frame_stats_need_warmup_frame(&snapshot));
17411        snapshot.layer_cache_misses = 0;
17412
17413        snapshot.shadow_shape_cache_misses = 1;
17414        assert!(frame_stats_need_warmup_frame(&snapshot));
17415        snapshot.shadow_shape_cache_misses = 0;
17416
17417        snapshot.text_image_cache_misses = 1;
17418        assert!(frame_stats_need_warmup_frame(&snapshot));
17419        snapshot.text_image_cache_misses = 0;
17420
17421        snapshot.text_glyph_atlas_misses = 1;
17422        assert!(frame_stats_need_warmup_frame(&snapshot));
17423    }
17424
17425    #[test]
17426    fn renderer_warmup_budget_is_consumed_by_a_repeated_cache_miss() {
17427        let stats = gpu_stats::FrameStats::default();
17428        let mut snapshot = stats.snapshot();
17429        snapshot.layer_cache_misses = 1;
17430        let mut pending_frames = 0;
17431
17432        update_frame_warmup_budget(&mut pending_frames, &snapshot);
17433        assert_eq!(pending_frames, CACHE_MISS_WARMUP_FRAMES);
17434
17435        update_frame_warmup_budget(&mut pending_frames, &snapshot);
17436        assert_eq!(
17437            pending_frames, 0,
17438            "a cache miss during the warmup frame must not replenish its budget"
17439        );
17440    }
17441
17442    #[test]
17443    fn non_scene_layer_surface_cache_miss_admits_first_render() {
17444        let key = LayerRasterCacheKey::new(
17445            Some(77),
17446            0xC0FFEE,
17447            0,
17448            Rect {
17449                x: 0.0,
17450                y: 0.0,
17451                width: 120.0,
17452                height: 80.0,
17453            },
17454            (120, 80),
17455            ScaleBucket::from_scale(1.0),
17456        );
17457
17458        assert!(
17459            first_cache_miss_admission(&key),
17460            "ordinary retained layer surfaces should still cache on first miss"
17461        );
17462    }
17463
17464    #[test]
17465    fn text_image_cache_key_is_content_addressed_not_node_addressed() {
17466        let first = test_text_draw(
17467            Rect {
17468                x: 12.25,
17469                y: 40.75,
17470                width: 220.0,
17471                height: 24.0,
17472            },
17473            TextMotion::Static,
17474        );
17475        let mut second = first.clone();
17476        second.node_id = first.node_id + 1;
17477
17478        let first_key = GpuRenderer::text_image_cache_key(&first, first.rect, 1.0, true);
17479        let second_key = GpuRenderer::text_image_cache_key(&second, second.rect, 1.0, true);
17480
17481        assert_eq!(
17482            first_key, second_key,
17483            "text raster cache keys must be based on rendered pixels, not node identity"
17484        );
17485    }
17486
17487    #[test]
17488    fn animated_text_image_cache_key_keeps_fractional_phase_only() {
17489        let base = test_text_draw(
17490            Rect {
17491                x: 12.25,
17492                y: 40.75,
17493                width: 220.0,
17494                height: 24.0,
17495            },
17496            TextMotion::Animated,
17497        );
17498        let integer_translated = test_text_draw(
17499            Rect {
17500                x: 44.25,
17501                y: 88.75,
17502                width: 220.0,
17503                height: 24.0,
17504            },
17505            TextMotion::Animated,
17506        );
17507        let phase_shifted = test_text_draw(
17508            Rect {
17509                x: 44.5,
17510                y: 88.75,
17511                width: 220.0,
17512                height: 24.0,
17513            },
17514            TextMotion::Animated,
17515        );
17516
17517        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, false);
17518        let translated_key = GpuRenderer::text_image_cache_key(
17519            &integer_translated,
17520            integer_translated.rect,
17521            1.0,
17522            false,
17523        );
17524        let phase_shifted_key =
17525            GpuRenderer::text_image_cache_key(&phase_shifted, phase_shifted.rect, 1.0, false);
17526
17527        assert_eq!(
17528            base_key, translated_key,
17529            "integer translation should not invalidate animated text raster cache entries"
17530        );
17531        assert_ne!(
17532            base_key, phase_shifted_key,
17533            "fractional phase affects animated text rasterization and must stay in the key"
17534        );
17535    }
17536
17537    #[test]
17538    fn animated_translated_text_raster_geometry_applies_snap_anchor() {
17539        let mut base = test_text_draw(
17540            Rect {
17541                x: 14.25,
17542                y: 16.50,
17543                width: 220.0,
17544                height: 24.0,
17545            },
17546            TextMotion::Animated,
17547        );
17548        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.50)));
17549
17550        let mut scrolled = test_text_draw(
17551            Rect {
17552                x: 14.25,
17553                y: 15.80,
17554                width: 220.0,
17555                height: 24.0,
17556            },
17557            TextMotion::Animated,
17558        );
17559        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 15.80)));
17560
17561        let (base_logical, base_raster, _, _, base_static) =
17562            text_raster_geometry_for_draw(&base, 1.0).expect("base text geometry");
17563        let (scrolled_logical, scrolled_raster, _, _, scrolled_static) =
17564            text_raster_geometry_for_draw(&scrolled, 1.0).expect("scrolled text geometry");
17565
17566        assert!(!base_static);
17567        assert!(!scrolled_static);
17568        assert!((base_logical.x - 14.0).abs() < f32::EPSILON);
17569        assert!((base_logical.y - 17.0).abs() < f32::EPSILON);
17570        assert!((scrolled_logical.x - 14.0).abs() < f32::EPSILON);
17571        assert!((scrolled_logical.y - 16.0).abs() < f32::EPSILON);
17572        assert_eq!(base_raster.x.fract(), 0.0);
17573        assert_eq!(base_raster.y.fract(), 0.0);
17574        assert_eq!(scrolled_raster.x.fract(), 0.0);
17575        assert_eq!(scrolled_raster.y.fract(), 0.0);
17576
17577        let base_key = GpuRenderer::text_image_cache_key(&base, base_raster, 1.0, false);
17578        let scrolled_key =
17579            GpuRenderer::text_image_cache_key(&scrolled, scrolled_raster, 1.0, false);
17580        assert_eq!(
17581            base_key, scrolled_key,
17582            "translated animated text should keep a stable raster phase while scrolling"
17583        );
17584    }
17585
17586    #[test]
17587    fn translated_static_text_moves_one_device_pixel_at_half_pixel_phase() {
17588        let root_scale = 1.25;
17589        let mut base = test_text_draw(
17590            Rect {
17591                x: 14.0,
17592                y: 276.0,
17593                width: 220.0,
17594                height: 24.0,
17595            },
17596            TextMotion::Static,
17597        );
17598        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.600_006)));
17599
17600        let mut scrolled = test_text_draw(
17601            Rect {
17602                x: 14.0,
17603                y: 275.2,
17604                width: 220.0,
17605                height: 24.0,
17606            },
17607            TextMotion::Static,
17608        );
17609        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 126.799_99)));
17610
17611        let (_, base_raster, _, _, _) =
17612            text_raster_geometry_for_draw(&base, root_scale).expect("base text geometry");
17613        let (_, scrolled_raster, _, _, _) =
17614            text_raster_geometry_for_draw(&scrolled, root_scale).expect("scrolled text geometry");
17615
17616        assert_eq!(
17617            base_raster.y - scrolled_raster.y,
17618            1.0,
17619            "one physical pixel of rigid scrolling must move static text by one raster pixel"
17620        );
17621    }
17622
17623    #[test]
17624    fn translated_text_snap_does_not_move_its_fixed_ancestor_clip() {
17625        let root_scale = 1.25;
17626        let fixed_clip = Rect {
17627            x: 8.0,
17628            y: 20.0,
17629            width: 300.0,
17630            height: 680.0,
17631        };
17632        let mut draw = test_text_draw(
17633            Rect {
17634                x: 14.0,
17635                y: 276.0,
17636                width: 220.0,
17637                height: 24.0,
17638            },
17639            TextMotion::Static,
17640        );
17641        draw.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.4)));
17642        draw.clip = Some(fixed_clip);
17643
17644        let (_, _, clip, _, _) =
17645            text_raster_geometry_for_draw(&draw, root_scale).expect("clipped text geometry");
17646
17647        assert_eq!(
17648            clip,
17649            Some(fixed_clip),
17650            "content pixel snapping must not translate a fixed ancestor clip"
17651        );
17652    }
17653
17654    #[test]
17655    fn clipped_static_multiline_text_raster_source_limits_visible_line_window() {
17656        let rect = Rect {
17657            x: 8.0,
17658            y: 100.0,
17659            width: 240.0,
17660            height: 1_000.0,
17661        };
17662        let mut draw = test_text_draw(rect, TextMotion::Static);
17663        let lines = (0..100)
17664            .map(|line| format!("line-{line:03}"))
17665            .collect::<Vec<_>>()
17666            .join("\n");
17667        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
17668
17669        let raster_rect = Rect {
17670            x: 16.0,
17671            y: 200.0,
17672            width: 480.0,
17673            height: 2_000.0,
17674        };
17675        let source = clipped_text_raster_source(
17676            &draw,
17677            rect,
17678            raster_rect,
17679            Some(Rect {
17680                x: 0.0,
17681                y: 610.0,
17682                width: 800.0,
17683                height: 40.0,
17684            }),
17685            2.0,
17686            true,
17687        );
17688
17689        let Cow::Owned(sliced_draw) = source.draw else {
17690            panic!("clipped static multiline text should rasterize only the visible line window");
17691        };
17692        let sliced_text = sliced_draw.text.text.as_str();
17693        assert!(sliced_text.contains("line-050"));
17694        assert!(sliced_text.contains("line-055"));
17695        assert!(!sliced_text.contains("line-000"));
17696        assert!(!sliced_text.contains("line-099"));
17697        assert_eq!(source.raster_rect.x, raster_rect.x);
17698        assert!(source.raster_rect.y > raster_rect.y);
17699        assert!(source.raster_rect.height < raster_rect.height);
17700    }
17701
17702    #[test]
17703    fn clipped_static_multiline_text_raster_source_slices_short_multiline_text() {
17704        let rect = Rect {
17705            x: 8.0,
17706            y: 100.0,
17707            width: 240.0,
17708            height: 320.0,
17709        };
17710        let mut draw = test_text_draw(rect, TextMotion::Static);
17711        let lines = (0..24)
17712            .map(|line| format!("code-line-{line:02}"))
17713            .collect::<Vec<_>>()
17714            .join("\n");
17715        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
17716
17717        let raster_rect = Rect {
17718            x: 16.0,
17719            y: 200.0,
17720            width: 480.0,
17721            height: 640.0,
17722        };
17723        let source = clipped_text_raster_source(
17724            &draw,
17725            rect,
17726            raster_rect,
17727            Some(Rect {
17728                x: 0.0,
17729                y: 190.0,
17730                width: 800.0,
17731                height: 120.0,
17732            }),
17733            2.0,
17734            true,
17735        );
17736
17737        let Cow::Owned(sliced_draw) = source.draw else {
17738            panic!("clipped multiline text should rasterize only the visible line window");
17739        };
17740        assert!(sliced_draw.text.text.as_str().contains("code-line-06"));
17741        assert!(!sliced_draw.text.text.as_str().contains("code-line-00"));
17742        assert!(!sliced_draw.text.text.as_str().contains("code-line-23"));
17743        assert_eq!(source.raster_rect.x, raster_rect.x);
17744        assert!(source.raster_rect.y > raster_rect.y);
17745        assert!(source.raster_rect.height < raster_rect.height);
17746    }
17747
17748    #[test]
17749    fn text_line_index_cache_reuses_retained_index_for_same_text_instance() {
17750        let mut cache = TextLineIndexCache::new(4);
17751        let text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
17752
17753        let first = cache.line_starts(&text);
17754        let second = cache.line_starts(&text);
17755
17756        assert_eq!(first.as_ref(), &[0, 2, 4]);
17757        assert!(
17758            Rc::ptr_eq(&first, &second),
17759            "retained text should not rebuild its line index on every clipped frame"
17760        );
17761    }
17762
17763    #[test]
17764    fn text_line_index_cache_is_retained_text_instance_local() {
17765        let mut cache = TextLineIndexCache::new(4);
17766        let first_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
17767        let second_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
17768
17769        let first = cache.line_starts(&first_text);
17770        let second = cache.line_starts(&second_text);
17771
17772        assert_eq!(first.as_ref(), second.as_ref());
17773        assert!(
17774            !Rc::ptr_eq(&first, &second),
17775            "line index lookup should not hash large text contents to find unrelated retained nodes"
17776        );
17777    }
17778
17779    #[test]
17780    fn device_pixel_bounds_for_rect_snaps_origin_and_extents() {
17781        let bounds = device_pixel_bounds_for_rect(
17782            Rect {
17783                x: 10.25,
17784                y: 14.6,
17785                width: 20.1,
17786                height: 9.2,
17787            },
17788            200,
17789            120,
17790            2.0,
17791        )
17792        .expect("rect should intersect the viewport");
17793
17794        assert_eq!(
17795            bounds,
17796            DevicePixelBounds {
17797                x: 20.0,
17798                y: 29.0,
17799                width: 41,
17800                height: 19,
17801            }
17802        );
17803    }
17804
17805    #[test]
17806    fn visible_layer_rect_intersects_clip_and_viewport() {
17807        let visible = visible_layer_rect(
17808            Rect {
17809                x: -10.0,
17810                y: 5.0,
17811                width: 80.0,
17812                height: 40.0,
17813            },
17814            Some(Rect {
17815                x: 4.0,
17816                y: 8.0,
17817                width: 20.0,
17818                height: 50.0,
17819            }),
17820            2.0,
17821            60,
17822            40,
17823        )
17824        .expect("visible rect");
17825
17826        assert_eq!(
17827            visible,
17828            Rect {
17829                x: 4.0,
17830                y: 8.0,
17831                width: 20.0,
17832                height: 12.0,
17833            }
17834        );
17835    }
17836
17837    #[test]
17838    fn clamp_effect_surface_scale_caps_large_surfaces_but_keeps_base_scale() {
17839        let clamped = clamp_effect_surface_scale(
17840            Rect {
17841                x: 0.0,
17842                y: 0.0,
17843                width: 1200.0,
17844                height: 900.0,
17845            },
17846            1.0,
17847            8.0,
17848            16_384,
17849        );
17850
17851        assert!(
17852            clamped < 8.0,
17853            "large translated effect layers must be capped to avoid OOM, got {clamped}"
17854        );
17855        assert!(
17856            clamped >= 1.0,
17857            "effect surfaces must not fall below destination resolution, got {clamped}"
17858        );
17859    }
17860
17861    #[test]
17862    fn clamp_effect_surface_scale_keeps_decorated_text_capture_scale() {
17863        let clamped = clamp_effect_surface_scale(
17864            Rect {
17865                x: 0.0,
17866                y: 0.0,
17867                width: 446.0,
17868                height: 44.0,
17869            },
17870            1.0,
17871            9.0,
17872            16_384,
17873        );
17874
17875        assert_eq!(
17876            clamped, 9.0,
17877            "decorated text motion-stable captures must keep full scale"
17878        );
17879    }
17880
17881    fn backdrop_layer(z_index: usize) -> BackdropLayer {
17882        BackdropLayer {
17883            node_id: Some(700 + z_index),
17884            rect: Rect {
17885                x: 0.0,
17886                y: 0.0,
17887                width: 10.0,
17888                height: 10.0,
17889            },
17890            clip: None,
17891            snap_anchor: None,
17892            effect: RenderEffect::blur(2.0),
17893            z_index,
17894        }
17895    }
17896
17897    fn test_shape(z_index: usize, blend_mode: BlendMode) -> DrawShape {
17898        DrawShape {
17899            rect: Rect {
17900                x: 0.0,
17901                y: 0.0,
17902                width: 8.0,
17903                height: 8.0,
17904            },
17905            local_rect: Rect {
17906                x: 0.0,
17907                y: 0.0,
17908                width: 8.0,
17909                height: 8.0,
17910            },
17911            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
17912            snap_anchor: None,
17913            brush: SceneBrush::Solid(Color::BLACK),
17914            shape: None,
17915            stroke: None,
17916            arc: None,
17917            z_index,
17918            clip: None,
17919            blend_mode,
17920            motion_context_animated: false,
17921        }
17922    }
17923
17924    #[test]
17925    fn shape_shadow_content_hash_ignores_viewport_translation() {
17926        fn translate_shape(shape: &DrawShape, dx: f32, dy: f32) -> DrawShape {
17927            let mut translated = *shape;
17928            translated.rect.x += dx;
17929            translated.rect.y += dy;
17930            translated.local_rect.x += dx;
17931            translated.local_rect.y += dy;
17932            for point in &mut translated.quad {
17933                point[0] += dx;
17934                point[1] += dy;
17935            }
17936            translated.snap_anchor = translated.snap_anchor.map(|anchor| {
17937                SnapAnchor::rigid(Point::new(anchor.origin.x + dx, anchor.origin.y + dy))
17938            });
17939            translated.clip = translated.clip.map(|mut clip| {
17940                clip.x += dx;
17941                clip.y += dy;
17942                clip
17943            });
17944            translated
17945        }
17946
17947        let mut first = test_shape(1, BlendMode::SrcOver);
17948        first.rect = Rect {
17949            x: 10.0,
17950            y: 20.0,
17951            width: 80.0,
17952            height: 40.0,
17953        };
17954        first.local_rect = first.rect;
17955        first.quad = [[10.0, 20.0], [90.0, 20.0], [10.0, 60.0], [90.0, 60.0]];
17956        first.snap_anchor = Some(SnapAnchor::rigid(Point::new(7.0, 11.0)));
17957        first.shape = Some(RoundedCornerShape::uniform(8.0));
17958        first.clip = Some(Rect {
17959            x: 8.0,
17960            y: 18.0,
17961            width: 86.0,
17962            height: 44.0,
17963        });
17964        let mut cutout = test_shape(2, BlendMode::DstOut);
17965        cutout.rect = Rect {
17966            x: 18.0,
17967            y: 26.0,
17968            width: 62.0,
17969            height: 22.0,
17970        };
17971        cutout.local_rect = cutout.rect;
17972        cutout.quad = [[18.0, 26.0], [80.0, 26.0], [18.0, 48.0], [80.0, 48.0]];
17973        cutout.shape = Some(RoundedCornerShape::uniform(4.0));
17974
17975        let dx = 37.0;
17976        let dy = -11.5;
17977        let translated = translate_shape(&first, dx, dy);
17978        let translated_cutout = translate_shape(&cutout, dx, dy);
17979
17980        let root_scale = 1.25;
17981        let first_shapes = vec![(first, BlendMode::SrcOver), (cutout, BlendMode::DstOut)];
17982        let translated_shapes = vec![
17983            (translated, BlendMode::SrcOver),
17984            (translated_cutout, BlendMode::DstOut),
17985        ];
17986
17987        let first_hash = shape_shadow_content_hash(&first_shapes, &[], root_scale);
17988        let translated_hash = shape_shadow_content_hash(&translated_shapes, &[], root_scale);
17989
17990        assert_eq!(first_hash, translated_hash);
17991
17992        let mut changed_shapes = translated_shapes;
17993        changed_shapes[0].0.rect.width += 1.0;
17994        let changed_hash = shape_shadow_content_hash(&changed_shapes, &[], root_scale);
17995
17996        assert_ne!(first_hash, changed_hash);
17997    }
17998
17999    #[test]
18000    fn shape_shadow_content_hash_is_stable_under_fractional_scale_scroll() {
18001        // Regression: scrolling a shadowed panel on a fractional-scale display
18002        // (e.g. Xft.dpi 130 → scale ≈ 1.354) must not re-render the shadow blur
18003        // every frame. The production cache key derives its viewport offset from
18004        // FLOORED device-pixel bounds, so the residual subpixel phase used to leak
18005        // into the content hash and miss the cache on every scroll step.
18006        fn shadow_shapes_at(y: f32) -> Vec<(DrawShape, BlendMode)> {
18007            let mut shape = test_shape(1, BlendMode::SrcOver);
18008            shape.rect = Rect {
18009                x: 24.0,
18010                y,
18011                width: 180.0,
18012                height: 90.0,
18013            };
18014            shape.local_rect = shape.rect;
18015            shape.quad = crate::rect_to_quad(shape.rect);
18016            shape.shape = Some(RoundedCornerShape::uniform(14.0));
18017            vec![(shape, BlendMode::SrcOver)]
18018        }
18019
18020        let root_scale = 130.0f32 / 96.0;
18021        let blur_radius = 18.0f32;
18022        let pixel_radius = blur_radius * root_scale;
18023
18024        let key_at = |y: f32| {
18025            let shapes = shadow_shapes_at(y);
18026            let plan =
18027                shape_shadow_surface_plan(&shapes, None, blur_radius, 1600, 1600, root_scale, 8192)
18028                    .expect("surface plan");
18029            shape_shadow_surface_cache_key(
18030                &shapes,
18031                &[],
18032                plan.source_device_bounds,
18033                pixel_radius,
18034                root_scale,
18035            )
18036            .expect("cache key")
18037        };
18038
18039        // Wheel scroll translates the panel by whole logical pixels; the device
18040        // subpixel phase changes on every step at fractional scale. The whole
18041        // cache key (content hash AND surface pixel size) must stay stable, or
18042        // every scroll frame re-renders the shadow blur.
18043        let base = key_at(640.0);
18044        for step in 1..=12 {
18045            let scrolled = key_at(640.0 - step as f32 * 4.0);
18046            assert_eq!(
18047                base, scrolled,
18048                "scrolled shadow cache key must stay stable at fractional scale (step {step})"
18049            );
18050        }
18051    }
18052
18053    #[test]
18054    fn shape_shadow_cache_key_uses_unclipped_source_bounds_for_scrolled_clip() {
18055        fn translated_card_shadow(y: f32) -> Vec<(DrawShape, BlendMode)> {
18056            let mut shape = test_shape(1, BlendMode::SrcOver);
18057            shape.rect = Rect {
18058                x: 24.0,
18059                y,
18060                width: 280.0,
18061                height: 120.0,
18062            };
18063            shape.local_rect = shape.rect;
18064            shape.quad = [[24.0, y], [304.0, y], [24.0, y + 120.0], [304.0, y + 120.0]];
18065            shape.shape = Some(RoundedCornerShape::uniform(18.0));
18066            vec![(shape, BlendMode::SrcOver)]
18067        }
18068
18069        let root_scale = 1.0;
18070        let blur_radius = 18.0;
18071        let viewport_clip = Rect {
18072            x: 0.0,
18073            y: 96.0,
18074            width: 360.0,
18075            height: 720.0,
18076        };
18077        let key_for = |y: f32| {
18078            let shapes = translated_card_shadow(y);
18079            let plan = shape_shadow_surface_plan(
18080                &shapes,
18081                Some(viewport_clip),
18082                blur_radius,
18083                360,
18084                900,
18085                root_scale,
18086                4096,
18087            )
18088            .expect("surface plan");
18089            shape_shadow_surface_cache_key(
18090                &shapes,
18091                &[],
18092                plan.source_device_bounds,
18093                plan.pixel_radius,
18094                root_scale,
18095            )
18096            .expect("cache key")
18097        };
18098
18099        // The card scrolls under a fixed viewport clip; the visible portion
18100        // changes but the cache key must stay anchored to the unclipped source.
18101        assert_eq!(key_for(740.0), key_for(756.0));
18102    }
18103
18104    #[test]
18105    fn shape_visibility_uses_nonzero_viewport_offset_for_cropped_offscreen() {
18106        let mut shape = test_shape(1, BlendMode::SrcOver);
18107        shape.rect = Rect {
18108            x: 24.0,
18109            y: 740.0,
18110            width: 280.0,
18111            height: 120.0,
18112        };
18113        shape.local_rect = shape.rect;
18114        shape.quad = [[24.0, 740.0], [304.0, 740.0], [24.0, 860.0], [304.0, 860.0]];
18115        let viewport = ViewportUniformParams {
18116            width: 316,
18117            height: 228,
18118            offset: [6.0, 686.0],
18119        };
18120
18121        assert!(shape_draw_is_visible_in_viewport(&shape, viewport, 1.0));
18122    }
18123
18124    #[test]
18125    fn text_prewarm_uses_nonzero_viewport_offset_for_cropped_offscreen() {
18126        let viewport = ViewportUniformParams {
18127            width: 316,
18128            height: 228,
18129            offset: [6.0, 686.0],
18130        };
18131        let text_rect = Rect {
18132            x: 24.0,
18133            y: 740.0,
18134            width: 280.0,
18135            height: 40.0,
18136        };
18137
18138        assert!(text_draw_is_visible_in_viewport(
18139            text_rect, None, viewport, 1.0
18140        ));
18141        assert!(text_draw_should_prewarm_in_viewport(
18142            text_rect, None, viewport, 1.0
18143        ));
18144    }
18145
18146    fn test_shadow_draw(shapes: Vec<(DrawShape, BlendMode)>) -> ShadowDraw {
18147        ShadowDraw {
18148            shapes,
18149            brushes: vec![],
18150            texts: vec![],
18151            blur_radius: 8.0,
18152            clip: None,
18153            z_index: 0,
18154        }
18155    }
18156
18157    fn test_image(z_index: usize, blend_mode: BlendMode) -> ImageDraw {
18158        ImageDraw {
18159            rect: Rect {
18160                x: 0.0,
18161                y: 0.0,
18162                width: 8.0,
18163                height: 8.0,
18164            },
18165            local_rect: Rect {
18166                x: 0.0,
18167                y: 0.0,
18168                width: 8.0,
18169                height: 8.0,
18170            },
18171            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
18172            snap_anchor: None,
18173            image: ImageBitmap::from_rgba8(1, 1, vec![255, 255, 255, 255]).expect("image"),
18174            alpha: 1.0,
18175            color_filter: None,
18176            sampling: ImageSampling::Nearest,
18177            z_index,
18178            clip: None,
18179            blend_mode,
18180            src_rect: None,
18181            motion_context_animated: false,
18182        }
18183    }
18184
18185    #[test]
18186    fn image_sampler_descriptors_match_requested_sampling() {
18187        let nearest = image_sampler_descriptor(ImageSampling::Nearest);
18188        assert_eq!(nearest.mag_filter, wgpu::FilterMode::Nearest);
18189        assert_eq!(nearest.min_filter, wgpu::FilterMode::Nearest);
18190
18191        let linear = image_sampler_descriptor(ImageSampling::Linear);
18192        assert_eq!(linear.mag_filter, wgpu::FilterMode::Linear);
18193        assert_eq!(linear.min_filter, wgpu::FilterMode::Linear);
18194    }
18195
18196    #[test]
18197    fn image_uv_rect_clamps_source_rect_to_texel_centers() {
18198        let image = ImageBitmap::from_rgba8(24, 16, vec![0; 24 * 16 * 4]).expect("image");
18199        let uv = image_uv_rect(
18200            &image,
18201            Some(Rect {
18202                x: 0.0,
18203                y: 0.0,
18204                width: 16.0,
18205                height: 16.0,
18206            }),
18207        )
18208        .expect("uv rect");
18209
18210        assert_eq!(uv.min, [0.0, 0.0]);
18211        assert_eq!(uv.max, [16.0 / 24.0, 1.0]);
18212        assert_eq!(
18213            uv.sample_bounds,
18214            [0.5 / 24.0, 0.5 / 16.0, 15.5 / 24.0, 15.5 / 16.0]
18215        );
18216    }
18217
18218    #[test]
18219    fn image_uv_rect_keeps_full_image_unclamped() {
18220        let image = ImageBitmap::from_rgba8(2, 2, vec![0; 16]).expect("image");
18221        let uv = image_uv_rect(&image, None).expect("uv rect");
18222
18223        assert_eq!(uv.min, [0.0, 0.0]);
18224        assert_eq!(uv.max, [1.0, 1.0]);
18225        assert_eq!(uv.sample_bounds, [0.0, 0.0, 1.0, 1.0]);
18226    }
18227
18228    fn test_text(z_index: usize) -> TextDraw {
18229        TextDraw {
18230            node_id: 0,
18231            rect: Rect {
18232                x: 0.0,
18233                y: 0.0,
18234                width: 8.0,
18235                height: 8.0,
18236            },
18237            snap_anchor: None,
18238            translated_content_context: false,
18239            text: Arc::new(cranpose_ui::text::AnnotatedString::from("t").render_string()),
18240            color: Color::WHITE,
18241            text_style: cranpose_ui::TextStyle::default(),
18242            font_size: 12.0,
18243            scale: 1.0,
18244            layout_options: cranpose_ui::TextLayoutOptions::default(),
18245            z_index,
18246            clip: None,
18247        }
18248    }
18249
18250    #[test]
18251    fn text_draw_visibility_rejects_text_outside_clip_before_rasterization() {
18252        let viewport = ViewportUniformParams {
18253            width: 320,
18254            height: 240,
18255            offset: [0.0, 0.0],
18256        };
18257        let text_rect = Rect {
18258            x: 0.0,
18259            y: 260.0,
18260            width: 200.0,
18261            height: 40.0,
18262        };
18263        let clip = Some(Rect {
18264            x: 0.0,
18265            y: 0.0,
18266            width: 320.0,
18267            height: 200.0,
18268        });
18269
18270        assert!(
18271            !text_draw_is_visible_in_viewport(text_rect, clip, viewport, 1.0),
18272            "lazy-list beyond-bound text outside the clip must not be rasterized"
18273        );
18274    }
18275
18276    #[test]
18277    fn text_draw_prewarm_accepts_clipped_text_near_viewport() {
18278        let viewport = ViewportUniformParams {
18279            width: 320,
18280            height: 240,
18281            offset: [0.0, 0.0],
18282        };
18283        let text_rect = Rect {
18284            x: 0.0,
18285            y: 260.0,
18286            width: 200.0,
18287            height: 40.0,
18288        };
18289        let clip = Some(Rect {
18290            x: 0.0,
18291            y: 0.0,
18292            width: 320.0,
18293            height: 200.0,
18294        });
18295
18296        assert!(!text_draw_is_visible_in_viewport(
18297            text_rect, clip, viewport, 1.0
18298        ));
18299        assert!(text_draw_should_prewarm_in_viewport(
18300            text_rect, clip, viewport, 1.0
18301        ));
18302    }
18303
18304    #[test]
18305    fn text_draw_prewarm_rejects_far_clipped_text() {
18306        let viewport = ViewportUniformParams {
18307            width: 320,
18308            height: 240,
18309            offset: [0.0, 0.0],
18310        };
18311        let text_rect = Rect {
18312            x: 0.0,
18313            y: 1600.0,
18314            width: 200.0,
18315            height: 40.0,
18316        };
18317        let clip = Some(Rect {
18318            x: 0.0,
18319            y: 0.0,
18320            width: 320.0,
18321            height: 200.0,
18322        });
18323
18324        assert!(!text_draw_should_prewarm_in_viewport(
18325            text_rect, clip, viewport, 1.0
18326        ));
18327    }
18328
18329    #[test]
18330    fn text_draw_visibility_rejects_unclipped_text_outside_viewport() {
18331        let viewport = ViewportUniformParams {
18332            width: 320,
18333            height: 240,
18334            offset: [0.0, 0.0],
18335        };
18336        let text_rect = Rect {
18337            x: 0.0,
18338            y: 241.0,
18339            width: 200.0,
18340            height: 40.0,
18341        };
18342
18343        assert!(
18344            !text_draw_is_visible_in_viewport(text_rect, None, viewport, 1.0),
18345            "unclipped text outside the target viewport must not be rasterized"
18346        );
18347    }
18348
18349    #[test]
18350    fn text_draw_visibility_keeps_partially_visible_text() {
18351        let viewport = ViewportUniformParams {
18352            width: 320,
18353            height: 240,
18354            offset: [0.0, 0.0],
18355        };
18356        let text_rect = Rect {
18357            x: 0.0,
18358            y: 220.0,
18359            width: 200.0,
18360            height: 40.0,
18361        };
18362
18363        assert!(text_draw_is_visible_in_viewport(
18364            text_rect, None, viewport, 1.0
18365        ));
18366    }
18367
18368    fn test_draw_ops(
18369        shapes: &[DrawShape],
18370        images: &[ImageDraw],
18371        texts: &[TextDraw],
18372        shadows: &[ShadowDraw],
18373    ) -> Vec<DrawOp> {
18374        let mut ops = Vec::new();
18375        ops.extend(shapes.iter().enumerate().map(|(index, shape)| DrawOp {
18376            z_index: shape.z_index,
18377            kind: DrawOpKind::Shape(index),
18378        }));
18379        ops.extend(images.iter().enumerate().map(|(index, image)| DrawOp {
18380            z_index: image.z_index,
18381            kind: DrawOpKind::Image(index),
18382        }));
18383        ops.extend(texts.iter().enumerate().map(|(index, text)| DrawOp {
18384            z_index: text.z_index,
18385            kind: DrawOpKind::Text(index),
18386        }));
18387        ops.extend(shadows.iter().enumerate().map(|(index, shadow)| DrawOp {
18388            z_index: shadow.z_index,
18389            kind: DrawOpKind::Shadow(index),
18390        }));
18391        ops.sort_by_key(|op| op.z_index);
18392        ops
18393    }
18394
18395    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
18396        crate::test_support::layer_node(
18397            local_bounds,
18398            ProjectiveTransform::identity(),
18399            GraphicsLayer::default(),
18400            children,
18401        )
18402    }
18403
18404    fn cacheable_layer(
18405        node_id: cranpose_core::NodeId,
18406        local_bounds: Rect,
18407        children: Vec<RenderNode>,
18408    ) -> LayerNode {
18409        let mut layer = test_layer(local_bounds, children);
18410        layer.node_id = Some(node_id);
18411        layer.cache_policy = cranpose_render_common::graph::CachePolicy::Auto;
18412        layer.recompute_raster_cache_hashes();
18413        layer
18414    }
18415
18416    fn text_layer_with_style(text: AnnotatedString, text_style: TextStyle) -> LayerNode {
18417        test_layer(
18418            Rect {
18419                x: 0.0,
18420                y: 0.0,
18421                width: 64.0,
18422                height: 32.0,
18423            },
18424            vec![RenderNode::Primitive(PrimitiveEntry {
18425                phase: PrimitivePhase::BeforeChildren,
18426                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18427                    node_id: 1,
18428                    rect: Rect {
18429                        x: 2.0,
18430                        y: 3.0,
18431                        width: 48.0,
18432                        height: 18.0,
18433                    },
18434                    text: std::rc::Rc::new(text),
18435                    text_style,
18436                    font_size: 14.0,
18437                    layout_options: TextLayoutOptions::default(),
18438                    clip: None,
18439                })),
18440            })],
18441        )
18442    }
18443
18444    fn snapped_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
18445        LayerNode {
18446            node_id: Some(77),
18447            local_bounds: Rect {
18448                x: 0.0,
18449                y: 0.0,
18450                width: 48.0,
18451                height: 24.0,
18452            },
18453            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
18454            motion_context_animated: animated,
18455            translated_content_context,
18456            translated_content_offset: Point::default(),
18457            content_offset: Point::default(),
18458            scene_children_origin: cranpose_ui_graphics::Point::default(),
18459            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
18460            graphics_layer: GraphicsLayer::default(),
18461            clip_to_bounds: false,
18462            shadow_clip: None,
18463            hit_test: None,
18464            has_hit_targets: false,
18465            isolation: IsolationReasons::default(),
18466            cache_policy: CachePolicy::None,
18467            cache_hashes: LayerRasterCacheHashes::default(),
18468            cache_hashes_valid: false,
18469            children: vec![
18470                RenderNode::Primitive(PrimitiveEntry {
18471                    phase: PrimitivePhase::BeforeChildren,
18472                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
18473                        primitive: DrawPrimitive::RoundRect {
18474                            rect: Rect {
18475                                x: 0.0,
18476                                y: 0.0,
18477                                width: 48.0,
18478                                height: 24.0,
18479                            },
18480                            brush: Brush::solid(Color(0.28, 0.30, 0.46, 0.88)),
18481                            radii: CornerRadii::uniform(6.0),
18482                            stroke: None,
18483                        },
18484                        clip: None,
18485                    }),
18486                }),
18487                RenderNode::Primitive(PrimitiveEntry {
18488                    phase: PrimitivePhase::BeforeChildren,
18489                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
18490                        primitive: DrawPrimitive::Image {
18491                            rect: Rect {
18492                                x: 2.0,
18493                                y: 2.0,
18494                                width: 12.0,
18495                                height: 12.0,
18496                            },
18497                            image: ImageBitmap::from_rgba8(
18498                                2,
18499                                2,
18500                                vec![
18501                                    255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255,
18502                                    255,
18503                                ],
18504                            )
18505                            .expect("image"),
18506                            alpha: 1.0,
18507                            color_filter: None,
18508                            sampling: ImageSampling::Linear,
18509                            src_rect: None,
18510                        },
18511                        clip: None,
18512                    }),
18513                }),
18514                RenderNode::Primitive(PrimitiveEntry {
18515                    phase: PrimitivePhase::BeforeChildren,
18516                    node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18517                        node_id: 77,
18518                        rect: Rect {
18519                            x: 6.0,
18520                            y: 4.0,
18521                            width: 36.0,
18522                            height: 16.0,
18523                        },
18524                        text: std::rc::Rc::new(AnnotatedString::from("48 px")),
18525                        text_style: TextStyle::default(),
18526                        font_size: 14.0,
18527                        layout_options: TextLayoutOptions::default(),
18528                        clip: None,
18529                    })),
18530                }),
18531            ],
18532        }
18533    }
18534
18535    fn snapped_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
18536        let text_leaf = snapped_text_leaf(animated, translated_content_context);
18537        test_layer(
18538            Rect {
18539                x: 0.0,
18540                y: 0.0,
18541                width: 96.0,
18542                height: 64.0,
18543            },
18544            vec![RenderNode::Layer(Box::new(text_leaf))],
18545        )
18546    }
18547
18548    fn translated_content_local_surface_root() -> LayerNode {
18549        let mut effectful_text = text_layer_with_style(
18550            AnnotatedString::from("shadow"),
18551            TextStyle::from_span_style(SpanStyle {
18552                shadow: Some(Shadow {
18553                    color: Color::BLACK,
18554                    offset: Point::new(1.0, 2.0),
18555                    blur_radius: 3.0,
18556                }),
18557                ..SpanStyle::default()
18558            }),
18559        );
18560        effectful_text.translated_content_context = true;
18561
18562        let translated_content = LayerNode {
18563            node_id: Some(78),
18564            local_bounds: Rect {
18565                x: 0.0,
18566                y: 0.0,
18567                width: 96.0,
18568                height: 64.0,
18569            },
18570            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
18571            motion_context_animated: false,
18572            translated_content_context: true,
18573            translated_content_offset: Point::default(),
18574            content_offset: Point::default(),
18575            scene_children_origin: cranpose_ui_graphics::Point::default(),
18576            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
18577            graphics_layer: GraphicsLayer::default(),
18578            clip_to_bounds: false,
18579            shadow_clip: None,
18580            hit_test: None,
18581            has_hit_targets: false,
18582            isolation: IsolationReasons::default(),
18583            cache_policy: CachePolicy::None,
18584            cache_hashes: LayerRasterCacheHashes::default(),
18585            cache_hashes_valid: false,
18586            children: vec![RenderNode::Layer(Box::new(effectful_text))],
18587        };
18588
18589        test_layer(
18590            Rect {
18591                x: 0.0,
18592                y: 0.0,
18593                width: 160.0,
18594                height: 120.0,
18595            },
18596            vec![RenderNode::Layer(Box::new(translated_content))],
18597        )
18598    }
18599
18600    #[test]
18601    fn scissor_rect_for_layer_intersects_with_clip() {
18602        let rect = Rect {
18603            x: 10.0,
18604            y: 10.0,
18605            width: 30.0,
18606            height: 20.0,
18607        };
18608        let clip = Rect {
18609            x: 20.0,
18610            y: 15.0,
18611            width: 100.0,
18612            height: 100.0,
18613        };
18614
18615        let scissor = scissor_rect_for_layer(rect, Some(clip), 1.0, 200, 200);
18616        assert_eq!(scissor, Some((20, 15, 20, 15)));
18617    }
18618
18619    #[test]
18620    fn visible_draw_rect_no_clip_returns_original() {
18621        let rect = Rect {
18622            x: 100.0,
18623            y: 200.0,
18624            width: 300.0,
18625            height: 400.0,
18626        };
18627        assert_eq!(visible_draw_rect(rect, None), Some(rect));
18628    }
18629
18630    #[test]
18631    fn visible_draw_rect_with_clip_intersects() {
18632        let rect = Rect {
18633            x: 0.0,
18634            y: 0.0,
18635            width: 2000.0,
18636            height: 5000.0,
18637        };
18638        let clip = Rect {
18639            x: 0.0,
18640            y: 0.0,
18641            width: 800.0,
18642            height: 600.0,
18643        };
18644        let visible = visible_draw_rect(rect, Some(clip)).expect("should have visible area");
18645        assert_eq!(visible.width, 800.0);
18646        assert_eq!(visible.height, 600.0);
18647    }
18648
18649    #[test]
18650    fn visible_draw_rect_fully_clipped_returns_none() {
18651        let rect = Rect {
18652            x: 1000.0,
18653            y: 1000.0,
18654            width: 200.0,
18655            height: 200.0,
18656        };
18657        let clip = Rect {
18658            x: 0.0,
18659            y: 0.0,
18660            width: 800.0,
18661            height: 600.0,
18662        };
18663        assert!(visible_draw_rect(rect, Some(clip)).is_none());
18664    }
18665
18666    #[test]
18667    fn scene_bounds_respects_clip_on_shapes() {
18668        let mut scene = CompositorScene::new();
18669        // Shape inside viewport — visible
18670        scene.shapes.push(DrawShape {
18671            rect: Rect {
18672                x: 10.0,
18673                y: 10.0,
18674                width: 100.0,
18675                height: 50.0,
18676            },
18677            clip: Some(Rect {
18678                x: 0.0,
18679                y: 0.0,
18680                width: 800.0,
18681                height: 600.0,
18682            }),
18683            ..test_shape(0, BlendMode::SrcOver)
18684        });
18685        // Shape far outside viewport — clipped away entirely
18686        scene.shapes.push(DrawShape {
18687            rect: Rect {
18688                x: 0.0,
18689                y: 3000.0,
18690                width: 100.0,
18691                height: 50.0,
18692            },
18693            clip: Some(Rect {
18694                x: 0.0,
18695                y: 0.0,
18696                width: 800.0,
18697                height: 600.0,
18698            }),
18699            ..test_shape(1, BlendMode::SrcOver)
18700        });
18701        let bounds = scene_bounds(&scene).expect("should have bounds");
18702        // Bounds should only cover the first shape's visible area,
18703        // NOT extend to y=3050 from the clipped second shape.
18704        assert!(bounds.y + bounds.height <= 600.0);
18705    }
18706
18707    #[test]
18708    fn scene_bounds_scroll_content_clipped_to_viewport() {
18709        // Simulates a scroll container: many items with large y offsets,
18710        // all clipped to a viewport-sized clip rect.
18711        let mut scene = CompositorScene::new();
18712        let viewport_clip = Rect {
18713            x: 0.0,
18714            y: 0.0,
18715            width: 800.0,
18716            height: 600.0,
18717        };
18718        for i in 0..20 {
18719            scene.shapes.push(DrawShape {
18720                rect: Rect {
18721                    x: 0.0,
18722                    y: i as f32 * 300.0,
18723                    width: 800.0,
18724                    height: 200.0,
18725                },
18726                clip: Some(viewport_clip),
18727                ..test_shape(i, BlendMode::SrcOver)
18728            });
18729        }
18730        let bounds = scene_bounds(&scene).expect("should have bounds");
18731        // All shapes are clipped to viewport — bounds should be viewport-sized,
18732        // NOT 20*300 = 6000 dp tall.
18733        assert_eq!(bounds.x, 0.0);
18734        assert_eq!(bounds.y, 0.0);
18735        assert!(bounds.width <= 800.0);
18736        assert!(bounds.height <= 600.0);
18737    }
18738
18739    #[test]
18740    fn scene_bounds_stable_across_scroll_offsets() {
18741        // Simulates horizontal scroll at different offsets —
18742        // bounds should be identical regardless of scroll position.
18743        let viewport_clip = Rect {
18744            x: 0.0,
18745            y: 0.0,
18746            width: 400.0,
18747            height: 50.0,
18748        };
18749        let compute_bounds_at_offset = |scroll_x: f32| {
18750            let mut scene = CompositorScene::new();
18751            for i in 0..10 {
18752                scene.shapes.push(DrawShape {
18753                    rect: Rect {
18754                        x: i as f32 * 100.0 - scroll_x,
18755                        y: 0.0,
18756                        width: 80.0,
18757                        height: 40.0,
18758                    },
18759                    clip: Some(viewport_clip),
18760                    ..test_shape(i, BlendMode::SrcOver)
18761                });
18762            }
18763            scene_bounds(&scene).expect("bounds")
18764        };
18765        let bounds_at_0 = compute_bounds_at_offset(0.0);
18766        let bounds_at_300 = compute_bounds_at_offset(300.0);
18767        let bounds_at_600 = compute_bounds_at_offset(600.0);
18768        // Width should be stable (clipped to viewport) regardless of scroll offset
18769        assert!(
18770            (bounds_at_0.width - bounds_at_300.width).abs() < 1.0,
18771            "bounds width changed with scroll: {} vs {}",
18772            bounds_at_0.width,
18773            bounds_at_300.width
18774        );
18775        assert!(
18776            (bounds_at_0.width - bounds_at_600.width).abs() < 1.0,
18777            "bounds width changed with scroll: {} vs {}",
18778            bounds_at_0.width,
18779            bounds_at_600.width
18780        );
18781    }
18782
18783    #[test]
18784    fn collect_effect_ranges_respects_excluded_effect() {
18785        let layers = vec![effect_layer(10, 40), effect_layer(20, 30)];
18786        let mut ranges = Vec::new();
18787        collect_effect_ranges(&layers, 10, 40, Some(0), &mut ranges);
18788        assert_eq!(ranges.len(), 1);
18789        assert_eq!(ranges[0], 20..30);
18790    }
18791
18792    #[test]
18793    fn collect_layer_events_includes_nested_when_parent_excluded() {
18794        let effects = vec![effect_layer(10, 40), effect_layer(20, 30)];
18795        let backdrops = vec![backdrop_layer(25)];
18796        let mut events = Vec::new();
18797        collect_layer_events(&effects, &backdrops, 10, 40, Some(0), &mut events);
18798        assert_eq!(events.len(), 2);
18799
18800        match events[0].kind {
18801            LayerEventKind::Effect(index) => assert_eq!(index, 1),
18802            LayerEventKind::Backdrop(_) => panic!("expected nested effect as first event"),
18803        }
18804        match events[1].kind {
18805            LayerEventKind::Backdrop(index) => assert_eq!(index, 0),
18806            LayerEventKind::Effect(_) => panic!("expected backdrop as second event"),
18807        }
18808    }
18809
18810    fn pure_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
18811        LayerNode {
18812            node_id: Some(177),
18813            local_bounds: Rect {
18814                x: 0.0,
18815                y: 0.0,
18816                width: 96.0,
18817                height: 32.0,
18818            },
18819            transform_to_parent: ProjectiveTransform::translation(11.4, 23.6),
18820            motion_context_animated: animated,
18821            translated_content_context,
18822            translated_content_offset: Point::default(),
18823            content_offset: Point::default(),
18824            scene_children_origin: cranpose_ui_graphics::Point::default(),
18825            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
18826            graphics_layer: GraphicsLayer::default(),
18827            clip_to_bounds: false,
18828            shadow_clip: None,
18829            hit_test: None,
18830            has_hit_targets: false,
18831            isolation: IsolationReasons::default(),
18832            cache_policy: CachePolicy::None,
18833            cache_hashes: LayerRasterCacheHashes::default(),
18834            cache_hashes_valid: false,
18835            children: vec![RenderNode::Primitive(PrimitiveEntry {
18836                phase: PrimitivePhase::BeforeChildren,
18837                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18838                    node_id: 177,
18839                    rect: Rect {
18840                        x: 0.0,
18841                        y: 0.0,
18842                        width: 96.0,
18843                        height: 24.0,
18844                    },
18845                    clip: None,
18846                    text: std::rc::Rc::new(AnnotatedString::from("Pure text")),
18847                    text_style: TextStyle::default(),
18848                    font_size: 14.0,
18849                    layout_options: TextLayoutOptions::default(),
18850                })),
18851            })],
18852        }
18853    }
18854
18855    fn pure_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
18856        let text_leaf = pure_text_leaf(animated, translated_content_context);
18857        test_layer(
18858            Rect {
18859                x: 0.0,
18860                y: 0.0,
18861                width: 160.0,
18862                height: 96.0,
18863            },
18864            vec![RenderNode::Layer(Box::new(text_leaf))],
18865        )
18866    }
18867
18868    #[test]
18869    fn collect_layer_events_sorts_backdrop_before_effect_at_same_z() {
18870        let effects = vec![effect_layer(10, 20)];
18871        let backdrops = vec![backdrop_layer(10)];
18872        let mut events = Vec::new();
18873        collect_layer_events(&effects, &backdrops, 0, 30, None, &mut events);
18874        assert_eq!(events.len(), 2);
18875
18876        match events[0].kind {
18877            LayerEventKind::Backdrop(_) => {}
18878            LayerEventKind::Effect(_) => panic!("expected backdrop to run before effect"),
18879        }
18880        match events[1].kind {
18881            LayerEventKind::Effect(_) => {}
18882            LayerEventKind::Backdrop(_) => panic!("expected effect as second event"),
18883        }
18884    }
18885
18886    #[test]
18887    fn collect_layer_events_prefers_outer_effect_when_same_start_z() {
18888        // Child emitted before parent (matching scene collection order where a
18889        // parent effect is recorded after recursively processing children).
18890        let effects = vec![effect_layer(10, 20), effect_layer(10, 40)];
18891        let mut events = Vec::new();
18892        collect_layer_events(&effects, &[], 0, 50, None, &mut events);
18893
18894        assert_eq!(events.len(), 2);
18895        match events[0].kind {
18896            LayerEventKind::Effect(index) => assert_eq!(index, 1),
18897            LayerEventKind::Backdrop(_) => panic!("expected outer effect first"),
18898        }
18899        match events[1].kind {
18900            LayerEventKind::Effect(index) => assert_eq!(index, 0),
18901            LayerEventKind::Backdrop(_) => panic!("expected child effect second"),
18902        }
18903    }
18904
18905    #[test]
18906    fn collect_layer_events_prefers_later_effect_when_ranges_match() {
18907        let effects = vec![effect_layer(10, 20), effect_layer(10, 20)];
18908        let mut events = Vec::new();
18909        collect_layer_events(&effects, &[], 0, 30, None, &mut events);
18910
18911        assert_eq!(events.len(), 2);
18912        match events[0].kind {
18913            LayerEventKind::Effect(index) => assert_eq!(index, 1),
18914            LayerEventKind::Backdrop(_) => panic!("expected later effect first"),
18915        }
18916        match events[1].kind {
18917            LayerEventKind::Effect(index) => assert_eq!(index, 0),
18918            LayerEventKind::Backdrop(_) => panic!("expected earlier effect second"),
18919        }
18920    }
18921
18922    #[test]
18923    fn has_backdrop_layer_in_range_detects_nested_layers() {
18924        let backdrops = vec![backdrop_layer(5), backdrop_layer(15), backdrop_layer(25)];
18925        assert!(has_backdrop_layer_in_range(&backdrops, 10, 20));
18926        assert!(has_backdrop_layer_in_range(&backdrops, 0, 6));
18927        assert!(!has_backdrop_layer_in_range(&backdrops, 20, 25));
18928    }
18929
18930    #[test]
18931    fn layer_contains_descendant_backdrop_ignores_self_backdrop() {
18932        let mut self_backdrop = test_layer(
18933            Rect {
18934                x: 0.0,
18935                y: 0.0,
18936                width: 10.0,
18937                height: 10.0,
18938            },
18939            vec![],
18940        );
18941        self_backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
18942        assert!(!layer_contains_descendant_backdrop(&self_backdrop));
18943
18944        let mut child = test_layer(
18945            Rect {
18946                x: 0.0,
18947                y: 0.0,
18948                width: 8.0,
18949                height: 8.0,
18950            },
18951            vec![],
18952        );
18953        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
18954
18955        let parent = test_layer(
18956            Rect {
18957                x: 0.0,
18958                y: 0.0,
18959                width: 20.0,
18960                height: 20.0,
18961            },
18962            vec![RenderNode::Layer(Box::new(child))],
18963        );
18964        assert!(layer_contains_descendant_backdrop(&parent));
18965    }
18966
18967    fn child_layer_composite(
18968        layer: &LayerNode,
18969        z_index: usize,
18970        rect: Rect,
18971        needs_nested_underlay: bool,
18972    ) -> crate::normalized_scene::ChildLayerComposite {
18973        let mut requirements_cache = cranpose_core::collections::map::HashMap::new();
18974        let surface_requirements =
18975            crate::surface_plan::layer_surface_requirements_cached(layer, &mut requirements_cache);
18976        crate::normalized_scene::ChildLayerComposite {
18977            z_index,
18978            logical_rect: Rect {
18979                x: 0.0,
18980                y: 0.0,
18981                width: rect.width,
18982                height: rect.height,
18983            },
18984            dest_quad: rect_to_quad(rect),
18985            snap_anchor: None,
18986            composite_snap_origin: None,
18987            backdrop_rect: rect,
18988            visual_clip: None,
18989            surface_clip: None,
18990            shadow_draws: Vec::new(),
18991            needs_nested_underlay,
18992            node_id: layer.node_id,
18993            backdrop: layer.backdrop().cloned(),
18994            has_effect: layer.effect().is_some(),
18995            effect_contains_runtime_shader: layer
18996                .effect()
18997                .is_some_and(|effect| effect.contains_runtime_shader()),
18998            target_content_hash: layer.target_content_hash(),
18999            effect_hash: layer.effect_hash(),
19000            motion_source_content_hash: Some(layer.motion_source_content_hash()),
19001            contains_descendant_backdrop: layer_contains_descendant_backdrop(layer),
19002            cache_policy: layer.cache_policy,
19003            surface_requirements,
19004            rounded_clip: crate::surface_executor::backend::LayerSurfaceRoundedClip::from_layer(
19005                layer,
19006            ),
19007            isolation: cranpose_render_common::layer_composition::effective_layer_isolation(
19008                &layer.graphics_layer,
19009            ),
19010            translated_content_context: layer.translated_content_context,
19011            own_translated_content_axes: crate::surface_plan::translated_content_axes_for_layer(
19012                layer,
19013            ),
19014            clip_rect: layer.clip_rect(),
19015            local_bounds: layer.local_bounds,
19016            surface_scale: crate::surface_plan::layer_surface_scale(layer),
19017            source: crate::normalized_scene::LoweredChildSource::default(),
19018        }
19019    }
19020
19021    #[test]
19022    fn root_direct_preflight_allows_first_translated_child_underlay() {
19023        let child = test_layer(
19024            Rect {
19025                x: 0.0,
19026                y: 0.0,
19027                width: 400.0,
19028                height: 280.0,
19029            },
19030            vec![],
19031        );
19032        let collected = CollectedLayer {
19033            scene: CompositorScene::new(),
19034            child_layers: vec![child_layer_composite(
19035                &child,
19036                3,
19037                Rect {
19038                    x: 48.0,
19039                    y: 96.0,
19040                    width: 400.0,
19041                    height: 280.0,
19042                },
19043                true,
19044            )],
19045        };
19046
19047        assert!(direct_root_child_underlays_are_supported(&collected, false));
19048    }
19049
19050    #[test]
19051    fn root_direct_preflight_allows_axis_aligned_prior_child_underlay() {
19052        let first = test_layer(
19053            Rect {
19054                x: 0.0,
19055                y: 0.0,
19056                width: 80.0,
19057                height: 40.0,
19058            },
19059            vec![],
19060        );
19061        let backdrop_child = test_layer(
19062            Rect {
19063                x: 0.0,
19064                y: 0.0,
19065                width: 400.0,
19066                height: 280.0,
19067            },
19068            vec![],
19069        );
19070        let collected = CollectedLayer {
19071            scene: CompositorScene::new(),
19072            child_layers: vec![
19073                child_layer_composite(
19074                    &first,
19075                    1,
19076                    Rect {
19077                        x: 8.0,
19078                        y: 16.0,
19079                        width: 80.0,
19080                        height: 40.0,
19081                    },
19082                    false,
19083                ),
19084                child_layer_composite(
19085                    &backdrop_child,
19086                    4,
19087                    Rect {
19088                        x: 48.0,
19089                        y: 96.0,
19090                        width: 400.0,
19091                        height: 280.0,
19092                    },
19093                    true,
19094                ),
19095            ],
19096        };
19097
19098        assert!(direct_root_child_underlays_are_supported(&collected, false));
19099    }
19100
19101    #[test]
19102    fn root_direct_preflight_rejects_effectful_prior_child_underlay() {
19103        let mut first = test_layer(
19104            Rect {
19105                x: 0.0,
19106                y: 0.0,
19107                width: 80.0,
19108                height: 40.0,
19109            },
19110            vec![],
19111        );
19112        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19113        let backdrop_child = test_layer(
19114            Rect {
19115                x: 0.0,
19116                y: 0.0,
19117                width: 400.0,
19118                height: 280.0,
19119            },
19120            vec![],
19121        );
19122        let collected = CollectedLayer {
19123            scene: CompositorScene::new(),
19124            child_layers: vec![
19125                child_layer_composite(
19126                    &first,
19127                    1,
19128                    Rect {
19129                        x: 64.0,
19130                        y: 112.0,
19131                        width: 80.0,
19132                        height: 40.0,
19133                    },
19134                    false,
19135                ),
19136                child_layer_composite(
19137                    &backdrop_child,
19138                    4,
19139                    Rect {
19140                        x: 48.0,
19141                        y: 96.0,
19142                        width: 400.0,
19143                        height: 280.0,
19144                    },
19145                    true,
19146                ),
19147            ],
19148        };
19149
19150        assert!(!direct_root_child_underlays_are_supported(
19151            &collected, false
19152        ));
19153    }
19154
19155    #[test]
19156    fn root_direct_preflight_ignores_non_overlapping_effectful_prior_child_underlay() {
19157        let mut first = test_layer(
19158            Rect {
19159                x: 0.0,
19160                y: 0.0,
19161                width: 80.0,
19162                height: 40.0,
19163            },
19164            vec![],
19165        );
19166        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19167        let backdrop_child = test_layer(
19168            Rect {
19169                x: 0.0,
19170                y: 0.0,
19171                width: 400.0,
19172                height: 280.0,
19173            },
19174            vec![],
19175        );
19176        let collected = CollectedLayer {
19177            scene: CompositorScene::new(),
19178            child_layers: vec![
19179                child_layer_composite(
19180                    &first,
19181                    1,
19182                    Rect {
19183                        x: 8.0,
19184                        y: 16.0,
19185                        width: 80.0,
19186                        height: 40.0,
19187                    },
19188                    false,
19189                ),
19190                child_layer_composite(
19191                    &backdrop_child,
19192                    4,
19193                    Rect {
19194                        x: 48.0,
19195                        y: 96.0,
19196                        width: 400.0,
19197                        height: 280.0,
19198                    },
19199                    true,
19200                ),
19201            ],
19202        };
19203
19204        assert!(direct_root_child_underlays_are_supported(&collected, false));
19205    }
19206
19207    #[test]
19208    fn root_direct_preflight_rejects_underlay_that_would_replay_prior_scene_effects() {
19209        let backdrop_child = test_layer(
19210            Rect {
19211                x: 0.0,
19212                y: 0.0,
19213                width: 400.0,
19214                height: 280.0,
19215            },
19216            vec![],
19217        );
19218        let mut scene = CompositorScene::new();
19219        scene.next_z = 1;
19220        scene.push_effect_layer(
19221            Rect {
19222                x: 0.0,
19223                y: 0.0,
19224                width: 120.0,
19225                height: 120.0,
19226            },
19227            None,
19228            Some(RenderEffect::blur(2.0)),
19229            BlendMode::SrcOver,
19230            1.0,
19231            0,
19232            1,
19233        );
19234        let collected = CollectedLayer {
19235            scene,
19236            child_layers: vec![child_layer_composite(
19237                &backdrop_child,
19238                4,
19239                Rect {
19240                    x: 48.0,
19241                    y: 96.0,
19242                    width: 400.0,
19243                    height: 280.0,
19244                },
19245                true,
19246            )],
19247        };
19248
19249        assert!(!direct_root_child_underlays_are_supported(
19250            &collected, false
19251        ));
19252    }
19253
19254    #[test]
19255    fn root_direct_eligibility_does_not_reject_descendant_backdrop() {
19256        let mut backdrop = test_layer(
19257            Rect {
19258                x: 0.0,
19259                y: 0.0,
19260                width: 40.0,
19261                height: 40.0,
19262            },
19263            vec![],
19264        );
19265        backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
19266        let child = test_layer(
19267            Rect {
19268                x: 0.0,
19269                y: 0.0,
19270                width: 120.0,
19271                height: 96.0,
19272            },
19273            vec![RenderNode::Layer(Box::new(backdrop))],
19274        );
19275        let root = test_layer(
19276            Rect {
19277                x: 0.0,
19278                y: 0.0,
19279                width: 240.0,
19280                height: 160.0,
19281            },
19282            vec![RenderNode::Layer(Box::new(child))],
19283        );
19284        let mut cache = HashMap::new();
19285
19286        assert!(root_can_render_directly_cached(&root, &mut cache));
19287    }
19288
19289    #[test]
19290    fn root_direct_scene_events_allow_root_local_effects() {
19291        let mut scene = CompositorScene::new();
19292        scene.effect_layers.push(EffectLayer {
19293            rect: Rect {
19294                x: 20.0,
19295                y: 30.0,
19296                width: 120.0,
19297                height: 80.0,
19298            },
19299            clip: None,
19300            snap_anchor: None,
19301            effect: Some(RenderEffect::blur(6.0)),
19302            blend_mode: BlendMode::SrcOver,
19303            composite_alpha: 1.0,
19304            z_start: 0,
19305            z_end: 1,
19306            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
19307        });
19308
19309        assert!(root_direct_scene_events_are_supported(&scene, false));
19310    }
19311
19312    #[test]
19313    fn root_direct_scene_events_reject_root_local_backdrops() {
19314        let mut scene = CompositorScene::new();
19315        scene.backdrop_layers.push(BackdropLayer {
19316            node_id: Some(99),
19317            rect: Rect {
19318                x: 20.0,
19319                y: 30.0,
19320                width: 120.0,
19321                height: 80.0,
19322            },
19323            clip: None,
19324            snap_anchor: None,
19325            effect: RenderEffect::blur(6.0),
19326            z_index: 1,
19327        });
19328
19329        assert!(!root_direct_scene_events_are_supported(&scene, false));
19330    }
19331
19332    fn scene_with_root_local_backdrop(z_index: usize) -> CompositorScene {
19333        let mut scene = CompositorScene::new();
19334        scene.next_z = z_index + 1;
19335        scene.backdrop_layers.push(BackdropLayer {
19336            node_id: Some(99),
19337            rect: Rect {
19338                x: 20.0,
19339                y: 30.0,
19340                width: 120.0,
19341                height: 80.0,
19342            },
19343            clip: None,
19344            snap_anchor: None,
19345            effect: RenderEffect::blur(6.0),
19346            z_index,
19347        });
19348        scene
19349    }
19350
19351    #[test]
19352    fn a_root_local_backdrop_takes_the_direct_road_when_the_target_reads() {
19353        let scene = scene_with_root_local_backdrop(1);
19354        assert!(root_direct_scene_events_are_supported(&scene, true));
19355    }
19356
19357    #[test]
19358    fn a_backdrop_inside_an_effect_layer_stays_off_the_direct_road() {
19359        let mut scene = scene_with_root_local_backdrop(1);
19360        scene.next_z = 3;
19361        scene.effect_layers.push(EffectLayer {
19362            rect: Rect {
19363                x: 0.0,
19364                y: 0.0,
19365                width: 200.0,
19366                height: 200.0,
19367            },
19368            clip: None,
19369            snap_anchor: None,
19370            effect: Some(RenderEffect::blur(6.0)),
19371            blend_mode: BlendMode::SrcOver,
19372            composite_alpha: 1.0,
19373            z_start: 0,
19374            z_end: 3,
19375            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
19376        });
19377
19378        assert!(!root_direct_scene_events_are_supported(&scene, true));
19379        assert!(!root_direct_scene_events_are_supported(&scene, false));
19380    }
19381
19382    #[test]
19383    fn a_child_that_carries_a_backdrop_takes_the_direct_road_when_the_target_reads() {
19384        let mut backdrop_child = test_layer(
19385            Rect {
19386                x: 0.0,
19387                y: 0.0,
19388                width: 400.0,
19389                height: 280.0,
19390            },
19391            vec![],
19392        );
19393        backdrop_child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
19394        let collected = CollectedLayer {
19395            scene: CompositorScene::new(),
19396            child_layers: vec![child_layer_composite(
19397                &backdrop_child,
19398                1,
19399                Rect {
19400                    x: 48.0,
19401                    y: 96.0,
19402                    width: 400.0,
19403                    height: 280.0,
19404                },
19405                false,
19406            )],
19407        };
19408
19409        assert!(collected.child_layers[0].backdrop.is_some());
19410        assert!(direct_root_child_underlays_are_supported(&collected, true));
19411        assert!(!direct_root_child_underlays_are_supported(
19412            &collected, false
19413        ));
19414    }
19415
19416    fn frosted_layer(bounds: Rect, offset: Point) -> LayerNode {
19417        let mut layer = test_layer(
19418            bounds,
19419            vec![RenderNode::Primitive(PrimitiveEntry {
19420                phase: PrimitivePhase::BeforeChildren,
19421                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19422                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19423                        rect: bounds,
19424                        brush: Brush::solid(Color::from_rgba_u8(255, 255, 255, 60)),
19425                        stroke: None,
19426                    },
19427                    clip: None,
19428                }),
19429            })],
19430        );
19431        layer.transform_to_parent = ProjectiveTransform::translation(offset.x, offset.y);
19432        layer.graphics_layer.backdrop_effect = Some(RenderEffect::blur(8.0));
19433        layer
19434    }
19435
19436    #[test]
19437    fn a_frosted_layer_keeps_its_own_surface() {
19438        let frosted = frosted_layer(
19439            Rect {
19440                x: 0.0,
19441                y: 0.0,
19442                width: 40.0,
19443                height: 20.0,
19444            },
19445            Point::new(10.0, 6.0),
19446        );
19447        let root = test_layer(
19448            Rect {
19449                x: 0.0,
19450                y: 0.0,
19451                width: 200.0,
19452                height: 100.0,
19453            },
19454            vec![RenderNode::Layer(Box::new(frosted))],
19455        );
19456        let mut rect_cache = HashMap::new();
19457        let mut requirements_cache = HashMap::new();
19458
19459        let collected =
19460            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19461
19462        assert_eq!(collected.child_layers.len(), 1);
19463        assert!(
19464            collected.scene.backdrop_layers.is_empty(),
19465            "a layer that keeps its surface carries its backdrop on the composite"
19466        );
19467        assert!(collected.child_layers[0].backdrop.is_some());
19468    }
19469
19470    fn row_with_clipped_glass(row_background: Color) -> LayerNode {
19471        let mut glass = frosted_layer(
19472            Rect {
19473                x: 0.0,
19474                y: 0.0,
19475                width: 40.0,
19476                height: 20.0,
19477            },
19478            Point::new(10.0, 6.0),
19479        );
19480        glass.isolation.shape_clip = true;
19481        let row_bounds = Rect {
19482            x: 0.0,
19483            y: 0.0,
19484            width: 200.0,
19485            height: 40.0,
19486        };
19487        let mut row = test_layer(
19488            row_bounds,
19489            vec![
19490                RenderNode::Primitive(PrimitiveEntry {
19491                    phase: PrimitivePhase::BeforeChildren,
19492                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
19493                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19494                            rect: row_bounds,
19495                            brush: Brush::solid(row_background),
19496                            stroke: None,
19497                        },
19498                        clip: None,
19499                    }),
19500                }),
19501                RenderNode::Layer(Box::new(glass)),
19502            ],
19503        );
19504        row.isolation.shape_clip = true;
19505        row
19506    }
19507
19508    #[test]
19509    fn a_backdrop_covered_by_its_own_row_asks_for_no_underlay() {
19510        let root = test_layer(
19511            Rect {
19512                x: 0.0,
19513                y: 0.0,
19514                width: 400.0,
19515                height: 200.0,
19516            },
19517            vec![RenderNode::Layer(Box::new(row_with_clipped_glass(
19518                Color::WHITE,
19519            )))],
19520        );
19521        let mut rect_cache = HashMap::new();
19522        let mut requirements_cache = HashMap::new();
19523
19524        let collected =
19525            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19526
19527        assert_eq!(collected.child_layers.len(), 1);
19528        assert!(collected.child_layers[0].contains_descendant_backdrop);
19529        assert!(
19530            !collected.child_layers[0].needs_nested_underlay,
19531            "an opaque row draw under the glass is all the blur reads, so no picture of the scene behind the row is needed"
19532        );
19533    }
19534
19535    #[test]
19536    fn a_backdrop_over_a_see_through_row_still_asks_for_an_underlay() {
19537        let root = test_layer(
19538            Rect {
19539                x: 0.0,
19540                y: 0.0,
19541                width: 400.0,
19542                height: 200.0,
19543            },
19544            vec![RenderNode::Layer(Box::new(row_with_clipped_glass(
19545                Color::from_rgba_u8(255, 255, 255, 40),
19546            )))],
19547        );
19548        let mut rect_cache = HashMap::new();
19549        let mut requirements_cache = HashMap::new();
19550
19551        let collected =
19552            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19553
19554        assert_eq!(collected.child_layers.len(), 1);
19555        assert!(collected.child_layers[0].needs_nested_underlay);
19556    }
19557
19558    #[test]
19559    fn estimate_layer_surface_rect_includes_transformed_child_bounds() {
19560        let mut child = test_layer(
19561            Rect {
19562                x: 0.0,
19563                y: 0.0,
19564                width: 10.0,
19565                height: 6.0,
19566            },
19567            vec![RenderNode::Primitive(PrimitiveEntry {
19568                phase: PrimitivePhase::BeforeChildren,
19569                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19570                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19571                        rect: Rect {
19572                            x: 0.0,
19573                            y: 0.0,
19574                            width: 10.0,
19575                            height: 6.0,
19576                        },
19577                        brush: Brush::solid(Color::WHITE),
19578                        stroke: None,
19579                    },
19580                    clip: None,
19581                }),
19582            })],
19583        );
19584        child.transform_to_parent = ProjectiveTransform::translation(18.0, 7.0);
19585
19586        let parent = test_layer(
19587            Rect {
19588                x: 0.0,
19589                y: 0.0,
19590                width: 4.0,
19591                height: 4.0,
19592            },
19593            vec![RenderNode::Layer(Box::new(child))],
19594        );
19595
19596        assert_eq!(
19597            estimate_layer_surface_rect(&parent),
19598            Rect {
19599                x: 18.0,
19600                y: 7.0,
19601                width: 10.0,
19602                height: 6.0,
19603            }
19604        );
19605    }
19606
19607    #[test]
19608    fn estimate_layer_surface_rect_clips_translated_clip_layers_without_hidden_leading_content() {
19609        let mut layer = test_layer(
19610            Rect {
19611                x: 0.0,
19612                y: 0.0,
19613                width: 120.0,
19614                height: 72.0,
19615            },
19616            vec![RenderNode::Primitive(PrimitiveEntry {
19617                phase: PrimitivePhase::BeforeChildren,
19618                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19619                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19620                        rect: Rect {
19621                            x: 24.0,
19622                            y: 0.0,
19623                            width: 200.0,
19624                            height: 480.0,
19625                        },
19626                        brush: Brush::solid(Color::WHITE),
19627                        stroke: None,
19628                    },
19629                    clip: None,
19630                }),
19631            })],
19632        );
19633        layer.translated_content_context = true;
19634        layer.motion_context_animated = true;
19635        layer.clip_to_bounds = true;
19636
19637        assert_eq!(
19638            estimate_layer_surface_rect(&layer),
19639            Rect {
19640                x: 24.0,
19641                y: 0.0,
19642                width: 96.0,
19643                height: 72.0,
19644            }
19645        );
19646    }
19647
19648    #[test]
19649    fn estimate_layer_surface_rect_clips_active_horizontal_scroll_content() {
19650        let mut layer = test_layer(
19651            Rect {
19652                x: 0.0,
19653                y: 0.0,
19654                width: 120.0,
19655                height: 72.0,
19656            },
19657            vec![RenderNode::Primitive(PrimitiveEntry {
19658                phase: PrimitivePhase::BeforeChildren,
19659                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19660                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19661                        rect: Rect {
19662                            x: -24.0,
19663                            y: 0.0,
19664                            width: 200.0,
19665                            height: 480.0,
19666                        },
19667                        brush: Brush::solid(Color::WHITE),
19668                        stroke: None,
19669                    },
19670                    clip: None,
19671                }),
19672            })],
19673        );
19674        layer.translated_content_context = true;
19675        layer.motion_context_animated = true;
19676        layer.clip_to_bounds = true;
19677
19678        assert_eq!(
19679            estimate_layer_surface_rect(&layer),
19680            Rect {
19681                x: 0.0,
19682                y: 0.0,
19683                width: 120.0,
19684                height: 72.0,
19685            }
19686        );
19687    }
19688
19689    #[test]
19690    fn estimate_layer_surface_rect_clips_active_vertical_scroll_content() {
19691        let mut layer = test_layer(
19692            Rect {
19693                x: 0.0,
19694                y: 0.0,
19695                width: 120.0,
19696                height: 72.0,
19697            },
19698            vec![RenderNode::Primitive(PrimitiveEntry {
19699                phase: PrimitivePhase::BeforeChildren,
19700                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19701                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19702                        rect: Rect {
19703                            x: 0.0,
19704                            y: -24.0,
19705                            width: 120.0,
19706                            height: 200.0,
19707                        },
19708                        brush: Brush::solid(Color::WHITE),
19709                        stroke: None,
19710                    },
19711                    clip: None,
19712                }),
19713            })],
19714        );
19715        layer.translated_content_context = true;
19716        layer.motion_context_animated = true;
19717        layer.clip_to_bounds = true;
19718
19719        assert_eq!(
19720            estimate_layer_surface_rect(&layer),
19721            Rect {
19722                x: 0.0,
19723                y: 0.0,
19724                width: 120.0,
19725                height: 72.0,
19726            }
19727        );
19728    }
19729
19730    #[test]
19731    fn estimate_layer_surface_rect_keeps_shallow_scroll_capture_origin_stable() {
19732        fn shallow_scroll_surface_rect(content_y: f32) -> Rect {
19733            let mut layer = test_layer(
19734                Rect {
19735                    x: 0.0,
19736                    y: 0.0,
19737                    width: 120.0,
19738                    height: 72.0,
19739                },
19740                vec![RenderNode::Primitive(PrimitiveEntry {
19741                    phase: PrimitivePhase::BeforeChildren,
19742                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
19743                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19744                            rect: Rect {
19745                                x: 0.0,
19746                                y: content_y,
19747                                width: 120.0,
19748                                height: 200.0,
19749                            },
19750                            brush: Brush::solid(Color::WHITE),
19751                            stroke: None,
19752                        },
19753                        clip: None,
19754                    }),
19755                })],
19756            );
19757            layer.translated_content_context = true;
19758            layer.motion_context_animated = true;
19759            layer.clip_to_bounds = true;
19760            estimate_layer_surface_rect(&layer)
19761        }
19762
19763        assert_eq!(
19764            shallow_scroll_surface_rect(-24.0),
19765            shallow_scroll_surface_rect(-25.0),
19766            "shallow scroll capture bounds must not move the offscreen surface origin on adjacent scroll positions"
19767        );
19768    }
19769
19770    #[test]
19771    fn estimate_layer_surface_rect_clips_active_xy_scroll_content() {
19772        let mut layer = test_layer(
19773            Rect {
19774                x: 0.0,
19775                y: 0.0,
19776                width: 120.0,
19777                height: 72.0,
19778            },
19779            vec![RenderNode::Primitive(PrimitiveEntry {
19780                phase: PrimitivePhase::BeforeChildren,
19781                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19782                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19783                        rect: Rect {
19784                            x: -16.0,
19785                            y: -24.0,
19786                            width: 180.0,
19787                            height: 240.0,
19788                        },
19789                        brush: Brush::solid(Color::WHITE),
19790                        stroke: None,
19791                    },
19792                    clip: None,
19793                }),
19794            })],
19795        );
19796        layer.translated_content_context = true;
19797        layer.motion_context_animated = true;
19798        layer.clip_to_bounds = true;
19799
19800        assert_eq!(
19801            estimate_layer_surface_rect(&layer),
19802            Rect {
19803                x: 0.0,
19804                y: 0.0,
19805                width: 120.0,
19806                height: 72.0,
19807            }
19808        );
19809    }
19810
19811    #[test]
19812    fn estimate_layer_surface_rect_clips_deep_hidden_active_scroll_content() {
19813        let mut layer = test_layer(
19814            Rect {
19815                x: 0.0,
19816                y: 0.0,
19817                width: 120.0,
19818                height: 72.0,
19819            },
19820            vec![RenderNode::Primitive(PrimitiveEntry {
19821                phase: PrimitivePhase::BeforeChildren,
19822                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19823                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19824                        rect: Rect {
19825                            x: 0.0,
19826                            y: -1200.0,
19827                            width: 120.0,
19828                            height: 1400.0,
19829                        },
19830                        brush: Brush::solid(Color::WHITE),
19831                        stroke: None,
19832                    },
19833                    clip: None,
19834                }),
19835            })],
19836        );
19837        layer.translated_content_context = true;
19838        layer.motion_context_animated = true;
19839        layer.clip_to_bounds = true;
19840
19841        assert_eq!(
19842            estimate_layer_surface_rect(&layer),
19843            Rect {
19844                x: 0.0,
19845                y: 0.0,
19846                width: 120.0,
19847                height: 72.0,
19848            }
19849        );
19850    }
19851
19852    #[test]
19853    fn estimate_layer_surface_rect_keeps_deep_scroll_capture_origin_stable() {
19854        fn deep_scroll_surface_rect(content_y: f32) -> Rect {
19855            let mut layer = test_layer(
19856                Rect {
19857                    x: 0.0,
19858                    y: 0.0,
19859                    width: 120.0,
19860                    height: 72.0,
19861                },
19862                vec![RenderNode::Primitive(PrimitiveEntry {
19863                    phase: PrimitivePhase::BeforeChildren,
19864                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
19865                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19866                            rect: Rect {
19867                                x: 0.0,
19868                                y: content_y,
19869                                width: 120.0,
19870                                height: 1400.0,
19871                            },
19872                            brush: Brush::solid(Color::WHITE),
19873                            stroke: None,
19874                        },
19875                        clip: None,
19876                    }),
19877                })],
19878            );
19879            layer.translated_content_context = true;
19880            layer.motion_context_animated = true;
19881            layer.clip_to_bounds = true;
19882            estimate_layer_surface_rect(&layer)
19883        }
19884
19885        assert_eq!(
19886            deep_scroll_surface_rect(-1200.0),
19887            deep_scroll_surface_rect(-1201.0),
19888            "deep scroll capture bounds must not re-phase the offscreen surface origin on adjacent scroll positions"
19889        );
19890    }
19891
19892    #[test]
19893    fn motion_stable_capture_bounds_bounds_shadows_for_clipped_effect_layer() {
19894        let mut layer = test_layer(
19895            Rect {
19896                x: 0.0,
19897                y: 0.0,
19898                width: 120.0,
19899                height: 72.0,
19900            },
19901            vec![],
19902        );
19903        layer.clip_to_bounds = true;
19904        layer.graphics_layer.clip = true;
19905        layer.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19906
19907        let mut shadow_shape = test_shape(0, BlendMode::SrcOver);
19908        shadow_shape.rect = Rect {
19909            x: -24.0,
19910            y: -1200.0,
19911            width: 180.0,
19912            height: 1400.0,
19913        };
19914        let mut scene = CompositorScene::new();
19915        scene
19916            .shadow_draws
19917            .push(test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]));
19918
19919        let requirements = SurfaceRequirementSet::default()
19920            .with(SurfaceRequirement::RenderEffect)
19921            .with(SurfaceRequirement::MotionStableCapture);
19922
19923        assert_eq!(
19924            motion_stable_capture_bounds(
19925                &layer,
19926                &scene,
19927                &[],
19928                requirements,
19929                TranslatedContentAxes::default(),
19930                None,
19931            ),
19932            Some(Rect {
19933                x: -360.0,
19934                y: -216.0,
19935                width: 480.0,
19936                height: 288.0,
19937            })
19938        );
19939    }
19940
19941    #[test]
19942    fn vertical_motion_stable_capture_uses_viewport_cross_axis_bounds() {
19943        let mut layer = test_layer(
19944            Rect {
19945                x: 0.0,
19946                y: 0.0,
19947                width: 200.0,
19948                height: 100.0,
19949            },
19950            vec![],
19951        );
19952        layer.clip_to_bounds = true;
19953        layer.graphics_layer.clip = true;
19954
19955        let mut shape = test_shape(0, BlendMode::SrcOver);
19956        shape.rect = Rect {
19957            x: 60.0,
19958            y: -80.0,
19959            width: 80.0,
19960            height: 220.0,
19961        };
19962        let mut scene = CompositorScene::new();
19963        scene.shapes.push(shape);
19964
19965        let requirements =
19966            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
19967
19968        assert_eq!(
19969            motion_stable_capture_bounds(
19970                &layer,
19971                &scene,
19972                &[],
19973                requirements,
19974                TranslatedContentAxes { x: false, y: true },
19975                None,
19976            ),
19977            Some(Rect {
19978                x: -96.0,
19979                y: -64.0,
19980                width: 296.0,
19981                height: 164.0,
19982            })
19983        );
19984    }
19985
19986    #[test]
19987    fn vertical_motion_stable_capture_uses_external_surface_clip() {
19988        let layer = test_layer(
19989            Rect {
19990                x: 0.0,
19991                y: 0.0,
19992                width: 200.0,
19993                height: 100.0,
19994            },
19995            vec![],
19996        );
19997
19998        let mut shape = test_shape(0, BlendMode::SrcOver);
19999        shape.rect = Rect {
20000            x: 60.0,
20001            y: -80.0,
20002            width: 80.0,
20003            height: 220.0,
20004        };
20005        let mut scene = CompositorScene::new();
20006        scene.shapes.push(shape);
20007
20008        let requirements =
20009            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
20010
20011        assert_eq!(
20012            motion_stable_capture_bounds(
20013                &layer,
20014                &scene,
20015                &[],
20016                requirements,
20017                TranslatedContentAxes { x: false, y: true },
20018                Some(Rect {
20019                    x: 0.0,
20020                    y: 0.0,
20021                    width: 200.0,
20022                    height: 100.0,
20023                }),
20024            ),
20025            Some(Rect {
20026                x: -96.0,
20027                y: -64.0,
20028                width: 296.0,
20029                height: 164.0,
20030            })
20031        );
20032    }
20033
20034    #[test]
20035    fn estimate_layer_surface_rect_expands_for_child_layer_shadow() {
20036        let mut child = test_layer(
20037            Rect {
20038                x: 0.0,
20039                y: 0.0,
20040                width: 12.0,
20041                height: 8.0,
20042            },
20043            vec![],
20044        );
20045        child.transform_to_parent = ProjectiveTransform::translation(20.0, 9.0);
20046        child.graphics_layer.shadow_elevation = 6.0;
20047
20048        let parent = test_layer(
20049            Rect {
20050                x: 0.0,
20051                y: 0.0,
20052                width: 4.0,
20053                height: 4.0,
20054            },
20055            vec![RenderNode::Layer(Box::new(child))],
20056        );
20057
20058        let rect = estimate_layer_surface_rect(&parent);
20059        assert!(rect.x < 20.0);
20060        assert!(rect.y < 9.0);
20061        assert!(rect.width > 12.0);
20062        assert!(rect.height > 8.0);
20063    }
20064
20065    #[test]
20066    fn estimate_layer_surface_rect_respects_local_bounds_for_effect_layers() {
20067        let mut layer = test_layer(
20068            Rect {
20069                x: 0.0,
20070                y: 0.0,
20071                width: 28.0,
20072                height: 28.0,
20073            },
20074            vec![RenderNode::Primitive(PrimitiveEntry {
20075                phase: PrimitivePhase::BeforeChildren,
20076                node: PrimitiveNode::Draw(DrawPrimitiveNode {
20077                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20078                        rect: Rect {
20079                            x: 10.0,
20080                            y: 10.0,
20081                            width: 10.0,
20082                            height: 10.0,
20083                        },
20084                        brush: Brush::solid(Color::WHITE),
20085                        stroke: None,
20086                    },
20087                    clip: None,
20088                }),
20089            })],
20090        );
20091        layer.graphics_layer.render_effect = Some(RenderEffect::blur(12.0));
20092
20093        assert_eq!(
20094            estimate_layer_surface_rect(&layer),
20095            Rect {
20096                x: 0.0,
20097                y: 0.0,
20098                width: 28.0,
20099                height: 28.0,
20100            }
20101        );
20102    }
20103
20104    #[test]
20105    fn layer_raster_cache_candidate_ignores_parent_transform() {
20106        let primitive = PrimitiveEntry {
20107            phase: PrimitivePhase::BeforeChildren,
20108            node: PrimitiveNode::Draw(DrawPrimitiveNode {
20109                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20110                    rect: Rect {
20111                        x: 2.0,
20112                        y: 3.0,
20113                        width: 6.0,
20114                        height: 4.0,
20115                    },
20116                    brush: Brush::solid(Color::BLACK),
20117                    stroke: None,
20118                },
20119                clip: None,
20120            }),
20121        };
20122        let base = cacheable_layer(
20123            41,
20124            Rect {
20125                x: 0.0,
20126                y: 0.0,
20127                width: 20.0,
20128                height: 20.0,
20129            },
20130            vec![RenderNode::Primitive(primitive.clone())],
20131        );
20132        let mut moved = base.clone();
20133        moved.transform_to_parent = ProjectiveTransform::translation(32.0, 18.0);
20134
20135        assert_eq!(
20136            layer_raster_cache_candidate(&base, 1.25, false, false),
20137            layer_raster_cache_candidate(&moved, 1.25, false, false)
20138        );
20139    }
20140
20141    #[test]
20142    fn layer_raster_cache_candidate_changes_for_translated_content_offset() {
20143        let primitive = PrimitiveEntry {
20144            phase: PrimitivePhase::BeforeChildren,
20145            node: PrimitiveNode::Draw(DrawPrimitiveNode {
20146                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20147                    rect: Rect {
20148                        x: 2.0,
20149                        y: 3.0,
20150                        width: 6.0,
20151                        height: 4.0,
20152                    },
20153                    brush: Brush::solid(Color::BLACK),
20154                    stroke: None,
20155                },
20156                clip: None,
20157            }),
20158        };
20159        let mut base = cacheable_layer(
20160            42,
20161            Rect {
20162                x: 0.0,
20163                y: 0.0,
20164                width: 20.0,
20165                height: 20.0,
20166            },
20167            vec![RenderNode::Primitive(primitive)],
20168        );
20169        base.translated_content_context = true;
20170        base.translated_content_offset = Point::new(0.0, -8.0);
20171        base.recompute_raster_cache_hashes();
20172
20173        let mut moved = base.clone();
20174        moved.translated_content_offset = Point::new(0.0, -16.0);
20175        moved.recompute_raster_cache_hashes();
20176
20177        assert_ne!(
20178            layer_raster_cache_candidate(&base, 1.25, false, false),
20179            layer_raster_cache_candidate(&moved, 1.25, false, false),
20180            "full-surface layer cache candidates must not alias different scroll offsets"
20181        );
20182    }
20183
20184    #[test]
20185    fn layer_raster_cache_candidate_changes_for_child_transform() {
20186        let mut child = cacheable_layer(
20187            8,
20188            Rect {
20189                x: 0.0,
20190                y: 0.0,
20191                width: 12.0,
20192                height: 10.0,
20193            },
20194            vec![],
20195        );
20196        child.transform_to_parent = ProjectiveTransform::translation(4.0, 6.0);
20197        let base = cacheable_layer(
20198            7,
20199            Rect {
20200                x: 0.0,
20201                y: 0.0,
20202                width: 20.0,
20203                height: 20.0,
20204            },
20205            vec![RenderNode::Layer(Box::new(child.clone()))],
20206        );
20207        let mut moved_child = child;
20208        moved_child.transform_to_parent = ProjectiveTransform::translation(9.0, 6.0);
20209        let moved = cacheable_layer(
20210            7,
20211            Rect {
20212                x: 0.0,
20213                y: 0.0,
20214                width: 20.0,
20215                height: 20.0,
20216            },
20217            vec![RenderNode::Layer(Box::new(moved_child))],
20218        );
20219
20220        assert_ne!(
20221            layer_raster_cache_candidate(&base, 1.0, false, false),
20222            layer_raster_cache_candidate(&moved, 1.0, false, false)
20223        );
20224    }
20225
20226    #[test]
20227    fn layer_raster_cache_candidate_rejects_external_backdrop_dependency() {
20228        let mut child = cacheable_layer(
20229            12,
20230            Rect {
20231                x: 0.0,
20232                y: 0.0,
20233                width: 8.0,
20234                height: 8.0,
20235            },
20236            vec![],
20237        );
20238        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
20239        let parent = cacheable_layer(
20240            11,
20241            Rect {
20242                x: 0.0,
20243                y: 0.0,
20244                width: 16.0,
20245                height: 16.0,
20246            },
20247            vec![RenderNode::Layer(Box::new(child))],
20248        );
20249
20250        assert!(layer_raster_cache_candidate(&parent, 1.0, false, false).is_some());
20251        assert!(layer_raster_cache_candidate(&parent, 1.0, true, false).is_none());
20252    }
20253
20254    #[test]
20255    fn layer_raster_cache_candidate_does_not_force_translation_only_text_surfaces() {
20256        let text = RenderNode::Primitive(PrimitiveEntry {
20257            phase: PrimitivePhase::BeforeChildren,
20258            node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
20259                node_id: 77,
20260                rect: Rect {
20261                    x: 2.0,
20262                    y: 3.0,
20263                    width: 48.0,
20264                    height: 18.0,
20265                },
20266                text: std::rc::Rc::new(AnnotatedString::from("runtime cache")),
20267                text_style: TextStyle::default(),
20268                font_size: 14.0,
20269                layout_options: TextLayoutOptions::default(),
20270                clip: None,
20271            })),
20272        });
20273        let mut layer = test_layer(
20274            Rect {
20275                x: 0.0,
20276                y: 0.0,
20277                width: 64.0,
20278                height: 32.0,
20279            },
20280            vec![text],
20281        );
20282        layer.node_id = Some(77);
20283        layer.recompute_raster_cache_hashes();
20284
20285        assert!(
20286            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
20287            "root path should not isolate plain translation-only text layers"
20288        );
20289        assert!(
20290            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
20291            "child path should also render plain translation-only text layers directly"
20292        );
20293    }
20294
20295    #[test]
20296    fn layer_raster_cache_candidate_allows_stable_runtime_child_effect_surfaces() {
20297        let mut layer = test_layer(
20298            Rect {
20299                x: 0.0,
20300                y: 0.0,
20301                width: 64.0,
20302                height: 32.0,
20303            },
20304            vec![RenderNode::Primitive(PrimitiveEntry {
20305                phase: PrimitivePhase::BeforeChildren,
20306                node: PrimitiveNode::Draw(DrawPrimitiveNode {
20307                    primitive: DrawPrimitive::Rect {
20308                        rect: Rect {
20309                            x: 0.0,
20310                            y: 0.0,
20311                            width: 64.0,
20312                            height: 32.0,
20313                        },
20314                        brush: Brush::solid(Color::WHITE),
20315                        stroke: None,
20316                    },
20317                    clip: None,
20318                }),
20319            })],
20320        );
20321        layer.node_id = Some(78);
20322        layer.graphics_layer.render_effect = Some(RenderEffect::blur(4.0));
20323        layer.recompute_raster_cache_hashes();
20324
20325        assert!(
20326            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
20327            "root direct path should not force-cache ordinary stable effects"
20328        );
20329        assert!(
20330            layer_raster_cache_candidate(&layer, 1.0, false, true).is_some(),
20331            "child surface rendering should retain stable non-runtime effects"
20332        );
20333    }
20334
20335    #[test]
20336    fn layer_raster_cache_candidate_rejects_runtime_shader_child_effect_surfaces() {
20337        let mut layer = test_layer(
20338            Rect {
20339                x: 0.0,
20340                y: 0.0,
20341                width: 64.0,
20342                height: 32.0,
20343            },
20344            vec![],
20345        );
20346        layer.node_id = Some(79);
20347        layer.graphics_layer.render_effect = Some(RenderEffect::runtime_shader(
20348            RuntimeShader::new("runtime shader"),
20349        ));
20350        layer.recompute_raster_cache_hashes();
20351
20352        assert!(
20353            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
20354            "runtime shaders must not fill the retained layer cache with per-frame uniform variants"
20355        );
20356    }
20357
20358    #[test]
20359    fn layer_surface_requirements_keep_plain_text_on_direct_path() {
20360        let layer = text_layer_with_style(AnnotatedString::from("plain"), TextStyle::default());
20361
20362        let requirements = layer_surface_requirements(&layer);
20363
20364        assert_eq!(requirements.direct_translation, Some(Point::default()));
20365        assert!(requirements
20366            .surface_requirements
20367            .contains(SurfaceRequirement::PixelStableComposite));
20368        assert!(!requirements
20369            .surface_requirements
20370            .has_isolating_requirement());
20371    }
20372
20373    #[test]
20374    fn layer_surface_requirements_keep_translated_plain_text_leaf_on_direct_path() {
20375        let layer = pure_text_leaf(false, true);
20376
20377        let requirements = layer_surface_requirements(&layer);
20378
20379        assert_eq!(
20380            requirements.direct_translation,
20381            Some(Point::new(11.4, 23.6))
20382        );
20383        assert!(
20384            requirements
20385                .surface_requirements
20386                .contains(SurfaceRequirement::PixelStableComposite)
20387                && !requirements
20388                    .surface_requirements
20389                    .has_isolating_requirement(),
20390            "translated plain text should stay on the direct path and isolate only the glyph draw"
20391        );
20392    }
20393
20394    #[test]
20395    fn layer_surface_requirements_keep_translated_text_leaf_with_background_on_direct_path() {
20396        let layer = snapped_text_leaf(false, true);
20397
20398        let requirements = layer_surface_requirements(&layer);
20399
20400        assert_eq!(
20401            requirements.direct_translation,
20402            Some(Point::new(14.25, 16.5))
20403        );
20404        assert!(
20405            requirements
20406                .surface_requirements
20407                .contains(SurfaceRequirement::PixelStableComposite)
20408                && !requirements
20409                    .surface_requirements
20410                    .has_isolating_requirement(),
20411            "translated text with direct sibling decoration/background should keep the layer direct"
20412        );
20413    }
20414
20415    #[test]
20416    fn translated_plain_text_uses_bounded_snap_surface() {
20417        let root = pure_text_leaf_root(true, true);
20418        let mut rect_cache = HashMap::new();
20419        let mut requirements_cache = HashMap::new();
20420        let collected =
20421            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20422
20423        assert_eq!(collected.child_layers.len(), 1);
20424        assert!(collected.scene.texts.is_empty());
20425        assert!(collected.scene.effect_layers.is_empty());
20426        assert_snap_anchor_close(
20427            collected.child_layers[0].snap_anchor,
20428            Point::new(11.4, 23.6),
20429            "translated plain text's bounded local surface should composite at the content-origin snap phase",
20430        );
20431    }
20432
20433    /// Not a correctness test: a local timing harness for the shape-run
20434    /// collect path. Run manually with
20435    /// `cargo test --release -p cranpose-render-wgpu -- --ignored collect_timing --nocapture`.
20436    #[test]
20437    #[ignore]
20438    fn shape_run_collect_timing_harness() {
20439        use cranpose_render_common::graph::DrawPrimitiveNode;
20440        use cranpose_render_common::layer_composition::local_content_layer_for;
20441        use cranpose_ui_graphics::Stroke;
20442
20443        let bounds = Rect {
20444            x: 0.0,
20445            y: 0.0,
20446            width: 1080.0,
20447            height: 2244.0,
20448        };
20449        let graphics_layer = GraphicsLayer::default();
20450
20451        // A MEGA-BOSS-shaped workload: thousands of consecutive arcs, most
20452        // solid, some gradient, one text-free layer.
20453        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
20454        for i in 0..3000u32 {
20455            let f = i as f32;
20456            let brush = if i % 8 == 0 {
20457                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
20458            } else {
20459                Brush::Solid(Color(0.5, 0.2, 0.8, 1.0))
20460            };
20461            let center = Point::new(540.0 + (f % 400.0), 1122.0 + (f % 350.0));
20462            let radius = 8.0 + (i % 23) as f32;
20463            let half = radius + 4.0;
20464            nodes.push(DrawPrimitiveNode {
20465                primitive: DrawPrimitive::Arc {
20466                    rect: Rect {
20467                        x: center.x - half,
20468                        y: center.y - half,
20469                        width: half * 2.0,
20470                        height: half * 2.0,
20471                    },
20472                    brush,
20473                    center,
20474                    radius,
20475                    start_angle: f * 0.07,
20476                    sweep_angle: 0.5 + (i % 5) as f32,
20477                    stroke: (i % 3 != 0).then(|| Stroke::new(4.0)),
20478                    inner_radius: if i % 3 == 0 { radius * 0.6 } else { 0.0 },
20479                },
20480                clip: None,
20481            });
20482        }
20483
20484        let children: Vec<RenderNode> = nodes
20485            .iter()
20486            .map(|node| {
20487                RenderNode::Primitive(PrimitiveEntry {
20488                    phase: PrimitivePhase::BeforeChildren,
20489                    node: PrimitiveNode::Draw(node.clone()),
20490                })
20491            })
20492            .collect();
20493        let layer = crate::test_support::layer_node(
20494            bounds,
20495            ProjectiveTransform::identity(),
20496            graphics_layer,
20497            children,
20498        );
20499
20500        const ITERS: usize = 300;
20501
20502        // Reference: the pre-run per-primitive path.
20503        let local_layer = local_content_layer_for(&layer.graphics_layer);
20504        let start = Instant::now();
20505        let mut sink_shapes = 0usize;
20506        for _ in 0..ITERS {
20507            let mut scene = CompositorScene::new();
20508            for node in &nodes {
20509                crate::pipeline::push_draw_primitive(
20510                    &node.primitive,
20511                    bounds,
20512                    &local_layer,
20513                    None,
20514                    &mut scene,
20515                    None,
20516                    false,
20517                );
20518            }
20519            sink_shapes = scene.shapes.len();
20520        }
20521        let serial = start.elapsed();
20522
20523        let mut rect_cache = HashMap::new();
20524        let mut requirements_cache = HashMap::new();
20525        let start = Instant::now();
20526        let mut run_shapes = 0usize;
20527        for _ in 0..ITERS {
20528            let collected = collect_layer_contents(
20529                &layer,
20530                None,
20531                None,
20532                &mut rect_cache,
20533                &mut requirements_cache,
20534            );
20535            run_shapes = collected.scene.shapes.len();
20536        }
20537        let run = start.elapsed();
20538
20539        println!(
20540            "per-primitive: {:?}/iter ({sink_shapes} shapes)  shape-run: {:?}/iter ({run_shapes} shapes)",
20541            serial / ITERS as u32,
20542            run / ITERS as u32,
20543        );
20544    }
20545
20546    /// Shared body for the serial and forced-parallel equivalence tests:
20547    fn assert_shape_run_collect_matches_per_primitive_emission() {
20548        use cranpose_render_common::graph::DrawPrimitiveNode;
20549        use cranpose_render_common::layer_composition::local_content_layer_for;
20550        use cranpose_render_common::primitive_emit::{resolve_primitive_clip, PrimitiveClipSpace};
20551        use cranpose_ui_graphics::{CornerRadii, Stroke};
20552
20553        let bounds = Rect {
20554            x: 0.0,
20555            y: 0.0,
20556            width: 800.0,
20557            height: 800.0,
20558        };
20559        // Rotation keeps rigid snapping off, so both paths agree on
20560        // `snap_anchor: None` without replicating the anchor computation here.
20561        let graphics_layer = GraphicsLayer {
20562            scale: 1.25,
20563            translation_x: 3.5,
20564            translation_y: -2.0,
20565            alpha: 0.9,
20566            rotation_z: 0.35,
20567            ..GraphicsLayer::default()
20568        };
20569
20570        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
20571        for i in 0..600u32 {
20572            let f = i as f32;
20573            let brush = if i % 11 == 0 {
20574                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
20575            } else {
20576                Brush::Solid(Color(0.1 + (i % 7) as f32 * 0.1, 0.5, 0.9, 1.0))
20577            };
20578            let stroke = (i % 5 == 0).then(|| Stroke::new(1.0 + (i % 3) as f32));
20579            let primitive = match i % 3 {
20580                0 => DrawPrimitive::Rect {
20581                    rect: Rect {
20582                        x: f % 37.0,
20583                        y: f % 53.0,
20584                        width: 8.0 + f % 9.0,
20585                        height: 6.0 + f % 5.0,
20586                    },
20587                    brush,
20588                    stroke,
20589                },
20590                1 => DrawPrimitive::RoundRect {
20591                    rect: Rect {
20592                        x: f % 41.0,
20593                        y: f % 43.0,
20594                        width: 12.0,
20595                        height: 10.0,
20596                    },
20597                    brush,
20598                    radii: CornerRadii::uniform(2.0 + (i % 4) as f32),
20599                    stroke,
20600                },
20601                _ => {
20602                    let center = Point::new(60.0 + f % 71.0, 60.0 + f % 67.0);
20603                    let radius = 5.0 + (i % 13) as f32;
20604                    // One degenerate sweep proves dropped draws stay dropped.
20605                    let sweep_angle = if i == 302 { 0.0 } else { 0.4 + (i % 6) as f32 };
20606                    let half = radius + 4.0;
20607                    DrawPrimitive::Arc {
20608                        rect: Rect {
20609                            x: center.x - half,
20610                            y: center.y - half,
20611                            width: half * 2.0,
20612                            height: half * 2.0,
20613                        },
20614                        brush,
20615                        center,
20616                        radius,
20617                        start_angle: f * 0.11,
20618                        sweep_angle,
20619                        stroke: (i % 2 == 0).then(|| Stroke::new(3.0)),
20620                        inner_radius: if i % 4 == 2 { radius * 0.5 } else { 0.0 },
20621                    }
20622                }
20623            };
20624            let primitive = if i == 300 {
20625                // A nested blend disqualifies the run view and forces a
20626                // mid-run flush through the serial path, splitting 600 draws
20627                // into two runs that are both long enough to fan out.
20628                DrawPrimitive::Blend {
20629                    primitive: Box::new(DrawPrimitive::Blend {
20630                        primitive: Box::new(primitive),
20631                        blend_mode: BlendMode::SrcOver,
20632                    }),
20633                    blend_mode: BlendMode::DstOut,
20634                }
20635            } else if i % 7 == 3 {
20636                DrawPrimitive::Blend {
20637                    primitive: Box::new(primitive),
20638                    blend_mode: BlendMode::DstOut,
20639                }
20640            } else {
20641                primitive
20642            };
20643            let clip = (i % 31 == 7).then_some(Rect {
20644                x: 0.0,
20645                y: 0.0,
20646                width: 30.0,
20647                height: 30.0,
20648            });
20649            nodes.push(DrawPrimitiveNode { primitive, clip });
20650        }
20651
20652        let children: Vec<RenderNode> = nodes
20653            .iter()
20654            .map(|node| {
20655                RenderNode::Primitive(PrimitiveEntry {
20656                    phase: PrimitivePhase::BeforeChildren,
20657                    node: PrimitiveNode::Draw(node.clone()),
20658                })
20659            })
20660            .collect();
20661        let layer = crate::test_support::layer_node(
20662            bounds,
20663            ProjectiveTransform::identity(),
20664            graphics_layer,
20665            children,
20666        );
20667
20668        let mut rect_cache = HashMap::new();
20669        let mut requirements_cache = HashMap::new();
20670        let collected =
20671            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
20672
20673        // The reference scene: every primitive through the per-primitive
20674        // emission path, exactly as the pre-run collect loop ran it.
20675        let local_layer = local_content_layer_for(&layer.graphics_layer);
20676        let mut expected = CompositorScene::new();
20677        for node in &nodes {
20678            let clip = resolve_primitive_clip(
20679                node.clip,
20680                bounds,
20681                &local_layer,
20682                None,
20683                PrimitiveClipSpace::Local,
20684            );
20685            if node.clip.is_some() && clip.is_none() {
20686                continue;
20687            }
20688            crate::pipeline::push_draw_primitive(
20689                &node.primitive,
20690                bounds,
20691                &local_layer,
20692                clip,
20693                &mut expected,
20694                None,
20695                false,
20696            );
20697        }
20698
20699        assert!(
20700            collected.scene.shapes.len() >= 590,
20701            "the runs should engage the parallel branch: got {} shapes",
20702            collected.scene.shapes.len()
20703        );
20704        assert_eq!(collected.scene.shapes.len(), expected.shapes.len());
20705        assert_eq!(collected.scene.draw_ops, expected.draw_ops);
20706        assert_eq!(collected.scene.next_z, expected.next_z);
20707        assert!(
20708            collected
20709                .scene
20710                .shapes
20711                .iter()
20712                .all(|s| s.snap_anchor.is_none()),
20713            "a rotated layer must not rigid-snap; the reference scene assumes it"
20714        );
20715        for (index, (got, want)) in collected
20716            .scene
20717            .shapes
20718            .iter()
20719            .zip(&expected.shapes)
20720            .enumerate()
20721        {
20722            assert_eq!(got.rect, want.rect, "shape {index} rect");
20723            assert_eq!(got.local_rect, want.local_rect, "shape {index} local_rect");
20724            assert_eq!(got.quad, want.quad, "shape {index} quad");
20725            assert_eq!(got.snap_anchor, want.snap_anchor, "shape {index} snap");
20726            assert_eq!(got.brush, want.brush, "shape {index} brush");
20727            assert_eq!(got.shape, want.shape, "shape {index} shape");
20728            assert_eq!(got.stroke, want.stroke, "shape {index} stroke");
20729            assert_eq!(got.arc, want.arc, "shape {index} arc");
20730            assert_eq!(got.z_index, want.z_index, "shape {index} z");
20731            assert_eq!(got.clip, want.clip, "shape {index} clip");
20732            assert_eq!(got.blend_mode, want.blend_mode, "shape {index} blend");
20733            assert_eq!(
20734                got.motion_context_animated, want.motion_context_animated,
20735                "shape {index} motion flag"
20736            );
20737        }
20738    }
20739
20740    /// The run collector must emit exactly what per-primitive emission does,
20741    /// on BOTH flush paths: the serial drain and the scoped-thread fan-out
20742    /// (forced via the tuning override, since a test-sized scene would never
20743    /// cross the size gate on its own).
20744    #[test]
20745    fn shape_run_collect_matches_per_primitive_emission_exactly() {
20746        assert_shape_run_collect_matches_per_primitive_emission();
20747        crate::normalized_scene::force_shape_run_parallel_for_tests(true);
20748        let outcome =
20749            std::panic::catch_unwind(assert_shape_run_collect_matches_per_primitive_emission);
20750        crate::normalized_scene::force_shape_run_parallel_for_tests(false);
20751        if let Err(payload) = outcome {
20752            std::panic::resume_unwind(payload);
20753        }
20754    }
20755
20756    #[test]
20757    fn non_translated_text_local_surface_keeps_linear_composite_resolve() {
20758        let layer = text_layer_with_style(
20759            AnnotatedString::from("gradient"),
20760            TextStyle::from_span_style(SpanStyle {
20761                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
20762                ..SpanStyle::default()
20763            }),
20764        );
20765        let requirements = layer_surface_requirements(&layer);
20766
20767        assert!(requirements
20768            .surface_requirements
20769            .contains(SurfaceRequirement::TextMaterialMask));
20770        assert_eq!(
20771            composite_sample_mode_for_requirements(false, false, requirements),
20772            CompositeSampleMode::Linear
20773        );
20774    }
20775
20776    #[test]
20777    fn inherited_translated_text_local_surface_uses_box4_layer_surface() {
20778        let layer = text_layer_with_style(
20779            AnnotatedString::from("shadow"),
20780            TextStyle::from_span_style(SpanStyle {
20781                shadow: Some(Shadow {
20782                    color: Color::BLACK,
20783                    offset: Point::new(1.0, 2.0),
20784                    blur_radius: 3.0,
20785                }),
20786                ..SpanStyle::default()
20787            }),
20788        );
20789        let requirements = layer_surface_requirements(&layer);
20790
20791        assert!(requirements
20792            .surface_requirements
20793            .contains(SurfaceRequirement::TextMaterialMask));
20794        assert_eq!(
20795            composite_sample_mode_for_requirements(true, false, requirements),
20796            CompositeSampleMode::Box4
20797        );
20798        assert_eq!(
20799            layer_surface_target_scale(
20800                true,
20801                false,
20802                requirements,
20803                1.25,
20804                layer_surface_scale(&layer)
20805            ),
20806            SurfaceRequirementSet::default()
20807                .with(SurfaceRequirement::TextMaterialMask)
20808                .with(SurfaceRequirement::MotionStableCapture)
20809                .target_scale(1.25, 1.0)
20810        );
20811    }
20812
20813    #[test]
20814    fn translated_text_local_surface_inside_capture_keeps_parent_scale() {
20815        let layer = text_layer_with_style(
20816            AnnotatedString::from("shadow"),
20817            TextStyle::from_span_style(SpanStyle {
20818                shadow: Some(Shadow {
20819                    color: Color::BLACK,
20820                    offset: Point::new(1.0, 2.0),
20821                    blur_radius: 3.0,
20822                }),
20823                ..SpanStyle::default()
20824            }),
20825        );
20826        let requirements = layer_surface_requirements(&layer);
20827
20828        assert_eq!(
20829            composite_sample_mode_for_requirements(true, true, requirements),
20830            CompositeSampleMode::Linear
20831        );
20832        assert_eq!(
20833            layer_surface_target_scale(true, true, requirements, 10.0, layer_surface_scale(&layer)),
20834            SurfaceRequirementSet::default()
20835                .with(SurfaceRequirement::TextMaterialMask)
20836                .target_scale(10.0, 1.0)
20837        );
20838    }
20839
20840    #[test]
20841    fn layer_surface_requirements_use_local_surface_for_gradient_and_stroke_text() {
20842        let cases = [
20843            (
20844                "draw_style",
20845                AnnotatedString::from("draw_style"),
20846                TextStyle::from_span_style(SpanStyle {
20847                    draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
20848                    ..SpanStyle::default()
20849                }),
20850            ),
20851            (
20852                "gradient_brush",
20853                AnnotatedString::from("gradient"),
20854                TextStyle::from_span_style(SpanStyle {
20855                    brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
20856                    ..SpanStyle::default()
20857                }),
20858            ),
20859        ];
20860
20861        for (label, text, text_style) in cases {
20862            let layer = text_layer_with_style(text, text_style);
20863            let requirements = layer_surface_requirements(&layer);
20864            assert!(
20865                requirements
20866                    .surface_requirements
20867                    .contains(SurfaceRequirement::TextMaterialMask),
20868                "{label} text should use a bounded local surface: {requirements:?}"
20869            );
20870        }
20871    }
20872
20873    #[test]
20874    fn layer_surface_requirements_use_local_surface_for_complex_text_effects() {
20875        let cases = [
20876            (
20877                "shadow",
20878                AnnotatedString::from("shadow"),
20879                TextStyle::from_span_style(SpanStyle {
20880                    shadow: Some(Shadow {
20881                        color: Color::BLACK,
20882                        offset: Point::new(1.0, 2.0),
20883                        blur_radius: 3.0,
20884                    }),
20885                    ..SpanStyle::default()
20886                }),
20887            ),
20888            (
20889                "background",
20890                AnnotatedString::from("background"),
20891                TextStyle::from_span_style(SpanStyle {
20892                    background: Some(Color::BLACK),
20893                    ..SpanStyle::default()
20894                }),
20895            ),
20896            (
20897                "baseline_shift",
20898                AnnotatedString::from("baseline_shift"),
20899                TextStyle::from_span_style(SpanStyle {
20900                    baseline_shift: Some(BaselineShift::SUPERSCRIPT),
20901                    ..SpanStyle::default()
20902                }),
20903            ),
20904            (
20905                "geometric_transform",
20906                AnnotatedString::from("geometric_transform"),
20907                TextStyle::from_span_style(SpanStyle {
20908                    text_geometric_transform: Some(TextGeometricTransform {
20909                        scale_x: 1.2,
20910                        skew_x: 0.15,
20911                    }),
20912                    ..SpanStyle::default()
20913                }),
20914            ),
20915            (
20916                "letter_spacing",
20917                AnnotatedString::from("letter_spacing"),
20918                TextStyle::from_span_style(SpanStyle {
20919                    letter_spacing: TextUnit::Em(0.2),
20920                    ..SpanStyle::default()
20921                }),
20922            ),
20923        ];
20924
20925        for (label, text, text_style) in cases {
20926            let layer = text_layer_with_style(text, text_style);
20927            let requirements = layer_surface_requirements(&layer);
20928            assert!(
20929                requirements
20930                    .surface_requirements
20931                    .contains(SurfaceRequirement::TextMaterialMask),
20932                "{label} text should use a bounded local surface: {requirements:?}"
20933            );
20934            assert_eq!(
20935                requirements.direct_translation,
20936                Some(Point::default()),
20937                "{label} text should still classify as a direct translation"
20938            );
20939        }
20940    }
20941
20942    #[test]
20943    fn layer_surface_requirements_color_only_span_styles_use_direct_path() {
20944        let layer = text_layer_with_style(
20945            AnnotatedString {
20946                text: "styled".to_string(),
20947                span_styles: vec![RangeStyle {
20948                    item: SpanStyle {
20949                        color: Some(Color::BLACK),
20950                        ..SpanStyle::default()
20951                    },
20952                    range: 0..3,
20953                }],
20954                ..AnnotatedString::default()
20955            },
20956            TextStyle::default(),
20957        );
20958        let requirements = layer_surface_requirements(&layer);
20959        assert!(
20960            !requirements
20961                .surface_requirements
20962                .contains(SurfaceRequirement::TextMaterialMask),
20963            "color-only span styles should render directly via software text raster colors"
20964        );
20965    }
20966
20967    #[test]
20968    fn layer_surface_requirements_keep_decoration_only_text_on_direct_path() {
20969        let layer = text_layer_with_style(
20970            AnnotatedString::from("decoration"),
20971            TextStyle::from_span_style(SpanStyle {
20972                text_decoration: Some(TextDecoration::UNDERLINE),
20973                ..SpanStyle::default()
20974            }),
20975        );
20976
20977        let requirements = layer_surface_requirements(&layer);
20978
20979        assert_eq!(requirements.direct_translation, Some(Point::default()));
20980        assert!(
20981            requirements
20982                .surface_requirements
20983                .contains(SurfaceRequirement::PixelStableComposite)
20984                && !requirements
20985                    .surface_requirements
20986                    .has_isolating_requirement(),
20987            "decoration-only text should not force an isolating layer surface: {requirements:?}"
20988        );
20989    }
20990
20991    #[test]
20992    fn direct_text_leaf_snaps_modifier_background_and_text_with_one_anchor() {
20993        let root = snapped_text_leaf_root(false, false);
20994        let mut rect_cache = HashMap::new();
20995        let mut requirements_cache = HashMap::new();
20996
20997        let collected =
20998            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20999
21000        assert_eq!(collected.scene.shapes.len(), 1);
21001        assert_eq!(collected.scene.images.len(), 1);
21002        assert_eq!(collected.scene.texts.len(), 1);
21003        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
21004        assert_eq!(collected.scene.shapes[0].snap_anchor, expected_anchor);
21005        assert_eq!(collected.scene.images[0].snap_anchor, expected_anchor);
21006        assert_eq!(collected.scene.texts[0].snap_anchor, expected_anchor);
21007    }
21008
21009    #[test]
21010    fn animated_translated_content_text_leaf_uses_bounded_content_snap() {
21011        let root = snapped_text_leaf_root(true, true);
21012        let mut rect_cache = HashMap::new();
21013        let mut requirements_cache = HashMap::new();
21014
21015        let collected =
21016            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21017
21018        assert_eq!(collected.child_layers.len(), 1);
21019        assert!(collected.scene.shapes.is_empty());
21020        assert!(collected.scene.images.is_empty());
21021        assert!(collected.scene.texts.is_empty());
21022        assert!(collected.scene.effect_layers.is_empty());
21023        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
21024        assert_eq!(
21025            collected.child_layers[0].snap_anchor, expected_anchor,
21026            "active translated leaf surface should keep the content-origin snap phase"
21027        );
21028    }
21029
21030    #[test]
21031    fn translated_content_assigns_motion_anchor_to_rotated_child_surface() {
21032        let mut child = snapped_text_leaf(false, false);
21033        child.graphics_layer.rotation_z = 5.0;
21034        child.transform_to_parent =
21035            cranpose_render_common::layer_transform::layer_transform_to_parent(
21036                child.local_bounds,
21037                Point::new(108.0, 3.0),
21038                &child.graphics_layer,
21039            );
21040        child.recompute_raster_cache_hashes();
21041        let mut root = test_layer(
21042            Rect {
21043                x: 0.0,
21044                y: 0.0,
21045                width: 320.0,
21046                height: 180.0,
21047            },
21048            vec![RenderNode::Layer(Box::new(child))],
21049        );
21050        root.translated_content_context = true;
21051        root.translated_content_offset = Point::new(0.0, -80.8);
21052        root.recompute_raster_cache_hashes();
21053        let mut rect_cache = HashMap::new();
21054        let mut requirements_cache = HashMap::new();
21055
21056        let collected =
21057            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21058
21059        assert_eq!(collected.child_layers.len(), 1);
21060        assert!(
21061            collected.child_layers[0].snap_anchor.is_some(),
21062            "a projective child still translates rigidly with its scrolling parent"
21063        );
21064    }
21065
21066    #[test]
21067    fn rested_translated_content_context_text_leaf_snaps_for_crisp_scroll_rest() {
21068        let root = snapped_text_leaf_root(false, true);
21069        let mut rect_cache = HashMap::new();
21070        let mut requirements_cache = HashMap::new();
21071
21072        let collected =
21073            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21074
21075        assert_eq!(collected.child_layers.len(), 0);
21076        assert_eq!(collected.scene.shapes.len(), 1);
21077        assert_eq!(collected.scene.images.len(), 1);
21078        assert_eq!(collected.scene.texts.len(), 1);
21079        assert_eq!(collected.scene.effect_layers.len(), 0);
21080        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
21081        assert_eq!(
21082            collected.scene.shapes[0].snap_anchor, expected_anchor,
21083            "rested scroll content should snap back to device pixels"
21084        );
21085        assert_eq!(
21086            collected.scene.images[0].snap_anchor, expected_anchor,
21087            "rested scroll images should snap back to device pixels"
21088        );
21089        assert_eq!(
21090            collected.scene.texts[0].snap_anchor, expected_anchor,
21091            "rested scroll text should snap back to device pixels"
21092        );
21093    }
21094
21095    #[test]
21096    fn complex_text_uses_local_surface() {
21097        let root = translated_content_local_surface_root();
21098        let mut rect_cache = HashMap::new();
21099        let mut requirements_cache = HashMap::new();
21100
21101        let collected =
21102            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21103
21104        assert!(
21105            !collected.child_layers.is_empty(),
21106            "translated-content effectful text should render through a bounded local surface"
21107        );
21108        assert!(collected.scene.texts.is_empty());
21109        assert!(collected.scene.shadow_draws.is_empty());
21110    }
21111
21112    #[test]
21113    fn translated_content_surface_composite_uses_scroll_content_snap_anchor() {
21114        let mut root = translated_content_local_surface_root();
21115        let scroll_offset = Point::new(0.0, -18.5);
21116        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
21117            panic!("expected translated content layer");
21118        };
21119        translated_content.translated_content_offset = scroll_offset;
21120        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
21121            panic!("expected effectful text layer");
21122        };
21123        effectful_text.transform_to_parent =
21124            effectful_text
21125                .transform_to_parent
21126                .then(ProjectiveTransform::translation(
21127                    scroll_offset.x,
21128                    scroll_offset.y,
21129                ));
21130
21131        let mut rect_cache = HashMap::new();
21132        let mut requirements_cache = HashMap::new();
21133        let collected =
21134            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21135
21136        assert_eq!(collected.child_layers.len(), 1);
21137        assert_eq!(
21138            collected.child_layers[0].snap_anchor,
21139            Some(SnapAnchor::rigid(Point::new(14.25, -2.0))),
21140            "isolated scrolled descendants must composite with the same content-origin snap phase"
21141        );
21142    }
21143
21144    #[test]
21145    fn animated_translated_content_surface_composite_uses_scroll_content_snap_anchor() {
21146        let mut root = translated_content_local_surface_root();
21147        let scroll_offset = Point::new(0.0, -18.5);
21148        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
21149            panic!("expected translated content layer");
21150        };
21151        translated_content.motion_context_animated = true;
21152        translated_content.translated_content_offset = scroll_offset;
21153        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
21154            panic!("expected effectful text layer");
21155        };
21156        effectful_text.transform_to_parent =
21157            effectful_text
21158                .transform_to_parent
21159                .then(ProjectiveTransform::translation(
21160                    scroll_offset.x,
21161                    scroll_offset.y,
21162                ));
21163
21164        let mut rect_cache = HashMap::new();
21165        let mut requirements_cache = HashMap::new();
21166        let collected =
21167            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21168
21169        assert_eq!(collected.child_layers.len(), 1);
21170        assert_eq!(
21171            collected.child_layers[0].snap_anchor,
21172            Some(SnapAnchor::rigid(Point::new(14.25, 16.5))),
21173            "animated translated content should composite the stable local surface at the viewport-origin snap phase"
21174        );
21175    }
21176
21177    #[test]
21178    fn translated_text_material_effect_layer_uses_scroll_content_snap_anchor() {
21179        let mut layer = text_layer_with_style(
21180            AnnotatedString::from("gradient"),
21181            TextStyle::from_span_style(SpanStyle {
21182                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
21183                ..SpanStyle::default()
21184            }),
21185        );
21186        layer.translated_content_context = true;
21187        layer.translated_content_offset = Point::new(0.0, -18.5);
21188        let mut rect_cache = HashMap::new();
21189        let mut requirements_cache = HashMap::new();
21190
21191        let collected =
21192            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
21193
21194        assert_eq!(collected.scene.effect_layers.len(), 1);
21195        assert_eq!(
21196            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
21197            CompositeSampleMode::Box4
21198        );
21199        assert_eq!(
21200            collected.scene.effect_layers[0].snap_anchor,
21201            Some(SnapAnchor::rigid(Point::new(0.0, -18.5))),
21202            "text material surfaces must composite with the scroll content-origin snap phase"
21203        );
21204    }
21205
21206    #[test]
21207    fn translated_layer_surface_capture_does_not_restart_local_picture_for_shadow_text() {
21208        let mut layer = text_layer_with_style(
21209            AnnotatedString::from("shadow"),
21210            TextStyle::from_span_style(SpanStyle {
21211                shadow: Some(Shadow {
21212                    color: Color::BLACK,
21213                    offset: Point::new(1.0, 2.0),
21214                    blur_radius: 3.0,
21215                }),
21216                ..SpanStyle::default()
21217            }),
21218        );
21219        layer.translated_content_context = true;
21220        let mut rect_cache = HashMap::new();
21221        let mut requirements_cache = HashMap::new();
21222
21223        let collected = collect_layer_contents_with_translation_context(
21224            &layer,
21225            None,
21226            None,
21227            TranslationRenderContext {
21228                inherited_content_translation: false,
21229                surface_capture_active: true,
21230                local_picture_capture_active: true,
21231                ..TranslationRenderContext::default()
21232            },
21233            &mut rect_cache,
21234            &mut requirements_cache,
21235        );
21236
21237        assert!(
21238            collected.scene.effect_layers.is_empty(),
21239            "a translated layer surface already provides the stable local capture"
21240        );
21241        assert_eq!(collected.scene.shadow_draws.len(), 1);
21242        assert_eq!(collected.scene.texts.len(), 1);
21243        assert!(
21244            !collected.scene.texts[0].translated_content_context,
21245            "text inside an active motion-stable capture must raster in capture-local coordinates"
21246        );
21247    }
21248
21249    #[test]
21250    fn translated_layer_surface_capture_keeps_only_material_effect_layers() {
21251        let mut layer = text_layer_with_style(
21252            AnnotatedString::from("gradient"),
21253            TextStyle::from_span_style(SpanStyle {
21254                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
21255                ..SpanStyle::default()
21256            }),
21257        );
21258        layer.translated_content_context = true;
21259        let mut rect_cache = HashMap::new();
21260        let mut requirements_cache = HashMap::new();
21261
21262        let collected = collect_layer_contents_with_translation_context(
21263            &layer,
21264            None,
21265            None,
21266            TranslationRenderContext {
21267                inherited_content_translation: false,
21268                surface_capture_active: true,
21269                local_picture_capture_active: true,
21270                ..TranslationRenderContext::default()
21271            },
21272            &mut rect_cache,
21273            &mut requirements_cache,
21274        );
21275
21276        assert_eq!(collected.scene.effect_layers.len(), 1);
21277        assert!(
21278            collected.scene.effect_layers[0]
21279                .requirements
21280                .contains(SurfaceRequirement::MotionStableCapture),
21281            "translated text materials still need motion-stable resolve semantics inside a stable capture"
21282        );
21283        assert_eq!(
21284            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
21285            CompositeSampleMode::Box4
21286        );
21287        assert_eq!(
21288            effect_layer_target_scale(&collected.scene.effect_layers[0], 10.0),
21289            10.0
21290        );
21291        assert!(collected.scene.effect_layers[0].effect.is_some());
21292    }
21293
21294    #[test]
21295    fn translated_viewport_surface_does_not_add_plain_local_picture_capture() {
21296        let mut layer = text_layer_with_style(
21297            AnnotatedString::from("shadow"),
21298            TextStyle::from_span_style(SpanStyle {
21299                shadow: Some(Shadow {
21300                    color: Color::BLACK,
21301                    offset: Point::new(1.0, 2.0),
21302                    blur_radius: 3.0,
21303                }),
21304                ..SpanStyle::default()
21305            }),
21306        );
21307        layer.translated_content_context = true;
21308        layer.motion_context_animated = true;
21309        let mut rect_cache = HashMap::new();
21310        let mut requirements_cache = HashMap::new();
21311
21312        let collected = collect_layer_contents_with_translation_context(
21313            &layer,
21314            None,
21315            None,
21316            TranslationRenderContext {
21317                surface_capture_active: true,
21318                ..TranslationRenderContext::default()
21319            },
21320            &mut rect_cache,
21321            &mut requirements_cache,
21322        );
21323
21324        assert_eq!(
21325            collected.scene.effect_layers.len(),
21326            0,
21327            "plain translated content inside a viewport surface should not be captured again"
21328        );
21329        assert_eq!(collected.scene.shadow_draws.len(), 1);
21330        assert_eq!(collected.scene.texts.len(), 1);
21331    }
21332
21333    #[test]
21334    fn static_pure_text_leaf_snaps_without_sibling_draw_primitives() {
21335        let root = pure_text_leaf_root(false, false);
21336        let mut rect_cache = HashMap::new();
21337        let mut requirements_cache = HashMap::new();
21338
21339        let collected =
21340            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21341
21342        assert_eq!(collected.scene.texts.len(), 1);
21343        assert!(
21344            collected.scene.texts[0].snap_anchor.is_some(),
21345            "idle pure text leaves should participate in rigid snap anchoring"
21346        );
21347    }
21348
21349    #[test]
21350    fn animated_pure_text_leaf_stays_unsnapped() {
21351        let root = pure_text_leaf_root(true, false);
21352        let mut rect_cache = HashMap::new();
21353        let mut requirements_cache = HashMap::new();
21354
21355        let collected =
21356            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21357
21358        assert_eq!(collected.scene.texts.len(), 1);
21359        assert_eq!(collected.scene.texts[0].snap_anchor, None);
21360    }
21361
21362    #[test]
21363    fn animated_translated_pure_text_uses_bounded_content_snap() {
21364        let root = pure_text_leaf_root(true, true);
21365        let mut rect_cache = HashMap::new();
21366        let mut requirements_cache = HashMap::new();
21367
21368        let collected =
21369            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21370
21371        assert_eq!(collected.child_layers.len(), 1);
21372        assert!(collected.scene.texts.is_empty());
21373        assert!(collected.scene.effect_layers.is_empty());
21374        assert_snap_anchor_close(
21375            collected.child_layers[0].snap_anchor,
21376            Point::new(11.4, 23.6),
21377            "animated translated pure text should use the bounded content snap phase",
21378        );
21379    }
21380
21381    #[test]
21382    fn rested_translated_pure_text_leaf_snaps_for_crisp_scroll_rest() {
21383        let root = pure_text_leaf_root(false, true);
21384        let mut rect_cache = HashMap::new();
21385        let mut requirements_cache = HashMap::new();
21386
21387        let collected =
21388            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21389
21390        assert_eq!(collected.child_layers.len(), 0);
21391        assert_eq!(collected.scene.texts.len(), 1);
21392        assert_eq!(collected.scene.effect_layers.len(), 0);
21393        assert_snap_anchor_close(
21394            collected.scene.texts[0].snap_anchor,
21395            Point::new(11.4, 23.6),
21396            "rested translated text should snap to device pixels",
21397        );
21398    }
21399
21400    #[test]
21401    fn static_gpu_effect_text_leaf_stays_unsnapped() {
21402        let root = text_layer_with_style(
21403            AnnotatedString::from("Gradient"),
21404            TextStyle::from_span_style(SpanStyle {
21405                brush: Some(Brush::linear_gradient(vec![
21406                    Color(0.2, 0.8, 1.0, 1.0),
21407                    Color(1.0, 0.7, 0.4, 1.0),
21408                ])),
21409                draw_style: Some(TextDrawStyle::Stroke { width: 2.5 }),
21410                ..SpanStyle::default()
21411            }),
21412        );
21413        let mut rect_cache = HashMap::new();
21414        let mut requirements_cache = HashMap::new();
21415
21416        let collected =
21417            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21418
21419        assert_eq!(collected.scene.texts.len(), 1);
21420        assert_eq!(
21421            collected.scene.texts[0].snap_anchor, None,
21422            "gpu text-effect leaves must not take the rigid text snap path"
21423        );
21424        assert_eq!(
21425            collected.scene.effect_layers.len(),
21426            1,
21427            "gradient stroke text should still emit a runtime shader effect layer"
21428        );
21429    }
21430
21431    #[test]
21432    fn layer_surface_requirements_keep_shape_plus_direct_child_on_direct_path() {
21433        let mut child = test_layer(
21434            Rect {
21435                x: 0.0,
21436                y: 0.0,
21437                width: 40.0,
21438                height: 20.0,
21439            },
21440            vec![RenderNode::Primitive(PrimitiveEntry {
21441                phase: PrimitivePhase::BeforeChildren,
21442                node: PrimitiveNode::Draw(DrawPrimitiveNode {
21443                    primitive: DrawPrimitive::Rect {
21444                        rect: Rect {
21445                            x: 0.0,
21446                            y: 0.0,
21447                            width: 40.0,
21448                            height: 20.0,
21449                        },
21450                        brush: Brush::solid(Color::WHITE),
21451                        stroke: None,
21452                    },
21453                    clip: None,
21454                }),
21455            })],
21456        );
21457        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
21458
21459        let layer = test_layer(
21460            Rect {
21461                x: 0.0,
21462                y: 0.0,
21463                width: 64.0,
21464                height: 32.0,
21465            },
21466            vec![
21467                RenderNode::Primitive(PrimitiveEntry {
21468                    phase: PrimitivePhase::BeforeChildren,
21469                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
21470                        primitive: DrawPrimitive::Rect {
21471                            rect: Rect {
21472                                x: 0.0,
21473                                y: 0.0,
21474                                width: 64.0,
21475                                height: 32.0,
21476                            },
21477                            brush: Brush::solid(Color::BLACK),
21478                            stroke: None,
21479                        },
21480                        clip: None,
21481                    }),
21482                }),
21483                RenderNode::Layer(Box::new(child)),
21484            ],
21485        );
21486
21487        let requirements = layer_surface_requirements(&layer);
21488
21489        assert_eq!(requirements.direct_translation, Some(Point::default()));
21490        assert!(!requirements
21491            .surface_requirements
21492            .contains(SurfaceRequirement::MixedDirectContent));
21493        assert!(!requirements
21494            .surface_requirements
21495            .has_isolating_requirement());
21496    }
21497
21498    #[test]
21499    fn collect_layer_contents_translates_direct_text_rects_into_parent_space() {
21500        let mut child = text_layer_with_style(
21501            AnnotatedString::from("direct"),
21502            TextStyle::from_span_style(SpanStyle {
21503                text_decoration: Some(TextDecoration::UNDERLINE),
21504                ..SpanStyle::default()
21505            }),
21506        );
21507        child.transform_to_parent = ProjectiveTransform::translation(9.0, 7.0);
21508
21509        let parent = test_layer(
21510            Rect {
21511                x: 0.0,
21512                y: 0.0,
21513                width: 64.0,
21514                height: 32.0,
21515            },
21516            vec![RenderNode::Layer(Box::new(child))],
21517        );
21518
21519        let mut rect_cache = HashMap::new();
21520        let mut requirements_cache = HashMap::new();
21521        let collected = with_test_app_context(|| {
21522            collect_layer_contents(
21523                &parent,
21524                None,
21525                None,
21526                &mut rect_cache,
21527                &mut requirements_cache,
21528            )
21529        });
21530
21531        assert!(
21532            collected.child_layers.is_empty(),
21533            "decoration-only text child should collapse directly into the parent scene"
21534        );
21535        assert_eq!(collected.scene.texts.len(), 1, "expected one text draw");
21536        let text = &collected.scene.texts[0];
21537        assert!(
21538            text.rect.x >= 9.0 && text.rect.y >= 7.0,
21539            "collapsed text rect should be translated into parent space, got {:?}",
21540            text.rect
21541        );
21542        assert!(
21543            collected
21544                .scene
21545                .shapes
21546                .iter()
21547                .any(|shape| shape.rect.y >= 7.0),
21548            "collapsed underline geometry should also be translated into parent space"
21549        );
21550    }
21551
21552    #[test]
21553    fn normalized_scene_keeps_lazy_after_bound_text_for_prewarm() {
21554        use std::cell::RefCell;
21555
21556        fn collect_graph_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
21557            for child in &layer.children {
21558                match child {
21559                    RenderNode::Primitive(PrimitiveEntry {
21560                        node: PrimitiveNode::Text(text),
21561                        ..
21562                    }) => labels.push(text.text.text.clone()),
21563                    RenderNode::Layer(child_layer) => {
21564                        collect_graph_text_labels(child_layer, labels)
21565                    }
21566                    RenderNode::Primitive(_) | RenderNode::DrawRun(_) => {}
21567                }
21568            }
21569        }
21570
21571        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
21572        let state_holder_for_comp = state_holder.clone();
21573        let mut composition = cranpose_ui::run_test_composition(move || {
21574            let list_state = remember_lazy_list_state();
21575            *state_holder_for_comp.borrow_mut() = Some(list_state);
21576            let mut spec = LazyColumnSpec::new()
21577                .vertical_arrangement(cranpose_ui::LinearArrangement::SpacedBy(6.0));
21578            spec.beyond_bounds_item_count = 0;
21579            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
21580                scope.items(
21581                    12,
21582                    None::<fn(usize) -> u64>,
21583                    None::<fn(usize) -> u64>,
21584                    |index| {
21585                        Text(
21586                            format!("WarmRow {index}"),
21587                            Modifier::empty().height(32.0),
21588                            TextStyle::default(),
21589                        );
21590                    },
21591                );
21592            });
21593        });
21594
21595        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
21596        list_state.scroll_to_item(4, 0.0);
21597
21598        let root = composition.root().expect("lazy column root");
21599        let handle = composition.runtime_handle();
21600        let mut applier = composition.applier_mut();
21601        applier.set_runtime_handle(handle);
21602        let _ = applier
21603            .compute_layout(
21604                root,
21605                Size {
21606                    width: 240.0,
21607                    height: 240.0,
21608                },
21609            )
21610            .expect("lazy column layout");
21611        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
21612        applier.clear_runtime_handle();
21613        let mut graph_labels = Vec::new();
21614        collect_graph_text_labels(&graph.root, &mut graph_labels);
21615
21616        let visible_indices: Vec<_> = list_state
21617            .layout_info()
21618            .visible_items_info
21619            .iter()
21620            .map(|item| item.index)
21621            .collect();
21622        assert_eq!(
21623            visible_indices,
21624            vec![4, 5, 6],
21625            "test setup expects exactly three viewport-visible rows"
21626        );
21627
21628        let mut rect_cache = HashMap::new();
21629        let mut requirements_cache = HashMap::new();
21630        let collected = with_test_app_context(|| {
21631            collect_layer_contents(
21632                &graph.root,
21633                None,
21634                None,
21635                &mut rect_cache,
21636                &mut requirements_cache,
21637            )
21638        });
21639        let root_text_labels: Vec<_> = collected
21640            .scene
21641            .texts
21642            .iter()
21643            .map(|text| text.text.text.clone())
21644            .collect();
21645        let child_layer_count = collected.child_layers.len();
21646        let warm_text = collected
21647            .scene
21648            .texts
21649            .iter()
21650            .find(|text| text.text.text == "WarmRow 7")
21651            .unwrap_or_else(|| {
21652                panic!(
21653                    "after-bound lazy text should reach WGPU scene collection; graph_texts={graph_labels:?} root_texts={root_text_labels:?} child_layers={child_layer_count}"
21654                )
21655            });
21656
21657        assert!(
21658            warm_text.rect.y >= 96.0,
21659            "after-bound text should be below the viewport, got {:?}",
21660            warm_text.rect
21661        );
21662        assert_eq!(
21663            visible_draw_rect(warm_text.rect, warm_text.clip),
21664            None,
21665            "after-bound text should remain clipped away for drawing while staying available for glyph prewarm"
21666        );
21667        assert!(
21668            text_draw_should_prewarm_in_viewport(
21669                warm_text.rect,
21670                warm_text.clip,
21671                ViewportUniformParams {
21672                    width: 240,
21673                    height: 96,
21674                    offset: [0.0, 0.0],
21675                },
21676                1.0,
21677            ),
21678            "after-bound text inside the warm window must be selected by WGPU prewarm"
21679        );
21680    }
21681
21682    #[test]
21683    fn direct_translation_accepts_nearly_identity_axis_scale_noise() {
21684        let local_bounds = Rect {
21685            x: 0.0,
21686            y: 0.0,
21687            width: 393.3,
21688            height: 16.8,
21689        };
21690        let quad = [
21691            [10.0, 78.399_994],
21692            [403.3, 78.399_994],
21693            [10.0, 95.2],
21694            [403.3, 95.2],
21695        ];
21696        let transform = ProjectiveTransform::from_rect_to_quad(local_bounds, quad);
21697
21698        assert_eq!(
21699            direct_translation(transform),
21700            Some(Point::new(10.0, 78.399_994)),
21701        );
21702    }
21703
21704    #[test]
21705    fn layer_surface_requirements_keep_shape_plus_isolating_child_as_mixed_content() {
21706        let mut child = test_layer(
21707            Rect {
21708                x: 0.0,
21709                y: 0.0,
21710                width: 24.0,
21711                height: 18.0,
21712            },
21713            vec![RenderNode::Primitive(PrimitiveEntry {
21714                phase: PrimitivePhase::BeforeChildren,
21715                node: PrimitiveNode::Draw(DrawPrimitiveNode {
21716                    primitive: DrawPrimitive::Rect {
21717                        rect: Rect {
21718                            x: 0.0,
21719                            y: 0.0,
21720                            width: 24.0,
21721                            height: 18.0,
21722                        },
21723                        brush: Brush::solid(Color::WHITE),
21724                        stroke: None,
21725                    },
21726                    clip: None,
21727                }),
21728            })],
21729        );
21730        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
21731        child.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
21732
21733        let layer = test_layer(
21734            Rect {
21735                x: 0.0,
21736                y: 0.0,
21737                width: 64.0,
21738                height: 32.0,
21739            },
21740            vec![
21741                RenderNode::Primitive(PrimitiveEntry {
21742                    phase: PrimitivePhase::BeforeChildren,
21743                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
21744                        primitive: DrawPrimitive::Rect {
21745                            rect: Rect {
21746                                x: 0.0,
21747                                y: 0.0,
21748                                width: 64.0,
21749                                height: 32.0,
21750                            },
21751                            brush: Brush::solid(Color::BLACK),
21752                            stroke: None,
21753                        },
21754                        clip: None,
21755                    }),
21756                }),
21757                RenderNode::Layer(Box::new(child)),
21758            ],
21759        );
21760
21761        let requirements = layer_surface_requirements(&layer);
21762
21763        assert!(requirements
21764            .surface_requirements
21765            .contains(SurfaceRequirement::MixedDirectContent));
21766        assert!(!requirements
21767            .surface_requirements
21768            .has_isolating_requirement());
21769    }
21770
21771    #[test]
21772    fn build_scene_window_filters_and_translates_items() {
21773        let mut shape = test_shape(6, BlendMode::SrcOver);
21774        shape.rect.x = 12.0;
21775        shape.rect.y = 25.0;
21776        shape.local_rect.x = 12.0;
21777        shape.local_rect.y = 25.0;
21778        shape.quad = [[12.0, 25.0], [20.0, 25.0], [12.0, 33.0], [20.0, 33.0]];
21779        shape.clip = Some(Rect {
21780            x: 11.0,
21781            y: 24.0,
21782            width: 10.0,
21783            height: 10.0,
21784        });
21785
21786        let mut image = test_image(8, BlendMode::SrcOver);
21787        image.rect.x = 18.0;
21788        image.rect.y = 27.0;
21789        image.local_rect.x = 18.0;
21790        image.local_rect.y = 27.0;
21791        image.quad = [[18.0, 27.0], [26.0, 27.0], [18.0, 35.0], [26.0, 35.0]];
21792
21793        let mut text = test_text(9);
21794        text.rect.x = 16.0;
21795        text.rect.y = 29.0;
21796        text.clip = Some(Rect {
21797            x: 15.0,
21798            y: 28.0,
21799            width: 9.0,
21800            height: 6.0,
21801        });
21802
21803        let mut shadow_shape = test_shape(7, BlendMode::SrcOver);
21804        shadow_shape.rect.x = 14.0;
21805        shadow_shape.rect.y = 26.0;
21806        shadow_shape.local_rect.x = 14.0;
21807        shadow_shape.local_rect.y = 26.0;
21808        shadow_shape.quad = [[14.0, 26.0], [22.0, 26.0], [14.0, 34.0], [22.0, 34.0]];
21809        let mut shadow = test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]);
21810        shadow.z_index = 7;
21811
21812        let mut nested_effect = effect_layer(6, 10);
21813        nested_effect.rect.x = 13.0;
21814        nested_effect.rect.y = 24.0;
21815        nested_effect.clip = Some(Rect {
21816            x: 15.0,
21817            y: 25.0,
21818            width: 4.0,
21819            height: 5.0,
21820        });
21821
21822        let mut nested_backdrop = backdrop_layer(8);
21823        nested_backdrop.rect.x = 17.0;
21824        nested_backdrop.rect.y = 26.0;
21825        nested_backdrop.clip = Some(Rect {
21826            x: 18.0,
21827            y: 27.0,
21828            width: 3.0,
21829            height: 4.0,
21830        });
21831
21832        let window = build_scene_window(
21833            SceneWindowSource {
21834                shapes: &[test_shape(4, BlendMode::SrcOver), shape],
21835                brushes: &[],
21836                images: &[image],
21837                texts: &[text],
21838                shadow_draws: &[shadow],
21839                draw_ops: &[],
21840                effect_layers: &[effect_layer(2, 4), nested_effect.clone()],
21841                backdrop_layers: &[backdrop_layer(4), nested_backdrop.clone()],
21842            },
21843            5,
21844            10,
21845            Rect {
21846                x: 10.0,
21847                y: 20.0,
21848                width: 20.0,
21849                height: 20.0,
21850            },
21851        );
21852
21853        assert_eq!(window.shapes.len(), 1);
21854        assert_eq!(
21855            window.shapes[0].rect,
21856            Rect {
21857                x: 2.0,
21858                y: 5.0,
21859                width: 8.0,
21860                height: 8.0,
21861            }
21862        );
21863        assert_eq!(
21864            window.shapes[0].clip,
21865            Some(Rect {
21866                x: 1.0,
21867                y: 4.0,
21868                width: 10.0,
21869                height: 10.0,
21870            })
21871        );
21872        assert_eq!(window.images.len(), 1);
21873        assert_eq!(window.images[0].rect.x, 8.0);
21874        assert_eq!(window.images[0].rect.y, 7.0);
21875        assert_eq!(window.texts.len(), 1);
21876        assert_eq!(window.texts[0].rect.x, 6.0);
21877        assert_eq!(window.texts[0].rect.y, 9.0);
21878        assert_eq!(
21879            window.texts[0].clip,
21880            Some(Rect {
21881                x: 5.0,
21882                y: 8.0,
21883                width: 9.0,
21884                height: 6.0,
21885            })
21886        );
21887        assert_eq!(window.shadow_draws.len(), 1);
21888        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.x, 4.0);
21889        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.y, 6.0);
21890        assert_eq!(window.effect_layers.len(), 1);
21891        assert_eq!(
21892            window.effect_layers[0].rect,
21893            Rect {
21894                x: 3.0,
21895                y: 4.0,
21896                width: 10.0,
21897                height: 10.0,
21898            }
21899        );
21900        assert_eq!(
21901            window.effect_layers[0].clip,
21902            Some(Rect {
21903                x: 5.0,
21904                y: 5.0,
21905                width: 4.0,
21906                height: 5.0,
21907            })
21908        );
21909        assert_eq!(window.backdrop_layers.len(), 1);
21910        assert_eq!(
21911            window.backdrop_layers[0].rect,
21912            Rect {
21913                x: 7.0,
21914                y: 6.0,
21915                width: 10.0,
21916                height: 10.0,
21917            }
21918        );
21919        assert_eq!(
21920            window.backdrop_layers[0].clip,
21921            Some(Rect {
21922                x: 8.0,
21923                y: 7.0,
21924                width: 3.0,
21925                height: 4.0,
21926            })
21927        );
21928    }
21929
21930    #[test]
21931    fn filtered_effect_layer_index_counts_only_window_members() {
21932        let effects = vec![
21933            effect_layer(0, 2),
21934            effect_layer(5, 12),
21935            effect_layer(6, 10),
21936            effect_layer(14, 20),
21937        ];
21938
21939        assert_eq!(filtered_effect_layer_index(&effects, 1, 5, 12), Some(0));
21940        assert_eq!(filtered_effect_layer_index(&effects, 2, 5, 12), Some(1));
21941        assert_eq!(filtered_effect_layer_index(&effects, 3, 5, 12), None);
21942    }
21943
21944    #[test]
21945    fn blend_mode_support_matrix_is_explicit() {
21946        assert!(is_blend_mode_supported(BlendMode::SrcOver));
21947        assert!(is_blend_mode_supported(BlendMode::DstOut));
21948        assert!(!is_blend_mode_supported(BlendMode::Clear));
21949        assert!(!is_blend_mode_supported(BlendMode::Multiply));
21950    }
21951
21952    #[test]
21953    fn collect_non_effect_segment_items_preserves_global_z_order() {
21954        let shapes = vec![
21955            test_shape(3, BlendMode::SrcOver),
21956            test_shape(1, BlendMode::DstOut),
21957        ];
21958        let images = vec![test_image(2, BlendMode::SrcOver)];
21959        let texts = vec![test_text(0)];
21960        let shadows: Vec<ShadowDraw> = Vec::new();
21961        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
21962
21963        let mut scratch = Vec::new();
21964        collect_non_effect_segment_items(
21965            &shapes,
21966            &images,
21967            &texts,
21968            &shadows,
21969            &draw_ops,
21970            0,
21971            4,
21972            &[],
21973            100,
21974            100,
21975            1.0,
21976            &mut scratch,
21977        );
21978        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
21979        assert_eq!(
21980            items,
21981            vec![
21982                SegmentDrawItem::Text(0),
21983                SegmentDrawItem::Shape(1),
21984                SegmentDrawItem::Image(0),
21985                SegmentDrawItem::Shape(0),
21986            ]
21987        );
21988    }
21989
21990    #[test]
21991    fn collect_non_effect_segment_items_filters_effect_ranges() {
21992        let shapes = vec![
21993            test_shape(1, BlendMode::SrcOver),
21994            test_shape(3, BlendMode::DstOut),
21995        ];
21996        let images = vec![test_image(2, BlendMode::SrcOver)];
21997        let texts = vec![test_text(4)];
21998        let shadows: Vec<ShadowDraw> = Vec::new();
21999        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
22000        let effect_ranges = [std::ops::Range { start: 2, end: 4 }];
22001
22002        let mut scratch = Vec::new();
22003        collect_non_effect_segment_items(
22004            &shapes,
22005            &images,
22006            &texts,
22007            &shadows,
22008            &draw_ops,
22009            0,
22010            5,
22011            &effect_ranges,
22012            100,
22013            100,
22014            1.0,
22015            &mut scratch,
22016        );
22017        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
22018        assert_eq!(
22019            items,
22020            vec![SegmentDrawItem::Shape(0), SegmentDrawItem::Text(0)]
22021        );
22022    }
22023
22024    #[test]
22025    fn collect_non_effect_segment_items_culls_offscreen_shapes_but_keeps_text_prewarm() {
22026        let mut shape = test_shape(0, BlendMode::SrcOver);
22027        shape.rect.y = 160.0;
22028        shape.local_rect.y = 160.0;
22029        shape.quad = [[0.0, 160.0], [8.0, 160.0], [0.0, 168.0], [8.0, 168.0]];
22030
22031        let shapes = vec![shape];
22032        let images = Vec::new();
22033        let mut text = test_text(1);
22034        text.rect.y = 160.0;
22035        let texts = vec![text];
22036        let shadows: Vec<ShadowDraw> = Vec::new();
22037        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
22038
22039        let mut scratch = Vec::new();
22040        collect_non_effect_segment_items(
22041            &shapes,
22042            &images,
22043            &texts,
22044            &shadows,
22045            &draw_ops,
22046            0,
22047            2,
22048            &[],
22049            100,
22050            100,
22051            1.0,
22052            &mut scratch,
22053        );
22054
22055        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
22056        assert_eq!(items, vec![SegmentDrawItem::Text(0)]);
22057    }
22058
22059    #[test]
22060    fn segment_command_iter_merges_non_conflicting_batches_into_one_chunk() {
22061        let ordered_items = vec![
22062            (0, SegmentDrawItem::Shape(0)),
22063            (1, SegmentDrawItem::Image(0)),
22064            (2, SegmentDrawItem::Text(0)),
22065        ];
22066        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
22067        let images = vec![test_image(1, BlendMode::DstOut)];
22068
22069        let commands: Vec<_> = SegmentCommandIter::new(
22070            &ordered_items,
22071            &shapes,
22072            &images,
22073            ShapeBatchLimits::desktop(),
22074        )
22075        .collect();
22076
22077        assert_eq!(
22078            commands,
22079            vec![SegmentRenderCommand::DrawChunk(chunk(&[
22080                SegmentBatchPlan::Shape {
22081                    start: 0,
22082                    end: 1,
22083                    blend_mode: BlendMode::SrcOver,
22084                },
22085                SegmentBatchPlan::Image {
22086                    start: 1,
22087                    end: 2,
22088                    blend_mode: BlendMode::DstOut,
22089                },
22090                SegmentBatchPlan::Text { start: 2, end: 3 },
22091            ]))]
22092        );
22093    }
22094
22095    #[test]
22096    fn segment_command_iter_keeps_layer_composites_in_ordered_draw_chunk() {
22097        let ordered_items = vec![
22098            (0, SegmentDrawItem::Shape(0)),
22099            (1, SegmentDrawItem::Composite(0)),
22100            (2, SegmentDrawItem::Image(0)),
22101            (3, SegmentDrawItem::Composite(1)),
22102            (4, SegmentDrawItem::Text(0)),
22103        ];
22104        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
22105        let images = vec![test_image(2, BlendMode::SrcOver)];
22106
22107        let commands: Vec<_> = SegmentCommandIter::new(
22108            &ordered_items,
22109            &shapes,
22110            &images,
22111            ShapeBatchLimits::desktop(),
22112        )
22113        .collect();
22114
22115        assert_eq!(
22116            commands,
22117            vec![SegmentRenderCommand::DrawChunk(chunk(&[
22118                SegmentBatchPlan::Shape {
22119                    start: 0,
22120                    end: 1,
22121                    blend_mode: BlendMode::SrcOver,
22122                },
22123                SegmentBatchPlan::Composite { start: 1, end: 2 },
22124                SegmentBatchPlan::Image {
22125                    start: 2,
22126                    end: 3,
22127                    blend_mode: BlendMode::SrcOver,
22128                },
22129                SegmentBatchPlan::Composite { start: 3, end: 4 },
22130                SegmentBatchPlan::Text { start: 4, end: 5 },
22131            ]))]
22132        );
22133    }
22134
22135    #[test]
22136    fn retain_renderable_shadow_items_culls_invisible_shadow_boundaries() {
22137        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
22138        let images = vec![test_image(2, BlendMode::SrcOver)];
22139        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
22140        shadow_shape.rect = Rect {
22141            x: 500.0,
22142            y: 500.0,
22143            width: 12.0,
22144            height: 12.0,
22145        };
22146        let shadow_draws = vec![ShadowDraw {
22147            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
22148            brushes: vec![],
22149            texts: Vec::new(),
22150            blur_radius: 8.0,
22151            clip: None,
22152            z_index: 1,
22153        }];
22154        let mut ordered_items = vec![
22155            (0, SegmentDrawItem::Shape(0)),
22156            (1, SegmentDrawItem::Shadow(0)),
22157            (2, SegmentDrawItem::Image(0)),
22158        ];
22159
22160        let culled =
22161            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
22162        let commands: Vec<_> = SegmentCommandIter::new(
22163            &ordered_items,
22164            &shapes,
22165            &images,
22166            ShapeBatchLimits::desktop(),
22167        )
22168        .collect();
22169
22170        assert_eq!(culled, 1);
22171        assert_eq!(
22172            commands,
22173            vec![SegmentRenderCommand::DrawChunk(chunk(&[
22174                SegmentBatchPlan::Shape {
22175                    start: 0,
22176                    end: 1,
22177                    blend_mode: BlendMode::SrcOver,
22178                },
22179                SegmentBatchPlan::Image {
22180                    start: 1,
22181                    end: 2,
22182                    blend_mode: BlendMode::SrcOver,
22183                },
22184            ]))]
22185        );
22186    }
22187
22188    #[test]
22189    fn retain_renderable_shadow_items_keeps_visible_shadow_boundaries() {
22190        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
22191        shadow_shape.rect = Rect {
22192            x: 20.0,
22193            y: 20.0,
22194            width: 12.0,
22195            height: 12.0,
22196        };
22197        let shadow_draws = vec![ShadowDraw {
22198            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
22199            brushes: vec![],
22200            texts: Vec::new(),
22201            blur_radius: 8.0,
22202            clip: None,
22203            z_index: 1,
22204        }];
22205        let mut ordered_items = vec![(1, SegmentDrawItem::Shadow(0))];
22206
22207        let culled =
22208            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
22209
22210        assert_eq!(culled, 0);
22211        assert_eq!(ordered_items, vec![(1, SegmentDrawItem::Shadow(0))]);
22212    }
22213
22214    #[test]
22215    fn shape_data_layout_matches_the_wgsl_mirror() {
22216        // 10 x vec4-sized slots. The uniform address space requires a 16-byte
22217        // multiple, and `shape.wgsl`'s array length literal is derived from
22218        // this size — if it drifts, batches silently overrun the binding.
22219        assert_eq!(std::mem::size_of::<ShapeData>(), 160);
22220        assert_eq!(std::mem::size_of::<ShapeData>() % 16, 0);
22221        assert_eq!(std::mem::size_of::<GradientStop>(), 32);
22222    }
22223
22224    #[test]
22225    fn shape_flags_pack_kind_cap_and_join_without_collision() {
22226        assert_eq!(
22227            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
22228            0.0
22229        );
22230        assert_eq!(
22231            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
22232            1.0
22233        );
22234        assert_eq!(
22235            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter),
22236            2.0
22237        );
22238        // cap in bits 2-3, join in bits 4-5
22239        assert_eq!(
22240            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Round, StrokeJoin::Miter),
22241            2.0 + 4.0
22242        );
22243        assert_eq!(
22244            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Square, StrokeJoin::Miter),
22245            2.0 + 8.0
22246        );
22247        assert_eq!(
22248            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Round),
22249            1.0 + 16.0
22250        );
22251        assert_eq!(
22252            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Bevel),
22253            1.0 + 32.0
22254        );
22255        // Every combination must round-trip through f32 exactly.
22256        for kind in [SHAPE_KIND_FILL, SHAPE_KIND_STROKE, SHAPE_KIND_ARC] {
22257            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
22258                for join in [StrokeJoin::Miter, StrokeJoin::Round, StrokeJoin::Bevel] {
22259                    let packed = pack_shape_flags(kind, cap, join);
22260                    let bits = packed as u32;
22261                    assert_eq!(bits & 3, kind);
22262                    assert_eq!((bits >> 2) & 3, stroke_cap_code(cap));
22263                    assert_eq!((bits >> 4) & 3, stroke_join_code(join));
22264                    assert_eq!(packed, bits as f32, "flags must be exact in f32");
22265                }
22266            }
22267        }
22268    }
22269
22270    #[cfg(not(target_arch = "wasm32"))]
22271    #[test]
22272    fn mesh_vertex_layout_matches_the_wgsl_input() {
22273        // {pos: vec2<f32>, uv: vec2<f32>, shape_idx: u32} = 20 bytes, no
22274        // padding — the vertex buffer layout stride relies on it.
22275        assert_eq!(std::mem::size_of::<MeshVertex>(), 20);
22276    }
22277
22278    /// f32 port of `sdf_arc_band` (shape.wgsl), operation for operation: the
22279    /// same ra/rb derivation and clamp, the same mirror trick (`abs` on the
22280    /// rotated x), the same cap branches.
22281    #[cfg(not(target_arch = "wasm32"))]
22282    #[allow(clippy::too_many_arguments)]
22283    fn sdf_arc_band_reference(
22284        p: [f32; 2],
22285        center: [f32; 2],
22286        inner: f32,
22287        outer: f32,
22288        mid_sin_cos: [f32; 2],
22289        half_sin_cos: [f32; 2],
22290        cap: u32,
22291    ) -> f32 {
22292        let ra = (outer + inner) * 0.5;
22293        let rb = ((outer - inner) * 0.5).max(0.0);
22294        let sm = mid_sin_cos[0];
22295        let cm = mid_sin_cos[1];
22296        let d = [p[0] - center[0], p[1] - center[1]];
22297        let mut q = [-sm * d[0] + cm * d[1], cm * d[0] + sm * d[1]];
22298        q[0] = q[0].abs();
22299        let sc = half_sin_cos;
22300        let mut dist = if sc[1] * q[0] > sc[0] * q[1] {
22301            let dx = q[0] - sc[0] * ra;
22302            let dy = q[1] - sc[1] * ra;
22303            (dx * dx + dy * dy).sqrt() - rb
22304        } else {
22305            ((q[0] * q[0] + q[1] * q[1]).sqrt() - ra).abs() - rb
22306        };
22307        let plane = sc[1] * q[0] - sc[0] * q[1];
22308        // STROKE_CAP_BUTT = 0, STROKE_CAP_SQUARE = 2, as in the shader.
22309        if cap == 0 {
22310            dist = dist.max(plane);
22311        } else if cap == 2 {
22312            dist = dist.max(plane - rb);
22313        }
22314        dist
22315    }
22316
22317    #[cfg(not(target_arch = "wasm32"))]
22318    fn point_in_triangle(p: [f64; 2], tri: &[[f64; 2]; 3]) -> bool {
22319        let side = |a: [f64; 2], b: [f64; 2]| {
22320            (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
22321        };
22322        let d0 = side(tri[0], tri[1]);
22323        let d1 = side(tri[1], tri[2]);
22324        let d2 = side(tri[2], tri[0]);
22325        let has_neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
22326        let has_pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
22327        !(has_neg && has_pos)
22328    }
22329
22330    #[cfg(not(target_arch = "wasm32"))]
22331    fn converted_arc_shape(arc: cranpose_ui_graphics::ArcGeometry, root_scale: f32) -> ShapeData {
22332        let bounds = arc.bounds();
22333        let mut shape = test_shape(0, BlendMode::SrcOver);
22334        shape.rect = bounds;
22335        shape.local_rect = bounds;
22336        shape.quad = [
22337            [bounds.x, bounds.y],
22338            [bounds.x + bounds.width, bounds.y],
22339            [bounds.x, bounds.y + bounds.height],
22340            [bounds.x + bounds.width, bounds.y + bounds.height],
22341        ];
22342        shape.arc = Some(arc);
22343        let mut converted = ShapeData::zeroed();
22344        convert_shape_into_slots(&shape, &[], root_scale, 0, &mut converted, &mut []);
22345        converted
22346    }
22347
22348    /// The containment invariant, checked directly: every point of the
22349    /// capture box whose (exactly ported) SDF keeps it must lie inside the
22350    /// emitted triangle set. Thin/thick, tiny/huge, full rings, near-zero
22351    /// and near-TAU sweeps, all caps, `Ri == 0` discs and pie wedges.
22352    #[cfg(not(target_arch = "wasm32"))]
22353    #[test]
22354    fn arc_mesh_contains_every_band_pixel() {
22355        use cranpose_ui_graphics::ArcGeometry;
22356        let tau = cranpose_ui_graphics::TAU;
22357        let center = Point::new(250.0, 250.0);
22358        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
22359            // full ring, thin band
22360            (90.0, 100.0, 0.0, tau, StrokeCap::Round),
22361            // sweep > TAU normalizes to a closed ring
22362            (80.0, 100.0, 1.0, 10.0, StrokeCap::Butt),
22363            // full disc: Ri == 0
22364            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
22365            // thick partial arc, every cap
22366            (30.0, 80.0, 0.7, 2.5, StrokeCap::Butt),
22367            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
22368            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
22369            // thin, axis-crossing sweep
22370            (99.0, 101.0, 3.0, 4.0, StrokeCap::Round),
22371            // tiny
22372            (0.6, 2.0, 0.3, 1.2, StrokeCap::Butt),
22373            // huge radius, thin band
22374            (1900.0, 1904.0, 0.1, 0.35, StrokeCap::Square),
22375            // near-zero sweep
22376            (40.0, 60.0, 5.0, 1e-3, StrokeCap::Round),
22377            // sweep near TAU: the cap pads wrap the range closed
22378            (40.0, 60.0, 0.2, tau - 1e-3, StrokeCap::Butt),
22379            // rb_m >= ra: the cap disc wraps the center (pie wedge)
22380            (0.0, 3.0, 1.0, 2.0, StrokeCap::Round),
22381            // filled annular sector (butt radial ends)
22382            (20.0, 60.0, 4.5, 1.9, StrokeCap::Butt),
22383        ];
22384        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
22385            // 2.75 is deliberately non-dyadic: quad corners and rect then
22386            // disagree by an ulp, which the axis-aligned gate must tolerate
22387            // (an equality-with-rect gate silently failed every arc on the
22388            // Huawei at scale 2.75).
22389            for root_scale in [1.0f32, 2.0, 2.75] {
22390                let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
22391                assert!(!arc.is_degenerate(), "case {case} must be drawable");
22392                let converted = converted_arc_shape(arc, root_scale);
22393                let band = arc_mesh_band(&converted)
22394                    .unwrap_or_else(|| panic!("case {case} must qualify for meshing"));
22395                let mut vertices = Vec::new();
22396                let mut indices = Vec::new();
22397                let segments =
22398                    emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22399                        .unwrap_or_else(|| panic!("case {case} must produce a mesh"));
22400                assert!(segments >= ARC_MESH_MIN_SEGMENTS);
22401                // The rasterized set is the indexed walk: triangles are index
22402                // triples into the shared vertex list.
22403                let position = |index: u32| {
22404                    let p = vertices[index as usize].position;
22405                    [p[0] as f64, p[1] as f64]
22406                };
22407                let triangles: Vec<[[f64; 2]; 3]> = indices
22408                    .as_chunks::<3>()
22409                    .0
22410                    .iter()
22411                    .map(|tri| [position(tri[0]), position(tri[1]), position(tri[2])])
22412                    .collect();
22413
22414                // Sample the QUAD box, not `rect`: quad expansion rasterizes the
22415                // quad, the mesh clips to the quad, and at non-dyadic root
22416                // scales the two boxes differ by an ulp.
22417                let [qx, qy, ..] = converted.quad01;
22418                let [_, _, qr, qb] = converted.quad23;
22419                let (rw, rh) = (qr - qx, qb - qy);
22420                let cap_bits = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
22421                let step = (rw.max(rh) / 400.0).clamp(0.25, 2.0);
22422                let mut band_points = 0usize;
22423                let mut y = qy;
22424                while y <= qb {
22425                    let mut x = qx;
22426                    while x <= qr {
22427                        let dist = sdf_arc_band_reference(
22428                            [x, y],
22429                            [converted.arc_params[0], converted.arc_params[1]],
22430                            converted.stroke_params[3],
22431                            converted.stroke_params[2],
22432                            [converted.radii[0], converted.radii[1]],
22433                            [converted.radii[2], converted.radii[3]],
22434                            cap_bits,
22435                        );
22436                        if dist <= 0.5 {
22437                            band_points += 1;
22438                            let p = [x as f64, y as f64];
22439                            assert!(
22440                                triangles.iter().any(|tri| point_in_triangle(p, tri)),
22441                                "case {case} scale {root_scale}: band point ({x}, {y}) \
22442                                 dist {dist} escapes the mesh"
22443                            );
22444                        }
22445                        x += step;
22446                    }
22447                    y += step;
22448                }
22449                assert!(
22450                    band_points > 0,
22451                    "case {case} scale {root_scale}: the sampling grid never hit the band"
22452                );
22453            }
22454        }
22455    }
22456
22457    /// An unmeshed shape contributes NOTHING to the mesh buffers — no
22458    /// vertices, no indices, only an empty `index_prefix` range — because
22459    /// the draw walk keeps it on the instanced-quad path. Routing
22460    /// passthrough quads through the mesh vertex stream is exactly what the
22461    /// watch A/B measured as the S3 loss.
22462    #[cfg(not(target_arch = "wasm32"))]
22463    #[test]
22464    fn unmeshed_shapes_leave_no_geometry_and_empty_index_ranges() {
22465        let shape = test_shape(0, BlendMode::SrcOver);
22466        let mut converted = ShapeData::zeroed();
22467        convert_shape_into_slots(&shape, &[], 1.0, 0, &mut converted, &mut []);
22468        let build = build_arc_mesh_vertices(
22469            std::slice::from_ref(&converted),
22470            RETAINED_MESH_MIN_PX2_DEFAULT as f64,
22471        )
22472        .expect("within budget");
22473        assert_eq!(build.meshed_arcs, 0);
22474        assert_eq!(build.meshed_rims, 0);
22475        assert_eq!(build.passthrough, 1);
22476        assert_eq!(build.meshed_stretches, 0);
22477        assert!(build.vertices.is_empty());
22478        assert!(build.indices.is_empty());
22479        assert_eq!(build.index_prefix, vec![0, 0]);
22480        // The instanced arm submits the bounding quad; the telemetry must
22481        // price it as such.
22482        assert_eq!(build.mesh_area, build.quad_area);
22483    }
22484
22485    /// The indexed-topology contract for arcs whose trapezoids survive
22486    /// clipping whole: every band boundary contributes exactly one (inner,
22487    /// outer) vertex pair, both adjacent trapezoids reference it through the
22488    /// index list, and a closed ring's last segment wraps around to boundary
22489    /// zero's pair — one seam vertex pair instead of bitwise-equal copies.
22490    #[cfg(not(target_arch = "wasm32"))]
22491    #[test]
22492    fn arc_mesh_indices_share_boundary_vertices_and_wrap_closed_rings() {
22493        use cranpose_ui_graphics::ArcGeometry;
22494        let tau = cranpose_ui_graphics::TAU;
22495        // (sweep, expected boundary count relation): a closed ring wraps
22496        // (boundaries == segments), an open arc does not (segments + 1).
22497        for (sweep, closed) in [(tau, true), (1.9f32, false)] {
22498            let arc = ArcGeometry::new(
22499                Point::new(250.0, 250.0),
22500                80.0,
22501                100.0,
22502                0.7,
22503                sweep,
22504                StrokeCap::Round,
22505            );
22506            let mut converted = converted_arc_shape(arc, 1.0);
22507            // Inflate the quad box (and rect, for uv) far beyond the dilated
22508            // band so NO trapezoid is clipped: every segment must take the
22509            // shared-boundary path.
22510            converted.rect = [0.0, 0.0, 500.0, 500.0];
22511            converted.quad01 = [0.0, 0.0, 500.0, 0.0];
22512            converted.quad23 = [0.0, 500.0, 500.0, 500.0];
22513            let band = arc_mesh_band(&converted).expect("arc must qualify");
22514            let mut vertices = Vec::new();
22515            let mut indices = Vec::new();
22516            let segments = emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22517                .expect("arc must mesh");
22518            let boundary_count = if closed { segments } else { segments + 1 };
22519            assert_eq!(
22520                vertices.len(),
22521                2 * boundary_count,
22522                "closed={closed}: every boundary owns exactly one (inner, outer) pair"
22523            );
22524            assert_eq!(indices.len(), 6 * segments);
22525            // Emission order is boundary order: boundary j's pair is
22526            // (2j, 2j + 1). Each segment must reference its own boundary and
22527            // its successor's — modulo the count exactly when closed.
22528            for j in 0..segments {
22529                let jb = (j + 1) % boundary_count;
22530                let (in_a, out_a) = (2 * j as u32, 2 * j as u32 + 1);
22531                let (in_b, out_b) = (2 * jb as u32, 2 * jb as u32 + 1);
22532                assert_eq!(
22533                    indices[6 * j..6 * j + 6],
22534                    [in_a, out_a, out_b, in_a, out_b, in_b],
22535                    "closed={closed}: segment {j} must share its boundary pairs"
22536                );
22537            }
22538            if closed {
22539                // The wrap made concrete: the final segment indexes boundary
22540                // zero's vertices.
22541                assert_eq!(indices[6 * segments - 1], 0);
22542            }
22543            // Inner vertices ride the dilated inner radius, outer vertices
22544            // the pushed-out chord radius — sanity that pairs are ordered
22545            // (inner, outer).
22546            for pair in vertices.as_chunks::<2>().0 {
22547                let radius = |v: &MeshVertex| {
22548                    let dx = v.position[0] - 250.0;
22549                    let dy = v.position[1] - 250.0;
22550                    (dx * dx + dy * dy).sqrt()
22551                };
22552                assert!(radius(&pair[0]) < radius(&pair[1]));
22553            }
22554        }
22555    }
22556
22557    /// The private-vertex arm of the indexed topology: under the real
22558    /// tight-AABB quad the pushed-out chord vertices near the box edges get
22559    /// clipped, and those trapezoids must fan over vertices of their own —
22560    /// appended after the shared block, carrying clip-plane coordinates —
22561    /// while untouched diagonal trapezoids still share boundary pairs.
22562    #[cfg(not(target_arch = "wasm32"))]
22563    #[test]
22564    fn arc_mesh_clipped_segments_fan_over_private_vertices() {
22565        use cranpose_ui_graphics::ArcGeometry;
22566        let arc = ArcGeometry::new(
22567            Point::new(250.0, 250.0),
22568            80.0,
22569            100.0,
22570            0.0,
22571            cranpose_ui_graphics::TAU,
22572            StrokeCap::Round,
22573        );
22574        let converted = converted_arc_shape(arc, 1.0);
22575        let band = arc_mesh_band(&converted).expect("ring must qualify");
22576        let mut vertices = Vec::new();
22577        let mut indices = Vec::new();
22578        emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22579            .expect("ring must mesh");
22580        // Sharing must actually happen: a shared boundary vertex is used by
22581        // both of its trapezoids' fans (at least three triangle references).
22582        let mut uses = vec![0usize; vertices.len()];
22583        for &index in &indices {
22584            uses[index as usize] += 1;
22585        }
22586        assert!(
22587            uses.iter().any(|&count| count >= 3),
22588            "some boundary vertices must be shared across trapezoids"
22589        );
22590        // Clipping must actually happen, and clipped polygons index private
22591        // vertices lying bitwise ON the quad box (the clipper writes the
22592        // bound coordinate exactly; boundary vertices never touch the box —
22593        // inner ones sit strictly inside, pushed-out outer ones strictly
22594        // outside near the extremes, where they are clipped).
22595        let [left, top, ..] = converted.quad01;
22596        let [.., right, bottom] = converted.quad23;
22597        let clipped: Vec<&MeshVertex> = vertices
22598            .iter()
22599            .filter(|vertex| {
22600                let [x, y] = vertex.position;
22601                x == left || x == right || y == top || y == bottom
22602            })
22603            .collect();
22604        assert!(
22605            !clipped.is_empty(),
22606            "the tight box must clip the pushed-out chord vertices"
22607        );
22608        // Fewer unique vertices than the non-indexed emitter's
22609        // three-per-triangle — the amplification this change removes.
22610        assert!(
22611            vertices.len() < indices.len(),
22612            "{} unique vertices should undercut {} triangle corners",
22613            vertices.len(),
22614            indices.len()
22615        );
22616    }
22617
22618    #[cfg(not(target_arch = "wasm32"))]
22619    #[test]
22620    fn arc_mesh_budget_overflow_falls_back_to_whole_slot_passthrough() {
22621        use cranpose_ui_graphics::ArcGeometry;
22622        // 100 large full rings mesh at the 64-segment ceiling (well over
22623        // 4 KB of vertices + indices each), far past the byte budget
22624        // max(100 * ~960 B, ~80 KB) — the builder must refuse the whole
22625        // slot rather than truncate.
22626        let arc = ArcGeometry::new(
22627            Point::new(2000.0, 2000.0),
22628            1690.0,
22629            1710.0,
22630            0.0,
22631            cranpose_ui_graphics::TAU,
22632            StrokeCap::Round,
22633        );
22634        let converted = converted_arc_shape(arc, 1.0);
22635        let shapes = vec![converted; 100];
22636        assert!(build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64).is_none());
22637    }
22638
22639    /// The size gate, boundary-exact: a shape meshes when its quad area is
22640    /// AT LEAST the threshold and passes through below it — with the
22641    /// engagement counters saying which happened — and the arc and rim
22642    /// acceptances both sit behind the same gate.
22643    #[cfg(not(target_arch = "wasm32"))]
22644    #[test]
22645    fn retained_mesh_size_gate_engages_exactly_per_threshold() {
22646        use cranpose_ui_graphics::ArcGeometry;
22647        // A big ring (quad ~322 px square ≈ 104k px²), a small brick arc
22648        // (quad well under 1024 px²), and a big stroked-circle rim
22649        // (90k-px² quad) in one capture.
22650        let big_ring = converted_arc_shape(
22651            ArcGeometry::new(
22652                Point::new(204.0, 204.0),
22653                140.0,
22654                160.0,
22655                0.0,
22656                cranpose_ui_graphics::TAU,
22657                StrokeCap::Butt,
22658            ),
22659            1.0,
22660        );
22661        let small_arc = converted_arc_shape(
22662            ArcGeometry::new(
22663                Point::new(204.0, 204.0),
22664                12.0,
22665                18.0,
22666                0.3,
22667                0.5,
22668                StrokeCap::Butt,
22669            ),
22670            1.0,
22671        );
22672        let rim = rim_test_shape_data();
22673        let shapes = [big_ring, small_arc, rim];
22674        let big_px2 = quad_shoelace_area(&shapes[0]);
22675        let small_px2 = quad_shoelace_area(&shapes[1]);
22676        let rim_px2 = quad_shoelace_area(&shapes[2]);
22677        assert!(small_px2 < 1024.0 && big_px2 > rim_px2 && rim_px2 > 16384.0);
22678
22679        // Default gate: both big shapes mesh, the brick arc stays instanced.
22680        // The meshed shapes sit at indices 0 and 2 with the brick between
22681        // them — two stretches.
22682        let build = build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64)
22683            .expect("within budget");
22684        assert_eq!(
22685            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22686            (1, 1, 1)
22687        );
22688        assert_eq!(build.meshed_stretches, 2);
22689        // The brick's index range is empty (no geometry emitted for it);
22690        // the ring's and rim's are not.
22691        assert_eq!(build.index_prefix[1], build.index_prefix[2]);
22692        assert!(build.index_prefix[1] > build.index_prefix[0]);
22693        assert!(build.index_prefix[3] > build.index_prefix[2]);
22694
22695        // ≥, not >: a threshold bitwise AT a shape's quad area still meshes
22696        // it...
22697        let build = build_arc_mesh_vertices(&shapes, big_px2).expect("within budget");
22698        assert_eq!(
22699            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22700            (1, 0, 2)
22701        );
22702        // ...and one ulp above it does not.
22703        let build =
22704            build_arc_mesh_vertices(&shapes, big_px2 + big_px2 * f64::EPSILON).expect("budget");
22705        assert_eq!(
22706            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22707            (0, 0, 3)
22708        );
22709
22710        // A threshold between the rim and the ring gates them apart.
22711        let build = build_arc_mesh_vertices(&shapes, (rim_px2 + big_px2) * 0.5).expect("budget");
22712        assert_eq!(
22713            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22714            (1, 0, 2)
22715        );
22716
22717        // Gate-rejected shapes leave the mesh buffers EMPTY — they stay on
22718        // the instanced path, so a capture like this keeps no mesh at all.
22719        let everything_gated =
22720            build_arc_mesh_vertices(&shapes, big_px2 * 2.0).expect("within budget");
22721        assert_eq!(everything_gated.passthrough, 3);
22722        assert_eq!(everything_gated.meshed_stretches, 0);
22723        assert!(everything_gated.vertices.is_empty());
22724        assert_eq!(everything_gated.index_prefix, vec![0, 0, 0, 0]);
22725    }
22726
22727    /// The stretch counter counts MAXIMAL RUNS of consecutive meshed
22728    /// shapes — the quantity the capture site caps at
22729    /// [`MESH_SLOT_MAX_STRETCHES`], because each stretch costs the draw
22730    /// walk two pipeline switches per covering op.
22731    #[cfg(not(target_arch = "wasm32"))]
22732    #[test]
22733    fn meshed_stretches_count_maximal_runs_of_consecutive_meshed_shapes() {
22734        use cranpose_ui_graphics::ArcGeometry;
22735        let big = converted_arc_shape(
22736            ArcGeometry::new(
22737                Point::new(204.0, 204.0),
22738                140.0,
22739                160.0,
22740                0.0,
22741                cranpose_ui_graphics::TAU,
22742                StrokeCap::Butt,
22743            ),
22744            1.0,
22745        );
22746        let small = converted_arc_shape(
22747            ArcGeometry::new(
22748                Point::new(204.0, 204.0),
22749                12.0,
22750                18.0,
22751                0.3,
22752                0.5,
22753                StrokeCap::Butt,
22754            ),
22755            1.0,
22756        );
22757        // big big small big small small big big -> runs [0..2], [3], [6..8].
22758        let shapes = [big, big, small, big, small, small, big, big];
22759        let build = build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64)
22760            .expect("within budget");
22761        assert_eq!(build.meshed_arcs, 5);
22762        assert_eq!(build.passthrough, 3);
22763        assert_eq!(build.meshed_stretches, 3);
22764        // An all-instanced interleave never exceeds the cap vacuously: the
22765        // cap compares against this exact counter.
22766        assert!(build.meshed_stretches <= MESH_SLOT_MAX_STRETCHES);
22767    }
22768
22769    /// The env override's parse-and-clamp: unset and garbage read the
22770    /// default, in-range values pass through, and both clamp ends hold.
22771    #[cfg(not(target_arch = "wasm32"))]
22772    #[test]
22773    fn retained_mesh_px2_override_parses_and_clamps() {
22774        assert_eq!(
22775            parse_retained_mesh_min_px2(None),
22776            RETAINED_MESH_MIN_PX2_DEFAULT as f64
22777        );
22778        assert_eq!(
22779            parse_retained_mesh_min_px2(Some("not a number")),
22780            RETAINED_MESH_MIN_PX2_DEFAULT as f64
22781        );
22782        assert_eq!(
22783            parse_retained_mesh_min_px2(Some("-5")),
22784            RETAINED_MESH_MIN_PX2_DEFAULT as f64
22785        );
22786        assert_eq!(parse_retained_mesh_min_px2(Some(" 40000 ")), 40000.0);
22787        assert_eq!(
22788            parse_retained_mesh_min_px2(Some("0")),
22789            *RETAINED_MESH_MIN_PX2_RANGE.start() as f64
22790        );
22791        assert_eq!(
22792            parse_retained_mesh_min_px2(Some("99999999")),
22793            *RETAINED_MESH_MIN_PX2_RANGE.end() as f64
22794        );
22795    }
22796
22797    /// The retained builder accepts stroked-circle rims through
22798    /// [`rim_band_geometry`]: the emitted mesh is the closed annulus band
22799    /// (every vertex inside the dilated ring, none inside the hole), counted
22800    /// as a rim, while the same shape under the gate stays a quad.
22801    #[cfg(not(target_arch = "wasm32"))]
22802    #[test]
22803    fn retained_capture_meshes_big_stroked_circle_rims_as_annuli() {
22804        let rim = rim_test_shape_data();
22805        let build = build_arc_mesh_vertices(
22806            std::slice::from_ref(&rim),
22807            RETAINED_MESH_MIN_PX2_DEFAULT as f64,
22808        )
22809        .expect("within budget");
22810        assert_eq!(
22811            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22812            (0, 1, 0)
22813        );
22814        assert!(build.meshed_segments >= ARC_MESH_MIN_SEGMENTS);
22815        // The annulus, not the quad: the mesh area is far below the 90k-px²
22816        // bounding quad and every vertex sits in the dilated band's radial
22817        // range (clip-plane vertices included — the quad box touches the
22818        // outer circle only near the axes, inside the band).
22819        assert!(build.mesh_area < 0.2 * build.quad_area);
22820        let band = rim_band_geometry(&rim).expect("rim must qualify");
22821        for vertex in &build.vertices {
22822            let dx = vertex.position[0] - band.center[0];
22823            let dy = vertex.position[1] - band.center[1];
22824            let radius = (dx * dx + dy * dy).sqrt();
22825            assert!(
22826                radius >= band.inner - ARC_MESH_MARGIN - 1e-3,
22827                "vertex at radius {radius} fell inside the annulus hole"
22828            );
22829        }
22830    }
22831
22832    /// A converted circle rim, hand-built in `ShapeData` terms: `rect` is the
22833    /// stroke-inflated 300×300 box, the geometry is 292×292, and the corner
22834    /// radius (300 − 8) / 2 = 146 equals the geometry half-extent — a circle.
22835    #[cfg(not(target_arch = "wasm32"))]
22836    fn rim_test_shape_data() -> ShapeData {
22837        let mut shape = ShapeData::zeroed();
22838        shape.rect = [40.0, 40.0, 300.0, 300.0];
22839        shape.radii = [146.0; 4];
22840        shape.stroke_params = [
22841            8.0,
22842            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
22843            0.0,
22844            0.0,
22845        ];
22846        shape.quad01 = [40.0, 40.0, 340.0, 40.0];
22847        shape.quad23 = [40.0, 340.0, 340.0, 340.0];
22848        shape.color = [1.0, 1.0, 1.0, 1.0];
22849        shape
22850    }
22851
22852    /// A viewport that never matches the diag's latched surface, so bucket
22853    /// tests exercise no corner accounting.
22854    #[cfg(not(target_arch = "wasm32"))]
22855    fn offscreen_test_viewport() -> ViewportUniformParams {
22856        ViewportUniformParams {
22857            width: 64,
22858            height: 64,
22859            offset: [7.0, 7.0],
22860        }
22861    }
22862
22863    #[cfg(not(target_arch = "wasm32"))]
22864    #[test]
22865    fn fill_diag_buckets_shape_quads_by_decoded_sdf_class() {
22866        let diag = FillAreaDiag::default();
22867        let mut arc = ShapeData::zeroed();
22868        arc.stroke_params[1] = pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter);
22869        // Arcs keep trig in `radii`; nonzero values there must not classify
22870        // the shape as a rounded fill.
22871        arc.radii = [0.5; 4];
22872        arc.quad01 = [0.0, 0.0, 10.0, 0.0];
22873        arc.quad23 = [0.0, 10.0, 10.0, 10.0];
22874        let mut rounded = ShapeData::zeroed();
22875        rounded.stroke_params[1] =
22876            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22877        rounded.radii = [2.0; 4];
22878        rounded.quad01 = [0.0, 0.0, 4.0, 0.0];
22879        rounded.quad23 = [0.0, 5.0, 4.0, 5.0];
22880        let mut plain = ShapeData::zeroed();
22881        plain.stroke_params[1] =
22882            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22883        plain.quad01 = [0.0, 0.0, 2.0, 0.0];
22884        plain.quad23 = [0.0, 3.0, 2.0, 3.0];
22885        diag.add_shape_quads(
22886            &[rim_test_shape_data(), arc, rounded, plain],
22887            offscreen_test_viewport(),
22888        );
22889        assert_eq!(diag.frame[FillAreaDiag::RRECT_STROKE].get(), 300.0 * 300.0);
22890        assert_eq!(diag.frame[FillAreaDiag::ARC].get(), 100.0);
22891        assert_eq!(diag.frame[FillAreaDiag::RRECT_FILL].get(), 20.0);
22892        assert_eq!(diag.frame[FillAreaDiag::RECT].get(), 6.0);
22893        // Off-frame passes never touch the corner counter.
22894        assert_eq!(diag.frame_corner.get(), 0.0);
22895        // Lit never exceeds the submitted area, bucket by bucket.
22896        for (lit, quad) in diag.frame_lit.iter().zip(&diag.frame) {
22897            assert!(lit.get() <= quad.get() + 1e-9);
22898        }
22899    }
22900
22901    #[cfg(not(target_arch = "wasm32"))]
22902    #[test]
22903    fn fill_diag_rim_mesh_moves_quad_area_to_the_mesh_bucket() {
22904        let diag = FillAreaDiag::default();
22905        diag.add_shape_quads(&[rim_test_shape_data()], offscreen_test_viewport());
22906        diag.note_rim_mesh(&rim_test_shape_data(), 1234.5);
22907        assert_eq!(diag.frame[FillAreaDiag::RRECT_STROKE].get(), 0.0);
22908        assert_eq!(diag.frame[FillAreaDiag::MESH].get(), 1234.5);
22909        // The lit accounting moves with the quad: nothing left in the
22910        // stroke bucket, and the mesh bucket's lit stays within the mesh.
22911        assert_eq!(diag.frame_lit[FillAreaDiag::RRECT_STROKE].get(), 0.0);
22912        assert!(diag.frame_lit[FillAreaDiag::MESH].get() <= 1234.5);
22913        assert!(diag.frame_lit[FillAreaDiag::MESH].get() > 0.0);
22914    }
22915
22916    #[cfg(not(target_arch = "wasm32"))]
22917    #[test]
22918    fn fill_diag_image_and_glyph_quads_share_one_bucket() {
22919        let diag = FillAreaDiag::default();
22920        diag.add_image_quad(&[[0.0, 0.0], [8.0, 0.0], [0.0, 4.0], [8.0, 4.0]]);
22921        let quad = CachedTextGlyphQuad {
22922            x: 0,
22923            y: 0,
22924            width: 5,
22925            height: 7,
22926            color: (1.0, 1.0, 1.0, 1.0),
22927            uv: ImageUvRect {
22928                min: [0.0, 0.0],
22929                max: [1.0, 1.0],
22930                sample_bounds: [0.0, 0.0, 1.0, 1.0],
22931            },
22932        };
22933        diag.add_glyph_quad(&quad);
22934        assert_eq!(diag.frame[FillAreaDiag::IMAGE_GLYPH].get(), 32.0 + 35.0);
22935        // Textures light their whole quad: lit tracks the submitted area.
22936        assert_eq!(diag.frame_lit[FillAreaDiag::IMAGE_GLYPH].get(), 32.0 + 35.0);
22937    }
22938
22939    /// Midpoint-rule area of `inside` over `bounds` (min x, min y, max x,
22940    /// max y), the reference the analytic-lit formulas are tested against.
22941    #[cfg(not(target_arch = "wasm32"))]
22942    fn numeric_area(bounds: [f64; 4], steps: usize, inside: impl Fn(f64, f64) -> bool) -> f64 {
22943        let dx = (bounds[2] - bounds[0]) / steps as f64;
22944        let dy = (bounds[3] - bounds[1]) / steps as f64;
22945        let mut area = 0.0;
22946        for column in 0..steps {
22947            let x = bounds[0] + (column as f64 + 0.5) * dx;
22948            for row in 0..steps {
22949                let y = bounds[1] + (row as f64 + 0.5) * dy;
22950                if inside(x, y) {
22951                    area += dx * dy;
22952                }
22953            }
22954        }
22955        area
22956    }
22957
22958    /// f64 rounded-rect SDF (uniform radius), the reference for the
22959    /// round-rect fill and stroke lit formulas.
22960    #[cfg(not(target_arch = "wasm32"))]
22961    fn sdf_rounded_rect_reference(
22962        p: [f64; 2],
22963        center: [f64; 2],
22964        half: [f64; 2],
22965        radius: f64,
22966    ) -> f64 {
22967        let qx = (p[0] - center[0]).abs() - (half[0] - radius);
22968        let qy = (p[1] - center[1]).abs() - (half[1] - radius);
22969        qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius
22970    }
22971
22972    #[cfg(not(target_arch = "wasm32"))]
22973    #[test]
22974    fn fill_truth_arc_lit_matches_the_sdf_covered_area() {
22975        use cranpose_ui_graphics::ArcGeometry;
22976        let tau = cranpose_ui_graphics::TAU;
22977        let center = Point::new(250.0, 250.0);
22978        // (inner, outer, start, sweep, cap): partial arcs with every cap,
22979        // a closed ring, and a full disc.
22980        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
22981            (90.0, 100.0, 0.7, 2.5, StrokeCap::Butt),
22982            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
22983            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
22984            (80.0, 100.0, 0.0, tau, StrokeCap::Round),
22985            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
22986        ];
22987        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
22988            let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
22989            let converted = converted_arc_shape(arc, 1.0);
22990            let cap_code = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
22991            let arc_center = [converted.arc_params[0], converted.arc_params[1]];
22992            let mid = [converted.radii[0], converted.radii[1]];
22993            let half = [converted.radii[2], converted.radii[3]];
22994            let aabb = quad_aabb(&converted);
22995            // Pad past the fast-trig AABB slop so the whole kept set is
22996            // integrated.
22997            let bounds = [aabb[0] - 2.0, aabb[1] - 2.0, aabb[2] + 2.0, aabb[3] + 2.0];
22998            let numeric = numeric_area(bounds, 1000, |x, y| {
22999                sdf_arc_band_reference(
23000                    [x as f32, y as f32],
23001                    arc_center,
23002                    converted.stroke_params[3],
23003                    converted.stroke_params[2],
23004                    mid,
23005                    half,
23006                    cap_code,
23007                ) < 0.0
23008            });
23009            let analytic = analytic_covered_area(&converted);
23010            let error = (analytic - numeric).abs() / numeric.max(1.0);
23011            assert!(
23012                error < 0.02,
23013                "case {case}: analytic {analytic:.1} vs sdf {numeric:.1} \
23014                 ({:.2}% off)",
23015                error * 100.0
23016            );
23017        }
23018    }
23019
23020    #[cfg(not(target_arch = "wasm32"))]
23021    #[test]
23022    fn fill_truth_circle_and_rrect_fill_lit_match_references() {
23023        // A filled circle degenerates to exactly pi r^2.
23024        let mut circle = ShapeData::zeroed();
23025        circle.stroke_params[1] =
23026            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23027        circle.rect = [10.0, 10.0, 200.0, 200.0];
23028        circle.radii = [100.0; 4];
23029        let analytic = analytic_covered_area(&circle);
23030        let exact = std::f64::consts::PI * 100.0 * 100.0;
23031        assert!(
23032            (analytic - exact).abs() / exact < 1e-9,
23033            "circle: {analytic} vs {exact}"
23034        );
23035
23036        // A rounded rect against the SDF reference.
23037        let mut rounded = ShapeData::zeroed();
23038        rounded.stroke_params[1] =
23039            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23040        rounded.rect = [50.0, 80.0, 200.0, 120.0];
23041        rounded.radii = [40.0; 4];
23042        let numeric = numeric_area([48.0, 78.0, 252.0, 202.0], 1000, |x, y| {
23043            sdf_rounded_rect_reference([x, y], [150.0, 140.0], [100.0, 60.0], 40.0) < 0.0
23044        });
23045        let analytic = analytic_covered_area(&rounded);
23046        let error = (analytic - numeric).abs() / numeric;
23047        assert!(
23048            error < 0.02,
23049            "rrect fill: analytic {analytic:.1} vs sdf {numeric:.1}"
23050        );
23051    }
23052
23053    #[cfg(not(target_arch = "wasm32"))]
23054    #[test]
23055    fn fill_truth_stroked_rrect_lit_matches_the_band_area() {
23056        // The circle rim: perimeter x stroke width equals the exact annulus
23057        // pi (outer^2 - inner^2) = 2 pi geom_half sw.
23058        let rim = rim_test_shape_data();
23059        let analytic = analytic_covered_area(&rim);
23060        let exact = std::f64::consts::PI * (150.0 * 150.0 - 142.0 * 142.0);
23061        assert!(
23062            (analytic - exact).abs() / exact < 1e-9,
23063            "circle rim: {analytic} vs {exact}"
23064        );
23065
23066        // A rounded-SQUARE ring (radius well below the half-extent) against
23067        // the SDF band |sdf| < sw/2.
23068        let mut square_ring = rim_test_shape_data();
23069        square_ring.radii = [60.0; 4];
23070        let numeric = numeric_area([38.0, 38.0, 342.0, 342.0], 1000, |x, y| {
23071            sdf_rounded_rect_reference([x, y], [190.0, 190.0], [146.0, 146.0], 60.0).abs() < 4.0
23072        });
23073        let analytic = analytic_covered_area(&square_ring);
23074        let error = (analytic - numeric).abs() / numeric;
23075        assert!(
23076            error < 0.02,
23077            "square ring: analytic {analytic:.1} vs sdf {numeric:.1}"
23078        );
23079    }
23080
23081    #[cfg(not(target_arch = "wasm32"))]
23082    #[test]
23083    fn fill_truth_corner_counter_prices_the_area_outside_the_inscribed_circle() {
23084        // A full-viewport quad on a square (watch) surface wastes exactly
23085        // the four corner lunes: (1 - pi/4) of the screen.
23086        let full = area_outside_inscribed_circle([0.0, 0.0, 454.0, 454.0], (454, 454));
23087        let exact = (1.0 - std::f64::consts::FRAC_PI_4) * 454.0 * 454.0;
23088        assert!(
23089            (full - exact).abs() / exact < 0.01,
23090            "full quad: {full} vs {exact}"
23091        );
23092        // A centered box inside the circle wastes nothing, exactly.
23093        assert_eq!(
23094            area_outside_inscribed_circle([127.0, 127.0, 327.0, 327.0], (454, 454)),
23095            0.0
23096        );
23097        // A box entirely inside a corner is all waste.
23098        let corner = area_outside_inscribed_circle([0.0, 0.0, 40.0, 40.0], (454, 454));
23099        assert!((corner - 1600.0).abs() < 1e-6, "corner box: {corner}");
23100    }
23101
23102    #[cfg(not(target_arch = "wasm32"))]
23103    #[test]
23104    fn fill_truth_opacity_histogram_classifies_solid_alpha_exactly() {
23105        let diag = FillAreaDiag::default();
23106        diag.reset_frame(454, 454);
23107        let full_frame = ViewportUniformParams {
23108            width: 454,
23109            height: 454,
23110            offset: [0.0, 0.0],
23111        };
23112        let mut opaque = ShapeData::zeroed();
23113        opaque.stroke_params[1] =
23114            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23115        opaque.rect = [0.0, 0.0, 100.0, 50.0];
23116        opaque.quad01 = [0.0, 0.0, 100.0, 0.0];
23117        opaque.quad23 = [0.0, 50.0, 100.0, 50.0];
23118        opaque.color = [1.0, 1.0, 1.0, 1.0];
23119        let mut faded = opaque;
23120        faded.color[3] = 0.82;
23121        let mut gradient = opaque;
23122        gradient.brush_type = 1;
23123        diag.add_shape_quads(&[opaque, faded, gradient], full_frame);
23124        // Plain rects are all-lit: 5000 px each, one per class.
23125        let lit = |class: FillOpacityClass| diag.frame_opacity[class as usize].get();
23126        assert_eq!(lit(FillOpacityClass::Opaque), 5000.0);
23127        assert_eq!(lit(FillOpacityClass::Translucent), 5000.0);
23128        assert_eq!(lit(FillOpacityClass::NonSolid), 5000.0);
23129        // The corner-hugging quads waste real area on a round display.
23130        assert!(diag.frame_corner.get() > 0.0);
23131
23132        // The same batch under an offset (offscreen) viewport must leave the
23133        // corner counter alone.
23134        let offscreen = FillAreaDiag::default();
23135        offscreen.reset_frame(454, 454);
23136        offscreen.add_shape_quads(&[opaque], offscreen_test_viewport());
23137        assert_eq!(offscreen.frame_corner.get(), 0.0);
23138    }
23139
23140    #[cfg(not(target_arch = "wasm32"))]
23141    #[test]
23142    fn fill_truth_retained_records_price_ranges_and_identity_corners() {
23143        let mut plain = ShapeData::zeroed();
23144        plain.stroke_params[1] =
23145            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23146        plain.rect = [200.0, 200.0, 20.0, 10.0];
23147        plain.quad01 = [200.0, 200.0, 220.0, 200.0];
23148        plain.quad23 = [200.0, 210.0, 220.0, 210.0];
23149        plain.color = [1.0, 1.0, 1.0, 1.0];
23150        let shapes = vec![rim_test_shape_data(), plain];
23151        let records = fill_diag_capture_records(&shapes, None);
23152        assert_eq!(records.len(), 2);
23153        assert_eq!(records[0].bucket, FillAreaDiag::RRECT_STROKE);
23154        assert_eq!(records[0].drawn_px2, 300.0 * 300.0);
23155        assert!(records[0].lit_px2 < records[0].drawn_px2, "a rim has slack");
23156        // A plain rect is exact: no slack at all.
23157        assert_eq!(records[1].bucket, FillAreaDiag::RECT);
23158        assert_eq!(records[1].lit_px2, records[1].drawn_px2);
23159
23160        let diag = FillAreaDiag::default();
23161        diag.reset_frame(454, 454);
23162        // Scaled replay: areas scale with the similarity squared, and the
23163        // capture-space AABBs no longer say where pixels land — no corner.
23164        let scaled = SimilarityTransform::new([0.0, 0.0], 0.0, 2.0);
23165        diag.add_retained_range(&records, 0, 2, &scaled);
23166        let drawn: f64 = records.iter().map(|record| record.drawn_px2).sum();
23167        assert!((diag.frame[FillAreaDiag::RETAINED].get() - drawn * 4.0).abs() < 1e-6);
23168        assert_eq!(diag.frame_corner.get(), 0.0);
23169
23170        // Identity replay: the rim's 300 px box on a 454 px round screen
23171        // pokes into the corner lunes.
23172        let identity_diag = FillAreaDiag::default();
23173        identity_diag.reset_frame(454, 454);
23174        identity_diag.add_retained_range(&records, 0, 2, &SimilarityTransform::IDENTITY);
23175        assert!(identity_diag.frame_corner.get() > 0.0);
23176        // And the range is respected: shape 1 alone has no rim slack.
23177        let tail = FillAreaDiag::default();
23178        tail.reset_frame(454, 454);
23179        tail.add_retained_range(&records, 1, 2, &SimilarityTransform::IDENTITY);
23180        assert_eq!(
23181            tail.frame[FillAreaDiag::RETAINED].get(),
23182            records[1].drawn_px2
23183        );
23184    }
23185
23186    #[cfg(not(target_arch = "wasm32"))]
23187    #[test]
23188    fn fill_truth_top_slack_dump_keeps_the_worst_ten() {
23189        let mut diag = FillAreaDiag::default();
23190        let records: Vec<FillDiagShapeRecord> = (0..12)
23191            .map(|index| FillDiagShapeRecord {
23192                drawn_px2: 1000.0 * (index + 1) as f64,
23193                lit_px2: 100.0,
23194                bucket: FillAreaDiag::ARC,
23195                opacity: FillOpacityClass::Opaque,
23196                aabb: [0.0, 0.0, 10.0, 10.0],
23197            })
23198            .collect();
23199        diag.note_retained_capture(3, &records);
23200        assert_eq!(diag.slack_top.len(), FILL_DIAG_SLACK_TOP);
23201        // Sorted by slack, worst first, and the two smallest fell off.
23202        assert_eq!(diag.slack_top[0].drawn_px2, 12000.0);
23203        assert_eq!(diag.slack_top[0].slot, 3);
23204        assert_eq!(diag.slack_top[0].shape, 11);
23205        for pair in diag.slack_top.windows(2) {
23206            assert!(pair[0].drawn_px2 - pair[0].lit_px2 >= pair[1].drawn_px2 - pair[1].lit_px2);
23207        }
23208        assert!(diag
23209            .slack_top
23210            .iter()
23211            .all(|entry| entry.drawn_px2 - entry.lit_px2 > 2000.0 - 100.0));
23212    }
23213
23214    #[cfg(not(target_arch = "wasm32"))]
23215    #[test]
23216    fn rim_mesh_band_accepts_only_huge_solid_unclipped_circle_rims() {
23217        let band = rim_mesh_band(&rim_test_shape_data()).expect("circle rim must qualify");
23218        assert_eq!(band.center, [190.0, 190.0]);
23219        assert_eq!(band.inner, 142.0);
23220        assert_eq!(band.outer, 150.0);
23221        assert_eq!(band.start, 0.0);
23222        assert!(
23223            band.sweep >= cranpose_ui_graphics::TAU,
23224            "a rim band is a closed ring"
23225        );
23226        // And it actually meshes through the shared emitter.
23227        let mut vertices = Vec::new();
23228        let mut indices = Vec::new();
23229        emit_arc_band_mesh(
23230            &rim_test_shape_data(),
23231            7,
23232            &band,
23233            &mut vertices,
23234            &mut indices,
23235        )
23236        .expect("rim must mesh");
23237        assert!(vertices.iter().all(|vertex| vertex.shape_idx == 7));
23238
23239        // Rounded SQUARE ring: radius well below the geometry half-extent.
23240        // Meshing it would under-cover the flat spans — the false positive
23241        // the circle gate exists to prevent.
23242        let mut square = rim_test_shape_data();
23243        square.radii = [100.0; 4];
23244        assert!(rim_mesh_band(&square).is_none());
23245
23246        // Non-square box.
23247        let mut oblong = rim_test_shape_data();
23248        oblong.rect = [40.0, 40.0, 300.0, 200.0];
23249        assert!(rim_mesh_band(&oblong).is_none());
23250
23251        // Gradient brush.
23252        let mut gradient = rim_test_shape_data();
23253        gradient.brush_type = 1;
23254        assert!(rim_mesh_band(&gradient).is_none());
23255
23256        // Live clip.
23257        let mut clipped = rim_test_shape_data();
23258        clipped.clip_rect = [0.0, 0.0, 400.0, 400.0];
23259        assert!(rim_mesh_band(&clipped).is_none());
23260
23261        // Small (100 × 100 < 65536 px²), even as a perfect circle.
23262        let mut small = rim_test_shape_data();
23263        small.rect = [40.0, 40.0, 100.0, 100.0];
23264        small.quad01 = [40.0, 40.0, 140.0, 40.0];
23265        small.quad23 = [40.0, 140.0, 140.0, 140.0];
23266        small.radii = [46.0; 4];
23267        assert!(rim_mesh_band(&small).is_none());
23268
23269        // Fill kind, not stroke.
23270        let mut fill = rim_test_shape_data();
23271        fill.stroke_params[1] =
23272            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23273        assert!(rim_mesh_band(&fill).is_none());
23274
23275        // Zero stroke width.
23276        let mut hairline = rim_test_shape_data();
23277        hairline.stroke_params[0] = 0.0;
23278        assert!(rim_mesh_band(&hairline).is_none());
23279
23280        // Mismatched corner radii.
23281        let mut uneven = rim_test_shape_data();
23282        uneven.radii[2] = 145.0;
23283        assert!(rim_mesh_band(&uneven).is_none());
23284    }
23285
23286    #[cfg(not(target_arch = "wasm32"))]
23287    #[test]
23288    fn shape_batch_limits_follow_uniform_binding_size() {
23289        // With a 160-byte ShapeData, even a desktop-class 64 KiB binding can no
23290        // longer hold the full compile-time cap: 65536 / 160 = 409 < 768.
23291        let desktop_shapes = 65536 / std::mem::size_of::<ShapeData>();
23292        assert_eq!(desktop_shapes, 409);
23293        assert_eq!(
23294            ShapeBatchLimits::desktop(),
23295            ShapeBatchLimits {
23296                max_shapes_per_batch: desktop_shapes.min(MAX_SHAPES_PER_BATCH),
23297                max_gradient_stops: MAX_GRADIENT_STOPS,
23298                storage: false,
23299            }
23300        );
23301
23302        // The 16 KiB downlevel/GLES minimum must shrink batches to fit:
23303        // 16384 / 160-byte ShapeData = 102 shapes, 16384 / 32-byte stop = 512.
23304        let downlevel = ShapeBatchLimits::for_uniform_binding_size(16384);
23305        assert_eq!(downlevel.max_shapes_per_batch, 16384 / 160);
23306        assert_eq!(downlevel.max_shapes_per_batch, 102);
23307        assert_eq!(downlevel.max_gradient_stops, 512.min(MAX_GRADIENT_STOPS));
23308        assert!(downlevel.max_shapes_per_batch * std::mem::size_of::<ShapeData>() <= 16384);
23309        assert!(downlevel.max_gradient_stops * std::mem::size_of::<GradientStop>() <= 16384);
23310
23311        // Degenerate limits must not produce zero-sized buffers.
23312        let tiny = ShapeBatchLimits::for_uniform_binding_size(1);
23313        assert_eq!(tiny.max_shapes_per_batch, 1);
23314        assert_eq!(tiny.max_gradient_stops, 1);
23315    }
23316
23317    #[test]
23318    fn storage_shape_batch_limits_uncap_the_batch_and_start_small() {
23319        // A typical 128 MiB storage binding hits the compile-time ceilings,
23320        // not the device limit: one batch holds the whole scene.
23321        let storage = ShapeBatchLimits::for_storage_binding_size(128 << 20);
23322        assert!(storage.storage);
23323        assert_eq!(storage.max_shapes_per_batch, MAX_SHAPES_PER_STORAGE_BATCH);
23324        assert_eq!(
23325            storage.max_gradient_stops,
23326            MAX_GRADIENT_STOPS_PER_STORAGE_BATCH
23327        );
23328
23329        // The buffers must not be allocated at the multi-megabyte ceiling up
23330        // front; they start small and grow on demand.
23331        assert_eq!(
23332            storage.initial_shape_capacity(),
23333            INITIAL_STORAGE_BATCH_CAPACITY
23334        );
23335        assert_eq!(
23336            storage.initial_gradient_capacity(),
23337            INITIAL_STORAGE_BATCH_CAPACITY
23338        );
23339        assert_eq!(
23340            storage.data_binding_type(),
23341            wgpu::BufferBindingType::Storage { read_only: true }
23342        );
23343        assert!(storage
23344            .data_buffer_usage()
23345            .contains(wgpu::BufferUsages::STORAGE));
23346
23347        // Uniform mode keeps its start-at-the-cap invariant: a uniform
23348        // binding smaller than the shader's fixed array fails validation.
23349        let uniform = ShapeBatchLimits::desktop();
23350        assert_eq!(
23351            uniform.initial_shape_capacity(),
23352            uniform.max_shapes_per_batch
23353        );
23354        assert_eq!(
23355            uniform.initial_gradient_capacity(),
23356            uniform.max_gradient_stops
23357        );
23358        assert_eq!(
23359            uniform.data_binding_type(),
23360            wgpu::BufferBindingType::Uniform
23361        );
23362        assert!(uniform
23363            .data_buffer_usage()
23364            .contains(wgpu::BufferUsages::UNIFORM));
23365    }
23366
23367    #[test]
23368    fn storage_shape_shader_swaps_the_arrays_to_runtime_sized_storage() {
23369        let source =
23370            shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20), false);
23371        assert!(
23372            source.contains("var<storage, read> shape_data: array<ShapeData>;"),
23373            "storage-mode shader must declare a runtime-sized shape array"
23374        );
23375        assert!(
23376            source.contains("var<storage, read> gradient_stops: array<GradientStop>;"),
23377            "storage-mode shader must declare a runtime-sized gradient array"
23378        );
23379        assert!(
23380            !source.contains("var<uniform> shape_data"),
23381            "the uniform shape declaration must be fully replaced"
23382        );
23383        assert!(
23384            !source.contains("var<uniform> gradient_stops"),
23385            "the uniform gradient declaration must be fully replaced"
23386        );
23387        assert!(
23388            source.contains("var<storage, read> paint: array<vec4<f32>>;"),
23389            "storage-mode shader must declare the retained paint array"
23390        );
23391        assert!(
23392            source.contains("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)"),
23393            "storage-mode shader must read paint under the paint_select flag"
23394        );
23395        assert!(
23396            source.contains("fn vs_mesh("),
23397            "the storage rewrite must leave the retained-mesh vertex entry intact"
23398        );
23399        assert!(
23400            source.contains("fn vs_shape_instanced("),
23401            "the storage rewrite must leave the instanced-quad vertex entry intact"
23402        );
23403        assert_eq!(
23404            source
23405                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
23406                .count(),
23407            3,
23408            "vs_main, vs_shape_instanced and vs_mesh must all read paint under \
23409             the paint_select flag (meshless retained draws ride the instanced \
23410             entry when the selection is latched on)"
23411        );
23412
23413        // The storage variant is what native devices actually compile; it
23414        // must be valid WGSL, not just textually plausible.
23415        let module = naga::front::wgsl::parse_str(&source)
23416            .expect("storage-mode shape shader must parse as WGSL");
23417        naga::valid::Validator::new(
23418            naga::valid::ValidationFlags::all(),
23419            naga::valid::Capabilities::all(),
23420        )
23421        .validate(&module)
23422        .expect("storage-mode shape shader must validate for WebGPU");
23423    }
23424
23425    #[test]
23426    fn solid_trim_keeps_the_full_struct_locations_with_the_dropped_slots_vacant() {
23427        // Suspect #1 from the reverted first trim (16a5d312 / 371dd06a): the
23428        // survivors were renumbered densely. Every surviving varying line in
23429        // `VertexOutputSolid` must be byte-identical to its `VertexOutput`
23430        // line — same index, same interpolation, same type — and the two
23431        // dropped slots must stay vacant.
23432        let appendix = shaders::SOLID_TRIM_APPENDIX;
23433        for line in [
23434            "@location(0) color: vec4<f32>,",
23435            "@location(1) uv: vec2<f32>,",
23436            "@location(2) world_pos: vec2<f32>,",
23437            "@location(3) @interpolate(flat) rect: vec4<f32>,",
23438            "@location(4) @interpolate(flat) radii: vec4<f32>,",
23439            "@location(6) @interpolate(flat) clip_rect: vec4<f32>,",
23440            "@location(7) @interpolate(flat) stroke_params: vec4<f32>,",
23441            "@location(8) @interpolate(flat) arc_params: vec4<f32>,",
23442        ] {
23443            assert!(
23444                shaders::SHADER.contains(line),
23445                "`{line}` drifted out of VertexOutput; realign the trimmed \
23446                 struct line for line before touching anything else"
23447            );
23448            assert!(
23449                appendix.contains(line),
23450                "`{line}` must appear verbatim in VertexOutputSolid — the \
23451                 surviving varyings keep the full struct's location indices"
23452            );
23453        }
23454        assert!(
23455            !appendix.contains("@location(5)"),
23456            "location 5 is gradient_params' slot and must stay VACANT — \
23457             dense renumbering is the reverted attempt's suspect #1"
23458        );
23459        assert!(
23460            !appendix.contains("@location(9)"),
23461            "location 9 is brush's slot and must stay VACANT — dense \
23462             renumbering is the reverted attempt's suspect #1"
23463        );
23464        assert!(
23465            !appendix.contains("output.gradient_params") && !appendix.contains("output.brush"),
23466            "the trimmed vertex entries must not write the dropped varyings"
23467        );
23468    }
23469
23470    #[test]
23471    fn solid_trim_source_reaches_every_injection_and_validates() {
23472        // The trimmed entries are appended BEFORE `shape_shader_source`'s
23473        // rewrites, so the storage rewrite's paint-select injection must land
23474        // in all five vertex entries — a solid entry that missed it would
23475        // freeze every recolor on the retained slots it draws.
23476        let storage =
23477            shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20), true);
23478        for entry in [
23479            "fn vs_solid(",
23480            "fn vs_solid_instanced(",
23481            "fn fs_solid_trim(",
23482        ] {
23483            assert!(
23484                storage.contains(entry),
23485                "trimmed storage source must carry `{entry}`"
23486            );
23487        }
23488        assert_eq!(
23489            storage
23490                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
23491                .count(),
23492            5,
23493            "vs_main, vs_shape_instanced, vs_mesh, vs_solid and \
23494             vs_solid_instanced must all read paint under the paint_select \
23495             flag"
23496        );
23497
23498        // Both variants a native device can compile must be valid WGSL, flat
23499        // and with the display-clip z rewrite applied.
23500        let uniform = shape_shader_source(ShapeBatchLimits::desktop(), true);
23501        for source in [&storage, &uniform] {
23502            for depth in [false, true] {
23503                let text = display_clip::with_content_z(Cow::Owned(source.to_string()), depth);
23504                let module = naga::front::wgsl::parse_str(&text)
23505                    .expect("trimmed shape shader must parse as WGSL");
23506                naga::valid::Validator::new(
23507                    naga::valid::ValidationFlags::all(),
23508                    naga::valid::Capabilities::all(),
23509                )
23510                .validate(&module)
23511                .expect("trimmed shape shader must validate for WebGPU");
23512            }
23513        }
23514    }
23515
23516    #[test]
23517    fn solid_trim_flag_reads_the_documented_variable() {
23518        // The parity suite's trimmed arms set exactly this variable; a name
23519        // drift here would leave them silently comparing full against full.
23520        std::env::remove_var("CRANPOSE_SOLID_TRIM_VARYINGS");
23521        assert!(!solid_trim_varyings_enabled(), "the trim must default OFF");
23522        std::env::set_var("CRANPOSE_SOLID_TRIM_VARYINGS", "1");
23523        assert!(solid_trim_varyings_enabled());
23524        std::env::set_var("CRANPOSE_SOLID_TRIM_VARYINGS", "0");
23525        assert!(!solid_trim_varyings_enabled());
23526        std::env::remove_var("CRANPOSE_SOLID_TRIM_VARYINGS");
23527    }
23528
23529    #[test]
23530    fn uniform_shape_shader_keeps_the_in_record_color_and_no_paint_binding() {
23531        // The base text serves WebGL-class uniform devices, which can bind
23532        // no storage buffers: the paint array and its select must exist only
23533        // in the storage-mode rewrite.
23534        for source in [
23535            Cow::Borrowed(shaders::SHADER),
23536            shape_shader_source(ShapeBatchLimits::desktop(), false),
23537        ] {
23538            assert!(
23539                !source.contains("paint: array"),
23540                "the uniform variant must not declare a paint array"
23541            );
23542            assert!(
23543                source.contains("output.color = shape.color;"),
23544                "the uniform variant must read the color from ShapeData \
23545                 (this literal is also what `shape_shader_source` rewrites)"
23546            );
23547            assert!(
23548                source.contains("paint_select: f32"),
23549                "SimilarityTransform must name the flag field in both \
23550                 variants; the Rust mirror is Pod and uploads raw bytes"
23551            );
23552        }
23553    }
23554
23555    #[test]
23556    fn shipped_shape_shader_array_length_fits_the_downlevel_uniform_floor() {
23557        // The wasm build uses `shaders::SHADER` verbatim, so its declared array
23558        // length is simultaneously the wasm batch cap and the WebGL binding
23559        // size. It must fit the 16 KiB floor exactly.
23560        assert!(
23561            shaders::SHADER.contains("array<ShapeData, 102>"),
23562            "shape.wgsl array length must stay in sync with \
23563             `shape_shader_source`'s replace string and MAX_SHAPES_PER_BATCH"
23564        );
23565        assert!(102 * std::mem::size_of::<ShapeData>() <= 16384);
23566        assert!(103 * std::mem::size_of::<ShapeData>() > 16384);
23567    }
23568
23569    #[test]
23570    fn glyph_atlas_doubles_on_overflow_and_stops_at_the_device_ceiling() {
23571        // Every overflow buys one doubling, so an app that needs the old fixed
23572        // 4096 atlas reaches it in three resets and then stays there.
23573        assert_eq!(
23574            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MIN_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
23575            1024
23576        );
23577        assert_eq!(
23578            next_glyph_atlas_size(2048, TEXT_GLYPH_ATLAS_MAX_SIZE),
23579            TEXT_GLYPH_ATLAS_MAX_SIZE
23580        );
23581        assert_eq!(
23582            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MAX_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
23583            TEXT_GLYPH_ATLAS_MAX_SIZE
23584        );
23585
23586        // A device that only grants `downlevel_defaults()`'s 2048 caps the
23587        // growth there rather than failing to create the texture.
23588        assert_eq!(next_glyph_atlas_size(1024, 2048), 2048);
23589        assert_eq!(next_glyph_atlas_size(2048, 2048), 2048);
23590
23591        // Never zero and never wrapping, whatever the ceiling turns out to be.
23592        assert_eq!(next_glyph_atlas_size(u32::MAX, 4096), 4096);
23593        assert_eq!(next_glyph_atlas_size(0, 0), 1);
23594    }
23595
23596    #[test]
23597    fn glyph_atlas_uv_rect_normalizes_against_the_atlas_it_was_placed_in() {
23598        // The atlas grows, so a UV is only meaningful together with the size of
23599        // the texture the entry came from. Reading the size off a constant is
23600        // what would make a grown atlas sample the wrong glyph.
23601        let entry = GlyphAtlasEntry {
23602            x: 128,
23603            y: 256,
23604            width: 16,
23605            height: 32,
23606        };
23607
23608        let small = glyph_atlas_uv_rect(entry, 512);
23609        let large = glyph_atlas_uv_rect(entry, 4096);
23610
23611        assert_eq!(small.min, [128.0 / 512.0, 256.0 / 512.0]);
23612        assert_eq!(large.min, [128.0 / 4096.0, 256.0 / 4096.0]);
23613        assert_eq!(small.max, [144.0 / 512.0, 288.0 / 512.0]);
23614        assert_eq!(large.max, [144.0 / 4096.0, 288.0 / 4096.0]);
23615    }
23616
23617    #[test]
23618    fn native_shape_shader_source_uses_native_batch_limits() {
23619        let limits = ShapeBatchLimits::desktop();
23620        let source = shape_shader_source(limits, false);
23621
23622        assert!(source.contains(&format!(
23623            "array<ShapeData, {}>",
23624            limits.max_shapes_per_batch
23625        )));
23626        assert!(source.contains(&format!(
23627            "array<GradientStop, {}>",
23628            limits.max_gradient_stops
23629        )));
23630        // Sanity: the substitution actually fired rather than silently leaving
23631        // the downlevel literal in place.
23632        assert!(!source.contains("array<ShapeData, 146>"));
23633    }
23634
23635    #[test]
23636    fn stroked_and_arc_shapes_batch_together_with_fills() {
23637        // Strokes and arcs ride the same pipeline, the same ShapeData array and
23638        // the same blend state as fills, so a run of mixed shapes must stay a
23639        // single batch. If they ever split the batch, a polar UI built from
23640        // hundreds of arcs would pay a draw call per arc — precisely the cost
23641        // this primitive exists to remove.
23642        let fill = test_shape(0, BlendMode::SrcOver);
23643        let mut stroked = test_shape(1, BlendMode::SrcOver);
23644        stroked.stroke = Some(
23645            cranpose_ui_graphics::Stroke::new(3.0)
23646                .with_cap(StrokeCap::Round)
23647                .with_join(StrokeJoin::Bevel),
23648        );
23649        let mut arc = test_shape(2, BlendMode::SrcOver);
23650        arc.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
23651            Point::new(4.0, 4.0),
23652            2.0,
23653            4.0,
23654            0.0,
23655            1.0,
23656            StrokeCap::Round,
23657        ));
23658        let trailing_fill = test_shape(3, BlendMode::SrcOver);
23659
23660        assert!(!fill.has_stroke_or_arc());
23661        assert!(stroked.has_stroke_or_arc());
23662        assert!(arc.has_stroke_or_arc());
23663        assert!(!trailing_fill.has_stroke_or_arc());
23664
23665        let shapes = vec![fill, stroked, arc, trailing_fill];
23666        let ordered_items: Vec<_> = (0..shapes.len())
23667            .map(|index| (index, SegmentDrawItem::Shape(index)))
23668            .collect();
23669        let images = Vec::new();
23670
23671        let commands: Vec<_> = SegmentCommandIter::new(
23672            &ordered_items,
23673            &shapes,
23674            &images,
23675            ShapeBatchLimits::desktop(),
23676        )
23677        .collect();
23678
23679        assert_eq!(
23680            commands,
23681            vec![SegmentRenderCommand::DrawChunk(chunk(&[
23682                SegmentBatchPlan::Shape {
23683                    start: 0,
23684                    end: 4,
23685                    blend_mode: BlendMode::SrcOver,
23686                }
23687            ]))],
23688            "mixed fill/stroke/arc runs must stay one batch"
23689        );
23690    }
23691
23692    #[cfg(not(target_arch = "wasm32"))]
23693    #[test]
23694    fn native_segment_fusion_budget_allows_small_interleaved_chunks() {
23695        let ordered_items = vec![
23696            (0, SegmentDrawItem::Shape(0)),
23697            (1, SegmentDrawItem::Image(0)),
23698            (2, SegmentDrawItem::Text(0)),
23699            (3, SegmentDrawItem::Shape(1)),
23700        ];
23701        let shapes = vec![
23702            test_shape(0, BlendMode::SrcOver),
23703            test_shape(3, BlendMode::DstOut),
23704        ];
23705        let segment = chunk(&[
23706            SegmentBatchPlan::Shape {
23707                start: 0,
23708                end: 1,
23709                blend_mode: BlendMode::SrcOver,
23710            },
23711            SegmentBatchPlan::Image {
23712                start: 1,
23713                end: 2,
23714                blend_mode: BlendMode::SrcOver,
23715            },
23716            SegmentBatchPlan::Text { start: 2, end: 3 },
23717            SegmentBatchPlan::Shape {
23718                start: 3,
23719                end: 4,
23720                blend_mode: BlendMode::DstOut,
23721            },
23722        ]);
23723
23724        let budget = native_segment_fusion_budget(
23725            &ordered_items,
23726            &shapes,
23727            &[],
23728            &segment,
23729            ShapeBatchLimits::desktop(),
23730        )
23731        .expect("budget should be valid")
23732        .expect("chunk should fit native fusion budget");
23733
23734        assert_eq!(
23735            budget,
23736            NativeSegmentFusionBudget {
23737                shape_count: 2,
23738                gradient_stop_count: 0,
23739            }
23740        );
23741    }
23742
23743    #[cfg(not(target_arch = "wasm32"))]
23744    #[test]
23745    fn native_segment_fusion_budget_rejects_shape_uniform_overflow() {
23746        let ordered_items: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
23747            .map(|index| (index, SegmentDrawItem::Shape(index)))
23748            .collect();
23749        let shapes: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
23750            .map(|index| test_shape(index, BlendMode::SrcOver))
23751            .collect();
23752        let segment = chunk(&[
23753            SegmentBatchPlan::Shape {
23754                start: 0,
23755                end: MAX_SHAPES_PER_BATCH,
23756                blend_mode: BlendMode::SrcOver,
23757            },
23758            SegmentBatchPlan::Shape {
23759                start: MAX_SHAPES_PER_BATCH,
23760                end: MAX_SHAPES_PER_BATCH + 1,
23761                blend_mode: BlendMode::SrcOver,
23762            },
23763        ]);
23764
23765        let budget = native_segment_fusion_budget(
23766            &ordered_items,
23767            &shapes,
23768            &[],
23769            &segment,
23770            ShapeBatchLimits::desktop(),
23771        )
23772        .expect("valid plan");
23773
23774        assert_eq!(budget, None);
23775    }
23776
23777    #[cfg(not(target_arch = "wasm32"))]
23778    #[test]
23779    fn native_segment_fusion_budget_rejects_gradient_uniform_overflow() {
23780        let ordered_items = vec![(0, SegmentDrawItem::Shape(0))];
23781        let mut shape = test_shape(0, BlendMode::SrcOver);
23782        let brushes = vec![Brush::linear_gradient(vec![
23783            Color::BLACK;
23784            MAX_GRADIENT_STOPS + 1
23785        ])];
23786        shape.brush = SceneBrush::Gradient(0);
23787        let shapes = vec![shape];
23788        let segment = chunk(&[SegmentBatchPlan::Shape {
23789            start: 0,
23790            end: 1,
23791            blend_mode: BlendMode::SrcOver,
23792        }]);
23793
23794        let budget = native_segment_fusion_budget(
23795            &ordered_items,
23796            &shapes,
23797            &brushes,
23798            &segment,
23799            ShapeBatchLimits::desktop(),
23800        )
23801        .expect("valid plan");
23802
23803        assert_eq!(budget, None);
23804    }
23805
23806    #[cfg(not(target_arch = "wasm32"))]
23807    #[test]
23808    fn native_segment_fusion_partitions_shape_uniform_overflow() {
23809        // The uniform batch cap is derived from the device binding size and
23810        // the 112-byte ShapeData, not from the compile-time ceiling.
23811        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
23812        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
23813            .map(|index| (index, SegmentDrawItem::Shape(index)))
23814            .collect();
23815        let shapes: Vec<_> = (0..=desktop_batch_cap)
23816            .map(|index| test_shape(index, BlendMode::SrcOver))
23817            .collect();
23818        let segment = chunk(&[
23819            SegmentBatchPlan::Shape {
23820                start: 0,
23821                end: desktop_batch_cap,
23822                blend_mode: BlendMode::SrcOver,
23823            },
23824            SegmentBatchPlan::Shape {
23825                start: desktop_batch_cap,
23826                end: desktop_batch_cap + 1,
23827                blend_mode: BlendMode::SrcOver,
23828            },
23829        ]);
23830
23831        let partitions = native_segment_fusion_partitions(
23832            &ordered_items,
23833            &shapes,
23834            &[],
23835            &segment,
23836            ShapeBatchLimits::desktop(),
23837        )
23838        .expect("valid plan")
23839        .expect("overflowing segment should be partitionable");
23840
23841        assert_eq!(partitions.len(), 2);
23842        assert_eq!(
23843            partitions[0],
23844            NativeSegmentFusionPartition {
23845                chunk: chunk(&[SegmentBatchPlan::Shape {
23846                    start: 0,
23847                    end: desktop_batch_cap,
23848                    blend_mode: BlendMode::SrcOver,
23849                }]),
23850                budget: NativeSegmentFusionBudget {
23851                    shape_count: desktop_batch_cap,
23852                    gradient_stop_count: 0,
23853                },
23854            }
23855        );
23856        assert_eq!(
23857            partitions[1],
23858            NativeSegmentFusionPartition {
23859                chunk: chunk(&[SegmentBatchPlan::Shape {
23860                    start: desktop_batch_cap,
23861                    end: desktop_batch_cap + 1,
23862                    blend_mode: BlendMode::SrcOver,
23863                }]),
23864                budget: NativeSegmentFusionBudget {
23865                    shape_count: 1,
23866                    gradient_stop_count: 0,
23867                },
23868            }
23869        );
23870    }
23871
23872    #[cfg(not(target_arch = "wasm32"))]
23873    #[test]
23874    fn native_segment_fusion_partitions_gradient_uniform_overflow() {
23875        const STOPS_PER_SHAPE: usize = MAX_GRADIENT_STOPS / 2;
23876        let ordered_items = vec![
23877            (0, SegmentDrawItem::Shape(0)),
23878            (1, SegmentDrawItem::Shape(1)),
23879            (2, SegmentDrawItem::Shape(2)),
23880        ];
23881        let mut shapes = Vec::new();
23882        let brushes = vec![Brush::linear_gradient(vec![Color::BLACK; STOPS_PER_SHAPE])];
23883        for index in 0..3 {
23884            let mut shape = test_shape(index, BlendMode::SrcOver);
23885            shape.brush = SceneBrush::Gradient(0);
23886            shapes.push(shape);
23887        }
23888        let segment = chunk(&[SegmentBatchPlan::Shape {
23889            start: 0,
23890            end: 3,
23891            blend_mode: BlendMode::SrcOver,
23892        }]);
23893
23894        let partitions = native_segment_fusion_partitions(
23895            &ordered_items,
23896            &shapes,
23897            &brushes,
23898            &segment,
23899            ShapeBatchLimits::desktop(),
23900        )
23901        .expect("valid plan")
23902        .expect("overflowing gradient segment should be partitionable");
23903
23904        assert_eq!(partitions.len(), 2);
23905        assert_eq!(
23906            partitions[0],
23907            NativeSegmentFusionPartition {
23908                chunk: chunk(&[SegmentBatchPlan::Shape {
23909                    start: 0,
23910                    end: 2,
23911                    blend_mode: BlendMode::SrcOver,
23912                }]),
23913                budget: NativeSegmentFusionBudget {
23914                    shape_count: 2,
23915                    gradient_stop_count: MAX_GRADIENT_STOPS,
23916                },
23917            }
23918        );
23919        assert_eq!(
23920            partitions[1],
23921            NativeSegmentFusionPartition {
23922                chunk: chunk(&[SegmentBatchPlan::Shape {
23923                    start: 2,
23924                    end: 3,
23925                    blend_mode: BlendMode::SrcOver,
23926                }]),
23927                budget: NativeSegmentFusionBudget {
23928                    shape_count: 1,
23929                    gradient_stop_count: STOPS_PER_SHAPE,
23930                },
23931            }
23932        );
23933    }
23934
23935    #[cfg(not(target_arch = "wasm32"))]
23936    #[test]
23937    fn native_segment_fusion_accepts_layer_composite_chunks() {
23938        let ordered_items = vec![
23939            (0, SegmentDrawItem::Shape(0)),
23940            (1, SegmentDrawItem::Composite(0)),
23941            (2, SegmentDrawItem::ShaderComposite(0)),
23942            (3, SegmentDrawItem::Shape(1)),
23943        ];
23944        let shapes = vec![
23945            test_shape(0, BlendMode::SrcOver),
23946            test_shape(1, BlendMode::SrcOver),
23947        ];
23948        let segment = chunk(&[
23949            SegmentBatchPlan::Shape {
23950                start: 0,
23951                end: 1,
23952                blend_mode: BlendMode::SrcOver,
23953            },
23954            SegmentBatchPlan::Composite { start: 1, end: 2 },
23955            SegmentBatchPlan::ShaderComposite { start: 2, end: 3 },
23956            SegmentBatchPlan::Shape {
23957                start: 3,
23958                end: 4,
23959                blend_mode: BlendMode::SrcOver,
23960            },
23961        ]);
23962
23963        let partitions = native_segment_fusion_partitions(
23964            &ordered_items,
23965            &shapes,
23966            &[],
23967            &segment,
23968            ShapeBatchLimits::desktop(),
23969        )
23970        .expect("valid plan")
23971        .expect("composites are drawable inside the native fused pass");
23972
23973        assert_eq!(
23974            partitions,
23975            vec![NativeSegmentFusionPartition {
23976                chunk: segment,
23977                budget: NativeSegmentFusionBudget {
23978                    shape_count: 2,
23979                    gradient_stop_count: 0,
23980                },
23981            }],
23982            "layer composites and shader composites must preserve order without forcing separate render passes"
23983        );
23984    }
23985
23986    #[cfg(not(target_arch = "wasm32"))]
23987    #[test]
23988    fn native_segment_fusion_partitions_preserve_non_shape_order_at_budget_boundary() {
23989        // The uniform batch cap is derived from the device binding size and
23990        // the 112-byte ShapeData, not from the compile-time ceiling.
23991        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
23992        let ordered_items: Vec<_> = (0..desktop_batch_cap)
23993            .map(|index| (index, SegmentDrawItem::Shape(index)))
23994            .chain([
23995                (desktop_batch_cap, SegmentDrawItem::Image(0)),
23996                (
23997                    desktop_batch_cap + 1,
23998                    SegmentDrawItem::Shape(desktop_batch_cap),
23999                ),
24000            ])
24001            .collect();
24002        let shapes: Vec<_> = (0..=desktop_batch_cap)
24003            .map(|index| test_shape(index, BlendMode::SrcOver))
24004            .collect();
24005        let segment = chunk(&[
24006            SegmentBatchPlan::Shape {
24007                start: 0,
24008                end: desktop_batch_cap,
24009                blend_mode: BlendMode::SrcOver,
24010            },
24011            SegmentBatchPlan::Image {
24012                start: desktop_batch_cap,
24013                end: desktop_batch_cap + 1,
24014                blend_mode: BlendMode::SrcOver,
24015            },
24016            SegmentBatchPlan::Shape {
24017                start: desktop_batch_cap + 1,
24018                end: desktop_batch_cap + 2,
24019                blend_mode: BlendMode::SrcOver,
24020            },
24021        ]);
24022
24023        let partitions = native_segment_fusion_partitions(
24024            &ordered_items,
24025            &shapes,
24026            &[],
24027            &segment,
24028            ShapeBatchLimits::desktop(),
24029        )
24030        .expect("valid plan")
24031        .expect("overflowing segment should be partitionable");
24032
24033        assert_eq!(partitions.len(), 2);
24034        assert_eq!(
24035            partitions[0].chunk,
24036            chunk(&[
24037                SegmentBatchPlan::Shape {
24038                    start: 0,
24039                    end: desktop_batch_cap,
24040                    blend_mode: BlendMode::SrcOver,
24041                },
24042                SegmentBatchPlan::Image {
24043                    start: desktop_batch_cap,
24044                    end: desktop_batch_cap + 1,
24045                    blend_mode: BlendMode::SrcOver,
24046                },
24047            ])
24048        );
24049        assert_eq!(
24050            partitions[1].chunk,
24051            chunk(&[SegmentBatchPlan::Shape {
24052                start: desktop_batch_cap + 1,
24053                end: desktop_batch_cap + 2,
24054                blend_mode: BlendMode::SrcOver,
24055            }])
24056        );
24057    }
24058
24059    #[test]
24060    fn segment_command_iter_keeps_repeated_batch_kinds_in_one_chunk() {
24061        let ordered_items = vec![
24062            (0, SegmentDrawItem::Shape(0)),
24063            (1, SegmentDrawItem::Image(0)),
24064            (2, SegmentDrawItem::Shape(1)),
24065        ];
24066        let shapes = vec![
24067            test_shape(0, BlendMode::SrcOver),
24068            test_shape(2, BlendMode::DstOut),
24069        ];
24070        let images = vec![test_image(1, BlendMode::SrcOver)];
24071
24072        let commands: Vec<_> = SegmentCommandIter::new(
24073            &ordered_items,
24074            &shapes,
24075            &images,
24076            ShapeBatchLimits::desktop(),
24077        )
24078        .collect();
24079
24080        assert_eq!(
24081            commands,
24082            vec![SegmentRenderCommand::DrawChunk(chunk(&[
24083                SegmentBatchPlan::Shape {
24084                    start: 0,
24085                    end: 1,
24086                    blend_mode: BlendMode::SrcOver,
24087                },
24088                SegmentBatchPlan::Image {
24089                    start: 1,
24090                    end: 2,
24091                    blend_mode: BlendMode::SrcOver,
24092                },
24093                SegmentBatchPlan::Shape {
24094                    start: 2,
24095                    end: 3,
24096                    blend_mode: BlendMode::DstOut,
24097                },
24098            ]))]
24099        );
24100    }
24101
24102    #[test]
24103    fn segment_command_iter_splits_contiguous_shape_runs_at_uniform_batch_limit() {
24104        // The uniform batch cap is derived from the device binding size and
24105        // the 112-byte ShapeData, not from the compile-time ceiling.
24106        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
24107        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
24108            .map(|index| (index, SegmentDrawItem::Shape(index)))
24109            .collect();
24110        let shapes: Vec<_> = (0..=desktop_batch_cap)
24111            .map(|index| test_shape(index, BlendMode::SrcOver))
24112            .collect();
24113        let images = Vec::new();
24114
24115        let commands: Vec<_> = SegmentCommandIter::new(
24116            &ordered_items,
24117            &shapes,
24118            &images,
24119            ShapeBatchLimits::desktop(),
24120        )
24121        .collect();
24122
24123        assert_eq!(
24124            commands,
24125            vec![SegmentRenderCommand::DrawChunk(chunk(&[
24126                SegmentBatchPlan::Shape {
24127                    start: 0,
24128                    end: desktop_batch_cap,
24129                    blend_mode: BlendMode::SrcOver,
24130                },
24131                SegmentBatchPlan::Shape {
24132                    start: desktop_batch_cap,
24133                    end: desktop_batch_cap + 1,
24134                    blend_mode: BlendMode::SrcOver,
24135                },
24136            ]))]
24137        );
24138    }
24139
24140    #[test]
24141    fn segment_command_iter_keeps_shadows_as_explicit_boundaries() {
24142        let ordered_items = vec![
24143            (0, SegmentDrawItem::Shape(0)),
24144            (1, SegmentDrawItem::Shadow(0)),
24145            (2, SegmentDrawItem::Image(0)),
24146            (3, SegmentDrawItem::Text(0)),
24147        ];
24148        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
24149        let images = vec![test_image(2, BlendMode::SrcOver)];
24150
24151        let commands: Vec<_> = SegmentCommandIter::new(
24152            &ordered_items,
24153            &shapes,
24154            &images,
24155            ShapeBatchLimits::desktop(),
24156        )
24157        .collect();
24158
24159        assert_eq!(
24160            commands,
24161            vec![
24162                SegmentRenderCommand::DrawChunk(chunk(&[SegmentBatchPlan::Shape {
24163                    start: 0,
24164                    end: 1,
24165                    blend_mode: BlendMode::SrcOver,
24166                }])),
24167                SegmentRenderCommand::Shadow(0),
24168                SegmentRenderCommand::DrawChunk(chunk(&[
24169                    SegmentBatchPlan::Image {
24170                        start: 2,
24171                        end: 3,
24172                        blend_mode: BlendMode::SrcOver,
24173                    },
24174                    SegmentBatchPlan::Text { start: 3, end: 4 },
24175                ])),
24176            ]
24177        );
24178    }
24179
24180    #[test]
24181    fn staged_buffer_uploads_align_new_copies_to_copy_buffer_alignment() {
24182        let mut uploads = StagedBufferUploads::default();
24183        uploads.bytes.extend_from_slice(&[1, 2]);
24184
24185        uploads.stage(UploadTarget::ImageIndex, &[3, 4, 5, 6]);
24186
24187        assert_eq!(uploads.bytes, vec![1, 2, 0, 0, 3, 4, 5, 6]);
24188        assert_eq!(
24189            uploads.copies,
24190            vec![PendingBufferCopy {
24191                source_offset: 4,
24192                target_offset: 0,
24193                size: 4,
24194                target: UploadTarget::ImageIndex,
24195            }]
24196        );
24197    }
24198
24199    #[test]
24200    fn staged_buffer_uploads_ignore_empty_payloads() {
24201        let mut uploads = StagedBufferUploads::default();
24202
24203        uploads.stage(UploadTarget::Uniform, &[]);
24204
24205        assert!(uploads.is_empty());
24206        assert!(uploads.bytes.is_empty());
24207    }
24208
24209    #[test]
24210    fn staged_buffer_uploads_return_exact_payload_slice_for_copy() {
24211        let mut uploads = StagedBufferUploads::default();
24212        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
24213        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
24214
24215        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
24216        assert_eq!(uploads.payload_for_copy(uploads.copies[1]), &[5, 6, 7, 8]);
24217    }
24218
24219    #[test]
24220    fn staged_buffer_uploads_record_destination_offsets() {
24221        let mut uploads = StagedBufferUploads::default();
24222
24223        uploads.stage_at(UploadTarget::ImageIndex, 256, &[1, 2, 3, 4]);
24224
24225        assert_eq!(uploads.copies[0].target_offset, 256);
24226        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
24227    }
24228
24229    #[test]
24230    fn staged_buffer_uploads_truncate_restores_previous_state() {
24231        let mut uploads = StagedBufferUploads::default();
24232        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
24233        let bytes_len = uploads.bytes.len();
24234        let copies_len = uploads.copies.len();
24235        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
24236
24237        uploads.truncate(bytes_len, copies_len);
24238
24239        assert_eq!(uploads.bytes, vec![1, 2, 3, 4]);
24240        assert_eq!(uploads.copies.len(), 1);
24241    }
24242
24243    #[test]
24244    fn inner_shadow_composite_mask_uses_fill_shape_and_scale() {
24245        let mut fill = test_shape(0, BlendMode::SrcOver);
24246        fill.local_rect = Rect {
24247            x: 10.0,
24248            y: 12.0,
24249            width: 40.0,
24250            height: 20.0,
24251        };
24252        fill.shape = Some(RoundedCornerShape::uniform(6.0));
24253
24254        let cutout = test_shape(1, BlendMode::DstOut);
24255        let shadow = test_shadow_draw(vec![
24256            (fill, BlendMode::SrcOver),
24257            (cutout, BlendMode::DstOut),
24258        ]);
24259
24260        let mask = inner_shadow_composite_mask(&shadow, 1.5).expect("inner mask expected");
24261        assert_eq!(mask.rect, [15.0, 18.0, 60.0, 30.0]);
24262        assert_eq!(mask.radii, [9.0, 9.0, 9.0, 9.0]);
24263    }
24264
24265    #[test]
24266    fn inner_shadow_composite_mask_is_none_without_dst_out() {
24267        let fill = test_shape(0, BlendMode::SrcOver);
24268        let shadow = test_shadow_draw(vec![(fill, BlendMode::SrcOver)]);
24269        assert!(inner_shadow_composite_mask(&shadow, 1.0).is_none());
24270    }
24271
24272    #[test]
24273    fn render_effect_support_matrix_covers_all_variants() {
24274        let blur = RenderEffect::blur(4.0);
24275        let offset = RenderEffect::offset(2.0, 3.0);
24276        let shader = RenderEffect::runtime_shader(cranpose_ui_graphics::RuntimeShader::new(
24277            r#"
24278            @group(0) @binding(0) var input_texture: texture_2d<f32>;
24279            @group(0) @binding(1) var input_sampler: sampler;
24280            @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
24281            struct VertexOutput {
24282                @builtin(position) position: vec4<f32>,
24283                @location(0) uv: vec2<f32>,
24284            }
24285            @vertex
24286            fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
24287                var output: VertexOutput;
24288                let x = f32(i32(vertex_index & 1u) * 2 - 1);
24289                let y = f32(i32(vertex_index >> 1u) * 2 - 1);
24290                output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
24291                output.position = vec4<f32>(x, y, 0.0, 1.0);
24292                return output;
24293            }
24294            @fragment
24295            fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
24296                return textureSample(input_texture, input_sampler, input.uv);
24297            }
24298            "#,
24299        ));
24300        let chain = blur.clone().then(offset.clone());
24301
24302        assert!(is_render_effect_supported(&blur));
24303        assert!(is_render_effect_supported(&offset));
24304        assert!(is_render_effect_supported(&shader));
24305        assert!(is_render_effect_supported(&chain));
24306    }
24307
24308    #[test]
24309    fn clip_to_bounds_propagates_visual_clip_to_all_descendant_shapes() {
24310        // Simulates: root → clip_to_bounds container → child with shapes above/below clip
24311        // All shapes inside the clip_to_bounds container must have a clip set.
24312        let container_local_bounds = Rect {
24313            x: 0.0,
24314            y: 0.0,
24315            width: 800.0,
24316            height: 500.0,
24317        };
24318        // Container is placed at y=50 in parent space via transform_to_parent
24319        let container_clip_in_parent = Rect {
24320            x: 0.0,
24321            y: 50.0,
24322            width: 800.0,
24323            height: 500.0,
24324        };
24325
24326        // Shape that extends above the clip boundary (scroll content scrolled up)
24327        let shape_above = RenderNode::Primitive(PrimitiveEntry {
24328            phase: PrimitivePhase::BeforeChildren,
24329            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24330                primitive: DrawPrimitive::Rect {
24331                    rect: Rect {
24332                        x: 10.0,
24333                        y: -30.0,
24334                        width: 100.0,
24335                        height: 40.0,
24336                    },
24337                    brush: Brush::solid(Color::WHITE),
24338                    stroke: None,
24339                },
24340                clip: None,
24341            }),
24342        });
24343
24344        // Shape within the clip boundary
24345        let shape_inside = RenderNode::Primitive(PrimitiveEntry {
24346            phase: PrimitivePhase::BeforeChildren,
24347            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24348                primitive: DrawPrimitive::Rect {
24349                    rect: Rect {
24350                        x: 10.0,
24351                        y: 100.0,
24352                        width: 100.0,
24353                        height: 40.0,
24354                    },
24355                    brush: Brush::solid(Color::WHITE),
24356                    stroke: None,
24357                },
24358                clip: None,
24359            }),
24360        });
24361
24362        // Shape below the clip boundary (scroll content below viewport)
24363        let shape_below = RenderNode::Primitive(PrimitiveEntry {
24364            phase: PrimitivePhase::BeforeChildren,
24365            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24366                primitive: DrawPrimitive::Rect {
24367                    rect: Rect {
24368                        x: 10.0,
24369                        y: 600.0,
24370                        width: 100.0,
24371                        height: 40.0,
24372                    },
24373                    brush: Brush::solid(Color::WHITE),
24374                    stroke: None,
24375                },
24376                clip: None,
24377            }),
24378        });
24379
24380        // Content child layer (represents scroll content, translated up by scroll offset)
24381        let mut content_layer = test_layer(
24382            Rect {
24383                x: 0.0,
24384                y: 0.0,
24385                width: 800.0,
24386                height: 1000.0,
24387            },
24388            vec![shape_above, shape_inside, shape_below],
24389        );
24390        content_layer.transform_to_parent = ProjectiveTransform::translation(0.0, -30.0);
24391        content_layer.translated_content_context = true;
24392
24393        // Clip container (e.g. TabContent with clip_to_bounds)
24394        let mut clip_container = test_layer(
24395            container_local_bounds,
24396            vec![RenderNode::Layer(Box::new(content_layer))],
24397        );
24398        clip_container.clip_to_bounds = true;
24399        clip_container.transform_to_parent = ProjectiveTransform::translation(0.0, 50.0);
24400
24401        // Root
24402        let root = test_layer(
24403            Rect {
24404                x: 0.0,
24405                y: 0.0,
24406                width: 800.0,
24407                height: 600.0,
24408            },
24409            vec![RenderNode::Layer(Box::new(clip_container))],
24410        );
24411
24412        let mut rect_cache = HashMap::new();
24413        let mut requirements_cache = HashMap::new();
24414        let collected =
24415            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24416
24417        assert_eq!(
24418            collected.scene.shapes.len(),
24419            3,
24420            "all three shapes should be flattened into the scene"
24421        );
24422
24423        for (i, shape) in collected.scene.shapes.iter().enumerate() {
24424            assert!(
24425                shape.clip.is_some(),
24426                "shape {} at rect {:?} must have a clip from clip_to_bounds container, but clip is None",
24427                i,
24428                shape.rect
24429            );
24430            let clip = shape.clip.unwrap();
24431            assert_eq!(
24432                clip, container_clip_in_parent,
24433                "shape {} clip should match the clip_to_bounds container bounds in parent space",
24434                i
24435            );
24436        }
24437    }
24438
24439    #[test]
24440    fn clip_to_bounds_culls_child_layers_outside_boundary() {
24441        // Reproduces the out-of-clip rendering bug: a child layer with
24442        // graphics_layer.clip=true (e.g. from rounded_surface()) positioned
24443        // entirely below the parent's clip_to_bounds boundary must be culled.
24444        // Before the fix, resolve_clip returned None for non-overlapping rects,
24445        // which downstream code interpreted as "no clipping" instead of "fully clipped",
24446        // causing invisible content to render everywhere.
24447
24448        let clip_container_bounds = Rect {
24449            x: 0.0,
24450            y: 0.0,
24451            width: 800.0,
24452            height: 500.0,
24453        };
24454
24455        let shape_in_card = RenderNode::Primitive(PrimitiveEntry {
24456            phase: PrimitivePhase::BeforeChildren,
24457            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24458                primitive: DrawPrimitive::Rect {
24459                    rect: Rect {
24460                        x: 0.0,
24461                        y: 0.0,
24462                        width: 300.0,
24463                        height: 80.0,
24464                    },
24465                    brush: Brush::solid(Color::WHITE),
24466                    stroke: None,
24467                },
24468                clip: None,
24469            }),
24470        });
24471
24472        // Card layer with graphics_layer.clip=true, positioned BELOW the clip boundary
24473        let mut card_outside = crate::test_support::layer_node(
24474            Rect {
24475                x: 0.0,
24476                y: 0.0,
24477                width: 300.0,
24478                height: 80.0,
24479            },
24480            ProjectiveTransform::identity(),
24481            GraphicsLayer {
24482                clip: true,
24483                ..GraphicsLayer::default()
24484            },
24485            vec![shape_in_card.clone()],
24486        );
24487        card_outside.transform_to_parent = ProjectiveTransform::translation(10.0, 600.0);
24488
24489        // Card layer with graphics_layer.clip=true, positioned INSIDE the clip boundary
24490        let mut card_inside = crate::test_support::layer_node(
24491            Rect {
24492                x: 0.0,
24493                y: 0.0,
24494                width: 300.0,
24495                height: 80.0,
24496            },
24497            ProjectiveTransform::identity(),
24498            GraphicsLayer {
24499                clip: true,
24500                ..GraphicsLayer::default()
24501            },
24502            vec![shape_in_card],
24503        );
24504        card_inside.transform_to_parent = ProjectiveTransform::translation(10.0, 100.0);
24505
24506        // Content layer holding both cards
24507        let content = test_layer(
24508            Rect {
24509                x: 0.0,
24510                y: 0.0,
24511                width: 800.0,
24512                height: 1000.0,
24513            },
24514            vec![
24515                RenderNode::Layer(Box::new(card_inside)),
24516                RenderNode::Layer(Box::new(card_outside)),
24517            ],
24518        );
24519
24520        // Clip container
24521        let mut clip_container = test_layer(
24522            clip_container_bounds,
24523            vec![RenderNode::Layer(Box::new(content))],
24524        );
24525        clip_container.clip_to_bounds = true;
24526
24527        // Root
24528        let root = test_layer(
24529            Rect {
24530                x: 0.0,
24531                y: 0.0,
24532                width: 800.0,
24533                height: 600.0,
24534            },
24535            vec![RenderNode::Layer(Box::new(clip_container))],
24536        );
24537
24538        let mut rect_cache = HashMap::new();
24539        let mut requirements_cache = HashMap::new();
24540        let collected =
24541            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24542
24543        assert_eq!(
24544            collected.scene.shapes.len(),
24545            1,
24546            "only the card inside the clip boundary should produce shapes; \
24547             the card outside must be culled entirely"
24548        );
24549
24550        let shape = &collected.scene.shapes[0];
24551        assert!(
24552            shape.clip.is_some(),
24553            "the visible card's shape must have a clip from clip_to_bounds"
24554        );
24555    }
24556
24557    #[test]
24558    fn flattened_layer_shadow_z_index_is_below_content() {
24559        // Shadow must render behind content. When a child layer with shadow_elevation
24560        // is flattened (no isolation), its shadow z-index must be lower than any
24561        // content z-index so shadow draws render first.
24562        let shape = RenderNode::Primitive(PrimitiveEntry {
24563            phase: PrimitivePhase::BeforeChildren,
24564            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24565                primitive: DrawPrimitive::Rect {
24566                    rect: Rect {
24567                        x: 0.0,
24568                        y: 0.0,
24569                        width: 100.0,
24570                        height: 100.0,
24571                    },
24572                    brush: Brush::solid(Color::WHITE),
24573                    stroke: None,
24574                },
24575                clip: None,
24576            }),
24577        });
24578
24579        let child_bounds = Rect {
24580            x: 0.0,
24581            y: 0.0,
24582            width: 100.0,
24583            height: 100.0,
24584        };
24585
24586        let child = crate::test_support::layer_node(
24587            child_bounds,
24588            ProjectiveTransform::translation(50.0, 50.0),
24589            GraphicsLayer {
24590                shadow_elevation: 20.0,
24591                ..GraphicsLayer::default()
24592            },
24593            vec![shape],
24594        );
24595
24596        let root = test_layer(
24597            Rect {
24598                x: 0.0,
24599                y: 0.0,
24600                width: 800.0,
24601                height: 600.0,
24602            },
24603            vec![RenderNode::Layer(Box::new(child))],
24604        );
24605
24606        let mut rect_cache = HashMap::new();
24607        let mut requirements_cache = HashMap::new();
24608        let collected =
24609            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24610
24611        assert!(
24612            !collected.scene.shadow_draws.is_empty(),
24613            "shadow_elevation > 0 must produce shadow draws"
24614        );
24615        let max_shadow_z = collected
24616            .scene
24617            .shadow_draws
24618            .iter()
24619            .map(|s| s.z_index)
24620            .max()
24621            .unwrap();
24622        let min_content_z = collected
24623            .scene
24624            .shapes
24625            .iter()
24626            .map(|s| s.z_index)
24627            .min()
24628            .unwrap();
24629        assert!(
24630            max_shadow_z < min_content_z,
24631            "shadow z-index ({}) must be less than content z-index ({}); \
24632             shadows must render behind their content",
24633            max_shadow_z,
24634            min_content_z
24635        );
24636    }
24637
24638    /// One retained bundle op key with the fields the invalidation tests
24639    /// vary; the rest stay representative constants.
24640    #[cfg(not(target_arch = "wasm32"))]
24641    fn bundle_op(slot: u32, epoch: Option<u64>, first: u32, last: u32) -> RetainedBundleOpKey {
24642        RetainedBundleOpKey {
24643            slot,
24644            capture_epoch: epoch,
24645            first,
24646            last,
24647            retained_index: slot,
24648            has_mesh: false,
24649        }
24650    }
24651
24652    #[cfg(not(target_arch = "wasm32"))]
24653    fn bundle_key(ops: &[RetainedBundleOpKey]) -> RetainedBundleKey {
24654        RetainedBundleKey {
24655            depth: false,
24656            ops: ops.to_vec(),
24657        }
24658    }
24659
24660    /// The same stretch on consecutive frames reuses its bundle: one
24661    /// rebuild, then cached executes.
24662    #[cfg(not(target_arch = "wasm32"))]
24663    #[test]
24664    fn retained_bundle_cache_reuses_stable_keys() {
24665        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24666        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
24667        let key = bundle_key(&ops);
24668
24669        assert!(!cache.hit(&key), "empty cache must miss");
24670        cache.insert(key.clone(), 111);
24671        assert_eq!(cache.get(&key), Some(&111));
24672        cache.end_frame();
24673
24674        for _ in 0..3 {
24675            assert!(cache.hit(&bundle_key(&ops)), "stable key must stay cached");
24676            cache.end_frame();
24677        }
24678        assert_eq!(cache.stats(), (1, 3), "one rebuild, three cached executes");
24679    }
24680
24681    /// Recapture (epoch bump), span reorder, count change, range change and
24682    /// slot release each change the key, so a stale bundle can never satisfy
24683    /// the lookup.
24684    #[cfg(not(target_arch = "wasm32"))]
24685    #[test]
24686    fn retained_bundle_cache_invalidates_on_any_op_change() {
24687        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
24688        let variants: [Vec<RetainedBundleOpKey>; 5] = [
24689            // Recaptured slot 3: same id, bumped epoch.
24690            vec![bundle_op(3, Some(8), 0, 40), bundle_op(5, Some(9), 4, 12)],
24691            // Reordered stretch.
24692            vec![bundle_op(5, Some(9), 4, 12), bundle_op(3, Some(7), 0, 40)],
24693            // Op count changed.
24694            vec![bundle_op(3, Some(7), 0, 40)],
24695            // Draw range changed.
24696            vec![bundle_op(3, Some(7), 0, 41), bundle_op(5, Some(9), 4, 12)],
24697            // Slot 5 released: epoch gone.
24698            vec![bundle_op(3, Some(7), 0, 40), bundle_op(5, None, 4, 12)],
24699        ];
24700        for changed in variants {
24701            let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24702            cache.insert(bundle_key(&ops), 111);
24703            cache.end_frame();
24704            assert!(
24705                !cache.hit(&RetainedBundleKey {
24706                    depth: false,
24707                    ops: changed.clone()
24708                }),
24709                "changed key {changed:?} must not reuse the stale bundle"
24710            );
24711        }
24712    }
24713
24714    /// A stretch encoded for the display-clip culled pass (depth
24715    /// attachment, depth-variant pipelines) must never satisfy the flat
24716    /// pass's lookup — and vice versa.
24717    #[cfg(not(target_arch = "wasm32"))]
24718    #[test]
24719    fn retained_bundle_cache_keys_depth_variants_apart() {
24720        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24721        let ops = vec![bundle_op(3, Some(7), 0, 40)];
24722        cache.insert(
24723            RetainedBundleKey {
24724                depth: false,
24725                ops: ops.clone(),
24726            },
24727            111,
24728        );
24729        cache.end_frame();
24730        assert!(
24731            !cache.hit(&RetainedBundleKey { depth: true, ops }),
24732            "a flat bundle must not replay into the display-clip culled pass"
24733        );
24734    }
24735
24736    /// Entries a frame does not use are evicted at its end — bundles pin
24737    /// slot buffers, so unused ones must not accumulate — and `clear` (the
24738    /// slot-release path) empties the cache outright.
24739    #[cfg(not(target_arch = "wasm32"))]
24740    #[test]
24741    fn retained_bundle_cache_evicts_unused_entries() {
24742        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24743        let stale = bundle_key(&[bundle_op(1, Some(1), 0, 6)]);
24744        let live = bundle_key(&[bundle_op(2, Some(2), 0, 6)]);
24745        cache.insert(stale.clone(), 1);
24746        cache.insert(live.clone(), 2);
24747        cache.end_frame();
24748
24749        assert!(cache.hit(&live));
24750        cache.end_frame();
24751
24752        assert!(
24753            !cache.hit(&stale),
24754            "entry unused for a frame must have been evicted"
24755        );
24756        assert!(cache.hit(&live), "used entry must survive eviction");
24757
24758        cache.clear();
24759        assert!(!cache.hit(&live), "clear must drop every entry");
24760    }
24761}