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::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(collected: &CollectedLayer) -> bool {
447    for (child_index, child) in collected.child_layers.iter().enumerate() {
448        if child.backdrop.is_some() {
449            if root_direct_diag_enabled() {
450                log::warn!(
451                    "[root-direct-diag] reject self-backdrop child node={:?}",
452                    child.node_id
453                );
454            }
455            return false;
456        }
457        if child.needs_nested_underlay {
458            let Some(dest_rect) = axis_aligned_quad_rect(child.dest_quad) else {
459                if root_direct_diag_enabled() {
460                    log::warn!(
461                        "[root-direct-diag] reject projective underlay child node={:?}",
462                        child.node_id
463                    );
464                }
465                return false;
466            };
467            let translation_only = (dest_rect.width - child.logical_rect.width).abs() <= 0.001
468                && (dest_rect.height - child.logical_rect.height).abs() <= 0.001;
469            let unsupported_preceding_child_layer = collected.child_layers[..child_index]
470                .iter()
471                .any(|preceding| {
472                    if direct_root_child_can_be_replayed_into_later_underlay(preceding) {
473                        return false;
474                    }
475                    axis_aligned_quad_rect(preceding.dest_quad)
476                        .is_none_or(|preceding_rect| rects_overlap(preceding_rect, dest_rect))
477                });
478            let preceding_scene_events =
479                scene_layer_events_precede_z(&collected.scene, child.z_index);
480            if unsupported_preceding_child_layer || preceding_scene_events || !translation_only {
481                if root_direct_diag_enabled() {
482                    log::warn!(
483                        "[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})",
484                        child.node_id,
485                        unsupported_preceding_child_layer,
486                        preceding_scene_events,
487                        translation_only,
488                        dest_rect.x,
489                        dest_rect.y,
490                        dest_rect.width,
491                        dest_rect.height,
492                        child.logical_rect.x,
493                        child.logical_rect.y,
494                        child.logical_rect.width,
495                        child.logical_rect.height
496                    );
497                }
498                return false;
499            }
500        }
501    }
502    true
503}
504
505#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
506struct ShadowSurfaceCacheKey {
507    content_hash: u64,
508    pixel_size: [u32; 2],
509    root_scale_bits: u32,
510    blur_radius_bits: u32,
511}
512
513struct CachedShadowSurface {
514    target: Rc<OffscreenTarget>,
515    byte_size: u64,
516}
517
518struct CachedShadowComposite {
519    source: Rc<OffscreenTarget>,
520    scissor: Option<(u32, u32, u32, u32)>,
521    rounded_mask: Option<RoundedCompositeMask>,
522    dest_viewport: Option<(f32, f32, f32, f32)>,
523}
524
525impl CachedShadowComposite {
526    fn batch_item(&self) -> CompositeBatchItem<'_> {
527        CompositeBatchItem {
528            source: &self.source,
529            alpha: 1.0,
530            scissor: self.scissor,
531            rounded_mask: self.rounded_mask,
532            blend_mode: BlendMode::SrcOver,
533            dest_viewport: self.dest_viewport,
534            source_viewport: None,
535            sample_mode: CompositeSampleMode::Nearest,
536        }
537    }
538}
539
540#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
541struct TextImageCacheKey(u64);
542
543struct CachedTextImage {
544    image: ImageBitmap,
545}
546
547#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
548struct TextGlyphRunCacheKey(u64);
549
550#[derive(Clone, Copy)]
551struct CachedTextGlyphQuad {
552    x: i32,
553    y: i32,
554    width: usize,
555    height: usize,
556    color: (f32, f32, f32, f32),
557    uv: ImageUvRect,
558}
559
560struct CachedTextGlyphRun {
561    glyphs: Rc<[SoftwareGlyphAtlasPlacement]>,
562    quads: Option<Rc<[CachedTextGlyphQuad]>>,
563    atlas_generation: u64,
564}
565
566const TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER: f32 = 2.0;
567
568#[cfg(not(target_arch = "wasm32"))]
569struct CachedGpuTextGlyphRun {
570    vertex_buffer: wgpu::Buffer,
571    index_buffer: wgpu::Buffer,
572    index_count: u32,
573    atlas_generation: u64,
574}
575
576#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
577struct TextLineIndexCacheKey(usize);
578
579struct CachedTextLineIndex {
580    text: std::sync::Weak<cranpose_ui::text::RenderString>,
581    len: usize,
582    starts: Rc<[usize]>,
583}
584
585struct TextLineIndexCache {
586    entries: BoundedLruCache<TextLineIndexCacheKey, CachedTextLineIndex>,
587}
588
589impl TextLineIndexCache {
590    fn new(capacity: usize) -> Self {
591        Self {
592            entries: BoundedLruCache::with_capacity_at_least_one(capacity),
593        }
594    }
595
596    fn line_starts(&mut self, text: &Arc<cranpose_ui::text::RenderString>) -> Rc<[usize]> {
597        let key = TextLineIndexCacheKey(Arc::as_ptr(text) as usize);
598        if let Some(cached) = self.entries.get(&key) {
599            if cached.len == text.text.len()
600                && cached
601                    .text
602                    .upgrade()
603                    .is_some_and(|cached_text| Arc::ptr_eq(&cached_text, text))
604            {
605                return cached.starts.clone();
606            }
607        }
608
609        let starts = Rc::<[usize]>::from(line_start_offsets(text.text.as_str()));
610        self.entries.put(
611            key,
612            CachedTextLineIndex {
613                text: Arc::downgrade(text),
614                len: text.text.len(),
615                starts: starts.clone(),
616            },
617        );
618        starts
619    }
620}
621
622#[derive(Clone, Copy, Debug, PartialEq)]
623struct ShapeShadowSurfacePlan {
624    source_device_bounds: DevicePixelBounds,
625    processing_scissor: Option<(u32, u32, u32, u32)>,
626    pixel_radius: f32,
627}
628
629/// Shared record of the device's uncaptured errors (validation, OOM,
630/// internal), written by the handler [`GpuRenderer::new`] installs via
631/// `Device::on_uncaptured_error` and read at the head of every
632/// [`GpuRenderer::render`].
633///
634/// wgpu's default handler panics on the reporting thread. Mid-encode that
635/// unwind runs the drop glue of live pass/encoder objects, whose own error
636/// reports re-enter the same panicking handler — a second panic inside the
637/// first's unwind aborts the process, and the tombstone carries neither
638/// message (a real device's validation failure was lost exactly this way).
639/// This handler never panics: it counts, logs the full error, and poisons;
640/// the render path answers with one cancelled packet per poisoning — the
641/// acquire path's give-up-this-frame semantics, not a latch.
642/// `CRANPOSE_SURVIVE_GPU_ERRORS=0` restores the fatal default
643/// ([`survive_gpu_errors_enabled`]).
644#[derive(Default)]
645struct DeviceErrorSentry {
646    /// Lifetime uncaptured errors on this device.
647    errors: std::sync::atomic::AtomicU64,
648    /// Set by the handler, taken (cleared) by the next frame's gate.
649    poisoned: std::sync::atomic::AtomicBool,
650}
651
652impl DeviceErrorSentry {
653    /// Never panics: this runs where the default handler would have
654    /// aborted the process (see the type doc).
655    fn record(&self, error: &wgpu::Error) {
656        use std::sync::atomic::Ordering;
657        self.poisoned.store(true, Ordering::Release);
658        let count = self.errors.fetch_add(1, Ordering::Relaxed) + 1;
659        // The full error every time it prints; rate-limited by count
660        // because one broken frame reports a follow-up error per
661        // subsequent encoder call. Power-of-two occurrences (1, 2, 4,
662        // 8, …) keep the first reports verbatim and decay the repeats
663        // without a clock; the count carries the volume.
664        if count.is_power_of_two() {
665            log::error!("[gpu-device] uncaptured wgpu error #{count}: {error}");
666        }
667    }
668
669    fn take_poison(&self) -> bool {
670        self.poisoned
671            .swap(false, std::sync::atomic::Ordering::AcqRel)
672    }
673
674    fn error_count(&self) -> u64 {
675        self.errors.load(std::sync::atomic::Ordering::Relaxed)
676    }
677}
678
679#[derive(Default)]
680struct RendererWarningState {
681    unsupported_effect_reported: Cell<bool>,
682}
683
684impl RendererWarningState {
685    fn warn_unsupported_effect_once(&self) {
686        if !self.unsupported_effect_reported.replace(true) {
687            log::warn!(
688                "WGPU renderer received an unsupported RenderEffect variant; falling back to passthrough compositing"
689            );
690        }
691    }
692}
693
694fn is_blend_mode_supported(mode: BlendMode) -> bool {
695    matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
696}
697
698fn blend_state_for_mode(mode: BlendMode) -> wgpu::BlendState {
699    match mode {
700        BlendMode::DstOut => wgpu::BlendState {
701            color: wgpu::BlendComponent {
702                src_factor: wgpu::BlendFactor::Zero,
703                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
704                operation: wgpu::BlendOperation::Add,
705            },
706            alpha: wgpu::BlendComponent {
707                src_factor: wgpu::BlendFactor::Zero,
708                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
709                operation: wgpu::BlendOperation::Add,
710            },
711        },
712        _ => wgpu::BlendState::ALPHA_BLENDING,
713    }
714}
715
716fn supported_blend_mode(mode: BlendMode) -> BlendMode {
717    if is_blend_mode_supported(mode) {
718        return mode;
719    }
720
721    BlendMode::SrcOver
722}
723
724fn direct_shader_composite_viewport(
725    alpha: f32,
726    blend_mode: BlendMode,
727    dest_viewport: Option<(f32, f32, f32, f32)>,
728    sample_mode: CompositeSampleMode,
729    source_size: (u32, u32),
730) -> Option<(f32, f32, f32, f32)> {
731    if alpha != 1.0 || supported_blend_mode(blend_mode) != BlendMode::SrcOver {
732        return None;
733    }
734    let viewport = dest_viewport?;
735    if viewport.2 <= 0.0 || viewport.3 <= 0.0 {
736        return None;
737    }
738    match sample_mode {
739        CompositeSampleMode::Linear | CompositeSampleMode::Nearest => Some(viewport),
740        CompositeSampleMode::Box4
741            if shader_composite_preserves_source_pixel_grid(viewport, source_size) =>
742        {
743            Some(viewport)
744        }
745        CompositeSampleMode::Box4 => None,
746    }
747}
748
749fn shader_composite_preserves_source_pixel_grid(
750    viewport: (f32, f32, f32, f32),
751    source_size: (u32, u32),
752) -> bool {
753    const EPSILON: f32 = 0.01;
754    let (x, y, width, height) = viewport;
755    let (source_width, source_height) = source_size;
756    (x - x.round()).abs() <= EPSILON
757        && (y - y.round()).abs() <= EPSILON
758        && (width - source_width as f32).abs() <= EPSILON
759        && (height - source_height as f32).abs() <= EPSILON
760}
761
762type DirectShaderTailComposite<'a> = (&'a RenderEffect, &'a RuntimeShader, (f32, f32, f32, f32));
763
764fn direct_shader_tail_composite(
765    effect: &RenderEffect,
766    alpha: f32,
767    blend_mode: BlendMode,
768    dest_viewport: Option<(f32, f32, f32, f32)>,
769    sample_mode: CompositeSampleMode,
770    source_size: (u32, u32),
771) -> Option<DirectShaderTailComposite<'_>> {
772    let viewport = direct_shader_composite_viewport(
773        alpha,
774        blend_mode,
775        dest_viewport,
776        sample_mode,
777        source_size,
778    )?;
779    let RenderEffect::Chain { first, second } = effect else {
780        return None;
781    };
782    let RenderEffect::Shader { shader } = second.as_ref() else {
783        return None;
784    };
785    Some((first.as_ref(), shader, viewport))
786}
787
788fn hash_f32_for_cache<H: Hasher>(value: f32, state: &mut H) {
789    value.to_bits().hash(state);
790}
791
792fn hash_text_raster_geometry_for_cache<H: Hasher>(
793    rect: Rect,
794    static_text_motion: bool,
795    state: &mut H,
796) {
797    hash_f32_for_cache(rect.width, state);
798    hash_f32_for_cache(rect.height, state);
799    static_text_motion.hash(state);
800    if !static_text_motion {
801        hash_f32_for_cache(rect.x.fract(), state);
802        hash_f32_for_cache(rect.y.fract(), state);
803    }
804}
805
806fn text_raster_geometry_for_draw(
807    text_draw: &TextDraw,
808    root_scale: f32,
809) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
810    if text_draw.text.is_empty()
811        || text_draw.rect.width <= 0.0
812        || text_draw.rect.height <= 0.0
813        || !root_scale.is_finite()
814        || root_scale <= 0.0
815    {
816        return None;
817    }
818
819    let text_scale = text_draw.scale * root_scale;
820    if !text_scale.is_finite() || text_scale <= 0.0 {
821        return None;
822    }
823
824    let static_text_motion = text_draw
825        .text_style
826        .paragraph_style
827        .text_motion
828        .unwrap_or(cranpose_ui::text::TextMotion::Static)
829        == cranpose_ui::text::TextMotion::Static;
830    let snap_delta = text_draw
831        .snap_anchor
832        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
833        .unwrap_or_default();
834    let logical_rect = text_draw.rect.translate(snap_delta.x, snap_delta.y);
835    // Clips are resolved in scene space from their own layer ancestry. A draw
836    // item's raster snap must never move a fixed ancestor clip.
837    let clip = text_draw.clip;
838    let mut raster_rect = Rect {
839        x: logical_rect.x * root_scale,
840        y: logical_rect.y * root_scale,
841        width: logical_rect.width * root_scale,
842        height: logical_rect.height * root_scale,
843    };
844    if text_draw.snap_anchor.is_some() {
845        raster_rect.x = canonicalize_device_coordinate(raster_rect.x);
846        raster_rect.y = canonicalize_device_coordinate(raster_rect.y);
847    }
848    if static_text_motion {
849        raster_rect.x = raster_rect.x.round();
850        raster_rect.y = raster_rect.y.round();
851    }
852    raster_rect.width = raster_rect.width.ceil().max(1.0);
853    raster_rect.height = raster_rect.height.ceil().max(1.0);
854    Some((
855        logical_rect,
856        raster_rect,
857        clip,
858        text_scale,
859        static_text_motion,
860    ))
861}
862
863fn text_draw_is_visible_in_viewport(
864    logical_rect: Rect,
865    clip: Option<Rect>,
866    viewport: ViewportUniformParams,
867    root_scale: f32,
868) -> bool {
869    draw_rect_is_visible_in_viewport(logical_rect, clip, viewport, root_scale)
870}
871
872fn text_draw_should_prewarm_in_viewport(
873    logical_rect: Rect,
874    clip: Option<Rect>,
875    viewport: ViewportUniformParams,
876    root_scale: f32,
877) -> bool {
878    if !root_scale.is_finite() || root_scale <= 0.0 {
879        return false;
880    }
881    let viewport_rect = Rect {
882        x: viewport.offset[0] / root_scale,
883        y: viewport.offset[1] / root_scale,
884        width: viewport.width as f32 / root_scale,
885        height: viewport.height as f32 / root_scale,
886    };
887    let margin_x = viewport_rect.width * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
888    let margin_y = viewport_rect.height * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
889    let prewarm_viewport = expand_rect(viewport_rect, margin_x, margin_y);
890    let prewarm_rect = match clip {
891        Some(clip) => expand_rect(clip, margin_x, margin_y).intersect(prewarm_viewport),
892        None => Some(prewarm_viewport),
893    };
894    prewarm_rect.is_some_and(|rect| logical_rect.intersect(rect).is_some())
895}
896
897fn expand_rect(rect: Rect, margin_x: f32, margin_y: f32) -> Rect {
898    Rect {
899        x: rect.x - margin_x,
900        y: rect.y - margin_y,
901        width: rect.width + margin_x * 2.0,
902        height: rect.height + margin_y * 2.0,
903    }
904}
905
906fn draw_rect_is_visible_in_viewport(
907    rect: Rect,
908    clip: Option<Rect>,
909    viewport: ViewportUniformParams,
910    root_scale: f32,
911) -> bool {
912    if !root_scale.is_finite() || root_scale <= 0.0 {
913        return false;
914    }
915    let viewport_rect = Rect {
916        x: viewport.offset[0] / root_scale,
917        y: viewport.offset[1] / root_scale,
918        width: viewport.width as f32 / root_scale,
919        height: viewport.height as f32 / root_scale,
920    };
921    let visible_rect = match clip {
922        Some(clip) => clip.intersect(viewport_rect),
923        None => Some(viewport_rect),
924    };
925    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
926}
927
928fn shape_draw_is_visible_in_viewport(
929    shape: &DrawShape,
930    viewport: ViewportUniformParams,
931    root_scale: f32,
932) -> bool {
933    let Some(viewport_rect) = viewport_rect_in_logical(viewport, root_scale) else {
934        return false;
935    };
936    shape_draw_is_visible_in_rect(shape, viewport_rect, root_scale)
937}
938
939/// The viewport in logical units, or `None` for a degenerate scale — the
940/// four divides are loop-invariant at every filter call site, so the hot
941/// paths derive this once per batch and test shapes against the result.
942fn viewport_rect_in_logical(viewport: ViewportUniformParams, root_scale: f32) -> Option<Rect> {
943    if !root_scale.is_finite() || root_scale <= 0.0 {
944        return None;
945    }
946    Some(Rect {
947        x: viewport.offset[0] / root_scale,
948        y: viewport.offset[1] / root_scale,
949        width: viewport.width as f32 / root_scale,
950        height: viewport.height as f32 / root_scale,
951    })
952}
953
954/// [`shape_draw_is_visible_in_viewport`] with the logical viewport rect
955/// already derived: identical decision, none of the per-shape divides.
956fn shape_draw_is_visible_in_rect(shape: &DrawShape, viewport_rect: Rect, root_scale: f32) -> bool {
957    let snap_delta = shape
958        .snap_anchor
959        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
960        .unwrap_or_default();
961    let rect = quad_bounds(translate_quad(shape.quad, snap_delta));
962    let visible_rect = match shape.clip {
963        Some(clip) => clip.intersect(viewport_rect),
964        None => Some(viewport_rect),
965    };
966    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
967}
968
969fn cached_text_glyph_quad(
970    glyph: &SoftwareGlyphAtlasPlacement,
971    entry: GlyphAtlasEntry,
972    atlas_size: u32,
973) -> CachedTextGlyphQuad {
974    CachedTextGlyphQuad {
975        x: glyph.x,
976        y: glyph.y,
977        width: glyph.width,
978        height: glyph.height,
979        color: (
980            glyph.color.0.clamp(0.0, 1.0),
981            glyph.color.1.clamp(0.0, 1.0),
982            glyph.color.2.clamp(0.0, 1.0),
983            glyph.color.3.clamp(0.0, 1.0),
984        ),
985        uv: glyph_atlas_uv_rect(entry, atlas_size),
986    }
987}
988
989fn append_cached_text_glyph_quad(
990    source_raster_rect: Rect,
991    quad: &CachedTextGlyphQuad,
992    image_vertices: &mut Vec<Vertex>,
993    image_indices: &mut Vec<u32>,
994) -> bool {
995    if quad.width == 0 || quad.height == 0 || quad.color.3 <= 0.0 {
996        return false;
997    }
998
999    let base_vertex = image_vertices.len() as u32;
1000    image_indices.extend_from_slice(&[
1001        base_vertex,
1002        base_vertex + 1,
1003        base_vertex + 2,
1004        base_vertex + 2,
1005        base_vertex + 1,
1006        base_vertex + 3,
1007    ]);
1008
1009    let x0 = source_raster_rect.x + quad.x as f32;
1010    let y0 = source_raster_rect.y + quad.y as f32;
1011    let x1 = x0 + quad.width as f32;
1012    let y1 = y0 + quad.height as f32;
1013    let color = [quad.color.0, quad.color.1, quad.color.2, quad.color.3];
1014
1015    image_vertices.extend_from_slice(&[
1016        Vertex {
1017            position: [x0, y0],
1018            color,
1019            uv: [quad.uv.min[0], quad.uv.min[1]],
1020            uv_bounds: quad.uv.sample_bounds,
1021        },
1022        Vertex {
1023            position: [x1, y0],
1024            color,
1025            uv: [quad.uv.max[0], quad.uv.min[1]],
1026            uv_bounds: quad.uv.sample_bounds,
1027        },
1028        Vertex {
1029            position: [x0, y1],
1030            color,
1031            uv: [quad.uv.min[0], quad.uv.max[1]],
1032            uv_bounds: quad.uv.sample_bounds,
1033        },
1034        Vertex {
1035            position: [x1, y1],
1036            color,
1037            uv: [quad.uv.max[0], quad.uv.max[1]],
1038            uv_bounds: quad.uv.sample_bounds,
1039        },
1040    ]);
1041    true
1042}
1043
1044fn cached_text_glyph_quad_logical_rect(
1045    source_raster_rect: Rect,
1046    quad: &CachedTextGlyphQuad,
1047    root_scale: f32,
1048) -> Option<Rect> {
1049    if !root_scale.is_finite() || root_scale <= 0.0 {
1050        return None;
1051    }
1052    Some(Rect {
1053        x: (source_raster_rect.x + quad.x as f32) / root_scale,
1054        y: (source_raster_rect.y + quad.y as f32) / root_scale,
1055        width: quad.width as f32 / root_scale,
1056        height: quad.height as f32 / root_scale,
1057    })
1058}
1059
1060fn cached_text_glyph_quad_is_visible_in_viewport(
1061    source_raster_rect: Rect,
1062    quad: &CachedTextGlyphQuad,
1063    clip: Option<Rect>,
1064    viewport: ViewportUniformParams,
1065    root_scale: f32,
1066) -> bool {
1067    cached_text_glyph_quad_logical_rect(source_raster_rect, quad, root_scale)
1068        .is_some_and(|rect| draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale))
1069}
1070
1071#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1072enum TextGlyphDrawAction {
1073    DrawVisible,
1074    PrewarmOffscreen,
1075    Skip,
1076}
1077
1078fn text_glyph_draw_action(
1079    is_visible: bool,
1080    is_prewarm_candidate: bool,
1081    allow_offscreen_prewarm: bool,
1082) -> TextGlyphDrawAction {
1083    if is_visible {
1084        TextGlyphDrawAction::DrawVisible
1085    } else if allow_offscreen_prewarm && is_prewarm_candidate {
1086        TextGlyphDrawAction::PrewarmOffscreen
1087    } else {
1088        TextGlyphDrawAction::Skip
1089    }
1090}
1091
1092#[cfg(not(target_arch = "wasm32"))]
1093fn should_use_retained_text_glyph_run(quads_len: usize, clip: Option<Rect>) -> bool {
1094    clip.is_none() && quads_len >= MIN_RETAINED_TEXT_GLYPH_QUADS
1095}
1096
1097#[cfg(not(target_arch = "wasm32"))]
1098fn offscreen_text_glyph_prewarm_work_is_bounded(
1099    cached_glyphs: Option<usize>,
1100    text_len: usize,
1101) -> bool {
1102    match cached_glyphs {
1103        Some(glyphs) => glyphs <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS,
1104        None => text_len <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
1105    }
1106}
1107
1108#[cfg(not(target_arch = "wasm32"))]
1109fn offscreen_text_glyph_prewarm_budget_exhausted(
1110    start: Instant,
1111    admitted_candidates: usize,
1112) -> bool {
1113    admitted_candidates >= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES
1114        || instant_ms(start, Instant::now()) >= OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS
1115}
1116
1117fn text_draws_for_ordered_range<'a>(
1118    ordered_items: &'a [(usize, SegmentDrawItem)],
1119    texts: &'a [TextDraw],
1120    start: usize,
1121    end: usize,
1122) -> Result<impl Iterator<Item = &'a TextDraw>, String> {
1123    let range_items = ordered_items
1124        .get(start..end)
1125        .ok_or_else(|| format!("text batch range {start}..{end} is outside ordered draw items"))?;
1126    for (_, item) in range_items {
1127        match item {
1128            SegmentDrawItem::Text(text_index) if *text_index < texts.len() => {}
1129            SegmentDrawItem::Text(text_index) => {
1130                return Err(format!(
1131                    "text batch references missing text draw index: {text_index}"
1132                ));
1133            }
1134            _ => return Err(format!("text batch contains non-text draw item: {item:?}")),
1135        }
1136    }
1137
1138    Ok(range_items.iter().filter_map(move |(_, item)| match item {
1139        SegmentDrawItem::Text(text_index) => texts.get(*text_index),
1140        _ => None,
1141    }))
1142}
1143
1144/// Shadow geometry is hashed in device pixels quantized to 1/16 px so rigid
1145/// translations reuse the cached blurred raster. The cached surface is
1146/// composited one-to-one with texel-exact sampling; translation may not change
1147/// either the blur or its sampling phase.
1148const SHADOW_CACHE_DEVICE_QUANT: f32 = 16.0;
1149
1150fn hash_shadow_device_offset<H: Hasher>(value: f32, origin: f32, root_scale: f32, state: &mut H) {
1151    let quantized = ((value - origin) * root_scale * SHADOW_CACHE_DEVICE_QUANT).round();
1152    (quantized as i64).hash(state);
1153}
1154
1155fn hash_shadow_device_rect<H: Hasher>(
1156    rect: Rect,
1157    origin_x: f32,
1158    origin_y: f32,
1159    root_scale: f32,
1160    state: &mut H,
1161) {
1162    hash_shadow_device_offset(rect.x, origin_x, root_scale, state);
1163    hash_shadow_device_offset(rect.y, origin_y, root_scale, state);
1164    hash_shadow_device_offset(rect.width, 0.0, root_scale, state);
1165    hash_shadow_device_offset(rect.height, 0.0, root_scale, state);
1166}
1167
1168fn hash_shape_shadow_item<H: Hasher>(
1169    shape: &DrawShape,
1170    brushes: &[Brush],
1171    blend_mode: BlendMode,
1172    origin_x: f32,
1173    origin_y: f32,
1174    root_scale: f32,
1175    state: &mut H,
1176) {
1177    hash_shadow_device_rect(shape.rect, origin_x, origin_y, root_scale, state);
1178    hash_shadow_device_rect(shape.local_rect, origin_x, origin_y, root_scale, state);
1179    for point in shape.quad {
1180        hash_shadow_device_offset(point[0], origin_x, root_scale, state);
1181        hash_shadow_device_offset(point[1], origin_y, root_scale, state);
1182    }
1183    match shape.snap_anchor {
1184        Some(anchor) => {
1185            1u8.hash(state);
1186            hash_shadow_device_offset(anchor.origin.x, origin_x, root_scale, state);
1187            hash_shadow_device_offset(anchor.origin.y, origin_y, root_scale, state);
1188            hash_f32_for_cache(anchor.device_pixel_step, state);
1189        }
1190        None => 0u8.hash(state),
1191    }
1192    shape.brush.render_hash(brushes).hash(state);
1193    match shape.shape {
1194        Some(corner_shape) => {
1195            1u8.hash(state);
1196            corner_shape.radii().render_hash().hash(state);
1197        }
1198        None => 0u8.hash(state),
1199    }
1200    match shape.clip {
1201        Some(clip) => {
1202            1u8.hash(state);
1203            hash_shadow_device_rect(clip, origin_x, origin_y, root_scale, state);
1204        }
1205        None => 0u8.hash(state),
1206    }
1207    blend_mode.hash(state);
1208    shape.blend_mode.hash(state);
1209}
1210
1211fn shape_shadow_content_hash(
1212    shapes: &[(DrawShape, BlendMode)],
1213    brushes: &[Brush],
1214    root_scale: f32,
1215) -> u64 {
1216    let mut hasher = FxHasher::default();
1217    // Anchor the hash to the shapes' own (unfloored) bounds so rigid translation
1218    // cancels out exactly. Anchoring to floored device-pixel bounds would leak
1219    // the device subpixel phase into the hash and defeat the cache at
1220    // fractional display scales.
1221    let origin = shape_shadow_bounds(shapes).unwrap_or(Rect {
1222        x: 0.0,
1223        y: 0.0,
1224        width: 0.0,
1225        height: 0.0,
1226    });
1227
1228    shapes.len().hash(&mut hasher);
1229    for (shape, blend_mode) in shapes {
1230        hash_shape_shadow_item(
1231            shape,
1232            brushes,
1233            *blend_mode,
1234            origin.x,
1235            origin.y,
1236            root_scale,
1237            &mut hasher,
1238        );
1239    }
1240    hasher.finish()
1241}
1242
1243fn shape_shadow_surface_cache_key(
1244    shapes: &[(DrawShape, BlendMode)],
1245    brushes: &[Brush],
1246    device_bounds: DevicePixelBounds,
1247    pixel_radius: f32,
1248    root_scale: f32,
1249) -> Option<ShadowSurfaceCacheKey> {
1250    (root_scale.is_finite() && root_scale > 0.0).then(|| ShadowSurfaceCacheKey {
1251        content_hash: shape_shadow_content_hash(shapes, brushes, root_scale),
1252        pixel_size: [device_bounds.width, device_bounds.height],
1253        root_scale_bits: root_scale.to_bits(),
1254        blur_radius_bits: pixel_radius.to_bits(),
1255    })
1256}
1257
1258fn shape_shadow_bounds(shapes: &[(DrawShape, BlendMode)]) -> Option<Rect> {
1259    shapes
1260        .iter()
1261        .map(|(shape, _)| shape.rect)
1262        .reduce(|a, b| Rect {
1263            x: a.x.min(b.x),
1264            y: a.y.min(b.y),
1265            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1266            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1267        })
1268}
1269
1270fn shared_shape_shadow_snap_anchor(shapes: &[(DrawShape, BlendMode)]) -> Option<SnapAnchor> {
1271    let anchor = shapes.first()?.0.snap_anchor?;
1272    shapes
1273        .iter()
1274        .all(|(shape, _)| shape.snap_anchor == Some(anchor))
1275        .then_some(anchor)
1276}
1277
1278fn shadow_draw_bounds(shadow: &ShadowDraw) -> Option<Rect> {
1279    shadow
1280        .shapes
1281        .iter()
1282        .map(|(shape, _)| shape.rect)
1283        .chain(shadow.texts.iter().map(|text| text.rect))
1284        .reduce(|a, b| Rect {
1285            x: a.x.min(b.x),
1286            y: a.y.min(b.y),
1287            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1288            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1289        })
1290}
1291
1292fn shadow_draw_may_render(
1293    shadow: &ShadowDraw,
1294    width: u32,
1295    height: u32,
1296    root_scale: f32,
1297    max_texture_dim: u32,
1298) -> bool {
1299    if shadow.texts.is_empty() && !shadow.shapes.is_empty() && shadow.blur_radius > 0.0 {
1300        return shape_shadow_surface_plan(
1301            &shadow.shapes,
1302            shadow.clip,
1303            shadow.blur_radius,
1304            width,
1305            height,
1306            root_scale,
1307            max_texture_dim,
1308        )
1309        .is_some();
1310    }
1311
1312    let Some(bounds) = shadow_draw_bounds(shadow) else {
1313        return false;
1314    };
1315    let blur_margin = blur_extent_margin(shadow.blur_radius);
1316    let mut visible_bounds = Rect {
1317        x: bounds.x - blur_margin,
1318        y: bounds.y - blur_margin,
1319        width: bounds.width + blur_margin * 2.0,
1320        height: bounds.height + blur_margin * 2.0,
1321    };
1322    if let Some(clip) = shadow.clip {
1323        let clip_expanded = Rect {
1324            x: clip.x - blur_margin,
1325            y: clip.y - blur_margin,
1326            width: clip.width + blur_margin * 2.0,
1327            height: clip.height + blur_margin * 2.0,
1328        };
1329        let Some(intersection) = visible_bounds.intersect(clip_expanded) else {
1330            return false;
1331        };
1332        visible_bounds = intersection;
1333    }
1334
1335    scissor_rect_for_rect(visible_bounds, root_scale, width, height).is_some()
1336}
1337
1338fn shape_shadow_surface_plan(
1339    shapes: &[(DrawShape, BlendMode)],
1340    clip: Option<Rect>,
1341    blur_radius: f32,
1342    width: u32,
1343    height: u32,
1344    root_scale: f32,
1345    max_texture_dim: u32,
1346) -> Option<ShapeShadowSurfacePlan> {
1347    let shape_bounds = shape_shadow_bounds(shapes)?;
1348    let blur_margin = blur_extent_margin(blur_radius);
1349    let source_blur_bounds = Rect {
1350        x: shape_bounds.x - blur_margin,
1351        y: shape_bounds.y - blur_margin,
1352        width: shape_bounds.width + blur_margin * 2.0,
1353        height: shape_bounds.height + blur_margin * 2.0,
1354    };
1355
1356    let mut visible_blur_bounds = source_blur_bounds;
1357    if let Some(clip) = clip {
1358        let clip_expanded = Rect {
1359            x: clip.x - blur_margin,
1360            y: clip.y - blur_margin,
1361            width: clip.width + blur_margin * 2.0,
1362            height: clip.height + blur_margin * 2.0,
1363        };
1364        visible_blur_bounds = visible_blur_bounds.intersect(clip_expanded)?;
1365    }
1366
1367    let processing_scissor = scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
1368    processing_scissor?;
1369    let visible_device_bounds =
1370        device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)?;
1371    let source_device_bounds = translation_stable_anchored_device_pixel_bounds(
1372        source_blur_bounds,
1373        shared_shape_shadow_snap_anchor(shapes),
1374        root_scale,
1375        max_texture_dim,
1376    )
1377    .unwrap_or(visible_device_bounds);
1378
1379    Some(ShapeShadowSurfacePlan {
1380        source_device_bounds,
1381        processing_scissor,
1382        pixel_radius: blur_radius * root_scale,
1383    })
1384}
1385
1386fn is_render_effect_supported(effect: &RenderEffect) -> bool {
1387    match effect {
1388        RenderEffect::Blur { .. } => true,
1389        RenderEffect::Offset { .. } => true,
1390        RenderEffect::Shader { .. } => true,
1391        RenderEffect::Chain { first, second } => {
1392            is_render_effect_supported(first) && is_render_effect_supported(second)
1393        }
1394    }
1395}
1396
1397fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
1398    if value.is_finite() {
1399        origin + value
1400    } else if value.is_sign_positive() {
1401        origin + extent
1402    } else {
1403        origin
1404    }
1405}
1406
1407fn gradient_tile_mode_value(tile_mode: TileMode) -> u32 {
1408    match tile_mode {
1409        TileMode::Clamp => 0,
1410        TileMode::Repeated => 1,
1411        TileMode::Mirror => 2,
1412        TileMode::Decal => 3,
1413    }
1414}
1415
1416/// The base text the shape rewrites below start from: `shape.wgsl` alone, or
1417/// — under `CRANPOSE_SOLID_TRIM_VARYINGS` — `shape.wgsl` with the trimmed
1418/// solid entries appended. Appending happens BEFORE the storage/array
1419/// rewrites so the paint-select injection and the batch-limit resizes land
1420/// in the trimmed entries exactly as they land in `vs_main` (the
1421/// substitution tests pin five landings); with the trim off the text is the
1422/// borrowed shipping constant, byte-identical to what always compiled.
1423fn shape_shader_base(solid_trim: bool) -> Cow<'static, str> {
1424    if solid_trim {
1425        return Cow::Owned(format!(
1426            "{}\n{}",
1427            shaders::SHADER,
1428            shaders::SOLID_TRIM_APPENDIX
1429        ));
1430    }
1431    Cow::Borrowed(shaders::SHADER)
1432}
1433
1434#[cfg(not(target_arch = "wasm32"))]
1435fn shape_shader_source(batch_limits: ShapeBatchLimits, solid_trim: bool) -> Cow<'static, str> {
1436    let base = shape_shader_base(solid_trim);
1437    // These literals must stay in sync with `shape.wgsl`; a mismatch makes
1438    // the substitution silently no-op and leaves the shader sized for the
1439    // downlevel floor.
1440    if batch_limits.storage {
1441        return Cow::Owned(
1442            base.replace(
1443                "var<uniform> shape_data: array<ShapeData, 102>;",
1444                "var<storage, read> shape_data: array<ShapeData>;",
1445            )
1446            .replace(
1447                "var<uniform> gradient_stops: array<GradientStop, 256>;",
1448                // Also inject the retained-paint array here: one mutable
1449                // color per shape, read when `similarity.paint_select`
1450                // is set, so recolor patches upload 16-byte colors
1451                // instead of whole ShapeData records. The base text
1452                // never declares it — uniform-mode devices cannot bind
1453                // storage and never host retained slots.
1454                "var<storage, read> gradient_stops: array<GradientStop>;\n\n\
1455                     @group(1) @binding(3)\n\
1456                     var<storage, read> paint: array<vec4<f32>>;",
1457            )
1458            .replace(
1459                "output.color = shape.color;",
1460                "output.color = \
1461                     select(shape.color, paint[shape_idx], similarity.paint_select > 0.5);",
1462            ),
1463        );
1464    }
1465    Cow::Owned(
1466        base.replace(
1467            "array<ShapeData, 102>",
1468            &format!("array<ShapeData, {}>", batch_limits.max_shapes_per_batch),
1469        )
1470        .replace(
1471            "array<GradientStop, 256>",
1472            &format!("array<GradientStop, {}>", batch_limits.max_gradient_stops),
1473        ),
1474    )
1475}
1476
1477#[cfg(target_arch = "wasm32")]
1478fn shape_shader_source(_batch_limits: ShapeBatchLimits, solid_trim: bool) -> Cow<'static, str> {
1479    // wasm keeps the downlevel array lengths verbatim. The trim flag is
1480    // env-driven and a browser has no environment to set it in, but the arm
1481    // stays honest for any embedder that reaches it.
1482    shape_shader_base(solid_trim)
1483}
1484
1485/// Runs one `create_render_pipeline` call under a timer and logs the result.
1486/// First-use creation happens on the render thread behind `get_or_init`,
1487/// where a driver backend compile is whole missed frames on slow devices;
1488/// the tag names the permutation so a stalled launch names its pipelines.
1489pub(crate) fn create_render_pipeline_logged<'a>(
1490    device: &wgpu::Device,
1491    cache: Option<&'a wgpu::PipelineCache>,
1492    tag: &str,
1493    mut descriptor: wgpu::RenderPipelineDescriptor<'a>,
1494) -> wgpu::RenderPipeline {
1495    descriptor.cache = cache;
1496    let started = Instant::now();
1497    let pipeline = device.create_render_pipeline(&descriptor);
1498    log::info!(
1499        "[pipeline-create] {tag} {:.1}ms",
1500        instant_ms(started, Instant::now())
1501    );
1502    pipeline
1503}
1504
1505/// `CRANPOSE_PIPELINE_PREWARM=0` (property `debug.cranpose.pipeline_prewarm`)
1506/// keeps first-use creation as the only compile path.
1507#[cfg(not(target_arch = "wasm32"))]
1508fn pipeline_prewarm_enabled() -> bool {
1509    std::env::var("CRANPOSE_PIPELINE_PREWARM").as_deref() != Ok("0")
1510}
1511
1512#[cfg(not(target_arch = "wasm32"))]
1513struct PipelinePrewarmInputs {
1514    device: Arc<wgpu::Device>,
1515    cache: Option<wgpu::PipelineCache>,
1516    surface_format: wgpu::TextureFormat,
1517    uniform_layout: wgpu::BindGroupLayout,
1518    shape_layout: wgpu::BindGroupLayout,
1519    image_layout: wgpu::BindGroupLayout,
1520    batch_limits: ShapeBatchLimits,
1521    instanced: bool,
1522}
1523
1524/// Builds the pipelines a first frame reaches for — off the render thread,
1525/// concurrent with app startup — and drops them. The point is the shared
1526/// device pipeline cache: the render thread's own `get_or_init` creates then
1527/// find the driver's compiled code instead of paying for it mid-frame
1528/// (measured on a Pixel Watch 3: 661 + 496 + 552 ms for the three shape
1529/// pipelines alone, each one swallowed frame). The set is the framework's
1530/// own base family with the flags the accessors would latch — same inputs,
1531/// same permutations, so the cache keys match. Spawned only when the device
1532/// has a pipeline cache; without one, warming another thread's `wgpu`
1533/// objects would leave nothing behind for the render thread to find.
1534#[cfg(not(target_arch = "wasm32"))]
1535fn spawn_pipeline_prewarm(inputs: PipelinePrewarmInputs) {
1536    if !pipeline_prewarm_enabled() {
1537        return;
1538    }
1539    let spawned = std::thread::Builder::new()
1540        .name("cranpose-pl-warm".into())
1541        .spawn(move || {
1542            let started = Instant::now();
1543            let cache = inputs.cache.as_ref();
1544            let device = &inputs.device;
1545            let solid_trim = solid_trim_varyings_enabled();
1546            let mut built = 0_u32;
1547            if inputs.instanced {
1548                let (vertex_entry, fragment_entry) = if solid_trim {
1549                    ("vs_solid_instanced", "fs_solid_trim")
1550                } else {
1551                    ("vs_shape_instanced", "fs_solid")
1552                };
1553                drop(create_instanced_shape_pipeline(
1554                    device,
1555                    cache,
1556                    inputs.surface_format,
1557                    &inputs.uniform_layout,
1558                    &inputs.shape_layout,
1559                    BlendMode::SrcOver,
1560                    inputs.batch_limits,
1561                    solid_trim,
1562                    vertex_entry,
1563                    fragment_entry,
1564                    false,
1565                ));
1566                drop(create_instanced_shape_pipeline(
1567                    device,
1568                    cache,
1569                    inputs.surface_format,
1570                    &inputs.uniform_layout,
1571                    &inputs.shape_layout,
1572                    BlendMode::SrcOver,
1573                    inputs.batch_limits,
1574                    false,
1575                    "vs_shape_instanced",
1576                    "fs_main",
1577                    false,
1578                ));
1579            } else {
1580                let (vertex_entry, fragment_entry) = if solid_trim {
1581                    ("vs_solid", "fs_solid_trim")
1582                } else {
1583                    ("vs_main", "fs_solid")
1584                };
1585                drop(create_shape_pipeline(
1586                    device,
1587                    cache,
1588                    inputs.surface_format,
1589                    &inputs.uniform_layout,
1590                    &inputs.shape_layout,
1591                    BlendMode::SrcOver,
1592                    inputs.batch_limits,
1593                    solid_trim,
1594                    vertex_entry,
1595                    fragment_entry,
1596                    false,
1597                ));
1598                drop(create_shape_pipeline(
1599                    device,
1600                    cache,
1601                    inputs.surface_format,
1602                    &inputs.uniform_layout,
1603                    &inputs.shape_layout,
1604                    BlendMode::SrcOver,
1605                    inputs.batch_limits,
1606                    false,
1607                    "vs_main",
1608                    "fs_main",
1609                    false,
1610                ));
1611            }
1612            built += 2;
1613            if inputs.batch_limits.storage {
1614                drop(create_mesh_shape_pipeline(
1615                    device,
1616                    cache,
1617                    inputs.surface_format,
1618                    &inputs.uniform_layout,
1619                    &inputs.shape_layout,
1620                    inputs.batch_limits,
1621                    false,
1622                ));
1623                built += 1;
1624            }
1625            drop(create_glyph_atlas_pipeline(
1626                device,
1627                cache,
1628                inputs.surface_format,
1629                &inputs.uniform_layout,
1630                &inputs.image_layout,
1631                false,
1632            ));
1633            built += 1;
1634            log::info!(
1635                "[pipeline-prewarm] {built} pipelines in {:.1} ms",
1636                instant_ms(started, Instant::now())
1637            );
1638        });
1639    if let Err(error) = spawned {
1640        log::warn!("[pipeline-prewarm] thread failed to spawn: {error}");
1641    }
1642}
1643
1644#[allow(clippy::too_many_arguments)]
1645fn create_shape_pipeline(
1646    device: &wgpu::Device,
1647    cache: Option<&wgpu::PipelineCache>,
1648    surface_format: wgpu::TextureFormat,
1649    uniform_layout: &wgpu::BindGroupLayout,
1650    shape_layout: &wgpu::BindGroupLayout,
1651    blend_mode: BlendMode,
1652    batch_limits: ShapeBatchLimits,
1653    solid_trim: bool,
1654    vertex_entry: &'static str,
1655    fragment_entry: &'static str,
1656    depth: bool,
1657) -> wgpu::RenderPipeline {
1658    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1659        label: Some("Shape Shader"),
1660        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1661            shape_shader_source(batch_limits, solid_trim),
1662            depth,
1663        )),
1664    });
1665
1666    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1667        label: Some("Render Pipeline Layout"),
1668        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1669        immediate_size: 0,
1670    });
1671
1672    create_render_pipeline_logged(
1673        device,
1674        cache,
1675        &format!("shape entry={fragment_entry} blend={blend_mode:?} depth={depth}"),
1676        wgpu::RenderPipelineDescriptor {
1677            label: Some("Render Pipeline"),
1678            layout: Some(&pipeline_layout),
1679            vertex: wgpu::VertexState {
1680                module: &shader,
1681                entry_point: Some(vertex_entry),
1682                compilation_options: wgpu::PipelineCompilationOptions::default(),
1683                // No vertex buffer: `vs_main` (and its trimmed twin `vs_solid`)
1684                // pulls quad corners from ShapeData by `vertex_index`.
1685                buffers: &[],
1686            },
1687            fragment: Some(wgpu::FragmentState {
1688                module: &shader,
1689                entry_point: Some(fragment_entry),
1690                compilation_options: wgpu::PipelineCompilationOptions::default(),
1691                targets: &[Some(wgpu::ColorTargetState {
1692                    format: surface_format,
1693                    blend: Some(blend_state_for_mode(blend_mode)),
1694                    write_mask: wgpu::ColorWrites::ALL,
1695                })],
1696            }),
1697            primitive: wgpu::PrimitiveState {
1698                topology: wgpu::PrimitiveTopology::TriangleList,
1699                strip_index_format: None,
1700                front_face: wgpu::FrontFace::Ccw,
1701                cull_mode: None,
1702                unclipped_depth: false,
1703                polygon_mode: wgpu::PolygonMode::Fill,
1704                conservative: false,
1705            },
1706            depth_stencil: display_clip::content_depth_state(depth),
1707            multisample: wgpu::MultisampleState::default(),
1708            multiview_mask: None,
1709            cache: None,
1710        },
1711    )
1712}
1713
1714/// Storage-mode pipeline for retained slots that captured a conservative arc
1715/// mesh: `vs_mesh` consumes `{position, uv, shape_idx}` vertices instead of
1716/// expanding six corners per shape. Fragment stage, bind group layouts
1717/// (including the dynamic-offset similarity binding and the retained paint
1718/// binding) and the SrcOver blend are exactly the ones the quad-expansion retained
1719/// path uses — only the vertex fetch differs.
1720#[cfg(not(target_arch = "wasm32"))]
1721fn create_mesh_shape_pipeline(
1722    device: &wgpu::Device,
1723    cache: Option<&wgpu::PipelineCache>,
1724    surface_format: wgpu::TextureFormat,
1725    uniform_layout: &wgpu::BindGroupLayout,
1726    shape_layout: &wgpu::BindGroupLayout,
1727    batch_limits: ShapeBatchLimits,
1728    depth: bool,
1729) -> wgpu::RenderPipeline {
1730    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1731        label: Some("Shape Mesh Shader"),
1732        // Mesh slots may carry gradients, so this family always compiles the
1733        // full interface — the trimmed entries never pair with `vs_mesh`.
1734        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1735            shape_shader_source(batch_limits, false),
1736            depth,
1737        )),
1738    });
1739
1740    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1741        label: Some("Mesh Render Pipeline Layout"),
1742        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1743        immediate_size: 0,
1744    });
1745
1746    create_render_pipeline_logged(
1747        device,
1748        cache,
1749        &format!("mesh depth={depth}"),
1750        wgpu::RenderPipelineDescriptor {
1751            label: Some("Retained Mesh Pipeline"),
1752            layout: Some(&pipeline_layout),
1753            vertex: wgpu::VertexState {
1754                module: &shader,
1755                entry_point: Some("vs_mesh"),
1756                compilation_options: wgpu::PipelineCompilationOptions::default(),
1757                buffers: &[MeshVertex::desc()],
1758            },
1759            fragment: Some(wgpu::FragmentState {
1760                module: &shader,
1761                entry_point: Some("fs_main"),
1762                compilation_options: wgpu::PipelineCompilationOptions::default(),
1763                targets: &[Some(wgpu::ColorTargetState {
1764                    format: surface_format,
1765                    blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1766                    write_mask: wgpu::ColorWrites::ALL,
1767                })],
1768            }),
1769            primitive: wgpu::PrimitiveState {
1770                topology: wgpu::PrimitiveTopology::TriangleList,
1771                strip_index_format: None,
1772                front_face: wgpu::FrontFace::Ccw,
1773                cull_mode: None,
1774                unclipped_depth: false,
1775                polygon_mode: wgpu::PolygonMode::Fill,
1776                conservative: false,
1777            },
1778            depth_stencil: display_clip::content_depth_state(depth),
1779            multisample: wgpu::MultisampleState::default(),
1780            multiview_mask: None,
1781            cache: None,
1782        },
1783    )
1784}
1785
1786/// Storage-mode pipeline for ordinary shape batches drawn as instanced
1787/// indexed quads (`vs_shape_instanced`): four vertex executions per shape
1788/// through the static `[0, 1, 2, 2, 1, 3]` index buffer instead of six
1789/// unindexed corner expansions. Everything but the vertex entry point is
1790/// exactly `create_shape_pipeline` — same fragment stage, same layouts,
1791/// same blend per mode — so a draw-time fallback to `vs_main` (the
1792/// `CRANPOSE_INSTANCED_QUADS=0` kill switch) changes nothing else.
1793#[cfg(not(target_arch = "wasm32"))]
1794#[allow(clippy::too_many_arguments)]
1795fn create_instanced_shape_pipeline(
1796    device: &wgpu::Device,
1797    cache: Option<&wgpu::PipelineCache>,
1798    surface_format: wgpu::TextureFormat,
1799    uniform_layout: &wgpu::BindGroupLayout,
1800    shape_layout: &wgpu::BindGroupLayout,
1801    blend_mode: BlendMode,
1802    batch_limits: ShapeBatchLimits,
1803    solid_trim: bool,
1804    vertex_entry: &'static str,
1805    fragment_entry: &'static str,
1806    depth: bool,
1807) -> wgpu::RenderPipeline {
1808    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1809        label: Some("Shape Instanced Shader"),
1810        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1811            shape_shader_source(batch_limits, solid_trim),
1812            depth,
1813        )),
1814    });
1815
1816    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1817        label: Some("Instanced Render Pipeline Layout"),
1818        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1819        immediate_size: 0,
1820    });
1821
1822    create_render_pipeline_logged(
1823        device,
1824        cache,
1825        &format!("instanced entry={fragment_entry} blend={blend_mode:?} depth={depth}"),
1826        wgpu::RenderPipelineDescriptor {
1827            label: Some("Instanced Render Pipeline"),
1828            layout: Some(&pipeline_layout),
1829            vertex: wgpu::VertexState {
1830                module: &shader,
1831                entry_point: Some(vertex_entry),
1832                compilation_options: wgpu::PipelineCompilationOptions::default(),
1833                // No vertex buffer: like `vs_main`, the corners come from
1834                // ShapeData; only the shape index source differs
1835                // (`instance_index` instead of `vertex_index / 6`).
1836                buffers: &[],
1837            },
1838            fragment: Some(wgpu::FragmentState {
1839                module: &shader,
1840                entry_point: Some(fragment_entry),
1841                compilation_options: wgpu::PipelineCompilationOptions::default(),
1842                targets: &[Some(wgpu::ColorTargetState {
1843                    format: surface_format,
1844                    blend: Some(blend_state_for_mode(blend_mode)),
1845                    write_mask: wgpu::ColorWrites::ALL,
1846                })],
1847            }),
1848            primitive: wgpu::PrimitiveState {
1849                topology: wgpu::PrimitiveTopology::TriangleList,
1850                strip_index_format: None,
1851                front_face: wgpu::FrontFace::Ccw,
1852                cull_mode: None,
1853                unclipped_depth: false,
1854                polygon_mode: wgpu::PolygonMode::Fill,
1855                conservative: false,
1856            },
1857            depth_stencil: display_clip::content_depth_state(depth),
1858            multisample: wgpu::MultisampleState::default(),
1859            multiview_mask: None,
1860            cache: None,
1861        },
1862    )
1863}
1864
1865fn create_image_pipeline(
1866    device: &wgpu::Device,
1867    cache: Option<&wgpu::PipelineCache>,
1868    surface_format: wgpu::TextureFormat,
1869    uniform_layout: &wgpu::BindGroupLayout,
1870    image_layout: &wgpu::BindGroupLayout,
1871    blend_mode: BlendMode,
1872    depth: bool,
1873) -> wgpu::RenderPipeline {
1874    let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1875        label: Some("Image Shader"),
1876        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1877            shaders::IMAGE_SHADER.into(),
1878            depth,
1879        )),
1880    });
1881
1882    let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1883        label: Some("Image Pipeline Layout"),
1884        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1885        immediate_size: 0,
1886    });
1887
1888    create_render_pipeline_logged(
1889        device,
1890        cache,
1891        &format!("image blend={blend_mode:?} depth={depth}"),
1892        wgpu::RenderPipelineDescriptor {
1893            label: Some("Image Pipeline"),
1894            layout: Some(&image_pipeline_layout),
1895            vertex: wgpu::VertexState {
1896                module: &image_shader,
1897                entry_point: Some("image_vs_main"),
1898                compilation_options: wgpu::PipelineCompilationOptions::default(),
1899                buffers: &[Vertex::desc()],
1900            },
1901            fragment: Some(wgpu::FragmentState {
1902                module: &image_shader,
1903                entry_point: Some("image_fs_main"),
1904                compilation_options: wgpu::PipelineCompilationOptions::default(),
1905                targets: &[Some(wgpu::ColorTargetState {
1906                    format: surface_format,
1907                    blend: Some(blend_state_for_mode(blend_mode)),
1908                    write_mask: wgpu::ColorWrites::ALL,
1909                })],
1910            }),
1911            primitive: wgpu::PrimitiveState {
1912                topology: wgpu::PrimitiveTopology::TriangleList,
1913                strip_index_format: None,
1914                front_face: wgpu::FrontFace::Ccw,
1915                cull_mode: None,
1916                unclipped_depth: false,
1917                polygon_mode: wgpu::PolygonMode::Fill,
1918                conservative: false,
1919            },
1920            depth_stencil: display_clip::content_depth_state(depth),
1921            multisample: wgpu::MultisampleState::default(),
1922            multiview_mask: None,
1923            cache: None,
1924        },
1925    )
1926}
1927
1928fn create_glyph_atlas_pipeline(
1929    device: &wgpu::Device,
1930    cache: Option<&wgpu::PipelineCache>,
1931    surface_format: wgpu::TextureFormat,
1932    uniform_layout: &wgpu::BindGroupLayout,
1933    image_layout: &wgpu::BindGroupLayout,
1934    depth: bool,
1935) -> wgpu::RenderPipeline {
1936    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1937        label: Some("Glyph Atlas Shader"),
1938        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1939            shaders::GLYPH_ATLAS_SHADER.into(),
1940            depth,
1941        )),
1942    });
1943
1944    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1945        label: Some("Glyph Atlas Pipeline Layout"),
1946        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1947        immediate_size: 0,
1948    });
1949
1950    create_render_pipeline_logged(
1951        device,
1952        cache,
1953        &format!("glyph-atlas depth={depth}"),
1954        wgpu::RenderPipelineDescriptor {
1955            label: Some("Glyph Atlas Pipeline"),
1956            layout: Some(&pipeline_layout),
1957            vertex: wgpu::VertexState {
1958                module: &shader,
1959                entry_point: Some("glyph_atlas_vs_main"),
1960                compilation_options: wgpu::PipelineCompilationOptions::default(),
1961                buffers: &[Vertex::desc()],
1962            },
1963            fragment: Some(wgpu::FragmentState {
1964                module: &shader,
1965                entry_point: Some("glyph_atlas_fs_main"),
1966                compilation_options: wgpu::PipelineCompilationOptions::default(),
1967                targets: &[Some(wgpu::ColorTargetState {
1968                    format: surface_format,
1969                    blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1970                    write_mask: wgpu::ColorWrites::ALL,
1971                })],
1972            }),
1973            primitive: wgpu::PrimitiveState {
1974                topology: wgpu::PrimitiveTopology::TriangleList,
1975                strip_index_format: None,
1976                front_face: wgpu::FrontFace::Ccw,
1977                cull_mode: None,
1978                unclipped_depth: false,
1979                polygon_mode: wgpu::PolygonMode::Fill,
1980                conservative: false,
1981            },
1982            depth_stencil: display_clip::content_depth_state(depth),
1983            multisample: wgpu::MultisampleState::default(),
1984            multiview_mask: None,
1985            cache: None,
1986        },
1987    )
1988}
1989
1990/// Pipeline for the display-clip occluder — the tessellated complement
1991/// of the visible region — the first draw of a culled
1992/// fused pass: depth write ON at the near plane, color writes fully masked
1993/// off, trivial fragment stage with no discard — exactly the shape early-Z
1994/// and LRZ hardware accepts as an occluder. The color target must still be
1995/// declared (the pass has a color attachment), which is what the empty
1996/// write mask is for.
1997#[cfg(not(target_arch = "wasm32"))]
1998fn create_display_clip_occluder_pipeline(
1999    device: &wgpu::Device,
2000    cache: Option<&wgpu::PipelineCache>,
2001    surface_format: wgpu::TextureFormat,
2002) -> wgpu::RenderPipeline {
2003    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2004        label: Some("Display Clip Occluder Shader"),
2005        source: wgpu::ShaderSource::Wgsl(display_clip::OCCLUDER_SHADER.into()),
2006    });
2007    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2008        label: Some("Display Clip Occluder Pipeline Layout"),
2009        bind_group_layouts: &[],
2010        immediate_size: 0,
2011    });
2012    create_render_pipeline_logged(
2013        device,
2014        cache,
2015        "occluder",
2016        wgpu::RenderPipelineDescriptor {
2017            label: Some("Display Clip Occluder Pipeline"),
2018            layout: Some(&pipeline_layout),
2019            vertex: wgpu::VertexState {
2020                module: &shader,
2021                entry_point: Some("mask_vs"),
2022                compilation_options: wgpu::PipelineCompilationOptions::default(),
2023                buffers: &[wgpu::VertexBufferLayout {
2024                    array_stride: (std::mem::size_of::<[f32; 2]>()) as wgpu::BufferAddress,
2025                    step_mode: wgpu::VertexStepMode::Vertex,
2026                    attributes: &[wgpu::VertexAttribute {
2027                        offset: 0,
2028                        shader_location: 0,
2029                        format: wgpu::VertexFormat::Float32x2,
2030                    }],
2031                }],
2032            },
2033            fragment: Some(wgpu::FragmentState {
2034                module: &shader,
2035                entry_point: Some("mask_fs"),
2036                compilation_options: wgpu::PipelineCompilationOptions::default(),
2037                targets: &[Some(wgpu::ColorTargetState {
2038                    format: surface_format,
2039                    blend: None,
2040                    write_mask: wgpu::ColorWrites::empty(),
2041                })],
2042            }),
2043            primitive: wgpu::PrimitiveState {
2044                topology: wgpu::PrimitiveTopology::TriangleList,
2045                strip_index_format: None,
2046                front_face: wgpu::FrontFace::Ccw,
2047                cull_mode: None,
2048                unclipped_depth: false,
2049                polygon_mode: wgpu::PolygonMode::Fill,
2050                conservative: false,
2051            },
2052            depth_stencil: Some(wgpu::DepthStencilState {
2053                format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
2054                depth_write_enabled: Some(true),
2055                depth_compare: Some(wgpu::CompareFunction::Always),
2056                stencil: wgpu::StencilState::default(),
2057                bias: wgpu::DepthBiasState::default(),
2058            }),
2059            multisample: wgpu::MultisampleState::default(),
2060            multiview_mask: None,
2061            cache: None,
2062        },
2063    )
2064}
2065
2066#[repr(C)]
2067#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2068struct Vertex {
2069    position: [f32; 2],
2070    color: [f32; 4],
2071    uv: [f32; 2],
2072    uv_bounds: [f32; 4],
2073}
2074
2075impl Vertex {
2076    const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
2077        0 => Float32x2,
2078        1 => Float32x4,
2079        2 => Float32x2,
2080        3 => Float32x4
2081    ];
2082
2083    fn desc() -> wgpu::VertexBufferLayout<'static> {
2084        wgpu::VertexBufferLayout {
2085            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
2086            step_mode: wgpu::VertexStepMode::Vertex,
2087            attributes: &Self::ATTRIBS,
2088        }
2089    }
2090}
2091
2092#[repr(C)]
2093#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2094struct Uniforms {
2095    viewport: [f32; 2],
2096    viewport_offset: [f32; 2],
2097}
2098
2099/// Mirror of `struct ShapeData` in `shape.wgsl`. Field order and sizes must
2100/// match exactly: 10 x 16 bytes = 160 bytes, every member 16-byte aligned as
2101/// the uniform address space requires. The quad corners and vertex color ride
2102/// in here because the shape pipeline has no vertex buffer: the vertex shader
2103/// pulls all six corners of a shape straight from this struct.
2104#[repr(C)]
2105#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2106struct ShapeData {
2107    rect: [f32; 4], // x, y, width, height
2108    /// Rects: top_left, top_right, bottom_left, bottom_right corner radii.
2109    /// Arcs: (sin, cos) of the mid angle and of the half sweep — the shader's
2110    /// per-shape trig, precomputed so `sdf_arc_band` needs none per fragment.
2111    radii: [f32; 4],
2112    gradient_params: [f32; 4], // linear: start.xy,end.xy; radial: center.xy,radius,unused
2113    clip_rect: [f32; 4],       // clip_x, clip_y, clip_width, clip_height (0,0,0,0 = no clip)
2114    /// stroke width, packed flags (see [`pack_shape_flags`]), arc outer radius,
2115    /// arc inner radius. All zero for a plain fill.
2116    stroke_params: [f32; 4],
2117    /// arc center.xy, start angle, sweep angle (radians, 0 = +X, clockwise).
2118    arc_params: [f32; 4],
2119    /// Device-space quad corners 0 (xy) and 1 (zw).
2120    quad01: [f32; 4],
2121    /// Device-space quad corners 2 (xy) and 3 (zw).
2122    quad23: [f32; 4],
2123    /// Vertex color: the solid brush color, or the first gradient stop.
2124    color: [f32; 4],
2125    brush_type: u32,         // 0=solid, 1=linear_gradient, 2=radial_gradient
2126    gradient_start: u32,     // Starting index in gradient buffer
2127    gradient_count: u32,     // Number of gradient stops
2128    gradient_tile_mode: u32, // 0=Clamp, 1=Repeated, 2=Mirror, 3=Decal
2129}
2130
2131/// Shape kinds understood by `shape.wgsl`.
2132const SHAPE_KIND_FILL: u32 = 0;
2133const SHAPE_KIND_STROKE: u32 = 1;
2134const SHAPE_KIND_ARC: u32 = 2;
2135
2136fn stroke_cap_code(cap: StrokeCap) -> u32 {
2137    match cap {
2138        StrokeCap::Butt => 0,
2139        StrokeCap::Round => 1,
2140        StrokeCap::Square => 2,
2141    }
2142}
2143
2144fn stroke_join_code(join: StrokeJoin) -> u32 {
2145    match join {
2146        StrokeJoin::Miter => 0,
2147        StrokeJoin::Round => 1,
2148        StrokeJoin::Bevel => 2,
2149    }
2150}
2151
2152/// Packs kind/cap/join into the single float `ShapeData::stroke_params[1]`.
2153///
2154/// Three 2-bit fields fit in one f32 exactly (integers below 2^24 are exact),
2155/// which keeps `ShapeData` a slot smaller than it would be if each field got
2156/// its own float — batch capacity is set by this size on uniform backends.
2157fn pack_shape_flags(kind: u32, cap: StrokeCap, join: StrokeJoin) -> f32 {
2158    ((kind & 3) | (stroke_cap_code(cap) << 2) | (stroke_join_code(join) << 4)) as f32
2159}
2160
2161/// Whether a batch conversion fans out is decided by measurement — see
2162/// [`crate::cost_tuner::CostTuner`]. The floor of 256 matters: a device
2163/// whose uniform binding caps batches at ~409 shapes never crossed the old
2164/// fixed threshold of 512, so conversion ran serial on exactly the class of
2165/// hardware (watch-grade in-order cores) where fanning out pays most. The
2166/// 400 µs cheap floor keeps a big phone core, which clears such a batch in
2167/// well under that, from ever paying for a spawn wave.
2168#[cfg(not(target_arch = "wasm32"))]
2169static SHAPE_CONVERT_TUNER: crate::cost_tuner::CostTuner =
2170    crate::cost_tuner::CostTuner::new("shape-convert", 256, 400_000);
2171
2172#[cfg(not(target_arch = "wasm32"))]
2173pub(crate) fn shape_convert_worker_count() -> usize {
2174    static WORKERS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
2175    *WORKERS.get_or_init(|| {
2176        let cpus = std::thread::available_parallelism()
2177            .map(|count| count.get())
2178            .unwrap_or(1);
2179        let workers = cpus.clamp(1, 4);
2180        // One line per process: on devices whose scheduler confines the
2181        // process (affinity masks, cpusets), this is the number that
2182        // explains why fan-out stages stayed serial.
2183        log::info!("[shape-convert] fan-out width {workers} (available parallelism {cpus})");
2184        workers
2185    })
2186}
2187
2188#[cfg(target_arch = "wasm32")]
2189pub(crate) fn shape_convert_worker_count() -> usize {
2190    1
2191}
2192
2193fn shape_gradient_stop_count(shape: &DrawShape, brushes: &[Brush]) -> usize {
2194    match shape.brush {
2195        SceneBrush::Solid(_) => 0,
2196        SceneBrush::Gradient(index) => match &brushes[index as usize] {
2197            Brush::Solid(_) => 0,
2198            Brush::LinearGradient { colors, .. }
2199            | Brush::RadialGradient { colors, .. }
2200            | Brush::SweepGradient { colors, .. } => colors.len(),
2201        },
2202    }
2203}
2204
2205/// Converts one [`DrawShape`] into its GPU representation, writing into
2206/// pre-sized slots so a batch can convert in parallel across disjoint
2207/// sub-slices. `gradient_start` is the shape's global offset into the batch
2208/// gradient buffer; `gradient_out` is exactly its span of that buffer.
2209fn convert_shape_into_slots(
2210    shape: &DrawShape,
2211    brushes: &[Brush],
2212    root_scale: f32,
2213    gradient_start: u32,
2214    shape_out: &mut ShapeData,
2215    gradient_out: &mut [GradientStop],
2216) {
2217    let snap_delta = shape
2218        .snap_anchor
2219        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
2220        .unwrap_or_default();
2221    let local_rect = shape.local_rect.translate(snap_delta.x, snap_delta.y);
2222    let quad = translate_quad(shape.quad, snap_delta);
2223    // Clips are resolved in scene space from their own layer ancestry. A draw
2224    // item's raster snap must never move a fixed ancestor clip.
2225    let clip = shape.clip;
2226    let canonicalize = shape.snap_anchor.is_some();
2227    let device_local_rect = if canonicalize {
2228        canonicalized_scaled_rect(local_rect, root_scale)
2229    } else {
2230        Rect {
2231            x: local_rect.x * root_scale,
2232            y: local_rect.y * root_scale,
2233            width: local_rect.width * root_scale,
2234            height: local_rect.height * root_scale,
2235        }
2236    };
2237    let device_quad = if canonicalize {
2238        canonicalized_scaled_quad(quad, root_scale)
2239    } else {
2240        scaled_quad(quad, root_scale)
2241    };
2242    let canonicalize_brush_coordinate = |value| {
2243        if canonicalize {
2244            canonicalize_device_coordinate(value)
2245        } else {
2246            value
2247        }
2248    };
2249
2250    // Clip rect (scaled to physical pixels)
2251    let clip_rect = if let Some(clip) = clip {
2252        let device_clip = if canonicalize {
2253            canonicalized_scaled_rect(clip, root_scale)
2254        } else {
2255            Rect {
2256                x: clip.x * root_scale,
2257                y: clip.y * root_scale,
2258                width: clip.width * root_scale,
2259                height: clip.height * root_scale,
2260            }
2261        };
2262        [
2263            device_clip.x,
2264            device_clip.y,
2265            device_clip.width,
2266            device_clip.height,
2267        ]
2268    } else {
2269        [0.0, 0.0, 0.0, 0.0]
2270    };
2271
2272    // Gradient parameters
2273    let mut fill_gradient_entries = |colors: &[Color], stops: Option<&[f32]>| {
2274        let count = colors.len();
2275        let explicit_stops = stops.filter(|values| values.len() == count);
2276        for (index, color) in colors.iter().enumerate() {
2277            let position = explicit_stops
2278                .map(|values| values[index])
2279                .unwrap_or_else(|| {
2280                    if count <= 1 {
2281                        0.0
2282                    } else {
2283                        index as f32 / (count - 1) as f32
2284                    }
2285                });
2286            gradient_out[index] = GradientStop {
2287                color: [color.r(), color.g(), color.b(), color.a()],
2288                position: [position, 0.0, 0.0, 0.0],
2289            };
2290        }
2291        count as u32
2292    };
2293    let mut gradient_params = [0.0f32; 4];
2294    let (brush_type, gradient_count, gradient_tile_mode) = match &shape.brush {
2295        SceneBrush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
2296        SceneBrush::Gradient(index) => match &brushes[*index as usize] {
2297            Brush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
2298            Brush::LinearGradient {
2299                colors,
2300                stops,
2301                start,
2302                end,
2303                tile_mode,
2304            } => {
2305                let count = fill_gradient_entries(colors, stops.as_deref());
2306                gradient_params = [
2307                    canonicalize_brush_coordinate(resolve_gradient_point(
2308                        device_local_rect.x,
2309                        device_local_rect.width,
2310                        start.x * root_scale,
2311                    )),
2312                    canonicalize_brush_coordinate(resolve_gradient_point(
2313                        device_local_rect.y,
2314                        device_local_rect.height,
2315                        start.y * root_scale,
2316                    )),
2317                    canonicalize_brush_coordinate(resolve_gradient_point(
2318                        device_local_rect.x,
2319                        device_local_rect.width,
2320                        end.x * root_scale,
2321                    )),
2322                    canonicalize_brush_coordinate(resolve_gradient_point(
2323                        device_local_rect.y,
2324                        device_local_rect.height,
2325                        end.y * root_scale,
2326                    )),
2327                ];
2328                (1u32, count, gradient_tile_mode_value(*tile_mode))
2329            }
2330            Brush::RadialGradient {
2331                colors,
2332                stops,
2333                center,
2334                radius,
2335                tile_mode,
2336            } => {
2337                let count = fill_gradient_entries(colors, stops.as_deref());
2338                gradient_params = [
2339                    canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
2340                    canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
2341                    (radius * root_scale).max(f32::EPSILON),
2342                    0.0,
2343                ];
2344                (2u32, count, gradient_tile_mode_value(*tile_mode))
2345            }
2346            Brush::SweepGradient {
2347                colors,
2348                stops,
2349                center,
2350            } => {
2351                let count = fill_gradient_entries(colors, stops.as_deref());
2352                gradient_params = [
2353                    canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
2354                    canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
2355                    0.0,
2356                    0.0,
2357                ];
2358                (3u32, count, gradient_tile_mode_value(TileMode::Clamp))
2359            }
2360        },
2361    };
2362
2363    // A stroked rect/round-rect was emitted with `local_rect` already
2364    // inflated by half the stroke width, so corner radii must resolve
2365    // against the geometry that was actually asked for, not the
2366    // inflated box. The shader shrinks `half_size` by the same amount.
2367    let stroke_outset = shape
2368        .stroke
2369        .map(|stroke| stroke.half_width())
2370        .unwrap_or(0.0);
2371    let geometry_width = (local_rect.width - stroke_outset * 2.0).max(0.0);
2372    let geometry_height = (local_rect.height - stroke_outset * 2.0).max(0.0);
2373
2374    let radii = if let Some(arc) = shape.arc {
2375        // Arcs never carry corner radii, so this slot ships the shader's
2376        // per-shape trig instead: (sin, cos) of the sweep's mid angle and of
2377        // the half sweep. Computing these here — once per shape — is what
2378        // lets `sdf_arc_band` run without a single transcendental per
2379        // fragment. A full ring is the common case (dots, particles) and
2380        // `ArcGeometry::new` normalizes it to start 0 / sweep TAU, whose
2381        // values are exact constants; the half-sweep sine is pinned to
2382        // non-negative just like the shader used to, so a closed ring keeps
2383        // its seam-free (0, -1) form.
2384        if arc.sweep_angle >= cranpose_ui_graphics::TAU && arc.start_angle == 0.0 {
2385            [0.0, -1.0, 0.0, -1.0]
2386        } else {
2387            let half_sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU) * 0.5;
2388            let (mid_sin, mid_cos) = (arc.start_angle + half_sweep).sin_cos();
2389            let (half_sin, half_cos) = half_sweep.sin_cos();
2390            [mid_sin, mid_cos, half_sin.max(0.0), half_cos]
2391        }
2392    } else if let Some(rounded) = shape.shape {
2393        let resolved = rounded.resolve(geometry_width, geometry_height);
2394        [
2395            resolved.top_left * root_scale,
2396            resolved.top_right * root_scale,
2397            resolved.bottom_left * root_scale,
2398            resolved.bottom_right * root_scale,
2399        ]
2400    } else {
2401        [0.0, 0.0, 0.0, 0.0]
2402    };
2403
2404    let device_rect = [
2405        device_local_rect.x,
2406        device_local_rect.y,
2407        device_local_rect.width,
2408        device_local_rect.height,
2409    ];
2410
2411    // Stroke/arc parameters ride in the same ShapeData and the same
2412    // pipeline as fills, so a stroked or arc shape never splits a
2413    // batch.
2414    let (stroke_params, arc_params) = match (shape.arc, shape.stroke) {
2415        (Some(arc), _) => (
2416            [
2417                0.0,
2418                pack_shape_flags(SHAPE_KIND_ARC, arc.cap, StrokeJoin::Miter),
2419                arc.outer_radius * root_scale,
2420                arc.inner_radius * root_scale,
2421            ],
2422            [
2423                (arc.center.x + snap_delta.x) * root_scale,
2424                (arc.center.y + snap_delta.y) * root_scale,
2425                arc.start_angle,
2426                arc.sweep_angle,
2427            ],
2428        ),
2429        (None, Some(stroke)) => (
2430            [
2431                stroke.width.max(0.0) * root_scale,
2432                pack_shape_flags(SHAPE_KIND_STROKE, stroke.cap, stroke.join),
2433                0.0,
2434                0.0,
2435            ],
2436            [0.0; 4],
2437        ),
2438        (None, None) => (
2439            [
2440                0.0,
2441                pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
2442                0.0,
2443                0.0,
2444            ],
2445            [0.0; 4],
2446        ),
2447    };
2448
2449    let color = match &shape.brush {
2450        SceneBrush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2451        SceneBrush::Gradient(index) => match &brushes[*index as usize] {
2452            Brush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2453            Brush::LinearGradient { colors, .. } => {
2454                let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2455                [first.r(), first.g(), first.b(), first.a()]
2456            }
2457            Brush::RadialGradient { colors, .. } | Brush::SweepGradient { colors, .. } => {
2458                let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2459                [first.r(), first.g(), first.b(), first.a()]
2460            }
2461        },
2462    };
2463
2464    *shape_out = ShapeData {
2465        rect: device_rect,
2466        radii,
2467        gradient_params,
2468        clip_rect,
2469        stroke_params,
2470        arc_params,
2471        quad01: [
2472            device_quad[0][0],
2473            device_quad[0][1],
2474            device_quad[1][0],
2475            device_quad[1][1],
2476        ],
2477        quad23: [
2478            device_quad[2][0],
2479            device_quad[2][1],
2480            device_quad[3][0],
2481            device_quad[3][1],
2482        ],
2483        color,
2484        brush_type,
2485        gradient_start,
2486        gradient_count,
2487        gradient_tile_mode,
2488    };
2489}
2490
2491/// `CRANPOSE_QUAD_AREA_DIAG=1` prints, per shape batch, how many device
2492/// pixels the emitted quads cover — split into arc quads, the true arc band
2493/// coverage inside them, and everything else. Fill cost is the product of
2494/// fragment count and shader cost, and this is the fragment-count half: it
2495/// is how the MEGA scene's ~10x overdraw (and the ~50% of arc-quad area that
2496/// the SDF discards) was measured.
2497fn quad_area_diag_enabled() -> bool {
2498    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2499    *ENABLED.get_or_init(|| std::env::var_os("CRANPOSE_QUAD_AREA_DIAG").is_some())
2500}
2501
2502/// Converts a batch of shapes into pre-sized output slices, fanning the work
2503/// across scoped threads when the batch is large enough to pay for spawns.
2504/// The outputs may be scratch vectors or mapped GPU staging memory; each
2505/// shape writes only its own disjoint slots, so chunked `split_at_mut`
2506/// hand-off keeps the parallel path free of any synchronization.
2507fn convert_shapes_into_outputs(
2508    shape_refs: &[&DrawShape],
2509    brushes: &[Brush],
2510    gradient_offsets: &[u32],
2511    root_scale: f32,
2512    shape_data_out: &mut [ShapeData],
2513    gradients_out: &mut [GradientStop],
2514) {
2515    let shape_count = shape_refs.len();
2516    #[cfg(not(target_arch = "wasm32"))]
2517    let convert_started = Instant::now();
2518    #[cfg(not(target_arch = "wasm32"))]
2519    let parallel =
2520        SHAPE_CONVERT_TUNER.choose_parallel(shape_count) && shape_convert_worker_count() > 1;
2521    if quad_area_diag_enabled() {
2522        let quad_area = |q: [[f32; 2]; 4]| {
2523            // Shoelace over the quad polygon TL, TR, BR, BL (corners 0,1,3,2).
2524            let poly = [q[0], q[1], q[3], q[2]];
2525            let mut twice = 0.0f64;
2526            for i in 0..4 {
2527                let a = poly[i];
2528                let b = poly[(i + 1) % 4];
2529                twice += a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64;
2530            }
2531            twice.abs() * 0.5
2532        };
2533        let mut arc_quad = 0.0f64; // quad px of arc shapes
2534        let mut arc_band = 0.0f64; // true band coverage of those arcs
2535        let mut arc_count = 0usize;
2536        let mut ring_count = 0usize;
2537        let mut other_quad = 0.0f64;
2538        let mut other_count = 0usize;
2539        // Largest non-arc quads: (area, index) so the tail of the diag can
2540        // name what the aggregate "other" fill actually is.
2541        let mut top_other: Vec<(f64, usize)> = Vec::new();
2542        for (index, shape) in shape_refs.iter().enumerate() {
2543            let area = quad_area(shape.quad);
2544            if let Some(arc) = shape.arc {
2545                arc_quad += area;
2546                arc_count += 1;
2547                if arc.sweep_angle >= cranpose_ui_graphics::TAU {
2548                    ring_count += 1;
2549                }
2550                let ra = arc.mid_radius() as f64;
2551                let rb = arc.half_thickness() as f64;
2552                arc_band +=
2553                    arc.sweep_angle as f64 * ra * (2.0 * rb) + std::f64::consts::PI * rb * rb;
2554            } else {
2555                other_quad += area;
2556                other_count += 1;
2557                top_other.push((area, index));
2558            }
2559        }
2560        let scale2 = (root_scale as f64) * (root_scale as f64);
2561        eprintln!(
2562            "[quad-area] arcs={arc_count} (rings={ring_count}) arc_quad_px={:.0} arc_band_px={:.0} | other={other_count} other_px={:.0}",
2563            arc_quad * scale2,
2564            arc_band * scale2,
2565            other_quad * scale2,
2566        );
2567        top_other.sort_by(|a, b| b.0.total_cmp(&a.0));
2568        for &(area, index) in top_other.iter().take(4) {
2569            let shape = shape_refs[index];
2570            let brush = match shape.brush.resolve(brushes).as_ref() {
2571                cranpose_ui_graphics::Brush::Solid(color) => format!("solid a={:.2}", color.3),
2572                cranpose_ui_graphics::Brush::LinearGradient { colors, .. } => {
2573                    format!("linear n={}", colors.len())
2574                }
2575                cranpose_ui_graphics::Brush::RadialGradient { colors, .. } => {
2576                    format!("radial n={}", colors.len())
2577                }
2578                cranpose_ui_graphics::Brush::SweepGradient { colors, .. } => {
2579                    format!("sweep n={}", colors.len())
2580                }
2581            };
2582            eprintln!(
2583                "[quad-area]   top other: {:.0}px {}x{} at ({:.0},{:.0}) {} shape={} stroke={} clip={} blend={:?} z={}",
2584                area * scale2,
2585                shape.rect.width.round(),
2586                shape.rect.height.round(),
2587                shape.rect.x,
2588                shape.rect.y,
2589                brush,
2590                shape.shape.is_some(),
2591                shape.stroke.is_some(),
2592                shape.clip.is_some(),
2593                shape.blend_mode,
2594                shape.z_index,
2595            );
2596        }
2597    }
2598    #[cfg(target_arch = "wasm32")]
2599    let parallel = false;
2600    let workers = if parallel {
2601        shape_convert_worker_count()
2602    } else {
2603        1
2604    };
2605    if workers <= 1 {
2606        for (idx, shape) in shape_refs.iter().enumerate() {
2607            let gradient_start = gradient_offsets[idx];
2608            let gradient_end = gradient_offsets[idx + 1];
2609            convert_shape_into_slots(
2610                shape,
2611                brushes,
2612                root_scale,
2613                gradient_start,
2614                &mut shape_data_out[idx],
2615                &mut gradients_out[gradient_start as usize..gradient_end as usize],
2616            );
2617        }
2618        #[cfg(not(target_arch = "wasm32"))]
2619        SHAPE_CONVERT_TUNER.record(
2620            false,
2621            shape_count,
2622            convert_started.elapsed().as_nanos() as u64,
2623        );
2624        return;
2625    }
2626
2627    let chunk_len = shape_count.div_ceil(workers);
2628    let mut shape_data_rest = shape_data_out;
2629    let mut gradients_rest = gradients_out;
2630    std::thread::scope(|scope| {
2631        let mut chunk_start = 0usize;
2632        while chunk_start < shape_count {
2633            let chunk_end = (chunk_start + chunk_len).min(shape_count);
2634            let count = chunk_end - chunk_start;
2635            let gradient_base = gradient_offsets[chunk_start];
2636            let gradient_span = (gradient_offsets[chunk_end] - gradient_base) as usize;
2637            let (shape_data_chunk, rest) = std::mem::take(&mut shape_data_rest).split_at_mut(count);
2638            shape_data_rest = rest;
2639            let (gradient_chunk, rest) =
2640                std::mem::take(&mut gradients_rest).split_at_mut(gradient_span);
2641            gradients_rest = rest;
2642            let chunk_refs = &shape_refs[chunk_start..chunk_end];
2643            let chunk_offsets = &gradient_offsets[chunk_start..=chunk_end];
2644            let mut convert_chunk = move || {
2645                for (j, shape) in chunk_refs.iter().enumerate() {
2646                    let gradient_start = chunk_offsets[j];
2647                    let local_start = (gradient_start - gradient_base) as usize;
2648                    let local_end = (chunk_offsets[j + 1] - gradient_base) as usize;
2649                    convert_shape_into_slots(
2650                        shape,
2651                        brushes,
2652                        root_scale,
2653                        gradient_start,
2654                        &mut shape_data_chunk[j],
2655                        &mut gradient_chunk[local_start..local_end],
2656                    );
2657                }
2658            };
2659            if chunk_end == shape_count {
2660                // The caller would only block at the scope join; converting
2661                // the final chunk inline puts that time to work and saves a
2662                // spawn.
2663                convert_chunk();
2664            } else {
2665                scope.spawn(convert_chunk);
2666            }
2667            chunk_start = chunk_end;
2668        }
2669    });
2670    #[cfg(not(target_arch = "wasm32"))]
2671    SHAPE_CONVERT_TUNER.record(
2672        true,
2673        shape_count,
2674        convert_started.elapsed().as_nanos() as u64,
2675    );
2676}
2677
2678#[repr(C)]
2679#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2680struct GradientStop {
2681    color: [f32; 4],
2682    position: [f32; 4],
2683}
2684
2685/// How many replay slots the shared transform buffer holds. Each slot's
2686/// transform lives at `slot * REPLAY_TRANSFORM_STRIDE`, aligned for the
2687/// strictest uniform-offset requirement any backend reports.
2688#[cfg(not(target_arch = "wasm32"))]
2689const MAX_REPLAY_SLOTS: u32 = 128;
2690#[cfg(not(target_arch = "wasm32"))]
2691const REPLAY_TRANSFORM_STRIDE: u64 = 256;
2692
2693/// One retained replay batch: converted shape slots captured on an earlier
2694/// frame, kept on the GPU and re-drawn each frame under the similarity
2695/// transform staged at `transform_offset`.
2696///
2697/// The immutable `ShapeData` and gradient buffers hold no handle here:
2698/// nothing addresses them after capture, and `bind_group` keeps them alive.
2699#[cfg(not(target_arch = "wasm32"))]
2700struct ReplaySlot {
2701    /// One `vec4<f32>` color per shape — the mutable paint the shader reads
2702    /// under `paint_select`, split out so recolor patches upload 16 bytes
2703    /// per shape while the 160-byte `ShapeData` stays immutable on the GPU
2704    /// from capture to release.
2705    paint_buffer: wgpu::Buffer,
2706    bind_group: wgpu::BindGroup,
2707    shape_count: u32,
2708    /// CPU mirror of the paint buffer. Recolor patches apply here first
2709    /// and upload as one contiguous span per slot per frame — MEGA's
2710    /// twinkle field recolors ~1.7k dots a frame, and that many individual
2711    /// copy commands stall a mobile GPU for longer than the spans' extra
2712    /// bytes ever could.
2713    paint_mirror: Vec<[f32; 4]>,
2714    /// Conservative capture-space arc/ring mesh, built once at capture.
2715    /// `None` when the kill switch is off, the slot meshed no shapes (none
2716    /// over the size gate), or the vertex budget overflowed — those slots
2717    /// replay through the quad-expansion six-vertices-per-shape path.
2718    mesh: Option<ReplaySlotMesh>,
2719    /// Which capture created this slot's buffers, from the store's global
2720    /// monotone counter. Retained bundle keys carry it so a slot id that is
2721    /// released and recaptured — new bind group, new buffers, same id — can
2722    /// never be drawn through a bundle recorded against the old capture.
2723    capture_epoch: u64,
2724    /// Whether any captured shape carries gradient stops. False routes the
2725    /// slot's quad-expansion draws through the `fs_solid` pipelines; fixed
2726    /// for the life of the capture, so bundle keys need nothing beyond the
2727    /// capture epoch they already carry.
2728    has_gradient: bool,
2729    /// Per-shape capture-space fill records for the `CRANPOSE_FILL_DIAG`
2730    /// instrument (`shape_count` entries): submitted area (mesh triangles
2731    /// when this slot replays its arc mesh, bounding quads otherwise),
2732    /// analytic lit area, opacity class and quad AABB. Empty when the
2733    /// diagnostic is off.
2734    fill_diag_shapes: Vec<FillDiagShapeRecord>,
2735    /// Per-shape capture-space quad AABBs (`[min_x, min_y, max_x, max_y]`,
2736    /// `shape_count` entries) — the segment-surface cache's geometry
2737    /// source. Always computed (one min/max pass over corners already in
2738    /// cache at capture), so a slot captured while that cache was off still
2739    /// serves it after an opt-in flip.
2740    shape_aabbs: Vec<[f32; 4]>,
2741    /// Running quad-area prefix sum (`shape_count + 1` entries): shape
2742    /// range `a..b` submits `area_prefix[b] - area_prefix[a]` device px²
2743    /// of quads at capture scale.
2744    area_prefix: Vec<f32>,
2745    /// Ratio of actually-submitted pixels to plain quad pixels when this
2746    /// slot replays its arc mesh (1.0 unmeshed) — the segment-surface
2747    /// economics gate prices the direct path by what it truly rasterizes.
2748    submitted_area_scale: f32,
2749}
2750
2751/// Band geometry a retained slot replays for its MESHED shapes only: arc
2752/// and stroked-circle rim bands over the size gate get trapezoid strips
2753/// covering their antialiasing footprint, while every other shape stays on
2754/// the latched instanced-quad path — the draw walk alternates between the
2755/// two along the shape range ([`GpuRenderer::encode_retained_op`]). The
2756/// buffers never hold passthrough quads: routing them through per-vertex
2757/// `MeshVertex` attributes instead of instancing's shared storage reads is
2758/// what the watch A/B measured as a 2-5 fps LOSS (see
2759/// [`arc_mesh_enabled`]). See [`build_arc_mesh_vertices`].
2760#[cfg(not(target_arch = "wasm32"))]
2761struct ReplaySlotMesh {
2762    vertex_buffer: wgpu::Buffer,
2763    /// `u32` triangle-list indices into `vertex_buffer`: band-boundary
2764    /// vertices are emitted once and shared by both adjacent trapezoids, so
2765    /// per-arc vertex-shader work drops from ~30 executions to the unique
2766    /// boundary vertices (~10-14) — the amplification that made the
2767    /// non-indexed mesh SLOWER than plain quads on the watch's Adreno 702.
2768    index_buffer: wgpu::Buffer,
2769    /// Prefix table, `shape_count + 1` entries: shape `i`'s triangles occupy
2770    /// indices `index_prefix[i]..index_prefix[i + 1]`; an EMPTY range marks
2771    /// a shape the draw walk keeps instanced. A run of meshed shapes draws
2772    /// as one `draw_indexed` over its combined range — identical shape
2773    /// order, z untouched.
2774    index_prefix: Vec<u32>,
2775    /// Capture engagement counts for the test/diagnostic view
2776    /// ([`GpuRenderer::replay_slot_mesh_engagement`]): shapes meshed as arc
2777    /// bands, shapes meshed as stroked-circle rim bands, and shapes that
2778    /// stayed on the instanced-quad path (gate-rejected or non-band).
2779    meshed_arcs: usize,
2780    meshed_rims: usize,
2781    passthrough: usize,
2782}
2783
2784/// Vertex of a retained slot's conservative arc mesh: capture-device-space
2785/// position, the uv reproducing `vs_main`'s affine rect map at that position,
2786/// and the shape index standing in for `vertex_index / 6`.
2787#[cfg(not(target_arch = "wasm32"))]
2788#[repr(C)]
2789#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2790struct MeshVertex {
2791    position: [f32; 2],
2792    uv: [f32; 2],
2793    shape_idx: u32,
2794}
2795
2796#[cfg(not(target_arch = "wasm32"))]
2797impl MeshVertex {
2798    const ATTRIBS: [wgpu::VertexAttribute; 3] =
2799        wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32];
2800
2801    fn desc() -> wgpu::VertexBufferLayout<'static> {
2802        wgpu::VertexBufferLayout {
2803            array_stride: std::mem::size_of::<MeshVertex>() as wgpu::BufferAddress,
2804            step_mode: wgpu::VertexStepMode::Vertex,
2805            attributes: &Self::ATTRIBS,
2806        }
2807    }
2808}
2809
2810/// Kill switch, mirroring `command_feed_enabled`: default ON,
2811/// `CRANPOSE_ARC_MESH=0` (or the `debug.cranpose.arc_mesh` property on
2812/// Android) makes the next capture skip mesh building entirely, so a device
2813/// A/B needs no rebuild. Read per capture — captures are rare.
2814#[cfg(not(target_arch = "wasm32"))]
2815fn arc_mesh_enabled() -> bool {
2816    // OPT-IN by measurement, size gate and all: alternating watch pairs
2817    // (Adreno 702, mega scene, gate at its 16384 px² default — 2 shapes
2818    // meshed, ~550 passthrough per slot) read mesh ON 48.7/43.5 fps vs
2819    // OFF 53.7/45.2 — both pairs lose. The earlier all-arcs regime lost
2820    // 4-11 fps; the gate shrank the loss, never crossed zero. The likely
2821    // mechanism is structural: a slot holding a mesh leaves the latched
2822    // instanced-quad path for EVERY shape in the slot, so its passthrough
2823    // quads pay per-vertex attribute bandwidth where the instanced path
2824    // paid shared storage reads — on a bandwidth-bound part that swamps
2825    // the meshed shapes' fill recovery (fill-truth: 0.45 Mpx/frame of
2826    // retained slack, 86-94% in a handful of huge ring/rim shapes). The
2827    // measured WIN regime stays the DYNAMIC transient rim mesh
2828    // ([`rim_mesh_band`], +9 fps, default on). A retry that could earn
2829    // default-on: split a meshed slot's draw so passthrough shapes stay
2830    // instanced and only gate-passing shapes take the mesh.
2831    matches!(std::env::var("CRANPOSE_ARC_MESH").as_deref(), Ok("1"))
2832}
2833
2834/// Dilation applied to the band's half-thickness before meshing, in capture
2835/// device pixels. The fragment SDF feathers over ±0.5 px
2836/// (`smoothstep(-0.5, 0.5, dist)`), so every pixel the shader keeps sits
2837/// within 0.5 px of the band; the other 0.5 px absorbs f32 slop between this
2838/// builder's trig and the converted shape's precomputed (sin, cos) pairs.
2839#[cfg(not(target_arch = "wasm32"))]
2840const ARC_MESH_MARGIN: f32 = 1.0;
2841
2842/// Chord overshoot budget in pixels: the segment count is chosen so pushing
2843/// outer edges tangent-outside the dilated outer circle overshoots it by
2844/// about this much at the chord ends.
2845#[cfg(not(target_arch = "wasm32"))]
2846const ARC_MESH_OVERSHOOT: f32 = 2.0;
2847
2848#[cfg(not(target_arch = "wasm32"))]
2849const ARC_MESH_MIN_SEGMENTS: usize = 4;
2850#[cfg(not(target_arch = "wasm32"))]
2851const ARC_MESH_MAX_SEGMENTS: usize = 64;
2852
2853/// Per-slot geometry budget in BYTES: 48 vertex-equivalents (~1 KB) per
2854/// shape, floored for tiny slots so a single huge ring still fits. The
2855/// non-indexed mesh spent this entirely on 20-byte vertices; the indexed
2856/// mesh counts vertices AND 4-byte indices against the same byte ceiling,
2857/// which indexed geometry fits with more headroom (MEGA's retained arcs
2858/// drop from ~30 vertices ≈ 600 B to ~12 unique vertices + ~30 indices
2859/// ≈ 360 B). Overflow falls back to whole-slot passthrough WITH a warning —
2860/// truncating silently would break the containment invariant.
2861#[cfg(not(target_arch = "wasm32"))]
2862const ARC_MESH_BUDGET_BYTES_PER_SHAPE: usize = 48 * std::mem::size_of::<MeshVertex>();
2863#[cfg(not(target_arch = "wasm32"))]
2864const ARC_MESH_BUDGET_FLOOR_BYTES: usize = 4096 * std::mem::size_of::<MeshVertex>();
2865
2866/// The budget-relevant size of an indexed mesh: what the GPU buffers will
2867/// actually hold.
2868#[cfg(not(target_arch = "wasm32"))]
2869fn arc_mesh_bytes(vertices: usize, indices: usize) -> usize {
2870    vertices * std::mem::size_of::<MeshVertex>() + indices * std::mem::size_of::<u32>()
2871}
2872
2873/// Ceiling on a capture's maximal runs of consecutive meshed shapes
2874/// ([`ArcMeshBuild::meshed_stretches`]). The draw walk alternates between
2875/// the mesh pipeline and the instanced-quad pipeline along the shape range,
2876/// so every meshed stretch costs an op that covers it two pipeline switches
2877/// plus an index-buffer rebind; a slot whose meshed shapes interleave
2878/// pathologically with passthrough ones would trade the fill win for
2879/// switch thrash. Past this cap the capture keeps NO mesh and the whole
2880/// slot stays on the instanced path — a structural property of the
2881/// captured content, not of any app. Eight stretches bound an op at
2882/// seventeen draws; the measured scene's slots hold two.
2883#[cfg(not(target_arch = "wasm32"))]
2884const MESH_SLOT_MAX_STRETCHES: usize = 8;
2885
2886/// Default size gate for the retained capture mesh, in capture-space px² of
2887/// a shape's bounding quad: shapes below it take the passthrough quad even
2888/// when they qualify geometrically.
2889///
2890/// The default follows from the trade's own economics, not from any one
2891/// scene. A band mesh costs a roughly shape-size-independent overhead — up
2892/// to [`ARC_MESH_MAX_SEGMENTS`] trapezoids of vertex work plus the extra
2893/// primitives' setup and bin-list traffic on a tiling GPU — while what it
2894/// can recover scales with the shape's quad area times its discard-slack
2895/// fraction (an arc or ring band fills only O(perimeter x thickness) of
2896/// its box, so the slack fraction RISES with size: big bands are almost
2897/// all slack, tiny ones barely any). Fixed cost against area-proportional
2898/// benefit crosses zero at some quad size; 16384 px² (a 128 px square)
2899/// puts the gate an order of magnitude above the measured loss regime and
2900/// an order below the measured win regime, so it is margin, not tuning:
2901/// on the Adreno 702 meshing ~14k retained ~100-800 px² shapes lost
2902/// 4-11 fps, while the same mesher over only large shapes wins on the same
2903/// GPU (the shipping [`rim_mesh_band`] path, gated at 65536 px²), and
2904/// fill-truth's top retained slack sits at ~19k px² and up (86-94% slack).
2905/// Any app whose retained content mixes the two populations lands on the
2906/// same split; a device where the crossover measurably differs A/Bs the
2907/// threshold through the override below without a rebuild.
2908#[cfg(not(target_arch = "wasm32"))]
2909const RETAINED_MESH_MIN_PX2_DEFAULT: usize = 16384;
2910/// Clamp for the `CRANPOSE_RETAINED_MESH_PX2` override: below ~1k px² the
2911/// tiny-mesh amplification regime demonstrably returns, and above 256k px²
2912/// the gate exceeds a whole 512x512 quad — both ends are "you no longer
2913/// mean the size gate", not useful A/B settings.
2914#[cfg(not(target_arch = "wasm32"))]
2915const RETAINED_MESH_MIN_PX2_RANGE: std::ops::RangeInclusive<usize> = 1024..=262144;
2916
2917/// The retained capture mesh's size gate in px², default
2918/// [`RETAINED_MESH_MIN_PX2_DEFAULT`], overridable for device A/Bs via
2919/// `CRANPOSE_RETAINED_MESH_PX2` (the `debug.cranpose.retained_mesh_px2`
2920/// property on Android), clamped to [`RETAINED_MESH_MIN_PX2_RANGE`]. Read
2921/// per capture like [`arc_mesh_enabled`] — captures are rare.
2922#[cfg(not(target_arch = "wasm32"))]
2923fn retained_mesh_min_px2() -> f64 {
2924    parse_retained_mesh_min_px2(std::env::var("CRANPOSE_RETAINED_MESH_PX2").ok().as_deref())
2925}
2926
2927#[cfg(not(target_arch = "wasm32"))]
2928fn parse_retained_mesh_min_px2(value: Option<&str>) -> f64 {
2929    value
2930        .and_then(|value| value.trim().parse::<usize>().ok())
2931        .map(|px2| {
2932            px2.clamp(
2933                *RETAINED_MESH_MIN_PX2_RANGE.start(),
2934                *RETAINED_MESH_MIN_PX2_RANGE.end(),
2935            )
2936        })
2937        .unwrap_or(RETAINED_MESH_MIN_PX2_DEFAULT) as f64
2938}
2939
2940/// Band parameters of a captured arc that qualifies for a conservative mesh:
2941/// solid brush, no clip, and a quad that is exactly — tolerance zero — the
2942/// axis-aligned box of its rect. Everything else returns `None` and passes
2943/// through as today's two quad triangles.
2944#[cfg(not(target_arch = "wasm32"))]
2945struct ArcMeshBand {
2946    center: [f32; 2],
2947    inner: f32,
2948    outer: f32,
2949    start: f32,
2950    sweep: f32,
2951}
2952
2953#[cfg(not(target_arch = "wasm32"))]
2954fn arc_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
2955    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
2956    let flags = shape.stroke_params[1].max(0.0) as u32;
2957    if flags & 3 != SHAPE_KIND_ARC {
2958        return None;
2959    }
2960    // Solid brushes only: gradients also derive from `rect_pos` and would
2961    // mesh in principle, but the hot retained scenes are solid and a narrow
2962    // gate keeps the byte-exactness surface small.
2963    if shape.brush_type != 0 {
2964        return None;
2965    }
2966    // A live clip is a hard `world_pos` comparison in the fragment shader.
2967    // Meshed arcs interpolate `world_pos` across different triangles than
2968    // the quad would, and one ulp of difference at the clip boundary flips
2969    // whole pixels — clipped arcs pass through untouched.
2970    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
2971        return None;
2972    }
2973    let [_, _, w, h] = shape.rect;
2974    if !(w > 0.0 && h > 0.0) {
2975        return None;
2976    }
2977    // The quad must be an axis-aligned box, tolerance zero: the mesh is
2978    // clipped to the quad's own corners, so as long as the quad IS a box its
2979    // rasterized pixel set equals the mesh clip region and the tight-AABB
2980    // tangent-point crop is reproduced exactly. (Comparing against `rect`
2981    // instead is an over-tight gate: under a non-dyadic root scale
2982    // `(x + w) * s` differs from `x * s + w * s` by an ulp and every arc
2983    // fell back to passthrough — observed on the Huawei at scale 2.75.)
2984    let [left, top, right, _] = shape.quad01;
2985    let [bl_x, bottom, br_x, br_y] = shape.quad23;
2986    let axis_aligned = shape.quad01[3] == top
2987        && bl_x == left
2988        && br_x == right
2989        && br_y == bottom
2990        && left < right
2991        && top < bottom;
2992    if !axis_aligned {
2993        return None;
2994    }
2995    let center = [shape.arc_params[0], shape.arc_params[1]];
2996    let start = shape.arc_params[2];
2997    let sweep = shape.arc_params[3];
2998    let outer = shape.stroke_params[2];
2999    let inner = shape.stroke_params[3];
3000    let finite = center[0].is_finite()
3001        && center[1].is_finite()
3002        && start.is_finite()
3003        && sweep.is_finite()
3004        && outer.is_finite()
3005        && inner.is_finite();
3006    if !finite || outer <= 0.0 || sweep <= 0.0 {
3007        return None;
3008    }
3009    Some(ArcMeshBand {
3010        center,
3011        inner,
3012        outer,
3013        start,
3014        sweep,
3015    })
3016}
3017
3018/// Kill switch for the transient rim band mesh, mirroring
3019/// [`arc_mesh_enabled`]'s property bridge: `CRANPOSE_RIM_MESH=0` (or the
3020/// `debug.cranpose.rim_mesh` property on Android) makes the fused shape
3021/// prepare skip rim detection entirely, so a device A/B needs no rebuild.
3022/// Default ON — the rim path only ever meshed a handful of huge shapes per
3023/// frame, which is the regime that WINS on the watch GPU (and the proof the
3024/// retained mesh's size gate is built on; see [`arc_mesh_enabled`]).
3025/// Read once per fused-chunk prepare (cheap), not per shape.
3026#[cfg(not(target_arch = "wasm32"))]
3027fn rim_mesh_enabled() -> bool {
3028    !matches!(std::env::var("CRANPOSE_RIM_MESH").as_deref(), Ok("0"))
3029}
3030
3031/// Fixed capacity of the per-frame transient rim mesh vertex buffer, in
3032/// vertices. The buffers are never recreated mid-frame — draws are encoded
3033/// before submit, so a reallocation would orphan already-encoded rims — and
3034/// overflow means "skip the rim, draw it as a quad", never truncation.
3035/// MEGA's arena meshes 2-3 rims per frame at ~80 vertices each (measured on
3036/// the Pixel Watch 3 via the emit log below), so ~100 rims of headroom; the
3037/// rate-limited warn below is the tell if a scene ever exceeds it.
3038#[cfg(not(target_arch = "wasm32"))]
3039const RIM_MESH_VERTEX_CAPACITY: usize = 8192;
3040/// Fixed capacity of the per-frame transient rim mesh index buffer, in
3041/// `u32` indices.
3042#[cfg(not(target_arch = "wasm32"))]
3043const RIM_MESH_INDEX_CAPACITY: usize = 32768;
3044
3045/// A dynamic shape inside a fused chunk that draws as a band mesh instead of
3046/// its full bounding quad: `shape_index` is the shape's position within the
3047/// whole fused upload (the index `vs_mesh` reads into the storage shape
3048/// array), `first_index..first_index + index_count` its span of the frame's
3049/// transient rim index buffer.
3050#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3051#[derive(Clone, Copy, Debug)]
3052struct RimDraw {
3053    shape_index: u32,
3054    first_index: u32,
3055    index_count: u32,
3056}
3057
3058/// Rate-limited overflow warning: silent skipping would hide a scene whose
3059/// rims permanently miss the fast path, while warning every frame would
3060/// flood the watch's logcat.
3061#[cfg(not(target_arch = "wasm32"))]
3062fn rim_mesh_capacity_warn() {
3063    use std::sync::atomic::{AtomicU64, Ordering};
3064    static OVERFLOWS: AtomicU64 = AtomicU64::new(0);
3065    let count = OVERFLOWS.fetch_add(1, Ordering::Relaxed);
3066    if count.is_multiple_of(512) {
3067        log::warn!(
3068            "[rim-mesh] transient buffers full; rim falls back to quad expansion \
3069             (lifetime overflows {})",
3070            count + 1,
3071        );
3072    }
3073}
3074
3075/// Band parameters of a stroked round-rect whose outline is geometrically a
3076/// circle — an arena "rim". Everything else returns `None` and rasterizes
3077/// through the ordinary quad expansion. Two callers, each behind its own
3078/// size gate: the DYNAMIC fused path via [`rim_mesh_band`], and the
3079/// retained capture builder ([`build_arc_mesh_vertices`]) via
3080/// [`retained_mesh_min_px2`] — retained slots hold big static ring circles
3081/// the dynamic path never sees.
3082///
3083/// Derivation: `ShapeData::rect` for a stroked shape is the stroke-inflated
3084/// box (geometry plus half the stroke width on each side), so the geometry
3085/// half-extent is `geom_half = (rect.w - stroke_width) / 2`. When the corner
3086/// radius equals that half-extent the outline is a circle of radius
3087/// `geom_half`, and `sdf_stroked_rounded_rect` degenerates exactly to an
3088/// annulus: its outer offset rounded-rect (`half_size` = `geom_half + hw`,
3089/// radius `geom_half + hw`) is the circle of radius `geom_half + sw/2`, its
3090/// inner offset the circle of radius `geom_half - sw/2` — centerline
3091/// `geom_half`, half-width `sw/2`. The bevel-join chamfer plane can only CUT
3092/// pixels from that annulus (`max(dist, chamfer)`), never add any, so for
3093/// every join style the shader's kept set is a subset of the annulus band.
3094/// [`emit_arc_band_mesh`] adds its own `ARC_MESH_MARGIN`, treats
3095/// `sweep >= TAU` as closed, and clips to the quad box, so containment
3096/// (mesh ⊇ every pixel with `|dist| < 0.5`, mesh ⊆ quad box) follows from
3097/// the same argument the retained arc mesh documents.
3098///
3099/// The CIRCLE gate is what keeps this correct: a false positive on a rounded
3100/// SQUARE ring would under-cover its flat spans and damage pixels, so the
3101/// radius must match `geom_half` to within 0.01 px (a deviation that small
3102/// stays inside the mesh margin's 0.5 px float-slop budget).
3103#[cfg(not(target_arch = "wasm32"))]
3104fn rim_band_geometry(shape: &ShapeData) -> Option<ArcMeshBand> {
3105    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
3106    let flags = shape.stroke_params[1].max(0.0) as u32;
3107    if flags & 3 != SHAPE_KIND_STROKE {
3108        return None;
3109    }
3110    // Solid brushes only — same narrow byte-exactness surface as
3111    // `arc_mesh_band`.
3112    if shape.brush_type != 0 {
3113        return None;
3114    }
3115    // A live clip is a hard `world_pos` comparison in the fragment shader;
3116    // meshed rims interpolate `world_pos` across different triangles and one
3117    // ulp at the clip boundary flips whole pixels.
3118    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
3119        return None;
3120    }
3121    let [x, y, w, h] = shape.rect;
3122    if !(w > 0.0 && h > 0.0) {
3123        return None;
3124    }
3125    // The quad must be an axis-aligned box, tolerance zero — the identical
3126    // check `arc_mesh_band` makes (compare quad corners against each other,
3127    // never against `rect`, which differs by an ulp under non-dyadic root
3128    // scales).
3129    let [left, top, right, _] = shape.quad01;
3130    let [bl_x, bottom, br_x, br_y] = shape.quad23;
3131    let axis_aligned = shape.quad01[3] == top
3132        && bl_x == left
3133        && br_x == right
3134        && br_y == bottom
3135        && left < right
3136        && top < bottom;
3137    if !axis_aligned {
3138        return None;
3139    }
3140    // A circle's box is square, bitwise.
3141    if w.to_bits() != h.to_bits() {
3142        return None;
3143    }
3144    // All four corner radii bitwise equal, finite and positive.
3145    let [r0, r1, r2, r3] = shape.radii;
3146    if r0.to_bits() != r1.to_bits() || r0.to_bits() != r2.to_bits() || r0.to_bits() != r3.to_bits()
3147    {
3148        return None;
3149    }
3150    if !r0.is_finite() || r0 <= 0.0 {
3151        return None;
3152    }
3153    let sw = shape.stroke_params[0];
3154    if !sw.is_finite() || sw <= 0.0 {
3155        return None;
3156    }
3157    // Finiteness before the circle gate: with every operand finite the
3158    // radius comparison below cannot see a NaN.
3159    let geom_half = (w - sw) * 0.5;
3160    let center = [x + w * 0.5, y + h * 0.5];
3161    let inner = geom_half - sw * 0.5;
3162    let outer = geom_half + sw * 0.5;
3163    let finite =
3164        center[0].is_finite() && center[1].is_finite() && inner.is_finite() && outer.is_finite();
3165    if !finite || outer <= 0.0 {
3166        return None;
3167    }
3168    // The circle gate (see the doc comment).
3169    if (r0 - geom_half).abs() > 0.01 {
3170        return None;
3171    }
3172    Some(ArcMeshBand {
3173        center,
3174        inner,
3175        outer,
3176        start: 0.0,
3177        sweep: cranpose_ui_graphics::TAU,
3178    })
3179}
3180
3181/// [`rim_band_geometry`] behind the DYNAMIC path's size gate. Big shapes
3182/// only: the win is proportional to the discarded quad area, and small
3183/// quads are cheaper than the extra pipeline switches.
3184#[cfg(not(target_arch = "wasm32"))]
3185fn rim_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
3186    let [_, _, w, h] = shape.rect;
3187    if w * h < 65536.0 {
3188        return None;
3189    }
3190    rim_band_geometry(shape)
3191}
3192
3193/// Kill switch for the opaque static leading-span cache, mirroring
3194/// [`rim_mesh_enabled`]'s property bridge: `CRANPOSE_STATIC_SPAN=0` (or the
3195/// `debug.cranpose.static_span` property on Android) makes the fused
3196/// partition never skip, capture, or blit — a device A/B needs no rebuild.
3197/// Default ON. Read once per engagement attempt (once per frame), so the
3198/// cost is one `env::var` per frame.
3199#[cfg(not(target_arch = "wasm32"))]
3200fn static_span_enabled() -> bool {
3201    !matches!(std::env::var("CRANPOSE_STATIC_SPAN").as_deref(), Ok("0"))
3202}
3203
3204/// Upper bound on how many leading shapes one span may cover. The target
3205/// span (full-screen background rect + vignette disc) is 2 shapes; the cap
3206/// only bounds the per-frame memcmp (16 x 160 B) and the prev-frame copy.
3207#[cfg(not(target_arch = "wasm32"))]
3208const STATIC_SPAN_MAX_SHAPES: usize = 16;
3209
3210/// Consecutive stable frames an EXTENSION of an already-valid span must
3211/// show before an upgrade recapture — see the hysteresis comment in
3212/// [`StaticSpanCache::engage`].
3213#[cfg(not(target_arch = "wasm32"))]
3214const STATIC_SPAN_UPGRADE_FRAMES: u32 = 30;
3215
3216/// What the engagement check decided for this frame's leading fused
3217/// partition.
3218#[cfg(not(target_arch = "wasm32"))]
3219#[derive(Clone, Copy, Debug, PartialEq)]
3220enum StaticSpanDecision {
3221    /// Not engaged: draw everything live, capture nothing.
3222    Pass,
3223    /// The cached span image is valid: skip the first `skip` shapes of the
3224    /// first batch and draw the cached full-target blit before everything.
3225    Hit { skip: usize },
3226    /// The leading `len` shapes were byte-stable across the last two frames
3227    /// but the cache does not match: draw live, then re-capture the span.
3228    Capture { len: usize, clear: wgpu::Color },
3229}
3230
3231/// Cache of the frame's leading static span — the opaque full-screen
3232/// background rect plus whatever byte-stable draws sit directly on top of it
3233/// (MEGA: the ~176k-px radial-gradient vignette disc) — as one composited
3234/// full-target texture that replaces those draws with a single blit.
3235///
3236/// Byte-exactness by construction, no tolerance anywhere:
3237///
3238/// * The engaged partition is the frame's first content (`load_op` is the
3239///   frame `Clear`, gated to alpha == 1.0), so what the live path would put
3240///   under the span is exactly the opaque clear color — and the capture
3241///   pass clears its offscreen with the SAME color before drawing the SAME
3242///   shape range through the IDENTICAL pipelines (same `ShapeData` bytes,
3243///   same gradient stop bytes, same viewport uniforms, same blend state,
3244///   same `has_gradient` pipeline variant, same surface format, identity
3245///   similarity offset 0). Deterministic pipelines on identical inputs give
3246///   identical bytes, so the cached image IS the bytes the live span render
3247///   would produce this frame.
3248/// * With an opaque clear below and SrcOver-only draws above, every texel of
3249///   that composite has alpha exactly 255: each blend step computes
3250///   `a_out = a_src + (1 - a_src) * 1.0`, whose float error is far inside
3251///   the half-level the unorm8 quantizer absorbs, and 255 reads back as
3252///   exactly 1.0 for the next step. The replacement blit then draws SrcOver
3253///   texels whose `1 - src.a` dst factor is exactly zero — the
3254///   fixed-function blender computes `1*src + 0*dst`, a replace-write — and
3255///   an unorm8 texel survives the sample/write round trip bit-exact
3256///   (`CompositeSampleMode::Nearest` is a `textureLoad`, `alpha` is 1.0).
3257///   Hence `over(rest, over(span, clear)) == over(rest, SPAN_IMAGE)`
3258///   bitwise, whatever `rest` is.
3259/// * Gradient dither cannot diverge between capture and screen: `shape.wgsl`
3260///   keys its ordered-dither matrix off `world_pos` — the device coordinate
3261///   interpolated from the `ShapeData` quad corners, deliberately not
3262///   `@builtin(position)` — so the dither phase is a pure function of the
3263///   memcmp'd bytes (see `gradient_dither` in `shape.wgsl`).
3264/// * Rim-mesh candidates ([`rim_mesh_band`] Some) end the span: the live
3265///   path may draw them through the band-mesh pipeline while the capture
3266///   pass draws plain instanced quads, and this cache refuses to depend on
3267///   that pair being byte-equal.
3268///
3269/// Validity is a memcmp: the leading K converted `ShapeData` records plus
3270/// their gradient stop payloads against the cached copy, ~160 B x few
3271/// shapes, sub-microsecond. The span length K itself comes from a two-frame
3272/// stability probe (`prev_shapes`): a capture only happens once the leading
3273/// run has already repeated byte-identically across two consecutive frames,
3274/// so churning scenes never pay the extra capture pass every frame — and
3275/// only when the span carries at least one gradient record, so scenes whose
3276/// leading static draws are all solid (cheap fill the blit cannot beat)
3277/// never engage at all.
3278#[cfg(not(target_arch = "wasm32"))]
3279#[derive(Default)]
3280struct StaticSpanCache {
3281    /// The captured span composite, same size and format as the frame
3282    /// target. Held out of the offscreen pool across frames; released back
3283    /// through the deferred-release path on resize.
3284    texture: Option<OffscreenTarget>,
3285    /// Validity key: the span's converted `ShapeData` records at capture.
3286    key_shapes: Vec<ShapeData>,
3287    /// Validity key: the span's gradient stop payload at capture.
3288    key_gradients: Vec<GradientStop>,
3289    key_width: u32,
3290    key_height: u32,
3291    /// The frame clear color the capture pass cleared with — pixels the
3292    /// span shapes do not fully cover composite against it, so a different
3293    /// clear invalidates the image even when every shape byte matches.
3294    key_clear: [u64; 4],
3295    /// The live first batch's whole-batch `has_gradient` flag at capture:
3296    /// it selects the `fs_solid` vs gradient pipeline variant for every
3297    /// shape in the batch, so the capture is only valid while the live
3298    /// batch would draw the span through the same variant.
3299    key_has_gradient: bool,
3300    /// Last frame's leading records — the two-frame stability probe that
3301    /// decides the span length at capture time.
3302    prev_shapes: Vec<ShapeData>,
3303    prev_gradients: Vec<GradientStop>,
3304    /// Consecutive hit frames whose stable leading run extended past the
3305    /// current key — the upgrade hysteresis counter.
3306    extension_stable_frames: u32,
3307    /// Set once per frame by [`GpuRenderer::render`], consumed by the first
3308    /// fused partition that carries the frame's opaque clear, so offscreen
3309    /// layer or shadow renders (transparent clears) can never engage and a
3310    /// frame engages at most once.
3311    armed: bool,
3312    hits: u64,
3313    recaptures: u64,
3314}
3315
3316#[cfg(not(target_arch = "wasm32"))]
3317impl StaticSpanCache {
3318    /// One engagement attempt per frame, at fused-partition time.
3319    /// `first_batch` is the chunk's first batch when it is a shape batch:
3320    /// (shape count, blend mode, whole-batch has_gradient). `shapes` /
3321    /// `gradients` are the partition's freshly converted scratch buffers,
3322    /// whose leading records belong to the first batch.
3323    fn engage(
3324        &mut self,
3325        load_op: wgpu::LoadOp<wgpu::Color>,
3326        first_batch: Option<(usize, BlendMode, bool)>,
3327        width: u32,
3328        height: u32,
3329        shapes: &[ShapeData],
3330        gradients: &[GradientStop],
3331    ) -> StaticSpanDecision {
3332        if !self.armed || !static_span_enabled() {
3333            return StaticSpanDecision::Pass;
3334        }
3335        let wgpu::LoadOp::Clear(clear) = load_op else {
3336            return StaticSpanDecision::Pass;
3337        };
3338        // The frame's leading clear is the only opaque one a frame stream
3339        // carries (layer and shadow sources clear transparent); engagement
3340        // happens here or not at all this frame.
3341        if clear.a != 1.0 {
3342            return StaticSpanDecision::Pass;
3343        }
3344        self.armed = false;
3345        let Some((batch_len, blend_mode, has_gradient)) = first_batch else {
3346            self.forget_observation();
3347            return StaticSpanDecision::Pass;
3348        };
3349        // SrcOver only: the alpha == 255 argument above is an SrcOver
3350        // property.
3351        if blend_mode != BlendMode::SrcOver || batch_len == 0 {
3352            self.forget_observation();
3353            return StaticSpanDecision::Pass;
3354        }
3355        let leading = &shapes[..batch_len.min(STATIC_SPAN_MAX_SHAPES).min(shapes.len())];
3356        if leading.is_empty() {
3357            self.forget_observation();
3358            return StaticSpanDecision::Pass;
3359        }
3360        if !static_span_fullscreen_opaque(&leading[0], width, height) {
3361            self.forget_observation();
3362            return StaticSpanDecision::Pass;
3363        }
3364        // The span ends at the first shape the capture pass could not
3365        // reproduce through the plain instanced arm (rim-mesh candidates).
3366        let mut eligible = 1;
3367        while eligible < leading.len() && rim_mesh_band(&leading[eligible]).is_none() {
3368            eligible += 1;
3369        }
3370        let leading = &leading[..eligible];
3371        let clear_key = [
3372            clear.r.to_bits(),
3373            clear.g.to_bits(),
3374            clear.b.to_bits(),
3375            clear.a.to_bits(),
3376        ];
3377
3378        let key_len = self.key_shapes.len();
3379        let valid = self.texture.is_some()
3380            && key_len > 0
3381            && key_len <= leading.len()
3382            && self.key_width == width
3383            && self.key_height == height
3384            && self.key_clear == clear_key
3385            && self.key_has_gradient == has_gradient
3386            && span_records_equal(
3387                &self.key_shapes,
3388                &leading[..key_len],
3389                &self.key_gradients,
3390                gradients,
3391            );
3392
3393        // Stability probe, shared by miss-capture and hit-upgrade: the
3394        // longest leading run whose record AND gradient bytes repeat from
3395        // last frame.
3396        let mut stable = 0;
3397        while stable < leading.len()
3398            && stable < self.prev_shapes.len()
3399            && span_records_equal(
3400                &self.prev_shapes[stable..stable + 1],
3401                &leading[stable..stable + 1],
3402                &self.prev_gradients,
3403                gradients,
3404            )
3405        {
3406            stable += 1;
3407        }
3408        self.remember_observation(leading, gradients);
3409
3410        if valid {
3411            // Upgrade hysteresis: a valid span may EXTEND (a partial
3412            // invalidation — say a vignette-only palette change — shrank an
3413            // earlier capture, and the tail has stabilized again) only after
3414            // the extension repeats for a full window of consecutive
3415            // frames. Without it, a leading shape animating with a period
3416            // of a few frames would alternate upgrade-capture and
3417            // shrink-capture forever — capture-churn instead of caching.
3418            // The initial capture below takes no window because the whole
3419            // span stabilizing at once is the cold-start common case. No
3420            // gradient gate here: the stable prefix contains the key, and
3421            // every stored key carries a gradient record.
3422            if stable > key_len {
3423                self.extension_stable_frames += 1;
3424                if self.extension_stable_frames >= STATIC_SPAN_UPGRADE_FRAMES {
3425                    self.extension_stable_frames = 0;
3426                    return StaticSpanDecision::Capture { len: stable, clear };
3427                }
3428            } else {
3429                self.extension_stable_frames = 0;
3430            }
3431            self.hits += 1;
3432            if self.hits.is_multiple_of(600) {
3433                log::debug!(
3434                    "[static-span] {} hits / {} recaptures lifetime (span {} shapes, {}x{})",
3435                    self.hits,
3436                    self.recaptures,
3437                    key_len,
3438                    width,
3439                    height,
3440                );
3441            }
3442            return StaticSpanDecision::Hit { skip: key_len };
3443        }
3444
3445        self.extension_stable_frames = 0;
3446        // Engagement economics: a candidate span with no gradient records
3447        // would replace the cheapest fill there is (solid quads) with a
3448        // same-size texture blit — a wash at best on a mobile GPU, plus a
3449        // held full-target texture and a capture pass. The fill this stage
3450        // chases is the gradient+dither span, so a capture must carry at
3451        // least one gradient record. This also keeps solid-background-only
3452        // frames (most non-game screens) from ever paying an offscreen
3453        // acquire.
3454        if stable == 0 || span_gradient_len(&leading[..stable]) == 0 {
3455            return StaticSpanDecision::Pass;
3456        }
3457        StaticSpanDecision::Capture { len: stable, clear }
3458    }
3459
3460    /// Stores this frame's leading run for next frame's stability probe.
3461    fn remember_observation(&mut self, leading: &[ShapeData], gradients: &[GradientStop]) {
3462        self.prev_shapes.clear();
3463        self.prev_shapes.extend_from_slice(leading);
3464        let stop_len = span_gradient_len(leading);
3465        self.prev_gradients.clear();
3466        self.prev_gradients
3467            .extend_from_slice(&gradients[..stop_len]);
3468    }
3469
3470    fn forget_observation(&mut self) {
3471        self.prev_shapes.clear();
3472        self.prev_gradients.clear();
3473        self.extension_stable_frames = 0;
3474    }
3475
3476    /// Adopts a freshly captured span as the validity key. The caller has
3477    /// already encoded the capture pass into `texture`.
3478    #[allow(clippy::too_many_arguments)]
3479    fn store_key(
3480        &mut self,
3481        span: &[ShapeData],
3482        gradients: &[GradientStop],
3483        width: u32,
3484        height: u32,
3485        clear: wgpu::Color,
3486        has_gradient: bool,
3487    ) {
3488        self.key_shapes.clear();
3489        self.key_shapes.extend_from_slice(span);
3490        let stop_len = span_gradient_len(span);
3491        self.key_gradients.clear();
3492        self.key_gradients.extend_from_slice(&gradients[..stop_len]);
3493        self.key_width = width;
3494        self.key_height = height;
3495        self.key_clear = [
3496            clear.r.to_bits(),
3497            clear.g.to_bits(),
3498            clear.b.to_bits(),
3499            clear.a.to_bits(),
3500        ];
3501        self.key_has_gradient = has_gradient;
3502        self.recaptures += 1;
3503        if self.recaptures.is_multiple_of(64) || self.recaptures == 1 {
3504            log::debug!(
3505                "[static-span] recapture #{} (span {} shapes, {} stops, {}x{}; {} hits lifetime)",
3506                self.recaptures,
3507                self.key_shapes.len(),
3508                self.key_gradients.len(),
3509                width,
3510                height,
3511                self.hits,
3512            );
3513        }
3514    }
3515}
3516
3517/// Total gradient stops a leading span consumes. The span is a prefix of
3518/// the fused upload, so its stop payload is exactly the leading
3519/// `sum(gradient_count)` entries of the scratch gradient buffer.
3520#[cfg(not(target_arch = "wasm32"))]
3521fn span_gradient_len(span: &[ShapeData]) -> usize {
3522    span.iter().map(|shape| shape.gradient_count as usize).sum()
3523}
3524
3525/// Byte equality of two span record runs INCLUDING their gradient stop
3526/// payloads. Each record's stops live at
3527/// `gradient_start..gradient_start + gradient_count` in its frame's leading
3528/// gradient buffer; `gradient_start`/`gradient_count` are part of the
3529/// memcmp'd record bytes, so matching records address matching stop ranges
3530/// in both buffers.
3531#[cfg(not(target_arch = "wasm32"))]
3532fn span_records_equal(
3533    expected: &[ShapeData],
3534    actual: &[ShapeData],
3535    expected_gradients: &[GradientStop],
3536    actual_gradients: &[GradientStop],
3537) -> bool {
3538    if bytemuck::cast_slice::<ShapeData, u8>(expected)
3539        != bytemuck::cast_slice::<ShapeData, u8>(actual)
3540    {
3541        return false;
3542    }
3543    for shape in expected {
3544        let start = shape.gradient_start as usize;
3545        let end = start + shape.gradient_count as usize;
3546        if end > expected_gradients.len() || end > actual_gradients.len() {
3547            return false;
3548        }
3549        if bytemuck::cast_slice::<GradientStop, u8>(&expected_gradients[start..end])
3550            != bytemuck::cast_slice::<GradientStop, u8>(&actual_gradients[start..end])
3551        {
3552            return false;
3553        }
3554    }
3555    true
3556}
3557
3558/// Whether a converted record is the full-screen opaque base the span
3559/// mechanism keys on: a plain solid fill (no stroke, no arc, no gradient,
3560/// no clip, no corner rounding) whose axis-aligned quad covers the whole
3561/// `width` x `height` target with alpha exactly 1.0. Soundness does not
3562/// strictly need full coverage — the opaque clear already makes the
3563/// composite alpha 255 — but requiring the measured scene shape keeps the
3564/// cache from engaging on frames whose leading draw is not the static
3565/// background this stage was built for.
3566#[cfg(not(target_arch = "wasm32"))]
3567fn static_span_fullscreen_opaque(shape: &ShapeData, width: u32, height: u32) -> bool {
3568    if shape.brush_type != 0 || shape.gradient_count != 0 {
3569        return false;
3570    }
3571    if shape.color[3] != 1.0 {
3572        return false;
3573    }
3574    if shape.clip_rect != [0.0; 4] || shape.stroke_params != [0.0; 4] || shape.radii != [0.0; 4] {
3575        return false;
3576    }
3577    // Same corner layout as `rim_mesh_band`: quad01 = TL.xy, TR.xy;
3578    // quad23 = BL.xy, BR.xy.
3579    let [left, top, right, top_right_y] = shape.quad01;
3580    let [bl_x, bottom, br_x, br_y] = shape.quad23;
3581    let axis_aligned = top_right_y == top
3582        && bl_x == left
3583        && br_x == right
3584        && br_y == bottom
3585        && left < right
3586        && top < bottom;
3587    axis_aligned && left <= 0.0 && top <= 0.0 && right >= width as f32 && bottom >= height as f32
3588}
3589
3590/// One Sutherland–Hodgman pass against an axis-aligned half-plane.
3591///
3592/// Two properties the byte-exactness bar depends on:
3593/// * the clipped coordinate is set to `bound` EXACTLY rather than recomputed
3594///   through `p + t * (q - p)`, so every clipped polygon's boundary lies
3595///   bitwise on the clip line;
3596/// * the intersection is computed on the lexicographically ordered endpoint
3597///   pair, so the shared radial edge of two adjacent trapezoids — traversed
3598///   in opposite directions — clips to bitwise-identical points, keeping the
3599///   strip watertight (no pixel shaded twice or missed along the seam).
3600#[cfg(not(target_arch = "wasm32"))]
3601fn clip_polygon_axis(
3602    input: &[[f32; 2]],
3603    axis: usize,
3604    bound: f32,
3605    keep_at_most: bool,
3606    output: &mut Vec<[f32; 2]>,
3607) {
3608    output.clear();
3609    let inside = |p: [f32; 2]| {
3610        if keep_at_most {
3611            p[axis] <= bound
3612        } else {
3613            p[axis] >= bound
3614        }
3615    };
3616    let intersect = |a: [f32; 2], b: [f32; 2]| {
3617        let (p, q) = if (b[0], b[1]) < (a[0], a[1]) {
3618            (b, a)
3619        } else {
3620            (a, b)
3621        };
3622        let t = (bound - p[axis]) / (q[axis] - p[axis]);
3623        let mut point = [0.0f32; 2];
3624        point[axis] = bound;
3625        point[1 - axis] = p[1 - axis] + t * (q[1 - axis] - p[1 - axis]);
3626        point
3627    };
3628    for (index, &current) in input.iter().enumerate() {
3629        let previous = input[(index + input.len() - 1) % input.len()];
3630        match (inside(previous), inside(current)) {
3631            (true, true) => output.push(current),
3632            (true, false) => output.push(intersect(previous, current)),
3633            (false, true) => {
3634                output.push(intersect(previous, current));
3635                output.push(current);
3636            }
3637            (false, false) => {}
3638        }
3639    }
3640}
3641
3642/// Emits the conservative trapezoid-strip mesh for one qualifying arc band.
3643///
3644/// CONTAINMENT INVARIANT (the byte-exactness bar): the union of emitted
3645/// triangles is a superset of `{ p in the capture quad's box :
3646/// sdf_arc_band(p) <= 0.5 }` — every pixel the fragment shader would keep.
3647/// Over-inclusion is free (the SDF discards those pixels identically to
3648/// today's quad); only under-inclusion can diverge, and
3649/// `arc_mesh_contains_every_band_pixel` checks it never happens.
3650///
3651/// Geometry: outer vertices ride at `Ro / cos(step / 2)` so every chord is
3652/// tangent-outside the dilated outer circle; inner vertices ride at the
3653/// dilated inner radius, whose chords lie inside the hole. Cap coverage is
3654/// bounded by the round-cap disc about the band endpoint (butt/square caps
3655/// only cut that disc with planes — see `sdf_arc_band`), so padding the
3656/// angular range by the disc's angular half-extent contains every cap. Each
3657/// trapezoid is clipped to the quad box and fan-triangulated IN INDEX SPACE:
3658/// a trapezoid the clipper left untouched shares its two boundary vertices
3659/// with each neighbor through the index list (closed rings wrap the sharing
3660/// modulo the boundary count), so the strip is watertight by construction —
3661/// the seam edge is one vertex pair, not two bitwise-equal copies — and the
3662/// per-arc vertex count collapses from three-per-triangle to the unique
3663/// boundary vertices. Clipped trapezoids cannot share boundary vertices (the
3664/// clipper rewrote them), so their fan vertices are appended PRIVATELY after
3665/// the shared block and indexed directly; seams against neighbors still hold
3666/// because a boundary edge either survives the clip on both sides
3667/// bitwise-identically (same input edge, same planes, same float ops — see
3668/// `clip_polygon_axis`) or is cut on both sides identically. Triangles are
3669/// emitted in exact segment order either way, so the indexed mesh's
3670/// primitive stream is triangle-for-triangle the one the non-indexed
3671/// emitter produced.
3672///
3673/// Returns the emitted segment count, or `None` when the mesh came out empty
3674/// — the caller emits the passthrough quad instead (never risk
3675/// under-coverage).
3676#[cfg(not(target_arch = "wasm32"))]
3677fn emit_arc_band_mesh(
3678    shape: &ShapeData,
3679    shape_idx: u32,
3680    band: &ArcMeshBand,
3681    vertices: &mut Vec<MeshVertex>,
3682    indices: &mut Vec<u32>,
3683) -> Option<usize> {
3684    let [cx, cy] = band.center;
3685    let ra = (band.outer + band.inner) * 0.5;
3686    let rb = ((band.outer - band.inner) * 0.5).max(0.0);
3687    let rb_m = rb + ARC_MESH_MARGIN;
3688    let ro = ra + rb_m;
3689    let ri = (ra - rb_m).max(0.0);
3690    let tau = cranpose_ui_graphics::TAU;
3691
3692    let (range_start, range) = if band.sweep >= tau {
3693        (0.0, tau)
3694    } else {
3695        let pad = if rb_m < ra {
3696            (rb_m / ra).asin() + 0.05
3697        } else {
3698            // The cap disc wraps the center; such shapes are tiny, take the
3699            // whole circle.
3700            std::f32::consts::PI
3701        };
3702        let padded = band.sweep + pad + pad;
3703        if padded >= tau {
3704            (0.0, tau)
3705        } else {
3706            (band.start - pad, padded)
3707        }
3708    };
3709    let closed = range >= tau;
3710
3711    let dtheta = (2.0 * (ro / (ro + ARC_MESH_OVERSHOOT)).acos()).clamp(tau / 64.0, tau / 6.0);
3712    let segments =
3713        ((range / dtheta).ceil() as usize).clamp(ARC_MESH_MIN_SEGMENTS, ARC_MESH_MAX_SEGMENTS);
3714    let step = range / segments as f32;
3715    let rc = ro / (step * 0.5).cos();
3716
3717    // Boundary vertices are computed once and shared by both adjacent
3718    // trapezoids: bitwise-equal edge endpoints are what let the rasterizer's
3719    // fill rule shade each seam exactly once.
3720    let boundary_count = if closed { segments } else { segments + 1 };
3721    let mut boundaries = Vec::with_capacity(boundary_count);
3722    for j in 0..boundary_count {
3723        let (sin, cos) = (range_start + step * j as f32).sin_cos();
3724        boundaries.push((
3725            [cx + cos * ri, cy + sin * ri],
3726            [cx + cos * rc, cy + sin * rc],
3727        ));
3728    }
3729
3730    let quad_min = [shape.quad01[0], shape.quad01[1]];
3731    let quad_max = [shape.quad23[2], shape.quad23[3]];
3732
3733    /// One trapezoid's clip outcome (see the function docs): `Shared` means
3734    /// the clip output is bitwise the input quad, so its corners index the
3735    /// shared boundary block; `Fan` carries the clipped polygon for private
3736    /// fan triangulation; `Empty` was clipped away entirely.
3737    enum SegmentGeometry {
3738        Shared,
3739        Fan(Vec<[f32; 2]>),
3740        Empty,
3741    }
3742
3743    // Phase 1: clip every trapezoid and classify it.
3744    let mut polygon: Vec<[f32; 2]> = Vec::with_capacity(8);
3745    let mut scratch: Vec<[f32; 2]> = Vec::with_capacity(8);
3746    let mut segment_geometry = Vec::with_capacity(segments);
3747    let mut boundary_used = vec![false; boundary_count];
3748    for j in 0..segments {
3749        let jb = (j + 1) % boundary_count;
3750        let (inner_a, outer_a) = boundaries[j];
3751        let (inner_b, outer_b) = boundaries[jb];
3752        polygon.clear();
3753        polygon.extend_from_slice(&[inner_a, outer_a, outer_b, inner_b]);
3754        clip_polygon_axis(&polygon, 0, quad_min[0], false, &mut scratch);
3755        clip_polygon_axis(&scratch, 0, quad_max[0], true, &mut polygon);
3756        clip_polygon_axis(&polygon, 1, quad_min[1], false, &mut scratch);
3757        clip_polygon_axis(&scratch, 1, quad_max[1], true, &mut polygon);
3758        // Collapse exact duplicates (an `Ri == 0` pie wedge duplicates the
3759        // center) before fanning.
3760        scratch.clear();
3761        for &point in polygon.iter() {
3762            if scratch.last() != Some(&point) {
3763                scratch.push(point);
3764            }
3765        }
3766        while scratch.len() > 1 && scratch.first() == scratch.last() {
3767            scratch.pop();
3768        }
3769        if scratch.len() < 3 {
3770            segment_geometry.push(SegmentGeometry::Empty);
3771        } else if scratch[..] == [inner_a, outer_a, outer_b, inner_b] {
3772            boundary_used[j] = true;
3773            boundary_used[jb] = true;
3774            segment_geometry.push(SegmentGeometry::Shared);
3775        } else {
3776            segment_geometry.push(SegmentGeometry::Fan(scratch.clone()));
3777        }
3778    }
3779
3780    let push_vertex = |vertices: &mut Vec<MeshVertex>, position: [f32; 2]| -> u32 {
3781        let index = vertices.len() as u32;
3782        vertices.push(MeshVertex {
3783            position,
3784            uv: [
3785                (position[0] - shape.rect[0]) / shape.rect[2],
3786                (position[1] - shape.rect[1]) / shape.rect[3],
3787            ],
3788            shape_idx,
3789        });
3790        index
3791    };
3792
3793    // Shared block: every boundary referenced by a surviving whole trapezoid
3794    // gets its (inner, outer) vertex pair exactly once, in boundary order.
3795    let mut boundary_vertex = vec![[0u32; 2]; boundary_count];
3796    for (j, used) in boundary_used.iter().enumerate() {
3797        if *used {
3798            let (inner, outer) = boundaries[j];
3799            boundary_vertex[j] = [push_vertex(vertices, inner), push_vertex(vertices, outer)];
3800        }
3801    }
3802
3803    // Phase 2: indices in exact segment order — the primitive stream matches
3804    // the non-indexed emitter triangle for triangle.
3805    let start_len = indices.len();
3806    for (j, geometry) in segment_geometry.iter().enumerate() {
3807        match geometry {
3808            SegmentGeometry::Empty => {}
3809            SegmentGeometry::Shared => {
3810                let jb = (j + 1) % boundary_count;
3811                let [in_a, out_a] = boundary_vertex[j];
3812                let [in_b, out_b] = boundary_vertex[jb];
3813                // The fan the non-indexed emitter produced for an untouched
3814                // trapezoid: (in_a, out_a, out_b)(in_a, out_b, in_b) — the
3815                // same quad diagonal.
3816                indices.extend_from_slice(&[in_a, out_a, out_b, in_a, out_b, in_b]);
3817            }
3818            SegmentGeometry::Fan(points) => {
3819                let base = vertices.len() as u32;
3820                for &point in points {
3821                    push_vertex(vertices, point);
3822                }
3823                for i in 1..points.len() as u32 - 1 {
3824                    indices.extend_from_slice(&[base, base + i, base + i + 1]);
3825                }
3826            }
3827        }
3828    }
3829    if indices.len() == start_len {
3830        return None;
3831    }
3832    Some(segments)
3833}
3834
3835/// Unsigned shoelace area of an emitted indexed triangle list, for
3836/// telemetry.
3837#[cfg(not(target_arch = "wasm32"))]
3838fn triangles_shoelace_area(vertices: &[MeshVertex], indices: &[u32]) -> f64 {
3839    indices
3840        .as_chunks::<3>()
3841        .0
3842        .iter()
3843        .map(|tri| {
3844            let [a, b, c] = [
3845                vertices[tri[0] as usize].position,
3846                vertices[tri[1] as usize].position,
3847                vertices[tri[2] as usize].position,
3848            ];
3849            let cross = (b[0] as f64 - a[0] as f64) * (c[1] as f64 - a[1] as f64)
3850                - (b[1] as f64 - a[1] as f64) * (c[0] as f64 - a[0] as f64);
3851            cross.abs() * 0.5
3852        })
3853        .sum()
3854}
3855
3856/// Unsigned area of the two triangles the quad-expansion path would rasterize for
3857/// this shape, for telemetry.
3858#[cfg(not(target_arch = "wasm32"))]
3859fn quad_shoelace_area(shape: &ShapeData) -> f64 {
3860    let corners = [
3861        [shape.quad01[0] as f64, shape.quad01[1] as f64],
3862        [shape.quad01[2] as f64, shape.quad01[3] as f64],
3863        [shape.quad23[0] as f64, shape.quad23[1] as f64],
3864        [shape.quad23[2] as f64, shape.quad23[3] as f64],
3865    ];
3866    let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
3867        ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
3868    };
3869    tri(corners[0], corners[1], corners[2]) + tri(corners[2], corners[1], corners[3])
3870}
3871
3872/// `CRANPOSE_FILL_DIAG` (`debug.cranpose.fill_diag` on Android): per-frame
3873/// CPU-side accounting of the fill area the renderer submits, in device px².
3874/// Off by default; any set value except "0" enables. Read once per process,
3875/// so a disabled hot path pays one static load and a branch.
3876#[cfg(not(target_arch = "wasm32"))]
3877pub(crate) fn fill_area_diag_enabled() -> bool {
3878    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3879    *ENABLED.get_or_init(
3880        || matches!(std::env::var("CRANPOSE_FILL_DIAG").as_deref(), Ok(value) if value != "0"),
3881    )
3882}
3883
3884/// Rendered frames aggregated into one `[fill-diag]` report line.
3885#[cfg(not(target_arch = "wasm32"))]
3886const FILL_DIAG_WINDOW_FRAMES: u32 = 120;
3887
3888#[cfg(not(target_arch = "wasm32"))]
3889const FILL_DIAG_BUCKETS: usize = 9;
3890
3891/// Opacity class of a shape's fill for the `[fill-truth]` histogram, decided
3892/// from the CONVERTED record: a solid brush with vertex alpha exactly 1.0 is
3893/// opaque, any other solid is translucent, and every gradient counts as
3894/// non-solid (its stops can each carry their own alpha). Retained shapes are
3895/// classified from their capture-time colors — a later recolor patch through
3896/// the slot's paint buffer is not re-classified.
3897#[cfg(not(target_arch = "wasm32"))]
3898#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3899enum FillOpacityClass {
3900    Opaque = 0,
3901    Translucent = 1,
3902    NonSolid = 2,
3903}
3904
3905#[cfg(not(target_arch = "wasm32"))]
3906fn fill_opacity_class(shape: &ShapeData) -> FillOpacityClass {
3907    if shape.brush_type != 0 {
3908        FillOpacityClass::NonSolid
3909    } else if shape.color[3] == 1.0 {
3910        FillOpacityClass::Opaque
3911    } else {
3912        FillOpacityClass::Translucent
3913    }
3914}
3915
3916/// The fill-diag bucket of a batched shape quad, decoded from the packed
3917/// flags the way the fragment shader decodes them (`u32(max(x, 0.0)) & 3`).
3918/// Fills keep real corner radii in `radii` (arcs reuse the field for trig,
3919/// but they take the arc arm first).
3920#[cfg(not(target_arch = "wasm32"))]
3921fn fill_diag_bucket(shape: &ShapeData) -> usize {
3922    match shape.stroke_params[1].max(0.0) as u32 & 3 {
3923        SHAPE_KIND_ARC => FillAreaDiag::ARC,
3924        SHAPE_KIND_STROKE => FillAreaDiag::RRECT_STROKE,
3925        _ if shape.radii.iter().any(|radius| *radius > 0.0) => FillAreaDiag::RRECT_FILL,
3926        _ => FillAreaDiag::RECT,
3927    }
3928}
3929
3930#[cfg(not(target_arch = "wasm32"))]
3931fn fill_diag_bucket_name(bucket: usize) -> &'static str {
3932    match bucket {
3933        FillAreaDiag::ARC => "arc",
3934        FillAreaDiag::RRECT_STROKE => "rrect-stroke",
3935        FillAreaDiag::RRECT_FILL => "rrect-fill",
3936        FillAreaDiag::RECT => "rect",
3937        FillAreaDiag::MESH => "mesh",
3938        FillAreaDiag::RETAINED => "retained",
3939        FillAreaDiag::IMAGE_GLYPH => "img+glyph",
3940        FillAreaDiag::EFFECT_COMPOSITE => "effect-comp",
3941        FillAreaDiag::OFFSCREEN_SOURCE => "offscr-src",
3942        _ => "?",
3943    }
3944}
3945
3946/// Analytic covered area of a shape in device px² — the pixels the SDF will
3947/// actually keep, as opposed to the bounding quad it is rasterized with —
3948/// decoded from the same converted `ShapeData` fields the classifier and the
3949/// band-mesh builders read. Deliberately closed-form per class:
3950///
3951/// * arc / annular sector: `sweep · r_mid · thickness` plus the endcap area
3952///   (two half-discs for round caps; square caps rasterize the same pixel
3953///   measure — `sdf_arc_band` cuts the endpoint disc at `plane − rb`, which
3954///   removes nothing but the tangent point; butt caps add nothing; a closed
3955///   ring has no caps).
3956/// * stroked round-rect: centerline perimeter × stroke width — exact while
3957///   every corner radius ≥ half the stroke width (the offset-band identity);
3958///   miter corner spurs at sharp corners are not modeled.
3959/// * round-rect / circle fill: `w·h − (1 − π/4)·Σ rᵢ²`, radii clamped to the
3960///   half-extent (a circle degenerates to exactly `π r²`).
3961/// * plain rect: the submitted quad IS the covered set — priced at the quad
3962///   area by the caller, this function returns `w·h` (equal under any
3963///   similarity).
3964///
3965/// Clips and viewport scissors are not modeled, same as the quad accounting.
3966#[cfg(not(target_arch = "wasm32"))]
3967fn analytic_covered_area(shape: &ShapeData) -> f64 {
3968    let flags = shape.stroke_params[1].max(0.0) as u32;
3969    match flags & 3 {
3970        SHAPE_KIND_ARC => {
3971            let outer = f64::from(shape.stroke_params[2]).max(0.0);
3972            let inner = f64::from(shape.stroke_params[3]).clamp(0.0, outer);
3973            let tau = f64::from(cranpose_ui_graphics::TAU);
3974            let sweep = f64::from(shape.arc_params[3]).clamp(0.0, tau);
3975            let thickness = outer - inner;
3976            let band = sweep * 0.5 * (outer + inner) * thickness;
3977            let caps = if sweep >= tau {
3978                0.0
3979            } else {
3980                match (flags >> 2) & 3 {
3981                    // Round and square: two half-discs of radius t/2 — the
3982                    // shader's square cap keeps the endpoint disc's measure
3983                    // (see the doc comment).
3984                    1 | 2 => std::f64::consts::PI * (thickness * 0.5) * (thickness * 0.5),
3985                    _ => 0.0,
3986                }
3987            };
3988            band + caps
3989        }
3990        SHAPE_KIND_STROKE => {
3991            let stroke_width = f64::from(shape.stroke_params[0]).max(0.0);
3992            // `rect` for a stroked shape is the stroke-inflated box.
3993            let geom_w = (f64::from(shape.rect[2]) - stroke_width).max(0.0);
3994            let geom_h = (f64::from(shape.rect[3]) - stroke_width).max(0.0);
3995            let max_radius = geom_w.min(geom_h) * 0.5;
3996            let radii_sum: f64 = shape
3997                .radii
3998                .iter()
3999                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4000                .sum();
4001            let perimeter =
4002                2.0 * (geom_w + geom_h) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum;
4003            perimeter.max(0.0) * stroke_width
4004        }
4005        _ => {
4006            let width = f64::from(shape.rect[2]).max(0.0);
4007            let height = f64::from(shape.rect[3]).max(0.0);
4008            let max_radius = width.min(height) * 0.5;
4009            let radii_sq: f64 = shape
4010                .radii
4011                .iter()
4012                .map(|radius| {
4013                    let radius = f64::from(*radius).clamp(0.0, max_radius);
4014                    radius * radius
4015                })
4016                .sum();
4017            width * height - (1.0 - std::f64::consts::FRAC_PI_4) * radii_sq
4018        }
4019    }
4020}
4021
4022/// Antialiasing allowance added on top of [`analytic_covered_area`]: the SDF
4023/// feathers over roughly one pixel of boundary, so ~1 px × the covered set's
4024/// perimeter approximates the partially-lit fringe. Plain rects get none
4025/// (their quad is exact); a stroked shape has two boundary curves, whose
4026/// perimeters sum to twice the centerline perimeter for a convex outline.
4027#[cfg(not(target_arch = "wasm32"))]
4028fn aa_perimeter_allowance(shape: &ShapeData) -> f64 {
4029    let flags = shape.stroke_params[1].max(0.0) as u32;
4030    match flags & 3 {
4031        SHAPE_KIND_ARC => {
4032            let outer = f64::from(shape.stroke_params[2]).max(0.0);
4033            let inner = f64::from(shape.stroke_params[3]).clamp(0.0, outer);
4034            let tau = f64::from(cranpose_ui_graphics::TAU);
4035            let sweep = f64::from(shape.arc_params[3]).clamp(0.0, tau);
4036            let ends = if sweep >= tau {
4037                0.0
4038            } else {
4039                2.0 * (outer - inner)
4040            };
4041            sweep * (outer + inner) + ends
4042        }
4043        SHAPE_KIND_STROKE => {
4044            let stroke_width = f64::from(shape.stroke_params[0]).max(0.0);
4045            let geom_w = (f64::from(shape.rect[2]) - stroke_width).max(0.0);
4046            let geom_h = (f64::from(shape.rect[3]) - stroke_width).max(0.0);
4047            let max_radius = geom_w.min(geom_h) * 0.5;
4048            let radii_sum: f64 = shape
4049                .radii
4050                .iter()
4051                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4052                .sum();
4053            let perimeter =
4054                2.0 * (geom_w + geom_h) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum;
4055            2.0 * perimeter.max(0.0)
4056        }
4057        _ if shape.radii.iter().any(|radius| *radius > 0.0) => {
4058            let width = f64::from(shape.rect[2]).max(0.0);
4059            let height = f64::from(shape.rect[3]).max(0.0);
4060            let max_radius = width.min(height) * 0.5;
4061            let radii_sum: f64 = shape
4062                .radii
4063                .iter()
4064                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4065                .sum();
4066            (2.0 * (width + height) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum).max(0.0)
4067        }
4068        _ => 0.0,
4069    }
4070}
4071
4072/// Analytic lit area: covered pixels plus the AA fringe allowance. Callers
4073/// clamp it to the shape's submitted area — the shader cannot light pixels
4074/// its quad never rasterizes.
4075#[cfg(not(target_arch = "wasm32"))]
4076fn analytic_lit_area(shape: &ShapeData) -> f64 {
4077    analytic_covered_area(shape) + aa_perimeter_allowance(shape)
4078}
4079
4080/// Device-space AABB of a shape's submitted quad: min x, min y, max x, max y.
4081#[cfg(not(target_arch = "wasm32"))]
4082fn quad_aabb(shape: &ShapeData) -> [f64; 4] {
4083    let xs = [
4084        f64::from(shape.quad01[0]),
4085        f64::from(shape.quad01[2]),
4086        f64::from(shape.quad23[0]),
4087        f64::from(shape.quad23[2]),
4088    ];
4089    let ys = [
4090        f64::from(shape.quad01[1]),
4091        f64::from(shape.quad01[3]),
4092        f64::from(shape.quad23[1]),
4093        f64::from(shape.quad23[3]),
4094    ];
4095    let fold = |values: [f64; 4], pick: fn(f64, f64) -> f64| {
4096        values.into_iter().reduce(pick).unwrap_or(0.0)
4097    };
4098    [
4099        fold(xs, f64::min),
4100        fold(ys, f64::min),
4101        fold(xs, f64::max),
4102        fold(ys, f64::max),
4103    ]
4104}
4105
4106/// Vertical strips of the midpoint rule used by
4107/// [`area_outside_inscribed_circle`]. 32 strips keep the chord error under
4108/// ~0.5% for a full-viewport quad — plenty for a corner-waste ratio.
4109#[cfg(not(target_arch = "wasm32"))]
4110const CORNER_FILL_STRIPS: usize = 32;
4111
4112/// Area of an axis-aligned box lying inside the viewport but OUTSIDE the
4113/// inscribed circle (diameter `min(w, h)`, centered) — the pixels a round
4114/// watch display physically cannot show. Approximations, deliberate: the
4115/// submitted quad is replaced by its AABB (exact for the axis-aligned quads
4116/// that dominate full-frame scenes), and the circle chord is integrated with
4117/// [`CORNER_FILL_STRIPS`] midpoint strips instead of closed-form segments.
4118/// On a non-square viewport the side bands beyond the circle count as
4119/// outside too, which is the honest answer for a round display.
4120#[cfg(not(target_arch = "wasm32"))]
4121fn area_outside_inscribed_circle(aabb: [f64; 4], viewport: (u32, u32)) -> f64 {
4122    let viewport_w = f64::from(viewport.0);
4123    let viewport_h = f64::from(viewport.1);
4124    if viewport_w <= 0.0 || viewport_h <= 0.0 {
4125        return 0.0;
4126    }
4127    let x0 = aabb[0].max(0.0);
4128    let y0 = aabb[1].max(0.0);
4129    let x1 = aabb[2].min(viewport_w);
4130    let y1 = aabb[3].min(viewport_h);
4131    if x1 <= x0 || y1 <= y0 {
4132        return 0.0;
4133    }
4134    let center_x = viewport_w * 0.5;
4135    let center_y = viewport_h * 0.5;
4136    let radius = viewport_w.min(viewport_h) * 0.5;
4137    let strip = (x1 - x0) / CORNER_FILL_STRIPS as f64;
4138    let mut outside = 0.0;
4139    for index in 0..CORNER_FILL_STRIPS {
4140        let x = x0 + (index as f64 + 0.5) * strip;
4141        let dx = x - center_x;
4142        let chord_sq = radius * radius - dx * dx;
4143        let inside = if chord_sq > 0.0 {
4144            let half_chord = chord_sq.sqrt();
4145            (y1.min(center_y + half_chord) - y0.max(center_y - half_chord)).max(0.0)
4146        } else {
4147            0.0
4148        };
4149        outside += ((y1 - y0) - inside) * strip;
4150    }
4151    outside
4152}
4153
4154/// Per-shape fill-diag record a replay slot retains at capture, so retained
4155/// draws can be priced per range without re-deriving anything per frame.
4156/// Only built while `CRANPOSE_FILL_DIAG` is on.
4157#[cfg(not(target_arch = "wasm32"))]
4158#[derive(Clone, Copy, Debug)]
4159struct FillDiagShapeRecord {
4160    /// Capture-space area actually submitted for this shape: band-mesh
4161    /// triangle area when the slot replays THIS shape's band, bounding-quad
4162    /// area otherwise (instanced passthrough or meshless slot).
4163    drawn_px2: f64,
4164    /// Analytic lit area ([`analytic_lit_area`]), clamped to `drawn_px2`.
4165    lit_px2: f64,
4166    /// SDF-class bucket ([`fill_diag_bucket`]), for the top-slack dump.
4167    bucket: usize,
4168    opacity: FillOpacityClass,
4169    /// Capture-space AABB of the submitted quad, for the corner counter.
4170    aabb: [f64; 4],
4171}
4172
4173/// Builds a capture's fill-diag records. `mesh` carries the kept arc mesh's
4174/// `(vertices, indices, index_prefix)` when the slot will replay it, so each
4175/// shape is priced by its true triangle area.
4176#[cfg(not(target_arch = "wasm32"))]
4177fn fill_diag_capture_records(
4178    shape_data: &[ShapeData],
4179    mesh: Option<(&[MeshVertex], &[u32], &[u32])>,
4180) -> Vec<FillDiagShapeRecord> {
4181    shape_data
4182        .iter()
4183        .enumerate()
4184        .map(|(index, shape)| {
4185            let drawn_px2 = match mesh {
4186                // An empty index range is a shape the draw walk keeps on the
4187                // instanced-quad path — priced at its bounding quad, exactly
4188                // what that path submits.
4189                Some((vertices, indices, index_prefix))
4190                    if index_prefix[index + 1] > index_prefix[index] =>
4191                {
4192                    let start = index_prefix[index] as usize;
4193                    let end = index_prefix[index + 1] as usize;
4194                    triangles_shoelace_area(vertices, &indices[start..end])
4195                }
4196                _ => quad_shoelace_area(shape),
4197            };
4198            FillDiagShapeRecord {
4199                drawn_px2,
4200                lit_px2: analytic_lit_area(shape).clamp(0.0, drawn_px2),
4201                bucket: fill_diag_bucket(shape),
4202                opacity: fill_opacity_class(shape),
4203                aabb: quad_aabb(shape),
4204            }
4205        })
4206        .collect()
4207}
4208
4209/// One entry of the once-per-process top-slack dump: a retained shape whose
4210/// submitted area most exceeds its lit area.
4211#[cfg(not(target_arch = "wasm32"))]
4212#[derive(Clone, Copy, Debug)]
4213struct FillDiagSlackEntry {
4214    slot: u32,
4215    shape: u32,
4216    bucket: usize,
4217    drawn_px2: f64,
4218    lit_px2: f64,
4219}
4220
4221#[cfg(not(target_arch = "wasm32"))]
4222const FILL_DIAG_SLACK_TOP: usize = 10;
4223
4224/// Submitted-fill-area accounting behind [`fill_area_diag_enabled`]. The
4225/// watch's GPU counters are sepolicy-blocked, but the renderer knows every
4226/// quad it emits, so summing their areas per bucket says where the fragment
4227/// work goes; the point is the RATIO between buckets, and several are
4228/// deliberately approximate where exactness would cost the hot path:
4229///
4230/// * `arc` / `rrect-stroke` / `rrect-fill` / `rect` — batched shape quads by
4231///   decoded SDF class: exact shoelace area of the submitted quads, from the
4232///   fused screen pass and the offscreen layer/shadow-source passes alike.
4233///   Scissors and the SDF's own discards are not modeled. The latched
4234///   instanced-quad path draws these same quads (one instance per shape), so
4235///   instanced draws live in these buckets rather than a separate one.
4236/// * `mesh` — transient rim band meshes: exact triangle area, replacing the
4237///   rim's bounding quad (which is subtracted back out of `rrect-stroke`).
4238/// * `retained` — replay-slot draws: exact capture-space area of the drawn
4239///   shape range (mesh triangles when the slot replays its arc mesh, quads
4240///   otherwise) times the draw's similarity scale squared.
4241/// * `img+glyph` — image quads exactly; glyph atlas quads as width x height.
4242///   A retained glyph run counts every quad of its cached buffer (the
4243///   shared path's per-quad viewport cull is not re-run for it).
4244/// * `effect-comp` — effect-renderer draws into a caller-supplied view:
4245///   composites/blits (incl. batched, projective and masked variants) and
4246///   src-over runtime shader passes. Priced per pass at the dest viewport
4247///   area, clamped by the scissor when one is set (min of the two areas
4248///   stands in for their exact intersection).
4249/// * `offscr-src` — passes rendering INTO offscreen chain textures: blur
4250///   ping-pong axis passes, offset passes, replace-mode shader passes, and
4251///   the shadow-source target passes of `encode_shadow_shape_source_passes`
4252///   (the whole bounds-sized target per pass — its load/store round trip —
4253///   on top of the shape quads it draws, which the SDF-class buckets price
4254///   as usual).
4255///
4256/// The `[fill-truth]` line splits every bucket into analytic lit vs slack
4257/// (`lit` per [`analytic_lit_area`], `slack = submitted − lit`, clamped
4258/// non-negative; effect passes are all-lit by definition), histograms lit
4259/// pixels by [`FillOpacityClass`] (shape buckets only — image/glyph and
4260/// effect fill has no CPU-known alpha and is excluded), and prices the
4261/// full-frame corner waste per [`area_outside_inscribed_circle`]. The corner
4262/// counter covers full-frame shape batches and identity-transform retained
4263/// draws; meshed rims stay priced by their bounding AABB there (documented
4264/// overcount), and image/glyph quads are excluded.
4265///
4266/// Not counted: frame-graph layer clears/attachments outside the effect
4267/// renderer's own draw sites.
4268#[cfg(not(target_arch = "wasm32"))]
4269#[derive(Default)]
4270struct FillAreaDiag {
4271    /// Current frame's per-bucket submitted area, device px². `Cell`s
4272    /// because draw encoding accumulates through `&self`, the same pattern
4273    /// as [`gpu_stats::FrameStats`].
4274    frame: [std::cell::Cell<f64>; FILL_DIAG_BUCKETS],
4275    /// Current frame's per-bucket analytic lit area, ≤ the submitted area.
4276    frame_lit: [std::cell::Cell<f64>; FILL_DIAG_BUCKETS],
4277    /// Current frame's lit area by [`FillOpacityClass`], shape buckets only.
4278    frame_opacity: [std::cell::Cell<f64>; 3],
4279    /// Current frame's full-frame fill outside the inscribed circle.
4280    frame_corner: std::cell::Cell<f64>,
4281    /// The frame's surface size, latched by [`Self::reset_frame`] — the
4282    /// full-frame-pass gate and the inscribed circle both derive from it.
4283    viewport: std::cell::Cell<(u32, u32)>,
4284    /// Window totals, folded once per frame by [`Self::finish_frame`].
4285    window: [f64; FILL_DIAG_BUCKETS],
4286    window_lit: [f64; FILL_DIAG_BUCKETS],
4287    window_opacity: [f64; 3],
4288    window_corner: f64,
4289    window_frames: u32,
4290    /// Worst retained shapes by slack, collected at slot capture and dumped
4291    /// once with the first report window that has any (then dropped).
4292    slack_top: Vec<FillDiagSlackEntry>,
4293    slack_dumped: bool,
4294}
4295
4296#[cfg(not(target_arch = "wasm32"))]
4297impl FillAreaDiag {
4298    const ARC: usize = 0;
4299    const RRECT_STROKE: usize = 1;
4300    const RRECT_FILL: usize = 2;
4301    const RECT: usize = 3;
4302    const MESH: usize = 4;
4303    const RETAINED: usize = 5;
4304    const IMAGE_GLYPH: usize = 6;
4305    const EFFECT_COMPOSITE: usize = 7;
4306    const OFFSCREEN_SOURCE: usize = 8;
4307
4308    fn add(&self, bucket: usize, area_px2: f64) {
4309        let cell = &self.frame[bucket];
4310        cell.set(cell.get() + area_px2);
4311    }
4312
4313    fn add_lit(&self, bucket: usize, lit_px2: f64) {
4314        let cell = &self.frame_lit[bucket];
4315        cell.set(cell.get() + lit_px2);
4316    }
4317
4318    fn add_corner(&self, px2: f64) {
4319        self.frame_corner.set(self.frame_corner.get() + px2);
4320    }
4321
4322    /// Whether a batch's viewport IS this frame's surface — the gate for the
4323    /// corner counter (offscreen shadow/layer passes carry their own bounds
4324    /// viewport and never qualify).
4325    fn is_full_frame(&self, viewport: ViewportUniformParams) -> bool {
4326        let (width, height) = self.viewport.get();
4327        width > 0
4328            && height > 0
4329            && viewport.width == width
4330            && viewport.height == height
4331            && viewport.offset == [0.0, 0.0]
4332    }
4333
4334    /// Splits a freshly converted batch's quads by SDF class
4335    /// ([`fill_diag_bucket`]), alongside each bucket's analytic lit area,
4336    /// the opacity histogram and — for full-frame passes — the corner
4337    /// counter.
4338    fn add_shape_quads(&self, shapes: &[ShapeData], viewport: ViewportUniformParams) {
4339        let full_frame = self.is_full_frame(viewport);
4340        let frame_viewport = self.viewport.get();
4341        let mut buckets = [0.0_f64; FILL_DIAG_BUCKETS];
4342        let mut lit_buckets = [0.0_f64; FILL_DIAG_BUCKETS];
4343        let mut opacity = [0.0_f64; 3];
4344        let mut corner = 0.0_f64;
4345        for shape in shapes {
4346            let bucket = fill_diag_bucket(shape);
4347            let quad = quad_shoelace_area(shape);
4348            let lit = analytic_lit_area(shape).clamp(0.0, quad);
4349            buckets[bucket] += quad;
4350            lit_buckets[bucket] += lit;
4351            opacity[fill_opacity_class(shape) as usize] += lit;
4352            if full_frame {
4353                corner += area_outside_inscribed_circle(quad_aabb(shape), frame_viewport);
4354            }
4355        }
4356        for (bucket, area) in buckets.into_iter().enumerate() {
4357            if area > 0.0 {
4358                self.add(bucket, area);
4359            }
4360        }
4361        for (bucket, lit) in lit_buckets.into_iter().enumerate() {
4362            if lit > 0.0 {
4363                self.add_lit(bucket, lit);
4364            }
4365        }
4366        for (class, lit) in self.frame_opacity.iter().zip(opacity) {
4367            class.set(class.get() + lit);
4368        }
4369        if corner > 0.0 {
4370            self.add_corner(corner);
4371        }
4372    }
4373
4374    /// A leading-span cache hit replaced these already-counted quads with
4375    /// one cached-texture blit: subtract their submitted, lit and
4376    /// opacity-class areas back out — those pixels now arrive through the
4377    /// blit, an effect-renderer composite that the effect-comp bucket
4378    /// prices at its own draw site and the opacity histogram excludes by
4379    /// design (no CPU-known alpha). The corner counter stays as priced at
4380    /// batch prepare: the full-target blit writes the very same corner
4381    /// pixels, so the waste that counter exists to expose is unchanged.
4382    fn note_static_span_skip(&self, shapes: &[ShapeData]) {
4383        for shape in shapes {
4384            let bucket = fill_diag_bucket(shape);
4385            let quad = quad_shoelace_area(shape);
4386            let lit = analytic_lit_area(shape).clamp(0.0, quad);
4387            self.add(bucket, -quad);
4388            self.add_lit(bucket, -lit);
4389            let class = &self.frame_opacity[fill_opacity_class(shape) as usize];
4390            class.set(class.get() - lit);
4391        }
4392    }
4393
4394    /// A transient rim replaced its bounding quad with a band mesh: move the
4395    /// quad's area and lit (already counted at batch prepare) out of the
4396    /// stroke bucket and count the mesh triangles instead. The opacity
4397    /// histogram and corner counter stay as priced at batch prepare — the
4398    /// same pixels light up either way, and the corner counter deliberately
4399    /// keeps the quad AABB (documented overcount for meshed rims).
4400    fn note_rim_mesh(&self, shape: &ShapeData, mesh_px2: f64) {
4401        let quad = quad_shoelace_area(shape);
4402        let lit = analytic_lit_area(shape).clamp(0.0, quad);
4403        self.add(Self::RRECT_STROKE, -quad);
4404        self.add_lit(Self::RRECT_STROKE, -lit);
4405        self.add(Self::MESH, mesh_px2);
4406        self.add_lit(Self::MESH, lit.min(mesh_px2));
4407    }
4408
4409    /// One retained replay draw over `first..last` of a slot's capture:
4410    /// capture-space records times the draw's similarity scale squared. The
4411    /// corner counter only accumulates for identity-transform draws (rot 0,
4412    /// scale 1 — the static background/rings case it exists for), because a
4413    /// moved batch's capture-space AABBs no longer say where it lands.
4414    fn add_retained_range(
4415        &self,
4416        records: &[FillDiagShapeRecord],
4417        first: u32,
4418        last: u32,
4419        transform: &SimilarityTransform,
4420    ) {
4421        let Some(range) = records.get(first as usize..last as usize) else {
4422            return;
4423        };
4424        let scale = f64::from(transform.scale);
4425        let factor = scale * scale;
4426        let identity = transform.rot == [1.0, 0.0] && transform.scale == 1.0;
4427        let frame_viewport = self.viewport.get();
4428        let mut drawn = 0.0_f64;
4429        let mut lit = 0.0_f64;
4430        let mut opacity = [0.0_f64; 3];
4431        let mut corner = 0.0_f64;
4432        for record in range {
4433            drawn += record.drawn_px2;
4434            lit += record.lit_px2;
4435            opacity[record.opacity as usize] += record.lit_px2;
4436            if identity {
4437                corner += area_outside_inscribed_circle(record.aabb, frame_viewport);
4438            }
4439        }
4440        self.add(Self::RETAINED, drawn * factor);
4441        self.add_lit(Self::RETAINED, lit * factor);
4442        for (class, value) in self.frame_opacity.iter().zip(opacity) {
4443            class.set(class.get() + value * factor);
4444        }
4445        if corner > 0.0 {
4446            self.add_corner(corner);
4447        }
4448    }
4449
4450    /// Collects top-slack candidates from a fresh capture, keeping the
4451    /// [`FILL_DIAG_SLACK_TOP`] worst across all captures until the first
4452    /// report window dumps them.
4453    fn note_retained_capture(&mut self, slot: u32, records: &[FillDiagShapeRecord]) {
4454        if self.slack_dumped {
4455            return;
4456        }
4457        for (index, record) in records.iter().enumerate() {
4458            if record.drawn_px2 - record.lit_px2 <= 0.0 {
4459                continue;
4460            }
4461            self.slack_top.push(FillDiagSlackEntry {
4462                slot,
4463                shape: index as u32,
4464                bucket: record.bucket,
4465                drawn_px2: record.drawn_px2,
4466                lit_px2: record.lit_px2,
4467            });
4468        }
4469        self.slack_top
4470            .sort_by(|a, b| (b.drawn_px2 - b.lit_px2).total_cmp(&(a.drawn_px2 - a.lit_px2)));
4471        self.slack_top.truncate(FILL_DIAG_SLACK_TOP);
4472    }
4473
4474    /// Area of an image or text-image quad from its four device-space
4475    /// corners (TL, TR, BL, BR — the shared `(0, 1, 2)(2, 1, 3)` pattern).
4476    /// Textures light every pixel of their quad, so lit == submitted.
4477    fn add_image_quad(&self, quad: &[[f32; 2]; 4]) {
4478        let corner = |index: usize| [f64::from(quad[index][0]), f64::from(quad[index][1])];
4479        let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
4480            ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
4481        };
4482        let [a, b, c, d] = [corner(0), corner(1), corner(2), corner(3)];
4483        let area = tri(a, b, c) + tri(c, b, d);
4484        self.add(Self::IMAGE_GLYPH, area);
4485        self.add_lit(Self::IMAGE_GLYPH, area);
4486    }
4487
4488    /// One glyph atlas quad, axis-aligned by construction.
4489    fn add_glyph_quad(&self, quad: &CachedTextGlyphQuad) {
4490        let area = quad.width as f64 * quad.height as f64;
4491        self.add(Self::IMAGE_GLYPH, area);
4492        self.add_lit(Self::IMAGE_GLYPH, area);
4493    }
4494
4495    /// Effect-renderer pass fill drained once per frame from the effect
4496    /// renderer's own counters. Full-target draws: every counted pixel is
4497    /// shaded, so lit == submitted and slack is zero by construction.
4498    fn add_effect_fill(&self, composite_px2: f64, offscreen_px2: f64) {
4499        if composite_px2 > 0.0 {
4500            self.add(Self::EFFECT_COMPOSITE, composite_px2);
4501            self.add_lit(Self::EFFECT_COMPOSITE, composite_px2);
4502        }
4503        if offscreen_px2 > 0.0 {
4504            self.add(Self::OFFSCREEN_SOURCE, offscreen_px2);
4505            self.add_lit(Self::OFFSCREEN_SOURCE, offscreen_px2);
4506        }
4507    }
4508
4509    /// One render pass targeting an offscreen source texture (shadow source
4510    /// passes): the whole target area counts — its clear/load/store round
4511    /// trip — on top of the shape quads the pass draws, which
4512    /// [`Self::add_shape_quads`] prices separately under the pass's own
4513    /// bounds viewport.
4514    fn add_offscreen_target_fill(&self, px2: f64) {
4515        if px2 > 0.0 {
4516            self.add(Self::OFFSCREEN_SOURCE, px2);
4517            self.add_lit(Self::OFFSCREEN_SOURCE, px2);
4518        }
4519    }
4520
4521    /// Restarts the frame counters and latches the surface size — called
4522    /// from the same per-frame reset point as the transient rim mesh
4523    /// scratch.
4524    fn reset_frame(&self, width: u32, height: u32) {
4525        for cell in &self.frame {
4526            cell.set(0.0);
4527        }
4528        for cell in &self.frame_lit {
4529            cell.set(0.0);
4530        }
4531        for cell in &self.frame_opacity {
4532            cell.set(0.0);
4533        }
4534        self.frame_corner.set(0.0);
4535        self.viewport.set((width, height));
4536    }
4537
4538    /// Folds the frame into the window and, every
4539    /// [`FILL_DIAG_WINDOW_FRAMES`] rendered frames, emits the `[fill-diag]`
4540    /// bucket line, the `[fill-truth]` lit/slack + opacity + corner line,
4541    /// and — once per process — the retained top-slack dump.
4542    fn finish_frame(&mut self, width: u32, height: u32) {
4543        for (total, cell) in self.window.iter_mut().zip(&self.frame) {
4544            *total += cell.get();
4545        }
4546        for (total, cell) in self.window_lit.iter_mut().zip(&self.frame_lit) {
4547            *total += cell.get();
4548        }
4549        for (total, cell) in self.window_opacity.iter_mut().zip(&self.frame_opacity) {
4550            *total += cell.get();
4551        }
4552        self.window_corner += self.frame_corner.get();
4553        self.window_frames += 1;
4554        if self.window_frames < FILL_DIAG_WINDOW_FRAMES {
4555            return;
4556        }
4557        let frames = f64::from(self.window_frames);
4558        let mega = |bucket: usize| self.window[bucket] / frames / 1e6;
4559        let total_mega = self.window.iter().sum::<f64>() / frames / 1e6;
4560        let screen_mega = f64::from(width) * f64::from(height) / 1e6;
4561        let overdraw = if screen_mega > 0.0 {
4562            total_mega / screen_mega
4563        } else {
4564            0.0
4565        };
4566        log::warn!(
4567            "[fill-diag] Mpx/frame: arc {:.1}, rrect-stroke {:.1}, rrect-fill {:.1}, \
4568             rect {:.1}, mesh {:.1}, retained {:.1}, img+glyph {:.1}, \
4569             effect-comp {:.1}, offscr-src {:.1}, total {:.1} \
4570             ({:.1}x overdraw of {:.3} Mpx)",
4571            mega(Self::ARC),
4572            mega(Self::RRECT_STROKE),
4573            mega(Self::RRECT_FILL),
4574            mega(Self::RECT),
4575            mega(Self::MESH),
4576            mega(Self::RETAINED),
4577            mega(Self::IMAGE_GLYPH),
4578            mega(Self::EFFECT_COMPOSITE),
4579            mega(Self::OFFSCREEN_SOURCE),
4580            total_mega,
4581            overdraw,
4582            screen_mega,
4583        );
4584        // Lit vs slack per bucket: lit per [`analytic_lit_area`], slack the
4585        // remainder of the submitted area (clamped — negatives are rim-mesh
4586        // rounding, not information).
4587        let lit = |bucket: usize| self.window_lit[bucket] / frames / 1e6;
4588        let slack = |bucket: usize| (mega(bucket) - lit(bucket)).max(0.0);
4589        let truth = |bucket: usize| format!("{:.2}|{:.2}", lit(bucket), slack(bucket));
4590        log::warn!(
4591            "[fill-truth] Mpx/frame lit|slack: arc {}, rrect-stroke {}, rrect-fill {}, \
4592             rect {}, mesh {}, retained {}, img+glyph {}, effect-comp {}, offscr-src {}; \
4593             lit alpha Mpx: opaque {:.2}, translucent {:.2}, nonsolid {:.2}; \
4594             corner-outside {:.2}",
4595            truth(Self::ARC),
4596            truth(Self::RRECT_STROKE),
4597            truth(Self::RRECT_FILL),
4598            truth(Self::RECT),
4599            truth(Self::MESH),
4600            truth(Self::RETAINED),
4601            truth(Self::IMAGE_GLYPH),
4602            truth(Self::EFFECT_COMPOSITE),
4603            truth(Self::OFFSCREEN_SOURCE),
4604            self.window_opacity[FillOpacityClass::Opaque as usize] / frames / 1e6,
4605            self.window_opacity[FillOpacityClass::Translucent as usize] / frames / 1e6,
4606            self.window_opacity[FillOpacityClass::NonSolid as usize] / frames / 1e6,
4607            self.window_corner / frames / 1e6,
4608        );
4609        if !self.slack_dumped && !self.slack_top.is_empty() {
4610            log::warn!("[fill-truth] top retained slack (once per process, capture-space px):");
4611            for (rank, entry) in self.slack_top.iter().enumerate() {
4612                log::warn!(
4613                    "[fill-truth]   #{} slot {} shape {} {}: quad {:.0}, lit {:.0}, \
4614                     slack {:.0}",
4615                    rank + 1,
4616                    entry.slot,
4617                    entry.shape,
4618                    fill_diag_bucket_name(entry.bucket),
4619                    entry.drawn_px2,
4620                    entry.lit_px2,
4621                    entry.drawn_px2 - entry.lit_px2,
4622                );
4623            }
4624            self.slack_dumped = true;
4625            self.slack_top = Vec::new();
4626        }
4627        self.window = [0.0; FILL_DIAG_BUCKETS];
4628        self.window_lit = [0.0; FILL_DIAG_BUCKETS];
4629        self.window_opacity = [0.0; 3];
4630        self.window_corner = 0.0;
4631        self.window_frames = 0;
4632    }
4633}
4634
4635#[cfg(not(target_arch = "wasm32"))]
4636struct ArcMeshBuild {
4637    vertices: Vec<MeshVertex>,
4638    /// Triangle-list indices into `vertices`; see [`ReplaySlotMesh`].
4639    indices: Vec<u32>,
4640    /// `shape_count + 1` entries; shape `i` owns triangles
4641    /// `indices[index_prefix[i]..index_prefix[i + 1]]`. An EMPTY range is a
4642    /// shape that did not mesh: the draw walk keeps it on the latched
4643    /// instanced-quad path (see [`GpuRenderer::encode_retained_op`]) — the
4644    /// mesh buffers hold band geometry only, never passthrough quads.
4645    index_prefix: Vec<u32>,
4646    meshed_arcs: usize,
4647    meshed_rims: usize,
4648    meshed_segments: usize,
4649    passthrough: usize,
4650    /// Maximal runs of CONSECUTIVE meshed shapes. Each stretch costs the
4651    /// draw walk two pipeline switches per op that covers it, so the
4652    /// capture site refuses meshes past [`MESH_SLOT_MAX_STRETCHES`].
4653    meshed_stretches: usize,
4654    quad_area: f64,
4655    /// Capture-space area the new encoding actually submits: band-mesh
4656    /// triangles for meshed shapes, bounding quads for everything else.
4657    mesh_area: f64,
4658}
4659
4660/// Builds a slot's conservative indexed mesh: arc bands and stroked-circle
4661/// rims whose bounding quad reaches `min_mesh_px2` become vertex-sharing
4662/// trapezoid strips; every other shape — including gate-rejected small arcs
4663/// — contributes NO geometry, only an empty `index_prefix` range, and stays
4664/// on the instanced-quad path at draw time. (Putting passthrough quads in
4665/// the mesh buffers was the S3 mistake the watch measured: every quad paid
4666/// per-vertex `MeshVertex` attribute bandwidth where the latched instanced
4667/// path pays shared storage reads, and ~550 passthrough quads per slot
4668/// swamped the two meshed shapes' fill recovery — mesh ON 48.7/43.5 fps vs
4669/// OFF 53.7/45.2 on the Adreno 702.) Returns `None` when the byte budget
4670/// overflows — the caller warns and the whole slot replays through the
4671/// quad-expansion path (silent truncation would break the containment
4672/// invariant).
4673#[cfg(not(target_arch = "wasm32"))]
4674fn build_arc_mesh_vertices(shape_data: &[ShapeData], min_mesh_px2: f64) -> Option<ArcMeshBuild> {
4675    let budget_bytes =
4676        (shape_data.len() * ARC_MESH_BUDGET_BYTES_PER_SHAPE).max(ARC_MESH_BUDGET_FLOOR_BYTES);
4677    let mut build = ArcMeshBuild {
4678        vertices: Vec::new(),
4679        indices: Vec::new(),
4680        index_prefix: Vec::with_capacity(shape_data.len() + 1),
4681        meshed_arcs: 0,
4682        meshed_rims: 0,
4683        meshed_segments: 0,
4684        passthrough: 0,
4685        meshed_stretches: 0,
4686        quad_area: 0.0,
4687        mesh_area: 0.0,
4688    };
4689    build.index_prefix.push(0);
4690    let mut previous_meshed = false;
4691    for (index, shape) in shape_data.iter().enumerate() {
4692        let start = build.indices.len();
4693        let quad_px2 = quad_shoelace_area(shape);
4694        // THE SIZE GATE (see [`arc_mesh_enabled`] for the measured history):
4695        // only shapes whose submitted quad is big enough to carry real
4696        // fill-truth slack are worth a mesh; below the gate the trapezoid
4697        // strip's vertex and binning amplification costs the watch GPU more
4698        // than the discarded fragments ever did. The two band shapes are
4699        // mutually exclusive by kind bits (`SHAPE_KIND_ARC` vs
4700        // `SHAPE_KIND_STROKE`), so the `or_else` never shadows one with the
4701        // other.
4702        let band = if quad_px2 >= min_mesh_px2 {
4703            arc_mesh_band(shape)
4704                .map(|band| (band, false))
4705                .or_else(|| rim_band_geometry(shape).map(|band| (band, true)))
4706        } else {
4707            None
4708        };
4709        let meshed = band.and_then(|(band, is_rim)| {
4710            emit_arc_band_mesh(
4711                shape,
4712                index as u32,
4713                &band,
4714                &mut build.vertices,
4715                &mut build.indices,
4716            )
4717            .map(|segments| (segments, is_rim))
4718        });
4719        match meshed {
4720            Some((segments, is_rim)) => {
4721                if is_rim {
4722                    build.meshed_rims += 1;
4723                } else {
4724                    build.meshed_arcs += 1;
4725                }
4726                build.meshed_segments += segments;
4727                if !previous_meshed {
4728                    build.meshed_stretches += 1;
4729                }
4730                previous_meshed = true;
4731                build.mesh_area +=
4732                    triangles_shoelace_area(&build.vertices, &build.indices[start..]);
4733            }
4734            None => {
4735                build.passthrough += 1;
4736                previous_meshed = false;
4737                build.mesh_area += quad_px2;
4738            }
4739        }
4740        if arc_mesh_bytes(build.vertices.len(), build.indices.len()) > budget_bytes {
4741            return None;
4742        }
4743        build.index_prefix.push(build.indices.len() as u32);
4744        build.quad_area += quad_px2;
4745    }
4746    Some(build)
4747}
4748
4749/// The renderer's registry of live replay slots. The replay cache (scene
4750/// side) owns slot LIFECYCLE decisions; this store owns the GPU resources.
4751#[cfg(not(target_arch = "wasm32"))]
4752struct ReplaySlotStore {
4753    slots: std::collections::HashMap<u32, ReplaySlot, cranpose_ui_graphics::FxBuildHasher>,
4754    transform_buffer: wgpu::Buffer,
4755    free_ids: Vec<u32>,
4756    /// Global capture counter feeding [`ReplaySlot::capture_epoch`]: bumped
4757    /// on every capture, never reused, so an epoch identifies one capture's
4758    /// buffers for the renderer's whole lifetime.
4759    next_capture_epoch: u64,
4760}
4761
4762#[cfg(not(target_arch = "wasm32"))]
4763impl ReplaySlotStore {
4764    fn new(device: &wgpu::Device) -> Self {
4765        let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4766            label: Some("Replay Transform Buffer"),
4767            // The trailing SEGMENT_CAPTURE_SLOTS strides are reserved for
4768            // segment-surface capture passes: each capture binds its
4769            // similarity (the span's own transform, retained paint
4770            // selected) at `(MAX_REPLAY_SLOTS + capture_index) * stride`,
4771            // past every per-draw slot, so captures can never clobber a
4772            // frame's staged draw transforms.
4773            size: (MAX_REPLAY_SLOTS + SEGMENT_CAPTURE_SLOTS) as u64 * REPLAY_TRANSFORM_STRIDE,
4774            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4775            mapped_at_creation: false,
4776        });
4777        Self {
4778            slots: std::collections::HashMap::default(),
4779            transform_buffer,
4780            free_ids: (0..MAX_REPLAY_SLOTS).rev().collect(),
4781            next_capture_epoch: 1,
4782        }
4783    }
4784}
4785
4786/// Kill switch for cached retained render bundles, mirroring
4787/// `command_feed_enabled`: default ON, `CRANPOSE_RETAINED_BUNDLES=0` (or the
4788/// `debug.cranpose.retained_bundles` property on Android) drops the fused
4789/// retained arms back to direct per-op encoding, so a device A/B needs no
4790/// rebuild. Read per partition — the parity harness flips it between passes.
4791#[cfg(not(target_arch = "wasm32"))]
4792fn retained_bundles_enabled() -> bool {
4793    std::env::var("CRANPOSE_RETAINED_BUNDLES").as_deref() != Ok("0")
4794}
4795
4796/// Kill switch for instanced ordinary-shape quads: default ON,
4797/// `CRANPOSE_INSTANCED_QUADS=0` (or the `debug.cranpose.instanced_quads`
4798/// property on Android) reverts every ordinary shape draw to the six-vertex
4799/// `vs_main` expansion. Unlike the per-partition bundle flag this is read
4800/// ONCE per [`GpuRenderer`] construction into a field: cached retained
4801/// bundles encode the selected pipeline, so a flag that moved per draw would
4802/// let a cached bundle replay a selection the direct path no longer makes.
4803#[cfg(not(target_arch = "wasm32"))]
4804fn instanced_quads_enabled() -> bool {
4805    std::env::var("CRANPOSE_INSTANCED_QUADS").as_deref() != Ok("0")
4806}
4807
4808/// Trimmed-varying solid pipelines: default OFF, `CRANPOSE_SOLID_TRIM_VARYINGS=1`
4809/// (or the `debug.cranpose.solid_trim` property on Android) opts in. When on,
4810/// the two `fs_solid` pipeline families compile `vs_solid` /
4811/// `vs_solid_instanced` + `fs_solid_trim` — the inter-stage interface without
4812/// the eight gradient scalars `fs_solid` never reads (see
4813/// `shape_solid_trim.wgsl` for the location discipline). Read at pipeline
4814/// build like every lazy pipeline — the property is seeded into the
4815/// environment before the render loop starts, and the `PassPipeline` slots
4816/// cache the first build, so retained bundles and direct draws always encode
4817/// the same selection. Kill switch first: the previous attempt (16a5d312,
4818/// reverted in 371dd06a) died on a watch undiagnosed, so the trim ships dark
4819/// until a Vulkan-validated device session clears it.
4820fn solid_trim_varyings_enabled() -> bool {
4821    std::env::var("CRANPOSE_SOLID_TRIM_VARYINGS").as_deref() == Ok("1")
4822}
4823
4824/// Kill switch for surviving uncaptured device errors: default ON,
4825/// `CRANPOSE_SURVIVE_GPU_ERRORS=0` (or the
4826/// `debug.cranpose.survive_gpu_errors` property on Android) restores
4827/// wgpu's fatal default handler, which panics with the error message on
4828/// the reporting thread — the pre-fix behavior, kept reachable so a
4829/// debugging session can die loudly at the first error instead of
4830/// logging past it. Read once, at [`GpuRenderer`] construction, where the
4831/// handler is installed; it changes nothing off the error path.
4832fn survive_gpu_errors_enabled() -> bool {
4833    std::env::var("CRANPOSE_SURVIVE_GPU_ERRORS").as_deref() != Ok("0")
4834}
4835
4836/// Kill switch for the display clip region cull: default ON wherever the
4837/// platform reports a cullable visible region
4838/// (`set_display_visible_region`), and `CRANPOSE_ROUND_CULL` (or the
4839/// `debug.cranpose.round_cull` property on Android) gates it — the
4840/// switch keeps the name of the capability's first provider, the round
4841/// display. Read per frame — the parity harness flips it between passes.
4842/// While the region is `Full` the variable is never consulted: the cull
4843/// is structurally off.
4844///
4845/// OPT-IN (=1), not default-on, by measurement: with the span-capture
4846/// depth-leak fixed, the on-watch A/B (Pixel Watch 3, Adreno 702, mega
4847/// scene, alternating pairs) read cull ON 47.0/46.9 fps vs OFF 48.6/46.9 —
4848/// a small loss to a tie, never a win, despite the cull masking 35723 px
4849/// (21% of the buffer). The shape fragment shaders discard, which defeats
4850/// LRZ/early-Z on this GPU: corner fragments still execute, so the depth
4851/// attachment and occluder are pure overhead. The capability stays for
4852/// displays and drivers where early rejection survives discard — a device
4853/// A/B is one env flip, no rebuild — but earning default-on takes a
4854/// measured win on some device class, not an assumption.
4855#[cfg(not(target_arch = "wasm32"))]
4856fn display_clip_cull_enabled() -> bool {
4857    std::env::var("CRANPOSE_ROUND_CULL").as_deref() == Ok("1")
4858}
4859
4860/// The index pattern of one instanced quad: the exact triangle pair
4861/// `vs_main`'s six-slot corner mapping produces — (0, 1, 2)(2, 1, 3), same
4862/// diagonal, same winding — shared by every instance.
4863#[cfg(not(target_arch = "wasm32"))]
4864const INSTANCED_QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3];
4865
4866/// The latched instanced-quad selection: `Some` exactly when the renderer
4867/// was constructed in storage mode with [`instanced_quads_enabled`]. Both
4868/// blend variants exist because ordinary batches draw SrcOver and DstOut;
4869/// the `vs_main` pipelines coexist untouched so the `=0` revert (and the
4870/// uniform-mode path) still has its six-vertex draws.
4871#[cfg(not(target_arch = "wasm32"))]
4872struct InstancedQuadPipelines {
4873    pipeline: PassPipeline,
4874    pipeline_dst_out: PassPipeline,
4875    /// `fs_solid` twin of `pipeline` (SrcOver only): chosen for draws whose
4876    /// shapes carry no gradient stops, which is nearly every draw of an
4877    /// arc-heavy scene.
4878    pipeline_solid: PassPipeline,
4879    /// Static `[0, 1, 2, 2, 1, 3]` u16 index buffer, created once and shared
4880    /// by every instanced draw.
4881    index_buffer: wgpu::Buffer,
4882}
4883
4884/// One command of a retained op's draw walk, emitted by
4885/// [`GpuRenderer::encode_retained_op`] — the SINGLE place the walk exists.
4886/// The two sinks (the fused pass on the direct path, a
4887/// `RenderBundleEncoder` on the cached path) each translate these
4888/// mechanically, one match arm per variant, so the bundle-parity bar ("a
4889/// bundle replays the IDENTICAL command sequence") holds by construction:
4890/// only the translation is duplicated, never the sequence logic. A shared
4891/// generic encoder (`wgpu::util::RenderEncoder`) cannot express this
4892/// instead: `&mut RenderPass<'p>` is invariant in `'p`, so unifying the
4893/// resource lifetime with the pass's would freeze `self` immutably
4894/// borrowed for the whole pass.
4895#[cfg(not(target_arch = "wasm32"))]
4896enum RetainedCmd<'r> {
4897    Pipeline(&'r wgpu::RenderPipeline),
4898    /// Bind group 0, no dynamic offsets.
4899    Uniforms(&'r wgpu::BindGroup),
4900    /// Bind group 1 with the retained draw's dynamic transform offset.
4901    SlotBindings(&'r wgpu::BindGroup, u32),
4902    /// The slot mesh's vertex buffer at slot 0.
4903    MeshVertices(&'r wgpu::Buffer),
4904    Index(&'r wgpu::Buffer, wgpu::IndexFormat),
4905    /// `draw(vertices, 0..1)`.
4906    Draw(Range<u32>),
4907    /// `draw_indexed(indices, 0, instances)`.
4908    DrawIndexed(Range<u32>, Range<u32>),
4909}
4910
4911/// Everything that decides the commands one retained op contributes to a
4912/// cached bundle. Equal op keys imply identical encoded commands:
4913/// `capture_epoch` pins the slot's bind group, buffers AND its mesh's
4914/// meshed/instanced stretch structure to one capture (the alternating walk
4915/// of [`GpuRenderer::encode_retained_op`] is a pure function of the
4916/// capture-fixed `index_prefix` and `first..last`, so no per-stretch state
4917/// belongs in the key), `has_mesh` pins whether that walk runs at all,
4918/// `first..last` is the clamped draw range, and `retained_index` is the
4919/// dynamic transform offset. Transforms and paints are NOT here — they are
4920/// data-buffer contents the bundle reads at execution.
4921#[cfg(not(target_arch = "wasm32"))]
4922#[derive(Clone, Debug, PartialEq, Eq, Hash)]
4923struct RetainedBundleOpKey {
4924    slot: u32,
4925    /// The slot's capture epoch at key time, `None` while the slot is absent
4926    /// from the store (the op encodes nothing). Epochs are globally unique
4927    /// per capture, so a recaptured slot reusing its id can never satisfy a
4928    /// key recorded against the previous capture's buffers.
4929    capture_epoch: Option<u64>,
4930    first: u32,
4931    last: u32,
4932    retained_index: u32,
4933    has_mesh: bool,
4934}
4935
4936/// Key of one maximal consecutive retained stretch: the op keys in draw
4937/// order. Any reorder, count change, range change, recapture, or slot
4938/// release changes the key and forces a rebuild.
4939#[cfg(not(target_arch = "wasm32"))]
4940#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
4941struct RetainedBundleKey {
4942    /// Whether the stretch was encoded for the display-clip culled pass:
4943    /// such a bundle declares the depth attachment and records
4944    /// depth-variant pipelines, so it must never replay into a flat pass
4945    /// (or vice versa) — the flag keys the cache apart.
4946    depth: bool,
4947    ops: Vec<RetainedBundleOpKey>,
4948}
4949
4950#[cfg(not(target_arch = "wasm32"))]
4951struct RetainedBundleCacheEntry<B> {
4952    bundle: B,
4953    last_used_frame: u64,
4954}
4955
4956/// Cache of encoded render bundles for retained stretches, generic over the
4957/// bundle payload so the reuse/invalidation/eviction logic is unit-testable
4958/// without a GPU. The full [`RetainedBundleKey`] is the map key — a fresh
4959/// key can only ever build a fresh bundle, never alias a stale one.
4960///
4961/// The surface format and the group-0 uniform bind group are deliberately
4962/// not part of the key: both are fixed for a `GpuRenderer`'s lifetime (a
4963/// surface reconfigure builds a new renderer, and with it an empty cache).
4964#[cfg(not(target_arch = "wasm32"))]
4965struct RetainedBundleCacheImpl<B> {
4966    entries: HashMap<RetainedBundleKey, RetainedBundleCacheEntry<B>>,
4967    frame: u64,
4968    rebuilds: u64,
4969    cached_executes: u64,
4970    window_rebuilds: u64,
4971    window_executes: u64,
4972}
4973
4974#[cfg(not(target_arch = "wasm32"))]
4975type RetainedBundleCache = RetainedBundleCacheImpl<wgpu::RenderBundle>;
4976
4977#[cfg(not(target_arch = "wasm32"))]
4978impl<B> RetainedBundleCacheImpl<B> {
4979    fn new() -> Self {
4980        Self {
4981            entries: HashMap::default(),
4982            frame: 0,
4983            rebuilds: 0,
4984            cached_executes: 0,
4985            window_rebuilds: 0,
4986            window_executes: 0,
4987        }
4988    }
4989
4990    /// True when a bundle for `key` is cached; marks it used this frame and
4991    /// counts a cached execute.
4992    fn hit(&mut self, key: &RetainedBundleKey) -> bool {
4993        let frame = self.frame;
4994        match self.entries.get_mut(key) {
4995            Some(entry) => {
4996                entry.last_used_frame = frame;
4997                self.cached_executes += 1;
4998                self.window_executes += 1;
4999                true
5000            }
5001            None => false,
5002        }
5003    }
5004
5005    /// Stores a freshly built bundle, counting a rebuild.
5006    fn insert(&mut self, key: RetainedBundleKey, bundle: B) {
5007        self.rebuilds += 1;
5008        self.window_rebuilds += 1;
5009        self.entries.insert(
5010            key,
5011            RetainedBundleCacheEntry {
5012                bundle,
5013                last_used_frame: self.frame,
5014            },
5015        );
5016    }
5017
5018    fn get(&self, key: &RetainedBundleKey) -> Option<&B> {
5019        self.entries.get(key).map(|entry| &entry.bundle)
5020    }
5021
5022    /// Drops every cached bundle. Called whenever a replay slot is released:
5023    /// the key compare already makes stale entries unreachable (their epochs
5024    /// can never recur), so this only releases the dropped capture's GPU
5025    /// resources promptly instead of one frame later via eviction.
5026    fn clear(&mut self) {
5027        self.entries.clear();
5028    }
5029
5030    /// Frame boundary: evicts entries the frame did not use — a bundle
5031    /// holds references on its slot's buffers, so unused entries must not
5032    /// accumulate — and emits the rate-limited rebuild/execute telemetry.
5033    fn end_frame(&mut self) {
5034        let frame = self.frame;
5035        self.entries
5036            .retain(|_, entry| entry.last_used_frame >= frame);
5037        self.frame = self.frame.wrapping_add(1);
5038        // Always-on at a cadence that cannot spam; every perf window (120
5039        // frames) under the replay diagnostics flag so short A/B runs see
5040        // the counts. log::warn because log::info is invisible on the
5041        // desktop console.
5042        let due = self.frame.is_multiple_of(1024)
5043            || (cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG")
5044                && self.frame.is_multiple_of(120));
5045        if due && self.window_rebuilds + self.window_executes > 0 {
5046            log::warn!(
5047                "[retained-bundles] {} stretches, {} rebuilds, {} cached executes ({} live bundles)",
5048                self.window_rebuilds + self.window_executes,
5049                self.window_rebuilds,
5050                self.window_executes,
5051                self.entries.len(),
5052            );
5053            self.window_rebuilds = 0;
5054            self.window_executes = 0;
5055        }
5056    }
5057
5058    /// Lifetime (rebuilds, cached executes) for tests and diagnostics.
5059    fn stats(&self) -> (u64, u64) {
5060        (self.rebuilds, self.cached_executes)
5061    }
5062}
5063
5064struct CachedImageTexture {
5065    _texture: wgpu::Texture,
5066    _view: wgpu::TextureView,
5067    nearest_bind_group: wgpu::BindGroup,
5068    linear_bind_group: wgpu::BindGroup,
5069    /// GPU bytes this entry pins (w×h×4): the cache is bounded by BYTES as
5070    /// well as count. A live camera publishes a new multi-MB bitmap id every
5071    /// frame; 256 count-slots of those is ~1.5GB of dead preview textures —
5072    /// which on iOS unified memory counts straight against the process's
5073    /// jetsam limit (measured: the app died mid-scan under an open camera
5074    /// with exactly that ballast).
5075    bytes: usize,
5076}
5077
5078impl CachedImageTexture {
5079    fn bind_group(&self, sampling: ImageSampling) -> &wgpu::BindGroup {
5080        match sampling {
5081            ImageSampling::Nearest => &self.nearest_bind_group,
5082            ImageSampling::Linear => &self.linear_bind_group,
5083        }
5084    }
5085}
5086
5087#[derive(Clone, Copy)]
5088struct GlyphAtlasEntry {
5089    x: u32,
5090    y: u32,
5091    width: u32,
5092    height: u32,
5093}
5094
5095/// Side length the glyph atlas should be rebuilt at after it overflowed at
5096/// `current`: one doubling, never past `max`.
5097///
5098/// Doubling (rather than jumping straight to `max`) is what makes the atlas
5099/// cost track the workload: an app that overflows once needs a little more
5100/// room, not sixteen times more.
5101fn next_glyph_atlas_size(current: u32, max: u32) -> u32 {
5102    current.saturating_mul(2).clamp(1, max.max(1))
5103}
5104
5105struct TextGlyphAtlas {
5106    texture: wgpu::Texture,
5107    _view: wgpu::TextureView,
5108    bind_group: wgpu::BindGroup,
5109    entries: BoundedLruCache<SoftwareGlyphAtlasKey, GlyphAtlasEntry>,
5110    generation: u64,
5111    /// Side length of `texture`, between `TEXT_GLYPH_ATLAS_MIN_SIZE` and the
5112    /// device's ceiling. Every UV is normalised against it, so it has to travel
5113    /// with the atlas rather than be read back off a constant.
5114    size: u32,
5115    /// Largest side length this atlas may grow to: the smaller of
5116    /// `TEXT_GLYPH_ATLAS_MAX_SIZE` and what the device grants. Mobile devices
5117    /// are requested `downlevel_defaults()` limits raised by `using_resolution`,
5118    /// so a device that only offers 2048 would otherwise fail to create the
5119    /// texture outright.
5120    max_size: u32,
5121    cursor_x: u32,
5122    cursor_y: u32,
5123    row_height: u32,
5124    upload_scratch: Vec<u8>,
5125}
5126
5127impl TextGlyphAtlas {
5128    fn new(
5129        device: &wgpu::Device,
5130        image_layout: &wgpu::BindGroupLayout,
5131        sampler: &wgpu::Sampler,
5132        size: u32,
5133    ) -> Self {
5134        let max_size = TEXT_GLYPH_ATLAS_MAX_SIZE.min(device.limits().max_texture_dimension_2d);
5135        let size = size.clamp(TEXT_GLYPH_ATLAS_MIN_SIZE.min(max_size), max_size);
5136        let texture = Self::create_texture(device, size);
5137        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
5138        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5139            label: Some("Text Glyph Atlas Bind Group"),
5140            layout: image_layout,
5141            entries: &[
5142                wgpu::BindGroupEntry {
5143                    binding: 0,
5144                    resource: wgpu::BindingResource::TextureView(&view),
5145                },
5146                wgpu::BindGroupEntry {
5147                    binding: 1,
5148                    resource: wgpu::BindingResource::Sampler(sampler),
5149                },
5150            ],
5151        });
5152        Self {
5153            texture,
5154            _view: view,
5155            bind_group,
5156            entries: BoundedLruCache::with_capacity_at_least_one(MAX_TEXT_GLYPH_ATLAS_ITEMS),
5157            generation: 0,
5158            size,
5159            max_size,
5160            cursor_x: TEXT_GLYPH_ATLAS_PADDING,
5161            cursor_y: TEXT_GLYPH_ATLAS_PADDING,
5162            row_height: 0,
5163            upload_scratch: Vec::new(),
5164        }
5165    }
5166
5167    fn create_texture(device: &wgpu::Device, size: u32) -> wgpu::Texture {
5168        device.create_texture(&wgpu::TextureDescriptor {
5169            label: Some("Text Glyph Atlas Texture"),
5170            size: wgpu::Extent3d {
5171                width: size,
5172                height: size,
5173                depth_or_array_layers: 1,
5174            },
5175            mip_level_count: 1,
5176            sample_count: 1,
5177            dimension: wgpu::TextureDimension::D2,
5178            format: wgpu::TextureFormat::R8Unorm,
5179            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5180            view_formats: &[],
5181        })
5182    }
5183
5184    /// Throws every cached glyph away and starts over on a texture one doubling
5185    /// larger, up to [`TextGlyphAtlas::max_size`].
5186    ///
5187    /// `allocate` is a one-way shelf cursor with no compaction, so the only
5188    /// recovery from a full atlas is to start again — and starting again at the
5189    /// same size makes a workload whose live glyph set genuinely does not fit
5190    /// re-raster every glyph every frame. Treating each overflow as the signal
5191    /// to double means the atlas converges on the size the workload actually
5192    /// needs: a text-heavy screen reaches the old fixed 4096 after at most three
5193    /// resets and behaves identically from then on, while a watch face that
5194    /// never overflows never pays for space it will not use.
5195    ///
5196    /// Bumping the generation is what invalidates the cached glyph runs, whose
5197    /// UVs are normalised against the previous size and would otherwise sample
5198    /// the wrong part of the new texture.
5199    fn reset(
5200        &mut self,
5201        device: &wgpu::Device,
5202        image_layout: &wgpu::BindGroupLayout,
5203        sampler: &wgpu::Sampler,
5204    ) {
5205        let generation = self.generation.wrapping_add(1);
5206        let grown = next_glyph_atlas_size(self.size, self.max_size);
5207        let mut next = Self::new(device, image_layout, sampler, grown);
5208        next.generation = generation;
5209        *self = next;
5210    }
5211
5212    fn generation(&self) -> u64 {
5213        self.generation
5214    }
5215
5216    fn size(&self) -> u32 {
5217        self.size
5218    }
5219
5220    fn entry(&mut self, key: &SoftwareGlyphAtlasKey) -> Option<GlyphAtlasEntry> {
5221        self.entries.get(key).copied()
5222    }
5223
5224    fn allocate(&mut self, width: u32, height: u32) -> Option<GlyphAtlasEntry> {
5225        if width == 0
5226            || height == 0
5227            || width + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
5228            || height + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
5229        {
5230            return None;
5231        }
5232
5233        if self.cursor_x + width + TEXT_GLYPH_ATLAS_PADDING > self.size {
5234            self.cursor_x = TEXT_GLYPH_ATLAS_PADDING;
5235            self.cursor_y = self
5236                .cursor_y
5237                .saturating_add(self.row_height)
5238                .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
5239            self.row_height = 0;
5240        }
5241        if self.cursor_y + height + TEXT_GLYPH_ATLAS_PADDING > self.size {
5242            return None;
5243        }
5244
5245        let entry = GlyphAtlasEntry {
5246            x: self.cursor_x,
5247            y: self.cursor_y,
5248            width,
5249            height,
5250        };
5251        self.cursor_x = self
5252            .cursor_x
5253            .saturating_add(width)
5254            .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
5255        self.row_height = self.row_height.max(height);
5256        Some(entry)
5257    }
5258
5259    fn upload_glyph(
5260        &mut self,
5261        key: SoftwareGlyphAtlasKey,
5262        glyph: &SoftwareGlyphAtlasGlyph,
5263        queue: &wgpu::Queue,
5264        executor: &mut WgpuFrameGraphExecutor,
5265        frame_stats: &mut gpu_stats::FrameStats,
5266    ) -> Option<GlyphAtlasEntry> {
5267        if let Some(entry) = self.entry(&key) {
5268            frame_stats.record_text_glyph_atlas_hit();
5269            return Some(entry);
5270        }
5271
5272        let width = u32::try_from(glyph.mask.width).ok()?;
5273        let height = u32::try_from(glyph.mask.height).ok()?;
5274        let entry = self.allocate(width, height)?;
5275        self.upload_scratch.clear();
5276        self.upload_scratch.reserve(
5277            glyph
5278                .mask
5279                .alpha
5280                .len()
5281                .saturating_sub(self.upload_scratch.capacity()),
5282        );
5283        self.upload_scratch.extend(
5284            glyph
5285                .mask
5286                .alpha
5287                .iter()
5288                .map(|alpha| (alpha.clamp(0.0, 1.0) * 255.0).round() as u8),
5289        );
5290
5291        let upload_stats = executor.upload_texture(
5292            queue,
5293            wgpu::TexelCopyTextureInfo {
5294                texture: &self.texture,
5295                mip_level: 0,
5296                origin: wgpu::Origin3d {
5297                    x: entry.x,
5298                    y: entry.y,
5299                    z: 0,
5300                },
5301                aspect: wgpu::TextureAspect::All,
5302            },
5303            &self.upload_scratch,
5304            wgpu::TexelCopyBufferLayout {
5305                offset: 0,
5306                bytes_per_row: Some(entry.width),
5307                rows_per_image: Some(entry.height),
5308            },
5309            wgpu::Extent3d {
5310                width: entry.width,
5311                height: entry.height,
5312                depth_or_array_layers: 1,
5313            },
5314        );
5315        frame_stats.record_command_stats(upload_stats);
5316        frame_stats.record_text_glyph_atlas_miss(entry.width, entry.height);
5317        self.entries.put(key, entry);
5318        Some(entry)
5319    }
5320}
5321
5322struct ImageDrawCmd {
5323    index_start: u32,
5324    scissor: (u32, u32, u32, u32),
5325    image_id: u64,
5326    sampling: ImageSampling,
5327}
5328
5329#[derive(Clone, Copy)]
5330enum GlyphDrawSource {
5331    Shared {
5332        index_start: u32,
5333        index_count: u32,
5334    },
5335    #[cfg(not(target_arch = "wasm32"))]
5336    Retained {
5337        cache_key: TextGlyphRunCacheKey,
5338        uniform_slot: usize,
5339    },
5340}
5341
5342#[derive(Clone, Copy)]
5343struct GlyphDrawCmd {
5344    source: GlyphDrawSource,
5345    scissor: (u32, u32, u32, u32),
5346}
5347
5348impl GlyphDrawCmd {
5349    fn shared(index_start: u32, index_count: u32, scissor: (u32, u32, u32, u32)) -> Self {
5350        Self {
5351            source: GlyphDrawSource::Shared {
5352                index_start,
5353                index_count,
5354            },
5355            scissor,
5356        }
5357    }
5358
5359    #[cfg(not(target_arch = "wasm32"))]
5360    fn retained(
5361        cache_key: TextGlyphRunCacheKey,
5362        uniform_slot: usize,
5363        scissor: (u32, u32, u32, u32),
5364    ) -> Self {
5365        Self {
5366            source: GlyphDrawSource::Retained {
5367                cache_key,
5368                uniform_slot,
5369            },
5370            scissor,
5371        }
5372    }
5373}
5374
5375#[derive(Clone, Copy, Debug, PartialEq)]
5376struct ImageUvRect {
5377    min: [f32; 2],
5378    max: [f32; 2],
5379    sample_bounds: [f32; 4],
5380}
5381
5382// Text raster cache is owned by GpuRenderer and backed by software text images
5383// between measurement and rendering to eliminate duplicate text shaping
5384
5385/// Persistent GPU buffers for batched shape rendering. There is no vertex or
5386/// index buffer: the shape shader pulls quad corners straight out of
5387/// `ShapeData` by `vertex_index`, so the batch is drawn unindexed.
5388struct ShapeBatchBuffers {
5389    shape_buffer: wgpu::Buffer,
5390    gradient_buffer: wgpu::Buffer,
5391    bind_group: wgpu::BindGroup,
5392    shape_capacity: usize,
5393    gradient_capacity: usize,
5394    batch_limits: ShapeBatchLimits,
5395}
5396
5397#[cfg(target_arch = "wasm32")]
5398struct UniformBatchBuffer {
5399    buffer: wgpu::Buffer,
5400    bind_group: wgpu::BindGroup,
5401}
5402
5403#[cfg(target_arch = "wasm32")]
5404struct ImageBatchBuffers {
5405    vertex_buffer: wgpu::Buffer,
5406    index_buffer: wgpu::Buffer,
5407    vertex_capacity: usize,
5408    index_capacity: usize,
5409}
5410
5411#[derive(Clone, Copy, Debug, PartialEq)]
5412struct ViewportUniformParams {
5413    width: u32,
5414    height: u32,
5415    offset: [f32; 2],
5416}
5417
5418#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5419#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
5420enum UploadTarget {
5421    Uniform,
5422    ShapeData,
5423    ShapeGradient,
5424    ImageVertex,
5425    ImageIndex,
5426    #[cfg(not(target_arch = "wasm32"))]
5427    RetainedGlyphUniform,
5428    /// The shared replay-transform buffer; copies land at each slot's fixed
5429    /// 256-byte-aligned offset.
5430    #[cfg(not(target_arch = "wasm32"))]
5431    ReplayTransform,
5432    /// A replay slot's retained paint buffer (color patches land here).
5433    #[cfg(not(target_arch = "wasm32"))]
5434    ReplayPaintData(u32),
5435}
5436
5437#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5438#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
5439struct PendingBufferCopy {
5440    source_offset: u64,
5441    target_offset: u64,
5442    size: u64,
5443    target: UploadTarget,
5444}
5445
5446#[derive(Default)]
5447struct StagedBufferUploads {
5448    bytes: Vec<u8>,
5449    copies: Vec<PendingBufferCopy>,
5450}
5451
5452impl StagedBufferUploads {
5453    fn clear(&mut self) {
5454        self.bytes.clear();
5455        self.copies.clear();
5456    }
5457
5458    fn shrink_retained_capacity(&mut self, max_bytes: usize, max_copies: usize) -> bool {
5459        let mut shrunk = false;
5460        if self.bytes.len() <= max_bytes && self.bytes.capacity() > max_bytes {
5461            self.bytes.shrink_to(max_bytes);
5462            shrunk = true;
5463        }
5464        if self.copies.len() <= max_copies && self.copies.capacity() > max_copies {
5465            self.copies.shrink_to(max_copies);
5466            shrunk = true;
5467        }
5468        shrunk
5469    }
5470
5471    fn is_empty(&self) -> bool {
5472        self.copies.is_empty()
5473    }
5474
5475    #[cfg(test)]
5476    fn payload_for_copy(&self, copy: PendingBufferCopy) -> &[u8] {
5477        let start = copy.source_offset as usize;
5478        let end = start + copy.size as usize;
5479        &self.bytes[start..end]
5480    }
5481
5482    #[cfg(not(target_arch = "wasm32"))]
5483    fn stage(&mut self, target: UploadTarget, bytes: &[u8]) {
5484        self.stage_at(target, 0, bytes);
5485    }
5486
5487    /// Records a GPU copy whose source bytes were already written into the
5488    /// frame upload buffer (via `Queue::write_buffer_with`), so nothing is
5489    /// appended to `bytes`. `source_offset` is relative to the same base the
5490    /// caller later passes to `flush_staged_uploads_at`.
5491    #[cfg(not(target_arch = "wasm32"))]
5492    fn record_upload_copy(
5493        &mut self,
5494        target: UploadTarget,
5495        source_offset: u64,
5496        target_offset: u64,
5497        size: u64,
5498    ) {
5499        if size == 0 {
5500            return;
5501        }
5502        self.copies.push(PendingBufferCopy {
5503            source_offset,
5504            target_offset,
5505            size,
5506            target,
5507        });
5508    }
5509
5510    #[cfg(not(target_arch = "wasm32"))]
5511    fn stage_at(&mut self, target: UploadTarget, target_offset: u64, bytes: &[u8]) {
5512        if bytes.is_empty() {
5513            return;
5514        }
5515
5516        debug_assert_eq!(
5517            bytes.len() % wgpu::COPY_BUFFER_ALIGNMENT as usize,
5518            0,
5519            "buffer uploads must be aligned to copy requirements"
5520        );
5521
5522        let aligned_offset = align_usize_to(self.bytes.len(), wgpu::COPY_BUFFER_ALIGNMENT as usize);
5523        if aligned_offset > self.bytes.len() {
5524            self.bytes.resize(aligned_offset, 0);
5525        }
5526
5527        let source_offset = self.bytes.len() as u64;
5528        self.bytes.extend_from_slice(bytes);
5529        self.copies.push(PendingBufferCopy {
5530            source_offset,
5531            target_offset,
5532            size: bytes.len() as u64,
5533            target,
5534        });
5535    }
5536
5537    fn truncate(&mut self, bytes_len: usize, copies_len: usize) {
5538        self.bytes.truncate(bytes_len);
5539        self.copies.truncate(copies_len);
5540    }
5541}
5542
5543/// The fresh-batch entry list for the shape bind group layout: the batch's
5544/// own data buffers, the shared identity similarity buffer, and — storage
5545/// mode only, where the layout carries the paint entry — the renderer-wide
5546/// dummy paint buffer (fresh draws leave `paint_select` at 0.0).
5547fn shape_batch_bind_group_entries<'a>(
5548    shape_buffer: &'a wgpu::Buffer,
5549    gradient_buffer: &'a wgpu::Buffer,
5550    similarity_buffer: &'a wgpu::Buffer,
5551    paint_buffer: Option<&'a wgpu::Buffer>,
5552) -> Vec<wgpu::BindGroupEntry<'a>> {
5553    let mut entries = vec![
5554        wgpu::BindGroupEntry {
5555            binding: 0,
5556            resource: shape_buffer.as_entire_binding(),
5557        },
5558        wgpu::BindGroupEntry {
5559            binding: 1,
5560            resource: gradient_buffer.as_entire_binding(),
5561        },
5562        wgpu::BindGroupEntry {
5563            binding: 2,
5564            resource: similarity_buffer.as_entire_binding(),
5565        },
5566    ];
5567    if let Some(paint_buffer) = paint_buffer {
5568        entries.push(wgpu::BindGroupEntry {
5569            binding: 3,
5570            resource: paint_buffer.as_entire_binding(),
5571        });
5572    }
5573    entries
5574}
5575
5576impl ShapeBatchBuffers {
5577    fn new(
5578        device: &wgpu::Device,
5579        bind_group_layout: &wgpu::BindGroupLayout,
5580        similarity_buffer: &wgpu::Buffer,
5581        paint_buffer: Option<&wgpu::Buffer>,
5582        batch_limits: ShapeBatchLimits,
5583    ) -> Self {
5584        debug_assert_eq!(
5585            paint_buffer.is_some(),
5586            batch_limits.storage,
5587            "the paint binding exists exactly when the layout is in storage mode"
5588        );
5589        let initial_shape_cap = batch_limits.initial_shape_capacity();
5590        let initial_gradient_cap = batch_limits.initial_gradient_capacity();
5591
5592        let shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5593            label: Some("Shape Data Buffer"),
5594            size: (std::mem::size_of::<ShapeData>() * initial_shape_cap) as u64,
5595            usage: batch_limits.data_buffer_usage(),
5596            mapped_at_creation: false,
5597        });
5598
5599        let gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5600            label: Some("Gradient Buffer"),
5601            size: (std::mem::size_of::<GradientStop>() * initial_gradient_cap) as u64,
5602            usage: batch_limits.data_buffer_usage(),
5603            mapped_at_creation: false,
5604        });
5605
5606        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5607            label: Some("Shape Bind Group"),
5608            layout: bind_group_layout,
5609            entries: &shape_batch_bind_group_entries(
5610                &shape_buffer,
5611                &gradient_buffer,
5612                similarity_buffer,
5613                paint_buffer,
5614            ),
5615        });
5616
5617        Self {
5618            shape_buffer,
5619            gradient_buffer,
5620            bind_group,
5621            shape_capacity: initial_shape_cap,
5622            gradient_capacity: initial_gradient_cap,
5623            batch_limits,
5624        }
5625    }
5626
5627    /// Ensure buffers have enough capacity, resizing if needed.
5628    /// Clamps growth to prevent excessive allocations for huge scenes.
5629    fn ensure_capacity(
5630        &mut self,
5631        device: &wgpu::Device,
5632        bind_group_layout: &wgpu::BindGroupLayout,
5633        similarity_buffer: &wgpu::Buffer,
5634        paint_buffer: Option<&wgpu::Buffer>,
5635        shapes_needed: usize,
5636        gradients_needed: usize,
5637    ) {
5638        let mut need_bind_group_update = false;
5639
5640        // In uniform mode the shape and gradient buffers start at the cap
5641        // (the shader's fixed-size array length) so these never fire; in
5642        // storage mode they double toward the cap as scenes demand.
5643        if shapes_needed > self.shape_capacity
5644            && self.shape_capacity < self.batch_limits.max_shapes_per_batch
5645        {
5646            let new_cap = shapes_needed
5647                .next_power_of_two()
5648                .min(self.batch_limits.max_shapes_per_batch);
5649            self.shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5650                label: Some("Shape Data Buffer"),
5651                size: (std::mem::size_of::<ShapeData>() * new_cap) as u64,
5652                usage: self.batch_limits.data_buffer_usage(),
5653                mapped_at_creation: false,
5654            });
5655            self.shape_capacity = new_cap;
5656            need_bind_group_update = true;
5657        }
5658
5659        if gradients_needed > self.gradient_capacity
5660            && self.gradient_capacity < self.batch_limits.max_gradient_stops
5661        {
5662            let new_cap = gradients_needed
5663                .max(1)
5664                .next_power_of_two()
5665                .min(self.batch_limits.max_gradient_stops);
5666            self.gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5667                label: Some("Gradient Buffer"),
5668                size: (std::mem::size_of::<GradientStop>() * new_cap) as u64,
5669                usage: self.batch_limits.data_buffer_usage(),
5670                mapped_at_creation: false,
5671            });
5672            self.gradient_capacity = new_cap;
5673            need_bind_group_update = true;
5674        }
5675
5676        if need_bind_group_update {
5677            self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5678                label: Some("Shape Bind Group"),
5679                layout: bind_group_layout,
5680                entries: &shape_batch_bind_group_entries(
5681                    &self.shape_buffer,
5682                    &self.gradient_buffer,
5683                    similarity_buffer,
5684                    paint_buffer,
5685                ),
5686            });
5687        }
5688    }
5689}
5690
5691#[cfg(target_arch = "wasm32")]
5692impl UniformBatchBuffer {
5693    fn new(device: &wgpu::Device, bind_group_layout: &wgpu::BindGroupLayout) -> Self {
5694        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
5695            label: Some("Viewport Uniform Batch Buffer"),
5696            size: std::mem::size_of::<Uniforms>() as u64,
5697            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5698            mapped_at_creation: false,
5699        });
5700        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5701            label: Some("Viewport Uniform Batch Bind Group"),
5702            layout: bind_group_layout,
5703            entries: &[wgpu::BindGroupEntry {
5704                binding: 0,
5705                resource: buffer.as_entire_binding(),
5706            }],
5707        });
5708        Self { buffer, bind_group }
5709    }
5710}
5711
5712#[cfg(target_arch = "wasm32")]
5713impl ImageBatchBuffers {
5714    fn new(device: &wgpu::Device) -> Self {
5715        let vertex_capacity = 4;
5716        let index_capacity = 6;
5717        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5718            label: Some("Image Vertex Batch Buffer"),
5719            size: (std::mem::size_of::<Vertex>() * vertex_capacity) as u64,
5720            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5721            mapped_at_creation: false,
5722        });
5723        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5724            label: Some("Image Index Batch Buffer"),
5725            size: (std::mem::size_of::<u32>() * index_capacity) as u64,
5726            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5727            mapped_at_creation: false,
5728        });
5729        Self {
5730            vertex_buffer,
5731            index_buffer,
5732            vertex_capacity,
5733            index_capacity,
5734        }
5735    }
5736
5737    fn ensure_capacity(
5738        &mut self,
5739        device: &wgpu::Device,
5740        vertices_needed: usize,
5741        indices_needed: usize,
5742    ) {
5743        let hard_max_bytes = HARD_MAX_BUFFER_MB * 1024 * 1024;
5744        if vertices_needed > self.vertex_capacity {
5745            let desired = vertices_needed.next_power_of_two();
5746            let max_count = hard_max_bytes / std::mem::size_of::<Vertex>();
5747            let new_cap = desired.min(max_count);
5748            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5749                label: Some("Image Vertex Batch Buffer"),
5750                size: (std::mem::size_of::<Vertex>() * new_cap) as u64,
5751                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5752                mapped_at_creation: false,
5753            });
5754            self.vertex_capacity = new_cap;
5755        }
5756        if indices_needed > self.index_capacity {
5757            let desired = indices_needed.next_power_of_two();
5758            let max_count = hard_max_bytes / std::mem::size_of::<u32>();
5759            let new_cap = desired.min(max_count);
5760            self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5761                label: Some("Image Index Batch Buffer"),
5762                size: (std::mem::size_of::<u32>() * new_cap) as u64,
5763                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5764                mapped_at_creation: false,
5765            });
5766            self.index_capacity = new_cap;
5767        }
5768    }
5769}
5770
5771// Text image cache keys are local to rasterized WGPU text batches
5772
5773pub struct GpuRenderer {
5774    pub(crate) device: Arc<wgpu::Device>,
5775    pub(crate) queue: Arc<wgpu::Queue>,
5776    /// Uncaptured-error record shared with the handler installed on
5777    /// `device` at construction ([`DeviceErrorSentry`];
5778    /// `CRANPOSE_SURVIVE_GPU_ERRORS` kill switch). Poisoned by any
5779    /// uncaptured error; the head of [`Self::render`] answers each
5780    /// poisoning with one cancelled packet.
5781    device_errors: Arc<DeviceErrorSentry>,
5782    /// This instance's renderer epoch, stamped by `init_gpu` at
5783    /// construction. A packet whose `renderer_epoch` differs was built
5784    /// against another instance and is cancelled at the head of
5785    /// [`Self::render`], never drawn.
5786    renderer_epoch: u64,
5787    /// The producer feed generation this store's slot universe belongs to:
5788    /// seeded at construction, advanced by `consume_replay_ops` when a
5789    /// higher-generation batch arrives (the batch itself carries the
5790    /// retirement releases). The store never reads the producer's
5791    /// thread-local — this field is its only generation authority.
5792    #[cfg(not(target_arch = "wasm32"))]
5793    store_feed_generation: u64,
5794    surface_format: wgpu::TextureFormat,
5795    adapter_backend: wgpu::Backend,
5796    shape_batch_limits: ShapeBatchLimits,
5797    /// `Some` exactly when the device granted [`wgpu::Features::PIPELINE_CACHE`]
5798    /// (Vulkan; the platform layer requests it where the adapter offers it).
5799    /// Every pipeline creation in this renderer passes it so the driver can
5800    /// reuse compiled code across creates — and across launches once
5801    /// [`crate::pipeline_disk_cache`] persists the blob.
5802    pipeline_cache: Option<wgpu::PipelineCache>,
5803    pipeline: PassPipeline,
5804    pipeline_dst_out: PassPipeline,
5805    /// `fs_solid` twin of `pipeline` (SrcOver only), for gradient-free draws.
5806    pipeline_solid: PassPipeline,
5807    /// `Some` exactly in storage mode: the retained-mesh pipeline (`vs_mesh`
5808    /// over a vertex buffer) that replay slots with a captured arc mesh draw
5809    /// through. Uniform-mode devices never host retained slots.
5810    #[cfg(not(target_arch = "wasm32"))]
5811    mesh_pipeline: PassPipeline,
5812    /// `Some` exactly when this renderer latched the instanced-quad path at
5813    /// construction (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0). Read
5814    /// ONCE per renderer lifetime — cached retained bundles encode the
5815    /// selection, so it must never move under them (see
5816    /// [`instanced_quads_enabled`]).
5817    #[cfg(not(target_arch = "wasm32"))]
5818    instanced_quads: Option<InstancedQuadPipelines>,
5819    uniform_bind_group_layout: wgpu::BindGroupLayout,
5820    shape_bind_group_layout: wgpu::BindGroupLayout,
5821    /// `Some` exactly in storage mode: the 16-byte stand-in every fresh
5822    /// batch binds at the paint entry (see `shape_batch_bind_group_entries`).
5823    dummy_paint_buffer: Option<wgpu::Buffer>,
5824    /// Shared identity binding for `@group(1) @binding(2)`: every freshly
5825    /// converted shape batch draws untransformed through this one buffer.
5826    identity_similarity_buffer: wgpu::Buffer,
5827    #[cfg(not(target_arch = "wasm32"))]
5828    replay_slots: ReplaySlotStore,
5829    image_pipeline: PassPipeline,
5830    image_pipeline_dst_out: PassPipeline,
5831    glyph_atlas_pipeline: PassPipeline,
5832    #[cfg(not(target_arch = "wasm32"))]
5833    retained_glyph_atlas_pipeline: PassPipeline,
5834    image_bind_group_layout: wgpu::BindGroupLayout,
5835    #[cfg(not(target_arch = "wasm32"))]
5836    retained_glyph_uniform_bind_group_layout: wgpu::BindGroupLayout,
5837    image_nearest_sampler: wgpu::Sampler,
5838    image_linear_sampler: wgpu::Sampler,
5839    text_fonts: SoftwareTextFontSet,
5840    // Persistent GPU buffers (reused across frames)
5841    #[cfg(not(target_arch = "wasm32"))]
5842    upload_buffer: wgpu::Buffer,
5843    #[cfg(not(target_arch = "wasm32"))]
5844    uniform_buffer: wgpu::Buffer,
5845    #[cfg(not(target_arch = "wasm32"))]
5846    uniform_bind_group: wgpu::BindGroup,
5847    #[cfg(not(target_arch = "wasm32"))]
5848    shape_buffers: ShapeBatchBuffers,
5849    #[cfg(not(target_arch = "wasm32"))]
5850    image_vertex_buffer: wgpu::Buffer,
5851    #[cfg(not(target_arch = "wasm32"))]
5852    image_index_buffer: wgpu::Buffer,
5853    #[cfg(not(target_arch = "wasm32"))]
5854    retained_glyph_uniform_buffer: wgpu::Buffer,
5855    #[cfg(not(target_arch = "wasm32"))]
5856    retained_glyph_uniform_bind_group: wgpu::BindGroup,
5857    #[cfg(not(target_arch = "wasm32"))]
5858    retained_glyph_uniform_stride: u64,
5859    #[cfg(not(target_arch = "wasm32"))]
5860    retained_glyph_uniform_capacity: usize,
5861    #[cfg(not(target_arch = "wasm32"))]
5862    retained_glyph_uniform_cursor: usize,
5863    #[cfg(target_arch = "wasm32")]
5864    wasm_uniform_batches: Vec<UniformBatchBuffer>,
5865    #[cfg(target_arch = "wasm32")]
5866    wasm_uniform_batch_cursor: usize,
5867    #[cfg(target_arch = "wasm32")]
5868    wasm_shape_batches: Vec<ShapeBatchBuffers>,
5869    #[cfg(target_arch = "wasm32")]
5870    wasm_shape_batch_cursor: usize,
5871    #[cfg(target_arch = "wasm32")]
5872    wasm_image_batches: Vec<ImageBatchBuffers>,
5873    #[cfg(target_arch = "wasm32")]
5874    wasm_image_batch_cursor: usize,
5875    image_texture_cache: BoundedLruCache<u64, CachedImageTexture>,
5876    /// Total `CachedImageTexture::bytes` currently in the cache.
5877    image_texture_cache_bytes: usize,
5878    text_image_cache: BoundedLruCache<TextImageCacheKey, CachedTextImage>,
5879    text_glyph_atlas: TextGlyphAtlas,
5880    text_glyph_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedTextGlyphRun>,
5881    #[cfg(not(target_arch = "wasm32"))]
5882    text_glyph_gpu_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedGpuTextGlyphRun>,
5883    text_glyph_mask_cache: SoftwareGlyphRasterCache,
5884    text_line_index_cache: TextLineIndexCache,
5885    scratch_shape_data: Vec<ShapeData>,
5886    scratch_gradients: Vec<GradientStop>,
5887    scratch_image_vertices: Vec<Vertex>,
5888    scratch_image_indices: Vec<u32>,
5889    scratch_image_cmds: Vec<ImageDrawCmd>,
5890    scratch_glyph_cmds: Vec<GlyphDrawCmd>,
5891    scratch_text_glyph_run: Vec<SoftwareGlyphAtlasRunGlyph>,
5892    scratch_text_glyph_placements: Vec<SoftwareGlyphAtlasPlacement>,
5893    scratch_text_glyph_quads: Vec<CachedTextGlyphQuad>,
5894    scratch_segment_items: Vec<(usize, SegmentDrawItem)>,
5895    scratch_effect_ranges: Vec<Range<usize>>,
5896    scratch_layer_events: Vec<LayerEvent>,
5897    staged_uploads: StagedBufferUploads,
5898    frame_graph_executor: WgpuFrameGraphExecutor,
5899    deferred_offscreen_releases: Vec<OffscreenTarget>,
5900    effect_renderer: EffectRenderer,
5901    layer_surface_cache: LayerSurfaceCache,
5902    observed_scene_range_cache_misses: BoundedLruCache<LayerRasterCacheKey, ()>,
5903    shadow_surface_cache: BoundedLruCache<ShadowSurfaceCacheKey, CachedShadowSurface>,
5904    shadow_surface_cache_bytes: u64,
5905    frame_stats: gpu_stats::FrameStats,
5906    last_frame_stats: Option<gpu_stats::FrameStatsSnapshot>,
5907    pending_frame_warmup_frames: u8,
5908    frame_count: u64,
5909    gpu_stats_enabled: bool,
5910    warning_state: RendererWarningState,
5911    #[cfg(not(target_arch = "wasm32"))]
5912    replay_upload_stats: ReplayUploadStats,
5913    #[cfg(not(target_arch = "wasm32"))]
5914    segment_encode_stats: SegmentEncodeStats,
5915    /// The frame's replay recolor patches, parked here by
5916    /// `consume_replay_ops` until the retained prepare arms drain them
5917    /// (`stage_replay_patches`). The vec this frame's ops displace is last
5918    /// frame's, already drained empty, and returns to the producer with
5919    /// the ack — capacity ping-pongs planner queue → packet ops → here →
5920    /// ack return, so neither side allocates per frame (P4b).
5921    #[cfg(not(target_arch = "wasm32"))]
5922    replay_color_patches: Vec<crate::scene::ColorPatch>,
5923    /// Drain arena for `replay_color_patches`: `stage_replay_patches`
5924    /// swaps against this instead of `mem::take`, so both keep their
5925    /// high-water capacity across frames. Always empty between drains.
5926    #[cfg(not(target_arch = "wasm32"))]
5927    color_patch_scratch: Vec<crate::scene::ColorPatch>,
5928    /// Capture staging scratch for `capture_replay_slot`: the converted
5929    /// `ShapeData` records and gradient stops are built here, copied into
5930    /// the slot's fresh GPU buffers, and the allocations survive to the
5931    /// next capture — a re-partition frame captures one slot per segment
5932    /// and used to allocate both vectors per slot.
5933    #[cfg(not(target_arch = "wasm32"))]
5934    replay_capture_shape_scratch: Vec<ShapeData>,
5935    /// The gradient-stop half of the capture staging scratch.
5936    #[cfg(not(target_arch = "wasm32"))]
5937    replay_capture_gradient_scratch: Vec<GradientStop>,
5938    /// Recycled confirmations buffer for the next [`crate::frame_packet::ReplayAck`]:
5939    /// `consume_replay_ops` fills it, the planner drains it in `apply_ack`,
5940    /// and the render loop hands the emptied vec (capacity intact) back
5941    /// here — the ack channel's half of the P4b no-allocation contract.
5942    #[cfg(not(target_arch = "wasm32"))]
5943    replay_ack_confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
5944    /// Lifetime count of replay-ops batches dropped whole by the
5945    /// generation check in `consume_replay_ops` — fail-closed against ops
5946    /// planned under a slot universe this store no longer holds.
5947    /// Synchronously impossible today; structural for the pipeline split.
5948    #[cfg(not(target_arch = "wasm32"))]
5949    replay_generation_drops: u64,
5950    /// Cached render bundles for maximal consecutive retained stretches in
5951    /// the fused segment pass (`CRANPOSE_RETAINED_BUNDLES` kill switch).
5952    #[cfg(not(target_arch = "wasm32"))]
5953    retained_bundle_cache: RetainedBundleCache,
5954    /// Per-frame scratch for transient rim band meshes (`rim_mesh_band`):
5955    /// appended per fused chunk, cleared at the top of every frame. Index
5956    /// values are absolute into the frame's vertex list, so later chunks
5957    /// append without rebasing.
5958    #[cfg(not(target_arch = "wasm32"))]
5959    rim_mesh_vertices: Vec<MeshVertex>,
5960    #[cfg(not(target_arch = "wasm32"))]
5961    rim_mesh_indices: Vec<u32>,
5962    /// Fixed-capacity GPU twins of the rim scratch vecs, created lazily on
5963    /// the first rim ([`RIM_MESH_VERTEX_CAPACITY`] /
5964    /// [`RIM_MESH_INDEX_CAPACITY`]). NEVER recreated mid-frame: draws are
5965    /// encoded before submit, so a replacement buffer would orphan every
5966    /// already-encoded rim draw.
5967    #[cfg(not(target_arch = "wasm32"))]
5968    rim_mesh_vertex_buffer: Option<wgpu::Buffer>,
5969    #[cfg(not(target_arch = "wasm32"))]
5970    rim_mesh_index_buffer: Option<wgpu::Buffer>,
5971    /// Counts of scratch vertices/indices already uploaded this frame, so
5972    /// each fused chunk uploads only its newly appended region.
5973    #[cfg(not(target_arch = "wasm32"))]
5974    rim_mesh_uploaded_vertices: usize,
5975    #[cfg(not(target_arch = "wasm32"))]
5976    rim_mesh_uploaded_indices: usize,
5977    /// Lifetime count of rims drawn as band meshes — the test hook behind
5978    /// [`Self::rim_meshes_emitted`].
5979    #[cfg(not(target_arch = "wasm32"))]
5980    rim_meshes_emitted: u64,
5981    /// Submitted fill-area accounting (`CRANPOSE_FILL_DIAG`); idle unless
5982    /// the flag is set.
5983    #[cfg(not(target_arch = "wasm32"))]
5984    fill_area_diag: FillAreaDiag,
5985    /// Opaque static leading-span cache (`CRANPOSE_STATIC_SPAN` kill
5986    /// switch): the frame's byte-stable leading draws as one cached
5987    /// full-target blit.
5988    #[cfg(not(target_arch = "wasm32"))]
5989    static_span: StaticSpanCache,
5990    /// Retained-segment surface cache (`CRANPOSE_SEGMENT_SURFACE` opt-in,
5991    /// see [`crate::segment_surface`]): qualifying retained spans rendered
5992    /// once into pooled offscreens and re-drawn per frame as one rotated/
5993    /// scaled textured quad each.
5994    #[cfg(not(target_arch = "wasm32"))]
5995    segment_surfaces: SegmentSurfaceCache,
5996    /// Display clip region cull (see [`crate::display_clip`]): the
5997    /// platform-provided visible region plus the per-size occluder/depth
5998    /// resources. Inert — nothing beyond the enum is ever populated —
5999    /// while the region is [`DisplayVisibleRegion::Full`].
6000    #[cfg(not(target_arch = "wasm32"))]
6001    display_clip: DisplayClipState,
6002}
6003
6004/// Cache key of the display-clip resources: the surface size and the
6005/// region whose complement the occluder was tessellated for.
6006#[cfg(not(target_arch = "wasm32"))]
6007type DisplayClipResourceKey = ((u32, u32), DisplayVisibleRegion);
6008
6009/// State of the display clip region cull, all renderer-side.
6010#[cfg(not(target_arch = "wasm32"))]
6011struct DisplayClipState {
6012    /// The visible region from
6013    /// [`GpuRenderer::set_display_visible_region`] — platform (or host)
6014    /// truth about the panel, never derived from app content. `Full` for
6015    /// every rectangular display; `InscribedCircle` is the round-display
6016    /// provider's value.
6017    visible_region: DisplayVisibleRegion,
6018    /// The view the current frame's packet renders to, set for the duration
6019    /// of [`GpuRenderer::render`]. The fused pass culls only when its
6020    /// target IS this view — full-frame-sized offscreen layer surfaces
6021    /// must render whole (their content can be transformed into view
6022    /// later), so size alone is not the test.
6023    frame_root_view: Option<wgpu::TextureView>,
6024    /// True exactly while a fused pass that carries the depth attachment is
6025    /// being encoded: every pipeline getter consults it to hand out the
6026    /// depth-tested variant, which keeps the dozens of draw sites (and the
6027    /// retained-bundle builder) untouched.
6028    pass_depth: Cell<bool>,
6029    /// Depth attachment + occluder geometry for the current (size, region)
6030    /// pair, or an inner `None` when the region's complement tessellation
6031    /// failed its conservative verification for this size (cull stays
6032    /// off; never retried until size or region changes).
6033    resources: Option<(DisplayClipResourceKey, Option<DisplayClipResources>)>,
6034    occluder_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
6035}
6036
6037#[cfg(not(target_arch = "wasm32"))]
6038impl DisplayClipState {
6039    fn new() -> Self {
6040        Self {
6041            visible_region: DisplayVisibleRegion::Full,
6042            frame_root_view: None,
6043            pass_depth: Cell::new(false),
6044            resources: None,
6045            occluder_pipeline: LazyGpuResource::new("display-clip/occluder"),
6046        }
6047    }
6048}
6049
6050/// Per-(surface-size, region) GPU resources of the display clip cull.
6051#[cfg(not(target_arch = "wasm32"))]
6052struct DisplayClipResources {
6053    /// `Depth16Unorm`, cleared each culled pass, stored never
6054    /// (`StoreOp::Discard`) — transient GMEM residency on tilers.
6055    depth_view: wgpu::TextureView,
6056    /// The region complement's conservative tessellation, NDC positions,
6057    /// triangle list.
6058    occluder_vertex_buffer: wgpu::Buffer,
6059    occluder_vertex_count: u32,
6060}
6061
6062/// Running totals for retained-slot patch uploads, the paint-bandwidth
6063/// instrument: recolors upload 16-byte paint records (plus gradient stop
6064/// spans), coalesced per slot between the lowest and highest patched
6065/// index, so `bytes` versus `ideal_bytes` (patched colors alone) is just
6066/// the untouched records inside each coalesced span.
6067#[cfg(not(target_arch = "wasm32"))]
6068#[derive(Default)]
6069struct ReplayUploadStats {
6070    calls: u64,
6071    patched_calls: u64,
6072    patches: u64,
6073    slots: u64,
6074    records: u64,
6075    bytes: u64,
6076    ideal_bytes: u64,
6077    max_frame_bytes: u64,
6078}
6079
6080#[cfg(not(target_arch = "wasm32"))]
6081impl ReplayUploadStats {
6082    /// One aggregate line roughly every few seconds: cheap enough to stay
6083    /// on unconditionally, which matters because the watch cannot take
6084    /// setprop-backed diag flags — its logcat is the only channel, and a
6085    /// measurement window must catch several lines. Counts every drain
6086    /// call (the drain runs several times per frame; only the first sees
6087    /// patches) so a target with zero paint traffic still reports an
6088    /// affirmative zero instead of silence, while the averages divide by
6089    /// PATCHED calls so they read as per-frame numbers.
6090    /// warn level: the platform loggers filter info on desktop.
6091    const REPORT_CALLS: u64 = 1024;
6092
6093    fn note_frame(&mut self, patches: u64, slots: u64, records: u64, bytes: u64, ideal: u64) {
6094        self.calls += 1;
6095        if patches > 0 {
6096            self.patched_calls += 1;
6097            self.patches += patches;
6098            self.slots += slots;
6099            self.records += records;
6100            self.bytes += bytes;
6101            self.ideal_bytes += ideal;
6102            self.max_frame_bytes = self.max_frame_bytes.max(bytes);
6103        }
6104        if self.calls >= Self::REPORT_CALLS {
6105            let patched = self.patched_calls.max(1);
6106            log::warn!(
6107                "[replay-upload] {} patched of {} drains: avg {:.1} KB/frame (max {:.1} KB), \
6108                 color-only would be {:.1} KB/frame; avg {} patches over {} records in {} slots",
6109                self.patched_calls,
6110                self.calls,
6111                self.bytes as f64 / patched as f64 / 1024.0,
6112                self.max_frame_bytes as f64 / 1024.0,
6113                self.ideal_bytes as f64 / patched as f64 / 1024.0,
6114                self.patches / patched,
6115                self.records / patched,
6116                self.slots / patched,
6117            );
6118            *self = Self::default();
6119        }
6120    }
6121}
6122
6123/// Aggregate cost of the fused native partition loop — the numbers a
6124/// parallel-encode decision needs: how many partitions each chunk carries
6125/// and how long the serial loop spends encoding them. Always-on for the
6126/// same reason as [`ReplayUploadStats`]: the watch takes no setprop diag
6127/// flags, so the line has to reach logcat on its own, and one warn every
6128/// [`Self::REPORT_CALLS`] chunks is bounded.
6129#[cfg(not(target_arch = "wasm32"))]
6130#[derive(Default)]
6131struct SegmentEncodeStats {
6132    calls: u64,
6133    partitions: u64,
6134    max_partitions: u64,
6135    encode_micros: u64,
6136    max_call_micros: u64,
6137}
6138
6139#[cfg(not(target_arch = "wasm32"))]
6140impl SegmentEncodeStats {
6141    const REPORT_CALLS: u64 = 1024;
6142
6143    fn note_call(&mut self, partitions: u64, micros: u64) {
6144        self.calls += 1;
6145        self.partitions += partitions;
6146        self.max_partitions = self.max_partitions.max(partitions);
6147        self.encode_micros += micros;
6148        self.max_call_micros = self.max_call_micros.max(micros);
6149        if self.calls >= Self::REPORT_CALLS {
6150            log::warn!(
6151                "[segment-encode] {} chunks: avg {:.1} partitions (max {}), \
6152                 avg {:.2} ms encode (max {:.2})",
6153                self.calls,
6154                self.partitions as f64 / self.calls as f64,
6155                self.max_partitions,
6156                self.encode_micros as f64 / self.calls as f64 / 1000.0,
6157                self.max_call_micros as f64 / 1000.0,
6158            );
6159            *self = Self::default();
6160        }
6161    }
6162}
6163
6164fn image_sampler_descriptor(sampling: ImageSampling) -> wgpu::SamplerDescriptor<'static> {
6165    let filter = match sampling {
6166        ImageSampling::Nearest => wgpu::FilterMode::Nearest,
6167        ImageSampling::Linear => wgpu::FilterMode::Linear,
6168    };
6169    wgpu::SamplerDescriptor {
6170        label: Some(match sampling {
6171            ImageSampling::Nearest => "Nearest Image Sampler",
6172            ImageSampling::Linear => "Linear Image Sampler",
6173        }),
6174        address_mode_u: wgpu::AddressMode::ClampToEdge,
6175        address_mode_v: wgpu::AddressMode::ClampToEdge,
6176        address_mode_w: wgpu::AddressMode::ClampToEdge,
6177        mag_filter: filter,
6178        min_filter: filter,
6179        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
6180        ..Default::default()
6181    }
6182}
6183
6184#[cfg(test)]
6185fn layer_raster_cache_candidate(
6186    layer: &LayerNode,
6187    root_scale: f32,
6188    has_backdrop_underlay: bool,
6189    allow_runtime_cache: bool,
6190) -> Option<(LayerRasterCacheKey, Rect)> {
6191    let mut layer_surface_requirements_cache = HashMap::new();
6192    let surface_requirements =
6193        layer_surface_requirements_cached(layer, &mut layer_surface_requirements_cache);
6194    let runtime_cache_is_safe = allow_runtime_cache
6195        && surface_requirements
6196            .surface_requirements
6197            .has_isolating_requirement()
6198        && !surface_requirements.contains_runtime_shader;
6199    let cache_is_allowed = layer.cache_policy == CachePolicy::Auto
6200        || (allow_runtime_cache && surface_requirements.has_renderer_forced_surface())
6201        || runtime_cache_is_safe;
6202    if !cache_is_allowed {
6203        return None;
6204    }
6205    if layer_uses_external_backdrop_input(layer, has_backdrop_underlay) {
6206        return None;
6207    }
6208    // Not just this layer's own effect: a shader anywhere below it makes the
6209    // whole subtree change every frame with nothing in any hash to say so.
6210    if surface_requirements.contains_runtime_shader {
6211        return None;
6212    }
6213
6214    let logical_rect = estimate_layer_surface_rect(layer);
6215    let pixel_size = surface_target_size(logical_rect, root_scale, u32::MAX);
6216    Some((
6217        LayerRasterCacheKey::new(
6218            layer.node_id,
6219            layer.target_content_hash(),
6220            layer.effect_hash(),
6221            logical_rect,
6222            pixel_size,
6223            ScaleBucket::from_scale(root_scale),
6224        ),
6225        logical_rect,
6226    ))
6227}
6228
6229impl GpuRenderer {
6230    #[allow(clippy::too_many_arguments)]
6231    pub fn new(
6232        device: Arc<wgpu::Device>,
6233        queue: Arc<wgpu::Queue>,
6234        surface_format: wgpu::TextureFormat,
6235        adapter_backend: wgpu::Backend,
6236        // Beside the backend because it is the same kind of fact: something
6237        // only the ADAPTER can answer, which the device cannot be asked for
6238        // (wgpu 29 has `Adapter::get_downlevel_capabilities` and no device
6239        // equivalent) and which decides whether the shape arrays can be
6240        // storage buffers at all.
6241        adapter_downlevel: wgpu::DownlevelFlags,
6242        text_fonts: SoftwareTextFontSet,
6243        renderer_epoch: u64,
6244        store_feed_generation: u64,
6245    ) -> Self {
6246        #[cfg(target_arch = "wasm32")]
6247        let _ = store_feed_generation;
6248        // Construction time is worth a line of its own. Before pipelines were
6249        // built lazily this call linked every pipeline the frontend could ever
6250        // need, and on a GL device each link ended in a blocking
6251        // `glGetProgramiv` -- 25 s on an emulator, with nothing on screen. That
6252        // is fixed, but "fixed" is a claim that needs a number on each device,
6253        // and the per-pipeline `[gpu-pipeline]` lines cannot say what the
6254        // renderer costs to build when it builds no pipelines at all.
6255        let construction_started = Instant::now();
6256        // Installed before this renderer's first device call, so even a
6257        // construction-time validation error is survived. Replaces wgpu's
6258        // fatal default handler — see [`DeviceErrorSentry`] for the
6259        // double-panic abort this prevents and
6260        // [`survive_gpu_errors_enabled`] for the kill switch.
6261        let device_errors = Arc::new(DeviceErrorSentry::default());
6262        if survive_gpu_errors_enabled() {
6263            let sentry = Arc::clone(&device_errors);
6264            device.on_uncaptured_error(Arc::new(move |error| sentry.record(&error)));
6265        }
6266        let shape_batch_limits = ShapeBatchLimits::for_device(&device, adapter_downlevel);
6267        let uniform_bind_group_layout =
6268            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6269                label: Some("Uniform Bind Group Layout"),
6270                entries: &[wgpu::BindGroupLayoutEntry {
6271                    binding: 0,
6272                    visibility: wgpu::ShaderStages::VERTEX,
6273                    ty: wgpu::BindingType::Buffer {
6274                        ty: wgpu::BufferBindingType::Uniform,
6275                        has_dynamic_offset: false,
6276                        min_binding_size: None,
6277                    },
6278                    count: None,
6279                }],
6280            });
6281        #[cfg(not(target_arch = "wasm32"))]
6282        let retained_glyph_uniform_bind_group_layout =
6283            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6284                label: Some("Retained Glyph Dynamic Uniform Bind Group Layout"),
6285                entries: &[wgpu::BindGroupLayoutEntry {
6286                    binding: 0,
6287                    visibility: wgpu::ShaderStages::VERTEX,
6288                    ty: wgpu::BindingType::Buffer {
6289                        ty: wgpu::BufferBindingType::Uniform,
6290                        has_dynamic_offset: true,
6291                        min_binding_size: wgpu::BufferSize::new(
6292                            std::mem::size_of::<Uniforms>() as u64
6293                        ),
6294                    },
6295                    count: None,
6296                }],
6297            });
6298
6299        // Read-only storage bindings where the device has them (so a whole
6300        // scene fits one batch); uniform arrays on WebGL-class devices, which
6301        // have no storage buffers in fragment shaders. The shape array is
6302        // visible to the vertex stage as well: the pipeline has no vertex
6303        // buffer and `vs_main` pulls quad corners from ShapeData. Storage mode
6304        // is gated on `DownlevelFlags::VERTEX_STORAGE` as well as on the
6305        // limit -- see `ShapeBatchLimits::select`, where the comment this
6306        // replaces claimed GL reports the limit as the minimum across stages
6307        // and Mali proved otherwise.
6308        let mut shape_bind_group_layout_entries = vec![
6309            wgpu::BindGroupLayoutEntry {
6310                binding: 0,
6311                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
6312                ty: wgpu::BindingType::Buffer {
6313                    ty: shape_batch_limits.data_binding_type(),
6314                    has_dynamic_offset: false,
6315                    min_binding_size: None,
6316                },
6317                count: None,
6318            },
6319            wgpu::BindGroupLayoutEntry {
6320                binding: 1,
6321                visibility: wgpu::ShaderStages::FRAGMENT,
6322                ty: wgpu::BindingType::Buffer {
6323                    ty: shape_batch_limits.data_binding_type(),
6324                    has_dynamic_offset: false,
6325                    min_binding_size: None,
6326                },
6327                count: None,
6328            },
6329            // The similarity transform rides a dynamic offset so
6330            // retained draws sharing one captured batch can each
6331            // apply their own transform; ordinary batches pass
6332            // offset 0 into the identity buffer.
6333            wgpu::BindGroupLayoutEntry {
6334                binding: 2,
6335                visibility: wgpu::ShaderStages::VERTEX,
6336                ty: wgpu::BindingType::Buffer {
6337                    ty: wgpu::BufferBindingType::Uniform,
6338                    has_dynamic_offset: true,
6339                    min_binding_size: wgpu::BufferSize::new(
6340                        std::mem::size_of::<SimilarityTransform>() as u64,
6341                    ),
6342                },
6343                count: None,
6344            },
6345        ];
6346        // Retained-slot paint colors, read by the vertex stage under
6347        // `paint_select` (see `shape_shader_source`). Storage mode only:
6348        // the uniform-variant shader never declares the array, and
6349        // uniform-mode devices never host retained slots, so their layout
6350        // stays exactly the three-entry one the uniform pipeline expects.
6351        if shape_batch_limits.storage {
6352            shape_bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry {
6353                binding: 3,
6354                visibility: wgpu::ShaderStages::VERTEX,
6355                ty: wgpu::BindingType::Buffer {
6356                    ty: wgpu::BufferBindingType::Storage { read_only: true },
6357                    has_dynamic_offset: false,
6358                    min_binding_size: None,
6359                },
6360                count: None,
6361            });
6362        }
6363        let shape_bind_group_layout =
6364            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6365                label: Some("Shape Bind Group Layout"),
6366                entries: &shape_bind_group_layout_entries,
6367            });
6368
6369        let identity_similarity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6370            label: Some("Identity Similarity Buffer"),
6371            size: std::mem::size_of::<SimilarityTransform>() as u64,
6372            usage: wgpu::BufferUsages::UNIFORM,
6373            mapped_at_creation: true,
6374        });
6375        identity_similarity_buffer
6376            .slice(..)
6377            .get_mapped_range_mut()
6378            .copy_from_slice(bytemuck::bytes_of(&SimilarityTransform::IDENTITY));
6379        identity_similarity_buffer.unmap();
6380
6381        // Fresh-batch bind groups need a resource at the paint binding even
6382        // though their draws leave `paint_select` at 0.0 and never use the
6383        // value; one minimal buffer (a single never-read vec4) serves every
6384        // batch. Uniform-mode layouts have no paint entry, so none exists.
6385        let dummy_paint_buffer = shape_batch_limits.storage.then(|| {
6386            device.create_buffer(&wgpu::BufferDescriptor {
6387                label: Some("Dummy Paint Buffer"),
6388                size: std::mem::size_of::<[f32; 4]>() as u64,
6389                usage: wgpu::BufferUsages::STORAGE,
6390                mapped_at_creation: false,
6391            })
6392        });
6393        #[cfg(not(target_arch = "wasm32"))]
6394        let replay_slot_store = ReplaySlotStore::new(&device);
6395
6396        let pipeline = PassPipeline::new("shape/src-over", "shape/src-over-depth");
6397        let pipeline_dst_out = PassPipeline::new("shape/dst-out", "shape/dst-out-depth");
6398        let pipeline_solid =
6399            PassPipeline::new("shape/solid-src-over", "shape/solid-src-over-depth");
6400        #[cfg(not(target_arch = "wasm32"))]
6401        let mesh_pipeline = PassPipeline::new("shape/mesh", "shape/mesh-depth");
6402        // The instanced-quad selection is LATCHED here, once per renderer:
6403        // cached retained bundles encode whichever pipelines this resolves
6404        // to, so a per-draw env read could let a bundle replay a selection
6405        // the direct path no longer makes. Storage mode only — the
6406        // uniform/WebGL path keeps `vs_main` and its plain draws untouched.
6407        #[cfg(not(target_arch = "wasm32"))]
6408        let instanced_quads =
6409            (shape_batch_limits.storage && instanced_quads_enabled()).then(|| {
6410                let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6411                    label: Some("Instanced Quad Index Buffer"),
6412                    size: std::mem::size_of_val(&INSTANCED_QUAD_INDICES) as u64,
6413                    usage: wgpu::BufferUsages::INDEX,
6414                    mapped_at_creation: true,
6415                });
6416                index_buffer
6417                    .slice(..)
6418                    .get_mapped_range_mut()
6419                    .copy_from_slice(bytemuck::cast_slice(&INSTANCED_QUAD_INDICES));
6420                index_buffer.unmap();
6421                InstancedQuadPipelines {
6422                    pipeline: PassPipeline::new(
6423                        "shape/instanced-src-over",
6424                        "shape/instanced-src-over-depth",
6425                    ),
6426                    pipeline_dst_out: PassPipeline::new(
6427                        "shape/instanced-dst-out",
6428                        "shape/instanced-dst-out-depth",
6429                    ),
6430                    pipeline_solid: PassPipeline::new(
6431                        "shape/instanced-solid",
6432                        "shape/instanced-solid-depth",
6433                    ),
6434                    index_buffer,
6435                }
6436            });
6437
6438        let image_bind_group_layout =
6439            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6440                label: Some("Image Texture Bind Group Layout"),
6441                entries: &[
6442                    wgpu::BindGroupLayoutEntry {
6443                        binding: 0,
6444                        visibility: wgpu::ShaderStages::FRAGMENT,
6445                        ty: wgpu::BindingType::Texture {
6446                            multisampled: false,
6447                            view_dimension: wgpu::TextureViewDimension::D2,
6448                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
6449                        },
6450                        count: None,
6451                    },
6452                    wgpu::BindGroupLayoutEntry {
6453                        binding: 1,
6454                        visibility: wgpu::ShaderStages::FRAGMENT,
6455                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
6456                        count: None,
6457                    },
6458                ],
6459            });
6460
6461        let image_pipeline = PassPipeline::new("image/src-over", "image/src-over-depth");
6462        let image_pipeline_dst_out = PassPipeline::new("image/dst-out", "image/dst-out-depth");
6463        let glyph_atlas_pipeline = PassPipeline::new("glyph/shared", "glyph/shared-depth");
6464        #[cfg(not(target_arch = "wasm32"))]
6465        let retained_glyph_atlas_pipeline =
6466            PassPipeline::new("glyph/retained", "glyph/retained-depth");
6467
6468        #[cfg(not(target_arch = "wasm32"))]
6469        let upload_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6470            label: Some("Frame Upload Buffer"),
6471            size: INITIAL_UPLOAD_BUFFER_BYTES,
6472            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
6473            mapped_at_creation: false,
6474        });
6475
6476        #[cfg(not(target_arch = "wasm32"))]
6477        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6478            label: Some("Uniform Buffer"),
6479            size: std::mem::size_of::<Uniforms>() as u64,
6480            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
6481            mapped_at_creation: false,
6482        });
6483
6484        #[cfg(not(target_arch = "wasm32"))]
6485        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
6486            label: Some("Uniform Bind Group"),
6487            layout: &uniform_bind_group_layout,
6488            entries: &[wgpu::BindGroupEntry {
6489                binding: 0,
6490                resource: uniform_buffer.as_entire_binding(),
6491            }],
6492        });
6493
6494        #[cfg(not(target_arch = "wasm32"))]
6495        let shape_buffers = ShapeBatchBuffers::new(
6496            &device,
6497            &shape_bind_group_layout,
6498            &identity_similarity_buffer,
6499            dummy_paint_buffer.as_ref(),
6500            shape_batch_limits,
6501        );
6502
6503        let image_nearest_sampler =
6504            device.create_sampler(&image_sampler_descriptor(ImageSampling::Nearest));
6505        let image_linear_sampler =
6506            device.create_sampler(&image_sampler_descriptor(ImageSampling::Linear));
6507        let text_glyph_atlas = TextGlyphAtlas::new(
6508            &device,
6509            &image_bind_group_layout,
6510            &image_nearest_sampler,
6511            TEXT_GLYPH_ATLAS_MIN_SIZE,
6512        );
6513
6514        #[cfg(not(target_arch = "wasm32"))]
6515        let image_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6516            label: Some("Image Vertex Buffer"),
6517            size: (std::mem::size_of::<Vertex>() * 4) as u64,
6518            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
6519            mapped_at_creation: false,
6520        });
6521
6522        #[cfg(not(target_arch = "wasm32"))]
6523        let image_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6524            label: Some("Image Index Buffer"),
6525            size: (std::mem::size_of::<u32>() * 6) as u64,
6526            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
6527            mapped_at_creation: false,
6528        });
6529        #[cfg(not(target_arch = "wasm32"))]
6530        let retained_glyph_uniform_stride = align_usize_to(
6531            std::mem::size_of::<Uniforms>(),
6532            (device.limits().min_uniform_buffer_offset_alignment as usize)
6533                .max(wgpu::COPY_BUFFER_ALIGNMENT as usize),
6534        ) as u64;
6535        #[cfg(not(target_arch = "wasm32"))]
6536        let retained_glyph_uniform_capacity = INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS;
6537        #[cfg(not(target_arch = "wasm32"))]
6538        let retained_glyph_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6539            label: Some("Retained Glyph Uniform Buffer"),
6540            size: retained_glyph_uniform_stride * retained_glyph_uniform_capacity as u64,
6541            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
6542            mapped_at_creation: false,
6543        });
6544        #[cfg(not(target_arch = "wasm32"))]
6545        let retained_glyph_uniform_bind_group =
6546            device.create_bind_group(&wgpu::BindGroupDescriptor {
6547                label: Some("Retained Glyph Uniform Bind Group"),
6548                layout: &retained_glyph_uniform_bind_group_layout,
6549                entries: &[wgpu::BindGroupEntry {
6550                    binding: 0,
6551                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
6552                        buffer: &retained_glyph_uniform_buffer,
6553                        offset: 0,
6554                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
6555                    }),
6556                }],
6557            });
6558
6559        // The cache handle costs nothing to create and pays on every device:
6560        // in-process, the shape family's permutations share most of their
6561        // compiled code; across launches, the persisted blob turns first-use
6562        // compiles (2.0 s of render thread inside the first six seconds on a
6563        // Pixel Watch 3) into cache hits. `None` where the device lacks the
6564        // feature — every creation site then behaves exactly as before.
6565        #[cfg(not(target_arch = "wasm32"))]
6566        let pipeline_cache = crate::pipeline_disk_cache::load(&device);
6567        #[cfg(target_arch = "wasm32")]
6568        let pipeline_cache: Option<wgpu::PipelineCache> = None;
6569        #[cfg(not(target_arch = "wasm32"))]
6570        if let Some(cache) = pipeline_cache.clone() {
6571            crate::pipeline_disk_cache::spawn_persist_schedule(cache);
6572            spawn_pipeline_prewarm(PipelinePrewarmInputs {
6573                device: Arc::clone(&device),
6574                cache: pipeline_cache.clone(),
6575                surface_format,
6576                uniform_layout: uniform_bind_group_layout.clone(),
6577                shape_layout: shape_bind_group_layout.clone(),
6578                image_layout: image_bind_group_layout.clone(),
6579                batch_limits: shape_batch_limits,
6580                instanced: instanced_quads.is_some(),
6581            });
6582        }
6583
6584        let effects_started = Instant::now();
6585        let effect_renderer = EffectRenderer::new(
6586            &device,
6587            pipeline_cache.clone(),
6588            surface_format,
6589            adapter_backend,
6590        );
6591        let effects_ms = instant_ms(effects_started, Instant::now());
6592
6593        let renderer = Self {
6594            device,
6595            queue,
6596            device_errors,
6597            renderer_epoch,
6598            #[cfg(not(target_arch = "wasm32"))]
6599            store_feed_generation,
6600            surface_format,
6601            adapter_backend,
6602            shape_batch_limits,
6603            pipeline_cache,
6604            pipeline,
6605            pipeline_dst_out,
6606            pipeline_solid,
6607            #[cfg(not(target_arch = "wasm32"))]
6608            mesh_pipeline,
6609            #[cfg(not(target_arch = "wasm32"))]
6610            instanced_quads,
6611            uniform_bind_group_layout,
6612            shape_bind_group_layout,
6613            dummy_paint_buffer,
6614            identity_similarity_buffer,
6615            #[cfg(not(target_arch = "wasm32"))]
6616            replay_slots: replay_slot_store,
6617            image_pipeline,
6618            image_pipeline_dst_out,
6619            glyph_atlas_pipeline,
6620            #[cfg(not(target_arch = "wasm32"))]
6621            retained_glyph_atlas_pipeline,
6622            image_bind_group_layout,
6623            #[cfg(not(target_arch = "wasm32"))]
6624            retained_glyph_uniform_bind_group_layout,
6625            image_nearest_sampler,
6626            image_linear_sampler,
6627            text_fonts,
6628            #[cfg(not(target_arch = "wasm32"))]
6629            upload_buffer,
6630            #[cfg(not(target_arch = "wasm32"))]
6631            uniform_buffer,
6632            #[cfg(not(target_arch = "wasm32"))]
6633            uniform_bind_group,
6634            #[cfg(not(target_arch = "wasm32"))]
6635            shape_buffers,
6636            #[cfg(not(target_arch = "wasm32"))]
6637            image_vertex_buffer,
6638            #[cfg(not(target_arch = "wasm32"))]
6639            image_index_buffer,
6640            #[cfg(not(target_arch = "wasm32"))]
6641            retained_glyph_uniform_buffer,
6642            #[cfg(not(target_arch = "wasm32"))]
6643            retained_glyph_uniform_bind_group,
6644            #[cfg(not(target_arch = "wasm32"))]
6645            retained_glyph_uniform_stride,
6646            #[cfg(not(target_arch = "wasm32"))]
6647            retained_glyph_uniform_capacity,
6648            #[cfg(not(target_arch = "wasm32"))]
6649            retained_glyph_uniform_cursor: 0,
6650            #[cfg(target_arch = "wasm32")]
6651            wasm_uniform_batches: Vec::new(),
6652            #[cfg(target_arch = "wasm32")]
6653            wasm_uniform_batch_cursor: 0,
6654            #[cfg(target_arch = "wasm32")]
6655            wasm_shape_batches: Vec::new(),
6656            #[cfg(target_arch = "wasm32")]
6657            wasm_shape_batch_cursor: 0,
6658            #[cfg(target_arch = "wasm32")]
6659            wasm_image_batches: Vec::new(),
6660            #[cfg(target_arch = "wasm32")]
6661            wasm_image_batch_cursor: 0,
6662            image_texture_cache: BoundedLruCache::with_capacity_at_least_one(
6663                MAX_TEXTURE_CACHE_ITEMS,
6664            ),
6665            image_texture_cache_bytes: 0,
6666            text_image_cache: BoundedLruCache::with_capacity_at_least_one(
6667                MAX_TEXT_IMAGE_CACHE_ITEMS,
6668            ),
6669            text_glyph_atlas,
6670            text_glyph_run_cache: BoundedLruCache::with_capacity_at_least_one(
6671                MAX_TEXT_GLYPH_RUN_CACHE_ITEMS,
6672            ),
6673            #[cfg(not(target_arch = "wasm32"))]
6674            text_glyph_gpu_run_cache: BoundedLruCache::with_capacity_at_least_one(
6675                MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS,
6676            ),
6677            text_glyph_mask_cache: SoftwareGlyphRasterCache::with_capacity_at_least_one(
6678                MAX_TEXT_GLYPH_MASK_CACHE_ITEMS,
6679            ),
6680            text_line_index_cache: TextLineIndexCache::new(MAX_TEXT_LINE_INDEX_CACHE_ITEMS),
6681            scratch_shape_data: Vec::new(),
6682            scratch_gradients: Vec::new(),
6683            scratch_image_vertices: Vec::new(),
6684            scratch_image_indices: Vec::new(),
6685            scratch_image_cmds: Vec::new(),
6686            scratch_glyph_cmds: Vec::new(),
6687            scratch_text_glyph_run: Vec::new(),
6688            scratch_text_glyph_placements: Vec::new(),
6689            scratch_text_glyph_quads: Vec::new(),
6690            scratch_segment_items: Vec::new(),
6691            scratch_effect_ranges: Vec::new(),
6692            scratch_layer_events: Vec::new(),
6693            staged_uploads: StagedBufferUploads::default(),
6694            frame_graph_executor: WgpuFrameGraphExecutor::new(),
6695            deferred_offscreen_releases: Vec::new(),
6696            effect_renderer,
6697            layer_surface_cache: LayerSurfaceCache::new(),
6698            observed_scene_range_cache_misses: BoundedLruCache::with_capacity_at_least_one(
6699                MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES,
6700            ),
6701            shadow_surface_cache: BoundedLruCache::with_capacity_at_least_one(
6702                MAX_SHADOW_SURFACE_CACHE_ITEMS,
6703            ),
6704            shadow_surface_cache_bytes: 0,
6705            frame_stats: gpu_stats::FrameStats::default(),
6706            last_frame_stats: None,
6707            pending_frame_warmup_frames: 0,
6708            frame_count: 0,
6709            gpu_stats_enabled: gpu_stats_enabled(),
6710            warning_state: RendererWarningState::default(),
6711            #[cfg(not(target_arch = "wasm32"))]
6712            replay_upload_stats: ReplayUploadStats::default(),
6713            #[cfg(not(target_arch = "wasm32"))]
6714            segment_encode_stats: SegmentEncodeStats::default(),
6715            #[cfg(not(target_arch = "wasm32"))]
6716            replay_color_patches: Vec::new(),
6717            #[cfg(not(target_arch = "wasm32"))]
6718            color_patch_scratch: Vec::new(),
6719            #[cfg(not(target_arch = "wasm32"))]
6720            replay_capture_shape_scratch: Vec::new(),
6721            #[cfg(not(target_arch = "wasm32"))]
6722            replay_capture_gradient_scratch: Vec::new(),
6723            #[cfg(not(target_arch = "wasm32"))]
6724            replay_ack_confirmations: Vec::new(),
6725            #[cfg(not(target_arch = "wasm32"))]
6726            replay_generation_drops: 0,
6727            #[cfg(not(target_arch = "wasm32"))]
6728            retained_bundle_cache: RetainedBundleCache::new(),
6729            #[cfg(not(target_arch = "wasm32"))]
6730            rim_mesh_vertices: Vec::new(),
6731            #[cfg(not(target_arch = "wasm32"))]
6732            rim_mesh_indices: Vec::new(),
6733            #[cfg(not(target_arch = "wasm32"))]
6734            rim_mesh_vertex_buffer: None,
6735            #[cfg(not(target_arch = "wasm32"))]
6736            rim_mesh_index_buffer: None,
6737            #[cfg(not(target_arch = "wasm32"))]
6738            rim_mesh_uploaded_vertices: 0,
6739            #[cfg(not(target_arch = "wasm32"))]
6740            rim_mesh_uploaded_indices: 0,
6741            #[cfg(not(target_arch = "wasm32"))]
6742            rim_meshes_emitted: 0,
6743            #[cfg(not(target_arch = "wasm32"))]
6744            fill_area_diag: FillAreaDiag::default(),
6745            #[cfg(not(target_arch = "wasm32"))]
6746            static_span: StaticSpanCache::default(),
6747            #[cfg(not(target_arch = "wasm32"))]
6748            segment_surfaces: SegmentSurfaceCache::default(),
6749            #[cfg(not(target_arch = "wasm32"))]
6750            display_clip: DisplayClipState::new(),
6751        };
6752        log::info!(
6753            "[gpu-init] {:?} renderer ready in {:.1} ms (effects {:.1} ms); \
6754             pipelines build on first use",
6755            adapter_backend,
6756            instant_ms(construction_started, Instant::now()),
6757            effects_ms,
6758        );
6759        renderer
6760    }
6761
6762    /// The display's visible region (see [`crate::display_clip`]): the
6763    /// part of the full-screen surface the panel physically shows. Only
6764    /// the platform layer (or a host standing in for it) sets this —
6765    /// never app content. `Full` — the default — keeps the cull machinery
6766    /// structurally inert.
6767    #[cfg(not(target_arch = "wasm32"))]
6768    pub fn set_display_visible_region(&mut self, region: DisplayVisibleRegion) {
6769        self.display_clip.visible_region = region;
6770    }
6771
6772    /// Whether the pass currently being encoded carries the display-clip
6773    /// depth attachment; pipeline getters consult this to hand out the
6774    /// depth-tested variant.
6775    #[cfg(not(target_arch = "wasm32"))]
6776    fn pass_depth(&self) -> bool {
6777        self.display_clip.pass_depth.get()
6778    }
6779
6780    #[cfg(target_arch = "wasm32")]
6781    fn pass_depth(&self) -> bool {
6782        false
6783    }
6784
6785    /// Decides whether the fused pass about to be encoded is the culled
6786    /// one and returns its depth view: the visible region must leave
6787    /// something to cull, the kill switch must be open, and `target_view`
6788    /// must be THIS frame's root target with the pass viewport covering
6789    /// it whole. Offscreen layer passes — even full-frame-sized ones —
6790    /// never qualify: a layer's content can be transformed into view
6791    /// later.
6792    #[cfg(not(target_arch = "wasm32"))]
6793    fn display_clip_pass_depth_view(
6794        &mut self,
6795        target_view: &wgpu::TextureView,
6796        width: u32,
6797        height: u32,
6798    ) -> Option<wgpu::TextureView> {
6799        if !self.display_clip.visible_region.cullable() {
6800            return None;
6801        }
6802        if self.display_clip.frame_root_view.as_ref() != Some(target_view) {
6803            return None;
6804        }
6805        if !display_clip_cull_enabled() {
6806            return None;
6807        }
6808        self.ensure_display_clip_resources(width, height)
6809    }
6810
6811    /// Returns the depth view for the current (size, region) pair,
6812    /// tessellating the region's complement and building its vertex
6813    /// buffer and the depth attachment on first use. A tessellation that
6814    /// fails its conservative verification pins `None` for the pair: the
6815    /// cull stays off rather than ever touching a visible pixel.
6816    #[cfg(not(target_arch = "wasm32"))]
6817    fn ensure_display_clip_resources(
6818        &mut self,
6819        width: u32,
6820        height: u32,
6821    ) -> Option<wgpu::TextureView> {
6822        let region = self.display_clip.visible_region;
6823        let key = ((width, height), region);
6824        if let Some((cached_key, resources)) = &self.display_clip.resources {
6825            if *cached_key == key {
6826                return resources
6827                    .as_ref()
6828                    .map(|resources| resources.depth_view.clone());
6829            }
6830        }
6831        let built = display_clip::tessellate_complement(region, width, height).map(|mesh| {
6832            let occluder_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
6833                label: Some("Display Clip Occluder Vertices"),
6834                size: std::mem::size_of_val(mesh.vertices.as_slice()) as u64,
6835                usage: wgpu::BufferUsages::VERTEX,
6836                mapped_at_creation: true,
6837            });
6838            occluder_vertex_buffer
6839                .slice(..)
6840                .get_mapped_range_mut()
6841                .copy_from_slice(bytemuck::cast_slice(&mesh.vertices));
6842            occluder_vertex_buffer.unmap();
6843            let depth_texture = self.device.create_texture(&wgpu::TextureDescriptor {
6844                label: Some("Display Clip Depth"),
6845                size: wgpu::Extent3d {
6846                    width,
6847                    height,
6848                    depth_or_array_layers: 1,
6849                },
6850                mip_level_count: 1,
6851                sample_count: 1,
6852                dimension: wgpu::TextureDimension::D2,
6853                format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
6854                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6855                view_formats: &[],
6856            });
6857            // Once per (size, region), which is as rate-limited as it
6858            // gets. The round display — the capability's first provider —
6859            // keeps its own line.
6860            match region {
6861                DisplayVisibleRegion::InscribedCircle => log::info!(
6862                    "[display-clip] round display: corner cull active ({} px masked) at {width}x{height}",
6863                    mesh.masked_px,
6864                ),
6865                _ => log::info!(
6866                    "[display-clip] visible-region cull active for {region:?} ({} px masked) at {width}x{height}",
6867                    mesh.masked_px,
6868                ),
6869            }
6870            DisplayClipResources {
6871                depth_view: depth_texture.create_view(&wgpu::TextureViewDescriptor::default()),
6872                occluder_vertex_buffer,
6873                occluder_vertex_count: mesh.vertices.len() as u32,
6874            }
6875        });
6876        let view = built.as_ref().map(|resources| resources.depth_view.clone());
6877        self.display_clip.resources = Some((key, built));
6878        view
6879    }
6880
6881    /// Encodes the region complement's occluder, the first draw of a
6882    /// culled fused pass: depth write at the near plane over the
6883    /// tessellation, color writes off.
6884    #[cfg(not(target_arch = "wasm32"))]
6885    fn draw_display_clip_occluder(
6886        &self,
6887        render_pass: &mut wgpu::RenderPass<'_>,
6888        width: u32,
6889        height: u32,
6890    ) {
6891        let Some((((size_w, size_h), _), Some(resources))) = &self.display_clip.resources else {
6892            return;
6893        };
6894        debug_assert_eq!((*size_w, *size_h), (width, height));
6895        let pipeline =
6896            self.display_clip
6897                .occluder_pipeline
6898                .get_or_init(self.adapter_backend, || {
6899                    create_display_clip_occluder_pipeline(
6900                        &self.device,
6901                        self.pipeline_cache.as_ref(),
6902                        self.surface_format,
6903                    )
6904                });
6905        render_pass.set_scissor_rect(0, 0, width, height);
6906        render_pass.set_pipeline(pipeline);
6907        render_pass.set_vertex_buffer(0, resources.occluder_vertex_buffer.slice(..));
6908        render_pass.draw(0..resources.occluder_vertex_count, 0..1);
6909        self.frame_stats.add_draw_calls(1);
6910    }
6911
6912    fn shape_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
6913        let resource = match blend_mode {
6914            BlendMode::DstOut => &self.pipeline_dst_out,
6915            _ => &self.pipeline,
6916        };
6917        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6918            create_shape_pipeline(
6919                &self.device,
6920                self.pipeline_cache.as_ref(),
6921                self.surface_format,
6922                &self.uniform_bind_group_layout,
6923                &self.shape_bind_group_layout,
6924                blend_mode,
6925                self.shape_batch_limits,
6926                false,
6927                "vs_main",
6928                "fs_main",
6929                depth,
6930            )
6931        })
6932    }
6933
6934    /// The `fs_solid` twin of [`Self::shape_pipeline`], SrcOver only. Callers
6935    /// pick it exactly when the draw's shapes carry zero gradient stops; the
6936    /// coverage math is byte-identical, the gradient machinery is compiled
6937    /// out of the fragment stage. Under `CRANPOSE_SOLID_TRIM_VARYINGS`
6938    /// (re-read per build, see [`solid_trim_varyings_enabled`]) the build
6939    /// compiles the trimmed-interface entries instead; either variant encodes
6940    /// identically — same layouts, same blend, no vertex buffers — so every
6941    /// caller, retained bundles included, is oblivious to the selection.
6942    fn shape_pipeline_solid(&self) -> &wgpu::RenderPipeline {
6943        self.pipeline_solid
6944            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6945                let solid_trim = solid_trim_varyings_enabled();
6946                let (vertex_entry, fragment_entry) = if solid_trim {
6947                    ("vs_solid", "fs_solid_trim")
6948                } else {
6949                    ("vs_main", "fs_solid")
6950                };
6951                create_shape_pipeline(
6952                    &self.device,
6953                    self.pipeline_cache.as_ref(),
6954                    self.surface_format,
6955                    &self.uniform_bind_group_layout,
6956                    &self.shape_bind_group_layout,
6957                    BlendMode::SrcOver,
6958                    self.shape_batch_limits,
6959                    solid_trim,
6960                    vertex_entry,
6961                    fragment_entry,
6962                    depth,
6963                )
6964            })
6965    }
6966
6967    #[cfg(not(target_arch = "wasm32"))]
6968    fn mesh_pipeline(&self) -> &wgpu::RenderPipeline {
6969        self.mesh_pipeline
6970            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6971                create_mesh_shape_pipeline(
6972                    &self.device,
6973                    self.pipeline_cache.as_ref(),
6974                    self.surface_format,
6975                    &self.uniform_bind_group_layout,
6976                    &self.shape_bind_group_layout,
6977                    self.shape_batch_limits,
6978                    depth,
6979                )
6980            })
6981    }
6982
6983    #[cfg(not(target_arch = "wasm32"))]
6984    fn instanced_pipeline<'a>(
6985        &'a self,
6986        instanced: &'a InstancedQuadPipelines,
6987        blend_mode: BlendMode,
6988    ) -> &'a wgpu::RenderPipeline {
6989        let resource = match blend_mode {
6990            BlendMode::DstOut => &instanced.pipeline_dst_out,
6991            _ => &instanced.pipeline,
6992        };
6993        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6994            create_instanced_shape_pipeline(
6995                &self.device,
6996                self.pipeline_cache.as_ref(),
6997                self.surface_format,
6998                &self.uniform_bind_group_layout,
6999                &self.shape_bind_group_layout,
7000                blend_mode,
7001                self.shape_batch_limits,
7002                false,
7003                "vs_shape_instanced",
7004                "fs_main",
7005                depth,
7006            )
7007        })
7008    }
7009
7010    /// The `fs_solid` twin of [`Self::instanced_pipeline`], SrcOver only.
7011    /// Trims its varyings under `CRANPOSE_SOLID_TRIM_VARYINGS` exactly like
7012    /// [`Self::shape_pipeline_solid`].
7013    #[cfg(not(target_arch = "wasm32"))]
7014    fn instanced_pipeline_solid<'a>(
7015        &'a self,
7016        instanced: &'a InstancedQuadPipelines,
7017    ) -> &'a wgpu::RenderPipeline {
7018        instanced
7019            .pipeline_solid
7020            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7021                let solid_trim = solid_trim_varyings_enabled();
7022                let (vertex_entry, fragment_entry) = if solid_trim {
7023                    ("vs_solid_instanced", "fs_solid_trim")
7024                } else {
7025                    ("vs_shape_instanced", "fs_solid")
7026                };
7027                create_instanced_shape_pipeline(
7028                    &self.device,
7029                    self.pipeline_cache.as_ref(),
7030                    self.surface_format,
7031                    &self.uniform_bind_group_layout,
7032                    &self.shape_bind_group_layout,
7033                    BlendMode::SrcOver,
7034                    self.shape_batch_limits,
7035                    solid_trim,
7036                    vertex_entry,
7037                    fragment_entry,
7038                    depth,
7039                )
7040            })
7041    }
7042
7043    fn image_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
7044        let resource = match blend_mode {
7045            BlendMode::DstOut => &self.image_pipeline_dst_out,
7046            _ => &self.image_pipeline,
7047        };
7048        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7049            create_image_pipeline(
7050                &self.device,
7051                self.pipeline_cache.as_ref(),
7052                self.surface_format,
7053                &self.uniform_bind_group_layout,
7054                &self.image_bind_group_layout,
7055                blend_mode,
7056                depth,
7057            )
7058        })
7059    }
7060
7061    fn glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
7062        self.glyph_atlas_pipeline
7063            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7064                create_glyph_atlas_pipeline(
7065                    &self.device,
7066                    self.pipeline_cache.as_ref(),
7067                    self.surface_format,
7068                    &self.uniform_bind_group_layout,
7069                    &self.image_bind_group_layout,
7070                    depth,
7071                )
7072            })
7073    }
7074
7075    #[cfg(not(target_arch = "wasm32"))]
7076    fn retained_glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
7077        self.retained_glyph_atlas_pipeline.get_or_init(
7078            self.adapter_backend,
7079            self.pass_depth(),
7080            |depth| {
7081                create_glyph_atlas_pipeline(
7082                    &self.device,
7083                    self.pipeline_cache.as_ref(),
7084                    self.surface_format,
7085                    &self.retained_glyph_uniform_bind_group_layout,
7086                    &self.image_bind_group_layout,
7087                    depth,
7088                )
7089            },
7090        )
7091    }
7092
7093    fn ensure_image_cached(&mut self, image: &ImageBitmap) -> Result<(), String> {
7094        if self.image_texture_cache.get(&image.id()).is_some() {
7095            return Ok(());
7096        }
7097
7098        let size = wgpu::Extent3d {
7099            width: image.width(),
7100            height: image.height(),
7101            depth_or_array_layers: 1,
7102        };
7103
7104        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
7105            label: Some("Image Texture"),
7106            size,
7107            mip_level_count: 1,
7108            sample_count: 1,
7109            dimension: wgpu::TextureDimension::D2,
7110            format: wgpu::TextureFormat::Rgba8Unorm,
7111            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7112            view_formats: &[],
7113        });
7114
7115        let upload_stats = self.frame_graph_executor.upload_texture(
7116            &self.queue,
7117            wgpu::TexelCopyTextureInfo {
7118                texture: &texture,
7119                mip_level: 0,
7120                origin: wgpu::Origin3d::ZERO,
7121                aspect: wgpu::TextureAspect::All,
7122            },
7123            image.pixels(),
7124            wgpu::TexelCopyBufferLayout {
7125                offset: 0,
7126                bytes_per_row: Some(4 * image.width()),
7127                rows_per_image: Some(image.height()),
7128            },
7129            size,
7130        );
7131        self.frame_stats.record_command_stats(upload_stats);
7132
7133        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
7134        let nearest_bind_group = self.image_bind_group(&view, &self.image_nearest_sampler);
7135        let linear_bind_group = self.image_bind_group(&view, &self.image_linear_sampler);
7136
7137        let bytes = image.width() as usize * image.height() as usize * 4;
7138        if let Some(replaced) = self.image_texture_cache.put(
7139            image.id(),
7140            CachedImageTexture {
7141                _texture: texture,
7142                _view: view,
7143                nearest_bind_group,
7144                linear_bind_group,
7145                bytes,
7146            },
7147        ) {
7148            self.image_texture_cache_bytes = self
7149                .image_texture_cache_bytes
7150                .saturating_sub(replaced.bytes);
7151        }
7152        self.image_texture_cache_bytes += bytes;
7153        // Byte-bounded eviction on top of the count bound: never evict the
7154        // entry just inserted (this frame draws it).
7155        while self.image_texture_cache_bytes > MAX_IMAGE_TEXTURE_CACHE_BYTES
7156            && self.image_texture_cache.len() > 1
7157        {
7158            let Some((_, evicted)) = self.image_texture_cache.pop_lru() else {
7159                break;
7160            };
7161            self.image_texture_cache_bytes =
7162                self.image_texture_cache_bytes.saturating_sub(evicted.bytes);
7163        }
7164        Ok(())
7165    }
7166
7167    fn image_bind_group(
7168        &self,
7169        view: &wgpu::TextureView,
7170        sampler: &wgpu::Sampler,
7171    ) -> wgpu::BindGroup {
7172        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
7173            label: Some("Image Texture Bind Group"),
7174            layout: &self.image_bind_group_layout,
7175            entries: &[
7176                wgpu::BindGroupEntry {
7177                    binding: 0,
7178                    resource: wgpu::BindingResource::TextureView(view),
7179                },
7180                wgpu::BindGroupEntry {
7181                    binding: 1,
7182                    resource: wgpu::BindingResource::Sampler(sampler),
7183                },
7184            ],
7185        })
7186    }
7187
7188    /// Acquire an offscreen target from the pool with stats tracking.
7189    /// Uses split borrows to avoid conflicting borrows on self.
7190    fn max_texture_dim(&self) -> u32 {
7191        self.effect_renderer.max_texture_dim()
7192    }
7193
7194    fn acquire_offscreen(&mut self, width: u32, height: u32) -> OffscreenTarget {
7195        self.effect_renderer
7196            .acquire_offscreen(&self.device, width, height, Some(&self.frame_stats))
7197    }
7198
7199    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
7200        self.acquire_offscreen(width, height)
7201    }
7202
7203    fn transient_offscreen_descriptor(
7204        &self,
7205        label: &'static str,
7206        width: u32,
7207        height: u32,
7208    ) -> FrameTextureDescriptor {
7209        let max_texture_dim = self.max_texture_dim();
7210        FrameTextureDescriptor::render_attachment(
7211            label,
7212            width.min(max_texture_dim),
7213            height.min(max_texture_dim),
7214            self.surface_format,
7215        )
7216    }
7217
7218    fn defer_offscreen_release(&mut self, target: OffscreenTarget) {
7219        self.deferred_offscreen_releases.push(target);
7220    }
7221
7222    fn flush_deferred_offscreen_releases(&mut self) {
7223        for target in self.deferred_offscreen_releases.drain(..) {
7224            self.effect_renderer.release_offscreen(target);
7225        }
7226    }
7227
7228    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
7229        if let LayerSurfaceTexture::Owned(target) = target {
7230            self.defer_offscreen_release(target);
7231        }
7232    }
7233
7234    fn cached_layer_surface(
7235        &mut self,
7236        key: &LayerRasterCacheKey,
7237    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
7238        self.layer_surface_cache.get(key, &self.frame_stats)
7239    }
7240
7241    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
7242        admit_layer_surface_cache_miss_impl(key, &mut self.observed_scene_range_cache_misses)
7243    }
7244
7245    fn insert_cached_layer_surface(
7246        &mut self,
7247        key: LayerRasterCacheKey,
7248        target: OffscreenTarget,
7249        logical_rect: Rect,
7250    ) -> Rc<OffscreenTarget> {
7251        self.layer_surface_cache
7252            .insert(key, target, logical_rect, &self.frame_stats)
7253    }
7254
7255    fn cached_shadow_surface(
7256        &mut self,
7257        key: &ShadowSurfaceCacheKey,
7258    ) -> Option<Rc<OffscreenTarget>> {
7259        self.shadow_surface_cache
7260            .get(key)
7261            .map(|cached| cached.target.clone())
7262    }
7263
7264    fn cached_shape_shadow_composite(
7265        &mut self,
7266        shadow: &ShadowDraw,
7267        width: u32,
7268        height: u32,
7269        root_scale: f32,
7270    ) -> Option<CachedShadowComposite> {
7271        if shadow.blur_radius <= 0.0 || shadow.shapes.is_empty() || !shadow.texts.is_empty() {
7272            return None;
7273        }
7274
7275        let plan = shape_shadow_surface_plan(
7276            &shadow.shapes,
7277            shadow.clip,
7278            shadow.blur_radius,
7279            width,
7280            height,
7281            root_scale,
7282            self.max_texture_dim(),
7283        )?;
7284        let key = shape_shadow_surface_cache_key(
7285            &shadow.shapes,
7286            &shadow.brushes,
7287            plan.source_device_bounds,
7288            plan.pixel_radius,
7289            root_scale,
7290        )?;
7291        let cached = self.cached_shadow_surface(&key)?;
7292        let viewport_offset = [plan.source_device_bounds.x, plan.source_device_bounds.y];
7293        self.frame_stats.record_shadow_shape_cache_hit(
7294            plan.source_device_bounds.width,
7295            plan.source_device_bounds.height,
7296        );
7297
7298        let clip_scissor = shadow
7299            .clip
7300            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
7301        let scissor = clip_scissor.or(plan.processing_scissor);
7302        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
7303            mask.rect[0] -= viewport_offset[0];
7304            mask.rect[1] -= viewport_offset[1];
7305            mask
7306        });
7307        let dest_viewport = Some((
7308            viewport_offset[0],
7309            viewport_offset[1],
7310            plan.source_device_bounds.width as f32,
7311            plan.source_device_bounds.height as f32,
7312        ));
7313
7314        Some(CachedShadowComposite {
7315            source: cached,
7316            scissor,
7317            rounded_mask,
7318            dest_viewport,
7319        })
7320    }
7321
7322    fn insert_cached_shadow_surface(
7323        &mut self,
7324        key: ShadowSurfaceCacheKey,
7325        target: OffscreenTarget,
7326    ) {
7327        let byte_size = offscreen_byte_size(target.width, target.height);
7328        while self.shadow_surface_cache_bytes + byte_size > MAX_SHADOW_SURFACE_CACHE_BYTES {
7329            let Some((_evicted_key, evicted_entry)) = self.shadow_surface_cache.pop_lru() else {
7330                break;
7331            };
7332            self.shadow_surface_cache_bytes = self
7333                .shadow_surface_cache_bytes
7334                .saturating_sub(evicted_entry.byte_size);
7335        }
7336
7337        let cached = CachedShadowSurface {
7338            target: Rc::new(target),
7339            byte_size,
7340        };
7341        if let Some((_replaced_key, replaced_entry)) = self.shadow_surface_cache.push(key, cached) {
7342            self.shadow_surface_cache_bytes = self
7343                .shadow_surface_cache_bytes
7344                .saturating_sub(replaced_entry.byte_size);
7345        }
7346        self.shadow_surface_cache_bytes = self.shadow_surface_cache_bytes.saturating_add(byte_size);
7347    }
7348
7349    fn supports_render_effect(&self, effect: &RenderEffect) -> bool {
7350        is_render_effect_supported(effect)
7351    }
7352}
7353
7354struct RecordingSurfaceBackend<'renderer, 'recorder, C: FrameCommandRecorder> {
7355    renderer: &'renderer mut GpuRenderer,
7356    recorder: &'recorder mut C,
7357}
7358
7359impl<C: FrameCommandRecorder> RecordingSurfaceBackend<'_, '_, C> {
7360    #[allow(clippy::too_many_arguments)]
7361    fn render_range_with_layer_events_to_target_recorded(
7362        &mut self,
7363        target: &OffscreenTarget,
7364        shapes: &[DrawShape],
7365        brushes: &[Brush],
7366        images: &[ImageDraw],
7367        texts: &[TextDraw],
7368        shadow_draws: &[ShadowDraw],
7369        draw_ops: &[DrawOp],
7370        effect_layers: &[EffectLayer],
7371        backdrop_layers: &[BackdropLayer],
7372        z_start: usize,
7373        z_end: usize,
7374        excluded_effect_layer: Option<usize>,
7375        width: u32,
7376        height: u32,
7377        root_scale: f32,
7378        backdrop_underlay: Option<&OffscreenTarget>,
7379        initial_load_op: wgpu::LoadOp<wgpu::Color>,
7380    ) -> Result<(), String> {
7381        if z_start >= z_end {
7382            if matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
7383                self.clear_target_view_with_load_op(&target.view, initial_load_op);
7384            }
7385            return Ok(());
7386        }
7387
7388        let mut effect_z_ranges = std::mem::take(&mut self.renderer.scratch_effect_ranges);
7389        collect_effect_ranges(
7390            effect_layers,
7391            z_start,
7392            z_end,
7393            excluded_effect_layer,
7394            &mut effect_z_ranges,
7395        );
7396        let mut events = std::mem::take(&mut self.renderer.scratch_layer_events);
7397        collect_layer_events(
7398            effect_layers,
7399            backdrop_layers,
7400            z_start,
7401            z_end,
7402            excluded_effect_layer,
7403            &mut events,
7404        );
7405
7406        let result = (|| -> Result<(), String> {
7407            let mut next_load_op = initial_load_op;
7408            let mut cursor_z = z_start;
7409            for event in &events {
7410                if event.z_index > cursor_z {
7411                    self.render_non_effect_segment(
7412                        &target.view,
7413                        shapes,
7414                        brushes,
7415                        images,
7416                        texts,
7417                        shadow_draws,
7418                        // Windowed scenes never carry retained draws — see
7419                        // `build_scene_window`.
7420                        &[],
7421                        draw_ops,
7422                        cursor_z,
7423                        event.z_index,
7424                        &effect_z_ranges,
7425                        width,
7426                        height,
7427                        root_scale,
7428                        next_load_op,
7429                    )?;
7430                    next_load_op = wgpu::LoadOp::Load;
7431                    cursor_z = event.z_index;
7432                } else if event.z_index < cursor_z {
7433                    continue;
7434                }
7435
7436                if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
7437                    self.clear_target_view_with_load_op(&target.view, next_load_op);
7438                    next_load_op = wgpu::LoadOp::Load;
7439                }
7440
7441                match event.kind {
7442                    LayerEventKind::Backdrop(index) => {
7443                        let layer = &backdrop_layers[index];
7444                        let effective_backdrop_underlay = if backdrop_underlay.is_some()
7445                            && backdrop_underlay_is_covered_by_local_content(
7446                                shapes,
7447                                brushes,
7448                                images,
7449                                shadow_draws,
7450                                draw_ops,
7451                                effect_layers,
7452                                backdrop_layers,
7453                                layer,
7454                            ) {
7455                            None
7456                        } else {
7457                            backdrop_underlay
7458                        };
7459                        execute_apply_backdrop_layer_to_target(
7460                            self,
7461                            target,
7462                            layer,
7463                            effective_backdrop_underlay,
7464                            width,
7465                            height,
7466                            root_scale,
7467                            None,
7468                        )?;
7469                    }
7470                    LayerEventKind::Effect(index) => {
7471                        let layer = &effect_layers[index];
7472                        if layer.z_start < cursor_z {
7473                            continue;
7474                        }
7475                        execute_render_effect_layer_to_target(
7476                            self,
7477                            target,
7478                            shapes,
7479                            brushes,
7480                            images,
7481                            texts,
7482                            shadow_draws,
7483                            draw_ops,
7484                            effect_layers,
7485                            backdrop_layers,
7486                            index,
7487                            backdrop_underlay,
7488                            width,
7489                            height,
7490                            root_scale,
7491                        )?;
7492                        cursor_z = cursor_z.max(layer.z_end);
7493                    }
7494                }
7495            }
7496
7497            if cursor_z < z_end {
7498                self.render_non_effect_segment(
7499                    &target.view,
7500                    shapes,
7501                    brushes,
7502                    images,
7503                    texts,
7504                    shadow_draws,
7505                    &[],
7506                    draw_ops,
7507                    cursor_z,
7508                    z_end,
7509                    &effect_z_ranges,
7510                    width,
7511                    height,
7512                    root_scale,
7513                    next_load_op,
7514                )?;
7515            } else if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
7516                self.clear_target_view_with_load_op(&target.view, next_load_op);
7517            }
7518
7519            Ok(())
7520        })();
7521
7522        self.renderer.scratch_effect_ranges = effect_z_ranges;
7523        self.renderer.scratch_layer_events = events;
7524        result
7525    }
7526
7527    #[allow(clippy::too_many_arguments)]
7528    fn record_shader_composite(
7529        &mut self,
7530        source: &OffscreenTarget,
7531        shader: &RuntimeShader,
7532        effect_rect: [f32; 4],
7533        dest_view: &wgpu::TextureView,
7534        alpha: f32,
7535        load_op: wgpu::LoadOp<wgpu::Color>,
7536        scissor: Option<(u32, u32, u32, u32)>,
7537        blend_mode: BlendMode,
7538        dest_viewport: Option<(f32, f32, f32, f32)>,
7539        sample_mode: CompositeSampleMode,
7540    ) {
7541        let device = self.renderer.device.clone();
7542        if let Some(viewport) = direct_shader_composite_viewport(
7543            alpha,
7544            blend_mode,
7545            dest_viewport,
7546            sample_mode,
7547            (source.width, source.height),
7548        ) {
7549            let shader_applied = self
7550                .renderer
7551                .effect_renderer
7552                .encode_shader_src_over_to_view(
7553                    self.recorder,
7554                    &device,
7555                    source,
7556                    dest_view,
7557                    shader,
7558                    effect_rect,
7559                    load_op,
7560                    scissor,
7561                    viewport,
7562                );
7563            if shader_applied {
7564                self.renderer
7565                    .effect_renderer
7566                    .debug_effects
7567                    .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7568                self.recorder.record_pass();
7569                self.renderer.effect_renderer.record_composite_pass();
7570                return;
7571            }
7572        }
7573        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7574            "Shader Effect Composite Scratch",
7575            source.width,
7576            source.height,
7577        );
7578        let scratch = self
7579            .recorder
7580            .acquire_transient_offscreen(&device, scratch_descriptor);
7581        let shader_applied = {
7582            self.renderer.effect_renderer.encode_shader(
7583                self.recorder,
7584                &device,
7585                source,
7586                &scratch.view,
7587                shader,
7588                effect_rect,
7589            )
7590        };
7591        let composite_source = if shader_applied {
7592            self.renderer
7593                .effect_renderer
7594                .debug_effects
7595                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7596            self.recorder.record_pass();
7597            &scratch
7598        } else {
7599            source
7600        };
7601        {
7602            self.renderer
7603                .effect_renderer
7604                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
7605                    self.recorder,
7606                    &device,
7607                    composite_source,
7608                    dest_view,
7609                    alpha,
7610                    load_op,
7611                    scissor,
7612                    None,
7613                    supported_blend_mode(blend_mode),
7614                    dest_viewport,
7615                    sample_mode,
7616                );
7617        }
7618        self.recorder.record_pass();
7619        self.renderer.effect_renderer.record_composite_pass();
7620        self.recorder
7621            .release_transient_offscreen(scratch_descriptor, scratch);
7622    }
7623
7624    #[allow(clippy::too_many_arguments)]
7625    fn record_shader_projective_composite(
7626        &mut self,
7627        source: &OffscreenTarget,
7628        shader: &RuntimeShader,
7629        effect_rect: [f32; 4],
7630        dest_view: &wgpu::TextureView,
7631        viewport: (u32, u32),
7632        source_size: (f32, f32),
7633        inverse_matrix: [[f32; 3]; 3],
7634        dest_bounds: [[f32; 2]; 4],
7635        alpha: f32,
7636        load_op: wgpu::LoadOp<wgpu::Color>,
7637        scissor: Option<(u32, u32, u32, u32)>,
7638        blend_mode: BlendMode,
7639        sample_mode: CompositeSampleMode,
7640    ) {
7641        if projective_dest_bounds_rect(dest_bounds).is_none() {
7642            return;
7643        }
7644        let device = self.renderer.device.clone();
7645        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7646            "Shader Projective Composite Scratch",
7647            source.width,
7648            source.height,
7649        );
7650        let scratch = self
7651            .recorder
7652            .acquire_transient_offscreen(&device, scratch_descriptor);
7653        let shader_applied = {
7654            self.renderer.effect_renderer.encode_shader(
7655                self.recorder,
7656                &device,
7657                source,
7658                &scratch.view,
7659                shader,
7660                effect_rect,
7661            )
7662        };
7663        let composite_source = if shader_applied {
7664            self.renderer
7665                .effect_renderer
7666                .debug_effects
7667                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7668            self.recorder.record_pass();
7669            &scratch
7670        } else {
7671            source
7672        };
7673        let composited = {
7674            self.renderer
7675                .effect_renderer
7676                .encode_composite_to_view_projective(
7677                    self.recorder,
7678                    &device,
7679                    composite_source,
7680                    dest_view,
7681                    viewport,
7682                    source_size,
7683                    inverse_matrix,
7684                    dest_bounds,
7685                    alpha,
7686                    load_op,
7687                    scissor,
7688                    supported_blend_mode(blend_mode),
7689                    sample_mode,
7690                )
7691        };
7692        if composited {
7693            self.recorder.record_pass();
7694            self.renderer.effect_renderer.record_composite_pass();
7695        }
7696        self.recorder
7697            .release_transient_offscreen(scratch_descriptor, scratch);
7698    }
7699
7700    #[allow(clippy::too_many_arguments)]
7701    fn record_effect_with_direct_shader_tail_composite(
7702        &mut self,
7703        source: &OffscreenTarget,
7704        first_effect: &RenderEffect,
7705        shader: &RuntimeShader,
7706        effect_rect: [f32; 4],
7707        dest_view: &wgpu::TextureView,
7708        load_op: wgpu::LoadOp<wgpu::Color>,
7709        scissor: Option<(u32, u32, u32, u32)>,
7710        dest_viewport: (f32, f32, f32, f32),
7711    ) -> Result<bool, String> {
7712        let device = self.renderer.device.clone();
7713        let intermediate_descriptor = self.renderer.transient_offscreen_descriptor(
7714            "Render Effect Direct Shader Tail Intermediate",
7715            source.width,
7716            source.height,
7717        );
7718        let intermediate = self
7719            .recorder
7720            .acquire_transient_offscreen(&device, intermediate_descriptor);
7721        let effect_scratch_targets = self
7722            .renderer
7723            .effect_renderer
7724            .acquire_recorded_effect_scratch_targets(
7725                self.recorder,
7726                &device,
7727                first_effect,
7728                source.width,
7729                source.height,
7730                self.renderer.surface_format,
7731            );
7732        let first_passes = {
7733            let mut effect_scratch_refs = effect_scratch_targets.refs();
7734            let pass_count = self.renderer.effect_renderer.encode_effect(
7735                self.recorder,
7736                &device,
7737                source,
7738                &intermediate.view,
7739                first_effect,
7740                effect_rect,
7741                &mut effect_scratch_refs,
7742            );
7743            match pass_count {
7744                Ok(pass_count) => effect_scratch_refs.assert_consumed().map(|()| pass_count),
7745                Err(error) => Err(error),
7746            }
7747        };
7748        let first_passes = match first_passes {
7749            Ok(pass_count) => pass_count,
7750            Err(error) => {
7751                effect_scratch_targets.release_into(self.recorder);
7752                self.recorder
7753                    .release_transient_offscreen(intermediate_descriptor, intermediate);
7754                return Err(error);
7755            }
7756        };
7757        let shader_applied = self
7758            .renderer
7759            .effect_renderer
7760            .encode_shader_src_over_to_view(
7761                self.recorder,
7762                &device,
7763                &intermediate,
7764                dest_view,
7765                shader,
7766                effect_rect,
7767                load_op,
7768                scissor,
7769                dest_viewport,
7770            );
7771        self.recorder
7772            .record_passes(first_passes.saturating_add(u32::from(shader_applied)));
7773        effect_scratch_targets.release_into(self.recorder);
7774        self.recorder
7775            .release_transient_offscreen(intermediate_descriptor, intermediate);
7776        if !shader_applied {
7777            return Ok(false);
7778        }
7779        self.renderer
7780            .effect_renderer
7781            .debug_effects
7782            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7783        self.renderer.effect_renderer.record_composite_pass();
7784        Ok(true)
7785    }
7786
7787    #[allow(clippy::too_many_arguments)]
7788    fn record_effect_composite(
7789        &mut self,
7790        source: &OffscreenTarget,
7791        effect: &RenderEffect,
7792        effect_rect: [f32; 4],
7793        dest_view: &wgpu::TextureView,
7794        alpha: f32,
7795        load_op: wgpu::LoadOp<wgpu::Color>,
7796        scissor: Option<(u32, u32, u32, u32)>,
7797        blend_mode: BlendMode,
7798        dest_viewport: Option<(f32, f32, f32, f32)>,
7799        sample_mode: CompositeSampleMode,
7800    ) -> Result<(), String> {
7801        if let (
7802            RenderEffect::Chain { first, second },
7803            Some(viewport),
7804            BlendMode::SrcOver,
7805            CompositeSampleMode::Linear,
7806        ) = (
7807            effect,
7808            dest_viewport,
7809            supported_blend_mode(blend_mode),
7810            sample_mode,
7811        ) {
7812            if let (
7813                RenderEffect::Blur {
7814                    radius_x,
7815                    radius_y,
7816                    edge_treatment,
7817                },
7818                RenderEffect::Shader { shader },
7819            ) = (first.as_ref(), second.as_ref())
7820            {
7821                if *radius_x > 0.0 || *radius_y > 0.0 {
7822                    let device = self.renderer.device.clone();
7823                    let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7824                        "Blur Rounded Mask Scratch",
7825                        source.width,
7826                        source.height,
7827                    );
7828                    let scratch = self
7829                        .recorder
7830                        .acquire_transient_offscreen(&device, scratch_descriptor);
7831                    let fused = self
7832                        .renderer
7833                        .effect_renderer
7834                        .encode_blur_then_rounded_mask_src_over_to_view(
7835                            self.recorder,
7836                            &device,
7837                            source,
7838                            &scratch,
7839                            dest_view,
7840                            *radius_x,
7841                            *radius_y,
7842                            *edge_treatment,
7843                            shader,
7844                            effect_rect,
7845                            load_op,
7846                            scissor,
7847                            viewport,
7848                        );
7849                    if fused {
7850                        self.recorder.record_passes(2);
7851                        self.renderer.effect_renderer.record_blur_pass();
7852                        self.renderer
7853                            .effect_renderer
7854                            .debug_effects
7855                            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7856                        self.renderer.effect_renderer.record_composite_pass();
7857                        self.recorder
7858                            .release_transient_offscreen(scratch_descriptor, scratch);
7859                        return Ok(());
7860                    }
7861                    self.recorder
7862                        .release_transient_offscreen(scratch_descriptor, scratch);
7863                }
7864            }
7865        }
7866        if let Some((first_effect, shader, viewport)) = direct_shader_tail_composite(
7867            effect,
7868            alpha,
7869            blend_mode,
7870            dest_viewport,
7871            sample_mode,
7872            (source.width, source.height),
7873        ) {
7874            if self.record_effect_with_direct_shader_tail_composite(
7875                source,
7876                first_effect,
7877                shader,
7878                effect_rect,
7879                dest_view,
7880                load_op,
7881                scissor,
7882                viewport,
7883            )? {
7884                return Ok(());
7885            }
7886        }
7887        let device = self.renderer.device.clone();
7888        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7889            "Render Effect Composite Scratch",
7890            source.width,
7891            source.height,
7892        );
7893        let scratch = self
7894            .recorder
7895            .acquire_transient_offscreen(&device, scratch_descriptor);
7896        let effect_scratch_targets = self
7897            .renderer
7898            .effect_renderer
7899            .acquire_recorded_effect_scratch_targets(
7900                self.recorder,
7901                &device,
7902                effect,
7903                source.width,
7904                source.height,
7905                self.renderer.surface_format,
7906            );
7907        let effect_passes = {
7908            let mut effect_scratch_refs = effect_scratch_targets.refs();
7909            let pass_count = self.renderer.effect_renderer.encode_effect(
7910                self.recorder,
7911                &device,
7912                source,
7913                &scratch.view,
7914                effect,
7915                effect_rect,
7916                &mut effect_scratch_refs,
7917            )?;
7918            effect_scratch_refs.assert_consumed()?;
7919            Ok(pass_count)
7920        };
7921        let effect_passes = match effect_passes {
7922            Ok(pass_count) => pass_count,
7923            Err(error) => {
7924                effect_scratch_targets.release_into(self.recorder);
7925                self.recorder
7926                    .release_transient_offscreen(scratch_descriptor, scratch);
7927                return Err(error);
7928            }
7929        };
7930        {
7931            self.renderer
7932                .effect_renderer
7933                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
7934                    self.recorder,
7935                    &device,
7936                    &scratch,
7937                    dest_view,
7938                    alpha,
7939                    load_op,
7940                    scissor,
7941                    None,
7942                    supported_blend_mode(blend_mode),
7943                    dest_viewport,
7944                    sample_mode,
7945                );
7946        }
7947        self.recorder.record_passes(effect_passes.saturating_add(1));
7948        self.renderer.effect_renderer.record_composite_pass();
7949        effect_scratch_targets.release_into(self.recorder);
7950        self.recorder
7951            .release_transient_offscreen(scratch_descriptor, scratch);
7952        Ok(())
7953    }
7954
7955    #[allow(clippy::too_many_arguments)]
7956    fn record_effect_projective_composite(
7957        &mut self,
7958        source: &OffscreenTarget,
7959        effect: &RenderEffect,
7960        effect_rect: [f32; 4],
7961        dest_view: &wgpu::TextureView,
7962        viewport: (u32, u32),
7963        source_size: (f32, f32),
7964        inverse_matrix: [[f32; 3]; 3],
7965        dest_bounds: [[f32; 2]; 4],
7966        alpha: f32,
7967        load_op: wgpu::LoadOp<wgpu::Color>,
7968        scissor: Option<(u32, u32, u32, u32)>,
7969        blend_mode: BlendMode,
7970        sample_mode: CompositeSampleMode,
7971    ) -> Result<(), String> {
7972        if projective_dest_bounds_rect(dest_bounds).is_none() {
7973            return Ok(());
7974        }
7975        let device = self.renderer.device.clone();
7976        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7977            "Render Effect Projective Composite Scratch",
7978            source.width,
7979            source.height,
7980        );
7981        let scratch = self
7982            .recorder
7983            .acquire_transient_offscreen(&device, scratch_descriptor);
7984        let effect_scratch_targets = self
7985            .renderer
7986            .effect_renderer
7987            .acquire_recorded_effect_scratch_targets(
7988                self.recorder,
7989                &device,
7990                effect,
7991                source.width,
7992                source.height,
7993                self.renderer.surface_format,
7994            );
7995        let effect_passes = {
7996            let mut effect_scratch_refs = effect_scratch_targets.refs();
7997            let pass_count = self.renderer.effect_renderer.encode_effect(
7998                self.recorder,
7999                &device,
8000                source,
8001                &scratch.view,
8002                effect,
8003                effect_rect,
8004                &mut effect_scratch_refs,
8005            )?;
8006            effect_scratch_refs.assert_consumed()?;
8007            Ok(pass_count)
8008        };
8009        let effect_passes = match effect_passes {
8010            Ok(pass_count) => pass_count,
8011            Err(error) => {
8012                effect_scratch_targets.release_into(self.recorder);
8013                self.recorder
8014                    .release_transient_offscreen(scratch_descriptor, scratch);
8015                return Err(error);
8016            }
8017        };
8018        let composited = {
8019            self.renderer
8020                .effect_renderer
8021                .encode_composite_to_view_projective(
8022                    self.recorder,
8023                    &device,
8024                    &scratch,
8025                    dest_view,
8026                    viewport,
8027                    source_size,
8028                    inverse_matrix,
8029                    dest_bounds,
8030                    alpha,
8031                    load_op,
8032                    scissor,
8033                    supported_blend_mode(blend_mode),
8034                    sample_mode,
8035                )
8036        };
8037        if composited {
8038            self.recorder.record_passes(effect_passes.saturating_add(1));
8039            self.renderer.effect_renderer.record_composite_pass();
8040        } else {
8041            self.recorder.record_passes(effect_passes);
8042        }
8043        effect_scratch_targets.release_into(self.recorder);
8044        self.recorder
8045            .release_transient_offscreen(scratch_descriptor, scratch);
8046        Ok(())
8047    }
8048}
8049
8050impl<C: FrameCommandRecorder> SurfaceExecutionBackend for RecordingSurfaceBackend<'_, '_, C> {
8051    fn max_texture_dim(&self) -> u32 {
8052        self.renderer.max_texture_dim()
8053    }
8054
8055    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
8056        self.renderer.acquire_retained_surface(width, height)
8057    }
8058
8059    fn acquire_frame_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
8060        let descriptor =
8061            self.renderer
8062                .transient_offscreen_descriptor("Frame Surface", width, height);
8063        self.recorder
8064            .acquire_transient_offscreen(&self.renderer.device, descriptor)
8065    }
8066
8067    fn release_frame_surface(&mut self, target: OffscreenTarget) {
8068        let descriptor = self.renderer.transient_offscreen_descriptor(
8069            "Frame Surface",
8070            target.width,
8071            target.height,
8072        );
8073        self.recorder
8074            .release_transient_offscreen(descriptor, target);
8075    }
8076
8077    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
8078        self.renderer.release_layer_surface_target(target);
8079    }
8080
8081    fn cached_layer_surface(
8082        &mut self,
8083        key: &LayerRasterCacheKey,
8084    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
8085        self.renderer.cached_layer_surface(key)
8086    }
8087
8088    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
8089        self.renderer.admit_layer_surface_cache_miss(key)
8090    }
8091
8092    fn insert_cached_layer_surface(
8093        &mut self,
8094        key: LayerRasterCacheKey,
8095        target: OffscreenTarget,
8096        logical_rect: Rect,
8097    ) -> Rc<OffscreenTarget> {
8098        self.renderer
8099            .insert_cached_layer_surface(key, target, logical_rect)
8100    }
8101
8102    fn clear_target_view_with_load_op(
8103        &mut self,
8104        target_view: &wgpu::TextureView,
8105        load_op: wgpu::LoadOp<wgpu::Color>,
8106    ) {
8107        {
8108            let _clear = self
8109                .recorder
8110                .encoder()
8111                .begin_render_pass(&wgpu::RenderPassDescriptor {
8112                    label: Some("Layer Event Clear Pass"),
8113                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8114                        view: target_view,
8115                        resolve_target: None,
8116                        depth_slice: None,
8117                        ops: wgpu::Operations {
8118                            load: load_op,
8119                            store: wgpu::StoreOp::Store,
8120                        },
8121                    })],
8122                    depth_stencil_attachment: None,
8123                    timestamp_writes: None,
8124                    occlusion_query_set: None,
8125                    multiview_mask: None,
8126                });
8127        }
8128        self.recorder.record_pass();
8129    }
8130
8131    #[allow(clippy::too_many_arguments)]
8132    fn render_non_effect_segment(
8133        &mut self,
8134        target_view: &wgpu::TextureView,
8135        shapes: &[DrawShape],
8136        brushes: &[Brush],
8137        images: &[ImageDraw],
8138        texts: &[TextDraw],
8139        shadow_draws: &[ShadowDraw],
8140        retained_draws: &[RetainedDraw],
8141        draw_ops: &[DrawOp],
8142        z_start: usize,
8143        z_end: usize,
8144        effect_z_ranges: &[Range<usize>],
8145        width: u32,
8146        height: u32,
8147        root_scale: f32,
8148        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8149    ) -> Result<(), String> {
8150        self.render_non_effect_segment_with_composites(
8151            target_view,
8152            shapes,
8153            brushes,
8154            images,
8155            texts,
8156            shadow_draws,
8157            retained_draws,
8158            draw_ops,
8159            z_start,
8160            z_end,
8161            effect_z_ranges,
8162            &[],
8163            &[],
8164            width,
8165            height,
8166            root_scale,
8167            initial_load_op,
8168        )
8169    }
8170
8171    #[allow(clippy::too_many_arguments)]
8172    fn render_non_effect_segment_with_composites(
8173        &mut self,
8174        target_view: &wgpu::TextureView,
8175        shapes: &[DrawShape],
8176        brushes: &[Brush],
8177        images: &[ImageDraw],
8178        texts: &[TextDraw],
8179        shadow_draws: &[ShadowDraw],
8180        retained_draws: &[RetainedDraw],
8181        draw_ops: &[DrawOp],
8182        z_start: usize,
8183        z_end: usize,
8184        effect_z_ranges: &[Range<usize>],
8185        composites: &[(usize, CompositeBatchItem<'_>)],
8186        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
8187        width: u32,
8188        height: u32,
8189        root_scale: f32,
8190        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8191    ) -> Result<(), String> {
8192        let mut ordered_items = std::mem::take(&mut self.renderer.scratch_segment_items);
8193        collect_non_effect_segment_items(
8194            shapes,
8195            images,
8196            texts,
8197            shadow_draws,
8198            draw_ops,
8199            z_start,
8200            z_end,
8201            effect_z_ranges,
8202            width,
8203            height,
8204            root_scale,
8205            &mut ordered_items,
8206        );
8207        #[cfg(not(target_arch = "wasm32"))]
8208        let raw_shadow_items = ordered_items
8209            .iter()
8210            .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
8211            .count();
8212        let culled_shadow_items = retain_renderable_shadow_items(
8213            &mut ordered_items,
8214            shadow_draws,
8215            width,
8216            height,
8217            root_scale,
8218            self.renderer.max_texture_dim(),
8219        );
8220        #[cfg(target_arch = "wasm32")]
8221        let _ = culled_shadow_items;
8222        let mut cached_shadow_composites: Vec<(usize, CachedShadowComposite)> = Vec::new();
8223        ordered_items.extend(
8224            composites
8225                .iter()
8226                .enumerate()
8227                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::Composite(index))),
8228        );
8229        ordered_items.extend(
8230            shader_composites
8231                .iter()
8232                .enumerate()
8233                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::ShaderComposite(index))),
8234        );
8235        for (z_index, item) in &mut ordered_items {
8236            let SegmentDrawItem::Shadow(shadow_index) = *item else {
8237                continue;
8238            };
8239            let Some(composite) = self.renderer.cached_shape_shadow_composite(
8240                &shadow_draws[shadow_index],
8241                width,
8242                height,
8243                root_scale,
8244            ) else {
8245                continue;
8246            };
8247            let composite_index = composites.len() + cached_shadow_composites.len();
8248            cached_shadow_composites.push((*z_index, composite));
8249            *item = SegmentDrawItem::Composite(composite_index);
8250        }
8251        let mut merged_composites = Vec::with_capacity(
8252            composites
8253                .len()
8254                .saturating_add(cached_shadow_composites.len()),
8255        );
8256        merged_composites.extend(composites.iter().copied());
8257        merged_composites.extend(
8258            cached_shadow_composites
8259                .iter()
8260                .map(|(z_index, composite)| (*z_index, composite.batch_item())),
8261        );
8262        // Z indices are unique — the scene hands every op its own `next_z` — so an
8263        // unstable sort cannot reorder anything a stable one wouldn't, and it skips
8264        // the stable sort's scratch allocation, paid here once per segment per frame.
8265        ordered_items.sort_unstable_by_key(|(z_index, _)| *z_index);
8266        #[cfg(not(target_arch = "wasm32"))]
8267        maybe_print_segment_diag(
8268            z_start..z_end,
8269            &ordered_items,
8270            shapes,
8271            brushes,
8272            images,
8273            SegmentDiagCounts {
8274                raw_shadow_items,
8275                culled_shadow_items,
8276                cached_shadow_composites: cached_shadow_composites.len(),
8277                composite_items: merged_composites.len(),
8278                shader_composite_items: shader_composites.len(),
8279            },
8280            self.renderer.shape_batch_limits,
8281        );
8282        let result = if ordered_items.is_empty() {
8283            Ok(SegmentCommandEncodeOutcome { first_batch: true })
8284        } else {
8285            self.renderer.encode_non_effect_segment_commands(
8286                self.recorder,
8287                target_view,
8288                &ordered_items,
8289                &merged_composites,
8290                shader_composites,
8291                shapes,
8292                brushes,
8293                images,
8294                texts,
8295                shadow_draws,
8296                retained_draws,
8297                initial_load_op,
8298                width,
8299                height,
8300                root_scale,
8301            )
8302        };
8303        self.renderer.scratch_segment_items = ordered_items;
8304        let outcome = result?;
8305        if outcome.first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
8306            self.clear_target_view_with_load_op(target_view, initial_load_op);
8307        }
8308        Ok(())
8309    }
8310
8311    fn render_range_with_layer_events_to_target(
8312        &mut self,
8313        target: &OffscreenTarget,
8314        shapes: &[DrawShape],
8315        brushes: &[Brush],
8316        images: &[ImageDraw],
8317        texts: &[TextDraw],
8318        shadow_draws: &[ShadowDraw],
8319        draw_ops: &[DrawOp],
8320        effect_layers: &[EffectLayer],
8321        backdrop_layers: &[BackdropLayer],
8322        z_start: usize,
8323        z_end: usize,
8324        excluded_effect_layer: Option<usize>,
8325        width: u32,
8326        height: u32,
8327        root_scale: f32,
8328        backdrop_underlay: Option<&OffscreenTarget>,
8329        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8330    ) -> Result<(), String> {
8331        self.render_range_with_layer_events_to_target_recorded(
8332            target,
8333            shapes,
8334            brushes,
8335            images,
8336            texts,
8337            shadow_draws,
8338            draw_ops,
8339            effect_layers,
8340            backdrop_layers,
8341            z_start,
8342            z_end,
8343            excluded_effect_layer,
8344            width,
8345            height,
8346            root_scale,
8347            backdrop_underlay,
8348            initial_load_op,
8349        )
8350    }
8351
8352    fn render_shadow_draw(
8353        &mut self,
8354        target_view: &wgpu::TextureView,
8355        shadow: &ShadowDraw,
8356        width: u32,
8357        height: u32,
8358        root_scale: f32,
8359    ) {
8360        self.renderer.encode_shadow_draw(
8361            self.recorder,
8362            target_view,
8363            shadow,
8364            width,
8365            height,
8366            root_scale,
8367        );
8368    }
8369
8370    fn composite_to_view_projective(
8371        &mut self,
8372        source: &OffscreenTarget,
8373        dest_view: &wgpu::TextureView,
8374        viewport: (u32, u32),
8375        source_size: (f32, f32),
8376        inverse_matrix: [[f32; 3]; 3],
8377        dest_bounds: [[f32; 2]; 4],
8378        alpha: f32,
8379        load_op: wgpu::LoadOp<wgpu::Color>,
8380        scissor: Option<(u32, u32, u32, u32)>,
8381        blend_mode: BlendMode,
8382        sample_mode: CompositeSampleMode,
8383    ) {
8384        let device = self.renderer.device.clone();
8385        let composited = {
8386            self.renderer
8387                .effect_renderer
8388                .encode_composite_to_view_projective(
8389                    self.recorder,
8390                    &device,
8391                    source,
8392                    dest_view,
8393                    viewport,
8394                    source_size,
8395                    inverse_matrix,
8396                    dest_bounds,
8397                    alpha,
8398                    load_op,
8399                    scissor,
8400                    supported_blend_mode(blend_mode),
8401                    sample_mode,
8402                )
8403        };
8404        if composited {
8405            self.recorder.record_pass();
8406            self.renderer.effect_renderer.record_composite_pass();
8407        }
8408    }
8409
8410    fn composite_projective_surfaces_to_view(
8411        &mut self,
8412        dest_view: &wgpu::TextureView,
8413        viewport: (u32, u32),
8414        composites: &[ProjectiveSurfaceComposite<'_>],
8415    ) {
8416        let device = self.renderer.device.clone();
8417        let mut composite_count = 0_u32;
8418        for composite in composites
8419            .iter()
8420            .copied()
8421            .filter(|composite| projective_dest_bounds_rect(composite.dest_bounds).is_some())
8422        {
8423            let composited = {
8424                self.renderer
8425                    .effect_renderer
8426                    .encode_composite_to_view_projective(
8427                        self.recorder,
8428                        &device,
8429                        composite.source,
8430                        dest_view,
8431                        viewport,
8432                        composite.source_size,
8433                        composite.inverse_matrix,
8434                        composite.dest_bounds,
8435                        composite.alpha,
8436                        composite.load_op,
8437                        composite.scissor,
8438                        supported_blend_mode(composite.blend_mode),
8439                        composite.sample_mode,
8440                    )
8441            };
8442            if composited {
8443                composite_count = composite_count.saturating_add(1);
8444            }
8445        }
8446        if composite_count > 0 {
8447            self.recorder.record_passes(composite_count);
8448            self.renderer
8449                .effect_renderer
8450                .debug_composites
8451                .set(self.renderer.effect_renderer.debug_composites.get() + composite_count);
8452        }
8453    }
8454
8455    fn composite_surface_batch_to_view(
8456        &mut self,
8457        dest_view: &wgpu::TextureView,
8458        viewport: (u32, u32),
8459        load_op: wgpu::LoadOp<wgpu::Color>,
8460        composites: &[CompositeBatchItem<'_>],
8461    ) {
8462        if composites.is_empty() {
8463            return;
8464        }
8465        let device = self.renderer.device.clone();
8466        self.renderer
8467            .effect_renderer
8468            .encode_composite_batch_to_view_pass(
8469                self.recorder,
8470                &device,
8471                dest_view,
8472                viewport,
8473                load_op,
8474                composites,
8475            );
8476        self.recorder.record_pass();
8477        self.renderer.effect_renderer.record_composite_pass();
8478    }
8479
8480    fn copy_texture_region_to_target(
8481        &mut self,
8482        source: &OffscreenTarget,
8483        source_origin: (u32, u32),
8484        target: &OffscreenTarget,
8485        size: (u32, u32),
8486    ) -> bool {
8487        let (width, height) = size;
8488        if width == 0 || height == 0 || width > target.width || height > target.height {
8489            return false;
8490        }
8491        let Some(source_right) = source_origin.0.checked_add(width) else {
8492            return false;
8493        };
8494        let Some(source_bottom) = source_origin.1.checked_add(height) else {
8495            return false;
8496        };
8497        if source_right > source.width || source_bottom > source.height {
8498            return false;
8499        }
8500
8501        self.recorder.encoder().copy_texture_to_texture(
8502            wgpu::TexelCopyTextureInfo {
8503                texture: source.texture(),
8504                mip_level: 0,
8505                origin: wgpu::Origin3d {
8506                    x: source_origin.0,
8507                    y: source_origin.1,
8508                    z: 0,
8509                },
8510                aspect: wgpu::TextureAspect::All,
8511            },
8512            wgpu::TexelCopyTextureInfo {
8513                texture: target.texture(),
8514                mip_level: 0,
8515                origin: wgpu::Origin3d::ZERO,
8516                aspect: wgpu::TextureAspect::All,
8517            },
8518            wgpu::Extent3d {
8519                width,
8520                height,
8521                depth_or_array_layers: 1,
8522            },
8523        );
8524        true
8525    }
8526
8527    fn shader_composite_batch_to_view(
8528        &mut self,
8529        dest_view: &wgpu::TextureView,
8530        viewport: (u32, u32),
8531        load_op: wgpu::LoadOp<wgpu::Color>,
8532        composites: &[ShaderCompositeBatchItem<'_>],
8533    ) -> bool {
8534        if composites.is_empty() {
8535            return true;
8536        }
8537        let device = self.renderer.device.clone();
8538        let encoded = self
8539            .renderer
8540            .effect_renderer
8541            .encode_shader_batch_src_over_to_view(
8542                self.recorder,
8543                &device,
8544                dest_view,
8545                viewport,
8546                load_op,
8547                composites,
8548            );
8549        if encoded {
8550            self.recorder.record_pass();
8551            self.renderer.effect_renderer.record_composite_pass();
8552            self.renderer
8553                .effect_renderer
8554                .debug_effects
8555                .set(self.renderer.effect_renderer.debug_effects.get() + composites.len() as u32);
8556        }
8557        encoded
8558    }
8559
8560    fn composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8561        &mut self,
8562        source: &OffscreenTarget,
8563        dest_view: &wgpu::TextureView,
8564        alpha: f32,
8565        load_op: wgpu::LoadOp<wgpu::Color>,
8566        scissor: Option<(u32, u32, u32, u32)>,
8567        rounded_mask: Option<RoundedCompositeMask>,
8568        blend_mode: BlendMode,
8569        dest_viewport: Option<(f32, f32, f32, f32)>,
8570        sample_mode: CompositeSampleMode,
8571    ) {
8572        let device = self.renderer.device.clone();
8573        {
8574            self.renderer
8575                .effect_renderer
8576                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8577                    self.recorder,
8578                    &device,
8579                    source,
8580                    dest_view,
8581                    alpha,
8582                    load_op,
8583                    scissor,
8584                    rounded_mask,
8585                    supported_blend_mode(blend_mode),
8586                    dest_viewport,
8587                    sample_mode,
8588                );
8589        }
8590        self.recorder.record_pass();
8591        self.renderer.effect_renderer.record_composite_pass();
8592    }
8593
8594    fn apply_effect_and_composite_to_view(
8595        &mut self,
8596        source: &OffscreenTarget,
8597        effect: &RenderEffect,
8598        effect_rect: [f32; 4],
8599        dest_view: &wgpu::TextureView,
8600        alpha: f32,
8601        load_op: wgpu::LoadOp<wgpu::Color>,
8602        scissor: Option<(u32, u32, u32, u32)>,
8603        blend_mode: BlendMode,
8604        dest_viewport: Option<(f32, f32, f32, f32)>,
8605        sample_mode: CompositeSampleMode,
8606    ) -> Result<(), String> {
8607        self.record_effect_composite(
8608            source,
8609            effect,
8610            effect_rect,
8611            dest_view,
8612            alpha,
8613            load_op,
8614            scissor,
8615            blend_mode,
8616            dest_viewport,
8617            sample_mode,
8618        )
8619    }
8620
8621    fn apply_shader_and_composite_to_view(
8622        &mut self,
8623        source: &OffscreenTarget,
8624        shader: &RuntimeShader,
8625        effect_rect: [f32; 4],
8626        dest_view: &wgpu::TextureView,
8627        alpha: f32,
8628        load_op: wgpu::LoadOp<wgpu::Color>,
8629        scissor: Option<(u32, u32, u32, u32)>,
8630        blend_mode: BlendMode,
8631        dest_viewport: Option<(f32, f32, f32, f32)>,
8632        sample_mode: CompositeSampleMode,
8633    ) {
8634        self.record_shader_composite(
8635            source,
8636            shader,
8637            effect_rect,
8638            dest_view,
8639            alpha,
8640            load_op,
8641            scissor,
8642            blend_mode,
8643            dest_viewport,
8644            sample_mode,
8645        );
8646    }
8647
8648    fn apply_shader_and_composite_to_view_projective(
8649        &mut self,
8650        source: &OffscreenTarget,
8651        shader: &RuntimeShader,
8652        effect_rect: [f32; 4],
8653        dest_view: &wgpu::TextureView,
8654        viewport: (u32, u32),
8655        source_size: (f32, f32),
8656        inverse_matrix: [[f32; 3]; 3],
8657        dest_bounds: [[f32; 2]; 4],
8658        alpha: f32,
8659        load_op: wgpu::LoadOp<wgpu::Color>,
8660        scissor: Option<(u32, u32, u32, u32)>,
8661        blend_mode: BlendMode,
8662        sample_mode: CompositeSampleMode,
8663    ) {
8664        self.record_shader_projective_composite(
8665            source,
8666            shader,
8667            effect_rect,
8668            dest_view,
8669            viewport,
8670            source_size,
8671            inverse_matrix,
8672            dest_bounds,
8673            alpha,
8674            load_op,
8675            scissor,
8676            blend_mode,
8677            sample_mode,
8678        );
8679    }
8680
8681    fn apply_effect_and_composite_to_view_projective(
8682        &mut self,
8683        source: &OffscreenTarget,
8684        effect: &RenderEffect,
8685        effect_rect: [f32; 4],
8686        dest_view: &wgpu::TextureView,
8687        viewport: (u32, u32),
8688        source_size: (f32, f32),
8689        inverse_matrix: [[f32; 3]; 3],
8690        dest_bounds: [[f32; 2]; 4],
8691        alpha: f32,
8692        load_op: wgpu::LoadOp<wgpu::Color>,
8693        scissor: Option<(u32, u32, u32, u32)>,
8694        blend_mode: BlendMode,
8695        sample_mode: CompositeSampleMode,
8696    ) -> Result<(), String> {
8697        self.record_effect_projective_composite(
8698            source,
8699            effect,
8700            effect_rect,
8701            dest_view,
8702            viewport,
8703            source_size,
8704            inverse_matrix,
8705            dest_bounds,
8706            alpha,
8707            load_op,
8708            scissor,
8709            blend_mode,
8710            sample_mode,
8711        )
8712    }
8713
8714    fn is_render_effect_supported(&self, effect: &RenderEffect) -> bool {
8715        self.renderer.supports_render_effect(effect)
8716    }
8717
8718    fn warn_unsupported_effect_once(&self) {
8719        self.renderer.warning_state.warn_unsupported_effect_once();
8720    }
8721
8722    fn record_layer_cache_miss(&self, width: u32, height: u32) {
8723        self.renderer
8724            .frame_stats
8725            .record_layer_cache_miss(width, height);
8726    }
8727
8728    fn record_isolated_layer_render(
8729        &self,
8730        width: u32,
8731        height: u32,
8732        node_id: Option<NodeId>,
8733        logical_rect: Rect,
8734        requirements: SurfaceRequirementSet,
8735    ) {
8736        self.renderer.frame_stats.record_isolated_layer_render(
8737            width,
8738            height,
8739            node_id,
8740            logical_rect,
8741            requirements.into(),
8742        );
8743    }
8744}
8745
8746impl GpuRenderer {
8747    pub fn render(
8748        &mut self,
8749        view: &wgpu::TextureView,
8750        width: u32,
8751        height: u32,
8752        mut packet: FramePacket,
8753        surface_epoch: u64,
8754        returns: &mut RenderReturns,
8755    ) -> Result<(), String> {
8756        // Threaded mode rides the emptied ack-confirmations buffer back to
8757        // the store inside the next packet ([`FramePacket::recycled_confirmations`]);
8758        // adopt it before the validity gate so even a cancelled packet
8759        // cannot leak the capacity. Sync callers always carry `None`.
8760        if let Some(confirmations) = packet.recycled_confirmations.take() {
8761            self.restore_replay_ack_confirmations(confirmations);
8762        }
8763        // Packet validity gate — BEFORE consume_replay_ops and any
8764        // encoding. A packet built against another renderer instance,
8765        // another surface configuration, or another viewport is cancelled
8766        // whole: its buffers travel back through `returns` for re-queue
8767        // and recycling, and nothing of it reaches the GPU.
8768        let cancel_reason = if packet.renderer_epoch != self.renderer_epoch {
8769            Some(CancelReason::RendererEpoch)
8770        } else if packet.surface_epoch != surface_epoch {
8771            Some(CancelReason::SurfaceEpoch)
8772        } else if packet.viewport != (width, height) {
8773            Some(CancelReason::Viewport)
8774        } else {
8775            None
8776        };
8777        if let Some(reason) = cancel_reason {
8778            return Self::cancel_packet(packet, reason, returns);
8779        }
8780        // Device-error gate — same protocol as the validity gate above: an
8781        // uncaptured error recorded since the last frame cancels this
8782        // packet whole, so nothing is encoded on the suspect device. The
8783        // take clears the poison, so the NEXT packet renders — one skipped
8784        // frame per poisoning, the acquire path's give-up-this-frame
8785        // semantics ([`DeviceErrorSentry`]).
8786        if self.device_errors.take_poison() {
8787            return Self::cancel_packet(packet, CancelReason::DeviceError, returns);
8788        }
8789        returns.frame_id = packet.frame_id;
8790        log::trace!("🎨 Rendering graph to {}x{}", width, height);
8791        let render_start = Instant::now();
8792
8793        #[cfg(target_arch = "wasm32")]
8794        {
8795            self.wasm_uniform_batch_cursor = 0;
8796            self.wasm_shape_batch_cursor = 0;
8797            self.wasm_image_batch_cursor = 0;
8798        }
8799        #[cfg(not(target_arch = "wasm32"))]
8800        {
8801            self.retained_glyph_uniform_cursor = 0;
8802            // Transient rim meshes live for exactly one frame: the scratch
8803            // restarts here and every fused chunk appends after the region
8804            // already uploaded (the GPU buffers themselves are fixed-capacity
8805            // and persist).
8806            self.rim_mesh_vertices.clear();
8807            self.rim_mesh_indices.clear();
8808            self.rim_mesh_uploaded_vertices = 0;
8809            self.rim_mesh_uploaded_indices = 0;
8810            if fill_area_diag_enabled() {
8811                self.fill_area_diag.reset_frame(width, height);
8812            }
8813            // One engagement per frame: the first fused partition carrying
8814            // the frame's opaque clear consumes this.
8815            self.static_span.armed = true;
8816            // Segment-surface frame boundary: config refresh, capture-slot
8817            // cursor reset, periodic idle sweep.
8818            self.segment_surfaces.begin_frame();
8819            // The frame's root target, held for the graph walk only: the
8820            // display clip cull compares fused-pass targets against it so
8821            // nothing but the real surface pass is ever culled.
8822            self.display_clip.frame_root_view = Some(view.clone());
8823        }
8824
8825        // Producer-side text layout cache size, carried by the packet — the
8826        // present call tree holds no text layout state, and no layout runs
8827        // between packet build and the stats block below.
8828        let text_cache_len = packet.text_cache_len;
8829        let result = self.render_graph(view, packet, returns);
8830        #[cfg(not(target_arch = "wasm32"))]
8831        {
8832            self.display_clip.frame_root_view = None;
8833        }
8834        let after_graph = Instant::now();
8835        self.flush_deferred_offscreen_releases();
8836        #[cfg(not(target_arch = "wasm32"))]
8837        {
8838            if fill_area_diag_enabled() {
8839                // Effect/composite fill accumulated during the graph walk
8840                // lives in the effect renderer's own cells; fold it into
8841                // this frame before the window closes over it.
8842                let (composite_px2, offscreen_px2) = self.effect_renderer.take_fill_diag_fill_px2();
8843                self.fill_area_diag
8844                    .add_effect_fill(composite_px2, offscreen_px2);
8845                self.fill_area_diag.finish_frame(width, height);
8846            }
8847        }
8848
8849        #[cfg(target_arch = "wasm32")]
8850        {
8851            const WASM_BATCH_POOL_MARGIN: usize = 4;
8852            self.wasm_uniform_batches.truncate(
8853                self.wasm_uniform_batch_cursor
8854                    .saturating_add(WASM_BATCH_POOL_MARGIN),
8855            );
8856            self.wasm_shape_batches.truncate(
8857                self.wasm_shape_batch_cursor
8858                    .saturating_add(WASM_BATCH_POOL_MARGIN),
8859            );
8860            self.wasm_image_batches.truncate(
8861                self.wasm_image_batch_cursor
8862                    .saturating_add(WASM_BATCH_POOL_MARGIN),
8863            );
8864        }
8865        self.staged_uploads
8866            .shrink_retained_capacity(RETAINED_STAGED_UPLOAD_BYTES, RETAINED_STAGED_UPLOAD_COPIES);
8867
8868        self.layer_surface_cache.finish_frame(&self.frame_stats);
8869        #[cfg(not(target_arch = "wasm32"))]
8870        self.retained_bundle_cache.end_frame();
8871
8872        self.frame_stats.offscreen_pool_size.set(
8873            self.effect_renderer
8874                .retained_offscreen_count()
8875                .saturating_add(self.frame_graph_executor.retained_texture_count())
8876                as u32,
8877        );
8878        self.frame_stats.offscreen_pool_bytes.set(
8879            (self.effect_renderer.retained_offscreen_bytes() as u64)
8880                .saturating_add(self.frame_graph_executor.retained_texture_bytes()),
8881        );
8882        self.frame_stats
8883            .text_pool_size
8884            .set(self.text_image_cache.len() as u32);
8885        self.frame_stats
8886            .image_cache_size
8887            .set(self.image_texture_cache.len() as u32);
8888        self.frame_stats.text_cache_size.set(text_cache_len as u32);
8889        self.effect_renderer
8890            .merge_and_reset_debug_counters(&self.frame_stats);
8891        self.frame_graph_executor.reset_upload_allocators();
8892        let snapshot = self.frame_stats.snapshot();
8893        self.last_frame_stats = Some(snapshot);
8894        PRESENTED_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8895        update_frame_warmup_budget(&mut self.pending_frame_warmup_frames, &snapshot);
8896        self.frame_stats.maybe_print_snapshot(
8897            snapshot,
8898            &mut self.frame_count,
8899            self.gpu_stats_enabled,
8900        );
8901        if self.gpu_stats_enabled && self.frame_count.is_multiple_of(60) {
8902            gpu_stats::print_gpu_memory_report(&self.device, self.frame_count);
8903        }
8904        self.frame_stats.reset();
8905        let after_stats = Instant::now();
8906        if let Some(total_ms) = should_log_wgpu_render_stage(render_start, after_stats) {
8907            log::warn!(
8908                "[wgpu-render-stage:render] total_ms={total_ms:.2} graph_ms={:.2} cleanup_stats_ms={:.2}",
8909                instant_ms(render_start, after_graph),
8910                instant_ms(after_graph, after_stats),
8911            );
8912        }
8913        if result.is_ok() {
8914            // Only a draw that actually ran may report `Presented`; an
8915            // errored draw leaves the default `NotRun`.
8916            returns.outcome = PresentOutcome::Presented;
8917        }
8918        result
8919    }
8920
8921    /// Refuses a packet whole, before any encoding: every buffer it
8922    /// carries travels back through `returns` — the direct scene for the
8923    /// producer pool, the unconsumed replay plan for the planner to
8924    /// re-queue (its releases name still-live store slots; dropping them
8925    /// would leak pool ids forever). A cancel is a protocol outcome, not a
8926    /// draw error, so the render call returns `Ok(())`.
8927    ///
8928    /// `pub(crate)` for the present runtime, which must cancel a packet
8929    /// that cannot render at all (surface dropped) without touching the
8930    /// GPU. Callers that bypass [`render`][Self::render] must take the
8931    /// packet's `recycled_confirmations` first — this refuses the packet
8932    /// without a store to adopt them into.
8933    pub(crate) fn cancel_packet(
8934        packet: FramePacket,
8935        reason: CancelReason,
8936        returns: &mut RenderReturns,
8937    ) -> Result<(), String> {
8938        let FramePacket {
8939            frame_id,
8940            viewport: _,
8941            renderer_epoch: _,
8942            surface_epoch: _,
8943            root_scale: _,
8944            root,
8945            overlay: _,
8946            replay,
8947            text_cache_len: _,
8948            recycled_confirmations: _,
8949            replay_preconsumed,
8950        } = packet;
8951        match root {
8952            PacketRoot::Direct(root) => {
8953                // Destructure: the scene buffers return to the producer
8954                // pool; the rest of the collected layer drops. A Direct
8955                // packet's replay plan came from the planner and must go
8956                // back to it unconsumed — a Surface packet only ever
8957                // carries the empty default plan, which has nothing to
8958                // reclaim. A plan the present stage already consumed
8959                // (`take_replay_ack_early`) is not here to reclaim: the
8960                // store honored it and its ack is on the way to the
8961                // planner, so `replay` holds only the taken-out default.
8962                returns.scene = Some(root.scene);
8963                #[cfg(not(target_arch = "wasm32"))]
8964                if !replay_preconsumed {
8965                    returns.cancelled_replay = Some(replay);
8966                }
8967            }
8968            PacketRoot::Surface(_) => {}
8969        }
8970        #[cfg(target_arch = "wasm32")]
8971        let _ = (replay, replay_preconsumed);
8972        returns.ack = None;
8973        returns.frame_id = frame_id;
8974        returns.outcome = PresentOutcome::Cancelled(reason);
8975        Ok(())
8976    }
8977
8978    pub fn last_frame_stats(&self) -> Option<gpu_stats::FrameStatsSnapshot> {
8979        self.last_frame_stats
8980    }
8981
8982    pub fn needs_frame_warmup(&self) -> bool {
8983        self.pending_frame_warmup_frames > 0
8984    }
8985
8986    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
8987        let layer_surface_cache_stats = self.layer_surface_cache.debug_stats();
8988        DebugCpuAllocationStats {
8989            scene_graph_node_count: 0,
8990            scene_graph_heap_bytes: 0,
8991            scene_hits_len: 0,
8992            scene_hits_cap: 0,
8993            scene_node_index_len: 0,
8994            scene_node_index_cap: 0,
8995            text_renderer_pool_len: self.text_image_cache.len(),
8996            text_renderer_pool_cap: self.text_image_cache.cap().get(),
8997            swash_image_cache_len: 0,
8998            swash_image_cache_cap: 0,
8999            swash_outline_cache_len: 0,
9000            swash_outline_cache_cap: 0,
9001            image_texture_cache_len: self.image_texture_cache.len(),
9002            image_texture_cache_cap: self.image_texture_cache.cap().get(),
9003            scratch_shape_data_cap: self.scratch_shape_data.capacity(),
9004            scratch_gradients_cap: self.scratch_gradients.capacity(),
9005            scratch_image_vertices_cap: self.scratch_image_vertices.capacity(),
9006            scratch_image_indices_cap: self.scratch_image_indices.capacity(),
9007            scratch_image_cmds_cap: self.scratch_image_cmds.capacity(),
9008            scratch_segment_items_cap: self.scratch_segment_items.capacity(),
9009            scratch_effect_ranges_cap: self.scratch_effect_ranges.capacity(),
9010            scratch_layer_events_cap: self.scratch_layer_events.capacity(),
9011            staged_upload_bytes_cap: self.staged_uploads.bytes.capacity(),
9012            staged_upload_copies_cap: self.staged_uploads.copies.capacity(),
9013            layer_surface_cache_len: layer_surface_cache_stats.entries_len,
9014            layer_surface_cache_cap: layer_surface_cache_stats.entries_cap,
9015            layer_surface_cache_identity_len: layer_surface_cache_stats.identity_len,
9016            layer_surface_cache_identity_cap: layer_surface_cache_stats.identity_cap,
9017            // The producer frontend owns the only lowering-memo pair since
9018            // step 6b; the present backend contributes nothing.
9019            layer_surface_rect_cache_len: 0,
9020            layer_surface_rect_cache_cap: 0,
9021            layer_surface_requirements_cache_len: 0,
9022            layer_surface_requirements_cache_cap: 0,
9023            layer_cache_seen_this_frame_len: layer_surface_cache_stats.seen_this_frame_len,
9024            layer_cache_seen_this_frame_cap: layer_surface_cache_stats.seen_this_frame_cap,
9025        }
9026    }
9027
9028    pub fn render_to_rgba_pixels(
9029        &mut self,
9030        width: u32,
9031        height: u32,
9032        packet: FramePacket,
9033        surface_epoch: u64,
9034        returns: &mut RenderReturns,
9035    ) -> Result<Vec<u8>, String> {
9036        if width == 0 || height == 0 {
9037            return Err("Screenshot size must be non-zero".to_string());
9038        }
9039
9040        let output_texture = self.device.create_texture(&wgpu::TextureDescriptor {
9041            label: Some("Screenshot Output Texture"),
9042            size: wgpu::Extent3d {
9043                width,
9044                height,
9045                depth_or_array_layers: 1,
9046            },
9047            mip_level_count: 1,
9048            sample_count: 1,
9049            dimension: wgpu::TextureDimension::D2,
9050            format: self.surface_format,
9051            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
9052            view_formats: &[],
9053        });
9054        let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
9055
9056        self.render(&output_view, width, height, packet, surface_epoch, returns)?;
9057
9058        let bytes_per_pixel = 4u32;
9059        let unpadded_bytes_per_row = width
9060            .checked_mul(bytes_per_pixel)
9061            .ok_or_else(|| "Screenshot row byte size overflow".to_string())?;
9062        let padded_bytes_per_row =
9063            align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
9064        let output_buffer_size = padded_bytes_per_row as u64 * height as u64;
9065
9066        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9067            label: Some("Screenshot Readback Buffer"),
9068            size: output_buffer_size,
9069            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
9070            mapped_at_creation: false,
9071        });
9072
9073        let device = self.device.clone();
9074        let queue = self.queue.clone();
9075        let mut graph = WgpuFrameGraph::new(Some("Screenshot Copy Encoder"));
9076        let source = graph.import_surface("screenshot-copy-source");
9077        graph.add_fallible_command_pass(Some("Screenshot Copy Pass"), &[source], &[], |context| {
9078            context.encoder.copy_texture_to_buffer(
9079                wgpu::TexelCopyTextureInfo {
9080                    texture: &output_texture,
9081                    mip_level: 0,
9082                    origin: wgpu::Origin3d::ZERO,
9083                    aspect: wgpu::TextureAspect::All,
9084                },
9085                wgpu::TexelCopyBufferInfo {
9086                    buffer: &output_buffer,
9087                    layout: wgpu::TexelCopyBufferLayout {
9088                        offset: 0,
9089                        bytes_per_row: Some(padded_bytes_per_row),
9090                        rows_per_image: Some(height),
9091                    },
9092                },
9093                wgpu::Extent3d {
9094                    width,
9095                    height,
9096                    depth_or_array_layers: 1,
9097                },
9098            );
9099            Ok(())
9100        });
9101        let mut executor = std::mem::take(&mut self.frame_graph_executor);
9102        let execution = executor.execute_recorded_graph(&device, &queue, graph);
9103        self.frame_graph_executor = executor;
9104        let execution = execution.map_err(|error| error.to_string())?;
9105        let submission_index = execution.submission;
9106        let copy_stats = execution.stats;
9107        self.last_frame_stats = self
9108            .last_frame_stats
9109            .map(|snapshot| snapshot.with_command_stats_added(copy_stats));
9110
9111        let buffer_slice = output_buffer.slice(..);
9112        let (tx, rx) = mpsc::channel();
9113        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
9114            let _ = tx.send(result);
9115        });
9116        let _ = self.device.poll(wgpu::PollType::Wait {
9117            submission_index: Some(submission_index),
9118            timeout: None,
9119        });
9120
9121        match rx.recv_timeout(Duration::from_secs(3)) {
9122            Ok(Ok(())) => {}
9123            Ok(Err(err)) => return Err(format!("Screenshot map_async failed: {err:?}")),
9124            Err(err) => return Err(format!("Screenshot readback timed out: {err}")),
9125        }
9126
9127        let mapped = buffer_slice.get_mapped_range();
9128        let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
9129
9130        let src_row_len = padded_bytes_per_row as usize;
9131        let dst_row_len = unpadded_bytes_per_row as usize;
9132        for row in 0..height as usize {
9133            let src_offset = row * src_row_len;
9134            let dst_offset = row * dst_row_len;
9135            pixels[dst_offset..dst_offset + dst_row_len]
9136                .copy_from_slice(&mapped[src_offset..src_offset + dst_row_len]);
9137        }
9138        drop(mapped);
9139        output_buffer.unmap();
9140
9141        self.convert_surface_pixels_to_rgba(&mut pixels)?;
9142        Ok(pixels)
9143    }
9144
9145    fn render_graph(
9146        &mut self,
9147        surface_view: &wgpu::TextureView,
9148        packet: FramePacket,
9149        returns: &mut RenderReturns,
9150    ) -> Result<(), String> {
9151        let device = self.device.clone();
9152        let queue = self.queue.clone();
9153        let graph_start = Instant::now();
9154
9155        #[cfg(not(target_arch = "wasm32"))]
9156        {
9157            let mut executor = std::mem::take(&mut self.frame_graph_executor);
9158            let mut frame_graph = WgpuFrameGraph::new(Some("Renderer Frame Graph"));
9159            let surface = frame_graph.import_surface("renderer-surface");
9160            frame_graph.add_fallible_recorded_command_pass(
9161                Some("Renderer Frame Pass"),
9162                &[],
9163                &[surface],
9164                |frame_encoder| {
9165                    self.render_graph_recorded(surface_view, packet, returns, frame_encoder)
9166                },
9167            );
9168            let after_build = Instant::now();
9169            let execution = executor.execute_recorded_graph(&device, &queue, frame_graph);
9170            let after_execute = Instant::now();
9171            self.frame_graph_executor = executor;
9172            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
9173                log::warn!(
9174                    "[wgpu-render-stage:graph] total_ms={total_ms:.2} build_ms={:.2} execute_ms={:.2}",
9175                    instant_ms(graph_start, after_build),
9176                    instant_ms(after_build, after_execute),
9177                );
9178            }
9179
9180            match execution {
9181                Ok(execution) => {
9182                    if execution.stats.pass_count > 0 {
9183                        self.frame_stats.record_command_stats(execution.stats);
9184                    }
9185                    Ok(())
9186                }
9187                Err(crate::frame_graph::FrameGraphError::NoDeclaredPasses) => Ok(()),
9188                Err(error) => Err(error.to_string()),
9189            }
9190        }
9191
9192        #[cfg(target_arch = "wasm32")]
9193        {
9194            let mut executor = std::mem::take(&mut self.frame_graph_executor);
9195            let (result, execution) = {
9196                let mut frame_encoder =
9197                    executor.begin(&device, &queue, Some("Renderer Frame Encoder"));
9198                let initial_pass_count = frame_encoder.recorded_pass_count();
9199                let result =
9200                    self.render_graph_recorded(surface_view, packet, returns, &mut frame_encoder);
9201                let execution =
9202                    if result.is_ok() && frame_encoder.recorded_pass_count() > initial_pass_count {
9203                        Some(frame_encoder.finish())
9204                    } else {
9205                        None
9206                    };
9207                (result, execution)
9208            };
9209            let after_execute = Instant::now();
9210            self.frame_graph_executor = executor;
9211            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
9212                log::warn!("[wgpu-render-stage:graph] total_ms={total_ms:.2}",);
9213            }
9214            if let Some(execution) = execution {
9215                self.frame_stats.record_command_stats(execution.stats);
9216            }
9217            result
9218        }
9219    }
9220
9221    fn render_graph_recorded<C: FrameCommandRecorder>(
9222        &mut self,
9223        surface_view: &wgpu::TextureView,
9224        packet: FramePacket,
9225        returns: &mut RenderReturns,
9226        frame_encoder: &mut C,
9227    ) -> Result<(), String> {
9228        let recorded_start = Instant::now();
9229
9230        // Present-side consumption of the packet's replay plan, adjacent to
9231        // packet consumption: the store honors the ops just before the
9232        // packet renders. Gated on a Direct root — a Surface packet never
9233        // touched the planner and carries the empty default plan
9234        // (generation 0), which the store must not consume: it would count
9235        // a false generation drop. The ack travels back through `returns`
9236        // and the producer applies it right after this render call —
9237        // equivalent to the in-store drain this replaces, because both
9238        // application points sit after this frame's graph build and before
9239        // the next collect, which is where the bypass gate and `feed_slots`
9240        // are read. The threaded present runtime consumes EARLIER
9241        // (`take_replay_ack_early`, before surface acquire) and marks the
9242        // packet, so this block must not feed the taken-out default plan
9243        // to the store.
9244        #[cfg(not(target_arch = "wasm32"))]
9245        let mut packet = packet;
9246        #[cfg(not(target_arch = "wasm32"))]
9247        if !packet.replay_preconsumed {
9248            if let PacketRoot::Direct(root) = &packet.root {
9249                let ops = std::mem::take(&mut packet.replay);
9250                let (ack, recycled) = self.consume_replay_ops(
9251                    ops,
9252                    &root.scene.shapes,
9253                    &root.scene.brushes,
9254                    packet.root_scale,
9255                );
9256                returns.ack = Some((ack, recycled));
9257            }
9258        }
9259
9260        let FramePacket {
9261            frame_id,
9262            viewport: (width, height),
9263            renderer_epoch: _,
9264            surface_epoch: _,
9265            root_scale,
9266            root,
9267            overlay,
9268            replay: _,
9269            text_cache_len: _,
9270            recycled_confirmations: _,
9271            replay_preconsumed: _,
9272        } = packet;
9273
9274        let mut backend = RecordingSurfaceBackend {
9275            renderer: self,
9276            recorder: frame_encoder,
9277        };
9278
9279        let surface_packet = match root {
9280            PacketRoot::Direct(root) => {
9281                let direct_render_start = Instant::now();
9282                let result = match execute_render_root_direct(
9283                    &mut backend,
9284                    surface_view,
9285                    *root,
9286                    width,
9287                    height,
9288                    root_scale,
9289                    wgpu::LoadOp::Clear(CLEAR_COLOR),
9290                ) {
9291                    // Return the packet's scene buffers to the producer pool
9292                    // in BOTH arms — for a heavy animated frame they are
9293                    // megabytes of Vec, and an errored draw must not leak
9294                    // them.
9295                    Ok(scene) => {
9296                        returns.scene = Some(scene);
9297                        Ok(())
9298                    }
9299                    Err((error, scene)) => {
9300                        returns.scene = Some(scene);
9301                        Err(error)
9302                    }
9303                };
9304                if result.is_ok() {
9305                    if let Some(overlay) = overlay {
9306                        Self::render_overlay_packet(
9307                            &mut backend,
9308                            surface_view,
9309                            overlay,
9310                            width,
9311                            height,
9312                            root_scale,
9313                        )?;
9314                    }
9315                }
9316                let after_direct_render = Instant::now();
9317                if let Some(total_ms) =
9318                    should_log_wgpu_render_stage(recorded_start, after_direct_render)
9319                {
9320                    log::warn!(
9321                        "[wgpu-render-stage:recorded-direct-root] frame={frame_id} total_ms={total_ms:.2} render_ms={:.2}",
9322                        instant_ms(direct_render_start, after_direct_render),
9323                    );
9324                }
9325                return result;
9326            }
9327            PacketRoot::Surface(surface_packet) => surface_packet,
9328        };
9329        let after_root_collect = Instant::now();
9330
9331        let RootSurfacePacket {
9332            lowered,
9333            source,
9334            transform_to_parent,
9335            node_id,
9336            backdrop,
9337            graphics_layer,
9338            local_bounds,
9339            clip_rect,
9340            shadow_clip,
9341        } = *surface_packet;
9342        let mut lowered = lowered;
9343        lowered.source = source;
9344
9345        // The root layer's visible area is always the viewport — content
9346        // outside the screen is invisible regardless of scroll offsets or
9347        // inflated scene bounds.  Pass the viewport rect as an explicit
9348        // surface rect to prevent offscreen inflation on constrained GPUs.
9349        let viewport_rect = Rect {
9350            x: 0.0,
9351            y: 0.0,
9352            width: width as f32 / root_scale,
9353            height: height as f32 / root_scale,
9354        };
9355        let root_surface = execute_render_layer_surface(
9356            &mut backend,
9357            &mut lowered,
9358            LayerSurfaceRequest {
9359                root_scale,
9360                backdrop_underlay: None,
9361                allow_runtime_cache: false,
9362                logical_rect_override: Some(viewport_rect),
9363                capture_clip_override: None,
9364                activates_nested_capture: false,
9365                translation_context: TranslationRenderContext::default(),
9366            },
9367        )?;
9368        let root_quad = transform_to_parent.map_rect(root_surface.logical_rect);
9369        let root_dest_quad = scaled_quad(root_quad, root_scale);
9370
9371        let needs_root_composite_target =
9372            backdrop.is_some() || graphics_layer.shadow_elevation > 0.0;
9373
9374        if needs_root_composite_target {
9375            let composite_target = backend.acquire_frame_surface(width, height);
9376            backend.clear_target_view_with_load_op(
9377                &composite_target.view,
9378                wgpu::LoadOp::Clear(CLEAR_COLOR),
9379            );
9380
9381            if let Some(backdrop) = &backdrop {
9382                execute_apply_backdrop_layer_to_target(
9383                    &mut backend,
9384                    &composite_target,
9385                    &BackdropLayer {
9386                        node_id,
9387                        rect: quad_bounds(transform_to_parent.map_rect(local_bounds)),
9388                        clip: clip_rect.map(|clip| quad_bounds(transform_to_parent.map_rect(clip))),
9389                        snap_anchor: None,
9390                        effect: backdrop.clone(),
9391                        z_index: 0,
9392                    },
9393                    None,
9394                    width,
9395                    height,
9396                    root_scale,
9397                    None,
9398                )?;
9399            }
9400
9401            let mut root_shadow_scene = CompositorScene::new();
9402            let root_shadow_clip =
9403                shadow_clip.map(|clip| quad_bounds(transform_to_parent.map_rect(clip)));
9404            push_layer_shadow(
9405                &mut root_shadow_scene,
9406                &graphics_layer,
9407                local_bounds,
9408                quad_bounds(transform_to_parent.map_rect(local_bounds)),
9409                root_shadow_clip,
9410            );
9411            for shadow in &root_shadow_scene.shadow_draws {
9412                backend.render_shadow_draw(
9413                    &composite_target.view,
9414                    shadow,
9415                    width,
9416                    height,
9417                    root_scale,
9418                );
9419            }
9420
9421            let composite_dest_quad =
9422                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
9423            execute_composite_surface_to_view(
9424                &mut backend,
9425                root_surface.target.target(),
9426                &composite_target.view,
9427                (width, height),
9428                composite_dest_quad,
9429                root_surface.composite_alpha,
9430                wgpu::LoadOp::Load,
9431                None,
9432                root_surface.blend_mode,
9433                root_surface.sample_mode,
9434            )?;
9435            backend.composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
9436                &composite_target,
9437                surface_view,
9438                1.0,
9439                wgpu::LoadOp::Clear(CLEAR_COLOR),
9440                None,
9441                None,
9442                BlendMode::SrcOver,
9443                None,
9444                CompositeSampleMode::Linear,
9445            );
9446            backend.release_frame_surface(composite_target);
9447        } else {
9448            let composite_dest_quad =
9449                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
9450            execute_composite_surface_to_view(
9451                &mut backend,
9452                root_surface.target.target(),
9453                surface_view,
9454                (width, height),
9455                composite_dest_quad,
9456                root_surface.composite_alpha,
9457                wgpu::LoadOp::Clear(CLEAR_COLOR),
9458                None,
9459                root_surface.blend_mode,
9460                root_surface.sample_mode,
9461            )?;
9462        }
9463        backend.release_layer_surface_target(root_surface.target);
9464        if let Some(overlay) = overlay {
9465            Self::render_overlay_packet(
9466                &mut backend,
9467                surface_view,
9468                overlay,
9469                width,
9470                height,
9471                root_scale,
9472            )?;
9473        }
9474        let after_layer_render = Instant::now();
9475        if let Some(total_ms) = should_log_wgpu_render_stage(recorded_start, after_layer_render) {
9476            log::warn!(
9477                "[wgpu-render-stage:recorded-layer-root] total_ms={total_ms:.2} collect_ms={:.2} render_ms={:.2}",
9478                instant_ms(recorded_start, after_root_collect),
9479                instant_ms(after_root_collect, after_layer_render),
9480            );
9481        }
9482        Ok(())
9483    }
9484
9485    /// Renders the producer-lowered dev overlay on top of the frame. The
9486    /// packet carries the collected overlay; the backend only validates
9487    /// that it stayed directly renderable and draws it.
9488    fn render_overlay_packet<C: FrameCommandRecorder>(
9489        backend: &mut RecordingSurfaceBackend<'_, '_, C>,
9490        surface_view: &wgpu::TextureView,
9491        overlay: CollectedLayer,
9492        width: u32,
9493        height: u32,
9494        root_scale: f32,
9495    ) -> Result<(), String> {
9496        if !overlay.child_layers.is_empty()
9497            || !root_direct_scene_events_are_supported(&overlay.scene)
9498            || !direct_root_child_underlays_are_supported(&overlay)
9499        {
9500            return Err("dev overlay graph must stay directly renderable".to_string());
9501        }
9502        execute_render_root_direct(
9503            backend,
9504            surface_view,
9505            overlay,
9506            width,
9507            height,
9508            root_scale,
9509            wgpu::LoadOp::Load,
9510        )
9511        .map(|_overlay_scene| ())
9512        .map_err(|(error, _overlay_scene)| error)
9513    }
9514
9515    #[allow(clippy::too_many_arguments)]
9516    fn encode_non_effect_segment_commands<C: FrameCommandRecorder>(
9517        &mut self,
9518        frame_encoder: &mut C,
9519        target_view: &wgpu::TextureView,
9520        ordered_items: &[(usize, SegmentDrawItem)],
9521        composites: &[(usize, CompositeBatchItem<'_>)],
9522        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9523        shapes: &[DrawShape],
9524        brushes: &[Brush],
9525        images: &[ImageDraw],
9526        texts: &[TextDraw],
9527        shadow_draws: &[ShadowDraw],
9528        retained_draws: &[RetainedDraw],
9529        initial_load_op: wgpu::LoadOp<wgpu::Color>,
9530        width: u32,
9531        height: u32,
9532        root_scale: f32,
9533    ) -> Result<SegmentCommandEncodeOutcome, String> {
9534        let mut first_batch = true;
9535        for command in
9536            SegmentCommandIter::new(ordered_items, shapes, images, self.shape_batch_limits)
9537        {
9538            match command {
9539                SegmentRenderCommand::DrawChunk(chunk) => {
9540                    let load_op = if first_batch {
9541                        initial_load_op
9542                    } else {
9543                        wgpu::LoadOp::Load
9544                    };
9545                    let outcome = self.render_segment_draw_chunk(
9546                        frame_encoder,
9547                        target_view,
9548                        ordered_items,
9549                        composites,
9550                        shader_composites,
9551                        shapes,
9552                        brushes,
9553                        images,
9554                        texts,
9555                        retained_draws,
9556                        chunk,
9557                        width,
9558                        height,
9559                        root_scale,
9560                        load_op,
9561                    )?;
9562                    if outcome.rendered_any {
9563                        frame_encoder.record_passes(outcome.pass_count);
9564                        first_batch = false;
9565                    }
9566                }
9567                SegmentRenderCommand::Shadow(index) => {
9568                    if first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
9569                        {
9570                            let _clear = frame_encoder.encoder().begin_render_pass(
9571                                &wgpu::RenderPassDescriptor {
9572                                    label: Some("Shadow Pre-Clear"),
9573                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9574                                        view: target_view,
9575                                        resolve_target: None,
9576                                        depth_slice: None,
9577                                        ops: wgpu::Operations {
9578                                            load: initial_load_op,
9579                                            store: wgpu::StoreOp::Store,
9580                                        },
9581                                    })],
9582                                    depth_stencil_attachment: None,
9583                                    timestamp_writes: None,
9584                                    occlusion_query_set: None,
9585                                    multiview_mask: None,
9586                                },
9587                            );
9588                        }
9589                        frame_encoder.record_pass();
9590                        first_batch = false;
9591                    }
9592                    let pass_count_before = frame_encoder.recorded_pass_count();
9593                    self.encode_shadow_draw(
9594                        frame_encoder,
9595                        target_view,
9596                        &shadow_draws[index],
9597                        width,
9598                        height,
9599                        root_scale,
9600                    );
9601                    if frame_encoder.recorded_pass_count() > pass_count_before {
9602                        first_batch = false;
9603                    }
9604                }
9605            }
9606        }
9607        Ok(SegmentCommandEncodeOutcome { first_batch })
9608    }
9609
9610    #[cfg(not(target_arch = "wasm32"))]
9611    #[allow(clippy::too_many_arguments)]
9612    fn render_segment_draw_chunk_fused_native<C: FrameCommandRecorder>(
9613        &mut self,
9614        frame_encoder: &mut C,
9615        target_view: &wgpu::TextureView,
9616        ordered_items: &[(usize, SegmentDrawItem)],
9617        composites: &[(usize, CompositeBatchItem<'_>)],
9618        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9619        shapes: &[DrawShape],
9620        brushes: &[Brush],
9621        images: &[ImageDraw],
9622        texts: &[TextDraw],
9623        retained_draws: &[RetainedDraw],
9624        chunk: &SegmentDrawChunkPlan,
9625        width: u32,
9626        height: u32,
9627        root_scale: f32,
9628        load_op: wgpu::LoadOp<wgpu::Color>,
9629    ) -> Result<Option<SegmentRenderOutcome>, String> {
9630        let Some(partitions) = native_segment_fusion_partitions(
9631            ordered_items,
9632            shapes,
9633            brushes,
9634            chunk,
9635            self.shape_batch_limits,
9636        )?
9637        else {
9638            return Ok(None);
9639        };
9640
9641        let mut rendered_any = false;
9642        let mut pass_count = 0_u32;
9643        let mut next_load_op = load_op;
9644        let encode_started = Instant::now();
9645        let mut partition_count = 0_u64;
9646        for partition in partitions {
9647            partition_count += 1;
9648            let outcome = self.render_segment_draw_chunk_fused_native_partition(
9649                frame_encoder,
9650                target_view,
9651                ordered_items,
9652                composites,
9653                shader_composites,
9654                shapes,
9655                brushes,
9656                images,
9657                texts,
9658                retained_draws,
9659                &partition.chunk,
9660                partition.budget,
9661                width,
9662                height,
9663                root_scale,
9664                next_load_op,
9665            )?;
9666            if outcome.rendered_any {
9667                rendered_any = true;
9668                pass_count = pass_count.saturating_add(outcome.pass_count);
9669                next_load_op = wgpu::LoadOp::Load;
9670            }
9671        }
9672
9673        self.segment_encode_stats
9674            .note_call(partition_count, encode_started.elapsed().as_micros() as u64);
9675
9676        Ok(Some(SegmentRenderOutcome {
9677            rendered_any,
9678            pass_count,
9679        }))
9680    }
9681
9682    #[cfg(not(target_arch = "wasm32"))]
9683    #[allow(clippy::too_many_arguments)]
9684    fn render_segment_draw_chunk_fused_native_partition<C: FrameCommandRecorder>(
9685        &mut self,
9686        frame_encoder: &mut C,
9687        target_view: &wgpu::TextureView,
9688        ordered_items: &[(usize, SegmentDrawItem)],
9689        composites: &[(usize, CompositeBatchItem<'_>)],
9690        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9691        shapes: &[DrawShape],
9692        brushes: &[Brush],
9693        images: &[ImageDraw],
9694        texts: &[TextDraw],
9695        retained_draws: &[RetainedDraw],
9696        chunk: &SegmentDrawChunkPlan,
9697        budget: NativeSegmentFusionBudget,
9698        width: u32,
9699        height: u32,
9700        root_scale: f32,
9701        load_op: wgpu::LoadOp<wgpu::Color>,
9702    ) -> Result<SegmentRenderOutcome, String> {
9703        let partition_start = Instant::now();
9704        let mut staged_uploads = self.take_staged_uploads();
9705        staged_uploads.clear();
9706        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
9707        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
9708        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
9709        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
9710        // Moved out like the scratch vecs: the span blit borrows the cached
9711        // texture across the render pass while `self` stays mutably usable.
9712        let mut span_cache = std::mem::take(&mut self.static_span);
9713        // Moved out for the same reason: prepared segment composites borrow
9714        // entry textures across the render pass.
9715        let mut segment_surfaces = std::mem::take(&mut self.segment_surfaces);
9716
9717        image_vertices.clear();
9718        image_indices.clear();
9719        image_cmds.clear();
9720        glyph_cmds.clear();
9721
9722        let result = (|| {
9723            let viewport = ViewportUniformParams {
9724                width,
9725                height,
9726                offset: [0.0, 0.0],
9727            };
9728            self.prewarm_offscreen_text_glyph_draws_in_chunk(
9729                ordered_items,
9730                texts,
9731                chunk,
9732                viewport,
9733                root_scale,
9734                &mut staged_uploads,
9735                &mut image_vertices,
9736                &mut image_indices,
9737                &mut glyph_cmds,
9738            )?;
9739            let mut shape_refs = Vec::with_capacity(budget.shape_count);
9740            for batch in chunk.iter() {
9741                let SegmentBatchPlan::Shape { start, end, .. } = batch else {
9742                    continue;
9743                };
9744                for (_, item) in &ordered_items[start..end] {
9745                    let SegmentDrawItem::Shape(shape_index) = item else {
9746                        return Err(format!(
9747                            "shape batch contains non-shape draw item: {item:?}"
9748                        ));
9749                    };
9750                    shape_refs.push(&shapes[*shape_index]);
9751                }
9752            }
9753            let after_shape_refs = Instant::now();
9754
9755            let mut direct_shape_uploads = StagedBufferUploads::default();
9756            let mut shape_upload_base = 0u64;
9757            if !shape_refs.is_empty() {
9758                let Some((_, upload_base)) = self.prepare_shapes_batch_direct(
9759                    frame_encoder,
9760                    shape_refs.iter().copied(),
9761                    brushes,
9762                    root_scale,
9763                    viewport,
9764                    &mut direct_shape_uploads,
9765                ) else {
9766                    return Err(
9767                        "native fused segment shape preparation produced no draw batch".to_string(),
9768                    );
9769                };
9770                shape_upload_base = upload_base;
9771            }
9772            let after_shape_prepare = Instant::now();
9773
9774            // Segment-surface phase 1 (CRANPOSE_SEGMENT_SURFACE opt-in, see
9775            // `crate::segment_surface`): per retained item of this
9776            // partition, decide cached-composite vs direct, install/refresh
9777            // entries, and stage this frame's capture transforms. Runs
9778            // BEFORE the batch-prepare loop because recolor dirtiness is
9779            // read from the frame's still-parked patch list, which the
9780            // Retained prepare arm drains (`stage_replay_patches`).
9781            let mut segment_captures: Vec<SegmentCaptureJob> = Vec::new();
9782            let mut segment_composite_plans: Vec<(usize, SegmentCompositePlan)> = Vec::new();
9783            if segment_surfaces.enabled() {
9784                self.plan_segment_surfaces(
9785                    &mut segment_surfaces,
9786                    ordered_items,
9787                    chunk,
9788                    retained_draws,
9789                    &mut staged_uploads,
9790                    &mut segment_captures,
9791                    &mut segment_composite_plans,
9792                );
9793            }
9794
9795            // Opaque static leading-span cache: decide once per frame, on
9796            // the partition carrying the frame's opaque clear, whether the
9797            // leading run of converted records matches the cached span
9798            // composite (skip them, blit instead), repeated byte-identically
9799            // from last frame (draw live, then capture), or neither.
9800            let first_batch_info = match chunk.batches.first() {
9801                Some(&SegmentBatchPlan::Shape {
9802                    start,
9803                    end,
9804                    blend_mode,
9805                }) => {
9806                    let mut has_gradient = false;
9807                    for (_, item) in &ordered_items[start..end] {
9808                        if let SegmentDrawItem::Shape(shape_index) = item {
9809                            has_gradient |=
9810                                shape_gradient_stop_count(&shapes[*shape_index], brushes) > 0;
9811                        }
9812                    }
9813                    Some((end - start, blend_mode, has_gradient))
9814                }
9815                _ => None,
9816            };
9817            let span_decision = span_cache.engage(
9818                load_op,
9819                first_batch_info,
9820                width,
9821                height,
9822                &self.scratch_shape_data,
9823                &self.scratch_gradients,
9824            );
9825            let span_skip = match span_decision {
9826                StaticSpanDecision::Hit { skip } => {
9827                    if fill_area_diag_enabled() {
9828                        // The skipped quads were counted at batch prepare;
9829                        // the replacing blit is an effect-renderer
9830                        // composite, which the instrument's policy does not
9831                        // count.
9832                        self.fill_area_diag
9833                            .note_static_span_skip(&self.scratch_shape_data[..skip]);
9834                    }
9835                    skip
9836                }
9837                _ => 0,
9838            };
9839
9840            // Transient rim band meshes: scan the freshly converted shapes
9841            // (still in `scratch_shape_data` after
9842            // `prepare_shapes_batch_direct`) for huge circle rims and give
9843            // each a band mesh covering ring ± AA margin instead of its full
9844            // bounding quad. Kill switch read once per chunk; the mesh
9845            // pipeline exists in storage mode only and blends SrcOver only,
9846            // hence the two extra gates at the batch arm below.
9847            let rim_mesh_on = rim_mesh_enabled();
9848            let mut chunk_rims: Vec<RimDraw> = Vec::new();
9849
9850            let mut fused_batches = Vec::with_capacity(chunk.batches.len());
9851            let mut shape_cursor = 0_u32;
9852            let mut composite_cursor = 0usize;
9853            let mut shader_composite_cursor = 0usize;
9854            for (batch_index, batch) in chunk.iter().enumerate() {
9855                match batch {
9856                    SegmentBatchPlan::Shape {
9857                        start,
9858                        end,
9859                        blend_mode,
9860                    } => {
9861                        let mut has_gradient = false;
9862                        for (_, item) in &ordered_items[start..end] {
9863                            let SegmentDrawItem::Shape(shape_index) = item else {
9864                                return Err(format!(
9865                                    "shape batch contains non-shape draw item: {item:?}"
9866                                ));
9867                            };
9868                            has_gradient |=
9869                                shape_gradient_stop_count(&shapes[*shape_index], brushes) > 0;
9870                        }
9871                        // A span hit skips the leading shapes of the FIRST
9872                        // batch only: they stay in the upload (indices of
9873                        // everything after them are untouched) but the draw
9874                        // range starts past them.
9875                        let skip = if batch_index == 0 { span_skip } else { 0 };
9876                        let shape_count = end - start;
9877                        if shape_count > 0 {
9878                            if rim_mesh_on
9879                                && self.instanced_quads.is_some()
9880                                && blend_mode == BlendMode::SrcOver
9881                            {
9882                                for offset in skip..shape_count {
9883                                    // The index `vs_mesh` reads into the
9884                                    // storage shape array: position within
9885                                    // the whole fused upload (shape_refs
9886                                    // order == scratch_shape_data order).
9887                                    let global_index = shape_cursor + offset as u32;
9888                                    let converted = &self.scratch_shape_data[global_index as usize];
9889                                    let Some(band) = rim_mesh_band(converted) else {
9890                                        continue;
9891                                    };
9892                                    let vertex_mark = self.rim_mesh_vertices.len();
9893                                    let index_mark = self.rim_mesh_indices.len();
9894                                    if emit_arc_band_mesh(
9895                                        converted,
9896                                        global_index,
9897                                        &band,
9898                                        &mut self.rim_mesh_vertices,
9899                                        &mut self.rim_mesh_indices,
9900                                    )
9901                                    .is_none()
9902                                    {
9903                                        // Nothing emitted (fully clipped) —
9904                                        // the quad path draws it as today.
9905                                        self.rim_mesh_vertices.truncate(vertex_mark);
9906                                        self.rim_mesh_indices.truncate(index_mark);
9907                                        continue;
9908                                    }
9909                                    if self.rim_mesh_vertices.len() > RIM_MESH_VERTEX_CAPACITY
9910                                        || self.rim_mesh_indices.len() > RIM_MESH_INDEX_CAPACITY
9911                                    {
9912                                        // Whole-rim rollback, never a
9913                                        // truncation: a partial band would
9914                                        // break the containment invariant.
9915                                        self.rim_mesh_vertices.truncate(vertex_mark);
9916                                        self.rim_mesh_indices.truncate(index_mark);
9917                                        rim_mesh_capacity_warn();
9918                                        continue;
9919                                    }
9920                                    chunk_rims.push(RimDraw {
9921                                        shape_index: global_index,
9922                                        first_index: index_mark as u32,
9923                                        index_count: (self.rim_mesh_indices.len() - index_mark)
9924                                            as u32,
9925                                    });
9926                                    if fill_area_diag_enabled() {
9927                                        self.fill_area_diag.note_rim_mesh(
9928                                            converted,
9929                                            triangles_shoelace_area(
9930                                                &self.rim_mesh_vertices,
9931                                                &self.rim_mesh_indices[index_mark..],
9932                                            ),
9933                                        );
9934                                    }
9935                                    self.rim_meshes_emitted += 1;
9936                                    if self.rim_meshes_emitted % 600 == 1 {
9937                                        log::debug!(
9938                                            "[rim-mesh] {} rims meshed lifetime ({} verts live this frame)",
9939                                            self.rim_meshes_emitted,
9940                                            self.rim_mesh_vertices.len(),
9941                                        );
9942                                    }
9943                                }
9944                            }
9945                            if shape_count > skip {
9946                                fused_batches.push(FusedSegmentBatch::Shape {
9947                                    batch: PreparedShapeBatch {
9948                                        vertex_start: (shape_cursor + skip as u32) * 6,
9949                                        vertex_count: (shape_count - skip) as u32 * 6,
9950                                        has_gradient,
9951                                    },
9952                                    blend_mode,
9953                                });
9954                            }
9955                            shape_cursor += shape_count as u32;
9956                        }
9957                    }
9958                    SegmentBatchPlan::Image {
9959                        start,
9960                        end,
9961                        blend_mode,
9962                    } => {
9963                        let cmd_start = image_cmds.len();
9964                        for (_, item) in &ordered_items[start..end] {
9965                            let SegmentDrawItem::Image(image_index) = item else {
9966                                return Err(format!(
9967                                    "image batch contains non-image draw item: {item:?}"
9968                                ));
9969                            };
9970                            self.append_image_draw_cmd(
9971                                &images[*image_index],
9972                                viewport,
9973                                root_scale,
9974                                &mut image_vertices,
9975                                &mut image_indices,
9976                                &mut image_cmds,
9977                            )?;
9978                        }
9979                        let cmd_end = image_cmds.len();
9980                        if cmd_start < cmd_end {
9981                            fused_batches.push(FusedSegmentBatch::Image {
9982                                cmd_range: cmd_start..cmd_end,
9983                                blend_mode,
9984                            });
9985                        }
9986                    }
9987                    SegmentBatchPlan::Text { start, end } => {
9988                        let glyph_cmd_start = glyph_cmds.len();
9989                        let image_cmd_start = image_cmds.len();
9990                        let text_draws =
9991                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
9992                        if !self.append_text_glyph_draws(
9993                            text_draws,
9994                            viewport,
9995                            root_scale,
9996                            false,
9997                            &mut staged_uploads,
9998                            &mut image_vertices,
9999                            &mut image_indices,
10000                            &mut glyph_cmds,
10001                        )? {
10002                            let text_draws =
10003                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10004                            self.append_text_image_draw_cmds(
10005                                text_draws,
10006                                viewport,
10007                                root_scale,
10008                                &mut image_vertices,
10009                                &mut image_indices,
10010                                &mut image_cmds,
10011                            )?;
10012                        }
10013                        let image_cmd_end = image_cmds.len();
10014                        let glyph_cmd_end = glyph_cmds.len();
10015                        if image_cmd_start < image_cmd_end || glyph_cmd_start < glyph_cmd_end {
10016                            fused_batches.push(FusedSegmentBatch::Text {
10017                                image_cmd_range: image_cmd_start..image_cmd_end,
10018                                glyph_cmd_range: glyph_cmd_start..glyph_cmd_end,
10019                            });
10020                        }
10021                    }
10022                    SegmentBatchPlan::Composite { start, end } => {
10023                        for (_, item) in &ordered_items[start..end] {
10024                            if !matches!(item, SegmentDrawItem::Composite(_)) {
10025                                return Err(format!(
10026                                    "composite batch contains non-composite draw item: {item:?}"
10027                                ));
10028                            }
10029                        }
10030                        let draw_count = end - start;
10031                        if draw_count > 0 {
10032                            let draw_start = composite_cursor;
10033                            composite_cursor += draw_count;
10034                            fused_batches.push(FusedSegmentBatch::Composite {
10035                                draw_range: draw_start..composite_cursor,
10036                            });
10037                        }
10038                    }
10039                    SegmentBatchPlan::ShaderComposite { start, end } => {
10040                        for (_, item) in &ordered_items[start..end] {
10041                            if !matches!(item, SegmentDrawItem::ShaderComposite(_)) {
10042                                return Err(format!(
10043                                    "shader composite batch contains non-shader-composite draw item: {item:?}"
10044                                ));
10045                            }
10046                        }
10047                        let draw_count = end - start;
10048                        if draw_count > 0 {
10049                            let draw_start = shader_composite_cursor;
10050                            shader_composite_cursor += draw_count;
10051                            fused_batches.push(FusedSegmentBatch::ShaderComposite {
10052                                draw_range: draw_start..shader_composite_cursor,
10053                            });
10054                        }
10055                    }
10056                    SegmentBatchPlan::Retained { start, end } => {
10057                        self.stage_replay_patches(&mut staged_uploads);
10058                        for (_, item) in &ordered_items[start..end] {
10059                            let SegmentDrawItem::Retained(index) = item else {
10060                                return Err(format!(
10061                                    "retained batch contains non-retained draw item: {item:?}"
10062                                ));
10063                            };
10064                            let retained = retained_draws.get(*index).ok_or_else(|| {
10065                                format!("retained draw index {index} out of bounds")
10066                            })?;
10067                            if (*index as u32) < MAX_REPLAY_SLOTS
10068                                && self.replay_slots.slots.contains_key(&retained.slot)
10069                            {
10070                                let transform = retained.transform.with_retained_paint();
10071                                staged_uploads.stage_at(
10072                                    UploadTarget::ReplayTransform,
10073                                    *index as u64 * REPLAY_TRANSFORM_STRIDE,
10074                                    bytemuck::bytes_of(&transform),
10075                                );
10076                            }
10077                        }
10078                        if end > start {
10079                            fused_batches.push(FusedSegmentBatch::Retained {
10080                                item_range: start..end,
10081                            });
10082                        }
10083                    }
10084                }
10085            }
10086            if !chunk_rims.is_empty() {
10087                self.upload_transient_rim_meshes();
10088            }
10089            let after_batch_prepare = Instant::now();
10090
10091            if !image_indices.is_empty() {
10092                self.stage_native_image_buffers(
10093                    &mut staged_uploads,
10094                    viewport,
10095                    &image_vertices,
10096                    &image_indices,
10097                );
10098            }
10099
10100            // Display clip region cull: engages exactly when this fused
10101            // pass draws the frame's root surface whole and the platform
10102            // reported a cullable visible region (the round display being
10103            // the first provider). The pass then carries a transient depth
10104            // attachment, the region complement's occluder is drawn first,
10105            // and every pipeline below is fetched in its depth-tested
10106            // variant (the getters read `pass_depth`). Offscreen/layer
10107            // passes never reach this branch with a `Some` here.
10108            let display_clip_depth_view =
10109                self.display_clip_pass_depth_view(target_view, width, height);
10110            let pass_depth = display_clip_depth_view.is_some();
10111
10112            let device = self.device.clone();
10113            let composite_items: Vec<_> = chunk
10114                .iter()
10115                .filter_map(|batch| match batch {
10116                    SegmentBatchPlan::Composite { start, end } => Some((start, end)),
10117                    _ => None,
10118                })
10119                .flat_map(|(start, end)| {
10120                    ordered_items[start..end].iter().filter_map(|(_, item)| {
10121                        let SegmentDrawItem::Composite(composite_index) = item else {
10122                            return None;
10123                        };
10124                        composites
10125                            .get(*composite_index)
10126                            .map(|(_, composite)| *composite)
10127                    })
10128                })
10129                .collect();
10130            let prepared_composites = self.effect_renderer.prepare_composite_batch_draws(
10131                frame_encoder,
10132                &device,
10133                load_op,
10134                &composite_items,
10135                pass_depth,
10136            );
10137            let shader_items: Vec<_> = chunk
10138                .iter()
10139                .filter_map(|batch| match batch {
10140                    SegmentBatchPlan::ShaderComposite { start, end } => Some((start, end)),
10141                    _ => None,
10142                })
10143                .flat_map(|(start, end)| {
10144                    ordered_items[start..end].iter().filter_map(|(_, item)| {
10145                        let SegmentDrawItem::ShaderComposite(composite_index) = item else {
10146                            return None;
10147                        };
10148                        shader_composites
10149                            .get(*composite_index)
10150                            .map(|(_, composite)| *composite)
10151                    })
10152                })
10153                .collect();
10154            let prepared_shaders = self
10155                .effect_renderer
10156                .prepare_shader_batch_draws(frame_encoder, &device, &shader_items, pass_depth)
10157                .ok_or_else(|| "shader composite batch preparation failed".to_string())?;
10158            if !shader_items.is_empty() {
10159                self.effect_renderer.record_composite_pass();
10160                self.effect_renderer
10161                    .debug_effects
10162                    .set(self.effect_renderer.debug_effects.get() + shader_items.len() as u32);
10163            }
10164            // Span hit: prepare the cached-texture blit that stands in for
10165            // the skipped shapes. Reuses the effect renderer's composite
10166            // machinery — the same prepared-draw path the Composite arms
10167            // ride — with Nearest sampling (an exact `textureLoad`), alpha
10168            // 1.0, no mask, no viewports: a 1:1 full-target replace-write
10169            // of alpha-255 texels (see `StaticSpanCache`).
10170            let span_blit_items =
10171                span_cache
10172                    .texture
10173                    .as_ref()
10174                    .filter(|_| span_skip > 0)
10175                    .map(|texture| CompositeBatchItem {
10176                        source: texture,
10177                        alpha: 1.0,
10178                        scissor: None,
10179                        rounded_mask: None,
10180                        blend_mode: BlendMode::SrcOver,
10181                        dest_viewport: None,
10182                        source_viewport: None,
10183                        sample_mode: CompositeSampleMode::Nearest,
10184                    });
10185            let span_blit = match &span_blit_items {
10186                Some(item) => self.effect_renderer.prepare_composite_batch_draws(
10187                    frame_encoder,
10188                    &device,
10189                    load_op,
10190                    std::slice::from_ref(item),
10191                    pass_depth,
10192                ),
10193                None => Vec::new(),
10194            };
10195            // Segment-surface phase 2: prepared rotated-quad composites for
10196            // the cached spans. Each is drawn inside the fused pass at its
10197            // span's exact batch position (see the Retained draw arm), so
10198            // interleaved z order is preserved by construction.
10199            let mut prepared_segment_composites: Vec<(usize, PreparedProjectiveComposite<'_>)> =
10200                Vec::with_capacity(segment_composite_plans.len());
10201            for (index, plan) in &segment_composite_plans {
10202                let Some(entry) = segment_surfaces.entry(&plan.key) else {
10203                    continue;
10204                };
10205                let item = ProjectiveCompositeItem {
10206                    source: &entry.texture,
10207                    viewport: (width, height),
10208                    dest_quad: plan.dest_quad,
10209                    inverse: plan.inverse,
10210                    alpha: 1.0,
10211                    blend_mode: BlendMode::SrcOver,
10212                    // An identity effective transform samples through the
10213                    // exact textureLoad path; motion samples bilinear.
10214                    sample_mode: if plan.identity {
10215                        CompositeSampleMode::Nearest
10216                    } else {
10217                        CompositeSampleMode::Linear
10218                    },
10219                };
10220                let prepared = self.effect_renderer.prepare_projective_composite_draw(
10221                    frame_encoder,
10222                    &device,
10223                    &item,
10224                    pass_depth,
10225                );
10226                prepared_segment_composites.push((*index, prepared));
10227            }
10228            let after_composite_prepare = Instant::now();
10229
10230            if fused_batches.is_empty() && span_blit.is_empty() {
10231                return Ok(SegmentRenderOutcome {
10232                    rendered_any: false,
10233                    pass_count: 0,
10234                });
10235            }
10236
10237            // The direct shape copies must be recorded before the staged
10238            // flush: its capacity check may replace `upload_buffer`, and the
10239            // shape payload was written into the buffer that existed at
10240            // prepare time. Recording first binds the copies to that buffer.
10241            self.flush_staged_uploads_at(
10242                frame_encoder.encoder(),
10243                &direct_shape_uploads,
10244                shape_upload_base,
10245            );
10246            let upload_offset =
10247                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10248            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
10249            let after_upload = Instant::now();
10250
10251            // Segment-surface phase 3: encode this frame's capture passes —
10252            // after the staged flush (their transforms and this frame's
10253            // recolor patches ride it), before the fused pass that samples
10254            // the surfaces. A recolored span therefore invalidates,
10255            // recaptures and composites within ONE frame, and the fused
10256            // pass never samples a stale surface. `pass_depth` is not yet
10257            // set on the pipeline getters here, so the capture walk fetches
10258            // the ordinary flat pipeline variants.
10259            let mut segment_capture_passes = 0u32;
10260            for job in &segment_captures {
10261                let Some(entry) = segment_surfaces.entry(&job.key) else {
10262                    continue;
10263                };
10264                let Some(slot) = self.replay_slots.slots.get(&job.key.slot) else {
10265                    continue;
10266                };
10267                let Some(uniform_group) =
10268                    segment_surfaces.capture_uniform_bind_group(job.capture_index)
10269                else {
10270                    continue;
10271                };
10272                let mut capture_pass =
10273                    frame_encoder
10274                        .encoder()
10275                        .begin_render_pass(&wgpu::RenderPassDescriptor {
10276                            label: Some("Segment Surface Capture Pass"),
10277                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10278                                view: &entry.texture.view,
10279                                resolve_target: None,
10280                                depth_slice: None,
10281                                ops: wgpu::Operations {
10282                                    // Transparent clear: the surface holds
10283                                    // the span's premultiplied flattening
10284                                    // and nothing else.
10285                                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
10286                                    store: wgpu::StoreOp::Store,
10287                                },
10288                            })],
10289                            depth_stencil_attachment: None,
10290                            timestamp_writes: None,
10291                            occlusion_query_set: None,
10292                            multiview_mask: None,
10293                        });
10294                let draws = self.encode_retained_op(
10295                    slot,
10296                    job.first,
10297                    job.last,
10298                    MAX_REPLAY_SLOTS + job.capture_index,
10299                    &mut |cmd| match cmd {
10300                        // The capture retargets bind group 0 to its
10301                        // sub-viewport uniforms (viewport_offset maps the
10302                        // capture rect onto the surface); everything else
10303                        // is the IDENTICAL walk the direct draw encodes.
10304                        RetainedCmd::Uniforms(_) => {
10305                            capture_pass.set_bind_group(0, uniform_group, &[])
10306                        }
10307                        RetainedCmd::Pipeline(pipeline) => capture_pass.set_pipeline(pipeline),
10308                        RetainedCmd::SlotBindings(group, offset) => {
10309                            capture_pass.set_bind_group(1, group, &[offset])
10310                        }
10311                        RetainedCmd::MeshVertices(buffer) => {
10312                            capture_pass.set_vertex_buffer(0, buffer.slice(..))
10313                        }
10314                        RetainedCmd::Index(buffer, format) => {
10315                            capture_pass.set_index_buffer(buffer.slice(..), format)
10316                        }
10317                        RetainedCmd::Draw(vertices) => capture_pass.draw(vertices, 0..1),
10318                        RetainedCmd::DrawIndexed(indices, instances) => {
10319                            capture_pass.draw_indexed(indices, 0, instances)
10320                        }
10321                    },
10322                );
10323                self.frame_stats.add_draw_calls(draws);
10324                segment_capture_passes += 1;
10325            }
10326
10327            let use_retained_bundles = retained_bundles_enabled();
10328            let mut retained_encode_ms = 0.0_f64;
10329            {
10330                let mut render_pass =
10331                    frame_encoder
10332                        .encoder()
10333                        .begin_render_pass(&wgpu::RenderPassDescriptor {
10334                            label: Some("Fused Segment Draw Pass"),
10335                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10336                                view: target_view,
10337                                resolve_target: None,
10338                                depth_slice: None,
10339                                ops: wgpu::Operations {
10340                                    load: load_op,
10341                                    store: wgpu::StoreOp::Store,
10342                                },
10343                            })],
10344                            // Clear + Discard: the display-clip depth
10345                            // buffer is born and dies inside this pass — on
10346                            // tiled GPUs it never leaves GMEM.
10347                            depth_stencil_attachment: display_clip_depth_view.as_ref().map(
10348                                |view| wgpu::RenderPassDepthStencilAttachment {
10349                                    view,
10350                                    depth_ops: Some(wgpu::Operations {
10351                                        load: wgpu::LoadOp::Clear(
10352                                            crate::display_clip::DISPLAY_CLIP_DEPTH_CLEAR,
10353                                        ),
10354                                        store: wgpu::StoreOp::Discard,
10355                                    }),
10356                                    stencil_ops: None,
10357                                },
10358                            ),
10359                            timestamp_writes: None,
10360                            occlusion_query_set: None,
10361                            multiview_mask: None,
10362                        });
10363
10364                // The cached span composite replaces the frame's leading
10365                // draws, so it goes down before every fused batch — same
10366                // z position the skipped shapes held.
10367                for draw in &span_blit {
10368                    self.effect_renderer.draw_prepared_composite(
10369                        &mut render_pass,
10370                        (width, height),
10371                        draw,
10372                        pass_depth,
10373                    );
10374                }
10375                if pass_depth {
10376                    // The occluder must be the pass's first draw: everything
10377                    // after it depth-tests against the region it wrote.
10378                    self.draw_display_clip_occluder(&mut render_pass, width, height);
10379                    self.display_clip.pass_depth.set(true);
10380                }
10381
10382                for batch in &fused_batches {
10383                    match batch {
10384                        FusedSegmentBatch::Shape { batch, blend_mode } => {
10385                            self.draw_prepared_shapes(
10386                                &mut render_pass,
10387                                *blend_mode,
10388                                *batch,
10389                                width,
10390                                height,
10391                                &chunk_rims,
10392                            );
10393                        }
10394                        FusedSegmentBatch::Image {
10395                            cmd_range,
10396                            blend_mode,
10397                        } => {
10398                            self.draw_native_prepared_image_cmd_range(
10399                                &mut render_pass,
10400                                &image_cmds,
10401                                cmd_range.clone(),
10402                                *blend_mode,
10403                            )?;
10404                        }
10405                        FusedSegmentBatch::Text {
10406                            image_cmd_range,
10407                            glyph_cmd_range,
10408                        } => {
10409                            if !image_cmd_range.is_empty() {
10410                                self.draw_native_prepared_image_cmd_range(
10411                                    &mut render_pass,
10412                                    &image_cmds,
10413                                    image_cmd_range.clone(),
10414                                    BlendMode::SrcOver,
10415                                )?;
10416                                self.frame_stats.bump_text();
10417                            }
10418                            if !glyph_cmd_range.is_empty() {
10419                                self.draw_native_prepared_glyph_cmd_range(
10420                                    &mut render_pass,
10421                                    &glyph_cmds,
10422                                    glyph_cmd_range.clone(),
10423                                )?;
10424                            }
10425                        }
10426                        FusedSegmentBatch::Composite { draw_range } => {
10427                            for draw in
10428                                prepared_composites.get(draw_range.clone()).ok_or_else(|| {
10429                                    "composite draw range is outside the prepared command buffer"
10430                                        .to_string()
10431                                })?
10432                            {
10433                                self.effect_renderer.draw_prepared_composite(
10434                                    &mut render_pass,
10435                                    (width, height),
10436                                    draw,
10437                                    pass_depth,
10438                                );
10439                            }
10440                        }
10441                        FusedSegmentBatch::ShaderComposite { draw_range } => {
10442                            for draw in prepared_shaders.get(draw_range.clone()).ok_or_else(|| {
10443                                "shader composite draw range is outside the prepared command buffer"
10444                                    .to_string()
10445                            })? {
10446                                self.effect_renderer.draw_prepared_shader_src_over(
10447                                    &device,
10448                                    &mut render_pass,
10449                                    (width, height),
10450                                    draw,
10451                                    pass_depth,
10452                                );
10453                            }
10454                        }
10455                        FusedSegmentBatch::Retained { item_range } => {
10456                            // Each Retained arm is one MAXIMAL consecutive
10457                            // retained stretch — the planner groups adjacent
10458                            // retained items into a single batch — so caching
10459                            // per arm never flattens across the dynamic
10460                            // batches interleaved at their z positions.
10461                            let retained_start = Instant::now();
10462                            // A render bundle cannot encode a segment-surface
10463                            // composite, so a stretch containing one this
10464                            // frame takes the per-item walk: order-identical,
10465                            // and the walk is a handful of binds exactly when
10466                            // the cache is saving the fragment work.
10467                            let stretch_has_composites = !prepared_segment_composites.is_empty()
10468                                && ordered_items[item_range.clone()].iter().any(|(_, item)| {
10469                                    matches!(
10470                                        item,
10471                                        SegmentDrawItem::Retained(index)
10472                                            if prepared_segment_composites
10473                                                .iter()
10474                                                .any(|(prepared_index, _)| prepared_index == index)
10475                                    )
10476                                });
10477                            if use_retained_bundles && !stretch_has_composites {
10478                                self.draw_retained_stretch_bundled(
10479                                    &mut render_pass,
10480                                    ordered_items,
10481                                    retained_draws,
10482                                    item_range.clone(),
10483                                    width,
10484                                    height,
10485                                );
10486                            } else {
10487                                for (_, item) in &ordered_items[item_range.clone()] {
10488                                    if let SegmentDrawItem::Retained(index) = item {
10489                                        if let Some((_, prepared)) = prepared_segment_composites
10490                                            .iter()
10491                                            .find(|(prepared_index, _)| prepared_index == index)
10492                                        {
10493                                            // The cached span's surface, at
10494                                            // the span's exact z position:
10495                                            // SrcOver over premultiplied
10496                                            // alpha is associative, so
10497                                            // flatten-then-composite blends
10498                                            // identically to the inline
10499                                            // member draws it replaces.
10500                                            self.effect_renderer
10501                                                .draw_prepared_projective_composite(
10502                                                    &mut render_pass,
10503                                                    (width, height),
10504                                                    prepared,
10505                                                    pass_depth,
10506                                                );
10507                                            self.frame_stats.add_draw_calls(1);
10508                                        } else if let Some(retained) = retained_draws.get(*index) {
10509                                            self.draw_retained_batch(
10510                                                &mut render_pass,
10511                                                retained,
10512                                                *index,
10513                                                width,
10514                                                height,
10515                                            );
10516                                        }
10517                                    }
10518                                }
10519                            }
10520                            retained_encode_ms += instant_ms(retained_start, Instant::now());
10521                        }
10522                    }
10523                }
10524            }
10525            // The depth attachment died with the fused pass just dropped;
10526            // anything encoded from here to the closure exit (the span
10527            // capture below) is a depth-less pass, so the pipeline getters
10528            // must stop handing out depth variants NOW — the closure-exit
10529            // reset is only the error-path net.
10530            self.display_clip.pass_depth.set(false);
10531            // Span capture (miss frames whose leading run proved stable):
10532            // re-render JUST the span shapes into the pooled offscreen,
10533            // through the IDENTICAL pipelines at identical device
10534            // coordinates — the shapes are already in this partition's
10535            // upload, so the capture is one extra pass drawing instances
10536            // 0..len of the same buffers, cleared with the frame's own
10537            // clear color. Rare by construction: palette drains, shakes,
10538            // and resizes are the only events that invalidate the key.
10539            let mut capture_passes = 0_u32;
10540            if let StaticSpanDecision::Capture { len, clear } = span_decision {
10541                let texture = match span_cache.texture.take() {
10542                    Some(existing) if existing.width == width && existing.height == height => {
10543                        existing
10544                    }
10545                    other => {
10546                        if let Some(stale) = other {
10547                            self.defer_offscreen_release(stale);
10548                        }
10549                        self.acquire_offscreen(width, height)
10550                    }
10551                };
10552                {
10553                    let mut capture_pass =
10554                        frame_encoder
10555                            .encoder()
10556                            .begin_render_pass(&wgpu::RenderPassDescriptor {
10557                                label: Some("Static Span Capture Pass"),
10558                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10559                                    view: &texture.view,
10560                                    resolve_target: None,
10561                                    depth_slice: None,
10562                                    ops: wgpu::Operations {
10563                                        load: wgpu::LoadOp::Clear(clear),
10564                                        store: wgpu::StoreOp::Store,
10565                                    },
10566                                })],
10567                                depth_stencil_attachment: None,
10568                                timestamp_writes: None,
10569                                occlusion_query_set: None,
10570                                multiview_mask: None,
10571                            });
10572                    // `has_gradient` is the LIVE first batch's whole-batch
10573                    // flag: it selects the same fs_solid/gradient pipeline
10574                    // variant the live path draws the span through.
10575                    let has_gradient = first_batch_info
10576                        .map(|(_, _, has_gradient)| has_gradient)
10577                        .unwrap_or(false);
10578                    self.draw_prepared_shapes(
10579                        &mut capture_pass,
10580                        BlendMode::SrcOver,
10581                        PreparedShapeBatch {
10582                            vertex_start: 0,
10583                            vertex_count: len as u32 * 6,
10584                            has_gradient,
10585                        },
10586                        width,
10587                        height,
10588                        &[],
10589                    );
10590                    if fill_area_diag_enabled() {
10591                        // The capture genuinely re-submits the span's fill
10592                        // this frame — submitted, lit, opacity and (the
10593                        // capture target is frame-sized) corner alike.
10594                        self.fill_area_diag
10595                            .add_shape_quads(&self.scratch_shape_data[..len], viewport);
10596                    }
10597                    span_cache.store_key(
10598                        &self.scratch_shape_data[..len],
10599                        &self.scratch_gradients,
10600                        width,
10601                        height,
10602                        clear,
10603                        has_gradient,
10604                    );
10605                }
10606                span_cache.texture = Some(texture);
10607                capture_passes = 1;
10608            }
10609            let after_pass = Instant::now();
10610            if let Some(total_ms) = should_log_wgpu_render_stage(partition_start, after_pass) {
10611                log::warn!(
10612                    "[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={}",
10613                    instant_ms(partition_start, after_shape_refs),
10614                    instant_ms(after_shape_refs, after_shape_prepare),
10615                    instant_ms(after_shape_prepare, after_batch_prepare),
10616                    instant_ms(after_batch_prepare, after_composite_prepare),
10617                    instant_ms(after_composite_prepare, after_upload),
10618                    instant_ms(after_upload, after_pass),
10619                    fused_batches.len(),
10620                    budget.shape_count,
10621                    image_cmds.len(),
10622                    glyph_cmds.len(),
10623                    staged_uploads.bytes.len(),
10624                );
10625            }
10626
10627            Ok(SegmentRenderOutcome {
10628                rendered_any: true,
10629                pass_count: 1 + capture_passes + segment_capture_passes,
10630            })
10631        })();
10632
10633        // The depth flag lives exactly as long as the culled pass's encode;
10634        // resetting here (not inside the closure) covers the error paths
10635        // too, so no later pass can inherit a depth-variant pipeline.
10636        self.display_clip.pass_depth.set(false);
10637        self.scratch_image_vertices = image_vertices;
10638        self.scratch_image_indices = image_indices;
10639        self.scratch_image_cmds = image_cmds;
10640        self.scratch_glyph_cmds = glyph_cmds;
10641        self.restore_staged_uploads(staged_uploads);
10642        self.static_span = span_cache;
10643        if result.is_err() {
10644            // An aborted partition may have installed entries whose capture
10645            // passes never encoded; a later frame must not sample them.
10646            segment_surfaces.clear();
10647        }
10648        self.segment_surfaces = segment_surfaces;
10649        result
10650    }
10651
10652    #[allow(clippy::too_many_arguments)]
10653    fn render_segment_draw_chunk<C: FrameCommandRecorder>(
10654        &mut self,
10655        frame_encoder: &mut C,
10656        target_view: &wgpu::TextureView,
10657        ordered_items: &[(usize, SegmentDrawItem)],
10658        composites: &[(usize, CompositeBatchItem<'_>)],
10659        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
10660        shapes: &[DrawShape],
10661        brushes: &[Brush],
10662        images: &[ImageDraw],
10663        texts: &[TextDraw],
10664        retained_draws: &[RetainedDraw],
10665        chunk: SegmentDrawChunkPlan,
10666        width: u32,
10667        height: u32,
10668        root_scale: f32,
10669        load_op: wgpu::LoadOp<wgpu::Color>,
10670    ) -> Result<SegmentRenderOutcome, String> {
10671        #[cfg(target_arch = "wasm32")]
10672        let _ = retained_draws;
10673        #[cfg(not(target_arch = "wasm32"))]
10674        if let Some(outcome) = self.render_segment_draw_chunk_fused_native(
10675            frame_encoder,
10676            target_view,
10677            ordered_items,
10678            composites,
10679            shader_composites,
10680            shapes,
10681            brushes,
10682            images,
10683            texts,
10684            retained_draws,
10685            &chunk,
10686            width,
10687            height,
10688            root_scale,
10689            load_op,
10690        )? {
10691            return Ok(outcome);
10692        }
10693
10694        let mut staged_uploads = self.take_staged_uploads();
10695        let result = (|| {
10696            let mut rendered_any = false;
10697            let mut pass_count = 0_u32;
10698            let mut next_load_op = load_op;
10699            for batch in chunk.iter() {
10700                staged_uploads.clear();
10701                match batch {
10702                    SegmentBatchPlan::Shape {
10703                        start,
10704                        end,
10705                        blend_mode,
10706                    } => {
10707                        let slice = &ordered_items[start..end];
10708                        if slice.len() > self.shape_batch_limits.max_shapes_per_batch {
10709                            return Err(format!(
10710                                "shape batch contains {} shapes, exceeding the renderer limit of {}",
10711                                slice.len(),
10712                                self.shape_batch_limits.max_shapes_per_batch
10713                            ));
10714                        }
10715                        let viewport = ViewportUniformParams {
10716                            width,
10717                            height,
10718                            offset: [0.0, 0.0],
10719                        };
10720                        for (_, item) in slice {
10721                            if !matches!(item, SegmentDrawItem::Shape(_)) {
10722                                return Err(format!(
10723                                    "shape batch contains non-shape draw item: {item:?}"
10724                                ));
10725                            }
10726                        }
10727                        let Some(prepared) = self.prepare_shapes_batch(
10728                            slice.iter().filter_map(|(_, item)| match item {
10729                                SegmentDrawItem::Shape(shape_index) => Some(&shapes[*shape_index]),
10730                                _ => None,
10731                            }),
10732                            brushes,
10733                            root_scale,
10734                            viewport,
10735                            &mut staged_uploads,
10736                        ) else {
10737                            continue;
10738                        };
10739                        let upload_offset = frame_encoder
10740                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10741                        self.flush_staged_uploads_at(
10742                            frame_encoder.encoder(),
10743                            &staged_uploads,
10744                            upload_offset,
10745                        );
10746                        {
10747                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
10748                                &wgpu::RenderPassDescriptor {
10749                                    label: Some("Segment Shape Pass"),
10750                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10751                                        view: target_view,
10752                                        resolve_target: None,
10753                                        depth_slice: None,
10754                                        ops: wgpu::Operations {
10755                                            load: next_load_op,
10756                                            store: wgpu::StoreOp::Store,
10757                                        },
10758                                    })],
10759                                    depth_stencil_attachment: None,
10760                                    timestamp_writes: None,
10761                                    occlusion_query_set: None,
10762                                    multiview_mask: None,
10763                                },
10764                            );
10765                            self.draw_prepared_shapes(
10766                                &mut render_pass,
10767                                blend_mode,
10768                                prepared,
10769                                width,
10770                                height,
10771                                &[],
10772                            );
10773                        }
10774                        pass_count = pass_count.saturating_add(1);
10775                        rendered_any = true;
10776                        next_load_op = wgpu::LoadOp::Load;
10777                    }
10778                    SegmentBatchPlan::Image {
10779                        start,
10780                        end,
10781                        blend_mode,
10782                    } => {
10783                        let viewport = ViewportUniformParams {
10784                            width,
10785                            height,
10786                            offset: [0.0, 0.0],
10787                        };
10788                        for (_, item) in &ordered_items[start..end] {
10789                            if !matches!(item, SegmentDrawItem::Image(_)) {
10790                                return Err(format!(
10791                                    "image batch contains non-image draw item: {item:?}"
10792                                ));
10793                            }
10794                        }
10795                        let prepared_images = self.prepare_image_draw_cmds(
10796                            ordered_items[start..end]
10797                                .iter()
10798                                .filter_map(|(_, item)| match item {
10799                                    SegmentDrawItem::Image(image_index) => {
10800                                        Some(&images[*image_index])
10801                                    }
10802                                    _ => None,
10803                                }),
10804                            viewport,
10805                            root_scale,
10806                            &mut staged_uploads,
10807                        )?;
10808                        if prepared_images.is_empty() {
10809                            self.scratch_image_cmds = prepared_images.into_cmds();
10810                            continue;
10811                        }
10812                        let upload_offset = frame_encoder
10813                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10814                        self.flush_staged_uploads_at(
10815                            frame_encoder.encoder(),
10816                            &staged_uploads,
10817                            upload_offset,
10818                        );
10819                        let draw_result = {
10820                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
10821                                &wgpu::RenderPassDescriptor {
10822                                    label: Some("Segment Image Pass"),
10823                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10824                                        view: target_view,
10825                                        resolve_target: None,
10826                                        depth_slice: None,
10827                                        ops: wgpu::Operations {
10828                                            load: next_load_op,
10829                                            store: wgpu::StoreOp::Store,
10830                                        },
10831                                    })],
10832                                    depth_stencil_attachment: None,
10833                                    timestamp_writes: None,
10834                                    occlusion_query_set: None,
10835                                    multiview_mask: None,
10836                                },
10837                            );
10838                            self.draw_prepared_images(
10839                                &mut render_pass,
10840                                &prepared_images,
10841                                blend_mode,
10842                            )
10843                        };
10844                        pass_count = pass_count.saturating_add(1);
10845                        self.scratch_image_cmds = prepared_images.into_cmds();
10846                        draw_result?;
10847                        rendered_any = true;
10848                        next_load_op = wgpu::LoadOp::Load;
10849                    }
10850                    SegmentBatchPlan::Text { start, end } => {
10851                        let viewport = ViewportUniformParams {
10852                            width,
10853                            height,
10854                            offset: [0.0, 0.0],
10855                        };
10856                        let text_draws =
10857                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10858                        if let Some(prepared_glyphs) = self.prepare_text_glyph_draw_cmds(
10859                            text_draws,
10860                            viewport,
10861                            root_scale,
10862                            &mut staged_uploads,
10863                        )? {
10864                            if prepared_glyphs.is_empty() {
10865                                self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
10866                                continue;
10867                            }
10868                            let upload_offset = frame_encoder
10869                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10870                            self.flush_staged_uploads_at(
10871                                frame_encoder.encoder(),
10872                                &staged_uploads,
10873                                upload_offset,
10874                            );
10875                            {
10876                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
10877                                    &wgpu::RenderPassDescriptor {
10878                                        label: Some("Segment Text Glyph Atlas Pass"),
10879                                        color_attachments: &[Some(
10880                                            wgpu::RenderPassColorAttachment {
10881                                                view: target_view,
10882                                                resolve_target: None,
10883                                                depth_slice: None,
10884                                                ops: wgpu::Operations {
10885                                                    load: next_load_op,
10886                                                    store: wgpu::StoreOp::Store,
10887                                                },
10888                                            },
10889                                        )],
10890                                        depth_stencil_attachment: None,
10891                                        timestamp_writes: None,
10892                                        occlusion_query_set: None,
10893                                        multiview_mask: None,
10894                                    },
10895                                );
10896                                self.draw_prepared_glyphs(&mut render_pass, &prepared_glyphs)?;
10897                            }
10898                            pass_count = pass_count.saturating_add(1);
10899                            self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
10900                            rendered_any = true;
10901                            next_load_op = wgpu::LoadOp::Load;
10902                        } else {
10903                            let text_draws =
10904                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10905                            let prepared_images = self.prepare_text_image_draw_cmds(
10906                                text_draws,
10907                                viewport,
10908                                root_scale,
10909                                &mut staged_uploads,
10910                            )?;
10911                            if prepared_images.is_empty() {
10912                                self.scratch_image_cmds = prepared_images.into_cmds();
10913                                continue;
10914                            }
10915                            let upload_offset = frame_encoder
10916                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10917                            self.flush_staged_uploads_at(
10918                                frame_encoder.encoder(),
10919                                &staged_uploads,
10920                                upload_offset,
10921                            );
10922                            {
10923                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
10924                                    &wgpu::RenderPassDescriptor {
10925                                        label: Some("Segment Text Pass"),
10926                                        color_attachments: &[Some(
10927                                            wgpu::RenderPassColorAttachment {
10928                                                view: target_view,
10929                                                resolve_target: None,
10930                                                depth_slice: None,
10931                                                ops: wgpu::Operations {
10932                                                    load: next_load_op,
10933                                                    store: wgpu::StoreOp::Store,
10934                                                },
10935                                            },
10936                                        )],
10937                                        depth_stencil_attachment: None,
10938                                        timestamp_writes: None,
10939                                        occlusion_query_set: None,
10940                                        multiview_mask: None,
10941                                    },
10942                                );
10943                                self.draw_prepared_images(
10944                                    &mut render_pass,
10945                                    &prepared_images,
10946                                    BlendMode::SrcOver,
10947                                )?;
10948                            }
10949                            self.frame_stats.bump_text();
10950                            pass_count = pass_count.saturating_add(1);
10951                            self.scratch_image_cmds = prepared_images.into_cmds();
10952                            rendered_any = true;
10953                            next_load_op = wgpu::LoadOp::Load;
10954                        }
10955                    }
10956                    SegmentBatchPlan::Composite { start, end } => {
10957                        let batch_items: Vec<_> = ordered_items[start..end]
10958                            .iter()
10959                            .map(|(_, item)| match item {
10960                                SegmentDrawItem::Composite(composite_index) => composites
10961                                    .get(*composite_index)
10962                                    .map(|(_, composite)| *composite)
10963                                    .ok_or_else(|| {
10964                                        "composite item index is outside the composite buffer"
10965                                            .to_string()
10966                                    }),
10967                                other => Err(format!(
10968                                    "composite batch contains non-composite draw item: {other:?}"
10969                                )),
10970                            })
10971                            .collect::<Result<_, _>>()?;
10972                        let device = self.device.clone();
10973                        self.effect_renderer.encode_composite_batch_to_view_pass(
10974                            frame_encoder,
10975                            &device,
10976                            target_view,
10977                            (width, height),
10978                            next_load_op,
10979                            &batch_items,
10980                        );
10981                        self.effect_renderer.record_composite_pass();
10982                        pass_count = pass_count.saturating_add(1);
10983                        rendered_any = true;
10984                        next_load_op = wgpu::LoadOp::Load;
10985                    }
10986                    SegmentBatchPlan::ShaderComposite { start, end } => {
10987                        let batch_items: Vec<_> = ordered_items[start..end]
10988                            .iter()
10989                            .map(|(_, item)| match item {
10990                                SegmentDrawItem::ShaderComposite(composite_index) => {
10991                                    shader_composites
10992                                        .get(*composite_index)
10993                                        .map(|(_, composite)| *composite)
10994                                        .ok_or_else(|| {
10995                                            "shader composite item index is outside the shader composite buffer"
10996                                                .to_string()
10997                                        })
10998                                }
10999                                other => Err(format!(
11000                                    "shader composite batch contains non-shader-composite draw item: {other:?}"
11001                                )),
11002                            })
11003                            .collect::<Result<Vec<_>, _>>()?;
11004                        let device = self.device.clone();
11005                        let encoded = self.effect_renderer.encode_shader_batch_src_over_to_view(
11006                            frame_encoder,
11007                            &device,
11008                            target_view,
11009                            (width, height),
11010                            next_load_op,
11011                            &batch_items,
11012                        );
11013                        if !encoded {
11014                            return Err("shader composite batch failed to encode".to_string());
11015                        }
11016                        self.effect_renderer.record_composite_pass();
11017                        self.effect_renderer.debug_effects.set(
11018                            self.effect_renderer.debug_effects.get() + batch_items.len() as u32,
11019                        );
11020                        pass_count = pass_count.saturating_add(1);
11021                        rendered_any = true;
11022                        next_load_op = wgpu::LoadOp::Load;
11023                    }
11024                    SegmentBatchPlan::Retained { start, end } => {
11025                        // Reached only when native fusion declined the chunk;
11026                        // retained batches exist on storage-mode native
11027                        // devices, where fusion always accepts, but the arm
11028                        // stays a real draw so that assumption is not load-
11029                        // bearing for correctness. Deliberately direct encode
11030                        // — retained bundle caching AND segment-surface
11031                        // compositing live in the fused path only; this
11032                        // fallback stays the simple reference.
11033                        #[cfg(target_arch = "wasm32")]
11034                        {
11035                            let _ = (start, end);
11036                            return Err("retained shape batches are native-only".to_string());
11037                        }
11038                        #[cfg(not(target_arch = "wasm32"))]
11039                        {
11040                            self.stage_replay_patches(&mut staged_uploads);
11041                            for (_, item) in &ordered_items[start..end] {
11042                                let SegmentDrawItem::Retained(index) = item else {
11043                                    return Err(format!(
11044                                        "retained batch contains non-retained draw item: {item:?}"
11045                                    ));
11046                                };
11047                                let retained = retained_draws.get(*index).ok_or_else(|| {
11048                                    format!("retained draw index {index} out of bounds")
11049                                })?;
11050                                if (*index as u32) < MAX_REPLAY_SLOTS
11051                                    && self.replay_slots.slots.contains_key(&retained.slot)
11052                                {
11053                                    let transform = retained.transform.with_retained_paint();
11054                                    staged_uploads.stage_at(
11055                                        UploadTarget::ReplayTransform,
11056                                        *index as u64 * REPLAY_TRANSFORM_STRIDE,
11057                                        bytemuck::bytes_of(&transform),
11058                                    );
11059                                }
11060                            }
11061                            let upload_offset = frame_encoder
11062                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11063                            self.flush_staged_uploads_at(
11064                                frame_encoder.encoder(),
11065                                &staged_uploads,
11066                                upload_offset,
11067                            );
11068                            {
11069                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
11070                                    &wgpu::RenderPassDescriptor {
11071                                        label: Some("Segment Retained Pass"),
11072                                        color_attachments: &[Some(
11073                                            wgpu::RenderPassColorAttachment {
11074                                                view: target_view,
11075                                                resolve_target: None,
11076                                                depth_slice: None,
11077                                                ops: wgpu::Operations {
11078                                                    load: next_load_op,
11079                                                    store: wgpu::StoreOp::Store,
11080                                                },
11081                                            },
11082                                        )],
11083                                        depth_stencil_attachment: None,
11084                                        timestamp_writes: None,
11085                                        occlusion_query_set: None,
11086                                        multiview_mask: None,
11087                                    },
11088                                );
11089                                for (_, item) in &ordered_items[start..end] {
11090                                    if let SegmentDrawItem::Retained(index) = item {
11091                                        if let Some(retained) = retained_draws.get(*index) {
11092                                            self.draw_retained_batch(
11093                                                &mut render_pass,
11094                                                retained,
11095                                                *index,
11096                                                width,
11097                                                height,
11098                                            );
11099                                        }
11100                                    }
11101                                }
11102                            }
11103                            pass_count = pass_count.saturating_add(1);
11104                            rendered_any = true;
11105                            next_load_op = wgpu::LoadOp::Load;
11106                        }
11107                    }
11108                }
11109            }
11110            Ok(SegmentRenderOutcome {
11111                rendered_any,
11112                pass_count,
11113            })
11114        })();
11115        self.restore_staged_uploads(staged_uploads);
11116        result
11117    }
11118
11119    fn viewport_uniforms(params: ViewportUniformParams) -> Uniforms {
11120        Uniforms {
11121            viewport: [params.width as f32, params.height as f32],
11122            viewport_offset: params.offset,
11123        }
11124    }
11125
11126    #[cfg(not(target_arch = "wasm32"))]
11127    fn stage_viewport_uniforms(
11128        &self,
11129        staged_uploads: &mut StagedBufferUploads,
11130        params: ViewportUniformParams,
11131    ) {
11132        let uniforms = Self::viewport_uniforms(params);
11133        staged_uploads.stage(UploadTarget::Uniform, bytemuck::bytes_of(&uniforms));
11134    }
11135
11136    #[cfg(not(target_arch = "wasm32"))]
11137    fn stage_retained_glyph_viewport_uniforms(
11138        &mut self,
11139        staged_uploads: &mut StagedBufferUploads,
11140        params: ViewportUniformParams,
11141    ) -> usize {
11142        let slot = self.claim_retained_glyph_uniform_slot();
11143        let uniforms = Self::viewport_uniforms(params);
11144        staged_uploads.stage_at(
11145            UploadTarget::RetainedGlyphUniform,
11146            self.retained_glyph_uniform_offset(slot),
11147            bytemuck::bytes_of(&uniforms),
11148        );
11149        slot
11150    }
11151
11152    #[cfg(not(target_arch = "wasm32"))]
11153    fn claim_retained_glyph_uniform_slot(&mut self) -> usize {
11154        let slot = self.retained_glyph_uniform_cursor;
11155        self.retained_glyph_uniform_cursor = self.retained_glyph_uniform_cursor.saturating_add(1);
11156        self.ensure_retained_glyph_uniform_capacity(slot.saturating_add(1));
11157        slot
11158    }
11159
11160    #[cfg(not(target_arch = "wasm32"))]
11161    fn retained_glyph_uniform_offset(&self, slot: usize) -> u64 {
11162        self.retained_glyph_uniform_stride * slot as u64
11163    }
11164
11165    #[cfg(not(target_arch = "wasm32"))]
11166    fn retained_glyph_uniform_dynamic_offset(&self, slot: usize) -> Result<u32, String> {
11167        let offset = self.retained_glyph_uniform_offset(slot);
11168        u32::try_from(offset).map_err(|_| {
11169            "retained glyph uniform offset exceeded WGPU dynamic offset range".to_string()
11170        })
11171    }
11172
11173    #[cfg(not(target_arch = "wasm32"))]
11174    fn ensure_retained_glyph_uniform_capacity(&mut self, required_slots: usize) {
11175        if required_slots <= self.retained_glyph_uniform_capacity {
11176            return;
11177        }
11178        let new_capacity = required_slots
11179            .next_power_of_two()
11180            .max(INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS);
11181        self.retained_glyph_uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11182            label: Some("Retained Glyph Uniform Buffer"),
11183            size: self.retained_glyph_uniform_stride * new_capacity as u64,
11184            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
11185            mapped_at_creation: false,
11186        });
11187        self.retained_glyph_uniform_bind_group =
11188            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
11189                label: Some("Retained Glyph Uniform Bind Group"),
11190                layout: &self.retained_glyph_uniform_bind_group_layout,
11191                entries: &[wgpu::BindGroupEntry {
11192                    binding: 0,
11193                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11194                        buffer: &self.retained_glyph_uniform_buffer,
11195                        offset: 0,
11196                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
11197                    }),
11198                }],
11199            });
11200        self.retained_glyph_uniform_capacity = new_capacity;
11201    }
11202
11203    #[cfg(target_arch = "wasm32")]
11204    fn prepare_wasm_viewport_uniforms(&mut self, params: ViewportUniformParams) -> usize {
11205        let slot = self.claim_wasm_uniform_batch();
11206        let uniforms = Self::viewport_uniforms(params);
11207        let bytes = bytemuck::bytes_of(&uniforms);
11208        let upload_stats = self.frame_graph_executor.upload_buffer(
11209            &self.queue,
11210            &self.wasm_uniform_batches[slot].buffer,
11211            0,
11212            bytes,
11213        );
11214        self.frame_stats.record_command_stats(upload_stats);
11215        slot
11216    }
11217
11218    #[cfg(target_arch = "wasm32")]
11219    fn claim_wasm_uniform_batch(&mut self) -> usize {
11220        let slot = self.wasm_uniform_batch_cursor;
11221        self.wasm_uniform_batch_cursor += 1;
11222        while self.wasm_uniform_batches.len() <= slot {
11223            self.wasm_uniform_batches.push(UniformBatchBuffer::new(
11224                &self.device,
11225                &self.uniform_bind_group_layout,
11226            ));
11227        }
11228        slot
11229    }
11230
11231    #[cfg(target_arch = "wasm32")]
11232    fn claim_wasm_shape_batch(&mut self) -> usize {
11233        let slot = self.wasm_shape_batch_cursor;
11234        self.wasm_shape_batch_cursor += 1;
11235        while self.wasm_shape_batches.len() <= slot {
11236            self.wasm_shape_batches.push(ShapeBatchBuffers::new(
11237                &self.device,
11238                &self.shape_bind_group_layout,
11239                &self.identity_similarity_buffer,
11240                self.dummy_paint_buffer.as_ref(),
11241                self.shape_batch_limits,
11242            ));
11243        }
11244        slot
11245    }
11246
11247    #[cfg(target_arch = "wasm32")]
11248    fn claim_wasm_image_batch(&mut self) -> usize {
11249        let slot = self.wasm_image_batch_cursor;
11250        self.wasm_image_batch_cursor += 1;
11251        while self.wasm_image_batches.len() <= slot {
11252            self.wasm_image_batches
11253                .push(ImageBatchBuffers::new(&self.device));
11254        }
11255        slot
11256    }
11257
11258    #[cfg(target_arch = "wasm32")]
11259    fn write_wasm_buffer(&self, buffer: &wgpu::Buffer, bytes: &[u8]) {
11260        let upload_stats = self
11261            .frame_graph_executor
11262            .upload_buffer(&self.queue, buffer, 0, bytes);
11263        self.frame_stats.record_command_stats(upload_stats);
11264    }
11265
11266    fn take_staged_uploads(&mut self) -> StagedBufferUploads {
11267        let mut staged_uploads = std::mem::take(&mut self.staged_uploads);
11268        debug_assert!(
11269            staged_uploads.is_empty(),
11270            "renderer-owned staged uploads should be restored as empty scratch storage"
11271        );
11272        staged_uploads.clear();
11273        staged_uploads
11274    }
11275
11276    fn restore_staged_uploads(&mut self, mut staged_uploads: StagedBufferUploads) {
11277        staged_uploads.clear();
11278        self.staged_uploads = staged_uploads;
11279    }
11280
11281    #[cfg(not(target_arch = "wasm32"))]
11282    fn ensure_upload_buffer_capacity(&mut self, required_bytes: u64) {
11283        if required_bytes <= self.upload_buffer.size() {
11284            return;
11285        }
11286
11287        let new_size = required_bytes
11288            .next_power_of_two()
11289            .max(INITIAL_UPLOAD_BUFFER_BYTES);
11290        self.upload_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11291            label: Some("Frame Upload Buffer"),
11292            size: new_size,
11293            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
11294            mapped_at_creation: false,
11295        });
11296    }
11297
11298    fn flush_staged_uploads_at(
11299        &mut self,
11300        encoder: &mut wgpu::CommandEncoder,
11301        staged_uploads: &StagedBufferUploads,
11302        upload_buffer_offset: u64,
11303    ) {
11304        if staged_uploads.is_empty() {
11305            return;
11306        }
11307        debug_assert_eq!(
11308            upload_buffer_offset % wgpu::COPY_BUFFER_ALIGNMENT,
11309            0,
11310            "upload-buffer base offset must satisfy copy alignment"
11311        );
11312
11313        #[cfg(target_arch = "wasm32")]
11314        {
11315            let _ = upload_buffer_offset;
11316            let _ = encoder;
11317            debug_assert!(
11318                staged_uploads.is_empty(),
11319                "wasm draw uploads use retained per-batch resource slots"
11320            );
11321            return;
11322        }
11323
11324        #[cfg(not(target_arch = "wasm32"))]
11325        {
11326            self.ensure_upload_buffer_capacity(
11327                upload_buffer_offset + staged_uploads.bytes.len() as u64,
11328            );
11329            let upload_stats = self.frame_graph_executor.upload_buffer(
11330                &self.queue,
11331                &self.upload_buffer,
11332                upload_buffer_offset,
11333                &staged_uploads.bytes,
11334            );
11335            self.frame_stats.record_command_stats(upload_stats);
11336
11337            for copy in &staged_uploads.copies {
11338                let target_buffer = match copy.target {
11339                    UploadTarget::Uniform => &self.uniform_buffer,
11340                    UploadTarget::ShapeData => &self.shape_buffers.shape_buffer,
11341                    UploadTarget::ShapeGradient => &self.shape_buffers.gradient_buffer,
11342                    UploadTarget::ImageVertex => &self.image_vertex_buffer,
11343                    UploadTarget::ImageIndex => &self.image_index_buffer,
11344                    UploadTarget::RetainedGlyphUniform => &self.retained_glyph_uniform_buffer,
11345                    UploadTarget::ReplayTransform => &self.replay_slots.transform_buffer,
11346                    UploadTarget::ReplayPaintData(slot) => {
11347                        // A slot released between staging and flush has
11348                        // nothing left to patch.
11349                        let Some(entry) = self.replay_slots.slots.get(&slot) else {
11350                            continue;
11351                        };
11352                        &entry.paint_buffer
11353                    }
11354                };
11355                encoder.copy_buffer_to_buffer(
11356                    &self.upload_buffer,
11357                    upload_buffer_offset + copy.source_offset,
11358                    target_buffer,
11359                    copy.target_offset,
11360                    copy.size,
11361                );
11362            }
11363        }
11364    }
11365
11366    #[allow(clippy::too_many_arguments)]
11367    fn encode_shadow_draw<C: FrameCommandRecorder>(
11368        &mut self,
11369        frame_encoder: &mut C,
11370        target_view: &wgpu::TextureView,
11371        shadow: &ShadowDraw,
11372        width: u32,
11373        height: u32,
11374        root_scale: f32,
11375    ) {
11376        if shadow.shapes.is_empty() && shadow.texts.is_empty() {
11377            return;
11378        }
11379
11380        let shape_bounds_opt = shadow
11381            .shapes
11382            .iter()
11383            .map(|(shape, _)| shape.rect)
11384            .reduce(|a, b| Rect {
11385                x: a.x.min(b.x),
11386                y: a.y.min(b.y),
11387                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
11388                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
11389            });
11390
11391        let text_bounds_opt = shadow
11392            .texts
11393            .iter()
11394            .map(|text| text.rect)
11395            .reduce(|a, b| Rect {
11396                x: a.x.min(b.x),
11397                y: a.y.min(b.y),
11398                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
11399                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
11400            });
11401
11402        let combined_bounds = match (shape_bounds_opt, text_bounds_opt) {
11403            (Some(s), Some(t)) => Some(Rect {
11404                x: s.x.min(t.x),
11405                y: s.y.min(t.y),
11406                width: (s.x + s.width).max(t.x + t.width) - s.x.min(t.x),
11407                height: (s.y + s.height).max(t.y + t.height) - s.y.min(t.y),
11408            }),
11409            (Some(s), None) => Some(s),
11410            (None, Some(t)) => Some(t),
11411            (None, None) => None,
11412        };
11413
11414        let Some(shape_bounds) = combined_bounds else {
11415            return;
11416        };
11417
11418        let blur_margin = blur_extent_margin(shadow.blur_radius);
11419        let source_blur_bounds = Rect {
11420            x: shape_bounds.x - blur_margin,
11421            y: shape_bounds.y - blur_margin,
11422            width: shape_bounds.width + blur_margin * 2.0,
11423            height: shape_bounds.height + blur_margin * 2.0,
11424        };
11425        let mut visible_blur_bounds = source_blur_bounds;
11426        if let Some(clip) = shadow.clip {
11427            let clip_expanded = Rect {
11428                x: clip.x - blur_margin,
11429                y: clip.y - blur_margin,
11430                width: clip.width + blur_margin * 2.0,
11431                height: clip.height + blur_margin * 2.0,
11432            };
11433            let Some(intersection) = visible_blur_bounds.intersect(clip_expanded) else {
11434                return;
11435            };
11436            visible_blur_bounds = intersection;
11437        }
11438        let processing_scissor =
11439            scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
11440        if processing_scissor.is_none() {
11441            return;
11442        }
11443
11444        // Zero blur: render shapes directly to target (fast path).
11445        if shadow.blur_radius <= 0.0 {
11446            for (shape, blend_mode) in &shadow.shapes {
11447                self.encode_shapes_pass(
11448                    frame_encoder,
11449                    target_view,
11450                    std::iter::once(shape),
11451                    &shadow.brushes,
11452                    *blend_mode,
11453                    width,
11454                    height,
11455                    root_scale,
11456                    wgpu::LoadOp::Load,
11457                    [0.0, 0.0],
11458                );
11459                frame_encoder.record_pass();
11460            }
11461            if !shadow.texts.is_empty() {
11462                let mut staged_uploads = self.take_staged_uploads();
11463                let viewport = ViewportUniformParams {
11464                    width,
11465                    height,
11466                    offset: [0.0, 0.0],
11467                };
11468                match self.prepare_text_image_draw_cmds(
11469                    shadow.texts.iter(),
11470                    viewport,
11471                    root_scale,
11472                    &mut staged_uploads,
11473                ) {
11474                    Ok(prepared_images) if !prepared_images.is_empty() => {
11475                        let upload_offset = frame_encoder
11476                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11477                        self.flush_staged_uploads_at(
11478                            frame_encoder.encoder(),
11479                            &staged_uploads,
11480                            upload_offset,
11481                        );
11482                        let draw_result = {
11483                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
11484                                &wgpu::RenderPassDescriptor {
11485                                    label: Some("Zero Blur Shadow Text Image Pass"),
11486                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11487                                        view: target_view,
11488                                        resolve_target: None,
11489                                        depth_slice: None,
11490                                        ops: wgpu::Operations {
11491                                            load: wgpu::LoadOp::Load,
11492                                            store: wgpu::StoreOp::Store,
11493                                        },
11494                                    })],
11495                                    depth_stencil_attachment: None,
11496                                    timestamp_writes: None,
11497                                    occlusion_query_set: None,
11498                                    multiview_mask: None,
11499                                },
11500                            );
11501                            self.draw_prepared_images(
11502                                &mut render_pass,
11503                                &prepared_images,
11504                                BlendMode::SrcOver,
11505                            )
11506                        };
11507                        self.scratch_image_cmds = prepared_images.into_cmds();
11508                        if let Err(e) = draw_result {
11509                            eprintln!("Failed to draw text for zero-blur shadow: {}", e);
11510                        } else {
11511                            self.frame_stats.bump_text();
11512                            frame_encoder.record_pass();
11513                        }
11514                    }
11515                    Ok(prepared_images) => {
11516                        self.scratch_image_cmds = prepared_images.into_cmds();
11517                    }
11518                    Err(e) => {
11519                        eprintln!("Failed to prepare text image for zero-blur shadow: {}", e);
11520                    }
11521                }
11522                self.restore_staged_uploads(staged_uploads);
11523            }
11524            return;
11525        }
11526
11527        // Compute pixel-space bounds for the offscreen textures, clamped to viewport.
11528        let Some(device_bounds) =
11529            device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)
11530        else {
11531            return;
11532        };
11533        let bounds_x = device_bounds.x;
11534        let bounds_y = device_bounds.y;
11535        let bounds_w = device_bounds.width;
11536        let bounds_h = device_bounds.height;
11537        let pixel_radius = shadow.blur_radius * root_scale;
11538
11539        if shadow.texts.is_empty() && !shadow.shapes.is_empty() {
11540            if let Some(plan) = shape_shadow_surface_plan(
11541                &shadow.shapes,
11542                shadow.clip,
11543                shadow.blur_radius,
11544                width,
11545                height,
11546                root_scale,
11547                self.max_texture_dim(),
11548            ) {
11549                if self.encode_shape_only_blurred_shadow_draw(
11550                    frame_encoder,
11551                    target_view,
11552                    shadow,
11553                    plan.source_device_bounds,
11554                    plan.pixel_radius,
11555                    plan.processing_scissor,
11556                    width,
11557                    height,
11558                    root_scale,
11559                ) {
11560                    return;
11561                }
11562            }
11563        }
11564
11565        if !shadow.texts.is_empty() {
11566            self.frame_stats.record_shadow_text_blur_fallback();
11567        }
11568
11569        let device = self.device.clone();
11570        let source_descriptor =
11571            self.transient_offscreen_descriptor("Shadow Source", bounds_w, bounds_h);
11572        let source = frame_encoder.acquire_transient_offscreen(&device, source_descriptor);
11573        let viewport_offset = [bounds_x, bounds_y];
11574        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
11575        let source_outcome = self.encode_shadow_shape_source_passes(
11576            frame_encoder,
11577            &source.view,
11578            &shadow.shapes,
11579            &shadow.brushes,
11580            bounds_w,
11581            bounds_h,
11582            viewport_offset,
11583            root_scale,
11584            &mut next_load_op,
11585        );
11586        frame_encoder.record_passes(source_outcome.pass_count);
11587        let mut rendered_any = source_outcome.rendered_any;
11588
11589        if !shadow.texts.is_empty() {
11590            let mut shifted_texts = shadow.texts.clone();
11591            for text in &mut shifted_texts {
11592                text.rect.x -= viewport_offset[0] / root_scale;
11593                text.rect.y -= viewport_offset[1] / root_scale;
11594                if let Some(clip) = text.clip.as_mut() {
11595                    clip.x -= viewport_offset[0] / root_scale;
11596                    clip.y -= viewport_offset[1] / root_scale;
11597                }
11598            }
11599
11600            let mut staged_uploads = self.take_staged_uploads();
11601            let viewport = ViewportUniformParams {
11602                width: bounds_w,
11603                height: bounds_h,
11604                offset: [0.0, 0.0],
11605            };
11606            match self.prepare_text_image_draw_cmds(
11607                shifted_texts.iter(),
11608                viewport,
11609                root_scale,
11610                &mut staged_uploads,
11611            ) {
11612                Ok(prepared_images) if !prepared_images.is_empty() => {
11613                    let upload_offset = frame_encoder
11614                        .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11615                    self.flush_staged_uploads_at(
11616                        frame_encoder.encoder(),
11617                        &staged_uploads,
11618                        upload_offset,
11619                    );
11620                    let draw_result = {
11621                        let mut render_pass = frame_encoder.encoder().begin_render_pass(
11622                            &wgpu::RenderPassDescriptor {
11623                                label: Some("Shadow Source Text Image Pass"),
11624                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11625                                    view: &source.view,
11626                                    resolve_target: None,
11627                                    depth_slice: None,
11628                                    ops: wgpu::Operations {
11629                                        load: next_load_op,
11630                                        store: wgpu::StoreOp::Store,
11631                                    },
11632                                })],
11633                                depth_stencil_attachment: None,
11634                                timestamp_writes: None,
11635                                occlusion_query_set: None,
11636                                multiview_mask: None,
11637                            },
11638                        );
11639                        self.draw_prepared_images(
11640                            &mut render_pass,
11641                            &prepared_images,
11642                            BlendMode::SrcOver,
11643                        )
11644                    };
11645                    self.scratch_image_cmds = prepared_images.into_cmds();
11646                    if let Err(e) = draw_result {
11647                        eprintln!("Failed to draw text for shadow: {}", e);
11648                    } else {
11649                        self.frame_stats.bump_text();
11650                        frame_encoder.record_pass();
11651                        rendered_any = true;
11652                    }
11653                }
11654                Ok(prepared_images) => {
11655                    self.scratch_image_cmds = prepared_images.into_cmds();
11656                }
11657                Err(e) => {
11658                    eprintln!("Failed to prepare text image for shadow: {}", e);
11659                }
11660            }
11661            self.restore_staged_uploads(staged_uploads);
11662        }
11663
11664        if !rendered_any {
11665            frame_encoder.release_transient_offscreen(source_descriptor, source);
11666            return;
11667        }
11668
11669        let scratch_descriptor =
11670            self.transient_offscreen_descriptor("Shadow Blur Scratch", bounds_w, bounds_h);
11671        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
11672        {
11673            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
11674                frame_encoder,
11675                &device,
11676                &source,
11677                &scratch,
11678                &source.view,
11679                pixel_radius,
11680                pixel_radius,
11681                TileMode::Decal,
11682                None, // No scissor needed — the texture is already bounds-sized
11683            );
11684        }
11685        frame_encoder.record_passes(2);
11686
11687        let clip_scissor = shadow
11688            .clip
11689            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
11690        let scissor = clip_scissor.or(processing_scissor);
11691        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
11692            // Adjust mask coordinates from viewport-space to texture-local space,
11693            // since the blit shader computes world_pos = uv * tex_size.
11694            mask.rect[0] -= viewport_offset[0];
11695            mask.rect[1] -= viewport_offset[1];
11696            mask
11697        });
11698        let dest_viewport = Some((
11699            viewport_offset[0],
11700            viewport_offset[1],
11701            bounds_w as f32,
11702            bounds_h as f32,
11703        ));
11704        {
11705            self.effect_renderer
11706                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
11707                    frame_encoder,
11708                    &device,
11709                    &source,
11710                    target_view,
11711                    1.0,
11712                    wgpu::LoadOp::Load,
11713                    scissor,
11714                    rounded_mask,
11715                    BlendMode::SrcOver,
11716                    dest_viewport,
11717                    CompositeSampleMode::Linear,
11718                );
11719        }
11720        frame_encoder.record_pass();
11721        self.effect_renderer.record_blur_pass();
11722        self.effect_renderer.record_composite_pass();
11723        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
11724        frame_encoder.release_transient_offscreen(source_descriptor, source);
11725    }
11726
11727    #[allow(clippy::too_many_arguments)]
11728    fn encode_shadow_shape_source_passes<C: FrameCommandRecorder>(
11729        &mut self,
11730        frame_encoder: &mut C,
11731        source_view: &wgpu::TextureView,
11732        shapes: &[(DrawShape, BlendMode)],
11733        brushes: &[Brush],
11734        width: u32,
11735        height: u32,
11736        viewport_offset: [f32; 2],
11737        root_scale: f32,
11738        next_load_op: &mut wgpu::LoadOp<wgpu::Color>,
11739    ) -> ShadowSourceRenderOutcome {
11740        if shapes.is_empty() {
11741            return ShadowSourceRenderOutcome {
11742                rendered_any: false,
11743                pass_count: 0,
11744            };
11745        }
11746
11747        let mut staged_uploads = self.take_staged_uploads();
11748        let mut rendered_any = false;
11749        let mut pass_count = 0_u32;
11750        let mut start = 0usize;
11751        while start < shapes.len() {
11752            let blend_mode = supported_blend_mode(shapes[start].1);
11753            let mut end = start + 1;
11754            while end < shapes.len()
11755                && end - start < self.shape_batch_limits.max_shapes_per_batch
11756                && supported_blend_mode(shapes[end].1) == blend_mode
11757            {
11758                end += 1;
11759            }
11760
11761            staged_uploads.clear();
11762            let viewport = ViewportUniformParams {
11763                width,
11764                height,
11765                offset: viewport_offset,
11766            };
11767            let viewport_rect_logical = viewport_rect_in_logical(viewport, root_scale);
11768            let Some(prepared_shape) = self.prepare_shapes_batch(
11769                shapes[start..end]
11770                    .iter()
11771                    .map(|(shape, _blend_mode)| shape)
11772                    .filter(|shape| match viewport_rect_logical {
11773                        Some(rect) => shape_draw_is_visible_in_rect(shape, rect, root_scale),
11774                        None => false,
11775                    }),
11776                brushes,
11777                root_scale,
11778                viewport,
11779                &mut staged_uploads,
11780            ) else {
11781                start = end;
11782                continue;
11783            };
11784
11785            let upload_offset =
11786                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11787            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
11788
11789            {
11790                let mut render_pass =
11791                    frame_encoder
11792                        .encoder()
11793                        .begin_render_pass(&wgpu::RenderPassDescriptor {
11794                            label: Some("Shadow Source Shape Pass"),
11795                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11796                                view: source_view,
11797                                resolve_target: None,
11798                                depth_slice: None,
11799                                ops: wgpu::Operations {
11800                                    load: *next_load_op,
11801                                    store: wgpu::StoreOp::Store,
11802                                },
11803                            })],
11804                            depth_stencil_attachment: None,
11805                            timestamp_writes: None,
11806                            occlusion_query_set: None,
11807                            multiview_mask: None,
11808                        });
11809                self.draw_prepared_shapes(
11810                    &mut render_pass,
11811                    blend_mode,
11812                    prepared_shape,
11813                    width,
11814                    height,
11815                    &[],
11816                );
11817            }
11818
11819            #[cfg(not(target_arch = "wasm32"))]
11820            {
11821                if fill_area_diag_enabled() {
11822                    // Each shadow-source pass round-trips the whole
11823                    // bounds-sized offscreen target (clear on the first
11824                    // pass, load/store after); the shape quads inside were
11825                    // already priced by `prepare_shapes_batch` under this
11826                    // pass's bounds viewport.
11827                    self.fill_area_diag
11828                        .add_offscreen_target_fill(f64::from(width) * f64::from(height));
11829                }
11830            }
11831
11832            pass_count = pass_count.saturating_add(1);
11833            rendered_any = true;
11834            *next_load_op = wgpu::LoadOp::Load;
11835            start = end;
11836        }
11837
11838        self.restore_staged_uploads(staged_uploads);
11839        ShadowSourceRenderOutcome {
11840            rendered_any,
11841            pass_count,
11842        }
11843    }
11844
11845    #[allow(clippy::too_many_arguments)]
11846    fn encode_shape_only_blurred_shadow_draw<C: FrameCommandRecorder>(
11847        &mut self,
11848        frame_encoder: &mut C,
11849        target_view: &wgpu::TextureView,
11850        shadow: &ShadowDraw,
11851        device_bounds: DevicePixelBounds,
11852        pixel_radius: f32,
11853        processing_scissor: Option<(u32, u32, u32, u32)>,
11854        width: u32,
11855        height: u32,
11856        root_scale: f32,
11857    ) -> bool {
11858        let bounds_w = device_bounds.width;
11859        let bounds_h = device_bounds.height;
11860        let viewport_offset = [device_bounds.x, device_bounds.y];
11861        let cache_key = shape_shadow_surface_cache_key(
11862            &shadow.shapes,
11863            &shadow.brushes,
11864            device_bounds,
11865            pixel_radius,
11866            root_scale,
11867        );
11868
11869        if let Some(key) = cache_key {
11870            if let Some(cached) = self.cached_shadow_surface(&key) {
11871                self.frame_stats
11872                    .record_shadow_shape_cache_hit(bounds_w, bounds_h);
11873                let clip_scissor = shadow
11874                    .clip
11875                    .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
11876                let scissor = clip_scissor.or(processing_scissor);
11877                let rounded_mask =
11878                    inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
11879                        mask.rect[0] -= viewport_offset[0];
11880                        mask.rect[1] -= viewport_offset[1];
11881                        mask
11882                    });
11883                let dest_viewport = Some((
11884                    viewport_offset[0],
11885                    viewport_offset[1],
11886                    bounds_w as f32,
11887                    bounds_h as f32,
11888                ));
11889                {
11890                    self.effect_renderer
11891                        .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
11892                            frame_encoder,
11893                            &self.device,
11894                            &cached,
11895                            target_view,
11896                            1.0,
11897                            wgpu::LoadOp::Load,
11898                            scissor,
11899                            rounded_mask,
11900                            BlendMode::SrcOver,
11901                            dest_viewport,
11902                            CompositeSampleMode::Nearest,
11903                        );
11904                }
11905                frame_encoder.record_pass();
11906                self.effect_renderer.record_composite_pass();
11907                return true;
11908            }
11909            self.frame_stats
11910                .record_shadow_shape_cache_miss(bounds_w, bounds_h);
11911            self.frame_stats.maybe_print_shadow_shape_cache_miss(
11912                bounds_w,
11913                bounds_h,
11914                key.content_hash,
11915                pixel_radius,
11916                viewport_offset,
11917                shadow.shapes.len(),
11918                shadow.clip,
11919            );
11920        }
11921
11922        let device = self.device.clone();
11923        let source_descriptor =
11924            self.transient_offscreen_descriptor("Shape Shadow Source", bounds_w, bounds_h);
11925        let source_is_cacheable = cache_key.is_some();
11926        let source = if source_is_cacheable {
11927            self.acquire_retained_surface(bounds_w, bounds_h)
11928        } else {
11929            frame_encoder.acquire_transient_offscreen(&device, source_descriptor)
11930        };
11931        let scratch_descriptor =
11932            self.transient_offscreen_descriptor("Shape Shadow Blur Scratch", bounds_w, bounds_h);
11933        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
11934        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
11935        let source_outcome = self.encode_shadow_shape_source_passes(
11936            frame_encoder,
11937            &source.view,
11938            &shadow.shapes,
11939            &shadow.brushes,
11940            bounds_w,
11941            bounds_h,
11942            viewport_offset,
11943            root_scale,
11944            &mut next_load_op,
11945        );
11946        frame_encoder.record_passes(source_outcome.pass_count);
11947
11948        if !source_outcome.rendered_any {
11949            frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
11950            if source_is_cacheable {
11951                self.defer_offscreen_release(source);
11952            } else {
11953                frame_encoder.release_transient_offscreen(source_descriptor, source);
11954            }
11955            return true;
11956        }
11957
11958        {
11959            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
11960                frame_encoder,
11961                &device,
11962                &source,
11963                &scratch,
11964                &source.view,
11965                pixel_radius,
11966                pixel_radius,
11967                TileMode::Decal,
11968                None,
11969            );
11970        }
11971        frame_encoder.record_passes(2);
11972
11973        let clip_scissor = shadow
11974            .clip
11975            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
11976        let scissor = clip_scissor.or(processing_scissor);
11977        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
11978            mask.rect[0] -= viewport_offset[0];
11979            mask.rect[1] -= viewport_offset[1];
11980            mask
11981        });
11982        let dest_viewport = Some((
11983            viewport_offset[0],
11984            viewport_offset[1],
11985            bounds_w as f32,
11986            bounds_h as f32,
11987        ));
11988        {
11989            self.effect_renderer
11990                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
11991                    frame_encoder,
11992                    &device,
11993                    &source,
11994                    target_view,
11995                    1.0,
11996                    wgpu::LoadOp::Load,
11997                    scissor,
11998                    rounded_mask,
11999                    BlendMode::SrcOver,
12000                    dest_viewport,
12001                    CompositeSampleMode::Nearest,
12002                );
12003        }
12004        frame_encoder.record_pass();
12005
12006        self.effect_renderer.record_blur_pass();
12007        self.effect_renderer.record_composite_pass();
12008        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
12009        if let Some(key) = cache_key {
12010            self.insert_cached_shadow_surface(key, source);
12011        } else {
12012            frame_encoder.release_transient_offscreen(source_descriptor, source);
12013        }
12014        true
12015    }
12016
12017    fn prepare_shapes_batch<'a, I>(
12018        &mut self,
12019        layer_shapes: I,
12020        brushes: &[Brush],
12021        root_scale: f32,
12022        viewport: ViewportUniformParams,
12023        staged_uploads: &mut StagedBufferUploads,
12024    ) -> Option<PreparedShapeBatch>
12025    where
12026        I: Iterator<Item = &'a DrawShape>,
12027    {
12028        #[cfg(target_arch = "wasm32")]
12029        let _ = staged_uploads;
12030
12031        // Build shape data for this subset. Callers hand in only shapes visible in
12032        // `viewport`: the segment paths culled at collect time, and the layer and
12033        // shadow-source paths filter at the call site. Re-checking here would run
12034        // the same quad math a second time on every shape of every frame.
12035        let shape_refs: Vec<&DrawShape> = layer_shapes
12036            .take(self.shape_batch_limits.max_shapes_per_batch)
12037            .collect();
12038        let shape_count = shape_refs.len();
12039        if shape_count == 0 {
12040            return None;
12041        }
12042
12043        // Per-shape gradient spans as a prefix sum, so every output slot is
12044        // known before conversion starts and the shapes can convert in
12045        // parallel into disjoint sub-slices.
12046        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12047        let mut total_gradient_stops = 0u32;
12048        gradient_offsets.push(0);
12049        for shape in &shape_refs {
12050            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12051            gradient_offsets.push(total_gradient_stops);
12052        }
12053
12054        self.scratch_shape_data.clear();
12055        self.scratch_shape_data
12056            .resize(shape_count, ShapeData::zeroed());
12057        self.scratch_gradients.clear();
12058        self.scratch_gradients
12059            .resize(total_gradient_stops as usize, GradientStop::zeroed());
12060
12061        convert_shapes_into_outputs(
12062            &shape_refs,
12063            brushes,
12064            &gradient_offsets,
12065            root_scale,
12066            &mut self.scratch_shape_data,
12067            &mut self.scratch_gradients,
12068        );
12069        #[cfg(not(target_arch = "wasm32"))]
12070        {
12071            if fill_area_diag_enabled() {
12072                self.fill_area_diag
12073                    .add_shape_quads(&self.scratch_shape_data, viewport);
12074            }
12075        }
12076
12077        #[cfg(not(target_arch = "wasm32"))]
12078        {
12079            self.shape_buffers.ensure_capacity(
12080                &self.device,
12081                &self.shape_bind_group_layout,
12082                &self.identity_similarity_buffer,
12083                self.dummy_paint_buffer.as_ref(),
12084                shape_count,
12085                self.scratch_gradients.len().max(1),
12086            );
12087            self.stage_viewport_uniforms(staged_uploads, viewport);
12088            staged_uploads.stage(
12089                UploadTarget::ShapeData,
12090                bytemuck::cast_slice(&self.scratch_shape_data),
12091            );
12092            if !self.scratch_gradients.is_empty() {
12093                staged_uploads.stage(
12094                    UploadTarget::ShapeGradient,
12095                    bytemuck::cast_slice(&self.scratch_gradients),
12096                );
12097            }
12098        }
12099
12100        #[cfg(target_arch = "wasm32")]
12101        let shape_slot = {
12102            let slot = self.claim_wasm_shape_batch();
12103            {
12104                let buffers = &mut self.wasm_shape_batches[slot];
12105                buffers.ensure_capacity(
12106                    &self.device,
12107                    &self.shape_bind_group_layout,
12108                    &self.identity_similarity_buffer,
12109                    self.dummy_paint_buffer.as_ref(),
12110                    shape_count,
12111                    self.scratch_gradients.len().max(1),
12112                );
12113            }
12114            let buffers = &self.wasm_shape_batches[slot];
12115            self.write_wasm_buffer(
12116                &buffers.shape_buffer,
12117                bytemuck::cast_slice(&self.scratch_shape_data),
12118            );
12119            if !self.scratch_gradients.is_empty() {
12120                self.write_wasm_buffer(
12121                    &buffers.gradient_buffer,
12122                    bytemuck::cast_slice(&self.scratch_gradients),
12123                );
12124            }
12125            slot
12126        };
12127
12128        #[cfg(target_arch = "wasm32")]
12129        let uniform_slot = self.prepare_wasm_viewport_uniforms(viewport);
12130
12131        Some(PreparedShapeBatch {
12132            vertex_start: 0,
12133            vertex_count: shape_count as u32 * 6,
12134            has_gradient: total_gradient_stops > 0,
12135            #[cfg(target_arch = "wasm32")]
12136            shape_slot,
12137            #[cfg(target_arch = "wasm32")]
12138            uniform_slot,
12139        })
12140    }
12141
12142    /// Like [`Self::prepare_shapes_batch`], but converts shapes straight into
12143    /// mapped regions of the frame upload buffer instead of scratch vectors —
12144    /// one CPU pass over the data instead of three (convert, stage, upload).
12145    /// Returns the prepared batch and the upload-buffer base offset to pass
12146    /// to `flush_staged_uploads_at`; the GPU copies are recorded into
12147    /// `staged_uploads` while its byte blob stays empty.
12148    #[cfg(not(target_arch = "wasm32"))]
12149    fn prepare_shapes_batch_direct<'a, I, C: FrameCommandRecorder>(
12150        &mut self,
12151        frame_encoder: &mut C,
12152        layer_shapes: I,
12153        brushes: &[Brush],
12154        root_scale: f32,
12155        viewport: ViewportUniformParams,
12156        staged_uploads: &mut StagedBufferUploads,
12157    ) -> Option<(PreparedShapeBatch, u64)>
12158    where
12159        I: Iterator<Item = &'a DrawShape>,
12160    {
12161        let shape_refs: Vec<&DrawShape> = layer_shapes
12162            .take(self.shape_batch_limits.max_shapes_per_batch)
12163            .collect();
12164        let shape_count = shape_refs.len();
12165        if shape_count == 0 {
12166            return None;
12167        }
12168
12169        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12170        let mut total_gradient_stops = 0u32;
12171        gradient_offsets.push(0);
12172        for shape in &shape_refs {
12173            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12174            gradient_offsets.push(total_gradient_stops);
12175        }
12176
12177        self.shape_buffers.ensure_capacity(
12178            &self.device,
12179            &self.shape_bind_group_layout,
12180            &self.identity_similarity_buffer,
12181            self.dummy_paint_buffer.as_ref(),
12182            shape_count,
12183            (total_gradient_stops as usize).max(1),
12184        );
12185
12186        self.scratch_shape_data.clear();
12187        self.scratch_shape_data
12188            .resize(shape_count, ShapeData::zeroed());
12189        self.scratch_gradients.clear();
12190        self.scratch_gradients
12191            .resize(total_gradient_stops as usize, GradientStop::zeroed());
12192        convert_shapes_into_outputs(
12193            &shape_refs,
12194            brushes,
12195            &gradient_offsets,
12196            root_scale,
12197            &mut self.scratch_shape_data,
12198            &mut self.scratch_gradients,
12199        );
12200        if fill_area_diag_enabled() {
12201            self.fill_area_diag
12202                .add_shape_quads(&self.scratch_shape_data, viewport);
12203        }
12204
12205        // Region layout inside the frame upload buffer. Every element type is
12206        // f32/u32-based, so all lengths are multiples of
12207        // `COPY_BUFFER_ALIGNMENT` and back-to-back packing keeps each offset
12208        // copy-aligned. Writing each scratch slice straight into the upload
12209        // buffer skips the intermediate staged-bytes blob (one fewer CPU pass
12210        // over the batch payload).
12211        let uniform_len = std::mem::size_of::<Uniforms>() as u64;
12212        let shape_len = (shape_count * std::mem::size_of::<ShapeData>()) as u64;
12213        let gradient_len = total_gradient_stops as u64 * std::mem::size_of::<GradientStop>() as u64;
12214        let total_len = uniform_len + shape_len + gradient_len;
12215        let upload_base = frame_encoder.allocate_staged_upload_bytes(total_len);
12216        self.ensure_upload_buffer_capacity(upload_base + total_len);
12217
12218        let shape_off = uniform_len;
12219        let gradient_off = shape_off + shape_len;
12220
12221        let uniforms = Self::viewport_uniforms(viewport);
12222        let mut upload_stats = self.frame_graph_executor.upload_buffer(
12223            &self.queue,
12224            &self.upload_buffer,
12225            upload_base,
12226            bytemuck::bytes_of(&uniforms),
12227        );
12228        upload_stats.upload_bytes += self
12229            .frame_graph_executor
12230            .upload_buffer(
12231                &self.queue,
12232                &self.upload_buffer,
12233                upload_base + shape_off,
12234                bytemuck::cast_slice(&self.scratch_shape_data),
12235            )
12236            .upload_bytes;
12237        if !self.scratch_gradients.is_empty() {
12238            upload_stats.upload_bytes += self
12239                .frame_graph_executor
12240                .upload_buffer(
12241                    &self.queue,
12242                    &self.upload_buffer,
12243                    upload_base + gradient_off,
12244                    bytemuck::cast_slice(&self.scratch_gradients),
12245                )
12246                .upload_bytes;
12247        }
12248        self.frame_stats.record_command_stats(upload_stats);
12249
12250        staged_uploads.record_upload_copy(UploadTarget::Uniform, 0, 0, uniform_len);
12251        staged_uploads.record_upload_copy(UploadTarget::ShapeData, shape_off, 0, shape_len);
12252        staged_uploads.record_upload_copy(
12253            UploadTarget::ShapeGradient,
12254            gradient_off,
12255            0,
12256            gradient_len,
12257        );
12258
12259        Some((
12260            PreparedShapeBatch {
12261                vertex_start: 0,
12262                vertex_count: shape_count as u32 * 6,
12263                has_gradient: total_gradient_stops > 0,
12264            },
12265            upload_base,
12266        ))
12267    }
12268
12269    /// Whether retained replay batches can exist on this device: they bind
12270    /// unsized buffers, so they ride the storage-buffer batch mode only.
12271    /// Always `false` on wasm, which has no retained replay path — the
12272    /// method exists on both arches so the packet producer has one
12273    /// architecture.
12274    pub(crate) fn replay_supported(&self) -> bool {
12275        // Deliberately not conditioned on free slot ids: an exhausted pool
12276        // only means new captures fail (handled per capture), while flipping
12277        // this bit would retire every live feed slot.
12278        #[cfg(target_arch = "wasm32")]
12279        {
12280            false
12281        }
12282        #[cfg(not(target_arch = "wasm32"))]
12283        {
12284            self.shape_batch_limits.storage
12285        }
12286    }
12287
12288    /// Return the planner-drained ack confirmations buffer (capacity
12289    /// intact) to the store after the producer applied a frame's
12290    /// [`crate::frame_packet::ReplayAck`] — the ack channel's half of the
12291    /// P4b no-allocation contract, closed by the caller now that ack
12292    /// application lives producer-side. No-op on wasm.
12293    pub(crate) fn restore_replay_ack_confirmations(
12294        &mut self,
12295        confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
12296    ) {
12297        #[cfg(not(target_arch = "wasm32"))]
12298        {
12299            self.replay_ack_confirmations = confirmations;
12300        }
12301        #[cfg(target_arch = "wasm32")]
12302        let _ = confirmations;
12303    }
12304
12305    /// The surface format this renderer was constructed for — the present
12306    /// runtime's offscreen test target must match it.
12307    #[cfg(not(target_arch = "wasm32"))]
12308    pub(crate) fn surface_format(&self) -> wgpu::TextureFormat {
12309        self.surface_format
12310    }
12311
12312    /// Test inspector for the threaded confirmations round-trip: the
12313    /// store-side ack buffer's current capacity.
12314    #[cfg(not(target_arch = "wasm32"))]
12315    pub(crate) fn replay_ack_confirmations_capacity(&self) -> usize {
12316        self.replay_ack_confirmations.capacity()
12317    }
12318
12319    /// EARLY present-side consumption of a validated packet's replay plan
12320    /// (threaded runtime only): identical store work to the render-time
12321    /// block in `render_graph_recorded`, but runnable BEFORE surface
12322    /// acquire, so the [`crate::frame_packet::ReplayAck`] can travel to the
12323    /// producer without waiting out the swapchain — a capture confirmed
12324    /// here is available to the very next frame's planning, the same
12325    /// one-frame latency the synchronous path has. Marks the packet so the
12326    /// render path does not consume the taken-out default plan, and so a
12327    /// later cancel does not reclaim it. `None` for Surface roots, which
12328    /// never touch the planner. The caller must have validated the packet
12329    /// (epochs, viewport) first: this executes against the live store.
12330    #[cfg(not(target_arch = "wasm32"))]
12331    pub(crate) fn take_replay_ack_early(
12332        &mut self,
12333        packet: &mut FramePacket,
12334    ) -> Option<(
12335        crate::frame_packet::ReplayAck,
12336        crate::frame_packet::ReplayFrameOps,
12337    )> {
12338        if packet.replay_preconsumed {
12339            return None;
12340        }
12341        let PacketRoot::Direct(root) = &packet.root else {
12342            return None;
12343        };
12344        let ops = std::mem::take(&mut packet.replay);
12345        let root_scale = packet.root_scale;
12346        let (ack, recycled) =
12347            self.consume_replay_ops(ops, &root.scene.shapes, &root.scene.brushes, root_scale);
12348        packet.replay_preconsumed = true;
12349        Some((ack, recycled))
12350    }
12351
12352    /// Present-side consumption of one frame's [`ReplayFrameOps`]: frees
12353    /// the plan's releases, then honors its capture requests against the
12354    /// scene they were recorded for, answering with a [`ReplayAck`] of
12355    /// (identity, gpu slot) confirmations plus the batch's emptied buffers
12356    /// for recycling. This is the store half of the split — it touches NO
12357    /// planner state: `feed_slots`, confirmation stamping, displaced-slot
12358    /// release, and age eviction all live in the planner
12359    /// (`take_frame_ops`/`apply_ack`).
12360    ///
12361    /// Ordering is what makes slot release safe: a slot the plan releases
12362    /// is never referenced by a retained op of the same frame (misses
12363    /// release before their op would have been pushed, and rebuild frames
12364    /// release at flush start), so freeing it here — before any encoding —
12365    /// cannot orphan a draw.
12366    #[cfg(not(target_arch = "wasm32"))]
12367    fn consume_replay_ops(
12368        &mut self,
12369        mut ops: crate::frame_packet::ReplayFrameOps,
12370        shapes: &[DrawShape],
12371        brushes: &[Brush],
12372        root_scale: f32,
12373    ) -> (
12374        crate::frame_packet::ReplayAck,
12375        crate::frame_packet::ReplayFrameOps,
12376    ) {
12377        // The batch's own staleness ordinal, echoed in the ack so the
12378        // planner purges exactly this batch's unconfirmed requests even
12379        // when another batch is already in flight behind it.
12380        let acked_frame = ops.frame;
12381        if ops.generation < self.store_feed_generation {
12382            // Fail-closed: ops planned under an OLDER slot universe name
12383            // slots this store does not hold. Drop the batch whole —
12384            // captures unconfirmed self-heal (the planner never serves
12385            // them), and stale releases must not free live ids.
12386            // Synchronously impossible today; structural for the split.
12387            self.replay_generation_drops += 1;
12388            log::warn!(
12389                "[command-feed] dropping replay ops of generation {} against store \
12390                 generation {} ({} captures, {} patches, {} releases; lifetime drops {})",
12391                ops.generation,
12392                self.store_feed_generation,
12393                ops.captures.len(),
12394                ops.color_patches.len(),
12395                ops.releases.len(),
12396                self.replay_generation_drops,
12397            );
12398            ops.captures.clear();
12399            ops.color_patches.clear();
12400            ops.releases.clear();
12401            return (
12402                crate::frame_packet::ReplayAck {
12403                    generation: self.store_feed_generation,
12404                    frame: acked_frame,
12405                    confirmations: Vec::new(),
12406                },
12407                ops,
12408            );
12409        }
12410        if ops.generation > self.store_feed_generation {
12411            // Adopt forward: a producer-side bump (scale change,
12412            // `retire_feed`) delivers its whole retirement — the releases
12413            // for every retired slot — THROUGH this very batch, so a
12414            // higher generation is the new universe arriving, not a stale
12415            // one. The store follows the producer's authority; it never
12416            // reads the producer's thread-local.
12417            self.store_feed_generation = ops.generation;
12418        }
12419        let generation = ops.generation;
12420        // Queued releases free first, so their buffers are available before
12421        // this frame's captures ask.
12422        for slot in ops.releases.drain(..) {
12423            self.release_replay_slot(slot);
12424        }
12425        // `take` leaves `Vec::new()` behind (no allocation); the render
12426        // loop restores the vec after the planner drains the ack.
12427        let mut confirmations = std::mem::take(&mut self.replay_ack_confirmations);
12428        debug_assert!(confirmations.is_empty());
12429        // One refs buffer for the whole batch: a re-partition frame carries
12430        // one capture per segment, and `shapes` outlives the loop, so each
12431        // capture's collect reuses a single allocation.
12432        let mut refs: Vec<&DrawShape> = Vec::new();
12433        for capture in ops.captures.drain(..) {
12434            if capture.frame != ops.frame {
12435                // Defensive: a capture that outlived its frame references
12436                // shape indices of a scene that never rendered; honoring it
12437                // against THIS frame's shapes would retain wrong content
12438                // under a confirmed identity. Categorically drop it. Should
12439                // never fire now that ops travel inside the frame's own
12440                // packet.
12441                log::warn!(
12442                    "[command-feed] dropping stale capture for slot {} of {:?} \
12443                     (queued frame {}, ops frame {})",
12444                    capture.key.1,
12445                    capture.key.0,
12446                    capture.frame,
12447                    ops.frame,
12448                );
12449                continue;
12450            }
12451            let end = capture.shape_start + capture.shape_count;
12452            let Some(slice) = shapes.get(capture.shape_start..end) else {
12453                continue;
12454            };
12455            refs.clear();
12456            refs.extend(slice.iter());
12457            let Some(gpu_slot) = self.capture_replay_slot(&refs, brushes, root_scale) else {
12458                continue;
12459            };
12460            confirmations.push((capture.key, gpu_slot));
12461        }
12462        // Park the frame's recolor patches for the retained prepare arms
12463        // (`stage_replay_patches`); the vec swapped out is last frame's,
12464        // already drained empty, and returns to the producer with the ack.
12465        // The defensive clear only bites when no prepare arm ran last
12466        // frame (aborted render): those patches targeted a frame that
12467        // never encoded, and their spans re-queue fresh recolors each
12468        // served frame.
12469        self.replay_color_patches.clear();
12470        std::mem::swap(&mut self.replay_color_patches, &mut ops.color_patches);
12471        (
12472            crate::frame_packet::ReplayAck {
12473                generation,
12474                frame: acked_frame,
12475                confirmations,
12476            },
12477            ops,
12478        )
12479    }
12480
12481    /// Test/diagnostic view of the store's lifetime count of replay-ops
12482    /// batches dropped whole by the generation check — the consume gate's
12483    /// proof that Surface frames (default plans, generation 0) are never
12484    /// fed to the store.
12485    #[cfg(not(target_arch = "wasm32"))]
12486    pub(crate) fn replay_generation_drops(&self) -> u64 {
12487        self.replay_generation_drops
12488    }
12489
12490    /// Test hook for the message protocol: runs one planner→store→planner
12491    /// replay cycle outside a frame, with the batch stamped
12492    /// `store_feed_generation + generation_skew`, and returns how many
12493    /// captures the store confirmed. A skew that lands BELOW the store's
12494    /// generation manufactures the fail-closed drop; a skew above it
12495    /// exercises adopt-forward. Both are synchronously impossible through
12496    /// the public render path today.
12497    #[cfg(not(target_arch = "wasm32"))]
12498    pub(crate) fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
12499        let generation = self.store_feed_generation.wrapping_add(generation_skew);
12500        let ops = crate::shape_replay::SHAPE_REPLAY
12501            .with(|state| state.borrow_mut().take_frame_ops(generation));
12502        let (ack, recycled) = self.consume_replay_ops(ops, &[], &[], 1.0);
12503        let confirmed = ack.confirmations.len();
12504        self.replay_ack_confirmations = crate::shape_replay::SHAPE_REPLAY
12505            .with(|state| state.borrow_mut().apply_ack(ack, recycled));
12506        confirmed
12507    }
12508
12509    /// Stages every queued replay recolor patch. Feed recolors are always
12510    /// solid, so every patch rewrites the shape's 16-byte record in the
12511    /// slot's paint buffer; the captured `ShapeData` itself is immutable, so
12512    /// a recolored frame uploads colors, not geometry. Runs in the retained
12513    /// prepare arms so the writes land in the same staged-upload flush that
12514    /// carries the frame's transforms; draining is idempotent across arms.
12515    #[cfg(not(target_arch = "wasm32"))]
12516    fn stage_replay_patches(&mut self, staged_uploads: &mut StagedBufferUploads) {
12517        // Capacity-retaining drain: swap the frame's parked patch buffer
12518        // (see `consume_replay_ops`) against the scratch arena instead of
12519        // `mem::take`, so both keep their high-water capacity across
12520        // frames. The scratch is cleared before every return, which
12521        // preserves drain idempotence across the retained prepare arms: a
12522        // later drain in the same frame swaps one empty-with-capacity
12523        // arena for another and stages nothing.
12524        std::mem::swap(
12525            &mut self.replay_color_patches,
12526            &mut self.color_patch_scratch,
12527        );
12528        let total_patches = self.color_patch_scratch.len();
12529        if total_patches == 0 {
12530            self.replay_upload_stats.note_frame(0, 0, 0, 0, 0);
12531            return;
12532        }
12533
12534        // Patches land in the slot's CPU mirror and upload as one contiguous
12535        // span per slot. Uploading each patch individually would record one
12536        // copy command per patch, and MEGA's twinkle field recolors ~1.7k
12537        // dots a frame — that many commands stall a mobile GPU for longer
12538        // than the spans' untouched bytes ever cost.
12539        #[derive(Clone, Copy)]
12540        struct DirtySpan {
12541            paint_min: u32,
12542            paint_max: u32,
12543        }
12544        const CLEAN: DirtySpan = DirtySpan {
12545            paint_min: u32::MAX,
12546            paint_max: 0,
12547        };
12548        let mut dirty: std::collections::HashMap<
12549            u32,
12550            DirtySpan,
12551            cranpose_ui_graphics::FxBuildHasher,
12552        > = std::collections::HashMap::default();
12553
12554        // One bare 16-byte write into the slot's paint mirror per patch.
12555        for patch in &self.color_patch_scratch {
12556            let Some(slot) = self.replay_slots.slots.get_mut(&patch.slot) else {
12557                continue;
12558            };
12559            let Some(paint) = slot.paint_mirror.get_mut(patch.shape_index as usize) else {
12560                continue;
12561            };
12562            *paint = patch.color;
12563            let span = dirty.entry(patch.slot).or_insert(CLEAN);
12564            span.paint_min = span.paint_min.min(patch.shape_index);
12565            span.paint_max = span.paint_max.max(patch.shape_index);
12566        }
12567
12568        let mut uploaded_records = 0u64;
12569        let mut uploaded_bytes = 0u64;
12570        let slots_touched = dirty.len() as u64;
12571        for (slot_id, span) in dirty {
12572            let Some(slot) = self.replay_slots.slots.get(&slot_id) else {
12573                continue;
12574            };
12575            if span.paint_min <= span.paint_max {
12576                let range = span.paint_min as usize..span.paint_max as usize + 1;
12577                uploaded_records += range.len() as u64;
12578                uploaded_bytes += (range.len() * std::mem::size_of::<[f32; 4]>()) as u64;
12579                staged_uploads.stage_at(
12580                    UploadTarget::ReplayPaintData(slot_id),
12581                    range.start as u64 * std::mem::size_of::<[f32; 4]>() as u64,
12582                    bytemuck::cast_slice(&slot.paint_mirror[range]),
12583                );
12584            }
12585        }
12586        // A patched color is one 16-byte vec4; the staged bytes exceed this
12587        // only by the untouched records inside each coalesced span.
12588        let ideal_bytes = total_patches as u64 * 16;
12589        self.replay_upload_stats.note_frame(
12590            total_patches as u64,
12591            slots_touched,
12592            uploaded_records,
12593            uploaded_bytes,
12594            ideal_bytes,
12595        );
12596        if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
12597            log::warn!(
12598                "[replay-upload] frame: {} patches -> {} records / {:.1} KB staged \
12599                 across {} slots (color-only {:.1} KB)",
12600                total_patches,
12601                uploaded_records,
12602                uploaded_bytes as f64 / 1024.0,
12603                slots_touched,
12604                ideal_bytes as f64 / 1024.0,
12605            );
12606        }
12607        self.color_patch_scratch.clear();
12608    }
12609
12610    /// Converts `shape_refs` once and retains the result on the GPU as a
12611    /// replay slot. Returns the slot id the scene's retained draws reference.
12612    #[cfg(not(target_arch = "wasm32"))]
12613    pub(crate) fn capture_replay_slot(
12614        &mut self,
12615        shape_refs: &[&DrawShape],
12616        brushes: &[Brush],
12617        root_scale: f32,
12618    ) -> Option<u32> {
12619        if !self.shape_batch_limits.storage || shape_refs.is_empty() {
12620            return None;
12621        }
12622        let id = self.replay_slots.free_ids.pop()?;
12623        let shape_count = shape_refs.len();
12624
12625        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12626        let mut total_gradient_stops = 0u32;
12627        gradient_offsets.push(0);
12628        for shape in shape_refs {
12629            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12630            gradient_offsets.push(total_gradient_stops);
12631        }
12632
12633        // Staging scratch, not fresh vectors: cleared and re-zeroed to this
12634        // capture's exact sizes, capacity kept across captures.
12635        let mut shape_data = std::mem::take(&mut self.replay_capture_shape_scratch);
12636        shape_data.clear();
12637        shape_data.resize(shape_count, ShapeData::zeroed());
12638        let mut gradients = std::mem::take(&mut self.replay_capture_gradient_scratch);
12639        gradients.clear();
12640        gradients.resize(
12641            (total_gradient_stops as usize).max(1),
12642            GradientStop::zeroed(),
12643        );
12644        convert_shapes_into_outputs(
12645            shape_refs,
12646            brushes,
12647            &gradient_offsets,
12648            root_scale,
12649            &mut shape_data,
12650            &mut gradients,
12651        );
12652
12653        let shape_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12654            label: Some("Replay Shape Buffer"),
12655            size: (std::mem::size_of::<ShapeData>() * shape_count) as u64,
12656            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
12657            mapped_at_creation: true,
12658        });
12659        shape_buffer
12660            .slice(..)
12661            .get_mapped_range_mut()
12662            .copy_from_slice(bytemuck::cast_slice(&shape_data));
12663        shape_buffer.unmap();
12664
12665        let gradient_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12666            label: Some("Replay Gradient Buffer"),
12667            size: (std::mem::size_of::<GradientStop>() * gradients.len()) as u64,
12668            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
12669            mapped_at_creation: true,
12670        });
12671        gradient_buffer
12672            .slice(..)
12673            .get_mapped_range_mut()
12674            .copy_from_slice(bytemuck::cast_slice(&gradients));
12675        gradient_buffer.unmap();
12676
12677        // Filled by the mesh arm when the capture keeps its arc mesh, so
12678        // the fill-diag records can price those shapes by their true
12679        // triangle area.
12680        let mut mesh_fill_records: Option<Vec<FillDiagShapeRecord>> = None;
12681        let mut submitted_area_scale = 1.0f32;
12682        let mesh = if arc_mesh_enabled() {
12683            match build_arc_mesh_vertices(&shape_data, retained_mesh_min_px2()) {
12684                Some(build) => {
12685                    let meshed_shapes = build.meshed_arcs + build.meshed_rims;
12686                    // A pathological meshed/instanced interleave would spend
12687                    // more on pipeline switches than the bands recover; the
12688                    // whole slot stays instanced instead (content-conditional
12689                    // — a property of this capture's shape order).
12690                    let within_stretch_cap = build.meshed_stretches <= MESH_SLOT_MAX_STRETCHES;
12691                    let cut = if build.quad_area > 0.0 {
12692                        (1.0 - build.mesh_area / build.quad_area) * 100.0
12693                    } else {
12694                        0.0
12695                    };
12696                    // Always-on warn: `log::info` is invisible on the desktop
12697                    // console, and captures are rare — one line per slot
12698                    // lifetime. The unique-vert/index counts are the
12699                    // vertex-amplification instrument P1b exists for; the
12700                    // meshed/instanced split and the stretch count are the
12701                    // size gate's own engagement instrument.
12702                    log::warn!(
12703                        "[arc-mesh] slot {id}: {} arcs + {} rims meshed ({} segs, \
12704                         {} stretches), {} instanced; {} unique verts / {} indices; \
12705                         quad_px {:.0} -> submit_px {:.0} (-{:.1}%)",
12706                        build.meshed_arcs,
12707                        build.meshed_rims,
12708                        build.meshed_segments,
12709                        build.meshed_stretches,
12710                        build.passthrough,
12711                        build.vertices.len(),
12712                        build.indices.len(),
12713                        build.quad_area,
12714                        build.mesh_area,
12715                        cut,
12716                    );
12717                    if !within_stretch_cap {
12718                        log::warn!(
12719                            "[arc-mesh] slot {id}: {} meshed stretches exceed the \
12720                             {MESH_SLOT_MAX_STRETCHES}-stretch switch cap; slot stays instanced",
12721                            build.meshed_stretches,
12722                        );
12723                    }
12724                    let keep_mesh = meshed_shapes > 0 && within_stretch_cap;
12725                    if keep_mesh && build.quad_area > 0.0 {
12726                        // What this slot's replay actually rasterizes per
12727                        // quad pixel, for the segment-surface economics
12728                        // gate. Clamped away from zero so a degenerate
12729                        // measurement cannot make the direct path look
12730                        // free.
12731                        submitted_area_scale =
12732                            (build.mesh_area / build.quad_area).clamp(0.05, 1.0) as f32;
12733                    }
12734                    if keep_mesh && fill_area_diag_enabled() {
12735                        mesh_fill_records = Some(fill_diag_capture_records(
12736                            &shape_data,
12737                            Some((&build.vertices, &build.indices, &build.index_prefix)),
12738                        ));
12739                    }
12740                    // A slot that meshed nothing gains nothing over the
12741                    // instanced path — skip the buffers.
12742                    keep_mesh.then(|| {
12743                        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12744                            label: Some("Replay Mesh Vertex Buffer"),
12745                            size: (std::mem::size_of::<MeshVertex>() * build.vertices.len()) as u64,
12746                            usage: wgpu::BufferUsages::VERTEX,
12747                            mapped_at_creation: true,
12748                        });
12749                        vertex_buffer
12750                            .slice(..)
12751                            .get_mapped_range_mut()
12752                            .copy_from_slice(bytemuck::cast_slice(&build.vertices));
12753                        vertex_buffer.unmap();
12754                        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12755                            label: Some("Replay Mesh Index Buffer"),
12756                            size: (std::mem::size_of::<u32>() * build.indices.len()) as u64,
12757                            usage: wgpu::BufferUsages::INDEX,
12758                            mapped_at_creation: true,
12759                        });
12760                        index_buffer
12761                            .slice(..)
12762                            .get_mapped_range_mut()
12763                            .copy_from_slice(bytemuck::cast_slice(&build.indices));
12764                        index_buffer.unmap();
12765                        ReplaySlotMesh {
12766                            vertex_buffer,
12767                            index_buffer,
12768                            index_prefix: build.index_prefix,
12769                            meshed_arcs: build.meshed_arcs,
12770                            meshed_rims: build.meshed_rims,
12771                            passthrough: build.passthrough,
12772                        }
12773                    })
12774                }
12775                None => {
12776                    log::warn!(
12777                        "[arc-mesh] slot {id}: geometry byte budget overflowed for \
12778                         {shape_count} shapes; whole slot stays instanced"
12779                    );
12780                    None
12781                }
12782            }
12783        } else {
12784            None
12785        };
12786
12787        let fill_diag_shapes = if fill_area_diag_enabled() {
12788            let records =
12789                mesh_fill_records.unwrap_or_else(|| fill_diag_capture_records(&shape_data, None));
12790            // Feed the once-per-process top-slack dump before the records
12791            // move into the slot.
12792            self.fill_area_diag.note_retained_capture(id, &records);
12793            records
12794        } else {
12795            Vec::new()
12796        };
12797
12798        // Capture-space quad AABBs and the quad-area prefix sum for the
12799        // segment-surface cache: the quads are the exact geometry the
12800        // replay rasterizes, so a range's surface economics and capture
12801        // rect derive from them with no second conversion.
12802        let mut shape_aabbs = Vec::with_capacity(shape_count);
12803        let mut area_prefix = Vec::with_capacity(shape_count + 1);
12804        area_prefix.push(0.0f32);
12805        for shape in &shape_data {
12806            let corners = [
12807                [shape.quad01[0], shape.quad01[1]],
12808                [shape.quad01[2], shape.quad01[3]],
12809                [shape.quad23[0], shape.quad23[1]],
12810                [shape.quad23[2], shape.quad23[3]],
12811            ];
12812            let mut min_x = f32::INFINITY;
12813            let mut min_y = f32::INFINITY;
12814            let mut max_x = f32::NEG_INFINITY;
12815            let mut max_y = f32::NEG_INFINITY;
12816            for corner in corners {
12817                min_x = min_x.min(corner[0]);
12818                min_y = min_y.min(corner[1]);
12819                max_x = max_x.max(corner[0]);
12820                max_y = max_y.max(corner[1]);
12821            }
12822            shape_aabbs.push([min_x, min_y, max_x, max_y]);
12823            // Shoelace over the quad's boundary order (corners 0, 1, 3, 2 —
12824            // the two triangles share the 1-2 diagonal).
12825            let ring = [corners[0], corners[1], corners[3], corners[2]];
12826            let mut doubled = 0.0f32;
12827            for i in 0..4 {
12828                let a = ring[i];
12829                let b = ring[(i + 1) % 4];
12830                doubled += a[0] * b[1] - b[0] * a[1];
12831            }
12832            let area = (doubled * 0.5).abs();
12833            let running = *area_prefix.last().expect("prefix seeded with 0.0");
12834            area_prefix.push(running + area);
12835        }
12836
12837        // Seed the mutable paint from the converted colors, so an unpatched
12838        // replay renders bit-identically to the capture frame.
12839        let paint: Vec<[f32; 4]> = shape_data.iter().map(|shape| shape.color).collect();
12840        let paint_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12841            label: Some("Replay Paint Buffer"),
12842            size: (std::mem::size_of::<[f32; 4]>() * shape_count) as u64,
12843            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
12844            mapped_at_creation: true,
12845        });
12846        paint_buffer
12847            .slice(..)
12848            .get_mapped_range_mut()
12849            .copy_from_slice(bytemuck::cast_slice(&paint));
12850        paint_buffer.unmap();
12851
12852        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
12853            label: Some("Replay Shape Bind Group"),
12854            layout: &self.shape_bind_group_layout,
12855            entries: &[
12856                wgpu::BindGroupEntry {
12857                    binding: 0,
12858                    resource: shape_buffer.as_entire_binding(),
12859                },
12860                wgpu::BindGroupEntry {
12861                    binding: 1,
12862                    resource: gradient_buffer.as_entire_binding(),
12863                },
12864                // The transform slot is selected per draw via the dynamic
12865                // offset, so retained draws sharing this capture can each
12866                // move independently.
12867                wgpu::BindGroupEntry {
12868                    binding: 2,
12869                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
12870                        buffer: &self.replay_slots.transform_buffer,
12871                        offset: 0,
12872                        size: Some(
12873                            std::num::NonZeroU64::new(
12874                                std::mem::size_of::<SimilarityTransform>() as u64
12875                            )
12876                            .expect("similarity transform is non-empty"),
12877                        ),
12878                    }),
12879                },
12880                wgpu::BindGroupEntry {
12881                    binding: 3,
12882                    resource: paint_buffer.as_entire_binding(),
12883                },
12884            ],
12885        });
12886
12887        let capture_epoch = self.replay_slots.next_capture_epoch;
12888        self.replay_slots.next_capture_epoch += 1;
12889        self.replay_slots.slots.insert(
12890            id,
12891            ReplaySlot {
12892                paint_buffer,
12893                bind_group,
12894                shape_count: shape_count as u32,
12895                paint_mirror: paint,
12896                mesh,
12897                capture_epoch,
12898                has_gradient: total_gradient_stops > 0,
12899                fill_diag_shapes,
12900                shape_aabbs,
12901                area_prefix,
12902                submitted_area_scale,
12903            },
12904        );
12905        // The staging buffers return to their scratch slots, contents
12906        // spent, capacity kept for the next capture.
12907        self.replay_capture_shape_scratch = shape_data;
12908        self.replay_capture_gradient_scratch = gradients;
12909        Some(id)
12910    }
12911
12912    /// Frees a replay slot's GPU resources and returns its id to the pool.
12913    #[cfg(not(target_arch = "wasm32"))]
12914    pub(crate) fn release_replay_slot(&mut self, id: u32) {
12915        if self.replay_slots.slots.remove(&id).is_some() {
12916            self.replay_slots.free_ids.push(id);
12917            // A cached bundle keeps references on the slot buffers it binds.
12918            // The epoch in each key already makes entries for this capture
12919            // unreachable — releases are rare (churn, retire_feed), so drop
12920            // the whole cache and free those references now rather than one
12921            // frame later through eviction.
12922            self.retained_bundle_cache.clear();
12923            // Segment death: every surface captured from this slot dies
12924            // with it.
12925            self.segment_surfaces.drop_slot(id);
12926        }
12927    }
12928
12929    /// Test/diagnostic view of the retained-segment surface cache:
12930    /// lifetime (captures, composite draws, dirty recaptures, churn
12931    /// rejections, economics rejections).
12932    #[cfg(not(target_arch = "wasm32"))]
12933    #[doc(hidden)]
12934    pub fn segment_surface_stats(&self) -> (u64, u64, u64, u64, u64) {
12935        let stats = &self.segment_surfaces.stats;
12936        (
12937            stats.captures,
12938            stats.composites,
12939            stats.dirty_recaptures,
12940            stats.rejected_churn,
12941            stats.rejected_economics,
12942        )
12943    }
12944
12945    /// Test/diagnostic view of the latched instanced-quad selection: `true`
12946    /// when this renderer's ordinary shape draws ride `vs_shape_instanced`.
12947    #[cfg(not(target_arch = "wasm32"))]
12948    #[doc(hidden)]
12949    pub fn instanced_quads_active(&self) -> bool {
12950        self.instanced_quads.is_some()
12951    }
12952
12953    /// Test/diagnostic view of retained arc meshes: how many live replay
12954    /// slots hold a mesh, out of all live slots.
12955    #[cfg(not(target_arch = "wasm32"))]
12956    #[doc(hidden)]
12957    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
12958        let meshed = self
12959            .replay_slots
12960            .slots
12961            .values()
12962            .filter(|slot| slot.mesh.is_some())
12963            .count();
12964        (meshed, self.replay_slots.slots.len())
12965    }
12966
12967    /// Test/diagnostic view of the capture size gate, summed over live slots
12968    /// that hold a mesh: (shapes meshed as arc bands, shapes meshed as
12969    /// stroked-circle rim bands, shapes on the passthrough quad).
12970    #[cfg(not(target_arch = "wasm32"))]
12971    #[doc(hidden)]
12972    pub fn replay_slot_mesh_engagement(&self) -> (usize, usize, usize) {
12973        self.replay_slots
12974            .slots
12975            .values()
12976            .filter_map(|slot| slot.mesh.as_ref())
12977            .fold((0, 0, 0), |(arcs, rims, passthrough), mesh| {
12978                (
12979                    arcs + mesh.meshed_arcs,
12980                    rims + mesh.meshed_rims,
12981                    passthrough + mesh.passthrough,
12982                )
12983            })
12984    }
12985
12986    /// Segment-surface phase 1 for one fused partition: walks the chunk's
12987    /// retained items, runs [`SegmentSurfaceCache::decide`] per item, and
12988    /// for each (re)capture acquires the surface, installs the entry and
12989    /// stages the capture similarity at its reserved transform slot. Emits
12990    /// the frame's capture jobs and per-item composite plans.
12991    #[cfg(not(target_arch = "wasm32"))]
12992    #[allow(clippy::too_many_arguments)]
12993    fn plan_segment_surfaces(
12994        &mut self,
12995        segment_surfaces: &mut SegmentSurfaceCache,
12996        ordered_items: &[(usize, SegmentDrawItem)],
12997        chunk: &SegmentDrawChunkPlan,
12998        retained_draws: &[RetainedDraw],
12999        staged_uploads: &mut StagedBufferUploads,
13000        captures: &mut Vec<SegmentCaptureJob>,
13001        composites: &mut Vec<(usize, SegmentCompositePlan)>,
13002    ) {
13003        segment_surfaces.ensure_dirty_map(
13004            self.replay_color_patches
13005                .iter()
13006                .map(|patch| (patch.slot, patch.shape_index)),
13007        );
13008        let max_texture_dim = self.effect_renderer.max_texture_dim();
13009        for batch in chunk.iter() {
13010            let SegmentBatchPlan::Retained { start, end } = batch else {
13011                continue;
13012            };
13013            for (_, item) in &ordered_items[start..end] {
13014                let SegmentDrawItem::Retained(index) = item else {
13015                    continue;
13016                };
13017                // Items past the transform-slot budget stage no transform
13018                // and draw nothing on the direct path either; leave them.
13019                if (*index as u32) >= MAX_REPLAY_SLOTS {
13020                    continue;
13021                }
13022                let Some(retained) = retained_draws.get(*index) else {
13023                    continue;
13024                };
13025                let transform = retained.transform;
13026                let (key, capture_epoch, dirty) = {
13027                    let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
13028                        continue;
13029                    };
13030                    let first = retained.first_shape.min(slot.shape_count);
13031                    let last = retained
13032                        .first_shape
13033                        .saturating_add(retained.shape_count)
13034                        .min(slot.shape_count);
13035                    if first >= last {
13036                        continue;
13037                    }
13038                    (
13039                        SegmentSurfaceKey {
13040                            slot: retained.slot,
13041                            first_shape: first,
13042                            shape_count: last - first,
13043                        },
13044                        slot.capture_epoch,
13045                        segment_surfaces.range_dirty(retained.slot, first, last),
13046                    )
13047                };
13048                let first = key.first_shape;
13049                let last = key.first_shape + key.shape_count;
13050                let slots = &self.replay_slots.slots;
13051                let decision =
13052                    segment_surfaces.decide(key, capture_epoch, dirty, transform.scale, || {
13053                        let slot = slots.get(&key.slot)?;
13054                        plan_segment_capture_geometry(slot, first, last, transform, max_texture_dim)
13055                    });
13056                let SegmentSurfaceDecision::Composite { capture } = decision else {
13057                    continue;
13058                };
13059                if let Some(plan) = capture {
13060                    let texture = segment_surfaces
13061                        .take_texture_for_recapture(&key, &plan.rect)
13062                        .unwrap_or_else(|| {
13063                            let device = self.device.clone();
13064                            self.effect_renderer.acquire_offscreen(
13065                                &device,
13066                                plan.rect.width,
13067                                plan.rect.height,
13068                                Some(&self.frame_stats),
13069                            )
13070                        });
13071                    segment_surfaces.install_entry(
13072                        key,
13073                        capture_epoch,
13074                        transform.center,
13075                        transform.rot,
13076                        transform.scale,
13077                        plan.rect,
13078                        texture,
13079                    );
13080                    // The capture renders the span under ITS OWN current
13081                    // similarity (retained paint selected), staged at the
13082                    // reserved slot past every per-draw transform.
13083                    staged_uploads.stage_at(
13084                        UploadTarget::ReplayTransform,
13085                        (MAX_REPLAY_SLOTS + plan.index) as u64 * REPLAY_TRANSFORM_STRIDE,
13086                        bytemuck::bytes_of(&transform.with_retained_paint()),
13087                    );
13088                    // The capture viewport uniforms are written directly:
13089                    // the cache is moved out of `self` for the partition,
13090                    // so its buffer cannot ride the staged-upload flush
13091                    // (which resolves targets on `self`). Queue writes
13092                    // execute before any later-submitted command buffer —
13093                    // exactly the capture pass's ordering need.
13094                    let uniforms = Self::viewport_uniforms(ViewportUniformParams {
13095                        width: plan.rect.width,
13096                        height: plan.rect.height,
13097                        offset: plan.rect.origin,
13098                    });
13099                    let device = self.device.clone();
13100                    let capture_uniforms =
13101                        segment_surfaces.capture_uniforms(&device, &self.uniform_bind_group_layout);
13102                    let upload_stats = self.frame_graph_executor.upload_buffer(
13103                        &self.queue,
13104                        &capture_uniforms.buffer,
13105                        plan.index as u64 * SEGMENT_CAPTURE_UNIFORM_STRIDE,
13106                        bytemuck::bytes_of(&uniforms),
13107                    );
13108                    self.frame_stats.record_command_stats(upload_stats);
13109                    captures.push(SegmentCaptureJob {
13110                        key,
13111                        first,
13112                        last,
13113                        capture_index: plan.index,
13114                    });
13115                    // Fresh capture: the effective transform is identity by
13116                    // construction, snapped exact so the composite is a 1:1
13117                    // texel mapping.
13118                    composites.push((
13119                        *index,
13120                        SegmentCompositePlan {
13121                            key,
13122                            dest_quad: segment_identity_quad(&plan.rect),
13123                            inverse: segment_identity_inverse(&plan.rect),
13124                            identity: true,
13125                        },
13126                    ));
13127                } else {
13128                    let Some(entry) = segment_surfaces.entry(&key) else {
13129                        continue;
13130                    };
13131                    let t_now =
13132                        Affine2::from_similarity(transform.center, transform.rot, transform.scale);
13133                    let t_cap =
13134                        Affine2::from_similarity(entry.cap_center, entry.cap_rot, entry.cap_scale);
13135                    let Some(cap_inverse) = t_cap.invert() else {
13136                        segment_surfaces.remove(&key);
13137                        continue;
13138                    };
13139                    let effective = t_now.compose(&cap_inverse);
13140                    let rect = entry.rect;
13141                    let plan = if effective.is_identity_for_sampling() {
13142                        // Snap away the compose/invert float noise so the
13143                        // identity frame is a texel-exact mapping.
13144                        SegmentCompositePlan {
13145                            key,
13146                            dest_quad: segment_identity_quad(&rect),
13147                            inverse: segment_identity_inverse(&rect),
13148                            identity: true,
13149                        }
13150                    } else {
13151                        let Some(inverse) = effective.invert() else {
13152                            segment_surfaces.remove(&key);
13153                            continue;
13154                        };
13155                        SegmentCompositePlan {
13156                            key,
13157                            dest_quad: segment_identity_quad(&rect).map(|c| effective.apply(c)),
13158                            inverse: [
13159                                [
13160                                    inverse.l[0][0],
13161                                    inverse.l[0][1],
13162                                    inverse.t[0] - rect.origin[0],
13163                                ],
13164                                [
13165                                    inverse.l[1][0],
13166                                    inverse.l[1][1],
13167                                    inverse.t[1] - rect.origin[1],
13168                                ],
13169                                [0.0, 0.0, 1.0],
13170                            ],
13171                            identity: false,
13172                        }
13173                    };
13174                    composites.push((*index, plan));
13175                }
13176            }
13177        }
13178    }
13179
13180    /// Draws one retained replay batch — `retained`'s shape range of its
13181    /// slot's capture, under the transform staged for this draw's index (see
13182    /// the retained arms of the segment paths).
13183    #[cfg(not(target_arch = "wasm32"))]
13184    fn draw_retained_batch(
13185        &self,
13186        render_pass: &mut wgpu::RenderPass<'_>,
13187        retained: &RetainedDraw,
13188        retained_index: usize,
13189        width: u32,
13190        height: u32,
13191    ) {
13192        let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
13193            return;
13194        };
13195        if retained_index as u32 >= MAX_REPLAY_SLOTS {
13196            return;
13197        }
13198        let first = retained.first_shape.min(slot.shape_count);
13199        let last = retained
13200            .first_shape
13201            .saturating_add(retained.shape_count)
13202            .min(slot.shape_count);
13203        if first >= last {
13204            return;
13205        }
13206        if fill_area_diag_enabled() {
13207            self.fill_area_diag.add_retained_range(
13208                &slot.fill_diag_shapes,
13209                first,
13210                last,
13211                &retained.transform,
13212            );
13213        }
13214        self.frame_stats.bump_shapes();
13215        render_pass.set_scissor_rect(0, 0, width, height);
13216        let draws =
13217            self.encode_retained_op(
13218                slot,
13219                first,
13220                last,
13221                retained_index as u32,
13222                &mut |cmd| match cmd {
13223                    RetainedCmd::Pipeline(pipeline) => render_pass.set_pipeline(pipeline),
13224                    RetainedCmd::Uniforms(group) => render_pass.set_bind_group(0, group, &[]),
13225                    RetainedCmd::SlotBindings(group, offset) => {
13226                        render_pass.set_bind_group(1, group, &[offset])
13227                    }
13228                    RetainedCmd::MeshVertices(buffer) => {
13229                        render_pass.set_vertex_buffer(0, buffer.slice(..))
13230                    }
13231                    RetainedCmd::Index(buffer, format) => {
13232                        render_pass.set_index_buffer(buffer.slice(..), format)
13233                    }
13234                    RetainedCmd::Draw(vertices) => render_pass.draw(vertices, 0..1),
13235                    RetainedCmd::DrawIndexed(indices, instances) => {
13236                        render_pass.draw_indexed(indices, 0, instances)
13237                    }
13238                },
13239            );
13240        self.frame_stats.add_draw_calls(draws);
13241    }
13242
13243    /// Emits one retained op's draw commands into `sink` — the SINGLE
13244    /// encoding shared by the direct pass path
13245    /// ([`Self::draw_retained_batch`]) and the cached-bundle path
13246    /// ([`Self::build_retained_bundle`]), so the two cannot drift. Returns
13247    /// the number of draw calls issued.
13248    ///
13249    /// A slot without a mesh draws its whole range through the latched
13250    /// instanced-quad pipeline (four vertex executions per shape, shape
13251    /// index from the instance index), else the plain six-vertex expansion.
13252    /// A slot WITH a mesh alternates along the range: maximal runs of
13253    /// meshed shapes (non-empty [`ReplaySlotMesh::index_prefix`] ranges)
13254    /// draw their band triangles through the mesh pipeline in one
13255    /// `draw_indexed` each, and every other run STAYS instanced — routing
13256    /// passthrough quads through per-vertex mesh attributes instead was the
13257    /// S3 loss the watch measured (see [`arc_mesh_enabled`]). The walk
13258    /// preserves exact shape order, so z is untouched, and every pipeline
13259    /// involved blends SrcOver. Bind groups are set once up front: all the
13260    /// pipelines share the uniform + shape bind-group layouts, so they stay
13261    /// bound across pipeline switches; the mesh vertex buffer likewise
13262    /// stays bound across instanced stretches because the instanced
13263    /// pipeline declares no vertex buffers — only the index buffer
13264    /// alternates. The alternation is a pure function of the capture-fixed
13265    /// `index_prefix` and `first..last`, which is what lets
13266    /// [`RetainedBundleOpKey`] pin the encoding by capture epoch and range
13267    /// alone.
13268    #[cfg(not(target_arch = "wasm32"))]
13269    fn encode_retained_op<'r>(
13270        &'r self,
13271        slot: &'r ReplaySlot,
13272        first: u32,
13273        last: u32,
13274        retained_index: u32,
13275        sink: &mut impl FnMut(RetainedCmd<'r>),
13276    ) -> u32 {
13277        sink(RetainedCmd::Uniforms(&self.uniform_bind_group));
13278        sink(RetainedCmd::SlotBindings(
13279            &slot.bind_group,
13280            retained_index * REPLAY_TRANSFORM_STRIDE as u32,
13281        ));
13282        let Some(mesh) = slot.mesh.as_ref() else {
13283            self.encode_retained_instanced(slot, first..last, sink);
13284            return 1;
13285        };
13286        sink(RetainedCmd::MeshVertices(&mesh.vertex_buffer));
13287        let prefix = &mesh.index_prefix;
13288        let meshed_at = |shape: u32| prefix[shape as usize + 1] > prefix[shape as usize];
13289        let mut draws = 0;
13290        let mut cursor = first;
13291        while cursor < last {
13292            let run_meshed = meshed_at(cursor);
13293            let mut end = cursor + 1;
13294            while end < last && meshed_at(end) == run_meshed {
13295                end += 1;
13296            }
13297            if run_meshed {
13298                sink(RetainedCmd::Pipeline(self.mesh_pipeline()));
13299                sink(RetainedCmd::Index(
13300                    &mesh.index_buffer,
13301                    wgpu::IndexFormat::Uint32,
13302                ));
13303                sink(RetainedCmd::DrawIndexed(
13304                    prefix[cursor as usize]..prefix[end as usize],
13305                    0..1,
13306                ));
13307            } else {
13308                self.encode_retained_instanced(slot, cursor..end, sink);
13309            }
13310            draws += 1;
13311            cursor = end;
13312        }
13313        draws
13314    }
13315
13316    /// One instanced-quad (or, unlatched, six-vertex expansion) draw over a
13317    /// contiguous shape range of a retained slot — the passthrough arm of
13318    /// [`Self::encode_retained_op`]. The solid-vs-gradient pipeline choice
13319    /// is fixed per capture, so a cached bundle can never encode a stale
13320    /// pipeline for a slot id (the op key carries the capture epoch).
13321    #[cfg(not(target_arch = "wasm32"))]
13322    fn encode_retained_instanced<'r>(
13323        &'r self,
13324        slot: &ReplaySlot,
13325        range: Range<u32>,
13326        sink: &mut impl FnMut(RetainedCmd<'r>),
13327    ) {
13328        match &self.instanced_quads {
13329            Some(instanced) => {
13330                if slot.has_gradient {
13331                    sink(RetainedCmd::Pipeline(
13332                        self.instanced_pipeline(instanced, BlendMode::SrcOver),
13333                    ));
13334                } else {
13335                    sink(RetainedCmd::Pipeline(
13336                        self.instanced_pipeline_solid(instanced),
13337                    ));
13338                }
13339                sink(RetainedCmd::Index(
13340                    &instanced.index_buffer,
13341                    wgpu::IndexFormat::Uint16,
13342                ));
13343                sink(RetainedCmd::DrawIndexed(0..6, range));
13344            }
13345            None => {
13346                if slot.has_gradient {
13347                    sink(RetainedCmd::Pipeline(
13348                        self.shape_pipeline(BlendMode::SrcOver),
13349                    ));
13350                } else {
13351                    sink(RetainedCmd::Pipeline(self.shape_pipeline_solid()));
13352                }
13353                sink(RetainedCmd::Draw(range.start * 6..range.end * 6));
13354            }
13355        }
13356    }
13357
13358    /// Key of the retained stretch at `item_range`: one op key per resolved
13359    /// retained item, in draw order, carrying exactly the state that decides
13360    /// the commands [`Self::draw_retained_batch`] would encode for it —
13361    /// clamped range, dynamic-offset index, whether the mesh-vs-instanced
13362    /// draw walk runs, and the slot's capture epoch, which pins the walk's
13363    /// stretch structure (`None` while the slot is absent, when the op
13364    /// draws nothing on the direct path too).
13365    #[cfg(not(target_arch = "wasm32"))]
13366    fn retained_bundle_key(
13367        &self,
13368        ordered_items: &[(usize, SegmentDrawItem)],
13369        retained_draws: &[RetainedDraw],
13370        item_range: Range<usize>,
13371    ) -> RetainedBundleKey {
13372        let mut ops = Vec::with_capacity(item_range.len());
13373        for (_, item) in &ordered_items[item_range] {
13374            let SegmentDrawItem::Retained(index) = item else {
13375                continue;
13376            };
13377            let Some(retained) = retained_draws.get(*index) else {
13378                continue;
13379            };
13380            let slot = self.replay_slots.slots.get(&retained.slot);
13381            let (first, last) = match slot {
13382                Some(slot) => (
13383                    retained.first_shape.min(slot.shape_count),
13384                    retained
13385                        .first_shape
13386                        .saturating_add(retained.shape_count)
13387                        .min(slot.shape_count),
13388                ),
13389                None => (
13390                    retained.first_shape,
13391                    retained.first_shape.saturating_add(retained.shape_count),
13392                ),
13393            };
13394            ops.push(RetainedBundleOpKey {
13395                slot: retained.slot,
13396                capture_epoch: slot.map(|slot| slot.capture_epoch),
13397                first,
13398                last,
13399                retained_index: *index as u32,
13400                has_mesh: slot.is_some_and(|slot| slot.mesh.is_some())
13401                    && self.shape_batch_limits.storage,
13402            });
13403        }
13404        RetainedBundleKey {
13405            depth: self.pass_depth(),
13406            ops,
13407        }
13408    }
13409
13410    /// Encodes `key`'s stretch into a render bundle: the IDENTICAL command
13411    /// sequence [`Self::draw_retained_batch`] issues on the pass, minus the
13412    /// scissor reset (bundles cannot set scissor; the caller sets the same
13413    /// full-target scissor on the pass before executing). Must only be
13414    /// called with a key built this frame, so every op with an epoch still
13415    /// resolves to its slot.
13416    #[cfg(not(target_arch = "wasm32"))]
13417    fn build_retained_bundle(&self, key: &RetainedBundleKey) -> wgpu::RenderBundle {
13418        let mut encoder =
13419            self.device
13420                .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor {
13421                    label: Some("Retained Stretch Bundle"),
13422                    // Every fused-pass target — the swapchain, screenshot
13423                    // textures, pooled layer surfaces — is created with the
13424                    // renderer's one surface format.
13425                    color_formats: &[Some(self.surface_format)],
13426                    // A display-clip culled pass carries the depth
13427                    // attachment; the bundle only reads it (content
13428                    // pipelines test `Less`, write off), hence read-only on
13429                    // both aspects.
13430                    depth_stencil: key.depth.then_some(wgpu::RenderBundleDepthStencil {
13431                        format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
13432                        depth_read_only: true,
13433                        stencil_read_only: true,
13434                    }),
13435                    sample_count: 1,
13436                    multiview: None,
13437                });
13438        for op in &key.ops {
13439            if op.capture_epoch.is_none()
13440                || op.retained_index >= MAX_REPLAY_SLOTS
13441                || op.first >= op.last
13442            {
13443                continue;
13444            }
13445            let Some(slot) = self.replay_slots.slots.get(&op.slot) else {
13446                continue;
13447            };
13448            // The latched instanced selection is a per-renderer constant,
13449            // so it needs no place in `RetainedBundleOpKey` — every cached
13450            // bundle in this renderer's lifetime encodes the same choice
13451            // the direct path makes.
13452            self.encode_retained_op(
13453                slot,
13454                op.first,
13455                op.last,
13456                op.retained_index,
13457                &mut |cmd| match cmd {
13458                    RetainedCmd::Pipeline(pipeline) => encoder.set_pipeline(pipeline),
13459                    RetainedCmd::Uniforms(group) => encoder.set_bind_group(0, group, &[]),
13460                    RetainedCmd::SlotBindings(group, offset) => {
13461                        encoder.set_bind_group(1, group, &[offset])
13462                    }
13463                    RetainedCmd::MeshVertices(buffer) => {
13464                        encoder.set_vertex_buffer(0, buffer.slice(..))
13465                    }
13466                    RetainedCmd::Index(buffer, format) => {
13467                        encoder.set_index_buffer(buffer.slice(..), format)
13468                    }
13469                    RetainedCmd::Draw(vertices) => encoder.draw(vertices, 0..1),
13470                    RetainedCmd::DrawIndexed(indices, instances) => {
13471                        encoder.draw_indexed(indices, 0, instances)
13472                    }
13473                },
13474            );
13475        }
13476        encoder.finish(&wgpu::RenderBundleDescriptor {
13477            label: Some("Retained Stretch Bundle"),
13478        })
13479    }
13480
13481    /// Draws one maximal consecutive retained stretch through the bundle
13482    /// cache: key the stretch, rebuild on any mismatch (recapture, reorder,
13483    /// range or count change, slot release), then execute the cached bundle.
13484    /// Replays byte-identical commands to the per-op direct path.
13485    /// `stage_replay_patches` and the per-frame transform staging stay in
13486    /// the prepare arms, untouched — bundles bind buffers whose contents are
13487    /// read at execution.
13488    #[cfg(not(target_arch = "wasm32"))]
13489    fn draw_retained_stretch_bundled(
13490        &mut self,
13491        render_pass: &mut wgpu::RenderPass<'_>,
13492        ordered_items: &[(usize, SegmentDrawItem)],
13493        retained_draws: &[RetainedDraw],
13494        item_range: Range<usize>,
13495        width: u32,
13496        height: u32,
13497    ) {
13498        let key = self.retained_bundle_key(ordered_items, retained_draws, item_range);
13499        if !self.retained_bundle_cache.hit(&key) {
13500            let bundle = self.build_retained_bundle(&key);
13501            self.retained_bundle_cache.insert(key.clone(), bundle);
13502        }
13503        // Mirror the direct path's per-op stats for every op the bundle
13504        // draws, so bundling is invisible to the frame counters.
13505        for op in &key.ops {
13506            if op.capture_epoch.is_some()
13507                && op.retained_index < MAX_REPLAY_SLOTS
13508                && op.first < op.last
13509            {
13510                self.frame_stats.bump_shapes();
13511                self.frame_stats.add_draw_calls(1);
13512                if fill_area_diag_enabled() {
13513                    // Mirror the direct path's fill accounting per bundled op.
13514                    let slot = self.replay_slots.slots.get(&op.slot);
13515                    let retained = retained_draws.get(op.retained_index as usize);
13516                    if let (Some(slot), Some(retained)) = (slot, retained) {
13517                        self.fill_area_diag.add_retained_range(
13518                            &slot.fill_diag_shapes,
13519                            op.first,
13520                            op.last,
13521                            &retained.transform,
13522                        );
13523                    }
13524                }
13525            }
13526        }
13527        // Bundles inherit the pass scissor: set the same full-target rect
13528        // the direct path sets before every retained draw. Executing the
13529        // bundle then resets pipeline/bind/vertex state, which is harmless —
13530        // every following fused arm re-binds its own.
13531        render_pass.set_scissor_rect(0, 0, width, height);
13532        if let Some(bundle) = self.retained_bundle_cache.get(&key) {
13533            render_pass.execute_bundles(std::iter::once(bundle));
13534        }
13535    }
13536
13537    /// Test/diagnostic view of the retained bundle cache: lifetime
13538    /// (rebuilds, cached executes).
13539    #[cfg(not(target_arch = "wasm32"))]
13540    #[doc(hidden)]
13541    pub fn retained_bundle_stats(&self) -> (u64, u64) {
13542        self.retained_bundle_cache.stats()
13543    }
13544
13545    /// Test/diagnostic view of the transient rim mesh path: lifetime count
13546    /// of rims drawn as band meshes instead of full bounding quads.
13547    #[cfg(not(target_arch = "wasm32"))]
13548    #[doc(hidden)]
13549    pub fn rim_meshes_emitted(&self) -> u64 {
13550        self.rim_meshes_emitted
13551    }
13552
13553    /// Test/diagnostic view of the device-error sentry: lifetime
13554    /// uncaptured wgpu errors recorded on this renderer's device
13555    /// (`CRANPOSE_SURVIVE_GPU_ERRORS` kill switch).
13556    #[doc(hidden)]
13557    pub fn device_error_count(&self) -> u64 {
13558        self.device_errors.error_count()
13559    }
13560
13561    /// Test/diagnostic view of the static leading-span cache: lifetime
13562    /// (hits, recaptures).
13563    pub fn static_span_stats(&self) -> (u64, u64) {
13564        (self.static_span.hits, self.static_span.recaptures)
13565    }
13566
13567    /// Uploads the region of the transient rim mesh scratch appended since
13568    /// the previous upload — chunks later in the frame append after regions
13569    /// whose draws are already encoded, so earlier bytes are never
13570    /// rewritten and the fixed-capacity buffers are never recreated
13571    /// mid-frame. The executor-owned upload lands at the head of the next
13572    /// submit, which is where this frame's passes execute.
13573    #[cfg(not(target_arch = "wasm32"))]
13574    fn upload_transient_rim_meshes(&mut self) {
13575        let device = self.device.clone();
13576        let mut upload_stats = crate::frame_graph::FrameCommandStats::default();
13577        if self.rim_mesh_vertices.len() > self.rim_mesh_uploaded_vertices {
13578            let vertex_buffer = self.rim_mesh_vertex_buffer.get_or_insert_with(|| {
13579                device.create_buffer(&wgpu::BufferDescriptor {
13580                    label: Some("Rim Mesh Vertex Buffer"),
13581                    size: (RIM_MESH_VERTEX_CAPACITY * std::mem::size_of::<MeshVertex>()) as u64,
13582                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
13583                    mapped_at_creation: false,
13584                })
13585            });
13586            upload_stats.upload_bytes += self
13587                .frame_graph_executor
13588                .upload_buffer(
13589                    &self.queue,
13590                    vertex_buffer,
13591                    (self.rim_mesh_uploaded_vertices * std::mem::size_of::<MeshVertex>()) as u64,
13592                    bytemuck::cast_slice(
13593                        &self.rim_mesh_vertices[self.rim_mesh_uploaded_vertices..],
13594                    ),
13595                )
13596                .upload_bytes;
13597            self.rim_mesh_uploaded_vertices = self.rim_mesh_vertices.len();
13598        }
13599        if self.rim_mesh_indices.len() > self.rim_mesh_uploaded_indices {
13600            let index_buffer = self.rim_mesh_index_buffer.get_or_insert_with(|| {
13601                device.create_buffer(&wgpu::BufferDescriptor {
13602                    label: Some("Rim Mesh Index Buffer"),
13603                    size: (RIM_MESH_INDEX_CAPACITY * std::mem::size_of::<u32>()) as u64,
13604                    usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
13605                    mapped_at_creation: false,
13606                })
13607            });
13608            upload_stats.upload_bytes += self
13609                .frame_graph_executor
13610                .upload_buffer(
13611                    &self.queue,
13612                    index_buffer,
13613                    (self.rim_mesh_uploaded_indices * std::mem::size_of::<u32>()) as u64,
13614                    bytemuck::cast_slice(&self.rim_mesh_indices[self.rim_mesh_uploaded_indices..]),
13615                )
13616                .upload_bytes;
13617            self.rim_mesh_uploaded_indices = self.rim_mesh_indices.len();
13618        }
13619        if upload_stats.upload_bytes > 0 {
13620            self.frame_stats.record_command_stats(upload_stats);
13621        }
13622    }
13623
13624    fn draw_prepared_shapes(
13625        &self,
13626        render_pass: &mut wgpu::RenderPass<'_>,
13627        blend_mode: BlendMode,
13628        batch: PreparedShapeBatch,
13629        width: u32,
13630        height: u32,
13631        rims: &[RimDraw],
13632    ) {
13633        #[cfg(target_arch = "wasm32")]
13634        let _ = rims;
13635        self.frame_stats.bump_shapes();
13636        self.frame_stats.add_draw_calls(1);
13637        render_pass.set_scissor_rect(0, 0, width, height);
13638        #[cfg(not(target_arch = "wasm32"))]
13639        let (uniform_bind_group, shape_buffers) = (&self.uniform_bind_group, &self.shape_buffers);
13640        #[cfg(target_arch = "wasm32")]
13641        let (uniform_bind_group, shape_buffers) = (
13642            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
13643            &self.wasm_shape_batches[batch.shape_slot],
13644        );
13645        // Latched instanced path (storage mode only): one instance per
13646        // shape, four vertices through the static quad index buffer —
13647        // identical triangles, identical bind groups, still one draw call.
13648        // The uniform/WebGL path never latches it and stays on `vs_main`.
13649        #[cfg(not(target_arch = "wasm32"))]
13650        if let Some(instanced) = &self.instanced_quads {
13651            assert!(
13652                batch.vertex_start.is_multiple_of(6) && batch.vertex_count.is_multiple_of(6),
13653                "shape batches are whole shapes: vertex range {}..+{} must be \
13654                 six-aligned to convert to an instance range",
13655                batch.vertex_start,
13656                batch.vertex_count,
13657            );
13658            // The same selection the preamble and every post-rim restore
13659            // make — factored so the two sites cannot disagree.
13660            let set_instanced_pipeline = |render_pass: &mut wgpu::RenderPass<'_>| {
13661                if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
13662                    render_pass.set_pipeline(self.instanced_pipeline_solid(instanced));
13663                } else {
13664                    render_pass.set_pipeline(self.instanced_pipeline(instanced, blend_mode));
13665                }
13666            };
13667            set_instanced_pipeline(render_pass);
13668            render_pass.set_bind_group(0, uniform_bind_group, &[]);
13669            // Dynamic offset 0: ordinary batches read the identity
13670            // similarity transform.
13671            render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
13672            let first_shape = batch.vertex_start / 6;
13673            let shape_count = batch.vertex_count / 6;
13674            render_pass
13675                .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
13676            // Rims arrive in ascending shape order (step 4 walks the fused
13677            // upload front to back), so this batch's rims are one contiguous
13678            // run of the slice.
13679            debug_assert!(
13680                rims.windows(2)
13681                    .all(|pair| pair[0].shape_index < pair[1].shape_index),
13682                "rim draws must arrive in ascending shape order"
13683            );
13684            let rim_start = rims.partition_point(|rim| rim.shape_index < first_shape);
13685            let rim_end = rims.partition_point(|rim| rim.shape_index < first_shape + shape_count);
13686            let batch_rims = &rims[rim_start..rim_end];
13687            let rim_buffers = match (&self.rim_mesh_vertex_buffer, &self.rim_mesh_index_buffer) {
13688                (Some(vertex_buffer), Some(index_buffer)) if !batch_rims.is_empty() => {
13689                    Some((vertex_buffer, index_buffer))
13690                }
13691                _ => None,
13692            };
13693            let Some((rim_vertex_buffer, rim_index_buffer)) = rim_buffers else {
13694                render_pass.draw_indexed(0..6, 0, first_shape..first_shape + shape_count);
13695                return;
13696            };
13697            // Split the instance range around each rim, in exact shape
13698            // order, so z is untouched: instances before the rim, the rim's
13699            // band mesh through `vs_mesh`, instances after. Bind groups
13700            // persist across `set_pipeline` because the mesh and instanced
13701            // pipelines share identical bind group layouts (uniform layout +
13702            // shape layout, dynamic similarity offset included), so only the
13703            // pipeline and index/vertex buffers are re-set per switch.
13704            let mut draw_calls = 0u32;
13705            let mut cursor = first_shape;
13706            for rim in batch_rims {
13707                if cursor < rim.shape_index {
13708                    render_pass.draw_indexed(0..6, 0, cursor..rim.shape_index);
13709                    draw_calls += 1;
13710                }
13711                render_pass.set_pipeline(self.mesh_pipeline());
13712                render_pass.set_vertex_buffer(0, rim_vertex_buffer.slice(..));
13713                render_pass.set_index_buffer(rim_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
13714                render_pass.draw_indexed(
13715                    rim.first_index..rim.first_index + rim.index_count,
13716                    0,
13717                    0..1,
13718                );
13719                draw_calls += 1;
13720                set_instanced_pipeline(render_pass);
13721                render_pass
13722                    .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
13723                cursor = rim.shape_index + 1;
13724            }
13725            if cursor < first_shape + shape_count {
13726                render_pass.draw_indexed(0..6, 0, cursor..first_shape + shape_count);
13727                draw_calls += 1;
13728            }
13729            // One draw call was already counted at the top of the fn.
13730            self.frame_stats
13731                .add_draw_calls(draw_calls.saturating_sub(1));
13732            return;
13733        }
13734        if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
13735            render_pass.set_pipeline(self.shape_pipeline_solid());
13736        } else {
13737            render_pass.set_pipeline(self.shape_pipeline(blend_mode));
13738        }
13739        render_pass.set_bind_group(0, uniform_bind_group, &[]);
13740        // Dynamic offset 0: ordinary batches read the identity similarity
13741        // transform.
13742        render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
13743        // Six unindexed vertices per shape; `vs_main` derives the corner from
13744        // `vertex_index` and pulls the quad out of `ShapeData`.
13745        render_pass.draw(
13746            batch.vertex_start..batch.vertex_start + batch.vertex_count,
13747            0..1,
13748        );
13749    }
13750
13751    /// Stage shape buffer writes and record a shape render pass onto the
13752    /// provided encoder. The caller is responsible for submitting.
13753    #[allow(clippy::too_many_arguments)]
13754    fn encode_shapes_pass<'a, I, C: FrameCommandRecorder>(
13755        &mut self,
13756        frame_encoder: &mut C,
13757        target_view: &wgpu::TextureView,
13758        layer_shapes: I,
13759        brushes: &[Brush],
13760        blend_mode: BlendMode,
13761        width: u32,
13762        height: u32,
13763        root_scale: f32,
13764        load_op: wgpu::LoadOp<wgpu::Color>,
13765        viewport_offset: [f32; 2],
13766    ) where
13767        I: Iterator<Item = &'a DrawShape>,
13768    {
13769        let mut staged_uploads = self.take_staged_uploads();
13770        let viewport = ViewportUniformParams {
13771            width,
13772            height,
13773            offset: viewport_offset,
13774        };
13775        let viewport_rect_logical = viewport_rect_in_logical(viewport, root_scale);
13776        let Some(batch) = self.prepare_shapes_batch(
13777            layer_shapes.filter(|shape| match viewport_rect_logical {
13778                Some(rect) => shape_draw_is_visible_in_rect(shape, rect, root_scale),
13779                None => false,
13780            }),
13781            brushes,
13782            root_scale,
13783            viewport,
13784            &mut staged_uploads,
13785        ) else {
13786            self.restore_staged_uploads(staged_uploads);
13787            return;
13788        };
13789        let upload_offset =
13790            frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
13791        self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
13792        self.restore_staged_uploads(staged_uploads);
13793        let mut render_pass =
13794            frame_encoder
13795                .encoder()
13796                .begin_render_pass(&wgpu::RenderPassDescriptor {
13797                    label: Some("Shape Pass"),
13798                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
13799                        view: target_view,
13800                        resolve_target: None,
13801                        depth_slice: None,
13802                        ops: wgpu::Operations {
13803                            load: load_op,
13804                            store: wgpu::StoreOp::Store,
13805                        },
13806                    })],
13807                    depth_stencil_attachment: None,
13808                    timestamp_writes: None,
13809                    occlusion_query_set: None,
13810                    multiview_mask: None,
13811                });
13812        self.draw_prepared_shapes(&mut render_pass, blend_mode, batch, width, height, &[]);
13813    }
13814
13815    fn draw_prepared_images(
13816        &mut self,
13817        render_pass: &mut wgpu::RenderPass<'_>,
13818        batch: &PreparedImageBatch,
13819        blend_mode: BlendMode,
13820    ) -> Result<(), String> {
13821        if batch.cmds.is_empty() {
13822            return Ok(());
13823        }
13824        self.frame_stats.bump_images();
13825        self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
13826        render_pass.set_pipeline(self.image_pipeline(blend_mode));
13827        #[cfg(not(target_arch = "wasm32"))]
13828        let (uniform_bind_group, vertex_buffer, index_buffer) = (
13829            &self.uniform_bind_group,
13830            &self.image_vertex_buffer,
13831            &self.image_index_buffer,
13832        );
13833        #[cfg(target_arch = "wasm32")]
13834        let (uniform_bind_group, vertex_buffer, index_buffer) = (
13835            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
13836            &self.wasm_image_batches[batch.image_slot].vertex_buffer,
13837            &self.wasm_image_batches[batch.image_slot].index_buffer,
13838        );
13839        render_pass.set_bind_group(0, uniform_bind_group, &[]);
13840        render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
13841        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
13842
13843        for cmd in &batch.cmds {
13844            let (sx, sy, sw, sh) = cmd.scissor;
13845            render_pass.set_scissor_rect(sx, sy, sw, sh);
13846
13847            let cached = self
13848                .image_texture_cache
13849                .get(&cmd.image_id)
13850                .ok_or_else(|| "image texture missing from cache".to_string())?;
13851            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
13852            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
13853        }
13854        Ok(())
13855    }
13856
13857    fn draw_prepared_glyphs(
13858        &mut self,
13859        render_pass: &mut wgpu::RenderPass<'_>,
13860        batch: &PreparedGlyphBatch,
13861    ) -> Result<(), String> {
13862        if batch.cmds.is_empty() {
13863            return Ok(());
13864        }
13865        #[cfg(not(target_arch = "wasm32"))]
13866        {
13867            self.draw_native_prepared_glyph_cmd_range(
13868                render_pass,
13869                &batch.cmds,
13870                0..batch.cmds.len(),
13871            )?;
13872        }
13873        #[cfg(target_arch = "wasm32")]
13874        {
13875            self.frame_stats.bump_text();
13876            self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
13877            render_pass.set_pipeline(self.glyph_atlas_pipeline());
13878            let (uniform_bind_group, vertex_buffer, index_buffer) = (
13879                &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
13880                &self.wasm_image_batches[batch.image_slot].vertex_buffer,
13881                &self.wasm_image_batches[batch.image_slot].index_buffer,
13882            );
13883            render_pass.set_bind_group(0, uniform_bind_group, &[]);
13884            render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
13885            render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
13886            render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
13887
13888            for cmd in &batch.cmds {
13889                let (sx, sy, sw, sh) = cmd.scissor;
13890                render_pass.set_scissor_rect(sx, sy, sw, sh);
13891                let GlyphDrawSource::Shared {
13892                    index_start,
13893                    index_count,
13894                } = cmd.source;
13895                render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
13896            }
13897        }
13898        Ok(())
13899    }
13900
13901    #[cfg(not(target_arch = "wasm32"))]
13902    fn draw_native_prepared_image_cmd_range(
13903        &mut self,
13904        render_pass: &mut wgpu::RenderPass<'_>,
13905        cmds: &[ImageDrawCmd],
13906        cmd_range: Range<usize>,
13907        blend_mode: BlendMode,
13908    ) -> Result<(), String> {
13909        let Some(cmds) = cmds.get(cmd_range) else {
13910            return Err("image command range is outside the prepared command buffer".to_string());
13911        };
13912        if cmds.is_empty() {
13913            return Ok(());
13914        }
13915
13916        self.frame_stats.bump_images();
13917        self.frame_stats.add_draw_calls(cmds.len() as u32);
13918        render_pass.set_pipeline(self.image_pipeline(blend_mode));
13919        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
13920        render_pass.set_index_buffer(self.image_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
13921        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
13922
13923        for cmd in cmds {
13924            let (sx, sy, sw, sh) = cmd.scissor;
13925            render_pass.set_scissor_rect(sx, sy, sw, sh);
13926
13927            let cached = self
13928                .image_texture_cache
13929                .get(&cmd.image_id)
13930                .ok_or_else(|| "image texture missing from cache".to_string())?;
13931            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
13932            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
13933        }
13934        Ok(())
13935    }
13936
13937    #[cfg(not(target_arch = "wasm32"))]
13938    fn draw_native_prepared_glyph_cmd_range(
13939        &mut self,
13940        render_pass: &mut wgpu::RenderPass<'_>,
13941        cmds: &[GlyphDrawCmd],
13942        cmd_range: Range<usize>,
13943    ) -> Result<(), String> {
13944        let Some(cmds) = cmds.get(cmd_range) else {
13945            return Err("glyph command range is outside the prepared command buffer".to_string());
13946        };
13947        if cmds.is_empty() {
13948            return Ok(());
13949        }
13950
13951        self.frame_stats.bump_text();
13952        self.frame_stats.add_draw_calls(cmds.len() as u32);
13953
13954        let mut shared_buffers_bound = false;
13955        let mut retained_pipeline_bound = false;
13956        for cmd in cmds {
13957            let (sx, sy, sw, sh) = cmd.scissor;
13958            render_pass.set_scissor_rect(sx, sy, sw, sh);
13959            match cmd.source {
13960                GlyphDrawSource::Shared {
13961                    index_start,
13962                    index_count,
13963                } => {
13964                    if retained_pipeline_bound || !shared_buffers_bound {
13965                        render_pass.set_pipeline(self.glyph_atlas_pipeline());
13966                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
13967                        retained_pipeline_bound = false;
13968                    }
13969                    if !shared_buffers_bound {
13970                        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
13971                        render_pass.set_index_buffer(
13972                            self.image_index_buffer.slice(..),
13973                            wgpu::IndexFormat::Uint32,
13974                        );
13975                        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
13976                        shared_buffers_bound = true;
13977                    }
13978                    render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
13979                }
13980                GlyphDrawSource::Retained {
13981                    cache_key,
13982                    uniform_slot,
13983                } => {
13984                    shared_buffers_bound = false;
13985                    if !retained_pipeline_bound {
13986                        render_pass.set_pipeline(self.retained_glyph_atlas_pipeline());
13987                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
13988                        retained_pipeline_bound = true;
13989                    }
13990                    let cached = self
13991                        .text_glyph_gpu_run_cache
13992                        .peek(&cache_key)
13993                        .ok_or_else(|| "retained glyph buffer missing from cache".to_string())?;
13994                    let dynamic_offset =
13995                        self.retained_glyph_uniform_dynamic_offset(uniform_slot)?;
13996                    render_pass.set_bind_group(
13997                        0,
13998                        &self.retained_glyph_uniform_bind_group,
13999                        &[dynamic_offset],
14000                    );
14001                    render_pass
14002                        .set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
14003                    render_pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
14004                    render_pass.draw_indexed(0..cached.index_count, 0, 0..1);
14005                }
14006            }
14007        }
14008        Ok(())
14009    }
14010
14011    fn append_image_draw_cmd(
14012        &mut self,
14013        image_draw: &ImageDraw,
14014        viewport: ViewportUniformParams,
14015        root_scale: f32,
14016        image_vertices: &mut Vec<Vertex>,
14017        image_indices: &mut Vec<u32>,
14018        image_cmds: &mut Vec<ImageDrawCmd>,
14019    ) -> Result<(), String> {
14020        let snap_delta = image_draw
14021            .snap_anchor
14022            .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
14023            .unwrap_or_default();
14024        let rect = image_draw.rect.translate(snap_delta.x, snap_delta.y);
14025        if rect.width <= 0.0 || rect.height <= 0.0 || image_draw.alpha <= 0.0 {
14026            return Ok(());
14027        }
14028
14029        let (tint, cpu_filter) = tint_for_image(image_draw.color_filter, image_draw.alpha);
14030        if tint[3] <= 0.0 {
14031            return Ok(());
14032        }
14033
14034        let prepared_image = if let Some(filter) = cpu_filter {
14035            apply_filter_to_bitmap(&image_draw.image, filter)?
14036        } else {
14037            image_draw.image.clone()
14038        };
14039        self.ensure_image_cached(&prepared_image)?;
14040
14041        let mut adjusted_image = ImageDraw {
14042            rect,
14043            local_rect: image_draw.local_rect.translate(snap_delta.x, snap_delta.y),
14044            quad: translate_quad(image_draw.quad, snap_delta),
14045            snap_anchor: image_draw.snap_anchor,
14046            image: image_draw.image.clone(),
14047            alpha: image_draw.alpha,
14048            color_filter: image_draw.color_filter,
14049            sampling: image_draw.sampling,
14050            z_index: image_draw.z_index,
14051            clip: image_draw.clip,
14052            blend_mode: image_draw.blend_mode,
14053            src_rect: image_draw.src_rect,
14054            motion_context_animated: image_draw.motion_context_animated,
14055        };
14056        snap_nearest_image_to_device_pixels(&mut adjusted_image, root_scale);
14057        let Some(scissor) =
14058            scissor_rect_for_image(&adjusted_image, root_scale, viewport.width, viewport.height)
14059        else {
14060            return Ok(());
14061        };
14062
14063        let Some(uv_rect) = image_uv_rect(&image_draw.image, image_draw.src_rect) else {
14064            return Ok(());
14065        };
14066        let device_quad =
14067            nearest_image_device_quad(&adjusted_image, root_scale).unwrap_or_else(|| {
14068                if adjusted_image.snap_anchor.is_some() {
14069                    canonicalized_scaled_quad(adjusted_image.quad, root_scale)
14070                } else {
14071                    scaled_quad(adjusted_image.quad, root_scale)
14072                }
14073            });
14074        #[cfg(not(target_arch = "wasm32"))]
14075        {
14076            if fill_area_diag_enabled() {
14077                self.fill_area_diag.add_image_quad(&device_quad);
14078            }
14079        }
14080
14081        let base_vertex = image_vertices.len() as u32;
14082        let index_start = image_indices.len() as u32;
14083        image_indices.extend_from_slice(&[
14084            base_vertex,
14085            base_vertex + 1,
14086            base_vertex + 2,
14087            base_vertex + 2,
14088            base_vertex + 1,
14089            base_vertex + 3,
14090        ]);
14091        image_vertices.extend_from_slice(&[
14092            Vertex {
14093                position: device_quad[0],
14094                color: tint,
14095                uv: [uv_rect.min[0], uv_rect.min[1]],
14096                uv_bounds: uv_rect.sample_bounds,
14097            },
14098            Vertex {
14099                position: device_quad[1],
14100                color: tint,
14101                uv: [uv_rect.max[0], uv_rect.min[1]],
14102                uv_bounds: uv_rect.sample_bounds,
14103            },
14104            Vertex {
14105                position: device_quad[2],
14106                color: tint,
14107                uv: [uv_rect.min[0], uv_rect.max[1]],
14108                uv_bounds: uv_rect.sample_bounds,
14109            },
14110            Vertex {
14111                position: device_quad[3],
14112                color: tint,
14113                uv: [uv_rect.max[0], uv_rect.max[1]],
14114                uv_bounds: uv_rect.sample_bounds,
14115            },
14116        ]);
14117
14118        image_cmds.push(ImageDrawCmd {
14119            index_start,
14120            scissor,
14121            image_id: prepared_image.id(),
14122            sampling: image_draw.sampling,
14123        });
14124        Ok(())
14125    }
14126
14127    #[cfg(not(target_arch = "wasm32"))]
14128    fn stage_native_image_buffers(
14129        &mut self,
14130        staged_uploads: &mut StagedBufferUploads,
14131        viewport: ViewportUniformParams,
14132        image_vertices: &[Vertex],
14133        image_indices: &[u32],
14134    ) {
14135        if image_indices.is_empty() {
14136            return;
14137        }
14138
14139        self.stage_viewport_uniforms(staged_uploads, viewport);
14140        // Grow to a power of two, as the shape batch and frame upload buffers
14141        // do. Sizing these to the exact byte count instead means one more glyph
14142        // quad than the last frame destroys and recreates both buffers, and a
14143        // caption that grows a character at a time does it on every frame.
14144        let needed_bytes = std::mem::size_of_val(image_vertices) as u64;
14145        if needed_bytes > self.image_vertex_buffer.size() {
14146            self.image_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14147                label: Some("Image Vertex Buffer"),
14148                size: needed_bytes.next_power_of_two(),
14149                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
14150                mapped_at_creation: false,
14151            });
14152        }
14153        let needed_index_bytes = std::mem::size_of_val(image_indices) as u64;
14154        if needed_index_bytes > self.image_index_buffer.size() {
14155            self.image_index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14156                label: Some("Image Index Buffer"),
14157                size: needed_index_bytes.next_power_of_two(),
14158                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
14159                mapped_at_creation: false,
14160            });
14161        }
14162
14163        staged_uploads.stage(
14164            UploadTarget::ImageVertex,
14165            bytemuck::cast_slice(image_vertices),
14166        );
14167        staged_uploads.stage(
14168            UploadTarget::ImageIndex,
14169            bytemuck::cast_slice(image_indices),
14170        );
14171    }
14172
14173    /// Prepare image vertices, indices, ensure caching, and write to GPU buffers.
14174    /// Returns the draw commands needed by `encode_images_pass`.
14175    fn prepare_image_draw_cmds<'a, I>(
14176        &mut self,
14177        layer_images: I,
14178        viewport: ViewportUniformParams,
14179        root_scale: f32,
14180        staged_uploads: &mut StagedBufferUploads,
14181    ) -> Result<PreparedImageBatch, String>
14182    where
14183        I: Iterator<Item = &'a ImageDraw>,
14184    {
14185        #[cfg(target_arch = "wasm32")]
14186        let _ = staged_uploads;
14187
14188        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
14189        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
14190        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
14191        image_vertices.clear();
14192        image_indices.clear();
14193        image_cmds.clear();
14194
14195        for image_draw in layer_images {
14196            self.append_image_draw_cmd(
14197                image_draw,
14198                viewport,
14199                root_scale,
14200                &mut image_vertices,
14201                &mut image_indices,
14202                &mut image_cmds,
14203            )?;
14204        }
14205
14206        #[cfg(not(target_arch = "wasm32"))]
14207        if !image_cmds.is_empty() {
14208            self.stage_native_image_buffers(
14209                staged_uploads,
14210                viewport,
14211                &image_vertices,
14212                &image_indices,
14213            );
14214        }
14215
14216        #[cfg(target_arch = "wasm32")]
14217        let image_slot = if image_cmds.is_empty() {
14218            0
14219        } else {
14220            let slot = self.claim_wasm_image_batch();
14221            {
14222                let buffers = &mut self.wasm_image_batches[slot];
14223                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
14224            }
14225            let buffers = &self.wasm_image_batches[slot];
14226            self.write_wasm_buffer(
14227                &buffers.vertex_buffer,
14228                bytemuck::cast_slice(&image_vertices),
14229            );
14230            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
14231            slot
14232        };
14233
14234        #[cfg(target_arch = "wasm32")]
14235        let uniform_slot = if image_cmds.is_empty() {
14236            0
14237        } else {
14238            self.prepare_wasm_viewport_uniforms(viewport)
14239        };
14240
14241        self.scratch_image_vertices = image_vertices;
14242        self.scratch_image_indices = image_indices;
14243        Ok(PreparedImageBatch {
14244            cmds: image_cmds,
14245            #[cfg(target_arch = "wasm32")]
14246            image_slot,
14247            #[cfg(target_arch = "wasm32")]
14248            uniform_slot,
14249        })
14250    }
14251
14252    fn glyph_atlas_entry_for(
14253        &mut self,
14254        glyph: &SoftwareGlyphAtlasGlyph,
14255    ) -> Result<GlyphAtlasEntry, String> {
14256        if let Some(entry) = self.text_glyph_atlas.upload_glyph(
14257            glyph.key,
14258            glyph,
14259            &self.queue,
14260            &mut self.frame_graph_executor,
14261            &mut self.frame_stats,
14262        ) {
14263            return Ok(entry);
14264        }
14265
14266        self.text_glyph_atlas.reset(
14267            &self.device,
14268            &self.image_bind_group_layout,
14269            &self.image_nearest_sampler,
14270        );
14271        Err("text glyph atlas filled and was reset".to_string())
14272    }
14273
14274    fn glyph_atlas_entry_for_cached(
14275        &mut self,
14276        glyph: &SoftwareGlyphAtlasPlacement,
14277    ) -> Option<GlyphAtlasEntry> {
14278        let entry = self.text_glyph_atlas.entry(&glyph.key)?;
14279        self.frame_stats.record_text_glyph_atlas_hit();
14280        Some(entry)
14281    }
14282
14283    fn glyph_atlas_entry_for_placement(
14284        &mut self,
14285        glyph: &SoftwareGlyphAtlasPlacement,
14286    ) -> Result<GlyphAtlasEntry, String> {
14287        if let Some(entry) = self.glyph_atlas_entry_for_cached(glyph) {
14288            return Ok(entry);
14289        }
14290
14291        let Some(upload_glyph) = self.text_glyph_mask_cache.atlas_glyph_for_placement(glyph) else {
14292            return Err("text glyph placement has no retained raster mask".to_string());
14293        };
14294        self.glyph_atlas_entry_for(&upload_glyph)
14295    }
14296
14297    fn prepare_text_glyph_quads(
14298        &mut self,
14299        run_key: TextGlyphRunCacheKey,
14300        atlas_generation: u64,
14301        cached_glyph_run: Option<&[SoftwareGlyphAtlasPlacement]>,
14302        collected_run: &[SoftwareGlyphAtlasRunGlyph],
14303        generated_quads: &mut Vec<CachedTextGlyphQuad>,
14304    ) -> Result<Rc<[CachedTextGlyphQuad]>, String> {
14305        generated_quads.clear();
14306        if let Some(glyph_run) = cached_glyph_run {
14307            for glyph in glyph_run {
14308                if glyph.width == 0 || glyph.height == 0 || glyph.color.3 <= 0.0 {
14309                    continue;
14310                }
14311                let entry = self.glyph_atlas_entry_for_placement(glyph)?;
14312                // Read the size after the entry is in hand: the only path that
14313                // resizes the atlas is the overflow reset, which returns `Err`
14314                // above, so `entry` is always normalised against the atlas it
14315                // was placed in.
14316                generated_quads.push(cached_text_glyph_quad(
14317                    glyph,
14318                    entry,
14319                    self.text_glyph_atlas.size(),
14320                ));
14321            }
14322        } else {
14323            for run_glyph in collected_run {
14324                let placement = run_glyph.placement();
14325                if placement.width == 0 || placement.height == 0 || placement.color.3 <= 0.0 {
14326                    continue;
14327                }
14328                let entry = match run_glyph {
14329                    SoftwareGlyphAtlasRunGlyph::Cached(placement) => {
14330                        self.glyph_atlas_entry_for_placement(placement)?
14331                    }
14332                    SoftwareGlyphAtlasRunGlyph::New(glyph) => self.glyph_atlas_entry_for(glyph)?,
14333                };
14334                generated_quads.push(cached_text_glyph_quad(
14335                    &placement,
14336                    entry,
14337                    self.text_glyph_atlas.size(),
14338                ));
14339            }
14340        }
14341
14342        let quads: Rc<[CachedTextGlyphQuad]> = Rc::from(generated_quads.clone().into_boxed_slice());
14343        if let Some(cached) = self.text_glyph_run_cache.get_mut(&run_key) {
14344            cached.quads = Some(Rc::clone(&quads));
14345            cached.atlas_generation = atlas_generation;
14346        }
14347        Ok(quads)
14348    }
14349
14350    #[allow(clippy::too_many_arguments)]
14351    fn append_text_glyph_quad_run(
14352        &mut self,
14353        source_raster_rect: Rect,
14354        quads: &[CachedTextGlyphQuad],
14355        clip: Option<Rect>,
14356        viewport: ViewportUniformParams,
14357        root_scale: f32,
14358        image_vertices: &mut Vec<Vertex>,
14359        image_indices: &mut Vec<u32>,
14360        record_cached_hits: bool,
14361    ) -> usize {
14362        let mut appended = 0usize;
14363        for quad in quads {
14364            if !cached_text_glyph_quad_is_visible_in_viewport(
14365                source_raster_rect,
14366                quad,
14367                clip,
14368                viewport,
14369                root_scale,
14370            ) {
14371                continue;
14372            }
14373            if append_cached_text_glyph_quad(
14374                source_raster_rect,
14375                quad,
14376                image_vertices,
14377                image_indices,
14378            ) {
14379                if record_cached_hits {
14380                    self.frame_stats.record_text_glyph_atlas_hit();
14381                }
14382                #[cfg(not(target_arch = "wasm32"))]
14383                {
14384                    if fill_area_diag_enabled() {
14385                        self.fill_area_diag.add_glyph_quad(quad);
14386                    }
14387                }
14388                appended = appended.saturating_add(1);
14389            }
14390        }
14391        appended
14392    }
14393
14394    #[cfg(not(target_arch = "wasm32"))]
14395    fn retained_glyph_viewport(
14396        viewport: ViewportUniformParams,
14397        source_raster_rect: Rect,
14398    ) -> ViewportUniformParams {
14399        ViewportUniformParams {
14400            width: viewport.width,
14401            height: viewport.height,
14402            offset: [
14403                viewport.offset[0] - source_raster_rect.x,
14404                viewport.offset[1] - source_raster_rect.y,
14405            ],
14406        }
14407    }
14408
14409    #[cfg(not(target_arch = "wasm32"))]
14410    fn retained_text_glyph_run_ready(&mut self, cache_key: TextGlyphRunCacheKey) -> bool {
14411        let atlas_generation = self.text_glyph_atlas.generation();
14412        self.text_glyph_gpu_run_cache
14413            .peek(&cache_key)
14414            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
14415    }
14416
14417    #[cfg(not(target_arch = "wasm32"))]
14418    #[allow(clippy::too_many_arguments)]
14419    fn emit_retained_text_glyph_run_if_ready(
14420        &mut self,
14421        cache_key: TextGlyphRunCacheKey,
14422        quads: &[CachedTextGlyphQuad],
14423        clip: Option<Rect>,
14424        viewport: ViewportUniformParams,
14425        source_raster_rect: Rect,
14426        scissor: (u32, u32, u32, u32),
14427        staged_uploads: &mut StagedBufferUploads,
14428        glyph_cmds: &mut Vec<GlyphDrawCmd>,
14429    ) -> bool {
14430        if !should_use_retained_text_glyph_run(quads.len(), clip) {
14431            return false;
14432        }
14433        if !self.retained_text_glyph_run_ready(cache_key)
14434            && !self.ensure_retained_text_glyph_run(cache_key, quads)
14435        {
14436            return false;
14437        }
14438
14439        let uniform_slot = self.stage_retained_glyph_viewport_uniforms(
14440            staged_uploads,
14441            Self::retained_glyph_viewport(viewport, source_raster_rect),
14442        );
14443        if fill_area_diag_enabled() {
14444            // The retained run draws every quad of its cached buffer; the
14445            // shared path's per-quad viewport cull is not re-run for it.
14446            for quad in quads {
14447                self.fill_area_diag.add_glyph_quad(quad);
14448            }
14449        }
14450        glyph_cmds.push(GlyphDrawCmd::retained(cache_key, uniform_slot, scissor));
14451        true
14452    }
14453
14454    #[cfg(not(target_arch = "wasm32"))]
14455    fn ensure_retained_text_glyph_run(
14456        &mut self,
14457        cache_key: TextGlyphRunCacheKey,
14458        quads: &[CachedTextGlyphQuad],
14459    ) -> bool {
14460        let atlas_generation = self.text_glyph_atlas.generation();
14461        if self
14462            .text_glyph_gpu_run_cache
14463            .peek(&cache_key)
14464            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
14465        {
14466            return true;
14467        }
14468
14469        let mut vertices = Vec::with_capacity(quads.len().saturating_mul(4));
14470        let mut indices = Vec::with_capacity(quads.len().saturating_mul(6));
14471        let origin = Rect {
14472            x: 0.0,
14473            y: 0.0,
14474            width: 0.0,
14475            height: 0.0,
14476        };
14477        for quad in quads {
14478            append_cached_text_glyph_quad(origin, quad, &mut vertices, &mut indices);
14479        }
14480        if indices.is_empty() {
14481            return false;
14482        }
14483
14484        let vertex_bytes = bytemuck::cast_slice(&vertices);
14485        let index_bytes = bytemuck::cast_slice(&indices);
14486        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14487            label: Some("Retained Text Glyph Vertex Buffer"),
14488            size: vertex_bytes.len() as u64,
14489            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
14490            mapped_at_creation: false,
14491        });
14492        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14493            label: Some("Retained Text Glyph Index Buffer"),
14494            size: index_bytes.len() as u64,
14495            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
14496            mapped_at_creation: false,
14497        });
14498        let vertex_upload =
14499            self.frame_graph_executor
14500                .upload_buffer(&self.queue, &vertex_buffer, 0, vertex_bytes);
14501        self.frame_stats.record_command_stats(vertex_upload);
14502        let index_upload =
14503            self.frame_graph_executor
14504                .upload_buffer(&self.queue, &index_buffer, 0, index_bytes);
14505        self.frame_stats.record_command_stats(index_upload);
14506
14507        self.text_glyph_gpu_run_cache.put(
14508            cache_key,
14509            CachedGpuTextGlyphRun {
14510                vertex_buffer,
14511                index_buffer,
14512                index_count: indices.len() as u32,
14513                atlas_generation,
14514            },
14515        );
14516        true
14517    }
14518
14519    #[allow(clippy::too_many_arguments)]
14520    fn append_text_glyph_draws<'a, I>(
14521        &mut self,
14522        layer_texts: I,
14523        viewport: ViewportUniformParams,
14524        root_scale: f32,
14525        allow_offscreen_prewarm: bool,
14526        staged_uploads: &mut StagedBufferUploads,
14527        image_vertices: &mut Vec<Vertex>,
14528        image_indices: &mut Vec<u32>,
14529        glyph_cmds: &mut Vec<GlyphDrawCmd>,
14530    ) -> Result<bool, String>
14531    where
14532        I: IntoIterator<Item = &'a TextDraw>,
14533    {
14534        let append_start = Instant::now();
14535        let initial_vertex_len = image_vertices.len();
14536        let initial_index_len = image_indices.len();
14537        let initial_cmd_len = glyph_cmds.len();
14538        let initial_staged_bytes_len = staged_uploads.bytes.len();
14539        let initial_staged_copies_len = staged_uploads.copies.len();
14540        let mut collected_run = std::mem::take(&mut self.scratch_text_glyph_run);
14541        let mut collected_placements = std::mem::take(&mut self.scratch_text_glyph_placements);
14542        let mut generated_quads = std::mem::take(&mut self.scratch_text_glyph_quads);
14543        generated_quads.clear();
14544        let mut visited = 0usize;
14545        let mut emitted_glyphs = 0usize;
14546        let mut prewarmed_glyphs = 0usize;
14547        let mut run_hits = 0usize;
14548        let mut run_misses = 0usize;
14549
14550        for text_draw in layer_texts {
14551            visited = visited.saturating_add(1);
14552            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
14553                self.text_raster_geometry(text_draw, root_scale)
14554            else {
14555                continue;
14556            };
14557            if !static_text_motion {
14558                image_vertices.truncate(initial_vertex_len);
14559                image_indices.truncate(initial_index_len);
14560                glyph_cmds.truncate(initial_cmd_len);
14561                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
14562                self.scratch_text_glyph_run = collected_run;
14563                self.scratch_text_glyph_placements = collected_placements;
14564                self.scratch_text_glyph_quads = generated_quads;
14565                return Ok(false);
14566            }
14567            let is_visible =
14568                text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale);
14569            let draw_action = text_glyph_draw_action(
14570                is_visible,
14571                text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale),
14572                allow_offscreen_prewarm,
14573            );
14574            if draw_action == TextGlyphDrawAction::Skip {
14575                continue;
14576            }
14577
14578            let raster_source = text_glyph_raster_source(text_draw, raster_rect);
14579            let source_draw = raster_source.draw.as_ref();
14580            let source_raster_rect = raster_source.raster_rect;
14581
14582            let run_key = Self::text_glyph_run_cache_key(
14583                source_draw,
14584                source_raster_rect,
14585                text_scale,
14586                static_text_motion,
14587            );
14588            let atlas_generation = self.text_glyph_atlas.generation();
14589            let mut cached_quad_run = None;
14590            let mut miss_collect_ms = None;
14591            let mut miss_cached_glyphs = 0usize;
14592            let mut miss_new_glyphs = 0usize;
14593            let cached_glyph_run = if let Some(cached) = self.text_glyph_run_cache.get(&run_key) {
14594                run_hits = run_hits.saturating_add(1);
14595                if cached.atlas_generation == atlas_generation {
14596                    cached_quad_run = cached.quads.as_ref().map(Rc::clone);
14597                }
14598                Some(Rc::clone(&cached.glyphs))
14599            } else {
14600                run_misses = run_misses.saturating_add(1);
14601                collected_run.clear();
14602                let collect_start = Instant::now();
14603                let collect_result = collect_solid_text_atlas_run(
14604                    source_draw.text.as_ref(),
14605                    source_raster_rect,
14606                    &source_draw.text_style,
14607                    source_draw.color,
14608                    source_draw.font_size,
14609                    text_scale,
14610                    &self.text_fonts,
14611                    &mut self.text_glyph_mask_cache,
14612                    &mut collected_run,
14613                );
14614                miss_collect_ms = Some(instant_ms(collect_start, Instant::now()));
14615                if collect_result.is_none() {
14616                    if text_atlas_fallback_diag_enabled() {
14617                        let preview: String = source_draw.text.text.chars().take(96).collect();
14618                        log::warn!(
14619                            "[text-atlas-fallback] node={:?} visible={} prewarm={} spans={} links={} text_len={} preview={:?} span_style={:?} paragraph_style={:?}",
14620                            source_draw.node_id,
14621                            is_visible,
14622                            draw_action == TextGlyphDrawAction::PrewarmOffscreen,
14623                            source_draw.text.span_styles.len(),
14624                            source_draw.text.links.len(),
14625                            source_draw.text.text.len(),
14626                            preview,
14627                            source_draw.text_style.span_style,
14628                            source_draw.text_style.paragraph_style,
14629                        );
14630                    }
14631                    if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
14632                        continue;
14633                    }
14634                    image_vertices.truncate(initial_vertex_len);
14635                    image_indices.truncate(initial_index_len);
14636                    glyph_cmds.truncate(initial_cmd_len);
14637                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
14638                    self.scratch_text_glyph_run = collected_run;
14639                    self.scratch_text_glyph_placements = collected_placements;
14640                    self.scratch_text_glyph_quads = generated_quads;
14641                    return Ok(false);
14642                }
14643                if text_glyph_run_diag_enabled() {
14644                    miss_cached_glyphs = collected_run
14645                        .iter()
14646                        .filter(|glyph| matches!(glyph, SoftwareGlyphAtlasRunGlyph::Cached(_)))
14647                        .count();
14648                    miss_new_glyphs = collected_run.len().saturating_sub(miss_cached_glyphs);
14649                }
14650                collected_placements.clear();
14651                collected_placements.extend(
14652                    collected_run
14653                        .iter()
14654                        .map(SoftwareGlyphAtlasRunGlyph::placement),
14655                );
14656                let glyphs: Rc<[SoftwareGlyphAtlasPlacement]> =
14657                    Rc::from(collected_placements.clone().into_boxed_slice());
14658                self.text_glyph_run_cache.put(
14659                    run_key,
14660                    CachedTextGlyphRun {
14661                        glyphs,
14662                        quads: None,
14663                        atlas_generation: 0,
14664                    },
14665                );
14666                None
14667            };
14668
14669            if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
14670                let prewarm_quads = if let Some(quad_run) = cached_quad_run {
14671                    quad_run
14672                } else {
14673                    let prepare_start = Instant::now();
14674                    match self.prepare_text_glyph_quads(
14675                        run_key,
14676                        atlas_generation,
14677                        cached_glyph_run.as_deref(),
14678                        &collected_run,
14679                        &mut generated_quads,
14680                    ) {
14681                        Ok(quads) => {
14682                            if let Some(collect_ms) = miss_collect_ms {
14683                                if text_glyph_run_diag_enabled() {
14684                                    log::warn!(
14685                                        "[text-glyph-run-diag] visible=false glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
14686                                        quads.len(),
14687                                        miss_cached_glyphs,
14688                                        miss_new_glyphs,
14689                                        collect_ms,
14690                                        instant_ms(prepare_start, Instant::now()),
14691                                    );
14692                                }
14693                            }
14694                            quads
14695                        }
14696                        Err(_) => continue,
14697                    }
14698                };
14699                #[cfg(not(target_arch = "wasm32"))]
14700                if should_use_retained_text_glyph_run(prewarm_quads.len(), source_draw.clip) {
14701                    self.ensure_retained_text_glyph_run(run_key, prewarm_quads.as_ref());
14702                }
14703                prewarmed_glyphs = prewarmed_glyphs.saturating_add(prewarm_quads.len());
14704                continue;
14705            }
14706
14707            let draw_rect = Rect {
14708                x: source_raster_rect.x / root_scale,
14709                y: source_raster_rect.y / root_scale,
14710                width: source_raster_rect.width / root_scale,
14711                height: source_raster_rect.height / root_scale,
14712            };
14713            let Some(scissor) = scissor_rect_for_layer(
14714                draw_rect,
14715                source_draw.clip,
14716                root_scale,
14717                viewport.width,
14718                viewport.height,
14719            ) else {
14720                continue;
14721            };
14722
14723            #[cfg(not(target_arch = "wasm32"))]
14724            if let Some(quad_run) = cached_quad_run.as_ref() {
14725                if should_use_retained_text_glyph_run(quad_run.len(), source_draw.clip)
14726                    && self.emit_retained_text_glyph_run_if_ready(
14727                        run_key,
14728                        quad_run.as_ref(),
14729                        source_draw.clip,
14730                        viewport,
14731                        source_raster_rect,
14732                        scissor,
14733                        staged_uploads,
14734                        glyph_cmds,
14735                    )
14736                {
14737                    emitted_glyphs = emitted_glyphs.saturating_add(quad_run.len());
14738                    continue;
14739                }
14740            }
14741
14742            let index_start = image_indices.len() as u32;
14743            if let Some(quad_run) = cached_quad_run {
14744                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
14745                    source_raster_rect,
14746                    quad_run.as_ref(),
14747                    source_draw.clip,
14748                    viewport,
14749                    root_scale,
14750                    image_vertices,
14751                    image_indices,
14752                    true,
14753                ));
14754            } else {
14755                let prepare_start = Instant::now();
14756                let Ok(quad_run) = self.prepare_text_glyph_quads(
14757                    run_key,
14758                    atlas_generation,
14759                    cached_glyph_run.as_deref(),
14760                    &collected_run,
14761                    &mut generated_quads,
14762                ) else {
14763                    image_vertices.truncate(initial_vertex_len);
14764                    image_indices.truncate(initial_index_len);
14765                    glyph_cmds.truncate(initial_cmd_len);
14766                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
14767                    self.scratch_text_glyph_run = collected_run;
14768                    self.scratch_text_glyph_placements = collected_placements;
14769                    self.scratch_text_glyph_quads = generated_quads;
14770                    return Ok(false);
14771                };
14772                if let Some(collect_ms) = miss_collect_ms {
14773                    if text_glyph_run_diag_enabled() {
14774                        log::warn!(
14775                            "[text-glyph-run-diag] visible=true glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
14776                            quad_run.len(),
14777                            miss_cached_glyphs,
14778                            miss_new_glyphs,
14779                            collect_ms,
14780                            instant_ms(prepare_start, Instant::now()),
14781                        );
14782                    }
14783                }
14784                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
14785                    source_raster_rect,
14786                    quad_run.as_ref(),
14787                    source_draw.clip,
14788                    viewport,
14789                    root_scale,
14790                    image_vertices,
14791                    image_indices,
14792                    false,
14793                ));
14794            }
14795            let index_count = image_indices.len() as u32 - index_start;
14796            if index_count > 0 {
14797                glyph_cmds.push(GlyphDrawCmd::shared(index_start, index_count, scissor));
14798            }
14799        }
14800
14801        self.scratch_text_glyph_run = collected_run;
14802        self.scratch_text_glyph_placements = collected_placements;
14803        self.scratch_text_glyph_quads = generated_quads;
14804        let append_end = Instant::now();
14805        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
14806            log::warn!(
14807                "[wgpu-render-stage:text-glyph-atlas] total_ms={total_ms:.2} visited={} cmds={} glyphs={} prewarmed={} run_hits={} run_misses={}",
14808                visited,
14809                glyph_cmds.len().saturating_sub(initial_cmd_len),
14810                emitted_glyphs,
14811                prewarmed_glyphs,
14812                run_hits,
14813                run_misses,
14814            );
14815        }
14816        Ok(true)
14817    }
14818
14819    #[cfg(not(target_arch = "wasm32"))]
14820    fn text_glyph_prewarm_decision(
14821        &self,
14822        text_draw: &TextDraw,
14823        viewport: ViewportUniformParams,
14824        root_scale: f32,
14825    ) -> TextGlyphPrewarmDecision {
14826        let Some((logical_rect, _, clip, _, static_text_motion)) =
14827            self.text_raster_geometry(text_draw, root_scale)
14828        else {
14829            return TextGlyphPrewarmDecision::MissingGeometry;
14830        };
14831        if !static_text_motion {
14832            return TextGlyphPrewarmDecision::DynamicMotion;
14833        }
14834        if text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
14835            return TextGlyphPrewarmDecision::Visible;
14836        }
14837        if text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale) {
14838            TextGlyphPrewarmDecision::Candidate
14839        } else {
14840            TextGlyphPrewarmDecision::OutsidePrewarmWindow
14841        }
14842    }
14843
14844    #[cfg(not(target_arch = "wasm32"))]
14845    #[allow(clippy::too_many_arguments)]
14846    fn prewarm_offscreen_text_glyph_draws_in_chunk(
14847        &mut self,
14848        ordered_items: &[(usize, SegmentDrawItem)],
14849        texts: &[TextDraw],
14850        chunk: &SegmentDrawChunkPlan,
14851        viewport: ViewportUniformParams,
14852        root_scale: f32,
14853        staged_uploads: &mut StagedBufferUploads,
14854        image_vertices: &mut Vec<Vertex>,
14855        image_indices: &mut Vec<u32>,
14856        glyph_cmds: &mut Vec<GlyphDrawCmd>,
14857    ) -> Result<(), String> {
14858        let prewarm_start = Instant::now();
14859        let diag_enabled = cranpose_core::env_flag!("CRANPOSE_TEXT_PREWARM_DIAG");
14860        let mut text_items = 0usize;
14861        let mut candidates = 0usize;
14862        let mut missing_geometry = 0usize;
14863        let mut dynamic_motion = 0usize;
14864        let mut visible = 0usize;
14865        let mut outside = 0usize;
14866        let mut already_prepared = 0usize;
14867        let mut admitted_candidates = 0usize;
14868        let mut skipped_unbounded = 0usize;
14869        let mut skipped_budget = 0usize;
14870        let initial_vertex_len = image_vertices.len();
14871        let initial_index_len = image_indices.len();
14872        let initial_cmd_len = glyph_cmds.len();
14873        let initial_staged_bytes_len = staged_uploads.bytes.len();
14874        let initial_staged_copies_len = staged_uploads.copies.len();
14875        'batches: for batch in chunk.iter() {
14876            let SegmentBatchPlan::Text { start, end } = batch else {
14877                continue;
14878            };
14879            for (_, item) in &ordered_items[start..end] {
14880                if offscreen_text_glyph_prewarm_budget_exhausted(prewarm_start, admitted_candidates)
14881                {
14882                    skipped_budget = skipped_budget.saturating_add(1);
14883                    break 'batches;
14884                }
14885                let SegmentDrawItem::Text(text_index) = item else {
14886                    return Err(format!(
14887                        "text prewarm batch contains non-text draw item: {item:?}"
14888                    ));
14889                };
14890                let Some(text_draw) = texts.get(*text_index) else {
14891                    continue;
14892                };
14893                text_items = text_items.saturating_add(1);
14894                match self.text_glyph_prewarm_decision(text_draw, viewport, root_scale) {
14895                    TextGlyphPrewarmDecision::Candidate => {}
14896                    TextGlyphPrewarmDecision::MissingGeometry => {
14897                        missing_geometry = missing_geometry.saturating_add(1);
14898                        continue;
14899                    }
14900                    TextGlyphPrewarmDecision::DynamicMotion => {
14901                        dynamic_motion = dynamic_motion.saturating_add(1);
14902                        continue;
14903                    }
14904                    TextGlyphPrewarmDecision::Visible => {
14905                        visible = visible.saturating_add(1);
14906                        continue;
14907                    }
14908                    TextGlyphPrewarmDecision::OutsidePrewarmWindow => {
14909                        outside = outside.saturating_add(1);
14910                        continue;
14911                    }
14912                }
14913
14914                candidates = candidates.saturating_add(1);
14915                let Some((_, raster_rect, _, text_scale, static_text_motion)) =
14916                    self.text_raster_geometry(text_draw, root_scale)
14917                else {
14918                    missing_geometry = missing_geometry.saturating_add(1);
14919                    continue;
14920                };
14921                let raster_source = text_glyph_raster_source(text_draw, raster_rect);
14922                let source_draw = raster_source.draw.as_ref();
14923                let run_key = Self::text_glyph_run_cache_key(
14924                    source_draw,
14925                    raster_source.raster_rect,
14926                    text_scale,
14927                    static_text_motion,
14928                );
14929                let atlas_generation = self.text_glyph_atlas.generation();
14930                let cached_glyphs = if let Some(cached) = self.text_glyph_run_cache.peek(&run_key) {
14931                    if cached.atlas_generation == atlas_generation && cached.quads.is_some() {
14932                        already_prepared = already_prepared.saturating_add(1);
14933                        continue;
14934                    }
14935                    Some(cached.glyphs.len())
14936                } else {
14937                    None
14938                };
14939                if !offscreen_text_glyph_prewarm_work_is_bounded(
14940                    cached_glyphs,
14941                    source_draw.text.text.len(),
14942                ) {
14943                    skipped_unbounded = skipped_unbounded.saturating_add(1);
14944                    continue;
14945                }
14946                admitted_candidates = admitted_candidates.saturating_add(1);
14947                self.append_text_glyph_draws(
14948                    std::iter::once(text_draw),
14949                    viewport,
14950                    root_scale,
14951                    true,
14952                    staged_uploads,
14953                    image_vertices,
14954                    image_indices,
14955                    glyph_cmds,
14956                )?;
14957                image_vertices.truncate(initial_vertex_len);
14958                image_indices.truncate(initial_index_len);
14959                glyph_cmds.truncate(initial_cmd_len);
14960                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
14961            }
14962        }
14963
14964        if diag_enabled && text_items > 0 {
14965            log::warn!(
14966                "[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}"
14967            );
14968        }
14969        if admitted_candidates > 0 {
14970            if let Some(total_ms) = should_log_wgpu_render_stage(prewarm_start, Instant::now()) {
14971                log::warn!(
14972                    "[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}"
14973                );
14974            }
14975        }
14976        Ok(())
14977    }
14978
14979    fn prepare_text_glyph_draw_cmds<'a, I>(
14980        &mut self,
14981        layer_texts: I,
14982        viewport: ViewportUniformParams,
14983        root_scale: f32,
14984        staged_uploads: &mut StagedBufferUploads,
14985    ) -> Result<Option<PreparedGlyphBatch>, String>
14986    where
14987        I: IntoIterator<Item = &'a TextDraw>,
14988    {
14989        #[cfg(target_arch = "wasm32")]
14990        let _ = staged_uploads;
14991
14992        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
14993        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
14994        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
14995        image_vertices.clear();
14996        image_indices.clear();
14997        glyph_cmds.clear();
14998
14999        if !self.append_text_glyph_draws(
15000            layer_texts,
15001            viewport,
15002            root_scale,
15003            false,
15004            staged_uploads,
15005            &mut image_vertices,
15006            &mut image_indices,
15007            &mut glyph_cmds,
15008        )? {
15009            self.scratch_image_vertices = image_vertices;
15010            self.scratch_image_indices = image_indices;
15011            self.scratch_glyph_cmds = glyph_cmds;
15012            return Ok(None);
15013        }
15014
15015        #[cfg(not(target_arch = "wasm32"))]
15016        if !image_indices.is_empty() {
15017            self.stage_native_image_buffers(
15018                staged_uploads,
15019                viewport,
15020                &image_vertices,
15021                &image_indices,
15022            );
15023        }
15024
15025        #[cfg(target_arch = "wasm32")]
15026        let image_slot = if glyph_cmds.is_empty() {
15027            0
15028        } else {
15029            let slot = self.claim_wasm_image_batch();
15030            {
15031                let buffers = &mut self.wasm_image_batches[slot];
15032                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
15033            }
15034            let buffers = &self.wasm_image_batches[slot];
15035            self.write_wasm_buffer(
15036                &buffers.vertex_buffer,
15037                bytemuck::cast_slice(&image_vertices),
15038            );
15039            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
15040            slot
15041        };
15042
15043        #[cfg(target_arch = "wasm32")]
15044        let uniform_slot = if glyph_cmds.is_empty() {
15045            0
15046        } else {
15047            self.prepare_wasm_viewport_uniforms(viewport)
15048        };
15049
15050        self.scratch_image_vertices = image_vertices;
15051        self.scratch_image_indices = image_indices;
15052        Ok(Some(PreparedGlyphBatch {
15053            cmds: glyph_cmds,
15054            #[cfg(target_arch = "wasm32")]
15055            image_slot,
15056            #[cfg(target_arch = "wasm32")]
15057            uniform_slot,
15058        }))
15059    }
15060
15061    #[allow(clippy::too_many_arguments)]
15062    fn append_image_bitmap_draw_cmd(
15063        &mut self,
15064        image: &ImageBitmap,
15065        rect: Rect,
15066        clip: Option<Rect>,
15067        sampling: ImageSampling,
15068        viewport: ViewportUniformParams,
15069        root_scale: f32,
15070        image_vertices: &mut Vec<Vertex>,
15071        image_indices: &mut Vec<u32>,
15072        image_cmds: &mut Vec<ImageDrawCmd>,
15073    ) -> Result<(), String> {
15074        if rect.width <= 0.0 || rect.height <= 0.0 {
15075            return Ok(());
15076        }
15077
15078        self.ensure_image_cached(image)?;
15079
15080        let (device_quad, scissor_rect) =
15081            if sampling == ImageSampling::Nearest && root_scale.is_finite() && root_scale > 0.0 {
15082                let left_px = (rect.x * root_scale).round();
15083                let top_px = (rect.y * root_scale).round();
15084                let width_px = (rect.width * root_scale).round().max(1.0);
15085                let height_px = (rect.height * root_scale).round().max(1.0);
15086                let snapped_rect = Rect {
15087                    x: left_px / root_scale,
15088                    y: top_px / root_scale,
15089                    width: width_px / root_scale,
15090                    height: height_px / root_scale,
15091                };
15092                let right_px = left_px + width_px;
15093                let bottom_px = top_px + height_px;
15094                (
15095                    [
15096                        [left_px, top_px],
15097                        [right_px, top_px],
15098                        [left_px, bottom_px],
15099                        [right_px, bottom_px],
15100                    ],
15101                    snapped_rect,
15102                )
15103            } else {
15104                (
15105                    rect_to_quad(rect).map(|[x, y]| [x * root_scale, y * root_scale]),
15106                    rect,
15107                )
15108            };
15109
15110        let Some(scissor) = scissor_rect_for_layer(
15111            scissor_rect,
15112            clip,
15113            root_scale,
15114            viewport.width,
15115            viewport.height,
15116        ) else {
15117            return Ok(());
15118        };
15119        let Some(uv_rect) = image_uv_rect(image, None) else {
15120            return Ok(());
15121        };
15122        #[cfg(not(target_arch = "wasm32"))]
15123        {
15124            if fill_area_diag_enabled() {
15125                self.fill_area_diag.add_image_quad(&device_quad);
15126            }
15127        }
15128
15129        let base_vertex = image_vertices.len() as u32;
15130        let index_start = image_indices.len() as u32;
15131        image_indices.extend_from_slice(&[
15132            base_vertex,
15133            base_vertex + 1,
15134            base_vertex + 2,
15135            base_vertex + 2,
15136            base_vertex + 1,
15137            base_vertex + 3,
15138        ]);
15139        let color = [1.0, 1.0, 1.0, 1.0];
15140        image_vertices.extend_from_slice(&[
15141            Vertex {
15142                position: device_quad[0],
15143                color,
15144                uv: [uv_rect.min[0], uv_rect.min[1]],
15145                uv_bounds: uv_rect.sample_bounds,
15146            },
15147            Vertex {
15148                position: device_quad[1],
15149                color,
15150                uv: [uv_rect.max[0], uv_rect.min[1]],
15151                uv_bounds: uv_rect.sample_bounds,
15152            },
15153            Vertex {
15154                position: device_quad[2],
15155                color,
15156                uv: [uv_rect.min[0], uv_rect.max[1]],
15157                uv_bounds: uv_rect.sample_bounds,
15158            },
15159            Vertex {
15160                position: device_quad[3],
15161                color,
15162                uv: [uv_rect.max[0], uv_rect.max[1]],
15163                uv_bounds: uv_rect.sample_bounds,
15164            },
15165        ]);
15166        image_cmds.push(ImageDrawCmd {
15167            index_start,
15168            scissor,
15169            image_id: image.id(),
15170            sampling,
15171        });
15172        Ok(())
15173    }
15174
15175    #[allow(clippy::too_many_arguments)]
15176    fn append_text_image_draw_cmds<'a, I>(
15177        &mut self,
15178        layer_texts: I,
15179        viewport: ViewportUniformParams,
15180        root_scale: f32,
15181        image_vertices: &mut Vec<Vertex>,
15182        image_indices: &mut Vec<u32>,
15183        image_cmds: &mut Vec<ImageDrawCmd>,
15184    ) -> Result<(), String>
15185    where
15186        I: Iterator<Item = &'a TextDraw>,
15187    {
15188        let append_start = Instant::now();
15189        let initial_len = image_cmds.len();
15190        let mut visited = 0usize;
15191        let mut hit_count = 0usize;
15192        let mut miss_count = 0usize;
15193        for text_draw in layer_texts {
15194            visited = visited.saturating_add(1);
15195            let _ = text_draw.node_id;
15196            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
15197                self.text_raster_geometry(text_draw, root_scale)
15198            else {
15199                continue;
15200            };
15201            if !text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
15202                continue;
15203            }
15204
15205            let raster_source = self.text_image_raster_source(
15206                text_draw,
15207                logical_rect,
15208                raster_rect,
15209                clip,
15210                root_scale,
15211                static_text_motion,
15212            );
15213            let source_draw = raster_source.draw.as_ref();
15214            let source_raster_rect = raster_source.raster_rect;
15215
15216            let cache_key = Self::text_image_cache_key(
15217                source_draw,
15218                source_raster_rect,
15219                text_scale,
15220                static_text_motion,
15221            );
15222            let image = if let Some(cached) = self.text_image_cache.get(&cache_key) {
15223                self.frame_stats
15224                    .record_text_image_cache_hit(cached.image.width(), cached.image.height());
15225                hit_count = hit_count.saturating_add(1);
15226                cached.image.clone()
15227            } else {
15228                let Some(image) =
15229                    self.rasterize_text_draw_to_image(source_draw, source_raster_rect, text_scale)
15230                else {
15231                    continue;
15232                };
15233                self.frame_stats
15234                    .record_text_image_cache_miss(image.width(), image.height());
15235                miss_count = miss_count.saturating_add(1);
15236                self.text_image_cache.put(
15237                    cache_key,
15238                    CachedTextImage {
15239                        image: image.clone(),
15240                    },
15241                );
15242                image
15243            };
15244
15245            let draw_origin = if static_text_motion {
15246                Point::new(
15247                    source_raster_rect.x / root_scale,
15248                    source_raster_rect.y / root_scale,
15249                )
15250            } else {
15251                Point::new(logical_rect.x, logical_rect.y)
15252            };
15253            let draw_rect = Rect {
15254                x: draw_origin.x,
15255                y: draw_origin.y,
15256                width: image.width() as f32 / root_scale,
15257                height: image.height() as f32 / root_scale,
15258            };
15259            self.append_image_bitmap_draw_cmd(
15260                &image,
15261                draw_rect,
15262                clip,
15263                ImageSampling::Nearest,
15264                viewport,
15265                root_scale,
15266                image_vertices,
15267                image_indices,
15268                image_cmds,
15269            )?;
15270        }
15271        let append_end = Instant::now();
15272        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
15273            log::warn!(
15274                "[wgpu-render-stage:text-images] total_ms={total_ms:.2} visited={} emitted={} hits={} misses={}",
15275                visited,
15276                image_cmds.len().saturating_sub(initial_len),
15277                hit_count,
15278                miss_count,
15279            );
15280        }
15281        Ok(())
15282    }
15283
15284    fn text_image_raster_source<'a>(
15285        &mut self,
15286        text_draw: &'a TextDraw,
15287        logical_rect: Rect,
15288        raster_rect: Rect,
15289        clip: Option<Rect>,
15290        root_scale: f32,
15291        static_text_motion: bool,
15292    ) -> TextRasterSource<'a> {
15293        let Some(clip) = clip else {
15294            return TextRasterSource {
15295                draw: Cow::Borrowed(text_draw),
15296                raster_rect,
15297            };
15298        };
15299        if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
15300            return TextRasterSource {
15301                draw: Cow::Borrowed(text_draw),
15302                raster_rect,
15303            };
15304        }
15305
15306        let line_starts = self.text_line_index_cache.line_starts(&text_draw.text);
15307        clipped_text_raster_source_with_line_starts(
15308            text_draw,
15309            logical_rect,
15310            raster_rect,
15311            clip,
15312            root_scale,
15313            line_starts.as_ref(),
15314        )
15315    }
15316
15317    fn prepare_text_image_draw_cmds<'a, I>(
15318        &mut self,
15319        layer_texts: I,
15320        viewport: ViewportUniformParams,
15321        root_scale: f32,
15322        staged_uploads: &mut StagedBufferUploads,
15323    ) -> Result<PreparedImageBatch, String>
15324    where
15325        I: Iterator<Item = &'a TextDraw>,
15326    {
15327        #[cfg(target_arch = "wasm32")]
15328        let _ = staged_uploads;
15329
15330        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
15331        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
15332        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
15333        image_vertices.clear();
15334        image_indices.clear();
15335        image_cmds.clear();
15336
15337        self.append_text_image_draw_cmds(
15338            layer_texts,
15339            viewport,
15340            root_scale,
15341            &mut image_vertices,
15342            &mut image_indices,
15343            &mut image_cmds,
15344        )?;
15345
15346        #[cfg(not(target_arch = "wasm32"))]
15347        if !image_cmds.is_empty() {
15348            self.stage_native_image_buffers(
15349                staged_uploads,
15350                viewport,
15351                &image_vertices,
15352                &image_indices,
15353            );
15354        }
15355
15356        #[cfg(target_arch = "wasm32")]
15357        let image_slot = if image_cmds.is_empty() {
15358            0
15359        } else {
15360            let slot = self.claim_wasm_image_batch();
15361            {
15362                let buffers = &mut self.wasm_image_batches[slot];
15363                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
15364            }
15365            let buffers = &self.wasm_image_batches[slot];
15366            self.write_wasm_buffer(
15367                &buffers.vertex_buffer,
15368                bytemuck::cast_slice(&image_vertices),
15369            );
15370            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
15371            slot
15372        };
15373
15374        #[cfg(target_arch = "wasm32")]
15375        let uniform_slot = if image_cmds.is_empty() {
15376            0
15377        } else {
15378            self.prepare_wasm_viewport_uniforms(viewport)
15379        };
15380
15381        self.scratch_image_vertices = image_vertices;
15382        self.scratch_image_indices = image_indices;
15383        Ok(PreparedImageBatch {
15384            cmds: image_cmds,
15385            #[cfg(target_arch = "wasm32")]
15386            image_slot,
15387            #[cfg(target_arch = "wasm32")]
15388            uniform_slot,
15389        })
15390    }
15391
15392    fn text_raster_geometry(
15393        &self,
15394        text_draw: &TextDraw,
15395        root_scale: f32,
15396    ) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
15397        text_raster_geometry_for_draw(text_draw, root_scale)
15398    }
15399
15400    fn text_image_cache_key(
15401        text_draw: &TextDraw,
15402        raster_rect: Rect,
15403        text_scale: f32,
15404        static_text_motion: bool,
15405    ) -> TextImageCacheKey {
15406        let mut state = default_hash::new();
15407        text_draw.text.render_hash().hash(&mut state);
15408        text_draw.text_style.render_hash().hash(&mut state);
15409        text_draw.color.render_hash().hash(&mut state);
15410        hash_text_raster_geometry_for_cache(raster_rect, static_text_motion, &mut state);
15411        text_draw.font_size.to_bits().hash(&mut state);
15412        text_scale.to_bits().hash(&mut state);
15413        text_draw.layout_options.hash(&mut state);
15414        TextImageCacheKey(state.finish())
15415    }
15416
15417    fn text_glyph_run_cache_key(
15418        text_draw: &TextDraw,
15419        raster_rect: Rect,
15420        text_scale: f32,
15421        static_text_motion: bool,
15422    ) -> TextGlyphRunCacheKey {
15423        TextGlyphRunCacheKey(
15424            Self::text_image_cache_key(text_draw, raster_rect, text_scale, static_text_motion).0,
15425        )
15426    }
15427
15428    fn rasterize_text_draw_to_image(
15429        &mut self,
15430        text_draw: &TextDraw,
15431        raster_rect: Rect,
15432        text_scale: f32,
15433    ) -> Option<ImageBitmap> {
15434        if text_draw.text.span_styles.is_empty() {
15435            let font = self.text_fonts.resolve(&text_draw.text_style)?;
15436            return rasterize_text_to_image_with_glyph_cache(
15437                text_draw.text.text.as_str(),
15438                raster_rect,
15439                &text_draw.text_style,
15440                text_draw.color,
15441                text_draw.font_size,
15442                text_scale,
15443                font,
15444                &mut self.text_glyph_mask_cache,
15445            );
15446        }
15447
15448        if let Some(image) = rasterize_annotated_text_to_image_with_glyph_cache(
15449            text_draw.text.as_ref(),
15450            raster_rect,
15451            &text_draw.text_style,
15452            text_draw.color,
15453            text_draw.font_size,
15454            text_scale,
15455            &self.text_fonts,
15456            &mut self.text_glyph_mask_cache,
15457        ) {
15458            return Some(image);
15459        }
15460
15461        rasterize_spanned_text_to_image(
15462            text_draw,
15463            raster_rect,
15464            text_scale,
15465            &self.text_fonts,
15466            &mut self.text_glyph_mask_cache,
15467        )
15468    }
15469}
15470
15471fn rasterize_spanned_text_to_image(
15472    text_draw: &TextDraw,
15473    raster_rect: Rect,
15474    text_scale: f32,
15475    fonts: &SoftwareTextFontSet,
15476    glyph_cache: &mut SoftwareGlyphRasterCache,
15477) -> Option<ImageBitmap> {
15478    let width = raster_rect.width.ceil().max(1.0) as u32;
15479    let height = raster_rect.height.ceil().max(1.0) as u32;
15480    let mut canvas = vec![0_u8; (width as usize) * (height as usize) * 4];
15481    let boundaries = text_draw.text.span_boundaries();
15482    let base_line_height = text_draw
15483        .text_style
15484        .resolve_line_height(14.0, text_draw.font_size)
15485        .max(1.0);
15486    let mut current_line_height = base_line_height;
15487    let mut cursor_x = raster_rect.x;
15488    let mut cursor_y = raster_rect.y;
15489
15490    for window in boundaries.windows(2) {
15491        let start = window[0];
15492        let end = window[1];
15493        if start == end {
15494            continue;
15495        }
15496
15497        let chunk = &text_draw.text.text[start..end];
15498        let mut merged_span = text_draw.text_style.span_style.clone();
15499        for span in &text_draw.text.span_styles {
15500            if span.range.start <= start && span.range.end >= end {
15501                merged_span = merged_span.merge(&span.item);
15502            }
15503        }
15504
15505        let mut chunk_style = text_draw.text_style.clone();
15506        chunk_style.span_style = merged_span;
15507
15508        for part in chunk.split_inclusive('\n') {
15509            let has_newline = part.ends_with('\n');
15510            let content = if has_newline {
15511                &part[..part.len().saturating_sub(1)]
15512            } else {
15513                part
15514            };
15515
15516            if !content.is_empty() {
15517                let chunk_font_size = chunk_style.resolve_font_size(text_draw.font_size);
15518                let Some(font) = fonts.resolve(&chunk_style) else {
15519                    continue;
15520                };
15521                let metrics = measure_text_with_font(content, &chunk_style, chunk_font_size, font);
15522                let segment_rect = Rect {
15523                    x: cursor_x,
15524                    y: cursor_y,
15525                    width: (metrics.width * text_scale).ceil().max(1.0),
15526                    height: (metrics.height * text_scale).ceil().max(1.0),
15527                };
15528                if let Some(segment_image) = rasterize_text_to_image_with_glyph_cache(
15529                    content,
15530                    segment_rect,
15531                    &chunk_style,
15532                    chunk_style.resolve_text_color(text_draw.color),
15533                    chunk_font_size,
15534                    text_scale,
15535                    font,
15536                    glyph_cache,
15537                ) {
15538                    composite_text_segment(
15539                        &mut canvas,
15540                        width,
15541                        height,
15542                        raster_rect,
15543                        segment_rect,
15544                        &segment_image,
15545                    );
15546                }
15547                cursor_x += metrics.width * text_scale;
15548                current_line_height = current_line_height.max(metrics.line_height.max(1.0));
15549            }
15550
15551            if has_newline {
15552                cursor_x = raster_rect.x;
15553                cursor_y += current_line_height * text_scale;
15554                current_line_height = base_line_height;
15555            }
15556        }
15557    }
15558
15559    ImageBitmap::from_rgba8(width, height, canvas).ok()
15560}
15561
15562struct TextRasterSource<'a> {
15563    draw: Cow<'a, TextDraw>,
15564    raster_rect: Rect,
15565}
15566
15567fn text_glyph_raster_source(text_draw: &TextDraw, raster_rect: Rect) -> TextRasterSource<'_> {
15568    TextRasterSource {
15569        draw: Cow::Borrowed(text_draw),
15570        raster_rect,
15571    }
15572}
15573
15574#[cfg(test)]
15575fn clipped_text_raster_source<'a>(
15576    text_draw: &'a TextDraw,
15577    logical_rect: Rect,
15578    raster_rect: Rect,
15579    clip: Option<Rect>,
15580    root_scale: f32,
15581    static_text_motion: bool,
15582) -> TextRasterSource<'a> {
15583    let Some(clip) = clip else {
15584        return TextRasterSource {
15585            draw: Cow::Borrowed(text_draw),
15586            raster_rect,
15587        };
15588    };
15589    if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
15590        return TextRasterSource {
15591            draw: Cow::Borrowed(text_draw),
15592            raster_rect,
15593        };
15594    }
15595    let line_starts = line_start_offsets(text_draw.text.text.as_str());
15596    clipped_text_raster_source_with_line_starts(
15597        text_draw,
15598        logical_rect,
15599        raster_rect,
15600        clip,
15601        root_scale,
15602        &line_starts,
15603    )
15604}
15605
15606fn clipped_text_raster_source_with_line_starts<'a>(
15607    text_draw: &'a TextDraw,
15608    logical_rect: Rect,
15609    raster_rect: Rect,
15610    clip: Rect,
15611    root_scale: f32,
15612    line_starts: &[usize],
15613) -> TextRasterSource<'a> {
15614    if line_starts.len() < MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER {
15615        return TextRasterSource {
15616            draw: Cow::Borrowed(text_draw),
15617            raster_rect,
15618        };
15619    }
15620
15621    let Some(visible_rect) = logical_rect.intersect(clip) else {
15622        return TextRasterSource {
15623            draw: Cow::Borrowed(text_draw),
15624            raster_rect,
15625        };
15626    };
15627
15628    let line_count = line_starts.len().max(1);
15629    let line_height = logical_rect.height / line_count as f32;
15630    if !line_height.is_finite() || line_height <= 0.0 {
15631        return TextRasterSource {
15632            draw: Cow::Borrowed(text_draw),
15633            raster_rect,
15634        };
15635    }
15636
15637    let visible_top = ((visible_rect.y - logical_rect.y) / line_height).floor() as isize;
15638    let visible_bottom =
15639        ((visible_rect.y + visible_rect.height - logical_rect.y) / line_height).ceil() as isize;
15640    let start_line = visible_top.saturating_sub(1).max(0) as usize;
15641    let end_line = (visible_bottom + 1).max(start_line as isize + 1) as usize;
15642    let end_line = end_line.min(line_count);
15643    if start_line == 0 && end_line >= line_count {
15644        return TextRasterSource {
15645            draw: Cow::Borrowed(text_draw),
15646            raster_rect,
15647        };
15648    }
15649
15650    let byte_start = line_starts[start_line];
15651    let byte_end = line_end_offset(text_draw.text.text.as_str(), line_starts, end_line - 1);
15652    if byte_start >= byte_end {
15653        return TextRasterSource {
15654            draw: Cow::Borrowed(text_draw),
15655            raster_rect,
15656        };
15657    }
15658
15659    let slice_y = logical_rect.y + start_line as f32 * line_height;
15660    let slice_height = (end_line - start_line) as f32 * line_height;
15661    let mut slice_raster_rect = Rect {
15662        x: logical_rect.x * root_scale,
15663        y: slice_y * root_scale,
15664        width: logical_rect.width * root_scale,
15665        height: slice_height * root_scale,
15666    };
15667    slice_raster_rect.x = slice_raster_rect.x.round();
15668    slice_raster_rect.y = slice_raster_rect.y.round();
15669    slice_raster_rect.width = slice_raster_rect.width.ceil().max(1.0);
15670    slice_raster_rect.height = slice_raster_rect.height.ceil().max(1.0);
15671
15672    let mut sliced_draw = text_draw.clone();
15673    sliced_draw.rect = Rect {
15674        x: logical_rect.x,
15675        y: slice_y,
15676        width: logical_rect.width,
15677        height: slice_height,
15678    };
15679    sliced_draw.text = Arc::new(text_draw.text.subsequence(byte_start..byte_end));
15680
15681    TextRasterSource {
15682        draw: Cow::Owned(sliced_draw),
15683        raster_rect: slice_raster_rect,
15684    }
15685}
15686
15687fn line_start_offsets(text: &str) -> Vec<usize> {
15688    let mut starts =
15689        Vec::with_capacity(text.as_bytes().iter().filter(|b| **b == b'\n').count() + 1);
15690    starts.push(0);
15691    starts.extend(
15692        text.char_indices()
15693            .filter_map(|(index, ch)| (ch == '\n').then_some(index + ch.len_utf8())),
15694    );
15695    starts
15696}
15697
15698fn line_end_offset(text: &str, line_starts: &[usize], line: usize) -> usize {
15699    line_starts.get(line + 1).copied().unwrap_or(text.len())
15700}
15701
15702fn composite_text_segment(
15703    canvas: &mut [u8],
15704    canvas_width: u32,
15705    canvas_height: u32,
15706    canvas_rect: Rect,
15707    segment_rect: Rect,
15708    segment_image: &ImageBitmap,
15709) {
15710    let offset_x = (segment_rect.x - canvas_rect.x).round() as i32;
15711    let offset_y = (segment_rect.y - canvas_rect.y).round() as i32;
15712    let src = segment_image.pixels();
15713    for sy in 0..segment_image.height() as i32 {
15714        let dy = offset_y + sy;
15715        if dy < 0 || dy >= canvas_height as i32 {
15716            continue;
15717        }
15718        for sx in 0..segment_image.width() as i32 {
15719            let dx = offset_x + sx;
15720            if dx < 0 || dx >= canvas_width as i32 {
15721                continue;
15722            }
15723            let src_index = ((sy as u32 * segment_image.width() + sx as u32) * 4) as usize;
15724            let dst_index = ((dy as u32 * canvas_width + dx as u32) * 4) as usize;
15725            blend_rgba_pixel(
15726                &mut canvas[dst_index..dst_index + 4],
15727                &src[src_index..src_index + 4],
15728            );
15729        }
15730    }
15731}
15732
15733fn blend_rgba_pixel(dst: &mut [u8], src: &[u8]) {
15734    let src_alpha = src[3] as f32 / 255.0;
15735    if src_alpha <= 0.0 {
15736        return;
15737    }
15738    let dst_alpha = dst[3] as f32 / 255.0;
15739    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
15740    if out_alpha <= f32::EPSILON {
15741        dst.copy_from_slice(&[0, 0, 0, 0]);
15742        return;
15743    }
15744
15745    for channel in 0..3 {
15746        let src_channel = src[channel] as f32 / 255.0;
15747        let dst_channel = dst[channel] as f32 / 255.0;
15748        let src_premult = src_channel * src_alpha;
15749        let dst_premult = dst_channel * dst_alpha;
15750        dst[channel] =
15751            (((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0) * 255.0)
15752                .round() as u8;
15753    }
15754    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
15755}
15756
15757fn align_to(value: u32, alignment: u32) -> u32 {
15758    debug_assert!(alignment > 0);
15759    value.div_ceil(alignment) * alignment
15760}
15761
15762#[cfg(not(target_arch = "wasm32"))]
15763fn align_usize_to(value: usize, alignment: usize) -> usize {
15764    debug_assert!(alignment > 0);
15765    value.div_ceil(alignment) * alignment
15766}
15767
15768impl GpuRenderer {
15769    fn convert_surface_pixels_to_rgba(&self, pixels: &mut [u8]) -> Result<(), String> {
15770        match self.surface_format {
15771            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => Ok(()),
15772            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
15773                for pixel in pixels.as_chunks_mut::<4>().0 {
15774                    pixel.swap(0, 2);
15775                }
15776                Ok(())
15777            }
15778            format => Err(format!(
15779                "Screenshot readback unsupported for texture format: {format:?}"
15780            )),
15781        }
15782    }
15783}
15784
15785fn is_in_effect_range(z_index: usize, effect_z_ranges: &[Range<usize>]) -> bool {
15786    effect_z_ranges.iter().any(|range| range.contains(&z_index))
15787}
15788
15789#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15790enum SegmentDrawItem {
15791    Shape(usize),
15792    Image(usize),
15793    Text(usize),
15794    Shadow(usize),
15795    Composite(usize),
15796    ShaderComposite(usize),
15797    Retained(usize),
15798}
15799
15800#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15801enum SegmentBatchPlan {
15802    Shape {
15803        start: usize,
15804        end: usize,
15805        blend_mode: BlendMode,
15806    },
15807    Image {
15808        start: usize,
15809        end: usize,
15810        blend_mode: BlendMode,
15811    },
15812    Text {
15813        start: usize,
15814        end: usize,
15815    },
15816    Composite {
15817        start: usize,
15818        end: usize,
15819    },
15820    ShaderComposite {
15821        start: usize,
15822        end: usize,
15823    },
15824    /// Retained replay batches: each item is one bind + draw of GPU slots
15825    /// captured on an earlier frame, so they never merge and cost no budget.
15826    Retained {
15827        start: usize,
15828        end: usize,
15829    },
15830}
15831
15832#[derive(Clone, Debug, Default, PartialEq, Eq)]
15833struct SegmentDrawChunkPlan {
15834    batches: Vec<SegmentBatchPlan>,
15835}
15836
15837struct SegmentRenderOutcome {
15838    rendered_any: bool,
15839    pass_count: u32,
15840}
15841
15842struct SegmentCommandEncodeOutcome {
15843    first_batch: bool,
15844}
15845
15846#[cfg(not(target_arch = "wasm32"))]
15847#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15848enum TextGlyphPrewarmDecision {
15849    Candidate,
15850    MissingGeometry,
15851    DynamicMotion,
15852    Visible,
15853    OutsidePrewarmWindow,
15854}
15855
15856#[cfg(not(target_arch = "wasm32"))]
15857#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15858struct NativeSegmentFusionBudget {
15859    shape_count: usize,
15860    gradient_stop_count: usize,
15861}
15862
15863#[cfg(not(target_arch = "wasm32"))]
15864#[derive(Clone, Debug, PartialEq, Eq)]
15865struct NativeSegmentFusionPartition {
15866    chunk: SegmentDrawChunkPlan,
15867    budget: NativeSegmentFusionBudget,
15868}
15869
15870#[cfg(not(target_arch = "wasm32"))]
15871#[derive(Clone, Debug, PartialEq, Eq)]
15872enum FusedSegmentBatch {
15873    Shape {
15874        batch: PreparedShapeBatch,
15875        blend_mode: BlendMode,
15876    },
15877    Image {
15878        cmd_range: Range<usize>,
15879        blend_mode: BlendMode,
15880    },
15881    Text {
15882        image_cmd_range: Range<usize>,
15883        glyph_cmd_range: Range<usize>,
15884    },
15885    Composite {
15886        draw_range: Range<usize>,
15887    },
15888    ShaderComposite {
15889        draw_range: Range<usize>,
15890    },
15891    Retained {
15892        item_range: Range<usize>,
15893    },
15894}
15895
15896struct ShadowSourceRenderOutcome {
15897    rendered_any: bool,
15898    pass_count: u32,
15899}
15900
15901/// One segment-surface capture this frame must encode: the entry's key,
15902/// the slot shape range, and the claimed per-frame capture slot (transform
15903/// stride + viewport-uniform slot index).
15904#[cfg(not(target_arch = "wasm32"))]
15905struct SegmentCaptureJob {
15906    key: SegmentSurfaceKey,
15907    first: u32,
15908    last: u32,
15909    capture_index: u32,
15910}
15911
15912/// One retained item's cached-composite plan: the dest quad (device px,
15913/// strip order TL TR BL BR) and the dest-px → source-texel inverse under
15914/// this frame's effective transform.
15915#[cfg(not(target_arch = "wasm32"))]
15916struct SegmentCompositePlan {
15917    key: SegmentSurfaceKey,
15918    dest_quad: [[f32; 2]; 4],
15919    inverse: [[f32; 3]; 3],
15920    identity: bool,
15921}
15922
15923/// The capture rect's corners in capture space — also the dest quad under
15924/// an identity effective transform.
15925#[cfg(not(target_arch = "wasm32"))]
15926fn segment_identity_quad(rect: &CaptureRect) -> [[f32; 2]; 4] {
15927    let [x, y] = rect.origin;
15928    let width = rect.width as f32;
15929    let height = rect.height as f32;
15930    [
15931        [x, y],
15932        [x + width, y],
15933        [x, y + height],
15934        [x + width, y + height],
15935    ]
15936}
15937
15938/// Dest px → source texel for the identity case: a pure integer translate,
15939/// so `textureLoad` sampling is texel-exact.
15940#[cfg(not(target_arch = "wasm32"))]
15941fn segment_identity_inverse(rect: &CaptureRect) -> [[f32; 3]; 3] {
15942    [
15943        [1.0, 0.0, -rect.origin[0]],
15944        [0.0, 1.0, -rect.origin[1]],
15945        [0.0, 0.0, 1.0],
15946    ]
15947}
15948
15949/// Measures a shape range's capture geometry under `transform`: the padded
15950/// integer capture rect (None when degenerate or larger than the device
15951/// allows) and the member-quad pixel sum the economics gate prices the
15952/// direct path at (submitted-area scaled for arc-meshed slots).
15953#[cfg(not(target_arch = "wasm32"))]
15954fn plan_segment_capture_geometry(
15955    slot: &ReplaySlot,
15956    first: u32,
15957    last: u32,
15958    transform: SimilarityTransform,
15959    max_texture_dim: u32,
15960) -> Option<(CaptureRect, f32)> {
15961    let range = first as usize..last as usize;
15962    let aabbs = slot.shape_aabbs.get(range)?;
15963    if aabbs.is_empty() {
15964        return None;
15965    }
15966    let affine = Affine2::from_similarity(transform.center, transform.rot, transform.scale);
15967    let mut min = [f32::INFINITY; 2];
15968    let mut max = [f32::NEG_INFINITY; 2];
15969    for aabb in aabbs {
15970        for corner in [
15971            [aabb[0], aabb[1]],
15972            [aabb[2], aabb[1]],
15973            [aabb[0], aabb[3]],
15974            [aabb[2], aabb[3]],
15975        ] {
15976            let p = affine.apply(corner);
15977            min[0] = min[0].min(p[0]);
15978            min[1] = min[1].min(p[1]);
15979            max[0] = max[0].max(p[0]);
15980            max[1] = max[1].max(p[1]);
15981        }
15982    }
15983    let rect = crate::segment_surface::snap_capture_rect(min, max, max_texture_dim)?;
15984    let base_area = slot.area_prefix.get(last as usize).copied()?
15985        - slot.area_prefix.get(first as usize).copied()?;
15986    let member_px = base_area * transform.scale * transform.scale * slot.submitted_area_scale;
15987    Some((rect, member_px))
15988}
15989
15990impl SegmentDrawChunkPlan {
15991    fn is_empty(&self) -> bool {
15992        self.batches.is_empty()
15993    }
15994
15995    fn push(&mut self, batch: SegmentBatchPlan) {
15996        self.batches.push(batch);
15997    }
15998
15999    fn iter(&self) -> impl Iterator<Item = SegmentBatchPlan> + '_ {
16000        self.batches.iter().copied()
16001    }
16002}
16003
16004#[derive(Clone, Debug, PartialEq, Eq)]
16005enum SegmentRenderCommand {
16006    DrawChunk(SegmentDrawChunkPlan),
16007    Shadow(usize),
16008}
16009
16010struct SegmentCommandIter<'a> {
16011    ordered_items: &'a [(usize, SegmentDrawItem)],
16012    shapes: &'a [DrawShape],
16013    images: &'a [ImageDraw],
16014    cursor: usize,
16015    batch_limits: ShapeBatchLimits,
16016}
16017
16018impl<'a> SegmentCommandIter<'a> {
16019    fn new(
16020        ordered_items: &'a [(usize, SegmentDrawItem)],
16021        shapes: &'a [DrawShape],
16022        images: &'a [ImageDraw],
16023        batch_limits: ShapeBatchLimits,
16024    ) -> Self {
16025        Self {
16026            ordered_items,
16027            shapes,
16028            images,
16029            cursor: 0,
16030            batch_limits,
16031        }
16032    }
16033}
16034
16035impl Iterator for SegmentCommandIter<'_> {
16036    type Item = SegmentRenderCommand;
16037
16038    fn next(&mut self) -> Option<Self::Item> {
16039        if self.cursor >= self.ordered_items.len() {
16040            return None;
16041        }
16042
16043        if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
16044            self.cursor += 1;
16045            return Some(SegmentRenderCommand::Shadow(index));
16046        }
16047
16048        let mut chunk = SegmentDrawChunkPlan::default();
16049        while self.cursor < self.ordered_items.len() {
16050            if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
16051                if chunk.is_empty() {
16052                    self.cursor += 1;
16053                    return Some(SegmentRenderCommand::Shadow(index));
16054                }
16055                break;
16056            }
16057
16058            let Some((batch, next_cursor)) = segment_batch_plan_at_cursor(
16059                self.ordered_items,
16060                self.shapes,
16061                self.images,
16062                self.cursor,
16063                self.batch_limits,
16064            ) else {
16065                break;
16066            };
16067            chunk.push(batch);
16068            self.cursor = next_cursor;
16069        }
16070
16071        Some(SegmentRenderCommand::DrawChunk(chunk))
16072    }
16073}
16074
16075#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16076struct PreparedShapeBatch {
16077    /// First vertex and vertex count for the unindexed shape draw; always
16078    /// multiples of 6 so `vs_main`'s `vertex_index / 6` lands on whole shapes.
16079    vertex_start: u32,
16080    vertex_count: u32,
16081    /// Whether any shape in the batch carries gradient stops. False routes
16082    /// a SrcOver draw through the `fs_solid` pipeline.
16083    has_gradient: bool,
16084    #[cfg(target_arch = "wasm32")]
16085    shape_slot: usize,
16086    #[cfg(target_arch = "wasm32")]
16087    uniform_slot: usize,
16088}
16089
16090struct PreparedImageBatch {
16091    cmds: Vec<ImageDrawCmd>,
16092    #[cfg(target_arch = "wasm32")]
16093    image_slot: usize,
16094    #[cfg(target_arch = "wasm32")]
16095    uniform_slot: usize,
16096}
16097
16098impl PreparedImageBatch {
16099    fn is_empty(&self) -> bool {
16100        self.cmds.is_empty()
16101    }
16102
16103    fn into_cmds(self) -> Vec<ImageDrawCmd> {
16104        self.cmds
16105    }
16106}
16107
16108struct PreparedGlyphBatch {
16109    cmds: Vec<GlyphDrawCmd>,
16110    #[cfg(target_arch = "wasm32")]
16111    image_slot: usize,
16112    #[cfg(target_arch = "wasm32")]
16113    uniform_slot: usize,
16114}
16115
16116impl PreparedGlyphBatch {
16117    fn is_empty(&self) -> bool {
16118        self.cmds.is_empty()
16119    }
16120
16121    fn into_cmds(self) -> Vec<GlyphDrawCmd> {
16122        self.cmds
16123    }
16124}
16125
16126#[cfg(not(target_arch = "wasm32"))]
16127fn gradient_stop_count_for_shape(shape: &DrawShape, brushes: &[Brush]) -> usize {
16128    match shape.brush {
16129        SceneBrush::Solid(_) => 0,
16130        SceneBrush::Gradient(index) => match &brushes[index as usize] {
16131            Brush::Solid(_) => 0,
16132            Brush::LinearGradient { colors, .. }
16133            | Brush::RadialGradient { colors, .. }
16134            | Brush::SweepGradient { colors, .. } => colors.len(),
16135        },
16136    }
16137}
16138
16139#[cfg(not(target_arch = "wasm32"))]
16140fn native_segment_fusion_budget(
16141    ordered_items: &[(usize, SegmentDrawItem)],
16142    shapes: &[DrawShape],
16143    brushes: &[Brush],
16144    chunk: &SegmentDrawChunkPlan,
16145    batch_limits: ShapeBatchLimits,
16146) -> Result<Option<NativeSegmentFusionBudget>, String> {
16147    let mut shape_count = 0usize;
16148    let mut gradient_stop_count = 0usize;
16149
16150    for batch in chunk.iter() {
16151        let SegmentBatchPlan::Shape { start, end, .. } = batch else {
16152            continue;
16153        };
16154        for (_, item) in &ordered_items[start..end] {
16155            let SegmentDrawItem::Shape(shape_index) = item else {
16156                return Err(format!(
16157                    "shape batch contains non-shape draw item: {item:?}"
16158                ));
16159            };
16160            let shape = &shapes[*shape_index];
16161            shape_count = shape_count.saturating_add(1);
16162            gradient_stop_count =
16163                gradient_stop_count.saturating_add(gradient_stop_count_for_shape(shape, brushes));
16164        }
16165    }
16166
16167    if shape_count > batch_limits.max_shapes_per_batch
16168        || gradient_stop_count > batch_limits.max_gradient_stops
16169    {
16170        return Ok(None);
16171    }
16172
16173    Ok(Some(NativeSegmentFusionBudget {
16174        shape_count,
16175        gradient_stop_count,
16176    }))
16177}
16178
16179#[cfg(not(target_arch = "wasm32"))]
16180fn push_native_segment_fusion_partition(
16181    partitions: &mut Vec<NativeSegmentFusionPartition>,
16182    current: &mut SegmentDrawChunkPlan,
16183    current_budget: &mut NativeSegmentFusionBudget,
16184) {
16185    if current.is_empty() {
16186        return;
16187    }
16188
16189    partitions.push(NativeSegmentFusionPartition {
16190        chunk: std::mem::take(current),
16191        budget: *current_budget,
16192    });
16193    *current_budget = NativeSegmentFusionBudget {
16194        shape_count: 0,
16195        gradient_stop_count: 0,
16196    };
16197}
16198
16199#[cfg(not(target_arch = "wasm32"))]
16200fn native_segment_fusion_partitions(
16201    ordered_items: &[(usize, SegmentDrawItem)],
16202    shapes: &[DrawShape],
16203    brushes: &[Brush],
16204    chunk: &SegmentDrawChunkPlan,
16205    batch_limits: ShapeBatchLimits,
16206) -> Result<Option<Vec<NativeSegmentFusionPartition>>, String> {
16207    if let Some(budget) =
16208        native_segment_fusion_budget(ordered_items, shapes, brushes, chunk, batch_limits)?
16209    {
16210        return Ok(Some(vec![NativeSegmentFusionPartition {
16211            chunk: chunk.clone(),
16212            budget,
16213        }]));
16214    }
16215
16216    let mut partitions = Vec::new();
16217    let mut current = SegmentDrawChunkPlan::default();
16218    let mut current_budget = NativeSegmentFusionBudget {
16219        shape_count: 0,
16220        gradient_stop_count: 0,
16221    };
16222
16223    for batch in chunk.iter() {
16224        let SegmentBatchPlan::Shape {
16225            start,
16226            end,
16227            blend_mode,
16228        } = batch
16229        else {
16230            current.push(batch);
16231            continue;
16232        };
16233
16234        let mut run_start = start;
16235        for (item_cursor, (_, item)) in ordered_items.iter().enumerate().take(end).skip(start) {
16236            let SegmentDrawItem::Shape(shape_index) = *item else {
16237                return Err(format!(
16238                    "shape batch contains non-shape draw item: {:?}",
16239                    item
16240                ));
16241            };
16242            let gradient_stop_count = gradient_stop_count_for_shape(&shapes[shape_index], brushes);
16243            if gradient_stop_count > batch_limits.max_gradient_stops {
16244                return Ok(None);
16245            }
16246
16247            let fits_shape_count =
16248                current_budget.shape_count.saturating_add(1) <= batch_limits.max_shapes_per_batch;
16249            let fits_gradient_count = current_budget
16250                .gradient_stop_count
16251                .saturating_add(gradient_stop_count)
16252                <= batch_limits.max_gradient_stops;
16253            if !fits_shape_count || !fits_gradient_count {
16254                if run_start < item_cursor {
16255                    current.push(SegmentBatchPlan::Shape {
16256                        start: run_start,
16257                        end: item_cursor,
16258                        blend_mode,
16259                    });
16260                }
16261                push_native_segment_fusion_partition(
16262                    &mut partitions,
16263                    &mut current,
16264                    &mut current_budget,
16265                );
16266                run_start = item_cursor;
16267            }
16268
16269            current_budget.shape_count = current_budget.shape_count.saturating_add(1);
16270            current_budget.gradient_stop_count = current_budget
16271                .gradient_stop_count
16272                .saturating_add(gradient_stop_count);
16273        }
16274
16275        if run_start < end {
16276            current.push(SegmentBatchPlan::Shape {
16277                start: run_start,
16278                end,
16279                blend_mode,
16280            });
16281        }
16282    }
16283
16284    push_native_segment_fusion_partition(&mut partitions, &mut current, &mut current_budget);
16285    Ok(Some(partitions))
16286}
16287
16288fn segment_batch_plan_at_cursor(
16289    ordered_items: &[(usize, SegmentDrawItem)],
16290    shapes: &[DrawShape],
16291    images: &[ImageDraw],
16292    start: usize,
16293    batch_limits: ShapeBatchLimits,
16294) -> Option<(SegmentBatchPlan, usize)> {
16295    match ordered_items[start].1 {
16296        SegmentDrawItem::Shape(index) => {
16297            let blend_mode = supported_blend_mode(shapes[index].blend_mode);
16298            let mut end = start + 1;
16299            let shape_limit = (start + batch_limits.max_shapes_per_batch).min(ordered_items.len());
16300            while end < shape_limit {
16301                match ordered_items[end].1 {
16302                    SegmentDrawItem::Shape(next_index)
16303                        if supported_blend_mode(shapes[next_index].blend_mode) == blend_mode =>
16304                    {
16305                        end += 1;
16306                    }
16307                    _ => break,
16308                }
16309            }
16310            Some((
16311                SegmentBatchPlan::Shape {
16312                    start,
16313                    end,
16314                    blend_mode,
16315                },
16316                end,
16317            ))
16318        }
16319        SegmentDrawItem::Image(index) => {
16320            let blend_mode = supported_blend_mode(images[index].blend_mode);
16321            let mut end = start + 1;
16322            while end < ordered_items.len() {
16323                match ordered_items[end].1 {
16324                    SegmentDrawItem::Image(next_index)
16325                        if supported_blend_mode(images[next_index].blend_mode) == blend_mode =>
16326                    {
16327                        end += 1;
16328                    }
16329                    _ => break,
16330                }
16331            }
16332            Some((
16333                SegmentBatchPlan::Image {
16334                    start,
16335                    end,
16336                    blend_mode,
16337                },
16338                end,
16339            ))
16340        }
16341        SegmentDrawItem::Text(_) => {
16342            let mut end = start + 1;
16343            while end < ordered_items.len() {
16344                if matches!(ordered_items[end].1, SegmentDrawItem::Text(_)) {
16345                    end += 1;
16346                } else {
16347                    break;
16348                }
16349            }
16350            Some((SegmentBatchPlan::Text { start, end }, end))
16351        }
16352        SegmentDrawItem::Composite(_) => {
16353            let mut end = start + 1;
16354            while end < ordered_items.len() {
16355                if matches!(ordered_items[end].1, SegmentDrawItem::Composite(_)) {
16356                    end += 1;
16357                } else {
16358                    break;
16359                }
16360            }
16361            Some((SegmentBatchPlan::Composite { start, end }, end))
16362        }
16363        SegmentDrawItem::ShaderComposite(_) => {
16364            let mut end = start + 1;
16365            while end < ordered_items.len() {
16366                if matches!(ordered_items[end].1, SegmentDrawItem::ShaderComposite(_)) {
16367                    end += 1;
16368                } else {
16369                    break;
16370                }
16371            }
16372            Some((SegmentBatchPlan::ShaderComposite { start, end }, end))
16373        }
16374        SegmentDrawItem::Retained(_) => {
16375            let mut end = start + 1;
16376            while end < ordered_items.len() {
16377                if matches!(ordered_items[end].1, SegmentDrawItem::Retained(_)) {
16378                    end += 1;
16379                } else {
16380                    break;
16381                }
16382            }
16383            Some((SegmentBatchPlan::Retained { start, end }, end))
16384        }
16385        SegmentDrawItem::Shadow(_) => None,
16386    }
16387}
16388
16389#[allow(clippy::too_many_arguments)]
16390fn collect_non_effect_segment_items(
16391    shapes: &[DrawShape],
16392    _images: &[ImageDraw],
16393    _texts: &[TextDraw],
16394    _shadow_draws: &[ShadowDraw],
16395    draw_ops: &[DrawOp],
16396    z_start: usize,
16397    z_end: usize,
16398    effect_z_ranges: &[Range<usize>],
16399    width: u32,
16400    height: u32,
16401    root_scale: f32,
16402    scratch: &mut Vec<(usize, SegmentDrawItem)>,
16403) {
16404    scratch.clear();
16405    let viewport = ViewportUniformParams {
16406        width,
16407        height,
16408        offset: [0.0, 0.0],
16409    };
16410
16411    scratch.extend(draw_ops.iter().filter_map(|op| {
16412        if op.z_index < z_start
16413            || op.z_index >= z_end
16414            || is_in_effect_range(op.z_index, effect_z_ranges)
16415        {
16416            return None;
16417        }
16418        let item = match op.kind {
16419            DrawOpKind::Shape(index) => {
16420                let shape = shapes.get(index)?;
16421                if !shape_draw_is_visible_in_viewport(shape, viewport, root_scale) {
16422                    return None;
16423                }
16424                SegmentDrawItem::Shape(index)
16425            }
16426            DrawOpKind::Image(index) => SegmentDrawItem::Image(index),
16427            DrawOpKind::Text(index) => SegmentDrawItem::Text(index),
16428            DrawOpKind::Shadow(index) => SegmentDrawItem::Shadow(index),
16429            DrawOpKind::Retained(index) => SegmentDrawItem::Retained(index),
16430        };
16431        Some((op.z_index, item))
16432    }));
16433}
16434
16435fn retain_renderable_shadow_items(
16436    ordered_items: &mut Vec<(usize, SegmentDrawItem)>,
16437    shadow_draws: &[ShadowDraw],
16438    width: u32,
16439    height: u32,
16440    root_scale: f32,
16441    max_texture_dim: u32,
16442) -> usize {
16443    let original_len = ordered_items.len();
16444    ordered_items.retain(|(_, item)| match item {
16445        SegmentDrawItem::Shadow(index) => shadow_draws.get(*index).is_some_and(|shadow| {
16446            shadow_draw_may_render(shadow, width, height, root_scale, max_texture_dim)
16447        }),
16448        _ => true,
16449    });
16450    original_len.saturating_sub(ordered_items.len())
16451}
16452
16453#[cfg(not(target_arch = "wasm32"))]
16454#[derive(Clone, Copy)]
16455struct SegmentDiagCounts {
16456    raw_shadow_items: usize,
16457    culled_shadow_items: usize,
16458    cached_shadow_composites: usize,
16459    composite_items: usize,
16460    shader_composite_items: usize,
16461}
16462
16463#[cfg(not(target_arch = "wasm32"))]
16464fn maybe_print_segment_diag(
16465    z_range: Range<usize>,
16466    ordered_items: &[(usize, SegmentDrawItem)],
16467    shapes: &[DrawShape],
16468    brushes: &[Brush],
16469    images: &[ImageDraw],
16470    counts: SegmentDiagCounts,
16471    batch_limits: ShapeBatchLimits,
16472) {
16473    if !cranpose_core::env_flag!("CRANPOSE_SEGMENT_DIAG") {
16474        return;
16475    }
16476    let line = SEGMENT_DIAG_LINES.fetch_add(1, Ordering::Relaxed);
16477    if line >= 64 {
16478        return;
16479    }
16480
16481    let remaining_shadow_items = ordered_items
16482        .iter()
16483        .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
16484        .count();
16485    let commands: Vec<_> =
16486        SegmentCommandIter::new(ordered_items, shapes, images, batch_limits).collect();
16487    let draw_chunks = commands
16488        .iter()
16489        .filter(|command| matches!(command, SegmentRenderCommand::DrawChunk(_)))
16490        .count();
16491    let shadow_commands = commands
16492        .iter()
16493        .filter(|command| matches!(command, SegmentRenderCommand::Shadow(_)))
16494        .count();
16495    let mut native_partitions = 0usize;
16496    let mut native_unfused_chunks = 0usize;
16497    for command in &commands {
16498        let SegmentRenderCommand::DrawChunk(chunk) = command else {
16499            continue;
16500        };
16501        match native_segment_fusion_partitions(ordered_items, shapes, brushes, chunk, batch_limits)
16502        {
16503            Ok(Some(partitions)) => native_partitions += partitions.len(),
16504            Ok(None) | Err(_) => native_unfused_chunks += 1,
16505        }
16506    }
16507
16508    eprintln!(
16509        "[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={}",
16510        z_range.start,
16511        z_range.end,
16512        ordered_items.len(),
16513        counts.raw_shadow_items,
16514        counts.culled_shadow_items,
16515        counts.cached_shadow_composites,
16516        remaining_shadow_items,
16517        counts.composite_items,
16518        counts.shader_composite_items,
16519        draw_chunks,
16520        shadow_commands,
16521        native_partitions,
16522        native_unfused_chunks,
16523    );
16524}
16525
16526pub(crate) fn has_backdrop_layer_in_range(
16527    backdrop_layers: &[BackdropLayer],
16528    z_start: usize,
16529    z_end: usize,
16530) -> bool {
16531    backdrop_layers
16532        .iter()
16533        .any(|layer| layer.z_index >= z_start && layer.z_index < z_end)
16534}
16535
16536pub(crate) fn scissor_rect_for_rect(
16537    rect: Rect,
16538    root_scale: f32,
16539    width: u32,
16540    height: u32,
16541) -> Option<(u32, u32, u32, u32)> {
16542    let mut left = canonicalize_device_coordinate(rect.x * root_scale);
16543    let mut top = canonicalize_device_coordinate(rect.y * root_scale);
16544    let mut right = canonicalize_device_coordinate((rect.x + rect.width) * root_scale);
16545    let mut bottom = canonicalize_device_coordinate((rect.y + rect.height) * root_scale);
16546
16547    left = left.max(0.0).min(width as f32).floor();
16548    top = top.max(0.0).min(height as f32).floor();
16549    right = right.max(0.0).min(width as f32).ceil();
16550    bottom = bottom.max(0.0).min(height as f32).ceil();
16551
16552    if right <= left || bottom <= top {
16553        return None;
16554    }
16555
16556    Some((
16557        left as u32,
16558        top as u32,
16559        (right - left) as u32,
16560        (bottom - top) as u32,
16561    ))
16562}
16563
16564fn scissor_rect_for_layer(
16565    rect: Rect,
16566    clip: Option<Rect>,
16567    root_scale: f32,
16568    width: u32,
16569    height: u32,
16570) -> Option<(u32, u32, u32, u32)> {
16571    let clipped_rect = match clip {
16572        Some(clip_rect) => rect.intersect(clip_rect)?,
16573        None => rect,
16574    };
16575
16576    scissor_rect_for_rect(clipped_rect, root_scale, width, height)
16577}
16578
16579fn tint_for_image(
16580    color_filter: Option<ColorFilter>,
16581    alpha: f32,
16582) -> ([f32; 4], Option<ColorFilter>) {
16583    let alpha = alpha.clamp(0.0, 1.0);
16584    match color_filter {
16585        Some(filter) if filter.supports_gpu_vertex_modulation() => {
16586            let Some(tint) = filter.gpu_vertex_tint() else {
16587                return ([1.0, 1.0, 1.0, alpha], Some(filter));
16588            };
16589            (
16590                [
16591                    tint[0].clamp(0.0, 1.0),
16592                    tint[1].clamp(0.0, 1.0),
16593                    tint[2].clamp(0.0, 1.0),
16594                    (tint[3] * alpha).clamp(0.0, 1.0),
16595                ],
16596                None,
16597            )
16598        }
16599        Some(filter) => ([1.0, 1.0, 1.0, alpha], Some(filter)),
16600        None => ([1.0, 1.0, 1.0, alpha], None),
16601    }
16602}
16603
16604fn image_uv_rect(image: &ImageBitmap, src_rect: Option<Rect>) -> Option<ImageUvRect> {
16605    let Some(src) = src_rect else {
16606        return Some(ImageUvRect {
16607            min: [0.0, 0.0],
16608            max: [1.0, 1.0],
16609            sample_bounds: [0.0, 0.0, 1.0, 1.0],
16610        });
16611    };
16612
16613    let (u_min, u_max, u_bound_min, u_bound_max) =
16614        source_axis_uv(src.x, src.width, image.width() as f32)?;
16615    let (v_min, v_max, v_bound_min, v_bound_max) =
16616        source_axis_uv(src.y, src.height, image.height() as f32)?;
16617
16618    Some(ImageUvRect {
16619        min: [u_min, v_min],
16620        max: [u_max, v_max],
16621        sample_bounds: [u_bound_min, v_bound_min, u_bound_max, v_bound_max],
16622    })
16623}
16624
16625/// Normalises an atlas entry against `atlas_size`, the side length of the
16626/// texture the entry was placed in. The atlas grows on overflow, so the size
16627/// has to be read from the live atlas rather than a constant — a UV computed
16628/// against the wrong size samples the wrong glyph.
16629fn glyph_atlas_uv_rect(entry: GlyphAtlasEntry, atlas_size: u32) -> ImageUvRect {
16630    let atlas_width = atlas_size as f32;
16631    let atlas_height = atlas_size as f32;
16632    let min = [entry.x as f32 / atlas_width, entry.y as f32 / atlas_height];
16633    let max = [
16634        (entry.x + entry.width) as f32 / atlas_width,
16635        (entry.y + entry.height) as f32 / atlas_height,
16636    ];
16637    let center_min = [
16638        (entry.x as f32 + 0.5) / atlas_width,
16639        (entry.y as f32 + 0.5) / atlas_height,
16640    ];
16641    let center_max = [
16642        (entry.x as f32 + entry.width as f32 - 0.5).max(entry.x as f32 + 0.5) / atlas_width,
16643        (entry.y as f32 + entry.height as f32 - 0.5).max(entry.y as f32 + 0.5) / atlas_height,
16644    ];
16645    ImageUvRect {
16646        min,
16647        max,
16648        sample_bounds: [center_min[0], center_min[1], center_max[0], center_max[1]],
16649    }
16650}
16651
16652fn snap_nearest_image_to_device_pixels(image: &mut ImageDraw, root_scale: f32) {
16653    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
16654        return;
16655    }
16656
16657    let Some(rect) = axis_aligned_quad_rect(image.quad) else {
16658        return;
16659    };
16660
16661    let left_px = (rect.x * root_scale).round();
16662    let top_px = (rect.y * root_scale).round();
16663    let width_px = (rect.width * root_scale).round().max(1.0);
16664    let height_px = (rect.height * root_scale).round().max(1.0);
16665    let snapped = Rect {
16666        x: left_px / root_scale,
16667        y: top_px / root_scale,
16668        width: width_px / root_scale,
16669        height: height_px / root_scale,
16670    };
16671
16672    image.rect = snapped;
16673    image.local_rect = Rect {
16674        x: image.local_rect.x + snapped.x - rect.x,
16675        y: image.local_rect.y + snapped.y - rect.y,
16676        width: snapped.width,
16677        height: snapped.height,
16678    };
16679    image.quad = crate::rect_to_quad(snapped);
16680}
16681
16682fn nearest_image_device_quad(image: &ImageDraw, root_scale: f32) -> Option<[[f32; 2]; 4]> {
16683    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
16684        return None;
16685    }
16686
16687    let rect = axis_aligned_quad_rect(image.quad)?;
16688    let left_px = (rect.x * root_scale).round();
16689    let top_px = (rect.y * root_scale).round();
16690    let width_px = (rect.width * root_scale).round().max(1.0);
16691    let height_px = (rect.height * root_scale).round().max(1.0);
16692    let right_px = left_px + width_px;
16693    let bottom_px = top_px + height_px;
16694    Some([
16695        [left_px, top_px],
16696        [right_px, top_px],
16697        [left_px, bottom_px],
16698        [right_px, bottom_px],
16699    ])
16700}
16701
16702fn source_axis_uv(start: f32, extent: f32, image_extent: f32) -> Option<(f32, f32, f32, f32)> {
16703    if !start.is_finite()
16704        || !extent.is_finite()
16705        || !image_extent.is_finite()
16706        || extent == 0.0
16707        || image_extent <= 0.0
16708    {
16709        return None;
16710    }
16711
16712    let end = start + extent;
16713    let edge_min = start.min(end).clamp(0.0, image_extent);
16714    let edge_max = start.max(end).clamp(0.0, image_extent);
16715    if edge_max <= edge_min {
16716        return None;
16717    }
16718
16719    let center_min = edge_min + 0.5;
16720    let center_max = edge_max - 0.5;
16721    let (bound_min, bound_max) = if center_min <= center_max {
16722        (center_min, center_max)
16723    } else {
16724        let center = (edge_min + edge_max) * 0.5;
16725        (center, center)
16726    };
16727
16728    Some((
16729        edge_min / image_extent,
16730        edge_max / image_extent,
16731        bound_min / image_extent,
16732        bound_max / image_extent,
16733    ))
16734}
16735
16736fn apply_filter_to_bitmap(image: &ImageBitmap, filter: ColorFilter) -> Result<ImageBitmap, String> {
16737    let mut filtered = Vec::with_capacity(image.pixels().len());
16738    for pixel in image.pixels().as_chunks::<4>().0 {
16739        let rgba = [
16740            pixel[0] as f32 / 255.0,
16741            pixel[1] as f32 / 255.0,
16742            pixel[2] as f32 / 255.0,
16743            pixel[3] as f32 / 255.0,
16744        ];
16745        let out = filter.apply_rgba(rgba);
16746        filtered.push((out[0].clamp(0.0, 1.0) * 255.0).round() as u8);
16747        filtered.push((out[1].clamp(0.0, 1.0) * 255.0).round() as u8);
16748        filtered.push((out[2].clamp(0.0, 1.0) * 255.0).round() as u8);
16749        filtered.push((out[3].clamp(0.0, 1.0) * 255.0).round() as u8);
16750    }
16751    ImageBitmap::from_rgba8(image.width(), image.height(), filtered)
16752        .map_err(|error| format!("failed to build filtered bitmap: {error}"))
16753}
16754
16755fn scissor_rect_for_image(
16756    image: &ImageDraw,
16757    root_scale: f32,
16758    width: u32,
16759    height: u32,
16760) -> Option<(u32, u32, u32, u32)> {
16761    scissor_rect_for_layer(image.rect, image.clip, root_scale, width, height)
16762}
16763
16764fn inner_shadow_composite_mask(
16765    shadow: &ShadowDraw,
16766    root_scale: f32,
16767) -> Option<RoundedCompositeMask> {
16768    if !shadow
16769        .shapes
16770        .iter()
16771        .any(|(_, mode)| *mode == BlendMode::DstOut)
16772    {
16773        return None;
16774    }
16775    let (fill, _) = shadow.shapes.first()?;
16776    let rect = fill.local_rect;
16777    if rect.width <= 0.0 || rect.height <= 0.0 {
16778        return None;
16779    }
16780
16781    let radii = fill.shape.map_or([0.0; 4], |rounded| {
16782        let resolved = rounded.resolve(rect.width, rect.height);
16783        [
16784            resolved.top_left * root_scale,
16785            resolved.top_right * root_scale,
16786            resolved.bottom_left * root_scale,
16787            resolved.bottom_right * root_scale,
16788        ]
16789    });
16790
16791    Some(RoundedCompositeMask {
16792        rect: [
16793            rect.x * root_scale,
16794            rect.y * root_scale,
16795            rect.width * root_scale,
16796            rect.height * root_scale,
16797        ],
16798        radii,
16799    })
16800}
16801
16802#[cfg(test)]
16803mod shape_batch_limits_tests {
16804    use super::*;
16805
16806    /// A device that reports plenty of storage buffers, as ARM's GLES driver
16807    /// does off the fragment stage.
16808    fn generous_limits() -> wgpu::Limits {
16809        wgpu::Limits {
16810            max_storage_buffers_per_shader_stage: 8,
16811            max_storage_buffer_binding_size: 128 << 20,
16812            max_uniform_buffer_binding_size: 16 << 10,
16813            ..wgpu::Limits::default()
16814        }
16815    }
16816
16817    #[test]
16818    fn a_device_without_vertex_storage_takes_the_uniform_path() {
16819        // The shape array is bound VERTEX_FRAGMENT because `vs_main` reads
16820        // quad corners out of it, so a device that cannot read storage from
16821        // the vertex stage cannot host the storage layout AT ALL -- creating
16822        // it is a validation error and wgpu makes that fatal. The limit alone
16823        // says nothing about it: Mali reports 8 here and zero vertex storage.
16824        let limits = ShapeBatchLimits::select(&generous_limits(), wgpu::DownlevelFlags::empty());
16825        assert!(
16826            !limits.storage,
16827            "no VERTEX_STORAGE must mean uniform mode, whatever the limit says"
16828        );
16829    }
16830
16831    #[test]
16832    fn a_device_with_vertex_storage_still_takes_the_storage_path() {
16833        let limits = ShapeBatchLimits::select(&generous_limits(), wgpu::DownlevelFlags::all());
16834        assert!(
16835            limits.storage,
16836            "the flag must not cost storage mode on a device that has it"
16837        );
16838    }
16839
16840    #[test]
16841    fn the_limit_still_gates_storage_when_the_flag_is_present() {
16842        let mut limits = generous_limits();
16843        limits.max_storage_buffers_per_shader_stage = 1;
16844        let limits = ShapeBatchLimits::select(&limits, wgpu::DownlevelFlags::all());
16845        assert!(!limits.storage, "two bindings are needed, not one");
16846    }
16847}
16848
16849#[cfg(test)]
16850mod tests {
16851    use super::*;
16852    use crate::normalized_scene::visible_draw_rect;
16853    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
16854    use cranpose_render_common::graph::{DrawPrimitiveNode, IsolationReasons, TextPrimitiveNode};
16855    use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
16856    use cranpose_render_common::scene_builder::build_graph_from_applier;
16857    use cranpose_ui::text::{
16858        AnnotatedString, BaselineShift, RangeStyle, Shadow, SpanStyle, TextDecoration,
16859        TextDrawStyle, TextGeometricTransform, TextMotion, TextUnit,
16860    };
16861    use cranpose_ui::{
16862        LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Size, Text, TextLayoutOptions,
16863        TextStyle,
16864    };
16865    use cranpose_ui_graphics::{
16866        Brush, Color, CornerRadii, DrawPrimitive, Rect, RenderEffect, RoundedCornerShape,
16867        RuntimeShader,
16868    };
16869
16870    fn chunk(batches: &[SegmentBatchPlan]) -> SegmentDrawChunkPlan {
16871        let mut chunk = SegmentDrawChunkPlan::default();
16872        for batch in batches {
16873            chunk.push(*batch);
16874        }
16875        chunk
16876    }
16877
16878    fn with_test_app_context<R>(block: impl FnOnce() -> R) -> R {
16879        let app_context = cranpose_ui::AppContext::new();
16880        app_context.enter(block)
16881    }
16882
16883    fn assert_snap_anchor_close(actual: Option<SnapAnchor>, expected_origin: Point, message: &str) {
16884        let Some(actual) = actual else {
16885            panic!("{message}: missing snap anchor");
16886        };
16887        let expected = SnapAnchor::rigid(expected_origin);
16888        assert_eq!(
16889            actual.device_pixel_step, expected.device_pixel_step,
16890            "{message}: device pixel step changed"
16891        );
16892        assert!(
16893            (actual.origin.x - expected.origin.x).abs() <= 1e-4
16894                && (actual.origin.y - expected.origin.y).abs() <= 1e-4,
16895            "{message}: expected origin {:?}, got {:?}",
16896            expected.origin,
16897            actual.origin
16898        );
16899    }
16900
16901    fn effect_layer(z_start: usize, z_end: usize) -> EffectLayer {
16902        EffectLayer {
16903            rect: Rect {
16904                x: 0.0,
16905                y: 0.0,
16906                width: 10.0,
16907                height: 10.0,
16908            },
16909            clip: None,
16910            snap_anchor: None,
16911            effect: Some(RenderEffect::blur(4.0)),
16912            blend_mode: BlendMode::SrcOver,
16913            composite_alpha: 1.0,
16914            z_start,
16915            z_end,
16916            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
16917        }
16918    }
16919
16920    #[test]
16921    fn direct_shader_composite_accepts_box4_when_viewport_preserves_source_pixels() {
16922        assert_eq!(
16923            direct_shader_composite_viewport(
16924                1.0,
16925                BlendMode::SrcOver,
16926                Some((12.0, 18.0, 64.0, 32.0)),
16927                CompositeSampleMode::Box4,
16928                (64, 32),
16929            ),
16930            Some((12.0, 18.0, 64.0, 32.0))
16931        );
16932    }
16933
16934    #[test]
16935    fn direct_shader_composite_rejects_box4_when_viewport_resamples_source() {
16936        assert_eq!(
16937            direct_shader_composite_viewport(
16938                1.0,
16939                BlendMode::SrcOver,
16940                Some((12.0, 18.0, 64.5, 32.0)),
16941                CompositeSampleMode::Box4,
16942                (64, 32),
16943            ),
16944            None
16945        );
16946        assert_eq!(
16947            direct_shader_composite_viewport(
16948                1.0,
16949                BlendMode::SrcOver,
16950                Some((12.25, 18.0, 64.0, 32.0)),
16951                CompositeSampleMode::Box4,
16952                (64, 32),
16953            ),
16954            None
16955        );
16956    }
16957
16958    fn test_text_draw(rect: Rect, text_motion: TextMotion) -> TextDraw {
16959        let mut text_style = TextStyle::default();
16960        text_style.paragraph_style.text_motion = Some(text_motion);
16961        TextDraw {
16962            node_id: 42,
16963            rect,
16964            snap_anchor: None,
16965            translated_content_context: false,
16966            text: Arc::new(AnnotatedString::new("stable markdown row".to_string()).render_string()),
16967            color: Color::WHITE,
16968            text_style,
16969            font_size: 14.0,
16970            scale: 1.0,
16971            layout_options: TextLayoutOptions::default(),
16972            z_index: 0,
16973            clip: None,
16974        }
16975    }
16976
16977    #[test]
16978    fn static_text_image_cache_key_ignores_absolute_scroll_position() {
16979        let base = test_text_draw(
16980            Rect {
16981                x: 12.25,
16982                y: 40.75,
16983                width: 220.0,
16984                height: 24.0,
16985            },
16986            TextMotion::Static,
16987        );
16988        let scrolled = test_text_draw(
16989            Rect {
16990                x: 12.75,
16991                y: -318.5,
16992                width: 220.0,
16993                height: 24.0,
16994            },
16995            TextMotion::Static,
16996        );
16997
16998        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, true);
16999        let scrolled_key = GpuRenderer::text_image_cache_key(&scrolled, scrolled.rect, 1.0, true);
17000
17001        assert_eq!(
17002            base_key, scrolled_key,
17003            "scrolling static text must reuse the same raster cache entry"
17004        );
17005    }
17006
17007    #[test]
17008    fn static_text_glyph_run_cache_key_ignores_absolute_scroll_position() {
17009        let base = test_text_draw(
17010            Rect {
17011                x: 12.25,
17012                y: 40.75,
17013                width: 220.0,
17014                height: 24.0,
17015            },
17016            TextMotion::Static,
17017        );
17018        let scrolled = test_text_draw(
17019            Rect {
17020                x: 12.75,
17021                y: -318.5,
17022                width: 220.0,
17023                height: 24.0,
17024            },
17025            TextMotion::Static,
17026        );
17027
17028        let base_key = GpuRenderer::text_glyph_run_cache_key(&base, base.rect, 1.0, true);
17029        let scrolled_key =
17030            GpuRenderer::text_glyph_run_cache_key(&scrolled, scrolled.rect, 1.0, true);
17031
17032        assert_eq!(
17033            base_key, scrolled_key,
17034            "scrolling static text must reuse the same retained glyph run"
17035        );
17036    }
17037
17038    #[test]
17039    fn static_multiline_text_glyph_source_keeps_full_text_when_image_source_slices() {
17040        let rect = Rect {
17041            x: 8.0,
17042            y: 100.0,
17043            width: 240.0,
17044            height: 1_000.0,
17045        };
17046        let mut draw = test_text_draw(rect, TextMotion::Static);
17047        let lines = (0..100)
17048            .map(|line| format!("line-{line:03}"))
17049            .collect::<Vec<_>>()
17050            .join("\n");
17051        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
17052
17053        let raster_rect = Rect {
17054            x: 16.0,
17055            y: 200.0,
17056            width: 480.0,
17057            height: 2_000.0,
17058        };
17059        let clipped = clipped_text_raster_source(
17060            &draw,
17061            rect,
17062            raster_rect,
17063            Some(Rect {
17064                x: 0.0,
17065                y: 610.0,
17066                width: 800.0,
17067                height: 40.0,
17068            }),
17069            2.0,
17070            true,
17071        );
17072        let glyph = text_glyph_raster_source(&draw, raster_rect);
17073
17074        assert!(
17075            matches!(clipped.draw, Cow::Owned(_)),
17076            "the image source should still slice large clipped multiline text"
17077        );
17078        assert!(
17079            matches!(glyph.draw, Cow::Borrowed(_)),
17080            "the glyph source must keep a stable full-text run key while scrolling"
17081        );
17082
17083        let clipped_key = GpuRenderer::text_glyph_run_cache_key(
17084            clipped.draw.as_ref(),
17085            clipped.raster_rect,
17086            2.0,
17087            true,
17088        );
17089        let glyph_key = GpuRenderer::text_glyph_run_cache_key(
17090            glyph.draw.as_ref(),
17091            glyph.raster_rect,
17092            2.0,
17093            true,
17094        );
17095
17096        assert_ne!(
17097            clipped_key, glyph_key,
17098            "image slicing must not force glyph rendering onto per-scroll line-window cache keys"
17099        );
17100    }
17101
17102    #[cfg(not(target_arch = "wasm32"))]
17103    #[test]
17104    fn retained_glyph_viewport_offsets_relative_vertices_by_source_origin() {
17105        let viewport = ViewportUniformParams {
17106            width: 800,
17107            height: 600,
17108            offset: [10.0, 20.0],
17109        };
17110        let source = Rect {
17111            x: 40.0,
17112            y: 90.0,
17113            width: 120.0,
17114            height: 48.0,
17115        };
17116
17117        let retained = GpuRenderer::retained_glyph_viewport(viewport, source);
17118
17119        assert_eq!(retained.width, viewport.width);
17120        assert_eq!(retained.height, viewport.height);
17121        assert_eq!(retained.offset, [-30.0, -70.0]);
17122    }
17123
17124    #[cfg(not(target_arch = "wasm32"))]
17125    #[test]
17126    fn tiny_text_glyph_runs_stay_in_shared_uploads() {
17127        assert!(
17128            !should_use_retained_text_glyph_run(8, None),
17129            "tiny labels must stay in the shared fused batch"
17130        );
17131    }
17132
17133    #[cfg(not(target_arch = "wasm32"))]
17134    #[test]
17135    fn line_sized_text_glyph_runs_stay_in_shared_uploads() {
17136        assert!(
17137            !should_use_retained_text_glyph_run(64, None),
17138            "Markdown scroll frames contain many line-sized text runs; retaining each one creates per-run buffer binds instead of one shared glyph batch"
17139        );
17140    }
17141
17142    #[cfg(not(target_arch = "wasm32"))]
17143    #[test]
17144    fn large_clipped_text_glyph_runs_stay_in_shared_uploads() {
17145        assert!(
17146            !should_use_retained_text_glyph_run(
17147                MIN_RETAINED_TEXT_GLYPH_QUADS.saturating_mul(2),
17148                Some(Rect {
17149                    x: 0.0,
17150                    y: 0.0,
17151                    width: 200.0,
17152                    height: 100.0,
17153                }),
17154            ),
17155            "clipped lazy-list text must not draw a full retained run outside the viewport"
17156        );
17157    }
17158
17159    #[test]
17160    fn normal_text_glyph_draw_skips_offscreen_prewarm_candidates() {
17161        assert_eq!(
17162            text_glyph_draw_action(false, true, false),
17163            TextGlyphDrawAction::Skip,
17164            "normal draw traversal must not prepare offscreen text"
17165        );
17166    }
17167
17168    #[test]
17169    fn bounded_text_glyph_prewarm_admits_offscreen_candidates() {
17170        assert_eq!(
17171            text_glyph_draw_action(false, true, true),
17172            TextGlyphDrawAction::PrewarmOffscreen,
17173            "only the bounded prewarm path may prepare offscreen text"
17174        );
17175    }
17176
17177    #[test]
17178    fn visible_text_glyph_draws_are_always_admitted() {
17179        assert_eq!(
17180            text_glyph_draw_action(true, false, false),
17181            TextGlyphDrawAction::DrawVisible
17182        );
17183        assert_eq!(
17184            text_glyph_draw_action(true, true, true),
17185            TextGlyphDrawAction::DrawVisible
17186        );
17187    }
17188
17189    #[cfg(not(target_arch = "wasm32"))]
17190    #[test]
17191    fn offscreen_text_prewarm_skips_large_uncached_text_runs() {
17192        assert!(
17193            !offscreen_text_glyph_prewarm_work_is_bounded(
17194                None,
17195                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS + 1,
17196            ),
17197            "offscreen prewarm must not collect large uncached text runs in an input frame"
17198        );
17199    }
17200
17201    #[cfg(not(target_arch = "wasm32"))]
17202    #[test]
17203    fn offscreen_text_prewarm_admits_small_uncached_text_runs() {
17204        assert!(
17205            offscreen_text_glyph_prewarm_work_is_bounded(
17206                None,
17207                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
17208            ),
17209            "small labels can be warmed without risking a frame-budget spike"
17210        );
17211    }
17212
17213    #[cfg(not(target_arch = "wasm32"))]
17214    #[test]
17215    fn offscreen_text_prewarm_skips_large_cached_runs_without_quads() {
17216        assert!(
17217            !offscreen_text_glyph_prewarm_work_is_bounded(
17218                Some(MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS + 1),
17219                0,
17220            ),
17221            "cached glyph placements can still be too large to prepare during input frames"
17222        );
17223    }
17224
17225    #[cfg(not(target_arch = "wasm32"))]
17226    #[test]
17227    fn offscreen_text_prewarm_stops_after_candidate_budget() {
17228        assert!(
17229            offscreen_text_glyph_prewarm_budget_exhausted(
17230                Instant::now(),
17231                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES,
17232            ),
17233            "prewarm must be bounded by candidate count even when each candidate is cheap"
17234        );
17235    }
17236
17237    #[test]
17238    fn clipped_cached_glyph_quads_are_filtered_to_viewport() {
17239        fn quad(y: i32) -> CachedTextGlyphQuad {
17240            CachedTextGlyphQuad {
17241                x: 8,
17242                y,
17243                width: 20,
17244                height: 10,
17245                color: (1.0, 1.0, 1.0, 1.0),
17246                uv: ImageUvRect {
17247                    min: [0.0, 0.0],
17248                    max: [1.0, 1.0],
17249                    sample_bounds: [0.0, 0.0, 1.0, 1.0],
17250                },
17251            }
17252        }
17253
17254        let source = Rect {
17255            x: 0.0,
17256            y: 0.0,
17257            width: 320.0,
17258            height: 400.0,
17259        };
17260        let clip = Some(Rect {
17261            x: 0.0,
17262            y: 0.0,
17263            width: 320.0,
17264            height: 80.0,
17265        });
17266        let viewport = ViewportUniformParams {
17267            width: 320,
17268            height: 80,
17269            offset: [0.0, 0.0],
17270        };
17271
17272        assert!(cached_text_glyph_quad_is_visible_in_viewport(
17273            source,
17274            &quad(40),
17275            clip,
17276            viewport,
17277            1.0,
17278        ));
17279        assert!(
17280            !cached_text_glyph_quad_is_visible_in_viewport(source, &quad(140), clip, viewport, 1.0,),
17281            "glyphs outside the effective clip should not enter the frame command stream"
17282        );
17283    }
17284
17285    #[test]
17286    fn small_scene_range_cache_miss_observes_first_render() {
17287        let key = LayerRasterCacheKey::scene_range(
17288            0xCACE,
17289            Rect {
17290                x: 0.0,
17291                y: 0.0,
17292                width: 120.0,
17293                height: 80.0,
17294            },
17295            (120, 80),
17296            ScaleBucket::from_scale(1.0),
17297        );
17298
17299        assert!(
17300            !first_cache_miss_admission(&key),
17301            "a small scene-range miss should render directly first instead of materializing a tiny one-frame retained target"
17302        );
17303        assert!(
17304            repeated_cache_miss_admission(&key),
17305            "a repeated small scene-range miss is stable enough to materialize into the retained cache"
17306        );
17307    }
17308
17309    #[test]
17310    fn large_scene_range_cache_miss_requires_repeated_stable_key() {
17311        let key = LayerRasterCacheKey::scene_range(
17312            0xCACE,
17313            Rect {
17314                x: 0.0,
17315                y: 0.0,
17316                width: 1200.0,
17317                height: 900.0,
17318            },
17319            (1200, 900),
17320            ScaleBucket::from_scale(1.0),
17321        );
17322
17323        assert!(
17324            !first_cache_miss_admission(&key),
17325            "a large first scene-range miss should render directly instead of materializing a multi-MB one-frame cache entry"
17326        );
17327        assert!(
17328            repeated_cache_miss_admission(&key),
17329            "a repeated scene-range miss is stable enough to materialize into the retained cache"
17330        );
17331    }
17332
17333    #[test]
17334    fn renderer_warmup_frame_is_requested_for_cache_miss_stats_only() {
17335        let stats = gpu_stats::FrameStats::default();
17336        let mut snapshot = stats.snapshot();
17337        assert!(
17338            !frame_stats_need_warmup_frame(&snapshot),
17339            "a clean frame must not keep a static scene redrawing"
17340        );
17341
17342        snapshot.layer_cache_misses = 1;
17343        assert!(frame_stats_need_warmup_frame(&snapshot));
17344        snapshot.layer_cache_misses = 0;
17345
17346        snapshot.shadow_shape_cache_misses = 1;
17347        assert!(frame_stats_need_warmup_frame(&snapshot));
17348        snapshot.shadow_shape_cache_misses = 0;
17349
17350        snapshot.text_image_cache_misses = 1;
17351        assert!(frame_stats_need_warmup_frame(&snapshot));
17352        snapshot.text_image_cache_misses = 0;
17353
17354        snapshot.text_glyph_atlas_misses = 1;
17355        assert!(frame_stats_need_warmup_frame(&snapshot));
17356    }
17357
17358    #[test]
17359    fn renderer_warmup_budget_is_consumed_by_a_repeated_cache_miss() {
17360        let stats = gpu_stats::FrameStats::default();
17361        let mut snapshot = stats.snapshot();
17362        snapshot.layer_cache_misses = 1;
17363        let mut pending_frames = 0;
17364
17365        update_frame_warmup_budget(&mut pending_frames, &snapshot);
17366        assert_eq!(pending_frames, CACHE_MISS_WARMUP_FRAMES);
17367
17368        update_frame_warmup_budget(&mut pending_frames, &snapshot);
17369        assert_eq!(
17370            pending_frames, 0,
17371            "a cache miss during the warmup frame must not replenish its budget"
17372        );
17373    }
17374
17375    #[test]
17376    fn non_scene_layer_surface_cache_miss_admits_first_render() {
17377        let key = LayerRasterCacheKey::new(
17378            Some(77),
17379            0xC0FFEE,
17380            0,
17381            Rect {
17382                x: 0.0,
17383                y: 0.0,
17384                width: 120.0,
17385                height: 80.0,
17386            },
17387            (120, 80),
17388            ScaleBucket::from_scale(1.0),
17389        );
17390
17391        assert!(
17392            first_cache_miss_admission(&key),
17393            "ordinary retained layer surfaces should still cache on first miss"
17394        );
17395    }
17396
17397    #[test]
17398    fn text_image_cache_key_is_content_addressed_not_node_addressed() {
17399        let first = test_text_draw(
17400            Rect {
17401                x: 12.25,
17402                y: 40.75,
17403                width: 220.0,
17404                height: 24.0,
17405            },
17406            TextMotion::Static,
17407        );
17408        let mut second = first.clone();
17409        second.node_id = first.node_id + 1;
17410
17411        let first_key = GpuRenderer::text_image_cache_key(&first, first.rect, 1.0, true);
17412        let second_key = GpuRenderer::text_image_cache_key(&second, second.rect, 1.0, true);
17413
17414        assert_eq!(
17415            first_key, second_key,
17416            "text raster cache keys must be based on rendered pixels, not node identity"
17417        );
17418    }
17419
17420    #[test]
17421    fn animated_text_image_cache_key_keeps_fractional_phase_only() {
17422        let base = test_text_draw(
17423            Rect {
17424                x: 12.25,
17425                y: 40.75,
17426                width: 220.0,
17427                height: 24.0,
17428            },
17429            TextMotion::Animated,
17430        );
17431        let integer_translated = test_text_draw(
17432            Rect {
17433                x: 44.25,
17434                y: 88.75,
17435                width: 220.0,
17436                height: 24.0,
17437            },
17438            TextMotion::Animated,
17439        );
17440        let phase_shifted = test_text_draw(
17441            Rect {
17442                x: 44.5,
17443                y: 88.75,
17444                width: 220.0,
17445                height: 24.0,
17446            },
17447            TextMotion::Animated,
17448        );
17449
17450        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, false);
17451        let translated_key = GpuRenderer::text_image_cache_key(
17452            &integer_translated,
17453            integer_translated.rect,
17454            1.0,
17455            false,
17456        );
17457        let phase_shifted_key =
17458            GpuRenderer::text_image_cache_key(&phase_shifted, phase_shifted.rect, 1.0, false);
17459
17460        assert_eq!(
17461            base_key, translated_key,
17462            "integer translation should not invalidate animated text raster cache entries"
17463        );
17464        assert_ne!(
17465            base_key, phase_shifted_key,
17466            "fractional phase affects animated text rasterization and must stay in the key"
17467        );
17468    }
17469
17470    #[test]
17471    fn animated_translated_text_raster_geometry_applies_snap_anchor() {
17472        let mut base = test_text_draw(
17473            Rect {
17474                x: 14.25,
17475                y: 16.50,
17476                width: 220.0,
17477                height: 24.0,
17478            },
17479            TextMotion::Animated,
17480        );
17481        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.50)));
17482
17483        let mut scrolled = test_text_draw(
17484            Rect {
17485                x: 14.25,
17486                y: 15.80,
17487                width: 220.0,
17488                height: 24.0,
17489            },
17490            TextMotion::Animated,
17491        );
17492        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 15.80)));
17493
17494        let (base_logical, base_raster, _, _, base_static) =
17495            text_raster_geometry_for_draw(&base, 1.0).expect("base text geometry");
17496        let (scrolled_logical, scrolled_raster, _, _, scrolled_static) =
17497            text_raster_geometry_for_draw(&scrolled, 1.0).expect("scrolled text geometry");
17498
17499        assert!(!base_static);
17500        assert!(!scrolled_static);
17501        assert!((base_logical.x - 14.0).abs() < f32::EPSILON);
17502        assert!((base_logical.y - 17.0).abs() < f32::EPSILON);
17503        assert!((scrolled_logical.x - 14.0).abs() < f32::EPSILON);
17504        assert!((scrolled_logical.y - 16.0).abs() < f32::EPSILON);
17505        assert_eq!(base_raster.x.fract(), 0.0);
17506        assert_eq!(base_raster.y.fract(), 0.0);
17507        assert_eq!(scrolled_raster.x.fract(), 0.0);
17508        assert_eq!(scrolled_raster.y.fract(), 0.0);
17509
17510        let base_key = GpuRenderer::text_image_cache_key(&base, base_raster, 1.0, false);
17511        let scrolled_key =
17512            GpuRenderer::text_image_cache_key(&scrolled, scrolled_raster, 1.0, false);
17513        assert_eq!(
17514            base_key, scrolled_key,
17515            "translated animated text should keep a stable raster phase while scrolling"
17516        );
17517    }
17518
17519    #[test]
17520    fn translated_static_text_moves_one_device_pixel_at_half_pixel_phase() {
17521        let root_scale = 1.25;
17522        let mut base = test_text_draw(
17523            Rect {
17524                x: 14.0,
17525                y: 276.0,
17526                width: 220.0,
17527                height: 24.0,
17528            },
17529            TextMotion::Static,
17530        );
17531        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.600_006)));
17532
17533        let mut scrolled = test_text_draw(
17534            Rect {
17535                x: 14.0,
17536                y: 275.2,
17537                width: 220.0,
17538                height: 24.0,
17539            },
17540            TextMotion::Static,
17541        );
17542        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 126.799_99)));
17543
17544        let (_, base_raster, _, _, _) =
17545            text_raster_geometry_for_draw(&base, root_scale).expect("base text geometry");
17546        let (_, scrolled_raster, _, _, _) =
17547            text_raster_geometry_for_draw(&scrolled, root_scale).expect("scrolled text geometry");
17548
17549        assert_eq!(
17550            base_raster.y - scrolled_raster.y,
17551            1.0,
17552            "one physical pixel of rigid scrolling must move static text by one raster pixel"
17553        );
17554    }
17555
17556    #[test]
17557    fn translated_text_snap_does_not_move_its_fixed_ancestor_clip() {
17558        let root_scale = 1.25;
17559        let fixed_clip = Rect {
17560            x: 8.0,
17561            y: 20.0,
17562            width: 300.0,
17563            height: 680.0,
17564        };
17565        let mut draw = test_text_draw(
17566            Rect {
17567                x: 14.0,
17568                y: 276.0,
17569                width: 220.0,
17570                height: 24.0,
17571            },
17572            TextMotion::Static,
17573        );
17574        draw.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.4)));
17575        draw.clip = Some(fixed_clip);
17576
17577        let (_, _, clip, _, _) =
17578            text_raster_geometry_for_draw(&draw, root_scale).expect("clipped text geometry");
17579
17580        assert_eq!(
17581            clip,
17582            Some(fixed_clip),
17583            "content pixel snapping must not translate a fixed ancestor clip"
17584        );
17585    }
17586
17587    #[test]
17588    fn clipped_static_multiline_text_raster_source_limits_visible_line_window() {
17589        let rect = Rect {
17590            x: 8.0,
17591            y: 100.0,
17592            width: 240.0,
17593            height: 1_000.0,
17594        };
17595        let mut draw = test_text_draw(rect, TextMotion::Static);
17596        let lines = (0..100)
17597            .map(|line| format!("line-{line:03}"))
17598            .collect::<Vec<_>>()
17599            .join("\n");
17600        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
17601
17602        let raster_rect = Rect {
17603            x: 16.0,
17604            y: 200.0,
17605            width: 480.0,
17606            height: 2_000.0,
17607        };
17608        let source = clipped_text_raster_source(
17609            &draw,
17610            rect,
17611            raster_rect,
17612            Some(Rect {
17613                x: 0.0,
17614                y: 610.0,
17615                width: 800.0,
17616                height: 40.0,
17617            }),
17618            2.0,
17619            true,
17620        );
17621
17622        let Cow::Owned(sliced_draw) = source.draw else {
17623            panic!("clipped static multiline text should rasterize only the visible line window");
17624        };
17625        let sliced_text = sliced_draw.text.text.as_str();
17626        assert!(sliced_text.contains("line-050"));
17627        assert!(sliced_text.contains("line-055"));
17628        assert!(!sliced_text.contains("line-000"));
17629        assert!(!sliced_text.contains("line-099"));
17630        assert_eq!(source.raster_rect.x, raster_rect.x);
17631        assert!(source.raster_rect.y > raster_rect.y);
17632        assert!(source.raster_rect.height < raster_rect.height);
17633    }
17634
17635    #[test]
17636    fn clipped_static_multiline_text_raster_source_slices_short_multiline_text() {
17637        let rect = Rect {
17638            x: 8.0,
17639            y: 100.0,
17640            width: 240.0,
17641            height: 320.0,
17642        };
17643        let mut draw = test_text_draw(rect, TextMotion::Static);
17644        let lines = (0..24)
17645            .map(|line| format!("code-line-{line:02}"))
17646            .collect::<Vec<_>>()
17647            .join("\n");
17648        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
17649
17650        let raster_rect = Rect {
17651            x: 16.0,
17652            y: 200.0,
17653            width: 480.0,
17654            height: 640.0,
17655        };
17656        let source = clipped_text_raster_source(
17657            &draw,
17658            rect,
17659            raster_rect,
17660            Some(Rect {
17661                x: 0.0,
17662                y: 190.0,
17663                width: 800.0,
17664                height: 120.0,
17665            }),
17666            2.0,
17667            true,
17668        );
17669
17670        let Cow::Owned(sliced_draw) = source.draw else {
17671            panic!("clipped multiline text should rasterize only the visible line window");
17672        };
17673        assert!(sliced_draw.text.text.as_str().contains("code-line-06"));
17674        assert!(!sliced_draw.text.text.as_str().contains("code-line-00"));
17675        assert!(!sliced_draw.text.text.as_str().contains("code-line-23"));
17676        assert_eq!(source.raster_rect.x, raster_rect.x);
17677        assert!(source.raster_rect.y > raster_rect.y);
17678        assert!(source.raster_rect.height < raster_rect.height);
17679    }
17680
17681    #[test]
17682    fn text_line_index_cache_reuses_retained_index_for_same_text_instance() {
17683        let mut cache = TextLineIndexCache::new(4);
17684        let text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
17685
17686        let first = cache.line_starts(&text);
17687        let second = cache.line_starts(&text);
17688
17689        assert_eq!(first.as_ref(), &[0, 2, 4]);
17690        assert!(
17691            Rc::ptr_eq(&first, &second),
17692            "retained text should not rebuild its line index on every clipped frame"
17693        );
17694    }
17695
17696    #[test]
17697    fn text_line_index_cache_is_retained_text_instance_local() {
17698        let mut cache = TextLineIndexCache::new(4);
17699        let first_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
17700        let second_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
17701
17702        let first = cache.line_starts(&first_text);
17703        let second = cache.line_starts(&second_text);
17704
17705        assert_eq!(first.as_ref(), second.as_ref());
17706        assert!(
17707            !Rc::ptr_eq(&first, &second),
17708            "line index lookup should not hash large text contents to find unrelated retained nodes"
17709        );
17710    }
17711
17712    #[test]
17713    fn device_pixel_bounds_for_rect_snaps_origin_and_extents() {
17714        let bounds = device_pixel_bounds_for_rect(
17715            Rect {
17716                x: 10.25,
17717                y: 14.6,
17718                width: 20.1,
17719                height: 9.2,
17720            },
17721            200,
17722            120,
17723            2.0,
17724        )
17725        .expect("rect should intersect the viewport");
17726
17727        assert_eq!(
17728            bounds,
17729            DevicePixelBounds {
17730                x: 20.0,
17731                y: 29.0,
17732                width: 41,
17733                height: 19,
17734            }
17735        );
17736    }
17737
17738    #[test]
17739    fn visible_layer_rect_intersects_clip_and_viewport() {
17740        let visible = visible_layer_rect(
17741            Rect {
17742                x: -10.0,
17743                y: 5.0,
17744                width: 80.0,
17745                height: 40.0,
17746            },
17747            Some(Rect {
17748                x: 4.0,
17749                y: 8.0,
17750                width: 20.0,
17751                height: 50.0,
17752            }),
17753            2.0,
17754            60,
17755            40,
17756        )
17757        .expect("visible rect");
17758
17759        assert_eq!(
17760            visible,
17761            Rect {
17762                x: 4.0,
17763                y: 8.0,
17764                width: 20.0,
17765                height: 12.0,
17766            }
17767        );
17768    }
17769
17770    #[test]
17771    fn clamp_effect_surface_scale_caps_large_surfaces_but_keeps_base_scale() {
17772        let clamped = clamp_effect_surface_scale(
17773            Rect {
17774                x: 0.0,
17775                y: 0.0,
17776                width: 1200.0,
17777                height: 900.0,
17778            },
17779            1.0,
17780            8.0,
17781            16_384,
17782        );
17783
17784        assert!(
17785            clamped < 8.0,
17786            "large translated effect layers must be capped to avoid OOM, got {clamped}"
17787        );
17788        assert!(
17789            clamped >= 1.0,
17790            "effect surfaces must not fall below destination resolution, got {clamped}"
17791        );
17792    }
17793
17794    #[test]
17795    fn clamp_effect_surface_scale_keeps_decorated_text_capture_scale() {
17796        let clamped = clamp_effect_surface_scale(
17797            Rect {
17798                x: 0.0,
17799                y: 0.0,
17800                width: 446.0,
17801                height: 44.0,
17802            },
17803            1.0,
17804            9.0,
17805            16_384,
17806        );
17807
17808        assert_eq!(
17809            clamped, 9.0,
17810            "decorated text motion-stable captures must keep full scale"
17811        );
17812    }
17813
17814    fn backdrop_layer(z_index: usize) -> BackdropLayer {
17815        BackdropLayer {
17816            node_id: Some(700 + z_index),
17817            rect: Rect {
17818                x: 0.0,
17819                y: 0.0,
17820                width: 10.0,
17821                height: 10.0,
17822            },
17823            clip: None,
17824            snap_anchor: None,
17825            effect: RenderEffect::blur(2.0),
17826            z_index,
17827        }
17828    }
17829
17830    fn test_shape(z_index: usize, blend_mode: BlendMode) -> DrawShape {
17831        DrawShape {
17832            rect: Rect {
17833                x: 0.0,
17834                y: 0.0,
17835                width: 8.0,
17836                height: 8.0,
17837            },
17838            local_rect: Rect {
17839                x: 0.0,
17840                y: 0.0,
17841                width: 8.0,
17842                height: 8.0,
17843            },
17844            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
17845            snap_anchor: None,
17846            brush: SceneBrush::Solid(Color::BLACK),
17847            shape: None,
17848            stroke: None,
17849            arc: None,
17850            z_index,
17851            clip: None,
17852            blend_mode,
17853            motion_context_animated: false,
17854        }
17855    }
17856
17857    #[test]
17858    fn shape_shadow_content_hash_ignores_viewport_translation() {
17859        fn translate_shape(shape: &DrawShape, dx: f32, dy: f32) -> DrawShape {
17860            let mut translated = *shape;
17861            translated.rect.x += dx;
17862            translated.rect.y += dy;
17863            translated.local_rect.x += dx;
17864            translated.local_rect.y += dy;
17865            for point in &mut translated.quad {
17866                point[0] += dx;
17867                point[1] += dy;
17868            }
17869            translated.snap_anchor = translated.snap_anchor.map(|anchor| {
17870                SnapAnchor::rigid(Point::new(anchor.origin.x + dx, anchor.origin.y + dy))
17871            });
17872            translated.clip = translated.clip.map(|mut clip| {
17873                clip.x += dx;
17874                clip.y += dy;
17875                clip
17876            });
17877            translated
17878        }
17879
17880        let mut first = test_shape(1, BlendMode::SrcOver);
17881        first.rect = Rect {
17882            x: 10.0,
17883            y: 20.0,
17884            width: 80.0,
17885            height: 40.0,
17886        };
17887        first.local_rect = first.rect;
17888        first.quad = [[10.0, 20.0], [90.0, 20.0], [10.0, 60.0], [90.0, 60.0]];
17889        first.snap_anchor = Some(SnapAnchor::rigid(Point::new(7.0, 11.0)));
17890        first.shape = Some(RoundedCornerShape::uniform(8.0));
17891        first.clip = Some(Rect {
17892            x: 8.0,
17893            y: 18.0,
17894            width: 86.0,
17895            height: 44.0,
17896        });
17897        let mut cutout = test_shape(2, BlendMode::DstOut);
17898        cutout.rect = Rect {
17899            x: 18.0,
17900            y: 26.0,
17901            width: 62.0,
17902            height: 22.0,
17903        };
17904        cutout.local_rect = cutout.rect;
17905        cutout.quad = [[18.0, 26.0], [80.0, 26.0], [18.0, 48.0], [80.0, 48.0]];
17906        cutout.shape = Some(RoundedCornerShape::uniform(4.0));
17907
17908        let dx = 37.0;
17909        let dy = -11.5;
17910        let translated = translate_shape(&first, dx, dy);
17911        let translated_cutout = translate_shape(&cutout, dx, dy);
17912
17913        let root_scale = 1.25;
17914        let first_shapes = vec![(first, BlendMode::SrcOver), (cutout, BlendMode::DstOut)];
17915        let translated_shapes = vec![
17916            (translated, BlendMode::SrcOver),
17917            (translated_cutout, BlendMode::DstOut),
17918        ];
17919
17920        let first_hash = shape_shadow_content_hash(&first_shapes, &[], root_scale);
17921        let translated_hash = shape_shadow_content_hash(&translated_shapes, &[], root_scale);
17922
17923        assert_eq!(first_hash, translated_hash);
17924
17925        let mut changed_shapes = translated_shapes;
17926        changed_shapes[0].0.rect.width += 1.0;
17927        let changed_hash = shape_shadow_content_hash(&changed_shapes, &[], root_scale);
17928
17929        assert_ne!(first_hash, changed_hash);
17930    }
17931
17932    #[test]
17933    fn shape_shadow_content_hash_is_stable_under_fractional_scale_scroll() {
17934        // Regression: scrolling a shadowed panel on a fractional-scale display
17935        // (e.g. Xft.dpi 130 → scale ≈ 1.354) must not re-render the shadow blur
17936        // every frame. The production cache key derives its viewport offset from
17937        // FLOORED device-pixel bounds, so the residual subpixel phase used to leak
17938        // into the content hash and miss the cache on every scroll step.
17939        fn shadow_shapes_at(y: f32) -> Vec<(DrawShape, BlendMode)> {
17940            let mut shape = test_shape(1, BlendMode::SrcOver);
17941            shape.rect = Rect {
17942                x: 24.0,
17943                y,
17944                width: 180.0,
17945                height: 90.0,
17946            };
17947            shape.local_rect = shape.rect;
17948            shape.quad = crate::rect_to_quad(shape.rect);
17949            shape.shape = Some(RoundedCornerShape::uniform(14.0));
17950            vec![(shape, BlendMode::SrcOver)]
17951        }
17952
17953        let root_scale = 130.0f32 / 96.0;
17954        let blur_radius = 18.0f32;
17955        let pixel_radius = blur_radius * root_scale;
17956
17957        let key_at = |y: f32| {
17958            let shapes = shadow_shapes_at(y);
17959            let plan =
17960                shape_shadow_surface_plan(&shapes, None, blur_radius, 1600, 1600, root_scale, 8192)
17961                    .expect("surface plan");
17962            shape_shadow_surface_cache_key(
17963                &shapes,
17964                &[],
17965                plan.source_device_bounds,
17966                pixel_radius,
17967                root_scale,
17968            )
17969            .expect("cache key")
17970        };
17971
17972        // Wheel scroll translates the panel by whole logical pixels; the device
17973        // subpixel phase changes on every step at fractional scale. The whole
17974        // cache key (content hash AND surface pixel size) must stay stable, or
17975        // every scroll frame re-renders the shadow blur.
17976        let base = key_at(640.0);
17977        for step in 1..=12 {
17978            let scrolled = key_at(640.0 - step as f32 * 4.0);
17979            assert_eq!(
17980                base, scrolled,
17981                "scrolled shadow cache key must stay stable at fractional scale (step {step})"
17982            );
17983        }
17984    }
17985
17986    #[test]
17987    fn shape_shadow_cache_key_uses_unclipped_source_bounds_for_scrolled_clip() {
17988        fn translated_card_shadow(y: f32) -> Vec<(DrawShape, BlendMode)> {
17989            let mut shape = test_shape(1, BlendMode::SrcOver);
17990            shape.rect = Rect {
17991                x: 24.0,
17992                y,
17993                width: 280.0,
17994                height: 120.0,
17995            };
17996            shape.local_rect = shape.rect;
17997            shape.quad = [[24.0, y], [304.0, y], [24.0, y + 120.0], [304.0, y + 120.0]];
17998            shape.shape = Some(RoundedCornerShape::uniform(18.0));
17999            vec![(shape, BlendMode::SrcOver)]
18000        }
18001
18002        let root_scale = 1.0;
18003        let blur_radius = 18.0;
18004        let viewport_clip = Rect {
18005            x: 0.0,
18006            y: 96.0,
18007            width: 360.0,
18008            height: 720.0,
18009        };
18010        let key_for = |y: f32| {
18011            let shapes = translated_card_shadow(y);
18012            let plan = shape_shadow_surface_plan(
18013                &shapes,
18014                Some(viewport_clip),
18015                blur_radius,
18016                360,
18017                900,
18018                root_scale,
18019                4096,
18020            )
18021            .expect("surface plan");
18022            shape_shadow_surface_cache_key(
18023                &shapes,
18024                &[],
18025                plan.source_device_bounds,
18026                plan.pixel_radius,
18027                root_scale,
18028            )
18029            .expect("cache key")
18030        };
18031
18032        // The card scrolls under a fixed viewport clip; the visible portion
18033        // changes but the cache key must stay anchored to the unclipped source.
18034        assert_eq!(key_for(740.0), key_for(756.0));
18035    }
18036
18037    #[test]
18038    fn shape_visibility_uses_nonzero_viewport_offset_for_cropped_offscreen() {
18039        let mut shape = test_shape(1, BlendMode::SrcOver);
18040        shape.rect = Rect {
18041            x: 24.0,
18042            y: 740.0,
18043            width: 280.0,
18044            height: 120.0,
18045        };
18046        shape.local_rect = shape.rect;
18047        shape.quad = [[24.0, 740.0], [304.0, 740.0], [24.0, 860.0], [304.0, 860.0]];
18048        let viewport = ViewportUniformParams {
18049            width: 316,
18050            height: 228,
18051            offset: [6.0, 686.0],
18052        };
18053
18054        assert!(shape_draw_is_visible_in_viewport(&shape, viewport, 1.0));
18055    }
18056
18057    #[test]
18058    fn text_prewarm_uses_nonzero_viewport_offset_for_cropped_offscreen() {
18059        let viewport = ViewportUniformParams {
18060            width: 316,
18061            height: 228,
18062            offset: [6.0, 686.0],
18063        };
18064        let text_rect = Rect {
18065            x: 24.0,
18066            y: 740.0,
18067            width: 280.0,
18068            height: 40.0,
18069        };
18070
18071        assert!(text_draw_is_visible_in_viewport(
18072            text_rect, None, viewport, 1.0
18073        ));
18074        assert!(text_draw_should_prewarm_in_viewport(
18075            text_rect, None, viewport, 1.0
18076        ));
18077    }
18078
18079    fn test_shadow_draw(shapes: Vec<(DrawShape, BlendMode)>) -> ShadowDraw {
18080        ShadowDraw {
18081            shapes,
18082            brushes: vec![],
18083            texts: vec![],
18084            blur_radius: 8.0,
18085            clip: None,
18086            z_index: 0,
18087        }
18088    }
18089
18090    fn test_image(z_index: usize, blend_mode: BlendMode) -> ImageDraw {
18091        ImageDraw {
18092            rect: Rect {
18093                x: 0.0,
18094                y: 0.0,
18095                width: 8.0,
18096                height: 8.0,
18097            },
18098            local_rect: Rect {
18099                x: 0.0,
18100                y: 0.0,
18101                width: 8.0,
18102                height: 8.0,
18103            },
18104            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
18105            snap_anchor: None,
18106            image: ImageBitmap::from_rgba8(1, 1, vec![255, 255, 255, 255]).expect("image"),
18107            alpha: 1.0,
18108            color_filter: None,
18109            sampling: ImageSampling::Nearest,
18110            z_index,
18111            clip: None,
18112            blend_mode,
18113            src_rect: None,
18114            motion_context_animated: false,
18115        }
18116    }
18117
18118    #[test]
18119    fn image_sampler_descriptors_match_requested_sampling() {
18120        let nearest = image_sampler_descriptor(ImageSampling::Nearest);
18121        assert_eq!(nearest.mag_filter, wgpu::FilterMode::Nearest);
18122        assert_eq!(nearest.min_filter, wgpu::FilterMode::Nearest);
18123
18124        let linear = image_sampler_descriptor(ImageSampling::Linear);
18125        assert_eq!(linear.mag_filter, wgpu::FilterMode::Linear);
18126        assert_eq!(linear.min_filter, wgpu::FilterMode::Linear);
18127    }
18128
18129    #[test]
18130    fn image_uv_rect_clamps_source_rect_to_texel_centers() {
18131        let image = ImageBitmap::from_rgba8(24, 16, vec![0; 24 * 16 * 4]).expect("image");
18132        let uv = image_uv_rect(
18133            &image,
18134            Some(Rect {
18135                x: 0.0,
18136                y: 0.0,
18137                width: 16.0,
18138                height: 16.0,
18139            }),
18140        )
18141        .expect("uv rect");
18142
18143        assert_eq!(uv.min, [0.0, 0.0]);
18144        assert_eq!(uv.max, [16.0 / 24.0, 1.0]);
18145        assert_eq!(
18146            uv.sample_bounds,
18147            [0.5 / 24.0, 0.5 / 16.0, 15.5 / 24.0, 15.5 / 16.0]
18148        );
18149    }
18150
18151    #[test]
18152    fn image_uv_rect_keeps_full_image_unclamped() {
18153        let image = ImageBitmap::from_rgba8(2, 2, vec![0; 16]).expect("image");
18154        let uv = image_uv_rect(&image, None).expect("uv rect");
18155
18156        assert_eq!(uv.min, [0.0, 0.0]);
18157        assert_eq!(uv.max, [1.0, 1.0]);
18158        assert_eq!(uv.sample_bounds, [0.0, 0.0, 1.0, 1.0]);
18159    }
18160
18161    fn test_text(z_index: usize) -> TextDraw {
18162        TextDraw {
18163            node_id: 0,
18164            rect: Rect {
18165                x: 0.0,
18166                y: 0.0,
18167                width: 8.0,
18168                height: 8.0,
18169            },
18170            snap_anchor: None,
18171            translated_content_context: false,
18172            text: Arc::new(cranpose_ui::text::AnnotatedString::from("t").render_string()),
18173            color: Color::WHITE,
18174            text_style: cranpose_ui::TextStyle::default(),
18175            font_size: 12.0,
18176            scale: 1.0,
18177            layout_options: cranpose_ui::TextLayoutOptions::default(),
18178            z_index,
18179            clip: None,
18180        }
18181    }
18182
18183    #[test]
18184    fn text_draw_visibility_rejects_text_outside_clip_before_rasterization() {
18185        let viewport = ViewportUniformParams {
18186            width: 320,
18187            height: 240,
18188            offset: [0.0, 0.0],
18189        };
18190        let text_rect = Rect {
18191            x: 0.0,
18192            y: 260.0,
18193            width: 200.0,
18194            height: 40.0,
18195        };
18196        let clip = Some(Rect {
18197            x: 0.0,
18198            y: 0.0,
18199            width: 320.0,
18200            height: 200.0,
18201        });
18202
18203        assert!(
18204            !text_draw_is_visible_in_viewport(text_rect, clip, viewport, 1.0),
18205            "lazy-list beyond-bound text outside the clip must not be rasterized"
18206        );
18207    }
18208
18209    #[test]
18210    fn text_draw_prewarm_accepts_clipped_text_near_viewport() {
18211        let viewport = ViewportUniformParams {
18212            width: 320,
18213            height: 240,
18214            offset: [0.0, 0.0],
18215        };
18216        let text_rect = Rect {
18217            x: 0.0,
18218            y: 260.0,
18219            width: 200.0,
18220            height: 40.0,
18221        };
18222        let clip = Some(Rect {
18223            x: 0.0,
18224            y: 0.0,
18225            width: 320.0,
18226            height: 200.0,
18227        });
18228
18229        assert!(!text_draw_is_visible_in_viewport(
18230            text_rect, clip, viewport, 1.0
18231        ));
18232        assert!(text_draw_should_prewarm_in_viewport(
18233            text_rect, clip, viewport, 1.0
18234        ));
18235    }
18236
18237    #[test]
18238    fn text_draw_prewarm_rejects_far_clipped_text() {
18239        let viewport = ViewportUniformParams {
18240            width: 320,
18241            height: 240,
18242            offset: [0.0, 0.0],
18243        };
18244        let text_rect = Rect {
18245            x: 0.0,
18246            y: 1600.0,
18247            width: 200.0,
18248            height: 40.0,
18249        };
18250        let clip = Some(Rect {
18251            x: 0.0,
18252            y: 0.0,
18253            width: 320.0,
18254            height: 200.0,
18255        });
18256
18257        assert!(!text_draw_should_prewarm_in_viewport(
18258            text_rect, clip, viewport, 1.0
18259        ));
18260    }
18261
18262    #[test]
18263    fn text_draw_visibility_rejects_unclipped_text_outside_viewport() {
18264        let viewport = ViewportUniformParams {
18265            width: 320,
18266            height: 240,
18267            offset: [0.0, 0.0],
18268        };
18269        let text_rect = Rect {
18270            x: 0.0,
18271            y: 241.0,
18272            width: 200.0,
18273            height: 40.0,
18274        };
18275
18276        assert!(
18277            !text_draw_is_visible_in_viewport(text_rect, None, viewport, 1.0),
18278            "unclipped text outside the target viewport must not be rasterized"
18279        );
18280    }
18281
18282    #[test]
18283    fn text_draw_visibility_keeps_partially_visible_text() {
18284        let viewport = ViewportUniformParams {
18285            width: 320,
18286            height: 240,
18287            offset: [0.0, 0.0],
18288        };
18289        let text_rect = Rect {
18290            x: 0.0,
18291            y: 220.0,
18292            width: 200.0,
18293            height: 40.0,
18294        };
18295
18296        assert!(text_draw_is_visible_in_viewport(
18297            text_rect, None, viewport, 1.0
18298        ));
18299    }
18300
18301    fn test_draw_ops(
18302        shapes: &[DrawShape],
18303        images: &[ImageDraw],
18304        texts: &[TextDraw],
18305        shadows: &[ShadowDraw],
18306    ) -> Vec<DrawOp> {
18307        let mut ops = Vec::new();
18308        ops.extend(shapes.iter().enumerate().map(|(index, shape)| DrawOp {
18309            z_index: shape.z_index,
18310            kind: DrawOpKind::Shape(index),
18311        }));
18312        ops.extend(images.iter().enumerate().map(|(index, image)| DrawOp {
18313            z_index: image.z_index,
18314            kind: DrawOpKind::Image(index),
18315        }));
18316        ops.extend(texts.iter().enumerate().map(|(index, text)| DrawOp {
18317            z_index: text.z_index,
18318            kind: DrawOpKind::Text(index),
18319        }));
18320        ops.extend(shadows.iter().enumerate().map(|(index, shadow)| DrawOp {
18321            z_index: shadow.z_index,
18322            kind: DrawOpKind::Shadow(index),
18323        }));
18324        ops.sort_by_key(|op| op.z_index);
18325        ops
18326    }
18327
18328    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
18329        crate::test_support::layer_node(
18330            local_bounds,
18331            ProjectiveTransform::identity(),
18332            GraphicsLayer::default(),
18333            children,
18334        )
18335    }
18336
18337    fn cacheable_layer(
18338        node_id: cranpose_core::NodeId,
18339        local_bounds: Rect,
18340        children: Vec<RenderNode>,
18341    ) -> LayerNode {
18342        let mut layer = test_layer(local_bounds, children);
18343        layer.node_id = Some(node_id);
18344        layer.cache_policy = cranpose_render_common::graph::CachePolicy::Auto;
18345        layer.recompute_raster_cache_hashes();
18346        layer
18347    }
18348
18349    fn text_layer_with_style(text: AnnotatedString, text_style: TextStyle) -> LayerNode {
18350        test_layer(
18351            Rect {
18352                x: 0.0,
18353                y: 0.0,
18354                width: 64.0,
18355                height: 32.0,
18356            },
18357            vec![RenderNode::Primitive(PrimitiveEntry {
18358                phase: PrimitivePhase::BeforeChildren,
18359                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18360                    node_id: 1,
18361                    rect: Rect {
18362                        x: 2.0,
18363                        y: 3.0,
18364                        width: 48.0,
18365                        height: 18.0,
18366                    },
18367                    text: std::rc::Rc::new(text),
18368                    text_style,
18369                    font_size: 14.0,
18370                    layout_options: TextLayoutOptions::default(),
18371                    clip: None,
18372                })),
18373            })],
18374        )
18375    }
18376
18377    fn snapped_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
18378        LayerNode {
18379            node_id: Some(77),
18380            local_bounds: Rect {
18381                x: 0.0,
18382                y: 0.0,
18383                width: 48.0,
18384                height: 24.0,
18385            },
18386            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
18387            motion_context_animated: animated,
18388            translated_content_context,
18389            translated_content_offset: Point::default(),
18390            content_offset: Point::default(),
18391            scene_children_origin: cranpose_ui_graphics::Point::default(),
18392            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
18393            graphics_layer: GraphicsLayer::default(),
18394            clip_to_bounds: false,
18395            shadow_clip: None,
18396            hit_test: None,
18397            has_hit_targets: false,
18398            isolation: IsolationReasons::default(),
18399            cache_policy: CachePolicy::None,
18400            cache_hashes: LayerRasterCacheHashes::default(),
18401            cache_hashes_valid: false,
18402            children: vec![
18403                RenderNode::Primitive(PrimitiveEntry {
18404                    phase: PrimitivePhase::BeforeChildren,
18405                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
18406                        primitive: DrawPrimitive::RoundRect {
18407                            rect: Rect {
18408                                x: 0.0,
18409                                y: 0.0,
18410                                width: 48.0,
18411                                height: 24.0,
18412                            },
18413                            brush: Brush::solid(Color(0.28, 0.30, 0.46, 0.88)),
18414                            radii: CornerRadii::uniform(6.0),
18415                            stroke: None,
18416                        },
18417                        clip: None,
18418                    }),
18419                }),
18420                RenderNode::Primitive(PrimitiveEntry {
18421                    phase: PrimitivePhase::BeforeChildren,
18422                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
18423                        primitive: DrawPrimitive::Image {
18424                            rect: Rect {
18425                                x: 2.0,
18426                                y: 2.0,
18427                                width: 12.0,
18428                                height: 12.0,
18429                            },
18430                            image: ImageBitmap::from_rgba8(
18431                                2,
18432                                2,
18433                                vec![
18434                                    255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255,
18435                                    255,
18436                                ],
18437                            )
18438                            .expect("image"),
18439                            alpha: 1.0,
18440                            color_filter: None,
18441                            sampling: ImageSampling::Linear,
18442                            src_rect: None,
18443                        },
18444                        clip: None,
18445                    }),
18446                }),
18447                RenderNode::Primitive(PrimitiveEntry {
18448                    phase: PrimitivePhase::BeforeChildren,
18449                    node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18450                        node_id: 77,
18451                        rect: Rect {
18452                            x: 6.0,
18453                            y: 4.0,
18454                            width: 36.0,
18455                            height: 16.0,
18456                        },
18457                        text: std::rc::Rc::new(AnnotatedString::from("48 px")),
18458                        text_style: TextStyle::default(),
18459                        font_size: 14.0,
18460                        layout_options: TextLayoutOptions::default(),
18461                        clip: None,
18462                    })),
18463                }),
18464            ],
18465        }
18466    }
18467
18468    fn snapped_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
18469        let text_leaf = snapped_text_leaf(animated, translated_content_context);
18470        test_layer(
18471            Rect {
18472                x: 0.0,
18473                y: 0.0,
18474                width: 96.0,
18475                height: 64.0,
18476            },
18477            vec![RenderNode::Layer(Box::new(text_leaf))],
18478        )
18479    }
18480
18481    fn translated_content_local_surface_root() -> LayerNode {
18482        let mut effectful_text = text_layer_with_style(
18483            AnnotatedString::from("shadow"),
18484            TextStyle::from_span_style(SpanStyle {
18485                shadow: Some(Shadow {
18486                    color: Color::BLACK,
18487                    offset: Point::new(1.0, 2.0),
18488                    blur_radius: 3.0,
18489                }),
18490                ..SpanStyle::default()
18491            }),
18492        );
18493        effectful_text.translated_content_context = true;
18494
18495        let translated_content = LayerNode {
18496            node_id: Some(78),
18497            local_bounds: Rect {
18498                x: 0.0,
18499                y: 0.0,
18500                width: 96.0,
18501                height: 64.0,
18502            },
18503            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
18504            motion_context_animated: false,
18505            translated_content_context: true,
18506            translated_content_offset: Point::default(),
18507            content_offset: Point::default(),
18508            scene_children_origin: cranpose_ui_graphics::Point::default(),
18509            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
18510            graphics_layer: GraphicsLayer::default(),
18511            clip_to_bounds: false,
18512            shadow_clip: None,
18513            hit_test: None,
18514            has_hit_targets: false,
18515            isolation: IsolationReasons::default(),
18516            cache_policy: CachePolicy::None,
18517            cache_hashes: LayerRasterCacheHashes::default(),
18518            cache_hashes_valid: false,
18519            children: vec![RenderNode::Layer(Box::new(effectful_text))],
18520        };
18521
18522        test_layer(
18523            Rect {
18524                x: 0.0,
18525                y: 0.0,
18526                width: 160.0,
18527                height: 120.0,
18528            },
18529            vec![RenderNode::Layer(Box::new(translated_content))],
18530        )
18531    }
18532
18533    #[test]
18534    fn scissor_rect_for_layer_intersects_with_clip() {
18535        let rect = Rect {
18536            x: 10.0,
18537            y: 10.0,
18538            width: 30.0,
18539            height: 20.0,
18540        };
18541        let clip = Rect {
18542            x: 20.0,
18543            y: 15.0,
18544            width: 100.0,
18545            height: 100.0,
18546        };
18547
18548        let scissor = scissor_rect_for_layer(rect, Some(clip), 1.0, 200, 200);
18549        assert_eq!(scissor, Some((20, 15, 20, 15)));
18550    }
18551
18552    #[test]
18553    fn visible_draw_rect_no_clip_returns_original() {
18554        let rect = Rect {
18555            x: 100.0,
18556            y: 200.0,
18557            width: 300.0,
18558            height: 400.0,
18559        };
18560        assert_eq!(visible_draw_rect(rect, None), Some(rect));
18561    }
18562
18563    #[test]
18564    fn visible_draw_rect_with_clip_intersects() {
18565        let rect = Rect {
18566            x: 0.0,
18567            y: 0.0,
18568            width: 2000.0,
18569            height: 5000.0,
18570        };
18571        let clip = Rect {
18572            x: 0.0,
18573            y: 0.0,
18574            width: 800.0,
18575            height: 600.0,
18576        };
18577        let visible = visible_draw_rect(rect, Some(clip)).expect("should have visible area");
18578        assert_eq!(visible.width, 800.0);
18579        assert_eq!(visible.height, 600.0);
18580    }
18581
18582    #[test]
18583    fn visible_draw_rect_fully_clipped_returns_none() {
18584        let rect = Rect {
18585            x: 1000.0,
18586            y: 1000.0,
18587            width: 200.0,
18588            height: 200.0,
18589        };
18590        let clip = Rect {
18591            x: 0.0,
18592            y: 0.0,
18593            width: 800.0,
18594            height: 600.0,
18595        };
18596        assert!(visible_draw_rect(rect, Some(clip)).is_none());
18597    }
18598
18599    #[test]
18600    fn scene_bounds_respects_clip_on_shapes() {
18601        let mut scene = CompositorScene::new();
18602        // Shape inside viewport — visible
18603        scene.shapes.push(DrawShape {
18604            rect: Rect {
18605                x: 10.0,
18606                y: 10.0,
18607                width: 100.0,
18608                height: 50.0,
18609            },
18610            clip: Some(Rect {
18611                x: 0.0,
18612                y: 0.0,
18613                width: 800.0,
18614                height: 600.0,
18615            }),
18616            ..test_shape(0, BlendMode::SrcOver)
18617        });
18618        // Shape far outside viewport — clipped away entirely
18619        scene.shapes.push(DrawShape {
18620            rect: Rect {
18621                x: 0.0,
18622                y: 3000.0,
18623                width: 100.0,
18624                height: 50.0,
18625            },
18626            clip: Some(Rect {
18627                x: 0.0,
18628                y: 0.0,
18629                width: 800.0,
18630                height: 600.0,
18631            }),
18632            ..test_shape(1, BlendMode::SrcOver)
18633        });
18634        let bounds = scene_bounds(&scene).expect("should have bounds");
18635        // Bounds should only cover the first shape's visible area,
18636        // NOT extend to y=3050 from the clipped second shape.
18637        assert!(bounds.y + bounds.height <= 600.0);
18638    }
18639
18640    #[test]
18641    fn scene_bounds_scroll_content_clipped_to_viewport() {
18642        // Simulates a scroll container: many items with large y offsets,
18643        // all clipped to a viewport-sized clip rect.
18644        let mut scene = CompositorScene::new();
18645        let viewport_clip = Rect {
18646            x: 0.0,
18647            y: 0.0,
18648            width: 800.0,
18649            height: 600.0,
18650        };
18651        for i in 0..20 {
18652            scene.shapes.push(DrawShape {
18653                rect: Rect {
18654                    x: 0.0,
18655                    y: i as f32 * 300.0,
18656                    width: 800.0,
18657                    height: 200.0,
18658                },
18659                clip: Some(viewport_clip),
18660                ..test_shape(i, BlendMode::SrcOver)
18661            });
18662        }
18663        let bounds = scene_bounds(&scene).expect("should have bounds");
18664        // All shapes are clipped to viewport — bounds should be viewport-sized,
18665        // NOT 20*300 = 6000 dp tall.
18666        assert_eq!(bounds.x, 0.0);
18667        assert_eq!(bounds.y, 0.0);
18668        assert!(bounds.width <= 800.0);
18669        assert!(bounds.height <= 600.0);
18670    }
18671
18672    #[test]
18673    fn scene_bounds_stable_across_scroll_offsets() {
18674        // Simulates horizontal scroll at different offsets —
18675        // bounds should be identical regardless of scroll position.
18676        let viewport_clip = Rect {
18677            x: 0.0,
18678            y: 0.0,
18679            width: 400.0,
18680            height: 50.0,
18681        };
18682        let compute_bounds_at_offset = |scroll_x: f32| {
18683            let mut scene = CompositorScene::new();
18684            for i in 0..10 {
18685                scene.shapes.push(DrawShape {
18686                    rect: Rect {
18687                        x: i as f32 * 100.0 - scroll_x,
18688                        y: 0.0,
18689                        width: 80.0,
18690                        height: 40.0,
18691                    },
18692                    clip: Some(viewport_clip),
18693                    ..test_shape(i, BlendMode::SrcOver)
18694                });
18695            }
18696            scene_bounds(&scene).expect("bounds")
18697        };
18698        let bounds_at_0 = compute_bounds_at_offset(0.0);
18699        let bounds_at_300 = compute_bounds_at_offset(300.0);
18700        let bounds_at_600 = compute_bounds_at_offset(600.0);
18701        // Width should be stable (clipped to viewport) regardless of scroll offset
18702        assert!(
18703            (bounds_at_0.width - bounds_at_300.width).abs() < 1.0,
18704            "bounds width changed with scroll: {} vs {}",
18705            bounds_at_0.width,
18706            bounds_at_300.width
18707        );
18708        assert!(
18709            (bounds_at_0.width - bounds_at_600.width).abs() < 1.0,
18710            "bounds width changed with scroll: {} vs {}",
18711            bounds_at_0.width,
18712            bounds_at_600.width
18713        );
18714    }
18715
18716    #[test]
18717    fn collect_effect_ranges_respects_excluded_effect() {
18718        let layers = vec![effect_layer(10, 40), effect_layer(20, 30)];
18719        let mut ranges = Vec::new();
18720        collect_effect_ranges(&layers, 10, 40, Some(0), &mut ranges);
18721        assert_eq!(ranges.len(), 1);
18722        assert_eq!(ranges[0], 20..30);
18723    }
18724
18725    #[test]
18726    fn collect_layer_events_includes_nested_when_parent_excluded() {
18727        let effects = vec![effect_layer(10, 40), effect_layer(20, 30)];
18728        let backdrops = vec![backdrop_layer(25)];
18729        let mut events = Vec::new();
18730        collect_layer_events(&effects, &backdrops, 10, 40, Some(0), &mut events);
18731        assert_eq!(events.len(), 2);
18732
18733        match events[0].kind {
18734            LayerEventKind::Effect(index) => assert_eq!(index, 1),
18735            LayerEventKind::Backdrop(_) => panic!("expected nested effect as first event"),
18736        }
18737        match events[1].kind {
18738            LayerEventKind::Backdrop(index) => assert_eq!(index, 0),
18739            LayerEventKind::Effect(_) => panic!("expected backdrop as second event"),
18740        }
18741    }
18742
18743    fn pure_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
18744        LayerNode {
18745            node_id: Some(177),
18746            local_bounds: Rect {
18747                x: 0.0,
18748                y: 0.0,
18749                width: 96.0,
18750                height: 32.0,
18751            },
18752            transform_to_parent: ProjectiveTransform::translation(11.4, 23.6),
18753            motion_context_animated: animated,
18754            translated_content_context,
18755            translated_content_offset: Point::default(),
18756            content_offset: Point::default(),
18757            scene_children_origin: cranpose_ui_graphics::Point::default(),
18758            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
18759            graphics_layer: GraphicsLayer::default(),
18760            clip_to_bounds: false,
18761            shadow_clip: None,
18762            hit_test: None,
18763            has_hit_targets: false,
18764            isolation: IsolationReasons::default(),
18765            cache_policy: CachePolicy::None,
18766            cache_hashes: LayerRasterCacheHashes::default(),
18767            cache_hashes_valid: false,
18768            children: vec![RenderNode::Primitive(PrimitiveEntry {
18769                phase: PrimitivePhase::BeforeChildren,
18770                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18771                    node_id: 177,
18772                    rect: Rect {
18773                        x: 0.0,
18774                        y: 0.0,
18775                        width: 96.0,
18776                        height: 24.0,
18777                    },
18778                    clip: None,
18779                    text: std::rc::Rc::new(AnnotatedString::from("Pure text")),
18780                    text_style: TextStyle::default(),
18781                    font_size: 14.0,
18782                    layout_options: TextLayoutOptions::default(),
18783                })),
18784            })],
18785        }
18786    }
18787
18788    fn pure_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
18789        let text_leaf = pure_text_leaf(animated, translated_content_context);
18790        test_layer(
18791            Rect {
18792                x: 0.0,
18793                y: 0.0,
18794                width: 160.0,
18795                height: 96.0,
18796            },
18797            vec![RenderNode::Layer(Box::new(text_leaf))],
18798        )
18799    }
18800
18801    #[test]
18802    fn collect_layer_events_sorts_backdrop_before_effect_at_same_z() {
18803        let effects = vec![effect_layer(10, 20)];
18804        let backdrops = vec![backdrop_layer(10)];
18805        let mut events = Vec::new();
18806        collect_layer_events(&effects, &backdrops, 0, 30, None, &mut events);
18807        assert_eq!(events.len(), 2);
18808
18809        match events[0].kind {
18810            LayerEventKind::Backdrop(_) => {}
18811            LayerEventKind::Effect(_) => panic!("expected backdrop to run before effect"),
18812        }
18813        match events[1].kind {
18814            LayerEventKind::Effect(_) => {}
18815            LayerEventKind::Backdrop(_) => panic!("expected effect as second event"),
18816        }
18817    }
18818
18819    #[test]
18820    fn collect_layer_events_prefers_outer_effect_when_same_start_z() {
18821        // Child emitted before parent (matching scene collection order where a
18822        // parent effect is recorded after recursively processing children).
18823        let effects = vec![effect_layer(10, 20), effect_layer(10, 40)];
18824        let mut events = Vec::new();
18825        collect_layer_events(&effects, &[], 0, 50, None, &mut events);
18826
18827        assert_eq!(events.len(), 2);
18828        match events[0].kind {
18829            LayerEventKind::Effect(index) => assert_eq!(index, 1),
18830            LayerEventKind::Backdrop(_) => panic!("expected outer effect first"),
18831        }
18832        match events[1].kind {
18833            LayerEventKind::Effect(index) => assert_eq!(index, 0),
18834            LayerEventKind::Backdrop(_) => panic!("expected child effect second"),
18835        }
18836    }
18837
18838    #[test]
18839    fn collect_layer_events_prefers_later_effect_when_ranges_match() {
18840        let effects = vec![effect_layer(10, 20), effect_layer(10, 20)];
18841        let mut events = Vec::new();
18842        collect_layer_events(&effects, &[], 0, 30, None, &mut events);
18843
18844        assert_eq!(events.len(), 2);
18845        match events[0].kind {
18846            LayerEventKind::Effect(index) => assert_eq!(index, 1),
18847            LayerEventKind::Backdrop(_) => panic!("expected later effect first"),
18848        }
18849        match events[1].kind {
18850            LayerEventKind::Effect(index) => assert_eq!(index, 0),
18851            LayerEventKind::Backdrop(_) => panic!("expected earlier effect second"),
18852        }
18853    }
18854
18855    #[test]
18856    fn has_backdrop_layer_in_range_detects_nested_layers() {
18857        let backdrops = vec![backdrop_layer(5), backdrop_layer(15), backdrop_layer(25)];
18858        assert!(has_backdrop_layer_in_range(&backdrops, 10, 20));
18859        assert!(has_backdrop_layer_in_range(&backdrops, 0, 6));
18860        assert!(!has_backdrop_layer_in_range(&backdrops, 20, 25));
18861    }
18862
18863    #[test]
18864    fn layer_contains_descendant_backdrop_ignores_self_backdrop() {
18865        let mut self_backdrop = test_layer(
18866            Rect {
18867                x: 0.0,
18868                y: 0.0,
18869                width: 10.0,
18870                height: 10.0,
18871            },
18872            vec![],
18873        );
18874        self_backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
18875        assert!(!layer_contains_descendant_backdrop(&self_backdrop));
18876
18877        let mut child = test_layer(
18878            Rect {
18879                x: 0.0,
18880                y: 0.0,
18881                width: 8.0,
18882                height: 8.0,
18883            },
18884            vec![],
18885        );
18886        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
18887
18888        let parent = test_layer(
18889            Rect {
18890                x: 0.0,
18891                y: 0.0,
18892                width: 20.0,
18893                height: 20.0,
18894            },
18895            vec![RenderNode::Layer(Box::new(child))],
18896        );
18897        assert!(layer_contains_descendant_backdrop(&parent));
18898    }
18899
18900    fn child_layer_composite(
18901        layer: &LayerNode,
18902        z_index: usize,
18903        rect: Rect,
18904        needs_nested_underlay: bool,
18905    ) -> crate::normalized_scene::ChildLayerComposite {
18906        let mut requirements_cache = cranpose_core::collections::map::HashMap::new();
18907        let surface_requirements =
18908            crate::surface_plan::layer_surface_requirements_cached(layer, &mut requirements_cache);
18909        crate::normalized_scene::ChildLayerComposite {
18910            z_index,
18911            logical_rect: Rect {
18912                x: 0.0,
18913                y: 0.0,
18914                width: rect.width,
18915                height: rect.height,
18916            },
18917            dest_quad: rect_to_quad(rect),
18918            snap_anchor: None,
18919            composite_snap_origin: None,
18920            backdrop_rect: rect,
18921            visual_clip: None,
18922            surface_clip: None,
18923            shadow_draws: Vec::new(),
18924            needs_nested_underlay,
18925            node_id: layer.node_id,
18926            backdrop: layer.backdrop().cloned(),
18927            has_effect: layer.effect().is_some(),
18928            effect_contains_runtime_shader: layer
18929                .effect()
18930                .is_some_and(|effect| effect.contains_runtime_shader()),
18931            target_content_hash: layer.target_content_hash(),
18932            effect_hash: layer.effect_hash(),
18933            motion_source_content_hash: Some(layer.motion_source_content_hash()),
18934            contains_descendant_backdrop: layer_contains_descendant_backdrop(layer),
18935            cache_policy: layer.cache_policy,
18936            surface_requirements,
18937            rounded_clip: crate::surface_executor::backend::LayerSurfaceRoundedClip::from_layer(
18938                layer,
18939            ),
18940            isolation: cranpose_render_common::layer_composition::effective_layer_isolation(
18941                &layer.graphics_layer,
18942            ),
18943            translated_content_context: layer.translated_content_context,
18944            own_translated_content_axes: crate::surface_plan::translated_content_axes_for_layer(
18945                layer,
18946            ),
18947            clip_rect: layer.clip_rect(),
18948            local_bounds: layer.local_bounds,
18949            surface_scale: crate::surface_plan::layer_surface_scale(layer),
18950            source: crate::normalized_scene::LoweredChildSource::default(),
18951        }
18952    }
18953
18954    #[test]
18955    fn root_direct_preflight_allows_first_translated_child_underlay() {
18956        let child = test_layer(
18957            Rect {
18958                x: 0.0,
18959                y: 0.0,
18960                width: 400.0,
18961                height: 280.0,
18962            },
18963            vec![],
18964        );
18965        let collected = CollectedLayer {
18966            scene: CompositorScene::new(),
18967            child_layers: vec![child_layer_composite(
18968                &child,
18969                3,
18970                Rect {
18971                    x: 48.0,
18972                    y: 96.0,
18973                    width: 400.0,
18974                    height: 280.0,
18975                },
18976                true,
18977            )],
18978        };
18979
18980        assert!(direct_root_child_underlays_are_supported(&collected));
18981    }
18982
18983    #[test]
18984    fn root_direct_preflight_allows_axis_aligned_prior_child_underlay() {
18985        let first = test_layer(
18986            Rect {
18987                x: 0.0,
18988                y: 0.0,
18989                width: 80.0,
18990                height: 40.0,
18991            },
18992            vec![],
18993        );
18994        let backdrop_child = test_layer(
18995            Rect {
18996                x: 0.0,
18997                y: 0.0,
18998                width: 400.0,
18999                height: 280.0,
19000            },
19001            vec![],
19002        );
19003        let collected = CollectedLayer {
19004            scene: CompositorScene::new(),
19005            child_layers: vec![
19006                child_layer_composite(
19007                    &first,
19008                    1,
19009                    Rect {
19010                        x: 8.0,
19011                        y: 16.0,
19012                        width: 80.0,
19013                        height: 40.0,
19014                    },
19015                    false,
19016                ),
19017                child_layer_composite(
19018                    &backdrop_child,
19019                    4,
19020                    Rect {
19021                        x: 48.0,
19022                        y: 96.0,
19023                        width: 400.0,
19024                        height: 280.0,
19025                    },
19026                    true,
19027                ),
19028            ],
19029        };
19030
19031        assert!(direct_root_child_underlays_are_supported(&collected));
19032    }
19033
19034    #[test]
19035    fn root_direct_preflight_rejects_effectful_prior_child_underlay() {
19036        let mut first = test_layer(
19037            Rect {
19038                x: 0.0,
19039                y: 0.0,
19040                width: 80.0,
19041                height: 40.0,
19042            },
19043            vec![],
19044        );
19045        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19046        let backdrop_child = test_layer(
19047            Rect {
19048                x: 0.0,
19049                y: 0.0,
19050                width: 400.0,
19051                height: 280.0,
19052            },
19053            vec![],
19054        );
19055        let collected = CollectedLayer {
19056            scene: CompositorScene::new(),
19057            child_layers: vec![
19058                child_layer_composite(
19059                    &first,
19060                    1,
19061                    Rect {
19062                        x: 64.0,
19063                        y: 112.0,
19064                        width: 80.0,
19065                        height: 40.0,
19066                    },
19067                    false,
19068                ),
19069                child_layer_composite(
19070                    &backdrop_child,
19071                    4,
19072                    Rect {
19073                        x: 48.0,
19074                        y: 96.0,
19075                        width: 400.0,
19076                        height: 280.0,
19077                    },
19078                    true,
19079                ),
19080            ],
19081        };
19082
19083        assert!(!direct_root_child_underlays_are_supported(&collected));
19084    }
19085
19086    #[test]
19087    fn root_direct_preflight_ignores_non_overlapping_effectful_prior_child_underlay() {
19088        let mut first = test_layer(
19089            Rect {
19090                x: 0.0,
19091                y: 0.0,
19092                width: 80.0,
19093                height: 40.0,
19094            },
19095            vec![],
19096        );
19097        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19098        let backdrop_child = test_layer(
19099            Rect {
19100                x: 0.0,
19101                y: 0.0,
19102                width: 400.0,
19103                height: 280.0,
19104            },
19105            vec![],
19106        );
19107        let collected = CollectedLayer {
19108            scene: CompositorScene::new(),
19109            child_layers: vec![
19110                child_layer_composite(
19111                    &first,
19112                    1,
19113                    Rect {
19114                        x: 8.0,
19115                        y: 16.0,
19116                        width: 80.0,
19117                        height: 40.0,
19118                    },
19119                    false,
19120                ),
19121                child_layer_composite(
19122                    &backdrop_child,
19123                    4,
19124                    Rect {
19125                        x: 48.0,
19126                        y: 96.0,
19127                        width: 400.0,
19128                        height: 280.0,
19129                    },
19130                    true,
19131                ),
19132            ],
19133        };
19134
19135        assert!(direct_root_child_underlays_are_supported(&collected));
19136    }
19137
19138    #[test]
19139    fn root_direct_preflight_rejects_underlay_that_would_replay_prior_scene_effects() {
19140        let backdrop_child = test_layer(
19141            Rect {
19142                x: 0.0,
19143                y: 0.0,
19144                width: 400.0,
19145                height: 280.0,
19146            },
19147            vec![],
19148        );
19149        let mut scene = CompositorScene::new();
19150        scene.next_z = 1;
19151        scene.push_effect_layer(
19152            Rect {
19153                x: 0.0,
19154                y: 0.0,
19155                width: 120.0,
19156                height: 120.0,
19157            },
19158            None,
19159            Some(RenderEffect::blur(2.0)),
19160            BlendMode::SrcOver,
19161            1.0,
19162            0,
19163            1,
19164        );
19165        let collected = CollectedLayer {
19166            scene,
19167            child_layers: vec![child_layer_composite(
19168                &backdrop_child,
19169                4,
19170                Rect {
19171                    x: 48.0,
19172                    y: 96.0,
19173                    width: 400.0,
19174                    height: 280.0,
19175                },
19176                true,
19177            )],
19178        };
19179
19180        assert!(!direct_root_child_underlays_are_supported(&collected));
19181    }
19182
19183    #[test]
19184    fn root_direct_eligibility_does_not_reject_descendant_backdrop() {
19185        let mut backdrop = test_layer(
19186            Rect {
19187                x: 0.0,
19188                y: 0.0,
19189                width: 40.0,
19190                height: 40.0,
19191            },
19192            vec![],
19193        );
19194        backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
19195        let child = test_layer(
19196            Rect {
19197                x: 0.0,
19198                y: 0.0,
19199                width: 120.0,
19200                height: 96.0,
19201            },
19202            vec![RenderNode::Layer(Box::new(backdrop))],
19203        );
19204        let root = test_layer(
19205            Rect {
19206                x: 0.0,
19207                y: 0.0,
19208                width: 240.0,
19209                height: 160.0,
19210            },
19211            vec![RenderNode::Layer(Box::new(child))],
19212        );
19213        let mut cache = HashMap::new();
19214
19215        assert!(root_can_render_directly_cached(&root, &mut cache));
19216    }
19217
19218    #[test]
19219    fn root_direct_scene_events_allow_root_local_effects() {
19220        let mut scene = CompositorScene::new();
19221        scene.effect_layers.push(EffectLayer {
19222            rect: Rect {
19223                x: 20.0,
19224                y: 30.0,
19225                width: 120.0,
19226                height: 80.0,
19227            },
19228            clip: None,
19229            snap_anchor: None,
19230            effect: Some(RenderEffect::blur(6.0)),
19231            blend_mode: BlendMode::SrcOver,
19232            composite_alpha: 1.0,
19233            z_start: 0,
19234            z_end: 1,
19235            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
19236        });
19237
19238        assert!(root_direct_scene_events_are_supported(&scene));
19239    }
19240
19241    #[test]
19242    fn root_direct_scene_events_reject_root_local_backdrops() {
19243        let mut scene = CompositorScene::new();
19244        scene.backdrop_layers.push(BackdropLayer {
19245            node_id: Some(99),
19246            rect: Rect {
19247                x: 20.0,
19248                y: 30.0,
19249                width: 120.0,
19250                height: 80.0,
19251            },
19252            clip: None,
19253            snap_anchor: None,
19254            effect: RenderEffect::blur(6.0),
19255            z_index: 1,
19256        });
19257
19258        assert!(!root_direct_scene_events_are_supported(&scene));
19259    }
19260
19261    #[test]
19262    fn estimate_layer_surface_rect_includes_transformed_child_bounds() {
19263        let mut child = test_layer(
19264            Rect {
19265                x: 0.0,
19266                y: 0.0,
19267                width: 10.0,
19268                height: 6.0,
19269            },
19270            vec![RenderNode::Primitive(PrimitiveEntry {
19271                phase: PrimitivePhase::BeforeChildren,
19272                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19273                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19274                        rect: Rect {
19275                            x: 0.0,
19276                            y: 0.0,
19277                            width: 10.0,
19278                            height: 6.0,
19279                        },
19280                        brush: Brush::solid(Color::WHITE),
19281                        stroke: None,
19282                    },
19283                    clip: None,
19284                }),
19285            })],
19286        );
19287        child.transform_to_parent = ProjectiveTransform::translation(18.0, 7.0);
19288
19289        let parent = test_layer(
19290            Rect {
19291                x: 0.0,
19292                y: 0.0,
19293                width: 4.0,
19294                height: 4.0,
19295            },
19296            vec![RenderNode::Layer(Box::new(child))],
19297        );
19298
19299        assert_eq!(
19300            estimate_layer_surface_rect(&parent),
19301            Rect {
19302                x: 18.0,
19303                y: 7.0,
19304                width: 10.0,
19305                height: 6.0,
19306            }
19307        );
19308    }
19309
19310    #[test]
19311    fn estimate_layer_surface_rect_clips_translated_clip_layers_without_hidden_leading_content() {
19312        let mut layer = test_layer(
19313            Rect {
19314                x: 0.0,
19315                y: 0.0,
19316                width: 120.0,
19317                height: 72.0,
19318            },
19319            vec![RenderNode::Primitive(PrimitiveEntry {
19320                phase: PrimitivePhase::BeforeChildren,
19321                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19322                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19323                        rect: Rect {
19324                            x: 24.0,
19325                            y: 0.0,
19326                            width: 200.0,
19327                            height: 480.0,
19328                        },
19329                        brush: Brush::solid(Color::WHITE),
19330                        stroke: None,
19331                    },
19332                    clip: None,
19333                }),
19334            })],
19335        );
19336        layer.translated_content_context = true;
19337        layer.motion_context_animated = true;
19338        layer.clip_to_bounds = true;
19339
19340        assert_eq!(
19341            estimate_layer_surface_rect(&layer),
19342            Rect {
19343                x: 24.0,
19344                y: 0.0,
19345                width: 96.0,
19346                height: 72.0,
19347            }
19348        );
19349    }
19350
19351    #[test]
19352    fn estimate_layer_surface_rect_clips_active_horizontal_scroll_content() {
19353        let mut layer = test_layer(
19354            Rect {
19355                x: 0.0,
19356                y: 0.0,
19357                width: 120.0,
19358                height: 72.0,
19359            },
19360            vec![RenderNode::Primitive(PrimitiveEntry {
19361                phase: PrimitivePhase::BeforeChildren,
19362                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19363                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19364                        rect: Rect {
19365                            x: -24.0,
19366                            y: 0.0,
19367                            width: 200.0,
19368                            height: 480.0,
19369                        },
19370                        brush: Brush::solid(Color::WHITE),
19371                        stroke: None,
19372                    },
19373                    clip: None,
19374                }),
19375            })],
19376        );
19377        layer.translated_content_context = true;
19378        layer.motion_context_animated = true;
19379        layer.clip_to_bounds = true;
19380
19381        assert_eq!(
19382            estimate_layer_surface_rect(&layer),
19383            Rect {
19384                x: 0.0,
19385                y: 0.0,
19386                width: 120.0,
19387                height: 72.0,
19388            }
19389        );
19390    }
19391
19392    #[test]
19393    fn estimate_layer_surface_rect_clips_active_vertical_scroll_content() {
19394        let mut layer = test_layer(
19395            Rect {
19396                x: 0.0,
19397                y: 0.0,
19398                width: 120.0,
19399                height: 72.0,
19400            },
19401            vec![RenderNode::Primitive(PrimitiveEntry {
19402                phase: PrimitivePhase::BeforeChildren,
19403                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19404                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19405                        rect: Rect {
19406                            x: 0.0,
19407                            y: -24.0,
19408                            width: 120.0,
19409                            height: 200.0,
19410                        },
19411                        brush: Brush::solid(Color::WHITE),
19412                        stroke: None,
19413                    },
19414                    clip: None,
19415                }),
19416            })],
19417        );
19418        layer.translated_content_context = true;
19419        layer.motion_context_animated = true;
19420        layer.clip_to_bounds = true;
19421
19422        assert_eq!(
19423            estimate_layer_surface_rect(&layer),
19424            Rect {
19425                x: 0.0,
19426                y: 0.0,
19427                width: 120.0,
19428                height: 72.0,
19429            }
19430        );
19431    }
19432
19433    #[test]
19434    fn estimate_layer_surface_rect_keeps_shallow_scroll_capture_origin_stable() {
19435        fn shallow_scroll_surface_rect(content_y: f32) -> Rect {
19436            let mut layer = test_layer(
19437                Rect {
19438                    x: 0.0,
19439                    y: 0.0,
19440                    width: 120.0,
19441                    height: 72.0,
19442                },
19443                vec![RenderNode::Primitive(PrimitiveEntry {
19444                    phase: PrimitivePhase::BeforeChildren,
19445                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
19446                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19447                            rect: Rect {
19448                                x: 0.0,
19449                                y: content_y,
19450                                width: 120.0,
19451                                height: 200.0,
19452                            },
19453                            brush: Brush::solid(Color::WHITE),
19454                            stroke: None,
19455                        },
19456                        clip: None,
19457                    }),
19458                })],
19459            );
19460            layer.translated_content_context = true;
19461            layer.motion_context_animated = true;
19462            layer.clip_to_bounds = true;
19463            estimate_layer_surface_rect(&layer)
19464        }
19465
19466        assert_eq!(
19467            shallow_scroll_surface_rect(-24.0),
19468            shallow_scroll_surface_rect(-25.0),
19469            "shallow scroll capture bounds must not move the offscreen surface origin on adjacent scroll positions"
19470        );
19471    }
19472
19473    #[test]
19474    fn estimate_layer_surface_rect_clips_active_xy_scroll_content() {
19475        let mut layer = test_layer(
19476            Rect {
19477                x: 0.0,
19478                y: 0.0,
19479                width: 120.0,
19480                height: 72.0,
19481            },
19482            vec![RenderNode::Primitive(PrimitiveEntry {
19483                phase: PrimitivePhase::BeforeChildren,
19484                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19485                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19486                        rect: Rect {
19487                            x: -16.0,
19488                            y: -24.0,
19489                            width: 180.0,
19490                            height: 240.0,
19491                        },
19492                        brush: Brush::solid(Color::WHITE),
19493                        stroke: None,
19494                    },
19495                    clip: None,
19496                }),
19497            })],
19498        );
19499        layer.translated_content_context = true;
19500        layer.motion_context_animated = true;
19501        layer.clip_to_bounds = true;
19502
19503        assert_eq!(
19504            estimate_layer_surface_rect(&layer),
19505            Rect {
19506                x: 0.0,
19507                y: 0.0,
19508                width: 120.0,
19509                height: 72.0,
19510            }
19511        );
19512    }
19513
19514    #[test]
19515    fn estimate_layer_surface_rect_clips_deep_hidden_active_scroll_content() {
19516        let mut layer = test_layer(
19517            Rect {
19518                x: 0.0,
19519                y: 0.0,
19520                width: 120.0,
19521                height: 72.0,
19522            },
19523            vec![RenderNode::Primitive(PrimitiveEntry {
19524                phase: PrimitivePhase::BeforeChildren,
19525                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19526                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19527                        rect: Rect {
19528                            x: 0.0,
19529                            y: -1200.0,
19530                            width: 120.0,
19531                            height: 1400.0,
19532                        },
19533                        brush: Brush::solid(Color::WHITE),
19534                        stroke: None,
19535                    },
19536                    clip: None,
19537                }),
19538            })],
19539        );
19540        layer.translated_content_context = true;
19541        layer.motion_context_animated = true;
19542        layer.clip_to_bounds = true;
19543
19544        assert_eq!(
19545            estimate_layer_surface_rect(&layer),
19546            Rect {
19547                x: 0.0,
19548                y: 0.0,
19549                width: 120.0,
19550                height: 72.0,
19551            }
19552        );
19553    }
19554
19555    #[test]
19556    fn estimate_layer_surface_rect_keeps_deep_scroll_capture_origin_stable() {
19557        fn deep_scroll_surface_rect(content_y: f32) -> Rect {
19558            let mut layer = test_layer(
19559                Rect {
19560                    x: 0.0,
19561                    y: 0.0,
19562                    width: 120.0,
19563                    height: 72.0,
19564                },
19565                vec![RenderNode::Primitive(PrimitiveEntry {
19566                    phase: PrimitivePhase::BeforeChildren,
19567                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
19568                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19569                            rect: Rect {
19570                                x: 0.0,
19571                                y: content_y,
19572                                width: 120.0,
19573                                height: 1400.0,
19574                            },
19575                            brush: Brush::solid(Color::WHITE),
19576                            stroke: None,
19577                        },
19578                        clip: None,
19579                    }),
19580                })],
19581            );
19582            layer.translated_content_context = true;
19583            layer.motion_context_animated = true;
19584            layer.clip_to_bounds = true;
19585            estimate_layer_surface_rect(&layer)
19586        }
19587
19588        assert_eq!(
19589            deep_scroll_surface_rect(-1200.0),
19590            deep_scroll_surface_rect(-1201.0),
19591            "deep scroll capture bounds must not re-phase the offscreen surface origin on adjacent scroll positions"
19592        );
19593    }
19594
19595    #[test]
19596    fn motion_stable_capture_bounds_bounds_shadows_for_clipped_effect_layer() {
19597        let mut layer = test_layer(
19598            Rect {
19599                x: 0.0,
19600                y: 0.0,
19601                width: 120.0,
19602                height: 72.0,
19603            },
19604            vec![],
19605        );
19606        layer.clip_to_bounds = true;
19607        layer.graphics_layer.clip = true;
19608        layer.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19609
19610        let mut shadow_shape = test_shape(0, BlendMode::SrcOver);
19611        shadow_shape.rect = Rect {
19612            x: -24.0,
19613            y: -1200.0,
19614            width: 180.0,
19615            height: 1400.0,
19616        };
19617        let mut scene = CompositorScene::new();
19618        scene
19619            .shadow_draws
19620            .push(test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]));
19621
19622        let requirements = SurfaceRequirementSet::default()
19623            .with(SurfaceRequirement::RenderEffect)
19624            .with(SurfaceRequirement::MotionStableCapture);
19625
19626        assert_eq!(
19627            motion_stable_capture_bounds(
19628                &layer,
19629                &scene,
19630                &[],
19631                requirements,
19632                TranslatedContentAxes::default(),
19633                None,
19634            ),
19635            Some(Rect {
19636                x: -360.0,
19637                y: -216.0,
19638                width: 480.0,
19639                height: 288.0,
19640            })
19641        );
19642    }
19643
19644    #[test]
19645    fn vertical_motion_stable_capture_uses_viewport_cross_axis_bounds() {
19646        let mut layer = test_layer(
19647            Rect {
19648                x: 0.0,
19649                y: 0.0,
19650                width: 200.0,
19651                height: 100.0,
19652            },
19653            vec![],
19654        );
19655        layer.clip_to_bounds = true;
19656        layer.graphics_layer.clip = true;
19657
19658        let mut shape = test_shape(0, BlendMode::SrcOver);
19659        shape.rect = Rect {
19660            x: 60.0,
19661            y: -80.0,
19662            width: 80.0,
19663            height: 220.0,
19664        };
19665        let mut scene = CompositorScene::new();
19666        scene.shapes.push(shape);
19667
19668        let requirements =
19669            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
19670
19671        assert_eq!(
19672            motion_stable_capture_bounds(
19673                &layer,
19674                &scene,
19675                &[],
19676                requirements,
19677                TranslatedContentAxes { x: false, y: true },
19678                None,
19679            ),
19680            Some(Rect {
19681                x: -96.0,
19682                y: -64.0,
19683                width: 296.0,
19684                height: 164.0,
19685            })
19686        );
19687    }
19688
19689    #[test]
19690    fn vertical_motion_stable_capture_uses_external_surface_clip() {
19691        let layer = test_layer(
19692            Rect {
19693                x: 0.0,
19694                y: 0.0,
19695                width: 200.0,
19696                height: 100.0,
19697            },
19698            vec![],
19699        );
19700
19701        let mut shape = test_shape(0, BlendMode::SrcOver);
19702        shape.rect = Rect {
19703            x: 60.0,
19704            y: -80.0,
19705            width: 80.0,
19706            height: 220.0,
19707        };
19708        let mut scene = CompositorScene::new();
19709        scene.shapes.push(shape);
19710
19711        let requirements =
19712            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
19713
19714        assert_eq!(
19715            motion_stable_capture_bounds(
19716                &layer,
19717                &scene,
19718                &[],
19719                requirements,
19720                TranslatedContentAxes { x: false, y: true },
19721                Some(Rect {
19722                    x: 0.0,
19723                    y: 0.0,
19724                    width: 200.0,
19725                    height: 100.0,
19726                }),
19727            ),
19728            Some(Rect {
19729                x: -96.0,
19730                y: -64.0,
19731                width: 296.0,
19732                height: 164.0,
19733            })
19734        );
19735    }
19736
19737    #[test]
19738    fn estimate_layer_surface_rect_expands_for_child_layer_shadow() {
19739        let mut child = test_layer(
19740            Rect {
19741                x: 0.0,
19742                y: 0.0,
19743                width: 12.0,
19744                height: 8.0,
19745            },
19746            vec![],
19747        );
19748        child.transform_to_parent = ProjectiveTransform::translation(20.0, 9.0);
19749        child.graphics_layer.shadow_elevation = 6.0;
19750
19751        let parent = test_layer(
19752            Rect {
19753                x: 0.0,
19754                y: 0.0,
19755                width: 4.0,
19756                height: 4.0,
19757            },
19758            vec![RenderNode::Layer(Box::new(child))],
19759        );
19760
19761        let rect = estimate_layer_surface_rect(&parent);
19762        assert!(rect.x < 20.0);
19763        assert!(rect.y < 9.0);
19764        assert!(rect.width > 12.0);
19765        assert!(rect.height > 8.0);
19766    }
19767
19768    #[test]
19769    fn estimate_layer_surface_rect_respects_local_bounds_for_effect_layers() {
19770        let mut layer = test_layer(
19771            Rect {
19772                x: 0.0,
19773                y: 0.0,
19774                width: 28.0,
19775                height: 28.0,
19776            },
19777            vec![RenderNode::Primitive(PrimitiveEntry {
19778                phase: PrimitivePhase::BeforeChildren,
19779                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19780                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19781                        rect: Rect {
19782                            x: 10.0,
19783                            y: 10.0,
19784                            width: 10.0,
19785                            height: 10.0,
19786                        },
19787                        brush: Brush::solid(Color::WHITE),
19788                        stroke: None,
19789                    },
19790                    clip: None,
19791                }),
19792            })],
19793        );
19794        layer.graphics_layer.render_effect = Some(RenderEffect::blur(12.0));
19795
19796        assert_eq!(
19797            estimate_layer_surface_rect(&layer),
19798            Rect {
19799                x: 0.0,
19800                y: 0.0,
19801                width: 28.0,
19802                height: 28.0,
19803            }
19804        );
19805    }
19806
19807    #[test]
19808    fn layer_raster_cache_candidate_ignores_parent_transform() {
19809        let primitive = PrimitiveEntry {
19810            phase: PrimitivePhase::BeforeChildren,
19811            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19812                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19813                    rect: Rect {
19814                        x: 2.0,
19815                        y: 3.0,
19816                        width: 6.0,
19817                        height: 4.0,
19818                    },
19819                    brush: Brush::solid(Color::BLACK),
19820                    stroke: None,
19821                },
19822                clip: None,
19823            }),
19824        };
19825        let base = cacheable_layer(
19826            41,
19827            Rect {
19828                x: 0.0,
19829                y: 0.0,
19830                width: 20.0,
19831                height: 20.0,
19832            },
19833            vec![RenderNode::Primitive(primitive.clone())],
19834        );
19835        let mut moved = base.clone();
19836        moved.transform_to_parent = ProjectiveTransform::translation(32.0, 18.0);
19837
19838        assert_eq!(
19839            layer_raster_cache_candidate(&base, 1.25, false, false),
19840            layer_raster_cache_candidate(&moved, 1.25, false, false)
19841        );
19842    }
19843
19844    #[test]
19845    fn layer_raster_cache_candidate_changes_for_translated_content_offset() {
19846        let primitive = PrimitiveEntry {
19847            phase: PrimitivePhase::BeforeChildren,
19848            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19849                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19850                    rect: Rect {
19851                        x: 2.0,
19852                        y: 3.0,
19853                        width: 6.0,
19854                        height: 4.0,
19855                    },
19856                    brush: Brush::solid(Color::BLACK),
19857                    stroke: None,
19858                },
19859                clip: None,
19860            }),
19861        };
19862        let mut base = cacheable_layer(
19863            42,
19864            Rect {
19865                x: 0.0,
19866                y: 0.0,
19867                width: 20.0,
19868                height: 20.0,
19869            },
19870            vec![RenderNode::Primitive(primitive)],
19871        );
19872        base.translated_content_context = true;
19873        base.translated_content_offset = Point::new(0.0, -8.0);
19874        base.recompute_raster_cache_hashes();
19875
19876        let mut moved = base.clone();
19877        moved.translated_content_offset = Point::new(0.0, -16.0);
19878        moved.recompute_raster_cache_hashes();
19879
19880        assert_ne!(
19881            layer_raster_cache_candidate(&base, 1.25, false, false),
19882            layer_raster_cache_candidate(&moved, 1.25, false, false),
19883            "full-surface layer cache candidates must not alias different scroll offsets"
19884        );
19885    }
19886
19887    #[test]
19888    fn layer_raster_cache_candidate_changes_for_child_transform() {
19889        let mut child = cacheable_layer(
19890            8,
19891            Rect {
19892                x: 0.0,
19893                y: 0.0,
19894                width: 12.0,
19895                height: 10.0,
19896            },
19897            vec![],
19898        );
19899        child.transform_to_parent = ProjectiveTransform::translation(4.0, 6.0);
19900        let base = cacheable_layer(
19901            7,
19902            Rect {
19903                x: 0.0,
19904                y: 0.0,
19905                width: 20.0,
19906                height: 20.0,
19907            },
19908            vec![RenderNode::Layer(Box::new(child.clone()))],
19909        );
19910        let mut moved_child = child;
19911        moved_child.transform_to_parent = ProjectiveTransform::translation(9.0, 6.0);
19912        let moved = cacheable_layer(
19913            7,
19914            Rect {
19915                x: 0.0,
19916                y: 0.0,
19917                width: 20.0,
19918                height: 20.0,
19919            },
19920            vec![RenderNode::Layer(Box::new(moved_child))],
19921        );
19922
19923        assert_ne!(
19924            layer_raster_cache_candidate(&base, 1.0, false, false),
19925            layer_raster_cache_candidate(&moved, 1.0, false, false)
19926        );
19927    }
19928
19929    #[test]
19930    fn layer_raster_cache_candidate_rejects_external_backdrop_dependency() {
19931        let mut child = cacheable_layer(
19932            12,
19933            Rect {
19934                x: 0.0,
19935                y: 0.0,
19936                width: 8.0,
19937                height: 8.0,
19938            },
19939            vec![],
19940        );
19941        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
19942        let parent = cacheable_layer(
19943            11,
19944            Rect {
19945                x: 0.0,
19946                y: 0.0,
19947                width: 16.0,
19948                height: 16.0,
19949            },
19950            vec![RenderNode::Layer(Box::new(child))],
19951        );
19952
19953        assert!(layer_raster_cache_candidate(&parent, 1.0, false, false).is_some());
19954        assert!(layer_raster_cache_candidate(&parent, 1.0, true, false).is_none());
19955    }
19956
19957    #[test]
19958    fn layer_raster_cache_candidate_does_not_force_translation_only_text_surfaces() {
19959        let text = RenderNode::Primitive(PrimitiveEntry {
19960            phase: PrimitivePhase::BeforeChildren,
19961            node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
19962                node_id: 77,
19963                rect: Rect {
19964                    x: 2.0,
19965                    y: 3.0,
19966                    width: 48.0,
19967                    height: 18.0,
19968                },
19969                text: std::rc::Rc::new(AnnotatedString::from("runtime cache")),
19970                text_style: TextStyle::default(),
19971                font_size: 14.0,
19972                layout_options: TextLayoutOptions::default(),
19973                clip: None,
19974            })),
19975        });
19976        let mut layer = test_layer(
19977            Rect {
19978                x: 0.0,
19979                y: 0.0,
19980                width: 64.0,
19981                height: 32.0,
19982            },
19983            vec![text],
19984        );
19985        layer.node_id = Some(77);
19986        layer.recompute_raster_cache_hashes();
19987
19988        assert!(
19989            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
19990            "root path should not isolate plain translation-only text layers"
19991        );
19992        assert!(
19993            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
19994            "child path should also render plain translation-only text layers directly"
19995        );
19996    }
19997
19998    #[test]
19999    fn layer_raster_cache_candidate_allows_stable_runtime_child_effect_surfaces() {
20000        let mut layer = test_layer(
20001            Rect {
20002                x: 0.0,
20003                y: 0.0,
20004                width: 64.0,
20005                height: 32.0,
20006            },
20007            vec![RenderNode::Primitive(PrimitiveEntry {
20008                phase: PrimitivePhase::BeforeChildren,
20009                node: PrimitiveNode::Draw(DrawPrimitiveNode {
20010                    primitive: DrawPrimitive::Rect {
20011                        rect: Rect {
20012                            x: 0.0,
20013                            y: 0.0,
20014                            width: 64.0,
20015                            height: 32.0,
20016                        },
20017                        brush: Brush::solid(Color::WHITE),
20018                        stroke: None,
20019                    },
20020                    clip: None,
20021                }),
20022            })],
20023        );
20024        layer.node_id = Some(78);
20025        layer.graphics_layer.render_effect = Some(RenderEffect::blur(4.0));
20026        layer.recompute_raster_cache_hashes();
20027
20028        assert!(
20029            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
20030            "root direct path should not force-cache ordinary stable effects"
20031        );
20032        assert!(
20033            layer_raster_cache_candidate(&layer, 1.0, false, true).is_some(),
20034            "child surface rendering should retain stable non-runtime effects"
20035        );
20036    }
20037
20038    #[test]
20039    fn layer_raster_cache_candidate_rejects_runtime_shader_child_effect_surfaces() {
20040        let mut layer = test_layer(
20041            Rect {
20042                x: 0.0,
20043                y: 0.0,
20044                width: 64.0,
20045                height: 32.0,
20046            },
20047            vec![],
20048        );
20049        layer.node_id = Some(79);
20050        layer.graphics_layer.render_effect = Some(RenderEffect::runtime_shader(
20051            RuntimeShader::new("runtime shader"),
20052        ));
20053        layer.recompute_raster_cache_hashes();
20054
20055        assert!(
20056            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
20057            "runtime shaders must not fill the retained layer cache with per-frame uniform variants"
20058        );
20059    }
20060
20061    #[test]
20062    fn layer_surface_requirements_keep_plain_text_on_direct_path() {
20063        let layer = text_layer_with_style(AnnotatedString::from("plain"), TextStyle::default());
20064
20065        let requirements = layer_surface_requirements(&layer);
20066
20067        assert_eq!(requirements.direct_translation, Some(Point::default()));
20068        assert!(requirements
20069            .surface_requirements
20070            .contains(SurfaceRequirement::PixelStableComposite));
20071        assert!(!requirements
20072            .surface_requirements
20073            .has_isolating_requirement());
20074    }
20075
20076    #[test]
20077    fn layer_surface_requirements_keep_translated_plain_text_leaf_on_direct_path() {
20078        let layer = pure_text_leaf(false, true);
20079
20080        let requirements = layer_surface_requirements(&layer);
20081
20082        assert_eq!(
20083            requirements.direct_translation,
20084            Some(Point::new(11.4, 23.6))
20085        );
20086        assert!(
20087            requirements
20088                .surface_requirements
20089                .contains(SurfaceRequirement::PixelStableComposite)
20090                && !requirements
20091                    .surface_requirements
20092                    .has_isolating_requirement(),
20093            "translated plain text should stay on the direct path and isolate only the glyph draw"
20094        );
20095    }
20096
20097    #[test]
20098    fn layer_surface_requirements_keep_translated_text_leaf_with_background_on_direct_path() {
20099        let layer = snapped_text_leaf(false, true);
20100
20101        let requirements = layer_surface_requirements(&layer);
20102
20103        assert_eq!(
20104            requirements.direct_translation,
20105            Some(Point::new(14.25, 16.5))
20106        );
20107        assert!(
20108            requirements
20109                .surface_requirements
20110                .contains(SurfaceRequirement::PixelStableComposite)
20111                && !requirements
20112                    .surface_requirements
20113                    .has_isolating_requirement(),
20114            "translated text with direct sibling decoration/background should keep the layer direct"
20115        );
20116    }
20117
20118    #[test]
20119    fn translated_plain_text_uses_bounded_snap_surface() {
20120        let root = pure_text_leaf_root(true, true);
20121        let mut rect_cache = HashMap::new();
20122        let mut requirements_cache = HashMap::new();
20123        let collected =
20124            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20125
20126        assert_eq!(collected.child_layers.len(), 1);
20127        assert!(collected.scene.texts.is_empty());
20128        assert!(collected.scene.effect_layers.is_empty());
20129        assert_snap_anchor_close(
20130            collected.child_layers[0].snap_anchor,
20131            Point::new(11.4, 23.6),
20132            "translated plain text's bounded local surface should composite at the content-origin snap phase",
20133        );
20134    }
20135
20136    /// Not a correctness test: a local timing harness for the shape-run
20137    /// collect path. Run manually with
20138    /// `cargo test --release -p cranpose-render-wgpu -- --ignored collect_timing --nocapture`.
20139    #[test]
20140    #[ignore]
20141    fn shape_run_collect_timing_harness() {
20142        use cranpose_render_common::graph::DrawPrimitiveNode;
20143        use cranpose_render_common::layer_composition::local_content_layer_for;
20144        use cranpose_ui_graphics::Stroke;
20145
20146        let bounds = Rect {
20147            x: 0.0,
20148            y: 0.0,
20149            width: 1080.0,
20150            height: 2244.0,
20151        };
20152        let graphics_layer = GraphicsLayer::default();
20153
20154        // A MEGA-BOSS-shaped workload: thousands of consecutive arcs, most
20155        // solid, some gradient, one text-free layer.
20156        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
20157        for i in 0..3000u32 {
20158            let f = i as f32;
20159            let brush = if i % 8 == 0 {
20160                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
20161            } else {
20162                Brush::Solid(Color(0.5, 0.2, 0.8, 1.0))
20163            };
20164            let center = Point::new(540.0 + (f % 400.0), 1122.0 + (f % 350.0));
20165            let radius = 8.0 + (i % 23) as f32;
20166            let half = radius + 4.0;
20167            nodes.push(DrawPrimitiveNode {
20168                primitive: DrawPrimitive::Arc {
20169                    rect: Rect {
20170                        x: center.x - half,
20171                        y: center.y - half,
20172                        width: half * 2.0,
20173                        height: half * 2.0,
20174                    },
20175                    brush,
20176                    center,
20177                    radius,
20178                    start_angle: f * 0.07,
20179                    sweep_angle: 0.5 + (i % 5) as f32,
20180                    stroke: (i % 3 != 0).then(|| Stroke::new(4.0)),
20181                    inner_radius: if i % 3 == 0 { radius * 0.6 } else { 0.0 },
20182                },
20183                clip: None,
20184            });
20185        }
20186
20187        let children: Vec<RenderNode> = nodes
20188            .iter()
20189            .map(|node| {
20190                RenderNode::Primitive(PrimitiveEntry {
20191                    phase: PrimitivePhase::BeforeChildren,
20192                    node: PrimitiveNode::Draw(node.clone()),
20193                })
20194            })
20195            .collect();
20196        let layer = crate::test_support::layer_node(
20197            bounds,
20198            ProjectiveTransform::identity(),
20199            graphics_layer,
20200            children,
20201        );
20202
20203        const ITERS: usize = 300;
20204
20205        // Reference: the pre-run per-primitive path.
20206        let local_layer = local_content_layer_for(&layer.graphics_layer);
20207        let start = Instant::now();
20208        let mut sink_shapes = 0usize;
20209        for _ in 0..ITERS {
20210            let mut scene = CompositorScene::new();
20211            for node in &nodes {
20212                crate::pipeline::push_draw_primitive(
20213                    &node.primitive,
20214                    bounds,
20215                    &local_layer,
20216                    None,
20217                    &mut scene,
20218                    None,
20219                    false,
20220                );
20221            }
20222            sink_shapes = scene.shapes.len();
20223        }
20224        let serial = start.elapsed();
20225
20226        let mut rect_cache = HashMap::new();
20227        let mut requirements_cache = HashMap::new();
20228        let start = Instant::now();
20229        let mut run_shapes = 0usize;
20230        for _ in 0..ITERS {
20231            let collected = collect_layer_contents(
20232                &layer,
20233                None,
20234                None,
20235                &mut rect_cache,
20236                &mut requirements_cache,
20237            );
20238            run_shapes = collected.scene.shapes.len();
20239        }
20240        let run = start.elapsed();
20241
20242        println!(
20243            "per-primitive: {:?}/iter ({sink_shapes} shapes)  shape-run: {:?}/iter ({run_shapes} shapes)",
20244            serial / ITERS as u32,
20245            run / ITERS as u32,
20246        );
20247    }
20248
20249    /// Shared body for the serial and forced-parallel equivalence tests:
20250    fn assert_shape_run_collect_matches_per_primitive_emission() {
20251        use cranpose_render_common::graph::DrawPrimitiveNode;
20252        use cranpose_render_common::layer_composition::local_content_layer_for;
20253        use cranpose_render_common::primitive_emit::{resolve_primitive_clip, PrimitiveClipSpace};
20254        use cranpose_ui_graphics::{CornerRadii, Stroke};
20255
20256        let bounds = Rect {
20257            x: 0.0,
20258            y: 0.0,
20259            width: 800.0,
20260            height: 800.0,
20261        };
20262        // Rotation keeps rigid snapping off, so both paths agree on
20263        // `snap_anchor: None` without replicating the anchor computation here.
20264        let graphics_layer = GraphicsLayer {
20265            scale: 1.25,
20266            translation_x: 3.5,
20267            translation_y: -2.0,
20268            alpha: 0.9,
20269            rotation_z: 0.35,
20270            ..GraphicsLayer::default()
20271        };
20272
20273        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
20274        for i in 0..600u32 {
20275            let f = i as f32;
20276            let brush = if i % 11 == 0 {
20277                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
20278            } else {
20279                Brush::Solid(Color(0.1 + (i % 7) as f32 * 0.1, 0.5, 0.9, 1.0))
20280            };
20281            let stroke = (i % 5 == 0).then(|| Stroke::new(1.0 + (i % 3) as f32));
20282            let primitive = match i % 3 {
20283                0 => DrawPrimitive::Rect {
20284                    rect: Rect {
20285                        x: f % 37.0,
20286                        y: f % 53.0,
20287                        width: 8.0 + f % 9.0,
20288                        height: 6.0 + f % 5.0,
20289                    },
20290                    brush,
20291                    stroke,
20292                },
20293                1 => DrawPrimitive::RoundRect {
20294                    rect: Rect {
20295                        x: f % 41.0,
20296                        y: f % 43.0,
20297                        width: 12.0,
20298                        height: 10.0,
20299                    },
20300                    brush,
20301                    radii: CornerRadii::uniform(2.0 + (i % 4) as f32),
20302                    stroke,
20303                },
20304                _ => {
20305                    let center = Point::new(60.0 + f % 71.0, 60.0 + f % 67.0);
20306                    let radius = 5.0 + (i % 13) as f32;
20307                    // One degenerate sweep proves dropped draws stay dropped.
20308                    let sweep_angle = if i == 302 { 0.0 } else { 0.4 + (i % 6) as f32 };
20309                    let half = radius + 4.0;
20310                    DrawPrimitive::Arc {
20311                        rect: Rect {
20312                            x: center.x - half,
20313                            y: center.y - half,
20314                            width: half * 2.0,
20315                            height: half * 2.0,
20316                        },
20317                        brush,
20318                        center,
20319                        radius,
20320                        start_angle: f * 0.11,
20321                        sweep_angle,
20322                        stroke: (i % 2 == 0).then(|| Stroke::new(3.0)),
20323                        inner_radius: if i % 4 == 2 { radius * 0.5 } else { 0.0 },
20324                    }
20325                }
20326            };
20327            let primitive = if i == 300 {
20328                // A nested blend disqualifies the run view and forces a
20329                // mid-run flush through the serial path, splitting 600 draws
20330                // into two runs that are both long enough to fan out.
20331                DrawPrimitive::Blend {
20332                    primitive: Box::new(DrawPrimitive::Blend {
20333                        primitive: Box::new(primitive),
20334                        blend_mode: BlendMode::SrcOver,
20335                    }),
20336                    blend_mode: BlendMode::DstOut,
20337                }
20338            } else if i % 7 == 3 {
20339                DrawPrimitive::Blend {
20340                    primitive: Box::new(primitive),
20341                    blend_mode: BlendMode::DstOut,
20342                }
20343            } else {
20344                primitive
20345            };
20346            let clip = (i % 31 == 7).then_some(Rect {
20347                x: 0.0,
20348                y: 0.0,
20349                width: 30.0,
20350                height: 30.0,
20351            });
20352            nodes.push(DrawPrimitiveNode { primitive, clip });
20353        }
20354
20355        let children: Vec<RenderNode> = nodes
20356            .iter()
20357            .map(|node| {
20358                RenderNode::Primitive(PrimitiveEntry {
20359                    phase: PrimitivePhase::BeforeChildren,
20360                    node: PrimitiveNode::Draw(node.clone()),
20361                })
20362            })
20363            .collect();
20364        let layer = crate::test_support::layer_node(
20365            bounds,
20366            ProjectiveTransform::identity(),
20367            graphics_layer,
20368            children,
20369        );
20370
20371        let mut rect_cache = HashMap::new();
20372        let mut requirements_cache = HashMap::new();
20373        let collected =
20374            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
20375
20376        // The reference scene: every primitive through the per-primitive
20377        // emission path, exactly as the pre-run collect loop ran it.
20378        let local_layer = local_content_layer_for(&layer.graphics_layer);
20379        let mut expected = CompositorScene::new();
20380        for node in &nodes {
20381            let clip = resolve_primitive_clip(
20382                node.clip,
20383                bounds,
20384                &local_layer,
20385                None,
20386                PrimitiveClipSpace::Local,
20387            );
20388            if node.clip.is_some() && clip.is_none() {
20389                continue;
20390            }
20391            crate::pipeline::push_draw_primitive(
20392                &node.primitive,
20393                bounds,
20394                &local_layer,
20395                clip,
20396                &mut expected,
20397                None,
20398                false,
20399            );
20400        }
20401
20402        assert!(
20403            collected.scene.shapes.len() >= 590,
20404            "the runs should engage the parallel branch: got {} shapes",
20405            collected.scene.shapes.len()
20406        );
20407        assert_eq!(collected.scene.shapes.len(), expected.shapes.len());
20408        assert_eq!(collected.scene.draw_ops, expected.draw_ops);
20409        assert_eq!(collected.scene.next_z, expected.next_z);
20410        assert!(
20411            collected
20412                .scene
20413                .shapes
20414                .iter()
20415                .all(|s| s.snap_anchor.is_none()),
20416            "a rotated layer must not rigid-snap; the reference scene assumes it"
20417        );
20418        for (index, (got, want)) in collected
20419            .scene
20420            .shapes
20421            .iter()
20422            .zip(&expected.shapes)
20423            .enumerate()
20424        {
20425            assert_eq!(got.rect, want.rect, "shape {index} rect");
20426            assert_eq!(got.local_rect, want.local_rect, "shape {index} local_rect");
20427            assert_eq!(got.quad, want.quad, "shape {index} quad");
20428            assert_eq!(got.snap_anchor, want.snap_anchor, "shape {index} snap");
20429            assert_eq!(got.brush, want.brush, "shape {index} brush");
20430            assert_eq!(got.shape, want.shape, "shape {index} shape");
20431            assert_eq!(got.stroke, want.stroke, "shape {index} stroke");
20432            assert_eq!(got.arc, want.arc, "shape {index} arc");
20433            assert_eq!(got.z_index, want.z_index, "shape {index} z");
20434            assert_eq!(got.clip, want.clip, "shape {index} clip");
20435            assert_eq!(got.blend_mode, want.blend_mode, "shape {index} blend");
20436            assert_eq!(
20437                got.motion_context_animated, want.motion_context_animated,
20438                "shape {index} motion flag"
20439            );
20440        }
20441    }
20442
20443    /// The run collector must emit exactly what per-primitive emission does,
20444    /// on BOTH flush paths: the serial drain and the scoped-thread fan-out
20445    /// (forced via the tuning override, since a test-sized scene would never
20446    /// cross the size gate on its own).
20447    #[test]
20448    fn shape_run_collect_matches_per_primitive_emission_exactly() {
20449        assert_shape_run_collect_matches_per_primitive_emission();
20450        crate::normalized_scene::force_shape_run_parallel_for_tests(true);
20451        let outcome =
20452            std::panic::catch_unwind(assert_shape_run_collect_matches_per_primitive_emission);
20453        crate::normalized_scene::force_shape_run_parallel_for_tests(false);
20454        if let Err(payload) = outcome {
20455            std::panic::resume_unwind(payload);
20456        }
20457    }
20458
20459    #[test]
20460    fn non_translated_text_local_surface_keeps_linear_composite_resolve() {
20461        let layer = text_layer_with_style(
20462            AnnotatedString::from("gradient"),
20463            TextStyle::from_span_style(SpanStyle {
20464                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
20465                ..SpanStyle::default()
20466            }),
20467        );
20468        let requirements = layer_surface_requirements(&layer);
20469
20470        assert!(requirements
20471            .surface_requirements
20472            .contains(SurfaceRequirement::TextMaterialMask));
20473        assert_eq!(
20474            composite_sample_mode_for_requirements(false, false, requirements),
20475            CompositeSampleMode::Linear
20476        );
20477    }
20478
20479    #[test]
20480    fn inherited_translated_text_local_surface_uses_box4_layer_surface() {
20481        let layer = text_layer_with_style(
20482            AnnotatedString::from("shadow"),
20483            TextStyle::from_span_style(SpanStyle {
20484                shadow: Some(Shadow {
20485                    color: Color::BLACK,
20486                    offset: Point::new(1.0, 2.0),
20487                    blur_radius: 3.0,
20488                }),
20489                ..SpanStyle::default()
20490            }),
20491        );
20492        let requirements = layer_surface_requirements(&layer);
20493
20494        assert!(requirements
20495            .surface_requirements
20496            .contains(SurfaceRequirement::TextMaterialMask));
20497        assert_eq!(
20498            composite_sample_mode_for_requirements(true, false, requirements),
20499            CompositeSampleMode::Box4
20500        );
20501        assert_eq!(
20502            layer_surface_target_scale(
20503                true,
20504                false,
20505                requirements,
20506                1.25,
20507                layer_surface_scale(&layer)
20508            ),
20509            SurfaceRequirementSet::default()
20510                .with(SurfaceRequirement::TextMaterialMask)
20511                .with(SurfaceRequirement::MotionStableCapture)
20512                .target_scale(1.25, 1.0)
20513        );
20514    }
20515
20516    #[test]
20517    fn translated_text_local_surface_inside_capture_keeps_parent_scale() {
20518        let layer = text_layer_with_style(
20519            AnnotatedString::from("shadow"),
20520            TextStyle::from_span_style(SpanStyle {
20521                shadow: Some(Shadow {
20522                    color: Color::BLACK,
20523                    offset: Point::new(1.0, 2.0),
20524                    blur_radius: 3.0,
20525                }),
20526                ..SpanStyle::default()
20527            }),
20528        );
20529        let requirements = layer_surface_requirements(&layer);
20530
20531        assert_eq!(
20532            composite_sample_mode_for_requirements(true, true, requirements),
20533            CompositeSampleMode::Linear
20534        );
20535        assert_eq!(
20536            layer_surface_target_scale(true, true, requirements, 10.0, layer_surface_scale(&layer)),
20537            SurfaceRequirementSet::default()
20538                .with(SurfaceRequirement::TextMaterialMask)
20539                .target_scale(10.0, 1.0)
20540        );
20541    }
20542
20543    #[test]
20544    fn layer_surface_requirements_use_local_surface_for_gradient_and_stroke_text() {
20545        let cases = [
20546            (
20547                "draw_style",
20548                AnnotatedString::from("draw_style"),
20549                TextStyle::from_span_style(SpanStyle {
20550                    draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
20551                    ..SpanStyle::default()
20552                }),
20553            ),
20554            (
20555                "gradient_brush",
20556                AnnotatedString::from("gradient"),
20557                TextStyle::from_span_style(SpanStyle {
20558                    brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
20559                    ..SpanStyle::default()
20560                }),
20561            ),
20562        ];
20563
20564        for (label, text, text_style) in cases {
20565            let layer = text_layer_with_style(text, text_style);
20566            let requirements = layer_surface_requirements(&layer);
20567            assert!(
20568                requirements
20569                    .surface_requirements
20570                    .contains(SurfaceRequirement::TextMaterialMask),
20571                "{label} text should use a bounded local surface: {requirements:?}"
20572            );
20573        }
20574    }
20575
20576    #[test]
20577    fn layer_surface_requirements_use_local_surface_for_complex_text_effects() {
20578        let cases = [
20579            (
20580                "shadow",
20581                AnnotatedString::from("shadow"),
20582                TextStyle::from_span_style(SpanStyle {
20583                    shadow: Some(Shadow {
20584                        color: Color::BLACK,
20585                        offset: Point::new(1.0, 2.0),
20586                        blur_radius: 3.0,
20587                    }),
20588                    ..SpanStyle::default()
20589                }),
20590            ),
20591            (
20592                "background",
20593                AnnotatedString::from("background"),
20594                TextStyle::from_span_style(SpanStyle {
20595                    background: Some(Color::BLACK),
20596                    ..SpanStyle::default()
20597                }),
20598            ),
20599            (
20600                "baseline_shift",
20601                AnnotatedString::from("baseline_shift"),
20602                TextStyle::from_span_style(SpanStyle {
20603                    baseline_shift: Some(BaselineShift::SUPERSCRIPT),
20604                    ..SpanStyle::default()
20605                }),
20606            ),
20607            (
20608                "geometric_transform",
20609                AnnotatedString::from("geometric_transform"),
20610                TextStyle::from_span_style(SpanStyle {
20611                    text_geometric_transform: Some(TextGeometricTransform {
20612                        scale_x: 1.2,
20613                        skew_x: 0.15,
20614                    }),
20615                    ..SpanStyle::default()
20616                }),
20617            ),
20618            (
20619                "letter_spacing",
20620                AnnotatedString::from("letter_spacing"),
20621                TextStyle::from_span_style(SpanStyle {
20622                    letter_spacing: TextUnit::Em(0.2),
20623                    ..SpanStyle::default()
20624                }),
20625            ),
20626        ];
20627
20628        for (label, text, text_style) in cases {
20629            let layer = text_layer_with_style(text, text_style);
20630            let requirements = layer_surface_requirements(&layer);
20631            assert!(
20632                requirements
20633                    .surface_requirements
20634                    .contains(SurfaceRequirement::TextMaterialMask),
20635                "{label} text should use a bounded local surface: {requirements:?}"
20636            );
20637            assert_eq!(
20638                requirements.direct_translation,
20639                Some(Point::default()),
20640                "{label} text should still classify as a direct translation"
20641            );
20642        }
20643    }
20644
20645    #[test]
20646    fn layer_surface_requirements_color_only_span_styles_use_direct_path() {
20647        let layer = text_layer_with_style(
20648            AnnotatedString {
20649                text: "styled".to_string(),
20650                span_styles: vec![RangeStyle {
20651                    item: SpanStyle {
20652                        color: Some(Color::BLACK),
20653                        ..SpanStyle::default()
20654                    },
20655                    range: 0..3,
20656                }],
20657                ..AnnotatedString::default()
20658            },
20659            TextStyle::default(),
20660        );
20661        let requirements = layer_surface_requirements(&layer);
20662        assert!(
20663            !requirements
20664                .surface_requirements
20665                .contains(SurfaceRequirement::TextMaterialMask),
20666            "color-only span styles should render directly via software text raster colors"
20667        );
20668    }
20669
20670    #[test]
20671    fn layer_surface_requirements_keep_decoration_only_text_on_direct_path() {
20672        let layer = text_layer_with_style(
20673            AnnotatedString::from("decoration"),
20674            TextStyle::from_span_style(SpanStyle {
20675                text_decoration: Some(TextDecoration::UNDERLINE),
20676                ..SpanStyle::default()
20677            }),
20678        );
20679
20680        let requirements = layer_surface_requirements(&layer);
20681
20682        assert_eq!(requirements.direct_translation, Some(Point::default()));
20683        assert!(
20684            requirements
20685                .surface_requirements
20686                .contains(SurfaceRequirement::PixelStableComposite)
20687                && !requirements
20688                    .surface_requirements
20689                    .has_isolating_requirement(),
20690            "decoration-only text should not force an isolating layer surface: {requirements:?}"
20691        );
20692    }
20693
20694    #[test]
20695    fn direct_text_leaf_snaps_modifier_background_and_text_with_one_anchor() {
20696        let root = snapped_text_leaf_root(false, false);
20697        let mut rect_cache = HashMap::new();
20698        let mut requirements_cache = HashMap::new();
20699
20700        let collected =
20701            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20702
20703        assert_eq!(collected.scene.shapes.len(), 1);
20704        assert_eq!(collected.scene.images.len(), 1);
20705        assert_eq!(collected.scene.texts.len(), 1);
20706        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
20707        assert_eq!(collected.scene.shapes[0].snap_anchor, expected_anchor);
20708        assert_eq!(collected.scene.images[0].snap_anchor, expected_anchor);
20709        assert_eq!(collected.scene.texts[0].snap_anchor, expected_anchor);
20710    }
20711
20712    #[test]
20713    fn animated_translated_content_text_leaf_uses_bounded_content_snap() {
20714        let root = snapped_text_leaf_root(true, true);
20715        let mut rect_cache = HashMap::new();
20716        let mut requirements_cache = HashMap::new();
20717
20718        let collected =
20719            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20720
20721        assert_eq!(collected.child_layers.len(), 1);
20722        assert!(collected.scene.shapes.is_empty());
20723        assert!(collected.scene.images.is_empty());
20724        assert!(collected.scene.texts.is_empty());
20725        assert!(collected.scene.effect_layers.is_empty());
20726        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
20727        assert_eq!(
20728            collected.child_layers[0].snap_anchor, expected_anchor,
20729            "active translated leaf surface should keep the content-origin snap phase"
20730        );
20731    }
20732
20733    #[test]
20734    fn translated_content_assigns_motion_anchor_to_rotated_child_surface() {
20735        let mut child = snapped_text_leaf(false, false);
20736        child.graphics_layer.rotation_z = 5.0;
20737        child.transform_to_parent =
20738            cranpose_render_common::layer_transform::layer_transform_to_parent(
20739                child.local_bounds,
20740                Point::new(108.0, 3.0),
20741                &child.graphics_layer,
20742            );
20743        child.recompute_raster_cache_hashes();
20744        let mut root = test_layer(
20745            Rect {
20746                x: 0.0,
20747                y: 0.0,
20748                width: 320.0,
20749                height: 180.0,
20750            },
20751            vec![RenderNode::Layer(Box::new(child))],
20752        );
20753        root.translated_content_context = true;
20754        root.translated_content_offset = Point::new(0.0, -80.8);
20755        root.recompute_raster_cache_hashes();
20756        let mut rect_cache = HashMap::new();
20757        let mut requirements_cache = HashMap::new();
20758
20759        let collected =
20760            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20761
20762        assert_eq!(collected.child_layers.len(), 1);
20763        assert!(
20764            collected.child_layers[0].snap_anchor.is_some(),
20765            "a projective child still translates rigidly with its scrolling parent"
20766        );
20767    }
20768
20769    #[test]
20770    fn rested_translated_content_context_text_leaf_snaps_for_crisp_scroll_rest() {
20771        let root = snapped_text_leaf_root(false, true);
20772        let mut rect_cache = HashMap::new();
20773        let mut requirements_cache = HashMap::new();
20774
20775        let collected =
20776            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20777
20778        assert_eq!(collected.child_layers.len(), 0);
20779        assert_eq!(collected.scene.shapes.len(), 1);
20780        assert_eq!(collected.scene.images.len(), 1);
20781        assert_eq!(collected.scene.texts.len(), 1);
20782        assert_eq!(collected.scene.effect_layers.len(), 0);
20783        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
20784        assert_eq!(
20785            collected.scene.shapes[0].snap_anchor, expected_anchor,
20786            "rested scroll content should snap back to device pixels"
20787        );
20788        assert_eq!(
20789            collected.scene.images[0].snap_anchor, expected_anchor,
20790            "rested scroll images should snap back to device pixels"
20791        );
20792        assert_eq!(
20793            collected.scene.texts[0].snap_anchor, expected_anchor,
20794            "rested scroll text should snap back to device pixels"
20795        );
20796    }
20797
20798    #[test]
20799    fn complex_text_uses_local_surface() {
20800        let root = translated_content_local_surface_root();
20801        let mut rect_cache = HashMap::new();
20802        let mut requirements_cache = HashMap::new();
20803
20804        let collected =
20805            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20806
20807        assert!(
20808            !collected.child_layers.is_empty(),
20809            "translated-content effectful text should render through a bounded local surface"
20810        );
20811        assert!(collected.scene.texts.is_empty());
20812        assert!(collected.scene.shadow_draws.is_empty());
20813    }
20814
20815    #[test]
20816    fn translated_content_surface_composite_uses_scroll_content_snap_anchor() {
20817        let mut root = translated_content_local_surface_root();
20818        let scroll_offset = Point::new(0.0, -18.5);
20819        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
20820            panic!("expected translated content layer");
20821        };
20822        translated_content.translated_content_offset = scroll_offset;
20823        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
20824            panic!("expected effectful text layer");
20825        };
20826        effectful_text.transform_to_parent =
20827            effectful_text
20828                .transform_to_parent
20829                .then(ProjectiveTransform::translation(
20830                    scroll_offset.x,
20831                    scroll_offset.y,
20832                ));
20833
20834        let mut rect_cache = HashMap::new();
20835        let mut requirements_cache = HashMap::new();
20836        let collected =
20837            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20838
20839        assert_eq!(collected.child_layers.len(), 1);
20840        assert_eq!(
20841            collected.child_layers[0].snap_anchor,
20842            Some(SnapAnchor::rigid(Point::new(14.25, -2.0))),
20843            "isolated scrolled descendants must composite with the same content-origin snap phase"
20844        );
20845    }
20846
20847    #[test]
20848    fn animated_translated_content_surface_composite_uses_scroll_content_snap_anchor() {
20849        let mut root = translated_content_local_surface_root();
20850        let scroll_offset = Point::new(0.0, -18.5);
20851        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
20852            panic!("expected translated content layer");
20853        };
20854        translated_content.motion_context_animated = true;
20855        translated_content.translated_content_offset = scroll_offset;
20856        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
20857            panic!("expected effectful text layer");
20858        };
20859        effectful_text.transform_to_parent =
20860            effectful_text
20861                .transform_to_parent
20862                .then(ProjectiveTransform::translation(
20863                    scroll_offset.x,
20864                    scroll_offset.y,
20865                ));
20866
20867        let mut rect_cache = HashMap::new();
20868        let mut requirements_cache = HashMap::new();
20869        let collected =
20870            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20871
20872        assert_eq!(collected.child_layers.len(), 1);
20873        assert_eq!(
20874            collected.child_layers[0].snap_anchor,
20875            Some(SnapAnchor::rigid(Point::new(14.25, 16.5))),
20876            "animated translated content should composite the stable local surface at the viewport-origin snap phase"
20877        );
20878    }
20879
20880    #[test]
20881    fn translated_text_material_effect_layer_uses_scroll_content_snap_anchor() {
20882        let mut layer = text_layer_with_style(
20883            AnnotatedString::from("gradient"),
20884            TextStyle::from_span_style(SpanStyle {
20885                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
20886                ..SpanStyle::default()
20887            }),
20888        );
20889        layer.translated_content_context = true;
20890        layer.translated_content_offset = Point::new(0.0, -18.5);
20891        let mut rect_cache = HashMap::new();
20892        let mut requirements_cache = HashMap::new();
20893
20894        let collected =
20895            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
20896
20897        assert_eq!(collected.scene.effect_layers.len(), 1);
20898        assert_eq!(
20899            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
20900            CompositeSampleMode::Box4
20901        );
20902        assert_eq!(
20903            collected.scene.effect_layers[0].snap_anchor,
20904            Some(SnapAnchor::rigid(Point::new(0.0, -18.5))),
20905            "text material surfaces must composite with the scroll content-origin snap phase"
20906        );
20907    }
20908
20909    #[test]
20910    fn translated_layer_surface_capture_does_not_restart_local_picture_for_shadow_text() {
20911        let mut layer = text_layer_with_style(
20912            AnnotatedString::from("shadow"),
20913            TextStyle::from_span_style(SpanStyle {
20914                shadow: Some(Shadow {
20915                    color: Color::BLACK,
20916                    offset: Point::new(1.0, 2.0),
20917                    blur_radius: 3.0,
20918                }),
20919                ..SpanStyle::default()
20920            }),
20921        );
20922        layer.translated_content_context = true;
20923        let mut rect_cache = HashMap::new();
20924        let mut requirements_cache = HashMap::new();
20925
20926        let collected = collect_layer_contents_with_translation_context(
20927            &layer,
20928            None,
20929            None,
20930            TranslationRenderContext {
20931                inherited_content_translation: false,
20932                surface_capture_active: true,
20933                local_picture_capture_active: true,
20934                ..TranslationRenderContext::default()
20935            },
20936            &mut rect_cache,
20937            &mut requirements_cache,
20938        );
20939
20940        assert!(
20941            collected.scene.effect_layers.is_empty(),
20942            "a translated layer surface already provides the stable local capture"
20943        );
20944        assert_eq!(collected.scene.shadow_draws.len(), 1);
20945        assert_eq!(collected.scene.texts.len(), 1);
20946        assert!(
20947            !collected.scene.texts[0].translated_content_context,
20948            "text inside an active motion-stable capture must raster in capture-local coordinates"
20949        );
20950    }
20951
20952    #[test]
20953    fn translated_layer_surface_capture_keeps_only_material_effect_layers() {
20954        let mut layer = text_layer_with_style(
20955            AnnotatedString::from("gradient"),
20956            TextStyle::from_span_style(SpanStyle {
20957                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
20958                ..SpanStyle::default()
20959            }),
20960        );
20961        layer.translated_content_context = true;
20962        let mut rect_cache = HashMap::new();
20963        let mut requirements_cache = HashMap::new();
20964
20965        let collected = collect_layer_contents_with_translation_context(
20966            &layer,
20967            None,
20968            None,
20969            TranslationRenderContext {
20970                inherited_content_translation: false,
20971                surface_capture_active: true,
20972                local_picture_capture_active: true,
20973                ..TranslationRenderContext::default()
20974            },
20975            &mut rect_cache,
20976            &mut requirements_cache,
20977        );
20978
20979        assert_eq!(collected.scene.effect_layers.len(), 1);
20980        assert!(
20981            collected.scene.effect_layers[0]
20982                .requirements
20983                .contains(SurfaceRequirement::MotionStableCapture),
20984            "translated text materials still need motion-stable resolve semantics inside a stable capture"
20985        );
20986        assert_eq!(
20987            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
20988            CompositeSampleMode::Box4
20989        );
20990        assert_eq!(
20991            effect_layer_target_scale(&collected.scene.effect_layers[0], 10.0),
20992            10.0
20993        );
20994        assert!(collected.scene.effect_layers[0].effect.is_some());
20995    }
20996
20997    #[test]
20998    fn translated_viewport_surface_does_not_add_plain_local_picture_capture() {
20999        let mut layer = text_layer_with_style(
21000            AnnotatedString::from("shadow"),
21001            TextStyle::from_span_style(SpanStyle {
21002                shadow: Some(Shadow {
21003                    color: Color::BLACK,
21004                    offset: Point::new(1.0, 2.0),
21005                    blur_radius: 3.0,
21006                }),
21007                ..SpanStyle::default()
21008            }),
21009        );
21010        layer.translated_content_context = true;
21011        layer.motion_context_animated = true;
21012        let mut rect_cache = HashMap::new();
21013        let mut requirements_cache = HashMap::new();
21014
21015        let collected = collect_layer_contents_with_translation_context(
21016            &layer,
21017            None,
21018            None,
21019            TranslationRenderContext {
21020                surface_capture_active: true,
21021                ..TranslationRenderContext::default()
21022            },
21023            &mut rect_cache,
21024            &mut requirements_cache,
21025        );
21026
21027        assert_eq!(
21028            collected.scene.effect_layers.len(),
21029            0,
21030            "plain translated content inside a viewport surface should not be captured again"
21031        );
21032        assert_eq!(collected.scene.shadow_draws.len(), 1);
21033        assert_eq!(collected.scene.texts.len(), 1);
21034    }
21035
21036    #[test]
21037    fn static_pure_text_leaf_snaps_without_sibling_draw_primitives() {
21038        let root = pure_text_leaf_root(false, false);
21039        let mut rect_cache = HashMap::new();
21040        let mut requirements_cache = HashMap::new();
21041
21042        let collected =
21043            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21044
21045        assert_eq!(collected.scene.texts.len(), 1);
21046        assert!(
21047            collected.scene.texts[0].snap_anchor.is_some(),
21048            "idle pure text leaves should participate in rigid snap anchoring"
21049        );
21050    }
21051
21052    #[test]
21053    fn animated_pure_text_leaf_stays_unsnapped() {
21054        let root = pure_text_leaf_root(true, false);
21055        let mut rect_cache = HashMap::new();
21056        let mut requirements_cache = HashMap::new();
21057
21058        let collected =
21059            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21060
21061        assert_eq!(collected.scene.texts.len(), 1);
21062        assert_eq!(collected.scene.texts[0].snap_anchor, None);
21063    }
21064
21065    #[test]
21066    fn animated_translated_pure_text_uses_bounded_content_snap() {
21067        let root = pure_text_leaf_root(true, true);
21068        let mut rect_cache = HashMap::new();
21069        let mut requirements_cache = HashMap::new();
21070
21071        let collected =
21072            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21073
21074        assert_eq!(collected.child_layers.len(), 1);
21075        assert!(collected.scene.texts.is_empty());
21076        assert!(collected.scene.effect_layers.is_empty());
21077        assert_snap_anchor_close(
21078            collected.child_layers[0].snap_anchor,
21079            Point::new(11.4, 23.6),
21080            "animated translated pure text should use the bounded content snap phase",
21081        );
21082    }
21083
21084    #[test]
21085    fn rested_translated_pure_text_leaf_snaps_for_crisp_scroll_rest() {
21086        let root = pure_text_leaf_root(false, true);
21087        let mut rect_cache = HashMap::new();
21088        let mut requirements_cache = HashMap::new();
21089
21090        let collected =
21091            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21092
21093        assert_eq!(collected.child_layers.len(), 0);
21094        assert_eq!(collected.scene.texts.len(), 1);
21095        assert_eq!(collected.scene.effect_layers.len(), 0);
21096        assert_snap_anchor_close(
21097            collected.scene.texts[0].snap_anchor,
21098            Point::new(11.4, 23.6),
21099            "rested translated text should snap to device pixels",
21100        );
21101    }
21102
21103    #[test]
21104    fn static_gpu_effect_text_leaf_stays_unsnapped() {
21105        let root = text_layer_with_style(
21106            AnnotatedString::from("Gradient"),
21107            TextStyle::from_span_style(SpanStyle {
21108                brush: Some(Brush::linear_gradient(vec![
21109                    Color(0.2, 0.8, 1.0, 1.0),
21110                    Color(1.0, 0.7, 0.4, 1.0),
21111                ])),
21112                draw_style: Some(TextDrawStyle::Stroke { width: 2.5 }),
21113                ..SpanStyle::default()
21114            }),
21115        );
21116        let mut rect_cache = HashMap::new();
21117        let mut requirements_cache = HashMap::new();
21118
21119        let collected =
21120            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21121
21122        assert_eq!(collected.scene.texts.len(), 1);
21123        assert_eq!(
21124            collected.scene.texts[0].snap_anchor, None,
21125            "gpu text-effect leaves must not take the rigid text snap path"
21126        );
21127        assert_eq!(
21128            collected.scene.effect_layers.len(),
21129            1,
21130            "gradient stroke text should still emit a runtime shader effect layer"
21131        );
21132    }
21133
21134    #[test]
21135    fn layer_surface_requirements_keep_shape_plus_direct_child_on_direct_path() {
21136        let mut child = test_layer(
21137            Rect {
21138                x: 0.0,
21139                y: 0.0,
21140                width: 40.0,
21141                height: 20.0,
21142            },
21143            vec![RenderNode::Primitive(PrimitiveEntry {
21144                phase: PrimitivePhase::BeforeChildren,
21145                node: PrimitiveNode::Draw(DrawPrimitiveNode {
21146                    primitive: DrawPrimitive::Rect {
21147                        rect: Rect {
21148                            x: 0.0,
21149                            y: 0.0,
21150                            width: 40.0,
21151                            height: 20.0,
21152                        },
21153                        brush: Brush::solid(Color::WHITE),
21154                        stroke: None,
21155                    },
21156                    clip: None,
21157                }),
21158            })],
21159        );
21160        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
21161
21162        let layer = test_layer(
21163            Rect {
21164                x: 0.0,
21165                y: 0.0,
21166                width: 64.0,
21167                height: 32.0,
21168            },
21169            vec![
21170                RenderNode::Primitive(PrimitiveEntry {
21171                    phase: PrimitivePhase::BeforeChildren,
21172                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
21173                        primitive: DrawPrimitive::Rect {
21174                            rect: Rect {
21175                                x: 0.0,
21176                                y: 0.0,
21177                                width: 64.0,
21178                                height: 32.0,
21179                            },
21180                            brush: Brush::solid(Color::BLACK),
21181                            stroke: None,
21182                        },
21183                        clip: None,
21184                    }),
21185                }),
21186                RenderNode::Layer(Box::new(child)),
21187            ],
21188        );
21189
21190        let requirements = layer_surface_requirements(&layer);
21191
21192        assert_eq!(requirements.direct_translation, Some(Point::default()));
21193        assert!(!requirements
21194            .surface_requirements
21195            .contains(SurfaceRequirement::MixedDirectContent));
21196        assert!(!requirements
21197            .surface_requirements
21198            .has_isolating_requirement());
21199    }
21200
21201    #[test]
21202    fn collect_layer_contents_translates_direct_text_rects_into_parent_space() {
21203        let mut child = text_layer_with_style(
21204            AnnotatedString::from("direct"),
21205            TextStyle::from_span_style(SpanStyle {
21206                text_decoration: Some(TextDecoration::UNDERLINE),
21207                ..SpanStyle::default()
21208            }),
21209        );
21210        child.transform_to_parent = ProjectiveTransform::translation(9.0, 7.0);
21211
21212        let parent = test_layer(
21213            Rect {
21214                x: 0.0,
21215                y: 0.0,
21216                width: 64.0,
21217                height: 32.0,
21218            },
21219            vec![RenderNode::Layer(Box::new(child))],
21220        );
21221
21222        let mut rect_cache = HashMap::new();
21223        let mut requirements_cache = HashMap::new();
21224        let collected = with_test_app_context(|| {
21225            collect_layer_contents(
21226                &parent,
21227                None,
21228                None,
21229                &mut rect_cache,
21230                &mut requirements_cache,
21231            )
21232        });
21233
21234        assert!(
21235            collected.child_layers.is_empty(),
21236            "decoration-only text child should collapse directly into the parent scene"
21237        );
21238        assert_eq!(collected.scene.texts.len(), 1, "expected one text draw");
21239        let text = &collected.scene.texts[0];
21240        assert!(
21241            text.rect.x >= 9.0 && text.rect.y >= 7.0,
21242            "collapsed text rect should be translated into parent space, got {:?}",
21243            text.rect
21244        );
21245        assert!(
21246            collected
21247                .scene
21248                .shapes
21249                .iter()
21250                .any(|shape| shape.rect.y >= 7.0),
21251            "collapsed underline geometry should also be translated into parent space"
21252        );
21253    }
21254
21255    #[test]
21256    fn normalized_scene_keeps_lazy_after_bound_text_for_prewarm() {
21257        use std::cell::RefCell;
21258
21259        fn collect_graph_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
21260            for child in &layer.children {
21261                match child {
21262                    RenderNode::Primitive(PrimitiveEntry {
21263                        node: PrimitiveNode::Text(text),
21264                        ..
21265                    }) => labels.push(text.text.text.clone()),
21266                    RenderNode::Layer(child_layer) => {
21267                        collect_graph_text_labels(child_layer, labels)
21268                    }
21269                    RenderNode::Primitive(_) | RenderNode::DrawRun(_) => {}
21270                }
21271            }
21272        }
21273
21274        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
21275        let state_holder_for_comp = state_holder.clone();
21276        let mut composition = cranpose_ui::run_test_composition(move || {
21277            let list_state = remember_lazy_list_state();
21278            *state_holder_for_comp.borrow_mut() = Some(list_state);
21279            let mut spec = LazyColumnSpec::new()
21280                .vertical_arrangement(cranpose_ui::LinearArrangement::SpacedBy(6.0));
21281            spec.beyond_bounds_item_count = 0;
21282            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
21283                scope.items(
21284                    12,
21285                    None::<fn(usize) -> u64>,
21286                    None::<fn(usize) -> u64>,
21287                    |index| {
21288                        Text(
21289                            format!("WarmRow {index}"),
21290                            Modifier::empty().height(32.0),
21291                            TextStyle::default(),
21292                        );
21293                    },
21294                );
21295            });
21296        });
21297
21298        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
21299        list_state.scroll_to_item(4, 0.0);
21300
21301        let root = composition.root().expect("lazy column root");
21302        let handle = composition.runtime_handle();
21303        let mut applier = composition.applier_mut();
21304        applier.set_runtime_handle(handle);
21305        let _ = applier
21306            .compute_layout(
21307                root,
21308                Size {
21309                    width: 240.0,
21310                    height: 240.0,
21311                },
21312            )
21313            .expect("lazy column layout");
21314        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
21315        applier.clear_runtime_handle();
21316        let mut graph_labels = Vec::new();
21317        collect_graph_text_labels(&graph.root, &mut graph_labels);
21318
21319        let visible_indices: Vec<_> = list_state
21320            .layout_info()
21321            .visible_items_info
21322            .iter()
21323            .map(|item| item.index)
21324            .collect();
21325        assert_eq!(
21326            visible_indices,
21327            vec![4, 5, 6],
21328            "test setup expects exactly three viewport-visible rows"
21329        );
21330
21331        let mut rect_cache = HashMap::new();
21332        let mut requirements_cache = HashMap::new();
21333        let collected = with_test_app_context(|| {
21334            collect_layer_contents(
21335                &graph.root,
21336                None,
21337                None,
21338                &mut rect_cache,
21339                &mut requirements_cache,
21340            )
21341        });
21342        let root_text_labels: Vec<_> = collected
21343            .scene
21344            .texts
21345            .iter()
21346            .map(|text| text.text.text.clone())
21347            .collect();
21348        let child_layer_count = collected.child_layers.len();
21349        let warm_text = collected
21350            .scene
21351            .texts
21352            .iter()
21353            .find(|text| text.text.text == "WarmRow 7")
21354            .unwrap_or_else(|| {
21355                panic!(
21356                    "after-bound lazy text should reach WGPU scene collection; graph_texts={graph_labels:?} root_texts={root_text_labels:?} child_layers={child_layer_count}"
21357                )
21358            });
21359
21360        assert!(
21361            warm_text.rect.y >= 96.0,
21362            "after-bound text should be below the viewport, got {:?}",
21363            warm_text.rect
21364        );
21365        assert_eq!(
21366            visible_draw_rect(warm_text.rect, warm_text.clip),
21367            None,
21368            "after-bound text should remain clipped away for drawing while staying available for glyph prewarm"
21369        );
21370        assert!(
21371            text_draw_should_prewarm_in_viewport(
21372                warm_text.rect,
21373                warm_text.clip,
21374                ViewportUniformParams {
21375                    width: 240,
21376                    height: 96,
21377                    offset: [0.0, 0.0],
21378                },
21379                1.0,
21380            ),
21381            "after-bound text inside the warm window must be selected by WGPU prewarm"
21382        );
21383    }
21384
21385    #[test]
21386    fn direct_translation_accepts_nearly_identity_axis_scale_noise() {
21387        let local_bounds = Rect {
21388            x: 0.0,
21389            y: 0.0,
21390            width: 393.3,
21391            height: 16.8,
21392        };
21393        let quad = [
21394            [10.0, 78.399_994],
21395            [403.3, 78.399_994],
21396            [10.0, 95.2],
21397            [403.3, 95.2],
21398        ];
21399        let transform = ProjectiveTransform::from_rect_to_quad(local_bounds, quad);
21400
21401        assert_eq!(
21402            direct_translation(transform),
21403            Some(Point::new(10.0, 78.399_994)),
21404        );
21405    }
21406
21407    #[test]
21408    fn layer_surface_requirements_keep_shape_plus_isolating_child_as_mixed_content() {
21409        let mut child = test_layer(
21410            Rect {
21411                x: 0.0,
21412                y: 0.0,
21413                width: 24.0,
21414                height: 18.0,
21415            },
21416            vec![RenderNode::Primitive(PrimitiveEntry {
21417                phase: PrimitivePhase::BeforeChildren,
21418                node: PrimitiveNode::Draw(DrawPrimitiveNode {
21419                    primitive: DrawPrimitive::Rect {
21420                        rect: Rect {
21421                            x: 0.0,
21422                            y: 0.0,
21423                            width: 24.0,
21424                            height: 18.0,
21425                        },
21426                        brush: Brush::solid(Color::WHITE),
21427                        stroke: None,
21428                    },
21429                    clip: None,
21430                }),
21431            })],
21432        );
21433        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
21434        child.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
21435
21436        let layer = test_layer(
21437            Rect {
21438                x: 0.0,
21439                y: 0.0,
21440                width: 64.0,
21441                height: 32.0,
21442            },
21443            vec![
21444                RenderNode::Primitive(PrimitiveEntry {
21445                    phase: PrimitivePhase::BeforeChildren,
21446                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
21447                        primitive: DrawPrimitive::Rect {
21448                            rect: Rect {
21449                                x: 0.0,
21450                                y: 0.0,
21451                                width: 64.0,
21452                                height: 32.0,
21453                            },
21454                            brush: Brush::solid(Color::BLACK),
21455                            stroke: None,
21456                        },
21457                        clip: None,
21458                    }),
21459                }),
21460                RenderNode::Layer(Box::new(child)),
21461            ],
21462        );
21463
21464        let requirements = layer_surface_requirements(&layer);
21465
21466        assert!(requirements
21467            .surface_requirements
21468            .contains(SurfaceRequirement::MixedDirectContent));
21469        assert!(!requirements
21470            .surface_requirements
21471            .has_isolating_requirement());
21472    }
21473
21474    #[test]
21475    fn build_scene_window_filters_and_translates_items() {
21476        let mut shape = test_shape(6, BlendMode::SrcOver);
21477        shape.rect.x = 12.0;
21478        shape.rect.y = 25.0;
21479        shape.local_rect.x = 12.0;
21480        shape.local_rect.y = 25.0;
21481        shape.quad = [[12.0, 25.0], [20.0, 25.0], [12.0, 33.0], [20.0, 33.0]];
21482        shape.clip = Some(Rect {
21483            x: 11.0,
21484            y: 24.0,
21485            width: 10.0,
21486            height: 10.0,
21487        });
21488
21489        let mut image = test_image(8, BlendMode::SrcOver);
21490        image.rect.x = 18.0;
21491        image.rect.y = 27.0;
21492        image.local_rect.x = 18.0;
21493        image.local_rect.y = 27.0;
21494        image.quad = [[18.0, 27.0], [26.0, 27.0], [18.0, 35.0], [26.0, 35.0]];
21495
21496        let mut text = test_text(9);
21497        text.rect.x = 16.0;
21498        text.rect.y = 29.0;
21499        text.clip = Some(Rect {
21500            x: 15.0,
21501            y: 28.0,
21502            width: 9.0,
21503            height: 6.0,
21504        });
21505
21506        let mut shadow_shape = test_shape(7, BlendMode::SrcOver);
21507        shadow_shape.rect.x = 14.0;
21508        shadow_shape.rect.y = 26.0;
21509        shadow_shape.local_rect.x = 14.0;
21510        shadow_shape.local_rect.y = 26.0;
21511        shadow_shape.quad = [[14.0, 26.0], [22.0, 26.0], [14.0, 34.0], [22.0, 34.0]];
21512        let mut shadow = test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]);
21513        shadow.z_index = 7;
21514
21515        let mut nested_effect = effect_layer(6, 10);
21516        nested_effect.rect.x = 13.0;
21517        nested_effect.rect.y = 24.0;
21518        nested_effect.clip = Some(Rect {
21519            x: 15.0,
21520            y: 25.0,
21521            width: 4.0,
21522            height: 5.0,
21523        });
21524
21525        let mut nested_backdrop = backdrop_layer(8);
21526        nested_backdrop.rect.x = 17.0;
21527        nested_backdrop.rect.y = 26.0;
21528        nested_backdrop.clip = Some(Rect {
21529            x: 18.0,
21530            y: 27.0,
21531            width: 3.0,
21532            height: 4.0,
21533        });
21534
21535        let window = build_scene_window(
21536            SceneWindowSource {
21537                shapes: &[test_shape(4, BlendMode::SrcOver), shape],
21538                brushes: &[],
21539                images: &[image],
21540                texts: &[text],
21541                shadow_draws: &[shadow],
21542                draw_ops: &[],
21543                effect_layers: &[effect_layer(2, 4), nested_effect.clone()],
21544                backdrop_layers: &[backdrop_layer(4), nested_backdrop.clone()],
21545            },
21546            5,
21547            10,
21548            Rect {
21549                x: 10.0,
21550                y: 20.0,
21551                width: 20.0,
21552                height: 20.0,
21553            },
21554        );
21555
21556        assert_eq!(window.shapes.len(), 1);
21557        assert_eq!(
21558            window.shapes[0].rect,
21559            Rect {
21560                x: 2.0,
21561                y: 5.0,
21562                width: 8.0,
21563                height: 8.0,
21564            }
21565        );
21566        assert_eq!(
21567            window.shapes[0].clip,
21568            Some(Rect {
21569                x: 1.0,
21570                y: 4.0,
21571                width: 10.0,
21572                height: 10.0,
21573            })
21574        );
21575        assert_eq!(window.images.len(), 1);
21576        assert_eq!(window.images[0].rect.x, 8.0);
21577        assert_eq!(window.images[0].rect.y, 7.0);
21578        assert_eq!(window.texts.len(), 1);
21579        assert_eq!(window.texts[0].rect.x, 6.0);
21580        assert_eq!(window.texts[0].rect.y, 9.0);
21581        assert_eq!(
21582            window.texts[0].clip,
21583            Some(Rect {
21584                x: 5.0,
21585                y: 8.0,
21586                width: 9.0,
21587                height: 6.0,
21588            })
21589        );
21590        assert_eq!(window.shadow_draws.len(), 1);
21591        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.x, 4.0);
21592        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.y, 6.0);
21593        assert_eq!(window.effect_layers.len(), 1);
21594        assert_eq!(
21595            window.effect_layers[0].rect,
21596            Rect {
21597                x: 3.0,
21598                y: 4.0,
21599                width: 10.0,
21600                height: 10.0,
21601            }
21602        );
21603        assert_eq!(
21604            window.effect_layers[0].clip,
21605            Some(Rect {
21606                x: 5.0,
21607                y: 5.0,
21608                width: 4.0,
21609                height: 5.0,
21610            })
21611        );
21612        assert_eq!(window.backdrop_layers.len(), 1);
21613        assert_eq!(
21614            window.backdrop_layers[0].rect,
21615            Rect {
21616                x: 7.0,
21617                y: 6.0,
21618                width: 10.0,
21619                height: 10.0,
21620            }
21621        );
21622        assert_eq!(
21623            window.backdrop_layers[0].clip,
21624            Some(Rect {
21625                x: 8.0,
21626                y: 7.0,
21627                width: 3.0,
21628                height: 4.0,
21629            })
21630        );
21631    }
21632
21633    #[test]
21634    fn filtered_effect_layer_index_counts_only_window_members() {
21635        let effects = vec![
21636            effect_layer(0, 2),
21637            effect_layer(5, 12),
21638            effect_layer(6, 10),
21639            effect_layer(14, 20),
21640        ];
21641
21642        assert_eq!(filtered_effect_layer_index(&effects, 1, 5, 12), Some(0));
21643        assert_eq!(filtered_effect_layer_index(&effects, 2, 5, 12), Some(1));
21644        assert_eq!(filtered_effect_layer_index(&effects, 3, 5, 12), None);
21645    }
21646
21647    #[test]
21648    fn blend_mode_support_matrix_is_explicit() {
21649        assert!(is_blend_mode_supported(BlendMode::SrcOver));
21650        assert!(is_blend_mode_supported(BlendMode::DstOut));
21651        assert!(!is_blend_mode_supported(BlendMode::Clear));
21652        assert!(!is_blend_mode_supported(BlendMode::Multiply));
21653    }
21654
21655    #[test]
21656    fn collect_non_effect_segment_items_preserves_global_z_order() {
21657        let shapes = vec![
21658            test_shape(3, BlendMode::SrcOver),
21659            test_shape(1, BlendMode::DstOut),
21660        ];
21661        let images = vec![test_image(2, BlendMode::SrcOver)];
21662        let texts = vec![test_text(0)];
21663        let shadows: Vec<ShadowDraw> = Vec::new();
21664        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
21665
21666        let mut scratch = Vec::new();
21667        collect_non_effect_segment_items(
21668            &shapes,
21669            &images,
21670            &texts,
21671            &shadows,
21672            &draw_ops,
21673            0,
21674            4,
21675            &[],
21676            100,
21677            100,
21678            1.0,
21679            &mut scratch,
21680        );
21681        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
21682        assert_eq!(
21683            items,
21684            vec![
21685                SegmentDrawItem::Text(0),
21686                SegmentDrawItem::Shape(1),
21687                SegmentDrawItem::Image(0),
21688                SegmentDrawItem::Shape(0),
21689            ]
21690        );
21691    }
21692
21693    #[test]
21694    fn collect_non_effect_segment_items_filters_effect_ranges() {
21695        let shapes = vec![
21696            test_shape(1, BlendMode::SrcOver),
21697            test_shape(3, BlendMode::DstOut),
21698        ];
21699        let images = vec![test_image(2, BlendMode::SrcOver)];
21700        let texts = vec![test_text(4)];
21701        let shadows: Vec<ShadowDraw> = Vec::new();
21702        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
21703        let effect_ranges = [std::ops::Range { start: 2, end: 4 }];
21704
21705        let mut scratch = Vec::new();
21706        collect_non_effect_segment_items(
21707            &shapes,
21708            &images,
21709            &texts,
21710            &shadows,
21711            &draw_ops,
21712            0,
21713            5,
21714            &effect_ranges,
21715            100,
21716            100,
21717            1.0,
21718            &mut scratch,
21719        );
21720        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
21721        assert_eq!(
21722            items,
21723            vec![SegmentDrawItem::Shape(0), SegmentDrawItem::Text(0)]
21724        );
21725    }
21726
21727    #[test]
21728    fn collect_non_effect_segment_items_culls_offscreen_shapes_but_keeps_text_prewarm() {
21729        let mut shape = test_shape(0, BlendMode::SrcOver);
21730        shape.rect.y = 160.0;
21731        shape.local_rect.y = 160.0;
21732        shape.quad = [[0.0, 160.0], [8.0, 160.0], [0.0, 168.0], [8.0, 168.0]];
21733
21734        let shapes = vec![shape];
21735        let images = Vec::new();
21736        let mut text = test_text(1);
21737        text.rect.y = 160.0;
21738        let texts = vec![text];
21739        let shadows: Vec<ShadowDraw> = Vec::new();
21740        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
21741
21742        let mut scratch = Vec::new();
21743        collect_non_effect_segment_items(
21744            &shapes,
21745            &images,
21746            &texts,
21747            &shadows,
21748            &draw_ops,
21749            0,
21750            2,
21751            &[],
21752            100,
21753            100,
21754            1.0,
21755            &mut scratch,
21756        );
21757
21758        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
21759        assert_eq!(items, vec![SegmentDrawItem::Text(0)]);
21760    }
21761
21762    #[test]
21763    fn segment_command_iter_merges_non_conflicting_batches_into_one_chunk() {
21764        let ordered_items = vec![
21765            (0, SegmentDrawItem::Shape(0)),
21766            (1, SegmentDrawItem::Image(0)),
21767            (2, SegmentDrawItem::Text(0)),
21768        ];
21769        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
21770        let images = vec![test_image(1, BlendMode::DstOut)];
21771
21772        let commands: Vec<_> = SegmentCommandIter::new(
21773            &ordered_items,
21774            &shapes,
21775            &images,
21776            ShapeBatchLimits::desktop(),
21777        )
21778        .collect();
21779
21780        assert_eq!(
21781            commands,
21782            vec![SegmentRenderCommand::DrawChunk(chunk(&[
21783                SegmentBatchPlan::Shape {
21784                    start: 0,
21785                    end: 1,
21786                    blend_mode: BlendMode::SrcOver,
21787                },
21788                SegmentBatchPlan::Image {
21789                    start: 1,
21790                    end: 2,
21791                    blend_mode: BlendMode::DstOut,
21792                },
21793                SegmentBatchPlan::Text { start: 2, end: 3 },
21794            ]))]
21795        );
21796    }
21797
21798    #[test]
21799    fn segment_command_iter_keeps_layer_composites_in_ordered_draw_chunk() {
21800        let ordered_items = vec![
21801            (0, SegmentDrawItem::Shape(0)),
21802            (1, SegmentDrawItem::Composite(0)),
21803            (2, SegmentDrawItem::Image(0)),
21804            (3, SegmentDrawItem::Composite(1)),
21805            (4, SegmentDrawItem::Text(0)),
21806        ];
21807        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
21808        let images = vec![test_image(2, BlendMode::SrcOver)];
21809
21810        let commands: Vec<_> = SegmentCommandIter::new(
21811            &ordered_items,
21812            &shapes,
21813            &images,
21814            ShapeBatchLimits::desktop(),
21815        )
21816        .collect();
21817
21818        assert_eq!(
21819            commands,
21820            vec![SegmentRenderCommand::DrawChunk(chunk(&[
21821                SegmentBatchPlan::Shape {
21822                    start: 0,
21823                    end: 1,
21824                    blend_mode: BlendMode::SrcOver,
21825                },
21826                SegmentBatchPlan::Composite { start: 1, end: 2 },
21827                SegmentBatchPlan::Image {
21828                    start: 2,
21829                    end: 3,
21830                    blend_mode: BlendMode::SrcOver,
21831                },
21832                SegmentBatchPlan::Composite { start: 3, end: 4 },
21833                SegmentBatchPlan::Text { start: 4, end: 5 },
21834            ]))]
21835        );
21836    }
21837
21838    #[test]
21839    fn retain_renderable_shadow_items_culls_invisible_shadow_boundaries() {
21840        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
21841        let images = vec![test_image(2, BlendMode::SrcOver)];
21842        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
21843        shadow_shape.rect = Rect {
21844            x: 500.0,
21845            y: 500.0,
21846            width: 12.0,
21847            height: 12.0,
21848        };
21849        let shadow_draws = vec![ShadowDraw {
21850            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
21851            brushes: vec![],
21852            texts: Vec::new(),
21853            blur_radius: 8.0,
21854            clip: None,
21855            z_index: 1,
21856        }];
21857        let mut ordered_items = vec![
21858            (0, SegmentDrawItem::Shape(0)),
21859            (1, SegmentDrawItem::Shadow(0)),
21860            (2, SegmentDrawItem::Image(0)),
21861        ];
21862
21863        let culled =
21864            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
21865        let commands: Vec<_> = SegmentCommandIter::new(
21866            &ordered_items,
21867            &shapes,
21868            &images,
21869            ShapeBatchLimits::desktop(),
21870        )
21871        .collect();
21872
21873        assert_eq!(culled, 1);
21874        assert_eq!(
21875            commands,
21876            vec![SegmentRenderCommand::DrawChunk(chunk(&[
21877                SegmentBatchPlan::Shape {
21878                    start: 0,
21879                    end: 1,
21880                    blend_mode: BlendMode::SrcOver,
21881                },
21882                SegmentBatchPlan::Image {
21883                    start: 1,
21884                    end: 2,
21885                    blend_mode: BlendMode::SrcOver,
21886                },
21887            ]))]
21888        );
21889    }
21890
21891    #[test]
21892    fn retain_renderable_shadow_items_keeps_visible_shadow_boundaries() {
21893        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
21894        shadow_shape.rect = Rect {
21895            x: 20.0,
21896            y: 20.0,
21897            width: 12.0,
21898            height: 12.0,
21899        };
21900        let shadow_draws = vec![ShadowDraw {
21901            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
21902            brushes: vec![],
21903            texts: Vec::new(),
21904            blur_radius: 8.0,
21905            clip: None,
21906            z_index: 1,
21907        }];
21908        let mut ordered_items = vec![(1, SegmentDrawItem::Shadow(0))];
21909
21910        let culled =
21911            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
21912
21913        assert_eq!(culled, 0);
21914        assert_eq!(ordered_items, vec![(1, SegmentDrawItem::Shadow(0))]);
21915    }
21916
21917    #[test]
21918    fn shape_data_layout_matches_the_wgsl_mirror() {
21919        // 10 x vec4-sized slots. The uniform address space requires a 16-byte
21920        // multiple, and `shape.wgsl`'s array length literal is derived from
21921        // this size — if it drifts, batches silently overrun the binding.
21922        assert_eq!(std::mem::size_of::<ShapeData>(), 160);
21923        assert_eq!(std::mem::size_of::<ShapeData>() % 16, 0);
21924        assert_eq!(std::mem::size_of::<GradientStop>(), 32);
21925    }
21926
21927    #[test]
21928    fn shape_flags_pack_kind_cap_and_join_without_collision() {
21929        assert_eq!(
21930            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
21931            0.0
21932        );
21933        assert_eq!(
21934            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
21935            1.0
21936        );
21937        assert_eq!(
21938            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter),
21939            2.0
21940        );
21941        // cap in bits 2-3, join in bits 4-5
21942        assert_eq!(
21943            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Round, StrokeJoin::Miter),
21944            2.0 + 4.0
21945        );
21946        assert_eq!(
21947            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Square, StrokeJoin::Miter),
21948            2.0 + 8.0
21949        );
21950        assert_eq!(
21951            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Round),
21952            1.0 + 16.0
21953        );
21954        assert_eq!(
21955            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Bevel),
21956            1.0 + 32.0
21957        );
21958        // Every combination must round-trip through f32 exactly.
21959        for kind in [SHAPE_KIND_FILL, SHAPE_KIND_STROKE, SHAPE_KIND_ARC] {
21960            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
21961                for join in [StrokeJoin::Miter, StrokeJoin::Round, StrokeJoin::Bevel] {
21962                    let packed = pack_shape_flags(kind, cap, join);
21963                    let bits = packed as u32;
21964                    assert_eq!(bits & 3, kind);
21965                    assert_eq!((bits >> 2) & 3, stroke_cap_code(cap));
21966                    assert_eq!((bits >> 4) & 3, stroke_join_code(join));
21967                    assert_eq!(packed, bits as f32, "flags must be exact in f32");
21968                }
21969            }
21970        }
21971    }
21972
21973    #[cfg(not(target_arch = "wasm32"))]
21974    #[test]
21975    fn mesh_vertex_layout_matches_the_wgsl_input() {
21976        // {pos: vec2<f32>, uv: vec2<f32>, shape_idx: u32} = 20 bytes, no
21977        // padding — the vertex buffer layout stride relies on it.
21978        assert_eq!(std::mem::size_of::<MeshVertex>(), 20);
21979    }
21980
21981    /// f32 port of `sdf_arc_band` (shape.wgsl), operation for operation: the
21982    /// same ra/rb derivation and clamp, the same mirror trick (`abs` on the
21983    /// rotated x), the same cap branches.
21984    #[cfg(not(target_arch = "wasm32"))]
21985    #[allow(clippy::too_many_arguments)]
21986    fn sdf_arc_band_reference(
21987        p: [f32; 2],
21988        center: [f32; 2],
21989        inner: f32,
21990        outer: f32,
21991        mid_sin_cos: [f32; 2],
21992        half_sin_cos: [f32; 2],
21993        cap: u32,
21994    ) -> f32 {
21995        let ra = (outer + inner) * 0.5;
21996        let rb = ((outer - inner) * 0.5).max(0.0);
21997        let sm = mid_sin_cos[0];
21998        let cm = mid_sin_cos[1];
21999        let d = [p[0] - center[0], p[1] - center[1]];
22000        let mut q = [-sm * d[0] + cm * d[1], cm * d[0] + sm * d[1]];
22001        q[0] = q[0].abs();
22002        let sc = half_sin_cos;
22003        let mut dist = if sc[1] * q[0] > sc[0] * q[1] {
22004            let dx = q[0] - sc[0] * ra;
22005            let dy = q[1] - sc[1] * ra;
22006            (dx * dx + dy * dy).sqrt() - rb
22007        } else {
22008            ((q[0] * q[0] + q[1] * q[1]).sqrt() - ra).abs() - rb
22009        };
22010        let plane = sc[1] * q[0] - sc[0] * q[1];
22011        // STROKE_CAP_BUTT = 0, STROKE_CAP_SQUARE = 2, as in the shader.
22012        if cap == 0 {
22013            dist = dist.max(plane);
22014        } else if cap == 2 {
22015            dist = dist.max(plane - rb);
22016        }
22017        dist
22018    }
22019
22020    #[cfg(not(target_arch = "wasm32"))]
22021    fn point_in_triangle(p: [f64; 2], tri: &[[f64; 2]; 3]) -> bool {
22022        let side = |a: [f64; 2], b: [f64; 2]| {
22023            (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
22024        };
22025        let d0 = side(tri[0], tri[1]);
22026        let d1 = side(tri[1], tri[2]);
22027        let d2 = side(tri[2], tri[0]);
22028        let has_neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
22029        let has_pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
22030        !(has_neg && has_pos)
22031    }
22032
22033    #[cfg(not(target_arch = "wasm32"))]
22034    fn converted_arc_shape(arc: cranpose_ui_graphics::ArcGeometry, root_scale: f32) -> ShapeData {
22035        let bounds = arc.bounds();
22036        let mut shape = test_shape(0, BlendMode::SrcOver);
22037        shape.rect = bounds;
22038        shape.local_rect = bounds;
22039        shape.quad = [
22040            [bounds.x, bounds.y],
22041            [bounds.x + bounds.width, bounds.y],
22042            [bounds.x, bounds.y + bounds.height],
22043            [bounds.x + bounds.width, bounds.y + bounds.height],
22044        ];
22045        shape.arc = Some(arc);
22046        let mut converted = ShapeData::zeroed();
22047        convert_shape_into_slots(&shape, &[], root_scale, 0, &mut converted, &mut []);
22048        converted
22049    }
22050
22051    /// The containment invariant, checked directly: every point of the
22052    /// capture box whose (exactly ported) SDF keeps it must lie inside the
22053    /// emitted triangle set. Thin/thick, tiny/huge, full rings, near-zero
22054    /// and near-TAU sweeps, all caps, `Ri == 0` discs and pie wedges.
22055    #[cfg(not(target_arch = "wasm32"))]
22056    #[test]
22057    fn arc_mesh_contains_every_band_pixel() {
22058        use cranpose_ui_graphics::ArcGeometry;
22059        let tau = cranpose_ui_graphics::TAU;
22060        let center = Point::new(250.0, 250.0);
22061        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
22062            // full ring, thin band
22063            (90.0, 100.0, 0.0, tau, StrokeCap::Round),
22064            // sweep > TAU normalizes to a closed ring
22065            (80.0, 100.0, 1.0, 10.0, StrokeCap::Butt),
22066            // full disc: Ri == 0
22067            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
22068            // thick partial arc, every cap
22069            (30.0, 80.0, 0.7, 2.5, StrokeCap::Butt),
22070            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
22071            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
22072            // thin, axis-crossing sweep
22073            (99.0, 101.0, 3.0, 4.0, StrokeCap::Round),
22074            // tiny
22075            (0.6, 2.0, 0.3, 1.2, StrokeCap::Butt),
22076            // huge radius, thin band
22077            (1900.0, 1904.0, 0.1, 0.35, StrokeCap::Square),
22078            // near-zero sweep
22079            (40.0, 60.0, 5.0, 1e-3, StrokeCap::Round),
22080            // sweep near TAU: the cap pads wrap the range closed
22081            (40.0, 60.0, 0.2, tau - 1e-3, StrokeCap::Butt),
22082            // rb_m >= ra: the cap disc wraps the center (pie wedge)
22083            (0.0, 3.0, 1.0, 2.0, StrokeCap::Round),
22084            // filled annular sector (butt radial ends)
22085            (20.0, 60.0, 4.5, 1.9, StrokeCap::Butt),
22086        ];
22087        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
22088            // 2.75 is deliberately non-dyadic: quad corners and rect then
22089            // disagree by an ulp, which the axis-aligned gate must tolerate
22090            // (an equality-with-rect gate silently failed every arc on the
22091            // Huawei at scale 2.75).
22092            for root_scale in [1.0f32, 2.0, 2.75] {
22093                let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
22094                assert!(!arc.is_degenerate(), "case {case} must be drawable");
22095                let converted = converted_arc_shape(arc, root_scale);
22096                let band = arc_mesh_band(&converted)
22097                    .unwrap_or_else(|| panic!("case {case} must qualify for meshing"));
22098                let mut vertices = Vec::new();
22099                let mut indices = Vec::new();
22100                let segments =
22101                    emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22102                        .unwrap_or_else(|| panic!("case {case} must produce a mesh"));
22103                assert!(segments >= ARC_MESH_MIN_SEGMENTS);
22104                // The rasterized set is the indexed walk: triangles are index
22105                // triples into the shared vertex list.
22106                let position = |index: u32| {
22107                    let p = vertices[index as usize].position;
22108                    [p[0] as f64, p[1] as f64]
22109                };
22110                let triangles: Vec<[[f64; 2]; 3]> = indices
22111                    .as_chunks::<3>()
22112                    .0
22113                    .iter()
22114                    .map(|tri| [position(tri[0]), position(tri[1]), position(tri[2])])
22115                    .collect();
22116
22117                // Sample the QUAD box, not `rect`: quad expansion rasterizes the
22118                // quad, the mesh clips to the quad, and at non-dyadic root
22119                // scales the two boxes differ by an ulp.
22120                let [qx, qy, ..] = converted.quad01;
22121                let [_, _, qr, qb] = converted.quad23;
22122                let (rw, rh) = (qr - qx, qb - qy);
22123                let cap_bits = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
22124                let step = (rw.max(rh) / 400.0).clamp(0.25, 2.0);
22125                let mut band_points = 0usize;
22126                let mut y = qy;
22127                while y <= qb {
22128                    let mut x = qx;
22129                    while x <= qr {
22130                        let dist = sdf_arc_band_reference(
22131                            [x, y],
22132                            [converted.arc_params[0], converted.arc_params[1]],
22133                            converted.stroke_params[3],
22134                            converted.stroke_params[2],
22135                            [converted.radii[0], converted.radii[1]],
22136                            [converted.radii[2], converted.radii[3]],
22137                            cap_bits,
22138                        );
22139                        if dist <= 0.5 {
22140                            band_points += 1;
22141                            let p = [x as f64, y as f64];
22142                            assert!(
22143                                triangles.iter().any(|tri| point_in_triangle(p, tri)),
22144                                "case {case} scale {root_scale}: band point ({x}, {y}) \
22145                                 dist {dist} escapes the mesh"
22146                            );
22147                        }
22148                        x += step;
22149                    }
22150                    y += step;
22151                }
22152                assert!(
22153                    band_points > 0,
22154                    "case {case} scale {root_scale}: the sampling grid never hit the band"
22155                );
22156            }
22157        }
22158    }
22159
22160    /// An unmeshed shape contributes NOTHING to the mesh buffers — no
22161    /// vertices, no indices, only an empty `index_prefix` range — because
22162    /// the draw walk keeps it on the instanced-quad path. Routing
22163    /// passthrough quads through the mesh vertex stream is exactly what the
22164    /// watch A/B measured as the S3 loss.
22165    #[cfg(not(target_arch = "wasm32"))]
22166    #[test]
22167    fn unmeshed_shapes_leave_no_geometry_and_empty_index_ranges() {
22168        let shape = test_shape(0, BlendMode::SrcOver);
22169        let mut converted = ShapeData::zeroed();
22170        convert_shape_into_slots(&shape, &[], 1.0, 0, &mut converted, &mut []);
22171        let build = build_arc_mesh_vertices(
22172            std::slice::from_ref(&converted),
22173            RETAINED_MESH_MIN_PX2_DEFAULT as f64,
22174        )
22175        .expect("within budget");
22176        assert_eq!(build.meshed_arcs, 0);
22177        assert_eq!(build.meshed_rims, 0);
22178        assert_eq!(build.passthrough, 1);
22179        assert_eq!(build.meshed_stretches, 0);
22180        assert!(build.vertices.is_empty());
22181        assert!(build.indices.is_empty());
22182        assert_eq!(build.index_prefix, vec![0, 0]);
22183        // The instanced arm submits the bounding quad; the telemetry must
22184        // price it as such.
22185        assert_eq!(build.mesh_area, build.quad_area);
22186    }
22187
22188    /// The indexed-topology contract for arcs whose trapezoids survive
22189    /// clipping whole: every band boundary contributes exactly one (inner,
22190    /// outer) vertex pair, both adjacent trapezoids reference it through the
22191    /// index list, and a closed ring's last segment wraps around to boundary
22192    /// zero's pair — one seam vertex pair instead of bitwise-equal copies.
22193    #[cfg(not(target_arch = "wasm32"))]
22194    #[test]
22195    fn arc_mesh_indices_share_boundary_vertices_and_wrap_closed_rings() {
22196        use cranpose_ui_graphics::ArcGeometry;
22197        let tau = cranpose_ui_graphics::TAU;
22198        // (sweep, expected boundary count relation): a closed ring wraps
22199        // (boundaries == segments), an open arc does not (segments + 1).
22200        for (sweep, closed) in [(tau, true), (1.9f32, false)] {
22201            let arc = ArcGeometry::new(
22202                Point::new(250.0, 250.0),
22203                80.0,
22204                100.0,
22205                0.7,
22206                sweep,
22207                StrokeCap::Round,
22208            );
22209            let mut converted = converted_arc_shape(arc, 1.0);
22210            // Inflate the quad box (and rect, for uv) far beyond the dilated
22211            // band so NO trapezoid is clipped: every segment must take the
22212            // shared-boundary path.
22213            converted.rect = [0.0, 0.0, 500.0, 500.0];
22214            converted.quad01 = [0.0, 0.0, 500.0, 0.0];
22215            converted.quad23 = [0.0, 500.0, 500.0, 500.0];
22216            let band = arc_mesh_band(&converted).expect("arc must qualify");
22217            let mut vertices = Vec::new();
22218            let mut indices = Vec::new();
22219            let segments = emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22220                .expect("arc must mesh");
22221            let boundary_count = if closed { segments } else { segments + 1 };
22222            assert_eq!(
22223                vertices.len(),
22224                2 * boundary_count,
22225                "closed={closed}: every boundary owns exactly one (inner, outer) pair"
22226            );
22227            assert_eq!(indices.len(), 6 * segments);
22228            // Emission order is boundary order: boundary j's pair is
22229            // (2j, 2j + 1). Each segment must reference its own boundary and
22230            // its successor's — modulo the count exactly when closed.
22231            for j in 0..segments {
22232                let jb = (j + 1) % boundary_count;
22233                let (in_a, out_a) = (2 * j as u32, 2 * j as u32 + 1);
22234                let (in_b, out_b) = (2 * jb as u32, 2 * jb as u32 + 1);
22235                assert_eq!(
22236                    indices[6 * j..6 * j + 6],
22237                    [in_a, out_a, out_b, in_a, out_b, in_b],
22238                    "closed={closed}: segment {j} must share its boundary pairs"
22239                );
22240            }
22241            if closed {
22242                // The wrap made concrete: the final segment indexes boundary
22243                // zero's vertices.
22244                assert_eq!(indices[6 * segments - 1], 0);
22245            }
22246            // Inner vertices ride the dilated inner radius, outer vertices
22247            // the pushed-out chord radius — sanity that pairs are ordered
22248            // (inner, outer).
22249            for pair in vertices.as_chunks::<2>().0 {
22250                let radius = |v: &MeshVertex| {
22251                    let dx = v.position[0] - 250.0;
22252                    let dy = v.position[1] - 250.0;
22253                    (dx * dx + dy * dy).sqrt()
22254                };
22255                assert!(radius(&pair[0]) < radius(&pair[1]));
22256            }
22257        }
22258    }
22259
22260    /// The private-vertex arm of the indexed topology: under the real
22261    /// tight-AABB quad the pushed-out chord vertices near the box edges get
22262    /// clipped, and those trapezoids must fan over vertices of their own —
22263    /// appended after the shared block, carrying clip-plane coordinates —
22264    /// while untouched diagonal trapezoids still share boundary pairs.
22265    #[cfg(not(target_arch = "wasm32"))]
22266    #[test]
22267    fn arc_mesh_clipped_segments_fan_over_private_vertices() {
22268        use cranpose_ui_graphics::ArcGeometry;
22269        let arc = ArcGeometry::new(
22270            Point::new(250.0, 250.0),
22271            80.0,
22272            100.0,
22273            0.0,
22274            cranpose_ui_graphics::TAU,
22275            StrokeCap::Round,
22276        );
22277        let converted = converted_arc_shape(arc, 1.0);
22278        let band = arc_mesh_band(&converted).expect("ring must qualify");
22279        let mut vertices = Vec::new();
22280        let mut indices = Vec::new();
22281        emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22282            .expect("ring must mesh");
22283        // Sharing must actually happen: a shared boundary vertex is used by
22284        // both of its trapezoids' fans (at least three triangle references).
22285        let mut uses = vec![0usize; vertices.len()];
22286        for &index in &indices {
22287            uses[index as usize] += 1;
22288        }
22289        assert!(
22290            uses.iter().any(|&count| count >= 3),
22291            "some boundary vertices must be shared across trapezoids"
22292        );
22293        // Clipping must actually happen, and clipped polygons index private
22294        // vertices lying bitwise ON the quad box (the clipper writes the
22295        // bound coordinate exactly; boundary vertices never touch the box —
22296        // inner ones sit strictly inside, pushed-out outer ones strictly
22297        // outside near the extremes, where they are clipped).
22298        let [left, top, ..] = converted.quad01;
22299        let [.., right, bottom] = converted.quad23;
22300        let clipped: Vec<&MeshVertex> = vertices
22301            .iter()
22302            .filter(|vertex| {
22303                let [x, y] = vertex.position;
22304                x == left || x == right || y == top || y == bottom
22305            })
22306            .collect();
22307        assert!(
22308            !clipped.is_empty(),
22309            "the tight box must clip the pushed-out chord vertices"
22310        );
22311        // Fewer unique vertices than the non-indexed emitter's
22312        // three-per-triangle — the amplification this change removes.
22313        assert!(
22314            vertices.len() < indices.len(),
22315            "{} unique vertices should undercut {} triangle corners",
22316            vertices.len(),
22317            indices.len()
22318        );
22319    }
22320
22321    #[cfg(not(target_arch = "wasm32"))]
22322    #[test]
22323    fn arc_mesh_budget_overflow_falls_back_to_whole_slot_passthrough() {
22324        use cranpose_ui_graphics::ArcGeometry;
22325        // 100 large full rings mesh at the 64-segment ceiling (well over
22326        // 4 KB of vertices + indices each), far past the byte budget
22327        // max(100 * ~960 B, ~80 KB) — the builder must refuse the whole
22328        // slot rather than truncate.
22329        let arc = ArcGeometry::new(
22330            Point::new(2000.0, 2000.0),
22331            1690.0,
22332            1710.0,
22333            0.0,
22334            cranpose_ui_graphics::TAU,
22335            StrokeCap::Round,
22336        );
22337        let converted = converted_arc_shape(arc, 1.0);
22338        let shapes = vec![converted; 100];
22339        assert!(build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64).is_none());
22340    }
22341
22342    /// The size gate, boundary-exact: a shape meshes when its quad area is
22343    /// AT LEAST the threshold and passes through below it — with the
22344    /// engagement counters saying which happened — and the arc and rim
22345    /// acceptances both sit behind the same gate.
22346    #[cfg(not(target_arch = "wasm32"))]
22347    #[test]
22348    fn retained_mesh_size_gate_engages_exactly_per_threshold() {
22349        use cranpose_ui_graphics::ArcGeometry;
22350        // A big ring (quad ~322 px square ≈ 104k px²), a small brick arc
22351        // (quad well under 1024 px²), and a big stroked-circle rim
22352        // (90k-px² quad) in one capture.
22353        let big_ring = converted_arc_shape(
22354            ArcGeometry::new(
22355                Point::new(204.0, 204.0),
22356                140.0,
22357                160.0,
22358                0.0,
22359                cranpose_ui_graphics::TAU,
22360                StrokeCap::Butt,
22361            ),
22362            1.0,
22363        );
22364        let small_arc = converted_arc_shape(
22365            ArcGeometry::new(
22366                Point::new(204.0, 204.0),
22367                12.0,
22368                18.0,
22369                0.3,
22370                0.5,
22371                StrokeCap::Butt,
22372            ),
22373            1.0,
22374        );
22375        let rim = rim_test_shape_data();
22376        let shapes = [big_ring, small_arc, rim];
22377        let big_px2 = quad_shoelace_area(&shapes[0]);
22378        let small_px2 = quad_shoelace_area(&shapes[1]);
22379        let rim_px2 = quad_shoelace_area(&shapes[2]);
22380        assert!(small_px2 < 1024.0 && big_px2 > rim_px2 && rim_px2 > 16384.0);
22381
22382        // Default gate: both big shapes mesh, the brick arc stays instanced.
22383        // The meshed shapes sit at indices 0 and 2 with the brick between
22384        // them — two stretches.
22385        let build = build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64)
22386            .expect("within budget");
22387        assert_eq!(
22388            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22389            (1, 1, 1)
22390        );
22391        assert_eq!(build.meshed_stretches, 2);
22392        // The brick's index range is empty (no geometry emitted for it);
22393        // the ring's and rim's are not.
22394        assert_eq!(build.index_prefix[1], build.index_prefix[2]);
22395        assert!(build.index_prefix[1] > build.index_prefix[0]);
22396        assert!(build.index_prefix[3] > build.index_prefix[2]);
22397
22398        // ≥, not >: a threshold bitwise AT a shape's quad area still meshes
22399        // it...
22400        let build = build_arc_mesh_vertices(&shapes, big_px2).expect("within budget");
22401        assert_eq!(
22402            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22403            (1, 0, 2)
22404        );
22405        // ...and one ulp above it does not.
22406        let build =
22407            build_arc_mesh_vertices(&shapes, big_px2 + big_px2 * f64::EPSILON).expect("budget");
22408        assert_eq!(
22409            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22410            (0, 0, 3)
22411        );
22412
22413        // A threshold between the rim and the ring gates them apart.
22414        let build = build_arc_mesh_vertices(&shapes, (rim_px2 + big_px2) * 0.5).expect("budget");
22415        assert_eq!(
22416            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22417            (1, 0, 2)
22418        );
22419
22420        // Gate-rejected shapes leave the mesh buffers EMPTY — they stay on
22421        // the instanced path, so a capture like this keeps no mesh at all.
22422        let everything_gated =
22423            build_arc_mesh_vertices(&shapes, big_px2 * 2.0).expect("within budget");
22424        assert_eq!(everything_gated.passthrough, 3);
22425        assert_eq!(everything_gated.meshed_stretches, 0);
22426        assert!(everything_gated.vertices.is_empty());
22427        assert_eq!(everything_gated.index_prefix, vec![0, 0, 0, 0]);
22428    }
22429
22430    /// The stretch counter counts MAXIMAL RUNS of consecutive meshed
22431    /// shapes — the quantity the capture site caps at
22432    /// [`MESH_SLOT_MAX_STRETCHES`], because each stretch costs the draw
22433    /// walk two pipeline switches per covering op.
22434    #[cfg(not(target_arch = "wasm32"))]
22435    #[test]
22436    fn meshed_stretches_count_maximal_runs_of_consecutive_meshed_shapes() {
22437        use cranpose_ui_graphics::ArcGeometry;
22438        let big = converted_arc_shape(
22439            ArcGeometry::new(
22440                Point::new(204.0, 204.0),
22441                140.0,
22442                160.0,
22443                0.0,
22444                cranpose_ui_graphics::TAU,
22445                StrokeCap::Butt,
22446            ),
22447            1.0,
22448        );
22449        let small = converted_arc_shape(
22450            ArcGeometry::new(
22451                Point::new(204.0, 204.0),
22452                12.0,
22453                18.0,
22454                0.3,
22455                0.5,
22456                StrokeCap::Butt,
22457            ),
22458            1.0,
22459        );
22460        // big big small big small small big big -> runs [0..2], [3], [6..8].
22461        let shapes = [big, big, small, big, small, small, big, big];
22462        let build = build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64)
22463            .expect("within budget");
22464        assert_eq!(build.meshed_arcs, 5);
22465        assert_eq!(build.passthrough, 3);
22466        assert_eq!(build.meshed_stretches, 3);
22467        // An all-instanced interleave never exceeds the cap vacuously: the
22468        // cap compares against this exact counter.
22469        assert!(build.meshed_stretches <= MESH_SLOT_MAX_STRETCHES);
22470    }
22471
22472    /// The env override's parse-and-clamp: unset and garbage read the
22473    /// default, in-range values pass through, and both clamp ends hold.
22474    #[cfg(not(target_arch = "wasm32"))]
22475    #[test]
22476    fn retained_mesh_px2_override_parses_and_clamps() {
22477        assert_eq!(
22478            parse_retained_mesh_min_px2(None),
22479            RETAINED_MESH_MIN_PX2_DEFAULT as f64
22480        );
22481        assert_eq!(
22482            parse_retained_mesh_min_px2(Some("not a number")),
22483            RETAINED_MESH_MIN_PX2_DEFAULT as f64
22484        );
22485        assert_eq!(
22486            parse_retained_mesh_min_px2(Some("-5")),
22487            RETAINED_MESH_MIN_PX2_DEFAULT as f64
22488        );
22489        assert_eq!(parse_retained_mesh_min_px2(Some(" 40000 ")), 40000.0);
22490        assert_eq!(
22491            parse_retained_mesh_min_px2(Some("0")),
22492            *RETAINED_MESH_MIN_PX2_RANGE.start() as f64
22493        );
22494        assert_eq!(
22495            parse_retained_mesh_min_px2(Some("99999999")),
22496            *RETAINED_MESH_MIN_PX2_RANGE.end() as f64
22497        );
22498    }
22499
22500    /// The retained builder accepts stroked-circle rims through
22501    /// [`rim_band_geometry`]: the emitted mesh is the closed annulus band
22502    /// (every vertex inside the dilated ring, none inside the hole), counted
22503    /// as a rim, while the same shape under the gate stays a quad.
22504    #[cfg(not(target_arch = "wasm32"))]
22505    #[test]
22506    fn retained_capture_meshes_big_stroked_circle_rims_as_annuli() {
22507        let rim = rim_test_shape_data();
22508        let build = build_arc_mesh_vertices(
22509            std::slice::from_ref(&rim),
22510            RETAINED_MESH_MIN_PX2_DEFAULT as f64,
22511        )
22512        .expect("within budget");
22513        assert_eq!(
22514            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22515            (0, 1, 0)
22516        );
22517        assert!(build.meshed_segments >= ARC_MESH_MIN_SEGMENTS);
22518        // The annulus, not the quad: the mesh area is far below the 90k-px²
22519        // bounding quad and every vertex sits in the dilated band's radial
22520        // range (clip-plane vertices included — the quad box touches the
22521        // outer circle only near the axes, inside the band).
22522        assert!(build.mesh_area < 0.2 * build.quad_area);
22523        let band = rim_band_geometry(&rim).expect("rim must qualify");
22524        for vertex in &build.vertices {
22525            let dx = vertex.position[0] - band.center[0];
22526            let dy = vertex.position[1] - band.center[1];
22527            let radius = (dx * dx + dy * dy).sqrt();
22528            assert!(
22529                radius >= band.inner - ARC_MESH_MARGIN - 1e-3,
22530                "vertex at radius {radius} fell inside the annulus hole"
22531            );
22532        }
22533    }
22534
22535    /// A converted circle rim, hand-built in `ShapeData` terms: `rect` is the
22536    /// stroke-inflated 300×300 box, the geometry is 292×292, and the corner
22537    /// radius (300 − 8) / 2 = 146 equals the geometry half-extent — a circle.
22538    #[cfg(not(target_arch = "wasm32"))]
22539    fn rim_test_shape_data() -> ShapeData {
22540        let mut shape = ShapeData::zeroed();
22541        shape.rect = [40.0, 40.0, 300.0, 300.0];
22542        shape.radii = [146.0; 4];
22543        shape.stroke_params = [
22544            8.0,
22545            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
22546            0.0,
22547            0.0,
22548        ];
22549        shape.quad01 = [40.0, 40.0, 340.0, 40.0];
22550        shape.quad23 = [40.0, 340.0, 340.0, 340.0];
22551        shape.color = [1.0, 1.0, 1.0, 1.0];
22552        shape
22553    }
22554
22555    /// A viewport that never matches the diag's latched surface, so bucket
22556    /// tests exercise no corner accounting.
22557    #[cfg(not(target_arch = "wasm32"))]
22558    fn offscreen_test_viewport() -> ViewportUniformParams {
22559        ViewportUniformParams {
22560            width: 64,
22561            height: 64,
22562            offset: [7.0, 7.0],
22563        }
22564    }
22565
22566    #[cfg(not(target_arch = "wasm32"))]
22567    #[test]
22568    fn fill_diag_buckets_shape_quads_by_decoded_sdf_class() {
22569        let diag = FillAreaDiag::default();
22570        let mut arc = ShapeData::zeroed();
22571        arc.stroke_params[1] = pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter);
22572        // Arcs keep trig in `radii`; nonzero values there must not classify
22573        // the shape as a rounded fill.
22574        arc.radii = [0.5; 4];
22575        arc.quad01 = [0.0, 0.0, 10.0, 0.0];
22576        arc.quad23 = [0.0, 10.0, 10.0, 10.0];
22577        let mut rounded = ShapeData::zeroed();
22578        rounded.stroke_params[1] =
22579            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22580        rounded.radii = [2.0; 4];
22581        rounded.quad01 = [0.0, 0.0, 4.0, 0.0];
22582        rounded.quad23 = [0.0, 5.0, 4.0, 5.0];
22583        let mut plain = ShapeData::zeroed();
22584        plain.stroke_params[1] =
22585            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22586        plain.quad01 = [0.0, 0.0, 2.0, 0.0];
22587        plain.quad23 = [0.0, 3.0, 2.0, 3.0];
22588        diag.add_shape_quads(
22589            &[rim_test_shape_data(), arc, rounded, plain],
22590            offscreen_test_viewport(),
22591        );
22592        assert_eq!(diag.frame[FillAreaDiag::RRECT_STROKE].get(), 300.0 * 300.0);
22593        assert_eq!(diag.frame[FillAreaDiag::ARC].get(), 100.0);
22594        assert_eq!(diag.frame[FillAreaDiag::RRECT_FILL].get(), 20.0);
22595        assert_eq!(diag.frame[FillAreaDiag::RECT].get(), 6.0);
22596        // Off-frame passes never touch the corner counter.
22597        assert_eq!(diag.frame_corner.get(), 0.0);
22598        // Lit never exceeds the submitted area, bucket by bucket.
22599        for (lit, quad) in diag.frame_lit.iter().zip(&diag.frame) {
22600            assert!(lit.get() <= quad.get() + 1e-9);
22601        }
22602    }
22603
22604    #[cfg(not(target_arch = "wasm32"))]
22605    #[test]
22606    fn fill_diag_rim_mesh_moves_quad_area_to_the_mesh_bucket() {
22607        let diag = FillAreaDiag::default();
22608        diag.add_shape_quads(&[rim_test_shape_data()], offscreen_test_viewport());
22609        diag.note_rim_mesh(&rim_test_shape_data(), 1234.5);
22610        assert_eq!(diag.frame[FillAreaDiag::RRECT_STROKE].get(), 0.0);
22611        assert_eq!(diag.frame[FillAreaDiag::MESH].get(), 1234.5);
22612        // The lit accounting moves with the quad: nothing left in the
22613        // stroke bucket, and the mesh bucket's lit stays within the mesh.
22614        assert_eq!(diag.frame_lit[FillAreaDiag::RRECT_STROKE].get(), 0.0);
22615        assert!(diag.frame_lit[FillAreaDiag::MESH].get() <= 1234.5);
22616        assert!(diag.frame_lit[FillAreaDiag::MESH].get() > 0.0);
22617    }
22618
22619    #[cfg(not(target_arch = "wasm32"))]
22620    #[test]
22621    fn fill_diag_image_and_glyph_quads_share_one_bucket() {
22622        let diag = FillAreaDiag::default();
22623        diag.add_image_quad(&[[0.0, 0.0], [8.0, 0.0], [0.0, 4.0], [8.0, 4.0]]);
22624        let quad = CachedTextGlyphQuad {
22625            x: 0,
22626            y: 0,
22627            width: 5,
22628            height: 7,
22629            color: (1.0, 1.0, 1.0, 1.0),
22630            uv: ImageUvRect {
22631                min: [0.0, 0.0],
22632                max: [1.0, 1.0],
22633                sample_bounds: [0.0, 0.0, 1.0, 1.0],
22634            },
22635        };
22636        diag.add_glyph_quad(&quad);
22637        assert_eq!(diag.frame[FillAreaDiag::IMAGE_GLYPH].get(), 32.0 + 35.0);
22638        // Textures light their whole quad: lit tracks the submitted area.
22639        assert_eq!(diag.frame_lit[FillAreaDiag::IMAGE_GLYPH].get(), 32.0 + 35.0);
22640    }
22641
22642    /// Midpoint-rule area of `inside` over `bounds` (min x, min y, max x,
22643    /// max y), the reference the analytic-lit formulas are tested against.
22644    #[cfg(not(target_arch = "wasm32"))]
22645    fn numeric_area(bounds: [f64; 4], steps: usize, inside: impl Fn(f64, f64) -> bool) -> f64 {
22646        let dx = (bounds[2] - bounds[0]) / steps as f64;
22647        let dy = (bounds[3] - bounds[1]) / steps as f64;
22648        let mut area = 0.0;
22649        for column in 0..steps {
22650            let x = bounds[0] + (column as f64 + 0.5) * dx;
22651            for row in 0..steps {
22652                let y = bounds[1] + (row as f64 + 0.5) * dy;
22653                if inside(x, y) {
22654                    area += dx * dy;
22655                }
22656            }
22657        }
22658        area
22659    }
22660
22661    /// f64 rounded-rect SDF (uniform radius), the reference for the
22662    /// round-rect fill and stroke lit formulas.
22663    #[cfg(not(target_arch = "wasm32"))]
22664    fn sdf_rounded_rect_reference(
22665        p: [f64; 2],
22666        center: [f64; 2],
22667        half: [f64; 2],
22668        radius: f64,
22669    ) -> f64 {
22670        let qx = (p[0] - center[0]).abs() - (half[0] - radius);
22671        let qy = (p[1] - center[1]).abs() - (half[1] - radius);
22672        qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius
22673    }
22674
22675    #[cfg(not(target_arch = "wasm32"))]
22676    #[test]
22677    fn fill_truth_arc_lit_matches_the_sdf_covered_area() {
22678        use cranpose_ui_graphics::ArcGeometry;
22679        let tau = cranpose_ui_graphics::TAU;
22680        let center = Point::new(250.0, 250.0);
22681        // (inner, outer, start, sweep, cap): partial arcs with every cap,
22682        // a closed ring, and a full disc.
22683        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
22684            (90.0, 100.0, 0.7, 2.5, StrokeCap::Butt),
22685            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
22686            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
22687            (80.0, 100.0, 0.0, tau, StrokeCap::Round),
22688            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
22689        ];
22690        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
22691            let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
22692            let converted = converted_arc_shape(arc, 1.0);
22693            let cap_code = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
22694            let arc_center = [converted.arc_params[0], converted.arc_params[1]];
22695            let mid = [converted.radii[0], converted.radii[1]];
22696            let half = [converted.radii[2], converted.radii[3]];
22697            let aabb = quad_aabb(&converted);
22698            // Pad past the fast-trig AABB slop so the whole kept set is
22699            // integrated.
22700            let bounds = [aabb[0] - 2.0, aabb[1] - 2.0, aabb[2] + 2.0, aabb[3] + 2.0];
22701            let numeric = numeric_area(bounds, 1000, |x, y| {
22702                sdf_arc_band_reference(
22703                    [x as f32, y as f32],
22704                    arc_center,
22705                    converted.stroke_params[3],
22706                    converted.stroke_params[2],
22707                    mid,
22708                    half,
22709                    cap_code,
22710                ) < 0.0
22711            });
22712            let analytic = analytic_covered_area(&converted);
22713            let error = (analytic - numeric).abs() / numeric.max(1.0);
22714            assert!(
22715                error < 0.02,
22716                "case {case}: analytic {analytic:.1} vs sdf {numeric:.1} \
22717                 ({:.2}% off)",
22718                error * 100.0
22719            );
22720        }
22721    }
22722
22723    #[cfg(not(target_arch = "wasm32"))]
22724    #[test]
22725    fn fill_truth_circle_and_rrect_fill_lit_match_references() {
22726        // A filled circle degenerates to exactly pi r^2.
22727        let mut circle = ShapeData::zeroed();
22728        circle.stroke_params[1] =
22729            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22730        circle.rect = [10.0, 10.0, 200.0, 200.0];
22731        circle.radii = [100.0; 4];
22732        let analytic = analytic_covered_area(&circle);
22733        let exact = std::f64::consts::PI * 100.0 * 100.0;
22734        assert!(
22735            (analytic - exact).abs() / exact < 1e-9,
22736            "circle: {analytic} vs {exact}"
22737        );
22738
22739        // A rounded rect against the SDF reference.
22740        let mut rounded = ShapeData::zeroed();
22741        rounded.stroke_params[1] =
22742            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22743        rounded.rect = [50.0, 80.0, 200.0, 120.0];
22744        rounded.radii = [40.0; 4];
22745        let numeric = numeric_area([48.0, 78.0, 252.0, 202.0], 1000, |x, y| {
22746            sdf_rounded_rect_reference([x, y], [150.0, 140.0], [100.0, 60.0], 40.0) < 0.0
22747        });
22748        let analytic = analytic_covered_area(&rounded);
22749        let error = (analytic - numeric).abs() / numeric;
22750        assert!(
22751            error < 0.02,
22752            "rrect fill: analytic {analytic:.1} vs sdf {numeric:.1}"
22753        );
22754    }
22755
22756    #[cfg(not(target_arch = "wasm32"))]
22757    #[test]
22758    fn fill_truth_stroked_rrect_lit_matches_the_band_area() {
22759        // The circle rim: perimeter x stroke width equals the exact annulus
22760        // pi (outer^2 - inner^2) = 2 pi geom_half sw.
22761        let rim = rim_test_shape_data();
22762        let analytic = analytic_covered_area(&rim);
22763        let exact = std::f64::consts::PI * (150.0 * 150.0 - 142.0 * 142.0);
22764        assert!(
22765            (analytic - exact).abs() / exact < 1e-9,
22766            "circle rim: {analytic} vs {exact}"
22767        );
22768
22769        // A rounded-SQUARE ring (radius well below the half-extent) against
22770        // the SDF band |sdf| < sw/2.
22771        let mut square_ring = rim_test_shape_data();
22772        square_ring.radii = [60.0; 4];
22773        let numeric = numeric_area([38.0, 38.0, 342.0, 342.0], 1000, |x, y| {
22774            sdf_rounded_rect_reference([x, y], [190.0, 190.0], [146.0, 146.0], 60.0).abs() < 4.0
22775        });
22776        let analytic = analytic_covered_area(&square_ring);
22777        let error = (analytic - numeric).abs() / numeric;
22778        assert!(
22779            error < 0.02,
22780            "square ring: analytic {analytic:.1} vs sdf {numeric:.1}"
22781        );
22782    }
22783
22784    #[cfg(not(target_arch = "wasm32"))]
22785    #[test]
22786    fn fill_truth_corner_counter_prices_the_area_outside_the_inscribed_circle() {
22787        // A full-viewport quad on a square (watch) surface wastes exactly
22788        // the four corner lunes: (1 - pi/4) of the screen.
22789        let full = area_outside_inscribed_circle([0.0, 0.0, 454.0, 454.0], (454, 454));
22790        let exact = (1.0 - std::f64::consts::FRAC_PI_4) * 454.0 * 454.0;
22791        assert!(
22792            (full - exact).abs() / exact < 0.01,
22793            "full quad: {full} vs {exact}"
22794        );
22795        // A centered box inside the circle wastes nothing, exactly.
22796        assert_eq!(
22797            area_outside_inscribed_circle([127.0, 127.0, 327.0, 327.0], (454, 454)),
22798            0.0
22799        );
22800        // A box entirely inside a corner is all waste.
22801        let corner = area_outside_inscribed_circle([0.0, 0.0, 40.0, 40.0], (454, 454));
22802        assert!((corner - 1600.0).abs() < 1e-6, "corner box: {corner}");
22803    }
22804
22805    #[cfg(not(target_arch = "wasm32"))]
22806    #[test]
22807    fn fill_truth_opacity_histogram_classifies_solid_alpha_exactly() {
22808        let diag = FillAreaDiag::default();
22809        diag.reset_frame(454, 454);
22810        let full_frame = ViewportUniformParams {
22811            width: 454,
22812            height: 454,
22813            offset: [0.0, 0.0],
22814        };
22815        let mut opaque = ShapeData::zeroed();
22816        opaque.stroke_params[1] =
22817            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22818        opaque.rect = [0.0, 0.0, 100.0, 50.0];
22819        opaque.quad01 = [0.0, 0.0, 100.0, 0.0];
22820        opaque.quad23 = [0.0, 50.0, 100.0, 50.0];
22821        opaque.color = [1.0, 1.0, 1.0, 1.0];
22822        let mut faded = opaque;
22823        faded.color[3] = 0.82;
22824        let mut gradient = opaque;
22825        gradient.brush_type = 1;
22826        diag.add_shape_quads(&[opaque, faded, gradient], full_frame);
22827        // Plain rects are all-lit: 5000 px each, one per class.
22828        let lit = |class: FillOpacityClass| diag.frame_opacity[class as usize].get();
22829        assert_eq!(lit(FillOpacityClass::Opaque), 5000.0);
22830        assert_eq!(lit(FillOpacityClass::Translucent), 5000.0);
22831        assert_eq!(lit(FillOpacityClass::NonSolid), 5000.0);
22832        // The corner-hugging quads waste real area on a round display.
22833        assert!(diag.frame_corner.get() > 0.0);
22834
22835        // The same batch under an offset (offscreen) viewport must leave the
22836        // corner counter alone.
22837        let offscreen = FillAreaDiag::default();
22838        offscreen.reset_frame(454, 454);
22839        offscreen.add_shape_quads(&[opaque], offscreen_test_viewport());
22840        assert_eq!(offscreen.frame_corner.get(), 0.0);
22841    }
22842
22843    #[cfg(not(target_arch = "wasm32"))]
22844    #[test]
22845    fn fill_truth_retained_records_price_ranges_and_identity_corners() {
22846        let mut plain = ShapeData::zeroed();
22847        plain.stroke_params[1] =
22848            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22849        plain.rect = [200.0, 200.0, 20.0, 10.0];
22850        plain.quad01 = [200.0, 200.0, 220.0, 200.0];
22851        plain.quad23 = [200.0, 210.0, 220.0, 210.0];
22852        plain.color = [1.0, 1.0, 1.0, 1.0];
22853        let shapes = vec![rim_test_shape_data(), plain];
22854        let records = fill_diag_capture_records(&shapes, None);
22855        assert_eq!(records.len(), 2);
22856        assert_eq!(records[0].bucket, FillAreaDiag::RRECT_STROKE);
22857        assert_eq!(records[0].drawn_px2, 300.0 * 300.0);
22858        assert!(records[0].lit_px2 < records[0].drawn_px2, "a rim has slack");
22859        // A plain rect is exact: no slack at all.
22860        assert_eq!(records[1].bucket, FillAreaDiag::RECT);
22861        assert_eq!(records[1].lit_px2, records[1].drawn_px2);
22862
22863        let diag = FillAreaDiag::default();
22864        diag.reset_frame(454, 454);
22865        // Scaled replay: areas scale with the similarity squared, and the
22866        // capture-space AABBs no longer say where pixels land — no corner.
22867        let scaled = SimilarityTransform::new([0.0, 0.0], 0.0, 2.0);
22868        diag.add_retained_range(&records, 0, 2, &scaled);
22869        let drawn: f64 = records.iter().map(|record| record.drawn_px2).sum();
22870        assert!((diag.frame[FillAreaDiag::RETAINED].get() - drawn * 4.0).abs() < 1e-6);
22871        assert_eq!(diag.frame_corner.get(), 0.0);
22872
22873        // Identity replay: the rim's 300 px box on a 454 px round screen
22874        // pokes into the corner lunes.
22875        let identity_diag = FillAreaDiag::default();
22876        identity_diag.reset_frame(454, 454);
22877        identity_diag.add_retained_range(&records, 0, 2, &SimilarityTransform::IDENTITY);
22878        assert!(identity_diag.frame_corner.get() > 0.0);
22879        // And the range is respected: shape 1 alone has no rim slack.
22880        let tail = FillAreaDiag::default();
22881        tail.reset_frame(454, 454);
22882        tail.add_retained_range(&records, 1, 2, &SimilarityTransform::IDENTITY);
22883        assert_eq!(
22884            tail.frame[FillAreaDiag::RETAINED].get(),
22885            records[1].drawn_px2
22886        );
22887    }
22888
22889    #[cfg(not(target_arch = "wasm32"))]
22890    #[test]
22891    fn fill_truth_top_slack_dump_keeps_the_worst_ten() {
22892        let mut diag = FillAreaDiag::default();
22893        let records: Vec<FillDiagShapeRecord> = (0..12)
22894            .map(|index| FillDiagShapeRecord {
22895                drawn_px2: 1000.0 * (index + 1) as f64,
22896                lit_px2: 100.0,
22897                bucket: FillAreaDiag::ARC,
22898                opacity: FillOpacityClass::Opaque,
22899                aabb: [0.0, 0.0, 10.0, 10.0],
22900            })
22901            .collect();
22902        diag.note_retained_capture(3, &records);
22903        assert_eq!(diag.slack_top.len(), FILL_DIAG_SLACK_TOP);
22904        // Sorted by slack, worst first, and the two smallest fell off.
22905        assert_eq!(diag.slack_top[0].drawn_px2, 12000.0);
22906        assert_eq!(diag.slack_top[0].slot, 3);
22907        assert_eq!(diag.slack_top[0].shape, 11);
22908        for pair in diag.slack_top.windows(2) {
22909            assert!(pair[0].drawn_px2 - pair[0].lit_px2 >= pair[1].drawn_px2 - pair[1].lit_px2);
22910        }
22911        assert!(diag
22912            .slack_top
22913            .iter()
22914            .all(|entry| entry.drawn_px2 - entry.lit_px2 > 2000.0 - 100.0));
22915    }
22916
22917    #[cfg(not(target_arch = "wasm32"))]
22918    #[test]
22919    fn rim_mesh_band_accepts_only_huge_solid_unclipped_circle_rims() {
22920        let band = rim_mesh_band(&rim_test_shape_data()).expect("circle rim must qualify");
22921        assert_eq!(band.center, [190.0, 190.0]);
22922        assert_eq!(band.inner, 142.0);
22923        assert_eq!(band.outer, 150.0);
22924        assert_eq!(band.start, 0.0);
22925        assert!(
22926            band.sweep >= cranpose_ui_graphics::TAU,
22927            "a rim band is a closed ring"
22928        );
22929        // And it actually meshes through the shared emitter.
22930        let mut vertices = Vec::new();
22931        let mut indices = Vec::new();
22932        emit_arc_band_mesh(
22933            &rim_test_shape_data(),
22934            7,
22935            &band,
22936            &mut vertices,
22937            &mut indices,
22938        )
22939        .expect("rim must mesh");
22940        assert!(vertices.iter().all(|vertex| vertex.shape_idx == 7));
22941
22942        // Rounded SQUARE ring: radius well below the geometry half-extent.
22943        // Meshing it would under-cover the flat spans — the false positive
22944        // the circle gate exists to prevent.
22945        let mut square = rim_test_shape_data();
22946        square.radii = [100.0; 4];
22947        assert!(rim_mesh_band(&square).is_none());
22948
22949        // Non-square box.
22950        let mut oblong = rim_test_shape_data();
22951        oblong.rect = [40.0, 40.0, 300.0, 200.0];
22952        assert!(rim_mesh_band(&oblong).is_none());
22953
22954        // Gradient brush.
22955        let mut gradient = rim_test_shape_data();
22956        gradient.brush_type = 1;
22957        assert!(rim_mesh_band(&gradient).is_none());
22958
22959        // Live clip.
22960        let mut clipped = rim_test_shape_data();
22961        clipped.clip_rect = [0.0, 0.0, 400.0, 400.0];
22962        assert!(rim_mesh_band(&clipped).is_none());
22963
22964        // Small (100 × 100 < 65536 px²), even as a perfect circle.
22965        let mut small = rim_test_shape_data();
22966        small.rect = [40.0, 40.0, 100.0, 100.0];
22967        small.quad01 = [40.0, 40.0, 140.0, 40.0];
22968        small.quad23 = [40.0, 140.0, 140.0, 140.0];
22969        small.radii = [46.0; 4];
22970        assert!(rim_mesh_band(&small).is_none());
22971
22972        // Fill kind, not stroke.
22973        let mut fill = rim_test_shape_data();
22974        fill.stroke_params[1] =
22975            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
22976        assert!(rim_mesh_band(&fill).is_none());
22977
22978        // Zero stroke width.
22979        let mut hairline = rim_test_shape_data();
22980        hairline.stroke_params[0] = 0.0;
22981        assert!(rim_mesh_band(&hairline).is_none());
22982
22983        // Mismatched corner radii.
22984        let mut uneven = rim_test_shape_data();
22985        uneven.radii[2] = 145.0;
22986        assert!(rim_mesh_band(&uneven).is_none());
22987    }
22988
22989    #[cfg(not(target_arch = "wasm32"))]
22990    #[test]
22991    fn shape_batch_limits_follow_uniform_binding_size() {
22992        // With a 160-byte ShapeData, even a desktop-class 64 KiB binding can no
22993        // longer hold the full compile-time cap: 65536 / 160 = 409 < 768.
22994        let desktop_shapes = 65536 / std::mem::size_of::<ShapeData>();
22995        assert_eq!(desktop_shapes, 409);
22996        assert_eq!(
22997            ShapeBatchLimits::desktop(),
22998            ShapeBatchLimits {
22999                max_shapes_per_batch: desktop_shapes.min(MAX_SHAPES_PER_BATCH),
23000                max_gradient_stops: MAX_GRADIENT_STOPS,
23001                storage: false,
23002            }
23003        );
23004
23005        // The 16 KiB downlevel/GLES minimum must shrink batches to fit:
23006        // 16384 / 160-byte ShapeData = 102 shapes, 16384 / 32-byte stop = 512.
23007        let downlevel = ShapeBatchLimits::for_uniform_binding_size(16384);
23008        assert_eq!(downlevel.max_shapes_per_batch, 16384 / 160);
23009        assert_eq!(downlevel.max_shapes_per_batch, 102);
23010        assert_eq!(downlevel.max_gradient_stops, 512.min(MAX_GRADIENT_STOPS));
23011        assert!(downlevel.max_shapes_per_batch * std::mem::size_of::<ShapeData>() <= 16384);
23012        assert!(downlevel.max_gradient_stops * std::mem::size_of::<GradientStop>() <= 16384);
23013
23014        // Degenerate limits must not produce zero-sized buffers.
23015        let tiny = ShapeBatchLimits::for_uniform_binding_size(1);
23016        assert_eq!(tiny.max_shapes_per_batch, 1);
23017        assert_eq!(tiny.max_gradient_stops, 1);
23018    }
23019
23020    #[test]
23021    fn storage_shape_batch_limits_uncap_the_batch_and_start_small() {
23022        // A typical 128 MiB storage binding hits the compile-time ceilings,
23023        // not the device limit: one batch holds the whole scene.
23024        let storage = ShapeBatchLimits::for_storage_binding_size(128 << 20);
23025        assert!(storage.storage);
23026        assert_eq!(storage.max_shapes_per_batch, MAX_SHAPES_PER_STORAGE_BATCH);
23027        assert_eq!(
23028            storage.max_gradient_stops,
23029            MAX_GRADIENT_STOPS_PER_STORAGE_BATCH
23030        );
23031
23032        // The buffers must not be allocated at the multi-megabyte ceiling up
23033        // front; they start small and grow on demand.
23034        assert_eq!(
23035            storage.initial_shape_capacity(),
23036            INITIAL_STORAGE_BATCH_CAPACITY
23037        );
23038        assert_eq!(
23039            storage.initial_gradient_capacity(),
23040            INITIAL_STORAGE_BATCH_CAPACITY
23041        );
23042        assert_eq!(
23043            storage.data_binding_type(),
23044            wgpu::BufferBindingType::Storage { read_only: true }
23045        );
23046        assert!(storage
23047            .data_buffer_usage()
23048            .contains(wgpu::BufferUsages::STORAGE));
23049
23050        // Uniform mode keeps its start-at-the-cap invariant: a uniform
23051        // binding smaller than the shader's fixed array fails validation.
23052        let uniform = ShapeBatchLimits::desktop();
23053        assert_eq!(
23054            uniform.initial_shape_capacity(),
23055            uniform.max_shapes_per_batch
23056        );
23057        assert_eq!(
23058            uniform.initial_gradient_capacity(),
23059            uniform.max_gradient_stops
23060        );
23061        assert_eq!(
23062            uniform.data_binding_type(),
23063            wgpu::BufferBindingType::Uniform
23064        );
23065        assert!(uniform
23066            .data_buffer_usage()
23067            .contains(wgpu::BufferUsages::UNIFORM));
23068    }
23069
23070    #[test]
23071    fn storage_shape_shader_swaps_the_arrays_to_runtime_sized_storage() {
23072        let source =
23073            shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20), false);
23074        assert!(
23075            source.contains("var<storage, read> shape_data: array<ShapeData>;"),
23076            "storage-mode shader must declare a runtime-sized shape array"
23077        );
23078        assert!(
23079            source.contains("var<storage, read> gradient_stops: array<GradientStop>;"),
23080            "storage-mode shader must declare a runtime-sized gradient array"
23081        );
23082        assert!(
23083            !source.contains("var<uniform> shape_data"),
23084            "the uniform shape declaration must be fully replaced"
23085        );
23086        assert!(
23087            !source.contains("var<uniform> gradient_stops"),
23088            "the uniform gradient declaration must be fully replaced"
23089        );
23090        assert!(
23091            source.contains("var<storage, read> paint: array<vec4<f32>>;"),
23092            "storage-mode shader must declare the retained paint array"
23093        );
23094        assert!(
23095            source.contains("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)"),
23096            "storage-mode shader must read paint under the paint_select flag"
23097        );
23098        assert!(
23099            source.contains("fn vs_mesh("),
23100            "the storage rewrite must leave the retained-mesh vertex entry intact"
23101        );
23102        assert!(
23103            source.contains("fn vs_shape_instanced("),
23104            "the storage rewrite must leave the instanced-quad vertex entry intact"
23105        );
23106        assert_eq!(
23107            source
23108                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
23109                .count(),
23110            3,
23111            "vs_main, vs_shape_instanced and vs_mesh must all read paint under \
23112             the paint_select flag (meshless retained draws ride the instanced \
23113             entry when the selection is latched on)"
23114        );
23115
23116        // The storage variant is what native devices actually compile; it
23117        // must be valid WGSL, not just textually plausible.
23118        let module = naga::front::wgsl::parse_str(&source)
23119            .expect("storage-mode shape shader must parse as WGSL");
23120        naga::valid::Validator::new(
23121            naga::valid::ValidationFlags::all(),
23122            naga::valid::Capabilities::all(),
23123        )
23124        .validate(&module)
23125        .expect("storage-mode shape shader must validate for WebGPU");
23126    }
23127
23128    #[test]
23129    fn solid_trim_keeps_the_full_struct_locations_with_the_dropped_slots_vacant() {
23130        // Suspect #1 from the reverted first trim (16a5d312 / 371dd06a): the
23131        // survivors were renumbered densely. Every surviving varying line in
23132        // `VertexOutputSolid` must be byte-identical to its `VertexOutput`
23133        // line — same index, same interpolation, same type — and the two
23134        // dropped slots must stay vacant.
23135        let appendix = shaders::SOLID_TRIM_APPENDIX;
23136        for line in [
23137            "@location(0) color: vec4<f32>,",
23138            "@location(1) uv: vec2<f32>,",
23139            "@location(2) world_pos: vec2<f32>,",
23140            "@location(3) @interpolate(flat) rect: vec4<f32>,",
23141            "@location(4) @interpolate(flat) radii: vec4<f32>,",
23142            "@location(6) @interpolate(flat) clip_rect: vec4<f32>,",
23143            "@location(7) @interpolate(flat) stroke_params: vec4<f32>,",
23144            "@location(8) @interpolate(flat) arc_params: vec4<f32>,",
23145        ] {
23146            assert!(
23147                shaders::SHADER.contains(line),
23148                "`{line}` drifted out of VertexOutput; realign the trimmed \
23149                 struct line for line before touching anything else"
23150            );
23151            assert!(
23152                appendix.contains(line),
23153                "`{line}` must appear verbatim in VertexOutputSolid — the \
23154                 surviving varyings keep the full struct's location indices"
23155            );
23156        }
23157        assert!(
23158            !appendix.contains("@location(5)"),
23159            "location 5 is gradient_params' slot and must stay VACANT — \
23160             dense renumbering is the reverted attempt's suspect #1"
23161        );
23162        assert!(
23163            !appendix.contains("@location(9)"),
23164            "location 9 is brush's slot and must stay VACANT — dense \
23165             renumbering is the reverted attempt's suspect #1"
23166        );
23167        assert!(
23168            !appendix.contains("output.gradient_params") && !appendix.contains("output.brush"),
23169            "the trimmed vertex entries must not write the dropped varyings"
23170        );
23171    }
23172
23173    #[test]
23174    fn solid_trim_source_reaches_every_injection_and_validates() {
23175        // The trimmed entries are appended BEFORE `shape_shader_source`'s
23176        // rewrites, so the storage rewrite's paint-select injection must land
23177        // in all five vertex entries — a solid entry that missed it would
23178        // freeze every recolor on the retained slots it draws.
23179        let storage =
23180            shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20), true);
23181        for entry in [
23182            "fn vs_solid(",
23183            "fn vs_solid_instanced(",
23184            "fn fs_solid_trim(",
23185        ] {
23186            assert!(
23187                storage.contains(entry),
23188                "trimmed storage source must carry `{entry}`"
23189            );
23190        }
23191        assert_eq!(
23192            storage
23193                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
23194                .count(),
23195            5,
23196            "vs_main, vs_shape_instanced, vs_mesh, vs_solid and \
23197             vs_solid_instanced must all read paint under the paint_select \
23198             flag"
23199        );
23200
23201        // Both variants a native device can compile must be valid WGSL, flat
23202        // and with the display-clip z rewrite applied.
23203        let uniform = shape_shader_source(ShapeBatchLimits::desktop(), true);
23204        for source in [&storage, &uniform] {
23205            for depth in [false, true] {
23206                let text = display_clip::with_content_z(Cow::Owned(source.to_string()), depth);
23207                let module = naga::front::wgsl::parse_str(&text)
23208                    .expect("trimmed shape shader must parse as WGSL");
23209                naga::valid::Validator::new(
23210                    naga::valid::ValidationFlags::all(),
23211                    naga::valid::Capabilities::all(),
23212                )
23213                .validate(&module)
23214                .expect("trimmed shape shader must validate for WebGPU");
23215            }
23216        }
23217    }
23218
23219    #[test]
23220    fn solid_trim_flag_reads_the_documented_variable() {
23221        // The parity suite's trimmed arms set exactly this variable; a name
23222        // drift here would leave them silently comparing full against full.
23223        std::env::remove_var("CRANPOSE_SOLID_TRIM_VARYINGS");
23224        assert!(!solid_trim_varyings_enabled(), "the trim must default OFF");
23225        std::env::set_var("CRANPOSE_SOLID_TRIM_VARYINGS", "1");
23226        assert!(solid_trim_varyings_enabled());
23227        std::env::set_var("CRANPOSE_SOLID_TRIM_VARYINGS", "0");
23228        assert!(!solid_trim_varyings_enabled());
23229        std::env::remove_var("CRANPOSE_SOLID_TRIM_VARYINGS");
23230    }
23231
23232    #[test]
23233    fn uniform_shape_shader_keeps_the_in_record_color_and_no_paint_binding() {
23234        // The base text serves WebGL-class uniform devices, which can bind
23235        // no storage buffers: the paint array and its select must exist only
23236        // in the storage-mode rewrite.
23237        for source in [
23238            Cow::Borrowed(shaders::SHADER),
23239            shape_shader_source(ShapeBatchLimits::desktop(), false),
23240        ] {
23241            assert!(
23242                !source.contains("paint: array"),
23243                "the uniform variant must not declare a paint array"
23244            );
23245            assert!(
23246                source.contains("output.color = shape.color;"),
23247                "the uniform variant must read the color from ShapeData \
23248                 (this literal is also what `shape_shader_source` rewrites)"
23249            );
23250            assert!(
23251                source.contains("paint_select: f32"),
23252                "SimilarityTransform must name the flag field in both \
23253                 variants; the Rust mirror is Pod and uploads raw bytes"
23254            );
23255        }
23256    }
23257
23258    #[test]
23259    fn shipped_shape_shader_array_length_fits_the_downlevel_uniform_floor() {
23260        // The wasm build uses `shaders::SHADER` verbatim, so its declared array
23261        // length is simultaneously the wasm batch cap and the WebGL binding
23262        // size. It must fit the 16 KiB floor exactly.
23263        assert!(
23264            shaders::SHADER.contains("array<ShapeData, 102>"),
23265            "shape.wgsl array length must stay in sync with \
23266             `shape_shader_source`'s replace string and MAX_SHAPES_PER_BATCH"
23267        );
23268        assert!(102 * std::mem::size_of::<ShapeData>() <= 16384);
23269        assert!(103 * std::mem::size_of::<ShapeData>() > 16384);
23270    }
23271
23272    #[test]
23273    fn glyph_atlas_doubles_on_overflow_and_stops_at_the_device_ceiling() {
23274        // Every overflow buys one doubling, so an app that needs the old fixed
23275        // 4096 atlas reaches it in three resets and then stays there.
23276        assert_eq!(
23277            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MIN_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
23278            1024
23279        );
23280        assert_eq!(
23281            next_glyph_atlas_size(2048, TEXT_GLYPH_ATLAS_MAX_SIZE),
23282            TEXT_GLYPH_ATLAS_MAX_SIZE
23283        );
23284        assert_eq!(
23285            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MAX_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
23286            TEXT_GLYPH_ATLAS_MAX_SIZE
23287        );
23288
23289        // A device that only grants `downlevel_defaults()`'s 2048 caps the
23290        // growth there rather than failing to create the texture.
23291        assert_eq!(next_glyph_atlas_size(1024, 2048), 2048);
23292        assert_eq!(next_glyph_atlas_size(2048, 2048), 2048);
23293
23294        // Never zero and never wrapping, whatever the ceiling turns out to be.
23295        assert_eq!(next_glyph_atlas_size(u32::MAX, 4096), 4096);
23296        assert_eq!(next_glyph_atlas_size(0, 0), 1);
23297    }
23298
23299    #[test]
23300    fn glyph_atlas_uv_rect_normalizes_against_the_atlas_it_was_placed_in() {
23301        // The atlas grows, so a UV is only meaningful together with the size of
23302        // the texture the entry came from. Reading the size off a constant is
23303        // what would make a grown atlas sample the wrong glyph.
23304        let entry = GlyphAtlasEntry {
23305            x: 128,
23306            y: 256,
23307            width: 16,
23308            height: 32,
23309        };
23310
23311        let small = glyph_atlas_uv_rect(entry, 512);
23312        let large = glyph_atlas_uv_rect(entry, 4096);
23313
23314        assert_eq!(small.min, [128.0 / 512.0, 256.0 / 512.0]);
23315        assert_eq!(large.min, [128.0 / 4096.0, 256.0 / 4096.0]);
23316        assert_eq!(small.max, [144.0 / 512.0, 288.0 / 512.0]);
23317        assert_eq!(large.max, [144.0 / 4096.0, 288.0 / 4096.0]);
23318    }
23319
23320    #[test]
23321    fn native_shape_shader_source_uses_native_batch_limits() {
23322        let limits = ShapeBatchLimits::desktop();
23323        let source = shape_shader_source(limits, false);
23324
23325        assert!(source.contains(&format!(
23326            "array<ShapeData, {}>",
23327            limits.max_shapes_per_batch
23328        )));
23329        assert!(source.contains(&format!(
23330            "array<GradientStop, {}>",
23331            limits.max_gradient_stops
23332        )));
23333        // Sanity: the substitution actually fired rather than silently leaving
23334        // the downlevel literal in place.
23335        assert!(!source.contains("array<ShapeData, 146>"));
23336    }
23337
23338    #[test]
23339    fn stroked_and_arc_shapes_batch_together_with_fills() {
23340        // Strokes and arcs ride the same pipeline, the same ShapeData array and
23341        // the same blend state as fills, so a run of mixed shapes must stay a
23342        // single batch. If they ever split the batch, a polar UI built from
23343        // hundreds of arcs would pay a draw call per arc — precisely the cost
23344        // this primitive exists to remove.
23345        let fill = test_shape(0, BlendMode::SrcOver);
23346        let mut stroked = test_shape(1, BlendMode::SrcOver);
23347        stroked.stroke = Some(
23348            cranpose_ui_graphics::Stroke::new(3.0)
23349                .with_cap(StrokeCap::Round)
23350                .with_join(StrokeJoin::Bevel),
23351        );
23352        let mut arc = test_shape(2, BlendMode::SrcOver);
23353        arc.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
23354            Point::new(4.0, 4.0),
23355            2.0,
23356            4.0,
23357            0.0,
23358            1.0,
23359            StrokeCap::Round,
23360        ));
23361        let trailing_fill = test_shape(3, BlendMode::SrcOver);
23362
23363        assert!(!fill.has_stroke_or_arc());
23364        assert!(stroked.has_stroke_or_arc());
23365        assert!(arc.has_stroke_or_arc());
23366        assert!(!trailing_fill.has_stroke_or_arc());
23367
23368        let shapes = vec![fill, stroked, arc, trailing_fill];
23369        let ordered_items: Vec<_> = (0..shapes.len())
23370            .map(|index| (index, SegmentDrawItem::Shape(index)))
23371            .collect();
23372        let images = Vec::new();
23373
23374        let commands: Vec<_> = SegmentCommandIter::new(
23375            &ordered_items,
23376            &shapes,
23377            &images,
23378            ShapeBatchLimits::desktop(),
23379        )
23380        .collect();
23381
23382        assert_eq!(
23383            commands,
23384            vec![SegmentRenderCommand::DrawChunk(chunk(&[
23385                SegmentBatchPlan::Shape {
23386                    start: 0,
23387                    end: 4,
23388                    blend_mode: BlendMode::SrcOver,
23389                }
23390            ]))],
23391            "mixed fill/stroke/arc runs must stay one batch"
23392        );
23393    }
23394
23395    #[cfg(not(target_arch = "wasm32"))]
23396    #[test]
23397    fn native_segment_fusion_budget_allows_small_interleaved_chunks() {
23398        let ordered_items = vec![
23399            (0, SegmentDrawItem::Shape(0)),
23400            (1, SegmentDrawItem::Image(0)),
23401            (2, SegmentDrawItem::Text(0)),
23402            (3, SegmentDrawItem::Shape(1)),
23403        ];
23404        let shapes = vec![
23405            test_shape(0, BlendMode::SrcOver),
23406            test_shape(3, BlendMode::DstOut),
23407        ];
23408        let segment = chunk(&[
23409            SegmentBatchPlan::Shape {
23410                start: 0,
23411                end: 1,
23412                blend_mode: BlendMode::SrcOver,
23413            },
23414            SegmentBatchPlan::Image {
23415                start: 1,
23416                end: 2,
23417                blend_mode: BlendMode::SrcOver,
23418            },
23419            SegmentBatchPlan::Text { start: 2, end: 3 },
23420            SegmentBatchPlan::Shape {
23421                start: 3,
23422                end: 4,
23423                blend_mode: BlendMode::DstOut,
23424            },
23425        ]);
23426
23427        let budget = native_segment_fusion_budget(
23428            &ordered_items,
23429            &shapes,
23430            &[],
23431            &segment,
23432            ShapeBatchLimits::desktop(),
23433        )
23434        .expect("budget should be valid")
23435        .expect("chunk should fit native fusion budget");
23436
23437        assert_eq!(
23438            budget,
23439            NativeSegmentFusionBudget {
23440                shape_count: 2,
23441                gradient_stop_count: 0,
23442            }
23443        );
23444    }
23445
23446    #[cfg(not(target_arch = "wasm32"))]
23447    #[test]
23448    fn native_segment_fusion_budget_rejects_shape_uniform_overflow() {
23449        let ordered_items: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
23450            .map(|index| (index, SegmentDrawItem::Shape(index)))
23451            .collect();
23452        let shapes: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
23453            .map(|index| test_shape(index, BlendMode::SrcOver))
23454            .collect();
23455        let segment = chunk(&[
23456            SegmentBatchPlan::Shape {
23457                start: 0,
23458                end: MAX_SHAPES_PER_BATCH,
23459                blend_mode: BlendMode::SrcOver,
23460            },
23461            SegmentBatchPlan::Shape {
23462                start: MAX_SHAPES_PER_BATCH,
23463                end: MAX_SHAPES_PER_BATCH + 1,
23464                blend_mode: BlendMode::SrcOver,
23465            },
23466        ]);
23467
23468        let budget = native_segment_fusion_budget(
23469            &ordered_items,
23470            &shapes,
23471            &[],
23472            &segment,
23473            ShapeBatchLimits::desktop(),
23474        )
23475        .expect("valid plan");
23476
23477        assert_eq!(budget, None);
23478    }
23479
23480    #[cfg(not(target_arch = "wasm32"))]
23481    #[test]
23482    fn native_segment_fusion_budget_rejects_gradient_uniform_overflow() {
23483        let ordered_items = vec![(0, SegmentDrawItem::Shape(0))];
23484        let mut shape = test_shape(0, BlendMode::SrcOver);
23485        let brushes = vec![Brush::linear_gradient(vec![
23486            Color::BLACK;
23487            MAX_GRADIENT_STOPS + 1
23488        ])];
23489        shape.brush = SceneBrush::Gradient(0);
23490        let shapes = vec![shape];
23491        let segment = chunk(&[SegmentBatchPlan::Shape {
23492            start: 0,
23493            end: 1,
23494            blend_mode: BlendMode::SrcOver,
23495        }]);
23496
23497        let budget = native_segment_fusion_budget(
23498            &ordered_items,
23499            &shapes,
23500            &brushes,
23501            &segment,
23502            ShapeBatchLimits::desktop(),
23503        )
23504        .expect("valid plan");
23505
23506        assert_eq!(budget, None);
23507    }
23508
23509    #[cfg(not(target_arch = "wasm32"))]
23510    #[test]
23511    fn native_segment_fusion_partitions_shape_uniform_overflow() {
23512        // The uniform batch cap is derived from the device binding size and
23513        // the 112-byte ShapeData, not from the compile-time ceiling.
23514        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
23515        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
23516            .map(|index| (index, SegmentDrawItem::Shape(index)))
23517            .collect();
23518        let shapes: Vec<_> = (0..=desktop_batch_cap)
23519            .map(|index| test_shape(index, BlendMode::SrcOver))
23520            .collect();
23521        let segment = chunk(&[
23522            SegmentBatchPlan::Shape {
23523                start: 0,
23524                end: desktop_batch_cap,
23525                blend_mode: BlendMode::SrcOver,
23526            },
23527            SegmentBatchPlan::Shape {
23528                start: desktop_batch_cap,
23529                end: desktop_batch_cap + 1,
23530                blend_mode: BlendMode::SrcOver,
23531            },
23532        ]);
23533
23534        let partitions = native_segment_fusion_partitions(
23535            &ordered_items,
23536            &shapes,
23537            &[],
23538            &segment,
23539            ShapeBatchLimits::desktop(),
23540        )
23541        .expect("valid plan")
23542        .expect("overflowing segment should be partitionable");
23543
23544        assert_eq!(partitions.len(), 2);
23545        assert_eq!(
23546            partitions[0],
23547            NativeSegmentFusionPartition {
23548                chunk: chunk(&[SegmentBatchPlan::Shape {
23549                    start: 0,
23550                    end: desktop_batch_cap,
23551                    blend_mode: BlendMode::SrcOver,
23552                }]),
23553                budget: NativeSegmentFusionBudget {
23554                    shape_count: desktop_batch_cap,
23555                    gradient_stop_count: 0,
23556                },
23557            }
23558        );
23559        assert_eq!(
23560            partitions[1],
23561            NativeSegmentFusionPartition {
23562                chunk: chunk(&[SegmentBatchPlan::Shape {
23563                    start: desktop_batch_cap,
23564                    end: desktop_batch_cap + 1,
23565                    blend_mode: BlendMode::SrcOver,
23566                }]),
23567                budget: NativeSegmentFusionBudget {
23568                    shape_count: 1,
23569                    gradient_stop_count: 0,
23570                },
23571            }
23572        );
23573    }
23574
23575    #[cfg(not(target_arch = "wasm32"))]
23576    #[test]
23577    fn native_segment_fusion_partitions_gradient_uniform_overflow() {
23578        const STOPS_PER_SHAPE: usize = MAX_GRADIENT_STOPS / 2;
23579        let ordered_items = vec![
23580            (0, SegmentDrawItem::Shape(0)),
23581            (1, SegmentDrawItem::Shape(1)),
23582            (2, SegmentDrawItem::Shape(2)),
23583        ];
23584        let mut shapes = Vec::new();
23585        let brushes = vec![Brush::linear_gradient(vec![Color::BLACK; STOPS_PER_SHAPE])];
23586        for index in 0..3 {
23587            let mut shape = test_shape(index, BlendMode::SrcOver);
23588            shape.brush = SceneBrush::Gradient(0);
23589            shapes.push(shape);
23590        }
23591        let segment = chunk(&[SegmentBatchPlan::Shape {
23592            start: 0,
23593            end: 3,
23594            blend_mode: BlendMode::SrcOver,
23595        }]);
23596
23597        let partitions = native_segment_fusion_partitions(
23598            &ordered_items,
23599            &shapes,
23600            &brushes,
23601            &segment,
23602            ShapeBatchLimits::desktop(),
23603        )
23604        .expect("valid plan")
23605        .expect("overflowing gradient segment should be partitionable");
23606
23607        assert_eq!(partitions.len(), 2);
23608        assert_eq!(
23609            partitions[0],
23610            NativeSegmentFusionPartition {
23611                chunk: chunk(&[SegmentBatchPlan::Shape {
23612                    start: 0,
23613                    end: 2,
23614                    blend_mode: BlendMode::SrcOver,
23615                }]),
23616                budget: NativeSegmentFusionBudget {
23617                    shape_count: 2,
23618                    gradient_stop_count: MAX_GRADIENT_STOPS,
23619                },
23620            }
23621        );
23622        assert_eq!(
23623            partitions[1],
23624            NativeSegmentFusionPartition {
23625                chunk: chunk(&[SegmentBatchPlan::Shape {
23626                    start: 2,
23627                    end: 3,
23628                    blend_mode: BlendMode::SrcOver,
23629                }]),
23630                budget: NativeSegmentFusionBudget {
23631                    shape_count: 1,
23632                    gradient_stop_count: STOPS_PER_SHAPE,
23633                },
23634            }
23635        );
23636    }
23637
23638    #[cfg(not(target_arch = "wasm32"))]
23639    #[test]
23640    fn native_segment_fusion_accepts_layer_composite_chunks() {
23641        let ordered_items = vec![
23642            (0, SegmentDrawItem::Shape(0)),
23643            (1, SegmentDrawItem::Composite(0)),
23644            (2, SegmentDrawItem::ShaderComposite(0)),
23645            (3, SegmentDrawItem::Shape(1)),
23646        ];
23647        let shapes = vec![
23648            test_shape(0, BlendMode::SrcOver),
23649            test_shape(1, BlendMode::SrcOver),
23650        ];
23651        let segment = chunk(&[
23652            SegmentBatchPlan::Shape {
23653                start: 0,
23654                end: 1,
23655                blend_mode: BlendMode::SrcOver,
23656            },
23657            SegmentBatchPlan::Composite { start: 1, end: 2 },
23658            SegmentBatchPlan::ShaderComposite { start: 2, end: 3 },
23659            SegmentBatchPlan::Shape {
23660                start: 3,
23661                end: 4,
23662                blend_mode: BlendMode::SrcOver,
23663            },
23664        ]);
23665
23666        let partitions = native_segment_fusion_partitions(
23667            &ordered_items,
23668            &shapes,
23669            &[],
23670            &segment,
23671            ShapeBatchLimits::desktop(),
23672        )
23673        .expect("valid plan")
23674        .expect("composites are drawable inside the native fused pass");
23675
23676        assert_eq!(
23677            partitions,
23678            vec![NativeSegmentFusionPartition {
23679                chunk: segment,
23680                budget: NativeSegmentFusionBudget {
23681                    shape_count: 2,
23682                    gradient_stop_count: 0,
23683                },
23684            }],
23685            "layer composites and shader composites must preserve order without forcing separate render passes"
23686        );
23687    }
23688
23689    #[cfg(not(target_arch = "wasm32"))]
23690    #[test]
23691    fn native_segment_fusion_partitions_preserve_non_shape_order_at_budget_boundary() {
23692        // The uniform batch cap is derived from the device binding size and
23693        // the 112-byte ShapeData, not from the compile-time ceiling.
23694        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
23695        let ordered_items: Vec<_> = (0..desktop_batch_cap)
23696            .map(|index| (index, SegmentDrawItem::Shape(index)))
23697            .chain([
23698                (desktop_batch_cap, SegmentDrawItem::Image(0)),
23699                (
23700                    desktop_batch_cap + 1,
23701                    SegmentDrawItem::Shape(desktop_batch_cap),
23702                ),
23703            ])
23704            .collect();
23705        let shapes: Vec<_> = (0..=desktop_batch_cap)
23706            .map(|index| test_shape(index, BlendMode::SrcOver))
23707            .collect();
23708        let segment = chunk(&[
23709            SegmentBatchPlan::Shape {
23710                start: 0,
23711                end: desktop_batch_cap,
23712                blend_mode: BlendMode::SrcOver,
23713            },
23714            SegmentBatchPlan::Image {
23715                start: desktop_batch_cap,
23716                end: desktop_batch_cap + 1,
23717                blend_mode: BlendMode::SrcOver,
23718            },
23719            SegmentBatchPlan::Shape {
23720                start: desktop_batch_cap + 1,
23721                end: desktop_batch_cap + 2,
23722                blend_mode: BlendMode::SrcOver,
23723            },
23724        ]);
23725
23726        let partitions = native_segment_fusion_partitions(
23727            &ordered_items,
23728            &shapes,
23729            &[],
23730            &segment,
23731            ShapeBatchLimits::desktop(),
23732        )
23733        .expect("valid plan")
23734        .expect("overflowing segment should be partitionable");
23735
23736        assert_eq!(partitions.len(), 2);
23737        assert_eq!(
23738            partitions[0].chunk,
23739            chunk(&[
23740                SegmentBatchPlan::Shape {
23741                    start: 0,
23742                    end: desktop_batch_cap,
23743                    blend_mode: BlendMode::SrcOver,
23744                },
23745                SegmentBatchPlan::Image {
23746                    start: desktop_batch_cap,
23747                    end: desktop_batch_cap + 1,
23748                    blend_mode: BlendMode::SrcOver,
23749                },
23750            ])
23751        );
23752        assert_eq!(
23753            partitions[1].chunk,
23754            chunk(&[SegmentBatchPlan::Shape {
23755                start: desktop_batch_cap + 1,
23756                end: desktop_batch_cap + 2,
23757                blend_mode: BlendMode::SrcOver,
23758            }])
23759        );
23760    }
23761
23762    #[test]
23763    fn segment_command_iter_keeps_repeated_batch_kinds_in_one_chunk() {
23764        let ordered_items = vec![
23765            (0, SegmentDrawItem::Shape(0)),
23766            (1, SegmentDrawItem::Image(0)),
23767            (2, SegmentDrawItem::Shape(1)),
23768        ];
23769        let shapes = vec![
23770            test_shape(0, BlendMode::SrcOver),
23771            test_shape(2, BlendMode::DstOut),
23772        ];
23773        let images = vec![test_image(1, BlendMode::SrcOver)];
23774
23775        let commands: Vec<_> = SegmentCommandIter::new(
23776            &ordered_items,
23777            &shapes,
23778            &images,
23779            ShapeBatchLimits::desktop(),
23780        )
23781        .collect();
23782
23783        assert_eq!(
23784            commands,
23785            vec![SegmentRenderCommand::DrawChunk(chunk(&[
23786                SegmentBatchPlan::Shape {
23787                    start: 0,
23788                    end: 1,
23789                    blend_mode: BlendMode::SrcOver,
23790                },
23791                SegmentBatchPlan::Image {
23792                    start: 1,
23793                    end: 2,
23794                    blend_mode: BlendMode::SrcOver,
23795                },
23796                SegmentBatchPlan::Shape {
23797                    start: 2,
23798                    end: 3,
23799                    blend_mode: BlendMode::DstOut,
23800                },
23801            ]))]
23802        );
23803    }
23804
23805    #[test]
23806    fn segment_command_iter_splits_contiguous_shape_runs_at_uniform_batch_limit() {
23807        // The uniform batch cap is derived from the device binding size and
23808        // the 112-byte ShapeData, not from the compile-time ceiling.
23809        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
23810        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
23811            .map(|index| (index, SegmentDrawItem::Shape(index)))
23812            .collect();
23813        let shapes: Vec<_> = (0..=desktop_batch_cap)
23814            .map(|index| test_shape(index, BlendMode::SrcOver))
23815            .collect();
23816        let images = Vec::new();
23817
23818        let commands: Vec<_> = SegmentCommandIter::new(
23819            &ordered_items,
23820            &shapes,
23821            &images,
23822            ShapeBatchLimits::desktop(),
23823        )
23824        .collect();
23825
23826        assert_eq!(
23827            commands,
23828            vec![SegmentRenderCommand::DrawChunk(chunk(&[
23829                SegmentBatchPlan::Shape {
23830                    start: 0,
23831                    end: desktop_batch_cap,
23832                    blend_mode: BlendMode::SrcOver,
23833                },
23834                SegmentBatchPlan::Shape {
23835                    start: desktop_batch_cap,
23836                    end: desktop_batch_cap + 1,
23837                    blend_mode: BlendMode::SrcOver,
23838                },
23839            ]))]
23840        );
23841    }
23842
23843    #[test]
23844    fn segment_command_iter_keeps_shadows_as_explicit_boundaries() {
23845        let ordered_items = vec![
23846            (0, SegmentDrawItem::Shape(0)),
23847            (1, SegmentDrawItem::Shadow(0)),
23848            (2, SegmentDrawItem::Image(0)),
23849            (3, SegmentDrawItem::Text(0)),
23850        ];
23851        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
23852        let images = vec![test_image(2, BlendMode::SrcOver)];
23853
23854        let commands: Vec<_> = SegmentCommandIter::new(
23855            &ordered_items,
23856            &shapes,
23857            &images,
23858            ShapeBatchLimits::desktop(),
23859        )
23860        .collect();
23861
23862        assert_eq!(
23863            commands,
23864            vec![
23865                SegmentRenderCommand::DrawChunk(chunk(&[SegmentBatchPlan::Shape {
23866                    start: 0,
23867                    end: 1,
23868                    blend_mode: BlendMode::SrcOver,
23869                }])),
23870                SegmentRenderCommand::Shadow(0),
23871                SegmentRenderCommand::DrawChunk(chunk(&[
23872                    SegmentBatchPlan::Image {
23873                        start: 2,
23874                        end: 3,
23875                        blend_mode: BlendMode::SrcOver,
23876                    },
23877                    SegmentBatchPlan::Text { start: 3, end: 4 },
23878                ])),
23879            ]
23880        );
23881    }
23882
23883    #[test]
23884    fn staged_buffer_uploads_align_new_copies_to_copy_buffer_alignment() {
23885        let mut uploads = StagedBufferUploads::default();
23886        uploads.bytes.extend_from_slice(&[1, 2]);
23887
23888        uploads.stage(UploadTarget::ImageIndex, &[3, 4, 5, 6]);
23889
23890        assert_eq!(uploads.bytes, vec![1, 2, 0, 0, 3, 4, 5, 6]);
23891        assert_eq!(
23892            uploads.copies,
23893            vec![PendingBufferCopy {
23894                source_offset: 4,
23895                target_offset: 0,
23896                size: 4,
23897                target: UploadTarget::ImageIndex,
23898            }]
23899        );
23900    }
23901
23902    #[test]
23903    fn staged_buffer_uploads_ignore_empty_payloads() {
23904        let mut uploads = StagedBufferUploads::default();
23905
23906        uploads.stage(UploadTarget::Uniform, &[]);
23907
23908        assert!(uploads.is_empty());
23909        assert!(uploads.bytes.is_empty());
23910    }
23911
23912    #[test]
23913    fn staged_buffer_uploads_return_exact_payload_slice_for_copy() {
23914        let mut uploads = StagedBufferUploads::default();
23915        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
23916        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
23917
23918        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
23919        assert_eq!(uploads.payload_for_copy(uploads.copies[1]), &[5, 6, 7, 8]);
23920    }
23921
23922    #[test]
23923    fn staged_buffer_uploads_record_destination_offsets() {
23924        let mut uploads = StagedBufferUploads::default();
23925
23926        uploads.stage_at(UploadTarget::ImageIndex, 256, &[1, 2, 3, 4]);
23927
23928        assert_eq!(uploads.copies[0].target_offset, 256);
23929        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
23930    }
23931
23932    #[test]
23933    fn staged_buffer_uploads_truncate_restores_previous_state() {
23934        let mut uploads = StagedBufferUploads::default();
23935        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
23936        let bytes_len = uploads.bytes.len();
23937        let copies_len = uploads.copies.len();
23938        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
23939
23940        uploads.truncate(bytes_len, copies_len);
23941
23942        assert_eq!(uploads.bytes, vec![1, 2, 3, 4]);
23943        assert_eq!(uploads.copies.len(), 1);
23944    }
23945
23946    #[test]
23947    fn inner_shadow_composite_mask_uses_fill_shape_and_scale() {
23948        let mut fill = test_shape(0, BlendMode::SrcOver);
23949        fill.local_rect = Rect {
23950            x: 10.0,
23951            y: 12.0,
23952            width: 40.0,
23953            height: 20.0,
23954        };
23955        fill.shape = Some(RoundedCornerShape::uniform(6.0));
23956
23957        let cutout = test_shape(1, BlendMode::DstOut);
23958        let shadow = test_shadow_draw(vec![
23959            (fill, BlendMode::SrcOver),
23960            (cutout, BlendMode::DstOut),
23961        ]);
23962
23963        let mask = inner_shadow_composite_mask(&shadow, 1.5).expect("inner mask expected");
23964        assert_eq!(mask.rect, [15.0, 18.0, 60.0, 30.0]);
23965        assert_eq!(mask.radii, [9.0, 9.0, 9.0, 9.0]);
23966    }
23967
23968    #[test]
23969    fn inner_shadow_composite_mask_is_none_without_dst_out() {
23970        let fill = test_shape(0, BlendMode::SrcOver);
23971        let shadow = test_shadow_draw(vec![(fill, BlendMode::SrcOver)]);
23972        assert!(inner_shadow_composite_mask(&shadow, 1.0).is_none());
23973    }
23974
23975    #[test]
23976    fn render_effect_support_matrix_covers_all_variants() {
23977        let blur = RenderEffect::blur(4.0);
23978        let offset = RenderEffect::offset(2.0, 3.0);
23979        let shader = RenderEffect::runtime_shader(cranpose_ui_graphics::RuntimeShader::new(
23980            r#"
23981            @group(0) @binding(0) var input_texture: texture_2d<f32>;
23982            @group(0) @binding(1) var input_sampler: sampler;
23983            @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
23984            struct VertexOutput {
23985                @builtin(position) position: vec4<f32>,
23986                @location(0) uv: vec2<f32>,
23987            }
23988            @vertex
23989            fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
23990                var output: VertexOutput;
23991                let x = f32(i32(vertex_index & 1u) * 2 - 1);
23992                let y = f32(i32(vertex_index >> 1u) * 2 - 1);
23993                output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
23994                output.position = vec4<f32>(x, y, 0.0, 1.0);
23995                return output;
23996            }
23997            @fragment
23998            fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
23999                return textureSample(input_texture, input_sampler, input.uv);
24000            }
24001            "#,
24002        ));
24003        let chain = blur.clone().then(offset.clone());
24004
24005        assert!(is_render_effect_supported(&blur));
24006        assert!(is_render_effect_supported(&offset));
24007        assert!(is_render_effect_supported(&shader));
24008        assert!(is_render_effect_supported(&chain));
24009    }
24010
24011    #[test]
24012    fn clip_to_bounds_propagates_visual_clip_to_all_descendant_shapes() {
24013        // Simulates: root → clip_to_bounds container → child with shapes above/below clip
24014        // All shapes inside the clip_to_bounds container must have a clip set.
24015        let container_local_bounds = Rect {
24016            x: 0.0,
24017            y: 0.0,
24018            width: 800.0,
24019            height: 500.0,
24020        };
24021        // Container is placed at y=50 in parent space via transform_to_parent
24022        let container_clip_in_parent = Rect {
24023            x: 0.0,
24024            y: 50.0,
24025            width: 800.0,
24026            height: 500.0,
24027        };
24028
24029        // Shape that extends above the clip boundary (scroll content scrolled up)
24030        let shape_above = RenderNode::Primitive(PrimitiveEntry {
24031            phase: PrimitivePhase::BeforeChildren,
24032            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24033                primitive: DrawPrimitive::Rect {
24034                    rect: Rect {
24035                        x: 10.0,
24036                        y: -30.0,
24037                        width: 100.0,
24038                        height: 40.0,
24039                    },
24040                    brush: Brush::solid(Color::WHITE),
24041                    stroke: None,
24042                },
24043                clip: None,
24044            }),
24045        });
24046
24047        // Shape within the clip boundary
24048        let shape_inside = RenderNode::Primitive(PrimitiveEntry {
24049            phase: PrimitivePhase::BeforeChildren,
24050            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24051                primitive: DrawPrimitive::Rect {
24052                    rect: Rect {
24053                        x: 10.0,
24054                        y: 100.0,
24055                        width: 100.0,
24056                        height: 40.0,
24057                    },
24058                    brush: Brush::solid(Color::WHITE),
24059                    stroke: None,
24060                },
24061                clip: None,
24062            }),
24063        });
24064
24065        // Shape below the clip boundary (scroll content below viewport)
24066        let shape_below = RenderNode::Primitive(PrimitiveEntry {
24067            phase: PrimitivePhase::BeforeChildren,
24068            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24069                primitive: DrawPrimitive::Rect {
24070                    rect: Rect {
24071                        x: 10.0,
24072                        y: 600.0,
24073                        width: 100.0,
24074                        height: 40.0,
24075                    },
24076                    brush: Brush::solid(Color::WHITE),
24077                    stroke: None,
24078                },
24079                clip: None,
24080            }),
24081        });
24082
24083        // Content child layer (represents scroll content, translated up by scroll offset)
24084        let mut content_layer = test_layer(
24085            Rect {
24086                x: 0.0,
24087                y: 0.0,
24088                width: 800.0,
24089                height: 1000.0,
24090            },
24091            vec![shape_above, shape_inside, shape_below],
24092        );
24093        content_layer.transform_to_parent = ProjectiveTransform::translation(0.0, -30.0);
24094        content_layer.translated_content_context = true;
24095
24096        // Clip container (e.g. TabContent with clip_to_bounds)
24097        let mut clip_container = test_layer(
24098            container_local_bounds,
24099            vec![RenderNode::Layer(Box::new(content_layer))],
24100        );
24101        clip_container.clip_to_bounds = true;
24102        clip_container.transform_to_parent = ProjectiveTransform::translation(0.0, 50.0);
24103
24104        // Root
24105        let root = test_layer(
24106            Rect {
24107                x: 0.0,
24108                y: 0.0,
24109                width: 800.0,
24110                height: 600.0,
24111            },
24112            vec![RenderNode::Layer(Box::new(clip_container))],
24113        );
24114
24115        let mut rect_cache = HashMap::new();
24116        let mut requirements_cache = HashMap::new();
24117        let collected =
24118            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24119
24120        assert_eq!(
24121            collected.scene.shapes.len(),
24122            3,
24123            "all three shapes should be flattened into the scene"
24124        );
24125
24126        for (i, shape) in collected.scene.shapes.iter().enumerate() {
24127            assert!(
24128                shape.clip.is_some(),
24129                "shape {} at rect {:?} must have a clip from clip_to_bounds container, but clip is None",
24130                i,
24131                shape.rect
24132            );
24133            let clip = shape.clip.unwrap();
24134            assert_eq!(
24135                clip, container_clip_in_parent,
24136                "shape {} clip should match the clip_to_bounds container bounds in parent space",
24137                i
24138            );
24139        }
24140    }
24141
24142    #[test]
24143    fn clip_to_bounds_culls_child_layers_outside_boundary() {
24144        // Reproduces the out-of-clip rendering bug: a child layer with
24145        // graphics_layer.clip=true (e.g. from rounded_surface()) positioned
24146        // entirely below the parent's clip_to_bounds boundary must be culled.
24147        // Before the fix, resolve_clip returned None for non-overlapping rects,
24148        // which downstream code interpreted as "no clipping" instead of "fully clipped",
24149        // causing invisible content to render everywhere.
24150
24151        let clip_container_bounds = Rect {
24152            x: 0.0,
24153            y: 0.0,
24154            width: 800.0,
24155            height: 500.0,
24156        };
24157
24158        let shape_in_card = RenderNode::Primitive(PrimitiveEntry {
24159            phase: PrimitivePhase::BeforeChildren,
24160            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24161                primitive: DrawPrimitive::Rect {
24162                    rect: Rect {
24163                        x: 0.0,
24164                        y: 0.0,
24165                        width: 300.0,
24166                        height: 80.0,
24167                    },
24168                    brush: Brush::solid(Color::WHITE),
24169                    stroke: None,
24170                },
24171                clip: None,
24172            }),
24173        });
24174
24175        // Card layer with graphics_layer.clip=true, positioned BELOW the clip boundary
24176        let mut card_outside = crate::test_support::layer_node(
24177            Rect {
24178                x: 0.0,
24179                y: 0.0,
24180                width: 300.0,
24181                height: 80.0,
24182            },
24183            ProjectiveTransform::identity(),
24184            GraphicsLayer {
24185                clip: true,
24186                ..GraphicsLayer::default()
24187            },
24188            vec![shape_in_card.clone()],
24189        );
24190        card_outside.transform_to_parent = ProjectiveTransform::translation(10.0, 600.0);
24191
24192        // Card layer with graphics_layer.clip=true, positioned INSIDE the clip boundary
24193        let mut card_inside = crate::test_support::layer_node(
24194            Rect {
24195                x: 0.0,
24196                y: 0.0,
24197                width: 300.0,
24198                height: 80.0,
24199            },
24200            ProjectiveTransform::identity(),
24201            GraphicsLayer {
24202                clip: true,
24203                ..GraphicsLayer::default()
24204            },
24205            vec![shape_in_card],
24206        );
24207        card_inside.transform_to_parent = ProjectiveTransform::translation(10.0, 100.0);
24208
24209        // Content layer holding both cards
24210        let content = test_layer(
24211            Rect {
24212                x: 0.0,
24213                y: 0.0,
24214                width: 800.0,
24215                height: 1000.0,
24216            },
24217            vec![
24218                RenderNode::Layer(Box::new(card_inside)),
24219                RenderNode::Layer(Box::new(card_outside)),
24220            ],
24221        );
24222
24223        // Clip container
24224        let mut clip_container = test_layer(
24225            clip_container_bounds,
24226            vec![RenderNode::Layer(Box::new(content))],
24227        );
24228        clip_container.clip_to_bounds = true;
24229
24230        // Root
24231        let root = test_layer(
24232            Rect {
24233                x: 0.0,
24234                y: 0.0,
24235                width: 800.0,
24236                height: 600.0,
24237            },
24238            vec![RenderNode::Layer(Box::new(clip_container))],
24239        );
24240
24241        let mut rect_cache = HashMap::new();
24242        let mut requirements_cache = HashMap::new();
24243        let collected =
24244            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24245
24246        assert_eq!(
24247            collected.scene.shapes.len(),
24248            1,
24249            "only the card inside the clip boundary should produce shapes; \
24250             the card outside must be culled entirely"
24251        );
24252
24253        let shape = &collected.scene.shapes[0];
24254        assert!(
24255            shape.clip.is_some(),
24256            "the visible card's shape must have a clip from clip_to_bounds"
24257        );
24258    }
24259
24260    #[test]
24261    fn flattened_layer_shadow_z_index_is_below_content() {
24262        // Shadow must render behind content. When a child layer with shadow_elevation
24263        // is flattened (no isolation), its shadow z-index must be lower than any
24264        // content z-index so shadow draws render first.
24265        let shape = RenderNode::Primitive(PrimitiveEntry {
24266            phase: PrimitivePhase::BeforeChildren,
24267            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24268                primitive: DrawPrimitive::Rect {
24269                    rect: Rect {
24270                        x: 0.0,
24271                        y: 0.0,
24272                        width: 100.0,
24273                        height: 100.0,
24274                    },
24275                    brush: Brush::solid(Color::WHITE),
24276                    stroke: None,
24277                },
24278                clip: None,
24279            }),
24280        });
24281
24282        let child_bounds = Rect {
24283            x: 0.0,
24284            y: 0.0,
24285            width: 100.0,
24286            height: 100.0,
24287        };
24288
24289        let child = crate::test_support::layer_node(
24290            child_bounds,
24291            ProjectiveTransform::translation(50.0, 50.0),
24292            GraphicsLayer {
24293                shadow_elevation: 20.0,
24294                ..GraphicsLayer::default()
24295            },
24296            vec![shape],
24297        );
24298
24299        let root = test_layer(
24300            Rect {
24301                x: 0.0,
24302                y: 0.0,
24303                width: 800.0,
24304                height: 600.0,
24305            },
24306            vec![RenderNode::Layer(Box::new(child))],
24307        );
24308
24309        let mut rect_cache = HashMap::new();
24310        let mut requirements_cache = HashMap::new();
24311        let collected =
24312            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24313
24314        assert!(
24315            !collected.scene.shadow_draws.is_empty(),
24316            "shadow_elevation > 0 must produce shadow draws"
24317        );
24318        let max_shadow_z = collected
24319            .scene
24320            .shadow_draws
24321            .iter()
24322            .map(|s| s.z_index)
24323            .max()
24324            .unwrap();
24325        let min_content_z = collected
24326            .scene
24327            .shapes
24328            .iter()
24329            .map(|s| s.z_index)
24330            .min()
24331            .unwrap();
24332        assert!(
24333            max_shadow_z < min_content_z,
24334            "shadow z-index ({}) must be less than content z-index ({}); \
24335             shadows must render behind their content",
24336            max_shadow_z,
24337            min_content_z
24338        );
24339    }
24340
24341    /// One retained bundle op key with the fields the invalidation tests
24342    /// vary; the rest stay representative constants.
24343    #[cfg(not(target_arch = "wasm32"))]
24344    fn bundle_op(slot: u32, epoch: Option<u64>, first: u32, last: u32) -> RetainedBundleOpKey {
24345        RetainedBundleOpKey {
24346            slot,
24347            capture_epoch: epoch,
24348            first,
24349            last,
24350            retained_index: slot,
24351            has_mesh: false,
24352        }
24353    }
24354
24355    #[cfg(not(target_arch = "wasm32"))]
24356    fn bundle_key(ops: &[RetainedBundleOpKey]) -> RetainedBundleKey {
24357        RetainedBundleKey {
24358            depth: false,
24359            ops: ops.to_vec(),
24360        }
24361    }
24362
24363    /// The same stretch on consecutive frames reuses its bundle: one
24364    /// rebuild, then cached executes.
24365    #[cfg(not(target_arch = "wasm32"))]
24366    #[test]
24367    fn retained_bundle_cache_reuses_stable_keys() {
24368        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24369        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
24370        let key = bundle_key(&ops);
24371
24372        assert!(!cache.hit(&key), "empty cache must miss");
24373        cache.insert(key.clone(), 111);
24374        assert_eq!(cache.get(&key), Some(&111));
24375        cache.end_frame();
24376
24377        for _ in 0..3 {
24378            assert!(cache.hit(&bundle_key(&ops)), "stable key must stay cached");
24379            cache.end_frame();
24380        }
24381        assert_eq!(cache.stats(), (1, 3), "one rebuild, three cached executes");
24382    }
24383
24384    /// Recapture (epoch bump), span reorder, count change, range change and
24385    /// slot release each change the key, so a stale bundle can never satisfy
24386    /// the lookup.
24387    #[cfg(not(target_arch = "wasm32"))]
24388    #[test]
24389    fn retained_bundle_cache_invalidates_on_any_op_change() {
24390        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
24391        let variants: [Vec<RetainedBundleOpKey>; 5] = [
24392            // Recaptured slot 3: same id, bumped epoch.
24393            vec![bundle_op(3, Some(8), 0, 40), bundle_op(5, Some(9), 4, 12)],
24394            // Reordered stretch.
24395            vec![bundle_op(5, Some(9), 4, 12), bundle_op(3, Some(7), 0, 40)],
24396            // Op count changed.
24397            vec![bundle_op(3, Some(7), 0, 40)],
24398            // Draw range changed.
24399            vec![bundle_op(3, Some(7), 0, 41), bundle_op(5, Some(9), 4, 12)],
24400            // Slot 5 released: epoch gone.
24401            vec![bundle_op(3, Some(7), 0, 40), bundle_op(5, None, 4, 12)],
24402        ];
24403        for changed in variants {
24404            let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24405            cache.insert(bundle_key(&ops), 111);
24406            cache.end_frame();
24407            assert!(
24408                !cache.hit(&RetainedBundleKey {
24409                    depth: false,
24410                    ops: changed.clone()
24411                }),
24412                "changed key {changed:?} must not reuse the stale bundle"
24413            );
24414        }
24415    }
24416
24417    /// A stretch encoded for the display-clip culled pass (depth
24418    /// attachment, depth-variant pipelines) must never satisfy the flat
24419    /// pass's lookup — and vice versa.
24420    #[cfg(not(target_arch = "wasm32"))]
24421    #[test]
24422    fn retained_bundle_cache_keys_depth_variants_apart() {
24423        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24424        let ops = vec![bundle_op(3, Some(7), 0, 40)];
24425        cache.insert(
24426            RetainedBundleKey {
24427                depth: false,
24428                ops: ops.clone(),
24429            },
24430            111,
24431        );
24432        cache.end_frame();
24433        assert!(
24434            !cache.hit(&RetainedBundleKey { depth: true, ops }),
24435            "a flat bundle must not replay into the display-clip culled pass"
24436        );
24437    }
24438
24439    /// Entries a frame does not use are evicted at its end — bundles pin
24440    /// slot buffers, so unused ones must not accumulate — and `clear` (the
24441    /// slot-release path) empties the cache outright.
24442    #[cfg(not(target_arch = "wasm32"))]
24443    #[test]
24444    fn retained_bundle_cache_evicts_unused_entries() {
24445        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24446        let stale = bundle_key(&[bundle_op(1, Some(1), 0, 6)]);
24447        let live = bundle_key(&[bundle_op(2, Some(2), 0, 6)]);
24448        cache.insert(stale.clone(), 1);
24449        cache.insert(live.clone(), 2);
24450        cache.end_frame();
24451
24452        assert!(cache.hit(&live));
24453        cache.end_frame();
24454
24455        assert!(
24456            !cache.hit(&stale),
24457            "entry unused for a frame must have been evicted"
24458        );
24459        assert!(cache.hit(&live), "used entry must survive eviction");
24460
24461        cache.clear();
24462        assert!(!cache.hit(&live), "clear must drop every entry");
24463    }
24464}