Files
iris/core/src/render/mod.rs
T
iris-aiandiris b234497d21 Draw the glyph atlas as an array texture and images with their own bind groups + primitive rendering overhaul
Replaces the bindless `binding_array<texture_2d<f32>>` the renderer bound every texture through. That array needs `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack, so the old shape did not run there at all.

The two things being bound want opposite treatment, so they are now split:

- **Glyph atlas pages become layers of one `texture_2d_array`.** A glyph primitive carries a `layer` instead of a view/sampler index pair. A layer index is an ordinary sampling operand, so this needs nothing beyond plain Vulkan 1.0 / GLES. Growing the atlas recreates the array with headroom and `copy_texture_to_texture`s the old layers across, no readback.
- **A standalone image gets its own texture and its own bind group,** and draws in its own call. It no longer needs a per-instance entry in `PrimitiveData`: the bind group has already picked the texture.

`Primitives` keeps images in a list of their own as a result, with `PrimitiveChange::is_image` naming which list a renumbering belongs to -- the two have independent index spaces, so `(layer, inst_idx)` alone would collide between them.

Two notes on judgement calls, since this slice was rebuilt on top of `main` rather than transplanted:

- The source version renamed `GlyphEntry::is_colored` to `is_color` and added a second `IS_COLOR` flag constant beside the existing `GlyphEntry::IS_COLORED`. Both dropped: #10's naming and its `flags()` are kept, and UVs stay `Vec2` rather than going back to `[f32; 2]`.
- `ImageGpu` no longer holds the `Texture` behind its view, which removes an `#[allow(dead_code)]`. A `TextureView` keeps its own reference to the texture, checked by rendering rather than assumed -- see below.

### Verification

```
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
```

All clean; the 4 text-edit tests pass. The only clippy output is the pre-existing future-incompatibility notice about `naga`/`wgpu`/`winit`.

Because this is a rendering change, it was also run for real rather than only compiled. The `tabs` example was rendered on this machine's GPU -- Venus onto an RX 7900 XT, confirmed from the loaded ICD (`libvulkan_virtio.so` on `/dev/dri/renderD128`) rather than assumed, since a failed Vulkan init here silently falls back to llvmpipe and would make the screenshots meaningless.

Screenshots before and after the change are **byte-identical** (same md5) in two scenes: the default tab, which exercises text (the atlas path) and rects, and the image tab with a standalone image pushed at startup, which exercises the per-image bind group. The image-tab scene needed a temporary local edit to the example to push the image without a click; that edit is not part of this branch. The same comparison, re-run after dropping the `Texture` field, is still byte-identical -- which is the check that the view alone keeps it alive.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: iris/iris#11
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 18:56:59 -04:00

377 lines
13 KiB
Rust

use crate::{
UiData, UiRenderState,
render::{data::PrimitiveInstance, util::ArrBuf},
util::{HashMap, Vec2},
};
use data::WindowUniform;
use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
*,
};
mod atlas;
mod data;
mod page;
mod primitive;
mod texture;
mod util;
pub use atlas::*;
pub use data::{Mask, MaskIdx};
pub use primitive::*;
const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
pub struct UiRenderNode {
shared_layout: BindGroupLayout,
shared_group: BindGroup,
format: TextureFormat,
/// One per registered primitive, in id order.
primitives: Vec<PrimitivePipeline>,
layers: HashMap<usize, RenderLayer>,
active: Vec<usize>,
window_buffer: Buffer,
masks: ArrBuf<Mask>,
}
struct RenderLayer {
/// One per registered primitive, `None` where this layer draws none.
primitives: Vec<Option<ListBuffers>>,
}
/// What draws one registered primitive.
struct PrimitivePipeline {
data_layout: BindGroupLayout,
pipeline: RenderPipeline,
render: Box<dyn PrimitiveRender>,
}
/// One list's vertex buffer and the data its shader reads.
struct ListBuffers {
instance: ArrBuf<PrimitiveInstance>,
data: ArrBuf<u8>,
group: Option<BindGroup>,
/// What the primitive asked to keep per instance, if anything.
bindings: Vec<u32>,
}
impl UiRenderNode {
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
pass.set_bind_group(0, &self.shared_group, &[]);
for i in &self.active {
let layer = &self.layers[i];
for (id, list) in layer.primitives.iter().enumerate() {
let Some(list) = list else { continue };
let Some(group) = &list.group else { continue };
let primitive = &self.primitives[id];
pass.set_pipeline(&primitive.pipeline);
pass.set_bind_group(1, group, &[]);
pass.set_vertex_buffer(0, list.instance.buffer.slice(..));
primitive.render.draw(
pass,
ListDraw {
instances: list.instance.len() as u32,
bindings: &list.bindings,
},
);
}
}
}
pub fn update(
&mut self,
device: &Device,
queue: &Queue,
ui: &mut UiData,
ui_render: &mut UiRenderState,
) {
// Before the layers: each list is given its pipeline's data layout.
self.build_pipelines(device, queue, &ui.primitives);
self.active.clear();
for (i, draws) in ui_render.layers.iter_mut() {
self.active.push(i);
for change in draws.apply_free() {
if let Some(inst) = ui_render.active.get_mut(&change.id) {
for h in &mut inst.primitives {
if h.layer == i && h.kind == change.kind && h.inst_idx == change.old {
h.inst_idx = change.new;
break;
}
}
}
}
let rlayer = self.layers.entry(i).or_insert_with(RenderLayer::new);
if draws.updated {
let lists = draws.primitives();
// The zip would otherwise skip a list with no pipeline.
assert!(lists.len() <= self.primitives.len());
rlayer.primitives.resize_with(lists.len(), || None);
for ((buffers, list), primitive) in rlayer
.primitives
.iter_mut()
.zip(lists)
.zip(&self.primitives)
{
let Some(list) = list else {
continue;
};
buffers
.get_or_insert_with(|| ListBuffers::new(device))
.update(device, queue, primitive, list);
}
draws.updated = false;
}
}
for primitive in &mut self.primitives {
primitive.render.update(ui);
}
if ui.masks.changed {
ui.masks.changed = false;
if self.masks.update(device, queue, &ui.masks[..]) {
self.shared_group = Self::shared_group(
device,
&self.shared_layout,
&self.window_buffer,
&self.masks,
);
}
}
}
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
let size = size.into();
let slice = &[WindowUniform {
width: size.x,
height: size.y,
}];
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
}
pub fn new(device: &Device, config: &SurfaceConfiguration) -> Self {
let window_uniform = WindowUniform {
width: config.width as f32,
height: config.height as f32,
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"),
contents: bytemuck::cast_slice(&[window_uniform]),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
});
let shared_layout = Self::shared_layout(device);
let masks = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks",
);
let shared_group = Self::shared_group(device, &shared_layout, &window_buffer, &masks);
Self {
shared_layout,
shared_group,
format: config.format,
primitives: Vec::new(),
window_buffer,
layers: HashMap::default(),
active: Vec::new(),
masks,
}
}
/// Compiles a pipeline for every primitive registered since the last call.
/// Sources only ever arrive at the end, so an id keeps its pipeline.
fn build_pipelines(&mut self, device: &Device, queue: &Queue, registry: &PrimitiveRegistry) {
for source in &registry.sources()[self.primitives.len()..] {
let render = (source.render)(device, queue);
let data_layout = Self::data_layout(device, source.stride);
let mut groups = vec![&self.shared_layout, &data_layout];
groups.extend(render.layout());
let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some(source.label),
bind_group_layouts: &groups,
immediate_size: 0,
});
let pipeline = Self::pipeline(device, &layout, self.format, source.wgsl, source.label);
self.primitives.push(PrimitivePipeline {
data_layout,
pipeline,
render,
});
}
}
fn pipeline(
device: &Device,
layout: &PipelineLayout,
format: TextureFormat,
wgsl: &str,
label: &str,
) -> RenderPipeline {
let module = device.create_shader_module(ShaderModuleDescriptor {
label: Some(label),
source: ShaderSource::Wgsl(format!("{PRELUDE}\n{wgsl}").into()),
});
device.create_render_pipeline(&RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: VertexState {
module: &module,
entry_point: Some("vs_main"),
buffers: &[PrimitiveInstance::desc()],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: &module,
entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState {
format,
blend: Some(BlendState::ALPHA_BLENDING),
write_mask: ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: PrimitiveState {
topology: PrimitiveTopology::TriangleStrip,
strip_index_format: None,
front_face: FrontFace::Cw,
cull_mode: Some(Face::Back),
polygon_mode: PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
})
}
/// What every draw in the ui is given: the window and the masks.
fn shared_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: BufferSize::new(size_of::<WindowUniform>() as u64),
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: BufferSize::new(size_of::<Mask>() as u64),
},
count: None,
},
],
label: Some("ui shared"),
})
}
fn shared_group(
device: &Device,
layout: &BindGroupLayout,
window: &Buffer,
masks: &ArrBuf<Mask>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: window.as_entire_binding(),
},
BindGroupEntry {
binding: 1,
resource: masks.buffer.as_entire_binding(),
},
],
label: Some("ui shared"),
})
}
/// Layout for a list of one primitive's data. Every size in the ui is
/// stated, so "is the buffer big enough for one entry?" is answered when
/// the bind group is made; a `None` size is wgpu's to check on every draw.
fn data_layout(device: &Device, stride: u64) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: BufferSize::new(stride),
},
count: None,
}],
label: Some("ui primitive data"),
})
}
}
impl RenderLayer {
fn new() -> Self {
Self {
primitives: Vec::new(),
}
}
}
impl ListBuffers {
fn new(device: &Device) -> Self {
Self {
instance: ArrBuf::new(
device,
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"instance",
),
data: ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"primitive data",
),
group: None,
bindings: Vec::new(),
}
}
fn update(
&mut self,
device: &Device,
queue: &Queue,
primitive: &PrimitivePipeline,
list: &InstanceList,
) {
self.bindings.clear();
primitive.render.instance_bindings(list, &mut self.bindings);
self.instance.update(device, queue, list.instances());
let resized = self.data.update(device, queue, list.data());
if list.instances().is_empty() {
self.group = None;
} else if resized || self.group.is_none() {
self.group = Some(device.create_bind_group(&BindGroupDescriptor {
layout: &primitive.data_layout,
entries: &[BindGroupEntry {
binding: 0,
resource: self.data.buffer.as_entire_binding(),
}],
label: Some("ui primitive data"),
}));
}
}
}