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

View File

@@ -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.

View File

@@ -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:

View File

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

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);
}
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
{
switch (format) {

View File

@@ -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<std::uint64_t> readback_byte_size(
TextureDesc desc,
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]] const char* texture_format_name(TextureFormat format) 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)
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}"

View File

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

View File

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