Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/evidence/windows-direct-frame-capture-v1.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions docs/evidence/windows-installed-native-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 7 additions & 3 deletions docs/windows-engine-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
266 changes: 266 additions & 0 deletions native/shared/src/renderer/direct_frame.rs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading