Add renderer frame capture contract

This commit is contained in:
2026-06-02 15:18:04 +02:00
parent d37145660a
commit 818014127a
9 changed files with 138 additions and 14 deletions

View File

@@ -285,12 +285,12 @@ Known local toolchain state:
source code reintroduces raw `GL_*`/`WGL_*` constants outside the allowed source code reintroduces raw `GL_*`/`WGL_*` constants outside the allowed
legacy OpenGL implementation files. legacy OpenGL implementation files.
- `pp_renderer_api` exposes a headless `RecordingRenderDevice` that validates - `pp_renderer_api` exposes a headless `RecordingRenderDevice` that validates
command order, readback bounds, and destination buffer sizes, records render command order, readback bounds, frame-capture sources, and destination buffer
and readback commands, and records trace markers without a window or GL sizes, records render/readback/frame-capture commands, and records trace
context. markers without a window or GL context.
- `pano_cli record-render` exposes the recording renderer through JSON - `pano_cli record-render` exposes the recording renderer through JSON
automation, including readback command/byte totals, and is covered by automation, including readback and frame-capture command/byte totals, and is
`pano_cli_record_render_smoke` plus covered by `pano_cli_record_render_smoke` plus
`pano_cli_record_render_rejects_oversized_target`. `pano_cli_record_render_rejects_oversized_target`.
- `pano_cli simulate-document-history` exposes `pp_document::DocumentHistory` - `pano_cli simulate-document-history` exposes `pp_document::DocumentHistory`
apply/undo/redo state through JSON automation and is covered by apply/undo/redo state through JSON automation and is covered by

View File

@@ -416,7 +416,8 @@ adding new backends.
Status: started. `pp_renderer_api` exists as a headless renderer-neutral target Status: started. `pp_renderer_api` exists as a headless renderer-neutral target
with texture descriptor, byte-size, viewport, mesh, readback bounds, command with texture descriptor, byte-size, viewport, mesh, readback bounds, command
context, render device, shader program descriptor, mesh, render target, 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 validation, and the canonical PanoPainter shader catalog now consumed by the
legacy OpenGL app initialization path. legacy OpenGL app initialization path.
`pp_renderer_gl` now exists as the first OpenGL backend library and owns pure `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. plus malformed payload rejection at the export boundary.
- `pp_renderer_api_tests` passed, including shader descriptor validation, - `pp_renderer_api_tests` passed, including shader descriptor validation,
PanoPainter shader catalog validation, readback byte-size and command-order PanoPainter shader catalog validation, readback byte-size and command-order
validation, recording readback command capture, and invalid catalog validation, frame-capture byte-size and command-order validation, recording
rejection. readback/frame-capture command capture, and invalid catalog rejection.
- `pp_paint_renderer_compositor_tests` passed. - `pp_paint_renderer_compositor_tests` passed.
- `pp_ui_core_color_tests` passed. - `pp_ui_core_color_tests` passed.
- `pp_ui_core_layout_value_tests` passed. - `pp_ui_core_layout_value_tests` passed.
@@ -813,13 +814,13 @@ Results:
reintroduces raw `GL_*`/`WGL_*` constants outside the allowed legacy OpenGL reintroduces raw `GL_*`/`WGL_*` constants outside the allowed legacy OpenGL
implementation files. implementation files.
- `pp_renderer_api` now includes a headless `RecordingRenderDevice` with strict - `pp_renderer_api` now includes a headless `RecordingRenderDevice` with strict
command-order/readback validation and command/trace/readback capture, giving command-order/readback/frame-capture validation; it records commands, trace
automation a backend-neutral render path that does not require a window or GL markers, texture readbacks, and frame captures, giving automation a
context. backend-neutral render path that does not require a window or GL context.
- `pano_cli record-render` exercises that headless recording renderer and emits - `pano_cli record-render` exercises that headless recording renderer and emits
JSON command counts, target dimensions, backend name, trace/draw summary, and JSON command counts, target dimensions, backend name, trace/draw summary, and
readback command/byte totals for agent automation, with an expected-failure readback/frame-capture command/byte totals for agent automation, with an
smoke for oversized render/readback targets. expected-failure smoke for oversized render/readback targets.
- `pano_cli simulate-document-history` exercises pure document history - `pano_cli simulate-document-history` exercises pure document history
apply/undo/redo behavior and emits JSON layer/frame/history state for agent apply/undo/redo behavior and emits JSON layer/frame/history state for agent
automation. automation.

View File

@@ -202,6 +202,32 @@ pp::foundation::Status RecordingCommandContext::read_texture(
return pp::foundation::Status::success(); 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 void RecordingCommandContext::end_render_pass() noexcept
{ {
if (!in_render_pass_) { if (!in_render_pass_) {
@@ -281,6 +307,8 @@ const char* recorded_render_command_kind_name(RecordedRenderCommandKind kind) no
return "draw"; return "draw";
case RecordedRenderCommandKind::read_texture: case RecordedRenderCommandKind::read_texture:
return "read_texture"; return "read_texture";
case RecordedRenderCommandKind::capture_frame:
return "capture_frame";
case RecordedRenderCommandKind::end_render_pass: case RecordedRenderCommandKind::end_render_pass:
return "end_render_pass"; return "end_render_pass";
case RecordedRenderCommandKind::trace_marker: case RecordedRenderCommandKind::trace_marker:

View File

@@ -14,6 +14,7 @@ enum class RecordedRenderCommandKind : std::uint8_t {
bind_mesh, bind_mesh,
draw, draw,
read_texture, read_texture,
capture_frame,
end_render_pass, end_render_pass,
trace_marker, trace_marker,
}; };
@@ -27,6 +28,7 @@ struct RecordedRenderCommand {
TextureDesc texture_desc {}; TextureDesc texture_desc {};
ReadbackRegion readback_region {}; ReadbackRegion readback_region {};
std::uint64_t readback_bytes = 0; std::uint64_t readback_bytes = 0;
std::uint64_t capture_bytes = 0;
const char* component = ""; const char* component = "";
const char* name = ""; const char* name = "";
}; };
@@ -91,6 +93,9 @@ public:
ITexture2D& texture, ITexture2D& texture,
ReadbackRegion region, ReadbackRegion region,
IReadbackBuffer& destination) noexcept override; IReadbackBuffer& destination) noexcept override;
[[nodiscard]] pp::foundation::Status capture_frame(
IRenderTarget& target,
IReadbackBuffer& destination) noexcept override;
void end_render_pass() noexcept override; void end_render_pass() noexcept override;
[[nodiscard]] bool in_render_pass() const noexcept; [[nodiscard]] bool in_render_pass() const noexcept;

View File

@@ -231,6 +231,16 @@ pp::foundation::Result<std::uint64_t> readback_byte_size(TextureDesc desc, Readb
return pp::foundation::Result<std::uint64_t>::success(bytes); return pp::foundation::Result<std::uint64_t>::success(bytes);
} }
pp::foundation::Result<std::uint64_t> frame_capture_byte_size(TextureDesc desc) noexcept
{
if (!desc.render_target) {
return pp::foundation::Result<std::uint64_t>::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 const char* texture_format_name(TextureFormat format) noexcept
{ {
switch (format) { switch (format) {

View File

@@ -126,6 +126,9 @@ public:
ITexture2D& texture, ITexture2D& texture,
ReadbackRegion region, ReadbackRegion region,
IReadbackBuffer& destination) noexcept = 0; IReadbackBuffer& destination) noexcept = 0;
[[nodiscard]] virtual pp::foundation::Status capture_frame(
IRenderTarget& target,
IReadbackBuffer& destination) noexcept = 0;
virtual void end_render_pass() noexcept = 0; virtual void end_render_pass() noexcept = 0;
}; };
@@ -146,6 +149,7 @@ public:
[[nodiscard]] pp::foundation::Result<std::uint64_t> readback_byte_size( [[nodiscard]] pp::foundation::Result<std::uint64_t> readback_byte_size(
TextureDesc desc, TextureDesc desc,
ReadbackRegion region) noexcept; ReadbackRegion region) noexcept;
[[nodiscard]] pp::foundation::Result<std::uint64_t> frame_capture_byte_size(TextureDesc desc) noexcept;
[[nodiscard]] pp::foundation::Status validate_readback_region(TextureDesc desc, ReadbackRegion region) 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* texture_format_name(TextureFormat format) noexcept;
[[nodiscard]] const char* primitive_topology_name(PrimitiveTopology topology) noexcept; [[nodiscard]] const char* primitive_topology_name(PrimitiveTopology topology) noexcept;

View File

@@ -365,7 +365,7 @@ if(TARGET pano_cli)
COMMAND pano_cli record-render --width 32 --height 16) COMMAND pano_cli record-render --width 32 --height 16)
set_tests_properties(pano_cli_record_render_smoke PROPERTIES set_tests_properties(pano_cli_record_render_smoke PROPERTIES
LABELS "renderer;integration;desktop-fast" 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 add_test(NAME pano_cli_record_render_rejects_oversized_target
COMMAND "${CMAKE_COMMAND}" COMMAND "${CMAKE_COMMAND}"

View File

@@ -9,6 +9,7 @@
using pp::foundation::StatusCode; using pp::foundation::StatusCode;
using pp::renderer::ClearColor; using pp::renderer::ClearColor;
using pp::renderer::Extent2D; using pp::renderer::Extent2D;
using pp::renderer::frame_capture_byte_size;
using pp::renderer::ICommandContext; using pp::renderer::ICommandContext;
using pp::renderer::IMesh; using pp::renderer::IMesh;
using pp::renderer::IRenderDevice; using pp::renderer::IRenderDevice;
@@ -167,6 +168,21 @@ public:
return pp::foundation::Status::success(); 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 void end_render_pass() noexcept override
{ {
in_render_pass = false; in_render_pass = false;
@@ -175,6 +191,7 @@ public:
bool in_render_pass = false; bool in_render_pass = false;
const char* shader_name = nullptr; const char* shader_name = nullptr;
std::uint64_t last_readback_bytes = 0; std::uint64_t last_readback_bytes = 0;
std::uint64_t last_capture_bytes = 0;
}; };
class FakeRenderDevice final : public IRenderDevice { 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); 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) void validates_viewports_and_mesh_descriptors(pp::tests::Harness& h)
{ {
const Extent2D target { .width = 64, .height = 32 }; 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 }, ReadbackRegion { .x = 2, .y = 3, .width = 4, .height = 5 },
readback_buffer) readback_buffer)
.ok()); .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.shader_name == std::string_view("fake-shader"));
PP_EXPECT(h, device.context.last_readback_bytes == 80U); 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) 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, 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, 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(); device.clear();
PP_EXPECT(h, device.commands().empty()); 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.ok());
PP_EXPECT(h, read_during_render_pass.code == StatusCode::invalid_argument); 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 {}); const auto nested_begin = context.begin_render_pass(target, ClearColor {});
PP_EXPECT(h, !nested_begin.ok()); PP_EXPECT(h, !nested_begin.ok());
PP_EXPECT(h, nested_begin.code == StatusCode::invalid_argument); 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); small_readback_buffer);
PP_EXPECT(h, !read_into_small_buffer.ok()); PP_EXPECT(h, !read_into_small_buffer.ok());
PP_EXPECT(h, read_into_small_buffer.code == StatusCode::out_of_range); 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("rejects_invalid_or_excessive_extents", rejects_invalid_or_excessive_extents);
harness.run("validates_readback_bounds", validates_readback_bounds); harness.run("validates_readback_bounds", validates_readback_bounds);
harness.run("computes_readback_byte_sizes", computes_readback_byte_sizes); 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_viewports_and_mesh_descriptors", validates_viewports_and_mesh_descriptors);
harness.run("validates_shader_program_descriptors", validates_shader_program_descriptors); harness.run("validates_shader_program_descriptors", validates_shader_program_descriptors);
harness.run("validates_panopainter_shader_catalog", validates_panopainter_shader_catalog); harness.run("validates_panopainter_shader_catalog", validates_panopainter_shader_catalog);

View File

@@ -2279,10 +2279,18 @@ int record_render(int argc, char** argv)
return 2; 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 draw_commands = 0;
std::size_t readback_commands = 0; std::size_t readback_commands = 0;
std::size_t capture_commands = 0;
std::size_t trace_markers = 0; std::size_t trace_markers = 0;
std::uint64_t readback_bytes = 0; std::uint64_t readback_bytes = 0;
std::uint64_t capture_bytes = 0;
const auto commands = device.commands(); const auto commands = device.commands();
for (const auto& command : commands) { for (const auto& command : commands) {
if (command.kind == pp::renderer::RecordedRenderCommandKind::draw) { 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) { } else if (command.kind == pp::renderer::RecordedRenderCommandKind::read_texture) {
++readback_commands; ++readback_commands;
readback_bytes += command.readback_bytes; 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) { } else if (command.kind == pp::renderer::RecordedRenderCommandKind::trace_marker) {
++trace_markers; ++trace_markers;
} }
@@ -2304,6 +2315,8 @@ int record_render(int argc, char** argv)
<< ",\"drawCommands\":" << draw_commands << ",\"drawCommands\":" << draw_commands
<< ",\"readbackCommands\":" << readback_commands << ",\"readbackCommands\":" << readback_commands
<< ",\"readbackBytes\":" << readback_bytes << ",\"readbackBytes\":" << readback_bytes
<< ",\"captureCommands\":" << capture_commands
<< ",\"captureBytes\":" << capture_bytes
<< ",\"traceMarkers\":" << trace_markers << ",\"traceMarkers\":" << trace_markers
<< ",\"first\":\"" << ",\"first\":\""
<< pp::renderer::recorded_render_command_kind_name(commands.front().kind) << pp::renderer::recorded_render_command_kind_name(commands.front().kind)