From 818014127a524c1a51e717c5792e8e7198aaea5a Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 2 Jun 2026 15:18:04 +0200 Subject: [PATCH] Add renderer frame capture contract --- docs/modernization/build-inventory.md | 10 ++-- docs/modernization/roadmap.md | 17 +++--- src/renderer_api/recording_renderer.cpp | 28 ++++++++++ src/renderer_api/recording_renderer.h | 5 ++ src/renderer_api/renderer_api.cpp | 10 ++++ src/renderer_api/renderer_api.h | 4 ++ tests/CMakeLists.txt | 2 +- tests/renderer_api/renderer_api_tests.cpp | 63 +++++++++++++++++++++++ tools/pano_cli/main.cpp | 13 +++++ 9 files changed, 138 insertions(+), 14 deletions(-) diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 9ba99be..eb26031 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -285,12 +285,12 @@ Known local toolchain state: source code reintroduces raw `GL_*`/`WGL_*` constants outside the allowed legacy OpenGL implementation files. - `pp_renderer_api` exposes a headless `RecordingRenderDevice` that validates - command order, readback bounds, and destination buffer sizes, records render - and readback commands, and records trace markers without a window or GL - context. + command order, readback bounds, frame-capture sources, and destination buffer + sizes, records render/readback/frame-capture commands, and records trace + markers without a window or GL context. - `pano_cli record-render` exposes the recording renderer through JSON - automation, including readback command/byte totals, and is covered by - `pano_cli_record_render_smoke` plus + automation, including readback and frame-capture command/byte totals, and is + covered by `pano_cli_record_render_smoke` plus `pano_cli_record_render_rejects_oversized_target`. - `pano_cli simulate-document-history` exposes `pp_document::DocumentHistory` apply/undo/redo state through JSON automation and is covered by diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 7f53ddd..7eb1866 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -416,7 +416,8 @@ adding new backends. Status: started. `pp_renderer_api` exists as a headless renderer-neutral target with texture descriptor, byte-size, viewport, mesh, readback bounds, command context, render device, shader program descriptor, mesh, render target, -readback byte-size helpers, readback command validation, trace interface +readback byte-size helpers, readback command validation, frame-capture +byte-size helpers, frame-capture command validation, trace interface validation, and the canonical PanoPainter shader catalog now consumed by the legacy OpenGL app initialization path. `pp_renderer_gl` now exists as the first OpenGL backend library and owns pure @@ -718,8 +719,8 @@ Results: plus malformed payload rejection at the export boundary. - `pp_renderer_api_tests` passed, including shader descriptor validation, PanoPainter shader catalog validation, readback byte-size and command-order - validation, recording readback command capture, and invalid catalog - rejection. + validation, frame-capture byte-size and command-order validation, recording + readback/frame-capture command capture, and invalid catalog rejection. - `pp_paint_renderer_compositor_tests` passed. - `pp_ui_core_color_tests` passed. - `pp_ui_core_layout_value_tests` passed. @@ -813,13 +814,13 @@ Results: reintroduces raw `GL_*`/`WGL_*` constants outside the allowed legacy OpenGL implementation files. - `pp_renderer_api` now includes a headless `RecordingRenderDevice` with strict - command-order/readback validation and command/trace/readback capture, giving - automation a backend-neutral render path that does not require a window or GL - context. + command-order/readback/frame-capture validation; it records commands, trace + markers, texture readbacks, and frame captures, giving automation a + backend-neutral render path that does not require a window or GL context. - `pano_cli record-render` exercises that headless recording renderer and emits JSON command counts, target dimensions, backend name, trace/draw summary, and - readback command/byte totals for agent automation, with an expected-failure - smoke for oversized render/readback targets. + readback/frame-capture command/byte totals for agent automation, with an + expected-failure smoke for oversized render/readback targets. - `pano_cli simulate-document-history` exercises pure document history apply/undo/redo behavior and emits JSON layer/frame/history state for agent automation. diff --git a/src/renderer_api/recording_renderer.cpp b/src/renderer_api/recording_renderer.cpp index e7d8a32..039a9e8 100644 --- a/src/renderer_api/recording_renderer.cpp +++ b/src/renderer_api/recording_renderer.cpp @@ -202,6 +202,32 @@ pp::foundation::Status RecordingCommandContext::read_texture( return pp::foundation::Status::success(); } +pp::foundation::Status RecordingCommandContext::capture_frame( + IRenderTarget& target, + IReadbackBuffer& destination) noexcept +{ + if (in_render_pass_) { + return pp::foundation::Status::invalid_argument("frame capture must be outside a render pass"); + } + + const auto desc = target.color_desc(); + const auto bytes = frame_capture_byte_size(desc); + if (!bytes) { + return bytes.status(); + } + + if (destination.size_bytes() < bytes.value()) { + return pp::foundation::Status::out_of_range("frame capture buffer is too small"); + } + + push_command(commands_, RecordedRenderCommand { + .kind = RecordedRenderCommandKind::capture_frame, + .target_desc = desc, + .capture_bytes = bytes.value(), + }); + return pp::foundation::Status::success(); +} + void RecordingCommandContext::end_render_pass() noexcept { if (!in_render_pass_) { @@ -281,6 +307,8 @@ const char* recorded_render_command_kind_name(RecordedRenderCommandKind kind) no return "draw"; case RecordedRenderCommandKind::read_texture: return "read_texture"; + case RecordedRenderCommandKind::capture_frame: + return "capture_frame"; case RecordedRenderCommandKind::end_render_pass: return "end_render_pass"; case RecordedRenderCommandKind::trace_marker: diff --git a/src/renderer_api/recording_renderer.h b/src/renderer_api/recording_renderer.h index ae59636..e6eebbf 100644 --- a/src/renderer_api/recording_renderer.h +++ b/src/renderer_api/recording_renderer.h @@ -14,6 +14,7 @@ enum class RecordedRenderCommandKind : std::uint8_t { bind_mesh, draw, read_texture, + capture_frame, end_render_pass, trace_marker, }; @@ -27,6 +28,7 @@ struct RecordedRenderCommand { TextureDesc texture_desc {}; ReadbackRegion readback_region {}; std::uint64_t readback_bytes = 0; + std::uint64_t capture_bytes = 0; const char* component = ""; const char* name = ""; }; @@ -91,6 +93,9 @@ public: ITexture2D& texture, ReadbackRegion region, IReadbackBuffer& destination) noexcept override; + [[nodiscard]] pp::foundation::Status capture_frame( + IRenderTarget& target, + IReadbackBuffer& destination) noexcept override; void end_render_pass() noexcept override; [[nodiscard]] bool in_render_pass() const noexcept; diff --git a/src/renderer_api/renderer_api.cpp b/src/renderer_api/renderer_api.cpp index 9353fe9..4f141bc 100644 --- a/src/renderer_api/renderer_api.cpp +++ b/src/renderer_api/renderer_api.cpp @@ -231,6 +231,16 @@ pp::foundation::Result readback_byte_size(TextureDesc desc, Readb return pp::foundation::Result::success(bytes); } +pp::foundation::Result frame_capture_byte_size(TextureDesc desc) noexcept +{ + if (!desc.render_target) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("frame capture source must be a render target")); + } + + return texture_byte_size(desc); +} + const char* texture_format_name(TextureFormat format) noexcept { switch (format) { diff --git a/src/renderer_api/renderer_api.h b/src/renderer_api/renderer_api.h index 4f0ce22..163e642 100644 --- a/src/renderer_api/renderer_api.h +++ b/src/renderer_api/renderer_api.h @@ -126,6 +126,9 @@ public: ITexture2D& texture, ReadbackRegion region, IReadbackBuffer& destination) noexcept = 0; + [[nodiscard]] virtual pp::foundation::Status capture_frame( + IRenderTarget& target, + IReadbackBuffer& destination) noexcept = 0; virtual void end_render_pass() noexcept = 0; }; @@ -146,6 +149,7 @@ public: [[nodiscard]] pp::foundation::Result readback_byte_size( TextureDesc desc, ReadbackRegion region) noexcept; +[[nodiscard]] pp::foundation::Result frame_capture_byte_size(TextureDesc desc) noexcept; [[nodiscard]] pp::foundation::Status validate_readback_region(TextureDesc desc, ReadbackRegion region) noexcept; [[nodiscard]] const char* texture_format_name(TextureFormat format) noexcept; [[nodiscard]] const char* primitive_topology_name(PrimitiveTopology topology) noexcept; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6d4080d..72394a2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -365,7 +365,7 @@ if(TARGET pano_cli) COMMAND pano_cli record-render --width 32 --height 16) set_tests_properties(pano_cli_record_render_smoke PROPERTIES LABELS "renderer;integration;desktop-fast" - PASS_REGULAR_EXPRESSION "\"backend\":\"recording\".*\"width\":32.*\"height\":16.*\"commands\":8.*\"drawCommands\":1.*\"readbackCommands\":1.*\"readbackBytes\":2048") + PASS_REGULAR_EXPRESSION "\"backend\":\"recording\".*\"width\":32.*\"height\":16.*\"commands\":9.*\"drawCommands\":1.*\"readbackCommands\":1.*\"readbackBytes\":2048.*\"captureCommands\":1.*\"captureBytes\":2048") add_test(NAME pano_cli_record_render_rejects_oversized_target COMMAND "${CMAKE_COMMAND}" diff --git a/tests/renderer_api/renderer_api_tests.cpp b/tests/renderer_api/renderer_api_tests.cpp index ee28ada..05308b3 100644 --- a/tests/renderer_api/renderer_api_tests.cpp +++ b/tests/renderer_api/renderer_api_tests.cpp @@ -9,6 +9,7 @@ using pp::foundation::StatusCode; using pp::renderer::ClearColor; using pp::renderer::Extent2D; +using pp::renderer::frame_capture_byte_size; using pp::renderer::ICommandContext; using pp::renderer::IMesh; using pp::renderer::IRenderDevice; @@ -167,6 +168,21 @@ public: return pp::foundation::Status::success(); } + [[nodiscard]] pp::foundation::Status capture_frame( + IRenderTarget& target, + pp::renderer::IReadbackBuffer& destination) noexcept override + { + const auto bytes = frame_capture_byte_size(target.color_desc()); + if (!bytes) { + return bytes.status(); + } + if (destination.size_bytes() < bytes.value()) { + return pp::foundation::Status::out_of_range("frame capture buffer is too small"); + } + last_capture_bytes = bytes.value(); + return pp::foundation::Status::success(); + } + void end_render_pass() noexcept override { in_render_pass = false; @@ -175,6 +191,7 @@ public: bool in_render_pass = false; const char* shader_name = nullptr; std::uint64_t last_readback_bytes = 0; + std::uint64_t last_capture_bytes = 0; }; class FakeRenderDevice final : public IRenderDevice { @@ -281,6 +298,28 @@ void computes_readback_byte_sizes(pp::tests::Harness& h) PP_EXPECT(h, overrun.status().code == StatusCode::out_of_range); } +void computes_frame_capture_byte_sizes(pp::tests::Harness& h) +{ + const TextureDesc target_desc { + .extent = Extent2D { .width = 16, .height = 8 }, + .format = TextureFormat::rgba8, + .render_target = true, + }; + const TextureDesc texture_desc { + .extent = Extent2D { .width = 16, .height = 8 }, + .format = TextureFormat::rgba8, + .render_target = false, + }; + + const auto capture = frame_capture_byte_size(target_desc); + const auto non_target = frame_capture_byte_size(texture_desc); + + PP_EXPECT(h, capture.ok()); + PP_EXPECT(h, capture.value() == 512U); + PP_EXPECT(h, !non_target.ok()); + PP_EXPECT(h, non_target.status().code == StatusCode::invalid_argument); +} + void validates_viewports_and_mesh_descriptors(pp::tests::Harness& h) { const Extent2D target { .width = 64, .height = 32 }; @@ -448,8 +487,10 @@ void renderer_interfaces_support_backend_neutral_dispatch(pp::tests::Harness& h) ReadbackRegion { .x = 2, .y = 3, .width = 4, .height = 5 }, readback_buffer) .ok()); + PP_EXPECT(h, context.capture_frame(target, readback_buffer).ok()); PP_EXPECT(h, device.context.shader_name == std::string_view("fake-shader")); PP_EXPECT(h, device.context.last_readback_bytes == 80U); + PP_EXPECT(h, device.context.last_capture_bytes == 8192U); } void recording_renderer_records_valid_command_sequences(pp::tests::Harness& h) @@ -512,6 +553,15 @@ void recording_renderer_records_valid_command_sequences(pp::tests::Harness& h) PP_EXPECT(h, commands_after_readback[7].readback_bytes == 96U); PP_EXPECT(h, recorded_render_command_kind_name(commands_after_readback[7].kind) == std::string_view("read_texture")); + PP_EXPECT(h, context.capture_frame(target, readback_buffer).ok()); + const auto commands_after_capture = device.commands(); + PP_EXPECT(h, commands_after_capture.size() == 9U); + PP_EXPECT(h, commands_after_capture[8].kind == RecordedRenderCommandKind::capture_frame); + PP_EXPECT(h, commands_after_capture[8].target_desc.extent.width == 64U); + PP_EXPECT(h, commands_after_capture[8].target_desc.extent.height == 32U); + PP_EXPECT(h, commands_after_capture[8].capture_bytes == 8192U); + PP_EXPECT(h, recorded_render_command_kind_name(commands_after_capture[8].kind) == std::string_view("capture_frame")); + device.clear(); PP_EXPECT(h, device.commands().empty()); } @@ -558,6 +608,10 @@ void recording_renderer_rejects_invalid_command_order_and_targets(pp::tests::Har PP_EXPECT(h, !read_during_render_pass.ok()); PP_EXPECT(h, read_during_render_pass.code == StatusCode::invalid_argument); + const auto capture_during_render_pass = context.capture_frame(target, full_readback_buffer); + PP_EXPECT(h, !capture_during_render_pass.ok()); + PP_EXPECT(h, capture_during_render_pass.code == StatusCode::invalid_argument); + const auto nested_begin = context.begin_render_pass(target, ClearColor {}); PP_EXPECT(h, !nested_begin.ok()); PP_EXPECT(h, nested_begin.code == StatusCode::invalid_argument); @@ -596,6 +650,14 @@ void recording_renderer_rejects_invalid_command_order_and_targets(pp::tests::Har small_readback_buffer); PP_EXPECT(h, !read_into_small_buffer.ok()); PP_EXPECT(h, read_into_small_buffer.code == StatusCode::out_of_range); + + const auto capture_into_small_buffer = context.capture_frame(target, small_readback_buffer); + PP_EXPECT(h, !capture_into_small_buffer.ok()); + PP_EXPECT(h, capture_into_small_buffer.code == StatusCode::out_of_range); + + const auto capture_non_target = context.capture_frame(non_render_target, full_readback_buffer); + PP_EXPECT(h, !capture_non_target.ok()); + PP_EXPECT(h, capture_non_target.code == StatusCode::invalid_argument); } } @@ -607,6 +669,7 @@ int main() harness.run("rejects_invalid_or_excessive_extents", rejects_invalid_or_excessive_extents); harness.run("validates_readback_bounds", validates_readback_bounds); harness.run("computes_readback_byte_sizes", computes_readback_byte_sizes); + harness.run("computes_frame_capture_byte_sizes", computes_frame_capture_byte_sizes); harness.run("validates_viewports_and_mesh_descriptors", validates_viewports_and_mesh_descriptors); harness.run("validates_shader_program_descriptors", validates_shader_program_descriptors); harness.run("validates_panopainter_shader_catalog", validates_panopainter_shader_catalog); diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index ec600e6..1846322 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -2279,10 +2279,18 @@ int record_render(int argc, char** argv) return 2; } + const auto capture_status = context.capture_frame(target, readback_buffer); + if (!capture_status.ok()) { + print_error("record-render", capture_status.message); + return 2; + } + std::size_t draw_commands = 0; std::size_t readback_commands = 0; + std::size_t capture_commands = 0; std::size_t trace_markers = 0; std::uint64_t readback_bytes = 0; + std::uint64_t capture_bytes = 0; const auto commands = device.commands(); for (const auto& command : commands) { if (command.kind == pp::renderer::RecordedRenderCommandKind::draw) { @@ -2290,6 +2298,9 @@ int record_render(int argc, char** argv) } else if (command.kind == pp::renderer::RecordedRenderCommandKind::read_texture) { ++readback_commands; readback_bytes += command.readback_bytes; + } else if (command.kind == pp::renderer::RecordedRenderCommandKind::capture_frame) { + ++capture_commands; + capture_bytes += command.capture_bytes; } else if (command.kind == pp::renderer::RecordedRenderCommandKind::trace_marker) { ++trace_markers; } @@ -2304,6 +2315,8 @@ int record_render(int argc, char** argv) << ",\"drawCommands\":" << draw_commands << ",\"readbackCommands\":" << readback_commands << ",\"readbackBytes\":" << readback_bytes + << ",\"captureCommands\":" << capture_commands + << ",\"captureBytes\":" << capture_bytes << ",\"traceMarkers\":" << trace_markers << ",\"first\":\"" << pp::renderer::recorded_render_command_kind_name(commands.front().kind)