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>
244 lines
7.6 KiB
Rust
244 lines
7.6 KiB
Rust
use image::{DynamicImage, EncodableLayout, GenericImageView, RgbaImage};
|
|
use wgpu::{util::DeviceExt, *};
|
|
|
|
use crate::{
|
|
PatchRect, TextureUpdate, Textures, UiData,
|
|
render::{
|
|
TexturePrimitive,
|
|
primitive::{ListDraw, PrimitiveRender},
|
|
},
|
|
};
|
|
|
|
/// Draws standalone images, which it owns. Each is its own texture, so each
|
|
/// instance binds its own and is a draw of its own.
|
|
pub struct ImageRender {
|
|
textures: GpuTextures,
|
|
layout: BindGroupLayout,
|
|
sampler: Sampler,
|
|
}
|
|
|
|
impl ImageRender {
|
|
pub fn new(device: &Device, queue: &Queue) -> Self {
|
|
Self {
|
|
textures: GpuTextures::new(device, queue),
|
|
layout: sampled_layout(device, TextureViewDimension::D2, "ui image"),
|
|
sampler: default_sampler(device),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PrimitiveRender for ImageRender {
|
|
fn layout(&self) -> Option<&BindGroupLayout> {
|
|
Some(&self.layout)
|
|
}
|
|
|
|
fn update(&mut self, ui: &mut UiData) {
|
|
self.textures
|
|
.update(&mut ui.textures, &self.layout, &self.sampler);
|
|
}
|
|
|
|
fn instance_bindings(&self, list: &super::InstanceList, out: &mut Vec<u32>) {
|
|
let slots = list
|
|
.data()
|
|
.chunks_exact(list.stride())
|
|
.map(|data| bytemuck::pod_read_unaligned::<TexturePrimitive>(data).slot);
|
|
out.extend(slots);
|
|
}
|
|
|
|
fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>) {
|
|
for (i, &slot) in list.bindings.iter().enumerate() {
|
|
let Some(image) = self.textures.group(slot) else {
|
|
continue;
|
|
};
|
|
pass.set_bind_group(2, image, &[]);
|
|
pass.draw(0..4, i as u32..i as u32 + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The standalone images a ui draws, each its own texture and bind group --
|
|
/// unlike the glyph atlas in `super::page`, which is one array they share.
|
|
pub struct GpuTextures {
|
|
device: Device,
|
|
queue: Queue,
|
|
slots: Vec<Option<ImageGpu>>,
|
|
}
|
|
|
|
struct ImageGpu {
|
|
/// Kept for `patch`, which needs the texture rather than the view.
|
|
texture: Texture,
|
|
group: BindGroup,
|
|
}
|
|
|
|
impl GpuTextures {
|
|
pub fn new(device: &Device, queue: &Queue) -> Self {
|
|
Self {
|
|
device: device.clone(),
|
|
queue: queue.clone(),
|
|
slots: Vec::new(),
|
|
}
|
|
}
|
|
|
|
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, sampler);
|
|
self.slots.push(Some(image));
|
|
}
|
|
TextureUpdate::Set(i, image) => {
|
|
let image = self.create(image, layout, sampler);
|
|
self.slots[i as usize] = Some(image);
|
|
}
|
|
TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image),
|
|
TextureUpdate::PushFree => self.slots.push(None),
|
|
TextureUpdate::SetFree => {}
|
|
TextureUpdate::Free(i) => self.slots[i as usize] = None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn group(&self, slot: u32) -> Option<&BindGroup> {
|
|
self.slots.get(slot as usize)?.as_ref().map(|i| &i.group)
|
|
}
|
|
|
|
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(
|
|
&self.queue,
|
|
&TextureDescriptor {
|
|
label: Some("image"),
|
|
size: Extent3d {
|
|
width,
|
|
height,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
mip_level_count: 1,
|
|
sample_count: 1,
|
|
dimension: TextureDimension::D2,
|
|
format: TextureFormat::Rgba8Unorm,
|
|
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
|
|
view_formats: &[],
|
|
},
|
|
wgt::TextureDataOrder::MipMajor,
|
|
rgba.as_bytes(),
|
|
);
|
|
let view = texture.create_view(&TextureViewDescriptor::default());
|
|
let group = sampled_group(&self.device, layout, &view, sampler, "ui image");
|
|
ImageGpu { texture, group }
|
|
}
|
|
|
|
fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) {
|
|
let Some(Some(slot)) = self.slots.get(i as usize) else {
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// What a primitive that samples binds: a texture, and the sampler that reads
|
|
/// it.
|
|
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),
|
|
})
|
|
}
|
|
|
|
/// The layout for one of those. The dimension differs -- the atlas is an
|
|
/// array of pages and an image is not -- and nothing else does.
|
|
pub fn sampled_layout(
|
|
device: &Device,
|
|
dimension: TextureViewDimension,
|
|
label: &'static str,
|
|
) -> BindGroupLayout {
|
|
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
|
|
entries: &[
|
|
BindGroupLayoutEntry {
|
|
binding: 0,
|
|
visibility: ShaderStages::FRAGMENT,
|
|
ty: BindingType::Texture {
|
|
sample_type: TextureSampleType::Float { filterable: false },
|
|
view_dimension: dimension,
|
|
multisampled: false,
|
|
},
|
|
count: None,
|
|
},
|
|
BindGroupLayoutEntry {
|
|
binding: 1,
|
|
visibility: ShaderStages::FRAGMENT,
|
|
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
|
|
count: None,
|
|
},
|
|
],
|
|
label: Some(label),
|
|
})
|
|
}
|
|
|
|
pub fn default_sampler(device: &Device) -> Sampler {
|
|
device.create_sampler(&SamplerDescriptor::default())
|
|
}
|