From 1d4403693386ae77cb78ea91a48ef19855070f14 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 2 Jun 2026 09:44:04 +0200 Subject: [PATCH] Add headless recording renderer api --- CMakeLists.txt | 1 + docs/modernization/build-inventory.md | 3 + docs/modernization/roadmap.md | 3 + src/renderer_api/recording_renderer.cpp | 263 ++++++++++++++++++++++ src/renderer_api/recording_renderer.h | 126 +++++++++++ tests/renderer_api/renderer_api_tests.cpp | 107 +++++++++ 6 files changed, 503 insertions(+) create mode 100644 src/renderer_api/recording_renderer.cpp create mode 100644 src/renderer_api/recording_renderer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b04c63a..e84c3f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,6 +150,7 @@ target_link_libraries(pp_document pp_project_warnings) add_library(pp_renderer_api STATIC + src/renderer_api/recording_renderer.cpp src/renderer_api/renderer_api.cpp src/renderer_api/shader_catalog.cpp) target_include_directories(pp_renderer_api diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index e71cca0..9363bff 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -241,6 +241,9 @@ Known local toolchain state: renderer-boundary guard that reports JSON and fails if active non-backend 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, records render commands, and records trace markers without a + window or GL context. - `pp_ui_core` consumes vcpkg tinyxml2 only when `PP_USE_VCPKG_TINYXML2=ON` through the vcpkg preset; default and Android validation still use the retained vendored fallback tracked by DEBT-0012. diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 6c79a71..0037632 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -740,6 +740,9 @@ Results: - Renderer-boundary automation fails if active non-backend source code reintroduces raw `GL_*`/`WGL_*` constants outside the allowed legacy OpenGL implementation files. +- `pp_renderer_api` now includes a headless `RecordingRenderDevice` with strict + command-order validation and command/trace capture, giving automation a + backend-neutral render path that does not require a window or GL context. - PowerShell package-smoke wrapper validates the Windows CMake app executable and runtime `data/` copy. - Android arm64 configured with NDK 29.0.14206865 through the platform-build diff --git a/src/renderer_api/recording_renderer.cpp b/src/renderer_api/recording_renderer.cpp new file mode 100644 index 0000000..160a403 --- /dev/null +++ b/src/renderer_api/recording_renderer.cpp @@ -0,0 +1,263 @@ +#include "renderer_api/recording_renderer.h" + +namespace pp::renderer { + +namespace { + +[[nodiscard]] const char* non_null_name(const char* name) noexcept +{ + return name == nullptr ? "" : name; +} + +void push_command( + std::vector* commands, + RecordedRenderCommand command) +{ + if (commands != nullptr) { + commands->push_back(command); + } +} + +} + +RecordingTexture2D::RecordingTexture2D(TextureDesc desc) noexcept + : desc_(desc) +{ +} + +TextureDesc RecordingTexture2D::desc() const noexcept +{ + return desc_; +} + +RecordingRenderTarget::RecordingRenderTarget(TextureDesc color_desc) noexcept + : color_desc_(color_desc) +{ +} + +TextureDesc RecordingRenderTarget::color_desc() const noexcept +{ + return color_desc_; +} + +RecordingShaderProgram::RecordingShaderProgram(const char* debug_name) noexcept + : debug_name_(non_null_name(debug_name)) +{ +} + +const char* RecordingShaderProgram::debug_name() const noexcept +{ + return debug_name_; +} + +RecordingMesh::RecordingMesh(MeshDesc desc) noexcept + : desc_(desc) +{ +} + +MeshDesc RecordingMesh::desc() const noexcept +{ + return desc_; +} + +RecordingReadbackBuffer::RecordingReadbackBuffer(std::uint64_t size_bytes) noexcept + : size_bytes_(size_bytes) +{ +} + +std::uint64_t RecordingReadbackBuffer::size_bytes() const noexcept +{ + return size_bytes_; +} + +RecordingCommandContext::RecordingCommandContext(std::vector& commands) noexcept + : commands_(&commands) +{ +} + +pp::foundation::Status RecordingCommandContext::begin_render_pass( + IRenderTarget& target, + ClearColor clear_color) noexcept +{ + if (in_render_pass_) { + return pp::foundation::Status::invalid_argument("render pass is already active"); + } + + active_target_ = target.color_desc(); + if (!active_target_.render_target) { + return pp::foundation::Status::invalid_argument("render target texture must be flagged as render_target"); + } + + const auto size_status = texture_byte_size(active_target_); + if (!size_status.ok()) { + return size_status.status(); + } + + in_render_pass_ = true; + shader_bound_ = false; + mesh_bound_ = false; + push_command(commands_, RecordedRenderCommand { + .kind = RecordedRenderCommandKind::begin_render_pass, + .target_desc = active_target_, + .clear_color = clear_color, + }); + return pp::foundation::Status::success(); +} + +pp::foundation::Status RecordingCommandContext::set_viewport(Viewport viewport) noexcept +{ + if (!in_render_pass_) { + return pp::foundation::Status::invalid_argument("render pass has not begun"); + } + + const auto status = validate_viewport(viewport, active_target_.extent); + if (!status.ok()) { + return status; + } + + push_command(commands_, RecordedRenderCommand { + .kind = RecordedRenderCommandKind::set_viewport, + .viewport = viewport, + }); + return pp::foundation::Status::success(); +} + +pp::foundation::Status RecordingCommandContext::bind_shader(IShaderProgram& shader) noexcept +{ + if (!in_render_pass_) { + return pp::foundation::Status::invalid_argument("render pass has not begun"); + } + + shader_bound_ = true; + push_command(commands_, RecordedRenderCommand { + .kind = RecordedRenderCommandKind::bind_shader, + .name = shader.debug_name(), + }); + return pp::foundation::Status::success(); +} + +pp::foundation::Status RecordingCommandContext::bind_mesh(IMesh& mesh) noexcept +{ + if (!in_render_pass_) { + return pp::foundation::Status::invalid_argument("render pass has not begun"); + } + + const auto desc = mesh.desc(); + const auto status = validate_mesh_desc(desc); + if (!status.ok()) { + return status; + } + + mesh_bound_ = true; + push_command(commands_, RecordedRenderCommand { + .kind = RecordedRenderCommandKind::bind_mesh, + .mesh_desc = desc, + }); + return pp::foundation::Status::success(); +} + +pp::foundation::Status RecordingCommandContext::draw() noexcept +{ + if (!in_render_pass_) { + return pp::foundation::Status::invalid_argument("render pass has not begun"); + } + if (!shader_bound_) { + return pp::foundation::Status::invalid_argument("shader must be bound before draw"); + } + if (!mesh_bound_) { + return pp::foundation::Status::invalid_argument("mesh must be bound before draw"); + } + + push_command(commands_, RecordedRenderCommand { + .kind = RecordedRenderCommandKind::draw, + }); + return pp::foundation::Status::success(); +} + +void RecordingCommandContext::end_render_pass() noexcept +{ + if (!in_render_pass_) { + return; + } + + push_command(commands_, RecordedRenderCommand { + .kind = RecordedRenderCommandKind::end_render_pass, + }); + in_render_pass_ = false; + shader_bound_ = false; + mesh_bound_ = false; +} + +bool RecordingCommandContext::in_render_pass() const noexcept +{ + return in_render_pass_; +} + +RecordingRenderTrace::RecordingRenderTrace(std::vector& commands) noexcept + : commands_(&commands) +{ +} + +void RecordingRenderTrace::marker(const char* component, const char* name) noexcept +{ + push_command(commands_, RecordedRenderCommand { + .kind = RecordedRenderCommandKind::trace_marker, + .component = non_null_name(component), + .name = non_null_name(name), + }); +} + +RecordingRenderDevice::RecordingRenderDevice() noexcept + : context_(commands_) + , trace_(commands_) +{ +} + +const char* RecordingRenderDevice::backend_name() const noexcept +{ + return "recording"; +} + +ICommandContext& RecordingRenderDevice::immediate_context() noexcept +{ + return context_; +} + +IRenderTrace* RecordingRenderDevice::trace() noexcept +{ + return &trace_; +} + +std::span RecordingRenderDevice::commands() const noexcept +{ + return commands_; +} + +void RecordingRenderDevice::clear() noexcept +{ + commands_.clear(); +} + +const char* recorded_render_command_kind_name(RecordedRenderCommandKind kind) noexcept +{ + switch (kind) { + case RecordedRenderCommandKind::begin_render_pass: + return "begin_render_pass"; + case RecordedRenderCommandKind::set_viewport: + return "set_viewport"; + case RecordedRenderCommandKind::bind_shader: + return "bind_shader"; + case RecordedRenderCommandKind::bind_mesh: + return "bind_mesh"; + case RecordedRenderCommandKind::draw: + return "draw"; + case RecordedRenderCommandKind::end_render_pass: + return "end_render_pass"; + case RecordedRenderCommandKind::trace_marker: + return "trace_marker"; + } + + return "unknown"; +} + +} diff --git a/src/renderer_api/recording_renderer.h b/src/renderer_api/recording_renderer.h new file mode 100644 index 0000000..d5cbcf6 --- /dev/null +++ b/src/renderer_api/recording_renderer.h @@ -0,0 +1,126 @@ +#pragma once + +#include "renderer_api/renderer_api.h" + +#include +#include + +namespace pp::renderer { + +enum class RecordedRenderCommandKind : std::uint8_t { + begin_render_pass, + set_viewport, + bind_shader, + bind_mesh, + draw, + end_render_pass, + trace_marker, +}; + +struct RecordedRenderCommand { + RecordedRenderCommandKind kind = RecordedRenderCommandKind::draw; + TextureDesc target_desc {}; + ClearColor clear_color {}; + Viewport viewport {}; + MeshDesc mesh_desc {}; + const char* component = ""; + const char* name = ""; +}; + +class RecordingTexture2D final : public ITexture2D { +public: + explicit RecordingTexture2D(TextureDesc desc) noexcept; + [[nodiscard]] TextureDesc desc() const noexcept override; + +private: + TextureDesc desc_ {}; +}; + +class RecordingRenderTarget final : public IRenderTarget { +public: + explicit RecordingRenderTarget(TextureDesc color_desc) noexcept; + [[nodiscard]] TextureDesc color_desc() const noexcept override; + +private: + TextureDesc color_desc_ {}; +}; + +class RecordingShaderProgram final : public IShaderProgram { +public: + explicit RecordingShaderProgram(const char* debug_name) noexcept; + [[nodiscard]] const char* debug_name() const noexcept override; + +private: + const char* debug_name_ = ""; +}; + +class RecordingMesh final : public IMesh { +public: + explicit RecordingMesh(MeshDesc desc) noexcept; + [[nodiscard]] MeshDesc desc() const noexcept override; + +private: + MeshDesc desc_ {}; +}; + +class RecordingReadbackBuffer final : public IReadbackBuffer { +public: + explicit RecordingReadbackBuffer(std::uint64_t size_bytes) noexcept; + [[nodiscard]] std::uint64_t size_bytes() const noexcept override; + +private: + std::uint64_t size_bytes_ = 0; +}; + +class RecordingCommandContext final : public ICommandContext { +public: + explicit RecordingCommandContext(std::vector& commands) noexcept; + + [[nodiscard]] pp::foundation::Status begin_render_pass( + IRenderTarget& target, + ClearColor clear_color) noexcept override; + [[nodiscard]] pp::foundation::Status set_viewport(Viewport viewport) noexcept override; + [[nodiscard]] pp::foundation::Status bind_shader(IShaderProgram& shader) noexcept override; + [[nodiscard]] pp::foundation::Status bind_mesh(IMesh& mesh) noexcept override; + [[nodiscard]] pp::foundation::Status draw() noexcept override; + void end_render_pass() noexcept override; + + [[nodiscard]] bool in_render_pass() const noexcept; + +private: + std::vector* commands_ = nullptr; + TextureDesc active_target_ {}; + bool in_render_pass_ = false; + bool shader_bound_ = false; + bool mesh_bound_ = false; +}; + +class RecordingRenderTrace final : public IRenderTrace { +public: + explicit RecordingRenderTrace(std::vector& commands) noexcept; + void marker(const char* component, const char* name) noexcept override; + +private: + std::vector* commands_ = nullptr; +}; + +class RecordingRenderDevice final : public IRenderDevice { +public: + RecordingRenderDevice() noexcept; + + [[nodiscard]] const char* backend_name() const noexcept override; + [[nodiscard]] ICommandContext& immediate_context() noexcept override; + [[nodiscard]] IRenderTrace* trace() noexcept override; + + [[nodiscard]] std::span commands() const noexcept; + void clear() noexcept; + +private: + std::vector commands_; + RecordingCommandContext context_; + RecordingRenderTrace trace_; +}; + +[[nodiscard]] const char* recorded_render_command_kind_name(RecordedRenderCommandKind kind) noexcept; + +} diff --git a/tests/renderer_api/renderer_api_tests.cpp b/tests/renderer_api/renderer_api_tests.cpp index fdd4015..fa11586 100644 --- a/tests/renderer_api/renderer_api_tests.cpp +++ b/tests/renderer_api/renderer_api_tests.cpp @@ -1,4 +1,5 @@ #include "renderer_api/renderer_api.h" +#include "renderer_api/recording_renderer.h" #include "renderer_api/shader_catalog.h" #include "test_harness.h" @@ -17,6 +18,11 @@ using pp::renderer::IShaderProgram; using pp::renderer::MeshDesc; using pp::renderer::PrimitiveTopology; using pp::renderer::ReadbackRegion; +using pp::renderer::RecordedRenderCommandKind; +using pp::renderer::RecordingMesh; +using pp::renderer::RecordingRenderDevice; +using pp::renderer::RecordingRenderTarget; +using pp::renderer::RecordingShaderProgram; using pp::renderer::ShaderProgramDesc; using pp::renderer::ShaderStageSource; using pp::renderer::TextureDesc; @@ -26,6 +32,7 @@ using pp::renderer::max_shader_source_bytes; using pp::renderer::max_texture_dimension; using pp::renderer::panopainter_shader_catalog; using pp::renderer::primitive_topology_name; +using pp::renderer::recorded_render_command_kind_name; using pp::renderer::ShaderCatalogEntry; using pp::renderer::texture_byte_size; using pp::renderer::texture_format_name; @@ -364,6 +371,104 @@ void renderer_interfaces_support_backend_neutral_dispatch(pp::tests::Harness& h) PP_EXPECT(h, device.context.shader_name == std::string_view("fake-shader")); } +void recording_renderer_records_valid_command_sequences(pp::tests::Harness& h) +{ + RecordingRenderDevice device; + RecordingRenderTarget target(TextureDesc { + .extent = Extent2D { .width = 64, .height = 32 }, + .format = TextureFormat::rgba8, + .render_target = true, + }); + RecordingShaderProgram shader("recorded-shader"); + RecordingMesh mesh(MeshDesc { .vertex_count = 3, .index_count = 0, .topology = PrimitiveTopology::triangles }); + + PP_EXPECT(h, device.backend_name() == std::string_view("recording")); + device.trace()->marker("renderer", "frame"); + + auto& context = device.immediate_context(); + PP_EXPECT(h, context.begin_render_pass(target, ClearColor { .r = 0.2F, .g = 0.3F, .b = 0.4F, .a = 1.0F }).ok()); + PP_EXPECT(h, context.set_viewport(Viewport { .x = 0, .y = 0, .width = 64, .height = 32 }).ok()); + PP_EXPECT(h, context.bind_shader(shader).ok()); + PP_EXPECT(h, context.bind_mesh(mesh).ok()); + PP_EXPECT(h, context.draw().ok()); + context.end_render_pass(); + + const auto commands = device.commands(); + PP_EXPECT(h, commands.size() == 7U); + PP_EXPECT(h, commands[0].kind == RecordedRenderCommandKind::trace_marker); + PP_EXPECT(h, commands[0].component == std::string_view("renderer")); + PP_EXPECT(h, commands[0].name == std::string_view("frame")); + PP_EXPECT(h, commands[1].kind == RecordedRenderCommandKind::begin_render_pass); + PP_EXPECT(h, commands[1].target_desc.extent.width == 64U); + PP_EXPECT(h, commands[1].clear_color.a == 1.0F); + PP_EXPECT(h, commands[2].kind == RecordedRenderCommandKind::set_viewport); + PP_EXPECT(h, commands[2].viewport.height == 32U); + PP_EXPECT(h, commands[3].kind == RecordedRenderCommandKind::bind_shader); + PP_EXPECT(h, commands[3].name == std::string_view("recorded-shader")); + PP_EXPECT(h, commands[4].kind == RecordedRenderCommandKind::bind_mesh); + PP_EXPECT(h, commands[4].mesh_desc.vertex_count == 3U); + PP_EXPECT(h, commands[5].kind == RecordedRenderCommandKind::draw); + PP_EXPECT(h, commands[6].kind == RecordedRenderCommandKind::end_render_pass); + PP_EXPECT(h, recorded_render_command_kind_name(commands[5].kind) == std::string_view("draw")); + + device.clear(); + PP_EXPECT(h, device.commands().empty()); +} + +void recording_renderer_rejects_invalid_command_order_and_targets(pp::tests::Harness& h) +{ + RecordingRenderDevice device; + RecordingRenderTarget target(TextureDesc { + .extent = Extent2D { .width = 32, .height = 16 }, + .format = TextureFormat::rgba8, + .render_target = true, + }); + RecordingRenderTarget non_render_target(TextureDesc { + .extent = Extent2D { .width = 32, .height = 16 }, + .format = TextureFormat::rgba8, + .render_target = false, + }); + RecordingShaderProgram shader("strict-shader"); + RecordingMesh mesh(MeshDesc { .vertex_count = 3, .topology = PrimitiveTopology::triangles }); + RecordingMesh empty_mesh(MeshDesc {}); + + auto& context = device.immediate_context(); + const auto draw_before_begin = context.draw(); + PP_EXPECT(h, !draw_before_begin.ok()); + PP_EXPECT(h, draw_before_begin.code == StatusCode::invalid_argument); + + const auto invalid_target = context.begin_render_pass(non_render_target, ClearColor {}); + PP_EXPECT(h, !invalid_target.ok()); + PP_EXPECT(h, invalid_target.code == StatusCode::invalid_argument); + PP_EXPECT(h, device.commands().empty()); + + PP_EXPECT(h, context.begin_render_pass(target, ClearColor {}).ok()); + 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); + + const auto draw_without_bindings = context.draw(); + PP_EXPECT(h, !draw_without_bindings.ok()); + PP_EXPECT(h, draw_without_bindings.code == StatusCode::invalid_argument); + + PP_EXPECT(h, context.bind_shader(shader).ok()); + const auto draw_without_mesh = context.draw(); + PP_EXPECT(h, !draw_without_mesh.ok()); + PP_EXPECT(h, draw_without_mesh.code == StatusCode::invalid_argument); + + const auto invalid_mesh = context.bind_mesh(empty_mesh); + PP_EXPECT(h, !invalid_mesh.ok()); + PP_EXPECT(h, invalid_mesh.code == StatusCode::invalid_argument); + + PP_EXPECT(h, context.bind_mesh(mesh).ok()); + PP_EXPECT(h, context.draw().ok()); + context.end_render_pass(); + + const auto viewport_after_end = context.set_viewport(Viewport { .x = 0, .y = 0, .width = 1, .height = 1 }); + PP_EXPECT(h, !viewport_after_end.ok()); + PP_EXPECT(h, viewport_after_end.code == StatusCode::invalid_argument); +} + } int main() @@ -377,5 +482,7 @@ int main() harness.run("validates_panopainter_shader_catalog", validates_panopainter_shader_catalog); harness.run("rejects_invalid_shader_catalogs", rejects_invalid_shader_catalogs); harness.run("renderer_interfaces_support_backend_neutral_dispatch", renderer_interfaces_support_backend_neutral_dispatch); + harness.run("recording_renderer_records_valid_command_sequences", recording_renderer_records_valid_command_sequences); + harness.run("recording_renderer_rejects_invalid_command_order_and_targets", recording_renderer_rejects_invalid_command_order_and_targets); return harness.finish(); }