diff --git a/.github/workflows/linux_self.yml b/.github/workflows/linux_self.yml index 1d133301..1afdacc0 100644 --- a/.github/workflows/linux_self.yml +++ b/.github/workflows/linux_self.yml @@ -20,5 +20,12 @@ jobs: - uses: actions/checkout@v4 with: clean: false + - uses: actions/setup-go@v5 + with: + go-version-file: tools/gnb/go.mod + - name: Build latest gnb + run: | + cd tools/gnb + go build -trimpath -ldflags="-s -w -X main.version=$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD)" -o "$GITHUB_WORKSPACE/gnb" ./cmd/gnb - run: ./gnb.sh setup - run: ./gnb.sh build diff --git a/.github/workflows/macos_self.yml b/.github/workflows/macos_self.yml index f625fc0c..53af830b 100644 --- a/.github/workflows/macos_self.yml +++ b/.github/workflows/macos_self.yml @@ -20,5 +20,12 @@ jobs: - uses: actions/checkout@v4 with: clean: false + - uses: actions/setup-go@v5 + with: + go-version-file: tools/gnb/go.mod + - name: Build latest gnb + run: | + cd tools/gnb + go build -trimpath -ldflags="-s -w -X main.version=$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD)" -o "$GITHUB_WORKSPACE/gnb" ./cmd/gnb - run: ./gnb.sh setup - run: ./gnb.sh build 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..b4c0849a --- /dev/null +++ b/.spec/TODO.md @@ -0,0 +1,18 @@ +# 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 + +### 待规划 + +### 最近完成 + +(暂无) 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/AGENTS.md b/AGENTS.md index 064530d8..8dc47d64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,3 +210,42 @@ assets/ - `MagicaLego.md` - MagicaLego subproject notes - **README.en.md** - Project overview and quick start - **.clang-tidy** - Naming conventions (source of truth) + +## Spec Workflow + +**完整规范见 [.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/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/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/README.en.md b/README.en.md index f1d0f530..319deb64 100644 --- a/README.en.md +++ b/README.en.md @@ -146,7 +146,7 @@ The project uses CMake + Ninja, with dependencies managed through vcpkg. Beyond - CMake 3.26+ - Visual Studio 2022 with C++ workload -- Vulkan SDK 1.4.313.2 +- Vulkan SDK 1.4.341.1 (downloaded into the repository by default; if `VULKAN_SDK` is set, that SDK is used first) - Enable "Use Unicode UTF-8 for worldwide language support" ```bat @@ -155,7 +155,7 @@ gnb.bat build gnb.bat run gkNextRenderer ``` -Aside from host-side requirements such as Visual Studio and the Vulkan SDK, the rest of the project dependencies are usually prepared by `gnb`. +Aside from host-side requirements such as Visual Studio, the rest of the project dependencies are usually prepared by `gnb`, including the pinned Vulkan SDK, Slang, and TypeScript toolchains. @@ -185,7 +185,8 @@ Aside from host-side requirements such as Visual Studio and the Vulkan SDK, the Notes: -- if `slangc` is not installed yet, `gnb setup` will automatically fetch the project-managed Slang toolchain into `external/` +- if no usable `VULKAN_SDK` is available, `gnb setup` automatically downloads the pinned LunarG Vulkan SDK into `external/VulkanSDK/` +- if `slangc` is not installed yet, `gnb setup` automatically fetches the project-managed Slang toolchain into `external/` - on pacman hosts, `gnb setup` and the first Linux `gnb build` automatically install the required system packages before vcpkg bootstrap; if that is unavailable, run `sudo pacman -S --needed base-devel cmake ninja curl zip unzip tar pkgconf libxrandr wayland-protocols libxkbcommon systemd-libs` manually - if a GitHub archive download fails during vcpkg setup, rerun the same build command once before doing deeper troubleshooting - deployment notes from a real Steam Deck setup are available in [docs/steamdeck-deployment-notes.md](docs/steamdeck-deployment-notes.md) @@ -207,7 +208,7 @@ Notes: ./gnb.sh run gkNextRenderer ``` -`gnb setup` automatically downloads the Slang and TypeScript toolchains used by the project, so those project-level dependencies no longer need to be installed separately. +`gnb setup` automatically downloads the Vulkan SDK, Slang, and TypeScript toolchains used by the project, so those project-level dependencies no longer need to be installed separately. If `VULKAN_SDK` is explicitly set, that SDK takes precedence. diff --git a/README.md b/README.md index e22b63f6..54538b37 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. 代码规模可控,适合学习和扩展 @@ -146,7 +146,7 @@ gkNextEngine 是一个基于现代 C++20 与 Vulkan 的跨平台 3D 游戏引擎 - CMake 3.26+ - Visual Studio 2022(C++ 工作负载) -- Vulkan SDK 1.4.313.2 +- Vulkan SDK 1.4.341.1(默认由 `gnb` 自动下载到仓库内;若设置 `VULKAN_SDK` 则优先使用环境里的 SDK) - 启用“使用 Unicode UTF-8 提供全球语言支持” ```bat @@ -155,7 +155,7 @@ gnb.bat build gnb.bat run gkNextRenderer ``` -除 Visual Studio / Vulkan SDK 这类宿主工具外,其余项目依赖通常都由 `gnb` 自动准备。 +除 Visual Studio 这类宿主工具外,其余项目依赖通常都由 `gnb` 自动准备;默认会拉取项目约定版本的 Vulkan SDK、Slang 与 TypeScript 工具链到仓库内。 @@ -185,6 +185,7 @@ gnb.bat run gkNextRenderer 说明: +- 如果机器上没有可用的 `VULKAN_SDK`,`gnb setup` 会自动下载项目约定版本的 LunarG Vulkan SDK 到 `external/VulkanSDK/` - 如果机器上还没有 `slangc`,`gnb setup` 会自动下载项目约定的 Slang 工具链到 `external/` - 在 pacman 环境下,`gnb setup` / Linux 首轮 `gnb build` 会在 vcpkg bootstrap 前自动安装系统包;如果自动安装不可用,可手动执行 `sudo pacman -S --needed base-devel cmake ninja curl zip unzip tar pkgconf libxrandr wayland-protocols libxkbcommon systemd-libs` - 如果 vcpkg 阶段遇到 GitHub 归档下载失败,优先直接重试同一条构建命令 @@ -207,7 +208,7 @@ gnb.bat run gkNextRenderer ./gnb.sh run gkNextRenderer ``` -`gnb setup` 会自动下载项目使用的 Slang 与 TypeScript 工具链,无需再单独准备这些项目级依赖。 +`gnb setup` 会自动下载项目使用的 Vulkan SDK、Slang 与 TypeScript 工具链,无需再单独准备这些项目级依赖。若显式设置 `VULKAN_SDK`,则优先使用该环境变量指向的 SDK。 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/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/Core.SwModernNoAmbient.comp.slang b/assets/shaders/Core.SwModernNoAmbient.comp.slang new file mode 100644 index 00000000..eb4da5fa --- /dev/null +++ b/assets/shaders/Core.SwModernNoAmbient.comp.slang @@ -0,0 +1,112 @@ +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_ALBEDO).Store(pixel, float4(1, 1, 1, 1)); + Bindless.GetStorageTexture(Bindless.RT_OBJEDCTID_0).Store(pixel, 0); + Bindless.GetStorageTexture(Bindless.RT_PREV_DEPTHBUFFER).Store(pixel, 1000); + 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); + + 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; + 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; + Bindless.GetStorageTexture(Bindless.RT_SINGLE_DIFFUSE).Store(pixel, color); + Bindless.GetStorageTexture(Bindless.RT_ALBEDO).Store(pixel, primaryAlbedo); + Bindless.GetStorageTexture(Bindless.RT_OBJEDCTID_0).Store(pixel, Common.EncodeObjectId(hitNode.instanceId, selected, hovered, locked, danger)); + Bindless.GetStorageTexture(Bindless.RT_PREV_DEPTHBUFFER).Store(pixel, ndcDepth); +} diff --git a/assets/shaders/Core.SwModernNoAmbient.comp.slang.spv b/assets/shaders/Core.SwModernNoAmbient.comp.slang.spv new file mode 100644 index 00000000..5597e51e Binary files /dev/null and b/assets/shaders/Core.SwModernNoAmbient.comp.slang.spv differ diff --git a/assets/shaders/Process.ComposeSimple.comp.slang b/assets/shaders/Process.ComposeSimple.comp.slang new file mode 100644 index 00000000..5156a59e --- /dev/null +++ b/assets/shaders/Process.ComposeSimple.comp.slang @@ -0,0 +1,112 @@ +import Common; +import Bindless; + +static const uint OBJECT_ID_FLAG_VALID = 0x80000000u; +static const uint OBJECT_ID_FLAG_SELECTED = 0x40000000u; +static const uint OBJECT_ID_FLAG_HOVERED = 0x20000000u; +static const uint OBJECT_ID_FLAG_LOCKED = 0x10000000u; +static const uint OBJECT_ID_FLAG_DANGER = 0x08000000u; +static const float DIAGONAL_EDGE_WEIGHT = 0.70710678; + +bool HasObjectFlag(uint objectId, uint flag) +{ + return ((objectId & OBJECT_ID_FLAG_VALID) != 0) && ((objectId & flag) != 0); +} + +uint SafeLoadObjectId(RWTexture2D objectImage, int2 ipos, int2 imageMax) +{ + return objectImage[clamp(ipos, int2(0, 0), imageMax)]; +} + +float ComputeEdgeStrengthTwoSided(RWTexture2D objectImage, int2 ipos, int2 imageMax, uint flag) +{ + bool centerFlagged = HasObjectFlag(SafeLoadObjectId(objectImage, ipos, imageMax), flag); + + float edgeSum = 0.0; + float weightSum = 0.0; + + bool x0 = HasObjectFlag(SafeLoadObjectId(objectImage, ipos + int2(1, 0), imageMax), flag); + bool x1 = HasObjectFlag(SafeLoadObjectId(objectImage, ipos + int2(-1, 0), imageMax), flag); + bool y0 = HasObjectFlag(SafeLoadObjectId(objectImage, ipos + int2(0, 1), imageMax), flag); + bool y1 = HasObjectFlag(SafeLoadObjectId(objectImage, ipos + int2(0, -1), imageMax), flag); + bool d0 = HasObjectFlag(SafeLoadObjectId(objectImage, ipos + int2(1, 1), imageMax), flag); + bool d1 = HasObjectFlag(SafeLoadObjectId(objectImage, ipos + int2(-1, -1), imageMax), flag); + bool d2 = HasObjectFlag(SafeLoadObjectId(objectImage, ipos + int2(-1, 1), imageMax), flag); + bool d3 = HasObjectFlag(SafeLoadObjectId(objectImage, ipos + int2(1, -1), imageMax), flag); + + edgeSum += (x0 != centerFlagged) ? 1.0 : 0.0; + edgeSum += (x1 != centerFlagged) ? 1.0 : 0.0; + edgeSum += (y0 != centerFlagged) ? 1.0 : 0.0; + edgeSum += (y1 != centerFlagged) ? 1.0 : 0.0; + weightSum += 4.0; + + edgeSum += ((d0 != centerFlagged) ? 1.0 : 0.0) * DIAGONAL_EDGE_WEIGHT; + edgeSum += ((d1 != centerFlagged) ? 1.0 : 0.0) * DIAGONAL_EDGE_WEIGHT; + edgeSum += ((d2 != centerFlagged) ? 1.0 : 0.0) * DIAGONAL_EDGE_WEIGHT; + edgeSum += ((d3 != centerFlagged) ? 1.0 : 0.0) * DIAGONAL_EDGE_WEIGHT; + weightSum += DIAGONAL_EDGE_WEIGHT * 4.0; + + float rawEdge = edgeSum / max(1e-5, weightSum); + return smoothstep(0.1, 0.4, rawEdge); +} + +[shader("compute")] +[numthreads(8, 8, 1)] +void main(uint3 DTid : SV_DispatchThreadID) +{ + let OutImage = Bindless.GetStorageTexture(Bindless.RT_DENOISED); + let LightingImage = Bindless.GetStorageTexture(Bindless.RT_SINGLE_DIFFUSE); + let AlbedoImage = Bindless.GetStorageTexture(Bindless.RT_ALBEDO); + let ObjectIdImage = Bindless.GetStorageTexture(Bindless.RT_OBJEDCTID_0); + + uint2 size = Bindless.GetStorageTextureDimensions(Bindless.RT_DENOISED); + int2 ipos = int2(DTid.xy); + if (DTid.x >= size.x || DTid.y >= size.y) + { + return; + } + + int2 imageMax = int2(size) - int2(1, 1); + UniformBufferObject Camera = Bindless.GetGpuscene().Camera[0]; + + float3 total = LightingImage[ipos].rgb; + if (!Camera.DebugDraw_Lighting) + { + total *= AlbedoImage[ipos].rgb; + } + + float dangerEdge = ComputeEdgeStrengthTwoSided(ObjectIdImage, ipos, imageMax, OBJECT_ID_FLAG_DANGER); + float lockedEdge = ComputeEdgeStrengthTwoSided(ObjectIdImage, ipos, imageMax, OBJECT_ID_FLAG_LOCKED); + float selectedEdge = ComputeEdgeStrengthTwoSided(ObjectIdImage, ipos, imageMax, OBJECT_ID_FLAG_SELECTED); + float hoveredEdge = ComputeEdgeStrengthTwoSided(ObjectIdImage, ipos, imageMax, OBJECT_ID_FLAG_HOVERED); + + if (dangerEdge > 0) + { + total = lerp(total, float3(255, 45, 35), saturate(dangerEdge * 0.90)); + } + else if (lockedEdge > 0) + { + total = lerp(total, float3(35, 145, 255), saturate(lockedEdge * 0.75)); + } + else if (selectedEdge > 0) + { + total = lerp(total, float3(150, 100, 0), saturate(selectedEdge * 0.80)); + } + else if (hoveredEdge > 0) + { + total = lerp(total, float3(80, 220, 120), saturate(hoveredEdge * 0.75)); + } + + if (Camera.HDR) + { + total = total / 2000; + total = GT_Tonemapping(total); + total = total * 2000; + float3 st2084 = LinearToST2084UE(total * Camera.PaperWhiteNit / 230.0); + OutImage[ipos] = float4(st2084, 1.0); + } + else + { + OutImage[ipos] = float4(GT_Tonemapping(total * Camera.PaperWhiteNit / 40000.0), 1.0); + } +} 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/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/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/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/UI.ImGui.frag.slang b/assets/shaders/UI.ImGui.frag.slang new file mode 100644 index 00000000..9edf63cc --- /dev/null +++ b/assets/shaders/UI.ImGui.frag.slang @@ -0,0 +1,85 @@ +import Bindless; + +struct PushConsts +{ + float2 scale; + float2 translate; + float4 rotation; + uint hdrOutput; + float hdrReferenceWhiteNit; + float2 padding; +}; + +[[vk::push_constant]] +ConstantBuffer pushConsts; + +struct FragmentInput +{ + float4 position : SV_Position; + [[vk::location(0)]] float2 uv : TEXCOORD0; + [[vk::location(1)]] float4 color : COLOR0; + [[vk::location(2)]] nointerpolation float4 clipRect : TEXCOORD1; + [[vk::location(3)]] nointerpolation uint textureIndex : TEXCOORD2; +}; + +struct FragmentOutput +{ + [[vk::location(0)]] float4 color : SV_Target0; +}; + +float3 SrgbToLinear(float3 color) +{ + float3 low = color / 12.92; + float3 high = pow((color + 0.055) / 1.055, float3(2.4, 2.4, 2.4)); + bool3 useHigh = color > 0.04045; + return float3(useHigh.x ? high.x : low.x, useHigh.y ? high.y : low.y, useHigh.z ? high.z : low.z); +} + +float3 LinearToST2084UE(float3 linearNits) +{ + const float m1 = 0.1593017578125; + const float m2 = 78.84375; + const float c1 = 0.8359375; + const float c2 = 18.8515625; + const float c3 = 18.6875; + const float maxNit = 10000.0; + + float3 luminance = linearNits / maxNit; + float3 luminancePow = pow(max(luminance, 0.0), float3(m1, m1, m1)); + float3 numerator = c1 + c2 * luminancePow; + float3 denominator = 1.0 + c3 * luminancePow; + return pow(numerator / denominator, float3(m2, m2, m2)); +} + +float3 Bt709ToBt2020(float3 color) +{ + return float3(0.6274040 * color.r + 0.3292820 * color.g + 0.0433136 * color.b, + 0.0690970 * color.r + 0.9195400 * color.g + 0.0113612 * color.b, + 0.0163916 * color.r + 0.0880132 * color.g + 0.8955950 * color.b); +} + +[shader("fragment")] +FragmentOutput main(FragmentInput input) +{ + FragmentOutput output; + + if (input.position.x < input.clipRect.x || input.position.y < input.clipRect.y || + input.position.x >= input.clipRect.z || input.position.y >= input.clipRect.w) + { + discard; + } + + float4 sdrColor = Bindless.GetSampleTexture(input.textureIndex).SampleLevel(input.uv, 0.0) * input.color; + if (pushConsts.hdrOutput != 0) + { + float3 linear709 = SrgbToLinear(saturate(sdrColor.rgb)); + float3 linear2020 = Bt709ToBt2020(linear709); + output.color = float4(LinearToST2084UE(linear2020 * pushConsts.hdrReferenceWhiteNit), sdrColor.a); + } + else + { + output.color = sdrColor; + } + + return output; +} diff --git a/assets/shaders/UI.ImGui.vert.slang b/assets/shaders/UI.ImGui.vert.slang new file mode 100644 index 00000000..667d42f9 --- /dev/null +++ b/assets/shaders/UI.ImGui.vert.slang @@ -0,0 +1,47 @@ +struct PushConsts +{ + float2 scale; + float2 translate; + float4 rotation; + uint hdrOutput; + float hdrReferenceWhiteNit; + float2 padding; +}; + +[[vk::push_constant]] +ConstantBuffer pushConsts; + +struct VertexInput +{ + [[vk::location(0)]] float2 position : POSITION; + [[vk::location(1)]] float2 uv : TEXCOORD0; + [[vk::location(2)]] float4 color : COLOR0; + [[vk::location(3)]] float4 clipRect : TEXCOORD1; + [[vk::location(4)]] uint textureIndex : TEXCOORD2; +}; + +struct VertexOutput +{ + float4 position : SV_Position; + [[vk::location(0)]] float2 uv : TEXCOORD0; + [[vk::location(1)]] float4 color : COLOR0; + [[vk::location(2)]] nointerpolation float4 clipRect : TEXCOORD1; + [[vk::location(3)]] nointerpolation uint textureIndex : TEXCOORD2; +}; + +[shader("vertex")] +VertexOutput main(VertexInput input) +{ + VertexOutput output; + + float2 position = input.position * pushConsts.scale + pushConsts.translate; + position = float2(position.x * pushConsts.rotation.x + position.y * pushConsts.rotation.y, + position.x * pushConsts.rotation.z + position.y * pushConsts.rotation.w); + + output.position = float4(position, 0.0, 1.0); + output.uv = input.uv; + output.color = input.color; + output.clipRect = input.clipRect; + output.textureIndex = input.textureIndex; + return output; +} diff --git a/assets/shaders/common/BasicTypes.slang b/assets/shaders/common/BasicTypes.slang index 690f9ea0..17c36bc6 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 { @@ -85,7 +126,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; }; @@ -266,42 +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 + one reserved address slot + 4 uint params; aligned size is 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; + uint64_t ReservedAddress0; uint32_t SwapChainIndex; uint32_t custom_data_0; @@ -315,30 +342,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; @@ -346,9 +402,7 @@ public struct ALIGN_8 GPUScene public GPUVertex *SkinnedVertices; public float4x4 *JointMatrices; - - public half4 *SkinnedVerticesSimple; - public AmbientCube *CubesPong; + public uint64_t ReservedAddress0; public RaytracingAccelerationStructure GetTLAS() { return RaytracingAccelerationStructure(TLAS); @@ -363,44 +417,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; } + get { return (uint*)Reorders_Vertices_Address.x; } } - public property half4* VerticesSimple - { - get { return (half4*)Reorders_VerticesSimple_Address.y; } - } - - 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) { @@ -408,81 +457,76 @@ 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; + + uint64_t2 TLAS_SkinWeights_Address; 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; 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; } - } - - 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; } + get { return (float4x4*)JointMatrices_Reserved_Address.x; } } public RaytracingAccelerationStructure GetTLAS() { #ifdef PLATFORM_ANDROID return BindedTLAS; #else - return RaytracingAccelerationStructure(GPUDrivenStats_TLAS_Address.y); + return RaytracingAccelerationStructure(TLAS_SkinWeights_Address.x); #endif } diff --git a/assets/shaders/common/GPUScene.slang b/assets/shaders/common/GPUScene.slang index c2de6f02..57ab8830 100644 --- a/assets/shaders/common/GPUScene.slang +++ b/assets/shaders/common/GPUScene.slang @@ -7,4 +7,4 @@ namespace Bindless { return gpuScene; } -} \ No newline at end of file +} 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/cmake/SetupDependencies.cmake b/cmake/SetupDependencies.cmake index 73026132..7162d2f8 100644 --- a/cmake/SetupDependencies.cmake +++ b/cmake/SetupDependencies.cmake @@ -1,6 +1,250 @@ message(STATUS "SDL3_DIR: ${SDL3_DIR}") find_package(SDL3 CONFIG REQUIRED) +set(GKNEXT_DISABLE_VULKAN_AUTO_FETCH OFF CACHE BOOL "Disable automatic project-managed Vulkan SDK download during CMake configure") + +function(_gk_try_use_desktop_vulkan_sdk sdkCandidate outVar) + set(_resolved "") + + if (WIN32) + if ((EXISTS "${sdkCandidate}/Include/vulkan/vulkan.h" OR EXISTS "${sdkCandidate}/include/vulkan/vulkan.h") AND + (EXISTS "${sdkCandidate}/Lib/vulkan-1.lib" OR EXISTS "${sdkCandidate}/lib/vulkan-1.lib")) + set(_resolved "${sdkCandidate}") + endif() + elseif (APPLE) + set(_resolved "") + + if (EXISTS "${sdkCandidate}/include/vulkan/vulkan.h" AND + EXISTS "${sdkCandidate}/lib/libvulkan.dylib") + set(_resolved "${sdkCandidate}") + elseif (EXISTS "${sdkCandidate}/macOS/include/vulkan/vulkan.h" AND + EXISTS "${sdkCandidate}/macOS/lib/libvulkan.dylib") + set(_resolved "${sdkCandidate}/macOS") + endif() + else() + if (EXISTS "${sdkCandidate}/include/vulkan/vulkan.h" AND + (EXISTS "${sdkCandidate}/lib/libvulkan.so" OR + EXISTS "${sdkCandidate}/lib/libvulkan.so.1" OR + EXISTS "${sdkCandidate}/lib/VulkanLoader/lib/libvulkan.so" OR + EXISTS "${sdkCandidate}/lib/VulkanLoader/lib/libvulkan.so.1")) + set(_resolved "${sdkCandidate}") + elseif (EXISTS "${sdkCandidate}/x86_64/include/vulkan/vulkan.h" AND + (EXISTS "${sdkCandidate}/x86_64/lib/libvulkan.so" OR + EXISTS "${sdkCandidate}/x86_64/lib/libvulkan.so.1" OR + EXISTS "${sdkCandidate}/x86_64/lib/VulkanLoader/lib/libvulkan.so" OR + EXISTS "${sdkCandidate}/x86_64/lib/VulkanLoader/lib/libvulkan.so.1")) + set(_resolved "${sdkCandidate}/x86_64") + endif() + endif() + + if (_resolved) + set(${outVar} "${_resolved}" PARENT_SCOPE) + else() + set(${outVar} "" PARENT_SCOPE) + endif() +endfunction() + +function(_gk_try_use_ios_vulkan_sdk sdkCandidate outVar) + set(_resolved "") + + if (EXISTS "${sdkCandidate}/include/vulkan/vulkan.h" AND + EXISTS "${sdkCandidate}/lib/vulkan.framework/vulkan") + set(_resolved "${sdkCandidate}") + elseif (EXISTS "${sdkCandidate}/iOS/include/vulkan/vulkan.h" AND + EXISTS "${sdkCandidate}/iOS/lib/vulkan.framework/vulkan") + set(_resolved "${sdkCandidate}/iOS") + elseif (EXISTS "${sdkCandidate}/../iOS/include/vulkan/vulkan.h" AND + EXISTS "${sdkCandidate}/../iOS/lib/vulkan.framework/vulkan") + set(_resolved "${sdkCandidate}/../iOS") + endif() + + if (_resolved) + set(${outVar} "${_resolved}" PARENT_SCOPE) + else() + set(${outVar} "" PARENT_SCOPE) + endif() +endfunction() + +function(_gk_apply_vulkan_sdk sdkRoot) + if (WIN32) + set(_include_dir "${sdkRoot}/Include") + if (NOT EXISTS "${_include_dir}/vulkan/vulkan.h") + set(_include_dir "${sdkRoot}/include") + endif() + set(_library_path "${sdkRoot}/Lib/vulkan-1.lib") + if (NOT EXISTS "${_library_path}") + set(_library_path "${sdkRoot}/lib/vulkan-1.lib") + endif() + elseif (APPLE) + set(_include_dir "${sdkRoot}/include") + set(_library_path "${sdkRoot}/lib/libvulkan.dylib") + else() + set(_include_dir "${sdkRoot}/include") + if (EXISTS "${sdkRoot}/lib/libvulkan.so") + set(_library_path "${sdkRoot}/lib/libvulkan.so") + elseif (EXISTS "${sdkRoot}/lib/libvulkan.so.1") + set(_library_path "${sdkRoot}/lib/libvulkan.so.1") + elseif (EXISTS "${sdkRoot}/lib/VulkanLoader/lib/libvulkan.so") + set(_library_path "${sdkRoot}/lib/VulkanLoader/lib/libvulkan.so") + else() + set(_library_path "${sdkRoot}/lib/VulkanLoader/lib/libvulkan.so.1") + endif() + endif() + + set(ENV{VULKAN_SDK} "${sdkRoot}") + set(Vulkan_INCLUDE_DIR "${_include_dir}" CACHE PATH "Vulkan include directory" FORCE) + set(Vulkan_LIBRARY "${_library_path}" CACHE FILEPATH "Vulkan library" FORCE) +endfunction() + +function(_gk_apply_ios_vulkan_sdk sdkRoot) + set(_include_dir "${sdkRoot}/include") + set(_library_path "${sdkRoot}/lib/vulkan.framework/vulkan") + + get_filename_component(_sdk_parent "${sdkRoot}" DIRECTORY) + set(_host_tools_root "${sdkRoot}") + if (EXISTS "${_sdk_parent}/macOS/bin/slangc") + set(_host_tools_root "${_sdk_parent}/macOS") + endif() + + set(ENV{VULKAN_SDK} "${_host_tools_root}") + set(Vulkan_FOUND TRUE) + set(Vulkan_INCLUDE_DIR "${_include_dir}" CACHE PATH "Vulkan include directory" FORCE) + set(Vulkan_LIBRARY "${_library_path}" CACHE FILEPATH "Vulkan library" FORCE) + set(Vulkan_INCLUDE_DIRS "${_include_dir}" PARENT_SCOPE) + set(Vulkan_LIBRARIES "${_library_path}" PARENT_SCOPE) +endfunction() + +function(_gk_collect_vulkan_sdk_candidates outVar) + set(_vulkan_sdk_candidates) + + if (DEFINED ENV{VULKAN_SDK} AND NOT "$ENV{VULKAN_SDK}" STREQUAL "") + list(APPEND _vulkan_sdk_candidates "$ENV{VULKAN_SDK}") + endif() + + set(_project_vulkan_sdk_dir "${CMAKE_SOURCE_DIR}/external/VulkanSDK") + if (EXISTS "${_project_vulkan_sdk_dir}/.current_version") + file(READ "${_project_vulkan_sdk_dir}/.current_version" _current_vulkan_sdk_version) + string(STRIP "${_current_vulkan_sdk_version}" _current_vulkan_sdk_version) + if (_current_vulkan_sdk_version) + list(APPEND _vulkan_sdk_candidates "${_project_vulkan_sdk_dir}/${_current_vulkan_sdk_version}") + endif() + endif() + file(GLOB _project_vulkan_sdk_candidates LIST_DIRECTORIES true "${_project_vulkan_sdk_dir}/*") + list(APPEND _vulkan_sdk_candidates ${_project_vulkan_sdk_candidates}) + + if (WIN32) + file(GLOB _system_vulkan_sdk_candidates LIST_DIRECTORIES true "C:/VulkanSDK/*") + list(APPEND _vulkan_sdk_candidates ${_system_vulkan_sdk_candidates}) + elseif (APPLE) + if (DEFINED ENV{HOME} AND NOT "$ENV{HOME}" STREQUAL "") + file(GLOB _home_vulkan_sdk_candidates LIST_DIRECTORIES true "$ENV{HOME}/VulkanSDK/*") + list(APPEND _vulkan_sdk_candidates ${_home_vulkan_sdk_candidates}) + endif() + if (DEFINED ENV{USER} AND NOT "$ENV{USER}" STREQUAL "") + file(GLOB _user_vulkan_sdk_candidates LIST_DIRECTORIES true "/Users/$ENV{USER}/VulkanSDK/*") + list(APPEND _vulkan_sdk_candidates ${_user_vulkan_sdk_candidates}) + endif() + else() + if (DEFINED ENV{HOME} AND NOT "$ENV{HOME}" STREQUAL "") + file(GLOB _home_vulkan_sdk_candidates LIST_DIRECTORIES true "$ENV{HOME}/VulkanSDK/*") + list(APPEND _vulkan_sdk_candidates ${_home_vulkan_sdk_candidates}) + endif() + endif() + + list(REMOVE_DUPLICATES _vulkan_sdk_candidates) + list(SORT _vulkan_sdk_candidates COMPARE NATURAL ORDER DESCENDING) + set(${outVar} "${_vulkan_sdk_candidates}" PARENT_SCOPE) +endfunction() + +function(_gk_resolve_desktop_vulkan_sdk outVar) + _gk_collect_vulkan_sdk_candidates(_vulkan_sdk_candidates) + set(_vulkan_sdk_root "") + + foreach(_vulkan_sdk_candidate IN LISTS _vulkan_sdk_candidates) + _gk_try_use_desktop_vulkan_sdk("${_vulkan_sdk_candidate}" _vulkan_sdk_root) + if (_vulkan_sdk_root) + break() + endif() + endforeach() + + set(${outVar} "${_vulkan_sdk_root}" PARENT_SCOPE) +endfunction() + +function(_gk_resolve_ios_vulkan_sdk outVar) + _gk_collect_vulkan_sdk_candidates(_vulkan_sdk_candidates) + set(_vulkan_sdk_root "") + + foreach(_vulkan_sdk_candidate IN LISTS _vulkan_sdk_candidates) + _gk_try_use_ios_vulkan_sdk("${_vulkan_sdk_candidate}" _vulkan_sdk_root) + if (_vulkan_sdk_root) + break() + endif() + endforeach() + + set(${outVar} "${_vulkan_sdk_root}" PARENT_SCOPE) +endfunction() + +function(_gk_fetch_project_vulkan_sdk outVar) + if (WIN32) + execute_process( + COMMAND cmd /c "${CMAKE_SOURCE_DIR}/gnb.bat" deps fetch vulkan + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + RESULT_VARIABLE _fetch_result) + else() + execute_process( + COMMAND sh "${CMAKE_SOURCE_DIR}/gnb.sh" deps fetch vulkan + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + RESULT_VARIABLE _fetch_result) + endif() + + set(${outVar} "${_fetch_result}" PARENT_SCOPE) +endfunction() + +if (IOS) + _gk_resolve_ios_vulkan_sdk(_gk_ios_vulkan_sdk_root) + + if (NOT _gk_ios_vulkan_sdk_root AND NOT GKNEXT_DISABLE_VULKAN_AUTO_FETCH) + message(STATUS "No usable iOS Vulkan SDK found. Fetching project-managed SDK via gnb.") + _gk_fetch_project_vulkan_sdk(_gk_vulkan_fetch_result) + if (NOT _gk_vulkan_fetch_result EQUAL 0) + message(FATAL_ERROR "Automatic Vulkan SDK download failed (gnb exit code ${_gk_vulkan_fetch_result}).") + endif() + _gk_resolve_ios_vulkan_sdk(_gk_ios_vulkan_sdk_root) + endif() + + if (_gk_ios_vulkan_sdk_root) + message(STATUS "Using iOS Vulkan SDK: ${_gk_ios_vulkan_sdk_root}") + _gk_apply_ios_vulkan_sdk("${_gk_ios_vulkan_sdk_root}") + endif() +elseif (NOT ANDROID) + if (DEFINED Vulkan_LIBRARY AND Vulkan_LIBRARY AND NOT EXISTS "${Vulkan_LIBRARY}") + message(STATUS "Clearing stale Vulkan library cache entry: ${Vulkan_LIBRARY}") + unset(Vulkan_LIBRARY CACHE) + endif() + + if (DEFINED Vulkan_INCLUDE_DIR AND Vulkan_INCLUDE_DIR AND + NOT EXISTS "${Vulkan_INCLUDE_DIR}/vulkan/vulkan.h") + message(STATUS "Clearing stale Vulkan include cache entry: ${Vulkan_INCLUDE_DIR}") + unset(Vulkan_INCLUDE_DIR CACHE) + endif() + + _gk_resolve_desktop_vulkan_sdk(_vulkan_sdk_root) + + if (NOT _vulkan_sdk_root AND NOT GKNEXT_DISABLE_VULKAN_AUTO_FETCH) + message(STATUS "No usable Vulkan SDK found. Fetching project-managed SDK via gnb.") + _gk_fetch_project_vulkan_sdk(_gk_vulkan_fetch_result) + if (NOT _gk_vulkan_fetch_result EQUAL 0) + message(FATAL_ERROR "Automatic Vulkan SDK download failed (gnb exit code ${_gk_vulkan_fetch_result}).") + endif() + _gk_resolve_desktop_vulkan_sdk(_vulkan_sdk_root) + endif() + + if (_vulkan_sdk_root) + message(STATUS "Using Vulkan SDK: ${_vulkan_sdk_root}") + _gk_apply_vulkan_sdk("${_vulkan_sdk_root}") + endif() +endif() + if (IOS) message(STATUS "MoltenVK: ${MOLTENVK_ROOT}") if (DEFINED MOLTENVK_ROOT) @@ -61,22 +305,16 @@ find_package(draco CONFIG REQUIRED) find_path(TINYGLTF_INCLUDE_DIRS "tiny_gltf.h") find_path(CPP_BASE64_INCLUDE_DIRS "cpp-base64/base64.cpp") -IF (NOT Vulkan_FOUND) +IF (NOT IOS AND NOT Vulkan_FOUND) message(FATAL_ERROR "Could not find Vulkan library!") ELSE() - message(STATUS ${Vulkan_LIBRARY}) + if (DEFINED Vulkan_LIBRARY AND Vulkan_LIBRARY) + message(STATUS ${Vulkan_LIBRARY}) + endif() ENDIF() set(_slang_hint_dirs) -list(APPEND _slang_hint_dirs ENV SLANG_ROOT ENV VULKAN_SDK) - -if(DEFINED ENV{SLANG_ROOT}) - list(APPEND _slang_hint_dirs "$ENV{SLANG_ROOT}") -endif() - -if(DEFINED SLANG_ROOT) - list(APPEND _slang_hint_dirs "${SLANG_ROOT}") -endif() +set(_project_managed_slangc "") set(_slang_candidate_roots) list(APPEND _slang_candidate_roots @@ -90,9 +328,35 @@ list(REMOVE_DUPLICATES _slang_candidate_roots) foreach(_slang_root ${_slang_candidate_roots}) if(EXISTS "${_slang_root}") list(APPEND _slang_hint_dirs "${_slang_root}") + if (NOT _project_managed_slangc AND EXISTS "${_slang_root}/bin/slangc") + set(_project_managed_slangc "${_slang_root}/bin/slangc") + endif() endif() endforeach() +if(DEFINED ENV{SLANG_ROOT}) + list(APPEND _slang_hint_dirs "$ENV{SLANG_ROOT}") +endif() + +if(DEFINED SLANG_ROOT) + list(APPEND _slang_hint_dirs "${SLANG_ROOT}") +endif() + +if(DEFINED ENV{VULKAN_SDK} AND NOT "$ENV{VULKAN_SDK}" STREQUAL "") + list(APPEND _slang_hint_dirs "$ENV{VULKAN_SDK}") +endif() + +list(REMOVE_DUPLICATES _slang_hint_dirs) + +if (DEFINED Vulkan_SLANGC AND Vulkan_SLANGC AND NOT EXISTS "${Vulkan_SLANGC}") + message(STATUS "Clearing stale slangc cache entry: ${Vulkan_SLANGC}") + unset(Vulkan_SLANGC CACHE) +endif() + +if (_project_managed_slangc) + set(Vulkan_SLANGC "${_project_managed_slangc}" CACHE FILEPATH "Preferred project-managed slangc executable" FORCE) +endif() + find_program(Vulkan_SLANGC NAMES slangc HINTS ${_slang_hint_dirs} 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/gnb.bat b/gnb.bat index a5483ee2..71ea34da 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,16 +13,16 @@ 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 ( set "NEED_BUILD=1" if exist "%LOCAL_GNB%" ( - 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" + 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$|\.html$' -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/gnb.toml b/gnb.toml index 1e655752..95d9f973 100644 --- a/gnb.toml +++ b/gnb.toml @@ -26,6 +26,10 @@ linux = "https://github.com/shader-slang/slang/releases/download/v2025.6.1/slang macos_amd64 = "https://github.com/shader-slang/slang/releases/download/v2025.6.1/slang-2025.6.1-macos-x86_64.zip" macos_arm64 = "https://github.com/shader-slang/slang/releases/download/v2025.6.1/slang-2025.6.1-macos-aarch64.zip" +[external.vulkansdk] +version = "1.4.341.1" +root = "external/VulkanSDK" + [paks] repo = "gameknife/gkNextEngine" release_tag = "paks-latest" 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..e5ccc328 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.MarkTransformDirty(); +} + +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().MarkTransformDirty(); // 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()) diff --git a/src/Application/gkNextRenderer/gkNextRenderer.cpp b/src/Application/gkNextRenderer/gkNextRenderer.cpp index 940d643c..b4bb634d 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; @@ -68,26 +71,54 @@ const char* GetSceneListGroupLabel(ESceneListGroup group) return "Other"; } } + +template +bool DrawSettingSliderRow(const char* label, ImGuiDataType dataType, T* value, + T minValue, T maxValue, const char* format, float dragSpeed, + float valueWidth = 84.0f) +{ + Runtime::UiTheme::LabelOver(label); + ImGui::PushID(label); + + const float sliderWidth = std::max(40.0f, ImGui::GetContentRegionAvail().x - valueWidth - 8.0f); + bool changed = false; + + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::SetNextItemWidth(sliderWidth); + changed |= ImGui::SliderScalar("##Slider", dataType, value, &minValue, &maxValue, format); + ImGui::PopStyleColor(); + + ImGui::SameLine(0.0f, 8.0f); + ImGui::SetNextItemWidth(valueWidth); + changed |= ImGui::DragScalar("##Value", dataType, value, dragSpeed, &minValue, &maxValue, format); + + ImGui::PopID(); + return changed; +} } // namespace // should use 1em instead of 1px -constexpr float constTitlebarSize = 40; -constexpr float constTitlebarControlSize = constTitlebarSize * 3; +constexpr float constTitlebarSize = 44; +constexpr float constTitlebarRightInfoWidth = 296; constexpr float constIconSize = 64; constexpr float constPaletteSize = 46; constexpr float constButtonSize = 36; constexpr float constBuildBarWidth = 240; constexpr float constSideBarWidth = 300; constexpr float constShortcutSize = 10; +constexpr float constModeRailWidth = 56; +constexpr float constModeRailButtonSize = 40; float TitlebarSize = constTitlebarSize; -float TitlebarControlSize = constTitlebarControlSize; +float TitlebarRightInfoWidth = constTitlebarRightInfoWidth; float IconSize = constIconSize; float PaletteSize = constPaletteSize; float ButtonSize = constButtonSize; float BuildBarWidth = constBuildBarWidth; float SideBarWidth = constSideBarWidth; float ShortcutSize = constShortcutSize; +float ModeRailWidth = constModeRailWidth; +float ModeRailButtonSize = constModeRailButtonSize; static void UpdateUiScaledMetrics() { @@ -109,13 +140,15 @@ static void UpdateUiScaledMetrics() } TitlebarSize = constTitlebarSize * scale; - TitlebarControlSize = constTitlebarControlSize * scale; + TitlebarRightInfoWidth = constTitlebarRightInfoWidth * scale; IconSize = constIconSize * scale; PaletteSize = constPaletteSize * scale; ButtonSize = constButtonSize * scale; BuildBarWidth = constBuildBarWidth * scale; SideBarWidth = constSideBarWidth * scale; ShortcutSize = constShortcutSize * scale; + ModeRailWidth = constModeRailWidth * scale; + ModeRailButtonSize = constModeRailButtonSize * scale; } std::unique_ptr CreateGameInstance(Vulkan::WindowConfig& config, Options& options, NextEngine* engine) @@ -145,7 +178,12 @@ void NextRendererGameInstance::OnInit() void NextRendererGameInstance::OnTick(double deltaSeconds) { + if (playbackPaused_ && !stepRequested_) + { + return; + } modelViewController_.UpdateCamera(10.0f, deltaSeconds); + stepRequested_ = false; } std::vector MatPreparedForAdd; @@ -201,7 +239,11 @@ bool NextRendererGameInstance::OnRenderUI() UpdateUiScaledMetrics(); DrawTitleBar(); + DrawModeRail(); DrawSettings(); + DrawViewportTopBar(); + DrawViewportBottomBar(); + DrawBottomStatusBar(); if (ImGui::GetCurrentContext() != nullptr) { @@ -260,6 +302,16 @@ void NextRendererGameInstance::OnInitUI() .extraGlyphsUtf8 = "gkNextRenderer", }); } + + if (titleBarFont_ == nullptr) + { + titleBarFont_ = FontLoader::Load(FontLoader::FFontRequest{ + .filePath = "assets/fonts/Roboto-BoldCondensed.ttf", + .pixelSize = 18.0f, + .includeChineseFull = true, + .extraGlyphsUtf8 = "gkNextRenderer", + }); + } } void NextRendererGameInstance::RequestScreenshot(bool openFolder, const std::string& tag) @@ -479,384 +531,758 @@ void NextRendererGameInstance::CreateBoxAndPush() void NextRendererGameInstance::DrawSettings() { - UserSettings& userSetting = GetEngine().GetUserSettings(); - - if (!userSetting.ShowSettings) - { - return; - } + UserSettings& userSetting = GetEngine().GetUserSettings(); - const float distance = 10.0f; - const ImVec2 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); - - const auto flags = - ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoSavedSettings; + if (!userSetting.ShowSettings) + { + return; + } - if (ImGui::Begin("Settings", &userSetting.ShowSettings, flags)) - { - if( ImGui::CollapsingHeader(LOCTEXT("Renderer"), ImGuiTreeNodeFlags_DefaultOpen) ) - { - ImGui::Text("%s", LOCTEXT("Renderer")); - Runtime::GraphicsDebugPanel::DrawRendererSelector(GetEngine(), userSetting, "##RendererList"); - ImGui::NewLine(); - } - - if( ImGui::CollapsingHeader(LOCTEXT("Scene"), ImGuiTreeNodeFlags_DefaultOpen) ) - { - std::vector sceneNames; - for (const auto& scene : SceneList::AllScenes) - { - std::filesystem::path path(scene); - sceneNames.push_back(path.filename().string()); - } - - std::vector camerasList; - for (const auto& cam : GetEngine().GetScene().GetCameras()) - { - camerasList.emplace_back(cam.name.c_str()); - } - - ImGui::Text("%s", LOCTEXT("Scene")); - - ImGui::PushItemWidth(-1); - const char* currentScenePreview = - (userSetting.SceneIndex >= 0 && userSetting.SceneIndex < static_cast(sceneNames.size())) - ? sceneNames[userSetting.SceneIndex].c_str() - : ""; - if (ImGui::BeginCombo("##SceneList", currentScenePreview)) - { - ESceneListGroup currentGroup = ESceneListGroup::Other; - bool hasGroup = false; - for (int sceneIdx = 0; sceneIdx < static_cast(SceneList::AllScenes.size()); ++sceneIdx) - { - const ESceneListGroup sceneGroup = GetSceneListGroup(SceneList::AllScenes[sceneIdx]); - if (!hasGroup || sceneGroup != currentGroup) - { - if (hasGroup) - { - ImGui::Separator(); - } - currentGroup = sceneGroup; - hasGroup = true; + ImGuiViewport* viewport = ImGui::GetMainViewport(); + constexpr float panelWidth = 360.0f; + constexpr float panelMargin = 10.0f; + const ImVec2 panelPos = viewport->Pos + ImVec2(ModeRailWidth + panelMargin, TitlebarSize + panelMargin); + const ImVec2 panelSize(panelWidth, + viewport->Size.y - TitlebarSize - 50.0f - panelMargin); - ImGui::TextDisabled("%s", GetSceneListGroupLabel(sceneGroup)); - } + if (!Runtime::UiTheme::BeginFloatingPanel("##RendererSettingsPanel", ICON_FA_SLIDERS, + "Renderer Settings", &userSetting.ShowSettings, + panelPos, panelSize)) + { + return; + } - const bool selected = (sceneIdx == userSetting.SceneIndex); - if (ImGui::Selectable(sceneNames[sceneIdx].c_str(), selected)) - { - userSetting.SceneIndex = sceneIdx; - GetEngine().RequestLoadScene({.filename = SceneList::AllScenes[userSetting.SceneIndex]}); - } - if (selected) - { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } - ImGui::PopItemWidth(); - - int prevCameraIdx = userSetting.CameraIdx; - ImGui::Text("%s", LOCTEXT("Camera")); - ImGui::PushItemWidth(-1); - ImGui::Combo("##CameraList", &userSetting.CameraIdx, camerasList.data(), static_cast(camerasList.size())); - ImGui::PopItemWidth(); - if(prevCameraIdx != userSetting.CameraIdx) - { - GetEngine().GetScene().SetRenderCamera( GetEngine().GetScene().GetCameras()[userSetting.CameraIdx] ); - modelViewController_.Reset(GetEngine().GetScene().GetRenderCamera()); - } + // Scrollable body + ImGui::BeginChild("##SettingsBody", ImVec2(0, 0), false, ImGuiWindowFlags_NoBackground); - auto& camera = GetEngine().GetScene().GetRenderCamera(); - ImGui::SliderFloat(LOCTEXT("Aperture"), &camera.Aperture, 0.0f, 1.0f, "%.2f"); - ImGui::SliderFloat(LOCTEXT("Focus(cm)"), &camera.FocalDistance, 0.001f, 1000.0f, "%.3f"); - ImGui::NewLine(); - } + auto DrawFloatSetting = [&](const char* label, float* value, float minValue, float maxValue, + const char* format, float dragSpeed) + { + return DrawSettingSliderRow(label, ImGuiDataType_Float, value, minValue, maxValue, format, dragSpeed); + }; - if( ImGui::CollapsingHeader(LOCTEXT("Ray Tracing"), ImGuiTreeNodeFlags_DefaultOpen) ) - { - 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::NewLine(); - } + auto DrawIntSetting = [&](const char* label, int* value, int minValue, int maxValue, + const char* format = "%d") + { + return DrawSettingSliderRow(label, ImGuiDataType_S32, value, minValue, maxValue, format, 1.0f); + }; - if( ImGui::CollapsingHeader(LOCTEXT("Denoiser"), ImGuiTreeNodeFlags_DefaultOpen) ) - { - ImGui::Checkbox(LOCTEXT("Use JBF"), &userSetting.Denoiser); - 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"); - ImGui::SliderInt(LOCTEXT("DenoiseSize"), &userSetting.DenoiseSize, 1, 10); - ImGui::NewLine(); - } + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Renderer"), true)) + { + Runtime::UiTheme::LabelOver(LOCTEXT("Renderer")); + ImGui::PushItemWidth(-1); + Runtime::GraphicsDebugPanel::DrawRendererSelector(GetEngine(), userSetting, "##RendererList"); + ImGui::PopItemWidth(); + Runtime::UiTheme::EndPanelSection(); + } - if( ImGui::CollapsingHeader(LOCTEXT("Upscaling"), ImGuiTreeNodeFlags_DefaultOpen) ) - { - if (GetEngine().GetRenderer().SupportDLSS()) - { - if (ImGui::Checkbox("NVIDIA DLSS", &userSetting.DLSS)) + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Scene"), true)) + { + std::vector sceneNames; + sceneNames.reserve(SceneList::AllScenes.size()); + for (const auto& scene : SceneList::AllScenes) + { + sceneNames.push_back(std::filesystem::path(scene).filename().string()); + } + + Runtime::UiTheme::LabelOver(LOCTEXT("Scene")); + ImGui::PushItemWidth(-1); + const char* currentScenePreview = + (userSetting.SceneIndex >= 0 && userSetting.SceneIndex < static_cast(sceneNames.size())) + ? sceneNames[userSetting.SceneIndex].c_str() + : ""; + if (ImGui::BeginCombo("##SceneList", currentScenePreview)) + { + ESceneListGroup currentGroup = ESceneListGroup::Other; + bool hasGroup = false; + for (int sceneIdx = 0; sceneIdx < static_cast(SceneList::AllScenes.size()); ++sceneIdx) + { + const ESceneListGroup sceneGroup = GetSceneListGroup(SceneList::AllScenes[sceneIdx]); + if (!hasGroup || sceneGroup != currentGroup) { - 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))) + if (hasGroup) { - GetEngine().GetRenderer().RequestRecreateSwapChain(); + ImGui::Separator(); } - - if (GetEngine().GetRenderer().SupportDLSSRR()) - { - ImGui::Checkbox("DLSS Ray Reconstruction", &userSetting.DLSSRR); - } - } - } - else - { - ImGui::TextDisabled("DLSS not supported on this hardware."); - } - - if (!userSetting.DLSS) - { - const char* upscaleModes[] = { "Quality", "Balanced", "Performance", "Ultra Performance", "Native" }; - if (ImGui::Combo("Upscale Mode", (int*)&userSetting.SuperResolution, upscaleModes, IM_ARRAYSIZE(upscaleModes))) + currentGroup = sceneGroup; + hasGroup = true; + ImGui::TextDisabled("%s", GetSceneListGroupLabel(sceneGroup)); + } + + const bool selected = (sceneIdx == userSetting.SceneIndex); + if (ImGui::Selectable(sceneNames[sceneIdx].c_str(), selected)) { - GetEngine().GetRenderer().RequestRecreateSwapChain(); + userSetting.SceneIndex = sceneIdx; + GetEngine().RequestLoadScene({.filename = SceneList::AllScenes[userSetting.SceneIndex]}); + } + if (selected) + { + ImGui::SetItemDefaultFocus(); } } - ImGui::NewLine(); - } - - if( ImGui::CollapsingHeader(LOCTEXT("Lighting"), ImGuiTreeNodeFlags_None) ) - { - ImGui::Checkbox(LOCTEXT("UseAmbientCubePropagation"), &userSetting.UseAmbientCubePropagation); - if (ImGui::Checkbox(LOCTEXT("UseGpuAmbientCubeSdf"), &userSetting.UseGpuAmbientCubeSdf)) - { - GetEngine().GetScene().RequestGpuDistanceFieldRebuild(); - GetEngine().GetScene().MarkDirty(); - } - - ImGui::Checkbox(LOCTEXT("HasSky"), &GetEngine().GetScene().GetEnvSettings().HasSky); - if(GetEngine().GetScene().GetEnvSettings().HasSky) - { - ImGui::SliderInt(LOCTEXT("SkyIdx"), &GetEngine().GetScene().GetEnvSettings().SkyIdx, 0, 10); - ImGui::SliderFloat(LOCTEXT("SkyRotation"), &GetEngine().GetScene().GetEnvSettings().SkyRotation, 0.0f, 2.0f, "%.2f"); - ImGui::SliderFloat(LOCTEXT("SkyLum"), &GetEngine().GetScene().GetEnvSettings().SkyIntensity, 0.0f, 1000.0f, "%.0f"); - } - - ImGui::Checkbox(LOCTEXT("HasSun"), &GetEngine().GetScene().GetEnvSettings().HasSun); - if(GetEngine().GetScene().GetEnvSettings().HasSun) - { - ImGui::SliderFloat(LOCTEXT("SunRotation"), &GetEngine().GetScene().GetEnvSettings().SunRotation, 0.0f, 2.0f, "%.2f"); - ImGui::SliderFloat(LOCTEXT("SunLum"), &GetEngine().GetScene().GetEnvSettings().SunIntensity, 0.0f, 2000.0f, "%.0f"); - } + ImGui::EndCombo(); + } + ImGui::PopItemWidth(); + Runtime::UiTheme::EndPanelSection(); + } - ImGui::SliderFloat(LOCTEXT("PaperWhitNit"), &userSetting.PaperWhiteNit, 100.0f, 1600.0f, "%.1f"); - ImGui::NewLine(); - } + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Camera"), true)) + { + std::vector camerasList; + for (const auto& cam : GetEngine().GetScene().GetCameras()) + { + camerasList.emplace_back(cam.name.c_str()); + } - if( ImGui::CollapsingHeader(LOCTEXT("Animation"), ImGuiTreeNodeFlags_None) ) - { - ImGui::Checkbox(LOCTEXT("Tick Animation"), &userSetting.TickAnimation); - ImGui::Checkbox(LOCTEXT("Show Debug Skeleton"), &GetEngine().GetShowFlags().ShowDebugSkeleton); - - ImGui::Separator(); - for (auto& node : GetEngine().GetScene().Nodes()) + Runtime::UiTheme::LabelOver(LOCTEXT("Camera")); + ImGui::PushItemWidth(-1); + const int prevCameraIdx = userSetting.CameraIdx; + ImGui::Combo("##CameraList", &userSetting.CameraIdx, camerasList.data(), + static_cast(camerasList.size())); + ImGui::PopItemWidth(); + if (prevCameraIdx != userSetting.CameraIdx) + { + GetEngine().GetScene().SetRenderCamera(GetEngine().GetScene().GetCameras()[userSetting.CameraIdx]); + modelViewController_.Reset(GetEngine().GetScene().GetRenderCamera()); + } + + auto& camera = GetEngine().GetScene().GetRenderCamera(); + DrawFloatSetting(LOCTEXT("Aperture"), &camera.Aperture, 0.0f, 1.0f, "%.2f", 0.01f); + DrawFloatSetting(LOCTEXT("Focus(cm)"), &camera.FocalDistance, 0.001f, 1000.0f, "%.3f", 0.05f); + Runtime::UiTheme::EndPanelSection(); + } + + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Ray Tracing"), true)) + { + static bool rayTracingEnabled = true; + ImGui::Checkbox("Enable", &rayTracingEnabled); + ImGui::BeginDisabled(!rayTracingEnabled); + ImGui::Checkbox(LOCTEXT("AntiAlias"), &userSetting.TAA); + DrawIntSetting(LOCTEXT("Samples"), &userSetting.NumberOfSamples, 1, 16); + DrawIntSetting(LOCTEXT("Temporal Steps"), &userSetting.AdaptiveSteps, 2, 64); + ImGui::Checkbox(LOCTEXT("FastGather"), &userSetting.FastGather); + DrawIntSetting(LOCTEXT("Ambient Speed"), &userSetting.BakeSpeedLevel, 0, 2); + ImGui::EndDisabled(); + Runtime::UiTheme::EndPanelSection(); + } + + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Denoiser"), true)) + { + static int denoiserAlgorithm = 0; + const char* denoiserAlgorithms[] = {"HDR JBF", "SVGF", "Atrous", "None"}; + Runtime::UiTheme::LabelOver("Algorithm"); + ImGui::PushItemWidth(-1); + if (ImGui::Combo("##DenoiserAlgo", &denoiserAlgorithm, denoiserAlgorithms, + IM_ARRAYSIZE(denoiserAlgorithms))) + { + userSetting.Denoiser = denoiserAlgorithm != 3; + } + ImGui::PopItemWidth(); + DrawFloatSetting(LOCTEXT("DenoiseSigma"), &userSetting.DenoiseSigma, 0.01f, 2.0f, "%.2f", 0.01f); + DrawFloatSetting(LOCTEXT("DenoiseSigma Lum"), &userSetting.DenoiseSigmaLum, 0.01f, 50.0f, "%.2f", 0.05f); + DrawFloatSetting(LOCTEXT("DenoiseSigma Norm"), &userSetting.DenoiseSigmaNormal, 0.0f, 0.2f, "%.3f", 0.001f); + DrawIntSetting(LOCTEXT("DenoiseSize"), &userSetting.DenoiseSize, 1, 10); + Runtime::UiTheme::EndPanelSection(); + } + + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Upscaling"), true)) + { + static int upscaleMethod = userSetting.DLSS ? 1 : 0; + const char* methods[] = {"None", "DLSS", "FSR", "TAAU"}; + Runtime::UiTheme::LabelOver("Method"); + ImGui::PushItemWidth(-1); + if (ImGui::Combo("##UpscaleMethod", &upscaleMethod, methods, IM_ARRAYSIZE(methods))) + { + userSetting.DLSS = upscaleMethod == 1 && GetEngine().GetRenderer().SupportDLSS(); + GetEngine().GetRenderer().RequestRecreateSwapChain(); + } + ImGui::PopItemWidth(); + + const char* qualities[] = {"Quality", "Balanced", "Performance", "Ultra Performance", "Native"}; + Runtime::UiTheme::LabelOver("Quality"); + ImGui::PushItemWidth(-1); + if (ImGui::Combo("##UpscaleQuality", (int*)&userSetting.SuperResolution, qualities, + IM_ARRAYSIZE(qualities))) + { + GetEngine().GetRenderer().RequestRecreateSwapChain(); + } + ImGui::PopItemWidth(); + + if (upscaleMethod == 1 && !GetEngine().GetRenderer().SupportDLSS()) + { + ImGui::TextDisabled("DLSS not supported on this hardware."); + } + Runtime::UiTheme::EndPanelSection(); + } + + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Lighting"), false)) + { + ImGui::Checkbox(LOCTEXT("UseAmbientCubePropagation"), &userSetting.UseAmbientCubePropagation); + if (ImGui::Checkbox(LOCTEXT("UseGpuAmbientCubeSdf"), &userSetting.UseGpuAmbientCubeSdf)) + { + GetEngine().GetScene().RequestGpuDistanceFieldRebuild(); + GetEngine().GetScene().MarkDirty(); + } + + ImGui::Checkbox(LOCTEXT("HasSky"), &GetEngine().GetScene().GetEnvSettings().HasSky); + if (GetEngine().GetScene().GetEnvSettings().HasSky) + { + ImGui::SliderInt(LOCTEXT("SkyIdx"), &GetEngine().GetScene().GetEnvSettings().SkyIdx, 0, 10); + ImGui::SliderFloat(LOCTEXT("SkyRotation"), &GetEngine().GetScene().GetEnvSettings().SkyRotation, 0.0f, 2.0f, "%.2f"); + ImGui::SliderFloat(LOCTEXT("SkyLum"), &GetEngine().GetScene().GetEnvSettings().SkyIntensity, 0.0f, 1000.0f, "%.0f"); + } + + ImGui::Checkbox(LOCTEXT("HasSun"), &GetEngine().GetScene().GetEnvSettings().HasSun); + if (GetEngine().GetScene().GetEnvSettings().HasSun) + { + ImGui::SliderFloat(LOCTEXT("SunRotation"), &GetEngine().GetScene().GetEnvSettings().SunRotation, 0.0f, 2.0f, "%.2f"); + ImGui::SliderFloat(LOCTEXT("SunLum"), &GetEngine().GetScene().GetEnvSettings().SunIntensity, 0.0f, 2000.0f, "%.0f"); + } + + ImGui::SliderFloat(LOCTEXT("PaperWhitNit"), &userSetting.PaperWhiteNit, 100.0f, 1600.0f, "%.1f"); + Runtime::UiTheme::EndPanelSection(); + } + + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Animation"), false)) + { + ImGui::Checkbox(LOCTEXT("Tick Animation"), &userSetting.TickAnimation); + ImGui::Checkbox(LOCTEXT("Show Debug Skeleton"), &GetEngine().GetShowFlags().ShowDebugSkeleton); + + ImGui::Separator(); + for (auto& node : GetEngine().GetScene().Nodes()) + { + if (auto skinnedMesh = node->GetComponent()) { - if (auto skinnedMesh = node->GetComponent()) + ImGui::PushID(node->GetName().c_str()); + ImGui::Text("%s", node->GetName().c_str()); + auto animNames = skinnedMesh->GetAnimationNames(); + if (!animNames.empty()) { - ImGui::PushID(node->GetName().c_str()); - ImGui::Text("%s", node->GetName().c_str()); - auto animNames = skinnedMesh->GetAnimationNames(); - if (!animNames.empty()) + std::string current = skinnedMesh->GetCurrentAnimationName(); + int selectedAnim = -1; + for (int i = 0; i < static_cast(animNames.size()); ++i) { - std::string current = skinnedMesh->GetCurrentAnimationName(); - int selectedAnim = -1; - for(int i=0; i(animNames.size()); ++i) { - if(animNames[i] == current) { - selectedAnim = i; - break; - } - } - - std::vector animPtrs; - for (const auto& name : animNames) animPtrs.push_back(name.c_str()); - - if (ImGui::Combo("##AnimList", &selectedAnim, animPtrs.data(), static_cast(animPtrs.size()))) + if (animNames[i] == current) { - skinnedMesh->PlayAnimation(animNames[selectedAnim]); - } - - float speed = skinnedMesh->GetPlaySpeed(); - if (ImGui::SliderFloat("Speed", &speed, -2.0f, 2.0f, "%.2f")) - { - skinnedMesh->SetPlaySpeed(speed); + selectedAnim = i; + break; } } - else + + std::vector animPtrs; + for (const auto& name : animNames) animPtrs.push_back(name.c_str()); + + if (ImGui::Combo("##AnimList", &selectedAnim, animPtrs.data(), + static_cast(animPtrs.size()))) + { + skinnedMesh->PlayAnimation(animNames[selectedAnim]); + } + + float speed = skinnedMesh->GetPlaySpeed(); + if (ImGui::SliderFloat("Speed", &speed, -2.0f, 2.0f, "%.2f")) { - ImGui::TextDisabled("No animations"); + skinnedMesh->SetPlaySpeed(speed); } - ImGui::PopID(); } + else + { + ImGui::TextDisabled("No animations"); + } + ImGui::PopID(); } - ImGui::NewLine(); - } + } + Runtime::UiTheme::EndPanelSection(); + } - if( ImGui::CollapsingHeader(LOCTEXT("Misc"), ImGuiTreeNodeFlags_None) ) - { - ImGui::Text("%s", LOCTEXT("Profiler")); - ImGui::Separator(); - ImGui::Checkbox(LOCTEXT("ShowWireframe"), &GetEngine().GetShowFlags().ShowWireframe); - ImGui::Checkbox(LOCTEXT("TickPhysics"), &userSetting.TickPhysics); - ImGui::Checkbox(LOCTEXT("DebugDraw"), &GetEngine().GetShowFlags().ShowVisualDebug); - ImGui::Checkbox(LOCTEXT("DebugDraw_Lighting"), &GetEngine().GetShowFlags().DebugDraw_Lighting); - ImGui::Checkbox(LOCTEXT("DebugDraw_BoundingBox"), &GetEngine().GetShowFlags().DebugDraw_BoundingBox); - - ImGui::SliderFloat(LOCTEXT("Time Scaling"), &userSetting.HeatmapScale, 0.10f, 2.0f, "%.2f", ImGuiSliderFlags_Logarithmic); - ImGui::NewLine(); - - ImGui::Text("%s", LOCTEXT("Performance")); - ImGui::Separator(); - uint32_t min = 8, max = 32; - ImGui::SliderScalar(LOCTEXT("Temporal Frames"), ImGuiDataType_U32, &userSetting.TemporalFrames, &min, &max); - } - } - ImGui::End(); + if (Runtime::UiTheme::BeginPanelSection(LOCTEXT("Misc"), false)) + { + ImGui::Checkbox(LOCTEXT("ShowWireframe"), &GetEngine().GetShowFlags().ShowWireframe); + ImGui::Checkbox(LOCTEXT("TickPhysics"), &userSetting.TickPhysics); + ImGui::Checkbox(LOCTEXT("DebugDraw"), &GetEngine().GetShowFlags().ShowVisualDebug); + ImGui::Checkbox(LOCTEXT("DebugDraw_Lighting"), &GetEngine().GetShowFlags().DebugDraw_Lighting); + ImGui::Checkbox(LOCTEXT("DebugDraw_BoundingBox"), &GetEngine().GetShowFlags().DebugDraw_BoundingBox); + + ImGui::SliderFloat(LOCTEXT("Time Scaling"), &userSetting.HeatmapScale, 0.10f, 2.0f, "%.2f", + ImGuiSliderFlags_Logarithmic); + + ImGui::Spacing(); + uint32_t tmin = 8, tmax = 32; + ImGui::SliderScalar(LOCTEXT("Temporal Frames"), ImGuiDataType_U32, &userSetting.TemporalFrames, &tmin, + &tmax); + Runtime::UiTheme::EndPanelSection(); + } + + ImGui::EndChild(); + Runtime::UiTheme::EndFloatingPanel(); } -void NextRendererGameInstance::DrawTitleBar() +void NextRendererGameInstance::DrawModeRail() { - // 获取窗口的大小 - 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"); + ImGuiViewport* viewport = ImGui::GetMainViewport(); + const ImVec2 railPos = viewport->Pos + ImVec2(0.0f, TitlebarSize); + const ImVec2 railSize = ImVec2(ModeRailWidth, viewport->Size.y - TitlebarSize - 30.0f); + + ImDrawList* background = ImGui::GetBackgroundDrawList(); + background->AddRectFilled(railPos, railPos + railSize, + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Background)); + background->AddLine(ImVec2(railPos.x + railSize.x - 1.0f, railPos.y), + ImVec2(railPos.x + railSize.x - 1.0f, railPos.y + railSize.y), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Border)); + + ImGui::SetNextWindowPos(railPos, ImGuiCond_Always); + ImGui::SetNextWindowSize(railSize, ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, + ImVec2((ModeRailWidth - ModeRailButtonSize) * 0.5f, 10.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 6.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + + constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground; + + if (ImGui::Begin("##ModeRail", nullptr, flags)) + { + struct ModeEntry + { + EWorkMode mode; + const char* icon; + const char* tooltip; + }; + const ModeEntry topEntries[] = { + {EWorkMode::Renderer, ICON_FA_CAMERA_RETRO, "Renderer"}, + {EWorkMode::Camera, ICON_FA_CAMERA, "Camera"}, + {EWorkMode::World, ICON_FA_GLOBE, "World / Lighting"}, + {EWorkMode::Mesh, ICON_FA_CUBE, "Scene Outliner"}, + {EWorkMode::Profiler, ICON_FA_CHART_LINE, "Profiler"}, + }; + + for (const auto& entry : topEntries) + { + const bool active = (entry.mode == workMode_); + if (Runtime::UiTheme::ModeRailButton(entry.icon, entry.tooltip, active, ModeRailButtonSize)) + { + workMode_ = entry.mode; + } + } - ImGui::PopFont(); + // Push the gear button to the bottom. + const float gearSize = ModeRailButtonSize; + const float spaceUntilBottom = ImGui::GetContentRegionAvail().y - gearSize - 6.0f; + if (spaceUntilBottom > 0.0f) + { + ImGui::Dummy(ImVec2(0.0f, spaceUntilBottom)); + } + const bool settingsActive = (workMode_ == EWorkMode::Settings); + if (Runtime::UiTheme::ModeRailButton(ICON_FA_GEAR, "Settings", settingsActive, gearSize)) + { + workMode_ = EWorkMode::Settings; + } + } + ImGui::End(); - 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::PopStyleVar(3); +} - ImGui::SetNextWindowPos(ImVec2(windowSize.x - TitlebarControlSize, 0), ImGuiCond_Always, ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(TitlebarControlSize, TitlebarSize)); +void NextRendererGameInstance::DrawViewportTopBar() +{ + UserSettings& userSetting = GetEngine().GetUserSettings(); + ImGuiViewport* viewport = ImGui::GetMainViewport(); - ImGui::Begin("TitleBarRight", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBackground); - titlebarRightReservedWidth = ImGui::GetWindowSize().x; + const float panelMargin = 10.0f; + const float leftEdge = viewport->Pos.x + ModeRailWidth + + (userSetting.ShowSettings ? (360.0f + panelMargin * 2.0f) : panelMargin); + const float topEdge = viewport->Pos.y + TitlebarSize + 10.0f; - if (ImGui::Button(ICON_FA_MINUS, ImVec2(TitlebarSize, TitlebarSize))) + // Left badge: "Path Tracing | Live" { - GetEngine().RequestMinimize(); + const char* rendererLabel = Runtime::GraphicsDebugPanel::GetCurrentRendererLabel(GetEngine(), userSetting); + const std::string rendererText = rendererLabel; + constexpr const char* liveText = "Live"; + const float badgeHeight = 30.0f; + const float rendererWidth = ImGui::CalcTextSize(rendererText.c_str()).x + 24.0f; + const float liveWidth = ImGui::CalcTextSize(liveText).x + 20.0f; + const float badgeWidth = rendererWidth + liveWidth + 26.0f; + + ImGui::SetNextWindowPos(ImVec2(leftEdge, topEdge), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(badgeWidth, badgeHeight), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0, 0, 0, 0)); + + if (ImGui::Begin("##ViewportTopLeftBadge", nullptr, + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoScrollbar)) + { + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 badgeMin = ImGui::GetWindowPos(); + const ImVec2 badgeMax = badgeMin + ImGui::GetWindowSize(); + const float separatorX = badgeMin.x + rendererWidth + 12.0f; + const float centerY = badgeMin.y + badgeHeight * 0.5f; + + dl->AddRectFilled(badgeMin, badgeMax, + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::SurfaceElevated, 0.94f), 6.0f); + dl->AddRect(badgeMin, badgeMax, + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Border, 0.92f), 6.0f); + dl->AddLine(ImVec2(separatorX, badgeMin.y + 6.0f), + ImVec2(separatorX, badgeMax.y - 6.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::BorderStrong, 0.8f)); + dl->AddCircleFilled(ImVec2(badgeMin.x + 14.0f, centerY), 4.0f, + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Success)); + dl->AddText(ImVec2(badgeMin.x + 24.0f, badgeMin.y + 7.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Text), rendererText.c_str()); + dl->AddText(ImVec2(separatorX + 12.0f, badgeMin.y + 7.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::TextMuted), liveText); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); } - ImGui::SameLine(); - if (ImGui::Button(GetEngine().IsMaximumed() ? ICON_FA_WINDOW_RESTORE : ICON_FA_SQUARE, ImVec2(TitlebarSize, TitlebarSize))) + + // Right cluster: screenshot / focus / 1:1 { - GetEngine().ToggleMaximize(); + const float clusterWidth = 138.0f; + const float rightEdge = viewport->Pos.x + viewport->Size.x - panelMargin - clusterWidth; + ImGui::SetNextWindowPos(ImVec2(rightEdge, topEdge), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(clusterWidth, 32.0f), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 2.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::SurfaceElevated, 0.92f)); + if (ImGui::Begin("##ViewportTopRightCluster", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav)) + { + if (Runtime::UiTheme::ToolbarButton(ICON_FA_CAMERA, "Take Screenshot", false, ImVec2(28.0f, 26.0f))) + { + RequestScreenshot(false, ""); + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_EXPAND, "Focus Selected", false, ImVec2(28.0f, 26.0f))) + { + glm::vec3 focusCenter; + float radius; + if (GetEngine().GetScene().GetSelectedNodeBounds(focusCenter, radius)) + { + modelViewController_.Focus(focusCenter, radius); + } + } + ImGui::SameLine(); + Runtime::UiTheme::ToolbarButton("1:1 " ICON_FA_CHEVRON_DOWN, "Native Resolution", false, ImVec2(46.0f, 26.0f)); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(3); } - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_XMARK, ImVec2(TitlebarSize, TitlebarSize))) +} + +void NextRendererGameInstance::DrawViewportBottomBar() +{ + ImGuiViewport* viewport = ImGui::GetMainViewport(); + constexpr float bottomStatusBar = 30.0f; + + const auto& swapChain = GetEngine().GetRenderer().SwapChain(); + const auto extent = swapChain.OutputExtent(); + const std::string frameText = fmt::format("Frame {}", GetEngine().GetTotalFrames()); + const std::string sampleText = fmt::format("Samples {} spp", GetEngine().GetUserSettings().NumberOfSamples); + const std::string resolutionText = fmt::format("{} x {}", extent.width, extent.height); + const float textWidth = ImGui::CalcTextSize(frameText.c_str()).x + + ImGui::CalcTextSize(sampleText.c_str()).x + + ImGui::CalcTextSize(resolutionText.c_str()).x + 72.0f; + const ImVec2 padding(16.0f, 6.0f); + const ImVec2 windowSize(textWidth + padding.x * 2.0f + 18.0f, + ImGui::GetTextLineHeight() + padding.y * 2.0f); + const ImVec2 windowPos(viewport->Pos.x + (viewport->Size.x - windowSize.x) * 0.5f, + viewport->Pos.y + viewport->Size.y - bottomStatusBar - windowSize.y - 8.0f); + + ImGui::SetNextWindowPos(windowPos, ImGuiCond_Always); + ImGui::SetNextWindowSize(windowSize, ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.85f); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, padding); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::SurfaceElevated, 0.92f)); + + if (ImGui::Begin("##ViewportFrameInfo", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav)) { - GetEngine().RequestClose(); + auto DrawTokenSeparator = [&]() + { + ImGui::SameLine(0.0f, 10.0f); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextDim), "|"); + ImGui::SameLine(0.0f, 10.0f); + }; + + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "%s", frameText.c_str()); + DrawTokenSeparator(); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "%s", sampleText.c_str()); + DrawTokenSeparator(); + ImGui::TextColored(Runtime::UiTheme::Color(Runtime::UiTheme::EColor::TextMuted), "%s", resolutionText.c_str()); + ImGui::SameLine(0.0f, 10.0f); + Runtime::UiTheme::ToolbarButton(ICON_FA_EXPAND, "Viewport Display Options", false, ImVec2(22.0f, 18.0f)); } ImGui::End(); - ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always, ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(TitlebarSize * 18, TitlebarSize)); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); +} - ImGui::Begin("TitleBarLeft", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBackground); - if (ImGui::Button(ICON_FA_GITHUB, ImVec2(TitlebarSize, TitlebarSize))) +void NextRendererGameInstance::DrawTitleBar() +{ + Runtime::UiTheme::FAppTitleBarConfig config{}; + config.BrandWindowId = "RendererBrand"; + config.MenuWindowId = "RendererMenuBar"; + config.RightWindowId = "RendererWindowControls"; + config.AppName = "gkNextRenderer"; + config.Height = TitlebarSize; + config.RightContentWidth = TitlebarRightInfoWidth; + config.TitleFont = titleBarFont_; + config.IsMaximized = GetEngine().IsMaximumed(); + config.DrawMenuBar = [&]() -> float { - 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))) + float menuRight = ImGui::GetCursorScreenPos().x; + + const auto UpdateMenuRight = [&menuRight]() + { + menuRight = std::max(menuRight, ImGui::GetItemRectMax().x); + }; + + if (ImGui::BeginMenu("File")) + { + UpdateMenuRight(); + if (ImGui::MenuItem("Project Page")) + { + NextRenderer::OSCommand("https://github.com/gameknife/gkNextRenderer"); + } + if (ImGui::MenuItem("Open Screenshot Folder")) + { + RequestScreenshot(true, ""); + } + ImGui::EndMenu(); + } + else + { + UpdateMenuRight(); + } + + if (ImGui::BeginMenu("View")) + { + UpdateMenuRight(); + auto& showFlags = GetEngine().GetShowFlags(); + Utilities::UI::DrawShowFlagsCommon(showFlags); + ImGui::MenuItem("Profiler Overlay", nullptr, &GetEngine().GetUserSettings().ShowOverlay); + ImGui::EndMenu(); + } + else + { + UpdateMenuRight(); + } + + if (ImGui::BeginMenu("Capture")) + { + UpdateMenuRight(); + if (ImGui::MenuItem("Screenshot")) + { + RequestScreenshot(false, ""); + } + if (ImGui::MenuItem("Screenshot and Open Folder")) + { + RequestScreenshot(true, ""); + } + ImGui::EndMenu(); + } + else + { + UpdateMenuRight(); + } + + if (ImGui::BeginMenu("Renderer")) + { + UpdateMenuRight(); + Runtime::GraphicsDebugPanel::DrawRendererSelector(GetEngine(), GetEngine().GetUserSettings(), + "##RendererMenuSelector", 180.0f); + ImGui::EndMenu(); + } + else + { + UpdateMenuRight(); + } + + if (ImGui::BeginMenu("Settings")) + { + UpdateMenuRight(); + ImGui::MenuItem("Render Settings", nullptr, &GetEngine().GetUserSettings().ShowSettings); + ImGui::MenuItem("Stats Overlay", nullptr, &GetEngine().GetUserSettings().ShowOverlay); + ImGui::EndMenu(); + } + else + { + UpdateMenuRight(); + } + + if (ImGui::BeginMenu("Help")) + { + UpdateMenuRight(); + ImGui::MenuItem("Documentation", nullptr, false, false); + ImGui::MenuItem("About gkNextRenderer", nullptr, false, false); + ImGui::EndMenu(); + } + else + { + UpdateMenuRight(); + } + + return menuRight; + }; + config.DrawRightContent = [&]() { - 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))) + const auto framebufferSize = GetEngine().GetWindow().FramebufferSize(); + ImGui::SetCursorPos(ImVec2(0.0f, std::floor((TitlebarSize - ImGui::GetTextLineHeight()) * 0.5f) - 1.0f)); + 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); + }; + config.OnMinimize = [&]() { GetEngine().RequestMinimize(); }; + config.OnToggleMaximize = [&]() { GetEngine().ToggleMaximize(); }; + config.OnClose = [&]() { GetEngine().RequestClose(); }; + Runtime::UiTheme::DrawAppTitleBar(GetEngine(), config); +} + +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)) { - GetEngine().AddTickedTask([this](double deltaSeconds)-> bool + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->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); + + auto DrawVerticalSeparator = [&]() { - 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")) + ImGui::SameLine(0.0f, 12.0f); + const ImVec2 separatorMin = ImGui::GetCursorScreenPos(); + drawList->AddLine(ImVec2(separatorMin.x, separatorMin.y + 2.0f), + ImVec2(separatorMin.x, separatorMin.y + 18.0f), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Border, 0.9f)); + ImGui::Dummy(ImVec2(1.0f, 18.0f)); + ImGui::SameLine(0.0f, 12.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, ""); + } + DrawVerticalSeparator(); - 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(); + const float centerStart = viewport->Size.x * 0.5f - 132.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_BACKWARD, "Previous Sample (placeholder)", false, + ImVec2(28.0f, 22.0f))) + { + playbackPaused_ = true; + stepRequested_ = true; + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_PLAY, "Play / Pause", !playbackPaused_, ImVec2(30.0f, 22.0f))) + { + playbackPaused_ = !playbackPaused_; + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_FORWARD, "Step Frame", false, ImVec2(28.0f, 22.0f))) + { + playbackPaused_ = true; + stepRequested_ = true; + } + ImGui::SameLine(); + if (Runtime::UiTheme::ToolbarButton(ICON_FA_FORWARD_STEP, "Advance 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); + + DrawVerticalSeparator(); + + const float rightStart = viewport->Size.x - 284.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::SameLine(0.0f, 10.0f); + Runtime::UiTheme::ToolbarButton(ICON_FA_CHART_COLUMN, "Memory Statistics", false, ImVec2(24.0f, 22.0f)); } - 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); + ImGui::PopStyleVar(3); } diff --git a/src/Application/gkNextRenderer/gkNextRenderer.hpp b/src/Application/gkNextRenderer/gkNextRenderer.hpp index 2688791a..7be8dd42 100644 --- a/src/Application/gkNextRenderer/gkNextRenderer.hpp +++ b/src/Application/gkNextRenderer/gkNextRenderer.hpp @@ -37,18 +37,38 @@ class NextRendererGameInstance : public NextGameInstanceBase void CreateSphereAndPush(); void CreateBoxAndPush(); + enum class EWorkMode : uint8_t + { + Renderer = 0, + Camera, + World, + Mesh, + Profiler, + Settings, + Count, + }; + private: void DrawSettings(); void DrawTitleBar(); + void DrawBottomStatusBar(); + void DrawModeRail(); + void DrawViewportTopBar(); + void DrawViewportBottomBar(); void RequestScreenshot(bool openFolder, const std::string& tag); ModelViewController modelViewController_; GizmoController gizmoController_; + EWorkMode workMode_ = EWorkMode::Renderer; + uint32_t modelId_; uint32_t boxModelId_; std::vector matIds_; struct ImFont* bigFont_ {}; + struct ImFont* titleBarFont_ {}; bool isTakingScreenshot_ = false; bool agentValidationCaptured_ = false; + bool playbackPaused_ = false; + bool stepRequested_ = false; }; 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/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 5810ef63..30b2bff1 100644 --- a/src/Assets/Core/Scene.cpp +++ b/src/Assets/Core/Scene.cpp @@ -25,6 +25,37 @@ namespace Assets { + 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(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() { using namespace entt::literals; @@ -65,46 +96,24 @@ 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, + commandPool, "SceneDynamic", 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), - 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_); - - Vulkan::BufferUtil::CreateDeviceBufferLocal( - commandPool, "GPUDrivenStats", flags, - VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, sizeof(Assets::GPUDrivenStat), - gpuDrivenStatsBuffer_, gpuDrivenStatsBuffer_Memory_); + Assets::GPU_SCENE_DYNAMIC_SIZE, sceneDynamicBuffer_, sceneDynamicBufferMemory_); 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, - Assets::CUBE_CASCADE_MAX * 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)); @@ -132,28 +141,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(); - - pageIndexBuffer_.reset(); - pageIndexBufferMemory_.reset(); - - hdrSHBuffer_.reset(); - hdrSHBufferMemory_.reset(); + sceneDynamicBuffer_.reset(); + sceneDynamicBufferMemory_.reset(); + ambientArenaBuffer_.reset(); + ambientArenaBufferMemory_.reset(); skinWeightBuffer_.reset(); skinWeightBufferMemory_.reset(); @@ -420,7 +411,6 @@ namespace Assets // 重建universe mesh buffer, 这个可以比较静态 std::vector vertices; - std::vector simpleVertices; std::vector indices; std::vector allWeights; std::vector allJoints; @@ -439,10 +429,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(); @@ -534,21 +520,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_, @@ -573,7 +548,10 @@ namespace Assets UpdateNodesGpuDriven(); MarkDirty(); - cpuAccelerationStructure_.AsyncProcessFull(*this, farAmbientCubeBufferMemory_.get(), false); + if (!NextEngine::GetInstance() || NextEngine::GetInstance()->GetRenderer().CurrentRendererUsesAmbientCube()) + { + cpuAccelerationStructure_.AsyncProcessFull(*this, ambientArenaBufferMemory_.get(), false); + } } void Scene::CleanUp() { cpuAccelerationStructure_.ClearAllTasks(); } @@ -790,28 +768,20 @@ 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_.ReservedAddress0 = 0; gpuScene_.SwapChainIndex = imageIndex; @@ -828,7 +798,7 @@ namespace Assets void Scene::MarkEnvDirty() { - // cpuAccelerationStructure_.AsyncProcessFull(*this, farAmbientCubeBufferMemory_.get(), true); + // cpuAccelerationStructure_.AsyncProcessFull(*this, ambientArenaBufferMemory_.get(), true); // cpuAccelerationStructure_.GenShadowMap(*this); } @@ -991,10 +961,11 @@ 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()); + *this, ambientArenaBufferMemory_.get(), ambientArenaBufferMemory_.get(), ambientArenaBufferMemory_.get()); if (voxelUploadCompleted && NextEngine::GetInstance()->GetUserSettings().UseGpuAmbientCubeSdf) { RequestGpuDistanceFieldRebuild(); @@ -1002,7 +973,7 @@ namespace Assets if (sceneDirtyForCpuAS_ && !cpuAccelerationStructure_.HasPendingWork()) { - if (cpuAccelerationStructure_.AsyncProcessFull(*this, farAmbientCubeBufferMemory_.get(), true)) + if (cpuAccelerationStructure_.AsyncProcessFull(*this, ambientArenaBufferMemory_.get(), true)) { sceneDirtyForCpuAS_ = false; } @@ -1021,10 +992,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); } @@ -1033,12 +1004,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 @@ -1057,9 +1029,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(); } } @@ -1132,9 +1105,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; } @@ -1261,11 +1235,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 1e564187..294e8a47 100644 --- a/src/Assets/Core/Scene.hpp +++ b/src/Assets/Core/Scene.hpp @@ -68,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_; } @@ -156,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_; } @@ -202,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_; @@ -214,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_; @@ -223,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_; @@ -250,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_{}; @@ -291,7 +270,6 @@ namespace Assets std::vector> cachedMeshShapes_; VkDeviceAddress skinnedVerticesAddr_ = 0; - VkDeviceAddress skinnedVerticesSimpleAddr_ = 0; VkDeviceAddress jointMatricesAddr_ = 0; }; } // namespace Assets 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..bc4b0562 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 @@ -298,13 +309,15 @@ namespace Assets Utilities::Package::FPackageFileSystem::GetInstance().LoadFile(filename, data); std::filesystem::path path(filename); std::string mime = std::string("image/") + path.extension().string().substr(1); - return GetInstance()->RequestNewTextureMemAsync(filename, mime, false, data.data(), data.size(),srgb); + return GetInstance()->RequestNewTextureMemAsync( + filename, mime, false, data.data(), data.size(), srgb, ETextureLifetime::ETL_Transient); } uint32_t GlobalTexturePool::LoadTexture(const std::string& texname, const std::string& mime, const unsigned char* data, size_t bytelength, bool srgb) { - return GetInstance()->RequestNewTextureMemAsync(texname, mime, false, data, bytelength, srgb); + return GetInstance()->RequestNewTextureMemAsync( + texname, mime, false, data, bytelength, srgb, ETextureLifetime::ETL_Transient); } uint32_t GlobalTexturePool::LoadHDRTexture(const std::string& filename) @@ -318,7 +331,8 @@ namespace Assets if (!hasMountedEntry && !hasOsFile) { SPDLOG_WARN("HDR texture '{}' is unavailable; using a placeholder environment.", filename); - return GetInstance()->RequestNewTextureMemAsync(filename, "image/hdr", true, nullptr, 0, false); + return GetInstance()->RequestNewTextureMemAsync( + filename, "image/hdr", true, nullptr, 0, false, ETextureLifetime::ETL_Persistent); } std::vector data; @@ -326,10 +340,12 @@ namespace Assets if (!loaded || data.empty()) { SPDLOG_WARN("HDR texture '{}' is unavailable; using a placeholder environment.", filename); - return GetInstance()->RequestNewTextureMemAsync(filename, "image/hdr", true, nullptr, 0, false); + return GetInstance()->RequestNewTextureMemAsync( + filename, "image/hdr", true, nullptr, 0, false, ETextureLifetime::ETL_Persistent); } - return GetInstance()->RequestNewTextureMemAsync(filename, "image/hdr", true, data.data(), data.size(),false); + return GetInstance()->RequestNewTextureMemAsync( + filename, "image/hdr", true, data.data(), data.size(), false, ETextureLifetime::ETL_Persistent); } TextureImage* GlobalTexturePool::GetTextureImage(uint32_t idx) @@ -365,11 +381,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); @@ -398,9 +414,14 @@ namespace Assets void GlobalTexturePool::BindTexture(uint32_t textureIdx, const TextureImage& textureImage) { auto& descriptorSets = descriptorSetManager_->DescriptorSets(); + const VkDescriptorImageInfo imageInfo{ + textureImage.Sampler().Handle(), + textureImage.ImageView().Handle(), + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + }; std::vector descriptorWrites = { - descriptorSets.Bind(0, 0, { textureImage.Sampler().Handle(), textureImage.ImageView().Handle(), VK_IMAGE_LAYOUT_GENERAL}, textureIdx, 1), + descriptorSets.Bind(0, 0, imageInfo, textureIdx, 1), }; descriptorSets.UpdateDescriptors(0, descriptorWrites); } @@ -408,13 +429,50 @@ namespace Assets void GlobalTexturePool::BindStorageTexture(uint32_t textureIdx, const Vulkan::ImageView& textureImage) { auto& descriptorSets = descriptorSetManager_->DescriptorSets(); + const VkDescriptorImageInfo imageInfo{ + VK_NULL_HANDLE, + textureImage.Handle(), + VK_IMAGE_LAYOUT_GENERAL, + }; std::vector descriptorWrites = { - descriptorSets.Bind(0, 1, {NULL, textureImage.Handle(), VK_IMAGE_LAYOUT_GENERAL}, textureIdx, 1), + descriptorSets.Bind(0, 1, imageInfo, textureIdx, 1), }; descriptorSets.UpdateDescriptors(0, descriptorWrites); } + uint32_t GlobalTexturePool::RegisterTexture(const std::string& textureName, std::unique_ptr textureImage, + ETextureLifetime lifetime) + { + if (!textureImage) + { + return static_cast(-1); + } + + uint32_t textureIdx = 0; + auto textureIt = textureNameMap_.find(textureName); + if (textureIt != textureNameMap_.end()) + { + textureIdx = textureIt->second.GlobalIdx_; + textureIt->second.Status_ = ETextureStatus::ETS_Loaded; + textureIt->second.Lifetime_ = lifetime; + if (textureImages_.size() <= textureIdx) + { + textureImages_.resize(static_cast(textureIdx) + 1); + } + textureImages_[textureIdx] = std::move(textureImage); + } + else + { + textureIdx = static_cast(textureImages_.size()); + textureNameMap_[textureName] = {textureIdx, ETextureStatus::ETS_Loaded, lifetime}; + textureImages_.push_back(std::move(textureImage)); + } + + BindTexture(textureIdx, *textureImages_[textureIdx]); + return textureIdx; + } + uint32_t GlobalTexturePool::TryGetTexureIndex(const std::string& textureName) const { if (textureNameMap_.find(textureName) != textureNameMap_.end()) @@ -425,7 +483,8 @@ namespace Assets } uint32_t GlobalTexturePool::RequestNewTextureMemAsync(const std::string& texname, const std::string& mime, bool hdr, - const unsigned char* data, size_t bytelength, bool srgb) + const unsigned char* data, size_t bytelength, bool srgb, + ETextureLifetime lifetime) { uint32_t newTextureIdx = 0; if (textureNameMap_.find(texname) != textureNameMap_.end()) @@ -434,10 +493,12 @@ namespace Assets if(textureNameMap_[texname].Status_ == ETextureStatus::ETS_Unloaded) { textureNameMap_[texname].Status_ = ETextureStatus::ETS_Loaded; + textureNameMap_[texname].Lifetime_ = lifetime; newTextureIdx = textureNameMap_[texname].GlobalIdx_; } else { + textureNameMap_[texname].Lifetime_ = lifetime; // 这里要判断一下,如果已经加载了,直接返回 return textureNameMap_[texname].GlobalIdx_; } @@ -446,7 +507,7 @@ namespace Assets { textureImages_.emplace_back(nullptr); newTextureIdx = static_cast(textureImages_.size()) - 1; - textureNameMap_[texname] = { newTextureIdx, ETextureStatus::ETS_Loaded }; + textureNameMap_[texname] = { newTextureIdx, ETextureStatus::ETS_Loaded, lifetime }; } // load parse bind texture into newTextureIdx with transfer queue @@ -922,27 +983,31 @@ namespace Assets return newTextureIdx; } - void GlobalTexturePool::FreeNonSystemTextures() + void GlobalTexturePool::FreeTransientTextures() { // make sure the binded image not in use device_.WaitIdle(); - for( int i = 0; i < textureImages_.size(); ++i) + for (auto& textureGroup : textureNameMap_) { - if( i > 10 ) + if (textureGroup.second.Lifetime_ == ETextureLifetime::ETL_Persistent) { - // free up TextureImage;, rebind with a default texture sampler - textureImages_[i].reset(); - BindTexture(i, *defaultWhiteTexture_); + continue; } - } - for( auto& textureGroup : textureNameMap_ ) - { - if( textureGroup.second.GlobalIdx_ > 10 ) + const uint32_t textureIdx = textureGroup.second.GlobalIdx_; + if (textureIdx >= textureImages_.size()) { - textureGroup.second.Status_ = ETextureStatus::ETS_Unloaded; + continue; } + + if (textureImages_[textureIdx]) + { + textureImages_[textureIdx].reset(); + BindTexture(textureIdx, *defaultWhiteTexture_); + } + + textureGroup.second.Status_ = ETextureStatus::ETS_Unloaded; } } diff --git a/src/Assets/GPU/Texture.hpp b/src/Assets/GPU/Texture.hpp index 351a7df4..e807a79d 100644 --- a/src/Assets/GPU/Texture.hpp +++ b/src/Assets/GPU/Texture.hpp @@ -25,10 +25,17 @@ namespace Assets ETS_Unloaded, }; + enum class ETextureLifetime : uint8 + { + ETL_Transient, + ETL_Persistent, + }; + struct FTextureBindingGroup { uint32_t GlobalIdx_; ETextureStatus Status_; + ETextureLifetime Lifetime_ = ETextureLifetime::ETL_Transient; }; class GlobalTexturePool final @@ -42,18 +49,24 @@ namespace Assets void BindTexture(uint32_t textureIdx, const TextureImage& textureImage); void BindStorageTexture(uint32_t textureIdx, const Vulkan::ImageView& textureImage); + uint32_t RegisterTexture(const std::string& textureName, std::unique_ptr textureImage, + ETextureLifetime lifetime = ETextureLifetime::ETL_Transient); uint32_t TryGetTexureIndex(const std::string& textureName) const; - uint32_t RequestNewTextureFileAsync(const std::string& filename, bool hdr); - uint32_t RequestNewTextureMemAsync(const std::string& texname, const std::string& mime, bool hdr, const unsigned char* data, size_t bytelength, bool srgb); + uint32_t RequestNewTextureFileAsync(const std::string& filename, bool hdr, + ETextureLifetime lifetime = ETextureLifetime::ETL_Transient); + uint32_t RequestNewTextureMemAsync(const std::string& texname, const std::string& mime, bool hdr, + const unsigned char* data, size_t bytelength, bool srgb, + ETextureLifetime lifetime = ETextureLifetime::ETL_Transient); uint32_t TotalTextures() const {return static_cast(textureImages_.size());} const std::unordered_map& TotalTextureMap() {return textureNameMap_;} - void FreeNonSystemTextures(); + void FreeTransientTextures(); void CreateDefaultTextures(); static GlobalTexturePool* GetInstance() {return instance_;} - static uint32_t LoadTexture(const std::string& texname, const std::string& mime, const unsigned char* data, size_t bytelength, bool srgb); + static uint32_t LoadTexture(const std::string& texname, const std::string& mime, const unsigned char* data, + size_t bytelength, bool srgb); static uint32_t LoadTexture(const std::string& filename, bool srgb); static uint32_t LoadHDRTexture(const std::string& filename); 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/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/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/CMakeLists.txt b/src/CMakeLists.txt index 461a1a4e..9302c72e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -65,7 +65,6 @@ add_library(gkNextRenderer SHARED ${src_files_vulkan} ${src_files_rendering} ${src_files_thirdparty} - ${src_files_customimgui} ${src_files_gkrenderer} AndroidMain.cpp #GameActivitySources.cpp @@ -253,6 +252,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 +310,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 +326,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/Editor/Core/EditorLayoutConstants.hpp b/src/Editor/Core/EditorLayoutConstants.hpp index 320406a4..cdf4c297 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 = 44.0f; + constexpr float kFooterHeight = 30.0f; } // namespace Editor diff --git a/src/Editor/EditorInterface.cpp b/src/Editor/EditorInterface.cpp index 84d8be02..bf67b77f 100644 --- a/src/Editor/EditorInterface.cpp +++ b/src/Editor/EditorInterface.cpp @@ -4,13 +4,12 @@ #include #include -#include -#include #include #include #include #include +#include #include "Editor/EditorUi.hpp" #include "Assets/Core/Scene.hpp" @@ -24,12 +23,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 +117,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 +141,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 +164,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 +181,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 +198,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 +367,7 @@ void EditorInterface::Render() // Global keyboard shortcuts are handled by NextEngine. ImGuiID id = DockSpaceUI(); - ToolbarUI(); + ToolbarUI(ctx); Editor::DrawTitleBarOverlay(ctx, uiState_); @@ -288,16 +375,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/EditorMain.cpp b/src/Editor/EditorMain.cpp index 86c04a03..86a20440 100644 --- a/src/Editor/EditorMain.cpp +++ b/src/Editor/EditorMain.cpp @@ -32,7 +32,7 @@ EditorGameInstance::EditorGameInstance(Vulkan::WindowConfig& config, Options& op glm::ivec2 monitorSize = GetEngine().GetMonitorSize(); // windows config - config.Title = "NextEditor"; + config.Title = "gkNextEditor"; config.Width = static_cast(monitorSize.x * 0.75f); config.Height = static_cast(monitorSize.y * 0.75f); config.ForceSDR = true; diff --git a/src/Editor/Nodes/NodeSetInt.cpp b/src/Editor/Nodes/NodeSetInt.cpp index 3da4ae57..8ede7f9f 100644 --- a/src/Editor/Nodes/NodeSetInt.cpp +++ b/src/Editor/Nodes/NodeSetInt.cpp @@ -1,7 +1,5 @@ #include "NodeSetInt.hpp" -#include - #include "Editor/EditorContext.hpp" #include "Runtime/Editor/UserInterface.hpp" @@ -66,10 +64,10 @@ namespace Nodes return; } - VkDescriptorSet tex = ctx->ui.RequestImTextureId(static_cast(textureId)); - if (tex != VK_NULL_HANDLE) + ImTextureID tex = ctx->ui.RequestImTextureId(static_cast(textureId)); + if (tex != 0) { - ImGui::Image((ImTextureID)(intptr_t)tex, ImVec2(128, 128)); + ImGui::Image(tex, ImVec2(128, 128)); } } } // namespace Nodes diff --git a/src/Editor/Nodes/NodeSetInt.hpp b/src/Editor/Nodes/NodeSetInt.hpp index d4b29a6a..58721b2c 100644 --- a/src/Editor/Nodes/NodeSetInt.hpp +++ b/src/Editor/Nodes/NodeSetInt.hpp @@ -3,7 +3,6 @@ #define NODES_SET_INT_H #include "../../ImNodeFlow/include/ImNodeFlow.h" -#include "Vulkan/DescriptorSystem.hpp" namespace Nodes { @@ -27,8 +26,7 @@ namespace Nodes private: int textureId = 0; - VkDescriptorSet imTextureId; }; } // namespace Nodes -#endif // NODES_SET_INT_H \ No newline at end of file +#endif // NODES_SET_INT_H diff --git a/src/Editor/Overlays/TitleBarOverlay.cpp b/src/Editor/Overlays/TitleBarOverlay.cpp index 3d9e4d0d..a4cdc4d2 100644 --- a/src/Editor/Overlays/TitleBarOverlay.cpp +++ b/src/Editor/Overlays/TitleBarOverlay.cpp @@ -12,42 +12,31 @@ #include #include "Runtime/Engine.hpp" +#include "Runtime/Editor/ProfessionalUI.hpp" #include "Runtime/Editor/UserInterface.hpp" namespace Editor { namespace { - constexpr float kMenuHitPadding = 32.0f; + constexpr const char* kWindowTitle = "gkNextEditor"; } // namespace void DrawTitleBarOverlay(EditorContext& ctx, EditorUiState& ui) { ImGuiViewport* viewport = ImGui::GetMainViewport(); - float menuRight = viewport->Pos.x + kTitleBarHeight; - 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::SetNextWindowViewport(viewport->ID); - ImGui::SetNextWindowBgAlpha(0); - - 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()) + Runtime::UiTheme::FAppTitleBarConfig config{}; + config.BrandWindowId = "EditorBrand"; + config.MenuWindowId = "EditorMenuBar"; + config.RightWindowId = "EditorWindowControls"; + config.AppName = kWindowTitle; + config.Height = kTitleBarHeight; + config.TitleFont = ui.fontIcon; + config.IsMaximized = ctx.engine.IsMaximumed(); + config.DrawMenuBar = [&]() -> float { + float menuRight = ImGui::GetCursorScreenPos().x; bool fileMenuOpen = ImGui::BeginMenu("File"); menuRight = std::max(menuRight, ImGui::GetItemRectMax().x); if (fileMenuOpen) @@ -55,8 +44,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 +75,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 +97,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 +140,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 +155,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 +169,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,90 +252,63 @@ 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(); } - ImGui::EndMenuBar(); - } - ImGui::End(); - - const float dragLeftReserved = std::max(kTitleBarHeight, menuRight - viewport->Pos.x + kMenuHitPadding); - ctx.engine.ConfigureCustomTitleBarDrag(true, kTitleBarHeight, dragLeftReserved, 200.0f); - - // LOGO - ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x, viewport->Pos.y)); - ImGui::SetNextWindowSize(ImVec2(kTitleBarHeight, kTitleBarHeight)); - ImGui::SetNextWindowViewport(viewport->ID); - ImGui::SetNextWindowBgAlpha(0); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); - ImGui::Begin("Logo", 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::PushFont(ui.bigIcon); - } - 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))) + return menuRight; + }; + config.OnMinimize = [&]() { ctx.actions.Dispatch(ctx, EEditorAction::System_RequestMinimize); - } - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_WINDOW_MAXIMIZE, ImVec2(40, 40))) + }; + config.OnToggleMaximize = [&]() { ctx.actions.Dispatch(ctx, EEditorAction::System_ToggleMaximize); - } - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_XMARK, ImVec2(40, 40))) + }; + config.OnClose = [&]() { ctx.actions.Dispatch(ctx, EEditorAction::System_RequestExit); - } - ImGui::SameLine(); - ImGui::PopStyleColor(); - ImGui::End(); - - ImGui::PopStyleVar(); - ImGui::PopStyleVar(); - ImGui::PopStyleVar(); + }; + Runtime::UiTheme::DrawAppTitleBar(ctx.engine, config); - // 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 +316,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 +326,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..6095ad0e 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,18 @@ 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)) + ImTextureID textureId = ctx.ui.RequestImTextureId(globalId); + if (iconOrTex || textureId == 0) { - ImGui::Button(icon, ImVec2(kIconSize, kIconSize)); + ImGui::Button(icon, ImVec2(GContentBrowserIconSize, GContentBrowserIconSize)); } else { - ImGui::Image((ImTextureID)(intptr_t)textureId, ImVec2(kIconSize, kIconSize)); + ImGui::Image(textureId, ImVec2(GContentBrowserIconSize, GContentBrowserIconSize)); } if (callbacks.onDragSource && ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) @@ -300,7 +302,7 @@ namespace Editor } ImGui::PopID(); - ImGui::PopStyleColor(); + ImGui::PopStyleColor(2); if (ui.bigIcon) { ImGui::PopFont(); @@ -324,12 +326,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 +354,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 +454,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 +464,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 +474,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 +522,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 +536,10 @@ namespace Editor { continue; } + if (contentFilter.IsActive() && !contentFilter.PassFilter(name.c_str())) + { + continue; + } const uint32_t stableId = Fnv1a32(abspath); DrawGeneralContentBrowser( @@ -508,8 +591,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/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/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..b818c8e8 100644 --- a/src/Rendering/PipelineCommon/CommonComputePipeline.cpp +++ b/src/Rendering/PipelineCommon/CommonComputePipeline.cpp @@ -16,11 +16,14 @@ namespace Vulkan::PipelineCommon { - ZeroBindWithTLASPipeline::ZeroBindWithTLASPipeline(const SwapChain& swapChain, const char* shaderfile):PipelineBase(swapChain) + 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; @@ -78,18 +81,21 @@ namespace Vulkan::PipelineCommon 0, sizeof(Assets::GPUScene), &(scene.FetchGPUScene(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); std::vector managers = { - &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager() + &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), }; pipelineLayout_.reset(new class PipelineLayout(device, managers, 1, &pushConstantRange, 1)); @@ -245,13 +251,17 @@ 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); + std::vector managers = { + &Assets::GlobalTexturePool::GetInstance()->GetDescriptorManager(), + }; + + 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, &pushConstantRange, 1)); + 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. @@ -300,33 +310,37 @@ 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; 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; @@ -339,9 +353,12 @@ 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.cullMode = VK_CULL_MODE_NONE; rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; rasterizer.depthBiasEnable = VK_FALSE; rasterizer.depthBiasConstantFactor = 0.0f; // Optional @@ -390,47 +407,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/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..000f2c1e 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; @@ -265,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{}; @@ -283,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); @@ -294,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; @@ -315,9 +320,8 @@ 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); - + vkCmdPushConstants(commandBuffer, directLightGenPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, dispatchGroupCount, 1, 1); } } @@ -369,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/SoftwareModern/SoftwareModernRenderer.cpp b/src/Rendering/SoftwareModern/SoftwareModernRenderer.cpp index 86d3ed61..a636de77 100644 --- a/src/Rendering/SoftwareModern/SoftwareModernRenderer.cpp +++ b/src/Rendering/SoftwareModern/SoftwareModernRenderer.cpp @@ -5,9 +5,15 @@ #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, + bool simpleComposeOnly) : + LogicRendererBase(baseRender), + shaderPath_(std::move(shaderPath)), + simpleComposeOnly_(simpleComposeOnly) { } @@ -19,9 +25,16 @@ SoftwareModernRenderer::~SoftwareModernRenderer() void SoftwareModernRenderer::CreateSwapChain(const VkExtent2D& extent) { - deferredShadingPipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Core.SwModern.comp.slang.spv")); + (void)extent; + deferredShadingPipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), shaderPath_.c_str(), GetScene())); + if (simpleComposeOnly_) + { + composePipeline_.reset(new PipelineCommon::ZeroBindPipeline(SwapChain(), "assets/shaders/Process.ComposeSimple.comp.slang.spv", GetScene())); + return; + } + 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,11 +69,37 @@ 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_ALBEDO); + transitionShadingOutput(Assets::Bindless::RT_OBJEDCTID_0); + transitionShadingOutput(Assets::Bindless::RT_PREV_DEPTHBUFFER); + if (!simpleComposeOnly_) + { + transitionShadingOutput(Assets::Bindless::RT_SINGLE_SPECULAR); + transitionShadingOutput(Assets::Bindless::RT_NORMAL); + transitionShadingOutput(Assets::Bindless::RT_MOTIONVECTOR); + 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); + } } - { + if (simpleComposeOnly_) + { + SCOPED_GPU_TIMER("compose pass"); + composePipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); + vkCmdDispatch(commandBuffer, SwapChain().RenderExtent().width / 8, SwapChain().RenderExtent().height / 8, 1); + return; + } + + { SCOPED_GPU_TIMER("reproject pass"); std::array pushConst { NextEngine::GetInstance()->IsProgressiveRendering(), uint32_t(NextEngine::GetInstance()->GetUserSettings().TemporalFrames), prevSingleDiffuseId_, prevSingleSpecularId_, prevSingleAlbedoId_, 1 }; @@ -116,7 +155,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..00db5f66 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,9 @@ 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", + bool simpleComposeOnly = false); ~SoftwareModernRenderer(); void CreateSwapChain(const VkExtent2D& extent) override; @@ -36,6 +39,8 @@ namespace Vulkan::LegacyDeferred std::unique_ptr deferredShadingPipeline_; std::unique_ptr composePipeline_; std::unique_ptr accumulatePipeline_; + std::string shaderPath_; + bool simpleComposeOnly_{false}; 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..95c9eb93 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,33 @@ 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)) + + 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_) + { + 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 +464,8 @@ namespace Vulkan PrintVulkanSwapChainInformation(*this); currentFrame_ = 0; - supportDLSS_ = true; - supportDLSSRR_ = true; + supportDLSS_ = streamlineDeviceExtensionsEnabled_; + supportDLSSRR_ = streamlineDeviceExtensionsEnabled_; } void VulkanBaseRenderer::End() @@ -352,19 +503,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 +554,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 +597,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)); @@ -568,22 +754,29 @@ 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())); + 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)); 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 +819,8 @@ namespace Vulkan screenShotImageMemory_.reset(); screenShotImage_.reset(); commandBuffers_.reset(); + wireframeFrameBuffers_.clear(); wireframePipeline_.reset(); - wireframeFramebuffer_.reset(); bufferClearPipeline_.reset(); softAmbientCubeGenPipeline_.reset(); clearAmbientCubeCachePipeline_.reset(); @@ -641,8 +834,6 @@ namespace Vulkan skinnedVertexBuffer_.reset(); skinnedVertexBufferMemory_.reset(); - skinnedSimpleVertexBuffer_.reset(); - skinnedSimpleVertexBufferMemory_.reset(); jointMatricesBuffer_.reset(); jointMatricesBufferMemory_.reset(); @@ -717,13 +908,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()) { @@ -763,22 +947,25 @@ namespace Vulkan UpdateSkinningBuffers(); InitializeBarriers(commandBuffer); - const bool useAmbientCubePropagation = NextEngine::GetInstance()->GetUserSettings().UseAmbientCubePropagation; - if (!ambientCubePropagationStateInitialized_) + if (CurrentRendererUsesAmbientCube()) { - lastAmbientCubePropagation_ = useAmbientCubePropagation; - ambientCubePropagationStateInitialized_ = true; - } - else if (lastAmbientCubePropagation_ != useAmbientCubePropagation) - { - 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) @@ -787,9 +974,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); @@ -832,10 +1019,8 @@ 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); - + 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); } @@ -845,15 +1030,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 +1151,13 @@ namespace Vulkan const VkBuffer indexBuffer = scene.IndexBuffer().Handle(); vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, visibilityPipeline_->Handle()); + const Assets::GPUScene& gpuScene = scene.FetchGPUScene(imageIndex); + visibilityPipeline_->PipelineLayout().BindDescriptorSets( + 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); - 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", true); + break; case ERendererType::ERT_VoxelTracing: logicRenderers_[type] = std::make_unique(*this); break; @@ -1373,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) @@ -1393,6 +1640,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,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"); @@ -1525,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); + } } } } @@ -1546,10 +1763,8 @@ 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); - + vkCmdPushConstants(commandBuffer, clearAmbientCubeCachePipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, groupCount, 1, 1); VkBufferMemoryBarrier barriers[2]{}; @@ -1559,11 +1774,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, @@ -1586,7 +1802,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{}; @@ -1604,7 +1822,7 @@ namespace Vulkan VkBufferCopy copyRegion{}; copyRegion.srcOffset = cascadeByteOffset; - copyRegion.dstOffset = 0; + copyRegion.dstOffset = pongByteOffset; copyRegion.size = cascadeByteSize; vkCmdCopyBuffer(commandBuffer, cubeBuffer, pongBuffer, 1, ©Region); @@ -1615,7 +1833,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; @@ -1636,10 +1854,8 @@ 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); - + vkCmdPushConstants(commandBuffer, propagationAmbientCubeGenPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier propagationToInjectionBarrier{}; @@ -1657,10 +1873,8 @@ namespace Vulkan injectAmbientCubeGenPipeline_->BindPipeline(commandBuffer, GetScene(), imageIndex); - layout = injectAmbientCubeGenPipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); - + vkCmdPushConstants(commandBuffer, injectAmbientCubeGenPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier postInjectionBarrier{}; @@ -1680,7 +1894,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"); @@ -1696,11 +1911,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; @@ -1719,12 +1937,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); - VkPipelineLayout layout = distanceFieldInitPipeline_->PipelineLayout().Handle(); - vkCmdPushConstants(commandBuffer, layout, VK_SHADER_STAGE_COMPUTE_BIT, - 0, sizeof(Assets::GPUScene), &gpuScene); + vkCmdPushConstants(commandBuffer, distanceFieldInitPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier initBarrier{}; @@ -1734,7 +1950,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, @@ -1747,9 +1963,8 @@ 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); + vkCmdPushConstants(commandBuffer, distanceFieldJumpPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier jumpBarriers[2]{}; @@ -1759,10 +1974,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); @@ -1776,9 +1992,8 @@ 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); + vkCmdPushConstants(commandBuffer, distanceFieldResolvePipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &gpuScene); vkCmdDispatch(commandBuffer, group, 1, 1); VkBufferMemoryBarrier postResolveBarrier{}; @@ -1797,7 +2012,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; @@ -1838,7 +2053,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{}; @@ -1856,7 +2073,7 @@ namespace Vulkan VkBufferCopy copyRegion{}; copyRegion.srcOffset = cascadeByteOffset; - copyRegion.dstOffset = 0; + copyRegion.dstOffset = pongByteOffset; copyRegion.size = cascadeByteSize; vkCmdCopyBuffer(commandBuffer, cubeBuffer, pongBuffer, 1, ©Region); @@ -1867,7 +2084,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; @@ -1888,16 +2105,15 @@ 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); - + vkCmdPushConstants(commandBuffer, softAmbientCubeGenPipeline_->PipelineLayout().Handle(), + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Assets::GPUScene), &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..4d0ea020 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_{}; @@ -181,19 +193,16 @@ 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: void RecreateSwapChain(); void UpdateUniformBuffer(uint32_t imageIndex); + void DrawWireframeOverlay(VkCommandBuffer commandBuffer, uint32_t imageIndex); const VkPresentModeKHR presentMode_; bool requestRecreateSwapChain_ = false; @@ -232,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_; 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..40e2757d --- /dev/null +++ b/src/Runtime/Editor/ProfessionalUI.cpp @@ -0,0 +1,750 @@ +#include "Common/CoreMinimal.hpp" + +#include "Runtime/Editor/ProfessionalUI.hpp" +#include "Runtime/Engine.hpp" + +#include +#include +#include +#include + +namespace Runtime::UiTheme +{ + namespace + { + constexpr float kTitleBarControlButtonWidth = 46.0f; + constexpr float kTitleBarControlButtonCount = 3.0f; + constexpr float kTitleBarControlsWidth = kTitleBarControlButtonWidth * kTitleBarControlButtonCount; + + ImVec4 WithAlpha(ImVec4 color, float alpha) + { + color.w *= alpha; + return color; + } + + float CalcFontTextWidth(ImFont* font, const char* text) + { + if (text == nullptr || text[0] == '\0') + { + return 0.0f; + } + + ImFont* activeFont = font != nullptr ? font : ImGui::GetFont(); + return activeFont->CalcTextSizeA(activeFont->FontSize, FLT_MAX, 0.0f, text).x; + } + + bool DrawWindowControlButton(const char* label, const char* tooltip, ImVec2 size, ImVec4 hoverColor, + ImVec4 activeColor) + { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, activeColor); + const bool pressed = ImGui::Button(label, size); + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(2); + DrawTooltip(tooltip); + return pressed; + } + } // 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 DrawAppTitleBar(NextEngine& engine, const FAppTitleBarConfig& config) + { + ImGuiViewport* viewport = ImGui::GetMainViewport(); + if (viewport == nullptr) + { + return; + } + + ImDrawList* background = ImGui::GetBackgroundDrawList(); + const ImVec2 titleMin = viewport->Pos; + const ImVec2 titleMax = viewport->Pos + ImVec2(viewport->Size.x, config.Height); + background->AddRectFilled(titleMin, titleMax, ColorU32(EColor::Background), 0.0f); + + ImFont* titleFont = config.TitleFont != nullptr ? config.TitleFont : ImGui::GetFont(); + const float brandTextWidth = CalcFontTextWidth(titleFont, config.AppName); + const float brandWidth = + config.BrandHorizontalPadding * 2.0f + config.BrandIconSize + config.BrandTextSpacing + brandTextWidth; + const float rightWidth = config.RightContentWidth + kTitleBarControlsWidth; + const float menuWidth = + std::max(0.0f, viewport->Size.x - brandWidth - rightWidth - config.MenuTrailingPadding); + float menuRight = viewport->Pos.x + brandWidth; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + + ImGui::SetNextWindowPos(viewport->Pos); + ImGui::SetNextWindowSize(ImVec2(brandWidth, config.Height)); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::SetNextWindowBgAlpha(0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::Begin(config.BrandWindowId, nullptr, + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoDocking); + ImGui::SetCursorPos( + ImVec2(config.BrandHorizontalPadding, std::floor((config.Height - config.BrandIconSize) * 0.5f))); + DrawBrandMark(ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos(), config.BrandIconSize); + ImGui::Dummy(ImVec2(config.BrandIconSize, config.BrandIconSize)); + ImGui::SameLine(0.0f, config.BrandTextSpacing); + if (titleFont != nullptr) + { + ImGui::PushFont(titleFont); + } + ImGui::SetCursorPosY(std::floor((config.Height - ImGui::GetTextLineHeight()) * 0.5f) - 1.0f); + ImGui::TextUnformatted(config.AppName); + if (titleFont != nullptr) + { + ImGui::PopFont(); + } + ImGui::End(); + ImGui::PopStyleVar(); + + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + brandWidth, viewport->Pos.y)); + ImGui::SetNextWindowSize(ImVec2(menuWidth, config.Height)); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::SetNextWindowBgAlpha(0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::Begin(config.MenuWindowId, nullptr, + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoDocking); + ImGui::PopStyleVar(); + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f, 11.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(16.0f, 0.0f)); + if (ImGui::BeginMenuBar()) + { + if (config.DrawMenuBar) + { + const float menuBarRightEdge = config.DrawMenuBar(); + menuRight = std::max(menuRight, menuBarRightEdge); + } + ImGui::EndMenuBar(); + } + ImGui::PopStyleVar(2); + ImGui::End(); + + ImGui::SetNextWindowPos(viewport->Pos + ImVec2(viewport->Size.x - rightWidth, 0.0f)); + ImGui::SetNextWindowSize(ImVec2(rightWidth, config.Height)); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::SetNextWindowBgAlpha(0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::Begin(config.RightWindowId, nullptr, + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoDocking); + if (config.DrawRightContent) + { + config.DrawRightContent(); + } + ImGui::SetCursorPos(ImVec2(config.RightContentWidth, 0.0f)); + + if (DrawWindowControlButton(ICON_FA_WINDOW_MINIMIZE, "Minimize", + ImVec2(kTitleBarControlButtonWidth, config.Height), Color(EColor::SurfaceHover), + Color(EColor::SurfaceHover, 0.92f))) + { + if (config.OnMinimize) + { + config.OnMinimize(); + } + } + ImGui::SameLine(0.0f, 0.0f); + if (DrawWindowControlButton(config.IsMaximized ? ICON_FA_WINDOW_RESTORE : ICON_FA_WINDOW_MAXIMIZE, "Maximize", + ImVec2(kTitleBarControlButtonWidth, config.Height), Color(EColor::SurfaceHover), + Color(EColor::SurfaceHover, 0.92f))) + { + if (config.OnToggleMaximize) + { + config.OnToggleMaximize(); + } + } + ImGui::SameLine(0.0f, 0.0f); + if (DrawWindowControlButton(ICON_FA_XMARK, "Close", ImVec2(kTitleBarControlButtonWidth, config.Height), + Color(EColor::Danger, 0.90f), Color(EColor::Danger))) + { + if (config.OnClose) + { + config.OnClose(); + } + } + ImGui::End(); + ImGui::PopStyleVar(); + + ImGui::PopStyleVar(2); + + const float dragLeftReserved = + std::max(brandWidth + 12.0f, menuRight - viewport->Pos.x + config.MenuHitPadding); + engine.ConfigureCustomTitleBarDrag(true, config.Height, dragLeftReserved, rightWidth); + } + + 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); + } + + bool ModeRailButton(const char* icon, const char* tooltip, bool active, float buttonSize) + { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 6.0f); + if (active) + { + ImGui::PushStyleColor(ImGuiCol_Button, Color(EColor::Accent, 0.18f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, Color(EColor::Accent, 0.30f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, Color(EColor::Accent, 0.45f)); + ImGui::PushStyleColor(ImGuiCol_Text, Color(EColor::AccentHover)); + } + else + { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, Color(EColor::SurfaceHover, 0.65f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, Color(EColor::SurfaceHover, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_Text, Color(EColor::TextMuted)); + } + const bool pressed = ImGui::Button(icon ? icon : "?", ImVec2(buttonSize, buttonSize)); + ImGui::PopStyleColor(4); + ImGui::PopStyleVar(2); + + if (active) + { + // 4px accent strip on the left edge, like the mockup. + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 itemMin = ImGui::GetItemRectMin(); + const ImVec2 itemMax = ImGui::GetItemRectMax(); + const float stripWidth = 3.0f; + const float stripPad = 6.0f; + drawList->AddRectFilled( + ImVec2(itemMin.x - 4.0f, itemMin.y + stripPad), + ImVec2(itemMin.x - 4.0f + stripWidth, itemMax.y - stripPad), + ColorU32(EColor::AccentHover), stripWidth * 0.5f); + } + + DrawTooltip(tooltip); + return pressed; + } + + bool BeginFloatingPanel(const char* id, const char* icon, const char* title, bool* pOpen, + ImVec2 position, ImVec2 size, ImVec2 pivot) + { + ImGui::SetNextWindowPos(position, ImGuiCond_Always, pivot); + ImGui::SetNextWindowSize(size, ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.94f); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Border, Color(EColor::Border, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, Color(EColor::Surface, 0.96f)); + + constexpr ImGuiWindowFlags flags = + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNavInputs; + + const bool visible = ImGui::Begin(id, nullptr, flags); + if (!visible) + { + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(3); + return false; + } + + // Header strip + constexpr float headerHeight = 38.0f; + const ImVec2 winPos = ImGui::GetWindowPos(); + const ImVec2 winSize = ImGui::GetWindowSize(); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddRectFilled( + winPos, ImVec2(winPos.x + winSize.x, winPos.y + headerHeight), + ColorU32(EColor::SurfaceElevated, 0.88f), 8.0f, ImDrawFlags_RoundCornersTop); + drawList->AddLine( + ImVec2(winPos.x, winPos.y + headerHeight), + ImVec2(winPos.x + winSize.x, winPos.y + headerHeight), + ColorU32(EColor::Border, 0.85f)); + + // Title text + const float textY = winPos.y + (headerHeight - ImGui::GetTextLineHeight()) * 0.5f; + const float textX = winPos.x + 14.0f; + const ImU32 iconCol = ColorU32(EColor::AccentHover); + const ImU32 titleCol = ColorU32(EColor::Text); + if (icon != nullptr && icon[0] != '\0') + { + drawList->AddText(ImVec2(textX, textY), iconCol, icon); + const float iconWidth = ImGui::CalcTextSize(icon).x; + drawList->AddText(ImVec2(textX + iconWidth + 8.0f, textY), titleCol, title ? title : ""); + } + else + { + drawList->AddText(ImVec2(textX, textY), titleCol, title ? title : ""); + } + + // Optional close X + if (pOpen != nullptr) + { + const float closeSize = 20.0f; + ImGui::SetCursorScreenPos(ImVec2(winPos.x + winSize.x - closeSize - 10.0f, + winPos.y + (headerHeight - closeSize) * 0.5f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, Color(EColor::SurfaceHover, 0.7f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, Color(EColor::SurfaceHover)); + ImGui::PushStyleColor(ImGuiCol_Text, Color(EColor::TextMuted)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0)); + if (ImGui::Button(ICON_FA_XMARK, ImVec2(closeSize, closeSize))) + { + *pOpen = false; + } + ImGui::PopStyleVar(); + ImGui::PopStyleColor(4); + } + + // Move cursor below header & start a child for the body so padding works as expected. + ImGui::SetCursorScreenPos(ImVec2(winPos.x, winPos.y + headerHeight)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(14.0f, 10.0f)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); + ImGui::BeginChild("##FloatingPanelBody", ImVec2(0, 0), false, ImGuiWindowFlags_NoBackground); + return true; + } + + void EndFloatingPanel() + { + ImGui::EndChild(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(3); + } + + bool BeginPanelSection(const char* label, bool defaultOpen) + { + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, Color(EColor::SurfaceHover, 0.45f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, Color(EColor::SurfaceHover, 0.65f)); + ImGui::PushStyleColor(ImGuiCol_Text, Color(EColor::Text, 0.92f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2.0f, 4.0f)); + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_AllowOverlap; + if (defaultOpen) + { + flags |= ImGuiTreeNodeFlags_DefaultOpen; + } + const bool open = ImGui::CollapsingHeader(label ? label : "", flags); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(4); + + if (open) + { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(6.0f, 5.0f)); + ImGui::Dummy(ImVec2(0.0f, 1.0f)); + } + return open; + } + + void EndPanelSection() + { + ImGui::PopStyleVar(); + ImGui::Dummy(ImVec2(0.0f, 4.0f)); + } + + void LabelOver(const char* label) + { + if (label == nullptr || label[0] == '\0') + { + return; + } + ImGui::PushStyleColor(ImGuiCol_Text, Color(EColor::TextMuted)); + ImGui::TextUnformatted(label); + ImGui::PopStyleColor(); + } + + void Sparkline(const float* values, int count, ImVec2 size, ImVec4 color, + float scaleMin, float scaleMax) + { + if (values == nullptr || count <= 1) + { + ImGui::Dummy(size); + return; + } + + if (size.x <= 0.0f) + { + size.x = ImGui::GetContentRegionAvail().x; + } + if (size.y <= 0.0f) + { + size.y = ImGui::GetTextLineHeight() * 1.6f; + } + + if (scaleMin == FLT_MAX || scaleMax == FLT_MAX) + { + float lo = values[0]; + float hi = values[0]; + for (int i = 1; i < count; ++i) + { + lo = std::min(lo, values[i]); + hi = std::max(hi, values[i]); + } + scaleMin = lo; + scaleMax = hi; + } + const float range = std::max(0.0001f, scaleMax - scaleMin); + + const ImVec2 origin = ImGui::GetCursorScreenPos(); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddRectFilled(origin, origin + size, ColorU32(EColor::Background, 0.55f), 4.0f); + + const float stepX = size.x / static_cast(count - 1); + ImU32 lineCol = ImGui::GetColorU32(color); + ImU32 fillCol = ImGui::GetColorU32(ImVec4(color.x, color.y, color.z, color.w * 0.18f)); + + // Build polyline + std::vector pts; + pts.reserve(count); + for (int i = 0; i < count; ++i) + { + const float t = (values[i] - scaleMin) / range; + const float x = origin.x + stepX * static_cast(i); + const float y = origin.y + size.y - 2.0f - t * (size.y - 4.0f); + pts.emplace_back(x, y); + } + + // Fill underneath + const ImVec2 baseRight(pts.back().x, origin.y + size.y); + const ImVec2 baseLeft(pts.front().x, origin.y + size.y); + for (int i = 0; i + 1 < count; ++i) + { + ImVec2 quad[4] = {pts[i], pts[i + 1], + ImVec2(pts[i + 1].x, origin.y + size.y), + ImVec2(pts[i].x, origin.y + size.y)}; + drawList->AddConvexPolyFilled(quad, 4, fillCol); + } + (void)baseRight; (void)baseLeft; + + drawList->AddPolyline(pts.data(), count, lineCol, ImDrawFlags_None, 1.5f); + 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..fa8db131 --- /dev/null +++ b/src/Runtime/Editor/ProfessionalUI.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include "Common/CoreMinimal.hpp" + +#include +#include + +class NextEngine; + +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); + + struct FAppTitleBarConfig + { + const char* BrandWindowId = "AppTitleBarBrand"; + const char* MenuWindowId = "AppTitleBarMenu"; + const char* RightWindowId = "AppTitleBarRight"; + const char* AppName = ""; + float Height = 44.0f; + float RightContentWidth = 0.0f; + float BrandHorizontalPadding = 14.0f; + float BrandIconSize = 22.0f; + float BrandTextSpacing = 10.0f; + float MenuHitPadding = 28.0f; + float MenuTrailingPadding = 8.0f; + ImFont* TitleFont = nullptr; + bool IsMaximized = false; + std::function DrawMenuBar; + std::function DrawRightContent; + std::function OnMinimize; + std::function OnToggleMaximize; + std::function OnClose; + }; + + void ApplyProfessionalTheme(); + void DrawBrandMark(ImDrawList* drawList, ImVec2 min, float size); + void DrawAppTitleBar(NextEngine& engine, const FAppTitleBarConfig& config); + 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 ModeRailButton(const char* icon, const char* tooltip, bool active, float buttonSize); + bool BeginSection(const char* icon, const char* label, bool defaultOpen = true); + void EndSection(); + + // Floating panel matching the new design language: rounded surface, single-line title with optional close X. + // pOpen may be null. Returns true when the panel body is visible (matches ImGui::Begin semantics). + bool BeginFloatingPanel(const char* id, const char* icon, const char* title, bool* pOpen, + ImVec2 position, ImVec2 size, ImVec2 pivot = ImVec2(0.0f, 0.0f)); + void EndFloatingPanel(); + + // Collapsible section inside a floating panel: chevron + title row, no border background. + bool BeginPanelSection(const char* label, bool defaultOpen = true); + void EndPanelSection(); + + // Renders "Label" small caption above the next control. Use right before a Combo / Slider / etc. + void LabelOver(const char* label); + + // Inline sparkline. width<=0 fills available width. + void Sparkline(const float* values, int count, ImVec2 size, ImVec4 color, + float scaleMin = FLT_MAX, float scaleMax = FLT_MAX); + + 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..93b466d6 100644 --- a/src/Runtime/Editor/UserInterface.cpp +++ b/src/Runtime/Editor/UserInterface.cpp @@ -6,9 +6,11 @@ #include "Runtime/Config/CVarSystem.hpp" #include "Runtime/Editor/ConsoleLogBuffer.hpp" #include "Runtime/Editor/FontLoader.h" +#include "Runtime/Editor/ProfessionalUI.hpp" +#include "ThirdParty/imgui-custom/imgui_impl_sdl3_custom.h" #include "Utilities/Exception.hpp" -#include "Vulkan/DescriptorSystem.hpp" #include "Vulkan/Device.hpp" +#include "Vulkan/MemoryAndShader.hpp" #include "Vulkan/Instance.hpp" #include "Vulkan/RenderingPipeline.hpp" #include "Vulkan/CommandExecution.hpp" @@ -19,17 +21,12 @@ #include #include #include -#if !ANDROID -#include -#include -#else -#include -#include -#endif - #include #include +#include +#include +#include #include #include #include @@ -52,6 +49,459 @@ extern std::unique_ptr GApplication; namespace { + constexpr const char* kUiVertexShaderPath = "assets/shaders/UI.ImGui.vert.slang.spv"; + constexpr const char* kUiFragmentShaderPath = "assets/shaders/UI.ImGui.frag.slang.spv"; + constexpr const char* kUiFontAtlasTextureName = "__imgui_font_atlas__"; + constexpr float kUiHdrReferenceWhiteNit = 203.0f; + + struct UiPushConstants + { + float scale[2]; + float translate[2]; + float rotation[4]; + uint32_t hdrOutput; + float hdrReferenceWhiteNit; + float padding[2]; + }; + + struct UiBatchedVertex + { + ImVec2 position; + ImVec2 uv; + ImU32 color = 0; + float clipRect[4]{}; + uint32_t textureIndex = 0; + }; + + struct UiDrawSegment + { + uint32_t vertexOffset = 0; + uint32_t vertexCount = 0; + }; + + struct UiDrawOp + { + enum class EType : uint8_t + { + Draw, + Callback, + }; + + EType type = EType::Draw; + UiDrawSegment segment{}; + const ImDrawList* drawList = nullptr; + const ImDrawCmd* drawCmd = nullptr; + }; + + struct UiRendererRenderState + { + VkCommandBuffer commandBuffer = VK_NULL_HANDLE; + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + }; + + struct UiPlatformFrame + { + VkCommandPool commandPool = VK_NULL_HANDLE; + VkCommandBuffer commandBuffer = VK_NULL_HANDLE; + VkFence fence = VK_NULL_HANDLE; + VkImage backbuffer = VK_NULL_HANDLE; + VkImageView backbufferView = VK_NULL_HANDLE; + VkFramebuffer framebuffer = VK_NULL_HANDLE; + }; + + struct UiPlatformFrameSemaphores + { + VkSemaphore imageAcquiredSemaphore = VK_NULL_HANDLE; + VkSemaphore renderCompleteSemaphore = VK_NULL_HANDLE; + }; + + struct UiPlatformWindow + { + int width = 0; + int height = 0; + VkSwapchainKHR swapchain = VK_NULL_HANDLE; + VkSurfaceKHR surface = VK_NULL_HANDLE; + VkSurfaceFormatKHR surfaceFormat{}; + VkPresentModeKHR presentMode = static_cast(~0); + VkRenderPass renderPass = VK_NULL_HANDLE; + bool useDynamicRendering = false; + bool clearEnable = true; + VkClearValue clearValue{}; + uint32_t frameIndex = 0; + uint32_t imageCount = 0; + uint32_t semaphoreCount = 0; + uint32_t semaphoreIndex = 0; + ImVector frames; + ImVector frameSemaphores; + }; + + struct UiPlatformViewportData + { + UiPlatformWindow window; + bool windowOwned = false; + bool swapChainNeedRebuild = false; + bool swapChainSuboptimal = false; + }; + + VkSurfaceFormatKHR SelectPlatformSurfaceFormat(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, + const VkFormat* requestFormats, int requestFormatsCount, + VkColorSpaceKHR requestColorSpace) + { + uint32_t availableCount = 0; + Vulkan::Check(vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface, &availableCount, nullptr), + "query ui platform surface format count"); + + ImVector availableFormats; + availableFormats.resize(static_cast(availableCount)); + Vulkan::Check(vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface, &availableCount, + availableFormats.Data), + "query ui platform surface formats"); + + if (availableCount == 1) + { + if (availableFormats[0].format == VK_FORMAT_UNDEFINED) + { + VkSurfaceFormatKHR format{}; + format.format = requestFormats[0]; + format.colorSpace = requestColorSpace; + return format; + } + return availableFormats[0]; + } + + for (int requestedIndex = 0; requestedIndex < requestFormatsCount; ++requestedIndex) + { + for (uint32_t availableIndex = 0; availableIndex < availableCount; ++availableIndex) + { + if (availableFormats[availableIndex].format == requestFormats[requestedIndex] && + availableFormats[availableIndex].colorSpace == requestColorSpace) + { + return availableFormats[availableIndex]; + } + } + } + + return availableFormats[0]; + } + + VkPresentModeKHR SelectPlatformPresentMode(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, + const VkPresentModeKHR* requestModes, int requestModesCount) + { + uint32_t availableCount = 0; + Vulkan::Check(vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &availableCount, nullptr), + "query ui platform present mode count"); + + ImVector availableModes; + availableModes.resize(static_cast(availableCount)); + Vulkan::Check(vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &availableCount, + availableModes.Data), + "query ui platform present modes"); + + for (int requestedIndex = 0; requestedIndex < requestModesCount; ++requestedIndex) + { + for (uint32_t availableIndex = 0; availableIndex < availableCount; ++availableIndex) + { + if (requestModes[requestedIndex] == availableModes[availableIndex]) + { + return requestModes[requestedIndex]; + } + } + } + + return VK_PRESENT_MODE_FIFO_KHR; + } + + void DestroyPlatformFrame(VkDevice device, UiPlatformFrame& frame) + { + if (frame.fence != VK_NULL_HANDLE) + { + vkDestroyFence(device, frame.fence, nullptr); + frame.fence = VK_NULL_HANDLE; + } + if (frame.commandBuffer != VK_NULL_HANDLE && frame.commandPool != VK_NULL_HANDLE) + { + vkFreeCommandBuffers(device, frame.commandPool, 1, &frame.commandBuffer); + frame.commandBuffer = VK_NULL_HANDLE; + } + if (frame.commandPool != VK_NULL_HANDLE) + { + vkDestroyCommandPool(device, frame.commandPool, nullptr); + frame.commandPool = VK_NULL_HANDLE; + } + if (frame.backbufferView != VK_NULL_HANDLE) + { + vkDestroyImageView(device, frame.backbufferView, nullptr); + frame.backbufferView = VK_NULL_HANDLE; + } + if (frame.framebuffer != VK_NULL_HANDLE) + { + vkDestroyFramebuffer(device, frame.framebuffer, nullptr); + frame.framebuffer = VK_NULL_HANDLE; + } + } + + void DestroyPlatformFrameSemaphores(VkDevice device, UiPlatformFrameSemaphores& semaphores) + { + if (semaphores.imageAcquiredSemaphore != VK_NULL_HANDLE) + { + vkDestroySemaphore(device, semaphores.imageAcquiredSemaphore, nullptr); + semaphores.imageAcquiredSemaphore = VK_NULL_HANDLE; + } + if (semaphores.renderCompleteSemaphore != VK_NULL_HANDLE) + { + vkDestroySemaphore(device, semaphores.renderCompleteSemaphore, nullptr); + semaphores.renderCompleteSemaphore = VK_NULL_HANDLE; + } + } + + void CreatePlatformWindowCommandBuffers(VkDevice device, UiPlatformWindow& window, uint32_t queueFamily) + { + for (uint32_t imageIndex = 0; imageIndex < window.imageCount; ++imageIndex) + { + UiPlatformFrame& frame = window.frames[imageIndex]; + + VkCommandPoolCreateInfo commandPoolInfo{}; + commandPoolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + commandPoolInfo.queueFamilyIndex = queueFamily; + Vulkan::Check(vkCreateCommandPool(device, &commandPoolInfo, nullptr, &frame.commandPool), + "create ui platform viewport command pool"); + + VkCommandBufferAllocateInfo commandBufferInfo{}; + commandBufferInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + commandBufferInfo.commandPool = frame.commandPool; + commandBufferInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + commandBufferInfo.commandBufferCount = 1; + Vulkan::Check(vkAllocateCommandBuffers(device, &commandBufferInfo, &frame.commandBuffer), + "allocate ui platform viewport command buffer"); + + VkFenceCreateInfo fenceInfo{}; + fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; + Vulkan::Check(vkCreateFence(device, &fenceInfo, nullptr, &frame.fence), + "create ui platform viewport fence"); + } + + for (uint32_t semaphoreIndex = 0; semaphoreIndex < window.semaphoreCount; ++semaphoreIndex) + { + UiPlatformFrameSemaphores& semaphores = window.frameSemaphores[semaphoreIndex]; + VkSemaphoreCreateInfo semaphoreInfo{}; + semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + Vulkan::Check(vkCreateSemaphore(device, &semaphoreInfo, nullptr, &semaphores.imageAcquiredSemaphore), + "create ui platform viewport acquire semaphore"); + Vulkan::Check(vkCreateSemaphore(device, &semaphoreInfo, nullptr, &semaphores.renderCompleteSemaphore), + "create ui platform viewport render semaphore"); + } + } + + void CreatePlatformWindowSwapChain(VkPhysicalDevice physicalDevice, VkDevice device, UiPlatformWindow& window, + int width, int height, uint32_t minImageCount) + { + VkSwapchainKHR oldSwapChain = window.swapchain; + window.swapchain = VK_NULL_HANDLE; + + Vulkan::Check(vkDeviceWaitIdle(device), "wait device idle for ui platform viewport resize"); + + for (int imageIndex = 0; imageIndex < window.frames.Size; ++imageIndex) + { + DestroyPlatformFrame(device, window.frames[imageIndex]); + } + for (int semaphoreIndex = 0; semaphoreIndex < window.frameSemaphores.Size; ++semaphoreIndex) + { + DestroyPlatformFrameSemaphores(device, window.frameSemaphores[semaphoreIndex]); + } + window.frames.clear(); + window.frameSemaphores.clear(); + window.imageCount = 0; + if (window.renderPass != VK_NULL_HANDLE) + { + vkDestroyRenderPass(device, window.renderPass, nullptr); + window.renderPass = VK_NULL_HANDLE; + } + + VkSurfaceCapabilitiesKHR surfaceCapabilities{}; + Vulkan::Check(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physicalDevice, window.surface, &surfaceCapabilities), + "query ui platform surface capabilities"); + + VkSwapchainCreateInfoKHR swapChainInfo{}; + swapChainInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; + swapChainInfo.surface = window.surface; + swapChainInfo.minImageCount = minImageCount; + swapChainInfo.imageFormat = window.surfaceFormat.format; + swapChainInfo.imageColorSpace = window.surfaceFormat.colorSpace; + swapChainInfo.imageArrayLayers = 1; + swapChainInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; + swapChainInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; + swapChainInfo.preTransform = + (surfaceCapabilities.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR) + ? VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR + : surfaceCapabilities.currentTransform; + swapChainInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; + swapChainInfo.presentMode = window.presentMode; + swapChainInfo.clipped = VK_TRUE; + swapChainInfo.oldSwapchain = oldSwapChain; + if (swapChainInfo.minImageCount < surfaceCapabilities.minImageCount) + { + swapChainInfo.minImageCount = surfaceCapabilities.minImageCount; + } + else if (surfaceCapabilities.maxImageCount != 0 && + swapChainInfo.minImageCount > surfaceCapabilities.maxImageCount) + { + swapChainInfo.minImageCount = surfaceCapabilities.maxImageCount; + } + + if (surfaceCapabilities.currentExtent.width == 0xffffffff) + { + swapChainInfo.imageExtent.width = window.width = width; + swapChainInfo.imageExtent.height = window.height = height; + } + else + { + swapChainInfo.imageExtent.width = window.width = static_cast(surfaceCapabilities.currentExtent.width); + swapChainInfo.imageExtent.height = window.height = static_cast(surfaceCapabilities.currentExtent.height); + } + + Vulkan::Check(vkCreateSwapchainKHR(device, &swapChainInfo, nullptr, &window.swapchain), + "create ui platform viewport swapchain"); + Vulkan::Check(vkGetSwapchainImagesKHR(device, window.swapchain, &window.imageCount, nullptr), + "query ui platform viewport swapchain image count"); + + std::array backbuffers{}; + if (window.imageCount > backbuffers.size()) + { + Throw(std::runtime_error("ui platform viewport swapchain exceeds supported image count")); + } + Vulkan::Check(vkGetSwapchainImagesKHR(device, window.swapchain, &window.imageCount, backbuffers.data()), + "query ui platform viewport swapchain images"); + + window.semaphoreCount = window.imageCount + 1; + window.frames.resize(static_cast(window.imageCount)); + window.frameSemaphores.resize(static_cast(window.semaphoreCount)); + std::fill_n(window.frames.Data, window.frames.Size, UiPlatformFrame{}); + std::fill_n(window.frameSemaphores.Data, window.frameSemaphores.Size, UiPlatformFrameSemaphores{}); + for (uint32_t imageIndex = 0; imageIndex < window.imageCount; ++imageIndex) + { + window.frames[imageIndex].backbuffer = backbuffers[imageIndex]; + } + + if (oldSwapChain != VK_NULL_HANDLE) + { + vkDestroySwapchainKHR(device, oldSwapChain, nullptr); + } + + VkAttachmentDescription attachment{}; + attachment.format = window.surfaceFormat.format; + attachment.samples = VK_SAMPLE_COUNT_1_BIT; + attachment.loadOp = window.clearEnable ? VK_ATTACHMENT_LOAD_OP_CLEAR : VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + + VkAttachmentReference colorAttachment{}; + colorAttachment.attachment = 0; + colorAttachment.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorAttachment; + + VkSubpassDependency dependency{}; + dependency.srcSubpass = VK_SUBPASS_EXTERNAL; + dependency.dstSubpass = 0; + dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo renderPassInfo{}; + renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + renderPassInfo.attachmentCount = 1; + renderPassInfo.pAttachments = &attachment; + renderPassInfo.subpassCount = 1; + renderPassInfo.pSubpasses = &subpass; + renderPassInfo.dependencyCount = 1; + renderPassInfo.pDependencies = &dependency; + Vulkan::Check(vkCreateRenderPass(device, &renderPassInfo, nullptr, &window.renderPass), + "create ui platform viewport render pass"); + + VkImageViewCreateInfo imageViewInfo{}; + imageViewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + imageViewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + imageViewInfo.format = window.surfaceFormat.format; + imageViewInfo.components.r = VK_COMPONENT_SWIZZLE_R; + imageViewInfo.components.g = VK_COMPONENT_SWIZZLE_G; + imageViewInfo.components.b = VK_COMPONENT_SWIZZLE_B; + imageViewInfo.components.a = VK_COMPONENT_SWIZZLE_A; + imageViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + imageViewInfo.subresourceRange.levelCount = 1; + imageViewInfo.subresourceRange.layerCount = 1; + + for (uint32_t imageIndex = 0; imageIndex < window.imageCount; ++imageIndex) + { + UiPlatformFrame& frame = window.frames[imageIndex]; + imageViewInfo.image = frame.backbuffer; + Vulkan::Check(vkCreateImageView(device, &imageViewInfo, nullptr, &frame.backbufferView), + "create ui platform viewport image view"); + } + + VkFramebufferCreateInfo framebufferInfo{}; + framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + framebufferInfo.renderPass = window.renderPass; + framebufferInfo.attachmentCount = 1; + framebufferInfo.width = static_cast(window.width); + framebufferInfo.height = static_cast(window.height); + framebufferInfo.layers = 1; + + for (uint32_t imageIndex = 0; imageIndex < window.imageCount; ++imageIndex) + { + UiPlatformFrame& frame = window.frames[imageIndex]; + VkImageView attachmentView = frame.backbufferView; + framebufferInfo.pAttachments = &attachmentView; + Vulkan::Check(vkCreateFramebuffer(device, &framebufferInfo, nullptr, &frame.framebuffer), + "create ui platform viewport framebuffer"); + } + } + + void CreateOrResizePlatformWindow(VkPhysicalDevice physicalDevice, VkDevice device, UiPlatformWindow& window, + uint32_t queueFamily, int width, int height, uint32_t minImageCount) + { + CreatePlatformWindowSwapChain(physicalDevice, device, window, width, height, minImageCount); + CreatePlatformWindowCommandBuffers(device, window, queueFamily); + } + + void DestroyPlatformWindow(VkInstance instance, VkDevice device, UiPlatformWindow& window) + { + Vulkan::Check(vkDeviceWaitIdle(device), "wait device idle for ui platform viewport destroy"); + + for (int imageIndex = 0; imageIndex < window.frames.Size; ++imageIndex) + { + DestroyPlatformFrame(device, window.frames[imageIndex]); + } + for (int semaphoreIndex = 0; semaphoreIndex < window.frameSemaphores.Size; ++semaphoreIndex) + { + DestroyPlatformFrameSemaphores(device, window.frameSemaphores[semaphoreIndex]); + } + window.frames.clear(); + window.frameSemaphores.clear(); + + if (window.renderPass != VK_NULL_HANDLE) + { + vkDestroyRenderPass(device, window.renderPass, nullptr); + } + if (window.swapchain != VK_NULL_HANDLE) + { + vkDestroySwapchainKHR(device, window.swapchain, nullptr); + } + if (window.surface != VK_NULL_HANDLE) + { + vkDestroySurfaceKHR(instance, window.surface, nullptr); + } + + window = UiPlatformWindow{}; + } + std::string ExtractConsolePrefix(const std::string& input) { size_t start = input.find_first_not_of(" \t\r\n"); @@ -66,6 +516,150 @@ namespace } return input.substr(start, end - start); } + + ImVec2 TransformUiPointToFramebuffer(const ImVec2 point, const UiPushConstants& pushConsts, const VkExtent2D& extent) + { + const float x = point.x * pushConsts.scale[0] + pushConsts.translate[0]; + const float y = point.y * pushConsts.scale[1] + pushConsts.translate[1]; + const float rx = x * pushConsts.rotation[0] + y * pushConsts.rotation[1]; + const float ry = x * pushConsts.rotation[2] + y * pushConsts.rotation[3]; + + return ImVec2((rx * 0.5f + 0.5f) * static_cast(extent.width), + (ry * 0.5f + 0.5f) * static_cast(extent.height)); + } + + void BindUiRenderState(VkCommandBuffer commandBuffer, VkPipeline pipeline, VkPipelineLayout pipelineLayout, + VkDescriptorSet bindlessDescriptorSet, VkBuffer vertexBuffer, const VkViewport& viewport, + const VkRect2D& scissor, const UiPushConstants& pushConsts) + { + vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + if (bindlessDescriptorSet != VK_NULL_HANDLE) + { + vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, + &bindlessDescriptorSet, 0, nullptr); + } + if (vertexBuffer != VK_NULL_HANDLE) + { + constexpr VkDeviceSize vertexOffset = 0; + vkCmdBindVertexBuffers(commandBuffer, 0, 1, &vertexBuffer, &vertexOffset); + } + vkCmdSetViewport(commandBuffer, 0, 1, &viewport); + vkCmdSetScissor(commandBuffer, 0, 1, &scissor); + vkCmdPushConstants(commandBuffer, pipelineLayout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(UiPushConstants), &pushConsts); + } + + VkPipeline CreateUiGraphicsPipeline(const Vulkan::Device& device, VkPipelineLayout pipelineLayout, + VkRenderPass renderPass) + { + const Vulkan::ShaderModule vertShader(device, kUiVertexShaderPath); + const Vulkan::ShaderModule fragShader(device, kUiFragmentShaderPath); + + VkVertexInputBindingDescription vertexBinding{}; + vertexBinding.binding = 0; + vertexBinding.stride = sizeof(UiBatchedVertex); + vertexBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::array vertexAttributes{}; + vertexAttributes[0].location = 0; + vertexAttributes[0].binding = 0; + vertexAttributes[0].format = VK_FORMAT_R32G32_SFLOAT; + vertexAttributes[0].offset = static_cast(offsetof(UiBatchedVertex, position)); + vertexAttributes[1].location = 1; + vertexAttributes[1].binding = 0; + vertexAttributes[1].format = VK_FORMAT_R32G32_SFLOAT; + vertexAttributes[1].offset = static_cast(offsetof(UiBatchedVertex, uv)); + vertexAttributes[2].location = 2; + vertexAttributes[2].binding = 0; + vertexAttributes[2].format = VK_FORMAT_R8G8B8A8_UNORM; + vertexAttributes[2].offset = static_cast(offsetof(UiBatchedVertex, color)); + vertexAttributes[3].location = 3; + vertexAttributes[3].binding = 0; + vertexAttributes[3].format = VK_FORMAT_R32G32B32A32_SFLOAT; + vertexAttributes[3].offset = static_cast(offsetof(UiBatchedVertex, clipRect)); + vertexAttributes[4].location = 4; + vertexAttributes[4].binding = 0; + vertexAttributes[4].format = VK_FORMAT_R32_UINT; + vertexAttributes[4].offset = static_cast(offsetof(UiBatchedVertex, textureIndex)); + + VkPipelineVertexInputStateCreateInfo vertexInputInfo{}; + vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vertexInputInfo.vertexBindingDescriptionCount = 1; + vertexInputInfo.pVertexBindingDescriptions = &vertexBinding; + vertexInputInfo.vertexAttributeDescriptionCount = static_cast(vertexAttributes.size()); + vertexInputInfo.pVertexAttributeDescriptions = vertexAttributes.data(); + + VkPipelineInputAssemblyStateCreateInfo inputAssembly{}; + inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + inputAssembly.primitiveRestartEnable = VK_FALSE; + + VkPipelineViewportStateCreateInfo viewportState{}; + viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + viewportState.viewportCount = 1; + viewportState.scissorCount = 1; + + VkPipelineRasterizationStateCreateInfo rasterizer{}; + rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + rasterizer.polygonMode = VK_POLYGON_MODE_FILL; + rasterizer.cullMode = VK_CULL_MODE_NONE; + rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + rasterizer.lineWidth = 1.0f; + + VkPipelineMultisampleStateCreateInfo multisampling{}; + multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + + VkPipelineColorBlendAttachmentState colorBlendAttachment{}; + colorBlendAttachment.blendEnable = VK_TRUE; + colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD; + colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD; + colorBlendAttachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + + VkPipelineColorBlendStateCreateInfo colorBlending{}; + colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + colorBlending.attachmentCount = 1; + colorBlending.pAttachments = &colorBlendAttachment; + + VkPipelineDepthStencilStateCreateInfo depthStencil{}; + depthStencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; + + VkDynamicState dynamicStates[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dynamicState{}; + dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dynamicState.dynamicStateCount = static_cast(std::size(dynamicStates)); + dynamicState.pDynamicStates = dynamicStates; + + VkPipelineShaderStageCreateInfo shaderStages[] = { + vertShader.CreateShaderStage(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.CreateShaderStage(VK_SHADER_STAGE_FRAGMENT_BIT)}; + + VkGraphicsPipelineCreateInfo pipelineInfo{}; + pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + pipelineInfo.stageCount = static_cast(std::size(shaderStages)); + pipelineInfo.pStages = shaderStages; + pipelineInfo.pVertexInputState = &vertexInputInfo; + pipelineInfo.pInputAssemblyState = &inputAssembly; + pipelineInfo.pViewportState = &viewportState; + pipelineInfo.pRasterizationState = &rasterizer; + pipelineInfo.pMultisampleState = &multisampling; + pipelineInfo.pDepthStencilState = &depthStencil; + pipelineInfo.pColorBlendState = &colorBlending; + pipelineInfo.pDynamicState = &dynamicState; + pipelineInfo.layout = pipelineLayout; + pipelineInfo.renderPass = renderPass; + pipelineInfo.subpass = 0; + + VkPipeline pipeline = VK_NULL_HANDLE; + Vulkan::Check(vkCreateGraphicsPipelines(device.Handle(), VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &pipeline), + "create ui pipeline"); + return pipeline; + } } // namespace UserInterface::UserInterface(NextEngine* engine, Vulkan::CommandPool& commandPool, const Vulkan::SwapChain& swapChain, @@ -73,16 +667,11 @@ UserInterface::UserInterface(NextEngine* engine, Vulkan::CommandPool& commandPoo std::function funcPreConfig, std::function funcInit) : userSettings_(userSettings), engine_(engine) { - const auto& device = swapChain.Device(); - const auto& window = device.Surface().Instance().Window(); + const auto& window = swapChain.Device().Surface().Instance().Window(); - // Initialise descriptor pool and render pass for ImGui. - const std::vector descriptorBindings = { - {0, 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 0}, - }; - descriptorPool_.reset(new Vulkan::DescriptorPool(device, descriptorBindings, swapChain.MinImageCount() + 2048)); renderPass_.reset(new Vulkan::RenderPass(swapChain, depthBuffer, VK_ATTACHMENT_LOAD_OP_LOAD)); renderPass_->SetDebugName("ImGui Render Pass"); + CreateUiPipeline(swapChain); // Initialise ImGui IMGUI_CHECKVERSION(); @@ -102,24 +691,7 @@ UserInterface::UserInterface(NextEngine* engine, Vulkan::CommandPool& commandPoo Throw(std::runtime_error("failed to initialise ImGui GLFW adapter")); } - // Initialise ImGui Vulkan adapter - ImGui_ImplVulkan_InitInfo vulkanInit = {}; - vulkanInit.Instance = device.Surface().Instance().Handle(); - vulkanInit.PhysicalDevice = device.PhysicalDevice(); - vulkanInit.Device = device.Handle(); - vulkanInit.QueueFamily = device.GraphicsFamilyIndex(); - vulkanInit.Queue = device.GraphicsQueue(); - vulkanInit.PipelineCache = nullptr; - vulkanInit.DescriptorPool = descriptorPool_->Handle(); - vulkanInit.MinImageCount = swapChain.MinImageCount(); - vulkanInit.ImageCount = static_cast(swapChain.Images().size()); - vulkanInit.Allocator = nullptr; - vulkanInit.RenderPass = renderPass_->Handle(); - - if (!ImGui_ImplVulkan_Init(&vulkanInit)) - { - Throw(std::runtime_error("failed to initialise ImGui vulkan adapter")); - } + InitializeRendererBackend(); // Window scaling and style. #if ANDROID @@ -194,171 +766,833 @@ UserInterface::UserInterface(NextEngine* engine, Vulkan::CommandPool& commandPoo .warnOnFailure = false, }); - if (funcInit != nullptr) + if (funcInit != nullptr) + { + funcInit(); + } + InitializeFontTexture(commandPool); +} + +UserInterface::~UserInterface() +{ + ShutdownRendererBackend(); + DestroyUiPipeline(); + uiFrameBuffers_.clear(); + uiRenderBuffers_.clear(); + platformUiRenderBuffers_.clear(); + + ImGui_ImplSDL3_Shutdown(); + ImGui::DestroyContext(); +} + +void UserInterface::OnCreateSurface(const Vulkan::SwapChain& swapChain, const Vulkan::DepthBuffer& depthBuffer) +{ + renderPass_.reset(new Vulkan::RenderPass(swapChain, depthBuffer, VK_ATTACHMENT_LOAD_OP_LOAD)); + renderPass_->SetDebugName("ImGui Render Pass"); + CreateUiPipeline(swapChain); + + for (const auto& imageView : swapChain.ImageViews()) + { + uiFrameBuffers_.emplace_back(swapChain.Extent(), *imageView, *renderPass_, false); + } + uiRenderBuffers_.resize(swapChain.Images().size()); +} + +void UserInterface::OnDestroySurface() +{ + DestroyUiPipeline(); + renderPass_.reset(); + uiFrameBuffers_.clear(); + uiRenderBuffers_.clear(); + platformUiRenderBuffers_.clear(); +} + +ImTextureID UserInterface::EncodeBindlessTextureId(uint32_t textureIndex) +{ + return (ImTextureID)(static_cast(textureIndex + 1u)); +} + +bool UserInterface::DecodeBindlessTextureId(ImTextureID textureId, uint32_t& outTextureIndex) +{ + const uint64_t rawValue = static_cast((intptr_t)textureId); + if (rawValue == 0) + { + return false; + } + + const uint64_t textureIndex = rawValue - 1u; + const auto* texturePool = Assets::GlobalTexturePool::GetInstance(); + if (texturePool == nullptr || textureIndex >= texturePool->TotalTextures()) + { + return false; + } + + outTextureIndex = static_cast(textureIndex); + return true; +} + +void UserInterface::InitializeRendererBackend() +{ + auto& io = ImGui::GetIO(); + if (io.BackendRendererUserData != nullptr) + { + Throw(std::runtime_error("imgui renderer backend already initialized")); + } + + io.BackendRendererUserData = this; + io.BackendRendererName = "gk_imgui_renderer"; + io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset; + io.BackendFlags |= ImGuiBackendFlags_RendererHasViewports; + + ImGuiPlatformIO& platformIo = ImGui::GetPlatformIO(); + if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) + { + if (platformIo.Platform_CreateVkSurface == nullptr) + { + Throw(std::runtime_error("imgui platform backend does not provide Platform_CreateVkSurface")); + } + } + + platformIo.Renderer_CreateWindow = &UserInterface::CreatePlatformViewportWindowCallback; + platformIo.Renderer_DestroyWindow = &UserInterface::DestroyPlatformViewportWindowCallback; + platformIo.Renderer_SetWindowSize = &UserInterface::ResizePlatformViewportWindowCallback; + platformIo.Renderer_RenderWindow = &UserInterface::RenderPlatformViewportWindowCallback; + platformIo.Renderer_SwapBuffers = &UserInterface::SwapPlatformViewportBuffersCallback; + + ImGuiViewport* mainViewport = ImGui::GetMainViewport(); + if (mainViewport->RendererUserData == nullptr) + { + mainViewport->RendererUserData = IM_NEW(UiPlatformViewportData)(); + } +} + +void UserInterface::ShutdownRendererBackend() +{ + if (ImGui::GetCurrentContext() == nullptr) + { + return; + } + + auto& io = ImGui::GetIO(); + ImGuiPlatformIO& platformIo = ImGui::GetPlatformIO(); + ImGui::DestroyPlatformWindows(); + + if (ImGuiViewport* mainViewport = ImGui::GetMainViewport(); mainViewport != nullptr) + { + if (mainViewport->RendererUserData != nullptr) + { + IM_DELETE(static_cast(mainViewport->RendererUserData)); + mainViewport->RendererUserData = nullptr; + } + } + + platformIo.Renderer_CreateWindow = nullptr; + platformIo.Renderer_DestroyWindow = nullptr; + platformIo.Renderer_SetWindowSize = nullptr; + platformIo.Renderer_RenderWindow = nullptr; + platformIo.Renderer_SwapBuffers = nullptr; + platformIo.Renderer_RenderState = nullptr; + + io.BackendRendererName = nullptr; + io.BackendRendererUserData = nullptr; + io.BackendFlags &= ~(ImGuiBackendFlags_RendererHasVtxOffset | ImGuiBackendFlags_RendererHasViewports); +} + +void UserInterface::BeginRendererBackendFrame() {} + +void UserInterface::InitializeFontTexture(Vulkan::CommandPool& commandPool) +{ + auto& io = ImGui::GetIO(); + unsigned char* pixels = nullptr; + int width = 0; + int height = 0; + io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height); + + if (pixels == nullptr || width <= 0 || height <= 0) + { + Throw(std::runtime_error("failed to build imgui font atlas")); + } + + const uint32_t fontTextureSize = static_cast(width * height * 4); + auto fontTexture = std::make_unique( + commandPool, static_cast(width), static_cast(height), 1, VK_FORMAT_R8G8B8A8_UNORM, pixels, + fontTextureSize); + fontTexture->MainThreadPostLoading(commandPool); + fontTexture->SetDebugName(kUiFontAtlasTextureName); + + auto* texturePool = Assets::GlobalTexturePool::GetInstance(); + if (texturePool == nullptr) + { + Throw(std::runtime_error("global texture pool is unavailable for imgui font atlas")); + } + + fontTextureIndex_ = texturePool->RegisterTexture( + kUiFontAtlasTextureName, std::move(fontTexture), Assets::ETextureLifetime::ETL_Persistent); + + io.Fonts->TexID = EncodeBindlessTextureId(fontTextureIndex_); +} + +ImTextureID UserInterface::RequestImTextureId(uint32_t globalTextureId) +{ + if (Assets::GlobalTexturePool::GetTextureImage(globalTextureId) == nullptr) + { + return 0; + } + + return EncodeBindlessTextureId(globalTextureId); +} + +ImTextureID UserInterface::RequestImTextureByName(const std::string& name) +{ + uint32_t id = Assets::GlobalTexturePool::GetTextureIndexByName(name); + if (id == static_cast(-1)) + { + return 0; + } + return RequestImTextureId(id); +} + +UserInterface::FUiTextureHandle UserInterface::RequestUiTexture(const std::string& path, bool srgb) +{ + FUiTextureHandle handle{}; + if (path.empty() || !Utilities::FileHelper::IsAssetAvailable(path)) + { + return handle; + } + + if (uiTextureLoadRequests_.insert(path).second) + { + Assets::GlobalTexturePool::LoadTexture(path, srgb); + } + + handle.textureId = RequestImTextureByName(path); + handle.valid = handle.textureId != 0; + + if (const auto sizeIt = uiTexturePixelSizeCache_.find(path); sizeIt != uiTexturePixelSizeCache_.end()) + { + handle.pixelSize = sizeIt->second; + return handle; + } + + int width = 0; + int height = 0; + int comp = 0; + const std::string platformPath = Utilities::FileHelper::GetPlatformFilePath(path.c_str()); + if (stbi_info(platformPath.c_str(), &width, &height, &comp) != 0 && width > 0 && height > 0) + { + handle.pixelSize = ImVec2(static_cast(width), static_cast(height)); + } + uiTexturePixelSizeCache_[path] = handle.pixelSize; + return handle; +} + +void UserInterface::CreateUiPipeline(const Vulkan::SwapChain& swapChain) +{ + DestroyUiPipeline(); + if (renderPass_ == nullptr) + { + return; + } + + const auto& device = swapChain.Device(); + const auto* texturePool = Assets::GlobalTexturePool::GetInstance(); + if (texturePool == nullptr) + { + Throw(std::runtime_error("global texture pool is unavailable for ui pipeline")); + } + + const VkDescriptorSetLayout bindlessLayout = texturePool->Layout(); + + VkPushConstantRange pushConstantRange{}; + pushConstantRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushConstantRange.offset = 0; + pushConstantRange.size = sizeof(UiPushConstants); + + VkPipelineLayoutCreateInfo pipelineLayoutInfo{}; + pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + pipelineLayoutInfo.setLayoutCount = 1; + pipelineLayoutInfo.pSetLayouts = &bindlessLayout; + pipelineLayoutInfo.pushConstantRangeCount = 1; + pipelineLayoutInfo.pPushConstantRanges = &pushConstantRange; + + Vulkan::Check(vkCreatePipelineLayout(device.Handle(), &pipelineLayoutInfo, nullptr, &uiPipelineLayout_), + "create ui pipeline layout"); + uiPipeline_ = CreateUiGraphicsPipeline(device, uiPipelineLayout_, renderPass_->Handle()); +} + +void UserInterface::DestroyUiPipeline() +{ + if (engine_ == nullptr) + { + return; + } + + const auto& device = engine_->GetRenderer().Device(); + if (uiPipeline_ != VK_NULL_HANDLE) + { + vkDestroyPipeline(device.Handle(), uiPipeline_, nullptr); + uiPipeline_ = VK_NULL_HANDLE; + } + if (uiPlatformViewportPipeline_ != VK_NULL_HANDLE) + { + vkDestroyPipeline(device.Handle(), uiPlatformViewportPipeline_, nullptr); + uiPlatformViewportPipeline_ = VK_NULL_HANDLE; + } + uiPlatformViewportRenderPass_ = VK_NULL_HANDLE; + if (uiPipelineLayout_ != VK_NULL_HANDLE) + { + vkDestroyPipelineLayout(device.Handle(), uiPipelineLayout_, nullptr); + uiPipelineLayout_ = VK_NULL_HANDLE; + } +} + +VkPipeline UserInterface::GetOrCreatePlatformViewportPipeline(VkRenderPass renderPass) +{ + if (renderPass == VK_NULL_HANDLE) + { + return VK_NULL_HANDLE; + } + + if (uiPlatformViewportPipeline_ != VK_NULL_HANDLE && uiPlatformViewportRenderPass_ == renderPass) + { + return uiPlatformViewportPipeline_; + } + + const auto& device = engine_->GetRenderer().Device(); + if (uiPlatformViewportPipeline_ != VK_NULL_HANDLE) + { + vkDestroyPipeline(device.Handle(), uiPlatformViewportPipeline_, nullptr); + uiPlatformViewportPipeline_ = VK_NULL_HANDLE; + } + + uiPlatformViewportPipeline_ = CreateUiGraphicsPipeline(device, uiPipelineLayout_, renderPass); + uiPlatformViewportRenderPass_ = renderPass; + return uiPlatformViewportPipeline_; +} + +void UserInterface::RenderDrawData(ImDrawData* drawData, VkCommandBuffer commandBuffer, FUiRenderBuffers& renderBuffers, + VkExtent2D framebufferExtent, bool hdrOutput, VkPipeline pipeline) +{ + if (drawData == nullptr || drawData->CmdListsCount <= 0 || pipeline == VK_NULL_HANDLE) + { + return; + } + if (drawData->DisplaySize.x <= 0.0f || drawData->DisplaySize.y <= 0.0f) + { + return; + } + if (framebufferExtent.width == 0 || framebufferExtent.height == 0) + { + return; + } + + UiPushConstants pushConsts{}; + pushConsts.scale[0] = 2.0f / drawData->DisplaySize.x; + pushConsts.scale[1] = 2.0f / drawData->DisplaySize.y; + pushConsts.translate[0] = -1.0f - drawData->DisplayPos.x * pushConsts.scale[0]; + pushConsts.translate[1] = -1.0f - drawData->DisplayPos.y * pushConsts.scale[1]; + pushConsts.rotation[0] = 1.0f; + pushConsts.rotation[1] = 0.0f; + pushConsts.rotation[2] = 0.0f; + pushConsts.rotation[3] = 1.0f; +#if ANDROID + pushConsts.rotation[0] = 0.0f; + pushConsts.rotation[1] = 1.0f; + pushConsts.rotation[2] = -1.0f; + pushConsts.rotation[3] = 0.0f; +#endif + pushConsts.hdrOutput = hdrOutput ? 1u : 0u; + pushConsts.hdrReferenceWhiteNit = kUiHdrReferenceWhiteNit; + + std::vector batchedVertices; + batchedVertices.reserve(static_cast(std::max(drawData->TotalIdxCount, 0))); + + std::vector drawOps; + drawOps.reserve(static_cast(drawData->CmdListsCount) * 2); + + auto FlushPendingDraw = [&](uint32_t& segmentStartVertex) + { + const uint32_t vertexCount = static_cast(batchedVertices.size()) - segmentStartVertex; + if (vertexCount == 0) + { + return; + } + + drawOps.push_back( + UiDrawOp{UiDrawOp::EType::Draw, UiDrawSegment{segmentStartVertex, vertexCount}, nullptr, nullptr}); + segmentStartVertex = static_cast(batchedVertices.size()); + }; + + uint32_t currentSegmentStartVertex = 0; + for (int listIndex = 0; listIndex < drawData->CmdListsCount; ++listIndex) + { + const ImDrawList* drawList = drawData->CmdLists[listIndex]; + if (drawList == nullptr) + { + continue; + } + + for (int cmdIndex = 0; cmdIndex < drawList->CmdBuffer.Size; ++cmdIndex) + { + const ImDrawCmd* drawCmd = &drawList->CmdBuffer[cmdIndex]; + if (drawCmd->UserCallback != nullptr) + { + FlushPendingDraw(currentSegmentStartVertex); + drawOps.push_back(UiDrawOp{UiDrawOp::EType::Callback, UiDrawSegment{}, drawList, drawCmd}); + continue; + } + + uint32_t textureIndex = fontTextureIndex_; + if (!DecodeBindlessTextureId(drawCmd->GetTexID(), textureIndex)) + { + continue; + } + + const ImVec2 corners[] = { + TransformUiPointToFramebuffer(ImVec2(drawCmd->ClipRect.x, drawCmd->ClipRect.y), pushConsts, + framebufferExtent), + TransformUiPointToFramebuffer(ImVec2(drawCmd->ClipRect.z, drawCmd->ClipRect.y), pushConsts, + framebufferExtent), + TransformUiPointToFramebuffer(ImVec2(drawCmd->ClipRect.z, drawCmd->ClipRect.w), pushConsts, + framebufferExtent), + TransformUiPointToFramebuffer(ImVec2(drawCmd->ClipRect.x, drawCmd->ClipRect.w), pushConsts, + framebufferExtent), + }; + + float clipMinX = corners[0].x; + float clipMinY = corners[0].y; + float clipMaxX = corners[0].x; + float clipMaxY = corners[0].y; + for (const ImVec2 corner : corners) + { + clipMinX = std::min(clipMinX, corner.x); + clipMinY = std::min(clipMinY, corner.y); + clipMaxX = std::max(clipMaxX, corner.x); + clipMaxY = std::max(clipMaxY, corner.y); + } + + clipMinX = std::clamp(clipMinX, 0.0f, static_cast(framebufferExtent.width)); + clipMinY = std::clamp(clipMinY, 0.0f, static_cast(framebufferExtent.height)); + clipMaxX = std::clamp(clipMaxX, 0.0f, static_cast(framebufferExtent.width)); + clipMaxY = std::clamp(clipMaxY, 0.0f, static_cast(framebufferExtent.height)); + if (clipMaxX <= clipMinX || clipMaxY <= clipMinY) + { + continue; + } + + for (uint32_t elemIndex = 0; elemIndex < drawCmd->ElemCount; ++elemIndex) + { + const uint32_t vertexIndex = static_cast(drawList->IdxBuffer[drawCmd->IdxOffset + elemIndex]) + + drawCmd->VtxOffset; + if (vertexIndex >= static_cast(drawList->VtxBuffer.Size)) + { + continue; + } + + const ImDrawVert& sourceVertex = drawList->VtxBuffer[vertexIndex]; + UiBatchedVertex& batchedVertex = batchedVertices.emplace_back(); + batchedVertex.position = sourceVertex.pos; + batchedVertex.uv = sourceVertex.uv; + batchedVertex.color = sourceVertex.col; + batchedVertex.clipRect[0] = clipMinX; + batchedVertex.clipRect[1] = clipMinY; + batchedVertex.clipRect[2] = clipMaxX; + batchedVertex.clipRect[3] = clipMaxY; + batchedVertex.textureIndex = textureIndex; + } + } + } + FlushPendingDraw(currentSegmentStartVertex); + + const auto& device = engine_->GetRenderer().Device(); + const VkDeviceSize vertexSize = static_cast(batchedVertices.size()) * sizeof(UiBatchedVertex); + if (vertexSize > 0) + { + if (!renderBuffers.vertexBuffer || renderBuffers.vertexBufferSize < vertexSize) + { + renderBuffers.vertexBuffer.reset(new Vulkan::Buffer(device, vertexSize, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT)); + renderBuffers.vertexBufferMemory.reset(new Vulkan::DeviceMemory( + renderBuffers.vertexBuffer->AllocateMemory(0, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT))); + renderBuffers.vertexBufferSize = vertexSize; + } + + void* mappedData = renderBuffers.vertexBufferMemory->Map(0, vertexSize); + memcpy(mappedData, batchedVertices.data(), static_cast(vertexSize)); + renderBuffers.vertexBufferMemory->Unmap(); + } + + VkViewport viewport{}; + viewport.x = 0.0f; + viewport.y = 0.0f; + viewport.width = static_cast(framebufferExtent.width); + viewport.height = static_cast(framebufferExtent.height); + viewport.minDepth = 0.0f; + viewport.maxDepth = 1.0f; + VkRect2D scissor{}; + scissor.offset = {0, 0}; + scissor.extent = framebufferExtent; + + const VkDescriptorSet bindlessDescriptorSet = Assets::GlobalTexturePool::GetInstance()->DescriptorSet(0); + const VkBuffer vertexBufferHandle = + renderBuffers.vertexBuffer ? renderBuffers.vertexBuffer->Handle() : VK_NULL_HANDLE; + BindUiRenderState(commandBuffer, pipeline, uiPipelineLayout_, bindlessDescriptorSet, vertexBufferHandle, + viewport, scissor, pushConsts); + + ImGuiPlatformIO& platformIo = ImGui::GetPlatformIO(); + UiRendererRenderState renderState{}; + renderState.commandBuffer = commandBuffer; + renderState.pipeline = pipeline; + renderState.pipelineLayout = uiPipelineLayout_; + platformIo.Renderer_RenderState = &renderState; + + for (const UiDrawOp& drawOp : drawOps) + { + if (drawOp.type == UiDrawOp::EType::Draw) + { + if (drawOp.segment.vertexCount > 0) + { + vkCmdDraw(commandBuffer, drawOp.segment.vertexCount, 1, drawOp.segment.vertexOffset, 0); + } + continue; + } + + if (drawOp.drawCmd == nullptr || drawOp.drawCmd->UserCallback == nullptr) + { + continue; + } + + if (drawOp.drawCmd->UserCallback == ImDrawCallback_ResetRenderState) + { + BindUiRenderState(commandBuffer, pipeline, uiPipelineLayout_, bindlessDescriptorSet, vertexBufferHandle, + viewport, scissor, pushConsts); + continue; + } + + drawOp.drawCmd->UserCallback(drawOp.drawList, drawOp.drawCmd); + BindUiRenderState(commandBuffer, pipeline, uiPipelineLayout_, bindlessDescriptorSet, vertexBufferHandle, + viewport, scissor, pushConsts); + } + + platformIo.Renderer_RenderState = nullptr; +} + +UserInterface* UserInterface::GetRendererBackendOwner() +{ + if (ImGui::GetCurrentContext() == nullptr) + { + return nullptr; + } + + return static_cast(ImGui::GetIO().BackendRendererUserData); +} + +void UserInterface::CreatePlatformViewportWindowCallback(ImGuiViewport* viewport) +{ + if (UserInterface* owner = GetRendererBackendOwner(); owner != nullptr) { - funcInit(); + owner->CreatePlatformViewportWindow(viewport); } - - Vulkan::SingleTimeCommands::Submit(commandPool, - [](VkCommandBuffer commandBuffer) - { - if (!ImGui_ImplVulkan_CreateFontsTexture()) - { - Throw(std::runtime_error("failed to create ImGui font textures")); - } - }); } -UserInterface::~UserInterface() +void UserInterface::DestroyPlatformViewportWindowCallback(ImGuiViewport* viewport) { - uiFrameBuffers_.clear(); + if (UserInterface* owner = GetRendererBackendOwner(); owner != nullptr) + { + owner->DestroyPlatformViewportWindow(viewport); + } +} - ImGui_ImplVulkan_Shutdown(); - ImGui_ImplSDL3_Shutdown(); - ImGui::DestroyContext(); +void UserInterface::ResizePlatformViewportWindowCallback(ImGuiViewport* viewport, ImVec2 size) +{ + if (UserInterface* owner = GetRendererBackendOwner(); owner != nullptr) + { + owner->ResizePlatformViewportWindow(viewport, size); + } } -void UserInterface::OnCreateSurface(const Vulkan::SwapChain& swapChain, const Vulkan::DepthBuffer& depthBuffer) +void UserInterface::RenderPlatformViewportWindowCallback(ImGuiViewport* viewport, void* renderArg) { - renderPass_.reset(new Vulkan::RenderPass(swapChain, depthBuffer, VK_ATTACHMENT_LOAD_OP_LOAD)); - renderPass_->SetDebugName("ImGui Render Pass"); + UserInterface* owner = renderArg != nullptr ? static_cast(renderArg) : GetRendererBackendOwner(); + if (owner != nullptr) + { + owner->RenderPlatformViewportWindow(viewport); + } +} - for (const auto& imageView : swapChain.ImageViews()) +void UserInterface::SwapPlatformViewportBuffersCallback(ImGuiViewport* viewport, void* renderArg) +{ + UserInterface* owner = renderArg != nullptr ? static_cast(renderArg) : GetRendererBackendOwner(); + if (owner != nullptr) { - uiFrameBuffers_.emplace_back(swapChain.Extent(), *imageView, *renderPass_, false); + owner->SwapPlatformViewportBuffers(viewport); } } -void UserInterface::OnDestroySurface() +void UserInterface::CreatePlatformViewportWindow(ImGuiViewport* viewport) { - renderPass_.reset(); - uiFrameBuffers_.clear(); - for (auto image : imTextureIdMap_) + if (viewport == nullptr) + { + return; + } + + auto* viewportData = IM_NEW(UiPlatformViewportData)(); + viewport->RendererUserData = viewportData; + + UiPlatformWindow& window = viewportData->window; + const auto& renderer = engine_->GetRenderer(); + const auto& device = renderer.Device(); + + ImGuiPlatformIO& platformIo = ImGui::GetPlatformIO(); + VkResult result = static_cast(platformIo.Platform_CreateVkSurface( + viewport, reinterpret_cast(device.Surface().Instance().Handle()), nullptr, + reinterpret_cast(&window.surface))); + Vulkan::Check(result, "create ui platform viewport surface"); + + VkBool32 presentSupported = VK_FALSE; + Vulkan::Check(vkGetPhysicalDeviceSurfaceSupportKHR(device.PhysicalDevice(), device.GraphicsFamilyIndex(), + window.surface, &presentSupported), + "query ui platform viewport present support"); + if (presentSupported != VK_TRUE) { - ImGui_ImplVulkan_RemoveTexture(image.second); + Throw(std::runtime_error("ui platform viewport surface has no present support")); } - imTextureIdMap_.clear(); + + const VkFormat requestedFormats[] = { + VK_FORMAT_B8G8R8A8_UNORM, + VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_B8G8R8_UNORM, + VK_FORMAT_R8G8B8_UNORM}; + window.surfaceFormat = SelectPlatformSurfaceFormat(device.PhysicalDevice(), window.surface, requestedFormats, + static_cast(std::size(requestedFormats)), + VK_COLORSPACE_SRGB_NONLINEAR_KHR); + + const VkPresentModeKHR requestedPresentModes[] = { + VK_PRESENT_MODE_MAILBOX_KHR, + VK_PRESENT_MODE_IMMEDIATE_KHR, + VK_PRESENT_MODE_FIFO_KHR}; + window.presentMode = SelectPlatformPresentMode(device.PhysicalDevice(), window.surface, requestedPresentModes, + static_cast(std::size(requestedPresentModes))); + window.clearEnable = (viewport->Flags & ImGuiViewportFlags_NoRendererClear) == 0; + window.useDynamicRendering = false; + + CreateOrResizePlatformWindow(device.PhysicalDevice(), device.Handle(), window, device.GraphicsFamilyIndex(), + static_cast(viewport->Size.x), + static_cast(viewport->Size.y), renderer.SwapChain().MinImageCount()); + viewportData->windowOwned = true; } -VkDescriptorSet UserInterface::RequestImTextureId(uint32_t globalTextureId) +void UserInterface::DestroyPlatformViewportWindow(ImGuiViewport* viewport) { - auto texture = Assets::GlobalTexturePool::GetTextureImage(globalTextureId); - if (texture == nullptr) - return VK_NULL_HANDLE; + if (viewport == nullptr || viewport->RendererUserData == nullptr) + { + return; + } - if (imTextureIdMap_.find(globalTextureId) == imTextureIdMap_.end()) + auto* viewportData = static_cast(viewport->RendererUserData); + if (viewportData->windowOwned) { - imTextureIdMap_[globalTextureId] = ImGui_ImplVulkan_AddTexture( - texture->Sampler().Handle(), texture->ImageView().Handle(), VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + const auto& device = engine_->GetRenderer().Device(); + DestroyPlatformWindow(device.Surface().Instance().Handle(), device.Handle(), viewportData->window); } - return imTextureIdMap_[globalTextureId]; + platformUiRenderBuffers_.erase(viewport->ID); + IM_DELETE(viewportData); + viewport->RendererUserData = nullptr; } -VkDescriptorSet UserInterface::RequestImTextureByName(const std::string& name) +void UserInterface::ResizePlatformViewportWindow(ImGuiViewport* viewport, ImVec2 size) { - uint32_t id = Assets::GlobalTexturePool::GetTextureIndexByName(name); - if (id == -1) + if (viewport == nullptr || viewport->RendererUserData == nullptr) { - return VK_NULL_HANDLE; + return; } - return RequestImTextureId(id); + + auto* viewportData = static_cast(viewport->RendererUserData); + UiPlatformWindow& window = viewportData->window; + const auto& renderer = engine_->GetRenderer(); + const auto& device = renderer.Device(); + + window.clearEnable = (viewport->Flags & ImGuiViewportFlags_NoRendererClear) == 0; + CreateOrResizePlatformWindow(device.PhysicalDevice(), device.Handle(), window, device.GraphicsFamilyIndex(), + static_cast(size.x), static_cast(size.y), + renderer.SwapChain().MinImageCount()); + viewportData->swapChainNeedRebuild = false; + viewportData->swapChainSuboptimal = false; } -UserInterface::FUiTextureHandle UserInterface::RequestUiTexture(const std::string& path, bool srgb) +void UserInterface::RenderPlatformViewportWindow(ImGuiViewport* viewport) { - FUiTextureHandle handle{}; - if (path.empty() || !Utilities::FileHelper::IsAssetAvailable(path)) + if (viewport == nullptr || viewport->RendererUserData == nullptr || viewport->DrawData == nullptr) { - return handle; + return; } - if (uiTextureLoadRequests_.insert(path).second) + auto* viewportData = static_cast(viewport->RendererUserData); + UiPlatformWindow& window = viewportData->window; + const auto& renderer = engine_->GetRenderer(); + const auto& device = renderer.Device(); + VkResult result = VK_SUCCESS; + + if (viewportData->swapChainNeedRebuild || viewportData->swapChainSuboptimal) { - Assets::GlobalTexturePool::LoadTexture(path, srgb); + ResizePlatformViewportWindow(viewport, viewport->Size); } - const VkDescriptorSet descriptor = RequestImTextureByName(path); - handle.textureId = descriptor != VK_NULL_HANDLE ? reinterpret_cast(descriptor) : static_cast(0); - handle.valid = handle.textureId != static_cast(0); + UiPlatformFrameSemaphores& frameSemaphores = window.frameSemaphores[window.semaphoreIndex]; + result = vkAcquireNextImageKHR(device.Handle(), window.swapchain, UINT64_MAX, + frameSemaphores.imageAcquiredSemaphore, VK_NULL_HANDLE, &window.frameIndex); + if (result == VK_ERROR_OUT_OF_DATE_KHR) + { + viewportData->swapChainNeedRebuild = true; + return; + } + if (result == VK_SUBOPTIMAL_KHR) + { + viewportData->swapChainSuboptimal = true; + } + else + { + Vulkan::Check(result, "acquire ui platform viewport image"); + } - if (const auto sizeIt = uiTexturePixelSizeCache_.find(path); sizeIt != uiTexturePixelSizeCache_.end()) + UiPlatformFrame& frame = window.frames[window.frameIndex]; + for (;;) { - handle.pixelSize = sizeIt->second; - return handle; + result = vkWaitForFences(device.Handle(), 1, &frame.fence, VK_TRUE, 100); + if (result == VK_SUCCESS) + { + break; + } + if (result != VK_TIMEOUT) + { + Vulkan::Check(result, "wait ui platform viewport fence"); + } } - int width = 0; - int height = 0; - int comp = 0; - const std::string platformPath = Utilities::FileHelper::GetPlatformFilePath(path.c_str()); - if (stbi_info(platformPath.c_str(), &width, &height, &comp) != 0 && width > 0 && height > 0) + Vulkan::Check(vkResetCommandPool(device.Handle(), frame.commandPool, 0), "reset ui platform viewport command pool"); + + VkCommandBufferBeginInfo beginInfo{}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + Vulkan::Check(vkBeginCommandBuffer(frame.commandBuffer, &beginInfo), "begin ui platform viewport command buffer"); + + VkRenderPassBeginInfo renderPassBeginInfo{}; + renderPassBeginInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + renderPassBeginInfo.renderPass = window.renderPass; + renderPassBeginInfo.framebuffer = frame.framebuffer; + renderPassBeginInfo.renderArea.extent.width = static_cast(window.width); + renderPassBeginInfo.renderArea.extent.height = static_cast(window.height); + renderPassBeginInfo.clearValueCount = (viewport->Flags & ImGuiViewportFlags_NoRendererClear) ? 0u : 1u; + renderPassBeginInfo.pClearValues = (viewport->Flags & ImGuiViewportFlags_NoRendererClear) ? nullptr : &window.clearValue; + vkCmdBeginRenderPass(frame.commandBuffer, &renderPassBeginInfo, VK_SUBPASS_CONTENTS_INLINE); + + VkPipeline viewportPipeline = GetOrCreatePlatformViewportPipeline(window.renderPass); + auto& viewportRenderBuffers = platformUiRenderBuffers_[viewport->ID]; + if (viewportRenderBuffers.size() < window.imageCount) { - handle.pixelSize = ImVec2(static_cast(width), static_cast(height)); + viewportRenderBuffers.resize(window.imageCount); } - uiTexturePixelSizeCache_[path] = handle.pixelSize; - return handle; + + RenderDrawData(viewport->DrawData, frame.commandBuffer, viewportRenderBuffers[window.frameIndex], + VkExtent2D{static_cast(window.width), static_cast(window.height)}, false, + viewportPipeline); + + vkCmdEndRenderPass(frame.commandBuffer); + + VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + VkSubmitInfo submitInfo{}; + submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submitInfo.waitSemaphoreCount = 1; + submitInfo.pWaitSemaphores = &frameSemaphores.imageAcquiredSemaphore; + submitInfo.pWaitDstStageMask = &waitStage; + submitInfo.commandBufferCount = 1; + submitInfo.pCommandBuffers = &frame.commandBuffer; + submitInfo.signalSemaphoreCount = 1; + submitInfo.pSignalSemaphores = &frameSemaphores.renderCompleteSemaphore; + + Vulkan::Check(vkEndCommandBuffer(frame.commandBuffer), "end ui platform viewport command buffer"); + Vulkan::Check(vkResetFences(device.Handle(), 1, &frame.fence), "reset ui platform viewport fence"); + Vulkan::Check(vkQueueSubmit(device.GraphicsQueue(), 1, &submitInfo, frame.fence), + "submit ui platform viewport command buffer"); } -void UserInterface::SetStyle() +void UserInterface::SwapPlatformViewportBuffers(ImGuiViewport* viewport) +{ + if (viewport == nullptr || viewport->RendererUserData == nullptr) + { + return; + } + + auto* viewportData = static_cast(viewport->RendererUserData); + if (viewportData->swapChainNeedRebuild) + { + return; + } + + UiPlatformWindow& window = viewportData->window; + UiPlatformFrameSemaphores& frameSemaphores = window.frameSemaphores[window.semaphoreIndex]; + uint32_t presentIndex = window.frameIndex; + + VkPresentInfoKHR presentInfo{}; + presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; + presentInfo.waitSemaphoreCount = 1; + presentInfo.pWaitSemaphores = &frameSemaphores.renderCompleteSemaphore; + presentInfo.swapchainCount = 1; + presentInfo.pSwapchains = &window.swapchain; + presentInfo.pImageIndices = &presentIndex; + + VkResult result = vkQueuePresentKHR(engine_->GetRenderer().Device().GraphicsQueue(), &presentInfo); + if (result == VK_ERROR_OUT_OF_DATE_KHR) + { + viewportData->swapChainNeedRebuild = true; + return; + } + if (result == VK_SUBOPTIMAL_KHR) + { + viewportData->swapChainSuboptimal = true; + } + else + { + Vulkan::Check(result, "present ui platform viewport"); + } + + window.semaphoreIndex = (window.semaphoreIndex + 1) % window.semaphoreCount; +} + +void UserInterface::PrunePlatformViewportRenderBuffers() { - ImGuiIO& io = ImGui::GetIO(); + std::unordered_set activeViewportIds; + ImGuiPlatformIO& platformIo = ImGui::GetPlatformIO(); + ImGuiViewport* mainViewport = ImGui::GetMainViewport(); + for (int viewportIndex = 0; viewportIndex < platformIo.Viewports.Size; ++viewportIndex) + { + ImGuiViewport* viewport = platformIo.Viewports[viewportIndex]; + if (viewport == nullptr || viewport == mainViewport) + { + continue; + } + activeViewportIds.insert(viewport->ID); + } + + for (auto it = platformUiRenderBuffers_.begin(); it != platformUiRenderBuffers_.end();) + { + if (!activeViewportIds.contains(it->first)) + { + it = platformUiRenderBuffers_.erase(it); + } + else + { + ++it; + } + } +} +void UserInterface::SetStyle() +{ // 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 +1888,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(); } @@ -714,16 +2013,14 @@ void UserInterface::DrawConsoleLogOutputInternal(const char* childId, const ImVe void UserInterface::PreRender() { - ImGui_ImplVulkan_NewFrame(); + BeginRendererBackendFrame(); ImGui_ImplSDL3_NewFrame(); +#if ANDROID + auto& io = ImGui::GetIO(); + io.DisplayFramebufferScale.x *= GAndroidMagicScale; + io.DisplayFramebufferScale.y *= GAndroidMagicScale; +#endif ImGui::NewFrame(); - - // update texture to ui - uint32_t maxId = Assets::GlobalTexturePool::GetInstance()->TotalTextures(); - for (uint32_t idx = 0; idx < maxId; ++idx) - { - RequestImTextureId(idx); - } } void UserInterface::Render(const Statistics& statistics, VulkanGpuTimer* gpuTimer, Assets::Scene* scene, @@ -767,14 +2064,16 @@ void UserInterface::PostRender(VkCommandBuffer commandBuffer, const Vulkan::Swap renderPassInfo.pClearValues = nullptr; vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE); - ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), commandBuffer); + RenderDrawData(ImGui::GetDrawData(), commandBuffer, uiRenderBuffers_[imageIdx], swapChain.Extent(), swapChain.IsHDR(), + uiPipeline_); vkCmdEndRenderPass(commandBuffer); auto& io = ImGui::GetIO(); if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { ImGui::UpdatePlatformWindows(); - ImGui::RenderPlatformWindowsDefault(); + PrunePlatformViewportRenderBuffers(); + ImGui::RenderPlatformWindowsDefault(nullptr, this); } } @@ -800,6 +2099,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) @@ -807,260 +2112,382 @@ void UserInterface::DrawOverlay(const Statistics& statistics, VulkanGpuTimer* gp return; } + frameRateSamples_[overlaySampleCursor_] = statistics.FrameRate; + frameTimeSamples_[overlaySampleCursor_] = statistics.FrameTime; + overlaySampleCursor_ = (overlaySampleCursor_ + 1) % kOverlaySparklineSampleCount; + overlaySampleFilled_ = std::min(overlaySampleFilled_ + 1, kOverlaySparklineSampleCount); + const auto& io = ImGui::GetIO(); - const float distance = 10.0f; - const ImVec2 pos = ImVec2(io.DisplaySize.x - distance, distance + 40); - const ImVec2 posPivot = ImVec2(1.0f, 0.0f); + constexpr float distance = 12.0f; + constexpr float panelWidth = 380.0f; + const ImVec2 pos = ImVec2(io.DisplaySize.x - distance - panelWidth, distance + 44.0f); + const float panelHeight = std::max(420.0f, io.DisplaySize.y - pos.y - 42.0f); + + if (!Runtime::UiTheme::BeginFloatingPanel( + "##ProfilerPanel", ICON_FA_CHART_LINE, "Profiler", &Settings().ShowOverlay, + pos, ImVec2(panelWidth, panelHeight))) + { + return; + } - ImGui::SetNextWindowPos(pos, ImGuiCond_Always, posPivot); - ImGui::SetNextWindowBgAlpha(0.3f); // Transparent background + ImGui::BeginChild("##ProfilerBody", ImVec2(0, 0), false, ImGuiWindowFlags_NoBackground); - const auto flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoSavedSettings; + auto BeginCard = [&](const char* id, float height, ImGuiWindowFlags extraFlags = 0) + { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f, 10.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f); + ImGui::PushStyleColor(ImGuiCol_ChildBg, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::SurfaceElevated, 0.38f)); + ImGui::PushStyleColor(ImGuiCol_Border, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Border, 0.84f)); + ImGui::BeginChild(id, ImVec2(0.0f, height), true, extraFlags); + }; - if (ImGui::Begin("Statistics", &Settings().ShowOverlay, flags)) + auto EndCard = [&]() { - // 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); + ImGui::EndChild(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(3); + }; - // Helper - auto LabelVal = [&](const char* label, const char* fmt, auto... args) + auto BuildOrdered = [&](const std::array& src, + std::array& dst, + int& outCount) + { + outCount = overlaySampleFilled_; + if (overlaySampleFilled_ < kOverlaySparklineSampleCount) { - ImGui::TextColored(colLabel, "%s", label); - ImGui::SameLine(); - ImGui::TextColored(colVal, fmt, args...); - }; + for (int i = 0; i < outCount; ++i) + { + dst[i] = src[i]; + } + } + else + { + for (int i = 0; i < kOverlaySparklineSampleCount; ++i) + { + dst[i] = src[(overlaySampleCursor_ + i) % kOverlaySparklineSampleCount]; + } + } + }; - 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()); + std::array orderedFps{}; + std::array orderedFt{}; + int orderedCount = 0; + BuildOrdered(frameRateSamples_, orderedFps, orderedCount); + BuildOrdered(frameTimeSamples_, orderedFt, orderedCount); - ImGui::Separator(); + 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); - // FPS - ImVec4 fpsColor = statistics.FrameRate > 55.0f ? colGood : (statistics.FrameRate > 30.0f ? colWarn : colBad); - ImGui::TextColored(colLabel, "Frame rate:"); - ImGui::SameLine(); - ImGui::TextColored(fpsColor, "%.0f fps", statistics.FrameRate); + auto LabelVal = [&](const char* label, const char* fmt, auto... args) + { + ImGui::TextColored(colLabel, "%s", label); + ImGui::SameLine(132.0f); + ImGui::TextColored(colVal, fmt, args...); + }; - LabelVal("Frame time:", "%.2f ms", statistics.FrameTime); + { + const VkPhysicalDeviceProperties deviceProperties = + NextEngine::GetInstance()->GetRenderer().Device().DeviceProperties(); - ImGui::Separator(); + BeginCard("##ProfilerDeviceCard", 76.0f); + if (ImGui::BeginTable("##ProfilerDeviceHeader", 2, ImGuiTableFlags_SizingStretchProp)) + { + ImGui::TableNextColumn(); + ImGui::TextColored(colHeader, "Device"); + ImGui::TableNextColumn(); + ImGui::TextColored(colLabel, "Resolution"); + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextColored(colVal, "%ux%u", statistics.FramebufferSize.width, + statistics.FramebufferSize.height); + ImGui::EndTable(); + } + ImGui::TextColored(colVal, "%s", deviceProperties.deviceName); + EndCard(); + ImGui::Dummy(ImVec2(0.0f, 8.0f)); + } + + { + const float panelAvail = ImGui::GetContentRegionAvail().x; + const float gap = 8.0f; + const float halfWidth = (panelAvail - gap) * 0.5f; - // 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); + auto DrawCard = [&](const char* title, const char* value, ImVec4 valueColor, + const float* samples, int count, ImVec4 sparkColor, float width) + { + ImVec2 cardPos = ImGui::GetCursorScreenPos(); + const float height = 78.0f; + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(cardPos, ImVec2(cardPos.x + width, cardPos.y + height), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::SurfaceElevated, 0.65f), 6.0f); + dl->AddRect(cardPos, ImVec2(cardPos.x + width, cardPos.y + height), + Runtime::UiTheme::ColorU32(Runtime::UiTheme::EColor::Border, 0.85f), 6.0f); + + ImGui::SetCursorScreenPos(ImVec2(cardPos.x + 10.0f, cardPos.y + 6.0f)); + ImGui::TextColored(colHeader, "%s", title); + + ImGui::SetCursorScreenPos(ImVec2(cardPos.x + 10.0f, cardPos.y + 22.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, valueColor); + ImGui::Text("%s", value); + ImGui::PopStyleColor(); - ImGui::Separator(); + ImGui::SetCursorScreenPos(ImVec2(cardPos.x + 10.0f, cardPos.y + height - 26.0f)); + Runtime::UiTheme::Sparkline(samples, count, ImVec2(width - 20.0f, 22.0f), sparkColor); + + ImGui::SetCursorScreenPos(ImVec2(cardPos.x + width, cardPos.y)); + ImGui::Dummy(ImVec2(width, height)); + }; + + const ImVec4 fpsColor = statistics.FrameRate > 55.0f ? colGood + : (statistics.FrameRate > 30.0f ? colWarn : colBad); + const std::string fpsText = fmt::format("{:.0f} FPS", statistics.FrameRate); + const std::string ftText = fmt::format("{:.2f} ms", statistics.FrameTime); + + DrawCard("Frame Rate", fpsText.c_str(), fpsColor, + orderedFps.data(), orderedCount, colGood, halfWidth); + ImGui::SameLine(0.0f, gap); + DrawCard("Frame Time", ftText.c_str(), colVal, + orderedFt.data(), orderedCount, Runtime::UiTheme::Color(Runtime::UiTheme::EColor::Blue), halfWidth); + ImGui::Dummy(ImVec2(0.0f, 8.0f)); + } - // GPU Stats - auto& gpuDrivenStat = NextEngine::GetInstance()->GetScene().GetGpuDrivenStat(); - uint32_t instanceCount = gpuDrivenStat.ProcessedCount - gpuDrivenStat.CulledCount; - uint32_t triangleCount = gpuDrivenStat.TriangleCount - gpuDrivenStat.CulledTriangleCount; + auto& gpuDrivenStat = NextEngine::GetInstance()->GetScene().GetGpuDrivenStat(); + const uint32_t instanceCount = gpuDrivenStat.ProcessedCount > gpuDrivenStat.CulledCount + ? gpuDrivenStat.ProcessedCount - gpuDrivenStat.CulledCount + : 0; + const uint32_t triangleCount = gpuDrivenStat.TriangleCount > gpuDrivenStat.CulledTriangleCount + ? gpuDrivenStat.TriangleCount - gpuDrivenStat.CulledTriangleCount + : 0; + + BeginCard("##ProfilerSceneStatsCard", 156.0f); + ImGui::TextColored(colHeader, "Scene Stats"); + ImGui::Dummy(ImVec2(0.0f, 4.0f)); + 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(); + } - 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()); + const uint32_t mainTasks = TaskCoordinator::GetInstance()->GetMainTaskCount(); + const uint32_t lowTasks = TaskCoordinator::GetInstance()->GetParralledTaskCount(); + const uint32_t completeTasks = TaskCoordinator::GetInstance()->GetComleteTaskQueueCount(); + LabelVal("Tasks:", "%d / %d / %d", mainTasks, lowTasks, completeTasks); + EndCard(); + ImGui::Dummy(ImVec2(0.0f, 8.0f)); - uint32_t mainTasks = TaskCoordinator::GetInstance()->GetMainTaskCount(); - uint32_t lowTasks = TaskCoordinator::GetInstance()->GetParralledTaskCount(); - uint32_t completeTasks = TaskCoordinator::GetInstance()->GetComleteTaskQueueCount(); - LabelVal("Tasks:", "%d / %d / %d", mainTasks, lowTasks, completeTasks); + struct TimingRow + { + std::string name; + int depth = 0; + float average = 0.0f; + float minimum = 0.0f; + float maximum = 0.0f; + uint32_t displayOrder = 0; + bool active = true; + }; - ImGui::Separator(); + constexpr double timingHistoryWindowSeconds = 2.0; + constexpr double timingStaleSeconds = 3.0; + const double now = ImGui::GetTime(); - // Timers - if (gpuTimer) + auto BuildTimingRows = [&](const std::vector& times, + std::unordered_map& historyMap) + { + uint32_t currentDisplayOrder = 0; + for (const auto& time : times) { - struct TimingRow + const std::string& historyKey = time.stableKey; + auto historyIter = historyMap.try_emplace(historyKey).first; + auto& history = historyIter->second; + + history.displayOrder = currentDisplayOrder++; + history.displayName = time.name; + history.depth = time.depth; + history.lastSeenTime = now; + history.samples.push_back({now, time.milliseconds}); + + while (!history.samples.empty() && + now - history.samples.front().sampleTime > timingHistoryWindowSeconds) { - std::string name; - int depth = 0; - float average = 0.0f; - float minimum = 0.0f; - float maximum = 0.0f; - uint32_t displayOrder = 0; - bool active = true; - }; - - constexpr double timingHistoryWindowSeconds = 2.0; - constexpr double timingStaleSeconds = 3.0; - const double now = ImGui::GetTime(); + history.samples.pop_front(); + } - auto BuildTimingRows = [&](const std::vector& times, - std::unordered_map& historyMap) + float sum = 0.0f; + float minimum = 1000000.0f; + float maximum = 0.0f; + for (const auto& sample : history.samples) { - uint32_t currentDisplayOrder = 0; - for (const auto& time : times) - { - const std::string& name = time.name; - const float ms = time.milliseconds; - const int depth = time.depth; - const std::string& historyKey = time.stableKey; - auto historyIter = historyMap.try_emplace(historyKey).first; - auto& history = historyIter->second; - - history.displayOrder = currentDisplayOrder++; - history.displayName = name; - history.depth = depth; - history.lastSeenTime = now; - history.samples.push_back({now, ms}); - - while (!history.samples.empty() && - now - history.samples.front().sampleTime > timingHistoryWindowSeconds) - { - history.samples.pop_front(); - } - - float sum = 0.0f; - float minimum = 1000000.0f; - float maximum = 0.0f; - for (const auto& sample : history.samples) - { - sum += sample.milliseconds; - minimum = std::min(minimum, sample.milliseconds); - maximum = std::max(maximum, sample.milliseconds); - } - - const float average = history.samples.empty() ? ms : sum / static_cast(history.samples.size()); - history.average = average; - history.minimum = minimum; - history.maximum = maximum; - } + sum += sample.milliseconds; + minimum = std::min(minimum, sample.milliseconds); + maximum = std::max(maximum, sample.milliseconds); + } - for (auto iter = historyMap.begin(); iter != historyMap.end();) - { - auto& history = iter->second; - while (!history.samples.empty() && - now - history.samples.front().sampleTime > timingHistoryWindowSeconds) - { - history.samples.pop_front(); - } - - if (now - iter->second.lastSeenTime > timingStaleSeconds) - { - iter = historyMap.erase(iter); - } - else - { - ++iter; - } - } + history.average = history.samples.empty() ? time.milliseconds : sum / static_cast(history.samples.size()); + history.minimum = minimum; + history.maximum = maximum; + } - std::vector timingRows; - timingRows.reserve(historyMap.size()); - for (const auto& [key, history] : historyMap) - { - timingRows.push_back( - {history.displayName, - history.depth, - history.average, - history.minimum, - history.maximum, - history.displayOrder, - now - history.lastSeenTime <= 0.1}); - } - std::sort(timingRows.begin(), timingRows.end(), [](const TimingRow& lhs, const TimingRow& rhs) - { - if (lhs.displayOrder != rhs.displayOrder) - { - return lhs.displayOrder < rhs.displayOrder; - } - if (lhs.active != rhs.active) - { - return lhs.active; - } - return lhs.name < rhs.name; - }); - return timingRows; - }; + for (auto iter = historyMap.begin(); iter != historyMap.end();) + { + auto& history = iter->second; + while (!history.samples.empty() && + now - history.samples.front().sampleTime > timingHistoryWindowSeconds) + { + history.samples.pop_front(); + } - auto DrawTimingSection = [&](const char* label, const char* tableId, const std::vector& timingRows, - const ImVec4& rootBarColor, const ImVec4& childBarColor) + if (now - iter->second.lastSeenTime > timingStaleSeconds) { - float totalTime = 0.0f; - for (const auto& row : timingRows) - { - if (row.depth == 0) - { - totalTime += row.average; - } - } + iter = historyMap.erase(iter); + } + else + { + ++iter; + } + } + + std::vector timingRows; + timingRows.reserve(historyMap.size()); + for (const auto& [key, history] : historyMap) + { + timingRows.push_back({history.displayName, + history.depth, + history.average, + history.minimum, + history.maximum, + history.displayOrder, + now - history.lastSeenTime <= 0.1}); + } + std::sort(timingRows.begin(), timingRows.end(), [](const TimingRow& lhs, const TimingRow& rhs) + { + if (lhs.displayOrder != rhs.displayOrder) + { + return lhs.displayOrder < rhs.displayOrder; + } + if (lhs.active != rhs.active) + { + return lhs.active; + } + return lhs.name < rhs.name; + }); + return timingRows; + }; - ImGui::TextColored(colHeader, "%s (avg %.2fms / %.1fs):", label, totalTime, - timingHistoryWindowSeconds); + auto DrawTimingSection = [&](const char* label, const char* tableId, const std::vector& timingRows) + { + float totalTime = 0.0f; + for (const auto& row : timingRows) + { + if (row.depth == 0) + { + totalTime += row.average; + } + } - if (ImGui::BeginTable(tableId, 5, - ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | - ImGuiTableFlags_SizingFixedFit)) - { - ImGui::TableSetupColumn("Pass", ImGuiTableColumnFlags_WidthFixed, 150.0f); - ImGui::TableSetupColumn("Avg", ImGuiTableColumnFlags_WidthFixed, 52.0f); - ImGui::TableSetupColumn("Min", ImGuiTableColumnFlags_WidthFixed, 52.0f); - ImGui::TableSetupColumn("Max", ImGuiTableColumnFlags_WidthFixed, 52.0f); - ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 74.0f); - ImGui::TableHeadersRow(); - - for (const auto& row : timingRows) - { - const float ratio = totalTime > 0.001f ? row.average / totalTime : 0.0f; - const ImVec4 rowColor = row.depth == 0 ? colVal : colLabel; - - ImGui::TableNextRow(); - ImGui::PushStyleVar(ImGuiStyleVar_Alpha, row.active ? 1.0f : 0.45f); - ImGui::TableNextColumn(); - ImGui::Indent(static_cast(row.depth) * 12.0f); - ImGui::TextColored(rowColor, "%s", row.name.c_str()); - ImGui::Unindent(static_cast(row.depth) * 12.0f); - - ImGui::TableNextColumn(); - ImGui::TextColored(rowColor, "%.2f", row.average); - ImGui::TableNextColumn(); - ImGui::TextColored(colLabel, "%.2f", row.minimum); - ImGui::TableNextColumn(); - 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); - ImGui::PopStyleVar(); - } - ImGui::EndTable(); - } - }; + ImGui::TextColored(colHeader, "%s (avg %.2fms / %.1fs)", label, totalTime, timingHistoryWindowSeconds); - const auto gpuTimingRows = BuildTimingRows(gpuTimer->FetchAllTimes(4), gpuTimeHistory_); - DrawTimingSection("GPU Time", "##GpuTimeTable", gpuTimingRows, - ImVec4(0.35f, 0.65f, 1.0f, 1.0f), ImVec4(0.2f, 0.7f, 0.35f, 1.0f)); + auto TimingBarColor = [&](float milliseconds) + { + if (milliseconds < 1.0f) + { + return colGood; + } + if (milliseconds < 4.0f) + { + return colWarn; + } + return colBad; + }; - const auto cpuTimingRows = BuildTimingRows(gpuTimer->FetchAllCpuTimes(5), cpuTimeHistory_); - DrawTimingSection("CPU Time", "##CpuTimeTable", cpuTimingRows, - ImVec4(0.85f, 0.58f, 0.25f, 1.0f), ImVec4(0.7f, 0.45f, 0.2f, 1.0f)); + if (ImGui::BeginTable(tableId, 5, + ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_SizingFixedFit)) + { + ImGui::TableSetupColumn("Pass", ImGuiTableColumnFlags_WidthFixed, 150.0f); + ImGui::TableSetupColumn("Avg (ms)", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableSetupColumn("Min (ms)", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableSetupColumn("Max (ms)", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableSetupColumn("Graph", ImGuiTableColumnFlags_WidthFixed, 74.0f); + ImGui::TableHeadersRow(); + + for (const auto& row : timingRows) + { + const float ratio = totalTime > 0.001f ? row.average / totalTime : 0.0f; + const ImVec4 rowColor = row.depth == 0 ? colVal : colLabel; + + ImGui::TableNextRow(); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, row.active ? 1.0f : 0.45f); + ImGui::TableNextColumn(); + ImGui::Indent(static_cast(row.depth) * 12.0f); + ImGui::TextColored(rowColor, "%s", row.name.c_str()); + ImGui::Unindent(static_cast(row.depth) * 12.0f); + + ImGui::TableNextColumn(); + ImGui::TextColored(rowColor, "%.2f", row.average); + ImGui::TableNextColumn(); + ImGui::TextColored(colLabel, "%.2f", row.minimum); + ImGui::TableNextColumn(); + ImGui::TextColored(colLabel, "%.2f", row.maximum); + ImGui::TableNextColumn(); + Runtime::UiTheme::DrawProgressBar(std::min(ratio, 1.0f), + TimingBarColor(row.average), + ImVec2(70.0f, ImGui::GetTextLineHeight())); + ImGui::PopStyleVar(); + } + ImGui::EndTable(); } + }; - ImGui::Separator(); - LabelVal("Frame:", "%d", statistics.TotalFrames); - LabelVal( - "Time:", "%s", - fmt::format("{:%H:%M:%S}", std::chrono::seconds(static_cast(statistics.RenderTime))).c_str()); + const float timingCardHeight = std::max(180.0f, ImGui::GetContentRegionAvail().y - 42.0f); + BeginCard("##ProfilerTimingCard", timingCardHeight, ImGuiWindowFlags_HorizontalScrollbar); + if (gpuTimer) + { + const auto gpuTimingRows = BuildTimingRows(gpuTimer->FetchAllTimes(4), gpuTimeHistory_); + DrawTimingSection("Pass Timing", "##GpuTimeTable", gpuTimingRows); + + const auto cpuTimingRows = BuildTimingRows(gpuTimer->FetchAllCpuTimes(5), cpuTimeHistory_); + if (!cpuTimingRows.empty()) + { + ImGui::Dummy(ImVec2(0.0f, 6.0f)); + Runtime::UiTheme::DrawThinSeparator(0.55f); + DrawTimingSection("CPU Time", "##CpuTimeTable", cpuTimingRows); + } } - ImGui::End(); + else + { + ImGui::TextColored(colLabel, "Timing data is unavailable."); + } + EndCard(); + + ImGui::Dummy(ImVec2(0.0f, 8.0f)); + LabelVal("Frame:", "%d", statistics.TotalFrames); + LabelVal("Time:", "%s", + fmt::format("{:%H:%M:%S}", std::chrono::seconds(static_cast(statistics.RenderTime))).c_str()); + + ImGui::EndChild(); + Runtime::UiTheme::EndFloatingPanel(); } void UserInterface::DrawIndicator(uint32_t frameCount) diff --git a/src/Runtime/Editor/UserInterface.hpp b/src/Runtime/Editor/UserInterface.hpp index 2d473a64..16b516d3 100644 --- a/src/Runtime/Editor/UserInterface.hpp +++ b/src/Runtime/Editor/UserInterface.hpp @@ -5,6 +5,7 @@ #include "Vulkan/DebugUtilities.hpp" #include "Vulkan/RenderingPipeline.hpp" #include +#include #include #include #include @@ -25,8 +26,9 @@ namespace Vulkan { class Window; class CommandPool; + class Buffer; class DepthBuffer; - class DescriptorPool; + class DeviceMemory; class FrameBuffer; class RenderPass; class SwapChain; @@ -87,8 +89,8 @@ class UserInterface final const Vulkan::DepthBuffer& depthBuffer); void OnDestroySurface(); - VkDescriptorSet RequestImTextureId(uint32_t globalTextureId); - VkDescriptorSet RequestImTextureByName(const std::string& name); + ImTextureID RequestImTextureId(uint32_t globalTextureId); + ImTextureID RequestImTextureByName(const std::string& name); struct FUiTextureHandle { @@ -106,8 +108,12 @@ 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: + struct FUiRenderBuffers; + NextEngine& GetEngine() {return *engine_;} void DrawOverlay(const Statistics& statistics, VulkanGpuTimer* gpuTimer); @@ -115,6 +121,29 @@ class UserInterface final void DrawConsoleWindow(); void RefreshConsoleMatches(size_t matchLimit); void DrawConsoleMatchPopup(float width, const char* popupId); + void InitializeRendererBackend(); + void ShutdownRendererBackend(); + void BeginRendererBackendFrame(); + void CreateUiPipeline(const Vulkan::SwapChain& swapChain); + void DestroyUiPipeline(); + void InitializeFontTexture(Vulkan::CommandPool& commandPool); + VkPipeline GetOrCreatePlatformViewportPipeline(VkRenderPass renderPass); + void CreatePlatformViewportWindow(ImGuiViewport* viewport); + void DestroyPlatformViewportWindow(ImGuiViewport* viewport); + void ResizePlatformViewportWindow(ImGuiViewport* viewport, ImVec2 size); + void RenderPlatformViewportWindow(ImGuiViewport* viewport); + void SwapPlatformViewportBuffers(ImGuiViewport* viewport); + static UserInterface* GetRendererBackendOwner(); + static void CreatePlatformViewportWindowCallback(ImGuiViewport* viewport); + static void DestroyPlatformViewportWindowCallback(ImGuiViewport* viewport); + static void ResizePlatformViewportWindowCallback(ImGuiViewport* viewport, ImVec2 size); + static void RenderPlatformViewportWindowCallback(ImGuiViewport* viewport, void* renderArg); + static void SwapPlatformViewportBuffersCallback(ImGuiViewport* viewport, void* renderArg); + void PrunePlatformViewportRenderBuffers(); + void RenderDrawData(ImDrawData* drawData, VkCommandBuffer commandBuffer, FUiRenderBuffers& renderBuffers, + VkExtent2D framebufferExtent, bool hdrOutput, VkPipeline pipeline); + static ImTextureID EncodeBindlessTextureId(uint32_t textureIndex); + static bool DecodeBindlessTextureId(ImTextureID textureId, uint32_t& outTextureIndex); static int ConsoleInputTextCallback(ImGuiInputTextCallbackData* data); int HandleConsoleInputTextCallback(ImGuiInputTextCallbackData* data); void DrawConsoleLogOutputInternal(const char* childId, const ImVec2& size, bool bordered); @@ -137,14 +166,25 @@ class UserInterface final uint32_t displayOrder = 0; }; - std::unique_ptr descriptorPool_; std::unique_ptr renderPass_; std::vector< Vulkan::FrameBuffer > uiFrameBuffers_; + struct FUiRenderBuffers + { + std::unique_ptr vertexBuffer; + std::unique_ptr vertexBufferMemory; + VkDeviceSize vertexBufferSize = 0; + }; + std::vector uiRenderBuffers_; + VkPipelineLayout uiPipelineLayout_ = VK_NULL_HANDLE; + VkPipeline uiPipeline_ = VK_NULL_HANDLE; + VkPipeline uiPlatformViewportPipeline_ = VK_NULL_HANDLE; + VkRenderPass uiPlatformViewportRenderPass_ = VK_NULL_HANDLE; UserSettings& userSettings_; - std::unordered_map imTextureIdMap_; + std::unordered_map> platformUiRenderBuffers_; std::unordered_set uiTextureLoadRequests_; std::unordered_map uiTexturePixelSizeCache_; + uint32_t fontTextureIndex_ = UINT32_MAX; std::vector< std::function > auxDrawRequest_; std::vector consoleHistory_; std::vector consoleMatches_; @@ -161,5 +201,11 @@ class UserInterface final std::unordered_map gpuTimeHistory_; std::unordered_map cpuTimeHistory_; + static constexpr int kOverlaySparklineSampleCount = 64; + std::array frameRateSamples_{}; + std::array frameTimeSamples_{}; + int overlaySampleCursor_ = 0; + int overlaySampleFilled_ = 0; + NextEngine* engine_; }; diff --git a/src/Runtime/Engine.cpp b/src/Runtime/Engine.cpp index 5da77029..834c148b 100644 --- a/src/Runtime/Engine.cpp +++ b/src/Runtime/Engine.cpp @@ -81,15 +81,47 @@ 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)) + + static_cast(Assets::ACGI_PAGE_COUNT) * Assets::ACGI_PAGE_COUNT * sizeof(Assets::PageIndex) + + 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 +148,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 +195,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(); @@ -298,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_); } @@ -308,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) { @@ -386,9 +439,16 @@ 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) + if (options_->ShaderHotReload) { shaderHotReloader_ = std::make_unique(); shaderHotReloader_->Initialize(*renderer_); @@ -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); @@ -1642,7 +1702,7 @@ void NextEngine::LoadScene(const FSceneLoadRequest& request) { scene_->CleanUp(); physicsEngine_->OnSceneDestroyed(); - Assets::GlobalTexturePool::GetInstance()->FreeNonSystemTextures(); + Assets::GlobalTexturePool::GetInstance()->FreeTransientTextures(); } LaunchLoadSceneTask( 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/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(); } } } diff --git a/src/Runtime/Utilities/GraphicsDebugPanel.hpp b/src/Runtime/Utilities/GraphicsDebugPanel.hpp index 5a0e62da..a6f545b8 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}, }}; @@ -71,7 +72,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 +83,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); 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]); diff --git a/src/Tests/Test_LDrawLoader.cpp b/src/Tests/Test_LDrawLoader.cpp index bcc39d06..11ddbe46 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; } @@ -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(), @@ -161,7 +166,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); 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; 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__ diff --git a/src/cmake/SourceFiles.cmake b/src/cmake/SourceFiles.cmake index e870532b..36cf3f65 100644 --- a/src/cmake/SourceFiles.cmake +++ b/src/cmake/SourceFiles.cmake @@ -41,15 +41,11 @@ file(GLOB_RECURSE src_files_thirdparty "ThirdParty/ImGuizmo/*.h" "ThirdParty/ImAnim/*.cpp" "ThirdParty/ImAnim/*.h" + "ThirdParty/imgui-custom/imgui_impl_sdl3_custom.cpp" + "ThirdParty/imgui-custom/imgui_impl_sdl3_custom.h" "ThirdParty/ozz/*.h" ) -# --- Custom ImGui Backend --- -file(GLOB_RECURSE src_files_customimgui - "ThirdParty/imgui-custom/*.cpp" - "ThirdParty/imgui-custom/*.h" -) - # --- Engine Core --- file(GLOB_RECURSE src_files_engine "Common/*.hpp" 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 diff --git a/tools/gnb/cmd/gnb/dashboard.go b/tools/gnb/cmd/gnb/dashboard.go new file mode 100644 index 00000000..dfcbb5df --- /dev/null +++ b/tools/gnb/cmd/gnb/dashboard.go @@ -0,0 +1,43 @@ +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, + Config: ctx.cfg, + }) + 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 9c468dc3..bf1bf6ac 100644 --- a/tools/gnb/cmd/gnb/main.go +++ b/tools/gnb/cmd/gnb/main.go @@ -57,6 +57,7 @@ func main() { root.AddCommand(newInfoCommand(ctx)) root.AddCommand(newDoctorCommand(ctx)) root.AddCommand(newSetupCommand(ctx)) + root.AddCommand(newDepsCommand(ctx)) root.AddCommand(newBuildCommand(ctx)) root.AddCommand(newRunCommand(ctx)) root.AddCommand(newTestCommand(ctx)) @@ -68,6 +69,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) @@ -138,8 +141,12 @@ func newDoctorCommand(ctx appContext) *cobra.Command { } } if runtime.GOOS == "windows" && os.Getenv("VULKAN_SDK") == "" { - console.Warn("missing VULKAN_SDK") - failed = true + if fetcher.DiscoverVulkanSDK(ctx.repoRoot, ctx.cfg) != "" { + console.Success("project Vulkan SDK") + } else { + console.Warn("missing Vulkan SDK (run `gnb setup`)") + failed = true + } } if err := platform.EnsureLinuxDesktopPackages(); err != nil { console.Warn("%s", err) @@ -193,6 +200,24 @@ func newSetupCommand(ctx appContext) *cobra.Command { return cmd } +func newDepsCommand(ctx appContext) *cobra.Command { + root := &cobra.Command{ + Use: "deps", + Short: "Fetch project-managed external toolchains", + } + + fetch := &cobra.Command{ + Use: "fetch [all|tsc|slang|vulkan|streamline]", + Short: "Fetch one or more external dependencies", + RunE: func(cmd *cobra.Command, args []string) error { + return fetcher.EnsureNamedExternal(ctx.repoRoot, ctx.cfg, args) + }, + } + + root.AddCommand(fetch) + return root +} + func newBuildCommand(ctx appContext) *cobra.Command { opts := cmakerun.BuildOptions{} skipSetup := false @@ -235,17 +260,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 +282,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 +291,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 { @@ -339,6 +421,9 @@ func newIOSCommand(ctx appContext) *cobra.Command { Use: "ios", Short: "Build iOS target with CMake preset", RunE: func(cmd *cobra.Command, args []string) error { + if err := fetcher.EnsureExternal(ctx.repoRoot, ctx.cfg); err != nil { + return err + } if err := fetcher.EnsureIOSExternal(ctx.repoRoot, ctx.cfg); err != nil { return err } 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") + } +} 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/config/config.go b/tools/gnb/internal/config/config.go index b6b2ff77..b177dfc6 100644 --- a/tools/gnb/internal/config/config.go +++ b/tools/gnb/internal/config/config.go @@ -33,6 +33,7 @@ type ExternalConfig struct { TSC TSCConfig `toml:"tsc"` MoltenVK ExternalURLConfig `toml:"moltenvk"` Slang PlatformURLs `toml:"slang"` + VulkanSDK VulkanSDKConfig `toml:"vulkansdk"` } type ExternalURLConfig struct { @@ -55,6 +56,11 @@ type PlatformURLs struct { MacOSArm64 string `toml:"macos_arm64"` } +type VulkanSDKConfig struct { + Version string `toml:"version"` + Root string `toml:"root"` +} + type PaksConfig struct { Repo string `toml:"repo"` ReleaseTag string `toml:"release_tag"` @@ -101,6 +107,12 @@ func Load(repoRoot string) (Config, error) { if cfg.Targets.Default == "" { cfg.Targets.Default = "gkNextRenderer" } + if cfg.External.VulkanSDK.Version == "" { + cfg.External.VulkanSDK.Version = "1.4.341.1" + } + if cfg.External.VulkanSDK.Root == "" { + cfg.External.VulkanSDK.Root = "external/VulkanSDK" + } return cfg, nil } diff --git a/tools/gnb/internal/dashboard/ansi.go b/tools/gnb/internal/dashboard/ansi.go new file mode 100644 index 00000000..1e3a2886 --- /dev/null +++ b/tools/gnb/internal/dashboard/ansi.go @@ -0,0 +1,359 @@ +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() + sawSGR := false + 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 + } + sawSGR = true + state.apply(params, &out) + } + state.closeAll(&out) + if !sawSGR { + return formatStructuredLogLine(line) + } + 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(`<span style="`) + if s.fg != "" { + sb.WriteString("color:") + sb.WriteString(s.fg) + sb.WriteByte(';') + } + if s.bg != "" { + sb.WriteString("background:") + sb.WriteString(s.bg) + sb.WriteByte(';') + } + if s.bold { + sb.WriteString("font-weight:600;") + } + if s.underline { + sb.WriteString("text-decoration:underline;") + } + sb.WriteString(`">`) + out.WriteString(sb.String()) + s.openSpans++ +} + +func (s *ansiState) closeAll(out *strings.Builder) { + for s.openSpans > 0 { + out.WriteString("</span>") + 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) + } +} + +func formatStructuredLogLine(line string) string { + timestamp, rest, ok := cutBracketToken(line) + if !ok { + return html.EscapeString(line) + } + level, rest, ok := cutBracketToken(strings.TrimLeft(rest, " ")) + if !ok { + return html.EscapeString(line) + } + levelStyle, ok := logLevelColor(level) + if !ok { + return html.EscapeString(line) + } + + rest = strings.TrimLeft(rest, " ") + source := "" + if token, remaining, ok := cutBracketToken(rest); ok { + source = token + rest = strings.TrimLeft(remaining, " ") + } + + var out strings.Builder + out.WriteString(`<span style="color:#6b7384">[`) + out.WriteString(html.EscapeString(timestamp)) + out.WriteString(`]</span> `) + out.WriteString(`<span style="color:`) + out.WriteString(levelStyle) + out.WriteString(`;font-weight:600">[`) + out.WriteString(html.EscapeString(level)) + out.WriteString(`]</span>`) + if source != "" { + out.WriteString(` <span style="color:#9aa3b2">[`) + out.WriteString(html.EscapeString(source)) + out.WriteString(`]</span>`) + } + if rest != "" { + out.WriteByte(' ') + out.WriteString(html.EscapeString(rest)) + } + return out.String() +} + +func cutBracketToken(s string) (token, rest string, ok bool) { + if !strings.HasPrefix(s, "[") { + return "", s, false + } + end := strings.IndexByte(s, ']') + if end < 0 { + return "", s, false + } + return s[1:end], s[end+1:], true +} + +func logLevelColor(level string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(level)) { + case "trace": + return "#6b7384", true + case "debug": + return "#93c5fd", true + case "info": + return "#22c55e", true + case "warning", "warn": + return "#eab308", true + case "error": + return "#ef4444", true + case "critical": + return "#f472b6", true + default: + return "", false + } +} diff --git a/tools/gnb/internal/dashboard/ansi_test.go b/tools/gnb/internal/dashboard/ansi_test.go new file mode 100644 index 00000000..6fc8e17d --- /dev/null +++ b/tools/gnb/internal/dashboard/ansi_test.go @@ -0,0 +1,48 @@ +package dashboard + +import ( + "strings" + "testing" +) + +func TestAnsiToHTMLFormatsStructuredSpdlogLine(t *testing.T) { + line := "[2026-04-09 00:31:54.985] [warning] [AIService.cpp:881] Failed to initialize provider: Gemini" + + got := ansiToHTML(line) + + if !strings.Contains(got, `<span style="color:#6b7384">[2026-04-09 00:31:54.985]</span>`) { + t.Fatalf("missing timestamp styling: %q", got) + } + if !strings.Contains(got, `<span style="color:#eab308;font-weight:600">[warning]</span>`) { + t.Fatalf("missing warning level styling: %q", got) + } + if !strings.Contains(got, `<span style="color:#9aa3b2">[AIService.cpp:881]</span>`) { + t.Fatalf("missing source styling: %q", got) + } + if !strings.Contains(got, `Failed to initialize provider: Gemini`) { + t.Fatalf("missing message text: %q", got) + } +} + +func TestAnsiToHTMLKeepsAnsiColorsWhenPresent(t *testing.T) { + line := "\x1b[31m[error]\x1b[0m fatal" + + got := ansiToHTML(line) + + if !strings.Contains(got, `<span style="color:#ef4444;">[error]</span> fatal`) { + t.Fatalf("expected ANSI-derived color output, got %q", got) + } + if strings.Contains(got, `font-weight:600">[error]`) { + t.Fatalf("unexpected structured log fallback for ANSI line: %q", got) + } +} + +func TestAnsiToHTMLFallsBackToEscapedPlainText(t *testing.T) { + line := "plain <text>" + + got := ansiToHTML(line) + + if got != "plain <text>" { + t.Fatalf("unexpected plain fallback output: %q", got) + } +} diff --git a/tools/gnb/internal/dashboard/handlers.go b/tools/gnb/internal/dashboard/handlers.go new file mode 100644 index 00000000..1b88120f --- /dev/null +++ b/tools/gnb/internal/dashboard/handlers.go @@ -0,0 +1,674 @@ +package dashboard + +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" +) + +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) + 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) +} + +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 + 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 { + 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, + ActiveTab: "todo", + } + 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 +} + +// 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: + 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) 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 { + 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("") + +// ----- 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 + extraArgs := strings.Fields(r.FormValue("extraArgs")) + spec, err = s.runJobSpec(target, extraArgs) + 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 + } + from := 0 + if raw := strings.TrimSpace(r.URL.Query().Get("from")); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + http.Error(w, "invalid stream offset", http.StatusBadRequest) + return + } + from = n + } + ch, snap := job.subscribe() + defer job.unsubscribe(ch) + if from > len(snap.Lines) { + from = len(snap.Lines) + } + if snap.Status != StatusRunning && from >= len(snap.Lines) { + w.WriteHeader(http.StatusNoContent) + 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) + + // 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 = `<div class="log-line">` + data + `</div>` + } + 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[from:] { + emit("line", line) + } + if snap.Status != StatusRunning { + emit("status", statusBadgeHTML(snap.Status, snap.ExitNote)) + emit("done", fmt.Sprintf("%d", snap.FinishedAt.Unix())) + 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, extraArgs []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, + Args: extraArgs, + 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..5130def3 --- /dev/null +++ b/tools/gnb/internal/dashboard/jobs.go @@ -0,0 +1,410 @@ +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() + donePayload := fmt.Sprintf("%d", j.finishedAt.Unix()) + j.exitNote = note + subs := make([]chan JobEvent, 0, len(j.subs)) + for ch := range j.subs { + subs = append(subs, ch) + } + j.mu.Unlock() + + // Append a completion summary line so the user doesn't miss the result. + switch status { + case StatusSuccess: + j.appendLine(`<span style="color:#22c55e;font-weight:600">✓ 完成 (` + note + `)</span>`) + case StatusFailed: + j.appendLine(`<span style="color:#ef4444;font-weight:600">✗ 失败 (` + note + `)</span>`) + case StatusCanceled: + j.appendLine(`<span style="color:#9aa3b2">⊘ 已取消</span>`) + } + + 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: donePayload}: + 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(`<span style="color:#9aa3b2">$ ` + escapeHTML(job.Command) + `</span>`) + + 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 = `<span style="color:#fca5a5">` + htmlLine + `</span>` + } + 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(`<span class="job-pill" style="color:`) + sb.WriteString(color) + sb.WriteString(`;border-color:`) + sb.WriteString(color) + sb.WriteString(`33;background:`) + sb.WriteString(color) + sb.WriteString(`14">`) + sb.WriteString(escapeHTML(label)) + if note != "" { + sb.WriteString(`<span class="job-pill-note">`) + sb.WriteString(escapeHTML(note)) + sb.WriteString(`</span>`) + } + sb.WriteString(`</span>`) + 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 new file mode 100644 index 00000000..1d3574aa --- /dev/null +++ b/tools/gnb/internal/dashboard/server.go @@ -0,0 +1,244 @@ +// 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" + "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 +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 + 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 +// 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, jobs: NewJobManager()}, 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") }, + "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 `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>` + case spec.SectionBacklog: + return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>` + case spec.SectionRecent: + return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="20 6 9 17 4 12"/></svg>` + } + return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>` + }, + "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(`<div class="log-line">`) + sb.WriteString(l) + sb.WriteString("</div>") + } + 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() + } + return formatElapsedDuration(end.Sub(s.StartedAt)) + }, + "emptyHint": func(k spec.SectionKind) string { + switch k { + case spec.SectionNext: + return "暂无任务(在左侧表单添加 →)" + case spec.SectionBacklog: + return "暂无最近完成的任务" + case spec.SectionRecent: + return "暂无最近完成的任务" + } + return "(暂无)" + }, + } +} + +func formatElapsedDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + totalSeconds := int(d / time.Second) + minutes := totalSeconds / 60 + seconds := totalSeconds % 60 + return fmt.Sprintf("%d min %d sec", minutes, seconds) +} + +// ensureStatusDir is unused but kept to make adding new mkdir-on-demand cases trivial. +var _ = os.MkdirAll diff --git a/tools/gnb/internal/dashboard/stream_test.go b/tools/gnb/internal/dashboard/stream_test.go new file mode 100644 index 00000000..fa07b51d --- /dev/null +++ b/tools/gnb/internal/dashboard/stream_test.go @@ -0,0 +1,163 @@ +package dashboard + +import ( + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestHandleJobStreamReturnsNoContentForCompletedCaughtUpClient(t *testing.T) { + server := &Server{jobs: NewJobManager()} + job := &Job{ + ID: "done-job", + Kind: JobBuild, + Target: "all", + Command: "cmake --build --preset windows", + status: StatusSuccess, + exitNote: "exit 0", + lines: []string{ + `<span>$ cmake --build --preset windows</span>`, + `done`, + }, + subs: map[chan JobEvent]struct{}{}, + } + server.jobs.jobs[job.ID] = job + + req := httptest.NewRequest("GET", "/jobs/"+job.ID+"/stream?from=2", nil) + req.SetPathValue("id", job.ID) + rec := httptest.NewRecorder() + + server.handleJobStream(rec, req) + + if rec.Code != 204 { + t.Fatalf("status = %d, want 204", rec.Code) + } + if body := rec.Body.String(); body != "" { + t.Fatalf("expected empty body for caught-up completed stream, got %q", body) + } +} + +func TestHandleJobStreamOnlyReplaysMissingLines(t *testing.T) { + server := &Server{jobs: NewJobManager()} + finishedAt := time.Unix(90, 0) + job := &Job{ + ID: "partial-job", + Kind: JobBuild, + Target: "all", + Command: "cmake --build --preset windows", + status: StatusSuccess, + finishedAt: finishedAt, + exitNote: "exit 0", + lines: []string{ + `<span>line-0</span>`, + `<span>line-1</span>`, + `<span>line-2</span>`, + }, + subs: map[chan JobEvent]struct{}{}, + } + server.jobs.jobs[job.ID] = job + + req := httptest.NewRequest("GET", "/jobs/"+job.ID+"/stream?from=1", nil) + req.SetPathValue("id", job.ID) + rec := httptest.NewRecorder() + + server.handleJobStream(rec, req) + + body := rec.Body.String() + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if strings.Contains(body, "line-0") { + t.Fatalf("unexpected replay of already-rendered line: %q", body) + } + if !strings.Contains(body, "line-1") || !strings.Contains(body, "line-2") { + t.Fatalf("missing expected replay lines: %q", body) + } + if !strings.Contains(body, "event: done\ndata: 90") { + t.Fatalf("missing finished timestamp in done event: %q", body) + } +} + +func TestLogPanelTemplateRendersBufferedLinesAndOnlyStreamsRunningJobs(t *testing.T) { + server, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + render := func(snap JobSnapshot) string { + var sb strings.Builder + if err := server.tpl.ExecuteTemplate(&sb, "log_panel", snap); err != nil { + t.Fatalf("ExecuteTemplate() error = %v", err) + } + return sb.String() + } + + runningHTML := render(JobSnapshot{ + ID: "running-job", + Kind: JobBuild, + Target: "all", + Status: StatusRunning, + Lines: []string{`<span>line-a</span>`, `<span>line-b</span>`}, + StartedAt: jobTime(), + }) + if !strings.Contains(runningHTML, `data-stream="/jobs/running-job/stream?from=2"`) { + t.Fatalf("running job missing stream URL with offset: %q", runningHTML) + } + if !strings.Contains(runningHTML, `data-started-at="1"`) { + t.Fatalf("running job missing started-at attribute: %q", runningHTML) + } + if !strings.Contains(runningHTML, `data-elapsed>`) || !strings.Contains(runningHTML, ` min `) || !strings.Contains(runningHTML, ` sec</span>`) { + t.Fatalf("running job missing formatted elapsed text: %q", runningHTML) + } + if !strings.Contains(runningHTML, `<div class="log-line"><span>line-a</span></div>`) { + t.Fatalf("running job missing buffered line render: %q", runningHTML) + } + + finishedHTML := render(JobSnapshot{ + ID: "finished-job", + Kind: JobBuild, + Target: "all", + Status: StatusSuccess, + ExitNote: "exit 0", + Lines: []string{`<span>line-x</span>`}, + StartedAt: jobTime(), + FinishedAt: jobTime().Add(125 * time.Second), + }) + if strings.Contains(finishedHTML, `data-stream="/jobs/finished-job/stream`) { + t.Fatalf("finished job should not keep stream endpoint attached: %q", finishedHTML) + } + if !strings.Contains(finishedHTML, `data-finished-at="126"`) { + t.Fatalf("finished job missing finished-at attribute: %q", finishedHTML) + } + if !strings.Contains(finishedHTML, `data-elapsed>2 min 5 sec</span>`) { + t.Fatalf("finished job missing formatted elapsed text: %q", finishedHTML) + } + if !strings.Contains(finishedHTML, `<div class="log-line"><span>line-x</span></div>`) { + t.Fatalf("finished job missing buffered line render: %q", finishedHTML) + } +} + +func TestFormatElapsedDuration(t *testing.T) { + tests := []struct { + name string + in time.Duration + want string + }{ + {name: "sub-second", in: 900 * time.Millisecond, want: "0 min 0 sec"}, + {name: "seconds only", in: 59 * time.Second, want: "0 min 59 sec"}, + {name: "minutes and seconds", in: 125 * time.Second, want: "2 min 5 sec"}, + {name: "negative", in: -3 * time.Second, want: "0 min 0 sec"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatElapsedDuration(tt.in); got != tt.want { + t.Fatalf("formatElapsedDuration(%v) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func jobTime() (t time.Time) { + return time.Unix(1, 0) +} diff --git a/tools/gnb/internal/dashboard/templates/layout.html b/tools/gnb/internal/dashboard/templates/layout.html new file mode 100644 index 00000000..fcc2e31f --- /dev/null +++ b/tools/gnb/internal/dashboard/templates/layout.html @@ -0,0 +1,1076 @@ +<!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 +
+ +
+ + + + + 工作流活跃 + {{.Status}} +
+ +
+
+ + {{.OS}} +
+
{{shortHash .Version}}
+
{{.Preset}}/amd64
+
{{userInitial}}
+
+
+ +
+ + + +
+ {{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}} +
+ +
+ + + + + diff --git a/tools/gnb/internal/dashboard/templates/partials.html b/tools/gnb/internal/dashboard/templates/partials.html new file mode 100644 index 00000000..06658bfb --- /dev/null +++ b/tools/gnb/internal/dashboard/templates/partials.html @@ -0,0 +1,684 @@ +{{define "tab_todo"}} +
+ + +
+ {{template "todo_panel" .}} +
+ +
+
点击任意任务查看详情(spec / journal / blocker)。
+
+
+{{end}} + + +{{define "todo_panel"}} +{{if gt .RecentSize 10}} +
+ 最近完成 已有 {{.RecentSize}} 条,建议在终端运行 gnb todo archive 归档。 +
+{{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}} +
+ {{else}} +
{{emptyHint .Kind}}
+ {{end}} +
+ {{end}} + +
+

+ + + + 最新 JOURNAL +

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

+ {{if .Task.Type}}{{.Task.Type}}{{end}} + {{if .Task.Priority}}{{.Task.Priority}}{{end}} + {{.Task.Title}} +

+ +
+
+ + +
+
+ + {{if .HasJournal}} +
+
+ + + + journal +
+
{{.JournalBody}}
+
+ {{end}} + + {{if .HasSpec}} +
+
+ + + + spec +
+
{{.SpecBody}}
+
+ {{end}} + + {{if .HasBlocker}} +
+
+ + + + blocker +
+
{{.BlockerBody}}
+
+ {{end}} + + {{if and (not .HasSpec) (not .HasJournal) (not .HasBlocker)}} +
这个任务没有 spec / journal / blocker 文件。
+ {{end}} + +
+ 创建于 {{or .Task.Paren "—"}} + 更新时间 {{or .Task.Paren "—"}} +
+
+{{end}} + + +{{define "tab_build"}} +
+ + +
+
+ {{if .BuildVM.HasJob}} + {{template "log_panel" .BuildVM.Latest}} + {{else}} + {{template "log_panel_empty" "build"}} + {{end}} +
+
+
+{{end}} + + +{{define "tab_run"}} +
+ + + + + + + +
+
+ {{if .RunVM.HasJob}} + {{template "log_panel" .RunVM.Latest}} + {{else}} + {{template "log_panel_empty" "run"}} + {{end}} +
+
+
+ + + + +{{end}} + + +{{define "tab_test"}} +
+ + +
+
+ {{if .TestVM.HasJob}} + {{template "log_panel" .TestVM.Latest}} + {{else}} + {{template "log_panel_empty" "test"}} + {{end}} +
+
+
+{{end}} + + +{{define "log_panel"}} +
+
+
+ + + + {{.Kind}} +
+ {{.Target}} + +
+ {{jobElapsed .}} + {{jobStatusBadge .}} +
+
+ {{if jobIsRunning .}} + + {{end}} + +
+
+
{{jobLines .}}
+
+{{end}} + + +{{define "log_panel_empty"}} +
+
+
+ + + + {{.}} +
+ +
暂无任务
+
+
选择左侧 target 后点击按钮开始 —— 日志会在这里实时显示(带 ANSI 颜色)。
+
+{{end}} + + +{{define "task_edit_form"}} +
+
+ + +
+
+ + +
+
+ + +
+
+{{end}} diff --git a/tools/gnb/internal/dashboard/tests.go b/tools/gnb/internal/dashboard/tests.go new file mode 100644 index 00000000..2ccd4078 --- /dev/null +++ b/tools/gnb/internal/dashboard/tests.go @@ -0,0 +1,90 @@ +package dashboard + +import ( + "context" + "os" + "os/exec" + "strings" + "time" +) + +// TestCase is a single Catch2 test discovered via `--list-tests`. +type TestCase struct { + Name string + Tags string +} + +// ListCatch2Tests runs the given test binary with `--list-tests` and parses the +// output into a slice. We deliberately use the verbose form (not +// `--list-test-names-only`) because the latter is renamed/removed across +// Catch2 versions. The parser is permissive: lines indented with exactly 2 +// spaces are test names; the very next line indented 4+ spaces is tags. +func ListCatch2Tests(binPath string) ([]TestCase, error) { + if _, err := os.Stat(binPath); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binPath, "--list-tests") + cmd.Dir = filepathDir(binPath) + out, err := cmd.Output() + if err != nil { + return nil, err + } + return parseCatch2List(string(out)), nil +} + +func parseCatch2List(text string) []TestCase { + text = strings.ReplaceAll(text, "\r\n", "\n") + lines := strings.Split(text, "\n") + var out []TestCase + var pending *TestCase + for _, line := range lines { + if line == "" { + pending = nil + continue + } + // Footer "N test cases" / "N matching test cases". + trimmed := strings.TrimSpace(line) + if strings.HasSuffix(trimmed, "test cases") || strings.HasSuffix(trimmed, "test case") { + pending = nil + continue + } + indent := countLeadingSpaces(line) + switch { + case indent == 2: + out = append(out, TestCase{Name: strings.TrimSpace(line)}) + pending = &out[len(out)-1] + case indent >= 4 && pending != nil: + tags := strings.TrimSpace(line) + if pending.Tags == "" { + pending.Tags = tags + } else { + pending.Tags += " " + tags + } + default: + pending = nil + } + } + return out +} + +func countLeadingSpaces(s string) int { + n := 0 + for n < len(s) && s[n] == ' ' { + n++ + } + return n +} + +// filepathDir is a tiny helper to avoid pulling in path/filepath at package top +// just for one call site (the binary's directory is used as the test cwd so +// any --reporter file paths stay near the binary). +func filepathDir(p string) string { + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' || p[i] == '\\' { + return p[:i] + } + } + return "." +} diff --git a/tools/gnb/internal/fetcher/fetcher.go b/tools/gnb/internal/fetcher/fetcher.go index e5ad16ef..3ae2e805 100644 --- a/tools/gnb/internal/fetcher/fetcher.go +++ b/tools/gnb/internal/fetcher/fetcher.go @@ -4,12 +4,15 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "crypto/sha256" "fmt" "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" + "slices" "strings" "github.com/gameknife/gknextrenderer/tools/gnb/internal/config" @@ -17,31 +20,137 @@ import ( "github.com/gameknife/gknextrenderer/tools/gnb/internal/platform" ) +type vulkanDownloadSpec struct { + Version string + Platform string + ArchiveName string + DownloadURL string + SHAURL string +} + func EnsureExternal(repoRoot string, cfg config.Config) error { - if err := ensureTSC(repoRoot, cfg); err != nil { + return EnsureNamedExternal(repoRoot, cfg, nil) +} + +func EnsureNamedExternal(repoRoot string, cfg config.Config, names []string) error { + if len(names) == 0 { + names = []string{"tsc", "slang", "vulkan", "streamline"} + } + + for _, name := range names { + switch strings.ToLower(name) { + case "all": + return EnsureExternal(repoRoot, cfg) + case "tsc": + if err := ensureTSC(repoRoot, cfg); err != nil { + return err + } + case "slang": + if err := ensureSlang(repoRoot, cfg); err != nil { + return err + } + case "vulkan", "vulkansdk", "vulkan-sdk": + if err := EnsureVulkanSDK(repoRoot, cfg); err != nil { + return err + } + case "streamline": + if runtime.GOOS == "windows" { + if err := ensureArchive(repoRoot, cfg.External.Streamline.URL, filepath.Join(repoRoot, "external", "streamline-2.10.0"), "include/sl.h"); err != nil { + return err + } + } + default: + return fmt.Errorf("unknown external dependency: %s", name) + } + } + + return nil +} + +func EnsureVulkanSDK(repoRoot string, cfg config.Config) error { + if sdkRoot := DiscoverVulkanSDK(repoRoot, cfg); sdkRoot != "" { + return nil + } + + spec, err := resolveVulkanDownloadSpec(cfg.External.VulkanSDK.Version) + if err != nil { return err } - key := platform.PlatformKey() - if key == "linux" || key == "macos_arm64" || key == "macos_amd64" { - url := cfg.External.Slang.Linux - name := "slang-2025.6.1-linux-x86_64" - if key == "macos_arm64" { - url = cfg.External.Slang.MacOSArm64 - name = "slang-2025.6.1-macos-aarch64" - } else if key == "macos_amd64" { - url = cfg.External.Slang.MacOSAMD64 - name = "slang-2025.6.1-macos-x86_64" - } - if err := ensureArchive(repoRoot, url, filepath.Join(repoRoot, "external", name), "bin/slangc"); err != nil { + + installBase := externalPath(repoRoot, cfg.External.VulkanSDK.Root) + versionRoot := filepath.Join(installBase, spec.Version) + if sdkRoot := normalizeVulkanSDKRoot(versionRoot); sdkRoot != "" { + return writeCurrentVulkanSDKVersion(installBase, spec.Version) + } + + if err := os.MkdirAll(installBase, 0o755); err != nil { + return err + } + if err := os.RemoveAll(versionRoot); err != nil { + return err + } + + tmp := filepath.Join(repoRoot, "external", ".download-"+spec.ArchiveName) + if err := downloadAndVerify(spec, tmp); err != nil { + return err + } + defer os.Remove(tmp) + + switch runtime.GOOS { + case "darwin": + if err := installVulkanSDKMac(tmp, versionRoot); err != nil { return err } - } - if runtime.GOOS == "windows" { - if err := ensureArchive(repoRoot, cfg.External.Streamline.URL, filepath.Join(repoRoot, "external", "streamline-2.10.0"), "include/sl.h"); err != nil { + case "linux": + if err := extractTarXZ(tmp, installBase); err != nil { + return err + } + case "windows": + if err := installVulkanSDKWindows(tmp, versionRoot); err != nil { return err } + default: + return fmt.Errorf("automatic Vulkan SDK download is not supported on %s/%s", runtime.GOOS, runtime.GOARCH) } - return nil + + if sdkRoot := normalizeVulkanSDKRoot(versionRoot); sdkRoot == "" { + return fmt.Errorf("installed Vulkan SDK at %s but could not locate a usable SDK root", versionRoot) + } + + return writeCurrentVulkanSDKVersion(installBase, spec.Version) +} + +func DiscoverVulkanSDK(repoRoot string, cfg config.Config) string { + if sdkRoot := normalizeVulkanSDKRoot(os.Getenv("VULKAN_SDK")); sdkRoot != "" { + return sdkRoot + } + + installBase := externalPath(repoRoot, cfg.External.VulkanSDK.Root) + if currentVersion := readCurrentVulkanSDKVersion(installBase); currentVersion != "" { + if sdkRoot := normalizeVulkanSDKRoot(filepath.Join(installBase, currentVersion)); sdkRoot != "" { + return sdkRoot + } + } + + if cfg.External.VulkanSDK.Version != "" && cfg.External.VulkanSDK.Version != "latest" { + if sdkRoot := normalizeVulkanSDKRoot(filepath.Join(installBase, cfg.External.VulkanSDK.Version)); sdkRoot != "" { + return sdkRoot + } + } + + matches, err := filepath.Glob(filepath.Join(installBase, "*")) + if err != nil { + return "" + } + slices.Sort(matches) + slices.Reverse(matches) + for _, match := range matches { + if sdkRoot := normalizeVulkanSDKRoot(match); sdkRoot != "" { + return sdkRoot + } + } + + return "" } func EnsureIOSExternal(repoRoot string, cfg config.Config) error { @@ -58,6 +167,25 @@ func EnsureIOSExternal(repoRoot string, cfg config.Config) error { return ensureArchive(repoRoot, cfg.External.MoltenVK.URL, dstDir, "MoltenVK/MoltenVK/static/MoltenVK.xcframework/ios-arm64/libMoltenVK.a") } +func ensureSlang(repoRoot string, cfg config.Config) error { + key := platform.PlatformKey() + if key != "linux" && key != "macos_arm64" && key != "macos_amd64" { + return nil + } + + url := cfg.External.Slang.Linux + name := "slang-2025.6.1-linux-x86_64" + if key == "macos_arm64" { + url = cfg.External.Slang.MacOSArm64 + name = "slang-2025.6.1-macos-aarch64" + } else if key == "macos_amd64" { + url = cfg.External.Slang.MacOSAMD64 + name = "slang-2025.6.1-macos-x86_64" + } + + return ensureArchive(repoRoot, url, filepath.Join(repoRoot, "external", name), "bin/slangc") +} + func ensureTSC(repoRoot string, cfg config.Config) error { filename := "tsc" url := cfg.External.TSC.Linux @@ -240,3 +368,276 @@ func untar(src string, dst string, gz bool) error { } } } + +func resolveVulkanDownloadSpec(requestedVersion string) (vulkanDownloadSpec, error) { + spec := vulkanDownloadSpec{} + switch runtime.GOOS { + case "darwin": + spec.Platform = "mac" + spec.ArchiveName = "vulkan_sdk.zip" + case "linux": + spec.Platform = "linux" + spec.ArchiveName = "vulkan_sdk.tar.xz" + case "windows": + if runtime.GOARCH == "arm64" { + spec.Platform = "warm" + } else { + spec.Platform = "windows" + } + spec.ArchiveName = "vulkan_sdk.exe" + default: + return spec, fmt.Errorf("automatic Vulkan SDK download is not supported on %s/%s", runtime.GOOS, runtime.GOARCH) + } + + spec.Version = requestedVersion + if spec.Version == "" || spec.Version == "latest" { + version, err := fetchText(fmt.Sprintf("https://vulkan.lunarg.com/sdk/latest/%s.txt", spec.Platform)) + if err != nil { + return spec, err + } + spec.Version = strings.TrimSpace(version) + } + + spec.DownloadURL = fmt.Sprintf("https://sdk.lunarg.com/sdk/download/%s/%s/%s?Human=true", spec.Version, spec.Platform, spec.ArchiveName) + spec.SHAURL = fmt.Sprintf("https://sdk.lunarg.com/sdk/sha/%s/%s/%s.txt", spec.Version, spec.Platform, spec.ArchiveName) + return spec, nil +} + +func downloadAndVerify(spec vulkanDownloadSpec, dst string) error { + if err := Download(spec.DownloadURL, dst); err != nil { + return err + } + + expected, err := fetchExpectedSHA(spec.SHAURL) + if err != nil { + return err + } + actual, err := fileSHA256(dst) + if err != nil { + return err + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("Vulkan SDK checksum mismatch for %s: expected %s, got %s", spec.Version, expected, actual) + } + return nil +} + +func fetchExpectedSHA(url string) (string, error) { + text, err := fetchText(url) + if err != nil { + return "", err + } + fields := strings.Fields(text) + if len(fields) == 0 { + return "", fmt.Errorf("empty SHA response from %s", url) + } + return fields[0], nil +} + +func fetchText(url string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("request failed: %s", resp.Status) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(data), nil +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} + +func installVulkanSDKMac(archivePath string, versionRoot string) error { + stageDir := versionRoot + ".installer" + if err := os.RemoveAll(stageDir); err != nil { + return err + } + defer os.RemoveAll(stageDir) + + if err := unzip(archivePath, stageDir); err != nil { + return err + } + installer, err := findMacVulkanInstaller(stageDir) + if err != nil { + return err + } + + args := []string{ + "--root", versionRoot, + "--accept-licenses", + "--default-answer", + "--confirm-command", "install", + "copy_only=1", + } + return runCommand(stageDir, installer, args...) +} + +func installVulkanSDKWindows(installerPath string, versionRoot string) error { + args := []string{ + "--root", versionRoot, + "--accept-licenses", + "--default-answer", + "--confirm-command", "install", + "copy_only=1", + } + return runCommand(filepath.Dir(installerPath), installerPath, args...) +} + +func extractTarXZ(archivePath string, dst string) error { + return runCommand(filepath.Dir(archivePath), "tar", "-xf", archivePath, "-C", dst) +} + +func runCommand(dir string, name string, args ...string) error { + console.Command(name, args...) + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s failed: %w", name, err) + } + return nil +} + +func findMacVulkanInstaller(root string) (string, error) { + var fallback string + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() || filepath.Ext(path) != ".app" { + return nil + } + + exeDir := filepath.Join(path, "Contents", "MacOS") + entries, err := os.ReadDir(exeDir) + if err != nil { + return nil + } + + appBase := strings.TrimSuffix(filepath.Base(path), ".app") + for _, entry := range entries { + if entry.IsDir() { + continue + } + candidate := filepath.Join(exeDir, entry.Name()) + if entry.Name() == appBase { + fallback = candidate + return io.EOF + } + if fallback == "" { + fallback = candidate + } + } + return nil + }) + if err != nil && err != io.EOF { + return "", err + } + if fallback == "" { + return "", fmt.Errorf("could not find a Vulkan SDK installer app in %s", root) + } + return fallback, nil +} + +func normalizeVulkanSDKRoot(candidate string) string { + if candidate == "" { + return "" + } + + switch runtime.GOOS { + case "darwin": + if fileExists(filepath.Join(candidate, "include", "vulkan", "vulkan.h")) && + fileExists(filepath.Join(candidate, "lib", "libvulkan.dylib")) { + return candidate + } + macRoot := filepath.Join(candidate, "macOS") + if fileExists(filepath.Join(macRoot, "include", "vulkan", "vulkan.h")) && + fileExists(filepath.Join(macRoot, "lib", "libvulkan.dylib")) { + return macRoot + } + case "linux": + if fileExists(filepath.Join(candidate, "include", "vulkan", "vulkan.h")) && + hasAnyFile( + filepath.Join(candidate, "lib", "libvulkan.so"), + filepath.Join(candidate, "lib", "libvulkan.so.1"), + filepath.Join(candidate, "lib", "VulkanLoader", "lib", "libvulkan.so"), + filepath.Join(candidate, "lib", "VulkanLoader", "lib", "libvulkan.so.1"), + ) { + return candidate + } + archRoot := filepath.Join(candidate, "x86_64") + if fileExists(filepath.Join(archRoot, "include", "vulkan", "vulkan.h")) && + hasAnyFile( + filepath.Join(archRoot, "lib", "libvulkan.so"), + filepath.Join(archRoot, "lib", "libvulkan.so.1"), + filepath.Join(archRoot, "lib", "VulkanLoader", "lib", "libvulkan.so"), + filepath.Join(archRoot, "lib", "VulkanLoader", "lib", "libvulkan.so.1"), + ) { + return archRoot + } + case "windows": + if hasAnyFile( + filepath.Join(candidate, "Include", "vulkan", "vulkan.h"), + filepath.Join(candidate, "include", "vulkan", "vulkan.h"), + ) && hasAnyFile( + filepath.Join(candidate, "Lib", "vulkan-1.lib"), + filepath.Join(candidate, "lib", "vulkan-1.lib"), + ) { + return candidate + } + } + + return "" +} + +func writeCurrentVulkanSDKVersion(installBase string, version string) error { + return os.WriteFile(filepath.Join(installBase, ".current_version"), []byte(version+"\n"), 0o644) +} + +func readCurrentVulkanSDKVersion(installBase string) string { + data, err := os.ReadFile(filepath.Join(installBase, ".current_version")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func externalPath(repoRoot string, configured string) string { + if filepath.IsAbs(configured) { + return configured + } + return filepath.Join(repoRoot, configured) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func hasAnyFile(paths ...string) bool { + for _, path := range paths { + if fileExists(path) { + return true + } + } + return false +} 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..e1d9a0c8 --- /dev/null +++ b/tools/gnb/internal/spec/spec.go @@ -0,0 +1,401 @@ +// 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 = "" } } + +// EditTask updates the title / type / priority of a pending task. It refuses to +// touch tasks that are not StatusPending so we never silently rewrite a done or +// blocked entry's metadata. +func (d *Document) EditTask(id int, title, taskType, priority string) error { + t, idx, ok := d.FindTask(id) + if !ok { + return fmt.Errorf("task #%05d not found in %s", id, filepath.Base(d.Path)) + } + if t.Status != StatusPending { + return fmt.Errorf("task #%05d is not pending (status=%q); refusing to edit", id, string(t.Status)) + } + title = strings.TrimSpace(title) + if title == "" { + return fmt.Errorf("title must not be empty") + } + t.Title = title + t.Type = strings.ToUpper(strings.TrimSpace(taskType)) + t.Priority = strings.ToUpper(strings.TrimSpace(priority)) + d.Tasks[idx] = *t + d.Lines[t.LineNum-1] = FormatLine(*t) + return nil +} + +// 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") + } +} diff --git a/vcpkg.json b/vcpkg.json index 58684795..111aabb9 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -14,6 +14,10 @@ "features": ["vulkan"], "platform": "!(android)" }, + { + "name": "vulkan-headers", + "platform": "!(android | ios)" + }, { "name": "dbus", "default-features": false, @@ -24,8 +28,6 @@ "name": "imgui", "features": [ "freetype", - "sdl3-binding", - "vulkan-binding", "docking-experimental" ], "platform": "!(android)" @@ -65,6 +67,10 @@ "xlib", "wayland" ] + }, + { + "name": "vulkan-loader", + "platform": "windows" } ], "features": {