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); 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