Draw textures separately, and keep them out of Primitives

Interleaving textures with primitives was solving a problem that does not
exist: within a layer, order is already undefined because freeing an
instance swap-removes it, and layering is what layers are for. So the run
batching is gone.

A layer is now `LayerDraws`: a `Primitives` and a texture `InstanceList`
side by side, with `updated` covering both. `Primitives` holds only
primitives again -- its instance list plus the group-1 data those
instances read -- and `InstanceList` is the shared push/free/apply_free
the two lists have in common rather than a second copy of it.
`PrimitiveHandle` names which list with `InstanceKind`, and
`PrimitiveChange` carries the same, since the two index independently.
The handle no longer carries a group-1 index at all: a primitive
instance already records where its entry is.

The renderer gives each layer a second instance buffer and draws its
textures one at a time after the instanced draw, each binding its own
group 2.

Review fixes alongside: `GlyphAtlas::allocate` returns the `PageUpload`
it reserved instead of a bare tuple; a page or image region uploads
through a new `write_region`, which passes the row stride to
`write_texture` rather than copying the rectangle out first.

Verified by replaying taps into the `tabs` example: three images added
and one deleted leaves two drawn with two live texture slots, and the
masked text-edit tab still clips, with the images freed on tab switch.
This commit is contained in:
iris committed 2026-09-13 13:32:05 -04:00
1 parent f5864da3c4
commit b3d3da5dab
10 files changed
+282 -241

No files matched your search

+3 -3
View File
@@ -2,7 +2,7 @@ use std::ops::{Index, IndexMut};
use crate::{
UiRegion, WidgetId,
render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
render::{LayerDraws, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst},
util::to_mut,
};
@@ -40,7 +40,7 @@ struct Child {
tail: usize,
}
pub type PrimitiveLayers = Layers<Primitives>;
pub type DrawLayers = Layers<LayerDraws>;
impl<T: Default> Layers<T> {
pub fn new() -> Layers<T> {
@@ -120,7 +120,7 @@ impl<T: Default> Layers<T> {
}
}
impl PrimitiveLayers {
impl DrawLayers {
pub fn write<P: Primitive>(
&mut self,
layer: LayerId,
+23 -17
View File
@@ -98,17 +98,10 @@ impl GlyphAtlas {
return None;
}
let (layer, x, y) = self.allocate(w, h);
write_glyph(&mut self.pages[layer as usize].image, image, x, y);
self.uploads.push(PageUpload {
layer,
rect: PatchRect {
x,
y,
width: w,
height: h,
},
});
let upload = self.allocate(w, h);
let PatchRect { x, y, .. } = upload.rect;
write_glyph(&mut self.pages[upload.layer as usize].image, image, x, y);
self.uploads.push(upload);
let scale = 1.0 / PAGE as f32;
let entry = GlyphEntry {
@@ -119,20 +112,30 @@ impl GlyphAtlas {
width: w,
height: h,
is_colored: matches!(image.content, Content::Color),
layer,
layer: upload.layer,
};
self.entries.insert(key, Some(entry));
Some(entry)
}
fn allocate(&mut self, w: u32, h: u32) -> (u32, u32, u32) {
/// Reserves room for a `w` by `h` glyph, adding a page if none has it.
fn allocate(&mut self, w: u32, h: u32) -> PageUpload {
let rect = |x, y| PatchRect {
x,
y,
width: w,
height: h,
};
if let Some((i, (x, y))) = self
.pages
.iter_mut()
.enumerate()
.find_map(|(i, page)| page.allocate(w, h).map(|position| (i, position)))
{
return (i as u32, x, y);
return PageUpload {
layer: i as u32,
rect: rect(x, y),
};
}
self.pages.push(Page {
@@ -141,12 +144,15 @@ impl GlyphAtlas {
y: PAD,
shelf_height: h + PAD,
});
(self.pages.len() as u32 - 1, PAD, PAD)
PageUpload {
layer: self.pages.len() as u32 - 1,
rect: rect(PAD, PAD),
}
}
/// Drains what has been written since the last call, for the renderer to
/// upload. Nothing else is needed for a new page: wgpu leaves the rest of
/// a fresh layer transparent, which is what an atlas wants.
/// upload. A new page needs nothing more: wgpu leaves the rest of a fresh
/// layer transparent, which is what an atlas wants.
pub fn uploads(&mut self) -> impl Iterator<Item = (PageUpload, &RgbaImage)> {
let pages = &self.pages;
self.uploads
+42 -57
View File
@@ -1,5 +1,3 @@
use std::ops::Range;
use crate::{
UiData, UiRenderState,
render::{
@@ -51,14 +49,10 @@ struct RenderLayer {
instance: ArrBuf<PrimitiveInstance>,
primitives: PrimitiveBuffers,
primitive_group: BindGroup,
draws: Vec<Draw>,
}
/// A run of consecutive instances sharing one group 2: `texture` names a
/// standalone image, or `None` is the glyph atlas rects and glyphs sample.
struct Draw {
texture: Option<u32>,
instances: Range<u32>,
texture_instance: ArrBuf<PrimitiveInstance>,
/// Which texture each entry of `texture_instance` samples, in the same
/// order. Not in the vertex buffer because it names a bind group.
texture_slots: Vec<u32>,
}
impl UiRenderNode {
@@ -68,21 +62,21 @@ impl UiRenderNode {
pass.set_bind_group(3, &self.mask_group, &[]);
for i in &self.active {
let layer = &self.layers[i];
if layer.draws.is_empty() {
continue;
}
pass.set_bind_group(1, &layer.primitive_group, &[]);
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
for draw in &layer.draws {
let group = match draw.texture {
Some(slot) => match self.textures.group(slot) {
Some(group) => group,
None => continue,
},
None => self.pages.group(),
};
pass.set_bind_group(2, group, &[]);
pass.draw(0..4, draw.instances.clone());
if layer.instance.len() > 0 {
pass.set_bind_group(2, self.pages.group(), &[]);
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
pass.draw(0..4, 0..layer.instance.len() as u32);
}
if !layer.texture_slots.is_empty() {
pass.set_vertex_buffer(0, layer.texture_instance.buffer.slice(..));
for (i, &slot) in layer.texture_slots.iter().enumerate() {
let Some(group) = self.textures.group(slot) else {
continue;
};
pass.set_bind_group(2, group, &[]);
pass.draw(0..4, i as u32..i as u32 + 1);
}
}
}
}
@@ -95,12 +89,12 @@ impl UiRenderNode {
ui_render: &mut UiRenderState,
) {
self.active.clear();
for (i, primitives) in ui_render.layers.iter_mut() {
for (i, draws) in ui_render.layers.iter_mut() {
self.active.push(i);
for change in primitives.apply_free() {
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.inst_idx == change.old {
if h.layer == i && h.kind == change.kind && h.inst_idx == change.old {
h.inst_idx = change.new;
break;
}
@@ -119,21 +113,34 @@ impl UiRenderNode {
),
primitives,
primitive_group,
draws: Vec::new(),
texture_instance: ArrBuf::new(
device,
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"texture instance",
),
texture_slots: Vec::new(),
}
});
if primitives.updated {
if draws.updated {
rlayer
.instance
.update(device, queue, primitives.instances());
rlayer.primitives.update(device, queue, primitives.data());
.update(device, queue, draws.primitives.instances());
rlayer
.primitives
.update(device, queue, draws.primitives.data());
rlayer.primitive_group = Self::primitive_group(
device,
&self.primitive_layout,
rlayer.primitives.buffers(),
);
rlayer.plan_draws(primitives.instances());
primitives.updated = false;
rlayer
.texture_instance
.update(device, queue, draws.textures.instances());
rlayer.texture_slots.clear();
rlayer
.texture_slots
.extend(draws.textures.instances().iter().map(|inst| inst.idx));
draws.updated = false;
}
}
if ui.masks.changed {
@@ -312,9 +319,8 @@ impl UiRenderNode {
})
}
/// Group 2: the texture a run of instances samples, and the sampler. No
/// `count` on either entry -- plain Vulkan 1.0 / GLES sampling, unlike the
/// `binding_array` layout this replaced.
/// Group 2: the texture this draw samples, and the sampler. No `count` on
/// either entry -- plain Vulkan 1.0 / GLES sampling is all this needs.
fn texture_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
@@ -372,24 +378,3 @@ impl UiRenderNode {
self.textures.count()
}
}
impl RenderLayer {
/// Only a texture instance breaks a run, so a ui without images plans one
/// draw however many rects and glyphs it has.
fn plan_draws(&mut self, instances: &[PrimitiveInstance]) {
self.draws.clear();
for (i, inst) in instances.iter().enumerate() {
let i = i as u32;
let texture = (inst.binding == TEXTURE_BINDING).then_some(inst.idx);
match self.draws.last_mut() {
Some(draw) if draw.texture.is_none() && texture.is_none() => {
draw.instances.end = i + 1;
}
_ => self.draws.push(Draw {
texture,
instances: i..i + 1,
}),
}
}
}
}
+13 -30
View File
@@ -1,18 +1,17 @@
use image::EncodableLayout;
use wgpu::*;
use crate::GlyphAtlas;
use super::{
atlas::PAGE,
texture::{array_view, texture_group},
texture::{array_view, texture_group, write_region},
};
/// The glyph atlas on the GPU: one array texture whose layers are the pages
/// `GlyphAtlas` packs.
///
/// An array rather than a `binding_array<texture_2d<f32>>` because a layer
/// index is ordinary Vulkan 1.0 / GLES sampling, while a binding array needs
/// One array rather than a texture per page because a layer index is ordinary
/// Vulkan 1.0 / GLES sampling, where a `binding_array` would need
/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack.
pub struct GpuPages {
device: Device,
@@ -43,33 +42,17 @@ impl GpuPages {
self.grow(atlas.page_count(), layout, sampler);
}
for (upload, page) in atlas.uploads() {
let rect = upload.rect;
// `write_texture` wants tightly packed rows; the page is wider.
let sub =
image::imageops::crop_imm(page, rect.x, rect.y, rect.width, rect.height).to_image();
self.queue.write_texture(
TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: Origin3d {
x: rect.x,
y: rect.y,
z: upload.layer,
},
aspect: TextureAspect::All,
let dst = TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: Origin3d {
x: upload.rect.x,
y: upload.rect.y,
z: upload.layer,
},
sub.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(rect.width * 4),
rows_per_image: Some(rect.height),
},
Extent3d {
width: rect.width,
height: rect.height,
depth_or_array_layers: 1,
},
);
aspect: TextureAspect::All,
};
write_region(&self.queue, dst, page, upload.rect);
}
}
+143 -87
View File
@@ -11,30 +11,89 @@ use crate::{
use bytemuck::Pod;
use wgpu::*;
pub struct Primitives {
instances: Vec<PrimitiveInstance>,
assoc: Vec<WidgetId>,
data: PrimitiveData,
free: Vec<usize>,
/// Everything one layer draws. A texture binds its own group 2, so it draws on
/// its own and cannot join the instanced draw the primitives share.
pub struct LayerDraws {
pub primitives: Primitives,
pub textures: InstanceList,
pub updated: bool,
}
impl Default for Primitives {
impl Default for LayerDraws {
fn default() -> Self {
Self {
instances: Default::default(),
assoc: Default::default(),
data: Default::default(),
free: Vec::new(),
primitives: Default::default(),
textures: Default::default(),
updated: true,
}
}
}
/// Not in `primitives!` and with no group-1 buffer: a texture instance's `idx`
/// names the texture to bind, so there is nothing to look up per-instance.
/// Which of a layer's two lists an instance is in. They index independently,
/// so a handle or a renumbering naming only a position would be ambiguous.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstanceKind {
Primitive,
Texture,
}
/// A texture instance's `idx` names the texture to bind rather than a group-1
/// entry, so it is not in `primitives!` and has no buffer of its own.
pub const TEXTURE_BINDING: u32 = 2;
/// Instances, the widget each belongs to, and the slots waiting to be reused.
#[derive(Default)]
pub struct InstanceList {
instances: Vec<PrimitiveInstance>,
assoc: Vec<WidgetId>,
free: Vec<usize>,
}
impl InstanceList {
pub fn instances(&self) -> &Vec<PrimitiveInstance> {
&self.instances
}
fn push(&mut self, id: WidgetId, inst: PrimitiveInstance) -> usize {
if let Some(i) = self.free.pop() {
self.instances[i] = inst;
self.assoc[i] = id;
i
} else {
let i = self.instances.len();
self.instances.push(inst);
self.assoc.push(id);
i
}
}
fn free(&mut self, i: usize) -> MaskIdx {
self.free.push(i);
self.instances[i].mask_idx
}
fn apply_free(&mut self, kind: InstanceKind) -> impl Iterator<Item = PrimitiveChange> {
self.free.sort_by(|a, b| b.cmp(a));
let instances = &mut self.instances;
let assoc = &mut self.assoc;
self.free.drain(..).filter_map(move |i| {
instances.swap_remove(i);
assoc.swap_remove(i);
if i == instances.len() {
return None;
}
let id = assoc[i];
let old = instances.len();
Some(PrimitiveChange {
id,
kind,
old,
new: i,
})
})
}
}
pub trait Primitive: Pod {
const BINDING: u32;
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
@@ -89,8 +148,6 @@ macro_rules! primitives {
}
$(
// Each is uploaded as its WGSL counterpart, so its fields have to
// sit at WGSL's offsets -- including an align Rust would not pick.
unsafe impl bytemuck::Pod for $ty {}
unsafe impl bytemuck::Zeroable for $ty {}
impl Primitive for $ty {
@@ -112,32 +169,63 @@ pub struct PrimitiveInst<P> {
pub mask_idx: MaskIdx,
}
/// The instanced half of a layer: a list, plus the group-1 data it reads.
#[derive(Default)]
pub struct Primitives {
list: InstanceList,
data: PrimitiveData,
}
impl Primitives {
pub fn write<P: Primitive>(
fn write<P: Primitive>(
&mut self,
layer: usize,
PrimitiveInst {
id,
primitive,
region,
mask_idx,
}: PrimitiveInst<P>,
) -> PrimitiveHandle {
let data_idx = P::vec(&mut self.data).add(primitive);
self.push(
layer,
) -> usize {
let idx = P::vec(&mut self.data).add(primitive) as u32;
self.list.push(
id,
PrimitiveInstance {
region,
idx: data_idx as u32,
idx,
mask_idx,
binding: P::BINDING,
},
Some(data_idx),
)
}
/// Writes an instance that samples `texture` instead of a group-1 buffer.
fn free(&mut self, i: usize) -> MaskIdx {
// The instance says where its group-1 entry is, so the handle need not.
let inst = self.list.instances[i];
self.data.free(inst.binding, inst.idx as usize);
self.list.free(i)
}
pub fn data(&self) -> &PrimitiveData {
&self.data
}
pub fn instances(&self) -> &Vec<PrimitiveInstance> {
&self.list.instances
}
}
impl LayerDraws {
pub fn write<P: Primitive>(&mut self, layer: usize, inst: PrimitiveInst<P>) -> PrimitiveHandle {
self.updated = true;
PrimitiveHandle {
layer,
kind: InstanceKind::Primitive,
inst_idx: self.primitives.write(inst),
}
}
/// Writes an instance that samples the texture in slot `texture` instead of
/// reading a group-1 buffer.
pub fn write_texture(
&mut self,
layer: usize,
@@ -145,86 +233,59 @@ impl Primitives {
texture: u32,
region: UiRegion,
mask_idx: MaskIdx,
) -> PrimitiveHandle {
self.push(
layer,
id,
PrimitiveInstance {
region,
idx: texture,
mask_idx,
binding: TEXTURE_BINDING,
},
None,
)
}
fn push(
&mut self,
layer: usize,
id: WidgetId,
inst: PrimitiveInstance,
data_idx: Option<usize>,
) -> PrimitiveHandle {
self.updated = true;
let inst_idx = if let Some(i) = self.free.pop() {
self.instances[i] = inst;
self.assoc[i] = id;
i
} else {
let i = self.instances.len();
self.instances.push(inst);
self.assoc.push(id);
i
};
PrimitiveHandle {
layer,
inst_idx,
data_idx,
binding: inst.binding,
kind: InstanceKind::Texture,
inst_idx: self.textures.push(
id,
PrimitiveInstance {
region,
idx: texture,
mask_idx,
binding: TEXTURE_BINDING,
},
),
}
}
/// returns (old index, new index)
pub fn apply_free(&mut self) -> impl Iterator<Item = PrimitiveChange> {
self.free.sort_by(|a, b| b.cmp(a));
self.free.drain(..).filter_map(|i| {
self.instances.swap_remove(i);
self.assoc.swap_remove(i);
if i == self.instances.len() {
return None;
}
let id = self.assoc[i];
let old = self.instances.len();
Some(PrimitiveChange { id, old, new: i })
})
let Self {
primitives,
textures,
..
} = self;
primitives
.list
.apply_free(InstanceKind::Primitive)
.chain(textures.apply_free(InstanceKind::Texture))
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self.updated = true;
if let Some(i) = h.data_idx {
self.data.free(h.binding, i);
match h.kind {
InstanceKind::Primitive => self.primitives.free(h.inst_idx),
InstanceKind::Texture => self.textures.free(h.inst_idx),
}
self.free.push(h.inst_idx);
self.instances[h.inst_idx].mask_idx
}
pub fn data(&self) -> &PrimitiveData {
&self.data
}
pub fn instances(&self) -> &Vec<PrimitiveInstance> {
&self.instances
}
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
self.updated = true;
&mut self.instances[h.inst_idx].region
&mut self.list_mut(h.kind).instances[h.inst_idx].region
}
fn list_mut(&mut self, kind: InstanceKind) -> &mut InstanceList {
match kind {
InstanceKind::Primitive => &mut self.primitives.list,
InstanceKind::Texture => &mut self.textures,
}
}
}
pub struct PrimitiveChange {
pub id: WidgetId,
pub kind: InstanceKind,
pub old: usize,
pub new: usize,
}
@@ -232,10 +293,8 @@ pub struct PrimitiveChange {
#[derive(Debug)]
pub struct PrimitiveHandle {
pub layer: usize,
pub kind: InstanceKind,
pub inst_idx: usize,
/// `None` for a texture instance, which has no group-1 entry to free.
pub data_idx: Option<usize>,
pub binding: u32,
}
primitives!(
@@ -265,9 +324,6 @@ impl RectPrimitive {
/// `color` is multiplied by the atlas alpha for a mask glyph; a colour glyph
/// takes the texel unchanged, which `GlyphEntry::IS_COLORED` selects.
///
/// `align(8)` because `vec2<f32>` aligns `GlyphInfo` to 8, which pads it to
/// 32 bytes.
#[repr(C, align(8))]
#[derive(Debug, Copy, Clone)]
pub struct GlyphPrimitive {
+2 -2
View File
@@ -45,8 +45,8 @@ struct UiVec2 {
abs: vec2<f32>,
}
// Whatever this run of instances samples: the glyph atlas, whose layers are
// its pages, or one standalone image as an array of one.
// What this draw samples: the glyph atlas, whose layers are its pages, or one
// standalone image as an array of one.
@group(2) @binding(0)
var tex: texture_2d_array<f32>;
@group(2) @binding(1)
+46 -32
View File
@@ -1,4 +1,4 @@
use image::{DynamicImage, EncodableLayout, GenericImageView};
use image::{DynamicImage, EncodableLayout, GenericImageView, RgbaImage};
use wgpu::{util::DeviceExt, *};
use crate::{PatchRect, TextureUpdate, Textures};
@@ -90,41 +90,55 @@ impl GpuTextures {
let Some(Some(slot)) = self.slots.get(i as usize) else {
return;
};
if rect.width == 0 || rect.height == 0 {
return;
let dst = TexelCopyTextureInfo {
texture: &slot.texture,
mip_level: 0,
origin: Origin3d {
x: rect.x,
y: rect.y,
z: 0,
},
aspect: TextureAspect::All,
};
match image.as_rgba8() {
Some(rgba) => write_region(&self.queue, dst, rgba, rect),
// The texture is rgba8, so any other layout has to be converted --
// and converting the rectangle is cheaper than the whole image.
None => {
let sub = image
.view(rect.x, rect.y, rect.width, rect.height)
.to_image();
write_region(&self.queue, dst, &sub, PatchRect { x: 0, y: 0, ..rect });
}
}
// `write_texture` requires tightly packed rows, unlike the source image.
let sub = image
.view(rect.x, rect.y, rect.width, rect.height)
.to_image();
self.queue.write_texture(
TexelCopyTextureInfo {
texture: &slot.texture,
mip_level: 0,
origin: Origin3d {
x: rect.x,
y: rect.y,
z: 0,
},
aspect: TextureAspect::All,
},
sub.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(rect.width * 4),
rows_per_image: Some(rect.height),
},
Extent3d {
width: rect.width,
height: rect.height,
depth_or_array_layers: 1,
},
);
}
}
/// One array texture and one sampler, so the atlas and a standalone image bind
/// the same layout and the shader samples whichever is bound. An image is a
/// Uploads `rect` of `src` without copying it out first: `write_texture` takes
/// a row stride, so a region can be addressed where it already is.
pub fn write_region(queue: &Queue, dst: TexelCopyTextureInfo, src: &RgbaImage, rect: PatchRect) {
if rect.width == 0 || rect.height == 0 {
return;
}
let stride = src.width() * 4;
queue.write_texture(
dst,
src.as_bytes(),
TexelCopyBufferLayout {
offset: (rect.y * stride + rect.x * 4) as u64,
bytes_per_row: Some(stride),
rows_per_image: Some(rect.height),
},
Extent3d {
width: rect.width,
height: rect.height,
depth_or_array_layers: 1,
},
);
}
/// One array texture and a sampler, so the atlas and a standalone image share a
/// layout and the shader samples whichever is bound -- an image being a
/// single-layer texture viewed as an array of one.
pub fn texture_group(
device: &Device,
+3
View File
@@ -33,6 +33,9 @@ impl<T: Pod> ArrBuf<T> {
queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
resized
}
pub fn len(&self) -> usize {
self.len
}
fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer {
let mut size = size as u64;
if usage.contains(BufferUsages::STORAGE) {
+7 -10
View File
@@ -1,13 +1,13 @@
use crate::{
ActiveData, Axis, IdLike, MaskIdx, Painter, PixelRegion, PrimitiveLayers, SizeCtx,
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, Painter, PixelRegion, SizeCtx, StrongWidget,
UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
ui::cache::Cache,
util::{HashMap, HashSet, Vec2, forget_ref},
};
pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
pub layers: PrimitiveLayers,
pub layers: DrawLayers,
pub(super) output_size: Vec2,
pub cache: Cache,
@@ -243,14 +243,11 @@ impl UiRenderState {
}
pub fn debug_layers(&self) {
for ((idx, depth), primitives) in self.layers.iter_depth() {
for ((idx, depth), draws) in self.layers.iter_depth() {
let indent = " ".repeat(depth * 2);
let len = primitives.instances().len();
print!("{indent}{idx}: {len} primitives");
if len >= 1 {
print!(" ({})", primitives.instances()[0].binding);
}
println!();
let primitives = draws.primitives.instances().len();
let textures = draws.textures.instances().len();
println!("{indent}{idx}: {primitives} primitives, {textures} textures");
}
}