Jelajahi Sumber

app/gfx: replace pattern `draw_cache: SyncMutex<Option<T>>` with an explicit EpochCache type that also invalidates the cache on epoch changes (as we should). More resilient, convenient and cleaner pattern with less potential for things to go wrong.

darkfi 1 hari lalu
induk
melakukan
fae130c05f

+ 1 - 1
bin/app/src/gfx/api.rs

@@ -144,7 +144,7 @@ pub struct Renderer {
     /// We are abusing async_channel since it's cloneable whereas std::sync::mpsc is shit.
     method_send: async_channel::Sender<(EpochIndex, GraphicsMethod)>,
     /// Keep track of the current UI epoch
-    epoch: Arc<AtomicU32>,
+    pub epoch: Arc<AtomicU32>,
 }
 
 impl Renderer {

+ 108 - 0
bin/app/src/gfx/epoch_cache.rs

@@ -0,0 +1,108 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::sync::{
+    atomic::{AtomicU32, Ordering},
+    Arc,
+};
+
+use parking_lot::Mutex as SyncMutex;
+
+use super::{EpochIndex, Renderer};
+
+/// Cache for draw outputs holding epoch-scoped GPU resources (buffers,
+/// textures, anims). Such resources only exist for the current UI epoch;
+/// when the epoch is bumped (e.g. the GL context is recreated on Android)
+/// every resource from the dead epoch is gone, so stale entries must
+/// never be served.
+pub struct EpochCache<T> {
+    /// Shared with the `Renderer`; bumped on UI restart
+    epoch: Arc<AtomicU32>,
+    inner: SyncMutex<Option<(EpochIndex, T)>>,
+}
+
+impl<T: Clone> EpochCache<T> {
+    pub fn new(renderer: &Renderer) -> Self {
+        Self { epoch: renderer.epoch.clone(), inner: SyncMutex::new(None) }
+    }
+
+    /// Returns the cached value, or None when empty, cleared, or built
+    /// under a dead epoch.
+    pub fn get(&self) -> Option<T> {
+        let mut cache = self.inner.lock();
+        // A stale entry belongs to a dead epoch and can never become
+        // valid again, so evict it here instead of holding it in memory
+        // until the next set().
+        match cache.take() {
+            Some((e, v)) if e == self.epoch.load(Ordering::Relaxed) => {
+                *cache = Some((e, v.clone()));
+                Some(v)
+            }
+            _ => None,
+        }
+    }
+
+    /// Returns the cached value, computing and storing it via `f` when
+    /// absent, cleared, or stale. `f` runs under the lock so a concurrent
+    /// `clear()` cannot be lost between the read and the store.
+    pub fn get_or_insert_with(&self, f: impl FnOnce() -> T) -> T {
+        let mut cache = self.inner.lock();
+        let cur = self.epoch.load(Ordering::Relaxed);
+        if let Some((e, v)) = &*cache {
+            if *e == cur {
+                return v.clone()
+            }
+        }
+        let v = f();
+        *cache = Some((cur, v.clone()));
+        v
+    }
+
+    pub fn set(&self, value: T) {
+        let e = self.epoch.load(Ordering::Relaxed);
+        *self.inner.lock() = Some((e, value));
+    }
+
+    pub fn clear(&self) {
+        *self.inner.lock() = None;
+    }
+}
+
+/// Remembers the UI epoch last seen and reports when it has changed, e.g.
+/// to drop caches holding epoch-scoped GPU resources.
+pub struct EpochTracker {
+    epoch: Arc<AtomicU32>,
+    cached: EpochIndex,
+}
+
+impl EpochTracker {
+    pub fn new(renderer: &Renderer) -> Self {
+        Self { epoch: renderer.epoch.clone(), cached: renderer.epoch.load(Ordering::Relaxed) }
+    }
+
+    /// Returns true when the epoch changed since the last call
+    pub fn changed(&mut self) -> bool {
+        let cur = self.epoch.load(Ordering::Relaxed);
+        if cur == self.cached {
+            return false
+        }
+
+        self.cached = cur;
+        true
+    }
+}

+ 2 - 0
bin/app/src/gfx/mod.rs

@@ -45,6 +45,8 @@ pub use api::{
     EpochIndex, GraphicsMethod, ManagedBuffer, ManagedBufferPtr, ManagedSeqAnim, ManagedSeqAnimPtr,
     ManagedTexture, ManagedTexturePtr, RenderApi, Renderer,
 };
+mod epoch_cache;
+pub use epoch_cache::{EpochCache, EpochTracker};
 mod ev;
 pub use ev::{
     GraphicsEventCharSub, GraphicsEventKeyDownSub, GraphicsEventKeyUpSub,

+ 4 - 1
bin/app/src/ui/chatview/mod.rs

@@ -1232,7 +1232,10 @@ impl UIObject for ChatView {
 
         let mut msgbuf = self.msgbuf.lock().await;
         let scale_changed = msgbuf.adjust_window_scale();
-        if rect_changed || scale_changed {
+        // Mesh caches hold epoch-scoped GPU resources; drop them after a
+        // UI restart so messages are rebuilt against the new epoch.
+        let epoch_changed = msgbuf.epoch_changed();
+        if rect_changed || scale_changed || epoch_changed {
             msgbuf.clear_meshes();
         }
 

+ 13 - 1
bin/app/src/ui/chatview/page.rs

@@ -40,7 +40,10 @@ use url::Url;
 
 use super::{MessageId, Timestamp};
 use crate::{
-    gfx::{gfxtag, DrawInstruction, ManagedTexturePtr, Point, Rectangle, RenderApi, Renderer},
+    gfx::{
+        gfxtag, DrawInstruction, EpochTracker, ManagedTexturePtr, Point, Rectangle, RenderApi,
+        Renderer,
+    },
     mesh::{Color, MeshBuilder, COLOR_CYAN, COLOR_GREEN, COLOR_RED, COLOR_WHITE},
     prop::{PropertyColor, PropertyFloat32, PropertyPtr},
     scene::SceneNodeWeak,
@@ -1142,6 +1145,7 @@ pub struct MessageBuffer {
     old_window_scale: f32,
 
     renderer: Renderer,
+    epoch_tracker: EpochTracker,
 }
 
 impl MessageBuffer {
@@ -1165,6 +1169,7 @@ impl MessageBuffer {
         renderer: Renderer,
     ) -> Self {
         let old_window_scale = window_scale.get();
+        let epoch_tracker = EpochTracker::new(&renderer);
         Self {
             msgs: vec![],
             date_msgs: HashMap::new(),
@@ -1189,6 +1194,7 @@ impl MessageBuffer {
             old_window_scale,
 
             renderer,
+            epoch_tracker,
         }
     }
 
@@ -1208,6 +1214,12 @@ impl MessageBuffer {
         true
     }
 
+    /// Returns whether the gfx epoch changed since the last check,
+    /// meaning every message mesh cache holds dead GPU resources.
+    pub fn epoch_changed(&mut self) -> bool {
+        self.epoch_tracker.changed()
+    }
+
     /// This will force a reload of everything
     pub fn adjust_params(&mut self) {
         let window_scale = self.window_scale.get();

+ 11 - 2
bin/app/src/ui/emoji_picker/emoji.rs

@@ -20,7 +20,7 @@ use parking_lot::Mutex as SyncMutex;
 use std::sync::Arc;
 
 use crate::{
-    gfx::{gfxtag, DrawInstruction, DrawMesh, Renderer},
+    gfx::{gfxtag, DrawInstruction, DrawMesh, EpochTracker, Renderer},
     mesh::COLOR_WHITE,
     text,
 };
@@ -32,12 +32,14 @@ pub type EmojiMeshesPtr = Arc<SyncMutex<EmojiMeshes>>;
 pub struct EmojiMeshes {
     renderer: Renderer,
     emoji_size: f32,
+    epoch_tracker: EpochTracker,
     meshes: Vec<DrawMesh>,
 }
 
 impl EmojiMeshes {
     pub fn new(renderer: Renderer, emoji_size: f32) -> EmojiMeshesPtr {
-        Arc::new(SyncMutex::new(Self { renderer, emoji_size, meshes: vec![] }))
+        let epoch_tracker = EpochTracker::new(&renderer);
+        Arc::new(SyncMutex::new(Self { renderer, emoji_size, epoch_tracker, meshes: vec![] }))
     }
 
     pub fn clear(&mut self) {
@@ -46,6 +48,13 @@ impl EmojiMeshes {
 
     pub fn get(&mut self, i: usize) -> DrawMesh {
         assert!(i < DEFAULT_EMOJI_LIST.len());
+
+        // Meshes hold epoch-scoped buffers; after a UI restart they are
+        // dead, so drop them and rebuild lazily.
+        if self.epoch_tracker.changed() {
+            self.meshes.clear();
+        }
+
         self.meshes.reserve_exact(DEFAULT_EMOJI_LIST.len());
 
         if i >= self.meshes.len() {

+ 23 - 19
bin/app/src/ui/emoji_picker/mod.rs

@@ -27,7 +27,7 @@ use std::sync::{
 };
 
 use crate::{
-    gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi, Renderer},
+    gfx::{gfxtag, DrawCall, DrawInstruction, EpochCache, Point, Rectangle, RenderApi, Renderer},
     prop::{PropertyAtomicGuard, PropertyFloat32, PropertyRect, PropertyUint32, Role},
     scene::{Pimpl, SceneNodeWeak},
     ExecutorPtr,
@@ -68,10 +68,11 @@ pub struct EmojiPicker {
     mouse_scroll_speed: PropertyFloat32,
 
     redraw: RedrawTrigger,
-    /// Cached emoji grid instructions. `None` means stale (rect, scroll or
+    /// Cached emoji grid instructions. Empty means stale (rect, scroll or
     /// z_index changed). Scroll is set with an internal role, so scroll
-    /// mutation sites invalidate explicitly.
-    draw_cache: SyncMutex<Option<Vec<DrawInstruction>>>,
+    /// mutation sites invalidate explicitly. Entries from a dead UI epoch
+    /// are evicted automatically.
+    draw_cache: EpochCache<Vec<DrawInstruction>>,
     is_mouse_hover: AtomicBool,
     touch_info: SyncMutex<Option<TouchInfo>>,
 }
@@ -92,6 +93,8 @@ impl EmojiPicker {
         let mouse_scroll_speed =
             PropertyFloat32::wrap(node_ref, Role::Internal, "mouse_scroll_speed", 0).unwrap();
 
+        let draw_cache = EpochCache::new(&renderer);
+
         let self_ = Arc::new(Self {
             node,
             renderer,
@@ -108,7 +111,7 @@ impl EmojiPicker {
             mouse_scroll_speed,
 
             redraw,
-            draw_cache: SyncMutex::new(None),
+            draw_cache,
             is_mouse_hover: AtomicBool::new(false),
             touch_info: SyncMutex::new(None),
         });
@@ -202,13 +205,16 @@ impl EmojiPicker {
         let max_scroll = self.max_scroll();
         if self.scroll.get() > max_scroll {
             self.scroll.set(atom, max_scroll);
-            *self.draw_cache.lock() = None;
+            self.draw_cache.clear();
         }
 
-        // The grid depends on rect and scroll. Compute under the lock so
-        // concurrent invalidations land before or after, never between.
-        let mut cache = self.draw_cache.lock();
-        if cache.is_none() || rect_changed {
+        // The grid depends on rect and scroll. Compute under the cache
+        // lock so concurrent invalidations land before or after, never
+        // between.
+        if rect_changed {
+            self.draw_cache.clear();
+        }
+        let instrs = self.draw_cache.get_or_insert_with(|| {
             let mut instrs = vec![DrawInstruction::ApplyView(rect)];
 
             let off_x = self.calc_off_x();
@@ -236,10 +242,8 @@ impl EmojiPicker {
                 }
             }
 
-            *cache = Some(instrs);
-        }
-        let instrs = cache.clone().unwrap();
-        drop(cache);
+            instrs
+        });
 
         Some(DrawUpdate {
             key: self.dc_key,
@@ -264,11 +268,11 @@ impl UIObject for EmojiPicker {
         // Invalidate the cache, then request a pass. Internal-role echoes
         // (the pass's own evals) are skipped.
         on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.z_index.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
 
@@ -277,7 +281,7 @@ impl UIObject for EmojiPicker {
 
     fn stop(&self) {
         self.tasks.lock().clear();
-        *self.draw_cache.lock() = None;
+        self.draw_cache.clear();
         self.emoji_meshes.lock().clear();
     }
 
@@ -307,7 +311,7 @@ impl UIObject for EmojiPicker {
         scroll = scroll.clamp(0., self.max_scroll());
         self.scroll.set(atom, scroll);
 
-        *self.draw_cache.lock() = None;
+        self.draw_cache.clear();
 
         true
     }
@@ -371,7 +375,7 @@ impl UIObject for EmojiPicker {
                         scroll = scroll.clamp(0., self.max_scroll());
                         self.scroll.set(atom, scroll);
 
-                        *self.draw_cache.lock() = None;
+                        self.draw_cache.clear();
                     }
                 }
                 TouchPhase::Ended | TouchPhase::Cancelled => {

+ 21 - 18
bin/app/src/ui/image.rs

@@ -26,8 +26,8 @@ use tracing::instrument;
 
 use crate::{
     gfx::{
-        gfxtag, DrawCall, DrawInstruction, DrawMesh, ManagedTexturePtr, Rectangle, RenderApi,
-        Renderer,
+        gfxtag, DrawCall, DrawInstruction, DrawMesh, EpochCache, ManagedTexturePtr, Rectangle,
+        RenderApi, Renderer,
     },
     mesh::{MeshBuilder, MeshInfo, COLOR_WHITE},
     prop::{BatchGuardPtr, PropertyAtomicGuard, PropertyRect, PropertyStr, PropertyUint32, Role},
@@ -54,8 +54,9 @@ pub struct Image {
     priority: PropertyUint32,
     path: PropertyStr,
 
-    /// Cached draw instructions. `None` means stale.
-    draw_cache: SyncMutex<Option<Vec<DrawInstruction>>>,
+    /// Cached draw instructions. Empty means stale. Entries from a dead
+    /// UI epoch are evicted automatically.
+    draw_cache: EpochCache<Vec<DrawInstruction>>,
 }
 
 impl Image {
@@ -67,6 +68,8 @@ impl Image {
         let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
         let path = PropertyStr::wrap(node_ref, Role::Internal, "path", 0).unwrap();
 
+        let draw_cache = EpochCache::new(&renderer);
+
         let self_ = Arc::new(Self {
             node,
             renderer,
@@ -82,7 +85,7 @@ impl Image {
             priority,
             path,
 
-            draw_cache: SyncMutex::new(None),
+            draw_cache,
         });
 
         Pimpl::Image(self_)
@@ -92,7 +95,7 @@ impl Image {
         let texture = self_.load_texture();
         *self_.texture.lock() = Some(texture);
 
-        *self_.draw_cache.lock() = None;
+        self_.draw_cache.clear();
         self_.redraw.trigger();
     }
 
@@ -145,10 +148,12 @@ impl Image {
         let rect_changed = rect != prev_rect;
         self.uv.eval(atom, &rect).ok()?;
 
-        // Mesh geometry depends on the rect; compute under the lock so a
-        // concurrent invalidation lands before or after, never between.
-        let mut cache = self.draw_cache.lock();
-        if cache.is_none() || rect_changed {
+        // Mesh geometry depends on the rect; compute under the cache lock
+        // so a concurrent invalidation lands before or after, never between.
+        if rect_changed {
+            self.draw_cache.clear();
+        }
+        let instrs = self.draw_cache.get_or_insert_with(|| {
             let mesh = self.regen_mesh();
             let texture = self.texture.lock().clone().expect("Node missing texture_id!");
             let mesh = DrawMesh {
@@ -157,10 +162,8 @@ impl Image {
                 textures: Some(vec![texture]),
                 num_elements: mesh.num_elements,
             };
-            *cache = Some(vec![DrawInstruction::Move(rect.pos()), DrawInstruction::Draw(mesh)]);
-        }
-        let instrs = cache.clone().unwrap();
-        drop(cache);
+            vec![DrawInstruction::Move(rect.pos()), DrawInstruction::Draw(mesh)]
+        });
 
         Some(DrawUpdate {
             key: self.dc_key,
@@ -189,15 +192,15 @@ impl UIObject for Image {
         // Invalidate the cache, then request a pass. Internal-role echoes
         // (the pass's own evals) are skipped.
         on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.uv.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.z_index.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change(self.path.prop(), Self::reload);
@@ -207,7 +210,7 @@ impl UIObject for Image {
 
     fn stop(&self) {
         self.tasks.lock().clear();
-        *self.draw_cache.lock() = None;
+        self.draw_cache.clear();
         *self.texture.lock() = None;
     }
 

+ 23 - 20
bin/app/src/ui/text.rs

@@ -23,7 +23,7 @@ use std::sync::Arc;
 use tracing::instrument;
 
 use crate::{
-    gfx::{gfxtag, DrawCall, DrawInstruction, Rectangle, RenderApi, Renderer},
+    gfx::{gfxtag, DrawCall, DrawInstruction, EpochCache, Rectangle, RenderApi, Renderer},
     mesh::MeshBuilder,
     prop::{
         PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyEnum, PropertyFloat32,
@@ -62,9 +62,10 @@ pub struct Text {
     debug: PropertyBool,
 
     window_scale: PropertyFloat32,
-    /// Cached layout + rendered instrs. `None` means stale: recompute in
+    /// Cached layout + rendered instrs. Empty means stale: recompute in
     /// the draw pass. Layout is the expensive part (shaping, line breaks).
-    draw_cache: SyncMutex<Option<(text::TextLayout, Vec<DrawInstruction>)>>,
+    /// Entries from a dead UI epoch are evicted automatically.
+    draw_cache: EpochCache<(text::TextLayout, Vec<DrawInstruction>)>,
 }
 
 impl Text {
@@ -90,6 +91,8 @@ impl Text {
         let use_i18n = PropertyBool::wrap(node_ref, Role::Internal, "use_i18n", 0).unwrap();
         let debug = PropertyBool::wrap(node_ref, Role::Internal, "debug", 0).unwrap();
 
+        let draw_cache = EpochCache::new(&renderer);
+
         let self_ = Arc::new(Self {
             node,
             renderer,
@@ -112,7 +115,7 @@ impl Text {
             debug,
 
             window_scale,
-            draw_cache: SyncMutex::new(None),
+            draw_cache,
         });
 
         Pimpl::Text(self_)
@@ -175,17 +178,17 @@ impl Text {
 
         // Layout depends on the width, so a rect change invalidates the
         // layout even if the text itself did not change. Compute under the
-        // lock: the compute is synchronous, so concurrent invalidations
-        // either land before (seen as None) or after (clear our result).
-        let mut cache = self.draw_cache.lock();
-        if cache.is_none() || rect_changed {
+        // cache lock: the compute is synchronous, so concurrent invalidations
+        // either land before (seen as None) or after (they clear our result).
+        if rect_changed {
+            self.draw_cache.clear();
+        }
+        let (layout, mut instrs) = self.draw_cache.get_or_insert_with(|| {
             let layout = self.make_layout();
             let mut instrs = vec![DrawInstruction::Move(rect.pos())];
             instrs.append(&mut self.regen_mesh(&layout));
-            *cache = Some((layout, instrs));
-        }
-        let (layout, mut instrs) = cache.clone().unwrap();
-        drop(cache);
+            (layout, instrs)
+        });
 
         // Height output for parents that depend on it.
         self.height.set(atom, layout.height());
@@ -221,31 +224,31 @@ impl UIObject for Text {
         // Invalidate the cache, then request a pass. Internal-role echoes
         // (the pass's own evals) are skipped.
         on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.z_index.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.text.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.text_align.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.font_size.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.text_color.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.debug.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
 
@@ -254,7 +257,7 @@ impl UIObject for Text {
 
     fn stop(&self) {
         self.tasks.lock().clear();
-        *self.draw_cache.lock() = None;
+        self.draw_cache.clear();
     }
 
     #[instrument(target = "ui::text")]

+ 23 - 20
bin/app/src/ui/tokentable/mod.rs

@@ -25,7 +25,7 @@ use rand::{rngs::OsRng, Rng};
 use std::sync::{Arc, Weak};
 
 use crate::{
-    gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi, Renderer},
+    gfx::{gfxtag, DrawCall, DrawInstruction, EpochCache, Point, Rectangle, RenderApi, Renderer},
     mesh::MeshBuilder,
     prop::{
         PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyRect, PropertyUint32, Role,
@@ -78,8 +78,9 @@ pub struct TokenTable {
     padding_x: PropertyFloat32,
     padding_y: PropertyFloat32,
 
-    /// Cached draw instructions. `None` means stale.
-    draw_cache: SyncMutex<Option<Vec<DrawInstruction>>>,
+    /// Cached draw instructions. Empty means stale. Entries from a dead
+    /// UI epoch are evicted automatically.
+    draw_cache: EpochCache<Vec<DrawInstruction>>,
 
     parent_rect: SyncMutex<Option<Rectangle>>,
     tasks: SyncMutex<Vec<smol::Task<()>>>,
@@ -98,6 +99,8 @@ impl TokenTable {
         let z_index = PropertyUint32::wrap(node_ref, Role::Internal, "z_index", 0).unwrap();
         let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
 
+        let draw_cache = EpochCache::new(&renderer);
+
         let self_ = Arc::new(Self {
             node: node.clone(),
             renderer: renderer.clone(),
@@ -113,7 +116,7 @@ impl TokenTable {
             separator_color,
             padding_x,
             padding_y,
-            draw_cache: SyncMutex::new(None),
+            draw_cache,
             parent_rect: SyncMutex::new(None),
             tasks: SyncMutex::new(vec![]),
         });
@@ -182,7 +185,7 @@ impl TokenTable {
 
         *self.rows.lock() = rows;
 
-        *self.draw_cache.lock() = None;
+        self.draw_cache.clear();
         self.redraw.trigger();
     }
 
@@ -286,31 +289,31 @@ impl UIObject for TokenTable {
         let mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
 
         on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.font_size.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.text_color.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.separator_color.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.padding_x.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.padding_y.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.z_index.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
 
@@ -322,7 +325,7 @@ impl UIObject for TokenTable {
     fn stop(&self) {
         self.tasks.lock().clear();
         *self.parent_rect.lock() = None;
-        *self.draw_cache.lock() = None;
+        self.draw_cache.clear();
     }
 
     async fn draw(
@@ -338,17 +341,17 @@ impl UIObject for TokenTable {
         let rect = self.rect.get();
         let rect_changed = rect != prev_rect;
 
-        // Compute under the lock so a concurrent invalidation lands
+        // Compute under the cache lock so a concurrent invalidation lands
         // before or after, never between.
-        let mut cache = self.draw_cache.lock();
-        if cache.is_none() || rect_changed {
+        if rect_changed {
+            self.draw_cache.clear();
+        }
+        let instrs = self.draw_cache.get_or_insert_with(|| {
             let mut mesh_instrs = self.get_meshes(&rect);
             let mut instrs = vec![DrawInstruction::ApplyView(rect)];
             instrs.append(&mut mesh_instrs);
-            *cache = Some(instrs);
-        }
-        let instrs = cache.clone().unwrap();
-        drop(cache);
+            instrs
+        });
 
         Some(DrawUpdate {
             key: self.dc_key,

+ 20 - 17
bin/app/src/ui/vector_art/mod.rs

@@ -23,7 +23,9 @@ use std::sync::Arc;
 use tracing::instrument;
 
 use crate::{
-    gfx::{gfxtag, DrawCall, DrawInstruction, DrawMesh, Rectangle, RenderApi, Renderer},
+    gfx::{
+        gfxtag, DrawCall, DrawInstruction, DrawMesh, EpochCache, Rectangle, RenderApi, Renderer,
+    },
     prop::{
         PropertyAtomicGuard, PropertyBool, PropertyFloat32, PropertyRect, PropertyUint32, Role,
     },
@@ -53,10 +55,11 @@ pub struct VectorArt {
     z_index: PropertyUint32,
     priority: PropertyUint32,
 
-    /// Cached draw instructions. `None` means the output is stale and must
-    /// be recomputed by the draw pass. Shape is static, so only visibility,
-    /// rect, scale and z_index changes invalidate it.
-    draw_cache: SyncMutex<Option<Vec<DrawInstruction>>>,
+    /// Cached draw instructions. Empty means the output is stale and must
+    /// be recomputed by the draw pass. Entries from a dead UI epoch are
+    /// evicted automatically. Shape is static, so only visibility, rect,
+    /// scale and z_index changes invalidate it.
+    draw_cache: EpochCache<Vec<DrawInstruction>>,
 }
 
 impl VectorArt {
@@ -73,6 +76,8 @@ impl VectorArt {
         let z_index = PropertyUint32::wrap(node_ref, Role::Internal, "z_index", 0).unwrap();
         let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
 
+        let draw_cache = EpochCache::new(&renderer);
+
         let self_ = Arc::new(Self {
             node,
             renderer,
@@ -88,7 +93,7 @@ impl VectorArt {
             z_index,
             priority,
 
-            draw_cache: SyncMutex::new(None),
+            draw_cache,
         });
 
         Pimpl::VectorArt(self_)
@@ -135,16 +140,14 @@ impl VectorArt {
         }
         let rect_changed = self.rect.get() != prev_rect;
 
-        // Compute while holding the lock: the compute is synchronous, so a
+        // Compute under the cache lock: the compute is synchronous, so a
         // concurrent invalidation either lands before us (we see None and
         // recompute with the newer state) or after us (it clears our result
         // and the trailing pass recomputes). No lost invalidation.
-        let mut cache = self.draw_cache.lock();
-        if cache.is_none() || rect_changed {
-            *cache = Some(self.get_draw_instrs());
+        if rect_changed {
+            self.draw_cache.clear();
         }
-        let instrs = cache.clone().unwrap();
-        drop(cache);
+        let instrs = self.draw_cache.get_or_insert_with(|| self.get_draw_instrs());
 
         Some(DrawUpdate {
             key: self.dc_key,
@@ -170,19 +173,19 @@ impl UIObject for VectorArt {
         // (the pass's own evals) are skipped: reacting to them would queue
         // a pass for every pass, forever.
         on_modify.when_change_external(self.is_visible.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.scale.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.z_index.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
 
@@ -191,7 +194,7 @@ impl UIObject for VectorArt {
 
     fn stop(&self) {
         self.tasks.lock().clear();
-        *self.draw_cache.lock() = None;
+        self.draw_cache.clear();
     }
 
     #[instrument(target = "ui::vector_art")]

+ 25 - 17
bin/app/src/ui/vid/mod.rs

@@ -24,7 +24,7 @@ use tracing::instrument;
 
 use crate::{
     gfx::{
-        anim::Frame, gfxtag, DrawCall, DrawInstruction, DrawMesh, GraphicPipeline,
+        anim::Frame, gfxtag, DrawCall, DrawInstruction, DrawMesh, EpochCache, GraphicPipeline,
         ManagedSeqAnimPtr, ManagedTexturePtr, Rectangle, RenderApi, Renderer,
     },
     mesh::{MeshBuilder, MeshInfo, COLOR_WHITE},
@@ -87,8 +87,9 @@ pub struct Video {
     priority: PropertyUint32,
     path: PropertyStr,
 
-    /// Cached draw instructions. `None` means stale.
-    draw_cache: SyncMutex<Option<Vec<DrawInstruction>>>,
+    /// Cached draw instructions. Empty means stale. Entries from a dead
+    /// UI epoch are evicted automatically.
+    draw_cache: EpochCache<Vec<DrawInstruction>>,
 
     parent_rect: SyncMutex<Option<Rectangle>>,
 }
@@ -107,6 +108,8 @@ impl Video {
         let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
         let path = PropertyStr::wrap(node_ref, Role::Internal, "path", 0).unwrap();
 
+        let draw_cache = EpochCache::new(&renderer);
+
         let self_ = Arc::new(Self {
             node,
             renderer,
@@ -126,7 +129,7 @@ impl Video {
             priority,
             path,
 
-            draw_cache: SyncMutex::new(None),
+            draw_cache,
 
             parent_rect: SyncMutex::new(None),
         });
@@ -136,7 +139,7 @@ impl Video {
 
     async fn reload(self_: Arc<Self>, _batch: BatchGuardPtr) {
         self_.load_video();
-        *self_.draw_cache.lock() = None;
+        self_.draw_cache.clear();
         self_.redraw.trigger();
     }
 
@@ -252,6 +255,8 @@ impl UIObject for Video {
     }
 
     fn init(&self) {
+        // Drop textures from a dead UI epoch (if any) before reloading
+        *self.vid_data.lock() = None;
         self.load_video();
     }
 
@@ -260,15 +265,15 @@ impl UIObject for Video {
 
         let mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
         on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.uv.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change_external(self.z_index.prop(), |self_, _| async move {
-            *self_.draw_cache.lock() = None;
+            self_.draw_cache.clear();
             self_.redraw.trigger();
         });
         on_modify.when_change(self.path.prop(), Self::reload);
@@ -280,7 +285,7 @@ impl UIObject for Video {
         self.tasks.lock().clear();
         *self.parent_rect.lock() = None;
         *self.vid_data.lock() = None;
-        *self.draw_cache.lock() = None;
+        self.draw_cache.clear();
         // Threads terminate naturally when channels close
     }
 
@@ -299,17 +304,20 @@ impl UIObject for Video {
         let rect_changed = rect != prev_rect;
         self.uv.eval(atom, &rect).ok()?;
 
-        // Compute under the lock so a concurrent invalidation lands
+        // Compute under the cache lock so a concurrent invalidation lands
         // before or after, never between. A video that has not loaded
         // yet stays uncached so the next pass retries.
-        let mut cache = self.draw_cache.lock();
-        if cache.is_none() || rect_changed {
-            if let Some(instrs) = self.make_instrs(&rect) {
-                *cache = Some(instrs);
-            }
+        if rect_changed {
+            self.draw_cache.clear();
         }
-        let instrs = cache.clone()?;
-        drop(cache);
+        let instrs = match self.draw_cache.get() {
+            Some(instrs) => instrs,
+            None => {
+                let Some(instrs) = self.make_instrs(&rect) else { return None };
+                self.draw_cache.set(instrs.clone());
+                instrs
+            }
+        };
 
         Some(DrawUpdate {
             key: self.dc_key,