From dc77ba547bfe0d62c6833ece59adadc96d7034e0 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 05:46:05 +0200 Subject: [PATCH] Capture direct native frames and verify both installed rendering paths --- .../windows-direct-frame-capture-v1.md | 48 ++++ docs/evidence/windows-installed-native-v1.md | 7 +- docs/windows-engine-plan.md | 10 +- native/shared/src/renderer/direct_frame.rs | 266 ++++++++++++++++++ native/shared/src/renderer/mod.rs | 240 +--------------- native/shared/tests/render_targets.rs | 43 +++ tools/ci/fixtures/native-package.ts | 3 + tools/ci/native_package_smoke.py | 62 ++-- 8 files changed, 410 insertions(+), 269 deletions(-) create mode 100644 docs/evidence/windows-direct-frame-capture-v1.md create mode 100644 native/shared/src/renderer/direct_frame.rs diff --git a/docs/evidence/windows-direct-frame-capture-v1.md b/docs/evidence/windows-direct-frame-capture-v1.md new file mode 100644 index 00000000..660f6ad8 --- /dev/null +++ b/docs/evidence/windows-direct-frame-capture-v1.md @@ -0,0 +1,48 @@ +# Direct-frame PNG capture + +An installed native game using `setDirect2DMode(true)` rendered and exited but +never produced its requested PNG. `Renderer::end_frame()` submitted the draws +without servicing the screenshot request; the normal scene path already had +framebuffer readback support. + +The direct path now records the existing output-texture readback after drawing, +submits it and completes the capture before presentation. Frames with no capture +keep their existing submission behavior. Render-target-only frames leave the +output request pending. Requests for scene diagnostic attachments wait for an +eligible scene frame. The existing readback implementation is reused unchanged. + +The direct frame method is extracted to `renderer/direct_frame.rs` to keep the +large renderer module shrinking. Removing the capture addition restores the +previous method text exactly; an extraction receipt is retained with the evidence. + +## Validation + +The installed-package checker now compiles its actual Jolt physics fixture in +both scene and direct-2D modes. Each starts on physical Radeon 760M DX12 and +Vulkan, and all four PNG files match all 16,384 expected pixels. They have the +same SHA-256: +`8a509d87d3aa3fab96e0a9e2c67228e187f1bc0853cb4726aa5844799440f30e`. +The first native compile takes 246.453 seconds and the second 3.125 seconds; +startup/capture takes 3.203 to 5.859 seconds. These are test durations, not FPS. + +A renderer regression checks uncaptured frames, capture deferral across a +smaller render texture and two successive output captures with different exact +pixels. All five render-target tests pass on DX12 and Vulkan. Each backend also +passes all 93 image goldens with the original assertions and four existing +ignored cases. Contracts, formatting and strict Clippy pass. The first WASM +check caught a misplaced native-only module guard during extraction; restoring +it to `quality_capture` and keeping `direct_frame` available on both platforms +passes the WASM compile check. The failure and correction are retained. + +The first checker result hashes the normalized UTF-8 entry text. The final +checker hashes the actual written entry file and also records installed renderer +source hashes. That metadata correction does not change the rendering fixture. + +This is native headless capture acceptance with the qualified Perry 0.5.1220 +runtime profile and local SDK DXC on PATH. Visible presentation, packaged DXC, +browser capture/startup, general long native paths and the complete starter +lifecycle remain separate requirements. No image baselines or thresholds change. + +Raw results are retained under +`tools/quality/out/windows-engine-plan/direct-2d-capture/`, including the original +missing-capture failure from the installed-native diagnosis. diff --git a/docs/evidence/windows-installed-native-v1.md b/docs/evidence/windows-installed-native-v1.md index 03e694ac..932357b1 100644 --- a/docs/evidence/windows-installed-native-v1.md +++ b/docs/evidence/windows-installed-native-v1.md @@ -49,9 +49,10 @@ stock-Perry usability, packaged DXC, a clean machine or window presentation. That audit harness override was removed before the normal startup checks. - Very long native project paths can still fail in MSVC's build-script linker; the resolver correction does not fix general Windows path-length support. -- Direct-2D mode currently does not service the queued PNG capture. That initial - probe exited without an image and failed. The accepted fixture exercises the - normal scene path; direct-2D capture remains separate work. +- The initial direct-2D probe exited without its queued PNG and failed. The + accepted fixture in this report exercises the normal scene path. A subsequent + [direct-frame capture correction](windows-direct-frame-capture-v1.md) adds + both rendering modes to the installed-package check. - Native headless startup does not prove browser startup or visible native presentation. #142/#74's complete starter and lifecycle remain open. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 640efc7c..91ece2b3 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -90,15 +90,19 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json now passes its clean installed help command, nine failure/assembly regression checks, and a complete installed Perry-plus-engine WASM build on Windows. Its asynchronous-copy follow-up also passes the hosted Windows pack/install - check and a fresh local installed web build; remaining hosted jobs are running. + check and a fresh local installed web build. All 22 hosted Tests jobs pass + at #166, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-portable-web-20260911). The [native package correction](evidence/windows-installed-native-v1.md) fixes Jolt directory lookup and redundant final-link metadata. A diagnostic installed fixture simulates Jolt and renders an exact frame on DX12 and Vulkan. The complete fresh-package checker also passes both backends with all 16,384 pixels matching and no CMake fallback. Hosted startup checks are - pending. Browser starter + pending. The [direct-frame capture correction](evidence/windows-direct-frame-capture-v1.md) + also passes the installed physics/image fixture in both scene and direct-2D + modes on DX12 and Vulkan. A render-target regression checks capture deferral + and fresh output pixels. Browser starter rendering, visible native presentation, shared lifecycle, general long-path - support and direct-2D frame capture remain open. + support remain open. 3. Complete the wider temporal/geometry, performance, memory, resize, and capability corpus. The [HD surface correction](evidence/windows-ssgi-surface-v1.md) and two valid diff --git a/native/shared/src/renderer/direct_frame.rs b/native/shared/src/renderer/direct_frame.rs new file mode 100644 index 00000000..784b0ea7 --- /dev/null +++ b/native/shared/src/renderer/direct_frame.rs @@ -0,0 +1,266 @@ +use super::Renderer; + +impl Renderer { + pub fn end_frame(&mut self) { + if !self.material_per_view_bg_live { + self.refresh_material_per_view_bg(); + } + // Flush pending joint matrices to GPU right before rendering + self.flush_joint_matrices(); + // One pooled upload for every cached-model draw's uniforms. + self.flush_model_uniforms(); + + // Q1: If rendering to a texture, use the RT view. Otherwise use the surface. + // We take ownership of the RT views (via Option::take) to avoid holding a + // borrow on `self` while the rest of end_frame mutates it. + let rt_color = self.rt_color_view.take(); + let rt_depth = self.rt_depth_view.take(); + let using_rt = rt_color.is_some(); + + let surface_output = if using_rt { + None + } else { + match self.acquire_frame() { + Some(t) => Some(t), + None => { + // Swapchain lost+reconfigured. Restore RT views if set. + self.rt_color_view = rt_color; + self.rt_depth_view = rt_depth; + return; + } + } + }; + + let view: wgpu::TextureView; + let owned_depth_view: wgpu::TextureView; + + if let Some(ref rt_view) = rt_color { + view = rt_view.clone(); + owned_depth_view = rt_depth.as_ref().unwrap().clone(); + } else { + view = self + .frame_texture(surface_output.as_ref().unwrap()) + .create_view(&wgpu::TextureViewDescriptor { + format: Some(self.output_format), + ..Default::default() + }); + owned_depth_view = self + .depth_texture + .create_view(&wgpu::TextureViewDescriptor::default()); + } + + // Restore RT views so they persist across frames. + self.rt_color_view = rt_color; + self.rt_depth_view = rt_depth; + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bloom_encoder"), + }); + self.frame_resource_stats.created_command_encoder(); + + // Upload 2D data to persistent GPU buffers + let has_2d = !self.vertices_2d.is_empty(); + if has_2d { + let vb_size = std::mem::size_of_val(self.vertices_2d.as_slice()); + let ib_size = std::mem::size_of_val(self.indices_2d.as_slice()); + self.ensure_buffer_capacity_2d(vb_size, ib_size); + self.queue.write_buffer( + &self.persistent_vb_2d, + 0, + bytemuck::cast_slice(&self.vertices_2d), + ); + self.queue.write_buffer( + &self.persistent_ib_2d, + 0, + bytemuck::cast_slice(&self.indices_2d), + ); + } + + // Upload 3D data to persistent GPU buffers + let has_3d = !self.vertices_3d.is_empty(); + if has_3d { + let vb_size = std::mem::size_of_val(self.vertices_3d.as_slice()); + let ib_size = std::mem::size_of_val(self.indices_3d.as_slice()); + self.ensure_buffer_capacity_3d(vb_size, ib_size); + self.queue.write_buffer( + &self.persistent_vb_3d, + 0, + bytemuck::cast_slice(&self.vertices_3d), + ); + self.queue.write_buffer( + &self.persistent_ib_3d, + 0, + bytemuck::cast_slice(&self.indices_3d), + ); + } + + { + // Only attach a depth target when we're drawing 3D. pipeline_2d is + // depth-less; on some mobile Vulkan drivers (Adreno) pairing a + // depth-less pipeline with a pass that carries a depth attachment + // discards all draws silently. Matches the overlay_2d pass in + // end_frame_with_scene, which also omits depth. + let depth_attachment = if has_3d { + Some(wgpu::RenderPassDepthStencilAttachment { + view: &owned_depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }) + } else { + None + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(self.clear_color), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: depth_attachment, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + // Draw 3D geometry first (with depth testing), batched by texture + if has_3d { + pass.set_pipeline(&self.pipeline_3d); + pass.set_bind_group(0, &self.uniform_bind_group_3d, &[]); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + pass.set_vertex_buffer(0, self.persistent_vb_3d.slice(..)); + pass.set_index_buffer(self.persistent_ib_3d.slice(..), wgpu::IndexFormat::Uint32); + + if self.draw_calls_3d.is_empty() { + // No draw calls tracked — draw all with white texture (backward compat) + pass.set_bind_group(2, &self.texture_bind_groups[0], &[]); + pass.draw_indexed(0..self.indices_3d.len() as u32, 0, 0..1); + } else { + let num_calls = self.draw_calls_3d.len(); + for i in 0..num_calls { + let call = &self.draw_calls_3d[i]; + let next_start = if i + 1 < num_calls { + self.draw_calls_3d[i + 1].index_start + } else { + self.indices_3d.len() as u32 + }; + let count = next_start - call.index_start; + if count == 0 { + continue; + } + let tex_idx = call.texture_idx as usize; + if tex_idx < self.texture_bind_groups.len() { + pass.set_bind_group(2, &self.texture_bind_groups[tex_idx], &[]); + } else { + pass.set_bind_group(2, &self.texture_bind_groups[0], &[]); + } + pass.draw_indexed(call.index_start..next_start, 0, 0..1); + } + } + } + + // Draw cached models (static models with GPU-resident buffers). + // Use the scene pipeline so PBR-style material bindings (base + // color + normal map) apply — drawModel should behave the same + // as attachModelToNode for PBR purposes. + if !self.model_draw_commands.is_empty() { + pass.set_pipeline(&self.scene_pipeline); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + + for cmd in &self.model_draw_commands { + if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { + if cmd.mesh_idx < meshes.len() { + let mesh = &meshes[cmd.mesh_idx]; + let draw = self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count); + pass.set_bind_group( + 0, + &self.model_uniform_bind_groups[cmd.uniform_slot], + &[], + ); + pass.set_bind_group(2, &mesh.material_bg, &[]); + pass.set_vertex_buffer(0, draw.vertex.slice(..)); + pass.set_index_buffer(draw.index.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(draw.index_range(), draw.base_vertex, 0..1); + } + } + } + } + + // Draw 2D geometry (no depth testing, always passes) + if has_2d { + pass.set_pipeline(&self.pipeline_2d); + pass.set_vertex_buffer(0, self.persistent_vb_2d.slice(..)); + pass.set_index_buffer(self.persistent_ib_2d.slice(..), wgpu::IndexFormat::Uint32); + + let num_calls = self.draw_calls_2d.len(); + for i in 0..num_calls { + let call = &self.draw_calls_2d[i]; + let next_start = if i + 1 < num_calls { + self.draw_calls_2d[i + 1].index_start + } else { + self.indices_2d.len() as u32 + }; + let count = next_start - call.index_start; + if count == 0 { + continue; + } + + pass.set_bind_group( + 0, + &self.uniform_bind_groups[call.uniform_idx as usize], + &[], + ); + if (call.texture_idx as usize) < self.texture_bind_groups.len() { + pass.set_bind_group( + 1, + &self.texture_bind_groups[call.texture_idx as usize], + &[], + ); + } + pass.draw_indexed(call.index_start..next_start, 0, 0..1); + } + } + } + + // A direct frame still owns the normal output texture. Service a PNG + // request after drawing, just as the scene graph's terminal capture + // pass does. Render-target-only frames and scene diagnostics stay + // pending for an eligible output/scene frame. + #[cfg(not(target_arch = "wasm32"))] + let frame_readback = if self.screenshot_requested + && self.pending_quality_capture_dir.is_none() + && self.pending_mrt_capture_dir.is_none() + { + surface_output + .as_ref() + .map(|output| self.record_frame_readback(&mut encoder, self.frame_texture(output))) + } else { + None + }; + + self.flush_lighting_uniforms(); + #[cfg(not(target_arch = "wasm32"))] + if let Some(readback) = frame_readback { + self.queue.submit(std::iter::once(encoder.finish())); + self.finish_frame_readback(readback); + } else { + self.submit_frame_commands(encoder.finish()); + } + #[cfg(target_arch = "wasm32")] + self.submit_frame_commands(encoder.finish()); + if let Some(out) = surface_output { + self.present_frame(out); + } + self.finish_frame_resource_stats(); + } +} diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index bb2ec728..2e5edb8c 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -60,6 +60,7 @@ pub fn build_cooked_color_mip_chain( (bytes, mip_count) } mod capability_api; +mod direct_frame; mod draw2d; mod env_prefilter; mod final_pass; @@ -12674,245 +12675,6 @@ impl Renderer { self.material_per_view_bg_live = true; } - pub fn end_frame(&mut self) { - if !self.material_per_view_bg_live { - self.refresh_material_per_view_bg(); - } - // Flush pending joint matrices to GPU right before rendering - self.flush_joint_matrices(); - // One pooled upload for every cached-model draw's uniforms. - self.flush_model_uniforms(); - - // Q1: If rendering to a texture, use the RT view. Otherwise use the surface. - // We take ownership of the RT views (via Option::take) to avoid holding a - // borrow on `self` while the rest of end_frame mutates it. - let rt_color = self.rt_color_view.take(); - let rt_depth = self.rt_depth_view.take(); - let using_rt = rt_color.is_some(); - - let surface_output = if using_rt { - None - } else { - match self.acquire_frame() { - Some(t) => Some(t), - None => { - // Swapchain lost+reconfigured. Restore RT views if set. - self.rt_color_view = rt_color; - self.rt_depth_view = rt_depth; - return; - } - } - }; - - let view: wgpu::TextureView; - let owned_depth_view: wgpu::TextureView; - - if let Some(ref rt_view) = rt_color { - view = rt_view.clone(); - owned_depth_view = rt_depth.as_ref().unwrap().clone(); - } else { - view = self - .frame_texture(surface_output.as_ref().unwrap()) - .create_view(&wgpu::TextureViewDescriptor { - format: Some(self.output_format), - ..Default::default() - }); - owned_depth_view = self - .depth_texture - .create_view(&wgpu::TextureViewDescriptor::default()); - } - - // Restore RT views so they persist across frames. - self.rt_color_view = rt_color; - self.rt_depth_view = rt_depth; - - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("bloom_encoder"), - }); - self.frame_resource_stats.created_command_encoder(); - - // Upload 2D data to persistent GPU buffers - let has_2d = !self.vertices_2d.is_empty(); - if has_2d { - let vb_size = std::mem::size_of_val(self.vertices_2d.as_slice()); - let ib_size = std::mem::size_of_val(self.indices_2d.as_slice()); - self.ensure_buffer_capacity_2d(vb_size, ib_size); - self.queue.write_buffer( - &self.persistent_vb_2d, - 0, - bytemuck::cast_slice(&self.vertices_2d), - ); - self.queue.write_buffer( - &self.persistent_ib_2d, - 0, - bytemuck::cast_slice(&self.indices_2d), - ); - } - - // Upload 3D data to persistent GPU buffers - let has_3d = !self.vertices_3d.is_empty(); - if has_3d { - let vb_size = std::mem::size_of_val(self.vertices_3d.as_slice()); - let ib_size = std::mem::size_of_val(self.indices_3d.as_slice()); - self.ensure_buffer_capacity_3d(vb_size, ib_size); - self.queue.write_buffer( - &self.persistent_vb_3d, - 0, - bytemuck::cast_slice(&self.vertices_3d), - ); - self.queue.write_buffer( - &self.persistent_ib_3d, - 0, - bytemuck::cast_slice(&self.indices_3d), - ); - } - - { - // Only attach a depth target when we're drawing 3D. pipeline_2d is - // depth-less; on some mobile Vulkan drivers (Adreno) pairing a - // depth-less pipeline with a pass that carries a depth attachment - // discards all draws silently. Matches the overlay_2d pass in - // end_frame_with_scene, which also omits depth. - let depth_attachment = if has_3d { - Some(wgpu::RenderPassDepthStencilAttachment { - view: &owned_depth_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }) - } else { - None - }; - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(self.clear_color), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: depth_attachment, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - - // Draw 3D geometry first (with depth testing), batched by texture - if has_3d { - pass.set_pipeline(&self.pipeline_3d); - pass.set_bind_group(0, &self.uniform_bind_group_3d, &[]); - pass.set_bind_group(1, &self.lighting_bind_group, &[]); - pass.set_bind_group(3, &self.joint_bind_group, &[]); - pass.set_vertex_buffer(0, self.persistent_vb_3d.slice(..)); - pass.set_index_buffer(self.persistent_ib_3d.slice(..), wgpu::IndexFormat::Uint32); - - if self.draw_calls_3d.is_empty() { - // No draw calls tracked — draw all with white texture (backward compat) - pass.set_bind_group(2, &self.texture_bind_groups[0], &[]); - pass.draw_indexed(0..self.indices_3d.len() as u32, 0, 0..1); - } else { - let num_calls = self.draw_calls_3d.len(); - for i in 0..num_calls { - let call = &self.draw_calls_3d[i]; - let next_start = if i + 1 < num_calls { - self.draw_calls_3d[i + 1].index_start - } else { - self.indices_3d.len() as u32 - }; - let count = next_start - call.index_start; - if count == 0 { - continue; - } - let tex_idx = call.texture_idx as usize; - if tex_idx < self.texture_bind_groups.len() { - pass.set_bind_group(2, &self.texture_bind_groups[tex_idx], &[]); - } else { - pass.set_bind_group(2, &self.texture_bind_groups[0], &[]); - } - pass.draw_indexed(call.index_start..next_start, 0, 0..1); - } - } - } - - // Draw cached models (static models with GPU-resident buffers). - // Use the scene pipeline so PBR-style material bindings (base - // color + normal map) apply — drawModel should behave the same - // as attachModelToNode for PBR purposes. - if !self.model_draw_commands.is_empty() { - pass.set_pipeline(&self.scene_pipeline); - pass.set_bind_group(1, &self.lighting_bind_group, &[]); - pass.set_bind_group(3, &self.joint_bind_group, &[]); - - for cmd in &self.model_draw_commands { - if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { - if cmd.mesh_idx < meshes.len() { - let mesh = &meshes[cmd.mesh_idx]; - let draw = self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count); - pass.set_bind_group( - 0, - &self.model_uniform_bind_groups[cmd.uniform_slot], - &[], - ); - pass.set_bind_group(2, &mesh.material_bg, &[]); - pass.set_vertex_buffer(0, draw.vertex.slice(..)); - pass.set_index_buffer(draw.index.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(draw.index_range(), draw.base_vertex, 0..1); - } - } - } - } - - // Draw 2D geometry (no depth testing, always passes) - if has_2d { - pass.set_pipeline(&self.pipeline_2d); - pass.set_vertex_buffer(0, self.persistent_vb_2d.slice(..)); - pass.set_index_buffer(self.persistent_ib_2d.slice(..), wgpu::IndexFormat::Uint32); - - let num_calls = self.draw_calls_2d.len(); - for i in 0..num_calls { - let call = &self.draw_calls_2d[i]; - let next_start = if i + 1 < num_calls { - self.draw_calls_2d[i + 1].index_start - } else { - self.indices_2d.len() as u32 - }; - let count = next_start - call.index_start; - if count == 0 { - continue; - } - - pass.set_bind_group( - 0, - &self.uniform_bind_groups[call.uniform_idx as usize], - &[], - ); - if (call.texture_idx as usize) < self.texture_bind_groups.len() { - pass.set_bind_group( - 1, - &self.texture_bind_groups[call.texture_idx as usize], - &[], - ); - } - pass.draw_indexed(call.index_start..next_start, 0, 0..1); - } - } - } - - self.flush_lighting_uniforms(); - self.submit_frame_commands(encoder.finish()); - if let Some(out) = surface_output { - self.present_frame(out); - } - self.finish_frame_resource_stats(); - } - /// Like end_frame, but also renders retained scene graph nodes. /// SH-055 diag — frame-graph bisection lever. `BLOOM_SKIP` is a comma /// list of pass-node names (e.g. "hdr_scene,translucent"); a listed node's diff --git a/native/shared/tests/render_targets.rs b/native/shared/tests/render_targets.rs index 0c2b3116..fd064813 100644 --- a/native/shared/tests/render_targets.rs +++ b/native/shared/tests/render_targets.rs @@ -248,3 +248,46 @@ fn deferred_frame_writes_render_target_override() { px ); } + +#[test] +fn direct_frame_capture_waits_for_output_and_returns_current_pixels() { + let Some(mut r) = try_renderer() else { + assert_ne!(std::env::var("BLOOM_REQUIRE_GPU").as_deref(), Ok("1")); + eprintln!("no GPU adapter - skipping"); + return; + }; + + // Ordinary direct frames do not create a readback. + r.begin_frame(); + r.set_clear_color(255.0, 0.0, 255.0, 255.0); + r.end_frame(); + assert!(r.screenshot_data.is_none()); + + // A smaller render texture must neither consume the output request nor + // become its image. This also exercises the direct path's RT view restore. + let (_, texture_index) = r.create_render_texture(64, 64); + let texture = r.get_texture_ref(texture_index).unwrap().clone(); + r.begin_texture_mode(&texture, 64, 64); + r.screenshot_requested = true; + r.begin_frame(); + r.set_clear_color(255.0, 0.0, 255.0, 255.0); + r.end_frame(); + assert!(r.screenshot_requested); + assert!(r.screenshot_data.is_none()); + assert!(r.rt_color_view.is_some()); + r.end_texture_mode(); + + for channel in [255u8, 0u8] { + r.screenshot_requested = true; + r.begin_frame(); + r.set_clear_color(channel.into(), channel.into(), channel.into(), 255.0); + r.end_frame(); + assert!(!r.screenshot_requested); + let (width, height, rgba) = r.screenshot_data.take().expect("direct output capture"); + assert_eq!((width, height), (256, 256)); + assert_eq!(rgba.len(), 256 * 256 * 4); + assert!(rgba + .chunks_exact(4) + .all(|pixel| pixel == [channel, channel, channel, 255])); + } +} diff --git a/tools/ci/fixtures/native-package.ts b/tools/ci/fixtures/native-package.ts index fd52b72e..9f39986c 100644 --- a/tools/ci/fixtures/native-package.ts +++ b/tools/ci/fixtures/native-package.ts @@ -1,6 +1,7 @@ import { initWindow, runGame, clearBackground, closeWindow, captureFrameToPng, isFrameCaptureReady, + setDirect2DMode, } from "@bloomengine/engine/core"; import { drawRect } from "@bloomengine/engine/shapes"; import { @@ -11,6 +12,8 @@ import { // Native installed-package acceptance: a real Jolt body must fall before the // renderer produces the white square checked by the host. No random inputs. initWindow(128, 128, "Bloom installed native startup"); +const BLOOM_SMOKE_DIRECT_2D = false; +setDirect2DMode(BLOOM_SMOKE_DIRECT_2D); const world = createWorld({ gravity: { x: 0, y: -9.81, z: 0 }, maxBodies: 64, numThreads: 1 }); const shape = sphereShape(0.5); const body = createBody(world, shape, { motionType: 2, position: { x: 0, y: 4, z: 0 } }); diff --git a/tools/ci/native_package_smoke.py b/tools/ci/native_package_smoke.py index 1e047fa8..aa23effd 100644 --- a/tools/ci/native_package_smoke.py +++ b/tools/ci/native_package_smoke.py @@ -54,12 +54,13 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--out", type=Path, default=ROOT / "target/ci/native-package") parser.add_argument("--backend", choices=["dx12", "vulkan"], action="append") + parser.add_argument("--mode", choices=["scene", "direct-2d"], action="append") args = parser.parse_args() if os.name != "nt": parser.error("this installed-package smoke currently supports Windows") out = args.out.resolve() out.mkdir(parents=True, exist_ok=True) - report = {"schema": "bloom-native-package-smoke-v1", "status": "running", "commands": [], "frames": [], + report = {"schema": "bloom-native-package-smoke-v2", "status": "running", "commands": [], "frames": [], "binaries": [], "scope": "Installed source package, native headless renderer and Jolt; window presentation and packaged DXC remain separate."} def save() -> None: @@ -117,34 +118,47 @@ def run(name: str, command: list[str], cwd: Path, env: dict, timeout: int) -> st report["fixture_sha256"] = hashlib.sha256(fixture.read_bytes()).hexdigest() run("install", npm + ["install", "--ignore-scripts", "--no-audit", "--no-fund", str(archive)], project, env, 240) installed = project / "node_modules/@bloomengine/engine" + report["installed_source_sha256"] = { + name: hashlib.sha256((installed / name).read_bytes()).hexdigest() + for name in ("native/shared/src/renderer/mod.rs", "native/shared/src/renderer/direct_frame.rs", + "native/shared/src/renderer/quality_capture.rs") + } jolt = project / "node_modules/@bloomengine/jolt-prebuilt" report["jolt_version"] = json.loads((jolt / "package.json").read_text())["version"] report["jolt_archives"] = [{"name": name, "sha256": hashlib.sha256((jolt / "lib/win32-x64" / name).read_bytes()).hexdigest()} for name in ("Jolt.lib", "bloom_jolt.lib")] - binary = temporary / "native-smoke.exe" - run("compile", [compiler, "compile", "main.ts", "-o", str(binary)], project, env, 1800) - with binary.open("rb") as stream: - if stream.read(2) != b"MZ": - raise RuntimeError("Perry did not produce a native Windows executable") - report["binary_sha256"] = hashlib.sha256(binary.read_bytes()).hexdigest() - report["binary_bytes"] = binary.stat().st_size - report["used_cmake_fallback"] = (installed / "native/third_party/bloom_jolt/build").exists() - if report["used_cmake_fallback"]: - raise RuntimeError("installed prebuilt package was ignored; CMake fallback was used") + fixture_text = fixture.read_text(encoding="utf-8") + mode_marker = "const BLOOM_SMOKE_DIRECT_2D = false;" + if fixture_text.count(mode_marker) != 1: + raise RuntimeError("native fixture must have exactly one render-mode marker") ctypes.windll.kernel32.SetErrorMode(0x0002 | 0x8000) - for backend in args.backend or ["dx12"]: - run_dir = temporary / backend - run_dir.mkdir() - runtime = env.copy() - runtime.update(BLOOM_HEADLESS="1", BLOOM_HEADLESS_PIXEL_EXACT="1", BLOOM_WGPU_BACKEND=backend) - run("startup-" + backend, [str(binary)], run_dir, runtime, 180) - png = run_dir / "native-startup.png" - if not png.is_file(): - raise RuntimeError("native startup exited without its required frame capture") - capture = out / f"startup-{backend}.png" - shutil.copyfile(png, capture) - report["frames"].append({"backend": backend, **check_frame(capture)}) - save() + for mode in args.mode or ["scene", "direct-2d"]: + entry = fixture_text.replace(mode_marker, "const BLOOM_SMOKE_DIRECT_2D = " + ("true;" if mode == "direct-2d" else "false;")) + (project / "main.ts").write_text(entry, encoding="utf-8") + binary = temporary / f"native-smoke-{mode}.exe" + run("compile-" + mode, [compiler, "compile", "main.ts", "-o", str(binary)], project, env, 1800) + with binary.open("rb") as stream: + if stream.read(2) != b"MZ": + raise RuntimeError("Perry did not produce a native Windows executable") + report["binaries"].append({"mode": mode, "sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + "bytes": binary.stat().st_size, "entry_sha256": hashlib.sha256((project / "main.ts").read_bytes()).hexdigest()}) + report["used_cmake_fallback"] = (installed / "native/third_party/bloom_jolt/build").exists() + if report["used_cmake_fallback"]: + raise RuntimeError("installed prebuilt package was ignored; CMake fallback was used") + for backend in args.backend or ["dx12"]: + name = f"startup-{mode}-{backend}" + run_dir = temporary / name + run_dir.mkdir() + runtime = env.copy() + runtime.update(BLOOM_HEADLESS="1", BLOOM_HEADLESS_PIXEL_EXACT="1", BLOOM_WGPU_BACKEND=backend) + run(name, [str(binary)], run_dir, runtime, 180) + png = run_dir / "native-startup.png" + if not png.is_file(): + raise RuntimeError("native startup exited without its required frame capture") + capture = out / f"{name}.png" + shutil.copyfile(png, capture) + report["frames"].append({"mode": mode, "backend": backend, **check_frame(capture)}) + save() report["status"] = "pass" print("PASS: installed native package links, simulates Jolt, and renders its exact frame.") return 0