From 78a851c8bbc2de1383063cfe7261142827748b37 Mon Sep 17 00:00:00 2001 From: gameKnife Date: Wed, 13 May 2026 07:48:41 +0800 Subject: [PATCH 01/27] Brotato3D Tweaks Hit FX Tweaks UI Tweaks --- ldraw_loader_6611990911200.mpd | 5 + .../Brotato3D/Brotato3DCombatSystem.cpp | 20 +- .../Brotato3D/Brotato3DDebrisSystem.cpp | 15 +- .../Brotato3D/Brotato3DEffectSystem.cpp | 2 + src/Application/Brotato3D/Brotato3DEnemy.hpp | 9 + .../Brotato3D/Brotato3DEnemySystem.cpp | 187 +++++++++- .../Brotato3D/Brotato3DGameInstance.hpp | 11 +- .../Brotato3D/Brotato3DPlayerSystem.cpp | 2 +- .../Brotato3D/Brotato3DProjectileSystem.cpp | 14 +- src/Application/Brotato3D/Brotato3DUI.cpp | 325 ++++++++++++------ 10 files changed, 459 insertions(+), 131 deletions(-) create mode 100644 ldraw_loader_6611990911200.mpd diff --git a/ldraw_loader_6611990911200.mpd b/ldraw_loader_6611990911200.mpd new file mode 100644 index 00000000..e1761dca --- /dev/null +++ b/ldraw_loader_6611990911200.mpd @@ -0,0 +1,5 @@ +0 FILE main.ldr +1 16 10 20 30 1 0 0 0 1 0 0 0 1 brick.dat +0 FILE brick.dat +0 BFC CERTIFY CCW +3 16 0 0 0 2 4 6 0 2 0 diff --git a/src/Application/Brotato3D/Brotato3DCombatSystem.cpp b/src/Application/Brotato3D/Brotato3DCombatSystem.cpp index 3e70905e..15348e51 100644 --- a/src/Application/Brotato3D/Brotato3DCombatSystem.cpp +++ b/src/Application/Brotato3D/Brotato3DCombatSystem.cpp @@ -21,12 +21,13 @@ int Brotato3DGameInstance::ApplyDamageToEnemy(Brotato3D::FEnemyRuntime& enemy, i enemy.currentHp -= effectiveDamage; Brotato3D::PlayHitSfx(effectiveDamage, isCrit); enemy.hitFlashRemainingMs = 80.0f; - NodeUtils::SetPrimaryMaterial(enemy.node, enemy.hitFlashMaterialId); + SetEnemyVisualMaterial(enemy, enemy.hitFlashMaterialId); + BreakEnemyBodyBlocks(enemy, effectiveDamage); PushFloatingText(enemy.worldPos + glm::vec3(0.0f, 0.8f, 0.0f), isCrit ? fmt::format("!{}", effectiveDamage) : fmt::format("-{}", effectiveDamage), isCrit ? glm::vec4(1.0f, 0.78f, 0.12f, 1.0f) : glm::vec4(1.0f, 0.25f, 0.18f, 1.0f), 600.0f, - isCrit ? 1.4f : 1.0f); + isCrit ? 1.55f : 1.15f); if (enemy.currentHp <= 0) { if (isCrit) @@ -199,16 +200,23 @@ void Brotato3DGameInstance::ApplyItemExplosionDamage(const glm::vec3& worldPos, continue; } - enemy.currentHp -= damage; + const int effectiveDamage = std::min(damage, std::max(0, enemy.currentHp)); + if (effectiveDamage <= 0) + { + continue; + } + + enemy.currentHp -= effectiveDamage; const glm::vec3 blastDir(enemy.worldPos.x - worldPos.x, 0.0f, enemy.worldPos.z - worldPos.z); if (glm::length(blastDir) > 0.001f) { enemy.lastHitDebrisDir = glm::normalize(blastDir); } enemy.hitFlashRemainingMs = 80.0f; - NodeUtils::SetPrimaryMaterial(enemy.node, enemy.hitFlashMaterialId); - PushFloatingText(enemy.worldPos + glm::vec3(0.0f, 0.8f, 0.0f), fmt::format("-{}", damage), - glm::vec4(1.0f, 0.5f, 0.18f, 1.0f), 600.0f); + SetEnemyVisualMaterial(enemy, enemy.hitFlashMaterialId); + BreakEnemyBodyBlocks(enemy, effectiveDamage); + PushFloatingText(enemy.worldPos + glm::vec3(0.0f, 0.8f, 0.0f), fmt::format("-{}", effectiveDamage), + glm::vec4(1.0f, 0.5f, 0.18f, 1.0f), 600.0f, 1.15f); if (enemy.currentHp <= 0) { KillEnemy(enemy, false); diff --git a/src/Application/Brotato3D/Brotato3DDebrisSystem.cpp b/src/Application/Brotato3D/Brotato3DDebrisSystem.cpp index bff241aa..7d996456 100644 --- a/src/Application/Brotato3D/Brotato3DDebrisSystem.cpp +++ b/src/Application/Brotato3D/Brotato3DDebrisSystem.cpp @@ -613,20 +613,12 @@ void Brotato3DGameInstance::UpdateDebris(double deltaSeconds) Brotato3D::PlayPickupMaterialSfx(); player_.materials += slot.payloadValue; totalMaterialsGained_ += slot.payloadValue; - PushFloatingText(player_.worldPos + glm::vec3(0.0f, 0.2f, 0.0f), - fmt::format("+{} MAT", slot.payloadValue), - glm::vec4(1.0f, 0.85f, 0.15f, 1.0f), - 500.0f); spdlog::info("[Brotato3D] Materials {}", player_.materials); } else if (slot.payload == Brotato3D::EDebrisPayload::Xp) { Brotato3D::PlayPickupXpSfx(); player_.currentXp += slot.payloadValue; - PushFloatingText(player_.worldPos + glm::vec3(0.0f, 0.6f, 0.0f), - fmt::format("+{} XP", slot.payloadValue), - glm::vec4(0.2f, 1.0f, 0.35f, 1.0f), - 500.0f); while (player_.currentXp >= GetXpToNextLevel()) { player_.currentXp -= GetXpToNextLevel(); @@ -675,7 +667,10 @@ void Brotato3DGameInstance::ClearAllDebris(bool keepPickable) } } -void Brotato3DGameInstance::SpawnHitXpDebris(const glm::vec3& worldPos, const glm::vec3& projectileDir, int damage) +void Brotato3DGameInstance::SpawnHitXpDebris(const glm::vec3& worldPos, + const glm::vec3& projectileDir, + int damage, + uint32_t materialId) { if (damage <= 0) { @@ -697,7 +692,7 @@ void Brotato3DGameInstance::SpawnHitXpDebris(const glm::vec3& worldPos, const gl glm::vec3(spawnPos.x, std::max(0.25f, spawnPos.y), spawnPos.z), impulseDir, HitXpSpeed, - xpDebrisMatId_ != 0 ? xpDebrisMatId_ : debrisFallbackTinyMatId_, + materialId != 0 ? materialId : (xpDebrisMatId_ != 0 ? xpDebrisMatId_ : debrisFallbackTinyMatId_), count, HitXpConeRad, Brotato3D::EDebrisPayload::Xp, diff --git a/src/Application/Brotato3D/Brotato3DEffectSystem.cpp b/src/Application/Brotato3D/Brotato3DEffectSystem.cpp index 064ad62c..dead35ff 100644 --- a/src/Application/Brotato3D/Brotato3DEffectSystem.cpp +++ b/src/Application/Brotato3D/Brotato3DEffectSystem.cpp @@ -300,6 +300,8 @@ void Brotato3DGameInstance::BeforeSceneRebuild(std::vector(models.size() - 1); + models.push_back(Assets::FProcModel::CreateBox(glm::vec3(-0.5f), glm::vec3(0.5f))); + visual.bodyBlockModelId = static_cast(models.size() - 1); visual.materialId = SceneBuilder::AddLambertianMaterial(materials, def.color); visual.darkMaterialId = SceneBuilder::AddLambertianMaterial(materials, def.color * 0.4f); visual.hitFlashMaterialId = SceneBuilder::AddLambertianMaterial(materials, glm::vec3(1.0f)); diff --git a/src/Application/Brotato3D/Brotato3DEnemy.hpp b/src/Application/Brotato3D/Brotato3DEnemy.hpp index 1eaaebb5..9d138a61 100644 --- a/src/Application/Brotato3D/Brotato3DEnemy.hpp +++ b/src/Application/Brotato3D/Brotato3DEnemy.hpp @@ -11,6 +11,13 @@ namespace Assets namespace Brotato3D { + struct FEnemyBodyBlockRuntime + { + std::shared_ptr node; + glm::vec3 localOffset = glm::vec3(0.0f); + bool visible = true; + }; + enum class ELanceState : uint8_t { Idle, @@ -55,6 +62,8 @@ namespace Brotato3D uint32_t hitFlashMaterialId = 0; uint32_t warningMaterialId = 0; uint32_t phase2MaterialId = 0; + std::vector bodyBlocks; + int bodyBlocksLost = 0; NextBodyID kinematicBodyId{}; bool kinematicBodyActive = false; std::shared_ptr node; diff --git a/src/Application/Brotato3D/Brotato3DEnemySystem.cpp b/src/Application/Brotato3D/Brotato3DEnemySystem.cpp index fbe2ee3f..fdcb5d05 100644 --- a/src/Application/Brotato3D/Brotato3DEnemySystem.cpp +++ b/src/Application/Brotato3D/Brotato3DEnemySystem.cpp @@ -2,6 +2,7 @@ #include "Brotato3DCommon.hpp" #include "Assets/Core/Node.h" +#include "Runtime/Components/PhysicsComponent.h" #include "Runtime/Components/RenderComponent.h" #include @@ -11,6 +12,9 @@ using namespace Brotato3DUtil; namespace { + constexpr float BodyBlockFillScale = 1.02f; + constexpr float BodyBlockMinExtent = 0.08f; + glm::vec3 ResolveEnemyDebrisDir(const Brotato3D::FEnemyRuntime& enemy, const glm::vec3& fallbackDir) { glm::vec3 dir(enemy.lastHitDebrisDir.x, 0.0f, enemy.lastHitDebrisDir.z); @@ -24,6 +28,164 @@ namespace } return glm::normalize(dir); } + + int CountBodyBlocksOnAxis(float size, bool boss, bool vertical) + { + if (boss) + { + return vertical ? 4 : 3; + } + const float threshold = vertical ? 0.65f : 0.75f; + return size >= threshold ? 3 : 2; + } +} + +void Brotato3DGameInstance::CreateEnemyBodyBlocks(Brotato3D::FEnemyRuntime& enemy, + const FEnemyVisualResource& visual, + const std::string& enemyId) +{ + if (!enemy.node || !enemy.def || visual.bodyBlockModelId == 0) + { + return; + } + + enemy.bodyBlocks.clear(); + enemy.bodyBlocksLost = 0; + const bool boss = enemy.def->boss.enabled; + const glm::ivec3 grid(CountBodyBlocksOnAxis(enemy.def->size.x, boss, false), + CountBodyBlocksOnAxis(enemy.def->size.y, boss, true), + CountBodyBlocksOnAxis(enemy.def->size.z, boss, false)); + const glm::vec3 cellSize = enemy.def->size / glm::vec3(grid); + const glm::vec3 blockScale = glm::max(cellSize * BodyBlockFillScale, glm::vec3(BodyBlockMinExtent)); + auto& scene = GetEngine().GetScene(); + + enemy.bodyBlocks.reserve(static_cast(grid.x * grid.y * grid.z)); + for (int y = 0; y < grid.y; ++y) + { + for (int z = 0; z < grid.z; ++z) + { + for (int x = 0; x < grid.x; ++x) + { + const glm::vec3 localOffset = -enemy.def->size * 0.5f + + cellSize * (glm::vec3(static_cast(x), static_cast(y), static_cast(z)) + glm::vec3(0.5f)); + auto blockNode = SceneBuilder::CreateRenderNode( + fmt::format("Brotato3D_EnemyBlock_{}_{}_{}_{}", enemyId, enemy.runtimeTag, enemy.bodyBlocks.size(), enemy.def->name), + localOffset, + blockScale, + scene.GenerateInstanceId(), + visual.bodyBlockModelId, + enemy.materialId, + true); + + auto physicsComponent = std::make_shared(); + physicsComponent->SetMobility(Runtime::ENodeMobility::Dynamic); + blockNode->AddComponent(physicsComponent); + blockNode->SetParent(enemy.node); + NodeUtils::SetOutlineFlags(blockNode, Runtime::RenderOutlineFlags::danger); + scene.AddNode(blockNode); + + Brotato3D::FEnemyBodyBlockRuntime block{}; + block.node = blockNode; + block.localOffset = localOffset; + block.visible = true; + enemy.bodyBlocks.push_back(block); + } + } + } + NodeUtils::SetVisible(enemy.node, false); + scene.MarkDirty(); +} + +void Brotato3DGameInstance::ResetEnemyBodyBlocks(Brotato3D::FEnemyRuntime& enemy) +{ + enemy.bodyBlocksLost = 0; + for (Brotato3D::FEnemyBodyBlockRuntime& block : enemy.bodyBlocks) + { + block.visible = true; + if (block.node) + { + block.node->SetTranslation(block.localOffset); + block.node->SetRotation(glm::quat(1.0f, 0.0f, 0.0f, 0.0f)); + NodeUtils::SetVisible(block.node, true); + } + } + SetEnemyVisualMaterial(enemy, enemy.materialId); + SetEnemyVisualOutlineFlags(enemy, Runtime::RenderOutlineFlags::danger); + SetEnemyVisualVisible(enemy, true); +} + +void Brotato3DGameInstance::SetEnemyVisualVisible(Brotato3D::FEnemyRuntime& enemy, bool visible) +{ + if (!enemy.node) + { + return; + } + + NodeUtils::SetVisible(enemy.node, visible && enemy.bodyBlocks.empty()); + for (Brotato3D::FEnemyBodyBlockRuntime& block : enemy.bodyBlocks) + { + NodeUtils::SetVisible(block.node, visible && block.visible); + } +} + +void Brotato3DGameInstance::SetEnemyVisualMaterial(const Brotato3D::FEnemyRuntime& enemy, uint32_t materialId) +{ + NodeUtils::SetPrimaryMaterial(enemy.node, materialId); + for (const Brotato3D::FEnemyBodyBlockRuntime& block : enemy.bodyBlocks) + { + NodeUtils::SetPrimaryMaterial(block.node, materialId); + } +} + +void Brotato3DGameInstance::SetEnemyVisualOutlineFlags(const Brotato3D::FEnemyRuntime& enemy, uint32_t outlineFlags) +{ + NodeUtils::SetOutlineFlags(enemy.node, outlineFlags); + for (const Brotato3D::FEnemyBodyBlockRuntime& block : enemy.bodyBlocks) + { + NodeUtils::SetOutlineFlags(block.node, outlineFlags); + } +} + +void Brotato3DGameInstance::BreakEnemyBodyBlocks(Brotato3D::FEnemyRuntime& enemy, int damage) +{ + if (damage <= 0 || enemy.bodyBlocks.empty() || enemy.maxHp <= 0) + { + return; + } + + const int totalBlocks = static_cast(enemy.bodyBlocks.size()); + const float hpRatio = std::clamp(static_cast(std::max(0, enemy.currentHp)) / static_cast(enemy.maxHp), 0.0f, 1.0f); + const int targetLost = enemy.currentHp <= 0 ? totalBlocks : + std::clamp(static_cast(std::floor((1.0f - hpRatio) * static_cast(totalBlocks))), 0, totalBlocks); + int dropsRemaining = std::max(0, targetLost - enemy.bodyBlocksLost); + if (dropsRemaining <= 0) + { + return; + } + + while (dropsRemaining > 0) + { + std::vector visibleIndices; + visibleIndices.reserve(enemy.bodyBlocks.size()); + for (size_t index = 0; index < enemy.bodyBlocks.size(); ++index) + { + if (enemy.bodyBlocks[index].visible) + { + visibleIndices.push_back(index); + } + } + if (visibleIndices.empty()) + { + break; + } + + std::uniform_int_distribution indexDist(0, visibleIndices.size() - 1); + Brotato3D::FEnemyBodyBlockRuntime& block = enemy.bodyBlocks[visibleIndices[indexDist(rng_)]]; + block.visible = false; + NodeUtils::SetVisible(block.node, false); + ++enemy.bodyBlocksLost; + --dropsRemaining; + } } void Brotato3DGameInstance::SpawnEnemy(const std::string& enemyId, const glm::vec3& worldPos) @@ -62,15 +224,18 @@ void Brotato3DGameInstance::SpawnEnemy(const std::string& enemyId, const glm::ve { const uint32_t runtimeTag = static_cast(std::distance(enemies_.begin(), reusableEnemy)) + 1U; enemy.node = reusableEnemy->node; + enemy.bodyBlocks = std::move(reusableEnemy->bodyBlocks); enemy.kinematicBodyId = reusableEnemy->kinematicBodyId.IsInvalid() ? AcquireEnemyKinematicBody(enemyId) : reusableEnemy->kinematicBodyId; enemy.runtimeTag = runtimeTag; - *reusableEnemy = enemy; - NodeUtils::SetPrimaryMaterial(reusableEnemy->node, reusableEnemy->materialId); - NodeUtils::SetOutlineFlags(reusableEnemy->node, Runtime::RenderOutlineFlags::danger); + *reusableEnemy = std::move(enemy); + if (reusableEnemy->bodyBlocks.empty()) + { + CreateEnemyBodyBlocks(*reusableEnemy, visual, enemyId); + } + ResetEnemyBodyBlocks(*reusableEnemy); reusableEnemy->node->SetTranslation(reusableEnemy->worldPos); reusableEnemy->node->SetScale(glm::vec3(1.0f)); - NodeUtils::SetVisible(reusableEnemy->node, true); // Spawn activation is a positional snap; use a stable fallback step for the kinematic body. SyncEnemyKinematicBody(*reusableEnemy, 1.0 / 60.0); return; @@ -78,12 +243,13 @@ void Brotato3DGameInstance::SpawnEnemy(const std::string& enemyId, const glm::ve enemy.kinematicBodyId = AcquireEnemyKinematicBody(enemyId); enemy.node = SceneBuilder::CreateRenderNode(fmt::format("Brotato3D_Enemy_{}_{}", enemyId, enemies_.size()), spawnPos, glm::vec3(1.0f), - GetEngine().GetScene().GenerateInstanceId(), visual.modelId, visual.materialId); - NodeUtils::SetOutlineFlags(enemy.node, Runtime::RenderOutlineFlags::danger); + GetEngine().GetScene().GenerateInstanceId(), visual.modelId, visual.materialId, false); GetEngine().GetScene().AddNode(enemy.node); - GetEngine().GetScene().MarkDirty(); enemies_.push_back(enemy); enemies_.back().runtimeTag = static_cast(enemies_.size()); + CreateEnemyBodyBlocks(enemies_.back(), visual, enemyId); + ResetEnemyBodyBlocks(enemies_.back()); + GetEngine().GetScene().MarkDirty(); // Spawn activation is a positional snap; use a stable fallback step for the kinematic body. SyncEnemyKinematicBody(enemies_.back(), 1.0 / 60.0); } @@ -134,7 +300,7 @@ void Brotato3DGameInstance::UpdateEnemies(double deltaSeconds) { activeMaterial = enemy.warningMaterialId; } - NodeUtils::SetPrimaryMaterial(enemy.node, activeMaterial); + SetEnemyVisualMaterial(enemy, activeMaterial); if (enemy.def->bomb.enabled && enemy.bombFuseMs >= 0.0f) { @@ -153,7 +319,7 @@ void Brotato3DGameInstance::UpdateEnemies(double deltaSeconds) enemy.alive = false; enemy.fading = false; DeactivateEnemyKinematicBody(enemy); - NodeUtils::SetVisible(enemy.node, false); + SetEnemyVisualVisible(enemy, false); continue; } SyncEnemyKinematicBody(enemy, deltaSeconds); @@ -503,6 +669,7 @@ void Brotato3DGameInstance::KillEnemy(Brotato3D::FEnemyRuntime& enemy, bool drop enemy.fading = false; enemy.deathFadeMs = 0.0f; enemy.node->SetScale(glm::vec3(1.0f)); + SetEnemyVisualVisible(enemy, true); if (dropLoot) { ++killCount_; @@ -560,7 +727,7 @@ void Brotato3DGameInstance::KillEnemy(Brotato3D::FEnemyRuntime& enemy, bool drop bossKillFlashMs_ = 100.0f; timeScaleRecoveryMs_ = 1200.0f; } - NodeUtils::SetVisible(enemy.node, false); + SetEnemyVisualVisible(enemy, false); } void Brotato3DGameInstance::ClearAliveEnemies(bool dropLoot) diff --git a/src/Application/Brotato3D/Brotato3DGameInstance.hpp b/src/Application/Brotato3D/Brotato3DGameInstance.hpp index ef61cc2e..86c140b8 100644 --- a/src/Application/Brotato3D/Brotato3DGameInstance.hpp +++ b/src/Application/Brotato3D/Brotato3DGameInstance.hpp @@ -153,6 +153,7 @@ class Brotato3DGameInstance : public NextGameInstanceBase uint32_t hitFlashMaterialId = 0; uint32_t warningMaterialId = 0; uint32_t phase2MaterialId = 0; + uint32_t bodyBlockModelId = 0; glm::vec3 baseColor = glm::vec3(1.0f); }; @@ -174,6 +175,14 @@ class Brotato3DGameInstance : public NextGameInstanceBase int CalculateWeaponDamage(const Brotato3D::FWeaponDef& weaponDef, bool& outIsCrit); int ApplyDamageToEnemy(Brotato3D::FEnemyRuntime& enemy, int damage, bool isCrit); void ApplyWeaponKnockback(Brotato3D::FEnemyRuntime& enemy, const glm::vec3& direction, float knockbackMeters); + void CreateEnemyBodyBlocks(Brotato3D::FEnemyRuntime& enemy, + const FEnemyVisualResource& visual, + const std::string& enemyId); + void ResetEnemyBodyBlocks(Brotato3D::FEnemyRuntime& enemy); + void SetEnemyVisualVisible(Brotato3D::FEnemyRuntime& enemy, bool visible); + void SetEnemyVisualMaterial(const Brotato3D::FEnemyRuntime& enemy, uint32_t materialId); + void SetEnemyVisualOutlineFlags(const Brotato3D::FEnemyRuntime& enemy, uint32_t outlineFlags); + void BreakEnemyBodyBlocks(Brotato3D::FEnemyRuntime& enemy, int damage); void DamagePlayer(int damage, float shakeMs, float flashMs); void BuildDebrisPool(std::vector& models, std::vector& materials, @@ -192,7 +201,7 @@ class Brotato3DGameInstance : public NextGameInstanceBase int payloadValuePerSlot = 0, float lifetimeMs = 0.0f); void ClearAllDebris(bool keepPickable); - void SpawnHitXpDebris(const glm::vec3& worldPos, const glm::vec3& projectileDir, int damage); + void SpawnHitXpDebris(const glm::vec3& worldPos, const glm::vec3& projectileDir, int damage, uint32_t materialId); void SpawnKillMaterialDebris(const Brotato3D::FEnemyRuntime& enemy, int countMultiplier = 1, float spawnRadius = 0.45f, diff --git a/src/Application/Brotato3D/Brotato3DPlayerSystem.cpp b/src/Application/Brotato3D/Brotato3DPlayerSystem.cpp index 7ed31153..c315c0e6 100644 --- a/src/Application/Brotato3D/Brotato3DPlayerSystem.cpp +++ b/src/Application/Brotato3D/Brotato3DPlayerSystem.cpp @@ -421,7 +421,7 @@ void Brotato3DGameInstance::ResetRuntimeState() enemy.lanceDashDir = glm::vec3(0.0f); enemy.lanceDashStartPos = glm::vec3(0.0f); enemy.lanceDashRemainingDist = 0.0f; - NodeUtils::SetVisible(enemy.node, false); + SetEnemyVisualVisible(enemy, false); enemy.node->SetScale(glm::vec3(1.0f)); } player_ = Brotato3D::FPlayerRuntime{}; diff --git a/src/Application/Brotato3D/Brotato3DProjectileSystem.cpp b/src/Application/Brotato3D/Brotato3DProjectileSystem.cpp index 3c2c8b40..d4493192 100644 --- a/src/Application/Brotato3D/Brotato3DProjectileSystem.cpp +++ b/src/Application/Brotato3D/Brotato3DProjectileSystem.cpp @@ -113,8 +113,10 @@ void Brotato3DGameInstance::UpdateWeapons(double deltaSeconds) const glm::vec3 hitPos = target->worldPos + glm::vec3(0.0f, target->def ? target->def->size.y * 0.5f : 0.4f, 0.0f); target->lastHitDebrisDir = dir; ApplyWeaponKnockback(*target, dir, weapon.def->knockbackMeters); + const uint32_t hitMaterialId = target->bossPhase2Active && target->phase2MaterialId != 0 ? target->phase2MaterialId : + target->materialId; const int effectiveDamage = ApplyDamageToEnemy(*target, weaponDamage, isCrit); - SpawnHitXpDebris(hitPos, dir, effectiveDamage); + SpawnHitXpDebris(hitPos, dir, effectiveDamage, hitMaterialId); PushLaserBeam(player_.worldPos + glm::vec3(0.0f, 0.25f, 0.0f), hitPos, glm::vec4(weapon.def->projectileColor, 1.0f), @@ -222,9 +224,11 @@ void Brotato3DGameInstance::UpdateProjectiles(double deltaSeconds) enemy.lastHitDebrisDir = glm::normalize(glm::vec3(projectile.velocity.x, 0.0f, projectile.velocity.z)); } ApplyWeaponKnockback(enemy, projectile.velocity, projectile.knockbackMeters); + const uint32_t hitMaterialId = enemy.bossPhase2Active && enemy.phase2MaterialId != 0 ? enemy.phase2MaterialId : + enemy.materialId; const int effectiveDamage = ApplyDamageToEnemy(enemy, projectile.damage, projectile.isCrit); const glm::vec3 hitDir = glm::length(projectile.velocity) > 0.001f ? projectile.velocity : enemy.worldPos - player_.worldPos; - SpawnHitXpDebris(projectile.worldPos, hitDir, effectiveDamage); + SpawnHitXpDebris(projectile.worldPos, hitDir, effectiveDamage, hitMaterialId); if (projectile.explosionRadius > 0.0f && projectile.explosionDamage > 0) { @@ -253,11 +257,15 @@ void Brotato3DGameInstance::UpdateProjectiles(double deltaSeconds) aoeEnemy, glm::length(blastDir) > 0.001f ? blastDir : projectile.velocity, projectile.knockbackMeters * 0.65f); + const uint32_t aoeHitMaterialId = aoeEnemy.bossPhase2Active && aoeEnemy.phase2MaterialId != 0 ? + aoeEnemy.phase2MaterialId : + aoeEnemy.materialId; const int effectiveAoeDamage = ApplyDamageToEnemy(aoeEnemy, projectile.explosionDamage, false); const glm::vec3 impactDir = glm::length(blastDir) > 0.001f ? blastDir : projectile.velocity; SpawnHitXpDebris(aoeEnemy.worldPos + glm::vec3(0.0f, aoeEnemy.def ? aoeEnemy.def->size.y * 0.45f : 0.35f, 0.0f), impactDir, - effectiveAoeDamage); + effectiveAoeDamage, + aoeHitMaterialId); } } deactivate = true; diff --git a/src/Application/Brotato3D/Brotato3DUI.cpp b/src/Application/Brotato3D/Brotato3DUI.cpp index 74400ab9..fd68b440 100644 --- a/src/Application/Brotato3D/Brotato3DUI.cpp +++ b/src/Application/Brotato3D/Brotato3DUI.cpp @@ -56,6 +56,10 @@ namespace ImTextureID LoadHudTexture(Brotato3DGameInstance& gameInstance, const std::string& relPath) { + if (relPath.starts_with("panel_")) + { + return EmptyTexture(); + } return LoadUiTexture(gameInstance, Brotato3D::PlaceholderAssets::Hud(relPath)); } @@ -84,13 +88,79 @@ namespace return font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text.c_str()); } + void DrawOutlinedBoldText(ImDrawList* drawList, + ImFont* font, + float fontSize, + const ImVec2& pos, + ImU32 textColor, + ImU32 outlineColor, + const std::string& text, + float outlinePx, + float boldPx) + { + const std::array outlineOffsets = { + ImVec2(-outlinePx, 0.0f), + ImVec2(outlinePx, 0.0f), + ImVec2(0.0f, -outlinePx), + ImVec2(0.0f, outlinePx), + ImVec2(-outlinePx, -outlinePx), + ImVec2(outlinePx, -outlinePx), + ImVec2(-outlinePx, outlinePx), + ImVec2(outlinePx, outlinePx), + }; + + for (const ImVec2& offset : outlineOffsets) + { + drawList->AddText(font, fontSize, ImVec2(pos.x + offset.x, pos.y + offset.y), outlineColor, text.c_str()); + } + drawList->AddText(font, fontSize, ImVec2(pos.x - boldPx, pos.y), textColor, text.c_str()); + drawList->AddText(font, fontSize, ImVec2(pos.x + boldPx, pos.y), textColor, text.c_str()); + drawList->AddText(font, fontSize, pos, textColor, text.c_str()); + } + using NextUI::Painter::DrawBar; using NextUI::Painter::DrawFullscreenDim; using NextUI::Painter::DrawImageContain; using NextUI::Painter::DrawImageCover; - using NextUI::Painter::DrawPanel; using NextUI::Painter::DrawTexturedBar; + void DrawPanel(ImDrawList* drawList, + ImTextureID texture, + const ImVec2& min, + const ImVec2& max, + ImU32 fallbackColor, + float rounding, + ImU32 tint = IM_COL32_WHITE, + const ImVec2& textureSize = ImVec2(64.0f, 64.0f)) + { + (void)texture; + (void)tint; + (void)textureSize; + if (!drawList) + { + return; + } + + drawList->AddRectFilled(min, max, fallbackColor, rounding); + drawList->AddRect(min, max, IM_COL32(0, 0, 0, 230), rounding, 0, 2.0f); + } + + void DrawMaterialIcon(ImDrawList* drawList, const ImVec2& center, float radius, float scale) + { + const std::array points = { + ImVec2(center.x, center.y - radius), + ImVec2(center.x + radius * 0.86f, center.y), + ImVec2(center.x, center.y + radius), + ImVec2(center.x - radius * 0.86f, center.y), + }; + drawList->AddConvexPolyFilled(points.data(), static_cast(points.size()), IM_COL32(255, 204, 54, 255)); + drawList->AddPolyline(points.data(), static_cast(points.size()), IM_COL32(0, 0, 0, 235), true, 2.0f * scale); + drawList->AddCircleFilled(ImVec2(center.x - radius * 0.18f, center.y - radius * 0.18f), + radius * 0.22f, + IM_COL32(255, 244, 150, 220), + 12); + } + std::string FormatTime(float seconds) { const int total = std::max(0, static_cast(std::ceil(seconds))); @@ -874,9 +944,16 @@ namespace Brotato3D const FWaveDef* waveDef = waveSystem.GetCurrentWaveDef(); const ImTextureID panelNormal = LoadHudTexture(gameInstance, "panel_normal.png"); const ImTextureID panelFlat = LoadHudTexture(gameInstance, "panel_flat.png"); - - ImGui::SetNextWindowPos(Scale(8.0f, 8.0f, uiScale), ImGuiCond_Always); - ImGui::SetNextWindowSize(Scale(280.0f, 112.0f, uiScale), ImGuiCond_Always); + const bool suppressForegroundUi = gameInstance.GetAppState() == EAppState::LevelUpPicking; + + constexpr float bottomPanelHeight = 62.0f; + constexpr float bottomPanelMargin = 10.0f; + const ImU32 bottomPanelColor = IM_COL32(14, 16, 20, 128); + const ImVec2 statusPanelSize = Scale(430.0f, bottomPanelHeight, uiScale); + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + (viewport->Size.x - statusPanelSize.x) * 0.5f, + viewport->Pos.y + viewport->Size.y - (bottomPanelHeight + bottomPanelMargin) * uiScale), + ImGuiCond_Always); + ImGui::SetNextWindowSize(statusPanelSize, ImGuiCond_Always); ImGui::Begin("PlayerPanel", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoNav); @@ -884,102 +961,136 @@ namespace Brotato3D panelNormal, ImGui::GetWindowPos(), ImVec2(ImGui::GetWindowPos().x + ImGui::GetWindowSize().x, ImGui::GetWindowPos().y + ImGui::GetWindowSize().y), - IM_COL32(14, 16, 22, 210), + bottomPanelColor, 8.0f * uiScale, IM_COL32(78, 92, 110, 245)); ImGui::SetWindowFontScale(uiScale); - ImGui::SetCursorPos(Scale(18.0f, 14.0f, uiScale)); + ImGui::SetCursorPos(Scale(16.0f, 17.0f, uiScale)); DrawBar(ImGui::GetCursorScreenPos(), - Scale(244.0f, 24.0f, uiScale), + Scale(164.0f, 24.0f, uiScale), hpRatio, IM_COL32(210, 68, 58, 255), fmt::format("HP {} / {}", player.currentHp, player.maxHp).c_str(), uiScale); - ImGui::SetCursorPos(Scale(18.0f, 44.0f, uiScale)); + + const std::string levelText = fmt::format("Lv {}", player.level); + const float levelFontSize = ImGui::GetFontSize() * 1.18f; + const ImVec2 levelTextSize = CalcFontTextSize(gameInstance.GetBigFont(), levelFontSize, levelText); + const ImVec2 levelPos(ImGui::GetWindowPos().x + (statusPanelSize.x - levelTextSize.x) * 0.5f, + ImGui::GetWindowPos().y + (statusPanelSize.y - levelTextSize.y) * 0.5f); + DrawOutlinedBoldText(ImGui::GetWindowDrawList(), + gameInstance.GetBigFont(), + levelFontSize, + levelPos, + IM_COL32(255, 245, 210, 255), + IM_COL32(0, 0, 0, 210), + levelText, + 1.2f * uiScale, + 0.6f * uiScale); + + ImGui::SetCursorPos(Scale(250.0f, 17.0f, uiScale)); DrawBar(ImGui::GetCursorScreenPos(), - Scale(244.0f, 18.0f, uiScale), + Scale(164.0f, 24.0f, uiScale), xpToNext > 0 ? static_cast(player.currentXp) / static_cast(xpToNext) : 0.0f, IM_COL32(72, 135, 245, 255), - fmt::format("Lv {} XP {}/{}", player.level, player.currentXp, xpToNext).c_str(), + fmt::format("XP {} / {}", player.currentXp, xpToNext).c_str(), uiScale); - ImGui::SetCursorPos(Scale(18.0f, 68.0f, uiScale)); - ImGui::TextUnformatted(gameInstance.IsPlayerDashing() ? "DASH" : "Dash Shift / X"); + ImGui::End(); + + ImDrawList* foregroundDrawList = ImGui::GetForegroundDrawList(); + ImVec2 playerScreen{}; const int dashCharges = gameInstance.GetDashCharges(); const int dashMaxCharges = gameInstance.GetDashMaxCharges(); - const float chargeSize = 13.0f * uiScale; - const float chargeGap = 7.0f * uiScale; - ImVec2 chargeMin(ImGui::GetWindowPos().x + 132.0f * uiScale, ImGui::GetWindowPos().y + 69.0f * uiScale); - for (int chargeIndex = 0; chargeIndex < dashMaxCharges; ++chargeIndex) - { - const ImVec2 min(chargeMin.x + static_cast(chargeIndex) * (chargeSize + chargeGap), chargeMin.y); - const ImVec2 max(min.x + chargeSize, min.y + chargeSize); - const bool filled = chargeIndex < dashCharges; - ImGui::GetWindowDrawList()->AddRectFilled(min, - max, - filled ? IM_COL32(92, 230, 255, 245) : IM_COL32(40, 52, 62, 210), - 3.0f * uiScale); - if (!filled && chargeIndex == dashCharges && gameInstance.GetDashCooldownRatio() > 0.0f) + if (!suppressForegroundUi && NextEngineHelper::TryProjectWorldToScreenForGame(gameInstance, player.worldPos, playerScreen)) + { + const float chargeSize = 12.0f * uiScale; + const float chargeGap = 5.0f * uiScale; + const float totalChargeWidth = static_cast(dashMaxCharges) * chargeSize + + static_cast(std::max(0, dashMaxCharges - 1)) * chargeGap; + const ImVec2 chargeMin(playerScreen.x - totalChargeWidth * 0.5f, playerScreen.y + 34.0f * uiScale); + for (int chargeIndex = 0; chargeIndex < dashMaxCharges; ++chargeIndex) { - const float ratio = gameInstance.GetDashCooldownRatio(); - ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - chargeSize * ratio), - max, - IM_COL32(92, 210, 170, 230), - 3.0f * uiScale); + const ImVec2 min(chargeMin.x + static_cast(chargeIndex) * (chargeSize + chargeGap), chargeMin.y); + const ImVec2 max(min.x + chargeSize, min.y + chargeSize); + const ImVec2 innerMin(min.x + 2.0f * uiScale, min.y + 2.0f * uiScale); + const ImVec2 innerMax(max.x - 2.0f * uiScale, max.y - 2.0f * uiScale); + const bool filled = chargeIndex < dashCharges; + foregroundDrawList->AddRectFilled(min, max, IM_COL32(7, 10, 14, 205), 3.0f * uiScale); + foregroundDrawList->AddRectFilled(innerMin, + innerMax, + filled ? IM_COL32(92, 230, 255, 245) : IM_COL32(34, 42, 50, 210), + 2.0f * uiScale); + if (!filled && chargeIndex == dashCharges && gameInstance.GetDashCooldownRatio() > 0.0f) + { + const float ratio = gameInstance.GetDashCooldownRatio(); + foregroundDrawList->AddRectFilled(ImVec2(innerMin.x, innerMax.y - (innerMax.y - innerMin.y) * ratio), + innerMax, + IM_COL32(92, 210, 170, 230), + 2.0f * uiScale); + } + foregroundDrawList->AddRect(min, max, IM_COL32(170, 225, 235, 210), 3.0f * uiScale, 0, 1.0f * uiScale); } - ImGui::GetWindowDrawList()->AddRect(min, max, IM_COL32(170, 225, 235, 210), 3.0f * uiScale, 0, 1.0f * uiScale); } - ImGui::End(); - ImGui::SetNextWindowPos(ImVec2((viewport->Size.x - 260.0f * uiScale) * 0.5f, 8.0f * uiScale), ImGuiCond_Always); - ImGui::SetNextWindowSize(Scale(320.0f, 88.0f, uiScale), ImGuiCond_Always); - ImGui::Begin("WavePanel", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | - ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoNav); - DrawPanel(ImGui::GetWindowDrawList(), - panelFlat, - ImGui::GetWindowPos(), - ImVec2(ImGui::GetWindowPos().x + ImGui::GetWindowSize().x, ImGui::GetWindowPos().y + ImGui::GetWindowSize().y), - IM_COL32(12, 16, 20, 180), - 8.0f * uiScale, - IM_COL32(62, 74, 90, 235)); - ImGui::SetWindowFontScale(uiScale); - ImGui::SetCursorPos(Scale(22.0f, 14.0f, uiScale)); - ImGui::Text("%s", TrFormat(gameInstance, - "hud.wave", - "第 {0} / {1} 波", - std::min(waveSystem.GetCurrentWaveIndex() + 1, waveSystem.GetWaveCount()), - waveSystem.GetWaveCount()).c_str()); + ImDrawList* hudDrawList = ImGui::GetForegroundDrawList(); + const std::string waveText = TrFormat(gameInstance, + "hud.wave", + "第 {0} / {1} 波", + std::min(waveSystem.GetCurrentWaveIndex() + 1, waveSystem.GetWaveCount()), + waveSystem.GetWaveCount()); + std::string waveStateText; + ImU32 waveStateColor = IM_COL32(255, 245, 215, 255); if (waveSystem.GetState() == EWaveState::Active) { const float remaining = waveSystem.GetWaveTimeRemainingSec(); if (remaining < 5.0f) { - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.22f, 0.18f, 1.0f)); - } - ImGui::Text("DUSK IN %s", FormatTime(remaining).c_str()); - if (remaining < 5.0f) - { - ImGui::PopStyleColor(); + waveStateColor = IM_COL32(255, 72, 56, 255); } + waveStateText = fmt::format("DUSK IN {}", FormatTime(remaining)); } else if (waveSystem.GetState() == EWaveState::DuskSurge) { const float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 8.0f) * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.28f + pulse * 0.35f, 0.16f, 1.0f)); - ImGui::TextUnformatted("EXTRACT NOW"); - ImGui::PopStyleColor(); + waveStateColor = ImGui::ColorConvertFloat4ToU32(ImVec4(1.0f, 0.28f + pulse * 0.35f, 0.16f, 1.0f)); + waveStateText = "EXTRACT NOW"; } else if (gameInstance.GetAppState() == EAppState::Shopping) { - ImGui::Text("%s", Tr(gameInstance, "hud.shop_phase", "商店阶段").c_str()); + waveStateText = Tr(gameInstance, "hud.shop_phase", "商店阶段"); } else { - ImGui::Text("%s", Tr(gameInstance, "hud.ready", "准备").c_str()); - } - float iconX = ImGui::GetWindowPos().x + 22.0f * uiScale; - const float iconY = ImGui::GetWindowPos().y + 60.0f * uiScale; + waveStateText = Tr(gameInstance, "hud.ready", "准备"); + } + + const float waveFontSize = ImGui::GetFontSize() * 1.15f * uiScale; + const float stateFontSize = ImGui::GetFontSize() * 1.38f * uiScale; + const ImVec2 waveTextSize = CalcFontTextSize(gameInstance.GetBigFont(), waveFontSize, waveText); + const ImVec2 stateTextSize = CalcFontTextSize(gameInstance.GetBigFont(), stateFontSize, waveStateText); + const float topCenterX = viewport->Pos.x + viewport->Size.x * 0.5f; + DrawOutlinedBoldText(hudDrawList, + gameInstance.GetBigFont(), + waveFontSize, + ImVec2(topCenterX - waveTextSize.x * 0.5f, viewport->Pos.y + 10.0f * uiScale), + IM_COL32(255, 255, 255, 245), + IM_COL32(0, 0, 0, 245), + waveText, + 1.6f * uiScale, + 0.45f * uiScale); + DrawOutlinedBoldText(hudDrawList, + gameInstance.GetBigFont(), + stateFontSize, + ImVec2(topCenterX - stateTextSize.x * 0.5f, viewport->Pos.y + 32.0f * uiScale), + waveStateColor, + IM_COL32(0, 0, 0, 245), + waveStateText, + 1.8f * uiScale, + 0.55f * uiScale); + + float iconX = topCenterX - 42.0f * uiScale; + const float iconY = viewport->Pos.y + 61.0f * uiScale; if (waveDef) { std::unordered_set seenEnemyIds; @@ -993,39 +1104,40 @@ namespace Brotato3D const ImTextureID icon = iconId ? LoadIconTexture(gameInstance, "enemies", iconId) : EmptyTexture(); const ImVec2 min(iconX, iconY); const ImVec2 max(iconX + 24.0f * uiScale, iconY + 24.0f * uiScale); - DrawPanel(ImGui::GetWindowDrawList(), EmptyTexture(), min, max, IM_COL32(20, 24, 28, 200), 4.0f * uiScale); + hudDrawList->AddRectFilled(min, max, IM_COL32(255, 255, 255, 34), 4.0f * uiScale); + hudDrawList->AddRect(min, max, IM_COL32(0, 0, 0, 230), 4.0f * uiScale, 0, 1.5f * uiScale); if (icon) { - ImGui::GetWindowDrawList()->AddImage(icon, min, max); + hudDrawList->AddImage(icon, min, max); } iconX += 30.0f * uiScale; - if (iconX > ImGui::GetWindowPos().x + 296.0f * uiScale) + if (iconX > topCenterX + 54.0f * uiScale) { break; } } } - ImGui::End(); - - ImGui::SetNextWindowPos(ImVec2(viewport->Size.x - 160.0f * uiScale, 8.0f * uiScale), ImGuiCond_Always); - ImGui::SetNextWindowSize(Scale(150.0f, 50.0f, uiScale), ImGuiCond_Always); - ImGui::Begin("ResourcePanel", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | - ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoNav); - DrawPanel(ImGui::GetWindowDrawList(), - panelFlat, - ImGui::GetWindowPos(), - ImVec2(ImGui::GetWindowPos().x + ImGui::GetWindowSize().x, ImGui::GetWindowPos().y + ImGui::GetWindowSize().y), - IM_COL32(16, 18, 22, 190), - 8.0f * uiScale, - IM_COL32(64, 72, 84, 235)); - ImGui::SetWindowFontScale(uiScale); - ImGui::SetCursorPos(Scale(22.0f, 16.0f, uiScale)); - ImGui::Text("%s", TrFormat(gameInstance, "hud.materials", "材料:{0}", player.materials).c_str()); - ImGui::End(); - ImGui::SetNextWindowPos(ImVec2(viewport->Size.x - 246.0f * uiScale, viewport->Size.y - 58.0f * uiScale), ImGuiCond_Always); - ImGui::SetNextWindowSize(Scale(236.0f, 48.0f, uiScale), ImGuiCond_Always); + const std::string materialText = fmt::format("{}", player.materials); + const float materialFontSize = ImGui::GetFontSize() * 1.45f * uiScale; + const ImVec2 materialTextSize = CalcFontTextSize(gameInstance.GetBigFont(), materialFontSize, materialText); + const ImVec2 materialIconCenter(viewport->Pos.x + viewport->Size.x - 108.0f * uiScale, + viewport->Pos.y + 26.0f * uiScale); + DrawMaterialIcon(hudDrawList, materialIconCenter, 11.0f * uiScale, uiScale); + DrawOutlinedBoldText(hudDrawList, + gameInstance.GetBigFont(), + materialFontSize, + ImVec2(materialIconCenter.x + 18.0f * uiScale, materialIconCenter.y - materialTextSize.y * 0.5f), + IM_COL32(255, 226, 90, 255), + IM_COL32(0, 0, 0, 245), + materialText, + 1.8f * uiScale, + 0.55f * uiScale); + + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + viewport->Size.x - 246.0f * uiScale, + viewport->Pos.y + viewport->Size.y - (bottomPanelHeight + bottomPanelMargin) * uiScale), + ImGuiCond_Always); + ImGui::SetNextWindowSize(Scale(236.0f, bottomPanelHeight, uiScale), ImGuiCond_Always); ImGui::Begin("ItemSlots", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoNav); @@ -1033,11 +1145,11 @@ namespace Brotato3D panelFlat, ImGui::GetWindowPos(), ImVec2(ImGui::GetWindowPos().x + ImGui::GetWindowSize().x, ImGui::GetWindowPos().y + ImGui::GetWindowSize().y), - IM_COL32(14, 15, 20, 180), + bottomPanelColor, 8.0f * uiScale, IM_COL32(58, 68, 84, 235)); ImGui::SetWindowFontScale(uiScale); - ImGui::SetCursorPos(Scale(18.0f, 12.0f, uiScale)); + ImGui::SetCursorPos(Scale(18.0f, 16.0f, uiScale)); const auto& ownedItemIds = gameInstance.GetOwnedItemIds(); for (size_t slotIndex = 0; slotIndex < 6; ++slotIndex) { @@ -1088,8 +1200,10 @@ namespace Brotato3D } ImGui::End(); - ImGui::SetNextWindowPos(ImVec2(8.0f * uiScale, viewport->Size.y - 86.0f * uiScale), ImGuiCond_Always); - ImGui::SetNextWindowSize(Scale(374.0f, 76.0f, uiScale), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + 10.0f * uiScale, + viewport->Pos.y + viewport->Size.y - (bottomPanelHeight + bottomPanelMargin) * uiScale), + ImGuiCond_Always); + ImGui::SetNextWindowSize(Scale(374.0f, bottomPanelHeight, uiScale), ImGuiCond_Always); ImGui::Begin("WeaponPanel", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoNav); @@ -1097,17 +1211,17 @@ namespace Brotato3D panelNormal, ImGui::GetWindowPos(), ImVec2(ImGui::GetWindowPos().x + ImGui::GetWindowSize().x, ImGui::GetWindowPos().y + ImGui::GetWindowSize().y), - IM_COL32(14, 16, 20, 215), + bottomPanelColor, 8.0f * uiScale, IM_COL32(78, 90, 104, 245)); ImGui::SetWindowFontScale(uiScale); const auto& weapons = gameInstance.GetWeapons(); const FPlayerStats effectiveStats = gameInstance.GetEffectivePlayerStats(); - ImGui::SetCursorPos(Scale(20.0f, 12.0f, uiScale)); + ImGui::SetCursorPos(Scale(20.0f, 8.0f, uiScale)); ImGui::Text("%s", Tr(gameInstance, "hud.weapons", "武器").c_str()); ImGui::SameLine(); ImGui::TextDisabled("%s", Tr(gameInstance, "hud.merge_hint", "3 把 T1 -> T2").c_str()); - ImGui::SetCursorPos(Scale(18.0f, 32.0f, uiScale)); + ImGui::SetCursorPos(Scale(18.0f, 27.0f, uiScale)); for (size_t slotIndex = 0; slotIndex < 6; ++slotIndex) { if (slotIndex > 0) @@ -1163,6 +1277,11 @@ namespace Brotato3D } ImGui::End(); + if (suppressForegroundUi) + { + return; + } + ImDrawList* drawList = ImGui::GetForegroundDrawList(); if (gameInstance.IsExtractionVehicleVisible()) { @@ -1266,11 +1385,17 @@ namespace Brotato3D const float rise = (1.0f - alpha) * 30.0f * uiScale; glm::vec4 color = text.color; color.a *= alpha; - drawList->AddText(gameInstance.GetBigFont(), - std::max(18.0f, 24.0f * uiScale * text.fontScale), - ImVec2(screen.x, screen.y - rise), - Color(color), - text.text.c_str()); + const float fontSize = std::max(22.0f, 28.0f * uiScale * text.fontScale); + const ImVec2 textSize = CalcFontTextSize(gameInstance.GetBigFont(), fontSize, text.text); + DrawOutlinedBoldText(drawList, + gameInstance.GetBigFont(), + fontSize, + ImVec2(screen.x - textSize.x * 0.5f, screen.y - rise - textSize.y * 0.5f), + Color(color), + IM_COL32(0, 0, 0, static_cast(alpha * 245.0f)), + text.text, + 2.0f * uiScale, + 0.8f * uiScale); } for (const FGroundIndicator& indicator : gameInstance.GetGroundIndicators()) From 4e5ebcbe3c8fb223ded37d585d306d81d609dba0 Mon Sep 17 00:00:00 2001 From: gameKnife Date: Wed, 13 May 2026 21:02:56 +0800 Subject: [PATCH 02/27] Add low-spec GPU no-ambient render path --- CMakeLists.txt | 8 +- assets/CMakeLists.txt | 2 +- .../shaders/Core.SwModernNoAmbient.comp.slang | 127 +++++ .../Core.SwModernNoAmbient.comp.slang.spv | Bin 0 -> 75012 bytes assets/shaders/Rast.Wireframe.frag.slang | 4 +- assets/shaders/common/BasicTypes.slang | 4 + assets/shaders/common/GPUScene.slang | 8 +- .../gkNextVisualTest/gkNextVisualTest.cpp | 1 + src/Assets/Core/Scene.cpp | 48 +- src/Assets/Core/Scene.hpp | 4 + src/Assets/Data/Vertex.hpp | 2 +- src/Assets/GPU/Texture.cpp | 15 +- src/CMakeLists.txt | 13 + .../PathTracing/PathTracingRenderer.cpp | 4 +- .../PipelineCommon/CommonComputePipeline.cpp | 92 ++-- .../PipelineCommon/CommonComputePipeline.hpp | 4 +- src/Rendering/RayTraceBaseRenderer.cpp | 11 +- .../SoftwareModern/SoftwareModernRenderer.cpp | 31 +- .../SoftwareModern/SoftwareModernRenderer.hpp | 5 +- .../SoftwareTracingRenderer.cpp | 4 +- src/Rendering/VulkanBaseRenderer.cpp | 457 +++++++++++++----- src/Rendering/VulkanBaseRenderer.hpp | 16 +- src/Runtime/Engine.cpp | 72 ++- src/Runtime/Utilities/GraphicsDebugPanel.hpp | 3 +- src/Vulkan/RenderingPipeline.cpp | 4 +- src/Vulkan/RenderingPipeline.hpp | 5 +- src/Vulkan/ShaderHotReloader.cpp | 2 +- 27 files changed, 740 insertions(+), 206 deletions(-) create mode 100644 assets/shaders/Core.SwModernNoAmbient.comp.slang create mode 100644 assets/shaders/Core.SwModernNoAmbient.comp.slang.spv diff --git a/CMakeLists.txt b/CMakeLists.txt index 148cc3a0..f5f07000 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,13 +17,15 @@ set(WITH_PHYSIC ON CACHE BOOL "Enable Physics" FORCE) set(WITH_AUDIO ON CACHE BOOL "Enable Audio" FORCE) set(WITH_QUICKJS ON CACHE BOOL "Enable QuickJS" FORCE) -if (WIN32) - set(WITH_STREAMLINE ON CACHE BOOL "Enable Nvidia Streamline" FORCE) +option(ENABLE_STREAMLINE "Enable NVIDIA Streamline/DLSS integration" OFF) +if (WIN32 AND ENABLE_STREAMLINE) + set(WITH_STREAMLINE ON CACHE BOOL "Enable NVIDIA Streamline/DLSS integration" FORCE) else() - set(WITH_STREAMLINE OFF CACHE BOOL "Enable Nvidia Streamline" FORCE) + set(WITH_STREAMLINE OFF CACHE BOOL "Enable NVIDIA Streamline/DLSS integration" FORCE) endif() option(GK_ENABLE_HOT_RELOAD "Enable desktop Slang shader hot reload" ON) +option(GK_ENABLE_SHADER_CLOCK "Enable shader clock heatmap instrumentation" OFF) if (ANDROID OR IOS) if (GK_ENABLE_HOT_RELOAD) message(STATUS "Hot reload is only enabled for desktop targets. Disabling.") diff --git a/assets/CMakeLists.txt b/assets/CMakeLists.txt index b4d5d458..e79edcd5 100644 --- a/assets/CMakeLists.txt +++ b/assets/CMakeLists.txt @@ -117,7 +117,7 @@ foreach(shader ${shader_files}) set_source_files_properties(${shader} PROPERTIES VS_TOOL_OVERRIDE "None") set(slangc_args "") - if(WIN32) + if(WIN32 AND GK_ENABLE_SHADER_CLOCK) set(slangc_args "-DSHADER_CLOCK") endif() if(APPLE) diff --git a/assets/shaders/Core.SwModernNoAmbient.comp.slang b/assets/shaders/Core.SwModernNoAmbient.comp.slang new file mode 100644 index 00000000..d38d0eeb --- /dev/null +++ b/assets/shaders/Core.SwModernNoAmbient.comp.slang @@ -0,0 +1,127 @@ +import Common; +import Bindless; + +[shader("compute")] +[numthreads(8, 8, 1)] +void main(uint3 DTid : SV_DispatchThreadID) +{ + let Camera = Bindless.GetGpuscene().Camera[0]; + uint2 size = Bindless.GetStorageTextureDimensions(Bindless.RT_SINGLE_DIFFUSE); + int2 pixel = int2(DTid.xy); + if (DTid.x >= size.x || DTid.y >= size.y) + { + return; + } + + float2 uv = (float2(pixel) / float2(size)) * 2.0 - 1.0; + float4 origin = mul(Camera.ModelViewInverse, float4(0, 0, 0, 1)); + float4 target = mul(Camera.ProjectionInverse, float4(uv.x, uv.y, 1, 1)); + float4 rayDir4 = mul(Camera.ModelViewInverse, float4(normalize(target.xyz), 0)); + float3 primaryRayDir = normalize(rayDir4.xyz); + + uint packedValue = Bindless.GetStorageTexture(Bindless.RT_MINIGBUFFER)[pixel]; + uint2 vBuffer = uint2((packedValue >> 17) & 0x7FFF, packedValue & 0x1FFFF); + if (vBuffer.x == 0) + { + float4 skyColor = Camera.HasSky + ? Common.SampleIBL(Camera.SkyIdx, primaryRayDir, Camera.SkyRotation, 0) * Camera.SkyIntensity + : float4(0, 0, 0, 1); + Bindless.GetStorageTexture(Bindless.RT_SINGLE_DIFFUSE).Store(pixel, skyColor); + Bindless.GetStorageTexture(Bindless.RT_SINGLE_SPECULAR).Store(pixel, float4(0, 0, 0, 1)); + Bindless.GetStorageTexture(Bindless.RT_DIFFUSE_HITDIST).Store(pixel, 1000); + Bindless.GetStorageTexture(Bindless.RT_SPECULAR_HITDIST).Store(pixel, 1000); + Bindless.GetStorageTexture(Bindless.RT_ALBEDO).Store(pixel, float4(1, 1, 1, 1)); + Bindless.GetStorageTexture(Bindless.RT_NORMAL).Store(pixel, float4(0, 1, 0, 1)); + Bindless.GetStorageTexture(Bindless.RT_OBJEDCTID_0).Store(pixel, 0); + Bindless.GetStorageTexture(Bindless.RT_MOTIONVECTOR).Store(pixel, float4(0, 0, 0, 0)); + Bindless.GetStorageTexture(Bindless.RT_PREV_DEPTHBUFFER).Store(pixel, 1000); + Bindless.GetStorageTexture(Bindless.RT_SPECULAR_ALBEDO).Store(pixel, float4(0, 0, 0, 0)); + return; + } + + GPUVertex* vertices = Bindless.GetGpuscene().Vertices; + uint* indices = Bindless.GetGpuscene().Indices; + NodeProxy* nodes = Bindless.GetGpuscene().Nodes; + ModelData* models = Bindless.GetGpuscene().Offsets; + + NodeProxy hitNode = nodes[vBuffer.x - 1]; + ModelData model = models[hitNode.modelId]; + if (hitNode.skinId != 0xFFFFFFFF) + { + vertices = Bindless.GetGpuscene().SkinnedVertices; + } + + uint indexBase = model.indexOffset + vBuffer.y * 3; + Vertex v0 = UnpackVertex(vertices[model.vertexOffset + indices[indexBase]]); + Vertex v1 = UnpackVertex(vertices[model.vertexOffset + indices[indexBase + 1]]); + Vertex v2 = UnpackVertex(vertices[model.vertexOffset + indices[indexBase + 2]]); + + float3 p0 = mul(hitNode.worldTS, float4(v0.Position, 1)).xyz; + float3 p1 = mul(hitNode.worldTS, float4(v1.Position, 1)).xyz; + float3 p2 = mul(hitNode.worldTS, float4(v2.Position, 1)).xyz; + float3 n0 = mul(hitNode.worldTS, float4(v0.Normal, 0)).xyz; + float3 n1 = mul(hitNode.worldTS, float4(v1.Normal, 0)).xyz; + float3 n2 = mul(hitNode.worldTS, float4(v2.Normal, 0)).xyz; + + float3 edge0 = p1 - p0; + float3 edge1 = p2 - p0; + float3 rayCrossEdge1 = cross(primaryRayDir, edge1); + float inverseDet = 1.0f / dot(edge0, rayCrossEdge1); + float3 rayTo0 = origin.xyz - p0; + float baryY = inverseDet * dot(rayTo0, rayCrossEdge1); + float baryZ = -inverseDet * dot(primaryRayDir, cross(edge0, rayTo0)); + float baryX = 1.0f - baryY - baryZ; + + Vertex hitVertex = {}; + hitVertex.Position = p0 * baryX + p1 * baryY + p2 * baryZ; + hitVertex.Normal = normalize(n0 * baryX + n1 * baryY + n2 * baryZ); + hitVertex.TexCoord = v0.TexCoord * baryX + v1.TexCoord * baryY + v2.TexCoord * baryZ; + hitVertex.MaterialIndex = FetchMaterialId(hitNode, v0.MaterialIndex); + float primaryRayLength = length(hitVertex.Position - origin.xyz); + + Material material = Bindless.GetGpuscene().GetMaterial(hitVertex.MaterialIndex); + float4 primaryAlbedo = material.Diffuse; + if (material.DiffuseTextureId >= 0) + { + primaryAlbedo *= Bindless.GetSampleTexture(material.DiffuseTextureId).SampleLevel(hitVertex.TexCoord, 0); + } + + float4 color = float4(0, 0, 0, 1); + if (material.MaterialModel == MaterialDiffuseLight) + { + color = min(material.Diffuse, float4(1000, 1000, 1000, 1)); + } + else + { + float4 directColor = float4(0, 0, 0, 0); + if (Camera.HasSun) + { + float NoL = max(dot(Camera.SunDirection.xyz, normalize(hitVertex.Normal)), 0.0f); + directColor = Camera.SunColor * (NoL * 0.3183098861837907f); + } + + float4 skyColor = Camera.HasSky + ? Common.SampleIBLRough(Camera.SkyIdx, hitVertex.Normal, Camera.SkyRotation) * Camera.SkyIntensity * 0.03f + : float4(0, 0, 0, 0); + color = directColor + skyColor; + } + + float4 clipPos = mul(Camera.ViewProjection, float4(hitVertex.Position, 1.0)); + float ndcDepth = clipPos.z / clipPos.w; + float2 motion = Common.CalculateMotionVector(Camera, hitNode, hitVertex, pixel); + const bool selected = hitNode.reserved1 != 0; + const bool hovered = (hitNode.reserved2 & 1u) != 0; + const bool locked = (hitNode.reserved2 & 2u) != 0; + const bool danger = (hitNode.reserved2 & 4u) != 0; + float4 specularAlbedo = material.MaterialModel == MaterialMetallic ? primaryAlbedo : float4(1, 1, 1, 1); + Bindless.GetStorageTexture(Bindless.RT_SINGLE_DIFFUSE).Store(pixel, color); + Bindless.GetStorageTexture(Bindless.RT_SINGLE_SPECULAR).Store(pixel, float4(0, 0, 0, 1)); + Bindless.GetStorageTexture(Bindless.RT_DIFFUSE_HITDIST).Store(pixel, primaryRayLength); + Bindless.GetStorageTexture(Bindless.RT_SPECULAR_HITDIST).Store(pixel, primaryRayLength); + Bindless.GetStorageTexture(Bindless.RT_ALBEDO).Store(pixel, primaryAlbedo); + Bindless.GetStorageTexture(Bindless.RT_NORMAL).Store(pixel, float4(hitVertex.Normal, 1)); + Bindless.GetStorageTexture(Bindless.RT_OBJEDCTID_0).Store(pixel, Common.EncodeObjectId(hitNode.instanceId, selected, hovered, locked, danger)); + Bindless.GetStorageTexture(Bindless.RT_MOTIONVECTOR).Store(pixel, float4(motion * size, 0, 0)); + Bindless.GetStorageTexture(Bindless.RT_PREV_DEPTHBUFFER).Store(pixel, ndcDepth); + Bindless.GetStorageTexture(Bindless.RT_SPECULAR_ALBEDO).Store(pixel, specularAlbedo); +} diff --git a/assets/shaders/Core.SwModernNoAmbient.comp.slang.spv b/assets/shaders/Core.SwModernNoAmbient.comp.slang.spv new file mode 100644 index 0000000000000000000000000000000000000000..5597e51e46c3e3aedddd65398e1e6144596ff133 GIT binary patch literal 75012 zcmb512b@*a^|mh^8`ygR6|o>T>{!4aYXrN912YtX8JrmqdyU2t(L`gcvG?9%FERfV zYZ7~JiAf~+Cni|1e9v?5Uc>Sv`F`Jb^swLeU2E^P_uBikd(X`D=)PK?TCZ-kHEXrn z69f2%ZLwPS+Crs#f5`@abcsf}Y0)<;<-NszPboJn`U0g~x|H207pyH%8@1QIV|U$o z@3GS-9oaE?eACpi9i46MO%q$jj+-%ILQ6Y7eY$))CN(v;w2vJ>wQc+qs?(?5wMV+w zYCG*VYPX>soy|jsZM?pEzgmyl2gEly9tw9o2G8pHnpwYI{#g3p4{FtzO&&N zejQVeEH&rX?U>#&e#F#qEzNC>KCD9@CYBu8hi~&A#k|E`|)t}RmUgU5#U)|Te36xDr6M0+JsQD?z_uTwI=cS}c0`^=W+9H&n`j%#?- z5lz##o5X>@9JI_b?+x3+*v8M`;F~tKxv8^htOruq7OB`aNVa|zo5#EB*kf$s^cnSa z1w`pnKi)<)4)4(|vpQ$Aw~T0SZ#uGW^BDYZHjmN&Y+Jb258K#!o@N!7u@k1YHFXY~ zHEb*g(e6!$wY9f45vyq`%gp~~cU^zmwomJKCU+9MF<#g4UjJ4Zjx!}DW@tx?}KJKt9N z-f$sCuX_Joqr~K3w%?65U4!;(dRu$v-Xsj#WpfRVn$fy_{ghbGq_#$mU4z)V&Wq7r zKlG7J7%%eeXBh#MVyBSwBMx3oh^GX3-e* z*3OpJj>(-zLTR&+Kd+U<+NHUEoHp{X4BBXdPu;PdGqdO7##&nzefOqWd(44=F`(%4 zO6WT`b65zS50J2_7`#%q5XxmrEh_HE)y)0pC}x`UE^RAcd~NW^9Y;-`IIXEGhS&C@?KWeYzKwYsGGE_=a~Eg0`R%M*Y)272`Z^SC$EJ?X zotio)vdrJmT@yCdQ}`-pYh496X*9q-(1)8fAmj0Y6j~b zhqkFR%JI@@cOz}ybnGy_WAfCt);MUQ?l9~lrj482(%R`|p3}4Eu14LBzin&WB%pbX z_Pn{tx9!_Cb&Ti6ajzzh7^jj<_pQz41`H9o~*cn`%H?XdAq1|o%b)rx0L^N-N zn7>&^Mm2oQuQs>It&LMgV=eTm>s}wGdEG#?ZGCm`;F{m?>s!~n9vtR9LgRXt#?`3f z_27uM_NmRIN6}8BKfTb#w~;7r9z368>WbktU|LhBA-Xir`!gqZOdiK6D*EDizLoPa z1zKY)p5Ixc^*f2idT8jL(?fTDy{@0dVCvVo?Y(n|t7fAuZElmj-6$h6hPrvF>s{xN zMq5_lV%gbM0UmYCQNz}#WFBnG-0)n`G<8JNxN*BT?(@TDj^^8%IGfIIubw04X7rKM zJy4@gJx|_QI;Q#=c)rbiwKwj2(;PL%l=Bw(nMwb+XY}5#WmY9W+T8YPbbcC;4;=S$rxqnlbMa%`aVs*Pt5tw>IX$ zxX#axGmbinHO$jRBhTt~bMKZ3?M?Ojs>Tg#^u_s!@zvK}y9o2ePP_bpvICpM1>pC=pFoigIatuD4+wN7ju z<(ffNl%BOC&?dF9vbo60kB0l4>or$?JiOUCZ0sCiEr znsw9$B+cVQ&FgCTd3>mCn>3FJwY`$&@t}5S(pVOCZFC?S!N;1$Dp2k{0XE zgh$<2ciSe-z8T|$q{X^>ENQXsJXoT=Sa;hdE!N!$NsD!NPSRrCU7oa9cef`k*4<-C z^V!cnKc6)7tF|b~i2nJkr?zC$7BAYeNn4_5D<+MlSg$)EX+F=b#=d&ee2!CFGig4@ zsSQb*&v0t%C(Y+JwP8u~c};Dzr1_ktHauybztpx(n$Kry+b7NEGqs(Q=ChgFZb=(Z zw7rrxuxO)`wrbJ#PugllJ2+{p7wyob4Jul5($*;2q@)cl+SH`2S+wa%TdQcDNn5*U zvy!$>(T+*lkfNQCG@q+hV?QNnLyPwPr1?yx-&sjpzi8(q&3kM8&P$p%C2BuO8kgp} zc1hBP747n*`D|p}tCQw4joR&Ko>!0HnDCf(y~$51{GGyoQ24fmUr>0Qw|`%_$EWpQ zE8OEt{$}BEKK?Y-_qdx2ce77!YVrJ3)i}M>{fr%cR@cw=Lt|)l%|3+|=eIbo8Y9kc zdnLa(znzn`IKMrgv^c;0K521&dnIXcetV;6@eGv57SBIwF*dd8*wm_HQ>%_mEygw= z#f`D8pR^d8>&IMKH^#PE(qe4Gix$r+c^vVaq89TJ&nRk(vb!c8q9gWYTXti5Kkb&= zQj7VvUii_yx}WFU(0UZjH5^*x#PeQg)ttn0g>hF;evy+klNLGgJP_?gP9mSS7xNwY zREz6bF5`H4#=M(P7 zz@nWc5?&_Z6%rnh@Sud(NqGG(+_N?w?6{_vakLdaweXI@W8KHIN~{IPet7YTIf`eJ z>@zd9V}6D*IWew{65c%FZMtyJ+MZzhX+MnFT)6XOd(UONb7yo9&m1}%I(=Ohz&OK!*lKa=o_3BOkG5}eJQ`+>}J z4@&V;tTWF^+p@b2fV&1cJnQ<>c(SC>xhJJQ%b;Gz^NE^mEJGdFsG8?S+w{J80K1!Z z?}^p4%e^0#yVm92_Zr83$-U>5hu!;Kx%HPt^}bdg?d$NbvAu_7xe&K!&HGQsU>y0t z!i^*Mn9$xBThH2{!i}SSaN)*~uT{9?w|{$4+JF12eQ(Nc?CRS0p){}dcV$NBbGq99 z>u%e5H^xC|_Sd#z%wxcA+7F@BZj7)uf$dlDX0Uw*ZQ06 zYIz^RjJfXLV|R?PTgUbNe$wXKdeuA@K1eqGmx6!9Zk@bG4(FiPYA0+HF+Yacrn;qm z=JXSG`)i&)r*usJw>*7@T|e{k1*Mys{L4CP*v+Y*n?%m)jl$I;c3%RSj}@uwXC3Ci zb-yxo{j5W6<=W=;&p71&FS~72ck4O-|7mx;|7|xvvFj7q(zZg2GW3X(^{A^BcE@gC zJ;r?Y`f$kx_PHyt&s<{j>+_b_He%h~fcDMt^Z8BkZ@Sg(#<>H|wr#Ajd*RN1>~p}j z{Q|mc+I~16KFis^iN*e6v8!wM+0MR){qo)9f6}x)${hwf_rta^vpyTq| z;r!2qtF4Uc^O?51$6C-Y>*AF;EBNeE)ni>$_2`eEk$l7EwRRrZYna!NFTrxp$18Kr z@i|58G~}erlcXw$)l>#`FBHT(rV=Hh968ud0v5H zEGG9D=|<_MCht+WcE@DgqrkQs{5ZH%68r)xgzVO#L!)#~|{{}ayK*JH^)g`fQSs#}NuygI+Ki}$WS#|#(| zv3kMPVmD^kRz%P1C~RJ924c(CMRCL&jEyXAAMLCGSIc|YhE(*r4|C>orfbJ_&3h9rpEtr0G_2U}2nm)E0e%Hgl`tq2r_O64Q zKlSW)BmADbhjsbgQ2f-h-_7tBo`1e8?oGu{J^S4XfB(I4U3t6>e#h;%ZxwmF1+I^J zu6GCgu}8P*s&^;+x@)iPig!C)AM0p~ao!F7;kk=-)w>6N@<}Ik)w|0&?$pyZgtKBZ zqe}f2a^`(3eVvWp?hzVch51}9>(TpM-JK@ny@aYC31@+B)ug_q()^q*>h2LG_msI%0iF?kupu&HgxW~&66CVseDe-~u z6BF0}gv1BIk563xV=Mfa#H~M~!naM_{tmD3Efcr?rit6%1rj%Y-^BIrleqouow)uz zE4)YI)_-^9Z?CU+D*WvVf2+dZtnfD~{PhZdt-@ce@K-AQ_aj0*3p@Qw;^ukgbwe0t)mvHlOP@PjJ+zzRR0!uPN6 z{VIIl3g4%~M_2f^6~0Y{Z(ZSARrv4<-?GBDsPN4ze6tE)y~0-6+SU>^D{ni<9|2twcvXtuK%8i>%T{Z@1D5x zxm$(rTH)VG-2RTN@DUZhZQ{lsR^b~ZZu|`^e1i&KKXK<{K;q_qmBbzY%89$4R!Us| z6)JrB#BG1E#Pwe^aog{oxb62#T>phDe4)f0Z;!-nuX}}etMFf!=e&6S`Bmc1$5V;> zO!#nxKUColCT{%O5_deeCT>1%N!;<>T;Vq*ZhMy}ZhJpX-1aU@-1aW5@JkZc|E$FI zKQnRTosqclPETC_(<=Pb#BKlB#PvTWaoaySaoayCas6ji_z{Ua->r$;-n0szTHza4 z_^`yC@AVURzSpeq!4KStamTZ4;^t$S#2w$#6~0vBwzpv7wzojyw%0dt+v`)| zy%V>+zj6}oLmBIRPU7bCPZj=A;@1D5!k?({$1D7?3V*c1AF1$%EBv7ff3U(IsPLav z`27`rUxnXW;rCSd-4%XUg{iY6+X4Xr&Rc16+XGb zCsp{w3ZGEnEfwBe;o~cOT!lAP_@NcPbA|7excS_!!bc`U%JAVs_-Q%e2EHQyuuf&@I@=Ue}(s}@I?~$e6~R1=4T#S9RJ=we37{Cl6+p_ zpH=v$iF^K;o4D=2n7Hl#E^*s`p~9c9@aGb@{bv*Re(d22e=u?5KajZbe^%l5CvN-q zRrtM$+up5->wiy zkErl%D}0*@-@3xLs_@|zzGa1PQQ>P;_@D}3y~0VCcOJe2*$3Tta^H^(?)v|2;@W+eGVFfu+Mc-W zcO;ct9j zc*(q5g?CTf{P(Evo)z9JamUj;apU_gYs8cLo@;RX|4HK7eJ3{To)2EB@K+Nz-Y*jO zd~j^y=F9hXqdnXAo!;QK-z{UQbV}@F|HKZ}kcvl(_Z( z$#;$sPyR~c)<3txf0(%bO%=XF;=Xg`JJY%SbNP}zxOU&K4(>ZxKd$f#D*VF4eJAUp z#C-?r;>7L$C5hh)zcg{@^RmR9k8z2+UdAV`y}7~{uke0}J3ikfkMWtWNr`Lsz4Nd; z{%IB7TH(_Zcl?JZ?)cjicl;fRJO0kZ9si8P{m#{Q;-f!)Cwpk(erM~u^kKLEzGolY z@lECn(#&_S@C_38`0$h z_FZ=0GZ?{l*@sYeV>|~_j$zklAIk1%>fzI;_)JVbebLmnCYGN8T1V{XgW964sc^O6 zQ^5Lvm-xMCZ*_dt+-4DfHsv5Rb=x!Fa_eh5hh1$uYRfMy+89dvdokrDMZbu0E8c-- z3DhnvI)?}?ao>qA&D7T9A8pn*p7X6{JnfgUtC`;oz;0h)Q(vEbJ$7?!X?@qiwd|jx z>90+{$ChaPyu}UR8`;BGo_(#O&Chio2Ro+cD2LF8XDOSC_5$U)Xs%!TuAkb_qUq-t zo~HEYd8|JP&f#5KAFSpaaCp@=0IRtf+jiB$zG1Oz8boRRRE37?hl4-!5*;n@kFlkm9-`>sax=SK;jm+<)s|2W|b68=fTexDv~`dxcq zzc=`}*2s_FDcoJK-#3__SVwcguA}Km`?P2sMf;NSEB1Np9`jN6Yp`{vC9OM)!uO7j zNt(ZdK)`MU_zPEMM?lfb&4B+cJVpvG-vS6qKbfpvX<-*v?$z4lYa<=Et$-fEYE z9k+Zj_$6R-B6p47Nv_OS@H@cfGWh)i8t?7|zYp#l1iu&T`~<%T?A!&v8|=IWzYFY~ z2fq{SdI)|8*tHV;cChOx_-)|XiQfu#{e}G&uxm5;&0yDU@SDJ{>EJhlUGKqf0DCOR zm#Xo(Il1sS34Sx!V<`Nu2YYSz;@f`kVf<4xQ zp8@tfAn%X=h0LAj2zfvFPr#l(w4{TBP)hGP=@PKV76f2JoICyV&=I%)NL`O6Ntq z7Nv70UYpYS6t6?+T#JWLx(>waQo2UOLn&Q9;`Jz9TjKR8U3cOQC|#4{4Jlo(;*BU> z%i>{_u5kHRWu6jts3tl{&^wR7J@e0kMpzl zq1ue?`aT=Gnz3A`a_<8+#O6JNrS}|LQ@VLiH->T}cI}q-!<_z=cB=PKe?wEZeea3o zk<+V*r4~882CioNk<)A8>N%(W49pkQ)8;*t@%@>pyE!1$-P9JRya!F)P0e%ky>NB6 z(C#Z5&2(w^7tPq=_p_q)D4H=IKr=?J`ygE1E$Tj0wCwkA(Q=GOik4$Mie`-L_ZVC~ z$9TMGImQ!3%XObDT8{B^G-HHjuAYLc=NP{zT8{BQMawaMSv1>oU7D+3p_za4WB+YG zY`+FO59Y~n7$ss`AO`B^`zj>@1 zHRsCp^>tZaK3l1|zCOmT?xyB==Afy&#XSENuAa5O6)orNe~XrL_V=R292nytXvWBO zKY^>~82>C<_WM`Sa*Vk}%P~GhGe-9N46dGId|tF1 zIWFUBbKUd;8`HLJLtodIpATq@_4N(wMlIHtHxHh_d`^q?)ffNzO-0e1`vuVIdeO|; zf@tb)vE~*kTF%+RMawx`q-Ze*#^{G;j9j-rT-_~VELybew^-3~jKzzVV=RGYjO@20 zTs_BFs%SaJ(nZU4mnm9~u`HS~LNiy(!PRq&<%^bMtWdNZW5uG`UaWj8`xY}G_1Hs0$ZQIb-_2uVL+G2fmV?C+G zT=g&O%lQp$@uJzT8t%0vinesoJiRSnG;RmFKA)I{v%1%2ssm4 zp>X}R)3w@Iux*9^Aza;!M6eX>=Arxu+JsIw*s%5_;B!$#J2>m zllT_kwG-bQ>@!ldw;6cN#5V=|Y!&uRz&>}$T|+yO3!lk??+EsJE&NAw~z5^;3 z`+lgtV&5IL9kD;>U<|Q817UozKObS=#P@;ix7eSna7D?3}4T4t74pPk^0ku|JFAIuQF_tZSt14|e^S3)lD^ z91lL*XalwP@-(&FJ!4mkXQ&(CYTdE*WVc=Kac{v#o4T6!xEpa| zQg>7HTr><#-A&E;*ch(v7TP97qnR#k)1pNT>uy%GxK0~mb2MY*x?8~2-6F=8MazD} zianQG-HJ3c(;eE=NLN_EyvigXgS7C zMYFxQU)UMV{F@*9Z~I}}1?)VSC&yu&uzd&Ye3&oCW4y5K3U*%1o8vOBHqT+ZfsJX~ zwxO@<&hPBBx$gA4m35=$T)Dn(E9)zA;riMWySkfx&i!6!>TWU5--WAZZSSJxoQ*14 z&e`aqMOmE|H z9Ahk+F+y{F9ST>^F`9~&V~i_WjxoMywij!m8O{8gANz0nVQT?959Y~n7$&x$mwZ;0nopqxY>+6oPzT%wb`kI1W-7VJF zR5bO_%-J-!de&NtmUGrtw4Af)MTv>fATG-HHju8x7L=NQKpEyp;nXgS95MYFwF3n!qNfAeGi zZ9i-$f}IER+4R| zjar<8eqPqsqO=j(Q$_QB#WvRGy6kzcem(@dM#cVohV6;{*$vwg`*R((VN7$oHC!$D zR$%?3Ps72s6?{vuKkE^E3$T5#f8HzXM`yjCaPwZl-yg6)n!53QFGFs9Z8vfKSBv^% zz}{;(uh#eH@DD*#x4!Rr$gQvKX6ma&{qbP$Z=$|Gx8IDWZhhYyky~HeE!0$PpSwtP?RGSu&Eh%i4!D|ae2(4U z8)?09w88Y(?gFdnXTK_iw*RMS_rlfv{ZygdSN!}PRc*xddF^Lt z_9^%SaGwvqPdnNlgzKYzcF`UJtKUI8Cl~DzxcUPFd?%rCJ$(#*FFvjf`|mn?0!?4H z(4H(BVY`l_pQGukZ4SD7wEGmAcGuvZ{FXhBfnUJY-Q8@9&^!+}e&^f&p!+uocRUs@43{BllKd(pJ(nLv5Z5^*aQtrmY*t zq3clG9qjrO`?GlZMV$rUYI4_pU$Fkc`+#lB*vF#>Hs9s~UCHE-6~nRlBrkQP)2I6wUWw zqpsTJMT@#>R}?Mk4o15Q&39&_uG-Z_i@Ivp6wSKk+xKX_#)vyheRF>u*mYq!mw9@N z@=bO(?bjE(rTm7%E#)^BZs|Pxx%^FFW5n9MS1~+c@>5iuEeVX^7 zJ;2seH;zA-tQKwb1ltGOaQ*oEK6|05d)`AR=WmlKjoyU zb~V_X2fqsJ{MoiYzbzO0v)%fN{kd=35&JXY#t{4S;>Ncv`|>len%utJ57u99U+x3j zR>Zy+Y@FctfbE0abHf814<5sDu6_{g`CC2ixgQ2wZw~%(&;1CRy4Ucyw|x|@?(PiWx_v+(~tT>p6fcoD4T^~7`YD_}Rz z&DvgKSMyvfwoR`kt_#o8VY8IKT6`_#uN7_?eRv&gA3X26Cf)$6HGyqko0|2#78%=8 z9@i_c54Pv}e;a&u;_rZGv!6`++TR81qaJnM1D})l``}#X1Gqlw3v+L3J0F65hT9(O z^Orv75yRvE4`Acyb4cT+c2*6)S{h# zfNjS$732P-Xr7PF%|GF49<$-|uZquHxSH3f6HC2M;nvmW{My!MVDqfa{uo~^+Wj1C zeKJ@%+;puA4I!E;;P;cAXI z>hyrSPW@~JSnJ6@YWgl*KHKO8cAhuHZfqU(3w!TmcbtyNI_7F2&ZpWVULSDoQ(w56 zTa0f3xO(hScR{ot(RT2K;JK}Z;c7Woi@?=mkGlQP&P#3ehv&8yg{$RUEe1cII+3fz z;cEIuE|vgCu8eIQ{ldOvvO7-4WS#h&XsKe~fqdv|4i_ee>e2Ugz`3tO;A(Dhzp^e| z-Q9X#TZe)#PGepVp4(X;uGYw3jhqwa9DpQiC` z1VrwaA%$_%8gKk~8_xf3K5v`^aUjwh!3*6m9*n$)o?v!u6LU*OmdB^QgNt*gA68{Ze4pZ}=|(_LvX8 zIN0;3v6iQli&vo3SG*FX?TGyyR>lzf`>l*G9zbc|Y}b4Y1*=7zb;0_}9pezNZOQHD zI$-03|5{-C5PVIr{fqme-_nSh_aC0$yq5kB>>P{)YmaN}i(qYj4-~#H6<;1}YT6&* z8s%r&FN3xD_Y=IQd=u>EJ*Bo+*E|+_IKFrr=|V8oYKDoVGRAX>1PbT zlUap{a>sEY* z!qwcOf9t`mt1bGsKG>MizYV}@`r1EjYSF(9!S=72J^Hs%(V~CD;A(E+vvI{|6S$f= zjs9&4x30G6-)3NAM*lVktLbb1w5dh^wgB5dKSzkzTNW+)Hyo~J?C{yD;ZY%S#VzY$fIFCEP)gBqZcVR`_5pF$gwrPE} z7|%{%d0wA87cH;PUC{K^W_;J;} z^#b+tubz6;K4ac{VqzV=0%TJ&WUIQL~VT+JHw^kpnKudzenYWmt2ZEDe%rNH(j ze#UKSxSG40eR92d%3T&s`=bN+?4)SR!ykid^Yc2dZ!3V+y(Y<50;~C*vVB+_tY#my zFA7%k8uUX-w|M_@0^D=AWwbX5Y#rM>vBa2MG;OXouOo+n)!pJ+G6k;g-}5nETd{jB z(Kd~Js&-;p+TZb%chH8{68*L5cRz8wj<$kr)4vH6{%YCZdX88B>0sM5{*jb!##e7= zKb+kdmeF3s*I%1{G0qOK<8-cKoSks>80Rdon{l+wWS^m(wk)f0^w*|ejPnSvZRT;R zInMC6lt(`;W86o9W8BArqd)3Lv#S|XY=6d4et?thxh}Nn6ZbX8fxQPJ?e+XeK8{C^ ze1z`_$ya;i=|r&YXm>tNr*tz<+D>9uGqyPHb4~{PH=k@P?sHB7tGgB=?)Sj<(c?#Z z=%*Il^QwA0Z=MFWj`ny@N?M$$`{f$1H1-5VYL3`By z0a%-R+*h6ro`avZs6QKQeO|e)X^%0S1GcXA7{j?>b>qhv)bbdd<2Wb$2<-gp+e|4B z+hSlfZT%?aVSA6ksKsY%@5Alm_Vm%u(fqqy_TT4;4~ost2=ucJ&l?|tz5k5QC;kA| zSKafCb^i$Vo?KgeF8!yX&B1;SesrVuXEb%+yRyGmfYqYTU%<9CC)N2Ft{!!+1gpj8 z>2tug;qK;f&k(!L2Y)R#ujlcM{x>xB*j*QnFktT66LbxV~db{($MIN4AGH|!r(kp8?p7W9XT_Gs z{yCa@>@oH)&}>h8^l2X0eCM%$3D?)nKIx+tb-n^yCy)JWxOHOe`lv;nZ@|{cWA`Ls zofx}5YOYJyQa7-5yjFYN><(9RcQaP__CPbY;oB3g=I*9%Tu*zU*_J+WZ1)Cx+>FFe zdmP7o!0*wncFz~~qc2$f0rc=&0Iq&3c5`+)HZ_m;cpkHi<91=NKCO(^pM6>cuI9b6 zydT*2+tht0O07TGcijA&dVcnxwkVqYUbocoZ|mvjy$MBaG5*mvA&oQnk zT8?pT(Q=IIie`Ipd|Z!a{>_j5xBal)0CpbCljAT>*lq+nALh&P7%yx$ft?rg=D3Wj z&G+PP1{>41Z9`wroBkd{ZJwX>I}NO+&EHw>IuuU>yZ*$jVEv-b47i%ywciQWKX?b& zwzgy4`99rsEJ)v{Q;+xQu18ZhZ?Tqb>uus_b94Quy<0SW?T^1ZT|e>0VC@lmbGTaY z&A|G{J4rXuw*8Iw>25EvJifKv!hSQm$G4?1&ZE4Ej(DxnUz>hjdk>*cUlade%KSdv zH)!hC_jkL??UV1?Z3tI$d|Xay{g!NKz^?b{`lG2^-`_~7j$D0WNvZwt4S|E_RL=h^ooeh)TAtlbyE zYJR@xv*pWRH`lH<`Ah8f!_s!`hg@5<`wG~0y=U?GdKIh|pCi5oc8#ce4EsLa>tNrf z(-!a3y#ZE_V>s%+iRN*rJ=W}7;PcpR&o%ruSWVw}pY9#7y6w4peE7cG;;eOTZlNtv zv~EdTvS`+IuKXJb))fy1TSx5QR?yeJ+5S+tTJUwj`Uf8Zwyk(4ZUyS9xx3j1W5)Y* zD`MBjSiSL)N8MGxw(ss%tvdj_KGyAnk38O|TMcYqwfXlK~!AkJlXiy@suU zrXD_n!N$!#Yoe)pf8}yr3v4}g(u*M zebzXV-pUa~^yVu=8izOHsSRS z1?w-jF9X506|o0^jT3wouziqwZg`(c9>Z~7{s8RxTRrZT{{Xh$9Q@;6{EukrGtlE1 z_#?QwyIZVD$6>qLBkrHTIqsj~p8M4!?#E#Je>ZJK+&O6KIqqNK>h5kiuI*}%xO2fd z?x%3i{p$8pZp`ri3?BZT$3KUAzE{^@ZqCF13wZc@y_pC1x}>hZ+&K&Xui*N}bHmqQ zHLro5o4Yy5Y@VC7eZ#KixmawQUQ1jTo~OfRDeqopK+_faSK0`U9cKG$ns5U!7U_$&neS*o)zJl9zSu8(?r zhSm@4v)A^t>2sJq@qSu=uyOQ>&l(p6yN0zz+{M5-&f;*t&sLANmH?~UR-Bub1Ybsf z%~5pNO$6*ci>kh%qdO_V|Ft7$WZSXzI3M zTUa@GNk3Gk)1XuIe37?fKKC8ghyhfc^>J5NfSDW)|TLZ!7S)2Va zzFM@qD%k!+?A73EZt=O`>TvbgqwXLy=PURc@Z8p5xSHdLw$_BJ#~yXpLd)xPZFp{L z9k`m~jXFc%uG9Doc3rrdzVU9(P_Xm7A@z)H9R0$+Ua~t*$7CIIbp*$;_K3GWIQMA- zxSCsxZ$r3x>``|kw1-lzhQV`N8^hIdt~PtlW9k6x?|M;vD zpIeTFtLbZQC~AlDPvp+H#?deAP08-~9FKL(k$IQMBhT+J=kSuXg+%dKMbDRngUmgTzNg23isJ8j>&0oHGN|pwSps8 z#x{1M*!^B(OTf$lSBv^NW$+dC4j7CEyIN5Oq2HD~gp;cEJt6SZT&ku&2PN58Ni zo9vF)aaqTl9ZSx%+b1rkwd26vr)cwU=Eovol*SPI_XCYDK8w=6*{=Dx2&@)yE(Gf@cZ@#) z+m_sZUH~>u_@58955dm^+rPMu58?Xn`*Fwg;u^=i_?`T^Xuh+i&HKSkDBZjt)V3b` zPEvQ&0sgjq`oKnX6*LcGCmvM99;cud4nFJ;xYb zv>aofqUE~#7A?ov56u{%nXCQb>N&;%MawY`ELx6nP|<8J&aVffnSb+R|7|~PW5CXX zd2$@a3ELrH=fiwC9^-{=EZBK5Z;s2j+WdU`P_Qv=+cxy|SlfY8o9kM?Ex~HemFsIb z*fqzYQrBExYaJAs?g7pvn4%oJWzYR7{@VCJBA^4kM`xpETuze5yI@oaqe+}%I zgTD$kAHiP%o2%e2gUw^`m%!#Y_={lkAN==V=O*~?z|LFD=}2}MX>!9zXWzH;+MgWQ~V0p9Ee{9 zn;-FOU~?ya9c*63Z-C9Y_)W0$A$|+&T#4TXJCEXbz|OJwU9j^n_U|Q|3$cGo*&Ia9 zdU8*w7Q6>s|KQztq0Y8~`+}Wuf`0?H55d0%+rQvnf$e+nFTsv0_&l&<4*mt$d<6d- zY_5WT1~!kuKLwlP;B&#|Kls1E&Q0)tf}OYEpMagy;Qs(S-*V5<-;j&?S}6Q$xa%bR zzW}?2%&ouURxW-JtgqPLacetbf5)vc#Qu(3c2V-ov2ZXK`K z-*Ia$#Qu(3^Cb3n+?qqNzvI^Yiv1n8=3eaYxOHB{{*GJcOziKtbw0)Zj$7wi?C-dB z9n}39i)*Cr4|e^CuK@de5#I}p;JEYI$KB2Ajn|j>3}+;EpFMU!)7Lhxqz(1RZO;Mx z96fP^_ki24;N9W&Ie0g?;|X4aJJ#UefXzejufgUh_*Y=_7yL_b{r3l#^XWXec@O&+ zVCN+G=V0e6_-A0}GWe%p=Q;RXuxlXrzrd~!pO2p67{@do!ii*&8T5A6`r8Iq)h>luSkK z0!pum=TZ7>X6am=OX>XhtY=@f=^HlZD%)%?Y|dM@88d9%DmMESHs>?fv+rSZZnMqt zh0S@+Hgga*=RDiYQ`nsUY%`Z(b1h_>`3{@wBHNsku(?LE&3Ox(>m}Qq+pxKIvd#Gq zo9igsTq9v~O=X+wC~R-lzHM_JV=evy>>TU+LrQtrc4T3wY4dlf%f> ztVDbr*w0bKs}j@iJnWO70l02__Sbd=e!j;X=a5n8{yd_)TlE|=8he~W^tBDoA?oq@ z!9L)7S-~NhmYIF0x!sokZiWYu8-@S&W9)3Q1y$)9o z&ExJ3xO&#!ESlpphJJ4q&HjY;cG2RV+!*hm86($y7q0FWb>Aym_Itl*ImQP?%P~Gg zGe-9N16)1F_+!y>jE{;9=|ImVySj1ihS_zPS;$N0EtImVo#=_7cJ-Pi=yS6%_~~Wfib>B zGe)la6@0+6K7!KIFIYu`$V`RVXXzDpekD}!mJ&Ts>_9|MA(HqSep}D^L zpsD8=eT$Z3EKsx@W5J@?UaWj8`xY{Uc{rSh3wrv~wy1xF2zqVLk&%@PRBYuAH0@(Ex&*y%AusHteZu)r-umqaA zTWCubE%Kw^QbmjWs4ZQz$hX=uMT_&M+OkE9wXU{Y(V~B9%NH$wey~E(^5+LD7R~vM zKCe`?{Q1GkMa!QbtWvc6`N4pq*@tLvV9~rRgtltY;`4(;x!*T`&PNm2yotww&6jvQ z*gT1w!RANY0yZz=31IUfo(MJ%;z?k~FP;o`+~UK)j#oSd>^Q|!!H!Qn4eYqYtzgF^ zZUZ}xxX#aps|7zBtbgzyfNd-ISzzM?KND;pf}a7lf5A@&+xOt#2Rp9dr-2=F@KeF& zBl!2g<|_CpVDlLKWUx67eiGRH2R{+)+yp-X?7YRCzF3Zvyp~=nT6}(RF8Q8n9y#Uki4e;_JZXKzu#e{D^M=n>+E1 zVDl=z32e^AH-nuI@hxEIN_;EWc@*CUc8`3d@V?vCoH()4teeN8^iquC#ArpE>Qf*ymBlB=%X=@rr$pH5X!^am|z1=U;Ou z_Sx9{ihXW2_hO%^ofoms+s>KTXL09K>~p$vE%q7Sbx`+bEUuBdKiKu-^X~p8ht20* ze@DrFg^#AJUu!76zU)EiHDy;yuOmBCdhOVO((A=YO0N;yPQ<}Tap zSJ=#Pw%PZvITzXH_`>F#Wt%w&n{%6O<|%BhfowCEVRNlyoB0l#Ybx8Eld!q=vdwu5 zn`<=NoZGOumb2}<$>y5RHrGhhd%R+E9fj>J<~7%I9^*JX0PGy=JBU&qwu4w0YTA6> zm5Y7e_4nt9ecttV?TCHe_4n|IS0$!D>ucP2-nEqb^I@(de=bblE2xv7eQ{+){E^RKpe{+$L^_ZZ9fX9qKGZEju{ef~YPXyNDc$uu7deP#ZQtj}f#XY53d(q;a(ik0R#>jO$;p%P?V@A=k-^`-r z7)KN>$C!m?jO=$LTs_A)s%SaJ(M8L3k11M?aV(lKLUX*w!PRq&jgl7KDkNvm(u$>Hc9?X;DFizM`0XrY&%kda5Y~KSrFXqj08CM%c?Nt6Trfu7X zzOKTdcu_h-P>-C~~4gsW%mtfJ+d{h(+$XJ;2J z=D--U(TtJnoUG0rVo_WNPca*Q7pEyp+y%^2D5e7Jg!@#CW97#9>R*ZoP+a*PYn zj1ija>ms;%j&X6(a*RugmSbF6G~0`{a2cBUH$V2@_QUp5u=8M^9EWkjb~)JjFkguWq%%{AilZ!_5S70;JG|6YTp?xvsj z1lPjV-9o#rXptZNt}j~TN9~59MZVQ;ELxm5)ov%kq zist-ApKmW(e*V3qX!-f~&Z6b#-@A%tAELdxi{@n^w0nvc&%cXsUuXWDkA7hDChiY5 zU*bi<=1II5*!+kW2b&l15@7QoUJ`5`#7lu4zj$e|;}$OicD&+c!H!eB9N6)Rmj^p8 z@d{waBVG~gIO4iL9Ih68OR)aIw*cE#@Xf)-3BDQFJ_O$sZ2y990=DnLHwHVd;KRU< zIrv6k^AUVQu(=Ap0oXhSUmt9agRciR|G|fXotxn6f}OXR(@EsQ<0P-8$wiCj->vaK zxa4mn*mJqL+z#w+E5VDlv+XJ|C$T2&%fqL?DMZV z6#M*Ze#JijntQR&zs`%;=U?Yc?DMblDfao-xfc8U>pH0WGZxoK-5>1w@%i^JCW|ee zfB#(gA1S@od_d{-wT1t!RHqHs!iXpIo52my|9^+Y%^xq%w4wG zudtcpY_soSb1t&Y@rBJf%QkZmHs?0m%v0E01KDOS!{%DaHuD`e*HpGSCt-8#Wt;OB zHrHsjIk#bREoYnaA2!!~wz)>a=CP4&uA{KM#k^*l^BBkB$6)7J-}@=$VLP0Kp{C8} zU%B}I_xU%TcP(w(pW`?NdviKca{5>;E zW4Pu|0P9olGo?Ii_i=DtjV5>9?@2p+RbBU;PQT>Zd=|I|?z6qzzTXY@xnE8bwY$LP zNAAx8+zB>E;eR{W=kwsVfv;e zkC(xZC;k%nvBclU-*ZybV9)WvUjlpn5B?(9YeVqggS~FZS;n>Bk_)dX z!CwG-y$Szkz+Q{Y?WWX|i?;;pD;^HE9q}3ic&7|59t<|VculZ<6R!of-{Q5wj!C=@ z*zt;ofX#(?U9fo)4+Wb;@p@qMD_$RL?!_B`ofq+jVCPJ{5!m?@4+A^b;*G(s1M#+$ zt`YGFuo!I2pWAm6h2<)-twQU&O&7bv9-vX@W zHAJk>R+Mh~97vDc@)JIM-jBe$m$tx$xP?c*pE-Om=)erp^0W`~AP@ z+T23>d(phc`m?+G{iD=h4s0EL;^$#L0soUW#}F@mF7}^j*3lMz|I&~B(-eO$-1uJC z<7aU`h1*wiu08612G%x@`tfs%pTlieo4-fF>-iVpvGm2C|LuYAJa~6Xb@Ss*!B=4Q z{UM&uz6Pt=p5wlo-LY${E#2tPAYdWeeb|b_jimIaAAX zwiWfP@BPQZ*jn**9GsSGi@?>iKMn5(?m?m=UVr$S=;QSV>#r8=Ee6i*Ee=6{ke+BTTrM_7Eip6d|RtD$# ztH9N)@9%^g0RF1f7i%9_?ABimoa?U+SBvv*G2y*W1Y$$jdBrhX89@jDRv!qwf~NJ&@U7AU%Xi(`I4Gb^G_anw9EBaUUnSsQE|x#L?0Y~TE>)*K80tL5EukoC-+cH4Fi z9D})!d=CYiZ`aNtw6z8NV9GJ<#@mN-OEh)+x&Y7A@~|xgS2OoMD<6za&CS0-I-7C~ zn!5E5pp;u*+xlR&?Wir^0IX(w`*Z<0+7PaOJ^l1MHFK~LT;28Rv%2lMK8B&0tH{m9 zV14s$ZuE2P+H-Eqkbt4A*FqxTpeY1+eXle+RZ*@rz*Fi#hlkTupBLIbi+e_WLhj+Y0_? zuyKO_32Yza=Hnx<{WI2FO1bz`N`1wjQQD69b4p`~zo0a}cpjyFvt8FpAC4C_x&7=7 z*FXBv3vOG%d%}$qya(Jq$n9r$xc!rRjhf6l^!Y06gTX%6%Dwil0rr_!Zhr=WUAuDE z`RZVwmBW8ku+P!K2ZFtBJGR~{bDk}@4_III1;Dl=UJz^y@j_tZix&plH`{d_AFwuO za*SwOxA4G@@ix|#=k^nsb93Nv;JN)wxSQv8Z6~p-dF~U(ef-H_@8hi-WBneQeU)>l z)lP-$7x(q2fz|YLy?b9D{XHGce)@c2|IdJ{Stt6f=J_W2V;SQ(8|>W1I4*>{ISy^- zu+L_99G13uET!wkHucx0UySiwuw%5{7~_x7@)*y9>lb4@AFQTdj4_Vk3(&NC{Ks?D zPvG`JTl7gS_sPD+I4=gfremB>!QC9Ewkz2$VRtN+_SC9w6i#pe?*gB{Nf)Ya~M|AJCI*3)fZwOAjwgVj8yZ2BQ?gd+?i9PDv zhh`n)T1OwXsB=HqI?buh&(N%6F0G@FTGV*}Y@Lbhar``p=JBJyc6qGLhr#+s{YT** zn|W-Hq3P#l8~Uh4oyWn}$zyv0%{nnQebl1PlVI!QvHcv)Ix#kV)berd@g03%p86hF zu@064$8qQNZ&|q4DD7VVmI22x@3FEJ+&)D8CBa_z9HZAkx!7x@zGAPRwj=i1Y7DX0 zUE_F*OEU3 z_PJBt-y`RJ!oy&7uUm0H>N(&Mw67`ci`ZEDMqeHS=e|4+SJQ6&C&1k~5j#%fJPG%6 zyl?IJ>StZ;@q1T)0rt7ZW7~P$l=^P2e{Gw9H)eMpEzSFylz!gp9O zK8tPFYsHJ$;xkS8OK`tOiTjyX;A%}tdljz!8hdl5E6Z46g)T-%_#IWG0x!D_Z4_8!FN_rG&IOrg(i@j1^w(A4e2 zUn%9r^mCoxb3?8cpYyy3R~v)PYx~8NFTvF>0{hv-LXtlKi+l_GBaE!0RNpbyiJNX-MebgflpA(~+ zhcD37?blpNdF0_u>Z(N^-h!(|9`*#k&91%&yLq6fy~981kq6HQ>hJQ;D0c19r>|+h z+NW>O)a}zeN_q6@J?g4OpWcV7R=5UyVB(;v|6m-hJm;eQ01zxY1jBe0sj z{{BOX+MoEx`)2Puw0ka_!yaSpyG+BUH|x^BALRO208RZa^5NQE5Uh42*tuT>?B?8S zTbO+zcIV#GeAFoQ_gKFR+!?$9wo1}s4GlnZ4Q){B=;Qi`I0M1CpR2;v>}SMY4Xz%sR|l)**n^T5vDZM0*w)d< z*wM~luE!}dQe_M9z1UjL|zB3rjPz!XNG{)<2ti0 zSj~9R=1{O}&o+Bf<~G-ZtLYPMt`Am^Ha7sP*kFkN68*&9&fMKS%j7{8`GG?AG%> zZ4R2c_b%dv7>sp2*5W#6DgQgV$FA3O`L>jD%ea1wqz~2W$98Dy_In#jx$$RHZ!^xv zb&WpCH;1cvPV@S8Hsuy@b#vx*+*sbz9ZWmgyf+SQOwkr88eOi9FPe2??j{t?&$!j} zpI9_5XLZf}N_)5|t=`{fl3v&Gy_t9Zf9B)8@B4Y)=Xu`c`~L6${{IY#@fWySVr;~wX8db; z9`9Ji*MS|Q_#v8?2`TQc^YmJ7hIZxgry~B7Lzt`G^rf&Uj(aMeI_ffBc z)$-ldYhce;?@v4TPv_ov|2wbOi|r6>ey^qP1hN|E4Y0BF$@f}sg4G-MT$lZMZ$|f( zeY11Xyx+6V(;2gV;xob8GY1#J)iOutf%Vt-3R=1NYFd5M?;5aL`fUd5pU+I=7^^-$ zpYYycss-}$eOcQS@OTc-S6h81()!ZJy)XfM6o)>x*3qB#2@cP9{k7?r=b4jzG=9@LiF)#}&vmb??@Tm#3pkl2pR>+4c@Ki1jr zzt1KJF@dNkK+CS1MNT8id) zjHh2~(aarfR?)gv+U%mGKKsron%9c-om(_c&)+k;Cwl;Aa*qX$P!PX*P0=5S6F<^6xmx9gdT3P#gxLSBSSpV>KU}J@^1)C>) z4cHpOSA(rDd==Q*!&ibGSNICBW6ruxqBhqxYva1M!PR|beiy^lYi&u<(%11FQ?zk~6Kw6V&j35F z@Mf@M&bm&aHrF-l;JPk{tJiC|0#Cw9o@>3jX!W|TDO$a*Yl~K|>$;-V z>)KwldR^BSE$iBoF3z>1SW#nZvx+Vtv+}{(pEc*%%065lUgG-a=bqe$ zei2RGzd6X?415XhJ#IIAwMYMQ(fur>p7+?3z}`#xO5Bst)QuItQ;MJ0;`p74rfzJX zZ+Ly)1h&7n^gRu14zKN}lzWoX(ae!pUjeHbE817V#MdIYdm+!esm>Rtpn_5)vLk2{H&@?eiesx*c#V5&#&RGse1bS2CSZS`YqTv>hZZ2?3!#U*QD#fYWg~- z+SHQwdT^ch2Dodao|rd+)x&QB*LBF;xxHqJ}6 zJeN6;HzhTnA4cnrrrv|Y7(Q!Jv%bk->$OeHUfBHpKD-Iuzq0p+8^igvhCXn8)ZM3Q zeZj`jW={L7nR6=GoKt9h{-l3@Y`%~818=7GL#uw;>VEdOHlIa*0$ct2fltEKwEN6` z0Jtk}#2L$BaBJ}yyf*#S5^o^5jyDLdmUx4~I}*?SV(mkU-Fk+C>;A*wYWAn;GMs<> zJJG~D67Juk<+mFn;QG{ux%4xZ_S8BGT<01MS4*xj;5ye>xPP~nT;t&S)Q7qBGnRJe zeHwMUUbf!j_!)C6d#>@jhX>HqUGG2B%B{uk8YU1+&F5$GiEuTq_2bd}UBS_C^>e`a zUBM){y7ysz-pw=pzYdU-7){-}9-@_J9-8T^mU)-~SIa!KfLl4#>+kSq6+QEi z_k-E!{;oiKYI=nDjWs=rrfyAtr_P4nPtbxrf(>UB*E;OdPvEkv^}?fG57 zBCzw9-xaih)%4AGbBn=V13u22YtMIc8S69Ds89X7f@jgx^SgrQz-k@DaP40N`*Q8I zy}3k&+iIe2CFT@X8u>fzRa)f-yHwq zFo&%<{z>axn?rwX`X$FJU~}Ym1^)r7nIrwx>i)(_|JT6w&pprUV6_hVTjT%0zN}H( zn;dU&Sck1S9;5BR#~k`=(=R#x3pPjIBj3`8BQ@Gzt?qA}^nV*{|9rRq4p=SstlPk9 z^>_RKue9u;chTHK1F6+M`aHvtJY9Hisq5SUu4bLd{QdvYdkKS3%e~)ya5eXgYkfcM{qXx}>)#b@ fMN{{^TkP)&?CZIf_c>enE9joPw%d3@F`VN)LAE*h literal 0 HcmV?d00001 diff --git a/assets/shaders/Rast.Wireframe.frag.slang b/assets/shaders/Rast.Wireframe.frag.slang index ced2f9a3..37c8baaf 100644 --- a/assets/shaders/Rast.Wireframe.frag.slang +++ b/assets/shaders/Rast.Wireframe.frag.slang @@ -9,7 +9,7 @@ FragmentOutput main() FragmentOutput output; output.OutColor = float4(0, 0, 0, 0); - output.OutColor.a = 0.15; + output.OutColor.a = 0.85; return output; -} \ No newline at end of file +} diff --git a/assets/shaders/common/BasicTypes.slang b/assets/shaders/common/BasicTypes.slang index 690f9ea0..545380b8 100644 --- a/assets/shaders/common/BasicTypes.slang +++ b/assets/shaders/common/BasicTypes.slang @@ -85,7 +85,11 @@ public struct ALIGN_16 UniformBufferObject public uint SuperResolution; public float SceneEpsilonScale; public float AmbientCubeUnit; + public float AmbientCubeOffsetPadding0; + public float AmbientCubeOffsetPadding1; + public float AmbientCubeOffsetPadding2; public float3 AmbientCubeOffset; + public float AmbientCubeOffsetPadding3; public float4 AmbientCubeCascadeParams; }; diff --git a/assets/shaders/common/GPUScene.slang b/assets/shaders/common/GPUScene.slang index c2de6f02..56577084 100644 --- a/assets/shaders/common/GPUScene.slang +++ b/assets/shaders/common/GPUScene.slang @@ -1,10 +1,12 @@ implementing Common; -[[vk::push_constant]] GPUScene gpuScene; +[[vk::binding(0, 1)]] +StructuredBuffer GPUSceneBuffer; + namespace Bindless { public GPUScene GetGpuscene() { - return gpuScene; + return GPUSceneBuffer[0]; } -} \ No newline at end of file +} diff --git a/src/Application/gkNextVisualTest/gkNextVisualTest.cpp b/src/Application/gkNextVisualTest/gkNextVisualTest.cpp index e465e65e..debff246 100644 --- a/src/Application/gkNextVisualTest/gkNextVisualTest.cpp +++ b/src/Application/gkNextVisualTest/gkNextVisualTest.cpp @@ -847,6 +847,7 @@ std::string VisualTestGameInstance::GetRendererName() case Vulkan::ERT_PathTracing: return "PathTracing"; case Vulkan::ERT_ModernDeferred: return "SoftTracing"; case Vulkan::ERT_LegacyDeferred: return "SoftModern"; + case Vulkan::ERT_LegacyDeferredNoAmbient: return "SoftModernNoAmbient"; case Vulkan::ERT_VoxelTracing: return "VoxelTracing"; default: return "Unknown"; } diff --git a/src/Assets/Core/Scene.cpp b/src/Assets/Core/Scene.cpp index 5810ef63..4ffadfd9 100644 --- a/src/Assets/Core/Scene.cpp +++ b/src/Assets/Core/Scene.cpp @@ -25,6 +25,8 @@ namespace Assets { + constexpr uint32_t maxGpuSceneBuffers = 8; + void Scene::RegisterReflection() { using namespace entt::literals; @@ -65,11 +67,14 @@ namespace Assets Scene::Scene(Vulkan::CommandPool& commandPool, bool supportRayTracing) { int flags = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT; + const bool usesAmbientCube = + !NextEngine::GetInstance() || NextEngine::GetInstance()->GetRenderer().CurrentRendererUsesAmbientCube(); + const uint32_t ambientCubeCascadeCapacity = usesAmbientCube ? Assets::CUBE_CASCADE_MAX : 1u; Vulkan::BufferUtil::CreateDeviceBufferLocal( commandPool, "VoxelDatas", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, - Assets::CUBE_CASCADE_MAX * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z * sizeof(Assets::VoxelData), + ambientCubeCascadeCapacity * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z * sizeof(Assets::VoxelData), farAmbientCubeBuffer_, farAmbientCubeBufferMemory_); Vulkan::BufferUtil::CreateDeviceBufferLocal( commandPool, "PageIndex", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, @@ -80,6 +85,16 @@ namespace Assets VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, sizeof(Assets::GPUDrivenStat), gpuDrivenStatsBuffer_, gpuDrivenStatsBuffer_Memory_); + gpuSceneBuffers_.resize(maxGpuSceneBuffers); + gpuSceneBufferMemories_.resize(maxGpuSceneBuffers); + for (uint32_t bufferIndex = 0; bufferIndex < maxGpuSceneBuffers; ++bufferIndex) + { + Vulkan::BufferUtil::CreateDeviceBufferLocal( + commandPool, "GPUScene", VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, sizeof(Assets::GPUScene), + gpuSceneBuffers_[bufferIndex], gpuSceneBufferMemories_[bufferIndex]); + } + Vulkan::BufferUtil::CreateDeviceBufferLocal( commandPool, "HDRSH", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, sizeof(SphericalHarmonics) * 100, hdrSHBuffer_, hdrSHBufferMemory_); @@ -91,7 +106,7 @@ namespace Assets indirectDrawBufferMemory_); // support 65535 nodes Vulkan::BufferUtil::CreateDeviceBufferLocal( commandPool, "AmbientCubes", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, - Assets::CUBE_CASCADE_MAX * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z * sizeof(Assets::AmbientCube), + ambientCubeCascadeCapacity * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z * sizeof(Assets::AmbientCube), ambientCubeBuffer_, ambientCubeBufferMemory_); // Single-cascade ping-pong snapshot for propagation-based ambient cube bake. @@ -148,6 +163,8 @@ namespace Assets farAmbientCubeBuffer_.reset(); farAmbientCubeBufferMemory_.reset(); + gpuSceneBuffers_.clear(); + gpuSceneBufferMemories_.clear(); pageIndexBuffer_.reset(); pageIndexBufferMemory_.reset(); @@ -573,7 +590,10 @@ namespace Assets UpdateNodesGpuDriven(); MarkDirty(); - cpuAccelerationStructure_.AsyncProcessFull(*this, farAmbientCubeBufferMemory_.get(), false); + if (!NextEngine::GetInstance() || NextEngine::GetInstance()->GetRenderer().CurrentRendererUsesAmbientCube()) + { + cpuAccelerationStructure_.AsyncProcessFull(*this, farAmbientCubeBufferMemory_.get(), false); + } } void Scene::CleanUp() { cpuAccelerationStructure_.ClearAllTasks(); } @@ -814,10 +834,29 @@ namespace Assets gpuScene_.JointMatrices = jointMatricesAddr_; gpuScene_.SwapChainIndex = imageIndex; + UpdateGPUSceneBuffer(imageIndex, gpuScene_); return gpuScene_; } + void Scene::UpdateGPUSceneBuffer(uint32_t imageIndex, const Assets::GPUScene& gpuScene) const + { + if (gpuSceneBufferMemories_.empty()) + { + return; + } + + const uint32_t bufferIndex = imageIndex % static_cast(gpuSceneBufferMemories_.size()); + void* data = gpuSceneBufferMemories_[bufferIndex]->Map(0, sizeof(Assets::GPUScene)); + std::memcpy(data, &gpuScene, sizeof(Assets::GPUScene)); + gpuSceneBufferMemories_[bufferIndex]->Unmap(); + } + + const Vulkan::Buffer& Scene::GPUSceneBuffer(uint32_t imageIndex) const + { + return *gpuSceneBuffers_[imageIndex % static_cast(gpuSceneBuffers_.size())]; + } + void Scene::PlayAllTracks() { for (auto& track : tracks_) @@ -991,7 +1030,8 @@ namespace Assets } } - if (NextEngine::GetInstance()->GetTotalFrames() % 30 == 0) + if (NextEngine::GetInstance()->GetRenderer().CurrentRendererUsesAmbientCube() && + NextEngine::GetInstance()->GetTotalFrames() % 30 == 0) { const bool voxelUploadCompleted = cpuAccelerationStructure_.Tick( *this, ambientCubeBufferMemory_.get(), farAmbientCubeBufferMemory_.get(), pageIndexBufferMemory_.get()); diff --git a/src/Assets/Core/Scene.hpp b/src/Assets/Core/Scene.hpp index 1e564187..efd3d2b8 100644 --- a/src/Assets/Core/Scene.hpp +++ b/src/Assets/Core/Scene.hpp @@ -59,6 +59,8 @@ namespace Assets // void RebuildBVH(); const Assets::GPUScene& FetchGPUScene(const uint32_t imageIndex) const; + void UpdateGPUSceneBuffer(uint32_t imageIndex, const Assets::GPUScene& gpuScene) const; + const Vulkan::Buffer& GPUSceneBuffer(uint32_t imageIndex) const; std::vector>& Nodes() { return nodes_; } const std::vector>& Nodes() const { return nodes_; } const std::vector& Models() const { return models_; } @@ -285,6 +287,8 @@ namespace Assets Assets::GPUDrivenStat gpuDrivenStat_; mutable Assets::GPUScene gpuScene_; + mutable std::vector> gpuSceneBuffers_; + mutable std::vector> gpuSceneBufferMemories_; glm::vec3 sceneAABBMin_{FLT_MAX, FLT_MAX, FLT_MAX}; glm::vec3 sceneAABBMax_{-FLT_MAX, -FLT_MAX, -FLT_MAX}; diff --git a/src/Assets/Data/Vertex.hpp b/src/Assets/Data/Vertex.hpp index 58283654..2f6c6b16 100644 --- a/src/Assets/Data/Vertex.hpp +++ b/src/Assets/Data/Vertex.hpp @@ -70,7 +70,7 @@ namespace Assets attributeDescriptions[0].binding = 0; attributeDescriptions[0].location = 0; - attributeDescriptions[0].format = VK_FORMAT_R16G16B16_SFLOAT; + attributeDescriptions[0].format = VK_FORMAT_R16G16B16A16_SFLOAT; attributeDescriptions[0].offset = 0; return attributeDescriptions; diff --git a/src/Assets/GPU/Texture.cpp b/src/Assets/GPU/Texture.cpp index 2bb909bf..ca86b2a1 100644 --- a/src/Assets/GPU/Texture.cpp +++ b/src/Assets/GPU/Texture.cpp @@ -54,6 +54,17 @@ namespace return hash; } + + bool ShouldEnableTextureWorkerUpload(const Vulkan::Device& device) + { + if (device.TransferFamilyIndex() == static_cast(device.GraphicsFamilyIndex())) + { + return false; + } + + const bool validationEnabled = GOption && GOption->Validation; + return !validationEnabled; + } } namespace Assets @@ -365,11 +376,11 @@ namespace Assets device_(device), commandPool_(commandPool), mainThreadCommandPool_(commandPoolMt), - textureWorkerUploadEnabled_(device.TransferFamilyIndex() != static_cast(device.GraphicsFamilyIndex())) + textureWorkerUploadEnabled_(ShouldEnableTextureWorkerUpload(device)) { if (!textureWorkerUploadEnabled_) { - SPDLOG_INFO("Texture uploads will run on the main thread because no dedicated transfer queue is available"); + SPDLOG_INFO("Texture uploads will run on the main thread because no dedicated transfer queue is available or validation mode is active"); } static const uint32_t kMaxBindlessResources = 65535u;// moltenVK returns a invalid value. std::min(65535u, device.DeviceProperties().limits.maxPerStageDescriptorSamplers); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 461a1a4e..9d6eda37 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -253,6 +253,11 @@ foreach(target IN LISTS AllTargets) else() target_compile_definitions(${target} PUBLIC GK_ENABLE_HOT_RELOAD=0) endif() + if (GK_ENABLE_SHADER_CLOCK) + target_compile_definitions(${target} PUBLIC GK_ENABLE_SHADER_CLOCK=1) + else() + target_compile_definitions(${target} PUBLIC GK_ENABLE_SHADER_CLOCK=0) + endif() if( IOS ) target_compile_definitions(${target} PUBLIC IOS=1) @@ -306,6 +311,10 @@ foreach(target IN LISTS AllTargets) target_include_directories(${target} PRIVATE ${STREAMLINE_INCLUDE_DIR}) target_link_directories(${target} PRIVATE ${STREAMLINE_LIB_DIR}) target_link_libraries(${target} PRIVATE sl.interposer) + if ( WIN32 ) + target_link_libraries(${target} PRIVATE delayimp) + target_link_options(${target} PRIVATE "/DELAYLOAD:sl.interposer.dll") + endif() endif() elseif ( ${target} STREQUAL NextGameplay ) set_target_properties(${target} PROPERTIES FOLDER "Gameplay") @@ -318,6 +327,10 @@ foreach(target IN LISTS AllTargets) if ( WITH_STREAMLINE ) target_link_directories(${target} PRIVATE ${STREAMLINE_LIB_DIR}) target_link_libraries(${target} PRIVATE sl.interposer) + if ( WIN32 ) + target_link_libraries(${target} PRIVATE delayimp) + target_link_options(${target} PRIVATE "/DELAYLOAD:sl.interposer.dll") + endif() endif() endif() diff --git a/src/Rendering/PathTracing/PathTracingRenderer.cpp b/src/Rendering/PathTracing/PathTracingRenderer.cpp index b39a697d..a2cbd055 100644 --- a/src/Rendering/PathTracing/PathTracingRenderer.cpp +++ b/src/Rendering/PathTracing/PathTracingRenderer.cpp @@ -22,9 +22,9 @@ namespace Vulkan::RayTracing void PathTracingRenderer::CreateSwapChain(const VkExtent2D& extent) { - rayTracingPipeline_.reset(new PipelineCommon::ZeroBindWithTLASPipeline( SwapChain(), "assets/shaders/Core.PathTracing.comp.slang.spv")); + rayTracingPipeline_.reset(new PipelineCommon::ZeroBindWithTLASPipeline( SwapChain(), "assets/shaders/Core.PathTracing.comp.slang.spv", GetScene())); accumulatePipeline_.reset(new PipelineCommon::ZeroBindCustomPushConstantPipeline(SwapChain(), "assets/shaders/Process.ReProject.comp.slang.spv", 24)); - composePipelineNonDenoiser_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Process.DenoiseJBF.comp.slang.spv")); + composePipelineNonDenoiser_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Process.DenoiseJBF.comp.slang.spv", GetScene())); if (GOption->ReferenceMode) { diff --git a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp index 18415720..97cd394a 100644 --- a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp +++ b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp @@ -16,15 +16,48 @@ namespace Vulkan::PipelineCommon { - ZeroBindWithTLASPipeline::ZeroBindWithTLASPipeline(const SwapChain& swapChain, const char* shaderfile):PipelineBase(swapChain) + namespace + { + std::unique_ptr CreateGPUSceneDescriptorSetManager( + const Device& device, + const Assets::Scene& scene, + uint32_t setCount, + VkShaderStageFlags stageFlags) + { + const std::vector descriptorBindings = + { + {0, 1, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, stageFlags}, + }; + + auto descriptorSetManager = std::make_unique(device, descriptorBindings, setCount); + auto& descriptorSets = descriptorSetManager->DescriptorSets(); + + for (uint32_t i = 0; i < setCount; ++i) + { + VkDescriptorBufferInfo gpuSceneBufferInfo = {}; + gpuSceneBufferInfo.buffer = scene.GPUSceneBuffer(i).Handle(); + gpuSceneBufferInfo.range = sizeof(Assets::GPUScene); + + const std::vector descriptorWrites = + { + descriptorSets.Bind(i, 0, gpuSceneBufferInfo), + }; + descriptorSets.UpdateDescriptors(i, descriptorWrites); + } + + return descriptorSetManager; + } + } + + ZeroBindWithTLASPipeline::ZeroBindWithTLASPipeline( + const SwapChain& swapChain, + const char* shaderfile, + const Assets::Scene& scene):PipelineBase(swapChain) { // Create descriptor pool/sets. const auto& device = swapChain.Device(); - - VkPushConstantRange pushConstantRange{}; - pushConstantRange.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; - pushConstantRange.offset = 0; - pushConstantRange.size = sizeof(Assets::GPUScene); + descriptorSetManager_ = CreateGPUSceneDescriptorSetManager( + device, scene, static_cast(swapChain.Images().size()), VK_SHADER_STAGE_COMPUTE_BIT); #if ANDROID std::vector descriptorBindings = @@ -51,12 +84,13 @@ namespace Vulkan::PipelineCommon std::vector managers = { &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), + descriptorSetManager_.get(), #if ANDROID descriptorSetManager_.get() #endif }; - pipelineLayout_.reset(new class PipelineLayout(device, managers, 1, &pushConstantRange, 1)); + pipelineLayout_.reset(new class PipelineLayout(device, managers, static_cast(swapChain.Images().size()))); const ShaderModule denoiseShader(device, shaderfile); @@ -73,26 +107,26 @@ namespace Vulkan::PipelineCommon uint32_t imageIndex) { vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, Handle()); - PipelineLayout().BindDescriptorSets(commandBuffer, 0); - vkCmdPushConstants(commandBuffer, PipelineLayout().Handle(), VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &(scene.FetchGPUScene(imageIndex))); + scene.FetchGPUScene(imageIndex); + PipelineLayout().BindDescriptorSets(commandBuffer, imageIndex); } - ZeroBindPipeline::ZeroBindPipeline(const SwapChain& swapChain, const char* shaderfile):PipelineBase(swapChain) + ZeroBindPipeline::ZeroBindPipeline( + const SwapChain& swapChain, + const char* shaderfile, + const Assets::Scene& scene):PipelineBase(swapChain) { // Create descriptor pool/sets. const auto& device = swapChain.Device(); - - VkPushConstantRange pushConstantRange{}; - pushConstantRange.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; - pushConstantRange.offset = 0; - pushConstantRange.size = sizeof(Assets::GPUScene); + descriptorSetManager_ = CreateGPUSceneDescriptorSetManager( + device, scene, static_cast(swapChain.Images().size()), VK_SHADER_STAGE_COMPUTE_BIT); std::vector managers = { - &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager() + &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), + descriptorSetManager_.get() }; - pipelineLayout_.reset(new class PipelineLayout(device, managers, 1, &pushConstantRange, 1)); + pipelineLayout_.reset(new class PipelineLayout(device, managers, static_cast(swapChain.Images().size()))); const ShaderModule denoiseShader(device, shaderfile); @@ -108,9 +142,8 @@ namespace Vulkan::PipelineCommon void ZeroBindPipeline::BindPipeline(VkCommandBuffer commandBuffer, const Assets::Scene& scene, uint32_t imageIndex) { vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, Handle()); - PipelineLayout().BindDescriptorSets(commandBuffer, 0); - vkCmdPushConstants(commandBuffer, PipelineLayout().Handle(), VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &(scene.FetchGPUScene(imageIndex))); + scene.FetchGPUScene(imageIndex); + PipelineLayout().BindDescriptorSets(commandBuffer, imageIndex); } ZeroBindCustomPushConstantPipeline::ZeroBindCustomPushConstantPipeline(const SwapChain& swapChain, @@ -245,13 +278,15 @@ namespace Vulkan::PipelineCommon colorBlending.blendConstants[2] = 0.0f; // Optional colorBlending.blendConstants[3] = 0.0f; // Optional - VkPushConstantRange pushConstantRange{}; - pushConstantRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; - pushConstantRange.offset = 0; - pushConstantRange.size = sizeof(Assets::GPUScene); + descriptorSetManager_ = CreateGPUSceneDescriptorSetManager( + device, scene, static_cast(swapChain.Images().size()), VK_SHADER_STAGE_VERTEX_BIT); + std::vector managers = { + &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), + descriptorSetManager_.get() + }; // Create pipeline layout and render pass. - pipelineLayout_.reset(new class PipelineLayout(device, &pushConstantRange, 1)); + pipelineLayout_.reset(new class PipelineLayout(device, managers, static_cast(swapChain.Images().size()))); renderPass_.reset(new class RenderPass(swapChain, VK_FORMAT_R32_UINT, depthBuffer, VK_ATTACHMENT_LOAD_OP_CLEAR, VK_ATTACHMENT_LOAD_OP_CLEAR)); renderPass_->SetDebugName("Visibility Render Pass"); // Load shaders. @@ -339,7 +374,10 @@ namespace Vulkan::PipelineCommon rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; rasterizer.depthClampEnable = VK_FALSE; rasterizer.rasterizerDiscardEnable = VK_FALSE; - rasterizer.polygonMode = isWireFrame ? VK_POLYGON_MODE_FILL : VK_POLYGON_MODE_FILL; + VkPhysicalDeviceFeatures physicalDeviceFeatures = {}; + vkGetPhysicalDeviceFeatures(device.PhysicalDevice(), &physicalDeviceFeatures); + rasterizer.polygonMode = + isWireFrame && physicalDeviceFeatures.fillModeNonSolid ? VK_POLYGON_MODE_LINE : VK_POLYGON_MODE_FILL; rasterizer.lineWidth = 1.0f; rasterizer.cullMode = VK_CULL_MODE_BACK_BIT; rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; diff --git a/src/Rendering/PipelineCommon/CommonComputePipeline.hpp b/src/Rendering/PipelineCommon/CommonComputePipeline.hpp index d6e468e3..cfb423e5 100644 --- a/src/Rendering/PipelineCommon/CommonComputePipeline.hpp +++ b/src/Rendering/PipelineCommon/CommonComputePipeline.hpp @@ -30,7 +30,7 @@ namespace Vulkan::PipelineCommon { public: VULKAN_NON_COPIABLE(ZeroBindWithTLASPipeline) - ZeroBindWithTLASPipeline(const SwapChain& swapChain, const char* shaderfile); + ZeroBindWithTLASPipeline(const SwapChain& swapChain, const char* shaderfile, const Assets::Scene& scene); void BindPipeline(VkCommandBuffer commandBuffer, const Assets::Scene& scene, uint32_t imageIndex); }; @@ -39,7 +39,7 @@ namespace Vulkan::PipelineCommon { public: VULKAN_NON_COPIABLE(ZeroBindPipeline) - ZeroBindPipeline(const SwapChain& swapChain, const char* shaderfile); + ZeroBindPipeline(const SwapChain& swapChain, const char* shaderfile, const Assets::Scene& scene); void BindPipeline(VkCommandBuffer commandBuffer, const Assets::Scene& scene, uint32_t imageIndex); }; diff --git a/src/Rendering/RayTraceBaseRenderer.cpp b/src/Rendering/RayTraceBaseRenderer.cpp index 0ec03ac8..1674b1b4 100644 --- a/src/Rendering/RayTraceBaseRenderer.cpp +++ b/src/Rendering/RayTraceBaseRenderer.cpp @@ -123,7 +123,10 @@ namespace Vulkan::RayTracing void RayTraceBaseRenderer::CreateSwapChain() { Vulkan::VulkanBaseRenderer::CreateSwapChain(); - directLightGenPipeline_.reset(new PipelineCommon::ZeroBindWithTLASPipeline(SwapChain(), "assets/shaders/Bake.HwAmbientCube.comp.slang.spv")); + if (CurrentRendererUsesAmbientCube()) + { + directLightGenPipeline_.reset(new PipelineCommon::ZeroBindWithTLASPipeline(SwapChain(), "assets/shaders/Bake.HwAmbientCube.comp.slang.spv", GetScene())); + } } void RayTraceBaseRenderer::DeleteSwapChain() @@ -222,7 +225,7 @@ namespace Vulkan::RayTracing { VulkanBaseRenderer::PostRender(commandBuffer, imageIndex); - if(supportRayTracing_ && !GOption->ForceSoftGen) + if(CurrentRendererUsesAmbientCube() && supportRayTracing_ && !GOption->ForceSoftGen) { const int cubesPerGroup = 64; const int perCascadeCount = Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z; @@ -315,9 +318,7 @@ namespace Vulkan::RayTracing gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation ? 1u : 0u; - vkCmdPushConstants(commandBuffer, directLightGenPipeline_->PipelineLayout().Handle(), VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); - + GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); vkCmdDispatch(commandBuffer, dispatchGroupCount, 1, 1); } } diff --git a/src/Rendering/SoftwareModern/SoftwareModernRenderer.cpp b/src/Rendering/SoftwareModern/SoftwareModernRenderer.cpp index 86d3ed61..14bd6dff 100644 --- a/src/Rendering/SoftwareModern/SoftwareModernRenderer.cpp +++ b/src/Rendering/SoftwareModern/SoftwareModernRenderer.cpp @@ -5,9 +5,13 @@ #include "Vulkan/WindowSurface.hpp" #include "Vulkan/GpuResources.hpp" +#include + namespace Vulkan::LegacyDeferred { -SoftwareModernRenderer::SoftwareModernRenderer(Vulkan::VulkanBaseRenderer& baseRender):LogicRendererBase(baseRender) +SoftwareModernRenderer::SoftwareModernRenderer(Vulkan::VulkanBaseRenderer& baseRender, std::string shaderPath) : + LogicRendererBase(baseRender), + shaderPath_(std::move(shaderPath)) { } @@ -19,9 +23,9 @@ SoftwareModernRenderer::~SoftwareModernRenderer() void SoftwareModernRenderer::CreateSwapChain(const VkExtent2D& extent) { - deferredShadingPipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Core.SwModern.comp.slang.spv")); + deferredShadingPipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), shaderPath_.c_str(), GetScene())); accumulatePipeline_.reset(new PipelineCommon::ZeroBindCustomPushConstantPipeline(SwapChain(), "assets/shaders/Process.ReProject.comp.slang.spv", 24)); - composePipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Process.DenoiseJBF.comp.slang.spv")); + composePipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Process.DenoiseJBF.comp.slang.spv", GetScene())); if (GOption->ReferenceMode) { @@ -56,8 +60,23 @@ void SoftwareModernRenderer::Render(VkCommandBuffer commandBuffer, uint32_t imag vkCmdDispatch(commandBuffer, SwapChain().RenderExtent().width / 8, SwapChain().RenderExtent().height / 8, 1); // copy to swap-buffer - baseRender_.GetStorageImage(Assets::Bindless::RT_DENOISED)->InsertBarrier(commandBuffer, VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_LAYOUT_GENERAL, - VK_IMAGE_LAYOUT_GENERAL); + const auto transitionShadingOutput = [this, commandBuffer](uint32_t bindlessId) + { + baseRender_.GetStorageImage(bindlessId)->InsertBarrier(commandBuffer, VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); + }; + transitionShadingOutput(Assets::Bindless::RT_SINGLE_DIFFUSE); + transitionShadingOutput(Assets::Bindless::RT_SINGLE_SPECULAR); + transitionShadingOutput(Assets::Bindless::RT_ALBEDO); + transitionShadingOutput(Assets::Bindless::RT_NORMAL); + transitionShadingOutput(Assets::Bindless::RT_OBJEDCTID_0); + transitionShadingOutput(Assets::Bindless::RT_MOTIONVECTOR); + transitionShadingOutput(Assets::Bindless::RT_PREV_DEPTHBUFFER); + transitionShadingOutput(Assets::Bindless::RT_DIFFUSE_HITDIST); + transitionShadingOutput(Assets::Bindless::RT_SPECULAR_HITDIST); + transitionShadingOutput(Assets::Bindless::RT_SPECULAR_ALBEDO); + baseRender_.GetStorageImage(Assets::Bindless::RT_DENOISED)->InsertBarrier(commandBuffer, VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); } { @@ -116,7 +135,7 @@ Vulkan::VoxelTracing::VoxelTracingRenderer::~VoxelTracingRenderer() void Vulkan::VoxelTracing::VoxelTracingRenderer::CreateSwapChain(const VkExtent2D& extent) { - deferredShadingPipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Core.VoxelTracing.comp.slang.spv")); + deferredShadingPipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Core.VoxelTracing.comp.slang.spv", GetScene())); } void Vulkan::VoxelTracing::VoxelTracingRenderer::DeleteSwapChain() diff --git a/src/Rendering/SoftwareModern/SoftwareModernRenderer.hpp b/src/Rendering/SoftwareModern/SoftwareModernRenderer.hpp index f5bd4314..126d1567 100644 --- a/src/Rendering/SoftwareModern/SoftwareModernRenderer.hpp +++ b/src/Rendering/SoftwareModern/SoftwareModernRenderer.hpp @@ -5,6 +5,7 @@ #include "Rendering/RayTraceBaseRenderer.hpp" #include +#include namespace Vulkan { @@ -25,7 +26,8 @@ namespace Vulkan::LegacyDeferred public: VULKAN_NON_COPIABLE(SoftwareModernRenderer) - SoftwareModernRenderer(Vulkan::VulkanBaseRenderer& baseRender); + explicit SoftwareModernRenderer(Vulkan::VulkanBaseRenderer& baseRender, + std::string shaderPath = "assets/shaders/Core.SwModern.comp.slang.spv"); ~SoftwareModernRenderer(); void CreateSwapChain(const VkExtent2D& extent) override; @@ -36,6 +38,7 @@ namespace Vulkan::LegacyDeferred std::unique_ptr deferredShadingPipeline_; std::unique_ptr composePipeline_; std::unique_ptr accumulatePipeline_; + std::string shaderPath_; uint32_t prevSingleDiffuseId_{}; uint32_t prevSingleSpecularId_{}; diff --git a/src/Rendering/SoftwareTracing/SoftwareTracingRenderer.cpp b/src/Rendering/SoftwareTracing/SoftwareTracingRenderer.cpp index 6e26ddb6..7255bc93 100644 --- a/src/Rendering/SoftwareTracing/SoftwareTracingRenderer.cpp +++ b/src/Rendering/SoftwareTracing/SoftwareTracingRenderer.cpp @@ -21,9 +21,9 @@ SoftwareTracingRenderer::~SoftwareTracingRenderer() void SoftwareTracingRenderer::CreateSwapChain(const VkExtent2D& extent) { - deferredShadingPipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Core.SwTracing.comp.slang.spv")); + deferredShadingPipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Core.SwTracing.comp.slang.spv", GetScene())); accumulatePipeline_.reset(new PipelineCommon::ZeroBindCustomPushConstantPipeline(SwapChain(), "assets/shaders/Process.ReProject.comp.slang.spv", 24)); - composePipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Process.DenoiseJBF.comp.slang.spv")); + composePipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Process.DenoiseJBF.comp.slang.spv", GetScene())); if (GOption->ReferenceMode) { diff --git a/src/Rendering/VulkanBaseRenderer.cpp b/src/Rendering/VulkanBaseRenderer.cpp index 0d2e420d..3b71b646 100644 --- a/src/Rendering/VulkanBaseRenderer.cpp +++ b/src/Rendering/VulkanBaseRenderer.cpp @@ -29,6 +29,7 @@ #include "Utilities/Exception.hpp" #include +#include #include "Common/CoreMinimal.hpp" #include "Options.hpp" @@ -40,6 +41,10 @@ #include #include +#if WITH_STREAMLINE && WIN32 +#include +#endif + #if WITH_STREAMLINE #include #include @@ -69,19 +74,93 @@ static sl::float4x4 toSlMatrix(const glm::mat4& m) res.row[3] = sl::float4(m[0][3], m[1][3], m[2][3], m[3][3]); return res; } + +static bool HasNvidiaAdapter() +{ +#if WIN32 + HMODULE dxgiModule = LoadLibraryW(L"dxgi.dll"); + if (!dxgiModule) + { + return false; + } + + using CreateDXGIFactory1Fn = HRESULT(WINAPI*)(REFIID, void**); + auto createFactory = reinterpret_cast( + GetProcAddress(dxgiModule, "CreateDXGIFactory1")); + if (!createFactory) + { + FreeLibrary(dxgiModule); + return false; + } + + IDXGIFactory1* factory = nullptr; + if (FAILED(createFactory(__uuidof(IDXGIFactory1), reinterpret_cast(&factory)))) + { + FreeLibrary(dxgiModule); + return false; + } + + bool hasNvidiaAdapter = false; + for (UINT adapterIndex = 0;; ++adapterIndex) + { + IDXGIAdapter1* adapter = nullptr; + const HRESULT result = factory->EnumAdapters1(adapterIndex, &adapter); + if (result == DXGI_ERROR_NOT_FOUND) + { + break; + } + if (FAILED(result)) + { + continue; + } + + DXGI_ADAPTER_DESC1 desc{}; + if (SUCCEEDED(adapter->GetDesc1(&desc)) && desc.VendorId == 0x10DE) + { + hasNvidiaAdapter = true; + } + adapter->Release(); + + if (hasNvidiaAdapter) + { + break; + } + } + + factory->Release(); + FreeLibrary(dxgiModule); + return hasNvidiaAdapter; +#else + return false; +#endif +} #endif namespace StreamlineWrapper { bool GStreamLineInit = false; + bool GStreamLineInitAttempted = false; bool GStreamLineEnabled = false; - - void LazyInit(VkDevice device, VkInstance instance, VkPhysicalDevice physicalDevice, uint32_t computeQueueIdx, uint32_t computeQueueFamily, uint32_t graphicsQueueIdx, uint32_t graphicsQueueFamily, bool& outSupportDLSS, bool& outSupportDLSSRR) - { + bool GStreamLineVulkanInfoSet = false; + + bool ShouldInitialize() + { #if WITH_STREAMLINE - if (GStreamLineInit) return; - GStreamLineInit = true; - + return HasNvidiaAdapter(); +#else + return false; +#endif + } + + void Initialize() + { +#if WITH_STREAMLINE + if (GStreamLineInitAttempted) + { + return; + } + GStreamLineInitAttempted = true; + sl::Preferences pref{}; //pref.showConsole = true; // for debugging, set to false in production //pref.logLevel = sl::LogLevel::eVerbose; @@ -101,12 +180,35 @@ namespace StreamlineWrapper //pref.renderAPI = sl::RenderAPI::eVulkan; sl::Result res; - if(SL_FAILED(res, slInit(pref))) + if (SL_FAILED(res, slInit(pref))) { SPDLOG_ERROR("Streamline slInit failed: {}", (int)res); return; } - + + GStreamLineInit = true; + GStreamLineEnabled = true; +#endif + } + + void LazyInit(VkDevice device, VkInstance instance, VkPhysicalDevice physicalDevice, uint32_t computeQueueIdx, uint32_t computeQueueFamily, uint32_t graphicsQueueIdx, uint32_t graphicsQueueFamily, bool& outSupportDLSS, bool& outSupportDLSSRR) + { +#if WITH_STREAMLINE + Initialize(); + if (!GStreamLineInit) + { + outSupportDLSS = false; + outSupportDLSSRR = false; + return; + } + + if (GStreamLineVulkanInfoSet) + { + return; + } + GStreamLineVulkanInfoSet = true; + + sl::Result res; sl::VulkanInfo slVulkanInfo{}; slVulkanInfo.device = device; slVulkanInfo.instance = instance; @@ -117,9 +219,9 @@ namespace StreamlineWrapper slVulkanInfo.graphicsQueueFamily = graphicsQueueFamily; if(SL_FAILED(res, slSetVulkanInfo(slVulkanInfo))) - { + { SPDLOG_ERROR("Streamline slSetVulkanInfo failed: {}", (int)res); - } + } else { SPDLOG_INFO("Streamline Initialized Successfully."); @@ -135,8 +237,6 @@ namespace StreamlineWrapper SPDLOG_INFO("DLSS Support: {}, RR Support: {}", outSupportDLSS, outSupportDLSSRR); } - - GStreamLineEnabled = true; #else outSupportDLSS = false; outSupportDLSSRR = false; @@ -209,6 +309,32 @@ namespace static_cast(swapChain.PresentMode())); } + bool HasDeviceExtension(VkPhysicalDevice physicalDevice, const char* requiredExtension) + { + const auto extensions = Vulkan::GetEnumerateVector(physicalDevice, static_cast(nullptr), + vkEnumerateDeviceExtensionProperties); + return std::any_of(extensions.begin(), extensions.end(), + [requiredExtension](const VkExtensionProperties& extension) + { + return std::strcmp(extension.extensionName, requiredExtension) == 0; + }); + } + + bool AddDeviceExtensionIfAvailable(VkPhysicalDevice physicalDevice, + std::vector& requiredExtensions, + const char* extensionName, + const char* featureName) + { + if (HasDeviceExtension(physicalDevice, extensionName)) + { + requiredExtensions.push_back(extensionName); + return true; + } + + SPDLOG_WARN("{} disabled because device extension {} is unavailable", featureName, extensionName); + return false; + } + void SetVulkanDevice(Vulkan::VulkanBaseRenderer& application, uint32_t gpuIdx) { const auto& physicalDevices = application.PhysicalDevices(); @@ -291,8 +417,32 @@ namespace Vulkan VkPhysicalDeviceFeatures deviceFeatures = {}; - deviceFeatures.multiDrawIndirect = true; - deviceFeatures.drawIndirectFirstInstance = true; + VkPhysicalDeviceMemoryProperties memoryProperties = {}; + vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memoryProperties); + VkDeviceSize largestDeviceLocalHeapSize = 0; + for (uint32_t heapIndex = 0; heapIndex < memoryProperties.memoryHeapCount; ++heapIndex) + { + if ((memoryProperties.memoryHeaps[heapIndex].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) != 0) + { + largestDeviceLocalHeapSize = + std::max(largestDeviceLocalHeapSize, memoryProperties.memoryHeaps[heapIndex].size); + } + } + + const VkDeviceSize perCascadeCount = + static_cast(Assets::CUBE_SIZE_XY) * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z; + const VkDeviceSize fullAmbientCubeAllocationSize = + static_cast(Assets::CUBE_CASCADE_MAX) * perCascadeCount * + (sizeof(Assets::VoxelData) + sizeof(Assets::AmbientCube)) + + perCascadeCount * (sizeof(Assets::AmbientCube) + sizeof(glm::u32vec4)); + fullAmbientCubeBudget_ = largestDeviceLocalHeapSize >= fullAmbientCubeAllocationSize; + if (!fullAmbientCubeBudget_) + { + SPDLOG_WARN("Largest Vulkan device-local memory heap is {} MB, smaller than full ambient-cube allocation {} MB; ambient-cube renderers will use the no-ambient fallback", + static_cast(largestDeviceLocalHeapSize / (1024 * 1024)), + static_cast(fullAmbientCubeAllocationSize / (1024 * 1024))); + } + supportRayTracing_ = !GOption->ForceNoRT && instance_->SupportsRayQuery(physicalDevice); SetPhysicalDeviceImpl(physicalDevice, requiredExtensions, deviceFeatures, nullptr); @@ -313,8 +463,8 @@ namespace Vulkan PrintVulkanSwapChainInformation(*this); currentFrame_ = 0; - supportDLSS_ = true; - supportDLSSRR_ = true; + supportDLSS_ = streamlineDeviceExtensionsEnabled_; + supportDLSSRR_ = streamlineDeviceExtensionsEnabled_; } void VulkanBaseRenderer::End() @@ -352,19 +502,44 @@ namespace Vulkan VkPhysicalDeviceFeatures& deviceFeatures, void* nextDeviceFeatures) { - deviceFeatures.fillModeNonSolid = false; + VkPhysicalDeviceFeatures supportedFeatures = {}; + vkGetPhysicalDeviceFeatures(physicalDevice, &supportedFeatures); + + VkPhysicalDeviceProperties deviceProperties = {}; + vkGetPhysicalDeviceProperties(physicalDevice, &deviceProperties); + auto enableDeviceExtensionIfAvailable = [&](const char* extensionName) + { + if (HasDeviceExtension(physicalDevice, extensionName) && + std::find(requiredExtensions.begin(), requiredExtensions.end(), extensionName) == requiredExtensions.end()) + { + requiredExtensions.push_back(extensionName); + } + }; + + enableDeviceExtensionIfAvailable(VK_KHR_BUFFER_DEVICE_ADDRESS_EXTENSION_NAME); + enableDeviceExtensionIfAvailable(VK_EXT_DESCRIPTOR_INDEXING_EXTENSION_NAME); + enableDeviceExtensionIfAvailable(VK_KHR_16BIT_STORAGE_EXTENSION_NAME); + enableDeviceExtensionIfAvailable(VK_KHR_SHADER_FLOAT16_INT8_EXTENSION_NAME); + if (deviceProperties.apiVersion < VK_API_VERSION_1_2 && + !HasDeviceExtension(physicalDevice, VK_KHR_BUFFER_DEVICE_ADDRESS_EXTENSION_NAME)) + { + Throw(std::runtime_error("VK_KHR_buffer_device_address is required")); + } + + deviceFeatures.multiDrawIndirect = true; + deviceFeatures.drawIndirectFirstInstance = true; + deviceFeatures.fillModeNonSolid = supportedFeatures.fillModeNonSolid; deviceFeatures.samplerAnisotropy = true; deviceFeatures.shaderStorageImageReadWithoutFormat = true; deviceFeatures.shaderStorageImageWriteWithoutFormat = true; deviceFeatures.shaderInt16 = true; deviceFeatures.shaderInt64 = true; - // Required extensions. windows only -#if WIN32 + // Optional heatmap instrumentation. +#if WIN32 && GK_ENABLE_SHADER_CLOCK requiredExtensions.insert(requiredExtensions.end(), { VK_KHR_SHADER_CLOCK_EXTENSION_NAME, - VK_NVX_BINARY_IMPORT_EXTENSION_NAME, VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME }); @@ -378,7 +553,7 @@ namespace Vulkan // support bindless material VkPhysicalDeviceDescriptorIndexingFeatures indexingFeatures = {}; indexingFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_INDEXING_FEATURES; -#if WIN32 +#if WIN32 && GK_ENABLE_SHADER_CLOCK indexingFeatures.pNext = &shaderClockFeatures; #else indexingFeatures.pNext = nextDeviceFeatures; @@ -421,16 +596,26 @@ namespace Vulkan VkPhysicalDeviceVulkan12Features deviceVulkan12Features = {}; deviceVulkan12Features.timelineSemaphore = true; deviceVulkan12Features.pNext = &shaderDrawParametersFeatures; - storage16BitFeatures.pNext = &deviceVulkan12Features; - - requiredExtensions.insert(requiredExtensions.end(), - { - VK_NVX_IMAGE_VIEW_HANDLE_EXTENSION_NAME, - VK_KHR_BUFFER_DEVICE_ADDRESS_EXTENSION_NAME, - VK_EXT_BUFFER_DEVICE_ADDRESS_EXTENSION_NAME - }); + const bool hasStreamlineExtensions = + AddDeviceExtensionIfAvailable(physicalDevice, requiredExtensions, + VK_NVX_BINARY_IMPORT_EXTENSION_NAME, "Streamline binary import") && + AddDeviceExtensionIfAvailable(physicalDevice, requiredExtensions, + VK_NVX_IMAGE_VIEW_HANDLE_EXTENSION_NAME, "Streamline image view handles") && + AddDeviceExtensionIfAvailable(physicalDevice, requiredExtensions, + VK_KHR_BUFFER_DEVICE_ADDRESS_EXTENSION_NAME, "Streamline buffer device address") && + AddDeviceExtensionIfAvailable(physicalDevice, requiredExtensions, + VK_EXT_BUFFER_DEVICE_ADDRESS_EXTENSION_NAME, "Streamline EXT buffer device address"); + if (hasStreamlineExtensions) + { + storage16BitFeatures.pNext = &deviceVulkan12Features; + streamlineDeviceExtensionsEnabled_ = true; + } + else + { + streamlineDeviceExtensionsEnabled_ = false; + } #endif - + device_.reset(new class Device(physicalDevice, *surface_, requiredExtensions, deviceFeatures, &storage16BitFeatures)); commandPool_.reset(new class CommandPool(*device_, device_->GraphicsFamilyIndex(), 0, true)); @@ -569,21 +754,24 @@ namespace Vulkan CreateRenderImages(); // 最简单的fallback pipeline, 也用作 wireframe pipeline - wireframePipeline_.reset(new class PipelineCommon::GraphicsPipeline(SwapChain(), DepthBuffer(), UniformBuffers(), GetScene(), true)); - wireframeFramebuffer_.reset(new FrameBuffer(swapChain_->RenderExtent(), GetStorageImage(Assets::Bindless::RT_DENOISED)->GetImageView(), wireframePipeline_->RenderPass())); + //wireframePipeline_.reset(new class PipelineCommon::GraphicsPipeline(SwapChain(), DepthBuffer(), UniformBuffers(), GetScene(), true)); + //wireframeFramebuffer_.reset(new FrameBuffer(swapChain_->RenderExtent(), GetStorageImage(Assets::Bindless::RT_DENOISED)->GetImageView(), wireframePipeline_->RenderPass())); // 公用Pipeline simpleComposePipeline_.reset( new PipelineCommon::ZeroBindCustomPushConstantPipeline(SwapChain(), "assets/shaders/Process.UpScaleFSR.comp.slang.spv", 20)); bufferClearPipeline_.reset(new PipelineCommon::ZeroBindCustomPushConstantPipeline(*swapChain_, "assets/shaders/Util.BufferClear.comp.slang.spv", 4)); - softAmbientCubeGenPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.SwAmbientCube.comp.slang.spv")); - clearAmbientCubeCachePipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.ClearAmbientCubeCache.comp.slang.spv")); - propagationAmbientCubeGenPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.PropagationAmbientCube.comp.slang.spv")); - injectAmbientCubeGenPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.InjectAmbientCube.comp.slang.spv")); - distanceFieldInitPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.DistanceFieldInit.comp.slang.spv")); - distanceFieldJumpPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.DistanceFieldJump.comp.slang.spv")); - distanceFieldResolvePipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.DistanceFieldResolve.comp.slang.spv")); - gpuCullPipeline_.reset(new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Task.GpuCull.comp.slang.spv")); - skinningPipeline_.reset(new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Task.Skinning.comp.slang.spv")); + if (CurrentRendererUsesAmbientCube()) + { + softAmbientCubeGenPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.SwAmbientCube.comp.slang.spv", GetScene())); + clearAmbientCubeCachePipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.ClearAmbientCubeCache.comp.slang.spv", GetScene())); + propagationAmbientCubeGenPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.PropagationAmbientCube.comp.slang.spv", GetScene())); + injectAmbientCubeGenPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.InjectAmbientCube.comp.slang.spv", GetScene())); + distanceFieldInitPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.DistanceFieldInit.comp.slang.spv", GetScene())); + distanceFieldJumpPipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.DistanceFieldJump.comp.slang.spv", GetScene())); + distanceFieldResolvePipeline_.reset( new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Bake.DistanceFieldResolve.comp.slang.spv", GetScene())); + } + gpuCullPipeline_.reset(new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Task.GpuCull.comp.slang.spv", GetScene())); + skinningPipeline_.reset(new PipelineCommon::ZeroBindPipeline(*swapChain_, "assets/shaders/Task.Skinning.comp.slang.spv", GetScene())); visualDebuggerPipeline_.reset(new PipelineCommon::ZeroBindCustomPushConstantPipeline(*swapChain_, "assets/shaders/Util.VisualDebugger.comp.slang.spv", 20)); visibilityPipeline_.reset(new PipelineCommon::VisibilityPipeline(SwapChain(), DepthBuffer(), UniformBuffers(), GetScene())); @@ -626,8 +814,8 @@ namespace Vulkan screenShotImageMemory_.reset(); screenShotImage_.reset(); commandBuffers_.reset(); - wireframePipeline_.reset(); - wireframeFramebuffer_.reset(); + //wireframePipeline_.reset(); + //wireframeFramebuffer_.reset(); bufferClearPipeline_.reset(); softAmbientCubeGenPipeline_.reset(); clearAmbientCubeCachePipeline_.reset(); @@ -763,22 +951,25 @@ namespace Vulkan UpdateSkinningBuffers(); InitializeBarriers(commandBuffer); - const bool useAmbientCubePropagation = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation; - if (!ambientCubePropagationStateInitialized_) - { - lastAmbientCubePropagation_ = useAmbientCubePropagation; - ambientCubePropagationStateInitialized_ = true; - } - else if (lastAmbientCubePropagation_ != useAmbientCubePropagation) + if (CurrentRendererUsesAmbientCube()) { - lastAmbientCubePropagation_ = useAmbientCubePropagation; - RequestClearAmbientCubeCache(); - } + const bool useAmbientCubePropagation = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation; + if (!ambientCubePropagationStateInitialized_) + { + lastAmbientCubePropagation_ = useAmbientCubePropagation; + ambientCubePropagationStateInitialized_ = true; + } + else if (lastAmbientCubePropagation_ != useAmbientCubePropagation) + { + lastAmbientCubePropagation_ = useAmbientCubePropagation; + RequestClearAmbientCubeCache(); + } - if (requestClearAmbientCubeCache_) - { - ClearAmbientCubeCache(commandBuffer, imageIndex); - requestClearAmbientCubeCache_ = false; + if (requestClearAmbientCubeCache_) + { + ClearAmbientCubeCache(commandBuffer, imageIndex); + requestClearAmbientCubeCache_ = false; + } } if (true) @@ -832,10 +1023,7 @@ namespace Vulkan gpuScene.custom_data_1 = vertexOffset; gpuScene.custom_data_2 = vertexCount; - VkPipelineLayout layout = skinningPipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); - + scene.UpdateGPUSceneBuffer(imageIndex, gpuScene); uint32_t groupCount = (vertexCount + 63) / 64; vkCmdDispatch(commandBuffer, groupCount, 1, 1); } @@ -845,15 +1033,28 @@ namespace Vulkan VkBufferMemoryBarrier skinnedBufferBarrier = {}; skinnedBufferBarrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; skinnedBufferBarrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; - skinnedBufferBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR; + skinnedBufferBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + if (supportRayTracing_) + { + skinnedBufferBarrier.dstAccessMask |= VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR; + } skinnedBufferBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; skinnedBufferBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; skinnedBufferBarrier.buffer = skinnedVertexBuffer_->Handle(); skinnedBufferBarrier.offset = 0; skinnedBufferBarrier.size = VK_WHOLE_SIZE; + VkPipelineStageFlags skinnedDstStages = + VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; + if (supportRayTracing_) + { + skinnedDstStages |= VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR; + } + vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, - VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, 0, 0, nullptr, 1, &skinnedBufferBarrier, 0, nullptr); + skinnedDstStages, 0, 0, nullptr, 1, &skinnedBufferBarrier, 0, nullptr); } } @@ -953,9 +1154,10 @@ namespace Vulkan const VkBuffer indexBuffer = scene.IndexBuffer().Handle(); vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, visibilityPipeline_->Handle()); + scene.FetchGPUScene(imageIndex); + visibilityPipeline_->PipelineLayout().BindDescriptorSets( + commandBuffer, imageIndex, VK_PIPELINE_BIND_POINT_GRAPHICS); vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT32); - vkCmdPushConstants(commandBuffer, visibilityPipeline_->PipelineLayout().Handle(), VK_SHADER_STAGE_VERTEX_BIT, - 0, sizeof(Assets::GPUScene), &(scene.FetchGPUScene(imageIndex))); vkCmdDrawIndexedIndirect(commandBuffer, scene.IndirectDrawBuffer().Handle(), 0, scene.GetIndirectDrawBatchCount(), sizeof(VkDrawIndexedIndirectCommand)); @@ -1359,6 +1561,10 @@ namespace Vulkan case ERendererType::ERT_LegacyDeferred: logicRenderers_[type] = std::make_unique(*this); break; + case ERendererType::ERT_LegacyDeferredNoAmbient: + logicRenderers_[type] = std::make_unique( + *this, "assets/shaders/Core.SwModernNoAmbient.comp.slang.spv"); + break; case ERendererType::ERT_VoxelTracing: logicRenderers_[type] = std::make_unique(*this); break; @@ -1393,6 +1599,9 @@ namespace Vulkan case ERendererType::ERT_LegacyDeferred: rendererName = "SoftModern"; break; + case ERendererType::ERT_LegacyDeferredNoAmbient: + rendererName = "SoftModernNoAmbient"; + break; case ERendererType::ERT_VoxelTracing: rendererName = "VoxelTracing"; break; @@ -1447,46 +1656,46 @@ namespace Vulkan logicRenderers_[currentLogicRenderer_]->Render(commandBuffer, imageIndex); } - if (NextEngine::GetInstance()->GetShowFlags().ShowWireframe) { - SCOPED_GPU_TIMER("wireframe"); - - VkRenderPassBeginInfo renderPassInfo = {}; - renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; - renderPassInfo.renderPass = wireframePipeline_->RenderPass().Handle(); - renderPassInfo.framebuffer = wireframeFramebuffer_->Handle(); - renderPassInfo.renderArea.offset = {0, 0}; - renderPassInfo.renderArea.extent = swapChain_->RenderExtent(); - - vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE); - { - auto& scene = GetScene(); - - VkDescriptorSet descriptorSets[] = {wireframePipeline_->DescriptorSet(imageIndex)}; - VkBuffer vertexBuffers[] = {scene.SimpleVertexBuffer().Handle()}; - const VkBuffer indexBuffer = scene.PrimAddressBuffer().Handle(); - VkDeviceSize offsets[] = {0}; - - vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, wireframePipeline_->Handle()); - vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, - wireframePipeline_->PipelineLayout().Handle(), 0, 1, descriptorSets, 0, nullptr); - vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets); - vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT32); - - // drawcall one by one, old school pipeline only - for (const auto& node : scene.GetNodeProxys()) - { - auto& offset = scene.Offsets()[node.modelId]; - const auto indexCount = static_cast(offset.indexCount); - if (indexCount == 0) continue; + // if (NextEngine::GetInstance()->GetShowFlags().ShowWireframe) { + // SCOPED_GPU_TIMER("wireframe"); + // + // VkRenderPassBeginInfo renderPassInfo = {}; + // renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + // renderPassInfo.renderPass = wireframePipeline_->RenderPass().Handle(); + // renderPassInfo.framebuffer = wireframeFramebuffer_->Handle(); + // renderPassInfo.renderArea.offset = {0, 0}; + // renderPassInfo.renderArea.extent = swapChain_->RenderExtent(); - glm::mat4 worldMatrix = node.worldTS; - vkCmdPushConstants(commandBuffer, wireframePipeline_->PipelineLayout().Handle(), - VK_SHADER_STAGE_VERTEX_BIT,0, sizeof(glm::mat4), &worldMatrix); - vkCmdDrawIndexed(commandBuffer, indexCount, 1, offset.indexOffset, static_cast(offset.vertexOffset), 0); - } - } - vkCmdEndRenderPass(commandBuffer); - } + // vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE); + // { + // auto& scene = GetScene(); + // + // VkDescriptorSet descriptorSets[] = {wireframePipeline_->DescriptorSet(imageIndex)}; + // VkBuffer vertexBuffers[] = {scene.SimpleVertexBuffer().Handle()}; + // const VkBuffer indexBuffer = scene.PrimAddressBuffer().Handle(); + // VkDeviceSize offsets[] = {0}; + // + // vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, wireframePipeline_->Handle()); + // vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + // wireframePipeline_->PipelineLayout().Handle(), 0, 1, descriptorSets, 0, nullptr); + // vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets); + // vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT32); + // + // // drawcall one by one, old school pipeline only + // for (const auto& node : scene.GetNodeProxys()) + // { + // auto& offset = scene.Offsets()[node.modelId]; + // const auto indexCount = static_cast(offset.indexCount); + // if (indexCount == 0) continue; + // + // glm::mat4 worldMatrix = node.worldTS; + // vkCmdPushConstants(commandBuffer, wireframePipeline_->PipelineLayout().Handle(), + // VK_SHADER_STAGE_VERTEX_BIT,0, sizeof(glm::mat4), &worldMatrix); + // vkCmdDrawIndexed(commandBuffer, indexCount, 1, offset.indexOffset, static_cast(offset.vertexOffset), 0); + // } + // } + // vkCmdEndRenderPass(commandBuffer); + //} { SCOPED_GPU_TIMER("resolve pass"); @@ -1546,10 +1755,7 @@ namespace Vulkan gpuScene.custom_data_1 = 0; gpuScene.custom_data_2 = 0; - VkPipelineLayout layout = clearAmbientCubeCachePipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); - + GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); vkCmdDispatch(commandBuffer, groupCount, 1, 1); VkBufferMemoryBarrier barriers[2]{}; @@ -1636,10 +1842,7 @@ namespace Vulkan gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = 0; - VkPipelineLayout layout = propagationAmbientCubeGenPipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); - + GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier propagationToInjectionBarrier{}; @@ -1657,10 +1860,7 @@ namespace Vulkan injectAmbientCubeGenPipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); - layout = injectAmbientCubeGenPipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); - + GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier postInjectionBarrier{}; @@ -1680,7 +1880,8 @@ namespace Vulkan void VulkanBaseRenderer::PostRender(VkCommandBuffer commandBuffer, uint32_t imageIndex) { //if (NextEngine::GetInstance()->IsProgressiveRendering()) return; - if (NextEngine::GetInstance()->GetUserSettings().UseGpuAmbientCubeSdf && + if (CurrentRendererUsesAmbientCube() && + NextEngine::GetInstance()->GetUserSettings().UseGpuAmbientCubeSdf && GetScene().ConsumeGpuDistanceFieldRebuild()) { SCOPED_GPU_TIMER("gpu-distance-field"); @@ -1722,9 +1923,7 @@ namespace Vulkan gpuScene.SkinnedVerticesSimple = GetScene().AmbientCubeSdfScratchBuffer().GetDeviceAddress(); distanceFieldInitPipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); - VkPipelineLayout layout = distanceFieldInitPipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); + GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier initBarrier{}; @@ -1747,9 +1946,7 @@ namespace Vulkan gpuScene.custom_data_1 = passParity; gpuScene.custom_data_2 = step; - layout = distanceFieldJumpPipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); + GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier jumpBarriers[2]{}; @@ -1776,9 +1973,7 @@ namespace Vulkan distanceFieldResolvePipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); gpuScene.custom_data_1 = passParity - 1; gpuScene.custom_data_2 = 0; - layout = distanceFieldResolvePipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); + GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier postResolveBarrier{}; @@ -1797,7 +1992,7 @@ namespace Vulkan } // soft ambient cube generation - if (!supportRayTracing_ || GOption->ForceSoftGen) + if (CurrentRendererUsesAmbientCube() && (!supportRayTracing_ || GOption->ForceSoftGen)) { const int cubesPerGroup = 64; const int perCascadeCount = Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z; @@ -1888,16 +2083,14 @@ namespace Vulkan gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation ? 1u : 0u; - VkPipelineLayout layout = softAmbientCubeGenPipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); - + GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); vkCmdDispatch(commandBuffer, dispatchGroupCount, 1, 1); } } } - if ((!supportRayTracing_ || GOption->ForceSoftGen) && + if (CurrentRendererUsesAmbientCube() && + (!supportRayTracing_ || GOption->ForceSoftGen) && NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation && NextEngine::GetInstance()->GetUserSettings().BakeSpeedLevel != 2) { diff --git a/src/Rendering/VulkanBaseRenderer.hpp b/src/Rendering/VulkanBaseRenderer.hpp index 6da1b7a7..2b5aab3b 100644 --- a/src/Rendering/VulkanBaseRenderer.hpp +++ b/src/Rendering/VulkanBaseRenderer.hpp @@ -13,6 +13,8 @@ namespace StreamlineWrapper { + bool ShouldInitialize(); + void Initialize(); void LazyInit(VkDevice device, VkInstance instance, VkPhysicalDevice physicalDevice, uint32_t computeQueueIdx, uint32_t computeQueueFamily, uint32_t graphicsQueueIdx, uint32_t graphicsQueueFamily, bool& outSupportDLSS, bool& outSupportDLSSRR); void Shutdown(); } @@ -48,7 +50,13 @@ namespace Vulkan ERT_ModernDeferred, ERT_LegacyDeferred, ERT_VoxelTracing, + ERT_LegacyDeferredNoAmbient, }; + + inline bool RendererUsesAmbientCube(const ERendererType type) + { + return type != ERT_LegacyDeferredNoAmbient; + } class VulkanBaseRenderer { @@ -130,6 +138,8 @@ namespace Vulkan bool SupportDLSS() const { return supportDLSS_; } bool SupportDLSSRR() const { return supportDLSSRR_; } + bool HasFullAmbientCubeBudget() const { return fullAmbientCubeBudget_; } + bool CurrentRendererUsesAmbientCube() const { return RendererUsesAmbientCube(currentLogicRenderer_); } virtual void RegisterLogicRenderer(ERendererType type); virtual void SwitchLogicRenderer(ERendererType type); @@ -156,6 +166,8 @@ namespace Vulkan bool supportDLSS_{}; bool supportDLSSRR_{}; bool supportDenoiser_ {}; + bool streamlineDeviceExtensionsEnabled_{}; + bool fullAmbientCubeBudget_{true}; // bool showWireframe_ {}; int frameCount_{}; bool forceSDR_{}; @@ -213,7 +225,7 @@ namespace Vulkan std::vector uniformBuffers_; - std::unique_ptr wireframePipeline_; + //std::unique_ptr wireframePipeline_; std::unique_ptr visibilityPipeline_; std::unique_ptr bufferClearPipeline_; @@ -232,7 +244,7 @@ namespace Vulkan std::unique_ptr depthBuffer_; std::unique_ptr visibilityFrameBuffer_; - std::unique_ptr wireframeFramebuffer_; + //std::unique_ptr wireframeFramebuffer_; std::unique_ptr commandPool_; std::unique_ptr commandPool2_; diff --git a/src/Runtime/Engine.cpp b/src/Runtime/Engine.cpp index 5da77029..8a5a0f71 100644 --- a/src/Runtime/Engine.cpp +++ b/src/Runtime/Engine.cpp @@ -81,15 +81,46 @@ void NextEngine::RegisterReflection() namespace { - Vulkan::ERendererType ResolveRendererType(Vulkan::ERendererType requestedType, bool supportsRayTracing) + Vulkan::ERendererType ResolveRendererType( + Vulkan::ERendererType requestedType, + bool supportsRayTracing, + bool hasFullAmbientCubeBudget) { if (!supportsRayTracing && requestedType == Vulkan::ERT_PathTracing) { - return Vulkan::ERT_ModernDeferred; + requestedType = Vulkan::ERT_ModernDeferred; + } + if (!hasFullAmbientCubeBudget && Vulkan::RendererUsesAmbientCube(requestedType)) + { + return Vulkan::ERT_LegacyDeferredNoAmbient; } return requestedType; } + bool HasFullAmbientCubeBudget(VkPhysicalDevice physicalDevice) + { + VkPhysicalDeviceMemoryProperties memoryProperties = {}; + vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memoryProperties); + + VkDeviceSize largestDeviceLocalHeapSize = 0; + for (uint32_t heapIndex = 0; heapIndex < memoryProperties.memoryHeapCount; ++heapIndex) + { + if ((memoryProperties.memoryHeaps[heapIndex].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) != 0) + { + largestDeviceLocalHeapSize = + std::max(largestDeviceLocalHeapSize, memoryProperties.memoryHeaps[heapIndex].size); + } + } + + const VkDeviceSize perCascadeCount = + static_cast(Assets::CUBE_SIZE_XY) * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z; + const VkDeviceSize fullAmbientCubeAllocationSize = + static_cast(Assets::CUBE_CASCADE_MAX) * perCascadeCount * + (sizeof(Assets::VoxelData) + sizeof(Assets::AmbientCube)) + + perCascadeCount * (sizeof(Assets::AmbientCube) + sizeof(glm::u32vec4)); + return largestDeviceLocalHeapSize >= fullAmbientCubeAllocationSize; + } + std::string ResolveScreenShotFilename(const std::string& requestedFilename, const char* defaultPrefix) { if (!requestedFilename.empty()) @@ -116,14 +147,35 @@ namespace NextRenderer { validationLayers.push_back("VK_LAYER_KHRONOS_validation"); } +#if WITH_STREAMLINE + if (StreamlineWrapper::ShouldInitialize()) + { + StreamlineWrapper::Initialize(); + } + else + { + SPDLOG_INFO("Streamline DLSS plugins disabled because no NVIDIA adapter is present"); + } +#endif Vulkan::Instance* instance = new Vulkan::Instance(*window, validationLayers, VK_API_VERSION_1_2); const auto& physicalDevices = instance->PhysicalDevices(); const uint32_t selectedGpuIdx = GOption->GpuIdx < physicalDevices.size() ? GOption->GpuIdx : 0; + const bool hasFullAmbientCubeBudget = HasFullAmbientCubeBudget(physicalDevices[selectedGpuIdx]); const bool useRayTracingRenderer = - !GOption->ForceNoRT && instance->SupportsRayQuery(physicalDevices[selectedGpuIdx]); + hasFullAmbientCubeBudget && !GOption->ForceNoRT && + instance->SupportsRayQuery(physicalDevices[selectedGpuIdx]); - std::vector supportedTypes = {Vulkan::ERT_ModernDeferred, Vulkan::ERT_LegacyDeferred, Vulkan::ERT_VoxelTracing}; + std::vector supportedTypes; + if (hasFullAmbientCubeBudget) + { + supportedTypes = {Vulkan::ERT_ModernDeferred, Vulkan::ERT_LegacyDeferred, + Vulkan::ERT_VoxelTracing, Vulkan::ERT_LegacyDeferredNoAmbient}; + } + else + { + supportedTypes = {Vulkan::ERT_LegacyDeferredNoAmbient}; + } Vulkan::VulkanBaseRenderer* renderer = nullptr; if (useRayTracingRenderer) { @@ -142,7 +194,8 @@ namespace NextRenderer } auto requestedType = - ResolveRendererType(static_cast(rendererType), useRayTracingRenderer); + ResolveRendererType(static_cast(rendererType), useRayTracingRenderer, + hasFullAmbientCubeBudget); if (std::find(supportedTypes.begin(), supportedTypes.end(), requestedType) == supportedTypes.end()) { requestedType = *supportedTypes.begin(); @@ -386,6 +439,13 @@ void NextEngine::Start() { OnRendererPostRender(commandBuffer, imageIndex); }; renderer_->Start(); + auto resolvedRendererType = ResolveRendererType( + renderer_->CurrentLogicRendererType(), renderer_->SupportsRayTracing(), renderer_->HasFullAmbientCubeBudget()); + if (resolvedRendererType != renderer_->CurrentLogicRendererType()) + { + renderer_->SwitchLogicRenderer(resolvedRendererType); + userSettings_.RendererType = static_cast(resolvedRendererType); + } #if GK_ENABLE_HOT_RELOAD if (options_->HotReload && options_->ShaderHotReload) @@ -508,7 +568,7 @@ bool NextEngine::Tick(bool forcingDelta) SCOPED_CPU_TIMER("renderer switch"); auto requestedRendererType = ResolveRendererType(static_cast(userSettings_.RendererType), - renderer_->SupportsRayTracing()); + renderer_->SupportsRayTracing(), renderer_->HasFullAmbientCubeBudget()); if (requestedRendererType != static_cast(userSettings_.RendererType)) { userSettings_.RendererType = static_cast(requestedRendererType); diff --git a/src/Runtime/Utilities/GraphicsDebugPanel.hpp b/src/Runtime/Utilities/GraphicsDebugPanel.hpp index 5a0e62da..18eb9707 100644 --- a/src/Runtime/Utilities/GraphicsDebugPanel.hpp +++ b/src/Runtime/Utilities/GraphicsDebugPanel.hpp @@ -33,9 +33,10 @@ namespace Runtime Custom, }; - inline constexpr std::array RendererOptions = {{ + inline constexpr std::array RendererOptions = {{ {"SoftTracing", Vulkan::ERT_ModernDeferred}, {"SoftModern", Vulkan::ERT_LegacyDeferred}, + {"SoftModernNoAmbient", Vulkan::ERT_LegacyDeferredNoAmbient}, {"VoxelTracing", Vulkan::ERT_VoxelTracing}, {"PathTracing", Vulkan::ERT_PathTracing}, }}; diff --git a/src/Vulkan/RenderingPipeline.cpp b/src/Vulkan/RenderingPipeline.cpp index edacd88b..c2edafd6 100644 --- a/src/Vulkan/RenderingPipeline.cpp +++ b/src/Vulkan/RenderingPipeline.cpp @@ -434,9 +434,9 @@ PipelineLayout::~PipelineLayout() } } -void PipelineLayout::BindDescriptorSets(VkCommandBuffer commandBuffer, uint32_t idx) const +void PipelineLayout::BindDescriptorSets(VkCommandBuffer commandBuffer, uint32_t idx, VkPipelineBindPoint bindPoint) const { - vkCmdBindDescriptorSets( commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE,Handle(), 0, + vkCmdBindDescriptorSets( commandBuffer, bindPoint,Handle(), 0, static_cast(cachedDescriptorSets_[idx].size()), cachedDescriptorSets_[idx].data(), 0, nullptr ); } diff --git a/src/Vulkan/RenderingPipeline.hpp b/src/Vulkan/RenderingPipeline.hpp index 3753f8f7..d9d90e60 100644 --- a/src/Vulkan/RenderingPipeline.hpp +++ b/src/Vulkan/RenderingPipeline.hpp @@ -79,7 +79,10 @@ namespace Vulkan PipelineLayout(const Device& device, const VkPushConstantRange* pushConstantRanges = nullptr, uint32_t pushConstantRangeCount = 0); ~PipelineLayout(); - void BindDescriptorSets(VkCommandBuffer commandBuffer, uint32_t idx) const; + void BindDescriptorSets( + VkCommandBuffer commandBuffer, + uint32_t idx, + VkPipelineBindPoint bindPoint = VK_PIPELINE_BIND_POINT_COMPUTE) const; private: const Device& device_; diff --git a/src/Vulkan/ShaderHotReloader.cpp b/src/Vulkan/ShaderHotReloader.cpp index 2a8d8b7b..76f9d5eb 100644 --- a/src/Vulkan/ShaderHotReloader.cpp +++ b/src/Vulkan/ShaderHotReloader.cpp @@ -356,7 +356,7 @@ namespace Vulkan } std::string platformDefines; -#if WIN32 +#if WIN32 && GK_ENABLE_SHADER_CLOCK platformDefines += " -DSHADER_CLOCK"; #endif #if __APPLE__ From fa5f38859729c8807688889bbab89cf9c97ca329 Mon Sep 17 00:00:00 2001 From: gameKnife Date: Wed, 13 May 2026 21:04:03 +0800 Subject: [PATCH 03/27] gitflow-feature-stash: productive-ui-refactor --- assets/typescript/Engine.d.ts | 11 + .../gkNextRenderer/gkNextRenderer.cpp | 378 ++++++++++++------ .../gkNextRenderer/gkNextRenderer.hpp | 3 + src/Editor/Core/EditorLayoutConstants.hpp | 4 +- src/Editor/EditorInterface.cpp | 179 ++++++--- src/Editor/EditorInterface.hpp | 4 +- src/Editor/Overlays/TitleBarOverlay.cpp | 284 +++++++++---- src/Editor/Panels/ContentBrowserPanel.cpp | 243 ++++++++--- src/Editor/Panels/OutlinerPanel.cpp | 130 ++++-- src/Editor/Panels/PropertiesPanel.cpp | 267 ++++++++----- src/Editor/Panels/ViewportOverlay.cpp | 163 +++++--- src/Runtime/Components/PhysicsComponent.cpp | 14 +- src/Runtime/Components/PhysicsComponent.h | 25 ++ src/Runtime/Components/RenderComponent.cpp | 10 + src/Runtime/Components/RenderComponent.h | 16 + src/Runtime/Editor/ProfessionalUI.cpp | 344 ++++++++++++++++ src/Runtime/Editor/ProfessionalUI.hpp | 46 +++ src/Runtime/Editor/UserInterface.cpp | 291 ++++++++------ src/Runtime/Editor/UserInterface.hpp | 2 + src/Runtime/Utilities/GraphicsDebugPanel.hpp | 5 +- 20 files changed, 1770 insertions(+), 649 deletions(-) create mode 100644 src/Runtime/Editor/ProfessionalUI.cpp create mode 100644 src/Runtime/Editor/ProfessionalUI.hpp diff --git a/assets/typescript/Engine.d.ts b/assets/typescript/Engine.d.ts index 88adea5f..94e3e282 100644 --- a/assets/typescript/Engine.d.ts +++ b/assets/typescript/Engine.d.ts @@ -39,6 +39,11 @@ export interface Scene { export class RenderComponent { Visible: boolean; RayCastVisible: boolean; + RaycastVisible: boolean; + CastShadows: boolean; + ReceiveGI: boolean; + LightmapUV: boolean; + LayerMask: number; readonly ModelId: number; readonly SkinIndex: number; Materials: number[]; @@ -48,6 +53,12 @@ export class RenderComponent { export class PhysicsComponent { Mobility: string; PhysicsOffset: Vec3; + SimulatePhysics: boolean; + PhysicsMaterial: string; + LinearDamping: number; + AngularDamping: number; + EnableGravity: boolean; + CollisionPresets: string; } export class SkinnedMeshComponent { PlaySpeed: number; diff --git a/src/Application/gkNextRenderer/gkNextRenderer.cpp b/src/Application/gkNextRenderer/gkNextRenderer.cpp index 940d643c..8c00d5a0 100644 --- a/src/Application/gkNextRenderer/gkNextRenderer.cpp +++ b/src/Application/gkNextRenderer/gkNextRenderer.cpp @@ -11,6 +11,8 @@ #include "Runtime/Components/PhysicsComponent.h" #include "Runtime/Engine.hpp" #include "Runtime/Editor/FontLoader.h" +#include "Runtime/Editor/ProfessionalUI.hpp" +#include "Runtime/Editor/UserInterface.hpp" #include "Runtime/Scene/SceneBuilder.h" #include "Runtime/Utilities/NextEngineHelper.h" #include "Runtime/Utilities/GraphicsDebugPanel.hpp" @@ -22,6 +24,7 @@ #include "Runtime/Components/SkinnedMeshComponent.h" #include "Runtime/Config/CVarSystem.hpp" #include "Vulkan/SwapChain.hpp" +#include "Vulkan/Device.hpp" extern float GAndroidMagicScale; @@ -145,7 +148,12 @@ void NextRendererGameInstance::OnInit() void NextRendererGameInstance::OnTick(double deltaSeconds) { + if (playbackPaused_ && !stepRequested_) + { + return; + } modelViewController_.UpdateCamera(10.0f, deltaSeconds); + stepRequested_ = false; } std::vector MatPreparedForAdd; @@ -202,6 +210,7 @@ bool NextRendererGameInstance::OnRenderUI() DrawTitleBar(); DrawSettings(); + DrawBottomStatusBar(); if (ImGui::GetCurrentContext() != nullptr) { @@ -487,20 +496,21 @@ void NextRendererGameInstance::DrawSettings() } const float distance = 10.0f; - const ImVec2 pos = ImVec2(distance, TitlebarSize + distance); + ImGuiViewport* viewport = ImGui::GetMainViewport(); + const ImVec2 pos = viewport->Pos + ImVec2(distance, TitlebarSize + distance); const ImVec2 posPivot = ImVec2(0.0f, 0.0f); ImGui::SetNextWindowPos(pos, ImGuiCond_Always, posPivot); - ImGui::SetNextWindowSize(ImVec2(ImGui::GetFontSize() * 30,-1)); - ImGui::SetNextWindowBgAlpha(0.9f); + ImGui::SetNextWindowSize(ImVec2(430.0f, viewport->Size.y - TitlebarSize - 50.0f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.92f); const auto flags = - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings; if (ImGui::Begin("Settings", &userSetting.ShowSettings, flags)) { + Runtime::UiTheme::DrawPanelHeader(ICON_FA_SLIDERS, "Renderer Settings", "Runtime path tracing controls"); if( ImGui::CollapsingHeader(LOCTEXT("Renderer"), ImGuiTreeNodeFlags_DefaultOpen) ) { ImGui::Text("%s", LOCTEXT("Renderer")); @@ -583,20 +593,27 @@ void NextRendererGameInstance::DrawSettings() if( ImGui::CollapsingHeader(LOCTEXT("Ray Tracing"), ImGuiTreeNodeFlags_DefaultOpen) ) { + static bool rayTracingEnabled = true; + ImGui::Checkbox("Enable", &rayTracingEnabled); + ImGui::BeginDisabled(!rayTracingEnabled); ImGui::Checkbox(LOCTEXT("AntiAlias"), &userSetting.TAA); ImGui::SliderInt(LOCTEXT("Samples"), &userSetting.NumberOfSamples, 1, 16); ImGui::SliderInt(LOCTEXT("TemporalSteps"), &userSetting.AdaptiveSteps, 2, 64); ImGui::Checkbox(LOCTEXT("FastGather"), &userSetting.FastGather); ImGui::SliderInt(LOCTEXT("AmbientSpeed"), &userSetting.BakeSpeedLevel, 0, 2); - - + ImGui::EndDisabled(); ImGui::NewLine(); } if( ImGui::CollapsingHeader(LOCTEXT("Denoiser"), ImGuiTreeNodeFlags_DefaultOpen) ) { - ImGui::Checkbox(LOCTEXT("Use JBF"), &userSetting.Denoiser); + static int denoiserAlgorithm = 0; + const char* denoiserAlgorithms[] = {"HDR JBF", "SVGF", "Atrous", "None"}; + if (ImGui::Combo("Algorithm", &denoiserAlgorithm, denoiserAlgorithms, IM_ARRAYSIZE(denoiserAlgorithms))) + { + userSetting.Denoiser = denoiserAlgorithm != 3; + } ImGui::SliderFloat(LOCTEXT("DenoiseSigma"), &userSetting.DenoiseSigma, 0.01f, 2.0f, "%.2f"); ImGui::SliderFloat(LOCTEXT("DenoiseSigmaLum"), &userSetting.DenoiseSigmaLum, 0.01f, 50.0f, "%.2f"); ImGui::SliderFloat(LOCTEXT("DenoiseSigmaNormal"), &userSetting.DenoiseSigmaNormal, 0.001f, 0.2f, "%.3f"); @@ -606,39 +623,24 @@ void NextRendererGameInstance::DrawSettings() if( ImGui::CollapsingHeader(LOCTEXT("Upscaling"), ImGuiTreeNodeFlags_DefaultOpen) ) { - if (GetEngine().GetRenderer().SupportDLSS()) - { - if (ImGui::Checkbox("NVIDIA DLSS", &userSetting.DLSS)) - { - GetEngine().GetRenderer().RequestRecreateSwapChain(); - } - - if (userSetting.DLSS) - { - const char* dlssModes[] = { "Quality", "Balanced", "Performance", "Ultra Performance", "DLAA (Native)" }; - if (ImGui::Combo("DLSS Mode", (int*)&userSetting.SuperResolution, dlssModes, IM_ARRAYSIZE(dlssModes))) - { - GetEngine().GetRenderer().RequestRecreateSwapChain(); - } - - if (GetEngine().GetRenderer().SupportDLSSRR()) - { - ImGui::Checkbox("DLSS Ray Reconstruction", &userSetting.DLSSRR); - } - } - } - else - { - ImGui::TextDisabled("DLSS not supported on this hardware."); - } - - if (!userSetting.DLSS) + static int upscaleMethod = userSetting.DLSS ? 1 : 0; + static int upscaleMode = 0; + const char* methods[] = {"None", "DLSS", "FSR", "TAAU"}; + if (ImGui::Combo("Method", &upscaleMethod, methods, IM_ARRAYSIZE(methods))) { - const char* upscaleModes[] = { "Quality", "Balanced", "Performance", "Ultra Performance", "Native" }; - if (ImGui::Combo("Upscale Mode", (int*)&userSetting.SuperResolution, upscaleModes, IM_ARRAYSIZE(upscaleModes))) - { - GetEngine().GetRenderer().RequestRecreateSwapChain(); - } + userSetting.DLSS = upscaleMethod == 1 && GetEngine().GetRenderer().SupportDLSS(); + GetEngine().GetRenderer().RequestRecreateSwapChain(); + } + + const char* qualities[] = {"Quality", "Balanced", "Performance", "Ultra Performance", "Native"}; + if (ImGui::Combo("Quality", (int*)&userSetting.SuperResolution, qualities, IM_ARRAYSIZE(qualities))) + { + GetEngine().GetRenderer().RequestRecreateSwapChain(); + } + ImGui::Combo("Upscale Mode", &upscaleMode, "Spatial\0Temporal\0Native Output\0\0"); + if (upscaleMethod == 1 && !GetEngine().GetRenderer().SupportDLSS()) + { + ImGui::TextDisabled("DLSS not supported on this hardware."); } ImGui::NewLine(); } @@ -743,120 +745,236 @@ void NextRendererGameInstance::DrawSettings() void NextRendererGameInstance::DrawTitleBar() { - // 获取窗口的大小 - ImVec2 windowSize = ImGui::GetMainViewport()->Size; - float titlebarLeftReservedWidth = 0.0f; - float titlebarRightReservedWidth = TitlebarControlSize; - - auto bgColor = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg); - bgColor.w = 0.9f; - ImGui::GetBackgroundDrawList()->AddRectFilled(ImVec2(0, 0), ImVec2(windowSize.x, TitlebarSize), ImGui::ColorConvertFloat4ToU32(bgColor)); - - ImGui::PushFont(bigFont_); - - auto textSize = ImGui::CalcTextSize("gkNextRenderer"); - ImGui::GetForegroundDrawList()->AddText(ImVec2((windowSize.x - textSize.x) * 0.5f, (TitlebarSize - textSize.y) * 0.5f), IM_COL32(255, 255, 255, 255), "gkNextRenderer"); - - ImGui::PopFont(); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); - - ImGui::SetNextWindowPos(ImVec2(windowSize.x - TitlebarControlSize, 0), ImGuiCond_Always, ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(TitlebarControlSize, TitlebarSize)); - - ImGui::Begin("TitleBarRight", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBackground); + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImVec2 windowSize = viewport->Size; + float titlebarLeftReservedWidth = 420.0f; + constexpr float rightReservedWidth = 560.0f; + float titlebarRightReservedWidth = rightReservedWidth; + + ImDrawList* background = ImGui::GetBackgroundDrawList(); + background->AddRectFilled(viewport->Pos, viewport->Pos + ImVec2(windowSize.x, TitlebarSize), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Background)); + background->AddLine(viewport->Pos + ImVec2(0.0f, TitlebarSize - 1.0f), + viewport->Pos + ImVec2(windowSize.x, TitlebarSize - 1.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Border)); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(8.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 0.0f)); + + ImGui::SetNextWindowPos(viewport->Pos + ImVec2(windowSize.x - rightReservedWidth, 0), ImGuiCond_Always, + ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(rightReservedWidth, TitlebarSize)); + + ImGui::Begin("TitleBarRight", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBackground); titlebarRightReservedWidth = ImGui::GetWindowSize().x; - if (ImGui::Button(ICON_FA_MINUS, ImVec2(TitlebarSize, TitlebarSize))) + const auto framebufferSize = GetEngine().GetWindow().FramebufferSize(); + ImGui::SetCursorPosY((TitlebarSize - ImGui::GetTextLineHeight()) * 0.5f); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "%ux%u", + framebufferSize.width, framebufferSize.height); + ImGui::SameLine(0.0f, 16.0f); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "Camera %d", + GetEngine().GetUserSettings().CameraIdx); + ImGui::SameLine(0.0f, 16.0f); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Success), "%.0f FPS", + GetEngine().GetFrameRate()); + ImGui::SameLine(0.0f, 16.0f); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "%.2f ms", + GetEngine().GetSmoothDeltaSeconds() * 1000.0); + ImGui::SameLine(0.0f, 16.0f); + + if (Runtime::UiTheme::ToolbarButton(ICON_FA_MINUS, LOCTEXT("Minimize"), false, ImVec2(34.0f, 28.0f))) { GetEngine().RequestMinimize(); } ImGui::SameLine(); - if (ImGui::Button(GetEngine().IsMaximumed() ? ICON_FA_WINDOW_RESTORE : ICON_FA_SQUARE, ImVec2(TitlebarSize, TitlebarSize))) + if (Runtime::UiTheme::ToolbarButton(GetEngine().IsMaximumed() ? ICON_FA_WINDOW_RESTORE : ICON_FA_SQUARE, + LOCTEXT("Maximize"), false, ImVec2(34.0f, 28.0f))) { GetEngine().ToggleMaximize(); } ImGui::SameLine(); - if (ImGui::Button(ICON_FA_XMARK, ImVec2(TitlebarSize, TitlebarSize))) + if (Runtime::UiTheme::ToolbarButton(ICON_FA_XMARK, LOCTEXT("Close"), false, ImVec2(34.0f, 28.0f))) { GetEngine().RequestClose(); } ImGui::End(); - ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always, ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(TitlebarSize * 18, TitlebarSize)); + ImGui::SetNextWindowPos(viewport->Pos, ImGuiCond_Always, ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(windowSize.x - rightReservedWidth, TitlebarSize)); - ImGui::Begin("TitleBarLeft", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBackground); - if (ImGui::Button(ICON_FA_GITHUB, ImVec2(TitlebarSize, TitlebarSize))) + ImGui::Begin("TitleBarLeft", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_MenuBar); + if (ImGui::BeginMenuBar()) { - NextRenderer::OSCommand("https://github.com/gameknife/gkNextRenderer"); - } - BUTTON_TOOLTIP(LOCTEXT("Open Project Page in OS Browser")) - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_TWITTER, ImVec2(TitlebarSize, TitlebarSize))) - { - NextRenderer::OSCommand("https://x.com/gKNIFE_"); - } - BUTTON_TOOLTIP(LOCTEXT("Open Twitter Page in OS Browser")) - ImGui::SameLine(); - ImGui::GetForegroundDrawList()->AddLine(ImGui::GetCursorPos() + ImVec2(4, TitlebarSize / 2 - 5), ImGui::GetCursorPos() + ImVec2(4, TitlebarSize / 2 + 5), IM_COL32(255, 255, 255, 160), 2.0f); - ImGui::Dummy(ImVec2(10, 10)); - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_CAMERA, ImVec2(TitlebarSize, TitlebarSize))) - { - GetEngine().AddTickedTask([this](double deltaSeconds)-> bool + Runtime::UiTheme::DrawBrandMark(ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos(), 24.0f); + ImGui::Dummy(ImVec2(28.0f, 24.0f)); + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextUnformatted("gkNextRenderer"); + ImGui::SameLine(0.0f, 20.0f); + + if (ImGui::BeginMenu("File")) { - GetEngine().RequestScreenShot({}); - return true; - }); - //GetEngine().RequestHighQualityScreenShot("", 512); - } - BUTTON_TOOLTIP(LOCTEXT("Take a Screenshot into the screenshots folder")) - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_EYE, ImVec2(TitlebarSize, TitlebarSize))) - { - ImGui::OpenPopup("RendererShowFlags"); - } - BUTTON_TOOLTIP(LOCTEXT("Show Flags")) + if (ImGui::MenuItem("Project Page")) + { + NextRenderer::OSCommand("https://github.com/gameknife/gkNextRenderer"); + } + if (ImGui::MenuItem("Open Screenshot Folder")) + { + RequestScreenshot(true, ""); + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("View")) + { + auto& showFlags = GetEngine().GetShowFlags(); + Utilities::UI::DrawShowFlagsCommon(showFlags); + ImGui::MenuItem("Profiler Overlay", nullptr, &GetEngine().GetUserSettings().ShowOverlay); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Capture")) + { + if (ImGui::MenuItem("Screenshot")) + { + RequestScreenshot(false, ""); + } + if (ImGui::MenuItem("Screenshot and Open Folder")) + { + RequestScreenshot(true, ""); + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Renderer")) + { + Runtime::GraphicsDebugPanel::DrawRendererSelector(GetEngine(), GetEngine().GetUserSettings(), + "##RendererMenuSelector", 180.0f); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Settings")) + { + ImGui::MenuItem("Render Settings", nullptr, &GetEngine().GetUserSettings().ShowSettings); + ImGui::MenuItem("Stats Overlay", nullptr, &GetEngine().GetUserSettings().ShowOverlay); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Help")) + { + ImGui::MenuItem("Documentation", nullptr, false, false); + ImGui::MenuItem("About gkNextRenderer", nullptr, false, false); + ImGui::EndMenu(); + } - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12, 12)); - if (ImGui::BeginPopup("RendererShowFlags")) - { - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 10)); - auto& showFlags = GetEngine().GetShowFlags(); - Utilities::UI::DrawShowFlagsCommon(showFlags); - - ImGui::PopStyleVar(); - ImGui::EndPopup(); + titlebarLeftReservedWidth = ImGui::GetItemRectMax().x + ImGui::GetStyle().ItemSpacing.x; + ImGui::EndMenuBar(); } - ImGui::PopStyleVar(); - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_LIST_CHECK, ImVec2(TitlebarSize, TitlebarSize))) - { - GetEngine().GetUserSettings().ShowSettings = !GetEngine().GetUserSettings().ShowSettings; - } - BUTTON_TOOLTIP(LOCTEXT("Toggle Settings Panel")) - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_GAUGE_SIMPLE_HIGH, ImVec2(TitlebarSize, TitlebarSize))) - { - GetEngine().GetUserSettings().ShowOverlay = !GetEngine().GetUserSettings().ShowOverlay; - } - BUTTON_TOOLTIP(LOCTEXT("Toggle Performance Overlay")) - ImGui::SameLine(); - ImGui::GetForegroundDrawList()->AddLine(ImGui::GetCursorPos() + ImVec2(4, TitlebarSize / 2 - 5), ImGui::GetCursorPos() + ImVec2(4, TitlebarSize / 2 + 5), IM_COL32(255, 255, 255, 160), 2.0f); - ImGui::Dummy(ImVec2(10, 10)); - ImGui::SameLine(); - ImGui::SetCursorPosY((TitlebarSize - ImGui::GetTextLineHeight()) / 2); - ImGui::TextUnformatted(fmt::format("{:.0f}fps", GetEngine().GetFrameRate()).c_str()); - titlebarLeftReservedWidth = ImGui::GetItemRectMax().x + ImGui::GetStyle().ItemSpacing.x; ImGui::End(); - ImGui::PopStyleColor(); ImGui::PopStyleVar(4); GetEngine().ConfigureCustomTitleBarDrag( true, TitlebarSize, titlebarLeftReservedWidth, titlebarRightReservedWidth); } + +void NextRendererGameInstance::DrawBottomStatusBar() +{ + ImGuiViewport* viewport = ImGui::GetMainViewport(); + constexpr float barHeight = 30.0f; + ImGui::SetNextWindowPos(viewport->Pos + ImVec2(0.0f, viewport->Size.y - barHeight), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(viewport->Size.x, barHeight), ImGuiCond_Always); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::SetNextWindowBgAlpha(1.0f); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoDocking; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 4.0f)); + if (ImGui::Begin("RendererStatusBar", nullptr, flags)) + { + ImGui::GetWindowDrawList()->AddLine( + viewport->Pos + ImVec2(0.0f, viewport->Size.y - barHeight), + viewport->Pos + ImVec2(viewport->Size.x, viewport->Size.y - barHeight), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Border), 1.0f); + + if (UserInterface* ui = GetEngine().GetUserInterface()) + { + if (Runtime::UiTheme::ToolbarButton("Console", "Toggle Console", ui->IsConsoleOpen(), ImVec2(74.0f, 22.0f))) + { + ui->ToggleConsole(); + } + ImGui::SameLine(); + } + if (Runtime::UiTheme::ToolbarButton("Stats", "Toggle Profiler", GetEngine().GetUserSettings().ShowOverlay, + ImVec2(58.0f, 22.0f))) + { + GetEngine().GetUserSettings().ShowOverlay = !GetEngine().GetUserSettings().ShowOverlay; + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton("Capture", "Take Screenshot", false, ImVec2(72.0f, 22.0f))) + { + RequestScreenshot(false, ""); + } + + const float centerStart = viewport->Size.x * 0.5f - 160.0f; + if (ImGui::GetCursorPosX() < centerStart) + { + ImGui::SameLine(centerStart); + } + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "Frame %u", + GetEngine().GetTotalFrames()); + ImGui::SameLine(0.0f, 10.0f); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_BACKWARD_STEP, "Previous Frame (placeholder)", false, + ImVec2(28.0f, 22.0f))) + { + stepRequested_ = true; + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_PLAY, "Play", !playbackPaused_, ImVec2(28.0f, 22.0f))) + { + playbackPaused_ = false; + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_PAUSE, "Pause", playbackPaused_, ImVec2(28.0f, 22.0f))) + { + playbackPaused_ = true; + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_FORWARD_STEP, "Step Frame", false, ImVec2(28.0f, 22.0f))) + { + playbackPaused_ = true; + stepRequested_ = true; + } + + VkPhysicalDeviceMemoryProperties memoryProperties{}; + vkGetPhysicalDeviceMemoryProperties(GetEngine().GetRenderer().Device().PhysicalDevice(), &memoryProperties); + uint64_t totalBytes = 0; + for (uint32_t i = 0; i < memoryProperties.memoryHeapCount; ++i) + { + if ((memoryProperties.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) != 0) + { + totalBytes += memoryProperties.memoryHeaps[i].size; + } + } + const double totalGb = static_cast(totalBytes) / (1024.0 * 1024.0 * 1024.0); + const double usedGb = 0.0; + const float memoryFraction = totalGb > 0.0 ? static_cast(usedGb / totalGb) : 0.0f; + const std::string memoryLabel = fmt::format("{:.2f}/{:.2f} GB ({:.0f}%)", usedGb, totalGb, + memoryFraction * 100.0f); + + const float rightStart = viewport->Size.x - 260.0f; + if (ImGui::GetCursorPosX() < rightStart) + { + ImGui::SameLine(rightStart); + } + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "%s", memoryLabel.c_str()); + ImGui::SameLine(0.0f, 8.0f); + Runtime::UiTheme::DrawProgressBar(memoryFraction, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Success), + ImVec2(92.0f, ImGui::GetTextLineHeight())); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} diff --git a/src/Application/gkNextRenderer/gkNextRenderer.hpp b/src/Application/gkNextRenderer/gkNextRenderer.hpp index 2688791a..f5e8ca96 100644 --- a/src/Application/gkNextRenderer/gkNextRenderer.hpp +++ b/src/Application/gkNextRenderer/gkNextRenderer.hpp @@ -40,6 +40,7 @@ class NextRendererGameInstance : public NextGameInstanceBase private: void DrawSettings(); void DrawTitleBar(); + void DrawBottomStatusBar(); void RequestScreenshot(bool openFolder, const std::string& tag); ModelViewController modelViewController_; GizmoController gizmoController_; @@ -51,4 +52,6 @@ class NextRendererGameInstance : public NextGameInstanceBase bool isTakingScreenshot_ = false; bool agentValidationCaptured_ = false; + bool playbackPaused_ = false; + bool stepRequested_ = false; }; diff --git a/src/Editor/Core/EditorLayoutConstants.hpp b/src/Editor/Core/EditorLayoutConstants.hpp index 320406a4..3aa0ceb1 100644 --- a/src/Editor/Core/EditorLayoutConstants.hpp +++ b/src/Editor/Core/EditorLayoutConstants.hpp @@ -4,6 +4,6 @@ namespace Editor { - constexpr float kTitleBarHeight = 55.0f; - constexpr float kFooterHeight = 40.0f; + constexpr float kTitleBarHeight = 38.0f; + constexpr float kFooterHeight = 30.0f; } // namespace Editor diff --git a/src/Editor/EditorInterface.cpp b/src/Editor/EditorInterface.cpp index 84d8be02..94736910 100644 --- a/src/Editor/EditorInterface.cpp +++ b/src/Editor/EditorInterface.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "Editor/EditorUi.hpp" #include "Assets/Core/Scene.hpp" @@ -24,12 +25,16 @@ #include "Editor/EditorUtils.h" #include "Options.hpp" #include "Rendering/VulkanBaseRenderer.hpp" +#include "Runtime/Editor/ProfessionalUI.hpp" +#include "Runtime/Utilities/GraphicsDebugPanel.hpp" #include "ThirdParty/fontawesome/IconsFontAwesome6.h" #include "Utilities/FileHelper.hpp" #include "Utilities/Localization.hpp" #include "Utilities/Math.hpp" #include "Vulkan/SwapChain.hpp" +#include + extern std::unique_ptr GApplication; namespace @@ -114,23 +119,21 @@ void EditorInterface::Init() namespace { - constexpr float kToolbarSize = 50.0f; - constexpr float kToolbarIconWidth = 32.0f; - constexpr float kToolbarIconHeight = 32.0f; - float gMenuBarHeight = 0.0f; + constexpr float kToolbarSize = 40.0f; + constexpr float kToolbarIconWidth = 34.0f; + constexpr float kToolbarIconHeight = 30.0f; } // namespace ImGuiID EditorInterface::DockSpaceUI() { ImGuiViewport* viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos( - ImVec2(viewport->Pos.x, viewport->Pos.y + kToolbarSize + Editor::kTitleBarHeight - gMenuBarHeight)); + const float topOffset = kToolbarSize + Editor::kTitleBarHeight; + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x, viewport->Pos.y + topOffset)); ImGui::SetNextWindowSize( - ImVec2(viewport->Size.x, - viewport->Size.y - kToolbarSize - Editor::kTitleBarHeight + gMenuBarHeight - Editor::kFooterHeight)); + ImVec2(viewport->Size.x, viewport->Size.y - topOffset - Editor::kFooterHeight)); ImGui::SetNextWindowViewport(viewport->ID); ImGui::SetNextWindowBgAlpha(0); - ImGuiWindowFlags windowFlags = 0 | ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking | + ImGuiWindowFlags windowFlags = 0 | ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus; @@ -140,9 +143,6 @@ ImGuiID EditorInterface::DockSpaceUI() ImGui::Begin("Master DockSpace", NULL, windowFlags); ImGuiID dockMain = ImGui::GetID("MyDockspace"); - // Save off menu bar height for later. - gMenuBarHeight = ImGui::GetCurrentWindow()->MenuBarHeight; - if (firstRun_ || uiState_.dockResetRequested) { RebuildDefaultDockLayout(dockMain); @@ -166,9 +166,9 @@ void EditorInterface::RebuildDefaultDockLayout(ImGuiID id) ImGui::DockBuilderSetNodeSize(id, viewport->Size); ImGuiID dockMain = id; - ImGuiID dock1 = ImGui::DockBuilderSplitNode(dockMain, ImGuiDir_Left, 0.1f, nullptr, &dockMain); - ImGuiID dock2 = ImGui::DockBuilderSplitNode(dockMain, ImGuiDir_Right, 0.2f, nullptr, &dockMain); - ImGuiID dock3 = ImGui::DockBuilderSplitNode(dockMain, ImGuiDir_Down, 0.25f, nullptr, &dockMain); + ImGuiID dock1 = ImGui::DockBuilderSplitNode(dockMain, ImGuiDir_Left, 0.135f, nullptr, &dockMain); + ImGuiID dock2 = ImGui::DockBuilderSplitNode(dockMain, ImGuiDir_Right, 0.19f, nullptr, &dockMain); + ImGuiID dock3 = ImGui::DockBuilderSplitNode(dockMain, ImGuiDir_Down, 0.24f, nullptr, &dockMain); ImGui::DockBuilderDockWindow("Outliner", dock1); ImGui::DockBuilderDockWindow("Properties", dock2); @@ -183,7 +183,7 @@ void EditorInterface::RebuildDefaultDockLayout(ImGuiID id) ImGui::DockBuilderFinish(id); } -void EditorInterface::ToolbarUI() +void EditorInterface::ToolbarUI(EditorContext& ctx) { ImGuiViewport* viewport = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x, viewport->Pos.y + Editor::kTitleBarHeight)); @@ -200,66 +200,155 @@ void EditorInterface::ToolbarUI() ImGui::PopStyleVar(); ImGui::PopStyleVar(); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(6.0f, 0.0f)); + ImGui::SetCursorPosY((kToolbarSize - kToolbarIconHeight) * 0.5f); + + static int projectIndex = 0; + static int backendIndex = 0; + static int platformIndex = 0; + static int buildConfigIndex = 0; + + ImGui::SetNextItemWidth(150.0f); + ImGui::Combo("##ProjectSelector", &projectIndex, ICON_FA_CUBE " RayQuery\0" ICON_FA_CUBE " Playground\0\0"); + Runtime::UiTheme::DrawTooltip("Project"); + ImGui::SameLine(); + + ImGui::SetNextItemWidth(118.0f); + ImGui::Combo("##BackendSelector", &backendIndex, "Vulkan\0Metal\0DirectX 12\0\0"); + Runtime::UiTheme::DrawTooltip("Backend"); + ImGui::SameLine(0.0f, 14.0f); + ImGui::BeginGroup(); if (uiState_.fontIcon) { ImGui::PushFont(uiState_.fontIcon); } - ImGui::Button(ICON_FA_FLOPPY_DISK, ImVec2(kToolbarIconWidth, kToolbarIconHeight)); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_FLOPPY_DISK, "Save Scene", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight))) + { + if (!uiState_.currentScenePath.empty()) + { + ctx.scene.Save(uiState_.currentScenePath); + SPDLOG_INFO("Scene saved: {}", uiState_.currentScenePath); + } + else + { + const std::string filename = "saved_scene.glb"; + ctx.scene.Save(filename); + uiState_.currentScenePath = filename; + SPDLOG_INFO("Scene saved: {}", filename); + } + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_FOLDER_OPEN, "Open Scene", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight))) + { + SDL_DialogFileFilter filters[] = { + {"Scenes", "glb;gltf;ldr;mpd"}, + {"All Files", "*"}, + }; + SDL_ShowOpenFileDialog( + [](void* userdata, const char* const* filelist, int /*filter*/) + { + auto* editorCtx = static_cast(userdata); + if (filelist && filelist[0]) + { + editorCtx->actions.Dispatch(*editorCtx, EEditorAction::IO_LoadScene, std::string(filelist[0])); + } + }, + &ctx, + ctx.engine.GetWindow().Handle(), + filters, 2, nullptr, false); + } ImGui::SameLine(); - ImGui::Button(ICON_FA_FOLDER, ImVec2(kToolbarIconWidth, kToolbarIconHeight)); + Runtime::UiTheme::ToolbarButton(ICON_FA_FILE_IMPORT, "Import Asset (placeholder)", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight)); ImGui::SameLine(); + Runtime::UiTheme::ToolbarButton(ICON_FA_CUBE, "Create Actor (placeholder)", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight)); if (uiState_.fontIcon) { ImGui::PopFont(); } ImGui::EndGroup(); - ImGui::SameLine(); + ImGui::SameLine(0.0f, 14.0f); ImGui::BeginGroup(); - ImGui::SameLine(50); if (uiState_.fontIcon) { ImGui::PushFont(uiState_.fontIcon); } - ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(80, 210, 0, 255)); - if (ImGui::Button(ICON_FA_PLAY, ImVec2(kToolbarIconWidth, kToolbarIconHeight))) + Runtime::UiTheme::ToolbarButton(ICON_FA_GEAR, "Project Settings (placeholder)", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight)); + ImGui::SameLine(); + Runtime::UiTheme::ToolbarButton(ICON_FA_CIRCLE_NODES, "Node Graph (placeholder)", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight)); + ImGui::SameLine(); + Runtime::UiTheme::ToolbarButton(ICON_FA_ARROWS_ROTATE, "Refresh Assets (placeholder)", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight)); + ImGui::SameLine(); + Runtime::UiTheme::ToolbarButton(ICON_FA_MAGNET, "Snap Settings (placeholder)", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight)); + ImGui::SameLine(); + if (uiState_.fontIcon) + { + ImGui::PopFont(); + } + ImGui::EndGroup(); + ImGui::SameLine(0.0f, 16.0f); + + ImGui::PushStyleColor(ImGuiCol_Button, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Success, 0.92f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Success)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Success, 0.75f)); + if (ImGui::Button(ICON_FA_PLAY " Play", ImVec2(92.0f, kToolbarIconHeight))) { std::filesystem::path currentPath = std::filesystem::current_path(); std::string cmdline = (currentPath / "gkNextRenderer").string() + (GOption->ForceSDR ? " --forcesdr" : ""); std::system(cmdline.c_str()); } + Runtime::UiTheme::DrawTooltip("Run in gkNextRenderer"); + ImGui::PopStyleColor(3); + ImGui::SameLine(0.0f, 12.0f); + + ImGui::SetNextItemWidth(124.0f); + ImGui::Combo("##PlatformSelector", &platformIndex, ICON_FA_DESKTOP " Desktop\0Android\0iOS\0\0"); + Runtime::UiTheme::DrawTooltip("Target Platform"); ImGui::SameLine(); + ImGui::SetNextItemWidth(142.0f); + ImGui::Combo("##BuildConfigSelector", &buildConfigIndex, "Development\0Debug\0Shipping\0\0"); + Runtime::UiTheme::DrawTooltip("Build Configuration"); - ImGui::PopStyleColor(); - if (uiState_.fontIcon) + const float rightStart = viewport->Size.x - 104.0f; + if (ImGui::GetCursorPosX() < rightStart) { - ImGui::PopFont(); + ImGui::SameLine(rightStart); } - static int item = 3; - ImGui::SetNextItemWidth(120); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(7, 7)); - ImGui::Combo("##Render", &item, "RTPipe\0ModernDeferred\0LegacyDeferred\0RayQuery\0HybirdRender\0\0"); - ImGui::SameLine(); - ImGui::PopStyleVar(); - ImGui::EndGroup(); - ImGui::SameLine(); - - - ImGui::BeginGroup(); - ImGui::SameLine(50); if (uiState_.fontIcon) { ImGui::PushFont(uiState_.fontIcon); } - ImGui::Button(ICON_FA_FILE_IMPORT, ImVec2(kToolbarIconWidth, kToolbarIconHeight)); - ImGui::SameLine(); + Runtime::UiTheme::ToolbarButton(ICON_FA_GEAR, "Editor Settings", false, + ImVec2(kToolbarIconWidth, kToolbarIconHeight)); if (uiState_.fontIcon) { ImGui::PopFont(); } - ImGui::EndGroup(); + ImGui::SameLine(0.0f, 8.0f); + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 avatarPos = ImGui::GetCursorScreenPos(); + const float avatarRadius = kToolbarIconHeight * 0.5f; + drawList->AddCircleFilled(avatarPos + ImVec2(avatarRadius, avatarRadius), avatarRadius, + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Accent, 0.55f), 24); + drawList->AddCircle(avatarPos + ImVec2(avatarRadius, avatarRadius), avatarRadius, + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::BorderStrong), 24, 1.0f); + const ImVec2 initialsSize = ImGui::CalcTextSize("GK"); + drawList->AddText(avatarPos + ImVec2(avatarRadius - initialsSize.x * 0.5f, avatarRadius - initialsSize.y * 0.5f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Text), "GK"); + ImGui::Dummy(ImVec2(kToolbarIconHeight, kToolbarIconHeight)); + Runtime::UiTheme::DrawTooltip("User"); + ImGui::PopStyleVar(); ImGui::End(); } @@ -280,7 +369,7 @@ void EditorInterface::Render() // Global keyboard shortcuts are handled by NextEngine. ImGuiID id = DockSpaceUI(); - ToolbarUI(); + ToolbarUI(ctx); Editor::DrawTitleBarOverlay(ctx, uiState_); @@ -288,16 +377,10 @@ void EditorInterface::Render() Editor::DrawOutlinerPanel(ctx, uiState_); if (uiState_.properties) Editor::DrawPropertiesPanel(ctx, uiState_); - if (uiState_.contentBrowser) + if (uiState_.contentBrowser || uiState_.materialBrowser || uiState_.textureBrowser || uiState_.meshBrowser) Editor::DrawContentBrowserPanel(ctx, uiState_); if (uiState_.logPanel) Editor::DrawConsoleLogPanel(ctx, uiState_); - if (uiState_.materialBrowser) - Editor::DrawMaterialBrowserPanel(ctx, uiState_); - if (uiState_.textureBrowser) - Editor::DrawTextureBrowserPanel(ctx, uiState_); - if (uiState_.meshBrowser) - Editor::DrawMeshBrowserPanel(ctx, uiState_); if (uiState_.commandHistoryPanel) Editor::DrawCommandHistoryPanel(ctx, uiState_); if (uiState_.hotReloadPanel) diff --git a/src/Editor/EditorInterface.hpp b/src/Editor/EditorInterface.hpp index 26e0fd7d..b17b97a1 100644 --- a/src/Editor/EditorInterface.hpp +++ b/src/Editor/EditorInterface.hpp @@ -6,6 +6,8 @@ #include +struct EditorContext; + class EditorInterface final { public: @@ -24,7 +26,7 @@ class EditorInterface final private: ImGuiID DockSpaceUI(); void RebuildDefaultDockLayout(ImGuiID id); - void ToolbarUI(); + void ToolbarUI(EditorContext& ctx); void DrawIndicator(uint32_t frameCount); EditorGameInstance* editor_; diff --git a/src/Editor/Overlays/TitleBarOverlay.cpp b/src/Editor/Overlays/TitleBarOverlay.cpp index 3d9e4d0d..b76b8902 100644 --- a/src/Editor/Overlays/TitleBarOverlay.cpp +++ b/src/Editor/Overlays/TitleBarOverlay.cpp @@ -12,6 +12,7 @@ #include #include "Runtime/Engine.hpp" +#include "Runtime/Editor/ProfessionalUI.hpp" #include "Runtime/Editor/UserInterface.hpp" namespace Editor @@ -24,26 +25,44 @@ namespace Editor void DrawTitleBarOverlay(EditorContext& ctx, EditorUiState& ui) { ImGuiViewport* viewport = ImGui::GetMainViewport(); - float menuRight = viewport->Pos.x + kTitleBarHeight; + ImDrawList* background = ImGui::GetBackgroundDrawList(); + const ImVec2 titleMin = viewport->Pos; + const ImVec2 titleMax = viewport->Pos + ImVec2(viewport->Size.x, kTitleBarHeight); + background->AddRectFilled(titleMin, titleMax, Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Background), 0.0f); + background->AddLine(ImVec2(titleMin.x, titleMax.y - 1.0f), ImVec2(titleMax.x, titleMax.y - 1.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Border)); + + float menuRight = viewport->Pos.x + 210.0f; ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - // MENU - ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + kTitleBarHeight, viewport->Pos.y)); - ImGui::SetNextWindowSize(ImVec2(viewport->Size.x - 255.0f, kTitleBarHeight)); + ImGui::SetNextWindowPos(viewport->Pos); + ImGui::SetNextWindowSize(ImVec2(210.0f, kTitleBarHeight)); ImGui::SetNextWindowViewport(viewport->ID); - ImGui::SetNextWindowBgAlpha(0); + ImGui::SetNextWindowBgAlpha(0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(9.0f, 6.0f)); + ImGui::Begin("EditorBrand", nullptr, + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoDocking); + Runtime::UiTheme::DrawBrandMark(ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos(), 24.0f); + ImGui::Dummy(ImVec2(30.0f, 24.0f)); + ImGui::SameLine(0.0f, 8.0f); + ImGui::SetCursorPosY(8.0f); + ImGui::TextUnformatted("gkNextEditor"); + ImGui::End(); + ImGui::PopStyleVar(); + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + 210.0f, viewport->Pos.y)); + ImGui::SetNextWindowSize(ImVec2(viewport->Size.x - 360.0f, kTitleBarHeight)); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::SetNextWindowBgAlpha(0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); ImGui::Begin("Menubar", nullptr, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoDocking); - - ImGui::GetWindowDrawList()->AddRectFilled(viewport->Pos, - viewport->Pos + ImVec2(viewport->Size.x, kTitleBarHeight), - ImGui::GetColorU32(ImGuiCol_MenuBarBg)); ImGui::PopStyleVar(); if (ImGui::BeginMenuBar()) @@ -55,8 +74,8 @@ namespace Editor if (ImGui::MenuItem("Open Scene...", "Ctrl+O")) { SDL_DialogFileFilter filters[] = { - { "Scenes", "glb;gltf;ldr;mpd" }, - { "All Files", "*" } + {"Scenes", "glb;gltf;ldr;mpd"}, + {"All Files", "*"}, }; SDL_ShowOpenFileDialog( [](void* userdata, const char* const* filelist, int /*filter*/) @@ -86,9 +105,8 @@ namespace Editor } else { - for (size_t i = 0; i < ui.recentScenes.size(); ++i) + for (const std::string& path : ui.recentScenes) { - const std::string& path = ui.recentScenes[i]; std::string displayName = std::filesystem::path(path).filename().string(); if (ImGui::MenuItem(displayName.c_str(), path.c_str())) { @@ -109,13 +127,27 @@ namespace Editor ImGui::EndMenu(); } + if (ImGui::MenuItem("Save Scene", "Ctrl+S")) + { + const std::string filename = ui.currentScenePath.empty() ? "saved_scene.glb" : ui.currentScenePath; + const bool success = ctx.scene.Save(filename); + if (success) + { + ui.currentScenePath = filename; + SPDLOG_INFO("Scene saved successfully: {}", filename); + } + else + { + SPDLOG_ERROR("Failed to save scene: {}", filename); + } + } if (ImGui::MenuItem("Save Scene As...", "Ctrl+Shift+S")) { - // TODO: Add file dialog for save path selection const std::string filename = "saved_scene.glb"; const bool success = ctx.scene.Save(filename); if (success) { + ui.currentScenePath = filename; SPDLOG_INFO("Scene saved successfully: {}", filename); } else @@ -138,18 +170,13 @@ namespace Editor menuRight = std::max(menuRight, ImGui::GetItemRectMax().x); if (editMenuOpen) { - // Undo/Redo CommandHistory& history = ctx.engine.GetCommandHistory(); - bool canUndo = history.CanUndo(); - bool canRedo = history.CanRedo(); - - std::string undoLabel = canUndo - ? fmt::format("Undo {}", history.GetUndoDescription()) - : "Undo"; - std::string redoLabel = canRedo - ? fmt::format("Redo {}", history.GetRedoDescription()) - : "Redo"; - + const bool canUndo = history.CanUndo(); + const bool canRedo = history.CanRedo(); + + const std::string undoLabel = canUndo ? fmt::format("Undo {}", history.GetUndoDescription()) : "Undo"; + const std::string redoLabel = canRedo ? fmt::format("Redo {}", history.GetRedoDescription()) : "Redo"; + if (ImGui::MenuItem(undoLabel.c_str(), "Ctrl+Z", false, canUndo)) { history.Undo(); @@ -158,12 +185,12 @@ namespace Editor { history.Redo(); } - + ImGui::Separator(); - + if (ImGui::BeginMenu("Layout")) { - if (ImGui::MenuItem("Reset")) + if (ImGui::MenuItem("Reset Dock Layout")) { ui.dockResetRequested = true; } @@ -172,6 +199,72 @@ namespace Editor ImGui::EndMenu(); } + bool viewMenuOpen = ImGui::BeginMenu("View"); + menuRight = std::max(menuRight, ImGui::GetItemRectMax().x); + if (viewMenuOpen) + { + static int viewportMode = 0; + static bool showGrid = true; + static bool showBounds = false; + static bool showIcons = true; + static bool gizmoTranslate = true; + static bool gizmoRotate = false; + static bool gizmoScale = false; + static bool snapEnabled = true; + + if (ImGui::BeginMenu("Viewport Display Mode")) + { + if (ImGui::MenuItem("Lit", nullptr, viewportMode == 0)) + { + viewportMode = 0; + } + if (ImGui::MenuItem("Lighting Only", nullptr, viewportMode == 1)) + { + viewportMode = 1; + } + if (ImGui::MenuItem("Wireframe", nullptr, viewportMode == 2)) + { + viewportMode = 2; + } + if (ImGui::MenuItem("Unlit", nullptr, viewportMode == 3)) + { + viewportMode = 3; + } + ImGui::EndMenu(); + } + + ImGui::Separator(); + ImGui::MenuItem("Show Grid", nullptr, &showGrid); + ImGui::MenuItem("Show Bounds", nullptr, &showBounds); + ImGui::MenuItem("Show Icons", nullptr, &showIcons); + + if (ImGui::BeginMenu("Gizmo")) + { + if (ImGui::MenuItem("Translate", "W", gizmoTranslate)) + { + gizmoTranslate = true; + gizmoRotate = false; + gizmoScale = false; + } + if (ImGui::MenuItem("Rotate", "E", gizmoRotate)) + { + gizmoTranslate = false; + gizmoRotate = true; + gizmoScale = false; + } + if (ImGui::MenuItem("Scale", "R", gizmoScale)) + { + gizmoTranslate = false; + gizmoRotate = false; + gizmoScale = true; + } + ImGui::Separator(); + ImGui::MenuItem("Enable Snap", nullptr, &snapEnabled); + ImGui::EndMenu(); + } + ImGui::EndMenu(); + } + bool toolsMenuOpen = ImGui::BeginMenu("Tools"); menuRight = std::max(menuRight, ImGui::GetItemRectMax().x); if (toolsMenuOpen) @@ -189,13 +282,43 @@ namespace Editor ImGui::EndMenu(); } + bool buildMenuOpen = ImGui::BeginMenu("Build"); + menuRight = std::max(menuRight, ImGui::GetItemRectMax().x); + if (buildMenuOpen) + { + ImGui::MenuItem("Cook Assets", nullptr, false, false); + ImGui::MenuItem("Package Project", nullptr, false, false); + ImGui::MenuItem("Launch Renderer", nullptr, false, false); + ImGui::EndMenu(); + } + + bool windowsMenuOpen = ImGui::BeginMenu("Windows"); + menuRight = std::max(menuRight, ImGui::GetItemRectMax().x); + if (windowsMenuOpen) + { + ImGui::MenuItem("Outliner", nullptr, &ui.sidebar); + ImGui::MenuItem("Properties", nullptr, &ui.properties); + ImGui::MenuItem("Content Browser", nullptr, &ui.contentBrowser); + ImGui::MenuItem("Console", nullptr, &ui.logPanel); + ImGui::MenuItem("Material Editor", nullptr, &ui.child_mat_editor); + + ImGui::Separator(); + ImGui::MenuItem("Material Browser", nullptr, &ui.materialBrowser); + ImGui::MenuItem("Texture Browser", nullptr, &ui.textureBrowser); + ImGui::MenuItem("Mesh Browser", nullptr, &ui.meshBrowser); + ImGui::MenuItem("AI Assistant", nullptr, &ui.aiPanel); + ImGui::MenuItem("Command History", nullptr, &ui.commandHistoryPanel); + ImGui::MenuItem("Hot Reload", nullptr, &ui.hotReloadPanel); + ImGui::EndMenu(); + } + bool helpMenuOpen = ImGui::BeginMenu("Help"); menuRight = std::max(menuRight, ImGui::GetItemRectMax().x); if (helpMenuOpen) { if (ImGui::MenuItem("Resources")) ui.child_resources = true; - if (ImGui::MenuItem("About ImStudio")) + if (ImGui::MenuItem("About gkNextEditor")) ui.child_about = true; ImGui::EndMenu(); } @@ -204,75 +327,46 @@ namespace Editor } ImGui::End(); - const float dragLeftReserved = std::max(kTitleBarHeight, menuRight - viewport->Pos.x + kMenuHitPadding); - ctx.engine.ConfigureCustomTitleBarDrag(true, kTitleBarHeight, dragLeftReserved, 200.0f); + const float dragLeftReserved = std::max(230.0f, menuRight - viewport->Pos.x + kMenuHitPadding); + ctx.engine.ConfigureCustomTitleBarDrag(true, kTitleBarHeight, dragLeftReserved, 150.0f); - // LOGO - ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x, viewport->Pos.y)); - ImGui::SetNextWindowSize(ImVec2(kTitleBarHeight, kTitleBarHeight)); + ImGui::SetNextWindowPos(viewport->Pos + ImVec2(viewport->Size.x - 150.0f, 0.0f)); + ImGui::SetNextWindowSize(ImVec2(150.0f, kTitleBarHeight)); ImGui::SetNextWindowViewport(viewport->ID); - ImGui::SetNextWindowBgAlpha(0); + ImGui::SetNextWindowBgAlpha(0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); - ImGui::Begin("Logo", nullptr, + ImGui::Begin("WindowControls", nullptr, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoDocking); - - ImGui::GetWindowDrawList()->AddRectFilled(viewport->Pos, - viewport->Pos + ImVec2(kTitleBarHeight, kTitleBarHeight), - ImGui::GetColorU32(ImGuiCol_MenuBarBg)); - if (ui.bigIcon) + ImGui::SetCursorPos(ImVec2(14.0f, 4.0f)); + if (ui.fontIcon) { - ImGui::PushFont(ui.bigIcon); + ImGui::PushFont(ui.fontIcon); } - ImGui::GetWindowDrawList()->AddText(viewport->Pos + ImVec2(10, 7), IM_COL32(240, 180, 60, 255), - ICON_FA_SHEKEL_SIGN); - if (ui.bigIcon) - { - ImGui::PopFont(); - } - ImGui::End(); - - // XMARK - ImGui::SetNextWindowPos(viewport->Pos + ImVec2(viewport->Size.x - 200.0f, 0.0f)); - ImGui::SetNextWindowSize(ImVec2(200.0f, kTitleBarHeight)); - ImGui::SetNextWindowViewport(viewport->ID); - ImGui::SetNextWindowBgAlpha(0); - - ImGui::Begin("XMark", nullptr, - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | - ImGuiWindowFlags_NoDocking); - - ImGui::GetWindowDrawList()->AddRectFilled(viewport->Pos + ImVec2(viewport->Size.x - 200.0f, 0.0f), - viewport->Pos + ImVec2(viewport->Size.x, kTitleBarHeight), - ImGui::GetColorU32(ImGuiCol_MenuBarBg)); - ImGui::SetCursorPos(ImVec2(50, 5)); - ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(0, 0, 0, 0)); - if (ImGui::Button(ICON_FA_WINDOW_MINIMIZE, ImVec2(40, 40))) + if (Runtime::UiTheme::ToolbarButton(ICON_FA_WINDOW_MINIMIZE, "Minimize", false, ImVec2(38.0f, 28.0f))) { ctx.actions.Dispatch(ctx, EEditorAction::System_RequestMinimize); } - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_WINDOW_MAXIMIZE, ImVec2(40, 40))) + ImGui::SameLine(0.0f, 4.0f); + if (Runtime::UiTheme::ToolbarButton(ctx.engine.IsMaximumed() ? ICON_FA_WINDOW_RESTORE : ICON_FA_WINDOW_MAXIMIZE, + "Maximize", false, ImVec2(38.0f, 28.0f))) { ctx.actions.Dispatch(ctx, EEditorAction::System_ToggleMaximize); } - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_XMARK, ImVec2(40, 40))) + ImGui::SameLine(0.0f, 4.0f); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_XMARK, "Close", false, ImVec2(38.0f, 28.0f))) { ctx.actions.Dispatch(ctx, EEditorAction::System_RequestExit); } - ImGui::SameLine(); - ImGui::PopStyleColor(); + if (ui.fontIcon) + { + ImGui::PopFont(); + } ImGui::End(); - ImGui::PopStyleVar(); - ImGui::PopStyleVar(); - ImGui::PopStyleVar(); + ImGui::PopStyleVar(2); - // FOOTER ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x, viewport->Pos.y + viewport->Size.y - kFooterHeight)); ImGui::SetNextWindowSize(ImVec2(viewport->Size.x, kFooterHeight)); ImGui::SetNextWindowViewport(viewport->ID); @@ -280,7 +374,7 @@ namespace Editor ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 4.0f)); ImGui::Begin("Footer", nullptr, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | @@ -290,15 +384,33 @@ namespace Editor ImGui::GetWindowDrawList()->AddLine( ImVec2(viewport->Pos.x, viewport->Pos.y + viewport->Size.y - kFooterHeight), ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y - kFooterHeight), - IM_COL32(20, 20, 20, 255), 2); + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Border), 1.0f); + + Runtime::UiTheme::DrawStatusDot("Ready", true); + + const float rightWidth = 430.0f; + const float rightStart = viewport->Size.x - rightWidth; + if (ImGui::GetCursorPosX() < rightStart) + { + ImGui::SameLine(rightStart); + } + Runtime::UiTheme::DrawBadge("Live Link", Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Success, 0.18f), + Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Success)); + ImGui::SameLine(0.0f, 8.0f); + Runtime::UiTheme::DrawBadge("Source Control", + Runtime::UiTheme::Color(Runtime::UiTheme::EColor::SurfaceElevated, 0.90f), + Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted)); + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "%.0f FPS", + ctx.engine.GetFrameRate()); + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Blue), "Vulkan"); + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "%.2f ms", + ctx.engine.GetSmoothDeltaSeconds() * 1000.0); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6, 6)); - ctx.ui.DrawConsoleCommandInput("##CVar", "Execute CVar...", 200.0f, false, true, "##FooterConsoleMatches"); - ImGui::PopStyleVar(); ImGui::End(); - ImGui::PopStyleVar(); - ImGui::PopStyleVar(); - ImGui::PopStyleVar(); + ImGui::PopStyleVar(3); } } // namespace Editor diff --git a/src/Editor/Panels/ContentBrowserPanel.cpp b/src/Editor/Panels/ContentBrowserPanel.cpp index 99152ae6..7cd43dbc 100644 --- a/src/Editor/Panels/ContentBrowserPanel.cpp +++ b/src/Editor/Panels/ContentBrowserPanel.cpp @@ -7,6 +7,7 @@ #include "Editor/EditorActionDispatcher.hpp" #include "Runtime/Scene/SceneList.hpp" #include "Runtime/Editor/UserInterface.hpp" +#include "Runtime/Editor/ProfessionalUI.hpp" #include "ThirdParty/fontawesome/IconsFontAwesome6.h" #include "Utilities/FileHelper.hpp" @@ -26,8 +27,8 @@ namespace Editor { namespace { - constexpr int kIconSize = 96; - constexpr int kIconPadding = 20; + float GContentBrowserIconSize = 76.0f; + constexpr float kIconPadding = 12.0f; struct ContentBrowserCallbacks { @@ -74,7 +75,7 @@ namespace Editor { const float windowWidth = ImGui::GetContentRegionAvail().x; const int itemsPerRow = - std::max(1, static_cast(windowWidth / (kIconSize + ImGui::GetStyle().ItemSpacing.x))); + std::max(1, static_cast(windowWidth / (GContentBrowserIconSize + ImGui::GetStyle().ItemSpacing.x))); return ContentGridLayout{itemsPerRow, 0}; } @@ -280,17 +281,19 @@ namespace Editor { ImGui::PushFont(ui.bigIcon); } - ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(32, 32, 32, 255)); + ImGui::PushStyleColor(ImGuiCol_Button, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Background)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::SurfaceHover)); ImGui::PushID(static_cast(globalId)); VkDescriptorSet textureId = ctx.ui.RequestImTextureId(globalId); if (iconOrTex || (VK_NULL_HANDLE == textureId)) { - ImGui::Button(icon, ImVec2(kIconSize, kIconSize)); + ImGui::Button(icon, ImVec2(GContentBrowserIconSize, GContentBrowserIconSize)); } else { - ImGui::Image((ImTextureID)(intptr_t)textureId, ImVec2(kIconSize, kIconSize)); + ImGui::Image((ImTextureID)(intptr_t)textureId, + ImVec2(GContentBrowserIconSize, GContentBrowserIconSize)); } if (callbacks.onDragSource && ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) @@ -300,7 +303,7 @@ namespace Editor } ImGui::PopID(); - ImGui::PopStyleColor(); + ImGui::PopStyleColor(2); if (ui.bigIcon) { ImGui::PopFont(); @@ -324,12 +327,15 @@ namespace Editor auto cursorPos = ImGui::GetCursorPos() + ImGui::GetWindowPos() - ImVec2(0, 4 + ImGui::GetScrollY()); const bool selected = selectionId == globalId; - ImGui::GetWindowDrawList()->AddRectFilled(cursorPos, cursorPos + ImVec2(kIconSize, kIconSize / 5.0f * 3.0f), - selected ? ActiveColor : IM_COL32(64, 64, 64, 255), 4); - ImGui::GetWindowDrawList()->AddLine(cursorPos, cursorPos + ImVec2(kIconSize, 0), color, 2); - - ImGui::PushItemWidth(kIconSize); - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + kIconSize); + ImGui::GetWindowDrawList()->AddRectFilled( + cursorPos, cursorPos + ImVec2(GContentBrowserIconSize, GContentBrowserIconSize / 5.0f * 3.0f), + selected ? Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Accent, 0.72f) + : Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::SurfaceElevated), + 4); + ImGui::GetWindowDrawList()->AddLine(cursorPos, cursorPos + ImVec2(GContentBrowserIconSize, 0), color, 2); + + ImGui::PushItemWidth(GContentBrowserIconSize); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + GContentBrowserIconSize); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 5); ImGui::Text("%s", name.c_str()); ImGui::PopTextWrapPos(); @@ -349,22 +355,98 @@ namespace Editor } } // namespace + int DrawMeshBrowserContents(EditorContext& ctx, EditorUiState& ui, ImGuiTextFilter* filter) + { + auto& allModels = ctx.scene.Models(); + int itemCount = 0; + ContentGridLayout grid = BeginContentGrid(); + for (uint32_t i = 0; i < allModels.size(); ++i) + { + auto& model = allModels[i]; + const std::string name = fmt::format("{}_#{}", model.Name(), i); + if (filter != nullptr && filter->IsActive() && !filter->PassFilter(name.c_str())) + { + continue; + } + + uint32_t dummySelection = InvalidId; + DrawGeneralContentBrowser(ctx, ui, dummySelection, true, i, name, ICON_FA_BOXES_PACKING, + IM_COL32(132, 182, 255, 255), + ContentBrowserCallbacks{}); + grid.Next(); + ++itemCount; + } + return itemCount; + } + + int DrawMaterialBrowserContents(EditorContext& ctx, EditorUiState& ui, ImGuiTextFilter* filter) + { + auto& allMaterials = ctx.scene.Materials(); + int itemCount = 0; + ContentGridLayout grid = BeginContentGrid(); + for (uint32_t i = 0; i < allMaterials.size(); ++i) + { + auto& mat = allMaterials[i]; + if (filter != nullptr && filter->IsActive() && !filter->PassFilter(mat.name_.c_str())) + { + continue; + } + + DrawGeneralContentBrowser(ctx, ui, ui.selectedMaterialId, true, i, mat.name_, ICON_FA_BOWLING_BALL, + IM_COL32(132, 255, 132, 255), + ContentBrowserCallbacks{ + .onDoubleClick = + [&]() + { + ui.selected_material = &(ctx.scene.Materials()[i]); + ui.ed_material = true; + OpenMaterialEditor(ctx, ui); + }, + .onDragSource = + [&]() + { + EditorDragDropPayload payload{}; + payload.type = EEditorDragPayloadType::Material; + payload.materialId = i; + ImGui::SetDragDropPayload(kEditorDragDropPayload, &payload, + sizeof(payload)); + ImGui::TextUnformatted(mat.name_.c_str()); + }, + }); + + grid.Next(); + ++itemCount; + } + return itemCount; + } + + int DrawTextureBrowserContents(EditorContext& ctx, EditorUiState& ui, ImGuiTextFilter* filter) + { + auto& totalTextureMap = Assets::GlobalTexturePool::GetInstance()->TotalTextureMap(); + int itemCount = 0; + ContentGridLayout grid = BeginContentGrid(); + for (auto& textureGroup : totalTextureMap) + { + if (filter != nullptr && filter->IsActive() && !filter->PassFilter(textureGroup.first.c_str())) + { + continue; + } + + DrawGeneralContentBrowser(ctx, ui, ui.selectedTextureId, false, textureGroup.second.GlobalIdx_, + textureGroup.first, ICON_FA_LINK_SLASH, IM_COL32(255, 72, 72, 255), + ContentBrowserCallbacks{}); + grid.Next(); + ++itemCount; + } + return itemCount; + } + void DrawMeshBrowserPanel(EditorContext& ctx, EditorUiState& ui) { ImGui::Begin("Mesh Browser", nullptr); { - auto& allModels = ctx.scene.Models(); - ContentGridLayout grid = BeginContentGrid(); - for (uint32_t i = 0; i < allModels.size(); ++i) - { - auto& model = allModels[i]; - const std::string name = fmt::format("{}_#{}", model.Name(), i); - uint32_t dummySelection = InvalidId; - DrawGeneralContentBrowser(ctx, ui, dummySelection, true, i, name, ICON_FA_BOXES_PACKING, - IM_COL32(132, 182, 255, 255), - ContentBrowserCallbacks{}); - grid.Next(); - } + Runtime::UiTheme::DrawPanelHeader(ICON_FA_BOXES_PACKING, "Meshes", "Scene model buffers"); + DrawMeshBrowserContents(ctx, ui, nullptr); } ImGui::End(); } @@ -373,35 +455,8 @@ namespace Editor { ImGui::Begin("Material Browser", nullptr); { - auto& allMaterials = ctx.scene.Materials(); - ContentGridLayout grid = BeginContentGrid(); - for (uint32_t i = 0; i < allMaterials.size(); ++i) - { - auto& mat = allMaterials[i]; - DrawGeneralContentBrowser(ctx, ui, ui.selectedMaterialId, true, i, mat.name_, ICON_FA_BOWLING_BALL, - IM_COL32(132, 255, 132, 255), - ContentBrowserCallbacks{ - .onDoubleClick = - [&]() - { - ui.selected_material = &(ctx.scene.Materials()[i]); - ui.ed_material = true; - OpenMaterialEditor(ctx, ui); - }, - .onDragSource = - [&]() - { - EditorDragDropPayload payload{}; - payload.type = EEditorDragPayloadType::Material; - payload.materialId = i; - ImGui::SetDragDropPayload(kEditorDragDropPayload, &payload, - sizeof(payload)); - ImGui::TextUnformatted(mat.name_.c_str()); - }, - }); - - grid.Next(); - } + Runtime::UiTheme::DrawPanelHeader(ICON_FA_CIRCLE_HALF_STROKE, "Materials", "Drag materials onto viewport objects"); + DrawMaterialBrowserContents(ctx, ui, nullptr); } ImGui::End(); } @@ -410,15 +465,8 @@ namespace Editor { ImGui::Begin("Texture Browser", nullptr); { - auto& totalTextureMap = Assets::GlobalTexturePool::GetInstance()->TotalTextureMap(); - ContentGridLayout grid = BeginContentGrid(); - for (auto& textureGroup : totalTextureMap) - { - DrawGeneralContentBrowser(ctx, ui, ui.selectedTextureId, false, textureGroup.second.GlobalIdx_, - textureGroup.first, ICON_FA_LINK_SLASH, IM_COL32(255, 72, 72, 255), - ContentBrowserCallbacks{}); - grid.Next(); - } + Runtime::UiTheme::DrawPanelHeader(ICON_FA_IMAGE, "Textures", "Loaded GPU textures"); + DrawTextureBrowserContents(ctx, ui, nullptr); } ImGui::End(); } @@ -427,12 +475,44 @@ namespace Editor { ImGui::Begin("Content Browser", nullptr); { + Runtime::UiTheme::DrawPanelHeader(ICON_FA_FOLDER_TREE, "Assets", "Project content browser"); static const std::filesystem::path rootPath = std::filesystem::path(Utilities::FileHelper::GetPlatformFilePath("assets")); static std::filesystem::path currentPath = rootPath; static std::unordered_map> directoryCache; + static ImGuiTextFilter contentFilter; + int itemCount = 0; + int selectedCount = ui.selectedContentItemId != InvalidId ? 1 : 0; + if (ImGui::Button(ICON_FA_FILE_IMPORT " Import")) + { + SPDLOG_INFO("Content Browser import placeholder"); + } + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_PLUS " Add")) + { + ImGui::OpenPopup("ContentAddPopup"); + } + if (ImGui::BeginPopup("ContentAddPopup")) + { + ImGui::MenuItem("Material", nullptr, false, false); + ImGui::MenuItem("Texture", nullptr, false, false); + ImGui::MenuItem("Scene", nullptr, false, false); + ImGui::MenuItem("Script", nullptr, false, false); + ImGui::EndPopup(); + } + ImGui::SameLine(); + contentFilter.Draw("Filter##ContentBrowserFilter", 220.0f); + ImGui::SameLine(); + ImGui::SetNextItemWidth(130.0f); + ImGui::SliderFloat("Thumbnail", &GContentBrowserIconSize, 48.0f, 112.0f, "%.0f"); + Runtime::UiTheme::DrawThinSeparator(); + + if (ImGui::BeginTabBar("ContentBrowserTabs")) + { + if (ImGui::BeginTabItem("Content Browser")) + { DrawContentBrowserNavigation(ui, rootPath, currentPath, directoryCache); auto cursorPos = ImGui::GetWindowPos() + ImVec2(0, ImGui::GetCursorPos().y + 2); @@ -443,7 +523,7 @@ namespace Editor ImGui::GetWindowDrawList()->AddLine(cursorPos, cursorPos + ImVec2(ImGui::GetWindowSize().x, 0), IM_COL32(20, 20, 20, 255), 1); - ImGui::BeginChild("Content Items"); + ImGui::BeginChild("Content Items", ImVec2(0.0f, -24.0f)); auto& entries = GetCachedDirectoryEntries(currentPath, directoryCache); ContentGridLayout grid = BeginContentGrid(); @@ -457,6 +537,10 @@ namespace Editor { continue; } + if (contentFilter.IsActive() && !contentFilter.PassFilter(name.c_str())) + { + continue; + } const uint32_t stableId = Fnv1a32(abspath); DrawGeneralContentBrowser( @@ -508,8 +592,39 @@ namespace Editor }); grid.Next(); + ++itemCount; } ImGui::EndChild(); + selectedCount = ui.selectedContentItemId != InvalidId ? 1 : 0; + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Material Browser")) + { + ImGui::BeginChild("Material Items", ImVec2(0.0f, -24.0f)); + itemCount = DrawMaterialBrowserContents(ctx, ui, &contentFilter); + selectedCount = ui.selectedMaterialId != InvalidId ? 1 : 0; + ImGui::EndChild(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Texture Browser")) + { + ImGui::BeginChild("Texture Items", ImVec2(0.0f, -24.0f)); + itemCount = DrawTextureBrowserContents(ctx, ui, &contentFilter); + selectedCount = ui.selectedTextureId != InvalidId ? 1 : 0; + ImGui::EndChild(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Mesh Browser")) + { + ImGui::BeginChild("Mesh Items", ImVec2(0.0f, -24.0f)); + itemCount = DrawMeshBrowserContents(ctx, ui, &contentFilter); + selectedCount = 0; + ImGui::EndChild(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + ImGui::Text("%d items (%d selected)", itemCount, selectedCount); } ImGui::End(); } diff --git a/src/Editor/Panels/OutlinerPanel.cpp b/src/Editor/Panels/OutlinerPanel.cpp index 8833aec5..ed6a6559 100644 --- a/src/Editor/Panels/OutlinerPanel.cpp +++ b/src/Editor/Panels/OutlinerPanel.cpp @@ -9,6 +9,7 @@ #include "Runtime/Command/DeleteNodesCommand.hpp" #include "Runtime/Command/RenameNodeCommand.hpp" #include "Runtime/Engine.hpp" +#include "Runtime/Editor/ProfessionalUI.hpp" #include "ThirdParty/fontawesome/IconsFontAwesome6.h" #include @@ -146,7 +147,6 @@ namespace Editor auto render = node.GetComponent(); const int modelId = render ? render->GetModelId() : -1; const bool visible = render == nullptr || render->GetVisible(); - const float visibilityIconWidth = ImGui::CalcTextSize(ICON_FA_EYE).x; const bool shouldOpenForTarget = autoScrollEnabled && pendingScrollTargetId != InvalidId && @@ -170,12 +170,18 @@ namespace Editor ui.pendingCollapseTargetId = InvalidId; } - const std::string lockPrefix = locked ? std::string(ICON_FA_LOCK " ") : ""; - const std::string label = lockPrefix + (modelId == -1 ? ICON_FA_CIRCLE_NOTCH : ICON_FA_CUBE) + + const std::string label = (modelId == -1 ? ICON_FA_CIRCLE_NOTCH : ICON_FA_CUBE) + std::string(" ") + node.GetName(); + const ImU32 textColor = !visible ? ImGui::GetColorU32(ImGuiCol_TextDisabled) + : selected ? ActiveColor : ImGui::GetColorU32(ImGuiCol_Text); + ImGui::PushStyleColor(ImGuiCol_Text, textColor); + const bool opened = ImGui::TreeNodeEx(label.c_str(), flag); + + ImGui::PopStyleColor(); + const float columnWidth = ImGui::GetColumnWidth(); + ImGui::SameLine(std::max(ImGui::GetCursorPosX(), columnWidth - 56.0f)); if (render != nullptr) { - ImGui::AlignTextToFramePadding(); if (!visible) { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(ImGuiCol_TextDisabled)); @@ -197,16 +203,18 @@ namespace Editor } else { - ImGui::Dummy(ImVec2(visibilityIconWidth, ImGui::GetFrameHeight())); + ImGui::TextDisabled(ICON_FA_EYE); + } + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextUnformatted(locked ? ICON_FA_LOCK : ICON_FA_LOCK_OPEN); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) + { + ctx.scene.ToggleLocked(node.GetInstanceId()); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", locked ? "Unlock Node" : "Lock Node"); } - AlignNextOutlinerInlineItem(); - - const ImU32 textColor = !visible ? ImGui::GetColorU32(ImGuiCol_TextDisabled) - : selected ? ActiveColor : ImGui::GetColorU32(ImGuiCol_Text); - ImGui::PushStyleColor(ImGuiCol_Text, textColor); - const bool opened = ImGui::TreeNodeEx(label.c_str(), flag); - - ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) { @@ -307,6 +315,38 @@ namespace Editor ImGui::PopID(); } + + void DrawLayersPanel() + { + struct FLayerRow + { + const char* name; + ImVec4 color; + }; + constexpr FLayerRow layers[] = { + {"Default", ImVec4(0.42f, 0.63f, 0.95f, 1.0f)}, + {"Gameplay", ImVec4(0.24f, 0.80f, 0.44f, 1.0f)}, + {"Props", ImVec4(0.95f, 0.68f, 0.24f, 1.0f)}, + {"Colliders", ImVec4(0.88f, 0.28f, 0.28f, 1.0f)}, + {"Lighting", ImVec4(0.80f, 0.70f, 1.0f, 1.0f)}, + }; + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + for (const FLayerRow& layer : layers) + { + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float rowHeight = ImGui::GetFrameHeight(); + drawList->AddCircleFilled(ImVec2(pos.x + 6.0f, pos.y + rowHeight * 0.5f), 4.0f, + ImGui::GetColorU32(layer.color), 12); + ImGui::Dummy(ImVec2(16.0f, rowHeight)); + ImGui::SameLine(); + ImGui::TextUnformatted(layer.name); + ImGui::SameLine(std::max(ImGui::GetCursorPosX(), ImGui::GetColumnWidth() - 56.0f)); + ImGui::TextUnformatted(ICON_FA_EYE); + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextUnformatted(ICON_FA_LOCK_OPEN); + } + } } // namespace void DrawOutlinerPanel(EditorContext& ctx, EditorUiState& ui) @@ -323,42 +363,28 @@ namespace Editor ImGui::Begin("Outliner", nullptr); { - ImGui::TextDisabled("NOTE"); + const std::string subtitle = std::to_string(ctx.scene.Nodes().size()) + " scene nodes"; + ImGui::Text("%s Outliner", ICON_FA_DIAGRAM_PROJECT); ImGui::SameLine(); - utils::HelpMarker("ALL SCENE NODES\n" - "limited to 1000 nodes\n" - "select and view node properties\n"); - ImGui::Separator(); + Runtime::UiTheme::IconButton(ICON_FA_PLUS "##CreateActor", "Create Actor (placeholder)", false, + ImVec2(24.0f, 22.0f)); + ImGui::TextDisabled("%s", subtitle.c_str()); + Runtime::UiTheme::DrawThinSeparator(); - ImGui::Text("Nodes"); - ImGui::SameLine(); const bool autoScrollWasEnabled = ui.outlinerAutoScrollToSelection; - if (autoScrollWasEnabled) - { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.10f, 0.35f, 0.75f, 0.75f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.16f, 0.42f, 0.82f, 0.85f)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.08f, 0.30f, 0.68f, 0.95f)); - } - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4.0f, 2.0f)); - if (ImGui::Button(ICON_FA_LOCATION_CROSSHAIRS "##AutoScrollToSelection")) + if (Runtime::UiTheme::IconButton(ICON_FA_LOCATION_CROSSHAIRS "##AutoScrollToSelection", + "Auto Scroll To Selection", autoScrollWasEnabled, + ImVec2(28.0f, 24.0f))) { ui.outlinerAutoScrollToSelection = !ui.outlinerAutoScrollToSelection; } - ImGui::PopStyleVar(); - if (autoScrollWasEnabled) - { - ImGui::PopStyleColor(3); - } - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("Auto Scroll To Selection: %s\nWhen enabled, selecting an object in viewport " - "auto-scrolls Outliner to it.", - ui.outlinerAutoScrollToSelection ? "On" : "Off"); - } ImGui::SameLine(); - ImGui::SetNextItemWidth(200.0f); - nodeFilter.Draw(ICON_FA_MAGNIFYING_GLASS "##OutlinerFilter", 200.0f); - ImGui::Separator(); + Runtime::UiTheme::IconButton(ICON_FA_LAYER_GROUP, "Create Group (placeholder)", false, + ImVec2(28.0f, 24.0f)); + + ImGui::SetNextItemWidth(-FLT_MIN); + nodeFilter.Draw(ICON_FA_MAGNIFYING_GLASS " Filter##OutlinerFilter", -FLT_MIN); + Runtime::UiTheme::DrawThinSeparator(); const uint32_t currentSelectionId = ctx.scene.GetSelectedId(); if (ui.outlinerAutoScrollToSelection) @@ -377,7 +403,7 @@ namespace Editor prevAutoScrollEnabled = ui.outlinerAutoScrollToSelection; lastSelectionId = currentSelectionId; - ImGui::BeginChild("ListBox", ImVec2(0, -50)); + ImGui::BeginChild("ListBox", ImVec2(0, -132.0f)); if (ImGui::BeginTable("NodesList", 1, ImGuiTableFlags_NoBordersInBodyUntilResize | ImGuiTableFlags_RowBg)) { @@ -590,8 +616,24 @@ namespace Editor ImGui::EndPopup(); } + if (ImGui::BeginTabBar("OutlinerSubTabs")) + { + if (ImGui::BeginTabItem("Scene")) + { + ImGui::TextDisabled("Root scene graph"); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Layers")) + { + DrawLayersPanel(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + ImGui::Spacing(); - ImGui::Text("%d Nodes", static_cast(ctx.scene.Nodes().size())); + ImGui::Text("%d actors (%d selected)", static_cast(ctx.scene.Nodes().size()), + static_cast(ctx.scene.GetSelectedIds().size())); ImGui::Spacing(); if ((ImGui::GetIO().KeyAlt) && (ImGui::IsKeyPressed(ImGuiKey_F4))) diff --git a/src/Editor/Panels/PropertiesPanel.cpp b/src/Editor/Panels/PropertiesPanel.cpp index 24f8ad71..76a34efd 100644 --- a/src/Editor/Panels/PropertiesPanel.cpp +++ b/src/Editor/Panels/PropertiesPanel.cpp @@ -8,6 +8,7 @@ #include "Runtime/Components/SkinnedMeshComponent.h" #include "Runtime/Command/RenameNodeCommand.hpp" #include "Runtime/Engine.hpp" +#include "Runtime/Editor/ProfessionalUI.hpp" #include "Runtime/Reflection/PropertyAccessor.h" #include "ThirdParty/fontawesome/IconsFontAwesome6.h" @@ -15,15 +16,61 @@ #include #include +#include #include #include namespace Editor { + namespace + { + bool DrawAxisFloat3(const char* label, glm::vec3& value, float speed) + { + bool changed = false; + constexpr ImVec4 axisColors[] = { + ImVec4(0.90f, 0.20f, 0.18f, 1.0f), + ImVec4(0.22f, 0.78f, 0.34f, 1.0f), + ImVec4(0.24f, 0.48f, 0.95f, 1.0f), + }; + constexpr const char* axisIds[] = {"X", "Y", "Z"}; + + ImGui::PushID(label); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(82.0f); + + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float width = std::max(58.0f, (ImGui::GetContentRegionAvail().x - spacing * 2.0f) / 3.0f); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + + for (int axis = 0; axis < 3; ++axis) + { + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float height = ImGui::GetFrameHeight(); + drawList->AddRectFilled(pos, ImVec2(pos.x + 2.0f, pos.y + height), + ImGui::GetColorU32(axisColors[axis]), 1.0f); + ImGui::SetCursorScreenPos(ImVec2(pos.x + 5.0f, pos.y)); + ImGui::SetNextItemWidth(width - 5.0f); + changed = ImGui::DragFloat(axisIds[axis], &value[axis], speed, 0.0f, 0.0f, "%.3f") || changed; + if (axis < 2) + { + ImGui::SameLine(0.0f, spacing); + } + } + + ImGui::PopID(); + return changed; + } + } // namespace + void DrawPropertiesPanel(EditorContext& ctx, EditorUiState& ui) { ImGui::Begin("Properties", nullptr); { + static ImGuiTextFilter propertyFilter; + propertyFilter.Draw(ICON_FA_MAGNIFYING_GLASS " Search properties##PropertiesSearch", -FLT_MIN); + Runtime::UiTheme::DrawThinSeparator(); + std::vector selectedIds = ctx.scene.GetSelectedIds(); if (selectedIds.empty() && ui.selected_obj_id != InvalidId) { @@ -174,16 +221,37 @@ namespace Editor return; } - if (ui.fontIcon) + const std::string inspectorSubtitle = "Instance " + std::to_string(selectedObj->GetInstanceId()); + Runtime::UiTheme::DrawPanelHeader(ICON_FA_SLIDERS, "Inspector", inspectorSubtitle.c_str()); + + auto render = selectedObj->GetComponent(); + auto physics = selectedObj->GetComponent(); + bool enabled = render == nullptr || render->GetVisible(); + if (ImGui::Checkbox("##ObjectEnabled", &enabled) && render != nullptr) { - ImGui::PushFont(ui.fontIcon); + render->SetVisible(enabled); + ctx.scene.MarkDirty(); } + ImGui::SameLine(); ImGui::TextUnformatted(selectedObj->GetName().c_str()); - if (ui.fontIcon) + ImGui::SameLine(std::max(ImGui::GetCursorPosX(), ImGui::GetContentRegionMax().x - 70.0f)); + bool isStatic = physics == nullptr || physics->GetMobility() == Runtime::ENodeMobility::Static; + if (ImGui::Checkbox("Static", &isStatic) && physics != nullptr) { - ImGui::PopFont(); + physics->SetMobility(isStatic ? Runtime::ENodeMobility::Static : Runtime::ENodeMobility::Dynamic); + ctx.scene.MarkDirty(); } + static int tagIndex = 0; + static int layerIndex = 0; + ImGui::SetNextItemWidth((ImGui::GetContentRegionAvail().x - 8.0f) * 0.5f); + ImGui::Combo("##TagSelector", &tagIndex, "Untagged\0Player\0Environment\0Interactable\0\0"); + Runtime::UiTheme::DrawTooltip("Tag"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::Combo("##LayerSelector", &layerIndex, "Default\0Gameplay\0Props\0Colliders\0Lighting\0\0"); + Runtime::UiTheme::DrawTooltip("Layer"); + ImGui::TextUnformatted("Name"); ImGui::SameLine(); ImGui::SetNextItemWidth(-FLT_MIN); @@ -211,113 +279,116 @@ namespace Editor } } - ImGui::Separator(); - ImGui::NewLine(); - - ImGui::Text(ICON_FA_LOCATION_ARROW " Transform"); - ImGui::Separator(); + Runtime::UiTheme::DrawThinSeparator(); - ImGui::BeginGroup(); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); - ImGui::Button("L"); - ImGui::SameLine(); - ImGui::PopStyleColor(); - if (ImGui::DragFloat3("##Location", &selectedObj->Translation().x, 0.1f)) + if (Runtime::UiTheme::BeginSection(ICON_FA_LOCATION_ARROW, "Transform", true)) { - selectedObj->RecalcTransform(true); - ctx.scene.MarkDirty(); - } - ImGui::EndGroup(); + if (DrawAxisFloat3("Location", selectedObj->Translation(), 0.1f)) + { + selectedObj->RecalcTransform(true); + ctx.scene.MarkDirty(); + } - ImGui::BeginGroup(); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.8f, 0.2f, 1.0f)); - ImGui::Button("R"); - ImGui::SameLine(); - ImGui::PopStyleColor(); - glm::vec3 eular = glm::eulerAngles(selectedObj->Rotation()); - if (ImGui::DragFloat3("##Rotation", &eular.x, 0.1f)) - { - selectedObj->SetRotation(glm::quat(eular)); - selectedObj->RecalcTransform(true); - ctx.scene.MarkDirty(); - } - ImGui::EndGroup(); + glm::vec3 eular = glm::eulerAngles(selectedObj->Rotation()); + if (DrawAxisFloat3("Rotation", eular, 0.1f)) + { + selectedObj->SetRotation(glm::quat(eular)); + selectedObj->RecalcTransform(true); + ctx.scene.MarkDirty(); + } - ImGui::BeginGroup(); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.8f, 1.0f)); - ImGui::Button("S"); - ImGui::SameLine(); - ImGui::PopStyleColor(); - if (ImGui::DragFloat3("##Scale", &selectedObj->Scale().x, 0.1f)) - { - selectedObj->RecalcTransform(true); - ctx.scene.MarkDirty(); + if (DrawAxisFloat3("Scale", selectedObj->Scale(), 0.1f)) + { + selectedObj->RecalcTransform(true); + ctx.scene.MarkDirty(); + } + Runtime::UiTheme::EndSection(); } - ImGui::EndGroup(); - - ImGui::NewLine(); - ImGui::Text(ICON_FA_CUBE " Mesh"); - ImGui::Separator(); - auto render = selectedObj->GetComponent(); int modelId = render ? render->GetModelId() : -1; - ImGui::InputInt("##ModelId", &modelId, 1, 1, ImGuiInputTextFlags_ReadOnly); - - ImGui::NewLine(); - ImGui::Text(ICON_FA_CIRCLE_HALF_STROKE " Material"); - ImGui::Separator(); - - if (modelId != -1 && render) + if (Runtime::UiTheme::BeginSection(ICON_FA_CUBE, "Mesh", true)) { - auto mats = render->GetMaterials(); - bool materialsChanged = false; - for (auto& mat : mats) + if (render != nullptr) { - const int matIdx = mat; - if (matIdx == 0) + const std::string preview = modelId >= 0 && modelId < static_cast(ctx.scene.Models().size()) + ? ctx.scene.Models()[modelId].Name() + : "None"; + if (ImGui::BeginCombo("Model", preview.c_str())) { - continue; + if (ImGui::Selectable("None", modelId == -1)) + { + render->SetModelId(static_cast(-1)); + ctx.scene.MarkDirty(); + } + for (int i = 0; i < static_cast(ctx.scene.Models().size()); ++i) + { + const std::string itemName = fmt::format("{}: {}", i, ctx.scene.Models()[i].Name()); + if (ImGui::Selectable(itemName.c_str(), modelId == i)) + { + render->SetModelId(static_cast(i)); + ctx.scene.MarkDirty(); + } + } + ImGui::EndCombo(); } + } + else + { + ImGui::TextDisabled("No RenderComponent"); + } + Runtime::UiTheme::EndSection(); + } - auto& refMat = ctx.scene.Materials()[matIdx]; - - ImGui::PushID(matIdx); - ImGui::InputText("##MatName", &refMat.name_, ImGuiInputTextFlags_ReadOnly); - ImGui::PopID(); - - ImGui::SameLine(); - - if (ImGui::Button(ICON_FA_CIRCLE_LEFT)) + if (Runtime::UiTheme::BeginSection(ICON_FA_CIRCLE_HALF_STROKE, "Material", true)) + { + if (modelId != -1 && render) + { + auto mats = render->GetMaterials(); + bool materialsChanged = false; + for (int elementIndex = 0; elementIndex < static_cast(mats.size()); ++elementIndex) { - if (ui.selectedMaterialId != InvalidId) + uint32_t& mat = mats[elementIndex]; + const std::string comboLabel = fmt::format("Element {}", elementIndex); + const std::string preview = mat < ctx.scene.Materials().size() + ? ctx.scene.Materials()[mat].name_ + : "None"; + ImGui::PushID(elementIndex); + if (ImGui::BeginCombo(comboLabel.c_str(), preview.c_str())) { - mat = static_cast(ui.selectedMaterialId); - materialsChanged = true; + for (int materialIndex = 0; materialIndex < static_cast(ctx.scene.Materials().size()); + ++materialIndex) + { + const std::string itemName = + fmt::format("{}: {}", materialIndex, ctx.scene.Materials()[materialIndex].name_); + if (ImGui::Selectable(itemName.c_str(), mat == static_cast(materialIndex))) + { + mat = static_cast(materialIndex); + materialsChanged = true; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + if (Runtime::UiTheme::IconButton(ICON_FA_PEN_TO_SQUARE, "Edit Material", false, + ImVec2(28.0f, 24.0f)) && + mat < ctx.scene.Materials().size()) + { + ui.selected_material = &(ctx.scene.Materials()[mat]); + ui.ed_material = true; + OpenMaterialEditor(ctx, ui); } + ImGui::PopID(); } - - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_PEN_TO_SQUARE)) + if (materialsChanged) { - ui.selected_material = &(ctx.scene.Materials()[matIdx]); - ui.ed_material = true; - OpenMaterialEditor(ctx, ui); + render->SetMaterials(mats); + ctx.scene.MarkDirty(); } } - if (materialsChanged) - { - render->SetMaterials(mats); - ctx.scene.MarkDirty(); - } + Runtime::UiTheme::EndSection(); } - // Draw component properties using reflection - ImGui::NewLine(); - ImGui::Text(ICON_FA_PUZZLE_PIECE " Components"); - ImGui::Separator(); - - static ImGuiTextFilter propertyFilter; - propertyFilter.Draw(ICON_FA_MAGNIFYING_GLASS " Filter##Properties", 220.0f); - + if (Runtime::UiTheme::BeginSection(ICON_FA_PUZZLE_PIECE, "Components", true)) + { const auto& components = selectedObj->GetComponents(); for (const auto& component : components) { @@ -354,6 +425,20 @@ namespace Editor ImGui::Unindent(); } } + if (ImGui::Button(ICON_FA_PLUS " Add Component", ImVec2(-FLT_MIN, 0.0f))) + { + ImGui::OpenPopup("AddComponentPopup"); + } + if (ImGui::BeginPopup("AddComponentPopup")) + { + ImGui::MenuItem("Render Component", nullptr, false, false); + ImGui::MenuItem("Physics Component", nullptr, false, false); + ImGui::MenuItem("Skinned Mesh Component", nullptr, false, false); + ImGui::MenuItem("Script Component", nullptr, false, false); + ImGui::EndPopup(); + } + Runtime::UiTheme::EndSection(); + } } ImGui::End(); diff --git a/src/Editor/Panels/ViewportOverlay.cpp b/src/Editor/Panels/ViewportOverlay.cpp index af848e03..899d04d4 100644 --- a/src/Editor/Panels/ViewportOverlay.cpp +++ b/src/Editor/Panels/ViewportOverlay.cpp @@ -6,6 +6,7 @@ #include "Assets/Core/Scene.hpp" #include "Editor/EditorActionDispatcher.hpp" #include "Runtime/Components/RenderComponent.h" +#include "Runtime/Editor/ProfessionalUI.hpp" #include "Runtime/Editor/GizmoController.hpp" #include "Runtime/Engine.hpp" #include "Runtime/Utilities/NextEngineHelper.h" @@ -13,6 +14,7 @@ #include "ThirdParty/fontawesome/IconsFontAwesome6.h" #include "Utilities/ImGui.hpp" #include "Utilities/Math.hpp" +#include "Vulkan/SyncAndTiming.hpp" #include @@ -20,7 +22,7 @@ namespace Editor { namespace { - constexpr float kToolIconWidth = 32.0f; + constexpr float kToolIconWidth = 34.0f; } void DrawViewportOverlay(EditorContext& ctx, EditorUiState& ui) @@ -135,57 +137,103 @@ namespace Editor ImGui::End(); } - constexpr float padding = 5.0f; - constexpr float statPadX = 8.0f; - constexpr float statPadY = 6.0f; - const float statW = 240.0f; - const float statH = ImGui::GetFrameHeight() + statPadY * 2.0f; - - ImGui::SetNextWindowPos(pos + ImVec2(padding, padding)); - ImGui::SetNextWindowSize(ImVec2(statW, statH)); - ImGui::SetNextWindowViewport(viewport->ID); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(statPadX, statPadY)); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.5f)); + constexpr float padding = 8.0f; + constexpr float statPadX = 10.0f; + constexpr float statPadY = 8.0f; ImGuiWindowFlags windowFlags = 0 | ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings; - ImGui::Begin("ViewportStat", nullptr, windowFlags); - - const double smoothDelta = ctx.engine.GetSmoothDeltaSeconds(); - const double frameRate = smoothDelta > 0.0 ? (1.0 / smoothDelta) : 0.0; - const ImGuiIO& io = ImGui::GetIO(); + ImGui::SetNextWindowPos(pos + ImVec2(padding, padding)); + ImGui::SetNextWindowSize(ImVec2(std::min(720.0f, size.x - padding * 2.0f), 40.0f)); + ImGui::SetNextWindowViewport(viewport->ID); - auto DrawBoolDot = [](const char* label, bool value) + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 5.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Background, 0.78f)); + ImGui::Begin("ViewportToolbar", nullptr, windowFlags); + + static int projectionMode = 0; + static int displayMode = 0; + static int cameraIndex = 0; + static float angleSnap = 10.0f; + static float distanceSnap = 0.25f; + + ImGui::SetNextItemWidth(132.0f); + ImGui::Combo("##ViewportProjection", &projectionMode, "Perspective\0Orthographic\0\0"); + Runtime::UiTheme::DrawTooltip("Camera Projection"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(118.0f); + ImGui::Combo("##ViewportDisplayMode", &displayMode, "Lit\0Lighting\0Wireframe\0\0"); + Runtime::UiTheme::DrawTooltip("Display Mode"); + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_EYE " Show", "Show Flags", false, ImVec2(74.0f, 28.0f))) { - ImDrawList* drawList = ImGui::GetWindowDrawList(); - const float radius = 4.0f; - const float spacing = 6.0f; - const ImVec2 cursor = ImGui::GetCursorScreenPos(); - const float centerY = cursor.y + ImGui::GetFrameHeight() * 0.5f; - const ImU32 color = value ? IM_COL32(80, 220, 120, 255) : IM_COL32(220, 80, 80, 255); - drawList->AddCircleFilled(ImVec2(cursor.x + radius, centerY), radius, color); - ImGui::Dummy(ImVec2(radius * 2.0f + spacing, ImGui::GetFrameHeight())); - ImGui::SameLine(0.0f, 4.0f); - ImGui::TextUnformatted(label); - }; - - ImGui::AlignTextToFramePadding(); - ImGui::Text("FPS %.0f", frameRate); + ImGui::OpenPopup("ViewportShowFlags"); + } ImGui::SameLine(); - DrawBoolDot("Mouse", io.WantCaptureMouse); + ImGui::SetNextItemWidth(84.0f); + ImGui::DragFloat("##AngleSnap", &angleSnap, 1.0f, 1.0f, 90.0f, "%.0f deg"); + Runtime::UiTheme::DrawTooltip("Angle Snap"); ImGui::SameLine(); - DrawBoolDot("Key", io.WantCaptureKeyboard); + ImGui::SetNextItemWidth(84.0f); + ImGui::DragFloat("##DistanceSnap", &distanceSnap, 0.01f, 0.01f, 10.0f, "%.2f"); + Runtime::UiTheme::DrawTooltip("Distance Snap"); ImGui::SameLine(); - DrawBoolDot("Text", io.WantTextInput); + ImGui::SetNextItemWidth(106.0f); + ImGui::Combo("##ViewportCamera", &cameraIndex, "Camera 0\0Editor Cam\0\0"); + Runtime::UiTheme::DrawTooltip("Active Camera"); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12, 12)); + if (ImGui::BeginPopup("ViewportShowFlags")) + { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 10)); + auto& showFlags = ctx.engine.GetShowFlags(); + Utilities::UI::DrawShowFlagsCommon(showFlags); + + ImGui::PopStyleVar(); + ImGui::EndPopup(); + } + ImGui::PopStyleVar(); ImGui::End(); ImGui::PopStyleColor(); ImGui::PopStyleVar(2); + const double smoothDelta = ctx.engine.GetSmoothDeltaSeconds(); + const double frameRate = smoothDelta > 0.0 ? (1.0 / smoothDelta) : 0.0; + const float gpuMs = ctx.engine.GpuTimer() ? ctx.engine.GpuTimer()->GetGpuTime("[gpu time]") : 0.0f; + const auto& gpuDrivenStat = ctx.scene.GetGpuDrivenStat(); + const uint32_t drawCalls = gpuDrivenStat.ProcessedCount > gpuDrivenStat.CulledCount + ? gpuDrivenStat.ProcessedCount - gpuDrivenStat.CulledCount + : 0; + const uint32_t triangles = gpuDrivenStat.TriangleCount > gpuDrivenStat.CulledTriangleCount + ? gpuDrivenStat.TriangleCount - gpuDrivenStat.CulledTriangleCount + : 0; + + const float statW = 190.0f; + const float statH = ImGui::GetTextLineHeightWithSpacing() * 6.0f + statPadY * 2.0f; + ImGui::SetNextWindowPos(pos + ImVec2(padding, padding + 44.0f)); + ImGui::SetNextWindowSize(ImVec2(statW, statH)); + ImGui::SetNextWindowViewport(viewport->ID); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(statPadX, statPadY)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.56f)); + ImGui::Begin("ViewportStat", nullptr, windowFlags); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.96f, 1.0f, 1.0f)); + ImGui::Text("FPS %.0f (%.2f ms)", frameRate, smoothDelta * 1000.0); + ImGui::Text("GPU %.2f ms", gpuMs); + ImGui::Text("Frame %u", ctx.engine.GetTotalFrames()); + ImGui::Text("Draw Calls %s", Utilities::metricFormatter(static_cast(drawCalls), "").c_str()); + ImGui::Text("Triangles %s", Utilities::metricFormatter(static_cast(triangles), "").c_str()); + ImGui::Text("Res %.0fx%.0f", size.x, size.y); + ImGui::PopStyleColor(); + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); + // Gizmo status overlay (operation + space) if (ctx.gizmoController && ctx.gizmoController->IsShowing()) { @@ -207,7 +255,7 @@ namespace Editor kSpaceNames[mode == ImGuizmo::LOCAL ? 0 : 1]; ImVec2 gizmoSize(ImGui::CalcTextSize(gizmoText.c_str()).x + statPadX * 2.0f, statH); - const float gizmoY = pos.y + padding + statH + 4.0f; + const float gizmoY = pos.y + padding + 44.0f + statH + 4.0f; ImGui::SetNextWindowPos(ImVec2(pos.x + padding, gizmoY)); ImGui::SetNextWindowSize(gizmoSize); @@ -215,7 +263,7 @@ namespace Editor ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(statPadX, statPadY)); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.5f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Background, 0.72f)); ImGui::Begin("GizmoStatus", nullptr, windowFlags); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(ImGuiCol_TextDisabled)); @@ -226,6 +274,22 @@ namespace Editor ImGui::PopStyleVar(2); } + const ImVec2 axisOrigin = pos + ImVec2(26.0f, size.y - 42.0f); + ImDrawList* foreground = ImGui::GetForegroundDrawList(viewport); + foreground->AddCircleFilled(axisOrigin, 4.0f, Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::TextMuted)); + foreground->AddLine(axisOrigin, axisOrigin + ImVec2(28.0f, 0.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Danger), 2.0f); + foreground->AddLine(axisOrigin, axisOrigin + ImVec2(0.0f, -28.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Success), 2.0f); + foreground->AddLine(axisOrigin, axisOrigin + ImVec2(-18.0f, 18.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Blue), 2.0f); + foreground->AddText(axisOrigin + ImVec2(32.0f, -7.0f), Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Danger), + "X"); + foreground->AddText(axisOrigin + ImVec2(-4.0f, -44.0f), Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Success), + "Y"); + foreground->AddText(axisOrigin + ImVec2(-34.0f, 18.0f), Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Blue), + "Z"); + const float toolH = kToolIconWidth; float toolW = kToolIconWidth + 16.0f; toolW = std::max(60.0f, std::min(toolW, size.x - padding * 2.0f)); @@ -240,31 +304,14 @@ namespace Editor ImGui::Begin("ViewportTool", nullptr, windowFlags); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.0f, 0.0f, 0.5f)); ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 1.0f); const float startX = ImGui::GetCursorPosX(); const float availW = ImGui::GetContentRegionAvail().x; ImGui::SetCursorPosX(startX + std::max(0.0f, availW - kToolIconWidth)); - if (ImGui::Button(ICON_FA_EYE, ImVec2(kToolIconWidth, kToolIconWidth))) - { - ImGui::OpenPopup("ViewportShowFlags"); - } - BUTTON_TOOLTIP("Show Flags") + Runtime::UiTheme::IconButton(ICON_FA_CAMERA, "Camera Options (placeholder)", false, + ImVec2(kToolIconWidth, kToolIconWidth)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12, 12)); - if (ImGui::BeginPopup("ViewportShowFlags")) - { - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 10)); - auto& showFlags = ctx.engine.GetShowFlags(); - Utilities::UI::DrawShowFlagsCommon(showFlags); - - ImGui::PopStyleVar(); - ImGui::EndPopup(); - } - ImGui::PopStyleVar(); - - ImGui::PopStyleColor(); ImGui::PopStyleVar(); ImGui::PopStyleVar(); ImGui::PopStyleVar(); diff --git a/src/Runtime/Components/PhysicsComponent.cpp b/src/Runtime/Components/PhysicsComponent.cpp index 64d0026d..b12c4007 100644 --- a/src/Runtime/Components/PhysicsComponent.cpp +++ b/src/Runtime/Components/PhysicsComponent.cpp @@ -24,7 +24,19 @@ namespace Runtime .custom(PropertyPresets::Editable("Mobility", "Physics", "Physics body mobility type")) // PhysicsOffset property - editable vec3 .data<&PhysicsComponent::SetPhysicsOffset, &PhysicsComponent::GetPhysicsOffset>("PhysicsOffset") - .custom(PropertyPresets::Editable("Physics Offset", "Physics", "Offset from node origin for physics body")); + .custom(PropertyPresets::Editable("Physics Offset", "Physics", "Offset from node origin for physics body")) + .data<&PhysicsComponent::SetSimulatePhysics, &PhysicsComponent::GetSimulatePhysics>("SimulatePhysics") + .custom(PropertyPresets::Editable("Simulate Physics", "Physics", "Enable dynamic rigid-body simulation")) + .data<&PhysicsComponent::SetPhysicsMaterial, &PhysicsComponent::GetPhysicsMaterial>("PhysicsMaterial") + .custom(PropertyPresets::Editable("Physics Material", "Physics", "Physics material preset")) + .data<&PhysicsComponent::SetLinearDamping, &PhysicsComponent::GetLinearDamping>("LinearDamping") + .custom(PropertyPresets::Editable("Linear Damping", "Physics", "Linear velocity damping")) + .data<&PhysicsComponent::SetAngularDamping, &PhysicsComponent::GetAngularDamping>("AngularDamping") + .custom(PropertyPresets::Editable("Angular Damping", "Physics", "Angular velocity damping")) + .data<&PhysicsComponent::SetEnableGravity, &PhysicsComponent::GetEnableGravity>("EnableGravity") + .custom(PropertyPresets::Editable("Enable Gravity", "Physics", "Apply gravity to this body")) + .data<&PhysicsComponent::SetCollisionPresets, &PhysicsComponent::GetCollisionPresets>("CollisionPresets") + .custom(PropertyPresets::Editable("Collision Presets", "Physics", "Collision response preset")); // Note: PhysicsBody ID is internal and not exposed to editor } diff --git a/src/Runtime/Components/PhysicsComponent.h b/src/Runtime/Components/PhysicsComponent.h index c19b13aa..63e475b5 100644 --- a/src/Runtime/Components/PhysicsComponent.h +++ b/src/Runtime/Components/PhysicsComponent.h @@ -3,6 +3,7 @@ #include "Runtime/Reflection/ReflectionMacros.h" #include "Runtime/Subsystems/NextPhysics.h" #include +#include namespace Runtime { @@ -29,9 +30,33 @@ namespace Runtime void SetPhysicsOffset(const glm::vec3& offset) { physicsOffset_ = offset; } glm::vec3 GetPhysicsOffset() const { return physicsOffset_; } + void SetSimulatePhysics(bool simulatePhysics) { simulatePhysics_ = simulatePhysics; } + bool GetSimulatePhysics() const { return simulatePhysics_; } + + void SetPhysicsMaterial(const std::string& physicsMaterial) { physicsMaterial_ = physicsMaterial; } + const std::string& GetPhysicsMaterial() const { return physicsMaterial_; } + + void SetLinearDamping(float linearDamping) { linearDamping_ = linearDamping; } + float GetLinearDamping() const { return linearDamping_; } + + void SetAngularDamping(float angularDamping) { angularDamping_ = angularDamping; } + float GetAngularDamping() const { return angularDamping_; } + + void SetEnableGravity(bool enableGravity) { enableGravity_ = enableGravity; } + bool GetEnableGravity() const { return enableGravity_; } + + void SetCollisionPresets(const std::string& collisionPresets) { collisionPresets_ = collisionPresets; } + const std::string& GetCollisionPresets() const { return collisionPresets_; } + private: NextBodyID physicsBodyTemp_; ENodeMobility mobility_ = ENodeMobility::Static; glm::vec3 physicsOffset_ = glm::vec3(0.0f); + bool simulatePhysics_ = false; + std::string physicsMaterial_ = "Default"; + float linearDamping_ = 0.0f; + float angularDamping_ = 0.05f; + bool enableGravity_ = true; + std::string collisionPresets_ = "BlockAll"; }; } diff --git a/src/Runtime/Components/RenderComponent.cpp b/src/Runtime/Components/RenderComponent.cpp index 691419ac..6ac4a2b8 100644 --- a/src/Runtime/Components/RenderComponent.cpp +++ b/src/Runtime/Components/RenderComponent.cpp @@ -17,6 +17,16 @@ namespace Runtime // RayCastVisible property - editable .data<&RenderComponent::SetRayCastVisible, &RenderComponent::GetRayCastVisible>("RayCastVisible") .custom(PropertyPresets::Editable("Raycast Visible", "Rendering", "Whether the object is visible to raycasts")) + .data<&RenderComponent::SetRayCastVisible, &RenderComponent::GetRayCastVisible>("RaycastVisible") + .custom(PropertyPresets::Editable("Raycast Visible", "Rendering", "Whether the object is visible to raycasts")) + .data<&RenderComponent::SetCastShadows, &RenderComponent::GetCastShadows>("CastShadows") + .custom(PropertyPresets::Editable("Cast Shadows", "Rendering", "Whether the object casts shadows")) + .data<&RenderComponent::SetReceiveGI, &RenderComponent::GetReceiveGI>("ReceiveGI") + .custom(PropertyPresets::Editable("Receive GI", "Rendering", "Whether the object receives global illumination")) + .data<&RenderComponent::SetLightmapUV, &RenderComponent::GetLightmapUV>("LightmapUV") + .custom(PropertyPresets::Editable("Lightmap UV", "Rendering", "Use the secondary UV set for baked lighting")) + .data<&RenderComponent::SetLayerMask, &RenderComponent::GetLayerMask>("LayerMask") + .custom(PropertyPresets::Editable("Layer Mask", "Rendering", "Render visibility layer mask")) // ModelId property - read-only (set through scene loading) .data("ModelId") .custom(PropertyPresets::ReadOnly("Model ID", "Mesh", "The model resource ID")) diff --git a/src/Runtime/Components/RenderComponent.h b/src/Runtime/Components/RenderComponent.h index 0df1ec2c..3fe9de55 100644 --- a/src/Runtime/Components/RenderComponent.h +++ b/src/Runtime/Components/RenderComponent.h @@ -34,6 +34,18 @@ namespace Runtime void SetRayCastVisible(bool visible) { rayCastVisible_ = visible; } bool GetRayCastVisible() const { return rayCastVisible_; } + void SetCastShadows(bool castShadows) { castShadows_ = castShadows; } + bool GetCastShadows() const { return castShadows_; } + + void SetReceiveGI(bool receiveGI) { receiveGI_ = receiveGI; } + bool GetReceiveGI() const { return receiveGI_; } + + void SetLightmapUV(bool lightmapUV) { lightmapUV_ = lightmapUV; } + bool GetLightmapUV() const { return lightmapUV_; } + + void SetLayerMask(uint32_t layerMask) { layerMask_ = layerMask; } + uint32_t GetLayerMask() const { return layerMask_; } + void SetOutlineFlags(uint32_t outlineFlags) { outlineFlags_ = outlineFlags; } uint32_t GetOutlineFlags() const { return outlineFlags_; } void ClearOutlineFlags() { outlineFlags_ = RenderOutlineFlags::none; } @@ -60,6 +72,10 @@ namespace Runtime std::array materialIdx_ = {0}; // Initialize with defaults bool visible_ = true; bool rayCastVisible_ = true; + bool castShadows_ = true; + bool receiveGI_ = true; + bool lightmapUV_ = false; + uint32_t layerMask_ = 0xFFFFFFFFu; int32_t skinIndex_ = -1; uint32_t outlineFlags_ = RenderOutlineFlags::none; }; diff --git a/src/Runtime/Editor/ProfessionalUI.cpp b/src/Runtime/Editor/ProfessionalUI.cpp new file mode 100644 index 00000000..9080b337 --- /dev/null +++ b/src/Runtime/Editor/ProfessionalUI.cpp @@ -0,0 +1,344 @@ +#include "Common/CoreMinimal.hpp" + +#include "Runtime/Editor/ProfessionalUI.hpp" + +#include +#include + +namespace Runtime::UiTheme +{ + namespace + { + ImVec4 WithAlpha(ImVec4 color, float alpha) + { + color.w *= alpha; + return color; + } + } // namespace + + ImVec4 Color(EColor color, float alpha) + { + ImVec4 result; + switch (color) + { + case EColor::Text: + result = ImVec4(0.88f, 0.90f, 0.93f, 1.0f); + break; + case EColor::TextMuted: + result = ImVec4(0.63f, 0.67f, 0.73f, 1.0f); + break; + case EColor::TextDim: + result = ImVec4(0.43f, 0.47f, 0.53f, 1.0f); + break; + case EColor::Background: + result = ImVec4(0.055f, 0.058f, 0.064f, 1.0f); + break; + case EColor::Surface: + result = ImVec4(0.105f, 0.112f, 0.122f, 1.0f); + break; + case EColor::SurfaceElevated: + result = ImVec4(0.145f, 0.153f, 0.166f, 1.0f); + break; + case EColor::SurfaceHover: + result = ImVec4(0.185f, 0.198f, 0.218f, 1.0f); + break; + case EColor::Border: + result = ImVec4(0.22f, 0.235f, 0.255f, 1.0f); + break; + case EColor::BorderStrong: + result = ImVec4(0.30f, 0.325f, 0.36f, 1.0f); + break; + case EColor::Accent: + result = ImVec4(0.18f, 0.43f, 0.78f, 1.0f); + break; + case EColor::AccentHover: + result = ImVec4(0.26f, 0.53f, 0.90f, 1.0f); + break; + case EColor::Brand: + result = ImVec4(0.95f, 0.58f, 0.14f, 1.0f); + break; + case EColor::Success: + result = ImVec4(0.22f, 0.78f, 0.38f, 1.0f); + break; + case EColor::Warning: + result = ImVec4(0.95f, 0.70f, 0.24f, 1.0f); + break; + case EColor::Danger: + result = ImVec4(0.92f, 0.25f, 0.28f, 1.0f); + break; + case EColor::Blue: + default: + result = ImVec4(0.38f, 0.62f, 0.94f, 1.0f); + break; + } + return WithAlpha(result, alpha); + } + + ImU32 ColorU32(EColor color, float alpha) + { + return ImGui::GetColorU32(Color(color, alpha)); + } + + void ApplyProfessionalTheme() + { + ImGuiStyle& style = ImGui::GetStyle(); + ImGui::StyleColorsDark(&style); + + ImVec4* colors = style.Colors; + colors[ImGuiCol_Text] = Color(EColor::Text); + colors[ImGuiCol_TextDisabled] = Color(EColor::TextDim); + colors[ImGuiCol_WindowBg] = Color(EColor::Surface); + colors[ImGuiCol_ChildBg] = Color(EColor::Background, 0.72f); + colors[ImGuiCol_PopupBg] = Color(EColor::SurfaceElevated, 0.98f); + colors[ImGuiCol_Border] = Color(EColor::Border); + colors[ImGuiCol_BorderShadow] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f); + colors[ImGuiCol_FrameBg] = Color(EColor::Background); + colors[ImGuiCol_FrameBgHovered] = Color(EColor::SurfaceHover); + colors[ImGuiCol_FrameBgActive] = Color(EColor::SurfaceHover); + colors[ImGuiCol_TitleBg] = Color(EColor::Background); + colors[ImGuiCol_TitleBgActive] = Color(EColor::Background); + colors[ImGuiCol_TitleBgCollapsed] = Color(EColor::Background); + colors[ImGuiCol_MenuBarBg] = Color(EColor::Background); + colors[ImGuiCol_ScrollbarBg] = Color(EColor::Background, 0.40f); + colors[ImGuiCol_ScrollbarGrab] = Color(EColor::BorderStrong); + colors[ImGuiCol_ScrollbarGrabHovered] = Color(EColor::TextDim); + colors[ImGuiCol_ScrollbarGrabActive] = Color(EColor::TextMuted); + colors[ImGuiCol_CheckMark] = Color(EColor::AccentHover); + colors[ImGuiCol_SliderGrab] = Color(EColor::Blue); + colors[ImGuiCol_SliderGrabActive] = Color(EColor::AccentHover); + colors[ImGuiCol_Button] = Color(EColor::SurfaceElevated); + colors[ImGuiCol_ButtonHovered] = Color(EColor::SurfaceHover); + colors[ImGuiCol_ButtonActive] = Color(EColor::Accent); + colors[ImGuiCol_Header] = Color(EColor::SurfaceElevated); + colors[ImGuiCol_HeaderHovered] = Color(EColor::SurfaceHover); + colors[ImGuiCol_HeaderActive] = Color(EColor::Accent, 0.85f); + colors[ImGuiCol_Separator] = Color(EColor::Border); + colors[ImGuiCol_SeparatorHovered] = Color(EColor::AccentHover); + colors[ImGuiCol_SeparatorActive] = Color(EColor::AccentHover); + colors[ImGuiCol_ResizeGrip] = Color(EColor::BorderStrong, 0.55f); + colors[ImGuiCol_ResizeGripHovered] = Color(EColor::AccentHover); + colors[ImGuiCol_ResizeGripActive] = Color(EColor::AccentHover); + colors[ImGuiCol_Tab] = Color(EColor::Surface); + colors[ImGuiCol_TabHovered] = Color(EColor::SurfaceHover); + colors[ImGuiCol_TabActive] = Color(EColor::SurfaceElevated); + colors[ImGuiCol_TabUnfocused] = Color(EColor::Surface, 0.82f); + colors[ImGuiCol_TabUnfocusedActive] = Color(EColor::SurfaceElevated, 0.88f); + colors[ImGuiCol_DockingPreview] = Color(EColor::Accent, 0.55f); + colors[ImGuiCol_DockingEmptyBg] = Color(EColor::Background); + colors[ImGuiCol_PlotHistogram] = Color(EColor::Success); + colors[ImGuiCol_PlotHistogramHovered] = Color(EColor::AccentHover); + colors[ImGuiCol_TextSelectedBg] = Color(EColor::Accent, 0.45f); + colors[ImGuiCol_NavHighlight] = Color(EColor::AccentHover); + colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.62f); + + style.WindowPadding = ImVec2(10.0f, 8.0f); + style.FramePadding = ImVec2(8.0f, 5.0f); + style.CellPadding = ImVec2(8.0f, 5.0f); + style.ItemSpacing = ImVec2(7.0f, 6.0f); + style.ItemInnerSpacing = ImVec2(6.0f, 4.0f); + style.TouchExtraPadding = ImVec2(0.0f, 0.0f); + style.IndentSpacing = 14.0f; + style.ScrollbarSize = 12.0f; + style.GrabMinSize = 12.0f; + style.WindowBorderSize = 1.0f; + style.ChildBorderSize = 1.0f; + style.PopupBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.TabBorderSize = 0.0f; + style.WindowRounding = 6.0f; + style.ChildRounding = 5.0f; + style.FrameRounding = 4.0f; + style.PopupRounding = 6.0f; + style.ScrollbarRounding = 7.0f; + style.GrabRounding = 6.0f; + style.TabRounding = 4.0f; + style.SeparatorTextBorderSize = 1.0f; + } + + void DrawBrandMark(ImDrawList* drawList, ImVec2 min, float size) + { + if (drawList == nullptr) + { + return; + } + + const ImVec2 max(min.x + size, min.y + size); + const float rounding = std::max(3.0f, size * 0.18f); + drawList->AddRectFilled(min, max, ColorU32(EColor::Brand, 0.16f), rounding); + drawList->AddRect(min, max, ColorU32(EColor::Brand, 0.92f), rounding, 0, 1.5f); + + const float pad = size * 0.24f; + const float stroke = std::max(2.0f, size * 0.10f); + const ImU32 lineColor = ColorU32(EColor::Brand); + drawList->AddLine(ImVec2(min.x + pad, min.y + size - pad), ImVec2(min.x + pad, min.y + pad), lineColor, stroke); + drawList->AddLine(ImVec2(min.x + pad, min.y + pad), ImVec2(min.x + size * 0.52f, min.y + pad), lineColor, stroke); + drawList->AddLine(ImVec2(min.x + size * 0.52f, min.y + pad), ImVec2(min.x + size * 0.52f, min.y + size - pad), lineColor, stroke); + drawList->AddLine(ImVec2(min.x + size * 0.52f, min.y + size - pad), ImVec2(min.x + size - pad, min.y + size - pad), lineColor, stroke); + drawList->AddLine(ImVec2(min.x + size - pad, min.y + size - pad), ImVec2(min.x + size - pad, min.y + pad), lineColor, stroke); + } + + void DrawTooltip(const char* text) + { + if (!ImGui::IsItemHovered() || text == nullptr || text[0] == '\0') + { + return; + } + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f)); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(text); + ImGui::EndTooltip(); + ImGui::PopStyleVar(); + } + + bool IconButton(const char* label, const char* tooltip, bool active, ImVec2 size) + { + if (active) + { + ImGui::PushStyleColor(ImGuiCol_Button, Color(EColor::Accent, 0.82f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, Color(EColor::AccentHover)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, Color(EColor::AccentHover)); + } + else + { + ImGui::PushStyleColor(ImGuiCol_Button, Color(EColor::SurfaceElevated, 0.86f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, Color(EColor::SurfaceHover)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, Color(EColor::Accent, 0.72f)); + } + + const bool pressed = ImGui::Button(label, size); + ImGui::PopStyleColor(3); + DrawTooltip(tooltip); + return pressed; + } + + bool ToolbarButton(const char* label, const char* tooltip, bool active, ImVec2 size) + { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + const bool pressed = IconButton(label, tooltip, active, size); + ImGui::PopStyleVar(); + return pressed; + } + + bool BeginSection(const char* icon, const char* label, bool defaultOpen) + { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(8.0f, 5.0f)); + ImGui::PushStyleColor(ImGuiCol_Header, Color(EColor::Background, 0.92f)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, Color(EColor::SurfaceHover)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, Color(EColor::SurfaceHover)); + + const std::string header = fmt::format("{} {}", icon ? icon : "", label ? label : ""); + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_Framed; + if (defaultOpen) + { + flags |= ImGuiTreeNodeFlags_DefaultOpen; + } + const bool open = ImGui::CollapsingHeader(header.c_str(), flags); + + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); + + if (open) + { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(7.0f, 6.0f)); + ImGui::Indent(4.0f); + } + return open; + } + + void EndSection() + { + ImGui::Unindent(4.0f); + ImGui::PopStyleVar(); + ImGui::Spacing(); + } + + void DrawPanelHeader(const char* icon, const char* title, const char* subtitle) + { + ImGui::PushStyleColor(ImGuiCol_Text, Color(EColor::Text)); + ImGui::Text("%s %s", icon ? icon : "", title ? title : ""); + ImGui::PopStyleColor(); + + if (subtitle != nullptr && subtitle[0] != '\0') + { + ImGui::PushStyleColor(ImGuiCol_Text, Color(EColor::TextMuted)); + ImGui::TextUnformatted(subtitle); + ImGui::PopStyleColor(); + } + + DrawThinSeparator(0.9f); + } + + void DrawLabelValue(const char* label, const char* value, ImVec4 valueColor) + { + ImGui::AlignTextToFramePadding(); + ImGui::PushStyleColor(ImGuiCol_Text, Color(EColor::TextMuted)); + ImGui::TextUnformatted(label); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, valueColor); + ImGui::TextUnformatted(value); + ImGui::PopStyleColor(); + } + + void DrawStatusDot(const char* label, bool active) + { + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float radius = 3.5f; + const ImU32 dotColor = active ? ColorU32(EColor::Success) : ColorU32(EColor::Danger); + drawList->AddCircleFilled(ImVec2(pos.x + radius, pos.y + ImGui::GetTextLineHeight() * 0.5f), radius, dotColor); + ImGui::Dummy(ImVec2(radius * 2.0f + 4.0f, ImGui::GetTextLineHeight())); + ImGui::SameLine(0.0f, 3.0f); + ImGui::TextUnformatted(label); + } + + void DrawBadge(const char* label, ImVec4 background, ImVec4 foreground) + { + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImVec2 textSize = ImGui::CalcTextSize(label); + const ImVec2 padding(8.0f, 3.0f); + const ImVec2 size(textSize.x + padding.x * 2.0f, textSize.y + padding.y * 2.0f); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddRectFilled(pos, pos + size, ImGui::GetColorU32(background), 4.0f); + drawList->AddRect(pos, pos + size, ColorU32(EColor::Border, 0.75f), 4.0f); + drawList->AddText(pos + padding, ImGui::GetColorU32(foreground), label); + ImGui::Dummy(size); + } + + void DrawMetricCard(const char* label, const char* value, ImVec4 valueColor, float width) + { + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float height = ImGui::GetTextLineHeight() * 2.35f; + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddRectFilled(pos, pos + ImVec2(width, height), ColorU32(EColor::Background, 0.72f), 5.0f); + drawList->AddRect(pos, pos + ImVec2(width, height), ColorU32(EColor::Border, 0.9f), 5.0f); + drawList->AddText(pos + ImVec2(8.0f, 5.0f), ColorU32(EColor::TextMuted), label); + drawList->AddText(pos + ImVec2(8.0f, 5.0f + ImGui::GetTextLineHeight()), ImGui::GetColorU32(valueColor), value); + ImGui::Dummy(ImVec2(width, height)); + } + + void DrawThinSeparator(float alpha) + { + const ImVec2 pos = ImGui::GetCursorScreenPos(); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddLine(pos, ImVec2(pos.x + ImGui::GetContentRegionAvail().x, pos.y), ColorU32(EColor::Border, alpha), 1.0f); + ImGui::Dummy(ImVec2(0.0f, 6.0f)); + } + + void DrawProgressBar(float fraction, ImVec4 color, ImVec2 size) + { + fraction = std::clamp(fraction, 0.0f, 1.0f); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddRectFilled(pos, pos + size, ColorU32(EColor::Background, 0.86f), 4.0f); + drawList->AddRect(pos, pos + size, ColorU32(EColor::BorderStrong, 0.88f), 4.0f); + if (fraction > 0.0f) + { + const float fillWidth = std::max(2.0f, size.x * fraction); + drawList->AddRectFilled(pos + ImVec2(1.0f, 1.0f), pos + ImVec2(fillWidth - 1.0f, size.y - 1.0f), + ImGui::GetColorU32(color), 3.0f); + } + ImGui::Dummy(size); + } +} // namespace Runtime::UiTheme diff --git a/src/Runtime/Editor/ProfessionalUI.hpp b/src/Runtime/Editor/ProfessionalUI.hpp new file mode 100644 index 00000000..235abae3 --- /dev/null +++ b/src/Runtime/Editor/ProfessionalUI.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "Common/CoreMinimal.hpp" + +#include + +namespace Runtime::UiTheme +{ + enum class EColor + { + Text, + TextMuted, + TextDim, + Background, + Surface, + SurfaceElevated, + SurfaceHover, + Border, + BorderStrong, + Accent, + AccentHover, + Brand, + Success, + Warning, + Danger, + Blue, + }; + + ImVec4 Color(EColor color, float alpha = 1.0f); + ImU32 ColorU32(EColor color, float alpha = 1.0f); + + void ApplyProfessionalTheme(); + void DrawBrandMark(ImDrawList* drawList, ImVec2 min, float size); + void DrawTooltip(const char* text); + bool IconButton(const char* label, const char* tooltip, bool active = false, ImVec2 size = ImVec2(30.0f, 30.0f)); + bool ToolbarButton(const char* label, const char* tooltip, bool active = false, ImVec2 size = ImVec2(34.0f, 30.0f)); + bool BeginSection(const char* icon, const char* label, bool defaultOpen = true); + void EndSection(); + void DrawPanelHeader(const char* icon, const char* title, const char* subtitle = nullptr); + void DrawLabelValue(const char* label, const char* value, ImVec4 valueColor = Color(EColor::Text)); + void DrawStatusDot(const char* label, bool active); + void DrawBadge(const char* label, ImVec4 background, ImVec4 foreground); + void DrawMetricCard(const char* label, const char* value, ImVec4 valueColor, float width); + void DrawThinSeparator(float alpha = 1.0f); + void DrawProgressBar(float fraction, ImVec4 color, ImVec2 size); +} // namespace Runtime::UiTheme diff --git a/src/Runtime/Editor/UserInterface.cpp b/src/Runtime/Editor/UserInterface.cpp index 6abbfae5..657cd8b4 100644 --- a/src/Runtime/Editor/UserInterface.cpp +++ b/src/Runtime/Editor/UserInterface.cpp @@ -6,6 +6,7 @@ #include "Runtime/Config/CVarSystem.hpp" #include "Runtime/Editor/ConsoleLogBuffer.hpp" #include "Runtime/Editor/FontLoader.h" +#include "Runtime/Editor/ProfessionalUI.hpp" #include "Utilities/Exception.hpp" #include "Vulkan/DescriptorSystem.hpp" #include "Vulkan/Device.hpp" @@ -302,63 +303,9 @@ UserInterface::FUiTextureHandle UserInterface::RequestUiTexture(const std::strin void UserInterface::SetStyle() { - ImGuiIO& io = ImGui::GetIO(); - // NOTE: Do not override io.IniFilename here. // The app/editor is responsible for choosing its ini file in the PreConfig hook. - - ImVec4 activeColor = true ? ImVec4(0.42f, 0.45f, 0.5f, 1.00f) : ImVec4(0.28f, 0.45f, 0.70f, 1.00f); - - ImGuiStyle* style = &ImGui::GetStyle(); - ImVec4* colors = style->Colors; - ImGui::StyleColorsDark(style); - colors[ImGuiCol_Text] = ImVec4(0.84f, 0.84f, 0.84f, 1.00f); - colors[ImGuiCol_WindowBg] = ImVec4(0.22f, 0.22f, 0.22f, 1.00f); - colors[ImGuiCol_ChildBg] = ImVec4(0.19f, 0.19f, 0.19f, 1.00f); - colors[ImGuiCol_PopupBg] = ImVec4(0.19f, 0.19f, 0.19f, 1.00f); - colors[ImGuiCol_Border] = ImVec4(0.17f, 0.17f, 0.17f, 1.00f); - colors[ImGuiCol_BorderShadow] = ImVec4(0.10f, 0.10f, 0.10f, 0.00f); - colors[ImGuiCol_FrameBg] = ImVec4(0.16f, 0.16f, 0.16f, 1.00f); - colors[ImGuiCol_FrameBgHovered] = ImVec4(0.16f, 0.16f, 0.16f, 1.00f); - colors[ImGuiCol_FrameBgActive] = ImVec4(0.16f, 0.16f, 0.16f, 1.00f); - colors[ImGuiCol_TitleBg] = ImVec4(0.11f, 0.11f, 0.11f, 1.00f); - colors[ImGuiCol_TitleBgActive] = ImVec4(0.0f, 0.0f, 0.0f, 1.00f); // TrueBlack - colors[ImGuiCol_MenuBarBg] = ImVec4(0.11f, 0.11f, 0.11f, 1.00f); - colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.33f, 0.33f, 0.33f, 1.00f); - colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.33f, 0.33f, 0.33f, 1.00f); - colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.35f, 0.35f, 0.35f, 1.00f); - colors[ImGuiCol_CheckMark] = activeColor; - colors[ImGuiCol_SliderGrab] = activeColor; - colors[ImGuiCol_SliderGrabActive] = activeColor; - colors[ImGuiCol_Button] = ImVec4(0.33f, 0.33f, 0.33f, 1.00f); - colors[ImGuiCol_ButtonHovered] = ImVec4(0.40f, 0.40f, 0.40f, 1.00f); - colors[ImGuiCol_ButtonActive] = activeColor; - colors[ImGuiCol_Header] = ImVec4(0.15f, 0.15f, 0.15f, 1.00f); - colors[ImGuiCol_HeaderHovered] = activeColor; - colors[ImGuiCol_HeaderActive] = ImVec4(0.15f, 0.15f, 0.15f, 1.00f); - colors[ImGuiCol_Separator] = ImVec4(0.18f, 0.18f, 0.18f, 1.00f); - colors[ImGuiCol_SeparatorHovered] = activeColor; - colors[ImGuiCol_SeparatorActive] = activeColor; - colors[ImGuiCol_ResizeGrip] = ImVec4(0.54f, 0.54f, 0.54f, 1.00f); - colors[ImGuiCol_ResizeGripHovered] = activeColor; - colors[ImGuiCol_ResizeGripActive] = ImVec4(0.19f, 0.39f, 0.69f, 1.00f); - colors[ImGuiCol_Tab] = ImVec4(0.11f, 0.11f, 0.11f, 1.00f); - colors[ImGuiCol_TabHovered] = ImVec4(0.14f, 0.14f, 0.14f, 1.00f); - colors[ImGuiCol_TabActive] = ImVec4(0.19f, 0.19f, 0.19f, 1.00f); - colors[ImGuiCol_PlotHistogram] = activeColor; - colors[ImGuiCol_PlotHistogramHovered] = ImVec4(0.20f, 0.39f, 0.69f, 1.00f); - colors[ImGuiCol_TextSelectedBg] = activeColor; - colors[ImGuiCol_NavHighlight] = activeColor; - colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.5f); - - style->WindowPadding = ImVec2(12.00f, 8.00f); - style->ItemSpacing = ImVec2(6.00f, 6.00f); - style->GrabMinSize = 20.00f; - style->WindowRounding = 8.00f; - style->FrameBorderSize = 0.00f; - style->FrameRounding = 4.00f; - style->GrabRounding = 12.00f; - style->SeparatorTextBorderSize = 1.0f; + Runtime::UiTheme::ApplyProfessionalTheme(); } void UserInterface::DrawPoint(float x, float y, float size, glm::vec4 color) @@ -654,48 +601,113 @@ void UserInterface::DrawConsoleLogOutputInternal(const char* childId, const ImVe const auto logSink = Runtime::Editor::GetConsoleLogSink(); const std::vector lines = logSink ? logSink->last_raw() : std::vector{}; const uint64_t revision = Runtime::Editor::GetConsoleLogSequence(); + static ImGuiTextFilter consoleFilter; + static bool showInfo = true; + static bool showWarn = true; + static bool showError = true; + static bool showDebug = true; + static size_t clearedLineOffset = 0; + + if (clearedLineOffset > lines.size()) + { + clearedLineOffset = 0; + } + + auto LevelInfo = [](spdlog::level::level_enum level) -> std::pair + { + switch (level) + { + case spdlog::level::trace: + case spdlog::level::debug: + return {"[Debug]", ImVec4(0.55f, 0.9f, 0.95f, 1.0f)}; + case spdlog::level::info: + return {"[Info]", ImVec4(0.76f, 0.86f, 1.0f, 1.0f)}; + case spdlog::level::warn: + return {"[Warn]", ImVec4(1.0f, 0.82f, 0.35f, 1.0f)}; + case spdlog::level::err: + case spdlog::level::critical: + return {"[Error]", ImVec4(1.0f, 0.45f, 0.45f, 1.0f)}; + case spdlog::level::off: + case spdlog::level::n_levels: + return {"[Info]", ImVec4(0.78f, 0.78f, 0.78f, 1.0f)}; + } + return {"[Info]", ImVec4(0.78f, 0.78f, 0.78f, 1.0f)}; + }; + + auto ShouldShowLevel = [&](spdlog::level::level_enum level) + { + switch (level) + { + case spdlog::level::trace: + case spdlog::level::debug: + return showDebug; + case spdlog::level::info: + return showInfo; + case spdlog::level::warn: + return showWarn; + case spdlog::level::err: + case spdlog::level::critical: + return showError; + case spdlog::level::off: + case spdlog::level::n_levels: + return showInfo; + } + return true; + }; + + if (ImGui::Button("Clear")) + { + clearedLineOffset = lines.size(); + consoleScrollToBottom_ = true; + } + ImGui::SameLine(); + consoleFilter.Draw("Filter##ConsoleFilter", 220.0f); + ImGui::SameLine(); + ImGui::Checkbox("Info", &showInfo); + ImGui::SameLine(); + ImGui::Checkbox("Warn", &showWarn); + ImGui::SameLine(); + ImGui::Checkbox("Error", &showError); + ImGui::SameLine(); + ImGui::Checkbox("Debug", &showDebug); + + std::vector visibleLines; + visibleLines.reserve(lines.size()); + for (size_t i = clearedLineOffset; i < lines.size(); ++i) + { + const auto& line = lines[i]; + const std::string payload(line.payload.data(), line.payload.size()); + if (!ShouldShowLevel(line.level)) + { + continue; + } + if (consoleFilter.IsActive() && !consoleFilter.PassFilter(payload.c_str())) + { + continue; + } + visibleLines.push_back(i); + } ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); const ImGuiChildFlags childFlags = bordered ? ImGuiChildFlags_Borders : ImGuiChildFlags_None; if (ImGui::BeginChild(childId, size, childFlags)) { ImGuiListClipper clipper; - clipper.Begin(static_cast(lines.size())); + clipper.Begin(static_cast(visibleLines.size())); while (clipper.Step()) { for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { - const auto& line = lines[static_cast(i)]; - ImVec4 color = ImVec4(0.9f, 0.9f, 0.9f, 1.0f); - switch (line.level) - { - case spdlog::level::trace: - color = ImVec4(0.55f, 0.55f, 0.55f, 1.0f); - break; - case spdlog::level::debug: - color = ImVec4(0.55f, 0.9f, 0.95f, 1.0f); - break; - case spdlog::level::info: - color = ImVec4(0.9f, 0.9f, 0.9f, 1.0f); - break; - case spdlog::level::warn: - color = ImVec4(1.0f, 0.82f, 0.35f, 1.0f); - break; - case spdlog::level::err: - color = ImVec4(1.0f, 0.45f, 0.45f, 1.0f); - break; - case spdlog::level::critical: - color = ImVec4(1.0f, 0.25f, 0.55f, 1.0f); - break; - case spdlog::level::off: - case spdlog::level::n_levels: - color = ImVec4(0.78f, 0.78f, 0.78f, 1.0f); - break; - } + const auto& line = lines[visibleLines[static_cast(i)]]; + const auto [prefix, prefixColor] = LevelInfo(line.level); const char* payloadStart = line.payload.data(); const char* payloadEnd = payloadStart + line.payload.size(); - ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::PushStyleColor(ImGuiCol_Text, prefixColor); + ImGui::TextUnformatted(prefix); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Text)); ImGui::TextUnformatted(payloadStart, payloadEnd); ImGui::PopStyleColor(); } @@ -800,6 +812,12 @@ bool UserInterface::WantsToCaptureKeyboard() const { return ImGui::GetIO().WantC bool UserInterface::WantsToCaptureMouse() const { return ImGui::GetIO().WantCaptureMouse; } +void UserInterface::ToggleConsole() +{ + showConsole_ = !showConsole_; + requestConsoleFocus_ = showConsole_; +} + void UserInterface::DrawOverlay(const Statistics& statistics, VulkanGpuTimer* gpuTimer) { if (!Settings().ShowOverlay) @@ -808,39 +826,44 @@ void UserInterface::DrawOverlay(const Statistics& statistics, VulkanGpuTimer* gp } const auto& io = ImGui::GetIO(); - const float distance = 10.0f; - const ImVec2 pos = ImVec2(io.DisplaySize.x - distance, distance + 40); + const float distance = 12.0f; + const ImVec2 pos = ImVec2(io.DisplaySize.x - distance, distance + 44.0f); const ImVec2 posPivot = ImVec2(1.0f, 0.0f); ImGui::SetNextWindowPos(pos, ImGuiCond_Always, posPivot); - ImGui::SetNextWindowBgAlpha(0.3f); // Transparent background + ImGui::SetNextWindowSize(ImVec2(430.0f, std::max(360.0f, io.DisplaySize.y - pos.y - 42.0f)), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.86f); - const auto flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration | + const auto flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoSavedSettings; if (ImGui::Begin("Statistics", &Settings().ShowOverlay, flags)) { // Colors - const ImVec4 colHeader = ImVec4(0.8f, 0.8f, 1.0f, 1.0f); - const ImVec4 colLabel = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); - const ImVec4 colVal = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); - const ImVec4 colGood = ImVec4(0.4f, 1.0f, 0.4f, 1.0f); - const ImVec4 colWarn = ImVec4(1.0f, 0.8f, 0.2f, 1.0f); - const ImVec4 colBad = ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + const ImVec4 colHeader = Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Blue); + const ImVec4 colLabel = Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted); + const ImVec4 colVal = Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Text); + const ImVec4 colGood = Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Success); + const ImVec4 colWarn = Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Warning); + const ImVec4 colBad = Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Danger); + + Runtime::UiTheme::DrawPanelHeader(ICON_FA_CHART_SIMPLE, "Profiler", "Frame, scene and pass timings"); // Helper auto LabelVal = [&](const char* label, const char* fmt, auto... args) { ImGui::TextColored(colLabel, "%s", label); - ImGui::SameLine(); + ImGui::SameLine(132.0f); ImGui::TextColored(colVal, fmt, args...); }; - ImGui::TextColored(colHeader, "Resolution: %dx%d -> %dx%d", statistics.RenderSize.width, - statistics.RenderSize.height, statistics.FramebufferSize.width, - statistics.FramebufferSize.height); - ImGui::TextColored(colLabel, "%s", statistics.Stats["gpu"].c_str()); + const VkPhysicalDeviceProperties deviceProperties = + NextEngine::GetInstance()->GetRenderer().Device().DeviceProperties(); + ImGui::TextColored(colHeader, "Device"); + ImGui::SameLine(132.0f); + ImGui::TextColored(colVal, "%s", deviceProperties.deviceName); + LabelVal("Resolution:", "%dx%d", statistics.FramebufferSize.width, statistics.FramebufferSize.height); ImGui::Separator(); @@ -854,24 +877,39 @@ void UserInterface::DrawOverlay(const Statistics& statistics, VulkanGpuTimer* gp ImGui::Separator(); - // Scene - LabelVal("Node:", "%s", Utilities::metricFormatter(static_cast(statistics.NodeCount), "").c_str()); - LabelVal("Instance:", "%s", - Utilities::metricFormatter(static_cast(statistics.InstanceCount), "").c_str()); - LabelVal("Texture:", "%d", statistics.TextureCount); - - ImGui::Separator(); - - // GPU Stats auto& gpuDrivenStat = NextEngine::GetInstance()->GetScene().GetGpuDrivenStat(); - uint32_t instanceCount = gpuDrivenStat.ProcessedCount - gpuDrivenStat.CulledCount; - uint32_t triangleCount = gpuDrivenStat.TriangleCount - gpuDrivenStat.CulledTriangleCount; + uint32_t instanceCount = gpuDrivenStat.ProcessedCount > gpuDrivenStat.CulledCount + ? gpuDrivenStat.ProcessedCount - gpuDrivenStat.CulledCount + : 0; + uint32_t triangleCount = gpuDrivenStat.TriangleCount > gpuDrivenStat.CulledTriangleCount + ? gpuDrivenStat.TriangleCount - gpuDrivenStat.CulledTriangleCount + : 0; - ImGui::TextColored(colHeader, "GPU Stats (Visible/Total):"); - LabelVal("Draws:", "%s / %s", Utilities::metricFormatter(static_cast(instanceCount), "").c_str(), - Utilities::metricFormatter(static_cast(gpuDrivenStat.ProcessedCount), "").c_str()); - LabelVal("Tris:", "%s / %s", Utilities::metricFormatter(static_cast(triangleCount), "").c_str(), - Utilities::metricFormatter(static_cast(gpuDrivenStat.TriangleCount), "").c_str()); + ImGui::Separator(); + ImGui::TextColored(colHeader, "Scene Stats"); + if (ImGui::BeginTable("##SceneStatsTable", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + { + ImGui::TableSetupColumn("Key", ImGuiTableColumnFlags_WidthFixed, 132.0f); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch); + auto DrawSceneStat = [&](const char* label, const std::string& value) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextColored(colLabel, "%s", label); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(colVal, "%s", value.c_str()); + }; + DrawSceneStat("Nodes", Utilities::metricFormatter(static_cast(statistics.NodeCount), "")); + DrawSceneStat("Instances", Utilities::metricFormatter(static_cast(statistics.InstanceCount), "")); + DrawSceneStat("Textures", std::to_string(statistics.TextureCount)); + DrawSceneStat("Draws", fmt::format("{} / {}", + Utilities::metricFormatter(static_cast(instanceCount), ""), + Utilities::metricFormatter(static_cast(gpuDrivenStat.ProcessedCount), ""))); + DrawSceneStat("Triangles", fmt::format("{} / {}", + Utilities::metricFormatter(static_cast(triangleCount), ""), + Utilities::metricFormatter(static_cast(gpuDrivenStat.TriangleCount), ""))); + ImGui::EndTable(); + } uint32_t mainTasks = TaskCoordinator::GetInstance()->GetMainTaskCount(); uint32_t lowTasks = TaskCoordinator::GetInstance()->GetParralledTaskCount(); @@ -1001,6 +1039,19 @@ void UserInterface::DrawOverlay(const Statistics& statistics, VulkanGpuTimer* gp ImGui::TextColored(colHeader, "%s (avg %.2fms / %.1fs):", label, totalTime, timingHistoryWindowSeconds); + auto TimingBarColor = [&](float milliseconds) + { + if (milliseconds < 1.0f) + { + return colGood; + } + if (milliseconds < 4.0f) + { + return colWarn; + } + return colBad; + }; + if (ImGui::BeginTable(tableId, 5, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingFixedFit)) @@ -1032,13 +1083,9 @@ void UserInterface::DrawOverlay(const Statistics& statistics, VulkanGpuTimer* gp ImGui::TextColored(colLabel, "%.2f", row.maximum); ImGui::TableNextColumn(); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, row.depth == 0 ? rootBarColor : childBarColor); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0, 0, 0, 0)); - ImGui::PushStyleColor(ImGuiCol_Border, colLabel); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 1.0f); - ImGui::ProgressBar(std::min(ratio, 1.0f), ImVec2(70, ImGui::GetTextLineHeight()), ""); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(3); + Runtime::UiTheme::DrawProgressBar(std::min(ratio, 1.0f), + TimingBarColor(row.average), + ImVec2(70.0f, ImGui::GetTextLineHeight())); ImGui::PopStyleVar(); } ImGui::EndTable(); diff --git a/src/Runtime/Editor/UserInterface.hpp b/src/Runtime/Editor/UserInterface.hpp index 2d473a64..8a995ccc 100644 --- a/src/Runtime/Editor/UserInterface.hpp +++ b/src/Runtime/Editor/UserInterface.hpp @@ -106,6 +106,8 @@ class UserInterface final bool DrawConsoleCommandInput(const char* label, const char* hint, float width = 0.0f, bool closeConsoleOnSubmit = false, bool showMatchPopup = false, const char* matchPopupId = nullptr, bool refreshMatches = true); void DrawConsoleLogOutput(const char* childId, const ImVec2& size = ImVec2(0.0f, 0.0f), bool bordered = true); + void ToggleConsole(); + bool IsConsoleOpen() const { return showConsole_; } private: NextEngine& GetEngine() {return *engine_;} diff --git a/src/Runtime/Utilities/GraphicsDebugPanel.hpp b/src/Runtime/Utilities/GraphicsDebugPanel.hpp index 5a0e62da..ed49ead2 100644 --- a/src/Runtime/Utilities/GraphicsDebugPanel.hpp +++ b/src/Runtime/Utilities/GraphicsDebugPanel.hpp @@ -71,7 +71,8 @@ namespace Runtime return -1; } - inline void DrawRendererSelector(NextEngine& engine, UserSettings& userSetting, const char* comboId) + inline void DrawRendererSelector(NextEngine& engine, UserSettings& userSetting, const char* comboId, + float width = -1.0f) { const int rendererOptionCount = GetRendererOptionCount(engine); int currentRendererIndex = ResolveRendererOptionIndex(userSetting, rendererOptionCount); @@ -81,7 +82,7 @@ namespace Runtime userSetting.RendererType = static_cast(RendererOptions[currentRendererIndex].type); } - ImGui::PushItemWidth(-1); + ImGui::PushItemWidth(width); auto renderersGetter = [](void* data, int index, const char** outText) { const auto* options = static_cast(data); From 46ff78924a4b86287033b38c74d3c6ff46ef39ff Mon Sep 17 00:00:00 2001 From: gameKnife Date: Wed, 13 May 2026 21:52:49 +0800 Subject: [PATCH 04/27] Fix GPUScene dispatch parameter updates --- src/Assets/Core/Scene.cpp | 32 +++++++++++++++++++++++++- src/Assets/Core/Scene.hpp | 2 ++ src/Rendering/RayTraceBaseRenderer.cpp | 2 +- src/Rendering/VulkanBaseRenderer.cpp | 16 ++++++------- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/Assets/Core/Scene.cpp b/src/Assets/Core/Scene.cpp index 4ffadfd9..4d20f1f9 100644 --- a/src/Assets/Core/Scene.cpp +++ b/src/Assets/Core/Scene.cpp @@ -90,7 +90,7 @@ namespace Assets for (uint32_t bufferIndex = 0; bufferIndex < maxGpuSceneBuffers; ++bufferIndex) { Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "GPUScene", VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + commandPool, "GPUScene", VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, sizeof(Assets::GPUScene), gpuSceneBuffers_[bufferIndex], gpuSceneBufferMemories_[bufferIndex]); } @@ -852,6 +852,36 @@ namespace Assets gpuSceneBufferMemories_[bufferIndex]->Unmap(); } + void Scene::CmdUpdateGPUSceneBuffer( + VkCommandBuffer commandBuffer, uint32_t imageIndex, const Assets::GPUScene& gpuScene) const + { + static_assert(sizeof(Assets::GPUScene) <= 65536); + static_assert(sizeof(Assets::GPUScene) % 4 == 0); + + if (gpuSceneBuffers_.empty()) + { + return; + } + + const uint32_t bufferIndex = imageIndex % static_cast(gpuSceneBuffers_.size()); + const Vulkan::Buffer& gpuSceneBuffer = *gpuSceneBuffers_[bufferIndex]; + vkCmdUpdateBuffer(commandBuffer, gpuSceneBuffer.Handle(), 0, sizeof(Assets::GPUScene), &gpuScene); + + VkBufferMemoryBarrier gpuSceneBarrier{}; + gpuSceneBarrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + gpuSceneBarrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + gpuSceneBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + gpuSceneBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + gpuSceneBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + gpuSceneBarrier.buffer = gpuSceneBuffer.Handle(); + gpuSceneBarrier.offset = 0; + gpuSceneBarrier.size = sizeof(Assets::GPUScene); + + vkCmdPipelineBarrier(commandBuffer, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + 0, 0, nullptr, 1, &gpuSceneBarrier, 0, nullptr); + } + const Vulkan::Buffer& Scene::GPUSceneBuffer(uint32_t imageIndex) const { return *gpuSceneBuffers_[imageIndex % static_cast(gpuSceneBuffers_.size())]; diff --git a/src/Assets/Core/Scene.hpp b/src/Assets/Core/Scene.hpp index efd3d2b8..5329ee65 100644 --- a/src/Assets/Core/Scene.hpp +++ b/src/Assets/Core/Scene.hpp @@ -60,6 +60,8 @@ namespace Assets const Assets::GPUScene& FetchGPUScene(const uint32_t imageIndex) const; void UpdateGPUSceneBuffer(uint32_t imageIndex, const Assets::GPUScene& gpuScene) const; + void CmdUpdateGPUSceneBuffer(VkCommandBuffer commandBuffer, uint32_t imageIndex, + const Assets::GPUScene& gpuScene) const; const Vulkan::Buffer& GPUSceneBuffer(uint32_t imageIndex) const; std::vector>& Nodes() { return nodes_; } const std::vector>& Nodes() const { return nodes_; } diff --git a/src/Rendering/RayTraceBaseRenderer.cpp b/src/Rendering/RayTraceBaseRenderer.cpp index 1674b1b4..641b8433 100644 --- a/src/Rendering/RayTraceBaseRenderer.cpp +++ b/src/Rendering/RayTraceBaseRenderer.cpp @@ -318,7 +318,7 @@ namespace Vulkan::RayTracing gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation ? 1u : 0u; - GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); + GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); vkCmdDispatch(commandBuffer, dispatchGroupCount, 1, 1); } } diff --git a/src/Rendering/VulkanBaseRenderer.cpp b/src/Rendering/VulkanBaseRenderer.cpp index 3b71b646..5051a571 100644 --- a/src/Rendering/VulkanBaseRenderer.cpp +++ b/src/Rendering/VulkanBaseRenderer.cpp @@ -1023,7 +1023,7 @@ namespace Vulkan gpuScene.custom_data_1 = vertexOffset; gpuScene.custom_data_2 = vertexCount; - scene.UpdateGPUSceneBuffer(imageIndex, gpuScene); + scene.CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); uint32_t groupCount = (vertexCount + 63) / 64; vkCmdDispatch(commandBuffer, groupCount, 1, 1); } @@ -1755,7 +1755,7 @@ namespace Vulkan gpuScene.custom_data_1 = 0; gpuScene.custom_data_2 = 0; - GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); + GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); vkCmdDispatch(commandBuffer, groupCount, 1, 1); VkBufferMemoryBarrier barriers[2]{}; @@ -1842,7 +1842,7 @@ namespace Vulkan gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = 0; - GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); + GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier propagationToInjectionBarrier{}; @@ -1860,7 +1860,7 @@ namespace Vulkan injectAmbientCubeGenPipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); - GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); + GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier postInjectionBarrier{}; @@ -1923,7 +1923,7 @@ namespace Vulkan gpuScene.SkinnedVerticesSimple = GetScene().AmbientCubeSdfScratchBuffer().GetDeviceAddress(); distanceFieldInitPipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); - GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); + GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier initBarrier{}; @@ -1946,7 +1946,7 @@ namespace Vulkan gpuScene.custom_data_1 = passParity; gpuScene.custom_data_2 = step; - GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); + GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier jumpBarriers[2]{}; @@ -1973,7 +1973,7 @@ namespace Vulkan distanceFieldResolvePipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); gpuScene.custom_data_1 = passParity - 1; gpuScene.custom_data_2 = 0; - GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); + GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier postResolveBarrier{}; @@ -2083,7 +2083,7 @@ namespace Vulkan gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation ? 1u : 0u; - GetScene().UpdateGPUSceneBuffer(imageIndex, gpuScene); + GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); vkCmdDispatch(commandBuffer, dispatchGroupCount, 1, 1); } } From fae712a3e54ff7ea52f9be287a3f5c89e2e95d5d Mon Sep 17 00:00:00 2001 From: gameKnife Date: Wed, 13 May 2026 23:34:50 +0800 Subject: [PATCH 05/27] Refactor GPU scene buffer layout --- AGENTS.md | 7 + TODO.md | 7 + .../shaders/Bake.DistanceFieldJump.comp.slang | 2 +- .../Bake.DistanceFieldResolve.comp.slang | 2 +- assets/shaders/Rast.VisibilityPass.vert.slang | 14 +- assets/shaders/Task.Skinning.comp.slang | 2 - assets/shaders/common/BasicTypes.slang | 194 +++++++++------- assets/shaders/common/GPUScene.slang | 6 +- .../Acceleration/CPUAccelerationStructure.cpp | 27 ++- .../Acceleration/CPUAccelerationStructure.h | 2 + src/Assets/Core/Scene.cpp | 209 +++++------------- src/Assets/Core/Scene.hpp | 64 ++---- .../PipelineCommon/CommonComputePipeline.cpp | 73 ++---- src/Rendering/RayTraceBaseRenderer.cpp | 17 +- src/Rendering/VulkanBaseRenderer.cpp | 75 ++++--- src/Rendering/VulkanBaseRenderer.hpp | 4 - src/Runtime/Engine.cpp | 1 + src/Vulkan/RayTracing/BottomLevelGeometry.cpp | 6 +- 18 files changed, 311 insertions(+), 401 deletions(-) create mode 100644 TODO.md diff --git a/AGENTS.md b/AGENTS.md index 064530d8..6092e7a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,3 +210,10 @@ assets/ - `MagicaLego.md` - MagicaLego subproject notes - **README.en.md** - Project overview and quick start - **.clang-tidy** - Naming conventions (source of truth) + +## Spec Workflow + +- 当用户说,"启动交互式工作流"时,开始扫描TODO.md内的未完成任务,执行任务,执行完后标记完成。继续执行下一个任务。 +- 如果所有任务都执行完毕,则等待60秒,再次读取TODO.md查看是否有未完成任务 +- TODO.md随时会被用户修改,每次都重新读取。 +- 交互式会话只有一种退出条件,就是当TODO.md的里程碑状态被改为已完成 \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..ab857516 --- /dev/null +++ b/TODO.md @@ -0,0 +1,7 @@ +## 下一步任务 +-[ ] 提交当前修改 +-[ ] 确认AmbientCube的改造,目前Voxel的更新和GPU读取是正确的,但AmbientCube感觉没有工作。但hwlightbake在执行 + +## 里程碑状态 +未完成 + diff --git a/assets/shaders/Bake.DistanceFieldJump.comp.slang b/assets/shaders/Bake.DistanceFieldJump.comp.slang index c1bc348d..01e79162 100644 --- a/assets/shaders/Bake.DistanceFieldJump.comp.slang +++ b/assets/shaders/Bake.DistanceFieldJump.comp.slang @@ -28,7 +28,7 @@ void main(uint3 DTid : SV_DispatchThreadID) uint passParity = Bindless.GetGpuscene().custom_data_1; uint4* SeedA = (uint4*)Bindless.GetGpuscene().CubesPong; - uint4* SeedB = (uint4*)Bindless.GetGpuscene().SkinnedVerticesSimple; + uint4* SeedB = Bindless.GetGpuscene().AmbientSdfScratch; uint4* SourceSeeds = (passParity & 1u) == 0u ? SeedA : SeedB; uint4* DestSeeds = (passParity & 1u) == 0u ? SeedB : SeedA; diff --git a/assets/shaders/Bake.DistanceFieldResolve.comp.slang b/assets/shaders/Bake.DistanceFieldResolve.comp.slang index 38d52e27..3ac82f3e 100644 --- a/assets/shaders/Bake.DistanceFieldResolve.comp.slang +++ b/assets/shaders/Bake.DistanceFieldResolve.comp.slang @@ -15,7 +15,7 @@ void main(uint3 DTid : SV_DispatchThreadID) } uint4* SeedA = (uint4*)Bindless.GetGpuscene().CubesPong; - uint4* SeedB = (uint4*)Bindless.GetGpuscene().SkinnedVerticesSimple; + uint4* SeedB = Bindless.GetGpuscene().AmbientSdfScratch; uint lastPassParity = Bindless.GetGpuscene().custom_data_1; uint4 seed = (lastPassParity & 1u) == 0u ? SeedB[localIdx] : SeedA[localIdx]; diff --git a/assets/shaders/Rast.VisibilityPass.vert.slang b/assets/shaders/Rast.VisibilityPass.vert.slang index 5db65b72..17f5e463 100644 --- a/assets/shaders/Rast.VisibilityPass.vert.slang +++ b/assets/shaders/Rast.VisibilityPass.vert.slang @@ -14,21 +14,15 @@ VertexOutput main(uint vertexIndex: SV_VertexID, uint baseVertex: SV_StartVertex uint absouluteInstanceIdx = instanceIndex + instanceBase; NodeProxy proxy = Bindless.GetGpuscene().Nodes[absouluteInstanceIdx]; - half4* currentVerticesSimple = Bindless.GetGpuscene().VerticesSimple; - if (proxy.skinId != 0xFFFFFFFF) - { - currentVerticesSimple = Bindless.GetGpuscene().SkinnedVerticesSimple; - } - - half4 Position = currentVerticesSimple[Bindless.GetGpuscene().Reorders[vertexIndex]]; - output.position = mul(mul(Bindless.GetGpuscene().Camera[0].ViewProjection, proxy.worldTS), float4(Position.xyz, 1.0)); - GPUVertex* currentVertices = Bindless.GetGpuscene().Vertices; if (proxy.skinId != 0xFFFFFFFF) { currentVertices = Bindless.GetGpuscene().SkinnedVertices; } - Vertex vertex = UnpackVertex(currentVertices[Bindless.GetGpuscene().Reorders[vertexIndex]]); + GPUVertex packedVertex = currentVertices[Bindless.GetGpuscene().Reorders[vertexIndex]]; + output.position = mul(mul(Bindless.GetGpuscene().Camera[0].ViewProjection, proxy.worldTS), float4(packedVertex.Position_Tx.xyz, 1.0)); + + Vertex vertex = UnpackVertex(packedVertex); const uint materialId = FetchMaterialId(proxy, vertex.MaterialIndex); output.material_model = Bindless.GetGpuscene().GetMaterial(materialId).MaterialModel; diff --git a/assets/shaders/Task.Skinning.comp.slang b/assets/shaders/Task.Skinning.comp.slang index b4ede2c1..3650fcb7 100644 --- a/assets/shaders/Task.Skinning.comp.slang +++ b/assets/shaders/Task.Skinning.comp.slang @@ -43,7 +43,5 @@ void main(uint3 DTid : SV_DispatchThreadID) outVertex.Normal_Ty = half4((half)skinnedNormal.x, (half)skinnedNormal.y, (half)skinnedNormal.z, baseVertex.Normal_Ty.w); outVertex.Tangent = half4((half)skinnedTangent.x, (half)skinnedTangent.y, (half)skinnedTangent.z, baseVertex.Tangent.w); - // Write to SkinnedVertices scene.SkinnedVertices[vertexIdx] = outVertex; - scene.SkinnedVerticesSimple[vertexIdx] = outVertex.Position_Tx; } diff --git a/assets/shaders/common/BasicTypes.slang b/assets/shaders/common/BasicTypes.slang index 545380b8..72f406bf 100644 --- a/assets/shaders/common/BasicTypes.slang +++ b/assets/shaders/common/BasicTypes.slang @@ -9,6 +9,47 @@ implementing Common; public static const int MAX_NODES = 65535; // 256; public static const int MAX_MATERIALS = 16384; +public static const int MAX_HDR_SH = 100; + +public static const int GPU_SCENE_NODE_PROXY_SIZE = 224; +public static const int GPU_SCENE_MATERIAL_SIZE = 64; +public static const int GPU_SCENE_GPU_DRIVEN_STAT_SIZE = 32; +public static const int GPU_SCENE_SPHERICAL_HARMONICS_SIZE = 112; + +public static const int GPU_SCENE_DYNAMIC_NODES_OFFSET = 0; +public static const int GPU_SCENE_DYNAMIC_MATERIALS_OFFSET = + GPU_SCENE_DYNAMIC_NODES_OFFSET + GPU_SCENE_NODE_PROXY_SIZE * MAX_NODES; +public static const int GPU_SCENE_DYNAMIC_GPU_DRIVEN_STATS_OFFSET = + GPU_SCENE_DYNAMIC_MATERIALS_OFFSET + GPU_SCENE_MATERIAL_SIZE * MAX_MATERIALS; +public static const int GPU_SCENE_DYNAMIC_HDRSHS_OFFSET = + GPU_SCENE_DYNAMIC_GPU_DRIVEN_STATS_OFFSET + GPU_SCENE_GPU_DRIVEN_STAT_SIZE; +public static const int GPU_SCENE_DYNAMIC_SIZE = + GPU_SCENE_DYNAMIC_HDRSHS_OFFSET + GPU_SCENE_SPHERICAL_HARMONICS_SIZE * MAX_HDR_SH; + +public static const int GPU_SCENE_AMBIENT_PER_CASCADE_COUNT = 192 * 192 * 48; +public static const int GPU_SCENE_AMBIENT_CASCADE_MAX = 4; +public static const int GPU_SCENE_AMBIENT_CUBE_SIZE = 56; +public static const int GPU_SCENE_VOXEL_DATA_SIZE = 16; +public static const int GPU_SCENE_PAGE_INDEX_SIZE = 16; +public static const int GPU_SCENE_AMBIENT_SEED_SIZE = 16; +public static const int GPU_SCENE_ACGI_PAGE_COUNT = 64; + +public static const int GPU_SCENE_AMBIENT_CUBES_OFFSET = 0; +public static const int GPU_SCENE_AMBIENT_VOXELS_OFFSET = + GPU_SCENE_AMBIENT_CUBES_OFFSET + + GPU_SCENE_AMBIENT_CUBE_SIZE * GPU_SCENE_AMBIENT_PER_CASCADE_COUNT * GPU_SCENE_AMBIENT_CASCADE_MAX; +public static const int GPU_SCENE_AMBIENT_PAGES_OFFSET = + GPU_SCENE_AMBIENT_VOXELS_OFFSET + + GPU_SCENE_VOXEL_DATA_SIZE * GPU_SCENE_AMBIENT_PER_CASCADE_COUNT * GPU_SCENE_AMBIENT_CASCADE_MAX; +public static const int GPU_SCENE_AMBIENT_CUBES_PONG_OFFSET = + GPU_SCENE_AMBIENT_PAGES_OFFSET + + GPU_SCENE_PAGE_INDEX_SIZE * GPU_SCENE_ACGI_PAGE_COUNT * GPU_SCENE_ACGI_PAGE_COUNT; +public static const int GPU_SCENE_AMBIENT_SDF_SCRATCH_OFFSET = + GPU_SCENE_AMBIENT_CUBES_PONG_OFFSET + + GPU_SCENE_AMBIENT_CUBE_SIZE * GPU_SCENE_AMBIENT_PER_CASCADE_COUNT; +public static const int GPU_SCENE_AMBIENT_SIZE = + GPU_SCENE_AMBIENT_SDF_SCRATCH_OFFSET + + GPU_SCENE_AMBIENT_SEED_SIZE * GPU_SCENE_AMBIENT_PER_CASCADE_COUNT; public struct ALIGN_16 GPUDrivenStat { @@ -270,43 +311,24 @@ public struct ALIGN_16 GPUVertex #ifdef __cplusplus -// still 144bytes, need to shrink into 128 bytes to make full compatible with vulkan -// some static address can be pack together +// 13 addresses + 4 uint params; aligned size stays within 128 bytes for Vulkan push_constant. public struct ALIGN_16 GPUScene { uint64_t Camera; - uint64_t Nodes; - + uint64_t SceneDynamicBase; uint64_t Reorders; - uint64_t VerticesSimple; - uint64_t Vertices; uint64_t Indices; - - uint64_t Materials; uint64_t Offsets; - - uint64_t Cubes; - uint64_t Voxels; - - uint64_t Pages; - uint64_t HDRSHs; - - uint64_t Lights; uint64_t IndirectDrawCommands; - - uint64_t GPUDrivenStats; + uint64_t AmbientBase; uint64_t TLAS; uint64_t SkinWeights; uint64_t SkinJoints; - uint64_t SkinnedVertices; uint64_t JointMatrices; - uint64_t SkinnedVerticesSimple; - uint64_t CubesPong; - uint32_t SwapChainIndex; uint32_t custom_data_0; uint32_t custom_data_1; @@ -319,30 +341,59 @@ public struct ALIGN_16 GPUScene public struct ALIGN_8 GPUScene { /* Scene Info */ - public UniformBufferObject *Camera; + public UniformBufferObject *Camera; + public uint64_t SceneDynamicBase; /* Scene Node Tree */ - public NodeProxy *Nodes; + public property NodeProxy* Nodes + { + get { return (NodeProxy*)(SceneDynamicBase + GPU_SCENE_DYNAMIC_NODES_OFFSET); } + } public uint *Reorders; - public half4 *VerticesSimple; public GPUVertex *Vertices; public uint *Indices; - public Material *Materials; + public property Material* Materials + { + get { return (Material*)(SceneDynamicBase + GPU_SCENE_DYNAMIC_MATERIALS_OFFSET); } + } public Material GetMaterial(int idx) { return Materials[min(MAX_MATERIALS - 1, max(0, idx))]; } public ModelData *Offsets; - public AmbientCube *Cubes; - public VoxelData *Voxels; - public PageIndex *Pages; - public SphericalHarmonics *HDRSHs; - - public LightObject *Lights; public VkDrawIndexedIndirectCommand *IndirectDrawCommands; - - public GPUDrivenStat *GPUDrivenStats; + + public uint64_t AmbientBase; + public property AmbientCube* Cubes + { + get { return (AmbientCube*)(AmbientBase + GPU_SCENE_AMBIENT_CUBES_OFFSET); } + } + public property VoxelData* Voxels + { + get { return (VoxelData*)(AmbientBase + GPU_SCENE_AMBIENT_VOXELS_OFFSET); } + } + public property PageIndex* Pages + { + get { return (PageIndex*)(AmbientBase + GPU_SCENE_AMBIENT_PAGES_OFFSET); } + } + public property AmbientCube* CubesPong + { + get { return (AmbientCube*)(AmbientBase + GPU_SCENE_AMBIENT_CUBES_PONG_OFFSET); } + } + public property uint4* AmbientSdfScratch + { + get { return (uint4*)(AmbientBase + GPU_SCENE_AMBIENT_SDF_SCRATCH_OFFSET); } + } + public property SphericalHarmonics* HDRSHs + { + get { return (SphericalHarmonics*)(SceneDynamicBase + GPU_SCENE_DYNAMIC_HDRSHS_OFFSET); } + } + + public property GPUDrivenStat* GPUDrivenStats + { + get { return (GPUDrivenStat*)(SceneDynamicBase + GPU_SCENE_DYNAMIC_GPU_DRIVEN_STATS_OFFSET); } + } public uint64_t TLAS; public float4 *SkinWeights; @@ -351,9 +402,6 @@ public struct ALIGN_8 GPUScene public GPUVertex *SkinnedVertices; public float4x4 *JointMatrices; - public half4 *SkinnedVerticesSimple; - public AmbientCube *CubesPong; - public RaytracingAccelerationStructure GetTLAS() { return RaytracingAccelerationStructure(TLAS); } @@ -367,44 +415,39 @@ public struct ALIGN_8 GPUScene public struct ALIGN_8 GPUScene { /* Scene Info */ - uint64_t2 Camera_Node_Address; + uint64_t2 Camera_SceneDynamicBase_Address; public property UniformBufferObject* Camera { - get { return (UniformBufferObject*)Camera_Node_Address.x; } + get { return (UniformBufferObject*)Camera_SceneDynamicBase_Address.x; } } /* Scene Node Tree */ public property NodeProxy* Nodes { - get { return (NodeProxy*)Camera_Node_Address.y; } + get { return (NodeProxy*)(Camera_SceneDynamicBase_Address.y + GPU_SCENE_DYNAMIC_NODES_OFFSET); } } /* Global Vertice Buffer */ - uint64_t2 Reorders_VerticesSimple_Address; + uint64_t2 Reorders_Vertices_Address; public property uint* Reorders { - get { return (uint*)Reorders_VerticesSimple_Address.x; } - } - public property half4* VerticesSimple - { - get { return (half4*)Reorders_VerticesSimple_Address.y; } + get { return (uint*)Reorders_Vertices_Address.x; } } - - uint64_t2 Vertices_Indices_Address; public property GPUVertex* Vertices { - get { return (GPUVertex*)Vertices_Indices_Address.x; } + get { return (GPUVertex*)Reorders_Vertices_Address.y; } } + + uint64_t2 Indices_Offsets_Address; public property uint* Indices { - get { return (uint*)Vertices_Indices_Address.y; } + get { return (uint*)Indices_Offsets_Address.x; } } /* Resources */ - uint64_t2 Materials_OffsetsAddress; public property Material* Materials { - get { return (Material*)Materials_OffsetsAddress.x; } + get { return (Material*)(Camera_SceneDynamicBase_Address.y + GPU_SCENE_DYNAMIC_MATERIALS_OFFSET); } } public Material GetMaterial(int idx) { @@ -412,44 +455,47 @@ public struct ALIGN_8 GPUScene } public property ModelData* Offsets { - get { return (ModelData*)Materials_OffsetsAddress.y; } + get { return (ModelData*)Indices_Offsets_Address.y; } } /* Others */ - uint64_t2 Cubes_Voxels_Address; + uint64_t2 IndirectDrawCommands_AmbientBase_Address; + public property VkDrawIndexedIndirectCommand* IndirectDrawCommands + { + get { return (VkDrawIndexedIndirectCommand*)IndirectDrawCommands_AmbientBase_Address.x; } + } + public property AmbientCube* Cubes { - get { return (AmbientCube*)Cubes_Voxels_Address.x; } + get { return (AmbientCube*)(IndirectDrawCommands_AmbientBase_Address.y + GPU_SCENE_AMBIENT_CUBES_OFFSET); } } public property VoxelData* Voxels { - get { return (VoxelData*)Cubes_Voxels_Address.y; } + get { return (VoxelData*)(IndirectDrawCommands_AmbientBase_Address.y + GPU_SCENE_AMBIENT_VOXELS_OFFSET); } } - - uint64_t2 Pages_HDRSHs_Address; + public property PageIndex* Pages { - get { return (PageIndex*)Pages_HDRSHs_Address.x; } + get { return (PageIndex*)(IndirectDrawCommands_AmbientBase_Address.y + GPU_SCENE_AMBIENT_PAGES_OFFSET); } } public property SphericalHarmonics* HDRSHs { - get { return (SphericalHarmonics*)Pages_HDRSHs_Address.y; } + get { return (SphericalHarmonics*)(Camera_SceneDynamicBase_Address.y + GPU_SCENE_DYNAMIC_HDRSHS_OFFSET); } } - uint64_t2 Lights_IndirectDrawCommands_Address; - public property LightObject* Lights + public property AmbientCube* CubesPong { - get { return (LightObject*)Lights_IndirectDrawCommands_Address.x; } + get { return (AmbientCube*)(IndirectDrawCommands_AmbientBase_Address.y + GPU_SCENE_AMBIENT_CUBES_PONG_OFFSET); } } - public property VkDrawIndexedIndirectCommand* IndirectDrawCommands + public property uint4* AmbientSdfScratch { - get { return (VkDrawIndexedIndirectCommand*)Lights_IndirectDrawCommands_Address.y; } + get { return (uint4*)(IndirectDrawCommands_AmbientBase_Address.y + GPU_SCENE_AMBIENT_SDF_SCRATCH_OFFSET); } } - - uint64_t2 GPUDrivenStats_TLAS_Address; + + public uint64_t TLAS; public property GPUDrivenStat* GPUDrivenStats { - get { return (GPUDrivenStat*)GPUDrivenStats_TLAS_Address.x; } + get { return (GPUDrivenStat*)(Camera_SceneDynamicBase_Address.y + GPU_SCENE_DYNAMIC_GPU_DRIVEN_STATS_OFFSET); } } uint64_t2 SkinWeights_SkinJoints_Address; @@ -471,22 +517,12 @@ public struct ALIGN_8 GPUScene { get { return (float4x4*)SkinnedVertices_JointMatrices_Address.y; } } - - uint64_t2 SkinnedVerticesSimple_Reserved_Address; - public property half4* SkinnedVerticesSimple - { - get { return (half4*)SkinnedVerticesSimple_Reserved_Address.x; } - } - public property AmbientCube* CubesPong - { - get { return (AmbientCube*)SkinnedVerticesSimple_Reserved_Address.y; } - } public RaytracingAccelerationStructure GetTLAS() { #ifdef PLATFORM_ANDROID return BindedTLAS; #else - return RaytracingAccelerationStructure(GPUDrivenStats_TLAS_Address.y); + return RaytracingAccelerationStructure(TLAS); #endif } diff --git a/assets/shaders/common/GPUScene.slang b/assets/shaders/common/GPUScene.slang index 56577084..57ab8830 100644 --- a/assets/shaders/common/GPUScene.slang +++ b/assets/shaders/common/GPUScene.slang @@ -1,12 +1,10 @@ implementing Common; -[[vk::binding(0, 1)]] -StructuredBuffer GPUSceneBuffer; - +[[vk::push_constant]] GPUScene gpuScene; namespace Bindless { public GPUScene GetGpuscene() { - return GPUSceneBuffer[0]; + return gpuScene; } } diff --git a/src/Assets/Acceleration/CPUAccelerationStructure.cpp b/src/Assets/Acceleration/CPUAccelerationStructure.cpp index 2858252c..bfdd80b3 100644 --- a/src/Assets/Acceleration/CPUAccelerationStructure.cpp +++ b/src/Assets/Acceleration/CPUAccelerationStructure.cpp @@ -387,7 +387,12 @@ void FCPUProbeBaker::Init(uint32_t cascadeIdx, float unitSize, vec3 offset) void FCPUProbeBaker::UploadGPU(Vulkan::DeviceMemory& voxelGpuMemory, uint32_t elementOffset) { - const size_t byteOffset = static_cast(elementOffset) * sizeof(VoxelData); + UploadGPU(voxelGpuMemory, 0, elementOffset); +} + +void FCPUProbeBaker::UploadGPU(Vulkan::DeviceMemory& voxelGpuMemory, size_t byteBaseOffset, uint32_t elementOffset) +{ + const size_t byteOffset = byteBaseOffset + static_cast(elementOffset) * sizeof(VoxelData); VoxelData* data = reinterpret_cast(voxelGpuMemory.Map(byteOffset, sizeof(VoxelData) * voxels.size())); std::memcpy(data, voxels.data(), voxels.size() * sizeof(VoxelData)); voxelGpuMemory.Unmap(); @@ -734,7 +739,7 @@ bool FCPUAccelerationStructure::AsyncProcessFull(Assets::Scene& scene, Vulkan::D { FCPUProbeBaker& baker = cascadeBakers[cascadeIndex]; baker.ClearAmbientCubes(); - baker.UploadGPU(*voxelGpuMemory, cascadeIndex * kCascadeVoxelCount); + baker.UploadGPU(*voxelGpuMemory, scene.AmbientVoxelsByteOffset(), cascadeIndex * kCascadeVoxelCount); } } else @@ -864,13 +869,14 @@ bool FCPUAccelerationStructure::Tick(Scene& scene, Vulkan::DeviceMemory* gpuMemo distanceFieldRebuildTasks.clear(); for (uint32_t cascadeIndex = 0; cascadeIndex < GetActiveCascadeCount(); ++cascadeIndex) { - cascadeBakers[cascadeIndex].UploadGPU(*voxelGpuMemory, cascadeIndex * kCascadeVoxelCount); + cascadeBakers[cascadeIndex].UploadGPU( + *voxelGpuMemory, scene.AmbientVoxelsByteOffset(), cascadeIndex * kCascadeVoxelCount); } if (!cascadeBakers.empty()) { cpuPageIndex.UpdateData(cascadeBakers); } - cpuPageIndex.UploadGPU(*pageIndexMemory); + cpuPageIndex.UploadGPU(*pageIndexMemory, scene.AmbientPagesByteOffset()); needFlush = false; voxelUploadCompleted = true; } @@ -895,13 +901,14 @@ bool FCPUAccelerationStructure::Tick(Scene& scene, Vulkan::DeviceMemory* gpuMemo // Upload to GPU, now entire range, optimize to partial upload later for (uint32_t cascadeIndex = 0; cascadeIndex < GetActiveCascadeCount(); ++cascadeIndex) { - cascadeBakers[cascadeIndex].UploadGPU(*voxelGpuMemory, cascadeIndex * kCascadeVoxelCount); + cascadeBakers[cascadeIndex].UploadGPU( + *voxelGpuMemory, scene.AmbientVoxelsByteOffset(), cascadeIndex * kCascadeVoxelCount); } if (!cascadeBakers.empty()) { cpuPageIndex.UpdateData(cascadeBakers); } - cpuPageIndex.UploadGPU(*pageIndexMemory); + cpuPageIndex.UploadGPU(*pageIndexMemory, scene.AmbientPagesByteOffset()); needFlush = false; distanceFieldRebuildScheduled_ = false; distanceFieldRebuildTasks.clear(); @@ -1063,7 +1070,13 @@ Assets::PageIndex& FCPUPageIndex::GetPage(glm::vec3 worldpos) void FCPUPageIndex::UploadGPU(Vulkan::DeviceMemory& gpuMemory) { - PageIndex* data = reinterpret_cast(gpuMemory.Map(0, sizeof(PageIndex) * pageIndex.size())); + UploadGPU(gpuMemory, 0); +} + +void FCPUPageIndex::UploadGPU(Vulkan::DeviceMemory& gpuMemory, size_t byteBaseOffset) +{ + PageIndex* data = reinterpret_cast( + gpuMemory.Map(byteBaseOffset, sizeof(PageIndex) * pageIndex.size())); std::memcpy(data, pageIndex.data(), pageIndex.size() * sizeof(PageIndex)); gpuMemory.Unmap(); } diff --git a/src/Assets/Acceleration/CPUAccelerationStructure.h b/src/Assets/Acceleration/CPUAccelerationStructure.h index ffbd262b..98ee4b62 100644 --- a/src/Assets/Acceleration/CPUAccelerationStructure.h +++ b/src/Assets/Acceleration/CPUAccelerationStructure.h @@ -86,6 +86,7 @@ struct FCPUProbeBaker void ProcessCube(int x, int y, int z, ECubeProcType procType); void RebuildDistanceField(); void UploadGPU(Vulkan::DeviceMemory& voxelDeviceMemory); + void UploadGPU(Vulkan::DeviceMemory& voxelDeviceMemory, size_t byteBaseOffset, uint32_t elementOffset); void UploadGPU(Vulkan::DeviceMemory& voxelDeviceMemory, uint32_t elementOffset); void ClearAmbientCubes(); }; @@ -98,6 +99,7 @@ struct FCPUPageIndex void UpdateData(const std::vector& bakers); Assets::PageIndex& GetPage(glm::vec3 worldpos); void UploadGPU(Vulkan::DeviceMemory& deviceMemory); + void UploadGPU(Vulkan::DeviceMemory& deviceMemory, size_t byteBaseOffset); }; class FCPUAccelerationStructure diff --git a/src/Assets/Core/Scene.cpp b/src/Assets/Core/Scene.cpp index 4d20f1f9..d41aba85 100644 --- a/src/Assets/Core/Scene.cpp +++ b/src/Assets/Core/Scene.cpp @@ -25,7 +25,32 @@ namespace Assets { - constexpr uint32_t maxGpuSceneBuffers = 8; + namespace + { + constexpr VkDeviceSize perAmbientCascadeCount = + static_cast(Assets::CUBE_SIZE_XY) * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z; + + constexpr VkDeviceSize AmbientArenaSizeForCascadeCapacity(uint32_t cascadeCapacity) + { + return static_cast(Assets::GPU_SCENE_AMBIENT_CUBE_SIZE) * perAmbientCascadeCount * + cascadeCapacity + + static_cast(Assets::GPU_SCENE_VOXEL_DATA_SIZE) * perAmbientCascadeCount * + cascadeCapacity + + static_cast(Assets::GPU_SCENE_PAGE_INDEX_SIZE) * Assets::ACGI_PAGE_COUNT * + Assets::ACGI_PAGE_COUNT + + static_cast(Assets::GPU_SCENE_AMBIENT_CUBE_SIZE) * perAmbientCascadeCount + + static_cast(Assets::GPU_SCENE_AMBIENT_SEED_SIZE) * perAmbientCascadeCount; + } + + static_assert(sizeof(Assets::NodeProxy) == Assets::GPU_SCENE_NODE_PROXY_SIZE); + static_assert(sizeof(Assets::Material) == Assets::GPU_SCENE_MATERIAL_SIZE); + static_assert(sizeof(Assets::GPUDrivenStat) == Assets::GPU_SCENE_GPU_DRIVEN_STAT_SIZE); + static_assert(sizeof(Assets::SphericalHarmonics) == Assets::GPU_SCENE_SPHERICAL_HARMONICS_SIZE); + static_assert(sizeof(Assets::AmbientCube) == Assets::GPU_SCENE_AMBIENT_CUBE_SIZE); + static_assert(sizeof(Assets::VoxelData) == Assets::GPU_SCENE_VOXEL_DATA_SIZE); + static_assert(sizeof(Assets::PageIndex) == Assets::GPU_SCENE_PAGE_INDEX_SIZE); + static_assert(sizeof(Assets::GPUScene) <= 128); + } void Scene::RegisterReflection() { @@ -72,54 +97,19 @@ namespace Assets const uint32_t ambientCubeCascadeCapacity = usesAmbientCube ? Assets::CUBE_CASCADE_MAX : 1u; Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "VoxelDatas", flags, + commandPool, "SceneDynamic", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, - ambientCubeCascadeCapacity * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z * sizeof(Assets::VoxelData), - farAmbientCubeBuffer_, farAmbientCubeBufferMemory_); - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "PageIndex", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, - ACGI_PAGE_COUNT * ACGI_PAGE_COUNT * sizeof(Assets::PageIndex), pageIndexBuffer_, pageIndexBufferMemory_); + Assets::GPU_SCENE_DYNAMIC_SIZE, sceneDynamicBuffer_, sceneDynamicBufferMemory_); Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "GPUDrivenStats", flags, - VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, sizeof(Assets::GPUDrivenStat), - gpuDrivenStatsBuffer_, gpuDrivenStatsBuffer_Memory_); - - gpuSceneBuffers_.resize(maxGpuSceneBuffers); - gpuSceneBufferMemories_.resize(maxGpuSceneBuffers); - for (uint32_t bufferIndex = 0; bufferIndex < maxGpuSceneBuffers; ++bufferIndex) - { - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "GPUScene", VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, sizeof(Assets::GPUScene), - gpuSceneBuffers_[bufferIndex], gpuSceneBufferMemories_[bufferIndex]); - } - - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "HDRSH", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, - sizeof(SphericalHarmonics) * 100, hdrSHBuffer_, hdrSHBufferMemory_); + commandPool, "AmbientArena", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + AmbientArenaSizeForCascadeCapacity(ambientCubeCascadeCapacity), ambientArenaBuffer_, ambientArenaBufferMemory_); // gpu local buffers Vulkan::BufferUtil::CreateDeviceBufferLocal( commandPool, "IndirectDraws", flags | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, sizeof(VkDrawIndexedIndirectCommand) * 65535, indirectDrawBuffer_, indirectDrawBufferMemory_); // support 65535 nodes - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "AmbientCubes", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, - ambientCubeCascadeCapacity * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z * sizeof(Assets::AmbientCube), - ambientCubeBuffer_, ambientCubeBufferMemory_); - - // Single-cascade ping-pong snapshot for propagation-based ambient cube bake. - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "AmbientCubesPong", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, - Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z * sizeof(Assets::AmbientCube), - ambientCubePongBuffer_, ambientCubePongBufferMemory_); - - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "AmbientCubeSdfScratch", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, - Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_XY * Assets::CUBE_SIZE_Z * sizeof(glm::u32vec4), - ambientCubeSdfScratchBuffer_, ambientCubeSdfScratchBufferMemory_); - // shadow maps cpuShadowMap_.reset( new TextureImage(commandPool, SHADOWMAP_SIZE, SHADOWMAP_SIZE, 1, VK_FORMAT_R32_SFLOAT, nullptr, 0)); @@ -147,30 +137,10 @@ namespace Assets indirectDrawBuffer_.reset(); indirectDrawBufferMemory_.reset(); - nodeMatrixBuffer_.reset(); - nodeMatrixBufferMemory_.reset(); - materialBuffer_.reset(); - materialBufferMemory_.reset(); - - ambientCubeBuffer_.reset(); - ambientCubeBufferMemory_.reset(); - - ambientCubePongBuffer_.reset(); - ambientCubePongBufferMemory_.reset(); - - ambientCubeSdfScratchBuffer_.reset(); - ambientCubeSdfScratchBufferMemory_.reset(); - - farAmbientCubeBuffer_.reset(); - farAmbientCubeBufferMemory_.reset(); - gpuSceneBuffers_.clear(); - gpuSceneBufferMemories_.clear(); - - pageIndexBuffer_.reset(); - pageIndexBufferMemory_.reset(); - - hdrSHBuffer_.reset(); - hdrSHBufferMemory_.reset(); + sceneDynamicBuffer_.reset(); + sceneDynamicBufferMemory_.reset(); + ambientArenaBuffer_.reset(); + ambientArenaBufferMemory_.reset(); skinWeightBuffer_.reset(); skinWeightBufferMemory_.reset(); @@ -437,7 +407,6 @@ namespace Assets // 重建universe mesh buffer, 这个可以比较静态 std::vector vertices; - std::vector simpleVertices; std::vector indices; std::vector allWeights; std::vector allJoints; @@ -456,10 +425,6 @@ namespace Assets for (auto& vertex : model.CPUVertices()) { vertices.push_back(MakeVertex(vertex)); - simpleVertices.push_back(glm::detail::toFloat16(vertex.Position.x)); - simpleVertices.push_back(glm::detail::toFloat16(vertex.Position.y)); - simpleVertices.push_back(glm::detail::toFloat16(vertex.Position.z)); - simpleVertices.push_back(glm::detail::toFloat16(vertex.Position.x)); } const auto& weights = model.CPUWeights(); @@ -551,21 +516,10 @@ namespace Assets int flags = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT; int rtxFlags = supportRayTracing ? VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR : 0; - // this two buffer may change violate, reverse to MAX_NODES and MAX_MATERIALS - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "Nodes", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, - sizeof(NodeProxy) * Assets::MAX_NODES, nodeMatrixBuffer_, nodeMatrixBufferMemory_); - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "Materials", flags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, - sizeof(Material) * Assets::MAX_MATERIALS, materialBuffer_, materialBufferMemory_); - // this buffer now, no support extended Vulkan::BufferUtil::CreateDeviceBuffer(commandPool, "Vertices", VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | rtxFlags | flags, vertices, vertexBuffer_, vertexBufferMemory_); - Vulkan::BufferUtil::CreateDeviceBuffer(commandPool, "SimpleVertices", - VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | rtxFlags | flags, simpleVertices, - simpleVertexBuffer_, simpleVertexBufferMemory_); Vulkan::BufferUtil::CreateDeviceBuffer(commandPool, "Indices", VK_BUFFER_USAGE_INDEX_BUFFER_BIT | flags, indices, indexBuffer_, indexBufferMemory_); Vulkan::BufferUtil::CreateDeviceBuffer(commandPool, "Reorder", flags, reorders, reorderBuffer_, @@ -592,7 +546,7 @@ namespace Assets if (!NextEngine::GetInstance() || NextEngine::GetInstance()->GetRenderer().CurrentRendererUsesAmbientCube()) { - cpuAccelerationStructure_.AsyncProcessFull(*this, farAmbientCubeBufferMemory_.get(), false); + cpuAccelerationStructure_.AsyncProcessFull(*this, ambientArenaBufferMemory_.get(), false); } } @@ -810,83 +764,25 @@ namespace Assets // all gpu device address gpuScene_.Camera = NextEngine::GetInstance()->GetRenderer().UniformBuffers()[imageIndex].Buffer().GetDeviceAddress(); - gpuScene_.Nodes = nodeMatrixBuffer_->GetDeviceAddress(); - gpuScene_.Materials = materialBuffer_->GetDeviceAddress(); + gpuScene_.SceneDynamicBase = sceneDynamicBuffer_->GetDeviceAddress(); gpuScene_.Offsets = offsetBuffer_->GetDeviceAddress(); gpuScene_.Indices = primAddressBuffer_->GetDeviceAddress(); gpuScene_.Vertices = vertexBuffer_->GetDeviceAddress(); - gpuScene_.VerticesSimple = simpleVertexBuffer_->GetDeviceAddress(); gpuScene_.Reorders = reorderBuffer_->GetDeviceAddress(); - gpuScene_.Lights = lightBuffer_->GetDeviceAddress(); - gpuScene_.Cubes = ambientCubeBuffer_->GetDeviceAddress(); - gpuScene_.CubesPong = ambientCubePongBuffer_->GetDeviceAddress(); - gpuScene_.Voxels = farAmbientCubeBuffer_->GetDeviceAddress(); - gpuScene_.Pages = pageIndexBuffer_->GetDeviceAddress(); - gpuScene_.HDRSHs = hdrSHBuffer_->GetDeviceAddress(); gpuScene_.IndirectDrawCommands = indirectDrawBuffer_->GetDeviceAddress(); - gpuScene_.GPUDrivenStats = gpuDrivenStatsBuffer_->GetDeviceAddress(); + gpuScene_.AmbientBase = ambientArenaBuffer_->GetDeviceAddress(); gpuScene_.TLAS = NextEngine::GetInstance()->TryGetGPUAccelerationStructureAddress(); gpuScene_.SkinWeights = skinWeightBuffer_->GetDeviceAddress(); gpuScene_.SkinJoints = skinJointBuffer_->GetDeviceAddress(); gpuScene_.SkinnedVertices = skinnedVerticesAddr_; - gpuScene_.SkinnedVerticesSimple = skinnedVerticesSimpleAddr_; gpuScene_.JointMatrices = jointMatricesAddr_; gpuScene_.SwapChainIndex = imageIndex; - UpdateGPUSceneBuffer(imageIndex, gpuScene_); return gpuScene_; } - void Scene::UpdateGPUSceneBuffer(uint32_t imageIndex, const Assets::GPUScene& gpuScene) const - { - if (gpuSceneBufferMemories_.empty()) - { - return; - } - - const uint32_t bufferIndex = imageIndex % static_cast(gpuSceneBufferMemories_.size()); - void* data = gpuSceneBufferMemories_[bufferIndex]->Map(0, sizeof(Assets::GPUScene)); - std::memcpy(data, &gpuScene, sizeof(Assets::GPUScene)); - gpuSceneBufferMemories_[bufferIndex]->Unmap(); - } - - void Scene::CmdUpdateGPUSceneBuffer( - VkCommandBuffer commandBuffer, uint32_t imageIndex, const Assets::GPUScene& gpuScene) const - { - static_assert(sizeof(Assets::GPUScene) <= 65536); - static_assert(sizeof(Assets::GPUScene) % 4 == 0); - - if (gpuSceneBuffers_.empty()) - { - return; - } - - const uint32_t bufferIndex = imageIndex % static_cast(gpuSceneBuffers_.size()); - const Vulkan::Buffer& gpuSceneBuffer = *gpuSceneBuffers_[bufferIndex]; - vkCmdUpdateBuffer(commandBuffer, gpuSceneBuffer.Handle(), 0, sizeof(Assets::GPUScene), &gpuScene); - - VkBufferMemoryBarrier gpuSceneBarrier{}; - gpuSceneBarrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; - gpuSceneBarrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - gpuSceneBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; - gpuSceneBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - gpuSceneBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - gpuSceneBarrier.buffer = gpuSceneBuffer.Handle(); - gpuSceneBarrier.offset = 0; - gpuSceneBarrier.size = sizeof(Assets::GPUScene); - - vkCmdPipelineBarrier(commandBuffer, - VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, - 0, 0, nullptr, 1, &gpuSceneBarrier, 0, nullptr); - } - - const Vulkan::Buffer& Scene::GPUSceneBuffer(uint32_t imageIndex) const - { - return *gpuSceneBuffers_[imageIndex % static_cast(gpuSceneBuffers_.size())]; - } - void Scene::PlayAllTracks() { for (auto& track : tracks_) @@ -897,7 +793,7 @@ namespace Assets void Scene::MarkEnvDirty() { - // cpuAccelerationStructure_.AsyncProcessFull(*this, farAmbientCubeBufferMemory_.get(), true); + // cpuAccelerationStructure_.AsyncProcessFull(*this, ambientArenaBufferMemory_.get(), true); // cpuAccelerationStructure_.GenShadowMap(*this); } @@ -1064,7 +960,7 @@ namespace Assets NextEngine::GetInstance()->GetTotalFrames() % 30 == 0) { const bool voxelUploadCompleted = cpuAccelerationStructure_.Tick( - *this, ambientCubeBufferMemory_.get(), farAmbientCubeBufferMemory_.get(), pageIndexBufferMemory_.get()); + *this, ambientArenaBufferMemory_.get(), ambientArenaBufferMemory_.get(), ambientArenaBufferMemory_.get()); if (voxelUploadCompleted && NextEngine::GetInstance()->GetUserSettings().UseGpuAmbientCubeSdf) { RequestGpuDistanceFieldRebuild(); @@ -1072,7 +968,7 @@ namespace Assets if (sceneDirtyForCpuAS_ && !cpuAccelerationStructure_.HasPendingWork()) { - if (cpuAccelerationStructure_.AsyncProcessFull(*this, farAmbientCubeBufferMemory_.get(), true)) + if (cpuAccelerationStructure_.AsyncProcessFull(*this, ambientArenaBufferMemory_.get(), true)) { sceneDirtyForCpuAS_ = false; } @@ -1091,10 +987,10 @@ namespace Assets gpuMaterials_.push_back(material.gpuMaterial_); } - Material* data = - reinterpret_cast(materialBufferMemory_->Map(0, sizeof(Material) * gpuMaterials_.size())); + Material* data = reinterpret_cast(sceneDynamicBufferMemory_->Map( + Assets::GPU_SCENE_DYNAMIC_MATERIALS_OFFSET, sizeof(Material) * gpuMaterials_.size())); std::memcpy(data, gpuMaterials_.data(), gpuMaterials_.size() * sizeof(Material)); - materialBufferMemory_->Unmap(); + sceneDynamicBufferMemory_->Unmap(); NextEngine::GetInstance()->SetProgressiveRendering(false, false); } @@ -1103,12 +999,13 @@ namespace Assets { GPUDrivenStat zero{}; // read back gpu driven stats - const auto data = gpuDrivenStatsBuffer_Memory_->Map(0, sizeof(Assets::GPUDrivenStat)); + const auto data = sceneDynamicBufferMemory_->Map( + Assets::GPU_SCENE_DYNAMIC_GPU_DRIVEN_STATS_OFFSET, sizeof(Assets::GPUDrivenStat)); // download GPUDrivenStat* gpuData = static_cast(data); std::memcpy(&gpuDrivenStat_, gpuData, sizeof(GPUDrivenStat)); std::memcpy(gpuData, &zero, sizeof(GPUDrivenStat)); // reset to zero - gpuDrivenStatsBuffer_Memory_->Unmap(); + sceneDynamicBufferMemory_->Unmap(); // if mat dirty, update @@ -1127,9 +1024,10 @@ namespace Assets if (shData.size() > 0) { SphericalHarmonics* data = reinterpret_cast( - hdrSHBufferMemory_->Map(0, sizeof(SphericalHarmonics) * shData.size())); + sceneDynamicBufferMemory_->Map( + Assets::GPU_SCENE_DYNAMIC_HDRSHS_OFFSET, sizeof(SphericalHarmonics) * shData.size())); std::memcpy(data, shData.data(), shData.size() * sizeof(SphericalHarmonics)); - hdrSHBufferMemory_->Unmap(); + sceneDynamicBufferMemory_->Unmap(); } } @@ -1202,9 +1100,10 @@ namespace Assets { SCOPED_CPU_TIMER("upload nodeproxy"); NodeProxy* data = reinterpret_cast( - nodeMatrixBufferMemory_->Map(0, sizeof(NodeProxy) * nodeProxys.size())); + sceneDynamicBufferMemory_->Map( + Assets::GPU_SCENE_DYNAMIC_NODES_OFFSET, sizeof(NodeProxy) * nodeProxys.size())); std::memcpy(data, nodeProxys.data(), nodeProxys.size() * sizeof(NodeProxy)); - nodeMatrixBufferMemory_->Unmap(); + sceneDynamicBufferMemory_->Unmap(); } return true; } @@ -1331,11 +1230,9 @@ namespace Assets } } - void Scene::SetSkinningBuffers(VkDeviceAddress skinnedVertices, VkDeviceAddress skinnedVerticesSimple, - VkDeviceAddress jointMatrices) + void Scene::SetSkinningBuffers(VkDeviceAddress skinnedVertices, VkDeviceAddress jointMatrices) { skinnedVerticesAddr_ = skinnedVertices; - skinnedVerticesSimpleAddr_ = skinnedVerticesSimple; jointMatricesAddr_ = jointMatrices; } diff --git a/src/Assets/Core/Scene.hpp b/src/Assets/Core/Scene.hpp index 5329ee65..294e8a47 100644 --- a/src/Assets/Core/Scene.hpp +++ b/src/Assets/Core/Scene.hpp @@ -59,10 +59,6 @@ namespace Assets // void RebuildBVH(); const Assets::GPUScene& FetchGPUScene(const uint32_t imageIndex) const; - void UpdateGPUSceneBuffer(uint32_t imageIndex, const Assets::GPUScene& gpuScene) const; - void CmdUpdateGPUSceneBuffer(VkCommandBuffer commandBuffer, uint32_t imageIndex, - const Assets::GPUScene& gpuScene) const; - const Vulkan::Buffer& GPUSceneBuffer(uint32_t imageIndex) const; std::vector>& Nodes() { return nodes_; } const std::vector>& Nodes() const { return nodes_; } const std::vector& Models() const { return models_; } @@ -72,12 +68,11 @@ namespace Assets std::vector& Lights() { return lights_; } const std::vector& Lights() const { return lights_; } const Vulkan::Buffer& VertexBuffer() const { return *vertexBuffer_; } - const Vulkan::Buffer& SimpleVertexBuffer() const { return *simpleVertexBuffer_; } const Vulkan::Buffer& IndexBuffer() const { return *indexBuffer_; } - const Vulkan::Buffer& MaterialBuffer() const { return *materialBuffer_; } + const Vulkan::Buffer& MaterialBuffer() const { return *sceneDynamicBuffer_; } const Vulkan::Buffer& OffsetsBuffer() const { return *offsetBuffer_; } const Vulkan::Buffer& LightBuffer() const { return *lightBuffer_; } - const Vulkan::Buffer& NodeMatrixBuffer() const { return *nodeMatrixBuffer_; } + const Vulkan::Buffer& NodeMatrixBuffer() const { return *sceneDynamicBuffer_; } const Vulkan::Buffer& IndirectDrawBuffer() const { return *indirectDrawBuffer_; } const Vulkan::Buffer& ReorderBuffer() const { return *reorderBuffer_; } const Vulkan::Buffer& PrimAddressBuffer() const { return *primAddressBuffer_; } @@ -160,21 +155,25 @@ namespace Assets void RestoreNodes(const std::vector& entries, const std::shared_ptr& parent, const std::shared_ptr& root); - void SetSkinningBuffers(VkDeviceAddress skinnedVertices, VkDeviceAddress skinnedVerticesSimple, - VkDeviceAddress jointMatrices); + void SetSkinningBuffers(VkDeviceAddress skinnedVertices, VkDeviceAddress jointMatrices); // Assets::RayCastResult RayCastInCPU(glm::vec3 rayOrigin, glm::vec3 rayDir); - Vulkan::Buffer& AmbientCubeBuffer() const { return *ambientCubeBuffer_; } - Vulkan::Buffer& AmbientCubePongBuffer() const { return *ambientCubePongBuffer_; } - Vulkan::Buffer& AmbientCubeSdfScratchBuffer() const { return *ambientCubeSdfScratchBuffer_; } - Vulkan::Buffer& FarAmbientCubeBuffer() const { return *farAmbientCubeBuffer_; } - Vulkan::Buffer& PageIndexBuffer() const { return *pageIndexBuffer_; } + Vulkan::Buffer& AmbientCubeBuffer() const { return *ambientArenaBuffer_; } + Vulkan::Buffer& AmbientCubePongBuffer() const { return *ambientArenaBuffer_; } + Vulkan::Buffer& AmbientCubeSdfScratchBuffer() const { return *ambientArenaBuffer_; } + Vulkan::Buffer& FarAmbientCubeBuffer() const { return *ambientArenaBuffer_; } + Vulkan::Buffer& PageIndexBuffer() const { return *ambientArenaBuffer_; } + size_t AmbientCubesByteOffset() const { return GPU_SCENE_AMBIENT_CUBES_OFFSET; } + size_t AmbientVoxelsByteOffset() const { return GPU_SCENE_AMBIENT_VOXELS_OFFSET; } + size_t AmbientPagesByteOffset() const { return GPU_SCENE_AMBIENT_PAGES_OFFSET; } + size_t AmbientCubesPongByteOffset() const { return GPU_SCENE_AMBIENT_CUBES_PONG_OFFSET; } + size_t AmbientSdfScratchByteOffset() const { return GPU_SCENE_AMBIENT_SDF_SCRATCH_OFFSET; } Vulkan::Buffer& SkinWeightBuffer() const { return *skinWeightBuffer_; } Vulkan::Buffer& SkinJointBuffer() const { return *skinJointBuffer_; } - Vulkan::Buffer& HDRSHBuffer() const { return *hdrSHBuffer_; } + Vulkan::Buffer& HDRSHBuffer() const { return *sceneDynamicBuffer_; } TextureImage& ShadowMap() const { return *cpuShadowMap_; } @@ -206,9 +205,6 @@ namespace Assets std::unique_ptr vertexBuffer_; std::unique_ptr vertexBufferMemory_; - std::unique_ptr simpleVertexBuffer_; - std::unique_ptr simpleVertexBufferMemory_; - std::unique_ptr indexBuffer_; std::unique_ptr indexBufferMemory_; @@ -218,8 +214,8 @@ namespace Assets std::unique_ptr primAddressBuffer_; std::unique_ptr primAddressBufferMemory_; - std::unique_ptr materialBuffer_; - std::unique_ptr materialBufferMemory_; + std::unique_ptr sceneDynamicBuffer_; + std::unique_ptr sceneDynamicBufferMemory_; std::unique_ptr offsetBuffer_; std::unique_ptr offsetBufferMemory_; @@ -227,26 +223,11 @@ namespace Assets std::unique_ptr lightBuffer_; std::unique_ptr lightBufferMemory_; - std::unique_ptr nodeMatrixBuffer_; - std::unique_ptr nodeMatrixBufferMemory_; - std::unique_ptr indirectDrawBuffer_; std::unique_ptr indirectDrawBufferMemory_; - std::unique_ptr ambientCubeBuffer_; - std::unique_ptr ambientCubeBufferMemory_; - - // Single-cascade snapshot used as read-side for propagation-based ambient cube bake. - std::unique_ptr ambientCubePongBuffer_; - std::unique_ptr ambientCubePongBufferMemory_; - std::unique_ptr ambientCubeSdfScratchBuffer_; - std::unique_ptr ambientCubeSdfScratchBufferMemory_; - - std::unique_ptr farAmbientCubeBuffer_; - std::unique_ptr farAmbientCubeBufferMemory_; - - std::unique_ptr pageIndexBuffer_; - std::unique_ptr pageIndexBufferMemory_; + std::unique_ptr ambientArenaBuffer_; + std::unique_ptr ambientArenaBufferMemory_; std::unique_ptr skinWeightBuffer_; std::unique_ptr skinWeightBufferMemory_; @@ -254,12 +235,6 @@ namespace Assets std::unique_ptr skinJointBuffer_; std::unique_ptr skinJointBufferMemory_; - std::unique_ptr hdrSHBuffer_; - std::unique_ptr hdrSHBufferMemory_; - - std::unique_ptr gpuDrivenStatsBuffer_; - std::unique_ptr gpuDrivenStatsBuffer_Memory_; - std::unique_ptr cpuShadowMap_; uint32_t lightCount_{}; @@ -289,15 +264,12 @@ namespace Assets Assets::GPUDrivenStat gpuDrivenStat_; mutable Assets::GPUScene gpuScene_; - mutable std::vector> gpuSceneBuffers_; - mutable std::vector> gpuSceneBufferMemories_; glm::vec3 sceneAABBMin_{FLT_MAX, FLT_MAX, FLT_MAX}; glm::vec3 sceneAABBMax_{-FLT_MAX, -FLT_MAX, -FLT_MAX}; std::vector> cachedMeshShapes_; VkDeviceAddress skinnedVerticesAddr_ = 0; - VkDeviceAddress skinnedVerticesSimpleAddr_ = 0; VkDeviceAddress jointMatricesAddr_ = 0; }; } // namespace Assets diff --git a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp index 97cd394a..f1de3dd8 100644 --- a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp +++ b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp @@ -16,39 +16,6 @@ namespace Vulkan::PipelineCommon { - namespace - { - std::unique_ptr CreateGPUSceneDescriptorSetManager( - const Device& device, - const Assets::Scene& scene, - uint32_t setCount, - VkShaderStageFlags stageFlags) - { - const std::vector descriptorBindings = - { - {0, 1, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, stageFlags}, - }; - - auto descriptorSetManager = std::make_unique(device, descriptorBindings, setCount); - auto& descriptorSets = descriptorSetManager->DescriptorSets(); - - for (uint32_t i = 0; i < setCount; ++i) - { - VkDescriptorBufferInfo gpuSceneBufferInfo = {}; - gpuSceneBufferInfo.buffer = scene.GPUSceneBuffer(i).Handle(); - gpuSceneBufferInfo.range = sizeof(Assets::GPUScene); - - const std::vector descriptorWrites = - { - descriptorSets.Bind(i, 0, gpuSceneBufferInfo), - }; - descriptorSets.UpdateDescriptors(i, descriptorWrites); - } - - return descriptorSetManager; - } - } - ZeroBindWithTLASPipeline::ZeroBindWithTLASPipeline( const SwapChain& swapChain, const char* shaderfile, @@ -56,8 +23,11 @@ namespace Vulkan::PipelineCommon { // Create descriptor pool/sets. const auto& device = swapChain.Device(); - descriptorSetManager_ = CreateGPUSceneDescriptorSetManager( - device, scene, static_cast(swapChain.Images().size()), VK_SHADER_STAGE_COMPUTE_BIT); + + VkPushConstantRange pushConstantRange{}; + pushConstantRange.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; + pushConstantRange.offset = 0; + pushConstantRange.size = sizeof(Assets::GPUScene); #if ANDROID std::vector descriptorBindings = @@ -84,13 +54,12 @@ namespace Vulkan::PipelineCommon std::vector managers = { &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), - descriptorSetManager_.get(), #if ANDROID descriptorSetManager_.get() #endif }; - pipelineLayout_.reset(new class PipelineLayout(device, managers, static_cast(swapChain.Images().size()))); + pipelineLayout_.reset(new class PipelineLayout(device, managers, 1, &pushConstantRange, 1)); const ShaderModule denoiseShader(device, shaderfile); @@ -107,8 +76,9 @@ namespace Vulkan::PipelineCommon uint32_t imageIndex) { vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, Handle()); - scene.FetchGPUScene(imageIndex); - PipelineLayout().BindDescriptorSets(commandBuffer, imageIndex); + PipelineLayout().BindDescriptorSets(commandBuffer, 0); + vkCmdPushConstants(commandBuffer, PipelineLayout().Handle(), VK_SHADER_STAGE_COMPUTE_BIT, + 0, sizeof(Assets::GPUScene), &(scene.FetchGPUScene(imageIndex))); } ZeroBindPipeline::ZeroBindPipeline( @@ -118,15 +88,17 @@ namespace Vulkan::PipelineCommon { // Create descriptor pool/sets. const auto& device = swapChain.Device(); - descriptorSetManager_ = CreateGPUSceneDescriptorSetManager( - device, scene, static_cast(swapChain.Images().size()), VK_SHADER_STAGE_COMPUTE_BIT); + + VkPushConstantRange pushConstantRange{}; + pushConstantRange.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; + pushConstantRange.offset = 0; + pushConstantRange.size = sizeof(Assets::GPUScene); std::vector managers = { &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), - descriptorSetManager_.get() }; - pipelineLayout_.reset(new class PipelineLayout(device, managers, static_cast(swapChain.Images().size()))); + pipelineLayout_.reset(new class PipelineLayout(device, managers, 1, &pushConstantRange, 1)); const ShaderModule denoiseShader(device, shaderfile); @@ -142,8 +114,9 @@ namespace Vulkan::PipelineCommon void ZeroBindPipeline::BindPipeline(VkCommandBuffer commandBuffer, const Assets::Scene& scene, uint32_t imageIndex) { vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, Handle()); - scene.FetchGPUScene(imageIndex); - PipelineLayout().BindDescriptorSets(commandBuffer, imageIndex); + PipelineLayout().BindDescriptorSets(commandBuffer, 0); + vkCmdPushConstants(commandBuffer, PipelineLayout().Handle(), VK_SHADER_STAGE_COMPUTE_BIT, + 0, sizeof(Assets::GPUScene), &(scene.FetchGPUScene(imageIndex))); } ZeroBindCustomPushConstantPipeline::ZeroBindCustomPushConstantPipeline(const SwapChain& swapChain, @@ -278,15 +251,17 @@ namespace Vulkan::PipelineCommon colorBlending.blendConstants[2] = 0.0f; // Optional colorBlending.blendConstants[3] = 0.0f; // Optional - descriptorSetManager_ = CreateGPUSceneDescriptorSetManager( - device, scene, static_cast(swapChain.Images().size()), VK_SHADER_STAGE_VERTEX_BIT); std::vector managers = { &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), - descriptorSetManager_.get() }; + VkPushConstantRange pushConstantRange{}; + pushConstantRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushConstantRange.offset = 0; + pushConstantRange.size = sizeof(Assets::GPUScene); + // Create pipeline layout and render pass. - pipelineLayout_.reset(new class PipelineLayout(device, managers, static_cast(swapChain.Images().size()))); + pipelineLayout_.reset(new class PipelineLayout(device, managers, 1, &pushConstantRange, 1)); renderPass_.reset(new class RenderPass(swapChain, VK_FORMAT_R32_UINT, depthBuffer, VK_ATTACHMENT_LOAD_OP_CLEAR, VK_ATTACHMENT_LOAD_OP_CLEAR)); renderPass_->SetDebugName("Visibility Render Pass"); // Load shaders. diff --git a/src/Rendering/RayTraceBaseRenderer.cpp b/src/Rendering/RayTraceBaseRenderer.cpp index 641b8433..000f2c1e 100644 --- a/src/Rendering/RayTraceBaseRenderer.cpp +++ b/src/Rendering/RayTraceBaseRenderer.cpp @@ -268,7 +268,9 @@ namespace Vulkan::RayTracing const uint32_t cascadeBaseOffset = cascadeIndex * static_cast(perCascadeCount); VkBuffer cubeBuffer = GetScene().AmbientCubeBuffer().Handle(); VkBuffer pongBuffer = GetScene().AmbientCubePongBuffer().Handle(); - const VkDeviceSize cascadeByteOffset = static_cast(cascadeBaseOffset) * sizeof(Assets::AmbientCube); + const VkDeviceSize cascadeByteOffset = + GetScene().AmbientCubesByteOffset() + static_cast(cascadeBaseOffset) * sizeof(Assets::AmbientCube); + const VkDeviceSize pongByteOffset = GetScene().AmbientCubesPongByteOffset(); const VkDeviceSize cascadeByteSize = static_cast(perCascadeCount) * sizeof(Assets::AmbientCube); VkBufferMemoryBarrier preCopyBarrier{}; @@ -286,7 +288,7 @@ namespace Vulkan::RayTracing VkBufferCopy copyRegion{}; copyRegion.srcOffset = cascadeByteOffset; - copyRegion.dstOffset = 0; + copyRegion.dstOffset = pongByteOffset; copyRegion.size = cascadeByteSize; vkCmdCopyBuffer(commandBuffer, cubeBuffer, pongBuffer, 1, ©Region); @@ -297,7 +299,7 @@ namespace Vulkan::RayTracing postCopyBarriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; postCopyBarriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; postCopyBarriers[0].buffer = pongBuffer; - postCopyBarriers[0].offset = 0; + postCopyBarriers[0].offset = pongByteOffset; postCopyBarriers[0].size = cascadeByteSize; postCopyBarriers[1].sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; postCopyBarriers[1].srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; @@ -318,7 +320,8 @@ namespace Vulkan::RayTracing gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation ? 1u : 0u; - GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, directLightGenPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, dispatchGroupCount, 1, 1); } } @@ -370,15 +373,15 @@ namespace Vulkan::RayTracing BottomLevelGeometry geometries; VkDeviceAddress vertexAddr = 0; - if (hasSkin && skinnedSimpleVertexBuffer_) + if (hasSkin && skinnedVertexBuffer_) { - vertexAddr = skinnedSimpleVertexBuffer_->GetDeviceAddress(); + vertexAddr = skinnedVertexBuffer_->GetDeviceAddress(); } geometries.AddGeometryTriangles(scene, vertexOffset, vertexCount, indexOffset, indexCount, true, vertexAddr); bottomAs_.emplace_back(Device().GetDeviceProcedures(), *rayTracingProperties_, geometries); - vertexOffset += vertexCount * sizeof(short) * 4; + vertexOffset += vertexCount * sizeof(Assets::GPUVertex); indexOffset += indexCount * sizeof(uint32_t); aabbOffset += sizeof(VkAabbPositionsKHR); } diff --git a/src/Rendering/VulkanBaseRenderer.cpp b/src/Rendering/VulkanBaseRenderer.cpp index 5051a571..c77ef1dd 100644 --- a/src/Rendering/VulkanBaseRenderer.cpp +++ b/src/Rendering/VulkanBaseRenderer.cpp @@ -434,6 +434,7 @@ namespace Vulkan const VkDeviceSize fullAmbientCubeAllocationSize = static_cast(Assets::CUBE_CASCADE_MAX) * perCascadeCount * (sizeof(Assets::VoxelData) + sizeof(Assets::AmbientCube)) + + static_cast(Assets::ACGI_PAGE_COUNT) * Assets::ACGI_PAGE_COUNT * sizeof(Assets::PageIndex) + perCascadeCount * (sizeof(Assets::AmbientCube) + sizeof(glm::u32vec4)); fullAmbientCubeBudget_ = largestDeviceLocalHeapSize >= fullAmbientCubeAllocationSize; if (!fullAmbientCubeBudget_) @@ -829,8 +830,6 @@ namespace Vulkan skinnedVertexBuffer_.reset(); skinnedVertexBufferMemory_.reset(); - skinnedSimpleVertexBuffer_.reset(); - skinnedSimpleVertexBufferMemory_.reset(); jointMatricesBuffer_.reset(); jointMatricesBufferMemory_.reset(); @@ -905,13 +904,6 @@ namespace Vulkan currentSkinnedVertexBufferSize_ = (uint32_t)requiredVertexSize; } - size_t requiredSimpleVertexSize = vertCount * sizeof(short) * 4; - if (!skinnedSimpleVertexBuffer_ || currentSkinnedSimpleVertexBufferSize_ < requiredSimpleVertexSize) - { - Vulkan::BufferUtil::CreateDeviceBufferLocal(*commandPool_, "SkinnedVerticesSimple", flags | rtxFlags, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, requiredSimpleVertexSize, skinnedSimpleVertexBuffer_, skinnedSimpleVertexBufferMemory_); - currentSkinnedSimpleVertexBufferSize_ = (uint32_t)requiredSimpleVertexSize; - } - uint32_t totalJoints = 0; for (auto& node : scene.Nodes()) { @@ -978,9 +970,9 @@ namespace Vulkan auto& scene = GetScene(); if (skinnedVertexBuffer_) { - scene.SetSkinningBuffers(skinnedVertexBuffer_->GetDeviceAddress(), skinnedSimpleVertexBuffer_->GetDeviceAddress(), jointMatricesBuffer_ ? jointMatricesBuffer_->GetDeviceAddress() : 0); + scene.SetSkinningBuffers(skinnedVertexBuffer_->GetDeviceAddress(), jointMatricesBuffer_ ? jointMatricesBuffer_->GetDeviceAddress() : 0); } else { - scene.SetSkinningBuffers(0, 0, 0); + scene.SetSkinningBuffers(0, 0); } skinningPipeline_->BindPipeline(commandBuffer, scene, imageIndex); @@ -1023,7 +1015,8 @@ namespace Vulkan gpuScene.custom_data_1 = vertexOffset; gpuScene.custom_data_2 = vertexCount; - scene.CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, skinningPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); uint32_t groupCount = (vertexCount + 63) / 64; vkCmdDispatch(commandBuffer, groupCount, 1, 1); } @@ -1154,9 +1147,12 @@ namespace Vulkan const VkBuffer indexBuffer = scene.IndexBuffer().Handle(); vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, visibilityPipeline_->Handle()); - scene.FetchGPUScene(imageIndex); + const Assets::GPUScene& gpuScene = scene.FetchGPUScene(imageIndex); visibilityPipeline_->PipelineLayout().BindDescriptorSets( - commandBuffer, imageIndex, VK_PIPELINE_BIND_POINT_GRAPHICS); + commandBuffer, 0, VK_PIPELINE_BIND_POINT_GRAPHICS); + vkCmdPushConstants(commandBuffer, visibilityPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT32); vkCmdDrawIndexedIndirect(commandBuffer, scene.IndirectDrawBuffer().Handle(), 0, @@ -1755,7 +1751,8 @@ namespace Vulkan gpuScene.custom_data_1 = 0; gpuScene.custom_data_2 = 0; - GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, clearAmbientCubeCachePipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, groupCount, 1, 1); VkBufferMemoryBarrier barriers[2]{}; @@ -1765,11 +1762,12 @@ namespace Vulkan barriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; barriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; barriers[0].buffer = GetScene().AmbientCubeBuffer().Handle(); - barriers[0].offset = 0; + barriers[0].offset = GetScene().AmbientCubesByteOffset(); barriers[0].size = static_cast(totalCubeCount) * sizeof(Assets::AmbientCube); barriers[1] = barriers[0]; barriers[1].buffer = GetScene().FarAmbientCubeBuffer().Handle(); + barriers[1].offset = GetScene().AmbientVoxelsByteOffset(); barriers[1].size = static_cast(totalCubeCount) * sizeof(Assets::VoxelData); vkCmdPipelineBarrier(commandBuffer, @@ -1792,7 +1790,9 @@ namespace Vulkan VkBuffer cubeBuffer = GetScene().AmbientCubeBuffer().Handle(); VkBuffer pongBuffer = GetScene().AmbientCubePongBuffer().Handle(); - const VkDeviceSize cascadeByteOffset = static_cast(cascadeBaseOffset) * sizeof(Assets::AmbientCube); + const VkDeviceSize cascadeByteOffset = + GetScene().AmbientCubesByteOffset() + static_cast(cascadeBaseOffset) * sizeof(Assets::AmbientCube); + const VkDeviceSize pongByteOffset = GetScene().AmbientCubesPongByteOffset(); const VkDeviceSize cascadeByteSize = static_cast(perCascadeCount) * sizeof(Assets::AmbientCube); VkBufferMemoryBarrier preCopyBarrier{}; @@ -1810,7 +1810,7 @@ namespace Vulkan VkBufferCopy copyRegion{}; copyRegion.srcOffset = cascadeByteOffset; - copyRegion.dstOffset = 0; + copyRegion.dstOffset = pongByteOffset; copyRegion.size = cascadeByteSize; vkCmdCopyBuffer(commandBuffer, cubeBuffer, pongBuffer, 1, ©Region); @@ -1821,7 +1821,7 @@ namespace Vulkan postCopyBarriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; postCopyBarriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; postCopyBarriers[0].buffer = pongBuffer; - postCopyBarriers[0].offset = 0; + postCopyBarriers[0].offset = pongByteOffset; postCopyBarriers[0].size = cascadeByteSize; postCopyBarriers[1].sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; postCopyBarriers[1].srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; @@ -1842,7 +1842,8 @@ namespace Vulkan gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = 0; - GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, propagationAmbientCubeGenPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier propagationToInjectionBarrier{}; @@ -1860,7 +1861,8 @@ namespace Vulkan injectAmbientCubeGenPipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); - GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, injectAmbientCubeGenPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier postInjectionBarrier{}; @@ -1897,11 +1899,14 @@ namespace Vulkan VkBuffer seedBufferB = GetScene().AmbientCubeSdfScratchBuffer().Handle(); const VkDeviceSize cascadeByteSize = static_cast(perCascadeCount) * sizeof(Assets::VoxelData); const VkDeviceSize seedByteSize = static_cast(perCascadeCount) * sizeof(glm::u32vec4); + const VkDeviceSize seedAByteOffset = GetScene().AmbientCubesPongByteOffset(); + const VkDeviceSize seedBByteOffset = GetScene().AmbientSdfScratchByteOffset(); for (uint32_t cascadeIndex = 0; cascadeIndex < cascadeCount; ++cascadeIndex) { const uint32_t cascadeBaseOffset = cascadeIndex * static_cast(perCascadeCount); - const VkDeviceSize cascadeByteOffset = static_cast(cascadeBaseOffset) * sizeof(Assets::VoxelData); + const VkDeviceSize cascadeByteOffset = + GetScene().AmbientVoxelsByteOffset() + static_cast(cascadeBaseOffset) * sizeof(Assets::VoxelData); VkBufferMemoryBarrier preSdfBarrier{}; preSdfBarrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; @@ -1920,10 +1925,10 @@ namespace Vulkan gpuScene.custom_data_0 = cascadeBaseOffset; gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = 0; - gpuScene.SkinnedVerticesSimple = GetScene().AmbientCubeSdfScratchBuffer().GetDeviceAddress(); distanceFieldInitPipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); - GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, distanceFieldInitPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier initBarrier{}; @@ -1933,7 +1938,7 @@ namespace Vulkan initBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; initBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; initBarrier.buffer = seedBufferA; - initBarrier.offset = 0; + initBarrier.offset = seedAByteOffset; initBarrier.size = seedByteSize; vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, @@ -1946,7 +1951,8 @@ namespace Vulkan gpuScene.custom_data_1 = passParity; gpuScene.custom_data_2 = step; - GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, distanceFieldJumpPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier jumpBarriers[2]{}; @@ -1956,10 +1962,11 @@ namespace Vulkan jumpBarriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; jumpBarriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; jumpBarriers[0].buffer = seedBufferA; - jumpBarriers[0].offset = 0; + jumpBarriers[0].offset = seedAByteOffset; jumpBarriers[0].size = seedByteSize; jumpBarriers[1] = jumpBarriers[0]; jumpBarriers[1].buffer = seedBufferB; + jumpBarriers[1].offset = seedBByteOffset; vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 2, jumpBarriers, 0, nullptr); @@ -1973,7 +1980,8 @@ namespace Vulkan distanceFieldResolvePipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); gpuScene.custom_data_1 = passParity - 1; gpuScene.custom_data_2 = 0; - GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, distanceFieldResolvePipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier postResolveBarrier{}; @@ -2033,7 +2041,9 @@ namespace Vulkan const uint32_t cascadeBaseOffset = cascadeIndex * static_cast(perCascadeCount); VkBuffer cubeBuffer = GetScene().AmbientCubeBuffer().Handle(); VkBuffer pongBuffer = GetScene().AmbientCubePongBuffer().Handle(); - const VkDeviceSize cascadeByteOffset = static_cast(cascadeBaseOffset) * sizeof(Assets::AmbientCube); + const VkDeviceSize cascadeByteOffset = + GetScene().AmbientCubesByteOffset() + static_cast(cascadeBaseOffset) * sizeof(Assets::AmbientCube); + const VkDeviceSize pongByteOffset = GetScene().AmbientCubesPongByteOffset(); const VkDeviceSize cascadeByteSize = static_cast(perCascadeCount) * sizeof(Assets::AmbientCube); VkBufferMemoryBarrier preCopyBarrier{}; @@ -2051,7 +2061,7 @@ namespace Vulkan VkBufferCopy copyRegion{}; copyRegion.srcOffset = cascadeByteOffset; - copyRegion.dstOffset = 0; + copyRegion.dstOffset = pongByteOffset; copyRegion.size = cascadeByteSize; vkCmdCopyBuffer(commandBuffer, cubeBuffer, pongBuffer, 1, ©Region); @@ -2062,7 +2072,7 @@ namespace Vulkan postCopyBarriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; postCopyBarriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; postCopyBarriers[0].buffer = pongBuffer; - postCopyBarriers[0].offset = 0; + postCopyBarriers[0].offset = pongByteOffset; postCopyBarriers[0].size = cascadeByteSize; postCopyBarriers[1].sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; postCopyBarriers[1].srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; @@ -2083,7 +2093,8 @@ namespace Vulkan gpuScene.custom_data_1 = cascadeIndex; gpuScene.custom_data_2 = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation ? 1u : 0u; - GetScene().CmdUpdateGPUSceneBuffer(commandBuffer, imageIndex, gpuScene); + vkCmdPushConstants(commandBuffer, softAmbientCubeGenPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, dispatchGroupCount, 1, 1); } } diff --git a/src/Rendering/VulkanBaseRenderer.hpp b/src/Rendering/VulkanBaseRenderer.hpp index 2b5aab3b..3569a846 100644 --- a/src/Rendering/VulkanBaseRenderer.hpp +++ b/src/Rendering/VulkanBaseRenderer.hpp @@ -193,14 +193,10 @@ namespace Vulkan std::unique_ptr skinnedVertexBuffer_; std::unique_ptr skinnedVertexBufferMemory_; - std::unique_ptr skinnedSimpleVertexBuffer_; - std::unique_ptr skinnedSimpleVertexBufferMemory_; - std::unique_ptr jointMatricesBuffer_; std::unique_ptr jointMatricesBufferMemory_; uint32_t currentSkinnedVertexBufferSize_{}; - uint32_t currentSkinnedSimpleVertexBufferSize_{}; uint32_t currentJointMatrixBufferSize_{}; private: diff --git a/src/Runtime/Engine.cpp b/src/Runtime/Engine.cpp index 8a5a0f71..703be4c8 100644 --- a/src/Runtime/Engine.cpp +++ b/src/Runtime/Engine.cpp @@ -117,6 +117,7 @@ namespace const VkDeviceSize fullAmbientCubeAllocationSize = static_cast(Assets::CUBE_CASCADE_MAX) * perCascadeCount * (sizeof(Assets::VoxelData) + sizeof(Assets::AmbientCube)) + + static_cast(Assets::ACGI_PAGE_COUNT) * Assets::ACGI_PAGE_COUNT * sizeof(Assets::PageIndex) + perCascadeCount * (sizeof(Assets::AmbientCube) + sizeof(glm::u32vec4)); return largestDeviceLocalHeapSize >= fullAmbientCubeAllocationSize; } diff --git a/src/Vulkan/RayTracing/BottomLevelGeometry.cpp b/src/Vulkan/RayTracing/BottomLevelGeometry.cpp index c8c762da..df135a94 100644 --- a/src/Vulkan/RayTracing/BottomLevelGeometry.cpp +++ b/src/Vulkan/RayTracing/BottomLevelGeometry.cpp @@ -19,8 +19,8 @@ void BottomLevelGeometry::AddGeometryTriangles( geometry.geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR; geometry.geometry.triangles.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_TRIANGLES_DATA_KHR; geometry.geometry.triangles.pNext = nullptr; - geometry.geometry.triangles.vertexData.deviceAddress = alternativeVertexAddress != 0 ? alternativeVertexAddress : scene.SimpleVertexBuffer().GetDeviceAddress(); - geometry.geometry.triangles.vertexStride = sizeof(short) * 4; + geometry.geometry.triangles.vertexData.deviceAddress = alternativeVertexAddress != 0 ? alternativeVertexAddress : scene.VertexBuffer().GetDeviceAddress(); + geometry.geometry.triangles.vertexStride = sizeof(Assets::GPUVertex); geometry.geometry.triangles.maxVertex = vertexCount; geometry.geometry.triangles.vertexFormat = VK_FORMAT_R16G16B16A16_SFLOAT; geometry.geometry.triangles.indexData.deviceAddress = scene.PrimAddressBuffer().GetDeviceAddress(); @@ -29,7 +29,7 @@ void BottomLevelGeometry::AddGeometryTriangles( geometry.flags = isOpaque ? VK_GEOMETRY_OPAQUE_BIT_KHR : 0; VkAccelerationStructureBuildRangeInfoKHR buildOffsetInfo = {}; - buildOffsetInfo.firstVertex = vertexOffset / (sizeof(short) * 4); + buildOffsetInfo.firstVertex = vertexOffset / sizeof(Assets::GPUVertex); buildOffsetInfo.primitiveOffset = indexOffset; buildOffsetInfo.primitiveCount = indexCount / 3; buildOffsetInfo.transformOffset = 0; From f530bdb6bad2fc075170441ecc93f4126e09c22c Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 00:03:12 +0800 Subject: [PATCH 06/27] Fix GPUScene push constants and wireframe overlay --- TODO.md | 13 ++- assets/shaders/Rast.Wireframe.vert.slang | 45 +++----- assets/shaders/common/BasicTypes.slang | 22 ++-- src/Assets/Core/Scene.cpp | 7 +- .../PipelineCommon/CommonComputePipeline.cpp | 54 +++------ src/Rendering/VulkanBaseRenderer.cpp | 106 ++++++++++-------- src/Rendering/VulkanBaseRenderer.hpp | 5 +- 7 files changed, 120 insertions(+), 132 deletions(-) diff --git a/TODO.md b/TODO.md index ab857516..27e9e0f3 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,15 @@ ## 下一步任务 --[ ] 提交当前修改 --[ ] 确认AmbientCube的改造,目前Voxel的更新和GPU读取是正确的,但AmbientCube感觉没有工作。但hwlightbake在执行 +- [x] 提交当前修改 +- [x] 确认AmbientCube的改造,目前Voxel的更新和GPU读取是正确的,但AmbientCube感觉没有工作。但hwlightbake在执行 +- [ ] 提交目前的修改 +- [ ] 清理上下文 +- [ ] Options下的bool HotReload{true}已经废弃,移除整个选项以及相关无用的逻辑 + +## 待确认任务 +- [x] 恢复wireframe的工作,这里wireframePipeline_,可考虑直接写在imgui绘制前,直接绘制在最终输出之上。不要像之前一样尝试写在RT_DENOISED上 + +## 注意事项 +- AGENT只能修改todo的完成状态,其他都不能修改 ## 里程碑状态 未完成 diff --git a/assets/shaders/Rast.Wireframe.vert.slang b/assets/shaders/Rast.Wireframe.vert.slang index 1bcb9634..4a77e5e8 100644 --- a/assets/shaders/Rast.Wireframe.vert.slang +++ b/assets/shaders/Rast.Wireframe.vert.slang @@ -1,30 +1,4 @@ -public struct UniformBufferObject -{ - public float4x4 ModelView; - public float4x4 Projection; - public float4x4 ModelViewInverse; - public float4x4 ProjectionInverse; - public float4x4 ViewProjection; - public float4x4 PrevViewProjection; - public float4x4 ViewProjectionUnJit; - public float4x4 PrevViewProjectionUnJit; -} - -[[vk::binding(0, 0)]] -ConstantBuffer Camera; - -struct PushConsts -{ - float4x4 worldMatrix; -}; - -[[vk::push_constant]] -ConstantBuffer pushConsts; - -struct VertexInput -{ - [[vk::location(0)]] half3 InPosition : POSITION; -}; +import Common; struct VertexOutput { @@ -32,10 +6,21 @@ struct VertexOutput }; [shader("vertex")] -VertexOutput main(VertexInput input) +VertexOutput main(uint vertexIndex: SV_VertexID, uint instanceIndex: SV_InstanceID, uint instanceBase: SV_StartInstanceLocation) { VertexOutput output; - output.position = mul(mul(Camera.ViewProjection, pushConsts.worldMatrix), float4(input.InPosition, 1.0)); + uint absoluteInstanceIdx = instanceIndex + instanceBase; + NodeProxy proxy = Bindless.GetGpuscene().Nodes[absoluteInstanceIdx]; + + GPUVertex* currentVertices = Bindless.GetGpuscene().Vertices; + if (proxy.skinId != 0xFFFFFFFF) + { + currentVertices = Bindless.GetGpuscene().SkinnedVertices; + } + + GPUVertex packedVertex = currentVertices[Bindless.GetGpuscene().Reorders[vertexIndex]]; + output.position = + mul(mul(Bindless.GetGpuscene().Camera[0].ViewProjection, proxy.worldTS), float4(packedVertex.Position_Tx.xyz, 1.0)); output.position.z -= 0.000001f * output.position.w; return output; -} \ No newline at end of file +} diff --git a/assets/shaders/common/BasicTypes.slang b/assets/shaders/common/BasicTypes.slang index 72f406bf..17c36bc6 100644 --- a/assets/shaders/common/BasicTypes.slang +++ b/assets/shaders/common/BasicTypes.slang @@ -311,7 +311,7 @@ public struct ALIGN_16 GPUVertex #ifdef __cplusplus -// 13 addresses + 4 uint params; aligned size stays within 128 bytes for Vulkan push_constant. +// 13 addresses + one reserved address slot + 4 uint params; aligned size is 128 bytes for Vulkan push_constant. public struct ALIGN_16 GPUScene { uint64_t Camera; @@ -328,6 +328,7 @@ public struct ALIGN_16 GPUScene uint64_t SkinJoints; uint64_t SkinnedVertices; uint64_t JointMatrices; + uint64_t ReservedAddress0; uint32_t SwapChainIndex; uint32_t custom_data_0; @@ -401,6 +402,7 @@ public struct ALIGN_8 GPUScene public GPUVertex *SkinnedVertices; public float4x4 *JointMatrices; + public uint64_t ReservedAddress0; public RaytracingAccelerationStructure GetTLAS() { return RaytracingAccelerationStructure(TLAS); @@ -492,37 +494,39 @@ public struct ALIGN_8 GPUScene get { return (uint4*)(IndirectDrawCommands_AmbientBase_Address.y + GPU_SCENE_AMBIENT_SDF_SCRATCH_OFFSET); } } - public uint64_t TLAS; + uint64_t2 TLAS_SkinWeights_Address; public property GPUDrivenStat* GPUDrivenStats { get { return (GPUDrivenStat*)(Camera_SceneDynamicBase_Address.y + GPU_SCENE_DYNAMIC_GPU_DRIVEN_STATS_OFFSET); } } - uint64_t2 SkinWeights_SkinJoints_Address; public property float4* SkinWeights { - get { return (float4*)SkinWeights_SkinJoints_Address.x; } + get { return (float4*)TLAS_SkinWeights_Address.y; } } + + uint64_t2 SkinJoints_SkinnedVertices_Address; public property uint4* SkinJoints { - get { return (uint4*)SkinWeights_SkinJoints_Address.y; } + get { return (uint4*)SkinJoints_SkinnedVertices_Address.x; } } - uint64_t2 SkinnedVertices_JointMatrices_Address; public property GPUVertex* SkinnedVertices { - get { return (GPUVertex*)SkinnedVertices_JointMatrices_Address.x; } + get { return (GPUVertex*)SkinJoints_SkinnedVertices_Address.y; } } + + uint64_t2 JointMatrices_Reserved_Address; public property float4x4* JointMatrices { - get { return (float4x4*)SkinnedVertices_JointMatrices_Address.y; } + get { return (float4x4*)JointMatrices_Reserved_Address.x; } } public RaytracingAccelerationStructure GetTLAS() { #ifdef PLATFORM_ANDROID return BindedTLAS; #else - return RaytracingAccelerationStructure(TLAS); + return RaytracingAccelerationStructure(TLAS_SkinWeights_Address.x); #endif } diff --git a/src/Assets/Core/Scene.cpp b/src/Assets/Core/Scene.cpp index d41aba85..30b2bff1 100644 --- a/src/Assets/Core/Scene.cpp +++ b/src/Assets/Core/Scene.cpp @@ -49,7 +49,11 @@ namespace Assets static_assert(sizeof(Assets::AmbientCube) == Assets::GPU_SCENE_AMBIENT_CUBE_SIZE); static_assert(sizeof(Assets::VoxelData) == Assets::GPU_SCENE_VOXEL_DATA_SIZE); static_assert(sizeof(Assets::PageIndex) == Assets::GPU_SCENE_PAGE_INDEX_SIZE); - static_assert(sizeof(Assets::GPUScene) <= 128); + static_assert(Assets::GPU_SCENE_AMBIENT_PER_CASCADE_COUNT == perAmbientCascadeCount); + static_assert(Assets::GPU_SCENE_AMBIENT_CASCADE_MAX == Assets::CUBE_CASCADE_MAX); + static_assert(Assets::GPU_SCENE_ACGI_PAGE_COUNT == Assets::ACGI_PAGE_COUNT); + static_assert(Assets::GPU_SCENE_AMBIENT_SIZE == AmbientArenaSizeForCascadeCapacity(Assets::CUBE_CASCADE_MAX)); + static_assert(sizeof(Assets::GPUScene) == 128); } void Scene::RegisterReflection() @@ -777,6 +781,7 @@ namespace Assets gpuScene_.SkinJoints = skinJointBuffer_->GetDeviceAddress(); gpuScene_.SkinnedVertices = skinnedVerticesAddr_; gpuScene_.JointMatrices = jointMatricesAddr_; + gpuScene_.ReservedAddress0 = 0; gpuScene_.SwapChainIndex = imageIndex; diff --git a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp index f1de3dd8..3aa954d8 100644 --- a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp +++ b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp @@ -310,16 +310,17 @@ namespace Vulkan::PipelineCommon const bool isWireFrame) : PipelineBase(swapChain) { + (void)uniformBuffers; + (void)scene; + const auto& device = swapChain.Device(); - const auto bindingDescription = Assets::GPUVertex::GetFastBindingDescription(); - const auto attributeDescriptions = Assets::GPUVertex::GetFastAttributeDescriptions(); VkPipelineVertexInputStateCreateInfo vertexInputInfo = {}; vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; - vertexInputInfo.vertexBindingDescriptionCount = 1; - vertexInputInfo.pVertexBindingDescriptions = &bindingDescription; - vertexInputInfo.vertexAttributeDescriptionCount = static_cast(attributeDescriptions.size()); - vertexInputInfo.pVertexAttributeDescriptions = attributeDescriptions.data(); + vertexInputInfo.vertexBindingDescriptionCount = 0; + vertexInputInfo.pVertexBindingDescriptions = nullptr; + vertexInputInfo.vertexAttributeDescriptionCount = 0; + vertexInputInfo.pVertexAttributeDescriptions = nullptr; VkPipelineInputAssemblyStateCreateInfo inputAssembly = {}; inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; @@ -354,7 +355,7 @@ namespace Vulkan::PipelineCommon rasterizer.polygonMode = isWireFrame && physicalDeviceFeatures.fillModeNonSolid ? VK_POLYGON_MODE_LINE : VK_POLYGON_MODE_FILL; rasterizer.lineWidth = 1.0f; - rasterizer.cullMode = VK_CULL_MODE_BACK_BIT; + rasterizer.cullMode = VK_CULL_MODE_NONE; rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; rasterizer.depthBiasEnable = VK_FALSE; rasterizer.depthBiasConstantFactor = 0.0f; // Optional @@ -403,47 +404,18 @@ namespace Vulkan::PipelineCommon colorBlending.blendConstants[2] = 0.0f; // Optional colorBlending.blendConstants[3] = 0.0f; // Optional - // Create descriptor pool/sets. - std::vector descriptorBindings = - { - {0, 1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT}, - {1, 1, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT} + std::vector managers = { + &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), }; - descriptorSetManager_.reset(new DescriptorSetManager(device, descriptorBindings, uniformBuffers.size())); - - auto& descriptorSets = descriptorSetManager_->DescriptorSets(); - - for (uint32_t i = 0; i != swapChain.Images().size(); ++i) - { - // Uniform buffer - VkDescriptorBufferInfo uniformBufferInfo = {}; - uniformBufferInfo.buffer = uniformBuffers[i].Buffer().Handle(); - uniformBufferInfo.range = VK_WHOLE_SIZE; - - // Nodes buffer - VkDescriptorBufferInfo nodesBufferInfo = {}; - nodesBufferInfo.buffer = scene.NodeMatrixBuffer().Handle(); - nodesBufferInfo.range = VK_WHOLE_SIZE; - - const std::vector descriptorWrites = - { - descriptorSets.Bind(i, 0, uniformBufferInfo), - descriptorSets.Bind(i, 1, nodesBufferInfo), - }; - - descriptorSets.UpdateDescriptors(i, descriptorWrites); - } - VkPushConstantRange pushConstantRange{}; - // Push constants will only be accessible at the selected pipeline stages, for this sample it's the vertex shader that reads them pushConstantRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; pushConstantRange.offset = 0; - pushConstantRange.size = 4 * 16; + pushConstantRange.size = sizeof(Assets::GPUScene); // Create pipeline layout and render pass. - pipelineLayout_.reset(new class PipelineLayout(device, descriptorSetManager_->DescriptorSetLayout(), &pushConstantRange, 1)); - renderPass_.reset(new class RenderPass(swapChain, VK_FORMAT_R16G16B16A16_SFLOAT, depthBuffer, VK_ATTACHMENT_LOAD_OP_LOAD, VK_ATTACHMENT_LOAD_OP_LOAD)); + pipelineLayout_.reset(new class PipelineLayout(device, managers, 1, &pushConstantRange, 1)); + renderPass_.reset(new class RenderPass(swapChain, depthBuffer, VK_ATTACHMENT_LOAD_OP_LOAD, VK_ATTACHMENT_LOAD_OP_LOAD)); renderPass_->SetDebugName("Wireframe Render Pass"); // Load shaders. diff --git a/src/Rendering/VulkanBaseRenderer.cpp b/src/Rendering/VulkanBaseRenderer.cpp index c77ef1dd..0789cc18 100644 --- a/src/Rendering/VulkanBaseRenderer.cpp +++ b/src/Rendering/VulkanBaseRenderer.cpp @@ -754,9 +754,13 @@ namespace Vulkan // 公用RenderImages CreateRenderImages(); - // 最简单的fallback pipeline, 也用作 wireframe pipeline - //wireframePipeline_.reset(new class PipelineCommon::GraphicsPipeline(SwapChain(), DepthBuffer(), UniformBuffers(), GetScene(), true)); - //wireframeFramebuffer_.reset(new FrameBuffer(swapChain_->RenderExtent(), GetStorageImage(Assets::Bindless::RT_DENOISED)->GetImageView(), wireframePipeline_->RenderPass())); + wireframePipeline_.reset(new class PipelineCommon::GraphicsPipeline(SwapChain(), DepthBuffer(), UniformBuffers(), GetScene(), true)); + wireframeFrameBuffers_.clear(); + wireframeFrameBuffers_.reserve(swapChain_->ImageViews().size()); + for (const auto& imageView : swapChain_->ImageViews()) + { + wireframeFrameBuffers_.emplace_back(swapChain_->Extent(), *imageView, wireframePipeline_->RenderPass()); + } // 公用Pipeline simpleComposePipeline_.reset( new PipelineCommon::ZeroBindCustomPushConstantPipeline(SwapChain(), "assets/shaders/Process.UpScaleFSR.comp.slang.spv", 20)); @@ -815,8 +819,8 @@ namespace Vulkan screenShotImageMemory_.reset(); screenShotImage_.reset(); commandBuffers_.reset(); - //wireframePipeline_.reset(); - //wireframeFramebuffer_.reset(); + wireframeFrameBuffers_.clear(); + wireframePipeline_.reset(); bufferClearPipeline_.reset(); softAmbientCubeGenPipeline_.reset(); clearAmbientCubeCachePipeline_.reset(); @@ -1575,6 +1579,47 @@ namespace Vulkan currentLogicRenderer_ = type; } + void VulkanBaseRenderer::DrawWireframeOverlay(VkCommandBuffer commandBuffer, uint32_t imageIndex) + { + if (!wireframePipeline_ || imageIndex >= wireframeFrameBuffers_.size()) + { + SwapChain().InsertBarrierToPresent(commandBuffer, imageIndex); + return; + } + + SCOPED_GPU_TIMER("wireframe"); + + ImageMemoryBarrier::FullInsert( + commandBuffer, SwapChain().Images()[imageIndex], + VK_ACCESS_TRANSFER_WRITE_BIT | VK_ACCESS_SHADER_WRITE_BIT, 0, + VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + + VkRenderPassBeginInfo renderPassInfo = {}; + renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + renderPassInfo.renderPass = wireframePipeline_->RenderPass().Handle(); + renderPassInfo.framebuffer = wireframeFrameBuffers_[imageIndex].Handle(); + renderPassInfo.renderArea.offset = {0, 0}; + renderPassInfo.renderArea.extent = SwapChain().Extent(); + renderPassInfo.clearValueCount = 0; + renderPassInfo.pClearValues = nullptr; + + vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE); + { + const auto& scene = GetScene(); + const Assets::GPUScene& gpuScene = scene.FetchGPUScene(imageIndex); + + vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, wireframePipeline_->Handle()); + wireframePipeline_->PipelineLayout().BindDescriptorSets( + commandBuffer, 0, VK_PIPELINE_BIND_POINT_GRAPHICS); + vkCmdPushConstants(commandBuffer, wireframePipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); + vkCmdBindIndexBuffer(commandBuffer, scene.IndexBuffer().Handle(), 0, VK_INDEX_TYPE_UINT32); + vkCmdDrawIndexedIndirect(commandBuffer, scene.IndirectDrawBuffer().Handle(), 0, + scene.GetIndirectDrawBatchCount(), sizeof(VkDrawIndexedIndirectCommand)); + } + vkCmdEndRenderPass(commandBuffer); + } + void VulkanBaseRenderer::Render(VkCommandBuffer commandBuffer, const uint32_t imageIndex) { if (GOption->ReferenceMode) @@ -1652,47 +1697,6 @@ namespace Vulkan logicRenderers_[currentLogicRenderer_]->Render(commandBuffer, imageIndex); } - // if (NextEngine::GetInstance()->GetShowFlags().ShowWireframe) { - // SCOPED_GPU_TIMER("wireframe"); - // - // VkRenderPassBeginInfo renderPassInfo = {}; - // renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; - // renderPassInfo.renderPass = wireframePipeline_->RenderPass().Handle(); - // renderPassInfo.framebuffer = wireframeFramebuffer_->Handle(); - // renderPassInfo.renderArea.offset = {0, 0}; - // renderPassInfo.renderArea.extent = swapChain_->RenderExtent(); - - // vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE); - // { - // auto& scene = GetScene(); - // - // VkDescriptorSet descriptorSets[] = {wireframePipeline_->DescriptorSet(imageIndex)}; - // VkBuffer vertexBuffers[] = {scene.SimpleVertexBuffer().Handle()}; - // const VkBuffer indexBuffer = scene.PrimAddressBuffer().Handle(); - // VkDeviceSize offsets[] = {0}; - // - // vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, wireframePipeline_->Handle()); - // vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, - // wireframePipeline_->PipelineLayout().Handle(), 0, 1, descriptorSets, 0, nullptr); - // vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets); - // vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT32); - // - // // drawcall one by one, old school pipeline only - // for (const auto& node : scene.GetNodeProxys()) - // { - // auto& offset = scene.Offsets()[node.modelId]; - // const auto indexCount = static_cast(offset.indexCount); - // if (indexCount == 0) continue; - // - // glm::mat4 worldMatrix = node.worldTS; - // vkCmdPushConstants(commandBuffer, wireframePipeline_->PipelineLayout().Handle(), - // VK_SHADER_STAGE_VERTEX_BIT,0, sizeof(glm::mat4), &worldMatrix); - // vkCmdDrawIndexed(commandBuffer, indexCount, 1, offset.indexOffset, static_cast(offset.vertexOffset), 0); - // } - // } - // vkCmdEndRenderPass(commandBuffer); - //} - { SCOPED_GPU_TIMER("resolve pass"); @@ -1730,7 +1734,15 @@ namespace Vulkan VK_FILTER_LINEAR); #endif } - SwapChain().InsertBarrierToPresent(commandBuffer, imageIndex); + + if (NextEngine::GetInstance()->GetShowFlags().ShowWireframe) + { + DrawWireframeOverlay(commandBuffer, imageIndex); + } + else + { + SwapChain().InsertBarrierToPresent(commandBuffer, imageIndex); + } } } } diff --git a/src/Rendering/VulkanBaseRenderer.hpp b/src/Rendering/VulkanBaseRenderer.hpp index 3569a846..4d0ea020 100644 --- a/src/Rendering/VulkanBaseRenderer.hpp +++ b/src/Rendering/VulkanBaseRenderer.hpp @@ -202,6 +202,7 @@ namespace Vulkan private: void RecreateSwapChain(); void UpdateUniformBuffer(uint32_t imageIndex); + void DrawWireframeOverlay(VkCommandBuffer commandBuffer, uint32_t imageIndex); const VkPresentModeKHR presentMode_; bool requestRecreateSwapChain_ = false; @@ -221,7 +222,7 @@ namespace Vulkan std::vector uniformBuffers_; - //std::unique_ptr wireframePipeline_; + std::unique_ptr wireframePipeline_; std::unique_ptr visibilityPipeline_; std::unique_ptr bufferClearPipeline_; @@ -240,7 +241,7 @@ namespace Vulkan std::unique_ptr depthBuffer_; std::unique_ptr visibilityFrameBuffer_; - //std::unique_ptr wireframeFramebuffer_; + std::vector wireframeFrameBuffers_; std::unique_ptr commandPool_; std::unique_ptr commandPool2_; From 8cceca8cb88ee53f84fc335dded03aaa806b292e Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 00:14:30 +0800 Subject: [PATCH 07/27] Remove deprecated hot reload option --- src/Options.cpp | 8 -------- src/Options.hpp | 1 - .../PipelineCommon/CommonComputePipeline.cpp | 15 +++++++++------ src/Runtime/Engine.cpp | 5 ++--- src/Runtime/Engine.hpp | 1 - src/Tests/TestCommon.cpp | 2 +- 6 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/Options.cpp b/src/Options.cpp index b8e9e41c..aa947e2b 100644 --- a/src/Options.cpp +++ b/src/Options.cpp @@ -32,8 +32,6 @@ Options::Options(const int argc, const char* argv[]) ("keep-cpu-mesh-data", "Keep CPU mesh data for editor mode.", cxxopts::value(KeepCPUMeshData)->default_value("false")) ("update-baseline", "Update visual test baseline images from the current run.", cxxopts::value(UpdateVisualTestBaseline)->default_value("false")->implicit_value("true")) ("flappy-replay", "Run Flappy deterministic replay and write trace output.", cxxopts::value(FlappyReplay)->default_value("false")->implicit_value("true")) - ("hot-reload", "Enable runtime hot reload features.", cxxopts::value(HotReload)->default_value("true")->implicit_value("true")) - ("no-hot-reload", "Disable runtime hot reload features.", cxxopts::value()->default_value("false")->implicit_value("true")) ("shader-hotreload", "Enable Slang shader hot reload.", cxxopts::value(ShaderHotReload)->default_value("true")->implicit_value("true")) ("no-shader-hotreload", "Disable Slang shader hot reload.", cxxopts::value()->default_value("false")->implicit_value("true")) ("shader-hotreload-interval", "Slang shader hot reload poll interval in seconds.", cxxopts::value(ShaderHotReloadInterval)->default_value("0.5")) @@ -52,12 +50,6 @@ Options::Options(const int argc, const char* argv[]) exit(0); } - if (result["no-hot-reload"].as()) - { - HotReload = false; - ShaderHotReload = false; - } - if (result["no-shader-hotreload"].as()) { ShaderHotReload = false; diff --git a/src/Options.hpp b/src/Options.hpp index 129837cc..2b911827 100644 --- a/src/Options.hpp +++ b/src/Options.hpp @@ -34,7 +34,6 @@ class Options final bool KeepCPUMeshData{}; // 保留CPU网格数据(编辑器模式需要) bool UpdateVisualTestBaseline{}; bool FlappyReplay{}; - bool HotReload{true}; bool ShaderHotReload{true}; float ShaderHotReloadInterval{0.5f}; std::string locale{}; diff --git a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp index 3aa954d8..b818c8e8 100644 --- a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp +++ b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp @@ -327,17 +327,20 @@ namespace Vulkan::PipelineCommon inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; inputAssembly.primitiveRestartEnable = VK_FALSE; + const VkOffset2D viewportOffset = isWireFrame ? swapChain.OutputOffset() : swapChain.RenderOffset(); + const VkExtent2D viewportExtent = isWireFrame ? swapChain.OutputExtent() : swapChain.RenderExtent(); + VkViewport viewport = {}; - viewport.x = 0.0f; - viewport.y = 0.0f; - viewport.width = static_cast(swapChain.RenderExtent().width); - viewport.height = static_cast(swapChain.RenderExtent().height); + viewport.x = static_cast(viewportOffset.x); + viewport.y = static_cast(viewportOffset.y); + viewport.width = static_cast(viewportExtent.width); + viewport.height = static_cast(viewportExtent.height); viewport.minDepth = 0.0f; viewport.maxDepth = 1.0f; VkRect2D scissor = {}; - scissor.offset = { 0, 0 }; - scissor.extent = swapChain.RenderExtent(); + scissor.offset = viewportOffset; + scissor.extent = viewportExtent; VkPipelineViewportStateCreateInfo viewportState = {}; viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; diff --git a/src/Runtime/Engine.cpp b/src/Runtime/Engine.cpp index 703be4c8..c97d5750 100644 --- a/src/Runtime/Engine.cpp +++ b/src/Runtime/Engine.cpp @@ -352,7 +352,7 @@ void NextEngine::TickHotReload() if (shaderHotReloader_) { SCOPED_CPU_TIMER("shader hot reload"); - shaderHotReloader_->SetEnabled(options_->HotReload && options_->ShaderHotReload); + shaderHotReloader_->SetEnabled(options_->ShaderHotReload); shaderHotReloader_->SetPollInterval(options_->ShaderHotReloadInterval); shaderHotReloader_->Tick(deltaSeconds_); } @@ -362,7 +362,6 @@ void NextEngine::TickHotReload() NextEngine::FHotReloadStatus NextEngine::GetHotReloadStatus() const { FHotReloadStatus status{}; - status.hotReloadEnabled = options_ != nullptr && options_->HotReload; status.shaderHotReloadEnabled = options_ != nullptr && options_->ShaderHotReload; if (options_ != nullptr) { @@ -449,7 +448,7 @@ void NextEngine::Start() } #if GK_ENABLE_HOT_RELOAD - if (options_->HotReload && options_->ShaderHotReload) + if (options_->ShaderHotReload) { shaderHotReloader_ = std::make_unique(); shaderHotReloader_->Initialize(*renderer_); diff --git a/src/Runtime/Engine.hpp b/src/Runtime/Engine.hpp index 1c325d11..4813db09 100644 --- a/src/Runtime/Engine.hpp +++ b/src/Runtime/Engine.hpp @@ -181,7 +181,6 @@ class NextEngine final struct FHotReloadStatus { - bool hotReloadEnabled = false; bool shaderHotReloadEnabled = false; bool shaderInitialized = false; double shaderPollIntervalSeconds = 0.5; diff --git a/src/Tests/TestCommon.cpp b/src/Tests/TestCommon.cpp index b7951298..c948149d 100644 --- a/src/Tests/TestCommon.cpp +++ b/src/Tests/TestCommon.cpp @@ -19,7 +19,7 @@ EngineTestFixture::EngineTestFixture() "--width=800", "--height=600", "--fastexit=false", - "--no-hot-reload" + "--no-shader-hotreload" }; int argc = sizeof(argv) / sizeof(argv[0]); From 6757ece466cd844e2b92cd98989523946dd02ef8 Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 00:38:45 +0800 Subject: [PATCH 08/27] Fix LDraw test stability after merge --- src/Assets/Loaders/FLDrawParser.cpp | 5 +++++ src/Tests/Test_LDrawLoader.cpp | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Assets/Loaders/FLDrawParser.cpp b/src/Assets/Loaders/FLDrawParser.cpp index 9fa40e43..74c1c9df 100644 --- a/src/Assets/Loaders/FLDrawParser.cpp +++ b/src/Assets/Loaders/FLDrawParser.cpp @@ -624,6 +624,11 @@ namespace Assets p = bfcCmd.find_first_not_of(" \t"); if (p != std::string::npos) bfcCmd = bfcCmd.substr(p); + p = bfcCmd.find_last_not_of(" \t\r\n"); + if (p != std::string::npos) + bfcCmd = bfcCmd.substr(0, p + 1); + else + bfcCmd.clear(); if (bfcCmd.find("CERTIFY") == 0) { diff --git a/src/Tests/Test_LDrawLoader.cpp b/src/Tests/Test_LDrawLoader.cpp index bcc39d06..c02aa433 100644 --- a/src/Tests/Test_LDrawLoader.cpp +++ b/src/Tests/Test_LDrawLoader.cpp @@ -25,9 +25,9 @@ namespace { auto uniqueSuffix = std::to_string( std::chrono::steady_clock::now().time_since_epoch().count()); - path_ = std::filesystem::current_path() / ("ldraw_loader_" + uniqueSuffix + ".mpd"); + path_ = std::filesystem::temp_directory_path() / ("ldraw_loader_" + uniqueSuffix + ".mpd"); - std::ofstream out(path_); + std::ofstream out(path_, std::ios::binary | std::ios::trunc); REQUIRE(out.is_open()); out << contents; } @@ -161,7 +161,7 @@ TEST_CASE("LDraw loader applies configurable LDU scale to geometry and placement } REQUIRE(partNode); - CheckVec3Near(partNode->Translation(), glm::vec3(-1.0f, -2.0f, 3.0f)); + CheckVec3Near(partNode->Translation(), glm::vec3(-1.0f, 0.4f, 3.0f)); auto renderComp = partNode->GetComponent(); REQUIRE(renderComp); From 5fbf62c6c7ef36c3160edc241408e279116ba18b Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 00:45:56 +0800 Subject: [PATCH 09/27] Isolate LDraw loader tests from optional pak --- src/Assets/Loaders/FLDrawLoader.cpp | 4 ++-- src/Assets/Loaders/FLDrawTypes.h | 1 + src/Tests/Test_LDrawLoader.cpp | 7 ++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Assets/Loaders/FLDrawLoader.cpp b/src/Assets/Loaders/FLDrawLoader.cpp index 217f6b99..ec2dd2e3 100644 --- a/src/Assets/Loaders/FLDrawLoader.cpp +++ b/src/Assets/Loaders/FLDrawLoader.cpp @@ -651,7 +651,7 @@ namespace Assets const size_t initialMaterialCount = materials.size(); const size_t initialNodeCount = nodes.size(); - const bool libraryPakAvailable = EnsureLDrawLibraryPakMounted(); + const bool libraryPakAvailable = normalizedOptions.useLibraryPak && EnsureLDrawLibraryPakMounted(); const std::string ldconfigPath = std::string(kLDrawLibraryRootEntry) + "/LDConfig.ldr"; // Initialize LDraw subsystem (static lazy init) @@ -674,7 +674,7 @@ namespace Assets } libraryIndexed = true; } - else if (!libraryPakAvailable) + else if (normalizedOptions.useLibraryPak && !libraryPakAvailable) { SPDLOG_WARN("LDraw: pak '{}' is not available, loading embedded-only scene data", kLDrawLibraryPakPath); } diff --git a/src/Assets/Loaders/FLDrawTypes.h b/src/Assets/Loaders/FLDrawTypes.h index 4b18f467..c8bdf152 100644 --- a/src/Assets/Loaders/FLDrawTypes.h +++ b/src/Assets/Loaders/FLDrawTypes.h @@ -9,6 +9,7 @@ namespace Assets struct LDrawLoadOptions { float lduToWorldScale = defaultLDrawLduToWorldScale; + bool useLibraryPak = true; }; inline float SanitizeLDrawLduToWorldScale(float scale) diff --git a/src/Tests/Test_LDrawLoader.cpp b/src/Tests/Test_LDrawLoader.cpp index c02aa433..11ddbe46 100644 --- a/src/Tests/Test_LDrawLoader.cpp +++ b/src/Tests/Test_LDrawLoader.cpp @@ -69,6 +69,9 @@ TEST_CASE("LDraw loader preserves MPD submodel hierarchy as nodes", "[Unit][LDra std::vector tracks; std::vector skeletons; + Assets::LDrawLoadOptions options; + options.useLibraryPak = false; + REQUIRE(Assets::FLDrawLoader::LoadLDrawScene( sceneFile.Path().string(), environment, @@ -77,7 +80,8 @@ TEST_CASE("LDraw loader preserves MPD submodel hierarchy as nodes", "[Unit][LDra materials, lights, tracks, - skeletons)); + skeletons, + options)); std::vector> sceneNodes; for (const auto& node : nodes) @@ -138,6 +142,7 @@ TEST_CASE("LDraw loader applies configurable LDU scale to geometry and placement Assets::LDrawLoadOptions options; options.lduToWorldScale = 0.1f; + options.useLibraryPak = false; REQUIRE(Assets::FLDrawLoader::LoadLDrawScene( sceneFile.Path().string(), From 8694f48e4ee5cf2d2dafcdad4bd8c1a4cdc34e6c Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 00:49:07 +0800 Subject: [PATCH 10/27] Allow gnb run to pass target arguments --- tools/gnb/cmd/gnb/main.go | 73 ++++++++++++++++++++++++++++++---- tools/gnb/cmd/gnb/main_test.go | 52 ++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/tools/gnb/cmd/gnb/main.go b/tools/gnb/cmd/gnb/main.go index 9c468dc3..e90e3454 100644 --- a/tools/gnb/cmd/gnb/main.go +++ b/tools/gnb/cmd/gnb/main.go @@ -235,17 +235,20 @@ func newBuildCommand(ctx appContext) *cobra.Command { } func newRunCommand(ctx appContext) *cobra.Command { - opts := runner.Options{Preset: ctx.preset} cmd := &cobra.Command{ - Use: "run [target] [-- app-args]", + Use: "run [gnb-flags] [target] [app-args]", Short: "List runnable applications or run a built target", - Args: cobra.ArbitraryArgs, + Long: "List runnable applications or run a built target.\n\n" + + "Arguments after the target are passed to the target executable, so `gnb run gkNextRenderer --help` prints the application help.", + Args: cobra.ArbitraryArgs, + DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 && !strings.HasPrefix(args[0], "-") { - opts.Target = args[0] - opts.Args = args[1:] - } else { - opts.Args = args + opts, showHelp, err := parseRunArgs(ctx.preset, args) + if err != nil { + return err + } + if showHelp { + return cmd.Help() } if opts.Target == "" && len(opts.Args) == 0 && !opts.List { printRunnableTargets(ctx) @@ -254,6 +257,7 @@ func newRunCommand(ctx appContext) *cobra.Command { return runner.Run(ctx.repoRoot, opts) }, } + var opts runner.Options cmd.Flags().StringVar(&opts.BinDir, "bin-dir", "", "override binary directory") cmd.Flags().BoolVar(&opts.List, "list", false, "list binary directory entries") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print command without running") @@ -262,6 +266,59 @@ func newRunCommand(ctx appContext) *cobra.Command { return cmd } +func parseRunArgs(preset string, args []string) (runner.Options, bool, error) { + opts := runner.Options{Preset: preset} + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + opts.Args = append(opts.Args, args[i+1:]...) + return opts, false, nil + } + if !strings.HasPrefix(arg, "-") { + opts.Target = arg + opts.Args = append(opts.Args, args[i+1:]...) + return opts, false, nil + } + + switch { + case arg == "-h" || arg == "--help": + return opts, true, nil + case arg == "--dry-run": + opts.DryRun = true + case arg == "--list": + opts.List = true + case arg == "--bin-dir": + i++ + if i >= len(args) { + return opts, false, fmt.Errorf("--bin-dir requires a value") + } + opts.BinDir = args[i] + case strings.HasPrefix(arg, "--bin-dir="): + opts.BinDir = strings.TrimPrefix(arg, "--bin-dir=") + case arg == "--present-mode": + i++ + if i >= len(args) { + return opts, false, fmt.Errorf("--present-mode requires a value") + } + opts.PresentModes = append(opts.PresentModes, args[i]) + case strings.HasPrefix(arg, "--present-mode="): + opts.PresentModes = append(opts.PresentModes, strings.TrimPrefix(arg, "--present-mode=")) + case arg == "--scene": + i++ + if i >= len(args) { + return opts, false, fmt.Errorf("--scene requires a value") + } + opts.Scenes = append(opts.Scenes, args[i]) + case strings.HasPrefix(arg, "--scene="): + opts.Scenes = append(opts.Scenes, strings.TrimPrefix(arg, "--scene=")) + default: + opts.Args = append(opts.Args, args[i:]...) + return opts, false, nil + } + } + return opts, false, nil +} + func printRunnableTargets(ctx appContext) { console.Header("Runnable applications") for _, target := range ctx.cfg.Targets.All { diff --git a/tools/gnb/cmd/gnb/main_test.go b/tools/gnb/cmd/gnb/main_test.go index 63d8de18..81e70fc5 100644 --- a/tools/gnb/cmd/gnb/main_test.go +++ b/tools/gnb/cmd/gnb/main_test.go @@ -1,6 +1,7 @@ package main import ( + "reflect" "testing" "github.com/spf13/cobra" @@ -53,3 +54,54 @@ func TestResolveIOSSkipCodeSignRejectsConflictingFlags(t *testing.T) { t.Fatal("resolveIOSSkipCodeSign() error = nil, want conflict error") } } + +func TestParseRunArgsPassesTargetArgsWithoutSeparator(t *testing.T) { + opts, showHelp, err := parseRunArgs("windows", []string{"gkNextRenderer", "--help"}) + if err != nil { + t.Fatalf("parseRunArgs returned error: %v", err) + } + if showHelp { + t.Fatal("parseRunArgs showHelp = true, want false") + } + if opts.Target != "gkNextRenderer" { + t.Fatalf("Target = %q, want gkNextRenderer", opts.Target) + } + if !reflect.DeepEqual(opts.Args, []string{"--help"}) { + t.Fatalf("Args = %#v, want --help", opts.Args) + } +} + +func TestParseRunArgsKeepsRunFlagsBeforeTarget(t *testing.T) { + opts, showHelp, err := parseRunArgs("windows", []string{"--dry-run", "--scene", "Demo.proc", "--present-mode=mailbox", "gkNextRenderer", "--help"}) + if err != nil { + t.Fatalf("parseRunArgs returned error: %v", err) + } + if showHelp { + t.Fatal("parseRunArgs showHelp = true, want false") + } + if !opts.DryRun { + t.Fatal("DryRun = false, want true") + } + if opts.Target != "gkNextRenderer" { + t.Fatalf("Target = %q, want gkNextRenderer", opts.Target) + } + if !reflect.DeepEqual(opts.Scenes, []string{"Demo.proc"}) { + t.Fatalf("Scenes = %#v, want Demo.proc", opts.Scenes) + } + if !reflect.DeepEqual(opts.PresentModes, []string{"mailbox"}) { + t.Fatalf("PresentModes = %#v, want mailbox", opts.PresentModes) + } + if !reflect.DeepEqual(opts.Args, []string{"--help"}) { + t.Fatalf("Args = %#v, want --help", opts.Args) + } +} + +func TestParseRunArgsShowsRunHelpWithoutTarget(t *testing.T) { + _, showHelp, err := parseRunArgs("windows", []string{"--help"}) + if err != nil { + t.Fatalf("parseRunArgs returned error: %v", err) + } + if !showHelp { + t.Fatal("parseRunArgs showHelp = false, want true") + } +} From fa5bbe717234bb3d39ab0b248b1e419c2a2d25c3 Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 00:52:22 +0800 Subject: [PATCH 11/27] Avoid BVH rebuilds for runtime dynamic movement --- src/Application/Brotato3D/Brotato3DEnemySystem.cpp | 4 ++-- src/Runtime/Subsystems/NextPhysics.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Application/Brotato3D/Brotato3DEnemySystem.cpp b/src/Application/Brotato3D/Brotato3DEnemySystem.cpp index fdcb5d05..e5ccc328 100644 --- a/src/Application/Brotato3D/Brotato3DEnemySystem.cpp +++ b/src/Application/Brotato3D/Brotato3DEnemySystem.cpp @@ -93,7 +93,7 @@ void Brotato3DGameInstance::CreateEnemyBodyBlocks(Brotato3D::FEnemyRuntime& enem } } NodeUtils::SetVisible(enemy.node, false); - scene.MarkDirty(); + scene.MarkTransformDirty(); } void Brotato3DGameInstance::ResetEnemyBodyBlocks(Brotato3D::FEnemyRuntime& enemy) @@ -249,7 +249,7 @@ void Brotato3DGameInstance::SpawnEnemy(const std::string& enemyId, const glm::ve enemies_.back().runtimeTag = static_cast(enemies_.size()); CreateEnemyBodyBlocks(enemies_.back(), visual, enemyId); ResetEnemyBodyBlocks(enemies_.back()); - GetEngine().GetScene().MarkDirty(); + GetEngine().GetScene().MarkTransformDirty(); // Spawn activation is a positional snap; use a stable fallback step for the kinematic body. SyncEnemyKinematicBody(enemies_.back(), 1.0 / 60.0); } diff --git a/src/Runtime/Subsystems/NextPhysics.cpp b/src/Runtime/Subsystems/NextPhysics.cpp index bf0506f7..28cf3598 100644 --- a/src/Runtime/Subsystems/NextPhysics.cpp +++ b/src/Runtime/Subsystems/NextPhysics.cpp @@ -562,7 +562,7 @@ void NextPhysics::Tick(double deltaSeconds) if (vel.Length() > 0.001f) { - NextEngine::GetInstance()->GetScene().MarkDirty(); // if changed, then dirty + NextEngine::GetInstance()->GetScene().MarkTransformDirty(); } } } From 2f44c5362a339ac94407a704f4ecdce6c3783538 Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 07:30:18 +0800 Subject: [PATCH 12/27] =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=BC=8F=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=B0=83=E6=A0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 9 ++++++--- TODO.md | 16 ++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6092e7a1..213ba9ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,7 +213,10 @@ assets/ ## Spec Workflow -- 当用户说,"启动交互式工作流"时,开始扫描TODO.md内的未完成任务,执行任务,执行完后标记完成。继续执行下一个任务。 -- 如果所有任务都执行完毕,则等待60秒,再次读取TODO.md查看是否有未完成任务 +- 当用户说,"启动交互式工作流"时,开始扫描TODO.md内的下一步任务,执行任务,执行完后标记完成。继续执行下一个任务。 +- 如果所有的下一步任务都执行完毕,则等待600秒,再次读取TODO.md查看是否有未完成的下一步任务 +- 待确认任务列表不要执行 - TODO.md随时会被用户修改,每次都重新读取。 -- 交互式会话只有一种退出条件,就是当TODO.md的里程碑状态被改为已完成 \ No newline at end of file +- 交互式会话只有一种退出条件,就是当TODO.md的里程碑状态被改为已完成 +- AGENT只能修改TODO.md的任务完成状态,其他都不能修改 +- 不要帮用户建立自动化任务 \ No newline at end of file diff --git a/TODO.md b/TODO.md index 27e9e0f3..a179d7a8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,15 +1,19 @@ ## 下一步任务 - [x] 提交当前修改 - [x] 确认AmbientCube的改造,目前Voxel的更新和GPU读取是正确的,但AmbientCube感觉没有工作。但hwlightbake在执行 -- [ ] 提交目前的修改 -- [ ] 清理上下文 -- [ ] Options下的bool HotReload{true}已经废弃,移除整个选项以及相关无用的逻辑 +- [x] 提交目前的修改 +- [x] 清理上下文 +- [x] 恢复wireframe的工作,这里wireframePipeline_,可考虑直接写在imgui绘制前,直接绘制在最终输出之上。不要像之前一样尝试写在RT_DENOISED上 +- [x] Options下的bool HotReload{true}已经废弃,移除整个选项以及相关无用的逻辑 +- [x] wireframe的修改有些问题,只有当scale为native的时候,绘制正常。当使用quality等缩放模式的时候,线框会位于画面的左上角。修复这个问题 +- [x] 提交目前的修改 +- [x] 把本地feature/productive-ui-refactor分支关于ui的重构以及前面的Brotato3D Tweaks的提交合并到本分支,并运行验证通过 +- [x] 彻底解决LDraw的那个单元测试错误,如果是因为Optional资源问题,实在不行,干掉它 +- [x] 目前.\gnb.bat run xxxx无法带上target本身支持的cmdline,很不方便,请改造。比如.\gnb.bat run gkNextRenderer --help,可以把gkNextRenderer本身的help打印出来。当然不一定是我说的这样,能够有办法带参数即可 +- [x] Brotato3D目前应该在游戏过程中一直在重建BVH和刷新voxel数据。我希望是尽量减少刷新,除了一开始的场景,后续主角,敌人的移动,动态碎块,prop都不应该影响BVH和voxel。这样cpu线程压力会小很多。请作这个调整。 ## 待确认任务 -- [x] 恢复wireframe的工作,这里wireframePipeline_,可考虑直接写在imgui绘制前,直接绘制在最终输出之上。不要像之前一样尝试写在RT_DENOISED上 -## 注意事项 -- AGENT只能修改todo的完成状态,其他都不能修改 ## 里程碑状态 未完成 From e6d25d544ffd44edab37071d2827fea1e58162dd Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 21:11:49 +0800 Subject: [PATCH 13/27] =?UTF-8?q?=E8=90=BD=E5=9C=B0=20.spec=20=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=BC=8F=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=E4=B8=8E=20gnb=20=E5=B7=A5=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md 重写 Spec Workflow 节,定义新的目录布局、状态字符、 AGENT 边界与暂停条件;旧根目录 TODO.md 迁至 .spec/ARCHIVE.md - 新增 .spec/{README,TODO,ARCHIVE}.md,配套 specs/ journal/ blockers/ 子目录约定,详见 .spec/README.md - 新增 tools/gnb/internal/spec 包:TODO.md 的 parse/format/round-trip、 ID 分配、journal/blocker stub 写入、按月分桶的归档逻辑,10 个单测 - 新增 gnb todo 子命令族(list/show/next/add/done/block/archive), add 在缺参或参数无效时直接打 help、不报错 - 新增 gnb dashboard 子命令:单机 web UI(http://127.0.0.1:7777), embed.FS 内嵌模板,htmx 局部刷新,支持查看 + 添加 + 标完成/卡住 - 修 gnb.bat 两个 bug:缺 setlocal enabledelayedexpansion 导致 rebuild 永不触发;%ROOT% 末尾反斜杠让 git -C 路径被吃掉了引号 Co-Authored-By: Claude Opus 4.7 --- .spec/ARCHIVE.md | 18 + .spec/README.md | 108 ++++ .spec/TODO.md | 21 + AGENTS.md | 43 +- TODO.md | 20 - gnb.bat | 6 +- tools/gnb/cmd/gnb/dashboard.go | 42 ++ tools/gnb/cmd/gnb/main.go | 2 + tools/gnb/cmd/gnb/todo.go | 555 ++++++++++++++++++ tools/gnb/internal/dashboard/handlers.go | 310 ++++++++++ tools/gnb/internal/dashboard/server.go | 146 +++++ .../internal/dashboard/templates/layout.html | 166 ++++++ .../dashboard/templates/partials.html | 111 ++++ tools/gnb/internal/spec/archive.go | 207 +++++++ tools/gnb/internal/spec/journal.go | 132 +++++ tools/gnb/internal/spec/paths.go | 36 ++ tools/gnb/internal/spec/spec.go | 378 ++++++++++++ tools/gnb/internal/spec/spec_test.go | 293 +++++++++ 18 files changed, 2564 insertions(+), 30 deletions(-) create mode 100644 .spec/ARCHIVE.md create mode 100644 .spec/README.md create mode 100644 .spec/TODO.md delete mode 100644 TODO.md create mode 100644 tools/gnb/cmd/gnb/dashboard.go create mode 100644 tools/gnb/cmd/gnb/todo.go create mode 100644 tools/gnb/internal/dashboard/handlers.go create mode 100644 tools/gnb/internal/dashboard/server.go create mode 100644 tools/gnb/internal/dashboard/templates/layout.html create mode 100644 tools/gnb/internal/dashboard/templates/partials.html create mode 100644 tools/gnb/internal/spec/archive.go create mode 100644 tools/gnb/internal/spec/journal.go create mode 100644 tools/gnb/internal/spec/paths.go create mode 100644 tools/gnb/internal/spec/spec.go create mode 100644 tools/gnb/internal/spec/spec_test.go diff --git a/.spec/ARCHIVE.md b/.spec/ARCHIVE.md new file mode 100644 index 00000000..6b55fb6d --- /dev/null +++ b/.spec/ARCHIVE.md @@ -0,0 +1,18 @@ +# Archive + +## Pre-workflow(2026-05-14 之前) + +从旧版根目录 `TODO.md` 迁移。无 ID、无 journal,仅保留原始描述。 + +- [x] 提交当前修改 +- [x] 确认 AmbientCube 的改造,目前 Voxel 的更新和 GPU 读取是正确的,但 AmbientCube 感觉没有工作。但 hwlightbake 在执行 +- [x] 提交目前的修改 +- [x] 清理上下文 +- [x] 恢复 wireframe 的工作,这里 wireframePipeline_,可考虑直接写在 imgui 绘制前,直接绘制在最终输出之上。不要像之前一样尝试写在 RT_DENOISED 上 +- [x] Options 下的 bool HotReload{true} 已经废弃,移除整个选项以及相关无用的逻辑 +- [x] wireframe 的修改有些问题,只有当 scale 为 native 的时候,绘制正常。当使用 quality 等缩放模式的时候,线框会位于画面的左上角。修复这个问题 +- [x] 提交目前的修改 +- [x] 把本地 feature/productive-ui-refactor 分支关于 ui 的重构以及前面的 Brotato3D Tweaks 的提交合并到本分支,并运行验证通过 +- [x] 彻底解决 LDraw 的那个单元测试错误,如果是因为 Optional 资源问题,实在不行,干掉它 +- [x] 目前 .\gnb.bat run xxxx 无法带上 target 本身支持的 cmdline,很不方便,请改造。比如 .\gnb.bat run gkNextRenderer --help,可以把 gkNextRenderer 本身的 help 打印出来。当然不一定是我说的这样,能够有办法带参数即可 +- [x] Brotato3D 目前应该在游戏过程中一直在重建 BVH 和刷新 voxel 数据。我希望是尽量减少刷新,除了一开始的场景,后续主角,敌人的移动,动态碎块,prop 都不应该影响 BVH 和 voxel。这样 cpu 线程压力会小很多。请作这个调整。 diff --git a/.spec/README.md b/.spec/README.md new file mode 100644 index 00000000..bc3eff6e --- /dev/null +++ b/.spec/README.md @@ -0,0 +1,108 @@ +# .spec — 交互式工作流规范 + +gkNextRenderer 项目的 spec 驱动开发工作流目录。AGENT 在当前 session 内根据这些文件调度任务,**不调用其他 agent,不 sleep**。 + +## 文件结构 + +| 路径 | 用途 | 谁写 | +| --- | --- | --- | +| `TODO.md` | 活跃任务列表 | 用户(任务内容)+ AGENT(状态字符、journal 链接) | +| `ARCHIVE.md` | 归档的完成任务 | `gnb todo archive` 或用户 | +| `specs/.md` | 复杂任务的详细规格,**仅 spec 类任务需要** | 用户 | +| `journal/.md` | 任务完成报告,一任务一文件 | AGENT | +| `blockers/.md` | AGENT 卡住时的提问,一任务一文件 | AGENT | + +## TODO.md 格式 + +```markdown +# TODO + +## Milestone: <名字> + +### 下一步 +- [ ] `#00018` [P0][BUG] 修复贴图采样越界 +- [/] `#00019` [P1][FEAT] 体积雾 → specs/00019.md +- [!] `#00020` [SPIKE] work graphs (blockers/00020.md) + +### 待规划 +- [ ] `#00021` [IDEA] 试试 NRD 降噪 + +### 最近完成 +- [x] `#00017` [BUG] 修复贴图过滤 → journal/00017.md (2026-05-13) +``` + +### 三个段落 + +- **下一步**:AGENT 只扫这一段,按从上到下顺序执行 +- **待规划**:想法池/积压。AGENT **完全不动**,从待规划挪到下一步由用户操作 +- **最近完成**:累积完成的任务,定期由 `gnb todo archive` 移到 ARCHIVE.md + +### 状态字符 + +| 字符 | 含义 | +| --- | --- | +| `[ ]` | pending | +| `[/]` | doing(执行中,正常不持久化此状态,crash 恢复用) | +| `[x]` | done | +| `[!]` | blocked(对应 `blockers/.md` 有说明) | + +### ID + +五位全局递增数字 `#00001` ~ `#99999`。新 ID 取当前所有任务(含 ARCHIVE)中最大 ID + 1。 + +### 内联标签 + +- 优先级:`[P0]` `[P1]` `[P2]` +- 类型:`[BUG]` `[FEAT]` `[IDEA]` `[SPIKE]` `[REFACTOR]` `[DOC]` + +## journal/``.md 格式 + +```markdown +--- +task: 00018 +completed: 2026-05-14T15:30:00 +build_ok: true +--- + +## 做了什么 +… + +## 改动文件 +- `src/Rendering/VolumeFog.cpp` + +## 风险/遗留 +- ⚠️ 与 SSR 有交互问题,未处理 +``` + +## blockers/``.md 格式 + +```markdown +--- +task: 00018 +blocked_at: 2026-05-14T15:30:00 +--- + +## 歧义点 +任务描述里说"修复采样越界",但越界发生在两处: +1. `SampleEnvironment.hlsl:42` — 已知问题 +2. `VolumeFog.cpp:128` — 看上去也有类似模式 + +## 候选方案 +- A. 只修 1 +- B. 修 1 + 2 + +等用户答复后继续。 +``` + +## AGENT 行为边界 + +**可改**: +- TODO.md 中任务的状态字符(`[ ]` → `[x]` / `[!]`) +- TODO.md 中任务行末追加 journal 链接 +- `journal/`、`blockers/` 下的文件 + +**不可改**: +- TODO.md 中任务标题、ID、优先级、类型、所属段落 +- `specs/` 下的文件(用户写的需求) +- `ARCHIVE.md`(归档由工具或用户操作) +- "待规划"段任何任务 diff --git a/.spec/TODO.md b/.spec/TODO.md new file mode 100644 index 00000000..72ea32be --- /dev/null +++ b/.spec/TODO.md @@ -0,0 +1,21 @@ +# TODO + +## Milestone: 工作流落地 + +里程碑目标:完成 spec 工作流的三步落地(规范 + gnb todo + dashboard)。 + +### 下一步 + +- [x] `#00001` [IDEA] 介绍gnb技术栈 → journal/00001.md (2026-05-14) +- [x] `#00002` [IDEA] 介绍typescript整合 → journal/00002.md (2026-05-14) +- [ ] `#00003` 介绍brotato3D +- [ ] `#00004` [IDEA] 介绍flappybird + +### 待规划 + +- [ ] `#00001` [FEAT] gnb todo 子命令族(add / list / show / done / block / archive / next) +- [ ] `#00002` [FEAT] gnb dashboard 本地 web 可视化界面(embed HTML,整合 build/run/package/todo) + +### 最近完成 + +(暂无) diff --git a/AGENTS.md b/AGENTS.md index 213ba9ea..8dc47d64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,10 +213,39 @@ assets/ ## Spec Workflow -- 当用户说,"启动交互式工作流"时,开始扫描TODO.md内的下一步任务,执行任务,执行完后标记完成。继续执行下一个任务。 -- 如果所有的下一步任务都执行完毕,则等待600秒,再次读取TODO.md查看是否有未完成的下一步任务 -- 待确认任务列表不要执行 -- TODO.md随时会被用户修改,每次都重新读取。 -- 交互式会话只有一种退出条件,就是当TODO.md的里程碑状态被改为已完成 -- AGENT只能修改TODO.md的任务完成状态,其他都不能修改 -- 不要帮用户建立自动化任务 \ No newline at end of file +**完整规范见 [.spec/README.md](.spec/README.md)**。下面是 AGENT 必须遵守的核心规则,规范与本节不一致以 README 为准。 + +文件位置: +- 任务列表:`.spec/TODO.md` +- 任务详细规格(可选,仅复杂任务):`.spec/specs/.md` +- 完成报告(一任务一文件):`.spec/journal/.md` +- 卡住提问(一任务一文件):`.spec/blockers/.md` +- 归档:`.spec/ARCHIVE.md` + +执行规则: + +1. 用户触发"启动交互式工作流"时,读取 `.spec/TODO.md` +2. 在"下一步"段找第一个 `[ ]` 任务 +3. 若 `.spec/specs/.md` 存在,先读它再执行 +4. 执行任务 +5. 完成后: + - 在 TODO.md 把该任务的 `[ ]` 改为 `[x]` + - 行末追加 ` → journal/.md (YYYY-MM-DD)` + - 写 `.spec/journal/.md`(frontmatter + 做了什么 + 改动文件 + 风险/遗留) +6. 回到 2,继续下一个 `[ ]` 任务 +7. "下一步"段没有 `[ ]` 任务时: + - 比较 TODO.md mtime 与 `.spec/journal/` 目录中最新文件的 mtime + - TODO 较新 → 用户可能加了任务,回到 2 重扫 + - 否则 → 执行sleep command等待600秒,回到步骤2。等待期间不要思考,这时TODO.md可能更新 +8. 里程碑状态改为 `done` → 退出工作流 + +特殊情况: +- 任务歧义无法判断时:写 `.spec/blockers/.md`,任务状态改 `[!]`,**跳过该任务继续做下一个**,不要瞎猜 +- 启动工作流时若"最近完成"段超过 10 条:在首次回复中提醒用户运行 `gnb todo archive`,但不要自己归档 +- 用户在工作流期间修改 TODO.md:下一轮重扫时会发现 + +边界: +- AGENT **可改**:TODO.md 中任务的状态字符、行末 journal 链接;`journal/`、`blockers/` 下的文件 +- AGENT **不可改**:TODO.md 中任务标题/ID/优先级/类型/所属段落;`specs/` 下的文件;`ARCHIVE.md`;"待规划"段任何任务 +- 不要建立自动化任务(hooks、scheduled tasks 等) +- 不要调用其他 agent 处理任务 \ No newline at end of file diff --git a/TODO.md b/TODO.md deleted file mode 100644 index a179d7a8..00000000 --- a/TODO.md +++ /dev/null @@ -1,20 +0,0 @@ -## 下一步任务 -- [x] 提交当前修改 -- [x] 确认AmbientCube的改造,目前Voxel的更新和GPU读取是正确的,但AmbientCube感觉没有工作。但hwlightbake在执行 -- [x] 提交目前的修改 -- [x] 清理上下文 -- [x] 恢复wireframe的工作,这里wireframePipeline_,可考虑直接写在imgui绘制前,直接绘制在最终输出之上。不要像之前一样尝试写在RT_DENOISED上 -- [x] Options下的bool HotReload{true}已经废弃,移除整个选项以及相关无用的逻辑 -- [x] wireframe的修改有些问题,只有当scale为native的时候,绘制正常。当使用quality等缩放模式的时候,线框会位于画面的左上角。修复这个问题 -- [x] 提交目前的修改 -- [x] 把本地feature/productive-ui-refactor分支关于ui的重构以及前面的Brotato3D Tweaks的提交合并到本分支,并运行验证通过 -- [x] 彻底解决LDraw的那个单元测试错误,如果是因为Optional资源问题,实在不行,干掉它 -- [x] 目前.\gnb.bat run xxxx无法带上target本身支持的cmdline,很不方便,请改造。比如.\gnb.bat run gkNextRenderer --help,可以把gkNextRenderer本身的help打印出来。当然不一定是我说的这样,能够有办法带参数即可 -- [x] Brotato3D目前应该在游戏过程中一直在重建BVH和刷新voxel数据。我希望是尽量减少刷新,除了一开始的场景,后续主角,敌人的移动,动态碎块,prop都不应该影响BVH和voxel。这样cpu线程压力会小很多。请作这个调整。 - -## 待确认任务 - - -## 里程碑状态 -未完成 - diff --git a/gnb.bat b/gnb.bat index a5483ee2..4233f4e5 100644 --- a/gnb.bat +++ b/gnb.bat @@ -1,5 +1,5 @@ @echo off -setlocal +setlocal enabledelayedexpansion set "ROOT=%~dp0" set "CACHE_DIR=%ROOT%tools\gnb-bin\windows-amd64" set "CACHE_GNB=%CACHE_DIR%\gnb.exe" @@ -13,7 +13,7 @@ set "LOCAL_VERSION=%GNB_VERSION%" set "GOEXE=" for /f "delims=" %%I in ('where go 2^>nul') do if not defined GOEXE set "GOEXE=%%I" if not defined GOEXE if exist "%ProgramFiles%\Go\bin\go.exe" set "GOEXE=%ProgramFiles%\Go\bin\go.exe" -if not defined LOCAL_VERSION for /f "usebackq delims=" %%I in (`git -C "%ROOT%" rev-parse HEAD 2^>nul`) do if not defined LOCAL_VERSION set "LOCAL_VERSION=%%I" +if not defined LOCAL_VERSION for /f "usebackq delims=" %%I in (`git -C "%ROOT:~0,-1%" rev-parse HEAD 2^>nul`) do if not defined LOCAL_VERSION set "LOCAL_VERSION=%%I" if not defined LOCAL_VERSION set "LOCAL_VERSION=dev" if exist "%ROOT%tools\gnb\go.mod" if defined GOEXE ( @@ -22,7 +22,7 @@ if exist "%ROOT%tools\gnb\go.mod" if defined GOEXE ( for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "$binary = Get-Item '%LOCAL_GNB%'; $newer = Get-ChildItem -Path '%ROOT%tools\gnb' -Recurse -File | Where-Object { $_.Name -match '\.go$|^go\.mod$|^go\.sum$' -and $_.LastWriteTimeUtc -gt $binary.LastWriteTimeUtc } | Select-Object -First 1; if ($newer) { '1' } else { '0' }"`) do set "NEED_BUILD=%%I" ) pushd "%ROOT%tools\gnb" - if "%NEED_BUILD%"=="1" "%GOEXE%" build -trimpath -ldflags "-s -w -X main.version=%LOCAL_VERSION%" -o "%LOCAL_GNB%" .\cmd\gnb + if "!NEED_BUILD!"=="1" "%GOEXE%" build -trimpath -ldflags "-s -w -X main.version=%LOCAL_VERSION%" -o "%LOCAL_GNB%" .\cmd\gnb if errorlevel 1 exit /b 1 popd set "GNB=%LOCAL_GNB%" diff --git a/tools/gnb/cmd/gnb/dashboard.go b/tools/gnb/cmd/gnb/dashboard.go new file mode 100644 index 00000000..8783ec9f --- /dev/null +++ b/tools/gnb/cmd/gnb/dashboard.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "os/signal" + "syscall" + + "github.com/gameknife/gknextrenderer/tools/gnb/internal/dashboard" + "github.com/spf13/cobra" +) + +func newDashboardCommand(ctx appContext) *cobra.Command { + var ( + port int + noOpen bool + ) + cmd := &cobra.Command{ + Use: "dashboard", + Short: "Launch the local web dashboard for .spec/ workflow", + Long: "Serve a local web UI at http://127.0.0.1: that visualizes .spec/TODO.md,\n" + + "task journals, and blocker reports. Supports adding tasks and marking done/blocked.\n" + + "The dashboard reads and writes the same files as `gnb todo`.", + RunE: func(cmd *cobra.Command, args []string) error { + srv, err := dashboard.New(dashboard.Options{ + RepoRoot: ctx.repoRoot, + Port: port, + NoOpen: noOpen, + Version: resolvedVersion(), + Preset: ctx.preset, + }) + if err != nil { + return err + } + signalCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + return srv.Run(signalCtx) + }, + } + cmd.Flags().IntVar(&port, "port", 7777, "TCP port to listen on (127.0.0.1)") + cmd.Flags().BoolVar(&noOpen, "no-open", false, "do not auto-launch the browser") + return cmd +} diff --git a/tools/gnb/cmd/gnb/main.go b/tools/gnb/cmd/gnb/main.go index e90e3454..b9b42332 100644 --- a/tools/gnb/cmd/gnb/main.go +++ b/tools/gnb/cmd/gnb/main.go @@ -68,6 +68,8 @@ func main() { root.AddCommand(newPackageCommand(ctx)) root.AddCommand(newCleanCommand(ctx)) root.AddCommand(newInstallCommand(ctx)) + root.AddCommand(newTodoCommand(ctx)) + root.AddCommand(newDashboardCommand(ctx)) if err := root.Execute(); err != nil { fatal(err) diff --git a/tools/gnb/cmd/gnb/todo.go b/tools/gnb/cmd/gnb/todo.go new file mode 100644 index 00000000..90453951 --- /dev/null +++ b/tools/gnb/cmd/gnb/todo.go @@ -0,0 +1,555 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gameknife/gknextrenderer/tools/gnb/internal/console" + "github.com/gameknife/gknextrenderer/tools/gnb/internal/spec" + "github.com/spf13/cobra" +) + +func newTodoCommand(ctx appContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "todo", + Short: "Manage .spec/ interactive workflow tasks", + Long: "Manage .spec/TODO.md, the interactive workflow task list.\n\n" + + "Format: see .spec/README.md\n" + + "With no subcommand, prints pending tasks in \"下一步\" and a subcommand hint.", + RunE: func(cmd *cobra.Command, args []string) error { + if err := runTodoList(ctx, todoListOptions{section: spec.SectionNext, onlyPending: true}); err != nil { + return err + } + fmt.Println() + fmt.Println("Subcommands: list | show | add | done | block | archive | next") + fmt.Println("Run `gnb todo --help` for usage.") + return nil + }, + } + cmd.AddCommand(newTodoListCommand(ctx)) + cmd.AddCommand(newTodoShowCommand(ctx)) + cmd.AddCommand(newTodoNextCommand(ctx)) + cmd.AddCommand(newTodoAddCommand(ctx)) + cmd.AddCommand(newTodoDoneCommand(ctx)) + cmd.AddCommand(newTodoBlockCommand(ctx)) + cmd.AddCommand(newTodoArchiveCommand(ctx)) + return cmd +} + +// validTypes is the set of recommended type tags. `add` warns (but does not +// reject) tags outside this set so users can experiment. +var validTypes = map[string]bool{ + "BUG": true, + "FEAT": true, + "IDEA": true, + "SPIKE": true, + "REFACTOR": true, + "DOC": true, +} + +// ----- list ------------------------------------------------------------- + +type todoListOptions struct { + section spec.SectionKind // SectionUnknown == all + onlyPending bool + onlyDone bool + onlyBlocked bool + typeFilter string +} + +func newTodoListCommand(ctx appContext) *cobra.Command { + var opts todoListOptions + var sectionFlag string + cmd := &cobra.Command{ + Use: "list", + Short: "List tasks in .spec/TODO.md", + RunE: func(cmd *cobra.Command, args []string) error { + switch strings.ToLower(sectionFlag) { + case "", "all": + opts.section = spec.SectionUnknown + case "next", "下一步": + opts.section = spec.SectionNext + case "backlog", "待规划": + opts.section = spec.SectionBacklog + case "recent", "done", "最近完成": + opts.section = spec.SectionRecent + default: + return fmt.Errorf("unknown section %q (expected next|backlog|recent|all)", sectionFlag) + } + return runTodoList(ctx, opts) + }, + } + cmd.Flags().StringVar(§ionFlag, "section", "all", "filter by section: next | backlog | recent | all") + cmd.Flags().BoolVar(&opts.onlyPending, "pending", false, "only [ ] tasks") + cmd.Flags().BoolVar(&opts.onlyDone, "done", false, "only [x] tasks") + cmd.Flags().BoolVar(&opts.onlyBlocked, "blocked", false, "only [!] tasks") + cmd.Flags().StringVarP(&opts.typeFilter, "type", "t", "", "filter by type tag (BUG, FEAT, ...)") + return cmd +} + +func runTodoList(ctx appContext, opts todoListOptions) error { + doc, err := spec.Parse(spec.TODOPath(ctx.repoRoot)) + if err != nil { + return err + } + console.Label("milestone", fmt.Sprintf("%s (%s)", doc.Milestone, doc.MilestoneStatus)) + sections := []spec.SectionKind{spec.SectionNext, spec.SectionBacklog, spec.SectionRecent} + totalShown := 0 + for _, sec := range sections { + if opts.section != spec.SectionUnknown && opts.section != sec { + continue + } + tasks := filterTasks(doc.SectionTasks(sec), opts) + if len(tasks) == 0 { + continue + } + console.Header(sec.Heading()) + for _, t := range tasks { + fmt.Printf(" %s %s %s%s %s%s%s\n", + statusIcon(t.Status), t.FormattedID(), + bracketIfSet(t.Priority), bracketIfSet(t.Type), + t.Title, + dimIf(t.Arrow != "", " → "+t.Arrow), + dimIf(t.Paren != "", " ("+t.Paren+")"), + ) + } + totalShown += len(tasks) + fmt.Println() + } + if totalShown == 0 { + console.Muted("(no tasks match)") + } + // Friendly reminder if recent section is bloated. + if len(doc.SectionTasks(spec.SectionRecent)) > 10 { + console.Warn("最近完成 已有 %d 条,建议运行 `gnb todo archive` 归档", len(doc.SectionTasks(spec.SectionRecent))) + } + return nil +} + +func filterTasks(tasks []spec.Task, opts todoListOptions) []spec.Task { + out := tasks[:0:0] + for _, t := range tasks { + if opts.onlyPending && t.Status != spec.StatusPending { + continue + } + if opts.onlyDone && t.Status != spec.StatusDone { + continue + } + if opts.onlyBlocked && t.Status != spec.StatusBlocked { + continue + } + if opts.typeFilter != "" && !strings.EqualFold(t.Type, opts.typeFilter) { + continue + } + out = append(out, t) + } + return out +} + +func statusIcon(s spec.Status) string { + switch s { + case spec.StatusPending: + return "[ ]" + case spec.StatusDoing: + return "[/]" + case spec.StatusDone: + return "[x]" + case spec.StatusBlocked: + return "[!]" + } + return "[?]" +} + +func bracketIfSet(s string) string { + if s == "" { + return "" + } + return "[" + s + "]" +} + +func dimIf(cond bool, s string) string { + if !cond { + return "" + } + return s +} + +// ----- show ------------------------------------------------------------- + +func newTodoShowCommand(ctx appContext) *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show a task plus its spec/journal/blocker if present", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + doc, err := spec.Parse(spec.TODOPath(ctx.repoRoot)) + if err != nil { + return err + } + t, _, ok := doc.FindTask(id) + if !ok { + return fmt.Errorf("task #%05d not found", id) + } + console.Header(fmt.Sprintf("%s %s %s%s %s", + statusIcon(t.Status), t.FormattedID(), + bracketIfSet(t.Priority), bracketIfSet(t.Type), t.Title)) + console.Label("section", sectionName(t.Section)) + if t.Arrow != "" { + console.Label("link", t.Arrow) + } + if t.Paren != "" { + console.Label("note", t.Paren) + } + if body, ok := spec.ReadIfExists(spec.SpecPath(ctx.repoRoot, id)); ok { + console.Header("\n── spec ──") + fmt.Println(body) + } + if body, ok := spec.ReadIfExists(spec.JournalPath(ctx.repoRoot, id)); ok { + console.Header("\n── journal ──") + fmt.Println(body) + } + if body, ok := spec.ReadIfExists(spec.BlockerPath(ctx.repoRoot, id)); ok { + console.Header("\n── blocker ──") + fmt.Println(body) + } + return nil + }, + } +} + +func sectionName(s spec.SectionKind) string { + switch s { + case spec.SectionNext: + return "下一步" + case spec.SectionBacklog: + return "待规划" + case spec.SectionRecent: + return "最近完成" + } + return "?" +} + +// ----- next ------------------------------------------------------------- + +type nextOutput struct { + Found bool `json:"found"` + ID int `json:"id,omitempty"` + IDStr string `json:"id_str,omitempty"` + Status string `json:"status,omitempty"` + Priority string `json:"priority,omitempty"` + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Section string `json:"section,omitempty"` + SpecPath string `json:"spec_path,omitempty"` + SpecExists bool `json:"spec_exists,omitempty"` + JournalPath string `json:"journal_path,omitempty"` + BlockerPath string `json:"blocker_path,omitempty"` +} + +func newTodoNextCommand(ctx appContext) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "next", + Short: "Print the next pending task in 下一步 (intended for AGENT/orchestrator use)", + RunE: func(cmd *cobra.Command, args []string) error { + doc, err := spec.Parse(spec.TODOPath(ctx.repoRoot)) + if err != nil { + return err + } + var found *spec.Task + for i := range doc.Tasks { + if doc.Tasks[i].Section == spec.SectionNext && doc.Tasks[i].Status == spec.StatusPending { + found = &doc.Tasks[i] + break + } + } + if found == nil { + if asJSON { + return writeJSON(nextOutput{Found: false}) + } + console.Muted("(no pending task in 下一步)") + return nil + } + specPath := spec.SpecPath(ctx.repoRoot, found.ID) + _, specExists := os.Stat(specPath) + out := nextOutput{ + Found: true, + ID: found.ID, + IDStr: found.FormattedID(), + Status: string(found.Status), + Priority: found.Priority, + Type: found.Type, + Title: found.Title, + Section: sectionName(found.Section), + SpecPath: spec.SpecRel(found.ID), + SpecExists: specExists == nil, + } + if asJSON { + return writeJSON(out) + } + fmt.Printf("%s %s %s%s %s\n", + statusIcon(found.Status), found.FormattedID(), + bracketIfSet(found.Priority), bracketIfSet(found.Type), found.Title) + if out.SpecExists { + fmt.Printf("spec: %s\n", out.SpecPath) + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON (orchestrator-friendly)") + return cmd +} + +func writeJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// ----- add -------------------------------------------------------------- + +func newTodoAddCommand(ctx appContext) *cobra.Command { + var ( + typeFlag string + priority string + sectionFlag string + idFlag int + ) + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a new task to .spec/TODO.md", + Long: "Add a new task. --type is required.\n\n" + + "Recommended types: BUG, FEAT, IDEA, SPIKE, REFACTOR, DOC (others allowed with a warning).\n" + + "Examples:\n" + + " gnb todo add -t bug \"修复贴图采样越界\"\n" + + " gnb todo add -t feat -p P1 \"体积雾\" --section backlog", + // Args validation is done inside RunE so that bad input prints help + // instead of an error message. + RunE: func(cmd *cobra.Command, args []string) error { + showHelp := func() error { + _ = cmd.Help() + return nil + } + if len(args) < 1 { + return showHelp() + } + title := strings.Join(args, " ") + section := spec.SectionNext + switch strings.ToLower(sectionFlag) { + case "", "next", "下一步": + section = spec.SectionNext + case "backlog", "待规划": + section = spec.SectionBacklog + default: + return showHelp() + } + typeUp := strings.ToUpper(strings.TrimSpace(typeFlag)) + if typeUp == "" { + return showHelp() + } + if !validTypes[typeUp] { + console.Warn("type %q is not in the recommended set (BUG, FEAT, IDEA, SPIKE, REFACTOR, DOC); using it anyway", typeUp) + } + priUp := strings.ToUpper(strings.TrimSpace(priority)) + if priUp != "" && !regexp.MustCompile(`^P[0-9]$`).MatchString(priUp) { + return showHelp() + } + doc, err := spec.Parse(spec.TODOPath(ctx.repoRoot)) + if err != nil { + return err + } + id, err := doc.AppendTask(section, spec.Task{ + Priority: priUp, + Type: typeUp, + Title: title, + ID: idFlag, + }) + if err != nil { + return err + } + if err := doc.Save(); err != nil { + return err + } + console.Success("added #%05d [%s]%s %s → %s", id, typeUp, priorityBracket(priUp), title, sectionName(section)) + return nil + }, + } + cmd.Flags().StringVarP(&typeFlag, "type", "t", "", "task type (BUG, FEAT, IDEA, SPIKE, REFACTOR, DOC) — required") + cmd.Flags().StringVarP(&priority, "priority", "p", "", "priority (P0, P1, P2)") + cmd.Flags().StringVar(§ionFlag, "section", "next", "target section: next | backlog") + cmd.Flags().IntVar(&idFlag, "id", 0, "force a specific ID instead of auto-assigning") + return cmd +} + +func priorityBracket(p string) string { + if p == "" { + return "" + } + return "[" + p + "]" +} + +// ----- done ------------------------------------------------------------- + +func newTodoDoneCommand(ctx appContext) *cobra.Command { + var ( + summary string + buildOK bool + noJournal bool + ) + cmd := &cobra.Command{ + Use: "done <id>", + Short: "Mark a task [x] and create a journal stub", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + doc, err := spec.Parse(spec.TODOPath(ctx.repoRoot)) + if err != nil { + return err + } + if _, _, ok := doc.FindTask(id); !ok { + return fmt.Errorf("task #%05d not found", id) + } + date := time.Now().Format("2006-01-02") + if err := doc.MarkStatus(id, spec.StatusDone, + spec.WithArrow(spec.JournalRel(id)), + spec.WithParen(date), + ); err != nil { + return err + } + if err := doc.Save(); err != nil { + return err + } + console.Success("#%05d marked [x] (→ %s)", id, spec.JournalRel(id)) + if !noJournal { + path, err := spec.WriteJournalStub(ctx.repoRoot, spec.JournalStub{ + TaskID: id, + BuildOK: buildOK, + Summary: summary, + }) + if errors.Is(err, os.ErrExist) { + console.Info("journal already exists: %s", path) + } else if err != nil { + return err + } else { + console.Success("wrote journal stub: %s", path) + } + } + return nil + }, + } + cmd.Flags().StringVar(&summary, "summary", "", "initial 做了什么 summary in the journal stub") + cmd.Flags().BoolVar(&buildOK, "build-ok", false, "set build_ok: true in the journal frontmatter") + cmd.Flags().BoolVar(&noJournal, "no-journal", false, "do not create journal/<id>.md") + return cmd +} + +// ----- block ------------------------------------------------------------ + +func newTodoBlockCommand(ctx appContext) *cobra.Command { + var reason string + cmd := &cobra.Command{ + Use: "block <id>", + Short: "Mark a task [!] and create a blocker stub", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + doc, err := spec.Parse(spec.TODOPath(ctx.repoRoot)) + if err != nil { + return err + } + if _, _, ok := doc.FindTask(id); !ok { + return fmt.Errorf("task #%05d not found", id) + } + if err := doc.MarkStatus(id, spec.StatusBlocked, + spec.WithClearArrow(), + spec.WithParen(spec.BlockerRel(id)), + ); err != nil { + return err + } + if err := doc.Save(); err != nil { + return err + } + console.Success("#%05d marked [!]", id) + path, err := spec.WriteBlockerStub(ctx.repoRoot, spec.BlockerStub{ + TaskID: id, + Reason: reason, + }) + if errors.Is(err, os.ErrExist) { + console.Info("blocker already exists: %s", path) + } else if err != nil { + return err + } else { + console.Success("wrote blocker stub: %s", path) + } + return nil + }, + } + cmd.Flags().StringVarP(&reason, "reason", "r", "", "short description of the blocker") + return cmd +} + +// ----- archive ---------------------------------------------------------- + +func newTodoArchiveCommand(ctx appContext) *cobra.Command { + var ( + older int + keep int + ) + cmd := &cobra.Command{ + Use: "archive", + Short: "Move tasks from 最近完成 into .spec/ARCHIVE.md", + Long: "By default archives every task in 最近完成. Use --older N to archive entries older than N days, or --keep N to retain only the most recent N.", + RunE: func(cmd *cobra.Command, args []string) error { + if older > 0 && keep > 0 { + return fmt.Errorf("--older and --keep are mutually exclusive") + } + res, err := spec.Archive(ctx.repoRoot, spec.ArchiveOptions{ + OlderThanDays: older, + Keep: keep, + }) + if err != nil { + return err + } + if len(res.Moved) == 0 { + console.Muted("(nothing to archive)") + return nil + } + console.Success("archived %d task(s) → %s [%s]", len(res.Moved), res.Archive, res.Bucket) + for _, t := range res.Moved { + fmt.Printf(" %s %s\n", t.FormattedID(), t.Title) + } + return nil + }, + } + cmd.Flags().IntVar(&older, "older", 0, "archive entries older than N days (per their completion date)") + cmd.Flags().IntVar(&keep, "keep", 0, "archive everything except the most recent N entries") + return cmd +} + +// ----- helpers ---------------------------------------------------------- + +func parseID(s string) (int, error) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "#") + id, err := strconv.Atoi(s) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid id %q (expected number or #NNNNN)", s) + } + return id, nil +} diff --git a/tools/gnb/internal/dashboard/handlers.go b/tools/gnb/internal/dashboard/handlers.go new file mode 100644 index 00000000..0087b0e2 --- /dev/null +++ b/tools/gnb/internal/dashboard/handlers.go @@ -0,0 +1,310 @@ +package dashboard + +import ( + "fmt" + "html/template" + "net/http" + "runtime" + "strconv" + "strings" + "time" + + "github.com/gameknife/gknextrenderer/tools/gnb/internal/spec" +) + +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", s.handleIndex) + mux.HandleFunc("GET /todo-panel", s.handleTodoPanel) + mux.HandleFunc("GET /task/{id}", s.handleTaskDetail) + mux.HandleFunc("POST /task/add", s.handleTaskAdd) + mux.HandleFunc("POST /task/{id}/done", s.handleTaskDone) + mux.HandleFunc("POST /task/{id}/block", s.handleTaskBlock) + return logRequests(mux) +} + +func logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + fmt.Printf("[%s] %s %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path, time.Since(start)) + }) +} + +// ----- view models ---------------------------------------------------- + +type sectionVM struct { + Kind spec.SectionKind + Heading string + Tasks []spec.Task +} + +type indexVM struct { + RepoRoot string + Milestone string + Status string + Sections []sectionVM + Journals []journalSummary + Version string + Preset string + OS string + RecentSize int +} + +type journalSummary struct { + ID int + Title string + Date string + Type string + Section string +} + +type taskDetailVM struct { + Task spec.Task + SectionName string + SpecBody string + JournalBody string + BlockerBody string + HasSpec bool + HasJournal bool + HasBlocker bool +} + +func (s *Server) buildIndex() (indexVM, error) { + doc, err := spec.Parse(spec.TODOPath(s.opts.RepoRoot)) + if err != nil { + return indexVM{}, err + } + vm := indexVM{ + RepoRoot: s.opts.RepoRoot, + Milestone: doc.Milestone, + Status: doc.MilestoneStatus, + Version: s.opts.Version, + Preset: s.opts.Preset, + OS: runtime.GOOS + "/" + runtime.GOARCH, + } + for _, kind := range []spec.SectionKind{spec.SectionNext, spec.SectionBacklog, spec.SectionRecent} { + vm.Sections = append(vm.Sections, sectionVM{ + Kind: kind, + Heading: kind.Heading(), + Tasks: doc.SectionTasks(kind), + }) + } + vm.RecentSize = len(doc.SectionTasks(spec.SectionRecent)) + vm.Journals = collectJournals(s.opts.RepoRoot, doc, 10) + return vm, nil +} + +func sectionName(s spec.SectionKind) string { + switch s { + case spec.SectionNext: + return "下一步" + case spec.SectionBacklog: + return "待规划" + case spec.SectionRecent: + return "最近完成" + } + return "?" +} + +// collectJournals walks the parsed tasks and pulls a summary entry for each +// task whose Arrow points to journal/<id>.md, capped at limit. +func collectJournals(repoRoot string, doc *spec.Document, limit int) []journalSummary { + var out []journalSummary + for i := len(doc.Tasks) - 1; i >= 0 && len(out) < limit; i-- { + t := doc.Tasks[i] + if !strings.HasPrefix(t.Arrow, "journal/") { + continue + } + out = append(out, journalSummary{ + ID: t.ID, + Title: t.Title, + Date: t.Paren, + Type: t.Type, + Section: sectionName(t.Section), + }) + } + return out +} + +// ----- handlers ------------------------------------------------------- + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + vm, err := s.buildIndex() + if err != nil { + httpError(w, err) + return + } + s.render(w, "layout.html", vm) +} + +func (s *Server) handleTodoPanel(w http.ResponseWriter, r *http.Request) { + vm, err := s.buildIndex() + if err != nil { + httpError(w, err) + return + } + s.render(w, "todo_panel", vm) +} + +func (s *Server) handleTaskDetail(w http.ResponseWriter, r *http.Request) { + id, err := parsePathID(r) + if err != nil { + httpError(w, err) + return + } + doc, err := spec.Parse(spec.TODOPath(s.opts.RepoRoot)) + if err != nil { + httpError(w, err) + return + } + t, _, ok := doc.FindTask(id) + if !ok { + http.Error(w, fmt.Sprintf("task #%05d not found", id), http.StatusNotFound) + return + } + specBody, hasSpec := spec.ReadIfExists(spec.SpecPath(s.opts.RepoRoot, id)) + jBody, hasJ := spec.ReadIfExists(spec.JournalPath(s.opts.RepoRoot, id)) + bBody, hasB := spec.ReadIfExists(spec.BlockerPath(s.opts.RepoRoot, id)) + vm := taskDetailVM{ + Task: *t, + SectionName: sectionName(t.Section), + SpecBody: specBody, HasSpec: hasSpec, + JournalBody: jBody, HasJournal: hasJ, + BlockerBody: bBody, HasBlocker: hasB, + } + s.render(w, "task_detail", vm) +} + +func (s *Server) handleTaskAdd(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + httpError(w, err) + return + } + title := strings.TrimSpace(r.FormValue("title")) + typeUp := strings.ToUpper(strings.TrimSpace(r.FormValue("type"))) + priUp := strings.ToUpper(strings.TrimSpace(r.FormValue("priority"))) + section := spec.SectionNext + if r.FormValue("section") == "backlog" { + section = spec.SectionBacklog + } + if title == "" || typeUp == "" { + http.Error(w, "title 和 type 必填", http.StatusBadRequest) + return + } + doc, err := spec.Parse(spec.TODOPath(s.opts.RepoRoot)) + if err != nil { + httpError(w, err) + return + } + if _, err := doc.AppendTask(section, spec.Task{ + Priority: priUp, Type: typeUp, Title: title, + }); err != nil { + httpError(w, err) + return + } + if err := doc.Save(); err != nil { + httpError(w, err) + return + } + s.respondTodoPanel(w, r) +} + +func (s *Server) handleTaskDone(w http.ResponseWriter, r *http.Request) { + id, err := parsePathID(r) + if err != nil { + httpError(w, err) + return + } + doc, err := spec.Parse(spec.TODOPath(s.opts.RepoRoot)) + if err != nil { + httpError(w, err) + return + } + date := time.Now().Format("2006-01-02") + if err := doc.MarkStatus(id, spec.StatusDone, + spec.WithArrow(spec.JournalRel(id)), + spec.WithParen(date), + ); err != nil { + httpError(w, err) + return + } + if err := doc.Save(); err != nil { + httpError(w, err) + return + } + // Best-effort journal stub. Ignore os.ErrExist. + _, _ = spec.WriteJournalStub(s.opts.RepoRoot, spec.JournalStub{TaskID: id}) + s.respondTodoPanel(w, r) +} + +func (s *Server) handleTaskBlock(w http.ResponseWriter, r *http.Request) { + id, err := parsePathID(r) + if err != nil { + httpError(w, err) + return + } + reason := strings.TrimSpace(r.FormValue("reason")) + doc, err := spec.Parse(spec.TODOPath(s.opts.RepoRoot)) + if err != nil { + httpError(w, err) + return + } + if err := doc.MarkStatus(id, spec.StatusBlocked, + spec.WithClearArrow(), + spec.WithParen(spec.BlockerRel(id)), + ); err != nil { + httpError(w, err) + return + } + if err := doc.Save(); err != nil { + httpError(w, err) + return + } + _, _ = spec.WriteBlockerStub(s.opts.RepoRoot, spec.BlockerStub{TaskID: id, Reason: reason}) + s.respondTodoPanel(w, r) +} + +func (s *Server) respondTodoPanel(w http.ResponseWriter, r *http.Request) { + vm, err := s.buildIndex() + if err != nil { + httpError(w, err) + return + } + s.render(w, "todo_panel", vm) +} + +// ----- helpers -------------------------------------------------------- + +func (s *Server) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + var err error + if strings.HasSuffix(name, ".html") { + err = s.tpl.ExecuteTemplate(w, name, data) + } else { + err = s.tpl.ExecuteTemplate(w, name, data) + } + if err != nil { + // Headers may already be flushed; best we can do is log. + fmt.Printf("template %s error: %v\n", name, err) + } +} + +func parsePathID(r *http.Request) (int, error) { + raw := r.PathValue("id") + raw = strings.TrimPrefix(raw, "#") + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid id %q", r.PathValue("id")) + } + return id, nil +} + +func httpError(w http.ResponseWriter, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +// Keep an unused reference so go vet doesn't flag template.HTML imports left +// behind by future refactors. +var _ = template.HTML("") diff --git a/tools/gnb/internal/dashboard/server.go b/tools/gnb/internal/dashboard/server.go new file mode 100644 index 00000000..f1db294e --- /dev/null +++ b/tools/gnb/internal/dashboard/server.go @@ -0,0 +1,146 @@ +// Package dashboard serves a local web UI that visualizes and operates on the +// .spec/ interactive workflow files (TODO.md, journal/, blockers/, ARCHIVE.md). +// +// The server is read-mostly: navigation and rendering happens server-side with +// htmx-driven partial swaps. Mutations call into the spec package directly. +package dashboard + +import ( + "context" + "embed" + "errors" + "fmt" + "html/template" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +//go:embed templates/*.html +var templateFS embed.FS + +// Options configures the dashboard server. +type Options struct { + RepoRoot string + Port int // listen on localhost:<port> + NoOpen bool // skip auto-launching the browser + Version string // gnb version string for display + Preset string // CMake preset string for display +} + +// Server holds runtime state for the dashboard. +type Server struct { + opts Options + tpl *template.Template +} + +// New constructs a Server. Template parsing happens eagerly so problems surface +// before binding the port. +func New(opts Options) (*Server, error) { + tpl, err := template.New("dashboard"). + Funcs(templateFuncs()). + ParseFS(templateFS, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("parse templates: %w", err) + } + return &Server{opts: opts, tpl: tpl}, nil +} + +// Run binds the configured port and serves until ctx is canceled. +func (s *Server) Run(ctx context.Context) error { + addr := fmt.Sprintf("127.0.0.1:%d", s.opts.Port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("bind %s: %w", addr, err) + } + mux := s.routes() + httpSrv := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + fmt.Printf("dashboard listening on http://%s\n", addr) + if !s.opts.NoOpen { + go openBrowser("http://" + addr) + } + + errCh := make(chan error, 1) + go func() { + if err := httpSrv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) + return nil + case err := <-errCh: + return err + } +} + +// openBrowser is best-effort; failure is silent (URL is already printed). +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + cmd = exec.Command("open", url) + default: + cmd = exec.Command("xdg-open", url) + } + cmd.Stdout = nil + cmd.Stderr = nil + _ = cmd.Start() +} + +// templateFuncs exposes the small set of helpers the templates need. +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "statusIcon": func(s string) string { + switch s { + case " ": + return "○" + case "/": + return "◐" + case "x": + return "●" + case "!": + return "⛔" + } + return "?" + }, + "statusClass": func(s string) string { + switch s { + case " ": + return "pending" + case "/": + return "doing" + case "x": + return "done" + case "!": + return "blocked" + } + return "" + }, + "trim": strings.TrimSpace, + "shortHash": func(s string) string { + if len(s) > 8 { + return s[:8] + } + return s + }, + "date": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, + } +} + +// ensureStatusDir is unused but kept to make adding new mkdir-on-demand cases trivial. +var _ = os.MkdirAll diff --git a/tools/gnb/internal/dashboard/templates/layout.html b/tools/gnb/internal/dashboard/templates/layout.html new file mode 100644 index 00000000..69cea7a5 --- /dev/null +++ b/tools/gnb/internal/dashboard/templates/layout.html @@ -0,0 +1,166 @@ +<!DOCTYPE html> +<html lang="zh-CN"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>gnb dashboard — {{.Milestone}} + + + + + +
+

gnb dashboard

+ {{.Milestone}} + {{.Status}} +
+ {{.Preset}} + {{shortHash .Version}} + {{.OS}} +
+
+ +
+ + + +
+ {{template "todo_panel" .}} +
+ +
+
点击任意任务查看详情(spec / journal / blocker)。
+
+ +
+ +
+ http://127.0.0.1 · 修改 TODO.md 后刷新页面或点击任务可见最新 + htmx 1.9.10 +
+ + + diff --git a/tools/gnb/internal/dashboard/templates/partials.html b/tools/gnb/internal/dashboard/templates/partials.html new file mode 100644 index 00000000..1c84d9c4 --- /dev/null +++ b/tools/gnb/internal/dashboard/templates/partials.html @@ -0,0 +1,111 @@ +{{define "todo_panel"}} +

TODO

+ +{{if gt .RecentSize 10}} +
+ 最近完成 已有 {{.RecentSize}} 条,建议在终端运行 gnb todo archive 归档。 +
+{{end}} + +{{range .Sections}} +
+

{{.Heading}}

+ {{if .Tasks}} + {{range .Tasks}} +
+ {{statusIcon (printf "%s" .Status)}} +
+ #{{printf "%05d" .ID}} + {{if .Priority}}{{.Priority}}{{end}} + {{if .Type}}{{.Type}}{{end}} + {{.Title}} + {{if or .Arrow .Paren}} +
+ {{if .Arrow}}→ {{.Arrow}}{{end}} + {{if .Paren}} ({{.Paren}}){{end}} +
+ {{end}} +
+ + {{if ne (printf "%s" .Status) "x"}} + + {{end}} + {{if and (ne (printf "%s" .Status) "!") (ne (printf "%s" .Status) "x")}} + + {{end}} + +
+ {{end}} + {{else}} +
(暂无)
+ {{end}} +
+{{end}} + +

最近 Journal

+{{if .Journals}} + {{range .Journals}} +
+
{{.Date}} · #{{printf "%05d" .ID}} · {{.Type}}
+
{{.Title}}
+
+ {{end}} +{{else}} +
(尚无 journal 记录)
+{{end}} +{{end}} + + +{{define "task_detail"}} +
+

+ {{statusIcon (printf "%s" .Task.Status)}} + {{if .Task.Priority}}{{.Task.Priority}}{{end}} + {{if .Task.Type}}{{.Task.Type}}{{end}} + {{.Task.Title}} +

+ + + {{if .HasSpec}} +
+

spec

+
{{.SpecBody}}
+
+ {{end}} + + {{if .HasJournal}} +
+

journal

+
{{.JournalBody}}
+
+ {{end}} + + {{if .HasBlocker}} +
+

blocker

+
{{.BlockerBody}}
+
+ {{end}} + + {{if and (not .HasSpec) (not .HasJournal) (not .HasBlocker)}} +
这个任务没有 spec / journal / blocker 文件。
+ {{end}} +
+{{end}} diff --git a/tools/gnb/internal/spec/archive.go b/tools/gnb/internal/spec/archive.go new file mode 100644 index 00000000..ef86388a --- /dev/null +++ b/tools/gnb/internal/spec/archive.go @@ -0,0 +1,207 @@ +package spec + +import ( + "fmt" + "os" + "regexp" + "strings" + "time" +) + +// ArchiveOptions controls which tasks in the "最近完成" section get moved to +// ARCHIVE.md. At most one of OlderThanDays or Keep should be > 0; if both are +// zero, every task in "最近完成" is archived. +type ArchiveOptions struct { + OlderThanDays int // archive entries whose Paren parses as a date older than today-N + Keep int // archive everything except the most recent N entries + Bucket string // override monthly bucket "YYYY-MM"; defaults to time.Now() +} + +// ArchiveResult is what the command prints. +type ArchiveResult struct { + Moved []Task + Bucket string + Archive string // path to ARCHIVE.md + TODOPath string +} + +// Archive runs the archive operation. Returns ArchiveResult with len(Moved)==0 +// when nothing matches; that's not an error. +func Archive(repoRoot string, opts ArchiveOptions) (ArchiveResult, error) { + res := ArchiveResult{ + Archive: ArchivePath(repoRoot), + TODOPath: TODOPath(repoRoot), + } + doc, err := Parse(res.TODOPath) + if err != nil { + return res, err + } + recents := doc.SectionTasks(SectionRecent) + if len(recents) == 0 { + return res, nil + } + + var move, keep []Task + switch { + case opts.Keep > 0: + if len(recents) <= opts.Keep { + return res, nil + } + move = recents[:len(recents)-opts.Keep] + keep = recents[len(recents)-opts.Keep:] + case opts.OlderThanDays > 0: + cutoff := time.Now().AddDate(0, 0, -opts.OlderThanDays) + for _, t := range recents { + if d, ok := parseDateLoose(t.Paren); ok && d.Before(cutoff) { + move = append(move, t) + } else { + keep = append(keep, t) + } + } + default: + move = recents + } + if len(move) == 0 { + return res, nil + } + + bucket := opts.Bucket + if bucket == "" { + bucket = time.Now().Format("2006-01") + } + res.Moved = move + res.Bucket = bucket + + if err := appendToArchive(res.Archive, bucket, move); err != nil { + return res, err + } + + lineNums := make([]int, 0, len(move)) + for _, t := range move { + lineNums = append(lineNums, t.LineNum) + } + doc.RemoveLines(lineNums) + // If "最近完成" is empty after removal, restore the "(暂无)" placeholder. + if len(doc.SectionTasks(SectionRecent)) == 0 { + insertPlaceholderIfEmpty(doc, SectionRecent) + } + if err := doc.Save(); err != nil { + return res, err + } + return res, nil +} + +func parseDateLoose(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + for _, layout := range []string{"2006-01-02", "2006/01/02", time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +var bucketHeadingRE = regexp.MustCompile(`^##\s+(\d{4}-\d{2})\s*$`) + +func appendToArchive(path string, bucket string, tasks []Task) error { + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + text := strings.ReplaceAll(string(data), "\r\n", "\n") + if text == "" { + text = "# Archive\n" + } + lines := strings.Split(strings.TrimRight(text, "\n"), "\n") + + bucketIdx := -1 + for i, line := range lines { + if m := bucketHeadingRE.FindStringSubmatch(line); m != nil && m[1] == bucket { + bucketIdx = i + break + } + } + + formatted := make([]string, 0, len(tasks)) + for _, t := range tasks { + formatted = append(formatted, FormatLine(t)) + } + + if bucketIdx == -1 { + // Insert a new "## YYYY-MM" section right after the "# Archive" title (or at top if not found). + titleIdx := -1 + for i, l := range lines { + if strings.HasPrefix(l, "# ") { + titleIdx = i + break + } + } + insertAt := titleIdx + 1 + newBlock := []string{"", "## " + bucket, ""} + newBlock = append(newBlock, formatted...) + out := make([]string, 0, len(lines)+len(newBlock)) + out = append(out, lines[:insertAt]...) + out = append(out, newBlock...) + out = append(out, lines[insertAt:]...) + lines = out + } else { + // Append after the existing bucket's last task line (or right after heading if empty). + end := bucketIdx + 1 + for end < len(lines) { + line := lines[end] + if strings.HasPrefix(line, "## ") || strings.HasPrefix(line, "# ") { + break + } + end++ + } + // Trim trailing blank lines within the bucket. + for end > bucketIdx+1 && strings.TrimSpace(lines[end-1]) == "" { + end-- + } + out := make([]string, 0, len(lines)+len(formatted)) + out = append(out, lines[:end]...) + out = append(out, formatted...) + out = append(out, lines[end:]...) + lines = out + } + + content := strings.Join(lines, "\n") + "\n" + return os.WriteFile(path, []byte(content), 0644) +} + +// insertPlaceholderIfEmpty puts a "(暂无)" line inside an empty section so the +// document reads nicely. The section heading must already exist. +func insertPlaceholderIfEmpty(d *Document, section SectionKind) { + rng, ok := d.sectionRanges[section] + if !ok { + return + } + hasContent := false + for i := rng[0]; i < rng[1]; i++ { + if strings.TrimSpace(d.Lines[i]) != "" { + hasContent = true + break + } + } + if hasContent { + return + } + // Replace the entire range with: blank, "(暂无)", blank + prefix := append([]string{}, d.Lines[:rng[0]]...) + suffix := append([]string{}, d.Lines[rng[1]:]...) + body := []string{"", "(暂无)", ""} + d.Lines = append(prefix, append(body, suffix...)...) + d.sectionRanges[section] = [2]int{rng[0], rng[0] + len(body)} + delta := len(body) - (rng[1] - rng[0]) + for k, r := range d.sectionRanges { + if k == section { + continue + } + if r[0] >= rng[1] { + d.sectionRanges[k] = [2]int{r[0] + delta, r[1] + delta} + } + } +} + +// formattedRange is a tiny helper retained for testing; unused at runtime. +var _ = fmt.Sprintf diff --git a/tools/gnb/internal/spec/journal.go b/tools/gnb/internal/spec/journal.go new file mode 100644 index 00000000..6fdf820a --- /dev/null +++ b/tools/gnb/internal/spec/journal.go @@ -0,0 +1,132 @@ +package spec + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// JournalStub is the minimal completion report layout written by `gnb todo done`. +// The actual report content is expected to be filled in by the AGENT during +// workflow execution; this stub gives it a consistent skeleton. +type JournalStub struct { + TaskID int + BuildOK bool + Completed time.Time + Summary string // optional initial summary + Files []string // optional file list + Notes string // optional risks/leftover +} + +// WriteJournalStub creates journal/.md if it doesn't exist, returning the +// path written. If it already exists, no write happens and the existing path +// is returned with os.ErrExist wrapped. +func WriteJournalStub(repoRoot string, j JournalStub) (string, error) { + if err := os.MkdirAll(JournalDir(repoRoot), 0755); err != nil { + return "", err + } + path := JournalPath(repoRoot, j.TaskID) + if _, err := os.Stat(path); err == nil { + return path, fmt.Errorf("%w: %s", os.ErrExist, path) + } + body := buildJournalBody(j) + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + return "", err + } + return path, nil +} + +func buildJournalBody(j JournalStub) string { + t := j.Completed + if t.IsZero() { + t = time.Now() + } + var b strings.Builder + fmt.Fprintf(&b, "---\n") + fmt.Fprintf(&b, "task: %05d\n", j.TaskID) + fmt.Fprintf(&b, "completed: %s\n", t.Format(time.RFC3339)) + fmt.Fprintf(&b, "build_ok: %t\n", j.BuildOK) + fmt.Fprintf(&b, "---\n\n") + b.WriteString("## 做了什么\n\n") + if j.Summary != "" { + b.WriteString(j.Summary) + b.WriteString("\n\n") + } else { + b.WriteString("…\n\n") + } + b.WriteString("## 改动文件\n\n") + if len(j.Files) > 0 { + for _, f := range j.Files { + fmt.Fprintf(&b, "- `%s`\n", f) + } + b.WriteByte('\n') + } else { + b.WriteString("- …\n\n") + } + b.WriteString("## 风险/遗留\n\n") + if j.Notes != "" { + b.WriteString(j.Notes) + b.WriteString("\n") + } else { + b.WriteString("- 无\n") + } + return b.String() +} + +// BlockerStub is the minimal blocker file layout written by `gnb todo block`. +type BlockerStub struct { + TaskID int + BlockedAt time.Time + Reason string +} + +func WriteBlockerStub(repoRoot string, b BlockerStub) (string, error) { + if err := os.MkdirAll(BlockerDir(repoRoot), 0755); err != nil { + return "", err + } + path := BlockerPath(repoRoot, b.TaskID) + if _, err := os.Stat(path); err == nil { + return path, fmt.Errorf("%w: %s", os.ErrExist, path) + } + body := buildBlockerBody(b) + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + return "", err + } + return path, nil +} + +func buildBlockerBody(b BlockerStub) string { + t := b.BlockedAt + if t.IsZero() { + t = time.Now() + } + var sb strings.Builder + fmt.Fprintf(&sb, "---\n") + fmt.Fprintf(&sb, "task: %05d\n", b.TaskID) + fmt.Fprintf(&sb, "blocked_at: %s\n", t.Format(time.RFC3339)) + fmt.Fprintf(&sb, "---\n\n") + sb.WriteString("## 歧义点\n\n") + if b.Reason != "" { + sb.WriteString(b.Reason) + sb.WriteString("\n\n") + } else { + sb.WriteString("…\n\n") + } + sb.WriteString("## 候选方案\n\n- …\n") + return sb.String() +} + +// ReadIfExists is a small helper for `gnb todo show` that returns the file +// contents and true if the file exists, or "" and false otherwise. +func ReadIfExists(path string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + return string(data), true +} + +// Sanity: ensure JournalDir/BlockerDir/SpecsDir helpers are usable by callers. +var _ = filepath.Join diff --git a/tools/gnb/internal/spec/paths.go b/tools/gnb/internal/spec/paths.go new file mode 100644 index 00000000..c6a95cca --- /dev/null +++ b/tools/gnb/internal/spec/paths.go @@ -0,0 +1,36 @@ +package spec + +import ( + "fmt" + "path/filepath" +) + +const ( + dirName = ".spec" + todoFile = "TODO.md" + archiveFile = "ARCHIVE.md" + journalDir = "journal" + blockerDir = "blockers" + specDir = "specs" +) + +func Root(repoRoot string) string { return filepath.Join(repoRoot, dirName) } +func TODOPath(repoRoot string) string { return filepath.Join(Root(repoRoot), todoFile) } +func ArchivePath(repoRoot string) string { return filepath.Join(Root(repoRoot), archiveFile) } +func JournalDir(repoRoot string) string { return filepath.Join(Root(repoRoot), journalDir) } +func BlockerDir(repoRoot string) string { return filepath.Join(Root(repoRoot), blockerDir) } +func SpecsDir(repoRoot string) string { return filepath.Join(Root(repoRoot), specDir) } +func JournalPath(repoRoot string, id int) string { + return filepath.Join(JournalDir(repoRoot), fmt.Sprintf("%05d.md", id)) +} +func BlockerPath(repoRoot string, id int) string { + return filepath.Join(BlockerDir(repoRoot), fmt.Sprintf("%05d.md", id)) +} +func SpecPath(repoRoot string, id int) string { + return filepath.Join(SpecsDir(repoRoot), fmt.Sprintf("%05d.md", id)) +} + +// JournalRel/BlockerRel/SpecRel return the relative reference used inside TODO.md. +func JournalRel(id int) string { return fmt.Sprintf("journal/%05d.md", id) } +func BlockerRel(id int) string { return fmt.Sprintf("blockers/%05d.md", id) } +func SpecRel(id int) string { return fmt.Sprintf("specs/%05d.md", id) } diff --git a/tools/gnb/internal/spec/spec.go b/tools/gnb/internal/spec/spec.go new file mode 100644 index 00000000..2b19302f --- /dev/null +++ b/tools/gnb/internal/spec/spec.go @@ -0,0 +1,378 @@ +// Package spec implements the .spec/ interactive workflow data model: +// parsing and rewriting TODO.md, ARCHIVE.md, and per-task journal/blocker files. +package spec + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +type Status string + +const ( + StatusPending Status = " " + StatusDoing Status = "/" + StatusDone Status = "x" + StatusBlocked Status = "!" +) + +type SectionKind int + +const ( + SectionUnknown SectionKind = iota + SectionNext + SectionBacklog + SectionRecent +) + +func (s SectionKind) Heading() string { + switch s { + case SectionNext: + return "### 下一步" + case SectionBacklog: + return "### 待规划" + case SectionRecent: + return "### 最近完成" + } + return "" +} + +func parseSectionHeading(line string) SectionKind { + switch strings.TrimSpace(line) { + case "### 下一步": + return SectionNext + case "### 待规划": + return SectionBacklog + case "### 最近完成": + return SectionRecent + } + return SectionUnknown +} + +// Task represents a single line in TODO.md. +type Task struct { + ID int + Status Status + Priority string // "P0", "P1", "P2", or empty + Type string // "BUG", "FEAT", "IDEA", "SPIKE", "REFACTOR", "DOC", or empty + Title string + Arrow string // path after " → ", e.g. "journal/00017.md" or "specs/00019.md" + Paren string // trailing "(...)" content: a date for [x], or path for [!] + Section SectionKind + LineNum int // 1-indexed line in source + Raw string +} + +func (t Task) FormattedID() string { + return fmt.Sprintf("#%05d", t.ID) +} + +// Document holds parsed TODO.md state plus the raw line buffer used to round-trip edits. +type Document struct { + Path string + Lines []string + Tasks []Task + Milestone string + MilestoneStatus string + // sectionRanges maps a section to [startLine, endLine) line indices (0-based). + // startLine is the first line AFTER the section heading; endLine is the line + // of the next heading (or len(Lines)). Used by inserts. + sectionRanges map[SectionKind][2]int +} + +var ( + taskHeadRE = regexp.MustCompile("^- \\[([ /x!])\\] `#(\\d+)`\\s*(.*)$") + tagRE = regexp.MustCompile(`^\[([A-Z][A-Z0-9]*)\]`) + milestoneRE = regexp.MustCompile(`^##\s+Milestone:\s*(.+?)(?:\s*)?\s*$`) +) + +// Parse reads a TODO.md file and produces a Document. +func Parse(path string) (*Document, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return parseBytes(path, data) +} + +func parseBytes(path string, data []byte) (*Document, error) { + text := strings.ReplaceAll(string(data), "\r\n", "\n") + // Trim a single trailing newline so we round-trip without growing. + trimmed := strings.TrimRight(text, "\n") + lines := strings.Split(trimmed, "\n") + if trimmed == "" { + lines = nil + } + + doc := &Document{Path: path, Lines: lines, sectionRanges: map[SectionKind][2]int{}} + + currentSection := SectionUnknown + sectionStart := 0 + closeSection := func(endIdx int) { + if currentSection != SectionUnknown { + doc.sectionRanges[currentSection] = [2]int{sectionStart, endIdx} + } + } + + for i, line := range lines { + if m := milestoneRE.FindStringSubmatch(line); m != nil { + doc.Milestone = strings.TrimSpace(m[1]) + doc.MilestoneStatus = m[2] + if doc.MilestoneStatus == "" { + doc.MilestoneStatus = "active" + } + continue + } + if strings.HasPrefix(line, "### ") || strings.HasPrefix(line, "## ") { + closeSection(i) + currentSection = parseSectionHeading(line) + sectionStart = i + 1 + continue + } + if currentSection == SectionUnknown { + continue + } + if t, ok := parseTaskLine(line, i+1, currentSection); ok { + doc.Tasks = append(doc.Tasks, t) + } + } + closeSection(len(lines)) + return doc, nil +} + +func parseTaskLine(line string, lineNum int, section SectionKind) (Task, bool) { + m := taskHeadRE.FindStringSubmatch(line) + if m == nil { + return Task{}, false + } + id, err := strconv.Atoi(m[2]) + if err != nil { + return Task{}, false + } + t := Task{ + ID: id, + Status: Status(m[1]), + Section: section, + LineNum: lineNum, + Raw: line, + } + rest := m[3] + // Strip leading [TAG][TAG]... — classify P\d as priority, anything else as type. + for { + tm := tagRE.FindStringSubmatch(rest) + if tm == nil { + break + } + tag := tm[1] + if len(tag) == 2 && tag[0] == 'P' && tag[1] >= '0' && tag[1] <= '9' { + t.Priority = tag + } else { + t.Type = tag + } + rest = strings.TrimLeft(rest[len(tm[0]):], " \t") + } + // Pull a trailing "(...)" first so it doesn't get glued to the arrow path. + if strings.HasSuffix(rest, ")") { + if pidx := strings.LastIndex(rest, " ("); pidx >= 0 { + t.Paren = rest[pidx+2 : len(rest)-1] + rest = strings.TrimRight(rest[:pidx], " \t") + } + } + // Pull a trailing " → ". + if idx := strings.LastIndex(rest, " → "); idx >= 0 { + t.Arrow = strings.TrimSpace(rest[idx+len(" → "):]) + rest = strings.TrimRight(rest[:idx], " \t") + } + t.Title = rest + return t, true +} + +// FormatLine produces the canonical TODO.md line for a task. +func FormatLine(t Task) string { + var b strings.Builder + fmt.Fprintf(&b, "- [%s] `#%05d` ", string(t.Status), t.ID) + if t.Priority != "" { + fmt.Fprintf(&b, "[%s]", t.Priority) + } + if t.Type != "" { + fmt.Fprintf(&b, "[%s]", t.Type) + } + if t.Priority != "" || t.Type != "" { + b.WriteByte(' ') + } + b.WriteString(t.Title) + if t.Arrow != "" { + fmt.Fprintf(&b, " → %s", t.Arrow) + } + if t.Paren != "" { + fmt.Fprintf(&b, " (%s)", t.Paren) + } + return b.String() +} + +// Save writes the current Lines buffer back to disk with a trailing newline. +func (d *Document) Save() error { + content := strings.Join(d.Lines, "\n") + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + return os.WriteFile(d.Path, []byte(content), 0644) +} + +// FindTask returns the task with the given ID and its index in d.Tasks. +func (d *Document) FindTask(id int) (*Task, int, bool) { + for i := range d.Tasks { + if d.Tasks[i].ID == id { + return &d.Tasks[i], i, true + } + } + return nil, -1, false +} + +// MaxID returns the largest task ID in the document, or 0 if none. +func (d *Document) MaxID() int { + max := 0 + for _, t := range d.Tasks { + if t.ID > max { + max = t.ID + } + } + return max +} + +// MarkStatus rewrites the line for the given task ID to the given status. +// For StatusDone it also fills Arrow and Paren with a journal link + date. +func (d *Document) MarkStatus(id int, status Status, opts ...EditOption) error { + t, idx, ok := d.FindTask(id) + if !ok { + return fmt.Errorf("task #%05d not found in %s", id, filepath.Base(d.Path)) + } + t.Status = status + for _, opt := range opts { + opt(t) + } + d.Tasks[idx] = *t + d.Lines[t.LineNum-1] = FormatLine(*t) + return nil +} + +// EditOption is a small functional-options knob for MarkStatus/Edit. +type EditOption func(*Task) + +func WithArrow(path string) EditOption { return func(t *Task) { t.Arrow = path } } +func WithParen(text string) EditOption { return func(t *Task) { t.Paren = text } } +func WithClearArrow() EditOption { return func(t *Task) { t.Arrow = "" } } +func WithClearParen() EditOption { return func(t *Task) { t.Paren = "" } } + +// AppendTask inserts a new task line at the end of the given section, returning +// the assigned ID (max existing + 1, capped at 99999). +func (d *Document) AppendTask(section SectionKind, t Task) (int, error) { + rng, ok := d.sectionRanges[section] + if !ok { + return 0, fmt.Errorf("section %q not present in %s", section.Heading(), filepath.Base(d.Path)) + } + if t.ID == 0 { + t.ID = d.MaxID() + 1 + } + if t.ID > 99999 { + return 0, fmt.Errorf("task id exceeds 5-digit range") + } + if t.Status == "" { + t.Status = StatusPending + } + t.Section = section + line := FormatLine(t) + + // Insert before the last trailing blank line of the section, if any; otherwise + // at the section end. Also remove a literal "(暂无)" placeholder. + insertAt := rng[1] + for insertAt > rng[0] && strings.TrimSpace(d.Lines[insertAt-1]) == "" { + insertAt-- + } + // Remove "(暂无)" placeholder line if it's the only content. + if insertAt-1 >= rng[0] && strings.TrimSpace(d.Lines[insertAt-1]) == "(暂无)" { + d.Lines = append(d.Lines[:insertAt-1], d.Lines[insertAt:]...) + d.shiftRanges(insertAt-1, -1) + insertAt-- + } + + newLines := make([]string, 0, len(d.Lines)+1) + newLines = append(newLines, d.Lines[:insertAt]...) + newLines = append(newLines, line) + newLines = append(newLines, d.Lines[insertAt:]...) + d.Lines = newLines + d.shiftRanges(insertAt, 1) + + t.LineNum = insertAt + 1 + t.Raw = line + d.Tasks = append(d.Tasks, t) + return t.ID, nil +} + +// RemoveLines removes the given 1-indexed line numbers from the buffer and +// updates section ranges accordingly. Task parsing is NOT re-done; callers that +// need a fresh task slice should re-Parse. +func (d *Document) RemoveLines(lineNums []int) { + if len(lineNums) == 0 { + return + } + drop := make(map[int]bool, len(lineNums)) + for _, n := range lineNums { + drop[n-1] = true + } + out := make([]string, 0, len(d.Lines)) + removedBefore := make([]int, len(d.Lines)+1) + count := 0 + for i, line := range d.Lines { + removedBefore[i] = count + if drop[i] { + count++ + continue + } + out = append(out, line) + } + removedBefore[len(d.Lines)] = count + d.Lines = out + for k, rng := range d.sectionRanges { + start := rng[0] - removedBefore[rng[0]] + end := rng[1] - removedBefore[rng[1]] + d.sectionRanges[k] = [2]int{start, end} + } + // Rebuild Tasks + kept := d.Tasks[:0] + for _, t := range d.Tasks { + if !drop[t.LineNum-1] { + t.LineNum -= removedBefore[t.LineNum-1] + kept = append(kept, t) + } + } + d.Tasks = kept +} + +// SectionTasks returns all tasks currently parsed in the given section. +func (d *Document) SectionTasks(section SectionKind) []Task { + var out []Task + for _, t := range d.Tasks { + if t.Section == section { + out = append(out, t) + } + } + return out +} + +func (d *Document) shiftRanges(insertedAt, delta int) { + for k, rng := range d.sectionRanges { + start, end := rng[0], rng[1] + if insertedAt <= start { + start += delta + } + if insertedAt < end || (insertedAt == end && delta > 0 && rng[1] == insertedAt) { + end += delta + } + d.sectionRanges[k] = [2]int{start, end} + } +} diff --git a/tools/gnb/internal/spec/spec_test.go b/tools/gnb/internal/spec/spec_test.go new file mode 100644 index 00000000..94a5a65c --- /dev/null +++ b/tools/gnb/internal/spec/spec_test.go @@ -0,0 +1,293 @@ +package spec + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const sampleTODO = `# TODO + +## Milestone: 测试 + +里程碑目标:测试。 + +### 下一步 + +- [ ] ` + "`#00018`" + ` [P0][BUG] 修复贴图采样越界 +- [/] ` + "`#00019`" + ` [P1][FEAT] 体积雾 → specs/00019.md +- [!] ` + "`#00020`" + ` [SPIKE] work graphs (blockers/00020.md) + +### 待规划 + +- [ ] ` + "`#00021`" + ` [IDEA] 试试 NRD 降噪 + +### 最近完成 + +- [x] ` + "`#00017`" + ` [BUG] 修复贴图过滤 → journal/00017.md (2026-05-13) +` + +func TestParseStatuses(t *testing.T) { + doc, err := parseBytes("TODO.md", []byte(sampleTODO)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if doc.Milestone != "测试" { + t.Errorf("milestone = %q, want 测试", doc.Milestone) + } + if doc.MilestoneStatus != "active" { + t.Errorf("status = %q, want active", doc.MilestoneStatus) + } + if got := len(doc.Tasks); got != 5 { + t.Fatalf("tasks = %d, want 5", got) + } + cases := []struct { + idx int + id int + status Status + pri string + typ string + title string + arrow string + paren string + section SectionKind + }{ + {0, 18, StatusPending, "P0", "BUG", "修复贴图采样越界", "", "", SectionNext}, + {1, 19, StatusDoing, "P1", "FEAT", "体积雾", "specs/00019.md", "", SectionNext}, + {2, 20, StatusBlocked, "", "SPIKE", "work graphs", "", "blockers/00020.md", SectionNext}, + {3, 21, StatusPending, "", "IDEA", "试试 NRD 降噪", "", "", SectionBacklog}, + {4, 17, StatusDone, "", "BUG", "修复贴图过滤", "journal/00017.md", "2026-05-13", SectionRecent}, + } + for _, c := range cases { + got := doc.Tasks[c.idx] + if got.ID != c.id || got.Status != c.status || got.Priority != c.pri || + got.Type != c.typ || got.Title != c.title || got.Arrow != c.arrow || + got.Paren != c.paren || got.Section != c.section { + t.Errorf("task[%d] = %+v, want id=%d status=%q pri=%q typ=%q title=%q arrow=%q paren=%q section=%d", + c.idx, got, c.id, c.status, c.pri, c.typ, c.title, c.arrow, c.paren, c.section) + } + } +} + +func TestRoundTripFormatLine(t *testing.T) { + doc, err := parseBytes("TODO.md", []byte(sampleTODO)) + if err != nil { + t.Fatalf("parse: %v", err) + } + for _, task := range doc.Tasks { + got := FormatLine(task) + if got != task.Raw { + t.Errorf("FormatLine round-trip: got %q want %q", got, task.Raw) + } + } +} + +func TestMarkDone(t *testing.T) { + doc, _ := parseBytes("TODO.md", []byte(sampleTODO)) + if err := doc.MarkStatus(18, StatusDone, WithArrow(JournalRel(18)), WithParen("2026-05-14")); err != nil { + t.Fatalf("MarkStatus: %v", err) + } + task, _, _ := doc.FindTask(18) + if task.Status != StatusDone { + t.Errorf("status = %q, want x", task.Status) + } + want := "- [x] `#00018` [P0][BUG] 修复贴图采样越界 → journal/00018.md (2026-05-14)" + if doc.Lines[task.LineNum-1] != want { + t.Errorf("line = %q\nwant %q", doc.Lines[task.LineNum-1], want) + } +} + +func TestMarkBlocked(t *testing.T) { + doc, _ := parseBytes("TODO.md", []byte(sampleTODO)) + if err := doc.MarkStatus(18, StatusBlocked, WithParen(BlockerRel(18))); err != nil { + t.Fatalf("MarkStatus: %v", err) + } + task, _, _ := doc.FindTask(18) + want := "- [!] `#00018` [P0][BUG] 修复贴图采样越界 (blockers/00018.md)" + if doc.Lines[task.LineNum-1] != want { + t.Errorf("line = %q\nwant %q", doc.Lines[task.LineNum-1], want) + } +} + +func TestAppendTaskAssignsNextID(t *testing.T) { + doc, _ := parseBytes("TODO.md", []byte(sampleTODO)) + id, err := doc.AppendTask(SectionBacklog, Task{Type: "FEAT", Priority: "P1", Title: "测试新任务"}) + if err != nil { + t.Fatalf("AppendTask: %v", err) + } + if id != 22 { // max in sample is 21 + t.Errorf("new id = %d, want 22", id) + } + // Verify the line ended up inside the backlog section. + doc2, _ := parseBytes("TODO.md", []byte(strings.Join(doc.Lines, "\n")+"\n")) + task, _, ok := doc2.FindTask(22) + if !ok { + t.Fatal("new task not found after re-parse") + } + if task.Section != SectionBacklog { + t.Errorf("section = %d, want %d (backlog)", task.Section, SectionBacklog) + } +} + +func TestAppendReplacesPlaceholder(t *testing.T) { + empty := `# TODO + +## Milestone: 空 + +### 下一步 + +(暂无) + +### 待规划 + +### 最近完成 + +(暂无) +` + doc, err := parseBytes("TODO.md", []byte(empty)) + if err != nil { + t.Fatalf("parse: %v", err) + } + id, err := doc.AppendTask(SectionNext, Task{Type: "BUG", Title: "首个任务"}) + if err != nil { + t.Fatalf("AppendTask: %v", err) + } + if id != 1 { + t.Errorf("first id = %d, want 1", id) + } + joined := strings.Join(doc.Lines, "\n") + if strings.Contains(joined, "### 下一步\n\n(暂无)") { + t.Errorf("placeholder still present:\n%s", joined) + } + if !strings.Contains(joined, "- [ ] `#00001` [BUG] 首个任务") { + t.Errorf("new task line missing:\n%s", joined) + } +} + +func TestRemoveLines(t *testing.T) { + doc, _ := parseBytes("TODO.md", []byte(sampleTODO)) + taskBefore, _, _ := doc.FindTask(17) + doc.RemoveLines([]int{taskBefore.LineNum}) + if _, _, ok := doc.FindTask(17); ok { + t.Fatal("task 17 should be removed") + } + // task 21's line number should have shifted up by one + t21, _, _ := doc.FindTask(21) + if t21.LineNum != 17 && t21.LineNum != 18 { + // loose check: just make sure FormatLine at that line matches. + if FormatLine(*t21) != doc.Lines[t21.LineNum-1] { + t.Errorf("task 21 line stale: %q vs %q", FormatLine(*t21), doc.Lines[t21.LineNum-1]) + } + } +} + +func TestArchiveByKeep(t *testing.T) { + dir := t.TempDir() + specDir := filepath.Join(dir, ".spec") + if err := os.MkdirAll(specDir, 0755); err != nil { + t.Fatal(err) + } + todo := `# TODO + +## Milestone: 测试 + +### 下一步 + +(暂无) + +### 待规划 + +(暂无) + +### 最近完成 + +- [x] ` + "`#00001`" + ` [BUG] 一 → journal/00001.md (2026-05-10) +- [x] ` + "`#00002`" + ` [BUG] 二 → journal/00002.md (2026-05-11) +- [x] ` + "`#00003`" + ` [BUG] 三 → journal/00003.md (2026-05-12) +- [x] ` + "`#00004`" + ` [BUG] 四 → journal/00004.md (2026-05-13) +` + if err := os.WriteFile(filepath.Join(specDir, "TODO.md"), []byte(todo), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(specDir, "ARCHIVE.md"), []byte("# Archive\n"), 0644); err != nil { + t.Fatal(err) + } + res, err := Archive(dir, ArchiveOptions{Keep: 2, Bucket: "2026-05"}) + if err != nil { + t.Fatalf("Archive: %v", err) + } + if len(res.Moved) != 2 { + t.Fatalf("moved = %d, want 2", len(res.Moved)) + } + if res.Moved[0].ID != 1 || res.Moved[1].ID != 2 { + t.Errorf("moved ids = %v, want [1 2]", []int{res.Moved[0].ID, res.Moved[1].ID}) + } + // TODO.md should retain tasks 3 and 4. + todoAfter, _ := os.ReadFile(filepath.Join(specDir, "TODO.md")) + if strings.Contains(string(todoAfter), "`#00001`") || strings.Contains(string(todoAfter), "`#00002`") { + t.Errorf("archived tasks still in TODO.md:\n%s", todoAfter) + } + if !strings.Contains(string(todoAfter), "`#00003`") || !strings.Contains(string(todoAfter), "`#00004`") { + t.Errorf("kept tasks missing from TODO.md:\n%s", todoAfter) + } + // ARCHIVE.md should have a "## 2026-05" bucket with tasks 1 and 2. + archAfter, _ := os.ReadFile(filepath.Join(specDir, "ARCHIVE.md")) + if !strings.Contains(string(archAfter), "## 2026-05") { + t.Errorf("bucket missing:\n%s", archAfter) + } + if !strings.Contains(string(archAfter), "`#00001`") || !strings.Contains(string(archAfter), "`#00002`") { + t.Errorf("archived tasks missing from ARCHIVE.md:\n%s", archAfter) + } +} + +func TestArchiveOlderThan(t *testing.T) { + dir := t.TempDir() + specDir := filepath.Join(dir, ".spec") + _ = os.MkdirAll(specDir, 0755) + todo := `# TODO + +## Milestone: x + +### 下一步 + +(暂无) + +### 待规划 + +(暂无) + +### 最近完成 + +- [x] ` + "`#00001`" + ` [BUG] old → journal/00001.md (2020-01-01) +- [x] ` + "`#00002`" + ` [BUG] new → journal/00002.md (2099-01-01) +` + _ = os.WriteFile(filepath.Join(specDir, "TODO.md"), []byte(todo), 0644) + _ = os.WriteFile(filepath.Join(specDir, "ARCHIVE.md"), []byte("# Archive\n"), 0644) + res, err := Archive(dir, ArchiveOptions{OlderThanDays: 7, Bucket: "2026-05"}) + if err != nil { + t.Fatalf("Archive: %v", err) + } + if len(res.Moved) != 1 || res.Moved[0].ID != 1 { + t.Errorf("moved = %+v, want [#00001]", res.Moved) + } +} + +func TestWriteJournalStub(t *testing.T) { + dir := t.TempDir() + path, err := WriteJournalStub(dir, JournalStub{TaskID: 42, BuildOK: true, Summary: "test", Files: []string{"a.go"}}) + if err != nil { + t.Fatalf("WriteJournalStub: %v", err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), "task: 00042") { + t.Errorf("missing task id: %s", data) + } + if !strings.Contains(string(data), "build_ok: true") { + t.Errorf("missing build_ok: %s", data) + } + // Re-writing should refuse. + if _, err := WriteJournalStub(dir, JournalStub{TaskID: 42}); err == nil { + t.Error("expected error on overwrite") + } +} From f6c6c5d53c7075d2565e7ef37fdd85c01dc2e85a Mon Sep 17 00:00:00 2001 From: gameKnife Date: Thu, 14 May 2026 21:12:18 +0800 Subject: [PATCH 14/27] =?UTF-8?q?=E8=A1=A5=E5=85=85=20gnb=20=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E6=A0=88=E4=B8=8E=20TypeScript=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 .spec 工作流首批任务 #00001 / #00002 跑出来的产出: - docs/gnb-tech-stack.md:gnb CLI 的启动层、CLI 层、配置层、 执行模块层、外部依赖与维护边界 - docs/typescript-integration.md:assets/typescript → assets/scripts 编译路径、bundled tsc、QuickJS 模块加载与生命周期、绑定与 Engine.d.ts 生成、热重载与验证 - README.md / docs/gnb-cli.md / tools/gnb/README.md / AGENT_GUIDE/QuickJSBindings.md:加入入口跳转链接 - .spec/journal/00001.md, 00002.md:对应任务的完成记录 Co-Authored-By: Claude Opus 4.7 --- .spec/journal/00001.md | 23 ++++ .spec/journal/00002.md | 23 ++++ AGENT_GUIDE/QuickJSBindings.md | 2 + README.md | 2 +- docs/gnb-cli.md | 3 + docs/gnb-tech-stack.md | 108 +++++++++++++++++++ docs/typescript-integration.md | 185 +++++++++++++++++++++++++++++++++ tools/gnb/README.md | 2 + 8 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 .spec/journal/00001.md create mode 100644 .spec/journal/00002.md create mode 100644 docs/gnb-tech-stack.md create mode 100644 docs/typescript-integration.md diff --git a/.spec/journal/00001.md b/.spec/journal/00001.md new file mode 100644 index 00000000..70224cec --- /dev/null +++ b/.spec/journal/00001.md @@ -0,0 +1,23 @@ +--- +task: 00001 +completed: 2026-05-14T16:53:33.4733882+08:00 +build_ok: false +--- + +## 做了什么 + +补了一份 `gnb` 技术栈说明文档,介绍它的启动层、CLI 层、配置层、执行模块层、外部依赖和维护边界。 + +同时在现有 `docs/gnb-cli.md` 和 `tools/gnb/README.md` 中加入跳转,避免 `gnb` 只有命令手册,没有实现视角的入口说明。 + +## 改动文件 + +- `docs/gnb-tech-stack.md` +- `docs/gnb-cli.md` +- `tools/gnb/README.md` +- `.spec/TODO.md` + +## 风险/遗留 + +- 未运行构建或自动化测试;本次仅修改 Markdown 文档和 spec 元数据。 +- `TODO.md` 中当前存在重复的 `#00001` 编号(“下一步”和“待规划”各一条),本次按 spec 边界未改任务标题或 ID。 diff --git a/.spec/journal/00002.md b/.spec/journal/00002.md new file mode 100644 index 00000000..c59ed5c5 --- /dev/null +++ b/.spec/journal/00002.md @@ -0,0 +1,23 @@ +--- +task: 00002 +completed: 2026-05-14T17:02:32.2719456+08:00 +build_ok: false +--- + +## 做了什么 + +新增 TypeScript 整合说明文档,介绍 `assets/typescript` 到 `assets/scripts` 的编译路径、bundled `tsc` 工具链、QuickJS 模块加载、生命周期封装、绑定与 `Engine.d.ts` 生成、热重载行为和验证方式。 + +同时在 README 的 QuickJS 能力介绍和 `AGENT_GUIDE/QuickJSBindings.md` 中加入入口链接,方便从项目概览或绑定 cookbook 跳到这份说明。 + +## 改动文件 + +- `docs/typescript-integration.md` +- `README.md` +- `AGENT_GUIDE/QuickJSBindings.md` +- `.spec/TODO.md` + +## 风险/遗留 + +- 未运行构建或自动化测试;本次仅修改 Markdown 文档和 spec 元数据。 +- 工作区中存在与本任务无关的既有变更,本次未处理。 diff --git a/AGENT_GUIDE/QuickJSBindings.md b/AGENT_GUIDE/QuickJSBindings.md index b02b217d..54aa5d08 100644 --- a/AGENT_GUIDE/QuickJSBindings.md +++ b/AGENT_GUIDE/QuickJSBindings.md @@ -2,6 +2,8 @@ This note documents the binding path used by the Flappy parity demo. Keep it aligned with `src/Runtime/Subsystems/QuickJSEngine.cpp`. +For a higher-level overview of the TypeScript source, compile, hot reload, and runtime loading pipeline, see `docs/typescript-integration.md`. + ## TypeScript Entry And Modules - TypeScript sources live under `assets/typescript`. diff --git a/README.md b/README.md index e22b63f6..b02d3507 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ gkNextEngine 是一个基于现代 C++20 与 Vulkan 的跨平台 3D 游戏引擎 - **ECS + Reflection**:基于 entt 的组件系统,加上反射层,服务于运行时、编辑器和脚本绑定 - **ImGui 编辑器**:`gkNextEditor` 面向材质、场景和运行时内容的编辑工作流 -- **QuickJS 脚本热重载**:运行时使用仓库内置 `tools/tsc/tsc[.exe]` 编译 TypeScript(Windows 为 `tsc.exe`,macOS/Linux 为 `tsc`),无需 Node/npm 或全局 `tsc` +- **QuickJS 脚本热重载**:运行时使用仓库内置 `tools/tsc/tsc[.exe]` 编译 TypeScript(Windows 为 `tsc.exe`,macOS/Linux 为 `tsc`),无需 Node/npm 或全局 `tsc`;整合链路见 [docs/typescript-integration.md](docs/typescript-integration.md) - **Jolt Physics**:为交互原型、拖拽玩法和游戏化验证提供更真实的物理基础 ### 3. 代码规模可控,适合学习和扩展 diff --git a/docs/gnb-cli.md b/docs/gnb-cli.md index edf56545..dc9eb361 100644 --- a/docs/gnb-cli.md +++ b/docs/gnb-cli.md @@ -3,6 +3,9 @@ `gnb` is the single build entry point for gkNextRenderer. Use `gnb.bat` on Windows and `./gnb.sh` on macOS/Linux when a system-wide `gnb` is not installed. +For implementation details and architecture notes, see +[gnb-tech-stack.md](gnb-tech-stack.md). + ## Setup Prepare vcpkg, external SDKs, TypeScript compiler, Slang, and optional assets: diff --git a/docs/gnb-tech-stack.md b/docs/gnb-tech-stack.md new file mode 100644 index 00000000..5a5b5a8a --- /dev/null +++ b/docs/gnb-tech-stack.md @@ -0,0 +1,108 @@ +# gnb 技术栈说明 + +`gnb` 是 `gkNextRenderer` 的统一工程入口,负责把原来分散的构建、运行、测试、资源准备、移动端入口和打包流程收敛到一个跨平台 CLI 里。命令面向使用者保持简洁,但实现上分成了几层明确的技术栈。 + +## 一句话概览 + +- 启动层:仓库根目录的 `gnb.bat` / `gnb.sh` 负责 bootstrap、本地重编译和缓存二进制切换 +- CLI 层:`tools/gnb/cmd/gnb/main.go` 使用 `cobra` 注册子命令与参数 +- 配置层:仓库根 `gnb.toml` 保存版本约束、vcpkg、外部工具链、pak 资源和目标列表 +- 执行层:`tools/gnb/internal/*` 分模块封装 CMake、vcpkg、资源下载、运行器、打包和平台逻辑 +- 产物层:构建结果仍然落在 `out/build//bin/`,`gnb` 只是统一驱动这些流程 + +## 为什么用 Go + +- `gnb` 是独立二进制,不依赖引擎本体,也不需要先把 C++ 工程编出来 +- 单文件分发适合做仓库根命令入口,Windows / Linux / macOS 都能保持一致体验 +- 标准库已经足够覆盖文件系统、进程启动、HTTP 下载、ZIP/TAR 解包等基础能力 +- 本地如果装了 Go,`gnb.bat` / `gnb.sh` 会优先从 `tools/gnb` 重新编译,方便维护者直接改 CLI + +## 实现分层 + +### 1. 启动与分发层 + +根目录的 `gnb.bat` 和 `gnb.sh` 不是完整业务脚本,而是很薄的 shim: + +- 优先使用仓库本地 `gnb(.exe)`,必要时从 `tools/gnb` 重新编译 +- 没有本地二进制时,使用 `tools/gnb-bin//` 下的缓存副本 +- 缓存缺失或版本落后时,从 GitHub release 下载预编译二进制和 `gnb-version.txt` + +这层的目标是让普通用户不必理解 Go 工具链,也能直接使用 `gnb`。 + +### 2. CLI 命令层 + +`tools/gnb/cmd/gnb/main.go` 是命令入口,核心技术是 [Cobra](https://github.com/spf13/cobra): + +- 子命令注册:`info`、`doctor`、`setup`、`build`、`run`、`test`、`visual`、`editor` +- 平台命令:`android`、`ios` +- 资源和发布命令:`paks`、`package`、`install` + +`main.go` 只做参数编排和上下文拼装,不把具体逻辑塞进命令定义里,这样后续新增子命令时不会让入口文件失控膨胀。 + +### 3. 配置与数据层 + +`gnb.toml` 是 `gnb` 的中心配置文件,`tools/gnb/internal/config/config.go` 用 `BurntSushi/toml` 解析。当前主要承载几类数据: + +- `[gnb]`:最小版本约束 +- `[vcpkg]`:vcpkg ref、本地根目录、binary cache 目录 +- `[external.*]`:Streamline、TypeScript 编译器、MoltenVK、Slang 的下载地址 +- `[paks]`:可选资源包所在仓库、release tag、每个资源的落盘位置 +- `[targets]`:默认启动目标与可运行目标白名单 + +这种做法把“仓库策略”放在 TOML,把“执行逻辑”放在 Go 代码里,便于后续调整版本、资源源地址和目标列表,而不必反复改命令实现。 + +### 4. 执行模块层 + +`tools/gnb/internal/` 里的模块按职责拆分: + +- `cmakerun`:选择 preset,执行 configure / build / clean +- `vcpkg`:bootstrap 与 toolchain 路径管理 +- `fetcher`:下载并解包外部工具链,如 Slang、TypeScript 编译器、MoltenVK +- `paks`:列出、拉取、发布可选 pak / ffmpeg / sfx 资源 +- `runner`:定位 `out/build//bin/` 下的可执行文件并启动 +- `packager`:把桌面版本或 `MagicaLego` 产物打成分发包 +- `android` / `ios`:移动端专用入口 +- `platform`:平台识别、可执行扩展名、Linux 包依赖检查 +- `console`:统一 `[gnb]` 风格输出和命令回显 + +这个拆法的好处是边界清晰。比如“下载工具链”不需要知道 CMake 参数怎么拼,“运行目标”也不需要关心 pak 发布逻辑。 + +## `gnb` 依赖了哪些外部技术 + +除了 Go 标准库,当前关键依赖主要有: + +- `spf13/cobra`:CLI 命令和参数系统 +- `BurntSushi/toml`:解析 `gnb.toml` +- `CMake` / `Ninja`:原生工程配置与构建后端 +- `vcpkg`:C++ 依赖管理与二进制缓存 +- GitHub Releases:预编译 `gnb` 二进制和可选 pak 资源的分发通道 + +换句话说,`gnb` 本身不是新的构建系统,而是站在现有 CMake + vcpkg 之上的统一控制面。 + +## 与主工程的关系 + +`gnb` 不直接链接 `src/` 下的 C++ 代码,也不参与 Vulkan / ECS / QuickJS 运行时。它的职责边界非常明确: + +- 在“构建前”准备工具链和资源 +- 在“构建时”统一驱动 CMake preset 和 target +- 在“构建后”提供运行、测试、可视化测试和打包入口 + +这意味着 `gnb` 出问题时,通常可以把问题归类到以下几个层面之一: + +- 宿主机环境缺失:`gnb doctor` +- 依赖准备不完整:`gnb setup` +- 原生工程配置或编译失败:`gnb build` +- 可执行产物缺失或路径不对:`gnb run` + +## 维护建议 + +- 加新外部工具链时,优先补 `gnb.toml`,再在 `internal/fetcher` 增加对应逻辑 +- 加新应用 target 时,同时更新 `gnb.toml` 的 `[targets]` +- 新增子命令时,保持 `main.go` 只负责组装,具体行为下沉到 `internal/` +- 不要把平台分支散落到多个命令里,优先收敛到 `internal/platform` + +## 相关文档 + +- 命令用法手册:`docs/gnb-cli.md` +- 最小命令规格:`docs/CLI_SPEC.md` +- 本地开发入口:`tools/gnb/README.md` diff --git a/docs/typescript-integration.md b/docs/typescript-integration.md new file mode 100644 index 00000000..c4ff4162 --- /dev/null +++ b/docs/typescript-integration.md @@ -0,0 +1,185 @@ +# TypeScript 整合说明 + +gkNextRenderer 的 TypeScript 链路不是独立的 Web 工具链,而是服务于引擎运行时的脚本层:开发者在 `assets/typescript` 编写 TypeScript,运行时使用仓库内置的 `tools/tsc/tsc[.exe]` 编译到 `assets/scripts`,然后由 QuickJS 以 ES module 形式加载。这个设计让脚本热重载、编辑器验证和玩法原型都能在不依赖 Node/npm/全局 `tsc` 的前提下运行。 + +## 一句话概览 + +- 源码层:TypeScript 源文件位于 `assets/typescript` +- 编译层:`assets/typescript/tsconfig.json` 输出 JavaScript 到 `assets/scripts` +- 工具链层:`gnb setup` 准备 `tools/tsc/tsc[.exe]`,CMake 把它复制到运行时 `tools/tsc` +- 运行层:`QuickJSEngine` 启动时先编译 TypeScript,再重建 QuickJS 上下文并加载入口脚本 +- 热重载层:桌面端每 0.5 秒检查一次源文件变化,编译成功后重载 QuickJS 上下文,编译失败时保留旧脚本 +- 绑定层:C++ 反射与手写 QuickJS API 共同生成 `Engine.d.ts` 和运行时 `Engine` 模块 + +## 文件布局 + +```text +assets/ +├── typescript/ +│ ├── tsconfig.json +│ ├── Engine.d.ts +│ ├── NextGameInstanceBase.ts +│ ├── test.ts +│ └── flappy/ +└── scripts/ + ├── .tsc.stamp + ├── Engine.js / Engine.d.ts 映射入口由 QuickJS 特判 + └── 编译后的 .js / .map 文件 + +tools/ +└── tsc/ + └── tsc.exe 或 tsc +``` + +`assets/typescript` 是人工维护的脚本源码目录。`assets/scripts` 是运行时读取的编译输出目录,里面的 `.tsc.stamp` 用于避免每帧重复编译。运行时默认入口是 `assets/scripts/test.js`,具体应用也可以通过 `GOption->QuickJSEntry` 指向自己的编译后脚本,例如 `FlappyJs` 使用 `assets/scripts/flappy/FlappyJs/FlappyJsGameInstance.js`。 + +## 构建和工具链准备 + +`gnb.toml` 的 `[external.tsc]` 记录了跨平台 TypeScript 编译器下载地址。执行 `gnb setup` 或首次构建时,`gnb` 会把对应平台的编译器准备到 `tools/tsc`。 + +构建资源时,`assets/CMakeLists.txt` 会把 `tools/tsc` 复制到构建输出旁边: + +```text +out/build//tools/tsc/tsc[.exe] +``` + +运行时查找顺序覆盖了几种常见场景: + +- 从构建输出目录直接运行可执行文件 +- 通过仓库根目录的 `gnb run` 启动 +- 从源码树或复制后的运行时布局启动 + +因此桌面二进制不要求当前工作目录必须是 `out/build//bin`。 + +## 运行时编译流程 + +`QuickJSEngine::Initialize()` 会先调用 `CompileTypeScriptSources()`,再调用 `ResetContextAndLoadScript()`。 + +编译流程的核心步骤是: + +1. `ResolveTypeScriptPaths()` 定位 `assets/typescript/tsconfig.json` 和 `assets/scripts` +2. `UpdateTypeScriptDefinitions()` 根据当前 C++ 绑定生成 `assets/typescript/Engine.d.ts` +3. 检查 `assets/typescript/**/*.ts`、`tsconfig.json` 与 `assets/scripts/.tsc.stamp` 的时间戳 +4. 有变化时执行 bundled `tsc -p assets/typescript/tsconfig.json` +5. 编译成功后刷新 `.tsc.stamp` +6. 编译失败时打印警告,并继续使用已有 JavaScript 输出 + +这个策略把失败影响限制在脚本层:TypeScript 写错不会直接让引擎初始化路径崩溃,但旧脚本是否还能满足当前逻辑仍需要开发者自行验证。 + +## QuickJS 模块加载 + +编译后的脚本以 ES module 形式运行。`QuickJSEngine` 的 module loader 会处理三类路径: + +- 内建模块 `Engine` +- 指向 `Engine` 的相对路径,如 `./Engine`、`../Engine` +- 编译输出树里的普通相对导入,如 `./helper`、`../FlappyCommon` + +TypeScript 侧通常这样导入引擎 API: + +```ts +import * as NE from "./Engine"; +``` + +不同目录深度的脚本也可以使用 `../Engine` 或直接使用 `Engine`,loader 会把这些形式映射回内建 `Engine` 模块。 + +## 生命周期和游戏实例 + +脚本化游戏建议继承 `NextGameInstanceBase`: + +```ts +import { NextGameInstanceBase, RunGameInstance } from "../NextGameInstanceBase"; + +class MyGameInstance extends NextGameInstanceBase { + OnInit(): void {} + OnTick(deltaSeconds: number): void {} + OnSceneLoaded(): void {} +} + +RunGameInstance(new MyGameInstance()); +``` + +`RunGameInstance()` 会把 TypeScript 类方法注册到 QuickJS 生命周期钩子,并用 `RegisterJSCallback()` 接入每帧 tick。当前常用生命周期包括: + +- `OnInit` +- `BeforeSceneRebuild` +- `OnSceneLoaded` +- `OnTick` +- `OnRenderUI` +- `OnInputEvent` +- `OverrideRenderCamera` +- `OnDestroy` + +这条路径让脚本游戏和原生 `NextGameInstanceBase` 保持类似职责边界,便于做 Flappy 这类 C++/TypeScript 行为一致性验证。 + +## 绑定和类型定义 + +TypeScript API 的来源分两类: + +- 反射生成:`Node`、`Scene`、`RenderComponent`、`PhysicsComponent`、`SkinnedMeshComponent` 等反射类型 +- 手写绑定:`Global`、`Input`、`Audio`、`UI`、`SceneBuild`、`LoadJson()`、`RequestLoadScene()` 等运行时函数 + +`BuildTypeScriptDefinitions()` 会把这些 API 写入 `assets/typescript/Engine.d.ts`。这份 `.d.ts` 是脚本编译期类型检查的入口,也是 C++ 绑定变化是否暴露给 TypeScript 的主要检查点。 + +新增绑定时,需要同步做三件事: + +1. 在 `QuickJSEngine.cpp` 中注册 QuickJS 函数或反射类型 +2. 在 `BuildTypeScriptDefinitions()` 中补齐 TypeScript 声明 +3. 在 `assets/typescript/test.ts` 或对应示例脚本里加最小调用验证 + +如果是对象形参数、JSON、可选参数或临时返回对象,优先使用原生 QuickJS C API。简单 C++ 类和稳定签名可以继续使用 `quickjspp`。 + +## 热重载行为 + +桌面端 `QuickJSEngine::TickHotReload()` 以 0.5 秒间隔调用 `CompileTypeScriptSources()`。如果编译输出更新成功,运行时会打印 TypeScript 输出更新日志,并重置 QuickJS context 重新加载入口脚本。 + +移动端当前不走这条热重载路径。Android/iOS 更偏向打包后的稳定运行布局,脚本变更需要通过正常资源构建或打包流程进入应用。 + +## 验证方式 + +只改 TypeScript 时,可以先直接运行 bundled compiler: + +```powershell +tools\tsc\tsc.exe -p assets\typescript\tsconfig.json +``` + +跨平台写法: + +```shell +tools/tsc/tsc -p assets/typescript/tsconfig.json +``` + +涉及 C++ 绑定、资源复制或运行时加载路径时,使用项目默认构建验证: + +```powershell +gnb.bat build --reconfigure +gnb.bat run gkNextRenderer +``` + +启动成功后,日志应能到达: + +```text +uploaded scene [...] to gpu +``` + +如果改动影响 Flappy 脚本绑定或输入同步,还应运行 C++/TypeScript replay 对比: + +```powershell +.\out\build\windows\bin\FlappyCpp.exe --flappy-replay +.\out\build\windows\bin\FlappyJs.exe --flappy-replay +python tools\flappy\diff_traces.py +``` + +## 维护建议 + +- 不要引入 Node/npm/global `tsc` 作为运行时前提;项目约束是 bundled `tsc` +- 新增脚本入口时,同步更新 `assets/typescript/tsconfig.json` 的 `files` +- 不要手改 `assets/scripts` 当成源码;它是编译输出 +- 修改反射组件后,确认 `Engine.d.ts` 中的类型随运行时生成逻辑同步更新 +- 脚本里修改 `Node` 的向量属性时,赋回整个对象,例如 `node.Translation = { x, y, z }` +- 不要修改 `node.Translation.x` 这类临时对象字段,它不会写回 C++ 节点 + +## 相关文档 + +- QuickJS 绑定细节:`AGENT_GUIDE/QuickJSBindings.md` +- 热重载总览:`AGENT_GUIDE/HotReload.md` +- TypeScript 代码规范检查项:`AGENT_GUIDE/coding-standards.md` diff --git a/tools/gnb/README.md b/tools/gnb/README.md index 95fe49e9..441611bb 100644 --- a/tools/gnb/README.md +++ b/tools/gnb/README.md @@ -3,6 +3,8 @@ `gnb` is the gkNextRenderer build helper. It wraps setup, CMake presets, running, tests, optional pak assets, packaging, and mobile build entry points. +Architecture and stack overview: [`docs/gnb-tech-stack.md`](../../docs/gnb-tech-stack.md) + Build locally from the repository root: ```bash From 40314c3d7294a7217ac48e07051ac0c10e51228e Mon Sep 17 00:00:00 2001 From: gameKnife Date: Fri, 15 May 2026 00:50:27 +0800 Subject: [PATCH 15/27] =?UTF-8?q?=E9=87=8D=E5=81=9A=20gnb=20dashboard?= =?UTF-8?q?=EF=BC=9Atab=20=E5=8C=96=E5=B8=83=E5=B1=80=20+=20build/run/test?= =?UTF-8?q?=20=E5=AE=9E=E6=97=B6=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左侧固定 tab strip 切换 TODO / Build / Run / Test 四页,TODO 保留原 3 列卡片化设计 - Build / Run / Test 共用 2 列布局:target 列表独立滚动、操作按钮固定在卡片底部 - 新增 JobManager 管理子进程,stdout/stderr 按行扫描后经 SSE 推送 - 自写 ANSI→HTML(基础 16 / 256 / truecolor、bold、underline),保留 cmake/msbuild 颜色 - 自写 EventSource 客户端,done 主动 close、onerror 不重连,避免 buffer 被反复回放 - Test 页通过 gkNextUnitTests --list-tests 解析 Catch2 用例,binary 缺失给出提示 - 详情面板用 marked 渲染 journal/spec/blocker,统一 Inter + Noto Sans SC + JetBrains Mono 字体栈 - 未启动任务支持内联编辑(标题 / type / priority),spec.Document 加 EditTask - 修 sidebar footer 左对齐并显示 git 短哈希 Co-Authored-By: Claude Opus 4.7 --- .spec/TODO.md | 3 - tools/gnb/cmd/gnb/dashboard.go | 1 + tools/gnb/internal/dashboard/ansi.go | 282 +++++ tools/gnb/internal/dashboard/handlers.go | 347 +++++ tools/gnb/internal/dashboard/jobs.go | 398 ++++++ tools/gnb/internal/dashboard/server.go | 101 +- .../internal/dashboard/templates/layout.html | 1115 +++++++++++++++-- .../dashboard/templates/partials.html | 560 +++++++-- tools/gnb/internal/dashboard/tests.go | 90 ++ tools/gnb/internal/spec/spec.go | 23 + 10 files changed, 2694 insertions(+), 226 deletions(-) create mode 100644 tools/gnb/internal/dashboard/ansi.go create mode 100644 tools/gnb/internal/dashboard/jobs.go create mode 100644 tools/gnb/internal/dashboard/tests.go diff --git a/.spec/TODO.md b/.spec/TODO.md index 72ea32be..b4c0849a 100644 --- a/.spec/TODO.md +++ b/.spec/TODO.md @@ -13,9 +13,6 @@ ### 待规划 -- [ ] `#00001` [FEAT] gnb todo 子命令族(add / list / show / done / block / archive / next) -- [ ] `#00002` [FEAT] gnb dashboard 本地 web 可视化界面(embed HTML,整合 build/run/package/todo) - ### 最近完成 (暂无) diff --git a/tools/gnb/cmd/gnb/dashboard.go b/tools/gnb/cmd/gnb/dashboard.go index 8783ec9f..dfcbb5df 100644 --- a/tools/gnb/cmd/gnb/dashboard.go +++ b/tools/gnb/cmd/gnb/dashboard.go @@ -27,6 +27,7 @@ func newDashboardCommand(ctx appContext) *cobra.Command { NoOpen: noOpen, Version: resolvedVersion(), Preset: ctx.preset, + Config: ctx.cfg, }) if err != nil { return err diff --git a/tools/gnb/internal/dashboard/ansi.go b/tools/gnb/internal/dashboard/ansi.go new file mode 100644 index 00000000..e415998f --- /dev/null +++ b/tools/gnb/internal/dashboard/ansi.go @@ -0,0 +1,282 @@ +package dashboard + +import ( + "fmt" + "html" + "strconv" + "strings" +) + +// ansiToHTML converts a single line of subprocess output containing ANSI +// CSI/SGR escape sequences into HTML. Color state is reset between lines: +// in the wild (cmake / msbuild / catch2) colors are typically scoped to a +// single line anyway, so per-line scoping keeps the implementation simple +// while remaining visually correct. +// +// Supported SGR codes: 0 (reset), 1 (bold), 22 (no bold), 4 (underline), +// 24 (no underline), 30-37 / 90-97 (fg), 40-47 / 100-107 (bg), and the +// 38;2;r;g;b / 48;2;r;g;b 24-bit color extensions. Cursor / erase / +// 256-color sequences are stripped silently. +func ansiToHTML(line string) string { + var out strings.Builder + state := newAnsiState() + i := 0 + for i < len(line) { + c := line[i] + // Strip carriage returns produced by some tools (e.g. msbuild + // progress lines). They would clobber the visual on the client. + if c == '\r' { + i++ + continue + } + if c != 0x1b { // not ESC + out.WriteString(html.EscapeString(string(c))) + i++ + continue + } + // We have an ESC. Look for CSI: ESC '[' params final-byte + if i+1 >= len(line) || line[i+1] != '[' { + i++ // unknown escape, skip ESC + continue + } + // Find the final byte: a byte in 0x40-0x7E + end := i + 2 + for end < len(line) { + b := line[end] + if b >= 0x40 && b <= 0x7E { + break + } + end++ + } + if end >= len(line) { + break + } + final := line[end] + params := line[i+2 : end] + i = end + 1 + if final != 'm' { + // Non-SGR: cursor moves, erase, etc. Drop silently. + continue + } + state.apply(params, &out) + } + state.closeAll(&out) + return out.String() +} + +type ansiState struct { + bold bool + underline bool + fg string // CSS color or "" for default + bg string + openSpans int +} + +func newAnsiState() *ansiState { return &ansiState{} } + +func (s *ansiState) apply(params string, out *strings.Builder) { + codes := splitParams(params) + for i := 0; i < len(codes); i++ { + n := codes[i] + switch { + case n == 0: + s.reset(out) + case n == 1: + s.set(out, func() { s.bold = true }) + case n == 22: + s.set(out, func() { s.bold = false }) + case n == 4: + s.set(out, func() { s.underline = true }) + case n == 24: + s.set(out, func() { s.underline = false }) + case n >= 30 && n <= 37: + s.set(out, func() { s.fg = basicColor(n - 30) }) + case n >= 90 && n <= 97: + s.set(out, func() { s.fg = brightColor(n - 90) }) + case n == 39: + s.set(out, func() { s.fg = "" }) + case n >= 40 && n <= 47: + s.set(out, func() { s.bg = basicColor(n - 40) }) + case n >= 100 && n <= 107: + s.set(out, func() { s.bg = brightColor(n - 100) }) + case n == 49: + s.set(out, func() { s.bg = "" }) + case n == 38 || n == 48: + // Extended color: 38;5;n (256) or 38;2;r;g;b (truecolor). + if i+1 >= len(codes) { + return + } + mode := codes[i+1] + var color string + switch mode { + case 5: + if i+2 >= len(codes) { + return + } + color = palette256(codes[i+2]) + i += 2 + case 2: + if i+4 >= len(codes) { + return + } + color = fmt.Sprintf("rgb(%d,%d,%d)", clamp8(codes[i+2]), clamp8(codes[i+3]), clamp8(codes[i+4])) + i += 4 + default: + return + } + isFg := n == 38 + s.set(out, func() { + if isFg { + s.fg = color + } else { + s.bg = color + } + }) + } + } +} + +func (s *ansiState) set(out *strings.Builder, mut func()) { + s.closeAll(out) + mut() + s.openSpan(out) +} + +func (s *ansiState) reset(out *strings.Builder) { + s.closeAll(out) + s.bold, s.underline, s.fg, s.bg = false, false, "", "" +} + +func (s *ansiState) openSpan(out *strings.Builder) { + if !s.bold && !s.underline && s.fg == "" && s.bg == "" { + return + } + var sb strings.Builder + sb.WriteString(``) + out.WriteString(sb.String()) + s.openSpans++ +} + +func (s *ansiState) closeAll(out *strings.Builder) { + for s.openSpans > 0 { + out.WriteString("") + s.openSpans-- + } +} + +func splitParams(s string) []int { + if s == "" { + return []int{0} + } + parts := strings.Split(s, ";") + out := make([]int, 0, len(parts)) + for _, p := range parts { + if p == "" { + out = append(out, 0) + continue + } + n, err := strconv.Atoi(p) + if err != nil { + n = 0 + } + out = append(out, n) + } + return out +} + +func clamp8(n int) int { + if n < 0 { + return 0 + } + if n > 255 { + return 255 + } + return n +} + +// Match a VS Code-ish dark theme palette; tuned to be readable on #0a0d14. +func basicColor(i int) string { + switch i { + case 0: + return "#4d525c" // black -> dim grey so it stays visible on dark bg + case 1: + return "#ef4444" // red + case 2: + return "#22c55e" // green + case 3: + return "#eab308" // yellow + case 4: + return "#3b82f6" // blue + case 5: + return "#c084fc" // magenta + case 6: + return "#06b6d4" // cyan + case 7: + return "#e6e9ef" // white + } + return "" +} + +func brightColor(i int) string { + switch i { + case 0: + return "#6b7384" + case 1: + return "#fca5a5" + case 2: + return "#86efac" + case 3: + return "#fde047" + case 4: + return "#93c5fd" + case 5: + return "#d8b4fe" + case 6: + return "#67e8f9" + case 7: + return "#ffffff" + } + return "" +} + +// palette256 covers the 16 base + 216 cube + 24 grayscale 256-color palette. +func palette256(n int) string { + switch { + case n < 8: + return basicColor(n) + case n < 16: + return brightColor(n - 8) + case n < 232: + idx := n - 16 + r := idx / 36 + g := (idx / 6) % 6 + b := idx % 6 + conv := func(v int) int { + if v == 0 { + return 0 + } + return 55 + v*40 + } + return fmt.Sprintf("rgb(%d,%d,%d)", conv(r), conv(g), conv(b)) + default: + v := 8 + (n-232)*10 + return fmt.Sprintf("rgb(%d,%d,%d)", v, v, v) + } +} diff --git a/tools/gnb/internal/dashboard/handlers.go b/tools/gnb/internal/dashboard/handlers.go index 0087b0e2..fb7dc697 100644 --- a/tools/gnb/internal/dashboard/handlers.go +++ b/tools/gnb/internal/dashboard/handlers.go @@ -4,11 +4,13 @@ import ( "fmt" "html/template" "net/http" + "path/filepath" "runtime" "strconv" "strings" "time" + "github.com/gameknife/gknextrenderer/tools/gnb/internal/platform" "github.com/gameknife/gknextrenderer/tools/gnb/internal/spec" ) @@ -20,6 +22,12 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /task/add", s.handleTaskAdd) mux.HandleFunc("POST /task/{id}/done", s.handleTaskDone) mux.HandleFunc("POST /task/{id}/block", s.handleTaskBlock) + mux.HandleFunc("GET /task/{id}/edit", s.handleTaskEditForm) + mux.HandleFunc("POST /task/{id}/edit", s.handleTaskEdit) + mux.HandleFunc("GET /tab/{kind}", s.handleTab) + mux.HandleFunc("POST /jobs/{kind}", s.handleJobStart) + mux.HandleFunc("POST /jobs/{id}/cancel", s.handleJobCancel) + mux.HandleFunc("GET /jobs/{id}/stream", s.handleJobStream) return logRequests(mux) } @@ -49,6 +57,31 @@ type indexVM struct { Preset string OS string RecentSize int + ActiveTab string // "todo" | "build" | "run" | "test" + BuildVM buildVM + RunVM runVM + TestVM testVM +} + +type buildVM struct { + Targets []string + Latest JobSnapshot + HasJob bool +} + +type runVM struct { + Targets []string + Latest JobSnapshot + HasJob bool +} + +type testVM struct { + Tests []TestCase + ListErr string + BinPath string + BinExists bool + Latest JobSnapshot + HasJob bool } type journalSummary struct { @@ -82,6 +115,7 @@ func (s *Server) buildIndex() (indexVM, error) { Version: s.opts.Version, Preset: s.opts.Preset, OS: runtime.GOOS + "/" + runtime.GOARCH, + ActiveTab: "todo", } for _, kind := range []spec.SectionKind{spec.SectionNext, spec.SectionBacklog, spec.SectionRecent} { vm.Sections = append(vm.Sections, sectionVM{ @@ -95,6 +129,61 @@ func (s *Server) buildIndex() (indexVM, error) { return vm, nil } +// buildHeader returns a minimal indexVM with just the header fields populated. +// Tab content handlers use this as a base so each tab can be rendered without +// re-parsing TODO.md when not needed. +func (s *Server) buildHeader(activeTab string) indexVM { + return indexVM{ + RepoRoot: s.opts.RepoRoot, + Version: s.opts.Version, + Preset: s.opts.Preset, + OS: runtime.GOOS + "/" + runtime.GOARCH, + ActiveTab: activeTab, + } +} + +func (s *Server) buildBuildVM() buildVM { + vm := buildVM{Targets: append([]string(nil), s.opts.Config.Targets.All...)} + if snap, ok := s.jobs.LatestSnapshot(JobBuild); ok { + vm.Latest = snap + vm.HasJob = true + } + return vm +} + +func (s *Server) buildRunVM() runVM { + vm := runVM{} + for _, t := range s.opts.Config.Targets.All { + if t == "gkNextUnitTests" { + continue // tests live in the Test tab + } + vm.Targets = append(vm.Targets, t) + } + if snap, ok := s.jobs.LatestSnapshot(JobRun); ok { + vm.Latest = snap + vm.HasJob = true + } + return vm +} + +func (s *Server) buildTestVM() testVM { + binDir := platform.BinDir(s.opts.RepoRoot, s.opts.Preset) + binPath := platform.ExecutablePath(binDir, "gkNextUnitTests") + vm := testVM{BinPath: binPath} + cases, err := ListCatch2Tests(binPath) + if err != nil { + vm.ListErr = err.Error() + } else { + vm.Tests = cases + vm.BinExists = true + } + if snap, ok := s.jobs.LatestSnapshot(JobTest); ok { + vm.Latest = snap + vm.HasJob = true + } + return vm +} + func sectionName(s spec.SectionKind) string { switch s { case spec.SectionNext: @@ -238,6 +327,59 @@ func (s *Server) handleTaskDone(w http.ResponseWriter, r *http.Request) { s.respondTodoPanel(w, r) } +func (s *Server) handleTaskEditForm(w http.ResponseWriter, r *http.Request) { + id, err := parsePathID(r) + if err != nil { + httpError(w, err) + return + } + doc, err := spec.Parse(spec.TODOPath(s.opts.RepoRoot)) + if err != nil { + httpError(w, err) + return + } + t, _, ok := doc.FindTask(id) + if !ok { + http.Error(w, fmt.Sprintf("task #%05d not found", id), http.StatusNotFound) + return + } + if t.Status != spec.StatusPending { + http.Error(w, "只能编辑未启动的任务", http.StatusBadRequest) + return + } + s.render(w, "task_edit_form", *t) +} + +func (s *Server) handleTaskEdit(w http.ResponseWriter, r *http.Request) { + id, err := parsePathID(r) + if err != nil { + httpError(w, err) + return + } + if err := r.ParseForm(); err != nil { + httpError(w, err) + return + } + doc, err := spec.Parse(spec.TODOPath(s.opts.RepoRoot)) + if err != nil { + httpError(w, err) + return + } + if err := doc.EditTask(id, + r.FormValue("title"), + r.FormValue("type"), + r.FormValue("priority"), + ); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := doc.Save(); err != nil { + httpError(w, err) + return + } + s.respondTodoPanel(w, r) +} + func (s *Server) handleTaskBlock(w http.ResponseWriter, r *http.Request) { id, err := parsePathID(r) if err != nil { @@ -308,3 +450,208 @@ func httpError(w http.ResponseWriter, err error) { // Keep an unused reference so go vet doesn't flag template.HTML imports left // behind by future refactors. var _ = template.HTML("") + +// ----- tab handlers --------------------------------------------------- + +// handleTab returns the inner content for one tab. Used by the left-side tab +// strip's htmx clicks; the outer layout (header + tab strip) stays put. +func (s *Server) handleTab(w http.ResponseWriter, r *http.Request) { + kind := r.PathValue("kind") + switch kind { + case "todo": + vm, err := s.buildIndex() + if err != nil { + httpError(w, err) + return + } + s.render(w, "tab_todo", vm) + case "build": + vm := s.buildHeader("build") + vm.BuildVM = s.buildBuildVM() + s.render(w, "tab_build", vm) + case "run": + vm := s.buildHeader("run") + vm.RunVM = s.buildRunVM() + s.render(w, "tab_run", vm) + case "test": + vm := s.buildHeader("test") + vm.TestVM = s.buildTestVM() + s.render(w, "tab_test", vm) + default: + http.Error(w, "unknown tab "+kind, http.StatusNotFound) + } +} + +// ----- job handlers --------------------------------------------------- + +func (s *Server) handleJobStart(w http.ResponseWriter, r *http.Request) { + kind := JobKind(r.PathValue("kind")) + if err := r.ParseForm(); err != nil { + httpError(w, err) + return + } + target := strings.TrimSpace(r.FormValue("target")) + var spec JobSpec + switch kind { + case JobBuild: + spec = s.buildJobSpec(target) + case JobRun: + var err error + spec, err = s.runJobSpec(target) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + case JobTest: + var err error + spec, err = s.testJobSpec(target) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + default: + http.Error(w, "unknown job kind "+string(kind), http.StatusBadRequest) + return + } + job, err := s.jobs.Start(spec) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.render(w, "log_panel", job.snapshot()) +} + +func (s *Server) handleJobCancel(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := s.jobs.Cancel(id); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + job, ok := s.jobs.Get(id) + if !ok { + http.Error(w, "job not found", http.StatusNotFound) + return + } + s.render(w, "log_panel", job.snapshot()) +} + +// handleJobStream serves Server-Sent Events for one job. The initial buffered +// output is replayed before the live channel takes over. The connection ends +// when the job terminates or the client disconnects. +func (s *Server) handleJobStream(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := s.jobs.Get(id) + if !ok { + http.Error(w, "job not found", http.StatusNotFound) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + ch, snap := job.subscribe() + defer job.unsubscribe(ch) + + // SSE protocol: `data:` lines may not contain raw newlines. Each log + // line is already a single HTML row, but defend against embedded \n + // just in case (some build tools embed them in escape sequences). + // Line events get a block wrapper so each row renders on its own + // row in the log body (spans alone would all flow inline). + emit := func(name, data string) { + if name == "line" { + data = `
` + data + `
` + } + safe := strings.ReplaceAll(data, "\n", " ") + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, safe) + flusher.Flush() + } + + // Replay buffer. + for _, line := range snap.Lines { + emit("line", line) + } + if snap.Status != StatusRunning { + emit("status", statusBadgeHTML(snap.Status, snap.ExitNote)) + emit("done", "") + return + } + emit("status", statusBadgeHTML(snap.Status, snap.ExitNote)) + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-ch: + if !ok { + return + } + emit(ev.Name, ev.Data) + if ev.Name == "done" { + return + } + } + } +} + +// ----- job spec builders ---------------------------------------------- + +func (s *Server) buildJobSpec(target string) JobSpec { + args := []string{"--build", "--preset", s.opts.Preset} + label := target + if target == "" || target == "all" { + label = "all" + } else { + args = append(args, "--target", target) + } + return JobSpec{ + Kind: JobBuild, + Target: label, + Name: "cmake", + Args: args, + WorkDir: s.opts.RepoRoot, + Env: []string{"CLICOLOR_FORCE=1", "FORCE_COLOR=1"}, + } +} + +func (s *Server) runJobSpec(target string) (JobSpec, error) { + if target == "" { + return JobSpec{}, fmt.Errorf("请选择要运行的 target") + } + binDir := platform.BinDir(s.opts.RepoRoot, s.opts.Preset) + exe := platform.ExecutablePath(binDir, target) + return JobSpec{ + Kind: JobRun, + Target: target, + Name: exe, + WorkDir: filepath.Dir(exe), + Env: []string{"FORCE_COLOR=1"}, + }, nil +} + +func (s *Server) testJobSpec(name string) (JobSpec, error) { + binDir := platform.BinDir(s.opts.RepoRoot, s.opts.Preset) + exe := platform.ExecutablePath(binDir, "gkNextUnitTests") + args := []string{"--use-colour", "yes"} + label := name + if name == "" || name == "all" { + label = "all" + } else { + args = append([]string{name}, args...) + } + return JobSpec{ + Kind: JobTest, + Target: label, + Name: exe, + Args: args, + WorkDir: filepath.Dir(exe), + Env: []string{"FORCE_COLOR=1"}, + }, nil +} diff --git a/tools/gnb/internal/dashboard/jobs.go b/tools/gnb/internal/dashboard/jobs.go new file mode 100644 index 00000000..e476bca1 --- /dev/null +++ b/tools/gnb/internal/dashboard/jobs.go @@ -0,0 +1,398 @@ +package dashboard + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" +) + +// JobKind tags a job so the UI can pick the right "latest" entry per tab. +type JobKind string + +const ( + JobBuild JobKind = "build" + JobRun JobKind = "run" + JobTest JobKind = "test" +) + +// JobStatus tracks the lifecycle of a Job. +type JobStatus string + +const ( + StatusRunning JobStatus = "running" + StatusSuccess JobStatus = "success" + StatusFailed JobStatus = "failed" + StatusCanceled JobStatus = "canceled" +) + +const ( + maxBufferLines = 5000 + subBufferSize = 256 +) + +// JobEvent is a single SSE message destined for a subscriber. Name maps to the +// SSE `event:` field, Data is already HTML-escaped / ANSI-converted. +type JobEvent struct { + Name string + Data string +} + +// Job is one in-flight or terminated subprocess. It is safe to read fields via +// Snapshot(); raw fields must only be touched while holding mu. +type Job struct { + ID string + Kind JobKind + Target string // descriptive label ("all", a target name, or "all tests") + Command string // human-readable command line for the header + + mu sync.Mutex + status JobStatus + startedAt time.Time + finishedAt time.Time + exitNote string // exit code or error message + lines []string + subs map[chan JobEvent]struct{} + cmd *exec.Cmd + cancel context.CancelFunc +} + +// JobSnapshot is a lightweight copy used by templates and the catch-up SSE +// replay path. It owns its slice so callers can mutate freely. +type JobSnapshot struct { + ID string + Kind JobKind + Target string + Command string + Status JobStatus + StartedAt time.Time + FinishedAt time.Time + ExitNote string + Lines []string +} + +func (j *Job) snapshot() JobSnapshot { + j.mu.Lock() + defer j.mu.Unlock() + lines := make([]string, len(j.lines)) + copy(lines, j.lines) + return JobSnapshot{ + ID: j.ID, + Kind: j.Kind, + Target: j.Target, + Command: j.Command, + Status: j.status, + StartedAt: j.startedAt, + FinishedAt: j.finishedAt, + ExitNote: j.exitNote, + Lines: lines, + } +} + +func (j *Job) appendLine(htmlLine string) { + j.mu.Lock() + j.lines = append(j.lines, htmlLine) + if over := len(j.lines) - maxBufferLines; over > 0 { + j.lines = j.lines[over:] + } + subs := make([]chan JobEvent, 0, len(j.subs)) + for ch := range j.subs { + subs = append(subs, ch) + } + j.mu.Unlock() + for _, ch := range subs { + // Non-blocking send: if the subscriber is slow we drop. The next + // page reload still gets the buffered output via snapshot. + select { + case ch <- JobEvent{Name: "line", Data: htmlLine}: + default: + } + } +} + +func (j *Job) finalize(status JobStatus, note string) { + j.mu.Lock() + j.status = status + j.finishedAt = time.Now() + j.exitNote = note + subs := make([]chan JobEvent, 0, len(j.subs)) + for ch := range j.subs { + subs = append(subs, ch) + } + j.mu.Unlock() + statusPayload := statusBadgeHTML(status, note) + for _, ch := range subs { + // Send status & done events synchronously where possible; we + // don't want to drop the terminal signal. + select { + case ch <- JobEvent{Name: "status", Data: statusPayload}: + default: + } + select { + case ch <- JobEvent{Name: "done", Data: ""}: + default: + } + } +} + +func (j *Job) subscribe() (chan JobEvent, JobSnapshot) { + ch := make(chan JobEvent, subBufferSize) + j.mu.Lock() + if j.subs == nil { + j.subs = map[chan JobEvent]struct{}{} + } + j.subs[ch] = struct{}{} + snap := JobSnapshot{ + ID: j.ID, + Kind: j.Kind, + Target: j.Target, + Command: j.Command, + Status: j.status, + StartedAt: j.startedAt, + FinishedAt: j.finishedAt, + ExitNote: j.exitNote, + Lines: append([]string(nil), j.lines...), + } + j.mu.Unlock() + return ch, snap +} + +func (j *Job) unsubscribe(ch chan JobEvent) { + j.mu.Lock() + delete(j.subs, ch) + j.mu.Unlock() +} + +// JobSpec describes how to spawn a subprocess. WorkDir, Env, and forcing of +// color output are managed centrally so build/run/test all behave the same. +type JobSpec struct { + Kind JobKind + Target string + Name string // executable + Args []string + WorkDir string + Env []string // extra env entries, appended to os.Environ() +} + +// JobManager owns the active and historical jobs. Per kind only one job is +// kept as "latest"; an older one is left running but loses the slot. The user +// can cancel the previous explicitly if they want to stop it. +type JobManager struct { + mu sync.Mutex + jobs map[string]*Job + latest map[JobKind]string +} + +func NewJobManager() *JobManager { + return &JobManager{ + jobs: map[string]*Job{}, + latest: map[JobKind]string{}, + } +} + +// Start spawns a subprocess based on spec and returns the new Job ID. +func (m *JobManager) Start(spec JobSpec) (*Job, error) { + if spec.Name == "" { + return nil, errors.New("job: empty executable") + } + id := newJobID() + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, spec.Name, spec.Args...) + cmd.Dir = spec.WorkDir + cmd.Env = append(cmd.Environ(), spec.Env...) + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancel() + return nil, err + } + if err := cmd.Start(); err != nil { + cancel() + return nil, err + } + + job := &Job{ + ID: id, + Kind: spec.Kind, + Target: spec.Target, + Command: strings.TrimSpace(spec.Name + " " + strings.Join(spec.Args, " ")), + status: StatusRunning, + startedAt: time.Now(), + subs: map[chan JobEvent]struct{}{}, + cmd: cmd, + cancel: cancel, + } + + m.mu.Lock() + m.jobs[id] = job + m.latest[spec.Kind] = id + m.mu.Unlock() + + // Banner line so the user can see what was run. + job.appendLine(`$ ` + escapeHTML(job.Command) + ``) + + var wg sync.WaitGroup + wg.Add(2) + go pumpLines(stdout, job, &wg, false) + go pumpLines(stderr, job, &wg, true) + + go func() { + wg.Wait() + err := cmd.Wait() + cancel() + switch { + case err == nil: + job.finalize(StatusSuccess, "exit 0") + case ctx.Err() == context.Canceled && job.statusIs(StatusRunning): + // Treat as canceled even if the OS surfaced an error. + job.finalize(StatusCanceled, "canceled") + default: + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + job.finalize(StatusFailed, fmt.Sprintf("exit %d", exitErr.ExitCode())) + } else { + job.finalize(StatusFailed, err.Error()) + } + } + }() + + return job, nil +} + +func (j *Job) statusIs(s JobStatus) bool { + j.mu.Lock() + defer j.mu.Unlock() + return j.status == s +} + +// Cancel terminates a running job; it's a no-op for already-finished jobs. +func (m *JobManager) Cancel(id string) error { + m.mu.Lock() + job, ok := m.jobs[id] + m.mu.Unlock() + if !ok { + return fmt.Errorf("job %s not found", id) + } + job.mu.Lock() + if job.status != StatusRunning { + job.mu.Unlock() + return nil + } + cancel := job.cancel + job.status = StatusCanceled + job.mu.Unlock() + if cancel != nil { + cancel() + } + return nil +} + +// Get returns a snapshot of a single job by ID. +func (m *JobManager) Get(id string) (*Job, bool) { + m.mu.Lock() + defer m.mu.Unlock() + j, ok := m.jobs[id] + return j, ok +} + +// Latest returns a snapshot of the most-recent job for a kind, if any. +func (m *JobManager) Latest(kind JobKind) (*Job, bool) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.latest[kind] + if !ok { + return nil, false + } + job, ok := m.jobs[id] + return job, ok +} + +// LatestSnapshot is a convenience for templates that only need read-only data. +func (m *JobManager) LatestSnapshot(kind JobKind) (JobSnapshot, bool) { + job, ok := m.Latest(kind) + if !ok { + return JobSnapshot{}, false + } + return job.snapshot(), true +} + +func newJobID() string { + var b [6]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// pumpLines scans a stream line-by-line, converts ANSI, and appends to the job. +// errPrefix flags stderr so we can tint stderr lines a touch differently. +func pumpLines(r io.Reader, job *Job, wg *sync.WaitGroup, isStderr bool) { + defer wg.Done() + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 8192), 1024*1024) + for scanner.Scan() { + raw := scanner.Text() + htmlLine := ansiToHTML(raw) + if isStderr && htmlLine == "" { + continue + } + if isStderr && !strings.Contains(htmlLine, "color:") { + // Lightly tint untagged stderr so warnings/errors stand out. + htmlLine = `` + htmlLine + `` + } + job.appendLine(htmlLine) + } +} + +// statusBadgeHTML renders the status pill HTML used by the SSE "status" event +// and by the initial server-side render of the log header. +func statusBadgeHTML(status JobStatus, note string) string { + var label, color string + switch status { + case StatusRunning: + label, color = "运行中", "#eab308" + case StatusSuccess: + label, color = "成功", "#22c55e" + case StatusFailed: + label, color = "失败", "#ef4444" + case StatusCanceled: + label, color = "已取消", "#9aa3b2" + default: + label, color = string(status), "#9aa3b2" + } + var sb strings.Builder + sb.WriteString(``) + sb.WriteString(escapeHTML(label)) + if note != "" { + sb.WriteString(``) + sb.WriteString(escapeHTML(note)) + sb.WriteString(``) + } + sb.WriteString(``) + return sb.String() +} + +func escapeHTML(s string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + ) + return r.Replace(s) +} diff --git a/tools/gnb/internal/dashboard/server.go b/tools/gnb/internal/dashboard/server.go index f1db294e..fb6fa4fb 100644 --- a/tools/gnb/internal/dashboard/server.go +++ b/tools/gnb/internal/dashboard/server.go @@ -15,9 +15,13 @@ import ( "net/http" "os" "os/exec" + "os/user" "runtime" "strings" "time" + + "github.com/gameknife/gknextrenderer/tools/gnb/internal/config" + "github.com/gameknife/gknextrenderer/tools/gnb/internal/spec" ) //go:embed templates/*.html @@ -26,16 +30,18 @@ var templateFS embed.FS // Options configures the dashboard server. type Options struct { RepoRoot string - Port int // listen on localhost: - NoOpen bool // skip auto-launching the browser - Version string // gnb version string for display - Preset string // CMake preset string for display + Port int // listen on localhost: + NoOpen bool // skip auto-launching the browser + Version string // gnb version string for display + Preset string // CMake preset string for display + Config config.Config // loaded gnb.toml — provides targets list } // Server holds runtime state for the dashboard. type Server struct { opts Options tpl *template.Template + jobs *JobManager } // New constructs a Server. Template parsing happens eagerly so problems surface @@ -47,7 +53,7 @@ func New(opts Options) (*Server, error) { if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } - return &Server{opts: opts, tpl: tpl}, nil + return &Server{opts: opts, tpl: tpl, jobs: NewJobManager()}, nil } // Run binds the configured port and serves until ctx is canceled. @@ -138,7 +144,90 @@ func templateFuncs() template.FuncMap { } return s }, - "date": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, + "date": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, + "safeHTML": func(s string) template.HTML { return template.HTML(s) }, + "userInitial": func() string { + if u, err := user.Current(); err == nil && u.Username != "" { + name := u.Username + if idx := strings.LastIndexAny(name, "\\/"); idx >= 0 { + name = name[idx+1:] + } + if name != "" { + return strings.ToUpper(name[:1]) + } + } + return "U" + }, + "sectionTint": func(k spec.SectionKind) template.CSS { + switch k { + case spec.SectionNext: + return template.CSS("rgba(34, 197, 94, 0.14)") + case spec.SectionBacklog: + return template.CSS("rgba(59, 130, 246, 0.14)") + case spec.SectionRecent: + return template.CSS("rgba(107, 115, 132, 0.18)") + } + return template.CSS("rgba(59, 130, 246, 0.12)") + }, + "sectionColor": func(k spec.SectionKind) template.CSS { + switch k { + case spec.SectionNext: + return template.CSS("#22c55e") + case spec.SectionBacklog: + return template.CSS("#3b82f6") + case spec.SectionRecent: + return template.CSS("#9aa3b2") + } + return template.CSS("#3b82f6") + }, + "sectionIcon": func(k spec.SectionKind) string { + switch k { + case spec.SectionNext: + return `` + case spec.SectionBacklog: + return `` + case spec.SectionRecent: + return `` + } + return `` + }, + "jobLines": func(s JobSnapshot) template.HTML { + // Pre-render the existing buffer for the initial page load; SSE + // then appends new lines on top of this. + var sb strings.Builder + for _, l := range s.Lines { + sb.WriteString(`
`) + sb.WriteString(l) + sb.WriteString("
") + } + return template.HTML(sb.String()) + }, + "jobStatusBadge": func(s JobSnapshot) template.HTML { + return template.HTML(statusBadgeHTML(s.Status, s.ExitNote)) + }, + "jobIsRunning": func(s JobSnapshot) bool { return s.Status == StatusRunning }, + "jobIsTerminal": func(s JobSnapshot) bool { + return s.Status == StatusSuccess || s.Status == StatusFailed || s.Status == StatusCanceled + }, + "jobElapsed": func(s JobSnapshot) string { + end := s.FinishedAt + if end.IsZero() { + end = time.Now() + } + d := end.Sub(s.StartedAt).Round(time.Millisecond) + return d.String() + }, + "emptyHint": func(k spec.SectionKind) string { + switch k { + case spec.SectionNext: + return "暂无任务(在左侧表单添加 →)" + case spec.SectionBacklog: + return "暂无最近完成的任务" + case spec.SectionRecent: + return "暂无最近完成的任务" + } + return "(暂无)" + }, } } diff --git a/tools/gnb/internal/dashboard/templates/layout.html b/tools/gnb/internal/dashboard/templates/layout.html index 69cea7a5..3c14a84a 100644 --- a/tools/gnb/internal/dashboard/templates/layout.html +++ b/tools/gnb/internal/dashboard/templates/layout.html @@ -4,163 +4,1000 @@ gnb dashboard — {{.Milestone}} + + + +
-

gnb dashboard

- {{.Milestone}} - {{.Status}} -
- {{.Preset}} - {{shortHash .Version}} - {{.OS}} +
+ + gnb dashboard +
+ +
+ + + + + 工作流活跃 + {{.Status}} +
+ +
+
+ + {{.OS}} +
+
{{shortHash .Version}}
+
{{.Preset}}/amd64
+
{{userInitial}}
- - -
- {{template "todo_panel" .}} -
- -
-
点击任意任务查看详情(spec / journal / blocker)。
-
+ + +
+ {{if eq .ActiveTab "build"}} + {{template "tab_build" .}} + {{else if eq .ActiveTab "run"}} + {{template "tab_run" .}} + {{else if eq .ActiveTab "test"}} + {{template "tab_test" .}} + {{else}} + {{template "tab_todo" .}} + {{end}} +
-
- http://127.0.0.1 · 修改 TODO.md 后刷新页面或点击任务可见最新 - htmx 1.9.10 -
+ diff --git a/tools/gnb/internal/dashboard/templates/partials.html b/tools/gnb/internal/dashboard/templates/partials.html index 1c84d9c4..99b5b1eb 100644 --- a/tools/gnb/internal/dashboard/templates/partials.html +++ b/tools/gnb/internal/dashboard/templates/partials.html @@ -1,111 +1,515 @@ -{{define "todo_panel"}} -

TODO

+{{define "tab_todo"}} +
+ + +
+ {{template "todo_panel" .}} +
+ +
+
点击任意任务查看详情(spec / journal / blocker)。
+
+
+{{end}} + + +{{define "todo_panel"}} {{if gt .RecentSize 10}}
最近完成 已有 {{.RecentSize}} 条,建议在终端运行 gnb todo archive 归档。
{{end}} -{{range .Sections}} -
-

{{.Heading}}

- {{if .Tasks}} - {{range .Tasks}} -
- {{statusIcon (printf "%s" .Status)}} -
- #{{printf "%05d" .ID}} - {{if .Priority}}{{.Priority}}{{end}} - {{if .Type}}{{.Type}}{{end}} - {{.Title}} - {{if or .Arrow .Paren}} -
- {{if .Arrow}}→ {{.Arrow}}{{end}} - {{if .Paren}} ({{.Paren}}){{end}} +
+

+ + + + + + TODO +

+ + {{range .Sections}} + {{$secHeading := .Heading}} +
+

+ + {{sectionIcon .Kind | safeHTML}} + + {{.Heading}} + {{len .Tasks}} +

+ {{if .Tasks}} +
+ {{range .Tasks}} +
+ +
+
+ #{{printf "%05d" .ID}} + {{if .Priority}}{{.Priority}}{{end}} + {{if .Type}}{{.Type}}{{end}} + {{.Title}} +
+ {{if or .Arrow .Paren}} +
+ {{if .Arrow}}→ {{.Arrow}}{{end}} + {{if .Paren}} + + {{.Paren}} + {{end}} +
+ {{end}} +
+
- {{end}} -
- - {{if ne (printf "%s" .Status) "x"}} - - {{end}} - {{if and (ne (printf "%s" .Status) "!") (ne (printf "%s" .Status) "x")}} - - {{end}} - + {{end}}
+ {{else}} +
{{emptyHint .Kind}}
{{end}} - {{else}} -
(暂无)
+
{{end}} -
-{{end}} -

最近 Journal

-{{if .Journals}} - {{range .Journals}} -
-
{{.Date}} · #{{printf "%05d" .ID}} · {{.Type}}
-
{{.Title}}
+
+

+ + + + 最新 JOURNAL +

+ {{if .Journals}} +
+ {{range .Journals}} +
+
+
+ {{.Date}} + #{{printf "%05d" .ID}} + {{.Type}} +
+
{{.Title}}
+
+ +
+ {{end}} +
+ {{else}} +
尚无最近完成的任务
+ {{end}}
- {{end}} -{{else}} -
(尚无 journal 记录)
-{{end}} +
{{end}} {{define "task_detail"}} -
-

- {{statusIcon (printf "%s" .Task.Status)}} - {{if .Task.Priority}}{{.Task.Priority}}{{end}} - {{if .Task.Type}}{{.Task.Type}}{{end}} - {{.Task.Title}} -

-