Skip to main content

cranpose_render_wgpu/
render.rs

1//! GPU rendering implementation using WGPU
2
3use crate::display_clip;
4#[cfg(not(target_arch = "wasm32"))]
5use crate::display_clip::DisplayVisibleRegion;
6use crate::effect_renderer::{
7    projective_dest_bounds_rect, CompositeBatchItem, CompositeSampleMode, EffectRenderer,
8    EffectScratchTargetProvider, ProjectiveSurfaceComposite, RoundedCompositeMask,
9    ShaderCompositeBatchItem,
10};
11#[cfg(not(target_arch = "wasm32"))]
12use crate::effect_renderer::{PreparedProjectiveComposite, ProjectiveCompositeItem};
13use crate::frame_graph::{
14    FrameCommandRecorder, FrameTextureDescriptor, WgpuFrameGraph, WgpuFrameGraphExecutor,
15};
16use crate::frame_packet::{
17    CancelReason, FramePacket, PacketRoot, PresentOutcome, RenderReturns, RootSurfacePacket,
18};
19use crate::layer_events::{
20    collect_effect_ranges, collect_layer_events, LayerEvent, LayerEventKind,
21};
22use crate::layer_surface_cache::LayerSurfaceCache;
23#[cfg(not(target_arch = "wasm32"))]
24use crate::lazy_resource::LazyGpuResource;
25use crate::lazy_resource::PassPipeline;
26#[cfg(test)]
27use crate::normalized_scene::{
28    build_scene_window, collect_layer_contents, collect_layer_contents_with_translation_context,
29    filtered_effect_layer_index, scene_bounds, SceneWindowSource,
30};
31#[cfg(test)]
32use crate::normalized_scene::{estimate_layer_surface_rect, motion_stable_capture_bounds};
33use crate::normalized_scene::{translate_quad, ChildLayerComposite, CollectedLayer};
34use crate::offscreen::{composition_bytes_per_pixel, OffscreenTarget, COMPOSITION_FORMAT};
35use crate::output_conversion::OutputConverter;
36use crate::rect_to_quad;
37use crate::scene::{
38    BackdropLayer, CompositorScene, DrawOp, DrawOpKind, DrawShape, EffectLayer, ImageDraw,
39    RetainedDraw, SceneBrush, ShadowDraw, SimilarityTransform, SnapAnchor, TextDraw,
40};
41#[cfg(not(target_arch = "wasm32"))]
42use crate::segment_surface::{
43    Affine2, CaptureRect, SegmentSurfaceCache, SegmentSurfaceDecision, SegmentSurfaceKey,
44    SEGMENT_CAPTURE_SLOTS, SEGMENT_CAPTURE_UNIFORM_STRIDE,
45};
46use crate::shaders;
47#[cfg(test)]
48use crate::surface_executor::surface_target_size;
49use crate::surface_executor::{
50    apply_backdrop_layer_to_target as execute_apply_backdrop_layer_to_target,
51    axis_aligned_quad_rect, backdrop_underlay_is_covered_by_local_content,
52    canonicalize_device_coordinate, canonicalized_scaled_quad, canonicalized_scaled_rect,
53    composite_surface_to_view as execute_composite_surface_to_view, device_pixel_bounds_for_rect,
54    offscreen_byte_size, render_effect_layer_to_target as execute_render_effect_layer_to_target,
55    render_layer_surface as execute_render_layer_surface,
56    render_root_direct as execute_render_root_direct, root_direct_scene_events_are_supported,
57    scaled_quad, snap_delta_for_anchor, snap_motion_stable_dest_quad,
58    translation_stable_anchored_device_pixel_bounds, DevicePixelBounds, LayerSurfaceTexture,
59    SurfaceExecutionBackend,
60};
61#[cfg(test)]
62use crate::surface_executor::{clamp_effect_surface_scale, visible_layer_rect};
63#[cfg(test)]
64use crate::surface_plan::root_can_render_directly_cached;
65#[cfg(test)]
66use crate::surface_plan::{
67    composite_sample_mode_for_effect_layer, composite_sample_mode_for_requirements,
68    direct_translation, effect_layer_target_scale, layer_contains_descendant_backdrop,
69    layer_surface_requirements, layer_surface_requirements_cached, layer_surface_scale,
70    layer_surface_target_scale, layer_uses_external_backdrop_input, TranslatedContentAxes,
71};
72use crate::surface_plan::{LayerSurfaceRequest, TranslationRenderContext};
73#[cfg(test)]
74use crate::surface_requirements::SurfaceRequirement;
75use crate::surface_requirements::SurfaceRequirementSet;
76use crate::DebugCpuAllocationStats;
77use bytemuck::{Pod, Zeroable};
78#[cfg(any(not(target_arch = "wasm32"), test))]
79use cranpose_core::collections::map::HashMap;
80use cranpose_core::{hash::default as default_hash, NodeId};
81use cranpose_render_common::bounded_lru_cache::BoundedLruCache;
82use cranpose_render_common::geometry::blur_extent_margin;
83use cranpose_render_common::graph::quad_bounds;
84#[cfg(test)]
85use cranpose_render_common::graph::{
86    CachePolicy, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform,
87    RenderNode,
88};
89use cranpose_render_common::raster_cache::LayerRasterCacheKey;
90#[cfg(test)]
91use cranpose_render_common::raster_cache::ScaleBucket;
92use cranpose_render_common::software_text_raster::{
93    collect_solid_text_atlas_run, measure_text_with_font,
94    rasterize_annotated_text_to_image_with_glyph_cache, rasterize_text_to_image_with_glyph_cache,
95    SoftwareGlyphAtlasGlyph, SoftwareGlyphAtlasKey, SoftwareGlyphAtlasPlacement,
96    SoftwareGlyphAtlasRunGlyph, SoftwareGlyphRasterCache, SoftwareTextFontSet,
97};
98#[cfg(test)]
99use cranpose_ui_graphics::GraphicsLayer;
100use cranpose_ui_graphics::{
101    BlendMode, Brush, Color, ColorFilter, FxHasher, ImageBitmap, ImageSampling, Point, Rect,
102    RenderEffect, RenderHash, RuntimeShader, StrokeCap, StrokeJoin, TileMode,
103};
104use std::borrow::Cow;
105use std::cell::Cell;
106use std::hash::{Hash, Hasher};
107use std::ops::Range;
108use std::rc::Rc;
109#[cfg(not(target_arch = "wasm32"))]
110use std::sync::atomic::{AtomicUsize, Ordering};
111use std::sync::{mpsc, Arc};
112use std::time::Duration;
113use web_time::Instant;
114
115use crate::gpu_stats;
116use crate::gpu_stats::gpu_stats_enabled;
117use crate::pipeline::push_layer_shadow;
118
119/// Must equal the `array<ShapeData, N>` literal in `shape.wgsl`: on wasm the
120/// shader source is used verbatim, so a larger batch cap here would index past
121/// the declared array. 102 x 160-byte ShapeData = 16320 bytes, the most that
122/// fits WebGL's 16 KiB uniform-binding floor.
123#[cfg(target_arch = "wasm32")]
124const MAX_SHAPES_PER_BATCH: usize = 102;
125#[cfg(not(target_arch = "wasm32"))]
126const MAX_SHAPES_PER_BATCH: usize = 768;
127#[cfg(target_arch = "wasm32")]
128const MAX_GRADIENT_STOPS: usize = 256;
129#[cfg(not(target_arch = "wasm32"))]
130const MAX_GRADIENT_STOPS: usize = 1024;
131
132/// Per-pass ceilings when the shape and gradient arrays live in storage
133/// buffers instead of uniforms. These are not hardware limits — storage
134/// bindings are hundreds of megabytes everywhere — they bound worst-case
135/// buffer growth: 65 536 shapes is a 7 MiB shape buffer and a 12 MiB vertex
136/// buffer, far past any real scene, while still forcing a batch split before
137/// a pathological one can ask for gigabytes.
138#[cfg(not(target_arch = "wasm32"))]
139const MAX_SHAPES_PER_STORAGE_BATCH: usize = 1 << 16;
140#[cfg(not(target_arch = "wasm32"))]
141const MAX_GRADIENT_STOPS_PER_STORAGE_BATCH: usize = 1 << 16;
142
143/// How many shapes/stops the storage-mode buffers start out sized for. In
144/// uniform mode the initial capacity must equal the cap (a uniform binding
145/// smaller than the shader's fixed-length array fails validation), but a
146/// runtime-sized storage array binds at any size, so start small and let
147/// `ensure_capacity` double toward the cap as scenes demand.
148#[cfg(not(target_arch = "wasm32"))]
149const INITIAL_STORAGE_BATCH_CAPACITY: usize = 1024;
150
151/// Shape/gradient batch capacities derived from the actual device limits.
152///
153/// Where storage buffers are available (any real Vulkan/Metal/D3D device, and
154/// GL only when it exposes SSBOs to fragment shaders) the arrays are bound as
155/// read-only storage and a whole scene fits one batch. Otherwise they fall
156/// back to uniform arrays: the compile-time `MAX_*` constants assume
157/// desktop-class 64 KiB uniform bindings, while Android downlevel and
158/// GLES-class devices may only offer the 16 KiB spec minimum; sizing the
159/// buffers (and the matching WGSL array lengths) past
160/// `max_uniform_buffer_binding_size` makes the very first "Shape Bind Group"
161/// fail validation and aborts the app.
162#[derive(Clone, Copy, Debug, Eq, PartialEq)]
163struct ShapeBatchLimits {
164    max_shapes_per_batch: usize,
165    max_gradient_stops: usize,
166    storage: bool,
167}
168
169impl ShapeBatchLimits {
170    fn for_device(device: &wgpu::Device, downlevel: wgpu::DownlevelFlags) -> Self {
171        Self::select(&device.limits(), downlevel)
172    }
173
174    /// Storage mode or uniform mode, from the two things that decide it.
175    ///
176    /// Split out from `for_device` because the interesting case cannot be
177    /// reached with a device in hand: it needs an adapter that reports storage
178    /// buffers and no vertex-stage access, which is every ARM Mali GLES driver
179    /// and no desktop.
180    fn select(limits: &wgpu::Limits, _downlevel: wgpu::DownlevelFlags) -> Self {
181        #[cfg(not(target_arch = "wasm32"))]
182        if limits.max_storage_buffers_per_shader_stage >= 2
183            // `max_storage_buffers_per_shader_stage` alone is NOT the question,
184            // even though its name reads like a per-stage minimum. On ARM's
185            // GLES driver it comes back non-zero off the fragment stage while
186            // the vertex stage has no storage at all, so the check passed, the
187            // storage layout was built, and binding 0 -- the shape array, which
188            // is VERTEX_FRAGMENT because `vs_main` pulls quad corners out of it
189            // -- failed validation the moment the layout was created:
190            //
191            //   In Device::create_bind_group_layout, label = 'Shape Bind Group
192            //   Layout'; Binding 0 entry is invalid; Downlevel flags
193            //   DownlevelFlags(VERTEX_STORAGE) are required but not supported
194            //   on the device.
195            //
196            // wgpu treats that as fatal, so the renderer thread panicked and
197            // the app dropped back to the launcher on Mali-G76 (r18p0, 2019)
198            // and Mali-G715 (r54p3, 2024) alike -- driver age is not the
199            // variable. Adreno 650 and Adreno 702 have the flag and are
200            // unaffected. `VERTEX_STORAGE` is the flag that actually answers
201            // the question the layout asks, so ask it.
202            && _downlevel.contains(wgpu::DownlevelFlags::VERTEX_STORAGE)
203        {
204            return Self::for_storage_binding_size(limits.max_storage_buffer_binding_size);
205        }
206        Self::for_uniform_binding_size(limits.max_uniform_buffer_binding_size)
207    }
208
209    fn for_uniform_binding_size(max_uniform_buffer_binding_size: u64) -> Self {
210        let binding = max_uniform_buffer_binding_size as usize;
211        Self {
212            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
213                .clamp(1, MAX_SHAPES_PER_BATCH),
214            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
215                .clamp(1, MAX_GRADIENT_STOPS),
216            storage: false,
217        }
218    }
219
220    #[cfg(not(target_arch = "wasm32"))]
221    fn for_storage_binding_size(max_storage_buffer_binding_size: u64) -> Self {
222        let binding = max_storage_buffer_binding_size as usize;
223        Self {
224            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
225                .clamp(1, MAX_SHAPES_PER_STORAGE_BATCH),
226            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
227                .clamp(1, MAX_GRADIENT_STOPS_PER_STORAGE_BATCH),
228            storage: true,
229        }
230    }
231
232    fn initial_shape_capacity(&self) -> usize {
233        #[cfg(not(target_arch = "wasm32"))]
234        if self.storage {
235            return self
236                .max_shapes_per_batch
237                .min(INITIAL_STORAGE_BATCH_CAPACITY);
238        }
239        self.max_shapes_per_batch
240    }
241
242    fn initial_gradient_capacity(&self) -> usize {
243        #[cfg(not(target_arch = "wasm32"))]
244        if self.storage {
245            return self.max_gradient_stops.min(INITIAL_STORAGE_BATCH_CAPACITY);
246        }
247        self.max_gradient_stops
248    }
249
250    fn data_buffer_usage(&self) -> wgpu::BufferUsages {
251        if self.storage {
252            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST
253        } else {
254            wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST
255        }
256    }
257
258    fn data_binding_type(&self) -> wgpu::BufferBindingType {
259        if self.storage {
260            wgpu::BufferBindingType::Storage { read_only: true }
261        } else {
262            wgpu::BufferBindingType::Uniform
263        }
264    }
265
266    #[cfg(test)]
267    fn desktop() -> Self {
268        Self::for_uniform_binding_size(wgpu::Limits::default().max_uniform_buffer_binding_size)
269    }
270}
271#[cfg(target_arch = "wasm32")]
272const HARD_MAX_BUFFER_MB: usize = 64; // Maximum 64MB per buffer (image vertex/index only)
273const MAX_SHADOW_SURFACE_CACHE_ITEMS: usize = 512;
274// Sized for HiDPI: a 4K fractional-scale screen full of shadowed panels needs
275// ~10-15 rasters of 4-12MB each; a 64MB budget made the large entries evict
276// each other every frame during scroll, re-blurring tens of megapixels.
277const MAX_SHADOW_SURFACE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
278const MAX_TEXT_IMAGE_CACHE_ITEMS: usize = 1024;
279const MAX_TEXT_GLYPH_MASK_CACHE_ITEMS: usize = 8192;
280const MAX_TEXT_GLYPH_ATLAS_ITEMS: usize = 8192;
281const MAX_TEXT_GLYPH_RUN_CACHE_ITEMS: usize = 1024;
282#[cfg(not(target_arch = "wasm32"))]
283const MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS: usize = 1024;
284#[cfg(not(target_arch = "wasm32"))]
285const MIN_RETAINED_TEXT_GLYPH_QUADS: usize = 192;
286#[cfg(not(target_arch = "wasm32"))]
287const OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS: f64 = 0.75;
288#[cfg(not(target_arch = "wasm32"))]
289const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES: usize = 2;
290#[cfg(not(target_arch = "wasm32"))]
291const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS: usize = 160;
292#[cfg(not(target_arch = "wasm32"))]
293const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS: usize = 160;
294/// Side length the glyph atlas starts at, and the one it doubles towards.
295///
296/// The atlas is square and `R8Unorm`, so the maximum is a 16 MiB texture. That
297/// was also the starting size until it became the single largest resource the
298/// renderer allocated: a 454x454 watch face draws a couple of hundred distinct
299/// glyphs and needs well under a megabyte of them, but paid the full 16 MiB at
300/// renderer construction, before a single glyph had been rastered. Starting at
301/// `MIN` and doubling on overflow (see `TextGlyphAtlas::reset`) costs at most
302/// three extra resets for a workload that genuinely needs the large atlas —
303/// which then behaves exactly as the fixed 4096 atlas did — and costs a
304/// text-light screen 256 KiB instead of 16 MiB, permanently.
305const TEXT_GLYPH_ATLAS_MIN_SIZE: u32 = 512;
306const TEXT_GLYPH_ATLAS_MAX_SIZE: u32 = 4096;
307const TEXT_GLYPH_ATLAS_PADDING: u32 = 1;
308const MAX_TEXT_LINE_INDEX_CACHE_ITEMS: usize = 512;
309const MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER: usize = 2;
310const MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES: usize = 128;
311const CACHE_MISS_WARMUP_FRAMES: u8 = 1;
312pub(crate) const CLEAR_COLOR: wgpu::Color = wgpu::Color {
313    r: cranpose_render_common::FRAME_CLEAR_COLOR[0] as f64,
314    g: cranpose_render_common::FRAME_CLEAR_COLOR[1] as f64,
315    b: cranpose_render_common::FRAME_CLEAR_COLOR[2] as f64,
316    a: cranpose_render_common::FRAME_CLEAR_COLOR[3] as f64,
317};
318#[cfg(not(target_arch = "wasm32"))]
319const INITIAL_UPLOAD_BUFFER_BYTES: u64 = 4 * 1024;
320#[cfg(not(target_arch = "wasm32"))]
321const INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS: usize = 128;
322const MAX_TEXTURE_CACHE_ITEMS: usize = 256;
323/// Byte ceiling for `image_texture_cache` (see `CachedImageTexture::bytes`).
324/// Generous enough for a screenful of full-page images plus thumbnails;
325/// small enough that a camera preview stream can never pin gigabytes.
326const MAX_IMAGE_TEXTURE_CACHE_BYTES: usize = 256 * 1024 * 1024;
327const RETAINED_STAGED_UPLOAD_BYTES: usize = 256 * 1024;
328const RETAINED_STAGED_UPLOAD_COPIES: usize = 128;
329pub(crate) const RETAINED_LAYER_REQUIREMENTS_CAPACITY: usize = 512;
330const DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS: f64 = 4.0;
331#[cfg(not(target_arch = "wasm32"))]
332static SEGMENT_DIAG_LINES: AtomicUsize = AtomicUsize::new(0);
333// Reclaim oversized text scratch allocations only after a meaningful 4x collapse
334// from a previously large frame; smaller swings are left alone to avoid churn.
335
336fn wgpu_render_stage_telemetry_threshold_ms() -> Option<f64> {
337    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
338    *THRESHOLD_MS.get_or_init(|| {
339        let explicit = std::env::var("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY_MS")
340            .ok()
341            .and_then(|value| value.parse::<f64>().ok())
342            .filter(|value| value.is_finite() && *value >= 0.0);
343        explicit.or_else(|| {
344            std::env::var_os("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY")
345                .is_some()
346                .then_some(DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS)
347        })
348    })
349}
350
351pub(crate) fn instant_ms(start: Instant, end: Instant) -> f64 {
352    end.duration_since(start).as_secs_f64() * 1000.0
353}
354
355pub(crate) fn should_log_wgpu_render_stage(start: Instant, end: Instant) -> Option<f64> {
356    let threshold_ms = wgpu_render_stage_telemetry_threshold_ms()?;
357    let total_ms = instant_ms(start, end);
358    (total_ms >= threshold_ms).then_some(total_ms)
359}
360
361fn admit_layer_surface_cache_miss_impl(
362    key: &LayerRasterCacheKey,
363    observed_scene_range_misses: &mut BoundedLruCache<LayerRasterCacheKey, ()>,
364) -> bool {
365    if !key.is_scene_range() {
366        return true;
367    }
368    if observed_scene_range_misses.contains(key) {
369        return true;
370    }
371    observed_scene_range_misses.put(*key, ());
372    false
373}
374
375#[cfg(test)]
376fn first_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
377    let mut observed_scene_range_misses =
378        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
379    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
380}
381
382#[cfg(test)]
383fn repeated_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
384    let mut observed_scene_range_misses =
385        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
386    let _ = admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses);
387    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
388}
389
390pub static PRESENTED_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
391
392pub fn frames_presented() -> u64 {
393    PRESENTED_FRAMES.load(std::sync::atomic::Ordering::Relaxed)
394}
395
396fn frame_stats_need_warmup_frame(snapshot: &gpu_stats::FrameStatsSnapshot) -> bool {
397    snapshot.layer_cache_misses > 0
398        || snapshot.shadow_shape_cache_misses > 0
399        || snapshot.text_image_cache_misses > 0
400        || snapshot.text_glyph_atlas_misses > 0
401}
402
403fn update_frame_warmup_budget(pending_frames: &mut u8, snapshot: &gpu_stats::FrameStatsSnapshot) {
404    if *pending_frames > 0 {
405        *pending_frames = pending_frames.saturating_sub(1);
406    } else if frame_stats_need_warmup_frame(snapshot) {
407        *pending_frames = CACHE_MISS_WARMUP_FRAMES;
408    }
409}
410
411fn text_atlas_fallback_diag_enabled() -> bool {
412    cranpose_core::env_flag!("CRANPOSE_TEXT_ATLAS_FALLBACK_DIAG")
413}
414
415fn text_glyph_run_diag_enabled() -> bool {
416    cranpose_core::env_flag!("CRANPOSE_TEXT_GLYPH_RUN_DIAG")
417}
418
419fn root_direct_diag_enabled() -> bool {
420    cranpose_core::env_flag!("CRANPOSE_ROOT_DIRECT_DIAG")
421}
422
423fn scene_layer_events_precede_z(scene: &CompositorScene, z_index: usize) -> bool {
424    scene
425        .effect_layers
426        .iter()
427        .any(|layer| layer.z_start < z_index && 0 < layer.z_end)
428        || scene
429            .backdrop_layers
430            .iter()
431            .any(|layer| layer.z_index < z_index)
432}
433
434fn direct_root_child_can_be_replayed_into_later_underlay(child: &ChildLayerComposite) -> bool {
435    child.backdrop.is_none()
436        && !child.has_effect
437        && child.shadow_draws.is_empty()
438        && axis_aligned_quad_rect(child.dest_quad).is_some()
439}
440
441fn rects_overlap(a: Rect, b: Rect) -> bool {
442    let a_right = a.x + a.width;
443    let a_bottom = a.y + a.height;
444    let b_right = b.x + b.width;
445    let b_bottom = b.y + b.height;
446    a.x < b_right && b.x < a_right && a.y < b_bottom && b.y < a_bottom
447}
448
449pub(crate) fn direct_root_child_underlays_are_supported(
450    collected: &CollectedLayer,
451    root_target_reads: bool,
452) -> bool {
453    for (child_index, child) in collected.child_layers.iter().enumerate() {
454        if child.backdrop.is_some() && !root_target_reads {
455            if root_direct_diag_enabled() {
456                log::warn!(
457                    "[root-direct-diag] reject self-backdrop child node={:?}",
458                    child.node_id
459                );
460            }
461            return false;
462        }
463        if child.needs_nested_underlay {
464            let Some(dest_rect) = axis_aligned_quad_rect(child.dest_quad) else {
465                if root_direct_diag_enabled() {
466                    log::warn!(
467                        "[root-direct-diag] reject projective underlay child node={:?}",
468                        child.node_id
469                    );
470                }
471                return false;
472            };
473            let translation_only = (dest_rect.width - child.logical_rect.width).abs() <= 0.001
474                && (dest_rect.height - child.logical_rect.height).abs() <= 0.001;
475            let unsupported_preceding_child_layer = collected.child_layers[..child_index]
476                .iter()
477                .any(|preceding| {
478                    if direct_root_child_can_be_replayed_into_later_underlay(preceding) {
479                        return false;
480                    }
481                    axis_aligned_quad_rect(preceding.dest_quad)
482                        .is_none_or(|preceding_rect| rects_overlap(preceding_rect, dest_rect))
483                });
484            let preceding_scene_events =
485                scene_layer_events_precede_z(&collected.scene, child.z_index);
486            if unsupported_preceding_child_layer || preceding_scene_events || !translation_only {
487                if root_direct_diag_enabled() {
488                    log::warn!(
489                        "[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})",
490                        child.node_id,
491                        unsupported_preceding_child_layer,
492                        preceding_scene_events,
493                        translation_only,
494                        dest_rect.x,
495                        dest_rect.y,
496                        dest_rect.width,
497                        dest_rect.height,
498                        child.logical_rect.x,
499                        child.logical_rect.y,
500                        child.logical_rect.width,
501                        child.logical_rect.height
502                    );
503                }
504                return false;
505            }
506        }
507    }
508    true
509}
510
511#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
512struct ShadowSurfaceCacheKey {
513    content_hash: u64,
514    pixel_size: [u32; 2],
515    root_scale_bits: u32,
516    blur_radius_bits: u32,
517}
518
519struct CachedShadowSurface {
520    target: Rc<OffscreenTarget>,
521    byte_size: u64,
522}
523
524struct CachedShadowComposite {
525    source: Rc<OffscreenTarget>,
526    scissor: Option<(u32, u32, u32, u32)>,
527    rounded_mask: Option<RoundedCompositeMask>,
528    dest_viewport: Option<(f32, f32, f32, f32)>,
529}
530
531impl CachedShadowComposite {
532    fn batch_item(&self) -> CompositeBatchItem<'_> {
533        CompositeBatchItem {
534            source: &self.source,
535            alpha: 1.0,
536            scissor: self.scissor,
537            rounded_mask: self.rounded_mask,
538            blend_mode: BlendMode::SrcOver,
539            dest_viewport: self.dest_viewport,
540            source_viewport: None,
541            sample_mode: CompositeSampleMode::Nearest,
542        }
543    }
544}
545
546#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
547struct TextImageCacheKey(u64);
548
549struct CachedTextImage {
550    image: ImageBitmap,
551}
552
553#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
554struct TextGlyphRunCacheKey(u64);
555
556#[derive(Clone, Copy)]
557struct CachedTextGlyphQuad {
558    x: i32,
559    y: i32,
560    width: usize,
561    height: usize,
562    color: (f32, f32, f32, f32),
563    uv: ImageUvRect,
564}
565
566struct CachedTextGlyphRun {
567    glyphs: Rc<[SoftwareGlyphAtlasPlacement]>,
568    quads: Option<Rc<[CachedTextGlyphQuad]>>,
569    atlas_generation: u64,
570}
571
572const TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER: f32 = 2.0;
573
574#[cfg(not(target_arch = "wasm32"))]
575struct CachedGpuTextGlyphRun {
576    vertex_buffer: wgpu::Buffer,
577    index_buffer: wgpu::Buffer,
578    index_count: u32,
579    atlas_generation: u64,
580}
581
582#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
583struct TextLineIndexCacheKey(usize);
584
585struct CachedTextLineIndex {
586    text: std::sync::Weak<cranpose_ui::text::RenderString>,
587    len: usize,
588    starts: Rc<[usize]>,
589}
590
591struct TextLineIndexCache {
592    entries: BoundedLruCache<TextLineIndexCacheKey, CachedTextLineIndex>,
593}
594
595impl TextLineIndexCache {
596    fn new(capacity: usize) -> Self {
597        Self {
598            entries: BoundedLruCache::with_capacity_at_least_one(capacity),
599        }
600    }
601
602    fn line_starts(&mut self, text: &Arc<cranpose_ui::text::RenderString>) -> Rc<[usize]> {
603        let key = TextLineIndexCacheKey(Arc::as_ptr(text) as usize);
604        if let Some(cached) = self.entries.get(&key) {
605            if cached.len == text.text.len()
606                && cached
607                    .text
608                    .upgrade()
609                    .is_some_and(|cached_text| Arc::ptr_eq(&cached_text, text))
610            {
611                return cached.starts.clone();
612            }
613        }
614
615        let starts = Rc::<[usize]>::from(line_start_offsets(text.text.as_str()));
616        self.entries.put(
617            key,
618            CachedTextLineIndex {
619                text: Arc::downgrade(text),
620                len: text.text.len(),
621                starts: starts.clone(),
622            },
623        );
624        starts
625    }
626}
627
628#[derive(Clone, Copy, Debug, PartialEq)]
629struct ShapeShadowSurfacePlan {
630    source_device_bounds: DevicePixelBounds,
631    processing_scissor: Option<(u32, u32, u32, u32)>,
632    pixel_radius: f32,
633}
634
635/// Shared record of the device's uncaptured errors (validation, OOM,
636/// internal), written by the handler [`GpuRenderer::new`] installs via
637/// `Device::on_uncaptured_error` and read at the head of every
638/// [`GpuRenderer::render`].
639///
640/// wgpu's default handler panics on the reporting thread. Mid-encode that
641/// unwind runs the drop glue of live pass/encoder objects, whose own error
642/// reports re-enter the same panicking handler — a second panic inside the
643/// first's unwind aborts the process, and the tombstone carries neither
644/// message (a real device's validation failure was lost exactly this way).
645/// This handler never panics: it counts, logs the full error, and poisons;
646/// the render path answers with one cancelled packet per poisoning — the
647/// acquire path's give-up-this-frame semantics, not a latch.
648/// `CRANPOSE_SURVIVE_GPU_ERRORS=0` restores the fatal default
649/// ([`survive_gpu_errors_enabled`]).
650#[derive(Default)]
651struct DeviceErrorSentry {
652    /// Lifetime uncaptured errors on this device.
653    errors: std::sync::atomic::AtomicU64,
654    /// Set by the handler, taken (cleared) by the next frame's gate.
655    poisoned: std::sync::atomic::AtomicBool,
656}
657
658impl DeviceErrorSentry {
659    /// Never panics: this runs where the default handler would have
660    /// aborted the process (see the type doc).
661    fn record(&self, error: &wgpu::Error) {
662        use std::sync::atomic::Ordering;
663        self.poisoned.store(true, Ordering::Release);
664        let count = self.errors.fetch_add(1, Ordering::Relaxed) + 1;
665        // The full error every time it prints; rate-limited by count
666        // because one broken frame reports a follow-up error per
667        // subsequent encoder call. Power-of-two occurrences (1, 2, 4,
668        // 8, …) keep the first reports verbatim and decay the repeats
669        // without a clock; the count carries the volume.
670        if count.is_power_of_two() {
671            log::error!("[gpu-device] uncaptured wgpu error #{count}: {error}");
672        }
673    }
674
675    fn take_poison(&self) -> bool {
676        self.poisoned
677            .swap(false, std::sync::atomic::Ordering::AcqRel)
678    }
679
680    fn error_count(&self) -> u64 {
681        self.errors.load(std::sync::atomic::Ordering::Relaxed)
682    }
683}
684
685#[derive(Default)]
686struct RendererWarningState {
687    unsupported_effect_reported: Cell<bool>,
688}
689
690impl RendererWarningState {
691    fn warn_unsupported_effect_once(&self) {
692        if !self.unsupported_effect_reported.replace(true) {
693            log::warn!(
694                "WGPU renderer received an unsupported RenderEffect variant; falling back to passthrough compositing"
695            );
696        }
697    }
698}
699
700fn is_blend_mode_supported(mode: BlendMode) -> bool {
701    matches!(
702        mode,
703        BlendMode::Src | BlendMode::SrcOver | BlendMode::DstOut
704    )
705}
706
707fn blend_state_for_mode(mode: BlendMode) -> wgpu::BlendState {
708    match mode {
709        BlendMode::Src => wgpu::BlendState::REPLACE,
710        BlendMode::DstOut => wgpu::BlendState {
711            color: wgpu::BlendComponent {
712                src_factor: wgpu::BlendFactor::Zero,
713                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
714                operation: wgpu::BlendOperation::Add,
715            },
716            alpha: wgpu::BlendComponent {
717                src_factor: wgpu::BlendFactor::Zero,
718                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
719                operation: wgpu::BlendOperation::Add,
720            },
721        },
722        _ => wgpu::BlendState::ALPHA_BLENDING,
723    }
724}
725
726fn supported_blend_mode(mode: BlendMode) -> BlendMode {
727    if is_blend_mode_supported(mode) {
728        return mode;
729    }
730
731    BlendMode::SrcOver
732}
733
734fn direct_shader_composite_viewport(
735    alpha: f32,
736    blend_mode: BlendMode,
737    dest_viewport: Option<(f32, f32, f32, f32)>,
738    sample_mode: CompositeSampleMode,
739    source_size: (u32, u32),
740) -> Option<(f32, f32, f32, f32)> {
741    if alpha != 1.0 || supported_blend_mode(blend_mode) != BlendMode::SrcOver {
742        return None;
743    }
744    let viewport = dest_viewport?;
745    if viewport.2 <= 0.0 || viewport.3 <= 0.0 {
746        return None;
747    }
748    match sample_mode {
749        CompositeSampleMode::Linear | CompositeSampleMode::Nearest => Some(viewport),
750        CompositeSampleMode::Box4
751            if shader_composite_preserves_source_pixel_grid(viewport, source_size) =>
752        {
753            Some(viewport)
754        }
755        CompositeSampleMode::Box4 => None,
756    }
757}
758
759fn shader_composite_preserves_source_pixel_grid(
760    viewport: (f32, f32, f32, f32),
761    source_size: (u32, u32),
762) -> bool {
763    const EPSILON: f32 = 0.01;
764    let (x, y, width, height) = viewport;
765    let (source_width, source_height) = source_size;
766    (x - x.round()).abs() <= EPSILON
767        && (y - y.round()).abs() <= EPSILON
768        && (width - source_width as f32).abs() <= EPSILON
769        && (height - source_height as f32).abs() <= EPSILON
770}
771
772type DirectShaderTailComposite<'a> = (&'a RenderEffect, &'a RuntimeShader, (f32, f32, f32, f32));
773
774fn direct_shader_tail_composite(
775    effect: &RenderEffect,
776    alpha: f32,
777    blend_mode: BlendMode,
778    dest_viewport: Option<(f32, f32, f32, f32)>,
779    sample_mode: CompositeSampleMode,
780    source_size: (u32, u32),
781) -> Option<DirectShaderTailComposite<'_>> {
782    let viewport = direct_shader_composite_viewport(
783        alpha,
784        blend_mode,
785        dest_viewport,
786        sample_mode,
787        source_size,
788    )?;
789    let RenderEffect::Chain { first, second } = effect else {
790        return None;
791    };
792    let RenderEffect::Shader { shader } = second.as_ref() else {
793        return None;
794    };
795    Some((first.as_ref(), shader, viewport))
796}
797
798fn hash_f32_for_cache<H: Hasher>(value: f32, state: &mut H) {
799    value.to_bits().hash(state);
800}
801
802fn hash_text_raster_geometry_for_cache<H: Hasher>(
803    rect: Rect,
804    static_text_motion: bool,
805    state: &mut H,
806) {
807    hash_f32_for_cache(rect.width, state);
808    hash_f32_for_cache(rect.height, state);
809    static_text_motion.hash(state);
810    if !static_text_motion {
811        hash_f32_for_cache(rect.x.fract(), state);
812        hash_f32_for_cache(rect.y.fract(), state);
813    }
814}
815
816fn text_raster_geometry_for_draw(
817    text_draw: &TextDraw,
818    root_scale: f32,
819) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
820    if text_draw.text.is_empty()
821        || text_draw.rect.width <= 0.0
822        || text_draw.rect.height <= 0.0
823        || !root_scale.is_finite()
824        || root_scale <= 0.0
825    {
826        return None;
827    }
828
829    let text_scale = text_draw.scale * root_scale;
830    if !text_scale.is_finite() || text_scale <= 0.0 {
831        return None;
832    }
833
834    let static_text_motion = text_draw
835        .text_style
836        .paragraph_style
837        .text_motion
838        .unwrap_or(cranpose_ui::text::TextMotion::Static)
839        == cranpose_ui::text::TextMotion::Static;
840    let snap_delta = text_draw
841        .snap_anchor
842        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
843        .unwrap_or_default();
844    let logical_rect = text_draw.rect.translate(snap_delta.x, snap_delta.y);
845    // Clips are resolved in scene space from their own layer ancestry. A draw
846    // item's raster snap must never move a fixed ancestor clip.
847    let clip = text_draw.clip;
848    let mut raster_rect = Rect {
849        x: logical_rect.x * root_scale,
850        y: logical_rect.y * root_scale,
851        width: logical_rect.width * root_scale,
852        height: logical_rect.height * root_scale,
853    };
854    if text_draw.snap_anchor.is_some() {
855        raster_rect.x = canonicalize_device_coordinate(raster_rect.x);
856        raster_rect.y = canonicalize_device_coordinate(raster_rect.y);
857    }
858    if static_text_motion {
859        raster_rect.x = raster_rect.x.round();
860        raster_rect.y = raster_rect.y.round();
861    }
862    raster_rect.width = raster_rect.width.ceil().max(1.0);
863    raster_rect.height = raster_rect.height.ceil().max(1.0);
864    Some((
865        logical_rect,
866        raster_rect,
867        clip,
868        text_scale,
869        static_text_motion,
870    ))
871}
872
873fn text_draw_is_visible_in_viewport(
874    logical_rect: Rect,
875    clip: Option<Rect>,
876    viewport: ViewportUniformParams,
877    root_scale: f32,
878) -> bool {
879    draw_rect_is_visible_in_viewport(logical_rect, clip, viewport, root_scale)
880}
881
882fn text_draw_should_prewarm_in_viewport(
883    logical_rect: Rect,
884    clip: Option<Rect>,
885    viewport: ViewportUniformParams,
886    root_scale: f32,
887) -> bool {
888    if !root_scale.is_finite() || root_scale <= 0.0 {
889        return false;
890    }
891    let viewport_rect = Rect {
892        x: viewport.offset[0] / root_scale,
893        y: viewport.offset[1] / root_scale,
894        width: viewport.width as f32 / root_scale,
895        height: viewport.height as f32 / root_scale,
896    };
897    let margin_x = viewport_rect.width * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
898    let margin_y = viewport_rect.height * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
899    let prewarm_viewport = expand_rect(viewport_rect, margin_x, margin_y);
900    let prewarm_rect = match clip {
901        Some(clip) => expand_rect(clip, margin_x, margin_y).intersect(prewarm_viewport),
902        None => Some(prewarm_viewport),
903    };
904    prewarm_rect.is_some_and(|rect| logical_rect.intersect(rect).is_some())
905}
906
907fn expand_rect(rect: Rect, margin_x: f32, margin_y: f32) -> Rect {
908    Rect {
909        x: rect.x - margin_x,
910        y: rect.y - margin_y,
911        width: rect.width + margin_x * 2.0,
912        height: rect.height + margin_y * 2.0,
913    }
914}
915
916fn draw_rect_is_visible_in_viewport(
917    rect: Rect,
918    clip: Option<Rect>,
919    viewport: ViewportUniformParams,
920    root_scale: f32,
921) -> bool {
922    if !root_scale.is_finite() || root_scale <= 0.0 {
923        return false;
924    }
925    let viewport_rect = Rect {
926        x: viewport.offset[0] / root_scale,
927        y: viewport.offset[1] / root_scale,
928        width: viewport.width as f32 / root_scale,
929        height: viewport.height as f32 / root_scale,
930    };
931    let visible_rect = match clip {
932        Some(clip) => clip.intersect(viewport_rect),
933        None => Some(viewport_rect),
934    };
935    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
936}
937
938fn shape_draw_is_visible_in_viewport(
939    shape: &DrawShape,
940    viewport: ViewportUniformParams,
941    root_scale: f32,
942) -> bool {
943    let Some(viewport_rect) = viewport_rect_in_logical(viewport, root_scale) else {
944        return false;
945    };
946    shape_draw_is_visible_in_rect(shape, viewport_rect, root_scale)
947}
948
949/// The viewport in logical units, or `None` for a degenerate scale — the
950/// four divides are loop-invariant at every filter call site, so the hot
951/// paths derive this once per batch and test shapes against the result.
952fn viewport_rect_in_logical(viewport: ViewportUniformParams, root_scale: f32) -> Option<Rect> {
953    if !root_scale.is_finite() || root_scale <= 0.0 {
954        return None;
955    }
956    Some(Rect {
957        x: viewport.offset[0] / root_scale,
958        y: viewport.offset[1] / root_scale,
959        width: viewport.width as f32 / root_scale,
960        height: viewport.height as f32 / root_scale,
961    })
962}
963
964/// [`shape_draw_is_visible_in_viewport`] with the logical viewport rect
965/// already derived: identical decision, none of the per-shape divides.
966fn shape_draw_is_visible_in_rect(shape: &DrawShape, viewport_rect: Rect, root_scale: f32) -> bool {
967    let snap_delta = shape
968        .snap_anchor
969        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
970        .unwrap_or_default();
971    let rect = quad_bounds(translate_quad(shape.quad, snap_delta));
972    let visible_rect = match shape.clip {
973        Some(clip) => clip.intersect(viewport_rect),
974        None => Some(viewport_rect),
975    };
976    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
977}
978
979fn cached_text_glyph_quad(
980    glyph: &SoftwareGlyphAtlasPlacement,
981    entry: GlyphAtlasEntry,
982    atlas_size: u32,
983) -> CachedTextGlyphQuad {
984    CachedTextGlyphQuad {
985        x: glyph.x,
986        y: glyph.y,
987        width: glyph.width,
988        height: glyph.height,
989        color: (
990            glyph.color.0.clamp(0.0, 1.0),
991            glyph.color.1.clamp(0.0, 1.0),
992            glyph.color.2.clamp(0.0, 1.0),
993            glyph.color.3.clamp(0.0, 1.0),
994        ),
995        uv: glyph_atlas_uv_rect(entry, atlas_size),
996    }
997}
998
999fn append_cached_text_glyph_quad(
1000    source_raster_rect: Rect,
1001    quad: &CachedTextGlyphQuad,
1002    image_vertices: &mut Vec<Vertex>,
1003    image_indices: &mut Vec<u32>,
1004) -> bool {
1005    if quad.width == 0 || quad.height == 0 || quad.color.3 <= 0.0 {
1006        return false;
1007    }
1008
1009    let base_vertex = image_vertices.len() as u32;
1010    image_indices.extend_from_slice(&[
1011        base_vertex,
1012        base_vertex + 1,
1013        base_vertex + 2,
1014        base_vertex + 2,
1015        base_vertex + 1,
1016        base_vertex + 3,
1017    ]);
1018
1019    let x0 = source_raster_rect.x + quad.x as f32;
1020    let y0 = source_raster_rect.y + quad.y as f32;
1021    let x1 = x0 + quad.width as f32;
1022    let y1 = y0 + quad.height as f32;
1023    let color = [quad.color.0, quad.color.1, quad.color.2, quad.color.3];
1024
1025    image_vertices.extend_from_slice(&[
1026        Vertex {
1027            position: [x0, y0],
1028            color,
1029            uv: [quad.uv.min[0], quad.uv.min[1]],
1030            uv_bounds: quad.uv.sample_bounds,
1031        },
1032        Vertex {
1033            position: [x1, y0],
1034            color,
1035            uv: [quad.uv.max[0], quad.uv.min[1]],
1036            uv_bounds: quad.uv.sample_bounds,
1037        },
1038        Vertex {
1039            position: [x0, y1],
1040            color,
1041            uv: [quad.uv.min[0], quad.uv.max[1]],
1042            uv_bounds: quad.uv.sample_bounds,
1043        },
1044        Vertex {
1045            position: [x1, y1],
1046            color,
1047            uv: [quad.uv.max[0], quad.uv.max[1]],
1048            uv_bounds: quad.uv.sample_bounds,
1049        },
1050    ]);
1051    true
1052}
1053
1054fn cached_text_glyph_quad_logical_rect(
1055    source_raster_rect: Rect,
1056    quad: &CachedTextGlyphQuad,
1057    root_scale: f32,
1058) -> Option<Rect> {
1059    if !root_scale.is_finite() || root_scale <= 0.0 {
1060        return None;
1061    }
1062    Some(Rect {
1063        x: (source_raster_rect.x + quad.x as f32) / root_scale,
1064        y: (source_raster_rect.y + quad.y as f32) / root_scale,
1065        width: quad.width as f32 / root_scale,
1066        height: quad.height as f32 / root_scale,
1067    })
1068}
1069
1070fn cached_text_glyph_quad_is_visible_in_viewport(
1071    source_raster_rect: Rect,
1072    quad: &CachedTextGlyphQuad,
1073    clip: Option<Rect>,
1074    viewport: ViewportUniformParams,
1075    root_scale: f32,
1076) -> bool {
1077    cached_text_glyph_quad_logical_rect(source_raster_rect, quad, root_scale)
1078        .is_some_and(|rect| draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale))
1079}
1080
1081#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1082enum TextGlyphDrawAction {
1083    DrawVisible,
1084    PrewarmOffscreen,
1085    Skip,
1086}
1087
1088fn text_glyph_draw_action(
1089    is_visible: bool,
1090    is_prewarm_candidate: bool,
1091    allow_offscreen_prewarm: bool,
1092) -> TextGlyphDrawAction {
1093    if is_visible {
1094        TextGlyphDrawAction::DrawVisible
1095    } else if allow_offscreen_prewarm && is_prewarm_candidate {
1096        TextGlyphDrawAction::PrewarmOffscreen
1097    } else {
1098        TextGlyphDrawAction::Skip
1099    }
1100}
1101
1102#[cfg(not(target_arch = "wasm32"))]
1103fn should_use_retained_text_glyph_run(quads_len: usize, clip: Option<Rect>) -> bool {
1104    clip.is_none() && quads_len >= MIN_RETAINED_TEXT_GLYPH_QUADS
1105}
1106
1107#[cfg(not(target_arch = "wasm32"))]
1108fn offscreen_text_glyph_prewarm_work_is_bounded(
1109    cached_glyphs: Option<usize>,
1110    text_len: usize,
1111) -> bool {
1112    match cached_glyphs {
1113        Some(glyphs) => glyphs <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS,
1114        None => text_len <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
1115    }
1116}
1117
1118#[cfg(not(target_arch = "wasm32"))]
1119fn offscreen_text_glyph_prewarm_budget_exhausted(
1120    start: Instant,
1121    admitted_candidates: usize,
1122) -> bool {
1123    admitted_candidates >= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES
1124        || instant_ms(start, Instant::now()) >= OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS
1125}
1126
1127fn text_draws_for_ordered_range<'a>(
1128    ordered_items: &'a [(usize, SegmentDrawItem)],
1129    texts: &'a [TextDraw],
1130    start: usize,
1131    end: usize,
1132) -> Result<impl Iterator<Item = &'a TextDraw>, String> {
1133    let range_items = ordered_items
1134        .get(start..end)
1135        .ok_or_else(|| format!("text batch range {start}..{end} is outside ordered draw items"))?;
1136    for (_, item) in range_items {
1137        match item {
1138            SegmentDrawItem::Text(text_index) if *text_index < texts.len() => {}
1139            SegmentDrawItem::Text(text_index) => {
1140                return Err(format!(
1141                    "text batch references missing text draw index: {text_index}"
1142                ));
1143            }
1144            _ => return Err(format!("text batch contains non-text draw item: {item:?}")),
1145        }
1146    }
1147
1148    Ok(range_items.iter().filter_map(move |(_, item)| match item {
1149        SegmentDrawItem::Text(text_index) => texts.get(*text_index),
1150        _ => None,
1151    }))
1152}
1153
1154/// Shadow geometry is hashed in device pixels quantized to 1/16 px so rigid
1155/// translations reuse the cached blurred raster. The cached surface is
1156/// composited one-to-one with texel-exact sampling; translation may not change
1157/// either the blur or its sampling phase.
1158const SHADOW_CACHE_DEVICE_QUANT: f32 = 16.0;
1159
1160fn hash_shadow_device_offset<H: Hasher>(value: f32, origin: f32, root_scale: f32, state: &mut H) {
1161    let quantized = ((value - origin) * root_scale * SHADOW_CACHE_DEVICE_QUANT).round();
1162    (quantized as i64).hash(state);
1163}
1164
1165fn hash_shadow_device_rect<H: Hasher>(
1166    rect: Rect,
1167    origin_x: f32,
1168    origin_y: f32,
1169    root_scale: f32,
1170    state: &mut H,
1171) {
1172    hash_shadow_device_offset(rect.x, origin_x, root_scale, state);
1173    hash_shadow_device_offset(rect.y, origin_y, root_scale, state);
1174    hash_shadow_device_offset(rect.width, 0.0, root_scale, state);
1175    hash_shadow_device_offset(rect.height, 0.0, root_scale, state);
1176}
1177
1178fn hash_shape_shadow_item<H: Hasher>(
1179    shape: &DrawShape,
1180    brushes: &[Brush],
1181    blend_mode: BlendMode,
1182    origin_x: f32,
1183    origin_y: f32,
1184    root_scale: f32,
1185    state: &mut H,
1186) {
1187    hash_shadow_device_rect(shape.rect, origin_x, origin_y, root_scale, state);
1188    hash_shadow_device_rect(shape.local_rect, origin_x, origin_y, root_scale, state);
1189    for point in shape.quad {
1190        hash_shadow_device_offset(point[0], origin_x, root_scale, state);
1191        hash_shadow_device_offset(point[1], origin_y, root_scale, state);
1192    }
1193    match shape.snap_anchor {
1194        Some(anchor) => {
1195            1u8.hash(state);
1196            hash_shadow_device_offset(anchor.origin.x, origin_x, root_scale, state);
1197            hash_shadow_device_offset(anchor.origin.y, origin_y, root_scale, state);
1198            hash_f32_for_cache(anchor.device_pixel_step, state);
1199        }
1200        None => 0u8.hash(state),
1201    }
1202    shape.brush.render_hash(brushes).hash(state);
1203    match shape.shape {
1204        Some(corner_shape) => {
1205            1u8.hash(state);
1206            corner_shape.radii().render_hash().hash(state);
1207        }
1208        None => 0u8.hash(state),
1209    }
1210    match shape.clip {
1211        Some(clip) => {
1212            1u8.hash(state);
1213            hash_shadow_device_rect(clip, origin_x, origin_y, root_scale, state);
1214        }
1215        None => 0u8.hash(state),
1216    }
1217    blend_mode.hash(state);
1218    shape.blend_mode.hash(state);
1219}
1220
1221fn shape_shadow_content_hash(
1222    shapes: &[(DrawShape, BlendMode)],
1223    brushes: &[Brush],
1224    root_scale: f32,
1225) -> u64 {
1226    let mut hasher = FxHasher::default();
1227    // Anchor the hash to the shapes' own (unfloored) bounds so rigid translation
1228    // cancels out exactly. Anchoring to floored device-pixel bounds would leak
1229    // the device subpixel phase into the hash and defeat the cache at
1230    // fractional display scales.
1231    let origin = shape_shadow_bounds(shapes).unwrap_or(Rect {
1232        x: 0.0,
1233        y: 0.0,
1234        width: 0.0,
1235        height: 0.0,
1236    });
1237
1238    shapes.len().hash(&mut hasher);
1239    for (shape, blend_mode) in shapes {
1240        hash_shape_shadow_item(
1241            shape,
1242            brushes,
1243            *blend_mode,
1244            origin.x,
1245            origin.y,
1246            root_scale,
1247            &mut hasher,
1248        );
1249    }
1250    hasher.finish()
1251}
1252
1253fn shape_shadow_surface_cache_key(
1254    shapes: &[(DrawShape, BlendMode)],
1255    brushes: &[Brush],
1256    device_bounds: DevicePixelBounds,
1257    pixel_radius: f32,
1258    root_scale: f32,
1259) -> Option<ShadowSurfaceCacheKey> {
1260    (root_scale.is_finite() && root_scale > 0.0).then(|| ShadowSurfaceCacheKey {
1261        content_hash: shape_shadow_content_hash(shapes, brushes, root_scale),
1262        pixel_size: [device_bounds.width, device_bounds.height],
1263        root_scale_bits: root_scale.to_bits(),
1264        blur_radius_bits: pixel_radius.to_bits(),
1265    })
1266}
1267
1268fn shape_shadow_bounds(shapes: &[(DrawShape, BlendMode)]) -> Option<Rect> {
1269    shapes
1270        .iter()
1271        .map(|(shape, _)| shape.rect)
1272        .reduce(|a, b| Rect {
1273            x: a.x.min(b.x),
1274            y: a.y.min(b.y),
1275            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1276            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1277        })
1278}
1279
1280fn shared_shape_shadow_snap_anchor(shapes: &[(DrawShape, BlendMode)]) -> Option<SnapAnchor> {
1281    let anchor = shapes.first()?.0.snap_anchor?;
1282    shapes
1283        .iter()
1284        .all(|(shape, _)| shape.snap_anchor == Some(anchor))
1285        .then_some(anchor)
1286}
1287
1288fn shadow_draw_bounds(shadow: &ShadowDraw) -> Option<Rect> {
1289    shadow
1290        .shapes
1291        .iter()
1292        .map(|(shape, _)| shape.rect)
1293        .chain(shadow.texts.iter().map(|text| text.rect))
1294        .reduce(|a, b| Rect {
1295            x: a.x.min(b.x),
1296            y: a.y.min(b.y),
1297            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1298            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1299        })
1300}
1301
1302fn shadow_draw_may_render(
1303    shadow: &ShadowDraw,
1304    width: u32,
1305    height: u32,
1306    root_scale: f32,
1307    max_texture_dim: u32,
1308) -> bool {
1309    if shadow.texts.is_empty() && !shadow.shapes.is_empty() && shadow.blur_radius > 0.0 {
1310        return shape_shadow_surface_plan(
1311            &shadow.shapes,
1312            shadow.clip,
1313            shadow.blur_radius,
1314            width,
1315            height,
1316            root_scale,
1317            max_texture_dim,
1318        )
1319        .is_some();
1320    }
1321
1322    let Some(bounds) = shadow_draw_bounds(shadow) else {
1323        return false;
1324    };
1325    let blur_margin = blur_extent_margin(shadow.blur_radius);
1326    let mut visible_bounds = Rect {
1327        x: bounds.x - blur_margin,
1328        y: bounds.y - blur_margin,
1329        width: bounds.width + blur_margin * 2.0,
1330        height: bounds.height + blur_margin * 2.0,
1331    };
1332    if let Some(clip) = shadow.clip {
1333        let clip_expanded = Rect {
1334            x: clip.x - blur_margin,
1335            y: clip.y - blur_margin,
1336            width: clip.width + blur_margin * 2.0,
1337            height: clip.height + blur_margin * 2.0,
1338        };
1339        let Some(intersection) = visible_bounds.intersect(clip_expanded) else {
1340            return false;
1341        };
1342        visible_bounds = intersection;
1343    }
1344
1345    scissor_rect_for_rect(visible_bounds, root_scale, width, height).is_some()
1346}
1347
1348fn shape_shadow_surface_plan(
1349    shapes: &[(DrawShape, BlendMode)],
1350    clip: Option<Rect>,
1351    blur_radius: f32,
1352    width: u32,
1353    height: u32,
1354    root_scale: f32,
1355    max_texture_dim: u32,
1356) -> Option<ShapeShadowSurfacePlan> {
1357    let shape_bounds = shape_shadow_bounds(shapes)?;
1358    let blur_margin = blur_extent_margin(blur_radius);
1359    let source_blur_bounds = Rect {
1360        x: shape_bounds.x - blur_margin,
1361        y: shape_bounds.y - blur_margin,
1362        width: shape_bounds.width + blur_margin * 2.0,
1363        height: shape_bounds.height + blur_margin * 2.0,
1364    };
1365
1366    let mut visible_blur_bounds = source_blur_bounds;
1367    if let Some(clip) = clip {
1368        let clip_expanded = Rect {
1369            x: clip.x - blur_margin,
1370            y: clip.y - blur_margin,
1371            width: clip.width + blur_margin * 2.0,
1372            height: clip.height + blur_margin * 2.0,
1373        };
1374        visible_blur_bounds = visible_blur_bounds.intersect(clip_expanded)?;
1375    }
1376
1377    let processing_scissor = scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
1378    processing_scissor?;
1379    let visible_device_bounds =
1380        device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)?;
1381    let source_device_bounds = translation_stable_anchored_device_pixel_bounds(
1382        source_blur_bounds,
1383        shared_shape_shadow_snap_anchor(shapes),
1384        root_scale,
1385        max_texture_dim,
1386    )
1387    .unwrap_or(visible_device_bounds);
1388
1389    Some(ShapeShadowSurfacePlan {
1390        source_device_bounds,
1391        processing_scissor,
1392        pixel_radius: blur_radius * root_scale,
1393    })
1394}
1395
1396fn is_render_effect_supported(effect: &RenderEffect) -> bool {
1397    match effect {
1398        RenderEffect::Blur { .. } => true,
1399        RenderEffect::Offset { .. } => true,
1400        RenderEffect::Shader { .. } => true,
1401        RenderEffect::Chain { first, second } => {
1402            is_render_effect_supported(first) && is_render_effect_supported(second)
1403        }
1404    }
1405}
1406
1407fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
1408    if value.is_finite() {
1409        origin + value
1410    } else if value.is_sign_positive() {
1411        origin + extent
1412    } else {
1413        origin
1414    }
1415}
1416
1417fn gradient_tile_mode_value(tile_mode: TileMode) -> u32 {
1418    match tile_mode {
1419        TileMode::Clamp => 0,
1420        TileMode::Repeated => 1,
1421        TileMode::Mirror => 2,
1422        TileMode::Decal => 3,
1423    }
1424}
1425
1426/// The base text the shape rewrites below start from: `shape.wgsl` alone, or
1427/// — under `CRANPOSE_SOLID_TRIM_VARYINGS` — `shape.wgsl` with the trimmed
1428/// solid entries appended. Appending happens BEFORE the storage/array
1429/// rewrites so the paint-select injection and the batch-limit resizes land
1430/// in the trimmed entries exactly as they land in `vs_main` (the
1431/// substitution tests pin five landings); with the trim off the text is the
1432/// borrowed shipping constant, byte-identical to what always compiled.
1433fn shape_shader_base(solid_trim: bool) -> Cow<'static, str> {
1434    if solid_trim {
1435        return Cow::Owned(format!(
1436            "{}\n{}",
1437            shaders::SHADER,
1438            shaders::SOLID_TRIM_APPENDIX
1439        ));
1440    }
1441    Cow::Borrowed(shaders::SHADER)
1442}
1443
1444#[cfg(not(target_arch = "wasm32"))]
1445fn shape_shader_source(batch_limits: ShapeBatchLimits, solid_trim: bool) -> Cow<'static, str> {
1446    let base = shape_shader_base(solid_trim);
1447    // These literals must stay in sync with `shape.wgsl`; a mismatch makes
1448    // the substitution silently no-op and leaves the shader sized for the
1449    // downlevel floor.
1450    if batch_limits.storage {
1451        return Cow::Owned(
1452            base.replace(
1453                "var<uniform> shape_data: array<ShapeData, 102>;",
1454                "var<storage, read> shape_data: array<ShapeData>;",
1455            )
1456            .replace(
1457                "var<uniform> gradient_stops: array<GradientStop, 256>;",
1458                // Also inject the retained-paint array here: one mutable
1459                // color per shape, read when `similarity.paint_select`
1460                // is set, so recolor patches upload 16-byte colors
1461                // instead of whole ShapeData records. The base text
1462                // never declares it — uniform-mode devices cannot bind
1463                // storage and never host retained slots.
1464                "var<storage, read> gradient_stops: array<GradientStop>;\n\n\
1465                     @group(1) @binding(3)\n\
1466                     var<storage, read> paint: array<vec4<f32>>;",
1467            )
1468            .replace(
1469                "output.color = shape.color;",
1470                "output.color = \
1471                     select(shape.color, paint[shape_idx], similarity.paint_select > 0.5);",
1472            ),
1473        );
1474    }
1475    Cow::Owned(
1476        base.replace(
1477            "array<ShapeData, 102>",
1478            &format!("array<ShapeData, {}>", batch_limits.max_shapes_per_batch),
1479        )
1480        .replace(
1481            "array<GradientStop, 256>",
1482            &format!("array<GradientStop, {}>", batch_limits.max_gradient_stops),
1483        ),
1484    )
1485}
1486
1487#[cfg(target_arch = "wasm32")]
1488fn shape_shader_source(_batch_limits: ShapeBatchLimits, solid_trim: bool) -> Cow<'static, str> {
1489    // wasm keeps the downlevel array lengths verbatim. The trim flag is
1490    // env-driven and a browser has no environment to set it in, but the arm
1491    // stays honest for any embedder that reaches it.
1492    shape_shader_base(solid_trim)
1493}
1494
1495/// Runs one `create_render_pipeline` call under a timer and logs the result.
1496/// First-use creation happens on the render thread behind `get_or_init`,
1497/// where a driver backend compile is whole missed frames on slow devices;
1498/// the tag names the permutation so a stalled launch names its pipelines.
1499pub(crate) fn create_render_pipeline_logged<'a>(
1500    device: &wgpu::Device,
1501    cache: Option<&'a wgpu::PipelineCache>,
1502    tag: &str,
1503    mut descriptor: wgpu::RenderPipelineDescriptor<'a>,
1504) -> wgpu::RenderPipeline {
1505    descriptor.cache = cache;
1506    let started = Instant::now();
1507    let pipeline = device.create_render_pipeline(&descriptor);
1508    log::info!(
1509        "[pipeline-create] {tag} {:.1}ms",
1510        instant_ms(started, Instant::now())
1511    );
1512    pipeline
1513}
1514
1515/// `CRANPOSE_PIPELINE_PREWARM=0` (property `debug.cranpose.pipeline_prewarm`)
1516/// keeps first-use creation as the only compile path.
1517#[cfg(not(target_arch = "wasm32"))]
1518fn pipeline_prewarm_enabled() -> bool {
1519    std::env::var("CRANPOSE_PIPELINE_PREWARM").as_deref() != Ok("0")
1520}
1521
1522#[cfg(not(target_arch = "wasm32"))]
1523struct PipelinePrewarmInputs {
1524    device: Arc<wgpu::Device>,
1525    cache: Option<wgpu::PipelineCache>,
1526    surface_format: wgpu::TextureFormat,
1527    uniform_layout: wgpu::BindGroupLayout,
1528    shape_layout: wgpu::BindGroupLayout,
1529    image_layout: wgpu::BindGroupLayout,
1530    batch_limits: ShapeBatchLimits,
1531    instanced: bool,
1532}
1533
1534/// Builds the pipelines a first frame reaches for — off the render thread,
1535/// concurrent with app startup — and drops them. The point is the shared
1536/// device pipeline cache: the render thread's own `get_or_init` creates then
1537/// find the driver's compiled code instead of paying for it mid-frame
1538/// (measured on a Pixel Watch 3: 661 + 496 + 552 ms for the three shape
1539/// pipelines alone, each one swallowed frame). The set is the framework's
1540/// own base family with the flags the accessors would latch — same inputs,
1541/// same permutations, so the cache keys match. Spawned only when the device
1542/// has a pipeline cache; without one, warming another thread's `wgpu`
1543/// objects would leave nothing behind for the render thread to find.
1544#[cfg(not(target_arch = "wasm32"))]
1545fn spawn_pipeline_prewarm(inputs: PipelinePrewarmInputs) {
1546    if !pipeline_prewarm_enabled() {
1547        return;
1548    }
1549    let spawned = std::thread::Builder::new()
1550        .name("cranpose-pl-warm".into())
1551        .spawn(move || {
1552            let started = Instant::now();
1553            let cache = inputs.cache.as_ref();
1554            let device = &inputs.device;
1555            let solid_trim = solid_trim_varyings_enabled();
1556            let mut built = 0_u32;
1557            if inputs.instanced {
1558                let (vertex_entry, fragment_entry) = if solid_trim {
1559                    ("vs_solid_instanced", "fs_solid_trim")
1560                } else {
1561                    ("vs_shape_instanced", "fs_solid")
1562                };
1563                drop(create_instanced_shape_pipeline(
1564                    device,
1565                    cache,
1566                    inputs.surface_format,
1567                    &inputs.uniform_layout,
1568                    &inputs.shape_layout,
1569                    BlendMode::SrcOver,
1570                    inputs.batch_limits,
1571                    solid_trim,
1572                    vertex_entry,
1573                    fragment_entry,
1574                    false,
1575                ));
1576                drop(create_instanced_shape_pipeline(
1577                    device,
1578                    cache,
1579                    inputs.surface_format,
1580                    &inputs.uniform_layout,
1581                    &inputs.shape_layout,
1582                    BlendMode::SrcOver,
1583                    inputs.batch_limits,
1584                    false,
1585                    "vs_shape_instanced",
1586                    "fs_main",
1587                    false,
1588                ));
1589            } else {
1590                let (vertex_entry, fragment_entry) = if solid_trim {
1591                    ("vs_solid", "fs_solid_trim")
1592                } else {
1593                    ("vs_main", "fs_solid")
1594                };
1595                drop(create_shape_pipeline(
1596                    device,
1597                    cache,
1598                    inputs.surface_format,
1599                    &inputs.uniform_layout,
1600                    &inputs.shape_layout,
1601                    BlendMode::SrcOver,
1602                    inputs.batch_limits,
1603                    solid_trim,
1604                    vertex_entry,
1605                    fragment_entry,
1606                    false,
1607                ));
1608                drop(create_shape_pipeline(
1609                    device,
1610                    cache,
1611                    inputs.surface_format,
1612                    &inputs.uniform_layout,
1613                    &inputs.shape_layout,
1614                    BlendMode::SrcOver,
1615                    inputs.batch_limits,
1616                    false,
1617                    "vs_main",
1618                    "fs_main",
1619                    false,
1620                ));
1621            }
1622            built += 2;
1623            if inputs.batch_limits.storage {
1624                drop(create_mesh_shape_pipeline(
1625                    device,
1626                    cache,
1627                    inputs.surface_format,
1628                    &inputs.uniform_layout,
1629                    &inputs.shape_layout,
1630                    inputs.batch_limits,
1631                    false,
1632                ));
1633                built += 1;
1634            }
1635            drop(create_glyph_atlas_pipeline(
1636                device,
1637                cache,
1638                inputs.surface_format,
1639                &inputs.uniform_layout,
1640                &inputs.image_layout,
1641                false,
1642            ));
1643            built += 1;
1644            log::info!(
1645                "[pipeline-prewarm] {built} pipelines in {:.1} ms",
1646                instant_ms(started, Instant::now())
1647            );
1648        });
1649    if let Err(error) = spawned {
1650        log::warn!("[pipeline-prewarm] thread failed to spawn: {error}");
1651    }
1652}
1653
1654#[allow(clippy::too_many_arguments)]
1655fn create_shape_pipeline(
1656    device: &wgpu::Device,
1657    cache: Option<&wgpu::PipelineCache>,
1658    surface_format: wgpu::TextureFormat,
1659    uniform_layout: &wgpu::BindGroupLayout,
1660    shape_layout: &wgpu::BindGroupLayout,
1661    blend_mode: BlendMode,
1662    batch_limits: ShapeBatchLimits,
1663    solid_trim: bool,
1664    vertex_entry: &'static str,
1665    fragment_entry: &'static str,
1666    depth: bool,
1667) -> wgpu::RenderPipeline {
1668    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1669        label: Some("Shape Shader"),
1670        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1671            shape_shader_source(batch_limits, solid_trim),
1672            depth,
1673        )),
1674    });
1675
1676    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1677        label: Some("Render Pipeline Layout"),
1678        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1679        immediate_size: 0,
1680    });
1681
1682    create_render_pipeline_logged(
1683        device,
1684        cache,
1685        &format!("shape entry={fragment_entry} blend={blend_mode:?} depth={depth}"),
1686        wgpu::RenderPipelineDescriptor {
1687            label: Some("Render Pipeline"),
1688            layout: Some(&pipeline_layout),
1689            vertex: wgpu::VertexState {
1690                module: &shader,
1691                entry_point: Some(vertex_entry),
1692                compilation_options: wgpu::PipelineCompilationOptions::default(),
1693                // No vertex buffer: `vs_main` (and its trimmed twin `vs_solid`)
1694                // pulls quad corners from ShapeData by `vertex_index`.
1695                buffers: &[],
1696            },
1697            fragment: Some(wgpu::FragmentState {
1698                module: &shader,
1699                entry_point: Some(fragment_entry),
1700                compilation_options: wgpu::PipelineCompilationOptions::default(),
1701                targets: &[Some(wgpu::ColorTargetState {
1702                    format: surface_format,
1703                    blend: Some(blend_state_for_mode(blend_mode)),
1704                    write_mask: wgpu::ColorWrites::ALL,
1705                })],
1706            }),
1707            primitive: wgpu::PrimitiveState {
1708                topology: wgpu::PrimitiveTopology::TriangleList,
1709                strip_index_format: None,
1710                front_face: wgpu::FrontFace::Ccw,
1711                cull_mode: None,
1712                unclipped_depth: false,
1713                polygon_mode: wgpu::PolygonMode::Fill,
1714                conservative: false,
1715            },
1716            depth_stencil: display_clip::content_depth_state(depth),
1717            multisample: wgpu::MultisampleState::default(),
1718            multiview_mask: None,
1719            cache: None,
1720        },
1721    )
1722}
1723
1724/// Storage-mode pipeline for retained slots that captured a conservative arc
1725/// mesh: `vs_mesh` consumes `{position, uv, shape_idx}` vertices instead of
1726/// expanding six corners per shape. Fragment stage, bind group layouts
1727/// (including the dynamic-offset similarity binding and the retained paint
1728/// binding) and the SrcOver blend are exactly the ones the quad-expansion retained
1729/// path uses — only the vertex fetch differs.
1730#[cfg(not(target_arch = "wasm32"))]
1731fn create_mesh_shape_pipeline(
1732    device: &wgpu::Device,
1733    cache: Option<&wgpu::PipelineCache>,
1734    surface_format: wgpu::TextureFormat,
1735    uniform_layout: &wgpu::BindGroupLayout,
1736    shape_layout: &wgpu::BindGroupLayout,
1737    batch_limits: ShapeBatchLimits,
1738    depth: bool,
1739) -> wgpu::RenderPipeline {
1740    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1741        label: Some("Shape Mesh Shader"),
1742        // Mesh slots may carry gradients, so this family always compiles the
1743        // full interface — the trimmed entries never pair with `vs_mesh`.
1744        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1745            shape_shader_source(batch_limits, false),
1746            depth,
1747        )),
1748    });
1749
1750    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1751        label: Some("Mesh Render Pipeline Layout"),
1752        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1753        immediate_size: 0,
1754    });
1755
1756    create_render_pipeline_logged(
1757        device,
1758        cache,
1759        &format!("mesh depth={depth}"),
1760        wgpu::RenderPipelineDescriptor {
1761            label: Some("Retained Mesh Pipeline"),
1762            layout: Some(&pipeline_layout),
1763            vertex: wgpu::VertexState {
1764                module: &shader,
1765                entry_point: Some("vs_mesh"),
1766                compilation_options: wgpu::PipelineCompilationOptions::default(),
1767                buffers: &[MeshVertex::desc()],
1768            },
1769            fragment: Some(wgpu::FragmentState {
1770                module: &shader,
1771                entry_point: Some("fs_main"),
1772                compilation_options: wgpu::PipelineCompilationOptions::default(),
1773                targets: &[Some(wgpu::ColorTargetState {
1774                    format: surface_format,
1775                    blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1776                    write_mask: wgpu::ColorWrites::ALL,
1777                })],
1778            }),
1779            primitive: wgpu::PrimitiveState {
1780                topology: wgpu::PrimitiveTopology::TriangleList,
1781                strip_index_format: None,
1782                front_face: wgpu::FrontFace::Ccw,
1783                cull_mode: None,
1784                unclipped_depth: false,
1785                polygon_mode: wgpu::PolygonMode::Fill,
1786                conservative: false,
1787            },
1788            depth_stencil: display_clip::content_depth_state(depth),
1789            multisample: wgpu::MultisampleState::default(),
1790            multiview_mask: None,
1791            cache: None,
1792        },
1793    )
1794}
1795
1796/// Storage-mode pipeline for ordinary shape batches drawn as instanced
1797/// indexed quads (`vs_shape_instanced`): four vertex executions per shape
1798/// through the static `[0, 1, 2, 2, 1, 3]` index buffer instead of six
1799/// unindexed corner expansions. Everything but the vertex entry point is
1800/// exactly `create_shape_pipeline` — same fragment stage, same layouts,
1801/// same blend per mode — so a draw-time fallback to `vs_main` (the
1802/// `CRANPOSE_INSTANCED_QUADS=0` kill switch) changes nothing else.
1803#[cfg(not(target_arch = "wasm32"))]
1804#[allow(clippy::too_many_arguments)]
1805fn create_instanced_shape_pipeline(
1806    device: &wgpu::Device,
1807    cache: Option<&wgpu::PipelineCache>,
1808    surface_format: wgpu::TextureFormat,
1809    uniform_layout: &wgpu::BindGroupLayout,
1810    shape_layout: &wgpu::BindGroupLayout,
1811    blend_mode: BlendMode,
1812    batch_limits: ShapeBatchLimits,
1813    solid_trim: bool,
1814    vertex_entry: &'static str,
1815    fragment_entry: &'static str,
1816    depth: bool,
1817) -> wgpu::RenderPipeline {
1818    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1819        label: Some("Shape Instanced Shader"),
1820        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1821            shape_shader_source(batch_limits, solid_trim),
1822            depth,
1823        )),
1824    });
1825
1826    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1827        label: Some("Instanced Render Pipeline Layout"),
1828        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1829        immediate_size: 0,
1830    });
1831
1832    create_render_pipeline_logged(
1833        device,
1834        cache,
1835        &format!("instanced entry={fragment_entry} blend={blend_mode:?} depth={depth}"),
1836        wgpu::RenderPipelineDescriptor {
1837            label: Some("Instanced Render Pipeline"),
1838            layout: Some(&pipeline_layout),
1839            vertex: wgpu::VertexState {
1840                module: &shader,
1841                entry_point: Some(vertex_entry),
1842                compilation_options: wgpu::PipelineCompilationOptions::default(),
1843                // No vertex buffer: like `vs_main`, the corners come from
1844                // ShapeData; only the shape index source differs
1845                // (`instance_index` instead of `vertex_index / 6`).
1846                buffers: &[],
1847            },
1848            fragment: Some(wgpu::FragmentState {
1849                module: &shader,
1850                entry_point: Some(fragment_entry),
1851                compilation_options: wgpu::PipelineCompilationOptions::default(),
1852                targets: &[Some(wgpu::ColorTargetState {
1853                    format: surface_format,
1854                    blend: Some(blend_state_for_mode(blend_mode)),
1855                    write_mask: wgpu::ColorWrites::ALL,
1856                })],
1857            }),
1858            primitive: wgpu::PrimitiveState {
1859                topology: wgpu::PrimitiveTopology::TriangleList,
1860                strip_index_format: None,
1861                front_face: wgpu::FrontFace::Ccw,
1862                cull_mode: None,
1863                unclipped_depth: false,
1864                polygon_mode: wgpu::PolygonMode::Fill,
1865                conservative: false,
1866            },
1867            depth_stencil: display_clip::content_depth_state(depth),
1868            multisample: wgpu::MultisampleState::default(),
1869            multiview_mask: None,
1870            cache: None,
1871        },
1872    )
1873}
1874
1875fn create_image_pipeline(
1876    device: &wgpu::Device,
1877    cache: Option<&wgpu::PipelineCache>,
1878    surface_format: wgpu::TextureFormat,
1879    uniform_layout: &wgpu::BindGroupLayout,
1880    image_layout: &wgpu::BindGroupLayout,
1881    blend_mode: BlendMode,
1882    depth: bool,
1883) -> wgpu::RenderPipeline {
1884    let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1885        label: Some("Image Shader"),
1886        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1887            shaders::IMAGE_SHADER.into(),
1888            depth,
1889        )),
1890    });
1891
1892    let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1893        label: Some("Image Pipeline Layout"),
1894        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1895        immediate_size: 0,
1896    });
1897
1898    create_render_pipeline_logged(
1899        device,
1900        cache,
1901        &format!("image blend={blend_mode:?} depth={depth}"),
1902        wgpu::RenderPipelineDescriptor {
1903            label: Some("Image Pipeline"),
1904            layout: Some(&image_pipeline_layout),
1905            vertex: wgpu::VertexState {
1906                module: &image_shader,
1907                entry_point: Some("image_vs_main"),
1908                compilation_options: wgpu::PipelineCompilationOptions::default(),
1909                buffers: &[Vertex::desc()],
1910            },
1911            fragment: Some(wgpu::FragmentState {
1912                module: &image_shader,
1913                entry_point: Some("image_fs_main"),
1914                compilation_options: wgpu::PipelineCompilationOptions::default(),
1915                targets: &[Some(wgpu::ColorTargetState {
1916                    format: surface_format,
1917                    blend: Some(blend_state_for_mode(blend_mode)),
1918                    write_mask: wgpu::ColorWrites::ALL,
1919                })],
1920            }),
1921            primitive: wgpu::PrimitiveState {
1922                topology: wgpu::PrimitiveTopology::TriangleList,
1923                strip_index_format: None,
1924                front_face: wgpu::FrontFace::Ccw,
1925                cull_mode: None,
1926                unclipped_depth: false,
1927                polygon_mode: wgpu::PolygonMode::Fill,
1928                conservative: false,
1929            },
1930            depth_stencil: display_clip::content_depth_state(depth),
1931            multisample: wgpu::MultisampleState::default(),
1932            multiview_mask: None,
1933            cache: None,
1934        },
1935    )
1936}
1937
1938fn create_glyph_atlas_pipeline(
1939    device: &wgpu::Device,
1940    cache: Option<&wgpu::PipelineCache>,
1941    surface_format: wgpu::TextureFormat,
1942    uniform_layout: &wgpu::BindGroupLayout,
1943    image_layout: &wgpu::BindGroupLayout,
1944    depth: bool,
1945) -> wgpu::RenderPipeline {
1946    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1947        label: Some("Glyph Atlas Shader"),
1948        source: wgpu::ShaderSource::Wgsl(display_clip::with_content_z(
1949            shaders::GLYPH_ATLAS_SHADER.into(),
1950            depth,
1951        )),
1952    });
1953
1954    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1955        label: Some("Glyph Atlas Pipeline Layout"),
1956        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1957        immediate_size: 0,
1958    });
1959
1960    create_render_pipeline_logged(
1961        device,
1962        cache,
1963        &format!("glyph-atlas depth={depth}"),
1964        wgpu::RenderPipelineDescriptor {
1965            label: Some("Glyph Atlas Pipeline"),
1966            layout: Some(&pipeline_layout),
1967            vertex: wgpu::VertexState {
1968                module: &shader,
1969                entry_point: Some("glyph_atlas_vs_main"),
1970                compilation_options: wgpu::PipelineCompilationOptions::default(),
1971                buffers: &[Vertex::desc()],
1972            },
1973            fragment: Some(wgpu::FragmentState {
1974                module: &shader,
1975                entry_point: Some("glyph_atlas_fs_main"),
1976                compilation_options: wgpu::PipelineCompilationOptions::default(),
1977                targets: &[Some(wgpu::ColorTargetState {
1978                    format: surface_format,
1979                    blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1980                    write_mask: wgpu::ColorWrites::ALL,
1981                })],
1982            }),
1983            primitive: wgpu::PrimitiveState {
1984                topology: wgpu::PrimitiveTopology::TriangleList,
1985                strip_index_format: None,
1986                front_face: wgpu::FrontFace::Ccw,
1987                cull_mode: None,
1988                unclipped_depth: false,
1989                polygon_mode: wgpu::PolygonMode::Fill,
1990                conservative: false,
1991            },
1992            depth_stencil: display_clip::content_depth_state(depth),
1993            multisample: wgpu::MultisampleState::default(),
1994            multiview_mask: None,
1995            cache: None,
1996        },
1997    )
1998}
1999
2000/// Pipeline for the display-clip occluder — the tessellated complement
2001/// of the visible region — the first draw of a culled
2002/// fused pass: depth write ON at the near plane, color writes fully masked
2003/// off, trivial fragment stage with no discard — exactly the shape early-Z
2004/// and LRZ hardware accepts as an occluder. The color target must still be
2005/// declared (the pass has a color attachment), which is what the empty
2006/// write mask is for.
2007#[cfg(not(target_arch = "wasm32"))]
2008fn create_display_clip_occluder_pipeline(
2009    device: &wgpu::Device,
2010    cache: Option<&wgpu::PipelineCache>,
2011    surface_format: wgpu::TextureFormat,
2012) -> wgpu::RenderPipeline {
2013    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2014        label: Some("Display Clip Occluder Shader"),
2015        source: wgpu::ShaderSource::Wgsl(display_clip::OCCLUDER_SHADER.into()),
2016    });
2017    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2018        label: Some("Display Clip Occluder Pipeline Layout"),
2019        bind_group_layouts: &[],
2020        immediate_size: 0,
2021    });
2022    create_render_pipeline_logged(
2023        device,
2024        cache,
2025        "occluder",
2026        wgpu::RenderPipelineDescriptor {
2027            label: Some("Display Clip Occluder Pipeline"),
2028            layout: Some(&pipeline_layout),
2029            vertex: wgpu::VertexState {
2030                module: &shader,
2031                entry_point: Some("mask_vs"),
2032                compilation_options: wgpu::PipelineCompilationOptions::default(),
2033                buffers: &[wgpu::VertexBufferLayout {
2034                    array_stride: (std::mem::size_of::<[f32; 2]>()) as wgpu::BufferAddress,
2035                    step_mode: wgpu::VertexStepMode::Vertex,
2036                    attributes: &[wgpu::VertexAttribute {
2037                        offset: 0,
2038                        shader_location: 0,
2039                        format: wgpu::VertexFormat::Float32x2,
2040                    }],
2041                }],
2042            },
2043            fragment: Some(wgpu::FragmentState {
2044                module: &shader,
2045                entry_point: Some("mask_fs"),
2046                compilation_options: wgpu::PipelineCompilationOptions::default(),
2047                targets: &[Some(wgpu::ColorTargetState {
2048                    format: surface_format,
2049                    blend: None,
2050                    write_mask: wgpu::ColorWrites::empty(),
2051                })],
2052            }),
2053            primitive: wgpu::PrimitiveState {
2054                topology: wgpu::PrimitiveTopology::TriangleList,
2055                strip_index_format: None,
2056                front_face: wgpu::FrontFace::Ccw,
2057                cull_mode: None,
2058                unclipped_depth: false,
2059                polygon_mode: wgpu::PolygonMode::Fill,
2060                conservative: false,
2061            },
2062            depth_stencil: Some(wgpu::DepthStencilState {
2063                format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
2064                depth_write_enabled: Some(true),
2065                depth_compare: Some(wgpu::CompareFunction::Always),
2066                stencil: wgpu::StencilState::default(),
2067                bias: wgpu::DepthBiasState::default(),
2068            }),
2069            multisample: wgpu::MultisampleState::default(),
2070            multiview_mask: None,
2071            cache: None,
2072        },
2073    )
2074}
2075
2076#[repr(C)]
2077#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2078struct Vertex {
2079    position: [f32; 2],
2080    color: [f32; 4],
2081    uv: [f32; 2],
2082    uv_bounds: [f32; 4],
2083}
2084
2085impl Vertex {
2086    const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
2087        0 => Float32x2,
2088        1 => Float32x4,
2089        2 => Float32x2,
2090        3 => Float32x4
2091    ];
2092
2093    fn desc() -> wgpu::VertexBufferLayout<'static> {
2094        wgpu::VertexBufferLayout {
2095            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
2096            step_mode: wgpu::VertexStepMode::Vertex,
2097            attributes: &Self::ATTRIBS,
2098        }
2099    }
2100}
2101
2102#[repr(C)]
2103#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2104struct Uniforms {
2105    viewport: [f32; 2],
2106    viewport_offset: [f32; 2],
2107}
2108
2109/// Mirror of `struct ShapeData` in `shape.wgsl`. Field order and sizes must
2110/// match exactly: 10 x 16 bytes = 160 bytes, every member 16-byte aligned as
2111/// the uniform address space requires. The quad corners and vertex color ride
2112/// in here because the shape pipeline has no vertex buffer: the vertex shader
2113/// pulls all six corners of a shape straight from this struct.
2114#[repr(C)]
2115#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2116struct ShapeData {
2117    rect: [f32; 4], // x, y, width, height
2118    /// Rects: top_left, top_right, bottom_left, bottom_right corner radii.
2119    /// Arcs: (sin, cos) of the mid angle and of the half sweep — the shader's
2120    /// per-shape trig, precomputed so `sdf_arc_band` needs none per fragment.
2121    radii: [f32; 4],
2122    gradient_params: [f32; 4], // linear: start.xy,end.xy; radial: center.xy,radius,unused
2123    clip_rect: [f32; 4],       // clip_x, clip_y, clip_width, clip_height (0,0,0,0 = no clip)
2124    /// stroke width, packed flags (see [`pack_shape_flags`]), arc outer radius,
2125    /// arc inner radius. All zero for a plain fill.
2126    stroke_params: [f32; 4],
2127    /// arc center.xy, start angle, sweep angle (radians, 0 = +X, clockwise).
2128    arc_params: [f32; 4],
2129    /// Device-space quad corners 0 (xy) and 1 (zw).
2130    quad01: [f32; 4],
2131    /// Device-space quad corners 2 (xy) and 3 (zw).
2132    quad23: [f32; 4],
2133    /// Vertex color: the solid brush color, or the first gradient stop.
2134    color: [f32; 4],
2135    brush_type: u32,         // 0=solid, 1=linear_gradient, 2=radial_gradient
2136    gradient_start: u32,     // Starting index in gradient buffer
2137    gradient_count: u32,     // Number of gradient stops
2138    gradient_tile_mode: u32, // 0=Clamp, 1=Repeated, 2=Mirror, 3=Decal
2139}
2140
2141/// Shape kinds understood by `shape.wgsl`.
2142const SHAPE_KIND_FILL: u32 = 0;
2143const SHAPE_KIND_STROKE: u32 = 1;
2144const SHAPE_KIND_ARC: u32 = 2;
2145
2146fn stroke_cap_code(cap: StrokeCap) -> u32 {
2147    match cap {
2148        StrokeCap::Butt => 0,
2149        StrokeCap::Round => 1,
2150        StrokeCap::Square => 2,
2151    }
2152}
2153
2154fn stroke_join_code(join: StrokeJoin) -> u32 {
2155    match join {
2156        StrokeJoin::Miter => 0,
2157        StrokeJoin::Round => 1,
2158        StrokeJoin::Bevel => 2,
2159    }
2160}
2161
2162/// Packs kind/cap/join into the single float `ShapeData::stroke_params[1]`.
2163///
2164/// Three 2-bit fields fit in one f32 exactly (integers below 2^24 are exact),
2165/// which keeps `ShapeData` a slot smaller than it would be if each field got
2166/// its own float — batch capacity is set by this size on uniform backends.
2167fn pack_shape_flags(kind: u32, cap: StrokeCap, join: StrokeJoin) -> f32 {
2168    ((kind & 3) | (stroke_cap_code(cap) << 2) | (stroke_join_code(join) << 4)) as f32
2169}
2170
2171/// Whether a batch conversion fans out is decided by measurement — see
2172/// [`crate::cost_tuner::CostTuner`]. The floor of 256 matters: a device
2173/// whose uniform binding caps batches at ~409 shapes never crossed the old
2174/// fixed threshold of 512, so conversion ran serial on exactly the class of
2175/// hardware (watch-grade in-order cores) where fanning out pays most. The
2176/// 400 µs cheap floor keeps a big phone core, which clears such a batch in
2177/// well under that, from ever paying for a spawn wave.
2178#[cfg(not(target_arch = "wasm32"))]
2179static SHAPE_CONVERT_TUNER: crate::cost_tuner::CostTuner =
2180    crate::cost_tuner::CostTuner::new("shape-convert", 256, 400_000);
2181
2182#[cfg(not(target_arch = "wasm32"))]
2183pub(crate) fn shape_convert_worker_count() -> usize {
2184    static WORKERS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
2185    *WORKERS.get_or_init(|| {
2186        let cpus = std::thread::available_parallelism()
2187            .map(|count| count.get())
2188            .unwrap_or(1);
2189        let workers = cpus.clamp(1, 4);
2190        // One line per process: on devices whose scheduler confines the
2191        // process (affinity masks, cpusets), this is the number that
2192        // explains why fan-out stages stayed serial.
2193        log::info!("[shape-convert] fan-out width {workers} (available parallelism {cpus})");
2194        workers
2195    })
2196}
2197
2198#[cfg(target_arch = "wasm32")]
2199pub(crate) fn shape_convert_worker_count() -> usize {
2200    1
2201}
2202
2203fn shape_gradient_stop_count(shape: &DrawShape, brushes: &[Brush]) -> usize {
2204    match shape.brush {
2205        SceneBrush::Solid(_) => 0,
2206        SceneBrush::Gradient(index) => match &brushes[index as usize] {
2207            Brush::Solid(_) => 0,
2208            Brush::LinearGradient { colors, .. }
2209            | Brush::RadialGradient { colors, .. }
2210            | Brush::SweepGradient { colors, .. } => colors.len(),
2211        },
2212    }
2213}
2214
2215/// Converts one [`DrawShape`] into its GPU representation, writing into
2216/// pre-sized slots so a batch can convert in parallel across disjoint
2217/// sub-slices. `gradient_start` is the shape's global offset into the batch
2218/// gradient buffer; `gradient_out` is exactly its span of that buffer.
2219fn convert_shape_into_slots(
2220    shape: &DrawShape,
2221    brushes: &[Brush],
2222    root_scale: f32,
2223    gradient_start: u32,
2224    shape_out: &mut ShapeData,
2225    gradient_out: &mut [GradientStop],
2226) {
2227    let snap_delta = shape
2228        .snap_anchor
2229        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
2230        .unwrap_or_default();
2231    let local_rect = shape.local_rect.translate(snap_delta.x, snap_delta.y);
2232    let quad = translate_quad(shape.quad, snap_delta);
2233    // Clips are resolved in scene space from their own layer ancestry. A draw
2234    // item's raster snap must never move a fixed ancestor clip.
2235    let clip = shape.clip;
2236    let canonicalize = shape.snap_anchor.is_some();
2237    let device_local_rect = if canonicalize {
2238        canonicalized_scaled_rect(local_rect, root_scale)
2239    } else {
2240        Rect {
2241            x: local_rect.x * root_scale,
2242            y: local_rect.y * root_scale,
2243            width: local_rect.width * root_scale,
2244            height: local_rect.height * root_scale,
2245        }
2246    };
2247    let device_quad = if canonicalize {
2248        canonicalized_scaled_quad(quad, root_scale)
2249    } else {
2250        scaled_quad(quad, root_scale)
2251    };
2252    let canonicalize_brush_coordinate = |value| {
2253        if canonicalize {
2254            canonicalize_device_coordinate(value)
2255        } else {
2256            value
2257        }
2258    };
2259
2260    // Clip rect (scaled to physical pixels)
2261    let clip_rect = if let Some(clip) = clip {
2262        let device_clip = if canonicalize {
2263            canonicalized_scaled_rect(clip, root_scale)
2264        } else {
2265            Rect {
2266                x: clip.x * root_scale,
2267                y: clip.y * root_scale,
2268                width: clip.width * root_scale,
2269                height: clip.height * root_scale,
2270            }
2271        };
2272        [
2273            device_clip.x,
2274            device_clip.y,
2275            device_clip.width,
2276            device_clip.height,
2277        ]
2278    } else {
2279        [0.0, 0.0, 0.0, 0.0]
2280    };
2281
2282    // Gradient parameters
2283    let mut fill_gradient_entries = |colors: &[Color], stops: Option<&[f32]>| {
2284        let count = colors.len();
2285        let explicit_stops = stops.filter(|values| values.len() == count);
2286        for (index, color) in colors.iter().enumerate() {
2287            let position = explicit_stops
2288                .map(|values| values[index])
2289                .unwrap_or_else(|| {
2290                    if count <= 1 {
2291                        0.0
2292                    } else {
2293                        index as f32 / (count - 1) as f32
2294                    }
2295                });
2296            gradient_out[index] = GradientStop {
2297                color: [color.r(), color.g(), color.b(), color.a()],
2298                position: [position, 0.0, 0.0, 0.0],
2299            };
2300        }
2301        count as u32
2302    };
2303    let mut gradient_params = [0.0f32; 4];
2304    let (brush_type, gradient_count, gradient_tile_mode) = match &shape.brush {
2305        SceneBrush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
2306        SceneBrush::Gradient(index) => match &brushes[*index as usize] {
2307            Brush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
2308            Brush::LinearGradient {
2309                colors,
2310                stops,
2311                start,
2312                end,
2313                tile_mode,
2314            } => {
2315                let count = fill_gradient_entries(colors, stops.as_deref());
2316                gradient_params = [
2317                    canonicalize_brush_coordinate(resolve_gradient_point(
2318                        device_local_rect.x,
2319                        device_local_rect.width,
2320                        start.x * root_scale,
2321                    )),
2322                    canonicalize_brush_coordinate(resolve_gradient_point(
2323                        device_local_rect.y,
2324                        device_local_rect.height,
2325                        start.y * root_scale,
2326                    )),
2327                    canonicalize_brush_coordinate(resolve_gradient_point(
2328                        device_local_rect.x,
2329                        device_local_rect.width,
2330                        end.x * root_scale,
2331                    )),
2332                    canonicalize_brush_coordinate(resolve_gradient_point(
2333                        device_local_rect.y,
2334                        device_local_rect.height,
2335                        end.y * root_scale,
2336                    )),
2337                ];
2338                (1u32, count, gradient_tile_mode_value(*tile_mode))
2339            }
2340            Brush::RadialGradient {
2341                colors,
2342                stops,
2343                center,
2344                radius,
2345                tile_mode,
2346            } => {
2347                let count = fill_gradient_entries(colors, stops.as_deref());
2348                gradient_params = [
2349                    canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
2350                    canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
2351                    (radius * root_scale).max(f32::EPSILON),
2352                    0.0,
2353                ];
2354                (2u32, count, gradient_tile_mode_value(*tile_mode))
2355            }
2356            Brush::SweepGradient {
2357                colors,
2358                stops,
2359                center,
2360            } => {
2361                let count = fill_gradient_entries(colors, stops.as_deref());
2362                gradient_params = [
2363                    canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
2364                    canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
2365                    0.0,
2366                    0.0,
2367                ];
2368                (3u32, count, gradient_tile_mode_value(TileMode::Clamp))
2369            }
2370        },
2371    };
2372
2373    // A stroked rect/round-rect was emitted with `local_rect` already
2374    // inflated by half the stroke width, so corner radii must resolve
2375    // against the geometry that was actually asked for, not the
2376    // inflated box. The shader shrinks `half_size` by the same amount.
2377    let stroke_outset = shape
2378        .stroke
2379        .map(|stroke| stroke.half_width())
2380        .unwrap_or(0.0);
2381    let geometry_width = (local_rect.width - stroke_outset * 2.0).max(0.0);
2382    let geometry_height = (local_rect.height - stroke_outset * 2.0).max(0.0);
2383
2384    let radii = if let Some(arc) = shape.arc {
2385        // Arcs never carry corner radii, so this slot ships the shader's
2386        // per-shape trig instead: (sin, cos) of the sweep's mid angle and of
2387        // the half sweep. Computing these here — once per shape — is what
2388        // lets `sdf_arc_band` run without a single transcendental per
2389        // fragment. A full ring is the common case (dots, particles) and
2390        // `ArcGeometry::new` normalizes it to start 0 / sweep TAU, whose
2391        // values are exact constants; the half-sweep sine is pinned to
2392        // non-negative just like the shader used to, so a closed ring keeps
2393        // its seam-free (0, -1) form.
2394        if arc.sweep_angle >= cranpose_ui_graphics::TAU && arc.start_angle == 0.0 {
2395            [0.0, -1.0, 0.0, -1.0]
2396        } else {
2397            let half_sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU) * 0.5;
2398            let (mid_sin, mid_cos) = (arc.start_angle + half_sweep).sin_cos();
2399            let (half_sin, half_cos) = half_sweep.sin_cos();
2400            [mid_sin, mid_cos, half_sin.max(0.0), half_cos]
2401        }
2402    } else if let Some(rounded) = shape.shape {
2403        let resolved = rounded.resolve(geometry_width, geometry_height);
2404        [
2405            resolved.top_left * root_scale,
2406            resolved.top_right * root_scale,
2407            resolved.bottom_left * root_scale,
2408            resolved.bottom_right * root_scale,
2409        ]
2410    } else {
2411        [0.0, 0.0, 0.0, 0.0]
2412    };
2413
2414    let device_rect = [
2415        device_local_rect.x,
2416        device_local_rect.y,
2417        device_local_rect.width,
2418        device_local_rect.height,
2419    ];
2420
2421    // Stroke/arc parameters ride in the same ShapeData and the same
2422    // pipeline as fills, so a stroked or arc shape never splits a
2423    // batch.
2424    let (stroke_params, arc_params) = match (shape.arc, shape.stroke) {
2425        (Some(arc), _) => (
2426            [
2427                0.0,
2428                pack_shape_flags(SHAPE_KIND_ARC, arc.cap, StrokeJoin::Miter),
2429                arc.outer_radius * root_scale,
2430                arc.inner_radius * root_scale,
2431            ],
2432            [
2433                (arc.center.x + snap_delta.x) * root_scale,
2434                (arc.center.y + snap_delta.y) * root_scale,
2435                arc.start_angle,
2436                arc.sweep_angle,
2437            ],
2438        ),
2439        (None, Some(stroke)) => (
2440            [
2441                stroke.width.max(0.0) * root_scale,
2442                pack_shape_flags(SHAPE_KIND_STROKE, stroke.cap, stroke.join),
2443                0.0,
2444                0.0,
2445            ],
2446            [0.0; 4],
2447        ),
2448        (None, None) => (
2449            [
2450                0.0,
2451                pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
2452                0.0,
2453                0.0,
2454            ],
2455            [0.0; 4],
2456        ),
2457    };
2458
2459    let color = match &shape.brush {
2460        SceneBrush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2461        SceneBrush::Gradient(index) => match &brushes[*index as usize] {
2462            Brush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2463            Brush::LinearGradient { colors, .. } => {
2464                let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2465                [first.r(), first.g(), first.b(), first.a()]
2466            }
2467            Brush::RadialGradient { colors, .. } | Brush::SweepGradient { colors, .. } => {
2468                let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2469                [first.r(), first.g(), first.b(), first.a()]
2470            }
2471        },
2472    };
2473
2474    *shape_out = ShapeData {
2475        rect: device_rect,
2476        radii,
2477        gradient_params,
2478        clip_rect,
2479        stroke_params,
2480        arc_params,
2481        quad01: [
2482            device_quad[0][0],
2483            device_quad[0][1],
2484            device_quad[1][0],
2485            device_quad[1][1],
2486        ],
2487        quad23: [
2488            device_quad[2][0],
2489            device_quad[2][1],
2490            device_quad[3][0],
2491            device_quad[3][1],
2492        ],
2493        color,
2494        brush_type,
2495        gradient_start,
2496        gradient_count,
2497        gradient_tile_mode,
2498    };
2499}
2500
2501/// `CRANPOSE_QUAD_AREA_DIAG=1` prints, per shape batch, how many device
2502/// pixels the emitted quads cover — split into arc quads, the true arc band
2503/// coverage inside them, and everything else. Fill cost is the product of
2504/// fragment count and shader cost, and this is the fragment-count half: it
2505/// is how the MEGA scene's ~10x overdraw (and the ~50% of arc-quad area that
2506/// the SDF discards) was measured.
2507fn quad_area_diag_enabled() -> bool {
2508    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2509    *ENABLED.get_or_init(|| std::env::var_os("CRANPOSE_QUAD_AREA_DIAG").is_some())
2510}
2511
2512/// Converts a batch of shapes into pre-sized output slices, fanning the work
2513/// across scoped threads when the batch is large enough to pay for spawns.
2514/// The outputs may be scratch vectors or mapped GPU staging memory; each
2515/// shape writes only its own disjoint slots, so chunked `split_at_mut`
2516/// hand-off keeps the parallel path free of any synchronization.
2517fn convert_shapes_into_outputs(
2518    shape_refs: &[&DrawShape],
2519    brushes: &[Brush],
2520    gradient_offsets: &[u32],
2521    root_scale: f32,
2522    shape_data_out: &mut [ShapeData],
2523    gradients_out: &mut [GradientStop],
2524) {
2525    let shape_count = shape_refs.len();
2526    #[cfg(not(target_arch = "wasm32"))]
2527    let convert_started = Instant::now();
2528    #[cfg(not(target_arch = "wasm32"))]
2529    let parallel =
2530        SHAPE_CONVERT_TUNER.choose_parallel(shape_count) && shape_convert_worker_count() > 1;
2531    if quad_area_diag_enabled() {
2532        let quad_area = |q: [[f32; 2]; 4]| {
2533            // Shoelace over the quad polygon TL, TR, BR, BL (corners 0,1,3,2).
2534            let poly = [q[0], q[1], q[3], q[2]];
2535            let mut twice = 0.0f64;
2536            for i in 0..4 {
2537                let a = poly[i];
2538                let b = poly[(i + 1) % 4];
2539                twice += a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64;
2540            }
2541            twice.abs() * 0.5
2542        };
2543        let mut arc_quad = 0.0f64; // quad px of arc shapes
2544        let mut arc_band = 0.0f64; // true band coverage of those arcs
2545        let mut arc_count = 0usize;
2546        let mut ring_count = 0usize;
2547        let mut other_quad = 0.0f64;
2548        let mut other_count = 0usize;
2549        // Largest non-arc quads: (area, index) so the tail of the diag can
2550        // name what the aggregate "other" fill actually is.
2551        let mut top_other: Vec<(f64, usize)> = Vec::new();
2552        for (index, shape) in shape_refs.iter().enumerate() {
2553            let area = quad_area(shape.quad);
2554            if let Some(arc) = shape.arc {
2555                arc_quad += area;
2556                arc_count += 1;
2557                if arc.sweep_angle >= cranpose_ui_graphics::TAU {
2558                    ring_count += 1;
2559                }
2560                let ra = arc.mid_radius() as f64;
2561                let rb = arc.half_thickness() as f64;
2562                arc_band +=
2563                    arc.sweep_angle as f64 * ra * (2.0 * rb) + std::f64::consts::PI * rb * rb;
2564            } else {
2565                other_quad += area;
2566                other_count += 1;
2567                top_other.push((area, index));
2568            }
2569        }
2570        let scale2 = (root_scale as f64) * (root_scale as f64);
2571        eprintln!(
2572            "[quad-area] arcs={arc_count} (rings={ring_count}) arc_quad_px={:.0} arc_band_px={:.0} | other={other_count} other_px={:.0}",
2573            arc_quad * scale2,
2574            arc_band * scale2,
2575            other_quad * scale2,
2576        );
2577        top_other.sort_by(|a, b| b.0.total_cmp(&a.0));
2578        for &(area, index) in top_other.iter().take(4) {
2579            let shape = shape_refs[index];
2580            let brush = match shape.brush.resolve(brushes).as_ref() {
2581                cranpose_ui_graphics::Brush::Solid(color) => format!("solid a={:.2}", color.3),
2582                cranpose_ui_graphics::Brush::LinearGradient { colors, .. } => {
2583                    format!("linear n={}", colors.len())
2584                }
2585                cranpose_ui_graphics::Brush::RadialGradient { colors, .. } => {
2586                    format!("radial n={}", colors.len())
2587                }
2588                cranpose_ui_graphics::Brush::SweepGradient { colors, .. } => {
2589                    format!("sweep n={}", colors.len())
2590                }
2591            };
2592            eprintln!(
2593                "[quad-area]   top other: {:.0}px {}x{} at ({:.0},{:.0}) {} shape={} stroke={} clip={} blend={:?} z={}",
2594                area * scale2,
2595                shape.rect.width.round(),
2596                shape.rect.height.round(),
2597                shape.rect.x,
2598                shape.rect.y,
2599                brush,
2600                shape.shape.is_some(),
2601                shape.stroke.is_some(),
2602                shape.clip.is_some(),
2603                shape.blend_mode,
2604                shape.z_index,
2605            );
2606        }
2607    }
2608    #[cfg(target_arch = "wasm32")]
2609    let parallel = false;
2610    let workers = if parallel {
2611        shape_convert_worker_count()
2612    } else {
2613        1
2614    };
2615    if workers <= 1 {
2616        for (idx, shape) in shape_refs.iter().enumerate() {
2617            let gradient_start = gradient_offsets[idx];
2618            let gradient_end = gradient_offsets[idx + 1];
2619            convert_shape_into_slots(
2620                shape,
2621                brushes,
2622                root_scale,
2623                gradient_start,
2624                &mut shape_data_out[idx],
2625                &mut gradients_out[gradient_start as usize..gradient_end as usize],
2626            );
2627        }
2628        #[cfg(not(target_arch = "wasm32"))]
2629        SHAPE_CONVERT_TUNER.record(
2630            false,
2631            shape_count,
2632            convert_started.elapsed().as_nanos() as u64,
2633        );
2634        return;
2635    }
2636
2637    let chunk_len = shape_count.div_ceil(workers);
2638    let mut shape_data_rest = shape_data_out;
2639    let mut gradients_rest = gradients_out;
2640    std::thread::scope(|scope| {
2641        let mut chunk_start = 0usize;
2642        while chunk_start < shape_count {
2643            let chunk_end = (chunk_start + chunk_len).min(shape_count);
2644            let count = chunk_end - chunk_start;
2645            let gradient_base = gradient_offsets[chunk_start];
2646            let gradient_span = (gradient_offsets[chunk_end] - gradient_base) as usize;
2647            let (shape_data_chunk, rest) = std::mem::take(&mut shape_data_rest).split_at_mut(count);
2648            shape_data_rest = rest;
2649            let (gradient_chunk, rest) =
2650                std::mem::take(&mut gradients_rest).split_at_mut(gradient_span);
2651            gradients_rest = rest;
2652            let chunk_refs = &shape_refs[chunk_start..chunk_end];
2653            let chunk_offsets = &gradient_offsets[chunk_start..=chunk_end];
2654            let mut convert_chunk = move || {
2655                for (j, shape) in chunk_refs.iter().enumerate() {
2656                    let gradient_start = chunk_offsets[j];
2657                    let local_start = (gradient_start - gradient_base) as usize;
2658                    let local_end = (chunk_offsets[j + 1] - gradient_base) as usize;
2659                    convert_shape_into_slots(
2660                        shape,
2661                        brushes,
2662                        root_scale,
2663                        gradient_start,
2664                        &mut shape_data_chunk[j],
2665                        &mut gradient_chunk[local_start..local_end],
2666                    );
2667                }
2668            };
2669            if chunk_end == shape_count {
2670                // The caller would only block at the scope join; converting
2671                // the final chunk inline puts that time to work and saves a
2672                // spawn.
2673                convert_chunk();
2674            } else {
2675                scope.spawn(convert_chunk);
2676            }
2677            chunk_start = chunk_end;
2678        }
2679    });
2680    #[cfg(not(target_arch = "wasm32"))]
2681    SHAPE_CONVERT_TUNER.record(
2682        true,
2683        shape_count,
2684        convert_started.elapsed().as_nanos() as u64,
2685    );
2686}
2687
2688#[repr(C)]
2689#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2690struct GradientStop {
2691    color: [f32; 4],
2692    position: [f32; 4],
2693}
2694
2695/// How many replay slots the shared transform buffer holds. Each slot's
2696/// transform lives at `slot * REPLAY_TRANSFORM_STRIDE`, aligned for the
2697/// strictest uniform-offset requirement any backend reports.
2698#[cfg(not(target_arch = "wasm32"))]
2699const MAX_REPLAY_SLOTS: u32 = 128;
2700#[cfg(not(target_arch = "wasm32"))]
2701const REPLAY_TRANSFORM_STRIDE: u64 = 256;
2702
2703/// One retained replay batch: converted shape slots captured on an earlier
2704/// frame, kept on the GPU and re-drawn each frame under the similarity
2705/// transform staged at `transform_offset`.
2706///
2707/// The immutable `ShapeData` and gradient buffers hold no handle here:
2708/// nothing addresses them after capture, and `bind_group` keeps them alive.
2709#[cfg(not(target_arch = "wasm32"))]
2710struct ReplaySlot {
2711    /// One `vec4<f32>` color per shape — the mutable paint the shader reads
2712    /// under `paint_select`, split out so recolor patches upload 16 bytes
2713    /// per shape while the 160-byte `ShapeData` stays immutable on the GPU
2714    /// from capture to release.
2715    paint_buffer: wgpu::Buffer,
2716    bind_group: wgpu::BindGroup,
2717    shape_count: u32,
2718    /// CPU mirror of the paint buffer. Recolor patches apply here first
2719    /// and upload as one contiguous span per slot per frame — MEGA's
2720    /// twinkle field recolors ~1.7k dots a frame, and that many individual
2721    /// copy commands stall a mobile GPU for longer than the spans' extra
2722    /// bytes ever could.
2723    paint_mirror: Vec<[f32; 4]>,
2724    /// Conservative capture-space arc/ring mesh, built once at capture.
2725    /// `None` when the kill switch is off, the slot meshed no shapes (none
2726    /// over the size gate), or the vertex budget overflowed — those slots
2727    /// replay through the quad-expansion six-vertices-per-shape path.
2728    mesh: Option<ReplaySlotMesh>,
2729    /// Which capture created this slot's buffers, from the store's global
2730    /// monotone counter. Retained bundle keys carry it so a slot id that is
2731    /// released and recaptured — new bind group, new buffers, same id — can
2732    /// never be drawn through a bundle recorded against the old capture.
2733    capture_epoch: u64,
2734    /// Whether any captured shape carries gradient stops. False routes the
2735    /// slot's quad-expansion draws through the `fs_solid` pipelines; fixed
2736    /// for the life of the capture, so bundle keys need nothing beyond the
2737    /// capture epoch they already carry.
2738    has_gradient: bool,
2739    /// Per-shape capture-space fill records for the `CRANPOSE_FILL_DIAG`
2740    /// instrument (`shape_count` entries): submitted area (mesh triangles
2741    /// when this slot replays its arc mesh, bounding quads otherwise),
2742    /// analytic lit area, opacity class and quad AABB. Empty when the
2743    /// diagnostic is off.
2744    fill_diag_shapes: Vec<FillDiagShapeRecord>,
2745    /// Per-shape capture-space quad AABBs (`[min_x, min_y, max_x, max_y]`,
2746    /// `shape_count` entries) — the segment-surface cache's geometry
2747    /// source. Always computed (one min/max pass over corners already in
2748    /// cache at capture), so a slot captured while that cache was off still
2749    /// serves it after an opt-in flip.
2750    shape_aabbs: Vec<[f32; 4]>,
2751    /// Running quad-area prefix sum (`shape_count + 1` entries): shape
2752    /// range `a..b` submits `area_prefix[b] - area_prefix[a]` device px²
2753    /// of quads at capture scale.
2754    area_prefix: Vec<f32>,
2755    /// Ratio of actually-submitted pixels to plain quad pixels when this
2756    /// slot replays its arc mesh (1.0 unmeshed) — the segment-surface
2757    /// economics gate prices the direct path by what it truly rasterizes.
2758    submitted_area_scale: f32,
2759}
2760
2761/// Band geometry a retained slot replays for its MESHED shapes only: arc
2762/// and stroked-circle rim bands over the size gate get trapezoid strips
2763/// covering their antialiasing footprint, while every other shape stays on
2764/// the latched instanced-quad path — the draw walk alternates between the
2765/// two along the shape range ([`GpuRenderer::encode_retained_op`]). The
2766/// buffers never hold passthrough quads: routing them through per-vertex
2767/// `MeshVertex` attributes instead of instancing's shared storage reads is
2768/// what the watch A/B measured as a 2-5 fps LOSS (see
2769/// [`arc_mesh_enabled`]). See [`build_arc_mesh_vertices`].
2770#[cfg(not(target_arch = "wasm32"))]
2771struct ReplaySlotMesh {
2772    vertex_buffer: wgpu::Buffer,
2773    /// `u32` triangle-list indices into `vertex_buffer`: band-boundary
2774    /// vertices are emitted once and shared by both adjacent trapezoids, so
2775    /// per-arc vertex-shader work drops from ~30 executions to the unique
2776    /// boundary vertices (~10-14) — the amplification that made the
2777    /// non-indexed mesh SLOWER than plain quads on the watch's Adreno 702.
2778    index_buffer: wgpu::Buffer,
2779    /// Prefix table, `shape_count + 1` entries: shape `i`'s triangles occupy
2780    /// indices `index_prefix[i]..index_prefix[i + 1]`; an EMPTY range marks
2781    /// a shape the draw walk keeps instanced. A run of meshed shapes draws
2782    /// as one `draw_indexed` over its combined range — identical shape
2783    /// order, z untouched.
2784    index_prefix: Vec<u32>,
2785    /// Capture engagement counts for the test/diagnostic view
2786    /// ([`GpuRenderer::replay_slot_mesh_engagement`]): shapes meshed as arc
2787    /// bands, shapes meshed as stroked-circle rim bands, and shapes that
2788    /// stayed on the instanced-quad path (gate-rejected or non-band).
2789    meshed_arcs: usize,
2790    meshed_rims: usize,
2791    passthrough: usize,
2792}
2793
2794/// Vertex of a retained slot's conservative arc mesh: capture-device-space
2795/// position, the uv reproducing `vs_main`'s affine rect map at that position,
2796/// and the shape index standing in for `vertex_index / 6`.
2797#[cfg(not(target_arch = "wasm32"))]
2798#[repr(C)]
2799#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2800struct MeshVertex {
2801    position: [f32; 2],
2802    uv: [f32; 2],
2803    shape_idx: u32,
2804}
2805
2806#[cfg(not(target_arch = "wasm32"))]
2807impl MeshVertex {
2808    const ATTRIBS: [wgpu::VertexAttribute; 3] =
2809        wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32];
2810
2811    fn desc() -> wgpu::VertexBufferLayout<'static> {
2812        wgpu::VertexBufferLayout {
2813            array_stride: std::mem::size_of::<MeshVertex>() as wgpu::BufferAddress,
2814            step_mode: wgpu::VertexStepMode::Vertex,
2815            attributes: &Self::ATTRIBS,
2816        }
2817    }
2818}
2819
2820/// Kill switch, mirroring `command_feed_enabled`: default ON,
2821/// `CRANPOSE_ARC_MESH=0` (or the `debug.cranpose.arc_mesh` property on
2822/// Android) makes the next capture skip mesh building entirely, so a device
2823/// A/B needs no rebuild. Read per capture — captures are rare.
2824#[cfg(not(target_arch = "wasm32"))]
2825fn arc_mesh_enabled() -> bool {
2826    // OPT-IN by measurement, size gate and all: alternating watch pairs
2827    // (Adreno 702, mega scene, gate at its 16384 px² default — 2 shapes
2828    // meshed, ~550 passthrough per slot) read mesh ON 48.7/43.5 fps vs
2829    // OFF 53.7/45.2 — both pairs lose. The earlier all-arcs regime lost
2830    // 4-11 fps; the gate shrank the loss, never crossed zero. The likely
2831    // mechanism is structural: a slot holding a mesh leaves the latched
2832    // instanced-quad path for EVERY shape in the slot, so its passthrough
2833    // quads pay per-vertex attribute bandwidth where the instanced path
2834    // paid shared storage reads — on a bandwidth-bound part that swamps
2835    // the meshed shapes' fill recovery (fill-truth: 0.45 Mpx/frame of
2836    // retained slack, 86-94% in a handful of huge ring/rim shapes). The
2837    // measured WIN regime stays the DYNAMIC transient rim mesh
2838    // ([`rim_mesh_band`], +9 fps, default on). A retry that could earn
2839    // default-on: split a meshed slot's draw so passthrough shapes stay
2840    // instanced and only gate-passing shapes take the mesh.
2841    matches!(std::env::var("CRANPOSE_ARC_MESH").as_deref(), Ok("1"))
2842}
2843
2844/// Dilation applied to the band's half-thickness before meshing, in capture
2845/// device pixels. The fragment SDF feathers over ±0.5 px
2846/// (`smoothstep(-0.5, 0.5, dist)`), so every pixel the shader keeps sits
2847/// within 0.5 px of the band; the other 0.5 px absorbs f32 slop between this
2848/// builder's trig and the converted shape's precomputed (sin, cos) pairs.
2849#[cfg(not(target_arch = "wasm32"))]
2850const ARC_MESH_MARGIN: f32 = 1.0;
2851
2852/// Chord overshoot budget in pixels: the segment count is chosen so pushing
2853/// outer edges tangent-outside the dilated outer circle overshoots it by
2854/// about this much at the chord ends.
2855#[cfg(not(target_arch = "wasm32"))]
2856const ARC_MESH_OVERSHOOT: f32 = 2.0;
2857
2858#[cfg(not(target_arch = "wasm32"))]
2859const ARC_MESH_MIN_SEGMENTS: usize = 4;
2860#[cfg(not(target_arch = "wasm32"))]
2861const ARC_MESH_MAX_SEGMENTS: usize = 64;
2862
2863/// Per-slot geometry budget in BYTES: 48 vertex-equivalents (~1 KB) per
2864/// shape, floored for tiny slots so a single huge ring still fits. The
2865/// non-indexed mesh spent this entirely on 20-byte vertices; the indexed
2866/// mesh counts vertices AND 4-byte indices against the same byte ceiling,
2867/// which indexed geometry fits with more headroom (MEGA's retained arcs
2868/// drop from ~30 vertices ≈ 600 B to ~12 unique vertices + ~30 indices
2869/// ≈ 360 B). Overflow falls back to whole-slot passthrough WITH a warning —
2870/// truncating silently would break the containment invariant.
2871#[cfg(not(target_arch = "wasm32"))]
2872const ARC_MESH_BUDGET_BYTES_PER_SHAPE: usize = 48 * std::mem::size_of::<MeshVertex>();
2873#[cfg(not(target_arch = "wasm32"))]
2874const ARC_MESH_BUDGET_FLOOR_BYTES: usize = 4096 * std::mem::size_of::<MeshVertex>();
2875
2876/// The budget-relevant size of an indexed mesh: what the GPU buffers will
2877/// actually hold.
2878#[cfg(not(target_arch = "wasm32"))]
2879fn arc_mesh_bytes(vertices: usize, indices: usize) -> usize {
2880    vertices * std::mem::size_of::<MeshVertex>() + indices * std::mem::size_of::<u32>()
2881}
2882
2883/// Ceiling on a capture's maximal runs of consecutive meshed shapes
2884/// ([`ArcMeshBuild::meshed_stretches`]). The draw walk alternates between
2885/// the mesh pipeline and the instanced-quad pipeline along the shape range,
2886/// so every meshed stretch costs an op that covers it two pipeline switches
2887/// plus an index-buffer rebind; a slot whose meshed shapes interleave
2888/// pathologically with passthrough ones would trade the fill win for
2889/// switch thrash. Past this cap the capture keeps NO mesh and the whole
2890/// slot stays on the instanced path — a structural property of the
2891/// captured content, not of any app. Eight stretches bound an op at
2892/// seventeen draws; the measured scene's slots hold two.
2893#[cfg(not(target_arch = "wasm32"))]
2894const MESH_SLOT_MAX_STRETCHES: usize = 8;
2895
2896/// Default size gate for the retained capture mesh, in capture-space px² of
2897/// a shape's bounding quad: shapes below it take the passthrough quad even
2898/// when they qualify geometrically.
2899///
2900/// The default follows from the trade's own economics, not from any one
2901/// scene. A band mesh costs a roughly shape-size-independent overhead — up
2902/// to [`ARC_MESH_MAX_SEGMENTS`] trapezoids of vertex work plus the extra
2903/// primitives' setup and bin-list traffic on a tiling GPU — while what it
2904/// can recover scales with the shape's quad area times its discard-slack
2905/// fraction (an arc or ring band fills only O(perimeter x thickness) of
2906/// its box, so the slack fraction RISES with size: big bands are almost
2907/// all slack, tiny ones barely any). Fixed cost against area-proportional
2908/// benefit crosses zero at some quad size; 16384 px² (a 128 px square)
2909/// puts the gate an order of magnitude above the measured loss regime and
2910/// an order below the measured win regime, so it is margin, not tuning:
2911/// on the Adreno 702 meshing ~14k retained ~100-800 px² shapes lost
2912/// 4-11 fps, while the same mesher over only large shapes wins on the same
2913/// GPU (the shipping [`rim_mesh_band`] path, gated at 65536 px²), and
2914/// fill-truth's top retained slack sits at ~19k px² and up (86-94% slack).
2915/// Any app whose retained content mixes the two populations lands on the
2916/// same split; a device where the crossover measurably differs A/Bs the
2917/// threshold through the override below without a rebuild.
2918#[cfg(not(target_arch = "wasm32"))]
2919const RETAINED_MESH_MIN_PX2_DEFAULT: usize = 16384;
2920/// Clamp for the `CRANPOSE_RETAINED_MESH_PX2` override: below ~1k px² the
2921/// tiny-mesh amplification regime demonstrably returns, and above 256k px²
2922/// the gate exceeds a whole 512x512 quad — both ends are "you no longer
2923/// mean the size gate", not useful A/B settings.
2924#[cfg(not(target_arch = "wasm32"))]
2925const RETAINED_MESH_MIN_PX2_RANGE: std::ops::RangeInclusive<usize> = 1024..=262144;
2926
2927/// The retained capture mesh's size gate in px², default
2928/// [`RETAINED_MESH_MIN_PX2_DEFAULT`], overridable for device A/Bs via
2929/// `CRANPOSE_RETAINED_MESH_PX2` (the `debug.cranpose.retained_mesh_px2`
2930/// property on Android), clamped to [`RETAINED_MESH_MIN_PX2_RANGE`]. Read
2931/// per capture like [`arc_mesh_enabled`] — captures are rare.
2932#[cfg(not(target_arch = "wasm32"))]
2933fn retained_mesh_min_px2() -> f64 {
2934    parse_retained_mesh_min_px2(std::env::var("CRANPOSE_RETAINED_MESH_PX2").ok().as_deref())
2935}
2936
2937#[cfg(not(target_arch = "wasm32"))]
2938fn parse_retained_mesh_min_px2(value: Option<&str>) -> f64 {
2939    value
2940        .and_then(|value| value.trim().parse::<usize>().ok())
2941        .map(|px2| {
2942            px2.clamp(
2943                *RETAINED_MESH_MIN_PX2_RANGE.start(),
2944                *RETAINED_MESH_MIN_PX2_RANGE.end(),
2945            )
2946        })
2947        .unwrap_or(RETAINED_MESH_MIN_PX2_DEFAULT) as f64
2948}
2949
2950/// Band parameters of a captured arc that qualifies for a conservative mesh:
2951/// solid brush, no clip, and a quad that is exactly — tolerance zero — the
2952/// axis-aligned box of its rect. Everything else returns `None` and passes
2953/// through as today's two quad triangles.
2954#[cfg(not(target_arch = "wasm32"))]
2955struct ArcMeshBand {
2956    center: [f32; 2],
2957    inner: f32,
2958    outer: f32,
2959    start: f32,
2960    sweep: f32,
2961}
2962
2963#[cfg(not(target_arch = "wasm32"))]
2964fn arc_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
2965    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
2966    let flags = shape.stroke_params[1].max(0.0) as u32;
2967    if flags & 3 != SHAPE_KIND_ARC {
2968        return None;
2969    }
2970    // Solid brushes only: gradients also derive from `rect_pos` and would
2971    // mesh in principle, but the hot retained scenes are solid and a narrow
2972    // gate keeps the byte-exactness surface small.
2973    if shape.brush_type != 0 {
2974        return None;
2975    }
2976    // A live clip is a hard `world_pos` comparison in the fragment shader.
2977    // Meshed arcs interpolate `world_pos` across different triangles than
2978    // the quad would, and one ulp of difference at the clip boundary flips
2979    // whole pixels — clipped arcs pass through untouched.
2980    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
2981        return None;
2982    }
2983    let [_, _, w, h] = shape.rect;
2984    if !(w > 0.0 && h > 0.0) {
2985        return None;
2986    }
2987    // The quad must be an axis-aligned box, tolerance zero: the mesh is
2988    // clipped to the quad's own corners, so as long as the quad IS a box its
2989    // rasterized pixel set equals the mesh clip region and the tight-AABB
2990    // tangent-point crop is reproduced exactly. (Comparing against `rect`
2991    // instead is an over-tight gate: under a non-dyadic root scale
2992    // `(x + w) * s` differs from `x * s + w * s` by an ulp and every arc
2993    // fell back to passthrough — observed on the Huawei at scale 2.75.)
2994    let [left, top, right, _] = shape.quad01;
2995    let [bl_x, bottom, br_x, br_y] = shape.quad23;
2996    let axis_aligned = shape.quad01[3] == top
2997        && bl_x == left
2998        && br_x == right
2999        && br_y == bottom
3000        && left < right
3001        && top < bottom;
3002    if !axis_aligned {
3003        return None;
3004    }
3005    let center = [shape.arc_params[0], shape.arc_params[1]];
3006    let start = shape.arc_params[2];
3007    let sweep = shape.arc_params[3];
3008    let outer = shape.stroke_params[2];
3009    let inner = shape.stroke_params[3];
3010    let finite = center[0].is_finite()
3011        && center[1].is_finite()
3012        && start.is_finite()
3013        && sweep.is_finite()
3014        && outer.is_finite()
3015        && inner.is_finite();
3016    if !finite || outer <= 0.0 || sweep <= 0.0 {
3017        return None;
3018    }
3019    Some(ArcMeshBand {
3020        center,
3021        inner,
3022        outer,
3023        start,
3024        sweep,
3025    })
3026}
3027
3028/// Kill switch for the transient rim band mesh, mirroring
3029/// [`arc_mesh_enabled`]'s property bridge: `CRANPOSE_RIM_MESH=0` (or the
3030/// `debug.cranpose.rim_mesh` property on Android) makes the fused shape
3031/// prepare skip rim detection entirely, so a device A/B needs no rebuild.
3032/// Default ON — the rim path only ever meshed a handful of huge shapes per
3033/// frame, which is the regime that WINS on the watch GPU (and the proof the
3034/// retained mesh's size gate is built on; see [`arc_mesh_enabled`]).
3035/// Read once per fused-chunk prepare (cheap), not per shape.
3036#[cfg(not(target_arch = "wasm32"))]
3037fn rim_mesh_enabled() -> bool {
3038    !matches!(std::env::var("CRANPOSE_RIM_MESH").as_deref(), Ok("0"))
3039}
3040
3041/// Fixed capacity of the per-frame transient rim mesh vertex buffer, in
3042/// vertices. The buffers are never recreated mid-frame — draws are encoded
3043/// before submit, so a reallocation would orphan already-encoded rims — and
3044/// overflow means "skip the rim, draw it as a quad", never truncation.
3045/// MEGA's arena meshes 2-3 rims per frame at ~80 vertices each (measured on
3046/// the Pixel Watch 3 via the emit log below), so ~100 rims of headroom; the
3047/// rate-limited warn below is the tell if a scene ever exceeds it.
3048#[cfg(not(target_arch = "wasm32"))]
3049const RIM_MESH_VERTEX_CAPACITY: usize = 8192;
3050/// Fixed capacity of the per-frame transient rim mesh index buffer, in
3051/// `u32` indices.
3052#[cfg(not(target_arch = "wasm32"))]
3053const RIM_MESH_INDEX_CAPACITY: usize = 32768;
3054
3055/// A dynamic shape inside a fused chunk that draws as a band mesh instead of
3056/// its full bounding quad: `shape_index` is the shape's position within the
3057/// whole fused upload (the index `vs_mesh` reads into the storage shape
3058/// array), `first_index..first_index + index_count` its span of the frame's
3059/// transient rim index buffer.
3060#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3061#[derive(Clone, Copy, Debug)]
3062struct RimDraw {
3063    shape_index: u32,
3064    first_index: u32,
3065    index_count: u32,
3066}
3067
3068/// Rate-limited overflow warning: silent skipping would hide a scene whose
3069/// rims permanently miss the fast path, while warning every frame would
3070/// flood the watch's logcat.
3071#[cfg(not(target_arch = "wasm32"))]
3072fn rim_mesh_capacity_warn() {
3073    use std::sync::atomic::{AtomicU64, Ordering};
3074    static OVERFLOWS: AtomicU64 = AtomicU64::new(0);
3075    let count = OVERFLOWS.fetch_add(1, Ordering::Relaxed);
3076    if count.is_multiple_of(512) {
3077        log::warn!(
3078            "[rim-mesh] transient buffers full; rim falls back to quad expansion \
3079             (lifetime overflows {})",
3080            count + 1,
3081        );
3082    }
3083}
3084
3085/// Band parameters of a stroked round-rect whose outline is geometrically a
3086/// circle — an arena "rim". Everything else returns `None` and rasterizes
3087/// through the ordinary quad expansion. Two callers, each behind its own
3088/// size gate: the DYNAMIC fused path via [`rim_mesh_band`], and the
3089/// retained capture builder ([`build_arc_mesh_vertices`]) via
3090/// [`retained_mesh_min_px2`] — retained slots hold big static ring circles
3091/// the dynamic path never sees.
3092///
3093/// Derivation: `ShapeData::rect` for a stroked shape is the stroke-inflated
3094/// box (geometry plus half the stroke width on each side), so the geometry
3095/// half-extent is `geom_half = (rect.w - stroke_width) / 2`. When the corner
3096/// radius equals that half-extent the outline is a circle of radius
3097/// `geom_half`, and `sdf_stroked_rounded_rect` degenerates exactly to an
3098/// annulus: its outer offset rounded-rect (`half_size` = `geom_half + hw`,
3099/// radius `geom_half + hw`) is the circle of radius `geom_half + sw/2`, its
3100/// inner offset the circle of radius `geom_half - sw/2` — centerline
3101/// `geom_half`, half-width `sw/2`. The bevel-join chamfer plane can only CUT
3102/// pixels from that annulus (`max(dist, chamfer)`), never add any, so for
3103/// every join style the shader's kept set is a subset of the annulus band.
3104/// [`emit_arc_band_mesh`] adds its own `ARC_MESH_MARGIN`, treats
3105/// `sweep >= TAU` as closed, and clips to the quad box, so containment
3106/// (mesh ⊇ every pixel with `|dist| < 0.5`, mesh ⊆ quad box) follows from
3107/// the same argument the retained arc mesh documents.
3108///
3109/// The CIRCLE gate is what keeps this correct: a false positive on a rounded
3110/// SQUARE ring would under-cover its flat spans and damage pixels, so the
3111/// radius must match `geom_half` to within 0.01 px (a deviation that small
3112/// stays inside the mesh margin's 0.5 px float-slop budget).
3113#[cfg(not(target_arch = "wasm32"))]
3114fn rim_band_geometry(shape: &ShapeData) -> Option<ArcMeshBand> {
3115    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
3116    let flags = shape.stroke_params[1].max(0.0) as u32;
3117    if flags & 3 != SHAPE_KIND_STROKE {
3118        return None;
3119    }
3120    // Solid brushes only — same narrow byte-exactness surface as
3121    // `arc_mesh_band`.
3122    if shape.brush_type != 0 {
3123        return None;
3124    }
3125    // A live clip is a hard `world_pos` comparison in the fragment shader;
3126    // meshed rims interpolate `world_pos` across different triangles and one
3127    // ulp at the clip boundary flips whole pixels.
3128    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
3129        return None;
3130    }
3131    let [x, y, w, h] = shape.rect;
3132    if !(w > 0.0 && h > 0.0) {
3133        return None;
3134    }
3135    // The quad must be an axis-aligned box, tolerance zero — the identical
3136    // check `arc_mesh_band` makes (compare quad corners against each other,
3137    // never against `rect`, which differs by an ulp under non-dyadic root
3138    // scales).
3139    let [left, top, right, _] = shape.quad01;
3140    let [bl_x, bottom, br_x, br_y] = shape.quad23;
3141    let axis_aligned = shape.quad01[3] == top
3142        && bl_x == left
3143        && br_x == right
3144        && br_y == bottom
3145        && left < right
3146        && top < bottom;
3147    if !axis_aligned {
3148        return None;
3149    }
3150    // A circle's box is square, bitwise.
3151    if w.to_bits() != h.to_bits() {
3152        return None;
3153    }
3154    // All four corner radii bitwise equal, finite and positive.
3155    let [r0, r1, r2, r3] = shape.radii;
3156    if r0.to_bits() != r1.to_bits() || r0.to_bits() != r2.to_bits() || r0.to_bits() != r3.to_bits()
3157    {
3158        return None;
3159    }
3160    if !r0.is_finite() || r0 <= 0.0 {
3161        return None;
3162    }
3163    let sw = shape.stroke_params[0];
3164    if !sw.is_finite() || sw <= 0.0 {
3165        return None;
3166    }
3167    // Finiteness before the circle gate: with every operand finite the
3168    // radius comparison below cannot see a NaN.
3169    let geom_half = (w - sw) * 0.5;
3170    let center = [x + w * 0.5, y + h * 0.5];
3171    let inner = geom_half - sw * 0.5;
3172    let outer = geom_half + sw * 0.5;
3173    let finite =
3174        center[0].is_finite() && center[1].is_finite() && inner.is_finite() && outer.is_finite();
3175    if !finite || outer <= 0.0 {
3176        return None;
3177    }
3178    // The circle gate (see the doc comment).
3179    if (r0 - geom_half).abs() > 0.01 {
3180        return None;
3181    }
3182    Some(ArcMeshBand {
3183        center,
3184        inner,
3185        outer,
3186        start: 0.0,
3187        sweep: cranpose_ui_graphics::TAU,
3188    })
3189}
3190
3191/// [`rim_band_geometry`] behind the DYNAMIC path's size gate. Big shapes
3192/// only: the win is proportional to the discarded quad area, and small
3193/// quads are cheaper than the extra pipeline switches.
3194#[cfg(not(target_arch = "wasm32"))]
3195fn rim_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
3196    let [_, _, w, h] = shape.rect;
3197    if w * h < 65536.0 {
3198        return None;
3199    }
3200    rim_band_geometry(shape)
3201}
3202
3203/// Kill switch for the opaque static leading-span cache, mirroring
3204/// [`rim_mesh_enabled`]'s property bridge: `CRANPOSE_STATIC_SPAN=0` (or the
3205/// `debug.cranpose.static_span` property on Android) makes the fused
3206/// partition never skip, capture, or blit — a device A/B needs no rebuild.
3207/// Default ON. Read once per engagement attempt (once per frame), so the
3208/// cost is one `env::var` per frame.
3209#[cfg(not(target_arch = "wasm32"))]
3210fn static_span_enabled() -> bool {
3211    !matches!(std::env::var("CRANPOSE_STATIC_SPAN").as_deref(), Ok("0"))
3212}
3213
3214/// Upper bound on how many leading shapes one span may cover. The target
3215/// span (full-screen background rect + vignette disc) is 2 shapes; the cap
3216/// only bounds the per-frame memcmp (16 x 160 B) and the prev-frame copy.
3217#[cfg(not(target_arch = "wasm32"))]
3218const STATIC_SPAN_MAX_SHAPES: usize = 16;
3219
3220/// Consecutive stable frames an EXTENSION of an already-valid span must
3221/// show before an upgrade recapture — see the hysteresis comment in
3222/// [`StaticSpanCache::engage`].
3223#[cfg(not(target_arch = "wasm32"))]
3224const STATIC_SPAN_UPGRADE_FRAMES: u32 = 30;
3225
3226/// What the engagement check decided for this frame's leading fused
3227/// partition.
3228#[cfg(not(target_arch = "wasm32"))]
3229#[derive(Clone, Copy, Debug, PartialEq)]
3230enum StaticSpanDecision {
3231    /// Not engaged: draw everything live, capture nothing.
3232    Pass,
3233    /// The cached span image is valid: skip the first `skip` shapes of the
3234    /// first batch and draw the cached full-target blit before everything.
3235    Hit { skip: usize },
3236    /// The leading `len` shapes were byte-stable across the last two frames
3237    /// but the cache does not match: draw live, then re-capture the span.
3238    Capture { len: usize, clear: wgpu::Color },
3239}
3240
3241/// Cache of the frame's leading static span — the opaque full-screen
3242/// background rect plus whatever byte-stable draws sit directly on top of it
3243/// (MEGA: the ~176k-px radial-gradient vignette disc) — as one composited
3244/// full-target texture that replaces those draws with a single blit.
3245///
3246/// Byte-exactness by construction, no tolerance anywhere:
3247///
3248/// * The engaged partition is the frame's first content (`load_op` is the
3249///   frame `Clear`, gated to alpha == 1.0), so what the live path would put
3250///   under the span is exactly the opaque clear color — and the capture
3251///   pass clears its offscreen with the SAME color before drawing the SAME
3252///   shape range through the IDENTICAL pipelines (same `ShapeData` bytes,
3253///   same gradient stop bytes, same viewport uniforms, same blend state,
3254///   same `has_gradient` pipeline variant, same surface format, identity
3255///   similarity offset 0). Deterministic pipelines on identical inputs give
3256///   identical bytes, so the cached image IS the bytes the live span render
3257///   would produce this frame.
3258/// * With an opaque clear below and SrcOver-only draws above, every texel of
3259///   that composite has alpha exactly 255: each blend step computes
3260///   `a_out = a_src + (1 - a_src) * 1.0`, whose float error is far inside
3261///   the half-level the unorm8 quantizer absorbs, and 255 reads back as
3262///   exactly 1.0 for the next step. The replacement blit then draws SrcOver
3263///   texels whose `1 - src.a` dst factor is exactly zero — the
3264///   fixed-function blender computes `1*src + 0*dst`, a replace-write — and
3265///   an unorm8 texel survives the sample/write round trip bit-exact
3266///   (`CompositeSampleMode::Nearest` is a `textureLoad`, `alpha` is 1.0).
3267///   Hence `over(rest, over(span, clear)) == over(rest, SPAN_IMAGE)`
3268///   bitwise, whatever `rest` is.
3269/// * Gradient dither cannot diverge between capture and screen: `shape.wgsl`
3270///   keys its ordered-dither matrix off `world_pos` — the device coordinate
3271///   interpolated from the `ShapeData` quad corners, deliberately not
3272///   `@builtin(position)` — so the dither phase is a pure function of the
3273///   memcmp'd bytes (see `gradient_dither` in `shape.wgsl`).
3274/// * Rim-mesh candidates ([`rim_mesh_band`] Some) end the span: the live
3275///   path may draw them through the band-mesh pipeline while the capture
3276///   pass draws plain instanced quads, and this cache refuses to depend on
3277///   that pair being byte-equal.
3278///
3279/// Validity is a memcmp: the leading K converted `ShapeData` records plus
3280/// their gradient stop payloads against the cached copy, ~160 B x few
3281/// shapes, sub-microsecond. The span length K itself comes from a two-frame
3282/// stability probe (`prev_shapes`): a capture only happens once the leading
3283/// run has already repeated byte-identically across two consecutive frames,
3284/// so churning scenes never pay the extra capture pass every frame — and
3285/// only when the span carries at least one gradient record, so scenes whose
3286/// leading static draws are all solid (cheap fill the blit cannot beat)
3287/// never engage at all.
3288#[cfg(not(target_arch = "wasm32"))]
3289#[derive(Default)]
3290struct StaticSpanCache {
3291    /// The captured span composite, same size and format as the frame
3292    /// target. Held out of the offscreen pool across frames; released back
3293    /// through the deferred-release path on resize.
3294    texture: Option<OffscreenTarget>,
3295    /// Validity key: the span's converted `ShapeData` records at capture.
3296    key_shapes: Vec<ShapeData>,
3297    /// Validity key: the span's gradient stop payload at capture.
3298    key_gradients: Vec<GradientStop>,
3299    key_width: u32,
3300    key_height: u32,
3301    /// The frame clear color the capture pass cleared with — pixels the
3302    /// span shapes do not fully cover composite against it, so a different
3303    /// clear invalidates the image even when every shape byte matches.
3304    key_clear: [u64; 4],
3305    /// The live first batch's whole-batch `has_gradient` flag at capture:
3306    /// it selects the `fs_solid` vs gradient pipeline variant for every
3307    /// shape in the batch, so the capture is only valid while the live
3308    /// batch would draw the span through the same variant.
3309    key_has_gradient: bool,
3310    /// Last frame's leading records — the two-frame stability probe that
3311    /// decides the span length at capture time.
3312    prev_shapes: Vec<ShapeData>,
3313    prev_gradients: Vec<GradientStop>,
3314    /// Consecutive hit frames whose stable leading run extended past the
3315    /// current key — the upgrade hysteresis counter.
3316    extension_stable_frames: u32,
3317    /// Set once per frame by [`GpuRenderer::render`], consumed by the first
3318    /// fused partition that carries the frame's opaque clear, so offscreen
3319    /// layer or shadow renders (transparent clears) can never engage and a
3320    /// frame engages at most once.
3321    armed: bool,
3322    hits: u64,
3323    recaptures: u64,
3324}
3325
3326#[cfg(not(target_arch = "wasm32"))]
3327impl StaticSpanCache {
3328    /// One engagement attempt per frame, at fused-partition time.
3329    /// `first_batch` is the chunk's first batch when it is a shape batch:
3330    /// (shape count, blend mode, whole-batch has_gradient). `shapes` /
3331    /// `gradients` are the partition's freshly converted scratch buffers,
3332    /// whose leading records belong to the first batch.
3333    fn engage(
3334        &mut self,
3335        load_op: wgpu::LoadOp<wgpu::Color>,
3336        first_batch: Option<(usize, BlendMode, bool)>,
3337        width: u32,
3338        height: u32,
3339        shapes: &[ShapeData],
3340        gradients: &[GradientStop],
3341    ) -> StaticSpanDecision {
3342        if !self.armed || !static_span_enabled() {
3343            return StaticSpanDecision::Pass;
3344        }
3345        let wgpu::LoadOp::Clear(clear) = load_op else {
3346            return StaticSpanDecision::Pass;
3347        };
3348        // The frame's leading clear is the only opaque one a frame stream
3349        // carries (layer and shadow sources clear transparent); engagement
3350        // happens here or not at all this frame.
3351        if clear.a != 1.0 {
3352            return StaticSpanDecision::Pass;
3353        }
3354        self.armed = false;
3355        let Some((batch_len, blend_mode, has_gradient)) = first_batch else {
3356            self.forget_observation();
3357            return StaticSpanDecision::Pass;
3358        };
3359        // SrcOver only: the alpha == 255 argument above is an SrcOver
3360        // property.
3361        if blend_mode != BlendMode::SrcOver || batch_len == 0 {
3362            self.forget_observation();
3363            return StaticSpanDecision::Pass;
3364        }
3365        let leading = &shapes[..batch_len.min(STATIC_SPAN_MAX_SHAPES).min(shapes.len())];
3366        if leading.is_empty() {
3367            self.forget_observation();
3368            return StaticSpanDecision::Pass;
3369        }
3370        if !static_span_fullscreen_opaque(&leading[0], width, height) {
3371            self.forget_observation();
3372            return StaticSpanDecision::Pass;
3373        }
3374        // The span ends at the first shape the capture pass could not
3375        // reproduce through the plain instanced arm (rim-mesh candidates).
3376        let mut eligible = 1;
3377        while eligible < leading.len() && rim_mesh_band(&leading[eligible]).is_none() {
3378            eligible += 1;
3379        }
3380        let leading = &leading[..eligible];
3381        let clear_key = [
3382            clear.r.to_bits(),
3383            clear.g.to_bits(),
3384            clear.b.to_bits(),
3385            clear.a.to_bits(),
3386        ];
3387
3388        let key_len = self.key_shapes.len();
3389        let valid = self.texture.is_some()
3390            && key_len > 0
3391            && key_len <= leading.len()
3392            && self.key_width == width
3393            && self.key_height == height
3394            && self.key_clear == clear_key
3395            && self.key_has_gradient == has_gradient
3396            && span_records_equal(
3397                &self.key_shapes,
3398                &leading[..key_len],
3399                &self.key_gradients,
3400                gradients,
3401            );
3402
3403        // Stability probe, shared by miss-capture and hit-upgrade: the
3404        // longest leading run whose record AND gradient bytes repeat from
3405        // last frame.
3406        let mut stable = 0;
3407        while stable < leading.len()
3408            && stable < self.prev_shapes.len()
3409            && span_records_equal(
3410                &self.prev_shapes[stable..stable + 1],
3411                &leading[stable..stable + 1],
3412                &self.prev_gradients,
3413                gradients,
3414            )
3415        {
3416            stable += 1;
3417        }
3418        self.remember_observation(leading, gradients);
3419
3420        if valid {
3421            // Upgrade hysteresis: a valid span may EXTEND (a partial
3422            // invalidation — say a vignette-only palette change — shrank an
3423            // earlier capture, and the tail has stabilized again) only after
3424            // the extension repeats for a full window of consecutive
3425            // frames. Without it, a leading shape animating with a period
3426            // of a few frames would alternate upgrade-capture and
3427            // shrink-capture forever — capture-churn instead of caching.
3428            // The initial capture below takes no window because the whole
3429            // span stabilizing at once is the cold-start common case. No
3430            // gradient gate here: the stable prefix contains the key, and
3431            // every stored key carries a gradient record.
3432            if stable > key_len {
3433                self.extension_stable_frames += 1;
3434                if self.extension_stable_frames >= STATIC_SPAN_UPGRADE_FRAMES {
3435                    self.extension_stable_frames = 0;
3436                    return StaticSpanDecision::Capture { len: stable, clear };
3437                }
3438            } else {
3439                self.extension_stable_frames = 0;
3440            }
3441            self.hits += 1;
3442            if self.hits.is_multiple_of(600) {
3443                log::debug!(
3444                    "[static-span] {} hits / {} recaptures lifetime (span {} shapes, {}x{})",
3445                    self.hits,
3446                    self.recaptures,
3447                    key_len,
3448                    width,
3449                    height,
3450                );
3451            }
3452            return StaticSpanDecision::Hit { skip: key_len };
3453        }
3454
3455        self.extension_stable_frames = 0;
3456        // Engagement economics: a candidate span with no gradient records
3457        // would replace the cheapest fill there is (solid quads) with a
3458        // same-size texture blit — a wash at best on a mobile GPU, plus a
3459        // held full-target texture and a capture pass. The fill this stage
3460        // chases is the gradient+dither span, so a capture must carry at
3461        // least one gradient record. This also keeps solid-background-only
3462        // frames (most non-game screens) from ever paying an offscreen
3463        // acquire.
3464        if stable == 0 || span_gradient_len(&leading[..stable]) == 0 {
3465            return StaticSpanDecision::Pass;
3466        }
3467        StaticSpanDecision::Capture { len: stable, clear }
3468    }
3469
3470    /// Stores this frame's leading run for next frame's stability probe.
3471    fn remember_observation(&mut self, leading: &[ShapeData], gradients: &[GradientStop]) {
3472        self.prev_shapes.clear();
3473        self.prev_shapes.extend_from_slice(leading);
3474        let stop_len = span_gradient_len(leading);
3475        self.prev_gradients.clear();
3476        self.prev_gradients
3477            .extend_from_slice(&gradients[..stop_len]);
3478    }
3479
3480    fn forget_observation(&mut self) {
3481        self.prev_shapes.clear();
3482        self.prev_gradients.clear();
3483        self.extension_stable_frames = 0;
3484    }
3485
3486    /// Adopts a freshly captured span as the validity key. The caller has
3487    /// already encoded the capture pass into `texture`.
3488    #[allow(clippy::too_many_arguments)]
3489    fn store_key(
3490        &mut self,
3491        span: &[ShapeData],
3492        gradients: &[GradientStop],
3493        width: u32,
3494        height: u32,
3495        clear: wgpu::Color,
3496        has_gradient: bool,
3497    ) {
3498        self.key_shapes.clear();
3499        self.key_shapes.extend_from_slice(span);
3500        let stop_len = span_gradient_len(span);
3501        self.key_gradients.clear();
3502        self.key_gradients.extend_from_slice(&gradients[..stop_len]);
3503        self.key_width = width;
3504        self.key_height = height;
3505        self.key_clear = [
3506            clear.r.to_bits(),
3507            clear.g.to_bits(),
3508            clear.b.to_bits(),
3509            clear.a.to_bits(),
3510        ];
3511        self.key_has_gradient = has_gradient;
3512        self.recaptures += 1;
3513        if self.recaptures.is_multiple_of(64) || self.recaptures == 1 {
3514            log::debug!(
3515                "[static-span] recapture #{} (span {} shapes, {} stops, {}x{}; {} hits lifetime)",
3516                self.recaptures,
3517                self.key_shapes.len(),
3518                self.key_gradients.len(),
3519                width,
3520                height,
3521                self.hits,
3522            );
3523        }
3524    }
3525}
3526
3527/// Total gradient stops a leading span consumes. The span is a prefix of
3528/// the fused upload, so its stop payload is exactly the leading
3529/// `sum(gradient_count)` entries of the scratch gradient buffer.
3530#[cfg(not(target_arch = "wasm32"))]
3531fn span_gradient_len(span: &[ShapeData]) -> usize {
3532    span.iter().map(|shape| shape.gradient_count as usize).sum()
3533}
3534
3535/// Byte equality of two span record runs INCLUDING their gradient stop
3536/// payloads. Each record's stops live at
3537/// `gradient_start..gradient_start + gradient_count` in its frame's leading
3538/// gradient buffer; `gradient_start`/`gradient_count` are part of the
3539/// memcmp'd record bytes, so matching records address matching stop ranges
3540/// in both buffers.
3541#[cfg(not(target_arch = "wasm32"))]
3542fn span_records_equal(
3543    expected: &[ShapeData],
3544    actual: &[ShapeData],
3545    expected_gradients: &[GradientStop],
3546    actual_gradients: &[GradientStop],
3547) -> bool {
3548    if bytemuck::cast_slice::<ShapeData, u8>(expected)
3549        != bytemuck::cast_slice::<ShapeData, u8>(actual)
3550    {
3551        return false;
3552    }
3553    for shape in expected {
3554        let start = shape.gradient_start as usize;
3555        let end = start + shape.gradient_count as usize;
3556        if end > expected_gradients.len() || end > actual_gradients.len() {
3557            return false;
3558        }
3559        if bytemuck::cast_slice::<GradientStop, u8>(&expected_gradients[start..end])
3560            != bytemuck::cast_slice::<GradientStop, u8>(&actual_gradients[start..end])
3561        {
3562            return false;
3563        }
3564    }
3565    true
3566}
3567
3568/// Whether a converted record is the full-screen opaque base the span
3569/// mechanism keys on: a plain solid fill (no stroke, no arc, no gradient,
3570/// no clip, no corner rounding) whose axis-aligned quad covers the whole
3571/// `width` x `height` target with alpha exactly 1.0. Soundness does not
3572/// strictly need full coverage — the opaque clear already makes the
3573/// composite alpha 255 — but requiring the measured scene shape keeps the
3574/// cache from engaging on frames whose leading draw is not the static
3575/// background this stage was built for.
3576#[cfg(not(target_arch = "wasm32"))]
3577fn static_span_fullscreen_opaque(shape: &ShapeData, width: u32, height: u32) -> bool {
3578    if shape.brush_type != 0 || shape.gradient_count != 0 {
3579        return false;
3580    }
3581    if shape.color[3] != 1.0 {
3582        return false;
3583    }
3584    if shape.clip_rect != [0.0; 4] || shape.stroke_params != [0.0; 4] || shape.radii != [0.0; 4] {
3585        return false;
3586    }
3587    // Same corner layout as `rim_mesh_band`: quad01 = TL.xy, TR.xy;
3588    // quad23 = BL.xy, BR.xy.
3589    let [left, top, right, top_right_y] = shape.quad01;
3590    let [bl_x, bottom, br_x, br_y] = shape.quad23;
3591    let axis_aligned = top_right_y == top
3592        && bl_x == left
3593        && br_x == right
3594        && br_y == bottom
3595        && left < right
3596        && top < bottom;
3597    axis_aligned && left <= 0.0 && top <= 0.0 && right >= width as f32 && bottom >= height as f32
3598}
3599
3600/// One Sutherland–Hodgman pass against an axis-aligned half-plane.
3601///
3602/// Two properties the byte-exactness bar depends on:
3603/// * the clipped coordinate is set to `bound` EXACTLY rather than recomputed
3604///   through `p + t * (q - p)`, so every clipped polygon's boundary lies
3605///   bitwise on the clip line;
3606/// * the intersection is computed on the lexicographically ordered endpoint
3607///   pair, so the shared radial edge of two adjacent trapezoids — traversed
3608///   in opposite directions — clips to bitwise-identical points, keeping the
3609///   strip watertight (no pixel shaded twice or missed along the seam).
3610#[cfg(not(target_arch = "wasm32"))]
3611fn clip_polygon_axis(
3612    input: &[[f32; 2]],
3613    axis: usize,
3614    bound: f32,
3615    keep_at_most: bool,
3616    output: &mut Vec<[f32; 2]>,
3617) {
3618    output.clear();
3619    let inside = |p: [f32; 2]| {
3620        if keep_at_most {
3621            p[axis] <= bound
3622        } else {
3623            p[axis] >= bound
3624        }
3625    };
3626    let intersect = |a: [f32; 2], b: [f32; 2]| {
3627        let (p, q) = if (b[0], b[1]) < (a[0], a[1]) {
3628            (b, a)
3629        } else {
3630            (a, b)
3631        };
3632        let t = (bound - p[axis]) / (q[axis] - p[axis]);
3633        let mut point = [0.0f32; 2];
3634        point[axis] = bound;
3635        point[1 - axis] = p[1 - axis] + t * (q[1 - axis] - p[1 - axis]);
3636        point
3637    };
3638    for (index, &current) in input.iter().enumerate() {
3639        let previous = input[(index + input.len() - 1) % input.len()];
3640        match (inside(previous), inside(current)) {
3641            (true, true) => output.push(current),
3642            (true, false) => output.push(intersect(previous, current)),
3643            (false, true) => {
3644                output.push(intersect(previous, current));
3645                output.push(current);
3646            }
3647            (false, false) => {}
3648        }
3649    }
3650}
3651
3652/// Emits the conservative trapezoid-strip mesh for one qualifying arc band.
3653///
3654/// CONTAINMENT INVARIANT (the byte-exactness bar): the union of emitted
3655/// triangles is a superset of `{ p in the capture quad's box :
3656/// sdf_arc_band(p) <= 0.5 }` — every pixel the fragment shader would keep.
3657/// Over-inclusion is free (the SDF discards those pixels identically to
3658/// today's quad); only under-inclusion can diverge, and
3659/// `arc_mesh_contains_every_band_pixel` checks it never happens.
3660///
3661/// Geometry: outer vertices ride at `Ro / cos(step / 2)` so every chord is
3662/// tangent-outside the dilated outer circle; inner vertices ride at the
3663/// dilated inner radius, whose chords lie inside the hole. Cap coverage is
3664/// bounded by the round-cap disc about the band endpoint (butt/square caps
3665/// only cut that disc with planes — see `sdf_arc_band`), so padding the
3666/// angular range by the disc's angular half-extent contains every cap. Each
3667/// trapezoid is clipped to the quad box and fan-triangulated IN INDEX SPACE:
3668/// a trapezoid the clipper left untouched shares its two boundary vertices
3669/// with each neighbor through the index list (closed rings wrap the sharing
3670/// modulo the boundary count), so the strip is watertight by construction —
3671/// the seam edge is one vertex pair, not two bitwise-equal copies — and the
3672/// per-arc vertex count collapses from three-per-triangle to the unique
3673/// boundary vertices. Clipped trapezoids cannot share boundary vertices (the
3674/// clipper rewrote them), so their fan vertices are appended PRIVATELY after
3675/// the shared block and indexed directly; seams against neighbors still hold
3676/// because a boundary edge either survives the clip on both sides
3677/// bitwise-identically (same input edge, same planes, same float ops — see
3678/// `clip_polygon_axis`) or is cut on both sides identically. Triangles are
3679/// emitted in exact segment order either way, so the indexed mesh's
3680/// primitive stream is triangle-for-triangle the one the non-indexed
3681/// emitter produced.
3682///
3683/// Returns the emitted segment count, or `None` when the mesh came out empty
3684/// — the caller emits the passthrough quad instead (never risk
3685/// under-coverage).
3686#[cfg(not(target_arch = "wasm32"))]
3687fn emit_arc_band_mesh(
3688    shape: &ShapeData,
3689    shape_idx: u32,
3690    band: &ArcMeshBand,
3691    vertices: &mut Vec<MeshVertex>,
3692    indices: &mut Vec<u32>,
3693) -> Option<usize> {
3694    let [cx, cy] = band.center;
3695    let ra = (band.outer + band.inner) * 0.5;
3696    let rb = ((band.outer - band.inner) * 0.5).max(0.0);
3697    let rb_m = rb + ARC_MESH_MARGIN;
3698    let ro = ra + rb_m;
3699    let ri = (ra - rb_m).max(0.0);
3700    let tau = cranpose_ui_graphics::TAU;
3701
3702    let (range_start, range) = if band.sweep >= tau {
3703        (0.0, tau)
3704    } else {
3705        let pad = if rb_m < ra {
3706            (rb_m / ra).asin() + 0.05
3707        } else {
3708            // The cap disc wraps the center; such shapes are tiny, take the
3709            // whole circle.
3710            std::f32::consts::PI
3711        };
3712        let padded = band.sweep + pad + pad;
3713        if padded >= tau {
3714            (0.0, tau)
3715        } else {
3716            (band.start - pad, padded)
3717        }
3718    };
3719    let closed = range >= tau;
3720
3721    let dtheta = (2.0 * (ro / (ro + ARC_MESH_OVERSHOOT)).acos()).clamp(tau / 64.0, tau / 6.0);
3722    let segments =
3723        ((range / dtheta).ceil() as usize).clamp(ARC_MESH_MIN_SEGMENTS, ARC_MESH_MAX_SEGMENTS);
3724    let step = range / segments as f32;
3725    let rc = ro / (step * 0.5).cos();
3726
3727    // Boundary vertices are computed once and shared by both adjacent
3728    // trapezoids: bitwise-equal edge endpoints are what let the rasterizer's
3729    // fill rule shade each seam exactly once.
3730    let boundary_count = if closed { segments } else { segments + 1 };
3731    let mut boundaries = Vec::with_capacity(boundary_count);
3732    for j in 0..boundary_count {
3733        let (sin, cos) = (range_start + step * j as f32).sin_cos();
3734        boundaries.push((
3735            [cx + cos * ri, cy + sin * ri],
3736            [cx + cos * rc, cy + sin * rc],
3737        ));
3738    }
3739
3740    let quad_min = [shape.quad01[0], shape.quad01[1]];
3741    let quad_max = [shape.quad23[2], shape.quad23[3]];
3742
3743    /// One trapezoid's clip outcome (see the function docs): `Shared` means
3744    /// the clip output is bitwise the input quad, so its corners index the
3745    /// shared boundary block; `Fan` carries the clipped polygon for private
3746    /// fan triangulation; `Empty` was clipped away entirely.
3747    enum SegmentGeometry {
3748        Shared,
3749        Fan(Vec<[f32; 2]>),
3750        Empty,
3751    }
3752
3753    // Phase 1: clip every trapezoid and classify it.
3754    let mut polygon: Vec<[f32; 2]> = Vec::with_capacity(8);
3755    let mut scratch: Vec<[f32; 2]> = Vec::with_capacity(8);
3756    let mut segment_geometry = Vec::with_capacity(segments);
3757    let mut boundary_used = vec![false; boundary_count];
3758    for j in 0..segments {
3759        let jb = (j + 1) % boundary_count;
3760        let (inner_a, outer_a) = boundaries[j];
3761        let (inner_b, outer_b) = boundaries[jb];
3762        polygon.clear();
3763        polygon.extend_from_slice(&[inner_a, outer_a, outer_b, inner_b]);
3764        clip_polygon_axis(&polygon, 0, quad_min[0], false, &mut scratch);
3765        clip_polygon_axis(&scratch, 0, quad_max[0], true, &mut polygon);
3766        clip_polygon_axis(&polygon, 1, quad_min[1], false, &mut scratch);
3767        clip_polygon_axis(&scratch, 1, quad_max[1], true, &mut polygon);
3768        // Collapse exact duplicates (an `Ri == 0` pie wedge duplicates the
3769        // center) before fanning.
3770        scratch.clear();
3771        for &point in polygon.iter() {
3772            if scratch.last() != Some(&point) {
3773                scratch.push(point);
3774            }
3775        }
3776        while scratch.len() > 1 && scratch.first() == scratch.last() {
3777            scratch.pop();
3778        }
3779        if scratch.len() < 3 {
3780            segment_geometry.push(SegmentGeometry::Empty);
3781        } else if scratch[..] == [inner_a, outer_a, outer_b, inner_b] {
3782            boundary_used[j] = true;
3783            boundary_used[jb] = true;
3784            segment_geometry.push(SegmentGeometry::Shared);
3785        } else {
3786            segment_geometry.push(SegmentGeometry::Fan(scratch.clone()));
3787        }
3788    }
3789
3790    let push_vertex = |vertices: &mut Vec<MeshVertex>, position: [f32; 2]| -> u32 {
3791        let index = vertices.len() as u32;
3792        vertices.push(MeshVertex {
3793            position,
3794            uv: [
3795                (position[0] - shape.rect[0]) / shape.rect[2],
3796                (position[1] - shape.rect[1]) / shape.rect[3],
3797            ],
3798            shape_idx,
3799        });
3800        index
3801    };
3802
3803    // Shared block: every boundary referenced by a surviving whole trapezoid
3804    // gets its (inner, outer) vertex pair exactly once, in boundary order.
3805    let mut boundary_vertex = vec![[0u32; 2]; boundary_count];
3806    for (j, used) in boundary_used.iter().enumerate() {
3807        if *used {
3808            let (inner, outer) = boundaries[j];
3809            boundary_vertex[j] = [push_vertex(vertices, inner), push_vertex(vertices, outer)];
3810        }
3811    }
3812
3813    // Phase 2: indices in exact segment order — the primitive stream matches
3814    // the non-indexed emitter triangle for triangle.
3815    let start_len = indices.len();
3816    for (j, geometry) in segment_geometry.iter().enumerate() {
3817        match geometry {
3818            SegmentGeometry::Empty => {}
3819            SegmentGeometry::Shared => {
3820                let jb = (j + 1) % boundary_count;
3821                let [in_a, out_a] = boundary_vertex[j];
3822                let [in_b, out_b] = boundary_vertex[jb];
3823                // The fan the non-indexed emitter produced for an untouched
3824                // trapezoid: (in_a, out_a, out_b)(in_a, out_b, in_b) — the
3825                // same quad diagonal.
3826                indices.extend_from_slice(&[in_a, out_a, out_b, in_a, out_b, in_b]);
3827            }
3828            SegmentGeometry::Fan(points) => {
3829                let base = vertices.len() as u32;
3830                for &point in points {
3831                    push_vertex(vertices, point);
3832                }
3833                for i in 1..points.len() as u32 - 1 {
3834                    indices.extend_from_slice(&[base, base + i, base + i + 1]);
3835                }
3836            }
3837        }
3838    }
3839    if indices.len() == start_len {
3840        return None;
3841    }
3842    Some(segments)
3843}
3844
3845/// Unsigned shoelace area of an emitted indexed triangle list, for
3846/// telemetry.
3847#[cfg(not(target_arch = "wasm32"))]
3848fn triangles_shoelace_area(vertices: &[MeshVertex], indices: &[u32]) -> f64 {
3849    indices
3850        .as_chunks::<3>()
3851        .0
3852        .iter()
3853        .map(|tri| {
3854            let [a, b, c] = [
3855                vertices[tri[0] as usize].position,
3856                vertices[tri[1] as usize].position,
3857                vertices[tri[2] as usize].position,
3858            ];
3859            let cross = (b[0] as f64 - a[0] as f64) * (c[1] as f64 - a[1] as f64)
3860                - (b[1] as f64 - a[1] as f64) * (c[0] as f64 - a[0] as f64);
3861            cross.abs() * 0.5
3862        })
3863        .sum()
3864}
3865
3866/// Unsigned area of the two triangles the quad-expansion path would rasterize for
3867/// this shape, for telemetry.
3868#[cfg(not(target_arch = "wasm32"))]
3869fn quad_shoelace_area(shape: &ShapeData) -> f64 {
3870    let corners = [
3871        [shape.quad01[0] as f64, shape.quad01[1] as f64],
3872        [shape.quad01[2] as f64, shape.quad01[3] as f64],
3873        [shape.quad23[0] as f64, shape.quad23[1] as f64],
3874        [shape.quad23[2] as f64, shape.quad23[3] as f64],
3875    ];
3876    let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
3877        ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
3878    };
3879    tri(corners[0], corners[1], corners[2]) + tri(corners[2], corners[1], corners[3])
3880}
3881
3882/// `CRANPOSE_FILL_DIAG` (`debug.cranpose.fill_diag` on Android): per-frame
3883/// CPU-side accounting of the fill area the renderer submits, in device px².
3884/// Off by default; any set value except "0" enables. Read once per process,
3885/// so a disabled hot path pays one static load and a branch.
3886#[cfg(not(target_arch = "wasm32"))]
3887pub(crate) fn fill_area_diag_enabled() -> bool {
3888    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3889    *ENABLED.get_or_init(
3890        || matches!(std::env::var("CRANPOSE_FILL_DIAG").as_deref(), Ok(value) if value != "0"),
3891    )
3892}
3893
3894/// Rendered frames aggregated into one `[fill-diag]` report line.
3895#[cfg(not(target_arch = "wasm32"))]
3896const FILL_DIAG_WINDOW_FRAMES: u32 = 120;
3897
3898#[cfg(not(target_arch = "wasm32"))]
3899const FILL_DIAG_BUCKETS: usize = 9;
3900
3901/// Opacity class of a shape's fill for the `[fill-truth]` histogram, decided
3902/// from the CONVERTED record: a solid brush with vertex alpha exactly 1.0 is
3903/// opaque, any other solid is translucent, and every gradient counts as
3904/// non-solid (its stops can each carry their own alpha). Retained shapes are
3905/// classified from their capture-time colors — a later recolor patch through
3906/// the slot's paint buffer is not re-classified.
3907#[cfg(not(target_arch = "wasm32"))]
3908#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3909enum FillOpacityClass {
3910    Opaque = 0,
3911    Translucent = 1,
3912    NonSolid = 2,
3913}
3914
3915#[cfg(not(target_arch = "wasm32"))]
3916fn fill_opacity_class(shape: &ShapeData) -> FillOpacityClass {
3917    if shape.brush_type != 0 {
3918        FillOpacityClass::NonSolid
3919    } else if shape.color[3] == 1.0 {
3920        FillOpacityClass::Opaque
3921    } else {
3922        FillOpacityClass::Translucent
3923    }
3924}
3925
3926/// The fill-diag bucket of a batched shape quad, decoded from the packed
3927/// flags the way the fragment shader decodes them (`u32(max(x, 0.0)) & 3`).
3928/// Fills keep real corner radii in `radii` (arcs reuse the field for trig,
3929/// but they take the arc arm first).
3930#[cfg(not(target_arch = "wasm32"))]
3931fn fill_diag_bucket(shape: &ShapeData) -> usize {
3932    match shape.stroke_params[1].max(0.0) as u32 & 3 {
3933        SHAPE_KIND_ARC => FillAreaDiag::ARC,
3934        SHAPE_KIND_STROKE => FillAreaDiag::RRECT_STROKE,
3935        _ if shape.radii.iter().any(|radius| *radius > 0.0) => FillAreaDiag::RRECT_FILL,
3936        _ => FillAreaDiag::RECT,
3937    }
3938}
3939
3940#[cfg(not(target_arch = "wasm32"))]
3941fn fill_diag_bucket_name(bucket: usize) -> &'static str {
3942    match bucket {
3943        FillAreaDiag::ARC => "arc",
3944        FillAreaDiag::RRECT_STROKE => "rrect-stroke",
3945        FillAreaDiag::RRECT_FILL => "rrect-fill",
3946        FillAreaDiag::RECT => "rect",
3947        FillAreaDiag::MESH => "mesh",
3948        FillAreaDiag::RETAINED => "retained",
3949        FillAreaDiag::IMAGE_GLYPH => "img+glyph",
3950        FillAreaDiag::EFFECT_COMPOSITE => "effect-comp",
3951        FillAreaDiag::OFFSCREEN_SOURCE => "offscr-src",
3952        _ => "?",
3953    }
3954}
3955
3956/// Analytic covered area of a shape in device px² — the pixels the SDF will
3957/// actually keep, as opposed to the bounding quad it is rasterized with —
3958/// decoded from the same converted `ShapeData` fields the classifier and the
3959/// band-mesh builders read. Deliberately closed-form per class:
3960///
3961/// * arc / annular sector: `sweep · r_mid · thickness` plus the endcap area
3962///   (two half-discs for round caps; square caps rasterize the same pixel
3963///   measure — `sdf_arc_band` cuts the endpoint disc at `plane − rb`, which
3964///   removes nothing but the tangent point; butt caps add nothing; a closed
3965///   ring has no caps).
3966/// * stroked round-rect: centerline perimeter × stroke width — exact while
3967///   every corner radius ≥ half the stroke width (the offset-band identity);
3968///   miter corner spurs at sharp corners are not modeled.
3969/// * round-rect / circle fill: `w·h − (1 − π/4)·Σ rᵢ²`, radii clamped to the
3970///   half-extent (a circle degenerates to exactly `π r²`).
3971/// * plain rect: the submitted quad IS the covered set — priced at the quad
3972///   area by the caller, this function returns `w·h` (equal under any
3973///   similarity).
3974///
3975/// Clips and viewport scissors are not modeled, same as the quad accounting.
3976#[cfg(not(target_arch = "wasm32"))]
3977fn analytic_covered_area(shape: &ShapeData) -> f64 {
3978    let flags = shape.stroke_params[1].max(0.0) as u32;
3979    match flags & 3 {
3980        SHAPE_KIND_ARC => {
3981            let outer = f64::from(shape.stroke_params[2]).max(0.0);
3982            let inner = f64::from(shape.stroke_params[3]).clamp(0.0, outer);
3983            let tau = f64::from(cranpose_ui_graphics::TAU);
3984            let sweep = f64::from(shape.arc_params[3]).clamp(0.0, tau);
3985            let thickness = outer - inner;
3986            let band = sweep * 0.5 * (outer + inner) * thickness;
3987            let caps = if sweep >= tau {
3988                0.0
3989            } else {
3990                match (flags >> 2) & 3 {
3991                    // Round and square: two half-discs of radius t/2 — the
3992                    // shader's square cap keeps the endpoint disc's measure
3993                    // (see the doc comment).
3994                    1 | 2 => std::f64::consts::PI * (thickness * 0.5) * (thickness * 0.5),
3995                    _ => 0.0,
3996                }
3997            };
3998            band + caps
3999        }
4000        SHAPE_KIND_STROKE => {
4001            let stroke_width = f64::from(shape.stroke_params[0]).max(0.0);
4002            // `rect` for a stroked shape is the stroke-inflated box.
4003            let geom_w = (f64::from(shape.rect[2]) - stroke_width).max(0.0);
4004            let geom_h = (f64::from(shape.rect[3]) - stroke_width).max(0.0);
4005            let max_radius = geom_w.min(geom_h) * 0.5;
4006            let radii_sum: f64 = shape
4007                .radii
4008                .iter()
4009                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4010                .sum();
4011            let perimeter =
4012                2.0 * (geom_w + geom_h) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum;
4013            perimeter.max(0.0) * stroke_width
4014        }
4015        _ => {
4016            let width = f64::from(shape.rect[2]).max(0.0);
4017            let height = f64::from(shape.rect[3]).max(0.0);
4018            let max_radius = width.min(height) * 0.5;
4019            let radii_sq: f64 = shape
4020                .radii
4021                .iter()
4022                .map(|radius| {
4023                    let radius = f64::from(*radius).clamp(0.0, max_radius);
4024                    radius * radius
4025                })
4026                .sum();
4027            width * height - (1.0 - std::f64::consts::FRAC_PI_4) * radii_sq
4028        }
4029    }
4030}
4031
4032/// Antialiasing allowance added on top of [`analytic_covered_area`]: the SDF
4033/// feathers over roughly one pixel of boundary, so ~1 px × the covered set's
4034/// perimeter approximates the partially-lit fringe. Plain rects get none
4035/// (their quad is exact); a stroked shape has two boundary curves, whose
4036/// perimeters sum to twice the centerline perimeter for a convex outline.
4037#[cfg(not(target_arch = "wasm32"))]
4038fn aa_perimeter_allowance(shape: &ShapeData) -> f64 {
4039    let flags = shape.stroke_params[1].max(0.0) as u32;
4040    match flags & 3 {
4041        SHAPE_KIND_ARC => {
4042            let outer = f64::from(shape.stroke_params[2]).max(0.0);
4043            let inner = f64::from(shape.stroke_params[3]).clamp(0.0, outer);
4044            let tau = f64::from(cranpose_ui_graphics::TAU);
4045            let sweep = f64::from(shape.arc_params[3]).clamp(0.0, tau);
4046            let ends = if sweep >= tau {
4047                0.0
4048            } else {
4049                2.0 * (outer - inner)
4050            };
4051            sweep * (outer + inner) + ends
4052        }
4053        SHAPE_KIND_STROKE => {
4054            let stroke_width = f64::from(shape.stroke_params[0]).max(0.0);
4055            let geom_w = (f64::from(shape.rect[2]) - stroke_width).max(0.0);
4056            let geom_h = (f64::from(shape.rect[3]) - stroke_width).max(0.0);
4057            let max_radius = geom_w.min(geom_h) * 0.5;
4058            let radii_sum: f64 = shape
4059                .radii
4060                .iter()
4061                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4062                .sum();
4063            let perimeter =
4064                2.0 * (geom_w + geom_h) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum;
4065            2.0 * perimeter.max(0.0)
4066        }
4067        _ if shape.radii.iter().any(|radius| *radius > 0.0) => {
4068            let width = f64::from(shape.rect[2]).max(0.0);
4069            let height = f64::from(shape.rect[3]).max(0.0);
4070            let max_radius = width.min(height) * 0.5;
4071            let radii_sum: f64 = shape
4072                .radii
4073                .iter()
4074                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
4075                .sum();
4076            (2.0 * (width + height) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum).max(0.0)
4077        }
4078        _ => 0.0,
4079    }
4080}
4081
4082/// Analytic lit area: covered pixels plus the AA fringe allowance. Callers
4083/// clamp it to the shape's submitted area — the shader cannot light pixels
4084/// its quad never rasterizes.
4085#[cfg(not(target_arch = "wasm32"))]
4086fn analytic_lit_area(shape: &ShapeData) -> f64 {
4087    analytic_covered_area(shape) + aa_perimeter_allowance(shape)
4088}
4089
4090/// Device-space AABB of a shape's submitted quad: min x, min y, max x, max y.
4091#[cfg(not(target_arch = "wasm32"))]
4092fn quad_aabb(shape: &ShapeData) -> [f64; 4] {
4093    let xs = [
4094        f64::from(shape.quad01[0]),
4095        f64::from(shape.quad01[2]),
4096        f64::from(shape.quad23[0]),
4097        f64::from(shape.quad23[2]),
4098    ];
4099    let ys = [
4100        f64::from(shape.quad01[1]),
4101        f64::from(shape.quad01[3]),
4102        f64::from(shape.quad23[1]),
4103        f64::from(shape.quad23[3]),
4104    ];
4105    let fold = |values: [f64; 4], pick: fn(f64, f64) -> f64| {
4106        values.into_iter().reduce(pick).unwrap_or(0.0)
4107    };
4108    [
4109        fold(xs, f64::min),
4110        fold(ys, f64::min),
4111        fold(xs, f64::max),
4112        fold(ys, f64::max),
4113    ]
4114}
4115
4116/// Vertical strips of the midpoint rule used by
4117/// [`area_outside_inscribed_circle`]. 32 strips keep the chord error under
4118/// ~0.5% for a full-viewport quad — plenty for a corner-waste ratio.
4119#[cfg(not(target_arch = "wasm32"))]
4120const CORNER_FILL_STRIPS: usize = 32;
4121
4122/// Area of an axis-aligned box lying inside the viewport but OUTSIDE the
4123/// inscribed circle (diameter `min(w, h)`, centered) — the pixels a round
4124/// watch display physically cannot show. Approximations, deliberate: the
4125/// submitted quad is replaced by its AABB (exact for the axis-aligned quads
4126/// that dominate full-frame scenes), and the circle chord is integrated with
4127/// [`CORNER_FILL_STRIPS`] midpoint strips instead of closed-form segments.
4128/// On a non-square viewport the side bands beyond the circle count as
4129/// outside too, which is the honest answer for a round display.
4130#[cfg(not(target_arch = "wasm32"))]
4131fn area_outside_inscribed_circle(aabb: [f64; 4], viewport: (u32, u32)) -> f64 {
4132    let viewport_w = f64::from(viewport.0);
4133    let viewport_h = f64::from(viewport.1);
4134    if viewport_w <= 0.0 || viewport_h <= 0.0 {
4135        return 0.0;
4136    }
4137    let x0 = aabb[0].max(0.0);
4138    let y0 = aabb[1].max(0.0);
4139    let x1 = aabb[2].min(viewport_w);
4140    let y1 = aabb[3].min(viewport_h);
4141    if x1 <= x0 || y1 <= y0 {
4142        return 0.0;
4143    }
4144    let center_x = viewport_w * 0.5;
4145    let center_y = viewport_h * 0.5;
4146    let radius = viewport_w.min(viewport_h) * 0.5;
4147    let strip = (x1 - x0) / CORNER_FILL_STRIPS as f64;
4148    let mut outside = 0.0;
4149    for index in 0..CORNER_FILL_STRIPS {
4150        let x = x0 + (index as f64 + 0.5) * strip;
4151        let dx = x - center_x;
4152        let chord_sq = radius * radius - dx * dx;
4153        let inside = if chord_sq > 0.0 {
4154            let half_chord = chord_sq.sqrt();
4155            (y1.min(center_y + half_chord) - y0.max(center_y - half_chord)).max(0.0)
4156        } else {
4157            0.0
4158        };
4159        outside += ((y1 - y0) - inside) * strip;
4160    }
4161    outside
4162}
4163
4164/// Per-shape fill-diag record a replay slot retains at capture, so retained
4165/// draws can be priced per range without re-deriving anything per frame.
4166/// Only built while `CRANPOSE_FILL_DIAG` is on.
4167#[cfg(not(target_arch = "wasm32"))]
4168#[derive(Clone, Copy, Debug)]
4169struct FillDiagShapeRecord {
4170    /// Capture-space area actually submitted for this shape: band-mesh
4171    /// triangle area when the slot replays THIS shape's band, bounding-quad
4172    /// area otherwise (instanced passthrough or meshless slot).
4173    drawn_px2: f64,
4174    /// Analytic lit area ([`analytic_lit_area`]), clamped to `drawn_px2`.
4175    lit_px2: f64,
4176    /// SDF-class bucket ([`fill_diag_bucket`]), for the top-slack dump.
4177    bucket: usize,
4178    opacity: FillOpacityClass,
4179    /// Capture-space AABB of the submitted quad, for the corner counter.
4180    aabb: [f64; 4],
4181}
4182
4183/// Builds a capture's fill-diag records. `mesh` carries the kept arc mesh's
4184/// `(vertices, indices, index_prefix)` when the slot will replay it, so each
4185/// shape is priced by its true triangle area.
4186#[cfg(not(target_arch = "wasm32"))]
4187fn fill_diag_capture_records(
4188    shape_data: &[ShapeData],
4189    mesh: Option<(&[MeshVertex], &[u32], &[u32])>,
4190) -> Vec<FillDiagShapeRecord> {
4191    shape_data
4192        .iter()
4193        .enumerate()
4194        .map(|(index, shape)| {
4195            let drawn_px2 = match mesh {
4196                // An empty index range is a shape the draw walk keeps on the
4197                // instanced-quad path — priced at its bounding quad, exactly
4198                // what that path submits.
4199                Some((vertices, indices, index_prefix))
4200                    if index_prefix[index + 1] > index_prefix[index] =>
4201                {
4202                    let start = index_prefix[index] as usize;
4203                    let end = index_prefix[index + 1] as usize;
4204                    triangles_shoelace_area(vertices, &indices[start..end])
4205                }
4206                _ => quad_shoelace_area(shape),
4207            };
4208            FillDiagShapeRecord {
4209                drawn_px2,
4210                lit_px2: analytic_lit_area(shape).clamp(0.0, drawn_px2),
4211                bucket: fill_diag_bucket(shape),
4212                opacity: fill_opacity_class(shape),
4213                aabb: quad_aabb(shape),
4214            }
4215        })
4216        .collect()
4217}
4218
4219/// One entry of the once-per-process top-slack dump: a retained shape whose
4220/// submitted area most exceeds its lit area.
4221#[cfg(not(target_arch = "wasm32"))]
4222#[derive(Clone, Copy, Debug)]
4223struct FillDiagSlackEntry {
4224    slot: u32,
4225    shape: u32,
4226    bucket: usize,
4227    drawn_px2: f64,
4228    lit_px2: f64,
4229}
4230
4231#[cfg(not(target_arch = "wasm32"))]
4232const FILL_DIAG_SLACK_TOP: usize = 10;
4233
4234/// Submitted-fill-area accounting behind [`fill_area_diag_enabled`]. The
4235/// watch's GPU counters are sepolicy-blocked, but the renderer knows every
4236/// quad it emits, so summing their areas per bucket says where the fragment
4237/// work goes; the point is the RATIO between buckets, and several are
4238/// deliberately approximate where exactness would cost the hot path:
4239///
4240/// * `arc` / `rrect-stroke` / `rrect-fill` / `rect` — batched shape quads by
4241///   decoded SDF class: exact shoelace area of the submitted quads, from the
4242///   fused screen pass and the offscreen layer/shadow-source passes alike.
4243///   Scissors and the SDF's own discards are not modeled. The latched
4244///   instanced-quad path draws these same quads (one instance per shape), so
4245///   instanced draws live in these buckets rather than a separate one.
4246/// * `mesh` — transient rim band meshes: exact triangle area, replacing the
4247///   rim's bounding quad (which is subtracted back out of `rrect-stroke`).
4248/// * `retained` — replay-slot draws: exact capture-space area of the drawn
4249///   shape range (mesh triangles when the slot replays its arc mesh, quads
4250///   otherwise) times the draw's similarity scale squared.
4251/// * `img+glyph` — image quads exactly; glyph atlas quads as width x height.
4252///   A retained glyph run counts every quad of its cached buffer (the
4253///   shared path's per-quad viewport cull is not re-run for it).
4254/// * `effect-comp` — effect-renderer draws into a caller-supplied view:
4255///   composites/blits (incl. batched, projective and masked variants) and
4256///   src-over runtime shader passes. Priced per pass at the dest viewport
4257///   area, clamped by the scissor when one is set (min of the two areas
4258///   stands in for their exact intersection).
4259/// * `offscr-src` — passes rendering INTO offscreen chain textures: blur
4260///   ping-pong axis passes, offset passes, replace-mode shader passes, and
4261///   the shadow-source target passes of `encode_shadow_shape_source_passes`
4262///   (the whole bounds-sized target per pass — its load/store round trip —
4263///   on top of the shape quads it draws, which the SDF-class buckets price
4264///   as usual).
4265///
4266/// The `[fill-truth]` line splits every bucket into analytic lit vs slack
4267/// (`lit` per [`analytic_lit_area`], `slack = submitted − lit`, clamped
4268/// non-negative; effect passes are all-lit by definition), histograms lit
4269/// pixels by [`FillOpacityClass`] (shape buckets only — image/glyph and
4270/// effect fill has no CPU-known alpha and is excluded), and prices the
4271/// full-frame corner waste per [`area_outside_inscribed_circle`]. The corner
4272/// counter covers full-frame shape batches and identity-transform retained
4273/// draws; meshed rims stay priced by their bounding AABB there (documented
4274/// overcount), and image/glyph quads are excluded.
4275///
4276/// Not counted: frame-graph layer clears/attachments outside the effect
4277/// renderer's own draw sites.
4278#[cfg(not(target_arch = "wasm32"))]
4279#[derive(Default)]
4280struct FillAreaDiag {
4281    /// Current frame's per-bucket submitted area, device px². `Cell`s
4282    /// because draw encoding accumulates through `&self`, the same pattern
4283    /// as [`gpu_stats::FrameStats`].
4284    frame: [std::cell::Cell<f64>; FILL_DIAG_BUCKETS],
4285    /// Current frame's per-bucket analytic lit area, ≤ the submitted area.
4286    frame_lit: [std::cell::Cell<f64>; FILL_DIAG_BUCKETS],
4287    /// Current frame's lit area by [`FillOpacityClass`], shape buckets only.
4288    frame_opacity: [std::cell::Cell<f64>; 3],
4289    /// Current frame's full-frame fill outside the inscribed circle.
4290    frame_corner: std::cell::Cell<f64>,
4291    /// The frame's surface size, latched by [`Self::reset_frame`] — the
4292    /// full-frame-pass gate and the inscribed circle both derive from it.
4293    viewport: std::cell::Cell<(u32, u32)>,
4294    /// Window totals, folded once per frame by [`Self::finish_frame`].
4295    window: [f64; FILL_DIAG_BUCKETS],
4296    window_lit: [f64; FILL_DIAG_BUCKETS],
4297    window_opacity: [f64; 3],
4298    window_corner: f64,
4299    window_frames: u32,
4300    /// Worst retained shapes by slack, collected at slot capture and dumped
4301    /// once with the first report window that has any (then dropped).
4302    slack_top: Vec<FillDiagSlackEntry>,
4303    slack_dumped: bool,
4304}
4305
4306#[cfg(not(target_arch = "wasm32"))]
4307impl FillAreaDiag {
4308    const ARC: usize = 0;
4309    const RRECT_STROKE: usize = 1;
4310    const RRECT_FILL: usize = 2;
4311    const RECT: usize = 3;
4312    const MESH: usize = 4;
4313    const RETAINED: usize = 5;
4314    const IMAGE_GLYPH: usize = 6;
4315    const EFFECT_COMPOSITE: usize = 7;
4316    const OFFSCREEN_SOURCE: usize = 8;
4317
4318    fn add(&self, bucket: usize, area_px2: f64) {
4319        let cell = &self.frame[bucket];
4320        cell.set(cell.get() + area_px2);
4321    }
4322
4323    fn add_lit(&self, bucket: usize, lit_px2: f64) {
4324        let cell = &self.frame_lit[bucket];
4325        cell.set(cell.get() + lit_px2);
4326    }
4327
4328    fn add_corner(&self, px2: f64) {
4329        self.frame_corner.set(self.frame_corner.get() + px2);
4330    }
4331
4332    /// Whether a batch's viewport IS this frame's surface — the gate for the
4333    /// corner counter (offscreen shadow/layer passes carry their own bounds
4334    /// viewport and never qualify).
4335    fn is_full_frame(&self, viewport: ViewportUniformParams) -> bool {
4336        let (width, height) = self.viewport.get();
4337        width > 0
4338            && height > 0
4339            && viewport.width == width
4340            && viewport.height == height
4341            && viewport.offset == [0.0, 0.0]
4342    }
4343
4344    /// Splits a freshly converted batch's quads by SDF class
4345    /// ([`fill_diag_bucket`]), alongside each bucket's analytic lit area,
4346    /// the opacity histogram and — for full-frame passes — the corner
4347    /// counter.
4348    fn add_shape_quads(&self, shapes: &[ShapeData], viewport: ViewportUniformParams) {
4349        let full_frame = self.is_full_frame(viewport);
4350        let frame_viewport = self.viewport.get();
4351        let mut buckets = [0.0_f64; FILL_DIAG_BUCKETS];
4352        let mut lit_buckets = [0.0_f64; FILL_DIAG_BUCKETS];
4353        let mut opacity = [0.0_f64; 3];
4354        let mut corner = 0.0_f64;
4355        for shape in shapes {
4356            let bucket = fill_diag_bucket(shape);
4357            let quad = quad_shoelace_area(shape);
4358            let lit = analytic_lit_area(shape).clamp(0.0, quad);
4359            buckets[bucket] += quad;
4360            lit_buckets[bucket] += lit;
4361            opacity[fill_opacity_class(shape) as usize] += lit;
4362            if full_frame {
4363                corner += area_outside_inscribed_circle(quad_aabb(shape), frame_viewport);
4364            }
4365        }
4366        for (bucket, area) in buckets.into_iter().enumerate() {
4367            if area > 0.0 {
4368                self.add(bucket, area);
4369            }
4370        }
4371        for (bucket, lit) in lit_buckets.into_iter().enumerate() {
4372            if lit > 0.0 {
4373                self.add_lit(bucket, lit);
4374            }
4375        }
4376        for (class, lit) in self.frame_opacity.iter().zip(opacity) {
4377            class.set(class.get() + lit);
4378        }
4379        if corner > 0.0 {
4380            self.add_corner(corner);
4381        }
4382    }
4383
4384    /// A leading-span cache hit replaced these already-counted quads with
4385    /// one cached-texture blit: subtract their submitted, lit and
4386    /// opacity-class areas back out — those pixels now arrive through the
4387    /// blit, an effect-renderer composite that the effect-comp bucket
4388    /// prices at its own draw site and the opacity histogram excludes by
4389    /// design (no CPU-known alpha). The corner counter stays as priced at
4390    /// batch prepare: the full-target blit writes the very same corner
4391    /// pixels, so the waste that counter exists to expose is unchanged.
4392    fn note_static_span_skip(&self, shapes: &[ShapeData]) {
4393        for shape in shapes {
4394            let bucket = fill_diag_bucket(shape);
4395            let quad = quad_shoelace_area(shape);
4396            let lit = analytic_lit_area(shape).clamp(0.0, quad);
4397            self.add(bucket, -quad);
4398            self.add_lit(bucket, -lit);
4399            let class = &self.frame_opacity[fill_opacity_class(shape) as usize];
4400            class.set(class.get() - lit);
4401        }
4402    }
4403
4404    /// A transient rim replaced its bounding quad with a band mesh: move the
4405    /// quad's area and lit (already counted at batch prepare) out of the
4406    /// stroke bucket and count the mesh triangles instead. The opacity
4407    /// histogram and corner counter stay as priced at batch prepare — the
4408    /// same pixels light up either way, and the corner counter deliberately
4409    /// keeps the quad AABB (documented overcount for meshed rims).
4410    fn note_rim_mesh(&self, shape: &ShapeData, mesh_px2: f64) {
4411        let quad = quad_shoelace_area(shape);
4412        let lit = analytic_lit_area(shape).clamp(0.0, quad);
4413        self.add(Self::RRECT_STROKE, -quad);
4414        self.add_lit(Self::RRECT_STROKE, -lit);
4415        self.add(Self::MESH, mesh_px2);
4416        self.add_lit(Self::MESH, lit.min(mesh_px2));
4417    }
4418
4419    /// One retained replay draw over `first..last` of a slot's capture:
4420    /// capture-space records times the draw's similarity scale squared. The
4421    /// corner counter only accumulates for identity-transform draws (rot 0,
4422    /// scale 1 — the static background/rings case it exists for), because a
4423    /// moved batch's capture-space AABBs no longer say where it lands.
4424    fn add_retained_range(
4425        &self,
4426        records: &[FillDiagShapeRecord],
4427        first: u32,
4428        last: u32,
4429        transform: &SimilarityTransform,
4430    ) {
4431        let Some(range) = records.get(first as usize..last as usize) else {
4432            return;
4433        };
4434        let scale = f64::from(transform.scale);
4435        let factor = scale * scale;
4436        let identity = transform.rot == [1.0, 0.0] && transform.scale == 1.0;
4437        let frame_viewport = self.viewport.get();
4438        let mut drawn = 0.0_f64;
4439        let mut lit = 0.0_f64;
4440        let mut opacity = [0.0_f64; 3];
4441        let mut corner = 0.0_f64;
4442        for record in range {
4443            drawn += record.drawn_px2;
4444            lit += record.lit_px2;
4445            opacity[record.opacity as usize] += record.lit_px2;
4446            if identity {
4447                corner += area_outside_inscribed_circle(record.aabb, frame_viewport);
4448            }
4449        }
4450        self.add(Self::RETAINED, drawn * factor);
4451        self.add_lit(Self::RETAINED, lit * factor);
4452        for (class, value) in self.frame_opacity.iter().zip(opacity) {
4453            class.set(class.get() + value * factor);
4454        }
4455        if corner > 0.0 {
4456            self.add_corner(corner);
4457        }
4458    }
4459
4460    /// Collects top-slack candidates from a fresh capture, keeping the
4461    /// [`FILL_DIAG_SLACK_TOP`] worst across all captures until the first
4462    /// report window dumps them.
4463    fn note_retained_capture(&mut self, slot: u32, records: &[FillDiagShapeRecord]) {
4464        if self.slack_dumped {
4465            return;
4466        }
4467        for (index, record) in records.iter().enumerate() {
4468            if record.drawn_px2 - record.lit_px2 <= 0.0 {
4469                continue;
4470            }
4471            self.slack_top.push(FillDiagSlackEntry {
4472                slot,
4473                shape: index as u32,
4474                bucket: record.bucket,
4475                drawn_px2: record.drawn_px2,
4476                lit_px2: record.lit_px2,
4477            });
4478        }
4479        self.slack_top
4480            .sort_by(|a, b| (b.drawn_px2 - b.lit_px2).total_cmp(&(a.drawn_px2 - a.lit_px2)));
4481        self.slack_top.truncate(FILL_DIAG_SLACK_TOP);
4482    }
4483
4484    /// Area of an image or text-image quad from its four device-space
4485    /// corners (TL, TR, BL, BR — the shared `(0, 1, 2)(2, 1, 3)` pattern).
4486    /// Textures light every pixel of their quad, so lit == submitted.
4487    fn add_image_quad(&self, quad: &[[f32; 2]; 4]) {
4488        let corner = |index: usize| [f64::from(quad[index][0]), f64::from(quad[index][1])];
4489        let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
4490            ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
4491        };
4492        let [a, b, c, d] = [corner(0), corner(1), corner(2), corner(3)];
4493        let area = tri(a, b, c) + tri(c, b, d);
4494        self.add(Self::IMAGE_GLYPH, area);
4495        self.add_lit(Self::IMAGE_GLYPH, area);
4496    }
4497
4498    /// One glyph atlas quad, axis-aligned by construction.
4499    fn add_glyph_quad(&self, quad: &CachedTextGlyphQuad) {
4500        let area = quad.width as f64 * quad.height as f64;
4501        self.add(Self::IMAGE_GLYPH, area);
4502        self.add_lit(Self::IMAGE_GLYPH, area);
4503    }
4504
4505    /// Effect-renderer pass fill drained once per frame from the effect
4506    /// renderer's own counters. Full-target draws: every counted pixel is
4507    /// shaded, so lit == submitted and slack is zero by construction.
4508    fn add_effect_fill(&self, composite_px2: f64, offscreen_px2: f64) {
4509        if composite_px2 > 0.0 {
4510            self.add(Self::EFFECT_COMPOSITE, composite_px2);
4511            self.add_lit(Self::EFFECT_COMPOSITE, composite_px2);
4512        }
4513        if offscreen_px2 > 0.0 {
4514            self.add(Self::OFFSCREEN_SOURCE, offscreen_px2);
4515            self.add_lit(Self::OFFSCREEN_SOURCE, offscreen_px2);
4516        }
4517    }
4518
4519    /// One render pass targeting an offscreen source texture (shadow source
4520    /// passes): the whole target area counts — its clear/load/store round
4521    /// trip — on top of the shape quads the pass draws, which
4522    /// [`Self::add_shape_quads`] prices separately under the pass's own
4523    /// bounds viewport.
4524    fn add_offscreen_target_fill(&self, px2: f64) {
4525        if px2 > 0.0 {
4526            self.add(Self::OFFSCREEN_SOURCE, px2);
4527            self.add_lit(Self::OFFSCREEN_SOURCE, px2);
4528        }
4529    }
4530
4531    /// Restarts the frame counters and latches the surface size — called
4532    /// from the same per-frame reset point as the transient rim mesh
4533    /// scratch.
4534    fn reset_frame(&self, width: u32, height: u32) {
4535        for cell in &self.frame {
4536            cell.set(0.0);
4537        }
4538        for cell in &self.frame_lit {
4539            cell.set(0.0);
4540        }
4541        for cell in &self.frame_opacity {
4542            cell.set(0.0);
4543        }
4544        self.frame_corner.set(0.0);
4545        self.viewport.set((width, height));
4546    }
4547
4548    /// Folds the frame into the window and, every
4549    /// [`FILL_DIAG_WINDOW_FRAMES`] rendered frames, emits the `[fill-diag]`
4550    /// bucket line, the `[fill-truth]` lit/slack + opacity + corner line,
4551    /// and — once per process — the retained top-slack dump.
4552    fn finish_frame(&mut self, width: u32, height: u32) {
4553        for (total, cell) in self.window.iter_mut().zip(&self.frame) {
4554            *total += cell.get();
4555        }
4556        for (total, cell) in self.window_lit.iter_mut().zip(&self.frame_lit) {
4557            *total += cell.get();
4558        }
4559        for (total, cell) in self.window_opacity.iter_mut().zip(&self.frame_opacity) {
4560            *total += cell.get();
4561        }
4562        self.window_corner += self.frame_corner.get();
4563        self.window_frames += 1;
4564        if self.window_frames < FILL_DIAG_WINDOW_FRAMES {
4565            return;
4566        }
4567        let frames = f64::from(self.window_frames);
4568        let mega = |bucket: usize| self.window[bucket] / frames / 1e6;
4569        let total_mega = self.window.iter().sum::<f64>() / frames / 1e6;
4570        let screen_mega = f64::from(width) * f64::from(height) / 1e6;
4571        let overdraw = if screen_mega > 0.0 {
4572            total_mega / screen_mega
4573        } else {
4574            0.0
4575        };
4576        log::warn!(
4577            "[fill-diag] Mpx/frame: arc {:.1}, rrect-stroke {:.1}, rrect-fill {:.1}, \
4578             rect {:.1}, mesh {:.1}, retained {:.1}, img+glyph {:.1}, \
4579             effect-comp {:.1}, offscr-src {:.1}, total {:.1} \
4580             ({:.1}x overdraw of {:.3} Mpx)",
4581            mega(Self::ARC),
4582            mega(Self::RRECT_STROKE),
4583            mega(Self::RRECT_FILL),
4584            mega(Self::RECT),
4585            mega(Self::MESH),
4586            mega(Self::RETAINED),
4587            mega(Self::IMAGE_GLYPH),
4588            mega(Self::EFFECT_COMPOSITE),
4589            mega(Self::OFFSCREEN_SOURCE),
4590            total_mega,
4591            overdraw,
4592            screen_mega,
4593        );
4594        // Lit vs slack per bucket: lit per [`analytic_lit_area`], slack the
4595        // remainder of the submitted area (clamped — negatives are rim-mesh
4596        // rounding, not information).
4597        let lit = |bucket: usize| self.window_lit[bucket] / frames / 1e6;
4598        let slack = |bucket: usize| (mega(bucket) - lit(bucket)).max(0.0);
4599        let truth = |bucket: usize| format!("{:.2}|{:.2}", lit(bucket), slack(bucket));
4600        log::warn!(
4601            "[fill-truth] Mpx/frame lit|slack: arc {}, rrect-stroke {}, rrect-fill {}, \
4602             rect {}, mesh {}, retained {}, img+glyph {}, effect-comp {}, offscr-src {}; \
4603             lit alpha Mpx: opaque {:.2}, translucent {:.2}, nonsolid {:.2}; \
4604             corner-outside {:.2}",
4605            truth(Self::ARC),
4606            truth(Self::RRECT_STROKE),
4607            truth(Self::RRECT_FILL),
4608            truth(Self::RECT),
4609            truth(Self::MESH),
4610            truth(Self::RETAINED),
4611            truth(Self::IMAGE_GLYPH),
4612            truth(Self::EFFECT_COMPOSITE),
4613            truth(Self::OFFSCREEN_SOURCE),
4614            self.window_opacity[FillOpacityClass::Opaque as usize] / frames / 1e6,
4615            self.window_opacity[FillOpacityClass::Translucent as usize] / frames / 1e6,
4616            self.window_opacity[FillOpacityClass::NonSolid as usize] / frames / 1e6,
4617            self.window_corner / frames / 1e6,
4618        );
4619        if !self.slack_dumped && !self.slack_top.is_empty() {
4620            log::warn!("[fill-truth] top retained slack (once per process, capture-space px):");
4621            for (rank, entry) in self.slack_top.iter().enumerate() {
4622                log::warn!(
4623                    "[fill-truth]   #{} slot {} shape {} {}: quad {:.0}, lit {:.0}, \
4624                     slack {:.0}",
4625                    rank + 1,
4626                    entry.slot,
4627                    entry.shape,
4628                    fill_diag_bucket_name(entry.bucket),
4629                    entry.drawn_px2,
4630                    entry.lit_px2,
4631                    entry.drawn_px2 - entry.lit_px2,
4632                );
4633            }
4634            self.slack_dumped = true;
4635            self.slack_top = Vec::new();
4636        }
4637        self.window = [0.0; FILL_DIAG_BUCKETS];
4638        self.window_lit = [0.0; FILL_DIAG_BUCKETS];
4639        self.window_opacity = [0.0; 3];
4640        self.window_corner = 0.0;
4641        self.window_frames = 0;
4642    }
4643}
4644
4645#[cfg(not(target_arch = "wasm32"))]
4646struct ArcMeshBuild {
4647    vertices: Vec<MeshVertex>,
4648    /// Triangle-list indices into `vertices`; see [`ReplaySlotMesh`].
4649    indices: Vec<u32>,
4650    /// `shape_count + 1` entries; shape `i` owns triangles
4651    /// `indices[index_prefix[i]..index_prefix[i + 1]]`. An EMPTY range is a
4652    /// shape that did not mesh: the draw walk keeps it on the latched
4653    /// instanced-quad path (see [`GpuRenderer::encode_retained_op`]) — the
4654    /// mesh buffers hold band geometry only, never passthrough quads.
4655    index_prefix: Vec<u32>,
4656    meshed_arcs: usize,
4657    meshed_rims: usize,
4658    meshed_segments: usize,
4659    passthrough: usize,
4660    /// Maximal runs of CONSECUTIVE meshed shapes. Each stretch costs the
4661    /// draw walk two pipeline switches per op that covers it, so the
4662    /// capture site refuses meshes past [`MESH_SLOT_MAX_STRETCHES`].
4663    meshed_stretches: usize,
4664    quad_area: f64,
4665    /// Capture-space area the new encoding actually submits: band-mesh
4666    /// triangles for meshed shapes, bounding quads for everything else.
4667    mesh_area: f64,
4668}
4669
4670/// Builds a slot's conservative indexed mesh: arc bands and stroked-circle
4671/// rims whose bounding quad reaches `min_mesh_px2` become vertex-sharing
4672/// trapezoid strips; every other shape — including gate-rejected small arcs
4673/// — contributes NO geometry, only an empty `index_prefix` range, and stays
4674/// on the instanced-quad path at draw time. (Putting passthrough quads in
4675/// the mesh buffers was the S3 mistake the watch measured: every quad paid
4676/// per-vertex `MeshVertex` attribute bandwidth where the latched instanced
4677/// path pays shared storage reads, and ~550 passthrough quads per slot
4678/// swamped the two meshed shapes' fill recovery — mesh ON 48.7/43.5 fps vs
4679/// OFF 53.7/45.2 on the Adreno 702.) Returns `None` when the byte budget
4680/// overflows — the caller warns and the whole slot replays through the
4681/// quad-expansion path (silent truncation would break the containment
4682/// invariant).
4683#[cfg(not(target_arch = "wasm32"))]
4684fn build_arc_mesh_vertices(shape_data: &[ShapeData], min_mesh_px2: f64) -> Option<ArcMeshBuild> {
4685    let budget_bytes =
4686        (shape_data.len() * ARC_MESH_BUDGET_BYTES_PER_SHAPE).max(ARC_MESH_BUDGET_FLOOR_BYTES);
4687    let mut build = ArcMeshBuild {
4688        vertices: Vec::new(),
4689        indices: Vec::new(),
4690        index_prefix: Vec::with_capacity(shape_data.len() + 1),
4691        meshed_arcs: 0,
4692        meshed_rims: 0,
4693        meshed_segments: 0,
4694        passthrough: 0,
4695        meshed_stretches: 0,
4696        quad_area: 0.0,
4697        mesh_area: 0.0,
4698    };
4699    build.index_prefix.push(0);
4700    let mut previous_meshed = false;
4701    for (index, shape) in shape_data.iter().enumerate() {
4702        let start = build.indices.len();
4703        let quad_px2 = quad_shoelace_area(shape);
4704        // THE SIZE GATE (see [`arc_mesh_enabled`] for the measured history):
4705        // only shapes whose submitted quad is big enough to carry real
4706        // fill-truth slack are worth a mesh; below the gate the trapezoid
4707        // strip's vertex and binning amplification costs the watch GPU more
4708        // than the discarded fragments ever did. The two band shapes are
4709        // mutually exclusive by kind bits (`SHAPE_KIND_ARC` vs
4710        // `SHAPE_KIND_STROKE`), so the `or_else` never shadows one with the
4711        // other.
4712        let band = if quad_px2 >= min_mesh_px2 {
4713            arc_mesh_band(shape)
4714                .map(|band| (band, false))
4715                .or_else(|| rim_band_geometry(shape).map(|band| (band, true)))
4716        } else {
4717            None
4718        };
4719        let meshed = band.and_then(|(band, is_rim)| {
4720            emit_arc_band_mesh(
4721                shape,
4722                index as u32,
4723                &band,
4724                &mut build.vertices,
4725                &mut build.indices,
4726            )
4727            .map(|segments| (segments, is_rim))
4728        });
4729        match meshed {
4730            Some((segments, is_rim)) => {
4731                if is_rim {
4732                    build.meshed_rims += 1;
4733                } else {
4734                    build.meshed_arcs += 1;
4735                }
4736                build.meshed_segments += segments;
4737                if !previous_meshed {
4738                    build.meshed_stretches += 1;
4739                }
4740                previous_meshed = true;
4741                build.mesh_area +=
4742                    triangles_shoelace_area(&build.vertices, &build.indices[start..]);
4743            }
4744            None => {
4745                build.passthrough += 1;
4746                previous_meshed = false;
4747                build.mesh_area += quad_px2;
4748            }
4749        }
4750        if arc_mesh_bytes(build.vertices.len(), build.indices.len()) > budget_bytes {
4751            return None;
4752        }
4753        build.index_prefix.push(build.indices.len() as u32);
4754        build.quad_area += quad_px2;
4755    }
4756    Some(build)
4757}
4758
4759/// The renderer's registry of live replay slots. The replay cache (scene
4760/// side) owns slot LIFECYCLE decisions; this store owns the GPU resources.
4761#[cfg(not(target_arch = "wasm32"))]
4762struct ReplaySlotStore {
4763    slots: std::collections::HashMap<u32, ReplaySlot, cranpose_ui_graphics::FxBuildHasher>,
4764    transform_buffer: wgpu::Buffer,
4765    free_ids: Vec<u32>,
4766    /// Global capture counter feeding [`ReplaySlot::capture_epoch`]: bumped
4767    /// on every capture, never reused, so an epoch identifies one capture's
4768    /// buffers for the renderer's whole lifetime.
4769    next_capture_epoch: u64,
4770}
4771
4772#[cfg(not(target_arch = "wasm32"))]
4773impl ReplaySlotStore {
4774    fn new(device: &wgpu::Device) -> Self {
4775        let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4776            label: Some("Replay Transform Buffer"),
4777            // The trailing SEGMENT_CAPTURE_SLOTS strides are reserved for
4778            // segment-surface capture passes: each capture binds its
4779            // similarity (the span's own transform, retained paint
4780            // selected) at `(MAX_REPLAY_SLOTS + capture_index) * stride`,
4781            // past every per-draw slot, so captures can never clobber a
4782            // frame's staged draw transforms.
4783            size: (MAX_REPLAY_SLOTS + SEGMENT_CAPTURE_SLOTS) as u64 * REPLAY_TRANSFORM_STRIDE,
4784            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4785            mapped_at_creation: false,
4786        });
4787        Self {
4788            slots: std::collections::HashMap::default(),
4789            transform_buffer,
4790            free_ids: (0..MAX_REPLAY_SLOTS).rev().collect(),
4791            next_capture_epoch: 1,
4792        }
4793    }
4794}
4795
4796/// Kill switch for cached retained render bundles, mirroring
4797/// `command_feed_enabled`: default ON, `CRANPOSE_RETAINED_BUNDLES=0` (or the
4798/// `debug.cranpose.retained_bundles` property on Android) drops the fused
4799/// retained arms back to direct per-op encoding, so a device A/B needs no
4800/// rebuild. Read per partition — the parity harness flips it between passes.
4801#[cfg(not(target_arch = "wasm32"))]
4802fn retained_bundles_enabled() -> bool {
4803    std::env::var("CRANPOSE_RETAINED_BUNDLES").as_deref() != Ok("0")
4804}
4805
4806/// Kill switch for instanced ordinary-shape quads: default ON,
4807/// `CRANPOSE_INSTANCED_QUADS=0` (or the `debug.cranpose.instanced_quads`
4808/// property on Android) reverts every ordinary shape draw to the six-vertex
4809/// `vs_main` expansion. Unlike the per-partition bundle flag this is read
4810/// ONCE per [`GpuRenderer`] construction into a field: cached retained
4811/// bundles encode the selected pipeline, so a flag that moved per draw would
4812/// let a cached bundle replay a selection the direct path no longer makes.
4813#[cfg(not(target_arch = "wasm32"))]
4814fn instanced_quads_enabled() -> bool {
4815    std::env::var("CRANPOSE_INSTANCED_QUADS").as_deref() != Ok("0")
4816}
4817
4818/// Trimmed-varying solid pipelines: default OFF, `CRANPOSE_SOLID_TRIM_VARYINGS=1`
4819/// (or the `debug.cranpose.solid_trim` property on Android) opts in. When on,
4820/// the two `fs_solid` pipeline families compile `vs_solid` /
4821/// `vs_solid_instanced` + `fs_solid_trim` — the inter-stage interface without
4822/// the eight gradient scalars `fs_solid` never reads (see
4823/// `shape_solid_trim.wgsl` for the location discipline). Read at pipeline
4824/// build like every lazy pipeline — the property is seeded into the
4825/// environment before the render loop starts, and the `PassPipeline` slots
4826/// cache the first build, so retained bundles and direct draws always encode
4827/// the same selection. Kill switch first: the previous attempt (16a5d312,
4828/// reverted in 371dd06a) died on a watch undiagnosed, so the trim ships dark
4829/// until a Vulkan-validated device session clears it.
4830fn solid_trim_varyings_enabled() -> bool {
4831    std::env::var("CRANPOSE_SOLID_TRIM_VARYINGS").as_deref() == Ok("1")
4832}
4833
4834/// Kill switch for surviving uncaptured device errors: default ON,
4835/// `CRANPOSE_SURVIVE_GPU_ERRORS=0` (or the
4836/// `debug.cranpose.survive_gpu_errors` property on Android) restores
4837/// wgpu's fatal default handler, which panics with the error message on
4838/// the reporting thread — the pre-fix behavior, kept reachable so a
4839/// debugging session can die loudly at the first error instead of
4840/// logging past it. Read once, at [`GpuRenderer`] construction, where the
4841/// handler is installed; it changes nothing off the error path.
4842fn survive_gpu_errors_enabled() -> bool {
4843    std::env::var("CRANPOSE_SURVIVE_GPU_ERRORS").as_deref() != Ok("0")
4844}
4845
4846/// Kill switch for the display clip region cull: default ON wherever the
4847/// platform reports a cullable visible region
4848/// (`set_display_visible_region`), and `CRANPOSE_ROUND_CULL` (or the
4849/// `debug.cranpose.round_cull` property on Android) gates it — the
4850/// switch keeps the name of the capability's first provider, the round
4851/// display. Read per frame — the parity harness flips it between passes.
4852/// While the region is `Full` the variable is never consulted: the cull
4853/// is structurally off.
4854///
4855/// OPT-IN (=1), not default-on, by measurement: with the span-capture
4856/// depth-leak fixed, the on-watch A/B (Pixel Watch 3, Adreno 702, mega
4857/// scene, alternating pairs) read cull ON 47.0/46.9 fps vs OFF 48.6/46.9 —
4858/// a small loss to a tie, never a win, despite the cull masking 35723 px
4859/// (21% of the buffer). The shape fragment shaders discard, which defeats
4860/// LRZ/early-Z on this GPU: corner fragments still execute, so the depth
4861/// attachment and occluder are pure overhead. The capability stays for
4862/// displays and drivers where early rejection survives discard — a device
4863/// A/B is one env flip, no rebuild — but earning default-on takes a
4864/// measured win on some device class, not an assumption.
4865#[cfg(not(target_arch = "wasm32"))]
4866fn display_clip_cull_enabled() -> bool {
4867    std::env::var("CRANPOSE_ROUND_CULL").as_deref() == Ok("1")
4868}
4869
4870/// The index pattern of one instanced quad: the exact triangle pair
4871/// `vs_main`'s six-slot corner mapping produces — (0, 1, 2)(2, 1, 3), same
4872/// diagonal, same winding — shared by every instance.
4873#[cfg(not(target_arch = "wasm32"))]
4874const INSTANCED_QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3];
4875
4876/// The latched instanced-quad selection: `Some` exactly when the renderer
4877/// was constructed in storage mode with [`instanced_quads_enabled`]. Both
4878/// blend variants exist because ordinary batches draw SrcOver and DstOut;
4879/// the `vs_main` pipelines coexist untouched so the `=0` revert (and the
4880/// uniform-mode path) still has its six-vertex draws.
4881#[cfg(not(target_arch = "wasm32"))]
4882struct InstancedQuadPipelines {
4883    pipeline: PassPipeline,
4884    pipeline_dst_out: PassPipeline,
4885    /// `fs_solid` twin of `pipeline` (SrcOver only): chosen for draws whose
4886    /// shapes carry no gradient stops, which is nearly every draw of an
4887    /// arc-heavy scene.
4888    pipeline_solid: PassPipeline,
4889    /// Static `[0, 1, 2, 2, 1, 3]` u16 index buffer, created once and shared
4890    /// by every instanced draw.
4891    index_buffer: wgpu::Buffer,
4892}
4893
4894#[cfg(not(target_arch = "wasm32"))]
4895struct SegmentCapturePipelines {
4896    expanded: PassPipeline,
4897    expanded_solid: PassPipeline,
4898    mesh: PassPipeline,
4899    instanced: PassPipeline,
4900    instanced_solid: PassPipeline,
4901}
4902
4903#[cfg(not(target_arch = "wasm32"))]
4904#[derive(Clone, Copy)]
4905enum RetainedPipelineKind {
4906    Expanded,
4907    ExpandedSolid,
4908    Mesh,
4909    Instanced,
4910    InstancedSolid,
4911}
4912
4913/// One command of a retained op's draw walk, emitted by
4914/// [`GpuRenderer::encode_retained_op`] — the SINGLE place the walk exists.
4915/// The two sinks (the fused pass on the direct path, a
4916/// `RenderBundleEncoder` on the cached path) each translate these
4917/// mechanically, one match arm per variant, so the bundle-parity bar ("a
4918/// bundle replays the IDENTICAL command sequence") holds by construction:
4919/// only the translation is duplicated, never the sequence logic. A shared
4920/// generic encoder (`wgpu::util::RenderEncoder`) cannot express this
4921/// instead: `&mut RenderPass<'p>` is invariant in `'p`, so unifying the
4922/// resource lifetime with the pass's would freeze `self` immutably
4923/// borrowed for the whole pass.
4924#[cfg(not(target_arch = "wasm32"))]
4925enum RetainedCmd<'r> {
4926    Pipeline(RetainedPipelineKind),
4927    /// Bind group 0, no dynamic offsets.
4928    Uniforms(&'r wgpu::BindGroup),
4929    /// Bind group 1 with the retained draw's dynamic transform offset.
4930    SlotBindings(&'r wgpu::BindGroup, u32),
4931    /// The slot mesh's vertex buffer at slot 0.
4932    MeshVertices(&'r wgpu::Buffer),
4933    Index(&'r wgpu::Buffer, wgpu::IndexFormat),
4934    /// `draw(vertices, 0..1)`.
4935    Draw(Range<u32>),
4936    /// `draw_indexed(indices, 0, instances)`.
4937    DrawIndexed(Range<u32>, Range<u32>),
4938}
4939
4940/// Everything that decides the commands one retained op contributes to a
4941/// cached bundle. Equal op keys imply identical encoded commands:
4942/// `capture_epoch` pins the slot's bind group, buffers AND its mesh's
4943/// meshed/instanced stretch structure to one capture (the alternating walk
4944/// of [`GpuRenderer::encode_retained_op`] is a pure function of the
4945/// capture-fixed `index_prefix` and `first..last`, so no per-stretch state
4946/// belongs in the key), `has_mesh` pins whether that walk runs at all,
4947/// `first..last` is the clamped draw range, and `retained_index` is the
4948/// dynamic transform offset. Transforms and paints are NOT here — they are
4949/// data-buffer contents the bundle reads at execution.
4950#[cfg(not(target_arch = "wasm32"))]
4951#[derive(Clone, Debug, PartialEq, Eq, Hash)]
4952struct RetainedBundleOpKey {
4953    slot: u32,
4954    /// The slot's capture epoch at key time, `None` while the slot is absent
4955    /// from the store (the op encodes nothing). Epochs are globally unique
4956    /// per capture, so a recaptured slot reusing its id can never satisfy a
4957    /// key recorded against the previous capture's buffers.
4958    capture_epoch: Option<u64>,
4959    first: u32,
4960    last: u32,
4961    retained_index: u32,
4962    has_mesh: bool,
4963}
4964
4965/// Key of one maximal consecutive retained stretch: the op keys in draw
4966/// order. Any reorder, count change, range change, recapture, or slot
4967/// release changes the key and forces a rebuild.
4968#[cfg(not(target_arch = "wasm32"))]
4969#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
4970struct RetainedBundleKey {
4971    /// Whether the stretch was encoded for the display-clip culled pass:
4972    /// such a bundle declares the depth attachment and records
4973    /// depth-variant pipelines, so it must never replay into a flat pass
4974    /// (or vice versa) — the flag keys the cache apart.
4975    depth: bool,
4976    ops: Vec<RetainedBundleOpKey>,
4977}
4978
4979#[cfg(not(target_arch = "wasm32"))]
4980struct RetainedBundleCacheEntry<B> {
4981    bundle: B,
4982    last_used_frame: u64,
4983}
4984
4985/// Cache of encoded render bundles for retained stretches, generic over the
4986/// bundle payload so the reuse/invalidation/eviction logic is unit-testable
4987/// without a GPU. The full [`RetainedBundleKey`] is the map key — a fresh
4988/// key can only ever build a fresh bundle, never alias a stale one.
4989///
4990/// The surface format and the group-0 uniform bind group are deliberately
4991/// not part of the key: both are fixed for a `GpuRenderer`'s lifetime (a
4992/// surface reconfigure builds a new renderer, and with it an empty cache).
4993#[cfg(not(target_arch = "wasm32"))]
4994struct RetainedBundleCacheImpl<B> {
4995    entries: HashMap<RetainedBundleKey, RetainedBundleCacheEntry<B>>,
4996    frame: u64,
4997    rebuilds: u64,
4998    cached_executes: u64,
4999    window_rebuilds: u64,
5000    window_executes: u64,
5001}
5002
5003#[cfg(not(target_arch = "wasm32"))]
5004type RetainedBundleCache = RetainedBundleCacheImpl<wgpu::RenderBundle>;
5005
5006#[cfg(not(target_arch = "wasm32"))]
5007impl<B> RetainedBundleCacheImpl<B> {
5008    fn new() -> Self {
5009        Self {
5010            entries: HashMap::default(),
5011            frame: 0,
5012            rebuilds: 0,
5013            cached_executes: 0,
5014            window_rebuilds: 0,
5015            window_executes: 0,
5016        }
5017    }
5018
5019    /// True when a bundle for `key` is cached; marks it used this frame and
5020    /// counts a cached execute.
5021    fn hit(&mut self, key: &RetainedBundleKey) -> bool {
5022        let frame = self.frame;
5023        match self.entries.get_mut(key) {
5024            Some(entry) => {
5025                entry.last_used_frame = frame;
5026                self.cached_executes += 1;
5027                self.window_executes += 1;
5028                true
5029            }
5030            None => false,
5031        }
5032    }
5033
5034    /// Stores a freshly built bundle, counting a rebuild.
5035    fn insert(&mut self, key: RetainedBundleKey, bundle: B) {
5036        self.rebuilds += 1;
5037        self.window_rebuilds += 1;
5038        self.entries.insert(
5039            key,
5040            RetainedBundleCacheEntry {
5041                bundle,
5042                last_used_frame: self.frame,
5043            },
5044        );
5045    }
5046
5047    fn get(&self, key: &RetainedBundleKey) -> Option<&B> {
5048        self.entries.get(key).map(|entry| &entry.bundle)
5049    }
5050
5051    /// Drops every cached bundle. Called whenever a replay slot is released:
5052    /// the key compare already makes stale entries unreachable (their epochs
5053    /// can never recur), so this only releases the dropped capture's GPU
5054    /// resources promptly instead of one frame later via eviction.
5055    fn clear(&mut self) {
5056        self.entries.clear();
5057    }
5058
5059    /// Frame boundary: evicts entries the frame did not use — a bundle
5060    /// holds references on its slot's buffers, so unused entries must not
5061    /// accumulate — and emits the rate-limited rebuild/execute telemetry.
5062    fn end_frame(&mut self) {
5063        let frame = self.frame;
5064        self.entries
5065            .retain(|_, entry| entry.last_used_frame >= frame);
5066        self.frame = self.frame.wrapping_add(1);
5067        // Always-on at a cadence that cannot spam; every perf window (120
5068        // frames) under the replay diagnostics flag so short A/B runs see
5069        // the counts. log::warn because log::info is invisible on the
5070        // desktop console.
5071        let due = self.frame.is_multiple_of(1024)
5072            || (cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG")
5073                && self.frame.is_multiple_of(120));
5074        if due && self.window_rebuilds + self.window_executes > 0 {
5075            log::warn!(
5076                "[retained-bundles] {} stretches, {} rebuilds, {} cached executes ({} live bundles)",
5077                self.window_rebuilds + self.window_executes,
5078                self.window_rebuilds,
5079                self.window_executes,
5080                self.entries.len(),
5081            );
5082            self.window_rebuilds = 0;
5083            self.window_executes = 0;
5084        }
5085    }
5086
5087    /// Lifetime (rebuilds, cached executes) for tests and diagnostics.
5088    fn stats(&self) -> (u64, u64) {
5089        (self.rebuilds, self.cached_executes)
5090    }
5091}
5092
5093struct CachedImageTexture {
5094    _texture: wgpu::Texture,
5095    _view: wgpu::TextureView,
5096    nearest_bind_group: wgpu::BindGroup,
5097    linear_bind_group: wgpu::BindGroup,
5098    /// GPU bytes this entry pins (w×h×4): the cache is bounded by BYTES as
5099    /// well as count. A live camera publishes a new multi-MB bitmap id every
5100    /// frame; 256 count-slots of those is ~1.5GB of dead preview textures —
5101    /// which on iOS unified memory counts straight against the process's
5102    /// jetsam limit (measured: the app died mid-scan under an open camera
5103    /// with exactly that ballast).
5104    bytes: usize,
5105}
5106
5107impl CachedImageTexture {
5108    fn bind_group(&self, sampling: ImageSampling) -> &wgpu::BindGroup {
5109        match sampling {
5110            ImageSampling::Nearest => &self.nearest_bind_group,
5111            ImageSampling::Linear => &self.linear_bind_group,
5112        }
5113    }
5114}
5115
5116#[derive(Clone, Copy)]
5117struct GlyphAtlasEntry {
5118    x: u32,
5119    y: u32,
5120    width: u32,
5121    height: u32,
5122}
5123
5124/// Side length the glyph atlas should be rebuilt at after it overflowed at
5125/// `current`: one doubling, never past `max`.
5126///
5127/// Doubling (rather than jumping straight to `max`) is what makes the atlas
5128/// cost track the workload: an app that overflows once needs a little more
5129/// room, not sixteen times more.
5130fn next_glyph_atlas_size(current: u32, max: u32) -> u32 {
5131    current.saturating_mul(2).clamp(1, max.max(1))
5132}
5133
5134struct TextGlyphAtlas {
5135    texture: wgpu::Texture,
5136    _view: wgpu::TextureView,
5137    bind_group: wgpu::BindGroup,
5138    entries: BoundedLruCache<SoftwareGlyphAtlasKey, GlyphAtlasEntry>,
5139    generation: u64,
5140    /// Side length of `texture`, between `TEXT_GLYPH_ATLAS_MIN_SIZE` and the
5141    /// device's ceiling. Every UV is normalised against it, so it has to travel
5142    /// with the atlas rather than be read back off a constant.
5143    size: u32,
5144    /// Largest side length this atlas may grow to: the smaller of
5145    /// `TEXT_GLYPH_ATLAS_MAX_SIZE` and what the device grants. Mobile devices
5146    /// are requested `downlevel_defaults()` limits raised by `using_resolution`,
5147    /// so a device that only offers 2048 would otherwise fail to create the
5148    /// texture outright.
5149    max_size: u32,
5150    cursor_x: u32,
5151    cursor_y: u32,
5152    row_height: u32,
5153    upload_scratch: Vec<u8>,
5154}
5155
5156impl TextGlyphAtlas {
5157    fn new(
5158        device: &wgpu::Device,
5159        image_layout: &wgpu::BindGroupLayout,
5160        sampler: &wgpu::Sampler,
5161        size: u32,
5162    ) -> Self {
5163        let max_size = TEXT_GLYPH_ATLAS_MAX_SIZE.min(device.limits().max_texture_dimension_2d);
5164        let size = size.clamp(TEXT_GLYPH_ATLAS_MIN_SIZE.min(max_size), max_size);
5165        let texture = Self::create_texture(device, size);
5166        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
5167        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5168            label: Some("Text Glyph Atlas Bind Group"),
5169            layout: image_layout,
5170            entries: &[
5171                wgpu::BindGroupEntry {
5172                    binding: 0,
5173                    resource: wgpu::BindingResource::TextureView(&view),
5174                },
5175                wgpu::BindGroupEntry {
5176                    binding: 1,
5177                    resource: wgpu::BindingResource::Sampler(sampler),
5178                },
5179            ],
5180        });
5181        Self {
5182            texture,
5183            _view: view,
5184            bind_group,
5185            entries: BoundedLruCache::with_capacity_at_least_one(MAX_TEXT_GLYPH_ATLAS_ITEMS),
5186            generation: 0,
5187            size,
5188            max_size,
5189            cursor_x: TEXT_GLYPH_ATLAS_PADDING,
5190            cursor_y: TEXT_GLYPH_ATLAS_PADDING,
5191            row_height: 0,
5192            upload_scratch: Vec::new(),
5193        }
5194    }
5195
5196    fn create_texture(device: &wgpu::Device, size: u32) -> wgpu::Texture {
5197        device.create_texture(&wgpu::TextureDescriptor {
5198            label: Some("Text Glyph Atlas Texture"),
5199            size: wgpu::Extent3d {
5200                width: size,
5201                height: size,
5202                depth_or_array_layers: 1,
5203            },
5204            mip_level_count: 1,
5205            sample_count: 1,
5206            dimension: wgpu::TextureDimension::D2,
5207            format: wgpu::TextureFormat::R8Unorm,
5208            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5209            view_formats: &[],
5210        })
5211    }
5212
5213    /// Throws every cached glyph away and starts over on a texture one doubling
5214    /// larger, up to [`TextGlyphAtlas::max_size`].
5215    ///
5216    /// `allocate` is a one-way shelf cursor with no compaction, so the only
5217    /// recovery from a full atlas is to start again — and starting again at the
5218    /// same size makes a workload whose live glyph set genuinely does not fit
5219    /// re-raster every glyph every frame. Treating each overflow as the signal
5220    /// to double means the atlas converges on the size the workload actually
5221    /// needs: a text-heavy screen reaches the old fixed 4096 after at most three
5222    /// resets and behaves identically from then on, while a watch face that
5223    /// never overflows never pays for space it will not use.
5224    ///
5225    /// Bumping the generation is what invalidates the cached glyph runs, whose
5226    /// UVs are normalised against the previous size and would otherwise sample
5227    /// the wrong part of the new texture.
5228    fn reset(
5229        &mut self,
5230        device: &wgpu::Device,
5231        image_layout: &wgpu::BindGroupLayout,
5232        sampler: &wgpu::Sampler,
5233    ) {
5234        let generation = self.generation.wrapping_add(1);
5235        let grown = next_glyph_atlas_size(self.size, self.max_size);
5236        let mut next = Self::new(device, image_layout, sampler, grown);
5237        next.generation = generation;
5238        *self = next;
5239    }
5240
5241    fn generation(&self) -> u64 {
5242        self.generation
5243    }
5244
5245    fn size(&self) -> u32 {
5246        self.size
5247    }
5248
5249    fn entry(&mut self, key: &SoftwareGlyphAtlasKey) -> Option<GlyphAtlasEntry> {
5250        self.entries.get(key).copied()
5251    }
5252
5253    fn allocate(&mut self, width: u32, height: u32) -> Option<GlyphAtlasEntry> {
5254        if width == 0
5255            || height == 0
5256            || width + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
5257            || height + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
5258        {
5259            return None;
5260        }
5261
5262        if self.cursor_x + width + TEXT_GLYPH_ATLAS_PADDING > self.size {
5263            self.cursor_x = TEXT_GLYPH_ATLAS_PADDING;
5264            self.cursor_y = self
5265                .cursor_y
5266                .saturating_add(self.row_height)
5267                .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
5268            self.row_height = 0;
5269        }
5270        if self.cursor_y + height + TEXT_GLYPH_ATLAS_PADDING > self.size {
5271            return None;
5272        }
5273
5274        let entry = GlyphAtlasEntry {
5275            x: self.cursor_x,
5276            y: self.cursor_y,
5277            width,
5278            height,
5279        };
5280        self.cursor_x = self
5281            .cursor_x
5282            .saturating_add(width)
5283            .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
5284        self.row_height = self.row_height.max(height);
5285        Some(entry)
5286    }
5287
5288    fn upload_glyph(
5289        &mut self,
5290        key: SoftwareGlyphAtlasKey,
5291        glyph: &SoftwareGlyphAtlasGlyph,
5292        queue: &wgpu::Queue,
5293        executor: &mut WgpuFrameGraphExecutor,
5294        frame_stats: &mut gpu_stats::FrameStats,
5295    ) -> Option<GlyphAtlasEntry> {
5296        if let Some(entry) = self.entry(&key) {
5297            frame_stats.record_text_glyph_atlas_hit();
5298            return Some(entry);
5299        }
5300
5301        let width = u32::try_from(glyph.mask.width).ok()?;
5302        let height = u32::try_from(glyph.mask.height).ok()?;
5303        let entry = self.allocate(width, height)?;
5304        self.upload_scratch.clear();
5305        self.upload_scratch.reserve(
5306            glyph
5307                .mask
5308                .alpha
5309                .len()
5310                .saturating_sub(self.upload_scratch.capacity()),
5311        );
5312        self.upload_scratch.extend(
5313            glyph
5314                .mask
5315                .alpha
5316                .iter()
5317                .map(|alpha| (alpha.clamp(0.0, 1.0) * 255.0).round() as u8),
5318        );
5319
5320        let upload_stats = executor.upload_texture(
5321            queue,
5322            wgpu::TexelCopyTextureInfo {
5323                texture: &self.texture,
5324                mip_level: 0,
5325                origin: wgpu::Origin3d {
5326                    x: entry.x,
5327                    y: entry.y,
5328                    z: 0,
5329                },
5330                aspect: wgpu::TextureAspect::All,
5331            },
5332            &self.upload_scratch,
5333            wgpu::TexelCopyBufferLayout {
5334                offset: 0,
5335                bytes_per_row: Some(entry.width),
5336                rows_per_image: Some(entry.height),
5337            },
5338            wgpu::Extent3d {
5339                width: entry.width,
5340                height: entry.height,
5341                depth_or_array_layers: 1,
5342            },
5343        );
5344        frame_stats.record_command_stats(upload_stats);
5345        frame_stats.record_text_glyph_atlas_miss(entry.width, entry.height);
5346        self.entries.put(key, entry);
5347        Some(entry)
5348    }
5349}
5350
5351struct ImageDrawCmd {
5352    index_start: u32,
5353    scissor: (u32, u32, u32, u32),
5354    image_id: u64,
5355    sampling: ImageSampling,
5356}
5357
5358#[derive(Clone, Copy)]
5359enum GlyphDrawSource {
5360    Shared {
5361        index_start: u32,
5362        index_count: u32,
5363    },
5364    #[cfg(not(target_arch = "wasm32"))]
5365    Retained {
5366        cache_key: TextGlyphRunCacheKey,
5367        uniform_slot: usize,
5368    },
5369}
5370
5371#[derive(Clone, Copy)]
5372struct GlyphDrawCmd {
5373    source: GlyphDrawSource,
5374    scissor: (u32, u32, u32, u32),
5375}
5376
5377impl GlyphDrawCmd {
5378    fn shared(index_start: u32, index_count: u32, scissor: (u32, u32, u32, u32)) -> Self {
5379        Self {
5380            source: GlyphDrawSource::Shared {
5381                index_start,
5382                index_count,
5383            },
5384            scissor,
5385        }
5386    }
5387
5388    #[cfg(not(target_arch = "wasm32"))]
5389    fn retained(
5390        cache_key: TextGlyphRunCacheKey,
5391        uniform_slot: usize,
5392        scissor: (u32, u32, u32, u32),
5393    ) -> Self {
5394        Self {
5395            source: GlyphDrawSource::Retained {
5396                cache_key,
5397                uniform_slot,
5398            },
5399            scissor,
5400        }
5401    }
5402}
5403
5404#[derive(Clone, Copy, Debug, PartialEq)]
5405struct ImageUvRect {
5406    min: [f32; 2],
5407    max: [f32; 2],
5408    sample_bounds: [f32; 4],
5409}
5410
5411// Text raster cache is owned by GpuRenderer and backed by software text images
5412// between measurement and rendering to eliminate duplicate text shaping
5413
5414/// Persistent GPU buffers for batched shape rendering. There is no vertex or
5415/// index buffer: the shape shader pulls quad corners straight out of
5416/// `ShapeData` by `vertex_index`, so the batch is drawn unindexed.
5417struct ShapeBatchBuffers {
5418    shape_buffer: wgpu::Buffer,
5419    gradient_buffer: wgpu::Buffer,
5420    bind_group: wgpu::BindGroup,
5421    shape_capacity: usize,
5422    gradient_capacity: usize,
5423    batch_limits: ShapeBatchLimits,
5424}
5425
5426#[cfg(target_arch = "wasm32")]
5427struct UniformBatchBuffer {
5428    buffer: wgpu::Buffer,
5429    bind_group: wgpu::BindGroup,
5430}
5431
5432#[cfg(target_arch = "wasm32")]
5433struct ImageBatchBuffers {
5434    vertex_buffer: wgpu::Buffer,
5435    index_buffer: wgpu::Buffer,
5436    vertex_capacity: usize,
5437    index_capacity: usize,
5438}
5439
5440#[derive(Clone, Copy, Debug, PartialEq)]
5441struct ViewportUniformParams {
5442    width: u32,
5443    height: u32,
5444    offset: [f32; 2],
5445}
5446
5447#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5448#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
5449enum UploadTarget {
5450    Uniform,
5451    ShapeData,
5452    ShapeGradient,
5453    ImageVertex,
5454    ImageIndex,
5455    #[cfg(not(target_arch = "wasm32"))]
5456    RetainedGlyphUniform,
5457    /// The shared replay-transform buffer; copies land at each slot's fixed
5458    /// 256-byte-aligned offset.
5459    #[cfg(not(target_arch = "wasm32"))]
5460    ReplayTransform,
5461    /// A replay slot's retained paint buffer (color patches land here).
5462    #[cfg(not(target_arch = "wasm32"))]
5463    ReplayPaintData(u32),
5464}
5465
5466#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5467#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
5468struct PendingBufferCopy {
5469    source_offset: u64,
5470    target_offset: u64,
5471    size: u64,
5472    target: UploadTarget,
5473}
5474
5475#[derive(Default)]
5476struct StagedBufferUploads {
5477    bytes: Vec<u8>,
5478    copies: Vec<PendingBufferCopy>,
5479}
5480
5481impl StagedBufferUploads {
5482    fn clear(&mut self) {
5483        self.bytes.clear();
5484        self.copies.clear();
5485    }
5486
5487    fn shrink_retained_capacity(&mut self, max_bytes: usize, max_copies: usize) -> bool {
5488        let mut shrunk = false;
5489        if self.bytes.len() <= max_bytes && self.bytes.capacity() > max_bytes {
5490            self.bytes.shrink_to(max_bytes);
5491            shrunk = true;
5492        }
5493        if self.copies.len() <= max_copies && self.copies.capacity() > max_copies {
5494            self.copies.shrink_to(max_copies);
5495            shrunk = true;
5496        }
5497        shrunk
5498    }
5499
5500    fn is_empty(&self) -> bool {
5501        self.copies.is_empty()
5502    }
5503
5504    #[cfg(test)]
5505    fn payload_for_copy(&self, copy: PendingBufferCopy) -> &[u8] {
5506        let start = copy.source_offset as usize;
5507        let end = start + copy.size as usize;
5508        &self.bytes[start..end]
5509    }
5510
5511    #[cfg(not(target_arch = "wasm32"))]
5512    fn stage(&mut self, target: UploadTarget, bytes: &[u8]) {
5513        self.stage_at(target, 0, bytes);
5514    }
5515
5516    /// Records a GPU copy whose source bytes were already written into the
5517    /// frame upload buffer (via `Queue::write_buffer_with`), so nothing is
5518    /// appended to `bytes`. `source_offset` is relative to the same base the
5519    /// caller later passes to `flush_staged_uploads_at`.
5520    #[cfg(not(target_arch = "wasm32"))]
5521    fn record_upload_copy(
5522        &mut self,
5523        target: UploadTarget,
5524        source_offset: u64,
5525        target_offset: u64,
5526        size: u64,
5527    ) {
5528        if size == 0 {
5529            return;
5530        }
5531        self.copies.push(PendingBufferCopy {
5532            source_offset,
5533            target_offset,
5534            size,
5535            target,
5536        });
5537    }
5538
5539    #[cfg(not(target_arch = "wasm32"))]
5540    fn stage_at(&mut self, target: UploadTarget, target_offset: u64, bytes: &[u8]) {
5541        if bytes.is_empty() {
5542            return;
5543        }
5544
5545        debug_assert_eq!(
5546            bytes.len() % wgpu::COPY_BUFFER_ALIGNMENT as usize,
5547            0,
5548            "buffer uploads must be aligned to copy requirements"
5549        );
5550
5551        let aligned_offset = align_usize_to(self.bytes.len(), wgpu::COPY_BUFFER_ALIGNMENT as usize);
5552        if aligned_offset > self.bytes.len() {
5553            self.bytes.resize(aligned_offset, 0);
5554        }
5555
5556        let source_offset = self.bytes.len() as u64;
5557        self.bytes.extend_from_slice(bytes);
5558        self.copies.push(PendingBufferCopy {
5559            source_offset,
5560            target_offset,
5561            size: bytes.len() as u64,
5562            target,
5563        });
5564    }
5565
5566    fn truncate(&mut self, bytes_len: usize, copies_len: usize) {
5567        self.bytes.truncate(bytes_len);
5568        self.copies.truncate(copies_len);
5569    }
5570}
5571
5572/// The fresh-batch entry list for the shape bind group layout: the batch's
5573/// own data buffers, the shared identity similarity buffer, and — storage
5574/// mode only, where the layout carries the paint entry — the renderer-wide
5575/// dummy paint buffer (fresh draws leave `paint_select` at 0.0).
5576fn shape_batch_bind_group_entries<'a>(
5577    shape_buffer: &'a wgpu::Buffer,
5578    gradient_buffer: &'a wgpu::Buffer,
5579    similarity_buffer: &'a wgpu::Buffer,
5580    paint_buffer: Option<&'a wgpu::Buffer>,
5581) -> Vec<wgpu::BindGroupEntry<'a>> {
5582    let mut entries = vec![
5583        wgpu::BindGroupEntry {
5584            binding: 0,
5585            resource: shape_buffer.as_entire_binding(),
5586        },
5587        wgpu::BindGroupEntry {
5588            binding: 1,
5589            resource: gradient_buffer.as_entire_binding(),
5590        },
5591        wgpu::BindGroupEntry {
5592            binding: 2,
5593            resource: similarity_buffer.as_entire_binding(),
5594        },
5595    ];
5596    if let Some(paint_buffer) = paint_buffer {
5597        entries.push(wgpu::BindGroupEntry {
5598            binding: 3,
5599            resource: paint_buffer.as_entire_binding(),
5600        });
5601    }
5602    entries
5603}
5604
5605impl ShapeBatchBuffers {
5606    fn new(
5607        device: &wgpu::Device,
5608        bind_group_layout: &wgpu::BindGroupLayout,
5609        similarity_buffer: &wgpu::Buffer,
5610        paint_buffer: Option<&wgpu::Buffer>,
5611        batch_limits: ShapeBatchLimits,
5612    ) -> Self {
5613        debug_assert_eq!(
5614            paint_buffer.is_some(),
5615            batch_limits.storage,
5616            "the paint binding exists exactly when the layout is in storage mode"
5617        );
5618        let initial_shape_cap = batch_limits.initial_shape_capacity();
5619        let initial_gradient_cap = batch_limits.initial_gradient_capacity();
5620
5621        let shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5622            label: Some("Shape Data Buffer"),
5623            size: (std::mem::size_of::<ShapeData>() * initial_shape_cap) as u64,
5624            usage: batch_limits.data_buffer_usage(),
5625            mapped_at_creation: false,
5626        });
5627
5628        let gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5629            label: Some("Gradient Buffer"),
5630            size: (std::mem::size_of::<GradientStop>() * initial_gradient_cap) as u64,
5631            usage: batch_limits.data_buffer_usage(),
5632            mapped_at_creation: false,
5633        });
5634
5635        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5636            label: Some("Shape Bind Group"),
5637            layout: bind_group_layout,
5638            entries: &shape_batch_bind_group_entries(
5639                &shape_buffer,
5640                &gradient_buffer,
5641                similarity_buffer,
5642                paint_buffer,
5643            ),
5644        });
5645
5646        Self {
5647            shape_buffer,
5648            gradient_buffer,
5649            bind_group,
5650            shape_capacity: initial_shape_cap,
5651            gradient_capacity: initial_gradient_cap,
5652            batch_limits,
5653        }
5654    }
5655
5656    /// Ensure buffers have enough capacity, resizing if needed.
5657    /// Clamps growth to prevent excessive allocations for huge scenes.
5658    fn ensure_capacity(
5659        &mut self,
5660        device: &wgpu::Device,
5661        bind_group_layout: &wgpu::BindGroupLayout,
5662        similarity_buffer: &wgpu::Buffer,
5663        paint_buffer: Option<&wgpu::Buffer>,
5664        shapes_needed: usize,
5665        gradients_needed: usize,
5666    ) {
5667        let mut need_bind_group_update = false;
5668
5669        // In uniform mode the shape and gradient buffers start at the cap
5670        // (the shader's fixed-size array length) so these never fire; in
5671        // storage mode they double toward the cap as scenes demand.
5672        if shapes_needed > self.shape_capacity
5673            && self.shape_capacity < self.batch_limits.max_shapes_per_batch
5674        {
5675            let new_cap = shapes_needed
5676                .next_power_of_two()
5677                .min(self.batch_limits.max_shapes_per_batch);
5678            self.shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5679                label: Some("Shape Data Buffer"),
5680                size: (std::mem::size_of::<ShapeData>() * new_cap) as u64,
5681                usage: self.batch_limits.data_buffer_usage(),
5682                mapped_at_creation: false,
5683            });
5684            self.shape_capacity = new_cap;
5685            need_bind_group_update = true;
5686        }
5687
5688        if gradients_needed > self.gradient_capacity
5689            && self.gradient_capacity < self.batch_limits.max_gradient_stops
5690        {
5691            let new_cap = gradients_needed
5692                .max(1)
5693                .next_power_of_two()
5694                .min(self.batch_limits.max_gradient_stops);
5695            self.gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5696                label: Some("Gradient Buffer"),
5697                size: (std::mem::size_of::<GradientStop>() * new_cap) as u64,
5698                usage: self.batch_limits.data_buffer_usage(),
5699                mapped_at_creation: false,
5700            });
5701            self.gradient_capacity = new_cap;
5702            need_bind_group_update = true;
5703        }
5704
5705        if need_bind_group_update {
5706            self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5707                label: Some("Shape Bind Group"),
5708                layout: bind_group_layout,
5709                entries: &shape_batch_bind_group_entries(
5710                    &self.shape_buffer,
5711                    &self.gradient_buffer,
5712                    similarity_buffer,
5713                    paint_buffer,
5714                ),
5715            });
5716        }
5717    }
5718}
5719
5720#[cfg(target_arch = "wasm32")]
5721impl UniformBatchBuffer {
5722    fn new(device: &wgpu::Device, bind_group_layout: &wgpu::BindGroupLayout) -> Self {
5723        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
5724            label: Some("Viewport Uniform Batch Buffer"),
5725            size: std::mem::size_of::<Uniforms>() as u64,
5726            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5727            mapped_at_creation: false,
5728        });
5729        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5730            label: Some("Viewport Uniform Batch Bind Group"),
5731            layout: bind_group_layout,
5732            entries: &[wgpu::BindGroupEntry {
5733                binding: 0,
5734                resource: buffer.as_entire_binding(),
5735            }],
5736        });
5737        Self { buffer, bind_group }
5738    }
5739}
5740
5741#[cfg(target_arch = "wasm32")]
5742impl ImageBatchBuffers {
5743    fn new(device: &wgpu::Device) -> Self {
5744        let vertex_capacity = 4;
5745        let index_capacity = 6;
5746        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5747            label: Some("Image Vertex Batch Buffer"),
5748            size: (std::mem::size_of::<Vertex>() * vertex_capacity) as u64,
5749            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5750            mapped_at_creation: false,
5751        });
5752        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5753            label: Some("Image Index Batch Buffer"),
5754            size: (std::mem::size_of::<u32>() * index_capacity) as u64,
5755            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5756            mapped_at_creation: false,
5757        });
5758        Self {
5759            vertex_buffer,
5760            index_buffer,
5761            vertex_capacity,
5762            index_capacity,
5763        }
5764    }
5765
5766    fn ensure_capacity(
5767        &mut self,
5768        device: &wgpu::Device,
5769        vertices_needed: usize,
5770        indices_needed: usize,
5771    ) {
5772        let hard_max_bytes = HARD_MAX_BUFFER_MB * 1024 * 1024;
5773        if vertices_needed > self.vertex_capacity {
5774            let desired = vertices_needed.next_power_of_two();
5775            let max_count = hard_max_bytes / std::mem::size_of::<Vertex>();
5776            let new_cap = desired.min(max_count);
5777            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5778                label: Some("Image Vertex Batch Buffer"),
5779                size: (std::mem::size_of::<Vertex>() * new_cap) as u64,
5780                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5781                mapped_at_creation: false,
5782            });
5783            self.vertex_capacity = new_cap;
5784        }
5785        if indices_needed > self.index_capacity {
5786            let desired = indices_needed.next_power_of_two();
5787            let max_count = hard_max_bytes / std::mem::size_of::<u32>();
5788            let new_cap = desired.min(max_count);
5789            self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5790                label: Some("Image Index Batch Buffer"),
5791                size: (std::mem::size_of::<u32>() * new_cap) as u64,
5792                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5793                mapped_at_creation: false,
5794            });
5795            self.index_capacity = new_cap;
5796        }
5797    }
5798}
5799
5800// Text image cache keys are local to rasterized WGPU text batches
5801
5802struct CompositionTarget {
5803    target: OffscreenTarget,
5804    output_bind_group: wgpu::BindGroup,
5805}
5806
5807#[derive(Clone, Copy)]
5808enum OutputMode {
5809    Display,
5810    Screenshot,
5811}
5812
5813pub struct GpuRenderer {
5814    pub(crate) device: Arc<wgpu::Device>,
5815    pub(crate) queue: Arc<wgpu::Queue>,
5816    /// Uncaptured-error record shared with the handler installed on
5817    /// `device` at construction ([`DeviceErrorSentry`];
5818    /// `CRANPOSE_SURVIVE_GPU_ERRORS` kill switch). Poisoned by any
5819    /// uncaptured error; the head of [`Self::render`] answers each
5820    /// poisoning with one cancelled packet.
5821    device_errors: Arc<DeviceErrorSentry>,
5822    /// This instance's renderer epoch, stamped by `init_gpu` at
5823    /// construction. A packet whose `renderer_epoch` differs was built
5824    /// against another instance and is cancelled at the head of
5825    /// [`Self::render`], never drawn.
5826    renderer_epoch: u64,
5827    /// The producer feed generation this store's slot universe belongs to:
5828    /// seeded at construction, advanced by `consume_replay_ops` when a
5829    /// higher-generation batch arrives (the batch itself carries the
5830    /// retirement releases). The store never reads the producer's
5831    /// thread-local — this field is its only generation authority.
5832    #[cfg(not(target_arch = "wasm32"))]
5833    store_feed_generation: u64,
5834    composition_format: wgpu::TextureFormat,
5835    #[cfg(not(target_arch = "wasm32"))]
5836    display_format: wgpu::TextureFormat,
5837    composition_target: Option<CompositionTarget>,
5838    output_converter: OutputConverter,
5839    screenshot_converter: OutputConverter,
5840    adapter_backend: wgpu::Backend,
5841    shape_batch_limits: ShapeBatchLimits,
5842    /// `Some` exactly when the device granted [`wgpu::Features::PIPELINE_CACHE`]
5843    /// (Vulkan; the platform layer requests it where the adapter offers it).
5844    /// Every pipeline creation in this renderer passes it so the driver can
5845    /// reuse compiled code across creates — and across launches once
5846    /// [`crate::pipeline_disk_cache`] persists the blob.
5847    pipeline_cache: Option<wgpu::PipelineCache>,
5848    pipeline: PassPipeline,
5849    pipeline_dst_out: PassPipeline,
5850    /// `fs_solid` twin of `pipeline` (SrcOver only), for gradient-free draws.
5851    pipeline_solid: PassPipeline,
5852    /// `Some` exactly in storage mode: the retained-mesh pipeline (`vs_mesh`
5853    /// over a vertex buffer) that replay slots with a captured arc mesh draw
5854    /// through. Uniform-mode devices never host retained slots.
5855    #[cfg(not(target_arch = "wasm32"))]
5856    mesh_pipeline: PassPipeline,
5857    /// `Some` exactly when this renderer latched the instanced-quad path at
5858    /// construction (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0). Read
5859    /// ONCE per renderer lifetime — cached retained bundles encode the
5860    /// selection, so it must never move under them (see
5861    /// [`instanced_quads_enabled`]).
5862    #[cfg(not(target_arch = "wasm32"))]
5863    instanced_quads: Option<InstancedQuadPipelines>,
5864    #[cfg(not(target_arch = "wasm32"))]
5865    segment_capture_pipelines: SegmentCapturePipelines,
5866    uniform_bind_group_layout: wgpu::BindGroupLayout,
5867    shape_bind_group_layout: wgpu::BindGroupLayout,
5868    /// `Some` exactly in storage mode: the 16-byte stand-in every fresh
5869    /// batch binds at the paint entry (see `shape_batch_bind_group_entries`).
5870    dummy_paint_buffer: Option<wgpu::Buffer>,
5871    /// Shared identity binding for `@group(1) @binding(2)`: every freshly
5872    /// converted shape batch draws untransformed through this one buffer.
5873    identity_similarity_buffer: wgpu::Buffer,
5874    #[cfg(not(target_arch = "wasm32"))]
5875    replay_slots: ReplaySlotStore,
5876    image_pipeline: PassPipeline,
5877    image_pipeline_dst_out: PassPipeline,
5878    glyph_atlas_pipeline: PassPipeline,
5879    #[cfg(not(target_arch = "wasm32"))]
5880    retained_glyph_atlas_pipeline: PassPipeline,
5881    image_bind_group_layout: wgpu::BindGroupLayout,
5882    #[cfg(not(target_arch = "wasm32"))]
5883    retained_glyph_uniform_bind_group_layout: wgpu::BindGroupLayout,
5884    image_nearest_sampler: wgpu::Sampler,
5885    image_linear_sampler: wgpu::Sampler,
5886    text_fonts: SoftwareTextFontSet,
5887    // Persistent GPU buffers (reused across frames)
5888    #[cfg(not(target_arch = "wasm32"))]
5889    upload_buffer: wgpu::Buffer,
5890    #[cfg(not(target_arch = "wasm32"))]
5891    uniform_buffer: wgpu::Buffer,
5892    #[cfg(not(target_arch = "wasm32"))]
5893    uniform_bind_group: wgpu::BindGroup,
5894    #[cfg(not(target_arch = "wasm32"))]
5895    shape_buffers: ShapeBatchBuffers,
5896    #[cfg(not(target_arch = "wasm32"))]
5897    image_vertex_buffer: wgpu::Buffer,
5898    #[cfg(not(target_arch = "wasm32"))]
5899    image_index_buffer: wgpu::Buffer,
5900    #[cfg(not(target_arch = "wasm32"))]
5901    retained_glyph_uniform_buffer: wgpu::Buffer,
5902    #[cfg(not(target_arch = "wasm32"))]
5903    retained_glyph_uniform_bind_group: wgpu::BindGroup,
5904    #[cfg(not(target_arch = "wasm32"))]
5905    retained_glyph_uniform_stride: u64,
5906    #[cfg(not(target_arch = "wasm32"))]
5907    retained_glyph_uniform_capacity: usize,
5908    #[cfg(not(target_arch = "wasm32"))]
5909    retained_glyph_uniform_cursor: usize,
5910    #[cfg(target_arch = "wasm32")]
5911    wasm_uniform_batches: Vec<UniformBatchBuffer>,
5912    #[cfg(target_arch = "wasm32")]
5913    wasm_uniform_batch_cursor: usize,
5914    #[cfg(target_arch = "wasm32")]
5915    wasm_shape_batches: Vec<ShapeBatchBuffers>,
5916    #[cfg(target_arch = "wasm32")]
5917    wasm_shape_batch_cursor: usize,
5918    #[cfg(target_arch = "wasm32")]
5919    wasm_image_batches: Vec<ImageBatchBuffers>,
5920    #[cfg(target_arch = "wasm32")]
5921    wasm_image_batch_cursor: usize,
5922    image_texture_cache: BoundedLruCache<u64, CachedImageTexture>,
5923    /// Total `CachedImageTexture::bytes` currently in the cache.
5924    image_texture_cache_bytes: usize,
5925    text_image_cache: BoundedLruCache<TextImageCacheKey, CachedTextImage>,
5926    text_glyph_atlas: TextGlyphAtlas,
5927    text_glyph_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedTextGlyphRun>,
5928    #[cfg(not(target_arch = "wasm32"))]
5929    text_glyph_gpu_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedGpuTextGlyphRun>,
5930    text_glyph_mask_cache: SoftwareGlyphRasterCache,
5931    text_line_index_cache: TextLineIndexCache,
5932    scratch_shape_data: Vec<ShapeData>,
5933    scratch_gradients: Vec<GradientStop>,
5934    scratch_image_vertices: Vec<Vertex>,
5935    scratch_image_indices: Vec<u32>,
5936    scratch_image_cmds: Vec<ImageDrawCmd>,
5937    scratch_glyph_cmds: Vec<GlyphDrawCmd>,
5938    scratch_text_glyph_run: Vec<SoftwareGlyphAtlasRunGlyph>,
5939    scratch_text_glyph_placements: Vec<SoftwareGlyphAtlasPlacement>,
5940    scratch_text_glyph_quads: Vec<CachedTextGlyphQuad>,
5941    scratch_segment_items: Vec<(usize, SegmentDrawItem)>,
5942    scratch_effect_ranges: Vec<Range<usize>>,
5943    scratch_layer_events: Vec<LayerEvent>,
5944    staged_uploads: StagedBufferUploads,
5945    frame_graph_executor: WgpuFrameGraphExecutor,
5946    deferred_offscreen_releases: Vec<OffscreenTarget>,
5947    effect_renderer: EffectRenderer,
5948    layer_surface_cache: LayerSurfaceCache,
5949    observed_scene_range_cache_misses: BoundedLruCache<LayerRasterCacheKey, ()>,
5950    shadow_surface_cache: BoundedLruCache<ShadowSurfaceCacheKey, CachedShadowSurface>,
5951    shadow_surface_cache_bytes: u64,
5952    frame_stats: gpu_stats::FrameStats,
5953    last_frame_stats: Option<gpu_stats::FrameStatsSnapshot>,
5954    pending_frame_warmup_frames: u8,
5955    frame_count: u64,
5956    gpu_stats_enabled: bool,
5957    warning_state: RendererWarningState,
5958    #[cfg(not(target_arch = "wasm32"))]
5959    replay_upload_stats: ReplayUploadStats,
5960    #[cfg(not(target_arch = "wasm32"))]
5961    segment_encode_stats: SegmentEncodeStats,
5962    /// The frame's replay recolor patches, parked here by
5963    /// `consume_replay_ops` until the retained prepare arms drain them
5964    /// (`stage_replay_patches`). The vec this frame's ops displace is last
5965    /// frame's, already drained empty, and returns to the producer with
5966    /// the ack — capacity ping-pongs planner queue → packet ops → here →
5967    /// ack return, so neither side allocates per frame (P4b).
5968    #[cfg(not(target_arch = "wasm32"))]
5969    replay_color_patches: Vec<crate::scene::ColorPatch>,
5970    /// Drain arena for `replay_color_patches`: `stage_replay_patches`
5971    /// swaps against this instead of `mem::take`, so both keep their
5972    /// high-water capacity across frames. Always empty between drains.
5973    #[cfg(not(target_arch = "wasm32"))]
5974    color_patch_scratch: Vec<crate::scene::ColorPatch>,
5975    /// Capture staging scratch for `capture_replay_slot`: the converted
5976    /// `ShapeData` records and gradient stops are built here, copied into
5977    /// the slot's fresh GPU buffers, and the allocations survive to the
5978    /// next capture — a re-partition frame captures one slot per segment
5979    /// and used to allocate both vectors per slot.
5980    #[cfg(not(target_arch = "wasm32"))]
5981    replay_capture_shape_scratch: Vec<ShapeData>,
5982    /// The gradient-stop half of the capture staging scratch.
5983    #[cfg(not(target_arch = "wasm32"))]
5984    replay_capture_gradient_scratch: Vec<GradientStop>,
5985    /// Recycled confirmations buffer for the next [`crate::frame_packet::ReplayAck`]:
5986    /// `consume_replay_ops` fills it, the planner drains it in `apply_ack`,
5987    /// and the render loop hands the emptied vec (capacity intact) back
5988    /// here — the ack channel's half of the P4b no-allocation contract.
5989    #[cfg(not(target_arch = "wasm32"))]
5990    replay_ack_confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
5991    /// Lifetime count of replay-ops batches dropped whole by the
5992    /// generation check in `consume_replay_ops` — fail-closed against ops
5993    /// planned under a slot universe this store no longer holds.
5994    /// Synchronously impossible today; structural for the pipeline split.
5995    #[cfg(not(target_arch = "wasm32"))]
5996    replay_generation_drops: u64,
5997    /// Cached render bundles for maximal consecutive retained stretches in
5998    /// the fused segment pass (`CRANPOSE_RETAINED_BUNDLES` kill switch).
5999    #[cfg(not(target_arch = "wasm32"))]
6000    retained_bundle_cache: RetainedBundleCache,
6001    /// Per-frame scratch for transient rim band meshes (`rim_mesh_band`):
6002    /// appended per fused chunk, cleared at the top of every frame. Index
6003    /// values are absolute into the frame's vertex list, so later chunks
6004    /// append without rebasing.
6005    #[cfg(not(target_arch = "wasm32"))]
6006    rim_mesh_vertices: Vec<MeshVertex>,
6007    #[cfg(not(target_arch = "wasm32"))]
6008    rim_mesh_indices: Vec<u32>,
6009    /// Fixed-capacity GPU twins of the rim scratch vecs, created lazily on
6010    /// the first rim ([`RIM_MESH_VERTEX_CAPACITY`] /
6011    /// [`RIM_MESH_INDEX_CAPACITY`]). NEVER recreated mid-frame: draws are
6012    /// encoded before submit, so a replacement buffer would orphan every
6013    /// already-encoded rim draw.
6014    #[cfg(not(target_arch = "wasm32"))]
6015    rim_mesh_vertex_buffer: Option<wgpu::Buffer>,
6016    #[cfg(not(target_arch = "wasm32"))]
6017    rim_mesh_index_buffer: Option<wgpu::Buffer>,
6018    /// Counts of scratch vertices/indices already uploaded this frame, so
6019    /// each fused chunk uploads only its newly appended region.
6020    #[cfg(not(target_arch = "wasm32"))]
6021    rim_mesh_uploaded_vertices: usize,
6022    #[cfg(not(target_arch = "wasm32"))]
6023    rim_mesh_uploaded_indices: usize,
6024    /// Lifetime count of rims drawn as band meshes — the test hook behind
6025    /// [`Self::rim_meshes_emitted`].
6026    #[cfg(not(target_arch = "wasm32"))]
6027    rim_meshes_emitted: u64,
6028    /// Submitted fill-area accounting (`CRANPOSE_FILL_DIAG`); idle unless
6029    /// the flag is set.
6030    #[cfg(not(target_arch = "wasm32"))]
6031    fill_area_diag: FillAreaDiag,
6032    /// Opaque static leading-span cache (`CRANPOSE_STATIC_SPAN` kill
6033    /// switch): the frame's byte-stable leading draws as one cached
6034    /// full-target blit.
6035    #[cfg(not(target_arch = "wasm32"))]
6036    static_span: StaticSpanCache,
6037    /// Retained-segment surface cache (`CRANPOSE_SEGMENT_SURFACE` opt-in,
6038    /// see [`crate::segment_surface`]): qualifying retained spans rendered
6039    /// once into pooled offscreens and re-drawn per frame as one rotated/
6040    /// scaled textured quad each.
6041    #[cfg(not(target_arch = "wasm32"))]
6042    segment_surfaces: SegmentSurfaceCache,
6043    /// Display clip region cull (see [`crate::display_clip`]): the
6044    /// platform-provided visible region plus the per-size occluder/depth
6045    /// resources. Inert — nothing beyond the enum is ever populated —
6046    /// while the region is [`DisplayVisibleRegion::Full`].
6047    #[cfg(not(target_arch = "wasm32"))]
6048    display_clip: DisplayClipState,
6049}
6050
6051/// Cache key of the display-clip resources: the surface size and the
6052/// region whose complement the occluder was tessellated for.
6053#[cfg(not(target_arch = "wasm32"))]
6054type DisplayClipResourceKey = ((u32, u32), DisplayVisibleRegion);
6055
6056/// State of the display clip region cull, all renderer-side.
6057#[cfg(not(target_arch = "wasm32"))]
6058struct DisplayClipState {
6059    /// The visible region from
6060    /// [`GpuRenderer::set_display_visible_region`] — platform (or host)
6061    /// truth about the panel, never derived from app content. `Full` for
6062    /// every rectangular display; `InscribedCircle` is the round-display
6063    /// provider's value.
6064    visible_region: DisplayVisibleRegion,
6065    /// The view the current frame's packet renders to, set for the duration
6066    /// of [`GpuRenderer::render`]. The fused pass culls only when its
6067    /// target IS this view — full-frame-sized offscreen layer surfaces
6068    /// must render whole (their content can be transformed into view
6069    /// later), so size alone is not the test.
6070    frame_root_view: Option<wgpu::TextureView>,
6071    /// True exactly while a fused pass that carries the depth attachment is
6072    /// being encoded: every pipeline getter consults it to hand out the
6073    /// depth-tested variant, which keeps the dozens of draw sites (and the
6074    /// retained-bundle builder) untouched.
6075    pass_depth: Cell<bool>,
6076    /// Depth attachment + occluder geometry for the current (size, region)
6077    /// pair, or an inner `None` when the region's complement tessellation
6078    /// failed its conservative verification for this size (cull stays
6079    /// off; never retried until size or region changes).
6080    resources: Option<(DisplayClipResourceKey, Option<DisplayClipResources>)>,
6081    occluder_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
6082}
6083
6084#[cfg(not(target_arch = "wasm32"))]
6085impl DisplayClipState {
6086    fn new() -> Self {
6087        Self {
6088            visible_region: DisplayVisibleRegion::Full,
6089            frame_root_view: None,
6090            pass_depth: Cell::new(false),
6091            resources: None,
6092            occluder_pipeline: LazyGpuResource::new("display-clip/occluder"),
6093        }
6094    }
6095}
6096
6097/// Per-(surface-size, region) GPU resources of the display clip cull.
6098#[cfg(not(target_arch = "wasm32"))]
6099struct DisplayClipResources {
6100    /// `Depth16Unorm`, cleared each culled pass, stored never
6101    /// (`StoreOp::Discard`) — transient GMEM residency on tilers.
6102    depth_view: wgpu::TextureView,
6103    /// The region complement's conservative tessellation, NDC positions,
6104    /// triangle list.
6105    occluder_vertex_buffer: wgpu::Buffer,
6106    occluder_vertex_count: u32,
6107}
6108
6109/// Running totals for retained-slot patch uploads, the paint-bandwidth
6110/// instrument: recolors upload 16-byte paint records (plus gradient stop
6111/// spans), coalesced per slot between the lowest and highest patched
6112/// index, so `bytes` versus `ideal_bytes` (patched colors alone) is just
6113/// the untouched records inside each coalesced span.
6114#[cfg(not(target_arch = "wasm32"))]
6115#[derive(Default)]
6116struct ReplayUploadStats {
6117    calls: u64,
6118    patched_calls: u64,
6119    patches: u64,
6120    slots: u64,
6121    records: u64,
6122    bytes: u64,
6123    ideal_bytes: u64,
6124    max_frame_bytes: u64,
6125}
6126
6127#[cfg(not(target_arch = "wasm32"))]
6128impl ReplayUploadStats {
6129    /// One aggregate line roughly every few seconds: cheap enough to stay
6130    /// on unconditionally, which matters because the watch cannot take
6131    /// setprop-backed diag flags — its logcat is the only channel, and a
6132    /// measurement window must catch several lines. Counts every drain
6133    /// call (the drain runs several times per frame; only the first sees
6134    /// patches) so a target with zero paint traffic still reports an
6135    /// affirmative zero instead of silence, while the averages divide by
6136    /// PATCHED calls so they read as per-frame numbers.
6137    /// warn level: the platform loggers filter info on desktop.
6138    const REPORT_CALLS: u64 = 1024;
6139
6140    fn note_frame(&mut self, patches: u64, slots: u64, records: u64, bytes: u64, ideal: u64) {
6141        self.calls += 1;
6142        if patches > 0 {
6143            self.patched_calls += 1;
6144            self.patches += patches;
6145            self.slots += slots;
6146            self.records += records;
6147            self.bytes += bytes;
6148            self.ideal_bytes += ideal;
6149            self.max_frame_bytes = self.max_frame_bytes.max(bytes);
6150        }
6151        if self.calls >= Self::REPORT_CALLS {
6152            let patched = self.patched_calls.max(1);
6153            log::warn!(
6154                "[replay-upload] {} patched of {} drains: avg {:.1} KB/frame (max {:.1} KB), \
6155                 color-only would be {:.1} KB/frame; avg {} patches over {} records in {} slots",
6156                self.patched_calls,
6157                self.calls,
6158                self.bytes as f64 / patched as f64 / 1024.0,
6159                self.max_frame_bytes as f64 / 1024.0,
6160                self.ideal_bytes as f64 / patched as f64 / 1024.0,
6161                self.patches / patched,
6162                self.records / patched,
6163                self.slots / patched,
6164            );
6165            *self = Self::default();
6166        }
6167    }
6168}
6169
6170/// Aggregate cost of the fused native partition loop — the numbers a
6171/// parallel-encode decision needs: how many partitions each chunk carries
6172/// and how long the serial loop spends encoding them. Always-on for the
6173/// same reason as [`ReplayUploadStats`]: the watch takes no setprop diag
6174/// flags, so the line has to reach logcat on its own, and one warn every
6175/// [`Self::REPORT_CALLS`] chunks is bounded.
6176#[cfg(not(target_arch = "wasm32"))]
6177#[derive(Default)]
6178struct SegmentEncodeStats {
6179    calls: u64,
6180    partitions: u64,
6181    max_partitions: u64,
6182    encode_micros: u64,
6183    max_call_micros: u64,
6184}
6185
6186#[cfg(not(target_arch = "wasm32"))]
6187impl SegmentEncodeStats {
6188    const REPORT_CALLS: u64 = 1024;
6189
6190    fn note_call(&mut self, partitions: u64, micros: u64) {
6191        self.calls += 1;
6192        self.partitions += partitions;
6193        self.max_partitions = self.max_partitions.max(partitions);
6194        self.encode_micros += micros;
6195        self.max_call_micros = self.max_call_micros.max(micros);
6196        if self.calls >= Self::REPORT_CALLS {
6197            log::warn!(
6198                "[segment-encode] {} chunks: avg {:.1} partitions (max {}), \
6199                 avg {:.2} ms encode (max {:.2})",
6200                self.calls,
6201                self.partitions as f64 / self.calls as f64,
6202                self.max_partitions,
6203                self.encode_micros as f64 / self.calls as f64 / 1000.0,
6204                self.max_call_micros as f64 / 1000.0,
6205            );
6206            *self = Self::default();
6207        }
6208    }
6209}
6210
6211fn image_sampler_descriptor(sampling: ImageSampling) -> wgpu::SamplerDescriptor<'static> {
6212    let filter = match sampling {
6213        ImageSampling::Nearest => wgpu::FilterMode::Nearest,
6214        ImageSampling::Linear => wgpu::FilterMode::Linear,
6215    };
6216    wgpu::SamplerDescriptor {
6217        label: Some(match sampling {
6218            ImageSampling::Nearest => "Nearest Image Sampler",
6219            ImageSampling::Linear => "Linear Image Sampler",
6220        }),
6221        address_mode_u: wgpu::AddressMode::ClampToEdge,
6222        address_mode_v: wgpu::AddressMode::ClampToEdge,
6223        address_mode_w: wgpu::AddressMode::ClampToEdge,
6224        mag_filter: filter,
6225        min_filter: filter,
6226        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
6227        ..Default::default()
6228    }
6229}
6230
6231#[cfg(test)]
6232fn layer_raster_cache_candidate(
6233    layer: &LayerNode,
6234    root_scale: f32,
6235    has_backdrop_underlay: bool,
6236    allow_runtime_cache: bool,
6237) -> Option<(LayerRasterCacheKey, Rect)> {
6238    let mut layer_surface_requirements_cache = HashMap::new();
6239    let surface_requirements =
6240        layer_surface_requirements_cached(layer, &mut layer_surface_requirements_cache);
6241    let runtime_cache_is_safe = allow_runtime_cache
6242        && surface_requirements
6243            .surface_requirements
6244            .has_isolating_requirement()
6245        && !surface_requirements.contains_runtime_shader;
6246    let cache_is_allowed = layer.cache_policy == CachePolicy::Auto
6247        || (allow_runtime_cache && surface_requirements.has_renderer_forced_surface())
6248        || runtime_cache_is_safe;
6249    if !cache_is_allowed {
6250        return None;
6251    }
6252    if layer_uses_external_backdrop_input(layer, has_backdrop_underlay) {
6253        return None;
6254    }
6255    // Not just this layer's own effect: a shader anywhere below it makes the
6256    // whole subtree change every frame with nothing in any hash to say so.
6257    if surface_requirements.contains_runtime_shader {
6258        return None;
6259    }
6260
6261    let logical_rect = estimate_layer_surface_rect(layer);
6262    let pixel_size = surface_target_size(logical_rect, root_scale, u32::MAX);
6263    Some((
6264        LayerRasterCacheKey::new(
6265            layer.node_id,
6266            layer.target_content_hash(),
6267            layer.effect_hash(),
6268            logical_rect,
6269            pixel_size,
6270            ScaleBucket::from_scale(root_scale),
6271        ),
6272        logical_rect,
6273    ))
6274}
6275
6276impl GpuRenderer {
6277    #[allow(clippy::too_many_arguments)]
6278    pub fn new(
6279        device: Arc<wgpu::Device>,
6280        queue: Arc<wgpu::Queue>,
6281        surface_format: wgpu::TextureFormat,
6282        adapter_backend: wgpu::Backend,
6283        // Beside the backend because it is the same kind of fact: something
6284        // only the ADAPTER can answer, which the device cannot be asked for
6285        // (wgpu 29 has `Adapter::get_downlevel_capabilities` and no device
6286        // equivalent) and which decides whether the shape arrays can be
6287        // storage buffers at all.
6288        adapter_downlevel: wgpu::DownlevelFlags,
6289        text_fonts: SoftwareTextFontSet,
6290        renderer_epoch: u64,
6291        store_feed_generation: u64,
6292    ) -> Self {
6293        #[cfg(target_arch = "wasm32")]
6294        let _ = store_feed_generation;
6295        let display_format = surface_format;
6296        let composition_format = COMPOSITION_FORMAT;
6297        // Construction time is worth a line of its own. Before pipelines were
6298        // built lazily this call linked every pipeline the frontend could ever
6299        // need, and on a GL device each link ended in a blocking
6300        // `glGetProgramiv` -- 25 s on an emulator, with nothing on screen. That
6301        // is fixed, but "fixed" is a claim that needs a number on each device,
6302        // and the per-pipeline `[gpu-pipeline]` lines cannot say what the
6303        // renderer costs to build when it builds no pipelines at all.
6304        let construction_started = Instant::now();
6305        // Installed before this renderer's first device call, so even a
6306        // construction-time validation error is survived. Replaces wgpu's
6307        // fatal default handler — see [`DeviceErrorSentry`] for the
6308        // double-panic abort this prevents and
6309        // [`survive_gpu_errors_enabled`] for the kill switch.
6310        let device_errors = Arc::new(DeviceErrorSentry::default());
6311        if survive_gpu_errors_enabled() {
6312            let sentry = Arc::clone(&device_errors);
6313            device.on_uncaptured_error(Arc::new(move |error| sentry.record(&error)));
6314        }
6315        let shape_batch_limits = ShapeBatchLimits::for_device(&device, adapter_downlevel);
6316        let uniform_bind_group_layout =
6317            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6318                label: Some("Uniform Bind Group Layout"),
6319                entries: &[wgpu::BindGroupLayoutEntry {
6320                    binding: 0,
6321                    visibility: wgpu::ShaderStages::VERTEX,
6322                    ty: wgpu::BindingType::Buffer {
6323                        ty: wgpu::BufferBindingType::Uniform,
6324                        has_dynamic_offset: false,
6325                        min_binding_size: None,
6326                    },
6327                    count: None,
6328                }],
6329            });
6330        #[cfg(not(target_arch = "wasm32"))]
6331        let retained_glyph_uniform_bind_group_layout =
6332            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6333                label: Some("Retained Glyph Dynamic Uniform Bind Group Layout"),
6334                entries: &[wgpu::BindGroupLayoutEntry {
6335                    binding: 0,
6336                    visibility: wgpu::ShaderStages::VERTEX,
6337                    ty: wgpu::BindingType::Buffer {
6338                        ty: wgpu::BufferBindingType::Uniform,
6339                        has_dynamic_offset: true,
6340                        min_binding_size: wgpu::BufferSize::new(
6341                            std::mem::size_of::<Uniforms>() as u64
6342                        ),
6343                    },
6344                    count: None,
6345                }],
6346            });
6347
6348        // Read-only storage bindings where the device has them (so a whole
6349        // scene fits one batch); uniform arrays on WebGL-class devices, which
6350        // have no storage buffers in fragment shaders. The shape array is
6351        // visible to the vertex stage as well: the pipeline has no vertex
6352        // buffer and `vs_main` pulls quad corners from ShapeData. Storage mode
6353        // is gated on `DownlevelFlags::VERTEX_STORAGE` as well as on the
6354        // limit -- see `ShapeBatchLimits::select`, where the comment this
6355        // replaces claimed GL reports the limit as the minimum across stages
6356        // and Mali proved otherwise.
6357        let mut shape_bind_group_layout_entries = vec![
6358            wgpu::BindGroupLayoutEntry {
6359                binding: 0,
6360                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
6361                ty: wgpu::BindingType::Buffer {
6362                    ty: shape_batch_limits.data_binding_type(),
6363                    has_dynamic_offset: false,
6364                    min_binding_size: None,
6365                },
6366                count: None,
6367            },
6368            wgpu::BindGroupLayoutEntry {
6369                binding: 1,
6370                visibility: wgpu::ShaderStages::FRAGMENT,
6371                ty: wgpu::BindingType::Buffer {
6372                    ty: shape_batch_limits.data_binding_type(),
6373                    has_dynamic_offset: false,
6374                    min_binding_size: None,
6375                },
6376                count: None,
6377            },
6378            // The similarity transform rides a dynamic offset so
6379            // retained draws sharing one captured batch can each
6380            // apply their own transform; ordinary batches pass
6381            // offset 0 into the identity buffer.
6382            wgpu::BindGroupLayoutEntry {
6383                binding: 2,
6384                visibility: wgpu::ShaderStages::VERTEX,
6385                ty: wgpu::BindingType::Buffer {
6386                    ty: wgpu::BufferBindingType::Uniform,
6387                    has_dynamic_offset: true,
6388                    min_binding_size: wgpu::BufferSize::new(
6389                        std::mem::size_of::<SimilarityTransform>() as u64,
6390                    ),
6391                },
6392                count: None,
6393            },
6394        ];
6395        // Retained-slot paint colors, read by the vertex stage under
6396        // `paint_select` (see `shape_shader_source`). Storage mode only:
6397        // the uniform-variant shader never declares the array, and
6398        // uniform-mode devices never host retained slots, so their layout
6399        // stays exactly the three-entry one the uniform pipeline expects.
6400        if shape_batch_limits.storage {
6401            shape_bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry {
6402                binding: 3,
6403                visibility: wgpu::ShaderStages::VERTEX,
6404                ty: wgpu::BindingType::Buffer {
6405                    ty: wgpu::BufferBindingType::Storage { read_only: true },
6406                    has_dynamic_offset: false,
6407                    min_binding_size: None,
6408                },
6409                count: None,
6410            });
6411        }
6412        let shape_bind_group_layout =
6413            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6414                label: Some("Shape Bind Group Layout"),
6415                entries: &shape_bind_group_layout_entries,
6416            });
6417
6418        let identity_similarity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6419            label: Some("Identity Similarity Buffer"),
6420            size: std::mem::size_of::<SimilarityTransform>() as u64,
6421            usage: wgpu::BufferUsages::UNIFORM,
6422            mapped_at_creation: true,
6423        });
6424        identity_similarity_buffer
6425            .slice(..)
6426            .get_mapped_range_mut()
6427            .copy_from_slice(bytemuck::bytes_of(&SimilarityTransform::IDENTITY));
6428        identity_similarity_buffer.unmap();
6429
6430        // Fresh-batch bind groups need a resource at the paint binding even
6431        // though their draws leave `paint_select` at 0.0 and never use the
6432        // value; one minimal buffer (a single never-read vec4) serves every
6433        // batch. Uniform-mode layouts have no paint entry, so none exists.
6434        let dummy_paint_buffer = shape_batch_limits.storage.then(|| {
6435            device.create_buffer(&wgpu::BufferDescriptor {
6436                label: Some("Dummy Paint Buffer"),
6437                size: std::mem::size_of::<[f32; 4]>() as u64,
6438                usage: wgpu::BufferUsages::STORAGE,
6439                mapped_at_creation: false,
6440            })
6441        });
6442        #[cfg(not(target_arch = "wasm32"))]
6443        let replay_slot_store = ReplaySlotStore::new(&device);
6444
6445        let pipeline = PassPipeline::new("shape/src-over", "shape/src-over-depth");
6446        let pipeline_dst_out = PassPipeline::new("shape/dst-out", "shape/dst-out-depth");
6447        let pipeline_solid =
6448            PassPipeline::new("shape/solid-src-over", "shape/solid-src-over-depth");
6449        #[cfg(not(target_arch = "wasm32"))]
6450        let mesh_pipeline = PassPipeline::new("shape/mesh", "shape/mesh-depth");
6451        // The instanced-quad selection is LATCHED here, once per renderer:
6452        // cached retained bundles encode whichever pipelines this resolves
6453        // to, so a per-draw env read could let a bundle replay a selection
6454        // the direct path no longer makes. Storage mode only — the
6455        // uniform/WebGL path keeps `vs_main` and its plain draws untouched.
6456        #[cfg(not(target_arch = "wasm32"))]
6457        let instanced_quads =
6458            (shape_batch_limits.storage && instanced_quads_enabled()).then(|| {
6459                let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6460                    label: Some("Instanced Quad Index Buffer"),
6461                    size: std::mem::size_of_val(&INSTANCED_QUAD_INDICES) as u64,
6462                    usage: wgpu::BufferUsages::INDEX,
6463                    mapped_at_creation: true,
6464                });
6465                index_buffer
6466                    .slice(..)
6467                    .get_mapped_range_mut()
6468                    .copy_from_slice(bytemuck::cast_slice(&INSTANCED_QUAD_INDICES));
6469                index_buffer.unmap();
6470                InstancedQuadPipelines {
6471                    pipeline: PassPipeline::new(
6472                        "shape/instanced-src-over",
6473                        "shape/instanced-src-over-depth",
6474                    ),
6475                    pipeline_dst_out: PassPipeline::new(
6476                        "shape/instanced-dst-out",
6477                        "shape/instanced-dst-out-depth",
6478                    ),
6479                    pipeline_solid: PassPipeline::new(
6480                        "shape/instanced-solid",
6481                        "shape/instanced-solid-depth",
6482                    ),
6483                    index_buffer,
6484                }
6485            });
6486        #[cfg(not(target_arch = "wasm32"))]
6487        let segment_capture_pipelines = SegmentCapturePipelines {
6488            expanded: PassPipeline::new("segment/expanded", "segment/expanded-depth"),
6489            expanded_solid: PassPipeline::new(
6490                "segment/expanded-solid",
6491                "segment/expanded-solid-depth",
6492            ),
6493            mesh: PassPipeline::new("segment/mesh", "segment/mesh-depth"),
6494            instanced: PassPipeline::new("segment/instanced", "segment/instanced-depth"),
6495            instanced_solid: PassPipeline::new(
6496                "segment/instanced-solid",
6497                "segment/instanced-solid-depth",
6498            ),
6499        };
6500
6501        let image_bind_group_layout =
6502            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
6503                label: Some("Image Texture Bind Group Layout"),
6504                entries: &[
6505                    wgpu::BindGroupLayoutEntry {
6506                        binding: 0,
6507                        visibility: wgpu::ShaderStages::FRAGMENT,
6508                        ty: wgpu::BindingType::Texture {
6509                            multisampled: false,
6510                            view_dimension: wgpu::TextureViewDimension::D2,
6511                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
6512                        },
6513                        count: None,
6514                    },
6515                    wgpu::BindGroupLayoutEntry {
6516                        binding: 1,
6517                        visibility: wgpu::ShaderStages::FRAGMENT,
6518                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
6519                        count: None,
6520                    },
6521                ],
6522            });
6523
6524        let image_pipeline = PassPipeline::new("image/src-over", "image/src-over-depth");
6525        let image_pipeline_dst_out = PassPipeline::new("image/dst-out", "image/dst-out-depth");
6526        let glyph_atlas_pipeline = PassPipeline::new("glyph/shared", "glyph/shared-depth");
6527        #[cfg(not(target_arch = "wasm32"))]
6528        let retained_glyph_atlas_pipeline =
6529            PassPipeline::new("glyph/retained", "glyph/retained-depth");
6530
6531        #[cfg(not(target_arch = "wasm32"))]
6532        let upload_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6533            label: Some("Frame Upload Buffer"),
6534            size: INITIAL_UPLOAD_BUFFER_BYTES,
6535            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
6536            mapped_at_creation: false,
6537        });
6538
6539        #[cfg(not(target_arch = "wasm32"))]
6540        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6541            label: Some("Uniform Buffer"),
6542            size: std::mem::size_of::<Uniforms>() as u64,
6543            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
6544            mapped_at_creation: false,
6545        });
6546
6547        #[cfg(not(target_arch = "wasm32"))]
6548        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
6549            label: Some("Uniform Bind Group"),
6550            layout: &uniform_bind_group_layout,
6551            entries: &[wgpu::BindGroupEntry {
6552                binding: 0,
6553                resource: uniform_buffer.as_entire_binding(),
6554            }],
6555        });
6556
6557        #[cfg(not(target_arch = "wasm32"))]
6558        let shape_buffers = ShapeBatchBuffers::new(
6559            &device,
6560            &shape_bind_group_layout,
6561            &identity_similarity_buffer,
6562            dummy_paint_buffer.as_ref(),
6563            shape_batch_limits,
6564        );
6565
6566        let image_nearest_sampler =
6567            device.create_sampler(&image_sampler_descriptor(ImageSampling::Nearest));
6568        let image_linear_sampler =
6569            device.create_sampler(&image_sampler_descriptor(ImageSampling::Linear));
6570        let text_glyph_atlas = TextGlyphAtlas::new(
6571            &device,
6572            &image_bind_group_layout,
6573            &image_nearest_sampler,
6574            TEXT_GLYPH_ATLAS_MIN_SIZE,
6575        );
6576
6577        #[cfg(not(target_arch = "wasm32"))]
6578        let image_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6579            label: Some("Image Vertex Buffer"),
6580            size: (std::mem::size_of::<Vertex>() * 4) as u64,
6581            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
6582            mapped_at_creation: false,
6583        });
6584
6585        #[cfg(not(target_arch = "wasm32"))]
6586        let image_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6587            label: Some("Image Index Buffer"),
6588            size: (std::mem::size_of::<u32>() * 6) as u64,
6589            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
6590            mapped_at_creation: false,
6591        });
6592        #[cfg(not(target_arch = "wasm32"))]
6593        let retained_glyph_uniform_stride = align_usize_to(
6594            std::mem::size_of::<Uniforms>(),
6595            (device.limits().min_uniform_buffer_offset_alignment as usize)
6596                .max(wgpu::COPY_BUFFER_ALIGNMENT as usize),
6597        ) as u64;
6598        #[cfg(not(target_arch = "wasm32"))]
6599        let retained_glyph_uniform_capacity = INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS;
6600        #[cfg(not(target_arch = "wasm32"))]
6601        let retained_glyph_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
6602            label: Some("Retained Glyph Uniform Buffer"),
6603            size: retained_glyph_uniform_stride * retained_glyph_uniform_capacity as u64,
6604            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
6605            mapped_at_creation: false,
6606        });
6607        #[cfg(not(target_arch = "wasm32"))]
6608        let retained_glyph_uniform_bind_group =
6609            device.create_bind_group(&wgpu::BindGroupDescriptor {
6610                label: Some("Retained Glyph Uniform Bind Group"),
6611                layout: &retained_glyph_uniform_bind_group_layout,
6612                entries: &[wgpu::BindGroupEntry {
6613                    binding: 0,
6614                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
6615                        buffer: &retained_glyph_uniform_buffer,
6616                        offset: 0,
6617                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
6618                    }),
6619                }],
6620            });
6621
6622        // The cache handle costs nothing to create and pays on every device:
6623        // in-process, the shape family's permutations share most of their
6624        // compiled code; across launches, the persisted blob turns first-use
6625        // compiles (2.0 s of render thread inside the first six seconds on a
6626        // Pixel Watch 3) into cache hits. `None` where the device lacks the
6627        // feature — every creation site then behaves exactly as before.
6628        #[cfg(not(target_arch = "wasm32"))]
6629        let pipeline_cache = crate::pipeline_disk_cache::load(&device);
6630        #[cfg(target_arch = "wasm32")]
6631        let pipeline_cache: Option<wgpu::PipelineCache> = None;
6632        #[cfg(not(target_arch = "wasm32"))]
6633        if let Some(cache) = pipeline_cache.clone() {
6634            crate::pipeline_disk_cache::spawn_persist_schedule(cache);
6635            spawn_pipeline_prewarm(PipelinePrewarmInputs {
6636                device: Arc::clone(&device),
6637                cache: pipeline_cache.clone(),
6638                surface_format: composition_format,
6639                uniform_layout: uniform_bind_group_layout.clone(),
6640                shape_layout: shape_bind_group_layout.clone(),
6641                image_layout: image_bind_group_layout.clone(),
6642                batch_limits: shape_batch_limits,
6643                instanced: instanced_quads.is_some(),
6644            });
6645        }
6646
6647        let effects_started = Instant::now();
6648        let effect_renderer = EffectRenderer::new(
6649            &device,
6650            pipeline_cache.clone(),
6651            composition_format,
6652            adapter_backend,
6653        );
6654        let output_converter = OutputConverter::new(&device, display_format);
6655        let screenshot_converter = OutputConverter::new(&device, wgpu::TextureFormat::Rgba8Unorm);
6656        let effects_ms = instant_ms(effects_started, Instant::now());
6657
6658        let renderer = Self {
6659            device,
6660            queue,
6661            device_errors,
6662            renderer_epoch,
6663            #[cfg(not(target_arch = "wasm32"))]
6664            store_feed_generation,
6665            composition_format,
6666            #[cfg(not(target_arch = "wasm32"))]
6667            display_format,
6668            composition_target: None,
6669            output_converter,
6670            screenshot_converter,
6671            adapter_backend,
6672            shape_batch_limits,
6673            pipeline_cache,
6674            pipeline,
6675            pipeline_dst_out,
6676            pipeline_solid,
6677            #[cfg(not(target_arch = "wasm32"))]
6678            mesh_pipeline,
6679            #[cfg(not(target_arch = "wasm32"))]
6680            instanced_quads,
6681            #[cfg(not(target_arch = "wasm32"))]
6682            segment_capture_pipelines,
6683            uniform_bind_group_layout,
6684            shape_bind_group_layout,
6685            dummy_paint_buffer,
6686            identity_similarity_buffer,
6687            #[cfg(not(target_arch = "wasm32"))]
6688            replay_slots: replay_slot_store,
6689            image_pipeline,
6690            image_pipeline_dst_out,
6691            glyph_atlas_pipeline,
6692            #[cfg(not(target_arch = "wasm32"))]
6693            retained_glyph_atlas_pipeline,
6694            image_bind_group_layout,
6695            #[cfg(not(target_arch = "wasm32"))]
6696            retained_glyph_uniform_bind_group_layout,
6697            image_nearest_sampler,
6698            image_linear_sampler,
6699            text_fonts,
6700            #[cfg(not(target_arch = "wasm32"))]
6701            upload_buffer,
6702            #[cfg(not(target_arch = "wasm32"))]
6703            uniform_buffer,
6704            #[cfg(not(target_arch = "wasm32"))]
6705            uniform_bind_group,
6706            #[cfg(not(target_arch = "wasm32"))]
6707            shape_buffers,
6708            #[cfg(not(target_arch = "wasm32"))]
6709            image_vertex_buffer,
6710            #[cfg(not(target_arch = "wasm32"))]
6711            image_index_buffer,
6712            #[cfg(not(target_arch = "wasm32"))]
6713            retained_glyph_uniform_buffer,
6714            #[cfg(not(target_arch = "wasm32"))]
6715            retained_glyph_uniform_bind_group,
6716            #[cfg(not(target_arch = "wasm32"))]
6717            retained_glyph_uniform_stride,
6718            #[cfg(not(target_arch = "wasm32"))]
6719            retained_glyph_uniform_capacity,
6720            #[cfg(not(target_arch = "wasm32"))]
6721            retained_glyph_uniform_cursor: 0,
6722            #[cfg(target_arch = "wasm32")]
6723            wasm_uniform_batches: Vec::new(),
6724            #[cfg(target_arch = "wasm32")]
6725            wasm_uniform_batch_cursor: 0,
6726            #[cfg(target_arch = "wasm32")]
6727            wasm_shape_batches: Vec::new(),
6728            #[cfg(target_arch = "wasm32")]
6729            wasm_shape_batch_cursor: 0,
6730            #[cfg(target_arch = "wasm32")]
6731            wasm_image_batches: Vec::new(),
6732            #[cfg(target_arch = "wasm32")]
6733            wasm_image_batch_cursor: 0,
6734            image_texture_cache: BoundedLruCache::with_capacity_at_least_one(
6735                MAX_TEXTURE_CACHE_ITEMS,
6736            ),
6737            image_texture_cache_bytes: 0,
6738            text_image_cache: BoundedLruCache::with_capacity_at_least_one(
6739                MAX_TEXT_IMAGE_CACHE_ITEMS,
6740            ),
6741            text_glyph_atlas,
6742            text_glyph_run_cache: BoundedLruCache::with_capacity_at_least_one(
6743                MAX_TEXT_GLYPH_RUN_CACHE_ITEMS,
6744            ),
6745            #[cfg(not(target_arch = "wasm32"))]
6746            text_glyph_gpu_run_cache: BoundedLruCache::with_capacity_at_least_one(
6747                MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS,
6748            ),
6749            text_glyph_mask_cache: SoftwareGlyphRasterCache::with_capacity_at_least_one(
6750                MAX_TEXT_GLYPH_MASK_CACHE_ITEMS,
6751            ),
6752            text_line_index_cache: TextLineIndexCache::new(MAX_TEXT_LINE_INDEX_CACHE_ITEMS),
6753            scratch_shape_data: Vec::new(),
6754            scratch_gradients: Vec::new(),
6755            scratch_image_vertices: Vec::new(),
6756            scratch_image_indices: Vec::new(),
6757            scratch_image_cmds: Vec::new(),
6758            scratch_glyph_cmds: Vec::new(),
6759            scratch_text_glyph_run: Vec::new(),
6760            scratch_text_glyph_placements: Vec::new(),
6761            scratch_text_glyph_quads: Vec::new(),
6762            scratch_segment_items: Vec::new(),
6763            scratch_effect_ranges: Vec::new(),
6764            scratch_layer_events: Vec::new(),
6765            staged_uploads: StagedBufferUploads::default(),
6766            frame_graph_executor: WgpuFrameGraphExecutor::new(),
6767            deferred_offscreen_releases: Vec::new(),
6768            effect_renderer,
6769            layer_surface_cache: LayerSurfaceCache::new(),
6770            observed_scene_range_cache_misses: BoundedLruCache::with_capacity_at_least_one(
6771                MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES,
6772            ),
6773            shadow_surface_cache: BoundedLruCache::with_capacity_at_least_one(
6774                MAX_SHADOW_SURFACE_CACHE_ITEMS,
6775            ),
6776            shadow_surface_cache_bytes: 0,
6777            frame_stats: gpu_stats::FrameStats::default(),
6778            last_frame_stats: None,
6779            pending_frame_warmup_frames: 0,
6780            frame_count: 0,
6781            gpu_stats_enabled: gpu_stats_enabled(),
6782            warning_state: RendererWarningState::default(),
6783            #[cfg(not(target_arch = "wasm32"))]
6784            replay_upload_stats: ReplayUploadStats::default(),
6785            #[cfg(not(target_arch = "wasm32"))]
6786            segment_encode_stats: SegmentEncodeStats::default(),
6787            #[cfg(not(target_arch = "wasm32"))]
6788            replay_color_patches: Vec::new(),
6789            #[cfg(not(target_arch = "wasm32"))]
6790            color_patch_scratch: Vec::new(),
6791            #[cfg(not(target_arch = "wasm32"))]
6792            replay_capture_shape_scratch: Vec::new(),
6793            #[cfg(not(target_arch = "wasm32"))]
6794            replay_capture_gradient_scratch: Vec::new(),
6795            #[cfg(not(target_arch = "wasm32"))]
6796            replay_ack_confirmations: Vec::new(),
6797            #[cfg(not(target_arch = "wasm32"))]
6798            replay_generation_drops: 0,
6799            #[cfg(not(target_arch = "wasm32"))]
6800            retained_bundle_cache: RetainedBundleCache::new(),
6801            #[cfg(not(target_arch = "wasm32"))]
6802            rim_mesh_vertices: Vec::new(),
6803            #[cfg(not(target_arch = "wasm32"))]
6804            rim_mesh_indices: Vec::new(),
6805            #[cfg(not(target_arch = "wasm32"))]
6806            rim_mesh_vertex_buffer: None,
6807            #[cfg(not(target_arch = "wasm32"))]
6808            rim_mesh_index_buffer: None,
6809            #[cfg(not(target_arch = "wasm32"))]
6810            rim_mesh_uploaded_vertices: 0,
6811            #[cfg(not(target_arch = "wasm32"))]
6812            rim_mesh_uploaded_indices: 0,
6813            #[cfg(not(target_arch = "wasm32"))]
6814            rim_meshes_emitted: 0,
6815            #[cfg(not(target_arch = "wasm32"))]
6816            fill_area_diag: FillAreaDiag::default(),
6817            #[cfg(not(target_arch = "wasm32"))]
6818            static_span: StaticSpanCache::default(),
6819            #[cfg(not(target_arch = "wasm32"))]
6820            segment_surfaces: SegmentSurfaceCache::default(),
6821            #[cfg(not(target_arch = "wasm32"))]
6822            display_clip: DisplayClipState::new(),
6823        };
6824        log::info!(
6825            "[gpu-init] {:?} renderer ready in {:.1} ms (effects {:.1} ms); \
6826             pipelines build on first use",
6827            adapter_backend,
6828            instant_ms(construction_started, Instant::now()),
6829            effects_ms,
6830        );
6831        renderer
6832    }
6833
6834    /// The display's visible region (see [`crate::display_clip`]): the
6835    /// part of the full-screen surface the panel physically shows. Only
6836    /// the platform layer (or a host standing in for it) sets this —
6837    /// never app content. `Full` — the default — keeps the cull machinery
6838    /// structurally inert.
6839    #[cfg(not(target_arch = "wasm32"))]
6840    pub fn set_display_visible_region(&mut self, region: DisplayVisibleRegion) {
6841        self.display_clip.visible_region = region;
6842    }
6843
6844    /// Whether the pass currently being encoded carries the display-clip
6845    /// depth attachment; pipeline getters consult this to hand out the
6846    /// depth-tested variant.
6847    #[cfg(not(target_arch = "wasm32"))]
6848    fn pass_depth(&self) -> bool {
6849        self.display_clip.pass_depth.get()
6850    }
6851
6852    #[cfg(target_arch = "wasm32")]
6853    fn pass_depth(&self) -> bool {
6854        false
6855    }
6856
6857    /// Decides whether the fused pass about to be encoded is the culled
6858    /// one and returns its depth view: the visible region must leave
6859    /// something to cull, the kill switch must be open, and `target_view`
6860    /// must be THIS frame's root target with the pass viewport covering
6861    /// it whole. Offscreen layer passes — even full-frame-sized ones —
6862    /// never qualify: a layer's content can be transformed into view
6863    /// later.
6864    #[cfg(not(target_arch = "wasm32"))]
6865    fn display_clip_pass_depth_view(
6866        &mut self,
6867        target_view: &wgpu::TextureView,
6868        width: u32,
6869        height: u32,
6870    ) -> Option<wgpu::TextureView> {
6871        if !self.display_clip.visible_region.cullable() {
6872            return None;
6873        }
6874        if self.display_clip.frame_root_view.as_ref() != Some(target_view) {
6875            return None;
6876        }
6877        if !display_clip_cull_enabled() {
6878            return None;
6879        }
6880        self.ensure_display_clip_resources(width, height)
6881    }
6882
6883    /// Returns the depth view for the current (size, region) pair,
6884    /// tessellating the region's complement and building its vertex
6885    /// buffer and the depth attachment on first use. A tessellation that
6886    /// fails its conservative verification pins `None` for the pair: the
6887    /// cull stays off rather than ever touching a visible pixel.
6888    #[cfg(not(target_arch = "wasm32"))]
6889    fn ensure_display_clip_resources(
6890        &mut self,
6891        width: u32,
6892        height: u32,
6893    ) -> Option<wgpu::TextureView> {
6894        let region = self.display_clip.visible_region;
6895        let key = ((width, height), region);
6896        if let Some((cached_key, resources)) = &self.display_clip.resources {
6897            if *cached_key == key {
6898                return resources
6899                    .as_ref()
6900                    .map(|resources| resources.depth_view.clone());
6901            }
6902        }
6903        let built = display_clip::tessellate_complement(region, width, height).map(|mesh| {
6904            let occluder_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
6905                label: Some("Display Clip Occluder Vertices"),
6906                size: std::mem::size_of_val(mesh.vertices.as_slice()) as u64,
6907                usage: wgpu::BufferUsages::VERTEX,
6908                mapped_at_creation: true,
6909            });
6910            occluder_vertex_buffer
6911                .slice(..)
6912                .get_mapped_range_mut()
6913                .copy_from_slice(bytemuck::cast_slice(&mesh.vertices));
6914            occluder_vertex_buffer.unmap();
6915            let depth_texture = self.device.create_texture(&wgpu::TextureDescriptor {
6916                label: Some("Display Clip Depth"),
6917                size: wgpu::Extent3d {
6918                    width,
6919                    height,
6920                    depth_or_array_layers: 1,
6921                },
6922                mip_level_count: 1,
6923                sample_count: 1,
6924                dimension: wgpu::TextureDimension::D2,
6925                format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
6926                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6927                view_formats: &[],
6928            });
6929            // Once per (size, region), which is as rate-limited as it
6930            // gets. The round display — the capability's first provider —
6931            // keeps its own line.
6932            match region {
6933                DisplayVisibleRegion::InscribedCircle => log::info!(
6934                    "[display-clip] round display: corner cull active ({} px masked) at {width}x{height}",
6935                    mesh.masked_px,
6936                ),
6937                _ => log::info!(
6938                    "[display-clip] visible-region cull active for {region:?} ({} px masked) at {width}x{height}",
6939                    mesh.masked_px,
6940                ),
6941            }
6942            DisplayClipResources {
6943                depth_view: depth_texture.create_view(&wgpu::TextureViewDescriptor::default()),
6944                occluder_vertex_buffer,
6945                occluder_vertex_count: mesh.vertices.len() as u32,
6946            }
6947        });
6948        let view = built.as_ref().map(|resources| resources.depth_view.clone());
6949        self.display_clip.resources = Some((key, built));
6950        view
6951    }
6952
6953    /// Encodes the region complement's occluder, the first draw of a
6954    /// culled fused pass: depth write at the near plane over the
6955    /// tessellation, color writes off.
6956    #[cfg(not(target_arch = "wasm32"))]
6957    fn draw_display_clip_occluder(
6958        &self,
6959        render_pass: &mut wgpu::RenderPass<'_>,
6960        width: u32,
6961        height: u32,
6962    ) {
6963        let Some((((size_w, size_h), _), Some(resources))) = &self.display_clip.resources else {
6964            return;
6965        };
6966        debug_assert_eq!((*size_w, *size_h), (width, height));
6967        let pipeline =
6968            self.display_clip
6969                .occluder_pipeline
6970                .get_or_init(self.adapter_backend, || {
6971                    create_display_clip_occluder_pipeline(
6972                        &self.device,
6973                        self.pipeline_cache.as_ref(),
6974                        self.composition_format,
6975                    )
6976                });
6977        render_pass.set_scissor_rect(0, 0, width, height);
6978        render_pass.set_pipeline(pipeline);
6979        render_pass.set_vertex_buffer(0, resources.occluder_vertex_buffer.slice(..));
6980        render_pass.draw(0..resources.occluder_vertex_count, 0..1);
6981        self.frame_stats.add_draw_calls(1);
6982    }
6983
6984    fn shape_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
6985        let resource = match blend_mode {
6986            BlendMode::DstOut => &self.pipeline_dst_out,
6987            _ => &self.pipeline,
6988        };
6989        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
6990            create_shape_pipeline(
6991                &self.device,
6992                self.pipeline_cache.as_ref(),
6993                self.composition_format,
6994                &self.uniform_bind_group_layout,
6995                &self.shape_bind_group_layout,
6996                blend_mode,
6997                self.shape_batch_limits,
6998                false,
6999                "vs_main",
7000                "fs_main",
7001                depth,
7002            )
7003        })
7004    }
7005
7006    /// The `fs_solid` twin of [`Self::shape_pipeline`], SrcOver only. Callers
7007    /// pick it exactly when the draw's shapes carry zero gradient stops; the
7008    /// coverage math is byte-identical, the gradient machinery is compiled
7009    /// out of the fragment stage. Under `CRANPOSE_SOLID_TRIM_VARYINGS`
7010    /// (re-read per build, see [`solid_trim_varyings_enabled`]) the build
7011    /// compiles the trimmed-interface entries instead; either variant encodes
7012    /// identically — same layouts, same blend, no vertex buffers — so every
7013    /// caller, retained bundles included, is oblivious to the selection.
7014    fn shape_pipeline_solid(&self) -> &wgpu::RenderPipeline {
7015        self.pipeline_solid
7016            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7017                let solid_trim = solid_trim_varyings_enabled();
7018                let (vertex_entry, fragment_entry) = if solid_trim {
7019                    ("vs_solid", "fs_solid_trim")
7020                } else {
7021                    ("vs_main", "fs_solid")
7022                };
7023                create_shape_pipeline(
7024                    &self.device,
7025                    self.pipeline_cache.as_ref(),
7026                    self.composition_format,
7027                    &self.uniform_bind_group_layout,
7028                    &self.shape_bind_group_layout,
7029                    BlendMode::SrcOver,
7030                    self.shape_batch_limits,
7031                    solid_trim,
7032                    vertex_entry,
7033                    fragment_entry,
7034                    depth,
7035                )
7036            })
7037    }
7038
7039    #[cfg(not(target_arch = "wasm32"))]
7040    fn mesh_pipeline(&self) -> &wgpu::RenderPipeline {
7041        self.mesh_pipeline
7042            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7043                create_mesh_shape_pipeline(
7044                    &self.device,
7045                    self.pipeline_cache.as_ref(),
7046                    self.composition_format,
7047                    &self.uniform_bind_group_layout,
7048                    &self.shape_bind_group_layout,
7049                    self.shape_batch_limits,
7050                    depth,
7051                )
7052            })
7053    }
7054
7055    #[cfg(not(target_arch = "wasm32"))]
7056    fn instanced_pipeline<'a>(
7057        &'a self,
7058        instanced: &'a InstancedQuadPipelines,
7059        blend_mode: BlendMode,
7060    ) -> &'a wgpu::RenderPipeline {
7061        let resource = match blend_mode {
7062            BlendMode::DstOut => &instanced.pipeline_dst_out,
7063            _ => &instanced.pipeline,
7064        };
7065        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7066            create_instanced_shape_pipeline(
7067                &self.device,
7068                self.pipeline_cache.as_ref(),
7069                self.composition_format,
7070                &self.uniform_bind_group_layout,
7071                &self.shape_bind_group_layout,
7072                blend_mode,
7073                self.shape_batch_limits,
7074                false,
7075                "vs_shape_instanced",
7076                "fs_main",
7077                depth,
7078            )
7079        })
7080    }
7081
7082    /// The `fs_solid` twin of [`Self::instanced_pipeline`], SrcOver only.
7083    /// Trims its varyings under `CRANPOSE_SOLID_TRIM_VARYINGS` exactly like
7084    /// [`Self::shape_pipeline_solid`].
7085    #[cfg(not(target_arch = "wasm32"))]
7086    fn instanced_pipeline_solid<'a>(
7087        &'a self,
7088        instanced: &'a InstancedQuadPipelines,
7089    ) -> &'a wgpu::RenderPipeline {
7090        instanced
7091            .pipeline_solid
7092            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7093                let solid_trim = solid_trim_varyings_enabled();
7094                let (vertex_entry, fragment_entry) = if solid_trim {
7095                    ("vs_solid_instanced", "fs_solid_trim")
7096                } else {
7097                    ("vs_shape_instanced", "fs_solid")
7098                };
7099                create_instanced_shape_pipeline(
7100                    &self.device,
7101                    self.pipeline_cache.as_ref(),
7102                    self.composition_format,
7103                    &self.uniform_bind_group_layout,
7104                    &self.shape_bind_group_layout,
7105                    BlendMode::SrcOver,
7106                    self.shape_batch_limits,
7107                    solid_trim,
7108                    vertex_entry,
7109                    fragment_entry,
7110                    depth,
7111                )
7112            })
7113    }
7114
7115    #[cfg(not(target_arch = "wasm32"))]
7116    fn segment_capture_pipeline(&self, kind: RetainedPipelineKind) -> &wgpu::RenderPipeline {
7117        let format = COMPOSITION_FORMAT;
7118        match kind {
7119            RetainedPipelineKind::Mesh => {
7120                self.segment_capture_pipelines
7121                    .mesh
7122                    .get_or_init(self.adapter_backend, false, |_| {
7123                        create_mesh_shape_pipeline(
7124                            &self.device,
7125                            self.pipeline_cache.as_ref(),
7126                            format,
7127                            &self.uniform_bind_group_layout,
7128                            &self.shape_bind_group_layout,
7129                            self.shape_batch_limits,
7130                            false,
7131                        )
7132                    })
7133            }
7134            RetainedPipelineKind::Expanded => self.segment_capture_pipelines.expanded.get_or_init(
7135                self.adapter_backend,
7136                false,
7137                |_| {
7138                    create_shape_pipeline(
7139                        &self.device,
7140                        self.pipeline_cache.as_ref(),
7141                        format,
7142                        &self.uniform_bind_group_layout,
7143                        &self.shape_bind_group_layout,
7144                        BlendMode::SrcOver,
7145                        self.shape_batch_limits,
7146                        false,
7147                        "vs_main",
7148                        "fs_main",
7149                        false,
7150                    )
7151                },
7152            ),
7153            RetainedPipelineKind::ExpandedSolid => self
7154                .segment_capture_pipelines
7155                .expanded_solid
7156                .get_or_init(self.adapter_backend, false, |_| {
7157                    let solid_trim = solid_trim_varyings_enabled();
7158                    let (vertex_entry, fragment_entry) = if solid_trim {
7159                        ("vs_solid", "fs_solid_trim")
7160                    } else {
7161                        ("vs_main", "fs_solid")
7162                    };
7163                    create_shape_pipeline(
7164                        &self.device,
7165                        self.pipeline_cache.as_ref(),
7166                        format,
7167                        &self.uniform_bind_group_layout,
7168                        &self.shape_bind_group_layout,
7169                        BlendMode::SrcOver,
7170                        self.shape_batch_limits,
7171                        solid_trim,
7172                        vertex_entry,
7173                        fragment_entry,
7174                        false,
7175                    )
7176                }),
7177            RetainedPipelineKind::Instanced | RetainedPipelineKind::InstancedSolid => {
7178                let Some(_) = self.instanced_quads.as_ref() else {
7179                    return self.segment_capture_pipeline(match kind {
7180                        RetainedPipelineKind::Instanced => RetainedPipelineKind::Expanded,
7181                        RetainedPipelineKind::InstancedSolid => RetainedPipelineKind::ExpandedSolid,
7182                        _ => unreachable!(),
7183                    });
7184                };
7185                let (resource, solid_trim, vertex_entry, fragment_entry) = match kind {
7186                    RetainedPipelineKind::Instanced => (
7187                        &self.segment_capture_pipelines.instanced,
7188                        false,
7189                        "vs_shape_instanced",
7190                        "fs_main",
7191                    ),
7192                    RetainedPipelineKind::InstancedSolid => {
7193                        let solid_trim = solid_trim_varyings_enabled();
7194                        (
7195                            &self.segment_capture_pipelines.instanced_solid,
7196                            solid_trim,
7197                            if solid_trim {
7198                                "vs_solid_instanced"
7199                            } else {
7200                                "vs_shape_instanced"
7201                            },
7202                            if solid_trim {
7203                                "fs_solid_trim"
7204                            } else {
7205                                "fs_solid"
7206                            },
7207                        )
7208                    }
7209                    _ => unreachable!(),
7210                };
7211                resource.get_or_init(self.adapter_backend, false, |_| {
7212                    create_instanced_shape_pipeline(
7213                        &self.device,
7214                        self.pipeline_cache.as_ref(),
7215                        format,
7216                        &self.uniform_bind_group_layout,
7217                        &self.shape_bind_group_layout,
7218                        BlendMode::SrcOver,
7219                        self.shape_batch_limits,
7220                        solid_trim,
7221                        vertex_entry,
7222                        fragment_entry,
7223                        false,
7224                    )
7225                })
7226            }
7227        }
7228    }
7229
7230    #[cfg(not(target_arch = "wasm32"))]
7231    fn retained_pipeline(&self, kind: RetainedPipelineKind) -> &wgpu::RenderPipeline {
7232        match kind {
7233            RetainedPipelineKind::Mesh => self.mesh_pipeline(),
7234            RetainedPipelineKind::Expanded => self.shape_pipeline(BlendMode::SrcOver),
7235            RetainedPipelineKind::ExpandedSolid => self.shape_pipeline_solid(),
7236            RetainedPipelineKind::Instanced => self
7237                .instanced_quads
7238                .as_ref()
7239                .map(|instanced| self.instanced_pipeline(instanced, BlendMode::SrcOver))
7240                .unwrap_or_else(|| self.shape_pipeline(BlendMode::SrcOver)),
7241            RetainedPipelineKind::InstancedSolid => self
7242                .instanced_quads
7243                .as_ref()
7244                .map(|instanced| self.instanced_pipeline_solid(instanced))
7245                .unwrap_or_else(|| self.shape_pipeline_solid()),
7246        }
7247    }
7248
7249    fn image_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
7250        let resource = match blend_mode {
7251            BlendMode::DstOut => &self.image_pipeline_dst_out,
7252            _ => &self.image_pipeline,
7253        };
7254        resource.get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7255            create_image_pipeline(
7256                &self.device,
7257                self.pipeline_cache.as_ref(),
7258                self.composition_format,
7259                &self.uniform_bind_group_layout,
7260                &self.image_bind_group_layout,
7261                blend_mode,
7262                depth,
7263            )
7264        })
7265    }
7266
7267    fn glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
7268        self.glyph_atlas_pipeline
7269            .get_or_init(self.adapter_backend, self.pass_depth(), |depth| {
7270                create_glyph_atlas_pipeline(
7271                    &self.device,
7272                    self.pipeline_cache.as_ref(),
7273                    self.composition_format,
7274                    &self.uniform_bind_group_layout,
7275                    &self.image_bind_group_layout,
7276                    depth,
7277                )
7278            })
7279    }
7280
7281    #[cfg(not(target_arch = "wasm32"))]
7282    fn retained_glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
7283        self.retained_glyph_atlas_pipeline.get_or_init(
7284            self.adapter_backend,
7285            self.pass_depth(),
7286            |depth| {
7287                create_glyph_atlas_pipeline(
7288                    &self.device,
7289                    self.pipeline_cache.as_ref(),
7290                    self.composition_format,
7291                    &self.retained_glyph_uniform_bind_group_layout,
7292                    &self.image_bind_group_layout,
7293                    depth,
7294                )
7295            },
7296        )
7297    }
7298
7299    fn ensure_image_cached(&mut self, image: &ImageBitmap) -> Result<(), String> {
7300        if self.image_texture_cache.get(&image.id()).is_some() {
7301            return Ok(());
7302        }
7303
7304        let size = wgpu::Extent3d {
7305            width: image.width(),
7306            height: image.height(),
7307            depth_or_array_layers: 1,
7308        };
7309
7310        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
7311            label: Some("Image Texture"),
7312            size,
7313            mip_level_count: 1,
7314            sample_count: 1,
7315            dimension: wgpu::TextureDimension::D2,
7316            format: wgpu::TextureFormat::Rgba8Unorm,
7317            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7318            view_formats: &[],
7319        });
7320
7321        let upload_stats = self.frame_graph_executor.upload_texture(
7322            &self.queue,
7323            wgpu::TexelCopyTextureInfo {
7324                texture: &texture,
7325                mip_level: 0,
7326                origin: wgpu::Origin3d::ZERO,
7327                aspect: wgpu::TextureAspect::All,
7328            },
7329            image.pixels(),
7330            wgpu::TexelCopyBufferLayout {
7331                offset: 0,
7332                bytes_per_row: Some(4 * image.width()),
7333                rows_per_image: Some(image.height()),
7334            },
7335            size,
7336        );
7337        self.frame_stats.record_command_stats(upload_stats);
7338
7339        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
7340        let nearest_bind_group = self.image_bind_group(&view, &self.image_nearest_sampler);
7341        let linear_bind_group = self.image_bind_group(&view, &self.image_linear_sampler);
7342
7343        let bytes = image.width() as usize * image.height() as usize * 4;
7344        if let Some(replaced) = self.image_texture_cache.put(
7345            image.id(),
7346            CachedImageTexture {
7347                _texture: texture,
7348                _view: view,
7349                nearest_bind_group,
7350                linear_bind_group,
7351                bytes,
7352            },
7353        ) {
7354            self.image_texture_cache_bytes = self
7355                .image_texture_cache_bytes
7356                .saturating_sub(replaced.bytes);
7357        }
7358        self.image_texture_cache_bytes += bytes;
7359        // Byte-bounded eviction on top of the count bound: never evict the
7360        // entry just inserted (this frame draws it).
7361        while self.image_texture_cache_bytes > MAX_IMAGE_TEXTURE_CACHE_BYTES
7362            && self.image_texture_cache.len() > 1
7363        {
7364            let Some((_, evicted)) = self.image_texture_cache.pop_lru() else {
7365                break;
7366            };
7367            self.image_texture_cache_bytes =
7368                self.image_texture_cache_bytes.saturating_sub(evicted.bytes);
7369        }
7370        Ok(())
7371    }
7372
7373    fn image_bind_group(
7374        &self,
7375        view: &wgpu::TextureView,
7376        sampler: &wgpu::Sampler,
7377    ) -> wgpu::BindGroup {
7378        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
7379            label: Some("Image Texture Bind Group"),
7380            layout: &self.image_bind_group_layout,
7381            entries: &[
7382                wgpu::BindGroupEntry {
7383                    binding: 0,
7384                    resource: wgpu::BindingResource::TextureView(view),
7385                },
7386                wgpu::BindGroupEntry {
7387                    binding: 1,
7388                    resource: wgpu::BindingResource::Sampler(sampler),
7389                },
7390            ],
7391        })
7392    }
7393
7394    /// Acquire an offscreen target from the pool with stats tracking.
7395    /// Uses split borrows to avoid conflicting borrows on self.
7396    fn max_texture_dim(&self) -> u32 {
7397        self.effect_renderer.max_texture_dim()
7398    }
7399
7400    fn acquire_offscreen(&mut self, width: u32, height: u32) -> OffscreenTarget {
7401        self.effect_renderer
7402            .acquire_offscreen(&self.device, width, height, Some(&self.frame_stats))
7403    }
7404
7405    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
7406        self.acquire_offscreen(width, height)
7407    }
7408
7409    fn take_composition_target(&mut self, width: u32, height: u32) -> CompositionTarget {
7410        if let Some(target) = self.composition_target.take() {
7411            if target.target.width == width && target.target.height == height {
7412                return target;
7413            }
7414        }
7415        let target = OffscreenTarget::new(&self.device, self.composition_format, width, height);
7416        let output_bind_group = self.output_converter.bind_group(&self.device, &target.view);
7417        CompositionTarget {
7418            target,
7419            output_bind_group,
7420        }
7421    }
7422
7423    #[cfg(not(target_arch = "wasm32"))]
7424    fn acquire_segment_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
7425        let max_texture_dim = self.max_texture_dim();
7426        OffscreenTarget::new(
7427            &self.device,
7428            COMPOSITION_FORMAT,
7429            width.min(max_texture_dim).max(1),
7430            height.min(max_texture_dim).max(1),
7431        )
7432    }
7433
7434    fn transient_offscreen_descriptor(
7435        &self,
7436        label: &'static str,
7437        width: u32,
7438        height: u32,
7439    ) -> FrameTextureDescriptor {
7440        let max_texture_dim = self.max_texture_dim();
7441        FrameTextureDescriptor::render_attachment(
7442            label,
7443            width.min(max_texture_dim),
7444            height.min(max_texture_dim),
7445            self.composition_format,
7446        )
7447    }
7448
7449    fn defer_offscreen_release(&mut self, target: OffscreenTarget) {
7450        self.deferred_offscreen_releases.push(target);
7451    }
7452
7453    fn flush_deferred_offscreen_releases(&mut self) {
7454        for target in self.deferred_offscreen_releases.drain(..) {
7455            self.effect_renderer.release_offscreen(target);
7456        }
7457    }
7458
7459    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
7460        if let LayerSurfaceTexture::Owned(target) = target {
7461            self.defer_offscreen_release(target);
7462        }
7463    }
7464
7465    fn cached_layer_surface(
7466        &mut self,
7467        key: &LayerRasterCacheKey,
7468    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
7469        self.layer_surface_cache.get(key, &self.frame_stats)
7470    }
7471
7472    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
7473        admit_layer_surface_cache_miss_impl(key, &mut self.observed_scene_range_cache_misses)
7474    }
7475
7476    fn insert_cached_layer_surface(
7477        &mut self,
7478        key: LayerRasterCacheKey,
7479        target: OffscreenTarget,
7480        logical_rect: Rect,
7481    ) -> Rc<OffscreenTarget> {
7482        self.layer_surface_cache
7483            .insert(key, target, logical_rect, &self.frame_stats)
7484    }
7485
7486    fn cached_shadow_surface(
7487        &mut self,
7488        key: &ShadowSurfaceCacheKey,
7489    ) -> Option<Rc<OffscreenTarget>> {
7490        self.shadow_surface_cache
7491            .get(key)
7492            .map(|cached| cached.target.clone())
7493    }
7494
7495    fn cached_shape_shadow_composite(
7496        &mut self,
7497        shadow: &ShadowDraw,
7498        width: u32,
7499        height: u32,
7500        root_scale: f32,
7501    ) -> Option<CachedShadowComposite> {
7502        if shadow.blur_radius <= 0.0 || shadow.shapes.is_empty() || !shadow.texts.is_empty() {
7503            return None;
7504        }
7505
7506        let plan = shape_shadow_surface_plan(
7507            &shadow.shapes,
7508            shadow.clip,
7509            shadow.blur_radius,
7510            width,
7511            height,
7512            root_scale,
7513            self.max_texture_dim(),
7514        )?;
7515        let key = shape_shadow_surface_cache_key(
7516            &shadow.shapes,
7517            &shadow.brushes,
7518            plan.source_device_bounds,
7519            plan.pixel_radius,
7520            root_scale,
7521        )?;
7522        let cached = self.cached_shadow_surface(&key)?;
7523        let viewport_offset = [plan.source_device_bounds.x, plan.source_device_bounds.y];
7524        self.frame_stats.record_shadow_shape_cache_hit(
7525            plan.source_device_bounds.width,
7526            plan.source_device_bounds.height,
7527        );
7528
7529        let clip_scissor = shadow
7530            .clip
7531            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
7532        let scissor = clip_scissor.or(plan.processing_scissor);
7533        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
7534            mask.rect[0] -= viewport_offset[0];
7535            mask.rect[1] -= viewport_offset[1];
7536            mask
7537        });
7538        let dest_viewport = Some((
7539            viewport_offset[0],
7540            viewport_offset[1],
7541            plan.source_device_bounds.width as f32,
7542            plan.source_device_bounds.height as f32,
7543        ));
7544
7545        Some(CachedShadowComposite {
7546            source: cached,
7547            scissor,
7548            rounded_mask,
7549            dest_viewport,
7550        })
7551    }
7552
7553    fn insert_cached_shadow_surface(
7554        &mut self,
7555        key: ShadowSurfaceCacheKey,
7556        target: OffscreenTarget,
7557    ) {
7558        let byte_size = offscreen_byte_size(target.width, target.height);
7559        while self.shadow_surface_cache_bytes + byte_size > MAX_SHADOW_SURFACE_CACHE_BYTES {
7560            let Some((_evicted_key, evicted_entry)) = self.shadow_surface_cache.pop_lru() else {
7561                break;
7562            };
7563            self.shadow_surface_cache_bytes = self
7564                .shadow_surface_cache_bytes
7565                .saturating_sub(evicted_entry.byte_size);
7566        }
7567
7568        let cached = CachedShadowSurface {
7569            target: Rc::new(target),
7570            byte_size,
7571        };
7572        if let Some((_replaced_key, replaced_entry)) = self.shadow_surface_cache.push(key, cached) {
7573            self.shadow_surface_cache_bytes = self
7574                .shadow_surface_cache_bytes
7575                .saturating_sub(replaced_entry.byte_size);
7576        }
7577        self.shadow_surface_cache_bytes = self.shadow_surface_cache_bytes.saturating_add(byte_size);
7578    }
7579
7580    fn supports_render_effect(&self, effect: &RenderEffect) -> bool {
7581        is_render_effect_supported(effect)
7582    }
7583}
7584
7585struct RecordingSurfaceBackend<'renderer, 'recorder, C: FrameCommandRecorder> {
7586    renderer: &'renderer mut GpuRenderer,
7587    recorder: &'recorder mut C,
7588}
7589
7590impl<C: FrameCommandRecorder> RecordingSurfaceBackend<'_, '_, C> {
7591    #[allow(clippy::too_many_arguments)]
7592    fn render_range_with_layer_events_to_target_recorded(
7593        &mut self,
7594        target: &OffscreenTarget,
7595        shapes: &[DrawShape],
7596        brushes: &[Brush],
7597        images: &[ImageDraw],
7598        texts: &[TextDraw],
7599        shadow_draws: &[ShadowDraw],
7600        retained_draws: &[RetainedDraw],
7601        draw_ops: &[DrawOp],
7602        effect_layers: &[EffectLayer],
7603        backdrop_layers: &[BackdropLayer],
7604        backdrop_input_hashes: &[u64],
7605        z_start: usize,
7606        z_end: usize,
7607        excluded_effect_layer: Option<usize>,
7608        width: u32,
7609        height: u32,
7610        root_scale: f32,
7611        backdrop_underlay: Option<&OffscreenTarget>,
7612        initial_load_op: wgpu::LoadOp<wgpu::Color>,
7613    ) -> Result<(), String> {
7614        if z_start >= z_end {
7615            if matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
7616                self.clear_target_view_with_load_op(&target.view, initial_load_op);
7617            }
7618            return Ok(());
7619        }
7620
7621        let mut effect_z_ranges = std::mem::take(&mut self.renderer.scratch_effect_ranges);
7622        collect_effect_ranges(
7623            effect_layers,
7624            z_start,
7625            z_end,
7626            excluded_effect_layer,
7627            &mut effect_z_ranges,
7628        );
7629        let mut events = std::mem::take(&mut self.renderer.scratch_layer_events);
7630        collect_layer_events(
7631            effect_layers,
7632            backdrop_layers,
7633            z_start,
7634            z_end,
7635            excluded_effect_layer,
7636            &mut events,
7637        );
7638
7639        let result = (|| -> Result<(), String> {
7640            let mut next_load_op = initial_load_op;
7641            let mut cursor_z = z_start;
7642            for event in &events {
7643                if event.z_index > cursor_z {
7644                    self.render_non_effect_segment(
7645                        &target.view,
7646                        shapes,
7647                        brushes,
7648                        images,
7649                        texts,
7650                        shadow_draws,
7651                        retained_draws,
7652                        draw_ops,
7653                        cursor_z,
7654                        event.z_index,
7655                        &effect_z_ranges,
7656                        width,
7657                        height,
7658                        root_scale,
7659                        next_load_op,
7660                    )?;
7661                    next_load_op = wgpu::LoadOp::Load;
7662                    cursor_z = event.z_index;
7663                } else if event.z_index < cursor_z {
7664                    continue;
7665                }
7666
7667                if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
7668                    self.clear_target_view_with_load_op(&target.view, next_load_op);
7669                    next_load_op = wgpu::LoadOp::Load;
7670                }
7671
7672                match event.kind {
7673                    LayerEventKind::Backdrop(index) => {
7674                        let layer = &backdrop_layers[index];
7675                        let effective_backdrop_underlay = if backdrop_underlay.is_some()
7676                            && backdrop_underlay_is_covered_by_local_content(
7677                                shapes,
7678                                brushes,
7679                                images,
7680                                shadow_draws,
7681                                draw_ops,
7682                                effect_layers,
7683                                backdrop_layers,
7684                                layer,
7685                            ) {
7686                            None
7687                        } else {
7688                            backdrop_underlay
7689                        };
7690                        execute_apply_backdrop_layer_to_target(
7691                            self,
7692                            target,
7693                            layer,
7694                            effective_backdrop_underlay,
7695                            width,
7696                            height,
7697                            root_scale,
7698                            backdrop_input_hashes.get(index).copied(),
7699                        )?;
7700                    }
7701                    LayerEventKind::Effect(index) => {
7702                        let layer = &effect_layers[index];
7703                        if layer.z_start < cursor_z {
7704                            continue;
7705                        }
7706                        execute_render_effect_layer_to_target(
7707                            self,
7708                            target,
7709                            shapes,
7710                            brushes,
7711                            images,
7712                            texts,
7713                            shadow_draws,
7714                            draw_ops,
7715                            effect_layers,
7716                            backdrop_layers,
7717                            index,
7718                            backdrop_underlay,
7719                            width,
7720                            height,
7721                            root_scale,
7722                        )?;
7723                        cursor_z = cursor_z.max(layer.z_end);
7724                    }
7725                }
7726            }
7727
7728            if cursor_z < z_end {
7729                self.render_non_effect_segment(
7730                    &target.view,
7731                    shapes,
7732                    brushes,
7733                    images,
7734                    texts,
7735                    shadow_draws,
7736                    retained_draws,
7737                    draw_ops,
7738                    cursor_z,
7739                    z_end,
7740                    &effect_z_ranges,
7741                    width,
7742                    height,
7743                    root_scale,
7744                    next_load_op,
7745                )?;
7746            } else if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
7747                self.clear_target_view_with_load_op(&target.view, next_load_op);
7748            }
7749
7750            Ok(())
7751        })();
7752
7753        self.renderer.scratch_effect_ranges = effect_z_ranges;
7754        self.renderer.scratch_layer_events = events;
7755        result
7756    }
7757
7758    #[allow(clippy::too_many_arguments)]
7759    fn record_shader_composite(
7760        &mut self,
7761        source: &OffscreenTarget,
7762        shader: &RuntimeShader,
7763        effect_rect: [f32; 4],
7764        dest_view: &wgpu::TextureView,
7765        alpha: f32,
7766        load_op: wgpu::LoadOp<wgpu::Color>,
7767        scissor: Option<(u32, u32, u32, u32)>,
7768        blend_mode: BlendMode,
7769        dest_viewport: Option<(f32, f32, f32, f32)>,
7770        sample_mode: CompositeSampleMode,
7771    ) {
7772        let device = self.renderer.device.clone();
7773        if let Some(viewport) = direct_shader_composite_viewport(
7774            alpha,
7775            blend_mode,
7776            dest_viewport,
7777            sample_mode,
7778            (source.width, source.height),
7779        ) {
7780            let shader_applied = self
7781                .renderer
7782                .effect_renderer
7783                .encode_shader_src_over_to_view(
7784                    self.recorder,
7785                    &device,
7786                    source,
7787                    dest_view,
7788                    shader,
7789                    effect_rect,
7790                    load_op,
7791                    scissor,
7792                    viewport,
7793                );
7794            if shader_applied {
7795                self.renderer
7796                    .effect_renderer
7797                    .debug_effects
7798                    .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7799                self.recorder.record_pass();
7800                self.renderer.effect_renderer.record_composite_pass();
7801                return;
7802            }
7803        }
7804        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7805            "Shader Effect Composite Scratch",
7806            source.width,
7807            source.height,
7808        );
7809        let scratch = self
7810            .recorder
7811            .acquire_transient_offscreen(&device, scratch_descriptor);
7812        let shader_applied = {
7813            self.renderer.effect_renderer.encode_shader(
7814                self.recorder,
7815                &device,
7816                source,
7817                &scratch.view,
7818                shader,
7819                effect_rect,
7820            )
7821        };
7822        let composite_source = if shader_applied {
7823            self.renderer
7824                .effect_renderer
7825                .debug_effects
7826                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7827            self.recorder.record_pass();
7828            &scratch
7829        } else {
7830            source
7831        };
7832        {
7833            self.renderer
7834                .effect_renderer
7835                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
7836                    self.recorder,
7837                    &device,
7838                    composite_source,
7839                    dest_view,
7840                    alpha,
7841                    load_op,
7842                    scissor,
7843                    None,
7844                    supported_blend_mode(blend_mode),
7845                    dest_viewport,
7846                    sample_mode,
7847                );
7848        }
7849        self.recorder.record_pass();
7850        self.renderer.effect_renderer.record_composite_pass();
7851        self.recorder
7852            .release_transient_offscreen(scratch_descriptor, scratch);
7853    }
7854
7855    #[allow(clippy::too_many_arguments)]
7856    fn record_shader_projective_composite(
7857        &mut self,
7858        source: &OffscreenTarget,
7859        shader: &RuntimeShader,
7860        effect_rect: [f32; 4],
7861        dest_view: &wgpu::TextureView,
7862        viewport: (u32, u32),
7863        source_size: (f32, f32),
7864        inverse_matrix: [[f32; 3]; 3],
7865        dest_bounds: [[f32; 2]; 4],
7866        alpha: f32,
7867        load_op: wgpu::LoadOp<wgpu::Color>,
7868        scissor: Option<(u32, u32, u32, u32)>,
7869        blend_mode: BlendMode,
7870        sample_mode: CompositeSampleMode,
7871    ) {
7872        if projective_dest_bounds_rect(dest_bounds).is_none() {
7873            return;
7874        }
7875        let device = self.renderer.device.clone();
7876        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
7877            "Shader Projective Composite Scratch",
7878            source.width,
7879            source.height,
7880        );
7881        let scratch = self
7882            .recorder
7883            .acquire_transient_offscreen(&device, scratch_descriptor);
7884        let shader_applied = {
7885            self.renderer.effect_renderer.encode_shader(
7886                self.recorder,
7887                &device,
7888                source,
7889                &scratch.view,
7890                shader,
7891                effect_rect,
7892            )
7893        };
7894        let composite_source = if shader_applied {
7895            self.renderer
7896                .effect_renderer
7897                .debug_effects
7898                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
7899            self.recorder.record_pass();
7900            &scratch
7901        } else {
7902            source
7903        };
7904        let composited = {
7905            self.renderer
7906                .effect_renderer
7907                .encode_composite_to_view_projective(
7908                    self.recorder,
7909                    &device,
7910                    composite_source,
7911                    dest_view,
7912                    viewport,
7913                    source_size,
7914                    inverse_matrix,
7915                    dest_bounds,
7916                    alpha,
7917                    load_op,
7918                    scissor,
7919                    supported_blend_mode(blend_mode),
7920                    sample_mode,
7921                )
7922        };
7923        if composited {
7924            self.recorder.record_pass();
7925            self.renderer.effect_renderer.record_composite_pass();
7926        }
7927        self.recorder
7928            .release_transient_offscreen(scratch_descriptor, scratch);
7929    }
7930
7931    #[allow(clippy::too_many_arguments)]
7932    fn record_effect_with_direct_shader_tail_composite(
7933        &mut self,
7934        source: &OffscreenTarget,
7935        first_effect: &RenderEffect,
7936        shader: &RuntimeShader,
7937        effect_rect: [f32; 4],
7938        dest_view: &wgpu::TextureView,
7939        load_op: wgpu::LoadOp<wgpu::Color>,
7940        scissor: Option<(u32, u32, u32, u32)>,
7941        dest_viewport: (f32, f32, f32, f32),
7942    ) -> Result<bool, String> {
7943        let device = self.renderer.device.clone();
7944        let intermediate_descriptor = self.renderer.transient_offscreen_descriptor(
7945            "Render Effect Direct Shader Tail Intermediate",
7946            source.width,
7947            source.height,
7948        );
7949        let intermediate = self
7950            .recorder
7951            .acquire_transient_offscreen(&device, intermediate_descriptor);
7952        let effect_scratch_targets = self
7953            .renderer
7954            .effect_renderer
7955            .acquire_recorded_effect_scratch_targets(
7956                self.recorder,
7957                &device,
7958                first_effect,
7959                source.width,
7960                source.height,
7961                self.renderer.composition_format,
7962            );
7963        let first_passes = {
7964            let mut effect_scratch_refs = effect_scratch_targets.refs();
7965            let pass_count = self.renderer.effect_renderer.encode_effect(
7966                self.recorder,
7967                &device,
7968                source,
7969                &intermediate.view,
7970                first_effect,
7971                effect_rect,
7972                &mut effect_scratch_refs,
7973            );
7974            match pass_count {
7975                Ok(pass_count) => effect_scratch_refs.assert_consumed().map(|()| pass_count),
7976                Err(error) => Err(error),
7977            }
7978        };
7979        let first_passes = match first_passes {
7980            Ok(pass_count) => pass_count,
7981            Err(error) => {
7982                effect_scratch_targets.release_into(self.recorder);
7983                self.recorder
7984                    .release_transient_offscreen(intermediate_descriptor, intermediate);
7985                return Err(error);
7986            }
7987        };
7988        let shader_applied = self
7989            .renderer
7990            .effect_renderer
7991            .encode_shader_src_over_to_view(
7992                self.recorder,
7993                &device,
7994                &intermediate,
7995                dest_view,
7996                shader,
7997                effect_rect,
7998                load_op,
7999                scissor,
8000                dest_viewport,
8001            );
8002        self.recorder
8003            .record_passes(first_passes.saturating_add(u32::from(shader_applied)));
8004        effect_scratch_targets.release_into(self.recorder);
8005        self.recorder
8006            .release_transient_offscreen(intermediate_descriptor, intermediate);
8007        if !shader_applied {
8008            return Ok(false);
8009        }
8010        self.renderer
8011            .effect_renderer
8012            .debug_effects
8013            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
8014        self.renderer.effect_renderer.record_composite_pass();
8015        Ok(true)
8016    }
8017
8018    #[allow(clippy::too_many_arguments)]
8019    fn record_effect_composite(
8020        &mut self,
8021        source: &OffscreenTarget,
8022        effect: &RenderEffect,
8023        effect_rect: [f32; 4],
8024        dest_view: &wgpu::TextureView,
8025        alpha: f32,
8026        load_op: wgpu::LoadOp<wgpu::Color>,
8027        scissor: Option<(u32, u32, u32, u32)>,
8028        blend_mode: BlendMode,
8029        dest_viewport: Option<(f32, f32, f32, f32)>,
8030        sample_mode: CompositeSampleMode,
8031    ) -> Result<(), String> {
8032        if let (
8033            RenderEffect::Chain { first, second },
8034            Some(viewport),
8035            BlendMode::SrcOver,
8036            CompositeSampleMode::Linear,
8037        ) = (
8038            effect,
8039            dest_viewport,
8040            supported_blend_mode(blend_mode),
8041            sample_mode,
8042        ) {
8043            if let (
8044                RenderEffect::Blur {
8045                    radius_x,
8046                    radius_y,
8047                    edge_treatment,
8048                },
8049                RenderEffect::Shader { shader },
8050            ) = (first.as_ref(), second.as_ref())
8051            {
8052                if *radius_x > 0.0 || *radius_y > 0.0 {
8053                    let device = self.renderer.device.clone();
8054                    let (scratch_width, scratch_height) = crate::effect_renderer::blur_scratch_size(
8055                        *radius_x,
8056                        *radius_y,
8057                        source.width,
8058                        source.height,
8059                    );
8060                    let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
8061                        "Blur Rounded Mask Scratch",
8062                        scratch_width,
8063                        scratch_height,
8064                    );
8065                    let scratch = self
8066                        .recorder
8067                        .acquire_transient_offscreen(&device, scratch_descriptor);
8068                    let fused = self
8069                        .renderer
8070                        .effect_renderer
8071                        .encode_blur_then_rounded_mask_src_over_to_view(
8072                            self.recorder,
8073                            &device,
8074                            source,
8075                            &scratch,
8076                            dest_view,
8077                            *radius_x,
8078                            *radius_y,
8079                            *edge_treatment,
8080                            shader,
8081                            effect_rect,
8082                            load_op,
8083                            scissor,
8084                            viewport,
8085                        );
8086                    if fused {
8087                        self.recorder.record_passes(2);
8088                        self.renderer.effect_renderer.record_blur_pass();
8089                        self.renderer
8090                            .effect_renderer
8091                            .debug_effects
8092                            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
8093                        self.renderer.effect_renderer.record_composite_pass();
8094                        self.recorder
8095                            .release_transient_offscreen(scratch_descriptor, scratch);
8096                        return Ok(());
8097                    }
8098                    self.recorder
8099                        .release_transient_offscreen(scratch_descriptor, scratch);
8100                }
8101            }
8102        }
8103        if let Some((first_effect, shader, viewport)) = direct_shader_tail_composite(
8104            effect,
8105            alpha,
8106            blend_mode,
8107            dest_viewport,
8108            sample_mode,
8109            (source.width, source.height),
8110        ) {
8111            if self.record_effect_with_direct_shader_tail_composite(
8112                source,
8113                first_effect,
8114                shader,
8115                effect_rect,
8116                dest_view,
8117                load_op,
8118                scissor,
8119                viewport,
8120            )? {
8121                return Ok(());
8122            }
8123        }
8124        let device = self.renderer.device.clone();
8125        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
8126            "Render Effect Composite Scratch",
8127            source.width,
8128            source.height,
8129        );
8130        let scratch = self
8131            .recorder
8132            .acquire_transient_offscreen(&device, scratch_descriptor);
8133        let effect_scratch_targets = self
8134            .renderer
8135            .effect_renderer
8136            .acquire_recorded_effect_scratch_targets(
8137                self.recorder,
8138                &device,
8139                effect,
8140                source.width,
8141                source.height,
8142                self.renderer.composition_format,
8143            );
8144        let effect_passes = {
8145            let mut effect_scratch_refs = effect_scratch_targets.refs();
8146            let pass_count = self.renderer.effect_renderer.encode_effect(
8147                self.recorder,
8148                &device,
8149                source,
8150                &scratch.view,
8151                effect,
8152                effect_rect,
8153                &mut effect_scratch_refs,
8154            )?;
8155            effect_scratch_refs.assert_consumed()?;
8156            Ok(pass_count)
8157        };
8158        let effect_passes = match effect_passes {
8159            Ok(pass_count) => pass_count,
8160            Err(error) => {
8161                effect_scratch_targets.release_into(self.recorder);
8162                self.recorder
8163                    .release_transient_offscreen(scratch_descriptor, scratch);
8164                return Err(error);
8165            }
8166        };
8167        {
8168            self.renderer
8169                .effect_renderer
8170                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8171                    self.recorder,
8172                    &device,
8173                    &scratch,
8174                    dest_view,
8175                    alpha,
8176                    load_op,
8177                    scissor,
8178                    None,
8179                    supported_blend_mode(blend_mode),
8180                    dest_viewport,
8181                    sample_mode,
8182                );
8183        }
8184        self.recorder.record_passes(effect_passes.saturating_add(1));
8185        self.renderer.effect_renderer.record_composite_pass();
8186        effect_scratch_targets.release_into(self.recorder);
8187        self.recorder
8188            .release_transient_offscreen(scratch_descriptor, scratch);
8189        Ok(())
8190    }
8191
8192    #[allow(clippy::too_many_arguments)]
8193    fn record_effect_projective_composite(
8194        &mut self,
8195        source: &OffscreenTarget,
8196        effect: &RenderEffect,
8197        effect_rect: [f32; 4],
8198        dest_view: &wgpu::TextureView,
8199        viewport: (u32, u32),
8200        source_size: (f32, f32),
8201        inverse_matrix: [[f32; 3]; 3],
8202        dest_bounds: [[f32; 2]; 4],
8203        alpha: f32,
8204        load_op: wgpu::LoadOp<wgpu::Color>,
8205        scissor: Option<(u32, u32, u32, u32)>,
8206        blend_mode: BlendMode,
8207        sample_mode: CompositeSampleMode,
8208    ) -> Result<(), String> {
8209        if projective_dest_bounds_rect(dest_bounds).is_none() {
8210            return Ok(());
8211        }
8212        let device = self.renderer.device.clone();
8213        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
8214            "Render Effect Projective Composite Scratch",
8215            source.width,
8216            source.height,
8217        );
8218        let scratch = self
8219            .recorder
8220            .acquire_transient_offscreen(&device, scratch_descriptor);
8221        let effect_scratch_targets = self
8222            .renderer
8223            .effect_renderer
8224            .acquire_recorded_effect_scratch_targets(
8225                self.recorder,
8226                &device,
8227                effect,
8228                source.width,
8229                source.height,
8230                self.renderer.composition_format,
8231            );
8232        let effect_passes = {
8233            let mut effect_scratch_refs = effect_scratch_targets.refs();
8234            let pass_count = self.renderer.effect_renderer.encode_effect(
8235                self.recorder,
8236                &device,
8237                source,
8238                &scratch.view,
8239                effect,
8240                effect_rect,
8241                &mut effect_scratch_refs,
8242            )?;
8243            effect_scratch_refs.assert_consumed()?;
8244            Ok(pass_count)
8245        };
8246        let effect_passes = match effect_passes {
8247            Ok(pass_count) => pass_count,
8248            Err(error) => {
8249                effect_scratch_targets.release_into(self.recorder);
8250                self.recorder
8251                    .release_transient_offscreen(scratch_descriptor, scratch);
8252                return Err(error);
8253            }
8254        };
8255        let composited = {
8256            self.renderer
8257                .effect_renderer
8258                .encode_composite_to_view_projective(
8259                    self.recorder,
8260                    &device,
8261                    &scratch,
8262                    dest_view,
8263                    viewport,
8264                    source_size,
8265                    inverse_matrix,
8266                    dest_bounds,
8267                    alpha,
8268                    load_op,
8269                    scissor,
8270                    supported_blend_mode(blend_mode),
8271                    sample_mode,
8272                )
8273        };
8274        if composited {
8275            self.recorder.record_passes(effect_passes.saturating_add(1));
8276            self.renderer.effect_renderer.record_composite_pass();
8277        } else {
8278            self.recorder.record_passes(effect_passes);
8279        }
8280        effect_scratch_targets.release_into(self.recorder);
8281        self.recorder
8282            .release_transient_offscreen(scratch_descriptor, scratch);
8283        Ok(())
8284    }
8285}
8286
8287impl<C: FrameCommandRecorder> SurfaceExecutionBackend for RecordingSurfaceBackend<'_, '_, C> {
8288    fn max_texture_dim(&self) -> u32 {
8289        self.renderer.max_texture_dim()
8290    }
8291
8292    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
8293        self.renderer.acquire_retained_surface(width, height)
8294    }
8295
8296    fn acquire_frame_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
8297        let descriptor =
8298            self.renderer
8299                .transient_offscreen_descriptor("Frame Surface", width, height);
8300        self.recorder
8301            .acquire_transient_offscreen(&self.renderer.device, descriptor)
8302    }
8303
8304    fn release_frame_surface(&mut self, target: OffscreenTarget) {
8305        let descriptor = self.renderer.transient_offscreen_descriptor(
8306            "Frame Surface",
8307            target.width,
8308            target.height,
8309        );
8310        self.recorder
8311            .release_transient_offscreen(descriptor, target);
8312    }
8313
8314    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
8315        self.renderer.release_layer_surface_target(target);
8316    }
8317
8318    fn cached_layer_surface(
8319        &mut self,
8320        key: &LayerRasterCacheKey,
8321    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
8322        self.renderer.cached_layer_surface(key)
8323    }
8324
8325    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
8326        self.renderer.admit_layer_surface_cache_miss(key)
8327    }
8328
8329    fn insert_cached_layer_surface(
8330        &mut self,
8331        key: LayerRasterCacheKey,
8332        target: OffscreenTarget,
8333        logical_rect: Rect,
8334    ) -> Rc<OffscreenTarget> {
8335        self.renderer
8336            .insert_cached_layer_surface(key, target, logical_rect)
8337    }
8338
8339    fn clear_target_view_with_load_op(
8340        &mut self,
8341        target_view: &wgpu::TextureView,
8342        load_op: wgpu::LoadOp<wgpu::Color>,
8343    ) {
8344        {
8345            let _clear = self
8346                .recorder
8347                .encoder()
8348                .begin_render_pass(&wgpu::RenderPassDescriptor {
8349                    label: Some("Layer Event Clear Pass"),
8350                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8351                        view: target_view,
8352                        resolve_target: None,
8353                        depth_slice: None,
8354                        ops: wgpu::Operations {
8355                            load: load_op,
8356                            store: wgpu::StoreOp::Store,
8357                        },
8358                    })],
8359                    depth_stencil_attachment: None,
8360                    timestamp_writes: None,
8361                    occlusion_query_set: None,
8362                    multiview_mask: None,
8363                });
8364        }
8365        self.recorder.record_pass();
8366    }
8367
8368    #[allow(clippy::too_many_arguments)]
8369    fn render_non_effect_segment(
8370        &mut self,
8371        target_view: &wgpu::TextureView,
8372        shapes: &[DrawShape],
8373        brushes: &[Brush],
8374        images: &[ImageDraw],
8375        texts: &[TextDraw],
8376        shadow_draws: &[ShadowDraw],
8377        retained_draws: &[RetainedDraw],
8378        draw_ops: &[DrawOp],
8379        z_start: usize,
8380        z_end: usize,
8381        effect_z_ranges: &[Range<usize>],
8382        width: u32,
8383        height: u32,
8384        root_scale: f32,
8385        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8386    ) -> Result<(), String> {
8387        self.render_non_effect_segment_with_composites(
8388            target_view,
8389            shapes,
8390            brushes,
8391            images,
8392            texts,
8393            shadow_draws,
8394            retained_draws,
8395            draw_ops,
8396            z_start,
8397            z_end,
8398            effect_z_ranges,
8399            &[],
8400            &[],
8401            width,
8402            height,
8403            root_scale,
8404            initial_load_op,
8405        )
8406    }
8407
8408    #[allow(clippy::too_many_arguments)]
8409    fn render_non_effect_segment_with_composites(
8410        &mut self,
8411        target_view: &wgpu::TextureView,
8412        shapes: &[DrawShape],
8413        brushes: &[Brush],
8414        images: &[ImageDraw],
8415        texts: &[TextDraw],
8416        shadow_draws: &[ShadowDraw],
8417        retained_draws: &[RetainedDraw],
8418        draw_ops: &[DrawOp],
8419        z_start: usize,
8420        z_end: usize,
8421        effect_z_ranges: &[Range<usize>],
8422        composites: &[(usize, CompositeBatchItem<'_>)],
8423        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
8424        width: u32,
8425        height: u32,
8426        root_scale: f32,
8427        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8428    ) -> Result<(), String> {
8429        let mut ordered_items = std::mem::take(&mut self.renderer.scratch_segment_items);
8430        collect_non_effect_segment_items(
8431            shapes,
8432            images,
8433            texts,
8434            shadow_draws,
8435            draw_ops,
8436            z_start,
8437            z_end,
8438            effect_z_ranges,
8439            width,
8440            height,
8441            root_scale,
8442            &mut ordered_items,
8443        );
8444        #[cfg(not(target_arch = "wasm32"))]
8445        let raw_shadow_items = ordered_items
8446            .iter()
8447            .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
8448            .count();
8449        let culled_shadow_items = retain_renderable_shadow_items(
8450            &mut ordered_items,
8451            shadow_draws,
8452            width,
8453            height,
8454            root_scale,
8455            self.renderer.max_texture_dim(),
8456        );
8457        #[cfg(target_arch = "wasm32")]
8458        let _ = culled_shadow_items;
8459        let mut cached_shadow_composites: Vec<(usize, CachedShadowComposite)> = Vec::new();
8460        ordered_items.extend(
8461            composites
8462                .iter()
8463                .enumerate()
8464                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::Composite(index))),
8465        );
8466        ordered_items.extend(
8467            shader_composites
8468                .iter()
8469                .enumerate()
8470                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::ShaderComposite(index))),
8471        );
8472        for (z_index, item) in &mut ordered_items {
8473            let SegmentDrawItem::Shadow(shadow_index) = *item else {
8474                continue;
8475            };
8476            let Some(composite) = self.renderer.cached_shape_shadow_composite(
8477                &shadow_draws[shadow_index],
8478                width,
8479                height,
8480                root_scale,
8481            ) else {
8482                continue;
8483            };
8484            let composite_index = composites.len() + cached_shadow_composites.len();
8485            cached_shadow_composites.push((*z_index, composite));
8486            *item = SegmentDrawItem::Composite(composite_index);
8487        }
8488        let mut merged_composites = Vec::with_capacity(
8489            composites
8490                .len()
8491                .saturating_add(cached_shadow_composites.len()),
8492        );
8493        merged_composites.extend(composites.iter().copied());
8494        merged_composites.extend(
8495            cached_shadow_composites
8496                .iter()
8497                .map(|(z_index, composite)| (*z_index, composite.batch_item())),
8498        );
8499        // Z indices are unique — the scene hands every op its own `next_z` — so an
8500        // unstable sort cannot reorder anything a stable one wouldn't, and it skips
8501        // the stable sort's scratch allocation, paid here once per segment per frame.
8502        ordered_items.sort_unstable_by_key(|(z_index, _)| *z_index);
8503        #[cfg(not(target_arch = "wasm32"))]
8504        maybe_print_segment_diag(
8505            z_start..z_end,
8506            &ordered_items,
8507            shapes,
8508            brushes,
8509            images,
8510            SegmentDiagCounts {
8511                raw_shadow_items,
8512                culled_shadow_items,
8513                cached_shadow_composites: cached_shadow_composites.len(),
8514                composite_items: merged_composites.len(),
8515                shader_composite_items: shader_composites.len(),
8516            },
8517            self.renderer.shape_batch_limits,
8518        );
8519        let result = if ordered_items.is_empty() {
8520            Ok(SegmentCommandEncodeOutcome { first_batch: true })
8521        } else {
8522            self.renderer.encode_non_effect_segment_commands(
8523                self.recorder,
8524                target_view,
8525                &ordered_items,
8526                &merged_composites,
8527                shader_composites,
8528                shapes,
8529                brushes,
8530                images,
8531                texts,
8532                shadow_draws,
8533                retained_draws,
8534                initial_load_op,
8535                width,
8536                height,
8537                root_scale,
8538            )
8539        };
8540        self.renderer.scratch_segment_items = ordered_items;
8541        let outcome = result?;
8542        if outcome.first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
8543            self.clear_target_view_with_load_op(target_view, initial_load_op);
8544        }
8545        Ok(())
8546    }
8547
8548    fn render_range_with_layer_events_to_target(
8549        &mut self,
8550        target: &OffscreenTarget,
8551        shapes: &[DrawShape],
8552        brushes: &[Brush],
8553        images: &[ImageDraw],
8554        texts: &[TextDraw],
8555        shadow_draws: &[ShadowDraw],
8556        retained_draws: &[RetainedDraw],
8557        draw_ops: &[DrawOp],
8558        effect_layers: &[EffectLayer],
8559        backdrop_layers: &[BackdropLayer],
8560        backdrop_input_hashes: &[u64],
8561        z_start: usize,
8562        z_end: usize,
8563        excluded_effect_layer: Option<usize>,
8564        width: u32,
8565        height: u32,
8566        root_scale: f32,
8567        backdrop_underlay: Option<&OffscreenTarget>,
8568        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8569    ) -> Result<(), String> {
8570        self.render_range_with_layer_events_to_target_recorded(
8571            target,
8572            shapes,
8573            brushes,
8574            images,
8575            texts,
8576            shadow_draws,
8577            retained_draws,
8578            draw_ops,
8579            effect_layers,
8580            backdrop_layers,
8581            backdrop_input_hashes,
8582            z_start,
8583            z_end,
8584            excluded_effect_layer,
8585            width,
8586            height,
8587            root_scale,
8588            backdrop_underlay,
8589            initial_load_op,
8590        )
8591    }
8592
8593    fn render_shadow_draw(
8594        &mut self,
8595        target_view: &wgpu::TextureView,
8596        shadow: &ShadowDraw,
8597        width: u32,
8598        height: u32,
8599        root_scale: f32,
8600    ) {
8601        self.renderer.encode_shadow_draw(
8602            self.recorder,
8603            target_view,
8604            shadow,
8605            width,
8606            height,
8607            root_scale,
8608        );
8609    }
8610
8611    fn composite_to_view_projective(
8612        &mut self,
8613        source: &OffscreenTarget,
8614        dest_view: &wgpu::TextureView,
8615        viewport: (u32, u32),
8616        source_size: (f32, f32),
8617        inverse_matrix: [[f32; 3]; 3],
8618        dest_bounds: [[f32; 2]; 4],
8619        alpha: f32,
8620        load_op: wgpu::LoadOp<wgpu::Color>,
8621        scissor: Option<(u32, u32, u32, u32)>,
8622        blend_mode: BlendMode,
8623        sample_mode: CompositeSampleMode,
8624    ) {
8625        let device = self.renderer.device.clone();
8626        let composited = {
8627            self.renderer
8628                .effect_renderer
8629                .encode_composite_to_view_projective(
8630                    self.recorder,
8631                    &device,
8632                    source,
8633                    dest_view,
8634                    viewport,
8635                    source_size,
8636                    inverse_matrix,
8637                    dest_bounds,
8638                    alpha,
8639                    load_op,
8640                    scissor,
8641                    supported_blend_mode(blend_mode),
8642                    sample_mode,
8643                )
8644        };
8645        if composited {
8646            self.recorder.record_pass();
8647            self.renderer.effect_renderer.record_composite_pass();
8648        }
8649    }
8650
8651    fn composite_projective_surfaces_to_view(
8652        &mut self,
8653        dest_view: &wgpu::TextureView,
8654        viewport: (u32, u32),
8655        composites: &[ProjectiveSurfaceComposite<'_>],
8656    ) {
8657        let device = self.renderer.device.clone();
8658        let mut composite_count = 0_u32;
8659        for composite in composites
8660            .iter()
8661            .copied()
8662            .filter(|composite| projective_dest_bounds_rect(composite.dest_bounds).is_some())
8663        {
8664            let composited = {
8665                self.renderer
8666                    .effect_renderer
8667                    .encode_composite_to_view_projective(
8668                        self.recorder,
8669                        &device,
8670                        composite.source,
8671                        dest_view,
8672                        viewport,
8673                        composite.source_size,
8674                        composite.inverse_matrix,
8675                        composite.dest_bounds,
8676                        composite.alpha,
8677                        composite.load_op,
8678                        composite.scissor,
8679                        supported_blend_mode(composite.blend_mode),
8680                        composite.sample_mode,
8681                    )
8682            };
8683            if composited {
8684                composite_count = composite_count.saturating_add(1);
8685            }
8686        }
8687        if composite_count > 0 {
8688            self.recorder.record_passes(composite_count);
8689            self.renderer
8690                .effect_renderer
8691                .debug_composites
8692                .set(self.renderer.effect_renderer.debug_composites.get() + composite_count);
8693        }
8694    }
8695
8696    fn composite_surface_batch_to_view(
8697        &mut self,
8698        dest_view: &wgpu::TextureView,
8699        viewport: (u32, u32),
8700        load_op: wgpu::LoadOp<wgpu::Color>,
8701        composites: &[CompositeBatchItem<'_>],
8702    ) {
8703        if composites.is_empty() {
8704            return;
8705        }
8706        let device = self.renderer.device.clone();
8707        self.renderer
8708            .effect_renderer
8709            .encode_composite_batch_to_view_pass(
8710                self.recorder,
8711                &device,
8712                dest_view,
8713                viewport,
8714                load_op,
8715                composites,
8716            );
8717        self.recorder.record_pass();
8718        self.renderer.effect_renderer.record_composite_pass();
8719    }
8720
8721    fn copy_texture_region_to_target(
8722        &mut self,
8723        source: &OffscreenTarget,
8724        source_origin: (u32, u32),
8725        target: &OffscreenTarget,
8726        size: (u32, u32),
8727    ) -> bool {
8728        let (width, height) = size;
8729        if width == 0 || height == 0 || width > target.width || height > target.height {
8730            return false;
8731        }
8732        let Some(source_right) = source_origin.0.checked_add(width) else {
8733            return false;
8734        };
8735        let Some(source_bottom) = source_origin.1.checked_add(height) else {
8736            return false;
8737        };
8738        if source_right > source.width || source_bottom > source.height {
8739            return false;
8740        }
8741
8742        self.recorder.encoder().copy_texture_to_texture(
8743            wgpu::TexelCopyTextureInfo {
8744                texture: source.texture(),
8745                mip_level: 0,
8746                origin: wgpu::Origin3d {
8747                    x: source_origin.0,
8748                    y: source_origin.1,
8749                    z: 0,
8750                },
8751                aspect: wgpu::TextureAspect::All,
8752            },
8753            wgpu::TexelCopyTextureInfo {
8754                texture: target.texture(),
8755                mip_level: 0,
8756                origin: wgpu::Origin3d::ZERO,
8757                aspect: wgpu::TextureAspect::All,
8758            },
8759            wgpu::Extent3d {
8760                width,
8761                height,
8762                depth_or_array_layers: 1,
8763            },
8764        );
8765        true
8766    }
8767
8768    fn shader_composite_batch_to_view(
8769        &mut self,
8770        dest_view: &wgpu::TextureView,
8771        viewport: (u32, u32),
8772        load_op: wgpu::LoadOp<wgpu::Color>,
8773        composites: &[ShaderCompositeBatchItem<'_>],
8774    ) -> bool {
8775        if composites.is_empty() {
8776            return true;
8777        }
8778        let device = self.renderer.device.clone();
8779        let encoded = self
8780            .renderer
8781            .effect_renderer
8782            .encode_shader_batch_src_over_to_view(
8783                self.recorder,
8784                &device,
8785                dest_view,
8786                viewport,
8787                load_op,
8788                composites,
8789            );
8790        if encoded {
8791            self.recorder.record_pass();
8792            self.renderer.effect_renderer.record_composite_pass();
8793            self.renderer
8794                .effect_renderer
8795                .debug_effects
8796                .set(self.renderer.effect_renderer.debug_effects.get() + composites.len() as u32);
8797        }
8798        encoded
8799    }
8800
8801    fn composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8802        &mut self,
8803        source: &OffscreenTarget,
8804        dest_view: &wgpu::TextureView,
8805        alpha: f32,
8806        load_op: wgpu::LoadOp<wgpu::Color>,
8807        scissor: Option<(u32, u32, u32, u32)>,
8808        rounded_mask: Option<RoundedCompositeMask>,
8809        blend_mode: BlendMode,
8810        dest_viewport: Option<(f32, f32, f32, f32)>,
8811        sample_mode: CompositeSampleMode,
8812    ) {
8813        let device = self.renderer.device.clone();
8814        {
8815            self.renderer
8816                .effect_renderer
8817                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8818                    self.recorder,
8819                    &device,
8820                    source,
8821                    dest_view,
8822                    alpha,
8823                    load_op,
8824                    scissor,
8825                    rounded_mask,
8826                    supported_blend_mode(blend_mode),
8827                    dest_viewport,
8828                    sample_mode,
8829                );
8830        }
8831        self.recorder.record_pass();
8832        self.renderer.effect_renderer.record_composite_pass();
8833    }
8834
8835    fn apply_effect_and_composite_to_view(
8836        &mut self,
8837        source: &OffscreenTarget,
8838        effect: &RenderEffect,
8839        effect_rect: [f32; 4],
8840        dest_view: &wgpu::TextureView,
8841        alpha: f32,
8842        load_op: wgpu::LoadOp<wgpu::Color>,
8843        scissor: Option<(u32, u32, u32, u32)>,
8844        blend_mode: BlendMode,
8845        dest_viewport: Option<(f32, f32, f32, f32)>,
8846        sample_mode: CompositeSampleMode,
8847    ) -> Result<(), String> {
8848        self.record_effect_composite(
8849            source,
8850            effect,
8851            effect_rect,
8852            dest_view,
8853            alpha,
8854            load_op,
8855            scissor,
8856            blend_mode,
8857            dest_viewport,
8858            sample_mode,
8859        )
8860    }
8861
8862    fn apply_shader_and_composite_to_view(
8863        &mut self,
8864        source: &OffscreenTarget,
8865        shader: &RuntimeShader,
8866        effect_rect: [f32; 4],
8867        dest_view: &wgpu::TextureView,
8868        alpha: f32,
8869        load_op: wgpu::LoadOp<wgpu::Color>,
8870        scissor: Option<(u32, u32, u32, u32)>,
8871        blend_mode: BlendMode,
8872        dest_viewport: Option<(f32, f32, f32, f32)>,
8873        sample_mode: CompositeSampleMode,
8874    ) {
8875        self.record_shader_composite(
8876            source,
8877            shader,
8878            effect_rect,
8879            dest_view,
8880            alpha,
8881            load_op,
8882            scissor,
8883            blend_mode,
8884            dest_viewport,
8885            sample_mode,
8886        );
8887    }
8888
8889    fn apply_shader_and_composite_to_view_projective(
8890        &mut self,
8891        source: &OffscreenTarget,
8892        shader: &RuntimeShader,
8893        effect_rect: [f32; 4],
8894        dest_view: &wgpu::TextureView,
8895        viewport: (u32, u32),
8896        source_size: (f32, f32),
8897        inverse_matrix: [[f32; 3]; 3],
8898        dest_bounds: [[f32; 2]; 4],
8899        alpha: f32,
8900        load_op: wgpu::LoadOp<wgpu::Color>,
8901        scissor: Option<(u32, u32, u32, u32)>,
8902        blend_mode: BlendMode,
8903        sample_mode: CompositeSampleMode,
8904    ) {
8905        self.record_shader_projective_composite(
8906            source,
8907            shader,
8908            effect_rect,
8909            dest_view,
8910            viewport,
8911            source_size,
8912            inverse_matrix,
8913            dest_bounds,
8914            alpha,
8915            load_op,
8916            scissor,
8917            blend_mode,
8918            sample_mode,
8919        );
8920    }
8921
8922    fn apply_effect_and_composite_to_view_projective(
8923        &mut self,
8924        source: &OffscreenTarget,
8925        effect: &RenderEffect,
8926        effect_rect: [f32; 4],
8927        dest_view: &wgpu::TextureView,
8928        viewport: (u32, u32),
8929        source_size: (f32, f32),
8930        inverse_matrix: [[f32; 3]; 3],
8931        dest_bounds: [[f32; 2]; 4],
8932        alpha: f32,
8933        load_op: wgpu::LoadOp<wgpu::Color>,
8934        scissor: Option<(u32, u32, u32, u32)>,
8935        blend_mode: BlendMode,
8936        sample_mode: CompositeSampleMode,
8937    ) -> Result<(), String> {
8938        self.record_effect_projective_composite(
8939            source,
8940            effect,
8941            effect_rect,
8942            dest_view,
8943            viewport,
8944            source_size,
8945            inverse_matrix,
8946            dest_bounds,
8947            alpha,
8948            load_op,
8949            scissor,
8950            blend_mode,
8951            sample_mode,
8952        )
8953    }
8954
8955    fn is_render_effect_supported(&self, effect: &RenderEffect) -> bool {
8956        self.renderer.supports_render_effect(effect)
8957    }
8958
8959    fn warn_unsupported_effect_once(&self) {
8960        self.renderer.warning_state.warn_unsupported_effect_once();
8961    }
8962
8963    fn record_layer_cache_miss(&self, width: u32, height: u32) {
8964        self.renderer
8965            .frame_stats
8966            .record_layer_cache_miss(width, height);
8967    }
8968
8969    fn record_isolated_layer_render(
8970        &self,
8971        width: u32,
8972        height: u32,
8973        node_id: Option<NodeId>,
8974        logical_rect: Rect,
8975        requirements: SurfaceRequirementSet,
8976    ) {
8977        self.renderer.frame_stats.record_isolated_layer_render(
8978            width,
8979            height,
8980            node_id,
8981            logical_rect,
8982            requirements.into(),
8983        );
8984    }
8985}
8986
8987impl GpuRenderer {
8988    #[allow(clippy::too_many_arguments)]
8989    pub fn render(
8990        &mut self,
8991        view: &wgpu::TextureView,
8992        width: u32,
8993        height: u32,
8994        packet: FramePacket,
8995        surface_epoch: u64,
8996        returns: &mut RenderReturns,
8997    ) -> Result<(), String> {
8998        self.render_internal(
8999            width,
9000            height,
9001            packet,
9002            surface_epoch,
9003            returns,
9004            OutputMode::Display,
9005            Some(view),
9006        )
9007    }
9008
9009    #[allow(clippy::too_many_arguments)]
9010    fn render_internal(
9011        &mut self,
9012        width: u32,
9013        height: u32,
9014        mut packet: FramePacket,
9015        surface_epoch: u64,
9016        returns: &mut RenderReturns,
9017        output_mode: OutputMode,
9018        output_view: Option<&wgpu::TextureView>,
9019    ) -> Result<(), String> {
9020        // Threaded mode rides the emptied ack-confirmations buffer back to
9021        // the store inside the next packet ([`FramePacket::recycled_confirmations`]);
9022        // adopt it before the validity gate so even a cancelled packet
9023        // cannot leak the capacity. Sync callers always carry `None`.
9024        if let Some(confirmations) = packet.recycled_confirmations.take() {
9025            self.restore_replay_ack_confirmations(confirmations);
9026        }
9027        // Packet validity gate — BEFORE consume_replay_ops and any
9028        // encoding. A packet built against another renderer instance,
9029        // another surface configuration, or another viewport is cancelled
9030        // whole: its buffers travel back through `returns` for re-queue
9031        // and recycling, and nothing of it reaches the GPU.
9032        let cancel_reason = if packet.renderer_epoch != self.renderer_epoch {
9033            Some(CancelReason::RendererEpoch)
9034        } else if packet.surface_epoch != surface_epoch {
9035            Some(CancelReason::SurfaceEpoch)
9036        } else if packet.viewport != (width, height) {
9037            Some(CancelReason::Viewport)
9038        } else {
9039            None
9040        };
9041        if let Some(reason) = cancel_reason {
9042            return Self::cancel_packet(packet, reason, returns);
9043        }
9044        // Device-error gate — same protocol as the validity gate above: an
9045        // uncaptured error recorded since the last frame cancels this
9046        // packet whole, so nothing is encoded on the suspect device. The
9047        // take clears the poison, so the NEXT packet renders — one skipped
9048        // frame per poisoning, the acquire path's give-up-this-frame
9049        // semantics ([`DeviceErrorSentry`]).
9050        if self.device_errors.take_poison() {
9051            return Self::cancel_packet(packet, CancelReason::DeviceError, returns);
9052        }
9053        returns.frame_id = packet.frame_id;
9054        log::trace!("🎨 Rendering graph to {}x{}", width, height);
9055        let render_start = Instant::now();
9056
9057        #[cfg(target_arch = "wasm32")]
9058        {
9059            self.wasm_uniform_batch_cursor = 0;
9060            self.wasm_shape_batch_cursor = 0;
9061            self.wasm_image_batch_cursor = 0;
9062        }
9063        #[cfg(not(target_arch = "wasm32"))]
9064        {
9065            self.retained_glyph_uniform_cursor = 0;
9066            // Transient rim meshes live for exactly one frame: the scratch
9067            // restarts here and every fused chunk appends after the region
9068            // already uploaded (the GPU buffers themselves are fixed-capacity
9069            // and persist).
9070            self.rim_mesh_vertices.clear();
9071            self.rim_mesh_indices.clear();
9072            self.rim_mesh_uploaded_vertices = 0;
9073            self.rim_mesh_uploaded_indices = 0;
9074            if fill_area_diag_enabled() {
9075                self.fill_area_diag.reset_frame(width, height);
9076            }
9077            // One engagement per frame: the first fused partition carrying
9078            // the frame's opaque clear consumes this.
9079            self.static_span.armed = true;
9080            // Segment-surface frame boundary: config refresh, capture-slot
9081            // cursor reset, periodic idle sweep.
9082            self.segment_surfaces.begin_frame();
9083            // The frame's root target, held for the graph walk only: the
9084            // display clip cull compares fused-pass targets against it so
9085            // nothing but the real surface pass is ever culled.
9086        }
9087
9088        // Producer-side text layout cache size, carried by the packet — the
9089        // present call tree holds no text layout state, and no layout runs
9090        // between packet build and the stats block below.
9091        let text_cache_len = packet.text_cache_len;
9092        let composition = self.take_composition_target(width.max(1), height.max(1));
9093        #[cfg(not(target_arch = "wasm32"))]
9094        {
9095            self.display_clip.frame_root_view = Some(composition.target.view.clone());
9096        }
9097        let composition_root = Some(&composition.target);
9098        let screenshot_bind_group = output_view.and_then(|_| {
9099            matches!(output_mode, OutputMode::Screenshot).then(|| {
9100                self.screenshot_converter
9101                    .bind_group(&self.device, &composition.target.view)
9102            })
9103        });
9104        let output = output_view.map(|view| {
9105            let bind_group = screenshot_bind_group
9106                .as_ref()
9107                .unwrap_or(&composition.output_bind_group);
9108            (view, bind_group)
9109        });
9110        let result = self.render_graph(
9111            &composition.target.view,
9112            composition_root,
9113            packet,
9114            returns,
9115            output_mode,
9116            output,
9117        );
9118        self.composition_target = Some(composition);
9119        #[cfg(not(target_arch = "wasm32"))]
9120        {
9121            self.display_clip.frame_root_view = None;
9122        }
9123        let after_graph = Instant::now();
9124        self.flush_deferred_offscreen_releases();
9125        #[cfg(not(target_arch = "wasm32"))]
9126        {
9127            if fill_area_diag_enabled() {
9128                // Effect/composite fill accumulated during the graph walk
9129                // lives in the effect renderer's own cells; fold it into
9130                // this frame before the window closes over it.
9131                let (composite_px2, offscreen_px2) = self.effect_renderer.take_fill_diag_fill_px2();
9132                self.fill_area_diag
9133                    .add_effect_fill(composite_px2, offscreen_px2);
9134                self.fill_area_diag.finish_frame(width, height);
9135            }
9136        }
9137
9138        #[cfg(target_arch = "wasm32")]
9139        {
9140            const WASM_BATCH_POOL_MARGIN: usize = 4;
9141            self.wasm_uniform_batches.truncate(
9142                self.wasm_uniform_batch_cursor
9143                    .saturating_add(WASM_BATCH_POOL_MARGIN),
9144            );
9145            self.wasm_shape_batches.truncate(
9146                self.wasm_shape_batch_cursor
9147                    .saturating_add(WASM_BATCH_POOL_MARGIN),
9148            );
9149            self.wasm_image_batches.truncate(
9150                self.wasm_image_batch_cursor
9151                    .saturating_add(WASM_BATCH_POOL_MARGIN),
9152            );
9153        }
9154        self.staged_uploads
9155            .shrink_retained_capacity(RETAINED_STAGED_UPLOAD_BYTES, RETAINED_STAGED_UPLOAD_COPIES);
9156
9157        self.layer_surface_cache.finish_frame(&self.frame_stats);
9158        for target in self.layer_surface_cache.take_recycled() {
9159            self.defer_offscreen_release(target);
9160        }
9161        #[cfg(not(target_arch = "wasm32"))]
9162        self.retained_bundle_cache.end_frame();
9163
9164        self.frame_stats.offscreen_pool_size.set(
9165            self.effect_renderer
9166                .retained_offscreen_count()
9167                .saturating_add(self.frame_graph_executor.retained_texture_count())
9168                .saturating_add(usize::from(self.composition_target.is_some())) as u32,
9169        );
9170        self.frame_stats.offscreen_pool_bytes.set(
9171            (self.effect_renderer.retained_offscreen_bytes() as u64)
9172                .saturating_add(self.frame_graph_executor.retained_texture_bytes())
9173                .saturating_add(
9174                    self.composition_target
9175                        .as_ref()
9176                        .map(|target| {
9177                            u64::from(target.target.width)
9178                                .saturating_mul(u64::from(target.target.height))
9179                                .saturating_mul(composition_bytes_per_pixel())
9180                        })
9181                        .unwrap_or(0),
9182                ),
9183        );
9184        self.frame_stats
9185            .text_pool_size
9186            .set(self.text_image_cache.len() as u32);
9187        self.frame_stats
9188            .image_cache_size
9189            .set(self.image_texture_cache.len() as u32);
9190        self.frame_stats.text_cache_size.set(text_cache_len as u32);
9191        self.effect_renderer
9192            .merge_and_reset_debug_counters(&self.frame_stats);
9193        self.frame_graph_executor.reset_upload_allocators();
9194        let snapshot = self.frame_stats.snapshot();
9195        self.last_frame_stats = Some(snapshot);
9196        PRESENTED_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9197        update_frame_warmup_budget(&mut self.pending_frame_warmup_frames, &snapshot);
9198        self.frame_stats.maybe_print_snapshot(
9199            snapshot,
9200            &mut self.frame_count,
9201            self.gpu_stats_enabled,
9202        );
9203        if self.gpu_stats_enabled && self.frame_count.is_multiple_of(60) {
9204            gpu_stats::print_gpu_memory_report(&self.device, self.frame_count);
9205        }
9206        self.frame_stats.reset();
9207        let after_stats = Instant::now();
9208        if let Some(total_ms) = should_log_wgpu_render_stage(render_start, after_stats) {
9209            log::warn!(
9210                "[wgpu-render-stage:render] total_ms={total_ms:.2} graph_ms={:.2} cleanup_stats_ms={:.2}",
9211                instant_ms(render_start, after_graph),
9212                instant_ms(after_graph, after_stats),
9213            );
9214        }
9215        if result.is_ok() {
9216            // Only a draw that actually ran may report `Presented`; an
9217            // errored draw leaves the default `NotRun`.
9218            returns.outcome = PresentOutcome::Presented;
9219        }
9220        result
9221    }
9222
9223    /// Refuses a packet whole, before any encoding: every buffer it
9224    /// carries travels back through `returns` — the direct scene for the
9225    /// producer pool, the unconsumed replay plan for the planner to
9226    /// re-queue (its releases name still-live store slots; dropping them
9227    /// would leak pool ids forever). A cancel is a protocol outcome, not a
9228    /// draw error, so the render call returns `Ok(())`.
9229    ///
9230    /// `pub(crate)` for the present runtime, which must cancel a packet
9231    /// that cannot render at all (surface dropped) without touching the
9232    /// GPU. Callers that bypass [`render`][Self::render] must take the
9233    /// packet's `recycled_confirmations` first — this refuses the packet
9234    /// without a store to adopt them into.
9235    pub(crate) fn cancel_packet(
9236        packet: FramePacket,
9237        reason: CancelReason,
9238        returns: &mut RenderReturns,
9239    ) -> Result<(), String> {
9240        let FramePacket {
9241            frame_id,
9242            viewport: _,
9243            renderer_epoch: _,
9244            surface_epoch: _,
9245            root_scale: _,
9246            root,
9247            overlay: _,
9248            replay,
9249            text_cache_len: _,
9250            recycled_confirmations: _,
9251            replay_preconsumed,
9252        } = packet;
9253        match root {
9254            PacketRoot::Direct(root) => {
9255                // Destructure: the scene buffers return to the producer
9256                // pool; the rest of the collected layer drops. A Direct
9257                // packet's replay plan came from the planner and must go
9258                // back to it unconsumed — a Surface packet only ever
9259                // carries the empty default plan, which has nothing to
9260                // reclaim. A plan the present stage already consumed
9261                // (`take_replay_ack_early`) is not here to reclaim: the
9262                // store honored it and its ack is on the way to the
9263                // planner, so `replay` holds only the taken-out default.
9264                returns.scene = Some(root.scene);
9265                #[cfg(not(target_arch = "wasm32"))]
9266                if !replay_preconsumed {
9267                    returns.cancelled_replay = Some(replay);
9268                }
9269            }
9270            PacketRoot::Surface(_) => {}
9271        }
9272        #[cfg(target_arch = "wasm32")]
9273        let _ = (replay, replay_preconsumed);
9274        returns.ack = None;
9275        returns.frame_id = frame_id;
9276        returns.outcome = PresentOutcome::Cancelled(reason);
9277        Ok(())
9278    }
9279
9280    pub fn last_frame_stats(&self) -> Option<gpu_stats::FrameStatsSnapshot> {
9281        self.last_frame_stats
9282    }
9283
9284    pub fn needs_frame_warmup(&self) -> bool {
9285        self.pending_frame_warmup_frames > 0
9286    }
9287
9288    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
9289        let layer_surface_cache_stats = self.layer_surface_cache.debug_stats();
9290        DebugCpuAllocationStats {
9291            scene_graph_node_count: 0,
9292            scene_graph_heap_bytes: 0,
9293            scene_hits_len: 0,
9294            scene_hits_cap: 0,
9295            scene_node_index_len: 0,
9296            scene_node_index_cap: 0,
9297            text_renderer_pool_len: self.text_image_cache.len(),
9298            text_renderer_pool_cap: self.text_image_cache.cap().get(),
9299            swash_image_cache_len: 0,
9300            swash_image_cache_cap: 0,
9301            swash_outline_cache_len: 0,
9302            swash_outline_cache_cap: 0,
9303            image_texture_cache_len: self.image_texture_cache.len(),
9304            image_texture_cache_cap: self.image_texture_cache.cap().get(),
9305            scratch_shape_data_cap: self.scratch_shape_data.capacity(),
9306            scratch_gradients_cap: self.scratch_gradients.capacity(),
9307            scratch_image_vertices_cap: self.scratch_image_vertices.capacity(),
9308            scratch_image_indices_cap: self.scratch_image_indices.capacity(),
9309            scratch_image_cmds_cap: self.scratch_image_cmds.capacity(),
9310            scratch_segment_items_cap: self.scratch_segment_items.capacity(),
9311            scratch_effect_ranges_cap: self.scratch_effect_ranges.capacity(),
9312            scratch_layer_events_cap: self.scratch_layer_events.capacity(),
9313            staged_upload_bytes_cap: self.staged_uploads.bytes.capacity(),
9314            staged_upload_copies_cap: self.staged_uploads.copies.capacity(),
9315            layer_surface_cache_len: layer_surface_cache_stats.entries_len,
9316            layer_surface_cache_cap: layer_surface_cache_stats.entries_cap,
9317            layer_surface_cache_identity_len: layer_surface_cache_stats.identity_len,
9318            layer_surface_cache_identity_cap: layer_surface_cache_stats.identity_cap,
9319            // The producer frontend owns the only lowering-memo pair since
9320            // step 6b; the present backend contributes nothing.
9321            layer_surface_rect_cache_len: 0,
9322            layer_surface_rect_cache_cap: 0,
9323            layer_surface_requirements_cache_len: 0,
9324            layer_surface_requirements_cache_cap: 0,
9325            layer_cache_seen_this_frame_len: layer_surface_cache_stats.seen_this_frame_len,
9326            layer_cache_seen_this_frame_cap: layer_surface_cache_stats.seen_this_frame_cap,
9327        }
9328    }
9329
9330    pub fn render_to_rgba_pixels(
9331        &mut self,
9332        width: u32,
9333        height: u32,
9334        packet: FramePacket,
9335        surface_epoch: u64,
9336        returns: &mut RenderReturns,
9337    ) -> Result<Vec<u8>, String> {
9338        if width == 0 || height == 0 {
9339            return Err("Screenshot size must be non-zero".to_string());
9340        }
9341
9342        let output_texture = self.device.create_texture(&wgpu::TextureDescriptor {
9343            label: Some("Screenshot Output Texture"),
9344            size: wgpu::Extent3d {
9345                width,
9346                height,
9347                depth_or_array_layers: 1,
9348            },
9349            mip_level_count: 1,
9350            sample_count: 1,
9351            dimension: wgpu::TextureDimension::D2,
9352            format: wgpu::TextureFormat::Rgba8Unorm,
9353            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
9354            view_formats: &[],
9355        });
9356        let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
9357        self.render_internal(
9358            width,
9359            height,
9360            packet,
9361            surface_epoch,
9362            returns,
9363            OutputMode::Screenshot,
9364            Some(&output_view),
9365        )?;
9366
9367        let bytes_per_pixel = 4u32;
9368        let unpadded_bytes_per_row = width
9369            .checked_mul(bytes_per_pixel)
9370            .ok_or_else(|| "Screenshot row byte size overflow".to_string())?;
9371        let padded_bytes_per_row =
9372            align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
9373        let output_buffer_size = padded_bytes_per_row as u64 * height as u64;
9374
9375        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9376            label: Some("Screenshot Readback Buffer"),
9377            size: output_buffer_size,
9378            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
9379            mapped_at_creation: false,
9380        });
9381
9382        let device = self.device.clone();
9383        let queue = self.queue.clone();
9384        let mut graph = WgpuFrameGraph::new(Some("Screenshot Copy Encoder"));
9385        let source = graph.import_surface("screenshot-copy-source");
9386        graph.add_fallible_command_pass(Some("Screenshot Copy Pass"), &[source], &[], |context| {
9387            context.encoder.copy_texture_to_buffer(
9388                wgpu::TexelCopyTextureInfo {
9389                    texture: &output_texture,
9390                    mip_level: 0,
9391                    origin: wgpu::Origin3d::ZERO,
9392                    aspect: wgpu::TextureAspect::All,
9393                },
9394                wgpu::TexelCopyBufferInfo {
9395                    buffer: &output_buffer,
9396                    layout: wgpu::TexelCopyBufferLayout {
9397                        offset: 0,
9398                        bytes_per_row: Some(padded_bytes_per_row),
9399                        rows_per_image: Some(height),
9400                    },
9401                },
9402                wgpu::Extent3d {
9403                    width,
9404                    height,
9405                    depth_or_array_layers: 1,
9406                },
9407            );
9408            Ok(())
9409        });
9410        let mut executor = std::mem::take(&mut self.frame_graph_executor);
9411        let execution = executor.execute_recorded_graph(&device, &queue, graph);
9412        self.frame_graph_executor = executor;
9413        let execution = execution.map_err(|error| error.to_string())?;
9414        let submission_index = execution.submission;
9415        let copy_stats = execution.stats;
9416        self.last_frame_stats = self
9417            .last_frame_stats
9418            .map(|snapshot| snapshot.with_command_stats_added(copy_stats));
9419
9420        let buffer_slice = output_buffer.slice(..);
9421        let (tx, rx) = mpsc::channel();
9422        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
9423            let _ = tx.send(result);
9424        });
9425        let _ = self.device.poll(wgpu::PollType::Wait {
9426            submission_index: Some(submission_index),
9427            timeout: None,
9428        });
9429
9430        match rx.recv_timeout(Duration::from_secs(3)) {
9431            Ok(Ok(())) => {}
9432            Ok(Err(err)) => return Err(format!("Screenshot map_async failed: {err:?}")),
9433            Err(err) => return Err(format!("Screenshot readback timed out: {err}")),
9434        }
9435
9436        let mapped = buffer_slice.get_mapped_range();
9437        let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
9438
9439        let src_row_len = padded_bytes_per_row as usize;
9440        let dst_row_len = unpadded_bytes_per_row as usize;
9441        for row in 0..height as usize {
9442            let src_offset = row * src_row_len;
9443            let dst_offset = row * dst_row_len;
9444            pixels[dst_offset..dst_offset + dst_row_len]
9445                .copy_from_slice(&mapped[src_offset..src_offset + dst_row_len]);
9446        }
9447        drop(mapped);
9448        output_buffer.unmap();
9449
9450        self.convert_surface_pixels_to_rgba(&pixels)
9451    }
9452
9453    fn render_graph(
9454        &mut self,
9455        surface_view: &wgpu::TextureView,
9456        root_target: Option<&OffscreenTarget>,
9457        packet: FramePacket,
9458        returns: &mut RenderReturns,
9459        output_mode: OutputMode,
9460        output: Option<(&wgpu::TextureView, &wgpu::BindGroup)>,
9461    ) -> Result<(), String> {
9462        let device = self.device.clone();
9463        let queue = self.queue.clone();
9464        let graph_start = Instant::now();
9465
9466        #[cfg(not(target_arch = "wasm32"))]
9467        {
9468            let mut executor = std::mem::take(&mut self.frame_graph_executor);
9469            let mut frame_graph = WgpuFrameGraph::new(Some("Renderer Frame Graph"));
9470            let surface = frame_graph.import_surface("renderer-surface");
9471            frame_graph.add_fallible_recorded_command_pass(
9472                Some("Renderer Frame Pass"),
9473                &[],
9474                &[surface],
9475                |frame_encoder| {
9476                    self.render_graph_recorded(
9477                        surface_view,
9478                        root_target,
9479                        packet,
9480                        returns,
9481                        frame_encoder,
9482                    )?;
9483                    if let Some((output_view, bind_group)) = output {
9484                        match output_mode {
9485                            OutputMode::Display => &self.output_converter,
9486                            OutputMode::Screenshot => &self.screenshot_converter,
9487                        }
9488                        .encode(
9489                            &self.device,
9490                            frame_encoder.encoder(),
9491                            output_view,
9492                            bind_group,
9493                            self.adapter_backend,
9494                        );
9495                        frame_encoder.record_pass();
9496                    }
9497                    Ok(())
9498                },
9499            );
9500            let after_build = Instant::now();
9501            let execution = executor.execute_recorded_graph(&device, &queue, frame_graph);
9502            let after_execute = Instant::now();
9503            self.frame_graph_executor = executor;
9504            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
9505                log::warn!(
9506                    "[wgpu-render-stage:graph] total_ms={total_ms:.2} build_ms={:.2} execute_ms={:.2}",
9507                    instant_ms(graph_start, after_build),
9508                    instant_ms(after_build, after_execute),
9509                );
9510            }
9511
9512            match execution {
9513                Ok(execution) => {
9514                    if execution.stats.pass_count > 0 {
9515                        self.frame_stats.record_command_stats(execution.stats);
9516                    }
9517                    Ok(())
9518                }
9519                Err(crate::frame_graph::FrameGraphError::NoDeclaredPasses) => Ok(()),
9520                Err(error) => Err(error.to_string()),
9521            }
9522        }
9523
9524        #[cfg(target_arch = "wasm32")]
9525        {
9526            let mut executor = std::mem::take(&mut self.frame_graph_executor);
9527            let (result, execution) = {
9528                let mut frame_encoder =
9529                    executor.begin(&device, &queue, Some("Renderer Frame Encoder"));
9530                let initial_pass_count = frame_encoder.recorded_pass_count();
9531                let result = self.render_graph_recorded(
9532                    surface_view,
9533                    root_target,
9534                    packet,
9535                    returns,
9536                    &mut frame_encoder,
9537                );
9538                if result.is_ok() {
9539                    if let Some((output_view, bind_group)) = output {
9540                        match output_mode {
9541                            OutputMode::Display => &self.output_converter,
9542                            OutputMode::Screenshot => &self.screenshot_converter,
9543                        }
9544                        .encode(
9545                            &self.device,
9546                            frame_encoder.encoder(),
9547                            output_view,
9548                            bind_group,
9549                            self.adapter_backend,
9550                        );
9551                        frame_encoder.record_pass();
9552                    }
9553                }
9554                let execution =
9555                    if result.is_ok() && frame_encoder.recorded_pass_count() > initial_pass_count {
9556                        Some(frame_encoder.finish())
9557                    } else {
9558                        None
9559                    };
9560                (result, execution)
9561            };
9562            let after_execute = Instant::now();
9563            self.frame_graph_executor = executor;
9564            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
9565                log::warn!("[wgpu-render-stage:graph] total_ms={total_ms:.2}",);
9566            }
9567            if let Some(execution) = execution {
9568                self.frame_stats.record_command_stats(execution.stats);
9569            }
9570            result
9571        }
9572    }
9573
9574    fn render_graph_recorded<C: FrameCommandRecorder>(
9575        &mut self,
9576        surface_view: &wgpu::TextureView,
9577        root_target: Option<&OffscreenTarget>,
9578        packet: FramePacket,
9579        returns: &mut RenderReturns,
9580        frame_encoder: &mut C,
9581    ) -> Result<(), String> {
9582        let recorded_start = Instant::now();
9583
9584        // Present-side consumption of the packet's replay plan, adjacent to
9585        // packet consumption: the store honors the ops just before the
9586        // packet renders. Gated on a Direct root — a Surface packet never
9587        // touched the planner and carries the empty default plan
9588        // (generation 0), which the store must not consume: it would count
9589        // a false generation drop. The ack travels back through `returns`
9590        // and the producer applies it right after this render call —
9591        // equivalent to the in-store drain this replaces, because both
9592        // application points sit after this frame's graph build and before
9593        // the next collect, which is where the bypass gate and `feed_slots`
9594        // are read. The threaded present runtime consumes EARLIER
9595        // (`take_replay_ack_early`, before surface acquire) and marks the
9596        // packet, so this block must not feed the taken-out default plan
9597        // to the store.
9598        #[cfg(not(target_arch = "wasm32"))]
9599        let mut packet = packet;
9600        #[cfg(not(target_arch = "wasm32"))]
9601        if !packet.replay_preconsumed {
9602            if let PacketRoot::Direct(root) = &packet.root {
9603                let ops = std::mem::take(&mut packet.replay);
9604                let (ack, recycled) = self.consume_replay_ops(
9605                    ops,
9606                    &root.scene.shapes,
9607                    &root.scene.brushes,
9608                    packet.root_scale,
9609                );
9610                returns.ack = Some((ack, recycled));
9611            }
9612        }
9613
9614        let FramePacket {
9615            frame_id,
9616            viewport: (width, height),
9617            renderer_epoch: _,
9618            surface_epoch: _,
9619            root_scale,
9620            root,
9621            overlay,
9622            replay: _,
9623            text_cache_len: _,
9624            recycled_confirmations: _,
9625            replay_preconsumed: _,
9626        } = packet;
9627
9628        let mut backend = RecordingSurfaceBackend {
9629            renderer: self,
9630            recorder: frame_encoder,
9631        };
9632
9633        let surface_packet = match root {
9634            PacketRoot::Direct(root) => {
9635                let direct_render_start = Instant::now();
9636                let result = match execute_render_root_direct(
9637                    &mut backend,
9638                    surface_view,
9639                    root_target,
9640                    *root,
9641                    width,
9642                    height,
9643                    root_scale,
9644                    wgpu::LoadOp::Clear(CLEAR_COLOR),
9645                ) {
9646                    // Return the packet's scene buffers to the producer pool
9647                    // in BOTH arms — for a heavy animated frame they are
9648                    // megabytes of Vec, and an errored draw must not leak
9649                    // them.
9650                    Ok(scene) => {
9651                        returns.scene = Some(scene);
9652                        Ok(())
9653                    }
9654                    Err((error, scene)) => {
9655                        returns.scene = Some(scene);
9656                        Err(error)
9657                    }
9658                };
9659                if result.is_ok() {
9660                    if let Some(overlay) = overlay {
9661                        Self::render_overlay_packet(
9662                            &mut backend,
9663                            surface_view,
9664                            overlay,
9665                            width,
9666                            height,
9667                            root_scale,
9668                        )?;
9669                    }
9670                }
9671                let after_direct_render = Instant::now();
9672                if let Some(total_ms) =
9673                    should_log_wgpu_render_stage(recorded_start, after_direct_render)
9674                {
9675                    log::warn!(
9676                        "[wgpu-render-stage:recorded-direct-root] frame={frame_id} total_ms={total_ms:.2} render_ms={:.2}",
9677                        instant_ms(direct_render_start, after_direct_render),
9678                    );
9679                }
9680                return result;
9681            }
9682            PacketRoot::Surface(surface_packet) => surface_packet,
9683        };
9684        let after_root_collect = Instant::now();
9685
9686        let RootSurfacePacket {
9687            lowered,
9688            source,
9689            transform_to_parent,
9690            node_id,
9691            backdrop,
9692            graphics_layer,
9693            local_bounds,
9694            clip_rect,
9695            shadow_clip,
9696        } = *surface_packet;
9697        let mut lowered = lowered;
9698        lowered.source = source;
9699
9700        // The root layer's visible area is always the viewport — content
9701        // outside the screen is invisible regardless of scroll offsets or
9702        // inflated scene bounds.  Pass the viewport rect as an explicit
9703        // surface rect to prevent offscreen inflation on constrained GPUs.
9704        let viewport_rect = Rect {
9705            x: 0.0,
9706            y: 0.0,
9707            width: width as f32 / root_scale,
9708            height: height as f32 / root_scale,
9709        };
9710        let root_surface = execute_render_layer_surface(
9711            &mut backend,
9712            &mut lowered,
9713            LayerSurfaceRequest {
9714                root_scale,
9715                backdrop_underlay: None,
9716                backdrop_underlay_color: None,
9717                allow_runtime_cache: false,
9718                logical_rect_override: Some(viewport_rect),
9719                capture_clip_override: None,
9720                activates_nested_capture: false,
9721                translation_context: TranslationRenderContext::default(),
9722            },
9723        )?;
9724        let root_quad = transform_to_parent.map_rect(root_surface.logical_rect);
9725        let root_dest_quad = scaled_quad(root_quad, root_scale);
9726
9727        let needs_root_composite_target =
9728            backdrop.is_some() || graphics_layer.shadow_elevation > 0.0;
9729
9730        if needs_root_composite_target {
9731            let composite_target = backend.acquire_frame_surface(width, height);
9732            backend.clear_target_view_with_load_op(
9733                &composite_target.view,
9734                wgpu::LoadOp::Clear(CLEAR_COLOR),
9735            );
9736
9737            if let Some(backdrop) = &backdrop {
9738                execute_apply_backdrop_layer_to_target(
9739                    &mut backend,
9740                    &composite_target,
9741                    &BackdropLayer {
9742                        node_id,
9743                        rect: quad_bounds(transform_to_parent.map_rect(local_bounds)),
9744                        clip: clip_rect.map(|clip| quad_bounds(transform_to_parent.map_rect(clip))),
9745                        snap_anchor: None,
9746                        effect: backdrop.clone(),
9747                        z_index: 0,
9748                    },
9749                    None,
9750                    width,
9751                    height,
9752                    root_scale,
9753                    None,
9754                )?;
9755            }
9756
9757            let mut root_shadow_scene = CompositorScene::new();
9758            let root_shadow_clip =
9759                shadow_clip.map(|clip| quad_bounds(transform_to_parent.map_rect(clip)));
9760            push_layer_shadow(
9761                &mut root_shadow_scene,
9762                &graphics_layer,
9763                local_bounds,
9764                quad_bounds(transform_to_parent.map_rect(local_bounds)),
9765                root_shadow_clip,
9766            );
9767            for shadow in &root_shadow_scene.shadow_draws {
9768                backend.render_shadow_draw(
9769                    &composite_target.view,
9770                    shadow,
9771                    width,
9772                    height,
9773                    root_scale,
9774                );
9775            }
9776
9777            let composite_dest_quad =
9778                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
9779            execute_composite_surface_to_view(
9780                &mut backend,
9781                root_surface.target.target(),
9782                &composite_target.view,
9783                (width, height),
9784                composite_dest_quad,
9785                root_surface.composite_alpha,
9786                wgpu::LoadOp::Load,
9787                None,
9788                root_surface.blend_mode,
9789                root_surface.sample_mode,
9790            )?;
9791            backend.composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
9792                &composite_target,
9793                surface_view,
9794                1.0,
9795                wgpu::LoadOp::Clear(CLEAR_COLOR),
9796                None,
9797                None,
9798                BlendMode::SrcOver,
9799                None,
9800                CompositeSampleMode::Linear,
9801            );
9802            backend.release_frame_surface(composite_target);
9803        } else {
9804            let composite_dest_quad =
9805                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
9806            execute_composite_surface_to_view(
9807                &mut backend,
9808                root_surface.target.target(),
9809                surface_view,
9810                (width, height),
9811                composite_dest_quad,
9812                root_surface.composite_alpha,
9813                wgpu::LoadOp::Clear(CLEAR_COLOR),
9814                None,
9815                root_surface.blend_mode,
9816                root_surface.sample_mode,
9817            )?;
9818        }
9819        backend.release_layer_surface_target(root_surface.target);
9820        if let Some(overlay) = overlay {
9821            Self::render_overlay_packet(
9822                &mut backend,
9823                surface_view,
9824                overlay,
9825                width,
9826                height,
9827                root_scale,
9828            )?;
9829        }
9830        let after_layer_render = Instant::now();
9831        if let Some(total_ms) = should_log_wgpu_render_stage(recorded_start, after_layer_render) {
9832            log::warn!(
9833                "[wgpu-render-stage:recorded-layer-root] total_ms={total_ms:.2} collect_ms={:.2} render_ms={:.2}",
9834                instant_ms(recorded_start, after_root_collect),
9835                instant_ms(after_root_collect, after_layer_render),
9836            );
9837        }
9838        Ok(())
9839    }
9840
9841    /// Renders the producer-lowered dev overlay on top of the frame. The
9842    /// packet carries the collected overlay; the backend only validates
9843    /// that it stayed directly renderable and draws it.
9844    fn render_overlay_packet<C: FrameCommandRecorder>(
9845        backend: &mut RecordingSurfaceBackend<'_, '_, C>,
9846        surface_view: &wgpu::TextureView,
9847        overlay: CollectedLayer,
9848        width: u32,
9849        height: u32,
9850        root_scale: f32,
9851    ) -> Result<(), String> {
9852        if !overlay.child_layers.is_empty()
9853            || !root_direct_scene_events_are_supported(&overlay.scene, false)
9854            || !direct_root_child_underlays_are_supported(&overlay, false)
9855        {
9856            return Err("dev overlay graph must stay directly renderable".to_string());
9857        }
9858        execute_render_root_direct(
9859            backend,
9860            surface_view,
9861            None,
9862            overlay,
9863            width,
9864            height,
9865            root_scale,
9866            wgpu::LoadOp::Load,
9867        )
9868        .map(|_overlay_scene| ())
9869        .map_err(|(error, _overlay_scene)| error)
9870    }
9871
9872    #[allow(clippy::too_many_arguments)]
9873    fn encode_non_effect_segment_commands<C: FrameCommandRecorder>(
9874        &mut self,
9875        frame_encoder: &mut C,
9876        target_view: &wgpu::TextureView,
9877        ordered_items: &[(usize, SegmentDrawItem)],
9878        composites: &[(usize, CompositeBatchItem<'_>)],
9879        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9880        shapes: &[DrawShape],
9881        brushes: &[Brush],
9882        images: &[ImageDraw],
9883        texts: &[TextDraw],
9884        shadow_draws: &[ShadowDraw],
9885        retained_draws: &[RetainedDraw],
9886        initial_load_op: wgpu::LoadOp<wgpu::Color>,
9887        width: u32,
9888        height: u32,
9889        root_scale: f32,
9890    ) -> Result<SegmentCommandEncodeOutcome, String> {
9891        let mut first_batch = true;
9892        for command in
9893            SegmentCommandIter::new(ordered_items, shapes, images, self.shape_batch_limits)
9894        {
9895            match command {
9896                SegmentRenderCommand::DrawChunk(chunk) => {
9897                    let load_op = if first_batch {
9898                        initial_load_op
9899                    } else {
9900                        wgpu::LoadOp::Load
9901                    };
9902                    let outcome = self.render_segment_draw_chunk(
9903                        frame_encoder,
9904                        target_view,
9905                        ordered_items,
9906                        composites,
9907                        shader_composites,
9908                        shapes,
9909                        brushes,
9910                        images,
9911                        texts,
9912                        retained_draws,
9913                        chunk,
9914                        width,
9915                        height,
9916                        root_scale,
9917                        load_op,
9918                    )?;
9919                    if outcome.rendered_any {
9920                        frame_encoder.record_passes(outcome.pass_count);
9921                        first_batch = false;
9922                    }
9923                }
9924                SegmentRenderCommand::Shadow(index) => {
9925                    if first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
9926                        {
9927                            let _clear = frame_encoder.encoder().begin_render_pass(
9928                                &wgpu::RenderPassDescriptor {
9929                                    label: Some("Shadow Pre-Clear"),
9930                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9931                                        view: target_view,
9932                                        resolve_target: None,
9933                                        depth_slice: None,
9934                                        ops: wgpu::Operations {
9935                                            load: initial_load_op,
9936                                            store: wgpu::StoreOp::Store,
9937                                        },
9938                                    })],
9939                                    depth_stencil_attachment: None,
9940                                    timestamp_writes: None,
9941                                    occlusion_query_set: None,
9942                                    multiview_mask: None,
9943                                },
9944                            );
9945                        }
9946                        frame_encoder.record_pass();
9947                        first_batch = false;
9948                    }
9949                    let pass_count_before = frame_encoder.recorded_pass_count();
9950                    self.encode_shadow_draw(
9951                        frame_encoder,
9952                        target_view,
9953                        &shadow_draws[index],
9954                        width,
9955                        height,
9956                        root_scale,
9957                    );
9958                    if frame_encoder.recorded_pass_count() > pass_count_before {
9959                        first_batch = false;
9960                    }
9961                }
9962            }
9963        }
9964        Ok(SegmentCommandEncodeOutcome { first_batch })
9965    }
9966
9967    #[cfg(not(target_arch = "wasm32"))]
9968    #[allow(clippy::too_many_arguments)]
9969    fn render_segment_draw_chunk_fused_native<C: FrameCommandRecorder>(
9970        &mut self,
9971        frame_encoder: &mut C,
9972        target_view: &wgpu::TextureView,
9973        ordered_items: &[(usize, SegmentDrawItem)],
9974        composites: &[(usize, CompositeBatchItem<'_>)],
9975        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9976        shapes: &[DrawShape],
9977        brushes: &[Brush],
9978        images: &[ImageDraw],
9979        texts: &[TextDraw],
9980        retained_draws: &[RetainedDraw],
9981        chunk: &SegmentDrawChunkPlan,
9982        width: u32,
9983        height: u32,
9984        root_scale: f32,
9985        load_op: wgpu::LoadOp<wgpu::Color>,
9986    ) -> Result<Option<SegmentRenderOutcome>, String> {
9987        let Some(partitions) = native_segment_fusion_partitions(
9988            ordered_items,
9989            shapes,
9990            brushes,
9991            chunk,
9992            self.shape_batch_limits,
9993        )?
9994        else {
9995            return Ok(None);
9996        };
9997
9998        let mut rendered_any = false;
9999        let mut pass_count = 0_u32;
10000        let mut next_load_op = load_op;
10001        let encode_started = Instant::now();
10002        let mut partition_count = 0_u64;
10003        for partition in partitions {
10004            partition_count += 1;
10005            let outcome = self.render_segment_draw_chunk_fused_native_partition(
10006                frame_encoder,
10007                target_view,
10008                ordered_items,
10009                composites,
10010                shader_composites,
10011                shapes,
10012                brushes,
10013                images,
10014                texts,
10015                retained_draws,
10016                &partition.chunk,
10017                partition.budget,
10018                width,
10019                height,
10020                root_scale,
10021                next_load_op,
10022            )?;
10023            if outcome.rendered_any {
10024                rendered_any = true;
10025                pass_count = pass_count.saturating_add(outcome.pass_count);
10026                next_load_op = wgpu::LoadOp::Load;
10027            }
10028        }
10029
10030        self.segment_encode_stats
10031            .note_call(partition_count, encode_started.elapsed().as_micros() as u64);
10032
10033        Ok(Some(SegmentRenderOutcome {
10034            rendered_any,
10035            pass_count,
10036        }))
10037    }
10038
10039    #[cfg(not(target_arch = "wasm32"))]
10040    #[allow(clippy::too_many_arguments)]
10041    fn render_segment_draw_chunk_fused_native_partition<C: FrameCommandRecorder>(
10042        &mut self,
10043        frame_encoder: &mut C,
10044        target_view: &wgpu::TextureView,
10045        ordered_items: &[(usize, SegmentDrawItem)],
10046        composites: &[(usize, CompositeBatchItem<'_>)],
10047        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
10048        shapes: &[DrawShape],
10049        brushes: &[Brush],
10050        images: &[ImageDraw],
10051        texts: &[TextDraw],
10052        retained_draws: &[RetainedDraw],
10053        chunk: &SegmentDrawChunkPlan,
10054        budget: NativeSegmentFusionBudget,
10055        width: u32,
10056        height: u32,
10057        root_scale: f32,
10058        load_op: wgpu::LoadOp<wgpu::Color>,
10059    ) -> Result<SegmentRenderOutcome, String> {
10060        let partition_start = Instant::now();
10061        let mut staged_uploads = self.take_staged_uploads();
10062        staged_uploads.clear();
10063        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
10064        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
10065        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
10066        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
10067        // Moved out like the scratch vecs: the span blit borrows the cached
10068        // texture across the render pass while `self` stays mutably usable.
10069        let mut span_cache = std::mem::take(&mut self.static_span);
10070        // Moved out for the same reason: prepared segment composites borrow
10071        // entry textures across the render pass.
10072        let mut segment_surfaces = std::mem::take(&mut self.segment_surfaces);
10073
10074        image_vertices.clear();
10075        image_indices.clear();
10076        image_cmds.clear();
10077        glyph_cmds.clear();
10078
10079        let result = (|| {
10080            let viewport = ViewportUniformParams {
10081                width,
10082                height,
10083                offset: [0.0, 0.0],
10084            };
10085            self.prewarm_offscreen_text_glyph_draws_in_chunk(
10086                ordered_items,
10087                texts,
10088                chunk,
10089                viewport,
10090                root_scale,
10091                &mut staged_uploads,
10092                &mut image_vertices,
10093                &mut image_indices,
10094                &mut glyph_cmds,
10095            )?;
10096            let mut shape_refs = Vec::with_capacity(budget.shape_count);
10097            for batch in chunk.iter() {
10098                let SegmentBatchPlan::Shape { start, end, .. } = batch else {
10099                    continue;
10100                };
10101                for (_, item) in &ordered_items[start..end] {
10102                    let SegmentDrawItem::Shape(shape_index) = item else {
10103                        return Err(format!(
10104                            "shape batch contains non-shape draw item: {item:?}"
10105                        ));
10106                    };
10107                    shape_refs.push(&shapes[*shape_index]);
10108                }
10109            }
10110            let after_shape_refs = Instant::now();
10111
10112            let mut direct_shape_uploads = StagedBufferUploads::default();
10113            let mut shape_upload_base = 0u64;
10114            if !shape_refs.is_empty() {
10115                let Some((_, upload_base)) = self.prepare_shapes_batch_direct(
10116                    frame_encoder,
10117                    shape_refs.iter().copied(),
10118                    brushes,
10119                    root_scale,
10120                    viewport,
10121                    &mut direct_shape_uploads,
10122                ) else {
10123                    return Err(
10124                        "native fused segment shape preparation produced no draw batch".to_string(),
10125                    );
10126                };
10127                shape_upload_base = upload_base;
10128            }
10129            let after_shape_prepare = Instant::now();
10130
10131            // Segment-surface phase 1 (CRANPOSE_SEGMENT_SURFACE opt-in, see
10132            // `crate::segment_surface`): per retained item of this
10133            // partition, decide cached-composite vs direct, install/refresh
10134            // entries, and stage this frame's capture transforms. Runs
10135            // BEFORE the batch-prepare loop because recolor dirtiness is
10136            // read from the frame's still-parked patch list, which the
10137            // Retained prepare arm drains (`stage_replay_patches`).
10138            let mut segment_captures: Vec<SegmentCaptureJob> = Vec::new();
10139            let mut segment_composite_plans: Vec<(usize, SegmentCompositePlan)> = Vec::new();
10140            if segment_surfaces.enabled() {
10141                self.plan_segment_surfaces(
10142                    &mut segment_surfaces,
10143                    ordered_items,
10144                    chunk,
10145                    retained_draws,
10146                    &mut staged_uploads,
10147                    &mut segment_captures,
10148                    &mut segment_composite_plans,
10149                );
10150            }
10151
10152            // Opaque static leading-span cache: decide once per frame, on
10153            // the partition carrying the frame's opaque clear, whether the
10154            // leading run of converted records matches the cached span
10155            // composite (skip them, blit instead), repeated byte-identically
10156            // from last frame (draw live, then capture), or neither.
10157            let first_batch_info = match chunk.batches.first() {
10158                Some(&SegmentBatchPlan::Shape {
10159                    start,
10160                    end,
10161                    blend_mode,
10162                }) => {
10163                    let mut has_gradient = false;
10164                    for (_, item) in &ordered_items[start..end] {
10165                        if let SegmentDrawItem::Shape(shape_index) = item {
10166                            has_gradient |=
10167                                shape_gradient_stop_count(&shapes[*shape_index], brushes) > 0;
10168                        }
10169                    }
10170                    Some((end - start, blend_mode, has_gradient))
10171                }
10172                _ => None,
10173            };
10174            let span_decision = span_cache.engage(
10175                load_op,
10176                first_batch_info,
10177                width,
10178                height,
10179                &self.scratch_shape_data,
10180                &self.scratch_gradients,
10181            );
10182            let span_skip = match span_decision {
10183                StaticSpanDecision::Hit { skip } => {
10184                    if fill_area_diag_enabled() {
10185                        // The skipped quads were counted at batch prepare;
10186                        // the replacing blit is an effect-renderer
10187                        // composite, which the instrument's policy does not
10188                        // count.
10189                        self.fill_area_diag
10190                            .note_static_span_skip(&self.scratch_shape_data[..skip]);
10191                    }
10192                    skip
10193                }
10194                _ => 0,
10195            };
10196
10197            // Transient rim band meshes: scan the freshly converted shapes
10198            // (still in `scratch_shape_data` after
10199            // `prepare_shapes_batch_direct`) for huge circle rims and give
10200            // each a band mesh covering ring ± AA margin instead of its full
10201            // bounding quad. Kill switch read once per chunk; the mesh
10202            // pipeline exists in storage mode only and blends SrcOver only,
10203            // hence the two extra gates at the batch arm below.
10204            let rim_mesh_on = rim_mesh_enabled();
10205            let mut chunk_rims: Vec<RimDraw> = Vec::new();
10206
10207            let mut fused_batches = Vec::with_capacity(chunk.batches.len());
10208            let mut shape_cursor = 0_u32;
10209            let mut composite_cursor = 0usize;
10210            let mut shader_composite_cursor = 0usize;
10211            for (batch_index, batch) in chunk.iter().enumerate() {
10212                match batch {
10213                    SegmentBatchPlan::Shape {
10214                        start,
10215                        end,
10216                        blend_mode,
10217                    } => {
10218                        let mut has_gradient = false;
10219                        for (_, item) in &ordered_items[start..end] {
10220                            let SegmentDrawItem::Shape(shape_index) = item else {
10221                                return Err(format!(
10222                                    "shape batch contains non-shape draw item: {item:?}"
10223                                ));
10224                            };
10225                            has_gradient |=
10226                                shape_gradient_stop_count(&shapes[*shape_index], brushes) > 0;
10227                        }
10228                        // A span hit skips the leading shapes of the FIRST
10229                        // batch only: they stay in the upload (indices of
10230                        // everything after them are untouched) but the draw
10231                        // range starts past them.
10232                        let skip = if batch_index == 0 { span_skip } else { 0 };
10233                        let shape_count = end - start;
10234                        if shape_count > 0 {
10235                            if rim_mesh_on
10236                                && self.instanced_quads.is_some()
10237                                && blend_mode == BlendMode::SrcOver
10238                            {
10239                                for offset in skip..shape_count {
10240                                    // The index `vs_mesh` reads into the
10241                                    // storage shape array: position within
10242                                    // the whole fused upload (shape_refs
10243                                    // order == scratch_shape_data order).
10244                                    let global_index = shape_cursor + offset as u32;
10245                                    let converted = &self.scratch_shape_data[global_index as usize];
10246                                    let Some(band) = rim_mesh_band(converted) else {
10247                                        continue;
10248                                    };
10249                                    let vertex_mark = self.rim_mesh_vertices.len();
10250                                    let index_mark = self.rim_mesh_indices.len();
10251                                    if emit_arc_band_mesh(
10252                                        converted,
10253                                        global_index,
10254                                        &band,
10255                                        &mut self.rim_mesh_vertices,
10256                                        &mut self.rim_mesh_indices,
10257                                    )
10258                                    .is_none()
10259                                    {
10260                                        // Nothing emitted (fully clipped) —
10261                                        // the quad path draws it as today.
10262                                        self.rim_mesh_vertices.truncate(vertex_mark);
10263                                        self.rim_mesh_indices.truncate(index_mark);
10264                                        continue;
10265                                    }
10266                                    if self.rim_mesh_vertices.len() > RIM_MESH_VERTEX_CAPACITY
10267                                        || self.rim_mesh_indices.len() > RIM_MESH_INDEX_CAPACITY
10268                                    {
10269                                        // Whole-rim rollback, never a
10270                                        // truncation: a partial band would
10271                                        // break the containment invariant.
10272                                        self.rim_mesh_vertices.truncate(vertex_mark);
10273                                        self.rim_mesh_indices.truncate(index_mark);
10274                                        rim_mesh_capacity_warn();
10275                                        continue;
10276                                    }
10277                                    chunk_rims.push(RimDraw {
10278                                        shape_index: global_index,
10279                                        first_index: index_mark as u32,
10280                                        index_count: (self.rim_mesh_indices.len() - index_mark)
10281                                            as u32,
10282                                    });
10283                                    if fill_area_diag_enabled() {
10284                                        self.fill_area_diag.note_rim_mesh(
10285                                            converted,
10286                                            triangles_shoelace_area(
10287                                                &self.rim_mesh_vertices,
10288                                                &self.rim_mesh_indices[index_mark..],
10289                                            ),
10290                                        );
10291                                    }
10292                                    self.rim_meshes_emitted += 1;
10293                                    if self.rim_meshes_emitted % 600 == 1 {
10294                                        log::debug!(
10295                                            "[rim-mesh] {} rims meshed lifetime ({} verts live this frame)",
10296                                            self.rim_meshes_emitted,
10297                                            self.rim_mesh_vertices.len(),
10298                                        );
10299                                    }
10300                                }
10301                            }
10302                            if shape_count > skip {
10303                                fused_batches.push(FusedSegmentBatch::Shape {
10304                                    batch: PreparedShapeBatch {
10305                                        vertex_start: (shape_cursor + skip as u32) * 6,
10306                                        vertex_count: (shape_count - skip) as u32 * 6,
10307                                        has_gradient,
10308                                    },
10309                                    blend_mode,
10310                                });
10311                            }
10312                            shape_cursor += shape_count as u32;
10313                        }
10314                    }
10315                    SegmentBatchPlan::Image {
10316                        start,
10317                        end,
10318                        blend_mode,
10319                    } => {
10320                        let cmd_start = image_cmds.len();
10321                        for (_, item) in &ordered_items[start..end] {
10322                            let SegmentDrawItem::Image(image_index) = item else {
10323                                return Err(format!(
10324                                    "image batch contains non-image draw item: {item:?}"
10325                                ));
10326                            };
10327                            self.append_image_draw_cmd(
10328                                &images[*image_index],
10329                                viewport,
10330                                root_scale,
10331                                &mut image_vertices,
10332                                &mut image_indices,
10333                                &mut image_cmds,
10334                            )?;
10335                        }
10336                        let cmd_end = image_cmds.len();
10337                        if cmd_start < cmd_end {
10338                            fused_batches.push(FusedSegmentBatch::Image {
10339                                cmd_range: cmd_start..cmd_end,
10340                                blend_mode,
10341                            });
10342                        }
10343                    }
10344                    SegmentBatchPlan::Text { start, end } => {
10345                        let glyph_cmd_start = glyph_cmds.len();
10346                        let image_cmd_start = image_cmds.len();
10347                        let text_draws =
10348                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10349                        if !self.append_text_glyph_draws(
10350                            text_draws,
10351                            viewport,
10352                            root_scale,
10353                            false,
10354                            &mut staged_uploads,
10355                            &mut image_vertices,
10356                            &mut image_indices,
10357                            &mut glyph_cmds,
10358                        )? {
10359                            let text_draws =
10360                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
10361                            self.append_text_image_draw_cmds(
10362                                text_draws,
10363                                viewport,
10364                                root_scale,
10365                                &mut image_vertices,
10366                                &mut image_indices,
10367                                &mut image_cmds,
10368                            )?;
10369                        }
10370                        let image_cmd_end = image_cmds.len();
10371                        let glyph_cmd_end = glyph_cmds.len();
10372                        if image_cmd_start < image_cmd_end || glyph_cmd_start < glyph_cmd_end {
10373                            fused_batches.push(FusedSegmentBatch::Text {
10374                                image_cmd_range: image_cmd_start..image_cmd_end,
10375                                glyph_cmd_range: glyph_cmd_start..glyph_cmd_end,
10376                            });
10377                        }
10378                    }
10379                    SegmentBatchPlan::Composite { start, end } => {
10380                        for (_, item) in &ordered_items[start..end] {
10381                            if !matches!(item, SegmentDrawItem::Composite(_)) {
10382                                return Err(format!(
10383                                    "composite batch contains non-composite draw item: {item:?}"
10384                                ));
10385                            }
10386                        }
10387                        let draw_count = end - start;
10388                        if draw_count > 0 {
10389                            let draw_start = composite_cursor;
10390                            composite_cursor += draw_count;
10391                            fused_batches.push(FusedSegmentBatch::Composite {
10392                                draw_range: draw_start..composite_cursor,
10393                            });
10394                        }
10395                    }
10396                    SegmentBatchPlan::ShaderComposite { start, end } => {
10397                        for (_, item) in &ordered_items[start..end] {
10398                            if !matches!(item, SegmentDrawItem::ShaderComposite(_)) {
10399                                return Err(format!(
10400                                    "shader composite batch contains non-shader-composite draw item: {item:?}"
10401                                ));
10402                            }
10403                        }
10404                        let draw_count = end - start;
10405                        if draw_count > 0 {
10406                            let draw_start = shader_composite_cursor;
10407                            shader_composite_cursor += draw_count;
10408                            fused_batches.push(FusedSegmentBatch::ShaderComposite {
10409                                draw_range: draw_start..shader_composite_cursor,
10410                            });
10411                        }
10412                    }
10413                    SegmentBatchPlan::Retained { start, end } => {
10414                        self.stage_replay_patches(&mut staged_uploads);
10415                        for (_, item) in &ordered_items[start..end] {
10416                            let SegmentDrawItem::Retained(index) = item else {
10417                                return Err(format!(
10418                                    "retained batch contains non-retained draw item: {item:?}"
10419                                ));
10420                            };
10421                            let retained = retained_draws.get(*index).ok_or_else(|| {
10422                                format!("retained draw index {index} out of bounds")
10423                            })?;
10424                            if (*index as u32) < MAX_REPLAY_SLOTS
10425                                && self.replay_slots.slots.contains_key(&retained.slot)
10426                            {
10427                                let transform = retained.transform.with_retained_paint();
10428                                staged_uploads.stage_at(
10429                                    UploadTarget::ReplayTransform,
10430                                    *index as u64 * REPLAY_TRANSFORM_STRIDE,
10431                                    bytemuck::bytes_of(&transform),
10432                                );
10433                            }
10434                        }
10435                        if end > start {
10436                            fused_batches.push(FusedSegmentBatch::Retained {
10437                                item_range: start..end,
10438                            });
10439                        }
10440                    }
10441                }
10442            }
10443            if !chunk_rims.is_empty() {
10444                self.upload_transient_rim_meshes();
10445            }
10446            let after_batch_prepare = Instant::now();
10447
10448            if !image_indices.is_empty() {
10449                self.stage_native_image_buffers(
10450                    &mut staged_uploads,
10451                    viewport,
10452                    &image_vertices,
10453                    &image_indices,
10454                );
10455            }
10456
10457            // Display clip region cull: engages exactly when this fused
10458            // pass draws the frame's root surface whole and the platform
10459            // reported a cullable visible region (the round display being
10460            // the first provider). The pass then carries a transient depth
10461            // attachment, the region complement's occluder is drawn first,
10462            // and every pipeline below is fetched in its depth-tested
10463            // variant (the getters read `pass_depth`). Offscreen/layer
10464            // passes never reach this branch with a `Some` here.
10465            let display_clip_depth_view =
10466                self.display_clip_pass_depth_view(target_view, width, height);
10467            let pass_depth = display_clip_depth_view.is_some();
10468
10469            let device = self.device.clone();
10470            let composite_items: Vec<_> = chunk
10471                .iter()
10472                .filter_map(|batch| match batch {
10473                    SegmentBatchPlan::Composite { start, end } => Some((start, end)),
10474                    _ => None,
10475                })
10476                .flat_map(|(start, end)| {
10477                    ordered_items[start..end].iter().filter_map(|(_, item)| {
10478                        let SegmentDrawItem::Composite(composite_index) = item else {
10479                            return None;
10480                        };
10481                        composites
10482                            .get(*composite_index)
10483                            .map(|(_, composite)| *composite)
10484                    })
10485                })
10486                .collect();
10487            let prepared_composites = self.effect_renderer.prepare_composite_batch_draws(
10488                frame_encoder,
10489                &device,
10490                load_op,
10491                &composite_items,
10492                pass_depth,
10493            );
10494            let shader_items: Vec<_> = chunk
10495                .iter()
10496                .filter_map(|batch| match batch {
10497                    SegmentBatchPlan::ShaderComposite { start, end } => Some((start, end)),
10498                    _ => None,
10499                })
10500                .flat_map(|(start, end)| {
10501                    ordered_items[start..end].iter().filter_map(|(_, item)| {
10502                        let SegmentDrawItem::ShaderComposite(composite_index) = item else {
10503                            return None;
10504                        };
10505                        shader_composites
10506                            .get(*composite_index)
10507                            .map(|(_, composite)| *composite)
10508                    })
10509                })
10510                .collect();
10511            let prepared_shaders = self
10512                .effect_renderer
10513                .prepare_shader_batch_draws(frame_encoder, &device, &shader_items, pass_depth)
10514                .ok_or_else(|| "shader composite batch preparation failed".to_string())?;
10515            if !shader_items.is_empty() {
10516                self.effect_renderer.record_composite_pass();
10517                self.effect_renderer
10518                    .debug_effects
10519                    .set(self.effect_renderer.debug_effects.get() + shader_items.len() as u32);
10520            }
10521            // Span hit: prepare the cached-texture blit that stands in for
10522            // the skipped shapes. Reuses the effect renderer's composite
10523            // machinery — the same prepared-draw path the Composite arms
10524            // ride — with Nearest sampling (an exact `textureLoad`), alpha
10525            // 1.0, no mask, no viewports: a 1:1 full-target replace-write
10526            // of alpha-255 texels (see `StaticSpanCache`).
10527            let span_blit_items =
10528                span_cache
10529                    .texture
10530                    .as_ref()
10531                    .filter(|_| span_skip > 0)
10532                    .map(|texture| CompositeBatchItem {
10533                        source: texture,
10534                        alpha: 1.0,
10535                        scissor: None,
10536                        rounded_mask: None,
10537                        blend_mode: BlendMode::Src,
10538                        dest_viewport: None,
10539                        source_viewport: None,
10540                        sample_mode: CompositeSampleMode::Nearest,
10541                    });
10542            let span_blit = match &span_blit_items {
10543                Some(item) => self.effect_renderer.prepare_composite_batch_draws(
10544                    frame_encoder,
10545                    &device,
10546                    load_op,
10547                    std::slice::from_ref(item),
10548                    pass_depth,
10549                ),
10550                None => Vec::new(),
10551            };
10552            // Segment-surface phase 2: prepared rotated-quad composites for
10553            // the cached spans. Each is drawn inside the fused pass at its
10554            // span's exact batch position (see the Retained draw arm), so
10555            // interleaved z order is preserved by construction.
10556            let mut prepared_segment_composites: Vec<(usize, PreparedProjectiveComposite<'_>)> =
10557                Vec::with_capacity(segment_composite_plans.len());
10558            for (index, plan) in &segment_composite_plans {
10559                let Some(entry) = segment_surfaces.entry(&plan.key) else {
10560                    continue;
10561                };
10562                let item = ProjectiveCompositeItem {
10563                    source: &entry.texture,
10564                    viewport: (width, height),
10565                    dest_quad: plan.dest_quad,
10566                    inverse: plan.inverse,
10567                    alpha: 1.0,
10568                    blend_mode: BlendMode::SrcOver,
10569                    sample_mode: if plan.identity || plan.integer_translation {
10570                        CompositeSampleMode::Nearest
10571                    } else {
10572                        CompositeSampleMode::Linear
10573                    },
10574                };
10575                let prepared = self.effect_renderer.prepare_projective_composite_draw(
10576                    frame_encoder,
10577                    &device,
10578                    &item,
10579                    pass_depth,
10580                );
10581                prepared_segment_composites.push((*index, prepared));
10582            }
10583            let after_composite_prepare = Instant::now();
10584
10585            if fused_batches.is_empty() && span_blit.is_empty() {
10586                return Ok(SegmentRenderOutcome {
10587                    rendered_any: false,
10588                    pass_count: 0,
10589                });
10590            }
10591
10592            // The direct shape copies must be recorded before the staged
10593            // flush: its capacity check may replace `upload_buffer`, and the
10594            // shape payload was written into the buffer that existed at
10595            // prepare time. Recording first binds the copies to that buffer.
10596            self.flush_staged_uploads_at(
10597                frame_encoder.encoder(),
10598                &direct_shape_uploads,
10599                shape_upload_base,
10600            );
10601            let upload_offset =
10602                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10603            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
10604            let after_upload = Instant::now();
10605
10606            // Segment-surface phase 3: encode this frame's capture passes —
10607            // after the staged flush (their transforms and this frame's
10608            // recolor patches ride it), before the fused pass that samples
10609            // the surfaces. A recolored span therefore invalidates,
10610            // recaptures and composites within ONE frame, and the fused
10611            // pass never samples a stale surface. `pass_depth` is not yet
10612            // set on the pipeline getters here, so the capture walk fetches
10613            // the ordinary flat pipeline variants.
10614            let mut segment_capture_passes = 0u32;
10615            for job in &segment_captures {
10616                let Some(entry) = segment_surfaces.entry(&job.key) else {
10617                    continue;
10618                };
10619                let Some(slot) = self.replay_slots.slots.get(&job.key.slot) else {
10620                    continue;
10621                };
10622                let Some(uniform_group) =
10623                    segment_surfaces.capture_uniform_bind_group(job.capture_index)
10624                else {
10625                    continue;
10626                };
10627                let mut capture_pass =
10628                    frame_encoder
10629                        .encoder()
10630                        .begin_render_pass(&wgpu::RenderPassDescriptor {
10631                            label: Some("Segment Surface Capture Pass"),
10632                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10633                                view: &entry.texture.view,
10634                                resolve_target: None,
10635                                depth_slice: None,
10636                                ops: wgpu::Operations {
10637                                    // Transparent clear: the surface holds
10638                                    // the span's premultiplied flattening
10639                                    // and nothing else.
10640                                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
10641                                    store: wgpu::StoreOp::Store,
10642                                },
10643                            })],
10644                            depth_stencil_attachment: None,
10645                            timestamp_writes: None,
10646                            occlusion_query_set: None,
10647                            multiview_mask: None,
10648                        });
10649                let draws = self.encode_retained_op(
10650                    slot,
10651                    job.first,
10652                    job.last,
10653                    MAX_REPLAY_SLOTS + job.capture_index,
10654                    &mut |cmd| match cmd {
10655                        // The capture retargets bind group 0 to its
10656                        // sub-viewport uniforms (viewport_offset maps the
10657                        // capture rect onto the surface); everything else
10658                        // is the IDENTICAL walk the direct draw encodes.
10659                        RetainedCmd::Uniforms(_) => {
10660                            capture_pass.set_bind_group(0, uniform_group, &[])
10661                        }
10662                        RetainedCmd::Pipeline(pipeline) => {
10663                            capture_pass.set_pipeline(self.segment_capture_pipeline(pipeline))
10664                        }
10665                        RetainedCmd::SlotBindings(group, offset) => {
10666                            capture_pass.set_bind_group(1, group, &[offset])
10667                        }
10668                        RetainedCmd::MeshVertices(buffer) => {
10669                            capture_pass.set_vertex_buffer(0, buffer.slice(..))
10670                        }
10671                        RetainedCmd::Index(buffer, format) => {
10672                            capture_pass.set_index_buffer(buffer.slice(..), format)
10673                        }
10674                        RetainedCmd::Draw(vertices) => capture_pass.draw(vertices, 0..1),
10675                        RetainedCmd::DrawIndexed(indices, instances) => {
10676                            capture_pass.draw_indexed(indices, 0, instances)
10677                        }
10678                    },
10679                );
10680                self.frame_stats.add_draw_calls(draws);
10681                segment_capture_passes += 1;
10682            }
10683
10684            let use_retained_bundles = retained_bundles_enabled();
10685            let mut retained_encode_ms = 0.0_f64;
10686            {
10687                let mut render_pass =
10688                    frame_encoder
10689                        .encoder()
10690                        .begin_render_pass(&wgpu::RenderPassDescriptor {
10691                            label: Some("Fused Segment Draw Pass"),
10692                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10693                                view: target_view,
10694                                resolve_target: None,
10695                                depth_slice: None,
10696                                ops: wgpu::Operations {
10697                                    load: load_op,
10698                                    store: wgpu::StoreOp::Store,
10699                                },
10700                            })],
10701                            // Clear + Discard: the display-clip depth
10702                            // buffer is born and dies inside this pass — on
10703                            // tiled GPUs it never leaves GMEM.
10704                            depth_stencil_attachment: display_clip_depth_view.as_ref().map(
10705                                |view| wgpu::RenderPassDepthStencilAttachment {
10706                                    view,
10707                                    depth_ops: Some(wgpu::Operations {
10708                                        load: wgpu::LoadOp::Clear(
10709                                            crate::display_clip::DISPLAY_CLIP_DEPTH_CLEAR,
10710                                        ),
10711                                        store: wgpu::StoreOp::Discard,
10712                                    }),
10713                                    stencil_ops: None,
10714                                },
10715                            ),
10716                            timestamp_writes: None,
10717                            occlusion_query_set: None,
10718                            multiview_mask: None,
10719                        });
10720
10721                // The cached span composite replaces the frame's leading
10722                // draws, so it goes down before every fused batch — same
10723                // z position the skipped shapes held.
10724                for draw in &span_blit {
10725                    self.effect_renderer.draw_prepared_composite(
10726                        &mut render_pass,
10727                        (width, height),
10728                        draw,
10729                        pass_depth,
10730                    );
10731                }
10732                if pass_depth {
10733                    // The occluder must be the pass's first draw: everything
10734                    // after it depth-tests against the region it wrote.
10735                    self.draw_display_clip_occluder(&mut render_pass, width, height);
10736                    self.display_clip.pass_depth.set(true);
10737                }
10738
10739                for batch in &fused_batches {
10740                    match batch {
10741                        FusedSegmentBatch::Shape { batch, blend_mode } => {
10742                            self.draw_prepared_shapes(
10743                                &mut render_pass,
10744                                *blend_mode,
10745                                *batch,
10746                                width,
10747                                height,
10748                                &chunk_rims,
10749                            );
10750                        }
10751                        FusedSegmentBatch::Image {
10752                            cmd_range,
10753                            blend_mode,
10754                        } => {
10755                            self.draw_native_prepared_image_cmd_range(
10756                                &mut render_pass,
10757                                &image_cmds,
10758                                cmd_range.clone(),
10759                                *blend_mode,
10760                            )?;
10761                        }
10762                        FusedSegmentBatch::Text {
10763                            image_cmd_range,
10764                            glyph_cmd_range,
10765                        } => {
10766                            if !image_cmd_range.is_empty() {
10767                                self.draw_native_prepared_image_cmd_range(
10768                                    &mut render_pass,
10769                                    &image_cmds,
10770                                    image_cmd_range.clone(),
10771                                    BlendMode::SrcOver,
10772                                )?;
10773                                self.frame_stats.bump_text();
10774                            }
10775                            if !glyph_cmd_range.is_empty() {
10776                                self.draw_native_prepared_glyph_cmd_range(
10777                                    &mut render_pass,
10778                                    &glyph_cmds,
10779                                    glyph_cmd_range.clone(),
10780                                )?;
10781                            }
10782                        }
10783                        FusedSegmentBatch::Composite { draw_range } => {
10784                            for draw in
10785                                prepared_composites.get(draw_range.clone()).ok_or_else(|| {
10786                                    "composite draw range is outside the prepared command buffer"
10787                                        .to_string()
10788                                })?
10789                            {
10790                                self.effect_renderer.draw_prepared_composite(
10791                                    &mut render_pass,
10792                                    (width, height),
10793                                    draw,
10794                                    pass_depth,
10795                                );
10796                            }
10797                        }
10798                        FusedSegmentBatch::ShaderComposite { draw_range } => {
10799                            for draw in prepared_shaders.get(draw_range.clone()).ok_or_else(|| {
10800                                "shader composite draw range is outside the prepared command buffer"
10801                                    .to_string()
10802                            })? {
10803                                self.effect_renderer.draw_prepared_shader_src_over(
10804                                    &device,
10805                                    &mut render_pass,
10806                                    (width, height),
10807                                    draw,
10808                                    pass_depth,
10809                                );
10810                            }
10811                        }
10812                        FusedSegmentBatch::Retained { item_range } => {
10813                            // Each Retained arm is one MAXIMAL consecutive
10814                            // retained stretch — the planner groups adjacent
10815                            // retained items into a single batch — so caching
10816                            // per arm never flattens across the dynamic
10817                            // batches interleaved at their z positions.
10818                            let retained_start = Instant::now();
10819                            // A render bundle cannot encode a segment-surface
10820                            // composite, so a stretch containing one this
10821                            // frame takes the per-item walk: order-identical,
10822                            // and the walk is a handful of binds exactly when
10823                            // the cache is saving the fragment work.
10824                            let stretch_has_composites = !prepared_segment_composites.is_empty()
10825                                && ordered_items[item_range.clone()].iter().any(|(_, item)| {
10826                                    matches!(
10827                                        item,
10828                                        SegmentDrawItem::Retained(index)
10829                                            if prepared_segment_composites
10830                                                .iter()
10831                                                .any(|(prepared_index, _)| prepared_index == index)
10832                                    )
10833                                });
10834                            if use_retained_bundles && !stretch_has_composites {
10835                                self.draw_retained_stretch_bundled(
10836                                    &mut render_pass,
10837                                    ordered_items,
10838                                    retained_draws,
10839                                    item_range.clone(),
10840                                    width,
10841                                    height,
10842                                );
10843                            } else {
10844                                for (_, item) in &ordered_items[item_range.clone()] {
10845                                    if let SegmentDrawItem::Retained(index) = item {
10846                                        if let Some((_, prepared)) = prepared_segment_composites
10847                                            .iter()
10848                                            .find(|(prepared_index, _)| prepared_index == index)
10849                                        {
10850                                            // The cached span's surface, at
10851                                            // the span's exact z position:
10852                                            // SrcOver over premultiplied
10853                                            // alpha is associative, so
10854                                            // flatten-then-composite blends
10855                                            // identically to the inline
10856                                            // member draws it replaces.
10857                                            self.effect_renderer
10858                                                .draw_prepared_projective_composite(
10859                                                    &mut render_pass,
10860                                                    (width, height),
10861                                                    prepared,
10862                                                    pass_depth,
10863                                                );
10864                                            self.frame_stats.add_draw_calls(1);
10865                                        } else if let Some(retained) = retained_draws.get(*index) {
10866                                            self.draw_retained_batch(
10867                                                &mut render_pass,
10868                                                retained,
10869                                                *index,
10870                                                width,
10871                                                height,
10872                                            );
10873                                        }
10874                                    }
10875                                }
10876                            }
10877                            retained_encode_ms += instant_ms(retained_start, Instant::now());
10878                        }
10879                    }
10880                }
10881            }
10882            // The depth attachment died with the fused pass just dropped;
10883            // anything encoded from here to the closure exit (the span
10884            // capture below) is a depth-less pass, so the pipeline getters
10885            // must stop handing out depth variants NOW — the closure-exit
10886            // reset is only the error-path net.
10887            self.display_clip.pass_depth.set(false);
10888            // Span capture (miss frames whose leading run proved stable):
10889            // re-render JUST the span shapes into the pooled offscreen,
10890            // through the IDENTICAL pipelines at identical device
10891            // coordinates — the shapes are already in this partition's
10892            // upload, so the capture is one extra pass drawing instances
10893            // 0..len of the same buffers, cleared with the frame's own
10894            // clear color. Rare by construction: palette drains, shakes,
10895            // and resizes are the only events that invalidate the key.
10896            let mut capture_passes = 0_u32;
10897            if let StaticSpanDecision::Capture { len, clear } = span_decision {
10898                let texture = match span_cache.texture.take() {
10899                    Some(existing) if existing.width == width && existing.height == height => {
10900                        existing
10901                    }
10902                    other => {
10903                        if let Some(stale) = other {
10904                            self.defer_offscreen_release(stale);
10905                        }
10906                        self.acquire_offscreen(width, height)
10907                    }
10908                };
10909                {
10910                    let mut capture_pass =
10911                        frame_encoder
10912                            .encoder()
10913                            .begin_render_pass(&wgpu::RenderPassDescriptor {
10914                                label: Some("Static Span Capture Pass"),
10915                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10916                                    view: &texture.view,
10917                                    resolve_target: None,
10918                                    depth_slice: None,
10919                                    ops: wgpu::Operations {
10920                                        load: wgpu::LoadOp::Clear(clear),
10921                                        store: wgpu::StoreOp::Store,
10922                                    },
10923                                })],
10924                                depth_stencil_attachment: None,
10925                                timestamp_writes: None,
10926                                occlusion_query_set: None,
10927                                multiview_mask: None,
10928                            });
10929                    // `has_gradient` is the LIVE first batch's whole-batch
10930                    // flag: it selects the same fs_solid/gradient pipeline
10931                    // variant the live path draws the span through.
10932                    let has_gradient = first_batch_info
10933                        .map(|(_, _, has_gradient)| has_gradient)
10934                        .unwrap_or(false);
10935                    self.draw_prepared_shapes(
10936                        &mut capture_pass,
10937                        BlendMode::SrcOver,
10938                        PreparedShapeBatch {
10939                            vertex_start: 0,
10940                            vertex_count: len as u32 * 6,
10941                            has_gradient,
10942                        },
10943                        width,
10944                        height,
10945                        &[],
10946                    );
10947                    if fill_area_diag_enabled() {
10948                        // The capture genuinely re-submits the span's fill
10949                        // this frame — submitted, lit, opacity and (the
10950                        // capture target is frame-sized) corner alike.
10951                        self.fill_area_diag
10952                            .add_shape_quads(&self.scratch_shape_data[..len], viewport);
10953                    }
10954                    span_cache.store_key(
10955                        &self.scratch_shape_data[..len],
10956                        &self.scratch_gradients,
10957                        width,
10958                        height,
10959                        clear,
10960                        has_gradient,
10961                    );
10962                }
10963                span_cache.texture = Some(texture);
10964                capture_passes = 1;
10965            }
10966            let after_pass = Instant::now();
10967            if let Some(total_ms) = should_log_wgpu_render_stage(partition_start, after_pass) {
10968                log::warn!(
10969                    "[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={}",
10970                    instant_ms(partition_start, after_shape_refs),
10971                    instant_ms(after_shape_refs, after_shape_prepare),
10972                    instant_ms(after_shape_prepare, after_batch_prepare),
10973                    instant_ms(after_batch_prepare, after_composite_prepare),
10974                    instant_ms(after_composite_prepare, after_upload),
10975                    instant_ms(after_upload, after_pass),
10976                    fused_batches.len(),
10977                    budget.shape_count,
10978                    image_cmds.len(),
10979                    glyph_cmds.len(),
10980                    staged_uploads.bytes.len(),
10981                );
10982            }
10983
10984            Ok(SegmentRenderOutcome {
10985                rendered_any: true,
10986                pass_count: 1 + capture_passes + segment_capture_passes,
10987            })
10988        })();
10989
10990        // The depth flag lives exactly as long as the culled pass's encode;
10991        // resetting here (not inside the closure) covers the error paths
10992        // too, so no later pass can inherit a depth-variant pipeline.
10993        self.display_clip.pass_depth.set(false);
10994        self.scratch_image_vertices = image_vertices;
10995        self.scratch_image_indices = image_indices;
10996        self.scratch_image_cmds = image_cmds;
10997        self.scratch_glyph_cmds = glyph_cmds;
10998        self.restore_staged_uploads(staged_uploads);
10999        self.static_span = span_cache;
11000        if result.is_err() {
11001            // An aborted partition may have installed entries whose capture
11002            // passes never encoded; a later frame must not sample them.
11003            segment_surfaces.clear();
11004        }
11005        self.segment_surfaces = segment_surfaces;
11006        result
11007    }
11008
11009    #[allow(clippy::too_many_arguments)]
11010    fn render_segment_draw_chunk<C: FrameCommandRecorder>(
11011        &mut self,
11012        frame_encoder: &mut C,
11013        target_view: &wgpu::TextureView,
11014        ordered_items: &[(usize, SegmentDrawItem)],
11015        composites: &[(usize, CompositeBatchItem<'_>)],
11016        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
11017        shapes: &[DrawShape],
11018        brushes: &[Brush],
11019        images: &[ImageDraw],
11020        texts: &[TextDraw],
11021        retained_draws: &[RetainedDraw],
11022        chunk: SegmentDrawChunkPlan,
11023        width: u32,
11024        height: u32,
11025        root_scale: f32,
11026        load_op: wgpu::LoadOp<wgpu::Color>,
11027    ) -> Result<SegmentRenderOutcome, String> {
11028        #[cfg(target_arch = "wasm32")]
11029        let _ = retained_draws;
11030        #[cfg(not(target_arch = "wasm32"))]
11031        if let Some(outcome) = self.render_segment_draw_chunk_fused_native(
11032            frame_encoder,
11033            target_view,
11034            ordered_items,
11035            composites,
11036            shader_composites,
11037            shapes,
11038            brushes,
11039            images,
11040            texts,
11041            retained_draws,
11042            &chunk,
11043            width,
11044            height,
11045            root_scale,
11046            load_op,
11047        )? {
11048            return Ok(outcome);
11049        }
11050
11051        let mut staged_uploads = self.take_staged_uploads();
11052        let result = (|| {
11053            let mut rendered_any = false;
11054            let mut pass_count = 0_u32;
11055            let mut next_load_op = load_op;
11056            for batch in chunk.iter() {
11057                staged_uploads.clear();
11058                match batch {
11059                    SegmentBatchPlan::Shape {
11060                        start,
11061                        end,
11062                        blend_mode,
11063                    } => {
11064                        let slice = &ordered_items[start..end];
11065                        if slice.len() > self.shape_batch_limits.max_shapes_per_batch {
11066                            return Err(format!(
11067                                "shape batch contains {} shapes, exceeding the renderer limit of {}",
11068                                slice.len(),
11069                                self.shape_batch_limits.max_shapes_per_batch
11070                            ));
11071                        }
11072                        let viewport = ViewportUniformParams {
11073                            width,
11074                            height,
11075                            offset: [0.0, 0.0],
11076                        };
11077                        for (_, item) in slice {
11078                            if !matches!(item, SegmentDrawItem::Shape(_)) {
11079                                return Err(format!(
11080                                    "shape batch contains non-shape draw item: {item:?}"
11081                                ));
11082                            }
11083                        }
11084                        let Some(prepared) = self.prepare_shapes_batch(
11085                            slice.iter().filter_map(|(_, item)| match item {
11086                                SegmentDrawItem::Shape(shape_index) => Some(&shapes[*shape_index]),
11087                                _ => None,
11088                            }),
11089                            brushes,
11090                            root_scale,
11091                            viewport,
11092                            &mut staged_uploads,
11093                        ) else {
11094                            continue;
11095                        };
11096                        let upload_offset = frame_encoder
11097                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11098                        self.flush_staged_uploads_at(
11099                            frame_encoder.encoder(),
11100                            &staged_uploads,
11101                            upload_offset,
11102                        );
11103                        {
11104                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
11105                                &wgpu::RenderPassDescriptor {
11106                                    label: Some("Segment Shape Pass"),
11107                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11108                                        view: target_view,
11109                                        resolve_target: None,
11110                                        depth_slice: None,
11111                                        ops: wgpu::Operations {
11112                                            load: next_load_op,
11113                                            store: wgpu::StoreOp::Store,
11114                                        },
11115                                    })],
11116                                    depth_stencil_attachment: None,
11117                                    timestamp_writes: None,
11118                                    occlusion_query_set: None,
11119                                    multiview_mask: None,
11120                                },
11121                            );
11122                            self.draw_prepared_shapes(
11123                                &mut render_pass,
11124                                blend_mode,
11125                                prepared,
11126                                width,
11127                                height,
11128                                &[],
11129                            );
11130                        }
11131                        pass_count = pass_count.saturating_add(1);
11132                        rendered_any = true;
11133                        next_load_op = wgpu::LoadOp::Load;
11134                    }
11135                    SegmentBatchPlan::Image {
11136                        start,
11137                        end,
11138                        blend_mode,
11139                    } => {
11140                        let viewport = ViewportUniformParams {
11141                            width,
11142                            height,
11143                            offset: [0.0, 0.0],
11144                        };
11145                        for (_, item) in &ordered_items[start..end] {
11146                            if !matches!(item, SegmentDrawItem::Image(_)) {
11147                                return Err(format!(
11148                                    "image batch contains non-image draw item: {item:?}"
11149                                ));
11150                            }
11151                        }
11152                        let prepared_images = self.prepare_image_draw_cmds(
11153                            ordered_items[start..end]
11154                                .iter()
11155                                .filter_map(|(_, item)| match item {
11156                                    SegmentDrawItem::Image(image_index) => {
11157                                        Some(&images[*image_index])
11158                                    }
11159                                    _ => None,
11160                                }),
11161                            viewport,
11162                            root_scale,
11163                            &mut staged_uploads,
11164                        )?;
11165                        if prepared_images.is_empty() {
11166                            self.scratch_image_cmds = prepared_images.into_cmds();
11167                            continue;
11168                        }
11169                        let upload_offset = frame_encoder
11170                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11171                        self.flush_staged_uploads_at(
11172                            frame_encoder.encoder(),
11173                            &staged_uploads,
11174                            upload_offset,
11175                        );
11176                        let draw_result = {
11177                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
11178                                &wgpu::RenderPassDescriptor {
11179                                    label: Some("Segment Image Pass"),
11180                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11181                                        view: target_view,
11182                                        resolve_target: None,
11183                                        depth_slice: None,
11184                                        ops: wgpu::Operations {
11185                                            load: next_load_op,
11186                                            store: wgpu::StoreOp::Store,
11187                                        },
11188                                    })],
11189                                    depth_stencil_attachment: None,
11190                                    timestamp_writes: None,
11191                                    occlusion_query_set: None,
11192                                    multiview_mask: None,
11193                                },
11194                            );
11195                            self.draw_prepared_images(
11196                                &mut render_pass,
11197                                &prepared_images,
11198                                blend_mode,
11199                            )
11200                        };
11201                        pass_count = pass_count.saturating_add(1);
11202                        self.scratch_image_cmds = prepared_images.into_cmds();
11203                        draw_result?;
11204                        rendered_any = true;
11205                        next_load_op = wgpu::LoadOp::Load;
11206                    }
11207                    SegmentBatchPlan::Text { start, end } => {
11208                        let viewport = ViewportUniformParams {
11209                            width,
11210                            height,
11211                            offset: [0.0, 0.0],
11212                        };
11213                        let text_draws =
11214                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
11215                        if let Some(prepared_glyphs) = self.prepare_text_glyph_draw_cmds(
11216                            text_draws,
11217                            viewport,
11218                            root_scale,
11219                            &mut staged_uploads,
11220                        )? {
11221                            if prepared_glyphs.is_empty() {
11222                                self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
11223                                continue;
11224                            }
11225                            let upload_offset = frame_encoder
11226                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11227                            self.flush_staged_uploads_at(
11228                                frame_encoder.encoder(),
11229                                &staged_uploads,
11230                                upload_offset,
11231                            );
11232                            {
11233                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
11234                                    &wgpu::RenderPassDescriptor {
11235                                        label: Some("Segment Text Glyph Atlas Pass"),
11236                                        color_attachments: &[Some(
11237                                            wgpu::RenderPassColorAttachment {
11238                                                view: target_view,
11239                                                resolve_target: None,
11240                                                depth_slice: None,
11241                                                ops: wgpu::Operations {
11242                                                    load: next_load_op,
11243                                                    store: wgpu::StoreOp::Store,
11244                                                },
11245                                            },
11246                                        )],
11247                                        depth_stencil_attachment: None,
11248                                        timestamp_writes: None,
11249                                        occlusion_query_set: None,
11250                                        multiview_mask: None,
11251                                    },
11252                                );
11253                                self.draw_prepared_glyphs(&mut render_pass, &prepared_glyphs)?;
11254                            }
11255                            pass_count = pass_count.saturating_add(1);
11256                            self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
11257                            rendered_any = true;
11258                            next_load_op = wgpu::LoadOp::Load;
11259                        } else {
11260                            let text_draws =
11261                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
11262                            let prepared_images = self.prepare_text_image_draw_cmds(
11263                                text_draws,
11264                                viewport,
11265                                root_scale,
11266                                &mut staged_uploads,
11267                            )?;
11268                            if prepared_images.is_empty() {
11269                                self.scratch_image_cmds = prepared_images.into_cmds();
11270                                continue;
11271                            }
11272                            let upload_offset = frame_encoder
11273                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11274                            self.flush_staged_uploads_at(
11275                                frame_encoder.encoder(),
11276                                &staged_uploads,
11277                                upload_offset,
11278                            );
11279                            {
11280                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
11281                                    &wgpu::RenderPassDescriptor {
11282                                        label: Some("Segment Text Pass"),
11283                                        color_attachments: &[Some(
11284                                            wgpu::RenderPassColorAttachment {
11285                                                view: target_view,
11286                                                resolve_target: None,
11287                                                depth_slice: None,
11288                                                ops: wgpu::Operations {
11289                                                    load: next_load_op,
11290                                                    store: wgpu::StoreOp::Store,
11291                                                },
11292                                            },
11293                                        )],
11294                                        depth_stencil_attachment: None,
11295                                        timestamp_writes: None,
11296                                        occlusion_query_set: None,
11297                                        multiview_mask: None,
11298                                    },
11299                                );
11300                                self.draw_prepared_images(
11301                                    &mut render_pass,
11302                                    &prepared_images,
11303                                    BlendMode::SrcOver,
11304                                )?;
11305                            }
11306                            self.frame_stats.bump_text();
11307                            pass_count = pass_count.saturating_add(1);
11308                            self.scratch_image_cmds = prepared_images.into_cmds();
11309                            rendered_any = true;
11310                            next_load_op = wgpu::LoadOp::Load;
11311                        }
11312                    }
11313                    SegmentBatchPlan::Composite { start, end } => {
11314                        let batch_items: Vec<_> = ordered_items[start..end]
11315                            .iter()
11316                            .map(|(_, item)| match item {
11317                                SegmentDrawItem::Composite(composite_index) => composites
11318                                    .get(*composite_index)
11319                                    .map(|(_, composite)| *composite)
11320                                    .ok_or_else(|| {
11321                                        "composite item index is outside the composite buffer"
11322                                            .to_string()
11323                                    }),
11324                                other => Err(format!(
11325                                    "composite batch contains non-composite draw item: {other:?}"
11326                                )),
11327                            })
11328                            .collect::<Result<_, _>>()?;
11329                        let device = self.device.clone();
11330                        self.effect_renderer.encode_composite_batch_to_view_pass(
11331                            frame_encoder,
11332                            &device,
11333                            target_view,
11334                            (width, height),
11335                            next_load_op,
11336                            &batch_items,
11337                        );
11338                        self.effect_renderer.record_composite_pass();
11339                        pass_count = pass_count.saturating_add(1);
11340                        rendered_any = true;
11341                        next_load_op = wgpu::LoadOp::Load;
11342                    }
11343                    SegmentBatchPlan::ShaderComposite { start, end } => {
11344                        let batch_items: Vec<_> = ordered_items[start..end]
11345                            .iter()
11346                            .map(|(_, item)| match item {
11347                                SegmentDrawItem::ShaderComposite(composite_index) => {
11348                                    shader_composites
11349                                        .get(*composite_index)
11350                                        .map(|(_, composite)| *composite)
11351                                        .ok_or_else(|| {
11352                                            "shader composite item index is outside the shader composite buffer"
11353                                                .to_string()
11354                                        })
11355                                }
11356                                other => Err(format!(
11357                                    "shader composite batch contains non-shader-composite draw item: {other:?}"
11358                                )),
11359                            })
11360                            .collect::<Result<Vec<_>, _>>()?;
11361                        let device = self.device.clone();
11362                        let encoded = self.effect_renderer.encode_shader_batch_src_over_to_view(
11363                            frame_encoder,
11364                            &device,
11365                            target_view,
11366                            (width, height),
11367                            next_load_op,
11368                            &batch_items,
11369                        );
11370                        if !encoded {
11371                            return Err("shader composite batch failed to encode".to_string());
11372                        }
11373                        self.effect_renderer.record_composite_pass();
11374                        self.effect_renderer.debug_effects.set(
11375                            self.effect_renderer.debug_effects.get() + batch_items.len() as u32,
11376                        );
11377                        pass_count = pass_count.saturating_add(1);
11378                        rendered_any = true;
11379                        next_load_op = wgpu::LoadOp::Load;
11380                    }
11381                    SegmentBatchPlan::Retained { start, end } => {
11382                        // Reached only when native fusion declined the chunk;
11383                        // retained batches exist on storage-mode native
11384                        // devices, where fusion always accepts, but the arm
11385                        // stays a real draw so that assumption is not load-
11386                        // bearing for correctness. Deliberately direct encode
11387                        // — retained bundle caching AND segment-surface
11388                        // compositing live in the fused path only; this
11389                        // fallback stays the simple reference.
11390                        #[cfg(target_arch = "wasm32")]
11391                        {
11392                            let _ = (start, end);
11393                            return Err("retained shape batches are native-only".to_string());
11394                        }
11395                        #[cfg(not(target_arch = "wasm32"))]
11396                        {
11397                            self.stage_replay_patches(&mut staged_uploads);
11398                            for (_, item) in &ordered_items[start..end] {
11399                                let SegmentDrawItem::Retained(index) = item else {
11400                                    return Err(format!(
11401                                        "retained batch contains non-retained draw item: {item:?}"
11402                                    ));
11403                                };
11404                                let retained = retained_draws.get(*index).ok_or_else(|| {
11405                                    format!("retained draw index {index} out of bounds")
11406                                })?;
11407                                if (*index as u32) < MAX_REPLAY_SLOTS
11408                                    && self.replay_slots.slots.contains_key(&retained.slot)
11409                                {
11410                                    let transform = retained.transform.with_retained_paint();
11411                                    staged_uploads.stage_at(
11412                                        UploadTarget::ReplayTransform,
11413                                        *index as u64 * REPLAY_TRANSFORM_STRIDE,
11414                                        bytemuck::bytes_of(&transform),
11415                                    );
11416                                }
11417                            }
11418                            let upload_offset = frame_encoder
11419                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11420                            self.flush_staged_uploads_at(
11421                                frame_encoder.encoder(),
11422                                &staged_uploads,
11423                                upload_offset,
11424                            );
11425                            {
11426                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
11427                                    &wgpu::RenderPassDescriptor {
11428                                        label: Some("Segment Retained Pass"),
11429                                        color_attachments: &[Some(
11430                                            wgpu::RenderPassColorAttachment {
11431                                                view: target_view,
11432                                                resolve_target: None,
11433                                                depth_slice: None,
11434                                                ops: wgpu::Operations {
11435                                                    load: next_load_op,
11436                                                    store: wgpu::StoreOp::Store,
11437                                                },
11438                                            },
11439                                        )],
11440                                        depth_stencil_attachment: None,
11441                                        timestamp_writes: None,
11442                                        occlusion_query_set: None,
11443                                        multiview_mask: None,
11444                                    },
11445                                );
11446                                for (_, item) in &ordered_items[start..end] {
11447                                    if let SegmentDrawItem::Retained(index) = item {
11448                                        if let Some(retained) = retained_draws.get(*index) {
11449                                            self.draw_retained_batch(
11450                                                &mut render_pass,
11451                                                retained,
11452                                                *index,
11453                                                width,
11454                                                height,
11455                                            );
11456                                        }
11457                                    }
11458                                }
11459                            }
11460                            pass_count = pass_count.saturating_add(1);
11461                            rendered_any = true;
11462                            next_load_op = wgpu::LoadOp::Load;
11463                        }
11464                    }
11465                }
11466            }
11467            Ok(SegmentRenderOutcome {
11468                rendered_any,
11469                pass_count,
11470            })
11471        })();
11472        self.restore_staged_uploads(staged_uploads);
11473        result
11474    }
11475
11476    fn viewport_uniforms(params: ViewportUniformParams) -> Uniforms {
11477        Uniforms {
11478            viewport: [params.width as f32, params.height as f32],
11479            viewport_offset: params.offset,
11480        }
11481    }
11482
11483    #[cfg(not(target_arch = "wasm32"))]
11484    fn stage_viewport_uniforms(
11485        &self,
11486        staged_uploads: &mut StagedBufferUploads,
11487        params: ViewportUniformParams,
11488    ) {
11489        let uniforms = Self::viewport_uniforms(params);
11490        staged_uploads.stage(UploadTarget::Uniform, bytemuck::bytes_of(&uniforms));
11491    }
11492
11493    #[cfg(not(target_arch = "wasm32"))]
11494    fn stage_retained_glyph_viewport_uniforms(
11495        &mut self,
11496        staged_uploads: &mut StagedBufferUploads,
11497        params: ViewportUniformParams,
11498    ) -> usize {
11499        let slot = self.claim_retained_glyph_uniform_slot();
11500        let uniforms = Self::viewport_uniforms(params);
11501        staged_uploads.stage_at(
11502            UploadTarget::RetainedGlyphUniform,
11503            self.retained_glyph_uniform_offset(slot),
11504            bytemuck::bytes_of(&uniforms),
11505        );
11506        slot
11507    }
11508
11509    #[cfg(not(target_arch = "wasm32"))]
11510    fn claim_retained_glyph_uniform_slot(&mut self) -> usize {
11511        let slot = self.retained_glyph_uniform_cursor;
11512        self.retained_glyph_uniform_cursor = self.retained_glyph_uniform_cursor.saturating_add(1);
11513        self.ensure_retained_glyph_uniform_capacity(slot.saturating_add(1));
11514        slot
11515    }
11516
11517    #[cfg(not(target_arch = "wasm32"))]
11518    fn retained_glyph_uniform_offset(&self, slot: usize) -> u64 {
11519        self.retained_glyph_uniform_stride * slot as u64
11520    }
11521
11522    #[cfg(not(target_arch = "wasm32"))]
11523    fn retained_glyph_uniform_dynamic_offset(&self, slot: usize) -> Result<u32, String> {
11524        let offset = self.retained_glyph_uniform_offset(slot);
11525        u32::try_from(offset).map_err(|_| {
11526            "retained glyph uniform offset exceeded WGPU dynamic offset range".to_string()
11527        })
11528    }
11529
11530    #[cfg(not(target_arch = "wasm32"))]
11531    fn ensure_retained_glyph_uniform_capacity(&mut self, required_slots: usize) {
11532        if required_slots <= self.retained_glyph_uniform_capacity {
11533            return;
11534        }
11535        let new_capacity = required_slots
11536            .next_power_of_two()
11537            .max(INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS);
11538        self.retained_glyph_uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11539            label: Some("Retained Glyph Uniform Buffer"),
11540            size: self.retained_glyph_uniform_stride * new_capacity as u64,
11541            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
11542            mapped_at_creation: false,
11543        });
11544        self.retained_glyph_uniform_bind_group =
11545            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
11546                label: Some("Retained Glyph Uniform Bind Group"),
11547                layout: &self.retained_glyph_uniform_bind_group_layout,
11548                entries: &[wgpu::BindGroupEntry {
11549                    binding: 0,
11550                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11551                        buffer: &self.retained_glyph_uniform_buffer,
11552                        offset: 0,
11553                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
11554                    }),
11555                }],
11556            });
11557        self.retained_glyph_uniform_capacity = new_capacity;
11558    }
11559
11560    #[cfg(target_arch = "wasm32")]
11561    fn prepare_wasm_viewport_uniforms(&mut self, params: ViewportUniformParams) -> usize {
11562        let slot = self.claim_wasm_uniform_batch();
11563        let uniforms = Self::viewport_uniforms(params);
11564        let bytes = bytemuck::bytes_of(&uniforms);
11565        let upload_stats = self.frame_graph_executor.upload_buffer(
11566            &self.queue,
11567            &self.wasm_uniform_batches[slot].buffer,
11568            0,
11569            bytes,
11570        );
11571        self.frame_stats.record_command_stats(upload_stats);
11572        slot
11573    }
11574
11575    #[cfg(target_arch = "wasm32")]
11576    fn claim_wasm_uniform_batch(&mut self) -> usize {
11577        let slot = self.wasm_uniform_batch_cursor;
11578        self.wasm_uniform_batch_cursor += 1;
11579        while self.wasm_uniform_batches.len() <= slot {
11580            self.wasm_uniform_batches.push(UniformBatchBuffer::new(
11581                &self.device,
11582                &self.uniform_bind_group_layout,
11583            ));
11584        }
11585        slot
11586    }
11587
11588    #[cfg(target_arch = "wasm32")]
11589    fn claim_wasm_shape_batch(&mut self) -> usize {
11590        let slot = self.wasm_shape_batch_cursor;
11591        self.wasm_shape_batch_cursor += 1;
11592        while self.wasm_shape_batches.len() <= slot {
11593            self.wasm_shape_batches.push(ShapeBatchBuffers::new(
11594                &self.device,
11595                &self.shape_bind_group_layout,
11596                &self.identity_similarity_buffer,
11597                self.dummy_paint_buffer.as_ref(),
11598                self.shape_batch_limits,
11599            ));
11600        }
11601        slot
11602    }
11603
11604    #[cfg(target_arch = "wasm32")]
11605    fn claim_wasm_image_batch(&mut self) -> usize {
11606        let slot = self.wasm_image_batch_cursor;
11607        self.wasm_image_batch_cursor += 1;
11608        while self.wasm_image_batches.len() <= slot {
11609            self.wasm_image_batches
11610                .push(ImageBatchBuffers::new(&self.device));
11611        }
11612        slot
11613    }
11614
11615    #[cfg(target_arch = "wasm32")]
11616    fn write_wasm_buffer(&self, buffer: &wgpu::Buffer, bytes: &[u8]) {
11617        let upload_stats = self
11618            .frame_graph_executor
11619            .upload_buffer(&self.queue, buffer, 0, bytes);
11620        self.frame_stats.record_command_stats(upload_stats);
11621    }
11622
11623    fn take_staged_uploads(&mut self) -> StagedBufferUploads {
11624        let mut staged_uploads = std::mem::take(&mut self.staged_uploads);
11625        debug_assert!(
11626            staged_uploads.is_empty(),
11627            "renderer-owned staged uploads should be restored as empty scratch storage"
11628        );
11629        staged_uploads.clear();
11630        staged_uploads
11631    }
11632
11633    fn restore_staged_uploads(&mut self, mut staged_uploads: StagedBufferUploads) {
11634        staged_uploads.clear();
11635        self.staged_uploads = staged_uploads;
11636    }
11637
11638    #[cfg(not(target_arch = "wasm32"))]
11639    fn ensure_upload_buffer_capacity(&mut self, required_bytes: u64) {
11640        if required_bytes <= self.upload_buffer.size() {
11641            return;
11642        }
11643
11644        let new_size = required_bytes
11645            .next_power_of_two()
11646            .max(INITIAL_UPLOAD_BUFFER_BYTES);
11647        self.upload_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11648            label: Some("Frame Upload Buffer"),
11649            size: new_size,
11650            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
11651            mapped_at_creation: false,
11652        });
11653    }
11654
11655    fn flush_staged_uploads_at(
11656        &mut self,
11657        encoder: &mut wgpu::CommandEncoder,
11658        staged_uploads: &StagedBufferUploads,
11659        upload_buffer_offset: u64,
11660    ) {
11661        if staged_uploads.is_empty() {
11662            return;
11663        }
11664        debug_assert_eq!(
11665            upload_buffer_offset % wgpu::COPY_BUFFER_ALIGNMENT,
11666            0,
11667            "upload-buffer base offset must satisfy copy alignment"
11668        );
11669
11670        #[cfg(target_arch = "wasm32")]
11671        {
11672            let _ = upload_buffer_offset;
11673            let _ = encoder;
11674            debug_assert!(
11675                staged_uploads.is_empty(),
11676                "wasm draw uploads use retained per-batch resource slots"
11677            );
11678            return;
11679        }
11680
11681        #[cfg(not(target_arch = "wasm32"))]
11682        {
11683            self.ensure_upload_buffer_capacity(
11684                upload_buffer_offset + staged_uploads.bytes.len() as u64,
11685            );
11686            let upload_stats = self.frame_graph_executor.upload_buffer(
11687                &self.queue,
11688                &self.upload_buffer,
11689                upload_buffer_offset,
11690                &staged_uploads.bytes,
11691            );
11692            self.frame_stats.record_command_stats(upload_stats);
11693
11694            for copy in &staged_uploads.copies {
11695                let target_buffer = match copy.target {
11696                    UploadTarget::Uniform => &self.uniform_buffer,
11697                    UploadTarget::ShapeData => &self.shape_buffers.shape_buffer,
11698                    UploadTarget::ShapeGradient => &self.shape_buffers.gradient_buffer,
11699                    UploadTarget::ImageVertex => &self.image_vertex_buffer,
11700                    UploadTarget::ImageIndex => &self.image_index_buffer,
11701                    UploadTarget::RetainedGlyphUniform => &self.retained_glyph_uniform_buffer,
11702                    UploadTarget::ReplayTransform => &self.replay_slots.transform_buffer,
11703                    UploadTarget::ReplayPaintData(slot) => {
11704                        // A slot released between staging and flush has
11705                        // nothing left to patch.
11706                        let Some(entry) = self.replay_slots.slots.get(&slot) else {
11707                            continue;
11708                        };
11709                        &entry.paint_buffer
11710                    }
11711                };
11712                encoder.copy_buffer_to_buffer(
11713                    &self.upload_buffer,
11714                    upload_buffer_offset + copy.source_offset,
11715                    target_buffer,
11716                    copy.target_offset,
11717                    copy.size,
11718                );
11719            }
11720        }
11721    }
11722
11723    #[allow(clippy::too_many_arguments)]
11724    fn encode_shadow_draw<C: FrameCommandRecorder>(
11725        &mut self,
11726        frame_encoder: &mut C,
11727        target_view: &wgpu::TextureView,
11728        shadow: &ShadowDraw,
11729        width: u32,
11730        height: u32,
11731        root_scale: f32,
11732    ) {
11733        if shadow.shapes.is_empty() && shadow.texts.is_empty() {
11734            return;
11735        }
11736
11737        let shape_bounds_opt = shadow
11738            .shapes
11739            .iter()
11740            .map(|(shape, _)| shape.rect)
11741            .reduce(|a, b| Rect {
11742                x: a.x.min(b.x),
11743                y: a.y.min(b.y),
11744                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
11745                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
11746            });
11747
11748        let text_bounds_opt = shadow
11749            .texts
11750            .iter()
11751            .map(|text| text.rect)
11752            .reduce(|a, b| Rect {
11753                x: a.x.min(b.x),
11754                y: a.y.min(b.y),
11755                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
11756                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
11757            });
11758
11759        let combined_bounds = match (shape_bounds_opt, text_bounds_opt) {
11760            (Some(s), Some(t)) => Some(Rect {
11761                x: s.x.min(t.x),
11762                y: s.y.min(t.y),
11763                width: (s.x + s.width).max(t.x + t.width) - s.x.min(t.x),
11764                height: (s.y + s.height).max(t.y + t.height) - s.y.min(t.y),
11765            }),
11766            (Some(s), None) => Some(s),
11767            (None, Some(t)) => Some(t),
11768            (None, None) => None,
11769        };
11770
11771        let Some(shape_bounds) = combined_bounds else {
11772            return;
11773        };
11774
11775        let blur_margin = blur_extent_margin(shadow.blur_radius);
11776        let source_blur_bounds = Rect {
11777            x: shape_bounds.x - blur_margin,
11778            y: shape_bounds.y - blur_margin,
11779            width: shape_bounds.width + blur_margin * 2.0,
11780            height: shape_bounds.height + blur_margin * 2.0,
11781        };
11782        let mut visible_blur_bounds = source_blur_bounds;
11783        if let Some(clip) = shadow.clip {
11784            let clip_expanded = Rect {
11785                x: clip.x - blur_margin,
11786                y: clip.y - blur_margin,
11787                width: clip.width + blur_margin * 2.0,
11788                height: clip.height + blur_margin * 2.0,
11789            };
11790            let Some(intersection) = visible_blur_bounds.intersect(clip_expanded) else {
11791                return;
11792            };
11793            visible_blur_bounds = intersection;
11794        }
11795        let processing_scissor =
11796            scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
11797        if processing_scissor.is_none() {
11798            return;
11799        }
11800
11801        // Zero blur: render shapes directly to target (fast path).
11802        if shadow.blur_radius <= 0.0 {
11803            for (shape, blend_mode) in &shadow.shapes {
11804                self.encode_shapes_pass(
11805                    frame_encoder,
11806                    target_view,
11807                    std::iter::once(shape),
11808                    &shadow.brushes,
11809                    *blend_mode,
11810                    width,
11811                    height,
11812                    root_scale,
11813                    wgpu::LoadOp::Load,
11814                    [0.0, 0.0],
11815                );
11816                frame_encoder.record_pass();
11817            }
11818            if !shadow.texts.is_empty() {
11819                let mut staged_uploads = self.take_staged_uploads();
11820                let viewport = ViewportUniformParams {
11821                    width,
11822                    height,
11823                    offset: [0.0, 0.0],
11824                };
11825                match self.prepare_text_image_draw_cmds(
11826                    shadow.texts.iter(),
11827                    viewport,
11828                    root_scale,
11829                    &mut staged_uploads,
11830                ) {
11831                    Ok(prepared_images) if !prepared_images.is_empty() => {
11832                        let upload_offset = frame_encoder
11833                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11834                        self.flush_staged_uploads_at(
11835                            frame_encoder.encoder(),
11836                            &staged_uploads,
11837                            upload_offset,
11838                        );
11839                        let draw_result = {
11840                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
11841                                &wgpu::RenderPassDescriptor {
11842                                    label: Some("Zero Blur Shadow Text Image Pass"),
11843                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11844                                        view: target_view,
11845                                        resolve_target: None,
11846                                        depth_slice: None,
11847                                        ops: wgpu::Operations {
11848                                            load: wgpu::LoadOp::Load,
11849                                            store: wgpu::StoreOp::Store,
11850                                        },
11851                                    })],
11852                                    depth_stencil_attachment: None,
11853                                    timestamp_writes: None,
11854                                    occlusion_query_set: None,
11855                                    multiview_mask: None,
11856                                },
11857                            );
11858                            self.draw_prepared_images(
11859                                &mut render_pass,
11860                                &prepared_images,
11861                                BlendMode::SrcOver,
11862                            )
11863                        };
11864                        self.scratch_image_cmds = prepared_images.into_cmds();
11865                        if let Err(e) = draw_result {
11866                            eprintln!("Failed to draw text for zero-blur shadow: {}", e);
11867                        } else {
11868                            self.frame_stats.bump_text();
11869                            frame_encoder.record_pass();
11870                        }
11871                    }
11872                    Ok(prepared_images) => {
11873                        self.scratch_image_cmds = prepared_images.into_cmds();
11874                    }
11875                    Err(e) => {
11876                        eprintln!("Failed to prepare text image for zero-blur shadow: {}", e);
11877                    }
11878                }
11879                self.restore_staged_uploads(staged_uploads);
11880            }
11881            return;
11882        }
11883
11884        // Compute pixel-space bounds for the offscreen textures, clamped to viewport.
11885        let Some(device_bounds) =
11886            device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)
11887        else {
11888            return;
11889        };
11890        let bounds_x = device_bounds.x;
11891        let bounds_y = device_bounds.y;
11892        let bounds_w = device_bounds.width;
11893        let bounds_h = device_bounds.height;
11894        let pixel_radius = shadow.blur_radius * root_scale;
11895
11896        if shadow.texts.is_empty() && !shadow.shapes.is_empty() {
11897            if let Some(plan) = shape_shadow_surface_plan(
11898                &shadow.shapes,
11899                shadow.clip,
11900                shadow.blur_radius,
11901                width,
11902                height,
11903                root_scale,
11904                self.max_texture_dim(),
11905            ) {
11906                if self.encode_shape_only_blurred_shadow_draw(
11907                    frame_encoder,
11908                    target_view,
11909                    shadow,
11910                    plan.source_device_bounds,
11911                    plan.pixel_radius,
11912                    plan.processing_scissor,
11913                    width,
11914                    height,
11915                    root_scale,
11916                ) {
11917                    return;
11918                }
11919            }
11920        }
11921
11922        if !shadow.texts.is_empty() {
11923            self.frame_stats.record_shadow_text_blur_fallback();
11924        }
11925
11926        let device = self.device.clone();
11927        let source_descriptor =
11928            self.transient_offscreen_descriptor("Shadow Source", bounds_w, bounds_h);
11929        let source = frame_encoder.acquire_transient_offscreen(&device, source_descriptor);
11930        let viewport_offset = [bounds_x, bounds_y];
11931        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
11932        let source_outcome = self.encode_shadow_shape_source_passes(
11933            frame_encoder,
11934            &source.view,
11935            &shadow.shapes,
11936            &shadow.brushes,
11937            bounds_w,
11938            bounds_h,
11939            viewport_offset,
11940            root_scale,
11941            &mut next_load_op,
11942        );
11943        frame_encoder.record_passes(source_outcome.pass_count);
11944        let mut rendered_any = source_outcome.rendered_any;
11945
11946        if !shadow.texts.is_empty() {
11947            let mut shifted_texts = shadow.texts.clone();
11948            for text in &mut shifted_texts {
11949                text.rect.x -= viewport_offset[0] / root_scale;
11950                text.rect.y -= viewport_offset[1] / root_scale;
11951                if let Some(clip) = text.clip.as_mut() {
11952                    clip.x -= viewport_offset[0] / root_scale;
11953                    clip.y -= viewport_offset[1] / root_scale;
11954                }
11955            }
11956
11957            let mut staged_uploads = self.take_staged_uploads();
11958            let viewport = ViewportUniformParams {
11959                width: bounds_w,
11960                height: bounds_h,
11961                offset: [0.0, 0.0],
11962            };
11963            match self.prepare_text_image_draw_cmds(
11964                shifted_texts.iter(),
11965                viewport,
11966                root_scale,
11967                &mut staged_uploads,
11968            ) {
11969                Ok(prepared_images) if !prepared_images.is_empty() => {
11970                    let upload_offset = frame_encoder
11971                        .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11972                    self.flush_staged_uploads_at(
11973                        frame_encoder.encoder(),
11974                        &staged_uploads,
11975                        upload_offset,
11976                    );
11977                    let draw_result = {
11978                        let mut render_pass = frame_encoder.encoder().begin_render_pass(
11979                            &wgpu::RenderPassDescriptor {
11980                                label: Some("Shadow Source Text Image Pass"),
11981                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11982                                    view: &source.view,
11983                                    resolve_target: None,
11984                                    depth_slice: None,
11985                                    ops: wgpu::Operations {
11986                                        load: next_load_op,
11987                                        store: wgpu::StoreOp::Store,
11988                                    },
11989                                })],
11990                                depth_stencil_attachment: None,
11991                                timestamp_writes: None,
11992                                occlusion_query_set: None,
11993                                multiview_mask: None,
11994                            },
11995                        );
11996                        self.draw_prepared_images(
11997                            &mut render_pass,
11998                            &prepared_images,
11999                            BlendMode::SrcOver,
12000                        )
12001                    };
12002                    self.scratch_image_cmds = prepared_images.into_cmds();
12003                    if let Err(e) = draw_result {
12004                        eprintln!("Failed to draw text for shadow: {}", e);
12005                    } else {
12006                        self.frame_stats.bump_text();
12007                        frame_encoder.record_pass();
12008                        rendered_any = true;
12009                    }
12010                }
12011                Ok(prepared_images) => {
12012                    self.scratch_image_cmds = prepared_images.into_cmds();
12013                }
12014                Err(e) => {
12015                    eprintln!("Failed to prepare text image for shadow: {}", e);
12016                }
12017            }
12018            self.restore_staged_uploads(staged_uploads);
12019        }
12020
12021        if !rendered_any {
12022            frame_encoder.release_transient_offscreen(source_descriptor, source);
12023            return;
12024        }
12025
12026        let (scratch_w, scratch_h) = crate::effect_renderer::blur_scratch_size(
12027            pixel_radius,
12028            pixel_radius,
12029            bounds_w,
12030            bounds_h,
12031        );
12032        let scratch_descriptor =
12033            self.transient_offscreen_descriptor("Shadow Blur Scratch", scratch_w, scratch_h);
12034        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
12035        {
12036            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
12037                frame_encoder,
12038                &device,
12039                &source,
12040                &scratch,
12041                &source.view,
12042                pixel_radius,
12043                pixel_radius,
12044                TileMode::Decal,
12045                None, // No scissor needed — the texture is already bounds-sized
12046            );
12047        }
12048        frame_encoder.record_passes(2);
12049
12050        let clip_scissor = shadow
12051            .clip
12052            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
12053        let scissor = clip_scissor.or(processing_scissor);
12054        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
12055            // Adjust mask coordinates from viewport-space to texture-local space,
12056            // since the blit shader computes world_pos = uv * tex_size.
12057            mask.rect[0] -= viewport_offset[0];
12058            mask.rect[1] -= viewport_offset[1];
12059            mask
12060        });
12061        let dest_viewport = Some((
12062            viewport_offset[0],
12063            viewport_offset[1],
12064            bounds_w as f32,
12065            bounds_h as f32,
12066        ));
12067        {
12068            self.effect_renderer
12069                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
12070                    frame_encoder,
12071                    &device,
12072                    &source,
12073                    target_view,
12074                    1.0,
12075                    wgpu::LoadOp::Load,
12076                    scissor,
12077                    rounded_mask,
12078                    BlendMode::SrcOver,
12079                    dest_viewport,
12080                    CompositeSampleMode::Linear,
12081                );
12082        }
12083        frame_encoder.record_pass();
12084        self.effect_renderer.record_blur_pass();
12085        self.effect_renderer.record_composite_pass();
12086        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
12087        frame_encoder.release_transient_offscreen(source_descriptor, source);
12088    }
12089
12090    #[allow(clippy::too_many_arguments)]
12091    fn encode_shadow_shape_source_passes<C: FrameCommandRecorder>(
12092        &mut self,
12093        frame_encoder: &mut C,
12094        source_view: &wgpu::TextureView,
12095        shapes: &[(DrawShape, BlendMode)],
12096        brushes: &[Brush],
12097        width: u32,
12098        height: u32,
12099        viewport_offset: [f32; 2],
12100        root_scale: f32,
12101        next_load_op: &mut wgpu::LoadOp<wgpu::Color>,
12102    ) -> ShadowSourceRenderOutcome {
12103        if shapes.is_empty() {
12104            return ShadowSourceRenderOutcome {
12105                rendered_any: false,
12106                pass_count: 0,
12107            };
12108        }
12109
12110        let mut staged_uploads = self.take_staged_uploads();
12111        let mut rendered_any = false;
12112        let mut pass_count = 0_u32;
12113        let mut start = 0usize;
12114        while start < shapes.len() {
12115            let blend_mode = supported_blend_mode(shapes[start].1);
12116            let mut end = start + 1;
12117            while end < shapes.len()
12118                && end - start < self.shape_batch_limits.max_shapes_per_batch
12119                && supported_blend_mode(shapes[end].1) == blend_mode
12120            {
12121                end += 1;
12122            }
12123
12124            staged_uploads.clear();
12125            let viewport = ViewportUniformParams {
12126                width,
12127                height,
12128                offset: viewport_offset,
12129            };
12130            let viewport_rect_logical = viewport_rect_in_logical(viewport, root_scale);
12131            let Some(prepared_shape) = self.prepare_shapes_batch(
12132                shapes[start..end]
12133                    .iter()
12134                    .map(|(shape, _blend_mode)| shape)
12135                    .filter(|shape| match viewport_rect_logical {
12136                        Some(rect) => shape_draw_is_visible_in_rect(shape, rect, root_scale),
12137                        None => false,
12138                    }),
12139                brushes,
12140                root_scale,
12141                viewport,
12142                &mut staged_uploads,
12143            ) else {
12144                start = end;
12145                continue;
12146            };
12147
12148            let upload_offset =
12149                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
12150            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
12151
12152            {
12153                let mut render_pass =
12154                    frame_encoder
12155                        .encoder()
12156                        .begin_render_pass(&wgpu::RenderPassDescriptor {
12157                            label: Some("Shadow Source Shape Pass"),
12158                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
12159                                view: source_view,
12160                                resolve_target: None,
12161                                depth_slice: None,
12162                                ops: wgpu::Operations {
12163                                    load: *next_load_op,
12164                                    store: wgpu::StoreOp::Store,
12165                                },
12166                            })],
12167                            depth_stencil_attachment: None,
12168                            timestamp_writes: None,
12169                            occlusion_query_set: None,
12170                            multiview_mask: None,
12171                        });
12172                self.draw_prepared_shapes(
12173                    &mut render_pass,
12174                    blend_mode,
12175                    prepared_shape,
12176                    width,
12177                    height,
12178                    &[],
12179                );
12180            }
12181
12182            #[cfg(not(target_arch = "wasm32"))]
12183            {
12184                if fill_area_diag_enabled() {
12185                    // Each shadow-source pass round-trips the whole
12186                    // bounds-sized offscreen target (clear on the first
12187                    // pass, load/store after); the shape quads inside were
12188                    // already priced by `prepare_shapes_batch` under this
12189                    // pass's bounds viewport.
12190                    self.fill_area_diag
12191                        .add_offscreen_target_fill(f64::from(width) * f64::from(height));
12192                }
12193            }
12194
12195            pass_count = pass_count.saturating_add(1);
12196            rendered_any = true;
12197            *next_load_op = wgpu::LoadOp::Load;
12198            start = end;
12199        }
12200
12201        self.restore_staged_uploads(staged_uploads);
12202        ShadowSourceRenderOutcome {
12203            rendered_any,
12204            pass_count,
12205        }
12206    }
12207
12208    #[allow(clippy::too_many_arguments)]
12209    fn encode_shape_only_blurred_shadow_draw<C: FrameCommandRecorder>(
12210        &mut self,
12211        frame_encoder: &mut C,
12212        target_view: &wgpu::TextureView,
12213        shadow: &ShadowDraw,
12214        device_bounds: DevicePixelBounds,
12215        pixel_radius: f32,
12216        processing_scissor: Option<(u32, u32, u32, u32)>,
12217        width: u32,
12218        height: u32,
12219        root_scale: f32,
12220    ) -> bool {
12221        let bounds_w = device_bounds.width;
12222        let bounds_h = device_bounds.height;
12223        let viewport_offset = [device_bounds.x, device_bounds.y];
12224        let cache_key = shape_shadow_surface_cache_key(
12225            &shadow.shapes,
12226            &shadow.brushes,
12227            device_bounds,
12228            pixel_radius,
12229            root_scale,
12230        );
12231
12232        if let Some(key) = cache_key {
12233            if let Some(cached) = self.cached_shadow_surface(&key) {
12234                self.frame_stats
12235                    .record_shadow_shape_cache_hit(bounds_w, bounds_h);
12236                let clip_scissor = shadow
12237                    .clip
12238                    .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
12239                let scissor = clip_scissor.or(processing_scissor);
12240                let rounded_mask =
12241                    inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
12242                        mask.rect[0] -= viewport_offset[0];
12243                        mask.rect[1] -= viewport_offset[1];
12244                        mask
12245                    });
12246                let dest_viewport = Some((
12247                    viewport_offset[0],
12248                    viewport_offset[1],
12249                    bounds_w as f32,
12250                    bounds_h as f32,
12251                ));
12252                {
12253                    self.effect_renderer
12254                        .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
12255                            frame_encoder,
12256                            &self.device,
12257                            &cached,
12258                            target_view,
12259                            1.0,
12260                            wgpu::LoadOp::Load,
12261                            scissor,
12262                            rounded_mask,
12263                            BlendMode::SrcOver,
12264                            dest_viewport,
12265                            CompositeSampleMode::Nearest,
12266                        );
12267                }
12268                frame_encoder.record_pass();
12269                self.effect_renderer.record_composite_pass();
12270                return true;
12271            }
12272            self.frame_stats
12273                .record_shadow_shape_cache_miss(bounds_w, bounds_h);
12274            self.frame_stats.maybe_print_shadow_shape_cache_miss(
12275                bounds_w,
12276                bounds_h,
12277                key.content_hash,
12278                pixel_radius,
12279                viewport_offset,
12280                shadow.shapes.len(),
12281                shadow.clip,
12282            );
12283        }
12284
12285        let device = self.device.clone();
12286        let source_descriptor =
12287            self.transient_offscreen_descriptor("Shape Shadow Source", bounds_w, bounds_h);
12288        let source_is_cacheable = cache_key.is_some();
12289        let source = if source_is_cacheable {
12290            self.acquire_retained_surface(bounds_w, bounds_h)
12291        } else {
12292            frame_encoder.acquire_transient_offscreen(&device, source_descriptor)
12293        };
12294        let (scratch_w, scratch_h) = crate::effect_renderer::blur_scratch_size(
12295            pixel_radius,
12296            pixel_radius,
12297            bounds_w,
12298            bounds_h,
12299        );
12300        let scratch_descriptor =
12301            self.transient_offscreen_descriptor("Shape Shadow Blur Scratch", scratch_w, scratch_h);
12302        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
12303        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
12304        let source_outcome = self.encode_shadow_shape_source_passes(
12305            frame_encoder,
12306            &source.view,
12307            &shadow.shapes,
12308            &shadow.brushes,
12309            bounds_w,
12310            bounds_h,
12311            viewport_offset,
12312            root_scale,
12313            &mut next_load_op,
12314        );
12315        frame_encoder.record_passes(source_outcome.pass_count);
12316
12317        if !source_outcome.rendered_any {
12318            frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
12319            if source_is_cacheable {
12320                self.defer_offscreen_release(source);
12321            } else {
12322                frame_encoder.release_transient_offscreen(source_descriptor, source);
12323            }
12324            return true;
12325        }
12326
12327        {
12328            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
12329                frame_encoder,
12330                &device,
12331                &source,
12332                &scratch,
12333                &source.view,
12334                pixel_radius,
12335                pixel_radius,
12336                TileMode::Decal,
12337                None,
12338            );
12339        }
12340        frame_encoder.record_passes(2);
12341
12342        let clip_scissor = shadow
12343            .clip
12344            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
12345        let scissor = clip_scissor.or(processing_scissor);
12346        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
12347            mask.rect[0] -= viewport_offset[0];
12348            mask.rect[1] -= viewport_offset[1];
12349            mask
12350        });
12351        let dest_viewport = Some((
12352            viewport_offset[0],
12353            viewport_offset[1],
12354            bounds_w as f32,
12355            bounds_h as f32,
12356        ));
12357        {
12358            self.effect_renderer
12359                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
12360                    frame_encoder,
12361                    &device,
12362                    &source,
12363                    target_view,
12364                    1.0,
12365                    wgpu::LoadOp::Load,
12366                    scissor,
12367                    rounded_mask,
12368                    BlendMode::SrcOver,
12369                    dest_viewport,
12370                    CompositeSampleMode::Nearest,
12371                );
12372        }
12373        frame_encoder.record_pass();
12374
12375        self.effect_renderer.record_blur_pass();
12376        self.effect_renderer.record_composite_pass();
12377        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
12378        if let Some(key) = cache_key {
12379            self.insert_cached_shadow_surface(key, source);
12380        } else {
12381            frame_encoder.release_transient_offscreen(source_descriptor, source);
12382        }
12383        true
12384    }
12385
12386    fn prepare_shapes_batch<'a, I>(
12387        &mut self,
12388        layer_shapes: I,
12389        brushes: &[Brush],
12390        root_scale: f32,
12391        viewport: ViewportUniformParams,
12392        staged_uploads: &mut StagedBufferUploads,
12393    ) -> Option<PreparedShapeBatch>
12394    where
12395        I: Iterator<Item = &'a DrawShape>,
12396    {
12397        #[cfg(target_arch = "wasm32")]
12398        let _ = staged_uploads;
12399
12400        // Build shape data for this subset. Callers hand in only shapes visible in
12401        // `viewport`: the segment paths culled at collect time, and the layer and
12402        // shadow-source paths filter at the call site. Re-checking here would run
12403        // the same quad math a second time on every shape of every frame.
12404        let shape_refs: Vec<&DrawShape> = layer_shapes
12405            .take(self.shape_batch_limits.max_shapes_per_batch)
12406            .collect();
12407        let shape_count = shape_refs.len();
12408        if shape_count == 0 {
12409            return None;
12410        }
12411
12412        // Per-shape gradient spans as a prefix sum, so every output slot is
12413        // known before conversion starts and the shapes can convert in
12414        // parallel into disjoint sub-slices.
12415        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12416        let mut total_gradient_stops = 0u32;
12417        gradient_offsets.push(0);
12418        for shape in &shape_refs {
12419            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12420            gradient_offsets.push(total_gradient_stops);
12421        }
12422
12423        self.scratch_shape_data.clear();
12424        self.scratch_shape_data
12425            .resize(shape_count, ShapeData::zeroed());
12426        self.scratch_gradients.clear();
12427        self.scratch_gradients
12428            .resize(total_gradient_stops as usize, GradientStop::zeroed());
12429
12430        convert_shapes_into_outputs(
12431            &shape_refs,
12432            brushes,
12433            &gradient_offsets,
12434            root_scale,
12435            &mut self.scratch_shape_data,
12436            &mut self.scratch_gradients,
12437        );
12438        #[cfg(not(target_arch = "wasm32"))]
12439        {
12440            if fill_area_diag_enabled() {
12441                self.fill_area_diag
12442                    .add_shape_quads(&self.scratch_shape_data, viewport);
12443            }
12444        }
12445
12446        #[cfg(not(target_arch = "wasm32"))]
12447        {
12448            self.shape_buffers.ensure_capacity(
12449                &self.device,
12450                &self.shape_bind_group_layout,
12451                &self.identity_similarity_buffer,
12452                self.dummy_paint_buffer.as_ref(),
12453                shape_count,
12454                self.scratch_gradients.len().max(1),
12455            );
12456            self.stage_viewport_uniforms(staged_uploads, viewport);
12457            staged_uploads.stage(
12458                UploadTarget::ShapeData,
12459                bytemuck::cast_slice(&self.scratch_shape_data),
12460            );
12461            if !self.scratch_gradients.is_empty() {
12462                staged_uploads.stage(
12463                    UploadTarget::ShapeGradient,
12464                    bytemuck::cast_slice(&self.scratch_gradients),
12465                );
12466            }
12467        }
12468
12469        #[cfg(target_arch = "wasm32")]
12470        let shape_slot = {
12471            let slot = self.claim_wasm_shape_batch();
12472            {
12473                let buffers = &mut self.wasm_shape_batches[slot];
12474                buffers.ensure_capacity(
12475                    &self.device,
12476                    &self.shape_bind_group_layout,
12477                    &self.identity_similarity_buffer,
12478                    self.dummy_paint_buffer.as_ref(),
12479                    shape_count,
12480                    self.scratch_gradients.len().max(1),
12481                );
12482            }
12483            let buffers = &self.wasm_shape_batches[slot];
12484            self.write_wasm_buffer(
12485                &buffers.shape_buffer,
12486                bytemuck::cast_slice(&self.scratch_shape_data),
12487            );
12488            if !self.scratch_gradients.is_empty() {
12489                self.write_wasm_buffer(
12490                    &buffers.gradient_buffer,
12491                    bytemuck::cast_slice(&self.scratch_gradients),
12492                );
12493            }
12494            slot
12495        };
12496
12497        #[cfg(target_arch = "wasm32")]
12498        let uniform_slot = self.prepare_wasm_viewport_uniforms(viewport);
12499
12500        Some(PreparedShapeBatch {
12501            vertex_start: 0,
12502            vertex_count: shape_count as u32 * 6,
12503            has_gradient: total_gradient_stops > 0,
12504            #[cfg(target_arch = "wasm32")]
12505            shape_slot,
12506            #[cfg(target_arch = "wasm32")]
12507            uniform_slot,
12508        })
12509    }
12510
12511    /// Like [`Self::prepare_shapes_batch`], but converts shapes straight into
12512    /// mapped regions of the frame upload buffer instead of scratch vectors —
12513    /// one CPU pass over the data instead of three (convert, stage, upload).
12514    /// Returns the prepared batch and the upload-buffer base offset to pass
12515    /// to `flush_staged_uploads_at`; the GPU copies are recorded into
12516    /// `staged_uploads` while its byte blob stays empty.
12517    #[cfg(not(target_arch = "wasm32"))]
12518    fn prepare_shapes_batch_direct<'a, I, C: FrameCommandRecorder>(
12519        &mut self,
12520        frame_encoder: &mut C,
12521        layer_shapes: I,
12522        brushes: &[Brush],
12523        root_scale: f32,
12524        viewport: ViewportUniformParams,
12525        staged_uploads: &mut StagedBufferUploads,
12526    ) -> Option<(PreparedShapeBatch, u64)>
12527    where
12528        I: Iterator<Item = &'a DrawShape>,
12529    {
12530        let shape_refs: Vec<&DrawShape> = layer_shapes
12531            .take(self.shape_batch_limits.max_shapes_per_batch)
12532            .collect();
12533        let shape_count = shape_refs.len();
12534        if shape_count == 0 {
12535            return None;
12536        }
12537
12538        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12539        let mut total_gradient_stops = 0u32;
12540        gradient_offsets.push(0);
12541        for shape in &shape_refs {
12542            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12543            gradient_offsets.push(total_gradient_stops);
12544        }
12545
12546        self.shape_buffers.ensure_capacity(
12547            &self.device,
12548            &self.shape_bind_group_layout,
12549            &self.identity_similarity_buffer,
12550            self.dummy_paint_buffer.as_ref(),
12551            shape_count,
12552            (total_gradient_stops as usize).max(1),
12553        );
12554
12555        self.scratch_shape_data.clear();
12556        self.scratch_shape_data
12557            .resize(shape_count, ShapeData::zeroed());
12558        self.scratch_gradients.clear();
12559        self.scratch_gradients
12560            .resize(total_gradient_stops as usize, GradientStop::zeroed());
12561        convert_shapes_into_outputs(
12562            &shape_refs,
12563            brushes,
12564            &gradient_offsets,
12565            root_scale,
12566            &mut self.scratch_shape_data,
12567            &mut self.scratch_gradients,
12568        );
12569        if fill_area_diag_enabled() {
12570            self.fill_area_diag
12571                .add_shape_quads(&self.scratch_shape_data, viewport);
12572        }
12573
12574        // Region layout inside the frame upload buffer. Every element type is
12575        // f32/u32-based, so all lengths are multiples of
12576        // `COPY_BUFFER_ALIGNMENT` and back-to-back packing keeps each offset
12577        // copy-aligned. Writing each scratch slice straight into the upload
12578        // buffer skips the intermediate staged-bytes blob (one fewer CPU pass
12579        // over the batch payload).
12580        let uniform_len = std::mem::size_of::<Uniforms>() as u64;
12581        let shape_len = (shape_count * std::mem::size_of::<ShapeData>()) as u64;
12582        let gradient_len = total_gradient_stops as u64 * std::mem::size_of::<GradientStop>() as u64;
12583        let total_len = uniform_len + shape_len + gradient_len;
12584        let upload_base = frame_encoder.allocate_staged_upload_bytes(total_len);
12585        self.ensure_upload_buffer_capacity(upload_base + total_len);
12586
12587        let shape_off = uniform_len;
12588        let gradient_off = shape_off + shape_len;
12589
12590        let uniforms = Self::viewport_uniforms(viewport);
12591        let mut upload_stats = self.frame_graph_executor.upload_buffer(
12592            &self.queue,
12593            &self.upload_buffer,
12594            upload_base,
12595            bytemuck::bytes_of(&uniforms),
12596        );
12597        upload_stats.upload_bytes += self
12598            .frame_graph_executor
12599            .upload_buffer(
12600                &self.queue,
12601                &self.upload_buffer,
12602                upload_base + shape_off,
12603                bytemuck::cast_slice(&self.scratch_shape_data),
12604            )
12605            .upload_bytes;
12606        if !self.scratch_gradients.is_empty() {
12607            upload_stats.upload_bytes += self
12608                .frame_graph_executor
12609                .upload_buffer(
12610                    &self.queue,
12611                    &self.upload_buffer,
12612                    upload_base + gradient_off,
12613                    bytemuck::cast_slice(&self.scratch_gradients),
12614                )
12615                .upload_bytes;
12616        }
12617        self.frame_stats.record_command_stats(upload_stats);
12618
12619        staged_uploads.record_upload_copy(UploadTarget::Uniform, 0, 0, uniform_len);
12620        staged_uploads.record_upload_copy(UploadTarget::ShapeData, shape_off, 0, shape_len);
12621        staged_uploads.record_upload_copy(
12622            UploadTarget::ShapeGradient,
12623            gradient_off,
12624            0,
12625            gradient_len,
12626        );
12627
12628        Some((
12629            PreparedShapeBatch {
12630                vertex_start: 0,
12631                vertex_count: shape_count as u32 * 6,
12632                has_gradient: total_gradient_stops > 0,
12633            },
12634            upload_base,
12635        ))
12636    }
12637
12638    /// Whether retained replay batches can exist on this device: they bind
12639    /// unsized buffers, so they ride the storage-buffer batch mode only.
12640    /// Always `false` on wasm, which has no retained replay path — the
12641    /// method exists on both arches so the packet producer has one
12642    /// architecture.
12643    pub(crate) fn replay_supported(&self) -> bool {
12644        // Deliberately not conditioned on free slot ids: an exhausted pool
12645        // only means new captures fail (handled per capture), while flipping
12646        // this bit would retire every live feed slot.
12647        #[cfg(target_arch = "wasm32")]
12648        {
12649            false
12650        }
12651        #[cfg(not(target_arch = "wasm32"))]
12652        {
12653            self.shape_batch_limits.storage
12654        }
12655    }
12656
12657    /// Return the planner-drained ack confirmations buffer (capacity
12658    /// intact) to the store after the producer applied a frame's
12659    /// [`crate::frame_packet::ReplayAck`] — the ack channel's half of the
12660    /// P4b no-allocation contract, closed by the caller now that ack
12661    /// application lives producer-side. No-op on wasm.
12662    pub(crate) fn restore_replay_ack_confirmations(
12663        &mut self,
12664        confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
12665    ) {
12666        #[cfg(not(target_arch = "wasm32"))]
12667        {
12668            self.replay_ack_confirmations = confirmations;
12669        }
12670        #[cfg(target_arch = "wasm32")]
12671        let _ = confirmations;
12672    }
12673
12674    /// The surface format this renderer was constructed for — the present
12675    /// runtime's offscreen test target must match it.
12676    #[cfg(not(target_arch = "wasm32"))]
12677    pub(crate) fn surface_format(&self) -> wgpu::TextureFormat {
12678        self.display_format
12679    }
12680
12681    /// Test inspector for the threaded confirmations round-trip: the
12682    /// store-side ack buffer's current capacity.
12683    #[cfg(not(target_arch = "wasm32"))]
12684    pub(crate) fn replay_ack_confirmations_capacity(&self) -> usize {
12685        self.replay_ack_confirmations.capacity()
12686    }
12687
12688    /// EARLY present-side consumption of a validated packet's replay plan
12689    /// (threaded runtime only): identical store work to the render-time
12690    /// block in `render_graph_recorded`, but runnable BEFORE surface
12691    /// acquire, so the [`crate::frame_packet::ReplayAck`] can travel to the
12692    /// producer without waiting out the swapchain — a capture confirmed
12693    /// here is available to the very next frame's planning, the same
12694    /// one-frame latency the synchronous path has. Marks the packet so the
12695    /// render path does not consume the taken-out default plan, and so a
12696    /// later cancel does not reclaim it. `None` for Surface roots, which
12697    /// never touch the planner. The caller must have validated the packet
12698    /// (epochs, viewport) first: this executes against the live store.
12699    #[cfg(not(target_arch = "wasm32"))]
12700    pub(crate) fn take_replay_ack_early(
12701        &mut self,
12702        packet: &mut FramePacket,
12703    ) -> Option<(
12704        crate::frame_packet::ReplayAck,
12705        crate::frame_packet::ReplayFrameOps,
12706    )> {
12707        if packet.replay_preconsumed {
12708            return None;
12709        }
12710        let PacketRoot::Direct(root) = &packet.root else {
12711            return None;
12712        };
12713        let ops = std::mem::take(&mut packet.replay);
12714        let root_scale = packet.root_scale;
12715        let (ack, recycled) =
12716            self.consume_replay_ops(ops, &root.scene.shapes, &root.scene.brushes, root_scale);
12717        packet.replay_preconsumed = true;
12718        Some((ack, recycled))
12719    }
12720
12721    /// Present-side consumption of one frame's [`ReplayFrameOps`]: frees
12722    /// the plan's releases, then honors its capture requests against the
12723    /// scene they were recorded for, answering with a [`ReplayAck`] of
12724    /// (identity, gpu slot) confirmations plus the batch's emptied buffers
12725    /// for recycling. This is the store half of the split — it touches NO
12726    /// planner state: `feed_slots`, confirmation stamping, displaced-slot
12727    /// release, and age eviction all live in the planner
12728    /// (`take_frame_ops`/`apply_ack`).
12729    ///
12730    /// Ordering is what makes slot release safe: a slot the plan releases
12731    /// is never referenced by a retained op of the same frame (misses
12732    /// release before their op would have been pushed, and rebuild frames
12733    /// release at flush start), so freeing it here — before any encoding —
12734    /// cannot orphan a draw.
12735    #[cfg(not(target_arch = "wasm32"))]
12736    fn consume_replay_ops(
12737        &mut self,
12738        mut ops: crate::frame_packet::ReplayFrameOps,
12739        shapes: &[DrawShape],
12740        brushes: &[Brush],
12741        root_scale: f32,
12742    ) -> (
12743        crate::frame_packet::ReplayAck,
12744        crate::frame_packet::ReplayFrameOps,
12745    ) {
12746        // The batch's own staleness ordinal, echoed in the ack so the
12747        // planner purges exactly this batch's unconfirmed requests even
12748        // when another batch is already in flight behind it.
12749        let acked_frame = ops.frame;
12750        if ops.generation < self.store_feed_generation {
12751            // Fail-closed: ops planned under an OLDER slot universe name
12752            // slots this store does not hold. Drop the batch whole —
12753            // captures unconfirmed self-heal (the planner never serves
12754            // them), and stale releases must not free live ids.
12755            // Synchronously impossible today; structural for the split.
12756            self.replay_generation_drops += 1;
12757            log::warn!(
12758                "[command-feed] dropping replay ops of generation {} against store \
12759                 generation {} ({} captures, {} patches, {} releases; lifetime drops {})",
12760                ops.generation,
12761                self.store_feed_generation,
12762                ops.captures.len(),
12763                ops.color_patches.len(),
12764                ops.releases.len(),
12765                self.replay_generation_drops,
12766            );
12767            ops.captures.clear();
12768            ops.color_patches.clear();
12769            ops.releases.clear();
12770            return (
12771                crate::frame_packet::ReplayAck {
12772                    generation: self.store_feed_generation,
12773                    frame: acked_frame,
12774                    confirmations: Vec::new(),
12775                },
12776                ops,
12777            );
12778        }
12779        if ops.generation > self.store_feed_generation {
12780            // Adopt forward: a producer-side bump (scale change,
12781            // `retire_feed`) delivers its whole retirement — the releases
12782            // for every retired slot — THROUGH this very batch, so a
12783            // higher generation is the new universe arriving, not a stale
12784            // one. The store follows the producer's authority; it never
12785            // reads the producer's thread-local.
12786            self.store_feed_generation = ops.generation;
12787        }
12788        let generation = ops.generation;
12789        // Queued releases free first, so their buffers are available before
12790        // this frame's captures ask.
12791        for slot in ops.releases.drain(..) {
12792            self.release_replay_slot(slot);
12793        }
12794        // `take` leaves `Vec::new()` behind (no allocation); the render
12795        // loop restores the vec after the planner drains the ack.
12796        let mut confirmations = std::mem::take(&mut self.replay_ack_confirmations);
12797        debug_assert!(confirmations.is_empty());
12798        // One refs buffer for the whole batch: a re-partition frame carries
12799        // one capture per segment, and `shapes` outlives the loop, so each
12800        // capture's collect reuses a single allocation.
12801        let mut refs: Vec<&DrawShape> = Vec::new();
12802        for capture in ops.captures.drain(..) {
12803            if capture.frame != ops.frame {
12804                // Defensive: a capture that outlived its frame references
12805                // shape indices of a scene that never rendered; honoring it
12806                // against THIS frame's shapes would retain wrong content
12807                // under a confirmed identity. Categorically drop it. Should
12808                // never fire now that ops travel inside the frame's own
12809                // packet.
12810                log::warn!(
12811                    "[command-feed] dropping stale capture for slot {} of {:?} \
12812                     (queued frame {}, ops frame {})",
12813                    capture.key.1,
12814                    capture.key.0,
12815                    capture.frame,
12816                    ops.frame,
12817                );
12818                continue;
12819            }
12820            let end = capture.shape_start + capture.shape_count;
12821            let Some(slice) = shapes.get(capture.shape_start..end) else {
12822                continue;
12823            };
12824            refs.clear();
12825            refs.extend(slice.iter());
12826            let Some(gpu_slot) = self.capture_replay_slot(&refs, brushes, root_scale) else {
12827                continue;
12828            };
12829            confirmations.push((capture.key, gpu_slot));
12830        }
12831        // Park the frame's recolor patches for the retained prepare arms
12832        // (`stage_replay_patches`); the vec swapped out is last frame's,
12833        // already drained empty, and returns to the producer with the ack.
12834        // The defensive clear only bites when no prepare arm ran last
12835        // frame (aborted render): those patches targeted a frame that
12836        // never encoded, and their spans re-queue fresh recolors each
12837        // served frame.
12838        self.replay_color_patches.clear();
12839        std::mem::swap(&mut self.replay_color_patches, &mut ops.color_patches);
12840        (
12841            crate::frame_packet::ReplayAck {
12842                generation,
12843                frame: acked_frame,
12844                confirmations,
12845            },
12846            ops,
12847        )
12848    }
12849
12850    /// Test/diagnostic view of the store's lifetime count of replay-ops
12851    /// batches dropped whole by the generation check — the consume gate's
12852    /// proof that Surface frames (default plans, generation 0) are never
12853    /// fed to the store.
12854    #[cfg(not(target_arch = "wasm32"))]
12855    pub(crate) fn replay_generation_drops(&self) -> u64 {
12856        self.replay_generation_drops
12857    }
12858
12859    /// Test hook for the message protocol: runs one planner→store→planner
12860    /// replay cycle outside a frame, with the batch stamped
12861    /// `store_feed_generation + generation_skew`, and returns how many
12862    /// captures the store confirmed. A skew that lands BELOW the store's
12863    /// generation manufactures the fail-closed drop; a skew above it
12864    /// exercises adopt-forward. Both are synchronously impossible through
12865    /// the public render path today.
12866    #[cfg(not(target_arch = "wasm32"))]
12867    pub(crate) fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
12868        let generation = self.store_feed_generation.wrapping_add(generation_skew);
12869        let ops = crate::shape_replay::SHAPE_REPLAY
12870            .with(|state| state.borrow_mut().take_frame_ops(generation));
12871        let (ack, recycled) = self.consume_replay_ops(ops, &[], &[], 1.0);
12872        let confirmed = ack.confirmations.len();
12873        self.replay_ack_confirmations = crate::shape_replay::SHAPE_REPLAY
12874            .with(|state| state.borrow_mut().apply_ack(ack, recycled));
12875        confirmed
12876    }
12877
12878    /// Stages every queued replay recolor patch. Feed recolors are always
12879    /// solid, so every patch rewrites the shape's 16-byte record in the
12880    /// slot's paint buffer; the captured `ShapeData` itself is immutable, so
12881    /// a recolored frame uploads colors, not geometry. Runs in the retained
12882    /// prepare arms so the writes land in the same staged-upload flush that
12883    /// carries the frame's transforms; draining is idempotent across arms.
12884    #[cfg(not(target_arch = "wasm32"))]
12885    fn stage_replay_patches(&mut self, staged_uploads: &mut StagedBufferUploads) {
12886        // Capacity-retaining drain: swap the frame's parked patch buffer
12887        // (see `consume_replay_ops`) against the scratch arena instead of
12888        // `mem::take`, so both keep their high-water capacity across
12889        // frames. The scratch is cleared before every return, which
12890        // preserves drain idempotence across the retained prepare arms: a
12891        // later drain in the same frame swaps one empty-with-capacity
12892        // arena for another and stages nothing.
12893        std::mem::swap(
12894            &mut self.replay_color_patches,
12895            &mut self.color_patch_scratch,
12896        );
12897        let total_patches = self.color_patch_scratch.len();
12898        if total_patches == 0 {
12899            self.replay_upload_stats.note_frame(0, 0, 0, 0, 0);
12900            return;
12901        }
12902
12903        // Patches land in the slot's CPU mirror and upload as one contiguous
12904        // span per slot. Uploading each patch individually would record one
12905        // copy command per patch, and MEGA's twinkle field recolors ~1.7k
12906        // dots a frame — that many commands stall a mobile GPU for longer
12907        // than the spans' untouched bytes ever cost.
12908        #[derive(Clone, Copy)]
12909        struct DirtySpan {
12910            paint_min: u32,
12911            paint_max: u32,
12912        }
12913        const CLEAN: DirtySpan = DirtySpan {
12914            paint_min: u32::MAX,
12915            paint_max: 0,
12916        };
12917        let mut dirty: std::collections::HashMap<
12918            u32,
12919            DirtySpan,
12920            cranpose_ui_graphics::FxBuildHasher,
12921        > = std::collections::HashMap::default();
12922
12923        // One bare 16-byte write into the slot's paint mirror per patch.
12924        for patch in &self.color_patch_scratch {
12925            let Some(slot) = self.replay_slots.slots.get_mut(&patch.slot) else {
12926                continue;
12927            };
12928            let Some(paint) = slot.paint_mirror.get_mut(patch.shape_index as usize) else {
12929                continue;
12930            };
12931            *paint = patch.color;
12932            let span = dirty.entry(patch.slot).or_insert(CLEAN);
12933            span.paint_min = span.paint_min.min(patch.shape_index);
12934            span.paint_max = span.paint_max.max(patch.shape_index);
12935        }
12936
12937        let mut uploaded_records = 0u64;
12938        let mut uploaded_bytes = 0u64;
12939        let slots_touched = dirty.len() as u64;
12940        for (slot_id, span) in dirty {
12941            let Some(slot) = self.replay_slots.slots.get(&slot_id) else {
12942                continue;
12943            };
12944            if span.paint_min <= span.paint_max {
12945                let range = span.paint_min as usize..span.paint_max as usize + 1;
12946                uploaded_records += range.len() as u64;
12947                uploaded_bytes += (range.len() * std::mem::size_of::<[f32; 4]>()) as u64;
12948                staged_uploads.stage_at(
12949                    UploadTarget::ReplayPaintData(slot_id),
12950                    range.start as u64 * std::mem::size_of::<[f32; 4]>() as u64,
12951                    bytemuck::cast_slice(&slot.paint_mirror[range]),
12952                );
12953            }
12954        }
12955        // A patched color is one 16-byte vec4; the staged bytes exceed this
12956        // only by the untouched records inside each coalesced span.
12957        let ideal_bytes = total_patches as u64 * 16;
12958        self.replay_upload_stats.note_frame(
12959            total_patches as u64,
12960            slots_touched,
12961            uploaded_records,
12962            uploaded_bytes,
12963            ideal_bytes,
12964        );
12965        if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
12966            log::warn!(
12967                "[replay-upload] frame: {} patches -> {} records / {:.1} KB staged \
12968                 across {} slots (color-only {:.1} KB)",
12969                total_patches,
12970                uploaded_records,
12971                uploaded_bytes as f64 / 1024.0,
12972                slots_touched,
12973                ideal_bytes as f64 / 1024.0,
12974            );
12975        }
12976        self.color_patch_scratch.clear();
12977    }
12978
12979    /// Converts `shape_refs` once and retains the result on the GPU as a
12980    /// replay slot. Returns the slot id the scene's retained draws reference.
12981    #[cfg(not(target_arch = "wasm32"))]
12982    pub(crate) fn capture_replay_slot(
12983        &mut self,
12984        shape_refs: &[&DrawShape],
12985        brushes: &[Brush],
12986        root_scale: f32,
12987    ) -> Option<u32> {
12988        if !self.shape_batch_limits.storage || shape_refs.is_empty() {
12989            return None;
12990        }
12991        let id = self.replay_slots.free_ids.pop()?;
12992        let shape_count = shape_refs.len();
12993
12994        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
12995        let mut total_gradient_stops = 0u32;
12996        gradient_offsets.push(0);
12997        for shape in shape_refs {
12998            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
12999            gradient_offsets.push(total_gradient_stops);
13000        }
13001
13002        // Staging scratch, not fresh vectors: cleared and re-zeroed to this
13003        // capture's exact sizes, capacity kept across captures.
13004        let mut shape_data = std::mem::take(&mut self.replay_capture_shape_scratch);
13005        shape_data.clear();
13006        shape_data.resize(shape_count, ShapeData::zeroed());
13007        let mut gradients = std::mem::take(&mut self.replay_capture_gradient_scratch);
13008        gradients.clear();
13009        gradients.resize(
13010            (total_gradient_stops as usize).max(1),
13011            GradientStop::zeroed(),
13012        );
13013        convert_shapes_into_outputs(
13014            shape_refs,
13015            brushes,
13016            &gradient_offsets,
13017            root_scale,
13018            &mut shape_data,
13019            &mut gradients,
13020        );
13021
13022        let shape_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
13023            label: Some("Replay Shape Buffer"),
13024            size: (std::mem::size_of::<ShapeData>() * shape_count) as u64,
13025            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
13026            mapped_at_creation: true,
13027        });
13028        shape_buffer
13029            .slice(..)
13030            .get_mapped_range_mut()
13031            .copy_from_slice(bytemuck::cast_slice(&shape_data));
13032        shape_buffer.unmap();
13033
13034        let gradient_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
13035            label: Some("Replay Gradient Buffer"),
13036            size: (std::mem::size_of::<GradientStop>() * gradients.len()) as u64,
13037            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
13038            mapped_at_creation: true,
13039        });
13040        gradient_buffer
13041            .slice(..)
13042            .get_mapped_range_mut()
13043            .copy_from_slice(bytemuck::cast_slice(&gradients));
13044        gradient_buffer.unmap();
13045
13046        // Filled by the mesh arm when the capture keeps its arc mesh, so
13047        // the fill-diag records can price those shapes by their true
13048        // triangle area.
13049        let mut mesh_fill_records: Option<Vec<FillDiagShapeRecord>> = None;
13050        let mut submitted_area_scale = 1.0f32;
13051        let mesh = if arc_mesh_enabled() {
13052            match build_arc_mesh_vertices(&shape_data, retained_mesh_min_px2()) {
13053                Some(build) => {
13054                    let meshed_shapes = build.meshed_arcs + build.meshed_rims;
13055                    // A pathological meshed/instanced interleave would spend
13056                    // more on pipeline switches than the bands recover; the
13057                    // whole slot stays instanced instead (content-conditional
13058                    // — a property of this capture's shape order).
13059                    let within_stretch_cap = build.meshed_stretches <= MESH_SLOT_MAX_STRETCHES;
13060                    let cut = if build.quad_area > 0.0 {
13061                        (1.0 - build.mesh_area / build.quad_area) * 100.0
13062                    } else {
13063                        0.0
13064                    };
13065                    // Always-on warn: `log::info` is invisible on the desktop
13066                    // console, and captures are rare — one line per slot
13067                    // lifetime. The unique-vert/index counts are the
13068                    // vertex-amplification instrument P1b exists for; the
13069                    // meshed/instanced split and the stretch count are the
13070                    // size gate's own engagement instrument.
13071                    log::warn!(
13072                        "[arc-mesh] slot {id}: {} arcs + {} rims meshed ({} segs, \
13073                         {} stretches), {} instanced; {} unique verts / {} indices; \
13074                         quad_px {:.0} -> submit_px {:.0} (-{:.1}%)",
13075                        build.meshed_arcs,
13076                        build.meshed_rims,
13077                        build.meshed_segments,
13078                        build.meshed_stretches,
13079                        build.passthrough,
13080                        build.vertices.len(),
13081                        build.indices.len(),
13082                        build.quad_area,
13083                        build.mesh_area,
13084                        cut,
13085                    );
13086                    if !within_stretch_cap {
13087                        log::warn!(
13088                            "[arc-mesh] slot {id}: {} meshed stretches exceed the \
13089                             {MESH_SLOT_MAX_STRETCHES}-stretch switch cap; slot stays instanced",
13090                            build.meshed_stretches,
13091                        );
13092                    }
13093                    let keep_mesh = meshed_shapes > 0 && within_stretch_cap;
13094                    if keep_mesh && build.quad_area > 0.0 {
13095                        // What this slot's replay actually rasterizes per
13096                        // quad pixel, for the segment-surface economics
13097                        // gate. Clamped away from zero so a degenerate
13098                        // measurement cannot make the direct path look
13099                        // free.
13100                        submitted_area_scale =
13101                            (build.mesh_area / build.quad_area).clamp(0.05, 1.0) as f32;
13102                    }
13103                    if keep_mesh && fill_area_diag_enabled() {
13104                        mesh_fill_records = Some(fill_diag_capture_records(
13105                            &shape_data,
13106                            Some((&build.vertices, &build.indices, &build.index_prefix)),
13107                        ));
13108                    }
13109                    // A slot that meshed nothing gains nothing over the
13110                    // instanced path — skip the buffers.
13111                    keep_mesh.then(|| {
13112                        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
13113                            label: Some("Replay Mesh Vertex Buffer"),
13114                            size: (std::mem::size_of::<MeshVertex>() * build.vertices.len()) as u64,
13115                            usage: wgpu::BufferUsages::VERTEX,
13116                            mapped_at_creation: true,
13117                        });
13118                        vertex_buffer
13119                            .slice(..)
13120                            .get_mapped_range_mut()
13121                            .copy_from_slice(bytemuck::cast_slice(&build.vertices));
13122                        vertex_buffer.unmap();
13123                        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
13124                            label: Some("Replay Mesh Index Buffer"),
13125                            size: (std::mem::size_of::<u32>() * build.indices.len()) as u64,
13126                            usage: wgpu::BufferUsages::INDEX,
13127                            mapped_at_creation: true,
13128                        });
13129                        index_buffer
13130                            .slice(..)
13131                            .get_mapped_range_mut()
13132                            .copy_from_slice(bytemuck::cast_slice(&build.indices));
13133                        index_buffer.unmap();
13134                        ReplaySlotMesh {
13135                            vertex_buffer,
13136                            index_buffer,
13137                            index_prefix: build.index_prefix,
13138                            meshed_arcs: build.meshed_arcs,
13139                            meshed_rims: build.meshed_rims,
13140                            passthrough: build.passthrough,
13141                        }
13142                    })
13143                }
13144                None => {
13145                    log::warn!(
13146                        "[arc-mesh] slot {id}: geometry byte budget overflowed for \
13147                         {shape_count} shapes; whole slot stays instanced"
13148                    );
13149                    None
13150                }
13151            }
13152        } else {
13153            None
13154        };
13155
13156        let fill_diag_shapes = if fill_area_diag_enabled() {
13157            let records =
13158                mesh_fill_records.unwrap_or_else(|| fill_diag_capture_records(&shape_data, None));
13159            // Feed the once-per-process top-slack dump before the records
13160            // move into the slot.
13161            self.fill_area_diag.note_retained_capture(id, &records);
13162            records
13163        } else {
13164            Vec::new()
13165        };
13166
13167        // Capture-space quad AABBs and the quad-area prefix sum for the
13168        // segment-surface cache: the quads are the exact geometry the
13169        // replay rasterizes, so a range's surface economics and capture
13170        // rect derive from them with no second conversion.
13171        let mut shape_aabbs = Vec::with_capacity(shape_count);
13172        let mut area_prefix = Vec::with_capacity(shape_count + 1);
13173        area_prefix.push(0.0f32);
13174        for shape in &shape_data {
13175            let corners = [
13176                [shape.quad01[0], shape.quad01[1]],
13177                [shape.quad01[2], shape.quad01[3]],
13178                [shape.quad23[0], shape.quad23[1]],
13179                [shape.quad23[2], shape.quad23[3]],
13180            ];
13181            let mut min_x = f32::INFINITY;
13182            let mut min_y = f32::INFINITY;
13183            let mut max_x = f32::NEG_INFINITY;
13184            let mut max_y = f32::NEG_INFINITY;
13185            for corner in corners {
13186                min_x = min_x.min(corner[0]);
13187                min_y = min_y.min(corner[1]);
13188                max_x = max_x.max(corner[0]);
13189                max_y = max_y.max(corner[1]);
13190            }
13191            shape_aabbs.push([min_x, min_y, max_x, max_y]);
13192            // Shoelace over the quad's boundary order (corners 0, 1, 3, 2 —
13193            // the two triangles share the 1-2 diagonal).
13194            let ring = [corners[0], corners[1], corners[3], corners[2]];
13195            let mut doubled = 0.0f32;
13196            for i in 0..4 {
13197                let a = ring[i];
13198                let b = ring[(i + 1) % 4];
13199                doubled += a[0] * b[1] - b[0] * a[1];
13200            }
13201            let area = (doubled * 0.5).abs();
13202            let running = *area_prefix.last().expect("prefix seeded with 0.0");
13203            area_prefix.push(running + area);
13204        }
13205
13206        // Seed the mutable paint from the converted colors, so an unpatched
13207        // replay renders bit-identically to the capture frame.
13208        let paint: Vec<[f32; 4]> = shape_data.iter().map(|shape| shape.color).collect();
13209        let paint_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
13210            label: Some("Replay Paint Buffer"),
13211            size: (std::mem::size_of::<[f32; 4]>() * shape_count) as u64,
13212            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
13213            mapped_at_creation: true,
13214        });
13215        paint_buffer
13216            .slice(..)
13217            .get_mapped_range_mut()
13218            .copy_from_slice(bytemuck::cast_slice(&paint));
13219        paint_buffer.unmap();
13220
13221        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
13222            label: Some("Replay Shape Bind Group"),
13223            layout: &self.shape_bind_group_layout,
13224            entries: &[
13225                wgpu::BindGroupEntry {
13226                    binding: 0,
13227                    resource: shape_buffer.as_entire_binding(),
13228                },
13229                wgpu::BindGroupEntry {
13230                    binding: 1,
13231                    resource: gradient_buffer.as_entire_binding(),
13232                },
13233                // The transform slot is selected per draw via the dynamic
13234                // offset, so retained draws sharing this capture can each
13235                // move independently.
13236                wgpu::BindGroupEntry {
13237                    binding: 2,
13238                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
13239                        buffer: &self.replay_slots.transform_buffer,
13240                        offset: 0,
13241                        size: Some(
13242                            std::num::NonZeroU64::new(
13243                                std::mem::size_of::<SimilarityTransform>() as u64
13244                            )
13245                            .expect("similarity transform is non-empty"),
13246                        ),
13247                    }),
13248                },
13249                wgpu::BindGroupEntry {
13250                    binding: 3,
13251                    resource: paint_buffer.as_entire_binding(),
13252                },
13253            ],
13254        });
13255
13256        let capture_epoch = self.replay_slots.next_capture_epoch;
13257        self.replay_slots.next_capture_epoch += 1;
13258        self.replay_slots.slots.insert(
13259            id,
13260            ReplaySlot {
13261                paint_buffer,
13262                bind_group,
13263                shape_count: shape_count as u32,
13264                paint_mirror: paint,
13265                mesh,
13266                capture_epoch,
13267                has_gradient: total_gradient_stops > 0,
13268                fill_diag_shapes,
13269                shape_aabbs,
13270                area_prefix,
13271                submitted_area_scale,
13272            },
13273        );
13274        // The staging buffers return to their scratch slots, contents
13275        // spent, capacity kept for the next capture.
13276        self.replay_capture_shape_scratch = shape_data;
13277        self.replay_capture_gradient_scratch = gradients;
13278        Some(id)
13279    }
13280
13281    /// Frees a replay slot's GPU resources and returns its id to the pool.
13282    #[cfg(not(target_arch = "wasm32"))]
13283    pub(crate) fn release_replay_slot(&mut self, id: u32) {
13284        if self.replay_slots.slots.remove(&id).is_some() {
13285            self.replay_slots.free_ids.push(id);
13286            // A cached bundle keeps references on the slot buffers it binds.
13287            // The epoch in each key already makes entries for this capture
13288            // unreachable — releases are rare (churn, retire_feed), so drop
13289            // the whole cache and free those references now rather than one
13290            // frame later through eviction.
13291            self.retained_bundle_cache.clear();
13292            // Segment death: every surface captured from this slot dies
13293            // with it.
13294            self.segment_surfaces.drop_slot(id);
13295        }
13296    }
13297
13298    /// Test/diagnostic view of the retained-segment surface cache:
13299    /// lifetime (captures, composite draws, dirty recaptures, churn
13300    /// rejections, economics rejections).
13301    #[cfg(not(target_arch = "wasm32"))]
13302    #[doc(hidden)]
13303    pub fn segment_surface_stats(&self) -> (u64, u64, u64, u64, u64) {
13304        let stats = &self.segment_surfaces.stats;
13305        (
13306            stats.captures,
13307            stats.composites,
13308            stats.dirty_recaptures,
13309            stats.rejected_churn,
13310            stats.rejected_economics,
13311        )
13312    }
13313
13314    /// Test/diagnostic view of the latched instanced-quad selection: `true`
13315    /// when this renderer's ordinary shape draws ride `vs_shape_instanced`.
13316    #[cfg(not(target_arch = "wasm32"))]
13317    #[doc(hidden)]
13318    pub fn instanced_quads_active(&self) -> bool {
13319        self.instanced_quads.is_some()
13320    }
13321
13322    /// Test/diagnostic view of retained arc meshes: how many live replay
13323    /// slots hold a mesh, out of all live slots.
13324    #[cfg(not(target_arch = "wasm32"))]
13325    #[doc(hidden)]
13326    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
13327        let meshed = self
13328            .replay_slots
13329            .slots
13330            .values()
13331            .filter(|slot| slot.mesh.is_some())
13332            .count();
13333        (meshed, self.replay_slots.slots.len())
13334    }
13335
13336    /// Test/diagnostic view of the capture size gate, summed over live slots
13337    /// that hold a mesh: (shapes meshed as arc bands, shapes meshed as
13338    /// stroked-circle rim bands, shapes on the passthrough quad).
13339    #[cfg(not(target_arch = "wasm32"))]
13340    #[doc(hidden)]
13341    pub fn replay_slot_mesh_engagement(&self) -> (usize, usize, usize) {
13342        self.replay_slots
13343            .slots
13344            .values()
13345            .filter_map(|slot| slot.mesh.as_ref())
13346            .fold((0, 0, 0), |(arcs, rims, passthrough), mesh| {
13347                (
13348                    arcs + mesh.meshed_arcs,
13349                    rims + mesh.meshed_rims,
13350                    passthrough + mesh.passthrough,
13351                )
13352            })
13353    }
13354
13355    /// Segment-surface phase 1 for one fused partition: walks the chunk's
13356    /// retained items, runs [`SegmentSurfaceCache::decide`] per item, and
13357    /// for each (re)capture acquires the surface, installs the entry and
13358    /// stages the capture similarity at its reserved transform slot. Emits
13359    /// the frame's capture jobs and per-item composite plans.
13360    #[cfg(not(target_arch = "wasm32"))]
13361    #[allow(clippy::too_many_arguments)]
13362    fn plan_segment_surfaces(
13363        &mut self,
13364        segment_surfaces: &mut SegmentSurfaceCache,
13365        ordered_items: &[(usize, SegmentDrawItem)],
13366        chunk: &SegmentDrawChunkPlan,
13367        retained_draws: &[RetainedDraw],
13368        staged_uploads: &mut StagedBufferUploads,
13369        captures: &mut Vec<SegmentCaptureJob>,
13370        composites: &mut Vec<(usize, SegmentCompositePlan)>,
13371    ) {
13372        segment_surfaces.ensure_dirty_map(
13373            self.replay_color_patches
13374                .iter()
13375                .map(|patch| (patch.slot, patch.shape_index)),
13376        );
13377        let max_texture_dim = self.effect_renderer.max_texture_dim();
13378        for batch in chunk.iter() {
13379            let SegmentBatchPlan::Retained { start, end } = batch else {
13380                continue;
13381            };
13382            for (_, item) in &ordered_items[start..end] {
13383                let SegmentDrawItem::Retained(index) = item else {
13384                    continue;
13385                };
13386                // Items past the transform-slot budget stage no transform
13387                // and draw nothing on the direct path either; leave them.
13388                if (*index as u32) >= MAX_REPLAY_SLOTS {
13389                    continue;
13390                }
13391                let Some(retained) = retained_draws.get(*index) else {
13392                    continue;
13393                };
13394                let transform = retained.transform;
13395                let (key, capture_epoch, dirty) = {
13396                    let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
13397                        continue;
13398                    };
13399                    let first = retained.first_shape.min(slot.shape_count);
13400                    let last = retained
13401                        .first_shape
13402                        .saturating_add(retained.shape_count)
13403                        .min(slot.shape_count);
13404                    if first >= last {
13405                        continue;
13406                    }
13407                    (
13408                        SegmentSurfaceKey {
13409                            slot: retained.slot,
13410                            first_shape: first,
13411                            shape_count: last - first,
13412                        },
13413                        slot.capture_epoch,
13414                        segment_surfaces.range_dirty(retained.slot, first, last),
13415                    )
13416                };
13417                let first = key.first_shape;
13418                let last = key.first_shape + key.shape_count;
13419                let slots = &self.replay_slots.slots;
13420                let decision =
13421                    segment_surfaces.decide(key, capture_epoch, dirty, transform.scale, || {
13422                        let slot = slots.get(&key.slot)?;
13423                        plan_segment_capture_geometry(slot, first, last, transform, max_texture_dim)
13424                    });
13425                let SegmentSurfaceDecision::Composite { capture } = decision else {
13426                    continue;
13427                };
13428                if let Some(plan) = capture {
13429                    let texture = segment_surfaces
13430                        .take_texture_for_recapture(&key, &plan.rect)
13431                        .unwrap_or_else(|| {
13432                            self.acquire_segment_surface(plan.rect.width, plan.rect.height)
13433                        });
13434                    segment_surfaces.install_entry(
13435                        key,
13436                        capture_epoch,
13437                        transform.center,
13438                        transform.rot,
13439                        transform.scale,
13440                        plan.rect,
13441                        texture,
13442                    );
13443                    // The capture renders the span under ITS OWN current
13444                    // similarity (retained paint selected), staged at the
13445                    // reserved slot past every per-draw transform.
13446                    staged_uploads.stage_at(
13447                        UploadTarget::ReplayTransform,
13448                        (MAX_REPLAY_SLOTS + plan.index) as u64 * REPLAY_TRANSFORM_STRIDE,
13449                        bytemuck::bytes_of(&transform.with_retained_paint()),
13450                    );
13451                    // The capture viewport uniforms are written directly:
13452                    // the cache is moved out of `self` for the partition,
13453                    // so its buffer cannot ride the staged-upload flush
13454                    // (which resolves targets on `self`). Queue writes
13455                    // execute before any later-submitted command buffer —
13456                    // exactly the capture pass's ordering need.
13457                    let uniforms = Self::viewport_uniforms(ViewportUniformParams {
13458                        width: plan.rect.width,
13459                        height: plan.rect.height,
13460                        offset: plan.rect.origin,
13461                    });
13462                    let device = self.device.clone();
13463                    let capture_uniforms =
13464                        segment_surfaces.capture_uniforms(&device, &self.uniform_bind_group_layout);
13465                    let upload_stats = self.frame_graph_executor.upload_buffer(
13466                        &self.queue,
13467                        &capture_uniforms.buffer,
13468                        plan.index as u64 * SEGMENT_CAPTURE_UNIFORM_STRIDE,
13469                        bytemuck::bytes_of(&uniforms),
13470                    );
13471                    self.frame_stats.record_command_stats(upload_stats);
13472                    captures.push(SegmentCaptureJob {
13473                        key,
13474                        first,
13475                        last,
13476                        capture_index: plan.index,
13477                    });
13478                    // Fresh capture: the effective transform is identity by
13479                    // construction, snapped exact so the composite is a 1:1
13480                    // texel mapping.
13481                    composites.push((
13482                        *index,
13483                        SegmentCompositePlan {
13484                            key,
13485                            dest_quad: segment_identity_quad(&plan.rect),
13486                            inverse: segment_identity_inverse(&plan.rect),
13487                            identity: true,
13488                            integer_translation: true,
13489                        },
13490                    ));
13491                } else {
13492                    let Some(entry) = segment_surfaces.entry(&key) else {
13493                        continue;
13494                    };
13495                    let t_now =
13496                        Affine2::from_similarity(transform.center, transform.rot, transform.scale);
13497                    let t_cap =
13498                        Affine2::from_similarity(entry.cap_center, entry.cap_rot, entry.cap_scale);
13499                    let Some(cap_inverse) = t_cap.invert() else {
13500                        segment_surfaces.remove(&key);
13501                        continue;
13502                    };
13503                    let effective = t_now.compose(&cap_inverse);
13504                    let rect = entry.rect;
13505                    let plan = if effective.is_identity_for_sampling() {
13506                        // Snap away the compose/invert float noise so the
13507                        // identity frame is a texel-exact mapping.
13508                        SegmentCompositePlan {
13509                            key,
13510                            dest_quad: segment_identity_quad(&rect),
13511                            inverse: segment_identity_inverse(&rect),
13512                            identity: true,
13513                            integer_translation: true,
13514                        }
13515                    } else {
13516                        let Some(inverse) = effective.invert() else {
13517                            segment_surfaces.remove(&key);
13518                            continue;
13519                        };
13520                        SegmentCompositePlan {
13521                            key,
13522                            dest_quad: segment_identity_quad(&rect).map(|c| effective.apply(c)),
13523                            inverse: [
13524                                [
13525                                    inverse.l[0][0],
13526                                    inverse.l[0][1],
13527                                    inverse.t[0] - rect.origin[0],
13528                                ],
13529                                [
13530                                    inverse.l[1][0],
13531                                    inverse.l[1][1],
13532                                    inverse.t[1] - rect.origin[1],
13533                                ],
13534                                [0.0, 0.0, 1.0],
13535                            ],
13536                            identity: false,
13537                            integer_translation: effective.is_integer_translation_for_sampling(),
13538                        }
13539                    };
13540                    composites.push((*index, plan));
13541                }
13542            }
13543        }
13544    }
13545
13546    /// Draws one retained replay batch — `retained`'s shape range of its
13547    /// slot's capture, under the transform staged for this draw's index (see
13548    /// the retained arms of the segment paths).
13549    #[cfg(not(target_arch = "wasm32"))]
13550    fn draw_retained_batch(
13551        &self,
13552        render_pass: &mut wgpu::RenderPass<'_>,
13553        retained: &RetainedDraw,
13554        retained_index: usize,
13555        width: u32,
13556        height: u32,
13557    ) {
13558        let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
13559            return;
13560        };
13561        if retained_index as u32 >= MAX_REPLAY_SLOTS {
13562            return;
13563        }
13564        let first = retained.first_shape.min(slot.shape_count);
13565        let last = retained
13566            .first_shape
13567            .saturating_add(retained.shape_count)
13568            .min(slot.shape_count);
13569        if first >= last {
13570            return;
13571        }
13572        if fill_area_diag_enabled() {
13573            self.fill_area_diag.add_retained_range(
13574                &slot.fill_diag_shapes,
13575                first,
13576                last,
13577                &retained.transform,
13578            );
13579        }
13580        self.frame_stats.bump_shapes();
13581        render_pass.set_scissor_rect(0, 0, width, height);
13582        let draws =
13583            self.encode_retained_op(
13584                slot,
13585                first,
13586                last,
13587                retained_index as u32,
13588                &mut |cmd| match cmd {
13589                    RetainedCmd::Pipeline(pipeline) => {
13590                        render_pass.set_pipeline(self.retained_pipeline(pipeline))
13591                    }
13592                    RetainedCmd::Uniforms(group) => render_pass.set_bind_group(0, group, &[]),
13593                    RetainedCmd::SlotBindings(group, offset) => {
13594                        render_pass.set_bind_group(1, group, &[offset])
13595                    }
13596                    RetainedCmd::MeshVertices(buffer) => {
13597                        render_pass.set_vertex_buffer(0, buffer.slice(..))
13598                    }
13599                    RetainedCmd::Index(buffer, format) => {
13600                        render_pass.set_index_buffer(buffer.slice(..), format)
13601                    }
13602                    RetainedCmd::Draw(vertices) => render_pass.draw(vertices, 0..1),
13603                    RetainedCmd::DrawIndexed(indices, instances) => {
13604                        render_pass.draw_indexed(indices, 0, instances)
13605                    }
13606                },
13607            );
13608        self.frame_stats.add_draw_calls(draws);
13609    }
13610
13611    /// Emits one retained op's draw commands into `sink` — the SINGLE
13612    /// encoding shared by the direct pass path
13613    /// ([`Self::draw_retained_batch`]) and the cached-bundle path
13614    /// ([`Self::build_retained_bundle`]), so the two cannot drift. Returns
13615    /// the number of draw calls issued.
13616    ///
13617    /// A slot without a mesh draws its whole range through the latched
13618    /// instanced-quad pipeline (four vertex executions per shape, shape
13619    /// index from the instance index), else the plain six-vertex expansion.
13620    /// A slot WITH a mesh alternates along the range: maximal runs of
13621    /// meshed shapes (non-empty [`ReplaySlotMesh::index_prefix`] ranges)
13622    /// draw their band triangles through the mesh pipeline in one
13623    /// `draw_indexed` each, and every other run STAYS instanced — routing
13624    /// passthrough quads through per-vertex mesh attributes instead was the
13625    /// S3 loss the watch measured (see [`arc_mesh_enabled`]). The walk
13626    /// preserves exact shape order, so z is untouched, and every pipeline
13627    /// involved blends SrcOver. Bind groups are set once up front: all the
13628    /// pipelines share the uniform + shape bind-group layouts, so they stay
13629    /// bound across pipeline switches; the mesh vertex buffer likewise
13630    /// stays bound across instanced stretches because the instanced
13631    /// pipeline declares no vertex buffers — only the index buffer
13632    /// alternates. The alternation is a pure function of the capture-fixed
13633    /// `index_prefix` and `first..last`, which is what lets
13634    /// [`RetainedBundleOpKey`] pin the encoding by capture epoch and range
13635    /// alone.
13636    #[cfg(not(target_arch = "wasm32"))]
13637    fn encode_retained_op<'r>(
13638        &'r self,
13639        slot: &'r ReplaySlot,
13640        first: u32,
13641        last: u32,
13642        retained_index: u32,
13643        sink: &mut impl FnMut(RetainedCmd<'r>),
13644    ) -> u32 {
13645        sink(RetainedCmd::Uniforms(&self.uniform_bind_group));
13646        sink(RetainedCmd::SlotBindings(
13647            &slot.bind_group,
13648            retained_index * REPLAY_TRANSFORM_STRIDE as u32,
13649        ));
13650        let Some(mesh) = slot.mesh.as_ref() else {
13651            self.encode_retained_instanced(slot, first..last, sink);
13652            return 1;
13653        };
13654        sink(RetainedCmd::MeshVertices(&mesh.vertex_buffer));
13655        let prefix = &mesh.index_prefix;
13656        let meshed_at = |shape: u32| prefix[shape as usize + 1] > prefix[shape as usize];
13657        let mut draws = 0;
13658        let mut cursor = first;
13659        while cursor < last {
13660            let run_meshed = meshed_at(cursor);
13661            let mut end = cursor + 1;
13662            while end < last && meshed_at(end) == run_meshed {
13663                end += 1;
13664            }
13665            if run_meshed {
13666                sink(RetainedCmd::Pipeline(RetainedPipelineKind::Mesh));
13667                sink(RetainedCmd::Index(
13668                    &mesh.index_buffer,
13669                    wgpu::IndexFormat::Uint32,
13670                ));
13671                sink(RetainedCmd::DrawIndexed(
13672                    prefix[cursor as usize]..prefix[end as usize],
13673                    0..1,
13674                ));
13675            } else {
13676                self.encode_retained_instanced(slot, cursor..end, sink);
13677            }
13678            draws += 1;
13679            cursor = end;
13680        }
13681        draws
13682    }
13683
13684    /// One instanced-quad (or, unlatched, six-vertex expansion) draw over a
13685    /// contiguous shape range of a retained slot — the passthrough arm of
13686    /// [`Self::encode_retained_op`]. The solid-vs-gradient pipeline choice
13687    /// is fixed per capture, so a cached bundle can never encode a stale
13688    /// pipeline for a slot id (the op key carries the capture epoch).
13689    #[cfg(not(target_arch = "wasm32"))]
13690    fn encode_retained_instanced<'r>(
13691        &'r self,
13692        slot: &ReplaySlot,
13693        range: Range<u32>,
13694        sink: &mut impl FnMut(RetainedCmd<'r>),
13695    ) {
13696        match &self.instanced_quads {
13697            Some(instanced) => {
13698                if slot.has_gradient {
13699                    sink(RetainedCmd::Pipeline(RetainedPipelineKind::Instanced));
13700                } else {
13701                    sink(RetainedCmd::Pipeline(RetainedPipelineKind::InstancedSolid));
13702                }
13703                sink(RetainedCmd::Index(
13704                    &instanced.index_buffer,
13705                    wgpu::IndexFormat::Uint16,
13706                ));
13707                sink(RetainedCmd::DrawIndexed(0..6, range));
13708            }
13709            None => {
13710                if slot.has_gradient {
13711                    sink(RetainedCmd::Pipeline(RetainedPipelineKind::Expanded));
13712                } else {
13713                    sink(RetainedCmd::Pipeline(RetainedPipelineKind::ExpandedSolid));
13714                }
13715                sink(RetainedCmd::Draw(range.start * 6..range.end * 6));
13716            }
13717        }
13718    }
13719
13720    /// Key of the retained stretch at `item_range`: one op key per resolved
13721    /// retained item, in draw order, carrying exactly the state that decides
13722    /// the commands [`Self::draw_retained_batch`] would encode for it —
13723    /// clamped range, dynamic-offset index, whether the mesh-vs-instanced
13724    /// draw walk runs, and the slot's capture epoch, which pins the walk's
13725    /// stretch structure (`None` while the slot is absent, when the op
13726    /// draws nothing on the direct path too).
13727    #[cfg(not(target_arch = "wasm32"))]
13728    fn retained_bundle_key(
13729        &self,
13730        ordered_items: &[(usize, SegmentDrawItem)],
13731        retained_draws: &[RetainedDraw],
13732        item_range: Range<usize>,
13733    ) -> RetainedBundleKey {
13734        let mut ops = Vec::with_capacity(item_range.len());
13735        for (_, item) in &ordered_items[item_range] {
13736            let SegmentDrawItem::Retained(index) = item else {
13737                continue;
13738            };
13739            let Some(retained) = retained_draws.get(*index) else {
13740                continue;
13741            };
13742            let slot = self.replay_slots.slots.get(&retained.slot);
13743            let (first, last) = match slot {
13744                Some(slot) => (
13745                    retained.first_shape.min(slot.shape_count),
13746                    retained
13747                        .first_shape
13748                        .saturating_add(retained.shape_count)
13749                        .min(slot.shape_count),
13750                ),
13751                None => (
13752                    retained.first_shape,
13753                    retained.first_shape.saturating_add(retained.shape_count),
13754                ),
13755            };
13756            ops.push(RetainedBundleOpKey {
13757                slot: retained.slot,
13758                capture_epoch: slot.map(|slot| slot.capture_epoch),
13759                first,
13760                last,
13761                retained_index: *index as u32,
13762                has_mesh: slot.is_some_and(|slot| slot.mesh.is_some())
13763                    && self.shape_batch_limits.storage,
13764            });
13765        }
13766        RetainedBundleKey {
13767            depth: self.pass_depth(),
13768            ops,
13769        }
13770    }
13771
13772    /// Encodes `key`'s stretch into a render bundle: the IDENTICAL command
13773    /// sequence [`Self::draw_retained_batch`] issues on the pass, minus the
13774    /// scissor reset (bundles cannot set scissor; the caller sets the same
13775    /// full-target scissor on the pass before executing). Must only be
13776    /// called with a key built this frame, so every op with an epoch still
13777    /// resolves to its slot.
13778    #[cfg(not(target_arch = "wasm32"))]
13779    fn build_retained_bundle(&self, key: &RetainedBundleKey) -> wgpu::RenderBundle {
13780        let mut encoder =
13781            self.device
13782                .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor {
13783                    label: Some("Retained Stretch Bundle"),
13784                    color_formats: &[Some(self.composition_format)],
13785                    // A display-clip culled pass carries the depth
13786                    // attachment; the bundle only reads it (content
13787                    // pipelines test `Less`, write off), hence read-only on
13788                    // both aspects.
13789                    depth_stencil: key.depth.then_some(wgpu::RenderBundleDepthStencil {
13790                        format: display_clip::DISPLAY_CLIP_DEPTH_FORMAT,
13791                        depth_read_only: true,
13792                        stencil_read_only: true,
13793                    }),
13794                    sample_count: 1,
13795                    multiview: None,
13796                });
13797        for op in &key.ops {
13798            if op.capture_epoch.is_none()
13799                || op.retained_index >= MAX_REPLAY_SLOTS
13800                || op.first >= op.last
13801            {
13802                continue;
13803            }
13804            let Some(slot) = self.replay_slots.slots.get(&op.slot) else {
13805                continue;
13806            };
13807            // The latched instanced selection is a per-renderer constant,
13808            // so it needs no place in `RetainedBundleOpKey` — every cached
13809            // bundle in this renderer's lifetime encodes the same choice
13810            // the direct path makes.
13811            self.encode_retained_op(
13812                slot,
13813                op.first,
13814                op.last,
13815                op.retained_index,
13816                &mut |cmd| match cmd {
13817                    RetainedCmd::Pipeline(pipeline) => {
13818                        encoder.set_pipeline(self.retained_pipeline(pipeline))
13819                    }
13820                    RetainedCmd::Uniforms(group) => encoder.set_bind_group(0, group, &[]),
13821                    RetainedCmd::SlotBindings(group, offset) => {
13822                        encoder.set_bind_group(1, group, &[offset])
13823                    }
13824                    RetainedCmd::MeshVertices(buffer) => {
13825                        encoder.set_vertex_buffer(0, buffer.slice(..))
13826                    }
13827                    RetainedCmd::Index(buffer, format) => {
13828                        encoder.set_index_buffer(buffer.slice(..), format)
13829                    }
13830                    RetainedCmd::Draw(vertices) => encoder.draw(vertices, 0..1),
13831                    RetainedCmd::DrawIndexed(indices, instances) => {
13832                        encoder.draw_indexed(indices, 0, instances)
13833                    }
13834                },
13835            );
13836        }
13837        encoder.finish(&wgpu::RenderBundleDescriptor {
13838            label: Some("Retained Stretch Bundle"),
13839        })
13840    }
13841
13842    /// Draws one maximal consecutive retained stretch through the bundle
13843    /// cache: key the stretch, rebuild on any mismatch (recapture, reorder,
13844    /// range or count change, slot release), then execute the cached bundle.
13845    /// Replays byte-identical commands to the per-op direct path.
13846    /// `stage_replay_patches` and the per-frame transform staging stay in
13847    /// the prepare arms, untouched — bundles bind buffers whose contents are
13848    /// read at execution.
13849    #[cfg(not(target_arch = "wasm32"))]
13850    fn draw_retained_stretch_bundled(
13851        &mut self,
13852        render_pass: &mut wgpu::RenderPass<'_>,
13853        ordered_items: &[(usize, SegmentDrawItem)],
13854        retained_draws: &[RetainedDraw],
13855        item_range: Range<usize>,
13856        width: u32,
13857        height: u32,
13858    ) {
13859        let key = self.retained_bundle_key(ordered_items, retained_draws, item_range);
13860        if !self.retained_bundle_cache.hit(&key) {
13861            let bundle = self.build_retained_bundle(&key);
13862            self.retained_bundle_cache.insert(key.clone(), bundle);
13863        }
13864        // Mirror the direct path's per-op stats for every op the bundle
13865        // draws, so bundling is invisible to the frame counters.
13866        for op in &key.ops {
13867            if op.capture_epoch.is_some()
13868                && op.retained_index < MAX_REPLAY_SLOTS
13869                && op.first < op.last
13870            {
13871                self.frame_stats.bump_shapes();
13872                self.frame_stats.add_draw_calls(1);
13873                if fill_area_diag_enabled() {
13874                    // Mirror the direct path's fill accounting per bundled op.
13875                    let slot = self.replay_slots.slots.get(&op.slot);
13876                    let retained = retained_draws.get(op.retained_index as usize);
13877                    if let (Some(slot), Some(retained)) = (slot, retained) {
13878                        self.fill_area_diag.add_retained_range(
13879                            &slot.fill_diag_shapes,
13880                            op.first,
13881                            op.last,
13882                            &retained.transform,
13883                        );
13884                    }
13885                }
13886            }
13887        }
13888        // Bundles inherit the pass scissor: set the same full-target rect
13889        // the direct path sets before every retained draw. Executing the
13890        // bundle then resets pipeline/bind/vertex state, which is harmless —
13891        // every following fused arm re-binds its own.
13892        render_pass.set_scissor_rect(0, 0, width, height);
13893        if let Some(bundle) = self.retained_bundle_cache.get(&key) {
13894            render_pass.execute_bundles(std::iter::once(bundle));
13895        }
13896    }
13897
13898    /// Test/diagnostic view of the retained bundle cache: lifetime
13899    /// (rebuilds, cached executes).
13900    #[cfg(not(target_arch = "wasm32"))]
13901    #[doc(hidden)]
13902    pub fn retained_bundle_stats(&self) -> (u64, u64) {
13903        self.retained_bundle_cache.stats()
13904    }
13905
13906    /// Test/diagnostic view of the transient rim mesh path: lifetime count
13907    /// of rims drawn as band meshes instead of full bounding quads.
13908    #[cfg(not(target_arch = "wasm32"))]
13909    #[doc(hidden)]
13910    pub fn rim_meshes_emitted(&self) -> u64 {
13911        self.rim_meshes_emitted
13912    }
13913
13914    /// Test/diagnostic view of the device-error sentry: lifetime
13915    /// uncaptured wgpu errors recorded on this renderer's device
13916    /// (`CRANPOSE_SURVIVE_GPU_ERRORS` kill switch).
13917    #[doc(hidden)]
13918    pub fn device_error_count(&self) -> u64 {
13919        self.device_errors.error_count()
13920    }
13921
13922    /// Test/diagnostic view of the static leading-span cache: lifetime
13923    /// (hits, recaptures).
13924    ///
13925    /// Gated like the cache it reads and like every sibling diagnostic here:
13926    /// `static_span` does not exist on wasm, so an ungated accessor compiles
13927    /// everywhere except the one target nothing in `cargo test` builds.
13928    #[cfg(not(target_arch = "wasm32"))]
13929    #[doc(hidden)]
13930    pub fn static_span_stats(&self) -> (u64, u64) {
13931        (self.static_span.hits, self.static_span.recaptures)
13932    }
13933
13934    /// Uploads the region of the transient rim mesh scratch appended since
13935    /// the previous upload — chunks later in the frame append after regions
13936    /// whose draws are already encoded, so earlier bytes are never
13937    /// rewritten and the fixed-capacity buffers are never recreated
13938    /// mid-frame. The executor-owned upload lands at the head of the next
13939    /// submit, which is where this frame's passes execute.
13940    #[cfg(not(target_arch = "wasm32"))]
13941    fn upload_transient_rim_meshes(&mut self) {
13942        let device = self.device.clone();
13943        let mut upload_stats = crate::frame_graph::FrameCommandStats::default();
13944        if self.rim_mesh_vertices.len() > self.rim_mesh_uploaded_vertices {
13945            let vertex_buffer = self.rim_mesh_vertex_buffer.get_or_insert_with(|| {
13946                device.create_buffer(&wgpu::BufferDescriptor {
13947                    label: Some("Rim Mesh Vertex Buffer"),
13948                    size: (RIM_MESH_VERTEX_CAPACITY * std::mem::size_of::<MeshVertex>()) as u64,
13949                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
13950                    mapped_at_creation: false,
13951                })
13952            });
13953            upload_stats.upload_bytes += self
13954                .frame_graph_executor
13955                .upload_buffer(
13956                    &self.queue,
13957                    vertex_buffer,
13958                    (self.rim_mesh_uploaded_vertices * std::mem::size_of::<MeshVertex>()) as u64,
13959                    bytemuck::cast_slice(
13960                        &self.rim_mesh_vertices[self.rim_mesh_uploaded_vertices..],
13961                    ),
13962                )
13963                .upload_bytes;
13964            self.rim_mesh_uploaded_vertices = self.rim_mesh_vertices.len();
13965        }
13966        if self.rim_mesh_indices.len() > self.rim_mesh_uploaded_indices {
13967            let index_buffer = self.rim_mesh_index_buffer.get_or_insert_with(|| {
13968                device.create_buffer(&wgpu::BufferDescriptor {
13969                    label: Some("Rim Mesh Index Buffer"),
13970                    size: (RIM_MESH_INDEX_CAPACITY * std::mem::size_of::<u32>()) as u64,
13971                    usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
13972                    mapped_at_creation: false,
13973                })
13974            });
13975            upload_stats.upload_bytes += self
13976                .frame_graph_executor
13977                .upload_buffer(
13978                    &self.queue,
13979                    index_buffer,
13980                    (self.rim_mesh_uploaded_indices * std::mem::size_of::<u32>()) as u64,
13981                    bytemuck::cast_slice(&self.rim_mesh_indices[self.rim_mesh_uploaded_indices..]),
13982                )
13983                .upload_bytes;
13984            self.rim_mesh_uploaded_indices = self.rim_mesh_indices.len();
13985        }
13986        if upload_stats.upload_bytes > 0 {
13987            self.frame_stats.record_command_stats(upload_stats);
13988        }
13989    }
13990
13991    fn draw_prepared_shapes(
13992        &self,
13993        render_pass: &mut wgpu::RenderPass<'_>,
13994        blend_mode: BlendMode,
13995        batch: PreparedShapeBatch,
13996        width: u32,
13997        height: u32,
13998        rims: &[RimDraw],
13999    ) {
14000        #[cfg(target_arch = "wasm32")]
14001        let _ = rims;
14002        self.frame_stats.bump_shapes();
14003        self.frame_stats.add_draw_calls(1);
14004        render_pass.set_scissor_rect(0, 0, width, height);
14005        #[cfg(not(target_arch = "wasm32"))]
14006        let (uniform_bind_group, shape_buffers) = (&self.uniform_bind_group, &self.shape_buffers);
14007        #[cfg(target_arch = "wasm32")]
14008        let (uniform_bind_group, shape_buffers) = (
14009            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
14010            &self.wasm_shape_batches[batch.shape_slot],
14011        );
14012        // Latched instanced path (storage mode only): one instance per
14013        // shape, four vertices through the static quad index buffer —
14014        // identical triangles, identical bind groups, still one draw call.
14015        // The uniform/WebGL path never latches it and stays on `vs_main`.
14016        #[cfg(not(target_arch = "wasm32"))]
14017        if let Some(instanced) = &self.instanced_quads {
14018            assert!(
14019                batch.vertex_start.is_multiple_of(6) && batch.vertex_count.is_multiple_of(6),
14020                "shape batches are whole shapes: vertex range {}..+{} must be \
14021                 six-aligned to convert to an instance range",
14022                batch.vertex_start,
14023                batch.vertex_count,
14024            );
14025            // The same selection the preamble and every post-rim restore
14026            // make — factored so the two sites cannot disagree.
14027            let set_instanced_pipeline = |render_pass: &mut wgpu::RenderPass<'_>| {
14028                if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
14029                    render_pass.set_pipeline(self.instanced_pipeline_solid(instanced));
14030                } else {
14031                    render_pass.set_pipeline(self.instanced_pipeline(instanced, blend_mode));
14032                }
14033            };
14034            set_instanced_pipeline(render_pass);
14035            render_pass.set_bind_group(0, uniform_bind_group, &[]);
14036            // Dynamic offset 0: ordinary batches read the identity
14037            // similarity transform.
14038            render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
14039            let first_shape = batch.vertex_start / 6;
14040            let shape_count = batch.vertex_count / 6;
14041            render_pass
14042                .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
14043            // Rims arrive in ascending shape order (step 4 walks the fused
14044            // upload front to back), so this batch's rims are one contiguous
14045            // run of the slice.
14046            debug_assert!(
14047                rims.windows(2)
14048                    .all(|pair| pair[0].shape_index < pair[1].shape_index),
14049                "rim draws must arrive in ascending shape order"
14050            );
14051            let rim_start = rims.partition_point(|rim| rim.shape_index < first_shape);
14052            let rim_end = rims.partition_point(|rim| rim.shape_index < first_shape + shape_count);
14053            let batch_rims = &rims[rim_start..rim_end];
14054            let rim_buffers = match (&self.rim_mesh_vertex_buffer, &self.rim_mesh_index_buffer) {
14055                (Some(vertex_buffer), Some(index_buffer)) if !batch_rims.is_empty() => {
14056                    Some((vertex_buffer, index_buffer))
14057                }
14058                _ => None,
14059            };
14060            let Some((rim_vertex_buffer, rim_index_buffer)) = rim_buffers else {
14061                render_pass.draw_indexed(0..6, 0, first_shape..first_shape + shape_count);
14062                return;
14063            };
14064            // Split the instance range around each rim, in exact shape
14065            // order, so z is untouched: instances before the rim, the rim's
14066            // band mesh through `vs_mesh`, instances after. Bind groups
14067            // persist across `set_pipeline` because the mesh and instanced
14068            // pipelines share identical bind group layouts (uniform layout +
14069            // shape layout, dynamic similarity offset included), so only the
14070            // pipeline and index/vertex buffers are re-set per switch.
14071            let mut draw_calls = 0u32;
14072            let mut cursor = first_shape;
14073            for rim in batch_rims {
14074                if cursor < rim.shape_index {
14075                    render_pass.draw_indexed(0..6, 0, cursor..rim.shape_index);
14076                    draw_calls += 1;
14077                }
14078                render_pass.set_pipeline(self.mesh_pipeline());
14079                render_pass.set_vertex_buffer(0, rim_vertex_buffer.slice(..));
14080                render_pass.set_index_buffer(rim_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
14081                render_pass.draw_indexed(
14082                    rim.first_index..rim.first_index + rim.index_count,
14083                    0,
14084                    0..1,
14085                );
14086                draw_calls += 1;
14087                set_instanced_pipeline(render_pass);
14088                render_pass
14089                    .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
14090                cursor = rim.shape_index + 1;
14091            }
14092            if cursor < first_shape + shape_count {
14093                render_pass.draw_indexed(0..6, 0, cursor..first_shape + shape_count);
14094                draw_calls += 1;
14095            }
14096            // One draw call was already counted at the top of the fn.
14097            self.frame_stats
14098                .add_draw_calls(draw_calls.saturating_sub(1));
14099            return;
14100        }
14101        if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
14102            render_pass.set_pipeline(self.shape_pipeline_solid());
14103        } else {
14104            render_pass.set_pipeline(self.shape_pipeline(blend_mode));
14105        }
14106        render_pass.set_bind_group(0, uniform_bind_group, &[]);
14107        // Dynamic offset 0: ordinary batches read the identity similarity
14108        // transform.
14109        render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
14110        // Six unindexed vertices per shape; `vs_main` derives the corner from
14111        // `vertex_index` and pulls the quad out of `ShapeData`.
14112        render_pass.draw(
14113            batch.vertex_start..batch.vertex_start + batch.vertex_count,
14114            0..1,
14115        );
14116    }
14117
14118    /// Stage shape buffer writes and record a shape render pass onto the
14119    /// provided encoder. The caller is responsible for submitting.
14120    #[allow(clippy::too_many_arguments)]
14121    fn encode_shapes_pass<'a, I, C: FrameCommandRecorder>(
14122        &mut self,
14123        frame_encoder: &mut C,
14124        target_view: &wgpu::TextureView,
14125        layer_shapes: I,
14126        brushes: &[Brush],
14127        blend_mode: BlendMode,
14128        width: u32,
14129        height: u32,
14130        root_scale: f32,
14131        load_op: wgpu::LoadOp<wgpu::Color>,
14132        viewport_offset: [f32; 2],
14133    ) where
14134        I: Iterator<Item = &'a DrawShape>,
14135    {
14136        let mut staged_uploads = self.take_staged_uploads();
14137        let viewport = ViewportUniformParams {
14138            width,
14139            height,
14140            offset: viewport_offset,
14141        };
14142        let viewport_rect_logical = viewport_rect_in_logical(viewport, root_scale);
14143        let Some(batch) = self.prepare_shapes_batch(
14144            layer_shapes.filter(|shape| match viewport_rect_logical {
14145                Some(rect) => shape_draw_is_visible_in_rect(shape, rect, root_scale),
14146                None => false,
14147            }),
14148            brushes,
14149            root_scale,
14150            viewport,
14151            &mut staged_uploads,
14152        ) else {
14153            self.restore_staged_uploads(staged_uploads);
14154            return;
14155        };
14156        let upload_offset =
14157            frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
14158        self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
14159        self.restore_staged_uploads(staged_uploads);
14160        let mut render_pass =
14161            frame_encoder
14162                .encoder()
14163                .begin_render_pass(&wgpu::RenderPassDescriptor {
14164                    label: Some("Shape Pass"),
14165                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
14166                        view: target_view,
14167                        resolve_target: None,
14168                        depth_slice: None,
14169                        ops: wgpu::Operations {
14170                            load: load_op,
14171                            store: wgpu::StoreOp::Store,
14172                        },
14173                    })],
14174                    depth_stencil_attachment: None,
14175                    timestamp_writes: None,
14176                    occlusion_query_set: None,
14177                    multiview_mask: None,
14178                });
14179        self.draw_prepared_shapes(&mut render_pass, blend_mode, batch, width, height, &[]);
14180    }
14181
14182    fn draw_prepared_images(
14183        &mut self,
14184        render_pass: &mut wgpu::RenderPass<'_>,
14185        batch: &PreparedImageBatch,
14186        blend_mode: BlendMode,
14187    ) -> Result<(), String> {
14188        if batch.cmds.is_empty() {
14189            return Ok(());
14190        }
14191        self.frame_stats.bump_images();
14192        self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
14193        render_pass.set_pipeline(self.image_pipeline(blend_mode));
14194        #[cfg(not(target_arch = "wasm32"))]
14195        let (uniform_bind_group, vertex_buffer, index_buffer) = (
14196            &self.uniform_bind_group,
14197            &self.image_vertex_buffer,
14198            &self.image_index_buffer,
14199        );
14200        #[cfg(target_arch = "wasm32")]
14201        let (uniform_bind_group, vertex_buffer, index_buffer) = (
14202            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
14203            &self.wasm_image_batches[batch.image_slot].vertex_buffer,
14204            &self.wasm_image_batches[batch.image_slot].index_buffer,
14205        );
14206        render_pass.set_bind_group(0, uniform_bind_group, &[]);
14207        render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
14208        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
14209
14210        for cmd in &batch.cmds {
14211            let (sx, sy, sw, sh) = cmd.scissor;
14212            render_pass.set_scissor_rect(sx, sy, sw, sh);
14213
14214            let cached = self
14215                .image_texture_cache
14216                .get(&cmd.image_id)
14217                .ok_or_else(|| "image texture missing from cache".to_string())?;
14218            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
14219            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
14220        }
14221        Ok(())
14222    }
14223
14224    fn draw_prepared_glyphs(
14225        &mut self,
14226        render_pass: &mut wgpu::RenderPass<'_>,
14227        batch: &PreparedGlyphBatch,
14228    ) -> Result<(), String> {
14229        if batch.cmds.is_empty() {
14230            return Ok(());
14231        }
14232        #[cfg(not(target_arch = "wasm32"))]
14233        {
14234            self.draw_native_prepared_glyph_cmd_range(
14235                render_pass,
14236                &batch.cmds,
14237                0..batch.cmds.len(),
14238            )?;
14239        }
14240        #[cfg(target_arch = "wasm32")]
14241        {
14242            self.frame_stats.bump_text();
14243            self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
14244            render_pass.set_pipeline(self.glyph_atlas_pipeline());
14245            let (uniform_bind_group, vertex_buffer, index_buffer) = (
14246                &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
14247                &self.wasm_image_batches[batch.image_slot].vertex_buffer,
14248                &self.wasm_image_batches[batch.image_slot].index_buffer,
14249            );
14250            render_pass.set_bind_group(0, uniform_bind_group, &[]);
14251            render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
14252            render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
14253            render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
14254
14255            for cmd in &batch.cmds {
14256                let (sx, sy, sw, sh) = cmd.scissor;
14257                render_pass.set_scissor_rect(sx, sy, sw, sh);
14258                let GlyphDrawSource::Shared {
14259                    index_start,
14260                    index_count,
14261                } = cmd.source;
14262                render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
14263            }
14264        }
14265        Ok(())
14266    }
14267
14268    #[cfg(not(target_arch = "wasm32"))]
14269    fn draw_native_prepared_image_cmd_range(
14270        &mut self,
14271        render_pass: &mut wgpu::RenderPass<'_>,
14272        cmds: &[ImageDrawCmd],
14273        cmd_range: Range<usize>,
14274        blend_mode: BlendMode,
14275    ) -> Result<(), String> {
14276        let Some(cmds) = cmds.get(cmd_range) else {
14277            return Err("image command range is outside the prepared command buffer".to_string());
14278        };
14279        if cmds.is_empty() {
14280            return Ok(());
14281        }
14282
14283        self.frame_stats.bump_images();
14284        self.frame_stats.add_draw_calls(cmds.len() as u32);
14285        render_pass.set_pipeline(self.image_pipeline(blend_mode));
14286        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
14287        render_pass.set_index_buffer(self.image_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
14288        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
14289
14290        for cmd in cmds {
14291            let (sx, sy, sw, sh) = cmd.scissor;
14292            render_pass.set_scissor_rect(sx, sy, sw, sh);
14293
14294            let cached = self
14295                .image_texture_cache
14296                .get(&cmd.image_id)
14297                .ok_or_else(|| "image texture missing from cache".to_string())?;
14298            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
14299            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
14300        }
14301        Ok(())
14302    }
14303
14304    #[cfg(not(target_arch = "wasm32"))]
14305    fn draw_native_prepared_glyph_cmd_range(
14306        &mut self,
14307        render_pass: &mut wgpu::RenderPass<'_>,
14308        cmds: &[GlyphDrawCmd],
14309        cmd_range: Range<usize>,
14310    ) -> Result<(), String> {
14311        let Some(cmds) = cmds.get(cmd_range) else {
14312            return Err("glyph command range is outside the prepared command buffer".to_string());
14313        };
14314        if cmds.is_empty() {
14315            return Ok(());
14316        }
14317
14318        self.frame_stats.bump_text();
14319        self.frame_stats.add_draw_calls(cmds.len() as u32);
14320
14321        let mut shared_buffers_bound = false;
14322        let mut retained_pipeline_bound = false;
14323        for cmd in cmds {
14324            let (sx, sy, sw, sh) = cmd.scissor;
14325            render_pass.set_scissor_rect(sx, sy, sw, sh);
14326            match cmd.source {
14327                GlyphDrawSource::Shared {
14328                    index_start,
14329                    index_count,
14330                } => {
14331                    if retained_pipeline_bound || !shared_buffers_bound {
14332                        render_pass.set_pipeline(self.glyph_atlas_pipeline());
14333                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
14334                        retained_pipeline_bound = false;
14335                    }
14336                    if !shared_buffers_bound {
14337                        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
14338                        render_pass.set_index_buffer(
14339                            self.image_index_buffer.slice(..),
14340                            wgpu::IndexFormat::Uint32,
14341                        );
14342                        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
14343                        shared_buffers_bound = true;
14344                    }
14345                    render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
14346                }
14347                GlyphDrawSource::Retained {
14348                    cache_key,
14349                    uniform_slot,
14350                } => {
14351                    shared_buffers_bound = false;
14352                    if !retained_pipeline_bound {
14353                        render_pass.set_pipeline(self.retained_glyph_atlas_pipeline());
14354                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
14355                        retained_pipeline_bound = true;
14356                    }
14357                    let cached = self
14358                        .text_glyph_gpu_run_cache
14359                        .peek(&cache_key)
14360                        .ok_or_else(|| "retained glyph buffer missing from cache".to_string())?;
14361                    let dynamic_offset =
14362                        self.retained_glyph_uniform_dynamic_offset(uniform_slot)?;
14363                    render_pass.set_bind_group(
14364                        0,
14365                        &self.retained_glyph_uniform_bind_group,
14366                        &[dynamic_offset],
14367                    );
14368                    render_pass
14369                        .set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
14370                    render_pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
14371                    render_pass.draw_indexed(0..cached.index_count, 0, 0..1);
14372                }
14373            }
14374        }
14375        Ok(())
14376    }
14377
14378    fn append_image_draw_cmd(
14379        &mut self,
14380        image_draw: &ImageDraw,
14381        viewport: ViewportUniformParams,
14382        root_scale: f32,
14383        image_vertices: &mut Vec<Vertex>,
14384        image_indices: &mut Vec<u32>,
14385        image_cmds: &mut Vec<ImageDrawCmd>,
14386    ) -> Result<(), String> {
14387        let snap_delta = image_draw
14388            .snap_anchor
14389            .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
14390            .unwrap_or_default();
14391        let rect = image_draw.rect.translate(snap_delta.x, snap_delta.y);
14392        if rect.width <= 0.0 || rect.height <= 0.0 || image_draw.alpha <= 0.0 {
14393            return Ok(());
14394        }
14395
14396        let (tint, cpu_filter) = tint_for_image(image_draw.color_filter, image_draw.alpha);
14397        if tint[3] <= 0.0 {
14398            return Ok(());
14399        }
14400
14401        let prepared_image = if let Some(filter) = cpu_filter {
14402            apply_filter_to_bitmap(&image_draw.image, filter)?
14403        } else {
14404            image_draw.image.clone()
14405        };
14406        self.ensure_image_cached(&prepared_image)?;
14407
14408        let mut adjusted_image = ImageDraw {
14409            rect,
14410            local_rect: image_draw.local_rect.translate(snap_delta.x, snap_delta.y),
14411            quad: translate_quad(image_draw.quad, snap_delta),
14412            snap_anchor: image_draw.snap_anchor,
14413            image: image_draw.image.clone(),
14414            alpha: image_draw.alpha,
14415            color_filter: image_draw.color_filter,
14416            sampling: image_draw.sampling,
14417            z_index: image_draw.z_index,
14418            clip: image_draw.clip,
14419            blend_mode: image_draw.blend_mode,
14420            src_rect: image_draw.src_rect,
14421            motion_context_animated: image_draw.motion_context_animated,
14422        };
14423        snap_nearest_image_to_device_pixels(&mut adjusted_image, root_scale);
14424        let Some(scissor) =
14425            scissor_rect_for_image(&adjusted_image, root_scale, viewport.width, viewport.height)
14426        else {
14427            return Ok(());
14428        };
14429
14430        let Some(uv_rect) = image_uv_rect(&image_draw.image, image_draw.src_rect) else {
14431            return Ok(());
14432        };
14433        let device_quad =
14434            nearest_image_device_quad(&adjusted_image, root_scale).unwrap_or_else(|| {
14435                if adjusted_image.snap_anchor.is_some() {
14436                    canonicalized_scaled_quad(adjusted_image.quad, root_scale)
14437                } else {
14438                    scaled_quad(adjusted_image.quad, root_scale)
14439                }
14440            });
14441        #[cfg(not(target_arch = "wasm32"))]
14442        {
14443            if fill_area_diag_enabled() {
14444                self.fill_area_diag.add_image_quad(&device_quad);
14445            }
14446        }
14447
14448        let base_vertex = image_vertices.len() as u32;
14449        let index_start = image_indices.len() as u32;
14450        image_indices.extend_from_slice(&[
14451            base_vertex,
14452            base_vertex + 1,
14453            base_vertex + 2,
14454            base_vertex + 2,
14455            base_vertex + 1,
14456            base_vertex + 3,
14457        ]);
14458        image_vertices.extend_from_slice(&[
14459            Vertex {
14460                position: device_quad[0],
14461                color: tint,
14462                uv: [uv_rect.min[0], uv_rect.min[1]],
14463                uv_bounds: uv_rect.sample_bounds,
14464            },
14465            Vertex {
14466                position: device_quad[1],
14467                color: tint,
14468                uv: [uv_rect.max[0], uv_rect.min[1]],
14469                uv_bounds: uv_rect.sample_bounds,
14470            },
14471            Vertex {
14472                position: device_quad[2],
14473                color: tint,
14474                uv: [uv_rect.min[0], uv_rect.max[1]],
14475                uv_bounds: uv_rect.sample_bounds,
14476            },
14477            Vertex {
14478                position: device_quad[3],
14479                color: tint,
14480                uv: [uv_rect.max[0], uv_rect.max[1]],
14481                uv_bounds: uv_rect.sample_bounds,
14482            },
14483        ]);
14484
14485        image_cmds.push(ImageDrawCmd {
14486            index_start,
14487            scissor,
14488            image_id: prepared_image.id(),
14489            sampling: image_draw.sampling,
14490        });
14491        Ok(())
14492    }
14493
14494    #[cfg(not(target_arch = "wasm32"))]
14495    fn stage_native_image_buffers(
14496        &mut self,
14497        staged_uploads: &mut StagedBufferUploads,
14498        viewport: ViewportUniformParams,
14499        image_vertices: &[Vertex],
14500        image_indices: &[u32],
14501    ) {
14502        if image_indices.is_empty() {
14503            return;
14504        }
14505
14506        self.stage_viewport_uniforms(staged_uploads, viewport);
14507        // Grow to a power of two, as the shape batch and frame upload buffers
14508        // do. Sizing these to the exact byte count instead means one more glyph
14509        // quad than the last frame destroys and recreates both buffers, and a
14510        // caption that grows a character at a time does it on every frame.
14511        let needed_bytes = std::mem::size_of_val(image_vertices) as u64;
14512        if needed_bytes > self.image_vertex_buffer.size() {
14513            self.image_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14514                label: Some("Image Vertex Buffer"),
14515                size: needed_bytes.next_power_of_two(),
14516                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
14517                mapped_at_creation: false,
14518            });
14519        }
14520        let needed_index_bytes = std::mem::size_of_val(image_indices) as u64;
14521        if needed_index_bytes > self.image_index_buffer.size() {
14522            self.image_index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14523                label: Some("Image Index Buffer"),
14524                size: needed_index_bytes.next_power_of_two(),
14525                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
14526                mapped_at_creation: false,
14527            });
14528        }
14529
14530        staged_uploads.stage(
14531            UploadTarget::ImageVertex,
14532            bytemuck::cast_slice(image_vertices),
14533        );
14534        staged_uploads.stage(
14535            UploadTarget::ImageIndex,
14536            bytemuck::cast_slice(image_indices),
14537        );
14538    }
14539
14540    /// Prepare image vertices, indices, ensure caching, and write to GPU buffers.
14541    /// Returns the draw commands needed by `encode_images_pass`.
14542    fn prepare_image_draw_cmds<'a, I>(
14543        &mut self,
14544        layer_images: I,
14545        viewport: ViewportUniformParams,
14546        root_scale: f32,
14547        staged_uploads: &mut StagedBufferUploads,
14548    ) -> Result<PreparedImageBatch, String>
14549    where
14550        I: Iterator<Item = &'a ImageDraw>,
14551    {
14552        #[cfg(target_arch = "wasm32")]
14553        let _ = staged_uploads;
14554
14555        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
14556        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
14557        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
14558        image_vertices.clear();
14559        image_indices.clear();
14560        image_cmds.clear();
14561
14562        for image_draw in layer_images {
14563            self.append_image_draw_cmd(
14564                image_draw,
14565                viewport,
14566                root_scale,
14567                &mut image_vertices,
14568                &mut image_indices,
14569                &mut image_cmds,
14570            )?;
14571        }
14572
14573        #[cfg(not(target_arch = "wasm32"))]
14574        if !image_cmds.is_empty() {
14575            self.stage_native_image_buffers(
14576                staged_uploads,
14577                viewport,
14578                &image_vertices,
14579                &image_indices,
14580            );
14581        }
14582
14583        #[cfg(target_arch = "wasm32")]
14584        let image_slot = if image_cmds.is_empty() {
14585            0
14586        } else {
14587            let slot = self.claim_wasm_image_batch();
14588            {
14589                let buffers = &mut self.wasm_image_batches[slot];
14590                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
14591            }
14592            let buffers = &self.wasm_image_batches[slot];
14593            self.write_wasm_buffer(
14594                &buffers.vertex_buffer,
14595                bytemuck::cast_slice(&image_vertices),
14596            );
14597            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
14598            slot
14599        };
14600
14601        #[cfg(target_arch = "wasm32")]
14602        let uniform_slot = if image_cmds.is_empty() {
14603            0
14604        } else {
14605            self.prepare_wasm_viewport_uniforms(viewport)
14606        };
14607
14608        self.scratch_image_vertices = image_vertices;
14609        self.scratch_image_indices = image_indices;
14610        Ok(PreparedImageBatch {
14611            cmds: image_cmds,
14612            #[cfg(target_arch = "wasm32")]
14613            image_slot,
14614            #[cfg(target_arch = "wasm32")]
14615            uniform_slot,
14616        })
14617    }
14618
14619    fn glyph_atlas_entry_for(
14620        &mut self,
14621        glyph: &SoftwareGlyphAtlasGlyph,
14622    ) -> Result<GlyphAtlasEntry, String> {
14623        if let Some(entry) = self.text_glyph_atlas.upload_glyph(
14624            glyph.key,
14625            glyph,
14626            &self.queue,
14627            &mut self.frame_graph_executor,
14628            &mut self.frame_stats,
14629        ) {
14630            return Ok(entry);
14631        }
14632
14633        self.text_glyph_atlas.reset(
14634            &self.device,
14635            &self.image_bind_group_layout,
14636            &self.image_nearest_sampler,
14637        );
14638        Err("text glyph atlas filled and was reset".to_string())
14639    }
14640
14641    fn glyph_atlas_entry_for_cached(
14642        &mut self,
14643        glyph: &SoftwareGlyphAtlasPlacement,
14644    ) -> Option<GlyphAtlasEntry> {
14645        let entry = self.text_glyph_atlas.entry(&glyph.key)?;
14646        self.frame_stats.record_text_glyph_atlas_hit();
14647        Some(entry)
14648    }
14649
14650    fn glyph_atlas_entry_for_placement(
14651        &mut self,
14652        glyph: &SoftwareGlyphAtlasPlacement,
14653    ) -> Result<GlyphAtlasEntry, String> {
14654        if let Some(entry) = self.glyph_atlas_entry_for_cached(glyph) {
14655            return Ok(entry);
14656        }
14657
14658        let Some(upload_glyph) = self.text_glyph_mask_cache.atlas_glyph_for_placement(glyph) else {
14659            return Err("text glyph placement has no retained raster mask".to_string());
14660        };
14661        self.glyph_atlas_entry_for(&upload_glyph)
14662    }
14663
14664    fn prepare_text_glyph_quads(
14665        &mut self,
14666        run_key: TextGlyphRunCacheKey,
14667        atlas_generation: u64,
14668        cached_glyph_run: Option<&[SoftwareGlyphAtlasPlacement]>,
14669        collected_run: &[SoftwareGlyphAtlasRunGlyph],
14670        generated_quads: &mut Vec<CachedTextGlyphQuad>,
14671    ) -> Result<Rc<[CachedTextGlyphQuad]>, String> {
14672        generated_quads.clear();
14673        if let Some(glyph_run) = cached_glyph_run {
14674            for glyph in glyph_run {
14675                if glyph.width == 0 || glyph.height == 0 || glyph.color.3 <= 0.0 {
14676                    continue;
14677                }
14678                let entry = self.glyph_atlas_entry_for_placement(glyph)?;
14679                // Read the size after the entry is in hand: the only path that
14680                // resizes the atlas is the overflow reset, which returns `Err`
14681                // above, so `entry` is always normalised against the atlas it
14682                // was placed in.
14683                generated_quads.push(cached_text_glyph_quad(
14684                    glyph,
14685                    entry,
14686                    self.text_glyph_atlas.size(),
14687                ));
14688            }
14689        } else {
14690            for run_glyph in collected_run {
14691                let placement = run_glyph.placement();
14692                if placement.width == 0 || placement.height == 0 || placement.color.3 <= 0.0 {
14693                    continue;
14694                }
14695                let entry = match run_glyph {
14696                    SoftwareGlyphAtlasRunGlyph::Cached(placement) => {
14697                        self.glyph_atlas_entry_for_placement(placement)?
14698                    }
14699                    SoftwareGlyphAtlasRunGlyph::New(glyph) => self.glyph_atlas_entry_for(glyph)?,
14700                };
14701                generated_quads.push(cached_text_glyph_quad(
14702                    &placement,
14703                    entry,
14704                    self.text_glyph_atlas.size(),
14705                ));
14706            }
14707        }
14708
14709        let quads: Rc<[CachedTextGlyphQuad]> = Rc::from(generated_quads.clone().into_boxed_slice());
14710        if let Some(cached) = self.text_glyph_run_cache.get_mut(&run_key) {
14711            cached.quads = Some(Rc::clone(&quads));
14712            cached.atlas_generation = atlas_generation;
14713        }
14714        Ok(quads)
14715    }
14716
14717    #[allow(clippy::too_many_arguments)]
14718    fn append_text_glyph_quad_run(
14719        &mut self,
14720        source_raster_rect: Rect,
14721        quads: &[CachedTextGlyphQuad],
14722        clip: Option<Rect>,
14723        viewport: ViewportUniformParams,
14724        root_scale: f32,
14725        image_vertices: &mut Vec<Vertex>,
14726        image_indices: &mut Vec<u32>,
14727        record_cached_hits: bool,
14728    ) -> usize {
14729        let mut appended = 0usize;
14730        for quad in quads {
14731            if !cached_text_glyph_quad_is_visible_in_viewport(
14732                source_raster_rect,
14733                quad,
14734                clip,
14735                viewport,
14736                root_scale,
14737            ) {
14738                continue;
14739            }
14740            if append_cached_text_glyph_quad(
14741                source_raster_rect,
14742                quad,
14743                image_vertices,
14744                image_indices,
14745            ) {
14746                if record_cached_hits {
14747                    self.frame_stats.record_text_glyph_atlas_hit();
14748                }
14749                #[cfg(not(target_arch = "wasm32"))]
14750                {
14751                    if fill_area_diag_enabled() {
14752                        self.fill_area_diag.add_glyph_quad(quad);
14753                    }
14754                }
14755                appended = appended.saturating_add(1);
14756            }
14757        }
14758        appended
14759    }
14760
14761    #[cfg(not(target_arch = "wasm32"))]
14762    fn retained_glyph_viewport(
14763        viewport: ViewportUniformParams,
14764        source_raster_rect: Rect,
14765    ) -> ViewportUniformParams {
14766        ViewportUniformParams {
14767            width: viewport.width,
14768            height: viewport.height,
14769            offset: [
14770                viewport.offset[0] - source_raster_rect.x,
14771                viewport.offset[1] - source_raster_rect.y,
14772            ],
14773        }
14774    }
14775
14776    #[cfg(not(target_arch = "wasm32"))]
14777    fn retained_text_glyph_run_ready(&mut self, cache_key: TextGlyphRunCacheKey) -> bool {
14778        let atlas_generation = self.text_glyph_atlas.generation();
14779        self.text_glyph_gpu_run_cache
14780            .peek(&cache_key)
14781            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
14782    }
14783
14784    #[cfg(not(target_arch = "wasm32"))]
14785    #[allow(clippy::too_many_arguments)]
14786    fn emit_retained_text_glyph_run_if_ready(
14787        &mut self,
14788        cache_key: TextGlyphRunCacheKey,
14789        quads: &[CachedTextGlyphQuad],
14790        clip: Option<Rect>,
14791        viewport: ViewportUniformParams,
14792        source_raster_rect: Rect,
14793        scissor: (u32, u32, u32, u32),
14794        staged_uploads: &mut StagedBufferUploads,
14795        glyph_cmds: &mut Vec<GlyphDrawCmd>,
14796    ) -> bool {
14797        if !should_use_retained_text_glyph_run(quads.len(), clip) {
14798            return false;
14799        }
14800        if !self.retained_text_glyph_run_ready(cache_key)
14801            && !self.ensure_retained_text_glyph_run(cache_key, quads)
14802        {
14803            return false;
14804        }
14805
14806        let uniform_slot = self.stage_retained_glyph_viewport_uniforms(
14807            staged_uploads,
14808            Self::retained_glyph_viewport(viewport, source_raster_rect),
14809        );
14810        if fill_area_diag_enabled() {
14811            // The retained run draws every quad of its cached buffer; the
14812            // shared path's per-quad viewport cull is not re-run for it.
14813            for quad in quads {
14814                self.fill_area_diag.add_glyph_quad(quad);
14815            }
14816        }
14817        glyph_cmds.push(GlyphDrawCmd::retained(cache_key, uniform_slot, scissor));
14818        true
14819    }
14820
14821    #[cfg(not(target_arch = "wasm32"))]
14822    fn ensure_retained_text_glyph_run(
14823        &mut self,
14824        cache_key: TextGlyphRunCacheKey,
14825        quads: &[CachedTextGlyphQuad],
14826    ) -> bool {
14827        let atlas_generation = self.text_glyph_atlas.generation();
14828        if self
14829            .text_glyph_gpu_run_cache
14830            .peek(&cache_key)
14831            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
14832        {
14833            return true;
14834        }
14835
14836        let mut vertices = Vec::with_capacity(quads.len().saturating_mul(4));
14837        let mut indices = Vec::with_capacity(quads.len().saturating_mul(6));
14838        let origin = Rect {
14839            x: 0.0,
14840            y: 0.0,
14841            width: 0.0,
14842            height: 0.0,
14843        };
14844        for quad in quads {
14845            append_cached_text_glyph_quad(origin, quad, &mut vertices, &mut indices);
14846        }
14847        if indices.is_empty() {
14848            return false;
14849        }
14850
14851        let vertex_bytes = bytemuck::cast_slice(&vertices);
14852        let index_bytes = bytemuck::cast_slice(&indices);
14853        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14854            label: Some("Retained Text Glyph Vertex Buffer"),
14855            size: vertex_bytes.len() as u64,
14856            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
14857            mapped_at_creation: false,
14858        });
14859        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
14860            label: Some("Retained Text Glyph Index Buffer"),
14861            size: index_bytes.len() as u64,
14862            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
14863            mapped_at_creation: false,
14864        });
14865        let vertex_upload =
14866            self.frame_graph_executor
14867                .upload_buffer(&self.queue, &vertex_buffer, 0, vertex_bytes);
14868        self.frame_stats.record_command_stats(vertex_upload);
14869        let index_upload =
14870            self.frame_graph_executor
14871                .upload_buffer(&self.queue, &index_buffer, 0, index_bytes);
14872        self.frame_stats.record_command_stats(index_upload);
14873
14874        self.text_glyph_gpu_run_cache.put(
14875            cache_key,
14876            CachedGpuTextGlyphRun {
14877                vertex_buffer,
14878                index_buffer,
14879                index_count: indices.len() as u32,
14880                atlas_generation,
14881            },
14882        );
14883        true
14884    }
14885
14886    #[allow(clippy::too_many_arguments)]
14887    fn append_text_glyph_draws<'a, I>(
14888        &mut self,
14889        layer_texts: I,
14890        viewport: ViewportUniformParams,
14891        root_scale: f32,
14892        allow_offscreen_prewarm: bool,
14893        staged_uploads: &mut StagedBufferUploads,
14894        image_vertices: &mut Vec<Vertex>,
14895        image_indices: &mut Vec<u32>,
14896        glyph_cmds: &mut Vec<GlyphDrawCmd>,
14897    ) -> Result<bool, String>
14898    where
14899        I: IntoIterator<Item = &'a TextDraw>,
14900    {
14901        let append_start = Instant::now();
14902        let initial_vertex_len = image_vertices.len();
14903        let initial_index_len = image_indices.len();
14904        let initial_cmd_len = glyph_cmds.len();
14905        let initial_staged_bytes_len = staged_uploads.bytes.len();
14906        let initial_staged_copies_len = staged_uploads.copies.len();
14907        let mut collected_run = std::mem::take(&mut self.scratch_text_glyph_run);
14908        let mut collected_placements = std::mem::take(&mut self.scratch_text_glyph_placements);
14909        let mut generated_quads = std::mem::take(&mut self.scratch_text_glyph_quads);
14910        generated_quads.clear();
14911        let mut visited = 0usize;
14912        let mut emitted_glyphs = 0usize;
14913        let mut prewarmed_glyphs = 0usize;
14914        let mut run_hits = 0usize;
14915        let mut run_misses = 0usize;
14916
14917        for text_draw in layer_texts {
14918            visited = visited.saturating_add(1);
14919            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
14920                self.text_raster_geometry(text_draw, root_scale)
14921            else {
14922                continue;
14923            };
14924            if !static_text_motion {
14925                image_vertices.truncate(initial_vertex_len);
14926                image_indices.truncate(initial_index_len);
14927                glyph_cmds.truncate(initial_cmd_len);
14928                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
14929                self.scratch_text_glyph_run = collected_run;
14930                self.scratch_text_glyph_placements = collected_placements;
14931                self.scratch_text_glyph_quads = generated_quads;
14932                return Ok(false);
14933            }
14934            let is_visible =
14935                text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale);
14936            let draw_action = text_glyph_draw_action(
14937                is_visible,
14938                text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale),
14939                allow_offscreen_prewarm,
14940            );
14941            if draw_action == TextGlyphDrawAction::Skip {
14942                continue;
14943            }
14944
14945            let raster_source = text_glyph_raster_source(text_draw, raster_rect);
14946            let source_draw = raster_source.draw.as_ref();
14947            let source_raster_rect = raster_source.raster_rect;
14948
14949            let run_key = Self::text_glyph_run_cache_key(
14950                source_draw,
14951                source_raster_rect,
14952                text_scale,
14953                static_text_motion,
14954            );
14955            let atlas_generation = self.text_glyph_atlas.generation();
14956            let mut cached_quad_run = None;
14957            let mut miss_collect_ms = None;
14958            let mut miss_cached_glyphs = 0usize;
14959            let mut miss_new_glyphs = 0usize;
14960            let cached_glyph_run = if let Some(cached) = self.text_glyph_run_cache.get(&run_key) {
14961                run_hits = run_hits.saturating_add(1);
14962                if cached.atlas_generation == atlas_generation {
14963                    cached_quad_run = cached.quads.as_ref().map(Rc::clone);
14964                }
14965                Some(Rc::clone(&cached.glyphs))
14966            } else {
14967                run_misses = run_misses.saturating_add(1);
14968                collected_run.clear();
14969                let collect_start = Instant::now();
14970                let collect_result = collect_solid_text_atlas_run(
14971                    source_draw.text.as_ref(),
14972                    source_raster_rect,
14973                    &source_draw.text_style,
14974                    source_draw.color,
14975                    source_draw.font_size,
14976                    text_scale,
14977                    &self.text_fonts,
14978                    &mut self.text_glyph_mask_cache,
14979                    &mut collected_run,
14980                );
14981                miss_collect_ms = Some(instant_ms(collect_start, Instant::now()));
14982                if collect_result.is_none() {
14983                    if text_atlas_fallback_diag_enabled() {
14984                        let preview: String = source_draw.text.text.chars().take(96).collect();
14985                        log::warn!(
14986                            "[text-atlas-fallback] node={:?} visible={} prewarm={} spans={} links={} text_len={} preview={:?} span_style={:?} paragraph_style={:?}",
14987                            source_draw.node_id,
14988                            is_visible,
14989                            draw_action == TextGlyphDrawAction::PrewarmOffscreen,
14990                            source_draw.text.span_styles.len(),
14991                            source_draw.text.links.len(),
14992                            source_draw.text.text.len(),
14993                            preview,
14994                            source_draw.text_style.span_style,
14995                            source_draw.text_style.paragraph_style,
14996                        );
14997                    }
14998                    if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
14999                        continue;
15000                    }
15001                    image_vertices.truncate(initial_vertex_len);
15002                    image_indices.truncate(initial_index_len);
15003                    glyph_cmds.truncate(initial_cmd_len);
15004                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
15005                    self.scratch_text_glyph_run = collected_run;
15006                    self.scratch_text_glyph_placements = collected_placements;
15007                    self.scratch_text_glyph_quads = generated_quads;
15008                    return Ok(false);
15009                }
15010                if text_glyph_run_diag_enabled() {
15011                    miss_cached_glyphs = collected_run
15012                        .iter()
15013                        .filter(|glyph| matches!(glyph, SoftwareGlyphAtlasRunGlyph::Cached(_)))
15014                        .count();
15015                    miss_new_glyphs = collected_run.len().saturating_sub(miss_cached_glyphs);
15016                }
15017                collected_placements.clear();
15018                collected_placements.extend(
15019                    collected_run
15020                        .iter()
15021                        .map(SoftwareGlyphAtlasRunGlyph::placement),
15022                );
15023                let glyphs: Rc<[SoftwareGlyphAtlasPlacement]> =
15024                    Rc::from(collected_placements.clone().into_boxed_slice());
15025                self.text_glyph_run_cache.put(
15026                    run_key,
15027                    CachedTextGlyphRun {
15028                        glyphs,
15029                        quads: None,
15030                        atlas_generation: 0,
15031                    },
15032                );
15033                None
15034            };
15035
15036            if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
15037                let prewarm_quads = if let Some(quad_run) = cached_quad_run {
15038                    quad_run
15039                } else {
15040                    let prepare_start = Instant::now();
15041                    match self.prepare_text_glyph_quads(
15042                        run_key,
15043                        atlas_generation,
15044                        cached_glyph_run.as_deref(),
15045                        &collected_run,
15046                        &mut generated_quads,
15047                    ) {
15048                        Ok(quads) => {
15049                            if let Some(collect_ms) = miss_collect_ms {
15050                                if text_glyph_run_diag_enabled() {
15051                                    log::warn!(
15052                                        "[text-glyph-run-diag] visible=false glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
15053                                        quads.len(),
15054                                        miss_cached_glyphs,
15055                                        miss_new_glyphs,
15056                                        collect_ms,
15057                                        instant_ms(prepare_start, Instant::now()),
15058                                    );
15059                                }
15060                            }
15061                            quads
15062                        }
15063                        Err(_) => continue,
15064                    }
15065                };
15066                #[cfg(not(target_arch = "wasm32"))]
15067                if should_use_retained_text_glyph_run(prewarm_quads.len(), source_draw.clip) {
15068                    self.ensure_retained_text_glyph_run(run_key, prewarm_quads.as_ref());
15069                }
15070                prewarmed_glyphs = prewarmed_glyphs.saturating_add(prewarm_quads.len());
15071                continue;
15072            }
15073
15074            let draw_rect = Rect {
15075                x: source_raster_rect.x / root_scale,
15076                y: source_raster_rect.y / root_scale,
15077                width: source_raster_rect.width / root_scale,
15078                height: source_raster_rect.height / root_scale,
15079            };
15080            let Some(scissor) = scissor_rect_for_layer(
15081                draw_rect,
15082                source_draw.clip,
15083                root_scale,
15084                viewport.width,
15085                viewport.height,
15086            ) else {
15087                continue;
15088            };
15089
15090            #[cfg(not(target_arch = "wasm32"))]
15091            if let Some(quad_run) = cached_quad_run.as_ref() {
15092                if should_use_retained_text_glyph_run(quad_run.len(), source_draw.clip)
15093                    && self.emit_retained_text_glyph_run_if_ready(
15094                        run_key,
15095                        quad_run.as_ref(),
15096                        source_draw.clip,
15097                        viewport,
15098                        source_raster_rect,
15099                        scissor,
15100                        staged_uploads,
15101                        glyph_cmds,
15102                    )
15103                {
15104                    emitted_glyphs = emitted_glyphs.saturating_add(quad_run.len());
15105                    continue;
15106                }
15107            }
15108
15109            let index_start = image_indices.len() as u32;
15110            if let Some(quad_run) = cached_quad_run {
15111                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
15112                    source_raster_rect,
15113                    quad_run.as_ref(),
15114                    source_draw.clip,
15115                    viewport,
15116                    root_scale,
15117                    image_vertices,
15118                    image_indices,
15119                    true,
15120                ));
15121            } else {
15122                let prepare_start = Instant::now();
15123                let Ok(quad_run) = self.prepare_text_glyph_quads(
15124                    run_key,
15125                    atlas_generation,
15126                    cached_glyph_run.as_deref(),
15127                    &collected_run,
15128                    &mut generated_quads,
15129                ) else {
15130                    image_vertices.truncate(initial_vertex_len);
15131                    image_indices.truncate(initial_index_len);
15132                    glyph_cmds.truncate(initial_cmd_len);
15133                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
15134                    self.scratch_text_glyph_run = collected_run;
15135                    self.scratch_text_glyph_placements = collected_placements;
15136                    self.scratch_text_glyph_quads = generated_quads;
15137                    return Ok(false);
15138                };
15139                if let Some(collect_ms) = miss_collect_ms {
15140                    if text_glyph_run_diag_enabled() {
15141                        log::warn!(
15142                            "[text-glyph-run-diag] visible=true glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
15143                            quad_run.len(),
15144                            miss_cached_glyphs,
15145                            miss_new_glyphs,
15146                            collect_ms,
15147                            instant_ms(prepare_start, Instant::now()),
15148                        );
15149                    }
15150                }
15151                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
15152                    source_raster_rect,
15153                    quad_run.as_ref(),
15154                    source_draw.clip,
15155                    viewport,
15156                    root_scale,
15157                    image_vertices,
15158                    image_indices,
15159                    false,
15160                ));
15161            }
15162            let index_count = image_indices.len() as u32 - index_start;
15163            if index_count > 0 {
15164                glyph_cmds.push(GlyphDrawCmd::shared(index_start, index_count, scissor));
15165            }
15166        }
15167
15168        self.scratch_text_glyph_run = collected_run;
15169        self.scratch_text_glyph_placements = collected_placements;
15170        self.scratch_text_glyph_quads = generated_quads;
15171        let append_end = Instant::now();
15172        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
15173            log::warn!(
15174                "[wgpu-render-stage:text-glyph-atlas] total_ms={total_ms:.2} visited={} cmds={} glyphs={} prewarmed={} run_hits={} run_misses={}",
15175                visited,
15176                glyph_cmds.len().saturating_sub(initial_cmd_len),
15177                emitted_glyphs,
15178                prewarmed_glyphs,
15179                run_hits,
15180                run_misses,
15181            );
15182        }
15183        Ok(true)
15184    }
15185
15186    #[cfg(not(target_arch = "wasm32"))]
15187    fn text_glyph_prewarm_decision(
15188        &self,
15189        text_draw: &TextDraw,
15190        viewport: ViewportUniformParams,
15191        root_scale: f32,
15192    ) -> TextGlyphPrewarmDecision {
15193        let Some((logical_rect, _, clip, _, static_text_motion)) =
15194            self.text_raster_geometry(text_draw, root_scale)
15195        else {
15196            return TextGlyphPrewarmDecision::MissingGeometry;
15197        };
15198        if !static_text_motion {
15199            return TextGlyphPrewarmDecision::DynamicMotion;
15200        }
15201        if text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
15202            return TextGlyphPrewarmDecision::Visible;
15203        }
15204        if text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale) {
15205            TextGlyphPrewarmDecision::Candidate
15206        } else {
15207            TextGlyphPrewarmDecision::OutsidePrewarmWindow
15208        }
15209    }
15210
15211    #[cfg(not(target_arch = "wasm32"))]
15212    #[allow(clippy::too_many_arguments)]
15213    fn prewarm_offscreen_text_glyph_draws_in_chunk(
15214        &mut self,
15215        ordered_items: &[(usize, SegmentDrawItem)],
15216        texts: &[TextDraw],
15217        chunk: &SegmentDrawChunkPlan,
15218        viewport: ViewportUniformParams,
15219        root_scale: f32,
15220        staged_uploads: &mut StagedBufferUploads,
15221        image_vertices: &mut Vec<Vertex>,
15222        image_indices: &mut Vec<u32>,
15223        glyph_cmds: &mut Vec<GlyphDrawCmd>,
15224    ) -> Result<(), String> {
15225        let prewarm_start = Instant::now();
15226        let diag_enabled = cranpose_core::env_flag!("CRANPOSE_TEXT_PREWARM_DIAG");
15227        let mut text_items = 0usize;
15228        let mut candidates = 0usize;
15229        let mut missing_geometry = 0usize;
15230        let mut dynamic_motion = 0usize;
15231        let mut visible = 0usize;
15232        let mut outside = 0usize;
15233        let mut already_prepared = 0usize;
15234        let mut admitted_candidates = 0usize;
15235        let mut skipped_unbounded = 0usize;
15236        let mut skipped_budget = 0usize;
15237        let initial_vertex_len = image_vertices.len();
15238        let initial_index_len = image_indices.len();
15239        let initial_cmd_len = glyph_cmds.len();
15240        let initial_staged_bytes_len = staged_uploads.bytes.len();
15241        let initial_staged_copies_len = staged_uploads.copies.len();
15242        'batches: for batch in chunk.iter() {
15243            let SegmentBatchPlan::Text { start, end } = batch else {
15244                continue;
15245            };
15246            for (_, item) in &ordered_items[start..end] {
15247                if offscreen_text_glyph_prewarm_budget_exhausted(prewarm_start, admitted_candidates)
15248                {
15249                    skipped_budget = skipped_budget.saturating_add(1);
15250                    break 'batches;
15251                }
15252                let SegmentDrawItem::Text(text_index) = item else {
15253                    return Err(format!(
15254                        "text prewarm batch contains non-text draw item: {item:?}"
15255                    ));
15256                };
15257                let Some(text_draw) = texts.get(*text_index) else {
15258                    continue;
15259                };
15260                text_items = text_items.saturating_add(1);
15261                match self.text_glyph_prewarm_decision(text_draw, viewport, root_scale) {
15262                    TextGlyphPrewarmDecision::Candidate => {}
15263                    TextGlyphPrewarmDecision::MissingGeometry => {
15264                        missing_geometry = missing_geometry.saturating_add(1);
15265                        continue;
15266                    }
15267                    TextGlyphPrewarmDecision::DynamicMotion => {
15268                        dynamic_motion = dynamic_motion.saturating_add(1);
15269                        continue;
15270                    }
15271                    TextGlyphPrewarmDecision::Visible => {
15272                        visible = visible.saturating_add(1);
15273                        continue;
15274                    }
15275                    TextGlyphPrewarmDecision::OutsidePrewarmWindow => {
15276                        outside = outside.saturating_add(1);
15277                        continue;
15278                    }
15279                }
15280
15281                candidates = candidates.saturating_add(1);
15282                let Some((_, raster_rect, _, text_scale, static_text_motion)) =
15283                    self.text_raster_geometry(text_draw, root_scale)
15284                else {
15285                    missing_geometry = missing_geometry.saturating_add(1);
15286                    continue;
15287                };
15288                let raster_source = text_glyph_raster_source(text_draw, raster_rect);
15289                let source_draw = raster_source.draw.as_ref();
15290                let run_key = Self::text_glyph_run_cache_key(
15291                    source_draw,
15292                    raster_source.raster_rect,
15293                    text_scale,
15294                    static_text_motion,
15295                );
15296                let atlas_generation = self.text_glyph_atlas.generation();
15297                let cached_glyphs = if let Some(cached) = self.text_glyph_run_cache.peek(&run_key) {
15298                    if cached.atlas_generation == atlas_generation && cached.quads.is_some() {
15299                        already_prepared = already_prepared.saturating_add(1);
15300                        continue;
15301                    }
15302                    Some(cached.glyphs.len())
15303                } else {
15304                    None
15305                };
15306                if !offscreen_text_glyph_prewarm_work_is_bounded(
15307                    cached_glyphs,
15308                    source_draw.text.text.len(),
15309                ) {
15310                    skipped_unbounded = skipped_unbounded.saturating_add(1);
15311                    continue;
15312                }
15313                admitted_candidates = admitted_candidates.saturating_add(1);
15314                self.append_text_glyph_draws(
15315                    std::iter::once(text_draw),
15316                    viewport,
15317                    root_scale,
15318                    true,
15319                    staged_uploads,
15320                    image_vertices,
15321                    image_indices,
15322                    glyph_cmds,
15323                )?;
15324                image_vertices.truncate(initial_vertex_len);
15325                image_indices.truncate(initial_index_len);
15326                glyph_cmds.truncate(initial_cmd_len);
15327                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
15328            }
15329        }
15330
15331        if diag_enabled && text_items > 0 {
15332            log::warn!(
15333                "[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}"
15334            );
15335        }
15336        if admitted_candidates > 0 {
15337            if let Some(total_ms) = should_log_wgpu_render_stage(prewarm_start, Instant::now()) {
15338                log::warn!(
15339                    "[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}"
15340                );
15341            }
15342        }
15343        Ok(())
15344    }
15345
15346    fn prepare_text_glyph_draw_cmds<'a, I>(
15347        &mut self,
15348        layer_texts: I,
15349        viewport: ViewportUniformParams,
15350        root_scale: f32,
15351        staged_uploads: &mut StagedBufferUploads,
15352    ) -> Result<Option<PreparedGlyphBatch>, String>
15353    where
15354        I: IntoIterator<Item = &'a TextDraw>,
15355    {
15356        #[cfg(target_arch = "wasm32")]
15357        let _ = staged_uploads;
15358
15359        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
15360        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
15361        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
15362        image_vertices.clear();
15363        image_indices.clear();
15364        glyph_cmds.clear();
15365
15366        if !self.append_text_glyph_draws(
15367            layer_texts,
15368            viewport,
15369            root_scale,
15370            false,
15371            staged_uploads,
15372            &mut image_vertices,
15373            &mut image_indices,
15374            &mut glyph_cmds,
15375        )? {
15376            self.scratch_image_vertices = image_vertices;
15377            self.scratch_image_indices = image_indices;
15378            self.scratch_glyph_cmds = glyph_cmds;
15379            return Ok(None);
15380        }
15381
15382        #[cfg(not(target_arch = "wasm32"))]
15383        if !image_indices.is_empty() {
15384            self.stage_native_image_buffers(
15385                staged_uploads,
15386                viewport,
15387                &image_vertices,
15388                &image_indices,
15389            );
15390        }
15391
15392        #[cfg(target_arch = "wasm32")]
15393        let image_slot = if glyph_cmds.is_empty() {
15394            0
15395        } else {
15396            let slot = self.claim_wasm_image_batch();
15397            {
15398                let buffers = &mut self.wasm_image_batches[slot];
15399                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
15400            }
15401            let buffers = &self.wasm_image_batches[slot];
15402            self.write_wasm_buffer(
15403                &buffers.vertex_buffer,
15404                bytemuck::cast_slice(&image_vertices),
15405            );
15406            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
15407            slot
15408        };
15409
15410        #[cfg(target_arch = "wasm32")]
15411        let uniform_slot = if glyph_cmds.is_empty() {
15412            0
15413        } else {
15414            self.prepare_wasm_viewport_uniforms(viewport)
15415        };
15416
15417        self.scratch_image_vertices = image_vertices;
15418        self.scratch_image_indices = image_indices;
15419        Ok(Some(PreparedGlyphBatch {
15420            cmds: glyph_cmds,
15421            #[cfg(target_arch = "wasm32")]
15422            image_slot,
15423            #[cfg(target_arch = "wasm32")]
15424            uniform_slot,
15425        }))
15426    }
15427
15428    #[allow(clippy::too_many_arguments)]
15429    fn append_image_bitmap_draw_cmd(
15430        &mut self,
15431        image: &ImageBitmap,
15432        rect: Rect,
15433        clip: Option<Rect>,
15434        sampling: ImageSampling,
15435        viewport: ViewportUniformParams,
15436        root_scale: f32,
15437        image_vertices: &mut Vec<Vertex>,
15438        image_indices: &mut Vec<u32>,
15439        image_cmds: &mut Vec<ImageDrawCmd>,
15440    ) -> Result<(), String> {
15441        if rect.width <= 0.0 || rect.height <= 0.0 {
15442            return Ok(());
15443        }
15444
15445        self.ensure_image_cached(image)?;
15446
15447        let (device_quad, scissor_rect) =
15448            if sampling == ImageSampling::Nearest && root_scale.is_finite() && root_scale > 0.0 {
15449                let left_px = (rect.x * root_scale).round();
15450                let top_px = (rect.y * root_scale).round();
15451                let width_px = (rect.width * root_scale).round().max(1.0);
15452                let height_px = (rect.height * root_scale).round().max(1.0);
15453                let snapped_rect = Rect {
15454                    x: left_px / root_scale,
15455                    y: top_px / root_scale,
15456                    width: width_px / root_scale,
15457                    height: height_px / root_scale,
15458                };
15459                let right_px = left_px + width_px;
15460                let bottom_px = top_px + height_px;
15461                (
15462                    [
15463                        [left_px, top_px],
15464                        [right_px, top_px],
15465                        [left_px, bottom_px],
15466                        [right_px, bottom_px],
15467                    ],
15468                    snapped_rect,
15469                )
15470            } else {
15471                (
15472                    rect_to_quad(rect).map(|[x, y]| [x * root_scale, y * root_scale]),
15473                    rect,
15474                )
15475            };
15476
15477        let Some(scissor) = scissor_rect_for_layer(
15478            scissor_rect,
15479            clip,
15480            root_scale,
15481            viewport.width,
15482            viewport.height,
15483        ) else {
15484            return Ok(());
15485        };
15486        let Some(uv_rect) = image_uv_rect(image, None) else {
15487            return Ok(());
15488        };
15489        #[cfg(not(target_arch = "wasm32"))]
15490        {
15491            if fill_area_diag_enabled() {
15492                self.fill_area_diag.add_image_quad(&device_quad);
15493            }
15494        }
15495
15496        let base_vertex = image_vertices.len() as u32;
15497        let index_start = image_indices.len() as u32;
15498        image_indices.extend_from_slice(&[
15499            base_vertex,
15500            base_vertex + 1,
15501            base_vertex + 2,
15502            base_vertex + 2,
15503            base_vertex + 1,
15504            base_vertex + 3,
15505        ]);
15506        let color = [1.0, 1.0, 1.0, 1.0];
15507        image_vertices.extend_from_slice(&[
15508            Vertex {
15509                position: device_quad[0],
15510                color,
15511                uv: [uv_rect.min[0], uv_rect.min[1]],
15512                uv_bounds: uv_rect.sample_bounds,
15513            },
15514            Vertex {
15515                position: device_quad[1],
15516                color,
15517                uv: [uv_rect.max[0], uv_rect.min[1]],
15518                uv_bounds: uv_rect.sample_bounds,
15519            },
15520            Vertex {
15521                position: device_quad[2],
15522                color,
15523                uv: [uv_rect.min[0], uv_rect.max[1]],
15524                uv_bounds: uv_rect.sample_bounds,
15525            },
15526            Vertex {
15527                position: device_quad[3],
15528                color,
15529                uv: [uv_rect.max[0], uv_rect.max[1]],
15530                uv_bounds: uv_rect.sample_bounds,
15531            },
15532        ]);
15533        image_cmds.push(ImageDrawCmd {
15534            index_start,
15535            scissor,
15536            image_id: image.id(),
15537            sampling,
15538        });
15539        Ok(())
15540    }
15541
15542    #[allow(clippy::too_many_arguments)]
15543    fn append_text_image_draw_cmds<'a, I>(
15544        &mut self,
15545        layer_texts: I,
15546        viewport: ViewportUniformParams,
15547        root_scale: f32,
15548        image_vertices: &mut Vec<Vertex>,
15549        image_indices: &mut Vec<u32>,
15550        image_cmds: &mut Vec<ImageDrawCmd>,
15551    ) -> Result<(), String>
15552    where
15553        I: Iterator<Item = &'a TextDraw>,
15554    {
15555        let append_start = Instant::now();
15556        let initial_len = image_cmds.len();
15557        let mut visited = 0usize;
15558        let mut hit_count = 0usize;
15559        let mut miss_count = 0usize;
15560        for text_draw in layer_texts {
15561            visited = visited.saturating_add(1);
15562            let _ = text_draw.node_id;
15563            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
15564                self.text_raster_geometry(text_draw, root_scale)
15565            else {
15566                continue;
15567            };
15568            if !text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
15569                continue;
15570            }
15571
15572            let raster_source = self.text_image_raster_source(
15573                text_draw,
15574                logical_rect,
15575                raster_rect,
15576                clip,
15577                root_scale,
15578                static_text_motion,
15579            );
15580            let source_draw = raster_source.draw.as_ref();
15581            let source_raster_rect = raster_source.raster_rect;
15582
15583            let cache_key = Self::text_image_cache_key(
15584                source_draw,
15585                source_raster_rect,
15586                text_scale,
15587                static_text_motion,
15588            );
15589            let image = if let Some(cached) = self.text_image_cache.get(&cache_key) {
15590                self.frame_stats
15591                    .record_text_image_cache_hit(cached.image.width(), cached.image.height());
15592                hit_count = hit_count.saturating_add(1);
15593                cached.image.clone()
15594            } else {
15595                let Some(image) =
15596                    self.rasterize_text_draw_to_image(source_draw, source_raster_rect, text_scale)
15597                else {
15598                    continue;
15599                };
15600                self.frame_stats
15601                    .record_text_image_cache_miss(image.width(), image.height());
15602                miss_count = miss_count.saturating_add(1);
15603                self.text_image_cache.put(
15604                    cache_key,
15605                    CachedTextImage {
15606                        image: image.clone(),
15607                    },
15608                );
15609                image
15610            };
15611
15612            let draw_origin = if static_text_motion {
15613                Point::new(
15614                    source_raster_rect.x / root_scale,
15615                    source_raster_rect.y / root_scale,
15616                )
15617            } else {
15618                Point::new(logical_rect.x, logical_rect.y)
15619            };
15620            let draw_rect = Rect {
15621                x: draw_origin.x,
15622                y: draw_origin.y,
15623                width: image.width() as f32 / root_scale,
15624                height: image.height() as f32 / root_scale,
15625            };
15626            self.append_image_bitmap_draw_cmd(
15627                &image,
15628                draw_rect,
15629                clip,
15630                ImageSampling::Nearest,
15631                viewport,
15632                root_scale,
15633                image_vertices,
15634                image_indices,
15635                image_cmds,
15636            )?;
15637        }
15638        let append_end = Instant::now();
15639        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
15640            log::warn!(
15641                "[wgpu-render-stage:text-images] total_ms={total_ms:.2} visited={} emitted={} hits={} misses={}",
15642                visited,
15643                image_cmds.len().saturating_sub(initial_len),
15644                hit_count,
15645                miss_count,
15646            );
15647        }
15648        Ok(())
15649    }
15650
15651    fn text_image_raster_source<'a>(
15652        &mut self,
15653        text_draw: &'a TextDraw,
15654        logical_rect: Rect,
15655        raster_rect: Rect,
15656        clip: Option<Rect>,
15657        root_scale: f32,
15658        static_text_motion: bool,
15659    ) -> TextRasterSource<'a> {
15660        let Some(clip) = clip else {
15661            return TextRasterSource {
15662                draw: Cow::Borrowed(text_draw),
15663                raster_rect,
15664            };
15665        };
15666        if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
15667            return TextRasterSource {
15668                draw: Cow::Borrowed(text_draw),
15669                raster_rect,
15670            };
15671        }
15672
15673        let line_starts = self.text_line_index_cache.line_starts(&text_draw.text);
15674        clipped_text_raster_source_with_line_starts(
15675            text_draw,
15676            logical_rect,
15677            raster_rect,
15678            clip,
15679            root_scale,
15680            line_starts.as_ref(),
15681        )
15682    }
15683
15684    fn prepare_text_image_draw_cmds<'a, I>(
15685        &mut self,
15686        layer_texts: I,
15687        viewport: ViewportUniformParams,
15688        root_scale: f32,
15689        staged_uploads: &mut StagedBufferUploads,
15690    ) -> Result<PreparedImageBatch, String>
15691    where
15692        I: Iterator<Item = &'a TextDraw>,
15693    {
15694        #[cfg(target_arch = "wasm32")]
15695        let _ = staged_uploads;
15696
15697        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
15698        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
15699        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
15700        image_vertices.clear();
15701        image_indices.clear();
15702        image_cmds.clear();
15703
15704        self.append_text_image_draw_cmds(
15705            layer_texts,
15706            viewport,
15707            root_scale,
15708            &mut image_vertices,
15709            &mut image_indices,
15710            &mut image_cmds,
15711        )?;
15712
15713        #[cfg(not(target_arch = "wasm32"))]
15714        if !image_cmds.is_empty() {
15715            self.stage_native_image_buffers(
15716                staged_uploads,
15717                viewport,
15718                &image_vertices,
15719                &image_indices,
15720            );
15721        }
15722
15723        #[cfg(target_arch = "wasm32")]
15724        let image_slot = if image_cmds.is_empty() {
15725            0
15726        } else {
15727            let slot = self.claim_wasm_image_batch();
15728            {
15729                let buffers = &mut self.wasm_image_batches[slot];
15730                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
15731            }
15732            let buffers = &self.wasm_image_batches[slot];
15733            self.write_wasm_buffer(
15734                &buffers.vertex_buffer,
15735                bytemuck::cast_slice(&image_vertices),
15736            );
15737            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
15738            slot
15739        };
15740
15741        #[cfg(target_arch = "wasm32")]
15742        let uniform_slot = if image_cmds.is_empty() {
15743            0
15744        } else {
15745            self.prepare_wasm_viewport_uniforms(viewport)
15746        };
15747
15748        self.scratch_image_vertices = image_vertices;
15749        self.scratch_image_indices = image_indices;
15750        Ok(PreparedImageBatch {
15751            cmds: image_cmds,
15752            #[cfg(target_arch = "wasm32")]
15753            image_slot,
15754            #[cfg(target_arch = "wasm32")]
15755            uniform_slot,
15756        })
15757    }
15758
15759    fn text_raster_geometry(
15760        &self,
15761        text_draw: &TextDraw,
15762        root_scale: f32,
15763    ) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
15764        text_raster_geometry_for_draw(text_draw, root_scale)
15765    }
15766
15767    fn text_image_cache_key(
15768        text_draw: &TextDraw,
15769        raster_rect: Rect,
15770        text_scale: f32,
15771        static_text_motion: bool,
15772    ) -> TextImageCacheKey {
15773        let mut state = default_hash::new();
15774        text_draw.text.render_hash().hash(&mut state);
15775        text_draw.text_style.render_hash().hash(&mut state);
15776        text_draw.color.render_hash().hash(&mut state);
15777        hash_text_raster_geometry_for_cache(raster_rect, static_text_motion, &mut state);
15778        text_draw.font_size.to_bits().hash(&mut state);
15779        text_scale.to_bits().hash(&mut state);
15780        text_draw.layout_options.hash(&mut state);
15781        TextImageCacheKey(state.finish())
15782    }
15783
15784    fn text_glyph_run_cache_key(
15785        text_draw: &TextDraw,
15786        raster_rect: Rect,
15787        text_scale: f32,
15788        static_text_motion: bool,
15789    ) -> TextGlyphRunCacheKey {
15790        TextGlyphRunCacheKey(
15791            Self::text_image_cache_key(text_draw, raster_rect, text_scale, static_text_motion).0,
15792        )
15793    }
15794
15795    fn rasterize_text_draw_to_image(
15796        &mut self,
15797        text_draw: &TextDraw,
15798        raster_rect: Rect,
15799        text_scale: f32,
15800    ) -> Option<ImageBitmap> {
15801        if text_draw.text.span_styles.is_empty() {
15802            let font = self.text_fonts.resolve(&text_draw.text_style)?;
15803            return rasterize_text_to_image_with_glyph_cache(
15804                text_draw.text.text.as_str(),
15805                raster_rect,
15806                &text_draw.text_style,
15807                text_draw.color,
15808                text_draw.font_size,
15809                text_scale,
15810                font,
15811                &mut self.text_glyph_mask_cache,
15812            );
15813        }
15814
15815        if let Some(image) = rasterize_annotated_text_to_image_with_glyph_cache(
15816            text_draw.text.as_ref(),
15817            raster_rect,
15818            &text_draw.text_style,
15819            text_draw.color,
15820            text_draw.font_size,
15821            text_scale,
15822            &self.text_fonts,
15823            &mut self.text_glyph_mask_cache,
15824        ) {
15825            return Some(image);
15826        }
15827
15828        rasterize_spanned_text_to_image(
15829            text_draw,
15830            raster_rect,
15831            text_scale,
15832            &self.text_fonts,
15833            &mut self.text_glyph_mask_cache,
15834        )
15835    }
15836}
15837
15838fn rasterize_spanned_text_to_image(
15839    text_draw: &TextDraw,
15840    raster_rect: Rect,
15841    text_scale: f32,
15842    fonts: &SoftwareTextFontSet,
15843    glyph_cache: &mut SoftwareGlyphRasterCache,
15844) -> Option<ImageBitmap> {
15845    let width = raster_rect.width.ceil().max(1.0) as u32;
15846    let height = raster_rect.height.ceil().max(1.0) as u32;
15847    let mut canvas = vec![0_u8; (width as usize) * (height as usize) * 4];
15848    let boundaries = text_draw.text.span_boundaries();
15849    let base_line_height = text_draw
15850        .text_style
15851        .resolve_line_height(14.0, text_draw.font_size)
15852        .max(1.0);
15853    let mut current_line_height = base_line_height;
15854    let mut cursor_x = raster_rect.x;
15855    let mut cursor_y = raster_rect.y;
15856
15857    for window in boundaries.windows(2) {
15858        let start = window[0];
15859        let end = window[1];
15860        if start == end {
15861            continue;
15862        }
15863
15864        let chunk = &text_draw.text.text[start..end];
15865        let mut merged_span = text_draw.text_style.span_style.clone();
15866        for span in &text_draw.text.span_styles {
15867            if span.range.start <= start && span.range.end >= end {
15868                merged_span = merged_span.merge(&span.item);
15869            }
15870        }
15871
15872        let mut chunk_style = text_draw.text_style.clone();
15873        chunk_style.span_style = merged_span;
15874
15875        for part in chunk.split_inclusive('\n') {
15876            let has_newline = part.ends_with('\n');
15877            let content = if has_newline {
15878                &part[..part.len().saturating_sub(1)]
15879            } else {
15880                part
15881            };
15882
15883            if !content.is_empty() {
15884                let chunk_font_size = chunk_style.resolve_font_size(text_draw.font_size);
15885                let Some(font) = fonts.resolve(&chunk_style) else {
15886                    continue;
15887                };
15888                let metrics = measure_text_with_font(content, &chunk_style, chunk_font_size, font);
15889                let segment_rect = Rect {
15890                    x: cursor_x,
15891                    y: cursor_y,
15892                    width: (metrics.width * text_scale).ceil().max(1.0),
15893                    height: (metrics.height * text_scale).ceil().max(1.0),
15894                };
15895                if let Some(segment_image) = rasterize_text_to_image_with_glyph_cache(
15896                    content,
15897                    segment_rect,
15898                    &chunk_style,
15899                    chunk_style.resolve_text_color(text_draw.color),
15900                    chunk_font_size,
15901                    text_scale,
15902                    font,
15903                    glyph_cache,
15904                ) {
15905                    composite_text_segment(
15906                        &mut canvas,
15907                        width,
15908                        height,
15909                        raster_rect,
15910                        segment_rect,
15911                        &segment_image,
15912                    );
15913                }
15914                cursor_x += metrics.width * text_scale;
15915                current_line_height = current_line_height.max(metrics.line_height.max(1.0));
15916            }
15917
15918            if has_newline {
15919                cursor_x = raster_rect.x;
15920                cursor_y += current_line_height * text_scale;
15921                current_line_height = base_line_height;
15922            }
15923        }
15924    }
15925
15926    ImageBitmap::from_rgba8(width, height, canvas).ok()
15927}
15928
15929struct TextRasterSource<'a> {
15930    draw: Cow<'a, TextDraw>,
15931    raster_rect: Rect,
15932}
15933
15934fn text_glyph_raster_source(text_draw: &TextDraw, raster_rect: Rect) -> TextRasterSource<'_> {
15935    TextRasterSource {
15936        draw: Cow::Borrowed(text_draw),
15937        raster_rect,
15938    }
15939}
15940
15941#[cfg(test)]
15942fn clipped_text_raster_source<'a>(
15943    text_draw: &'a TextDraw,
15944    logical_rect: Rect,
15945    raster_rect: Rect,
15946    clip: Option<Rect>,
15947    root_scale: f32,
15948    static_text_motion: bool,
15949) -> TextRasterSource<'a> {
15950    let Some(clip) = clip else {
15951        return TextRasterSource {
15952            draw: Cow::Borrowed(text_draw),
15953            raster_rect,
15954        };
15955    };
15956    if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
15957        return TextRasterSource {
15958            draw: Cow::Borrowed(text_draw),
15959            raster_rect,
15960        };
15961    }
15962    let line_starts = line_start_offsets(text_draw.text.text.as_str());
15963    clipped_text_raster_source_with_line_starts(
15964        text_draw,
15965        logical_rect,
15966        raster_rect,
15967        clip,
15968        root_scale,
15969        &line_starts,
15970    )
15971}
15972
15973fn clipped_text_raster_source_with_line_starts<'a>(
15974    text_draw: &'a TextDraw,
15975    logical_rect: Rect,
15976    raster_rect: Rect,
15977    clip: Rect,
15978    root_scale: f32,
15979    line_starts: &[usize],
15980) -> TextRasterSource<'a> {
15981    if line_starts.len() < MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER {
15982        return TextRasterSource {
15983            draw: Cow::Borrowed(text_draw),
15984            raster_rect,
15985        };
15986    }
15987
15988    let Some(visible_rect) = logical_rect.intersect(clip) else {
15989        return TextRasterSource {
15990            draw: Cow::Borrowed(text_draw),
15991            raster_rect,
15992        };
15993    };
15994
15995    let line_count = line_starts.len().max(1);
15996    let line_height = logical_rect.height / line_count as f32;
15997    if !line_height.is_finite() || line_height <= 0.0 {
15998        return TextRasterSource {
15999            draw: Cow::Borrowed(text_draw),
16000            raster_rect,
16001        };
16002    }
16003
16004    let visible_top = ((visible_rect.y - logical_rect.y) / line_height).floor() as isize;
16005    let visible_bottom =
16006        ((visible_rect.y + visible_rect.height - logical_rect.y) / line_height).ceil() as isize;
16007    let start_line = visible_top.saturating_sub(1).max(0) as usize;
16008    let end_line = (visible_bottom + 1).max(start_line as isize + 1) as usize;
16009    let end_line = end_line.min(line_count);
16010    if start_line == 0 && end_line >= line_count {
16011        return TextRasterSource {
16012            draw: Cow::Borrowed(text_draw),
16013            raster_rect,
16014        };
16015    }
16016
16017    let byte_start = line_starts[start_line];
16018    let byte_end = line_end_offset(text_draw.text.text.as_str(), line_starts, end_line - 1);
16019    if byte_start >= byte_end {
16020        return TextRasterSource {
16021            draw: Cow::Borrowed(text_draw),
16022            raster_rect,
16023        };
16024    }
16025
16026    let slice_y = logical_rect.y + start_line as f32 * line_height;
16027    let slice_height = (end_line - start_line) as f32 * line_height;
16028    let mut slice_raster_rect = Rect {
16029        x: logical_rect.x * root_scale,
16030        y: slice_y * root_scale,
16031        width: logical_rect.width * root_scale,
16032        height: slice_height * root_scale,
16033    };
16034    slice_raster_rect.x = slice_raster_rect.x.round();
16035    slice_raster_rect.y = slice_raster_rect.y.round();
16036    slice_raster_rect.width = slice_raster_rect.width.ceil().max(1.0);
16037    slice_raster_rect.height = slice_raster_rect.height.ceil().max(1.0);
16038
16039    let mut sliced_draw = text_draw.clone();
16040    sliced_draw.rect = Rect {
16041        x: logical_rect.x,
16042        y: slice_y,
16043        width: logical_rect.width,
16044        height: slice_height,
16045    };
16046    sliced_draw.text = Arc::new(text_draw.text.subsequence(byte_start..byte_end));
16047
16048    TextRasterSource {
16049        draw: Cow::Owned(sliced_draw),
16050        raster_rect: slice_raster_rect,
16051    }
16052}
16053
16054fn line_start_offsets(text: &str) -> Vec<usize> {
16055    let mut starts =
16056        Vec::with_capacity(text.as_bytes().iter().filter(|b| **b == b'\n').count() + 1);
16057    starts.push(0);
16058    starts.extend(
16059        text.char_indices()
16060            .filter_map(|(index, ch)| (ch == '\n').then_some(index + ch.len_utf8())),
16061    );
16062    starts
16063}
16064
16065fn line_end_offset(text: &str, line_starts: &[usize], line: usize) -> usize {
16066    line_starts.get(line + 1).copied().unwrap_or(text.len())
16067}
16068
16069fn composite_text_segment(
16070    canvas: &mut [u8],
16071    canvas_width: u32,
16072    canvas_height: u32,
16073    canvas_rect: Rect,
16074    segment_rect: Rect,
16075    segment_image: &ImageBitmap,
16076) {
16077    let offset_x = (segment_rect.x - canvas_rect.x).round() as i32;
16078    let offset_y = (segment_rect.y - canvas_rect.y).round() as i32;
16079    let src = segment_image.pixels();
16080    for sy in 0..segment_image.height() as i32 {
16081        let dy = offset_y + sy;
16082        if dy < 0 || dy >= canvas_height as i32 {
16083            continue;
16084        }
16085        for sx in 0..segment_image.width() as i32 {
16086            let dx = offset_x + sx;
16087            if dx < 0 || dx >= canvas_width as i32 {
16088                continue;
16089            }
16090            let src_index = ((sy as u32 * segment_image.width() + sx as u32) * 4) as usize;
16091            let dst_index = ((dy as u32 * canvas_width + dx as u32) * 4) as usize;
16092            blend_rgba_pixel(
16093                &mut canvas[dst_index..dst_index + 4],
16094                &src[src_index..src_index + 4],
16095            );
16096        }
16097    }
16098}
16099
16100fn blend_rgba_pixel(dst: &mut [u8], src: &[u8]) {
16101    let src_alpha = src[3] as f32 / 255.0;
16102    if src_alpha <= 0.0 {
16103        return;
16104    }
16105    let dst_alpha = dst[3] as f32 / 255.0;
16106    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
16107    if out_alpha <= f32::EPSILON {
16108        dst.copy_from_slice(&[0, 0, 0, 0]);
16109        return;
16110    }
16111
16112    for channel in 0..3 {
16113        let src_channel = src[channel] as f32 / 255.0;
16114        let dst_channel = dst[channel] as f32 / 255.0;
16115        let src_premult = src_channel * src_alpha;
16116        let dst_premult = dst_channel * dst_alpha;
16117        dst[channel] =
16118            (((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0) * 255.0)
16119                .round() as u8;
16120    }
16121    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
16122}
16123
16124fn align_to(value: u32, alignment: u32) -> u32 {
16125    debug_assert!(alignment > 0);
16126    value.div_ceil(alignment) * alignment
16127}
16128
16129#[cfg(not(target_arch = "wasm32"))]
16130fn align_usize_to(value: usize, alignment: usize) -> usize {
16131    debug_assert!(alignment > 0);
16132    value.div_ceil(alignment) * alignment
16133}
16134
16135impl GpuRenderer {
16136    fn convert_surface_pixels_to_rgba(&self, pixels: &[u8]) -> Result<Vec<u8>, String> {
16137        if !pixels.len().is_multiple_of(4) {
16138            return Err("Screenshot readback has an incomplete pixel".to_string());
16139        }
16140        Ok(pixels.to_vec())
16141    }
16142}
16143
16144fn is_in_effect_range(z_index: usize, effect_z_ranges: &[Range<usize>]) -> bool {
16145    effect_z_ranges.iter().any(|range| range.contains(&z_index))
16146}
16147
16148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16149enum SegmentDrawItem {
16150    Shape(usize),
16151    Image(usize),
16152    Text(usize),
16153    Shadow(usize),
16154    Composite(usize),
16155    ShaderComposite(usize),
16156    Retained(usize),
16157}
16158
16159#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16160enum SegmentBatchPlan {
16161    Shape {
16162        start: usize,
16163        end: usize,
16164        blend_mode: BlendMode,
16165    },
16166    Image {
16167        start: usize,
16168        end: usize,
16169        blend_mode: BlendMode,
16170    },
16171    Text {
16172        start: usize,
16173        end: usize,
16174    },
16175    Composite {
16176        start: usize,
16177        end: usize,
16178    },
16179    ShaderComposite {
16180        start: usize,
16181        end: usize,
16182    },
16183    /// Retained replay batches: each item is one bind + draw of GPU slots
16184    /// captured on an earlier frame, so they never merge and cost no budget.
16185    Retained {
16186        start: usize,
16187        end: usize,
16188    },
16189}
16190
16191#[derive(Clone, Debug, Default, PartialEq, Eq)]
16192struct SegmentDrawChunkPlan {
16193    batches: Vec<SegmentBatchPlan>,
16194}
16195
16196struct SegmentRenderOutcome {
16197    rendered_any: bool,
16198    pass_count: u32,
16199}
16200
16201struct SegmentCommandEncodeOutcome {
16202    first_batch: bool,
16203}
16204
16205#[cfg(not(target_arch = "wasm32"))]
16206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16207enum TextGlyphPrewarmDecision {
16208    Candidate,
16209    MissingGeometry,
16210    DynamicMotion,
16211    Visible,
16212    OutsidePrewarmWindow,
16213}
16214
16215#[cfg(not(target_arch = "wasm32"))]
16216#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16217struct NativeSegmentFusionBudget {
16218    shape_count: usize,
16219    gradient_stop_count: usize,
16220}
16221
16222#[cfg(not(target_arch = "wasm32"))]
16223#[derive(Clone, Debug, PartialEq, Eq)]
16224struct NativeSegmentFusionPartition {
16225    chunk: SegmentDrawChunkPlan,
16226    budget: NativeSegmentFusionBudget,
16227}
16228
16229#[cfg(not(target_arch = "wasm32"))]
16230#[derive(Clone, Debug, PartialEq, Eq)]
16231enum FusedSegmentBatch {
16232    Shape {
16233        batch: PreparedShapeBatch,
16234        blend_mode: BlendMode,
16235    },
16236    Image {
16237        cmd_range: Range<usize>,
16238        blend_mode: BlendMode,
16239    },
16240    Text {
16241        image_cmd_range: Range<usize>,
16242        glyph_cmd_range: Range<usize>,
16243    },
16244    Composite {
16245        draw_range: Range<usize>,
16246    },
16247    ShaderComposite {
16248        draw_range: Range<usize>,
16249    },
16250    Retained {
16251        item_range: Range<usize>,
16252    },
16253}
16254
16255struct ShadowSourceRenderOutcome {
16256    rendered_any: bool,
16257    pass_count: u32,
16258}
16259
16260/// One segment-surface capture this frame must encode: the entry's key,
16261/// the slot shape range, and the claimed per-frame capture slot (transform
16262/// stride + viewport-uniform slot index).
16263#[cfg(not(target_arch = "wasm32"))]
16264struct SegmentCaptureJob {
16265    key: SegmentSurfaceKey,
16266    first: u32,
16267    last: u32,
16268    capture_index: u32,
16269}
16270
16271/// One retained item's cached-composite plan: the dest quad (device px,
16272/// strip order TL TR BL BR) and the dest-px → source-texel inverse under
16273/// this frame's effective transform.
16274#[cfg(not(target_arch = "wasm32"))]
16275struct SegmentCompositePlan {
16276    key: SegmentSurfaceKey,
16277    dest_quad: [[f32; 2]; 4],
16278    inverse: [[f32; 3]; 3],
16279    identity: bool,
16280    integer_translation: bool,
16281}
16282
16283/// The capture rect's corners in capture space — also the dest quad under
16284/// an identity effective transform.
16285#[cfg(not(target_arch = "wasm32"))]
16286fn segment_identity_quad(rect: &CaptureRect) -> [[f32; 2]; 4] {
16287    let [x, y] = rect.origin;
16288    let width = rect.width as f32;
16289    let height = rect.height as f32;
16290    [
16291        [x, y],
16292        [x + width, y],
16293        [x, y + height],
16294        [x + width, y + height],
16295    ]
16296}
16297
16298/// Dest px → source texel for the identity case: a pure integer translate,
16299/// so `textureLoad` sampling is texel-exact.
16300#[cfg(not(target_arch = "wasm32"))]
16301fn segment_identity_inverse(rect: &CaptureRect) -> [[f32; 3]; 3] {
16302    [
16303        [1.0, 0.0, -rect.origin[0]],
16304        [0.0, 1.0, -rect.origin[1]],
16305        [0.0, 0.0, 1.0],
16306    ]
16307}
16308
16309/// Measures a shape range's capture geometry under `transform`: the padded
16310/// integer capture rect (None when degenerate or larger than the device
16311/// allows) and the member-quad pixel sum the economics gate prices the
16312/// direct path at (submitted-area scaled for arc-meshed slots).
16313#[cfg(not(target_arch = "wasm32"))]
16314fn plan_segment_capture_geometry(
16315    slot: &ReplaySlot,
16316    first: u32,
16317    last: u32,
16318    transform: SimilarityTransform,
16319    max_texture_dim: u32,
16320) -> Option<(CaptureRect, f32)> {
16321    let range = first as usize..last as usize;
16322    let aabbs = slot.shape_aabbs.get(range)?;
16323    if aabbs.is_empty() {
16324        return None;
16325    }
16326    let affine = Affine2::from_similarity(transform.center, transform.rot, transform.scale);
16327    let mut min = [f32::INFINITY; 2];
16328    let mut max = [f32::NEG_INFINITY; 2];
16329    for aabb in aabbs {
16330        for corner in [
16331            [aabb[0], aabb[1]],
16332            [aabb[2], aabb[1]],
16333            [aabb[0], aabb[3]],
16334            [aabb[2], aabb[3]],
16335        ] {
16336            let p = affine.apply(corner);
16337            min[0] = min[0].min(p[0]);
16338            min[1] = min[1].min(p[1]);
16339            max[0] = max[0].max(p[0]);
16340            max[1] = max[1].max(p[1]);
16341        }
16342    }
16343    let rect = crate::segment_surface::snap_capture_rect(min, max, max_texture_dim)?;
16344    let base_area = slot.area_prefix.get(last as usize).copied()?
16345        - slot.area_prefix.get(first as usize).copied()?;
16346    let member_px = base_area * transform.scale * transform.scale * slot.submitted_area_scale;
16347    Some((rect, member_px))
16348}
16349
16350impl SegmentDrawChunkPlan {
16351    fn is_empty(&self) -> bool {
16352        self.batches.is_empty()
16353    }
16354
16355    fn push(&mut self, batch: SegmentBatchPlan) {
16356        self.batches.push(batch);
16357    }
16358
16359    fn iter(&self) -> impl Iterator<Item = SegmentBatchPlan> + '_ {
16360        self.batches.iter().copied()
16361    }
16362}
16363
16364#[derive(Clone, Debug, PartialEq, Eq)]
16365enum SegmentRenderCommand {
16366    DrawChunk(SegmentDrawChunkPlan),
16367    Shadow(usize),
16368}
16369
16370struct SegmentCommandIter<'a> {
16371    ordered_items: &'a [(usize, SegmentDrawItem)],
16372    shapes: &'a [DrawShape],
16373    images: &'a [ImageDraw],
16374    cursor: usize,
16375    batch_limits: ShapeBatchLimits,
16376}
16377
16378impl<'a> SegmentCommandIter<'a> {
16379    fn new(
16380        ordered_items: &'a [(usize, SegmentDrawItem)],
16381        shapes: &'a [DrawShape],
16382        images: &'a [ImageDraw],
16383        batch_limits: ShapeBatchLimits,
16384    ) -> Self {
16385        Self {
16386            ordered_items,
16387            shapes,
16388            images,
16389            cursor: 0,
16390            batch_limits,
16391        }
16392    }
16393}
16394
16395impl Iterator for SegmentCommandIter<'_> {
16396    type Item = SegmentRenderCommand;
16397
16398    fn next(&mut self) -> Option<Self::Item> {
16399        if self.cursor >= self.ordered_items.len() {
16400            return None;
16401        }
16402
16403        if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
16404            self.cursor += 1;
16405            return Some(SegmentRenderCommand::Shadow(index));
16406        }
16407
16408        let mut chunk = SegmentDrawChunkPlan::default();
16409        while self.cursor < self.ordered_items.len() {
16410            if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
16411                if chunk.is_empty() {
16412                    self.cursor += 1;
16413                    return Some(SegmentRenderCommand::Shadow(index));
16414                }
16415                break;
16416            }
16417
16418            let Some((batch, next_cursor)) = segment_batch_plan_at_cursor(
16419                self.ordered_items,
16420                self.shapes,
16421                self.images,
16422                self.cursor,
16423                self.batch_limits,
16424            ) else {
16425                break;
16426            };
16427            chunk.push(batch);
16428            self.cursor = next_cursor;
16429        }
16430
16431        Some(SegmentRenderCommand::DrawChunk(chunk))
16432    }
16433}
16434
16435#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16436struct PreparedShapeBatch {
16437    /// First vertex and vertex count for the unindexed shape draw; always
16438    /// multiples of 6 so `vs_main`'s `vertex_index / 6` lands on whole shapes.
16439    vertex_start: u32,
16440    vertex_count: u32,
16441    /// Whether any shape in the batch carries gradient stops. False routes
16442    /// a SrcOver draw through the `fs_solid` pipeline.
16443    has_gradient: bool,
16444    #[cfg(target_arch = "wasm32")]
16445    shape_slot: usize,
16446    #[cfg(target_arch = "wasm32")]
16447    uniform_slot: usize,
16448}
16449
16450struct PreparedImageBatch {
16451    cmds: Vec<ImageDrawCmd>,
16452    #[cfg(target_arch = "wasm32")]
16453    image_slot: usize,
16454    #[cfg(target_arch = "wasm32")]
16455    uniform_slot: usize,
16456}
16457
16458impl PreparedImageBatch {
16459    fn is_empty(&self) -> bool {
16460        self.cmds.is_empty()
16461    }
16462
16463    fn into_cmds(self) -> Vec<ImageDrawCmd> {
16464        self.cmds
16465    }
16466}
16467
16468struct PreparedGlyphBatch {
16469    cmds: Vec<GlyphDrawCmd>,
16470    #[cfg(target_arch = "wasm32")]
16471    image_slot: usize,
16472    #[cfg(target_arch = "wasm32")]
16473    uniform_slot: usize,
16474}
16475
16476impl PreparedGlyphBatch {
16477    fn is_empty(&self) -> bool {
16478        self.cmds.is_empty()
16479    }
16480
16481    fn into_cmds(self) -> Vec<GlyphDrawCmd> {
16482        self.cmds
16483    }
16484}
16485
16486#[cfg(not(target_arch = "wasm32"))]
16487fn gradient_stop_count_for_shape(shape: &DrawShape, brushes: &[Brush]) -> usize {
16488    match shape.brush {
16489        SceneBrush::Solid(_) => 0,
16490        SceneBrush::Gradient(index) => match &brushes[index as usize] {
16491            Brush::Solid(_) => 0,
16492            Brush::LinearGradient { colors, .. }
16493            | Brush::RadialGradient { colors, .. }
16494            | Brush::SweepGradient { colors, .. } => colors.len(),
16495        },
16496    }
16497}
16498
16499#[cfg(not(target_arch = "wasm32"))]
16500fn native_segment_fusion_budget(
16501    ordered_items: &[(usize, SegmentDrawItem)],
16502    shapes: &[DrawShape],
16503    brushes: &[Brush],
16504    chunk: &SegmentDrawChunkPlan,
16505    batch_limits: ShapeBatchLimits,
16506) -> Result<Option<NativeSegmentFusionBudget>, String> {
16507    let mut shape_count = 0usize;
16508    let mut gradient_stop_count = 0usize;
16509
16510    for batch in chunk.iter() {
16511        let SegmentBatchPlan::Shape { start, end, .. } = batch else {
16512            continue;
16513        };
16514        for (_, item) in &ordered_items[start..end] {
16515            let SegmentDrawItem::Shape(shape_index) = item else {
16516                return Err(format!(
16517                    "shape batch contains non-shape draw item: {item:?}"
16518                ));
16519            };
16520            let shape = &shapes[*shape_index];
16521            shape_count = shape_count.saturating_add(1);
16522            gradient_stop_count =
16523                gradient_stop_count.saturating_add(gradient_stop_count_for_shape(shape, brushes));
16524        }
16525    }
16526
16527    if shape_count > batch_limits.max_shapes_per_batch
16528        || gradient_stop_count > batch_limits.max_gradient_stops
16529    {
16530        return Ok(None);
16531    }
16532
16533    Ok(Some(NativeSegmentFusionBudget {
16534        shape_count,
16535        gradient_stop_count,
16536    }))
16537}
16538
16539#[cfg(not(target_arch = "wasm32"))]
16540fn push_native_segment_fusion_partition(
16541    partitions: &mut Vec<NativeSegmentFusionPartition>,
16542    current: &mut SegmentDrawChunkPlan,
16543    current_budget: &mut NativeSegmentFusionBudget,
16544) {
16545    if current.is_empty() {
16546        return;
16547    }
16548
16549    partitions.push(NativeSegmentFusionPartition {
16550        chunk: std::mem::take(current),
16551        budget: *current_budget,
16552    });
16553    *current_budget = NativeSegmentFusionBudget {
16554        shape_count: 0,
16555        gradient_stop_count: 0,
16556    };
16557}
16558
16559#[cfg(not(target_arch = "wasm32"))]
16560fn native_segment_fusion_partitions(
16561    ordered_items: &[(usize, SegmentDrawItem)],
16562    shapes: &[DrawShape],
16563    brushes: &[Brush],
16564    chunk: &SegmentDrawChunkPlan,
16565    batch_limits: ShapeBatchLimits,
16566) -> Result<Option<Vec<NativeSegmentFusionPartition>>, String> {
16567    if let Some(budget) =
16568        native_segment_fusion_budget(ordered_items, shapes, brushes, chunk, batch_limits)?
16569    {
16570        return Ok(Some(vec![NativeSegmentFusionPartition {
16571            chunk: chunk.clone(),
16572            budget,
16573        }]));
16574    }
16575
16576    let mut partitions = Vec::new();
16577    let mut current = SegmentDrawChunkPlan::default();
16578    let mut current_budget = NativeSegmentFusionBudget {
16579        shape_count: 0,
16580        gradient_stop_count: 0,
16581    };
16582
16583    for batch in chunk.iter() {
16584        let SegmentBatchPlan::Shape {
16585            start,
16586            end,
16587            blend_mode,
16588        } = batch
16589        else {
16590            current.push(batch);
16591            continue;
16592        };
16593
16594        let mut run_start = start;
16595        for (item_cursor, (_, item)) in ordered_items.iter().enumerate().take(end).skip(start) {
16596            let SegmentDrawItem::Shape(shape_index) = *item else {
16597                return Err(format!(
16598                    "shape batch contains non-shape draw item: {:?}",
16599                    item
16600                ));
16601            };
16602            let gradient_stop_count = gradient_stop_count_for_shape(&shapes[shape_index], brushes);
16603            if gradient_stop_count > batch_limits.max_gradient_stops {
16604                return Ok(None);
16605            }
16606
16607            let fits_shape_count =
16608                current_budget.shape_count.saturating_add(1) <= batch_limits.max_shapes_per_batch;
16609            let fits_gradient_count = current_budget
16610                .gradient_stop_count
16611                .saturating_add(gradient_stop_count)
16612                <= batch_limits.max_gradient_stops;
16613            if !fits_shape_count || !fits_gradient_count {
16614                if run_start < item_cursor {
16615                    current.push(SegmentBatchPlan::Shape {
16616                        start: run_start,
16617                        end: item_cursor,
16618                        blend_mode,
16619                    });
16620                }
16621                push_native_segment_fusion_partition(
16622                    &mut partitions,
16623                    &mut current,
16624                    &mut current_budget,
16625                );
16626                run_start = item_cursor;
16627            }
16628
16629            current_budget.shape_count = current_budget.shape_count.saturating_add(1);
16630            current_budget.gradient_stop_count = current_budget
16631                .gradient_stop_count
16632                .saturating_add(gradient_stop_count);
16633        }
16634
16635        if run_start < end {
16636            current.push(SegmentBatchPlan::Shape {
16637                start: run_start,
16638                end,
16639                blend_mode,
16640            });
16641        }
16642    }
16643
16644    push_native_segment_fusion_partition(&mut partitions, &mut current, &mut current_budget);
16645    Ok(Some(partitions))
16646}
16647
16648fn segment_batch_plan_at_cursor(
16649    ordered_items: &[(usize, SegmentDrawItem)],
16650    shapes: &[DrawShape],
16651    images: &[ImageDraw],
16652    start: usize,
16653    batch_limits: ShapeBatchLimits,
16654) -> Option<(SegmentBatchPlan, usize)> {
16655    match ordered_items[start].1 {
16656        SegmentDrawItem::Shape(index) => {
16657            let blend_mode = supported_blend_mode(shapes[index].blend_mode);
16658            let mut end = start + 1;
16659            let shape_limit = (start + batch_limits.max_shapes_per_batch).min(ordered_items.len());
16660            while end < shape_limit {
16661                match ordered_items[end].1 {
16662                    SegmentDrawItem::Shape(next_index)
16663                        if supported_blend_mode(shapes[next_index].blend_mode) == blend_mode =>
16664                    {
16665                        end += 1;
16666                    }
16667                    _ => break,
16668                }
16669            }
16670            Some((
16671                SegmentBatchPlan::Shape {
16672                    start,
16673                    end,
16674                    blend_mode,
16675                },
16676                end,
16677            ))
16678        }
16679        SegmentDrawItem::Image(index) => {
16680            let blend_mode = supported_blend_mode(images[index].blend_mode);
16681            let mut end = start + 1;
16682            while end < ordered_items.len() {
16683                match ordered_items[end].1 {
16684                    SegmentDrawItem::Image(next_index)
16685                        if supported_blend_mode(images[next_index].blend_mode) == blend_mode =>
16686                    {
16687                        end += 1;
16688                    }
16689                    _ => break,
16690                }
16691            }
16692            Some((
16693                SegmentBatchPlan::Image {
16694                    start,
16695                    end,
16696                    blend_mode,
16697                },
16698                end,
16699            ))
16700        }
16701        SegmentDrawItem::Text(_) => {
16702            let mut end = start + 1;
16703            while end < ordered_items.len() {
16704                if matches!(ordered_items[end].1, SegmentDrawItem::Text(_)) {
16705                    end += 1;
16706                } else {
16707                    break;
16708                }
16709            }
16710            Some((SegmentBatchPlan::Text { start, end }, end))
16711        }
16712        SegmentDrawItem::Composite(_) => {
16713            let mut end = start + 1;
16714            while end < ordered_items.len() {
16715                if matches!(ordered_items[end].1, SegmentDrawItem::Composite(_)) {
16716                    end += 1;
16717                } else {
16718                    break;
16719                }
16720            }
16721            Some((SegmentBatchPlan::Composite { start, end }, end))
16722        }
16723        SegmentDrawItem::ShaderComposite(_) => {
16724            let mut end = start + 1;
16725            while end < ordered_items.len() {
16726                if matches!(ordered_items[end].1, SegmentDrawItem::ShaderComposite(_)) {
16727                    end += 1;
16728                } else {
16729                    break;
16730                }
16731            }
16732            Some((SegmentBatchPlan::ShaderComposite { start, end }, end))
16733        }
16734        SegmentDrawItem::Retained(_) => {
16735            let mut end = start + 1;
16736            while end < ordered_items.len() {
16737                if matches!(ordered_items[end].1, SegmentDrawItem::Retained(_)) {
16738                    end += 1;
16739                } else {
16740                    break;
16741                }
16742            }
16743            Some((SegmentBatchPlan::Retained { start, end }, end))
16744        }
16745        SegmentDrawItem::Shadow(_) => None,
16746    }
16747}
16748
16749#[allow(clippy::too_many_arguments)]
16750fn collect_non_effect_segment_items(
16751    shapes: &[DrawShape],
16752    _images: &[ImageDraw],
16753    _texts: &[TextDraw],
16754    _shadow_draws: &[ShadowDraw],
16755    draw_ops: &[DrawOp],
16756    z_start: usize,
16757    z_end: usize,
16758    effect_z_ranges: &[Range<usize>],
16759    width: u32,
16760    height: u32,
16761    root_scale: f32,
16762    scratch: &mut Vec<(usize, SegmentDrawItem)>,
16763) {
16764    scratch.clear();
16765    let viewport = ViewportUniformParams {
16766        width,
16767        height,
16768        offset: [0.0, 0.0],
16769    };
16770
16771    scratch.extend(draw_ops.iter().filter_map(|op| {
16772        if op.z_index < z_start
16773            || op.z_index >= z_end
16774            || is_in_effect_range(op.z_index, effect_z_ranges)
16775        {
16776            return None;
16777        }
16778        let item = match op.kind {
16779            DrawOpKind::Shape(index) => {
16780                let shape = shapes.get(index)?;
16781                if !shape_draw_is_visible_in_viewport(shape, viewport, root_scale) {
16782                    return None;
16783                }
16784                SegmentDrawItem::Shape(index)
16785            }
16786            DrawOpKind::Image(index) => SegmentDrawItem::Image(index),
16787            DrawOpKind::Text(index) => SegmentDrawItem::Text(index),
16788            DrawOpKind::Shadow(index) => SegmentDrawItem::Shadow(index),
16789            DrawOpKind::Retained(index) => SegmentDrawItem::Retained(index),
16790        };
16791        Some((op.z_index, item))
16792    }));
16793}
16794
16795fn retain_renderable_shadow_items(
16796    ordered_items: &mut Vec<(usize, SegmentDrawItem)>,
16797    shadow_draws: &[ShadowDraw],
16798    width: u32,
16799    height: u32,
16800    root_scale: f32,
16801    max_texture_dim: u32,
16802) -> usize {
16803    let original_len = ordered_items.len();
16804    ordered_items.retain(|(_, item)| match item {
16805        SegmentDrawItem::Shadow(index) => shadow_draws.get(*index).is_some_and(|shadow| {
16806            shadow_draw_may_render(shadow, width, height, root_scale, max_texture_dim)
16807        }),
16808        _ => true,
16809    });
16810    original_len.saturating_sub(ordered_items.len())
16811}
16812
16813#[cfg(not(target_arch = "wasm32"))]
16814#[derive(Clone, Copy)]
16815struct SegmentDiagCounts {
16816    raw_shadow_items: usize,
16817    culled_shadow_items: usize,
16818    cached_shadow_composites: usize,
16819    composite_items: usize,
16820    shader_composite_items: usize,
16821}
16822
16823#[cfg(not(target_arch = "wasm32"))]
16824fn maybe_print_segment_diag(
16825    z_range: Range<usize>,
16826    ordered_items: &[(usize, SegmentDrawItem)],
16827    shapes: &[DrawShape],
16828    brushes: &[Brush],
16829    images: &[ImageDraw],
16830    counts: SegmentDiagCounts,
16831    batch_limits: ShapeBatchLimits,
16832) {
16833    if !cranpose_core::env_flag!("CRANPOSE_SEGMENT_DIAG") {
16834        return;
16835    }
16836    let line = SEGMENT_DIAG_LINES.fetch_add(1, Ordering::Relaxed);
16837    if line >= 64 {
16838        return;
16839    }
16840
16841    let remaining_shadow_items = ordered_items
16842        .iter()
16843        .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
16844        .count();
16845    let commands: Vec<_> =
16846        SegmentCommandIter::new(ordered_items, shapes, images, batch_limits).collect();
16847    let draw_chunks = commands
16848        .iter()
16849        .filter(|command| matches!(command, SegmentRenderCommand::DrawChunk(_)))
16850        .count();
16851    let shadow_commands = commands
16852        .iter()
16853        .filter(|command| matches!(command, SegmentRenderCommand::Shadow(_)))
16854        .count();
16855    let mut native_partitions = 0usize;
16856    let mut native_unfused_chunks = 0usize;
16857    for command in &commands {
16858        let SegmentRenderCommand::DrawChunk(chunk) = command else {
16859            continue;
16860        };
16861        match native_segment_fusion_partitions(ordered_items, shapes, brushes, chunk, batch_limits)
16862        {
16863            Ok(Some(partitions)) => native_partitions += partitions.len(),
16864            Ok(None) | Err(_) => native_unfused_chunks += 1,
16865        }
16866    }
16867
16868    eprintln!(
16869        "[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={}",
16870        z_range.start,
16871        z_range.end,
16872        ordered_items.len(),
16873        counts.raw_shadow_items,
16874        counts.culled_shadow_items,
16875        counts.cached_shadow_composites,
16876        remaining_shadow_items,
16877        counts.composite_items,
16878        counts.shader_composite_items,
16879        draw_chunks,
16880        shadow_commands,
16881        native_partitions,
16882        native_unfused_chunks,
16883    );
16884}
16885
16886pub(crate) fn has_backdrop_layer_in_range(
16887    backdrop_layers: &[BackdropLayer],
16888    z_start: usize,
16889    z_end: usize,
16890) -> bool {
16891    backdrop_layers
16892        .iter()
16893        .any(|layer| layer.z_index >= z_start && layer.z_index < z_end)
16894}
16895
16896pub(crate) fn scissor_rect_for_rect(
16897    rect: Rect,
16898    root_scale: f32,
16899    width: u32,
16900    height: u32,
16901) -> Option<(u32, u32, u32, u32)> {
16902    let mut left = canonicalize_device_coordinate(rect.x * root_scale);
16903    let mut top = canonicalize_device_coordinate(rect.y * root_scale);
16904    let mut right = canonicalize_device_coordinate((rect.x + rect.width) * root_scale);
16905    let mut bottom = canonicalize_device_coordinate((rect.y + rect.height) * root_scale);
16906
16907    left = left.max(0.0).min(width as f32).floor();
16908    top = top.max(0.0).min(height as f32).floor();
16909    right = right.max(0.0).min(width as f32).ceil();
16910    bottom = bottom.max(0.0).min(height as f32).ceil();
16911
16912    if right <= left || bottom <= top {
16913        return None;
16914    }
16915
16916    Some((
16917        left as u32,
16918        top as u32,
16919        (right - left) as u32,
16920        (bottom - top) as u32,
16921    ))
16922}
16923
16924fn scissor_rect_for_layer(
16925    rect: Rect,
16926    clip: Option<Rect>,
16927    root_scale: f32,
16928    width: u32,
16929    height: u32,
16930) -> Option<(u32, u32, u32, u32)> {
16931    let clipped_rect = match clip {
16932        Some(clip_rect) => rect.intersect(clip_rect)?,
16933        None => rect,
16934    };
16935
16936    scissor_rect_for_rect(clipped_rect, root_scale, width, height)
16937}
16938
16939fn tint_for_image(
16940    color_filter: Option<ColorFilter>,
16941    alpha: f32,
16942) -> ([f32; 4], Option<ColorFilter>) {
16943    let alpha = alpha.clamp(0.0, 1.0);
16944    match color_filter {
16945        Some(filter) if filter.supports_gpu_vertex_modulation() => {
16946            let Some(tint) = filter.gpu_vertex_tint() else {
16947                return ([1.0, 1.0, 1.0, alpha], Some(filter));
16948            };
16949            (
16950                [
16951                    tint[0].clamp(0.0, 1.0),
16952                    tint[1].clamp(0.0, 1.0),
16953                    tint[2].clamp(0.0, 1.0),
16954                    (tint[3] * alpha).clamp(0.0, 1.0),
16955                ],
16956                None,
16957            )
16958        }
16959        Some(filter) => ([1.0, 1.0, 1.0, alpha], Some(filter)),
16960        None => ([1.0, 1.0, 1.0, alpha], None),
16961    }
16962}
16963
16964fn image_uv_rect(image: &ImageBitmap, src_rect: Option<Rect>) -> Option<ImageUvRect> {
16965    let Some(src) = src_rect else {
16966        return Some(ImageUvRect {
16967            min: [0.0, 0.0],
16968            max: [1.0, 1.0],
16969            sample_bounds: [0.0, 0.0, 1.0, 1.0],
16970        });
16971    };
16972
16973    let (u_min, u_max, u_bound_min, u_bound_max) =
16974        source_axis_uv(src.x, src.width, image.width() as f32)?;
16975    let (v_min, v_max, v_bound_min, v_bound_max) =
16976        source_axis_uv(src.y, src.height, image.height() as f32)?;
16977
16978    Some(ImageUvRect {
16979        min: [u_min, v_min],
16980        max: [u_max, v_max],
16981        sample_bounds: [u_bound_min, v_bound_min, u_bound_max, v_bound_max],
16982    })
16983}
16984
16985/// Normalises an atlas entry against `atlas_size`, the side length of the
16986/// texture the entry was placed in. The atlas grows on overflow, so the size
16987/// has to be read from the live atlas rather than a constant — a UV computed
16988/// against the wrong size samples the wrong glyph.
16989fn glyph_atlas_uv_rect(entry: GlyphAtlasEntry, atlas_size: u32) -> ImageUvRect {
16990    let atlas_width = atlas_size as f32;
16991    let atlas_height = atlas_size as f32;
16992    let min = [entry.x as f32 / atlas_width, entry.y as f32 / atlas_height];
16993    let max = [
16994        (entry.x + entry.width) as f32 / atlas_width,
16995        (entry.y + entry.height) as f32 / atlas_height,
16996    ];
16997    let center_min = [
16998        (entry.x as f32 + 0.5) / atlas_width,
16999        (entry.y as f32 + 0.5) / atlas_height,
17000    ];
17001    let center_max = [
17002        (entry.x as f32 + entry.width as f32 - 0.5).max(entry.x as f32 + 0.5) / atlas_width,
17003        (entry.y as f32 + entry.height as f32 - 0.5).max(entry.y as f32 + 0.5) / atlas_height,
17004    ];
17005    ImageUvRect {
17006        min,
17007        max,
17008        sample_bounds: [center_min[0], center_min[1], center_max[0], center_max[1]],
17009    }
17010}
17011
17012fn snap_nearest_image_to_device_pixels(image: &mut ImageDraw, root_scale: f32) {
17013    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
17014        return;
17015    }
17016
17017    let Some(rect) = axis_aligned_quad_rect(image.quad) else {
17018        return;
17019    };
17020
17021    let left_px = (rect.x * root_scale).round();
17022    let top_px = (rect.y * root_scale).round();
17023    let width_px = (rect.width * root_scale).round().max(1.0);
17024    let height_px = (rect.height * root_scale).round().max(1.0);
17025    let snapped = Rect {
17026        x: left_px / root_scale,
17027        y: top_px / root_scale,
17028        width: width_px / root_scale,
17029        height: height_px / root_scale,
17030    };
17031
17032    image.rect = snapped;
17033    image.local_rect = Rect {
17034        x: image.local_rect.x + snapped.x - rect.x,
17035        y: image.local_rect.y + snapped.y - rect.y,
17036        width: snapped.width,
17037        height: snapped.height,
17038    };
17039    image.quad = crate::rect_to_quad(snapped);
17040}
17041
17042fn nearest_image_device_quad(image: &ImageDraw, root_scale: f32) -> Option<[[f32; 2]; 4]> {
17043    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
17044        return None;
17045    }
17046
17047    let rect = axis_aligned_quad_rect(image.quad)?;
17048    let left_px = (rect.x * root_scale).round();
17049    let top_px = (rect.y * root_scale).round();
17050    let width_px = (rect.width * root_scale).round().max(1.0);
17051    let height_px = (rect.height * root_scale).round().max(1.0);
17052    let right_px = left_px + width_px;
17053    let bottom_px = top_px + height_px;
17054    Some([
17055        [left_px, top_px],
17056        [right_px, top_px],
17057        [left_px, bottom_px],
17058        [right_px, bottom_px],
17059    ])
17060}
17061
17062fn source_axis_uv(start: f32, extent: f32, image_extent: f32) -> Option<(f32, f32, f32, f32)> {
17063    if !start.is_finite()
17064        || !extent.is_finite()
17065        || !image_extent.is_finite()
17066        || extent == 0.0
17067        || image_extent <= 0.0
17068    {
17069        return None;
17070    }
17071
17072    let end = start + extent;
17073    let edge_min = start.min(end).clamp(0.0, image_extent);
17074    let edge_max = start.max(end).clamp(0.0, image_extent);
17075    if edge_max <= edge_min {
17076        return None;
17077    }
17078
17079    let center_min = edge_min + 0.5;
17080    let center_max = edge_max - 0.5;
17081    let (bound_min, bound_max) = if center_min <= center_max {
17082        (center_min, center_max)
17083    } else {
17084        let center = (edge_min + edge_max) * 0.5;
17085        (center, center)
17086    };
17087
17088    Some((
17089        edge_min / image_extent,
17090        edge_max / image_extent,
17091        bound_min / image_extent,
17092        bound_max / image_extent,
17093    ))
17094}
17095
17096fn apply_filter_to_bitmap(image: &ImageBitmap, filter: ColorFilter) -> Result<ImageBitmap, String> {
17097    let mut filtered = Vec::with_capacity(image.pixels().len());
17098    for pixel in image.pixels().as_chunks::<4>().0 {
17099        let rgba = [
17100            pixel[0] as f32 / 255.0,
17101            pixel[1] as f32 / 255.0,
17102            pixel[2] as f32 / 255.0,
17103            pixel[3] as f32 / 255.0,
17104        ];
17105        let out = filter.apply_rgba(rgba);
17106        filtered.push((out[0].clamp(0.0, 1.0) * 255.0).round() as u8);
17107        filtered.push((out[1].clamp(0.0, 1.0) * 255.0).round() as u8);
17108        filtered.push((out[2].clamp(0.0, 1.0) * 255.0).round() as u8);
17109        filtered.push((out[3].clamp(0.0, 1.0) * 255.0).round() as u8);
17110    }
17111    ImageBitmap::from_rgba8(image.width(), image.height(), filtered)
17112        .map_err(|error| format!("failed to build filtered bitmap: {error}"))
17113}
17114
17115fn scissor_rect_for_image(
17116    image: &ImageDraw,
17117    root_scale: f32,
17118    width: u32,
17119    height: u32,
17120) -> Option<(u32, u32, u32, u32)> {
17121    scissor_rect_for_layer(image.rect, image.clip, root_scale, width, height)
17122}
17123
17124fn inner_shadow_composite_mask(
17125    shadow: &ShadowDraw,
17126    root_scale: f32,
17127) -> Option<RoundedCompositeMask> {
17128    if !shadow
17129        .shapes
17130        .iter()
17131        .any(|(_, mode)| *mode == BlendMode::DstOut)
17132    {
17133        return None;
17134    }
17135    let (fill, _) = shadow.shapes.first()?;
17136    let rect = fill.local_rect;
17137    if rect.width <= 0.0 || rect.height <= 0.0 {
17138        return None;
17139    }
17140
17141    let radii = fill.shape.map_or([0.0; 4], |rounded| {
17142        let resolved = rounded.resolve(rect.width, rect.height);
17143        [
17144            resolved.top_left * root_scale,
17145            resolved.top_right * root_scale,
17146            resolved.bottom_left * root_scale,
17147            resolved.bottom_right * root_scale,
17148        ]
17149    });
17150
17151    Some(RoundedCompositeMask {
17152        rect: [
17153            rect.x * root_scale,
17154            rect.y * root_scale,
17155            rect.width * root_scale,
17156            rect.height * root_scale,
17157        ],
17158        radii,
17159    })
17160}
17161
17162#[cfg(test)]
17163mod shape_batch_limits_tests {
17164    use super::*;
17165
17166    /// A device that reports plenty of storage buffers, as ARM's GLES driver
17167    /// does off the fragment stage.
17168    fn generous_limits() -> wgpu::Limits {
17169        wgpu::Limits {
17170            max_storage_buffers_per_shader_stage: 8,
17171            max_storage_buffer_binding_size: 128 << 20,
17172            max_uniform_buffer_binding_size: 16 << 10,
17173            ..wgpu::Limits::default()
17174        }
17175    }
17176
17177    #[test]
17178    fn a_device_without_vertex_storage_takes_the_uniform_path() {
17179        // The shape array is bound VERTEX_FRAGMENT because `vs_main` reads
17180        // quad corners out of it, so a device that cannot read storage from
17181        // the vertex stage cannot host the storage layout AT ALL -- creating
17182        // it is a validation error and wgpu makes that fatal. The limit alone
17183        // says nothing about it: Mali reports 8 here and zero vertex storage.
17184        let limits = ShapeBatchLimits::select(&generous_limits(), wgpu::DownlevelFlags::empty());
17185        assert!(
17186            !limits.storage,
17187            "no VERTEX_STORAGE must mean uniform mode, whatever the limit says"
17188        );
17189    }
17190
17191    #[test]
17192    fn a_device_with_vertex_storage_still_takes_the_storage_path() {
17193        let limits = ShapeBatchLimits::select(&generous_limits(), wgpu::DownlevelFlags::all());
17194        assert!(
17195            limits.storage,
17196            "the flag must not cost storage mode on a device that has it"
17197        );
17198    }
17199
17200    #[test]
17201    fn the_limit_still_gates_storage_when_the_flag_is_present() {
17202        let mut limits = generous_limits();
17203        limits.max_storage_buffers_per_shader_stage = 1;
17204        let limits = ShapeBatchLimits::select(&limits, wgpu::DownlevelFlags::all());
17205        assert!(!limits.storage, "two bindings are needed, not one");
17206    }
17207}
17208
17209#[cfg(test)]
17210mod tests {
17211    use super::*;
17212    use crate::normalized_scene::visible_draw_rect;
17213    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
17214    use cranpose_render_common::graph::{DrawPrimitiveNode, IsolationReasons, TextPrimitiveNode};
17215    use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
17216    use cranpose_render_common::scene_builder::build_graph_from_applier;
17217    use cranpose_ui::text::{
17218        AnnotatedString, BaselineShift, RangeStyle, Shadow, SpanStyle, TextDecoration,
17219        TextDrawStyle, TextGeometricTransform, TextMotion, TextUnit,
17220    };
17221    use cranpose_ui::{
17222        LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Size, Text, TextLayoutOptions,
17223        TextStyle,
17224    };
17225    use cranpose_ui_graphics::{
17226        Brush, Color, CornerRadii, DrawPrimitive, Rect, RenderEffect, RoundedCornerShape,
17227        RuntimeShader,
17228    };
17229
17230    fn chunk(batches: &[SegmentBatchPlan]) -> SegmentDrawChunkPlan {
17231        let mut chunk = SegmentDrawChunkPlan::default();
17232        for batch in batches {
17233            chunk.push(*batch);
17234        }
17235        chunk
17236    }
17237
17238    fn with_test_app_context<R>(block: impl FnOnce() -> R) -> R {
17239        let app_context = cranpose_ui::AppContext::new();
17240        app_context.enter(block)
17241    }
17242
17243    fn assert_snap_anchor_close(actual: Option<SnapAnchor>, expected_origin: Point, message: &str) {
17244        let Some(actual) = actual else {
17245            panic!("{message}: missing snap anchor");
17246        };
17247        let expected = SnapAnchor::rigid(expected_origin);
17248        assert_eq!(
17249            actual.device_pixel_step, expected.device_pixel_step,
17250            "{message}: device pixel step changed"
17251        );
17252        assert!(
17253            (actual.origin.x - expected.origin.x).abs() <= 1e-4
17254                && (actual.origin.y - expected.origin.y).abs() <= 1e-4,
17255            "{message}: expected origin {:?}, got {:?}",
17256            expected.origin,
17257            actual.origin
17258        );
17259    }
17260
17261    fn effect_layer(z_start: usize, z_end: usize) -> EffectLayer {
17262        EffectLayer {
17263            rect: Rect {
17264                x: 0.0,
17265                y: 0.0,
17266                width: 10.0,
17267                height: 10.0,
17268            },
17269            clip: None,
17270            snap_anchor: None,
17271            effect: Some(RenderEffect::blur(4.0)),
17272            blend_mode: BlendMode::SrcOver,
17273            composite_alpha: 1.0,
17274            z_start,
17275            z_end,
17276            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
17277        }
17278    }
17279
17280    #[test]
17281    fn direct_shader_composite_accepts_box4_when_viewport_preserves_source_pixels() {
17282        assert_eq!(
17283            direct_shader_composite_viewport(
17284                1.0,
17285                BlendMode::SrcOver,
17286                Some((12.0, 18.0, 64.0, 32.0)),
17287                CompositeSampleMode::Box4,
17288                (64, 32),
17289            ),
17290            Some((12.0, 18.0, 64.0, 32.0))
17291        );
17292    }
17293
17294    #[test]
17295    fn direct_shader_composite_rejects_box4_when_viewport_resamples_source() {
17296        assert_eq!(
17297            direct_shader_composite_viewport(
17298                1.0,
17299                BlendMode::SrcOver,
17300                Some((12.0, 18.0, 64.5, 32.0)),
17301                CompositeSampleMode::Box4,
17302                (64, 32),
17303            ),
17304            None
17305        );
17306        assert_eq!(
17307            direct_shader_composite_viewport(
17308                1.0,
17309                BlendMode::SrcOver,
17310                Some((12.25, 18.0, 64.0, 32.0)),
17311                CompositeSampleMode::Box4,
17312                (64, 32),
17313            ),
17314            None
17315        );
17316    }
17317
17318    fn test_text_draw(rect: Rect, text_motion: TextMotion) -> TextDraw {
17319        let mut text_style = TextStyle::default();
17320        text_style.paragraph_style.text_motion = Some(text_motion);
17321        TextDraw {
17322            node_id: 42,
17323            rect,
17324            snap_anchor: None,
17325            translated_content_context: false,
17326            text: Arc::new(AnnotatedString::new("stable markdown row".to_string()).render_string()),
17327            color: Color::WHITE,
17328            text_style,
17329            font_size: 14.0,
17330            scale: 1.0,
17331            layout_options: TextLayoutOptions::default(),
17332            z_index: 0,
17333            clip: None,
17334        }
17335    }
17336
17337    #[test]
17338    fn static_text_image_cache_key_ignores_absolute_scroll_position() {
17339        let base = test_text_draw(
17340            Rect {
17341                x: 12.25,
17342                y: 40.75,
17343                width: 220.0,
17344                height: 24.0,
17345            },
17346            TextMotion::Static,
17347        );
17348        let scrolled = test_text_draw(
17349            Rect {
17350                x: 12.75,
17351                y: -318.5,
17352                width: 220.0,
17353                height: 24.0,
17354            },
17355            TextMotion::Static,
17356        );
17357
17358        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, true);
17359        let scrolled_key = GpuRenderer::text_image_cache_key(&scrolled, scrolled.rect, 1.0, true);
17360
17361        assert_eq!(
17362            base_key, scrolled_key,
17363            "scrolling static text must reuse the same raster cache entry"
17364        );
17365    }
17366
17367    #[test]
17368    fn static_text_glyph_run_cache_key_ignores_absolute_scroll_position() {
17369        let base = test_text_draw(
17370            Rect {
17371                x: 12.25,
17372                y: 40.75,
17373                width: 220.0,
17374                height: 24.0,
17375            },
17376            TextMotion::Static,
17377        );
17378        let scrolled = test_text_draw(
17379            Rect {
17380                x: 12.75,
17381                y: -318.5,
17382                width: 220.0,
17383                height: 24.0,
17384            },
17385            TextMotion::Static,
17386        );
17387
17388        let base_key = GpuRenderer::text_glyph_run_cache_key(&base, base.rect, 1.0, true);
17389        let scrolled_key =
17390            GpuRenderer::text_glyph_run_cache_key(&scrolled, scrolled.rect, 1.0, true);
17391
17392        assert_eq!(
17393            base_key, scrolled_key,
17394            "scrolling static text must reuse the same retained glyph run"
17395        );
17396    }
17397
17398    #[test]
17399    fn static_multiline_text_glyph_source_keeps_full_text_when_image_source_slices() {
17400        let rect = Rect {
17401            x: 8.0,
17402            y: 100.0,
17403            width: 240.0,
17404            height: 1_000.0,
17405        };
17406        let mut draw = test_text_draw(rect, TextMotion::Static);
17407        let lines = (0..100)
17408            .map(|line| format!("line-{line:03}"))
17409            .collect::<Vec<_>>()
17410            .join("\n");
17411        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
17412
17413        let raster_rect = Rect {
17414            x: 16.0,
17415            y: 200.0,
17416            width: 480.0,
17417            height: 2_000.0,
17418        };
17419        let clipped = clipped_text_raster_source(
17420            &draw,
17421            rect,
17422            raster_rect,
17423            Some(Rect {
17424                x: 0.0,
17425                y: 610.0,
17426                width: 800.0,
17427                height: 40.0,
17428            }),
17429            2.0,
17430            true,
17431        );
17432        let glyph = text_glyph_raster_source(&draw, raster_rect);
17433
17434        assert!(
17435            matches!(clipped.draw, Cow::Owned(_)),
17436            "the image source should still slice large clipped multiline text"
17437        );
17438        assert!(
17439            matches!(glyph.draw, Cow::Borrowed(_)),
17440            "the glyph source must keep a stable full-text run key while scrolling"
17441        );
17442
17443        let clipped_key = GpuRenderer::text_glyph_run_cache_key(
17444            clipped.draw.as_ref(),
17445            clipped.raster_rect,
17446            2.0,
17447            true,
17448        );
17449        let glyph_key = GpuRenderer::text_glyph_run_cache_key(
17450            glyph.draw.as_ref(),
17451            glyph.raster_rect,
17452            2.0,
17453            true,
17454        );
17455
17456        assert_ne!(
17457            clipped_key, glyph_key,
17458            "image slicing must not force glyph rendering onto per-scroll line-window cache keys"
17459        );
17460    }
17461
17462    #[cfg(not(target_arch = "wasm32"))]
17463    #[test]
17464    fn retained_glyph_viewport_offsets_relative_vertices_by_source_origin() {
17465        let viewport = ViewportUniformParams {
17466            width: 800,
17467            height: 600,
17468            offset: [10.0, 20.0],
17469        };
17470        let source = Rect {
17471            x: 40.0,
17472            y: 90.0,
17473            width: 120.0,
17474            height: 48.0,
17475        };
17476
17477        let retained = GpuRenderer::retained_glyph_viewport(viewport, source);
17478
17479        assert_eq!(retained.width, viewport.width);
17480        assert_eq!(retained.height, viewport.height);
17481        assert_eq!(retained.offset, [-30.0, -70.0]);
17482    }
17483
17484    #[cfg(not(target_arch = "wasm32"))]
17485    #[test]
17486    fn tiny_text_glyph_runs_stay_in_shared_uploads() {
17487        assert!(
17488            !should_use_retained_text_glyph_run(8, None),
17489            "tiny labels must stay in the shared fused batch"
17490        );
17491    }
17492
17493    #[cfg(not(target_arch = "wasm32"))]
17494    #[test]
17495    fn line_sized_text_glyph_runs_stay_in_shared_uploads() {
17496        assert!(
17497            !should_use_retained_text_glyph_run(64, None),
17498            "Markdown scroll frames contain many line-sized text runs; retaining each one creates per-run buffer binds instead of one shared glyph batch"
17499        );
17500    }
17501
17502    #[cfg(not(target_arch = "wasm32"))]
17503    #[test]
17504    fn large_clipped_text_glyph_runs_stay_in_shared_uploads() {
17505        assert!(
17506            !should_use_retained_text_glyph_run(
17507                MIN_RETAINED_TEXT_GLYPH_QUADS.saturating_mul(2),
17508                Some(Rect {
17509                    x: 0.0,
17510                    y: 0.0,
17511                    width: 200.0,
17512                    height: 100.0,
17513                }),
17514            ),
17515            "clipped lazy-list text must not draw a full retained run outside the viewport"
17516        );
17517    }
17518
17519    #[test]
17520    fn normal_text_glyph_draw_skips_offscreen_prewarm_candidates() {
17521        assert_eq!(
17522            text_glyph_draw_action(false, true, false),
17523            TextGlyphDrawAction::Skip,
17524            "normal draw traversal must not prepare offscreen text"
17525        );
17526    }
17527
17528    #[test]
17529    fn bounded_text_glyph_prewarm_admits_offscreen_candidates() {
17530        assert_eq!(
17531            text_glyph_draw_action(false, true, true),
17532            TextGlyphDrawAction::PrewarmOffscreen,
17533            "only the bounded prewarm path may prepare offscreen text"
17534        );
17535    }
17536
17537    #[test]
17538    fn visible_text_glyph_draws_are_always_admitted() {
17539        assert_eq!(
17540            text_glyph_draw_action(true, false, false),
17541            TextGlyphDrawAction::DrawVisible
17542        );
17543        assert_eq!(
17544            text_glyph_draw_action(true, true, true),
17545            TextGlyphDrawAction::DrawVisible
17546        );
17547    }
17548
17549    #[cfg(not(target_arch = "wasm32"))]
17550    #[test]
17551    fn offscreen_text_prewarm_skips_large_uncached_text_runs() {
17552        assert!(
17553            !offscreen_text_glyph_prewarm_work_is_bounded(
17554                None,
17555                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS + 1,
17556            ),
17557            "offscreen prewarm must not collect large uncached text runs in an input frame"
17558        );
17559    }
17560
17561    #[cfg(not(target_arch = "wasm32"))]
17562    #[test]
17563    fn offscreen_text_prewarm_admits_small_uncached_text_runs() {
17564        assert!(
17565            offscreen_text_glyph_prewarm_work_is_bounded(
17566                None,
17567                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
17568            ),
17569            "small labels can be warmed without risking a frame-budget spike"
17570        );
17571    }
17572
17573    #[cfg(not(target_arch = "wasm32"))]
17574    #[test]
17575    fn offscreen_text_prewarm_skips_large_cached_runs_without_quads() {
17576        assert!(
17577            !offscreen_text_glyph_prewarm_work_is_bounded(
17578                Some(MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS + 1),
17579                0,
17580            ),
17581            "cached glyph placements can still be too large to prepare during input frames"
17582        );
17583    }
17584
17585    #[cfg(not(target_arch = "wasm32"))]
17586    #[test]
17587    fn offscreen_text_prewarm_stops_after_candidate_budget() {
17588        assert!(
17589            offscreen_text_glyph_prewarm_budget_exhausted(
17590                Instant::now(),
17591                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES,
17592            ),
17593            "prewarm must be bounded by candidate count even when each candidate is cheap"
17594        );
17595    }
17596
17597    #[test]
17598    fn clipped_cached_glyph_quads_are_filtered_to_viewport() {
17599        fn quad(y: i32) -> CachedTextGlyphQuad {
17600            CachedTextGlyphQuad {
17601                x: 8,
17602                y,
17603                width: 20,
17604                height: 10,
17605                color: (1.0, 1.0, 1.0, 1.0),
17606                uv: ImageUvRect {
17607                    min: [0.0, 0.0],
17608                    max: [1.0, 1.0],
17609                    sample_bounds: [0.0, 0.0, 1.0, 1.0],
17610                },
17611            }
17612        }
17613
17614        let source = Rect {
17615            x: 0.0,
17616            y: 0.0,
17617            width: 320.0,
17618            height: 400.0,
17619        };
17620        let clip = Some(Rect {
17621            x: 0.0,
17622            y: 0.0,
17623            width: 320.0,
17624            height: 80.0,
17625        });
17626        let viewport = ViewportUniformParams {
17627            width: 320,
17628            height: 80,
17629            offset: [0.0, 0.0],
17630        };
17631
17632        assert!(cached_text_glyph_quad_is_visible_in_viewport(
17633            source,
17634            &quad(40),
17635            clip,
17636            viewport,
17637            1.0,
17638        ));
17639        assert!(
17640            !cached_text_glyph_quad_is_visible_in_viewport(source, &quad(140), clip, viewport, 1.0,),
17641            "glyphs outside the effective clip should not enter the frame command stream"
17642        );
17643    }
17644
17645    #[test]
17646    fn small_scene_range_cache_miss_observes_first_render() {
17647        let key = LayerRasterCacheKey::scene_range(
17648            0xCACE,
17649            Rect {
17650                x: 0.0,
17651                y: 0.0,
17652                width: 120.0,
17653                height: 80.0,
17654            },
17655            (120, 80),
17656            ScaleBucket::from_scale(1.0),
17657        );
17658
17659        assert!(
17660            !first_cache_miss_admission(&key),
17661            "a small scene-range miss should render directly first instead of materializing a tiny one-frame retained target"
17662        );
17663        assert!(
17664            repeated_cache_miss_admission(&key),
17665            "a repeated small scene-range miss is stable enough to materialize into the retained cache"
17666        );
17667    }
17668
17669    #[test]
17670    fn large_scene_range_cache_miss_requires_repeated_stable_key() {
17671        let key = LayerRasterCacheKey::scene_range(
17672            0xCACE,
17673            Rect {
17674                x: 0.0,
17675                y: 0.0,
17676                width: 1200.0,
17677                height: 900.0,
17678            },
17679            (1200, 900),
17680            ScaleBucket::from_scale(1.0),
17681        );
17682
17683        assert!(
17684            !first_cache_miss_admission(&key),
17685            "a large first scene-range miss should render directly instead of materializing a multi-MB one-frame cache entry"
17686        );
17687        assert!(
17688            repeated_cache_miss_admission(&key),
17689            "a repeated scene-range miss is stable enough to materialize into the retained cache"
17690        );
17691    }
17692
17693    #[test]
17694    fn renderer_warmup_frame_is_requested_for_cache_miss_stats_only() {
17695        let stats = gpu_stats::FrameStats::default();
17696        let mut snapshot = stats.snapshot();
17697        assert!(
17698            !frame_stats_need_warmup_frame(&snapshot),
17699            "a clean frame must not keep a static scene redrawing"
17700        );
17701
17702        snapshot.layer_cache_misses = 1;
17703        assert!(frame_stats_need_warmup_frame(&snapshot));
17704        snapshot.layer_cache_misses = 0;
17705
17706        snapshot.shadow_shape_cache_misses = 1;
17707        assert!(frame_stats_need_warmup_frame(&snapshot));
17708        snapshot.shadow_shape_cache_misses = 0;
17709
17710        snapshot.text_image_cache_misses = 1;
17711        assert!(frame_stats_need_warmup_frame(&snapshot));
17712        snapshot.text_image_cache_misses = 0;
17713
17714        snapshot.text_glyph_atlas_misses = 1;
17715        assert!(frame_stats_need_warmup_frame(&snapshot));
17716    }
17717
17718    #[test]
17719    fn renderer_warmup_budget_is_consumed_by_a_repeated_cache_miss() {
17720        let stats = gpu_stats::FrameStats::default();
17721        let mut snapshot = stats.snapshot();
17722        snapshot.layer_cache_misses = 1;
17723        let mut pending_frames = 0;
17724
17725        update_frame_warmup_budget(&mut pending_frames, &snapshot);
17726        assert_eq!(pending_frames, CACHE_MISS_WARMUP_FRAMES);
17727
17728        update_frame_warmup_budget(&mut pending_frames, &snapshot);
17729        assert_eq!(
17730            pending_frames, 0,
17731            "a cache miss during the warmup frame must not replenish its budget"
17732        );
17733    }
17734
17735    #[test]
17736    fn non_scene_layer_surface_cache_miss_admits_first_render() {
17737        let key = LayerRasterCacheKey::new(
17738            Some(77),
17739            0xC0FFEE,
17740            0,
17741            Rect {
17742                x: 0.0,
17743                y: 0.0,
17744                width: 120.0,
17745                height: 80.0,
17746            },
17747            (120, 80),
17748            ScaleBucket::from_scale(1.0),
17749        );
17750
17751        assert!(
17752            first_cache_miss_admission(&key),
17753            "ordinary retained layer surfaces should still cache on first miss"
17754        );
17755    }
17756
17757    #[test]
17758    fn text_image_cache_key_is_content_addressed_not_node_addressed() {
17759        let first = test_text_draw(
17760            Rect {
17761                x: 12.25,
17762                y: 40.75,
17763                width: 220.0,
17764                height: 24.0,
17765            },
17766            TextMotion::Static,
17767        );
17768        let mut second = first.clone();
17769        second.node_id = first.node_id + 1;
17770
17771        let first_key = GpuRenderer::text_image_cache_key(&first, first.rect, 1.0, true);
17772        let second_key = GpuRenderer::text_image_cache_key(&second, second.rect, 1.0, true);
17773
17774        assert_eq!(
17775            first_key, second_key,
17776            "text raster cache keys must be based on rendered pixels, not node identity"
17777        );
17778    }
17779
17780    #[test]
17781    fn animated_text_image_cache_key_keeps_fractional_phase_only() {
17782        let base = test_text_draw(
17783            Rect {
17784                x: 12.25,
17785                y: 40.75,
17786                width: 220.0,
17787                height: 24.0,
17788            },
17789            TextMotion::Animated,
17790        );
17791        let integer_translated = test_text_draw(
17792            Rect {
17793                x: 44.25,
17794                y: 88.75,
17795                width: 220.0,
17796                height: 24.0,
17797            },
17798            TextMotion::Animated,
17799        );
17800        let phase_shifted = test_text_draw(
17801            Rect {
17802                x: 44.5,
17803                y: 88.75,
17804                width: 220.0,
17805                height: 24.0,
17806            },
17807            TextMotion::Animated,
17808        );
17809
17810        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, false);
17811        let translated_key = GpuRenderer::text_image_cache_key(
17812            &integer_translated,
17813            integer_translated.rect,
17814            1.0,
17815            false,
17816        );
17817        let phase_shifted_key =
17818            GpuRenderer::text_image_cache_key(&phase_shifted, phase_shifted.rect, 1.0, false);
17819
17820        assert_eq!(
17821            base_key, translated_key,
17822            "integer translation should not invalidate animated text raster cache entries"
17823        );
17824        assert_ne!(
17825            base_key, phase_shifted_key,
17826            "fractional phase affects animated text rasterization and must stay in the key"
17827        );
17828    }
17829
17830    #[test]
17831    fn animated_translated_text_raster_geometry_applies_snap_anchor() {
17832        let mut base = test_text_draw(
17833            Rect {
17834                x: 14.25,
17835                y: 16.50,
17836                width: 220.0,
17837                height: 24.0,
17838            },
17839            TextMotion::Animated,
17840        );
17841        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.50)));
17842
17843        let mut scrolled = test_text_draw(
17844            Rect {
17845                x: 14.25,
17846                y: 15.80,
17847                width: 220.0,
17848                height: 24.0,
17849            },
17850            TextMotion::Animated,
17851        );
17852        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 15.80)));
17853
17854        let (base_logical, base_raster, _, _, base_static) =
17855            text_raster_geometry_for_draw(&base, 1.0).expect("base text geometry");
17856        let (scrolled_logical, scrolled_raster, _, _, scrolled_static) =
17857            text_raster_geometry_for_draw(&scrolled, 1.0).expect("scrolled text geometry");
17858
17859        assert!(!base_static);
17860        assert!(!scrolled_static);
17861        assert!((base_logical.x - 14.0).abs() < f32::EPSILON);
17862        assert!((base_logical.y - 17.0).abs() < f32::EPSILON);
17863        assert!((scrolled_logical.x - 14.0).abs() < f32::EPSILON);
17864        assert!((scrolled_logical.y - 16.0).abs() < f32::EPSILON);
17865        assert_eq!(base_raster.x.fract(), 0.0);
17866        assert_eq!(base_raster.y.fract(), 0.0);
17867        assert_eq!(scrolled_raster.x.fract(), 0.0);
17868        assert_eq!(scrolled_raster.y.fract(), 0.0);
17869
17870        let base_key = GpuRenderer::text_image_cache_key(&base, base_raster, 1.0, false);
17871        let scrolled_key =
17872            GpuRenderer::text_image_cache_key(&scrolled, scrolled_raster, 1.0, false);
17873        assert_eq!(
17874            base_key, scrolled_key,
17875            "translated animated text should keep a stable raster phase while scrolling"
17876        );
17877    }
17878
17879    #[test]
17880    fn translated_static_text_moves_one_device_pixel_at_half_pixel_phase() {
17881        let root_scale = 1.25;
17882        let mut base = test_text_draw(
17883            Rect {
17884                x: 14.0,
17885                y: 276.0,
17886                width: 220.0,
17887                height: 24.0,
17888            },
17889            TextMotion::Static,
17890        );
17891        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.600_006)));
17892
17893        let mut scrolled = test_text_draw(
17894            Rect {
17895                x: 14.0,
17896                y: 275.2,
17897                width: 220.0,
17898                height: 24.0,
17899            },
17900            TextMotion::Static,
17901        );
17902        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 126.799_99)));
17903
17904        let (_, base_raster, _, _, _) =
17905            text_raster_geometry_for_draw(&base, root_scale).expect("base text geometry");
17906        let (_, scrolled_raster, _, _, _) =
17907            text_raster_geometry_for_draw(&scrolled, root_scale).expect("scrolled text geometry");
17908
17909        assert_eq!(
17910            base_raster.y - scrolled_raster.y,
17911            1.0,
17912            "one physical pixel of rigid scrolling must move static text by one raster pixel"
17913        );
17914    }
17915
17916    #[test]
17917    fn translated_text_snap_does_not_move_its_fixed_ancestor_clip() {
17918        let root_scale = 1.25;
17919        let fixed_clip = Rect {
17920            x: 8.0,
17921            y: 20.0,
17922            width: 300.0,
17923            height: 680.0,
17924        };
17925        let mut draw = test_text_draw(
17926            Rect {
17927                x: 14.0,
17928                y: 276.0,
17929                width: 220.0,
17930                height: 24.0,
17931            },
17932            TextMotion::Static,
17933        );
17934        draw.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.4)));
17935        draw.clip = Some(fixed_clip);
17936
17937        let (_, _, clip, _, _) =
17938            text_raster_geometry_for_draw(&draw, root_scale).expect("clipped text geometry");
17939
17940        assert_eq!(
17941            clip,
17942            Some(fixed_clip),
17943            "content pixel snapping must not translate a fixed ancestor clip"
17944        );
17945    }
17946
17947    #[test]
17948    fn clipped_static_multiline_text_raster_source_limits_visible_line_window() {
17949        let rect = Rect {
17950            x: 8.0,
17951            y: 100.0,
17952            width: 240.0,
17953            height: 1_000.0,
17954        };
17955        let mut draw = test_text_draw(rect, TextMotion::Static);
17956        let lines = (0..100)
17957            .map(|line| format!("line-{line:03}"))
17958            .collect::<Vec<_>>()
17959            .join("\n");
17960        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
17961
17962        let raster_rect = Rect {
17963            x: 16.0,
17964            y: 200.0,
17965            width: 480.0,
17966            height: 2_000.0,
17967        };
17968        let source = clipped_text_raster_source(
17969            &draw,
17970            rect,
17971            raster_rect,
17972            Some(Rect {
17973                x: 0.0,
17974                y: 610.0,
17975                width: 800.0,
17976                height: 40.0,
17977            }),
17978            2.0,
17979            true,
17980        );
17981
17982        let Cow::Owned(sliced_draw) = source.draw else {
17983            panic!("clipped static multiline text should rasterize only the visible line window");
17984        };
17985        let sliced_text = sliced_draw.text.text.as_str();
17986        assert!(sliced_text.contains("line-050"));
17987        assert!(sliced_text.contains("line-055"));
17988        assert!(!sliced_text.contains("line-000"));
17989        assert!(!sliced_text.contains("line-099"));
17990        assert_eq!(source.raster_rect.x, raster_rect.x);
17991        assert!(source.raster_rect.y > raster_rect.y);
17992        assert!(source.raster_rect.height < raster_rect.height);
17993    }
17994
17995    #[test]
17996    fn clipped_static_multiline_text_raster_source_slices_short_multiline_text() {
17997        let rect = Rect {
17998            x: 8.0,
17999            y: 100.0,
18000            width: 240.0,
18001            height: 320.0,
18002        };
18003        let mut draw = test_text_draw(rect, TextMotion::Static);
18004        let lines = (0..24)
18005            .map(|line| format!("code-line-{line:02}"))
18006            .collect::<Vec<_>>()
18007            .join("\n");
18008        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
18009
18010        let raster_rect = Rect {
18011            x: 16.0,
18012            y: 200.0,
18013            width: 480.0,
18014            height: 640.0,
18015        };
18016        let source = clipped_text_raster_source(
18017            &draw,
18018            rect,
18019            raster_rect,
18020            Some(Rect {
18021                x: 0.0,
18022                y: 190.0,
18023                width: 800.0,
18024                height: 120.0,
18025            }),
18026            2.0,
18027            true,
18028        );
18029
18030        let Cow::Owned(sliced_draw) = source.draw else {
18031            panic!("clipped multiline text should rasterize only the visible line window");
18032        };
18033        assert!(sliced_draw.text.text.as_str().contains("code-line-06"));
18034        assert!(!sliced_draw.text.text.as_str().contains("code-line-00"));
18035        assert!(!sliced_draw.text.text.as_str().contains("code-line-23"));
18036        assert_eq!(source.raster_rect.x, raster_rect.x);
18037        assert!(source.raster_rect.y > raster_rect.y);
18038        assert!(source.raster_rect.height < raster_rect.height);
18039    }
18040
18041    #[test]
18042    fn text_line_index_cache_reuses_retained_index_for_same_text_instance() {
18043        let mut cache = TextLineIndexCache::new(4);
18044        let text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
18045
18046        let first = cache.line_starts(&text);
18047        let second = cache.line_starts(&text);
18048
18049        assert_eq!(first.as_ref(), &[0, 2, 4]);
18050        assert!(
18051            Rc::ptr_eq(&first, &second),
18052            "retained text should not rebuild its line index on every clipped frame"
18053        );
18054    }
18055
18056    #[test]
18057    fn text_line_index_cache_is_retained_text_instance_local() {
18058        let mut cache = TextLineIndexCache::new(4);
18059        let first_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
18060        let second_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
18061
18062        let first = cache.line_starts(&first_text);
18063        let second = cache.line_starts(&second_text);
18064
18065        assert_eq!(first.as_ref(), second.as_ref());
18066        assert!(
18067            !Rc::ptr_eq(&first, &second),
18068            "line index lookup should not hash large text contents to find unrelated retained nodes"
18069        );
18070    }
18071
18072    #[test]
18073    fn device_pixel_bounds_for_rect_snaps_origin_and_extents() {
18074        let bounds = device_pixel_bounds_for_rect(
18075            Rect {
18076                x: 10.25,
18077                y: 14.6,
18078                width: 20.1,
18079                height: 9.2,
18080            },
18081            200,
18082            120,
18083            2.0,
18084        )
18085        .expect("rect should intersect the viewport");
18086
18087        assert_eq!(
18088            bounds,
18089            DevicePixelBounds {
18090                x: 20.0,
18091                y: 29.0,
18092                width: 41,
18093                height: 19,
18094            }
18095        );
18096    }
18097
18098    #[test]
18099    fn visible_layer_rect_intersects_clip_and_viewport() {
18100        let visible = visible_layer_rect(
18101            Rect {
18102                x: -10.0,
18103                y: 5.0,
18104                width: 80.0,
18105                height: 40.0,
18106            },
18107            Some(Rect {
18108                x: 4.0,
18109                y: 8.0,
18110                width: 20.0,
18111                height: 50.0,
18112            }),
18113            2.0,
18114            60,
18115            40,
18116        )
18117        .expect("visible rect");
18118
18119        assert_eq!(
18120            visible,
18121            Rect {
18122                x: 4.0,
18123                y: 8.0,
18124                width: 20.0,
18125                height: 12.0,
18126            }
18127        );
18128    }
18129
18130    #[test]
18131    fn clamp_effect_surface_scale_caps_large_surfaces_but_keeps_base_scale() {
18132        let clamped = clamp_effect_surface_scale(
18133            Rect {
18134                x: 0.0,
18135                y: 0.0,
18136                width: 1200.0,
18137                height: 900.0,
18138            },
18139            1.0,
18140            8.0,
18141            16_384,
18142        );
18143
18144        assert!(
18145            clamped < 8.0,
18146            "large translated effect layers must be capped to avoid OOM, got {clamped}"
18147        );
18148        assert!(
18149            clamped >= 1.0,
18150            "effect surfaces must not fall below destination resolution, got {clamped}"
18151        );
18152    }
18153
18154    #[test]
18155    fn clamp_effect_surface_scale_keeps_decorated_text_capture_scale() {
18156        let clamped = clamp_effect_surface_scale(
18157            Rect {
18158                x: 0.0,
18159                y: 0.0,
18160                width: 446.0,
18161                height: 44.0,
18162            },
18163            1.0,
18164            9.0,
18165            16_384,
18166        );
18167
18168        assert_eq!(
18169            clamped, 9.0,
18170            "decorated text motion-stable captures must keep full scale"
18171        );
18172    }
18173
18174    fn backdrop_layer(z_index: usize) -> BackdropLayer {
18175        BackdropLayer {
18176            node_id: Some(700 + z_index),
18177            rect: Rect {
18178                x: 0.0,
18179                y: 0.0,
18180                width: 10.0,
18181                height: 10.0,
18182            },
18183            clip: None,
18184            snap_anchor: None,
18185            effect: RenderEffect::blur(2.0),
18186            z_index,
18187        }
18188    }
18189
18190    fn test_shape(z_index: usize, blend_mode: BlendMode) -> DrawShape {
18191        DrawShape {
18192            rect: Rect {
18193                x: 0.0,
18194                y: 0.0,
18195                width: 8.0,
18196                height: 8.0,
18197            },
18198            local_rect: Rect {
18199                x: 0.0,
18200                y: 0.0,
18201                width: 8.0,
18202                height: 8.0,
18203            },
18204            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
18205            snap_anchor: None,
18206            brush: SceneBrush::Solid(Color::BLACK),
18207            shape: None,
18208            stroke: None,
18209            arc: None,
18210            z_index,
18211            clip: None,
18212            blend_mode,
18213            motion_context_animated: false,
18214        }
18215    }
18216
18217    #[test]
18218    fn shape_shadow_content_hash_ignores_viewport_translation() {
18219        fn translate_shape(shape: &DrawShape, dx: f32, dy: f32) -> DrawShape {
18220            let mut translated = *shape;
18221            translated.rect.x += dx;
18222            translated.rect.y += dy;
18223            translated.local_rect.x += dx;
18224            translated.local_rect.y += dy;
18225            for point in &mut translated.quad {
18226                point[0] += dx;
18227                point[1] += dy;
18228            }
18229            translated.snap_anchor = translated.snap_anchor.map(|anchor| {
18230                SnapAnchor::rigid(Point::new(anchor.origin.x + dx, anchor.origin.y + dy))
18231            });
18232            translated.clip = translated.clip.map(|mut clip| {
18233                clip.x += dx;
18234                clip.y += dy;
18235                clip
18236            });
18237            translated
18238        }
18239
18240        let mut first = test_shape(1, BlendMode::SrcOver);
18241        first.rect = Rect {
18242            x: 10.0,
18243            y: 20.0,
18244            width: 80.0,
18245            height: 40.0,
18246        };
18247        first.local_rect = first.rect;
18248        first.quad = [[10.0, 20.0], [90.0, 20.0], [10.0, 60.0], [90.0, 60.0]];
18249        first.snap_anchor = Some(SnapAnchor::rigid(Point::new(7.0, 11.0)));
18250        first.shape = Some(RoundedCornerShape::uniform(8.0));
18251        first.clip = Some(Rect {
18252            x: 8.0,
18253            y: 18.0,
18254            width: 86.0,
18255            height: 44.0,
18256        });
18257        let mut cutout = test_shape(2, BlendMode::DstOut);
18258        cutout.rect = Rect {
18259            x: 18.0,
18260            y: 26.0,
18261            width: 62.0,
18262            height: 22.0,
18263        };
18264        cutout.local_rect = cutout.rect;
18265        cutout.quad = [[18.0, 26.0], [80.0, 26.0], [18.0, 48.0], [80.0, 48.0]];
18266        cutout.shape = Some(RoundedCornerShape::uniform(4.0));
18267
18268        let dx = 37.0;
18269        let dy = -11.5;
18270        let translated = translate_shape(&first, dx, dy);
18271        let translated_cutout = translate_shape(&cutout, dx, dy);
18272
18273        let root_scale = 1.25;
18274        let first_shapes = vec![(first, BlendMode::SrcOver), (cutout, BlendMode::DstOut)];
18275        let translated_shapes = vec![
18276            (translated, BlendMode::SrcOver),
18277            (translated_cutout, BlendMode::DstOut),
18278        ];
18279
18280        let first_hash = shape_shadow_content_hash(&first_shapes, &[], root_scale);
18281        let translated_hash = shape_shadow_content_hash(&translated_shapes, &[], root_scale);
18282
18283        assert_eq!(first_hash, translated_hash);
18284
18285        let mut changed_shapes = translated_shapes;
18286        changed_shapes[0].0.rect.width += 1.0;
18287        let changed_hash = shape_shadow_content_hash(&changed_shapes, &[], root_scale);
18288
18289        assert_ne!(first_hash, changed_hash);
18290    }
18291
18292    #[test]
18293    fn shape_shadow_content_hash_is_stable_under_fractional_scale_scroll() {
18294        // Regression: scrolling a shadowed panel on a fractional-scale display
18295        // (e.g. Xft.dpi 130 → scale ≈ 1.354) must not re-render the shadow blur
18296        // every frame. The production cache key derives its viewport offset from
18297        // FLOORED device-pixel bounds, so the residual subpixel phase used to leak
18298        // into the content hash and miss the cache on every scroll step.
18299        fn shadow_shapes_at(y: f32) -> Vec<(DrawShape, BlendMode)> {
18300            let mut shape = test_shape(1, BlendMode::SrcOver);
18301            shape.rect = Rect {
18302                x: 24.0,
18303                y,
18304                width: 180.0,
18305                height: 90.0,
18306            };
18307            shape.local_rect = shape.rect;
18308            shape.quad = crate::rect_to_quad(shape.rect);
18309            shape.shape = Some(RoundedCornerShape::uniform(14.0));
18310            vec![(shape, BlendMode::SrcOver)]
18311        }
18312
18313        let root_scale = 130.0f32 / 96.0;
18314        let blur_radius = 18.0f32;
18315        let pixel_radius = blur_radius * root_scale;
18316
18317        let key_at = |y: f32| {
18318            let shapes = shadow_shapes_at(y);
18319            let plan =
18320                shape_shadow_surface_plan(&shapes, None, blur_radius, 1600, 1600, root_scale, 8192)
18321                    .expect("surface plan");
18322            shape_shadow_surface_cache_key(
18323                &shapes,
18324                &[],
18325                plan.source_device_bounds,
18326                pixel_radius,
18327                root_scale,
18328            )
18329            .expect("cache key")
18330        };
18331
18332        // Wheel scroll translates the panel by whole logical pixels; the device
18333        // subpixel phase changes on every step at fractional scale. The whole
18334        // cache key (content hash AND surface pixel size) must stay stable, or
18335        // every scroll frame re-renders the shadow blur.
18336        let base = key_at(640.0);
18337        for step in 1..=12 {
18338            let scrolled = key_at(640.0 - step as f32 * 4.0);
18339            assert_eq!(
18340                base, scrolled,
18341                "scrolled shadow cache key must stay stable at fractional scale (step {step})"
18342            );
18343        }
18344    }
18345
18346    #[test]
18347    fn shape_shadow_cache_key_uses_unclipped_source_bounds_for_scrolled_clip() {
18348        fn translated_card_shadow(y: f32) -> Vec<(DrawShape, BlendMode)> {
18349            let mut shape = test_shape(1, BlendMode::SrcOver);
18350            shape.rect = Rect {
18351                x: 24.0,
18352                y,
18353                width: 280.0,
18354                height: 120.0,
18355            };
18356            shape.local_rect = shape.rect;
18357            shape.quad = [[24.0, y], [304.0, y], [24.0, y + 120.0], [304.0, y + 120.0]];
18358            shape.shape = Some(RoundedCornerShape::uniform(18.0));
18359            vec![(shape, BlendMode::SrcOver)]
18360        }
18361
18362        let root_scale = 1.0;
18363        let blur_radius = 18.0;
18364        let viewport_clip = Rect {
18365            x: 0.0,
18366            y: 96.0,
18367            width: 360.0,
18368            height: 720.0,
18369        };
18370        let key_for = |y: f32| {
18371            let shapes = translated_card_shadow(y);
18372            let plan = shape_shadow_surface_plan(
18373                &shapes,
18374                Some(viewport_clip),
18375                blur_radius,
18376                360,
18377                900,
18378                root_scale,
18379                4096,
18380            )
18381            .expect("surface plan");
18382            shape_shadow_surface_cache_key(
18383                &shapes,
18384                &[],
18385                plan.source_device_bounds,
18386                plan.pixel_radius,
18387                root_scale,
18388            )
18389            .expect("cache key")
18390        };
18391
18392        // The card scrolls under a fixed viewport clip; the visible portion
18393        // changes but the cache key must stay anchored to the unclipped source.
18394        assert_eq!(key_for(740.0), key_for(756.0));
18395    }
18396
18397    #[test]
18398    fn shape_visibility_uses_nonzero_viewport_offset_for_cropped_offscreen() {
18399        let mut shape = test_shape(1, BlendMode::SrcOver);
18400        shape.rect = Rect {
18401            x: 24.0,
18402            y: 740.0,
18403            width: 280.0,
18404            height: 120.0,
18405        };
18406        shape.local_rect = shape.rect;
18407        shape.quad = [[24.0, 740.0], [304.0, 740.0], [24.0, 860.0], [304.0, 860.0]];
18408        let viewport = ViewportUniformParams {
18409            width: 316,
18410            height: 228,
18411            offset: [6.0, 686.0],
18412        };
18413
18414        assert!(shape_draw_is_visible_in_viewport(&shape, viewport, 1.0));
18415    }
18416
18417    #[test]
18418    fn text_prewarm_uses_nonzero_viewport_offset_for_cropped_offscreen() {
18419        let viewport = ViewportUniformParams {
18420            width: 316,
18421            height: 228,
18422            offset: [6.0, 686.0],
18423        };
18424        let text_rect = Rect {
18425            x: 24.0,
18426            y: 740.0,
18427            width: 280.0,
18428            height: 40.0,
18429        };
18430
18431        assert!(text_draw_is_visible_in_viewport(
18432            text_rect, None, viewport, 1.0
18433        ));
18434        assert!(text_draw_should_prewarm_in_viewport(
18435            text_rect, None, viewport, 1.0
18436        ));
18437    }
18438
18439    fn test_shadow_draw(shapes: Vec<(DrawShape, BlendMode)>) -> ShadowDraw {
18440        ShadowDraw {
18441            shapes,
18442            brushes: vec![],
18443            texts: vec![],
18444            blur_radius: 8.0,
18445            clip: None,
18446            z_index: 0,
18447        }
18448    }
18449
18450    fn test_image(z_index: usize, blend_mode: BlendMode) -> ImageDraw {
18451        ImageDraw {
18452            rect: Rect {
18453                x: 0.0,
18454                y: 0.0,
18455                width: 8.0,
18456                height: 8.0,
18457            },
18458            local_rect: Rect {
18459                x: 0.0,
18460                y: 0.0,
18461                width: 8.0,
18462                height: 8.0,
18463            },
18464            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
18465            snap_anchor: None,
18466            image: ImageBitmap::from_rgba8(1, 1, vec![255, 255, 255, 255]).expect("image"),
18467            alpha: 1.0,
18468            color_filter: None,
18469            sampling: ImageSampling::Nearest,
18470            z_index,
18471            clip: None,
18472            blend_mode,
18473            src_rect: None,
18474            motion_context_animated: false,
18475        }
18476    }
18477
18478    #[test]
18479    fn image_sampler_descriptors_match_requested_sampling() {
18480        let nearest = image_sampler_descriptor(ImageSampling::Nearest);
18481        assert_eq!(nearest.mag_filter, wgpu::FilterMode::Nearest);
18482        assert_eq!(nearest.min_filter, wgpu::FilterMode::Nearest);
18483
18484        let linear = image_sampler_descriptor(ImageSampling::Linear);
18485        assert_eq!(linear.mag_filter, wgpu::FilterMode::Linear);
18486        assert_eq!(linear.min_filter, wgpu::FilterMode::Linear);
18487    }
18488
18489    #[test]
18490    fn image_uv_rect_clamps_source_rect_to_texel_centers() {
18491        let image = ImageBitmap::from_rgba8(24, 16, vec![0; 24 * 16 * 4]).expect("image");
18492        let uv = image_uv_rect(
18493            &image,
18494            Some(Rect {
18495                x: 0.0,
18496                y: 0.0,
18497                width: 16.0,
18498                height: 16.0,
18499            }),
18500        )
18501        .expect("uv rect");
18502
18503        assert_eq!(uv.min, [0.0, 0.0]);
18504        assert_eq!(uv.max, [16.0 / 24.0, 1.0]);
18505        assert_eq!(
18506            uv.sample_bounds,
18507            [0.5 / 24.0, 0.5 / 16.0, 15.5 / 24.0, 15.5 / 16.0]
18508        );
18509    }
18510
18511    #[test]
18512    fn image_uv_rect_keeps_full_image_unclamped() {
18513        let image = ImageBitmap::from_rgba8(2, 2, vec![0; 16]).expect("image");
18514        let uv = image_uv_rect(&image, None).expect("uv rect");
18515
18516        assert_eq!(uv.min, [0.0, 0.0]);
18517        assert_eq!(uv.max, [1.0, 1.0]);
18518        assert_eq!(uv.sample_bounds, [0.0, 0.0, 1.0, 1.0]);
18519    }
18520
18521    fn test_text(z_index: usize) -> TextDraw {
18522        TextDraw {
18523            node_id: 0,
18524            rect: Rect {
18525                x: 0.0,
18526                y: 0.0,
18527                width: 8.0,
18528                height: 8.0,
18529            },
18530            snap_anchor: None,
18531            translated_content_context: false,
18532            text: Arc::new(cranpose_ui::text::AnnotatedString::from("t").render_string()),
18533            color: Color::WHITE,
18534            text_style: cranpose_ui::TextStyle::default(),
18535            font_size: 12.0,
18536            scale: 1.0,
18537            layout_options: cranpose_ui::TextLayoutOptions::default(),
18538            z_index,
18539            clip: None,
18540        }
18541    }
18542
18543    #[test]
18544    fn text_draw_visibility_rejects_text_outside_clip_before_rasterization() {
18545        let viewport = ViewportUniformParams {
18546            width: 320,
18547            height: 240,
18548            offset: [0.0, 0.0],
18549        };
18550        let text_rect = Rect {
18551            x: 0.0,
18552            y: 260.0,
18553            width: 200.0,
18554            height: 40.0,
18555        };
18556        let clip = Some(Rect {
18557            x: 0.0,
18558            y: 0.0,
18559            width: 320.0,
18560            height: 200.0,
18561        });
18562
18563        assert!(
18564            !text_draw_is_visible_in_viewport(text_rect, clip, viewport, 1.0),
18565            "lazy-list beyond-bound text outside the clip must not be rasterized"
18566        );
18567    }
18568
18569    #[test]
18570    fn text_draw_prewarm_accepts_clipped_text_near_viewport() {
18571        let viewport = ViewportUniformParams {
18572            width: 320,
18573            height: 240,
18574            offset: [0.0, 0.0],
18575        };
18576        let text_rect = Rect {
18577            x: 0.0,
18578            y: 260.0,
18579            width: 200.0,
18580            height: 40.0,
18581        };
18582        let clip = Some(Rect {
18583            x: 0.0,
18584            y: 0.0,
18585            width: 320.0,
18586            height: 200.0,
18587        });
18588
18589        assert!(!text_draw_is_visible_in_viewport(
18590            text_rect, clip, viewport, 1.0
18591        ));
18592        assert!(text_draw_should_prewarm_in_viewport(
18593            text_rect, clip, viewport, 1.0
18594        ));
18595    }
18596
18597    #[test]
18598    fn text_draw_prewarm_rejects_far_clipped_text() {
18599        let viewport = ViewportUniformParams {
18600            width: 320,
18601            height: 240,
18602            offset: [0.0, 0.0],
18603        };
18604        let text_rect = Rect {
18605            x: 0.0,
18606            y: 1600.0,
18607            width: 200.0,
18608            height: 40.0,
18609        };
18610        let clip = Some(Rect {
18611            x: 0.0,
18612            y: 0.0,
18613            width: 320.0,
18614            height: 200.0,
18615        });
18616
18617        assert!(!text_draw_should_prewarm_in_viewport(
18618            text_rect, clip, viewport, 1.0
18619        ));
18620    }
18621
18622    #[test]
18623    fn text_draw_visibility_rejects_unclipped_text_outside_viewport() {
18624        let viewport = ViewportUniformParams {
18625            width: 320,
18626            height: 240,
18627            offset: [0.0, 0.0],
18628        };
18629        let text_rect = Rect {
18630            x: 0.0,
18631            y: 241.0,
18632            width: 200.0,
18633            height: 40.0,
18634        };
18635
18636        assert!(
18637            !text_draw_is_visible_in_viewport(text_rect, None, viewport, 1.0),
18638            "unclipped text outside the target viewport must not be rasterized"
18639        );
18640    }
18641
18642    #[test]
18643    fn text_draw_visibility_keeps_partially_visible_text() {
18644        let viewport = ViewportUniformParams {
18645            width: 320,
18646            height: 240,
18647            offset: [0.0, 0.0],
18648        };
18649        let text_rect = Rect {
18650            x: 0.0,
18651            y: 220.0,
18652            width: 200.0,
18653            height: 40.0,
18654        };
18655
18656        assert!(text_draw_is_visible_in_viewport(
18657            text_rect, None, viewport, 1.0
18658        ));
18659    }
18660
18661    fn test_draw_ops(
18662        shapes: &[DrawShape],
18663        images: &[ImageDraw],
18664        texts: &[TextDraw],
18665        shadows: &[ShadowDraw],
18666    ) -> Vec<DrawOp> {
18667        let mut ops = Vec::new();
18668        ops.extend(shapes.iter().enumerate().map(|(index, shape)| DrawOp {
18669            z_index: shape.z_index,
18670            kind: DrawOpKind::Shape(index),
18671        }));
18672        ops.extend(images.iter().enumerate().map(|(index, image)| DrawOp {
18673            z_index: image.z_index,
18674            kind: DrawOpKind::Image(index),
18675        }));
18676        ops.extend(texts.iter().enumerate().map(|(index, text)| DrawOp {
18677            z_index: text.z_index,
18678            kind: DrawOpKind::Text(index),
18679        }));
18680        ops.extend(shadows.iter().enumerate().map(|(index, shadow)| DrawOp {
18681            z_index: shadow.z_index,
18682            kind: DrawOpKind::Shadow(index),
18683        }));
18684        ops.sort_by_key(|op| op.z_index);
18685        ops
18686    }
18687
18688    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
18689        crate::test_support::layer_node(
18690            local_bounds,
18691            ProjectiveTransform::identity(),
18692            GraphicsLayer::default(),
18693            children,
18694        )
18695    }
18696
18697    fn cacheable_layer(
18698        node_id: cranpose_core::NodeId,
18699        local_bounds: Rect,
18700        children: Vec<RenderNode>,
18701    ) -> LayerNode {
18702        let mut layer = test_layer(local_bounds, children);
18703        layer.node_id = Some(node_id);
18704        layer.cache_policy = cranpose_render_common::graph::CachePolicy::Auto;
18705        layer.recompute_raster_cache_hashes();
18706        layer
18707    }
18708
18709    fn text_layer_with_style(text: AnnotatedString, text_style: TextStyle) -> LayerNode {
18710        test_layer(
18711            Rect {
18712                x: 0.0,
18713                y: 0.0,
18714                width: 64.0,
18715                height: 32.0,
18716            },
18717            vec![RenderNode::Primitive(PrimitiveEntry {
18718                phase: PrimitivePhase::BeforeChildren,
18719                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18720                    node_id: 1,
18721                    rect: Rect {
18722                        x: 2.0,
18723                        y: 3.0,
18724                        width: 48.0,
18725                        height: 18.0,
18726                    },
18727                    text: std::rc::Rc::new(text),
18728                    text_style,
18729                    font_size: 14.0,
18730                    layout_options: TextLayoutOptions::default(),
18731                    clip: None,
18732                })),
18733            })],
18734        )
18735    }
18736
18737    fn snapped_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
18738        LayerNode {
18739            node_id: Some(77),
18740            local_bounds: Rect {
18741                x: 0.0,
18742                y: 0.0,
18743                width: 48.0,
18744                height: 24.0,
18745            },
18746            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
18747            motion_context_animated: animated,
18748            translated_content_context,
18749            translated_content_offset: Point::default(),
18750            content_offset: Point::default(),
18751            scene_children_origin: cranpose_ui_graphics::Point::default(),
18752            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
18753            graphics_layer: GraphicsLayer::default(),
18754            clip_to_bounds: false,
18755            shadow_clip: None,
18756            hit_test: None,
18757            has_hit_targets: false,
18758            isolation: IsolationReasons::default(),
18759            cache_policy: CachePolicy::None,
18760            cache_hashes: LayerRasterCacheHashes::default(),
18761            cache_hashes_valid: false,
18762            children: vec![
18763                RenderNode::Primitive(PrimitiveEntry {
18764                    phase: PrimitivePhase::BeforeChildren,
18765                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
18766                        primitive: DrawPrimitive::RoundRect {
18767                            rect: Rect {
18768                                x: 0.0,
18769                                y: 0.0,
18770                                width: 48.0,
18771                                height: 24.0,
18772                            },
18773                            brush: Brush::solid(Color(0.28, 0.30, 0.46, 0.88)),
18774                            radii: CornerRadii::uniform(6.0),
18775                            stroke: None,
18776                        },
18777                        clip: None,
18778                    }),
18779                }),
18780                RenderNode::Primitive(PrimitiveEntry {
18781                    phase: PrimitivePhase::BeforeChildren,
18782                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
18783                        primitive: DrawPrimitive::Image {
18784                            rect: Rect {
18785                                x: 2.0,
18786                                y: 2.0,
18787                                width: 12.0,
18788                                height: 12.0,
18789                            },
18790                            image: ImageBitmap::from_rgba8(
18791                                2,
18792                                2,
18793                                vec![
18794                                    255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255,
18795                                    255,
18796                                ],
18797                            )
18798                            .expect("image"),
18799                            alpha: 1.0,
18800                            color_filter: None,
18801                            sampling: ImageSampling::Linear,
18802                            src_rect: None,
18803                        },
18804                        clip: None,
18805                    }),
18806                }),
18807                RenderNode::Primitive(PrimitiveEntry {
18808                    phase: PrimitivePhase::BeforeChildren,
18809                    node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18810                        node_id: 77,
18811                        rect: Rect {
18812                            x: 6.0,
18813                            y: 4.0,
18814                            width: 36.0,
18815                            height: 16.0,
18816                        },
18817                        text: std::rc::Rc::new(AnnotatedString::from("48 px")),
18818                        text_style: TextStyle::default(),
18819                        font_size: 14.0,
18820                        layout_options: TextLayoutOptions::default(),
18821                        clip: None,
18822                    })),
18823                }),
18824            ],
18825        }
18826    }
18827
18828    fn snapped_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
18829        let text_leaf = snapped_text_leaf(animated, translated_content_context);
18830        test_layer(
18831            Rect {
18832                x: 0.0,
18833                y: 0.0,
18834                width: 96.0,
18835                height: 64.0,
18836            },
18837            vec![RenderNode::Layer(Box::new(text_leaf))],
18838        )
18839    }
18840
18841    fn translated_content_local_surface_root() -> LayerNode {
18842        let mut effectful_text = text_layer_with_style(
18843            AnnotatedString::from("shadow"),
18844            TextStyle::from_span_style(SpanStyle {
18845                shadow: Some(Shadow {
18846                    color: Color::BLACK,
18847                    offset: Point::new(1.0, 2.0),
18848                    blur_radius: 3.0,
18849                }),
18850                ..SpanStyle::default()
18851            }),
18852        );
18853        effectful_text.translated_content_context = true;
18854
18855        let translated_content = LayerNode {
18856            node_id: Some(78),
18857            local_bounds: Rect {
18858                x: 0.0,
18859                y: 0.0,
18860                width: 96.0,
18861                height: 64.0,
18862            },
18863            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
18864            motion_context_animated: false,
18865            translated_content_context: true,
18866            translated_content_offset: Point::default(),
18867            content_offset: Point::default(),
18868            scene_children_origin: cranpose_ui_graphics::Point::default(),
18869            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
18870            graphics_layer: GraphicsLayer::default(),
18871            clip_to_bounds: false,
18872            shadow_clip: None,
18873            hit_test: None,
18874            has_hit_targets: false,
18875            isolation: IsolationReasons::default(),
18876            cache_policy: CachePolicy::None,
18877            cache_hashes: LayerRasterCacheHashes::default(),
18878            cache_hashes_valid: false,
18879            children: vec![RenderNode::Layer(Box::new(effectful_text))],
18880        };
18881
18882        test_layer(
18883            Rect {
18884                x: 0.0,
18885                y: 0.0,
18886                width: 160.0,
18887                height: 120.0,
18888            },
18889            vec![RenderNode::Layer(Box::new(translated_content))],
18890        )
18891    }
18892
18893    #[test]
18894    fn scissor_rect_for_layer_intersects_with_clip() {
18895        let rect = Rect {
18896            x: 10.0,
18897            y: 10.0,
18898            width: 30.0,
18899            height: 20.0,
18900        };
18901        let clip = Rect {
18902            x: 20.0,
18903            y: 15.0,
18904            width: 100.0,
18905            height: 100.0,
18906        };
18907
18908        let scissor = scissor_rect_for_layer(rect, Some(clip), 1.0, 200, 200);
18909        assert_eq!(scissor, Some((20, 15, 20, 15)));
18910    }
18911
18912    #[test]
18913    fn visible_draw_rect_no_clip_returns_original() {
18914        let rect = Rect {
18915            x: 100.0,
18916            y: 200.0,
18917            width: 300.0,
18918            height: 400.0,
18919        };
18920        assert_eq!(visible_draw_rect(rect, None), Some(rect));
18921    }
18922
18923    #[test]
18924    fn visible_draw_rect_with_clip_intersects() {
18925        let rect = Rect {
18926            x: 0.0,
18927            y: 0.0,
18928            width: 2000.0,
18929            height: 5000.0,
18930        };
18931        let clip = Rect {
18932            x: 0.0,
18933            y: 0.0,
18934            width: 800.0,
18935            height: 600.0,
18936        };
18937        let visible = visible_draw_rect(rect, Some(clip)).expect("should have visible area");
18938        assert_eq!(visible.width, 800.0);
18939        assert_eq!(visible.height, 600.0);
18940    }
18941
18942    #[test]
18943    fn visible_draw_rect_fully_clipped_returns_none() {
18944        let rect = Rect {
18945            x: 1000.0,
18946            y: 1000.0,
18947            width: 200.0,
18948            height: 200.0,
18949        };
18950        let clip = Rect {
18951            x: 0.0,
18952            y: 0.0,
18953            width: 800.0,
18954            height: 600.0,
18955        };
18956        assert!(visible_draw_rect(rect, Some(clip)).is_none());
18957    }
18958
18959    #[test]
18960    fn scene_bounds_respects_clip_on_shapes() {
18961        let mut scene = CompositorScene::new();
18962        // Shape inside viewport — visible
18963        scene.shapes.push(DrawShape {
18964            rect: Rect {
18965                x: 10.0,
18966                y: 10.0,
18967                width: 100.0,
18968                height: 50.0,
18969            },
18970            clip: Some(Rect {
18971                x: 0.0,
18972                y: 0.0,
18973                width: 800.0,
18974                height: 600.0,
18975            }),
18976            ..test_shape(0, BlendMode::SrcOver)
18977        });
18978        // Shape far outside viewport — clipped away entirely
18979        scene.shapes.push(DrawShape {
18980            rect: Rect {
18981                x: 0.0,
18982                y: 3000.0,
18983                width: 100.0,
18984                height: 50.0,
18985            },
18986            clip: Some(Rect {
18987                x: 0.0,
18988                y: 0.0,
18989                width: 800.0,
18990                height: 600.0,
18991            }),
18992            ..test_shape(1, BlendMode::SrcOver)
18993        });
18994        let bounds = scene_bounds(&scene).expect("should have bounds");
18995        // Bounds should only cover the first shape's visible area,
18996        // NOT extend to y=3050 from the clipped second shape.
18997        assert!(bounds.y + bounds.height <= 600.0);
18998    }
18999
19000    #[test]
19001    fn scene_bounds_scroll_content_clipped_to_viewport() {
19002        // Simulates a scroll container: many items with large y offsets,
19003        // all clipped to a viewport-sized clip rect.
19004        let mut scene = CompositorScene::new();
19005        let viewport_clip = Rect {
19006            x: 0.0,
19007            y: 0.0,
19008            width: 800.0,
19009            height: 600.0,
19010        };
19011        for i in 0..20 {
19012            scene.shapes.push(DrawShape {
19013                rect: Rect {
19014                    x: 0.0,
19015                    y: i as f32 * 300.0,
19016                    width: 800.0,
19017                    height: 200.0,
19018                },
19019                clip: Some(viewport_clip),
19020                ..test_shape(i, BlendMode::SrcOver)
19021            });
19022        }
19023        let bounds = scene_bounds(&scene).expect("should have bounds");
19024        // All shapes are clipped to viewport — bounds should be viewport-sized,
19025        // NOT 20*300 = 6000 dp tall.
19026        assert_eq!(bounds.x, 0.0);
19027        assert_eq!(bounds.y, 0.0);
19028        assert!(bounds.width <= 800.0);
19029        assert!(bounds.height <= 600.0);
19030    }
19031
19032    #[test]
19033    fn scene_bounds_stable_across_scroll_offsets() {
19034        // Simulates horizontal scroll at different offsets —
19035        // bounds should be identical regardless of scroll position.
19036        let viewport_clip = Rect {
19037            x: 0.0,
19038            y: 0.0,
19039            width: 400.0,
19040            height: 50.0,
19041        };
19042        let compute_bounds_at_offset = |scroll_x: f32| {
19043            let mut scene = CompositorScene::new();
19044            for i in 0..10 {
19045                scene.shapes.push(DrawShape {
19046                    rect: Rect {
19047                        x: i as f32 * 100.0 - scroll_x,
19048                        y: 0.0,
19049                        width: 80.0,
19050                        height: 40.0,
19051                    },
19052                    clip: Some(viewport_clip),
19053                    ..test_shape(i, BlendMode::SrcOver)
19054                });
19055            }
19056            scene_bounds(&scene).expect("bounds")
19057        };
19058        let bounds_at_0 = compute_bounds_at_offset(0.0);
19059        let bounds_at_300 = compute_bounds_at_offset(300.0);
19060        let bounds_at_600 = compute_bounds_at_offset(600.0);
19061        // Width should be stable (clipped to viewport) regardless of scroll offset
19062        assert!(
19063            (bounds_at_0.width - bounds_at_300.width).abs() < 1.0,
19064            "bounds width changed with scroll: {} vs {}",
19065            bounds_at_0.width,
19066            bounds_at_300.width
19067        );
19068        assert!(
19069            (bounds_at_0.width - bounds_at_600.width).abs() < 1.0,
19070            "bounds width changed with scroll: {} vs {}",
19071            bounds_at_0.width,
19072            bounds_at_600.width
19073        );
19074    }
19075
19076    #[test]
19077    fn collect_effect_ranges_respects_excluded_effect() {
19078        let layers = vec![effect_layer(10, 40), effect_layer(20, 30)];
19079        let mut ranges = Vec::new();
19080        collect_effect_ranges(&layers, 10, 40, Some(0), &mut ranges);
19081        assert_eq!(ranges.len(), 1);
19082        assert_eq!(ranges[0], 20..30);
19083    }
19084
19085    #[test]
19086    fn collect_layer_events_includes_nested_when_parent_excluded() {
19087        let effects = vec![effect_layer(10, 40), effect_layer(20, 30)];
19088        let backdrops = vec![backdrop_layer(25)];
19089        let mut events = Vec::new();
19090        collect_layer_events(&effects, &backdrops, 10, 40, Some(0), &mut events);
19091        assert_eq!(events.len(), 2);
19092
19093        match events[0].kind {
19094            LayerEventKind::Effect(index) => assert_eq!(index, 1),
19095            LayerEventKind::Backdrop(_) => panic!("expected nested effect as first event"),
19096        }
19097        match events[1].kind {
19098            LayerEventKind::Backdrop(index) => assert_eq!(index, 0),
19099            LayerEventKind::Effect(_) => panic!("expected backdrop as second event"),
19100        }
19101    }
19102
19103    fn pure_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
19104        LayerNode {
19105            node_id: Some(177),
19106            local_bounds: Rect {
19107                x: 0.0,
19108                y: 0.0,
19109                width: 96.0,
19110                height: 32.0,
19111            },
19112            transform_to_parent: ProjectiveTransform::translation(11.4, 23.6),
19113            motion_context_animated: animated,
19114            translated_content_context,
19115            translated_content_offset: Point::default(),
19116            content_offset: Point::default(),
19117            scene_children_origin: cranpose_ui_graphics::Point::default(),
19118            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
19119            graphics_layer: GraphicsLayer::default(),
19120            clip_to_bounds: false,
19121            shadow_clip: None,
19122            hit_test: None,
19123            has_hit_targets: false,
19124            isolation: IsolationReasons::default(),
19125            cache_policy: CachePolicy::None,
19126            cache_hashes: LayerRasterCacheHashes::default(),
19127            cache_hashes_valid: false,
19128            children: vec![RenderNode::Primitive(PrimitiveEntry {
19129                phase: PrimitivePhase::BeforeChildren,
19130                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
19131                    node_id: 177,
19132                    rect: Rect {
19133                        x: 0.0,
19134                        y: 0.0,
19135                        width: 96.0,
19136                        height: 24.0,
19137                    },
19138                    clip: None,
19139                    text: std::rc::Rc::new(AnnotatedString::from("Pure text")),
19140                    text_style: TextStyle::default(),
19141                    font_size: 14.0,
19142                    layout_options: TextLayoutOptions::default(),
19143                })),
19144            })],
19145        }
19146    }
19147
19148    fn pure_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
19149        let text_leaf = pure_text_leaf(animated, translated_content_context);
19150        test_layer(
19151            Rect {
19152                x: 0.0,
19153                y: 0.0,
19154                width: 160.0,
19155                height: 96.0,
19156            },
19157            vec![RenderNode::Layer(Box::new(text_leaf))],
19158        )
19159    }
19160
19161    #[test]
19162    fn collect_layer_events_sorts_backdrop_before_effect_at_same_z() {
19163        let effects = vec![effect_layer(10, 20)];
19164        let backdrops = vec![backdrop_layer(10)];
19165        let mut events = Vec::new();
19166        collect_layer_events(&effects, &backdrops, 0, 30, None, &mut events);
19167        assert_eq!(events.len(), 2);
19168
19169        match events[0].kind {
19170            LayerEventKind::Backdrop(_) => {}
19171            LayerEventKind::Effect(_) => panic!("expected backdrop to run before effect"),
19172        }
19173        match events[1].kind {
19174            LayerEventKind::Effect(_) => {}
19175            LayerEventKind::Backdrop(_) => panic!("expected effect as second event"),
19176        }
19177    }
19178
19179    #[test]
19180    fn collect_layer_events_prefers_outer_effect_when_same_start_z() {
19181        // Child emitted before parent (matching scene collection order where a
19182        // parent effect is recorded after recursively processing children).
19183        let effects = vec![effect_layer(10, 20), effect_layer(10, 40)];
19184        let mut events = Vec::new();
19185        collect_layer_events(&effects, &[], 0, 50, None, &mut events);
19186
19187        assert_eq!(events.len(), 2);
19188        match events[0].kind {
19189            LayerEventKind::Effect(index) => assert_eq!(index, 1),
19190            LayerEventKind::Backdrop(_) => panic!("expected outer effect first"),
19191        }
19192        match events[1].kind {
19193            LayerEventKind::Effect(index) => assert_eq!(index, 0),
19194            LayerEventKind::Backdrop(_) => panic!("expected child effect second"),
19195        }
19196    }
19197
19198    #[test]
19199    fn collect_layer_events_prefers_later_effect_when_ranges_match() {
19200        let effects = vec![effect_layer(10, 20), effect_layer(10, 20)];
19201        let mut events = Vec::new();
19202        collect_layer_events(&effects, &[], 0, 30, None, &mut events);
19203
19204        assert_eq!(events.len(), 2);
19205        match events[0].kind {
19206            LayerEventKind::Effect(index) => assert_eq!(index, 1),
19207            LayerEventKind::Backdrop(_) => panic!("expected later effect first"),
19208        }
19209        match events[1].kind {
19210            LayerEventKind::Effect(index) => assert_eq!(index, 0),
19211            LayerEventKind::Backdrop(_) => panic!("expected earlier effect second"),
19212        }
19213    }
19214
19215    #[test]
19216    fn has_backdrop_layer_in_range_detects_nested_layers() {
19217        let backdrops = vec![backdrop_layer(5), backdrop_layer(15), backdrop_layer(25)];
19218        assert!(has_backdrop_layer_in_range(&backdrops, 10, 20));
19219        assert!(has_backdrop_layer_in_range(&backdrops, 0, 6));
19220        assert!(!has_backdrop_layer_in_range(&backdrops, 20, 25));
19221    }
19222
19223    #[test]
19224    fn layer_contains_descendant_backdrop_ignores_self_backdrop() {
19225        let mut self_backdrop = test_layer(
19226            Rect {
19227                x: 0.0,
19228                y: 0.0,
19229                width: 10.0,
19230                height: 10.0,
19231            },
19232            vec![],
19233        );
19234        self_backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
19235        assert!(!layer_contains_descendant_backdrop(&self_backdrop));
19236
19237        let mut child = test_layer(
19238            Rect {
19239                x: 0.0,
19240                y: 0.0,
19241                width: 8.0,
19242                height: 8.0,
19243            },
19244            vec![],
19245        );
19246        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
19247
19248        let parent = test_layer(
19249            Rect {
19250                x: 0.0,
19251                y: 0.0,
19252                width: 20.0,
19253                height: 20.0,
19254            },
19255            vec![RenderNode::Layer(Box::new(child))],
19256        );
19257        assert!(layer_contains_descendant_backdrop(&parent));
19258    }
19259
19260    fn child_layer_composite(
19261        layer: &LayerNode,
19262        z_index: usize,
19263        rect: Rect,
19264        needs_nested_underlay: bool,
19265    ) -> crate::normalized_scene::ChildLayerComposite {
19266        let mut requirements_cache = cranpose_core::collections::map::HashMap::new();
19267        let surface_requirements =
19268            crate::surface_plan::layer_surface_requirements_cached(layer, &mut requirements_cache);
19269        crate::normalized_scene::ChildLayerComposite {
19270            z_index,
19271            logical_rect: Rect {
19272                x: 0.0,
19273                y: 0.0,
19274                width: rect.width,
19275                height: rect.height,
19276            },
19277            dest_quad: rect_to_quad(rect),
19278            snap_anchor: None,
19279            composite_snap_origin: None,
19280            backdrop_rect: rect,
19281            visual_clip: None,
19282            surface_clip: None,
19283            shadow_draws: Vec::new(),
19284            needs_nested_underlay,
19285            node_id: layer.node_id,
19286            backdrop: layer.backdrop().cloned(),
19287            has_effect: layer.effect().is_some(),
19288            effect_contains_runtime_shader: layer
19289                .effect()
19290                .is_some_and(|effect| effect.contains_runtime_shader()),
19291            target_content_hash: layer.target_content_hash(),
19292            effect_hash: layer.effect_hash(),
19293            motion_source_content_hash: Some(layer.motion_source_content_hash()),
19294            contains_descendant_backdrop: layer_contains_descendant_backdrop(layer),
19295            cache_policy: layer.cache_policy,
19296            surface_requirements,
19297            rounded_clip: crate::surface_executor::backend::LayerSurfaceRoundedClip::from_layer(
19298                layer,
19299            ),
19300            isolation: cranpose_render_common::layer_composition::effective_layer_isolation(
19301                &layer.graphics_layer,
19302            ),
19303            translated_content_context: layer.translated_content_context,
19304            own_translated_content_axes: crate::surface_plan::translated_content_axes_for_layer(
19305                layer,
19306            ),
19307            clip_rect: layer.clip_rect(),
19308            local_bounds: layer.local_bounds,
19309            surface_scale: crate::surface_plan::layer_surface_scale(layer),
19310            source: crate::normalized_scene::LoweredChildSource::default(),
19311        }
19312    }
19313
19314    #[test]
19315    fn root_direct_preflight_allows_first_translated_child_underlay() {
19316        let child = test_layer(
19317            Rect {
19318                x: 0.0,
19319                y: 0.0,
19320                width: 400.0,
19321                height: 280.0,
19322            },
19323            vec![],
19324        );
19325        let collected = CollectedLayer {
19326            scene: CompositorScene::new(),
19327            child_layers: vec![child_layer_composite(
19328                &child,
19329                3,
19330                Rect {
19331                    x: 48.0,
19332                    y: 96.0,
19333                    width: 400.0,
19334                    height: 280.0,
19335                },
19336                true,
19337            )],
19338        };
19339
19340        assert!(direct_root_child_underlays_are_supported(&collected, false));
19341    }
19342
19343    #[test]
19344    fn root_direct_preflight_allows_axis_aligned_prior_child_underlay() {
19345        let first = test_layer(
19346            Rect {
19347                x: 0.0,
19348                y: 0.0,
19349                width: 80.0,
19350                height: 40.0,
19351            },
19352            vec![],
19353        );
19354        let backdrop_child = test_layer(
19355            Rect {
19356                x: 0.0,
19357                y: 0.0,
19358                width: 400.0,
19359                height: 280.0,
19360            },
19361            vec![],
19362        );
19363        let collected = CollectedLayer {
19364            scene: CompositorScene::new(),
19365            child_layers: vec![
19366                child_layer_composite(
19367                    &first,
19368                    1,
19369                    Rect {
19370                        x: 8.0,
19371                        y: 16.0,
19372                        width: 80.0,
19373                        height: 40.0,
19374                    },
19375                    false,
19376                ),
19377                child_layer_composite(
19378                    &backdrop_child,
19379                    4,
19380                    Rect {
19381                        x: 48.0,
19382                        y: 96.0,
19383                        width: 400.0,
19384                        height: 280.0,
19385                    },
19386                    true,
19387                ),
19388            ],
19389        };
19390
19391        assert!(direct_root_child_underlays_are_supported(&collected, false));
19392    }
19393
19394    #[test]
19395    fn root_direct_preflight_rejects_effectful_prior_child_underlay() {
19396        let mut first = test_layer(
19397            Rect {
19398                x: 0.0,
19399                y: 0.0,
19400                width: 80.0,
19401                height: 40.0,
19402            },
19403            vec![],
19404        );
19405        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19406        let backdrop_child = test_layer(
19407            Rect {
19408                x: 0.0,
19409                y: 0.0,
19410                width: 400.0,
19411                height: 280.0,
19412            },
19413            vec![],
19414        );
19415        let collected = CollectedLayer {
19416            scene: CompositorScene::new(),
19417            child_layers: vec![
19418                child_layer_composite(
19419                    &first,
19420                    1,
19421                    Rect {
19422                        x: 64.0,
19423                        y: 112.0,
19424                        width: 80.0,
19425                        height: 40.0,
19426                    },
19427                    false,
19428                ),
19429                child_layer_composite(
19430                    &backdrop_child,
19431                    4,
19432                    Rect {
19433                        x: 48.0,
19434                        y: 96.0,
19435                        width: 400.0,
19436                        height: 280.0,
19437                    },
19438                    true,
19439                ),
19440            ],
19441        };
19442
19443        assert!(!direct_root_child_underlays_are_supported(
19444            &collected, false
19445        ));
19446    }
19447
19448    #[test]
19449    fn root_direct_preflight_ignores_non_overlapping_effectful_prior_child_underlay() {
19450        let mut first = test_layer(
19451            Rect {
19452                x: 0.0,
19453                y: 0.0,
19454                width: 80.0,
19455                height: 40.0,
19456            },
19457            vec![],
19458        );
19459        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19460        let backdrop_child = test_layer(
19461            Rect {
19462                x: 0.0,
19463                y: 0.0,
19464                width: 400.0,
19465                height: 280.0,
19466            },
19467            vec![],
19468        );
19469        let collected = CollectedLayer {
19470            scene: CompositorScene::new(),
19471            child_layers: vec![
19472                child_layer_composite(
19473                    &first,
19474                    1,
19475                    Rect {
19476                        x: 8.0,
19477                        y: 16.0,
19478                        width: 80.0,
19479                        height: 40.0,
19480                    },
19481                    false,
19482                ),
19483                child_layer_composite(
19484                    &backdrop_child,
19485                    4,
19486                    Rect {
19487                        x: 48.0,
19488                        y: 96.0,
19489                        width: 400.0,
19490                        height: 280.0,
19491                    },
19492                    true,
19493                ),
19494            ],
19495        };
19496
19497        assert!(direct_root_child_underlays_are_supported(&collected, false));
19498    }
19499
19500    #[test]
19501    fn root_direct_preflight_rejects_underlay_that_would_replay_prior_scene_effects() {
19502        let backdrop_child = test_layer(
19503            Rect {
19504                x: 0.0,
19505                y: 0.0,
19506                width: 400.0,
19507                height: 280.0,
19508            },
19509            vec![],
19510        );
19511        let mut scene = CompositorScene::new();
19512        scene.next_z = 1;
19513        scene.push_effect_layer(
19514            Rect {
19515                x: 0.0,
19516                y: 0.0,
19517                width: 120.0,
19518                height: 120.0,
19519            },
19520            None,
19521            Some(RenderEffect::blur(2.0)),
19522            BlendMode::SrcOver,
19523            1.0,
19524            0,
19525            1,
19526        );
19527        let collected = CollectedLayer {
19528            scene,
19529            child_layers: vec![child_layer_composite(
19530                &backdrop_child,
19531                4,
19532                Rect {
19533                    x: 48.0,
19534                    y: 96.0,
19535                    width: 400.0,
19536                    height: 280.0,
19537                },
19538                true,
19539            )],
19540        };
19541
19542        assert!(!direct_root_child_underlays_are_supported(
19543            &collected, false
19544        ));
19545    }
19546
19547    #[test]
19548    fn root_direct_eligibility_does_not_reject_descendant_backdrop() {
19549        let mut backdrop = test_layer(
19550            Rect {
19551                x: 0.0,
19552                y: 0.0,
19553                width: 40.0,
19554                height: 40.0,
19555            },
19556            vec![],
19557        );
19558        backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
19559        let child = test_layer(
19560            Rect {
19561                x: 0.0,
19562                y: 0.0,
19563                width: 120.0,
19564                height: 96.0,
19565            },
19566            vec![RenderNode::Layer(Box::new(backdrop))],
19567        );
19568        let root = test_layer(
19569            Rect {
19570                x: 0.0,
19571                y: 0.0,
19572                width: 240.0,
19573                height: 160.0,
19574            },
19575            vec![RenderNode::Layer(Box::new(child))],
19576        );
19577        let mut cache = HashMap::new();
19578
19579        assert!(root_can_render_directly_cached(&root, &mut cache));
19580    }
19581
19582    #[test]
19583    fn root_direct_scene_events_allow_root_local_effects() {
19584        let mut scene = CompositorScene::new();
19585        scene.effect_layers.push(EffectLayer {
19586            rect: Rect {
19587                x: 20.0,
19588                y: 30.0,
19589                width: 120.0,
19590                height: 80.0,
19591            },
19592            clip: None,
19593            snap_anchor: None,
19594            effect: Some(RenderEffect::blur(6.0)),
19595            blend_mode: BlendMode::SrcOver,
19596            composite_alpha: 1.0,
19597            z_start: 0,
19598            z_end: 1,
19599            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
19600        });
19601
19602        assert!(root_direct_scene_events_are_supported(&scene, false));
19603    }
19604
19605    #[test]
19606    fn root_direct_scene_events_reject_root_local_backdrops() {
19607        let mut scene = CompositorScene::new();
19608        scene.backdrop_layers.push(BackdropLayer {
19609            node_id: Some(99),
19610            rect: Rect {
19611                x: 20.0,
19612                y: 30.0,
19613                width: 120.0,
19614                height: 80.0,
19615            },
19616            clip: None,
19617            snap_anchor: None,
19618            effect: RenderEffect::blur(6.0),
19619            z_index: 1,
19620        });
19621
19622        assert!(!root_direct_scene_events_are_supported(&scene, false));
19623    }
19624
19625    fn scene_with_root_local_backdrop(z_index: usize) -> CompositorScene {
19626        let mut scene = CompositorScene::new();
19627        scene.next_z = z_index + 1;
19628        scene.backdrop_layers.push(BackdropLayer {
19629            node_id: Some(99),
19630            rect: Rect {
19631                x: 20.0,
19632                y: 30.0,
19633                width: 120.0,
19634                height: 80.0,
19635            },
19636            clip: None,
19637            snap_anchor: None,
19638            effect: RenderEffect::blur(6.0),
19639            z_index,
19640        });
19641        scene
19642    }
19643
19644    #[test]
19645    fn a_root_local_backdrop_takes_the_direct_road_when_the_target_reads() {
19646        let scene = scene_with_root_local_backdrop(1);
19647        assert!(root_direct_scene_events_are_supported(&scene, true));
19648    }
19649
19650    #[test]
19651    fn a_backdrop_inside_an_effect_layer_stays_off_the_direct_road() {
19652        let mut scene = scene_with_root_local_backdrop(1);
19653        scene.next_z = 3;
19654        scene.effect_layers.push(EffectLayer {
19655            rect: Rect {
19656                x: 0.0,
19657                y: 0.0,
19658                width: 200.0,
19659                height: 200.0,
19660            },
19661            clip: None,
19662            snap_anchor: None,
19663            effect: Some(RenderEffect::blur(6.0)),
19664            blend_mode: BlendMode::SrcOver,
19665            composite_alpha: 1.0,
19666            z_start: 0,
19667            z_end: 3,
19668            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
19669        });
19670
19671        assert!(!root_direct_scene_events_are_supported(&scene, true));
19672        assert!(!root_direct_scene_events_are_supported(&scene, false));
19673    }
19674
19675    #[test]
19676    fn a_child_that_carries_a_backdrop_takes_the_direct_road_when_the_target_reads() {
19677        let mut backdrop_child = test_layer(
19678            Rect {
19679                x: 0.0,
19680                y: 0.0,
19681                width: 400.0,
19682                height: 280.0,
19683            },
19684            vec![],
19685        );
19686        backdrop_child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
19687        let collected = CollectedLayer {
19688            scene: CompositorScene::new(),
19689            child_layers: vec![child_layer_composite(
19690                &backdrop_child,
19691                1,
19692                Rect {
19693                    x: 48.0,
19694                    y: 96.0,
19695                    width: 400.0,
19696                    height: 280.0,
19697                },
19698                false,
19699            )],
19700        };
19701
19702        assert!(collected.child_layers[0].backdrop.is_some());
19703        assert!(direct_root_child_underlays_are_supported(&collected, true));
19704        assert!(!direct_root_child_underlays_are_supported(
19705            &collected, false
19706        ));
19707    }
19708
19709    fn frosted_layer(bounds: Rect, offset: Point) -> LayerNode {
19710        let mut layer = test_layer(
19711            bounds,
19712            vec![RenderNode::Primitive(PrimitiveEntry {
19713                phase: PrimitivePhase::BeforeChildren,
19714                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19715                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19716                        rect: bounds,
19717                        brush: Brush::solid(Color::from_rgba_u8(255, 255, 255, 60)),
19718                        stroke: None,
19719                    },
19720                    clip: None,
19721                }),
19722            })],
19723        );
19724        layer.transform_to_parent = ProjectiveTransform::translation(offset.x, offset.y);
19725        layer.graphics_layer.backdrop_effect = Some(RenderEffect::blur(8.0));
19726        layer
19727    }
19728
19729    #[test]
19730    fn a_frosted_layer_keeps_its_own_surface() {
19731        let frosted = frosted_layer(
19732            Rect {
19733                x: 0.0,
19734                y: 0.0,
19735                width: 40.0,
19736                height: 20.0,
19737            },
19738            Point::new(10.0, 6.0),
19739        );
19740        let root = test_layer(
19741            Rect {
19742                x: 0.0,
19743                y: 0.0,
19744                width: 200.0,
19745                height: 100.0,
19746            },
19747            vec![RenderNode::Layer(Box::new(frosted))],
19748        );
19749        let mut rect_cache = HashMap::new();
19750        let mut requirements_cache = HashMap::new();
19751
19752        let collected =
19753            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19754
19755        assert_eq!(collected.child_layers.len(), 1);
19756        assert!(
19757            collected.scene.backdrop_layers.is_empty(),
19758            "a layer that keeps its surface carries its backdrop on the composite"
19759        );
19760        assert!(collected.child_layers[0].backdrop.is_some());
19761    }
19762
19763    fn row_with_clipped_glass(row_background: Color) -> LayerNode {
19764        let mut glass = frosted_layer(
19765            Rect {
19766                x: 0.0,
19767                y: 0.0,
19768                width: 40.0,
19769                height: 20.0,
19770            },
19771            Point::new(10.0, 6.0),
19772        );
19773        glass.isolation.shape_clip = true;
19774        let row_bounds = Rect {
19775            x: 0.0,
19776            y: 0.0,
19777            width: 200.0,
19778            height: 40.0,
19779        };
19780        let mut row = test_layer(
19781            row_bounds,
19782            vec![
19783                RenderNode::Primitive(PrimitiveEntry {
19784                    phase: PrimitivePhase::BeforeChildren,
19785                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
19786                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19787                            rect: row_bounds,
19788                            brush: Brush::solid(row_background),
19789                            stroke: None,
19790                        },
19791                        clip: None,
19792                    }),
19793                }),
19794                RenderNode::Layer(Box::new(glass)),
19795            ],
19796        );
19797        row.isolation.shape_clip = true;
19798        row
19799    }
19800
19801    #[test]
19802    fn a_backdrop_covered_by_its_own_row_asks_for_no_underlay() {
19803        let root = test_layer(
19804            Rect {
19805                x: 0.0,
19806                y: 0.0,
19807                width: 400.0,
19808                height: 200.0,
19809            },
19810            vec![RenderNode::Layer(Box::new(row_with_clipped_glass(
19811                Color::WHITE,
19812            )))],
19813        );
19814        let mut rect_cache = HashMap::new();
19815        let mut requirements_cache = HashMap::new();
19816
19817        let collected =
19818            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19819
19820        assert_eq!(collected.child_layers.len(), 1);
19821        assert!(collected.child_layers[0].contains_descendant_backdrop);
19822        assert!(
19823            !collected.child_layers[0].needs_nested_underlay,
19824            "an opaque row draw under the glass is all the blur reads, so no picture of the scene behind the row is needed"
19825        );
19826    }
19827
19828    #[test]
19829    fn a_backdrop_over_a_see_through_row_still_asks_for_an_underlay() {
19830        let root = test_layer(
19831            Rect {
19832                x: 0.0,
19833                y: 0.0,
19834                width: 400.0,
19835                height: 200.0,
19836            },
19837            vec![RenderNode::Layer(Box::new(row_with_clipped_glass(
19838                Color::from_rgba_u8(255, 255, 255, 40),
19839            )))],
19840        );
19841        let mut rect_cache = HashMap::new();
19842        let mut requirements_cache = HashMap::new();
19843
19844        let collected =
19845            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19846
19847        assert_eq!(collected.child_layers.len(), 1);
19848        assert!(collected.child_layers[0].needs_nested_underlay);
19849    }
19850
19851    #[test]
19852    fn estimate_layer_surface_rect_includes_transformed_child_bounds() {
19853        let mut child = test_layer(
19854            Rect {
19855                x: 0.0,
19856                y: 0.0,
19857                width: 10.0,
19858                height: 6.0,
19859            },
19860            vec![RenderNode::Primitive(PrimitiveEntry {
19861                phase: PrimitivePhase::BeforeChildren,
19862                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19863                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19864                        rect: Rect {
19865                            x: 0.0,
19866                            y: 0.0,
19867                            width: 10.0,
19868                            height: 6.0,
19869                        },
19870                        brush: Brush::solid(Color::WHITE),
19871                        stroke: None,
19872                    },
19873                    clip: None,
19874                }),
19875            })],
19876        );
19877        child.transform_to_parent = ProjectiveTransform::translation(18.0, 7.0);
19878
19879        let parent = test_layer(
19880            Rect {
19881                x: 0.0,
19882                y: 0.0,
19883                width: 4.0,
19884                height: 4.0,
19885            },
19886            vec![RenderNode::Layer(Box::new(child))],
19887        );
19888
19889        assert_eq!(
19890            estimate_layer_surface_rect(&parent),
19891            Rect {
19892                x: 18.0,
19893                y: 7.0,
19894                width: 10.0,
19895                height: 6.0,
19896            }
19897        );
19898    }
19899
19900    #[test]
19901    fn estimate_layer_surface_rect_clips_translated_clip_layers_without_hidden_leading_content() {
19902        let mut layer = test_layer(
19903            Rect {
19904                x: 0.0,
19905                y: 0.0,
19906                width: 120.0,
19907                height: 72.0,
19908            },
19909            vec![RenderNode::Primitive(PrimitiveEntry {
19910                phase: PrimitivePhase::BeforeChildren,
19911                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19912                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19913                        rect: Rect {
19914                            x: 24.0,
19915                            y: 0.0,
19916                            width: 200.0,
19917                            height: 480.0,
19918                        },
19919                        brush: Brush::solid(Color::WHITE),
19920                        stroke: None,
19921                    },
19922                    clip: None,
19923                }),
19924            })],
19925        );
19926        layer.translated_content_context = true;
19927        layer.motion_context_animated = true;
19928        layer.clip_to_bounds = true;
19929
19930        assert_eq!(
19931            estimate_layer_surface_rect(&layer),
19932            Rect {
19933                x: 24.0,
19934                y: 0.0,
19935                width: 96.0,
19936                height: 72.0,
19937            }
19938        );
19939    }
19940
19941    #[test]
19942    fn estimate_layer_surface_rect_clips_active_horizontal_scroll_content() {
19943        let mut layer = test_layer(
19944            Rect {
19945                x: 0.0,
19946                y: 0.0,
19947                width: 120.0,
19948                height: 72.0,
19949            },
19950            vec![RenderNode::Primitive(PrimitiveEntry {
19951                phase: PrimitivePhase::BeforeChildren,
19952                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19953                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19954                        rect: Rect {
19955                            x: -24.0,
19956                            y: 0.0,
19957                            width: 200.0,
19958                            height: 480.0,
19959                        },
19960                        brush: Brush::solid(Color::WHITE),
19961                        stroke: None,
19962                    },
19963                    clip: None,
19964                }),
19965            })],
19966        );
19967        layer.translated_content_context = true;
19968        layer.motion_context_animated = true;
19969        layer.clip_to_bounds = true;
19970
19971        assert_eq!(
19972            estimate_layer_surface_rect(&layer),
19973            Rect {
19974                x: 0.0,
19975                y: 0.0,
19976                width: 120.0,
19977                height: 72.0,
19978            }
19979        );
19980    }
19981
19982    #[test]
19983    fn estimate_layer_surface_rect_clips_active_vertical_scroll_content() {
19984        let mut layer = test_layer(
19985            Rect {
19986                x: 0.0,
19987                y: 0.0,
19988                width: 120.0,
19989                height: 72.0,
19990            },
19991            vec![RenderNode::Primitive(PrimitiveEntry {
19992                phase: PrimitivePhase::BeforeChildren,
19993                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19994                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
19995                        rect: Rect {
19996                            x: 0.0,
19997                            y: -24.0,
19998                            width: 120.0,
19999                            height: 200.0,
20000                        },
20001                        brush: Brush::solid(Color::WHITE),
20002                        stroke: None,
20003                    },
20004                    clip: None,
20005                }),
20006            })],
20007        );
20008        layer.translated_content_context = true;
20009        layer.motion_context_animated = true;
20010        layer.clip_to_bounds = true;
20011
20012        assert_eq!(
20013            estimate_layer_surface_rect(&layer),
20014            Rect {
20015                x: 0.0,
20016                y: 0.0,
20017                width: 120.0,
20018                height: 72.0,
20019            }
20020        );
20021    }
20022
20023    #[test]
20024    fn estimate_layer_surface_rect_keeps_shallow_scroll_capture_origin_stable() {
20025        fn shallow_scroll_surface_rect(content_y: f32) -> Rect {
20026            let mut layer = test_layer(
20027                Rect {
20028                    x: 0.0,
20029                    y: 0.0,
20030                    width: 120.0,
20031                    height: 72.0,
20032                },
20033                vec![RenderNode::Primitive(PrimitiveEntry {
20034                    phase: PrimitivePhase::BeforeChildren,
20035                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
20036                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20037                            rect: Rect {
20038                                x: 0.0,
20039                                y: content_y,
20040                                width: 120.0,
20041                                height: 200.0,
20042                            },
20043                            brush: Brush::solid(Color::WHITE),
20044                            stroke: None,
20045                        },
20046                        clip: None,
20047                    }),
20048                })],
20049            );
20050            layer.translated_content_context = true;
20051            layer.motion_context_animated = true;
20052            layer.clip_to_bounds = true;
20053            estimate_layer_surface_rect(&layer)
20054        }
20055
20056        assert_eq!(
20057            shallow_scroll_surface_rect(-24.0),
20058            shallow_scroll_surface_rect(-25.0),
20059            "shallow scroll capture bounds must not move the offscreen surface origin on adjacent scroll positions"
20060        );
20061    }
20062
20063    #[test]
20064    fn estimate_layer_surface_rect_clips_active_xy_scroll_content() {
20065        let mut layer = test_layer(
20066            Rect {
20067                x: 0.0,
20068                y: 0.0,
20069                width: 120.0,
20070                height: 72.0,
20071            },
20072            vec![RenderNode::Primitive(PrimitiveEntry {
20073                phase: PrimitivePhase::BeforeChildren,
20074                node: PrimitiveNode::Draw(DrawPrimitiveNode {
20075                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20076                        rect: Rect {
20077                            x: -16.0,
20078                            y: -24.0,
20079                            width: 180.0,
20080                            height: 240.0,
20081                        },
20082                        brush: Brush::solid(Color::WHITE),
20083                        stroke: None,
20084                    },
20085                    clip: None,
20086                }),
20087            })],
20088        );
20089        layer.translated_content_context = true;
20090        layer.motion_context_animated = true;
20091        layer.clip_to_bounds = true;
20092
20093        assert_eq!(
20094            estimate_layer_surface_rect(&layer),
20095            Rect {
20096                x: 0.0,
20097                y: 0.0,
20098                width: 120.0,
20099                height: 72.0,
20100            }
20101        );
20102    }
20103
20104    #[test]
20105    fn estimate_layer_surface_rect_clips_deep_hidden_active_scroll_content() {
20106        let mut layer = test_layer(
20107            Rect {
20108                x: 0.0,
20109                y: 0.0,
20110                width: 120.0,
20111                height: 72.0,
20112            },
20113            vec![RenderNode::Primitive(PrimitiveEntry {
20114                phase: PrimitivePhase::BeforeChildren,
20115                node: PrimitiveNode::Draw(DrawPrimitiveNode {
20116                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20117                        rect: Rect {
20118                            x: 0.0,
20119                            y: -1200.0,
20120                            width: 120.0,
20121                            height: 1400.0,
20122                        },
20123                        brush: Brush::solid(Color::WHITE),
20124                        stroke: None,
20125                    },
20126                    clip: None,
20127                }),
20128            })],
20129        );
20130        layer.translated_content_context = true;
20131        layer.motion_context_animated = true;
20132        layer.clip_to_bounds = true;
20133
20134        assert_eq!(
20135            estimate_layer_surface_rect(&layer),
20136            Rect {
20137                x: 0.0,
20138                y: 0.0,
20139                width: 120.0,
20140                height: 72.0,
20141            }
20142        );
20143    }
20144
20145    #[test]
20146    fn estimate_layer_surface_rect_keeps_deep_scroll_capture_origin_stable() {
20147        fn deep_scroll_surface_rect(content_y: f32) -> Rect {
20148            let mut layer = test_layer(
20149                Rect {
20150                    x: 0.0,
20151                    y: 0.0,
20152                    width: 120.0,
20153                    height: 72.0,
20154                },
20155                vec![RenderNode::Primitive(PrimitiveEntry {
20156                    phase: PrimitivePhase::BeforeChildren,
20157                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
20158                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20159                            rect: Rect {
20160                                x: 0.0,
20161                                y: content_y,
20162                                width: 120.0,
20163                                height: 1400.0,
20164                            },
20165                            brush: Brush::solid(Color::WHITE),
20166                            stroke: None,
20167                        },
20168                        clip: None,
20169                    }),
20170                })],
20171            );
20172            layer.translated_content_context = true;
20173            layer.motion_context_animated = true;
20174            layer.clip_to_bounds = true;
20175            estimate_layer_surface_rect(&layer)
20176        }
20177
20178        assert_eq!(
20179            deep_scroll_surface_rect(-1200.0),
20180            deep_scroll_surface_rect(-1201.0),
20181            "deep scroll capture bounds must not re-phase the offscreen surface origin on adjacent scroll positions"
20182        );
20183    }
20184
20185    #[test]
20186    fn motion_stable_capture_bounds_bounds_shadows_for_clipped_effect_layer() {
20187        let mut layer = test_layer(
20188            Rect {
20189                x: 0.0,
20190                y: 0.0,
20191                width: 120.0,
20192                height: 72.0,
20193            },
20194            vec![],
20195        );
20196        layer.clip_to_bounds = true;
20197        layer.graphics_layer.clip = true;
20198        layer.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
20199
20200        let mut shadow_shape = test_shape(0, BlendMode::SrcOver);
20201        shadow_shape.rect = Rect {
20202            x: -24.0,
20203            y: -1200.0,
20204            width: 180.0,
20205            height: 1400.0,
20206        };
20207        let mut scene = CompositorScene::new();
20208        scene
20209            .shadow_draws
20210            .push(test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]));
20211
20212        let requirements = SurfaceRequirementSet::default()
20213            .with(SurfaceRequirement::RenderEffect)
20214            .with(SurfaceRequirement::MotionStableCapture);
20215
20216        assert_eq!(
20217            motion_stable_capture_bounds(
20218                &layer,
20219                &scene,
20220                &[],
20221                requirements,
20222                TranslatedContentAxes::default(),
20223                None,
20224            ),
20225            Some(Rect {
20226                x: -360.0,
20227                y: -216.0,
20228                width: 480.0,
20229                height: 288.0,
20230            })
20231        );
20232    }
20233
20234    #[test]
20235    fn vertical_motion_stable_capture_uses_viewport_cross_axis_bounds() {
20236        let mut layer = test_layer(
20237            Rect {
20238                x: 0.0,
20239                y: 0.0,
20240                width: 200.0,
20241                height: 100.0,
20242            },
20243            vec![],
20244        );
20245        layer.clip_to_bounds = true;
20246        layer.graphics_layer.clip = true;
20247
20248        let mut shape = test_shape(0, BlendMode::SrcOver);
20249        shape.rect = Rect {
20250            x: 60.0,
20251            y: -80.0,
20252            width: 80.0,
20253            height: 220.0,
20254        };
20255        let mut scene = CompositorScene::new();
20256        scene.shapes.push(shape);
20257
20258        let requirements =
20259            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
20260
20261        assert_eq!(
20262            motion_stable_capture_bounds(
20263                &layer,
20264                &scene,
20265                &[],
20266                requirements,
20267                TranslatedContentAxes { x: false, y: true },
20268                None,
20269            ),
20270            Some(Rect {
20271                x: -96.0,
20272                y: -64.0,
20273                width: 296.0,
20274                height: 164.0,
20275            })
20276        );
20277    }
20278
20279    #[test]
20280    fn vertical_motion_stable_capture_uses_external_surface_clip() {
20281        let layer = test_layer(
20282            Rect {
20283                x: 0.0,
20284                y: 0.0,
20285                width: 200.0,
20286                height: 100.0,
20287            },
20288            vec![],
20289        );
20290
20291        let mut shape = test_shape(0, BlendMode::SrcOver);
20292        shape.rect = Rect {
20293            x: 60.0,
20294            y: -80.0,
20295            width: 80.0,
20296            height: 220.0,
20297        };
20298        let mut scene = CompositorScene::new();
20299        scene.shapes.push(shape);
20300
20301        let requirements =
20302            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
20303
20304        assert_eq!(
20305            motion_stable_capture_bounds(
20306                &layer,
20307                &scene,
20308                &[],
20309                requirements,
20310                TranslatedContentAxes { x: false, y: true },
20311                Some(Rect {
20312                    x: 0.0,
20313                    y: 0.0,
20314                    width: 200.0,
20315                    height: 100.0,
20316                }),
20317            ),
20318            Some(Rect {
20319                x: -96.0,
20320                y: -64.0,
20321                width: 296.0,
20322                height: 164.0,
20323            })
20324        );
20325    }
20326
20327    #[test]
20328    fn estimate_layer_surface_rect_expands_for_child_layer_shadow() {
20329        let mut child = test_layer(
20330            Rect {
20331                x: 0.0,
20332                y: 0.0,
20333                width: 12.0,
20334                height: 8.0,
20335            },
20336            vec![],
20337        );
20338        child.transform_to_parent = ProjectiveTransform::translation(20.0, 9.0);
20339        child.graphics_layer.shadow_elevation = 6.0;
20340
20341        let parent = test_layer(
20342            Rect {
20343                x: 0.0,
20344                y: 0.0,
20345                width: 4.0,
20346                height: 4.0,
20347            },
20348            vec![RenderNode::Layer(Box::new(child))],
20349        );
20350
20351        let rect = estimate_layer_surface_rect(&parent);
20352        assert!(rect.x < 20.0);
20353        assert!(rect.y < 9.0);
20354        assert!(rect.width > 12.0);
20355        assert!(rect.height > 8.0);
20356    }
20357
20358    #[test]
20359    fn estimate_layer_surface_rect_respects_local_bounds_for_effect_layers() {
20360        let mut layer = test_layer(
20361            Rect {
20362                x: 0.0,
20363                y: 0.0,
20364                width: 28.0,
20365                height: 28.0,
20366            },
20367            vec![RenderNode::Primitive(PrimitiveEntry {
20368                phase: PrimitivePhase::BeforeChildren,
20369                node: PrimitiveNode::Draw(DrawPrimitiveNode {
20370                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20371                        rect: Rect {
20372                            x: 10.0,
20373                            y: 10.0,
20374                            width: 10.0,
20375                            height: 10.0,
20376                        },
20377                        brush: Brush::solid(Color::WHITE),
20378                        stroke: None,
20379                    },
20380                    clip: None,
20381                }),
20382            })],
20383        );
20384        layer.graphics_layer.render_effect = Some(RenderEffect::blur(12.0));
20385
20386        assert_eq!(
20387            estimate_layer_surface_rect(&layer),
20388            Rect {
20389                x: 0.0,
20390                y: 0.0,
20391                width: 28.0,
20392                height: 28.0,
20393            }
20394        );
20395    }
20396
20397    #[test]
20398    fn layer_raster_cache_candidate_ignores_parent_transform() {
20399        let primitive = PrimitiveEntry {
20400            phase: PrimitivePhase::BeforeChildren,
20401            node: PrimitiveNode::Draw(DrawPrimitiveNode {
20402                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20403                    rect: Rect {
20404                        x: 2.0,
20405                        y: 3.0,
20406                        width: 6.0,
20407                        height: 4.0,
20408                    },
20409                    brush: Brush::solid(Color::BLACK),
20410                    stroke: None,
20411                },
20412                clip: None,
20413            }),
20414        };
20415        let base = cacheable_layer(
20416            41,
20417            Rect {
20418                x: 0.0,
20419                y: 0.0,
20420                width: 20.0,
20421                height: 20.0,
20422            },
20423            vec![RenderNode::Primitive(primitive.clone())],
20424        );
20425        let mut moved = base.clone();
20426        moved.transform_to_parent = ProjectiveTransform::translation(32.0, 18.0);
20427
20428        assert_eq!(
20429            layer_raster_cache_candidate(&base, 1.25, false, false),
20430            layer_raster_cache_candidate(&moved, 1.25, false, false)
20431        );
20432    }
20433
20434    #[test]
20435    fn layer_raster_cache_candidate_changes_for_translated_content_offset() {
20436        let primitive = PrimitiveEntry {
20437            phase: PrimitivePhase::BeforeChildren,
20438            node: PrimitiveNode::Draw(DrawPrimitiveNode {
20439                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
20440                    rect: Rect {
20441                        x: 2.0,
20442                        y: 3.0,
20443                        width: 6.0,
20444                        height: 4.0,
20445                    },
20446                    brush: Brush::solid(Color::BLACK),
20447                    stroke: None,
20448                },
20449                clip: None,
20450            }),
20451        };
20452        let mut base = cacheable_layer(
20453            42,
20454            Rect {
20455                x: 0.0,
20456                y: 0.0,
20457                width: 20.0,
20458                height: 20.0,
20459            },
20460            vec![RenderNode::Primitive(primitive)],
20461        );
20462        base.translated_content_context = true;
20463        base.translated_content_offset = Point::new(0.0, -8.0);
20464        base.recompute_raster_cache_hashes();
20465
20466        let mut moved = base.clone();
20467        moved.translated_content_offset = Point::new(0.0, -16.0);
20468        moved.recompute_raster_cache_hashes();
20469
20470        assert_ne!(
20471            layer_raster_cache_candidate(&base, 1.25, false, false),
20472            layer_raster_cache_candidate(&moved, 1.25, false, false),
20473            "full-surface layer cache candidates must not alias different scroll offsets"
20474        );
20475    }
20476
20477    #[test]
20478    fn layer_raster_cache_candidate_changes_for_child_transform() {
20479        let mut child = cacheable_layer(
20480            8,
20481            Rect {
20482                x: 0.0,
20483                y: 0.0,
20484                width: 12.0,
20485                height: 10.0,
20486            },
20487            vec![],
20488        );
20489        child.transform_to_parent = ProjectiveTransform::translation(4.0, 6.0);
20490        let base = cacheable_layer(
20491            7,
20492            Rect {
20493                x: 0.0,
20494                y: 0.0,
20495                width: 20.0,
20496                height: 20.0,
20497            },
20498            vec![RenderNode::Layer(Box::new(child.clone()))],
20499        );
20500        let mut moved_child = child;
20501        moved_child.transform_to_parent = ProjectiveTransform::translation(9.0, 6.0);
20502        let moved = cacheable_layer(
20503            7,
20504            Rect {
20505                x: 0.0,
20506                y: 0.0,
20507                width: 20.0,
20508                height: 20.0,
20509            },
20510            vec![RenderNode::Layer(Box::new(moved_child))],
20511        );
20512
20513        assert_ne!(
20514            layer_raster_cache_candidate(&base, 1.0, false, false),
20515            layer_raster_cache_candidate(&moved, 1.0, false, false)
20516        );
20517    }
20518
20519    #[test]
20520    fn layer_raster_cache_candidate_rejects_external_backdrop_dependency() {
20521        let mut child = cacheable_layer(
20522            12,
20523            Rect {
20524                x: 0.0,
20525                y: 0.0,
20526                width: 8.0,
20527                height: 8.0,
20528            },
20529            vec![],
20530        );
20531        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
20532        let parent = cacheable_layer(
20533            11,
20534            Rect {
20535                x: 0.0,
20536                y: 0.0,
20537                width: 16.0,
20538                height: 16.0,
20539            },
20540            vec![RenderNode::Layer(Box::new(child))],
20541        );
20542
20543        assert!(layer_raster_cache_candidate(&parent, 1.0, false, false).is_some());
20544        assert!(layer_raster_cache_candidate(&parent, 1.0, true, false).is_none());
20545    }
20546
20547    #[test]
20548    fn layer_raster_cache_candidate_does_not_force_translation_only_text_surfaces() {
20549        let text = RenderNode::Primitive(PrimitiveEntry {
20550            phase: PrimitivePhase::BeforeChildren,
20551            node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
20552                node_id: 77,
20553                rect: Rect {
20554                    x: 2.0,
20555                    y: 3.0,
20556                    width: 48.0,
20557                    height: 18.0,
20558                },
20559                text: std::rc::Rc::new(AnnotatedString::from("runtime cache")),
20560                text_style: TextStyle::default(),
20561                font_size: 14.0,
20562                layout_options: TextLayoutOptions::default(),
20563                clip: None,
20564            })),
20565        });
20566        let mut layer = test_layer(
20567            Rect {
20568                x: 0.0,
20569                y: 0.0,
20570                width: 64.0,
20571                height: 32.0,
20572            },
20573            vec![text],
20574        );
20575        layer.node_id = Some(77);
20576        layer.recompute_raster_cache_hashes();
20577
20578        assert!(
20579            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
20580            "root path should not isolate plain translation-only text layers"
20581        );
20582        assert!(
20583            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
20584            "child path should also render plain translation-only text layers directly"
20585        );
20586    }
20587
20588    #[test]
20589    fn layer_raster_cache_candidate_allows_stable_runtime_child_effect_surfaces() {
20590        let mut layer = test_layer(
20591            Rect {
20592                x: 0.0,
20593                y: 0.0,
20594                width: 64.0,
20595                height: 32.0,
20596            },
20597            vec![RenderNode::Primitive(PrimitiveEntry {
20598                phase: PrimitivePhase::BeforeChildren,
20599                node: PrimitiveNode::Draw(DrawPrimitiveNode {
20600                    primitive: DrawPrimitive::Rect {
20601                        rect: Rect {
20602                            x: 0.0,
20603                            y: 0.0,
20604                            width: 64.0,
20605                            height: 32.0,
20606                        },
20607                        brush: Brush::solid(Color::WHITE),
20608                        stroke: None,
20609                    },
20610                    clip: None,
20611                }),
20612            })],
20613        );
20614        layer.node_id = Some(78);
20615        layer.graphics_layer.render_effect = Some(RenderEffect::blur(4.0));
20616        layer.recompute_raster_cache_hashes();
20617
20618        assert!(
20619            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
20620            "root direct path should not force-cache ordinary stable effects"
20621        );
20622        assert!(
20623            layer_raster_cache_candidate(&layer, 1.0, false, true).is_some(),
20624            "child surface rendering should retain stable non-runtime effects"
20625        );
20626    }
20627
20628    #[test]
20629    fn layer_raster_cache_candidate_rejects_runtime_shader_child_effect_surfaces() {
20630        let mut layer = test_layer(
20631            Rect {
20632                x: 0.0,
20633                y: 0.0,
20634                width: 64.0,
20635                height: 32.0,
20636            },
20637            vec![],
20638        );
20639        layer.node_id = Some(79);
20640        layer.graphics_layer.render_effect = Some(RenderEffect::runtime_shader(
20641            RuntimeShader::new("runtime shader"),
20642        ));
20643        layer.recompute_raster_cache_hashes();
20644
20645        assert!(
20646            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
20647            "runtime shaders must not fill the retained layer cache with per-frame uniform variants"
20648        );
20649    }
20650
20651    #[test]
20652    fn layer_surface_requirements_keep_plain_text_on_direct_path() {
20653        let layer = text_layer_with_style(AnnotatedString::from("plain"), TextStyle::default());
20654
20655        let requirements = layer_surface_requirements(&layer);
20656
20657        assert_eq!(requirements.direct_translation, Some(Point::default()));
20658        assert!(requirements
20659            .surface_requirements
20660            .contains(SurfaceRequirement::PixelStableComposite));
20661        assert!(!requirements
20662            .surface_requirements
20663            .has_isolating_requirement());
20664    }
20665
20666    #[test]
20667    fn layer_surface_requirements_keep_translated_plain_text_leaf_on_direct_path() {
20668        let layer = pure_text_leaf(false, true);
20669
20670        let requirements = layer_surface_requirements(&layer);
20671
20672        assert_eq!(
20673            requirements.direct_translation,
20674            Some(Point::new(11.4, 23.6))
20675        );
20676        assert!(
20677            requirements
20678                .surface_requirements
20679                .contains(SurfaceRequirement::PixelStableComposite)
20680                && !requirements
20681                    .surface_requirements
20682                    .has_isolating_requirement(),
20683            "translated plain text should stay on the direct path and isolate only the glyph draw"
20684        );
20685    }
20686
20687    #[test]
20688    fn layer_surface_requirements_keep_translated_text_leaf_with_background_on_direct_path() {
20689        let layer = snapped_text_leaf(false, true);
20690
20691        let requirements = layer_surface_requirements(&layer);
20692
20693        assert_eq!(
20694            requirements.direct_translation,
20695            Some(Point::new(14.25, 16.5))
20696        );
20697        assert!(
20698            requirements
20699                .surface_requirements
20700                .contains(SurfaceRequirement::PixelStableComposite)
20701                && !requirements
20702                    .surface_requirements
20703                    .has_isolating_requirement(),
20704            "translated text with direct sibling decoration/background should keep the layer direct"
20705        );
20706    }
20707
20708    #[test]
20709    fn translated_plain_text_uses_bounded_snap_surface() {
20710        let root = pure_text_leaf_root(true, true);
20711        let mut rect_cache = HashMap::new();
20712        let mut requirements_cache = HashMap::new();
20713        let collected =
20714            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
20715
20716        assert_eq!(collected.child_layers.len(), 1);
20717        assert!(collected.scene.texts.is_empty());
20718        assert!(collected.scene.effect_layers.is_empty());
20719        assert_snap_anchor_close(
20720            collected.child_layers[0].snap_anchor,
20721            Point::new(11.4, 23.6),
20722            "translated plain text's bounded local surface should composite at the content-origin snap phase",
20723        );
20724    }
20725
20726    /// Not a correctness test: a local timing harness for the shape-run
20727    /// collect path. Run manually with
20728    /// `cargo test --release -p cranpose-render-wgpu -- --ignored collect_timing --nocapture`.
20729    #[test]
20730    #[ignore]
20731    fn shape_run_collect_timing_harness() {
20732        use cranpose_render_common::graph::DrawPrimitiveNode;
20733        use cranpose_render_common::layer_composition::local_content_layer_for;
20734        use cranpose_ui_graphics::Stroke;
20735
20736        let bounds = Rect {
20737            x: 0.0,
20738            y: 0.0,
20739            width: 1080.0,
20740            height: 2244.0,
20741        };
20742        let graphics_layer = GraphicsLayer::default();
20743
20744        // A MEGA-BOSS-shaped workload: thousands of consecutive arcs, most
20745        // solid, some gradient, one text-free layer.
20746        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
20747        for i in 0..3000u32 {
20748            let f = i as f32;
20749            let brush = if i % 8 == 0 {
20750                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
20751            } else {
20752                Brush::Solid(Color(0.5, 0.2, 0.8, 1.0))
20753            };
20754            let center = Point::new(540.0 + (f % 400.0), 1122.0 + (f % 350.0));
20755            let radius = 8.0 + (i % 23) as f32;
20756            let half = radius + 4.0;
20757            nodes.push(DrawPrimitiveNode {
20758                primitive: DrawPrimitive::Arc {
20759                    rect: Rect {
20760                        x: center.x - half,
20761                        y: center.y - half,
20762                        width: half * 2.0,
20763                        height: half * 2.0,
20764                    },
20765                    brush,
20766                    center,
20767                    radius,
20768                    start_angle: f * 0.07,
20769                    sweep_angle: 0.5 + (i % 5) as f32,
20770                    stroke: (i % 3 != 0).then(|| Stroke::new(4.0)),
20771                    inner_radius: if i % 3 == 0 { radius * 0.6 } else { 0.0 },
20772                },
20773                clip: None,
20774            });
20775        }
20776
20777        let children: Vec<RenderNode> = nodes
20778            .iter()
20779            .map(|node| {
20780                RenderNode::Primitive(PrimitiveEntry {
20781                    phase: PrimitivePhase::BeforeChildren,
20782                    node: PrimitiveNode::Draw(node.clone()),
20783                })
20784            })
20785            .collect();
20786        let layer = crate::test_support::layer_node(
20787            bounds,
20788            ProjectiveTransform::identity(),
20789            graphics_layer,
20790            children,
20791        );
20792
20793        const ITERS: usize = 300;
20794
20795        // Reference: the pre-run per-primitive path.
20796        let local_layer = local_content_layer_for(&layer.graphics_layer);
20797        let start = Instant::now();
20798        let mut sink_shapes = 0usize;
20799        for _ in 0..ITERS {
20800            let mut scene = CompositorScene::new();
20801            for node in &nodes {
20802                crate::pipeline::push_draw_primitive(
20803                    &node.primitive,
20804                    bounds,
20805                    &local_layer,
20806                    None,
20807                    &mut scene,
20808                    None,
20809                    false,
20810                );
20811            }
20812            sink_shapes = scene.shapes.len();
20813        }
20814        let serial = start.elapsed();
20815
20816        let mut rect_cache = HashMap::new();
20817        let mut requirements_cache = HashMap::new();
20818        let start = Instant::now();
20819        let mut run_shapes = 0usize;
20820        for _ in 0..ITERS {
20821            let collected = collect_layer_contents(
20822                &layer,
20823                None,
20824                None,
20825                &mut rect_cache,
20826                &mut requirements_cache,
20827            );
20828            run_shapes = collected.scene.shapes.len();
20829        }
20830        let run = start.elapsed();
20831
20832        println!(
20833            "per-primitive: {:?}/iter ({sink_shapes} shapes)  shape-run: {:?}/iter ({run_shapes} shapes)",
20834            serial / ITERS as u32,
20835            run / ITERS as u32,
20836        );
20837    }
20838
20839    /// Shared body for the serial and forced-parallel equivalence tests:
20840    fn assert_shape_run_collect_matches_per_primitive_emission() {
20841        use cranpose_render_common::graph::DrawPrimitiveNode;
20842        use cranpose_render_common::layer_composition::local_content_layer_for;
20843        use cranpose_render_common::primitive_emit::{resolve_primitive_clip, PrimitiveClipSpace};
20844        use cranpose_ui_graphics::{CornerRadii, Stroke};
20845
20846        let bounds = Rect {
20847            x: 0.0,
20848            y: 0.0,
20849            width: 800.0,
20850            height: 800.0,
20851        };
20852        // Rotation keeps rigid snapping off, so both paths agree on
20853        // `snap_anchor: None` without replicating the anchor computation here.
20854        let graphics_layer = GraphicsLayer {
20855            scale: 1.25,
20856            translation_x: 3.5,
20857            translation_y: -2.0,
20858            alpha: 0.9,
20859            rotation_z: 0.35,
20860            ..GraphicsLayer::default()
20861        };
20862
20863        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
20864        for i in 0..600u32 {
20865            let f = i as f32;
20866            let brush = if i % 11 == 0 {
20867                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
20868            } else {
20869                Brush::Solid(Color(0.1 + (i % 7) as f32 * 0.1, 0.5, 0.9, 1.0))
20870            };
20871            let stroke = (i % 5 == 0).then(|| Stroke::new(1.0 + (i % 3) as f32));
20872            let primitive = match i % 3 {
20873                0 => DrawPrimitive::Rect {
20874                    rect: Rect {
20875                        x: f % 37.0,
20876                        y: f % 53.0,
20877                        width: 8.0 + f % 9.0,
20878                        height: 6.0 + f % 5.0,
20879                    },
20880                    brush,
20881                    stroke,
20882                },
20883                1 => DrawPrimitive::RoundRect {
20884                    rect: Rect {
20885                        x: f % 41.0,
20886                        y: f % 43.0,
20887                        width: 12.0,
20888                        height: 10.0,
20889                    },
20890                    brush,
20891                    radii: CornerRadii::uniform(2.0 + (i % 4) as f32),
20892                    stroke,
20893                },
20894                _ => {
20895                    let center = Point::new(60.0 + f % 71.0, 60.0 + f % 67.0);
20896                    let radius = 5.0 + (i % 13) as f32;
20897                    // One degenerate sweep proves dropped draws stay dropped.
20898                    let sweep_angle = if i == 302 { 0.0 } else { 0.4 + (i % 6) as f32 };
20899                    let half = radius + 4.0;
20900                    DrawPrimitive::Arc {
20901                        rect: Rect {
20902                            x: center.x - half,
20903                            y: center.y - half,
20904                            width: half * 2.0,
20905                            height: half * 2.0,
20906                        },
20907                        brush,
20908                        center,
20909                        radius,
20910                        start_angle: f * 0.11,
20911                        sweep_angle,
20912                        stroke: (i % 2 == 0).then(|| Stroke::new(3.0)),
20913                        inner_radius: if i % 4 == 2 { radius * 0.5 } else { 0.0 },
20914                    }
20915                }
20916            };
20917            let primitive = if i == 300 {
20918                // A nested blend disqualifies the run view and forces a
20919                // mid-run flush through the serial path, splitting 600 draws
20920                // into two runs that are both long enough to fan out.
20921                DrawPrimitive::Blend {
20922                    primitive: Box::new(DrawPrimitive::Blend {
20923                        primitive: Box::new(primitive),
20924                        blend_mode: BlendMode::SrcOver,
20925                    }),
20926                    blend_mode: BlendMode::DstOut,
20927                }
20928            } else if i % 7 == 3 {
20929                DrawPrimitive::Blend {
20930                    primitive: Box::new(primitive),
20931                    blend_mode: BlendMode::DstOut,
20932                }
20933            } else {
20934                primitive
20935            };
20936            let clip = (i % 31 == 7).then_some(Rect {
20937                x: 0.0,
20938                y: 0.0,
20939                width: 30.0,
20940                height: 30.0,
20941            });
20942            nodes.push(DrawPrimitiveNode { primitive, clip });
20943        }
20944
20945        let children: Vec<RenderNode> = nodes
20946            .iter()
20947            .map(|node| {
20948                RenderNode::Primitive(PrimitiveEntry {
20949                    phase: PrimitivePhase::BeforeChildren,
20950                    node: PrimitiveNode::Draw(node.clone()),
20951                })
20952            })
20953            .collect();
20954        let layer = crate::test_support::layer_node(
20955            bounds,
20956            ProjectiveTransform::identity(),
20957            graphics_layer,
20958            children,
20959        );
20960
20961        let mut rect_cache = HashMap::new();
20962        let mut requirements_cache = HashMap::new();
20963        let collected =
20964            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
20965
20966        // The reference scene: every primitive through the per-primitive
20967        // emission path, exactly as the pre-run collect loop ran it.
20968        let local_layer = local_content_layer_for(&layer.graphics_layer);
20969        let mut expected = CompositorScene::new();
20970        for node in &nodes {
20971            let clip = resolve_primitive_clip(
20972                node.clip,
20973                bounds,
20974                &local_layer,
20975                None,
20976                PrimitiveClipSpace::Local,
20977            );
20978            if node.clip.is_some() && clip.is_none() {
20979                continue;
20980            }
20981            crate::pipeline::push_draw_primitive(
20982                &node.primitive,
20983                bounds,
20984                &local_layer,
20985                clip,
20986                &mut expected,
20987                None,
20988                false,
20989            );
20990        }
20991
20992        assert!(
20993            collected.scene.shapes.len() >= 590,
20994            "the runs should engage the parallel branch: got {} shapes",
20995            collected.scene.shapes.len()
20996        );
20997        assert_eq!(collected.scene.shapes.len(), expected.shapes.len());
20998        assert_eq!(collected.scene.draw_ops, expected.draw_ops);
20999        assert_eq!(collected.scene.next_z, expected.next_z);
21000        assert!(
21001            collected
21002                .scene
21003                .shapes
21004                .iter()
21005                .all(|s| s.snap_anchor.is_none()),
21006            "a rotated layer must not rigid-snap; the reference scene assumes it"
21007        );
21008        for (index, (got, want)) in collected
21009            .scene
21010            .shapes
21011            .iter()
21012            .zip(&expected.shapes)
21013            .enumerate()
21014        {
21015            assert_eq!(got.rect, want.rect, "shape {index} rect");
21016            assert_eq!(got.local_rect, want.local_rect, "shape {index} local_rect");
21017            assert_eq!(got.quad, want.quad, "shape {index} quad");
21018            assert_eq!(got.snap_anchor, want.snap_anchor, "shape {index} snap");
21019            assert_eq!(got.brush, want.brush, "shape {index} brush");
21020            assert_eq!(got.shape, want.shape, "shape {index} shape");
21021            assert_eq!(got.stroke, want.stroke, "shape {index} stroke");
21022            assert_eq!(got.arc, want.arc, "shape {index} arc");
21023            assert_eq!(got.z_index, want.z_index, "shape {index} z");
21024            assert_eq!(got.clip, want.clip, "shape {index} clip");
21025            assert_eq!(got.blend_mode, want.blend_mode, "shape {index} blend");
21026            assert_eq!(
21027                got.motion_context_animated, want.motion_context_animated,
21028                "shape {index} motion flag"
21029            );
21030        }
21031    }
21032
21033    /// The run collector must emit exactly what per-primitive emission does,
21034    /// on BOTH flush paths: the serial drain and the scoped-thread fan-out
21035    /// (forced via the tuning override, since a test-sized scene would never
21036    /// cross the size gate on its own).
21037    #[test]
21038    fn shape_run_collect_matches_per_primitive_emission_exactly() {
21039        assert_shape_run_collect_matches_per_primitive_emission();
21040        crate::normalized_scene::force_shape_run_parallel_for_tests(true);
21041        let outcome =
21042            std::panic::catch_unwind(assert_shape_run_collect_matches_per_primitive_emission);
21043        crate::normalized_scene::force_shape_run_parallel_for_tests(false);
21044        if let Err(payload) = outcome {
21045            std::panic::resume_unwind(payload);
21046        }
21047    }
21048
21049    #[test]
21050    fn non_translated_text_local_surface_keeps_linear_composite_resolve() {
21051        let layer = text_layer_with_style(
21052            AnnotatedString::from("gradient"),
21053            TextStyle::from_span_style(SpanStyle {
21054                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
21055                ..SpanStyle::default()
21056            }),
21057        );
21058        let requirements = layer_surface_requirements(&layer);
21059
21060        assert!(requirements
21061            .surface_requirements
21062            .contains(SurfaceRequirement::TextMaterialMask));
21063        assert_eq!(
21064            composite_sample_mode_for_requirements(false, false, requirements),
21065            CompositeSampleMode::Linear
21066        );
21067    }
21068
21069    #[test]
21070    fn inherited_translated_text_local_surface_uses_box4_layer_surface() {
21071        let layer = text_layer_with_style(
21072            AnnotatedString::from("shadow"),
21073            TextStyle::from_span_style(SpanStyle {
21074                shadow: Some(Shadow {
21075                    color: Color::BLACK,
21076                    offset: Point::new(1.0, 2.0),
21077                    blur_radius: 3.0,
21078                }),
21079                ..SpanStyle::default()
21080            }),
21081        );
21082        let requirements = layer_surface_requirements(&layer);
21083
21084        assert!(requirements
21085            .surface_requirements
21086            .contains(SurfaceRequirement::TextMaterialMask));
21087        assert_eq!(
21088            composite_sample_mode_for_requirements(true, false, requirements),
21089            CompositeSampleMode::Box4
21090        );
21091        assert_eq!(
21092            layer_surface_target_scale(
21093                true,
21094                false,
21095                requirements,
21096                1.25,
21097                layer_surface_scale(&layer)
21098            ),
21099            SurfaceRequirementSet::default()
21100                .with(SurfaceRequirement::TextMaterialMask)
21101                .with(SurfaceRequirement::MotionStableCapture)
21102                .target_scale(1.25, 1.0)
21103        );
21104    }
21105
21106    #[test]
21107    fn translated_text_local_surface_inside_capture_keeps_parent_scale() {
21108        let layer = text_layer_with_style(
21109            AnnotatedString::from("shadow"),
21110            TextStyle::from_span_style(SpanStyle {
21111                shadow: Some(Shadow {
21112                    color: Color::BLACK,
21113                    offset: Point::new(1.0, 2.0),
21114                    blur_radius: 3.0,
21115                }),
21116                ..SpanStyle::default()
21117            }),
21118        );
21119        let requirements = layer_surface_requirements(&layer);
21120
21121        assert_eq!(
21122            composite_sample_mode_for_requirements(true, true, requirements),
21123            CompositeSampleMode::Linear
21124        );
21125        assert_eq!(
21126            layer_surface_target_scale(true, true, requirements, 10.0, layer_surface_scale(&layer)),
21127            SurfaceRequirementSet::default()
21128                .with(SurfaceRequirement::TextMaterialMask)
21129                .target_scale(10.0, 1.0)
21130        );
21131    }
21132
21133    #[test]
21134    fn layer_surface_requirements_use_local_surface_for_gradient_and_stroke_text() {
21135        let cases = [
21136            (
21137                "draw_style",
21138                AnnotatedString::from("draw_style"),
21139                TextStyle::from_span_style(SpanStyle {
21140                    draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
21141                    ..SpanStyle::default()
21142                }),
21143            ),
21144            (
21145                "gradient_brush",
21146                AnnotatedString::from("gradient"),
21147                TextStyle::from_span_style(SpanStyle {
21148                    brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
21149                    ..SpanStyle::default()
21150                }),
21151            ),
21152        ];
21153
21154        for (label, text, text_style) in cases {
21155            let layer = text_layer_with_style(text, text_style);
21156            let requirements = layer_surface_requirements(&layer);
21157            assert!(
21158                requirements
21159                    .surface_requirements
21160                    .contains(SurfaceRequirement::TextMaterialMask),
21161                "{label} text should use a bounded local surface: {requirements:?}"
21162            );
21163        }
21164    }
21165
21166    #[test]
21167    fn layer_surface_requirements_use_local_surface_for_complex_text_effects() {
21168        let cases = [
21169            (
21170                "shadow",
21171                AnnotatedString::from("shadow"),
21172                TextStyle::from_span_style(SpanStyle {
21173                    shadow: Some(Shadow {
21174                        color: Color::BLACK,
21175                        offset: Point::new(1.0, 2.0),
21176                        blur_radius: 3.0,
21177                    }),
21178                    ..SpanStyle::default()
21179                }),
21180            ),
21181            (
21182                "background",
21183                AnnotatedString::from("background"),
21184                TextStyle::from_span_style(SpanStyle {
21185                    background: Some(Color::BLACK),
21186                    ..SpanStyle::default()
21187                }),
21188            ),
21189            (
21190                "baseline_shift",
21191                AnnotatedString::from("baseline_shift"),
21192                TextStyle::from_span_style(SpanStyle {
21193                    baseline_shift: Some(BaselineShift::SUPERSCRIPT),
21194                    ..SpanStyle::default()
21195                }),
21196            ),
21197            (
21198                "geometric_transform",
21199                AnnotatedString::from("geometric_transform"),
21200                TextStyle::from_span_style(SpanStyle {
21201                    text_geometric_transform: Some(TextGeometricTransform {
21202                        scale_x: 1.2,
21203                        skew_x: 0.15,
21204                    }),
21205                    ..SpanStyle::default()
21206                }),
21207            ),
21208            (
21209                "letter_spacing",
21210                AnnotatedString::from("letter_spacing"),
21211                TextStyle::from_span_style(SpanStyle {
21212                    letter_spacing: TextUnit::Em(0.2),
21213                    ..SpanStyle::default()
21214                }),
21215            ),
21216        ];
21217
21218        for (label, text, text_style) in cases {
21219            let layer = text_layer_with_style(text, text_style);
21220            let requirements = layer_surface_requirements(&layer);
21221            assert!(
21222                requirements
21223                    .surface_requirements
21224                    .contains(SurfaceRequirement::TextMaterialMask),
21225                "{label} text should use a bounded local surface: {requirements:?}"
21226            );
21227            assert_eq!(
21228                requirements.direct_translation,
21229                Some(Point::default()),
21230                "{label} text should still classify as a direct translation"
21231            );
21232        }
21233    }
21234
21235    #[test]
21236    fn layer_surface_requirements_color_only_span_styles_use_direct_path() {
21237        let layer = text_layer_with_style(
21238            AnnotatedString {
21239                text: "styled".to_string(),
21240                span_styles: vec![RangeStyle {
21241                    item: SpanStyle {
21242                        color: Some(Color::BLACK),
21243                        ..SpanStyle::default()
21244                    },
21245                    range: 0..3,
21246                }],
21247                ..AnnotatedString::default()
21248            },
21249            TextStyle::default(),
21250        );
21251        let requirements = layer_surface_requirements(&layer);
21252        assert!(
21253            !requirements
21254                .surface_requirements
21255                .contains(SurfaceRequirement::TextMaterialMask),
21256            "color-only span styles should render directly via software text raster colors"
21257        );
21258    }
21259
21260    #[test]
21261    fn layer_surface_requirements_keep_decoration_only_text_on_direct_path() {
21262        let layer = text_layer_with_style(
21263            AnnotatedString::from("decoration"),
21264            TextStyle::from_span_style(SpanStyle {
21265                text_decoration: Some(TextDecoration::UNDERLINE),
21266                ..SpanStyle::default()
21267            }),
21268        );
21269
21270        let requirements = layer_surface_requirements(&layer);
21271
21272        assert_eq!(requirements.direct_translation, Some(Point::default()));
21273        assert!(
21274            requirements
21275                .surface_requirements
21276                .contains(SurfaceRequirement::PixelStableComposite)
21277                && !requirements
21278                    .surface_requirements
21279                    .has_isolating_requirement(),
21280            "decoration-only text should not force an isolating layer surface: {requirements:?}"
21281        );
21282    }
21283
21284    #[test]
21285    fn direct_text_leaf_snaps_modifier_background_and_text_with_one_anchor() {
21286        let root = snapped_text_leaf_root(false, false);
21287        let mut rect_cache = HashMap::new();
21288        let mut requirements_cache = HashMap::new();
21289
21290        let collected =
21291            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21292
21293        assert_eq!(collected.scene.shapes.len(), 1);
21294        assert_eq!(collected.scene.images.len(), 1);
21295        assert_eq!(collected.scene.texts.len(), 1);
21296        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
21297        assert_eq!(collected.scene.shapes[0].snap_anchor, expected_anchor);
21298        assert_eq!(collected.scene.images[0].snap_anchor, expected_anchor);
21299        assert_eq!(collected.scene.texts[0].snap_anchor, expected_anchor);
21300    }
21301
21302    #[test]
21303    fn animated_translated_content_text_leaf_uses_bounded_content_snap() {
21304        let root = snapped_text_leaf_root(true, true);
21305        let mut rect_cache = HashMap::new();
21306        let mut requirements_cache = HashMap::new();
21307
21308        let collected =
21309            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21310
21311        assert_eq!(collected.child_layers.len(), 1);
21312        assert!(collected.scene.shapes.is_empty());
21313        assert!(collected.scene.images.is_empty());
21314        assert!(collected.scene.texts.is_empty());
21315        assert!(collected.scene.effect_layers.is_empty());
21316        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
21317        assert_eq!(
21318            collected.child_layers[0].snap_anchor, expected_anchor,
21319            "active translated leaf surface should keep the content-origin snap phase"
21320        );
21321    }
21322
21323    #[test]
21324    fn translated_content_assigns_motion_anchor_to_rotated_child_surface() {
21325        let mut child = snapped_text_leaf(false, false);
21326        child.graphics_layer.rotation_z = 5.0;
21327        child.transform_to_parent =
21328            cranpose_render_common::layer_transform::layer_transform_to_parent(
21329                child.local_bounds,
21330                Point::new(108.0, 3.0),
21331                &child.graphics_layer,
21332            );
21333        child.recompute_raster_cache_hashes();
21334        let mut root = test_layer(
21335            Rect {
21336                x: 0.0,
21337                y: 0.0,
21338                width: 320.0,
21339                height: 180.0,
21340            },
21341            vec![RenderNode::Layer(Box::new(child))],
21342        );
21343        root.translated_content_context = true;
21344        root.translated_content_offset = Point::new(0.0, -80.8);
21345        root.recompute_raster_cache_hashes();
21346        let mut rect_cache = HashMap::new();
21347        let mut requirements_cache = HashMap::new();
21348
21349        let collected =
21350            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21351
21352        assert_eq!(collected.child_layers.len(), 1);
21353        assert!(
21354            collected.child_layers[0].snap_anchor.is_some(),
21355            "a projective child still translates rigidly with its scrolling parent"
21356        );
21357    }
21358
21359    #[test]
21360    fn rested_translated_content_context_text_leaf_snaps_for_crisp_scroll_rest() {
21361        let root = snapped_text_leaf_root(false, true);
21362        let mut rect_cache = HashMap::new();
21363        let mut requirements_cache = HashMap::new();
21364
21365        let collected =
21366            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21367
21368        assert_eq!(collected.child_layers.len(), 0);
21369        assert_eq!(collected.scene.shapes.len(), 1);
21370        assert_eq!(collected.scene.images.len(), 1);
21371        assert_eq!(collected.scene.texts.len(), 1);
21372        assert_eq!(collected.scene.effect_layers.len(), 0);
21373        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
21374        assert_eq!(
21375            collected.scene.shapes[0].snap_anchor, expected_anchor,
21376            "rested scroll content should snap back to device pixels"
21377        );
21378        assert_eq!(
21379            collected.scene.images[0].snap_anchor, expected_anchor,
21380            "rested scroll images should snap back to device pixels"
21381        );
21382        assert_eq!(
21383            collected.scene.texts[0].snap_anchor, expected_anchor,
21384            "rested scroll text should snap back to device pixels"
21385        );
21386    }
21387
21388    #[test]
21389    fn complex_text_uses_local_surface() {
21390        let root = translated_content_local_surface_root();
21391        let mut rect_cache = HashMap::new();
21392        let mut requirements_cache = HashMap::new();
21393
21394        let collected =
21395            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21396
21397        assert!(
21398            !collected.child_layers.is_empty(),
21399            "translated-content effectful text should render through a bounded local surface"
21400        );
21401        assert!(collected.scene.texts.is_empty());
21402        assert!(collected.scene.shadow_draws.is_empty());
21403    }
21404
21405    #[test]
21406    fn translated_content_surface_composite_uses_scroll_content_snap_anchor() {
21407        let mut root = translated_content_local_surface_root();
21408        let scroll_offset = Point::new(0.0, -18.5);
21409        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
21410            panic!("expected translated content layer");
21411        };
21412        translated_content.translated_content_offset = scroll_offset;
21413        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
21414            panic!("expected effectful text layer");
21415        };
21416        effectful_text.transform_to_parent =
21417            effectful_text
21418                .transform_to_parent
21419                .then(ProjectiveTransform::translation(
21420                    scroll_offset.x,
21421                    scroll_offset.y,
21422                ));
21423
21424        let mut rect_cache = HashMap::new();
21425        let mut requirements_cache = HashMap::new();
21426        let collected =
21427            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21428
21429        assert_eq!(collected.child_layers.len(), 1);
21430        assert_eq!(
21431            collected.child_layers[0].snap_anchor,
21432            Some(SnapAnchor::rigid(Point::new(14.25, -2.0))),
21433            "isolated scrolled descendants must composite with the same content-origin snap phase"
21434        );
21435    }
21436
21437    #[test]
21438    fn animated_translated_content_surface_composite_uses_scroll_content_snap_anchor() {
21439        let mut root = translated_content_local_surface_root();
21440        let scroll_offset = Point::new(0.0, -18.5);
21441        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
21442            panic!("expected translated content layer");
21443        };
21444        translated_content.motion_context_animated = true;
21445        translated_content.translated_content_offset = scroll_offset;
21446        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
21447            panic!("expected effectful text layer");
21448        };
21449        effectful_text.transform_to_parent =
21450            effectful_text
21451                .transform_to_parent
21452                .then(ProjectiveTransform::translation(
21453                    scroll_offset.x,
21454                    scroll_offset.y,
21455                ));
21456
21457        let mut rect_cache = HashMap::new();
21458        let mut requirements_cache = HashMap::new();
21459        let collected =
21460            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21461
21462        assert_eq!(collected.child_layers.len(), 1);
21463        assert_eq!(
21464            collected.child_layers[0].snap_anchor,
21465            Some(SnapAnchor::rigid(Point::new(14.25, 16.5))),
21466            "animated translated content should composite the stable local surface at the viewport-origin snap phase"
21467        );
21468    }
21469
21470    #[test]
21471    fn translated_text_material_effect_layer_uses_scroll_content_snap_anchor() {
21472        let mut layer = text_layer_with_style(
21473            AnnotatedString::from("gradient"),
21474            TextStyle::from_span_style(SpanStyle {
21475                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
21476                ..SpanStyle::default()
21477            }),
21478        );
21479        layer.translated_content_context = true;
21480        layer.translated_content_offset = Point::new(0.0, -18.5);
21481        let mut rect_cache = HashMap::new();
21482        let mut requirements_cache = HashMap::new();
21483
21484        let collected =
21485            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
21486
21487        assert_eq!(collected.scene.effect_layers.len(), 1);
21488        assert_eq!(
21489            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
21490            CompositeSampleMode::Box4
21491        );
21492        assert_eq!(
21493            collected.scene.effect_layers[0].snap_anchor,
21494            Some(SnapAnchor::rigid(Point::new(0.0, -18.5))),
21495            "text material surfaces must composite with the scroll content-origin snap phase"
21496        );
21497    }
21498
21499    #[test]
21500    fn translated_layer_surface_capture_does_not_restart_local_picture_for_shadow_text() {
21501        let mut layer = text_layer_with_style(
21502            AnnotatedString::from("shadow"),
21503            TextStyle::from_span_style(SpanStyle {
21504                shadow: Some(Shadow {
21505                    color: Color::BLACK,
21506                    offset: Point::new(1.0, 2.0),
21507                    blur_radius: 3.0,
21508                }),
21509                ..SpanStyle::default()
21510            }),
21511        );
21512        layer.translated_content_context = true;
21513        let mut rect_cache = HashMap::new();
21514        let mut requirements_cache = HashMap::new();
21515
21516        let collected = collect_layer_contents_with_translation_context(
21517            &layer,
21518            None,
21519            None,
21520            TranslationRenderContext {
21521                inherited_content_translation: false,
21522                surface_capture_active: true,
21523                local_picture_capture_active: true,
21524                ..TranslationRenderContext::default()
21525            },
21526            &mut rect_cache,
21527            &mut requirements_cache,
21528        );
21529
21530        assert!(
21531            collected.scene.effect_layers.is_empty(),
21532            "a translated layer surface already provides the stable local capture"
21533        );
21534        assert_eq!(collected.scene.shadow_draws.len(), 1);
21535        assert_eq!(collected.scene.texts.len(), 1);
21536        assert!(
21537            !collected.scene.texts[0].translated_content_context,
21538            "text inside an active motion-stable capture must raster in capture-local coordinates"
21539        );
21540    }
21541
21542    #[test]
21543    fn translated_layer_surface_capture_keeps_only_material_effect_layers() {
21544        let mut layer = text_layer_with_style(
21545            AnnotatedString::from("gradient"),
21546            TextStyle::from_span_style(SpanStyle {
21547                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
21548                ..SpanStyle::default()
21549            }),
21550        );
21551        layer.translated_content_context = true;
21552        let mut rect_cache = HashMap::new();
21553        let mut requirements_cache = HashMap::new();
21554
21555        let collected = collect_layer_contents_with_translation_context(
21556            &layer,
21557            None,
21558            None,
21559            TranslationRenderContext {
21560                inherited_content_translation: false,
21561                surface_capture_active: true,
21562                local_picture_capture_active: true,
21563                ..TranslationRenderContext::default()
21564            },
21565            &mut rect_cache,
21566            &mut requirements_cache,
21567        );
21568
21569        assert_eq!(collected.scene.effect_layers.len(), 1);
21570        assert!(
21571            collected.scene.effect_layers[0]
21572                .requirements
21573                .contains(SurfaceRequirement::MotionStableCapture),
21574            "translated text materials still need motion-stable resolve semantics inside a stable capture"
21575        );
21576        assert_eq!(
21577            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
21578            CompositeSampleMode::Box4
21579        );
21580        assert_eq!(
21581            effect_layer_target_scale(&collected.scene.effect_layers[0], 10.0),
21582            10.0
21583        );
21584        assert!(collected.scene.effect_layers[0].effect.is_some());
21585    }
21586
21587    #[test]
21588    fn translated_viewport_surface_does_not_add_plain_local_picture_capture() {
21589        let mut layer = text_layer_with_style(
21590            AnnotatedString::from("shadow"),
21591            TextStyle::from_span_style(SpanStyle {
21592                shadow: Some(Shadow {
21593                    color: Color::BLACK,
21594                    offset: Point::new(1.0, 2.0),
21595                    blur_radius: 3.0,
21596                }),
21597                ..SpanStyle::default()
21598            }),
21599        );
21600        layer.translated_content_context = true;
21601        layer.motion_context_animated = true;
21602        let mut rect_cache = HashMap::new();
21603        let mut requirements_cache = HashMap::new();
21604
21605        let collected = collect_layer_contents_with_translation_context(
21606            &layer,
21607            None,
21608            None,
21609            TranslationRenderContext {
21610                surface_capture_active: true,
21611                ..TranslationRenderContext::default()
21612            },
21613            &mut rect_cache,
21614            &mut requirements_cache,
21615        );
21616
21617        assert_eq!(
21618            collected.scene.effect_layers.len(),
21619            0,
21620            "plain translated content inside a viewport surface should not be captured again"
21621        );
21622        assert_eq!(collected.scene.shadow_draws.len(), 1);
21623        assert_eq!(collected.scene.texts.len(), 1);
21624    }
21625
21626    #[test]
21627    fn static_pure_text_leaf_snaps_without_sibling_draw_primitives() {
21628        let root = pure_text_leaf_root(false, false);
21629        let mut rect_cache = HashMap::new();
21630        let mut requirements_cache = HashMap::new();
21631
21632        let collected =
21633            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21634
21635        assert_eq!(collected.scene.texts.len(), 1);
21636        assert!(
21637            collected.scene.texts[0].snap_anchor.is_some(),
21638            "idle pure text leaves should participate in rigid snap anchoring"
21639        );
21640    }
21641
21642    #[test]
21643    fn animated_pure_text_leaf_stays_unsnapped() {
21644        let root = pure_text_leaf_root(true, false);
21645        let mut rect_cache = HashMap::new();
21646        let mut requirements_cache = HashMap::new();
21647
21648        let collected =
21649            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21650
21651        assert_eq!(collected.scene.texts.len(), 1);
21652        assert_eq!(collected.scene.texts[0].snap_anchor, None);
21653    }
21654
21655    #[test]
21656    fn animated_translated_pure_text_uses_bounded_content_snap() {
21657        let root = pure_text_leaf_root(true, true);
21658        let mut rect_cache = HashMap::new();
21659        let mut requirements_cache = HashMap::new();
21660
21661        let collected =
21662            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21663
21664        assert_eq!(collected.child_layers.len(), 1);
21665        assert!(collected.scene.texts.is_empty());
21666        assert!(collected.scene.effect_layers.is_empty());
21667        assert_snap_anchor_close(
21668            collected.child_layers[0].snap_anchor,
21669            Point::new(11.4, 23.6),
21670            "animated translated pure text should use the bounded content snap phase",
21671        );
21672    }
21673
21674    #[test]
21675    fn rested_translated_pure_text_leaf_snaps_for_crisp_scroll_rest() {
21676        let root = pure_text_leaf_root(false, true);
21677        let mut rect_cache = HashMap::new();
21678        let mut requirements_cache = HashMap::new();
21679
21680        let collected =
21681            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21682
21683        assert_eq!(collected.child_layers.len(), 0);
21684        assert_eq!(collected.scene.texts.len(), 1);
21685        assert_eq!(collected.scene.effect_layers.len(), 0);
21686        assert_snap_anchor_close(
21687            collected.scene.texts[0].snap_anchor,
21688            Point::new(11.4, 23.6),
21689            "rested translated text should snap to device pixels",
21690        );
21691    }
21692
21693    #[test]
21694    fn static_gpu_effect_text_leaf_stays_unsnapped() {
21695        let root = text_layer_with_style(
21696            AnnotatedString::from("Gradient"),
21697            TextStyle::from_span_style(SpanStyle {
21698                brush: Some(Brush::linear_gradient(vec![
21699                    Color(0.2, 0.8, 1.0, 1.0),
21700                    Color(1.0, 0.7, 0.4, 1.0),
21701                ])),
21702                draw_style: Some(TextDrawStyle::Stroke { width: 2.5 }),
21703                ..SpanStyle::default()
21704            }),
21705        );
21706        let mut rect_cache = HashMap::new();
21707        let mut requirements_cache = HashMap::new();
21708
21709        let collected =
21710            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21711
21712        assert_eq!(collected.scene.texts.len(), 1);
21713        assert_eq!(
21714            collected.scene.texts[0].snap_anchor, None,
21715            "gpu text-effect leaves must not take the rigid text snap path"
21716        );
21717        assert_eq!(
21718            collected.scene.effect_layers.len(),
21719            1,
21720            "gradient stroke text should still emit a runtime shader effect layer"
21721        );
21722    }
21723
21724    #[test]
21725    fn layer_surface_requirements_keep_shape_plus_direct_child_on_direct_path() {
21726        let mut child = test_layer(
21727            Rect {
21728                x: 0.0,
21729                y: 0.0,
21730                width: 40.0,
21731                height: 20.0,
21732            },
21733            vec![RenderNode::Primitive(PrimitiveEntry {
21734                phase: PrimitivePhase::BeforeChildren,
21735                node: PrimitiveNode::Draw(DrawPrimitiveNode {
21736                    primitive: DrawPrimitive::Rect {
21737                        rect: Rect {
21738                            x: 0.0,
21739                            y: 0.0,
21740                            width: 40.0,
21741                            height: 20.0,
21742                        },
21743                        brush: Brush::solid(Color::WHITE),
21744                        stroke: None,
21745                    },
21746                    clip: None,
21747                }),
21748            })],
21749        );
21750        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
21751
21752        let layer = test_layer(
21753            Rect {
21754                x: 0.0,
21755                y: 0.0,
21756                width: 64.0,
21757                height: 32.0,
21758            },
21759            vec![
21760                RenderNode::Primitive(PrimitiveEntry {
21761                    phase: PrimitivePhase::BeforeChildren,
21762                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
21763                        primitive: DrawPrimitive::Rect {
21764                            rect: Rect {
21765                                x: 0.0,
21766                                y: 0.0,
21767                                width: 64.0,
21768                                height: 32.0,
21769                            },
21770                            brush: Brush::solid(Color::BLACK),
21771                            stroke: None,
21772                        },
21773                        clip: None,
21774                    }),
21775                }),
21776                RenderNode::Layer(Box::new(child)),
21777            ],
21778        );
21779
21780        let requirements = layer_surface_requirements(&layer);
21781
21782        assert_eq!(requirements.direct_translation, Some(Point::default()));
21783        assert!(!requirements
21784            .surface_requirements
21785            .contains(SurfaceRequirement::MixedDirectContent));
21786        assert!(!requirements
21787            .surface_requirements
21788            .has_isolating_requirement());
21789    }
21790
21791    #[test]
21792    fn collect_layer_contents_translates_direct_text_rects_into_parent_space() {
21793        let mut child = text_layer_with_style(
21794            AnnotatedString::from("direct"),
21795            TextStyle::from_span_style(SpanStyle {
21796                text_decoration: Some(TextDecoration::UNDERLINE),
21797                ..SpanStyle::default()
21798            }),
21799        );
21800        child.transform_to_parent = ProjectiveTransform::translation(9.0, 7.0);
21801
21802        let parent = test_layer(
21803            Rect {
21804                x: 0.0,
21805                y: 0.0,
21806                width: 64.0,
21807                height: 32.0,
21808            },
21809            vec![RenderNode::Layer(Box::new(child))],
21810        );
21811
21812        let mut rect_cache = HashMap::new();
21813        let mut requirements_cache = HashMap::new();
21814        let collected = with_test_app_context(|| {
21815            collect_layer_contents(
21816                &parent,
21817                None,
21818                None,
21819                &mut rect_cache,
21820                &mut requirements_cache,
21821            )
21822        });
21823
21824        assert!(
21825            collected.child_layers.is_empty(),
21826            "decoration-only text child should collapse directly into the parent scene"
21827        );
21828        assert_eq!(collected.scene.texts.len(), 1, "expected one text draw");
21829        let text = &collected.scene.texts[0];
21830        assert!(
21831            text.rect.x >= 9.0 && text.rect.y >= 7.0,
21832            "collapsed text rect should be translated into parent space, got {:?}",
21833            text.rect
21834        );
21835        assert!(
21836            collected
21837                .scene
21838                .shapes
21839                .iter()
21840                .any(|shape| shape.rect.y >= 7.0),
21841            "collapsed underline geometry should also be translated into parent space"
21842        );
21843    }
21844
21845    #[test]
21846    fn normalized_scene_keeps_lazy_after_bound_text_for_prewarm() {
21847        use std::cell::RefCell;
21848
21849        fn collect_graph_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
21850            for child in &layer.children {
21851                match child {
21852                    RenderNode::Primitive(PrimitiveEntry {
21853                        node: PrimitiveNode::Text(text),
21854                        ..
21855                    }) => labels.push(text.text.text.clone()),
21856                    RenderNode::Layer(child_layer) => {
21857                        collect_graph_text_labels(child_layer, labels)
21858                    }
21859                    RenderNode::Primitive(_) | RenderNode::DrawRun(_) => {}
21860                }
21861            }
21862        }
21863
21864        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
21865        let state_holder_for_comp = state_holder.clone();
21866        let mut composition = cranpose_ui::run_test_composition(move || {
21867            let list_state = remember_lazy_list_state();
21868            *state_holder_for_comp.borrow_mut() = Some(list_state);
21869            let mut spec = LazyColumnSpec::new()
21870                .vertical_arrangement(cranpose_ui::LinearArrangement::SpacedBy(6.0));
21871            spec.beyond_bounds_item_count = 0;
21872            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
21873                scope.items(
21874                    12,
21875                    None::<fn(usize) -> u64>,
21876                    None::<fn(usize) -> u64>,
21877                    |index| {
21878                        Text(
21879                            format!("WarmRow {index}"),
21880                            Modifier::empty().height(32.0),
21881                            TextStyle::default(),
21882                        );
21883                    },
21884                );
21885            });
21886        });
21887
21888        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
21889        list_state.scroll_to_item(4, 0.0);
21890
21891        let root = composition.root().expect("lazy column root");
21892        let handle = composition.runtime_handle();
21893        let mut applier = composition.applier_mut();
21894        applier.set_runtime_handle(handle);
21895        let _ = applier
21896            .compute_layout(
21897                root,
21898                Size {
21899                    width: 240.0,
21900                    height: 240.0,
21901                },
21902            )
21903            .expect("lazy column layout");
21904        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
21905        applier.clear_runtime_handle();
21906        let mut graph_labels = Vec::new();
21907        collect_graph_text_labels(&graph.root, &mut graph_labels);
21908
21909        let visible_indices: Vec<_> = list_state
21910            .layout_info()
21911            .visible_items_info
21912            .iter()
21913            .map(|item| item.index)
21914            .collect();
21915        assert_eq!(
21916            visible_indices,
21917            vec![4, 5, 6],
21918            "test setup expects exactly three viewport-visible rows"
21919        );
21920
21921        let mut rect_cache = HashMap::new();
21922        let mut requirements_cache = HashMap::new();
21923        let collected = with_test_app_context(|| {
21924            collect_layer_contents(
21925                &graph.root,
21926                None,
21927                None,
21928                &mut rect_cache,
21929                &mut requirements_cache,
21930            )
21931        });
21932        let root_text_labels: Vec<_> = collected
21933            .scene
21934            .texts
21935            .iter()
21936            .map(|text| text.text.text.clone())
21937            .collect();
21938        let child_layer_count = collected.child_layers.len();
21939        let warm_text = collected
21940            .scene
21941            .texts
21942            .iter()
21943            .find(|text| text.text.text == "WarmRow 7")
21944            .unwrap_or_else(|| {
21945                panic!(
21946                    "after-bound lazy text should reach WGPU scene collection; graph_texts={graph_labels:?} root_texts={root_text_labels:?} child_layers={child_layer_count}"
21947                )
21948            });
21949
21950        assert!(
21951            warm_text.rect.y >= 96.0,
21952            "after-bound text should be below the viewport, got {:?}",
21953            warm_text.rect
21954        );
21955        assert_eq!(
21956            visible_draw_rect(warm_text.rect, warm_text.clip),
21957            None,
21958            "after-bound text should remain clipped away for drawing while staying available for glyph prewarm"
21959        );
21960        assert!(
21961            text_draw_should_prewarm_in_viewport(
21962                warm_text.rect,
21963                warm_text.clip,
21964                ViewportUniformParams {
21965                    width: 240,
21966                    height: 96,
21967                    offset: [0.0, 0.0],
21968                },
21969                1.0,
21970            ),
21971            "after-bound text inside the warm window must be selected by WGPU prewarm"
21972        );
21973    }
21974
21975    #[test]
21976    fn direct_translation_accepts_nearly_identity_axis_scale_noise() {
21977        let local_bounds = Rect {
21978            x: 0.0,
21979            y: 0.0,
21980            width: 393.3,
21981            height: 16.8,
21982        };
21983        let quad = [
21984            [10.0, 78.399_994],
21985            [403.3, 78.399_994],
21986            [10.0, 95.2],
21987            [403.3, 95.2],
21988        ];
21989        let transform = ProjectiveTransform::from_rect_to_quad(local_bounds, quad);
21990
21991        assert_eq!(
21992            direct_translation(transform),
21993            Some(Point::new(10.0, 78.399_994)),
21994        );
21995    }
21996
21997    #[test]
21998    fn layer_surface_requirements_keep_shape_plus_isolating_child_as_mixed_content() {
21999        let mut child = test_layer(
22000            Rect {
22001                x: 0.0,
22002                y: 0.0,
22003                width: 24.0,
22004                height: 18.0,
22005            },
22006            vec![RenderNode::Primitive(PrimitiveEntry {
22007                phase: PrimitivePhase::BeforeChildren,
22008                node: PrimitiveNode::Draw(DrawPrimitiveNode {
22009                    primitive: DrawPrimitive::Rect {
22010                        rect: Rect {
22011                            x: 0.0,
22012                            y: 0.0,
22013                            width: 24.0,
22014                            height: 18.0,
22015                        },
22016                        brush: Brush::solid(Color::WHITE),
22017                        stroke: None,
22018                    },
22019                    clip: None,
22020                }),
22021            })],
22022        );
22023        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
22024        child.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
22025
22026        let layer = test_layer(
22027            Rect {
22028                x: 0.0,
22029                y: 0.0,
22030                width: 64.0,
22031                height: 32.0,
22032            },
22033            vec![
22034                RenderNode::Primitive(PrimitiveEntry {
22035                    phase: PrimitivePhase::BeforeChildren,
22036                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
22037                        primitive: DrawPrimitive::Rect {
22038                            rect: Rect {
22039                                x: 0.0,
22040                                y: 0.0,
22041                                width: 64.0,
22042                                height: 32.0,
22043                            },
22044                            brush: Brush::solid(Color::BLACK),
22045                            stroke: None,
22046                        },
22047                        clip: None,
22048                    }),
22049                }),
22050                RenderNode::Layer(Box::new(child)),
22051            ],
22052        );
22053
22054        let requirements = layer_surface_requirements(&layer);
22055
22056        assert!(requirements
22057            .surface_requirements
22058            .contains(SurfaceRequirement::MixedDirectContent));
22059        assert!(!requirements
22060            .surface_requirements
22061            .has_isolating_requirement());
22062    }
22063
22064    #[test]
22065    fn build_scene_window_filters_and_translates_items() {
22066        let mut shape = test_shape(6, BlendMode::SrcOver);
22067        shape.rect.x = 12.0;
22068        shape.rect.y = 25.0;
22069        shape.local_rect.x = 12.0;
22070        shape.local_rect.y = 25.0;
22071        shape.quad = [[12.0, 25.0], [20.0, 25.0], [12.0, 33.0], [20.0, 33.0]];
22072        shape.clip = Some(Rect {
22073            x: 11.0,
22074            y: 24.0,
22075            width: 10.0,
22076            height: 10.0,
22077        });
22078
22079        let mut image = test_image(8, BlendMode::SrcOver);
22080        image.rect.x = 18.0;
22081        image.rect.y = 27.0;
22082        image.local_rect.x = 18.0;
22083        image.local_rect.y = 27.0;
22084        image.quad = [[18.0, 27.0], [26.0, 27.0], [18.0, 35.0], [26.0, 35.0]];
22085
22086        let mut text = test_text(9);
22087        text.rect.x = 16.0;
22088        text.rect.y = 29.0;
22089        text.clip = Some(Rect {
22090            x: 15.0,
22091            y: 28.0,
22092            width: 9.0,
22093            height: 6.0,
22094        });
22095
22096        let mut shadow_shape = test_shape(7, BlendMode::SrcOver);
22097        shadow_shape.rect.x = 14.0;
22098        shadow_shape.rect.y = 26.0;
22099        shadow_shape.local_rect.x = 14.0;
22100        shadow_shape.local_rect.y = 26.0;
22101        shadow_shape.quad = [[14.0, 26.0], [22.0, 26.0], [14.0, 34.0], [22.0, 34.0]];
22102        let mut shadow = test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]);
22103        shadow.z_index = 7;
22104
22105        let mut nested_effect = effect_layer(6, 10);
22106        nested_effect.rect.x = 13.0;
22107        nested_effect.rect.y = 24.0;
22108        nested_effect.clip = Some(Rect {
22109            x: 15.0,
22110            y: 25.0,
22111            width: 4.0,
22112            height: 5.0,
22113        });
22114
22115        let mut nested_backdrop = backdrop_layer(8);
22116        nested_backdrop.rect.x = 17.0;
22117        nested_backdrop.rect.y = 26.0;
22118        nested_backdrop.clip = Some(Rect {
22119            x: 18.0,
22120            y: 27.0,
22121            width: 3.0,
22122            height: 4.0,
22123        });
22124
22125        let window = build_scene_window(
22126            SceneWindowSource {
22127                shapes: &[test_shape(4, BlendMode::SrcOver), shape],
22128                brushes: &[],
22129                images: &[image],
22130                texts: &[text],
22131                shadow_draws: &[shadow],
22132                draw_ops: &[],
22133                effect_layers: &[effect_layer(2, 4), nested_effect.clone()],
22134                backdrop_layers: &[backdrop_layer(4), nested_backdrop.clone()],
22135            },
22136            5,
22137            10,
22138            Rect {
22139                x: 10.0,
22140                y: 20.0,
22141                width: 20.0,
22142                height: 20.0,
22143            },
22144        );
22145
22146        assert_eq!(window.shapes.len(), 1);
22147        assert_eq!(
22148            window.shapes[0].rect,
22149            Rect {
22150                x: 2.0,
22151                y: 5.0,
22152                width: 8.0,
22153                height: 8.0,
22154            }
22155        );
22156        assert_eq!(
22157            window.shapes[0].clip,
22158            Some(Rect {
22159                x: 1.0,
22160                y: 4.0,
22161                width: 10.0,
22162                height: 10.0,
22163            })
22164        );
22165        assert_eq!(window.images.len(), 1);
22166        assert_eq!(window.images[0].rect.x, 8.0);
22167        assert_eq!(window.images[0].rect.y, 7.0);
22168        assert_eq!(window.texts.len(), 1);
22169        assert_eq!(window.texts[0].rect.x, 6.0);
22170        assert_eq!(window.texts[0].rect.y, 9.0);
22171        assert_eq!(
22172            window.texts[0].clip,
22173            Some(Rect {
22174                x: 5.0,
22175                y: 8.0,
22176                width: 9.0,
22177                height: 6.0,
22178            })
22179        );
22180        assert_eq!(window.shadow_draws.len(), 1);
22181        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.x, 4.0);
22182        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.y, 6.0);
22183        assert_eq!(window.effect_layers.len(), 1);
22184        assert_eq!(
22185            window.effect_layers[0].rect,
22186            Rect {
22187                x: 3.0,
22188                y: 4.0,
22189                width: 10.0,
22190                height: 10.0,
22191            }
22192        );
22193        assert_eq!(
22194            window.effect_layers[0].clip,
22195            Some(Rect {
22196                x: 5.0,
22197                y: 5.0,
22198                width: 4.0,
22199                height: 5.0,
22200            })
22201        );
22202        assert_eq!(window.backdrop_layers.len(), 1);
22203        assert_eq!(
22204            window.backdrop_layers[0].rect,
22205            Rect {
22206                x: 7.0,
22207                y: 6.0,
22208                width: 10.0,
22209                height: 10.0,
22210            }
22211        );
22212        assert_eq!(
22213            window.backdrop_layers[0].clip,
22214            Some(Rect {
22215                x: 8.0,
22216                y: 7.0,
22217                width: 3.0,
22218                height: 4.0,
22219            })
22220        );
22221    }
22222
22223    #[test]
22224    fn filtered_effect_layer_index_counts_only_window_members() {
22225        let effects = vec![
22226            effect_layer(0, 2),
22227            effect_layer(5, 12),
22228            effect_layer(6, 10),
22229            effect_layer(14, 20),
22230        ];
22231
22232        assert_eq!(filtered_effect_layer_index(&effects, 1, 5, 12), Some(0));
22233        assert_eq!(filtered_effect_layer_index(&effects, 2, 5, 12), Some(1));
22234        assert_eq!(filtered_effect_layer_index(&effects, 3, 5, 12), None);
22235    }
22236
22237    #[test]
22238    fn blend_mode_support_matrix_is_explicit() {
22239        assert!(is_blend_mode_supported(BlendMode::Src));
22240        assert!(is_blend_mode_supported(BlendMode::SrcOver));
22241        assert!(is_blend_mode_supported(BlendMode::DstOut));
22242        assert!(!is_blend_mode_supported(BlendMode::Clear));
22243        assert!(!is_blend_mode_supported(BlendMode::Multiply));
22244    }
22245
22246    #[test]
22247    fn collect_non_effect_segment_items_preserves_global_z_order() {
22248        let shapes = vec![
22249            test_shape(3, BlendMode::SrcOver),
22250            test_shape(1, BlendMode::DstOut),
22251        ];
22252        let images = vec![test_image(2, BlendMode::SrcOver)];
22253        let texts = vec![test_text(0)];
22254        let shadows: Vec<ShadowDraw> = Vec::new();
22255        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
22256
22257        let mut scratch = Vec::new();
22258        collect_non_effect_segment_items(
22259            &shapes,
22260            &images,
22261            &texts,
22262            &shadows,
22263            &draw_ops,
22264            0,
22265            4,
22266            &[],
22267            100,
22268            100,
22269            1.0,
22270            &mut scratch,
22271        );
22272        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
22273        assert_eq!(
22274            items,
22275            vec![
22276                SegmentDrawItem::Text(0),
22277                SegmentDrawItem::Shape(1),
22278                SegmentDrawItem::Image(0),
22279                SegmentDrawItem::Shape(0),
22280            ]
22281        );
22282    }
22283
22284    #[test]
22285    fn collect_non_effect_segment_items_filters_effect_ranges() {
22286        let shapes = vec![
22287            test_shape(1, BlendMode::SrcOver),
22288            test_shape(3, BlendMode::DstOut),
22289        ];
22290        let images = vec![test_image(2, BlendMode::SrcOver)];
22291        let texts = vec![test_text(4)];
22292        let shadows: Vec<ShadowDraw> = Vec::new();
22293        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
22294        let effect_ranges = [std::ops::Range { start: 2, end: 4 }];
22295
22296        let mut scratch = Vec::new();
22297        collect_non_effect_segment_items(
22298            &shapes,
22299            &images,
22300            &texts,
22301            &shadows,
22302            &draw_ops,
22303            0,
22304            5,
22305            &effect_ranges,
22306            100,
22307            100,
22308            1.0,
22309            &mut scratch,
22310        );
22311        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
22312        assert_eq!(
22313            items,
22314            vec![SegmentDrawItem::Shape(0), SegmentDrawItem::Text(0)]
22315        );
22316    }
22317
22318    #[test]
22319    fn collect_non_effect_segment_items_culls_offscreen_shapes_but_keeps_text_prewarm() {
22320        let mut shape = test_shape(0, BlendMode::SrcOver);
22321        shape.rect.y = 160.0;
22322        shape.local_rect.y = 160.0;
22323        shape.quad = [[0.0, 160.0], [8.0, 160.0], [0.0, 168.0], [8.0, 168.0]];
22324
22325        let shapes = vec![shape];
22326        let images = Vec::new();
22327        let mut text = test_text(1);
22328        text.rect.y = 160.0;
22329        let texts = vec![text];
22330        let shadows: Vec<ShadowDraw> = Vec::new();
22331        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
22332
22333        let mut scratch = Vec::new();
22334        collect_non_effect_segment_items(
22335            &shapes,
22336            &images,
22337            &texts,
22338            &shadows,
22339            &draw_ops,
22340            0,
22341            2,
22342            &[],
22343            100,
22344            100,
22345            1.0,
22346            &mut scratch,
22347        );
22348
22349        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
22350        assert_eq!(items, vec![SegmentDrawItem::Text(0)]);
22351    }
22352
22353    #[test]
22354    fn segment_command_iter_merges_non_conflicting_batches_into_one_chunk() {
22355        let ordered_items = vec![
22356            (0, SegmentDrawItem::Shape(0)),
22357            (1, SegmentDrawItem::Image(0)),
22358            (2, SegmentDrawItem::Text(0)),
22359        ];
22360        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
22361        let images = vec![test_image(1, BlendMode::DstOut)];
22362
22363        let commands: Vec<_> = SegmentCommandIter::new(
22364            &ordered_items,
22365            &shapes,
22366            &images,
22367            ShapeBatchLimits::desktop(),
22368        )
22369        .collect();
22370
22371        assert_eq!(
22372            commands,
22373            vec![SegmentRenderCommand::DrawChunk(chunk(&[
22374                SegmentBatchPlan::Shape {
22375                    start: 0,
22376                    end: 1,
22377                    blend_mode: BlendMode::SrcOver,
22378                },
22379                SegmentBatchPlan::Image {
22380                    start: 1,
22381                    end: 2,
22382                    blend_mode: BlendMode::DstOut,
22383                },
22384                SegmentBatchPlan::Text { start: 2, end: 3 },
22385            ]))]
22386        );
22387    }
22388
22389    #[test]
22390    fn segment_command_iter_keeps_layer_composites_in_ordered_draw_chunk() {
22391        let ordered_items = vec![
22392            (0, SegmentDrawItem::Shape(0)),
22393            (1, SegmentDrawItem::Composite(0)),
22394            (2, SegmentDrawItem::Image(0)),
22395            (3, SegmentDrawItem::Composite(1)),
22396            (4, SegmentDrawItem::Text(0)),
22397        ];
22398        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
22399        let images = vec![test_image(2, BlendMode::SrcOver)];
22400
22401        let commands: Vec<_> = SegmentCommandIter::new(
22402            &ordered_items,
22403            &shapes,
22404            &images,
22405            ShapeBatchLimits::desktop(),
22406        )
22407        .collect();
22408
22409        assert_eq!(
22410            commands,
22411            vec![SegmentRenderCommand::DrawChunk(chunk(&[
22412                SegmentBatchPlan::Shape {
22413                    start: 0,
22414                    end: 1,
22415                    blend_mode: BlendMode::SrcOver,
22416                },
22417                SegmentBatchPlan::Composite { start: 1, end: 2 },
22418                SegmentBatchPlan::Image {
22419                    start: 2,
22420                    end: 3,
22421                    blend_mode: BlendMode::SrcOver,
22422                },
22423                SegmentBatchPlan::Composite { start: 3, end: 4 },
22424                SegmentBatchPlan::Text { start: 4, end: 5 },
22425            ]))]
22426        );
22427    }
22428
22429    #[test]
22430    fn retain_renderable_shadow_items_culls_invisible_shadow_boundaries() {
22431        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
22432        let images = vec![test_image(2, BlendMode::SrcOver)];
22433        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
22434        shadow_shape.rect = Rect {
22435            x: 500.0,
22436            y: 500.0,
22437            width: 12.0,
22438            height: 12.0,
22439        };
22440        let shadow_draws = vec![ShadowDraw {
22441            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
22442            brushes: vec![],
22443            texts: Vec::new(),
22444            blur_radius: 8.0,
22445            clip: None,
22446            z_index: 1,
22447        }];
22448        let mut ordered_items = vec![
22449            (0, SegmentDrawItem::Shape(0)),
22450            (1, SegmentDrawItem::Shadow(0)),
22451            (2, SegmentDrawItem::Image(0)),
22452        ];
22453
22454        let culled =
22455            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
22456        let commands: Vec<_> = SegmentCommandIter::new(
22457            &ordered_items,
22458            &shapes,
22459            &images,
22460            ShapeBatchLimits::desktop(),
22461        )
22462        .collect();
22463
22464        assert_eq!(culled, 1);
22465        assert_eq!(
22466            commands,
22467            vec![SegmentRenderCommand::DrawChunk(chunk(&[
22468                SegmentBatchPlan::Shape {
22469                    start: 0,
22470                    end: 1,
22471                    blend_mode: BlendMode::SrcOver,
22472                },
22473                SegmentBatchPlan::Image {
22474                    start: 1,
22475                    end: 2,
22476                    blend_mode: BlendMode::SrcOver,
22477                },
22478            ]))]
22479        );
22480    }
22481
22482    #[test]
22483    fn retain_renderable_shadow_items_keeps_visible_shadow_boundaries() {
22484        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
22485        shadow_shape.rect = Rect {
22486            x: 20.0,
22487            y: 20.0,
22488            width: 12.0,
22489            height: 12.0,
22490        };
22491        let shadow_draws = vec![ShadowDraw {
22492            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
22493            brushes: vec![],
22494            texts: Vec::new(),
22495            blur_radius: 8.0,
22496            clip: None,
22497            z_index: 1,
22498        }];
22499        let mut ordered_items = vec![(1, SegmentDrawItem::Shadow(0))];
22500
22501        let culled =
22502            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
22503
22504        assert_eq!(culled, 0);
22505        assert_eq!(ordered_items, vec![(1, SegmentDrawItem::Shadow(0))]);
22506    }
22507
22508    #[test]
22509    fn shape_data_layout_matches_the_wgsl_mirror() {
22510        // 10 x vec4-sized slots. The uniform address space requires a 16-byte
22511        // multiple, and `shape.wgsl`'s array length literal is derived from
22512        // this size — if it drifts, batches silently overrun the binding.
22513        assert_eq!(std::mem::size_of::<ShapeData>(), 160);
22514        assert_eq!(std::mem::size_of::<ShapeData>() % 16, 0);
22515        assert_eq!(std::mem::size_of::<GradientStop>(), 32);
22516    }
22517
22518    #[test]
22519    fn shape_flags_pack_kind_cap_and_join_without_collision() {
22520        assert_eq!(
22521            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
22522            0.0
22523        );
22524        assert_eq!(
22525            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
22526            1.0
22527        );
22528        assert_eq!(
22529            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter),
22530            2.0
22531        );
22532        // cap in bits 2-3, join in bits 4-5
22533        assert_eq!(
22534            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Round, StrokeJoin::Miter),
22535            2.0 + 4.0
22536        );
22537        assert_eq!(
22538            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Square, StrokeJoin::Miter),
22539            2.0 + 8.0
22540        );
22541        assert_eq!(
22542            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Round),
22543            1.0 + 16.0
22544        );
22545        assert_eq!(
22546            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Bevel),
22547            1.0 + 32.0
22548        );
22549        // Every combination must round-trip through f32 exactly.
22550        for kind in [SHAPE_KIND_FILL, SHAPE_KIND_STROKE, SHAPE_KIND_ARC] {
22551            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
22552                for join in [StrokeJoin::Miter, StrokeJoin::Round, StrokeJoin::Bevel] {
22553                    let packed = pack_shape_flags(kind, cap, join);
22554                    let bits = packed as u32;
22555                    assert_eq!(bits & 3, kind);
22556                    assert_eq!((bits >> 2) & 3, stroke_cap_code(cap));
22557                    assert_eq!((bits >> 4) & 3, stroke_join_code(join));
22558                    assert_eq!(packed, bits as f32, "flags must be exact in f32");
22559                }
22560            }
22561        }
22562    }
22563
22564    #[cfg(not(target_arch = "wasm32"))]
22565    #[test]
22566    fn mesh_vertex_layout_matches_the_wgsl_input() {
22567        // {pos: vec2<f32>, uv: vec2<f32>, shape_idx: u32} = 20 bytes, no
22568        // padding — the vertex buffer layout stride relies on it.
22569        assert_eq!(std::mem::size_of::<MeshVertex>(), 20);
22570    }
22571
22572    /// f32 port of `sdf_arc_band` (shape.wgsl), operation for operation: the
22573    /// same ra/rb derivation and clamp, the same mirror trick (`abs` on the
22574    /// rotated x), the same cap branches.
22575    #[cfg(not(target_arch = "wasm32"))]
22576    #[allow(clippy::too_many_arguments)]
22577    fn sdf_arc_band_reference(
22578        p: [f32; 2],
22579        center: [f32; 2],
22580        inner: f32,
22581        outer: f32,
22582        mid_sin_cos: [f32; 2],
22583        half_sin_cos: [f32; 2],
22584        cap: u32,
22585    ) -> f32 {
22586        let ra = (outer + inner) * 0.5;
22587        let rb = ((outer - inner) * 0.5).max(0.0);
22588        let sm = mid_sin_cos[0];
22589        let cm = mid_sin_cos[1];
22590        let d = [p[0] - center[0], p[1] - center[1]];
22591        let mut q = [-sm * d[0] + cm * d[1], cm * d[0] + sm * d[1]];
22592        q[0] = q[0].abs();
22593        let sc = half_sin_cos;
22594        let mut dist = if sc[1] * q[0] > sc[0] * q[1] {
22595            let dx = q[0] - sc[0] * ra;
22596            let dy = q[1] - sc[1] * ra;
22597            (dx * dx + dy * dy).sqrt() - rb
22598        } else {
22599            ((q[0] * q[0] + q[1] * q[1]).sqrt() - ra).abs() - rb
22600        };
22601        let plane = sc[1] * q[0] - sc[0] * q[1];
22602        // STROKE_CAP_BUTT = 0, STROKE_CAP_SQUARE = 2, as in the shader.
22603        if cap == 0 {
22604            dist = dist.max(plane);
22605        } else if cap == 2 {
22606            dist = dist.max(plane - rb);
22607        }
22608        dist
22609    }
22610
22611    #[cfg(not(target_arch = "wasm32"))]
22612    fn point_in_triangle(p: [f64; 2], tri: &[[f64; 2]; 3]) -> bool {
22613        let side = |a: [f64; 2], b: [f64; 2]| {
22614            (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
22615        };
22616        let d0 = side(tri[0], tri[1]);
22617        let d1 = side(tri[1], tri[2]);
22618        let d2 = side(tri[2], tri[0]);
22619        let has_neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
22620        let has_pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
22621        !(has_neg && has_pos)
22622    }
22623
22624    #[cfg(not(target_arch = "wasm32"))]
22625    fn converted_arc_shape(arc: cranpose_ui_graphics::ArcGeometry, root_scale: f32) -> ShapeData {
22626        let bounds = arc.bounds();
22627        let mut shape = test_shape(0, BlendMode::SrcOver);
22628        shape.rect = bounds;
22629        shape.local_rect = bounds;
22630        shape.quad = [
22631            [bounds.x, bounds.y],
22632            [bounds.x + bounds.width, bounds.y],
22633            [bounds.x, bounds.y + bounds.height],
22634            [bounds.x + bounds.width, bounds.y + bounds.height],
22635        ];
22636        shape.arc = Some(arc);
22637        let mut converted = ShapeData::zeroed();
22638        convert_shape_into_slots(&shape, &[], root_scale, 0, &mut converted, &mut []);
22639        converted
22640    }
22641
22642    /// The containment invariant, checked directly: every point of the
22643    /// capture box whose (exactly ported) SDF keeps it must lie inside the
22644    /// emitted triangle set. Thin/thick, tiny/huge, full rings, near-zero
22645    /// and near-TAU sweeps, all caps, `Ri == 0` discs and pie wedges.
22646    #[cfg(not(target_arch = "wasm32"))]
22647    #[test]
22648    fn arc_mesh_contains_every_band_pixel() {
22649        use cranpose_ui_graphics::ArcGeometry;
22650        let tau = cranpose_ui_graphics::TAU;
22651        let center = Point::new(250.0, 250.0);
22652        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
22653            // full ring, thin band
22654            (90.0, 100.0, 0.0, tau, StrokeCap::Round),
22655            // sweep > TAU normalizes to a closed ring
22656            (80.0, 100.0, 1.0, 10.0, StrokeCap::Butt),
22657            // full disc: Ri == 0
22658            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
22659            // thick partial arc, every cap
22660            (30.0, 80.0, 0.7, 2.5, StrokeCap::Butt),
22661            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
22662            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
22663            // thin, axis-crossing sweep
22664            (99.0, 101.0, 3.0, 4.0, StrokeCap::Round),
22665            // tiny
22666            (0.6, 2.0, 0.3, 1.2, StrokeCap::Butt),
22667            // huge radius, thin band
22668            (1900.0, 1904.0, 0.1, 0.35, StrokeCap::Square),
22669            // near-zero sweep
22670            (40.0, 60.0, 5.0, 1e-3, StrokeCap::Round),
22671            // sweep near TAU: the cap pads wrap the range closed
22672            (40.0, 60.0, 0.2, tau - 1e-3, StrokeCap::Butt),
22673            // rb_m >= ra: the cap disc wraps the center (pie wedge)
22674            (0.0, 3.0, 1.0, 2.0, StrokeCap::Round),
22675            // filled annular sector (butt radial ends)
22676            (20.0, 60.0, 4.5, 1.9, StrokeCap::Butt),
22677        ];
22678        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
22679            // 2.75 is deliberately non-dyadic: quad corners and rect then
22680            // disagree by an ulp, which the axis-aligned gate must tolerate
22681            // (an equality-with-rect gate silently failed every arc on the
22682            // Huawei at scale 2.75).
22683            for root_scale in [1.0f32, 2.0, 2.75] {
22684                let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
22685                assert!(!arc.is_degenerate(), "case {case} must be drawable");
22686                let converted = converted_arc_shape(arc, root_scale);
22687                let band = arc_mesh_band(&converted)
22688                    .unwrap_or_else(|| panic!("case {case} must qualify for meshing"));
22689                let mut vertices = Vec::new();
22690                let mut indices = Vec::new();
22691                let segments =
22692                    emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22693                        .unwrap_or_else(|| panic!("case {case} must produce a mesh"));
22694                assert!(segments >= ARC_MESH_MIN_SEGMENTS);
22695                // The rasterized set is the indexed walk: triangles are index
22696                // triples into the shared vertex list.
22697                let position = |index: u32| {
22698                    let p = vertices[index as usize].position;
22699                    [p[0] as f64, p[1] as f64]
22700                };
22701                let triangles: Vec<[[f64; 2]; 3]> = indices
22702                    .as_chunks::<3>()
22703                    .0
22704                    .iter()
22705                    .map(|tri| [position(tri[0]), position(tri[1]), position(tri[2])])
22706                    .collect();
22707
22708                // Sample the QUAD box, not `rect`: quad expansion rasterizes the
22709                // quad, the mesh clips to the quad, and at non-dyadic root
22710                // scales the two boxes differ by an ulp.
22711                let [qx, qy, ..] = converted.quad01;
22712                let [_, _, qr, qb] = converted.quad23;
22713                let (rw, rh) = (qr - qx, qb - qy);
22714                let cap_bits = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
22715                let step = (rw.max(rh) / 400.0).clamp(0.25, 2.0);
22716                let mut band_points = 0usize;
22717                let mut y = qy;
22718                while y <= qb {
22719                    let mut x = qx;
22720                    while x <= qr {
22721                        let dist = sdf_arc_band_reference(
22722                            [x, y],
22723                            [converted.arc_params[0], converted.arc_params[1]],
22724                            converted.stroke_params[3],
22725                            converted.stroke_params[2],
22726                            [converted.radii[0], converted.radii[1]],
22727                            [converted.radii[2], converted.radii[3]],
22728                            cap_bits,
22729                        );
22730                        if dist <= 0.5 {
22731                            band_points += 1;
22732                            let p = [x as f64, y as f64];
22733                            assert!(
22734                                triangles.iter().any(|tri| point_in_triangle(p, tri)),
22735                                "case {case} scale {root_scale}: band point ({x}, {y}) \
22736                                 dist {dist} escapes the mesh"
22737                            );
22738                        }
22739                        x += step;
22740                    }
22741                    y += step;
22742                }
22743                assert!(
22744                    band_points > 0,
22745                    "case {case} scale {root_scale}: the sampling grid never hit the band"
22746                );
22747            }
22748        }
22749    }
22750
22751    /// An unmeshed shape contributes NOTHING to the mesh buffers — no
22752    /// vertices, no indices, only an empty `index_prefix` range — because
22753    /// the draw walk keeps it on the instanced-quad path. Routing
22754    /// passthrough quads through the mesh vertex stream is exactly what the
22755    /// watch A/B measured as the S3 loss.
22756    #[cfg(not(target_arch = "wasm32"))]
22757    #[test]
22758    fn unmeshed_shapes_leave_no_geometry_and_empty_index_ranges() {
22759        let shape = test_shape(0, BlendMode::SrcOver);
22760        let mut converted = ShapeData::zeroed();
22761        convert_shape_into_slots(&shape, &[], 1.0, 0, &mut converted, &mut []);
22762        let build = build_arc_mesh_vertices(
22763            std::slice::from_ref(&converted),
22764            RETAINED_MESH_MIN_PX2_DEFAULT as f64,
22765        )
22766        .expect("within budget");
22767        assert_eq!(build.meshed_arcs, 0);
22768        assert_eq!(build.meshed_rims, 0);
22769        assert_eq!(build.passthrough, 1);
22770        assert_eq!(build.meshed_stretches, 0);
22771        assert!(build.vertices.is_empty());
22772        assert!(build.indices.is_empty());
22773        assert_eq!(build.index_prefix, vec![0, 0]);
22774        // The instanced arm submits the bounding quad; the telemetry must
22775        // price it as such.
22776        assert_eq!(build.mesh_area, build.quad_area);
22777    }
22778
22779    /// The indexed-topology contract for arcs whose trapezoids survive
22780    /// clipping whole: every band boundary contributes exactly one (inner,
22781    /// outer) vertex pair, both adjacent trapezoids reference it through the
22782    /// index list, and a closed ring's last segment wraps around to boundary
22783    /// zero's pair — one seam vertex pair instead of bitwise-equal copies.
22784    #[cfg(not(target_arch = "wasm32"))]
22785    #[test]
22786    fn arc_mesh_indices_share_boundary_vertices_and_wrap_closed_rings() {
22787        use cranpose_ui_graphics::ArcGeometry;
22788        let tau = cranpose_ui_graphics::TAU;
22789        // (sweep, expected boundary count relation): a closed ring wraps
22790        // (boundaries == segments), an open arc does not (segments + 1).
22791        for (sweep, closed) in [(tau, true), (1.9f32, false)] {
22792            let arc = ArcGeometry::new(
22793                Point::new(250.0, 250.0),
22794                80.0,
22795                100.0,
22796                0.7,
22797                sweep,
22798                StrokeCap::Round,
22799            );
22800            let mut converted = converted_arc_shape(arc, 1.0);
22801            // Inflate the quad box (and rect, for uv) far beyond the dilated
22802            // band so NO trapezoid is clipped: every segment must take the
22803            // shared-boundary path.
22804            converted.rect = [0.0, 0.0, 500.0, 500.0];
22805            converted.quad01 = [0.0, 0.0, 500.0, 0.0];
22806            converted.quad23 = [0.0, 500.0, 500.0, 500.0];
22807            let band = arc_mesh_band(&converted).expect("arc must qualify");
22808            let mut vertices = Vec::new();
22809            let mut indices = Vec::new();
22810            let segments = emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22811                .expect("arc must mesh");
22812            let boundary_count = if closed { segments } else { segments + 1 };
22813            assert_eq!(
22814                vertices.len(),
22815                2 * boundary_count,
22816                "closed={closed}: every boundary owns exactly one (inner, outer) pair"
22817            );
22818            assert_eq!(indices.len(), 6 * segments);
22819            // Emission order is boundary order: boundary j's pair is
22820            // (2j, 2j + 1). Each segment must reference its own boundary and
22821            // its successor's — modulo the count exactly when closed.
22822            for j in 0..segments {
22823                let jb = (j + 1) % boundary_count;
22824                let (in_a, out_a) = (2 * j as u32, 2 * j as u32 + 1);
22825                let (in_b, out_b) = (2 * jb as u32, 2 * jb as u32 + 1);
22826                assert_eq!(
22827                    indices[6 * j..6 * j + 6],
22828                    [in_a, out_a, out_b, in_a, out_b, in_b],
22829                    "closed={closed}: segment {j} must share its boundary pairs"
22830                );
22831            }
22832            if closed {
22833                // The wrap made concrete: the final segment indexes boundary
22834                // zero's vertices.
22835                assert_eq!(indices[6 * segments - 1], 0);
22836            }
22837            // Inner vertices ride the dilated inner radius, outer vertices
22838            // the pushed-out chord radius — sanity that pairs are ordered
22839            // (inner, outer).
22840            for pair in vertices.as_chunks::<2>().0 {
22841                let radius = |v: &MeshVertex| {
22842                    let dx = v.position[0] - 250.0;
22843                    let dy = v.position[1] - 250.0;
22844                    (dx * dx + dy * dy).sqrt()
22845                };
22846                assert!(radius(&pair[0]) < radius(&pair[1]));
22847            }
22848        }
22849    }
22850
22851    /// The private-vertex arm of the indexed topology: under the real
22852    /// tight-AABB quad the pushed-out chord vertices near the box edges get
22853    /// clipped, and those trapezoids must fan over vertices of their own —
22854    /// appended after the shared block, carrying clip-plane coordinates —
22855    /// while untouched diagonal trapezoids still share boundary pairs.
22856    #[cfg(not(target_arch = "wasm32"))]
22857    #[test]
22858    fn arc_mesh_clipped_segments_fan_over_private_vertices() {
22859        use cranpose_ui_graphics::ArcGeometry;
22860        let arc = ArcGeometry::new(
22861            Point::new(250.0, 250.0),
22862            80.0,
22863            100.0,
22864            0.0,
22865            cranpose_ui_graphics::TAU,
22866            StrokeCap::Round,
22867        );
22868        let converted = converted_arc_shape(arc, 1.0);
22869        let band = arc_mesh_band(&converted).expect("ring must qualify");
22870        let mut vertices = Vec::new();
22871        let mut indices = Vec::new();
22872        emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
22873            .expect("ring must mesh");
22874        // Sharing must actually happen: a shared boundary vertex is used by
22875        // both of its trapezoids' fans (at least three triangle references).
22876        let mut uses = vec![0usize; vertices.len()];
22877        for &index in &indices {
22878            uses[index as usize] += 1;
22879        }
22880        assert!(
22881            uses.iter().any(|&count| count >= 3),
22882            "some boundary vertices must be shared across trapezoids"
22883        );
22884        // Clipping must actually happen, and clipped polygons index private
22885        // vertices lying bitwise ON the quad box (the clipper writes the
22886        // bound coordinate exactly; boundary vertices never touch the box —
22887        // inner ones sit strictly inside, pushed-out outer ones strictly
22888        // outside near the extremes, where they are clipped).
22889        let [left, top, ..] = converted.quad01;
22890        let [.., right, bottom] = converted.quad23;
22891        let clipped: Vec<&MeshVertex> = vertices
22892            .iter()
22893            .filter(|vertex| {
22894                let [x, y] = vertex.position;
22895                x == left || x == right || y == top || y == bottom
22896            })
22897            .collect();
22898        assert!(
22899            !clipped.is_empty(),
22900            "the tight box must clip the pushed-out chord vertices"
22901        );
22902        // Fewer unique vertices than the non-indexed emitter's
22903        // three-per-triangle — the amplification this change removes.
22904        assert!(
22905            vertices.len() < indices.len(),
22906            "{} unique vertices should undercut {} triangle corners",
22907            vertices.len(),
22908            indices.len()
22909        );
22910    }
22911
22912    #[cfg(not(target_arch = "wasm32"))]
22913    #[test]
22914    fn arc_mesh_budget_overflow_falls_back_to_whole_slot_passthrough() {
22915        use cranpose_ui_graphics::ArcGeometry;
22916        // 100 large full rings mesh at the 64-segment ceiling (well over
22917        // 4 KB of vertices + indices each), far past the byte budget
22918        // max(100 * ~960 B, ~80 KB) — the builder must refuse the whole
22919        // slot rather than truncate.
22920        let arc = ArcGeometry::new(
22921            Point::new(2000.0, 2000.0),
22922            1690.0,
22923            1710.0,
22924            0.0,
22925            cranpose_ui_graphics::TAU,
22926            StrokeCap::Round,
22927        );
22928        let converted = converted_arc_shape(arc, 1.0);
22929        let shapes = vec![converted; 100];
22930        assert!(build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64).is_none());
22931    }
22932
22933    /// The size gate, boundary-exact: a shape meshes when its quad area is
22934    /// AT LEAST the threshold and passes through below it — with the
22935    /// engagement counters saying which happened — and the arc and rim
22936    /// acceptances both sit behind the same gate.
22937    #[cfg(not(target_arch = "wasm32"))]
22938    #[test]
22939    fn retained_mesh_size_gate_engages_exactly_per_threshold() {
22940        use cranpose_ui_graphics::ArcGeometry;
22941        // A big ring (quad ~322 px square ≈ 104k px²), a small brick arc
22942        // (quad well under 1024 px²), and a big stroked-circle rim
22943        // (90k-px² quad) in one capture.
22944        let big_ring = converted_arc_shape(
22945            ArcGeometry::new(
22946                Point::new(204.0, 204.0),
22947                140.0,
22948                160.0,
22949                0.0,
22950                cranpose_ui_graphics::TAU,
22951                StrokeCap::Butt,
22952            ),
22953            1.0,
22954        );
22955        let small_arc = converted_arc_shape(
22956            ArcGeometry::new(
22957                Point::new(204.0, 204.0),
22958                12.0,
22959                18.0,
22960                0.3,
22961                0.5,
22962                StrokeCap::Butt,
22963            ),
22964            1.0,
22965        );
22966        let rim = rim_test_shape_data();
22967        let shapes = [big_ring, small_arc, rim];
22968        let big_px2 = quad_shoelace_area(&shapes[0]);
22969        let small_px2 = quad_shoelace_area(&shapes[1]);
22970        let rim_px2 = quad_shoelace_area(&shapes[2]);
22971        assert!(small_px2 < 1024.0 && big_px2 > rim_px2 && rim_px2 > 16384.0);
22972
22973        // Default gate: both big shapes mesh, the brick arc stays instanced.
22974        // The meshed shapes sit at indices 0 and 2 with the brick between
22975        // them — two stretches.
22976        let build = build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64)
22977            .expect("within budget");
22978        assert_eq!(
22979            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22980            (1, 1, 1)
22981        );
22982        assert_eq!(build.meshed_stretches, 2);
22983        // The brick's index range is empty (no geometry emitted for it);
22984        // the ring's and rim's are not.
22985        assert_eq!(build.index_prefix[1], build.index_prefix[2]);
22986        assert!(build.index_prefix[1] > build.index_prefix[0]);
22987        assert!(build.index_prefix[3] > build.index_prefix[2]);
22988
22989        // ≥, not >: a threshold bitwise AT a shape's quad area still meshes
22990        // it...
22991        let build = build_arc_mesh_vertices(&shapes, big_px2).expect("within budget");
22992        assert_eq!(
22993            (build.meshed_arcs, build.meshed_rims, build.passthrough),
22994            (1, 0, 2)
22995        );
22996        // ...and one ulp above it does not.
22997        let build =
22998            build_arc_mesh_vertices(&shapes, big_px2 + big_px2 * f64::EPSILON).expect("budget");
22999        assert_eq!(
23000            (build.meshed_arcs, build.meshed_rims, build.passthrough),
23001            (0, 0, 3)
23002        );
23003
23004        // A threshold between the rim and the ring gates them apart.
23005        let build = build_arc_mesh_vertices(&shapes, (rim_px2 + big_px2) * 0.5).expect("budget");
23006        assert_eq!(
23007            (build.meshed_arcs, build.meshed_rims, build.passthrough),
23008            (1, 0, 2)
23009        );
23010
23011        // Gate-rejected shapes leave the mesh buffers EMPTY — they stay on
23012        // the instanced path, so a capture like this keeps no mesh at all.
23013        let everything_gated =
23014            build_arc_mesh_vertices(&shapes, big_px2 * 2.0).expect("within budget");
23015        assert_eq!(everything_gated.passthrough, 3);
23016        assert_eq!(everything_gated.meshed_stretches, 0);
23017        assert!(everything_gated.vertices.is_empty());
23018        assert_eq!(everything_gated.index_prefix, vec![0, 0, 0, 0]);
23019    }
23020
23021    /// The stretch counter counts MAXIMAL RUNS of consecutive meshed
23022    /// shapes — the quantity the capture site caps at
23023    /// [`MESH_SLOT_MAX_STRETCHES`], because each stretch costs the draw
23024    /// walk two pipeline switches per covering op.
23025    #[cfg(not(target_arch = "wasm32"))]
23026    #[test]
23027    fn meshed_stretches_count_maximal_runs_of_consecutive_meshed_shapes() {
23028        use cranpose_ui_graphics::ArcGeometry;
23029        let big = converted_arc_shape(
23030            ArcGeometry::new(
23031                Point::new(204.0, 204.0),
23032                140.0,
23033                160.0,
23034                0.0,
23035                cranpose_ui_graphics::TAU,
23036                StrokeCap::Butt,
23037            ),
23038            1.0,
23039        );
23040        let small = converted_arc_shape(
23041            ArcGeometry::new(
23042                Point::new(204.0, 204.0),
23043                12.0,
23044                18.0,
23045                0.3,
23046                0.5,
23047                StrokeCap::Butt,
23048            ),
23049            1.0,
23050        );
23051        // big big small big small small big big -> runs [0..2], [3], [6..8].
23052        let shapes = [big, big, small, big, small, small, big, big];
23053        let build = build_arc_mesh_vertices(&shapes, RETAINED_MESH_MIN_PX2_DEFAULT as f64)
23054            .expect("within budget");
23055        assert_eq!(build.meshed_arcs, 5);
23056        assert_eq!(build.passthrough, 3);
23057        assert_eq!(build.meshed_stretches, 3);
23058        // An all-instanced interleave never exceeds the cap vacuously: the
23059        // cap compares against this exact counter.
23060        assert!(build.meshed_stretches <= MESH_SLOT_MAX_STRETCHES);
23061    }
23062
23063    /// The env override's parse-and-clamp: unset and garbage read the
23064    /// default, in-range values pass through, and both clamp ends hold.
23065    #[cfg(not(target_arch = "wasm32"))]
23066    #[test]
23067    fn retained_mesh_px2_override_parses_and_clamps() {
23068        assert_eq!(
23069            parse_retained_mesh_min_px2(None),
23070            RETAINED_MESH_MIN_PX2_DEFAULT as f64
23071        );
23072        assert_eq!(
23073            parse_retained_mesh_min_px2(Some("not a number")),
23074            RETAINED_MESH_MIN_PX2_DEFAULT as f64
23075        );
23076        assert_eq!(
23077            parse_retained_mesh_min_px2(Some("-5")),
23078            RETAINED_MESH_MIN_PX2_DEFAULT as f64
23079        );
23080        assert_eq!(parse_retained_mesh_min_px2(Some(" 40000 ")), 40000.0);
23081        assert_eq!(
23082            parse_retained_mesh_min_px2(Some("0")),
23083            *RETAINED_MESH_MIN_PX2_RANGE.start() as f64
23084        );
23085        assert_eq!(
23086            parse_retained_mesh_min_px2(Some("99999999")),
23087            *RETAINED_MESH_MIN_PX2_RANGE.end() as f64
23088        );
23089    }
23090
23091    /// The retained builder accepts stroked-circle rims through
23092    /// [`rim_band_geometry`]: the emitted mesh is the closed annulus band
23093    /// (every vertex inside the dilated ring, none inside the hole), counted
23094    /// as a rim, while the same shape under the gate stays a quad.
23095    #[cfg(not(target_arch = "wasm32"))]
23096    #[test]
23097    fn retained_capture_meshes_big_stroked_circle_rims_as_annuli() {
23098        let rim = rim_test_shape_data();
23099        let build = build_arc_mesh_vertices(
23100            std::slice::from_ref(&rim),
23101            RETAINED_MESH_MIN_PX2_DEFAULT as f64,
23102        )
23103        .expect("within budget");
23104        assert_eq!(
23105            (build.meshed_arcs, build.meshed_rims, build.passthrough),
23106            (0, 1, 0)
23107        );
23108        assert!(build.meshed_segments >= ARC_MESH_MIN_SEGMENTS);
23109        // The annulus, not the quad: the mesh area is far below the 90k-px²
23110        // bounding quad and every vertex sits in the dilated band's radial
23111        // range (clip-plane vertices included — the quad box touches the
23112        // outer circle only near the axes, inside the band).
23113        assert!(build.mesh_area < 0.2 * build.quad_area);
23114        let band = rim_band_geometry(&rim).expect("rim must qualify");
23115        for vertex in &build.vertices {
23116            let dx = vertex.position[0] - band.center[0];
23117            let dy = vertex.position[1] - band.center[1];
23118            let radius = (dx * dx + dy * dy).sqrt();
23119            assert!(
23120                radius >= band.inner - ARC_MESH_MARGIN - 1e-3,
23121                "vertex at radius {radius} fell inside the annulus hole"
23122            );
23123        }
23124    }
23125
23126    /// A converted circle rim, hand-built in `ShapeData` terms: `rect` is the
23127    /// stroke-inflated 300×300 box, the geometry is 292×292, and the corner
23128    /// radius (300 − 8) / 2 = 146 equals the geometry half-extent — a circle.
23129    #[cfg(not(target_arch = "wasm32"))]
23130    fn rim_test_shape_data() -> ShapeData {
23131        let mut shape = ShapeData::zeroed();
23132        shape.rect = [40.0, 40.0, 300.0, 300.0];
23133        shape.radii = [146.0; 4];
23134        shape.stroke_params = [
23135            8.0,
23136            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
23137            0.0,
23138            0.0,
23139        ];
23140        shape.quad01 = [40.0, 40.0, 340.0, 40.0];
23141        shape.quad23 = [40.0, 340.0, 340.0, 340.0];
23142        shape.color = [1.0, 1.0, 1.0, 1.0];
23143        shape
23144    }
23145
23146    /// A viewport that never matches the diag's latched surface, so bucket
23147    /// tests exercise no corner accounting.
23148    #[cfg(not(target_arch = "wasm32"))]
23149    fn offscreen_test_viewport() -> ViewportUniformParams {
23150        ViewportUniformParams {
23151            width: 64,
23152            height: 64,
23153            offset: [7.0, 7.0],
23154        }
23155    }
23156
23157    #[cfg(not(target_arch = "wasm32"))]
23158    #[test]
23159    fn fill_diag_buckets_shape_quads_by_decoded_sdf_class() {
23160        let diag = FillAreaDiag::default();
23161        let mut arc = ShapeData::zeroed();
23162        arc.stroke_params[1] = pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter);
23163        // Arcs keep trig in `radii`; nonzero values there must not classify
23164        // the shape as a rounded fill.
23165        arc.radii = [0.5; 4];
23166        arc.quad01 = [0.0, 0.0, 10.0, 0.0];
23167        arc.quad23 = [0.0, 10.0, 10.0, 10.0];
23168        let mut rounded = ShapeData::zeroed();
23169        rounded.stroke_params[1] =
23170            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23171        rounded.radii = [2.0; 4];
23172        rounded.quad01 = [0.0, 0.0, 4.0, 0.0];
23173        rounded.quad23 = [0.0, 5.0, 4.0, 5.0];
23174        let mut plain = ShapeData::zeroed();
23175        plain.stroke_params[1] =
23176            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23177        plain.quad01 = [0.0, 0.0, 2.0, 0.0];
23178        plain.quad23 = [0.0, 3.0, 2.0, 3.0];
23179        diag.add_shape_quads(
23180            &[rim_test_shape_data(), arc, rounded, plain],
23181            offscreen_test_viewport(),
23182        );
23183        assert_eq!(diag.frame[FillAreaDiag::RRECT_STROKE].get(), 300.0 * 300.0);
23184        assert_eq!(diag.frame[FillAreaDiag::ARC].get(), 100.0);
23185        assert_eq!(diag.frame[FillAreaDiag::RRECT_FILL].get(), 20.0);
23186        assert_eq!(diag.frame[FillAreaDiag::RECT].get(), 6.0);
23187        // Off-frame passes never touch the corner counter.
23188        assert_eq!(diag.frame_corner.get(), 0.0);
23189        // Lit never exceeds the submitted area, bucket by bucket.
23190        for (lit, quad) in diag.frame_lit.iter().zip(&diag.frame) {
23191            assert!(lit.get() <= quad.get() + 1e-9);
23192        }
23193    }
23194
23195    #[cfg(not(target_arch = "wasm32"))]
23196    #[test]
23197    fn fill_diag_rim_mesh_moves_quad_area_to_the_mesh_bucket() {
23198        let diag = FillAreaDiag::default();
23199        diag.add_shape_quads(&[rim_test_shape_data()], offscreen_test_viewport());
23200        diag.note_rim_mesh(&rim_test_shape_data(), 1234.5);
23201        assert_eq!(diag.frame[FillAreaDiag::RRECT_STROKE].get(), 0.0);
23202        assert_eq!(diag.frame[FillAreaDiag::MESH].get(), 1234.5);
23203        // The lit accounting moves with the quad: nothing left in the
23204        // stroke bucket, and the mesh bucket's lit stays within the mesh.
23205        assert_eq!(diag.frame_lit[FillAreaDiag::RRECT_STROKE].get(), 0.0);
23206        assert!(diag.frame_lit[FillAreaDiag::MESH].get() <= 1234.5);
23207        assert!(diag.frame_lit[FillAreaDiag::MESH].get() > 0.0);
23208    }
23209
23210    #[cfg(not(target_arch = "wasm32"))]
23211    #[test]
23212    fn fill_diag_image_and_glyph_quads_share_one_bucket() {
23213        let diag = FillAreaDiag::default();
23214        diag.add_image_quad(&[[0.0, 0.0], [8.0, 0.0], [0.0, 4.0], [8.0, 4.0]]);
23215        let quad = CachedTextGlyphQuad {
23216            x: 0,
23217            y: 0,
23218            width: 5,
23219            height: 7,
23220            color: (1.0, 1.0, 1.0, 1.0),
23221            uv: ImageUvRect {
23222                min: [0.0, 0.0],
23223                max: [1.0, 1.0],
23224                sample_bounds: [0.0, 0.0, 1.0, 1.0],
23225            },
23226        };
23227        diag.add_glyph_quad(&quad);
23228        assert_eq!(diag.frame[FillAreaDiag::IMAGE_GLYPH].get(), 32.0 + 35.0);
23229        // Textures light their whole quad: lit tracks the submitted area.
23230        assert_eq!(diag.frame_lit[FillAreaDiag::IMAGE_GLYPH].get(), 32.0 + 35.0);
23231    }
23232
23233    /// Midpoint-rule area of `inside` over `bounds` (min x, min y, max x,
23234    /// max y), the reference the analytic-lit formulas are tested against.
23235    #[cfg(not(target_arch = "wasm32"))]
23236    fn numeric_area(bounds: [f64; 4], steps: usize, inside: impl Fn(f64, f64) -> bool) -> f64 {
23237        let dx = (bounds[2] - bounds[0]) / steps as f64;
23238        let dy = (bounds[3] - bounds[1]) / steps as f64;
23239        let mut area = 0.0;
23240        for column in 0..steps {
23241            let x = bounds[0] + (column as f64 + 0.5) * dx;
23242            for row in 0..steps {
23243                let y = bounds[1] + (row as f64 + 0.5) * dy;
23244                if inside(x, y) {
23245                    area += dx * dy;
23246                }
23247            }
23248        }
23249        area
23250    }
23251
23252    /// f64 rounded-rect SDF (uniform radius), the reference for the
23253    /// round-rect fill and stroke lit formulas.
23254    #[cfg(not(target_arch = "wasm32"))]
23255    fn sdf_rounded_rect_reference(
23256        p: [f64; 2],
23257        center: [f64; 2],
23258        half: [f64; 2],
23259        radius: f64,
23260    ) -> f64 {
23261        let qx = (p[0] - center[0]).abs() - (half[0] - radius);
23262        let qy = (p[1] - center[1]).abs() - (half[1] - radius);
23263        qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius
23264    }
23265
23266    #[cfg(not(target_arch = "wasm32"))]
23267    #[test]
23268    fn fill_truth_arc_lit_matches_the_sdf_covered_area() {
23269        use cranpose_ui_graphics::ArcGeometry;
23270        let tau = cranpose_ui_graphics::TAU;
23271        let center = Point::new(250.0, 250.0);
23272        // (inner, outer, start, sweep, cap): partial arcs with every cap,
23273        // a closed ring, and a full disc.
23274        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
23275            (90.0, 100.0, 0.7, 2.5, StrokeCap::Butt),
23276            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
23277            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
23278            (80.0, 100.0, 0.0, tau, StrokeCap::Round),
23279            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
23280        ];
23281        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
23282            let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
23283            let converted = converted_arc_shape(arc, 1.0);
23284            let cap_code = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
23285            let arc_center = [converted.arc_params[0], converted.arc_params[1]];
23286            let mid = [converted.radii[0], converted.radii[1]];
23287            let half = [converted.radii[2], converted.radii[3]];
23288            let aabb = quad_aabb(&converted);
23289            // Pad past the fast-trig AABB slop so the whole kept set is
23290            // integrated.
23291            let bounds = [aabb[0] - 2.0, aabb[1] - 2.0, aabb[2] + 2.0, aabb[3] + 2.0];
23292            let numeric = numeric_area(bounds, 1000, |x, y| {
23293                sdf_arc_band_reference(
23294                    [x as f32, y as f32],
23295                    arc_center,
23296                    converted.stroke_params[3],
23297                    converted.stroke_params[2],
23298                    mid,
23299                    half,
23300                    cap_code,
23301                ) < 0.0
23302            });
23303            let analytic = analytic_covered_area(&converted);
23304            let error = (analytic - numeric).abs() / numeric.max(1.0);
23305            assert!(
23306                error < 0.02,
23307                "case {case}: analytic {analytic:.1} vs sdf {numeric:.1} \
23308                 ({:.2}% off)",
23309                error * 100.0
23310            );
23311        }
23312    }
23313
23314    #[cfg(not(target_arch = "wasm32"))]
23315    #[test]
23316    fn fill_truth_circle_and_rrect_fill_lit_match_references() {
23317        // A filled circle degenerates to exactly pi r^2.
23318        let mut circle = ShapeData::zeroed();
23319        circle.stroke_params[1] =
23320            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23321        circle.rect = [10.0, 10.0, 200.0, 200.0];
23322        circle.radii = [100.0; 4];
23323        let analytic = analytic_covered_area(&circle);
23324        let exact = std::f64::consts::PI * 100.0 * 100.0;
23325        assert!(
23326            (analytic - exact).abs() / exact < 1e-9,
23327            "circle: {analytic} vs {exact}"
23328        );
23329
23330        // A rounded rect against the SDF reference.
23331        let mut rounded = ShapeData::zeroed();
23332        rounded.stroke_params[1] =
23333            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23334        rounded.rect = [50.0, 80.0, 200.0, 120.0];
23335        rounded.radii = [40.0; 4];
23336        let numeric = numeric_area([48.0, 78.0, 252.0, 202.0], 1000, |x, y| {
23337            sdf_rounded_rect_reference([x, y], [150.0, 140.0], [100.0, 60.0], 40.0) < 0.0
23338        });
23339        let analytic = analytic_covered_area(&rounded);
23340        let error = (analytic - numeric).abs() / numeric;
23341        assert!(
23342            error < 0.02,
23343            "rrect fill: analytic {analytic:.1} vs sdf {numeric:.1}"
23344        );
23345    }
23346
23347    #[cfg(not(target_arch = "wasm32"))]
23348    #[test]
23349    fn fill_truth_stroked_rrect_lit_matches_the_band_area() {
23350        // The circle rim: perimeter x stroke width equals the exact annulus
23351        // pi (outer^2 - inner^2) = 2 pi geom_half sw.
23352        let rim = rim_test_shape_data();
23353        let analytic = analytic_covered_area(&rim);
23354        let exact = std::f64::consts::PI * (150.0 * 150.0 - 142.0 * 142.0);
23355        assert!(
23356            (analytic - exact).abs() / exact < 1e-9,
23357            "circle rim: {analytic} vs {exact}"
23358        );
23359
23360        // A rounded-SQUARE ring (radius well below the half-extent) against
23361        // the SDF band |sdf| < sw/2.
23362        let mut square_ring = rim_test_shape_data();
23363        square_ring.radii = [60.0; 4];
23364        let numeric = numeric_area([38.0, 38.0, 342.0, 342.0], 1000, |x, y| {
23365            sdf_rounded_rect_reference([x, y], [190.0, 190.0], [146.0, 146.0], 60.0).abs() < 4.0
23366        });
23367        let analytic = analytic_covered_area(&square_ring);
23368        let error = (analytic - numeric).abs() / numeric;
23369        assert!(
23370            error < 0.02,
23371            "square ring: analytic {analytic:.1} vs sdf {numeric:.1}"
23372        );
23373    }
23374
23375    #[cfg(not(target_arch = "wasm32"))]
23376    #[test]
23377    fn fill_truth_corner_counter_prices_the_area_outside_the_inscribed_circle() {
23378        // A full-viewport quad on a square (watch) surface wastes exactly
23379        // the four corner lunes: (1 - pi/4) of the screen.
23380        let full = area_outside_inscribed_circle([0.0, 0.0, 454.0, 454.0], (454, 454));
23381        let exact = (1.0 - std::f64::consts::FRAC_PI_4) * 454.0 * 454.0;
23382        assert!(
23383            (full - exact).abs() / exact < 0.01,
23384            "full quad: {full} vs {exact}"
23385        );
23386        // A centered box inside the circle wastes nothing, exactly.
23387        assert_eq!(
23388            area_outside_inscribed_circle([127.0, 127.0, 327.0, 327.0], (454, 454)),
23389            0.0
23390        );
23391        // A box entirely inside a corner is all waste.
23392        let corner = area_outside_inscribed_circle([0.0, 0.0, 40.0, 40.0], (454, 454));
23393        assert!((corner - 1600.0).abs() < 1e-6, "corner box: {corner}");
23394    }
23395
23396    #[cfg(not(target_arch = "wasm32"))]
23397    #[test]
23398    fn fill_truth_opacity_histogram_classifies_solid_alpha_exactly() {
23399        let diag = FillAreaDiag::default();
23400        diag.reset_frame(454, 454);
23401        let full_frame = ViewportUniformParams {
23402            width: 454,
23403            height: 454,
23404            offset: [0.0, 0.0],
23405        };
23406        let mut opaque = ShapeData::zeroed();
23407        opaque.stroke_params[1] =
23408            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23409        opaque.rect = [0.0, 0.0, 100.0, 50.0];
23410        opaque.quad01 = [0.0, 0.0, 100.0, 0.0];
23411        opaque.quad23 = [0.0, 50.0, 100.0, 50.0];
23412        opaque.color = [1.0, 1.0, 1.0, 1.0];
23413        let mut faded = opaque;
23414        faded.color[3] = 0.82;
23415        let mut gradient = opaque;
23416        gradient.brush_type = 1;
23417        diag.add_shape_quads(&[opaque, faded, gradient], full_frame);
23418        // Plain rects are all-lit: 5000 px each, one per class.
23419        let lit = |class: FillOpacityClass| diag.frame_opacity[class as usize].get();
23420        assert_eq!(lit(FillOpacityClass::Opaque), 5000.0);
23421        assert_eq!(lit(FillOpacityClass::Translucent), 5000.0);
23422        assert_eq!(lit(FillOpacityClass::NonSolid), 5000.0);
23423        // The corner-hugging quads waste real area on a round display.
23424        assert!(diag.frame_corner.get() > 0.0);
23425
23426        // The same batch under an offset (offscreen) viewport must leave the
23427        // corner counter alone.
23428        let offscreen = FillAreaDiag::default();
23429        offscreen.reset_frame(454, 454);
23430        offscreen.add_shape_quads(&[opaque], offscreen_test_viewport());
23431        assert_eq!(offscreen.frame_corner.get(), 0.0);
23432    }
23433
23434    #[cfg(not(target_arch = "wasm32"))]
23435    #[test]
23436    fn fill_truth_retained_records_price_ranges_and_identity_corners() {
23437        let mut plain = ShapeData::zeroed();
23438        plain.stroke_params[1] =
23439            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23440        plain.rect = [200.0, 200.0, 20.0, 10.0];
23441        plain.quad01 = [200.0, 200.0, 220.0, 200.0];
23442        plain.quad23 = [200.0, 210.0, 220.0, 210.0];
23443        plain.color = [1.0, 1.0, 1.0, 1.0];
23444        let shapes = vec![rim_test_shape_data(), plain];
23445        let records = fill_diag_capture_records(&shapes, None);
23446        assert_eq!(records.len(), 2);
23447        assert_eq!(records[0].bucket, FillAreaDiag::RRECT_STROKE);
23448        assert_eq!(records[0].drawn_px2, 300.0 * 300.0);
23449        assert!(records[0].lit_px2 < records[0].drawn_px2, "a rim has slack");
23450        // A plain rect is exact: no slack at all.
23451        assert_eq!(records[1].bucket, FillAreaDiag::RECT);
23452        assert_eq!(records[1].lit_px2, records[1].drawn_px2);
23453
23454        let diag = FillAreaDiag::default();
23455        diag.reset_frame(454, 454);
23456        // Scaled replay: areas scale with the similarity squared, and the
23457        // capture-space AABBs no longer say where pixels land — no corner.
23458        let scaled = SimilarityTransform::new([0.0, 0.0], 0.0, 2.0);
23459        diag.add_retained_range(&records, 0, 2, &scaled);
23460        let drawn: f64 = records.iter().map(|record| record.drawn_px2).sum();
23461        assert!((diag.frame[FillAreaDiag::RETAINED].get() - drawn * 4.0).abs() < 1e-6);
23462        assert_eq!(diag.frame_corner.get(), 0.0);
23463
23464        // Identity replay: the rim's 300 px box on a 454 px round screen
23465        // pokes into the corner lunes.
23466        let identity_diag = FillAreaDiag::default();
23467        identity_diag.reset_frame(454, 454);
23468        identity_diag.add_retained_range(&records, 0, 2, &SimilarityTransform::IDENTITY);
23469        assert!(identity_diag.frame_corner.get() > 0.0);
23470        // And the range is respected: shape 1 alone has no rim slack.
23471        let tail = FillAreaDiag::default();
23472        tail.reset_frame(454, 454);
23473        tail.add_retained_range(&records, 1, 2, &SimilarityTransform::IDENTITY);
23474        assert_eq!(
23475            tail.frame[FillAreaDiag::RETAINED].get(),
23476            records[1].drawn_px2
23477        );
23478    }
23479
23480    #[cfg(not(target_arch = "wasm32"))]
23481    #[test]
23482    fn fill_truth_top_slack_dump_keeps_the_worst_ten() {
23483        let mut diag = FillAreaDiag::default();
23484        let records: Vec<FillDiagShapeRecord> = (0..12)
23485            .map(|index| FillDiagShapeRecord {
23486                drawn_px2: 1000.0 * (index + 1) as f64,
23487                lit_px2: 100.0,
23488                bucket: FillAreaDiag::ARC,
23489                opacity: FillOpacityClass::Opaque,
23490                aabb: [0.0, 0.0, 10.0, 10.0],
23491            })
23492            .collect();
23493        diag.note_retained_capture(3, &records);
23494        assert_eq!(diag.slack_top.len(), FILL_DIAG_SLACK_TOP);
23495        // Sorted by slack, worst first, and the two smallest fell off.
23496        assert_eq!(diag.slack_top[0].drawn_px2, 12000.0);
23497        assert_eq!(diag.slack_top[0].slot, 3);
23498        assert_eq!(diag.slack_top[0].shape, 11);
23499        for pair in diag.slack_top.windows(2) {
23500            assert!(pair[0].drawn_px2 - pair[0].lit_px2 >= pair[1].drawn_px2 - pair[1].lit_px2);
23501        }
23502        assert!(diag
23503            .slack_top
23504            .iter()
23505            .all(|entry| entry.drawn_px2 - entry.lit_px2 > 2000.0 - 100.0));
23506    }
23507
23508    #[cfg(not(target_arch = "wasm32"))]
23509    #[test]
23510    fn rim_mesh_band_accepts_only_huge_solid_unclipped_circle_rims() {
23511        let band = rim_mesh_band(&rim_test_shape_data()).expect("circle rim must qualify");
23512        assert_eq!(band.center, [190.0, 190.0]);
23513        assert_eq!(band.inner, 142.0);
23514        assert_eq!(band.outer, 150.0);
23515        assert_eq!(band.start, 0.0);
23516        assert!(
23517            band.sweep >= cranpose_ui_graphics::TAU,
23518            "a rim band is a closed ring"
23519        );
23520        // And it actually meshes through the shared emitter.
23521        let mut vertices = Vec::new();
23522        let mut indices = Vec::new();
23523        emit_arc_band_mesh(
23524            &rim_test_shape_data(),
23525            7,
23526            &band,
23527            &mut vertices,
23528            &mut indices,
23529        )
23530        .expect("rim must mesh");
23531        assert!(vertices.iter().all(|vertex| vertex.shape_idx == 7));
23532
23533        // Rounded SQUARE ring: radius well below the geometry half-extent.
23534        // Meshing it would under-cover the flat spans — the false positive
23535        // the circle gate exists to prevent.
23536        let mut square = rim_test_shape_data();
23537        square.radii = [100.0; 4];
23538        assert!(rim_mesh_band(&square).is_none());
23539
23540        // Non-square box.
23541        let mut oblong = rim_test_shape_data();
23542        oblong.rect = [40.0, 40.0, 300.0, 200.0];
23543        assert!(rim_mesh_band(&oblong).is_none());
23544
23545        // Gradient brush.
23546        let mut gradient = rim_test_shape_data();
23547        gradient.brush_type = 1;
23548        assert!(rim_mesh_band(&gradient).is_none());
23549
23550        // Live clip.
23551        let mut clipped = rim_test_shape_data();
23552        clipped.clip_rect = [0.0, 0.0, 400.0, 400.0];
23553        assert!(rim_mesh_band(&clipped).is_none());
23554
23555        // Small (100 × 100 < 65536 px²), even as a perfect circle.
23556        let mut small = rim_test_shape_data();
23557        small.rect = [40.0, 40.0, 100.0, 100.0];
23558        small.quad01 = [40.0, 40.0, 140.0, 40.0];
23559        small.quad23 = [40.0, 140.0, 140.0, 140.0];
23560        small.radii = [46.0; 4];
23561        assert!(rim_mesh_band(&small).is_none());
23562
23563        // Fill kind, not stroke.
23564        let mut fill = rim_test_shape_data();
23565        fill.stroke_params[1] =
23566            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
23567        assert!(rim_mesh_band(&fill).is_none());
23568
23569        // Zero stroke width.
23570        let mut hairline = rim_test_shape_data();
23571        hairline.stroke_params[0] = 0.0;
23572        assert!(rim_mesh_band(&hairline).is_none());
23573
23574        // Mismatched corner radii.
23575        let mut uneven = rim_test_shape_data();
23576        uneven.radii[2] = 145.0;
23577        assert!(rim_mesh_band(&uneven).is_none());
23578    }
23579
23580    #[cfg(not(target_arch = "wasm32"))]
23581    #[test]
23582    fn shape_batch_limits_follow_uniform_binding_size() {
23583        // With a 160-byte ShapeData, even a desktop-class 64 KiB binding can no
23584        // longer hold the full compile-time cap: 65536 / 160 = 409 < 768.
23585        let desktop_shapes = 65536 / std::mem::size_of::<ShapeData>();
23586        assert_eq!(desktop_shapes, 409);
23587        assert_eq!(
23588            ShapeBatchLimits::desktop(),
23589            ShapeBatchLimits {
23590                max_shapes_per_batch: desktop_shapes.min(MAX_SHAPES_PER_BATCH),
23591                max_gradient_stops: MAX_GRADIENT_STOPS,
23592                storage: false,
23593            }
23594        );
23595
23596        // The 16 KiB downlevel/GLES minimum must shrink batches to fit:
23597        // 16384 / 160-byte ShapeData = 102 shapes, 16384 / 32-byte stop = 512.
23598        let downlevel = ShapeBatchLimits::for_uniform_binding_size(16384);
23599        assert_eq!(downlevel.max_shapes_per_batch, 16384 / 160);
23600        assert_eq!(downlevel.max_shapes_per_batch, 102);
23601        assert_eq!(downlevel.max_gradient_stops, 512.min(MAX_GRADIENT_STOPS));
23602        assert!(downlevel.max_shapes_per_batch * std::mem::size_of::<ShapeData>() <= 16384);
23603        assert!(downlevel.max_gradient_stops * std::mem::size_of::<GradientStop>() <= 16384);
23604
23605        // Degenerate limits must not produce zero-sized buffers.
23606        let tiny = ShapeBatchLimits::for_uniform_binding_size(1);
23607        assert_eq!(tiny.max_shapes_per_batch, 1);
23608        assert_eq!(tiny.max_gradient_stops, 1);
23609    }
23610
23611    #[test]
23612    fn storage_shape_batch_limits_uncap_the_batch_and_start_small() {
23613        // A typical 128 MiB storage binding hits the compile-time ceilings,
23614        // not the device limit: one batch holds the whole scene.
23615        let storage = ShapeBatchLimits::for_storage_binding_size(128 << 20);
23616        assert!(storage.storage);
23617        assert_eq!(storage.max_shapes_per_batch, MAX_SHAPES_PER_STORAGE_BATCH);
23618        assert_eq!(
23619            storage.max_gradient_stops,
23620            MAX_GRADIENT_STOPS_PER_STORAGE_BATCH
23621        );
23622
23623        // The buffers must not be allocated at the multi-megabyte ceiling up
23624        // front; they start small and grow on demand.
23625        assert_eq!(
23626            storage.initial_shape_capacity(),
23627            INITIAL_STORAGE_BATCH_CAPACITY
23628        );
23629        assert_eq!(
23630            storage.initial_gradient_capacity(),
23631            INITIAL_STORAGE_BATCH_CAPACITY
23632        );
23633        assert_eq!(
23634            storage.data_binding_type(),
23635            wgpu::BufferBindingType::Storage { read_only: true }
23636        );
23637        assert!(storage
23638            .data_buffer_usage()
23639            .contains(wgpu::BufferUsages::STORAGE));
23640
23641        // Uniform mode keeps its start-at-the-cap invariant: a uniform
23642        // binding smaller than the shader's fixed array fails validation.
23643        let uniform = ShapeBatchLimits::desktop();
23644        assert_eq!(
23645            uniform.initial_shape_capacity(),
23646            uniform.max_shapes_per_batch
23647        );
23648        assert_eq!(
23649            uniform.initial_gradient_capacity(),
23650            uniform.max_gradient_stops
23651        );
23652        assert_eq!(
23653            uniform.data_binding_type(),
23654            wgpu::BufferBindingType::Uniform
23655        );
23656        assert!(uniform
23657            .data_buffer_usage()
23658            .contains(wgpu::BufferUsages::UNIFORM));
23659    }
23660
23661    #[test]
23662    fn storage_shape_shader_swaps_the_arrays_to_runtime_sized_storage() {
23663        let source =
23664            shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20), false);
23665        assert!(
23666            source.contains("var<storage, read> shape_data: array<ShapeData>;"),
23667            "storage-mode shader must declare a runtime-sized shape array"
23668        );
23669        assert!(
23670            source.contains("var<storage, read> gradient_stops: array<GradientStop>;"),
23671            "storage-mode shader must declare a runtime-sized gradient array"
23672        );
23673        assert!(
23674            !source.contains("var<uniform> shape_data"),
23675            "the uniform shape declaration must be fully replaced"
23676        );
23677        assert!(
23678            !source.contains("var<uniform> gradient_stops"),
23679            "the uniform gradient declaration must be fully replaced"
23680        );
23681        assert!(
23682            source.contains("var<storage, read> paint: array<vec4<f32>>;"),
23683            "storage-mode shader must declare the retained paint array"
23684        );
23685        assert!(
23686            source.contains("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)"),
23687            "storage-mode shader must read paint under the paint_select flag"
23688        );
23689        assert!(
23690            source.contains("fn vs_mesh("),
23691            "the storage rewrite must leave the retained-mesh vertex entry intact"
23692        );
23693        assert!(
23694            source.contains("fn vs_shape_instanced("),
23695            "the storage rewrite must leave the instanced-quad vertex entry intact"
23696        );
23697        assert_eq!(
23698            source
23699                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
23700                .count(),
23701            3,
23702            "vs_main, vs_shape_instanced and vs_mesh must all read paint under \
23703             the paint_select flag (meshless retained draws ride the instanced \
23704             entry when the selection is latched on)"
23705        );
23706
23707        // The storage variant is what native devices actually compile; it
23708        // must be valid WGSL, not just textually plausible.
23709        let module = naga::front::wgsl::parse_str(&source)
23710            .expect("storage-mode shape shader must parse as WGSL");
23711        naga::valid::Validator::new(
23712            naga::valid::ValidationFlags::all(),
23713            naga::valid::Capabilities::all(),
23714        )
23715        .validate(&module)
23716        .expect("storage-mode shape shader must validate for WebGPU");
23717    }
23718
23719    #[test]
23720    fn solid_trim_keeps_the_full_struct_locations_with_the_dropped_slots_vacant() {
23721        // Suspect #1 from the reverted first trim (16a5d312 / 371dd06a): the
23722        // survivors were renumbered densely. Every surviving varying line in
23723        // `VertexOutputSolid` must be byte-identical to its `VertexOutput`
23724        // line — same index, same interpolation, same type — and the two
23725        // dropped slots must stay vacant.
23726        let appendix = shaders::SOLID_TRIM_APPENDIX;
23727        for line in [
23728            "@location(0) color: vec4<f32>,",
23729            "@location(1) uv: vec2<f32>,",
23730            "@location(2) world_pos: vec2<f32>,",
23731            "@location(3) @interpolate(flat) rect: vec4<f32>,",
23732            "@location(4) @interpolate(flat) radii: vec4<f32>,",
23733            "@location(6) @interpolate(flat) clip_rect: vec4<f32>,",
23734            "@location(7) @interpolate(flat) stroke_params: vec4<f32>,",
23735            "@location(8) @interpolate(flat) arc_params: vec4<f32>,",
23736        ] {
23737            assert!(
23738                shaders::SHADER.contains(line),
23739                "`{line}` drifted out of VertexOutput; realign the trimmed \
23740                 struct line for line before touching anything else"
23741            );
23742            assert!(
23743                appendix.contains(line),
23744                "`{line}` must appear verbatim in VertexOutputSolid — the \
23745                 surviving varyings keep the full struct's location indices"
23746            );
23747        }
23748        assert!(
23749            !appendix.contains("@location(5)"),
23750            "location 5 is gradient_params' slot and must stay VACANT — \
23751             dense renumbering is the reverted attempt's suspect #1"
23752        );
23753        assert!(
23754            !appendix.contains("@location(9)"),
23755            "location 9 is brush's slot and must stay VACANT — dense \
23756             renumbering is the reverted attempt's suspect #1"
23757        );
23758        assert!(
23759            !appendix.contains("output.gradient_params") && !appendix.contains("output.brush"),
23760            "the trimmed vertex entries must not write the dropped varyings"
23761        );
23762    }
23763
23764    #[test]
23765    fn solid_trim_source_reaches_every_injection_and_validates() {
23766        // The trimmed entries are appended BEFORE `shape_shader_source`'s
23767        // rewrites, so the storage rewrite's paint-select injection must land
23768        // in all five vertex entries — a solid entry that missed it would
23769        // freeze every recolor on the retained slots it draws.
23770        let storage =
23771            shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20), true);
23772        for entry in [
23773            "fn vs_solid(",
23774            "fn vs_solid_instanced(",
23775            "fn fs_solid_trim(",
23776        ] {
23777            assert!(
23778                storage.contains(entry),
23779                "trimmed storage source must carry `{entry}`"
23780            );
23781        }
23782        assert_eq!(
23783            storage
23784                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
23785                .count(),
23786            5,
23787            "vs_main, vs_shape_instanced, vs_mesh, vs_solid and \
23788             vs_solid_instanced must all read paint under the paint_select \
23789             flag"
23790        );
23791
23792        // Both variants a native device can compile must be valid WGSL, flat
23793        // and with the display-clip z rewrite applied.
23794        let uniform = shape_shader_source(ShapeBatchLimits::desktop(), true);
23795        for source in [&storage, &uniform] {
23796            for depth in [false, true] {
23797                let text = display_clip::with_content_z(Cow::Owned(source.to_string()), depth);
23798                let module = naga::front::wgsl::parse_str(&text)
23799                    .expect("trimmed shape shader must parse as WGSL");
23800                naga::valid::Validator::new(
23801                    naga::valid::ValidationFlags::all(),
23802                    naga::valid::Capabilities::all(),
23803                )
23804                .validate(&module)
23805                .expect("trimmed shape shader must validate for WebGPU");
23806            }
23807        }
23808    }
23809
23810    #[test]
23811    fn solid_trim_flag_reads_the_documented_variable() {
23812        // The parity suite's trimmed arms set exactly this variable; a name
23813        // drift here would leave them silently comparing full against full.
23814        std::env::remove_var("CRANPOSE_SOLID_TRIM_VARYINGS");
23815        assert!(!solid_trim_varyings_enabled(), "the trim must default OFF");
23816        std::env::set_var("CRANPOSE_SOLID_TRIM_VARYINGS", "1");
23817        assert!(solid_trim_varyings_enabled());
23818        std::env::set_var("CRANPOSE_SOLID_TRIM_VARYINGS", "0");
23819        assert!(!solid_trim_varyings_enabled());
23820        std::env::remove_var("CRANPOSE_SOLID_TRIM_VARYINGS");
23821    }
23822
23823    #[test]
23824    fn uniform_shape_shader_keeps_the_in_record_color_and_no_paint_binding() {
23825        // The base text serves WebGL-class uniform devices, which can bind
23826        // no storage buffers: the paint array and its select must exist only
23827        // in the storage-mode rewrite.
23828        for source in [
23829            Cow::Borrowed(shaders::SHADER),
23830            shape_shader_source(ShapeBatchLimits::desktop(), false),
23831        ] {
23832            assert!(
23833                !source.contains("paint: array"),
23834                "the uniform variant must not declare a paint array"
23835            );
23836            assert!(
23837                source.contains("output.color = shape.color;"),
23838                "the uniform variant must read the color from ShapeData \
23839                 (this literal is also what `shape_shader_source` rewrites)"
23840            );
23841            assert!(
23842                source.contains("paint_select: f32"),
23843                "SimilarityTransform must name the flag field in both \
23844                 variants; the Rust mirror is Pod and uploads raw bytes"
23845            );
23846        }
23847    }
23848
23849    #[test]
23850    fn shipped_shape_shader_array_length_fits_the_downlevel_uniform_floor() {
23851        // The wasm build uses `shaders::SHADER` verbatim, so its declared array
23852        // length is simultaneously the wasm batch cap and the WebGL binding
23853        // size. It must fit the 16 KiB floor exactly.
23854        assert!(
23855            shaders::SHADER.contains("array<ShapeData, 102>"),
23856            "shape.wgsl array length must stay in sync with \
23857             `shape_shader_source`'s replace string and MAX_SHAPES_PER_BATCH"
23858        );
23859        assert!(102 * std::mem::size_of::<ShapeData>() <= 16384);
23860        assert!(103 * std::mem::size_of::<ShapeData>() > 16384);
23861    }
23862
23863    #[test]
23864    fn glyph_atlas_doubles_on_overflow_and_stops_at_the_device_ceiling() {
23865        // Every overflow buys one doubling, so an app that needs the old fixed
23866        // 4096 atlas reaches it in three resets and then stays there.
23867        assert_eq!(
23868            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MIN_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
23869            1024
23870        );
23871        assert_eq!(
23872            next_glyph_atlas_size(2048, TEXT_GLYPH_ATLAS_MAX_SIZE),
23873            TEXT_GLYPH_ATLAS_MAX_SIZE
23874        );
23875        assert_eq!(
23876            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MAX_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
23877            TEXT_GLYPH_ATLAS_MAX_SIZE
23878        );
23879
23880        // A device that only grants `downlevel_defaults()`'s 2048 caps the
23881        // growth there rather than failing to create the texture.
23882        assert_eq!(next_glyph_atlas_size(1024, 2048), 2048);
23883        assert_eq!(next_glyph_atlas_size(2048, 2048), 2048);
23884
23885        // Never zero and never wrapping, whatever the ceiling turns out to be.
23886        assert_eq!(next_glyph_atlas_size(u32::MAX, 4096), 4096);
23887        assert_eq!(next_glyph_atlas_size(0, 0), 1);
23888    }
23889
23890    #[test]
23891    fn glyph_atlas_uv_rect_normalizes_against_the_atlas_it_was_placed_in() {
23892        // The atlas grows, so a UV is only meaningful together with the size of
23893        // the texture the entry came from. Reading the size off a constant is
23894        // what would make a grown atlas sample the wrong glyph.
23895        let entry = GlyphAtlasEntry {
23896            x: 128,
23897            y: 256,
23898            width: 16,
23899            height: 32,
23900        };
23901
23902        let small = glyph_atlas_uv_rect(entry, 512);
23903        let large = glyph_atlas_uv_rect(entry, 4096);
23904
23905        assert_eq!(small.min, [128.0 / 512.0, 256.0 / 512.0]);
23906        assert_eq!(large.min, [128.0 / 4096.0, 256.0 / 4096.0]);
23907        assert_eq!(small.max, [144.0 / 512.0, 288.0 / 512.0]);
23908        assert_eq!(large.max, [144.0 / 4096.0, 288.0 / 4096.0]);
23909    }
23910
23911    #[test]
23912    fn native_shape_shader_source_uses_native_batch_limits() {
23913        let limits = ShapeBatchLimits::desktop();
23914        let source = shape_shader_source(limits, false);
23915
23916        assert!(source.contains(&format!(
23917            "array<ShapeData, {}>",
23918            limits.max_shapes_per_batch
23919        )));
23920        assert!(source.contains(&format!(
23921            "array<GradientStop, {}>",
23922            limits.max_gradient_stops
23923        )));
23924        // Sanity: the substitution actually fired rather than silently leaving
23925        // the downlevel literal in place.
23926        assert!(!source.contains("array<ShapeData, 146>"));
23927    }
23928
23929    #[test]
23930    fn stroked_and_arc_shapes_batch_together_with_fills() {
23931        // Strokes and arcs ride the same pipeline, the same ShapeData array and
23932        // the same blend state as fills, so a run of mixed shapes must stay a
23933        // single batch. If they ever split the batch, a polar UI built from
23934        // hundreds of arcs would pay a draw call per arc — precisely the cost
23935        // this primitive exists to remove.
23936        let fill = test_shape(0, BlendMode::SrcOver);
23937        let mut stroked = test_shape(1, BlendMode::SrcOver);
23938        stroked.stroke = Some(
23939            cranpose_ui_graphics::Stroke::new(3.0)
23940                .with_cap(StrokeCap::Round)
23941                .with_join(StrokeJoin::Bevel),
23942        );
23943        let mut arc = test_shape(2, BlendMode::SrcOver);
23944        arc.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
23945            Point::new(4.0, 4.0),
23946            2.0,
23947            4.0,
23948            0.0,
23949            1.0,
23950            StrokeCap::Round,
23951        ));
23952        let trailing_fill = test_shape(3, BlendMode::SrcOver);
23953
23954        assert!(!fill.has_stroke_or_arc());
23955        assert!(stroked.has_stroke_or_arc());
23956        assert!(arc.has_stroke_or_arc());
23957        assert!(!trailing_fill.has_stroke_or_arc());
23958
23959        let shapes = vec![fill, stroked, arc, trailing_fill];
23960        let ordered_items: Vec<_> = (0..shapes.len())
23961            .map(|index| (index, SegmentDrawItem::Shape(index)))
23962            .collect();
23963        let images = Vec::new();
23964
23965        let commands: Vec<_> = SegmentCommandIter::new(
23966            &ordered_items,
23967            &shapes,
23968            &images,
23969            ShapeBatchLimits::desktop(),
23970        )
23971        .collect();
23972
23973        assert_eq!(
23974            commands,
23975            vec![SegmentRenderCommand::DrawChunk(chunk(&[
23976                SegmentBatchPlan::Shape {
23977                    start: 0,
23978                    end: 4,
23979                    blend_mode: BlendMode::SrcOver,
23980                }
23981            ]))],
23982            "mixed fill/stroke/arc runs must stay one batch"
23983        );
23984    }
23985
23986    #[cfg(not(target_arch = "wasm32"))]
23987    #[test]
23988    fn native_segment_fusion_budget_allows_small_interleaved_chunks() {
23989        let ordered_items = vec![
23990            (0, SegmentDrawItem::Shape(0)),
23991            (1, SegmentDrawItem::Image(0)),
23992            (2, SegmentDrawItem::Text(0)),
23993            (3, SegmentDrawItem::Shape(1)),
23994        ];
23995        let shapes = vec![
23996            test_shape(0, BlendMode::SrcOver),
23997            test_shape(3, BlendMode::DstOut),
23998        ];
23999        let segment = chunk(&[
24000            SegmentBatchPlan::Shape {
24001                start: 0,
24002                end: 1,
24003                blend_mode: BlendMode::SrcOver,
24004            },
24005            SegmentBatchPlan::Image {
24006                start: 1,
24007                end: 2,
24008                blend_mode: BlendMode::SrcOver,
24009            },
24010            SegmentBatchPlan::Text { start: 2, end: 3 },
24011            SegmentBatchPlan::Shape {
24012                start: 3,
24013                end: 4,
24014                blend_mode: BlendMode::DstOut,
24015            },
24016        ]);
24017
24018        let budget = native_segment_fusion_budget(
24019            &ordered_items,
24020            &shapes,
24021            &[],
24022            &segment,
24023            ShapeBatchLimits::desktop(),
24024        )
24025        .expect("budget should be valid")
24026        .expect("chunk should fit native fusion budget");
24027
24028        assert_eq!(
24029            budget,
24030            NativeSegmentFusionBudget {
24031                shape_count: 2,
24032                gradient_stop_count: 0,
24033            }
24034        );
24035    }
24036
24037    #[cfg(not(target_arch = "wasm32"))]
24038    #[test]
24039    fn native_segment_fusion_budget_rejects_shape_uniform_overflow() {
24040        let ordered_items: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
24041            .map(|index| (index, SegmentDrawItem::Shape(index)))
24042            .collect();
24043        let shapes: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
24044            .map(|index| test_shape(index, BlendMode::SrcOver))
24045            .collect();
24046        let segment = chunk(&[
24047            SegmentBatchPlan::Shape {
24048                start: 0,
24049                end: MAX_SHAPES_PER_BATCH,
24050                blend_mode: BlendMode::SrcOver,
24051            },
24052            SegmentBatchPlan::Shape {
24053                start: MAX_SHAPES_PER_BATCH,
24054                end: MAX_SHAPES_PER_BATCH + 1,
24055                blend_mode: BlendMode::SrcOver,
24056            },
24057        ]);
24058
24059        let budget = native_segment_fusion_budget(
24060            &ordered_items,
24061            &shapes,
24062            &[],
24063            &segment,
24064            ShapeBatchLimits::desktop(),
24065        )
24066        .expect("valid plan");
24067
24068        assert_eq!(budget, None);
24069    }
24070
24071    #[cfg(not(target_arch = "wasm32"))]
24072    #[test]
24073    fn native_segment_fusion_budget_rejects_gradient_uniform_overflow() {
24074        let ordered_items = vec![(0, SegmentDrawItem::Shape(0))];
24075        let mut shape = test_shape(0, BlendMode::SrcOver);
24076        let brushes = vec![Brush::linear_gradient(vec![
24077            Color::BLACK;
24078            MAX_GRADIENT_STOPS + 1
24079        ])];
24080        shape.brush = SceneBrush::Gradient(0);
24081        let shapes = vec![shape];
24082        let segment = chunk(&[SegmentBatchPlan::Shape {
24083            start: 0,
24084            end: 1,
24085            blend_mode: BlendMode::SrcOver,
24086        }]);
24087
24088        let budget = native_segment_fusion_budget(
24089            &ordered_items,
24090            &shapes,
24091            &brushes,
24092            &segment,
24093            ShapeBatchLimits::desktop(),
24094        )
24095        .expect("valid plan");
24096
24097        assert_eq!(budget, None);
24098    }
24099
24100    #[cfg(not(target_arch = "wasm32"))]
24101    #[test]
24102    fn native_segment_fusion_partitions_shape_uniform_overflow() {
24103        // The uniform batch cap is derived from the device binding size and
24104        // the 112-byte ShapeData, not from the compile-time ceiling.
24105        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
24106        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
24107            .map(|index| (index, SegmentDrawItem::Shape(index)))
24108            .collect();
24109        let shapes: Vec<_> = (0..=desktop_batch_cap)
24110            .map(|index| test_shape(index, BlendMode::SrcOver))
24111            .collect();
24112        let segment = chunk(&[
24113            SegmentBatchPlan::Shape {
24114                start: 0,
24115                end: desktop_batch_cap,
24116                blend_mode: BlendMode::SrcOver,
24117            },
24118            SegmentBatchPlan::Shape {
24119                start: desktop_batch_cap,
24120                end: desktop_batch_cap + 1,
24121                blend_mode: BlendMode::SrcOver,
24122            },
24123        ]);
24124
24125        let partitions = native_segment_fusion_partitions(
24126            &ordered_items,
24127            &shapes,
24128            &[],
24129            &segment,
24130            ShapeBatchLimits::desktop(),
24131        )
24132        .expect("valid plan")
24133        .expect("overflowing segment should be partitionable");
24134
24135        assert_eq!(partitions.len(), 2);
24136        assert_eq!(
24137            partitions[0],
24138            NativeSegmentFusionPartition {
24139                chunk: chunk(&[SegmentBatchPlan::Shape {
24140                    start: 0,
24141                    end: desktop_batch_cap,
24142                    blend_mode: BlendMode::SrcOver,
24143                }]),
24144                budget: NativeSegmentFusionBudget {
24145                    shape_count: desktop_batch_cap,
24146                    gradient_stop_count: 0,
24147                },
24148            }
24149        );
24150        assert_eq!(
24151            partitions[1],
24152            NativeSegmentFusionPartition {
24153                chunk: chunk(&[SegmentBatchPlan::Shape {
24154                    start: desktop_batch_cap,
24155                    end: desktop_batch_cap + 1,
24156                    blend_mode: BlendMode::SrcOver,
24157                }]),
24158                budget: NativeSegmentFusionBudget {
24159                    shape_count: 1,
24160                    gradient_stop_count: 0,
24161                },
24162            }
24163        );
24164    }
24165
24166    #[cfg(not(target_arch = "wasm32"))]
24167    #[test]
24168    fn native_segment_fusion_partitions_gradient_uniform_overflow() {
24169        const STOPS_PER_SHAPE: usize = MAX_GRADIENT_STOPS / 2;
24170        let ordered_items = vec![
24171            (0, SegmentDrawItem::Shape(0)),
24172            (1, SegmentDrawItem::Shape(1)),
24173            (2, SegmentDrawItem::Shape(2)),
24174        ];
24175        let mut shapes = Vec::new();
24176        let brushes = vec![Brush::linear_gradient(vec![Color::BLACK; STOPS_PER_SHAPE])];
24177        for index in 0..3 {
24178            let mut shape = test_shape(index, BlendMode::SrcOver);
24179            shape.brush = SceneBrush::Gradient(0);
24180            shapes.push(shape);
24181        }
24182        let segment = chunk(&[SegmentBatchPlan::Shape {
24183            start: 0,
24184            end: 3,
24185            blend_mode: BlendMode::SrcOver,
24186        }]);
24187
24188        let partitions = native_segment_fusion_partitions(
24189            &ordered_items,
24190            &shapes,
24191            &brushes,
24192            &segment,
24193            ShapeBatchLimits::desktop(),
24194        )
24195        .expect("valid plan")
24196        .expect("overflowing gradient segment should be partitionable");
24197
24198        assert_eq!(partitions.len(), 2);
24199        assert_eq!(
24200            partitions[0],
24201            NativeSegmentFusionPartition {
24202                chunk: chunk(&[SegmentBatchPlan::Shape {
24203                    start: 0,
24204                    end: 2,
24205                    blend_mode: BlendMode::SrcOver,
24206                }]),
24207                budget: NativeSegmentFusionBudget {
24208                    shape_count: 2,
24209                    gradient_stop_count: MAX_GRADIENT_STOPS,
24210                },
24211            }
24212        );
24213        assert_eq!(
24214            partitions[1],
24215            NativeSegmentFusionPartition {
24216                chunk: chunk(&[SegmentBatchPlan::Shape {
24217                    start: 2,
24218                    end: 3,
24219                    blend_mode: BlendMode::SrcOver,
24220                }]),
24221                budget: NativeSegmentFusionBudget {
24222                    shape_count: 1,
24223                    gradient_stop_count: STOPS_PER_SHAPE,
24224                },
24225            }
24226        );
24227    }
24228
24229    #[cfg(not(target_arch = "wasm32"))]
24230    #[test]
24231    fn native_segment_fusion_accepts_layer_composite_chunks() {
24232        let ordered_items = vec![
24233            (0, SegmentDrawItem::Shape(0)),
24234            (1, SegmentDrawItem::Composite(0)),
24235            (2, SegmentDrawItem::ShaderComposite(0)),
24236            (3, SegmentDrawItem::Shape(1)),
24237        ];
24238        let shapes = vec![
24239            test_shape(0, BlendMode::SrcOver),
24240            test_shape(1, BlendMode::SrcOver),
24241        ];
24242        let segment = chunk(&[
24243            SegmentBatchPlan::Shape {
24244                start: 0,
24245                end: 1,
24246                blend_mode: BlendMode::SrcOver,
24247            },
24248            SegmentBatchPlan::Composite { start: 1, end: 2 },
24249            SegmentBatchPlan::ShaderComposite { start: 2, end: 3 },
24250            SegmentBatchPlan::Shape {
24251                start: 3,
24252                end: 4,
24253                blend_mode: BlendMode::SrcOver,
24254            },
24255        ]);
24256
24257        let partitions = native_segment_fusion_partitions(
24258            &ordered_items,
24259            &shapes,
24260            &[],
24261            &segment,
24262            ShapeBatchLimits::desktop(),
24263        )
24264        .expect("valid plan")
24265        .expect("composites are drawable inside the native fused pass");
24266
24267        assert_eq!(
24268            partitions,
24269            vec![NativeSegmentFusionPartition {
24270                chunk: segment,
24271                budget: NativeSegmentFusionBudget {
24272                    shape_count: 2,
24273                    gradient_stop_count: 0,
24274                },
24275            }],
24276            "layer composites and shader composites must preserve order without forcing separate render passes"
24277        );
24278    }
24279
24280    #[cfg(not(target_arch = "wasm32"))]
24281    #[test]
24282    fn native_segment_fusion_partitions_preserve_non_shape_order_at_budget_boundary() {
24283        // The uniform batch cap is derived from the device binding size and
24284        // the 112-byte ShapeData, not from the compile-time ceiling.
24285        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
24286        let ordered_items: Vec<_> = (0..desktop_batch_cap)
24287            .map(|index| (index, SegmentDrawItem::Shape(index)))
24288            .chain([
24289                (desktop_batch_cap, SegmentDrawItem::Image(0)),
24290                (
24291                    desktop_batch_cap + 1,
24292                    SegmentDrawItem::Shape(desktop_batch_cap),
24293                ),
24294            ])
24295            .collect();
24296        let shapes: Vec<_> = (0..=desktop_batch_cap)
24297            .map(|index| test_shape(index, BlendMode::SrcOver))
24298            .collect();
24299        let segment = chunk(&[
24300            SegmentBatchPlan::Shape {
24301                start: 0,
24302                end: desktop_batch_cap,
24303                blend_mode: BlendMode::SrcOver,
24304            },
24305            SegmentBatchPlan::Image {
24306                start: desktop_batch_cap,
24307                end: desktop_batch_cap + 1,
24308                blend_mode: BlendMode::SrcOver,
24309            },
24310            SegmentBatchPlan::Shape {
24311                start: desktop_batch_cap + 1,
24312                end: desktop_batch_cap + 2,
24313                blend_mode: BlendMode::SrcOver,
24314            },
24315        ]);
24316
24317        let partitions = native_segment_fusion_partitions(
24318            &ordered_items,
24319            &shapes,
24320            &[],
24321            &segment,
24322            ShapeBatchLimits::desktop(),
24323        )
24324        .expect("valid plan")
24325        .expect("overflowing segment should be partitionable");
24326
24327        assert_eq!(partitions.len(), 2);
24328        assert_eq!(
24329            partitions[0].chunk,
24330            chunk(&[
24331                SegmentBatchPlan::Shape {
24332                    start: 0,
24333                    end: desktop_batch_cap,
24334                    blend_mode: BlendMode::SrcOver,
24335                },
24336                SegmentBatchPlan::Image {
24337                    start: desktop_batch_cap,
24338                    end: desktop_batch_cap + 1,
24339                    blend_mode: BlendMode::SrcOver,
24340                },
24341            ])
24342        );
24343        assert_eq!(
24344            partitions[1].chunk,
24345            chunk(&[SegmentBatchPlan::Shape {
24346                start: desktop_batch_cap + 1,
24347                end: desktop_batch_cap + 2,
24348                blend_mode: BlendMode::SrcOver,
24349            }])
24350        );
24351    }
24352
24353    #[test]
24354    fn segment_command_iter_keeps_repeated_batch_kinds_in_one_chunk() {
24355        let ordered_items = vec![
24356            (0, SegmentDrawItem::Shape(0)),
24357            (1, SegmentDrawItem::Image(0)),
24358            (2, SegmentDrawItem::Shape(1)),
24359        ];
24360        let shapes = vec![
24361            test_shape(0, BlendMode::SrcOver),
24362            test_shape(2, BlendMode::DstOut),
24363        ];
24364        let images = vec![test_image(1, BlendMode::SrcOver)];
24365
24366        let commands: Vec<_> = SegmentCommandIter::new(
24367            &ordered_items,
24368            &shapes,
24369            &images,
24370            ShapeBatchLimits::desktop(),
24371        )
24372        .collect();
24373
24374        assert_eq!(
24375            commands,
24376            vec![SegmentRenderCommand::DrawChunk(chunk(&[
24377                SegmentBatchPlan::Shape {
24378                    start: 0,
24379                    end: 1,
24380                    blend_mode: BlendMode::SrcOver,
24381                },
24382                SegmentBatchPlan::Image {
24383                    start: 1,
24384                    end: 2,
24385                    blend_mode: BlendMode::SrcOver,
24386                },
24387                SegmentBatchPlan::Shape {
24388                    start: 2,
24389                    end: 3,
24390                    blend_mode: BlendMode::DstOut,
24391                },
24392            ]))]
24393        );
24394    }
24395
24396    #[test]
24397    fn segment_command_iter_splits_contiguous_shape_runs_at_uniform_batch_limit() {
24398        // The uniform batch cap is derived from the device binding size and
24399        // the 112-byte ShapeData, not from the compile-time ceiling.
24400        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
24401        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
24402            .map(|index| (index, SegmentDrawItem::Shape(index)))
24403            .collect();
24404        let shapes: Vec<_> = (0..=desktop_batch_cap)
24405            .map(|index| test_shape(index, BlendMode::SrcOver))
24406            .collect();
24407        let images = Vec::new();
24408
24409        let commands: Vec<_> = SegmentCommandIter::new(
24410            &ordered_items,
24411            &shapes,
24412            &images,
24413            ShapeBatchLimits::desktop(),
24414        )
24415        .collect();
24416
24417        assert_eq!(
24418            commands,
24419            vec![SegmentRenderCommand::DrawChunk(chunk(&[
24420                SegmentBatchPlan::Shape {
24421                    start: 0,
24422                    end: desktop_batch_cap,
24423                    blend_mode: BlendMode::SrcOver,
24424                },
24425                SegmentBatchPlan::Shape {
24426                    start: desktop_batch_cap,
24427                    end: desktop_batch_cap + 1,
24428                    blend_mode: BlendMode::SrcOver,
24429                },
24430            ]))]
24431        );
24432    }
24433
24434    #[test]
24435    fn segment_command_iter_keeps_shadows_as_explicit_boundaries() {
24436        let ordered_items = vec![
24437            (0, SegmentDrawItem::Shape(0)),
24438            (1, SegmentDrawItem::Shadow(0)),
24439            (2, SegmentDrawItem::Image(0)),
24440            (3, SegmentDrawItem::Text(0)),
24441        ];
24442        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
24443        let images = vec![test_image(2, BlendMode::SrcOver)];
24444
24445        let commands: Vec<_> = SegmentCommandIter::new(
24446            &ordered_items,
24447            &shapes,
24448            &images,
24449            ShapeBatchLimits::desktop(),
24450        )
24451        .collect();
24452
24453        assert_eq!(
24454            commands,
24455            vec![
24456                SegmentRenderCommand::DrawChunk(chunk(&[SegmentBatchPlan::Shape {
24457                    start: 0,
24458                    end: 1,
24459                    blend_mode: BlendMode::SrcOver,
24460                }])),
24461                SegmentRenderCommand::Shadow(0),
24462                SegmentRenderCommand::DrawChunk(chunk(&[
24463                    SegmentBatchPlan::Image {
24464                        start: 2,
24465                        end: 3,
24466                        blend_mode: BlendMode::SrcOver,
24467                    },
24468                    SegmentBatchPlan::Text { start: 3, end: 4 },
24469                ])),
24470            ]
24471        );
24472    }
24473
24474    #[test]
24475    fn staged_buffer_uploads_align_new_copies_to_copy_buffer_alignment() {
24476        let mut uploads = StagedBufferUploads::default();
24477        uploads.bytes.extend_from_slice(&[1, 2]);
24478
24479        uploads.stage(UploadTarget::ImageIndex, &[3, 4, 5, 6]);
24480
24481        assert_eq!(uploads.bytes, vec![1, 2, 0, 0, 3, 4, 5, 6]);
24482        assert_eq!(
24483            uploads.copies,
24484            vec![PendingBufferCopy {
24485                source_offset: 4,
24486                target_offset: 0,
24487                size: 4,
24488                target: UploadTarget::ImageIndex,
24489            }]
24490        );
24491    }
24492
24493    #[test]
24494    fn staged_buffer_uploads_ignore_empty_payloads() {
24495        let mut uploads = StagedBufferUploads::default();
24496
24497        uploads.stage(UploadTarget::Uniform, &[]);
24498
24499        assert!(uploads.is_empty());
24500        assert!(uploads.bytes.is_empty());
24501    }
24502
24503    #[test]
24504    fn staged_buffer_uploads_return_exact_payload_slice_for_copy() {
24505        let mut uploads = StagedBufferUploads::default();
24506        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
24507        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
24508
24509        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
24510        assert_eq!(uploads.payload_for_copy(uploads.copies[1]), &[5, 6, 7, 8]);
24511    }
24512
24513    #[test]
24514    fn staged_buffer_uploads_record_destination_offsets() {
24515        let mut uploads = StagedBufferUploads::default();
24516
24517        uploads.stage_at(UploadTarget::ImageIndex, 256, &[1, 2, 3, 4]);
24518
24519        assert_eq!(uploads.copies[0].target_offset, 256);
24520        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
24521    }
24522
24523    #[test]
24524    fn staged_buffer_uploads_truncate_restores_previous_state() {
24525        let mut uploads = StagedBufferUploads::default();
24526        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
24527        let bytes_len = uploads.bytes.len();
24528        let copies_len = uploads.copies.len();
24529        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
24530
24531        uploads.truncate(bytes_len, copies_len);
24532
24533        assert_eq!(uploads.bytes, vec![1, 2, 3, 4]);
24534        assert_eq!(uploads.copies.len(), 1);
24535    }
24536
24537    #[test]
24538    fn inner_shadow_composite_mask_uses_fill_shape_and_scale() {
24539        let mut fill = test_shape(0, BlendMode::SrcOver);
24540        fill.local_rect = Rect {
24541            x: 10.0,
24542            y: 12.0,
24543            width: 40.0,
24544            height: 20.0,
24545        };
24546        fill.shape = Some(RoundedCornerShape::uniform(6.0));
24547
24548        let cutout = test_shape(1, BlendMode::DstOut);
24549        let shadow = test_shadow_draw(vec![
24550            (fill, BlendMode::SrcOver),
24551            (cutout, BlendMode::DstOut),
24552        ]);
24553
24554        let mask = inner_shadow_composite_mask(&shadow, 1.5).expect("inner mask expected");
24555        assert_eq!(mask.rect, [15.0, 18.0, 60.0, 30.0]);
24556        assert_eq!(mask.radii, [9.0, 9.0, 9.0, 9.0]);
24557    }
24558
24559    #[test]
24560    fn inner_shadow_composite_mask_is_none_without_dst_out() {
24561        let fill = test_shape(0, BlendMode::SrcOver);
24562        let shadow = test_shadow_draw(vec![(fill, BlendMode::SrcOver)]);
24563        assert!(inner_shadow_composite_mask(&shadow, 1.0).is_none());
24564    }
24565
24566    #[test]
24567    fn render_effect_support_matrix_covers_all_variants() {
24568        let blur = RenderEffect::blur(4.0);
24569        let offset = RenderEffect::offset(2.0, 3.0);
24570        let shader = RenderEffect::runtime_shader(cranpose_ui_graphics::RuntimeShader::new(
24571            r#"
24572            @group(0) @binding(0) var input_texture: texture_2d<f32>;
24573            @group(0) @binding(1) var input_sampler: sampler;
24574            @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
24575            struct VertexOutput {
24576                @builtin(position) position: vec4<f32>,
24577                @location(0) uv: vec2<f32>,
24578            }
24579            @vertex
24580            fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
24581                var output: VertexOutput;
24582                let x = f32(i32(vertex_index & 1u) * 2 - 1);
24583                let y = f32(i32(vertex_index >> 1u) * 2 - 1);
24584                output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
24585                output.position = vec4<f32>(x, y, 0.0, 1.0);
24586                return output;
24587            }
24588            @fragment
24589            fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
24590                return textureSample(input_texture, input_sampler, input.uv);
24591            }
24592            "#,
24593        ));
24594        let chain = blur.clone().then(offset.clone());
24595
24596        assert!(is_render_effect_supported(&blur));
24597        assert!(is_render_effect_supported(&offset));
24598        assert!(is_render_effect_supported(&shader));
24599        assert!(is_render_effect_supported(&chain));
24600    }
24601
24602    #[test]
24603    fn clip_to_bounds_propagates_visual_clip_to_all_descendant_shapes() {
24604        // Simulates: root → clip_to_bounds container → child with shapes above/below clip
24605        // All shapes inside the clip_to_bounds container must have a clip set.
24606        let container_local_bounds = Rect {
24607            x: 0.0,
24608            y: 0.0,
24609            width: 800.0,
24610            height: 500.0,
24611        };
24612        // Container is placed at y=50 in parent space via transform_to_parent
24613        let container_clip_in_parent = Rect {
24614            x: 0.0,
24615            y: 50.0,
24616            width: 800.0,
24617            height: 500.0,
24618        };
24619
24620        // Shape that extends above the clip boundary (scroll content scrolled up)
24621        let shape_above = RenderNode::Primitive(PrimitiveEntry {
24622            phase: PrimitivePhase::BeforeChildren,
24623            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24624                primitive: DrawPrimitive::Rect {
24625                    rect: Rect {
24626                        x: 10.0,
24627                        y: -30.0,
24628                        width: 100.0,
24629                        height: 40.0,
24630                    },
24631                    brush: Brush::solid(Color::WHITE),
24632                    stroke: None,
24633                },
24634                clip: None,
24635            }),
24636        });
24637
24638        // Shape within the clip boundary
24639        let shape_inside = RenderNode::Primitive(PrimitiveEntry {
24640            phase: PrimitivePhase::BeforeChildren,
24641            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24642                primitive: DrawPrimitive::Rect {
24643                    rect: Rect {
24644                        x: 10.0,
24645                        y: 100.0,
24646                        width: 100.0,
24647                        height: 40.0,
24648                    },
24649                    brush: Brush::solid(Color::WHITE),
24650                    stroke: None,
24651                },
24652                clip: None,
24653            }),
24654        });
24655
24656        // Shape below the clip boundary (scroll content below viewport)
24657        let shape_below = RenderNode::Primitive(PrimitiveEntry {
24658            phase: PrimitivePhase::BeforeChildren,
24659            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24660                primitive: DrawPrimitive::Rect {
24661                    rect: Rect {
24662                        x: 10.0,
24663                        y: 600.0,
24664                        width: 100.0,
24665                        height: 40.0,
24666                    },
24667                    brush: Brush::solid(Color::WHITE),
24668                    stroke: None,
24669                },
24670                clip: None,
24671            }),
24672        });
24673
24674        // Content child layer (represents scroll content, translated up by scroll offset)
24675        let mut content_layer = test_layer(
24676            Rect {
24677                x: 0.0,
24678                y: 0.0,
24679                width: 800.0,
24680                height: 1000.0,
24681            },
24682            vec![shape_above, shape_inside, shape_below],
24683        );
24684        content_layer.transform_to_parent = ProjectiveTransform::translation(0.0, -30.0);
24685        content_layer.translated_content_context = true;
24686
24687        // Clip container (e.g. TabContent with clip_to_bounds)
24688        let mut clip_container = test_layer(
24689            container_local_bounds,
24690            vec![RenderNode::Layer(Box::new(content_layer))],
24691        );
24692        clip_container.clip_to_bounds = true;
24693        clip_container.transform_to_parent = ProjectiveTransform::translation(0.0, 50.0);
24694
24695        // Root
24696        let root = test_layer(
24697            Rect {
24698                x: 0.0,
24699                y: 0.0,
24700                width: 800.0,
24701                height: 600.0,
24702            },
24703            vec![RenderNode::Layer(Box::new(clip_container))],
24704        );
24705
24706        let mut rect_cache = HashMap::new();
24707        let mut requirements_cache = HashMap::new();
24708        let collected =
24709            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24710
24711        assert_eq!(
24712            collected.scene.shapes.len(),
24713            3,
24714            "all three shapes should be flattened into the scene"
24715        );
24716
24717        for (i, shape) in collected.scene.shapes.iter().enumerate() {
24718            assert!(
24719                shape.clip.is_some(),
24720                "shape {} at rect {:?} must have a clip from clip_to_bounds container, but clip is None",
24721                i,
24722                shape.rect
24723            );
24724            let clip = shape.clip.unwrap();
24725            assert_eq!(
24726                clip, container_clip_in_parent,
24727                "shape {} clip should match the clip_to_bounds container bounds in parent space",
24728                i
24729            );
24730        }
24731    }
24732
24733    #[test]
24734    fn clip_to_bounds_culls_child_layers_outside_boundary() {
24735        // Reproduces the out-of-clip rendering bug: a child layer with
24736        // graphics_layer.clip=true (e.g. from rounded_surface()) positioned
24737        // entirely below the parent's clip_to_bounds boundary must be culled.
24738        // Before the fix, resolve_clip returned None for non-overlapping rects,
24739        // which downstream code interpreted as "no clipping" instead of "fully clipped",
24740        // causing invisible content to render everywhere.
24741
24742        let clip_container_bounds = Rect {
24743            x: 0.0,
24744            y: 0.0,
24745            width: 800.0,
24746            height: 500.0,
24747        };
24748
24749        let shape_in_card = RenderNode::Primitive(PrimitiveEntry {
24750            phase: PrimitivePhase::BeforeChildren,
24751            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24752                primitive: DrawPrimitive::Rect {
24753                    rect: Rect {
24754                        x: 0.0,
24755                        y: 0.0,
24756                        width: 300.0,
24757                        height: 80.0,
24758                    },
24759                    brush: Brush::solid(Color::WHITE),
24760                    stroke: None,
24761                },
24762                clip: None,
24763            }),
24764        });
24765
24766        // Card layer with graphics_layer.clip=true, positioned BELOW the clip boundary
24767        let mut card_outside = crate::test_support::layer_node(
24768            Rect {
24769                x: 0.0,
24770                y: 0.0,
24771                width: 300.0,
24772                height: 80.0,
24773            },
24774            ProjectiveTransform::identity(),
24775            GraphicsLayer {
24776                clip: true,
24777                ..GraphicsLayer::default()
24778            },
24779            vec![shape_in_card.clone()],
24780        );
24781        card_outside.transform_to_parent = ProjectiveTransform::translation(10.0, 600.0);
24782
24783        // Card layer with graphics_layer.clip=true, positioned INSIDE the clip boundary
24784        let mut card_inside = crate::test_support::layer_node(
24785            Rect {
24786                x: 0.0,
24787                y: 0.0,
24788                width: 300.0,
24789                height: 80.0,
24790            },
24791            ProjectiveTransform::identity(),
24792            GraphicsLayer {
24793                clip: true,
24794                ..GraphicsLayer::default()
24795            },
24796            vec![shape_in_card],
24797        );
24798        card_inside.transform_to_parent = ProjectiveTransform::translation(10.0, 100.0);
24799
24800        // Content layer holding both cards
24801        let content = test_layer(
24802            Rect {
24803                x: 0.0,
24804                y: 0.0,
24805                width: 800.0,
24806                height: 1000.0,
24807            },
24808            vec![
24809                RenderNode::Layer(Box::new(card_inside)),
24810                RenderNode::Layer(Box::new(card_outside)),
24811            ],
24812        );
24813
24814        // Clip container
24815        let mut clip_container = test_layer(
24816            clip_container_bounds,
24817            vec![RenderNode::Layer(Box::new(content))],
24818        );
24819        clip_container.clip_to_bounds = true;
24820
24821        // Root
24822        let root = test_layer(
24823            Rect {
24824                x: 0.0,
24825                y: 0.0,
24826                width: 800.0,
24827                height: 600.0,
24828            },
24829            vec![RenderNode::Layer(Box::new(clip_container))],
24830        );
24831
24832        let mut rect_cache = HashMap::new();
24833        let mut requirements_cache = HashMap::new();
24834        let collected =
24835            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24836
24837        assert_eq!(
24838            collected.scene.shapes.len(),
24839            1,
24840            "only the card inside the clip boundary should produce shapes; \
24841             the card outside must be culled entirely"
24842        );
24843
24844        let shape = &collected.scene.shapes[0];
24845        assert!(
24846            shape.clip.is_some(),
24847            "the visible card's shape must have a clip from clip_to_bounds"
24848        );
24849    }
24850
24851    #[test]
24852    fn flattened_layer_shadow_z_index_is_below_content() {
24853        // Shadow must render behind content. When a child layer with shadow_elevation
24854        // is flattened (no isolation), its shadow z-index must be lower than any
24855        // content z-index so shadow draws render first.
24856        let shape = RenderNode::Primitive(PrimitiveEntry {
24857            phase: PrimitivePhase::BeforeChildren,
24858            node: PrimitiveNode::Draw(DrawPrimitiveNode {
24859                primitive: DrawPrimitive::Rect {
24860                    rect: Rect {
24861                        x: 0.0,
24862                        y: 0.0,
24863                        width: 100.0,
24864                        height: 100.0,
24865                    },
24866                    brush: Brush::solid(Color::WHITE),
24867                    stroke: None,
24868                },
24869                clip: None,
24870            }),
24871        });
24872
24873        let child_bounds = Rect {
24874            x: 0.0,
24875            y: 0.0,
24876            width: 100.0,
24877            height: 100.0,
24878        };
24879
24880        let child = crate::test_support::layer_node(
24881            child_bounds,
24882            ProjectiveTransform::translation(50.0, 50.0),
24883            GraphicsLayer {
24884                shadow_elevation: 20.0,
24885                ..GraphicsLayer::default()
24886            },
24887            vec![shape],
24888        );
24889
24890        let root = test_layer(
24891            Rect {
24892                x: 0.0,
24893                y: 0.0,
24894                width: 800.0,
24895                height: 600.0,
24896            },
24897            vec![RenderNode::Layer(Box::new(child))],
24898        );
24899
24900        let mut rect_cache = HashMap::new();
24901        let mut requirements_cache = HashMap::new();
24902        let collected =
24903            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
24904
24905        assert!(
24906            !collected.scene.shadow_draws.is_empty(),
24907            "shadow_elevation > 0 must produce shadow draws"
24908        );
24909        let max_shadow_z = collected
24910            .scene
24911            .shadow_draws
24912            .iter()
24913            .map(|s| s.z_index)
24914            .max()
24915            .unwrap();
24916        let min_content_z = collected
24917            .scene
24918            .shapes
24919            .iter()
24920            .map(|s| s.z_index)
24921            .min()
24922            .unwrap();
24923        assert!(
24924            max_shadow_z < min_content_z,
24925            "shadow z-index ({}) must be less than content z-index ({}); \
24926             shadows must render behind their content",
24927            max_shadow_z,
24928            min_content_z
24929        );
24930    }
24931
24932    /// One retained bundle op key with the fields the invalidation tests
24933    /// vary; the rest stay representative constants.
24934    #[cfg(not(target_arch = "wasm32"))]
24935    fn bundle_op(slot: u32, epoch: Option<u64>, first: u32, last: u32) -> RetainedBundleOpKey {
24936        RetainedBundleOpKey {
24937            slot,
24938            capture_epoch: epoch,
24939            first,
24940            last,
24941            retained_index: slot,
24942            has_mesh: false,
24943        }
24944    }
24945
24946    #[cfg(not(target_arch = "wasm32"))]
24947    fn bundle_key(ops: &[RetainedBundleOpKey]) -> RetainedBundleKey {
24948        RetainedBundleKey {
24949            depth: false,
24950            ops: ops.to_vec(),
24951        }
24952    }
24953
24954    /// The same stretch on consecutive frames reuses its bundle: one
24955    /// rebuild, then cached executes.
24956    #[cfg(not(target_arch = "wasm32"))]
24957    #[test]
24958    fn retained_bundle_cache_reuses_stable_keys() {
24959        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24960        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
24961        let key = bundle_key(&ops);
24962
24963        assert!(!cache.hit(&key), "empty cache must miss");
24964        cache.insert(key.clone(), 111);
24965        assert_eq!(cache.get(&key), Some(&111));
24966        cache.end_frame();
24967
24968        for _ in 0..3 {
24969            assert!(cache.hit(&bundle_key(&ops)), "stable key must stay cached");
24970            cache.end_frame();
24971        }
24972        assert_eq!(cache.stats(), (1, 3), "one rebuild, three cached executes");
24973    }
24974
24975    /// Recapture (epoch bump), span reorder, count change, range change and
24976    /// slot release each change the key, so a stale bundle can never satisfy
24977    /// the lookup.
24978    #[cfg(not(target_arch = "wasm32"))]
24979    #[test]
24980    fn retained_bundle_cache_invalidates_on_any_op_change() {
24981        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
24982        let variants: [Vec<RetainedBundleOpKey>; 5] = [
24983            // Recaptured slot 3: same id, bumped epoch.
24984            vec![bundle_op(3, Some(8), 0, 40), bundle_op(5, Some(9), 4, 12)],
24985            // Reordered stretch.
24986            vec![bundle_op(5, Some(9), 4, 12), bundle_op(3, Some(7), 0, 40)],
24987            // Op count changed.
24988            vec![bundle_op(3, Some(7), 0, 40)],
24989            // Draw range changed.
24990            vec![bundle_op(3, Some(7), 0, 41), bundle_op(5, Some(9), 4, 12)],
24991            // Slot 5 released: epoch gone.
24992            vec![bundle_op(3, Some(7), 0, 40), bundle_op(5, None, 4, 12)],
24993        ];
24994        for changed in variants {
24995            let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
24996            cache.insert(bundle_key(&ops), 111);
24997            cache.end_frame();
24998            assert!(
24999                !cache.hit(&RetainedBundleKey {
25000                    depth: false,
25001                    ops: changed.clone()
25002                }),
25003                "changed key {changed:?} must not reuse the stale bundle"
25004            );
25005        }
25006    }
25007
25008    /// A stretch encoded for the display-clip culled pass (depth
25009    /// attachment, depth-variant pipelines) must never satisfy the flat
25010    /// pass's lookup — and vice versa.
25011    #[cfg(not(target_arch = "wasm32"))]
25012    #[test]
25013    fn retained_bundle_cache_keys_depth_variants_apart() {
25014        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
25015        let ops = vec![bundle_op(3, Some(7), 0, 40)];
25016        cache.insert(
25017            RetainedBundleKey {
25018                depth: false,
25019                ops: ops.clone(),
25020            },
25021            111,
25022        );
25023        cache.end_frame();
25024        assert!(
25025            !cache.hit(&RetainedBundleKey { depth: true, ops }),
25026            "a flat bundle must not replay into the display-clip culled pass"
25027        );
25028    }
25029
25030    /// Entries a frame does not use are evicted at its end — bundles pin
25031    /// slot buffers, so unused ones must not accumulate — and `clear` (the
25032    /// slot-release path) empties the cache outright.
25033    #[cfg(not(target_arch = "wasm32"))]
25034    #[test]
25035    fn retained_bundle_cache_evicts_unused_entries() {
25036        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
25037        let stale = bundle_key(&[bundle_op(1, Some(1), 0, 6)]);
25038        let live = bundle_key(&[bundle_op(2, Some(2), 0, 6)]);
25039        cache.insert(stale.clone(), 1);
25040        cache.insert(live.clone(), 2);
25041        cache.end_frame();
25042
25043        assert!(cache.hit(&live));
25044        cache.end_frame();
25045
25046        assert!(
25047            !cache.hit(&stale),
25048            "entry unused for a frame must have been evicted"
25049        );
25050        assert!(cache.hit(&live), "used entry must survive eviction");
25051
25052        cache.clear();
25053        assert!(!cache.hit(&live), "clear must drop every entry");
25054    }
25055}