//! wgpu implementation of [`RenderBackend`], batched (v0.2 step 10).
//!
//! v0.1 was the deliberately naive reference renderer: per-draw buffer
//! uploads, one draw call per sprite (measured: 514 ms/frame at 10,000
//! sprites). v0.2 batches by INSTANCING: the unit quad is uploaded once at
//! startup; `draw()` records a 96-byte instance record (model matrix, tint,
//! uv rect); `end_frame()` sorts by (camera, texture, z), uploads every
//! record with ONE write_buffer, and issues one draw call per (camera,
//! texture) run. With a texture atlas that is one draw call per frame.
//! The public API did not change a single signature: batching lives entirely
//! behind the RenderBackend seam, exactly as the design promised.
//!
//! A mesh that IS the shared unit quad joins the batch; any other mesh takes
//! the v0.1-style slow path (own buffers, single-instance draw) AFTER the
//! batch. Mixing overlapping quads and custom meshes in one frame therefore
//! has no painter's-order guarantee between the two groups until v0.3's
//! depth buffer; v0.2 scenes are quad-only in practice.
//!
//! Compiled and runtime-verified against wgpu 29.0.3 + winit 0.30.13.
//! The wgpu 29 API drift points are annotated with `// wgpu 29:` comments
//! where each one lives - they are the exact lines that break when wgpu
//! bumps a major version, so keep them.

use std::sync::Arc;

use wgpu::util::DeviceExt;
use winit::window::Window;

use crate::camera::Camera;
use crate::color::Color;
use crate::content::{TextureData, TextureId};
use crate::drawable::{Drawable, Mesh, Shading, Vertex};
use crate::error::Result;
use crate::gfx::backend::CameraUniform;
use crate::gfx::gpu::GpuContext;
use crate::gfx::RenderBackend;
use crate::math::Mat4;

/// One record per sprite, fetched by the vertex stage PER INSTANCE, not per
/// vertex. Replaces the v0.1 `ModelUniform`: everything that was per-draw
/// uniform state is now data in an array the GPU indexes. Must match
/// `InstanceInput` in sprite.wgsl field for field, location for location.
/// 96 bytes per sprite: 10,000 sprites = 960 KB per frame, trivial traffic.
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct Instance {
    model: [[f32; 4]; 4], // locations 3,4,5,6 (one vec4 per column)
    tint: [f32; 4],       // location 7
    uv_rect: [f32; 4],    // location 8: min.x, min.y, size.x, size.y
}

impl Instance {
    // An associated const gives the array a 'static home the layout can borrow.
    const ATTRS: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![
        4 => Float32x4, 5 => Float32x4, 6 => Float32x4,
        7 => Float32x4, 8 => Float32x4, 9 => Float32x4
    ];

    fn layout() -> wgpu::VertexBufferLayout<'static> {
        wgpu::VertexBufferLayout {
            array_stride: std::mem::size_of::<Instance>() as wgpu::BufferAddress,
            step_mode: wgpu::VertexStepMode::Instance, // the one line that matters
            attributes: &Self::ATTRS,
        }
    }
}

/// A GPU texture plus its bind group (group 1: texture + sampler only; the
/// per-draw model uniform is gone, it rides in the instance buffer now).
struct GpuTexture {
    bind_group: wgpu::BindGroup,
}

/// What `draw()` records for a batchable quad. ~120 bytes, Copy, no clones.
#[derive(Clone, Copy)]
struct BatchItem {
    cam: u32,           // index into this frame's distinct cameras (seam 5)
    texture: TextureId, // TextureId(pub u64): the u64 IS the sort key
    z: f32,             // painter's-order tie-break within a texture run
    instance: Instance,
}

/// The v0.1-style slow path for meshes that are not the shared unit quad:
/// own geometry buffers, drawn as a single instance after the batch.
struct SlowDraw {
    cam: u32,
    texture: TextureId,
    shading: Shading, // v0.3: which pipeline this mesh wants
    vertices: Vec<Vertex>,
    indices: Vec<u32>,
    instance: Instance,
}

/// Persistent, grow-by-doubling instance buffer: create once, write per
/// frame with `queue.write_buffer`, grow only when a frame needs more.
struct InstanceBuffer {
    buf: wgpu::Buffer,
    capacity: usize, // in instances, not bytes
}

impl InstanceBuffer {
    fn create(device: &wgpu::Device, capacity: usize) -> wgpu::Buffer {
        device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("sprite instances"),
            size: (capacity * std::mem::size_of::<Instance>()) as wgpu::BufferAddress,
            // VERTEX: the pass reads it as buffer slot 1.
            // COPY_DST: write_buffer copies into it every frame.
            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        })
    }

    fn ensure(&mut self, device: &wgpu::Device, needed: usize) {
        if needed <= self.capacity {
            return; // steady state: free
        }
        let capacity = needed.next_power_of_two(); // grow by doubling
        // Assigning drops the old buffer; wgpu keeps it alive until
        // in-flight frames finish, so growth mid-game is safe.
        self.buf = Self::create(device, capacity);
        self.capacity = capacity;
    }
}

/// One persistent camera uniform + bind group per distinct camera seen in a
/// frame. A 2D frame uses one (one write, one bind); per-eye VR uses more.
/// Slots are created on demand and reused forever (never per frame).
struct CameraSlot {
    buf: wgpu::Buffer,
    bind_group: wgpu::BindGroup,
}

pub struct WgpuBackend {
    /// The five wgpu handles, owned by the ONE bring-up path in gpu.rs.
    /// The planned refactor, done: this backend no longer bootstraps its
    /// own copies; any future renderer (or the XR backend) reuses the same
    /// GpuContext, and the instance + adapter it keeps are the wgpu-hal
    /// handoff OpenXR needs.
    gpu: GpuContext,

    pipeline: wgpu::RenderPipeline,      // unlit sprites: no cull, LessEqual
    lit_pipeline: wgpu::RenderPipeline,  // v0.3: back-cull, Less, lambert
    light_bind_group: wgpu::BindGroup,
    light_buf: wgpu::Buffer,
    depth_view: wgpu::TextureView,       // v0.3: recreated on resize
    camera_bind_layout: wgpu::BindGroupLayout,
    texture_bind_layout: wgpu::BindGroupLayout,
    sampler: wgpu::Sampler,

    // texture cache: TextureId -> uploaded GPU texture + bind group
    textures: std::collections::HashMap<TextureId, GpuTexture>,
    white: TextureId,

    // --- the batching machinery (v0.2 step 10) ---
    // The one quad every sprite shares: 4 vertices, 6 indices, uploaded once
    // at startup, drawn n times per call via the instance range.
    quad_mesh: Mesh, // canonical copy, for "is this mesh THE quad?" detection
    quad_vbuf: wgpu::Buffer,
    quad_ibuf: wgpu::Buffer,
    instance_buf: InstanceBuffer,
    instances: Vec<Instance>, // reused CPU staging Vec: clear() keeps capacity
    cam_slots: Vec<CameraSlot>,

    // per-frame
    clear: Color,
    batch: Vec<BatchItem>,
    slow: Vec<SlowDraw>,
    frame_cams: Vec<Mat4>, // distinct view_proj matrices seen this frame

    // once-a-second stats (log::info): presented frames and draw calls.
    // "Log the run count next to the frame time to prove it" - the batching
    // guide's milestone wants numbers from the backend itself, not vibes.
    stat_since: std::time::Instant,
    stat_frames: u32,
    stat_draws: u64,
}

impl WgpuBackend {
    /// Initialize the backend for `window`. `backends` lets you force a specific
    /// graphics API; pass `wgpu::Backends::VULKAN` if you intend to reach down to
    /// raw Vulkan handles later for OpenXR (see handoff.md), or
    /// `wgpu::Backends::all()` to let wgpu pick.
    pub fn new(window: Arc<Window>, backends: wgpu::Backends) -> Result<Self> {
        // The one bring-up path: instance -> surface -> adapter -> device ->
        // configured surface, all owned by GpuContext (see gfx/gpu.rs).
        let gpu = GpuContext::new(window, backends)?;
        let device = &gpu.device;
        let format = gpu.config.format;
        let size = gpu.size();

        // --- shaders + layouts ---
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("sprite shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/sprite.wgsl").into()),
        });
        let lit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("lit shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/lit.wgsl").into()),
        });

        let camera_bind_layout =
            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
                label: Some("camera bind layout"),
                entries: &[wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                }],
            });

        let texture_bind_layout =
            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
                label: Some("texture bind layout"),
                entries: &[
                    wgpu::BindGroupLayoutEntry {
                        binding: 0,
                        visibility: wgpu::ShaderStages::FRAGMENT,
                        ty: wgpu::BindingType::Texture {
                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
                            view_dimension: wgpu::TextureViewDimension::D2,
                            multisampled: false,
                        },
                        count: None,
                    },
                    wgpu::BindGroupLayoutEntry {
                        binding: 1,
                        visibility: wgpu::ShaderStages::FRAGMENT,
                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                        count: None,
                    },
                    // The old binding 2 (per-draw Model uniform) is gone:
                    // model matrix + tint ride in the instance buffer now.
                ],
            });

        // v0.3: the directional light, group(2) on the lit pipeline only.
        let light_bind_layout =
            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
                label: Some("light bind layout"),
                entries: &[wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                }],
            });

        // wgpu 29: bind_group_layouts entries are Option, and the old
        // push_constant_ranges field became immediate_size (in bytes).
        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("sprite pipeline layout"),
            bind_group_layouts: &[Some(&camera_bind_layout), Some(&texture_bind_layout)],
            immediate_size: 0,
        });
        let lit_pipeline_layout =
            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
                label: Some("lit pipeline layout"),
                bind_group_layouts: &[
                    Some(&camera_bind_layout),
                    Some(&texture_bind_layout),
                    Some(&light_bind_layout),
                ],
                immediate_size: 0,
            });

        // The two pipelines differ in exactly three places, and each
        // difference is a 2D-vs-3D lesson:
        //   cull_mode: sprites None (the y-down ortho flips winding on
        //     screen, so culling would eat every quad); lit Some(Back)
        //     (the decided convention: CCW front faces, seen from outside).
        //   depth_compare: sprites LessEqual (equal-z sprites keep
        //     painter's order: later draw wins); lit Less (strict).
        //   shader: unlit multiply vs one-light Lambert.
        let make_pipeline = |label: &str,
                             layout: &wgpu::PipelineLayout,
                             module: &wgpu::ShaderModule,
                             cull_mode: Option<wgpu::Face>,
                             depth_compare: wgpu::CompareFunction| {
            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
                label: Some(label),
                layout: Some(layout),
                vertex: wgpu::VertexState {
                    module,
                    entry_point: Some("vs_main"),
                    // Slot 0 steps per vertex, slot 1 per INSTANCE.
                    buffers: &[Vertex::layout(), Instance::layout()],
                    compilation_options: Default::default(),
                },
                fragment: Some(wgpu::FragmentState {
                    module,
                    entry_point: Some("fs_main"),
                    targets: &[Some(wgpu::ColorTargetState {
                        format,
                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
                        write_mask: wgpu::ColorWrites::ALL,
                    })],
                    compilation_options: Default::default(),
                }),
                primitive: wgpu::PrimitiveState {
                    topology: wgpu::PrimitiveTopology::TriangleList,
                    strip_index_format: None,
                    front_face: wgpu::FrontFace::Ccw,
                    cull_mode,
                    polygon_mode: wgpu::PolygonMode::Fill,
                    unclipped_depth: false,
                    conservative: false,
                },
                // v0.3: the depth buffer. wgpu 29: write_enabled/compare
                // are Options on DepthStencilState.
                depth_stencil: Some(wgpu::DepthStencilState {
                    format: wgpu::TextureFormat::Depth32Float,
                    depth_write_enabled: Some(true),
                    depth_compare: Some(depth_compare),
                    stencil: Default::default(),
                    bias: Default::default(),
                }),
                multisample: wgpu::MultisampleState::default(),
                // wgpu 29: multiview became a layer mask (single-pass stereo later).
                multiview_mask: None,
                cache: None,
            })
        };

        let pipeline = make_pipeline(
            "sprite pipeline",
            &pipeline_layout,
            &shader,
            None,
            wgpu::CompareFunction::LessEqual,
        );
        let lit_pipeline = make_pipeline(
            "lit pipeline",
            &lit_pipeline_layout,
            &lit_shader,
            Some(wgpu::Face::Back),
            wgpu::CompareFunction::Less,
        );

        // The default sun: from upper-front-left, white. set_light overrides.
        let light_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("light uniform"),
            contents: bytemuck::cast_slice(&Self::light_bytes(
                crate::math::Vec3::new(-0.4, -0.7, -0.5),
                Color::WHITE,
            )),
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        });
        let light_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("light bind group"),
            layout: &light_bind_layout,
            entries: &[wgpu::BindGroupEntry {
                binding: 0,
                resource: light_buf.as_entire_binding(),
            }],
        });

        // v0.3: the depth texture, recreated whenever the surface is.
        let depth_view = Self::create_depth(device, size.0, size.1);

        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("sprite sampler"),
            mag_filter: wgpu::FilterMode::Nearest, // crisp pixels; switch to Linear for smooth
            min_filter: wgpu::FilterMode::Nearest,
            ..Default::default()
        });

        // Startup: the one quad every sprite shares. 4 vertices, 6 indices,
        // uploaded ONCE - create_buffer_init is right here (setup), wrong
        // per frame (that is what write_buffer + the instance buffer is for).
        let quad_mesh = Mesh::quad();
        let quad_vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("shared quad vertices"),
            contents: bytemuck::cast_slice(&quad_mesh.vertices),
            usage: wgpu::BufferUsages::VERTEX,
        });
        let quad_ibuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("shared quad indices"),
            contents: bytemuck::cast_slice(&quad_mesh.indices),
            usage: wgpu::BufferUsages::INDEX,
        });
        let instance_buf = InstanceBuffer {
            buf: InstanceBuffer::create(device, 1024),
            capacity: 1024,
        };

        let mut backend = Self {
            gpu,
            pipeline,
            lit_pipeline,
            light_bind_group,
            light_buf,
            depth_view,
            camera_bind_layout,
            texture_bind_layout,
            sampler,
            textures: std::collections::HashMap::new(),
            white: TextureId(u64::MAX), // placeholder, set below
            quad_mesh,
            quad_vbuf,
            quad_ibuf,
            instance_buf,
            instances: Vec::new(),
            cam_slots: Vec::new(),
            clear: Color::BLACK,
            batch: Vec::new(),
            slow: Vec::new(),
            frame_cams: Vec::new(),
            stat_since: std::time::Instant::now(),
            stat_frames: 0,
            stat_draws: 0,
        };

        // Always-available 1x1 white texture for untextured (solid-color) draws.
        let white_id = TextureId(u64::MAX);
        backend.upload_texture(
            white_id,
            &TextureData {
                width: 1,
                height: 1,
                rgba: vec![255, 255, 255, 255],
            },
        );
        backend.white = white_id;

        Ok(backend)
    }

    /// The Light uniform's bytes: direction (normalized, padded to vec4)
    /// then color. Two vec4s = 32 bytes, no alignment surprises.
    fn light_bytes(direction: crate::math::Vec3, color: Color) -> [f32; 8] {
        let d = direction.normalize_or_zero();
        let c = color.to_array();
        [d.x, d.y, d.z, 0.0, c[0], c[1], c[2], c[3]]
    }

    /// Point the directional light (v0.3). Direction is FROM the light,
    /// world space; it is normalized here.
    pub fn set_light(&mut self, direction: crate::math::Vec3, color: Color) {
        self.gpu.queue.write_buffer(
            &self.light_buf,
            0,
            bytemuck::cast_slice(&Self::light_bytes(direction, color)),
        );
    }

    /// The depth texture, surface-sized: created at startup and again on
    /// every resize (like the swapchain it shadows). Depth32Float: one
    /// float of "how far" per pixel, cleared to 1.0 = the far plane.
    fn create_depth(device: &wgpu::Device, width: u32, height: u32) -> wgpu::TextureView {
        let texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("depth"),
            size: wgpu::Extent3d {
                width: width.max(1),
                height: height.max(1),
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Depth32Float,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            view_formats: &[],
        });
        texture.create_view(&wgpu::TextureViewDescriptor::default())
    }


    /// Upload (or replace) a GPU texture for `id`. The app layer calls this after
    /// loading assets through [`crate::content::ContentManager`]. Cheap to call
    /// once per texture; do not call per frame.
    pub fn upload_texture(&mut self, id: TextureId, data: &TextureData) {
        let size = wgpu::Extent3d {
            width: data.width,
            height: data.height,
            depth_or_array_layers: 1,
        };
        let texture = self.gpu.device.create_texture(&wgpu::TextureDescriptor {
            label: Some("mawida texture"),
            size,
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8UnormSrgb,
            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
            view_formats: &[],
        });

        // wgpu 29 naming (was ImageCopyTexture in older versions).
        self.gpu.queue.write_texture(
            wgpu::TexelCopyTextureInfo {
                texture: &texture,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            &data.rgba,
            wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(4 * data.width),
                rows_per_image: Some(data.height),
            },
            size,
        );

        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());

        let bind_group = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("texture bind group"),
            layout: &self.texture_bind_layout,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: wgpu::BindingResource::TextureView(&view),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: wgpu::BindingResource::Sampler(&self.sampler),
                },
            ],
        });

        self.textures.insert(id, GpuTexture { bind_group });
    }
}

impl RenderBackend for WgpuBackend {
    fn begin_frame(&mut self, clear: Color) -> Result<()> {
        self.clear = clear;
        // clear() keeps each Vec's allocation: after the first frame these
        // refills allocate nothing. Same discipline as the GPU side.
        self.batch.clear();
        self.slow.clear();
        self.frame_cams.clear();
        Ok(())
    }

    /// Unchanged signature. A mesh that IS the shared unit quad joins the
    /// batch; anything else takes the slow path. No privileged sprite API
    /// appears: the backend merely recognizes the common case of seam 3.
    fn draw(&mut self, drawable: &dyn Drawable, camera: &dyn Camera) -> Result<()> {
        let material = drawable.material();
        let transform = drawable.transform();
        let texture = material.texture.unwrap_or(self.white);

        // Seam 5: the camera arrives per call. Each distinct view_proj this
        // frame gets a slot index; a camera change breaks the batch. One
        // camera (the 2D norm) = one slot; per-eye VR = one per eye.
        let view_proj = camera.view_projection();
        let cam = match self.frame_cams.iter().position(|m| *m == view_proj) {
            Some(i) => i as u32,
            None => {
                self.frame_cams.push(view_proj);
                (self.frame_cams.len() - 1) as u32
            }
        };

        let instance = Instance {
            model: transform.matrix().to_cols_array_2d(),
            tint: material.base_color.to_array(),
            uv_rect: material.uv_rect, // [0,0,1,1] = whole texture; else atlas region
        };

        let mesh = drawable.mesh();
        let is_quad = mesh.indices == self.quad_mesh.indices
            && mesh.vertices == self.quad_mesh.vertices
            && material.shading == Shading::Unlit; // lit anything: slow path
        if is_quad {
            self.batch.push(BatchItem {
                cam,
                texture,
                z: transform.translation.z,
                instance,
            });
        } else {
            // Not the shared unlit quad: v0.1-style single draw, after
            // the batch, on whichever pipeline the material asks for.
            self.slow.push(SlowDraw {
                cam,
                texture,
                shading: material.shading,
                vertices: mesh.vertices.clone(),
                indices: mesh.indices.clone(),
                instance,
            });
        }
        Ok(())
    }

    fn end_frame(&mut self) -> Result<()> {
        // Acquire the frame through the one bring-up path. gpu.acquire()
        // owns the wgpu 29 CurrentSurfaceTexture dance (Suboptimal
        // reconfigure, Outdated/Lost retry); Ok(None) means "skip this
        // frame" (timeout, occluded/minimized window).
        let Some(frame) = self.gpu.acquire()? else {
            return Ok(());
        };

        let view = frame
            .texture
            .create_view(&wgpu::TextureViewDescriptor::default());

        // --- sort: camera first (submission grouping), then texture (the
        // longest runs), z as tie-break. sort_by is STABLE: equal keys keep
        // their submission order, which preserves painter's order within a
        // run. f32 is not Ord (NaN); total_cmp is the honest total order.
        self.batch.sort_by(|a, b| {
            a.cam
                .cmp(&b.cam)
                .then(a.texture.0.cmp(&b.texture.0))
                .then(a.z.total_cmp(&b.z))
        });

        // --- ONE upload for the frame: batch records, then slow records.
        self.instances.clear(); // reused Vec, no realloc once warm
        self.instances.extend(self.batch.iter().map(|b| b.instance));
        let slow_first = self.instances.len() as u32;
        self.instances.extend(self.slow.iter().map(|s| s.instance));
        self.instance_buf.ensure(&self.gpu.device, self.instances.len());
        if !self.instances.is_empty() {
            self.gpu.queue
                .write_buffer(&self.instance_buf.buf, 0, bytemuck::cast_slice(&self.instances));
        }

        // --- camera slots: one persistent uniform + bind group per distinct
        // camera this frame, created on demand, reused forever. Distinct
        // buffers mean mid-frame camera changes cannot race one buffer
        // (write_buffer lands before the pass runs, not between draws).
        while self.cam_slots.len() < self.frame_cams.len() {
            let buf = self.gpu.device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("camera uniform"),
                size: std::mem::size_of::<CameraUniform>() as wgpu::BufferAddress,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            });
            let bind_group = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some("camera bind group"),
                layout: &self.camera_bind_layout,
                entries: &[wgpu::BindGroupEntry {
                    binding: 0,
                    resource: buf.as_entire_binding(),
                }],
            });
            self.cam_slots.push(CameraSlot { buf, bind_group });
        }
        for (i, m) in self.frame_cams.iter().enumerate() {
            let cam_uniform = CameraUniform::from_matrix(*m);
            self.gpu.queue
                .write_buffer(&self.cam_slots[i].buf, 0, bytemuck::cast_slice(&[cam_uniform]));
        }

        // --- slow-path geometry, uploaded per draw exactly like v0.1 did.
        // Rare in 2D scenes; candidate 3 (the CPU batch) is its upgrade.
        let slow_bufs: Vec<(wgpu::Buffer, wgpu::Buffer, u32)> = self
            .slow
            .iter()
            .map(|s| {
                let vbuf = self
                    .gpu
                    .device
                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
                        label: Some("slow-path vertices"),
                        contents: bytemuck::cast_slice(&s.vertices),
                        usage: wgpu::BufferUsages::VERTEX,
                    });
                let ibuf = self
                    .gpu
                    .device
                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
                        label: Some("slow-path indices"),
                        contents: bytemuck::cast_slice(&s.indices),
                        usage: wgpu::BufferUsages::INDEX,
                    });
                (vbuf, ibuf, s.indices.len() as u32)
            })
            .collect();

        let mut encoder = self
            .gpu
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("frame encoder"),
            });

        let mut draw_calls = 0u64;
        {
            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("sprite pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &view,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color {
                            r: self.clear.r as f64,
                            g: self.clear.g as f64,
                            b: self.clear.b as f64,
                            a: self.clear.a as f64,
                        }),
                        store: wgpu::StoreOp::Store,
                    },
                    // wgpu 29: required; None = not rendering to a 3D slice.
                    depth_slice: None,
                })],
                // v0.3: the depth buffer rides every pass. Cleared to the
                // far plane (1.0), like glClearDepth.
                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
                    view: &self.depth_view,
                    depth_ops: Some(wgpu::Operations {
                        load: wgpu::LoadOp::Clear(1.0),
                        store: wgpu::StoreOp::Store,
                    }),
                    stencil_ops: None,
                }),
                timestamp_writes: None,
                occlusion_query_set: None,
                // wgpu 29: required; None = not a multiview (layered) pass.
                multiview_mask: None,
            });

            pass.set_pipeline(&self.pipeline);
            // The shared quad and the instance buffer are set ONCE.
            pass.set_vertex_buffer(0, self.quad_vbuf.slice(..)); // per-vertex data
            pass.set_vertex_buffer(1, self.instance_buf.buf.slice(..)); // per-INSTANCE
            pass.set_index_buffer(self.quad_ibuf.slice(..), wgpu::IndexFormat::Uint32);

            // chunk_by yields each maximal run of one (camera, texture):
            // one draw call each. With an atlas: one run, one call, period.
            let mut first = 0u32;
            let mut bound_cam = u32::MAX;
            for run in self
                .batch
                .chunk_by(|a, b| a.cam == b.cam && a.texture == b.texture)
            {
                let n = run.len() as u32;
                if run[0].cam != bound_cam {
                    pass.set_bind_group(0, &self.cam_slots[run[0].cam as usize].bind_group, &[]);
                    bound_cam = run[0].cam;
                }
                if let Some(gpu) = self.textures.get(&run[0].texture) {
                    pass.set_bind_group(1, &gpu.bind_group, &[]); // texture + sampler only
                    pass.draw_indexed(0..6, 0, first..first + n); // ONE call, n sprites
                    draw_calls += 1;
                }
                first += n; // advance even if the texture was missing
            }

            // Slow path: arbitrary meshes, one single-instance draw each,
            // reading their record from the same instance buffer. The
            // material's shading picks the pipeline: an exhaustive match,
            // so a third shading model cannot be forgotten here.
            for (i, s) in self.slow.iter().enumerate() {
                match s.shading {
                    Shading::Unlit => pass.set_pipeline(&self.pipeline),
                    Shading::Lit => {
                        pass.set_pipeline(&self.lit_pipeline);
                        pass.set_bind_group(2, &self.light_bind_group, &[]);
                    }
                }
                if s.cam != bound_cam {
                    pass.set_bind_group(0, &self.cam_slots[s.cam as usize].bind_group, &[]);
                    bound_cam = s.cam;
                }
                if let Some(gpu) = self.textures.get(&s.texture) {
                    let (vbuf, ibuf, index_count) = &slow_bufs[i];
                    pass.set_bind_group(1, &gpu.bind_group, &[]);
                    pass.set_vertex_buffer(0, vbuf.slice(..));
                    pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
                    let idx = slow_first + i as u32;
                    pass.draw_indexed(0..*index_count, 0, idx..idx + 1);
                    draw_calls += 1;
                }
            }
        }

        self.gpu.queue.submit(std::iter::once(encoder.finish()));
        frame.present();

        // Once-a-second honesty: frames actually PRESENTED (the acquire
        // early-outs above never reach here) and draw calls per frame.
        // "Log the run count next to the frame time to prove it."
        self.stat_frames += 1;
        self.stat_draws += draw_calls;
        let elapsed = self.stat_since.elapsed().as_secs_f32();
        if elapsed >= 1.0 {
            log::info!(
                "presented {} frames in {:.2}s ({:.2} ms/frame), {} draw calls/frame",
                self.stat_frames,
                elapsed,
                elapsed * 1000.0 / self.stat_frames as f32,
                self.stat_draws / self.stat_frames as u64,
            );
            self.stat_since = std::time::Instant::now();
            self.stat_frames = 0;
            self.stat_draws = 0;
        }
        Ok(())
    }

    fn resize(&mut self, width: u32, height: u32) {
        if width == 0 || height == 0 {
            return;
        }
        self.gpu.resize(width, height);
        // v0.3: the depth texture shadows the surface size.
        self.depth_view = Self::create_depth(&self.gpu.device, width, height);
    }

    fn size(&self) -> (u32, u32) {
        self.gpu.size()
    }
}
