//! Backend GPU directo (Fases 2 + 3 del SDD §"GPU directo wgpu"). //! //! Cuatro pipelines `wgpu` cacheadas en [`GpuPipelines`] (lines / tris / //! rects / discs) + un acumulador [`GpuBatch`] que las apps usan por frame para //! emitir centenares de miles a millones de primitivos en una draw call //! por tipo, sin pasar por vello. //! //! Diseño minimal Fase 2/3: //! //! - Vertex format triángulos: `[x, y: f32, color: 4×f32]` (24 B/vert). //! - Instance format líneas: `[x0, y0, x1, y1, color: 4×f32]` (32 B/seg). //! - Instance format rects: `[x, y, w, h, color: 4×f32]` (32 B/rect). //! - Instance format discos: `[cx, cy, r, stroke, color: 4×f32]` (32 B/disco). //! - El color va como **4×f32** (no u32 empaquetado): preserva precisión HDR //! (>1.0) y sub-8-bit cuando el target es `Rgba16Float`/`Rgb10a2`; en target //! 8-bit el resultado visible es idéntico al pack anterior. //! - Sin texturas. Rects/líneas/tris obtienen AA de **bordes** vía MSAA 4× //! (ver más abajo); los discos SÍ traen AA por SDF en el fragment //! (smoothstep sobre `fwidth`), que MSAA respeta. Así rects/tris/líneas //! instanciados salen con bordes suaves sin que el caller toque nada. //! - Blending alfa habilitado: el alpha del color es respetado. //! - El viewport `(width, height)` se pasa al flush y va en un uniform — //! los shaders convierten pixel → NDC ahí. //! //! Cache de pipelines: una sola instancia de `GpuPipelines` por //! `(device, color_format)`. Construirla compila los 3 pipelines en //! caliente (~ms en hardware moderno). Los callers la mantienen viva //! entre frames (en su Model o vía `OnceLock`). //! //! Grow strategy: `flush` crea un buffer por tipo no vacío en el //! mismo frame. Sin reuso entre frames — Fase 4 (`GpuSceneCanvas`) //! introducirá el `GpuBuffers` persistente que dobla capacidad si //! aparece la necesidad. //! //! Techo de buffer: `max_buffer_size` es un límite duro del dispositivo //! (256 MB en una Iris Xe = ~8,4 M de primitivas a 32 B). Pasado eso, wgpu //! **rechaza** la reserva; no es "va más lento", es que no anda. `flush` //! parte cada tipo en lotes consecutivos ([`units_per_chunk`]) y dibuja uno //! por lote conservando el orden, así que el alpha-over sale idéntico. En el //! caso normal —todo entra— es un lote y las mismas cuatro draw calls. //! //! ## MSAA 4× (antialiasing de bordes) //! //! El pase no dibuja directo sobre el `view` que recibe `flush`. En su //! lugar rasteriza todos los primitivos a una textura **multisample 4×** //! (cleared a transparente), la *resuelve* a una textura single-sample //! scratch y **compone con alpha** ese resultado sobre el `view`. Así: //! //! - Los bordes de rects/tris/líneas quedan suaves (4 muestras/pixel), //! no escalonados. //! - El contenido previo del `view` (lo que vello pintó) se preserva, //! porque el composite es alpha-over con `LoadOp::Load` — exactamente //! la semántica que tenía el viejo render pass directo con `LoadOp::Load`. //! - `LoadOp::Clear(c)` se respeta: el `view` se limpia a `c` antes del //! composite (equivalente a la pasada directa anterior). //! //! Backward-compat: la firma pública de `flush` / `GpuPipelines::new` no //! cambia. Las texturas MSAA + scratch se crean por-flush dimensionadas //! **al rect sucio** ([`GpuBatch::scissor`]) o, si no hay, al `viewport` //! entero; así el resize "sale gratis" — cada frame usa el tamaño que se le //! pasa, sin estado persistente que recrear. Medido: crear el par de texturas //! a 1920×1080 son 6,8 µs, así que cachearlas no compraría nada. El pipeline //! de composite se compila una vez y se cachea en `GpuPipelines` (es `Sync`, //! vive en `OnceLock`). //! //! Que las intermedias sigan al rect y no al viewport es lo que hace que //! `scissor` valga: el `LoadOp::Clear` del attachment y el resolve del MSAA //! son operaciones de **toda** la superficie —el scissor es estado de //! rasterización, no toca los load/store ops—, así que con texturas de //! pantalla completa pintar un cursor de 32 px seguía costando limpiar y //! resolver 1920×1080 en 4 muestras. Medido antes y después, mismo escenario: //! el ahorro pasó de **14,8 % a 97,9 %**. use llimphi_hal::wgpu; use vello::peniko::Color; /// Número de muestras del MSAA del pase GPU. 4× es el punto dulce /// universal (soportado por todo hardware moderno, coste moderado). const MSAA_SAMPLES: u32 = 4; /// Bytes por instancia de rect: `x, y, w, h` (4 × f32) + color (4 × f32). /// /// Es **público y una sola constante** porque quien arme un buffer de /// instancias a mano —el bench standalone, un caller que quiera reusar el /// buffer entre frames— tiene que empaquetar exactamente esto, y adivinarlo /// sale caro y en silencio: cuando el color del vértice se ensanchó de un `u32` /// empaquetado a `Float32x4` (2026-07-13) el stride pasó de 20 a 32 B, el bench /// siguió escribiendo 20, y wgpu rechazó todos sus draws con /// «Instance N extends beyond limit N×0,625». El bench no dibujó nada durante /// semanas y reportó 23.000 fps por ello. pub const RECT_INSTANCE_STRIDE: u32 = 32; /// Bytes por instancia de línea: `x0, y0, x1, y1` + color (4 × f32) + grosor. /// /// El grosor viaja **por instancia** desde 2026-08-05. Antes era un uniforme del /// lote entero, así que una grilla fina y una curva gruesa no podían convivir en /// un `flush`: había que cortar el lote, y cada `flush` arma su propia textura /// MSAA 4×, resuelve y compone. O sea que «una draw call para todo» dejaba de /// serlo apenas el dibujo tenía dos grosores — que es cualquier gráfico. pub const LINE_INSTANCE_STRIDE: u32 = 36; /// Bytes por instancia de disco/anillo: `cx, cy, r, stroke` + color (4 × f32). pub const DISC_INSTANCE_STRIDE: u32 = 32; /// Bytes por **vértice** de triángulo: `x, y` + color (4 × f32). Ojo que acá la /// unidad es el vértice, no la primitiva: el triangle-list va sin instanciar y /// se dibuja de a 3. pub const TRI_VERTEX_STRIDE: u32 = 24; /// Bytes por instancia de quad texturado: `x, y, w, h` + `u0, v0, u1, v1` + /// color (4 × f32). /// /// Es el formato del texto en el camino directo: un glifo es un quad con la UV /// de su mapa de cobertura dentro del atlas. 48 B por letra, y un renglón de 80 /// caracteres entra en una draw call — contra 80 contornos rellenos por vello. pub const QUAD_INSTANCE_STRIDE: u32 = 48; /// Cuántas unidades entran en un buffer de `max_bytes`, redondeando a un borde /// válido. /// /// «Unidad» es la instancia en lines/rects/discs y el **vértice** en el /// triangle-list; por eso `multiplo`: cortar el buffer de triángulos en un /// múltiplo de 3 no es cosmético, un corte a mitad de triángulo dibujaría /// basura con los dos vértices que le quedan del lote siguiente. /// /// Nunca devuelve 0 —eso sería un bucle infinito de lotes vacíos—: si ni una /// unidad entra en el límite, devuelve `multiplo` y deja que wgpu se queje con /// su propio mensaje, que es más informativo que un cuelgue nuestro. pub fn units_per_chunk(stride: u32, max_bytes: u64, multiplo: u32) -> u32 { let multiplo = multiplo.max(1); let cabe = (max_bytes / stride.max(1) as u64).min(u32::MAX as u64) as u32; (cabe / multiplo * multiplo).max(multiplo) } /// Pipelines cacheadas. Crear uno por proceso (o por surface format). /// /// Para uso típico via [`GpuBatch`] los campos no se tocan directo. La /// API pública existe para callers avanzados que quieran montar su propio /// buffer persistente (datos que no cambian por frame: starfield Gaia, /// particles iniciales, viewport estático) y emitir draw calls /// manualmente reusando estas pipelines. /// /// Layouts (color = 4×f32, HDR-capable): /// - Vertex buffer triángulos: `[x, y, color:4×f32]` (24 B/vert). /// - Instance buffer rects: `[x, y, w, h, color:4×f32]` (32 B/inst). /// - Instance buffer líneas: `[x0, y0, x1, y1, color:4×f32, w]` (36 B/inst). /// - Bind group 0 binding 0: uniform `{dst_size: vec2, origin: vec2, /// _pad: vec4}` (32 B). `dst_size` es la textura intermedia y /// `origin` su esquina en coordenadas de viewport: los callers siguen /// hablando en píxeles de pantalla y el corrimiento lo hace el shader. pub struct GpuPipelines { pub lines: wgpu::RenderPipeline, pub tris: wgpu::RenderPipeline, pub rects: wgpu::RenderPipeline, /// Discos/anillos rellenos con AA por SDF en el fragment. Instance /// format: `[cx, cy, r, stroke, rgba]` (20 B/disco). `stroke <= 0` /// → disco lleno; `stroke > 0` → anillo de ese grosor (px). Ver /// [`GpuBatch::add_disc`] / [`GpuBatch::add_ring`]. pub discs: wgpu::RenderPipeline, /// Quads texturados contra un atlas de cobertura de un canal — el camino /// del **texto** en el path directo. Instance format: /// `[x, y, w, h, u0, v0, u1, v1, rgba]` (48 B/quad). Ver /// [`GpuBatch::add_glyph`] y [`crate::gpu_atlas::AtlasTextura`]. pub quads: wgpu::RenderPipeline, pub bind_layout: wgpu::BindGroupLayout, /// Layout del grupo 1 del pipeline de quads: la textura del atlas + su /// sampler. Sólo lo usa `quads`; los otros cuatro pipelines siguen con un /// solo grupo, así que no pagan nada por esto. atlas_bgl: wgpu::BindGroupLayout, atlas_sampler: wgpu::Sampler, /// Pipeline de pantalla completa que compone (alpha-over) la textura /// scratch resuelta del MSAA sobre el `view` del `flush`. Single-sample. /// El formato del target es el `color_format` con el que se construyó. composite: wgpu::RenderPipeline, composite_bgl: wgpu::BindGroupLayout, composite_sampler: wgpu::Sampler, /// Formato de color del target — necesario para crear las texturas /// MSAA/scratch del `flush` con el mismo formato que el `view`. color_format: wgpu::TextureFormat, } impl GpuPipelines { /// Compila los 3 pipelines apuntando al `color_format` del target /// que recibirán en `flush` (el de la intermediate de `WinitSurface`, /// normalmente `Rgba8Unorm`). pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("llimphi-raster-gpu-shader"), source: wgpu::ShaderSource::Wgsl(WGSL.into()), }); let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-raster-gpu-bgl"), 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 pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("llimphi-raster-gpu-pl"), bind_group_layouts: &[Some(&bind_layout)], immediate_size: 0, }); let color_targets = [Some(wgpu::ColorTargetState { format: color_format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })]; // Triángulos (vertex buffer plano, color per-vertex). let tris = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-raster-gpu-tris"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_tris"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { array_stride: 24, step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 0, shader_location: 0, }, wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 8, shader_location: 1, }, ], }], }, primitive: tri_primitive(), depth_stencil: None, multisample: wgpu::MultisampleState { count: MSAA_SAMPLES, ..Default::default() }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs"), compilation_options: Default::default(), targets: &color_targets, }), multiview_mask: None, cache: None, }); // Rects (instanced quad). let rects = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-raster-gpu-rects"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_rects"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { array_stride: RECT_INSTANCE_STRIDE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &[ wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 0, shader_location: 0, }, wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 8, shader_location: 1, }, wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 2, }, ], }], }, primitive: tri_primitive(), depth_stencil: None, multisample: wgpu::MultisampleState { count: MSAA_SAMPLES, ..Default::default() }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs"), compilation_options: Default::default(), targets: &color_targets, }), multiview_mask: None, cache: None, }); // Líneas con grosor: cada segmento es una instancia de 20 B; el // VS expande a un quad de 6 vértices perpendicular al segmento // usando un grosor uniforme en píxeles (vienen del uniform). let lines = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-raster-gpu-lines"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_lines"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { // Del `const`, no un 32 a mano: el stride escrito dos veces // fue lo que dejó al bench midiendo draws rechazados. array_stride: LINE_INSTANCE_STRIDE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &[ wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 0, shader_location: 0, }, wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 1, }, // Grosor de ESTA línea, en px. wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32, offset: 32, shader_location: 2, }, ], }], }, primitive: tri_primitive(), depth_stencil: None, multisample: wgpu::MultisampleState { count: MSAA_SAMPLES, ..Default::default() }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs"), compilation_options: Default::default(), targets: &color_targets, }), multiview_mask: None, cache: None, }); // Discos/anillos (instanced quad + SDF AA en el fragment). Cada // disco es una instancia de 24 B: `[cx, cy, r, stroke, rgba]`. El // VS expande un quad que cubre el disco (con 1 px de margen para // que el smoothstep del borde no se recorte) y pasa al FS la // posición local en px; el FS calcula la distancia al centro y // hace smoothstep sobre ~1 px (`fwidth`) → borde antialiased. let discs = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-raster-gpu-discs"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_discs"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { array_stride: 32, step_mode: wgpu::VertexStepMode::Instance, attributes: &[ // cx, cy wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 0, shader_location: 0, }, // r, stroke wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 8, shader_location: 1, }, // rgba (4×f32, HDR-capable) wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 2, }, ], }], }, primitive: tri_primitive(), depth_stencil: None, multisample: wgpu::MultisampleState { count: MSAA_SAMPLES, ..Default::default() }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_disc"), compilation_options: Default::default(), targets: &color_targets, }), multiview_mask: None, cache: None, }); // Quads texturados (el texto). Grupo 0: el uniform de siempre. // Grupo 1: la textura del atlas de cobertura + su sampler. // // El sampler es **Nearest** y no es una economía: los quads de glifo se // colocan en coordenadas enteras y el desplazamiento subpíxel viaja // horneado en el mapa (`llimphi-glifos` rasteriza una variante por // fracción), así que el mapeo textura→pantalla es exactamente 1:1. // Filtrar ahí no agregaría suavidad: sólo emborronaría un mapa que ya // trae su propio antialiasing. let atlas_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-raster-gpu-atlas-bgl"), 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, }, ], }); let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("llimphi-raster-gpu-atlas-sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Nearest, min_filter: wgpu::FilterMode::Nearest, ..Default::default() }); let quads_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("llimphi-raster-gpu-quads-pl"), bind_group_layouts: &[Some(&bind_layout), Some(&atlas_bgl)], immediate_size: 0, }); let quads = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-raster-gpu-quads"), layout: Some(&quads_pl), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_quads"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { array_stride: QUAD_INSTANCE_STRIDE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &[ // x, y, w, h wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 0, shader_location: 0, }, // u0, v0, u1, v1 wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 1, }, // rgba wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 32, shader_location: 2, }, ], }], }, primitive: tri_primitive(), depth_stencil: None, multisample: wgpu::MultisampleState { count: MSAA_SAMPLES, ..Default::default() }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_quad"), compilation_options: Default::default(), targets: &color_targets, }), multiview_mask: None, cache: None, }); // Pipeline de composite (alpha-over) de la scratch resuelta del // MSAA sobre el `view`. Single-sample (count = 1), pase fullscreen // de un triángulo. Asume alpha **premultiplicado** — el MSAA + el // blending de los primitivos producen color premultiplicado, así // que el over correcto es `src.rgb*1 + dst.rgb*(1-src.a)`. let composite_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-raster-gpu-composite-bgl"), 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, }, // Dónde va el blit dentro del `view`. Sin scissor es el // viewport entero; con scissor, el rect sucio. wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, ], }); let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("llimphi-raster-gpu-composite-pl"), bind_group_layouts: &[Some(&composite_bgl)], immediate_size: 0, }); let composite = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-raster-gpu-composite"), layout: Some(&composite_pl), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_composite"), compilation_options: Default::default(), buffers: &[], }, primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_composite"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: color_format, blend: Some(wgpu::BlendState { color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, operation: wgpu::BlendOperation::Add, }, alpha: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, operation: wgpu::BlendOperation::Add, }, }), write_mask: wgpu::ColorWrites::ALL, })], }), multiview_mask: None, cache: None, }); let composite_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("llimphi-raster-gpu-composite-sampler"), ..Default::default() }); Self { lines, tris, rects, discs, quads, bind_layout, atlas_bgl, atlas_sampler, composite, composite_bgl, composite_sampler, color_format, } } /// Layout del grupo 1 del pipeline de quads. Lo necesita /// [`crate::gpu_atlas::AtlasTextura`] para armar su bind group. pub fn atlas_layout(&self) -> &wgpu::BindGroupLayout { &self.atlas_bgl } /// El sampler del atlas (Nearest + ClampToEdge). Uno solo para todos los /// atlas: no tiene estado por textura. pub fn atlas_sampler(&self) -> &wgpu::Sampler { &self.atlas_sampler } } fn tri_primitive() -> wgpu::PrimitiveState { wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: None, unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, } } /// Acumulador de primitivas por frame. Construir → `add_*` → `flush`. pub struct GpuBatch<'a> { pipelines: &'a GpuPipelines, line_verts: Vec, tri_verts: Vec, rect_insts: Vec, disc_insts: Vec, quad_insts: Vec, line_width: f32, line_count: u32, tri_vert_count: u32, rect_count: u32, disc_count: u32, quad_count: u32, /// El atlas contra el que se muestrean los quads. Sin él los quads /// acumulados **no se dibujan**: no hay de dónde sacar la cobertura. atlas: Option<&'a crate::gpu_atlas::AtlasTextura>, /// Rect de recorte (x, y, w, h) en px del target. `None` = pinta todo el /// viewport (comportamiento histórico). Cuando está seteado, ambas pasadas /// (primitivos y composite) se restringen a ese rect → sólo se tocan los /// píxeles sucios: es el habilitador de **damage / partial-present**. scissor: Option<(u32, u32, u32, u32)>, /// Techo por buffer en bytes. `None` = el de la GPU /// (`device.limits().max_buffer_size`). Ver [`GpuBatch::max_buffer_bytes`]. max_buffer_bytes: Option, } impl<'a> GpuBatch<'a> { pub fn new(pipelines: &'a GpuPipelines) -> Self { Self { pipelines, line_verts: Vec::new(), tri_verts: Vec::new(), rect_insts: Vec::new(), disc_insts: Vec::new(), quad_insts: Vec::new(), line_width: 1.0, line_count: 0, tri_vert_count: 0, rect_count: 0, disc_count: 0, quad_count: 0, atlas: None, scissor: None, max_buffer_bytes: None, } } /// Restringe el `flush` a un rect (x, y, w, h) en px del target: sólo se /// rasterizan y componen los píxeles dentro del rect, el resto del `view` /// queda intacto. Es el primitivo de **damage / partial-present**: cuando /// sólo cambió una región chica (un cursor, un spinner, un nodo animado), el /// caller setea el rect sucio y evita re-pintar la pantalla entera. El rect /// se clampea al viewport en `flush` (un rect fuera de rango no revienta). /// Sin llamar a esto, `flush` pinta todo el viewport (histórico). /// /// Las coordenadas son siempre **de viewport**: el rect se pide donde se ve, /// no relativo a nada. `flush` baja el origen a par —por las derivadas del /// AA, ver el comentario ahí— así que el área efectivamente tocada puede /// tener una fila y una columna de más; con `LoadOp::Load` eso se repinta /// con lo mismo que ya había. pub fn scissor(&mut self, x: u32, y: u32, w: u32, h: u32) { self.scissor = Some((x, y, w, h)); } /// Fuerza el tamaño máximo de cada buffer de este `flush`, en bytes. /// /// Por defecto se toma de `device.limits().max_buffer_size` y no hay que /// tocarlo. Existe por dos razones: **testear el troceado sin reservar /// cientos de MB** (bajarlo a un puñado de bytes obliga a partir una escena /// mínima), y dejarle al caller la puerta de bajar el techo si su plataforma /// se porta mal con reservas grandes. pub fn max_buffer_bytes(&mut self, bytes: u64) { self.max_buffer_bytes = Some(bytes.max(1)); } /// Grosor de las líneas que se agreguen **a partir de acá** (px del frame, /// sin AA). Es una pluma: se cambia las veces que haga falta y cada línea se /// queda con el que estaba puesto cuando se la agregó. /// /// **Cambió en 2026-08-05.** Antes era un uniforme del lote, así que valía /// para todas las líneas del `flush` sin importar el orden de las llamadas — /// y dibujar dos grosores obligaba a partir en dos `flush`, cada uno con su /// textura MSAA 4×, su resolve y su composite. Una grilla con una curva /// encima es el caso más común de un gráfico, y era justo el que no entraba /// en «una draw call para todo». pub fn line_width(&mut self, w: f32) { self.line_width = w; } /// Añade un segmento de línea como instancia. pub fn add_line(&mut self, p0: (f32, f32), p1: (f32, f32), color: Color) { self.line_verts.extend_from_slice(&p0.0.to_ne_bytes()); self.line_verts.extend_from_slice(&p0.1.to_ne_bytes()); self.line_verts.extend_from_slice(&p1.0.to_ne_bytes()); self.line_verts.extend_from_slice(&p1.1.to_ne_bytes()); push_color(&mut self.line_verts, color); self.line_verts .extend_from_slice(&self.line_width.to_ne_bytes()); self.line_count += 1; } /// Añade una polilínea como secuencia de segmentos individuales /// (line-list). Para N puntos emite N-1 instancias. pub fn add_polyline(&mut self, points: &[(f32, f32)], color: Color) { if points.len() < 2 { return; } for w in points.windows(2) { self.add_line(w[0], w[1], color); } } /// Añade un triángulo con color por vértice. pub fn add_tri( &mut self, a: (f32, f32), b: (f32, f32), c: (f32, f32), ca: Color, cb: Color, cc: Color, ) { self.push_tri_vert(a, ca); self.push_tri_vert(b, cb); self.push_tri_vert(c, cc); } fn push_tri_vert(&mut self, p: (f32, f32), color: Color) { self.tri_verts.extend_from_slice(&p.0.to_ne_bytes()); self.tri_verts.extend_from_slice(&p.1.to_ne_bytes()); push_color(&mut self.tri_verts, color); self.tri_vert_count += 1; } /// Añade un triangle list crudo `[(x, y); 3*N]` con un mismo color /// uniforme por vértice. Útil para teselaciones precomputadas /// (contornos, polígonos rellenos). pub fn add_tri_list(&mut self, verts: &[(f32, f32)], color: Color) { for &p in verts { self.push_tri_vert(p, color); } } /// Añade un rectángulo lleno como instancia (sin radio — para /// rounded rects sigue por vello). pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) { self.rect_insts.extend_from_slice(&x.to_ne_bytes()); self.rect_insts.extend_from_slice(&y.to_ne_bytes()); self.rect_insts.extend_from_slice(&w.to_ne_bytes()); self.rect_insts.extend_from_slice(&h.to_ne_bytes()); push_color(&mut self.rect_insts, color); self.rect_count += 1; } /// Añade un disco (círculo relleno) con AA por shader como instancia. /// `(cx, cy)` es el centro y `r` el radio, ambos en pixels del frame. /// El borde queda antialiased vía un SDF + `smoothstep` de ~1 px en /// el fragment — no escalonado, sin MSAA. El alpha del color se /// respeta (blending alfa activo). pub fn add_disc(&mut self, cx: f32, cy: f32, r: f32, color: Color) { self.push_disc(cx, cy, r, 0.0, color); } /// Añade un anillo (círculo hueco / stroke circular) con AA por /// shader. `r` es el radio exterior; `stroke` el grosor del trazo en /// px (el agujero interior tiene radio `r - stroke`). `stroke <= 0` /// degenera en un disco lleno. Ambos bordes (externo e interno) /// quedan antialiased. pub fn add_ring(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) { self.push_disc(cx, cy, r, stroke.max(0.0), color); } fn push_disc(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) { self.disc_insts.extend_from_slice(&cx.to_ne_bytes()); self.disc_insts.extend_from_slice(&cy.to_ne_bytes()); self.disc_insts.extend_from_slice(&r.to_ne_bytes()); self.disc_insts.extend_from_slice(&stroke.to_ne_bytes()); push_color(&mut self.disc_insts, color); self.disc_count += 1; } /// Fija el atlas de cobertura contra el que se muestrean los quads. /// /// Sin esto, los quads acumulados con [`GpuBatch::add_glyph`] **no se /// dibujan** — silenciosamente, y a propósito: la alternativa sería un /// panic dentro de `flush`, o sea reventar el frame de una app porque /// alguien pidió texto antes de tener el atlas listo. Que no aparezca el /// texto es un síntoma leíble; que se caiga la ventana, no. pub fn atlas(&mut self, atlas: &'a crate::gpu_atlas::AtlasTextura) { self.atlas = Some(atlas); } /// Un quad texturado: `(x, y, w, h)` en px de pantalla, `uv` como /// `(u0, v0, u1, v1)` normalizado en el atlas, y el color con el que se /// tiñe la cobertura. /// /// El atlas guarda **cobertura**, no color: el quad sale del color pedido /// con su alfa multiplicado por lo que dice el atlas. Por eso el mismo /// glifo cacheado sirve para pintar en cualquier color sin re-rasterizar. pub fn add_glyph( &mut self, x: f32, y: f32, w: f32, h: f32, uv: (f32, f32, f32, f32), color: Color, ) { for v in [x, y, w, h, uv.0, uv.1, uv.2, uv.3] { self.quad_insts.extend_from_slice(&v.to_ne_bytes()); } push_color(&mut self.quad_insts, color); self.quad_count += 1; } /// Cuenta total de primitivas pendientes (útil para benches). pub fn primitive_count(&self) -> u32 { self.line_count + self.rect_count + self.disc_count + self.quad_count + self.tri_vert_count / 3 } /// Despacha las primitivas acumuladas como 1 draw call por tipo /// no vacío contra `view`. `viewport` es el tamaño en pixels del /// target (lo usa el VS para mapear pixel → NDC). /// /// `load_op` decide si la pasada conserva el contenido previo /// (`Load`, lo normal cuando vello ya pintó algo) o limpia /// (`Clear(color)`). Apps que llamen a `GpuBatch` desde /// `gpu_paint_with` quieren `Load`. pub fn flush( self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView, viewport: (f32, f32), load_op: wgpu::LoadOp, ) { // Los quads sólo cuentan si hay atlas: sin él no se pueden dibujar, y // sumarlos acá haría que un batch de puro texto sin atlas armara las // texturas MSAA y las dos pasadas para no pintar nada. let quads_vivos = if self.atlas.is_some() { self.quad_count } else { 0 }; let total = self.line_count + self.tri_vert_count + self.rect_count + self.disc_count + quads_vivos; if total == 0 { return; } // Rect de damage clampeado al viewport, ANTES que nada: de él salen el // tamaño de las texturas intermedias y el corrimiento que va al shader. // Si se pidió scissor pero clampea a área 0 (región sucia fuera de // rango), no hay nada que pintar → salir. let vp_w = (viewport.0.round() as u32).max(1); let vp_h = (viewport.1.round() as u32).max(1); let rect: (u32, u32, u32, u32) = match self.scissor { None => (0, 0, vp_w, vp_h), Some((x, y, w, h)) => { let x = x.min(vp_w); let y = y.min(vp_h); let w = w.min(vp_w.saturating_sub(x)); let h = h.min(vp_h.saturating_sub(y)); if w == 0 || h == 0 { return; } // **El origen se baja a par.** El AA del disco sale de // `fwidth`, y las derivadas se calculan por bloques de 2×2 // fragmentos alineados a la grilla del *target*. Correr el // origen un número impar de píxeles reagrupa esos bloques: el // píxel se aparea con otro vecino, la diferencia finita cambia y // el borde antialiaseado sale distinto que en la pasada // completa. Medido: hasta 11/255 de diferencia en la orla del // disco — invisible en un frame suelto, una costura al mover el // rect sucio de cuadro en cuadro. Con el origen par la paridad // se conserva y el reparto en bloques es idéntico. // // Cuesta a lo sumo una fila y una columna de más, que con // `LoadOp::Load` se repintan con lo mismo que ya tenían. let (x0, y0) = (x & !1, y & !1); let w = (w + (x - x0)).min(vp_w - x0); let h = (h + (y - y0)).min(vp_h - y0); (x0, y0, w, h) } }; // Uniforms: [target.w, target.h, origin.x, origin.y, 0,0,0,0]. El // grosor de línea ya no vive acá: viaja por instancia. // `target` es la textura intermedia (= el rect sucio) y `origin` su // esquina, así que el caller sigue hablando en coordenadas de viewport // y el corrimiento lo hace el vertex shader. let u_data = [ rect.2 as f32, rect.3 as f32, rect.0 as f32, rect.1 as f32, 0.0, 0.0, 0.0, 0.0, ]; let mut u_bytes = Vec::with_capacity(32); for v in u_data { u_bytes.extend_from_slice(&v.to_ne_bytes()); } let uniforms = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-raster-gpu-u"), size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(&uniforms, 0, &u_bytes); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-raster-gpu-bg"), layout: &self.pipelines.bind_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniforms.as_entire_binding(), }], }); // Buffers por tipo (sólo si hay datos), **troceados** al techo de la // GPU. Una sola draw call para todo es el punto del camino directo, // pero `max_buffer_size` es un límite duro (256 MB en esta Iris Xe): a // 32 B por rect, 8,4 M de primitivas y wgpu rechaza la reserva. Antes // eso no era «más lento», era **no anda**; ahora la escena se parte en // lotes consecutivos y se dibuja igual. El orden entre lotes se // conserva, así que el alpha-over sale idéntico al de un buffer único. let techo = self .max_buffer_bytes .unwrap_or_else(|| device.limits().max_buffer_size); let lines_bufs = chunked_buffers( device, queue, "llimphi-raster-gpu-lines-buf", &self.line_verts, LINE_INSTANCE_STRIDE, 1, techo, ); let tris_bufs = chunked_buffers( device, queue, "llimphi-raster-gpu-tris-buf", &self.tri_verts, TRI_VERTEX_STRIDE, 3, techo, ); let rects_bufs = chunked_buffers( device, queue, "llimphi-raster-gpu-rects-buf", &self.rect_insts, RECT_INSTANCE_STRIDE, 1, techo, ); let discs_bufs = chunked_buffers( device, queue, "llimphi-raster-gpu-discs-buf", &self.disc_insts, DISC_INSTANCE_STRIDE, 1, techo, ); let quads_bufs = if self.atlas.is_some() { chunked_buffers( device, queue, "llimphi-raster-gpu-quads-buf", &self.quad_insts, QUAD_INSTANCE_STRIDE, 1, techo, ) } else { Vec::new() }; // ── MSAA 4× ────────────────────────────────────────────────── // Texturas por-flush dimensionadas **al rect sucio**, no al viewport. // // Antes iban al viewport y el recorte se hacía con `set_scissor_rect`. // Eso recortaba lo único que el scissor puede recortar —el sombreado de // los fragmentos—, pero el `LoadOp::Clear` del attachment y el resolve // del MSAA son operaciones de **toda** la superficie por definición (el // scissor es estado de rasterización, no toca los load/store ops). O sea // que pintar un cursor de 32 px seguía costando limpiar y resolver // 1920×1080 en 4 muestras. Medido acá antes de cambiarlo: pintar el // 0,05 % de la pantalla ahorraba **14,8 %**, no 99 %. // // Con la textura del tamaño del rect, el clear y el resolve pasan a ser // del rect. El `set_scissor_rect` se conserva igual como cinturón: si // alguna vez el quad del composite no casara exacto, no puede sangrar. let extent = wgpu::Extent3d { width: rect.2, height: rect.3, depth_or_array_layers: 1, }; let scissor_px = self.scissor.map(|_| rect); let fmt = self.pipelines.color_format; // Color attachment multisample: lo rasterizan los 4 pipelines. let msaa_tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-raster-gpu-msaa"), size: extent, mip_level_count: 1, sample_count: MSAA_SAMPLES, dimension: wgpu::TextureDimension::D2, format: fmt, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default()); // Scratch single-sample: recibe el resolve del MSAA y luego se // samplea en el composite sobre el `view`. let resolve_tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-raster-gpu-resolve"), size: extent, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: fmt, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let resolve_view = resolve_tex.create_view(&wgpu::TextureViewDescriptor::default()); // Pase de primitivos: MSAA cleared a TRANSPARENT, resuelto al // scratch single-sample. El scratch queda con alpha // **premultiplicado** (el blending alfa de los pipelines sobre // fondo transparente produce `rgb = color*alpha`, `a = alpha`). { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-raster-gpu-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &msaa_view, resolve_target: Some(&resolve_view), depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); pass.set_bind_group(0, &bind_group, &[]); // Sin `set_scissor_rect` acá: el attachment **es** el rect sucio, y // el vertex shader ya corrió las coordenadas por `origin`. Pedir el // scissor en coordenadas de viewport sobre una textura del tamaño // del rect sería, además de redundante, fuera de rango. // Orden de draws: rects (fondo) → discos → tris → lines (encima). // Match de la convención usual "fill abajo, stroke arriba". // Dentro de cada tipo, los lotes van en orden: el caso normal es // uno solo, y ahí esto son las mismas cuatro draw calls de antes. if !rects_bufs.is_empty() { pass.set_pipeline(&self.pipelines.rects); for (buf, n) in &rects_bufs { pass.set_vertex_buffer(0, buf.slice(..)); pass.draw(0..6, 0..*n); } } if !discs_bufs.is_empty() { pass.set_pipeline(&self.pipelines.discs); for (buf, n) in &discs_bufs { pass.set_vertex_buffer(0, buf.slice(..)); pass.draw(0..6, 0..*n); } } if !tris_bufs.is_empty() { pass.set_pipeline(&self.pipelines.tris); for (buf, n) in &tris_bufs { pass.set_vertex_buffer(0, buf.slice(..)); // El triangle-list no instancia: `n` acá son vértices. pass.draw(0..*n, 0..1); } } if !lines_bufs.is_empty() { pass.set_pipeline(&self.pipelines.lines); for (buf, n) in &lines_bufs { pass.set_vertex_buffer(0, buf.slice(..)); pass.draw(0..6, 0..*n); } } // El texto va **último**: sobre el relleno y sobre el trazo, que es // el orden en que se lee una etiqueta encima de un gráfico. if let (Some(atlas), false) = (self.atlas, quads_bufs.is_empty()) { pass.set_pipeline(&self.pipelines.quads); pass.set_bind_group(1, &atlas.bind_group, &[]); for (buf, n) in &quads_bufs { pass.set_vertex_buffer(0, buf.slice(..)); pass.draw(0..6, 0..*n); } } } // Composite del scratch resuelto sobre el `view`. Respeta el // `load_op` recibido: // - `Load` → alpha-over: preserva lo que ya está en `view` // (vello), exactamente como el viejo pase directo. // - `Clear` → limpia `view` al color pedido y luego compone // el scratch encima (mismo resultado que limpiar y // dibujar directo). // Dónde va el blit: el rect destino en px del `view`, más el tamaño del // `view` para pasar a NDC. Sin scissor el rect es el viewport entero y // el quad cubre la pantalla, igual que el triángulo de antes. let cu_data = [ rect.0 as f32, rect.1 as f32, rect.2 as f32, rect.3 as f32, vp_w as f32, vp_h as f32, 0.0, 0.0, ]; let mut cu_bytes = Vec::with_capacity(32); for v in cu_data { cu_bytes.extend_from_slice(&v.to_ne_bytes()); } let composite_u = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-raster-gpu-composite-u"), size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(&composite_u, 0, &cu_bytes); let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-raster-gpu-composite-bg"), layout: &self.pipelines.composite_bgl, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&resolve_view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler( &self.pipelines.composite_sampler, ), }, wgpu::BindGroupEntry { binding: 2, resource: composite_u.as_entire_binding(), }, ], }); let mut cpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-raster-gpu-composite-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { // El blend del pipeline ya hace el alpha-over; con // Load conserva el fondo, con Clear lo borra primero. load: load_op, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); cpass.set_pipeline(&self.pipelines.composite); cpass.set_bind_group(0, &composite_bg, &[]); if let Some((x, y, w, h)) = scissor_px { cpass.set_scissor_rect(x, y, w, h); } cpass.draw(0..6, 0..1); } } /// Empuja un `peniko::Color` como 4×`f32` (RGBA lineal-del-color, 16 B) al /// buffer de vértices/instancias. Se lee `Color::components` **directo** — a /// diferencia de `to_rgba8()`, NO cuantiza a 8-bit ni clampea a [0,1], así que /// un color HDR (componente >1.0) o un paso fino entre dos niveles 8-bit /// sobrevive hasta el target. Cuando el target es 8-bit (`Rgba8Unorm`, el caso /// normal) el resultado visible es idéntico al del pack u32 anterior; la /// precisión extra sólo aparece con un target `Rgba16Float`/`Rgb10a2`. El WGSL /// lo recibe como `vec4` directo (sin `unpack_rgba`). /// Sube `data` a uno o más buffers de vértices, ninguno mayor que `max_bytes`. /// /// Devuelve `(buffer, unidades)` por lote, en orden. Con datos vacíos devuelve /// vacío; en el caso normal —todo entra— devuelve **un** solo lote, así que el /// camino de siempre no paga nada por esto. #[allow(clippy::too_many_arguments)] fn chunked_buffers( device: &wgpu::Device, queue: &wgpu::Queue, label: &str, data: &[u8], stride: u32, multiplo: u32, max_bytes: u64, ) -> Vec<(wgpu::Buffer, u32)> { if data.is_empty() { return Vec::new(); } let por_lote = units_per_chunk(stride, max_bytes, multiplo) as usize; let bytes_por_lote = por_lote * stride as usize; let mut out = Vec::with_capacity(data.len().div_ceil(bytes_por_lote)); for trozo in data.chunks(bytes_por_lote) { let b = device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size: trozo.len() as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(&b, 0, trozo); out.push((b, (trozo.len() / stride as usize) as u32)); } out } fn push_color(buf: &mut Vec, c: Color) { for comp in c.components { buf.extend_from_slice(&comp.to_ne_bytes()); } } const WGSL: &str = r#" struct Uniforms { // Tamaño **de la textura destino** en px. Sin scissor es el viewport; con // scissor es el rect sucio, porque ahí se rasteriza a una textura de ese // tamaño y no a una de pantalla completa. dst_size: vec2, // Esquina del rect sucio en coordenadas del viewport. (0,0) sin scissor. // Los callers siguen dando coordenadas de viewport; el corrimiento lo hace // el shader, así que nadie aguas arriba se entera del recorte. origin: vec2, // Relleno hasta 32 B. Acá vivía `line_width`, que ahora viaja por // instancia: un lote con dos grosores era dos lotes. _pad0: vec4, }; @group(0) @binding(0) var u: Uniforms; struct V2F { @builtin(position) pos: vec4, @location(0) color: vec4, }; fn px_to_ndc(p: vec2) -> vec2 { let q = p - u.origin; return vec2(q.x / u.dst_size.x * 2.0 - 1.0, 1.0 - q.y / u.dst_size.y * 2.0); } // -------- triángulos: 1 vértice = (xy, color) -------- @vertex fn vs_tris(@location(0) xy: vec2, @location(1) color: vec4) -> V2F { var out: V2F; out.pos = vec4(px_to_ndc(xy), 0.0, 1.0); out.color = color; return out; } // -------- rects: 1 instancia = (xy, wh, color), 6 vértices/quad -------- @vertex fn vs_rects( @builtin(vertex_index) vid: u32, @location(0) inst_xy: vec2, @location(1) inst_wh: vec2, @location(2) color: vec4, ) -> V2F { var corners = array, 6>( vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0), ); let local = corners[vid]; let px = inst_xy + local * inst_wh; var out: V2F; out.pos = vec4(px_to_ndc(px), 0.0, 1.0); out.color = color; return out; } // -------- líneas: 1 instancia = (p0xy, p1xy, color), expandida a quad ---- @vertex fn vs_lines( @builtin(vertex_index) vid: u32, @location(0) seg: vec4, @location(1) color: vec4, @location(2) width: f32, ) -> V2F { // Quad perpendicular al segmento, con el grosor de ESTA instancia. // vid 0..5 mapea a los 6 vértices del quad (2 tris). let p0 = seg.xy; let p1 = seg.zw; let dir = normalize(p1 - p0); let n = vec2(-dir.y, dir.x); let half_w = max(width, 0.0) * 0.5; let offsets = array, 6>( vec2(0.0, -half_w), // p0 -n vec2(0.0, half_w), // p0 +n vec2(1.0, half_w), // p1 +n vec2(0.0, -half_w), // p0 -n vec2(1.0, half_w), // p1 +n vec2(1.0, -half_w), // p1 -n ); let o = offsets[vid]; let along = mix(p0, p1, o.x); let across = n * o.y; let px = along + across; var out: V2F; out.pos = vec4(px_to_ndc(px), 0.0, 1.0); out.color = color; return out; } // -------- discos/anillos: 1 instancia = (cxcy, r/stroke, rgba) -------- // // Quad que cubre el disco con 1.5 px de margen (para que el smoothstep // del borde no se recorte). El VS pasa al FS la posición local en px // relativa al centro; el FS evalúa el SDF del círculo y hace smoothstep // sobre `fwidth` → borde antialiased. `stroke > 0` recorta un anillo. struct DiscV2F { @builtin(position) pos: vec4, @location(0) color: vec4, @location(1) local: vec2, // px relativos al centro @location(2) params: vec2, // r, stroke (px) }; @vertex fn vs_discs( @builtin(vertex_index) vid: u32, @location(0) inst_c: vec2, @location(1) inst_rs: vec2, @location(2) color: vec4, ) -> DiscV2F { var corners = array, 6>( vec2(-1.0, -1.0), vec2( 1.0, -1.0), vec2( 1.0, 1.0), vec2(-1.0, -1.0), vec2( 1.0, 1.0), vec2(-1.0, 1.0), ); let r = inst_rs.x; let margin = r + 1.5; // 1.5 px de aire para el AA del borde let local = corners[vid] * margin; let px = inst_c + local; var out: DiscV2F; out.pos = vec4(px_to_ndc(px), 0.0, 1.0); out.color = color; out.local = local; out.params = inst_rs; return out; } @fragment fn fs_disc(in: DiscV2F) -> @location(0) vec4 { let r = in.params.x; let stroke = in.params.y; let dist = length(in.local); // distancia al centro en px // Ancho del filtro AA en px (≈ 1 px en pantalla). let aa = fwidth(dist); // Borde exterior: cobertura 1 dentro de r, 0 fuera de r+aa. var cov = 1.0 - smoothstep(r - aa, r + aa, dist); // Anillo: si hay stroke, recortamos el agujero interior con AA. if (stroke > 0.0) { let inner = max(r - stroke, 0.0); cov = cov * smoothstep(inner - aa, inner + aa, dist); } if (cov <= 0.0) { discard; } return vec4(in.color.rgb, in.color.a * cov); } @fragment fn fs(in: V2F) -> @location(0) vec4 { return in.color; } // -------- quads texturados: 1 instancia = (xywh, uv, rgba) -------- // // El camino del TEXTO. El atlas guarda **cobertura** (un canal), no color: el // fragmento sale del color de la instancia con su alfa multiplicado por lo que // diga el atlas. Por eso el mismo glifo cacheado se pinta de cualquier color // sin volver a rasterizarlo. struct QuadV2F { @builtin(position) pos: vec4, @location(0) color: vec4, @location(1) uv: vec2, }; @group(1) @binding(0) var atlas_tex: texture_2d; @group(1) @binding(1) var atlas_samp: sampler; @vertex fn vs_quads( @builtin(vertex_index) vid: u32, @location(0) xywh: vec4, @location(1) uv: vec4, @location(2) color: vec4, ) -> QuadV2F { var corners = array, 6>( vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0), ); let c = corners[vid]; let px = xywh.xy + c * xywh.zw; var out: QuadV2F; out.pos = vec4(px_to_ndc(px), 0.0, 1.0); out.color = color; out.uv = mix(uv.xy, uv.zw, c); return out; } @fragment fn fs_quad(in: QuadV2F) -> @location(0) vec4 { let cov = textureSample(atlas_tex, atlas_samp, in.uv).r; if (cov <= 0.0) { discard; } return vec4(in.color.rgb, in.color.a * cov); } // -------- composite: blit de la scratch resuelta del MSAA al rect destino --- // // Quad de 6 vértices (sin vertex buffer) colocado exactamente sobre el rect // destino dentro del `view`. Sin scissor el rect es el viewport entero, así // que esto equivale al viejo triángulo de pantalla completa. Samplea la // scratch (alpha **premultiplicado**) y la emite tal cual; el alpha-over real // lo hace el BlendState del pipeline (`One, OneMinusSrcAlpha`). @group(0) @binding(0) var src_tex: texture_2d; @group(0) @binding(1) var src_samp: sampler; struct CompUniforms { rect: vec4, // x, y, w, h — dónde va, en px del `view` vp: vec4, // vw, vh, _, _ }; @group(0) @binding(2) var cu: CompUniforms; struct CompV2F { @builtin(position) pos: vec4, @location(0) uv: vec2, }; @vertex fn vs_composite(@builtin(vertex_index) vid: u32) -> CompV2F { var corners = array, 6>( vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0), ); let c = corners[vid]; let px = cu.rect.xy + c * cu.rect.zw; var out: CompV2F; out.pos = vec4( px.x / cu.vp.x * 2.0 - 1.0, 1.0 - px.y / cu.vp.y * 2.0, 0.0, 1.0, ); // La scratch cubre justo el rect y su fila 0 es el borde de arriba, igual // que el `view`: la uv es el corner tal cual, sin voltear. out.uv = c; return out; } @fragment fn fs_composite(in: CompV2F) -> @location(0) vec4 { return textureSample(src_tex, src_samp, in.uv); } "#; #[cfg(test)] mod tests { use super::*; #[test] fn el_caso_normal_entra_en_un_solo_lote() { // 256 MB / 32 B = 8.388.608 instancias: el techo real de esta GPU, y el // número que la tabla del PLAN-MOTOR-PROFUNDO daba por «no entra». let techo = 256 * 1024 * 1024; assert_eq!(units_per_chunk(RECT_INSTANCE_STRIDE, techo, 1), 8_388_608); // Y 10 M —el caso que el bench no podía correr— son dos lotes, no un // fallo de validación. assert_eq!(10_000_000_u32.div_ceil(8_388_608), 2); } #[test] fn los_triangulos_cortan_en_multiplo_de_tres() { // Un lote que termina a mitad de triángulo dibujaría basura con los dos // vértices que le quedan del siguiente. for techo in [24 * 10, 24 * 11, 24 * 12] { assert_eq!(units_per_chunk(TRI_VERTEX_STRIDE, techo, 3) % 3, 0); } assert_eq!(units_per_chunk(TRI_VERTEX_STRIDE, 24 * 11, 3), 9); } #[test] fn un_techo_absurdo_no_devuelve_cero() { // Cero unidades por lote sería un bucle infinito de lotes vacíos. Se // devuelve el mínimo válido y que wgpu se queje con su propio mensaje, // que dice más que un cuelgue nuestro. assert_eq!(units_per_chunk(RECT_INSTANCE_STRIDE, 0, 1), 1); assert_eq!(units_per_chunk(RECT_INSTANCE_STRIDE, 31, 1), 1); assert_eq!(units_per_chunk(TRI_VERTEX_STRIDE, 0, 3), 3); } }