Add renderer mipmap command contract

This commit is contained in:
2026-06-02 16:47:44 +02:00
parent 901aff1051
commit 07293c0590
7 changed files with 279 additions and 29 deletions

View File

@@ -288,13 +288,14 @@ Known local toolchain state:
backend-owned resource creation, explicit texture usage flags, 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, explicit draw descriptor ranges,
texture-copy regions, readback bounds, frame-capture sources, destination
buffer sizes, and render-target blit regions, records
counts, texture mip-level counts, mipmap-generation commands,
shader-uniform writes, explicit draw descriptor ranges, texture-copy regions,
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/draw/upload/texture-copy/readback/frame-capture/blit commands,
draw mesh inputs, explicit draw ranges, and records trace markers without a
window or GL context.
sampler-bind/draw/upload/mipmap-generation/texture-copy/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/texture-copy/readback/

View File

@@ -417,12 +417,13 @@ Status: started. `pp_renderer_api` exists as a headless renderer-neutral target
with explicit texture usage flags, texture descriptor, byte-size, viewport,
mesh, readback bounds, command context, render device, shader program
descriptor, mesh, render target, readback byte-size helpers,
texture-upload/readback command validation, frame-capture byte-size helpers,
texture mip-level validation, texture-upload/readback command validation,
mipmap-generation command validation, frame-capture byte-size helpers,
frame-capture command validation, render-target blit validation, texture-slot
binding validation, blend-state validation, scissor-state validation,
depth-state validation, trace interface validation, sampler-state validation,
and the canonical PanoPainter shader catalog now consumed by the legacy
OpenGL app initialization path.
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
OpenGL capability detection for framebuffer fetch, map-buffer alignment, and
float texture support. It also owns the OpenGL texture upload-type mapping used
@@ -722,7 +723,8 @@ Results:
plus malformed payload rejection at the export boundary.
- `pp_renderer_api_tests` passed, including shader descriptor validation,
PanoPainter shader catalog validation, explicit texture usage validation,
readback byte-size and command-order validation, texture-upload byte-count
texture mip-level validation, readback byte-size and command-order
validation, texture-upload byte-count validation, mipmap-generation command
validation, frame-capture byte-size and command-order validation,
render-target blit validation, texture-slot binding validation, blend-state
validation, scissor-state validation, render-pass color/depth/stencil clear
@@ -829,15 +831,15 @@ Results:
renderer-owned resource factory and
command-order/render-pass-clear/scissor-state/depth-state/blend-state/
texture-usage/texture-bind/sampler-bind/shader-uniform/texture-upload/
readback/frame-capture/blit validation plus explicit draw descriptor and
texture-copy validation; it creates validated textures, render targets,
shaders, meshes, and readback buffers, then records commands, trace markers,
render-pass
mipmap-generation/readback/frame-capture/blit validation plus explicit draw
descriptor and texture-copy 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, texture uploads/copies/readbacks, frame captures, and render-target
blits, giving automation a backend-neutral render path that does not require
a window or GL context.
ranges, texture uploads/mipmap generations/copies/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

View File

@@ -409,6 +409,32 @@ pp::foundation::Status RecordingCommandContext::upload_texture(
return pp::foundation::Status::success();
}
pp::foundation::Status RecordingCommandContext::generate_mipmaps(ITexture2D& texture) noexcept
{
if (in_render_pass_) {
return pp::foundation::Status::invalid_argument("mipmap generation must be outside a render pass");
}
const auto desc = texture.desc();
const auto desc_status = validate_mipmap_generation_desc(desc);
if (!desc_status.ok()) {
return desc_status;
}
const auto bytes = texture_byte_size(desc);
if (!bytes.ok()) {
return bytes.status();
}
push_command(commands_, RecordedRenderCommand {
.kind = RecordedRenderCommandKind::generate_mipmaps,
.texture_desc = desc,
.generated_mip_levels = desc.mip_levels,
.generated_mip_bytes = bytes.value(),
});
return pp::foundation::Status::success();
}
pp::foundation::Status RecordingCommandContext::copy_texture(
ITexture2D& source,
ReadbackRegion source_region,
@@ -691,6 +717,8 @@ const char* recorded_render_command_kind_name(RecordedRenderCommandKind kind) no
return "draw";
case RecordedRenderCommandKind::upload_texture:
return "upload_texture";
case RecordedRenderCommandKind::generate_mipmaps:
return "generate_mipmaps";
case RecordedRenderCommandKind::copy_texture:
return "copy_texture";
case RecordedRenderCommandKind::read_texture:

View File

@@ -20,6 +20,7 @@ enum class RecordedRenderCommandKind : std::uint8_t {
bind_mesh,
draw,
upload_texture,
generate_mipmaps,
copy_texture,
read_texture,
capture_frame,
@@ -54,6 +55,8 @@ struct RecordedRenderCommand {
ReadbackRegion destination_region {};
BlitFilter blit_filter = BlitFilter::nearest;
std::uint64_t upload_bytes = 0;
std::uint32_t generated_mip_levels = 0;
std::uint64_t generated_mip_bytes = 0;
std::uint64_t copy_source_bytes = 0;
std::uint64_t copy_destination_bytes = 0;
std::uint64_t readback_bytes = 0;
@@ -141,6 +144,8 @@ public:
ITexture2D& texture,
ReadbackRegion region,
std::span<const std::byte> rgba_or_channel_bytes) noexcept override;
[[nodiscard]] pp::foundation::Status generate_mipmaps(
ITexture2D& texture) noexcept override;
[[nodiscard]] pp::foundation::Status copy_texture(
ITexture2D& source,
ReadbackRegion source_region,

View File

@@ -31,6 +31,18 @@ namespace {
return pp::foundation::Status::success();
}
[[nodiscard]] Extent2D mip_level_extent(Extent2D extent, std::uint32_t level) noexcept
{
auto width = extent.width;
auto height = extent.height;
for (std::uint32_t index = 0; index < level; ++index) {
width = width > 1U ? width / 2U : 1U;
height = height > 1U ? height / 2U : 1U;
}
return Extent2D { .width = width, .height = height };
}
}
std::uint32_t bytes_per_pixel(TextureFormat format) noexcept
@@ -47,6 +59,22 @@ std::uint32_t bytes_per_pixel(TextureFormat format) noexcept
return 0;
}
std::uint32_t max_mip_levels_for_extent(Extent2D extent) noexcept
{
if (extent.width == 0U || extent.height == 0U) {
return 0;
}
auto dimension = extent.width > extent.height ? extent.width : extent.height;
std::uint32_t levels = 1;
while (dimension > 1U && levels < max_texture_mip_levels) {
dimension /= 2U;
++levels;
}
return levels;
}
bool has_texture_usage(TextureUsage usage, TextureUsage required) noexcept
{
const auto usage_bits = static_cast<std::uint32_t>(usage);
@@ -100,6 +128,14 @@ pp::foundation::Status validate_texture_desc(TextureDesc desc) noexcept
return pp::foundation::Status::invalid_argument("texture format is not supported");
}
if (desc.mip_levels == 0U) {
return pp::foundation::Status::invalid_argument("texture mip level count must be greater than zero");
}
if (desc.mip_levels > max_mip_levels_for_extent(desc.extent)) {
return pp::foundation::Status::out_of_range("texture mip level count exceeds the texture extent");
}
return validate_texture_usage(desc.usage);
}
@@ -111,20 +147,30 @@ pp::foundation::Result<std::uint64_t> texture_byte_size(TextureDesc desc) noexce
}
const auto bpp = static_cast<std::uint64_t>(bytes_per_pixel(desc.format));
const auto width = static_cast<std::uint64_t>(desc.extent.width);
const auto height = static_cast<std::uint64_t>(desc.extent.height);
if (width > std::numeric_limits<std::uint64_t>::max() / height) {
return pp::foundation::Result<std::uint64_t>::failure(
pp::foundation::Status::out_of_range("texture size overflows uint64"));
std::uint64_t bytes = 0;
for (std::uint32_t level = 0; level < desc.mip_levels; ++level) {
const auto level_extent = mip_level_extent(desc.extent, level);
const auto width = static_cast<std::uint64_t>(level_extent.width);
const auto height = static_cast<std::uint64_t>(level_extent.height);
if (width > std::numeric_limits<std::uint64_t>::max() / height) {
return pp::foundation::Result<std::uint64_t>::failure(
pp::foundation::Status::out_of_range("texture size overflows uint64"));
}
const auto pixels = width * height;
if (pixels > std::numeric_limits<std::uint64_t>::max() / bpp) {
return pp::foundation::Result<std::uint64_t>::failure(
pp::foundation::Status::out_of_range("texture byte size overflows uint64"));
}
const auto level_bytes = pixels * bpp;
if (bytes > std::numeric_limits<std::uint64_t>::max() - level_bytes) {
return pp::foundation::Result<std::uint64_t>::failure(
pp::foundation::Status::out_of_range("texture byte size overflows uint64"));
}
bytes += level_bytes;
}
const auto pixels = width * height;
if (pixels > std::numeric_limits<std::uint64_t>::max() / bpp) {
return pp::foundation::Result<std::uint64_t>::failure(
pp::foundation::Status::out_of_range("texture byte size overflows uint64"));
}
const auto bytes = pixels * bpp;
if (bytes > max_texture_bytes) {
return pp::foundation::Result<std::uint64_t>::failure(
pp::foundation::Status::out_of_range("texture byte size exceeds the configured limit"));
@@ -562,6 +608,32 @@ pp::foundation::Status validate_texture_copy_descs(
return validate_readback_region(destination, destination_region);
}
pp::foundation::Status validate_mipmap_generation_desc(TextureDesc desc) noexcept
{
const auto desc_status = validate_texture_desc(desc);
if (!desc_status.ok()) {
return desc_status;
}
if (desc.mip_levels <= 1U) {
return pp::foundation::Status::invalid_argument("mipmap generation requires more than one mip level");
}
if (!has_texture_usage(desc.usage, TextureUsage::sampled)) {
return pp::foundation::Status::invalid_argument("mipmap texture must allow sampled usage");
}
if (!has_texture_usage(desc.usage, TextureUsage::copy_source)) {
return pp::foundation::Status::invalid_argument("mipmap texture must allow copy_source usage");
}
if (!has_texture_usage(desc.usage, TextureUsage::copy_destination)) {
return pp::foundation::Status::invalid_argument("mipmap texture must allow copy_destination usage");
}
return pp::foundation::Status::success();
}
pp::foundation::Status validate_blit_filter(BlitFilter filter) noexcept
{
switch (filter) {

View File

@@ -10,6 +10,7 @@
namespace pp::renderer {
constexpr std::uint32_t max_texture_dimension = 32768;
constexpr std::uint32_t max_texture_mip_levels = 16;
constexpr std::uint32_t max_mesh_vertices = 16777216;
constexpr std::uint32_t max_texture_slots = 32;
constexpr std::uint64_t max_texture_bytes = 1024ULL * 1024ULL * 1024ULL;
@@ -58,6 +59,7 @@ struct Extent2D {
struct TextureDesc {
Extent2D extent;
TextureFormat format = TextureFormat::rgba8;
std::uint32_t mip_levels = 1;
TextureUsage usage = TextureUsage::sampled
| TextureUsage::upload_destination
| TextureUsage::readback_source
@@ -275,6 +277,8 @@ public:
ITexture2D& texture,
ReadbackRegion region,
std::span<const std::byte> rgba_or_channel_bytes) noexcept = 0;
[[nodiscard]] virtual pp::foundation::Status generate_mipmaps(
ITexture2D& texture) noexcept = 0;
[[nodiscard]] virtual pp::foundation::Status copy_texture(
ITexture2D& source,
ReadbackRegion source_region,
@@ -311,6 +315,7 @@ public:
};
[[nodiscard]] std::uint32_t bytes_per_pixel(TextureFormat format) noexcept;
[[nodiscard]] std::uint32_t max_mip_levels_for_extent(Extent2D extent) noexcept;
[[nodiscard]] bool has_texture_usage(TextureUsage usage, TextureUsage required) noexcept;
[[nodiscard]] pp::foundation::Status validate_extent(Extent2D extent) noexcept;
[[nodiscard]] pp::foundation::Status validate_texture_usage(TextureUsage usage) noexcept;
@@ -344,6 +349,7 @@ public:
ReadbackRegion source_region,
TextureDesc destination,
ReadbackRegion destination_region) noexcept;
[[nodiscard]] pp::foundation::Status validate_mipmap_generation_desc(TextureDesc desc) noexcept;
[[nodiscard]] pp::foundation::Status validate_blit_filter(BlitFilter filter) noexcept;
[[nodiscard]] pp::foundation::Status validate_blit_descs(
TextureDesc source,

View File

@@ -58,6 +58,7 @@ using pp::renderer::TextureUsage;
using pp::renderer::Viewport;
using pp::renderer::max_shader_source_bytes;
using pp::renderer::max_shader_uniform_bytes;
using pp::renderer::max_mip_levels_for_extent;
using pp::renderer::max_texture_dimension;
using pp::renderer::max_texture_slots;
using pp::renderer::panopainter_shader_catalog;
@@ -77,6 +78,7 @@ 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_mipmap_generation_desc;
using pp::renderer::validate_readback_region;
using pp::renderer::validate_render_pass_desc;
using pp::renderer::validate_sampler_address_mode;
@@ -397,6 +399,24 @@ public:
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Status generate_mipmaps(
pp::renderer::ITexture2D& texture) noexcept override
{
const auto status = validate_mipmap_generation_desc(texture.desc());
if (!status.ok()) {
return status;
}
const auto bytes = texture_byte_size(texture.desc());
if (!bytes.ok()) {
return bytes.status();
}
last_generated_mip_levels = texture.desc().mip_levels;
last_generated_mip_bytes = bytes.value();
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Status copy_texture(
pp::renderer::ITexture2D& source,
ReadbackRegion source_region,
@@ -497,6 +517,8 @@ public:
std::uint32_t last_sampler_slot = 0;
SamplerDesc last_sampler_desc {};
std::uint64_t last_upload_bytes = 0;
std::uint32_t last_generated_mip_levels = 0;
std::uint64_t last_generated_mip_bytes = 0;
std::uint64_t last_copy_source_bytes = 0;
std::uint64_t last_copy_destination_bytes = 0;
std::uint64_t last_readback_bytes = 0;
@@ -629,6 +651,14 @@ void computes_texture_sizes(pp::tests::Harness& h)
PP_EXPECT(h, r8.ok());
PP_EXPECT(h, r8.value() == 128U);
PP_EXPECT(h, texture_format_name(TextureFormat::depth24_stencil8) == std::string_view("depth24_stencil8"));
const auto mipmapped = texture_byte_size(TextureDesc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 3,
});
PP_EXPECT(h, mipmapped.ok());
PP_EXPECT(h, mipmapped.value() == 84U);
}
void validates_texture_usage_contract(pp::tests::Harness& h)
@@ -676,6 +706,68 @@ void validates_texture_usage_contract(pp::tests::Harness& h)
PP_EXPECT(h, unknown_format.code == StatusCode::invalid_argument);
}
void validates_mipmap_generation_contract(pp::tests::Harness& h)
{
const TextureDesc mipmapped_desc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 3,
.usage = TextureUsage::sampled | TextureUsage::copy_source | TextureUsage::copy_destination,
};
const TextureDesc one_mip_desc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 1,
.usage = TextureUsage::sampled | TextureUsage::copy_source | TextureUsage::copy_destination,
};
const TextureDesc too_many_mips_desc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 4,
.usage = TextureUsage::sampled | TextureUsage::copy_source | TextureUsage::copy_destination,
};
const TextureDesc missing_sampled_desc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 3,
.usage = TextureUsage::copy_source | TextureUsage::copy_destination,
};
const TextureDesc missing_copy_source_desc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 3,
.usage = TextureUsage::sampled | TextureUsage::copy_destination,
};
const TextureDesc missing_copy_destination_desc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 3,
.usage = TextureUsage::sampled | TextureUsage::copy_source,
};
PP_EXPECT(h, max_mip_levels_for_extent(Extent2D { .width = 1, .height = 1 }) == 1U);
PP_EXPECT(h, max_mip_levels_for_extent(Extent2D { .width = 4, .height = 3 }) == 3U);
PP_EXPECT(h, max_mip_levels_for_extent(Extent2D { .width = max_texture_dimension, .height = 1 }) == 16U);
PP_EXPECT(h, validate_mipmap_generation_desc(mipmapped_desc).ok());
const auto one_mip = validate_mipmap_generation_desc(one_mip_desc);
const auto too_many_mips = validate_mipmap_generation_desc(too_many_mips_desc);
const auto missing_sampled = validate_mipmap_generation_desc(missing_sampled_desc);
const auto missing_copy_source = validate_mipmap_generation_desc(missing_copy_source_desc);
const auto missing_copy_destination = validate_mipmap_generation_desc(missing_copy_destination_desc);
PP_EXPECT(h, !one_mip.ok());
PP_EXPECT(h, one_mip.code == StatusCode::invalid_argument);
PP_EXPECT(h, !too_many_mips.ok());
PP_EXPECT(h, too_many_mips.code == StatusCode::out_of_range);
PP_EXPECT(h, !missing_sampled.ok());
PP_EXPECT(h, missing_sampled.code == StatusCode::invalid_argument);
PP_EXPECT(h, !missing_copy_source.ok());
PP_EXPECT(h, missing_copy_source.code == StatusCode::invalid_argument);
PP_EXPECT(h, !missing_copy_destination.ok());
PP_EXPECT(h, missing_copy_destination.code == StatusCode::invalid_argument);
}
void rejects_invalid_or_excessive_extents(pp::tests::Harness& h)
{
const auto zero = validate_extent(Extent2D { .width = 0, .height = 1 });
@@ -1410,6 +1502,48 @@ void recording_renderer_records_shader_uniform_writes(pp::tests::Harness& h)
PP_EXPECT(h, commands[3].kind == RecordedRenderCommandKind::end_render_pass);
}
void recording_renderer_records_mipmap_generation(pp::tests::Harness& h)
{
RecordingRenderDevice device;
RecordingTexture2D texture(TextureDesc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 3,
.usage = TextureUsage::sampled | TextureUsage::copy_source | TextureUsage::copy_destination,
});
RecordingTexture2D one_mip_texture(TextureDesc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.mip_levels = 1,
.usage = TextureUsage::sampled | TextureUsage::copy_source | TextureUsage::copy_destination,
});
RecordingRenderTarget target(TextureDesc {
.extent = Extent2D { .width = 4, .height = 4 },
.format = TextureFormat::rgba8,
.usage = all_texture_usages,
});
auto& context = device.immediate_context();
PP_EXPECT(h, context.generate_mipmaps(texture).ok());
const auto commands = device.commands();
PP_EXPECT(h, commands.size() == 1U);
PP_EXPECT(h, commands[0].kind == RecordedRenderCommandKind::generate_mipmaps);
PP_EXPECT(h, commands[0].texture_desc.mip_levels == 3U);
PP_EXPECT(h, commands[0].generated_mip_levels == 3U);
PP_EXPECT(h, commands[0].generated_mip_bytes == 84U);
PP_EXPECT(h, recorded_render_command_kind_name(commands[0].kind) == std::string_view("generate_mipmaps"));
const auto one_mip = context.generate_mipmaps(one_mip_texture);
PP_EXPECT(h, !one_mip.ok());
PP_EXPECT(h, one_mip.code == StatusCode::invalid_argument);
PP_EXPECT(h, context.begin_render_pass(target, RenderPassDesc {}).ok());
const auto during_render_pass = context.generate_mipmaps(texture);
PP_EXPECT(h, !during_render_pass.ok());
PP_EXPECT(h, during_render_pass.code == StatusCode::invalid_argument);
context.end_render_pass();
}
void recording_renderer_records_valid_command_sequences(pp::tests::Harness& h)
{
RecordingRenderDevice device;
@@ -1997,6 +2131,7 @@ int main()
pp::tests::Harness harness;
harness.run("computes_texture_sizes", computes_texture_sizes);
harness.run("validates_texture_usage_contract", validates_texture_usage_contract);
harness.run("validates_mipmap_generation_contract", validates_mipmap_generation_contract);
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);
@@ -2016,6 +2151,7 @@ int main()
harness.run("renderer_interfaces_support_backend_neutral_dispatch", renderer_interfaces_support_backend_neutral_dispatch);
harness.run("render_devices_create_validated_resources", render_devices_create_validated_resources);
harness.run("recording_renderer_records_shader_uniform_writes", recording_renderer_records_shader_uniform_writes);
harness.run("recording_renderer_records_mipmap_generation", recording_renderer_records_mipmap_generation);
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();