lumen_core/native.rs
1//! Native paint seam - the way a plugin draws its own pixels inside the retained scene.
2//!
3//! A plugin that owns a drawing surface (a chart, a map, a canvas) has content the core
4//! primitives cannot describe. The seam gives it an opaque leaf: the plugin contributes an
5//! [`ExtractedNative`] from a normal extract fn, registers a [`NativePainter`] for the same
6//! `extension_id`, and the render backend calls that painter with its own draw target when the
7//! leaf's turn comes in paint order.
8//!
9//! This is the same shape as `QSGRenderNode` in Qt SceneGraph and `GskGLShaderNode` in GTK 4:
10//! an opaque leaf that the scene graph positions, orders, and clips, and whose interior only the
11//! backend and the extension understand.
12//!
13//! ## Contracts
14//!
15//! - **Bounds enclose the paint.** [`ExtractedNative::bounds`] must cover every pixel the painter
16//! touches. The damage diff repaints exactly that rect, so paint outside it survives as stale
17//! pixels until something else damages the region.
18//! - **`revision` is pixel identity.** Two leaves with the same `extension_id`, `bounds`,
19//! `clip_to_bounds`, and `revision` are treated as identical pixels and contribute no damage.
20//! Call [`next_revision`] whenever the content changes, or the frame never repaints. Payload
21//! identity is deliberately not part of the comparison: producers rebuild the `Arc` on every
22//! dirty frame, which would mark the leaf changed every time.
23//! - **Painters take `&self`.** One painter serves every leaf carrying its `extension_id`, so
24//! per-frame state goes behind interior mutability.
25//! - **`extension_id` names a contract**, covering both the concrete payload type and the API the
26//! painter expects from the backend. A backend with no painter registered for an id skips the
27//! leaf, which is what keeps a scene portable across backends.
28//! - **Bounds with no area never repaint on their own.** Damage is a rect, and an empty rect is no
29//! damage, so a leaf that declares zero width or height falls back to whole-viewport damage when
30//! its revision moves. Declare the area you paint.
31//!
32//! ## Producing leaves
33//!
34//! [`NativeExtract`] resolves the placement of one entity - paint order, scroll offset, inherited
35//! opacity, hidden subtrees - the same way the built-in extractors do, and
36//! [`upsert_native_leaves`] carries the leaves across frames under one extension id so two plugins
37//! extracting in the same frame cannot evict each other's entities.
38
39use crate::components::Transform;
40use crate::node_ir::Affine2;
41use crate::render_world::{
42 PaintOrder, Rect, RenderEntityMap, aabb_outside, build_parent_map, draw_opacity,
43 hidden_entities, opacity_model, paint_order_of, parent_scroll_clip_rects,
44 parent_scroll_offsets,
45};
46use bevy_ecs::entity::Entity;
47use bevy_ecs::prelude::{Component, Resource};
48use bevy_ecs::world::World;
49use glam::Vec2;
50use std::any::Any;
51use std::collections::HashMap;
52use std::fmt;
53use std::sync::Arc;
54use std::sync::atomic::{AtomicU64, Ordering};
55
56/// Render-world component describing one backend-painted leaf.
57///
58/// Spawned by a plugin's extract fn like any other `Extracted*` component, so it inherits document
59/// paint order via [`crate::render_world::paint_order_of`], enclosing clip brackets, the top-layer
60/// band, the [`crate::render_world::clear_extracted`] lifecycle, and the hidden-subtree sweep.
61#[derive(Component, Clone)]
62pub struct ExtractedNative {
63 /// Identifies the extension that owns this leaf. The backend dispatches on it and skips the
64 /// leaf when no painter is registered.
65 pub extension_id: Arc<str>,
66 /// Type-erased draw data. The painter downcasts it to the type its `extension_id` promises.
67 pub payload: Arc<dyn Any + Send + Sync>,
68 /// Bounding rect in logical window coordinates. Encloses every pixel the painter touches.
69 pub bounds: Rect,
70 /// Painter-algorithm sort key, from [`crate::render_world::paint_order_of`].
71 pub order: PaintOrder,
72 /// Content stamp. Equal revisions mean identical pixels; see [`next_revision`].
73 pub revision: u64,
74 /// When `true`, the backend clips the painter to [`Self::bounds`].
75 pub clip_to_bounds: bool,
76}
77
78impl fmt::Debug for ExtractedNative {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.debug_struct("ExtractedNative")
81 .field("extension_id", &self.extension_id)
82 .field("bounds", &self.bounds)
83 .field("order", &self.order)
84 .field("revision", &self.revision)
85 .field("clip_to_bounds", &self.clip_to_bounds)
86 .finish()
87 }
88}
89
90/// Monotonic content stamps for [`ExtractedNative::revision`].
91///
92/// Every call returns a value no other call returns, so a producer that stamps its leaf on each
93/// content change can never collide with a stale value.
94pub fn next_revision() -> u64 {
95 static REVISION: AtomicU64 = AtomicU64::new(1);
96 REVISION.fetch_add(1, Ordering::Relaxed)
97}
98
99/// Where one entity's leaf belongs this frame, as [`NativeExtract`] resolved it.
100#[derive(Clone, Copy, Debug, PartialEq)]
101pub struct NativePlacement {
102 /// Bounds in window coordinates, with ancestor scroll offsets already applied.
103 pub bounds: Rect,
104 /// Paint order for the entity's position in the document.
105 pub order: PaintOrder,
106 /// The alpha the leaf's own paints carry: the entity's own opacity, unless the entity fades as a
107 /// group with its children, times what ancestors outside any group holding it contribute. A
108 /// group's fade is a layer the backend composites, so it is not part of this figure.
109 pub opacity: f32,
110}
111
112/// The hierarchy-derived facts a plugin's extract fn needs, computed once per frame.
113///
114/// A leaf placed by hand from `Transform::absolute` alone ignores everything an ancestor says: it
115/// stays pinned while its scroll container scrolls, ignores `opacity`, and keeps painting inside a
116/// hidden subtree. Build one of these at the top of the extract fn and ask it to
117/// [`place`](Self::place) each entity instead. It reads the same memo the built-in extractors
118/// share, so the maps are built once per frame no matter how many extractors ask.
119pub struct NativeExtract {
120 parents: HashMap<Entity, Entity>,
121 depth_cache: HashMap<Entity, u32>,
122 hidden: std::collections::HashSet<Entity>,
123 scroll: HashMap<Entity, Vec2>,
124 opacities: HashMap<Entity, f32>,
125 clip: HashMap<Entity, (Vec2, Vec2)>,
126}
127
128impl NativeExtract {
129 /// Reads the frame's hierarchy, scroll, opacity, visibility, and clip maps from the main world.
130 pub fn new(main: &mut World) -> Self {
131 let (parents, depth_cache) = build_parent_map(main);
132 let hidden = hidden_entities(main, &parents);
133 let scroll = parent_scroll_offsets(main, &parents);
134 let opacities = opacity_model(main, &parents).draw;
135 let clip = parent_scroll_clip_rects(main, &parents);
136 Self {
137 parents,
138 depth_cache,
139 hidden,
140 scroll,
141 opacities,
142 clip,
143 }
144 }
145
146 /// Resolves where `entity`'s leaf belongs, or `None` when it should not be extracted at all:
147 /// hidden by a `Visible(false)` on itself or an ancestor, or scrolled fully out of the nearest
148 /// scroll or `overflow: hidden` container.
149 pub fn place(&mut self, entity: Entity, transform: &Transform) -> Option<NativePlacement> {
150 if self.hidden.contains(&entity) {
151 return None;
152 }
153 let offset = self.scroll.get(&entity).copied().unwrap_or(Vec2::ZERO);
154 let origin = transform.absolute - offset;
155 if let Some(clip_rect) = self.clip.get(&entity)
156 && aabb_outside(origin, transform.size, *clip_rect)
157 {
158 return None;
159 }
160 Some(NativePlacement {
161 bounds: Rect::new(origin, transform.size),
162 order: paint_order_of(entity, &self.parents, &mut self.depth_cache),
163 opacity: draw_opacity(&self.opacities, entity).0,
164 })
165 }
166}
167
168/// Carries one extension's leaves into the render world, reusing each entity's render-world entity
169/// across frames and despawning the ones that went away.
170///
171/// The lifecycle is scoped to `extension_id`, so several plugins extracting in the same frame keep
172/// their own sets: a plugin only ever retires leaves it produced. Pass the id the leaves carry.
173pub fn upsert_native_leaves<I>(render: &mut World, extension_id: &str, leaves: I)
174where
175 I: IntoIterator<Item = (Entity, ExtractedNative)>,
176{
177 let mut map = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().native);
178 let mut prior: HashMap<Entity, Entity> = HashMap::new();
179 map.retain(|(id, main_e), render_e| {
180 if &**id == extension_id {
181 prior.insert(*main_e, *render_e);
182 false
183 } else {
184 true
185 }
186 });
187
188 let id: Arc<str> = Arc::from(extension_id);
189 for (main_e, leaf) in leaves {
190 let reuse = prior
191 .remove(&main_e)
192 .filter(|&re| render.get_entity(re).is_ok());
193 let render_e = match reuse {
194 Some(re) => {
195 render.entity_mut(re).insert(leaf);
196 re
197 }
198 None => render.spawn(leaf).id(),
199 };
200 map.insert((id.clone(), main_e), render_e);
201 }
202 // Whatever is left in `prior` had a leaf last frame and none now.
203 for (_, render_e) in prior {
204 if let Ok(em) = render.get_entity_mut(render_e) {
205 em.despawn();
206 }
207 }
208 render.resource_mut::<RenderEntityMap>().native = map;
209}
210
211/// What a backend hands a [`NativePainter`] for one leaf.
212///
213/// Both the payload and the draw target are type-erased, which is what keeps this crate free of
214/// backend dependencies. A painter downcasts the payload to the type its `extension_id` promises
215/// and the target to the type the backend named in [`Self::backend_id`]; a painter that does not
216/// recognise the backend draws nothing.
217pub struct NativePaintCtx<'a> {
218 payload: &'a (dyn Any + Send + Sync),
219 target: &'a mut dyn Any,
220 /// Identifies the backend that owns [`Self::target`], for example `"lumen.render-wgpu"`.
221 pub backend_id: &'static str,
222 /// The leaf's bounds in logical window coordinates.
223 pub bounds: Rect,
224 /// Transform accumulated from ancestor transform nodes, in logical coordinates. The tree
225 /// builder emits no transform nodes today, so this is the identity for now; compose through
226 /// [`Self::device_transform`] rather than assuming it.
227 pub transform: Affine2,
228 /// Device pixel ratio between logical coordinates and the target's pixels.
229 pub dpr: f32,
230 /// Alpha multiplier accumulated from ancestor opacity nodes.
231 ///
232 /// The painter is the only thing that applies it. A bounds clip composites nothing, so asking
233 /// to be clipped never changes a leaf's alpha. An ancestor that fades as a group is a layer the
234 /// backend composites over whatever the painter draws, so it never reaches this figure, which is
235 /// `1.0` for every leaf the tree builder places; the way to honour CSS `opacity` is to fold
236 /// [`NativePlacement::opacity`] into the payload at extract, which is what the built-in
237 /// extractors do with their own colours.
238 pub opacity: f32,
239}
240
241impl<'a> NativePaintCtx<'a> {
242 /// Builds a context around one leaf's payload and the backend's draw target.
243 pub fn new(
244 payload: &'a (dyn Any + Send + Sync),
245 target: &'a mut dyn Any,
246 backend_id: &'static str,
247 bounds: Rect,
248 transform: Affine2,
249 dpr: f32,
250 opacity: f32,
251 ) -> Self {
252 Self {
253 payload,
254 target,
255 backend_id,
256 bounds,
257 transform,
258 dpr,
259 opacity,
260 }
261 }
262
263 /// Borrows the payload as `T`, or `None` when the leaf carries something else.
264 pub fn payload_as<T: Any>(&self) -> Option<&'a T> {
265 self.payload.downcast_ref::<T>()
266 }
267
268 /// Borrows the draw target as `T`, or `None` when this backend's target is something else.
269 pub fn target_as<T: Any>(&mut self) -> Option<&mut T> {
270 self.target.downcast_mut::<T>()
271 }
272
273 /// The transform from logical leaf coordinates to target pixels: the ancestor transform
274 /// followed by the device-pixel scale.
275 pub fn device_transform(&self) -> Affine2 {
276 Affine2::scale(self.dpr as f64) * self.transform
277 }
278}
279
280/// Paints one [`ExtractedNative`] leaf onto a backend draw target.
281///
282/// A single painter serves every leaf carrying its `extension_id`, so it takes `&self` and keeps
283/// any per-frame state behind interior mutability.
284pub trait NativePainter: Send + Sync + 'static {
285 /// Draws the leaf described by `ctx`.
286 fn paint(&self, ctx: &mut NativePaintCtx<'_>);
287}
288
289/// Render-world registry mapping an `extension_id` to the painter that draws it.
290///
291/// Cloning shares the table; registering on a clone copies it first, so a backend can take a cheap
292/// snapshot of the registry before it starts a frame.
293#[derive(Resource, Clone, Default)]
294pub struct NativePainters {
295 painters: Arc<HashMap<Arc<str>, Arc<dyn NativePainter>>>,
296}
297
298impl NativePainters {
299 /// Registers `painter` for `extension_id`, replacing any painter already registered for it.
300 pub fn register<P: NativePainter>(&mut self, extension_id: impl Into<Arc<str>>, painter: P) {
301 Arc::make_mut(&mut self.painters).insert(extension_id.into(), Arc::new(painter));
302 }
303
304 /// Returns the painter registered for `extension_id`, or `None`.
305 pub fn get(&self, extension_id: &str) -> Option<&Arc<dyn NativePainter>> {
306 self.painters.get(extension_id)
307 }
308
309 /// Number of registered painters.
310 pub fn len(&self) -> usize {
311 self.painters.len()
312 }
313
314 /// Returns `true` when no painter is registered.
315 pub fn is_empty(&self) -> bool {
316 self.painters.is_empty()
317 }
318}
319
320impl fmt::Debug for NativePainters {
321 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322 f.debug_struct("NativePainters")
323 .field("registered", &self.painters.len())
324 .finish()
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use std::sync::Mutex;
332
333 #[derive(Clone, Default)]
334 struct Recorder {
335 seen: Arc<Mutex<Vec<String>>>,
336 }
337
338 impl NativePainter for Recorder {
339 fn paint(&self, ctx: &mut NativePaintCtx<'_>) {
340 let label = ctx
341 .payload_as::<String>()
342 .cloned()
343 .unwrap_or_else(|| "?".to_string());
344 self.seen.lock().expect("recorder lock").push(label);
345 }
346 }
347
348 struct Noop;
349
350 impl NativePainter for Noop {
351 fn paint(&self, _ctx: &mut NativePaintCtx<'_>) {}
352 }
353
354 fn ctx_over<'a>(
355 payload: &'a (dyn Any + Send + Sync),
356 target: &'a mut dyn Any,
357 ) -> NativePaintCtx<'a> {
358 NativePaintCtx::new(
359 payload,
360 target,
361 "test.backend",
362 Rect::new(glam::Vec2::ZERO, glam::Vec2::new(10.0, 10.0)),
363 Affine2::IDENTITY,
364 1.0,
365 1.0,
366 )
367 }
368
369 /// A painter is found by the id it was registered under, and registering the
370 /// same id again replaces it - so a plugin reinstalled at runtime does not
371 /// leave the old painter drawing.
372 #[test]
373 fn the_last_painter_registered_for_an_id_is_the_one_that_paints() {
374 let mut registry = NativePainters::default();
375 assert!(registry.is_empty());
376
377 registry.register("demo.native", Noop);
378 registry.register("demo.other", Noop);
379 assert_eq!(registry.len(), 2);
380 assert!(registry.get("demo.native").is_some());
381 assert!(registry.get("demo.missing").is_none());
382
383 let recorder = Recorder::default();
384 registry.register("demo.native", recorder.clone());
385 assert_eq!(registry.len(), 2, "re-registering replaces, not appends");
386
387 let payload: Arc<dyn Any + Send + Sync> = Arc::new("hello".to_string());
388 let mut target = ();
389 let painter = registry.get("demo.native").expect("painter").clone();
390 painter.paint(&mut ctx_over(payload.as_ref(), &mut target));
391
392 assert_eq!(recorder.seen.lock().expect("lock").as_slice(), ["hello"]);
393 }
394
395 /// A backend snapshots the registry for the frame it is about to paint.
396 /// Registering after the snapshot must not reach into it.
397 #[test]
398 fn a_snapshot_of_the_registry_does_not_see_later_registrations() {
399 let mut registry = NativePainters::default();
400 registry.register("demo.native", Noop);
401
402 let snapshot = registry.clone();
403 registry.register("demo.late", Noop);
404
405 assert_eq!(snapshot.len(), 1);
406 assert!(snapshot.get("demo.late").is_none());
407 assert!(registry.get("demo.late").is_some());
408 }
409
410 /// The painter maps its own logical coordinates to target pixels through
411 /// `device_transform`: ancestor placement first, device scale second.
412 #[test]
413 fn device_transform_scales_the_ancestor_transform() {
414 let payload: Arc<dyn Any + Send + Sync> = Arc::new(7u32);
415 let mut target = ();
416 let mut ctx = ctx_over(payload.as_ref(), &mut target);
417 ctx.transform = Affine2::translate(10.0, 20.0);
418 ctx.dpr = 2.0;
419
420 assert_eq!(
421 ctx.device_transform().coeffs,
422 [2.0, 0.0, 0.0, 2.0, 20.0, 40.0]
423 );
424
425 ctx.dpr = 1.0;
426 assert_eq!(ctx.device_transform().coeffs, ctx.transform.coeffs);
427 }
428
429 /// Downcasting is how a painter checks that a leaf really carries the payload
430 /// its `extension_id` promises; a mismatch reads as absent rather than
431 /// misinterpreting the bytes.
432 #[test]
433 fn a_payload_of_the_wrong_type_reads_as_absent() {
434 let payload: Arc<dyn Any + Send + Sync> = Arc::new(7u32);
435 let mut target = 5i64;
436 let mut ctx = ctx_over(payload.as_ref(), &mut target);
437
438 assert_eq!(ctx.payload_as::<u32>(), Some(&7));
439 assert!(ctx.payload_as::<String>().is_none());
440 assert_eq!(ctx.target_as::<i64>(), Some(&mut 5));
441 assert!(ctx.target_as::<String>().is_none());
442 }
443
444 /// Revisions are what tell the damage diff that pixels changed, so two of
445 /// them are never equal.
446 #[test]
447 fn revisions_never_repeat() {
448 let a = next_revision();
449 let b = next_revision();
450 let c = next_revision();
451 assert!(a < b && b < c);
452 }
453}