diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 93f3c28..a6fabed 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -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/ diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 57b88c3..eb1cf3b 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -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 diff --git a/src/renderer_api/recording_renderer.cpp b/src/renderer_api/recording_renderer.cpp index 3e94f09..b39840e 100644 --- a/src/renderer_api/recording_renderer.cpp +++ b/src/renderer_api/recording_renderer.cpp @@ -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: diff --git a/src/renderer_api/recording_renderer.h b/src/renderer_api/recording_renderer.h index b316336..a7fb927 100644 --- a/src/renderer_api/recording_renderer.h +++ b/src/renderer_api/recording_renderer.h @@ -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 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, diff --git a/src/renderer_api/renderer_api.cpp b/src/renderer_api/renderer_api.cpp index dab1df6..f033ee7 100644 --- a/src/renderer_api/renderer_api.cpp +++ b/src/renderer_api/renderer_api.cpp @@ -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(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 texture_byte_size(TextureDesc desc) noexce } const auto bpp = static_cast(bytes_per_pixel(desc.format)); - const auto width = static_cast(desc.extent.width); - const auto height = static_cast(desc.extent.height); - if (width > std::numeric_limits::max() / height) { - return pp::foundation::Result::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(level_extent.width); + const auto height = static_cast(level_extent.height); + if (width > std::numeric_limits::max() / height) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("texture size overflows uint64")); + } + + const auto pixels = width * height; + if (pixels > std::numeric_limits::max() / bpp) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("texture byte size overflows uint64")); + } + + const auto level_bytes = pixels * bpp; + if (bytes > std::numeric_limits::max() - level_bytes) { + return pp::foundation::Result::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::max() / bpp) { - return pp::foundation::Result::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::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) { diff --git a/src/renderer_api/renderer_api.h b/src/renderer_api/renderer_api.h index 0a66d00..62a6e4d 100644 --- a/src/renderer_api/renderer_api.h +++ b/src/renderer_api/renderer_api.h @@ -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 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, diff --git a/tests/renderer_api/renderer_api_tests.cpp b/tests/renderer_api/renderer_api_tests.cpp index 519fcc9..97f9122 100644 --- a/tests/renderer_api/renderer_api_tests.cpp +++ b/tests/renderer_api/renderer_api_tests.cpp @@ -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();