diff --git a/IRIS.md b/IRIS.md index 7630e6c..98f5422 100644 --- a/IRIS.md +++ b/IRIS.md @@ -8,4 +8,41 @@ capability that moved. Small and trivial changes do not go here. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. -_No entries yet._ +## 2026-09-04: texture pipeline rebuilt off the binding array + +`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all +changed shape. Why: the old pipeline bound every texture ever drawn in one +`binding_array>` and asked every device, unconditionally, +for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it, +and it failed outright on the Android emulator's software Vulkan. See +TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04". + +- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and + `UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue, + &config, UiLimits::default())`. After: `UiRenderNode::new(&device, + &queue, &config)`. Nothing replaces it — there are no more + binding-array limits to size. +- **`src/default/render.rs`'s device request asks for no features and no + binding-array limits.** Before: `required_features: + Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY + | Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` + plus two `max_binding_array_*` limits. After: `Features::empty()` (the + `DeviceDescriptor` default) and only `max_buffer_size` set, which was + never about the binding array. +- **`TextureHandle` has no `primitive()` method any more**; a caller + outside `iris` shouldn't have been calling it (it fed the old renderer's + internals), but if something did: use `image_index()` for a standalone + image's bind-group index. There is no equivalent for a page — a page has + no bind group of its own now, see below. +- **`GlyphPrimitive` has no public constructor from a struct literal.** + Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color, + flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color, + flags)` — one `layer` (the shared atlas array's layer) instead of a + `view_idx`/`sampler_idx` pair, since a page is now a layer of one array + texture rather than its own bound texture. +- **A widget author drawing images is unaffected**: `Painter::texture`/ + `texture_at`/`texture_within` and `Textures::add` keep their signatures. + What changed underneath is that each standalone image now gets its own + `wgpu::BindGroup` and draw call instead of a slot in the shared array — + invisible from the widget API, visible only in `UiRenderNode`'s internals + and in `iris`'s device requirements. diff --git a/RUST.md b/RUST.md index f36ca11..e175945 100644 --- a/RUST.md +++ b/RUST.md @@ -46,19 +46,25 @@ session spending an afternoon on them again. android-view's `accesskit_android` adapter also has a reproducible abort (a client detaching, not attaching, is the trigger) — see E1 below for both, with the mitigation iris/I4 needs to carry. -- **Blocking, found 2026-09-04, before I2 can be called done**: iris's - texture pipeline asks every device, unconditionally, for - `VK_EXT_descriptor_indexing` ("bindless" binding arrays) — and a - sourced check says a real share of Android hardware does not have it, - not just the emulator's software renderer. See "iris's binding array - does not survive real Android hardware" under the iris track for the - measurement, the sources, and the recommended fix (generalize the - glyph atlas to images, the same way I1 already did for text). Not yet - implemented; a design decision to confirm before iris's render core - changes. -- **Next**: decide on the atlas-based image fix above, then **I2** — - iris on android-view. **E2** (a transcript in Masonry) can go in - parallel in another session. +- **Resolved, 2026-09-04: iris's binding array does not survive real + Android hardware.** iris's texture pipeline used to ask every device, + unconditionally, for `VK_EXT_descriptor_indexing` ("bindless" binding + arrays), which a real share of Android hardware lacks. It has been + rebuilt per TEXTURES.md's "Recommended shape": the glyph atlas is one + `texture_2d_array` (a layer per page), a standalone image is its own + ordinary `Texture`/`BindGroup`, and `request_device` now asks for no + features and no binding-array limits at all. `rigs/gpu-probe`, rewritten + to match, confirms `request_device` now succeeds on the emulator's + software Vulkan (`EMU_GPU=software`, SwiftShader) — see TEXTURES.md's + "Implemented, 2026-09-04" for the exact command and output, and for what + was verified (rendering, via `run-headless.sh`) versus what was reasoned + through but not separately stress-tested (a real second-atlas-page + grow under load). Nothing here has been run on real Android hardware + yet, only the emulator; the Android Vulkan Profile 2025 sourcing in + "iris's binding array does not survive real Android hardware" below is + what stands in for that until I2 gets a device. +- **Next**: **I2** — iris on android-view. **E2** (a transcript in + Masonry) can go in parallel in another session. - **Not started**: `client-core`, which is item 1 of the recommendation below and does not depend on the framework choice. Nothing has been built for it, and it is not one of the numbered boxes — worth picking up @@ -868,7 +874,12 @@ step measured. nightly gates — `portable_simd` (the old glyph compositing) and `gen_blocks` (the deleted line iterator). **Eleven left.** -### iris's binding array does not survive real Android hardware (found 2026-09-04) +### iris's binding array does not survive real Android hardware (found 2026-09-04, resolved 2026-09-04) + +**Resolved the same day**: see "Where things stand" above and +TEXTURES.md's "Implemented, 2026-09-04". The measurement and sourcing +below are unchanged and are why the fix looks the way it does; nothing +here needs re-checking on its own account. Iris asked, of the "unknown number of images" case — a transcript with an unbounded number of attached screenshots — whether iris's approach even diff --git a/TEXTURES.md b/TEXTURES.md index e38e5df..c49b7c6 100644 --- a/TEXTURES.md +++ b/TEXTURES.md @@ -1,5 +1,16 @@ # How iris should render an unbounded number of images +## Status (2026-09-04) + +**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and +`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for +what landed, what differs from the proposal below and why, and what was +verified versus merely reasoned about. The short version: the binding array +is gone, `request_device` asks for no features and no binding-array limits, +and that is now proven on the emulator's software Vulkan +(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item +is resolved. + Iris (the person) asked whether iris's (the library's) approach to "draw however many images happen to be on screen" — relevant here because a transcript can hold an unbounded number of attached screenshots — actually @@ -333,3 +344,140 @@ needs to know an image from a page (two kinds of handle, or a kind on `TextureHandle`), and `Primitives` gets a second instance list per layer. What it saves: the sort, the size threshold, the eviction policy, and any per-page bind group switch. + +## Implemented, 2026-09-04 + +The shape above, built as proposed with one structural addition the proposal +didn't need to spell out and one bug it predicted made moot rather than +literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`, +`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`), +`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`), +`core/src/render/atlas.rs`, `core/src/ui/painter.rs`, +`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed), +`core/src/render/shader.wgsl`, `src/default/render.rs`, and +`rigs/gpu-probe/src/main.rs`. + +**1. Atlas pages as array layers.** `GpuTextures` owns one +`texture_2d_array` (`array_texture`/`array_view`), grown by doubling +(`grow_array`): a new texture is created at twice the layer capacity, the +old layers are copied across with `copy_texture_to_texture` (GPU-side, no +readback), and every bind group that referenced the old view — the main +one and every live standalone image's — is rebuilt, since the view's +identity changed. `GlyphPrimitive` carries `layer: u32` instead of +`view_idx`/`sampler_idx`; the layer number is assigned synchronously in +`Textures::add_page` (a plain counter, `next_page_layer`), not by the +renderer, because `GlyphAtlas::insert` needs it in the same call, before +any GPU sync happens — the renderer only finds out later, when it +processes the queued `Push`. + +**2. Standalone images, one bind group each.** `TextureKind` on +`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index, +`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list +— `images: Vec`, tagged `IMAGE_BINDING` — separate from +`instances` (rects and glyphs), written by `Painter::write_image` rather +than through the generic `Primitive` trait, since an image has nowhere in +`PrimitiveData` to put a per-instance entry once the bind group already +picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer +once as before, then walks `image_instance` one entry at a time, binding +that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing +`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed +`{atlas array, one image texture, sampler, masks}`; the main draw binds a +1x1 null view in the image slot. + +**The one addition beyond the proposal**: the masks storage buffer lives +in every per-image bind group (group 2, binding 3), and `ArrBuf` +recreates its buffer whenever the mask count changes size +(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized). +A resize invalidates every bind group holding the old buffer, not just the +main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls +`rebuild_image_bind_groups` when it's set, alongside the same rebuild the +array-growth path already needed. This wasn't a design question the +proposal had to answer (it treated bind-group construction as a given), +but it's exactly the shape of trap layer growth already had, so it uses +the same fix. + +**3. No thumbnail atlas.** Not built, as proposed. + +**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`, +`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from +`src/default/render.rs`'s `request_device`, and `UiLimits` (the type +itself, not just its binding-array methods — once its two fields were +gone there was nothing left in it, and `UiRenderNode::new` no longer takes +a limits parameter). `binding_array` no longer appears anywhere in +`shader.wgsl`. + +**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own +note that this is a separate decision for whenever the image widget itself +is touched. + +**The `changed = false` bug is structurally gone, not patched.** The old +`GpuTextures::update` held one `changed: bool` that a `Patch` reset +unconditionally, which could erase an earlier `Push` in the same batch (a +new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s +`Patch`, both queued before the renderer ever runs). The new `update` +computes the rebuild signal by OR-ing each event's own answer +(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never +contributes to it — there is no shared mutable flag left for a `Patch` to +stomp on. Documented at the call site +(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the +`Patch` match arm's comment) rather than fixed as a one-line diff, since +the mechanism that could go wrong no longer exists. + +**In-layer draw order is an explicit invariant now, not just a fact about +`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its +rects and glyphs, and `Primitives::apply_free`'s doc comment states +directly that both of a layer's lists (`instances` and `images`) free with +`swap_remove` and that nothing may assume adjacency survives a free — +recorded there because `apply_free` is the one place a change to either +list's ordering would have to be reconciled. + +**Verified:** + +- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`, + `cargo clippy --all-targets`, `cargo test --workspace` all clean in + `iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass + (unchanged from I1; nothing here is pure-logic enough to add a unit + test to — it's all GPU resource wiring). +- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and + `iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly + on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every + panel, confirming `GlyphPrimitive.layer` addresses the array correctly. +- The standalone-image path specifically: a throwaway example (not + committed) with an `image(...)` widget as part of the root, run the same + way, rendered the image next to glyph-atlas text in one frame — + confirming a live `BindGroup` built by `GpuTextures::create_image` and + bound per-`draw()` call actually samples the right texture. `tabs`'s own + "image span" tab exercises the same widget but needs a click to reach, + which the headless compositor can't deliver (no seat devices, per I1's + own note on this file) — the throwaway example is what stood in for it. +- **Not separately stress-tested**: triggering a second atlas page (the + `grow_array` doubling-and-copy path) under a real glyph load large + enough to fill the first 1024x1024 page. The code path was reasoned + through and matches the existing single-page write exactly except for + the `z` origin and the extra copy, but nobody has watched a real + second-page grow happen on screen. Worth doing before trusting this + under a transcript with a large or unusual glyph set (many distinct + fonts/sizes, or a font with an unusually large character set). +- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new + (empty) feature/limit set and run on this checkout's own emulator + (`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets + a real Vulkan device (SwiftShader) rather than the `-gpu host` default, + which disables Vulkan in this VM entirely (`-feature -Vulkan`, because + gfxstream can't pair Venus with the real GPU here — worth remembering, + since the *default* `emu up` gives a device with **no** Vulkan adapter + at all, which reads exactly like the old bindless failure if you don't + know to ask for `EMU_GPU=software`): + + cd rigs/gpu-probe + ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \ + cargo ndk -t arm64-v8a -P 26 build --release + EMU_GPU=software emu up # from ~/repos/emulator-tools + adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/ + adb shell chmod 755 /data/local/tmp/gpu-probe + adb shell /data/local/tmp/gpu-probe + + Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`, + `features iris requires:` (none listed — the set is empty), + `max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix + measured working, on the exact rig that first measured it failing. + Emulator stopped afterward (`emu down`); nothing was left running. diff --git a/iris/core/src/primitive/layer.rs b/iris/core/src/primitive/layer.rs index 54f6a96..5f62efd 100644 --- a/iris/core/src/primitive/layer.rs +++ b/iris/core/src/primitive/layer.rs @@ -1,6 +1,7 @@ use std::ops::{Index, IndexMut}; use crate::{ + UiRegion, WidgetId, render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives}, util::to_mut, }; @@ -131,6 +132,17 @@ impl PrimitiveLayers { pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx { self[h.layer].free(h) } + + pub fn write_image( + &mut self, + layer: LayerId, + id: WidgetId, + texture_idx: u32, + region: UiRegion, + mask_idx: MaskIdx, + ) -> PrimitiveHandle { + self[layer].write_image(layer, id, texture_idx, region, mask_idx) + } } impl Default for Layers { diff --git a/iris/core/src/primitive/texture.rs b/iris/core/src/primitive/texture.rs index 4fec782..8486c0b 100644 --- a/iris/core/src/primitive/texture.rs +++ b/iris/core/src/primitive/texture.rs @@ -1,19 +1,33 @@ -use crate::{ - render::TexturePrimitive, - util::{RefCounter, Vec2}, -}; +use crate::util::{RefCounter, Vec2}; use image::{DynamicImage, GenericImageView}; use std::{ ops::Index, sync::mpsc::{Receiver, Sender, channel}, }; +/// Which of the two things a texture slot holds. See TEXTURES.md's +/// "Recommended shape" for why these are drawn so differently: a page is a +/// layer of one shared array texture and never gets its own bind group; a +/// standalone image is the opposite, one texture and one bind group, never a +/// layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextureKind { + Image, + /// The array-texture layer this page was assigned. Chosen synchronously + /// by `Textures::add_page` rather than by the renderer, because glyph + /// insertion needs it in the same call, before any GPU sync happens. + Page { + layer: u32, + }, +} + #[derive(Debug, Clone)] pub struct TextureHandle { - inner: TexturePrimitive, + slot: u32, + kind: TextureKind, size: Vec2, counter: RefCounter, - send: Sender, + send: Sender<(TextureKind, u32)>, } /// a texture manager for a ui @@ -21,20 +35,24 @@ pub struct TextureHandle { pub struct Textures { free: Vec, images: Vec>, + /// Next layer to hand out to an atlas page. Pages are never freed (no + /// atlas eviction), so this only grows and `free` never holds one. + next_page_layer: u32, updates: Vec, - send: Sender, - recv: Receiver, + send: Sender<(TextureKind, u32)>, + recv: Receiver<(TextureKind, u32)>, } pub enum TextureUpdate<'a> { - Push(&'a DynamicImage), - Set(u32, &'a DynamicImage), + Push(TextureKind, &'a DynamicImage), + Set(TextureKind, u32, &'a DynamicImage), /// Overwrite a rectangle of an existing texture, rather than replacing it. /// The glyph atlas grows a glyph at a time, and re-uploading a whole atlas /// per glyph is megabytes of copy for a few hundred bytes of change. + /// Only ever issued against a page -- a standalone image is never patched. Patch(u32, PatchRect, &'a DynamicImage), Free(u32), - PushFree, + PushFree(TextureKind), SetFree, } @@ -47,8 +65,8 @@ pub struct PatchRect { } enum Update { - Push(u32), - Set(u32), + Push(TextureKind, u32), + Set(TextureKind, u32), Patch(u32, PatchRect), Free(u32), } @@ -59,71 +77,95 @@ impl Textures { Self { free: Vec::new(), images: Vec::new(), + next_page_layer: 0, updates: Vec::new(), send, recv, } } + pub fn add(&mut self, image: impl Into) -> TextureHandle { let image = image.into(); let size = image.dimensions().into(); - let view_idx = self.push(image); - // 0 == default in renderer; TODO: actually create samplers here - let sampler_idx = 0; + let kind = TextureKind::Image; + let slot = self.push(kind, image); TextureHandle { - inner: TexturePrimitive { - view_idx, - sampler_idx, - }, + slot, + kind, size, counter: RefCounter::new(), send: self.send.clone(), } } - fn push(&mut self, image: DynamicImage) -> u32 { + /// Adds a page of the shared glyph atlas array. Only `atlas.rs` should + /// call this -- everything else wants `add`. + pub fn add_page(&mut self, image: impl Into) -> TextureHandle { + let image = image.into(); + let size = image.dimensions().into(); + let layer = self.next_page_layer; + self.next_page_layer += 1; + let kind = TextureKind::Page { layer }; + let slot = self.push(kind, image); + TextureHandle { + slot, + kind, + size, + counter: RefCounter::new(), + send: self.send.clone(), + } + } + + fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 { if let Some(i) = self.free.pop() { self.images[i as usize] = Some(image); - self.updates.push(Update::Set(i)); + self.updates.push(Update::Set(kind, i)); i } else { let i = self.images.len() as u32; self.images.push(Some(image)); - self.updates.push(Update::Push(i)); + self.updates.push(Update::Push(kind, i)); i } } /// The stored image for a handle, to be written into before `patch`. pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage { - self.images[handle.inner.view_idx as usize] + self.images[handle.slot as usize] .as_mut() .expect("texture was freed while still held") } /// Queue an upload of just `rect`, after writing it with `image_mut`. pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) { - self.updates - .push(Update::Patch(handle.inner.view_idx, rect)); + self.updates.push(Update::Patch(handle.slot, rect)); } pub fn free(&mut self) { - for idx in self.recv.try_iter() { + for (kind, idx) in self.recv.try_iter() { self.images[idx as usize] = None; self.updates.push(Update::Free(idx)); - self.free.push(idx); + // A page's slot is never reclaimed: `GlyphAtlas` never drops the + // handles it holds, and there is no eviction path for a hole in + // the middle of the array's layers. If that ever changes, this + // is where a freed page's layer would need to go on a free list + // of its own, separate from `free`, which only ever holds + // ordinary image slots today. + if kind == TextureKind::Image { + self.free.push(idx); + } } } pub fn updates(&mut self) -> impl Iterator> { self.updates.drain(..).map(|u| match u { - Update::Push(i) => self.images[i as usize] + Update::Push(kind, i) => self.images[i as usize] .as_ref() - .map(TextureUpdate::Push) - .unwrap_or(TextureUpdate::PushFree), - Update::Set(i) => self.images[i as usize] + .map(|img| TextureUpdate::Push(kind, img)) + .unwrap_or(TextureUpdate::PushFree(kind)), + Update::Set(kind, i) => self.images[i as usize] .as_ref() - .map(|img| TextureUpdate::Set(i, img)) + .map(|img| TextureUpdate::Set(kind, i, img)) .unwrap_or(TextureUpdate::SetFree), Update::Patch(i, rect) => self.images[i as usize] .as_ref() @@ -135,18 +177,36 @@ impl Textures { } impl TextureHandle { - pub fn primitive(&self) -> TexturePrimitive { - self.inner - } pub fn size(&self) -> Vec2 { self.size } + + /// The bind-group index this handle draws with. Only valid for a + /// standalone image; an atlas page has no bind group of its own -- it + /// samples the shared array via `layer()` instead. Getting this wrong is + /// a caller bug (the wrong kind of handle reached the wrong draw path), + /// not a recoverable condition, so it panics rather than drawing garbage. + pub fn image_index(&self) -> u32 { + match self.kind { + TextureKind::Image => self.slot, + TextureKind::Page { .. } => panic!("image_index() called on an atlas page handle"), + } + } + + /// The layer this page occupies in the shared atlas array texture. + /// Only valid for a page handle; see `image_index`'s note. + pub fn layer(&self) -> u32 { + match self.kind { + TextureKind::Page { layer } => layer, + TextureKind::Image => panic!("layer() called on a standalone image handle"), + } + } } impl Drop for TextureHandle { fn drop(&mut self) { if self.counter.drop() { - let _ = self.send.send(self.inner.view_idx); + let _ = self.send.send((self.kind, self.slot)); } } } @@ -155,7 +215,7 @@ impl Index<&TextureHandle> for Textures { type Output = DynamicImage; fn index(&self, index: &TextureHandle) -> &Self::Output { - self.images[index.inner.view_idx as usize].as_ref().unwrap() + self.images[index.slot as usize].as_ref().unwrap() } } diff --git a/iris/core/src/render/atlas.rs b/iris/core/src/render/atlas.rs index a85d9ac..ec29a19 100644 --- a/iris/core/src/render/atlas.rs +++ b/iris/core/src/render/atlas.rs @@ -18,8 +18,10 @@ use swash::scale::image::{Content, Image}; /// Side of one atlas page, in pixels. 1024 is 4 MB at RGBA8 -- enough for a /// few thousand glyphs at UI sizes, and small enough that a page nobody fills -/// is not a big waste. -const PAGE: u32 = 1024; +/// is not a big waste. Also the fixed width/height of every layer of the +/// shared array texture in `render::texture` -- `pub(crate)` so that module +/// can size it without a second constant to keep in sync. +pub(crate) const PAGE: u32 = 1024; /// Transparent margin kept around every glyph, so that sampling one cannot /// pick up its neighbour along a shared edge. @@ -51,8 +53,8 @@ pub struct GlyphEntry { pub width: u32, pub height: u32, pub is_color: bool, - pub view_idx: u32, - pub sampler_idx: u32, + /// The atlas array layer this glyph's page occupies. + pub layer: u32, } struct Page { @@ -126,8 +128,7 @@ impl GlyphAtlas { width: w, height: h, is_color: matches!(image.content, Content::Color), - view_idx: page.handle.primitive().view_idx, - sampler_idx: page.handle.primitive().sampler_idx, + layer: page.handle.layer(), }; self.entries.insert(key, Some(entry)); Some(entry) @@ -150,7 +151,7 @@ impl GlyphAtlas { return (i, x, y); } - let handle = textures.add(RgbaImage::new(PAGE, PAGE)); + let handle = textures.add_page(RgbaImage::new(PAGE, PAGE)); self.pages.push(Page { handle, x: PAD + w + PAD, diff --git a/iris/core/src/render/mod.rs b/iris/core/src/render/mod.rs index e298230..87799eb 100644 --- a/iris/core/src/render/mod.rs +++ b/iris/core/src/render/mod.rs @@ -1,5 +1,3 @@ -use std::num::NonZero; - use crate::{ UiData, UiRenderState, render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf}, @@ -42,21 +40,44 @@ struct RenderLayer { instance: ArrBuf, primitives: PrimitiveBuffers, primitive_group: BindGroup, + /// A standalone image's instances, kept apart from `instance` because + /// each one draws with its own bind group -- see `UiRenderNode::draw`. + image_instance: ArrBuf, + /// The texture slot each entry of `image_instance` draws with, in the + /// same order, refreshed alongside it. Not stored in the vertex buffer + /// itself because it names a bind group, not shader data. + image_tex_indices: Vec, } impl UiRenderNode { pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) { pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.uniform_group, &[]); - pass.set_bind_group(2, &self.rsc_group, &[]); for i in &self.active { let layer = &self.layers[i]; - if layer.instance.len() == 0 { + if layer.instance.len() == 0 && layer.image_instance.len() == 0 { continue; } pass.set_bind_group(1, &layer.primitive_group, &[]); - pass.set_vertex_buffer(0, layer.instance.buffer.slice(..)); - pass.draw(0..4, 0..layer.instance.len() as u32); + if layer.instance.len() > 0 { + pass.set_bind_group(2, &self.rsc_group, &[]); + pass.set_vertex_buffer(0, layer.instance.buffer.slice(..)); + pass.draw(0..4, 0..layer.instance.len() as u32); + } + // Images draw after this layer's rects and glyphs, one draw call + // each with its own bind group. That draws every image "on top" + // within the layer, which loses nothing that currently exists: + // `Primitives::apply_free` frees with `swap_remove`, so a layer's + // draw order was already undefined before images had their own + // list -- nothing before this relied on interleaving a rect + // between two images at a particular position. + if layer.image_instance.len() > 0 { + pass.set_vertex_buffer(0, layer.image_instance.buffer.slice(..)); + for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() { + pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]); + pass.draw(0..4, k as u32..k as u32 + 1); + } + } } } @@ -73,7 +94,15 @@ impl UiRenderNode { for change in primitives.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 { + // `is_image` disambiguates: `instances` and `images` + // are separate lists with independent indices, so + // without it a rect's renumbering could be applied to + // an image handle that happened to share the same + // (layer, inst_idx). + if h.layer == i + && h.inst_idx == change.old + && (h.binding == IMAGE_BINDING) == change.is_image + { h.inst_idx = change.new; break; } @@ -92,6 +121,12 @@ impl UiRenderNode { ), primitives, primitive_group, + image_instance: ArrBuf::new( + device, + BufferUsages::VERTEX | BufferUsages::COPY_DST, + "image instance", + ), + image_tex_indices: Vec::new(), } }); if primitives.updated { @@ -104,17 +139,30 @@ impl UiRenderNode { &self.primitive_layout, rlayer.primitives.buffers(), ); + rlayer + .image_instance + .update(device, queue, primitives.image_instances()); + rlayer.image_tex_indices = primitives + .image_instances() + .iter() + .map(|inst| inst.idx) + .collect(); primitives.updated = false; } } - let mut changed = false; - changed |= self.textures.update(&mut ui.textures); - if ui.masks.changed { + let masks_resized = if ui.masks.changed { ui.masks.changed = false; - self.masks.update(device, queue, &ui.masks[..]); - changed = true; - } - if changed { + self.masks.update(device, queue, &ui.masks[..]) + } else { + false + }; + let rebuild_main = self.textures.update( + &mut ui.textures, + &self.rsc_layout, + &self.masks, + masks_resized, + ); + if rebuild_main { self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures, &self.masks); } } @@ -132,12 +180,7 @@ impl UiRenderNode { queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice)); } - pub fn new( - device: &Device, - queue: &Queue, - config: &SurfaceConfiguration, - limits: UiLimits, - ) -> Self { + pub fn new(device: &Device, queue: &Queue, config: &SurfaceConfiguration) -> Self { let shader = device.create_shader_module(ShaderModuleDescriptor { label: Some("UI Shape Shader"), source: ShaderSource::Wgsl(SHAPE_SHADER.into()), @@ -167,17 +210,15 @@ impl UiRenderNode { let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer); let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor { - entries: &core::array::from_fn::<_, { PrimitiveBuffers::LEN }, _>(|i| { - BindGroupLayoutEntry { - binding: i as u32, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - } + entries: &PrimitiveBuffers::BINDINGS.map(|binding| BindGroupLayoutEntry { + binding, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }), label: Some("primitive"), }); @@ -189,7 +230,7 @@ impl UiRenderNode { "ui masks", ); - let rsc_layout = Self::rsc_layout(device, &limits); + let rsc_layout = Self::rsc_layout(device); let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks); let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { @@ -279,7 +320,13 @@ impl UiRenderNode { }) } - fn rsc_layout(device: &Device, limits: &UiLimits) -> BindGroupLayout { + /// Group 2: the shared atlas array, one standalone-image slot (a null + /// view for the main draw, a real one for each image's own bind group -- + /// see `GpuTextures`), one sampler and the masks buffer. No `count` on + /// any entry: this needs nothing beyond plain Vulkan 1.0 / GLES + /// sampling, unlike the `binding_array` layout it replaced (see + /// TEXTURES.md's "Recommended shape"). + fn rsc_layout(device: &Device) -> BindGroupLayout { device.create_bind_group_layout(&BindGroupLayoutDescriptor { entries: &[ BindGroupLayoutEntry { @@ -287,20 +334,30 @@ impl UiRenderNode { visibility: ShaderStages::FRAGMENT, ty: BindingType::Texture { sample_type: TextureSampleType::Float { filterable: false }, - view_dimension: TextureViewDimension::D2, + view_dimension: TextureViewDimension::D2Array, multisampled: false, }, - count: Some(NonZero::new(limits.max_textures).unwrap()), + count: None, }, BindGroupLayoutEntry { binding: 1, visibility: ShaderStages::FRAGMENT, - ty: BindingType::Sampler(SamplerBindingType::NonFiltering), - count: Some(NonZero::new(limits.max_samplers).unwrap()), + ty: BindingType::Texture { + sample_type: TextureSampleType::Float { filterable: false }, + view_dimension: TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, BindGroupLayoutEntry { binding: 2, visibility: ShaderStages::FRAGMENT, + ty: BindingType::Sampler(SamplerBindingType::NonFiltering), + count: None, + }, + BindGroupLayoutEntry { + binding: 3, + visibility: ShaderStages::FRAGMENT, ty: BindingType::Buffer { ty: BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, @@ -313,6 +370,8 @@ impl UiRenderNode { }) } + /// The main group: rects and glyphs never sample the image slot, so it + /// gets a 1x1 null view rather than any live standalone image's. fn rsc_group( device: &Device, layout: &BindGroupLayout, @@ -324,14 +383,18 @@ impl UiRenderNode { entries: &[ BindGroupEntry { binding: 0, - resource: BindingResource::TextureViewArray(&tex_manager.views()), + resource: BindingResource::TextureView(tex_manager.array_view()), }, BindGroupEntry { binding: 1, - resource: BindingResource::SamplerArray(&tex_manager.samplers()), + resource: BindingResource::TextureView(tex_manager.null_view()), }, BindGroupEntry { binding: 2, + resource: BindingResource::Sampler(tex_manager.sampler()), + }, + BindGroupEntry { + binding: 3, resource: masks.buffer.as_entire_binding(), }, ], @@ -343,26 +406,3 @@ impl UiRenderNode { self.textures.view_count() } } - -pub struct UiLimits { - max_textures: u32, - max_samplers: u32, -} - -impl Default for UiLimits { - fn default() -> Self { - Self { - max_textures: 100000, - max_samplers: 1000, - } - } -} - -impl UiLimits { - pub fn max_binding_array_elements_per_shader_stage(&self) -> u32 { - self.max_textures + self.max_samplers - } - pub fn max_binding_array_sampler_elements_per_shader_stage(&self) -> u32 { - self.max_samplers - } -} diff --git a/iris/core/src/render/primitive.rs b/iris/core/src/render/primitive.rs index b79a0e6..a283af3 100644 --- a/iris/core/src/render/primitive.rs +++ b/iris/core/src/render/primitive.rs @@ -15,6 +15,18 @@ pub struct Primitives { assoc: Vec, data: PrimitiveData, free: Vec, + + /// Standalone images, kept apart from `instances` because each one draws + /// with its own bind group rather than sharing the layer's one instanced + /// draw -- see TEXTURES.md's "Recommended shape". `idx` on each + /// `PrimitiveInstance` here is the texture's slot in `Textures`/ + /// `GpuTextures`, not an index into `data`; there is no per-image entry + /// in `data` because a bind group already picks the texture; nothing + /// left to look up per-instance. + images: Vec, + image_assoc: Vec, + image_free: Vec, + pub updated: bool, } @@ -25,11 +37,21 @@ impl Default for Primitives { assoc: Default::default(), data: Default::default(), free: Vec::new(), + images: Default::default(), + image_assoc: Default::default(), + image_free: Vec::new(), updated: true, } } } +/// The `binding` tag `Painter` writes on an image instance. Distinct from any +/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key +/// one from -- a bind group already selects the texture -- so this only ever +/// has to match the shader's `TEXTURE` constant and flag "this instance lives +/// in `Primitives::images`, not `Primitives::instances`" to the code below. +pub const IMAGE_BINDING: u32 = 1; + pub trait Primitive: Pod { const BINDING: u32; fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec; @@ -54,6 +76,14 @@ macro_rules! primitives { impl PrimitiveBuffers { pub const LEN: usize = primitives!(@count $($name)*); + /// The group-1 binding number each primitive's storage buffer + /// sits at, in declaration order. Not `0..LEN`: a primitive's + /// `BINDING` also tags its instances for the shader's dispatch + /// switch, and a removed primitive (as `TEXTURE` was, once + /// images stopped needing a per-instance storage entry) can + /// leave a gap, so the pipeline layout has to ask for these + /// exact numbers rather than assuming they are contiguous. + pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*]; pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] { [ $((<$ty>::BINDING, &self.$name.buffer),)* @@ -143,26 +173,103 @@ impl Primitives { PrimitiveHandle::new::

(layer, inst_i, i) } - /// returns (old index, new index) - pub fn apply_free(&mut self) -> impl Iterator { - 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 }) - }) + /// Writes an image instance directly -- there is no `Primitive` impl for + /// it to go through `write`, since it has nowhere in `PrimitiveData` to + /// put a per-instance entry. `texture_idx` is the slot the bind group at + /// draw time is chosen from, carried in the otherwise-unused `idx` field. + pub fn write_image( + &mut self, + layer: usize, + id: WidgetId, + texture_idx: u32, + region: UiRegion, + mask_idx: MaskIdx, + ) -> PrimitiveHandle { + self.updated = true; + let inst = PrimitiveInstance { + region, + idx: texture_idx, + mask_idx, + binding: IMAGE_BINDING, + }; + let inst_i = if let Some(i) = self.image_free.pop() { + self.images[i] = inst; + self.image_assoc[i] = id; + i + } else { + let i = self.images.len(); + self.images.push(inst); + self.image_assoc.push(id); + i + }; + PrimitiveHandle { + layer, + inst_idx: inst_i, + data_idx: 0, + binding: IMAGE_BINDING, + } + } + + pub fn image_instances(&self) -> &Vec { + &self.images + } + + /// returns (old index, new index) for both lists this layer keeps -- + /// `PrimitiveChange::is_image` says which, since the two have separate + /// index spaces and `old`/`new` alone would collide between them. + /// + /// Both lists free with `swap_remove`, so a layer's draw order was + /// already undefined before images existed: nothing here may assume one + /// primitive stays adjacent to another once anything in the layer has + /// been freed. + pub fn apply_free(&mut self) -> Vec { + let mut changes = + Self::apply_free_list(&mut self.free, &mut self.instances, &mut self.assoc, false); + changes.extend(Self::apply_free_list( + &mut self.image_free, + &mut self.images, + &mut self.image_assoc, + true, + )); + changes + } + + fn apply_free_list( + free: &mut Vec, + instances: &mut Vec, + assoc: &mut Vec, + is_image: bool, + ) -> Vec { + free.sort_by(|a, b| b.cmp(a)); + free.drain(..) + .filter_map(|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, + is_image, + old, + new: i, + }) + }) + .collect() } pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx { self.updated = true; - self.data.free(h.binding, h.data_idx); - self.free.push(h.inst_idx); - self.instances[h.inst_idx].mask_idx + if h.binding == IMAGE_BINDING { + self.image_free.push(h.inst_idx); + self.images[h.inst_idx].mask_idx + } else { + self.data.free(h.binding, h.data_idx); + self.free.push(h.inst_idx); + self.instances[h.inst_idx].mask_idx + } } pub fn data(&self) -> &PrimitiveData { @@ -175,12 +282,21 @@ impl Primitives { pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion { self.updated = true; - &mut self.instances[h.inst_idx].region + if h.binding == IMAGE_BINDING { + &mut self.images[h.inst_idx].region + } else { + &mut self.instances[h.inst_idx].region + } } } pub struct PrimitiveChange { pub id: WidgetId, + /// Which of `Primitives::instances`/`Primitives::images` this change + /// belongs to -- their `old`/`new` indices are independent, so a + /// consumer matching only on `(layer, inst_idx)` could apply an image's + /// renumbering to a rect's handle that happens to share the same index. + pub is_image: bool, pub old: usize, pub new: usize, } @@ -206,7 +322,6 @@ impl PrimitiveHandle { primitives!( rects: RectPrimitive => 0, - textures: TexturePrimitive => 1, glyphs: GlyphPrimitive => 2, ); @@ -230,33 +345,48 @@ impl RectPrimitive { } } -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct TexturePrimitive { - pub view_idx: u32, - pub sampler_idx: u32, -} - -/// One glyph, drawn as a sub-rectangle of the glyph atlas. +/// One glyph, drawn as a sub-rectangle of the glyph atlas array. /// -/// Separate from `TexturePrimitive` because that one samples a whole texture: -/// text needs many quads sharing one atlas, which is the whole point of having -/// an atlas. `color` is the text colour and is multiplied by the atlas's alpha -/// for an ordinary mask glyph; a colour glyph (emoji) carries its own colour -/// and takes the atlas texel unchanged, which is what `IS_COLOR` selects. +/// `color` is the text colour and is multiplied by the atlas's alpha for an +/// ordinary mask glyph; a colour glyph (emoji) carries its own colour and +/// takes the atlas texel unchanged, which is what `IS_COLOR` selects. #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct GlyphPrimitive { pub uv_min: [f32; 2], pub uv_max: [f32; 2], - pub view_idx: u32, - pub sampler_idx: u32, + /// Layer of the shared atlas array texture this glyph's page occupies -- + /// not a bind-group or view index, since a page never gets one of its + /// own. See TEXTURES.md's "Recommended shape". + pub layer: u32, pub color: Color, pub flags: u32, + /// Pads this struct's Rust size to match WGSL's storage-buffer layout for + /// `GlyphInfo`: two `vec2` members give the struct an 8-byte + /// alignment, which rounds the WGSL size up to 32 bytes even though the + /// fields above only total 28. `bytemuck` does not check this for us. + _pad: u32, } impl GlyphPrimitive { pub const IS_COLOR: u32 = 1; + + pub fn new( + uv_min: [f32; 2], + uv_max: [f32; 2], + layer: u32, + color: Color, + flags: u32, + ) -> Self { + Self { + uv_min, + uv_max, + layer, + color, + flags, + _pad: 0, + } + } } pub struct PrimitiveVec { diff --git a/iris/core/src/render/shader.wgsl b/iris/core/src/render/shader.wgsl index 1e6df68..4606188 100644 --- a/iris/core/src/render/shader.wgsl +++ b/iris/core/src/render/shader.wgsl @@ -1,4 +1,7 @@ const RECT: u32 = 0u; +// TEXTURE has no entry in group 1: a standalone image draws with its own +// bind group (see UiRenderNode::draw), so there is nothing per-instance left +// to look up here -- the bind group already picked the texture. const TEXTURE: u32 = 1u; const GLYPH: u32 = 2u; @@ -6,8 +9,6 @@ const GLYPH: u32 = 2u; var window: WindowUniform; @group(1) @binding(RECT) var rects: array; -@group(1) @binding(TEXTURE) -var textures: array; @group(1) @binding(GLYPH) var glyphs: array; @@ -18,16 +19,13 @@ struct Rect { inner_radius: f32, } -struct TextureInfo { - view_idx: u32, - sampler_idx: u32, -} - struct GlyphInfo { uv_min: vec2, uv_max: vec2, - view_idx: u32, - sampler_idx: u32, + // Layer of the shared atlas array texture, not a view or bind-group + // index -- a page never gets its own bind group. See TEXTURES.md's + // "Recommended shape". + layer: u32, color: u32, flags: u32, } @@ -52,11 +50,21 @@ struct UiVec2 { abs: vec2, } +// The shared glyph atlas: every page is one layer. Growing it recreates this +// texture with headroom and copies the old layers across -- see +// GpuTextures::grow_array -- rather than the binding_array> +// this replaced, which needed VK_EXT_descriptor_indexing and does not survive +// a real share of Android GPUs (see TEXTURES.md). @group(2) @binding(0) -var views: binding_array>; +var atlas: texture_2d_array; +// One standalone image's texture. The main draw (rects and glyphs) binds a +// 1x1 null texture here, since neither samples it; each image draw call +// binds its own -- see UiRenderNode::draw. @group(2) @binding(1) -var samplers: binding_array; +var image_texture: texture_2d; @group(2) @binding(2) +var samp: sampler; +@group(2) @binding(3) var masks: array; struct WindowUniform { @@ -135,7 +143,7 @@ fn fs_main( color = draw_rounded_rect(region, rects[i]); } case TEXTURE: { - color = draw_texture(region, textures[i]); + color = draw_texture(region); } case GLYPH: { color = draw_glyph(region, glyphs[i]); @@ -158,14 +166,13 @@ fn fs_main( return color; } -// TODO: this seems really inefficient (per frag indexing)? -fn draw_texture(region: Region, info: TextureInfo) -> vec4 { - return textureSample(views[info.view_idx], samplers[info.sampler_idx], region.uv); +fn draw_texture(region: Region) -> vec4 { + return textureSample(image_texture, samp, region.uv); } fn draw_glyph(region: Region, g: GlyphInfo) -> vec4 { let uv = mix(g.uv_min, g.uv_max, region.uv); - let texel = textureSample(views[g.view_idx], samplers[g.sampler_idx], uv); + let texel = textureSample(atlas, samp, uv, i32(g.layer)); if (g.flags & 1u) != 0u { return texel; } diff --git a/iris/core/src/render/texture.rs b/iris/core/src/render/texture.rs index 5c44ef0..0ae5642 100644 --- a/iris/core/src/render/texture.rs +++ b/iris/core/src/render/texture.rs @@ -1,69 +1,165 @@ use image::{DynamicImage, EncodableLayout, GenericImageView}; use wgpu::{util::DeviceExt, *}; -use crate::{PatchRect, TextureUpdate, Textures}; +use crate::{Mask, PatchRect, TextureKind, TextureUpdate, Textures, render::util::ArrBuf}; +use super::atlas::PAGE; + +/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot +/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the +/// same thing on both sides without a second map to keep in sync. +enum Slot { + /// A slot that was freed, or pushed and freed within the same batch + /// before ever reaching here. + Empty, + Image(ImageGpu), + /// The array layer a page occupies. Pages are never freed (see + /// `Textures::free`), so this is the only variant that outlives a `Free`. + Page(u32), +} + +struct ImageGpu { + /// Kept alive alongside `view`/`bind_group`, which borrow from it only in + /// the sense that dropping this drops the GPU resource they point to. + #[allow(dead_code)] + texture: Texture, + view: TextureView, + bind_group: BindGroup, +} + +/// Owns the two kinds of texture iris draws: +/// +/// - **The glyph atlas**, one `texture_2d_array` whose layers are pages +/// (`Slot::Page`), grown by recreating the array with headroom and +/// `copy_texture_to_texture`-ing the old layers across. No feature beyond +/// Vulkan 1.0/GLES sampling is needed for this -- a layer index is an +/// ordinary sampling operand. +/// - **Standalone images** (`Slot::Image`), each its own `Texture` and +/// `BindGroup`, drawn one `draw()` call at a time with that bind group +/// bound -- see `UiRenderNode::draw`. +/// +/// See TEXTURES.md's "Recommended shape" for why, and RUST.md's +/// "iris's binding array does not survive real Android hardware" for what +/// this replaced (one giant `binding_array>` needing +/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack). pub struct GpuTextures { device: Device, queue: Queue, - /// Kept alongside the views because a patch writes into the texture, and a - /// view cannot be written through. Parallel to `views`; `None` where the - /// slot is the shared null view. - textures: Vec>, - views: Vec, - view_count: usize, - samplers: Vec, + + slots: Vec, + + array_texture: Texture, + array_view: TextureView, + array_capacity: u32, + /// Layers actually written. Only grows -- see `Slot::Page`. + page_count: u32, + + sampler: Sampler, + /// Bound in the image slot of the main draw's bind group, which has + /// nothing of its own to put there: rects and glyphs never sample it, + /// but the layout requires something bound regardless. null_view: TextureView, - no_views: Vec, } impl GpuTextures { - pub fn update(&mut self, textures: &mut Textures) -> bool { - let mut changed = false; + /// Applies queued `Textures` updates, then reports whether the *main* + /// bind group (the one rects and glyphs draw with) needs rebuilding -- + /// true when the atlas array was recreated (its view identity changed) + /// or the masks buffer was, since both are bound there. Pushing or + /// freeing a standalone image never touches that group: it built or drops + /// its own. + pub fn update( + &mut self, + textures: &mut Textures, + rsc_layout: &BindGroupLayout, + masks: &ArrBuf, + masks_resized: bool, + ) -> bool { + let mut rebuild_main = masks_resized; + if masks_resized { + // The masks buffer just moved, so every bind group holding a + // reference to it -- one per live standalone image -- is stale. + self.rebuild_image_bind_groups(rsc_layout, masks); + } for update in textures.updates() { - changed = true; match update { - TextureUpdate::Push(image) => self.push(image), - TextureUpdate::Set(i, image) => self.set(i, image), - TextureUpdate::Patch(i, rect, image) => { - // A patch changes texture contents, not the binding array, - // so it must not report `changed` -- rebuilding the bind - // group per glyph is the cost this exists to avoid. - self.patch(i, rect, image); - changed = false; + TextureUpdate::Push(kind, image) => { + rebuild_main |= self.push(kind, image, rsc_layout, masks); } - TextureUpdate::SetFree => self.view_count += 1, + TextureUpdate::Set(kind, i, image) => { + rebuild_main |= self.set(kind, i, image, rsc_layout, masks); + } + // A patch changes texture contents, not which layer or bind + // group exists, so it never asks for a rebuild -- rebuilding + // per glyph is exactly the cost this exists to avoid. + TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image), + TextureUpdate::SetFree => {} TextureUpdate::Free(i) => self.free(i), - TextureUpdate::PushFree => self.push_free(), + TextureUpdate::PushFree(_kind) => self.slots.push(Slot::Empty), } } - changed + rebuild_main } - fn set(&mut self, i: u32, image: &DynamicImage) { - self.view_count += 1; - let (texture, view) = self.create(image); - self.textures[i as usize] = Some(texture); - self.views[i as usize] = view; + + fn push( + &mut self, + kind: TextureKind, + image: &DynamicImage, + rsc_layout: &BindGroupLayout, + masks: &ArrBuf, + ) -> bool { + let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks); + self.slots.push(slot); + rebuilt } + + fn set( + &mut self, + kind: TextureKind, + i: u32, + image: &DynamicImage, + rsc_layout: &BindGroupLayout, + masks: &ArrBuf, + ) -> bool { + let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks); + self.slots[i as usize] = slot; + rebuilt + } + + fn make_slot( + &mut self, + kind: TextureKind, + image: &DynamicImage, + rsc_layout: &BindGroupLayout, + masks: &ArrBuf, + ) -> (Slot, bool) { + match kind { + TextureKind::Image => { + let gpu = self.create_image(image, rsc_layout, masks); + (Slot::Image(gpu), false) + } + TextureKind::Page { layer } => { + let mut rebuilt = false; + if layer >= self.array_capacity { + self.grow_array(rsc_layout, masks); + rebuilt = true; + } + self.write_full_layer(layer, image); + self.page_count = self.page_count.max(layer + 1); + (Slot::Page(layer), rebuilt) + } + } + } + fn free(&mut self, i: u32) { - self.view_count -= 1; - self.textures[i as usize] = None; - self.views[i as usize] = self.null_view.clone(); - } - fn push(&mut self, image: &DynamicImage) { - self.view_count += 1; - let (texture, view) = self.create(image); - self.textures.push(Some(texture)); - self.views.push(view); - } - fn push_free(&mut self) { - self.view_count += 1; - self.textures.push(None); - self.views.push(self.null_view.clone()); + if let Some(slot) = self.slots.get_mut(i as usize) { + *slot = Slot::Empty; + } + // A page's layer is not reclaimed here either -- see `Slot::Page`. } fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) { - let Some(texture) = &self.textures[i as usize] else { + let Some(&Slot::Page(layer)) = self.slots.get(i as usize) else { return; }; if rect.width == 0 || rect.height == 0 { @@ -77,12 +173,12 @@ impl GpuTextures { .to_image(); self.queue.write_texture( TexelCopyTextureInfo { - texture, + texture: &self.array_texture, mip_level: 0, origin: Origin3d { x: rect.x, y: rect.y, - z: 0, + z: layer, }, aspect: TextureAspect::All, }, @@ -100,13 +196,105 @@ impl GpuTextures { ); } - fn create(&self, image: &DynamicImage) -> (Texture, TextureView) { - let image = image.to_rgba8(); - let (width, height) = image.dimensions(); + fn write_full_layer(&self, layer: u32, image: &DynamicImage) { + // Every page is created as exactly PAGE x PAGE (`GlyphAtlas::allocate`), + // so this is always a whole-layer write, never a crop. + let rgba = image.to_rgba8(); + self.queue.write_texture( + TexelCopyTextureInfo { + texture: &self.array_texture, + mip_level: 0, + origin: Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect: TextureAspect::All, + }, + rgba.as_bytes(), + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(PAGE * 4), + rows_per_image: Some(PAGE), + }, + Extent3d { + width: PAGE, + height: PAGE, + depth_or_array_layers: 1, + }, + ); + } + + /// Doubles the array's layer capacity (headroom, so this is rare) and + /// copies the old layers across GPU-side -- no readback. Recreates the + /// array's view, which invalidates every bind group that referenced it, + /// so this also rebuilds all of them before returning. + fn grow_array(&mut self, rsc_layout: &BindGroupLayout, masks: &ArrBuf) { + let new_capacity = self.array_capacity * 2; + let new_texture = Self::create_array_texture(&self.device, new_capacity); + if self.page_count > 0 { + let mut encoder = self + .device + .create_command_encoder(&CommandEncoderDescriptor { + label: Some("atlas array grow"), + }); + encoder.copy_texture_to_texture( + TexelCopyTextureInfo { + texture: &self.array_texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }, + TexelCopyTextureInfo { + texture: &new_texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }, + Extent3d { + width: PAGE, + height: PAGE, + depth_or_array_layers: self.page_count, + }, + ); + self.queue.submit(std::iter::once(encoder.finish())); + } + self.array_texture = new_texture; + self.array_view = self.array_texture.create_view(&TextureViewDescriptor { + dimension: Some(TextureViewDimension::D2Array), + ..Default::default() + }); + self.array_capacity = new_capacity; + self.rebuild_image_bind_groups(rsc_layout, masks); + } + + fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout, masks: &ArrBuf) { + for slot in &mut self.slots { + if let Slot::Image(gpu) = slot { + gpu.bind_group = Self::make_image_bind_group( + &self.device, + rsc_layout, + &self.array_view, + &gpu.view, + &self.sampler, + masks, + ); + } + } + } + + fn create_image( + &self, + image: &DynamicImage, + rsc_layout: &BindGroupLayout, + masks: &ArrBuf, + ) -> ImageGpu { + let rgba = image.to_rgba8(); + let (width, height) = rgba.dimensions(); let texture = self.device.create_texture_with_data( &self.queue, &TextureDescriptor { - label: None, + label: Some("image"), size: Extent3d { width, height, @@ -120,43 +308,138 @@ impl GpuTextures { view_formats: &[], }, wgt::TextureDataOrder::MipMajor, - image.as_bytes(), + rgba.as_bytes(), ); let view = texture.create_view(&TextureViewDescriptor::default()); - (texture, view) + let bind_group = Self::make_image_bind_group( + &self.device, + rsc_layout, + &self.array_view, + &view, + &self.sampler, + masks, + ); + ImageGpu { + texture, + view, + bind_group, + } + } + + /// Builds group 2 for one standalone image: the shared atlas array, this + /// image's own view, the shared sampler, and the shared masks buffer -- + /// the same layout the main draw uses with a null view in the image slot. + fn make_image_bind_group( + device: &Device, + rsc_layout: &BindGroupLayout, + array_view: &TextureView, + image_view: &TextureView, + sampler: &Sampler, + masks: &ArrBuf, + ) -> BindGroup { + device.create_bind_group(&BindGroupDescriptor { + layout: rsc_layout, + entries: &[ + BindGroupEntry { + binding: 0, + resource: BindingResource::TextureView(array_view), + }, + BindGroupEntry { + binding: 1, + resource: BindingResource::TextureView(image_view), + }, + BindGroupEntry { + binding: 2, + resource: BindingResource::Sampler(sampler), + }, + BindGroupEntry { + binding: 3, + resource: masks.buffer.as_entire_binding(), + }, + ], + label: Some("ui rsc image"), + }) + } + + fn create_array_texture(device: &Device, capacity: u32) -> Texture { + device.create_texture(&TextureDescriptor { + label: Some("glyph atlas array"), + size: Extent3d { + width: PAGE, + height: PAGE, + depth_or_array_layers: capacity, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Rgba8Unorm, + usage: TextureUsages::TEXTURE_BINDING + | TextureUsages::COPY_DST + | TextureUsages::COPY_SRC, + view_formats: &[], + }) } pub fn new(device: &Device, queue: &Queue) -> Self { + let sampler = default_sampler(device); let null_view = null_texture_view(device); + let array_capacity = 1; + let array_texture = Self::create_array_texture(device, array_capacity); + let array_view = array_texture.create_view(&TextureViewDescriptor { + dimension: Some(TextureViewDimension::D2Array), + ..Default::default() + }); Self { device: device.clone(), queue: queue.clone(), - textures: Vec::new(), - views: Vec::new(), - samplers: vec![default_sampler(device)], - no_views: vec![null_view.clone()], + slots: Vec::new(), + array_texture, + array_view, + array_capacity, + page_count: 0, + sampler, null_view, - view_count: 0, } } - pub fn views(&self) -> Vec<&TextureView> { - if self.views.is_empty() { - &self.no_views - } else { - &self.views - } - .iter() - .by_ref() - .collect() + pub fn array_view(&self) -> &TextureView { + &self.array_view } - pub fn samplers(&self) -> Vec<&Sampler> { - self.samplers.iter().by_ref().collect() + pub fn null_view(&self) -> &TextureView { + &self.null_view + } + + pub fn sampler(&self) -> &Sampler { + &self.sampler + } + + /// The bind group a standalone image draws with. Panics if `idx` names an + /// atlas page or a freed slot instead -- either is a caller bug (the + /// wrong kind of instance reached this draw path), not a condition to + /// recover from. + pub fn image_bind_group(&self, idx: u32) -> &BindGroup { + match self.slots.get(idx as usize) { + Some(Slot::Image(gpu)) => &gpu.bind_group, + other => panic!("texture slot {idx} is not a live standalone image: {other:?}"), + } } pub fn view_count(&self) -> usize { - self.view_count + self.slots + .iter() + .filter(|s| !matches!(s, Slot::Empty)) + .count() + } +} + +impl std::fmt::Debug for Slot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Slot::Empty => write!(f, "Empty"), + Slot::Image(_) => write!(f, "Image"), + Slot::Page(layer) => write!(f, "Page(layer={layer})"), + } } } diff --git a/iris/core/src/render/util/mod.rs b/iris/core/src/render/util/mod.rs index c9d48ff..d4faf54 100644 --- a/iris/core/src/render/util/mod.rs +++ b/iris/core/src/render/util/mod.rs @@ -21,13 +21,18 @@ impl ArrBuf { _pd: PhantomData, } } - pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) { - if self.len != data.len() { + /// Returns whether the underlying `Buffer` was recreated -- a caller that + /// cached a `BindGroup` referencing it (as `GpuTextures` does for the + /// masks buffer) needs to know to rebuild that too. + pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) -> bool { + let resized = self.len != data.len(); + if resized { self.len = data.len(); self.buffer = Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label); } queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data)); + resized } fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer { let mut size = size as u64; diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 380a819..9ec455c 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -77,17 +77,31 @@ impl<'a> Painter<'a> { pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) { self.textures.push(handle.clone()); - self.primitive_at(handle.primitive(), region.within(&self.region)); + self.write_image(handle.image_index(), region.within(&self.region)); } pub fn texture(&mut self, handle: &TextureHandle) { self.textures.push(handle.clone()); - self.primitive(handle.primitive()); + self.write_image(handle.image_index(), self.region); } pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) { self.textures.push(handle.clone()); - self.primitive_at(handle.primitive(), region); + self.write_image(handle.image_index(), region); + } + + /// A standalone image draws with its own bind group rather than sharing + /// the layer's one instanced draw, so it goes through + /// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`. + fn write_image(&mut self, texture_idx: u32, region: UiRegion) { + let h = self + .state + .layers + .write_image(self.layer, self.id, texture_idx, region, self.mask); + if self.mask != MaskIdx::NONE { + self.rsc.ui_mut().masks.push_ref(self.mask); + } + self.primitives.push(h); } pub fn render_text( @@ -121,14 +135,13 @@ impl<'a> Painter<'a> { region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32); region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32); self.primitive_at( - GlyphPrimitive { - uv_min: glyph.entry.uv_min, - uv_max: glyph.entry.uv_max, - view_idx: glyph.entry.view_idx, - sampler_idx: glyph.entry.sampler_idx, - color: text.color, - flags: flags_for(glyph.entry.is_color), - }, + GlyphPrimitive::new( + glyph.entry.uv_min, + glyph.entry.uv_max, + glyph.entry.layer, + text.color, + flags_for(glyph.entry.is_color), + ), region, ); } diff --git a/iris/src/default/render.rs b/iris/src/default/render.rs index 0004dd1..3be551c 100644 --- a/iris/src/default/render.rs +++ b/iris/src/default/render.rs @@ -1,4 +1,4 @@ -use iris_core::{UiData, UiLimits, UiRenderNode, UiRenderState}; +use iris_core::{UiData, UiRenderNode, UiRenderState}; use pollster::FutureExt; use std::sync::Arc; use wgpu::*; @@ -89,18 +89,16 @@ impl UiRenderer { .block_on() .expect("Could not get adapter!"); - let ui_limits = UiLimits::default(); - + // No features beyond what wgpu asks for by default, and no + // binding-array limits: the atlas is one texture_2d_array and a + // standalone image is its own ordinary bind group, neither of which + // needs descriptor indexing. See TEXTURES.md's "Recommended shape" + // for why the old binding array asked for + // VK_EXT_descriptor_indexing unconditionally and did not survive a + // real share of Android GPUs. let (device, queue) = adapter .request_device(&DeviceDescriptor { - required_features: Features::TEXTURE_BINDING_ARRAY - | Features::PARTIALLY_BOUND_BINDING_ARRAY - | Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, required_limits: Limits { - max_binding_array_elements_per_shader_stage: ui_limits - .max_binding_array_elements_per_shader_stage(), - max_binding_array_sampler_elements_per_shader_stage: ui_limits - .max_binding_array_sampler_elements_per_shader_stage(), max_buffer_size: 1 << 30, ..Default::default() }, @@ -137,7 +135,7 @@ impl UiRenderer { let encoder = Self::create_encoder(&device); - let ui = UiRenderNode::new(&device, &queue, &config, ui_limits); + let ui = UiRenderNode::new(&device, &queue, &config); Self { surface, diff --git a/rigs/gpu-probe/src/main.rs b/rigs/gpu-probe/src/main.rs index 1643e6b..a15f977 100644 --- a/rigs/gpu-probe/src/main.rs +++ b/rigs/gpu-probe/src/main.rs @@ -1,11 +1,19 @@ //! Ask a device whether it can give iris the GPU it asks for. //! -//! iris's renderer binds every texture it has drawn as one binding array and -//! indexes it non-uniformly from the shader, which needs descriptor indexing -//! and a very large per-stage binding-array limit (101,000 elements: 100,000 -//! textures and 1,000 samplers, `UiLimits::default`). Those are ordinary on a -//! desktop and not obviously available on a phone, so this reports what the -//! adapter offers before anything is built on the assumption. +//! Until 2026-09-04 iris's renderer bound every texture it had drawn as one +//! binding array and indexed it non-uniformly from the shader, which needed +//! descriptor indexing and a very large per-stage binding-array limit +//! (101,000 elements: 100,000 textures and 1,000 samplers, +//! `UiLimits::default`). That was ordinary on a desktop and, per +//! TEXTURES.md's "iris's binding array does not survive real Android +//! hardware", not available on a real share of Android GPUs -- and it failed +//! outright on this emulator's software Vulkan, which is what this rig +//! caught first. iris now asks for nothing beyond wgpu's own defaults (see +//! `iris/src/default/render.rs`): the glyph atlas is one `texture_2d_array` +//! and a standalone image is its own ordinary bind group, and neither needs +//! descriptor indexing. This rig still asks `request_device` for exactly +//! what iris asks for, so it keeps being the answer to "does iris's actual +//! device request succeed here" rather than a guess from reading the code. //! //! It runs as a plain executable with no window and no APK, because //! `request_adapter` needs no surface -- so it can be pushed to a device with @@ -17,16 +25,15 @@ mod vk; use wgpu::*; -/// What `iris/src/default/render.rs` asks `request_device` for. +/// What `iris/src/default/render.rs` asks `request_device` for, now that the +/// binding array is gone: nothing beyond wgpu's own default feature set. fn iris_features() -> Features { - Features::TEXTURE_BINDING_ARRAY - | Features::PARTIALLY_BOUND_BINDING_ARRAY - | Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING + Features::empty() } -/// `UiLimits::default()`: 100,000 textures + 1,000 samplers. -const IRIS_MAX_BINDING_ARRAY: u32 = 101_000; -const IRIS_MAX_BINDING_ARRAY_SAMPLERS: u32 = 1_000; +/// The one non-default limit iris asks for -- unrelated to the binding array, +/// kept for the big storage buffers behind rects/glyphs. +const IRIS_MAX_BUFFER_SIZE: u64 = 1 << 30; fn main() { vk::report(); @@ -80,75 +87,37 @@ fn main() { let limits = adapter.limits(); println!("\nlimits iris requires:"); - for (name, want, got) in [ - ( - "max_binding_array_elements_per_shader_stage", - IRIS_MAX_BINDING_ARRAY, - limits.max_binding_array_elements_per_shader_stage, - ), - ( - "max_binding_array_sampler_elements_per_shader_stage", - IRIS_MAX_BINDING_ARRAY_SAMPLERS, - limits.max_binding_array_sampler_elements_per_shader_stage, - ), - ] { - println!( - " {name:52} want {want:>7} have {got:>7} {}", - if got >= want { "ok" } else { "TOO SMALL" } - ); - } println!( - " {:52} want {:>7} have {:>7}", - "max_buffer_size (iris asks 1<<30)", - 1u64 << 30, - limits.max_buffer_size + " {:52} want {:>7} have {:>7} {}", + "max_buffer_size", + IRIS_MAX_BUFFER_SIZE, + limits.max_buffer_size, + if limits.max_buffer_size >= IRIS_MAX_BUFFER_SIZE { + "ok" + } else { + "TOO SMALL" + } ); // The question that actually matters: does the device iris builds come - // back, or does wgpu refuse it? - let mut wanted = Limits { - max_binding_array_elements_per_shader_stage: IRIS_MAX_BINDING_ARRAY, - max_binding_array_sampler_elements_per_shader_stage: IRIS_MAX_BINDING_ARRAY_SAMPLERS, - max_buffer_size: 1 << 30, + // back, or does wgpu refuse it? With no features and no binding-array + // limits requested, this is expected to succeed everywhere -- this rig + // is what turned that from an assumption into a measurement, first on + // this emulator's software Vulkan. + let wanted = Limits { + max_buffer_size: IRIS_MAX_BUFFER_SIZE, ..Default::default() }; match pollster::block_on(adapter.request_device(&DeviceDescriptor { required_features: iris_features(), - required_limits: wanted.clone(), + required_limits: wanted, ..Default::default() })) { Ok(_) => println!("\nIRIS DEVICE: ok"), Err(e) => println!("\nIRIS DEVICE: FAILED -- {e}"), } - // If it failed, say how far down it has to be turned before it works, so - // the report names a number to design against rather than just "no". - if missing.is_empty() { - for cap in [ - limits.max_binding_array_elements_per_shader_stage, - 1024, - 128, - 16, - ] { - if cap >= IRIS_MAX_BINDING_ARRAY { - continue; - } - wanted.max_binding_array_elements_per_shader_stage = cap; - wanted.max_binding_array_sampler_elements_per_shader_stage = - cap.min(IRIS_MAX_BINDING_ARRAY_SAMPLERS); - let ok = pollster::block_on(adapter.request_device(&DeviceDescriptor { - required_features: iris_features(), - required_limits: wanted.clone(), - ..Default::default() - })) - .is_ok(); - println!( - " binding array capped at {cap:>7}: {}", - if ok { "ok" } else { "no" } - ); - if ok { - break; - } - } + if !missing.is_empty() { + println!("\nmissing features: {missing:?}"); } }