Give the atlas to the primitive that samples it, not to every shader

`Primitive::SAMPLES` says what a primitive samples and how often it has to be
bound: `Atlas` once for a list, `Image` for one instance alone. Group 2 is
that, whichever it is, so a rect's pipeline has no texture and no sampler in
its layout and the prelude hands out neither.

The two differ only in the view dimension and in what binds them, so they
share `sampled_layout` and `sampled_group`; `GpuPages` owns its bind group
again and rebuilds it when the array grows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-13 17:43:55 -04:00
1 parent c0fcc0345c
commit 79dcc156c9
7 files changed
+157 -107

No files matched your search

+50 -59
View File
@@ -30,6 +30,8 @@ const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
pub struct UiRenderNode {
shared_layout: BindGroupLayout,
shared_group: BindGroup,
/// What a primitive that samples binds at group 2, one per `Sampled`.
atlas_layout: BindGroupLayout,
image_layout: BindGroupLayout,
format: TextureFormat,
@@ -54,6 +56,7 @@ struct RenderLayer {
struct PrimitivePipeline {
data_layout: BindGroupLayout,
pipeline: RenderPipeline,
samples: Option<Sampled>,
}
/// One list's vertex buffer and the data its shader reads.
@@ -73,15 +76,14 @@ impl UiRenderNode {
for (id, list) in layer.primitives.iter().enumerate() {
let Some(list) = list else { continue };
let Some(group) = &list.group else { continue };
pass.set_pipeline(&self.primitives[id].pipeline);
let primitive = &self.primitives[id];
pass.set_pipeline(&primitive.pipeline);
// After the pipeline: a change drops the groups from where two
// pipeline layouts differ, and each primitive has its own.
pass.set_bind_group(1, group, &[]);
pass.set_vertex_buffer(0, list.instance.buffer.slice(..));
if list.slots.is_empty() {
pass.draw(0..4, 0..list.instance.len() as u32);
continue;
}
match primitive.samples {
Some(Sampled::Image) => {
for (i, &slot) in list.slots.iter().enumerate() {
let Some(image) = self.textures.group(slot) else {
continue;
@@ -89,6 +91,12 @@ impl UiRenderNode {
pass.set_bind_group(2, image, &[]);
pass.draw(0..4, i as u32..i as u32 + 1);
}
continue;
}
Some(Sampled::Atlas) => pass.set_bind_group(2, self.pages.group(), &[]),
None => {}
}
pass.draw(0..4, 0..list.instance.len() as u32);
}
}
}
@@ -137,22 +145,21 @@ impl UiRenderNode {
draws.updated = false;
}
}
let mut shared_stale = self.pages.update(&mut ui.text.atlas);
self.pages
.update(&mut ui.text.atlas, &self.atlas_layout, &self.sampler);
if ui.masks.changed {
ui.masks.changed = false;
shared_stale |= self.masks.update(device, queue, &ui.masks[..]);
}
if shared_stale {
if self.masks.update(device, queue, &ui.masks[..]) {
self.shared_group = Self::shared_group(
device,
&self.shared_layout,
&self.window_buffer,
&self.masks,
&self.pages,
&self.sampler,
);
}
self.textures.update(&mut ui.textures, &self.image_layout);
}
self.textures
.update(&mut ui.textures, &self.image_layout, &self.sampler);
}
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
@@ -176,7 +183,8 @@ impl UiRenderNode {
});
let shared_layout = Self::shared_layout(device);
let image_layout = Self::image_layout(device);
let atlas_layout = Self::sampled_layout(device, TextureViewDimension::D2Array, "ui atlas");
let image_layout = Self::sampled_layout(device, TextureViewDimension::D2, "ui image");
let masks = ArrBuf::new(
device,
@@ -185,20 +193,14 @@ impl UiRenderNode {
);
let sampler = default_sampler(device);
let pages = GpuPages::new(device, queue);
let pages = GpuPages::new(device, queue, &atlas_layout, &sampler);
let textures = GpuTextures::new(device, queue);
let shared_group = Self::shared_group(
device,
&shared_layout,
&window_buffer,
&masks,
&pages,
&sampler,
);
let shared_group = Self::shared_group(device, &shared_layout, &window_buffer, &masks);
Self {
shared_layout,
shared_group,
atlas_layout,
image_layout,
format: config.format,
primitives: Vec::new(),
@@ -218,9 +220,11 @@ impl UiRenderNode {
for source in &registry.sources()[self.primitives.len()..] {
let data_layout = Self::data_layout(device, source.stride);
let mut groups = vec![&self.shared_layout, &data_layout];
if source.textured {
groups.push(&self.image_layout);
}
groups.extend(match source.samples {
Some(Sampled::Atlas) => Some(&self.atlas_layout),
Some(Sampled::Image) => Some(&self.image_layout),
None => None,
});
let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some(source.label),
bind_group_layouts: &groups,
@@ -230,6 +234,7 @@ impl UiRenderNode {
self.primitives.push(PrimitivePipeline {
data_layout,
pipeline,
samples: source.samples,
});
}
}
@@ -284,8 +289,7 @@ impl UiRenderNode {
})
}
/// What every draw in the ui is given, whether or not its shader reads it:
/// the window, the masks, the glyph atlas and the one sampler.
/// 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: &[
@@ -309,22 +313,6 @@ impl UiRenderNode {
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 3,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: None,
},
],
label: Some("ui shared"),
})
@@ -335,8 +323,6 @@ impl UiRenderNode {
layout: &BindGroupLayout,
window: &Buffer,
masks: &ArrBuf<Mask>,
pages: &GpuPages,
sampler: &Sampler,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -349,14 +335,6 @@ impl UiRenderNode {
binding: 1,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 2,
resource: BindingResource::TextureView(pages.view()),
},
BindGroupEntry {
binding: 3,
resource: BindingResource::Sampler(sampler),
},
],
label: Some("ui shared"),
})
@@ -381,20 +359,33 @@ impl UiRenderNode {
})
}
/// The image one instance samples.
fn image_layout(device: &Device) -> BindGroupLayout {
/// What a primitive samples, and the sampler it reads it with. One per
/// `Sampled`, since the atlas is an array and an image is not.
fn sampled_layout(
device: &Device,
dimension: TextureViewDimension,
label: &'static str,
) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[BindGroupLayoutEntry {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2,
view_dimension: dimension,
multisampled: false,
},
count: None,
}],
label: Some("ui image"),
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: None,
},
],
label: Some(label),
})
}
+29 -17
View File
@@ -2,7 +2,10 @@ use wgpu::*;
use crate::GlyphAtlas;
use super::{atlas::PAGE, texture::write_region};
use super::{
atlas::PAGE,
texture::{sampled_group, write_region},
};
/// The glyph atlas on the GPU: one array texture whose layers are the pages
/// `GlyphAtlas` packs.
@@ -14,25 +17,28 @@ pub struct GpuPages {
device: Device,
queue: Queue,
texture: Texture,
view: TextureView,
group: BindGroup,
}
impl GpuPages {
pub fn new(device: &Device, queue: &Queue) -> Self {
pub fn new(
device: &Device,
queue: &Queue,
layout: &BindGroupLayout,
sampler: &Sampler,
) -> Self {
let texture = create_array(device, 1);
Self {
device: device.clone(),
queue: queue.clone(),
view: array_view(&texture),
group: atlas_group(device, layout, &texture, sampler),
texture,
}
}
/// Returns whether the array was replaced, which stales the view.
pub fn update(&mut self, atlas: &mut GlyphAtlas) -> bool {
let grew = atlas.page_count() > self.texture.depth_or_array_layers();
if grew {
self.grow(atlas.page_count());
pub fn update(&mut self, atlas: &mut GlyphAtlas, layout: &BindGroupLayout, sampler: &Sampler) {
if atlas.page_count() > self.texture.depth_or_array_layers() {
self.grow(atlas.page_count(), layout, sampler);
}
for (upload, page) in atlas.uploads() {
let dst = TexelCopyTextureInfo {
@@ -47,15 +53,15 @@ impl GpuPages {
};
write_region(&self.queue, dst, page, upload.rect);
}
grew
}
pub fn view(&self) -> &TextureView {
&self.view
pub fn group(&self) -> &BindGroup {
&self.group
}
/// Doubles until `needed` fits and copies the old layers across GPU side.
fn grow(&mut self, needed: u32) {
/// The new texture stales the group, so that is rebuilt here.
fn grow(&mut self, needed: u32, layout: &BindGroupLayout, sampler: &Sampler) {
let old = self.texture.depth_or_array_layers();
let mut layers = old;
while layers < needed {
@@ -77,16 +83,22 @@ impl GpuPages {
},
);
self.queue.submit(std::iter::once(encoder.finish()));
self.view = array_view(&texture);
self.group = atlas_group(&self.device, layout, &texture, sampler);
self.texture = texture;
}
}
fn array_view(texture: &Texture) -> TextureView {
texture.create_view(&TextureViewDescriptor {
fn atlas_group(
device: &Device,
layout: &BindGroupLayout,
texture: &Texture,
sampler: &Sampler,
) -> BindGroup {
let view = texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
})
});
sampled_group(device, layout, &view, sampler, "ui atlas")
}
fn create_array(device: &Device, layers: u32) -> Texture {
+37 -9
View File
@@ -15,9 +15,34 @@ pub trait Primitive: Pod + 'static {
/// Compiled after `prelude.wgsl`, which states what it declares and what
/// it is given.
const WGSL: &'static str;
/// Reads the image an instance uses. Each instance is then a draw of its
/// own, with that image bound for it alone.
const TEXTURE: Option<fn(&Self) -> u32> = None;
/// What its shader samples. Nothing, for a primitive that draws from its
/// own data alone.
const SAMPLES: Option<Samples<Self>> = None;
}
/// What a primitive samples, and how often that has to be bound.
pub enum Samples<P> {
/// The glyph atlas, bound once for a whole list.
Atlas,
/// A `Textures` slot read from each instance, bound for that instance
/// alone -- so an instance is a draw of its own.
Image(fn(&P) -> u32),
}
/// `Samples` with the type erased, which is all the renderer needs.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Sampled {
Atlas,
Image,
}
impl<P> Samples<P> {
const fn erase(&self) -> Sampled {
match self {
Self::Atlas => Sampled::Atlas,
Self::Image(_) => Sampled::Image,
}
}
}
/// Which registered primitive an instance is.
@@ -55,8 +80,7 @@ pub struct PrimitiveSource {
pub label: &'static str,
/// Size of one instance's entry, stated as the data binding's minimum.
pub stride: u64,
/// Whether an instance binds a texture of its own, from `P::TEXTURE`.
pub textured: bool,
pub samples: Option<Sampled>,
}
impl PrimitiveRegistry {
@@ -68,7 +92,7 @@ impl PrimitiveRegistry {
wgsl: P::WGSL,
label: std::any::type_name::<P>(),
stride: size_of::<P>() as u64,
textured: P::TEXTURE.is_some(),
samples: P::SAMPLES.as_ref().map(Samples::erase),
});
kinds.len() as u32 - 1
});
@@ -89,7 +113,7 @@ pub struct InstanceList {
free: Vec<usize>,
/// `stride` bytes of the primitive's own data per instance.
data: Vec<u8>,
/// The image each instance samples, from `P::TEXTURE`. Empty without it.
/// The image each instance samples. Empty unless it samples one.
slots: Vec<u32>,
/// From the type the list was made for, so a write is never checked.
stride: usize,
@@ -220,7 +244,10 @@ impl LayerDraws {
id,
PrimitiveInstance { region, mask_idx },
bytemuck::bytes_of(&primitive),
P::TEXTURE.map(|slot| slot(&primitive)),
match &P::SAMPLES {
Some(Samples::Image(slot)) => Some(slot(&primitive)),
_ => None,
},
);
PrimitiveHandle {
layer,
@@ -325,6 +352,7 @@ unsafe impl bytemuck::Pod for GlyphPrimitive {}
unsafe impl bytemuck::Zeroable for GlyphPrimitive {}
impl Primitive for GlyphPrimitive {
const WGSL: &'static str = include_str!("shader/glyph.wgsl");
const SAMPLES: Option<Samples<Self>> = Some(Samples::Atlas);
}
/// One drawn image. Its shader reads nothing per instance; the slot names the
@@ -337,7 +365,7 @@ pub struct TexturePrimitive {
impl Primitive for TexturePrimitive {
const WGSL: &'static str = include_str!("shader/texture.wgsl");
const TEXTURE: Option<fn(&Self) -> u32> = Some(|texture| texture.slot);
const SAMPLES: Option<Samples<Self>> = Some(Samples::Image(|texture| texture.slot));
}
impl From<&TextureHandle> for TexturePrimitive {
+2 -2
View File
@@ -2,9 +2,9 @@
const COLORED: u32 = 1u;
// The glyph atlas, whose array layers are its pages.
@group(0) @binding(2)
@group(2) @binding(0)
var atlas: texture_2d_array<f32>;
@group(0) @binding(3)
@group(2) @binding(1)
var samp: sampler;
struct GlyphInfo {
+2 -5
View File
@@ -1,10 +1,7 @@
// Prepended to every primitive's shader, which declares its own instance data
// as `var<storage> <name>: array<T>` at group 1 binding 0, and an `fs_main`
// shading one instance of it.
//
// The rest of group 0 is bound for every draw but declared by the shaders that
// read it: the glyph atlas at binding 2 and the ui's sampler at binding 3. A
// primitive that samples an image of its own takes it at group 2 binding 0.
// shading one instance of it. What it samples, if anything, is bound at group
// 2: the texture at binding 0 and the sampler at binding 1.
@group(0) @binding(0)
var<uniform> window: WindowUniform;
+1 -1
View File
@@ -1,7 +1,7 @@
// The image this instance draws, bound for it alone.
@group(2) @binding(0)
var image: texture_2d<f32>;
@group(0) @binding(3)
@group(2) @binding(1)
var samp: sampler;
@fragment
+36 -14
View File
@@ -26,15 +26,15 @@ impl GpuTextures {
}
}
pub fn update(&mut self, textures: &mut Textures, layout: &BindGroupLayout) {
pub fn update(&mut self, textures: &mut Textures, layout: &BindGroupLayout, sampler: &Sampler) {
for update in textures.updates() {
match update {
TextureUpdate::Push(image) => {
let image = self.create(image, layout);
let image = self.create(image, layout, sampler);
self.slots.push(Some(image));
}
TextureUpdate::Set(i, image) => {
let image = self.create(image, layout);
let image = self.create(image, layout, sampler);
self.slots[i as usize] = Some(image);
}
TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image),
@@ -53,7 +53,12 @@ impl GpuTextures {
self.slots.iter().flatten().count()
}
fn create(&self, image: &DynamicImage, layout: &BindGroupLayout) -> ImageGpu {
fn create(
&self,
image: &DynamicImage,
layout: &BindGroupLayout,
sampler: &Sampler,
) -> ImageGpu {
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
let texture = self.device.create_texture_with_data(
@@ -75,16 +80,8 @@ impl GpuTextures {
wgt::TextureDataOrder::MipMajor,
rgba.as_bytes(),
);
let group = self.device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(
&texture.create_view(&TextureViewDescriptor::default()),
),
}],
label: Some("ui image"),
});
let view = texture.create_view(&TextureViewDescriptor::default());
let group = sampled_group(&self.device, layout, &view, sampler, "ui image");
ImageGpu { texture, group }
}
@@ -137,6 +134,31 @@ pub fn write_region(queue: &Queue, dst: TexelCopyTextureInfo, src: &RgbaImage, r
);
}
/// A texture and the sampler that reads it: what a primitive that samples
/// binds, whichever of the two it is.
pub fn sampled_group(
device: &Device,
layout: &BindGroupLayout,
view: &TextureView,
sampler: &Sampler,
label: &'static str,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(view),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::Sampler(sampler),
},
],
label: Some(label),
})
}
pub fn default_sampler(device: &Device) -> Sampler {
device.create_sampler(&SamplerDescriptor::default())
}