diff --git a/.gitignore b/.gitignore index 9309668d..a38f0748 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ /active_project /test/assets/assets.db /test/products/ +_codeql_detected_source_root diff --git a/runtime/render/adaptor/include/render/adaptor/components/StaticMeshComponent.h b/runtime/render/adaptor/include/render/adaptor/components/StaticMeshComponent.h index 1b1583a0..3cc69572 100644 --- a/runtime/render/adaptor/include/render/adaptor/components/StaticMeshComponent.h +++ b/runtime/render/adaptor/include/render/adaptor/components/StaticMeshComponent.h @@ -9,9 +9,15 @@ #include #include #include +#include namespace sky { + struct LodMeshAssetData { + Uuid meshUuid; + float screenSize = 0.f; + }; + class StaticMeshComponent : public ComponentBase, public IAssetEvent { public: StaticMeshComponent() = default; @@ -40,11 +46,17 @@ namespace sky { void SetEnableMeshletConeDebug(bool enable); bool GetEnableMeshletConeDebug() const { return debugFlags.TestBit(MeshDebugFlagBit::MESHLET_CONE); } + void SetLodMeshes(const std::vector &lodMeshes); + const std::vector &GetLodMeshes() const { return lodMeshAssets; } + void SetLodBias(float bias); + float GetLodBias() const { return lodBias; } + void OnAttachToWorld() override; void OnDetachFromWorld() override; private: void ShutDown(); void BuildRenderer(); + void BuildLodGroup(); void OnAssetLoaded() override; @@ -59,6 +71,11 @@ namespace sky { RDMeshPtr meshInstance; MeshRenderer *renderer = nullptr; + std::vector lodMeshAssets; + std::vector lodMeshAssetPtrs; + RDMeshLodGroupPtr lodGroup; + float lodBias = 1.0f; + std::atomic_bool dirty = false; EventBinder binder; diff --git a/runtime/render/adaptor/src/components/StaticMeshComponent.cpp b/runtime/render/adaptor/src/components/StaticMeshComponent.cpp index 07b88fdb..8e431afa 100644 --- a/runtime/render/adaptor/src/components/StaticMeshComponent.cpp +++ b/runtime/render/adaptor/src/components/StaticMeshComponent.cpp @@ -39,6 +39,18 @@ namespace sky { ar.SaveValueObject(std::string("receiveShadow"), receiveShadow); ar.SaveValueObject(std::string("meshShading"), enableMeshShading); ar.SaveValueObject(std::string("mesh"), meshAsset ? meshAsset->GetUuid() : Uuid()); + ar.SaveValueObject(std::string("lodBias"), lodBias); + + ar.Key("lodMeshes"); + ar.StartArray(); + for (const auto &lod : lodMeshAssets) { + ar.StartObject(); + ar.SaveValueObject(std::string("mesh"), lod.meshUuid); + ar.SaveValueObject(std::string("screenSize"), lod.screenSize); + ar.EndObject(); + } + ar.EndArray(); + ar.EndObject(); } @@ -51,6 +63,7 @@ namespace sky { Uuid uuid; ar.LoadKeyValue("mesh", uuid); SetMeshUuid(uuid); + ar.LoadKeyValue("lodBias", lodBias); } void StaticMeshComponent::SetEnableMeshShading(bool enable) @@ -105,6 +118,43 @@ namespace sky { } } + void StaticMeshComponent::SetLodMeshes(const std::vector &lodMeshes) + { + lodMeshAssets = lodMeshes; + dirty.store(true); + } + + void StaticMeshComponent::SetLodBias(float bias) + { + lodBias = bias; + if (lodGroup) { + lodGroup->SetLodBias(lodBias); + } + } + + void StaticMeshComponent::BuildLodGroup() + { + if (lodMeshAssets.empty()) { + lodGroup = nullptr; + return; + } + + auto *am = AssetManager::Get(); + lodGroup = new MeshLodGroup(); + lodGroup->SetLodBias(lodBias); + lodMeshAssetPtrs.clear(); + + for (const auto &lodData : lodMeshAssets) { + auto asset = am->LoadAsset(lodData.meshUuid); + if (asset) { + asset->BlockUntilLoaded(); + auto meshPtr = CreateMeshFromAsset(asset); + lodGroup->AddLodMesh(meshPtr, lodData.screenSize); + lodMeshAssetPtrs.emplace_back(asset); + } + } + } + void StaticMeshComponent::BuildRenderer() { SKY_PROFILE_NAME("Build Static Render") @@ -121,7 +171,14 @@ namespace sky { } renderer = mf->CreateStaticMesh(); - renderer->SetMesh(meshInstance, enableMeshShading); + + BuildLodGroup(); + + if (lodGroup && lodGroup->GetLodCount() > 0) { + renderer->SetLodGroup(lodGroup); + } else { + renderer->SetMesh(meshInstance, enableMeshShading); + } } void StaticMeshComponent::ShutDown() diff --git a/runtime/render/core/include/render/lod/LodGroup.h b/runtime/render/core/include/render/lod/LodGroup.h new file mode 100644 index 00000000..cfea9ab7 --- /dev/null +++ b/runtime/render/core/include/render/lod/LodGroup.h @@ -0,0 +1,120 @@ +// +// Created by blues on 2025/2/16. +// + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace sky { + + static constexpr uint32_t MAX_LOD_LEVEL = 8; + static constexpr uint32_t INVALID_LOD_LEVEL = ~(0U); + + struct LodLevel { + float screenSize = 0.f; + }; + + struct LodConfig { + std::vector lodLevels; + std::vector distancesSq; + float lodBias = 1.0f; + }; + + // UE-style: extract screen-size multiplier from projection matrix + // ScreenMultiple = max(0.5 * ProjMatrix[0][0], 0.5 * ProjMatrix[1][1]) + inline float GetScreenMultiple(const Matrix4 &projMatrix) + { + return std::max(0.5f * projMatrix[0][0], 0.5f * projMatrix[1][1]); + } + + // UE-style: ComputeBoundsScreenSize + // ScreenSize = 2 * ScreenMultiple * SphereRadius / max(1, Distance) + inline float ComputeBoundsScreenSize(float sphereRadius, float dist, float screenMultiple) + { + float screenRadius = screenMultiple * sphereRadius / std::max(1.0f, dist); + return screenRadius * 2.0f; + } + + inline float CalculateScreenSize(const AABB &worldBound, const Vector3 &viewPos, const Matrix4 &projMatrix) + { + auto center = (worldBound.min + worldBound.max) * 0.5f; + auto extent = (worldBound.max - worldBound.min) * 0.5f; + float radius = extent.Length(); + + auto diff = center - viewPos; + float dist = diff.Length(); + + float screenMultiple = GetScreenMultiple(projMatrix); + return ComputeBoundsScreenSize(radius, dist, screenMultiple); + } + + // Convert a screen-size threshold to a distance threshold + // From: ScreenSize = 2 * ScreenMultiple * SphereRadius / Distance + // Solving: Distance = 2 * ScreenMultiple * SphereRadius / ScreenSize + inline float ScreenSizeToDistance(float screenSize, float sphereRadius, float screenMultiple) + { + if (screenSize <= 0.f) { + return std::numeric_limits::max(); + } + return (2.0f * screenMultiple * sphereRadius) / screenSize; + } + + inline void PreComputeDistances(LodConfig &config, float sphereRadius, const Matrix4 &projMatrix) + { + float screenMultiple = GetScreenMultiple(projMatrix); + config.distancesSq.resize(config.lodLevels.size()); + for (uint32_t i = 0; i < static_cast(config.lodLevels.size()); ++i) { + float d = ScreenSizeToDistance(config.lodLevels[i].screenSize, sphereRadius, screenMultiple); + config.distancesSq[i] = d * d; + } + } + + inline float CalculateDistanceSq(const AABB &worldBound, const Vector3 &viewPos) + { + auto center = (worldBound.min + worldBound.max) * 0.5f; + auto diff = center - viewPos; + return diff.x * diff.x + diff.y * diff.y + diff.z * diff.z; + } + + inline uint32_t SelectLodByScreenSize(const LodConfig &config, float screenSize) + { + float biasedSize = screenSize * config.lodBias; + for (uint32_t i = 0; i < static_cast(config.lodLevels.size()); ++i) { + if (biasedSize >= config.lodLevels[i].screenSize) { + return i; + } + } + return INVALID_LOD_LEVEL; + } + + inline uint32_t SelectLodLevel(const LodConfig &config, float screenSize) + { + return SelectLodByScreenSize(config, screenSize); + } + + inline uint32_t SelectLodLevel(const LodConfig &config, const AABB &worldBound, const Vector3 &viewPos) + { + if (config.distancesSq.empty()) { + return INVALID_LOD_LEVEL; + } + + float distSq = CalculateDistanceSq(worldBound, viewPos); + float biasInv = 1.0f / std::max(config.lodBias, 0.001f); + float biasedDistSq = distSq * biasInv * biasInv; + + for (uint32_t i = 0; i < static_cast(config.distancesSq.size()); ++i) { + if (biasedDistSq < config.distancesSq[i]) { + return i; + } + } + return INVALID_LOD_LEVEL; + } + +} // namespace sky diff --git a/runtime/render/core/include/render/lod/MeshLodGroup.h b/runtime/render/core/include/render/lod/MeshLodGroup.h new file mode 100644 index 00000000..eb608e2f --- /dev/null +++ b/runtime/render/core/include/render/lod/MeshLodGroup.h @@ -0,0 +1,35 @@ +// +// Created by blues on 2025/2/16. +// + +#pragma once + +#include +#include + +namespace sky { + + class MeshLodGroup : public RenderResource { + public: + MeshLodGroup() = default; + ~MeshLodGroup() override = default; + + void AddLodMesh(const RDMeshPtr &mesh, float screenSize); + void SetLodBias(float bias); + void PreComputeDistances(float sphereRadius, const Matrix4 &projMatrix); + + uint32_t GetLodCount() const { return static_cast(lodMeshes.size()); } + const RDMeshPtr &GetMesh(uint32_t lod) const { return lodMeshes[lod]; } + + uint32_t SelectLod(const AABB &worldBound, const Vector3 &viewPos) const; + + const LodConfig &GetConfig() const { return config; } + + private: + LodConfig config; + std::vector lodMeshes; + }; + + using RDMeshLodGroupPtr = CounterPtr; + +} // namespace sky diff --git a/runtime/render/core/include/render/mesh/MeshFeatureProcessor.h b/runtime/render/core/include/render/mesh/MeshFeatureProcessor.h index 4d913ab7..867ea464 100644 --- a/runtime/render/core/include/render/mesh/MeshFeatureProcessor.h +++ b/runtime/render/core/include/render/mesh/MeshFeatureProcessor.h @@ -24,10 +24,16 @@ namespace sky { SkeletonMeshRenderer *CreateSkeletonMesh(); void RemoveSkeletonMesh(SkeletonMeshRenderer *mesh); + void SetMainViewName(const Name &name) { mainViewName = name; } + private: + void UpdateLod(); + std::list> staticMeshes; std::list> skeletonMeshes; + + Name mainViewName; }; } // namespace sky diff --git a/runtime/render/core/include/render/mesh/MeshRenderer.h b/runtime/render/core/include/render/mesh/MeshRenderer.h index 97eca5c9..3fc11222 100644 --- a/runtime/render/core/include/render/mesh/MeshRenderer.h +++ b/runtime/render/core/include/render/mesh/MeshRenderer.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include #include @@ -27,13 +28,19 @@ namespace sky { void Tick(); void AttachScene(RenderScene *scn); void SetMesh(const RDMeshPtr &mesh, bool meshShading = false); + void SetLodGroup(const RDMeshLodGroupPtr &lodGroup); void SetDebugFlags(const MeshDebugFlags& flag); void UpdateTransform(const Matrix4 &matrix); + void UpdateLod(const Vector3 &viewPos); void BuildGeometry(); void SetMaterial(const RDMaterialInstancePtr &mat, uint32_t subMesh); + + uint32_t GetCurrentLod() const { return currentLod; } + bool IsVisible() const { return isVisible; } + bool IsCulledByLod() const { return culledByLod; } protected: virtual void PrepareUBO(); virtual RDResourceGroupPtr RequestResourceGroup(MeshFeature *feature); @@ -41,16 +48,23 @@ namespace sky { void SetupDebugMeshlet(); void Reset(); + void ShowPrimitives(); + void HidePrimitives(); RenderScene *scene = nullptr; RDMeshPtr mesh; + RDMeshLodGroupPtr lodGroup; + uint32_t currentLod = 0; + std::vector> primitives; std::unique_ptr meshletDebug; std::vector meshletInfos; RDDynamicUniformBufferPtr ubo; bool enableMeshShading = false; + bool isVisible = true; + bool culledByLod = false; MeshDebugFlags debugFlags; }; diff --git a/runtime/render/core/src/lod/MeshLodGroup.cpp b/runtime/render/core/src/lod/MeshLodGroup.cpp new file mode 100644 index 00000000..9f1da30c --- /dev/null +++ b/runtime/render/core/src/lod/MeshLodGroup.cpp @@ -0,0 +1,30 @@ +// +// Created by blues on 2025/2/16. +// + +#include + +namespace sky { + + void MeshLodGroup::AddLodMesh(const RDMeshPtr &mesh, float screenSize) + { + lodMeshes.emplace_back(mesh); + config.lodLevels.emplace_back(LodLevel{screenSize}); + } + + void MeshLodGroup::SetLodBias(float bias) + { + config.lodBias = bias; + } + + void MeshLodGroup::PreComputeDistances(float sphereRadius, const Matrix4 &projMatrix) + { + sky::PreComputeDistances(config, sphereRadius, projMatrix); + } + + uint32_t MeshLodGroup::SelectLod(const AABB &worldBound, const Vector3 &viewPos) const + { + return SelectLodLevel(config, worldBound, viewPos); + } + +} // namespace sky diff --git a/runtime/render/core/src/mesh/MeshFeatureProcessor.cpp b/runtime/render/core/src/mesh/MeshFeatureProcessor.cpp index 851803a9..617b709b 100644 --- a/runtime/render/core/src/mesh/MeshFeatureProcessor.cpp +++ b/runtime/render/core/src/mesh/MeshFeatureProcessor.cpp @@ -3,11 +3,16 @@ // #include +#include +#include +#include namespace sky { void MeshFeatureProcessor::Tick(float time) { + UpdateLod(); + for (auto &mesh : staticMeshes) { mesh->Tick(); } @@ -17,6 +22,21 @@ namespace sky { } } + void MeshFeatureProcessor::UpdateLod() + { + auto *view = scene->GetSceneView(mainViewName); + if (view == nullptr) { + return; + } + + const auto &worldMat = view->GetWorld(); + Vector3 viewPos(worldMat[3][0], worldMat[3][1], worldMat[3][2]); + + for (auto &mesh : staticMeshes) { + mesh->UpdateLod(viewPos); + } + } + void MeshFeatureProcessor::Render(rdg::RenderGraph &rdg) { } diff --git a/runtime/render/core/src/mesh/MeshRenderer.cpp b/runtime/render/core/src/mesh/MeshRenderer.cpp index 4cda00bb..92f9057c 100644 --- a/runtime/render/core/src/mesh/MeshRenderer.cpp +++ b/runtime/render/core/src/mesh/MeshRenderer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -144,6 +145,83 @@ namespace sky { scene->RemovePrimitive(meshletDebug->GetPrimitive()); meshletDebug = nullptr; } + isVisible = true; + culledByLod = false; + } + + void MeshRenderer::HidePrimitives() + { + if (!isVisible || scene == nullptr) { + return; + } + + for (auto &prim : primitives) { + scene->RemovePrimitive(prim.get()); + } + if (meshletDebug) { + scene->RemovePrimitive(meshletDebug->GetPrimitive()); + } + isVisible = false; + } + + void MeshRenderer::ShowPrimitives() + { + if (isVisible || scene == nullptr) { + return; + } + + for (auto &prim : primitives) { + scene->AddPrimitive(prim.get()); + } + if (meshletDebug && debugFlags.TestBit(MeshDebugFlagBit::MESHLET_CONE)) { + scene->AddPrimitive(meshletDebug->GetPrimitive()); + } + isVisible = true; + } + + void MeshRenderer::SetLodGroup(const RDMeshLodGroupPtr &lodGroup_) + { + lodGroup = lodGroup_; + currentLod = 0; + if (lodGroup && lodGroup->GetLodCount() > 0) { + SetMesh(lodGroup->GetMesh(0), enableMeshShading); + } + } + + void MeshRenderer::UpdateLod(const Vector3 &viewPos) + { + if (!lodGroup || lodGroup->GetLodCount() == 0 || primitives.empty()) { + return; + } + + AABB combinedBound = primitives[0]->worldBound; + for (uint32_t i = 1; i < static_cast(primitives.size()); ++i) { + Merge(combinedBound, primitives[i]->worldBound, combinedBound); + } + + uint32_t newLod = lodGroup->SelectLod(combinedBound, viewPos); + + if (newLod == INVALID_LOD_LEVEL) { + // Mesh is beyond all LOD thresholds — cull it + if (!culledByLod) { + culledByLod = true; + HidePrimitives(); + } + return; + } + + // Mesh is within LOD range — restore if previously culled + if (culledByLod) { + culledByLod = false; + ShowPrimitives(); + } + + if (newLod != currentLod && newLod < lodGroup->GetLodCount()) { + currentLod = newLod; + mesh = lodGroup->GetMesh(currentLod); + Reset(); + BuildGeometry(); + } } void MeshRenderer::SetMesh(const RDMeshPtr &mesh_, bool meshShading) diff --git a/test/core/LodTest.cpp b/test/core/LodTest.cpp new file mode 100644 index 00000000..d91cfb7d --- /dev/null +++ b/test/core/LodTest.cpp @@ -0,0 +1,536 @@ +// +// Created by blues on 2025/2/16. +// + +#include +#include +#include + +// Inline LOD functions for testing (duplicated from render/lod/LodGroup.h to avoid render dependency) +namespace sky { +namespace lod_test { + + static constexpr uint32_t INVALID_LOD_LEVEL = ~(0U); + + struct LodLevel { + float screenSize = 0.f; + }; + + struct LodConfig { + std::vector lodLevels; + std::vector distancesSq; + float lodBias = 1.0f; + }; + + // UE-style: extract screen-size multiplier from projection matrix + inline float GetScreenMultiple(const Matrix4 &projMatrix) + { + return std::max(0.5f * projMatrix[0][0], 0.5f * projMatrix[1][1]); + } + + // UE-style: ComputeBoundsScreenSize + inline float ComputeBoundsScreenSize(float sphereRadius, float dist, float screenMultiple) + { + float screenRadius = screenMultiple * sphereRadius / std::max(1.0f, dist); + return screenRadius * 2.0f; + } + + inline float CalculateScreenSize(const AABB &worldBound, const Vector3 &viewPos, const Matrix4 &projMatrix) + { + auto center = (worldBound.min + worldBound.max) * 0.5f; + auto extent = (worldBound.max - worldBound.min) * 0.5f; + float radius = extent.Length(); + + auto diff = center - viewPos; + float dist = diff.Length(); + + float screenMultiple = GetScreenMultiple(projMatrix); + return ComputeBoundsScreenSize(radius, dist, screenMultiple); + } + + inline float ScreenSizeToDistance(float screenSize, float sphereRadius, float screenMultiple) + { + if (screenSize <= 0.f) { + return std::numeric_limits::max(); + } + return (2.0f * screenMultiple * sphereRadius) / screenSize; + } + + inline void PreComputeDistances(LodConfig &config, float sphereRadius, const Matrix4 &projMatrix) + { + float screenMultiple = GetScreenMultiple(projMatrix); + config.distancesSq.resize(config.lodLevels.size()); + for (uint32_t i = 0; i < static_cast(config.lodLevels.size()); ++i) { + float d = ScreenSizeToDistance(config.lodLevels[i].screenSize, sphereRadius, screenMultiple); + config.distancesSq[i] = d * d; + } + } + + inline float CalculateDistanceSq(const AABB &worldBound, const Vector3 &viewPos) + { + auto center = (worldBound.min + worldBound.max) * 0.5f; + auto diff = center - viewPos; + return diff.x * diff.x + diff.y * diff.y + diff.z * diff.z; + } + + inline uint32_t SelectLodByScreenSize(const LodConfig &config, float screenSize) + { + float biasedSize = screenSize * config.lodBias; + for (uint32_t i = 0; i < static_cast(config.lodLevels.size()); ++i) { + if (biasedSize >= config.lodLevels[i].screenSize) { + return i; + } + } + return INVALID_LOD_LEVEL; + } + + inline uint32_t SelectLodLevel(const LodConfig &config, float screenSize) + { + return SelectLodByScreenSize(config, screenSize); + } + + inline uint32_t SelectLodLevel(const LodConfig &config, const AABB &worldBound, const Vector3 &viewPos) + { + if (config.distancesSq.empty()) { + return INVALID_LOD_LEVEL; + } + + float distSq = CalculateDistanceSq(worldBound, viewPos); + float biasInv = 1.0f / std::max(config.lodBias, 0.001f); + float biasedDistSq = distSq * biasInv * biasInv; + + for (uint32_t i = 0; i < static_cast(config.distancesSq.size()); ++i) { + if (biasedDistSq < config.distancesSq[i]) { + return i; + } + } + return INVALID_LOD_LEVEL; + } + +} // namespace lod_test +} // namespace sky + +using namespace sky; +using namespace sky::lod_test; + +TEST(LodTest, ScreenSizeCalculation) +{ + AABB bound(Vector3(-1, -1, -1), Vector3(1, 1, 1)); + auto projMatrix = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + + // Camera at the object center → screen size should be large (clamped by max(1,dist)) + { + Vector3 viewPos(0, 0, 0); + float size = CalculateScreenSize(bound, viewPos, projMatrix); + ASSERT_GT(size, 1.0f); + } + + // Camera far away → screen size should be small + { + Vector3 viewPos(0, 0, 100); + float size = CalculateScreenSize(bound, viewPos, projMatrix); + ASSERT_GT(size, 0.0f); + ASSERT_LT(size, 0.1f); + } + + // Camera nearby → screen size should be larger than far away + { + Vector3 viewPos(0, 0, 5); + float sizeNear = CalculateScreenSize(bound, viewPos, projMatrix); + Vector3 viewPosFar(0, 0, 50); + float sizeFar = CalculateScreenSize(bound, viewPosFar, projMatrix); + ASSERT_GT(sizeNear, sizeFar); + } +} + +TEST(LodTest, SelectLodLevelBasic) +{ + LodConfig config; + config.lodBias = 1.0f; + config.lodLevels = { + LodLevel{0.5f}, // LOD 0: screenSize >= 0.5 + LodLevel{0.25f}, // LOD 1: screenSize >= 0.25 + LodLevel{0.1f}, // LOD 2: screenSize >= 0.1 + LodLevel{0.0f}, // LOD 3: screenSize >= 0.0 (always matches as fallback) + }; + + // Large screen size → should select LOD 0 + ASSERT_EQ(SelectLodLevel(config, 0.8f), 0u); + + // Medium screen size → should select LOD 1 + ASSERT_EQ(SelectLodLevel(config, 0.3f), 1u); + + // Small screen size → should select LOD 2 + ASSERT_EQ(SelectLodLevel(config, 0.15f), 2u); + + // Very small screen size → should select LOD 3 + ASSERT_EQ(SelectLodLevel(config, 0.05f), 3u); + + // Exact boundary → should select that LOD + ASSERT_EQ(SelectLodLevel(config, 0.5f), 0u); + ASSERT_EQ(SelectLodLevel(config, 0.25f), 1u); + ASSERT_EQ(SelectLodLevel(config, 0.1f), 2u); +} + +TEST(LodTest, SelectLodLevelWithBias) +{ + LodConfig config; + config.lodLevels = { + LodLevel{0.5f}, + LodLevel{0.25f}, + LodLevel{0.1f}, + }; + + // Normal bias (1.0) + config.lodBias = 1.0f; + ASSERT_EQ(SelectLodLevel(config, 0.3f), 1u); + + // Higher bias (2.0) makes effective screen size larger, selecting higher quality LOD + config.lodBias = 2.0f; + ASSERT_EQ(SelectLodLevel(config, 0.3f), 0u); // 0.3 * 2.0 = 0.6 >= 0.5 + + // Lower bias (0.5) makes effective screen size smaller, selecting lower quality LOD + config.lodBias = 0.5f; + ASSERT_EQ(SelectLodLevel(config, 0.3f), 2u); // 0.3 * 0.5 = 0.15 >= 0.1 +} + +TEST(LodTest, SelectLodLevelEmpty) +{ + LodConfig config; + config.lodBias = 1.0f; + + // Empty config → should return INVALID + ASSERT_EQ(SelectLodLevel(config, 0.5f), INVALID_LOD_LEVEL); +} + +TEST(LodTest, SelectLodLevelSingleLevel) +{ + LodConfig config; + config.lodBias = 1.0f; + config.lodLevels = {LodLevel{0.0f}}; + + // Single level with 0 threshold → always matches + ASSERT_EQ(SelectLodLevel(config, 0.5f), 0u); + ASSERT_EQ(SelectLodLevel(config, 0.01f), 0u); + ASSERT_EQ(SelectLodLevel(config, 0.0f), 0u); +} + +TEST(LodTest, ScreenSizeDecreasesWithDistance) +{ + AABB bound(Vector3(-2, -2, -2), Vector3(2, 2, 2)); + auto projMatrix = MakePerspective(ToRadian(60.0f), 1.0f, 0.1f, 1000.f); + + float prevSize = std::numeric_limits::max(); + for (float dist = 10.0f; dist <= 100.0f; dist += 10.0f) { + Vector3 viewPos(0, 0, dist); + float size = CalculateScreenSize(bound, viewPos, projMatrix); + ASSERT_LT(size, prevSize); + prevSize = size; + } +} + +// ===== UE-style screen multiplier tests ===== + +TEST(LodTest, GetScreenMultipleFromProjMatrix) +{ + // 90° FOV, aspect 1.0: inverseHalfTan = 1/tan(45°) = 1.0 + auto proj90 = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + float sm90 = GetScreenMultiple(proj90); + ASSERT_NEAR(sm90, 0.5f, 0.001f); // max(0.5*1.0, 0.5*1.0) = 0.5 + + // 60° FOV, aspect 1.0: inverseHalfTan = 1/tan(30°) ≈ 1.732 + auto proj60 = MakePerspective(ToRadian(60.0f), 1.0f, 0.1f, 1000.f); + float sm60 = GetScreenMultiple(proj60); + ASSERT_GT(sm60, sm90); // Narrower FOV → larger screen multiple + + // With aspect != 1.0 + auto projWide = MakePerspective(ToRadian(90.0f), 16.0f/9.0f, 0.1f, 1000.f); + float smWide = GetScreenMultiple(projWide); + // [0][0] = inverseHalfTan / aspect < inverseHalfTan = [1][1] + // max(0.5*[0][0], 0.5*[1][1]) = 0.5 * [1][1] = 0.5 + ASSERT_NEAR(smWide, 0.5f, 0.001f); +} + +TEST(LodTest, ScreenSizeToDistanceConversion) +{ + auto projMatrix = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + float screenMultiple = GetScreenMultiple(projMatrix); + float radius = 1.7320508f; // sqrt(3) ~ extent of unit AABB + + // dist = 2 * screenMultiple * radius / screenSize + { + float d = ScreenSizeToDistance(0.5f, radius, screenMultiple); + float expected = (2.0f * screenMultiple * radius) / 0.5f; + ASSERT_FLOAT_EQ(d, expected); + } + + // screenSize 0 → infinite distance + { + float d = ScreenSizeToDistance(0.0f, radius, screenMultiple); + ASSERT_EQ(d, std::numeric_limits::max()); + } + + // Smaller screenSize → larger distance + { + float d1 = ScreenSizeToDistance(0.5f, radius, screenMultiple); + float d2 = ScreenSizeToDistance(0.25f, radius, screenMultiple); + ASSERT_GT(d2, d1); + } +} + +TEST(LodTest, PreComputeDistancesMatchesScreenSize) +{ + AABB bound(Vector3(-1, -1, -1), Vector3(1, 1, 1)); + auto extent = (bound.max - bound.min) * 0.5f; + float radius = extent.Length(); + auto projMatrix = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + + LodConfig config; + config.lodBias = 1.0f; + config.lodLevels = { + LodLevel{0.5f}, + LodLevel{0.25f}, + LodLevel{0.1f}, + LodLevel{0.0f}, + }; + + PreComputeDistances(config, radius, projMatrix); + + // Test at various distances: both methods should agree + for (float dist = 5.0f; dist <= 200.0f; dist += 5.0f) { + Vector3 viewPos(0, 0, dist); + float screenSize = CalculateScreenSize(bound, viewPos, projMatrix); + + uint32_t lodByScreen = SelectLodByScreenSize(config, screenSize); + uint32_t lodByDist = SelectLodLevel(config, bound, viewPos); + + // For the fallback LOD (screenSize=0 → infinite distance), distance-based + // returns INVALID while screen-size returns 3. Both are valid. + if (lodByScreen == 3u && lodByDist == INVALID_LOD_LEVEL) { + continue; + } + ASSERT_EQ(lodByScreen, lodByDist) + << "Mismatch at distance " << dist + << " (screenSize=" << screenSize << ")"; + } +} + +TEST(LodTest, PreComputeDistancesWithBias) +{ + AABB bound(Vector3(-1, -1, -1), Vector3(1, 1, 1)); + auto extent = (bound.max - bound.min) * 0.5f; + float radius = extent.Length(); + auto projMatrix = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + + LodConfig config; + config.lodLevels = { + LodLevel{0.5f}, + LodLevel{0.25f}, + LodLevel{0.1f}, + }; + PreComputeDistances(config, radius, projMatrix); + + Vector3 viewPos(0, 0, 10); + + // Normal bias + config.lodBias = 1.0f; + uint32_t lodNormal = SelectLodLevel(config, bound, viewPos); + + // Higher bias → should select higher quality (lower LOD index) + config.lodBias = 2.0f; + uint32_t lodHigh = SelectLodLevel(config, bound, viewPos); + ASSERT_LE(lodHigh, lodNormal); + + // Lower bias → should select lower quality (higher LOD index) + config.lodBias = 0.5f; + uint32_t lodLow = SelectLodLevel(config, bound, viewPos); + ASSERT_GE(lodLow, lodNormal); +} + +TEST(LodTest, PreComputeDistancesEmpty) +{ + LodConfig config; + config.lodBias = 1.0f; + + AABB bound(Vector3(-1, -1, -1), Vector3(1, 1, 1)); + Vector3 viewPos(0, 0, 10); + + // No pre-computed distances → should return INVALID + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), INVALID_LOD_LEVEL); +} + +TEST(LodTest, RuntimeSelectionOnlyUsesDistance) +{ + AABB bound(Vector3(-2, -2, -2), Vector3(2, 2, 2)); + auto extent = (bound.max - bound.min) * 0.5f; + float radius = extent.Length(); + auto projMatrix = MakePerspective(ToRadian(60.0f), 1.0f, 0.1f, 1000.f); + + LodConfig config; + config.lodBias = 1.0f; + config.lodLevels = { + LodLevel{0.6f}, + LodLevel{0.3f}, + LodLevel{0.1f}, + }; + PreComputeDistances(config, radius, projMatrix); + + // Close camera → LOD 0 + { + Vector3 viewPos(0, 0, 5); + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), 0u); + } + + // Medium distance → LOD 1 + { + Vector3 viewPos(0, 0, 20); + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), 1u); + } + + // Far distance → LOD 2 + { + Vector3 viewPos(0, 0, 80); + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), 2u); + } +} + +TEST(LodTest, DifferentAspectRatios) +{ + // Verify that screen size handles different aspect ratios correctly + AABB bound(Vector3(-1, -1, -1), Vector3(1, 1, 1)); + Vector3 viewPos(0, 0, 20); + + auto proj1to1 = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + auto proj16to9 = MakePerspective(ToRadian(90.0f), 16.0f/9.0f, 0.1f, 1000.f); + + float size1to1 = CalculateScreenSize(bound, viewPos, proj1to1); + float size16to9 = CalculateScreenSize(bound, viewPos, proj16to9); + + // Both should be positive + ASSERT_GT(size1to1, 0.0f); + ASSERT_GT(size16to9, 0.0f); + + // For wider aspect, [1][1] is the same but [0][0] is smaller + // GetScreenMultiple uses max(), so same FOV → same screen multiple + // when [1][1] >= [0][0] + ASSERT_FLOAT_EQ(size1to1, size16to9); +} + +// ===== LOD culling tests ===== + +TEST(LodTest, LodCullingWhenBeyondAllThresholds) +{ + // When no LOD level has a screenSize=0 fallback, objects beyond all + // thresholds should return INVALID_LOD_LEVEL (mesh should be culled) + AABB bound(Vector3(-1, -1, -1), Vector3(1, 1, 1)); + auto extent = (bound.max - bound.min) * 0.5f; + float radius = extent.Length(); + auto projMatrix = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + + LodConfig config; + config.lodBias = 1.0f; + config.lodLevels = { + LodLevel{0.5f}, // LOD 0: close + LodLevel{0.25f}, // LOD 1: medium + LodLevel{0.1f}, // LOD 2: far (but not infinite) + }; + PreComputeDistances(config, radius, projMatrix); + + // Close → valid LOD + { + Vector3 viewPos(0, 0, 5); + uint32_t lod = SelectLodLevel(config, bound, viewPos); + ASSERT_NE(lod, INVALID_LOD_LEVEL); + } + + // Very far → should return INVALID_LOD_LEVEL (culled) + { + Vector3 viewPos(0, 0, 10000); + uint32_t lod = SelectLodLevel(config, bound, viewPos); + ASSERT_EQ(lod, INVALID_LOD_LEVEL); + } +} + +TEST(LodTest, LodCullingTransition) +{ + // Test the transition from visible to culled and back + AABB bound(Vector3(-1, -1, -1), Vector3(1, 1, 1)); + auto extent = (bound.max - bound.min) * 0.5f; + float radius = extent.Length(); + auto projMatrix = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + + LodConfig config; + config.lodBias = 1.0f; + config.lodLevels = { + LodLevel{0.5f}, + LodLevel{0.1f}, + }; + PreComputeDistances(config, radius, projMatrix); + + // Moving away: LOD 0 → LOD 1 → CULLED + { + Vector3 viewPos(0, 0, 2); + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), 0u); + } + { + Vector3 viewPos(0, 0, 10); + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), 1u); + } + { + Vector3 viewPos(0, 0, 10000); + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), INVALID_LOD_LEVEL); + } + + // Moving back: CULLED → LOD 1 → LOD 0 (streaming back in) + { + Vector3 viewPos(0, 0, 10); + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), 1u); + } + { + Vector3 viewPos(0, 0, 2); + ASSERT_EQ(SelectLodLevel(config, bound, viewPos), 0u); + } +} + +TEST(LodTest, LodCullingWithScreenSizeFallback) +{ + // When the last LOD has screenSize=0 (fallback), it should always + // match — no culling occurs + LodConfig config; + config.lodBias = 1.0f; + config.lodLevels = { + LodLevel{0.5f}, + LodLevel{0.0f}, // fallback: always matches via screen-size + }; + + // Screen-size based: even tiny sizes match the fallback + ASSERT_EQ(SelectLodLevel(config, 0.001f), 1u); + ASSERT_EQ(SelectLodLevel(config, 0.0f), 1u); +} + +TEST(LodTest, LodCullingWithBias) +{ + // Bias should affect the culling threshold + AABB bound(Vector3(-1, -1, -1), Vector3(1, 1, 1)); + auto extent = (bound.max - bound.min) * 0.5f; + float radius = extent.Length(); + auto projMatrix = MakePerspective(ToRadian(90.0f), 1.0f, 0.1f, 1000.f); + + LodConfig config; + config.lodLevels = { + LodLevel{0.5f}, + LodLevel{0.1f}, + }; + PreComputeDistances(config, radius, projMatrix); + + // Find a distance where it's culled with bias=1.0 + Vector3 farViewPos(0, 0, 10000); + config.lodBias = 1.0f; + ASSERT_EQ(SelectLodLevel(config, bound, farViewPos), INVALID_LOD_LEVEL); + + // With higher bias, the same distance may no longer be culled + // (higher bias = keep higher quality longer = extend visible range) + config.lodBias = 100.0f; + uint32_t lodHighBias = SelectLodLevel(config, bound, farViewPos); + ASSERT_NE(lodHighBias, INVALID_LOD_LEVEL); +}