Add renderer draw descriptor contract

This commit is contained in:
2026-06-02 16:27:28 +02:00
parent 58f163788b
commit 483bbb4a9c
8 changed files with 157 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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