From 8e0682be4aa9d295d7564ba5fd327b759be01d3d Mon Sep 17 00:00:00 2001 From: Jean Philippe Date: Thu, 17 Sep 2026 01:46:52 +0900 Subject: [PATCH 1/2] feat(rendering): gate sky bakes by device capabilities --- .../Rendering/Renderers/GraphicRenderer.cpp | 227 ++++++++++++++---- .../Rendering/Renderers/GraphicRenderer.h | 57 +++-- .../Rendering/Scenes/SkyEnvironment.cpp | 40 ++- .../ZEngine/Rendering/Scenes/SkyEnvironment.h | 14 +- ZEngine/docs/future-plan/sky-rendering.md | 2 +- .../tests/Rendering/SkyEnvironment_test.cpp | 32 +++ 6 files changed, 292 insertions(+), 80 deletions(-) diff --git a/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.cpp b/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.cpp index 53bb23a9..c9b935ef 100644 --- a/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.cpp +++ b/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.cpp @@ -16,6 +16,7 @@ #include #include #include +#include using namespace ZEngine::Hardwares; using namespace ZEngine::Helpers; @@ -27,7 +28,12 @@ namespace ZEngine::Rendering::Renderers { namespace { - uint32_t GetFullMipCount(uint32_t resolution) + constexpr VkFormat SkyLightingFormat = VK_FORMAT_R16G16B16A16_SFLOAT; + constexpr VkFormat HDRISourceFormat = VK_FORMAT_R32G32B32A32_SFLOAT; + constexpr VkFormatFeatureFlags RequiredSkyFormatFeatures = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT; + constexpr VkImageUsageFlags RequiredSkyImageUsage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT; + + uint32_t GetFullMipCount(uint32_t resolution) { uint32_t mip_count = 1; while (resolution > 1) @@ -37,6 +43,25 @@ namespace ZEngine::Rendering::Renderers } return mip_count; } + + void SetCapabilityReason(cstring* out_reason, cstring reason) + { + if (out_reason) + *out_reason = reason; + } + + bool SupportsSkyFormat(VkPhysicalDevice device, VkFormat format) + { + VkFormatProperties properties = {}; + vkGetPhysicalDeviceFormatProperties(device, format, &properties); + return (properties.optimalTilingFeatures & RequiredSkyFormatFeatures) == RequiredSkyFormatFeatures; + } + + bool SupportsSkyImage(VkPhysicalDevice device, VkFormat format, VkImageType type, VkImageCreateFlags flags, uint32_t width, uint32_t height, uint32_t depth, uint32_t layers) + { + VkImageFormatProperties properties = {}; + return vkGetPhysicalDeviceImageFormatProperties(device, format, type, VK_IMAGE_TILING_OPTIMAL, RequiredSkyImageUsage, flags, &properties) == VK_SUCCESS && properties.maxExtent.width >= width && properties.maxExtent.height >= height && properties.maxExtent.depth >= depth && properties.maxArrayLayers >= layers; + } } // namespace GraphicRenderer::GraphicRenderer() {} @@ -98,15 +123,23 @@ namespace ZEngine::Rendering::Renderers ZENGINE_VALIDATE_ASSERT(fallback_environment.Valid(), "Sky environment fallback source creation failed") ZENGINE_VALIDATE_ASSERT(fallback_lighting.Valid(), "Sky environment fallback lighting creation failed") m_sky_environment.Initialize(fallback_environment, fallback_lighting, Device->EnvironmentLightingBakeSettings); - m_lighting_pass = lighting_pass; - m_grid_pass = grid_pass; - m_skybox_pass = skybox_pass; - m_sky_sphere_pass = sky_sphere_pass; - m_sky_view_lut_pass = sky_view_lut_pass; - m_aerial_perspective_pass = aerial_pass; - m_sky_composite_pass = sky_composite_pass; - m_tone_mapping_pass = tone_mapping_pass; - m_atmosphere_view_resources_supported = SupportsAtmosphereViewResources(); + m_lighting_pass = lighting_pass; + m_grid_pass = grid_pass; + m_skybox_pass = skybox_pass; + m_sky_sphere_pass = sky_sphere_pass; + m_sky_view_lut_pass = sky_view_lut_pass; + m_aerial_perspective_pass = aerial_pass; + m_sky_composite_pass = sky_composite_pass; + m_tone_mapping_pass = tone_mapping_pass; + m_environment_lighting_resources_supported = SupportsEnvironmentLightingResources(Device->EnvironmentLightingBakeSettings, &m_environment_lighting_unavailable_reason); + m_atmosphere_bake_resources_supported = SupportsAtmosphereBakeResources(Device->EnvironmentLightingBakeSettings, &m_atmosphere_bake_unavailable_reason); + m_atmosphere_view_resources_supported = SupportsAtmosphereViewResources(&m_atmosphere_view_unavailable_reason); + if (!m_environment_lighting_resources_supported) + ZENGINE_CORE_WARN("[SkyEnvironment] HDRI and atmosphere IBL are disabled: {}", m_environment_lighting_unavailable_reason) + if (!m_atmosphere_bake_resources_supported) + ZENGINE_CORE_WARN("[SkyEnvironment] Atmosphere baking is disabled: {}", m_atmosphere_bake_unavailable_reason) + if (!m_atmosphere_view_resources_supported) + ZENGINE_CORE_WARN("[SkyEnvironment] Per-view atmosphere composition is disabled: {}", m_atmosphere_view_unavailable_reason) m_sky_atmosphere_transmittance_pass = ZPushStructCtorArgs(Device->Arena, SkyAtmosphereTransmittancePass, &m_sky_environment); m_sky_atmosphere_multiscattering_pass = ZPushStructCtorArgs(Device->Arena, SkyAtmosphereMultiscatteringPass, &m_sky_environment); m_sky_atmosphere_source_radiance_pass = ZPushStructCtorArgs(Device->Arena, SkyAtmosphereSourceRadiancePass, &m_sky_environment); @@ -155,22 +188,24 @@ namespace ZEngine::Rendering::Renderers Scenes::SkyEnvironmentResources retired_sky_resources = {}; while (m_sky_environment.TakeRetiredSnapshot(UINT64_MAX, retired_sky_resources)) DiscardSkyResources(retired_sky_resources); - m_lighting_pass = nullptr; - m_grid_pass = nullptr; - m_skybox_pass = nullptr; - m_sky_sphere_pass = nullptr; - m_sky_view_lut_pass = nullptr; - m_aerial_perspective_pass = nullptr; - m_sky_composite_pass = nullptr; - m_tone_mapping_pass = nullptr; - m_sky_atmosphere_transmittance_pass = nullptr; - m_sky_atmosphere_multiscattering_pass = nullptr; - m_sky_atmosphere_source_radiance_pass = nullptr; - m_sky_hdri_mip_generation_pass = nullptr; - m_sky_atmosphere_mip_generation_pass = nullptr; - m_sky_diffuse_irradiance_pass = nullptr; - m_sky_specular_prefilter_pass = nullptr; - m_atmosphere_view_resources_supported = false; + m_lighting_pass = nullptr; + m_grid_pass = nullptr; + m_skybox_pass = nullptr; + m_sky_sphere_pass = nullptr; + m_sky_view_lut_pass = nullptr; + m_aerial_perspective_pass = nullptr; + m_sky_composite_pass = nullptr; + m_tone_mapping_pass = nullptr; + m_sky_atmosphere_transmittance_pass = nullptr; + m_sky_atmosphere_multiscattering_pass = nullptr; + m_sky_atmosphere_source_radiance_pass = nullptr; + m_sky_hdri_mip_generation_pass = nullptr; + m_sky_atmosphere_mip_generation_pass = nullptr; + m_sky_diffuse_irradiance_pass = nullptr; + m_sky_specular_prefilter_pass = nullptr; + m_environment_lighting_resources_supported = false; + m_atmosphere_bake_resources_supported = false; + m_atmosphere_view_resources_supported = false; RenderGraph->Dispose(); if (RenderSceneData) @@ -351,9 +386,23 @@ namespace ZEngine::Rendering::Renderers if (request.Config.IsAtmosphere()) { - if (!request.BakeInputsValid || !SupportsAtmosphereBakeResources(request.BakeSettings)) + if (!request.BakeInputsValid) + { + ZENGINE_CORE_WARN("[SkyEnvironment] Revision {} is using the fallback: atmosphere requires valid settings and a selected directional light", request.Revision) + m_sky_environment.CompleteBake(request.Revision, {}, false); + return; + } + + if (!m_environment_lighting_resources_supported) + { + ZENGINE_CORE_WARN("[SkyEnvironment] Revision {} is using the fallback: atmosphere IBL is unavailable ({})", request.Revision, m_environment_lighting_unavailable_reason) + m_sky_environment.CompleteBake(request.Revision, {}, false); + return; + } + + if (!m_atmosphere_bake_resources_supported) { - ZENGINE_CORE_WARN("[SkyEnvironment] Revision {} is using the fallback: atmosphere requires valid resources and a selected directional light", request.Revision) + ZENGINE_CORE_WARN("[SkyEnvironment] Revision {} is using the fallback: atmosphere baking is unavailable ({})", request.Revision, m_atmosphere_bake_unavailable_reason) m_sky_environment.CompleteBake(request.Revision, {}, false); return; } @@ -386,6 +435,13 @@ namespace ZEngine::Rendering::Renderers return; } + if (!m_environment_lighting_resources_supported) + { + ZENGINE_CORE_WARN("[SkyEnvironment] Revision {} is using the fallback: HDRI IBL is unavailable ({})", request.Revision, m_environment_lighting_unavailable_reason) + m_sky_environment.CompleteBake(request.Revision, {}, false); + return; + } + auto* const asset_manager = ZEngine::Managers::AssetManager::Instance(); auto* const rrm = Device && Device->RRM ? static_cast(Device->RRM) : nullptr; if (!asset_manager || !asset_manager->Registry || !rrm) @@ -435,6 +491,14 @@ namespace ZEngine::Rendering::Renderers return; } + cstring hdri_capability_reason = nullptr; + if (!SupportsHDRISourceResources(artifact_header.FaceWidth, &hdri_capability_reason)) + { + ZENGINE_CORE_WARN("[SkyEnvironment] Revision {} is using the fallback: HDRI source is unavailable ({})", request.Revision, hdri_capability_reason) + m_sky_environment.CompleteBake(request.Revision, {}, false); + return; + } + const Textures::TextureHandle source_radiance = rrm->SubmitTextureFile(native_path, {}, true); if (!source_radiance.Valid() || !m_sky_environment.AttachBakeResource(request.Revision, source_radiance)) { @@ -598,44 +662,103 @@ namespace ZEngine::Rendering::Renderers DiscardSkyTexture(resources.Lighting.SpecularEnvironment); } - bool GraphicRenderer::SupportsAtmosphereBakeResources(const EnvironmentLightingBakeSettings& bake_settings) const + bool GraphicRenderer::SupportsEnvironmentLightingResources(const EnvironmentLightingBakeSettings& bake_settings, cstring* out_reason) const { - if (!Device || !bake_settings.IsValid()) + SetCapabilityReason(out_reason, nullptr); + if (!Device || Device->PhysicalDevice == VK_NULL_HANDLE) + { + SetCapabilityReason(out_reason, "no Vulkan physical device is available"); + return false; + } + if (!bake_settings.IsValid()) + { + SetCapabilityReason(out_reason, "the selected environment-lighting quality tier is invalid"); + return false; + } + + if (!SupportsSkyFormat(Device->PhysicalDevice, SkyLightingFormat)) + { + SetCapabilityReason(out_reason, "RGBA16F images cannot be both sampled and written by compute shaders"); return false; + } - constexpr VkFormat kAtmosphereFormat = VK_FORMAT_R16G16B16A16_SFLOAT; - constexpr VkFormatFeatureFlags kRequiredFeatures = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT; - VkFormatProperties format_properties = {}; - vkGetPhysicalDeviceFormatProperties(Device->PhysicalDevice, kAtmosphereFormat, &format_properties); - if ((format_properties.optimalTilingFeatures & kRequiredFeatures) != kRequiredFeatures) + const uint32_t cube_resolution = std::max({bake_settings.SourceRadianceResolution, bake_settings.DiffuseResolution, bake_settings.SpecularResolution}); + if (!SupportsSkyImage(Device->PhysicalDevice, SkyLightingFormat, VK_IMAGE_TYPE_2D, VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT, cube_resolution, cube_resolution, 1, 6)) + { + SetCapabilityReason(out_reason, "the selected quality tier exceeds RGBA16F cubemap support"); return false; + } + return true; + } - VkImageFormatProperties cube_properties = {}; - const VkResult cube_result = vkGetPhysicalDeviceImageFormatProperties(Device->PhysicalDevice, kAtmosphereFormat, VK_IMAGE_TYPE_2D, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT, VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT, &cube_properties); - if (cube_result != VK_SUCCESS || cube_properties.maxExtent.width < bake_settings.SourceRadianceResolution || cube_properties.maxExtent.height < bake_settings.SourceRadianceResolution || cube_properties.maxArrayLayers < 6) + bool GraphicRenderer::SupportsAtmosphereBakeResources(const EnvironmentLightingBakeSettings& bake_settings, cstring* out_reason) const + { + if (!SupportsEnvironmentLightingResources(bake_settings, out_reason)) return false; - VkPhysicalDeviceProperties properties = {}; - vkGetPhysicalDeviceProperties(Device->PhysicalDevice, &properties); - return properties.limits.maxImageDimension2D >= 256 && properties.limits.maxImageDimensionCube >= bake_settings.SourceRadianceResolution; + if (!SupportsSkyImage(Device->PhysicalDevice, SkyLightingFormat, VK_IMAGE_TYPE_2D, 0, 256, 64, 1, 1)) + { + SetCapabilityReason(out_reason, "the device cannot allocate the required RGBA16F atmosphere lookup textures"); + return false; + } + return true; } - bool GraphicRenderer::SupportsAtmosphereViewResources() const + bool GraphicRenderer::SupportsAtmosphereViewResources(cstring* out_reason) const { - if (!Device) + SetCapabilityReason(out_reason, nullptr); + if (!Device || Device->PhysicalDevice == VK_NULL_HANDLE) + { + SetCapabilityReason(out_reason, "no Vulkan physical device is available"); + return false; + } + + if (!SupportsSkyFormat(Device->PhysicalDevice, SkyLightingFormat)) + { + SetCapabilityReason(out_reason, "RGBA16F view textures cannot be both sampled and written by compute shaders"); + return false; + } + + if (!SupportsSkyImage(Device->PhysicalDevice, SkyLightingFormat, VK_IMAGE_TYPE_2D, 0, 192, 108, 1, 1)) + { + SetCapabilityReason(out_reason, "the device cannot allocate the required RGBA16F sky-view lookup texture"); return false; + } - constexpr VkFormat kAtmosphereFormat = VK_FORMAT_R16G16B16A16_SFLOAT; - constexpr VkFormatFeatureFlags kRequiredFeatures = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT; - constexpr VkImageUsageFlags kRequiredImageUsage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT; - VkFormatProperties format_properties = {}; - vkGetPhysicalDeviceFormatProperties(Device->PhysicalDevice, kAtmosphereFormat, &format_properties); - if ((format_properties.optimalTilingFeatures & kRequiredFeatures) != kRequiredFeatures) + if (!SupportsSkyImage(Device->PhysicalDevice, SkyLightingFormat, VK_IMAGE_TYPE_3D, 0, 32, 32, 32, 1)) + { + SetCapabilityReason(out_reason, "the device cannot allocate the required 32³ RGBA16F aerial-perspective volume"); return false; + } + return true; + } - VkImageFormatProperties volume_properties = {}; - const VkResult volume_result = vkGetPhysicalDeviceImageFormatProperties(Device->PhysicalDevice, kAtmosphereFormat, VK_IMAGE_TYPE_3D, VK_IMAGE_TILING_OPTIMAL, kRequiredImageUsage, 0, &volume_properties); - return volume_result == VK_SUCCESS && volume_properties.maxExtent.width >= 32 && volume_properties.maxExtent.height >= 32 && volume_properties.maxExtent.depth >= 32; + bool GraphicRenderer::SupportsHDRISourceResources(uint32_t face_resolution, cstring* out_reason) const + { + SetCapabilityReason(out_reason, nullptr); + if (!Device || Device->PhysicalDevice == VK_NULL_HANDLE) + { + SetCapabilityReason(out_reason, "no Vulkan physical device is available"); + return false; + } + if (face_resolution == 0 || face_resolution > Importers::AssetCodec::ENVIRONMENT_MAP_MAX_FACE_SIZE) + { + SetCapabilityReason(out_reason, "the cooked source has an invalid cubemap face resolution"); + return false; + } + + if (!SupportsSkyFormat(Device->PhysicalDevice, HDRISourceFormat)) + { + SetCapabilityReason(out_reason, "RGBA32F cubemaps cannot be both sampled and written by compute shaders"); + return false; + } + + if (!SupportsSkyImage(Device->PhysicalDevice, HDRISourceFormat, VK_IMAGE_TYPE_2D, VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT, face_resolution, face_resolution, 1, 6)) + { + SetCapabilityReason(out_reason, "the HDRI cubemap exceeds device support"); + return false; + } + return true; } Scenes::AtmosphereStaticResources GraphicRenderer::CreateAtmosphereStaticResources() diff --git a/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.h b/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.h index 4b3b9e56..c0da0561 100644 --- a/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.h +++ b/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.h @@ -47,8 +47,14 @@ namespace ZEngine::Rendering::Renderers void CollectRetiredSkySnapshots(); void DiscardSkyTexture(Textures::TextureHandle texture); void DiscardSkyResources(const Scenes::SkyEnvironmentResources& resources); - [[nodiscard]] bool SupportsAtmosphereBakeResources(const EnvironmentLightingBakeSettings& bake_settings) const; - [[nodiscard]] bool SupportsAtmosphereViewResources() const; + /// @brief Verifies the shared RGBA16F cubemap contract used by IBL. + [[nodiscard]] bool SupportsEnvironmentLightingResources(const EnvironmentLightingBakeSettings& bake_settings, cstring* out_reason = nullptr) const; + /// @brief Verifies the additional LUT and source-radiance requirements of atmosphere baking. + [[nodiscard]] bool SupportsAtmosphereBakeResources(const EnvironmentLightingBakeSettings& bake_settings, cstring* out_reason = nullptr) const; + /// @brief Verifies the transient 2D/3D atmosphere-view resource contract. + [[nodiscard]] bool SupportsAtmosphereViewResources(cstring* out_reason = nullptr) const; + /// @brief Verifies that one cooked RGBA32F HDRI can be mip-generated on this device. + [[nodiscard]] bool SupportsHDRISourceResources(uint32_t face_resolution, cstring* out_reason = nullptr) const; [[nodiscard]] Scenes::AtmosphereStaticResources CreateAtmosphereStaticResources(); [[nodiscard]] Textures::TextureHandle CreateAtmosphereSourceRadiance(const EnvironmentLightingBakeSettings& bake_settings); EnvironmentLightingResources CreateSkyLightingResources(const EnvironmentLightingBakeSettings& bake_settings); @@ -60,27 +66,32 @@ namespace ZEngine::Rendering::Renderers // the render thread publishes it after compiling the current graph. // Sequence-guard the two 64-bit handle fields so readers never observe // a mixed index/generation pair. - PaddedAtomic m_frame_output_sequence = {}; - PaddedAtomic m_frame_output_index = {.value = UINT64_MAX}; - PaddedAtomic m_frame_output_generation = {}; - Scenes::SkyEnvironment m_sky_environment = {}; - LightingPass* m_lighting_pass = nullptr; - GridPass* m_grid_pass = nullptr; - SkyboxPass* m_skybox_pass = nullptr; - SkySpherePass* m_sky_sphere_pass = nullptr; - SkyViewLutPass* m_sky_view_lut_pass = nullptr; - AerialPerspectivePass* m_aerial_perspective_pass = nullptr; - SkyCompositePass* m_sky_composite_pass = nullptr; - ToneMappingPass* m_tone_mapping_pass = nullptr; - SkyAtmosphereTransmittancePass* m_sky_atmosphere_transmittance_pass = nullptr; - SkyAtmosphereMultiscatteringPass* m_sky_atmosphere_multiscattering_pass = nullptr; - SkyAtmosphereSourceRadiancePass* m_sky_atmosphere_source_radiance_pass = nullptr; - SkyEnvironmentMipGenerationPass* m_sky_hdri_mip_generation_pass = nullptr; - SkyEnvironmentMipGenerationPass* m_sky_atmosphere_mip_generation_pass = nullptr; - SkyEnvironmentDiffuseIrradiancePass* m_sky_diffuse_irradiance_pass = nullptr; - SkyEnvironmentSpecularPrefilterPass* m_sky_specular_prefilter_pass = nullptr; - bool m_atmosphere_view_resources_supported = false; - Scenes::AtmosphereViewClass m_last_atmosphere_view_class = Scenes::AtmosphereViewClass::Invalid; + PaddedAtomic m_frame_output_sequence = {}; + PaddedAtomic m_frame_output_index = {.value = UINT64_MAX}; + PaddedAtomic m_frame_output_generation = {}; + Scenes::SkyEnvironment m_sky_environment = {}; + LightingPass* m_lighting_pass = nullptr; + GridPass* m_grid_pass = nullptr; + SkyboxPass* m_skybox_pass = nullptr; + SkySpherePass* m_sky_sphere_pass = nullptr; + SkyViewLutPass* m_sky_view_lut_pass = nullptr; + AerialPerspectivePass* m_aerial_perspective_pass = nullptr; + SkyCompositePass* m_sky_composite_pass = nullptr; + ToneMappingPass* m_tone_mapping_pass = nullptr; + SkyAtmosphereTransmittancePass* m_sky_atmosphere_transmittance_pass = nullptr; + SkyAtmosphereMultiscatteringPass* m_sky_atmosphere_multiscattering_pass = nullptr; + SkyAtmosphereSourceRadiancePass* m_sky_atmosphere_source_radiance_pass = nullptr; + SkyEnvironmentMipGenerationPass* m_sky_hdri_mip_generation_pass = nullptr; + SkyEnvironmentMipGenerationPass* m_sky_atmosphere_mip_generation_pass = nullptr; + SkyEnvironmentDiffuseIrradiancePass* m_sky_diffuse_irradiance_pass = nullptr; + SkyEnvironmentSpecularPrefilterPass* m_sky_specular_prefilter_pass = nullptr; + bool m_environment_lighting_resources_supported = false; + bool m_atmosphere_bake_resources_supported = false; + bool m_atmosphere_view_resources_supported = false; + cstring m_environment_lighting_unavailable_reason = "not evaluated"; + cstring m_atmosphere_bake_unavailable_reason = "not evaluated"; + cstring m_atmosphere_view_unavailable_reason = "not evaluated"; + Scenes::AtmosphereViewClass m_last_atmosphere_view_class = Scenes::AtmosphereViewClass::Invalid; }; ZDEFINE_PTR(GraphicRenderer); } // namespace ZEngine::Rendering::Renderers diff --git a/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.cpp b/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.cpp index 2ccb8b8c..98ecca0b 100644 --- a/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.cpp +++ b/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.cpp @@ -1,4 +1,6 @@ #include +#include +#include namespace ZEngine::Rendering::Scenes { @@ -64,6 +66,14 @@ namespace ZEngine::Rendering::Scenes return true; } + // A moving primary sun can otherwise invalidate every stage of the + // atmosphere chain once per rendered frame. Keep the last bake key + // current until the fixed revision budget opens; the most recently + // submitted direction is still retained as presentation state above. + const bool only_dynamic_celestial_input_changed = sanitized.IsAtmosphere() && inputs_valid && m_bake_inputs_valid && m_bake_config.IsAtmosphere() && m_bake_settings.Matches(resolved_bake_settings) && HasEquivalentAtmosphereSourceInputs(m_bake_config, sanitized) && !m_bake_celestial_light.Matches(celestial_light) && !HasSignificantCelestialLightChange(m_bake_celestial_light, celestial_light); + if (only_dynamic_celestial_input_changed && revision >= m_latest_bake_revision && revision - m_latest_bake_revision < DynamicCelestialBakeRevisionInterval) + return true; + if (inputs_valid && m_bake_inputs_valid && HasEquivalentBakeInputs(m_bake_config, m_bake_celestial_light, m_bake_hdri_source_hash, m_bake_hdri_artifact_ready, sanitized, celestial_light, hdri_source_hash, hdri_artifact_ready) && m_bake_settings.Matches(resolved_bake_settings)) { // The source radiance remains valid. Keep editor-facing presentation @@ -444,6 +454,33 @@ namespace ZEngine::Rendering::Scenes first.MieAnisotropy == second.MieAnisotropy && equal3(first.OzoneAbsorptionPerKilometer, second.OzoneAbsorptionPerKilometer) && first.OzoneCenterKilometers == second.OzoneCenterKilometers && first.OzoneThicknessKilometers == second.OzoneThicknessKilometers; } + bool SkyEnvironment::HasEquivalentAtmosphereSourceInputs(const SkyConfig& left, const SkyConfig& right) + { + if (!left.IsAtmosphere() || !right.IsAtmosphere() || !HasEquivalentAtmosphereStaticInputs(left, right)) + return false; + + const auto equal3 = [](const float (&first)[3], const float (&second)[3]) { return first[0] == second[0] && first[1] == second[1] && first[2] == second[2]; }; + return left.Atmosphere.SunAngularRadiusRadians == right.Atmosphere.SunAngularRadiusRadians && left.Atmosphere.SunIlluminanceLux == right.Atmosphere.SunIlluminanceLux && equal3(left.Atmosphere.GroundAlbedo, right.Atmosphere.GroundAlbedo) && left.Atmosphere.GroundAmbientIrradiance == right.Atmosphere.GroundAmbientIrradiance; + } + + bool SkyEnvironment::HasSignificantCelestialLightChange(const SkyCelestialLight& previous, const SkyCelestialLight& next) + { + if (previous.IsAvailable != next.IsAvailable) + return true; + if (!previous.IsAvailable) + return false; + + const auto squared_length = [](const SkyCelestialLight& light) { return light.DirectionToLight[0] * light.DirectionToLight[0] + light.DirectionToLight[1] * light.DirectionToLight[1] + light.DirectionToLight[2] * light.DirectionToLight[2]; }; + const float previous_length_squared = squared_length(previous); + const float next_length_squared = squared_length(next); + if (!std::isfinite(previous_length_squared) || !std::isfinite(next_length_squared) || previous_length_squared <= 1.0e-8f || next_length_squared <= 1.0e-8f) + return true; + + const float dot = previous.DirectionToLight[0] * next.DirectionToLight[0] + previous.DirectionToLight[1] * next.DirectionToLight[1] + previous.DirectionToLight[2] * next.DirectionToLight[2]; + const float normalized_dot = std::clamp(dot / std::sqrt(previous_length_squared * next_length_squared), -1.0f, 1.0f); + return normalized_dot < DynamicCelestialBakeDirectionCosThreshold; + } + bool SkyEnvironment::HasEquivalentBakeInputs(const SkyConfig& left, const SkyCelestialLight& left_celestial_light, uint64_t left_hdri_source_hash, bool left_hdri_artifact_ready, const SkyConfig& right, const SkyCelestialLight& right_celestial_light, uint64_t right_hdri_source_hash, bool right_hdri_artifact_ready) { if (left.Mode != right.Mode) @@ -453,8 +490,7 @@ namespace ZEngine::Rendering::Scenes if (left.IsSkySphere()) return true; - const auto equal3 = [](const float (&first)[3], const float (&second)[3]) { return first[0] == second[0] && first[1] == second[1] && first[2] == second[2]; }; - return HasEquivalentAtmosphereStaticInputs(left, right) && left_celestial_light.Matches(right_celestial_light) && left.Atmosphere.SunAngularRadiusRadians == right.Atmosphere.SunAngularRadiusRadians && left.Atmosphere.SunIlluminanceLux == right.Atmosphere.SunIlluminanceLux && equal3(left.Atmosphere.GroundAlbedo, right.Atmosphere.GroundAlbedo) && left.Atmosphere.GroundAmbientIrradiance == right.Atmosphere.GroundAmbientIrradiance; + return HasEquivalentAtmosphereSourceInputs(left, right) && left_celestial_light.Matches(right_celestial_light); } bool SkyEnvironment::IsAtmosphereShared(uint32_t excluded_snapshot_slot, const AtmosphereStaticResources& atmosphere) const diff --git a/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.h b/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.h index 808d2ce3..ac974039 100644 --- a/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.h +++ b/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.h @@ -101,8 +101,16 @@ namespace ZEngine::Rendering::Scenes /// that frame's graphics timeline has completed. struct SkyEnvironment { - static constexpr uint32_t MaxSnapshots = 8; - static constexpr uint32_t MaxPendingFramePins = 16; + static constexpr uint32_t MaxSnapshots = 8; + static constexpr uint32_t MaxPendingFramePins = 16; + /// @brief Bounds source-radiance churn from an animated primary sun. + /// @details Non-celestial edits always schedule immediately. A running + /// day/night controller submits at most one new source bake for + /// every eight observed sky revisions; an availability change + /// or a direction change of at least five degrees bypasses the + /// budget so an intentional editor edit is never delayed. + static constexpr uint64_t DynamicCelestialBakeRevisionInterval = 8; + static constexpr float DynamicCelestialBakeDirectionCosThreshold = 0.9961947f; // cos(5 degrees) /// @brief Establishes the engine-provided source and lighting fallbacks. void Initialize(Textures::TextureHandle fallback_source, const EnvironmentLightingResources& fallback_lighting = {}, const EnvironmentLightingBakeSettings& bake_settings = {}); @@ -171,6 +179,8 @@ namespace ZEngine::Rendering::Scenes private: [[nodiscard]] static bool HasEquivalentAtmosphereStaticInputs(const SkyConfig& left, const SkyConfig& right); + [[nodiscard]] static bool HasEquivalentAtmosphereSourceInputs(const SkyConfig& left, const SkyConfig& right); + [[nodiscard]] static bool HasSignificantCelestialLightChange(const SkyCelestialLight& previous, const SkyCelestialLight& next); [[nodiscard]] static bool HasEquivalentBakeInputs(const SkyConfig& left, const SkyCelestialLight& left_celestial_light, uint64_t left_hdri_source_hash, bool left_hdri_artifact_ready, const SkyConfig& right, const SkyCelestialLight& right_celestial_light, uint64_t right_hdri_source_hash, bool right_hdri_artifact_ready); [[nodiscard]] bool IsAtmosphereShared(uint32_t excluded_snapshot_slot, const AtmosphereStaticResources& atmosphere) const; void ReleaseNextFramePin(uint64_t timeline_value); diff --git a/ZEngine/docs/future-plan/sky-rendering.md b/ZEngine/docs/future-plan/sky-rendering.md index b6e0ff43..880882ea 100644 --- a/ZEngine/docs/future-plan/sky-rendering.md +++ b/ZEngine/docs/future-plan/sky-rendering.md @@ -179,7 +179,7 @@ At frame start, the renderer acquires one ready snapshot and pins it for that fr Descriptor writes are scoped to a reusable frame slot only after that slot's prior submission has retired. Publishing a new snapshot updates future frame slots; it never overwrites descriptors still visible to an in-flight command buffer. -An obsolete bake that has not been submitted may be cancelled. A submitted obsolete bake is allowed to finish, but its result is discarded unless its revision is still current. The bake scheduler has a bounded queue and a configurable time budget; it never accumulates one expensive full bake for every slider edit. +An obsolete bake that has not been submitted may be cancelled. A submitted obsolete bake is allowed to finish, but its result is discarded unless its revision is still current. The bake scheduler has a bounded queue and a configurable time budget; it never accumulates one expensive full bake for every slider edit. Dynamic primary-celestial updates are additionally coalesced: the current implementation accepts at most one new atmosphere source bake per eight observed sky revisions, retaining the newest direction for the next accepted request. An availability change or a rotation of at least five degrees bypasses the budget, so a deliberate editor edit responds immediately. Physical atmosphere, ground, quality, and mode changes remain immediate because they are not animation-budgeted edits. Shutdown first unregisters asset-completion listeners and stops accepting bake work. It then retires pinned snapshots through the normal device-timeline path before the device allocator is destroyed. Worker completion messages received during shutdown are discarded without dereferencing scene or renderer state. diff --git a/ZEngine/tests/Rendering/SkyEnvironment_test.cpp b/ZEngine/tests/Rendering/SkyEnvironment_test.cpp index d187573d..b2397f10 100644 --- a/ZEngine/tests/Rendering/SkyEnvironment_test.cpp +++ b/ZEngine/tests/Rendering/SkyEnvironment_test.cpp @@ -599,6 +599,38 @@ TEST(SkyEnvironmentTest, AtmosphereSourceBakeIgnoresScenePlacementInputs) EXPECT_FALSE(environment.TakeBakeRequest(request)); } +TEST(SkyEnvironmentTest, DynamicCelestialUpdatesCoalesceUntilTheBakeBudgetOpens) +{ + SkyEnvironment environment = {}; + environment.Initialize(Texture(1), Lighting(10)); + + SkyConfig config = {}; + SkyCelestialLight sun = {}; + sun.IsAvailable = true; + + SkyEnvironmentBakeRequest request = {}; + ASSERT_TRUE(environment.SubmitConfig(config, 1, {}, sun)); + ASSERT_TRUE(environment.TakeBakeRequest(request)); + ASSERT_TRUE(environment.AttachBakeAtmosphere(1, Atmosphere(20))); + ASSERT_TRUE(environment.AttachBakeResource(1, Texture(30))); + ASSERT_TRUE(environment.AttachBakeLighting(1, Lighting(40))); + ASSERT_EQ(environment.CompleteBake(1, Texture(30), true, Lighting(40), Atmosphere(20)), SkyEnvironmentBakeResult::Published); + + for (uint64_t revision = 2; revision < SkyEnvironment::DynamicCelestialBakeRevisionInterval + 1; ++revision) + { + sun.DirectionToLight[0] = static_cast(revision) * 0.001f; + ASSERT_TRUE(environment.SubmitConfig(config, revision, {}, sun)); + EXPECT_FALSE(environment.TakeBakeRequest(request)); + } + + const uint64_t accepted_revision = SkyEnvironment::DynamicCelestialBakeRevisionInterval + 1; + sun.DirectionToLight[0] = static_cast(accepted_revision) * 0.001f; + ASSERT_TRUE(environment.SubmitConfig(config, accepted_revision, {}, sun)); + ASSERT_TRUE(environment.TakeBakeRequest(request)); + EXPECT_EQ(request.Revision, accepted_revision); + EXPECT_FLOAT_EQ(request.CelestialLight.DirectionToLight[0], sun.DirectionToLight[0]); +} + TEST(SkyEnvironmentTest, GroundChangeRebakesSourceRadianceAndReusesStaticAtmosphere) { SkyEnvironment environment = {}; From 7e36de35862165d8558f7c5768f4223eea818a91 Mon Sep 17 00:00:00 2001 From: Jean Philippe Date: Thu, 17 Sep 2026 09:09:22 +0900 Subject: [PATCH 2/2] feat(rendering): enforce environment bake memory budget --- ZEngine/ZEngine/Engine.cpp | 32 +++++- ZEngine/ZEngine/Hardwares/VulkanDevice.h | 6 +- .../ZEngine/Rendering/EnvironmentLighting.h | 4 + .../Rendering/Renderers/GraphicRenderer.cpp | 58 +++++++++++ .../Rendering/Scenes/SkyEnvironment.cpp | 99 ++++++++++++++----- .../ZEngine/Rendering/Scenes/SkyEnvironment.h | 14 +++ ZEngine/docs/future-plan/sky-rendering.md | 10 +- .../tests/Rendering/SkyEnvironment_test.cpp | 43 ++++++++ 8 files changed, 235 insertions(+), 31 deletions(-) diff --git a/ZEngine/ZEngine/Engine.cpp b/ZEngine/ZEngine/Engine.cpp index 28dbcc93..d42f4350 100644 --- a/ZEngine/ZEngine/Engine.cpp +++ b/ZEngine/ZEngine/Engine.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #ifdef __APPLE__ #include @@ -41,7 +42,7 @@ namespace ZEngine // Read memory.geometry_streaming_mb from a project.json file. // Returns 0 if the file is absent, unparseable, or the key is missing — callers // treat 0 as "use auto-detection from device VRAM". - static VkDeviceSize ReadGeometryBudgetOverride(const char* config_file) + static VkDeviceSize ReadGeometryBudgetOverride(cstring config_file) { if (!config_file || config_file[0] == '\0') return 0; @@ -59,7 +60,7 @@ namespace ZEngine // Read rendering.environment_lighting_quality from a project.json file. // Missing, malformed, or unrecognized values retain the documented Standard tier. - static Rendering::EnvironmentLightingBakeSettings ReadEnvironmentLightingQuality(const char* config_file) + static Rendering::EnvironmentLightingBakeSettings ReadEnvironmentLightingQuality(cstring config_file) { const auto fallback = Rendering::ResolveEnvironmentLightingQuality(Rendering::EnvironmentLightingQualityTier::Standard); if (!config_file || config_file[0] == '\0') @@ -85,6 +86,32 @@ namespace ZEngine return fallback; } + // Read rendering.environment_lighting_budget_mb from project.json. + // Missing, malformed, zero, and overflowing values retain the documented default. + static VkDeviceSize ReadEnvironmentLightingMemoryBudget(cstring config_file) + { + if (!config_file || config_file[0] == '\0') + return Rendering::DefaultEnvironmentLightingMemoryBudget; + + std::ifstream file(config_file); + if (!file.is_open()) + return Rendering::DefaultEnvironmentLightingMemoryBudget; + + const auto json = nlohmann::json::parse(file, nullptr, /*exceptions=*/false); + if (json.is_discarded() || !json.contains("rendering") || !json["rendering"].is_object()) + return Rendering::DefaultEnvironmentLightingMemoryBudget; + + const auto& rendering = json["rendering"]; + if (!rendering.contains("environment_lighting_budget_mb") || !rendering["environment_lighting_budget_mb"].is_number_unsigned()) + return Rendering::DefaultEnvironmentLightingMemoryBudget; + + constexpr uint64_t bytes_per_megabyte = 1024ULL * 1024ULL; + const uint64_t megabytes = rendering["environment_lighting_budget_mb"].get(); + if (megabytes == 0 || megabytes > std::numeric_limits::max() / bytes_per_megabyte) + return Rendering::DefaultEnvironmentLightingMemoryBudget; + return megabytes * bytes_per_megabyte; + } + void Engine::Initialize(Core::Memory::MemoryManager* memory, Windows::WindowConfigurationPtr window_cfg_ptr, Applications::GameApplicationPtr app) { ZENGINE_VALIDATE_ASSERT(memory != nullptr, "Engine::Initialize: memory is null — Obelisk must call MemoryManager::Initialize first") @@ -184,6 +211,7 @@ namespace ZEngine // since InitGlobalBuffers reads it during VkBuffer allocation. g_engine_ctx->Device->GeometryStreamingBudget = ReadGeometryBudgetOverride(app->ConfigFile); g_engine_ctx->Device->EnvironmentLightingBakeSettings = ReadEnvironmentLightingQuality(app->ConfigFile); + g_engine_ctx->Device->EnvironmentLightingMemoryBudget = ReadEnvironmentLightingMemoryBudget(app->ConfigFile); // RenderResourceManager — GPU lifetime authority, bridges asset layer and VulkanDevice g_engine_ctx->RenderResourceManager = ZPushStructCtor(&g_engine_ctx->AssetArena, Rendering::RenderResourceManager); diff --git a/ZEngine/ZEngine/Hardwares/VulkanDevice.h b/ZEngine/ZEngine/Hardwares/VulkanDevice.h index 65e3c8ab..b4aeda7c 100644 --- a/ZEngine/ZEngine/Hardwares/VulkanDevice.h +++ b/ZEngine/ZEngine/Hardwares/VulkanDevice.h @@ -331,8 +331,12 @@ namespace ZEngine::Hardwares /// Set by Engine::Initialize from project.json memory.geometry_streaming_mb /// before RenderResourceManager::Initialize runs. VkDeviceSize GeometryStreamingBudget = 0; - /// @brief Project-selected IBL budget copied into each new SkyEnvironment revision. + /// @brief Project-selected IBL quality copied into each new SkyEnvironment revision. Rendering::EnvironmentLightingBakeSettings EnvironmentLightingBakeSettings = {}; + /// @brief Persistent fallback, published, and active-bake environment texture budget. + /// Set by Engine::Initialize from project.json + /// rendering.environment_lighting_budget_mb. + VkDeviceSize EnvironmentLightingMemoryBudget = Rendering::DefaultEnvironmentLightingMemoryBudget; VkInstance Instance = VK_NULL_HANDLE; VkSurfaceKHR Surface = VK_NULL_HANDLE; VkSurfaceFormatKHR SurfaceFormat = {}; diff --git a/ZEngine/ZEngine/Rendering/EnvironmentLighting.h b/ZEngine/ZEngine/Rendering/EnvironmentLighting.h index ee5b4734..e1c3f834 100644 --- a/ZEngine/ZEngine/Rendering/EnvironmentLighting.h +++ b/ZEngine/ZEngine/Rendering/EnvironmentLighting.h @@ -1,9 +1,13 @@ #pragma once #include +#include #include namespace ZEngine::Rendering { + /// @brief Default cap for persistent fallback, published, and baking environment textures. + inline constexpr uint64_t DefaultEnvironmentLightingMemoryBudget = ZMega(384ULL); + /// @brief Selects the fixed resource and sampling budget for environment IBL. /// @details This is renderer/project policy, not serialized artistic scene data. enum class EnvironmentLightingQualityTier : uint8_t diff --git a/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.cpp b/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.cpp index c9b935ef..843dd021 100644 --- a/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.cpp +++ b/ZEngine/ZEngine/Rendering/Renderers/GraphicRenderer.cpp @@ -44,6 +44,46 @@ namespace ZEngine::Rendering::Renderers return mip_count; } + uint64_t EstimateTextureBytes(uint32_t width, uint32_t height, uint32_t depth, uint32_t bytes_per_pixel, uint32_t layers, uint32_t mip_count) + { + uint64_t bytes = 0; + for (uint32_t mip = 0; mip < mip_count; ++mip) + { + const uint32_t mip_width = std::max(1u, width >> mip); + const uint32_t mip_height = std::max(1u, height >> mip); + const uint32_t mip_depth = std::max(1u, depth >> mip); + bytes += static_cast(mip_width) * mip_height * mip_depth * bytes_per_pixel * layers; + } + return bytes; + } + + uint64_t EstimateSkyLightingBytes(const EnvironmentLightingBakeSettings& bake_settings) + { + constexpr uint32_t rgba16f_bytes_per_pixel = sizeof(uint16_t) * 4; + return EstimateTextureBytes(bake_settings.DiffuseResolution, bake_settings.DiffuseResolution, 1, rgba16f_bytes_per_pixel, 6, 1) + EstimateTextureBytes(bake_settings.SpecularResolution, bake_settings.SpecularResolution, 1, rgba16f_bytes_per_pixel, 6, GetFullMipCount(bake_settings.SpecularResolution)); + } + + uint64_t EstimateAtmosphereBakeBytes(const EnvironmentLightingBakeSettings& bake_settings) + { + constexpr uint32_t rgba16f_bytes_per_pixel = sizeof(uint16_t) * 4; + constexpr uint32_t atmosphere_lut_bytes = 256 * 64 * rgba16f_bytes_per_pixel + 32 * 32 * rgba16f_bytes_per_pixel; + return atmosphere_lut_bytes + EstimateTextureBytes(bake_settings.SourceRadianceResolution, bake_settings.SourceRadianceResolution, 1, rgba16f_bytes_per_pixel, 6, GetFullMipCount(bake_settings.SourceRadianceResolution)) + EstimateSkyLightingBytes(bake_settings); + } + + uint64_t EstimateHDRIBakeBytes(const EnvironmentLightingBakeSettings& bake_settings, uint32_t face_resolution) + { + constexpr uint32_t rgba32f_bytes_per_pixel = sizeof(float) * 4; + return EstimateTextureBytes(face_resolution, face_resolution, 1, rgba32f_bytes_per_pixel, 6, GetFullMipCount(face_resolution)) + EstimateSkyLightingBytes(bake_settings); + } + + uint64_t GetTextureBytes(Hardwares::VulkanDevice* device, Textures::TextureHandle handle) + { + if (!device || !handle.Valid()) + return 0; + const Textures::Texture* const texture = device->GlobalTextures.Access(handle); + return texture ? static_cast(texture->BufferSize) : 0; + } + void SetCapabilityReason(cstring* out_reason, cstring reason) { if (out_reason) @@ -123,6 +163,8 @@ namespace ZEngine::Rendering::Renderers ZENGINE_VALIDATE_ASSERT(fallback_environment.Valid(), "Sky environment fallback source creation failed") ZENGINE_VALIDATE_ASSERT(fallback_lighting.Valid(), "Sky environment fallback lighting creation failed") m_sky_environment.Initialize(fallback_environment, fallback_lighting, Device->EnvironmentLightingBakeSettings); + const uint64_t fallback_memory_bytes = GetTextureBytes(Device, fallback_environment) + GetTextureBytes(Device, fallback_lighting.DiffuseIrradiance) + GetTextureBytes(Device, fallback_lighting.SpecularEnvironment) + GetTextureBytes(Device, fallback_lighting.BrdfIntegrationLut); + m_sky_environment.ConfigureMemoryBudget(Device->EnvironmentLightingMemoryBudget, fallback_memory_bytes); m_lighting_pass = lighting_pass; m_grid_pass = grid_pass; m_skybox_pass = skybox_pass; @@ -407,6 +449,14 @@ namespace ZEngine::Rendering::Renderers return; } + const uint64_t atmosphere_bake_bytes = EstimateAtmosphereBakeBytes(request.BakeSettings); + if (!m_sky_environment.ReserveActiveBakeMemory(request.Revision, atmosphere_bake_bytes)) + { + ZENGINE_CORE_WARN("[SkyEnvironment] Revision {} is using the fallback: atmosphere bake needs {} bytes but {} of the {} byte environment budget is reserved", request.Revision, atmosphere_bake_bytes, m_sky_environment.GetReservedMemoryBytes(), m_sky_environment.GetMemoryBudgetBytes()) + m_sky_environment.CompleteBake(request.Revision, {}, false); + return; + } + const Scenes::AtmosphereStaticResources* reusable_atmosphere = m_sky_environment.FindReusableAtmosphere(request.Config); const Scenes::AtmosphereStaticResources atmosphere = reusable_atmosphere ? *reusable_atmosphere : CreateAtmosphereStaticResources(); const bool owns_atmosphere = reusable_atmosphere == nullptr; @@ -499,6 +549,14 @@ namespace ZEngine::Rendering::Renderers return; } + const uint64_t hdri_bake_bytes = EstimateHDRIBakeBytes(request.BakeSettings, artifact_header.FaceWidth); + if (!m_sky_environment.ReserveActiveBakeMemory(request.Revision, hdri_bake_bytes)) + { + ZENGINE_CORE_WARN("[SkyEnvironment] Revision {} is using the fallback: HDRI bake needs {} bytes but {} of the {} byte environment budget is reserved", request.Revision, hdri_bake_bytes, m_sky_environment.GetReservedMemoryBytes(), m_sky_environment.GetMemoryBudgetBytes()) + m_sky_environment.CompleteBake(request.Revision, {}, false); + return; + } + const Textures::TextureHandle source_radiance = rrm->SubmitTextureFile(native_path, {}, true); if (!source_radiance.Valid() || !m_sky_environment.AttachBakeResource(request.Revision, source_radiance)) { diff --git a/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.cpp b/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.cpp index 98ecca0b..241bd6d6 100644 --- a/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.cpp +++ b/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.cpp @@ -1,6 +1,7 @@ #include #include #include +#include namespace ZEngine::Rendering::Scenes { @@ -20,6 +21,34 @@ namespace ZEngine::Rendering::Scenes m_state = SkyEnvironmentState::Fallback; } + void SkyEnvironment::ConfigureMemoryBudget(uint64_t budget_bytes, uint64_t fallback_bytes) + { + m_memory_budget_bytes = budget_bytes; + m_snapshots[0].MemoryBytes = fallback_bytes; + } + + bool SkyEnvironment::ReserveActiveBakeMemory(uint64_t revision, uint64_t bytes) + { + if (!m_has_active_bake || m_active_bake.Revision != revision) + return false; + + // Unit tests and integrations that do not configure a budget retain + // the existing scheduler behavior. The renderer always configures its + // non-zero project/default budget before accepting a bake. + if (m_memory_budget_bytes == 0) + return true; + + if (m_active_bake_reserved_memory_bytes != 0) + return false; + + const uint64_t reserved_memory_bytes = GetReservedMemoryBytes(); + if (reserved_memory_bytes > m_memory_budget_bytes || bytes > m_memory_budget_bytes - reserved_memory_bytes) + return false; + + m_active_bake_reserved_memory_bytes = bytes; + return true; + } + bool SkyEnvironment::SubmitConfig(const SkyConfig& config, uint64_t revision, const EnvironmentLightingBakeSettings& bake_settings, const SkyCelestialLight& celestial_light, uint64_t hdri_source_hash, bool hdri_artifact_ready) { if (revision == 0 || revision < m_latest_revision) @@ -119,19 +148,20 @@ namespace ZEngine::Rendering::Scenes if (!m_has_pending_request || m_has_active_bake) return false; - out_request = m_pending_request; - m_active_bake = m_pending_request; - m_active_bake_atmosphere = {}; - m_active_bake_owns_atmosphere = false; - m_active_bake_source = {}; - m_active_bake_lighting = {}; - m_active_stage_timeline = 0; - m_active_bake_stage = SkyEnvironmentBakeStage::AwaitingSource; - m_active_stage_submitted = false; - m_active_stage_recorded = false; - m_has_pending_request = false; - m_has_active_bake = true; - m_state = SkyEnvironmentState::Baking; + out_request = m_pending_request; + m_active_bake = m_pending_request; + m_active_bake_atmosphere = {}; + m_active_bake_owns_atmosphere = false; + m_active_bake_source = {}; + m_active_bake_lighting = {}; + m_active_bake_reserved_memory_bytes = 0; + m_active_stage_timeline = 0; + m_active_bake_stage = SkyEnvironmentBakeStage::AwaitingSource; + m_active_stage_submitted = false; + m_active_stage_recorded = false; + m_has_pending_request = false; + m_has_active_bake = true; + m_state = SkyEnvironmentState::Baking; return true; } @@ -253,11 +283,13 @@ namespace ZEngine::Rendering::Scenes const Textures::TextureHandle completed_source = source_radiance.Valid() ? source_radiance : m_active_bake_source; const EnvironmentLightingResources completed_lighting = lighting.Valid() ? lighting : m_active_bake_lighting.Valid() ? m_active_bake_lighting : m_fallback_lighting; const AtmosphereStaticResources completed_atmosphere = atmosphere.Valid() ? atmosphere : m_active_bake_atmosphere; + const uint64_t completed_memory_bytes = m_active_bake_reserved_memory_bytes; m_active_bake = {}; m_active_bake_atmosphere = {}; m_active_bake_owns_atmosphere = false; m_active_bake_source = {}; m_active_bake_lighting = {}; + m_active_bake_reserved_memory_bytes = 0; m_active_bake_stage = SkyEnvironmentBakeStage::AwaitingSource; m_active_stage_timeline = 0; m_active_stage_submitted = false; @@ -292,6 +324,7 @@ namespace ZEngine::Rendering::Scenes published.SourceRadiance = completed_source; published.Lighting = completed_lighting; published.Revision = revision; + published.MemoryBytes = completed_memory_bytes; published.State = SkyEnvironmentState::Ready; m_published_slot = static_cast(new_slot); m_state = SkyEnvironmentState::Ready; @@ -358,17 +391,18 @@ namespace ZEngine::Rendering::Scenes .SourceRadiance = m_active_bake_source, .Lighting = m_active_bake_lighting, }; - m_active_bake = {}; - m_active_bake_atmosphere = {}; - m_active_bake_owns_atmosphere = false; - m_active_bake_source = {}; - m_active_bake_lighting = {}; - m_active_bake_stage = SkyEnvironmentBakeStage::AwaitingSource; - m_active_stage_timeline = 0; - m_active_stage_submitted = false; - m_active_stage_recorded = false; - m_has_active_bake = false; - m_has_pending_request = false; + m_active_bake = {}; + m_active_bake_atmosphere = {}; + m_active_bake_owns_atmosphere = false; + m_active_bake_source = {}; + m_active_bake_lighting = {}; + m_active_bake_reserved_memory_bytes = 0; + m_active_bake_stage = SkyEnvironmentBakeStage::AwaitingSource; + m_active_stage_timeline = 0; + m_active_stage_submitted = false; + m_active_stage_recorded = false; + m_has_active_bake = false; + m_has_pending_request = false; return active_bake_resources; } @@ -440,6 +474,23 @@ namespace ZEngine::Rendering::Scenes return m_latest_revision; } + uint64_t SkyEnvironment::GetReservedMemoryBytes() const + { + uint64_t reserved_memory_bytes = m_active_bake_reserved_memory_bytes; + for (const SkyEnvironmentSnapshot& snapshot : m_snapshots) + { + if (snapshot.MemoryBytes > std::numeric_limits::max() - reserved_memory_bytes) + return std::numeric_limits::max(); + reserved_memory_bytes += snapshot.MemoryBytes; + } + return reserved_memory_bytes; + } + + uint64_t SkyEnvironment::GetMemoryBudgetBytes() const + { + return m_memory_budget_bytes; + } + bool SkyEnvironment::HasEquivalentAtmosphereStaticInputs(const SkyConfig& left, const SkyConfig& right) { const auto equal3 = [](const float (&first)[3], const float (&second)[3]) { return first[0] == second[0] && first[1] == second[1] && first[2] == second[2]; }; diff --git a/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.h b/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.h index ac974039..5ec89237 100644 --- a/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.h +++ b/ZEngine/ZEngine/Rendering/Scenes/SkyEnvironment.h @@ -61,6 +61,10 @@ namespace ZEngine::Rendering::Scenes EnvironmentLightingResources Lighting = {}; uint64_t Revision = 0; uint64_t LastUseTimeline = 0; + /// @brief Conservative persistent-memory reservation for this snapshot. + /// @details Static atmosphere LUTs are counted by every snapshot that + /// references them, which can defer a bake early but never undercounts. + uint64_t MemoryBytes = 0; uint32_t PinCount = 0; SkyEnvironmentState State = SkyEnvironmentState::Fallback; bool IsFallback = false; @@ -115,6 +119,12 @@ namespace ZEngine::Rendering::Scenes /// @brief Establishes the engine-provided source and lighting fallbacks. void Initialize(Textures::TextureHandle fallback_source, const EnvironmentLightingResources& fallback_lighting = {}, const EnvironmentLightingBakeSettings& bake_settings = {}); + /// @brief Sets the persistent environment-texture budget after fallbacks are created. + void ConfigureMemoryBudget(uint64_t budget_bytes, uint64_t fallback_bytes); + /// @brief Reserves the complete next-snapshot allocation before the renderer creates it. + /// @return False when the current snapshots plus this bake would exceed the configured cap. + bool ReserveActiveBakeMemory(uint64_t revision, uint64_t bytes); + /// @brief Coalesces an immutable config revision while preserving its identity. /// @return False if the revision is stale or already observed. bool SubmitConfig(const SkyConfig& config, uint64_t revision, const EnvironmentLightingBakeSettings& bake_settings = {}, const SkyCelestialLight& celestial_light = {}, uint64_t hdri_source_hash = 0, bool hdri_artifact_ready = true); @@ -176,6 +186,8 @@ namespace ZEngine::Rendering::Scenes [[nodiscard]] const EnvironmentLightingResources& GetFallbackLighting() const; [[nodiscard]] SkyEnvironmentState GetState() const; [[nodiscard]] uint64_t GetLatestRevision() const; + [[nodiscard]] uint64_t GetReservedMemoryBytes() const; + [[nodiscard]] uint64_t GetMemoryBudgetBytes() const; private: [[nodiscard]] static bool HasEquivalentAtmosphereStaticInputs(const SkyConfig& left, const SkyConfig& right); @@ -206,6 +218,8 @@ namespace ZEngine::Rendering::Scenes uint64_t m_latest_revision = 0; uint64_t m_latest_bake_revision = 0; uint64_t m_active_stage_timeline = 0; + uint64_t m_memory_budget_bytes = 0; + uint64_t m_active_bake_reserved_memory_bytes = 0; SkyEnvironmentBakeStage m_active_bake_stage = SkyEnvironmentBakeStage::AwaitingSource; bool m_has_pending_request = false; bool m_has_active_bake = false; diff --git a/ZEngine/docs/future-plan/sky-rendering.md b/ZEngine/docs/future-plan/sky-rendering.md index 880882ea..442d23a7 100644 --- a/ZEngine/docs/future-plan/sky-rendering.md +++ b/ZEngine/docs/future-plan/sky-rendering.md @@ -462,6 +462,8 @@ The HDRI IBL implementation reads the project-level `rendering.environment_light | Standard | 32 per face | 32 | 128 per face, full mip chain | 128 | | High | 64 per face | 64 | 256 per face, full mip chain | 256 | +Persistent environment texture memory is independently capped by the project-level `rendering.environment_lighting_budget_mb` key. It accepts a positive integer number of MiB and defaults to 384 MiB when the key is missing or invalid. + --- ## 10. HDR, formats, and memory budget @@ -482,18 +484,18 @@ All values below use binary units and exclude transient command/descriptors: | Resource | Allocation | Memory | |---|---|---:| -| Transmittance + multiscattering | 256 x 64 packed + 32 x 32 RGBA16F | about 72 KiB | +| Transmittance + multiscattering | 256 x 64 + 32 x 32 RGBA16F | about 136 KiB | | One 192 x 108 sky-view + two 32 cubed aerial LUTs | RGBA16F | about 674 KiB per RenderView | | 512 cubemap source radiance, base level | 6 faces RGBA16F | 12 MiB | | The same source with a complete mip chain | RGBA16F | about 16 MiB | | Diffuse + full-chain 128 specular IBL | RGBA16F | about 1.05 MiB per SkyEnvironment | -| 512 x 512 BRDF LUT | RG16F | 1 MiB engine-global | +| 512 x 512 BRDF LUT | RGBA16F | 2 MiB engine-global | -Version 1 uses the complete source mip chain: an atmosphere environment is therefore about 17.1 MiB plus 674 KiB per view; with the shared BRDF LUT initialized, the first such environment is about 18.8 MiB. A 512 cubemap is 12 MiB at its base level and about 16 MiB with its full chain; the BRDF LUT is 1 MiB, not 512 KiB. +Version 1 uses the complete source mip chain: an atmosphere environment is therefore about 17.2 MiB plus 674 KiB per view; with the shared BRDF LUT initialized, the first such environment is about 19.9 MiB. A 512 cubemap is 12 MiB at its base level and about 16 MiB with its full chain; the BRDF LUT is 2 MiB, not 512 KiB. HDRI memory depends on the selected cooked cubemap quality tier. The old 32 MiB/128 MiB raw 2K/4K equirectangular estimates are useful import-memory warnings, but are not the desired steady-state resident runtime budget. -The allocator budget reserves the update peak, not only steady state: current snapshot, next bake snapshot, all in-flight pinned snapshots, the global fallback, and all active RenderView resources. The bake is deferred or cancelled before this reservation would exceed its configured environment budget. It never evicts the snapshot currently selected by a frame. +The persistent-environment budget reserves the update peak, not only steady state: current snapshot, next bake snapshot, all in-flight pinned snapshots, and the global fallback. The shared BRDF LUT is counted once with the fallback. Static atmosphere LUTs are conservatively counted by every snapshot that references them, so sharing can defer a bake early but never undercount its peak. The bake is cancelled before this reservation would exceed `rendering.environment_lighting_budget_mb`; it never evicts the snapshot currently selected by a frame. Per-RenderView transient resources remain managed by the render-graph transient pool rather than this persistent-resource gate. ### 10.1 Colour, exposure, and output diff --git a/ZEngine/tests/Rendering/SkyEnvironment_test.cpp b/ZEngine/tests/Rendering/SkyEnvironment_test.cpp index b2397f10..3ce3c8dd 100644 --- a/ZEngine/tests/Rendering/SkyEnvironment_test.cpp +++ b/ZEngine/tests/Rendering/SkyEnvironment_test.cpp @@ -75,6 +75,49 @@ TEST(SkyEnvironmentTest, FirstFramePinsTheValidFallbackSnapshot) EXPECT_EQ(snapshot->LastUseTimeline, 7u); } +TEST(SkyEnvironmentTest, MemoryBudgetDefersBakesUntilRetiredSnapshotsReleaseReservations) +{ + SkyEnvironment environment = {}; + environment.Initialize(Texture(1), Lighting(10)); + environment.ConfigureMemoryBudget(100, 10); + EXPECT_EQ(environment.GetMemoryBudgetBytes(), 100u); + EXPECT_EQ(environment.GetReservedMemoryBytes(), 10u); + + SkyEnvironmentBakeRequest request = {}; + ASSERT_TRUE(environment.SubmitConfig(HDRIConfig(), 1, {}, {}, 1)); + ASSERT_TRUE(environment.TakeBakeRequest(request)); + ASSERT_TRUE(environment.ReserveActiveBakeMemory(1, 60)); + ASSERT_TRUE(environment.AttachBakeResource(1, Texture(2))); + ASSERT_EQ(environment.CompleteBake(1, Texture(2), true), SkyEnvironmentBakeResult::Published); + EXPECT_EQ(environment.GetReservedMemoryBytes(), 70u); + ASSERT_NE(environment.AcquireForFrame(), nullptr); + + ASSERT_TRUE(environment.SubmitConfig(HDRIConfig(), 2, {}, {}, 2)); + ASSERT_TRUE(environment.TakeBakeRequest(request)); + ASSERT_TRUE(environment.ReserveActiveBakeMemory(2, 30)); + ASSERT_TRUE(environment.AttachBakeResource(2, Texture(3))); + ASSERT_EQ(environment.CompleteBake(2, Texture(3), true), SkyEnvironmentBakeResult::Published); + EXPECT_EQ(environment.GetReservedMemoryBytes(), 100u); + + ASSERT_TRUE(environment.SubmitConfig(HDRIConfig(), 3, {}, {}, 3)); + ASSERT_TRUE(environment.TakeBakeRequest(request)); + EXPECT_FALSE(environment.ReserveActiveBakeMemory(3, 1)); + ASSERT_EQ(environment.CompleteBake(3, {}, false), SkyEnvironmentBakeResult::Failed); + + environment.ReleaseSubmittedFrame(9); + SkyEnvironmentResources retired = {}; + ASSERT_TRUE(environment.TakeRetiredSnapshot(9, retired)); + EXPECT_EQ(retired.SourceRadiance.Index, 2u); + EXPECT_EQ(environment.GetReservedMemoryBytes(), 40u); + + ASSERT_TRUE(environment.SubmitConfig(HDRIConfig(), 4, {}, {}, 4)); + ASSERT_TRUE(environment.TakeBakeRequest(request)); + ASSERT_TRUE(environment.ReserveActiveBakeMemory(4, 60)); + EXPECT_EQ(environment.GetReservedMemoryBytes(), 100u); + ASSERT_EQ(environment.CompleteBake(4, {}, false), SkyEnvironmentBakeResult::Failed); + EXPECT_EQ(environment.GetReservedMemoryBytes(), 40u); +} + TEST(SkyEnvironmentTest, RapidEditsCoalesceToTheNewestRevision) { SkyEnvironment environment = {};