From 38f22786aa708426dc99a4ad415905ddbbdd0609 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 21:26:31 -0700 Subject: [PATCH 1/2] fix(render): keep each display's backdrop on the zone that places it A display whose zone has no face layers shows its slice of the scene canvas as its backdrop, and the scene canvas composed every output in zone order. A later zone's LED outputs that overlapped a display's box painted that zone's effect onto the physical display, so a custom zone's ripple layer showed up on LCDs the primary zone places. It hid while every device sat in one zone, because every overlap painted the same content. Projection now composes in two strata over the base: every LED output in zone order, then every display output in zone order. Display outputs are the ones is_led_sampled_zone rejects, the test the LED sampler and device routing already use. The GPU projected path inserts LED layers at a boundary index inside the admitted layer scratch, so frames stay allocation free and GPU resident. The CPU reconstruction runs the same two passes, and its full-scene identity copy runs in the stratum of the one output it stands in for. The single full-scene zone path needs no ordering, since one zone places every output. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../render_thread/render_zones/projection.rs | 133 ++++++-- .../render_thread/render_zones/render_pass.rs | 7 + .../render_zones/scene_assembly.rs | 5 + .../render_zones/tests/projection.rs | 314 ++++++++++++++++++ 4 files changed, 428 insertions(+), 31 deletions(-) diff --git a/crates/hypercolor-daemon/src/render_thread/render_zones/projection.rs b/crates/hypercolor-daemon/src/render_thread/render_zones/projection.rs index ce6ea3daa..324e46202 100644 --- a/crates/hypercolor-daemon/src/render_thread/render_zones/projection.rs +++ b/crates/hypercolor-daemon/src/render_thread/render_zones/projection.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, TryReserveError}; -use hypercolor_core::spatial::sample_canvas_position; +use hypercolor_core::spatial::{is_led_sampled_zone, sample_canvas_position}; use hypercolor_types::canvas::Canvas; use hypercolor_types::scene::{Zone, ZoneId}; use hypercolor_types::spatial::{ @@ -21,6 +21,7 @@ pub(super) struct CachedZoneProjection { pub(super) struct CachedOutputProjection { zone: Output, + stratum: ProjectionStratum, sampling_mode: SamplingMode, edge_behavior: EdgeBehavior, pub(super) bounds: Option, @@ -59,6 +60,33 @@ impl CachedZoneProjection { } } +/// The two strata of the scene canvas, in composition order. +/// +/// LED outputs land first, in zone order; where they overlap, the later zone +/// wins, a harmless detail of a preview image. Display outputs land after +/// every LED output, again in zone order. A display's box in the scene canvas +/// is that display's backdrop on real hardware, so it shows the zone that +/// places the display, whatever LED outputs from later zones overlap it +/// (Spec 86 §5.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectionStratum { + Led, + DisplayBackdrop, +} + +impl ProjectionStratum { + const COMPOSITION_ORDER: [Self; 2] = [Self::Led, Self::DisplayBackdrop]; + + /// Display outputs are exactly the outputs the LED sampler skips. + fn of(output: &Output) -> Self { + if is_led_sampled_zone(output) { + Self::Led + } else { + Self::DisplayBackdrop + } + } +} + #[derive(Debug, Clone, Copy)] struct ProjectionAffine { local_origin: [f32; 2], @@ -86,34 +114,52 @@ pub(super) fn compose_authoritative_scene_canvas( ) { scene_canvas.clear(); - for zone in zones - .iter() - .filter(|zone| zone_contributes_to_scene_canvas(zone)) - { - let Some(source) = target_canvases.get(&zone.id) else { - continue; - }; - - let Some(projection) = scene_projection_cache.get(&zone.id) else { - for output in &zone.layout.zones { - blit_zone_projection( - scene_canvas, - source, - output, - &zone.layout, - scene_width, - scene_height, - ); + for stratum in ProjectionStratum::COMPOSITION_ORDER { + for zone in zones + .iter() + .filter(|zone| zone_contributes_to_scene_canvas(zone)) + { + let Some(source) = target_canvases.get(&zone.id) else { + continue; + }; + + let Some(projection) = scene_projection_cache.get(&zone.id) else { + for output in zone + .layout + .zones + .iter() + .filter(|output| ProjectionStratum::of(output) == stratum) + { + blit_zone_projection( + scene_canvas, + source, + output, + &zone.layout, + scene_width, + scene_height, + ); + } + continue; + }; + + // The identity copy stands in for rasterizing the zone's only + // output, so it runs in that output's stratum. + if projection + .zones + .first() + .is_some_and(|output| output.stratum == stratum) + && copy_full_scene_identity_projection(scene_canvas, source, projection) + { + continue; } - continue; - }; - if copy_full_scene_identity_projection(scene_canvas, source, projection) { - continue; - } - - for zone_projection in &projection.zones { - rasterize_zone_projection(scene_canvas, source, zone_projection); + for zone_projection in projection + .zones + .iter() + .filter(|output| output.stratum == stratum) + { + rasterize_zone_projection(scene_canvas, source, zone_projection); + } } } } @@ -149,8 +195,18 @@ pub(super) fn projection_supports_composition(projection: &CachedZoneProjection) !projection.zones.is_empty() && projection.zones.iter().all(|zone| zone.affine.is_some()) } +/// Add a zone's projection layers to the scene composition, one per output, +/// in scene composition order. +/// +/// `display_layer_start` is the index where display-output layers begin. An +/// LED output's layer is inserted there and the boundary advances, so LED +/// layers stay in zone order beneath every display backdrop; a display +/// output's layer is pushed onto the end, in zone order. The capacity check +/// covers every layer before any lands, and insertion shifts layers within +/// that capacity, so a frame never allocates here. pub(super) fn append_projection_composition_layers_for_zone( layers: &mut Vec, + display_layer_start: &mut usize, frame: &ProducerFrame, zone: &Zone, projection: &CachedZoneProjection, @@ -167,9 +223,14 @@ pub(super) fn append_projection_composition_layers_for_zone( if layers.capacity().saturating_sub(layers.len()) < projection.zones.len() { return false; } + debug_assert!( + *display_layer_start <= layers.len(), + "display layers must start within the projected scene layers" + ); + let mut led_insert_at = (*display_layer_start).min(layers.len()); for output in &projection.zones { - layers.push(CompositionLayer::alpha(frame.clone(), 1.0).with_transform( - CompositionTransform { + let layer = + CompositionLayer::alpha(frame.clone(), 1.0).with_transform(CompositionTransform { anchor: output.zone.position, scale: [ output.zone.size.x * output.zone.scale, @@ -178,9 +239,16 @@ pub(super) fn append_projection_composition_layers_for_zone( rotation: output.zone.rotation, fit: FitMode::Stretch, sample_target_space: true, - }, - )); + }); + match output.stratum { + ProjectionStratum::Led => { + layers.insert(led_insert_at, layer); + led_insert_at += 1; + } + ProjectionStratum::DisplayBackdrop => layers.push(layer), + } } + *display_layer_start = led_insert_at; true } @@ -194,8 +262,10 @@ pub(super) fn projection_composition_layers_for_zone( ) -> Option> { let mut layers = Vec::new(); layers.try_reserve_exact(projection.zones.len()).ok()?; + let mut display_layer_start = 0; append_projection_composition_layers_for_zone( &mut layers, + &mut display_layer_start, frame, zone, projection, @@ -304,6 +374,7 @@ fn build_output_projection( let affine = ProjectionAffine::new(zone, target_width, target_height); CachedOutputProjection { zone: zone.clone(), + stratum: ProjectionStratum::of(zone), sampling_mode, edge_behavior, bounds: affine diff --git a/crates/hypercolor-daemon/src/render_thread/render_zones/render_pass.rs b/crates/hypercolor-daemon/src/render_thread/render_zones/render_pass.rs index a32c517ae..27fa10d1c 100644 --- a/crates/hypercolor-daemon/src/render_thread/render_zones/render_pass.rs +++ b/crates/hypercolor-daemon/src/render_thread/render_zones/render_pass.rs @@ -31,6 +31,9 @@ pub(super) struct RenderedZonePassOutput { pub(super) struct ProjectedSceneFrames { pub(super) layers: Vec, + /// Where display-output layers begin in `layers`: every LED-output + /// layer sits below it and every display backdrop above (Spec 86 §5.1). + pub(super) display_layer_start: usize, pub(super) cpu_replay_complete: bool, /// Each GPU-resident scene zone's frame this pass, in zone order: the /// sources its LEDs sample from (Spec 86 §3). @@ -41,6 +44,7 @@ impl Default for ProjectedSceneFrames { fn default() -> Self { Self { layers: Vec::new(), + display_layer_start: 0, cpu_replay_complete: true, zone_frames: Vec::new(), } @@ -132,6 +136,7 @@ impl ZoneRuntime { .layers .push(CompositionLayer::replace_opaque(opaque_black)); } + projected_scene.display_layer_start = projected_scene.layers.len(); } let render_result = (|| -> Result<()> { for zone in context.zones { @@ -196,6 +201,7 @@ impl ZoneRuntime { && let Some(projection) = self.scene_projection_cache.get(&zone.id) && append_projection_composition_layers_for_zone( &mut projected_scene.layers, + &mut projected_scene.display_layer_start, &frame, zone, projection, @@ -285,6 +291,7 @@ impl ZoneRuntime { .is_some_and(|projection| { append_projection_composition_layers_for_zone( &mut projected_scene.layers, + &mut projected_scene.display_layer_start, &frame, zone, projection, diff --git a/crates/hypercolor-daemon/src/render_thread/render_zones/scene_assembly.rs b/crates/hypercolor-daemon/src/render_thread/render_zones/scene_assembly.rs index c930c282d..06d1a9fe7 100644 --- a/crates/hypercolor-daemon/src/render_thread/render_zones/scene_assembly.rs +++ b/crates/hypercolor-daemon/src/render_thread/render_zones/scene_assembly.rs @@ -157,6 +157,11 @@ impl ZoneRuntime { Ok(result) } + /// Render a lone scene zone at the scene extent straight into the scene + /// frame, skipping projection. A display output's box keeps the zone + /// that places it without the projected scene's display-last ordering + /// (Spec 86 §5.1): one contributor places every output, LED or display, + /// and every box shows that zone's own pixels. fn render_single_full_scene_zone( &mut self, context: RenderSceneContext<'_>, diff --git a/crates/hypercolor-daemon/src/render_thread/render_zones/tests/projection.rs b/crates/hypercolor-daemon/src/render_thread/render_zones/tests/projection.rs index 44540df72..210a69b6a 100644 --- a/crates/hypercolor-daemon/src/render_thread/render_zones/tests/projection.rs +++ b/crates/hypercolor-daemon/src/render_thread/render_zones/tests/projection.rs @@ -19,6 +19,7 @@ use hypercolor_core::input::screen::planner::{ ScreenPublicationExecutorRequest, ScreenPublicationKind, ScreenPublicationRequest, ScreenResourceApi, ScreenSourceReflection, ScreenSourceSelector, ScreenUpscalePolicy, }; +use hypercolor_core::spatial::DISPLAY_ZONE_NAME; #[cfg(feature = "wgpu")] use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize}; #[cfg(feature = "wgpu")] @@ -714,6 +715,203 @@ fn projected_composition_preserves_zone_and_zone_overlap_order() { assert_eq!(actual.get_pixel(0, 7), Rgba::BLACK); } +#[test] +fn authoritative_scene_canvas_keeps_display_backdrop_on_its_owner_zone() { + let mut runtime = cpu_backed_runtime(8, 8); + let zones = display_backdrop_overlap_zones(); + runtime + .target_canvases + .insert(zones[0].id, solid_canvas(8, 8, Rgba::new(255, 0, 0, 255))); + runtime + .target_canvases + .insert(zones[1].id, solid_canvas(8, 8, Rgba::new(0, 0, 255, 255))); + + let scene_frame = runtime + .compose_scene_frame(&zones) + .expect("scene frame should allocate"); + let scene = canvas_from_scene_frame(&scene_frame); + + assert_display_backdrop_owns_its_box(|x, y| canvas_rgb(&scene, x, y)); +} + +#[test] +fn cpu_scene_render_keeps_display_backdrop_on_its_owner_zone() { + let registry = EffectRegistry::default(); + let zones = display_backdrop_overlap_zones(); + let mut runtime = ZoneRuntime::new(8, 8); + let mut zone_colors = Vec::new(); + + let rendered = render_scene_for_test( + &mut runtime, + &zones, + 1, + 0, + &HashMap::new(), + ®istry, + &mut zone_colors, + ) + .expect("CPU multi-zone scene should render"); + let scene = canvas_from_scene_frame(&rendered.scene_frame); + + assert_display_backdrop_owns_its_box(|x, y| canvas_rgb(&scene, x, y)); +} + +#[test] +fn full_scene_identity_display_backdrop_lands_after_led_outputs() { + let [mut owner, overlay] = display_backdrop_overlap_zones(); + let mut owner_display = point_zone("owner_full_display"); + owner_display.size = NormalizedPosition::new(1.0, 1.0); + owner_display.zone_name = Some(DISPLAY_ZONE_NAME.to_owned()); + owner_display.sampling_mode = Some(SamplingMode::Nearest); + owner.layout.zones = vec![owner_display]; + let owner_source = solid_canvas(8, 8, Rgba::new(255, 0, 0, 255)); + let owner_projection = + build_zone_projection(&owner, 8, 8).expect("owner projection metadata should allocate"); + let mut identity_probe = Canvas::new(8, 8); + assert!( + copy_full_scene_identity_projection(&mut identity_probe, &owner_source, &owner_projection), + "the owner zone should take the full-scene identity fast path" + ); + let projection_cache = HashMap::from([ + (owner.id, owner_projection), + ( + overlay.id, + build_zone_projection(&overlay, 8, 8) + .expect("overlay projection metadata should allocate"), + ), + ]); + let target_canvases = HashMap::from([ + (owner.id, owner_source), + (overlay.id, solid_canvas(8, 8, Rgba::new(0, 0, 255, 255))), + ]); + let mut scene = Canvas::new(8, 8); + + compose_authoritative_scene_canvas( + &mut scene, + &[owner, overlay], + &target_canvases, + 8, + 8, + &projection_cache, + ); + + for y in 0..8 { + for x in 0..8 { + assert_eq!( + canvas_rgb(&scene, x, y), + [255, 0, 0], + "a full-scene display backdrop should cover the later LED output at ({x}, {y})" + ); + } + } +} + +#[test] +fn overlapping_display_backdrops_keep_zone_order() { + let [mut back, mut front] = display_backdrop_overlap_zones(); + let mut front_display = back.layout.zones[0].clone(); + front_display.id = "front_display".into(); + front_display.position = NormalizedPosition::new(0.5, 0.25); + front.layout.zones = vec![front_display]; + back.layout.zones.truncate(1); + let projection_cache = HashMap::from([ + ( + back.id, + build_zone_projection(&back, 8, 8).expect("back projection metadata should allocate"), + ), + ( + front.id, + build_zone_projection(&front, 8, 8).expect("front projection metadata should allocate"), + ), + ]); + let target_canvases = HashMap::from([ + (back.id, solid_canvas(8, 8, Rgba::new(255, 0, 0, 255))), + (front.id, solid_canvas(8, 8, Rgba::new(0, 0, 255, 255))), + ]); + let mut scene = Canvas::new(8, 8); + + compose_authoritative_scene_canvas( + &mut scene, + &[back, front], + &target_canvases, + 8, + 8, + &projection_cache, + ); + + assert_eq!(canvas_rgb(&scene, 1, 1), [255, 0, 0]); + assert_eq!( + canvas_rgb(&scene, 3, 1), + [0, 0, 255], + "the later zone's display backdrop should win where two backdrops overlap" + ); + assert_eq!(canvas_rgb(&scene, 5, 1), [0, 0, 255]); +} + +#[test] +fn projected_scene_layers_put_display_backdrops_last() { + let mut runtime = ZoneRuntime::new(8, 8); + let registry = EffectRegistry::default(); + let zones = display_backdrop_overlap_zones(); + let dependency_key = SceneDependencyKey::new(1, registry.generation()); + runtime + .reconcile( + &zones, + Some(SceneId::DEFAULT), + dependency_key, + ®istry, + &HashMap::new(), + None, + ) + .expect("projection resources should reconcile"); + runtime + .projected_scene_layers + .try_reserve_exact(3) + .expect("test projection scratch should allocate"); + let audio = AudioData::silence(); + let interaction = InteractionData::default(); + let sensors = SystemSnapshot::empty(); + let target_fps = HashMap::new(); + let context = RenderSceneContext { + zones: &zones, + active_scene_id: Some(SceneId::DEFAULT), + dependency_key, + elapsed_ms: 0, + display_zone_target_fps: &target_fps, + display_zone_descriptors: &HashMap::new(), + registry: ®istry, + authoritative_spatial_engine: None, + inputs: ZoneFrameInputs { + delta_secs: 1.0 / 60.0, + audio: &audio, + interaction: &interaction, + screen: None, + sensors: &sensors, + input_availability: InputSourceAvailability::default(), + media: None, + net: None, + lighting: None, + }, + }; + let mut sparkleflinger = SparkleFlinger::cpu(); + let mut output = super::super::render_pass::RenderedZonePassOutput::default(); + + let projected = runtime + .render_scene_contributor_frames(context, &mut sparkleflinger, true, true, &mut output) + .expect("projected contributors should render"); + + let layer_count = projected.layers.len(); + let display_layer_start = projected.display_layer_start; + let scene = compose_projection_layers_on_cpu(projected.layers, 8, 8); + + assert_display_backdrop_owns_its_box(|x, y| canvas_rgb(&scene, x, y)); + assert_eq!(layer_count, 3); + assert_eq!( + display_layer_start, 2, + "both LED outputs should sit below the one display backdrop" + ); +} + /// The projected scene feeds previews only, so bilinear zones project like /// any affine zone; their LEDs sample their own textures (Spec 86 §5.1). #[test] @@ -863,6 +1061,45 @@ fn gpu_projected_scene_frame_stays_gpu_resident() { assert!(matches!(frame, ProducerFrame::GpuTexture(_))); } +#[cfg(feature = "wgpu")] +#[test] +fn gpu_projected_scene_keeps_display_backdrop_on_its_owner_zone() { + let Some(mut sparkleflinger) = required_gpu_sparkleflinger() else { + return; + }; + let canvas = sparkleflinger + .prepare_canvas_resize(8, 8) + .expect("display backdrop scene canvas should prepare"); + assert!(canvas.is_admitted()); + sparkleflinger.apply_canvas_resize(canvas); + let registry = EffectRegistry::default(); + let zones = display_backdrop_overlap_zones(); + let mut runtime = ZoneRuntime::new(8, 8); + let mut zone_colors = Vec::new(); + + let rendered = render_scene_for_test_with_screen_and_sparkleflinger( + &mut runtime, + &zones, + 1, + 0, + &HashMap::new(), + ®istry, + &mut zone_colors, + None, + &mut sparkleflinger, + ) + .expect("GPU multi-zone scene should render"); + + assert!( + matches!(rendered.scene_frame, ProducerFrame::GpuTexture(_)), + "the scene canvas should come from GPU projection, not the CPU fallback" + ); + let pixels = sample_full_gpu_canvas(&mut sparkleflinger, 8, 8); + assert_display_backdrop_owns_its_box(|x, y| { + pixels[usize::try_from(y * 8 + x).expect("scene pixel index should fit usize")] + }); +} + #[cfg(feature = "wgpu")] #[test] fn two_gpu_resident_zones_produce_stable_projected_scene_frame() { @@ -2104,6 +2341,83 @@ fn cpu_backed_runtime(width: u32, height: u32) -> ZoneRuntime { runtime } +/// Two color-fill zones at the 8x8 scene extent. The red owner zone places a +/// display output over the top-left quadrant and an LED output over the +/// bottom-right one. The blue overlay zone, later in zone order, places one +/// LED output across the center that overlaps both. +fn display_backdrop_overlap_zones() -> [Zone; 2] { + let mut owner = sample_zone(8, 8); + make_color_fill_zone(&mut owner); + let mut owner_display = point_zone_at("owner_display", 0.25, 0.25); + owner_display.size = NormalizedPosition::new(0.5, 0.5); + owner_display.zone_name = Some(DISPLAY_ZONE_NAME.to_owned()); + let mut owner_led = point_zone_at("owner_led", 0.75, 0.75); + owner_led.size = NormalizedPosition::new(0.5, 0.5); + owner.layout.zones = vec![owner_display, owner_led]; + owner.layout.default_sampling_mode = SamplingMode::Nearest; + + let mut overlay = sample_zone(8, 8); + make_color_fill_zone(&mut overlay); + let LayerSource::ColorFill { rgba } = &mut overlay.layers[0].source else { + unreachable!("color-fill helper should create a color layer") + }; + *rgba = [0.0, 0.0, 1.0, 1.0]; + let mut overlay_led = point_zone_at("overlay_led", 0.5, 0.5); + overlay_led.size = NormalizedPosition::new(0.5, 0.5); + overlay.layout.zones = vec![overlay_led]; + overlay.layout.default_sampling_mode = SamplingMode::Nearest; + [owner, overlay] +} + +/// Check a scene canvas composed from `display_backdrop_overlap_zones`, read +/// as RGB through `pixel(x, y)`. +fn assert_display_backdrop_owns_its_box(pixel: impl Fn(u32, u32) -> [u8; 3]) { + const OWNER: [u8; 3] = [255, 0, 0]; + const OVERLAY: [u8; 3] = [0, 0, 255]; + for (x, y) in [(2, 2), (3, 3), (2, 3), (3, 2)] { + assert_eq!( + pixel(x, y), + OWNER, + "the display box at ({x}, {y}) should show the zone that places the display, \ + not the later zone's overlapping LED output" + ); + } + for (x, y) in [(0, 0), (1, 3)] { + assert_eq!(pixel(x, y), OWNER, "display box at ({x}, {y})"); + } + for (x, y) in [(4, 2), (5, 3), (2, 4), (3, 5)] { + assert_eq!( + pixel(x, y), + OVERLAY, + "outside the display box, the overlay's LED output should show at ({x}, {y})" + ); + } + for (x, y) in [(4, 4), (5, 5)] { + assert_eq!( + pixel(x, y), + OVERLAY, + "overlapping LED outputs should keep zone order at ({x}, {y})" + ); + } + for (x, y) in [(6, 4), (7, 7)] { + assert_eq!(pixel(x, y), OWNER, "owner LED output at ({x}, {y})"); + } + for (x, y) in [(7, 0), (0, 7)] { + assert_eq!(pixel(x, y), [0, 0, 0], "uncovered pixel at ({x}, {y})"); + } +} + +fn solid_canvas(width: u32, height: u32, color: Rgba) -> Canvas { + let mut canvas = Canvas::new(width, height); + canvas.fill(color); + canvas +} + +fn canvas_rgb(canvas: &Canvas, x: u32, y: u32) -> [u8; 3] { + let pixel = canvas.get_pixel(x, y); + [pixel.r, pixel.g, pixel.b] +} + #[cfg(feature = "wgpu")] fn gpu_projection_zones() -> [Zone; 2] { let mut back = sample_zone(4, 4); From d0106771e58f12958cb4cbdf51f3491a17115a4c Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 21:26:36 -0700 Subject: [PATCH 2/2] =?UTF-8?q?docs(spec):=20state=20the=20display-last=20?= =?UTF-8?q?projection=20order=20in=20Spec=2086=20=C2=A75.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 86 §5.1 carried over Spec 64's view that zone-order overwrite in the scene canvas is a harmless detail of a preview image. That holds for LED outputs only. A display's box in the scene canvas is that display's backdrop, so it reaches hardware, and a later zone's LED output overlapping it painted the wrong zone onto the display. The section now states the two-stratum order (LED outputs first, then display outputs, each in zone order), the predicate that identifies a display output, why the box has to show the zone that places the display, and how the CPU reconstruction and the single full-scene zone path line up with it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/86-gpu-resident-multi-zone.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/specs/86-gpu-resident-multi-zone.md b/docs/specs/86-gpu-resident-multi-zone.md index 2d580a03e..a38a73239 100644 --- a/docs/specs/86-gpu-resident-multi-zone.md +++ b/docs/specs/86-gpu-resident-multi-zone.md @@ -383,14 +383,30 @@ its assigned outputs sample: ### 5.1 Scene canvas The scene canvas is composed on the GPU from zone snapshots with the existing -projection layers (`projection.rs:150-180`, one layer per output over the +projection layers (`projection.rs:207-254`, one layer per output over the opaque black base) whenever a consumer requests it: a preview receiver, a display face that blends the scene, or a canvas capture. The primary composition plan, including active transitions, runs over it exactly as today, so display backdrops see transitions. It never feeds LED output, so admission drops the sampling-mode and edge-behavior requirements and keeps -the affine requirement. Overlapping outputs keep zone-order overwrite, which -Spec 64 already calls a harmless detail of a preview image. +the affine requirement. + +Projection layers land in two strata. Every LED output's layer comes first, +in zone order, so overlapping LED outputs keep zone-order overwrite, which +Spec 64 calls a harmless detail of a preview image. Every display output's +layer comes after all of them, also in zone order, so overlapping display +outputs keep zone order among themselves. A display output is one that +`is_led_sampled_zone` rejects, the same test the LED sampler and device +routing use. Display boxes are not preview detail: a display whose zone has +no face layers shows its slice of the scene canvas as its backdrop, so its +box puts the scene canvas on hardware. Under plain zone order, an LED output +from a later zone that overlapped a display's box painted that zone's effect +onto the physical display (a custom zone's ripple layer showing on LCDs the +primary zone places). The box has to show the zone that places the display. +The CPU reconstruction (`compose_authoritative_scene_canvas`) composes the +same two strata, and its full-scene identity copy runs in the stratum of the +one output it stands in for. The single full-scene zone path skips +projection and needs no ordering, because one zone places every output. The target-space fetch rounds to the nearest texel. That is exact for nearest outputs and close for others, but Spec 48 §4.2 asks the preview to follow LED