Skip to main content

siglus_scene_vm/render/
mod.rs

1//! WGPU renderer for Siglus stage composition.
2//!
3//! This renderer consumes a painter-ordered list of sprites and draws them
4//! in order. It supports fixed sprite effects, dual-source wipes, and a
5//! depth-backed path for 3D-transformed quads.
6
7use anyhow::{Context, Result};
8use bytemuck::{Pod, Zeroable};
9use std::collections::{HashMap, HashSet};
10use std::num::NonZeroU64;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use wgpu::util::DeviceExt;
14use winit::window::Window;
15
16use crate::assets::load_image_any;
17use crate::image_manager::{ImageHandle, ImageKey, ImageManager};
18use crate::layer::{
19    ClipRect, RenderFrame, RenderSprite, SpriteBlend, SpriteFit, SpriteSizeMode, WipeRenderPlan,
20};
21use crate::mesh3d::{MeshAsset, load_mesh_asset};
22use crate::render_math::sprite_quad_geometry_rect;
23use crate::runtime::FrameCaptureBackend;
24
25mod emote;
26mod mipmap;
27
28use crate::render_plan::*;
29
30impl Vertex {
31    // The backing buffer keeps the complete CPU-side Vertex structure, but
32    // mesh shaders consume only ten attributes. The previous 26-attribute
33    // declaration exceeded WebGPU's guaranteed MAX_VERTEX_ATTRIBUTES=16.
34    const ATTRS: [wgpu::VertexAttribute; 10] = [
35        wgpu::VertexAttribute {
36            offset: 0,
37            shader_location: 0,
38            format: wgpu::VertexFormat::Float32x3,
39        },
40        wgpu::VertexAttribute {
41            offset: 12,
42            shader_location: 1,
43            format: wgpu::VertexFormat::Float32x2,
44        },
45        wgpu::VertexAttribute {
46            offset: 28,
47            shader_location: 2,
48            format: wgpu::VertexFormat::Float32,
49        },
50        wgpu::VertexAttribute {
51            offset: 144,
52            shader_location: 3,
53            format: wgpu::VertexFormat::Float32x4,
54        },
55        wgpu::VertexAttribute {
56            offset: 160,
57            shader_location: 4,
58            format: wgpu::VertexFormat::Float32x4,
59        },
60        wgpu::VertexAttribute {
61            offset: 224,
62            shader_location: 5,
63            format: wgpu::VertexFormat::Float32x4,
64        },
65        wgpu::VertexAttribute {
66            offset: 240,
67            shader_location: 6,
68            format: wgpu::VertexFormat::Float32x4,
69        },
70        wgpu::VertexAttribute {
71            offset: 256,
72            shader_location: 7,
73            format: wgpu::VertexFormat::Float32x4,
74        },
75        wgpu::VertexAttribute {
76            offset: 288,
77            shader_location: 8,
78            format: wgpu::VertexFormat::Float32x4,
79        },
80        wgpu::VertexAttribute {
81            offset: 304,
82            shader_location: 9,
83            format: wgpu::VertexFormat::Float32x4,
84        },
85    ];
86
87    fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
88        wgpu::VertexBufferLayout {
89            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
90            step_mode: wgpu::VertexStepMode::Vertex,
91            attributes: &Self::ATTRS,
92        }
93    }
94}
95
96struct VertexSprite2d;
97
98impl VertexSprite2d {
99    const ATTRS: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![
100        0 => Float32x3,
101        1 => Float32x2,
102        2 => Float32x2,
103        3 => Float32,
104        4 => Float32x4,
105        5 => Float32x4
106    ];
107
108    fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
109        wgpu::VertexBufferLayout {
110            array_stride: std::mem::size_of::<VertexSprite2dData>() as wgpu::BufferAddress,
111            step_mode: wgpu::VertexStepMode::Vertex,
112            attributes: &Self::ATTRS,
113        }
114    }
115}
116
117#[derive(Debug)]
118pub struct Renderer {
119    pub surface: wgpu::Surface<'static>,
120    /// Kept so that a replacement surface is created from the same instance (and
121    /// therefore the same backend) as `device`. Android re-creates the
122    /// ANativeWindow on every activity stop, and a surface built from a fresh
123    /// instance may land on a different backend (GLES instead of Vulkan), which
124    /// makes `Surface::configure` fail validation against the existing device.
125    pub instance: wgpu::Instance,
126    /// The adapter `device` came from, kept so a replacement surface can be
127    /// checked against the configuration before it is applied.
128    pub adapter: wgpu::Adapter,
129    pub device: wgpu::Device,
130    pub queue: wgpu::Queue,
131    pub config: wgpu::SurfaceConfiguration,
132    /// Original Siglus `wait_display_vsync_total` state. The concrete wgpu
133    /// present mode is selected from the current surface capabilities.
134    wait_display_vsync: bool,
135    logical_width: f32,
136    logical_height: f32,
137    scale_factor: f32,
138    surface_viewport: SurfaceViewport,
139
140    pipelines: HashMap<RenderPipelineKey, wgpu::RenderPipeline>,
141    bind_group_layout: wgpu::BindGroupLayout,
142    shader: wgpu::ShaderModule,
143    pipeline_layout: wgpu::PipelineLayout,
144    wipe_bind_group_layout: wgpu::BindGroupLayout,
145    wipe_pipeline: wgpu::RenderPipeline,
146    page_wipe_bind_group_layout: wgpu::BindGroupLayout,
147    page_wipe_pipeline: wgpu::RenderPipeline,
148
149    vertex_buf: wgpu::Buffer,
150    vertex_capacity: usize,
151    vertex_sprite2d_buf: wgpu::Buffer,
152
153    // D3D9 keeps shader constants as device state instead of allocating a
154    // constant buffer per sprite.  Mirror that model with one dynamic-uniform
155    // arena per frame; each draw selects its aligned slice with a dynamic offset.
156    vs_uniform_buf: wgpu::Buffer,
157    vs_uniform_capacity: usize,
158    vs_uniform_stride: usize,
159    vs_uniform_staging: Vec<u8>,
160    zero_bone_uniform_buf: wgpu::Buffer,
161
162    // Numeric keys must not own the runtime images they identify.
163    textures: HashMap<ImageKey, GpuTexture>,
164    mipmap_generator: mipmap::MipmapGenerator,
165    external_textures: HashMap<PathBuf, GpuTexture>,
166    default_aux: GpuTexture,
167    /// shader.cfx declares the fog sampler with WRAP addressing.  Keep it
168    /// separate from the image-owned CLAMP sampler used by normal sprites.
169    fog_sampler: wgpu::Sampler,
170    /// tona3 samplers used by dynamic mesh/shadow effects.
171    mesh_sampler: wgpu::Sampler,
172    normal_sampler: wgpu::Sampler,
173    toon_sampler: wgpu::Sampler,
174    shadow_sampler: wgpu::Sampler,
175    depth: DepthTexture,
176    surface_depth: DepthTexture,
177    scene_a: RenderTargetTexture,
178    scene_b: RenderTargetTexture,
179    wipe_a: RenderTargetTexture,
180    wipe_b: RenderTargetTexture,
181    wipe_mask_cache: Option<(WipeMaskCacheKey, GpuTexture)>,
182    shadow_map: RenderTargetTexture,
183    shadow_depth: DepthTexture,
184    /// The scene/wipe targets and `depth` have the logical size (they are
185    /// 1x1 until a frame first needs them).
186    offscreen_ready: bool,
187    /// The shadow map has its size (1x1 until a shadow is first cast).
188    shadow_ready: bool,
189
190    sprite2d_verts: Vec<VertexSprite2dData>,
191    /// The frame's draw commands (see `crate::render_plan`).
192    plan: FramePlanner,
193    draw_gpu_slots: Vec<DrawGpuSlot>,
194    shared_draw_bind_groups: HashMap<DrawBindKey, Arc<wgpu::BindGroup>>,
195    draw_bind_epoch: u64,
196    debug_frame_serial: u64,
197    emote_compositor: emote::EmoteCompositor,
198}
199
200fn align_up_usize(value: usize, alignment: usize) -> usize {
201    debug_assert!(alignment > 0);
202    let rem = value % alignment;
203    if rem == 0 {
204        value
205    } else {
206        value.saturating_add(alignment - rem)
207    }
208}
209
210#[derive(Debug, Clone, Copy, Default)]
211pub struct RendererMemoryStats {
212    pub image_texture_bytes: u64,
213    pub external_texture_bytes: u64,
214    pub internal_color_target_bytes: u64,
215    pub internal_depth_target_bytes: u64,
216    pub renderer_gpu_buffer_bytes: u64,
217    pub frame_arena_capacity_bytes: usize,
218    pub image_texture_count: usize,
219    pub external_texture_count: usize,
220    pub cached_render_pipeline_count: usize,
221}
222
223#[derive(Debug, Clone)]
224pub struct RendererDebugTexture {
225    pub key: String,
226    pub kind: String,
227    pub label: String,
228    pub usage: String,
229    pub usage_count: usize,
230    pub width: u32,
231    pub height: u32,
232    pub version: u64,
233    pub rgba: Vec<u8>,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
237enum RendererDebugRenderTarget {
238    SceneA,
239    SceneB,
240    ShadowMap,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Hash)]
244enum RendererDebugTextureKey {
245    DefaultAux,
246    Image(ImageKey),
247    External(PathBuf),
248    RenderTarget(RendererDebugRenderTarget),
249}
250
251#[derive(Debug, Clone)]
252struct PendingRendererDebugTexture {
253    order: usize,
254    kind: String,
255    label: String,
256    usage: Vec<String>,
257    width: u32,
258    height: u32,
259    version: u64,
260}
261
262#[derive(Debug)]
263struct DepthTexture {
264    _tex: wgpu::Texture,
265    view: wgpu::TextureView,
266}
267
268#[derive(Debug)]
269struct GpuTexture {
270    _tex: wgpu::Texture,
271    view: wgpu::TextureView,
272    sampler: wgpu::Sampler,
273    width: u32,
274    height: u32,
275    version: u64,
276}
277
278#[derive(Debug)]
279struct RenderTargetTexture {
280    _tex: wgpu::Texture,
281    view: wgpu::TextureView,
282    sampler: wgpu::Sampler,
283    width: u32,
284    height: u32,
285    format: wgpu::TextureFormat,
286}
287
288#[derive(Debug, Clone, Copy)]
289enum InternalColorTarget {
290    SceneA,
291    SceneB,
292    WipeA,
293    WipeB,
294    ShadowMap,
295}
296
297#[derive(Debug, Clone, Copy)]
298enum DepthTarget {
299    None,
300    Main,
301    Surface,
302    Shadow,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
306enum BackdropTarget {
307    SceneA,
308    SceneB,
309}
310
311fn backdrop_to_internal(target: BackdropTarget) -> InternalColorTarget {
312    match target {
313        BackdropTarget::SceneA => InternalColorTarget::SceneA,
314        BackdropTarget::SceneB => InternalColorTarget::SceneB,
315    }
316}
317
318fn opposite_backdrop(target: BackdropTarget) -> BackdropTarget {
319    match target {
320        BackdropTarget::SceneA => BackdropTarget::SceneB,
321        BackdropTarget::SceneB => BackdropTarget::SceneA,
322    }
323}
324
325#[derive(Debug, Clone, Copy)]
326enum ColorTarget<'a> {
327    External(&'a wgpu::TextureView),
328    Internal(InternalColorTarget),
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Hash)]
332struct DrawBindKey {
333    image_id: Option<ImageKey>,
334    emote_render_id: Option<u64>,
335    mesh_texture_path: Option<PathBuf>,
336    mesh_normal_texture_path: Option<PathBuf>,
337    mesh_toon_texture_path: Option<PathBuf>,
338    mask_image_id: Option<ImageKey>,
339    tonecurve_image_id: Option<ImageKey>,
340    fog_image_id: Option<ImageKey>,
341    wipe_src_image_id: Option<ImageKey>,
342    overlay_backdrop: Option<BackdropTarget>,
343    mesh_base_sampler: bool,
344    use_bone_uniform: bool,
345}
346
347impl DrawBindKey {
348    fn from_command(cmd: &DrawCommand, overlay_backdrop: Option<BackdropTarget>) -> Self {
349        Self {
350            image_id: cmd.image_id.as_ref().map(|id| id.key()),
351            emote_render_id: cmd.emote_render_id,
352            mesh_texture_path: cmd.mesh_texture_path.clone(),
353            mesh_normal_texture_path: cmd.mesh_normal_texture_path.clone(),
354            mesh_toon_texture_path: cmd.mesh_toon_texture_path.clone(),
355            mask_image_id: cmd.mask_image_id.as_ref().map(|id| id.key()),
356            tonecurve_image_id: cmd.tonecurve_image_id.as_ref().map(|id| id.key()),
357            fog_image_id: cmd.fog_image_id.as_ref().map(|id| id.key()),
358            wipe_src_image_id: cmd.wipe_src_image_id.as_ref().map(|id| id.key()),
359            overlay_backdrop: if matches!(
360                cmd.pipeline_key.technique.special,
361                TechniqueSpecial::Overlay
362            ) {
363                overlay_backdrop
364            } else {
365                None
366            },
367            mesh_base_sampler: matches!(
368                cmd.draw_kind,
369                MeshDrawKind::StaticMesh | MeshDrawKind::SkinnedMesh | MeshDrawKind::ShadowCaster
370            ),
371            use_bone_uniform: draw_uses_bone_uniform(cmd),
372        }
373    }
374}
375
376#[derive(Debug)]
377struct DrawGpuSlot {
378    bone_uniform_buf: Option<wgpu::Buffer>,
379    bind_group: Option<Arc<wgpu::BindGroup>>,
380    bind_key: Option<DrawBindKey>,
381    bind_epoch: u64,
382}
383
384fn draw_uses_bone_uniform(cmd: &DrawCommand) -> bool {
385    cmd.bone_uniform_index.is_some()
386}
387
388#[derive(Debug, Clone, Copy, Default)]
389struct EffectGlobalValPackSemantic {
390    use_bone_uniform: bool,
391    use_shadow_tex: bool,
392    use_normal_tex: bool,
393    use_toon_tex: bool,
394}
395
396#[derive(Debug)]
397struct EffectResolvedResources<'a> {
398    base: &'a GpuTexture,
399    mask: &'a GpuTexture,
400    tone: &'a GpuTexture,
401    fog: &'a GpuTexture,
402    normal: &'a GpuTexture,
403    toon: &'a GpuTexture,
404    aux_view: &'a wgpu::TextureView,
405    aux_sampler: &'a wgpu::Sampler,
406    shadow_view: &'a wgpu::TextureView,
407    shadow_sampler: &'a wgpu::Sampler,
408    global_vals: EffectGlobalValPackSemantic,
409}
410
411fn d3d_front_face() -> wgpu::FrontFace {
412    // Tona3 enables D3DCULL_CCW, which removes counter-clockwise triangles;
413    // Direct3D's surviving/front triangles are therefore clockwise.
414    wgpu::FrontFace::Cw
415}
416
417type DebugTextureReadback = (u32, u32, u64, Vec<u8>);
418
419fn present_mode_for_wait_display_vsync(
420    wait_display_vsync: bool,
421    supported: &[wgpu::PresentMode],
422) -> wgpu::PresentMode {
423    if wait_display_vsync {
424        // D3DPRESENT_INTERVAL_ONE. Fifo is guaranteed by wgpu on every
425        // presentable surface and is the direct VSync-on equivalent.
426        return wgpu::PresentMode::Fifo;
427    }
428
429    // D3DPRESENT_INTERVAL_IMMEDIATE. Prefer the exact no-VSync mode. When a
430    // backend cannot expose it (notably some Wayland paths), AutoNoVsync's
431    // documented fallback order is effectively Immediate -> Mailbox -> Fifo;
432    // select the concrete mode ourselves so surface replacement can validate it.
433    if supported.contains(&wgpu::PresentMode::Immediate) {
434        wgpu::PresentMode::Immediate
435    } else if supported.contains(&wgpu::PresentMode::Mailbox) {
436        wgpu::PresentMode::Mailbox
437    } else {
438        wgpu::PresentMode::Fifo
439    }
440}
441
442impl Renderer {
443    /// Human-readable presentation adapter used by the VM's compatibility
444    /// variable. Platform renderers expose the same method without exposing
445    /// their native graphics object to the host.
446    pub fn adapter_name(&self) -> String {
447        self.adapter.get_info().name
448    }
449
450    pub async fn new<W>(window: W) -> Result<Self>
451    where
452        W: std::ops::Deref<Target = dyn Window> + Into<wgpu::SurfaceTarget<'static>>,
453    {
454        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
455        let backends = wgpu::Backends::GL;
456        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
457        let backends = wgpu::Backends::all();
458
459        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
460            backends,
461            ..Default::default()
462        });
463
464        let size = window.surface_size();
465        let scale_factor = window.scale_factor() as f32;
466        let surface = instance.create_surface(window).context("create_surface")?;
467        Self::new_from_instance_surface(instance, surface, size.width, size.height, scale_factor)
468            .await
469    }
470
471    #[cfg(any(target_os = "android", target_os = "ios"))]
472    pub async unsafe fn new_from_raw_handles(
473        raw_display_handle: raw_window_handle::RawDisplayHandle,
474        raw_window_handle: raw_window_handle::RawWindowHandle,
475        width: u32,
476        height: u32,
477        scale_factor: f32,
478    ) -> Result<Self> {
479        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
480            backends: wgpu::Backends::all(),
481            ..Default::default()
482        });
483        let surface = instance
484            .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle {
485                raw_display_handle,
486                raw_window_handle,
487            })
488            .context("create_surface_unsafe")?;
489        Self::new_from_instance_surface(instance, surface, width, height, scale_factor).await
490    }
491
492    /// Re-attach a new platform surface to the existing device/queue.
493    ///
494    /// Android destroys the `ANativeWindow` whenever the activity stops, so a
495    /// background/foreground round trip hands us a new window while the engine
496    /// state (VM, decoded resources) has to survive. The surface format is
497    /// fixed by the platform, so the current configuration is reused and only
498    /// the size changes; callers re-apply their logical viewport afterwards,
499    /// exactly as they do after `resize`.
500    ///
501    /// # Safety
502    ///
503    /// Both handles must be valid and identify the same display/window pair.
504    /// Their underlying native objects must remain valid until the replacement
505    /// surface is dropped. Call on a thread permitted by the windowing platform.
506    pub unsafe fn replace_surface_from_raw_handles(
507        &mut self,
508        raw_display_handle: raw_window_handle::RawDisplayHandle,
509        raw_window_handle: raw_window_handle::RawWindowHandle,
510        width: u32,
511        height: u32,
512    ) -> Result<()> {
513        let surface = unsafe {
514            self.instance
515                .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle {
516                    raw_display_handle,
517                    raw_window_handle,
518                })
519        }
520        .context("create_surface_unsafe (replace)")?;
521        // `Surface::configure` aborts the process on a validation error, so check
522        // the new surface really is usable with this device first. A surface built
523        // from a different instance (different VkInstance) is not.
524        let caps = surface.get_capabilities(&self.adapter);
525        if !caps.formats.contains(&self.config.format) {
526            anyhow::bail!(
527                "replacement surface does not support format {:?} (adapter formats: {:?})",
528                self.config.format,
529                caps.formats
530            );
531        }
532        let replacement_present_mode =
533            present_mode_for_wait_display_vsync(self.wait_display_vsync, &caps.present_modes);
534        if !caps.alpha_modes.contains(&self.config.alpha_mode) {
535            anyhow::bail!(
536                "replacement surface does not support alpha mode {:?} (adapter modes: {:?})",
537                self.config.alpha_mode,
538                caps.alpha_modes
539            );
540        }
541        if !caps.usages.contains(self.config.usage) {
542            anyhow::bail!(
543                "replacement surface does not support usage {:?} (adapter usages: {:?})",
544                self.config.usage,
545                caps.usages
546            );
547        }
548        self.config.present_mode = replacement_present_mode;
549        self.surface = surface;
550        let scale_factor = self.scale_factor;
551        self.resize_with_scale(width.max(1), height.max(1), scale_factor);
552        Ok(())
553    }
554
555    async fn new_from_instance_surface(
556        instance: wgpu::Instance,
557        surface: wgpu::Surface<'static>,
558        width: u32,
559        height: u32,
560        scale_factor: f32,
561    ) -> Result<Self> {
562        let adapter = instance
563            .request_adapter(&wgpu::RequestAdapterOptions {
564                power_preference: wgpu::PowerPreference::HighPerformance,
565                compatible_surface: Some(&surface),
566                force_fallback_adapter: false,
567            })
568            .await
569            .context("request_adapter")?;
570
571        let (device, queue) = adapter
572            .request_device(
573                &wgpu::DeviceDescriptor {
574                    label: Some("siglus-bg-device"),
575                    required_features: wgpu::Features::empty(),
576                    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
577                    required_limits: wgpu::Limits::downlevel_webgl2_defaults(),
578                    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
579                    required_limits: wgpu::Limits::default(),
580                },
581                None,
582            )
583            .await
584            .context("request_device")?;
585
586        let surface_caps = surface.get_capabilities(&adapter);
587        // The original D3D9 renderer uses A8R8G8B8/X8R8G8B8 without
588        // D3DSAMP_SRGBTEXTURE or D3DRS_SRGBWRITEENABLE.  Prefer the non-sRGB
589        // surface view so texture sampling, blending, and render-target writes
590        // operate directly on the stored 8-bit channel values.
591        let format = surface_caps
592            .formats
593            .iter()
594            .copied()
595            .find(|f| !f.is_srgb())
596            .unwrap_or(surface_caps.formats[0]);
597        if format.is_srgb() {
598            log::error!(
599                "adapter exposes no non-sRGB surface format; D3D9 byte-space blending cannot be reproduced exactly (using {format:?})"
600            );
601        }
602
603        let scale_factor = if scale_factor.is_finite() && scale_factor > 0.0 {
604            scale_factor
605        } else {
606            1.0
607        };
608        let width = width.max(1);
609        let height = height.max(1);
610        let logical_width = ((width as f32) / scale_factor).max(1.0);
611        let logical_height = ((height as f32) / scale_factor).max(1.0);
612        let alpha_mode = surface_caps
613            .alpha_modes
614            .iter()
615            .copied()
616            .find(|m| *m == wgpu::CompositeAlphaMode::Opaque)
617            .unwrap_or(surface_caps.alpha_modes[0]);
618        let wait_display_vsync = true;
619        let present_mode =
620            present_mode_for_wait_display_vsync(wait_display_vsync, &surface_caps.present_modes);
621        let config = wgpu::SurfaceConfiguration {
622            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
623            format,
624            width,
625            height,
626            present_mode,
627            alpha_mode,
628            view_formats: vec![],
629            desired_maximum_frame_latency: 2,
630        };
631        surface.configure(&device, &config);
632
633        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
634            label: Some("siglus-sprite-bgl"),
635            entries: &[
636                wgpu::BindGroupLayoutEntry {
637                    binding: 0,
638                    visibility: wgpu::ShaderStages::FRAGMENT,
639                    ty: wgpu::BindingType::Texture {
640                        multisampled: false,
641                        view_dimension: wgpu::TextureViewDimension::D2,
642                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
643                    },
644                    count: None,
645                },
646                wgpu::BindGroupLayoutEntry {
647                    binding: 1,
648                    visibility: wgpu::ShaderStages::FRAGMENT,
649                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
650                    count: None,
651                },
652                wgpu::BindGroupLayoutEntry {
653                    binding: 2,
654                    visibility: wgpu::ShaderStages::FRAGMENT,
655                    ty: wgpu::BindingType::Texture {
656                        multisampled: false,
657                        view_dimension: wgpu::TextureViewDimension::D2,
658                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
659                    },
660                    count: None,
661                },
662                wgpu::BindGroupLayoutEntry {
663                    binding: 3,
664                    visibility: wgpu::ShaderStages::FRAGMENT,
665                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
666                    count: None,
667                },
668                wgpu::BindGroupLayoutEntry {
669                    binding: 4,
670                    visibility: wgpu::ShaderStages::FRAGMENT,
671                    ty: wgpu::BindingType::Texture {
672                        multisampled: false,
673                        view_dimension: wgpu::TextureViewDimension::D2,
674                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
675                    },
676                    count: None,
677                },
678                wgpu::BindGroupLayoutEntry {
679                    binding: 5,
680                    visibility: wgpu::ShaderStages::FRAGMENT,
681                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
682                    count: None,
683                },
684                wgpu::BindGroupLayoutEntry {
685                    binding: 6,
686                    visibility: wgpu::ShaderStages::FRAGMENT,
687                    ty: wgpu::BindingType::Texture {
688                        multisampled: false,
689                        view_dimension: wgpu::TextureViewDimension::D2,
690                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
691                    },
692                    count: None,
693                },
694                wgpu::BindGroupLayoutEntry {
695                    binding: 7,
696                    visibility: wgpu::ShaderStages::FRAGMENT,
697                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
698                    count: None,
699                },
700                wgpu::BindGroupLayoutEntry {
701                    binding: 8,
702                    visibility: wgpu::ShaderStages::FRAGMENT,
703                    ty: wgpu::BindingType::Texture {
704                        multisampled: false,
705                        view_dimension: wgpu::TextureViewDimension::D2,
706                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
707                    },
708                    count: None,
709                },
710                wgpu::BindGroupLayoutEntry {
711                    binding: 9,
712                    visibility: wgpu::ShaderStages::FRAGMENT,
713                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
714                    count: None,
715                },
716                wgpu::BindGroupLayoutEntry {
717                    binding: 10,
718                    visibility: wgpu::ShaderStages::FRAGMENT,
719                    ty: wgpu::BindingType::Texture {
720                        multisampled: false,
721                        view_dimension: wgpu::TextureViewDimension::D2,
722                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
723                    },
724                    count: None,
725                },
726                wgpu::BindGroupLayoutEntry {
727                    binding: 11,
728                    visibility: wgpu::ShaderStages::FRAGMENT,
729                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
730                    count: None,
731                },
732                wgpu::BindGroupLayoutEntry {
733                    binding: 12,
734                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
735                    ty: wgpu::BindingType::Buffer {
736                        ty: wgpu::BufferBindingType::Uniform,
737                        has_dynamic_offset: true,
738                        min_binding_size: NonZeroU64::new(std::mem::size_of::<VsUniform>() as u64),
739                    },
740                    count: None,
741                },
742                wgpu::BindGroupLayoutEntry {
743                    binding: 13,
744                    visibility: wgpu::ShaderStages::VERTEX,
745                    ty: wgpu::BindingType::Buffer {
746                        ty: wgpu::BufferBindingType::Uniform,
747                        has_dynamic_offset: false,
748                        min_binding_size: None,
749                    },
750                    count: None,
751                },
752                wgpu::BindGroupLayoutEntry {
753                    binding: 14,
754                    visibility: wgpu::ShaderStages::FRAGMENT,
755                    ty: wgpu::BindingType::Texture {
756                        multisampled: false,
757                        view_dimension: wgpu::TextureViewDimension::D2,
758                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
759                    },
760                    count: None,
761                },
762                wgpu::BindGroupLayoutEntry {
763                    binding: 15,
764                    visibility: wgpu::ShaderStages::FRAGMENT,
765                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
766                    count: None,
767                },
768                wgpu::BindGroupLayoutEntry {
769                    binding: 16,
770                    visibility: wgpu::ShaderStages::FRAGMENT,
771                    ty: wgpu::BindingType::Texture {
772                        multisampled: false,
773                        view_dimension: wgpu::TextureViewDimension::D2,
774                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
775                    },
776                    count: None,
777                },
778                wgpu::BindGroupLayoutEntry {
779                    binding: 17,
780                    visibility: wgpu::ShaderStages::FRAGMENT,
781                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
782                    count: None,
783                },
784            ],
785        });
786
787        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
788        let shader_source = wgpu::ShaderSource::Wgsl(wasm_shader_source().into());
789        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
790        let shader_source = wgpu::ShaderSource::Wgsl(SHADER.into());
791        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
792            label: Some("siglus-sprite-shader"),
793            source: shader_source,
794        });
795
796        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
797            label: Some("siglus-sprite-pipeline-layout"),
798            bind_group_layouts: &[&bind_group_layout],
799            push_constant_ranges: &[],
800        });
801        let (wipe_bind_group_layout, wipe_pipeline) = create_wipe_pipeline(&device, config.format);
802        let (page_wipe_bind_group_layout, page_wipe_pipeline) =
803            create_page_wipe_pipeline(&device, config.format);
804
805        // The original engine starts each shared 2D vertex buffer at 32 vertices.
806        // Rust emits two triangles (6 vertices) instead of four indexed vertices, so
807        // 48 vertices is the equivalent eight-quad starting capacity.
808        let vertex_capacity = 48;
809        let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor {
810            label: Some("siglus-sprite-vertex-buf"),
811            size: (vertex_capacity * std::mem::size_of::<Vertex>()) as wgpu::BufferAddress,
812            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
813            mapped_at_creation: false,
814        });
815        let vertex_sprite2d_buf = device.create_buffer(&wgpu::BufferDescriptor {
816            label: Some("siglus-sprite2d-vertex-buf"),
817            size: (vertex_capacity * std::mem::size_of::<VertexSprite2dData>())
818                as wgpu::BufferAddress,
819            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
820            mapped_at_creation: false,
821        });
822
823        let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment.max(1) as usize;
824        let vs_uniform_stride = align_up_usize(std::mem::size_of::<VsUniform>(), uniform_alignment);
825        let vs_uniform_capacity = 64usize;
826        let vs_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
827            label: Some("siglus-vs-uniform-arena"),
828            size: (vs_uniform_stride * vs_uniform_capacity) as wgpu::BufferAddress,
829            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
830            mapped_at_creation: false,
831        });
832        let zero_bone_uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
833            label: Some("siglus-zero-bone-uniform"),
834            contents: bytemuck::bytes_of(&BoneUniform::zero()),
835            usage: wgpu::BufferUsages::UNIFORM,
836        });
837
838        let mipmap_generator = mipmap::MipmapGenerator::new(&device);
839        let default_aux =
840            create_solid_texture(&device, &queue, &mipmap_generator, [255, 255, 255, 255])?;
841        let fog_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
842            label: Some("siglus-cfx-fog-sampler"),
843            address_mode_u: wgpu::AddressMode::ClampToEdge,
844            address_mode_v: wgpu::AddressMode::ClampToEdge,
845            address_mode_w: wgpu::AddressMode::ClampToEdge,
846            mag_filter: wgpu::FilterMode::Linear,
847            min_filter: wgpu::FilterMode::Linear,
848            mipmap_filter: wgpu::FilterMode::Nearest,
849            lod_min_clamp: 0.0,
850            lod_max_clamp: 0.0,
851            ..Default::default()
852        });
853        let mesh_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
854            label: Some("siglus-tona3-mesh-clamp-sampler"),
855            address_mode_u: wgpu::AddressMode::ClampToEdge,
856            address_mode_v: wgpu::AddressMode::ClampToEdge,
857            address_mode_w: wgpu::AddressMode::ClampToEdge,
858            mag_filter: wgpu::FilterMode::Linear,
859            min_filter: wgpu::FilterMode::Linear,
860            mipmap_filter: wgpu::FilterMode::Linear,
861            ..Default::default()
862        });
863        let normal_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
864            label: Some("siglus-tona3-normal-clamp-sampler"),
865            address_mode_u: wgpu::AddressMode::ClampToEdge,
866            address_mode_v: wgpu::AddressMode::ClampToEdge,
867            address_mode_w: wgpu::AddressMode::ClampToEdge,
868            mag_filter: wgpu::FilterMode::Linear,
869            min_filter: wgpu::FilterMode::Linear,
870            mipmap_filter: wgpu::FilterMode::Nearest,
871            lod_min_clamp: 0.0,
872            lod_max_clamp: 0.0,
873            ..Default::default()
874        });
875        let toon_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
876            label: Some("siglus-tona3-toon-clamp-sampler"),
877            address_mode_u: wgpu::AddressMode::ClampToEdge,
878            address_mode_v: wgpu::AddressMode::ClampToEdge,
879            address_mode_w: wgpu::AddressMode::ClampToEdge,
880            mag_filter: wgpu::FilterMode::Linear,
881            min_filter: wgpu::FilterMode::Linear,
882            mipmap_filter: wgpu::FilterMode::Nearest,
883            lod_min_clamp: 0.0,
884            lod_max_clamp: 0.0,
885            ..Default::default()
886        });
887        let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
888            label: Some("siglus-tona3-shadow-point-sampler"),
889            address_mode_u: wgpu::AddressMode::ClampToEdge,
890            address_mode_v: wgpu::AddressMode::ClampToEdge,
891            address_mode_w: wgpu::AddressMode::ClampToEdge,
892            mag_filter: wgpu::FilterMode::Nearest,
893            min_filter: wgpu::FilterMode::Nearest,
894            mipmap_filter: wgpu::FilterMode::Nearest,
895            lod_min_clamp: 0.0,
896            lod_max_clamp: 0.0,
897            ..Default::default()
898        });
899        // The offscreen targets and the shadow map start as 1x1 stand-ins
900        // and get their size when first used (`ensure_offscreen_targets`,
901        // `ensure_shadow_targets`): most frames draw straight to the surface,
902        // and most games never cast a 3D shadow (at 1080p they are 66 MiB).
903        let internal_width = 1;
904        let internal_height = 1;
905        let depth = create_depth_texture(&device, internal_width, internal_height);
906        let surface_depth = create_depth_texture(&device, config.width, config.height);
907        let scene_a = create_render_target_texture(
908            &device,
909            internal_width,
910            internal_height,
911            config.format,
912            "siglus-scene-a",
913        );
914        let scene_b = create_render_target_texture(
915            &device,
916            internal_width,
917            internal_height,
918            config.format,
919            "siglus-scene-b",
920        );
921        let wipe_a = create_render_target_texture(
922            &device,
923            internal_width,
924            internal_height,
925            config.format,
926            "siglus-wipe-a",
927        );
928        let wipe_b = create_render_target_texture(
929            &device,
930            internal_width,
931            internal_height,
932            config.format,
933            "siglus-wipe-b",
934        );
935        let shadow_map =
936            create_render_target_texture(&device, 1, 1, config.format, "siglus-shadow-map");
937        let shadow_depth = create_depth_texture_with_format(
938            &device,
939            1,
940            1,
941            wgpu::TextureFormat::Depth16Unorm,
942            "siglus-shadow-depth-d16",
943        );
944
945        let surface_viewport = SurfaceViewport::full(config.width, config.height);
946        let emote_compositor = emote::EmoteCompositor::new(&device);
947        Ok(Self {
948            instance,
949            adapter,
950            surface,
951            device,
952            queue,
953            config,
954            wait_display_vsync,
955            logical_width,
956            logical_height,
957            scale_factor: scale_factor.max(1.0),
958            surface_viewport,
959            pipelines: HashMap::new(),
960            bind_group_layout,
961            shader,
962            pipeline_layout,
963            wipe_bind_group_layout,
964            wipe_pipeline,
965            page_wipe_bind_group_layout,
966            page_wipe_pipeline,
967            vertex_buf,
968            vertex_capacity,
969            vertex_sprite2d_buf,
970            vs_uniform_buf,
971            vs_uniform_capacity,
972            vs_uniform_stride,
973            vs_uniform_staging: Vec::new(),
974            zero_bone_uniform_buf,
975            textures: HashMap::new(),
976            external_textures: HashMap::new(),
977            default_aux,
978            mipmap_generator,
979            fog_sampler,
980            mesh_sampler,
981            normal_sampler,
982            toon_sampler,
983            shadow_sampler,
984            depth,
985            surface_depth,
986            scene_a,
987            scene_b,
988            wipe_a,
989            wipe_b,
990            wipe_mask_cache: None,
991            shadow_map,
992            shadow_depth,
993            offscreen_ready: false,
994            shadow_ready: false,
995            sprite2d_verts: Vec::new(),
996            plan: FramePlanner::default(),
997            draw_gpu_slots: Vec::new(),
998            shared_draw_bind_groups: HashMap::new(),
999            draw_bind_epoch: 1,
1000            debug_frame_serial: 0,
1001            emote_compositor,
1002        })
1003    }
1004
1005    /// Gives the offscreen targets their size before a frame renders
1006    /// through them; they are kept from then on.
1007    fn ensure_offscreen_targets(&mut self) {
1008        if !self.offscreen_ready {
1009            self.offscreen_ready = true;
1010            self.recreate_logical_render_targets();
1011        }
1012    }
1013
1014    /// Gives the shadow map its size before the first shadow pass.
1015    fn ensure_shadow_targets(&mut self) {
1016        if self.shadow_ready {
1017            return;
1018        }
1019        self.shadow_ready = true;
1020        self.shadow_map = create_render_target_texture(
1021            &self.device,
1022            2048,
1023            2048,
1024            self.config.format,
1025            "siglus-shadow-map",
1026        );
1027        self.shadow_depth = create_depth_texture_with_format(
1028            &self.device,
1029            2048,
1030            2048,
1031            wgpu::TextureFormat::Depth16Unorm,
1032            "siglus-shadow-depth-d16",
1033        );
1034        // Draw bind groups hold the shadow map's view.
1035        self.clear_draw_bindings();
1036    }
1037
1038    fn recreate_logical_render_targets(&mut self) {
1039        let (width, height) = if self.offscreen_ready {
1040            (
1041                self.logical_width.max(1.0).round() as u32,
1042                self.logical_height.max(1.0).round() as u32,
1043            )
1044        } else {
1045            (1, 1)
1046        };
1047        self.depth = create_depth_texture(&self.device, width, height);
1048        self.scene_a = create_render_target_texture(
1049            &self.device,
1050            width,
1051            height,
1052            self.config.format,
1053            "siglus-scene-a",
1054        );
1055        self.scene_b = create_render_target_texture(
1056            &self.device,
1057            width,
1058            height,
1059            self.config.format,
1060            "siglus-scene-b",
1061        );
1062        self.wipe_a = create_render_target_texture(
1063            &self.device,
1064            width,
1065            height,
1066            self.config.format,
1067            "siglus-wipe-a",
1068        );
1069        self.wipe_b = create_render_target_texture(
1070            &self.device,
1071            width,
1072            height,
1073            self.config.format,
1074            "siglus-wipe-b",
1075        );
1076        self.wipe_mask_cache = None;
1077        self.clear_draw_bindings();
1078    }
1079
1080    /// Match `tnm_set_wait_display_vsync()` from the original engine.
1081    ///
1082    /// The script-side `SET_VSYNC_WAIT_OFF_FLAG` only changes local engine
1083    /// state; `eng_frame.cpp` applies the resulting total state once per frame.
1084    /// Reconfigure the surface only when that desired state changes, matching
1085    /// the original change-on-difference behavior.
1086    pub fn set_wait_display_vsync(&mut self, wait_display_vsync: bool) {
1087        if self.wait_display_vsync == wait_display_vsync {
1088            return;
1089        }
1090        self.wait_display_vsync = wait_display_vsync;
1091
1092        let caps = self.surface.get_capabilities(&self.adapter);
1093        let present_mode =
1094            present_mode_for_wait_display_vsync(wait_display_vsync, &caps.present_modes);
1095        if !wait_display_vsync && present_mode != wgpu::PresentMode::Immediate {
1096            if present_mode == wgpu::PresentMode::Fifo {
1097                log::warn!(
1098                    "VSync-off requested by Siglus script, but this surface exposes neither Immediate nor Mailbox; falling back to Fifo"
1099                );
1100            } else {
1101                log::warn!(
1102                    "VSync-off requested by Siglus script, but Immediate is unavailable; using {:?}",
1103                    present_mode
1104                );
1105            }
1106        }
1107
1108        if self.config.present_mode == present_mode {
1109            return;
1110        }
1111        self.config.present_mode = present_mode;
1112        self.surface.configure(&self.device, &self.config);
1113    }
1114
1115    pub fn scale_factor(&self) -> f32 {
1116        self.scale_factor
1117    }
1118
1119    pub fn surface_viewport(&self) -> (u32, u32, u32, u32) {
1120        (
1121            self.surface_viewport.x,
1122            self.surface_viewport.y,
1123            self.surface_viewport.w,
1124            self.surface_viewport.h,
1125        )
1126    }
1127
1128    pub fn resize(&mut self, width: u32, height: u32) {
1129        self.resize_with_scale(width, height, self.scale_factor);
1130    }
1131
1132    pub fn resize_with_scale(&mut self, width: u32, height: u32, scale_factor: f32) {
1133        let sf = Self::valid_scale_factor(scale_factor);
1134        self.resize_targets(width, height, sf, width as f32 / sf, height as f32 / sf);
1135        self.surface_viewport = SurfaceViewport::full(self.config.width, self.config.height);
1136    }
1137
1138    fn valid_scale_factor(scale_factor: f32) -> f32 {
1139        if scale_factor.is_finite() && scale_factor > 0.0 {
1140            scale_factor
1141        } else {
1142            1.0
1143        }
1144    }
1145
1146    fn resize_targets(
1147        &mut self,
1148        width: u32,
1149        height: u32,
1150        scale_factor: f32,
1151        logical_width: f32,
1152        logical_height: f32,
1153    ) {
1154        if width == 0 || height == 0 {
1155            return;
1156        }
1157        let surface_changed = self.config.width != width || self.config.height != height;
1158        let previous_logical_size = self.logical_size();
1159        self.scale_factor = Self::valid_scale_factor(scale_factor);
1160        self.logical_width = logical_width.max(1.0);
1161        self.logical_height = logical_height.max(1.0);
1162        self.config.width = width;
1163        self.config.height = height;
1164        // Keep explicit reconfiguration for surface-loss recovery at the same size.
1165        self.surface.configure(&self.device, &self.config);
1166        if surface_changed {
1167            self.surface_depth = create_depth_texture(&self.device, width, height);
1168        }
1169        if previous_logical_size != self.logical_size() {
1170            self.recreate_logical_render_targets();
1171        }
1172    }
1173
1174    pub fn resize_with_logical_viewport(
1175        &mut self,
1176        surface_width: u32,
1177        surface_height: u32,
1178        scale_factor: f32,
1179        logical_width: u32,
1180        logical_height: u32,
1181        viewport_x: u32,
1182        viewport_y: u32,
1183        viewport_width: u32,
1184        viewport_height: u32,
1185    ) {
1186        self.resize_targets(
1187            surface_width,
1188            surface_height,
1189            scale_factor,
1190            logical_width.max(1) as f32,
1191            logical_height.max(1) as f32,
1192        );
1193        let max_w = self.config.width;
1194        let max_h = self.config.height;
1195        let x = viewport_x.min(max_w.saturating_sub(1));
1196        let y = viewport_y.min(max_h.saturating_sub(1));
1197        let w = viewport_width.max(1).min(max_w.saturating_sub(x).max(1));
1198        let h = viewport_height.max(1).min(max_h.saturating_sub(y).max(1));
1199        self.surface_viewport = SurfaceViewport { x, y, w, h };
1200    }
1201
1202    pub fn logical_size(&self) -> (u32, u32) {
1203        (
1204            self.logical_width.max(1.0).round() as u32,
1205            self.logical_height.max(1.0).round() as u32,
1206        )
1207    }
1208
1209    pub fn render_sprites(
1210        &mut self,
1211        images: &ImageManager,
1212        sprites: &[RenderSprite],
1213    ) -> Result<()> {
1214        self.render_frame(images, &RenderFrame::ordinary(sprites.to_vec()))
1215    }
1216
1217    pub fn render_frame(&mut self, images: &ImageManager, frame_plan: &RenderFrame) -> Result<()> {
1218        let frame = match self.surface.get_current_texture() {
1219            Ok(frame) => frame,
1220            Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
1221                // Android hands the app a new ANativeWindow whenever the activity
1222                // stops, and a reconfigured surface can report Lost/Outdated for a
1223                // frame. Recover in place: failing here would surface as an error
1224                // from `SiglusHost::step`, which the Android frame loop treats as
1225                // "exit" and would freeze the picture.
1226                self.surface.configure(&self.device, &self.config);
1227                return Ok(());
1228            }
1229            Err(wgpu::SurfaceError::OutOfMemory) => {
1230                anyhow::bail!("surface out of memory");
1231            }
1232            Err(wgpu::SurfaceError::Timeout) => return Ok(()),
1233            Err(err) => return Err(err).context("get_current_texture"),
1234        };
1235        let view = frame
1236            .texture
1237            .create_view(&wgpu::TextureViewDescriptor::default());
1238        self.debug_frame_serial = self.debug_frame_serial.wrapping_add(1);
1239        {
1240            let mut live_emote_ids = HashSet::new();
1241            let mut collect = |sprites: &[RenderSprite]| {
1242                for entry in sprites {
1243                    if let Some(packet) = entry.sprite.emote_render.as_deref() {
1244                        live_emote_ids.insert(packet.render_id);
1245                    }
1246                }
1247            };
1248            if let Some(wipe) = frame_plan.wipe.as_ref() {
1249                collect(&wipe.under);
1250                collect(&wipe.current);
1251                collect(&wipe.next);
1252                collect(&wipe.over);
1253            } else {
1254                collect(&frame_plan.sprites);
1255            }
1256            self.emote_compositor.retain_render_ids(&live_emote_ids);
1257        }
1258
1259        // The original engine draws an ordinary frame directly into the final
1260        // opaque back buffer.  A game/offscreen buffer is only used when a
1261        // feature actually needs to sample the already rendered scene (WIPE,
1262        // OVERLAY, capture, ...).  Routing every frame through an
1263        // alpha-bearing texture changes SRCALPHA/INVSRCALPHA accumulation and
1264        // makes translucent objects and glyphs darken against the intermediate
1265        // black surface before presentation.
1266        let needs_scene_texture = frame_plan.wipe.is_some()
1267            || frame_plan
1268                .sprites
1269                .iter()
1270                .any(|entry| matches!(entry.sprite.blend, SpriteBlend::Overlay));
1271
1272        if needs_scene_texture {
1273            let final_target = self.render_frame_to_internal(images, frame_plan)?;
1274            let blit_range = self.prepare_blit_vertices()?;
1275            let mut encoder = self
1276                .device
1277                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1278                    label: Some("siglus-present-encoder"),
1279                });
1280            self.render_copy_pass(
1281                &mut encoder,
1282                ColorTarget::External(&view),
1283                final_target,
1284                blit_range,
1285            )?;
1286            self.submit(encoder);
1287        } else {
1288            self.render_ordinary_frame_to_surface(images, &frame_plan.sprites, &view)?;
1289        }
1290
1291        frame.present();
1292        Ok(())
1293    }
1294
1295    fn render_ordinary_frame_to_surface(
1296        &mut self,
1297        images: &ImageManager,
1298        sprites: &[RenderSprite],
1299        view: &wgpu::TextureView,
1300    ) -> Result<()> {
1301        let _ = self.prepare_draws(images, sprites, true)?;
1302        let draw_count = self.plan.draws.len();
1303        let mut encoder = self
1304            .device
1305            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1306                label: Some("siglus-direct-present-encoder"),
1307            });
1308
1309        let shadow_indices: Vec<usize> = self
1310            .plan
1311            .draws
1312            .iter()
1313            .enumerate()
1314            .filter_map(|(idx, cmd)| cmd.shadow_cast.then_some(idx))
1315            .collect();
1316        if !shadow_indices.is_empty() {
1317            self.ensure_shadow_targets();
1318            self.render_command_slice(
1319                &mut encoder,
1320                ColorTarget::Internal(InternalColorTarget::ShadowMap),
1321                DepthTarget::Shadow,
1322                0..0,
1323                wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1324                true,
1325                None,
1326                None,
1327            )?;
1328            for idx in shadow_indices {
1329                self.render_command_slice(
1330                    &mut encoder,
1331                    ColorTarget::Internal(InternalColorTarget::ShadowMap),
1332                    DepthTarget::Shadow,
1333                    idx..idx + 1,
1334                    wgpu::LoadOp::Load,
1335                    false,
1336                    None,
1337                    Some(TechniqueSpecial::Shadow),
1338                )?;
1339            }
1340        }
1341
1342        self.render_command_slice(
1343            &mut encoder,
1344            ColorTarget::External(view),
1345            DepthTarget::Surface,
1346            0..draw_count,
1347            wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1348            true,
1349            None,
1350            None,
1351        )?;
1352        self.submit(encoder);
1353        Ok(())
1354    }
1355
1356    fn prepare_draws(
1357        &mut self,
1358        images: &ImageManager,
1359        sprites: &[RenderSprite],
1360        external_target: bool,
1361    ) -> Result<std::ops::Range<u32>> {
1362        self.plan.verts.clear();
1363        self.plan.draws.clear();
1364        self.plan.draw_bone_uniforms.clear();
1365
1366        let win_w = self.logical_width.max(1.0);
1367        let win_h = self.logical_height.max(1.0);
1368        let (surface_w, surface_h, surface_viewport) = if external_target {
1369            (self.config.width, self.config.height, self.surface_viewport)
1370        } else {
1371            let width = self.logical_width.max(1.0).round() as u32;
1372            let height = self.logical_height.max(1.0).round() as u32;
1373            (width, height, SurfaceViewport::full(width, height))
1374        };
1375
1376        for s in sprites {
1377            if let Some(packet) = s.sprite.emote_render.as_deref() {
1378                self.emote_compositor
1379                    .prepare(&self.device, &self.queue, packet)?;
1380            }
1381        }
1382        self.plan.plan(
1383            images,
1384            sprites,
1385            &PlanTarget {
1386                win_w,
1387                win_h,
1388                surface_w,
1389                surface_h,
1390                surface_viewport,
1391            },
1392        )?;
1393
1394        let blit_range = append_fullscreen_blit_vertices(&mut self.plan.verts);
1395
1396        self.upload_prepared_vertices()?;
1397        self.upload_draw_uniforms();
1398
1399        let mut live_image_ids = HashSet::new();
1400        for cmd in &self.plan.draws {
1401            if let Some(ref id) = cmd.image_id {
1402                live_image_ids.insert(id.key());
1403            }
1404            if let Some(ref id) = cmd.mask_image_id {
1405                live_image_ids.insert(id.key());
1406            }
1407            if let Some(ref id) = cmd.tonecurve_image_id {
1408                live_image_ids.insert(id.key());
1409            }
1410            if let Some(ref id) = cmd.fog_image_id {
1411                live_image_ids.insert(id.key());
1412            }
1413            if let Some(ref id) = cmd.wipe_src_image_id {
1414                live_image_ids.insert(id.key());
1415            }
1416        }
1417        for id in live_image_ids {
1418            self.ensure_texture_uploaded(images, id)?;
1419        }
1420        self.organize_textures(images);
1421
1422        self.ensure_draw_pipelines();
1423        self.ensure_pipeline(sprite2d_copy_render_pipeline_key(), "siglus-sprite2d-copy");
1424
1425        Ok(blit_range)
1426    }
1427
1428    fn prepare_blit_vertices(&mut self) -> Result<std::ops::Range<u32>> {
1429        self.plan.verts.clear();
1430        self.plan.draws.clear();
1431        let range = append_fullscreen_blit_vertices(&mut self.plan.verts);
1432        self.upload_prepared_vertices()?;
1433        self.ensure_pipeline(sprite2d_copy_render_pipeline_key(), "siglus-sprite2d-copy");
1434        Ok(range)
1435    }
1436
1437    fn render_frame_to_internal(
1438        &mut self,
1439        images: &ImageManager,
1440        frame: &RenderFrame,
1441    ) -> Result<BackdropTarget> {
1442        self.ensure_offscreen_targets();
1443        if let Some(wipe) = frame.wipe.as_ref() {
1444            let current = self.render_sprite_list_to_scene_pair(images, &wipe.current, None)?;
1445            self.copy_internal_target(backdrop_to_internal(current), InternalColorTarget::WipeA);
1446            let next = self.render_sprite_list_to_scene_pair(images, &wipe.next, None)?;
1447            self.copy_internal_target(backdrop_to_internal(next), InternalColorTarget::WipeB);
1448            let under = self.render_sprite_list_to_scene_pair(images, &wipe.under, None)?;
1449            let composed = self.render_wipe_composite(images, wipe, under)?;
1450            self.render_sprite_list_to_scene_pair(images, &wipe.over, Some(composed))
1451        } else {
1452            self.render_sprite_list_to_scene_pair(images, &frame.sprites, None)
1453        }
1454    }
1455
1456    fn render_sprite_list_to_scene_pair(
1457        &mut self,
1458        images: &ImageManager,
1459        sprites: &[RenderSprite],
1460        initial: Option<BackdropTarget>,
1461    ) -> Result<BackdropTarget> {
1462        let blit_range = self.prepare_draws(images, sprites, false)?;
1463        let draw_count = self.plan.draws.len();
1464        let mut encoder = self
1465            .device
1466            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1467                label: Some("siglus-offscreen-scene-encoder"),
1468            });
1469
1470        let shadow_indices: Vec<usize> = self
1471            .plan
1472            .draws
1473            .iter()
1474            .enumerate()
1475            .filter_map(|(idx, cmd)| cmd.shadow_cast.then_some(idx))
1476            .collect();
1477        if !shadow_indices.is_empty() {
1478            self.ensure_shadow_targets();
1479            self.render_command_slice(
1480                &mut encoder,
1481                ColorTarget::Internal(InternalColorTarget::ShadowMap),
1482                DepthTarget::Shadow,
1483                0..0,
1484                wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1485                true,
1486                None,
1487                None,
1488            )?;
1489            for idx in shadow_indices {
1490                self.render_command_slice(
1491                    &mut encoder,
1492                    ColorTarget::Internal(InternalColorTarget::ShadowMap),
1493                    DepthTarget::Shadow,
1494                    idx..idx + 1,
1495                    wgpu::LoadOp::Load,
1496                    false,
1497                    None,
1498                    Some(TechniqueSpecial::Shadow),
1499                )?;
1500            }
1501        }
1502
1503        let mut current = initial.unwrap_or(BackdropTarget::SceneA);
1504        let initial_color_load = if initial.is_some() {
1505            wgpu::LoadOp::Load
1506        } else {
1507            wgpu::LoadOp::Clear(wgpu::Color::BLACK)
1508        };
1509        self.render_command_slice(
1510            &mut encoder,
1511            ColorTarget::Internal(backdrop_to_internal(current)),
1512            DepthTarget::Main,
1513            0..0,
1514            initial_color_load,
1515            true,
1516            None,
1517            None,
1518        )?;
1519
1520        let mut index = 0usize;
1521        while index < draw_count {
1522            let is_overlay = matches!(
1523                self.plan.draws[index].pipeline_key.technique.special,
1524                TechniqueSpecial::Overlay
1525            );
1526            let start = index;
1527            while index < draw_count
1528                && matches!(
1529                    self.plan.draws[index].pipeline_key.technique.special,
1530                    TechniqueSpecial::Overlay
1531                ) == is_overlay
1532            {
1533                index += 1;
1534            }
1535            if is_overlay {
1536                let dst = opposite_backdrop(current);
1537                self.render_copy_pass(
1538                    &mut encoder,
1539                    ColorTarget::Internal(backdrop_to_internal(dst)),
1540                    current,
1541                    blit_range.clone(),
1542                )?;
1543                self.render_command_slice(
1544                    &mut encoder,
1545                    ColorTarget::Internal(backdrop_to_internal(dst)),
1546                    DepthTarget::Main,
1547                    start..index,
1548                    wgpu::LoadOp::Load,
1549                    false,
1550                    Some(current),
1551                    None,
1552                )?;
1553                current = dst;
1554            } else {
1555                self.render_command_slice(
1556                    &mut encoder,
1557                    ColorTarget::Internal(backdrop_to_internal(current)),
1558                    DepthTarget::Main,
1559                    start..index,
1560                    wgpu::LoadOp::Load,
1561                    false,
1562                    None,
1563                    None,
1564                )?;
1565            }
1566        }
1567        self.submit(encoder);
1568        Ok(current)
1569    }
1570
1571    fn copy_internal_target(&self, src: InternalColorTarget, dst: InternalColorTarget) {
1572        let src_tex = self.internal_target_ref(src);
1573        let dst_tex = self.internal_target_ref(dst);
1574        let width = src_tex.width.min(dst_tex.width);
1575        let height = src_tex.height.min(dst_tex.height);
1576        let mut encoder = self
1577            .device
1578            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1579                label: Some("siglus-copy-internal-target"),
1580            });
1581        encoder.copy_texture_to_texture(
1582            wgpu::ImageCopyTexture {
1583                texture: &src_tex._tex,
1584                mip_level: 0,
1585                origin: wgpu::Origin3d::ZERO,
1586                aspect: wgpu::TextureAspect::All,
1587            },
1588            wgpu::ImageCopyTexture {
1589                texture: &dst_tex._tex,
1590                mip_level: 0,
1591                origin: wgpu::Origin3d::ZERO,
1592                aspect: wgpu::TextureAspect::All,
1593            },
1594            wgpu::Extent3d {
1595                width,
1596                height,
1597                depth_or_array_layers: 1,
1598            },
1599        );
1600        self.submit(encoder);
1601    }
1602
1603    fn ensure_generated_wipe_mask(&mut self, wipe: &WipeRenderPlan) -> Result<()> {
1604        let width = self.logical_width.max(1.0).round() as u32;
1605        let height = self.logical_height.max(1.0).round() as u32;
1606        let key = WipeMaskCacheKey {
1607            wipe_type: wipe.wipe_type,
1608            option: wipe.option.clone(),
1609            width,
1610            height,
1611            seed: wipe.random_seed,
1612        };
1613        if self
1614            .wipe_mask_cache
1615            .as_ref()
1616            .is_some_and(|(cached, _)| cached == &key)
1617        {
1618            return Ok(());
1619        }
1620        let Some(image) = generated_wipe_mask(wipe, width, height) else {
1621            self.wipe_mask_cache = None;
1622            return Ok(());
1623        };
1624        let texture = create_gpu_texture(
1625            &self.device,
1626            &self.queue,
1627            &self.mipmap_generator,
1628            "siglus-generated-wipe-mask",
1629            &image,
1630            wipe.random_seed as u64,
1631        )?;
1632        self.wipe_mask_cache = Some((key, texture));
1633        Ok(())
1634    }
1635
1636    fn render_wipe_composite(
1637        &mut self,
1638        images: &ImageManager,
1639        wipe: &WipeRenderPlan,
1640        under: BackdropTarget,
1641    ) -> Result<BackdropTarget> {
1642        if matches!(wipe.wipe_type, 300 | 301) {
1643            return self.render_page_wipe(wipe, under);
1644        }
1645
1646        if let Some(ref id) = wipe.mask_image_id {
1647            self.ensure_texture_uploaded(images, id.key())?;
1648        } else {
1649            self.ensure_generated_wipe_mask(wipe)?;
1650        }
1651
1652        let uniform = wipe_uniform(
1653            wipe,
1654            self.logical_width.max(1.0),
1655            self.logical_height.max(1.0),
1656        );
1657        let uniform_buffer = self
1658            .device
1659            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1660                label: Some("siglus-wipe-uniform"),
1661                contents: bytemuck::bytes_of(&uniform),
1662                usage: wgpu::BufferUsages::UNIFORM,
1663            });
1664
1665        let under_texture = self.backdrop_target_ref(under);
1666        let current_texture = &self.wipe_a;
1667        let next_texture = &self.wipe_b;
1668        let external_mask = wipe
1669            .mask_image_id
1670            .as_ref()
1671            .and_then(|id| self.textures.get(&id.key()));
1672        let generated_mask = self.wipe_mask_cache.as_ref().map(|(_, texture)| texture);
1673        let mask_texture = external_mask
1674            .or(generated_mask)
1675            .unwrap_or(&self.default_aux);
1676        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1677            label: Some("siglus-wipe-bind-group"),
1678            layout: &self.wipe_bind_group_layout,
1679            entries: &[
1680                wgpu::BindGroupEntry {
1681                    binding: 0,
1682                    resource: wgpu::BindingResource::TextureView(&under_texture.view),
1683                },
1684                wgpu::BindGroupEntry {
1685                    binding: 1,
1686                    resource: wgpu::BindingResource::Sampler(&under_texture.sampler),
1687                },
1688                wgpu::BindGroupEntry {
1689                    binding: 2,
1690                    resource: wgpu::BindingResource::TextureView(&current_texture.view),
1691                },
1692                wgpu::BindGroupEntry {
1693                    binding: 3,
1694                    resource: wgpu::BindingResource::Sampler(&current_texture.sampler),
1695                },
1696                wgpu::BindGroupEntry {
1697                    binding: 4,
1698                    resource: wgpu::BindingResource::TextureView(&next_texture.view),
1699                },
1700                wgpu::BindGroupEntry {
1701                    binding: 5,
1702                    resource: wgpu::BindingResource::Sampler(&next_texture.sampler),
1703                },
1704                wgpu::BindGroupEntry {
1705                    binding: 6,
1706                    resource: wgpu::BindingResource::TextureView(&mask_texture.view),
1707                },
1708                wgpu::BindGroupEntry {
1709                    binding: 7,
1710                    resource: wgpu::BindingResource::Sampler(&mask_texture.sampler),
1711                },
1712                wgpu::BindGroupEntry {
1713                    binding: 8,
1714                    resource: uniform_buffer.as_entire_binding(),
1715                },
1716            ],
1717        });
1718
1719        let target = opposite_backdrop(under);
1720        let target_view = &self.backdrop_target_ref(target).view;
1721        let viewport = SurfaceViewport::full(
1722            self.logical_width.max(1.0).round() as u32,
1723            self.logical_height.max(1.0).round() as u32,
1724        );
1725        let mut encoder = self
1726            .device
1727            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1728                label: Some("siglus-wipe-composite-encoder"),
1729            });
1730        {
1731            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1732                label: Some("siglus-wipe-composite-pass"),
1733                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1734                    view: target_view,
1735                    resolve_target: None,
1736                    ops: wgpu::Operations {
1737                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1738                        store: wgpu::StoreOp::Store,
1739                    },
1740                })],
1741                depth_stencil_attachment: None,
1742                timestamp_writes: None,
1743                occlusion_query_set: None,
1744            });
1745            pass.set_viewport(
1746                viewport.x as f32,
1747                viewport.y as f32,
1748                viewport.w as f32,
1749                viewport.h as f32,
1750                0.0,
1751                1.0,
1752            );
1753            pass.set_pipeline(&self.wipe_pipeline);
1754            pass.set_bind_group(0, &bind_group, &[]);
1755            pass.draw(0..3, 0..1);
1756        }
1757        self.submit(encoder);
1758        Ok(target)
1759    }
1760
1761    fn render_page_wipe(
1762        &mut self,
1763        wipe: &WipeRenderPlan,
1764        under: BackdropTarget,
1765    ) -> Result<BackdropTarget> {
1766        // Page wipes use the same GPU source render targets and preserve the
1767        // original perspective/culling branch.  The page geometry itself is
1768        // emitted as regular 3D sprite quads, never rasterized on the CPU.
1769        let target = opposite_backdrop(under);
1770        self.copy_internal_target(backdrop_to_internal(under), backdrop_to_internal(target));
1771        self.render_page_wipe_geometry(wipe, target)?;
1772        Ok(target)
1773    }
1774
1775    fn render_page_wipe_geometry(
1776        &mut self,
1777        wipe: &WipeRenderPlan,
1778        target: BackdropTarget,
1779    ) -> Result<()> {
1780        let draws = build_page_wipe_draws(
1781            wipe,
1782            self.logical_width.max(1.0),
1783            self.logical_height.max(1.0),
1784        );
1785        if draws.is_empty() {
1786            return Ok(());
1787        }
1788
1789        let mut buffers = Vec::with_capacity(draws.len());
1790        let mut bind_groups = Vec::with_capacity(draws.len());
1791        for draw in &draws {
1792            let buffer = self
1793                .device
1794                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1795                    label: Some("siglus-page-wipe-vertex-buffer"),
1796                    contents: bytemuck::cast_slice(&draw.vertices),
1797                    usage: wgpu::BufferUsages::VERTEX,
1798                });
1799            let texture = if draw.use_current {
1800                &self.wipe_a
1801            } else {
1802                &self.wipe_b
1803            };
1804            let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1805                label: Some("siglus-page-wipe-bind-group"),
1806                layout: &self.page_wipe_bind_group_layout,
1807                entries: &[
1808                    wgpu::BindGroupEntry {
1809                        binding: 0,
1810                        resource: wgpu::BindingResource::TextureView(&texture.view),
1811                    },
1812                    wgpu::BindGroupEntry {
1813                        binding: 1,
1814                        resource: wgpu::BindingResource::Sampler(&texture.sampler),
1815                    },
1816                ],
1817            });
1818            buffers.push(buffer);
1819            bind_groups.push(bind_group);
1820        }
1821
1822        let target_view = &self.backdrop_target_ref(target).view;
1823        let viewport = SurfaceViewport::full(
1824            self.logical_width.max(1.0).round() as u32,
1825            self.logical_height.max(1.0).round() as u32,
1826        );
1827        let mut encoder = self
1828            .device
1829            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1830                label: Some("siglus-page-wipe-encoder"),
1831            });
1832        {
1833            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1834                label: Some("siglus-page-wipe-pass"),
1835                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1836                    view: target_view,
1837                    resolve_target: None,
1838                    ops: wgpu::Operations {
1839                        load: wgpu::LoadOp::Load,
1840                        store: wgpu::StoreOp::Store,
1841                    },
1842                })],
1843                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1844                    view: &self.depth.view,
1845                    depth_ops: Some(wgpu::Operations {
1846                        load: wgpu::LoadOp::Clear(1.0),
1847                        store: wgpu::StoreOp::Store,
1848                    }),
1849                    stencil_ops: None,
1850                }),
1851                timestamp_writes: None,
1852                occlusion_query_set: None,
1853            });
1854            pass.set_viewport(
1855                viewport.x as f32,
1856                viewport.y as f32,
1857                viewport.w as f32,
1858                viewport.h as f32,
1859                0.0,
1860                1.0,
1861            );
1862            pass.set_pipeline(&self.page_wipe_pipeline);
1863            for (index, draw) in draws.iter().enumerate() {
1864                pass.set_bind_group(0, &bind_groups[index], &[]);
1865                pass.set_vertex_buffer(0, buffers[index].slice(..));
1866                pass.draw(0..draw.vertices.len() as u32, 0..1);
1867            }
1868        }
1869        self.submit(encoder);
1870        Ok(())
1871    }
1872
1873    fn debug_collect_render_chain_textures(
1874        &self,
1875    ) -> HashMap<RendererDebugTextureKey, PendingRendererDebugTexture> {
1876        let mut pending: HashMap<RendererDebugTextureKey, PendingRendererDebugTexture> =
1877            HashMap::new();
1878
1879        for (draw_idx, cmd) in self.plan.draws.iter().enumerate() {
1880            let role_prefix = format!("draw[{draw_idx}]");
1881            self.debug_add_base_texture_usage(&mut pending, cmd, &format!("{role_prefix}.base"));
1882            self.debug_add_image_texture_usage(
1883                &mut pending,
1884                cmd.mask_image_id.as_ref(),
1885                "image",
1886                &format!("{role_prefix}.mask"),
1887            );
1888            self.debug_add_image_texture_usage(
1889                &mut pending,
1890                cmd.tonecurve_image_id.as_ref(),
1891                "image",
1892                &format!("{role_prefix}.tonecurve"),
1893            );
1894            self.debug_add_image_texture_usage(
1895                &mut pending,
1896                cmd.fog_image_id.as_ref(),
1897                "image",
1898                &format!("{role_prefix}.fog"),
1899            );
1900            self.debug_add_aux_texture_usage(&mut pending, cmd, &format!("{role_prefix}.aux"));
1901            self.debug_add_external_texture_usage(
1902                &mut pending,
1903                cmd.mesh_normal_texture_path.as_deref(),
1904                "external",
1905                &format!("{role_prefix}.normal"),
1906            );
1907            self.debug_add_external_texture_usage(
1908                &mut pending,
1909                cmd.mesh_toon_texture_path.as_deref(),
1910                "external",
1911                &format!("{role_prefix}.toon"),
1912            );
1913            if cmd.pipeline_key.use_depth
1914                || cmd.shadow_cast
1915                || cmd.mesh_material_key.as_ref().is_some_and(|k| k.shadow)
1916            {
1917                self.debug_add_render_target_usage(
1918                    &mut pending,
1919                    RendererDebugRenderTarget::ShadowMap,
1920                    &format!("{role_prefix}.shadow"),
1921                );
1922            }
1923        }
1924
1925        if self.plan.draws.iter().any(|cmd| {
1926            matches!(
1927                cmd.pipeline_key.technique.special,
1928                TechniqueSpecial::Overlay
1929            )
1930        }) {
1931            self.debug_add_render_target_usage(
1932                &mut pending,
1933                RendererDebugRenderTarget::SceneA,
1934                "overlay.backdrop.scene_a",
1935            );
1936            self.debug_add_render_target_usage(
1937                &mut pending,
1938                RendererDebugRenderTarget::SceneB,
1939                "overlay.backdrop.scene_b",
1940            );
1941        }
1942        if self.plan.draws.is_empty() {
1943            self.debug_add_default_aux_usage(&mut pending, "empty-frame.default_aux");
1944        }
1945
1946        pending
1947    }
1948
1949    /// Metadata-only view of the current render-chain textures.  This is safe to
1950    /// call every HUD frame: it never submits GPU work, maps a buffer or waits on
1951    /// the device.  rgba is intentionally empty; F3 requests an explicit snapshot.
1952    pub fn debug_render_chain_texture_metadata(&self) -> Vec<RendererDebugTexture> {
1953        let pending = self.debug_collect_render_chain_textures();
1954        let mut items = Vec::with_capacity(pending.len());
1955        for (key, meta) in pending.into_iter() {
1956            items.push((
1957                meta.order,
1958                RendererDebugTexture {
1959                    key: Self::debug_texture_key_string(&key),
1960                    kind: meta.kind,
1961                    label: meta.label,
1962                    usage: meta.usage.join("; "),
1963                    usage_count: meta.usage.len(),
1964                    width: meta.width,
1965                    height: meta.height,
1966                    version: meta.version,
1967                    rgba: Vec::new(),
1968                },
1969            ));
1970        }
1971        items.sort_by_key(|(order, _)| *order);
1972        items.into_iter().map(|(_, item)| item).collect()
1973    }
1974
1975    /// Explicit debug snapshot used only on user request.  Unlike the metadata
1976    /// path above, this performs GPU->CPU copies and waits for completion.
1977    pub fn debug_read_render_chain_textures(&self) -> Result<Vec<RendererDebugTexture>> {
1978        let pending = self.debug_collect_render_chain_textures();
1979        let mut items = Vec::with_capacity(pending.len());
1980        for (key, meta) in pending.into_iter() {
1981            let Some((width, height, version, rgba)) = self.debug_read_texture_by_key(&key)? else {
1982                continue;
1983            };
1984            items.push((
1985                meta.order,
1986                RendererDebugTexture {
1987                    key: Self::debug_texture_key_string(&key),
1988                    kind: meta.kind,
1989                    label: meta.label,
1990                    usage: meta.usage.join("; "),
1991                    usage_count: meta.usage.len(),
1992                    width,
1993                    height,
1994                    version,
1995                    rgba,
1996                },
1997            ));
1998        }
1999        items.sort_by_key(|(order, _)| *order);
2000        Ok(items.into_iter().map(|(_, item)| item).collect())
2001    }
2002
2003    fn debug_add_pending_texture_usage(
2004        &self,
2005        pending: &mut HashMap<RendererDebugTextureKey, PendingRendererDebugTexture>,
2006        key: RendererDebugTextureKey,
2007        kind: &str,
2008        label: String,
2009        width: u32,
2010        height: u32,
2011        version: u64,
2012        usage: &str,
2013    ) {
2014        let order = pending.len();
2015        let entry = pending
2016            .entry(key)
2017            .or_insert_with(|| PendingRendererDebugTexture {
2018                order,
2019                kind: kind.to_string(),
2020                label,
2021                usage: Vec::new(),
2022                width,
2023                height,
2024                version,
2025            });
2026        if !entry.usage.iter().any(|s| s == usage) {
2027            entry.usage.push(usage.to_string());
2028        }
2029    }
2030
2031    fn debug_add_default_aux_usage(
2032        &self,
2033        pending: &mut HashMap<RendererDebugTextureKey, PendingRendererDebugTexture>,
2034        usage: &str,
2035    ) {
2036        self.debug_add_pending_texture_usage(
2037            pending,
2038            RendererDebugTextureKey::DefaultAux,
2039            "default",
2040            "default_aux".to_string(),
2041            self.default_aux.width,
2042            self.default_aux.height,
2043            self.default_aux.version,
2044            usage,
2045        );
2046    }
2047
2048    fn debug_add_image_texture_usage(
2049        &self,
2050        pending: &mut HashMap<RendererDebugTextureKey, PendingRendererDebugTexture>,
2051        image_id: Option<&ImageHandle>,
2052        kind: &str,
2053        usage: &str,
2054    ) {
2055        if let Some(id) = image_id
2056            && let Some(tex) = self.textures.get(&id.key())
2057        {
2058            self.debug_add_pending_texture_usage(
2059                pending,
2060                RendererDebugTextureKey::Image(id.key()),
2061                kind,
2062                format!("ImageHandle({})", id.index()),
2063                tex.width,
2064                tex.height,
2065                tex.version,
2066                usage,
2067            );
2068            return;
2069        }
2070        self.debug_add_default_aux_usage(pending, usage);
2071    }
2072
2073    fn debug_add_external_texture_usage(
2074        &self,
2075        pending: &mut HashMap<RendererDebugTextureKey, PendingRendererDebugTexture>,
2076        path: Option<&Path>,
2077        kind: &str,
2078        usage: &str,
2079    ) {
2080        if let Some(path) = path
2081            && let Some(tex) = self.external_textures.get(path)
2082        {
2083            self.debug_add_pending_texture_usage(
2084                pending,
2085                RendererDebugTextureKey::External(path.to_path_buf()),
2086                kind,
2087                path.display().to_string(),
2088                tex.width,
2089                tex.height,
2090                tex.version,
2091                usage,
2092            );
2093            return;
2094        }
2095        self.debug_add_default_aux_usage(pending, usage);
2096    }
2097
2098    fn debug_add_render_target_usage(
2099        &self,
2100        pending: &mut HashMap<RendererDebugTextureKey, PendingRendererDebugTexture>,
2101        target: RendererDebugRenderTarget,
2102        usage: &str,
2103    ) {
2104        let rt = self.debug_render_target_ref(target);
2105        self.debug_add_pending_texture_usage(
2106            pending,
2107            RendererDebugTextureKey::RenderTarget(target),
2108            "render-target",
2109            match target {
2110                RendererDebugRenderTarget::SceneA => "scene_a".to_string(),
2111                RendererDebugRenderTarget::SceneB => "scene_b".to_string(),
2112                RendererDebugRenderTarget::ShadowMap => "shadow_map".to_string(),
2113            },
2114            rt.width,
2115            rt.height,
2116            self.debug_frame_serial,
2117            usage,
2118        );
2119    }
2120
2121    fn debug_add_base_texture_usage(
2122        &self,
2123        pending: &mut HashMap<RendererDebugTextureKey, PendingRendererDebugTexture>,
2124        cmd: &DrawCommand,
2125        usage: &str,
2126    ) {
2127        if let Some(path) = cmd.mesh_texture_path.as_deref()
2128            && let Some(tex) = self.external_textures.get(path)
2129        {
2130            self.debug_add_pending_texture_usage(
2131                pending,
2132                RendererDebugTextureKey::External(path.to_path_buf()),
2133                "external",
2134                path.display().to_string(),
2135                tex.width,
2136                tex.height,
2137                tex.version,
2138                usage,
2139            );
2140            return;
2141        }
2142        self.debug_add_image_texture_usage(pending, cmd.image_id.as_ref(), "image", usage);
2143    }
2144
2145    fn debug_add_aux_texture_usage(
2146        &self,
2147        pending: &mut HashMap<RendererDebugTextureKey, PendingRendererDebugTexture>,
2148        cmd: &DrawCommand,
2149        usage: &str,
2150    ) {
2151        if matches!(
2152            cmd.pipeline_key.technique.special,
2153            TechniqueSpecial::Overlay
2154        ) {
2155            self.debug_add_render_target_usage(pending, RendererDebugRenderTarget::SceneA, usage);
2156            self.debug_add_render_target_usage(pending, RendererDebugRenderTarget::SceneB, usage);
2157            return;
2158        }
2159        self.debug_add_image_texture_usage(pending, cmd.wipe_src_image_id.as_ref(), "image", usage);
2160    }
2161
2162    fn debug_render_target_ref(&self, target: RendererDebugRenderTarget) -> &RenderTargetTexture {
2163        match target {
2164            RendererDebugRenderTarget::SceneA => &self.scene_a,
2165            RendererDebugRenderTarget::SceneB => &self.scene_b,
2166            RendererDebugRenderTarget::ShadowMap => &self.shadow_map,
2167        }
2168    }
2169
2170    fn debug_texture_key_string(key: &RendererDebugTextureKey) -> String {
2171        match key {
2172            RendererDebugTextureKey::DefaultAux => "default_aux".to_string(),
2173            RendererDebugTextureKey::Image(id) => format!("image:{id}"),
2174            RendererDebugTextureKey::External(path) => format!("external:{}", path.display()),
2175            RendererDebugTextureKey::RenderTarget(RendererDebugRenderTarget::SceneA) => {
2176                "render-target:scene_a".to_string()
2177            }
2178            RendererDebugTextureKey::RenderTarget(RendererDebugRenderTarget::SceneB) => {
2179                "render-target:scene_b".to_string()
2180            }
2181            RendererDebugTextureKey::RenderTarget(RendererDebugRenderTarget::ShadowMap) => {
2182                "render-target:shadow_map".to_string()
2183            }
2184        }
2185    }
2186
2187    fn debug_read_texture_by_key(
2188        &self,
2189        key: &RendererDebugTextureKey,
2190    ) -> Result<Option<DebugTextureReadback>> {
2191        match key {
2192            RendererDebugTextureKey::DefaultAux => Ok(Some((
2193                self.default_aux.width,
2194                self.default_aux.height,
2195                self.default_aux.version,
2196                self.debug_read_texture_rgba(
2197                    &self.default_aux._tex,
2198                    self.default_aux.width,
2199                    self.default_aux.height,
2200                    wgpu::TextureFormat::Rgba8Unorm,
2201                )?,
2202            ))),
2203            RendererDebugTextureKey::Image(id) => {
2204                let Some(tex) = self.textures.get(id) else {
2205                    return Ok(None);
2206                };
2207                Ok(Some((
2208                    tex.width,
2209                    tex.height,
2210                    tex.version,
2211                    self.debug_read_texture_rgba(
2212                        &tex._tex,
2213                        tex.width,
2214                        tex.height,
2215                        wgpu::TextureFormat::Rgba8Unorm,
2216                    )?,
2217                )))
2218            }
2219            RendererDebugTextureKey::External(path) => {
2220                let Some(tex) = self.external_textures.get(path) else {
2221                    return Ok(None);
2222                };
2223                Ok(Some((
2224                    tex.width,
2225                    tex.height,
2226                    tex.version,
2227                    self.debug_read_texture_rgba(
2228                        &tex._tex,
2229                        tex.width,
2230                        tex.height,
2231                        wgpu::TextureFormat::Rgba8Unorm,
2232                    )?,
2233                )))
2234            }
2235            RendererDebugTextureKey::RenderTarget(target) => {
2236                let rt = self.debug_render_target_ref(*target);
2237                Ok(Some((
2238                    rt.width,
2239                    rt.height,
2240                    self.debug_frame_serial,
2241                    self.debug_read_texture_rgba(&rt._tex, rt.width, rt.height, rt.format)?,
2242                )))
2243            }
2244        }
2245    }
2246
2247    fn debug_read_texture_rgba(
2248        &self,
2249        texture: &wgpu::Texture,
2250        width: u32,
2251        height: u32,
2252        format: wgpu::TextureFormat,
2253    ) -> Result<Vec<u8>> {
2254        if width == 0 || height == 0 {
2255            return Ok(Vec::new());
2256        }
2257        let bytes_per_pixel = 4u32;
2258        let unpadded_bytes_per_row = width.saturating_mul(bytes_per_pixel);
2259        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
2260        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
2261        let output_buffer_size = padded_bytes_per_row as u64 * height as u64;
2262        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
2263            label: Some("siglus-debug-texture-readback"),
2264            size: output_buffer_size,
2265            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2266            mapped_at_creation: false,
2267        });
2268        let mut encoder = self
2269            .device
2270            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2271                label: Some("siglus-debug-texture-readback-encoder"),
2272            });
2273        encoder.copy_texture_to_buffer(
2274            wgpu::ImageCopyTexture {
2275                texture,
2276                mip_level: 0,
2277                origin: wgpu::Origin3d::ZERO,
2278                aspect: wgpu::TextureAspect::All,
2279            },
2280            wgpu::ImageCopyBuffer {
2281                buffer: &output_buffer,
2282                layout: wgpu::ImageDataLayout {
2283                    offset: 0,
2284                    bytes_per_row: Some(padded_bytes_per_row),
2285                    rows_per_image: Some(height),
2286                },
2287            },
2288            wgpu::Extent3d {
2289                width,
2290                height,
2291                depth_or_array_layers: 1,
2292            },
2293        );
2294        self.submit(encoder);
2295
2296        let buffer_slice = output_buffer.slice(..);
2297        let (tx, rx) = std::sync::mpsc::channel();
2298        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
2299            let _ = tx.send(result);
2300        });
2301        self.device.poll(wgpu::Maintain::Wait);
2302        rx.recv()
2303            .context("wait for debug texture readback")?
2304            .context("map debug texture readback")?;
2305        let data = buffer_slice.get_mapped_range();
2306        let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
2307        for y in 0..height as usize {
2308            let src_offset = y * padded_bytes_per_row as usize;
2309            let dst_offset = y * unpadded_bytes_per_row as usize;
2310            let src = &data[src_offset..src_offset + unpadded_bytes_per_row as usize];
2311            let dst = &mut rgba[dst_offset..dst_offset + unpadded_bytes_per_row as usize];
2312            match format {
2313                wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
2314                    for (src_px, dst_px) in src
2315                        .as_chunks::<4>()
2316                        .0
2317                        .iter()
2318                        .zip(dst.as_chunks_mut::<4>().0.iter_mut())
2319                    {
2320                        dst_px[0] = src_px[2];
2321                        dst_px[1] = src_px[1];
2322                        dst_px[2] = src_px[0];
2323                        dst_px[3] = src_px[3];
2324                    }
2325                }
2326                wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => {
2327                    dst.copy_from_slice(src);
2328                }
2329                other => {
2330                    anyhow::bail!("unsupported debug texture readback format: {other:?}");
2331                }
2332            }
2333        }
2334        drop(data);
2335        output_buffer.unmap();
2336        Ok(rgba)
2337    }
2338
2339    fn ensure_external_texture(&mut self, path: &Path) -> Option<()> {
2340        if self.external_textures.contains_key(path) {
2341            return Some(());
2342        }
2343        let img = load_image_any(path, 0).ok()?;
2344        let tex = create_gpu_texture(
2345            &self.device,
2346            &self.queue,
2347            &self.mipmap_generator,
2348            &format!("siglus-external-texture-{}", self.external_textures.len()),
2349            &img,
2350            0,
2351        )
2352        .ok()?;
2353        self.external_textures.insert(path.to_path_buf(), tex);
2354        Some(())
2355    }
2356
2357    fn ensure_draw_pipelines(&mut self) {
2358        for draw_idx in 0..self.plan.draws.len() {
2359            let (pipeline_key, pipeline_label, shadow) = {
2360                let cmd = &self.plan.draws[draw_idx];
2361                let pipeline_key = cmd.pipeline_key.render_pipeline_key();
2362                let pipeline_label = (!self.pipelines.contains_key(&pipeline_key))
2363                    .then(|| format!("siglus-{}", technique_name_for_pipeline(&cmd.pipeline_key)));
2364                let shadow = cmd.shadow_cast.then(|| {
2365                    let key = cmd.pipeline_key.shadow_render_pipeline_key();
2366                    let label = (!self.pipelines.contains_key(&key)).then(|| {
2367                        cmd.shadow_pipeline_name.as_deref().map_or_else(
2368                            || format!("siglus-{}", key.program.short_name()),
2369                            |name| format!("siglus-{name}#{}", key.program.short_name()),
2370                        )
2371                    });
2372                    (key, label)
2373                });
2374                (pipeline_key, pipeline_label, shadow)
2375            };
2376
2377            if let Some(label) = pipeline_label {
2378                self.ensure_pipeline(pipeline_key, &label);
2379            }
2380            if let Some((shadow_key, Some(label))) = shadow {
2381                self.ensure_pipeline(shadow_key, &label);
2382            }
2383        }
2384    }
2385
2386    fn ensure_pipeline(&mut self, key: RenderPipelineKey, label: &str) {
2387        if self.pipelines.contains_key(&key) {
2388            return;
2389        }
2390        let blend_state = if let Some(blend) = key.blend {
2391            Some(match blend {
2392                SpriteBlend::Normal => wgpu::BlendState {
2393                    color: wgpu::BlendComponent {
2394                        src_factor: wgpu::BlendFactor::SrcAlpha,
2395                        dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
2396                        operation: wgpu::BlendOperation::Add,
2397                    },
2398                    alpha: wgpu::BlendComponent {
2399                        src_factor: wgpu::BlendFactor::One,
2400                        dst_factor: wgpu::BlendFactor::One,
2401                        operation: wgpu::BlendOperation::Add,
2402                    },
2403                },
2404                SpriteBlend::Add => wgpu::BlendState {
2405                    color: wgpu::BlendComponent {
2406                        src_factor: wgpu::BlendFactor::SrcAlpha,
2407                        dst_factor: wgpu::BlendFactor::One,
2408                        operation: wgpu::BlendOperation::Add,
2409                    },
2410                    alpha: wgpu::BlendComponent {
2411                        src_factor: wgpu::BlendFactor::One,
2412                        dst_factor: wgpu::BlendFactor::One,
2413                        operation: wgpu::BlendOperation::Add,
2414                    },
2415                },
2416                SpriteBlend::Sub => wgpu::BlendState {
2417                    color: wgpu::BlendComponent {
2418                        src_factor: wgpu::BlendFactor::SrcAlpha,
2419                        dst_factor: wgpu::BlendFactor::One,
2420                        operation: wgpu::BlendOperation::ReverseSubtract,
2421                    },
2422                    alpha: wgpu::BlendComponent {
2423                        src_factor: wgpu::BlendFactor::One,
2424                        dst_factor: wgpu::BlendFactor::One,
2425                        operation: wgpu::BlendOperation::Add,
2426                    },
2427                },
2428                SpriteBlend::Mul => wgpu::BlendState {
2429                    color: wgpu::BlendComponent {
2430                        src_factor: wgpu::BlendFactor::Zero,
2431                        dst_factor: wgpu::BlendFactor::Src,
2432                        operation: wgpu::BlendOperation::Add,
2433                    },
2434                    alpha: wgpu::BlendComponent {
2435                        src_factor: wgpu::BlendFactor::One,
2436                        dst_factor: wgpu::BlendFactor::One,
2437                        operation: wgpu::BlendOperation::Add,
2438                    },
2439                },
2440                SpriteBlend::Screen => wgpu::BlendState {
2441                    color: wgpu::BlendComponent {
2442                        src_factor: wgpu::BlendFactor::One,
2443                        dst_factor: wgpu::BlendFactor::OneMinusSrc,
2444                        operation: wgpu::BlendOperation::Add,
2445                    },
2446                    alpha: wgpu::BlendComponent {
2447                        src_factor: wgpu::BlendFactor::One,
2448                        dst_factor: wgpu::BlendFactor::One,
2449                        operation: wgpu::BlendOperation::Add,
2450                    },
2451                },
2452                SpriteBlend::Overlay => wgpu::BlendState {
2453                    color: wgpu::BlendComponent {
2454                        src_factor: wgpu::BlendFactor::One,
2455                        dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
2456                        operation: wgpu::BlendOperation::Add,
2457                    },
2458                    alpha: wgpu::BlendComponent::OVER,
2459                },
2460            })
2461        } else {
2462            None
2463        };
2464
2465        let pipeline = self
2466            .device
2467            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2468                label: Some(label),
2469                layout: Some(&self.pipeline_layout),
2470                vertex: wgpu::VertexState {
2471                    module: &self.shader,
2472                    entry_point: key.program.vertex_entry(),
2473                    buffers: &[if key.program.uses_sprite2d_layout() {
2474                        VertexSprite2d::layout()
2475                    } else {
2476                        Vertex::layout()
2477                    }],
2478                    compilation_options: Default::default(),
2479                },
2480                fragment: Some(wgpu::FragmentState {
2481                    module: &self.shader,
2482                    entry_point: key.program.fragment_entry(),
2483                    targets: &[Some(wgpu::ColorTargetState {
2484                        format: self.config.format,
2485                        blend: blend_state,
2486                        write_mask: wgpu::ColorWrites::ALL,
2487                    })],
2488                    compilation_options: Default::default(),
2489                }),
2490                primitive: wgpu::PrimitiveState {
2491                    topology: wgpu::PrimitiveTopology::TriangleList,
2492                    strip_index_format: None,
2493                    front_face: d3d_front_face(),
2494                    cull_mode: if key.cull_back {
2495                        Some(wgpu::Face::Back)
2496                    } else {
2497                        None
2498                    },
2499                    polygon_mode: wgpu::PolygonMode::Fill,
2500                    unclipped_depth: false,
2501                    conservative: false,
2502                },
2503                depth_stencil: key.depth.map(|depth| wgpu::DepthStencilState {
2504                    format: if matches!(
2505                        key.program,
2506                        EffectProgram::ShadowStatic | EffectProgram::ShadowSkinned
2507                    ) {
2508                        wgpu::TextureFormat::Depth16Unorm
2509                    } else {
2510                        wgpu::TextureFormat::Depth32Float
2511                    },
2512                    depth_write_enabled: depth.depth_write,
2513                    depth_compare: if depth.use_depth {
2514                        wgpu::CompareFunction::LessEqual
2515                    } else {
2516                        wgpu::CompareFunction::Always
2517                    },
2518                    stencil: wgpu::StencilState::default(),
2519                    bias: wgpu::DepthBiasState::default(),
2520                }),
2521                multisample: wgpu::MultisampleState::default(),
2522                multiview: None,
2523            });
2524        self.pipelines.insert(key, pipeline);
2525    }
2526
2527    fn internal_target_ref(&self, target: InternalColorTarget) -> &RenderTargetTexture {
2528        match target {
2529            InternalColorTarget::SceneA => &self.scene_a,
2530            InternalColorTarget::SceneB => &self.scene_b,
2531            InternalColorTarget::WipeA => &self.wipe_a,
2532            InternalColorTarget::WipeB => &self.wipe_b,
2533            InternalColorTarget::ShadowMap => &self.shadow_map,
2534        }
2535    }
2536
2537    fn color_target_view<'a>(&'a self, target: ColorTarget<'a>) -> &'a wgpu::TextureView {
2538        match target {
2539            ColorTarget::External(view) => view,
2540            ColorTarget::Internal(InternalColorTarget::SceneA) => &self.scene_a.view,
2541            ColorTarget::Internal(InternalColorTarget::SceneB) => &self.scene_b.view,
2542            ColorTarget::Internal(InternalColorTarget::WipeA) => &self.wipe_a.view,
2543            ColorTarget::Internal(InternalColorTarget::WipeB) => &self.wipe_b.view,
2544            ColorTarget::Internal(InternalColorTarget::ShadowMap) => &self.shadow_map.view,
2545        }
2546    }
2547
2548    fn depth_target_view(&self, target: DepthTarget) -> Option<&wgpu::TextureView> {
2549        match target {
2550            DepthTarget::None => None,
2551            DepthTarget::Main => Some(&self.depth.view),
2552            DepthTarget::Surface => Some(&self.surface_depth.view),
2553            DepthTarget::Shadow => Some(&self.shadow_depth.view),
2554        }
2555    }
2556
2557    fn backdrop_target_ref(&self, target: BackdropTarget) -> &RenderTargetTexture {
2558        match target {
2559            BackdropTarget::SceneA => &self.scene_a,
2560            BackdropTarget::SceneB => &self.scene_b,
2561        }
2562    }
2563
2564    fn render_command_slice(
2565        &mut self,
2566        encoder: &mut wgpu::CommandEncoder,
2567        color_target: ColorTarget<'_>,
2568        depth_target: DepthTarget,
2569        range: std::ops::Range<usize>,
2570        color_load: wgpu::LoadOp<wgpu::Color>,
2571        clear_depth: bool,
2572        overlay_backdrop: Option<BackdropTarget>,
2573        force_special: Option<TechniqueSpecial>,
2574    ) -> Result<()> {
2575        // D3D9 keeps its constant buffers/device state alive across draw calls.
2576        // Creating two wgpu buffers and a bind group for every sprite on every
2577        // frame made CPU cost scale catastrophically with scene complexity.
2578        // Resolve external resources first, then update persistent per-draw slots.
2579        let external_paths: Vec<PathBuf> = range
2580            .clone()
2581            .flat_map(|idx| {
2582                let cmd = &self.plan.draws[idx];
2583                [
2584                    cmd.mesh_texture_path.clone(),
2585                    cmd.mesh_normal_texture_path.clone(),
2586                    cmd.mesh_toon_texture_path.clone(),
2587                ]
2588                .into_iter()
2589                .flatten()
2590            })
2591            .collect();
2592        for path in external_paths {
2593            let _ = self.ensure_external_texture(&path);
2594        }
2595        for draw_idx in range.clone() {
2596            self.prepare_draw_gpu_slot(draw_idx, overlay_backdrop)?;
2597        }
2598
2599        let viewport = match color_target {
2600            ColorTarget::External(_) => self.surface_viewport,
2601            ColorTarget::Internal(InternalColorTarget::SceneA)
2602            | ColorTarget::Internal(InternalColorTarget::SceneB)
2603            | ColorTarget::Internal(InternalColorTarget::WipeA)
2604            | ColorTarget::Internal(InternalColorTarget::WipeB) => SurfaceViewport::full(
2605                self.logical_width.max(1.0).round() as u32,
2606                self.logical_height.max(1.0).round() as u32,
2607            ),
2608            ColorTarget::Internal(InternalColorTarget::ShadowMap) => {
2609                SurfaceViewport::full(self.shadow_map.width, self.shadow_map.height)
2610            }
2611        };
2612        let color_view = self.color_target_view(color_target);
2613        let depth_view = self.depth_target_view(depth_target);
2614        let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2615            label: Some("siglus-sprite-pass"),
2616            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2617                view: color_view,
2618                resolve_target: None,
2619                ops: wgpu::Operations {
2620                    load: color_load,
2621                    store: wgpu::StoreOp::Store,
2622                },
2623            })],
2624            depth_stencil_attachment: depth_view.map(|view| {
2625                wgpu::RenderPassDepthStencilAttachment {
2626                    view,
2627                    depth_ops: Some(wgpu::Operations {
2628                        load: if clear_depth {
2629                            wgpu::LoadOp::Clear(1.0)
2630                        } else {
2631                            wgpu::LoadOp::Load
2632                        },
2633                        store: wgpu::StoreOp::Store,
2634                    }),
2635                    stencil_ops: None,
2636                }
2637            }),
2638            timestamp_writes: None,
2639            occlusion_query_set: None,
2640        });
2641        rp.set_vertex_buffer(0, self.vertex_buf.slice(..));
2642        rp.set_viewport(
2643            viewport.x as f32,
2644            viewport.y as f32,
2645            viewport.w as f32,
2646            viewport.h as f32,
2647            0.0,
2648            1.0,
2649        );
2650
2651        for draw_idx in range {
2652            let cmd = &self.plan.draws[draw_idx];
2653            let effective_key = if force_special.is_some() {
2654                cmd.pipeline_key.shadow_render_pipeline_key()
2655            } else {
2656                cmd.pipeline_key.render_pipeline_key()
2657            };
2658            if let Some(pipeline) = self.pipelines.get(&effective_key) {
2659                rp.set_pipeline(pipeline);
2660            }
2661            if effective_key.program.uses_sprite2d_layout() {
2662                rp.set_vertex_buffer(0, self.vertex_sprite2d_buf.slice(..));
2663            } else {
2664                rp.set_vertex_buffer(0, self.vertex_buf.slice(..));
2665            }
2666            let bind_group = self.draw_gpu_slots[draw_idx]
2667                .bind_group
2668                .as_ref()
2669                .expect("draw gpu slot prepared before render pass");
2670            let dynamic_offset = draw_idx
2671                .checked_mul(self.vs_uniform_stride)
2672                .and_then(|offset| u32::try_from(offset).ok())
2673                .expect("draw uniform dynamic offset exceeds wgpu u32 range");
2674            rp.set_bind_group(0, bind_group.as_ref(), &[dynamic_offset]);
2675            if let Some(sci) = cmd.scissor {
2676                rp.set_scissor_rect(sci.x, sci.y, sci.w, sci.h);
2677            } else {
2678                rp.set_scissor_rect(viewport.x, viewport.y, viewport.w, viewport.h);
2679            }
2680            rp.draw(cmd.range.clone(), 0..1);
2681        }
2682        Ok(())
2683    }
2684
2685    fn resolve_effect_resources_for_draw<'a>(
2686        &'a self,
2687        cmd: &'a DrawCommand,
2688        overlay_backdrop: Option<&'a RenderTargetTexture>,
2689    ) -> EffectResolvedResources<'a> {
2690        let emote_base = cmd
2691            .emote_render_id
2692            .and_then(|id| self.emote_compositor.texture(id));
2693        let base = if let Some(texture) = emote_base {
2694            texture
2695        } else if let Some(path) = cmd.mesh_texture_path.as_deref() {
2696            self.external_textures
2697                .get(path)
2698                .or_else(|| {
2699                    cmd.image_id
2700                        .as_ref()
2701                        .and_then(|id| self.textures.get(&id.key()))
2702                })
2703                .unwrap_or(&self.default_aux)
2704        } else {
2705            cmd.image_id
2706                .as_ref()
2707                .and_then(|id| self.textures.get(&id.key()))
2708                .unwrap_or(&self.default_aux)
2709        };
2710        let mask = cmd
2711            .mask_image_id
2712            .as_ref()
2713            .and_then(|id| self.textures.get(&id.key()))
2714            .unwrap_or(&self.default_aux);
2715        let tone = cmd
2716            .tonecurve_image_id
2717            .as_ref()
2718            .and_then(|id| self.textures.get(&id.key()))
2719            .unwrap_or(&self.default_aux);
2720        let fog = cmd
2721            .fog_image_id
2722            .as_ref()
2723            .and_then(|id| self.textures.get(&id.key()))
2724            .unwrap_or(&self.default_aux);
2725        let normal = cmd
2726            .mesh_normal_texture_path
2727            .as_deref()
2728            .and_then(|p| self.external_textures.get(p))
2729            .unwrap_or(&self.default_aux);
2730        let toon = cmd
2731            .mesh_toon_texture_path
2732            .as_deref()
2733            .and_then(|p| self.external_textures.get(p))
2734            .unwrap_or(&self.default_aux);
2735        let (aux_view, aux_sampler) = if matches!(
2736            cmd.pipeline_key.technique.special,
2737            TechniqueSpecial::Overlay
2738        ) {
2739            if let Some(backdrop) = overlay_backdrop {
2740                (&backdrop.view, &backdrop.sampler)
2741            } else {
2742                (&self.default_aux.view, &self.default_aux.sampler)
2743            }
2744        } else if let Some(ref id) = cmd.wipe_src_image_id {
2745            if let Some(tex) = self.textures.get(&id.key()) {
2746                (&tex.view, &tex.sampler)
2747            } else {
2748                (&self.default_aux.view, &self.default_aux.sampler)
2749            }
2750        } else {
2751            (&self.default_aux.view, &self.default_aux.sampler)
2752        };
2753        let global_vals = EffectGlobalValPackSemantic {
2754            use_bone_uniform: matches!(
2755                cmd.draw_kind,
2756                MeshDrawKind::SkinnedMesh | MeshDrawKind::ShadowCaster
2757            ) && cmd.mesh_material_key.as_ref().is_some_and(|k| k.skinned),
2758            use_shadow_tex: cmd.pipeline_key.use_depth
2759                || cmd.shadow_cast
2760                || cmd.mesh_material_key.as_ref().is_some_and(|k| k.shadow),
2761            use_normal_tex: cmd
2762                .mesh_material_key
2763                .as_ref()
2764                .is_some_and(|k| k.use_normal_tex),
2765            use_toon_tex: cmd
2766                .mesh_material_key
2767                .as_ref()
2768                .is_some_and(|k| k.use_toon_tex),
2769        };
2770        EffectResolvedResources {
2771            base,
2772            mask,
2773            tone,
2774            fog,
2775            normal,
2776            toon,
2777            aux_view,
2778            aux_sampler,
2779            shadow_view: &self.shadow_map.view,
2780            shadow_sampler: &self.shadow_sampler,
2781            global_vals,
2782        }
2783    }
2784
2785    fn render_copy_pass(
2786        &self,
2787        encoder: &mut wgpu::CommandEncoder,
2788        color_target: ColorTarget<'_>,
2789        src: BackdropTarget,
2790        blit_range: std::ops::Range<u32>,
2791    ) -> Result<()> {
2792        let color_view = self.color_target_view(color_target);
2793        let src = self.backdrop_target_ref(src);
2794        let key = sprite2d_copy_render_pipeline_key();
2795        let target_is_external = matches!(color_target, ColorTarget::External(_));
2796        let uniform_width = if target_is_external {
2797            self.config.width as f32
2798        } else {
2799            self.logical_width.max(1.0)
2800        };
2801        let uniform_height = if target_is_external {
2802            self.config.height as f32
2803        } else {
2804            self.logical_height.max(1.0)
2805        };
2806        let vs_uniform = plain_sprite2d_uniform(uniform_width, uniform_height);
2807        self.queue
2808            .write_buffer(&self.vs_uniform_buf, 0, bytemuck::bytes_of(&vs_uniform));
2809        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2810            label: Some("siglus-copy-bg"),
2811            layout: &self.bind_group_layout,
2812            entries: &[
2813                wgpu::BindGroupEntry {
2814                    binding: 0,
2815                    resource: wgpu::BindingResource::TextureView(&src.view),
2816                },
2817                wgpu::BindGroupEntry {
2818                    binding: 1,
2819                    resource: wgpu::BindingResource::Sampler(&src.sampler),
2820                },
2821                wgpu::BindGroupEntry {
2822                    binding: 2,
2823                    resource: wgpu::BindingResource::TextureView(&self.default_aux.view),
2824                },
2825                wgpu::BindGroupEntry {
2826                    binding: 3,
2827                    resource: wgpu::BindingResource::Sampler(&self.default_aux.sampler),
2828                },
2829                wgpu::BindGroupEntry {
2830                    binding: 4,
2831                    resource: wgpu::BindingResource::TextureView(&self.default_aux.view),
2832                },
2833                wgpu::BindGroupEntry {
2834                    binding: 5,
2835                    resource: wgpu::BindingResource::Sampler(&self.default_aux.sampler),
2836                },
2837                wgpu::BindGroupEntry {
2838                    binding: 6,
2839                    resource: wgpu::BindingResource::TextureView(&self.default_aux.view),
2840                },
2841                wgpu::BindGroupEntry {
2842                    binding: 7,
2843                    resource: wgpu::BindingResource::Sampler(&self.default_aux.sampler),
2844                },
2845                wgpu::BindGroupEntry {
2846                    binding: 8,
2847                    resource: wgpu::BindingResource::TextureView(&self.default_aux.view),
2848                },
2849                wgpu::BindGroupEntry {
2850                    binding: 9,
2851                    resource: wgpu::BindingResource::Sampler(&self.default_aux.sampler),
2852                },
2853                wgpu::BindGroupEntry {
2854                    binding: 10,
2855                    resource: wgpu::BindingResource::TextureView(&self.shadow_map.view),
2856                },
2857                wgpu::BindGroupEntry {
2858                    binding: 11,
2859                    resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
2860                },
2861                wgpu::BindGroupEntry {
2862                    binding: 12,
2863                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
2864                        buffer: &self.vs_uniform_buf,
2865                        offset: 0,
2866                        size: NonZeroU64::new(std::mem::size_of::<VsUniform>() as u64),
2867                    }),
2868                },
2869                wgpu::BindGroupEntry {
2870                    binding: 13,
2871                    resource: self.zero_bone_uniform_buf.as_entire_binding(),
2872                },
2873                wgpu::BindGroupEntry {
2874                    binding: 14,
2875                    resource: wgpu::BindingResource::TextureView(&self.default_aux.view),
2876                },
2877                wgpu::BindGroupEntry {
2878                    binding: 15,
2879                    resource: wgpu::BindingResource::Sampler(&self.normal_sampler),
2880                },
2881                wgpu::BindGroupEntry {
2882                    binding: 16,
2883                    resource: wgpu::BindingResource::TextureView(&self.default_aux.view),
2884                },
2885                wgpu::BindGroupEntry {
2886                    binding: 17,
2887                    resource: wgpu::BindingResource::Sampler(&self.toon_sampler),
2888                },
2889            ],
2890        });
2891        let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2892            label: Some("siglus-copy-pass"),
2893            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2894                view: color_view,
2895                resolve_target: None,
2896                ops: wgpu::Operations {
2897                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2898                    store: wgpu::StoreOp::Store,
2899                },
2900            })],
2901            depth_stencil_attachment: None,
2902            timestamp_writes: None,
2903            occlusion_query_set: None,
2904        });
2905        if let Some(pipeline) = self.pipelines.get(&key) {
2906            rp.set_pipeline(pipeline);
2907        }
2908        let viewport = if target_is_external {
2909            self.surface_viewport
2910        } else {
2911            SurfaceViewport::full(
2912                self.logical_width.max(1.0).round() as u32,
2913                self.logical_height.max(1.0).round() as u32,
2914            )
2915        };
2916        rp.set_viewport(
2917            viewport.x as f32,
2918            viewport.y as f32,
2919            viewport.w as f32,
2920            viewport.h as f32,
2921            0.0,
2922            1.0,
2923        );
2924        rp.set_vertex_buffer(0, self.vertex_sprite2d_buf.slice(..));
2925        rp.set_bind_group(0, &bind_group, &[0]);
2926        rp.set_scissor_rect(viewport.x, viewport.y, viewport.w, viewport.h);
2927        rp.draw(blit_range, 0..1);
2928        Ok(())
2929    }
2930
2931    fn ensure_vertex_capacity(&mut self, needed: usize) -> Result<()> {
2932        if needed <= self.vertex_capacity {
2933            return Ok(());
2934        }
2935
2936        // wgpu/Metal buffer creation is substantially heavier than extending the
2937        // old D3D9 SYSTEMMEM dynamic buffer. Keep semantic contents identical but
2938        // grow the high-water mark geometrically so particle systems do not
2939        // recreate a GPU buffer every time one more quad becomes visible.
2940        let mut new_cap = self.vertex_capacity.max(48);
2941        while new_cap < needed {
2942            new_cap = new_cap.saturating_mul(2);
2943        }
2944        new_cap = align_up_usize(new_cap, 6);
2945        self.vertex_capacity = new_cap;
2946
2947        self.vertex_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2948            label: Some("siglus-sprite-vertex-buf"),
2949            size: (new_cap * std::mem::size_of::<Vertex>()) as wgpu::BufferAddress,
2950            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2951            mapped_at_creation: false,
2952        });
2953        self.vertex_sprite2d_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2954            label: Some("siglus-sprite2d-vertex-buf"),
2955            size: (new_cap * std::mem::size_of::<VertexSprite2dData>()) as wgpu::BufferAddress,
2956            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2957            mapped_at_creation: false,
2958        });
2959        Ok(())
2960    }
2961
2962    fn upload_prepared_vertices(&mut self) -> Result<()> {
2963        self.ensure_vertex_capacity(self.plan.verts.len())?;
2964
2965        // Tona3 keeps separate FVF-specific 2D/3D buffers. Do the same here:
2966        // ordinary Siglus 2D sprites upload only pos/uv/mask-uv/alpha instead of
2967        // the 384-byte all-purpose mesh vertex used by the 3D path.
2968        self.sprite2d_verts.clear();
2969        self.sprite2d_verts.extend(
2970            self.plan
2971                .verts
2972                .iter()
2973                .copied()
2974                .map(VertexSprite2dData::from),
2975        );
2976        if !self.sprite2d_verts.is_empty() {
2977            self.queue.write_buffer(
2978                &self.vertex_sprite2d_buf,
2979                0,
2980                bytemuck::cast_slice(&self.sprite2d_verts),
2981            );
2982        }
2983
2984        let needs_mesh_vertices = self
2985            .plan
2986            .draws
2987            .iter()
2988            .any(|cmd| !cmd.pipeline_key.program.uses_sprite2d_layout() || cmd.shadow_cast);
2989        if needs_mesh_vertices && !self.plan.verts.is_empty() {
2990            self.queue
2991                .write_buffer(&self.vertex_buf, 0, bytemuck::cast_slice(&self.plan.verts));
2992        }
2993        Ok(())
2994    }
2995
2996    fn ensure_vs_uniform_capacity(&mut self, needed: usize) {
2997        if needed <= self.vs_uniform_capacity {
2998            return;
2999        }
3000        let mut new_cap = self.vs_uniform_capacity.max(64);
3001        while new_cap < needed {
3002            new_cap = new_cap.saturating_mul(2);
3003        }
3004        self.vs_uniform_capacity = new_cap;
3005        self.vs_uniform_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
3006            label: Some("siglus-vs-uniform-arena"),
3007            size: (self.vs_uniform_stride * new_cap) as wgpu::BufferAddress,
3008            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3009            mapped_at_creation: false,
3010        });
3011
3012        // Bind groups reference the old arena object. They must be rebuilt after
3013        // a high-water resize, but not on ordinary frames.
3014        self.clear_draw_bindings();
3015    }
3016
3017    fn upload_draw_uniforms(&mut self) {
3018        let draw_count = self.plan.draws.len();
3019        if draw_count == 0 {
3020            return;
3021        }
3022        self.ensure_vs_uniform_capacity(draw_count);
3023        let byte_len = self.vs_uniform_stride.saturating_mul(draw_count);
3024        self.vs_uniform_staging.clear();
3025        self.vs_uniform_staging.resize(byte_len, 0);
3026        for (idx, cmd) in self.plan.draws.iter().enumerate() {
3027            let offset = idx * self.vs_uniform_stride;
3028            let bytes = bytemuck::bytes_of(&cmd.vs_uniform);
3029            self.vs_uniform_staging[offset..offset + bytes.len()].copy_from_slice(bytes);
3030        }
3031        self.queue
3032            .write_buffer(&self.vs_uniform_buf, 0, &self.vs_uniform_staging);
3033    }
3034
3035    fn ensure_draw_gpu_slots(&mut self, needed: usize) {
3036        if self.draw_gpu_slots.len() >= needed {
3037            return;
3038        }
3039        // Slots now own GPU state only for the uncommon skinned-mesh bone palette.
3040        // Growing the CPU-side slot vector is cheap and does not allocate GPU
3041        // buffers for ordinary 2D sprites such as the 256 benchmark papers.
3042        self.draw_gpu_slots.resize_with(needed, || DrawGpuSlot {
3043            bone_uniform_buf: None,
3044            bind_group: None,
3045            bind_key: None,
3046            bind_epoch: 0,
3047        });
3048    }
3049
3050    fn prepare_draw_gpu_slot(
3051        &mut self,
3052        draw_idx: usize,
3053        overlay_backdrop: Option<BackdropTarget>,
3054    ) -> Result<()> {
3055        self.ensure_draw_gpu_slots(draw_idx + 1);
3056
3057        let use_bone_uniform = draw_uses_bone_uniform(&self.plan.draws[draw_idx]);
3058        if use_bone_uniform {
3059            if self.draw_gpu_slots[draw_idx].bone_uniform_buf.is_none() {
3060                self.draw_gpu_slots[draw_idx].bone_uniform_buf =
3061                    Some(self.device.create_buffer(&wgpu::BufferDescriptor {
3062                        label: Some("siglus-bone-uniform-slot"),
3063                        size: std::mem::size_of::<BoneUniform>() as wgpu::BufferAddress,
3064                        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3065                        mapped_at_creation: false,
3066                    }));
3067            }
3068            let bone_buf = self.draw_gpu_slots[draw_idx]
3069                .bone_uniform_buf
3070                .as_ref()
3071                .expect("skinned draw allocated a bone uniform buffer");
3072            let bone_uniform_index = self.plan.draws[draw_idx]
3073                .bone_uniform_index
3074                .expect("skinned draw missing bone palette index");
3075            let bone_uniform = self
3076                .plan
3077                .draw_bone_uniforms
3078                .get(bone_uniform_index as usize)
3079                .expect("skinned draw bone palette index out of range");
3080            self.queue
3081                .write_buffer(bone_buf, 0, bytemuck::bytes_of(bone_uniform));
3082        }
3083
3084        let cmd = &self.plan.draws[draw_idx];
3085        let bind_key = DrawBindKey::from_command(cmd, overlay_backdrop);
3086        // Emote's offscreen target may be recreated in-place when the requested
3087        // render size changes while keeping the same render id. Its texture view
3088        // therefore cannot be safely retained in a cached bind group across
3089        // frames. Ordinary Siglus images and mesh textures use stable cache keys.
3090        let cacheable = cmd.emote_render_id.is_none();
3091        let slot = &self.draw_gpu_slots[draw_idx];
3092        let needs_bind_group = !cacheable
3093            || slot.bind_group.is_none()
3094            || slot.bind_epoch != self.draw_bind_epoch
3095            || slot.bind_key.as_ref() != Some(&bind_key);
3096        if !needs_bind_group {
3097            return Ok(());
3098        }
3099
3100        // With the shared dynamic VsUniform arena and shared zero bone palette,
3101        // ordinary 2D draws no longer have any slot-specific buffer binding. This
3102        // lets identical resource sets reuse one bind group, matching tona3's
3103        // state batching instead of allocating one bind group per paper sprite.
3104        if cacheable
3105            && !use_bone_uniform
3106            && let Some(bind_group) = self.shared_draw_bind_groups.get(&bind_key).cloned()
3107        {
3108            let slot = &mut self.draw_gpu_slots[draw_idx];
3109            slot.bind_group = Some(bind_group);
3110            slot.bind_key = Some(bind_key);
3111            slot.bind_epoch = self.draw_bind_epoch;
3112            return Ok(());
3113        }
3114
3115        let semantics = self.resolve_effect_resources_for_draw(
3116            cmd,
3117            overlay_backdrop.map(|target| self.backdrop_target_ref(target)),
3118        );
3119        let base_sampler = if bind_key.mesh_base_sampler {
3120            &self.mesh_sampler
3121        } else {
3122            &semantics.base.sampler
3123        };
3124        let bone_uniform_buf = if use_bone_uniform {
3125            self.draw_gpu_slots[draw_idx]
3126                .bone_uniform_buf
3127                .as_ref()
3128                .expect("skinned draw bone buffer missing")
3129        } else {
3130            &self.zero_bone_uniform_buf
3131        };
3132        let bind_group = Arc::new(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
3133            label: Some("siglus-sprite-bg-slot"),
3134            layout: &self.bind_group_layout,
3135            entries: &[
3136                wgpu::BindGroupEntry {
3137                    binding: 0,
3138                    resource: wgpu::BindingResource::TextureView(&semantics.base.view),
3139                },
3140                wgpu::BindGroupEntry {
3141                    binding: 1,
3142                    resource: wgpu::BindingResource::Sampler(base_sampler),
3143                },
3144                wgpu::BindGroupEntry {
3145                    binding: 2,
3146                    resource: wgpu::BindingResource::TextureView(&semantics.mask.view),
3147                },
3148                wgpu::BindGroupEntry {
3149                    binding: 3,
3150                    resource: wgpu::BindingResource::Sampler(&semantics.mask.sampler),
3151                },
3152                wgpu::BindGroupEntry {
3153                    binding: 4,
3154                    resource: wgpu::BindingResource::TextureView(&semantics.tone.view),
3155                },
3156                wgpu::BindGroupEntry {
3157                    binding: 5,
3158                    resource: wgpu::BindingResource::Sampler(&semantics.tone.sampler),
3159                },
3160                wgpu::BindGroupEntry {
3161                    binding: 6,
3162                    resource: wgpu::BindingResource::TextureView(semantics.aux_view),
3163                },
3164                wgpu::BindGroupEntry {
3165                    binding: 7,
3166                    resource: wgpu::BindingResource::Sampler(semantics.aux_sampler),
3167                },
3168                wgpu::BindGroupEntry {
3169                    binding: 8,
3170                    resource: wgpu::BindingResource::TextureView(&semantics.fog.view),
3171                },
3172                wgpu::BindGroupEntry {
3173                    binding: 9,
3174                    resource: wgpu::BindingResource::Sampler(&self.fog_sampler),
3175                },
3176                wgpu::BindGroupEntry {
3177                    binding: 10,
3178                    resource: wgpu::BindingResource::TextureView(semantics.shadow_view),
3179                },
3180                wgpu::BindGroupEntry {
3181                    binding: 11,
3182                    resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
3183                },
3184                wgpu::BindGroupEntry {
3185                    binding: 12,
3186                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3187                        buffer: &self.vs_uniform_buf,
3188                        offset: 0,
3189                        size: NonZeroU64::new(std::mem::size_of::<VsUniform>() as u64),
3190                    }),
3191                },
3192                wgpu::BindGroupEntry {
3193                    binding: 13,
3194                    resource: bone_uniform_buf.as_entire_binding(),
3195                },
3196                wgpu::BindGroupEntry {
3197                    binding: 14,
3198                    resource: wgpu::BindingResource::TextureView(&semantics.normal.view),
3199                },
3200                wgpu::BindGroupEntry {
3201                    binding: 15,
3202                    resource: wgpu::BindingResource::Sampler(&self.normal_sampler),
3203                },
3204                wgpu::BindGroupEntry {
3205                    binding: 16,
3206                    resource: wgpu::BindingResource::TextureView(&semantics.toon.view),
3207                },
3208                wgpu::BindGroupEntry {
3209                    binding: 17,
3210                    resource: wgpu::BindingResource::Sampler(&self.toon_sampler),
3211                },
3212            ],
3213        }));
3214
3215        if cacheable && !use_bone_uniform {
3216            self.shared_draw_bind_groups
3217                .insert(bind_key.clone(), Arc::clone(&bind_group));
3218        }
3219        let slot = &mut self.draw_gpu_slots[draw_idx];
3220        slot.bind_group = Some(bind_group);
3221        slot.bind_key = cacheable.then_some(bind_key);
3222        slot.bind_epoch = self.draw_bind_epoch;
3223        Ok(())
3224    }
3225
3226    /// Drop GPU textures that are keyed by runtime ImageKey.
3227    ///
3228    /// Scene restart reinitializes ImageManager and reuses ImageKey indices from 0.
3229    /// Keeping the old GPU cache would make a newly decoded image with the same
3230    /// ImageKey/version sample the previous scene's texture. External path based
3231    /// textures are intentionally kept because their keys are stable resource paths.
3232    pub fn clear_runtime_image_textures(&mut self) {
3233        self.plan.draws.clear();
3234        self.plan.draw_bone_uniforms.clear();
3235        self.plan.draw_bone_uniforms.shrink_to_fit();
3236        self.textures.clear();
3237        self.clear_draw_bindings();
3238    }
3239
3240    fn clear_draw_bindings(&mut self) {
3241        for slot in &mut self.draw_gpu_slots {
3242            slot.bind_group = None;
3243            slot.bind_key = None;
3244        }
3245        self.shared_draw_bind_groups.clear();
3246        self.draw_bind_epoch = self.draw_bind_epoch.wrapping_add(1).max(1);
3247    }
3248
3249    pub fn texture_cache_bytes(&self) -> u64 {
3250        self.textures
3251            .values()
3252            .map(|tex| u64::from(tex.width) * u64::from(tex.height) * 4 * 4 / 3)
3253            .sum()
3254    }
3255
3256    /// Low-overhead renderer allocation accounting for the HUD.  This only
3257    /// inspects sizes already stored by the renderer; it never maps or copies a
3258    /// GPU resource.  Texture sizes include the same 4/3 mip estimate used by
3259    /// texture_cache_bytes().
3260    pub fn debug_memory_stats(&self) -> RendererMemoryStats {
3261        let image_texture_bytes = self.texture_cache_bytes();
3262        let external_texture_bytes = self
3263            .external_textures
3264            .values()
3265            .map(|tex| u64::from(tex.width) * u64::from(tex.height) * 4 * 4 / 3)
3266            .sum();
3267        let color_bytes = |rt: &RenderTargetTexture| u64::from(rt.width) * u64::from(rt.height) * 4;
3268        let internal_color_target_bytes = color_bytes(&self.scene_a)
3269            + color_bytes(&self.scene_b)
3270            + color_bytes(&self.wipe_a)
3271            + color_bytes(&self.wipe_b)
3272            + color_bytes(&self.shadow_map);
3273        // No HUD bookkeeping is stored in DepthTexture. Infer the three depth
3274        // allocations from dimensions the renderer already owns.
3275        let internal_depth_target_bytes =
3276            u64::from(self.scene_a.width) * u64::from(self.scene_a.height) * 4
3277                + u64::from(self.config.width) * u64::from(self.config.height) * 4
3278                + u64::from(self.shadow_map.width) * u64::from(self.shadow_map.height) * 2;
3279        let renderer_gpu_buffer_bytes = (self.vertex_capacity * std::mem::size_of::<Vertex>())
3280            as u64
3281            + (self.vertex_capacity * std::mem::size_of::<VertexSprite2dData>()) as u64
3282            + (self.vs_uniform_stride * self.vs_uniform_capacity) as u64
3283            + std::mem::size_of::<BoneUniform>() as u64
3284            + self
3285                .draw_gpu_slots
3286                .iter()
3287                .filter(|slot| slot.bone_uniform_buf.is_some())
3288                .count() as u64
3289                * std::mem::size_of::<BoneUniform>() as u64;
3290        let frame_arena_capacity_bytes = self.plan.verts.capacity() * std::mem::size_of::<Vertex>()
3291            + self.sprite2d_verts.capacity() * std::mem::size_of::<VertexSprite2dData>()
3292            + self.plan.draws.capacity() * std::mem::size_of::<DrawCommand>()
3293            + self.plan.draw_bone_uniforms.capacity() * std::mem::size_of::<BoneUniform>()
3294            + self.vs_uniform_staging.capacity();
3295        RendererMemoryStats {
3296            image_texture_bytes,
3297            external_texture_bytes,
3298            internal_color_target_bytes,
3299            internal_depth_target_bytes,
3300            renderer_gpu_buffer_bytes,
3301            frame_arena_capacity_bytes,
3302            image_texture_count: self.textures.len(),
3303            external_texture_count: self.external_textures.len(),
3304            cached_render_pipeline_count: self.pipelines.len(),
3305        }
3306    }
3307
3308    fn organize_textures(&mut self, images: &ImageManager) {
3309        let before = self.textures.len();
3310        self.textures.retain(|id, _| images.contains(*id));
3311        if self.textures.len() != before {
3312            // Bind groups also own texture views. Drop those references, not
3313            // just their cache keys, when the runtime releases a resource.
3314            self.clear_draw_bindings();
3315        }
3316    }
3317
3318    fn ensure_texture_uploaded(&mut self, images: &ImageManager, key: ImageKey) -> Result<()> {
3319        let Some(id) = images.image_handle(key) else {
3320            return Ok(());
3321        };
3322        let Some((img, version)) = images.get_entry(&id) else {
3323            return Ok(());
3324        };
3325        if let Some(mut tex) = self.textures.remove(&id.key()) {
3326            if tex.version != version {
3327                if tex.width == img.width && tex.height == img.height {
3328                    self.update_texture(&tex, &img)?;
3329                    tex.version = version;
3330                } else {
3331                    tex = create_gpu_texture(
3332                        &self.device,
3333                        &self.queue,
3334                        &self.mipmap_generator,
3335                        &format!("siglus-texture-{}", id.index()),
3336                        &img,
3337                        version,
3338                    )?;
3339                    self.clear_draw_bindings();
3340                }
3341            }
3342            self.textures.insert(id.key(), tex);
3343        } else {
3344            let tex = create_gpu_texture(
3345                &self.device,
3346                &self.queue,
3347                &self.mipmap_generator,
3348                &format!("siglus-texture-{}", id.index()),
3349                &img,
3350                version,
3351            )?;
3352            self.textures.insert(id.key(), tex);
3353        }
3354        Ok(())
3355    }
3356
3357    fn update_texture(&self, tex: &GpuTexture, img: &crate::assets::RgbaImage) -> Result<()> {
3358        if tex.width != img.width || tex.height != img.height {
3359            return Ok(());
3360        }
3361        upload_texture_pixels(&self.queue, &tex._tex, img);
3362        if let Some(mipmaps) = self.mipmap_generator.generate(&self.device, &tex._tex) {
3363            // D3D9 AUTOGENMIPMAP makes regenerated levels available after a
3364            // level-0 update. Submit this texture's chain immediately so the
3365            // Metal backend cannot accumulate native command buffers for every
3366            // texture prepared in the frame before any work reaches the queue.
3367            self.queue.submit(Some(mipmaps));
3368        }
3369        Ok(())
3370    }
3371
3372    fn submit(&self, encoder: wgpu::CommandEncoder) {
3373        self.queue.submit(Some(encoder.finish()));
3374    }
3375}
3376impl FrameCaptureBackend for Renderer {
3377    fn capture_render_frame(
3378        &mut self,
3379        images: &ImageManager,
3380        frame: &RenderFrame,
3381        logical_width: u32,
3382        logical_height: u32,
3383    ) -> Result<crate::assets::RgbaImage> {
3384        let renderer_width = self.logical_width.max(1.0).round() as u32;
3385        let renderer_height = self.logical_height.max(1.0).round() as u32;
3386        if renderer_width != logical_width.max(1) || renderer_height != logical_height.max(1) {
3387            anyhow::bail!(
3388                "capture logical size mismatch: renderer={}x{}, runtime={}x{}",
3389                renderer_width,
3390                renderer_height,
3391                logical_width,
3392                logical_height,
3393            );
3394        }
3395        let final_target = self.render_frame_to_internal(images, frame)?;
3396        let target = self.backdrop_target_ref(final_target);
3397        let rgba =
3398            self.debug_read_texture_rgba(&target._tex, target.width, target.height, target.format)?;
3399        Ok(crate::assets::RgbaImage {
3400            width: target.width,
3401            height: target.height,
3402            center_x: 0,
3403            center_y: 0,
3404            rgba,
3405        })
3406    }
3407}
3408
3409fn create_solid_texture(
3410    device: &wgpu::Device,
3411    queue: &wgpu::Queue,
3412    mipmap_generator: &mipmap::MipmapGenerator,
3413    rgba: [u8; 4],
3414) -> Result<GpuTexture> {
3415    let img = crate::assets::RgbaImage {
3416        width: 1,
3417        height: 1,
3418        center_x: 0,
3419        center_y: 0,
3420        rgba: rgba.to_vec(),
3421    };
3422    create_gpu_texture(
3423        device,
3424        queue,
3425        mipmap_generator,
3426        "siglus-default-aux",
3427        &img,
3428        0,
3429    )
3430}
3431
3432#[cfg(test)]
3433#[derive(Debug)]
3434struct Rgba8MipLevel {
3435    width: u32,
3436    height: u32,
3437    rgba: Vec<u8>,
3438}
3439
3440/// Build the same kind of full mip chain requested by the original
3441/// D3DUSAGE_AUTOGENMIPMAP textures.  Values are averaged in the stored 8-bit
3442/// color space rather than converted through sRGB, matching the D3D9 setup.
3443#[cfg(test)]
3444fn build_rgba8_mip_chain(width: u32, height: u32, rgba: &[u8]) -> Vec<Rgba8MipLevel> {
3445    if width == 0 || height == 0 || rgba.len() < width as usize * height as usize * 4 {
3446        return Vec::new();
3447    }
3448
3449    let mut levels = vec![Rgba8MipLevel {
3450        width,
3451        height,
3452        rgba: rgba[..width as usize * height as usize * 4].to_vec(),
3453    }];
3454
3455    while levels
3456        .last()
3457        .is_some_and(|level| level.width > 1 || level.height > 1)
3458    {
3459        let prev = levels.last().expect("mip chain contains level zero");
3460        let next_width = (prev.width / 2).max(1);
3461        let next_height = (prev.height / 2).max(1);
3462        let mut next = vec![0u8; next_width as usize * next_height as usize * 4];
3463
3464        for y in 0..next_height {
3465            for x in 0..next_width {
3466                let src_x0 = x.saturating_mul(2);
3467                let src_y0 = y.saturating_mul(2);
3468                let src_x1 = (src_x0 + 1).min(prev.width - 1);
3469                let src_y1 = (src_y0 + 1).min(prev.height - 1);
3470                let coords = [
3471                    (src_x0, src_y0),
3472                    (src_x1, src_y0),
3473                    (src_x0, src_y1),
3474                    (src_x1, src_y1),
3475                ];
3476                let dst = ((y * next_width + x) * 4) as usize;
3477                for channel in 0..4usize {
3478                    let sum = coords.iter().fold(0u32, |acc, (sx, sy)| {
3479                        let src = ((*sy * prev.width + *sx) * 4) as usize + channel;
3480                        acc + prev.rgba[src] as u32
3481                    });
3482                    next[dst + channel] = ((sum + 2) / 4).min(255) as u8;
3483                }
3484            }
3485        }
3486
3487        levels.push(Rgba8MipLevel {
3488            width: next_width,
3489            height: next_height,
3490            rgba: next,
3491        });
3492    }
3493
3494    levels
3495}
3496
3497fn create_gpu_texture(
3498    device: &wgpu::Device,
3499    queue: &wgpu::Queue,
3500    mipmap_generator: &mipmap::MipmapGenerator,
3501    label: &str,
3502    img: &crate::assets::RgbaImage,
3503    version: u64,
3504) -> Result<GpuTexture> {
3505    anyhow::ensure!(img.width > 0 && img.height > 0, "empty texture dimensions");
3506    let pixel_bytes = (img.width as usize)
3507        .checked_mul(img.height as usize)
3508        .and_then(|n| n.checked_mul(4))
3509        .context("texture size overflow")?;
3510    anyhow::ensure!(img.rgba.len() >= pixel_bytes, "truncated texture pixels");
3511    let mip_level_count = u32::BITS - img.width.max(img.height).leading_zeros();
3512    let tex = device.create_texture(&wgpu::TextureDescriptor {
3513        label: Some(label),
3514        size: wgpu::Extent3d {
3515            width: img.width,
3516            height: img.height,
3517            depth_or_array_layers: 1,
3518        },
3519        mip_level_count,
3520        sample_count: 1,
3521        dimension: wgpu::TextureDimension::D2,
3522        format: wgpu::TextureFormat::Rgba8Unorm,
3523        usage: wgpu::TextureUsages::TEXTURE_BINDING
3524            | wgpu::TextureUsages::COPY_DST
3525            | wgpu::TextureUsages::COPY_SRC
3526            | wgpu::TextureUsages::RENDER_ATTACHMENT,
3527        view_formats: &[],
3528    });
3529
3530    upload_texture_pixels(queue, &tex, img);
3531    if let Some(mipmaps) = mipmap_generator.generate(device, &tex) {
3532        queue.submit(Some(mipmaps));
3533    }
3534
3535    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
3536    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3537        label: Some("siglus-sampler"),
3538        address_mode_u: wgpu::AddressMode::ClampToEdge,
3539        address_mode_v: wgpu::AddressMode::ClampToEdge,
3540        address_mode_w: wgpu::AddressMode::ClampToEdge,
3541        mag_filter: wgpu::FilterMode::Linear,
3542        min_filter: wgpu::FilterMode::Linear,
3543        mipmap_filter: wgpu::FilterMode::Linear,
3544        ..Default::default()
3545    });
3546
3547    Ok(GpuTexture {
3548        _tex: tex,
3549        view,
3550        sampler,
3551        width: img.width,
3552        height: img.height,
3553        version,
3554    })
3555}
3556
3557fn upload_texture_pixels(
3558    queue: &wgpu::Queue,
3559    texture: &wgpu::Texture,
3560    img: &crate::assets::RgbaImage,
3561) {
3562    queue.write_texture(
3563        wgpu::ImageCopyTexture {
3564            texture,
3565            mip_level: 0,
3566            origin: wgpu::Origin3d::ZERO,
3567            aspect: wgpu::TextureAspect::All,
3568        },
3569        &img.rgba,
3570        wgpu::ImageDataLayout {
3571            offset: 0,
3572            bytes_per_row: Some(4 * img.width),
3573            rows_per_image: Some(img.height),
3574        },
3575        wgpu::Extent3d {
3576            width: img.width,
3577            height: img.height,
3578            depth_or_array_layers: 1,
3579        },
3580    );
3581}
3582
3583fn create_render_target_texture(
3584    device: &wgpu::Device,
3585    width: u32,
3586    height: u32,
3587    format: wgpu::TextureFormat,
3588    label: &str,
3589) -> RenderTargetTexture {
3590    let tex = device.create_texture(&wgpu::TextureDescriptor {
3591        label: Some(label),
3592        size: wgpu::Extent3d {
3593            width: width.max(1),
3594            height: height.max(1),
3595            depth_or_array_layers: 1,
3596        },
3597        mip_level_count: 1,
3598        sample_count: 1,
3599        dimension: wgpu::TextureDimension::D2,
3600        format,
3601        usage: wgpu::TextureUsages::TEXTURE_BINDING
3602            | wgpu::TextureUsages::RENDER_ATTACHMENT
3603            | wgpu::TextureUsages::COPY_SRC
3604            | wgpu::TextureUsages::COPY_DST,
3605        view_formats: &[],
3606    });
3607    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
3608    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3609        label: Some("siglus-render-target-sampler"),
3610        address_mode_u: wgpu::AddressMode::ClampToEdge,
3611        address_mode_v: wgpu::AddressMode::ClampToEdge,
3612        address_mode_w: wgpu::AddressMode::ClampToEdge,
3613        mag_filter: wgpu::FilterMode::Linear,
3614        min_filter: wgpu::FilterMode::Linear,
3615        mipmap_filter: wgpu::FilterMode::Nearest,
3616        ..Default::default()
3617    });
3618    RenderTargetTexture {
3619        _tex: tex,
3620        view,
3621        sampler,
3622        width: width.max(1),
3623        height: height.max(1),
3624        format,
3625    }
3626}
3627
3628fn create_depth_texture(device: &wgpu::Device, width: u32, height: u32) -> DepthTexture {
3629    create_depth_texture_with_format(
3630        device,
3631        width,
3632        height,
3633        wgpu::TextureFormat::Depth32Float,
3634        "siglus-depth",
3635    )
3636}
3637
3638fn create_depth_texture_with_format(
3639    device: &wgpu::Device,
3640    width: u32,
3641    height: u32,
3642    format: wgpu::TextureFormat,
3643    label: &str,
3644) -> DepthTexture {
3645    let tex = device.create_texture(&wgpu::TextureDescriptor {
3646        label: Some(label),
3647        size: wgpu::Extent3d {
3648            width: width.max(1),
3649            height: height.max(1),
3650            depth_or_array_layers: 1,
3651        },
3652        mip_level_count: 1,
3653        sample_count: 1,
3654        dimension: wgpu::TextureDimension::D2,
3655        format,
3656        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
3657        view_formats: &[],
3658    });
3659    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
3660    DepthTexture { _tex: tex, view }
3661}
3662
3663#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
3664fn wasm_shader_source() -> String {
3665    SHADER.to_string()
3666}
3667
3668fn create_wipe_pipeline(
3669    device: &wgpu::Device,
3670    format: wgpu::TextureFormat,
3671) -> (wgpu::BindGroupLayout, wgpu::RenderPipeline) {
3672    let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3673        label: Some("siglus-wipe-bind-group-layout"),
3674        entries: &[
3675            texture_layout_entry(0),
3676            sampler_layout_entry(1),
3677            texture_layout_entry(2),
3678            sampler_layout_entry(3),
3679            texture_layout_entry(4),
3680            sampler_layout_entry(5),
3681            texture_layout_entry(6),
3682            sampler_layout_entry(7),
3683            wgpu::BindGroupLayoutEntry {
3684                binding: 8,
3685                visibility: wgpu::ShaderStages::FRAGMENT,
3686                ty: wgpu::BindingType::Buffer {
3687                    ty: wgpu::BufferBindingType::Uniform,
3688                    has_dynamic_offset: false,
3689                    min_binding_size: None,
3690                },
3691                count: None,
3692            },
3693        ],
3694    });
3695    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
3696        label: Some("siglus-wipe-shader"),
3697        source: wgpu::ShaderSource::Wgsl(WIPE_SHADER.into()),
3698    });
3699    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3700        label: Some("siglus-wipe-pipeline-layout"),
3701        bind_group_layouts: &[&layout],
3702        push_constant_ranges: &[],
3703    });
3704    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
3705        label: Some("siglus-wipe-pipeline"),
3706        layout: Some(&pipeline_layout),
3707        vertex: wgpu::VertexState {
3708            module: &shader,
3709            entry_point: "vs_main",
3710            compilation_options: wgpu::PipelineCompilationOptions::default(),
3711            buffers: &[],
3712        },
3713        fragment: Some(wgpu::FragmentState {
3714            module: &shader,
3715            entry_point: "fs_main",
3716            compilation_options: wgpu::PipelineCompilationOptions::default(),
3717            targets: &[Some(wgpu::ColorTargetState {
3718                format,
3719                blend: None,
3720                write_mask: wgpu::ColorWrites::ALL,
3721            })],
3722        }),
3723        primitive: wgpu::PrimitiveState {
3724            topology: wgpu::PrimitiveTopology::TriangleList,
3725            strip_index_format: None,
3726            front_face: wgpu::FrontFace::Ccw,
3727            cull_mode: None,
3728            polygon_mode: wgpu::PolygonMode::Fill,
3729            unclipped_depth: false,
3730            conservative: false,
3731        },
3732        depth_stencil: None,
3733        multisample: wgpu::MultisampleState::default(),
3734        multiview: None,
3735    });
3736    (layout, pipeline)
3737}
3738
3739fn create_page_wipe_pipeline(
3740    device: &wgpu::Device,
3741    format: wgpu::TextureFormat,
3742) -> (wgpu::BindGroupLayout, wgpu::RenderPipeline) {
3743    let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3744        label: Some("siglus-page-wipe-bind-group-layout"),
3745        entries: &[texture_layout_entry(0), sampler_layout_entry(1)],
3746    });
3747    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
3748        label: Some("siglus-page-wipe-shader"),
3749        source: wgpu::ShaderSource::Wgsl(PAGE_WIPE_SHADER.into()),
3750    });
3751    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3752        label: Some("siglus-page-wipe-pipeline-layout"),
3753        bind_group_layouts: &[&layout],
3754        push_constant_ranges: &[],
3755    });
3756    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
3757        label: Some("siglus-page-wipe-pipeline"),
3758        layout: Some(&pipeline_layout),
3759        vertex: wgpu::VertexState {
3760            module: &shader,
3761            entry_point: "vs_main",
3762            compilation_options: wgpu::PipelineCompilationOptions::default(),
3763            buffers: &[wgpu::VertexBufferLayout {
3764                array_stride: std::mem::size_of::<PageWipeVertex>() as wgpu::BufferAddress,
3765                step_mode: wgpu::VertexStepMode::Vertex,
3766                attributes: &[
3767                    wgpu::VertexAttribute {
3768                        format: wgpu::VertexFormat::Float32x4,
3769                        offset: 0,
3770                        shader_location: 0,
3771                    },
3772                    wgpu::VertexAttribute {
3773                        format: wgpu::VertexFormat::Float32x2,
3774                        offset: 16,
3775                        shader_location: 1,
3776                    },
3777                ],
3778            }],
3779        },
3780        fragment: Some(wgpu::FragmentState {
3781            module: &shader,
3782            entry_point: "fs_main",
3783            compilation_options: wgpu::PipelineCompilationOptions::default(),
3784            targets: &[Some(wgpu::ColorTargetState {
3785                format,
3786                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
3787                write_mask: wgpu::ColorWrites::ALL,
3788            })],
3789        }),
3790        primitive: wgpu::PrimitiveState {
3791            topology: wgpu::PrimitiveTopology::TriangleList,
3792            strip_index_format: None,
3793            front_face: wgpu::FrontFace::Ccw,
3794            cull_mode: Some(wgpu::Face::Back),
3795            polygon_mode: wgpu::PolygonMode::Fill,
3796            unclipped_depth: false,
3797            conservative: false,
3798        },
3799        depth_stencil: Some(wgpu::DepthStencilState {
3800            format: wgpu::TextureFormat::Depth32Float,
3801            depth_write_enabled: true,
3802            depth_compare: wgpu::CompareFunction::LessEqual,
3803            stencil: wgpu::StencilState::default(),
3804            bias: wgpu::DepthBiasState::default(),
3805        }),
3806        multisample: wgpu::MultisampleState::default(),
3807        multiview: None,
3808    });
3809    (layout, pipeline)
3810}
3811
3812const PAGE_WIPE_SHADER: &str = include_str!("shaders/page_wipe.wgsl");
3813
3814#[cfg(test)]
3815mod present_mode_tests {
3816    use super::present_mode_for_wait_display_vsync;
3817
3818    #[test]
3819    fn vsync_wait_uses_fifo() {
3820        let modes = [wgpu::PresentMode::Fifo, wgpu::PresentMode::Immediate];
3821        assert_eq!(
3822            present_mode_for_wait_display_vsync(true, &modes),
3823            wgpu::PresentMode::Fifo
3824        );
3825    }
3826
3827    #[test]
3828    fn vsync_off_prefers_immediate_then_mailbox_then_fifo() {
3829        let all = [
3830            wgpu::PresentMode::Fifo,
3831            wgpu::PresentMode::Mailbox,
3832            wgpu::PresentMode::Immediate,
3833        ];
3834        assert_eq!(
3835            present_mode_for_wait_display_vsync(false, &all),
3836            wgpu::PresentMode::Immediate
3837        );
3838        assert_eq!(
3839            present_mode_for_wait_display_vsync(
3840                false,
3841                &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Mailbox],
3842            ),
3843            wgpu::PresentMode::Mailbox
3844        );
3845        assert_eq!(
3846            present_mode_for_wait_display_vsync(false, &[wgpu::PresentMode::Fifo]),
3847            wgpu::PresentMode::Fifo
3848        );
3849    }
3850}
3851
3852#[cfg(test)]
3853mod depth_state_tests {
3854    use super::{d3d_front_face, depth_write_enabled};
3855
3856    #[test]
3857    fn translucent_3d_sprites_test_depth_without_writing_it() {
3858        assert!(depth_write_enabled(true, false));
3859        assert!(!depth_write_enabled(true, true));
3860        assert!(!depth_write_enabled(false, false));
3861    }
3862
3863    #[test]
3864    fn tona3_ccw_cull_uses_clockwise_front_faces() {
3865        assert!(matches!(d3d_front_face(), wgpu::FrontFace::Cw));
3866    }
3867}
3868
3869fn texture_layout_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
3870    wgpu::BindGroupLayoutEntry {
3871        binding,
3872        visibility: wgpu::ShaderStages::FRAGMENT,
3873        ty: wgpu::BindingType::Texture {
3874            multisampled: false,
3875            view_dimension: wgpu::TextureViewDimension::D2,
3876            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3877        },
3878        count: None,
3879    }
3880}
3881
3882fn sampler_layout_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
3883    wgpu::BindGroupLayoutEntry {
3884        binding,
3885        visibility: wgpu::ShaderStages::FRAGMENT,
3886        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3887        count: None,
3888    }
3889}
3890
3891const WIPE_SHADER: &str = include_str!("shaders/wipe.wgsl");
3892
3893const SHADER: &str = include_str!("shaders/sprite.wgsl");