From 483bbb4a9c484a675e7db58e79cbfbe25c6faae5 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 2 Jun 2026 16:27:28 +0200 Subject: [PATCH] Add renderer draw descriptor contract --- docs/modernization/build-inventory.md | 12 +-- docs/modernization/roadmap.md | 20 ++--- src/renderer_api/recording_renderer.cpp | 8 +- src/renderer_api/recording_renderer.h | 3 +- src/renderer_api/renderer_api.cpp | 34 +++++++++ src/renderer_api/renderer_api.h | 11 ++- tests/renderer_api/renderer_api_tests.cpp | 91 ++++++++++++++++++++--- tools/pano_cli/main.cpp | 9 ++- 8 files changed, 157 insertions(+), 31 deletions(-) diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 1213fa0..54cf18f 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -288,16 +288,18 @@ Known local toolchain state: backend-owned resource creation, command order, render-pass color/depth/ stencil clear intent, scissor state, depth state, blend state, texture-slot binding, sampler-state binding, texture-upload byte counts, - shader-uniform writes, readback bounds, frame-capture sources, destination - buffer sizes, and render-target blit regions, records + shader-uniform writes, explicit draw descriptor ranges, readback bounds, + frame-capture sources, destination buffer sizes, and render-target blit + regions, records render-pass-clear/scissor/depth/blend/shader-uniform/texture-bind/ - sampler-bind/upload/readback/frame-capture/blit commands, draw mesh inputs, - and records trace markers without a window or GL context. + sampler-bind/draw/upload/readback/frame-capture/blit commands, draw mesh + inputs, explicit draw ranges, and records trace markers without a window or + GL context. - `pano_cli record-render` exposes the recording renderer through JSON automation, including render-pass/depth-clear counts, scissor/depth/blend/ shader-uniform/texture-bind/sampler-bind/upload/readback/frame-capture/blit command and byte totals, backend resource creation counts, plus draw - vertex/index totals, and is covered by + descriptor vertex/index 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` diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 5ecdf1d..dd86c64 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -725,10 +725,11 @@ Results: command-order validation, render-target blit validation, texture-slot binding validation, blend-state validation, scissor-state validation, render-pass color/depth/stencil clear validation, shader-uniform write - validation, backend-neutral resource factory validation, recording + validation, draw descriptor/range validation, backend-neutral resource + factory validation, recording render-pass clear/scissor/depth/blend/shader-uniform/texture/sampler-bind/ upload/readback/frame-capture/blit command capture, draw mesh-input capture, - and invalid catalog rejection. + explicit draw-range capture, and invalid catalog rejection. - `pp_paint_renderer_compositor_tests` passed. - `pp_ui_core_color_tests` passed. - `pp_ui_core_layout_value_tests` passed. @@ -825,18 +826,19 @@ Results: renderer-owned resource factory and command-order/render-pass-clear/scissor-state/depth-state/blend-state/ texture-bind/sampler-bind/shader-uniform/texture-upload/readback/ - frame-capture/blit validation; it creates validated textures, render targets, - shaders, meshes, and readback buffers, then records commands, trace markers, - render-pass color/depth/stencil clear intent, scissor state, depth state, - blend state, shader uniform writes, texture/sampler binds, draw mesh inputs, + frame-capture/blit validation plus explicit draw descriptor validation; it + creates validated textures, render targets, shaders, meshes, and readback + buffers, then records commands, trace markers, render-pass color/depth/ + stencil clear intent, scissor state, depth state, blend state, shader uniform + writes, texture/sampler binds, draw mesh inputs, explicit draw ranges, uploads/readbacks, frame captures, and render-target blits, 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, resource creation counts, target dimensions, backend name, trace/draw summary, render-pass/depth-clear counts, and draw - vertex/index totals, scissor/depth/blend-state plus shader-uniform/texture/ - sampler-bind/upload/readback/frame-capture/blit command/byte totals for - agent automation, with an expected-failure smoke for oversized + descriptor vertex/index totals, scissor/depth/blend-state plus + shader-uniform/texture/sampler-bind/upload/readback/frame-capture/blit + 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 diff --git a/src/renderer_api/recording_renderer.cpp b/src/renderer_api/recording_renderer.cpp index fb45256..e10a7ee 100644 --- a/src/renderer_api/recording_renderer.cpp +++ b/src/renderer_api/recording_renderer.cpp @@ -316,7 +316,7 @@ pp::foundation::Status RecordingCommandContext::bind_mesh(IMesh& mesh) noexcept return pp::foundation::Status::success(); } -pp::foundation::Status RecordingCommandContext::draw() noexcept +pp::foundation::Status RecordingCommandContext::draw(DrawDesc desc) noexcept { if (!in_render_pass_) { return pp::foundation::Status::invalid_argument("render pass has not begun"); @@ -328,9 +328,15 @@ pp::foundation::Status RecordingCommandContext::draw() noexcept return pp::foundation::Status::invalid_argument("mesh must be bound before draw"); } + const auto draw_status = validate_draw_desc(active_mesh_, desc); + if (!draw_status.ok()) { + return draw_status; + } + push_command(commands_, RecordedRenderCommand { .kind = RecordedRenderCommandKind::draw, .mesh_desc = active_mesh_, + .draw_desc = desc, }); return pp::foundation::Status::success(); } diff --git a/src/renderer_api/recording_renderer.h b/src/renderer_api/recording_renderer.h index 8845acc..dc18f55 100644 --- a/src/renderer_api/recording_renderer.h +++ b/src/renderer_api/recording_renderer.h @@ -41,6 +41,7 @@ struct RecordedRenderCommand { BlendState blend_state {}; DepthState depth_state {}; MeshDesc mesh_desc {}; + DrawDesc draw_desc {}; TextureDesc texture_desc {}; std::uint32_t texture_slot = 0; SamplerDesc sampler_desc {}; @@ -128,7 +129,7 @@ public: std::uint32_t slot, SamplerDesc sampler) noexcept override; [[nodiscard]] pp::foundation::Status bind_mesh(IMesh& mesh) noexcept override; - [[nodiscard]] pp::foundation::Status draw() noexcept override; + [[nodiscard]] pp::foundation::Status draw(DrawDesc desc) noexcept override; [[nodiscard]] pp::foundation::Status read_texture( ITexture2D& texture, ReadbackRegion region, diff --git a/src/renderer_api/renderer_api.cpp b/src/renderer_api/renderer_api.cpp index 4225482..0a18b84 100644 --- a/src/renderer_api/renderer_api.cpp +++ b/src/renderer_api/renderer_api.cpp @@ -337,6 +337,40 @@ pp::foundation::Status validate_mesh_desc(MeshDesc desc) noexcept return pp::foundation::Status::invalid_argument("mesh topology is not supported"); } +pp::foundation::Status validate_draw_desc(MeshDesc mesh, DrawDesc draw) noexcept +{ + const auto mesh_status = validate_mesh_desc(mesh); + if (!mesh_status.ok()) { + return mesh_status; + } + + if (draw.instance_count == 0) { + return pp::foundation::Status::invalid_argument("draw instance count must be greater than zero"); + } + + if (draw.vertex_count == 0 && draw.index_count == 0) { + return pp::foundation::Status::invalid_argument("draw must include vertices or indices"); + } + + if (draw.index_count > 0) { + if (mesh.index_count == 0) { + return pp::foundation::Status::invalid_argument("indexed draw requires an indexed mesh"); + } + + if (draw.first_index > mesh.index_count || draw.index_count > mesh.index_count - draw.first_index) { + return pp::foundation::Status::out_of_range("draw index range exceeds the bound mesh"); + } + + return pp::foundation::Status::success(); + } + + if (draw.first_vertex > mesh.vertex_count || draw.vertex_count > mesh.vertex_count - draw.first_vertex) { + return pp::foundation::Status::out_of_range("draw vertex range exceeds the bound mesh"); + } + + return pp::foundation::Status::success(); +} + pp::foundation::Status validate_texture_slot(std::uint32_t slot) noexcept { if (slot >= max_texture_slots) { diff --git a/src/renderer_api/renderer_api.h b/src/renderer_api/renderer_api.h index ca03011..8bb146e 100644 --- a/src/renderer_api/renderer_api.h +++ b/src/renderer_api/renderer_api.h @@ -157,6 +157,14 @@ struct MeshDesc { PrimitiveTopology topology = PrimitiveTopology::triangles; }; +struct DrawDesc { + std::uint32_t first_vertex = 0; + std::uint32_t vertex_count = 0; + std::uint32_t first_index = 0; + std::uint32_t index_count = 0; + std::uint32_t instance_count = 1; +}; + struct ShaderStageSource { const char* entry_point = "main"; const char* source = nullptr; @@ -226,7 +234,7 @@ public: std::uint32_t slot, SamplerDesc sampler) noexcept = 0; [[nodiscard]] virtual pp::foundation::Status bind_mesh(IMesh& mesh) noexcept = 0; - [[nodiscard]] virtual pp::foundation::Status draw() noexcept = 0; + [[nodiscard]] virtual pp::foundation::Status draw(DrawDesc desc) noexcept = 0; [[nodiscard]] virtual pp::foundation::Status read_texture( ITexture2D& texture, ReadbackRegion region, @@ -279,6 +287,7 @@ public: [[nodiscard]] pp::foundation::Status validate_sampler_address_mode(SamplerAddressMode mode) noexcept; [[nodiscard]] pp::foundation::Status validate_sampler_desc(SamplerDesc desc) noexcept; [[nodiscard]] pp::foundation::Status validate_mesh_desc(MeshDesc desc) noexcept; +[[nodiscard]] pp::foundation::Status validate_draw_desc(MeshDesc mesh, DrawDesc draw) noexcept; [[nodiscard]] pp::foundation::Status validate_texture_slot(std::uint32_t slot) noexcept; [[nodiscard]] pp::foundation::Status validate_shader_program_desc(ShaderProgramDesc desc) noexcept; [[nodiscard]] pp::foundation::Status validate_shader_uniform_write( diff --git a/tests/renderer_api/renderer_api_tests.cpp b/tests/renderer_api/renderer_api_tests.cpp index bf50829..ed51b69 100644 --- a/tests/renderer_api/renderer_api_tests.cpp +++ b/tests/renderer_api/renderer_api_tests.cpp @@ -23,6 +23,7 @@ using pp::renderer::ClearColor; using pp::renderer::CompareOp; using pp::renderer::compare_op_name; using pp::renderer::DepthState; +using pp::renderer::DrawDesc; using pp::renderer::Extent2D; using pp::renderer::frame_capture_byte_size; using pp::renderer::ICommandContext; @@ -72,6 +73,7 @@ using pp::renderer::validate_blend_op; using pp::renderer::validate_blend_state; using pp::renderer::validate_compare_op; using pp::renderer::validate_depth_state; +using pp::renderer::validate_draw_desc; using pp::renderer::validate_mesh_desc; using pp::renderer::validate_readback_region; using pp::renderer::validate_render_pass_desc; @@ -319,13 +321,31 @@ public: [[nodiscard]] pp::foundation::Status bind_mesh(IMesh& mesh) noexcept override { - return validate_mesh_desc(mesh.desc()); + const auto status = validate_mesh_desc(mesh.desc()); + if (!status.ok()) { + return status; + } + last_mesh_desc = mesh.desc(); + mesh_bound = true; + return pp::foundation::Status::success(); } - [[nodiscard]] pp::foundation::Status draw() noexcept override + [[nodiscard]] pp::foundation::Status draw(DrawDesc desc) noexcept override { - return in_render_pass ? pp::foundation::Status::success() - : pp::foundation::Status::invalid_argument("render pass has not begun"); + if (!in_render_pass) { + return pp::foundation::Status::invalid_argument("render pass has not begun"); + } + if (!mesh_bound) { + return pp::foundation::Status::invalid_argument("mesh must be bound before draw"); + } + + const auto status = validate_draw_desc(last_mesh_desc, desc); + if (!status.ok()) { + return status; + } + + last_draw_desc = desc; + return pp::foundation::Status::success(); } [[nodiscard]] pp::foundation::Status read_texture( @@ -410,10 +430,15 @@ public: void end_render_pass() noexcept override { in_render_pass = false; + mesh_bound = false; + last_mesh_desc = MeshDesc {}; } bool in_render_pass = false; + bool mesh_bound = false; RenderPassDesc last_render_pass_desc {}; + MeshDesc last_mesh_desc {}; + DrawDesc last_draw_desc {}; const char* shader_name = nullptr; const char* last_uniform_name = nullptr; std::size_t last_uniform_bytes = 0; @@ -810,6 +835,41 @@ void validates_viewports_and_mesh_descriptors(pp::tests::Harness& h) PP_EXPECT(h, invalid_slot.code == StatusCode::out_of_range); } +void validates_draw_descriptors(pp::tests::Harness& h) +{ + const MeshDesc vertex_mesh { + .vertex_count = 8, + .index_count = 0, + .topology = PrimitiveTopology::triangles, + }; + const MeshDesc indexed_mesh { + .vertex_count = 8, + .index_count = 12, + .topology = PrimitiveTopology::triangles, + }; + + PP_EXPECT(h, validate_draw_desc(vertex_mesh, DrawDesc { .first_vertex = 2, .vertex_count = 3 }).ok()); + PP_EXPECT(h, validate_draw_desc(indexed_mesh, DrawDesc { .first_index = 3, .index_count = 6 }).ok()); + PP_EXPECT(h, validate_draw_desc(indexed_mesh, DrawDesc { .index_count = 12, .instance_count = 4 }).ok()); + + const auto empty_draw = validate_draw_desc(vertex_mesh, DrawDesc {}); + const auto zero_instances = validate_draw_desc(vertex_mesh, DrawDesc { .vertex_count = 3, .instance_count = 0 }); + const auto vertex_overrun = validate_draw_desc(vertex_mesh, DrawDesc { .first_vertex = 7, .vertex_count = 2 }); + const auto indexed_without_indices = validate_draw_desc(vertex_mesh, DrawDesc { .index_count = 3 }); + const auto index_overrun = validate_draw_desc(indexed_mesh, DrawDesc { .first_index = 11, .index_count = 2 }); + + PP_EXPECT(h, !empty_draw.ok()); + PP_EXPECT(h, empty_draw.code == StatusCode::invalid_argument); + PP_EXPECT(h, !zero_instances.ok()); + PP_EXPECT(h, zero_instances.code == StatusCode::invalid_argument); + PP_EXPECT(h, !vertex_overrun.ok()); + PP_EXPECT(h, vertex_overrun.code == StatusCode::out_of_range); + PP_EXPECT(h, !indexed_without_indices.ok()); + PP_EXPECT(h, indexed_without_indices.code == StatusCode::invalid_argument); + PP_EXPECT(h, !index_overrun.ok()); + PP_EXPECT(h, index_overrun.code == StatusCode::out_of_range); +} + void validates_render_pass_descriptors(pp::tests::Harness& h) { const auto valid = validate_render_pass_desc(RenderPassDesc { @@ -1034,10 +1094,10 @@ void renderer_interfaces_support_backend_neutral_dispatch(pp::tests::Harness& h) }) .ok()); PP_EXPECT(h, context.bind_mesh(mesh).ok()); - PP_EXPECT(h, context.draw().ok()); + PP_EXPECT(h, context.draw(DrawDesc { .vertex_count = 3 }).ok()); context.end_render_pass(); - const auto draw_after_end = context.draw(); + const auto draw_after_end = context.draw(DrawDesc { .vertex_count = 3 }); PP_EXPECT(h, !draw_after_end.ok()); PP_EXPECT(h, draw_after_end.code == StatusCode::invalid_argument); PP_EXPECT(h, context.upload_texture( @@ -1073,6 +1133,8 @@ void renderer_interfaces_support_backend_neutral_dispatch(pp::tests::Harness& h) PP_EXPECT(h, device.context.last_depth_state.compare == CompareOp::less_or_equal); PP_EXPECT(h, device.context.last_uniform_name == std::string_view("mvp")); PP_EXPECT(h, device.context.last_uniform_bytes == 64U); + PP_EXPECT(h, device.context.last_draw_desc.vertex_count == 3U); + PP_EXPECT(h, device.context.last_draw_desc.instance_count == 1U); PP_EXPECT(h, device.context.last_texture_slot == 2U); PP_EXPECT(h, device.context.last_texture_bytes == 8192U); PP_EXPECT(h, device.context.last_sampler_slot == 2U); @@ -1259,7 +1321,7 @@ void recording_renderer_records_valid_command_sequences(pp::tests::Harness& h) }) .ok()); PP_EXPECT(h, context.bind_mesh(mesh).ok()); - PP_EXPECT(h, context.draw().ok()); + PP_EXPECT(h, context.draw(DrawDesc { .vertex_count = 3, .index_count = 3 }).ok()); context.end_render_pass(); const auto commands = device.commands(); @@ -1309,6 +1371,9 @@ void recording_renderer_records_valid_command_sequences(pp::tests::Harness& h) PP_EXPECT(h, commands[10].mesh_desc.vertex_count == 3U); PP_EXPECT(h, commands[10].mesh_desc.index_count == 3U); PP_EXPECT(h, commands[10].mesh_desc.topology == PrimitiveTopology::triangles); + PP_EXPECT(h, commands[10].draw_desc.vertex_count == 3U); + PP_EXPECT(h, commands[10].draw_desc.index_count == 3U); + PP_EXPECT(h, commands[10].draw_desc.instance_count == 1U); PP_EXPECT(h, commands[11].kind == RecordedRenderCommandKind::end_render_pass); PP_EXPECT(h, recorded_render_command_kind_name(commands[10].kind) == std::string_view("draw")); @@ -1403,7 +1468,7 @@ void recording_renderer_rejects_invalid_command_order_and_targets(pp::tests::Har RecordingMesh empty_mesh(MeshDesc {}); auto& context = device.immediate_context(); - const auto draw_before_begin = context.draw(); + const auto draw_before_begin = context.draw(DrawDesc { .vertex_count = 3 }); PP_EXPECT(h, !draw_before_begin.ok()); PP_EXPECT(h, draw_before_begin.code == StatusCode::invalid_argument); @@ -1513,7 +1578,7 @@ void recording_renderer_rejects_invalid_command_order_and_targets(pp::tests::Har }) .ok()); - const auto draw_without_bindings = context.draw(); + const auto draw_without_bindings = context.draw(DrawDesc { .vertex_count = 3 }); PP_EXPECT(h, !draw_without_bindings.ok()); PP_EXPECT(h, draw_without_bindings.code == StatusCode::invalid_argument); @@ -1535,7 +1600,7 @@ void recording_renderer_rejects_invalid_command_order_and_targets(pp::tests::Har PP_EXPECT(h, context.bind_sampler(0, SamplerDesc {}).ok()); - const auto draw_without_mesh = context.draw(); + const auto draw_without_mesh = context.draw(DrawDesc { .vertex_count = 3 }); PP_EXPECT(h, !draw_without_mesh.ok()); PP_EXPECT(h, draw_without_mesh.code == StatusCode::invalid_argument); @@ -1544,7 +1609,10 @@ void recording_renderer_rejects_invalid_command_order_and_targets(pp::tests::Har PP_EXPECT(h, invalid_mesh.code == StatusCode::invalid_argument); PP_EXPECT(h, context.bind_mesh(mesh).ok()); - PP_EXPECT(h, context.draw().ok()); + const auto draw_outside_mesh = context.draw(DrawDesc { .first_vertex = 2, .vertex_count = 2 }); + PP_EXPECT(h, !draw_outside_mesh.ok()); + PP_EXPECT(h, draw_outside_mesh.code == StatusCode::out_of_range); + PP_EXPECT(h, context.draw(DrawDesc { .vertex_count = 3 }).ok()); context.end_render_pass(); const auto viewport_after_end = context.set_viewport(Viewport { .x = 0, .y = 0, .width = 1, .height = 1 }); @@ -1659,6 +1727,7 @@ int main() harness.run("validates_depth_contract", validates_depth_contract); harness.run("validates_sampler_contract", validates_sampler_contract); harness.run("validates_viewports_and_mesh_descriptors", validates_viewports_and_mesh_descriptors); + harness.run("validates_draw_descriptors", validates_draw_descriptors); harness.run("validates_render_pass_descriptors", validates_render_pass_descriptors); harness.run("validates_shader_program_descriptors", validates_shader_program_descriptors); harness.run("validates_shader_uniform_writes", validates_shader_uniform_writes); diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 5e4202f..ba6b250 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -2350,7 +2350,10 @@ int record_render(int argc, char** argv) .address_w = pp::renderer::SamplerAddressMode::clamp_to_edge, }); const auto mesh_status = context.bind_mesh(*mesh.value()); - const auto draw_status = context.draw(); + const auto draw_status = context.draw(pp::renderer::DrawDesc { + .vertex_count = 3, + .index_count = 3, + }); context.end_render_pass(); if (!shader_status.ok()) { @@ -2463,8 +2466,8 @@ int record_render(int argc, char** argv) } } else if (command.kind == pp::renderer::RecordedRenderCommandKind::draw) { ++draw_commands; - draw_vertices += command.mesh_desc.vertex_count; - draw_indices += command.mesh_desc.index_count; + draw_vertices += command.draw_desc.vertex_count; + draw_indices += command.draw_desc.index_count; } else if (command.kind == pp::renderer::RecordedRenderCommandKind::set_scissor) { ++scissor_commands; } else if (command.kind == pp::renderer::RecordedRenderCommandKind::set_blend_state) {