From ba774aaa3a6f75ebf4cec6b005928e604ea0020a Mon Sep 17 00:00:00 2001 From: LyeZinho Date: Tue, 22 Sep 2026 01:36:41 +0100 Subject: [PATCH 1/5] Add optional editor plugin SDK and move feature tooling out of Doppio core. Introduce dynamic plugin host with stable plugin IDs, Git/terrain/procedural/post-process plugins, modular post-process and procedural systems, and editor preview fixes including Ctrl+L command palette. Co-authored-by: Cursor --- CMakeLists.txt | 119 ++- apps/plugins/git_plugin/GitRunner.cpp | 131 +++ apps/plugins/git_plugin/GitRunner.hpp | 30 + apps/plugins/git_plugin/main.cpp | 241 +++++ apps/plugins/post_processing_plugin/main.cpp | 62 +- .../ProceduralBridge.cpp | 207 ++++ .../ProceduralBridge.hpp | 53 ++ .../ProceduralPluginServices.cpp | 125 +++ apps/plugins/procedural_tools_plugin/main.cpp | 226 +++++ apps/plugins/terrain_plugin/TerrainBridge.cpp | 147 +++ apps/plugins/terrain_plugin/TerrainBridge.hpp | 31 + .../terrain_plugin/TerrainPluginServices.cpp | 74 ++ apps/plugins/terrain_plugin/main.cpp | 146 +++ apps/runtime/main.cpp | 2 + .../caffeine-export.js | 71 ++ .../post_processing/scripts/custom_effect.lua | 15 + .../procedural/scripts/backrooms_director.lua | 15 + .../procedural/scripts/custom_director.lua | 33 + .../scripts/endless_race_director.lua | 15 + .../scripts/exploration_director.lua | 16 + docs/HISTORY.md | 10 + docs/MASTER.md | 7 +- docs/README.md | 23 + docs/editor/git-plugin.md | 24 + docs/editor/plugin-sdk.md | 94 ++ docs/plans/2026-09-21-rendering-roadmap.md | 205 ++++ .../2026-09-22-engine-pillars-roadmap.md | 334 +++++++ docs/rendering/post-processing.md | 81 ++ docs/terrain/procedural-tools-plugin.md | 119 +++ docs/terrain/terrain-plugin.md | 88 ++ docs/terrain/terrain-system.md | 102 ++ include/caffeine/plugin/PluginAPI.hpp | 90 ++ include/caffeine/plugin/PluginServices.hpp | 56 ++ .../postprocess/PostProcessEditorUI.hpp | 190 ++++ .../postprocess/PostProcessPresets.hpp | 67 ++ planning/caffeine-studio-ide.md | 2 + src/ecs/PostProcessComponents.hpp | 150 ++- src/ecs/ProceduralComponents.hpp | 38 + src/ecs/TerrainComponents.hpp | 7 +- src/editor/Camera2DPreviewRenderer.hpp | 205 ++++ src/editor/CameraPreviewPanel.cpp | 116 +-- src/editor/CameraPreviewPanel.hpp | 9 +- src/editor/CommandPalette.cpp | 2 +- src/editor/ComponentRegistry.cpp | 8 + src/editor/EditorContext.hpp | 3 + src/editor/EditorPaths.cpp | 52 +- src/editor/EditorPaths.hpp | 5 + src/editor/GameplayPreviewPanel.cpp | 50 +- src/editor/GameplayPreviewPanel.hpp | 4 + src/editor/HierarchyPanel.cpp | 2 +- src/editor/InspectorPanel.cpp | 40 +- src/editor/PluginAPI.hpp | 74 +- src/editor/PluginServiceRegistry.cpp | 40 + src/editor/PluginServiceRegistry.hpp | 31 + src/editor/PluginSystem.cpp | 251 +++-- src/editor/PluginSystem.hpp | 14 + src/editor/SceneEditor.cpp | 15 +- src/editor/SceneSerializer.cpp | 123 +-- src/editor/SceneViewport.cpp | 188 +++- src/editor/SceneViewport.hpp | 2 + src/editor/TerrainEditorPanel.cpp | 523 +---------- src/editor/TerrainEditorPanel.hpp | 54 -- .../TerrainGenerationBlobLegacy.hpp} | 64 +- src/procedural/ProceduralBindings.cpp | 192 ++++ src/procedural/ProceduralGenerator.cpp | 209 +++++ src/procedural/ProceduralGenerator.hpp | 21 + src/procedural/ProceduralNoise.cpp | 48 + src/procedural/ProceduralNoise.hpp | 12 + src/procedural/ProceduralProfiles.cpp | 71 ++ src/procedural/ProceduralProfiles.hpp | 16 + src/procedural/ProceduralStreamer.cpp | 181 ++++ src/procedural/ProceduralStreamer.hpp | 44 + src/procedural/ProceduralWorldSystem.cpp | 33 + src/procedural/ProceduralWorldSystem.hpp | 13 + src/render/PostProcessRenderer.hpp | 90 +- src/script/PostProcessBindings.cpp | 109 +++ src/script/ScriptEngine.cpp | 5 + src/terrain/TerrainCache.cpp | 41 +- src/terrain/TerrainCache.hpp | 6 +- src/terrain/TerrainCollisionMeshBuilder.cpp | 29 + src/terrain/TerrainCollisionMeshBuilder.hpp | 16 + src/terrain/TerrainHeightmapImporter.cpp | 134 +++ src/terrain/TerrainHeightmapImporter.hpp | 36 + src/terrain/TerrainMeshBuilder.cpp | 62 +- .../generation/GeologicalSimulator.cpp | 558 ----------- .../generation/GeologicalSimulator.hpp | 24 - src/terrain/generation/TerrainFractal.cpp | 276 ------ src/terrain/generation/TerrainFractal.hpp | 19 - src/terrain/generation/TerrainGenerator.cpp | 881 ------------------ src/terrain/generation/TerrainGenerator.hpp | 52 -- src/terrain/generation/TerrainHydrology.cpp | 345 ------- src/terrain/generation/TerrainHydrology.hpp | 36 - src/terrain/generation/TerrainNoise.cpp | 310 ------ src/terrain/generation/TerrainNoise.hpp | 53 -- 94 files changed, 5588 insertions(+), 3655 deletions(-) create mode 100644 apps/plugins/git_plugin/GitRunner.cpp create mode 100644 apps/plugins/git_plugin/GitRunner.hpp create mode 100644 apps/plugins/git_plugin/main.cpp create mode 100644 apps/plugins/procedural_tools_plugin/ProceduralBridge.cpp create mode 100644 apps/plugins/procedural_tools_plugin/ProceduralBridge.hpp create mode 100644 apps/plugins/procedural_tools_plugin/ProceduralPluginServices.cpp create mode 100644 apps/plugins/procedural_tools_plugin/main.cpp create mode 100644 apps/plugins/terrain_plugin/TerrainBridge.cpp create mode 100644 apps/plugins/terrain_plugin/TerrainBridge.hpp create mode 100644 apps/plugins/terrain_plugin/TerrainPluginServices.cpp create mode 100644 apps/plugins/terrain_plugin/main.cpp create mode 100644 assets/general-ultra-realistic-terrain-algorithm/caffeine-export.js create mode 100644 assets/plugins/post_processing/scripts/custom_effect.lua create mode 100644 assets/plugins/procedural/scripts/backrooms_director.lua create mode 100644 assets/plugins/procedural/scripts/custom_director.lua create mode 100644 assets/plugins/procedural/scripts/endless_race_director.lua create mode 100644 assets/plugins/procedural/scripts/exploration_director.lua create mode 100644 docs/editor/git-plugin.md create mode 100644 docs/editor/plugin-sdk.md create mode 100644 docs/plans/2026-09-21-rendering-roadmap.md create mode 100644 docs/plans/2026-09-22-engine-pillars-roadmap.md create mode 100644 docs/rendering/post-processing.md create mode 100644 docs/terrain/procedural-tools-plugin.md create mode 100644 docs/terrain/terrain-plugin.md create mode 100644 docs/terrain/terrain-system.md create mode 100644 include/caffeine/plugin/PluginAPI.hpp create mode 100644 include/caffeine/plugin/PluginServices.hpp create mode 100644 include/caffeine/postprocess/PostProcessEditorUI.hpp create mode 100644 include/caffeine/postprocess/PostProcessPresets.hpp create mode 100644 src/ecs/ProceduralComponents.hpp create mode 100644 src/editor/Camera2DPreviewRenderer.hpp create mode 100644 src/editor/PluginServiceRegistry.cpp create mode 100644 src/editor/PluginServiceRegistry.hpp rename src/{terrain/generation/TerrainGeneratorTypes.hpp => editor/TerrainGenerationBlobLegacy.hpp} (62%) create mode 100644 src/procedural/ProceduralBindings.cpp create mode 100644 src/procedural/ProceduralGenerator.cpp create mode 100644 src/procedural/ProceduralGenerator.hpp create mode 100644 src/procedural/ProceduralNoise.cpp create mode 100644 src/procedural/ProceduralNoise.hpp create mode 100644 src/procedural/ProceduralProfiles.cpp create mode 100644 src/procedural/ProceduralProfiles.hpp create mode 100644 src/procedural/ProceduralStreamer.cpp create mode 100644 src/procedural/ProceduralStreamer.hpp create mode 100644 src/procedural/ProceduralWorldSystem.cpp create mode 100644 src/procedural/ProceduralWorldSystem.hpp create mode 100644 src/script/PostProcessBindings.cpp create mode 100644 src/terrain/TerrainCollisionMeshBuilder.cpp create mode 100644 src/terrain/TerrainCollisionMeshBuilder.hpp create mode 100644 src/terrain/TerrainHeightmapImporter.cpp create mode 100644 src/terrain/TerrainHeightmapImporter.hpp delete mode 100644 src/terrain/generation/GeologicalSimulator.cpp delete mode 100644 src/terrain/generation/GeologicalSimulator.hpp delete mode 100644 src/terrain/generation/TerrainFractal.cpp delete mode 100644 src/terrain/generation/TerrainFractal.hpp delete mode 100644 src/terrain/generation/TerrainGenerator.cpp delete mode 100644 src/terrain/generation/TerrainGenerator.hpp delete mode 100644 src/terrain/generation/TerrainHydrology.cpp delete mode 100644 src/terrain/generation/TerrainHydrology.hpp delete mode 100644 src/terrain/generation/TerrainNoise.cpp delete mode 100644 src/terrain/generation/TerrainNoise.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 08e1787..834954f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,11 +81,15 @@ add_library(caffeine-core src/terrain/TerrainSplatPainter.cpp src/terrain/TerrainSerializer.cpp src/terrain/TerrainGpuTextures.cpp - src/terrain/generation/TerrainNoise.cpp - src/terrain/generation/TerrainFractal.cpp - src/terrain/generation/TerrainGenerator.cpp - src/terrain/generation/GeologicalSimulator.cpp - src/terrain/generation/TerrainHydrology.cpp + src/terrain/TerrainHeightmapImporter.cpp + src/terrain/TerrainCollisionMeshBuilder.cpp + src/procedural/ProceduralNoise.cpp + src/procedural/ProceduralProfiles.cpp + src/procedural/ProceduralGenerator.cpp + src/procedural/ProceduralStreamer.cpp + src/procedural/ProceduralWorldSystem.cpp + src/procedural/ProceduralBindings.cpp + src/script/PostProcessBindings.cpp src/render/SkyboxRenderer.cpp src/engine/AssetLoader.cpp src/editor/TransformGizmo.cpp @@ -98,12 +102,15 @@ add_library(caffeine-core target_include_directories(caffeine-core PUBLIC $ + $ $ $ $ ) target_compile_features(caffeine-core PUBLIC cxx_std_20) +# Required when editor plugins (.so) link this static library. +set_target_properties(caffeine-core PROPERTIES POSITION_INDEPENDENT_CODE ON) target_compile_definitions(caffeine-core PUBLIC CAFFEINE_SOURCE_DIR="${CMAKE_SOURCE_DIR}") @@ -456,6 +463,7 @@ with open(sys.argv[1], 'w') as f: src/editor/PrefabSystem.cpp src/editor/EntityDebugger.cpp src/editor/PluginSystem.cpp + src/editor/PluginServiceRegistry.cpp src/editor/TestUIMapper.cpp src/editor/TestRequestHandler.cpp src/editor/AssetCooker.cpp @@ -487,6 +495,7 @@ with open(sys.argv[1], 'w') as f: endif() target_include_directories(doppio PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/assets/scripts ${imgui_test_engine_SOURCE_DIR} ${nanosvg_SOURCE_DIR}/src) @@ -514,7 +523,7 @@ with open(sys.argv[1], 'w') as f: if(NOT MSVC) target_compile_options(caffeine-runtime PRIVATE -Wno-macro-redefined -Wno-cpp) endif() - add_dependencies(doppio caffeine-runtime hello_plugin post_processing_plugin) + add_dependencies(doppio caffeine-runtime) add_executable(build-smoke apps/build_smoke/main.cpp @@ -532,11 +541,16 @@ with open(sys.argv[1], 'w') as f: target_compile_options(build-smoke PRIVATE -Wno-macro-redefined -Wno-cpp) endif() + option(CAFFEINE_BUNDLE_DEFAULT_PLUGINS + "Copy built editor plugins next to doppio (off = lean IDE, install plugins manually)" + OFF) + add_library(hello_plugin SHARED apps/plugins/hello_plugin/main.cpp ) target_include_directories(hello_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/include ${imgui_SOURCE_DIR}) target_compile_definitions(hello_plugin PRIVATE CF_HAS_IMGUI=1 CAFFEINE_PLUGIN_EXPORTS=1) target_compile_features(hello_plugin PRIVATE cxx_std_20) @@ -552,6 +566,7 @@ with open(sys.argv[1], 'w') as f: ) target_include_directories(post_processing_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/include ${imgui_SOURCE_DIR}) target_compile_definitions(post_processing_plugin PRIVATE CF_HAS_IMGUI=1 CAFFEINE_PLUGIN_EXPORTS=1) target_compile_features(post_processing_plugin PRIVATE cxx_std_20) @@ -562,16 +577,94 @@ with open(sys.argv[1], 'w') as f: if(NOT MSVC) target_compile_options(post_processing_plugin PRIVATE -Wno-macro-redefined -Wno-cpp) endif() + add_library(terrain_plugin SHARED + apps/plugins/terrain_plugin/main.cpp + apps/plugins/terrain_plugin/TerrainBridge.cpp + apps/plugins/terrain_plugin/TerrainPluginServices.cpp + ) + target_include_directories(terrain_plugin PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/apps/plugins/terrain_plugin + ${imgui_SOURCE_DIR}) + # Symbols resolved at load time from doppio (-rdynamic + RTLD_GLOBAL). + target_link_libraries(terrain_plugin PRIVATE ${CMAKE_DL_LIBS}) + target_compile_definitions(terrain_plugin PRIVATE + CF_HAS_IMGUI=1 CAFFEINE_PLUGIN_EXPORTS=1 + CAFFEINE_SOURCE_DIR="${CMAKE_SOURCE_DIR}") + target_compile_features(terrain_plugin PRIVATE cxx_std_20) + set_target_properties(terrain_plugin PROPERTIES + PREFIX "" + OUTPUT_NAME "terrain_plugin" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins") + if(NOT MSVC) + target_compile_options(terrain_plugin PRIVATE -Wno-macro-redefined -Wno-cpp) + endif() + add_library(procedural_tools_plugin SHARED + apps/plugins/procedural_tools_plugin/main.cpp + apps/plugins/procedural_tools_plugin/ProceduralBridge.cpp + apps/plugins/procedural_tools_plugin/ProceduralPluginServices.cpp + ) + target_include_directories(procedural_tools_plugin PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/apps/plugins/procedural_tools_plugin + ${imgui_SOURCE_DIR}) + target_link_libraries(procedural_tools_plugin PRIVATE ${CMAKE_DL_LIBS}) + target_compile_definitions(procedural_tools_plugin PRIVATE + CF_HAS_IMGUI=1 CAFFEINE_PLUGIN_EXPORTS=1 + CAFFEINE_SOURCE_DIR="${CMAKE_SOURCE_DIR}") + target_compile_features(procedural_tools_plugin PRIVATE cxx_std_20) + set_target_properties(procedural_tools_plugin PROPERTIES + PREFIX "" + OUTPUT_NAME "procedural_tools_plugin" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins") + if(NOT MSVC) + target_compile_options(procedural_tools_plugin PRIVATE -Wno-macro-redefined -Wno-cpp) + endif() + + add_library(git_plugin SHARED + apps/plugins/git_plugin/main.cpp + apps/plugins/git_plugin/GitRunner.cpp + ) + target_include_directories(git_plugin PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/apps/plugins/git_plugin + ${imgui_SOURCE_DIR}) + target_compile_definitions(git_plugin PRIVATE CF_HAS_IMGUI=1 CAFFEINE_PLUGIN_EXPORTS=1) + target_compile_features(git_plugin PRIVATE cxx_std_20) + set_target_properties(git_plugin PROPERTIES + PREFIX "" + OUTPUT_NAME "git_plugin" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins") + if(NOT MSVC) + target_compile_options(git_plugin PRIVATE -Wno-macro-redefined -Wno-cpp) + endif() add_custom_command(TARGET doppio POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory $/plugins - COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ - $/plugins/$ - COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ - $/plugins/$ - COMMENT "Copying editor plugins to plugins folder") + COMMENT "Ensure plugins directory exists") + + if(CAFFEINE_BUNDLE_DEFAULT_PLUGINS) + add_custom_command(TARGET doppio POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $/plugins/$ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $/plugins/$ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $/plugins/$ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $/plugins/$ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $/plugins/$ + COMMENT "Copying default editor plugins to plugins folder") + endif() target_compile_definitions(doppio PRIVATE CF_HAS_IMGUI=1 diff --git a/apps/plugins/git_plugin/GitRunner.cpp b/apps/plugins/git_plugin/GitRunner.cpp new file mode 100644 index 0000000..e95f352 --- /dev/null +++ b/apps/plugins/git_plugin/GitRunner.cpp @@ -0,0 +1,131 @@ +#include "GitRunner.hpp" + +#include +#include +#include +#include + +namespace Caffeine::Plugins::Git { +namespace { + +std::string shellQuote(const std::string& value) { + std::string out = "'"; + for (char c : value) { + if (c == '\'') out += "'\\''"; + else out += c; + } + out += "'"; + return out; +} + +std::string joinArgs(const std::vector& args) { + std::ostringstream oss; + for (std::size_t i = 0; i < args.size(); ++i) { + if (i > 0) oss << ' '; + oss << shellQuote(args[i]); + } + return oss.str(); +} + +} // namespace + +GitResult runGit(const std::filesystem::path& repoRoot, const std::vector& args) { + GitResult result; + if (repoRoot.empty()) { + result.output = "Repository path is empty."; + return result; + } + + const std::string cmd = + "cd " + shellQuote(repoRoot.string()) + " && git " + joinArgs(args) + " 2>&1"; + std::array buffer {}; + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) { + result.output = "Failed to run git."; + return result; + } + + while (fgets(buffer.data(), static_cast(buffer.size()), pipe)) { + result.output += buffer.data(); + } + result.exitCode = pclose(pipe); + if (result.exitCode != 0) { + result.exitCode = 1; + } + return result; +} + +bool isGitRepository(const std::filesystem::path& repoRoot) { + return std::filesystem::exists(repoRoot / ".git"); +} + +std::string currentBranch(const std::filesystem::path& repoRoot) { + const GitResult result = runGit(repoRoot, {"branch", "--show-current"}); + if (result.exitCode != 0) return {}; + std::string branch = result.output; + while (!branch.empty() && (branch.back() == '\n' || branch.back() == '\r')) { + branch.pop_back(); + } + return branch; +} + +std::vector listStatus(const std::filesystem::path& repoRoot) { + std::vector files; + const GitResult result = runGit(repoRoot, {"status", "--porcelain"}); + if (result.exitCode != 0) return files; + + std::istringstream stream(result.output); + std::string line; + while (std::getline(stream, line)) { + if (line.size() < 4) continue; + GitFileStatus entry; + entry.index = line[0]; + entry.workTree = line[1]; + entry.path = line.substr(3); + entry.staged = entry.index != ' ' && entry.index != '?'; + files.push_back(std::move(entry)); + } + return files; +} + +std::vector listBranches(const std::filesystem::path& repoRoot) { + std::vector branches; + const GitResult result = runGit(repoRoot, {"branch", "--format=%(refname:short)"}); + if (result.exitCode != 0) return branches; + + std::istringstream stream(result.output); + std::string line; + while (std::getline(stream, line)) { + if (!line.empty()) branches.push_back(line); + } + return branches; +} + +std::vector listRemotes(const std::filesystem::path& repoRoot) { + std::vector remotes; + const GitResult result = runGit(repoRoot, {"remote"}); + if (result.exitCode != 0) return remotes; + + std::istringstream stream(result.output); + std::string line; + while (std::getline(stream, line)) { + if (!line.empty()) remotes.push_back(line); + } + return remotes; +} + +std::vector listLog(const std::filesystem::path& repoRoot, int maxEntries) { + std::vector entries; + const GitResult result = + runGit(repoRoot, {"log", "--oneline", "-n", std::to_string(maxEntries)}); + if (result.exitCode != 0) return entries; + + std::istringstream stream(result.output); + std::string line; + while (std::getline(stream, line)) { + if (!line.empty()) entries.push_back(line); + } + return entries; +} + +} // namespace Caffeine::Plugins::Git diff --git a/apps/plugins/git_plugin/GitRunner.hpp b/apps/plugins/git_plugin/GitRunner.hpp new file mode 100644 index 0000000..bb7c2b5 --- /dev/null +++ b/apps/plugins/git_plugin/GitRunner.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +namespace Caffeine::Plugins::Git { + +struct GitResult { + int exitCode = -1; + std::string output; +}; + +struct GitFileStatus { + char index = ' '; + char workTree = ' '; + std::string path; + bool staged = false; +}; + +GitResult runGit(const std::filesystem::path& repoRoot, const std::vector& args); + +bool isGitRepository(const std::filesystem::path& repoRoot); +std::string currentBranch(const std::filesystem::path& repoRoot); +std::vector listStatus(const std::filesystem::path& repoRoot); +std::vector listBranches(const std::filesystem::path& repoRoot); +std::vector listRemotes(const std::filesystem::path& repoRoot); +std::vector listLog(const std::filesystem::path& repoRoot, int maxEntries = 30); + +} // namespace Caffeine::Plugins::Git diff --git a/apps/plugins/git_plugin/main.cpp b/apps/plugins/git_plugin/main.cpp new file mode 100644 index 0000000..6283cc8 --- /dev/null +++ b/apps/plugins/git_plugin/main.cpp @@ -0,0 +1,241 @@ +#include "caffeine/plugin/PluginAPI.hpp" +#include "editor/EditorContext.hpp" +#include "GitRunner.hpp" + +#ifdef CF_HAS_IMGUI +#include +#endif + +#include +#include +#include +#include +#include + +namespace { + +class GitPlugin : public Caffeine::Editor::IPlugin { +public: + explicit GitPlugin(const Caffeine::Editor::PluginHostApi* host) : m_host(host) {} + + void OnLoad() override { + if (!m_host || !m_host->registerPanel) return; + m_host->registerPanel(m_host->editorContext, GetName(), "Git", + &GitPlugin::renderPanel, this); + if (m_host->registerMenuAction) { + m_host->registerMenuAction(m_host->editorContext, GetName(), "Version Control/Git Panel", + &GitPlugin::openPanelMenu, this); + } + } + + void OnUnload() override {} + void OnUpdate(float) override {} + + const char* GetName() const override { return "Git"; } + const char* GetVersion() const override { return "0.1.0"; } + const char* GetDescription() const override { + return "Optional Git project management — status, stage, commit, branches, remotes."; + } + +private: + static Caffeine::Editor::EditorContext* editorCtx(const Caffeine::Editor::PluginHostApi* host) { + return host ? static_cast(host->editorContext) : nullptr; + } + + static void openPanelMenu(void* userData) { + auto* self = static_cast(userData); + if (self->m_host && self->m_host->openPanel) { + self->m_host->openPanel(self->m_host->editorContext, "Git"); + } + } + + std::filesystem::path projectRoot() const { + if (!m_host || !m_host->getProjectRootPath) return {}; + char buffer[1024] = {}; + if (!m_host->getProjectRootPath(m_host->editorContext, buffer, sizeof(buffer))) { + return {}; + } + return std::filesystem::path(buffer); + } + + void refresh() { + m_repoRoot = projectRoot(); + m_branch = Caffeine::Plugins::Git::currentBranch(m_repoRoot); + m_files = Caffeine::Plugins::Git::listStatus(m_repoRoot); + m_branches = Caffeine::Plugins::Git::listBranches(m_repoRoot); + m_remotes = Caffeine::Plugins::Git::listRemotes(m_repoRoot); + m_log = Caffeine::Plugins::Git::listLog(m_repoRoot, 40); + m_lastOutput.clear(); + } + + void runGitCommand(const std::vector& args) { + const auto result = Caffeine::Plugins::Git::runGit(m_repoRoot, args); + m_lastOutput = result.output; + if (result.exitCode != 0 && m_host->logError) { + m_host->logError(m_host->editorContext, result.output.c_str()); + } else if (m_host->logInfo) { + m_host->logInfo(m_host->editorContext, "Git command completed."); + } + refresh(); + } + + static void renderPanel(void* userData) { +#ifdef CF_HAS_IMGUI + auto* self = static_cast(userData); + if (self->m_needsRefresh) { + self->refresh(); + self->m_needsRefresh = false; + } + + ImGui::TextWrapped( + "Optional Git panel — not part of Doppio core. Requires git in PATH and an open project."); + + if (ImGui::Button("Refresh")) self->refresh(); + + if (self->m_repoRoot.empty()) { + ImGui::TextDisabled("Open a project scene to detect repository root."); + return; + } + + if (!Caffeine::Plugins::Git::isGitRepository(self->m_repoRoot)) { + ImGui::TextColored(ImVec4(1, 0.5f, 0.3f, 1), "Not a git repository: %s", + self->m_repoRoot.string().c_str()); + if (ImGui::Button("git init")) { + self->runGitCommand({"init"}); + } + return; + } + + ImGui::Text("Repo: %s", self->m_repoRoot.string().c_str()); + ImGui::Text("Branch: %s", self->m_branch.empty() ? "(detached)" : self->m_branch.c_str()); + + if (ImGui::CollapsingHeader("Changes", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::Button("Stage all")) self->runGitCommand({"add", "-A"}); + ImGui::SameLine(); + if (ImGui::Button("Unstage all")) self->runGitCommand({"reset"}); + + ImGui::BeginChild("git_changes", ImVec2(0, 160), true); + for (std::size_t i = 0; i < self->m_files.size(); ++i) { + const auto& file = self->m_files[i]; + ImGui::PushID(static_cast(i)); + ImGui::Text("%c%c %s", file.index, file.workTree, file.path.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("Stage")) { + self->runGitCommand({"add", file.path}); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Diff")) { + self->m_lastOutput = + Caffeine::Plugins::Git::runGit(self->m_repoRoot, {"diff", file.path}).output; + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + + if (ImGui::CollapsingHeader("Commit", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::InputTextMultiline("Message", self->m_commitMessage, sizeof(self->m_commitMessage), + ImVec2(-1, 60)); + if (ImGui::Button("Commit") && self->m_commitMessage[0] != '\0') { + self->runGitCommand({"commit", "-m", self->m_commitMessage}); + self->m_commitMessage[0] = '\0'; + } + } + + if (ImGui::CollapsingHeader("Branches")) { + int branchIndex = 0; + for (std::size_t i = 0; i < self->m_branches.size(); ++i) { + if (self->m_branches[i] == self->m_branch) branchIndex = static_cast(i); + } + if (!self->m_branches.empty()) { + std::vector labels; + labels.reserve(self->m_branches.size()); + for (const auto& b : self->m_branches) labels.push_back(b.c_str()); + if (ImGui::Combo("Checkout", &branchIndex, labels.data(), + static_cast(labels.size()))) { + self->runGitCommand({"checkout", self->m_branches[branchIndex]}); + } + } + ImGui::InputText("New branch", self->m_newBranch, sizeof(self->m_newBranch)); + ImGui::SameLine(); + if (ImGui::Button("Create") && self->m_newBranch[0] != '\0') { + self->runGitCommand({"checkout", "-b", self->m_newBranch}); + self->m_newBranch[0] = '\0'; + } + } + + if (ImGui::CollapsingHeader("Remotes & sync")) { + for (const auto& remote : self->m_remotes) { + ImGui::BulletText("%s", remote.c_str()); + } + if (ImGui::Button("Fetch")) self->runGitCommand({"fetch", "--all", "--prune"}); + ImGui::SameLine(); + if (ImGui::Button("Pull")) self->runGitCommand({"pull"}); + ImGui::SameLine(); + if (ImGui::Button("Push")) self->runGitCommand({"push"}); + } + + if (ImGui::CollapsingHeader("History")) { + ImGui::BeginChild("git_log", ImVec2(0, 120), true); + for (const auto& entry : self->m_log) { + if (ImGui::Selectable(entry.c_str())) { + self->m_lastOutput = entry; + } + } + ImGui::EndChild(); + } + + if (ImGui::CollapsingHeader("Stash")) { + if (ImGui::Button("Stash push")) self->runGitCommand({"stash", "push", "-u"}); + ImGui::SameLine(); + if (ImGui::Button("Stash pop")) self->runGitCommand({"stash", "pop"}); + ImGui::SameLine(); + if (ImGui::Button("Stash list")) { + self->m_lastOutput = + Caffeine::Plugins::Git::runGit(self->m_repoRoot, {"stash", "list"}).output; + } + } + + if (!self->m_lastOutput.empty()) { + ImGui::Separator(); + ImGui::TextUnformatted("Output"); + ImGui::BeginChild("git_output", ImVec2(0, 120), true); + ImGui::TextUnformatted(self->m_lastOutput.c_str()); + ImGui::EndChild(); + } +#else + (void)userData; +#endif + } + + const Caffeine::Editor::PluginHostApi* m_host = nullptr; + std::filesystem::path m_repoRoot; + std::string m_branch; + std::vector m_files; + std::vector m_branches; + std::vector m_remotes; + std::vector m_log; + std::string m_lastOutput; + char m_commitMessage[512] = {}; + char m_newBranch[128] = {}; + bool m_needsRefresh = true; +}; + +GitPlugin* g_plugin = nullptr; + +} // namespace + +extern "C" { + +CAFFEINE_PLUGIN_API Caffeine::Editor::IPlugin* CreatePlugin( + const Caffeine::Editor::PluginHostApi* host) { + g_plugin = new GitPlugin(host); + return g_plugin; +} + +CAFFEINE_PLUGIN_API void DestroyPlugin(Caffeine::Editor::IPlugin* plugin) { + delete plugin; + g_plugin = nullptr; +} + +} // extern "C" diff --git a/apps/plugins/post_processing_plugin/main.cpp b/apps/plugins/post_processing_plugin/main.cpp index 8a69d73..0d3fb7f 100644 --- a/apps/plugins/post_processing_plugin/main.cpp +++ b/apps/plugins/post_processing_plugin/main.cpp @@ -1,4 +1,6 @@ -#include "editor/PluginAPI.hpp" +#include "caffeine/plugin/PluginAPI.hpp" +#include "caffeine/postprocess/PostProcessEditorUI.hpp" +#include "caffeine/postprocess/PostProcessPresets.hpp" #include "editor/EditorContext.hpp" #include "ecs/PostProcessComponents.hpp" #include "ecs/CameraComponents.hpp" @@ -21,8 +23,8 @@ class PostProcessingPlugin : public Caffeine::Editor::IPlugin { } if (m_host->registerMenuAction) { m_host->registerMenuAction(m_host->editorContext, GetName(), - "Effects/Add Post Process to Camera", - &PostProcessingPlugin::addToSelectedCamera, this); + "Effects/Add Post Process to Camera", + &PostProcessingPlugin::addToSelectedCamera, this); } if (m_host->registerComponentDrawer && m_host->getComponentTypeId) { const Caffeine::u32 typeId = @@ -35,13 +37,12 @@ class PostProcessingPlugin : public Caffeine::Editor::IPlugin { } void OnUnload() override {} - void OnUpdate(float) override {} const char* GetName() const override { return "Post Processing"; } - const char* GetVersion() const override { return "0.1.0"; } + const char* GetVersion() const override { return "0.2.0"; } const char* GetDescription() const override { - return "Camera post-processing stack: bloom, vignette, color grading and more."; + return "Modular post-processing stack: per-effect modules, Lua control, optional benchmarks."; } private: @@ -59,8 +60,9 @@ class PostProcessingPlugin : public Caffeine::Editor::IPlugin { } Caffeine::ECS::World& world = *ctx->activeWorld; if (!ctx->selectedEntity.isValid()) { - ImGui::TextWrapped("Select a camera entity, then use the presets below or add the " - "Post Process component from the Inspector."); + ImGui::TextWrapped( + "Select a camera entity. Combine effect modules below — Cinematic / Horror / Arcade " + "are optional benchmark looks, not game types."); return; } @@ -68,7 +70,7 @@ class PostProcessingPlugin : public Caffeine::Editor::IPlugin { ImGui::Text("Target: %s (entity %u)", Caffeine::Editor::getEntityName(world, e), e.id()); if (!world.has(e)) { - if (ImGui::Button("Add Post Process Component")) { + if (ImGui::Button("Add Post Process Stack")) { world.add(e); ctx->isDirty = true; } @@ -78,40 +80,21 @@ class PostProcessingPlugin : public Caffeine::Editor::IPlugin { auto* fx = world.get(e); if (!fx) return; - ImGui::Separator(); - ImGui::TextUnformatted("Presets"); - if (ImGui::Button("Cinematic")) applyPreset(*fx, 1.05f, 1.1f, 1.05f, 0.35f, 0.15f, 0.02f, 0.04f); - ImGui::SameLine(); - if (ImGui::Button("Horror")) applyPreset(*fx, 0.75f, 1.25f, 0.7f, 0.55f, 0.0f, 0.08f, 0.12f); - ImGui::SameLine(); - if (ImGui::Button("Arcade")) applyPreset(*fx, 1.2f, 1.15f, 1.3f, 0.1f, 0.25f, 0.0f, 0.0f); + ImGui::TextWrapped( + "Modular effect stack — enable and tune each module independently. GPU passes are " + "planned; editor preview uses approximate overlays."); - ImGui::Separator(); - if (ImGui::Checkbox("Enabled", &fx->enabled)) ctx->isDirty = true; - if (ImGui::SliderFloat("Exposure", &fx->exposure, 0.2f, 3.0f)) ctx->isDirty = true; - if (ImGui::SliderFloat("Vignette", &fx->vignette, 0.0f, 1.0f)) ctx->isDirty = true; - if (ImGui::SliderFloat("Bloom", &fx->bloom, 0.0f, 1.0f)) ctx->isDirty = true; - if (ImGui::SliderFloat("Chromatic", &fx->chromaticAberration, 0.0f, 1.0f)) { - ctx->isDirty = true; + if (ImGui::CollapsingHeader("Benchmark looks (optional)", ImGuiTreeNodeFlags_DefaultOpen)) { + if (Caffeine::PostProcess::EditorUI::drawBenchmarkPresets(*fx)) ctx->isDirty = true; } - if (ImGui::SliderFloat("Film Grain", &fx->filmGrain, 0.0f, 1.0f)) ctx->isDirty = true; + + ImGui::Separator(); + if (Caffeine::PostProcess::EditorUI::drawEffectStack(*fx)) ctx->isDirty = true; #else (void)userData; #endif } - static void applyPreset(Caffeine::ECS::PostProcessComponent& fx, float exposure, float contrast, - float saturation, float vignette, float bloom, float chroma, float grain) { - fx.enabled = true; - fx.exposure = exposure; - fx.contrast = contrast; - fx.saturation = saturation; - fx.vignette = vignette; - fx.bloom = bloom; - fx.chromaticAberration = chroma; - fx.filmGrain = grain; - } - static void addToSelectedCamera(void* userData) { auto* self = static_cast(userData); auto* ctx = editorCtx(self->m_host); @@ -131,7 +114,7 @@ class PostProcessingPlugin : public Caffeine::Editor::IPlugin { ctx->isDirty = true; } if (self->m_host->logInfo) { - self->m_host->logInfo(self->m_host->editorContext, "Post Process added to camera."); + self->m_host->logInfo(self->m_host->editorContext, "Post-process stack added to camera."); } } @@ -140,10 +123,7 @@ class PostProcessingPlugin : public Caffeine::Editor::IPlugin { (void)userData; auto* fx = static_cast(componentData); if (!fx) return; - ImGui::Checkbox("Enabled", &fx->enabled); - ImGui::SliderFloat("Exposure", &fx->exposure, 0.2f, 3.0f); - ImGui::SliderFloat("Vignette", &fx->vignette, 0.0f, 1.0f); - ImGui::SliderFloat("Bloom", &fx->bloom, 0.0f, 1.0f); + Caffeine::PostProcess::EditorUI::drawEffectStack(*fx); #else (void)componentData; (void)userData; diff --git a/apps/plugins/procedural_tools_plugin/ProceduralBridge.cpp b/apps/plugins/procedural_tools_plugin/ProceduralBridge.cpp new file mode 100644 index 0000000..4f7e55f --- /dev/null +++ b/apps/plugins/procedural_tools_plugin/ProceduralBridge.cpp @@ -0,0 +1,207 @@ +#include "ProceduralBridge.hpp" + +#include "ecs/CameraComponents.hpp" +#include "ecs/Components.hpp" +#include "ecs/MeshComponents.hpp" +#include "ecs/ProceduralComponents.hpp" +#include "ecs/TerrainComponents.hpp" +#include "procedural/ProceduralGenerator.hpp" +#include "procedural/ProceduralProfiles.hpp" +#include "procedural/ProceduralStreamer.hpp" +#include "script/ScriptTypes.hpp" +#include "terrain/TerrainCache.hpp" + +#include +#include + +namespace Caffeine::Editor { +namespace { + +std::filesystem::path engineSourceRoot() { +#ifdef CAFFEINE_SOURCE_DIR + return std::filesystem::path(CAFFEINE_SOURCE_DIR); +#else + return {}; +#endif +} + +bool copyFileIfMissing(const std::filesystem::path& src, const std::filesystem::path& dst) { + std::error_code ec; + if (std::filesystem::exists(dst, ec)) return true; + std::filesystem::create_directories(dst.parent_path(), ec); + std::filesystem::copy_file(src, dst, ec); + return !ec; +} + +f32 maxHeightForTerrainProfile(const char* terrainProfile) { + if (terrainProfile && std::strcmp(terrainProfile, "flat") == 0) { + return 4.0f; + } + return 32.0f; +} + +} // namespace + +std::filesystem::path ProceduralBridge::bundledScriptsDirectory() { + const auto fromSource = + engineSourceRoot() / "assets" / "plugins" / "procedural" / "scripts"; + if (!fromSource.empty() && std::filesystem::exists(fromSource)) { + return fromSource; + } + return std::filesystem::path("assets/plugins/procedural/scripts"); +} + +bool ProceduralBridge::installScriptTemplates(const std::filesystem::path& projectRoot, + std::string& errorOut) { + if (projectRoot.empty()) { + errorOut = "Project root is empty"; + return false; + } + + const std::filesystem::path src = bundledScriptsDirectory(); + if (!std::filesystem::exists(src)) { + errorOut = "Bundled procedural scripts not found: " + src.string(); + return false; + } + + const std::filesystem::path dst = projectRoot / "scripts" / "procedural"; + std::error_code ec; + std::filesystem::create_directories(dst, ec); + + for (const auto& entry : std::filesystem::directory_iterator(src)) { + if (!entry.is_regular_file()) continue; + if (entry.path().extension() != ".lua") continue; + if (!copyFileIfMissing(entry.path(), dst / entry.path().filename())) { + errorOut = "Failed to copy " + entry.path().filename().string(); + return false; + } + } + return true; +} + +ECS::Entity ProceduralBridge::addStreamingTerrain(ECS::World& world, f32 chunkSize, u32 viewRadius, + const char* terrainProfile, + std::string& errorOut) { + (void)errorOut; + const char* profile = terrainProfile && terrainProfile[0] ? terrainProfile : "hills"; + + ECS::Entity terrain = world.create("ProceduralTerrain"); + ECS::TerrainComponent tc; + tc.resolutionX = 257; + tc.resolutionZ = 257; + tc.worldSizeX = chunkSize * static_cast(viewRadius * 2 + 1); + tc.worldSizeZ = tc.worldSizeX; + tc.maxHeight = maxHeightForTerrainProfile(profile); + tc.useChunks = true; + terrain.add(tc); + Terrain::TerrainCache::instance().initializeEntity(world, terrain); + return terrain; +} + +ECS::Entity ProceduralBridge::addDirector(ECS::World& world, const ProceduralStreamingSetup& setup, + u32 terrainEntityId, std::string& errorOut) { + (void)errorOut; + ECS::Entity director = world.create("ProceduralDirector"); + + ECS::ProceduralWorldComponent proc; + proc.seed = setup.seed; + proc.chunkSize = setup.chunkSize; + proc.viewRadius = setup.viewRadius; + proc.terrainEntityId = terrainEntityId; + if (setup.terrainProfile) { + std::strncpy(proc.terrainProfile, setup.terrainProfile, sizeof(proc.terrainProfile) - 1); + } + if (setup.structureProfile) { + std::strncpy(proc.structureProfile, setup.structureProfile, + sizeof(proc.structureProfile) - 1); + } + if (setup.scriptPath && setup.scriptPath[0]) { + std::strncpy(proc.customScriptPath, setup.scriptPath, sizeof(proc.customScriptPath) - 1); + } + director.add(proc); + + Script::ScriptComponent script; + script.scriptPath = proc.customScriptPath; + director.add(script); + + if (setup.addCamera) { + ECS::Camera3DComponent cam; + director.add(cam); + ECS::CameraActiveComponent active; + active.is2D = false; + director.add(active); + ECS::Transform& t = director.getOrAdd(); + t.position = Vec3(0.0f, 24.0f, -48.0f); + } + + return director; +} + +bool ProceduralBridge::createStreamingSetup(ECS::World& world, const ProceduralStreamingSetup& setup, + ECS::Entity& outDirector, ECS::Entity& outTerrain, + std::string& errorOut) { + outTerrain = addStreamingTerrain(world, setup.chunkSize, setup.viewRadius, setup.terrainProfile, + errorOut); + outDirector = addDirector(world, setup, outTerrain.id(), errorOut); + + if (setup.bakeInitialChunks && + !bakeSimpleTerrain(world, outTerrain, setup.seed, setup.terrainProfile, errorOut)) { + return false; + } + + if (auto* procPtr = outDirector.get()) { + i32 cx = 0; + i32 cz = 0; + Procedural::ProceduralStreamer::instance().streamAround(world, outDirector, *procPtr, cx, + cz); + } + return true; +} + +bool ProceduralBridge::loadBenchmark(ECS::World& world, const char* benchmarkName, u32 seed, + f32 chunkSize, u32 viewRadius, ECS::Entity& outDirector, + ECS::Entity& outTerrain, std::string& errorOut) { + ProceduralStreamingSetup setup; + setup.seed = seed; + setup.chunkSize = chunkSize; + setup.viewRadius = viewRadius; + setup.bakeInitialChunks = true; + setup.addCamera = true; + + ECS::ProceduralWorldComponent proc; + char scriptPath[256] = {}; + Procedural::applyBenchmark(proc, benchmarkName, scriptPath, sizeof(scriptPath)); + setup.scriptPath = scriptPath; + setup.terrainProfile = proc.terrainProfile; + setup.structureProfile = proc.structureProfile; + + return createStreamingSetup(world, setup, outDirector, outTerrain, errorOut); +} + +bool ProceduralBridge::createDirector(ECS::World& world, const char* preset, u32 seed, f32 chunkSize, + u32 viewRadius, ECS::Entity& outDirector, ECS::Entity& outTerrain, + std::string& errorOut) { + return loadBenchmark(world, preset, seed, chunkSize, viewRadius, outDirector, outTerrain, + errorOut); +} + +bool ProceduralBridge::bakeSimpleTerrain(ECS::World& world, ECS::Entity terrain, u32 seed, + const char* terrainProfile, std::string& errorOut) { + (void)errorOut; + ECS::ProceduralWorldComponent settings; + settings.seed = seed; + settings.chunkSize = 64.0f; + if (terrainProfile) { + std::strncpy(settings.terrainProfile, terrainProfile, sizeof(settings.terrainProfile) - 1); + } + + const u32 radius = 2; + for (i32 cz = -radius; cz <= radius; ++cz) { + for (i32 cx = -radius; cx <= radius; ++cx) { + Procedural::ProceduralGenerator::generateTerrainChunk(world, terrain, cx, cz, settings); + } + } + return true; +} + +} // namespace Caffeine::Editor diff --git a/apps/plugins/procedural_tools_plugin/ProceduralBridge.hpp b/apps/plugins/procedural_tools_plugin/ProceduralBridge.hpp new file mode 100644 index 0000000..5e10a56 --- /dev/null +++ b/apps/plugins/procedural_tools_plugin/ProceduralBridge.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "core/Types.hpp" +#include "ecs/Entity.hpp" +#include "ecs/World.hpp" + +#include +#include + +namespace Caffeine::Editor { + +struct ProceduralStreamingSetup { + u32 seed = 42; + f32 chunkSize = 64.0f; + u32 viewRadius = 4; + const char* scriptPath = "scripts/procedural/custom_director.lua"; + const char* terrainProfile = "hills"; + const char* structureProfile = "none"; + bool bakeInitialChunks = true; + bool addCamera = true; +}; + +class ProceduralBridge { +public: + static std::filesystem::path bundledScriptsDirectory(); + + static bool installScriptTemplates(const std::filesystem::path& projectRoot, + std::string& errorOut); + + static bool createStreamingSetup(ECS::World& world, const ProceduralStreamingSetup& setup, + ECS::Entity& outDirector, ECS::Entity& outTerrain, + std::string& errorOut); + + static bool loadBenchmark(ECS::World& world, const char* benchmarkName, u32 seed, f32 chunkSize, + u32 viewRadius, ECS::Entity& outDirector, ECS::Entity& outTerrain, + std::string& errorOut); + + /// @deprecated Use createStreamingSetup or loadBenchmark. + static bool createDirector(ECS::World& world, const char* preset, u32 seed, f32 chunkSize, + u32 viewRadius, ECS::Entity& outDirector, ECS::Entity& outTerrain, + std::string& errorOut); + + static ECS::Entity addStreamingTerrain(ECS::World& world, f32 chunkSize, u32 viewRadius, + const char* terrainProfile, std::string& errorOut); + + static ECS::Entity addDirector(ECS::World& world, const ProceduralStreamingSetup& setup, + u32 terrainEntityId, std::string& errorOut); + + static bool bakeSimpleTerrain(ECS::World& world, ECS::Entity terrain, u32 seed, + const char* terrainProfile, std::string& errorOut); +}; + +} // namespace Caffeine::Editor diff --git a/apps/plugins/procedural_tools_plugin/ProceduralPluginServices.cpp b/apps/plugins/procedural_tools_plugin/ProceduralPluginServices.cpp new file mode 100644 index 0000000..f7837d0 --- /dev/null +++ b/apps/plugins/procedural_tools_plugin/ProceduralPluginServices.cpp @@ -0,0 +1,125 @@ +#include "caffeine/plugin/PluginAPI.hpp" +#include "caffeine/plugin/PluginServices.hpp" +#include "editor/EditorContext.hpp" +#include "ProceduralBridge.hpp" + +#include +#include + +namespace { + +bool fillSetupResponse(void* response, std::size_t responseSize, Caffeine::ECS::Entity director, + Caffeine::ECS::Entity terrain) { + if (response && responseSize >= sizeof(Caffeine::Editor::ProceduralSetupResponse)) { + auto* out = static_cast(response); + out->directorEntityId = director.id(); + out->terrainEntityId = terrain.id(); + } + return true; +} + +bool proceduralInstallScripts(void* editorContext, const void* request, std::size_t requestSize, + void* response, std::size_t responseSize) { + (void)request; + (void)requestSize; + (void)response; + (void)responseSize; + if (!editorContext) return false; + auto* ctx = static_cast(editorContext); + + std::filesystem::path projectRoot; + if (!ctx->currentScenePath.empty()) { + projectRoot = std::filesystem::path(ctx->currentScenePath).parent_path().parent_path(); + } + if (projectRoot.empty()) return false; + + std::string error; + if (!Caffeine::Editor::ProceduralBridge::installScriptTemplates(projectRoot, error)) { + std::cerr << "[ProceduralPlugin] installScripts: " << error << '\n'; + return false; + } + return true; +} + +bool proceduralCreateStreamingSetup(void* editorContext, const void* request, + std::size_t requestSize, void* response, + std::size_t responseSize) { + if (!editorContext || !request || + requestSize < sizeof(Caffeine::Editor::ProceduralStreamingSetupRequest)) { + return false; + } + auto* ctx = static_cast(editorContext); + if (!ctx->activeWorld) return false; + + const auto* req = static_cast(request); + Caffeine::Editor::ProceduralStreamingSetup setup; + setup.seed = req->seed; + setup.chunkSize = req->chunkSizeMeters; + setup.viewRadius = req->viewRadiusChunks; + setup.scriptPath = req->scriptPath; + setup.terrainProfile = req->terrainProfile; + setup.structureProfile = req->structureProfile; + + Caffeine::ECS::World& world = *ctx->activeWorld; + Caffeine::ECS::Entity director; + Caffeine::ECS::Entity terrain; + std::string error; + if (!Caffeine::Editor::ProceduralBridge::createStreamingSetup(world, setup, director, terrain, + error)) { + std::cerr << "[ProceduralPlugin] createStreamingSetup: " << error << '\n'; + return false; + } + + fillSetupResponse(response, responseSize, director, terrain); + ctx->selectedEntity = director; + ctx->isDirty = true; + return true; +} + +bool proceduralLoadBenchmark(void* editorContext, const void* request, std::size_t requestSize, + void* response, std::size_t responseSize) { + if (!editorContext || !request || + requestSize < sizeof(Caffeine::Editor::ProceduralLoadBenchmarkRequest)) { + return false; + } + auto* ctx = static_cast(editorContext); + if (!ctx->activeWorld) return false; + + const auto* req = static_cast(request); + Caffeine::ECS::World& world = *ctx->activeWorld; + Caffeine::ECS::Entity director; + Caffeine::ECS::Entity terrain; + std::string error; + if (!Caffeine::Editor::ProceduralBridge::loadBenchmark(world, req->benchmarkName, req->seed, + req->chunkSizeMeters, + req->viewRadiusChunks, director, terrain, + error)) { + std::cerr << "[ProceduralPlugin] loadBenchmark: " << error << '\n'; + return false; + } + + fillSetupResponse(response, responseSize, director, terrain); + ctx->selectedEntity = director; + ctx->isDirty = true; + return true; +} + +} // namespace + +extern "C" void caffeine_procedural_plugin_register_services( + const Caffeine::Editor::PluginHostApi* host) { + if (!host || !host->registerService) return; + const char* name = "Procedural Tools"; + host->registerService(host->editorContext, name, + Caffeine::Editor::kServiceProceduralInstallScripts, + &proceduralInstallScripts); + host->registerService(host->editorContext, name, + Caffeine::Editor::kServiceProceduralCreateStreamingSetup, + &proceduralCreateStreamingSetup); + host->registerService(host->editorContext, name, + Caffeine::Editor::kServiceProceduralLoadBenchmark, + &proceduralLoadBenchmark); + host->registerService(host->editorContext, name, + Caffeine::Editor::kServiceProceduralCreateDirector, + &proceduralCreateStreamingSetup); +} diff --git a/apps/plugins/procedural_tools_plugin/main.cpp b/apps/plugins/procedural_tools_plugin/main.cpp new file mode 100644 index 0000000..32dee67 --- /dev/null +++ b/apps/plugins/procedural_tools_plugin/main.cpp @@ -0,0 +1,226 @@ +#include "caffeine/plugin/PluginAPI.hpp" +#include "editor/EditorContext.hpp" +#include "ProceduralBridge.hpp" + +extern "C" void caffeine_procedural_plugin_register_services( + const Caffeine::Editor::PluginHostApi* host); + +#ifdef CF_HAS_IMGUI +#include +#endif + +#include +#include +#include + +namespace { + +#ifdef CF_HAS_IMGUI +static const char* kTerrainProfiles[] = {"flat", "hills", "fbm"}; +static const char* kTerrainProfileLabels[] = {"Flat", "Hills (FBM)", "FBM (generic)"}; +static const char* kStructureProfiles[] = {"none", "markers", "rooms", "track"}; +static const char* kStructureProfileLabels[] = {"None", "Random markers", "Room grid", + "Race track walls"}; +static const char* kBenchmarks[] = {"exploration", "backrooms", "endless_race"}; +static const char* kBenchmarkLabels[] = {"Exploration (hills + markers)", + "Backrooms (flat + rooms)", + "Endless race (flat + track)"}; +#endif + +class ProceduralToolsPlugin : public Caffeine::Editor::IPlugin { +public: + explicit ProceduralToolsPlugin(const Caffeine::Editor::PluginHostApi* host) : m_host(host) {} + + void OnLoad() override { + if (!m_host) return; + caffeine_procedural_plugin_register_services(m_host); + if (!m_host->registerPanel) return; + m_host->registerPanel(m_host->editorContext, GetName(), "Procedural Tools", + &ProceduralToolsPlugin::renderPanel, this); + if (m_host->registerMenuAction) { + m_host->registerMenuAction( + m_host->editorContext, GetName(), "Game/Install Procedural Scripts", + &ProceduralToolsPlugin::installScriptsMenu, this); + } + } + + void OnUnload() override {} + void OnUpdate(float) override {} + + const char* GetName() const override { return "Procedural Tools"; } + const char* GetVersion() const override { return "0.2.0"; } + const char* GetDescription() const override { + return "Modular procedural streaming tools — terrain, structures, and Lua directors."; + } + +private: + static Caffeine::Editor::EditorContext* editorCtx( + const Caffeine::Editor::PluginHostApi* host) { + return host ? static_cast(host->editorContext) : nullptr; + } + + static std::filesystem::path projectRoot(Caffeine::Editor::EditorContext* ctx) { + if (!ctx || ctx->currentScenePath.empty()) return {}; + return std::filesystem::path(ctx->currentScenePath).parent_path().parent_path(); + } + + static void installScriptsMenu(void* userData) { + auto* self = static_cast(userData); + auto* ctx = editorCtx(self->m_host); + if (!ctx) return; + std::string error; + if (!Caffeine::Editor::ProceduralBridge::installScriptTemplates(projectRoot(ctx), error)) { + if (self->m_host->logError) self->m_host->logError(self->m_host->editorContext, error.c_str()); + } + } + +#ifdef CF_HAS_IMGUI + bool invokeStreamingSetup(Caffeine::Editor::EditorContext* ctx) const { + if (!ctx || !ctx->activeWorld) return false; + Caffeine::Editor::ProceduralStreamingSetup setup; + setup.seed = static_cast(m_seed); + setup.chunkSize = m_chunkSize; + setup.viewRadius = static_cast(m_viewRadius); + setup.scriptPath = m_scriptPath; + setup.terrainProfile = kTerrainProfiles[m_terrainProfileIndex]; + setup.structureProfile = kStructureProfiles[m_structureProfileIndex]; + + Caffeine::ECS::Entity director; + Caffeine::ECS::Entity terrain; + std::string error; + if (!Caffeine::Editor::ProceduralBridge::createStreamingSetup(*ctx->activeWorld, setup, + director, terrain, error)) { + if (m_host->logError) m_host->logError(m_host->editorContext, error.c_str()); + return false; + } + ctx->selectedEntity = director; + ctx->isDirty = true; + return true; + } + + bool invokeBenchmark(Caffeine::Editor::EditorContext* ctx, int index) const { + if (!ctx || !ctx->activeWorld || index < 0 || index >= 3) return false; + Caffeine::ECS::Entity director; + Caffeine::ECS::Entity terrain; + std::string error; + if (!Caffeine::Editor::ProceduralBridge::loadBenchmark( + *ctx->activeWorld, kBenchmarks[index], static_cast(m_seed), + m_chunkSize, static_cast(m_viewRadius), director, terrain, error)) { + if (m_host->logError) m_host->logError(m_host->editorContext, error.c_str()); + return false; + } + ctx->selectedEntity = director; + ctx->isDirty = true; + return true; + } +#endif + + static void renderPanel(void* userData) { +#ifdef CF_HAS_IMGUI + auto* self = static_cast(userData); + auto* ctx = editorCtx(self->m_host); + if (!ctx || !ctx->activeWorld) { + ImGui::TextDisabled("No active scene."); + return; + } + + ImGui::TextWrapped( + "Modular tools for procedural streaming: combine terrain generators, structure " + "spawners, and Lua directors to build any game. Press Play to stream chunks."); + + if (ImGui::CollapsingHeader("Streaming", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::DragInt("Seed", &self->m_seed, 1, 0, 999999); + ImGui::DragFloat("Chunk size (m)", &self->m_chunkSize, 4.0f, 32.0f, 256.0f, "%.0f"); + ImGui::DragInt("View radius (chunks)", &self->m_viewRadius, 1, 2, 12); + } + + if (ImGui::CollapsingHeader("Built-in generators", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Combo("Terrain height", &self->m_terrainProfileIndex, kTerrainProfileLabels, + IM_ARRAYSIZE(kTerrainProfileLabels)); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Height profile baked per chunk. Override in Lua with " + "generateTerrainChunk / fillTerrainNoise."); + } + ImGui::Combo("Structure spawner", &self->m_structureProfileIndex, + kStructureProfileLabels, IM_ARRAYSIZE(kStructureProfileLabels)); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Optional cubes spawned per chunk. Use none and spawn via Lua for full control."); + } + } + + if (ImGui::CollapsingHeader("Director script", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::InputText("Script path", self->m_scriptPath, sizeof(self->m_scriptPath)); + if (ImGui::Button("Install Lua scripts to project")) { + installScriptsMenu(self); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Copies templates to scripts/procedural/"); + } + } + + if (ImGui::CollapsingHeader("Scene setup", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::Button("Create Streaming Setup")) { + if (self->invokeStreamingSetup(ctx) && self->m_host->logInfo) { + self->m_host->logInfo(self->m_host->editorContext, + "Streaming setup created. Press Play to stream."); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Creates ProceduralTerrain + ProceduralDirector with your settings."); + } + } + + if (ImGui::CollapsingHeader("Lua API reference")) { + ImGui::BulletText("caffeine.procedural.stream(director, terrain, cx, cz)"); + ImGui::BulletText("caffeine.procedural.generateTerrainChunk(...)"); + ImGui::BulletText("caffeine.procedural.spawnCube(...)"); + ImGui::BulletText("caffeine.procedural.fbm2d / noise2d / hash"); + ImGui::BulletText("caffeine.procedural.getFocusChunk / isChunkLoaded"); + } + + if (ImGui::CollapsingHeader("Benchmarks (examples)")) { + ImGui::TextWrapped( + "Reference setups — not game types. Study or duplicate these scripts, then mix " + "tools for your own design."); + for (int i = 0; i < 3; ++i) { + if (ImGui::Button(kBenchmarkLabels[i])) { + if (self->invokeBenchmark(ctx, i) && self->m_host->logInfo) { + self->m_host->logInfo(self->m_host->editorContext, + "Benchmark scene loaded. Press Play to test."); + } + } + if (i < 2) ImGui::SameLine(); + } + ImGui::Separator(); + ImGui::TextDisabled("Scripts: exploration_director.lua, backrooms_director.lua, " + "endless_race_director.lua, custom_director.lua"); + } +#else + (void)userData; +#endif + } + + const Caffeine::Editor::PluginHostApi* m_host = nullptr; +#ifdef CF_HAS_IMGUI + int m_seed = 42; + float m_chunkSize = 64.0f; + int m_viewRadius = 4; + int m_terrainProfileIndex = 1; + int m_structureProfileIndex = 0; + char m_scriptPath[256] = "scripts/procedural/custom_director.lua"; +#endif +}; + +} // namespace + +extern "C" { + +CAFFEINE_PLUGIN_API Caffeine::Editor::IPlugin* CreatePlugin( + const Caffeine::Editor::PluginHostApi* host) { + return new ProceduralToolsPlugin(host); +} + +CAFFEINE_PLUGIN_API void DestroyPlugin(Caffeine::Editor::IPlugin* plugin) { delete plugin; } + +} // extern "C" diff --git a/apps/plugins/terrain_plugin/TerrainBridge.cpp b/apps/plugins/terrain_plugin/TerrainBridge.cpp new file mode 100644 index 0000000..f7e20b9 --- /dev/null +++ b/apps/plugins/terrain_plugin/TerrainBridge.cpp @@ -0,0 +1,147 @@ +#include "TerrainBridge.hpp" + +#include "ecs/TerrainComponents.hpp" +#include "terrain/TerrainHeightmapImporter.hpp" + +#include +#include +#include + +namespace Caffeine::Editor { +namespace { + +std::filesystem::path engineSourceRoot() { +#ifdef CAFFEINE_SOURCE_DIR + return std::filesystem::path(CAFFEINE_SOURCE_DIR); +#else + return {}; +#endif +} + +int runShellCommand(const std::string& command) { + return std::system(command.c_str()); +} + +bool readMetaFile(const std::filesystem::path& metaPath, f32& worldSize, u32& gridSize, + f32& minHeight, f32& maxHeight) { + std::ifstream file(metaPath); + if (!file.is_open()) return false; + + std::string content((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + auto findNumber = [&](const char* key, f32& out) -> bool { + const std::string token = std::string("\"") + key + "\":"; + const size_t pos = content.find(token); + if (pos == std::string::npos) return false; + out = std::stof(content.substr(pos + token.size())); + return true; + }; + + f32 gridF = 0.0f; + if (!findNumber("worldSize", worldSize)) return false; + if (!findNumber("gridSize", gridF)) return false; + gridSize = static_cast(gridF); + findNumber("minHeight", minHeight); + findNumber("maxHeight", maxHeight); + return gridSize >= 2; +} + +} // namespace + +std::filesystem::path TerrainBridge::resolveAlgorithmDirectory() { + const std::filesystem::path rel = + "assets/general-ultra-realistic-terrain-algorithm"; + const auto fromSource = engineSourceRoot() / rel; + if (!fromSource.empty() && std::filesystem::exists(fromSource / "caffeine-export.js")) { + return fromSource; + } + return rel; +} + +bool TerrainBridge::ensureAlgorithmDependencies(const std::filesystem::path& algorithmDir, + std::string& errorOut) { + if (!std::filesystem::exists(algorithmDir / "caffeine-export.js")) { + errorOut = "Algorithm not found at: " + algorithmDir.string(); + return false; + } + if (std::filesystem::exists(algorithmDir / "node_modules/simplex-noise")) { + return true; + } + + const std::string installCmd = + "cd \"" + algorithmDir.string() + "\" && npm install --omit=dev --silent"; + if (runShellCommand(installCmd) != 0) { + errorOut = "npm install failed in " + algorithmDir.string(); + return false; + } + return true; +} + +bool TerrainBridge::generateUltraRealistic(ECS::World& world, ECS::Entity entity, + const char* preset, u32 seed, + f32 worldSizeMeters, u32 gridSize, + std::string& errorOut) { + auto* terrain = world.get(entity); + if (!terrain) { + errorOut = "Entity has no TerrainComponent"; + return false; + } + + const std::filesystem::path algorithmDir = resolveAlgorithmDirectory(); + if (!ensureAlgorithmDependencies(algorithmDir, errorOut)) { + return false; + } + + const std::filesystem::path outDir = + std::filesystem::temp_directory_path() / "caffeine-terrain-gen"; + std::error_code ec; + std::filesystem::create_directories(outDir, ec); + + const std::filesystem::path heightmapPath = outDir / "heightmap.txt"; + const std::filesystem::path metaPath = outDir / "meta.json"; + + std::ostringstream cmd; + cmd << "cd \"" << algorithmDir.string() << "\" && node caffeine-export.js " + << (preset ? preset : "default") << " --out \"" << heightmapPath.string() << "\" --meta \"" + << metaPath.string() << "\" --world-size " << worldSizeMeters << " --grid-size " << gridSize + << " --seed " << seed; + + if (runShellCommand(cmd.str()) != 0) { + errorOut = "Terrain generator process failed"; + return false; + } + + f32 metaWorld = worldSizeMeters; + u32 metaGrid = gridSize; + f32 minH = 0.0f; + f32 maxH = terrain->maxHeight; + if (readMetaFile(metaPath, metaWorld, metaGrid, minH, maxH)) { + worldSizeMeters = metaWorld; + gridSize = metaGrid; + } + + terrain->worldSizeX = worldSizeMeters; + terrain->worldSizeZ = worldSizeMeters; + if (maxH > minH) { + terrain->maxHeight = maxH - minH; + } + + return importHeightmap(world, entity, heightmapPath, terrain->worldSizeX, terrain->worldSizeZ, + errorOut); +} + +bool TerrainBridge::importHeightmap(ECS::World& world, ECS::Entity entity, + const std::filesystem::path& heightmapPath, + f32 worldSizeX, f32 worldSizeZ, + std::string& errorOut) { + if (!std::filesystem::exists(heightmapPath)) { + errorOut = "Heightmap not found: " + heightmapPath.string(); + return false; + } + if (!Terrain::importHeightmapFile(world, entity, heightmapPath, worldSizeX, worldSizeZ)) { + errorOut = "Failed to import heightmap"; + return false; + } + return true; +} + +} // namespace Caffeine::Editor diff --git a/apps/plugins/terrain_plugin/TerrainBridge.hpp b/apps/plugins/terrain_plugin/TerrainBridge.hpp new file mode 100644 index 0000000..f56ac58 --- /dev/null +++ b/apps/plugins/terrain_plugin/TerrainBridge.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "core/Types.hpp" +#include "ecs/Entity.hpp" +#include "ecs/World.hpp" + +#include +#include + +namespace Caffeine::Editor { + +/// Host-side bridge used by the terrain plugin to run the ultra-realistic JS generator. +class TerrainBridge { +public: + static std::filesystem::path resolveAlgorithmDirectory(); + + static bool ensureAlgorithmDependencies(const std::filesystem::path& algorithmDir, + std::string& errorOut); + + static bool generateUltraRealistic(ECS::World& world, ECS::Entity entity, + const char* preset, u32 seed, + f32 worldSizeMeters, u32 gridSize, + std::string& errorOut); + + static bool importHeightmap(ECS::World& world, ECS::Entity entity, + const std::filesystem::path& heightmapPath, + f32 worldSizeX, f32 worldSizeZ, + std::string& errorOut); +}; + +} // namespace Caffeine::Editor diff --git a/apps/plugins/terrain_plugin/TerrainPluginServices.cpp b/apps/plugins/terrain_plugin/TerrainPluginServices.cpp new file mode 100644 index 0000000..a6e2a70 --- /dev/null +++ b/apps/plugins/terrain_plugin/TerrainPluginServices.cpp @@ -0,0 +1,74 @@ +#include "caffeine/plugin/PluginAPI.hpp" +#include "caffeine/plugin/PluginServices.hpp" +#include "editor/EditorContext.hpp" +#include "ecs/TerrainComponents.hpp" +#include "TerrainBridge.hpp" + +#include +#include + +namespace { + +bool terrainGenerateUltra(void* editorContext, const void* request, std::size_t requestSize, + void* response, std::size_t responseSize) { + (void)response; + (void)responseSize; + if (!editorContext || !request || requestSize < sizeof(Caffeine::Editor::TerrainGenerateUltraRequest)) { + return false; + } + auto* ctx = static_cast(editorContext); + if (!ctx->activeWorld) return false; + + const auto* req = static_cast(request); + Caffeine::ECS::World& world = *ctx->activeWorld; + Caffeine::ECS::Entity entity(req->entityId, &world); + if (!entity.isValid() || !world.has(entity)) return false; + + std::string error; + const bool ok = Caffeine::Editor::TerrainBridge::generateUltraRealistic( + world, entity, req->preset, req->seed, req->worldSizeMeters, req->gridSize, error); + if (!ok) { + std::cerr << "[TerrainPlugin] terrain.generateUltra: " << error << '\n'; + return false; + } + ctx->isDirty = true; + return true; +} + +bool terrainImportHeightmap(void* editorContext, const void* request, std::size_t requestSize, + void* response, std::size_t responseSize) { + (void)response; + (void)responseSize; + if (!editorContext || !request || + requestSize < sizeof(Caffeine::Editor::TerrainImportHeightmapRequest)) { + return false; + } + auto* ctx = static_cast(editorContext); + if (!ctx->activeWorld) return false; + + const auto* req = static_cast(request); + Caffeine::ECS::World& world = *ctx->activeWorld; + Caffeine::ECS::Entity entity(req->entityId, &world); + if (!entity.isValid()) return false; + + std::string error; + const bool ok = Caffeine::Editor::TerrainBridge::importHeightmap( + world, entity, req->heightmapPath, req->worldSizeX, req->worldSizeZ, error); + if (!ok) { + std::cerr << "[TerrainPlugin] terrain.importHeightmap: " << error << '\n'; + return false; + } + ctx->isDirty = true; + return true; +} + +} // namespace + +extern "C" void caffeine_terrain_plugin_register_services( + const Caffeine::Editor::PluginHostApi* host) { + if (!host || !host->registerService) return; + host->registerService(host->editorContext, "Terrain Generator", + Caffeine::Editor::kServiceTerrainGenerateUltra, &terrainGenerateUltra); + host->registerService(host->editorContext, "Terrain Generator", + Caffeine::Editor::kServiceTerrainImportHeightmap, &terrainImportHeightmap); +} diff --git a/apps/plugins/terrain_plugin/main.cpp b/apps/plugins/terrain_plugin/main.cpp new file mode 100644 index 0000000..63b296b --- /dev/null +++ b/apps/plugins/terrain_plugin/main.cpp @@ -0,0 +1,146 @@ +#include "caffeine/plugin/PluginAPI.hpp" +#include "editor/EditorContext.hpp" +#include "ecs/TerrainComponents.hpp" +#include "TerrainBridge.hpp" + +extern "C" void caffeine_terrain_plugin_register_services( + const Caffeine::Editor::PluginHostApi* host); + +#ifdef CF_HAS_IMGUI +#include +#endif + +#include + +namespace { + +class TerrainGeneratorPlugin : public Caffeine::Editor::IPlugin { +public: + explicit TerrainGeneratorPlugin(const Caffeine::Editor::PluginHostApi* host) : m_host(host) {} + + void OnLoad() override { + if (!m_host) return; + caffeine_terrain_plugin_register_services(m_host); + if (m_host->registerPanel) { + m_host->registerPanel(m_host->editorContext, GetName(), "Terrain Generator", + &TerrainGeneratorPlugin::renderPanel, this); + } + } + + void OnUnload() override {} + void OnUpdate(float) override {} + + const char* GetName() const override { return "Terrain Generator"; } + const char* GetVersion() const override { return "0.1.0"; } + const char* GetDescription() const override { + return "Ultra-realistic procedural terrain via domain warping + geomorphological PDE."; + } + +private: + static Caffeine::Editor::EditorContext* editorCtx( + const Caffeine::Editor::PluginHostApi* host) { + return host ? static_cast(host->editorContext) : nullptr; + } + + static void renderPanel(void* userData) { +#ifdef CF_HAS_IMGUI + auto* self = static_cast(userData); + auto* ctx = editorCtx(self->m_host); + if (!ctx || !ctx->activeWorld) { + ImGui::TextDisabled("No active scene."); + return; + } + + Caffeine::ECS::World& world = *ctx->activeWorld; + if (!ctx->selectedEntity.isValid() || !world.has(ctx->selectedEntity)) { + ImGui::TextWrapped( + "Select a Terrain entity, then choose a preset and click Generate. Sculpt and paint " + "in the Terrain Editor panel."); + return; + } + + auto* terrain = world.get(ctx->selectedEntity); + if (!terrain) return; + + ImGui::Text("Entity %u — %ux%u heightmap", ctx->selectedEntity.id(), terrain->resolutionX, + terrain->resolutionZ); + + static const char* kPresets[] = {"default", "alpine", "mesa", "volcanic", "glacial", "blend"}; + ImGui::Combo("Preset", &self->m_presetIndex, kPresets, IM_ARRAYSIZE(kPresets)); + + ImGui::DragFloat("World Size (m)", &self->m_worldSize, 4.0f, 32.0f, 4096.0f, "%.0f"); + ImGui::DragInt("Grid Size", &self->m_gridSize, 8, 64, 1024); + ImGui::DragInt("Seed", &self->m_seed, 1, 0, 999999); + + ImGui::Separator(); + if (self->m_busy) { + ImGui::TextDisabled("Generating… (Node.js)"); + } else if (ImGui::Button("Generate Terrain")) { + self->m_busy = true; + std::string error; + const bool ok = Caffeine::Editor::TerrainBridge::generateUltraRealistic( + world, ctx->selectedEntity, kPresets[self->m_presetIndex], + static_cast(self->m_seed), self->m_worldSize, + static_cast(self->m_gridSize), error); + self->m_busy = false; + if (ok) { + ctx->isDirty = true; + if (self->m_host->logInfo) { + self->m_host->logInfo(self->m_host->editorContext, "Terrain generated."); + } + } else if (self->m_host->logError) { + self->m_host->logError(self->m_host->editorContext, error.c_str()); + } + } + + ImGui::Separator(); + ImGui::TextUnformatted("Import"); + ImGui::InputText("Heightmap path", self->m_importPath, sizeof(self->m_importPath)); + if (ImGui::Button("Import .txt heightmap")) { + std::string error; + if (Caffeine::Editor::TerrainBridge::importHeightmap( + world, ctx->selectedEntity, self->m_importPath, terrain->worldSizeX, + terrain->worldSizeZ, error)) { + ctx->isDirty = true; + } else if (self->m_host->logError) { + self->m_host->logError(self->m_host->editorContext, error.c_str()); + } + } + + ImGui::Separator(); + ImGui::TextDisabled( + "Algorithm: assets/general-ultra-realistic-terrain-algorithm (npm install on first run)."); +#else + (void)userData; +#endif + } + + const Caffeine::Editor::PluginHostApi* m_host = nullptr; +#ifdef CF_HAS_IMGUI + int m_presetIndex = 0; + float m_worldSize = 256.0f; + int m_gridSize = 257; + int m_seed = 42; + bool m_busy = false; + char m_importPath[512] = {}; +#endif +}; + +TerrainGeneratorPlugin* g_plugin = nullptr; + +} // namespace + +extern "C" { + +CAFFEINE_PLUGIN_API Caffeine::Editor::IPlugin* CreatePlugin( + const Caffeine::Editor::PluginHostApi* host) { + g_plugin = new TerrainGeneratorPlugin(host); + return g_plugin; +} + +CAFFEINE_PLUGIN_API void DestroyPlugin(Caffeine::Editor::IPlugin* plugin) { + delete plugin; + g_plugin = nullptr; +} + +} // extern "C" diff --git a/apps/runtime/main.cpp b/apps/runtime/main.cpp index 00afb4e..4df836f 100644 --- a/apps/runtime/main.cpp +++ b/apps/runtime/main.cpp @@ -21,6 +21,7 @@ #include "ui/UISystem.hpp" #include "ui/UIRenderer.hpp" #include "scene/EnvironmentSystem.hpp" +#include "procedural/ProceduralWorldSystem.hpp" #include "render/GpuSceneRenderer.hpp" #include "render/SkyboxRenderer.hpp" #include "math/Mat4.hpp" @@ -563,6 +564,7 @@ int main(int argc, char** argv) { scriptEngine.setInput(&input); scriptSystem.onUpdate(world, dt); } + Caffeine::Procedural::ProceduralWorldSystem::update(world); uiSystem.onUpdate(world, dt); } diff --git a/assets/general-ultra-realistic-terrain-algorithm/caffeine-export.js b/assets/general-ultra-realistic-terrain-algorithm/caffeine-export.js new file mode 100644 index 0000000..e027211 --- /dev/null +++ b/assets/general-ultra-realistic-terrain-algorithm/caffeine-export.js @@ -0,0 +1,71 @@ +import fs from 'fs'; +import path from 'path'; +import { generateTerrainAuto, DEFAULT_CONFIG, recommendedGridSize } from './src/terrain.js'; +import { exportHeightmap } from './src/export.js'; +import { resolveConfig } from './src/presets.js'; + +function parseArgs(argv) { + const opts = { + preset: 'default', + out: 'heightmap.txt', + meta: 'meta.json', + worldSize: DEFAULT_CONFIG.worldSize, + gridSize: DEFAULT_CONFIG.gridSize, + seed: DEFAULT_CONFIG.seed, + }; + + const args = [...argv]; + if (args.length > 0 && !args[0].startsWith('--')) { + opts.preset = args.shift(); + } + + for (let i = 0; i < args.length; ++i) { + const arg = args[i]; + if (arg === '--out' && args[i + 1]) opts.out = args[++i]; + else if (arg === '--meta' && args[i + 1]) opts.meta = args[++i]; + else if (arg === '--world-size' && args[i + 1]) opts.worldSize = Number(args[++i]); + else if (arg === '--grid-size' && args[i + 1]) opts.gridSize = Number(args[++i]); + else if (arg === '--seed' && args[i + 1]) opts.seed = Number(args[++i]); + } + + if (!opts.gridSize || opts.gridSize < 2) { + opts.gridSize = recommendedGridSize(opts.worldSize); + } + return opts; +} + +const opts = parseArgs(process.argv.slice(2)); + +const config = + opts.preset === 'blend' + ? { + ...DEFAULT_CONFIG, + worldSize: opts.worldSize, + gridSize: opts.gridSize, + seed: opts.seed, + blend: { + enabled: true, + presetA: 'alpine', + presetB: 'mesa', + mask: { type: 'noise', scale: 80, sharpness: 1.2 }, + }, + } + : resolveConfig( + { ...DEFAULT_CONFIG, worldSize: opts.worldSize, gridSize: opts.gridSize, seed: opts.seed }, + opts.preset + ); + +const result = generateTerrainAuto(config); +exportHeightmap(result.heights, result.gridSize, opts.out); + +const meta = { + preset: opts.preset, + gridSize: result.gridSize, + worldSize: result.worldSize, + seed: opts.seed, + minHeight: result.stats.elevation.min, + maxHeight: result.stats.elevation.max, +}; + +fs.mkdirSync(path.dirname(path.resolve(opts.meta)), { recursive: true }); +fs.writeFileSync(opts.meta, JSON.stringify(meta, null, 2)); diff --git a/assets/plugins/post_processing/scripts/custom_effect.lua b/assets/plugins/post_processing/scripts/custom_effect.lua new file mode 100644 index 0000000..888b41f --- /dev/null +++ b/assets/plugins/post_processing/scripts/custom_effect.lua @@ -0,0 +1,15 @@ +-- Custom post-process driver (template). +-- Attach path on PostProcessComponent.customEffectScript, or set via: +-- caffeine.postprocess.setCustomScript(cameraId, "scripts/postprocessing/custom_effect.lua") + +local state = { cameraId = 0, pulse = 0.0 } + +function onCreate(entityId) + state.cameraId = entityId +end + +function onUpdate(entityId, dt) + state.pulse = state.pulse + dt + local bloom = 0.1 + math.sin(state.pulse * 0.5) * 0.05 + caffeine.postprocess.setBloom(state.cameraId, bloom) +end diff --git a/assets/plugins/procedural/scripts/backrooms_director.lua b/assets/plugins/procedural/scripts/backrooms_director.lua new file mode 100644 index 0000000..802b380 --- /dev/null +++ b/assets/plugins/procedural/scripts/backrooms_director.lua @@ -0,0 +1,15 @@ +-- Backrooms-style infinite rooms: flat floor + yellow-ish wall boxes per chunk. + +local state = {} + +function onCreate(directorId) + state.id = directorId + state.terrain = caffeine.procedural.findTerrain() + state.chunkSize = 64 + state.preset = "backrooms" +end + +function onUpdate(directorId, dt) + local focus = caffeine.procedural.getFocusChunk(state.id, state.chunkSize) + caffeine.procedural.stream(state.id, state.terrain, focus.cx, focus.cz, state.preset) +end diff --git a/assets/plugins/procedural/scripts/custom_director.lua b/assets/plugins/procedural/scripts/custom_director.lua new file mode 100644 index 0000000..84ed3ed --- /dev/null +++ b/assets/plugins/procedural/scripts/custom_director.lua @@ -0,0 +1,33 @@ +-- Template: override generation per chunk with Lua logic. +-- Duplicate this file and implement terrain() / structures(). + +local M = { + chunkSize = 64, + terrainProfile = "hills", +} + +function M.terrain(cx, cz, api) + -- Example: raise center of each chunk + local h = caffeine.procedural.fbm2d(cx * 3.1, cz * 3.1, api.seed, 4, 2.0, 0.5) + caffeine.procedural.generateTerrainChunk(api.terrainId, cx, cz, api.seed, M.terrainProfile) +end + +function M.structures(cx, cz, api) + local baseX = cx * M.chunkSize + M.chunkSize * 0.5 + local baseZ = cz * M.chunkSize + M.chunkSize * 0.5 + if caffeine.procedural.hash(cx, cz, api.seed, 99) > 0.6 then + caffeine.procedural.spawnCube(api.directorId, cx, cz, baseX, 2, baseZ, 2, 4, 2) + end +end + +local state = { module = M } + +function onCreate(directorId) + state.id = directorId + state.terrain = caffeine.procedural.findTerrain() +end + +function onUpdate(directorId, dt) + local focus = caffeine.procedural.getFocusChunk(state.id, state.module.chunkSize) + caffeine.procedural.stream(state.id, state.terrain, focus.cx, focus.cz) +end diff --git a/assets/plugins/procedural/scripts/endless_race_director.lua b/assets/plugins/procedural/scripts/endless_race_director.lua new file mode 100644 index 0000000..2602943 --- /dev/null +++ b/assets/plugins/procedural/scripts/endless_race_director.lua @@ -0,0 +1,15 @@ +-- Endless racer: track strip + walls spawn ahead along +Z chunks. + +local state = {} + +function onCreate(directorId) + state.id = directorId + state.terrain = caffeine.procedural.findTerrain() + state.chunkSize = 80 + state.preset = "endless_race" +end + +function onUpdate(directorId, dt) + local focus = caffeine.procedural.getFocusChunk(state.id, state.chunkSize) + caffeine.procedural.stream(state.id, state.terrain, focus.cx, focus.cz, state.preset) +end diff --git a/assets/plugins/procedural/scripts/exploration_director.lua b/assets/plugins/procedural/scripts/exploration_director.lua new file mode 100644 index 0000000..80d7d48 --- /dev/null +++ b/assets/plugins/procedural/scripts/exploration_director.lua @@ -0,0 +1,16 @@ +-- Exploration: terrain grows with the player; props scatter per chunk. +-- Attach to ProceduralDirector entity with ProceduralWorldComponent. + +local state = {} + +function onCreate(directorId) + state.id = directorId + state.terrain = caffeine.procedural.findTerrain() + state.chunkSize = 64 + state.preset = "exploration" +end + +function onUpdate(directorId, dt) + local focus = caffeine.procedural.getFocusChunk(state.id, state.chunkSize) + caffeine.procedural.stream(state.id, state.terrain, focus.cx, focus.cz, state.preset) +end diff --git a/docs/HISTORY.md b/docs/HISTORY.md index a4ed1c0..61b16d0 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -5,6 +5,16 @@ --- +## Pilares Fundamentais (2026-09-22) + +Os subsistemas que a engine deve ter de forma **sólida** — Phong 3D, eventos com timers, Bullet3, OpenAL, ECS integrado, handles com refcount, networking, deferred PBR, animação, scene management, scripting futuro (C#/Python) e Android (maybe) — estão documentados em: + +**[`plans/2026-09-22-engine-pillars-roadmap.md`](plans/2026-09-22-engine-pillars-roadmap.md)** + +Este roadmap complementa as fases abaixo com critérios de aceitação por pilar e ordem de implementação sugerida. + +--- + ## Fases de Desenvolvimento O projeto foi estruturado em 6 fases progressivas. Abaixo, o resumo de cada fase e seu status atual. diff --git a/docs/MASTER.md b/docs/MASTER.md index 63622ee..ca211d8 100644 --- a/docs/MASTER.md +++ b/docs/MASTER.md @@ -2,7 +2,7 @@ **Versão:** 1.1.0 **Status:** Alpha (Pré-produção) -**Última Atualização:** 2026-04-14 +**Última Atualização:** 2026-09-22 **Mantido por:** Codex Studio Guild --- @@ -17,6 +17,8 @@ | **HISTORY.md** | Histórico de desenvolvimento (roadmap, planos, revisões) | [`docs/HISTORY.md`](docs/HISTORY.md) | | **architecture_specs.md** | Especificações técnicas detalhadas | [`docs/architecture_specs.md`](docs/architecture_specs.md) | | **Índice Funcional** | Documentação por módulo | [`docs/README.md`](docs/README.md) | +| **Engine Pillars** | Pilares fundamentais da engine (Phong, Bullet3, OpenAL, ECS, networking, PBR…) | [`docs/plans/2026-09-22-engine-pillars-roadmap.md`](docs/plans/2026-09-22-engine-pillars-roadmap.md) | +| **Rendering Roadmap** | Plano técnico de rendering 3D (P0–P6) | [`docs/plans/2026-09-21-rendering-roadmap.md`](docs/plans/2026-09-21-rendering-roadmap.md) | --- @@ -38,6 +40,7 @@ docs/ ├── rendering/ # 🎨 Rendering (rhi, batch-renderer, camera-2d, camera-3d) ├── assets/ # 📦 Assets (asset-manager, caf-format, mesh-loading, asset-pipeline) ├── ecs/ # 🧩 ECS (core, scene, examples) +├── terrain/ # 🏔️ Terrain (heightmap, LOD, plugin de geração) ├── physics/ # 🌌 Physics (physics-2d, spatial-partitioning) ├── animation/ # 🎬 Animation (animation-2d, skeletal-animation) ├── audio/ # 🔊 Audio (audio-system) @@ -84,6 +87,8 @@ docs/ ### 1. Visão Geral & Desenvolvimento - [**HISTORY.md**](HISTORY.md) — Histórico completo de desenvolvimento +- [**Engine Pillars**](plans/2026-09-22-engine-pillars-roadmap.md) — Roadmap de sistemas fundamentais (Phong, Bullet3, OpenAL, ECS, networking, PBR, animação…) +- [**Rendering Roadmap**](plans/2026-09-21-rendering-roadmap.md) — Plano técnico de rendering 3D de alto volume - [**architecture_specs.md**](architecture_specs.md) — Especificações técnicas detalhadas ### 2. Documentação por Módulo diff --git a/docs/README.md b/docs/README.md index 7386807..dc7a274 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,13 @@ ## Índice da Documentação +### Roadmap & Planos + +| Documento | Descrição | +|-----------|-----------| +| **Engine Pillars** | [`plans/2026-09-22-engine-pillars-roadmap.md`](plans/2026-09-22-engine-pillars-roadmap.md) | Pilares fundamentais: Phong 3D, Bullet3, OpenAL, ECS, handles, networking, PBR, animação, scripting, Android | +| **Rendering Roadmap** | [`plans/2026-09-21-rendering-roadmap.md`](plans/2026-09-21-rendering-roadmap.md) | Plano P0–P6: GPU único, texturas, LOD, PBR, sombras, reflexos, volumétricos | + ### Engine Core | Módulo | Documentação | Descrição | @@ -46,6 +53,7 @@ | Módulo | Documentação | Descrição | |--------|-------------|-----------| +| **Post-Processing** | [`rendering/post-processing.md`](rendering/post-processing.md) | Stack modular de efeitos, Lua, benchmarks opcionais | | **RHI** | [`rendering/rhi.md`](rendering/rhi.md) | RenderDevice, CommandBuffer, Triple Buffering, abstração SDL_GPU | | **Batch Renderer** | [`rendering/batch-renderer.md`](rendering/batch-renderer.md) | Sprite batching, Texture Atlas, Radix Sort, Persistent Mapped Buffers | | **Camera 2D** | [`rendering/camera-2d.md`](rendering/camera-2d.md) | Projeção ortográfica, follow, shake, bounds | @@ -69,6 +77,21 @@ | **ECS Overview** | [`ecs/README.md`](ecs/README.md) | Visão geral do ECS | | **ECS Examples** | [`ecs/examples.md`](ecs/examples.md) | Exemplos de uso do ECS | +### Editor + +| Módulo | Documentação | Descrição | +|--------|-------------|-----------| +| **Plugin SDK** | [`editor/plugin-sdk.md`](editor/plugin-sdk.md) | Doppio lean + plugins dinâmicos, serviços, UI | +| **Git Plugin** | [`editor/git-plugin.md`](editor/git-plugin.md) | Painel opcional de gestão Git do projeto | + +### Terrain + +| Módulo | Documentação | Descrição | +|--------|-------------|-----------| +| **Terrain System** | [`terrain/terrain-system.md`](terrain/terrain-system.md) | Heightmap, mesh, LOD, colisão, sculpt/paint, `.cterrain` | +| **Terrain Generator Plugin** | [`terrain/terrain-plugin.md`](terrain/terrain-plugin.md) | Geração procedural via algoritmo ultra-realistic (Node.js) | +| **Procedural Tools Plugin** | [`terrain/procedural-tools-plugin.md`](terrain/procedural-tools-plugin.md) | Streaming procedural (exploração, backrooms, corrida) + Lua | + ### Physics | Módulo | Documentação | Descrição | diff --git a/docs/editor/git-plugin.md b/docs/editor/git-plugin.md new file mode 100644 index 0000000..c47167c --- /dev/null +++ b/docs/editor/git-plugin.md @@ -0,0 +1,24 @@ +# Git Plugin + +> **Plugin:** `git_plugin.so` (optional — not bundled by default) + +Full Git workflow panel for the open Caffeine project. Requires `git` in PATH. + +## Features + +- Repository detection + `git init` +- **Changes** — porcelain status, stage/unstage per file or all +- **Commit** — message + commit +- **Branches** — checkout, create +- **Remotes** — fetch, pull, push +- **History** — recent log +- **Stash** — push, pop, list +- **Diff output** — per-file diff in output panel + +## Open panel + +Menu: **Version Control → Git Panel**, or enable plugin in **Plugins** manager. + +## Project root + +Uses `PluginHostApi::getProjectRootPath` — parent of `scenes/` from the open scene path. diff --git a/docs/editor/plugin-sdk.md b/docs/editor/plugin-sdk.md new file mode 100644 index 0000000..88f670d --- /dev/null +++ b/docs/editor/plugin-sdk.md @@ -0,0 +1,94 @@ +# Plugin SDK + +Doppio is a **lean IDE**. Feature plugins ship separately and load at runtime from `plugins/`. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Doppio (core IDE) │ +│ Scene editor, viewport, hierarchy, inspector, assets │ +│ PluginManager + PluginServiceRegistry (empty at start) │ +│ NO terrain/procedural/post-process/git UI in core │ +└───────────────────────────┬─────────────────────────────┘ + │ PluginHostApi (dlopen) + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + terrain_plugin procedural_tools_plugin git_plugin + (owns TerrainBridge) (owns ProceduralBridge) (owns GitRunner) +``` + +### Rules + +1. **Plugins own their logic** — bridges, panels, services live in `.so` files, not in `doppio` sources. +2. **Core owns ECS + runtime** — `PostProcessComponent`, `TerrainComponent`, procedural systems are engine data/runtime; editor UI for them is plugin territory. +3. **Service registry is generic** — plugins call `registerService` in `OnLoad`; Doppio never hardcodes feature services. +4. **Optional bundling** — CMake `CAFFEINE_BUNDLE_DEFAULT_PLUGINS=OFF` (default): Doppio ships without copying plugins. Turn `ON` for dev convenience. + +## Install plugins + +```bash +# Build plugins (from build dir) +cmake --build . --target terrain_plugin post_processing_plugin procedural_tools_plugin git_plugin + +# Copy into Doppio plugins folder +cp build/plugins/*.so build/doppio/plugins/ +``` + +Or enable bundling at configure time: + +```bash +cmake -DCAFFEINE_BUNDLE_DEFAULT_PLUGINS=ON .. +``` + +## Plugin catalog (reference) + +| Plugin | Purpose | +|--------|---------| +| `hello_plugin` | Minimal SDK example | +| `terrain_plugin` | Ultra-realistic terrain generator (Node.js) | +| `procedural_tools_plugin` | Modular procedural streaming tools | +| `post_processing_plugin` | Post-process effect stack UI | +| `git_plugin` | Optional Git project management | + +## Host API + +See `include/caffeine/plugin/PluginAPI.hpp`: + +- `registerPanel`, `registerMenuAction`, `registerComponentDrawer` +- `registerService` / `invokeService` +- `getProjectRootPath`, `getActiveWorld`, `markSceneDirty` + +## Services (cross-plugin) + +Plugins may expose services for aggregators / stores: + +| Service | Owner plugin | +|---------|----------------| +| `terrain.generateUltra` | terrain_plugin | +| `terrain.importHeightmap` | terrain_plugin | +| `procedural.*` | procedural_tools_plugin | + +## Inspector drawers + +Plugins register component drawers via `registerComponentDrawer`. Doppio Inspector delegates to registered drawers (e.g. Post Process shows plugin UI when plugin is loaded). + +## Writing a plugin + +```cpp +#include "caffeine/plugin/PluginAPI.hpp" + +class MyPlugin : public Caffeine::Editor::IPlugin { + void OnLoad() override { + m_host->registerPanel(m_host->editorContext, GetName(), "My Panel", &render, this); + } + // ... +}; + +extern "C" CAFFEINE_PLUGIN_API Caffeine::Editor::IPlugin* CreatePlugin( + const Caffeine::Editor::PluginHostApi* host) { + return new MyPlugin(host); +} +``` + +Build as `SHARED` library with `CAFFEINE_PLUGIN_EXPORTS=1`, output to `plugins/`. diff --git a/docs/plans/2026-09-21-rendering-roadmap.md b/docs/plans/2026-09-21-rendering-roadmap.md new file mode 100644 index 0000000..eacde93 --- /dev/null +++ b/docs/plans/2026-09-21-rendering-roadmap.md @@ -0,0 +1,205 @@ +# 🎨 Rendering Roadmap — 3D de Alto Volume + +> **Objetivo:** renderizar ambientes 3D com alto volume de polígonos e texturas de forma **otimizada, consistente e estável** (sem spikes de FPS). +> **Critério de “completo”:** cena densa (terreno + centenas de meshes texturizados + iluminação dinâmica + sombras) a 60 FPS estáveis no editor e no runtime. +> **Estado atual:** pipeline híbrido GPU+CPU+ImGui; sombras GPU existem mas não estão ligadas; mesh LOD é stub; texturas sem mips/streaming. + +--- + +## Diagnóstico (causa provável dos FPS spikes) + +| Sintoma | Causa raiz | Ficheiros | +|---------|-----------|-----------| +| Spike ao mover câmara | CPU raster + `stbi_load` por textura; cache fraco | `SceneViewport.cpp`, `MeshCpuRasterizer` | +| Terreno pesado | Rebuild mesh / upload GPU síncrono | `TerrainCache.cpp`, `GpuSceneRenderer.cpp` | +| Muitos draw calls | 1 `drawIndexed` por mesh, sem batching | `GpuSceneRenderer.cpp` | +| Sombras inconsistentes | CPU shadow maps no editor; GPU shadows mortos | `CpuDirectionalShadowMap.*`, `GpuDirectionalShadowMap.*` | +| Materiais “flat” | Shader Lambert sem PBR; normal map não ligado | `scene_lit.frag`, `terrain_lit.frag` | + +--- + +## Fases e dependências + +```mermaid +flowchart TD + P0[P0 Fundação GPU única] + P1[P1 Texturas e materiais] + P2[P2 Geometria e LOD] + P3[P3 Iluminação PBR] + P4[P4 Shadow mapping GPU] + P5[P5 Reflexos] + P6[P6 Luz volumétrica] + P0 --> P1 --> P2 + P1 --> P3 --> P4 + P4 --> P5 + P3 --> P6 +``` + +Cada fase tem entregável testável. **Não avançar** para reflexos/volumétricos antes de P4 estável. + +--- + +## P0 — Fundação: um único caminho GPU + +**Meta:** eliminar dual-path (CPU raster + GPU) no viewport 3D. + +| Tarefa | Descrição | Ficheiros | +|--------|-----------|-----------| +| P0.1 | GPU como default para **todos** os meshes texturizados | `SceneViewport.cpp`, `GpuSceneRenderer.cpp` | +| P0.2 | Remover fallback CPU exceto wireframe/debug | `SceneViewport.cpp` | +| P0.3 | Upload de buffers **antes** do render pass (já parcial) | `GpuSceneRenderer.cpp` | +| P0.4 | Runtime play mode usar `GpuSceneRenderer` (hoje CPU-only) | `RuntimeSceneRenderer.cpp` | +| P0.5 | Profiler markers por pass (shadow, opaque, terrain, composite) | `Profiler.hpp`, viewport | + +**Métrica de sucesso:** viewport 3D sem `MeshCpuRasterizer` em modo shaded; FPS estável ±5% ao orbitar câmara. + +**Doc detalhada:** [`rendering/rhi.md`](../rendering/rhi.md) (atualizar após P0) + +--- + +## P1 — Texturas e materiais + +**Meta:** cache GPU global, mips, sem reload por frame. + +| Tarefa | Descrição | Ficheiros | +|--------|-----------|-----------| +| P1.1 | `GpuTextureCache` central (path → `GPUTexture`, refcount) | novo `src/render/GpuTextureCache.*` | +| P1.2 | Geração de mipmaps no upload | `RenderDevice.cpp` | +| P1.3 | Bind albedo + normal + ORM no `scene_lit` | `scene_lit.frag`, `MeshComponents.hpp` | +| P1.4 | Async decode (job system) + upload no frame N+1 | `JobSystem`, `AssetManager` | +| P1.5 | Atlas opcional para props repetidos (instancing) | `TextureAtlas.hpp` (estender) | + +**Métrica:** 0 `stbi_load` durante render loop; memória GPU estável após warm-up. + +**Doc:** [`assets/asset-manager.md`](../assets/asset-manager.md) + nova `docs/rendering/materials-pbr.md` + +--- + +## P2 — Polígonos, meshes e LOD + +**Meta:** cenas com 500k–2M triângulos visíveis com culling agressivo. + +| Tarefa | Descrição | Ficheiros | +|--------|-----------|-----------| +| P2.1 | Mesh LOD real (simplificação ou LOD chains no import) | `MeshLOD.cpp`, pipeline `.caf` | +| P2.2 | Seleção LOD por distância + hysteresis (como terreno) | `GpuSceneRenderer.cpp` | +| P2.3 | Frustum + occlusion coarse (octree já existe) | `GpuSceneRenderer.cpp` | +| P2.4 | Instancing para meshes repetidos (árvores, props) | novo pass instanced | +| P2.5 | Terreno: collision mesh separado (feito); GPU chunks estáveis | `TerrainLodSystem.*` | + +**Métrica:** draw calls < 200 para cena de referência; triângulos visíveis escalam com LOD. + +**Doc:** nova `docs/rendering/mesh-lod.md` + +--- + +## P3 — Iluminação + +**Meta:** PBR forward+ (Cook-Torrance) com múltiplas luzes. + +| Tarefa | Descrição | Ficheiros | +|--------|-----------|-----------| +| P3.1 | UBO de luzes unificado (dir/point/spot, até N luzes) | `LightingSystem.*`, shaders | +| P3.2 | PBR: metallic/roughness, Fresnel, GGX | `scene_lit.frag` | +| P3.3 | IBL básico (cubemap skybox como ambiente) | `SkyboxRenderer`, shader | +| P3.4 | Tone mapping (ACES) + exposure | shader + `PostProcess` GPU futuro | +| P3.5 | Spot lights no GPU (hoje só CPU) | `scene_lit.frag` | + +**Métrica:** esfera de referência + terreno com resposta física plausível; sem banding em gradientes. + +**Doc:** atualizar [`rendering/camera-3d.md`](../rendering/camera-3d.md) + +--- + +## P4 — Shadow mapping (GPU) + +**Meta:** sombras direcionais + spot estáveis; point opcional (cubemap). + +| Tarefa | Descrição | Ficheiros | +|--------|-----------|-----------| +| P4.1 | Ligar `renderDirectionalShadows` em `renderWithCamera` | `GpuSceneRenderer.cpp` | +| P4.2 | CSM 2–4 cascatas para sol | `GpuDirectionalShadowMap.*` | +| P4.3 | PCF / PCSS no `scene_lit.frag` | shaders | +| P4.4 | Terreno `castShadows = true` no gather GPU | `GpuSceneRenderer.cpp` | +| P4.5 | Spot shadow maps (1–2 luzes) | novo ou estender GPU shadow | +| P4.6 | Desligar CPU shadow path no editor quando GPU OK | `SceneViewport.cpp` | + +**Infra existente (não ligada):** `GpuDirectionalShadowMap`, `GpuPointShadowMap`, `shadow_depth.*` + +**Métrica:** sombras sem acne/ peter-panning visível; custo < 3 ms @ 1080p. + +**Doc:** nova `docs/rendering/shadow-mapping.md` + +--- + +## P5 — Reflexos em tempo real + +**Meta:** reflexos convincentes sem custo proibitivo. + +| Abordagem | Quando | Custo | +|-----------|--------|-------| +| **Planar** (espelhos, água plana) | P5.1 | Baixo | +| **SSR** (screen-space) | P5.2 | Médio | +| **Reflection probes** (cubemap local) | P5.3 | Médio | +| **RT reflections** | futuro | Alto | + +| Tarefa | Descrição | +|--------|-----------| +| P5.1 | Pass de reflexão planar + clip plane | +| P5.2 | SSR com depth + normal buffer (exige G-buffer parcial) | +| P5.3 | Probes automáticos em interiores | + +**Pré-requisito:** P4 + depth prepass ou G-buffer mínimo. + +**Doc:** nova `docs/rendering/reflections.md` + +--- + +## P6 — Luz volumétrica + +**Meta:** god rays, fog volumétrico, shafts de luz direcional. + +| Tarefa | Descrição | +|--------|-----------| +| P6.1 | Ray marching fullscreen (luz dir + depth) | +| P6.2 | Froxel grid ou 3D texture para fog local | +| P6.3 | Integração com sombras (sample shadow map no march) | + +**Nota:** `PostProcessRenderer` atual é overlay ImGui — não conta como volumétrico GPU. + +**Doc:** nova `docs/rendering/volumetric-lighting.md` + +--- + +## Cena de referência (benchmark interno) + +Criar `assets/benchmarks/dense_outdoor.caf`: + +- Terreno 512² com splat (plugin) +- 200–500 meshes instanciados (árvores/rochas) +- 1 direcional + 2 point + 1 spot +- Câmara orbit com medição de frame time (p50, p95, p99) + +**Gate de qualidade:** p95 frame time < 16.6 ms @ 1080p antes de marcar fase como done. + +--- + +## Ordem de execução recomendada + +1. **P0** (esta semana) — maior impacto nos spikes +2. **P1** — texturas são o suspeito #1 dos spikes atuais +3. **P2** — polígonos sem LOD matam GPU e CPU +4. **P4** — sombras GPU (infra já existe) +5. **P3** — PBR em cima de base estável +6. **P5 / P6** — polish visual avançado + +--- + +## Links + +| Documento | Conteúdo | +|-----------|----------| +| [`rendering/rhi.md`](../rendering/rhi.md) | Abstração SDL_GPU | +| [`rendering/batch-renderer.md`](../rendering/batch-renderer.md) | Sprites 2D (não 3D) | +| [`terrain/terrain-system.md`](../terrain/terrain-system.md) | Terreno LOD/chunks | +| [`plans/2026-05-23-3d-mesh-enhancements.md`](2026-05-23-3d-mesh-enhancements.md) | Plano anterior meshes | diff --git a/docs/plans/2026-09-22-engine-pillars-roadmap.md b/docs/plans/2026-09-22-engine-pillars-roadmap.md new file mode 100644 index 0000000..08390ca --- /dev/null +++ b/docs/plans/2026-09-22-engine-pillars-roadmap.md @@ -0,0 +1,334 @@ +# 🏛️ Engine Pillars — Roadmap de Sistemas Fundamentais + +> **Objetivo:** definir de forma **sólida e verificável** os subsistemas que a Caffeine Engine deve ter como base de produção — independentemente de features de editor ou plugins. +> **Critério de “completo”:** cada pilar tem requisitos, critérios de aceitação e estado atual documentados. +> **Última atualização:** 2026-09-22 + +Este documento complementa: +- [`2026-09-21-rendering-roadmap.md`](2026-09-21-rendering-roadmap.md) — detalhe técnico de rendering 3D +- [`../HISTORY.md`](../HISTORY.md) — fases históricas de desenvolvimento +- [`../../planning/caffeine-studio-ide.md`](../../planning/caffeine-studio-ide.md) — milestones do Doppio (IDE) + +--- + +## Prioridades + +| Tier | Significado | +|------|-------------| +| **P0 — Core** | Fundação obrigatória para jogos 3D sérios. Sem isto a engine não é “production-ready”. | +| **P1 — Production** | Necessário para títulos completos (multiplayer, animação rica, serialização avançada). | +| **P2 — Future** | Planeado, mas não bloqueia o primeiro release 3D. | +| **P3 — Maybe** | Desejável; depende de recursos e validação de mercado. | + +```mermaid +flowchart LR + subgraph P0["P0 — Core"] + R1[Phong 3D + sombras + bump] + E1[Eventos + timers + tipos custom] + PHY[Bullet3 rigid body] + AUD[OpenAL 3D] + ECS[ECS próprio + editor/runtime] + RES[Handles + refcount] + end + subgraph P1["P1 — Production"] + NET[Networking + serialização] + ANIM[Animation subsystem] + SCN[Scene + tipos de utilizador] + PBR[Deferred PBR] + end + subgraph P2["P2 — Future"] + SCR[C# ou Python] + end + subgraph P3["P3 — Maybe"] + AND[Android] + end + R1 --> PBR + ECS --> NET + ECS --> SCN + RES --> NET + R1 --> ANIM +``` + +--- + +## P0 — Core + +### 1. Rendering 3D Phong (sombras dinâmicas, bump mapping, câmaras) + +**Estado:** 🟡 Parcial — pipeline GPU híbrido; Lambert/PBR parcial; sombras CPU no editor; normal maps não ligados. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| Iluminação Phong (ambient + diffuse + specular) | Materiais com `shininess`, luzes direcionais/pontuais estáveis a 60 FPS | +| Sombras dinâmicas | Shadow mapping GPU para luz direcional principal; sem fallback CPU no caminho default | +| Bump / normal mapping | Normal maps em meshes e terreno; tangentes corretas no vertex buffer | +| Sistema de câmaras 3D | Componente `Camera3D` + follow/orbit/FPS; editor e runtime partilham a mesma API | +| Pipeline único GPU | Sem dual-path CPU raster no viewport shaded (ver rendering roadmap P0) | + +**Dependências:** RHI, mesh loading, materiais, `Camera3DComponent`. + +**Relação com PBR:** Phong é o **degrau imediato** (P0 rendering). O deferred PBR (P1) evolui sobre a mesma infraestrutura de G-buffer, shadow maps e materiais — não substitui o trabalho de câmaras, sombras e normal mapping. + +**Docs:** [`rendering/camera-3d.md`](../rendering/camera-3d.md), [`2026-09-21-rendering-roadmap.md`](2026-09-21-rendering-roadmap.md) + +--- + +### 2. Sistema de eventos (timers + tipos customizados) + +**Estado:** 🟡 Parcial — `EventBus` tipado com `publish` / `publishDeferred` existe; timers de engine e tipos custom registáveis ainda limitados. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| Pub/sub tipado | `subscribe` / `publish` com handles canceláveis | +| Eventos diferidos | Fila processada em `dispatch()` no game loop | +| **Timers** | `scheduleOnce`, `scheduleRepeating`, cancelamento por handle; integração com fixed timestep | +| **Tipos custom** | API para registar novos tipos de evento (ID estável ou RTTI controlado) sem recompilar o core | +| Thread-safety documentada | Regras claras: o quê pode ser chamado de jobs vs main thread | + +**Dependências:** Game loop, `EventBus`. + +**Docs:** [`core/events.md`](../core/events.md), [`core/timer.md`](../core/timer.md) + +--- + +### 3. Física rigid body (Bullet3) + +**Estado:** 🔴 Não iniciado (3D) — Physics 2D custom existe; menções a Jolt/Bullet em planning apenas. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| Integração **Bullet3** | `btDiscreteDynamicsWorld`, shapes (box, sphere, capsule, mesh convex), rigid bodies | +| ECS bridge | `RigidBody3D`, `Collider3D`, sync transform ↔ physics (editor + play mode) | +| Terreno | Heightfield ou mesh collision para terreno sculptado | +| Debug draw | Wireframes de colliders no viewport (toggle) | +| Determinismo razoável | Fixed step; documentar limitações cross-platform | + +**Dependências:** ECS, math, terreno collision mesh (`TerrainCollisionMeshBuilder`). + +**Nota:** Bullet3 escolhido explicitamente como backend; abstração `IPhysicsWorld3D` para não acoplar ECS ao Bullet em todo o código. + +--- + +### 4. Áudio 3D (OpenAL) + +**Estado:** 🟡 Parcial — áudio 2D/spatial básico; OpenAL não integrado como backend principal. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| Backend **OpenAL** | Contexto, buffers, sources, listener | +| Formatos comuns | WAV, OGG, FLAC (via decoder); MP3 opcional | +| Áudio 3D | Posição/orientação de listener e sources; atenuação e cone | +| ECS | `AudioEmitter3D`, `AudioListener` ligados a `Transform` / `Position3D` | +| Streaming | Música/long-form sem carregar ficheiro inteiro na RAM | + +**Dependências:** Asset manager, ECS, game loop. + +**Docs:** [`audio/audio-system.md`](../audio/audio-system.md) + +--- + +### 5. ECS próprio, integrado em runtime e editor + +**Estado:** 🟢 Avançado — `World` archetype-based, editor Doppio, runtime `caffeine-runtime`, serialização de cena. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| ECS **escrito pela equipa** | Sem dependência de flecs/EnTT em produção | +| Editor ↔ runtime | Mesmos componentes serializáveis; play mode não corrompe cena | +| Command buffer | Create/destroy seguro durante iteração | +| Reflection mínima | Inspector e serializers gerados ou registados por tipo | +| Systems pipeline | Ordem determinística; prioridades documentadas | + +**Dependências:** Memory, scene serializer. + +**Docs:** [`ecs/README.md`](../ecs/README.md), [`ecs/scene.md`](../ecs/scene.md) + +--- + +### 6. Gestão de recursos com handles e reference counting + +**Estado:** 🟡 Parcial — `AssetManager`, caches de mesh/textura; handles tipados e refcount explícito incompletos. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| **Handles opacos** | `TextureHandle`, `MeshHandle`, etc. — sem ponteiros expostos ao gameplay | +| **Reference counting** | Acquire/release; unload automático quando refcount = 0 | +| Resistente a erros | Load falhado → handle inválido + log; sem crash em uso de handle morto | +| Hot-reload | Recarregar asset mantém handles válidos ou invalidação explícita | +| Thread-safe load | Async load com callback ou future; main thread só consome handles prontos | + +**Dependências:** Job system, file I/O, formatos `.caf`. + +**Docs:** [`assets/asset-manager.md`](../assets/asset-manager.md) + +--- + +## P1 — Production + +### 7. Networking + serialização custom de objetos + +**Estado:** 🔴 Não iniciado. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| Transporte | UDP com camada de confiabilidade (ou TCP opcional para lobby) | +| **Serialização custom** | Formato binário versionado para componentes/entidades; sem JSON em hot path | +| Replicação ECS | Spawn/despawn/update de entidades autoritativas | +| Interpolação / predição | Documentada para transforms e física | +| Editor opcional | Painel de debug de rede (latency, packet loss) | + +**Dependências:** ECS, handles, event bus, scene serialization. + +--- + +### 8. Deferred rendering, physically-based (PBR) + +**Estado:** 🟡 Parcial — shader graph com nó PBR; pipeline deferred não completo. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| G-buffer | Albedo, normal, metallic-roughness, depth | +| Deferred lighting | Múltiplas luzes sem custo linear por objeto | +| PBR (GGX / Fresnel) | Materiais energy-conserving; IBL opcional | +| Coexistência com Phong | Migração gradual; materiais legacy convertíveis | + +**Dependências:** P0 rendering (sombras, normal maps, pipeline GPU único). + +**Docs:** [`2026-09-21-rendering-roadmap.md`](2026-09-21-rendering-roadmap.md) fases P3–P6. + +--- + +### 9. Subsistema de animação + +**Estado:** 🟡 Parcial — animation 2D, skeletal stubs, timeline no editor. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| Skeletal 3D | Bones, skinning GPU, import glTF | +| State machines | Transitions, blend trees | +| Animation 2D | Sprite clips (já parcial) | +| Timeline editor | Keyframes sincronizados com play mode | +| Eventos de animação | Footsteps, hit frames via event bus | + +**Dependências:** ECS, mesh loading, rendering. + +**Docs:** [`animation/skeletal-animation.md`](../animation/skeletal-animation.md) + +--- + +### 10. Scene management + serialização de tipos de utilizador + +**Estado:** 🟡 Parcial — `SceneSerializer`, blobs por componente; tipos user-defined limitados. + +**Deve ter de forma sólida:** + +| Requisito | Critério de aceitação | +|-----------|----------------------| +| Cenas hierárquicas | Load/save, streaming opcional | +| **Tipos de utilizador** | API para registar serializers de componentes custom (plugins, gameplay) | +| Versionamento | Migração de formatos `.cscene` / `.caf` | +| Prefabs | Instâncias com override por entidade | +| Async load | Progress callback; sem frame hitch | + +**Dependências:** ECS, asset handles, editor. + +**Docs:** [`ecs/scene.md`](../ecs/scene.md) + +--- + +## P2 — Future + +### 11. Scripting via C# ou Python + +**Estado:** 🟡 Lua em produção; C#/Python não decidido. + +**Direção:** + +| Opção | Prós | Contras | +|-------|------|---------| +| **C#** | Ecossistema Unity-like, tooling maduro | Runtime (Mono/CoreCLR), GC, build | +| **Python** | Prototipagem rápida | Performance, embedding, GIL | + +**Critério de aceitação (quando iniciar):** VM/embed estável, hot-reload, bindings ECS gerados ou reflection, sandbox documentado. + +**Nota:** Lua permanece suportado; C#/Python seria **camada adicional**, não substituição imediata. + +**Docs:** [`scripting/scripting.md`](../scripting/scripting.md) + +--- + +## P3 — Maybe + +### 12. Suporte a dispositivos Android + +**Estado:** 🔴 Não planeado em detalhe — Linux/desktop é o alvo atual. + +**Pré-requisitos antes de comprometer:** + +- RHI portável (Vulkan/ GLES via SDL3) +- Input touch + safe areas +- Asset pipeline mobile (compressão ASTC/ETC2) +- Bullet + OpenAL em ARM +- CI com build NDK + +**Critério para promover a P1:** produto ou parceiro com requisito Android confirmado. + +--- + +## Matriz resumo + +| # | Pilar | Tier | Estado | Backend / notas | +|---|-------|------|--------|-----------------| +| 1 | Phong 3D + sombras + bump + câmaras | P0 | 🟡 Parcial | GPU pipeline; ver rendering roadmap | +| 2 | Eventos + timers + tipos custom | P0 | 🟡 Parcial | `EventBus` existe | +| 3 | Rigid body 3D | P0 | 🔴 Não iniciado | **Bullet3** | +| 4 | Áudio 3D | P0 | 🟡 Parcial | **OpenAL** | +| 5 | ECS próprio + editor/runtime | P0 | 🟢 Avançado | Core da engine | +| 6 | Handles + refcount | P0 | 🟡 Parcial | `AssetManager` | +| 7 | Networking + serialização | P1 | 🔴 Não iniciado | Binário custom | +| 8 | Deferred PBR | P1 | 🟡 Parcial | Após Phong sólido | +| 9 | Animação | P1 | 🟡 Parcial | 2D + skeletal | +| 10 | Scene + tipos user | P1 | 🟡 Parcial | `SceneSerializer` | +| 11 | C# / Python | P2 | 🔴 Futuro | Lua atual | +| 12 | Android | P3 | 🔴 Maybe | Depende de RHI mobile | + +--- + +## Ordem de implementação sugerida + +1. **Handles + refcount** (desbloqueia assets estáveis em todo o resto) +2. **Phong + sombras + bump + câmaras** (rendering roadmap P0–P2) +3. **Eventos: timers + tipos custom** (desbloqueia gameplay e networking) +4. **Bullet3** (colisão 3D com terreno) +5. **OpenAL 3D** (paridade sensorial) +6. **Scene + serializers user** (plugins e jogos) +7. **Animação skeletal completa** +8. **Networking** +9. **Deferred PBR** +10. C# / Python / Android conforme prioridade de produto + +--- + +## Como usar este documento + +- **Antes de implementar** um subsistema: abrir o pilar correspondente e verificar critérios de aceitação. +- **Ao fechar uma feature:** atualizar coluna “Estado” neste ficheiro e linkar doc técnica em `docs//`. +- **Rendering:** detalhe fase-a-fase continua em [`2026-09-21-rendering-roadmap.md`](2026-09-21-rendering-roadmap.md); este doc define *o quê*; aquele define *como*. diff --git a/docs/rendering/post-processing.md b/docs/rendering/post-processing.md new file mode 100644 index 0000000..f8f0eb7 --- /dev/null +++ b/docs/rendering/post-processing.md @@ -0,0 +1,81 @@ +# 🎨 Post-Processing Stack + +> **Component:** `PostProcessComponent` on camera entities +> **Plugin:** `post_processing_plugin.so` +> **Lua API:** `caffeine.postprocess.*` +> **Preview:** ImGui overlay (editor); full GPU stack planned in rendering roadmap P3+ + +--- + +## Filosofia + +O stack é **modular** — cada efeito é um módulo independente com toggle e parâmetros. Presets (Cinematic, Horror, Arcade) são **benchmark looks** opcionais, não tipos de jogo. + +--- + +## Módulos de efeito + +| Módulo | Categoria | Parâmetros principais | +|--------|-----------|----------------------| +| **Anti-aliasing** | Pre-processing | FXAA / TAA, sharpness | +| **Ambient occlusion** | Pre-processing | intensity, radius, bias | +| **Auto exposure** | Color | min/max, adaptation speed | +| **Color grading** | Color | exposure, contrast, saturation, temperature, tint | +| **Bloom** | Image | intensity, threshold, scatter | +| **Depth of field** | Image | focus distance, aperture, max blur | +| **Motion blur** | Image | intensity, max velocity | +| **Chromatic aberration** | Image | intensity | +| **Lens distortion** | Image | intensity | +| **Grain** | Image | intensity, size | +| **Vignette** | Image | intensity, smoothness | +| **Screen space reflections** | Screen space | intensity, max roughness | +| **Deferred fog** | Atmosphere | density, start/end, color | + +--- + +## Editor + +1. Selecionar entidade **Camera2D** ou **Camera3D** +2. Menu **Effects → Add Post Process to Camera** ou botão no painel +3. Ativar módulos individualmente na secção correspondente +4. (Opcional) Aplicar benchmark look como ponto de partida + +UI partilhada: `include/caffeine/postprocess/PostProcessEditorUI.hpp` + +--- + +## Scripting + +### Controlar efeitos em runtime + +```lua +caffeine.postprocess.setEnabled(cameraId, true) +caffeine.postprocess.setExposure(cameraId, 1.2) +caffeine.postprocess.setBloom(cameraId, 0.3) +caffeine.postprocess.setVignette(cameraId, 0.4) +caffeine.postprocess.enableEffect(cameraId, "motionBlur", true) +caffeine.postprocess.enableEffect(cameraId, "ssr", true) +caffeine.postprocess.setCustomScript(cameraId, "scripts/postprocessing/custom_effect.lua") +local snapshot = caffeine.postprocess.get(cameraId) +``` + +### Efeitos custom (Lua) + +Definir `customEffectScript` no componente e implementar `onCreate` / `onUpdate` para animar ou combinar módulos. + +Template: `assets/plugins/post_processing/scripts/custom_effect.lua` + +Escrita de **efeitos GPU custom** (shaders HLSL/GLSL no stack) está planeado para quando o render graph GPU unificar passes de pós-processamento — ver [`plans/2026-09-21-rendering-roadmap.md`](../plans/2026-09-21-rendering-roadmap.md) fase P3+. + +--- + +## Ficheiros + +| Ficheiro | Função | +|----------|--------| +| `src/ecs/PostProcessComponents.hpp` | Structs por efeito | +| `include/caffeine/postprocess/PostProcessEditorUI.hpp` | UI ImGui partilhada | +| `include/caffeine/postprocess/PostProcessPresets.hpp` | Benchmark looks | +| `src/render/PostProcessRenderer.hpp` | Preview overlay (editor) | +| `src/script/PostProcessBindings.cpp` | Lua API | +| `apps/plugins/post_processing_plugin/` | Painel do plugin | diff --git a/docs/terrain/procedural-tools-plugin.md b/docs/terrain/procedural-tools-plugin.md new file mode 100644 index 0000000..50cf808 --- /dev/null +++ b/docs/terrain/procedural-tools-plugin.md @@ -0,0 +1,119 @@ +# 🎲 Procedural Tools Plugin + +> **Plugin:** `procedural_tools_plugin.so` +> **Scripts:** `assets/plugins/procedural/scripts/` +> **Runtime API:** `caffeine.procedural.*` (Lua) + +--- + +## Visão Geral + +Ferramentas **modulares** para streaming procedural — não é um sistema de “tipos de jogo”. Combina: + +| Ferramenta | Opções | Função | +|------------|--------|--------| +| **Terrain height** | `flat`, `hills`, `fbm` | Perfil de altura por chunk | +| **Structure spawner** | `none`, `markers`, `rooms`, `track` | Cubos opcionais por chunk | +| **Lua director** | qualquer `.lua` | Lógica de streaming em play mode | +| **Noise API** | `hash`, `noise2d`, `fbm2d` | Dados para scripts custom | + +Exploração, backrooms e corrida infinita são **benchmarks** (exemplos de referência), não arquetipos obrigatórios. + +--- + +## Setup (Editor) + +1. Abrir painel **Procedural Tools** +2. Configurar **Streaming** (seed, chunk size, view radius) +3. Escolher **Terrain height** e **Structure spawner** (ou `none` + Lua) +4. Definir **Script path** (default: `custom_director.lua`) +5. **Install Lua scripts** → copia templates para `scripts/procedural/` +6. **Create Streaming Setup** → cria `ProceduralTerrain` + `ProceduralDirector` +7. **Play** — chunks carregam à volta da câmara + +### Benchmarks + +Na secção **Benchmarks (examples)** — carrega uma cena de referência para estudar ou duplicar. Não limita o que podes construir. + +| Benchmark | Terrain | Structures | Script | +|-----------|---------|------------|--------| +| Exploration | hills | markers | `exploration_director.lua` | +| Backrooms | flat | rooms | `backrooms_director.lua` | +| Endless race | flat | track | `endless_race_director.lua` | + +--- + +## Lua API (`caffeine.procedural`) + +```lua +-- Noise / coords +caffeine.procedural.hash(x, z, seed, salt) +caffeine.procedural.noise2d(x, z, seed) +caffeine.procedural.fbm2d(x, z, seed, octaves, lacunarity, persistence) +caffeine.procedural.worldToChunk(worldCoord, chunkSize) + +-- Streaming +local focus = caffeine.procedural.getFocusChunk(directorId, chunkSize) +caffeine.procedural.stream(directorId, terrainId, focus.cx, focus.cz) +caffeine.procedural.isChunkLoaded(directorId, cx, cz) +caffeine.procedural.unloadChunk(directorId, cx, cz) + +-- Terrain bake +caffeine.procedural.fillTerrainNoise(terrainId, seed, amplitude) +caffeine.procedural.generateTerrainChunk(terrainId, cx, cz, seed, "hills") + +-- Structures +caffeine.procedural.spawnCube(directorId, cx, cz, x, y, z, sx, sy, sz) +``` + +--- + +## Director script (template) + +```lua +local state = { chunkSize = 64 } + +function onCreate(directorId) + state.id = directorId + state.terrain = caffeine.procedural.findTerrain() +end + +function onUpdate(directorId, dt) + local f = caffeine.procedural.getFocusChunk(state.id, state.chunkSize) + caffeine.procedural.stream(state.id, state.terrain, f.cx, f.cz) +end +``` + +--- + +## Componentes ECS + +| Campo | Função | +|-------|--------| +| `terrainProfile` | `flat`, `hills`, `fbm` | +| `structureProfile` | `none`, `markers`, `rooms`, `track` | +| `customScriptPath` | Lua director | +| `seed`, `chunkSize`, `viewRadius` | Streaming | +| `ProceduralSpawnTag` | Entidades removidas no unload | + +--- + +## Serviços (Plugin SDK) + +| Serviço | Uso | +|---------|-----| +| `procedural.installScripts` | Copia templates Lua | +| `procedural.createStreamingSetup` | Cria cena com ferramentas escolhidas | +| `procedural.loadBenchmark` | Carrega benchmark de referência | + +--- + +## Core (engine) + +| Módulo | Ficheiro | +|--------|----------| +| Profiles | `src/procedural/ProceduralProfiles.*` | +| Noise | `src/procedural/ProceduralNoise.*` | +| Geradores | `src/procedural/ProceduralGenerator.*` | +| Streaming | `src/procedural/ProceduralStreamer.*` | +| Tick play mode | `src/procedural/ProceduralWorldSystem.*` | diff --git a/docs/terrain/terrain-plugin.md b/docs/terrain/terrain-plugin.md new file mode 100644 index 0000000..d2f47d0 --- /dev/null +++ b/docs/terrain/terrain-plugin.md @@ -0,0 +1,88 @@ +# 🌋 Terrain Generator Plugin + +> **Tipo:** plugin do editor (`terrain_plugin.so`) +> **Algoritmo:** `assets/general-ultra-realistic-terrain-algorithm` +> **Host API:** `PluginHostApi::terrainGenerateUltra`, `terrainImportHeightmap` + +--- + +## Visão Geral + +O plugin **Terrain Generator** move a geração procedural para fora do core. Usa o pipeline Node.js com **domain warping** e **PDEs geomorfológicas** (versão mais completa que o gerador C++ antigo removido do engine). + +**Fluxo:** + +1. Utilizador seleciona entidade com `TerrainComponent`. +2. Painel **Terrain Generator** → preset + world size + grid + seed. +3. Host executa `node caffeine-export.js` no diretório do algoritmo. +4. `heightmap.txt` + `meta.json` são importados via `TerrainHeightmapImporter`. +5. `TerrainCache` reconstrói mesh render, LOD e mesh de colisão. + +--- + +## Presets + +| Preset | Descrição | +|--------|-----------| +| `default` | Erosão + tectônica balanceada | +| `alpine` | Tectônica + gelo | +| `mesa` | Caprock + deslizamentos | +| `volcanic` | Cone + caldeira | +| `glacial` | Vales em U | +| `blend` | Mistura alpine ⊕ mesa | + +--- + +## CLI de exportação (Caffeine) + +```bash +cd assets/general-ultra-realistic-terrain-algorithm +npm install # primeira vez (simplex-noise) +node caffeine-export.js alpine \ + --out /tmp/heightmap.txt \ + --meta /tmp/meta.json \ + --world-size 256 \ + --grid-size 257 \ + --seed 42 +``` + +`meta.json` inclui `minHeight`, `maxHeight`, `worldSize`, `gridSize` para o importador. + +--- + +## Host API (plugins) + +```cpp +struct PluginHostApi { + bool (*terrainGenerateUltra)(void* ctx, u32 entityId, const char* preset, u32 seed, + float worldSizeMeters, u32 gridSize); + bool (*terrainImportHeightmap)(void* ctx, u32 entityId, const char* heightmapPath, + float worldSizeX, float worldSizeZ); +}; +``` + +Implementação: `TerrainBridge` exposto via serviço `terrain.generateUltra` (`PluginServiceRegistry`). + +--- + +## Desenvolvimento + +| Ficheiro | Descrição | +|----------|-----------| +| `apps/plugins/terrain_plugin/main.cpp` | UI ImGui do plugin | +| `apps/plugins/terrain_plugin/TerrainBridge.cpp` | Spawn Node + import (no Doppio core) | +| `src/terrain/TerrainHeightmapImporter.cpp` | Parse `.txt` → heightmap | + +Build: `terrain_plugin` é copiado para `doppio/plugins/` no POST_BUILD do editor. + +--- + +## Migração do core antigo + +Removido de `caffeine-core`: + +- `src/terrain/generation/*` (TerrainGenerator, noise, hydrology, geo sim) +- Tab **Generate** do Terrain Editor (graph imnodes) +- Campo `TerrainComponent::generation` + +Cenas antigas com blob v2–v8 ainda carregam; parâmetros de geração são ignorados. Re-gerar terreno com o plugin se necessário. diff --git a/docs/terrain/terrain-system.md b/docs/terrain/terrain-system.md new file mode 100644 index 0000000..894d930 --- /dev/null +++ b/docs/terrain/terrain-system.md @@ -0,0 +1,102 @@ +# 🏔️ Terrain System + +> **Namespace:** `Caffeine::Terrain`, `Caffeine::ECS::TerrainComponent` +> **Arquivos:** `src/terrain/*`, `src/ecs/TerrainComponents.hpp` +> **Status:** ✅ Implementado (runtime + editor sculpt/paint) +> **Geração procedural:** plugin — ver [`terrain-plugin.md`](terrain-plugin.md) + +--- + +## Visão Geral + +O terreno na Caffeine é um **heightfield editável**: dados em `TerrainHeightmap`, renderização via `TerrainMeshBuilder` + LOD por chunks, pintura de splat em `TerrainSplatmap`. A engine **não** embute mais geração procedural no core — isso ficou no plugin **Terrain Generator**, que usa o algoritmo em `assets/general-ultra-realistic-terrain-algorithm`. + +**Separação de responsabilidades:** + +| Camada | Responsabilidade | +|--------|------------------| +| **Core** | Heightmap, mesh, LOD, colisão coarse, sculpt/paint, `.cterrain` | +| **Terrain Editor** | Ferramentas de escultura, splat e export de dados | +| **Terrain Generator (plugin)** | Presets, Node.js, import de heightmap | + +--- + +## TerrainComponent + +```cpp +struct TerrainComponent { + u32 resolutionX = 257; + u32 resolutionZ = 257; + f32 worldSizeX, worldSizeZ; // metros (1 u = 1 m) + f32 maxHeight; // altura 0–1 do heightmap → metros + + bool useChunks = true; + u32 chunkVertexCount = 33; + u32 maxLodLevels = 2; + f32 lodDistanceScale = 520.0f; + + u32 collisionSampleStep = 4; // passo do mesh de colisão + bool buildCollisionMesh = true; + + char terrainDataPath[256]; // caminho .cterrain no projeto +}; +``` + +O heightmap interno guarda valores **normalizados** `[0, 1]`. Na mesh, `y = sample * maxHeight`. + +--- + +## Pipeline de Dados + +``` +TerrainComponent (ECS) + │ + ▼ +TerrainCache::initializeEntity / syncEntity + │ + ├── TerrainHeightmap (CPU, editável) + ├── TerrainSplatmap (4 camadas) + ├── Mesh3D render (TerrainMeshBuilder + tangentes) + ├── Mesh3D collision (TerrainCollisionMeshBuilder, decimado) + └── TerrainChunk[] (LOD + frustum cull) +``` + +### TerrainMeshBuilder + +- Gera vértices com normais do heightmap (`sampleNormalBilinear`). +- UVs em tiling conforme `textureTileSize` / `splatTileSize`. +- **Tangentes** calculadas por triângulo (base para normal mapping). + +### Colisão + +`TerrainCollisionMeshBuilder` reutiliza `buildRegion` com `collisionSampleStep` (ex.: 4 → ~16× menos triângulos). Acesso via `TerrainCache::collisionMeshFor(entity)`. + +--- + +## Editor + +| Painel | Função | +|--------|--------| +| **Inspector → Terrain** | Resolução, tamanho, LOD, splat, colisão | +| **Terrain Editor** | Sculpt (raise/lower/smooth/flatten/noise), splat paint, export `.cterrain` | + +Geração procedural: painel **Terrain Generator** (plugin). + +--- + +## Serialização + +`SceneSerializer` grava `TerrainComponent` no blob de cena (versão **9**). Campos de geração legados (v2–v8) são lidos e descartados para compatibilidade. Dados de height/splat pesados vivem em `.cterrain` referenciado por `terrainDataPath`. + +--- + +## API Útil + +```cpp +TerrainCache& cache = TerrainCache::instance(); +cache.initializeEntity(world, entity); +cache.heightmapFor(entity)->setNormalized(x, z, h); +cache.syncEntity(world, entity); + +Terrain::importHeightmapFile(world, entity, path, worldSizeX, worldSizeZ); +``` diff --git a/include/caffeine/plugin/PluginAPI.hpp b/include/caffeine/plugin/PluginAPI.hpp new file mode 100644 index 0000000..a6e4a24 --- /dev/null +++ b/include/caffeine/plugin/PluginAPI.hpp @@ -0,0 +1,90 @@ +#pragma once + +/** + * Caffeine Plugin SDK — public API for editor plugins (.so / .dll). + * + * Plugins are loaded dynamically from the plugins/ directory at runtime. + * They link only against this header + ImGui; the host (Doppio) provides callbacks. + * + * See docs/editor/plugin-sdk.md + */ + +#include +#include + +#if defined(_WIN32) + #ifdef CAFFEINE_PLUGIN_EXPORTS + #define CAFFEINE_PLUGIN_API __declspec(dllexport) + #else + #define CAFFEINE_PLUGIN_API __declspec(dllimport) + #endif +#else + #define CAFFEINE_PLUGIN_API __attribute__((visibility("default"))) +#endif + +using CaffeinePluginU32 = std::uint32_t; + +namespace Caffeine::Editor { + +class IPlugin { +public: + virtual ~IPlugin() = default; + virtual void OnLoad() = 0; + virtual void OnUnload() = 0; + virtual void OnUpdate(float dt) = 0; + virtual const char* GetName() const = 0; + virtual const char* GetVersion() const = 0; + virtual const char* GetDescription() const { return ""; } +}; + +using PluginServiceHandler = bool (*)(void* editorContext, const void* request, std::size_t requestSize, + void* response, std::size_t responseSize); + +struct PluginHostApi { + void* editorContext = nullptr; + /// Stable plugin id (library stem). Set by the host before OnLoad(). + const char* pluginId = nullptr; + + void (*logInfo)(void* ctx, const char* message) = nullptr; + void (*logError)(void* ctx, const char* message) = nullptr; + + void* (*getActiveWorld)(void* editorContext) = nullptr; + CaffeinePluginU32 (*getSelectedEntityId)(void* editorContext) = nullptr; + void (*markSceneDirty)(void* editorContext) = nullptr; + + /// Fills buffer with open project's root path (parent of scenes/). Returns false if no project. + bool (*getProjectRootPath)(void* editorContext, char* buffer, std::size_t bufferSize) = nullptr; + + bool (*registerPanel)(void* ctx, const char* pluginName, const char* title, + void (*renderFn)(void* userData), void* userData) = nullptr; + /// Opens a panel previously registered with registerPanel (matches title). + bool (*openPanel)(void* ctx, const char* title) = nullptr; + bool (*registerMenuAction)(void* ctx, const char* pluginName, const char* menuPath, + void (*actionFn)(void* userData), void* userData) = nullptr; + bool (*registerComponentDrawer)(void* ctx, const char* pluginName, CaffeinePluginU32 componentTypeId, + void (*drawFn)(void* componentData, void* userData), + void* userData) = nullptr; + bool (*registerEntityPresetManifest)(void* ctx, const char* pluginName, + const char* manifestPath) = nullptr; + + CaffeinePluginU32 (*getComponentTypeId)(void* ctx, const char* componentName) = nullptr; + + /// Generic service dispatch — plugins call host capabilities by name (see plugin-sdk.md). + bool (*invokeService)(void* ctx, const char* serviceName, const void* request, + std::size_t requestSize, void* response, std::size_t responseSize) = nullptr; + + /// Plugins may register additional services (for aggregator / store plugins). + bool (*registerService)(void* ctx, const char* pluginName, const char* serviceName, + PluginServiceHandler handler) = nullptr; +}; + +using CreatePluginFn = IPlugin* (*)(const PluginHostApi* host); +using DestroyPluginFn = void (*)(IPlugin* plugin); + +} // namespace Caffeine::Editor + +extern "C" { + CAFFEINE_PLUGIN_API Caffeine::Editor::IPlugin* CreatePlugin( + const Caffeine::Editor::PluginHostApi* host); + CAFFEINE_PLUGIN_API void DestroyPlugin(Caffeine::Editor::IPlugin* plugin); +} diff --git a/include/caffeine/plugin/PluginServices.hpp b/include/caffeine/plugin/PluginServices.hpp new file mode 100644 index 0000000..24c06b0 --- /dev/null +++ b/include/caffeine/plugin/PluginServices.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "caffeine/plugin/PluginAPI.hpp" +#include + +namespace Caffeine::Editor { + +// Well-known service names (host may implement these when matching plugins are absent). +inline constexpr const char* kServiceTerrainGenerateUltra = "terrain.generateUltra"; +inline constexpr const char* kServiceTerrainImportHeightmap = "terrain.importHeightmap"; +inline constexpr const char* kServiceProceduralInstallScripts = "procedural.installScripts"; +inline constexpr const char* kServiceProceduralCreateStreamingSetup = "procedural.createStreamingSetup"; +inline constexpr const char* kServiceProceduralLoadBenchmark = "procedural.loadBenchmark"; +/// @deprecated Use createStreamingSetup or loadBenchmark. +inline constexpr const char* kServiceProceduralCreateDirector = "procedural.createDirector"; + +struct TerrainGenerateUltraRequest { + CaffeinePluginU32 entityId = 0; + char preset[64] = "default"; + CaffeinePluginU32 seed = 42; + float worldSizeMeters = 256.0f; + CaffeinePluginU32 gridSize = 257; +}; + +struct TerrainImportHeightmapRequest { + CaffeinePluginU32 entityId = 0; + char heightmapPath[512] = {}; + float worldSizeX = 0.0f; + float worldSizeZ = 0.0f; +}; + +struct ProceduralStreamingSetupRequest { + char scriptPath[256] = "scripts/procedural/custom_director.lua"; + char terrainProfile[64] = "hills"; + char structureProfile[64] = "none"; + CaffeinePluginU32 seed = 42; + float chunkSizeMeters = 64.0f; + CaffeinePluginU32 viewRadiusChunks = 4; +}; + +struct ProceduralLoadBenchmarkRequest { + char benchmarkName[64] = "exploration"; + CaffeinePluginU32 seed = 42; + float chunkSizeMeters = 64.0f; + CaffeinePluginU32 viewRadiusChunks = 4; +}; + +struct ProceduralSetupResponse { + CaffeinePluginU32 directorEntityId = 0; + CaffeinePluginU32 terrainEntityId = 0; +}; + +using ProceduralCreateDirectorRequest = ProceduralStreamingSetupRequest; +using ProceduralCreateDirectorResponse = ProceduralSetupResponse; + +} // namespace Caffeine::Editor diff --git a/include/caffeine/postprocess/PostProcessEditorUI.hpp b/include/caffeine/postprocess/PostProcessEditorUI.hpp new file mode 100644 index 0000000..ed745f5 --- /dev/null +++ b/include/caffeine/postprocess/PostProcessEditorUI.hpp @@ -0,0 +1,190 @@ +#pragma once + +#include "caffeine/postprocess/PostProcessPresets.hpp" +#include "ecs/PostProcessComponents.hpp" + +#ifdef CF_HAS_IMGUI +#include +#endif + +namespace Caffeine::PostProcess::EditorUI { + +#ifdef CF_HAS_IMGUI + +inline bool dragEffectBool(const char* label, bool& value) { + return ImGui::Checkbox(label, &value); +} + +inline bool drawBenchmarkPresets(ECS::PostProcessComponent& fx) { + bool dirty = false; + ImGui::TextDisabled("Benchmark looks (optional starting points)"); + if (ImGui::Button("Cinematic")) { + applyBenchmarkCinematic(fx); + dirty = true; + } + ImGui::SameLine(); + if (ImGui::Button("Horror")) { + applyBenchmarkHorror(fx); + dirty = true; + } + ImGui::SameLine(); + if (ImGui::Button("Arcade")) { + applyBenchmarkArcade(fx); + dirty = true; + } + ImGui::SameLine(); + if (ImGui::Button("Reset")) { + resetAllEffects(fx); + dirty = true; + } + return dirty; +} + +inline bool drawEffectStack(ECS::PostProcessComponent& fx) { + bool dirty = false; + + ImGui::TextColored(ImVec4(0.75f, 0.8f, 0.95f, 1.0f), + "Editor preview overlays are active in the Scene Viewport and camera previews. " + "Full GPU post-processing passes are not wired yet."); + ImGui::Spacing(); + + if (ImGui::Checkbox("Stack enabled", &fx.enabled)) dirty = true; + + if (ImGui::CollapsingHeader("Pre-processing", ImGuiTreeNodeFlags_DefaultOpen)) { + if (dragEffectBool("Anti-aliasing", fx.antiAliasing.enabled)) dirty = true; + if (fx.antiAliasing.enabled) { + const char* modes[] = {"FXAA", "TAA"}; + int mode = static_cast(fx.antiAliasing.mode); + if (ImGui::Combo("AA mode", &mode, modes, 2)) { + fx.antiAliasing.mode = static_cast(mode); + dirty = true; + } + if (ImGui::SliderFloat("Sharpness", &fx.antiAliasing.sharpness, 0.0f, 1.0f)) { + dirty = true; + } + } + if (dragEffectBool("Ambient occlusion", fx.ambientOcclusion.enabled)) dirty = true; + if (fx.ambientOcclusion.enabled) { + if (ImGui::SliderFloat("AO intensity", &fx.ambientOcclusion.intensity, 0.0f, 2.0f)) { + dirty = true; + } + if (ImGui::SliderFloat("AO radius", &fx.ambientOcclusion.radius, 0.1f, 2.0f)) { + dirty = true; + } + } + } + + if (ImGui::CollapsingHeader("Color & exposure", ImGuiTreeNodeFlags_DefaultOpen)) { + if (dragEffectBool("Auto exposure", fx.autoExposure.enabled)) dirty = true; + if (fx.autoExposure.enabled) { + if (ImGui::SliderFloat("Min exposure", &fx.autoExposure.minExposure, 0.05f, 2.0f)) { + dirty = true; + } + if (ImGui::SliderFloat("Max exposure", &fx.autoExposure.maxExposure, 1.0f, 8.0f)) { + dirty = true; + } + if (ImGui::SliderFloat("Adapt speed", &fx.autoExposure.adaptationSpeed, 0.1f, 5.0f)) { + dirty = true; + } + } + if (dragEffectBool("Color grading", fx.colorGrading.enabled)) dirty = true; + if (fx.colorGrading.enabled) { + if (ImGui::SliderFloat("Exposure", &fx.colorGrading.exposure, 0.2f, 4.0f)) dirty = true; + if (ImGui::SliderFloat("Contrast", &fx.colorGrading.contrast, 0.5f, 2.0f)) dirty = true; + if (ImGui::SliderFloat("Saturation", &fx.colorGrading.saturation, 0.0f, 2.0f)) { + dirty = true; + } + if (ImGui::SliderFloat("Temperature", &fx.colorGrading.temperature, -1.0f, 1.0f)) { + dirty = true; + } + if (ImGui::SliderFloat("Tint", &fx.colorGrading.tint, -1.0f, 1.0f)) dirty = true; + } + } + + if (ImGui::CollapsingHeader("Image effects", ImGuiTreeNodeFlags_DefaultOpen)) { + if (dragEffectBool("Bloom", fx.bloom.enabled)) dirty = true; + if (fx.bloom.enabled) { + if (ImGui::SliderFloat("Bloom intensity", &fx.bloom.intensity, 0.0f, 1.0f)) dirty = true; + if (ImGui::SliderFloat("Threshold", &fx.bloom.threshold, 0.5f, 2.0f)) dirty = true; + if (ImGui::SliderFloat("Scatter", &fx.bloom.scatter, 0.0f, 1.0f)) dirty = true; + } + if (dragEffectBool("Depth of field", fx.depthOfField.enabled)) dirty = true; + if (fx.depthOfField.enabled) { + if (ImGui::SliderFloat("Focus distance", &fx.depthOfField.focusDistance, 0.5f, 100.0f)) { + dirty = true; + } + if (ImGui::SliderFloat("Aperture", &fx.depthOfField.aperture, 0.01f, 1.0f)) dirty = true; + if (ImGui::SliderFloat("Max blur", &fx.depthOfField.maxBlur, 0.0f, 2.0f)) dirty = true; + } + if (dragEffectBool("Motion blur", fx.motionBlur.enabled)) dirty = true; + if (fx.motionBlur.enabled) { + if (ImGui::SliderFloat("Motion blur", &fx.motionBlur.intensity, 0.0f, 1.0f)) { + dirty = true; + } + } + if (dragEffectBool("Chromatic aberration", fx.chromaticAberration.enabled)) dirty = true; + if (fx.chromaticAberration.enabled) { + if (ImGui::SliderFloat("Chromatic", &fx.chromaticAberration.intensity, 0.0f, 1.0f)) { + dirty = true; + } + } + if (dragEffectBool("Lens distortion", fx.lensDistortion.enabled)) dirty = true; + if (fx.lensDistortion.enabled) { + if (ImGui::SliderFloat("Distortion", &fx.lensDistortion.intensity, -1.0f, 1.0f)) { + dirty = true; + } + } + if (dragEffectBool("Grain", fx.grain.enabled)) dirty = true; + if (fx.grain.enabled) { + if (ImGui::SliderFloat("Grain", &fx.grain.intensity, 0.0f, 1.0f)) dirty = true; + if (ImGui::SliderFloat("Grain size", &fx.grain.size, 0.5f, 3.0f)) dirty = true; + } + if (dragEffectBool("Vignette", fx.vignette.enabled)) dirty = true; + if (fx.vignette.enabled) { + if (ImGui::SliderFloat("Vignette", &fx.vignette.intensity, 0.0f, 1.0f)) dirty = true; + if (ImGui::SliderFloat("Vignette smooth", &fx.vignette.smoothness, 0.0f, 1.0f)) { + dirty = true; + } + } + } + + if (ImGui::CollapsingHeader("Screen space", ImGuiTreeNodeFlags_DefaultOpen)) { + if (dragEffectBool("Screen space reflections", fx.screenSpaceReflections.enabled)) { + dirty = true; + } + if (fx.screenSpaceReflections.enabled) { + if (ImGui::SliderFloat("SSR intensity", &fx.screenSpaceReflections.intensity, 0.0f, 1.0f)) { + dirty = true; + } + if (ImGui::SliderFloat("Max roughness", &fx.screenSpaceReflections.maxRoughness, 0.0f, 1.0f)) { + dirty = true; + } + } + } + + if (ImGui::CollapsingHeader("Atmosphere")) { + if (dragEffectBool("Deferred fog", fx.deferredFog.enabled)) dirty = true; + if (fx.deferredFog.enabled) { + if (ImGui::SliderFloat("Fog density", &fx.deferredFog.density, 0.0f, 0.2f)) dirty = true; + if (ImGui::SliderFloat("Fog start", &fx.deferredFog.start, 0.0f, 500.0f)) dirty = true; + if (ImGui::SliderFloat("Fog end", &fx.deferredFog.end, 1.0f, 2000.0f)) dirty = true; + if (ImGui::ColorEdit3("Fog color", &fx.deferredFog.colorR)) dirty = true; + } + } + + if (ImGui::CollapsingHeader("Scripting")) { + ImGui::TextWrapped( + "Attach a Lua module to drive the stack at runtime, or author custom effect logic."); + if (ImGui::InputText("Custom effect script", fx.customEffectScript, + sizeof(fx.customEffectScript))) { + dirty = true; + } + ImGui::TextDisabled("API: caffeine.postprocess.* — see docs/rendering/post-processing.md"); + } + + return dirty; +} + +#endif + +} // namespace Caffeine::PostProcess::EditorUI diff --git a/include/caffeine/postprocess/PostProcessPresets.hpp b/include/caffeine/postprocess/PostProcessPresets.hpp new file mode 100644 index 0000000..d93a287 --- /dev/null +++ b/include/caffeine/postprocess/PostProcessPresets.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "ecs/PostProcessComponents.hpp" + +namespace Caffeine::PostProcess { + +/// Benchmark looks — starting points, not game types. +inline void applyBenchmarkCinematic(ECS::PostProcessComponent& fx) { + fx.enabled = true; + fx.colorGrading.enabled = true; + fx.colorGrading.exposure = 1.05f; + fx.colorGrading.contrast = 1.1f; + fx.colorGrading.saturation = 1.05f; + fx.vignette.enabled = true; + fx.vignette.intensity = 0.35f; + fx.bloom.enabled = true; + fx.bloom.intensity = 0.15f; + fx.chromaticAberration.enabled = true; + fx.chromaticAberration.intensity = 0.02f; + fx.grain.enabled = true; + fx.grain.intensity = 0.04f; + fx.depthOfField.enabled = true; + fx.depthOfField.focusDistance = 12.0f; + fx.depthOfField.aperture = 0.08f; +} + +inline void applyBenchmarkHorror(ECS::PostProcessComponent& fx) { + fx.enabled = true; + fx.colorGrading.enabled = true; + fx.colorGrading.exposure = 0.75f; + fx.colorGrading.contrast = 1.25f; + fx.colorGrading.saturation = 0.7f; + fx.colorGrading.temperature = -0.15f; + fx.vignette.enabled = true; + fx.vignette.intensity = 0.55f; + fx.grain.enabled = true; + fx.grain.intensity = 0.12f; + fx.chromaticAberration.enabled = true; + fx.chromaticAberration.intensity = 0.08f; + fx.deferredFog.enabled = true; + fx.deferredFog.density = 0.04f; + fx.ambientOcclusion.enabled = true; + fx.ambientOcclusion.intensity = 1.2f; +} + +inline void applyBenchmarkArcade(ECS::PostProcessComponent& fx) { + fx.enabled = true; + fx.colorGrading.enabled = true; + fx.colorGrading.exposure = 1.2f; + fx.colorGrading.contrast = 1.15f; + fx.colorGrading.saturation = 1.3f; + fx.bloom.enabled = true; + fx.bloom.intensity = 0.25f; + fx.bloom.scatter = 0.85f; + fx.vignette.enabled = true; + fx.vignette.intensity = 0.1f; + fx.motionBlur.enabled = true; + fx.motionBlur.intensity = 0.15f; + fx.antiAliasing.enabled = true; + fx.antiAliasing.mode = 0; +} + +inline void resetAllEffects(ECS::PostProcessComponent& fx) { + fx = ECS::PostProcessComponent {}; +} + +} // namespace Caffeine::PostProcess diff --git a/planning/caffeine-studio-ide.md b/planning/caffeine-studio-ide.md index ae653b3..d778734 100644 --- a/planning/caffeine-studio-ide.md +++ b/planning/caffeine-studio-ide.md @@ -22,6 +22,8 @@ Este plano divide o desenvolvimento em **4 milestones** progressivas: > **Nota:** Cada milestone é autónoma — pode ser entregue e testada independentemente. +> **Engine pillars:** subsistemas de runtime (rendering Phong, Bullet3, OpenAL, networking, PBR, etc.) estão no roadmap de pilares — [`docs/plans/2026-09-22-engine-pillars-roadmap.md`](../docs/plans/2026-09-22-engine-pillars-roadmap.md). + --- ## Estado Atual (Pré-M1) diff --git a/src/ecs/PostProcessComponents.hpp b/src/ecs/PostProcessComponents.hpp index 94107a2..882d084 100644 --- a/src/ecs/PostProcessComponents.hpp +++ b/src/ecs/PostProcessComponents.hpp @@ -1,19 +1,157 @@ #pragma once #include "core/Types.hpp" +#include namespace Caffeine::ECS { -struct PostProcessComponent { - bool enabled = true; +/// Modular post-processing stack — each effect can be toggled and tuned independently. +/// GPU passes are planned; editor preview uses ImGui overlays until the render graph is wired. + +struct PostProcessAmbientOcclusion { + bool enabled = false; + f32 intensity = 1.0f; + f32 radius = 0.5f; + f32 bias = 0.025f; +}; + +struct PostProcessAntiAliasing { + bool enabled = false; + /// 0 = FXAA, 1 = TAA (placeholder indices for future GPU path) + u32 mode = 0; + f32 sharpness = 0.5f; +}; + +struct PostProcessAutoExposure { + bool enabled = false; + f32 minExposure = 0.2f; + f32 maxExposure = 4.0f; + f32 adaptationSpeed = 1.0f; + f32 compensation = 0.0f; +}; + +struct PostProcessBloom { + bool enabled = false; + f32 intensity = 0.0f; + f32 threshold = 1.0f; + f32 scatter = 0.7f; +}; +struct PostProcessChromaticAberration { + bool enabled = false; + f32 intensity = 0.0f; +}; + +struct PostProcessColorGrading { + bool enabled = true; f32 exposure = 1.0f; f32 contrast = 1.0f; f32 saturation = 1.0f; - f32 vignette = 0.0f; - f32 bloom = 0.0f; - f32 chromaticAberration = 0.0f; - f32 filmGrain = 0.0f; + f32 temperature = 0.0f; + f32 tint = 0.0f; +}; + +struct PostProcessDeferredFog { + bool enabled = false; + f32 density = 0.02f; + f32 start = 10.0f; + f32 end = 200.0f; + f32 colorR = 0.7f; + f32 colorG = 0.75f; + f32 colorB = 0.85f; +}; + +struct PostProcessDepthOfField { + bool enabled = false; + f32 focusDistance = 10.0f; + f32 aperture = 0.1f; + f32 focalLength = 50.0f; + f32 maxBlur = 1.0f; +}; + +struct PostProcessGrain { + bool enabled = false; + f32 intensity = 0.0f; + f32 size = 1.0f; +}; + +struct PostProcessLensDistortion { + bool enabled = false; + f32 intensity = 0.0f; +}; + +struct PostProcessMotionBlur { + bool enabled = false; + f32 intensity = 0.0f; + f32 maxVelocity = 1.0f; +}; + +struct PostProcessScreenSpaceReflections { + bool enabled = false; + f32 intensity = 0.5f; + f32 maxRoughness = 0.6f; + u32 maxSteps = 64; +}; + +struct PostProcessVignette { + bool enabled = false; + f32 intensity = 0.0f; + f32 smoothness = 0.5f; +}; + +struct PostProcessComponent { + bool enabled = true; + + PostProcessAmbientOcclusion ambientOcclusion; + PostProcessAntiAliasing antiAliasing; + PostProcessAutoExposure autoExposure; + PostProcessBloom bloom; + PostProcessChromaticAberration chromaticAberration; + PostProcessColorGrading colorGrading; + PostProcessDeferredFog deferredFog; + PostProcessDepthOfField depthOfField; + PostProcessGrain grain; + PostProcessLensDistortion lensDistortion; + PostProcessMotionBlur motionBlur; + PostProcessScreenSpaceReflections screenSpaceReflections; + PostProcessVignette vignette; + + /// Optional Lua module: onPostProcess(entityId, dt, api) for custom stacks / effect logic. + char customEffectScript[256] = {}; + + // Legacy flat accessors (sync with modular fields for older code paths). + f32 exposure() const { return colorGrading.exposure; } + void setExposure(f32 v) { colorGrading.exposure = v; } + + f32 contrast() const { return colorGrading.contrast; } + void setContrast(f32 v) { colorGrading.contrast = v; } + + f32 saturation() const { return colorGrading.saturation; } + void setSaturation(f32 v) { colorGrading.saturation = v; } + + f32 vignetteIntensity() const { return vignette.intensity; } + void setVignetteIntensity(f32 v) { + vignette.intensity = v; + vignette.enabled = v > 0.001f; + } + + f32 bloomIntensity() const { return bloom.intensity; } + void setBloomIntensity(f32 v) { + bloom.intensity = v; + bloom.enabled = v > 0.001f; + } + + f32 chromaticIntensity() const { return chromaticAberration.intensity; } + void setChromaticIntensity(f32 v) { + chromaticAberration.intensity = v; + chromaticAberration.enabled = v > 0.001f; + } + + f32 filmGrainIntensity() const { return grain.intensity; } + void setFilmGrainIntensity(f32 v) { + grain.intensity = v; + grain.enabled = v > 0.001f; + } }; } // namespace Caffeine::ECS diff --git a/src/ecs/ProceduralComponents.hpp b/src/ecs/ProceduralComponents.hpp new file mode 100644 index 0000000..2ce8723 --- /dev/null +++ b/src/ecs/ProceduralComponents.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "core/Types.hpp" +#include + +namespace Caffeine::ECS { + +/// Marks entities spawned by procedural streaming (removed on chunk unload). +struct ProceduralSpawnTag { + u32 directorEntityId = 0; + i32 chunkX = 0; + i32 chunkZ = 0; +}; + +/// Drives infinite / streaming procedural worlds from Lua scripts. +struct ProceduralWorldComponent { + u32 seed = 1337; + f32 chunkSize = 64.0f; + u32 viewRadius = 4; + u32 terrainEntityId = 0; + u32 focusEntityId = 0; + + /// Built-in terrain height: flat, hills, fbm + char terrainProfile[64] = "hills"; + + /// Built-in chunk structures: none, markers, rooms, track + char structureProfile[64] = "none"; + + /// Legacy combined preset (exploration, backrooms, …) — prefer terrain/structure profiles + char preset[64] = {}; + + /// Lua director script (streaming logic in play mode). + char customScriptPath[256] = "scripts/procedural/custom_director.lua"; + + bool enabled = true; +}; + +} // namespace Caffeine::ECS diff --git a/src/ecs/TerrainComponents.hpp b/src/ecs/TerrainComponents.hpp index 8a2a4a5..002feaa 100644 --- a/src/ecs/TerrainComponents.hpp +++ b/src/ecs/TerrainComponents.hpp @@ -2,7 +2,6 @@ #include "core/Types.hpp" #include "core/WorldUnits.hpp" -#include "terrain/generation/TerrainGeneratorTypes.hpp" #include namespace Caffeine::ECS { @@ -41,9 +40,11 @@ struct TerrainComponent { f32 lodDistanceScale = 520.0f; f32 lodHysteresis = 0.25f; - char terrainDataPath[256] = {}; + /// Sample step for the collision mesh (1 = full resolution, 4 = ~16x fewer tris). + u32 collisionSampleStep = 4; + bool buildCollisionMesh = true; - TerrainGenerationSettings generation; + char terrainDataPath[256] = {}; }; } // namespace Caffeine::ECS diff --git a/src/editor/Camera2DPreviewRenderer.hpp b/src/editor/Camera2DPreviewRenderer.hpp new file mode 100644 index 0000000..260a8a5 --- /dev/null +++ b/src/editor/Camera2DPreviewRenderer.hpp @@ -0,0 +1,205 @@ +#pragma once + +#include "editor/EditorContext.hpp" +#include "ecs/CameraComponents.hpp" +#include "ecs/Components.hpp" +#include "ecs/ComponentQuery.hpp" +#include "scene/HierarchySystem.hpp" +#include "scene/PlayMode2D.hpp" +#include "scene/SceneComponents.hpp" + +#ifdef CF_HAS_IMGUI +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace Caffeine::Editor { + +#ifdef CF_HAS_IMGUI + +struct Camera2DPreviewTextureEntry { + std::unique_ptr texture; + int width = 0; + int height = 0; + bool loadFailed = false; +}; + +inline std::string resolveSpritePathForPreview(const std::string& name, const EditorContext& ctx) { + if (name.empty()) return {}; + const std::filesystem::path sceneDir = + ctx.currentScenePath.empty() + ? std::filesystem::current_path() + : std::filesystem::path(ctx.currentScenePath).parent_path(); + const std::filesystem::path projectRoot = sceneDir.parent_path(); + const std::array candidates = { + projectRoot / "assets" / "sprites" / name, + projectRoot / "assets" / name, + sceneDir / name, + std::filesystem::path(name), + }; + for (const auto& path : candidates) { + if (std::filesystem::exists(path)) return path.string(); + } + return {}; +} + +inline ECS::Entity findPreviewCamera2D(ECS::World& world, const EditorContext& ctx) { + if (ctx.selectedEntity.isValid() && world.has(ctx.selectedEntity)) { + return ECS::Entity(ctx.selectedEntity.id(), &world); + } + ECS::Entity active = Scene::findActiveCamera2DEntity(world); + if (active.isValid()) return active; + + ECS::ComponentQuery q; + q.with(); + ECS::Entity fallback; + world.forEach(q, [&](ECS::Entity e, ECS::Camera2DComponent&) { + if (!fallback.isValid()) fallback = e; + }); + return fallback; +} + +inline void drawCamera2DPreviewGrid(ImDrawList* dl, ImVec2 origin, ImVec2 panelSize, float camX, + float camY, float zoom) { + const float worldToScreen = zoom * 50.0f; + const float cx = origin.x + panelSize.x * 0.5f; + const float cy = origin.y + panelSize.y * 0.5f; + auto w2s = [&](float wx, float wy) -> ImVec2 { + return ImVec2(cx + (wx - camX) * worldToScreen, cy - (wy - camY) * worldToScreen); + }; + + const ImU32 gridCol = IM_COL32(50, 50, 58, 180); + const float step = 50.0f; + const float halfW = (panelSize.x * 0.5f) / std::max(worldToScreen, 1.0f); + const float halfH = (panelSize.y * 0.5f) / std::max(worldToScreen, 1.0f); + const int minX = static_cast(std::floor((camX - halfW) / step)); + const int maxX = static_cast(std::ceil((camX + halfW) / step)); + const int minY = static_cast(std::floor((camY - halfH) / step)); + const int maxY = static_cast(std::ceil((camY + halfH) / step)); + for (int gx = minX; gx <= maxX; ++gx) { + ImVec2 a = w2s(gx * step, camY - halfH); + ImVec2 b = w2s(gx * step, camY + halfH); + dl->AddLine(a, b, gridCol, 1.0f); + } + for (int gy = minY; gy <= maxY; ++gy) { + ImVec2 a = w2s(camX - halfW, gy * step); + ImVec2 b = w2s(camX + halfW, gy * step); + dl->AddLine(a, b, gridCol, 1.0f); + } + + const ImVec2 center = w2s(camX, camY); + dl->AddLine(ImVec2(center.x - 10.0f, center.y), ImVec2(center.x + 10.0f, center.y), + IM_COL32(255, 220, 80, 200), 1.5f); + dl->AddLine(ImVec2(center.x, center.y - 10.0f), ImVec2(center.x, center.y + 10.0f), + IM_COL32(255, 220, 80, 200), 1.5f); +} + +inline int renderCamera2DPreview(ECS::World& world, const EditorContext& ctx, ImDrawList* dl, + ImVec2 origin, ImVec2 panelSize, float camX, float camY, + float zoom, + std::unordered_map& texCache) { + dl->AddRectFilled(origin, ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), + IM_COL32(15, 15, 18, 255)); + dl->PushClipRect(origin, ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), true); + drawCamera2DPreviewGrid(dl, origin, panelSize, camX, camY, zoom); + + const float worldToScreen = zoom * 50.0f; + const float cx = origin.x + panelSize.x * 0.5f; + const float cy = origin.y + panelSize.y * 0.5f; + auto w2s = [&](float wx, float wy) -> ImVec2 { + return ImVec2(cx + (wx - camX) * worldToScreen, cy - (wy - camY) * worldToScreen); + }; + + int spriteCount = 0; + ECS::ComponentQuery spriteQ; + spriteQ.with(); + spriteQ.with(); + world.forEach(spriteQ, + [&](ECS::Entity entity, ECS::Transform& pos, ECS::Sprite& sprite) { + if (Scene::isEffectivelyDisabled(world, entity)) return; + ++spriteCount; + + Vec3 worldPosition = pos.position; + float scaleX = std::max(0.1f, pos.scale.x); + float scaleY = std::max(0.1f, pos.scale.y); + if (auto* wt = world.get(entity)) { + worldPosition = Vec3(wt->matrix(0, 3), wt->matrix(1, 3), wt->matrix(2, 3)); + scaleX = std::max(0.1f, std::sqrt(wt->matrix(0, 0) * wt->matrix(0, 0) + + wt->matrix(1, 0) * wt->matrix(1, 0))); + scaleY = std::max(0.1f, std::sqrt(wt->matrix(0, 1) * wt->matrix(0, 1) + + wt->matrix(1, 1) * wt->matrix(1, 1))); + } + + ImVec2 screenPos = w2s(worldPosition.x, worldPosition.y); + float halfW = std::max(8.0f, 0.5f * worldToScreen * scaleX); + float halfH = std::max(8.0f, 0.5f * worldToScreen * scaleY); + + bool hasTexture = false; + ImTextureRef texRef; + if (!sprite.name.empty()) { + const std::string path = resolveSpritePathForPreview(sprite.name, ctx); + if (!path.empty()) { + auto it = texCache.find(path); + if (it == texCache.end()) { + int w = 0, h = 0, ch = 0; + unsigned char* px = stbi_load(path.c_str(), &w, &h, &ch, 4); + Camera2DPreviewTextureEntry entry; + if (px && w > 0 && h > 0) { + entry.width = w; + entry.height = h; + entry.texture = std::make_unique(); + entry.texture->Create(ImTextureFormat_RGBA32, w, h); + std::memcpy(entry.texture->GetPixels(), px, + static_cast(w * h * 4)); + entry.texture->SetStatus(ImTextureStatus_WantCreate); + ImGui_ImplSDLGPU3_UpdateTexture(entry.texture.get()); + } else { + entry.loadFailed = true; + } + if (px) stbi_image_free(px); + auto [newIt, ok] = texCache.emplace(path, std::move(entry)); + it = newIt; + } + if (!it->second.loadFailed && it->second.texture) { + if (it->second.texture->Status == ImTextureStatus_WantCreate) { + ImGui_ImplSDLGPU3_UpdateTexture(it->second.texture.get()); + } + if (it->second.texture->GetTexID() != ImTextureID_Invalid) { + hasTexture = true; + texRef = it->second.texture->GetTexRef(); + const float aspect = + static_cast(it->second.width) / + static_cast(std::max(it->second.height, 1)); + if (aspect > 1.0f) halfH = std::max(8.0f, halfW / aspect); + else halfW = std::max(8.0f, halfH * aspect); + } + } + } + } + + const ImVec2 p1(screenPos.x - halfW, screenPos.y - halfH); + const ImVec2 p3(screenPos.x + halfW, screenPos.y + halfH); + if (hasTexture) { + dl->AddImage(texRef, p1, p3); + } else { + dl->AddRectFilled(p1, p3, IM_COL32(90, 140, 220, 200)); + dl->AddRect(p1, p3, IM_COL32(180, 210, 255, 220), 0.0f, 0, 1.5f); + } + }); + + dl->PopClipRect(); + return spriteCount; +} + +#endif + +} // namespace Caffeine::Editor diff --git a/src/editor/CameraPreviewPanel.cpp b/src/editor/CameraPreviewPanel.cpp index 43cea3c..af1879b 100644 --- a/src/editor/CameraPreviewPanel.cpp +++ b/src/editor/CameraPreviewPanel.cpp @@ -1,4 +1,5 @@ #include "editor/CameraPreviewPanel.hpp" +#include "editor/Camera2DPreviewRenderer.hpp" #include "editor/EditorPanelUtils.hpp" #include "editor/SceneViewport.hpp" @@ -323,119 +324,20 @@ void CameraPreviewPanel::renderCameraView(ECS::World& world, EditorContext& ctx, ImVec2 origin, ImVec2 panelSize, float camX, float camY, float zoom) { ImDrawList* dl = ImGui::GetWindowDrawList(); - - dl->AddRectFilled(origin, - ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), - IM_COL32(15, 15, 18, 255)); - - dl->PushClipRect(origin, - ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), - true); - - const float worldToScreen = zoom * 50.0f; - const float cx = origin.x + panelSize.x * 0.5f; - const float cy = origin.y + panelSize.y * 0.5f; - - auto w2s = [&](float wx, float wy) -> ImVec2 { - return ImVec2(cx + (wx - camX) * worldToScreen, - cy - (wy - camY) * worldToScreen); - }; - - ECS::ComponentQuery spriteQ; - spriteQ.with(); - spriteQ.with(); - - int spriteCount = 0; - world.forEach(spriteQ, - [&](ECS::Entity entity, ECS::Transform& pos, ECS::Sprite& sprite) { - if (Scene::isEffectivelyDisabled(world, entity)) return; - ++spriteCount; - - Vec3 worldPosition = pos.position; - float scaleX = std::max(0.1f, pos.scale.x); - float scaleY = std::max(0.1f, pos.scale.y); - if (auto* wt = world.get(entity)) { - worldPosition = Vec3(wt->matrix(0, 3), wt->matrix(1, 3), wt->matrix(2, 3)); - scaleX = std::max(0.1f, std::sqrt(wt->matrix(0, 0) * wt->matrix(0, 0) + - wt->matrix(1, 0) * wt->matrix(1, 0))); - scaleY = std::max(0.1f, std::sqrt(wt->matrix(0, 1) * wt->matrix(0, 1) + - wt->matrix(1, 1) * wt->matrix(1, 1))); - } - - ImVec2 screenPos = w2s(worldPosition.x, worldPosition.y); - - float halfW = std::max(8.0f, 0.5f * worldToScreen * scaleX); - float halfH = std::max(8.0f, 0.5f * worldToScreen * scaleY); - - bool hasTexture = false; - ImTextureRef texRef; - - if (!sprite.name.empty()) { - const std::string path = resolveSpritePath(sprite.name, ctx); - if (!path.empty()) { - auto it = m_texCache.find(path); - if (it == m_texCache.end()) { - int w = 0, h = 0, ch = 0; - unsigned char* px = stbi_load(path.c_str(), &w, &h, &ch, 4); - TexEntry entry; - if (px && w > 0 && h > 0) { - entry.width = w; - entry.height = h; - entry.texture = std::make_unique(); - entry.texture->Create(ImTextureFormat_RGBA32, w, h); - std::memcpy(entry.texture->GetPixels(), px, - static_cast(w * h * 4)); - entry.texture->SetStatus(ImTextureStatus_WantCreate); - ImGui_ImplSDLGPU3_UpdateTexture(entry.texture.get()); - } else { - entry.loadFailed = true; - } - if (px) stbi_image_free(px); - auto [newIt, ok] = m_texCache.emplace(path, std::move(entry)); - it = newIt; - } - if (!it->second.loadFailed && it->second.texture) { - if (it->second.texture->Status == ImTextureStatus_WantCreate) - ImGui_ImplSDLGPU3_UpdateTexture(it->second.texture.get()); - ImTextureID tid = it->second.texture->GetTexID(); - hasTexture = (tid != ImTextureID_Invalid); - if (hasTexture) { - texRef = it->second.texture->GetTexRef(); - const float aspect = static_cast(it->second.width) / - static_cast(it->second.height); - if (aspect > 1.0f) halfH = std::max(8.0f, halfW / aspect); - else halfW = std::max(8.0f, halfH * aspect); - } - } - } - } - - const ImVec2 p1 = ImVec2(screenPos.x - halfW, screenPos.y - halfH); - const ImVec2 p2 = ImVec2(screenPos.x + halfW, screenPos.y - halfH); - const ImVec2 p3 = ImVec2(screenPos.x + halfW, screenPos.y + halfH); - const ImVec2 p4 = ImVec2(screenPos.x - halfW, screenPos.y + halfH); - - if (hasTexture) { - dl->AddImageQuad(texRef, p1, p2, p3, p4); - } else { - dl->AddRectFilled(p1, p3, IM_COL32(64, 64, 64, 200)); - } - }); - - dl->PopClipRect(); + const int spriteCount = + renderCamera2DPreview(world, ctx, dl, origin, panelSize, camX, camY, zoom, m_texCache); if (spriteCount == 0) { - const char* msg = "No sprites in camera view"; + const char* msg = + "Camera OK — add sprites (Entity Presets: 2D Character, Parallax BG)"; ImVec2 ts = ImGui::CalcTextSize(msg); dl->AddText(ImVec2(origin.x + (panelSize.x - ts.x) * 0.5f, - origin.y + (panelSize.y - ts.y) * 0.5f), - IM_COL32(150, 150, 160, 200), msg); + origin.y + panelSize.y * 0.55f), + IM_COL32(170, 170, 180, 220), msg); } - const float borderAlpha = 160; - dl->AddRect(origin, - ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), - IM_COL32(80, 140, 255, borderAlpha), 0.0f, 0, 1.5f); + dl->AddRect(origin, ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), + IM_COL32(80, 140, 255, 160), 0.0f, 0, 1.5f); } void CameraPreviewPanel::renderEditorCameraFallback(ECS::World& world, EditorContext& ctx, diff --git a/src/editor/CameraPreviewPanel.hpp b/src/editor/CameraPreviewPanel.hpp index 136811e..d415ad4 100644 --- a/src/editor/CameraPreviewPanel.hpp +++ b/src/editor/CameraPreviewPanel.hpp @@ -3,6 +3,7 @@ #include "ecs/CameraComponents.hpp" #include "ecs/World.hpp" #include "editor/EditorContext.hpp" +#include "editor/Camera2DPreviewRenderer.hpp" #include "render/SkyboxRenderer.hpp" #ifdef CF_HAS_IMGUI @@ -38,13 +39,7 @@ class CameraPreviewPanel { void renderEditorCameraFallback(ECS::World& world, EditorContext& ctx, SceneViewport& viewport, ImVec2 origin, ImVec2 panelSize); - struct TexEntry { - std::unique_ptr texture; - int width = 0; - int height = 0; - bool loadFailed = false; - }; - std::unordered_map m_texCache; + std::unordered_map m_texCache; Render::SkyboxRenderer m_skyboxRenderer; std::string resolveSpritePath(const std::string& name, const EditorContext& ctx) const; diff --git a/src/editor/CommandPalette.cpp b/src/editor/CommandPalette.cpp index 4d7b521..15ecd67 100644 --- a/src/editor/CommandPalette.cpp +++ b/src/editor/CommandPalette.cpp @@ -174,7 +174,7 @@ void CommandPalette::render() { ImGui::EndChild(); ImGui::Separator(); - ImGui::TextDisabled("↑↓ Navigate Enter Execute Esc Close"); + ImGui::TextDisabled("Ctrl+L to open | ↑↓ Navigate Enter Execute Esc Close"); } ImGui::End(); } diff --git a/src/editor/ComponentRegistry.cpp b/src/editor/ComponentRegistry.cpp index 8f49cd7..3cba395 100644 --- a/src/editor/ComponentRegistry.cpp +++ b/src/editor/ComponentRegistry.cpp @@ -5,6 +5,7 @@ #include "ecs/CameraComponents.hpp" #include "ecs/LightComponents.hpp" #include "ecs/TerrainComponents.hpp" +#include "ecs/ProceduralComponents.hpp" #include "terrain/TerrainCache.hpp" #include "physics/PhysicsComponents2D.hpp" #include "audio/AudioComponents.hpp" @@ -193,8 +194,15 @@ void registerAllComponents(ComponentRegistry& reg) { [](ECS::World& w, ECS::Entity e) { return w.has(e); }, [](ECS::World& w, ECS::Entity e) { w.add(e); } }); + reg.registerComponent({ + "Procedural", "Procedural World", + [](ECS::World& w, ECS::Entity e) { return w.has(e); }, + [](ECS::World& w, ECS::Entity e) { w.add(e); } + }); ComponentTypeRegistry::instance().registerType("PostProcess"); + ComponentTypeRegistry::instance().registerType( + "ProceduralWorld"); } } // namespace Caffeine::Editor diff --git a/src/editor/EditorContext.hpp b/src/editor/EditorContext.hpp index 2e4eae3..9bc164a 100644 --- a/src/editor/EditorContext.hpp +++ b/src/editor/EditorContext.hpp @@ -117,6 +117,9 @@ class EditorContext { f32 viewportPanY = 0.0f; f32 viewportZoom = 1.0f; + static constexpr f32 kViewport2DZoomMin = 0.02f; + static constexpr f32 kViewport2DZoomMax = 64.0f; + // ── Viewport camera ──────────────────────────────────────────────── enum class ViewMode : u8 { Mode2D, Mode3D, Isometric }; diff --git a/src/editor/EditorPaths.cpp b/src/editor/EditorPaths.cpp index e199f24..51fb0ac 100644 --- a/src/editor/EditorPaths.cpp +++ b/src/editor/EditorPaths.cpp @@ -10,10 +10,16 @@ namespace Caffeine::Editor { std::filesystem::path EditorPaths::s_root; +std::filesystem::path EditorPaths::s_bundledPlugins; void EditorPaths::init() { - if (!s_root.empty()) return; - s_root = findAssetsRoot(); + if (!s_root.empty() && !s_bundledPlugins.empty()) return; + if (s_root.empty()) { + s_root = findAssetsRoot(); + } + if (s_bundledPlugins.empty()) { + s_bundledPlugins = findBundledPluginsRoot(); + } } std::filesystem::path EditorPaths::findAssetsRoot() { @@ -48,6 +54,48 @@ std::filesystem::path EditorPaths::findAssetsRoot() { return {}; } +std::filesystem::path EditorPaths::findBundledPluginsRoot() { + std::vector candidates; + +#ifdef __linux__ + char exePath[PATH_MAX] = {}; + const ssize_t len = readlink("/proc/self/exe", exePath, sizeof(exePath) - 1); + if (len > 0) { + exePath[len] = '\0'; + const std::filesystem::path exeDir = std::filesystem::path(exePath).parent_path(); + candidates.push_back(exeDir / "plugins"); + candidates.push_back(exeDir / ".." / "plugins"); + } +#endif + + candidates.push_back(std::filesystem::current_path() / "plugins"); + candidates.push_back(std::filesystem::current_path() / ".." / "plugins"); + +#ifdef CAFFEINE_SOURCE_DIR + candidates.push_back(std::filesystem::path(CAFFEINE_SOURCE_DIR) / "build" / "plugins"); +#endif + + for (const auto& candidate : candidates) { + std::error_code ec; + if (!std::filesystem::exists(candidate, ec)) continue; + for (const auto& entry : std::filesystem::directory_iterator(candidate, ec)) { + if (ec) break; + const auto ext = entry.path().extension().string(); + if (ext == ".so" || ext == ".dll") { + std::error_code canonEc; + return std::filesystem::weakly_canonical(candidate, canonEc); + } + } + } + + return {}; +} + +std::filesystem::path EditorPaths::bundledPluginsDirectory() { + if (s_bundledPlugins.empty()) init(); + return s_bundledPlugins; +} + std::filesystem::path EditorPaths::resolve(const std::string& relativePath) { if (s_root.empty()) init(); if (s_root.empty()) return std::filesystem::path(relativePath); diff --git a/src/editor/EditorPaths.hpp b/src/editor/EditorPaths.hpp index b553e78..2940e6b 100644 --- a/src/editor/EditorPaths.hpp +++ b/src/editor/EditorPaths.hpp @@ -20,9 +20,14 @@ class EditorPaths { static std::filesystem::path skyboxPath(const std::string& fileName); static std::filesystem::path brandLogoPath(); + /// Editor-shipped plugins (next to doppio executable or build/plugins). + static std::filesystem::path bundledPluginsDirectory(); + private: static std::filesystem::path s_root; + static std::filesystem::path s_bundledPlugins; static std::filesystem::path findAssetsRoot(); + static std::filesystem::path findBundledPluginsRoot(); }; } // namespace Caffeine::Editor diff --git a/src/editor/GameplayPreviewPanel.cpp b/src/editor/GameplayPreviewPanel.cpp index aebc7fa..e432112 100644 --- a/src/editor/GameplayPreviewPanel.cpp +++ b/src/editor/GameplayPreviewPanel.cpp @@ -13,6 +13,7 @@ #include "math/Mat4.hpp" #include "scene/EnvironmentSystem.hpp" #include "scene/HierarchySystem.hpp" +#include "scene/PlayMode2D.hpp" #include "render/SkyboxRenderer.hpp" #include @@ -147,12 +148,55 @@ void GameplayPreviewPanel::render(ECS::World& world, EditorContext& ctx) { ImDrawList* dl = ImGui::GetWindowDrawList(); ImGui::InvisibleButton("##gameplay_preview", panelSize); + Scene::propagateTransforms(world); + ECS::Entity cameraEntity; ECS::Camera3DComponent* cam = nullptr; - const bool found = findGameplayCamera(world, cameraEntity, cam); + const bool found3D = findGameplayCamera(world, cameraEntity, cam); + + const bool prefer2D = ctx.viewMode == EditorContext::ViewMode::Mode2D; + ECS::Entity camera2D = findPreviewCamera2D(world, ctx); + float cam2DX = 0.0f, cam2DY = 0.0f, cam2DZoom = 1.0f; + if (camera2D.isValid()) { + if (auto* camComp = world.get(camera2D)) { + cam2DZoom = camComp->zoom; + } + Vec3 worldPos; + if (auto* wt = world.get(camera2D)) { + worldPos = wt->matrix.transformPoint(Vec3(0.0f, 0.0f, 0.0f)); + } else if (auto* t = world.get(camera2D)) { + worldPos = t->position; + } + cam2DX = worldPos.x; + cam2DY = worldPos.y; + } + if ((prefer2D || !found3D) && camera2D.isValid()) { + cameraEntity = camera2D; + const int spriteCount = + renderCamera2DPreview(world, ctx, dl, origin, panelSize, cam2DX, cam2DY, cam2DZoom, + m_texCache2D); + const char* badge = ctx.isPlayMode ? "PLAY" : "STOPPED"; + const ImU32 badgeCol = + ctx.isPlayMode ? IM_COL32(120, 230, 140, 230) : IM_COL32(220, 180, 90, 220); + dl->AddText(ImVec2(origin.x + 8.0f, origin.y + 6.0f), badgeCol, badge); + if (const char* name = getEntityName(world, camera2D); name && name[0] != '\0') { + dl->AddText(ImVec2(origin.x + 8.0f, origin.y + 22.0f), IM_COL32(200, 200, 210, 200), + name); + } + dl->AddText(ImVec2(origin.x + 8.0f, origin.y + 38.0f), IM_COL32(140, 180, 255, 200), + "2D preview"); + if (spriteCount == 0) { + const char* hint = + "Camera OK — add sprites (Entity Presets: 2D Character, Parallax BG)"; + ImVec2 ts = ImGui::CalcTextSize(hint); + dl->AddText(ImVec2(origin.x + (panelSize.x - ts.x) * 0.5f, + origin.y + panelSize.y * 0.55f), + IM_COL32(170, 170, 180, 220), hint); + } + } else #ifdef CF_HAS_SDL3 - if (found && cam && m_ready && m_frameCmd && m_renderer.isReady()) { + if (found3D && cam && m_ready && m_frameCmd && m_renderer.isReady()) { u32 w = static_cast(panelSize.x); u32 h = static_cast(panelSize.y); w = std::clamp(w, 8u, 1280u); @@ -218,7 +262,7 @@ void GameplayPreviewPanel::render(ECS::World& world, EditorContext& ctx) { { dl->AddRectFilled(origin, ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), IM_COL32(18, 18, 22, 255)); - const char* msg = found ? "Gameplay GPU not ready" : "No Camera3D in the scene"; + const char* msg = found3D ? "Gameplay GPU not ready" : "No camera in the scene"; const ImVec2 ts = ImGui::CalcTextSize(msg); dl->AddText(ImVec2(origin.x + (panelSize.x - ts.x) * 0.5f, origin.y + (panelSize.y - ts.y) * 0.5f), diff --git a/src/editor/GameplayPreviewPanel.hpp b/src/editor/GameplayPreviewPanel.hpp index f90bd8f..90811b6 100644 --- a/src/editor/GameplayPreviewPanel.hpp +++ b/src/editor/GameplayPreviewPanel.hpp @@ -3,6 +3,9 @@ #include "core/Types.hpp" #include "ecs/World.hpp" #include "editor/EditorContext.hpp" +#include "editor/Camera2DPreviewRenderer.hpp" + +#include #ifdef CF_HAS_SDL3 #include "render/GpuSceneRenderer.hpp" @@ -45,6 +48,7 @@ class GameplayPreviewPanel { #endif #ifdef CF_HAS_IMGUI Render::SkyboxRenderer m_skyboxRenderer; + std::unordered_map m_texCache2D; #endif bool m_open = true; bool m_detached = false; diff --git a/src/editor/HierarchyPanel.cpp b/src/editor/HierarchyPanel.cpp index 9eb365a..43f8f8b 100644 --- a/src/editor/HierarchyPanel.cpp +++ b/src/editor/HierarchyPanel.cpp @@ -185,7 +185,7 @@ void HierarchyPanel::renderEntityNode(ECS::Entity entity) { m_lastScrollTarget = entity; } - if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) { + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { if (ImGui::GetIO().KeyCtrl) { m_context->toggleSelection(entity); } else if (ImGui::GetIO().KeyShift) { diff --git a/src/editor/InspectorPanel.cpp b/src/editor/InspectorPanel.cpp index b47922c..1ab58ff 100644 --- a/src/editor/InspectorPanel.cpp +++ b/src/editor/InspectorPanel.cpp @@ -14,11 +14,11 @@ #include "editor/EditorPaths.hpp" #include "ecs/CameraComponents.hpp" #include "ecs/PostProcessComponents.hpp" +#include "editor/ComponentTypeRegistry.hpp" #include "ecs/TerrainComponents.hpp" #include "core/WorldUnits.hpp" #include "terrain/TerrainCache.hpp" #include "terrain/TerrainResolution.hpp" -#include "terrain/generation/TerrainGenerator.hpp" #include "terrain/TerrainGpuTextures.hpp" #include "math/Quat.hpp" #include "script/CppScript.hpp" @@ -54,6 +54,7 @@ void InspectorPanel::render(ECS::World& world, EditorContext& ctx) { editorPanelDetachTabButton(m_detached); if (!ctx.selectedEntity.isValid()) { ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "No entity selected"); + ImGui::TextDisabled("Click an entity in the Hierarchy to inspect it."); ImGui::End(); return; } @@ -66,7 +67,7 @@ void InspectorPanel::render(ECS::World& world, EditorContext& ctx) { return; } - ECS::Entity e = ctx.selectedEntity; + ECS::Entity e(ctx.selectedEntity.id(), &world); // Entity header const char* name = getEntityName(world, e); @@ -1142,7 +1143,25 @@ void InspectorPanel::drawTerrain(ECS::World& world, ECS::Entity e, EditorContext } ImGui::Separator(); - ImGui::TextDisabled("Generation, sculpt and paint live in the Terrain Editor panel."); + ImGui::TextUnformatted("Collision"); + int collisionStep = static_cast(terrain->collisionSampleStep); + if (ImGui::SliderInt("Collision Sample Step", &collisionStep, 1, 16)) { + terrain->collisionSampleStep = static_cast(collisionStep); + terrain->dataRevision++; + Terrain::TerrainCache::instance().syncEntity(world, e); + ctx.isDirty = true; + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Higher = fewer collision triangles (render mesh unchanged)"); + } + if (ImGui::Checkbox("Build Collision Mesh", &terrain->buildCollisionMesh)) { + terrain->dataRevision++; + Terrain::TerrainCache::instance().syncEntity(world, e); + ctx.isDirty = true; + } + + ImGui::Separator(); + ImGui::TextDisabled("Sculpt/paint: Terrain Editor. Generation: Terrain Generator plugin."); ImGui::PopID(); } @@ -1162,16 +1181,13 @@ void InspectorPanel::drawPostProcess(ECS::World& world, ECS::Entity e, EditorCon if (!fx) return; ImGui::PushID("postprocess"); - if (ImGui::Checkbox("Enabled", &fx->enabled)) ctx.isDirty = true; - if (ImGui::SliderFloat("Exposure", &fx->exposure, 0.2f, 3.0f)) ctx.isDirty = true; - if (ImGui::SliderFloat("Contrast", &fx->contrast, 0.5f, 2.0f)) ctx.isDirty = true; - if (ImGui::SliderFloat("Saturation", &fx->saturation, 0.0f, 2.0f)) ctx.isDirty = true; - if (ImGui::SliderFloat("Vignette", &fx->vignette, 0.0f, 1.0f)) ctx.isDirty = true; - if (ImGui::SliderFloat("Bloom", &fx->bloom, 0.0f, 1.0f)) ctx.isDirty = true; - if (ImGui::SliderFloat("Chromatic Aberration", &fx->chromaticAberration, 0.0f, 1.0f)) { - ctx.isDirty = true; + const u32 typeId = ComponentTypeRegistry::instance().lookup("PostProcess"); + if (const ComponentDrawer* drawer = m_drawers.get(typeId)) { + (*drawer)(fx); + ImGui::PopID(); + return; } - if (ImGui::SliderFloat("Film Grain", &fx->filmGrain, 0.0f, 1.0f)) ctx.isDirty = true; + ImGui::TextDisabled("Install the Post Processing plugin for the full effect stack editor."); ImGui::PopID(); } diff --git a/src/editor/PluginAPI.hpp b/src/editor/PluginAPI.hpp index cfafd38..7c0cd01 100644 --- a/src/editor/PluginAPI.hpp +++ b/src/editor/PluginAPI.hpp @@ -1,73 +1,5 @@ #pragma once -#include "core/Types.hpp" - -namespace Caffeine::Editor { - -// ============================================================================ -// Plugin export macros -// ============================================================================ - -#if defined(_WIN32) - #ifdef CAFFEINE_PLUGIN_EXPORTS - #define CAFFEINE_PLUGIN_API __declspec(dllexport) - #else - #define CAFFEINE_PLUGIN_API __declspec(dllimport) - #endif -#else - #define CAFFEINE_PLUGIN_API __attribute__((visibility("default"))) -#endif - -// ============================================================================ -// IPlugin — contract implemented by every plugin -// ============================================================================ - -class IPlugin { -public: - virtual ~IPlugin() = default; - - virtual void OnLoad() = 0; - virtual void OnUnload() = 0; - virtual void OnUpdate(float dt) = 0; - - virtual const char* GetName() const = 0; - virtual const char* GetVersion() const = 0; - virtual const char* GetDescription() const { return ""; } -}; - -// ============================================================================ -// PluginHostApi — C-style callbacks passed to plugins (ABI-friendly surface) -// ============================================================================ - -struct PluginHostApi { - void* editorContext = nullptr; - - void (*logInfo)(void* ctx, const char* message) = nullptr; - void (*logError)(void* ctx, const char* message) = nullptr; - - bool (*registerPanel)(void* ctx, const char* pluginName, const char* title, - void (*renderFn)(void* userData), void* userData) = nullptr; - bool (*registerMenuAction)(void* ctx, const char* pluginName, const char* menuPath, - void (*actionFn)(void* userData), void* userData) = nullptr; - bool (*registerComponentDrawer)(void* ctx, const char* pluginName, u32 componentTypeId, - void (*drawFn)(void* componentData, void* userData), - void* userData) = nullptr; - - // Registers presets from an entity_presets.json manifest (package/plugin format). - bool (*registerEntityPresetManifest)(void* ctx, const char* pluginName, - const char* manifestPath) = nullptr; - - // Stable component type id from the host (safe across plugin DLL boundaries). - u32 (*getComponentTypeId)(void* ctx, const char* componentName) = nullptr; -}; - -using CreatePluginFn = IPlugin* (*)(const PluginHostApi* host); -using DestroyPluginFn = void (*)(IPlugin* plugin); - -} // namespace Caffeine::Editor - -extern "C" { - CAFFEINE_PLUGIN_API Caffeine::Editor::IPlugin* CreatePlugin( - const Caffeine::Editor::PluginHostApi* host); - CAFFEINE_PLUGIN_API void DestroyPlugin(Caffeine::Editor::IPlugin* plugin); -} +// Internal include path — public SDK lives in include/caffeine/plugin/ +#include "caffeine/plugin/PluginAPI.hpp" +#include "caffeine/plugin/PluginServices.hpp" diff --git a/src/editor/PluginServiceRegistry.cpp b/src/editor/PluginServiceRegistry.cpp new file mode 100644 index 0000000..c007c17 --- /dev/null +++ b/src/editor/PluginServiceRegistry.cpp @@ -0,0 +1,40 @@ +#include "editor/PluginServiceRegistry.hpp" + +#include + +namespace Caffeine::Editor { + +PluginServiceRegistry& PluginServiceRegistry::instance() { + static PluginServiceRegistry registry; + return registry; +} + +bool PluginServiceRegistry::registerService(const std::string& pluginName, + const std::string& serviceName, + PluginServiceHandler handler) { + if (serviceName.empty() || !handler) return false; + m_services[serviceName] = Entry{pluginName, handler}; + return true; +} + +void PluginServiceRegistry::unregisterPlugin(const std::string& pluginName) { + for (auto it = m_services.begin(); it != m_services.end();) { + if (it->second.pluginName == pluginName) { + it = m_services.erase(it); + } else { + ++it; + } + } +} + +bool PluginServiceRegistry::invoke(const std::string& serviceName, void* editorContext, + const void* request, std::size_t requestSize, void* response, + std::size_t responseSize) const { + const auto it = m_services.find(serviceName); + if (it == m_services.end() || !it->second.handler) { + return false; + } + return it->second.handler(editorContext, request, requestSize, response, responseSize); +} + +} // namespace Caffeine::Editor diff --git a/src/editor/PluginServiceRegistry.hpp b/src/editor/PluginServiceRegistry.hpp new file mode 100644 index 0000000..1d88b24 --- /dev/null +++ b/src/editor/PluginServiceRegistry.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "caffeine/plugin/PluginAPI.hpp" + +#include +#include +#include + +namespace Caffeine::Editor { + +class PluginServiceRegistry { +public: + static PluginServiceRegistry& instance(); + + bool registerService(const std::string& pluginName, const std::string& serviceName, + PluginServiceHandler handler); + void unregisterPlugin(const std::string& pluginName); + + bool invoke(const std::string& serviceName, void* editorContext, const void* request, + std::size_t requestSize, void* response, std::size_t responseSize) const; + +private: + struct Entry { + std::string pluginName; + PluginServiceHandler handler = nullptr; + }; + + std::unordered_map m_services; +}; + +} // namespace Caffeine::Editor diff --git a/src/editor/PluginSystem.cpp b/src/editor/PluginSystem.cpp index 183ed04..ead47b7 100644 --- a/src/editor/PluginSystem.cpp +++ b/src/editor/PluginSystem.cpp @@ -5,6 +5,7 @@ #include "editor/EntityPresetPackages.hpp" #include "editor/EntityPresetRegistry.hpp" #include "editor/ComponentTypeRegistry.hpp" +#include "editor/PluginServiceRegistry.hpp" #include #include #include @@ -63,14 +64,21 @@ void PluginManager::initialize(const std::filesystem::path& pluginsDirectory, m_commandPalette = commandPalette; m_editorContext = editorContext; - m_hostApi.editorContext = this; + m_hostApi.editorContext = m_editorContext; m_hostApi.logInfo = &PluginManager::hostLogInfo; m_hostApi.logError = &PluginManager::hostLogError; m_hostApi.registerPanel = &PluginManager::hostRegisterPanel; + m_hostApi.openPanel = &PluginManager::hostOpenPanel; m_hostApi.registerMenuAction = &PluginManager::hostRegisterMenuAction; m_hostApi.registerComponentDrawer = &PluginManager::hostRegisterComponentDrawer; m_hostApi.registerEntityPresetManifest = &PluginManager::hostRegisterEntityPresetManifest; m_hostApi.getComponentTypeId = &PluginManager::hostGetComponentTypeId; + m_hostApi.getActiveWorld = &PluginManager::hostGetActiveWorld; + m_hostApi.getSelectedEntityId = &PluginManager::hostGetSelectedEntityId; + m_hostApi.markSceneDirty = &PluginManager::hostMarkSceneDirty; + m_hostApi.invokeService = &PluginManager::hostInvokeService; + m_hostApi.registerService = &PluginManager::hostRegisterService; + m_hostApi.getProjectRootPath = &PluginManager::hostGetProjectRootPath; std::error_code ec; std::filesystem::create_directories(m_pluginsDirectory, ec); @@ -88,12 +96,12 @@ void PluginManager::initialize(const std::filesystem::path& pluginsDirectory, void PluginManager::shutdown() { if (!m_initialized) return; - std::vector names; - for (const auto& [name, handle] : m_loadedPlugins) { - names.push_back(name); + std::vector paths; + for (const auto& [path, handle] : m_loadedPlugins) { + paths.push_back(path); } - for (const auto& name : names) { - unloadPlugin(name); + for (const auto& path : paths) { + unloadPluginByPath(path); } m_watcher.stop(); @@ -115,42 +123,59 @@ void PluginManager::hostLogError(void* ctx, const char* message) { bool PluginManager::hostRegisterPanel(void* ctx, const char* pluginName, const char* title, void (*renderFn)(void* userData), void* userData) { - if (!ctx || !pluginName || !title || !renderFn) return false; - auto* manager = static_cast(ctx); - return manager->registerPanel(pluginName, title, [renderFn, userData]() { renderFn(userData); }); + if (!title || !renderFn) return false; + (void)ctx; + auto& manager = PluginManager::instance(); + const char* id = manager.m_hostApi.pluginId ? manager.m_hostApi.pluginId : pluginName; + if (!id || id[0] == '\0') return false; + return manager.registerPanel(id, title, [renderFn, userData]() { renderFn(userData); }); +} + +bool PluginManager::hostOpenPanel(void* ctx, const char* title) { + (void)ctx; + if (!title || title[0] == '\0') return false; + return PluginManager::instance().openPanel(title); } bool PluginManager::hostRegisterMenuAction(void* ctx, const char* pluginName, const char* menuPath, void (*actionFn)(void* userData), void* userData) { - if (!ctx || !pluginName || !menuPath || !actionFn) return false; - auto* manager = static_cast(ctx); - return manager->registerMenuAction(pluginName, menuPath, - [actionFn, userData]() { actionFn(userData); }); + if (!menuPath || !actionFn) return false; + (void)ctx; + auto& manager = PluginManager::instance(); + const char* id = manager.m_hostApi.pluginId ? manager.m_hostApi.pluginId : pluginName; + if (!id || id[0] == '\0') return false; + return manager.registerMenuAction(id, menuPath, + [actionFn, userData]() { actionFn(userData); }); } bool PluginManager::hostRegisterComponentDrawer(void* ctx, const char* pluginName, u32 componentTypeId, void (*drawFn)(void* componentData, void* userData), void* userData) { - if (!ctx || !pluginName || !drawFn) return false; - auto* manager = static_cast(ctx); - return manager->registerComponentDrawer(pluginName, componentTypeId, - [drawFn, userData](void* data) { drawFn(data, userData); }); + if (!drawFn) return false; + (void)ctx; + auto& manager = PluginManager::instance(); + const char* id = manager.m_hostApi.pluginId ? manager.m_hostApi.pluginId : pluginName; + if (!id || id[0] == '\0') return false; + return manager.registerComponentDrawer(id, componentTypeId, + [drawFn, userData](void* data) { drawFn(data, userData); }); } bool PluginManager::hostRegisterEntityPresetManifest(void* ctx, const char* pluginName, const char* manifestPath) { - if (!ctx || !pluginName || !manifestPath) return false; - auto* manager = static_cast(ctx); + if (!manifestPath) return false; + auto& manager = PluginManager::instance(); + const char* id = manager.m_hostApi.pluginId ? manager.m_hostApi.pluginId : pluginName; + if (!id || id[0] == '\0') return false; auto loaded = loadEntityPresetPackage(manifestPath); if (!loaded.presets.empty()) { for (auto& preset : loaded.presets) { - preset.source = pluginName; + preset.source = id; EntityPresetRegistry::instance().registerPreset(std::move(preset)); } return true; } if (!loaded.error.empty()) { - hostLogError(manager, loaded.error.c_str()); + hostLogError(nullptr, loaded.error.c_str()); } return false; } @@ -160,6 +185,58 @@ u32 PluginManager::hostGetComponentTypeId(void* ctx, const char* componentName) return ComponentTypeRegistry::instance().lookup(componentName); } +void* PluginManager::hostGetActiveWorld(void* editorContext) { + auto* ctx = static_cast(editorContext); + return ctx ? static_cast(ctx->activeWorld) : nullptr; +} + +CaffeinePluginU32 PluginManager::hostGetSelectedEntityId(void* editorContext) { + auto* ctx = static_cast(editorContext); + return (ctx && ctx->selectedEntity.isValid()) ? ctx->selectedEntity.id() : 0; +} + +void PluginManager::hostMarkSceneDirty(void* editorContext) { + auto* ctx = static_cast(editorContext); + if (ctx) ctx->isDirty = true; +} + +bool PluginManager::hostInvokeService(void* ctx, const char* serviceName, const void* request, + std::size_t requestSize, void* response, + std::size_t responseSize) { + (void)ctx; + auto& manager = PluginManager::instance(); + if (!serviceName || !manager.m_editorContext) return false; + return PluginServiceRegistry::instance().invoke(serviceName, manager.m_editorContext, request, + requestSize, response, responseSize); +} + +bool PluginManager::hostRegisterService(void* ctx, const char* pluginName, const char* serviceName, + PluginServiceHandler handler) { + (void)ctx; + if (!serviceName || !handler) return false; + auto& manager = PluginManager::instance(); + const char* id = manager.m_hostApi.pluginId ? manager.m_hostApi.pluginId : pluginName; + if (!id || id[0] == '\0') return false; + return PluginServiceRegistry::instance().registerService(id, serviceName, handler); +} + +bool PluginManager::hostGetProjectRootPath(void* editorContext, char* buffer, + std::size_t bufferSize) { + if (!buffer || bufferSize == 0) return false; + buffer[0] = '\0'; + auto* ctx = static_cast(editorContext); + if (!ctx || ctx->currentScenePath.empty()) return false; + + const std::filesystem::path root = + std::filesystem::path(ctx->currentScenePath).parent_path().parent_path(); + if (root.empty()) return false; + + const std::string path = root.string(); + std::strncpy(buffer, path.c_str(), bufferSize - 1); + buffer[bufferSize - 1] = '\0'; + return true; +} + bool PluginManager::registerPanel(const std::string& pluginName, const std::string& title, std::function renderFunc) { if (title.empty() || !renderFunc) return false; @@ -167,29 +244,30 @@ bool PluginManager::registerPanel(const std::string& pluginName, const std::stri PluginPanel panel; panel.pluginName = pluginName; panel.title = title; + panel.commandId = std::string("plugin_panel_") + pluginName + "_" + title; panel.render = std::move(renderFunc); panel.open = true; m_panels.push_back(std::move(panel)); if (m_commandPalette) { - FixedString<64> id("plugin_panel_"); - id.append(pluginName.c_str()); - id.append("_"); - id.append(title.c_str()); + FixedString<64> id(panel.commandId.c_str()); m_commandPalette->registerCommand( id, FixedString<128>(title.c_str()), FixedString<128>("Plugins"), - [this, title]() { - for (auto& p : m_panels) { - if (p.title == title) { - p.open = true; - break; - } - } - }); + [this, title]() { openPanel(title); }); } return true; } +bool PluginManager::openPanel(const std::string& title) { + for (auto& panel : m_panels) { + if (panel.title == title) { + panel.open = true; + return true; + } + } + return false; +} + bool PluginManager::registerMenuAction(const std::string& pluginName, const std::string& menuPath, std::function action) { if (menuPath.empty() || !action) return false; @@ -207,6 +285,14 @@ bool PluginManager::registerComponentDrawer(const std::string& pluginName, u32 c } void PluginManager::unregisterPluginResources(const std::string& pluginName) { + if (m_commandPalette) { + for (const auto& panel : m_panels) { + if (panel.pluginName == pluginName && !panel.commandId.empty()) { + m_commandPalette->unregisterCommand(FixedString<64>(panel.commandId.c_str())); + } + } + } + m_panels.erase(std::remove_if(m_panels.begin(), m_panels.end(), [&](const PluginPanel& p) { return p.pluginName == pluginName; }), m_panels.end()); @@ -309,7 +395,7 @@ bool PluginManager::loadPluginInternal(const std::string& path, bool fromHotRelo } #endif - m_hostApi.editorContext = this; + m_hostApi.editorContext = m_editorContext; IPlugin* plugin = createFn(&m_hostApi); if (!plugin) { #if defined(_WIN32) @@ -324,16 +410,25 @@ bool PluginManager::loadPluginInternal(const std::string& path, bool fromHotRelo entry.libraryHandle = handle; entry.instance = plugin; entry.path = path; - entry.name = plugin->GetName() ? plugin->GetName() : libPath.stem().string(); + entry.name = libPath.stem().string(); + const char* reportedName = plugin->GetName(); entry.version = plugin->GetVersion() ? plugin->GetVersion() : "0.0.0"; entry.description = plugin->GetDescription() ? plugin->GetDescription() : ""; + if (reportedName && reportedName[0] != '\0') { + std::cout << "[Plugin] Loaded: " << reportedName << " [" << entry.name << "] (" << path + << ")\n"; + } else { + std::cout << "[Plugin] Loaded: " << entry.name << " (" << path << ")\n"; + } entry.lastWriteTime = fileTimestamp(libPath); entry.status = PluginStatus::Loaded; - if (m_loadedPlugins.contains(entry.name) && !fromHotReload) { - unloadPlugin(entry.name); + if (m_loadedPlugins.contains(entry.path) && !fromHotReload) { + unloadPluginByPath(entry.path); } + m_hostApi.pluginId = entry.name.c_str(); + try { plugin->OnLoad(); entry.status = PluginStatus::Active; @@ -346,16 +441,28 @@ bool PluginManager::loadPluginInternal(const std::string& path, bool fromHotRelo #else dlclose(handle); #endif - m_loadedPlugins[entry.name] = std::move(entry); + m_hostApi.pluginId = nullptr; + m_loadedPlugins[entry.path] = std::move(entry); return false; } - m_loadedPlugins[entry.name] = std::move(entry); + m_hostApi.pluginId = nullptr; + m_loadedPlugins[entry.path] = std::move(entry); return true; } -bool PluginManager::unloadPlugin(const std::string& name) { - auto it = m_loadedPlugins.find(name); +const PluginHandle* PluginManager::findLoadedPluginByStem( + const std::string& stem) const { + for (const auto& [path, handle] : m_loadedPlugins) { + if (handle.name == stem || path == stem) { + return &handle; + } + } + return nullptr; +} + +bool PluginManager::unloadPluginByPath(const std::string& path) { + auto it = m_loadedPlugins.find(path); if (it == m_loadedPlugins.end()) return false; PluginHandle& handle = it->second; @@ -366,7 +473,8 @@ bool PluginManager::unloadPlugin(const std::string& name) { } } - unregisterPluginResources(name); + unregisterPluginResources(handle.name); + PluginServiceRegistry::instance().unregisterPlugin(handle.name); #if defined(_WIN32) auto destroyFn = reinterpret_cast( @@ -391,13 +499,23 @@ bool PluginManager::unloadPlugin(const std::string& name) { return true; } -bool PluginManager::reloadPlugin(const std::string& name) { - auto it = m_loadedPlugins.find(name); - if (it == m_loadedPlugins.end()) return false; +bool PluginManager::unloadPlugin(const std::string& name) { + for (const auto& [path, handle] : m_loadedPlugins) { + if (handle.name == name || path == name) { + return unloadPluginByPath(path); + } + } + return false; +} - const std::string path = it->second.path; - unloadPlugin(name); - return loadPluginInternal(path, true); +bool PluginManager::reloadPlugin(const std::string& name) { + for (const auto& [path, handle] : m_loadedPlugins) { + if (handle.name == name || path == name) { + unloadPluginByPath(path); + return loadPluginInternal(handle.path, true); + } + } + return false; } void PluginManager::refreshPluginsDirectory() { @@ -430,16 +548,17 @@ void PluginManager::refreshPlugins(float dt) { for (const auto& path : changed) { if (!isPluginLibrary(path)) continue; - std::string pluginName; - for (auto& [name, handle] : m_loadedPlugins) { + std::string pluginPath; + for (auto& [loadedPath, handle] : m_loadedPlugins) { if (handle.path == path.string()) { - pluginName = name; + pluginPath = loadedPath; break; } } - if (!pluginName.empty()) { - reloadPlugin(pluginName); + if (!pluginPath.empty()) { + unloadPluginByPath(pluginPath); + loadPluginInternal(pluginPath, true); } else { loadPluginInternal(path.string(), true); } @@ -462,8 +581,9 @@ void PluginManager::renderPanels() { for (auto& panel : m_panels) { if (!panel.open) continue; - const auto pluginIt = m_loadedPlugins.find(panel.pluginName); - if (pluginIt != m_loadedPlugins.end() && !pluginIt->second.enabled) continue; + if (const PluginHandle* handle = findLoadedPluginByStem(panel.pluginName)) { + if (!handle->enabled) continue; + } bool open = panel.open; if (ImGui::Begin(panel.title.c_str(), &open)) { @@ -504,7 +624,10 @@ void PluginManager::renderManagerUI() { if (!m_managerOpen) return; if (ImGui::Begin("Plugin Manager", &m_managerOpen)) { - ImGui::TextDisabled("Directory: %s", m_pluginsDirectory.string().c_str()); + ImGui::TextDisabled("Project plugins: %s", m_pluginsDirectory.string().c_str()); + if (!m_bundledPluginsDirectory.empty()) { + ImGui::TextDisabled("Bundled plugins: %s", m_bundledPluginsDirectory.string().c_str()); + } if (ImGui::Button("Refresh Directory")) { refreshPluginsDirectory(); @@ -530,13 +653,13 @@ void PluginManager::renderManagerUI() { ImGui::TableSetupColumn("Actions"); ImGui::TableHeadersRow(); - for (auto& [name, handle] : m_loadedPlugins) { + for (auto& [path, handle] : m_loadedPlugins) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - ImGui::Checkbox(("##en" + name).c_str(), &handle.enabled); + ImGui::Checkbox(("##en" + handle.name).c_str(), &handle.enabled); ImGui::TableSetColumnIndex(1); - ImGui::TextUnformatted(name.c_str()); + ImGui::TextUnformatted(handle.name.c_str()); if (!handle.description.empty()) { ImGui::TextDisabled("%s", handle.description.c_str()); } @@ -558,12 +681,12 @@ void PluginManager::renderManagerUI() { } ImGui::TableSetColumnIndex(4); - if (ImGui::SmallButton(("Reload##" + name).c_str())) { - reloadPlugin(name); + if (ImGui::SmallButton(("Reload##" + handle.name).c_str())) { + reloadPlugin(handle.name); } ImGui::SameLine(); - if (ImGui::SmallButton(("Unload##" + name).c_str())) { - unloadPlugin(name); + if (ImGui::SmallButton(("Unload##" + handle.name).c_str())) { + unloadPlugin(handle.name); } } @@ -575,7 +698,7 @@ void PluginManager::renderManagerUI() { for (const auto& path : m_discoveredPaths) { const auto filename = std::filesystem::path(path).filename().string(); bool loaded = false; - for (const auto& [name, handle] : m_loadedPlugins) { + for (const auto& [loadedPath, handle] : m_loadedPlugins) { if (handle.path == path) { loaded = true; break; diff --git a/src/editor/PluginSystem.hpp b/src/editor/PluginSystem.hpp index 4052110..f1aa2b6 100644 --- a/src/editor/PluginSystem.hpp +++ b/src/editor/PluginSystem.hpp @@ -4,6 +4,7 @@ #include "core/Types.hpp" #include "core/io/FileWatcher.hpp" +#include #include #include #include @@ -23,6 +24,7 @@ class EditorContext; struct PluginPanel { std::string pluginName; std::string title; + std::string commandId; std::function render; bool open = true; }; @@ -91,6 +93,7 @@ class PluginManager { // Editor-side registration (also used by host API callbacks) bool registerPanel(const std::string& pluginName, const std::string& title, std::function renderFunc); + bool openPanel(const std::string& title); bool registerMenuAction(const std::string& pluginName, const std::string& menuPath, std::function action); bool registerComponentDrawer(const std::string& pluginName, u32 componentTypeId, @@ -102,6 +105,8 @@ class PluginManager { PluginManager() = default; bool loadPluginInternal(const std::string& path, bool fromHotReload); + bool unloadPluginByPath(const std::string& path); + const struct PluginHandle* findLoadedPluginByStem(const std::string& stem) const; void scanPluginsDirectory(); void ensureWatcher(); std::filesystem::path pluginsDirectory() const; @@ -110,6 +115,7 @@ class PluginManager { static void hostLogError(void* ctx, const char* message); static bool hostRegisterPanel(void* ctx, const char* pluginName, const char* title, void (*renderFn)(void* userData), void* userData); + static bool hostOpenPanel(void* ctx, const char* title); static bool hostRegisterMenuAction(void* ctx, const char* pluginName, const char* menuPath, void (*actionFn)(void* userData), void* userData); static bool hostRegisterComponentDrawer(void* ctx, const char* pluginName, u32 componentTypeId, @@ -118,6 +124,14 @@ class PluginManager { static bool hostRegisterEntityPresetManifest(void* ctx, const char* pluginName, const char* manifestPath); static u32 hostGetComponentTypeId(void* ctx, const char* componentName); + static void* hostGetActiveWorld(void* editorContext); + static CaffeinePluginU32 hostGetSelectedEntityId(void* editorContext); + static void hostMarkSceneDirty(void* editorContext); + static bool hostInvokeService(void* ctx, const char* serviceName, const void* request, + std::size_t requestSize, void* response, std::size_t responseSize); + static bool hostRegisterService(void* ctx, const char* pluginName, const char* serviceName, + PluginServiceHandler handler); + static bool hostGetProjectRootPath(void* editorContext, char* buffer, std::size_t bufferSize); std::filesystem::path m_pluginsDirectory; std::filesystem::path m_bundledPluginsDirectory; diff --git a/src/editor/SceneEditor.cpp b/src/editor/SceneEditor.cpp index 3a1d07c..b0c6ca4 100644 --- a/src/editor/SceneEditor.cpp +++ b/src/editor/SceneEditor.cpp @@ -13,6 +13,7 @@ #include "editor/EditorPaths.hpp" #include "scene/HierarchySystem.hpp" #include "scene/PlayMode2D.hpp" +#include "procedural/ProceduralWorldSystem.hpp" #include "script/ScriptTypes.hpp" #include "events/Events.hpp" #include "input/InputManager.hpp" @@ -130,14 +131,12 @@ bool SceneEditor::init(RHI::RenderDevice* device, Assets::AssetManager* assetMan m_buildDialog.setPrepareBuildCallback([this]() { return prepareSceneForBuild(); }); m_buildDialog.setProjectContext(projectConfig); + EditorPaths::init(); const std::filesystem::path pluginsDir = projectConfig.RootPath.empty() ? (std::filesystem::current_path() / "plugins") : (projectConfig.RootPath / "plugins"); - std::filesystem::path bundledPluginsDir; - if (EditorPaths::isReady()) { - bundledPluginsDir = EditorPaths::root().parent_path() / "plugins"; - } + const std::filesystem::path bundledPluginsDir = EditorPaths::bundledPluginsDirectory(); PluginManager::instance().initialize(pluginsDir, &m_inspector, &m_commandPalette, &m_ctx, bundledPluginsDir); @@ -365,6 +364,7 @@ void SceneEditor::enterPlayMode(ECS::World& world) { } m_scriptSystem = Script::ScriptSystem(&m_scriptEngine); m_scriptSystem.resetPlayState(); + Procedural::ProceduralWorldSystem::reset(); ECS::ComponentQuery cppQ; cppQ.with(); world.forEach(cppQ, @@ -413,6 +413,7 @@ void SceneEditor::exitPlayMode(ECS::World& world) { #ifdef CF_HAS_SCRIPTING m_scriptSystem.resetPlayState(); #endif + Procedural::ProceduralWorldSystem::reset(); } void SceneEditor::tickSystems(ECS::World& world, f32 dt) { @@ -540,6 +541,7 @@ void SceneEditor::tickSystems(ECS::World& world, f32 dt) { if (m_scriptEngineReady) { m_scriptEngine.setWorld(&world); m_scriptSystem.onUpdate(world, dt); + Procedural::ProceduralWorldSystem::update(world); } #endif { @@ -1057,6 +1059,11 @@ void SceneEditor::handleShortcuts(ECS::World& world) { bool ctrl = ImGui::GetIO().KeyCtrl; bool shift = ImGui::GetIO().KeyShift; + if (ctrl && ImGui::IsKeyPressed(ImGuiKey_L)) { + m_commandPalette.toggle(); + return; + } + if (ctrl && shift && ImGui::IsKeyPressed(ImGuiKey_P)) { m_commandPalette.toggle(); return; diff --git a/src/editor/SceneSerializer.cpp b/src/editor/SceneSerializer.cpp index 697b240..61baa36 100644 --- a/src/editor/SceneSerializer.cpp +++ b/src/editor/SceneSerializer.cpp @@ -12,6 +12,7 @@ #include "ui/UIComponents.hpp" #include "editor/EditorContext.hpp" #include "scene/SceneComponents.hpp" +#include "editor/TerrainGenerationBlobLegacy.hpp" #include "terrain/TerrainCache.hpp" #include #include @@ -27,7 +28,7 @@ namespace IO = SceneSerializerIO; namespace { -constexpr u32 kTerrainBlobVersion = 8; +constexpr u32 kTerrainBlobVersion = 9; std::filesystem::path projectRootFromScenePath(const std::string& scenePath) { if (scenePath.empty()) return {}; @@ -486,90 +487,8 @@ std::vector SceneSerializer::serializeTerrainComponent(const ECS::TerrainCom for (u32 i = 0; i < ECS::kTerrainSplatLayerCount; ++i) { appendString(data, terrain.splatLayerPaths[i]); } - const ECS::TerrainGenerationSettings& gen = terrain.generation; - appendU32(data, gen.seed); - appendF32(data, gen.noiseScale); - appendU32(data, gen.octaves); - appendF32(data, gen.persistence); - appendF32(data, gen.lacunarity); - appendF32(data, gen.amplitude); - appendF32(data, gen.baseHeight); - appendU8(data, static_cast(gen.noiseAlgorithm)); - appendU8(data, gen.domainWarp ? 1 : 0); - appendF32(data, gen.domainWarpStrength); - appendU8(data, gen.thermalErosion ? 1 : 0); - appendU32(data, gen.thermalIterations); - appendF32(data, gen.thermalTalus); - appendU8(data, gen.hydraulicErosion ? 1 : 0); - appendU32(data, gen.hydraulicIterations); - appendF32(data, gen.hydraulicRain); - appendF32(data, gen.hydraulicErode); - appendF32(data, gen.hydraulicDeposit); - appendU8(data, gen.smoothPass ? 1 : 0); - appendU32(data, gen.smoothIterations); - appendF32(data, gen.heightQuantize); - appendU8(data, gen.autoSplat ? 1 : 0); - appendU8(data, static_cast(gen.style)); - if (kTerrainBlobVersion >= 4) { - appendU8(data, gen.useRidgedNoise ? 1 : 0); - appendU32(data, gen.domainWarpPasses); - appendU32(data, gen.hydraulicMaxSteps); - appendF32(data, gen.hydraulicInertia); - appendF32(data, gen.hydraulicEvaporation); - appendF32(data, gen.splatBlendRange); - const ECS::TerrainSimulationSettings& sim = gen.simulation; - appendU8(data, sim.enabled ? 1 : 0); - appendU8(data, static_cast(sim.environment)); - appendU32(data, sim.totalIterations); - appendU32(data, sim.dropletsPerIteration); - appendU32(data, sim.maxDropletSteps); - appendF32(data, sim.convergenceThreshold); - appendU8(data, sim.autoConvergence ? 1 : 0); - appendF32(data, sim.tectonicActivity); - appendF32(data, sim.erosionWater); - appendF32(data, sim.erosionThermal); - appendF32(data, sim.erosionGlacial); - appendF32(data, sim.erosionWind); - appendF32(data, sim.erosionBiological); - appendF32(data, sim.temperature); - appendF32(data, sim.humidity); - appendF32(data, sim.windDirection); - appendF32(data, sim.rainfallBase); - appendF32(data, gen.ridgedBlend); - appendU32(data, gen.postSimSmoothIterations); - appendU32(data, gen.splatBlurPasses); - } - if (kTerrainBlobVersion >= 6) { - appendU8(data, static_cast(gen.heightModel)); - appendF32(data, gen.ridgeOffset); - appendF32(data, gen.ridgeGain); - appendF32(data, gen.multiplicativeContrast); - appendU8(data, gen.fractalDomainWarp ? 1 : 0); - appendF32(data, gen.domainWarpScale); - appendU8(data, gen.slopeWeighting ? 1 : 0); - appendF32(data, gen.slopeWeightAlpha); - } - if (kTerrainBlobVersion >= 7) { - appendU8(data, gen.useClimateBiomes ? 1 : 0); - appendF32(data, gen.climate.prevailingWindAngle); - appendF32(data, gen.climate.baseHumidity); - appendF32(data, gen.climate.temperature); - appendF32(data, gen.climate.seaLevel); - appendU8(data, gen.hydrology.traceRivers ? 1 : 0); - appendU32(data, gen.hydrology.maxRiverSources); - appendF32(data, gen.hydrology.riverSourceMinHeight); - appendF32(data, gen.hydrology.riverSourceMaxHeight); - appendF32(data, gen.hydrology.riverCarveStrength); - appendU32(data, gen.hydrology.rainShadowSteps); - appendF32(data, gen.hydrology.waterMoistureRadius); - appendF32(data, gen.hydrology.sedimentDepositStrength); - } - if (kTerrainBlobVersion >= 8) { - appendF32(data, gen.fractalRoughness); - appendF32(data, gen.spectralExponent); - appendU8(data, static_cast(gen.multiplyLayerA)); - appendU8(data, static_cast(gen.multiplyLayerB)); - } + appendU32(data, terrain.collisionSampleStep); + appendU8(data, terrain.buildCollisionMesh ? 1 : 0); return data; } @@ -630,8 +549,8 @@ bool SceneSerializer::deserializeTerrainComponent(const u8* data, u32 size, } } - if (blobVersion >= 2) { - ECS::TerrainGenerationSettings& gen = terrain.generation; + if (blobVersion >= 2 && blobVersion <= 8) { + LegacyTerrainGenerationSettings gen; u8 noiseAlgorithm = 0; u8 domainWarp = 0; u8 thermalErosion = 0; @@ -664,13 +583,13 @@ bool SceneSerializer::deserializeTerrainComponent(const u8* data, u32 size, if (!readU8(cursor, end, autoSplat)) return false; if (!readU8(cursor, end, style)) return false; - gen.noiseAlgorithm = static_cast(noiseAlgorithm); + gen.noiseAlgorithm = static_cast(noiseAlgorithm); gen.domainWarp = domainWarp != 0; gen.thermalErosion = thermalErosion != 0; gen.hydraulicErosion = hydraulicErosion != 0; gen.smoothPass = smoothPass != 0; gen.autoSplat = autoSplat != 0; - gen.style = static_cast(style); + gen.style = static_cast(style); if (blobVersion >= 4) { u8 useRidgedNoise = 1; @@ -683,7 +602,7 @@ bool SceneSerializer::deserializeTerrainComponent(const u8* data, u32 size, if (!readF32(cursor, end, gen.hydraulicInertia)) return false; if (!readF32(cursor, end, gen.hydraulicEvaporation)) return false; if (!readF32(cursor, end, gen.splatBlendRange)) return false; - ECS::TerrainSimulationSettings& sim = gen.simulation; + LegacyTerrainSimulationSettings& sim = gen.simulation; if (!readU8(cursor, end, simEnabled)) return false; if (!readU8(cursor, end, simEnvironment)) return false; if (!readU32(cursor, end, sim.totalIterations)) return false; @@ -703,7 +622,7 @@ bool SceneSerializer::deserializeTerrainComponent(const u8* data, u32 size, if (!readF32(cursor, end, sim.rainfallBase)) return false; gen.useRidgedNoise = useRidgedNoise != 0; sim.enabled = simEnabled != 0; - sim.environment = static_cast(simEnvironment); + sim.environment = static_cast(simEnvironment); sim.autoConvergence = simAutoConvergence != 0; } else { gen.useRidgedNoise = false; @@ -736,12 +655,12 @@ bool SceneSerializer::deserializeTerrainComponent(const u8* data, u32 size, if (!readF32(cursor, end, gen.domainWarpScale)) return false; if (!readU8(cursor, end, slopeWeighting)) return false; if (!readF32(cursor, end, gen.slopeWeightAlpha)) return false; - gen.heightModel = static_cast(heightModel); + gen.heightModel = static_cast(heightModel); gen.fractalDomainWarp = fractalDomainWarp != 0; gen.slopeWeighting = slopeWeighting != 0; } else { - gen.heightModel = gen.useRidgedNoise ? Terrain::TerrainHeightModel::Hybrid - : Terrain::TerrainHeightModel::RollingHills; + gen.heightModel = gen.useRidgedNoise ? LegacyTerrainHeightModel::Hybrid + : LegacyTerrainHeightModel::RollingHills; gen.ridgeOffset = 1.0f; gen.ridgeGain = 1.5f; gen.multiplicativeContrast = 1.2f; @@ -781,14 +700,22 @@ bool SceneSerializer::deserializeTerrainComponent(const u8* data, u32 size, u8 multiplyLayerB = 2; if (!readU8(cursor, end, multiplyLayerA)) return false; if (!readU8(cursor, end, multiplyLayerB)) return false; - gen.multiplyLayerA = static_cast(multiplyLayerA); - gen.multiplyLayerB = static_cast(multiplyLayerB); + gen.multiplyLayerA = static_cast(multiplyLayerA); + gen.multiplyLayerB = static_cast(multiplyLayerB); } else { gen.fractalRoughness = 0.55f; gen.spectralExponent = 2.0f; - gen.multiplyLayerA = Terrain::TerrainHeightModel::RollingHills; - gen.multiplyLayerB = Terrain::TerrainHeightModel::RidgedMountains; + gen.multiplyLayerA = LegacyTerrainHeightModel::RollingHills; + gen.multiplyLayerB = LegacyTerrainHeightModel::RidgedMountains; } + (void)gen; + } + + if (blobVersion >= 9) { + if (!readU32(cursor, end, terrain.collisionSampleStep)) return false; + u8 buildCollision = 1; + if (!readU8(cursor, end, buildCollision)) return false; + terrain.buildCollisionMesh = buildCollision != 0; } return cursor <= end; diff --git a/src/editor/SceneViewport.cpp b/src/editor/SceneViewport.cpp index 95b5d12..548f711 100644 --- a/src/editor/SceneViewport.cpp +++ b/src/editor/SceneViewport.cpp @@ -32,6 +32,7 @@ #include "terrain/TerrainLodSystem.hpp" #include "terrain/TerrainGpuTextures.hpp" #include "render/GpuProceduralMeshes.hpp" +#include "render/PostProcessRenderer.hpp" #include "editor/EditorPaths.hpp" #include "editor/EditorPanelUtils.hpp" #include "ui/UIRenderer.hpp" @@ -924,6 +925,35 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { } } + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !m_gizmoDragging && hovered && + ctx.viewMode == EditorContext::ViewMode::Mode2D) { + const bool panModifier = ImGui::IsKeyDown(ImGuiKey_Space) || + ImGui::IsKeyDown(ImGuiKey_LeftAlt) || + ImGui::IsKeyDown(ImGuiKey_RightAlt); + if (!panModifier) { + ImVec2 mousePos = ImGui::GetMousePos(); + ImVec2 vpMin = ImGui::GetItemRectMin(); + const f32 cx = vpMin.x + viewportSize.x * 0.5f; + const f32 cy = vpMin.y + viewportSize.y * 0.5f; + const f32 s = ctx.viewportZoom * 50.0f; + const Vec2 worldPos((mousePos.x - cx - ctx.viewportPanX) / s, + -(mousePos.y - cy - ctx.viewportPanY) / s); + ECS::Entity picked = pickEntity2D(worldPos, world); + const bool shiftPressed = ImGui::IsKeyDown(ImGuiKey_LeftShift) || + ImGui::IsKeyDown(ImGuiKey_RightShift); + if (picked.isValid()) { + if (shiftPressed) { + ctx.toggleSelection(picked); + } else { + ctx.selectEntity(picked); + } + TestInstrumentation::onEntitiesSelected(ctx.selectedEntities); + } else if (!shiftPressed) { + ctx.clearSelection(); + } + } + } + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { if (ImGui::IsKeyPressed(ImGuiKey_T)) ctx.gizmoMode = EditorContext::GizmoMode::Translate; if (ImGui::IsKeyPressed(ImGuiKey_E)) ctx.gizmoMode = EditorContext::GizmoMode::Rotate; @@ -1323,40 +1353,46 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { drawNavigationWidget(world, ctx, origin, viewportSize); - if (!ctx.isPlayMode && hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Middle)) { - ImVec2 delta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Middle); + if (!ctx.isPlayMode && hovered) { + const bool is2D = ctx.viewMode == EditorContext::ViewMode::Mode2D; const bool is3DIso = (ctx.viewMode == EditorContext::ViewMode::Mode3D || ctx.viewMode == EditorContext::ViewMode::Isometric); - if (is3DIso) { - const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); - const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 forward(-sinY * cosP, sinP, cosY * cosP); - const Vec3 worldUp(0.0f, 1.0f, 0.0f); - Vec3 right = forward.cross(worldUp); - if (right.lengthSquared() < 1e-6f) right = Vec3(1.0f, 0.0f, 0.0f); - else right = right.normalized(); - const Vec3 up = right.cross(forward).normalized(); - const f32 panSpeed = ctx.camDistance * 0.002f; - ctx.camFocus += right * (delta.x * panSpeed); - ctx.camFocus -= up * (delta.y * panSpeed); - } else { - ctx.viewportPanX += delta.x; - ctx.viewportPanY += delta.y; + const bool spaceHeld = ImGui::IsKeyDown(ImGuiKey_Space); + const bool altHeld = ImGui::IsKeyDown(ImGuiKey_LeftAlt) || + ImGui::IsKeyDown(ImGuiKey_RightAlt); + + bool panning = false; + ImGuiMouseButton panButton = ImGuiMouseButton_Middle; + if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle)) { + panning = true; + } else if (is2D && !m_gizmoDragging && (spaceHeld || altHeld) && + ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { + panning = true; + panButton = ImGuiMouseButton_Left; + } + + if (panning) { + ImVec2 delta = ImGui::GetMouseDragDelta(panButton); + if (is3DIso && panButton == ImGuiMouseButton_Middle) { + const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); + const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); + const Vec3 forward(-sinY * cosP, sinP, cosY * cosP); + const Vec3 worldUp(0.0f, 1.0f, 0.0f); + Vec3 right = forward.cross(worldUp); + if (right.lengthSquared() < 1e-6f) right = Vec3(1.0f, 0.0f, 0.0f); + else right = right.normalized(); + const Vec3 up = right.cross(forward).normalized(); + const f32 panSpeed = ctx.camDistance * 0.002f; + ctx.camFocus += right * (delta.x * panSpeed); + ctx.camFocus -= up * (delta.y * panSpeed); + } else { + ctx.viewportPanX += delta.x; + ctx.viewportPanY += delta.y; + } + ImGui::ResetMouseDragDelta(panButton); } - ImGui::ResetMouseDragDelta(ImGuiMouseButton_Middle); } - // 2D View: Left mouse button drag to pan (disabled during play — camera drives the view) - if (!ctx.isPlayMode && hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { - if (ctx.viewMode == EditorContext::ViewMode::Mode2D) { - ImVec2 delta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Left); - f32 s = ctx.viewportZoom * 50.0f; - ctx.viewportPanX -= delta.x / s; - ctx.viewportPanY -= delta.y / s; - ImGui::ResetMouseDragDelta(ImGuiMouseButton_Left); - } - } - if (hovered && !ImGui::GetIO().WantTextInput && !ctx.isPlayMode) { bool is3DIso = (ctx.viewMode == EditorContext::ViewMode::Mode3D || ctx.viewMode == EditorContext::ViewMode::Isometric); @@ -1405,7 +1441,7 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { ImGui::ResetMouseDragDelta(ImGuiMouseButton_Right); } - if (!ctx.isPlayMode && hovered && !ImGui::GetIO().WantCaptureMouse) { + if (!ctx.isPlayMode && hovered) { f32 scroll = ImGui::GetIO().MouseWheel; if (scroll != 0) { if (ctx.viewMode == EditorContext::ViewMode::Mode3D || @@ -1425,8 +1461,22 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { ctx.camFocus += lookDir * (scroll * step); } } else { - ctx.viewportZoom *= (scroll > 0) ? 1.1f : 0.9f; - ctx.viewportZoom = std::max(0.1f, std::min(10.0f, ctx.viewportZoom)); + ImVec2 mousePos = ImGui::GetMousePos(); + ImVec2 vpMin = ImGui::GetItemRectMin(); + const f32 cx = vpMin.x + viewportSize.x * 0.5f; + const f32 cy = vpMin.y + viewportSize.y * 0.5f; + const f32 sBefore = ctx.viewportZoom * 50.0f; + const f32 worldX = (mousePos.x - cx - ctx.viewportPanX) / sBefore; + const f32 worldY = -(mousePos.y - cy - ctx.viewportPanY) / sBefore; + + const f32 zoomFactor = (scroll > 0) ? 1.12f : (1.0f / 1.12f); + ctx.viewportZoom *= zoomFactor; + ctx.viewportZoom = std::clamp(ctx.viewportZoom, EditorContext::kViewport2DZoomMin, + EditorContext::kViewport2DZoomMax); + + const f32 sAfter = ctx.viewportZoom * 50.0f; + ctx.viewportPanX = mousePos.x - cx - worldX * sAfter; + ctx.viewportPanY = mousePos.y - cy + worldY * sAfter; } } } @@ -1435,6 +1485,28 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { UI::drawWidgets(world, drawList, origin, viewportSize); } + ECS::Entity previewCamera = ctx.selectedEntity; + if (!previewCamera.isValid() || + (!world.has(previewCamera) && + !world.has(previewCamera))) { + ECS::ComponentQuery cameraQuery; + cameraQuery.with(); + world.forEach(cameraQuery, [&](ECS::Entity e, ECS::Camera3DComponent&) { + if (!previewCamera.isValid()) previewCamera = e; + }); + if (!previewCamera.isValid()) { + cameraQuery = {}; + cameraQuery.with(); + world.forEach(cameraQuery, [&](ECS::Entity e, ECS::Camera2DComponent&) { + if (!previewCamera.isValid()) previewCamera = e; + }); + } + } + if (const ECS::PostProcessComponent* fx = + Render::findPostProcessForCamera(world, previewCamera)) { + Render::applyPostProcessOverlay(drawList, origin, viewportSize, *fx); + } + ImGui::End(); } @@ -1449,9 +1521,9 @@ ImVec2 SceneViewport::projectToScreen(Vec3 p, ImVec2 origin, ImVec2 viewportSize switch (ctx.viewMode) { case EditorContext::ViewMode::Mode2D: { - f32 s = ctx.viewportZoom * 50.0f; - return ImVec2(cx + (p.x + ctx.viewportPanX / s) * s, - cy + (-p.y + ctx.viewportPanY / s) * s); + const f32 s = ctx.viewportZoom * 50.0f; + return ImVec2(cx + p.x * s + ctx.viewportPanX, + cy - p.y * s + ctx.viewportPanY); } case EditorContext::ViewMode::Isometric: { f32 s = ctx.viewportZoom * 50.0f; @@ -2969,6 +3041,52 @@ f32 SceneViewport::rayIntersectsAABB(const Vec3& rayOrigin, const Vec3& rayDir, return -1.0f; } +ECS::Entity SceneViewport::pickEntity2D(const Vec2& worldPos, ECS::World& world) const { + ECS::Entity best = ECS::Entity::INVALID; + f32 bestDistSq = 1e10f; + + ECS::ComponentQuery query; + query.with(); + + world.forEach(query, [&](ECS::Entity entity, ECS::Transform& pos) { + if (Scene::isEffectivelyDisabled(world, entity)) return; + + Vec3 worldPosition = pos.position; + f32 scaleX = std::max(0.1f, pos.scale.x); + f32 scaleY = std::max(0.1f, pos.scale.y); + + if (auto* wt = world.get(entity)) { + worldPosition = Vec3(wt->matrix(0, 3), wt->matrix(1, 3), wt->matrix(2, 3)); + scaleX = std::max(0.1f, sqrtf(wt->matrix(0, 0) * wt->matrix(0, 0) + + wt->matrix(1, 0) * wt->matrix(1, 0))); + scaleY = std::max(0.1f, sqrtf(wt->matrix(0, 1) * wt->matrix(0, 1) + + wt->matrix(1, 1) * wt->matrix(1, 1))); + } + + f32 halfW = std::max(0.25f, 0.5f * scaleX); + f32 halfH = std::max(0.25f, 0.5f * scaleY); + if (world.has(entity)) { + halfW = std::max(0.5f, halfW); + halfH = std::max(0.5f, halfH); + } + + if (worldPos.x < worldPosition.x - halfW || worldPos.x > worldPosition.x + halfW || + worldPos.y < worldPosition.y - halfH || worldPos.y > worldPosition.y + halfH) { + return; + } + + const f32 dx = worldPos.x - worldPosition.x; + const f32 dy = worldPos.y - worldPosition.y; + const f32 distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { + bestDistSq = distSq; + best = entity; + } + }); + + return best; +} + ECS::Entity SceneViewport::raycastSelectEntity(const Vec3& rayOrigin, const Vec3& rayDir, ECS::World& world, const std::string& projectRoot) { diff --git a/src/editor/SceneViewport.hpp b/src/editor/SceneViewport.hpp index 66879a5..21eec90 100644 --- a/src/editor/SceneViewport.hpp +++ b/src/editor/SceneViewport.hpp @@ -188,6 +188,8 @@ class SceneViewport { ECS::World& world, const std::string& projectRoot = ""); + ECS::Entity pickEntity2D(const Vec2& worldPos, ECS::World& world) const; + struct SpriteTextureCacheEntry { std::unique_ptr texture; int width = 0; diff --git a/src/editor/TerrainEditorPanel.cpp b/src/editor/TerrainEditorPanel.cpp index 331158e..f7f130c 100644 --- a/src/editor/TerrainEditorPanel.cpp +++ b/src/editor/TerrainEditorPanel.cpp @@ -2,22 +2,18 @@ #ifdef CF_HAS_IMGUI -#include "assets/HdrAssetPresets.hpp" #include "assets/MeshCache.hpp" #include "ecs/Components.hpp" #include "ecs/TerrainComponents.hpp" #include "terrain/TerrainCache.hpp" #include "terrain/TerrainGpuTextures.hpp" -#include "terrain/generation/GeologicalSimulator.hpp" -#include "terrain/generation/TerrainGenerator.hpp" -#include +#include #include #include #include #include #include -#include namespace Caffeine::Editor { namespace { @@ -35,239 +31,8 @@ void copyCString(char* dest, usize destSize, const char* src) { dest[destSize - 1] = '\0'; } -void applyHdrGroundPreset(ECS::TerrainComponent& terrain, int presetIndex) { - presetIndex = std::clamp(presetIndex, 0, Assets::kHdrGroundPresetCount - 1); - const char* grass = Assets::kHdrGroundPresetPaths[presetIndex]; - constexpr const char* kRock = Assets::kHdrGroundPresetPaths[5]; - constexpr const char* kSand = Assets::kHdrGroundPresetPaths[1]; - constexpr const char* kDirt = Assets::kHdrGroundPresetPaths[4]; - - copyCString(terrain.texturePath, sizeof(terrain.texturePath), grass); - copyCString(terrain.splatLayerPaths[0], sizeof(terrain.splatLayerPaths[0]), grass); - copyCString(terrain.splatLayerPaths[1], sizeof(terrain.splatLayerPaths[1]), kRock); - copyCString(terrain.splatLayerPaths[2], sizeof(terrain.splatLayerPaths[2]), kSand); - copyCString(terrain.splatLayerPaths[3], sizeof(terrain.splatLayerPaths[3]), kDirt); - terrain.textureTileSize = 4.0f; - terrain.splatTileSize = 4.0f; -} - -constexpr int kAttrStride = 1000; - -int nodeInputAttr(int nodeId) { return nodeId * kAttrStride + 1; } -int nodeOutputAttr(int nodeId) { return nodeId * kAttrStride + 2; } -int nodeIdFromAttr(int attrId) { return attrId / kAttrStride; } - -bool isGeneratorNode(TerrainGraphNodeType type) { - return type == TerrainGraphNodeType::Noise || type == TerrainGraphNodeType::Ridged || - type == TerrainGraphNodeType::Hybrid || type == TerrainGraphNodeType::DiamondSquare || - type == TerrainGraphNodeType::SpectralFFT; -} - -const char* nodeTitle(TerrainGraphNodeType type) { - switch (type) { - case TerrainGraphNodeType::Noise: return "Noise FBM"; - case TerrainGraphNodeType::Ridged: return "Ridged"; - case TerrainGraphNodeType::Hybrid: return "Hybrid"; - case TerrainGraphNodeType::DiamondSquare: return "Diamond-Square"; - case TerrainGraphNodeType::SpectralFFT: return "FFT Spectral"; - case TerrainGraphNodeType::Thermal: return "Thermal Erosion"; - case TerrainGraphNodeType::MicroSculpt: return "Micro Sculpt"; - case TerrainGraphNodeType::Rivers: return "Rivers"; - case TerrainGraphNodeType::GeoSim: return "Geo Simulation"; - case TerrainGraphNodeType::Smooth: return "Smooth"; - case TerrainGraphNodeType::Output: return "Output"; - } - return "Node"; -} - } // namespace -TerrainEditorPanel::TerrainEditorPanel() { - if (!ImNodes::GetCurrentContext()) { - ImNodes::CreateContext(); - } - m_editorContext = ImNodes::EditorContextCreate(); - ensureDefaultGraph(); -} - -TerrainEditorPanel::~TerrainEditorPanel() { - if (m_editorContext) { - ImNodes::EditorContextFree(m_editorContext); - m_editorContext = nullptr; - } -} - -void TerrainEditorPanel::ensureDefaultGraph() { - if (!m_graphNodes.empty()) return; - syncGraphFromStyle(ECS::TerrainGenStyle::Realistic); -} - -void TerrainEditorPanel::syncGraphFromStyle(ECS::TerrainGenStyle style) { - m_graphNodes.clear(); - m_graphLinks.clear(); - m_selectedNodeId = 0; - m_graphLayoutDirty = true; - - auto add = [&](TerrainGraphNodeType type) { - TerrainGraphNode node; - node.id = m_nextNodeId++; - node.type = type; - m_graphNodes.push_back(node); - }; - - m_nextNodeId = 1; - m_nextLinkId = 1; - switch (style) { - case ECS::TerrainGenStyle::Realistic: - add(TerrainGraphNodeType::Hybrid); - add(TerrainGraphNodeType::Thermal); - add(TerrainGraphNodeType::MicroSculpt); - add(TerrainGraphNodeType::Output); - break; - case ECS::TerrainGenStyle::UltraRealistic: - add(TerrainGraphNodeType::Hybrid); - add(TerrainGraphNodeType::GeoSim); - add(TerrainGraphNodeType::MicroSculpt); - add(TerrainGraphNodeType::Output); - break; - case ECS::TerrainGenStyle::LowPoly: - add(TerrainGraphNodeType::Noise); - add(TerrainGraphNodeType::Output); - break; - case ECS::TerrainGenStyle::Stylized: - add(TerrainGraphNodeType::Ridged); - add(TerrainGraphNodeType::Smooth); - add(TerrainGraphNodeType::Output); - break; - case ECS::TerrainGenStyle::Custom: - add(TerrainGraphNodeType::Noise); - add(TerrainGraphNodeType::Thermal); - add(TerrainGraphNodeType::Smooth); - add(TerrainGraphNodeType::Output); - break; - } - - for (size_t i = 0; i + 1 < m_graphNodes.size(); ++i) { - TerrainGraphLink link; - link.id = m_nextLinkId++; - link.fromAttr = nodeOutputAttr(m_graphNodes[i].id); - link.toAttr = nodeInputAttr(m_graphNodes[i + 1].id); - m_graphLinks.push_back(link); - } - - layoutGraphNodes(); -} - -void TerrainEditorPanel::layoutGraphNodes() { - constexpr f32 kStartX = 48.0f; - constexpr f32 kStartY = 96.0f; - constexpr f32 kXSpacing = 260.0f; - constexpr f32 kYSpacing = 150.0f; - constexpr int kColumns = 2; - - for (size_t i = 0; i < m_graphNodes.size(); ++i) { - const int col = static_cast(i % kColumns); - const int row = static_cast(i / kColumns); - m_graphNodes[i].posX = kStartX + static_cast(col) * kXSpacing; - m_graphNodes[i].posY = kStartY + static_cast(row) * kYSpacing; - } -} - -void TerrainEditorPanel::applyGraphToSettings(ECS::TerrainGenerationSettings& gen) const { - gen.microSculpt = false; - gen.microRiverCarve = 0.0f; - gen.thermalErosion = false; - gen.hydraulicErosion = false; - gen.hydrology.traceRivers = false; - gen.smoothPass = false; - gen.slopeWeighting = false; - gen.simulation.enabled = false; - - int outputId = 0; - for (const auto& node : m_graphNodes) { - if (node.type == TerrainGraphNodeType::Output) outputId = node.id; - } - if (outputId == 0) return; - - auto findNode = [&](int id) -> const TerrainGraphNode* { - for (const auto& node : m_graphNodes) { - if (node.id == id) return &node; - } - return nullptr; - }; - auto sourceOf = [&](int nodeId) -> int { - const int inAttr = nodeInputAttr(nodeId); - for (const auto& link : m_graphLinks) { - if (link.toAttr == inAttr) { - return nodeIdFromAttr(link.fromAttr); - } - } - return 0; - }; - - int current = outputId; - for (int hop = 0; hop < 32 && current != 0; ++hop) { - const TerrainGraphNode* node = findNode(current); - if (!node) break; - switch (node->type) { - case TerrainGraphNodeType::Noise: - gen.heightModel = Terrain::TerrainHeightModel::FractalFBM; - gen.useRidgedNoise = false; - break; - case TerrainGraphNodeType::Ridged: - gen.heightModel = Terrain::TerrainHeightModel::RidgedMountains; - gen.useRidgedNoise = true; - break; - case TerrainGraphNodeType::Hybrid: - gen.heightModel = Terrain::TerrainHeightModel::Hybrid; - gen.useRidgedNoise = true; - gen.ridgedBlend = std::max(gen.ridgedBlend, 0.4f); - break; - case TerrainGraphNodeType::DiamondSquare: - gen.heightModel = Terrain::TerrainHeightModel::DiamondSquare; - gen.domainWarp = false; - break; - case TerrainGraphNodeType::SpectralFFT: - gen.heightModel = Terrain::TerrainHeightModel::SpectralFFT; - gen.domainWarp = false; - break; - case TerrainGraphNodeType::Thermal: - gen.thermalErosion = true; - gen.thermalIterations = std::max(12u, gen.thermalIterations); - break; - case TerrainGraphNodeType::MicroSculpt: - gen.microSculpt = true; - break; - case TerrainGraphNodeType::GeoSim: - gen.simulation.enabled = true; - gen.thermalErosion = false; - gen.hydraulicErosion = false; - gen.hydrology.traceRivers = false; - break; - case TerrainGraphNodeType::Rivers: - gen.microRiverCarve = std::max(gen.microRiverCarve, 0.35f); - gen.hydrology.traceRivers = true; - gen.hydraulicErosion = true; - gen.hydraulicIterations = std::max(gen.hydraulicIterations, 4000u); - break; - case TerrainGraphNodeType::Smooth: - gen.smoothPass = true; - gen.smoothIterations = std::max(1u, gen.smoothIterations); - break; - case TerrainGraphNodeType::Output: - break; - } - current = sourceOf(current); - } -} - -void TerrainEditorPanel::generateFromGraph(ECS::World& world, ECS::Entity entity, - ECS::TerrainComponent& terrain, EditorContext& ctx) { - applyGraphToSettings(terrain.generation); - Terrain::TerrainCache::instance().generateTerrain(world, entity, terrain); - ctx.isDirty = true; -} - void TerrainEditorPanel::render(ECS::World& world, EditorContext& ctx) { if (!m_open) return; @@ -278,7 +43,9 @@ void TerrainEditorPanel::render(ECS::World& world, EditorContext& ctx) { ECS::Entity entity = ctx.selectedEntity; if (!entity.isValid() || !world.has(entity)) { - ImGui::TextDisabled("Select a Terrain entity to generate, sculpt, or paint."); + ImGui::TextWrapped( + "Select a Terrain entity to sculpt or paint. Use the Terrain Generator plugin to " + "procedurally create heightmaps."); ImGui::End(); return; } @@ -289,14 +56,10 @@ void TerrainEditorPanel::render(ECS::World& world, EditorContext& ctx) { return; } - ImGui::TextUnformatted("Tools for the selected Terrain — not component data."); + ImGui::TextUnformatted("Sculpt, paint and export terrain data for the selected entity."); ImGui::Separator(); if (ImGui::BeginTabBar("##terrain_editor_tabs")) { - if (ImGui::BeginTabItem("Generate")) { - drawGeneration(world, entity, ctx); - ImGui::EndTabItem(); - } if (ImGui::BeginTabItem("Sculpt / Paint")) { drawEditTools(world, entity, ctx); ImGui::EndTabItem(); @@ -311,282 +74,6 @@ void TerrainEditorPanel::render(ECS::World& world, EditorContext& ctx) { ImGui::End(); } -void TerrainEditorPanel::drawGeneration(ECS::World& world, ECS::Entity entity, EditorContext& ctx) { - auto* terrain = world.get(entity); - if (!terrain) return; - auto& gen = terrain->generation; - - static const char* styleNames[] = { - "Realistic", "Low Poly", "Stylized", "Custom", "Ultra Realistic" - }; - int style = static_cast(gen.style); - if (ImGui::Combo("Preset", &style, styleNames, 5)) { - gen.style = static_cast(style); - Terrain::applyGenerationStylePreset(gen, gen.style); - if (gen.style == ECS::TerrainGenStyle::UltraRealistic) { - copyCString(terrain->texturePath, sizeof(terrain->texturePath), - Assets::kUltraRealisticAlbedo); - for (u32 i = 0; i < ECS::kTerrainSplatLayerCount; ++i) { - copyCString(terrain->splatLayerPaths[i], sizeof(terrain->splatLayerPaths[i]), - Assets::kUltraRealisticSplatLayers[i]); - } - terrain->useSplatmap = true; - terrain->textureTileSize = 12.0f; - terrain->splatTileSize = 12.0f; - terrain->splatRevision++; - } - if (gen.style != ECS::TerrainGenStyle::Custom) { - syncGraphFromStyle(gen.style); - } - generateFromGraph(world, entity, *terrain, ctx); - ctx.isDirty = true; - } - ImGui::SameLine(); - int seed = static_cast(gen.seed); - ImGui::SetNextItemWidth(120.0f); - if (ImGui::InputInt("Seed", &seed)) { - gen.seed = static_cast(std::max(0, seed)); - ctx.isDirty = true; - } - if (ImGui::Button("Gerar")) { - generateFromGraph(world, entity, *terrain, ctx); - } - ImGui::SameLine(); - if (ImGui::Button("Aplanar")) { - if (auto* heightmap = Terrain::TerrainCache::instance().heightmapFor(entity)) { - heightmap->fill(0.0f); - terrain->dataRevision++; - Terrain::TerrainCache::instance().syncEntity(world, entity); - ctx.isDirty = true; - } - } - - ImGui::TextDisabled("Liga geradores a filtros e depois a Output. Clique Gerar para aplicar."); - drawGenerationGraph(world, entity, ctx); - - ImGui::SeparatorText("HDR Ground"); - static int hdrGroundPreset = 0; - hdrGroundPreset = std::clamp(hdrGroundPreset, 0, Assets::kHdrGroundPresetCount - 1); - if (ImGui::Combo("Solo", &hdrGroundPreset, Assets::kHdrGroundPresetLabels, - Assets::kHdrGroundPresetCount)) { - applyHdrGroundPreset(*terrain, hdrGroundPreset); - Terrain::TerrainCache::instance().syncTextureToFilter(world, entity, *terrain); -#ifdef CF_HAS_SDL3 - Terrain::TerrainGpuTextureCache::instance().invalidateEntity(entity, nullptr); -#endif - terrain->splatRevision++; - ctx.isDirty = true; - } -} - -void TerrainEditorPanel::drawGenerationGraph(ECS::World& world, ECS::Entity entity, - EditorContext& ctx) { - auto* terrain = world.get(entity); - if (!terrain) return; - ensureDefaultGraph(); - - if (m_editorContext) { - ImNodes::EditorContextSet(m_editorContext); - } - ImNodes::GetIO().AutoPanningSpeed = 0.0f; - - if (ImGui::Button("Adicionar node")) { - ImGui::OpenPopup("##add_terrain_node"); - } - ImGui::SameLine(); - if (ImGui::Button("Reorganizar")) { - layoutGraphNodes(); - } - if (ImGui::BeginPopup("##add_terrain_node")) { - auto add = [&](TerrainGraphNodeType type) { - TerrainGraphNode node; - node.id = m_nextNodeId++; - node.type = type; - node.posX = 24.0f + static_cast(m_graphNodes.size()) * 36.0f; - node.posY = 48.0f + static_cast(m_graphNodes.size()) * 12.0f; - m_graphNodes.push_back(node); - ImNodes::SetNodeEditorSpacePos(node.id, ImVec2(node.posX, node.posY)); - ImGui::CloseCurrentPopup(); - }; - if (ImGui::MenuItem("Noise FBM")) add(TerrainGraphNodeType::Noise); - if (ImGui::MenuItem("Ridged")) add(TerrainGraphNodeType::Ridged); - if (ImGui::MenuItem("Hybrid")) add(TerrainGraphNodeType::Hybrid); - if (ImGui::MenuItem("Diamond-Square")) add(TerrainGraphNodeType::DiamondSquare); - if (ImGui::MenuItem("FFT Spectral")) add(TerrainGraphNodeType::SpectralFFT); - if (ImGui::MenuItem("Thermal Erosion")) add(TerrainGraphNodeType::Thermal); - if (ImGui::MenuItem("Micro Sculpt")) add(TerrainGraphNodeType::MicroSculpt); - if (ImGui::MenuItem("Rivers")) add(TerrainGraphNodeType::Rivers); - if (ImGui::MenuItem("Geo Simulation")) add(TerrainGraphNodeType::GeoSim); - if (ImGui::MenuItem("Smooth")) add(TerrainGraphNodeType::Smooth); - ImGui::EndPopup(); - } - - const f32 canvasHeight = - std::clamp(ImGui::GetContentRegionAvail().y * 0.58f, 240.0f, 520.0f); - ImGui::PushID("##terrain_graph_editor"); - ImGui::BeginChild("##terrain_graph_canvas", ImVec2(0.0f, canvasHeight), true, - ImGuiWindowFlags_NoScrollbar); - - if (m_graphLayoutDirty) { - layoutGraphNodes(); - m_graphLayoutDirty = false; - } - - ImNodes::BeginNodeEditor(); - for (const auto& node : m_graphNodes) { - ImNodes::SetNodeEditorSpacePos(node.id, ImVec2(node.posX, node.posY)); - ImNodes::BeginNode(node.id); - ImNodes::BeginNodeTitleBar(); - ImGui::TextUnformatted(nodeTitle(node.type)); - ImNodes::EndNodeTitleBar(); - - if (!isGeneratorNode(node.type)) { - ImNodes::BeginInputAttribute(nodeInputAttr(node.id)); - ImGui::TextUnformatted("in"); - ImNodes::EndInputAttribute(); - } - if (node.type != TerrainGraphNodeType::Output) { - ImNodes::BeginOutputAttribute(nodeOutputAttr(node.id)); - ImGui::TextUnformatted("out"); - ImNodes::EndOutputAttribute(); - } - ImNodes::EndNode(); - } - for (const auto& link : m_graphLinks) { - ImNodes::Link(link.id, link.fromAttr, link.toAttr); - } - ImNodes::EndNodeEditor(); - - for (auto& node : m_graphNodes) { - const ImVec2 pos = ImNodes::GetNodeEditorSpacePos(node.id); - node.posX = pos.x; - node.posY = pos.y; - } - - int startAttr = 0; - int endAttr = 0; - if (ImNodes::IsLinkCreated(&startAttr, &endAttr)) { - TerrainGraphLink link; - link.id = m_nextLinkId++; - link.fromAttr = startAttr; - link.toAttr = endAttr; - m_graphLinks.push_back(link); - ctx.isDirty = true; - } - int destroyed = 0; - if (ImNodes::IsLinkDestroyed(&destroyed)) { - m_graphLinks.erase(std::remove_if(m_graphLinks.begin(), m_graphLinks.end(), - [destroyed](const TerrainGraphLink& link) { - return link.id == destroyed; - }), - m_graphLinks.end()); - ctx.isDirty = true; - } - - const int selectedCount = ImNodes::NumSelectedNodes(); - if (selectedCount == 1) { - std::vector selectedIds(static_cast(selectedCount)); - ImNodes::GetSelectedNodes(selectedIds.data()); - m_selectedNodeId = selectedIds[0]; - } else if (selectedCount == 0) { - m_selectedNodeId = 0; - } - - ImGui::EndChild(); - ImGui::PopID(); - - if (m_selectedNodeId != 0) { - TerrainGraphNode* selected = nullptr; - for (auto& node : m_graphNodes) { - if (node.id == m_selectedNodeId) selected = &node; - } - if (selected) { - auto& gen = terrain->generation; - ImGui::SeparatorText(nodeTitle(selected->type)); - if (selected->type == TerrainGraphNodeType::Noise || - selected->type == TerrainGraphNodeType::Ridged || - selected->type == TerrainGraphNodeType::Hybrid) { - static const char* noiseNames[] = {"Perlin", "Simplex", "Value", "Worley"}; - int noiseType = static_cast(gen.noiseAlgorithm); - if (ImGui::Combo("Sampler", &noiseType, noiseNames, 4)) { - gen.noiseAlgorithm = static_cast(noiseType); - ctx.isDirty = true; - } - if (ImGui::DragFloat("Escala", &gen.noiseScale, 0.001f, 0.005f, 0.2f, "%.3f")) { - ctx.isDirty = true; - } - int octaves = static_cast(gen.octaves); - if (ImGui::SliderInt("Octaves", &octaves, 1, 8)) { - gen.octaves = static_cast(octaves); - ctx.isDirty = true; - } - } - if (selected->type == TerrainGraphNodeType::DiamondSquare) { - if (ImGui::SliderFloat("Rugosidade", &gen.fractalRoughness, 0.1f, 0.9f)) { - ctx.isDirty = true; - } - } - if (selected->type == TerrainGraphNodeType::SpectralFFT) { - if (ImGui::SliderFloat("Expoente", &gen.spectralExponent, 1.0f, 3.5f)) { - ctx.isDirty = true; - } - } - if (selected->type == TerrainGraphNodeType::MicroSculpt) { - if (ImGui::SliderFloat("Strength", &gen.microSculptStrength, 0.0f, 1.0f)) { - ctx.isDirty = true; - } - if (ImGui::SliderFloat("Softness", &gen.microSculptSoftness, 0.0f, 1.0f)) { - ctx.isDirty = true; - } - if (ImGui::SliderFloat("Rivers", &gen.microRiverCarve, 0.0f, 1.0f)) { - ctx.isDirty = true; - } - } - if (selected->type == TerrainGraphNodeType::GeoSim) { - ImGui::TextDisabled("Mais lento, mais detalhe geologico."); - int iterations = static_cast(gen.simulation.totalIterations); - if (ImGui::SliderInt("Iterations", &iterations, 80, 800)) { - gen.simulation.totalIterations = static_cast(iterations); - ctx.isDirty = true; - } - int droplets = static_cast(gen.simulation.dropletsPerIteration); - if (ImGui::SliderInt("Droplets", &droplets, 4, 32)) { - gen.simulation.dropletsPerIteration = static_cast(droplets); - ctx.isDirty = true; - } - if (ImGui::SliderFloat("Tectonic", &gen.simulation.tectonicActivity, 0.0f, 1.0f)) { - ctx.isDirty = true; - } - if (ImGui::SliderFloat("Water Erosion", &gen.simulation.erosionWater, 0.0f, 1.0f)) { - ctx.isDirty = true; - } - } - if (selected->type == TerrainGraphNodeType::Thermal) { - int thermalIters = static_cast(gen.thermalIterations); - if (ImGui::SliderInt("Iterations", &thermalIters, 1, 48)) { - gen.thermalIterations = static_cast(thermalIters); - ctx.isDirty = true; - } - } - if (ImGui::Button("Apagar node") && selected->type != TerrainGraphNodeType::Output) { - const int id = selected->id; - m_graphLinks.erase(std::remove_if(m_graphLinks.begin(), m_graphLinks.end(), - [id](const TerrainGraphLink& link) { - return nodeIdFromAttr(link.fromAttr) == id || - nodeIdFromAttr(link.toAttr) == id; - }), - m_graphLinks.end()); - m_graphNodes.erase(std::remove_if(m_graphNodes.begin(), m_graphNodes.end(), - [id](const TerrainGraphNode& node) { - return node.id == id; - }), - m_graphNodes.end()); - m_selectedNodeId = 0; - } - } - } -} - void TerrainEditorPanel::drawEditTools(ECS::World& world, ECS::Entity entity, EditorContext& ctx) { auto* terrain = world.get(entity); if (!terrain) return; diff --git a/src/editor/TerrainEditorPanel.hpp b/src/editor/TerrainEditorPanel.hpp index a432eb5..001ddbc 100644 --- a/src/editor/TerrainEditorPanel.hpp +++ b/src/editor/TerrainEditorPanel.hpp @@ -5,47 +5,10 @@ #include "ecs/World.hpp" #include "editor/EditorContext.hpp" -#ifdef CF_HAS_IMGUI -#include -struct ImNodesEditorContext; -#endif - -#include - namespace Caffeine::Editor { -enum class TerrainGraphNodeType : u8 { - Noise = 0, - Ridged, - Hybrid, - DiamondSquare, - SpectralFFT, - Thermal, - MicroSculpt, - Rivers, - GeoSim, - Smooth, - Output, -}; - -struct TerrainGraphNode { - int id = 0; - TerrainGraphNodeType type = TerrainGraphNodeType::Noise; - float posX = 0.0f; - float posY = 48.0f; -}; - -struct TerrainGraphLink { - int id = 0; - int fromAttr = 0; - int toAttr = 0; -}; - class TerrainEditorPanel { public: - TerrainEditorPanel(); - ~TerrainEditorPanel(); - void render(ECS::World& world, EditorContext& ctx); bool isOpen() const { return m_open; } @@ -54,28 +17,11 @@ class TerrainEditorPanel { private: #ifdef CF_HAS_IMGUI - void drawGeneration(ECS::World& world, ECS::Entity entity, EditorContext& ctx); void drawEditTools(ECS::World& world, ECS::Entity entity, EditorContext& ctx); void drawDataFile(ECS::World& world, ECS::Entity entity, EditorContext& ctx); - void drawGenerationGraph(ECS::World& world, ECS::Entity entity, EditorContext& ctx); - void ensureDefaultGraph(); - void layoutGraphNodes(); - void syncGraphFromStyle(ECS::TerrainGenStyle style); - void applyGraphToSettings(ECS::TerrainGenerationSettings& gen) const; - void generateFromGraph(ECS::World& world, ECS::Entity entity, ECS::TerrainComponent& terrain, - EditorContext& ctx); #endif bool m_open = true; -#ifdef CF_HAS_IMGUI - ImNodesEditorContext* m_editorContext = nullptr; - std::vector m_graphNodes; - std::vector m_graphLinks; - int m_nextNodeId = 1; - int m_nextLinkId = 1; - int m_selectedNodeId = 0; - bool m_graphLayoutDirty = true; -#endif }; } // namespace Caffeine::Editor diff --git a/src/terrain/generation/TerrainGeneratorTypes.hpp b/src/editor/TerrainGenerationBlobLegacy.hpp similarity index 62% rename from src/terrain/generation/TerrainGeneratorTypes.hpp rename to src/editor/TerrainGenerationBlobLegacy.hpp index 6ab8d3e..e472226 100644 --- a/src/terrain/generation/TerrainGeneratorTypes.hpp +++ b/src/editor/TerrainGenerationBlobLegacy.hpp @@ -1,11 +1,32 @@ #pragma once +// Legacy terrain generation blob (v2–v8 scene files). Generation lives in the terrain plugin; +// this header exists only so old scenes deserialize without pulling generation into core. + #include "core/Types.hpp" -#include "terrain/generation/TerrainNoise.hpp" -namespace Caffeine::ECS { +namespace Caffeine::Editor { + +enum class LegacyTerrainNoiseAlgorithm : u8 { + Perlin = 0, + Simplex = 1, + Value = 2, + Worley = 3, +}; -enum class TerrainGenStyle : u8 { +enum class LegacyTerrainHeightModel : u8 { + RollingHills = 0, + FractalFBM = 1, + RidgedMountains = 2, + Multiplicative = 3, + Hybrid = 4, + Combined = 5, + DiamondSquare = 6, + SpectralFFT = 7, + HeightfieldMultiply = 8, +}; + +enum class LegacyTerrainGenStyle : u8 { Realistic = 0, LowPoly = 1, Stylized = 2, @@ -13,7 +34,7 @@ enum class TerrainGenStyle : u8 { UltraRealistic = 4, }; -enum class TerrainEnvironment : u8 { +enum class LegacyTerrainEnvironment : u8 { Custom = 0, TropicalWet = 1, AridDesert = 2, @@ -22,36 +43,34 @@ enum class TerrainEnvironment : u8 { Volcanic = 5, }; -struct TerrainSimulationSettings { +struct LegacyTerrainSimulationSettings { bool enabled = false; - TerrainEnvironment environment = TerrainEnvironment::TropicalWet; + LegacyTerrainEnvironment environment = LegacyTerrainEnvironment::TropicalWet; u32 totalIterations = 250; u32 dropletsPerIteration = 12; u32 maxDropletSteps = 80; f32 convergenceThreshold = 0.00008f; bool autoConvergence = true; - f32 tectonicActivity = 0.55f; f32 erosionWater = 0.85f; f32 erosionThermal = 0.5f; f32 erosionGlacial = 0.0f; f32 erosionWind = 0.1f; f32 erosionBiological = 0.35f; - f32 temperature = 0.75f; f32 humidity = 0.8f; f32 windDirection = 180.0f; f32 rainfallBase = 0.04f; }; -struct TerrainClimateSettings { +struct LegacyTerrainClimateSettings { f32 prevailingWindAngle = 225.0f; f32 baseHumidity = 0.7f; f32 temperature = 0.6f; f32 seaLevel = 0.38f; }; -struct TerrainHydrologySettings { +struct LegacyTerrainHydrologySettings { bool traceRivers = false; u32 maxRiverSources = 48; f32 riverSourceMinHeight = 0.35f; @@ -62,7 +81,7 @@ struct TerrainHydrologySettings { f32 sedimentDepositStrength = 0.00008f; }; -struct TerrainGenerationSettings { +struct LegacyTerrainGenerationSettings { u32 seed = 1337; f32 noiseScale = 0.025f; u32 octaves = 5; @@ -70,8 +89,8 @@ struct TerrainGenerationSettings { f32 lacunarity = 2.0f; f32 amplitude = 0.38f; f32 baseHeight = 0.28f; - Terrain::TerrainNoiseAlgorithm noiseAlgorithm = Terrain::TerrainNoiseAlgorithm::Simplex; - Terrain::TerrainHeightModel heightModel = Terrain::TerrainHeightModel::RollingHills; + LegacyTerrainNoiseAlgorithm noiseAlgorithm = LegacyTerrainNoiseAlgorithm::Simplex; + LegacyTerrainHeightModel heightModel = LegacyTerrainHeightModel::RollingHills; bool useRidgedNoise = false; f32 ridgedBlend = 0.25f; f32 ridgeOffset = 1.0f; @@ -79,8 +98,8 @@ struct TerrainGenerationSettings { f32 multiplicativeContrast = 1.2f; f32 fractalRoughness = 0.55f; f32 spectralExponent = 2.0f; - Terrain::TerrainHeightModel multiplyLayerA = Terrain::TerrainHeightModel::RollingHills; - Terrain::TerrainHeightModel multiplyLayerB = Terrain::TerrainHeightModel::RidgedMountains; + LegacyTerrainHeightModel multiplyLayerA = LegacyTerrainHeightModel::RollingHills; + LegacyTerrainHeightModel multiplyLayerB = LegacyTerrainHeightModel::RidgedMountains; bool domainWarp = true; bool fractalDomainWarp = true; f32 domainWarpStrength = 0.12f; @@ -99,11 +118,6 @@ struct TerrainGenerationSettings { f32 hydraulicDeposit = 0.25f; f32 hydraulicInertia = 0.85f; f32 hydraulicEvaporation = 0.05f; - bool microSculpt = true; - f32 microSculptStrength = 0.42f; - f32 microSculptSoftness = 0.35f; - f32 microRiverCarve = 0.0f; - f32 plateauSharpen = 0.55f; bool smoothPass = true; u32 smoothIterations = 1; u32 postSimSmoothIterations = 4; @@ -112,10 +126,10 @@ struct TerrainGenerationSettings { f32 splatBlendRange = 0.28f; u32 splatBlurPasses = 2; bool useClimateBiomes = true; - TerrainClimateSettings climate; - TerrainHydrologySettings hydrology; - TerrainGenStyle style = TerrainGenStyle::Realistic; - TerrainSimulationSettings simulation; + LegacyTerrainClimateSettings climate; + LegacyTerrainHydrologySettings hydrology; + LegacyTerrainGenStyle style = LegacyTerrainGenStyle::Realistic; + LegacyTerrainSimulationSettings simulation; }; -} // namespace Caffeine::ECS +} // namespace Caffeine::Editor diff --git a/src/procedural/ProceduralBindings.cpp b/src/procedural/ProceduralBindings.cpp new file mode 100644 index 0000000..584cc7c --- /dev/null +++ b/src/procedural/ProceduralBindings.cpp @@ -0,0 +1,192 @@ +#include "procedural/ProceduralGenerator.hpp" +#include "procedural/ProceduralNoise.hpp" +#include "procedural/ProceduralStreamer.hpp" + +#include "ecs/CameraComponents.hpp" +#include "ecs/Components.hpp" +#include "ecs/MeshComponents.hpp" +#include "ecs/ProceduralComponents.hpp" +#include "ecs/TerrainComponents.hpp" +#include "ecs/World.hpp" +#include "terrain/TerrainCache.hpp" + +#include + +#include +#include +#include + +namespace Caffeine::Script { +namespace { + +void registerProceduralBindings(sol::state& lua, ECS::World** worldPtr) { + lua["caffeine"]["procedural"] = lua.create_table(); + sol::table pt = lua["caffeine"]["procedural"]; + + pt["hash"] = [](i32 x, i32 z, u32 seed, u32 salt) { + return Procedural::hash01(x, z, seed, salt); + }; + + pt["noise2d"] = [](f32 x, f32 z, u32 seed) { + return Procedural::valueNoise2D(x, z, seed); + }; + + pt["fbm2d"] = [](f32 x, f32 z, u32 seed, u32 octaves, f32 lacunarity, f32 persistence) { + return Procedural::fbm2D(x, z, seed, octaves, lacunarity, persistence); + }; + + pt["worldToChunk"] = [](f32 worldCoord, f32 chunkSize) { + return Procedural::ProceduralStreamer::worldToChunk(worldCoord, chunkSize); + }; + + pt["findTerrain"] = [worldPtr]() -> u32 { + ECS::World* world = worldPtr ? *worldPtr : nullptr; + if (!world) return 0; + return Procedural::ProceduralStreamer::instance().findTerrainEntity(*world); + }; + + pt["getFocusChunk"] = [&lua, worldPtr](u32 directorId, f32 chunkSize, sol::optional focusId) + -> sol::table { + sol::table t = lua.create_table(); + t["cx"] = 0; + t["cz"] = 0; + ECS::World* world = worldPtr ? *worldPtr : nullptr; + if (!world) return t; + + ECS::ProceduralWorldComponent settings; + settings.chunkSize = chunkSize; + if (focusId) settings.focusEntityId = *focusId; + + ECS::Entity director(directorId, world); + if (director.isValid()) { + if (auto* proc = director.get()) { + settings = *proc; + } + } + + i32 cx = 0; + i32 cz = 0; + Procedural::ProceduralStreamer::focusChunkFromEntity(*world, settings, cx, cz); + t["cx"] = cx; + t["cz"] = cz; + return t; + }; + + pt["isChunkLoaded"] = [](u32 directorId, i32 cx, i32 cz) { + return Procedural::ProceduralStreamer::instance().isChunkLoaded(directorId, cx, cz); + }; + + pt["unloadChunk"] = [worldPtr](u32 directorId, i32 cx, i32 cz) { + ECS::World* world = worldPtr ? *worldPtr : nullptr; + if (!world) return; + Procedural::ProceduralStreamer::instance().unloadChunk(*world, directorId, cx, cz); + }; + + pt["stream"] = [worldPtr](u32 directorId, sol::optional terrainId, i32 cx, i32 cz, + sol::optional preset) { + ECS::World* world = worldPtr ? *worldPtr : nullptr; + if (!world) return; + + ECS::Entity director(directorId, world); + if (!director.isValid()) return; + + auto& settings = director.getOrAdd(); + if (terrainId) settings.terrainEntityId = *terrainId; + if (preset) { + std::strncpy(settings.preset, preset->c_str(), sizeof(settings.preset) - 1); + settings.preset[sizeof(settings.preset) - 1] = '\0'; + } + + Procedural::ProceduralStreamer::instance().streamAround(*world, director, settings, cx, cz); + }; + + pt["generateTerrainChunk"] = [worldPtr](u32 terrainId, i32 cx, i32 cz, u32 seed, + sol::optional preset) { + ECS::World* world = worldPtr ? *worldPtr : nullptr; + if (!world) return; + + ECS::Entity terrain(terrainId, world); + if (!terrain.isValid()) return; + + ECS::ProceduralWorldComponent settings; + settings.seed = seed; + if (preset) { + std::strncpy(settings.preset, preset->c_str(), sizeof(settings.preset) - 1); + } + settings.chunkSize = 64.0f; + if (auto* tc = terrain.get()) { + settings.chunkSize = std::max(tc->worldSizeX / 8.0f, 32.0f); + } + + Procedural::ProceduralGenerator::generateTerrainChunk(*world, terrain, cx, cz, settings); + }; + + pt["spawnCube"] = [worldPtr](u32 directorId, i32 cx, i32 cz, f32 x, f32 y, f32 z, f32 sx, + f32 sy, f32 sz) -> u32 { + ECS::World* world = worldPtr ? *worldPtr : nullptr; + if (!world) return 0; + + ECS::Entity e = world->create("ProcCube"); + ECS::Transform& t = e.getOrAdd(); + t.position = Vec3(x, y, z); + t.scale = Vec3(sx, sy, sz); + + ECS::MeshFilterComponent filter; + filter.primitive = ECS::MeshPrimitive::Cube; + e.add(filter); + e.add(); + + ECS::ProceduralSpawnTag tag; + tag.directorEntityId = directorId; + tag.chunkX = cx; + tag.chunkZ = cz; + e.add(tag); + return e.id(); + }; + + pt["fillTerrainNoise"] = [worldPtr](u32 terrainId, u32 seed, f32 amplitude) { + ECS::World* world = worldPtr ? *worldPtr : nullptr; + if (!world) return; + ECS::Entity terrain(terrainId, world); + if (!terrain.isValid()) return; + + Terrain::TerrainHeightmap* hm = Terrain::TerrainCache::instance().heightmapFor(terrain); + if (!hm) { + Terrain::TerrainCache::instance().initializeEntity(*world, terrain); + hm = Terrain::TerrainCache::instance().heightmapFor(terrain); + } + if (!hm) return; + + auto* tc = terrain.get(); + if (!tc) return; + + const f32 halfX = tc->worldSizeX * 0.5f; + const f32 halfZ = tc->worldSizeZ * 0.5f; + const u32 resX = hm->resolutionX(); + const u32 resZ = hm->resolutionZ(); + + for (u32 z = 0; z < resZ; ++z) { + for (u32 x = 0; x < resX; ++x) { + const f32 u = static_cast(x) / static_cast(std::max(1u, resX - 1)); + const f32 v = static_cast(z) / static_cast(std::max(1u, resZ - 1)); + const f32 wx = -halfX + u * tc->worldSizeX; + const f32 wz = -halfZ + v * tc->worldSizeZ; + const f32 h = Procedural::fbm2D(wx * 0.02f, wz * 0.02f, seed, 5, 2.0f, 0.45f); + hm->setNormalized(x, z, std::clamp(h * amplitude, 0.0f, 1.0f)); + } + } + + tc->dataRevision++; + Terrain::TerrainCache::instance().syncEntity(*world, terrain); + }; +} + +} // namespace + +void registerProceduralScriptBindings(sol::state& lua, ECS::World** worldPtr); + +void registerProceduralScriptBindings(sol::state& lua, ECS::World** worldPtr) { + registerProceduralBindings(lua, worldPtr); +} + +} // namespace Caffeine::Script diff --git a/src/procedural/ProceduralGenerator.cpp b/src/procedural/ProceduralGenerator.cpp new file mode 100644 index 0000000..2e93852 --- /dev/null +++ b/src/procedural/ProceduralGenerator.cpp @@ -0,0 +1,209 @@ +#include "procedural/ProceduralGenerator.hpp" + +#include "ecs/Components.hpp" +#include "ecs/MeshComponents.hpp" +#include "ecs/TerrainComponents.hpp" +#include "procedural/ProceduralNoise.hpp" +#include "procedural/ProceduralProfiles.hpp" +#include "terrain/TerrainCache.hpp" + +#include +#include +#include +#include + +namespace Caffeine::Procedural { +namespace { + +bool profileIs(const char* profile, const char* name) { + return profile && name && std::strcmp(profile, name) == 0; +} + +f32 heightForTerrainProfile(const char* profile, f32 worldX, f32 worldZ, u32 seed) { + if (profileIs(profile, "flat")) { + return 0.0f; + } + if (profileIs(profile, "hills")) { + const f32 n = fbm2D(worldX * 0.02f, worldZ * 0.02f, seed, 5, 2.0f, 0.45f); + const f32 hills = fbm2D(worldX * 0.005f, worldZ * 0.005f, seed + 17, 3, 2.0f, 0.5f); + return n * 0.55f + hills * 0.25f; + } + return fbm2D(worldX * 0.015f, worldZ * 0.015f, seed, 4, 2.0f, 0.5f) * 0.4f; +} + +ECS::Entity spawnCube(ECS::World& world, const char* name, f32 x, f32 y, f32 z, + f32 sx, f32 sy, f32 sz, u32 directorId, i32 cx, i32 cz) { + ECS::Entity e = world.create(name); + ECS::Transform& t = e.getOrAdd(); + t.position = Vec3(x, y, z); + t.scale = Vec3(sx, sy, sz); + + ECS::MeshFilterComponent filter; + filter.primitive = ECS::MeshPrimitive::Cube; + e.add(filter); + + ECS::MeshRendererComponent renderer; + e.add(renderer); + + ECS::ProceduralSpawnTag tag; + tag.directorEntityId = directorId; + tag.chunkX = cx; + tag.chunkZ = cz; + e.add(tag); + return e; +} + +void spawnExplorationProps(ECS::World& world, u32 directorId, i32 cx, i32 cz, f32 chunkSize, + u32 seed) { + const f32 baseX = static_cast(cx) * chunkSize; + const f32 baseZ = static_cast(cz) * chunkSize; + const int count = 4 + static_cast(hash01(cx, cz, seed, 7) * 6.0f); + for (int i = 0; i < count; ++i) { + const f32 rx = hash01(cx + i, cz, seed, 11 + i); + const f32 rz = hash01(cx, cz + i, seed, 23 + i); + if (rx < 0.25f) continue; + const f32 x = baseX + rx * chunkSize; + const f32 z = baseZ + rz * chunkSize; + const f32 h = 2.0f + hash01(i, cx, seed, 31) * 5.0f; + spawnCube(world, "ExplorationMarker", x, h * 0.5f, z, 1.2f, h, 1.2f, directorId, cx, cz); + } +} + +void spawnBackroomsChunk(ECS::World& world, u32 directorId, i32 cx, i32 cz, f32 chunkSize, + u32 seed) { + const f32 baseX = static_cast(cx) * chunkSize; + const f32 baseZ = static_cast(cz) * chunkSize; + const f32 roomW = 12.0f; + const f32 wallH = 3.0f; + const f32 floorY = 0.05f; + + spawnCube(world, "BackroomsFloor", baseX + chunkSize * 0.5f, floorY, baseZ + chunkSize * 0.5f, + chunkSize, 0.1f, chunkSize, directorId, cx, cz); + + const int roomsX = std::max(1, static_cast(chunkSize / roomW)); + const int roomsZ = std::max(1, static_cast(chunkSize / roomW)); + for (int rz = 0; rz < roomsZ; ++rz) { + for (int rx = 0; rx < roomsX; ++rx) { + const f32 cx0 = baseX + static_cast(rx) * roomW; + const f32 cz0 = baseZ + static_cast(rz) * roomW; + const f32 roll = hash01(cx + rx, cz + rz, seed, 101); + if (roll < 0.12f) continue; + + spawnCube(world, "BackroomsWallN", cx0 + roomW * 0.5f, wallH * 0.5f, cz0 + 0.1f, + roomW, wallH, 0.2f, directorId, cx, cz); + spawnCube(world, "BackroomsWallS", cx0 + roomW * 0.5f, wallH * 0.5f, + cz0 + roomW - 0.1f, roomW, wallH, 0.2f, directorId, cx, cz); + spawnCube(world, "BackroomsWallW", cx0 + 0.1f, wallH * 0.5f, cz0 + roomW * 0.5f, 0.2f, + wallH, roomW, directorId, cx, cz); + spawnCube(world, "BackroomsWallE", cx0 + roomW - 0.1f, wallH * 0.5f, + cz0 + roomW * 0.5f, 0.2f, wallH, roomW, directorId, cx, cz); + + if (hash01(rx, rz, seed, 202) > 0.7f) { + spawnCube(world, "BackroomsPillar", cx0 + roomW * 0.5f, wallH * 0.5f, + cz0 + roomW * 0.5f, 0.8f, wallH, 0.8f, directorId, cx, cz); + } + } + } +} + +void spawnEndlessRaceChunk(ECS::World& world, u32 directorId, i32 cx, i32 cz, f32 chunkSize, + u32 seed) { + const f32 baseX = static_cast(cx) * chunkSize; + const f32 baseZ = static_cast(cz) * chunkSize; + const f32 trackW = chunkSize * 0.45f; + const f32 centerX = baseX + chunkSize * 0.5f; + + spawnCube(world, "RaceTrack", centerX, 0.05f, baseZ + chunkSize * 0.5f, trackW, 0.1f, chunkSize, + directorId, cx, cz); + + const f32 wallOffset = trackW * 0.5f + 0.5f; + const f32 curve = (hash01(cx, cz, seed, 303) - 0.5f) * chunkSize * 0.15f; + spawnCube(world, "RaceWallL", centerX - wallOffset + curve, 1.5f, baseZ + chunkSize * 0.5f, + 0.4f, 3.0f, chunkSize, directorId, cx, cz); + spawnCube(world, "RaceWallR", centerX + wallOffset + curve, 1.5f, baseZ + chunkSize * 0.5f, + 0.4f, 3.0f, chunkSize, directorId, cx, cz); + + if (hash01(cx, cz, seed, 404) > 0.65f) { + spawnCube(world, "RaceObstacle", centerX + curve, 0.75f, + baseZ + chunkSize * (0.3f + hash01(cx, cz, seed, 505) * 0.4f), 2.0f, 1.5f, 2.0f, + directorId, cx, cz); + } +} + +} // namespace + +void ProceduralGenerator::generateTerrainChunk(ECS::World& world, ECS::Entity terrainEntity, + i32 chunkX, i32 chunkZ, + const ECS::ProceduralWorldComponent& settings) { + auto* terrain = world.get(terrainEntity); + if (!terrain) return; + + Terrain::TerrainHeightmap* hm = Terrain::TerrainCache::instance().heightmapFor(terrainEntity); + if (!hm || hm->empty()) { + Terrain::TerrainCache::instance().initializeEntity(world, terrainEntity); + hm = Terrain::TerrainCache::instance().heightmapFor(terrainEntity); + } + if (!hm) return; + + const f32 chunkSize = std::max(settings.chunkSize, 8.0f); + const f32 halfX = terrain->worldSizeX * 0.5f; + const f32 halfZ = terrain->worldSizeZ * 0.5f; + const u32 resX = hm->resolutionX(); + const u32 resZ = hm->resolutionZ(); + if (resX < 2 || resZ < 2) return; + + const f32 baseX = static_cast(chunkX) * chunkSize; + const f32 baseZ = static_cast(chunkZ) * chunkSize; + + for (u32 z = 0; z < resZ; ++z) { + for (u32 x = 0; x < resX; ++x) { + const f32 u = static_cast(x) / static_cast(resX - 1); + const f32 v = static_cast(z) / static_cast(resZ - 1); + const f32 worldX = -halfX + u * terrain->worldSizeX; + const f32 worldZ = -halfZ + v * terrain->worldSizeZ; + + if (worldX < baseX || worldX >= baseX + chunkSize || worldZ < baseZ || + worldZ >= baseZ + chunkSize) { + continue; + } + + const f32 h = + heightForTerrainProfile(effectiveTerrainProfile(settings), worldX, worldZ, settings.seed); + hm->setNormalized(x, z, std::clamp(h, 0.0f, 1.0f)); + } + } + + terrain->dataRevision++; + Terrain::TerrainCache::instance().syncEntity(world, terrainEntity); +} + +std::vector ProceduralGenerator::generateStructureChunk(ECS::World& world, + ECS::Entity directorEntity, + i32 chunkX, i32 chunkZ, + const ECS::ProceduralWorldComponent& settings) { + std::vector spawned; + if (!directorEntity.isValid()) return spawned; + + const f32 chunkSize = std::max(settings.chunkSize, 8.0f); + const u32 directorId = directorEntity.id(); + + const char* structure = effectiveStructureProfile(settings); + if (profileIs(structure, "rooms")) { + spawnBackroomsChunk(world, directorId, chunkX, chunkZ, chunkSize, settings.seed); + } else if (profileIs(structure, "track")) { + spawnEndlessRaceChunk(world, directorId, chunkX, chunkZ, chunkSize, settings.seed); + } else if (profileIs(structure, "markers")) { + spawnExplorationProps(world, directorId, chunkX, chunkZ, chunkSize, settings.seed); + } + + ECS::ComponentQuery q; + q.with(); + world.forEach(q, [&](ECS::Entity e, ECS::ProceduralSpawnTag& tag) { + if (tag.directorEntityId == directorId && tag.chunkX == chunkX && tag.chunkZ == chunkZ) { + spawned.push_back(e.id()); + } + }); + return spawned; +} + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralGenerator.hpp b/src/procedural/ProceduralGenerator.hpp new file mode 100644 index 0000000..a2eb4cf --- /dev/null +++ b/src/procedural/ProceduralGenerator.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "core/Types.hpp" +#include "ecs/Entity.hpp" +#include "ecs/ProceduralComponents.hpp" +#include "ecs/World.hpp" + +namespace Caffeine::Procedural { + +class ProceduralGenerator { +public: + static void generateTerrainChunk(ECS::World& world, ECS::Entity terrainEntity, + i32 chunkX, i32 chunkZ, + const ECS::ProceduralWorldComponent& settings); + + static std::vector generateStructureChunk(ECS::World& world, ECS::Entity directorEntity, + i32 chunkX, i32 chunkZ, + const ECS::ProceduralWorldComponent& settings); +}; + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralNoise.cpp b/src/procedural/ProceduralNoise.cpp new file mode 100644 index 0000000..26bc3dd --- /dev/null +++ b/src/procedural/ProceduralNoise.cpp @@ -0,0 +1,48 @@ +#include "procedural/ProceduralNoise.hpp" + +#include + +namespace Caffeine::Procedural { + +f32 hash01(i32 x, i32 z, u32 seed, u32 salt) { + u32 n = static_cast(x) * 374761393u + static_cast(z) * 668265263u + + seed * 362437u + salt * 1013904223u; + n = (n ^ (n >> 13u)) * 1274126177u; + n ^= n >> 16u; + return static_cast(n & 0xFFFFu) / 65535.0f; +} + +f32 valueNoise2D(f32 x, f32 z, u32 seed) { + const i32 x0 = static_cast(std::floor(x)); + const i32 z0 = static_cast(std::floor(z)); + const i32 x1 = x0 + 1; + const i32 z1 = z0 + 1; + const f32 tx = x - static_cast(x0); + const f32 tz = z - static_cast(z0); + const f32 sx = tx * tx * (3.0f - 2.0f * tx); + const f32 sz = tz * tz * (3.0f - 2.0f * tz); + + const f32 n00 = hash01(x0, z0, seed); + const f32 n10 = hash01(x1, z0, seed); + const f32 n01 = hash01(x0, z1, seed); + const f32 n11 = hash01(x1, z1, seed); + const f32 nx0 = n00 + (n10 - n00) * sx; + const f32 nx1 = n01 + (n11 - n01) * sx; + return nx0 + (nx1 - nx0) * sz; +} + +f32 fbm2D(f32 x, f32 z, u32 seed, u32 octaves, f32 lacunarity, f32 persistence) { + f32 amplitude = 1.0f; + f32 frequency = 1.0f; + f32 sum = 0.0f; + f32 norm = 0.0f; + for (u32 i = 0; i < octaves; ++i) { + sum += valueNoise2D(x * frequency, z * frequency, seed + i * 7919u) * amplitude; + norm += amplitude; + amplitude *= persistence; + frequency *= lacunarity; + } + return norm > 0.0f ? sum / norm : 0.0f; +} + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralNoise.hpp b/src/procedural/ProceduralNoise.hpp new file mode 100644 index 0000000..356793f --- /dev/null +++ b/src/procedural/ProceduralNoise.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "core/Types.hpp" + +namespace Caffeine::Procedural { + +f32 hash01(i32 x, i32 z, u32 seed, u32 salt = 0); +f32 valueNoise2D(f32 x, f32 z, u32 seed); +f32 fbm2D(f32 x, f32 z, u32 seed, u32 octaves = 4, f32 lacunarity = 2.0f, + f32 persistence = 0.5f); + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralProfiles.cpp b/src/procedural/ProceduralProfiles.cpp new file mode 100644 index 0000000..520b3e8 --- /dev/null +++ b/src/procedural/ProceduralProfiles.cpp @@ -0,0 +1,71 @@ +#include "procedural/ProceduralProfiles.hpp" + +#include +#include + +namespace Caffeine::Procedural { +namespace { + +bool strEq(const char* a, const char* b) { + return a && b && std::strcmp(a, b) == 0; +} + +} // namespace + +const char* effectiveTerrainProfile(const ECS::ProceduralWorldComponent& settings) { + if (settings.terrainProfile[0] != '\0') { + return settings.terrainProfile; + } + if (strEq(settings.preset, "flat") || strEq(settings.preset, "backrooms") || + strEq(settings.preset, "endless_race")) { + return "flat"; + } + if (strEq(settings.preset, "exploration")) { + return "hills"; + } + return "hills"; +} + +const char* effectiveStructureProfile(const ECS::ProceduralWorldComponent& settings) { + if (settings.structureProfile[0] != '\0') { + return settings.structureProfile; + } + if (strEq(settings.preset, "backrooms")) return "rooms"; + if (strEq(settings.preset, "endless_race")) return "track"; + if (strEq(settings.preset, "exploration")) return "markers"; + return "none"; +} + +void applyLegacyPreset(ECS::ProceduralWorldComponent& settings, const char* legacyPreset) { + if (!legacyPreset || !legacyPreset[0]) return; + std::strncpy(settings.preset, legacyPreset, sizeof(settings.preset) - 1); + settings.preset[sizeof(settings.preset) - 1] = '\0'; + + if (strEq(legacyPreset, "exploration")) { + std::strncpy(settings.terrainProfile, "hills", sizeof(settings.terrainProfile) - 1); + std::strncpy(settings.structureProfile, "markers", sizeof(settings.structureProfile) - 1); + } else if (strEq(legacyPreset, "backrooms")) { + std::strncpy(settings.terrainProfile, "flat", sizeof(settings.terrainProfile) - 1); + std::strncpy(settings.structureProfile, "rooms", sizeof(settings.structureProfile) - 1); + } else if (strEq(legacyPreset, "endless_race")) { + std::strncpy(settings.terrainProfile, "flat", sizeof(settings.terrainProfile) - 1); + std::strncpy(settings.structureProfile, "track", sizeof(settings.structureProfile) - 1); + } else if (strEq(legacyPreset, "flat")) { + std::strncpy(settings.terrainProfile, "flat", sizeof(settings.terrainProfile) - 1); + std::strncpy(settings.structureProfile, "none", sizeof(settings.structureProfile) - 1); + } +} + +void applyBenchmark(ECS::ProceduralWorldComponent& settings, const char* benchmarkName, + char* scriptPathOut, std::size_t scriptPathSize) { + const char* name = (benchmarkName && benchmarkName[0]) ? benchmarkName : "exploration"; + applyLegacyPreset(settings, name); + + if (scriptPathOut && scriptPathSize > 0) { + const std::string path = std::string("scripts/procedural/") + name + "_director.lua"; + std::strncpy(scriptPathOut, path.c_str(), scriptPathSize - 1); + scriptPathOut[scriptPathSize - 1] = '\0'; + } +} + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralProfiles.hpp b/src/procedural/ProceduralProfiles.hpp new file mode 100644 index 0000000..1bee9e0 --- /dev/null +++ b/src/procedural/ProceduralProfiles.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include "ecs/ProceduralComponents.hpp" + +namespace Caffeine::Procedural { + +/// Resolves terrain/structure profiles, including legacy `preset` field fallback. +const char* effectiveTerrainProfile(const ECS::ProceduralWorldComponent& settings); +const char* effectiveStructureProfile(const ECS::ProceduralWorldComponent& settings); + +void applyLegacyPreset(ECS::ProceduralWorldComponent& settings, const char* legacyPreset); + +void applyBenchmark(ECS::ProceduralWorldComponent& settings, const char* benchmarkName, + char* scriptPathOut, std::size_t scriptPathSize); + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralStreamer.cpp b/src/procedural/ProceduralStreamer.cpp new file mode 100644 index 0000000..c3989d4 --- /dev/null +++ b/src/procedural/ProceduralStreamer.cpp @@ -0,0 +1,181 @@ +#include "procedural/ProceduralStreamer.hpp" + +#include "ecs/CameraComponents.hpp" +#include "ecs/Components.hpp" +#include "ecs/TerrainComponents.hpp" +#include "procedural/ProceduralGenerator.hpp" +#include +#include + +namespace Caffeine::Procedural { + +ProceduralStreamer& ProceduralStreamer::instance() { + static ProceduralStreamer streamer; + return streamer; +} + +void ProceduralStreamer::reset() { + m_directorChunks.clear(); +} + +u64 ProceduralStreamer::chunkKey(i32 cx, i32 cz) const { + return (static_cast(static_cast(cx)) << 32) | + static_cast(static_cast(cz)); +} + +i32 ProceduralStreamer::worldToChunk(f32 worldCoord, f32 chunkSize) { + chunkSize = std::max(chunkSize, 1.0f); + return static_cast(std::floor(worldCoord / chunkSize)); +} + +bool ProceduralStreamer::isChunkLoaded(u32 directorId, i32 cx, i32 cz) const { + const auto it = m_directorChunks.find(directorId); + if (it == m_directorChunks.end()) return false; + return it->second.find(chunkKey(cx, cz)) != it->second.end(); +} + +u32 ProceduralStreamer::findTerrainEntity(ECS::World& world) const { + ECS::ComponentQuery q; + q.with(); + u32 found = 0; + world.forEach(q, [&](ECS::Entity e, ECS::TerrainComponent&) { + if (found == 0) found = e.id(); + }); + return found; +} + +void ProceduralStreamer::focusChunkFromEntity(ECS::World& world, + const ECS::ProceduralWorldComponent& settings, + i32& outCx, i32& outCz) { + outCx = 0; + outCz = 0; + const f32 chunkSize = std::max(settings.chunkSize, 8.0f); + + ECS::Entity focus; + if (settings.focusEntityId != 0) { + focus = ECS::Entity(settings.focusEntityId, &world); + } else { + ECS::ComponentQuery camQ; + camQ.with(); + world.forEach(camQ, [&](ECS::Entity e, ECS::Camera3DComponent&) { + if (!focus.isValid()) focus = e; + }); + if (!focus.isValid()) { + ECS::ComponentQuery cam2Q; + cam2Q.with(); + world.forEach(cam2Q, [&](ECS::Entity e, ECS::Camera2DComponent&) { + if (!focus.isValid()) focus = e; + }); + } + } + + f32 x = 0.0f; + f32 z = 0.0f; + if (focus.isValid()) { + if (auto* p3 = focus.get()) { + x = p3->position.x; + z = p3->position.z; + } else if (auto* t = focus.get()) { + x = t->position.x; + z = t->position.z; + } + } + + outCx = worldToChunk(x, chunkSize); + outCz = worldToChunk(z, chunkSize); +} + +void ProceduralStreamer::unloadChunk(ECS::World& world, u32 directorId, i32 cx, i32 cz) { + auto dirIt = m_directorChunks.find(directorId); + if (dirIt == m_directorChunks.end()) return; + + const u64 key = chunkKey(cx, cz); + auto chunkIt = dirIt->second.find(key); + if (chunkIt == dirIt->second.end()) return; + + for (u32 entityId : chunkIt->second.entityIds) { + ECS::Entity e(entityId, &world); + if (e.isValid()) { + world.destroy(e); + } + } + + ECS::ComponentQuery q; + q.with(); + world.forEach(q, [&](ECS::Entity e, ECS::ProceduralSpawnTag& tag) { + if (tag.directorEntityId == directorId && tag.chunkX == cx && tag.chunkZ == cz) { + world.destroy(e); + } + }); + + dirIt->second.erase(chunkIt); +} + +void ProceduralStreamer::loadChunk(ECS::World& world, ECS::Entity directorEntity, + ECS::ProceduralWorldComponent& settings, i32 cx, i32 cz) { + if (!settings.enabled || !directorEntity.isValid()) return; + if (isChunkLoaded(directorEntity.id(), cx, cz)) return; + + u32 terrainId = settings.terrainEntityId; + if (terrainId == 0) { + terrainId = findTerrainEntity(world); + settings.terrainEntityId = terrainId; + } + + if (terrainId != 0) { + ECS::Entity terrain(terrainId, &world); + if (terrain.isValid()) { + ProceduralGenerator::generateTerrainChunk(world, terrain, cx, cz, settings); + } + } + + LoadedChunk chunk; + chunk.cx = cx; + chunk.cz = cz; + chunk.entityIds = + ProceduralGenerator::generateStructureChunk(world, directorEntity, cx, cz, settings); + + m_directorChunks[directorEntity.id()][chunkKey(cx, cz)] = std::move(chunk); +} + +void ProceduralStreamer::streamAround(ECS::World& world, ECS::Entity directorEntity, + ECS::ProceduralWorldComponent& settings, i32 focusChunkX, + i32 focusChunkZ) { + if (!settings.enabled || !directorEntity.isValid()) return; + + const u32 radius = std::max(1u, settings.viewRadius); + const u32 directorId = directorEntity.id(); + + std::vector> needed; + needed.reserve(static_cast((2 * radius + 1) * (2 * radius + 1))); + for (i32 dz = -static_cast(radius); dz <= static_cast(radius); ++dz) { + for (i32 dx = -static_cast(radius); dx <= static_cast(radius); ++dx) { + needed.emplace_back(focusChunkX + dx, focusChunkZ + dz); + } + } + + std::vector> toUnload; + auto& loaded = m_directorChunks[directorId]; + for (const auto& [key, chunk] : loaded) { + bool keep = false; + for (const auto& n : needed) { + if (n.first == chunk.cx && n.second == chunk.cz) { + keep = true; + break; + } + } + if (!keep) { + toUnload.emplace_back(chunk.cx, chunk.cz); + } + } + + for (const auto& [cx, cz] : toUnload) { + unloadChunk(world, directorId, cx, cz); + } + + for (const auto& [cx, cz] : needed) { + loadChunk(world, directorEntity, settings, cx, cz); + } +} + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralStreamer.hpp b/src/procedural/ProceduralStreamer.hpp new file mode 100644 index 0000000..5cfb9e2 --- /dev/null +++ b/src/procedural/ProceduralStreamer.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "core/Types.hpp" +#include "ecs/Entity.hpp" +#include "ecs/ProceduralComponents.hpp" +#include "ecs/World.hpp" + +#include +#include + +namespace Caffeine::Procedural { + +class ProceduralStreamer { +public: + static ProceduralStreamer& instance(); + + void reset(); + void streamAround(ECS::World& world, ECS::Entity directorEntity, + ECS::ProceduralWorldComponent& settings, i32 focusChunkX, i32 focusChunkZ); + + bool isChunkLoaded(u32 directorId, i32 cx, i32 cz) const; + void unloadChunk(ECS::World& world, u32 directorId, i32 cx, i32 cz); + + static i32 worldToChunk(f32 worldCoord, f32 chunkSize); + static void focusChunkFromEntity(ECS::World& world, const ECS::ProceduralWorldComponent& settings, + i32& outCx, i32& outCz); + + u32 findTerrainEntity(ECS::World& world) const; + +private: + struct LoadedChunk { + i32 cx = 0; + i32 cz = 0; + std::vector entityIds; + }; + + u64 chunkKey(i32 cx, i32 cz) const; + void loadChunk(ECS::World& world, ECS::Entity directorEntity, + ECS::ProceduralWorldComponent& settings, i32 cx, i32 cz); + + std::unordered_map> m_directorChunks; +}; + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralWorldSystem.cpp b/src/procedural/ProceduralWorldSystem.cpp new file mode 100644 index 0000000..6710bd0 --- /dev/null +++ b/src/procedural/ProceduralWorldSystem.cpp @@ -0,0 +1,33 @@ +#include "procedural/ProceduralWorldSystem.hpp" + +#include "ecs/ComponentQuery.hpp" +#include "ecs/ProceduralComponents.hpp" +#include "procedural/ProceduralStreamer.hpp" +#include "script/ScriptTypes.hpp" + +namespace Caffeine::Procedural { + +void ProceduralWorldSystem::reset() { + ProceduralStreamer::instance().reset(); +} + +void ProceduralWorldSystem::update(ECS::World& world) { + ECS::ComponentQuery q; + q.with(); + + world.forEach(q, + [&](ECS::Entity entity, ECS::ProceduralWorldComponent& settings) { + if (!settings.enabled) return; + + if (world.has(entity)) { + return; + } + + i32 cx = 0; + i32 cz = 0; + ProceduralStreamer::focusChunkFromEntity(world, settings, cx, cz); + ProceduralStreamer::instance().streamAround(world, entity, settings, cx, cz); + }); +} + +} // namespace Caffeine::Procedural diff --git a/src/procedural/ProceduralWorldSystem.hpp b/src/procedural/ProceduralWorldSystem.hpp new file mode 100644 index 0000000..a20a238 --- /dev/null +++ b/src/procedural/ProceduralWorldSystem.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "ecs/World.hpp" + +namespace Caffeine::Procedural { + +class ProceduralWorldSystem { +public: + static void update(ECS::World& world); + static void reset(); +}; + +} // namespace Caffeine::Procedural diff --git a/src/render/PostProcessRenderer.hpp b/src/render/PostProcessRenderer.hpp index 0091c7f..aa8172f 100644 --- a/src/render/PostProcessRenderer.hpp +++ b/src/render/PostProcessRenderer.hpp @@ -22,18 +22,64 @@ inline void applyPostProcessOverlay(ImDrawList* dl, ImVec2 origin, ImVec2 size, const ImVec2 p0 = origin; const ImVec2 p1(origin.x + size.x, origin.y + size.y); - if (fx.exposure != 1.0f || fx.contrast != 1.0f || fx.saturation != 1.0f) { - const f32 tint = std::clamp(fx.exposure, 0.2f, 3.0f); - const u8 alpha = static_cast(std::clamp((tint - 1.0f) * 40.0f + 8.0f, 0.0f, 48.0f)); - if (tint > 1.0f) { + f32 exposure = fx.colorGrading.exposure; + if (fx.autoExposure.enabled) { + exposure = std::clamp(exposure + fx.autoExposure.compensation, fx.autoExposure.minExposure, + fx.autoExposure.maxExposure); + } + + if (fx.colorGrading.enabled) { + const f32 tint = std::clamp(exposure, 0.2f, 4.0f); + const f32 contrastDelta = std::abs(fx.colorGrading.contrast - 1.0f); + const f32 satDelta = std::abs(fx.colorGrading.saturation - 1.0f); + const u8 alpha = static_cast( + std::clamp((tint - 1.0f) * 40.0f + contrastDelta * 30.0f + satDelta * 24.0f + 10.0f, 8.0f, + 56.0f)); + if (tint >= 1.0f) { dl->AddRectFilled(p0, p1, IM_COL32(255, 255, 240, alpha)); } else { dl->AddRectFilled(p0, p1, IM_COL32(0, 0, 20, alpha)); } + if (fx.colorGrading.temperature < -0.05f) { + dl->AddRectFilled(p0, p1, IM_COL32(0, 20, 40, 20)); + } else if (fx.colorGrading.temperature > 0.05f) { + dl->AddRectFilled(p0, p1, IM_COL32(40, 20, 0, 20)); + } + } + + if (fx.deferredFog.enabled && fx.deferredFog.density > 0.001f) { + const u8 fogA = static_cast(std::clamp(fx.deferredFog.density * 400.0f, 0.0f, 120.0f)); + const u8 r = static_cast(fx.deferredFog.colorR * 255.0f); + const u8 g = static_cast(fx.deferredFog.colorG * 255.0f); + const u8 b = static_cast(fx.deferredFog.colorB * 255.0f); + dl->AddRectFilledMultiColor(p0, p1, IM_COL32(r, g, b, 0), IM_COL32(r, g, b, 0), + IM_COL32(r, g, b, fogA), IM_COL32(r, g, b, fogA)); + } + + if (fx.ambientOcclusion.enabled && fx.ambientOcclusion.intensity > 0.001f) { + const u8 ao = static_cast( + std::clamp(fx.ambientOcclusion.intensity * 35.0f, 0.0f, 80.0f)); + dl->AddRectFilled(p0, p1, IM_COL32(0, 0, 0, ao / 2)); + dl->AddRectFilledMultiColor(p0, p1, IM_COL32(0, 0, 0, ao), IM_COL32(0, 0, 0, ao), + IM_COL32(0, 0, 0, 0), IM_COL32(0, 0, 0, 0)); + } + + if (fx.screenSpaceReflections.enabled && fx.screenSpaceReflections.intensity > 0.001f) { + const u8 ssr = static_cast( + std::clamp(fx.screenSpaceReflections.intensity * 40.0f, 0.0f, 60.0f)); + const f32 h = size.y * 0.35f; + dl->AddRectFilled(ImVec2(p0.x, p1.y - h), p1, IM_COL32(200, 220, 255, ssr)); } - if (fx.vignette > 0.001f) { - const f32 v = std::clamp(fx.vignette, 0.0f, 1.0f); + if (fx.depthOfField.enabled && fx.depthOfField.maxBlur > 0.001f) { + const f32 blur = std::clamp(fx.depthOfField.maxBlur, 0.0f, 2.0f); + const u8 edge = static_cast(blur * 60.0f); + dl->AddRectFilledMultiColor(p0, p1, IM_COL32(0, 0, 0, edge), IM_COL32(0, 0, 0, edge), + IM_COL32(0, 0, 0, 0), IM_COL32(0, 0, 0, 0)); + } + + if (fx.vignette.enabled && fx.vignette.intensity > 0.001f) { + const f32 v = std::clamp(fx.vignette.intensity, 0.0f, 1.0f); const u8 edge = static_cast(v * 180.0f); dl->AddRectFilledMultiColor(p0, p1, IM_COL32(0, 0, 0, edge), IM_COL32(0, 0, 0, edge), IM_COL32(0, 0, 0, 0), IM_COL32(0, 0, 0, 0)); @@ -41,13 +87,13 @@ inline void applyPostProcessOverlay(ImDrawList* dl, ImVec2 origin, ImVec2 size, IM_COL32(0, 0, 0, edge), IM_COL32(0, 0, 0, edge)); } - if (fx.bloom > 0.001f) { - const u8 bloomA = static_cast(std::clamp(fx.bloom, 0.0f, 1.0f) * 50.0f); + if (fx.bloom.enabled && fx.bloom.intensity > 0.001f) { + const u8 bloomA = static_cast(std::clamp(fx.bloom.intensity, 0.0f, 1.0f) * 50.0f); dl->AddRectFilled(p0, p1, IM_COL32(255, 245, 220, bloomA)); } - if (fx.chromaticAberration > 0.001f) { - const f32 ca = std::clamp(fx.chromaticAberration, 0.0f, 1.0f); + if (fx.chromaticAberration.enabled && fx.chromaticAberration.intensity > 0.001f) { + const f32 ca = std::clamp(fx.chromaticAberration.intensity, 0.0f, 1.0f); const f32 offset = ca * 3.0f; dl->AddRect(ImVec2(p0.x - offset, p0.y), ImVec2(p1.x - offset, p1.y), IM_COL32(255, 0, 0, 40), 0.0f, 0, 2.0f); @@ -55,10 +101,30 @@ inline void applyPostProcessOverlay(ImDrawList* dl, ImVec2 origin, ImVec2 size, IM_COL32(0, 0, 255, 40), 0.0f, 0, 2.0f); } - if (fx.filmGrain > 0.001f) { - const u8 grain = static_cast(std::clamp(fx.filmGrain, 0.0f, 1.0f) * 28.0f); + if (fx.lensDistortion.enabled && std::abs(fx.lensDistortion.intensity) > 0.001f) { + const u8 d = static_cast(std::clamp(std::abs(fx.lensDistortion.intensity) * 50.0f, 0.0f, 70.0f)); + dl->AddRectFilledMultiColor(p0, p1, IM_COL32(0, 0, 0, d), IM_COL32(0, 0, 0, d), + IM_COL32(0, 0, 0, d), IM_COL32(0, 0, 0, d)); + } + + if (fx.motionBlur.enabled && fx.motionBlur.intensity > 0.001f) { + const u8 mb = static_cast(std::clamp(fx.motionBlur.intensity, 0.0f, 1.0f) * 35.0f); + const f32 stripe = size.x * 0.15f; + dl->AddRectFilled(ImVec2(p0.x, p0.y + size.y * 0.45f), + ImVec2(p0.x + stripe, p0.y + size.y * 0.55f), + IM_COL32(255, 255, 255, mb)); + } + + if (fx.grain.enabled && fx.grain.intensity > 0.001f) { + const u8 grain = static_cast(std::clamp(fx.grain.intensity, 0.0f, 1.0f) * 28.0f); dl->AddRectFilled(p0, p1, IM_COL32(128, 128, 128, grain)); } + + if (fx.antiAliasing.enabled) { + const u8 aa = + static_cast(std::clamp(fx.antiAliasing.sharpness * 18.0f + 8.0f, 8.0f, 28.0f)); + dl->AddRect(p0, p1, IM_COL32(180, 220, 255, aa), 0.0f, 0, 1.0f); + } } inline const ECS::PostProcessComponent* findPostProcessForCamera(ECS::World& world, diff --git a/src/script/PostProcessBindings.cpp b/src/script/PostProcessBindings.cpp new file mode 100644 index 0000000..d924466 --- /dev/null +++ b/src/script/PostProcessBindings.cpp @@ -0,0 +1,109 @@ +#include "ecs/PostProcessComponents.hpp" +#include "ecs/World.hpp" + +#include + +#include + +namespace Caffeine::Script { +namespace { + +ECS::PostProcessComponent* postProcessForEntity(ECS::World* world, u32 entityId) { + if (!world) return nullptr; + ECS::Entity entity(entityId, world); + if (!entity.isValid()) return nullptr; + return entity.get(); +} + +void registerPostProcessBindings(sol::state& lua, ECS::World** worldPtr) { + lua["caffeine"]["postprocess"] = lua.create_table(); + sol::table pp = lua["caffeine"]["postprocess"]; + + pp["get"] = [&lua, worldPtr](u32 entityId) -> sol::table { + sol::table t = lua.create_table(); + ECS::World* world = worldPtr ? *worldPtr : nullptr; + auto* fx = postProcessForEntity(world, entityId); + if (!fx) return t; + t["enabled"] = fx->enabled; + t["exposure"] = fx->colorGrading.exposure; + t["bloom"] = fx->bloom.intensity; + t["vignette"] = fx->vignette.intensity; + return t; + }; + + pp["setEnabled"] = [worldPtr](u32 entityId, bool enabled) { + if (auto* fx = postProcessForEntity(worldPtr ? *worldPtr : nullptr, entityId)) { + fx->enabled = enabled; + } + }; + + pp["setExposure"] = [worldPtr](u32 entityId, float exposure) { + if (auto* fx = postProcessForEntity(worldPtr ? *worldPtr : nullptr, entityId)) { + fx->colorGrading.exposure = exposure; + fx->colorGrading.enabled = true; + } + }; + + pp["setBloom"] = [worldPtr](u32 entityId, float intensity) { + if (auto* fx = postProcessForEntity(worldPtr ? *worldPtr : nullptr, entityId)) { + fx->bloom.intensity = intensity; + fx->bloom.enabled = intensity > 0.001f; + } + }; + + pp["setVignette"] = [worldPtr](u32 entityId, float intensity) { + if (auto* fx = postProcessForEntity(worldPtr ? *worldPtr : nullptr, entityId)) { + fx->vignette.intensity = intensity; + fx->vignette.enabled = intensity > 0.001f; + } + }; + + pp["enableEffect"] = [worldPtr](u32 entityId, const std::string& effectName, bool enabled) { + auto* fx = postProcessForEntity(worldPtr ? *worldPtr : nullptr, entityId); + if (!fx) return; + if (effectName == "ambientOcclusion" || effectName == "ao") { + fx->ambientOcclusion.enabled = enabled; + } else if (effectName == "antiAliasing" || effectName == "aa") { + fx->antiAliasing.enabled = enabled; + } else if (effectName == "autoExposure") { + fx->autoExposure.enabled = enabled; + } else if (effectName == "bloom") { + fx->bloom.enabled = enabled; + } else if (effectName == "chromaticAberration" || effectName == "chromatic") { + fx->chromaticAberration.enabled = enabled; + } else if (effectName == "colorGrading") { + fx->colorGrading.enabled = enabled; + } else if (effectName == "deferredFog" || effectName == "fog") { + fx->deferredFog.enabled = enabled; + } else if (effectName == "depthOfField" || effectName == "dof") { + fx->depthOfField.enabled = enabled; + } else if (effectName == "grain") { + fx->grain.enabled = enabled; + } else if (effectName == "lensDistortion") { + fx->lensDistortion.enabled = enabled; + } else if (effectName == "motionBlur") { + fx->motionBlur.enabled = enabled; + } else if (effectName == "screenSpaceReflections" || effectName == "ssr") { + fx->screenSpaceReflections.enabled = enabled; + } else if (effectName == "vignette") { + fx->vignette.enabled = enabled; + } + }; + + pp["setCustomScript"] = [worldPtr](u32 entityId, const std::string& path) { + if (auto* fx = postProcessForEntity(worldPtr ? *worldPtr : nullptr, entityId)) { + std::strncpy(fx->customEffectScript, path.c_str(), sizeof(fx->customEffectScript) - 1); + fx->customEffectScript[sizeof(fx->customEffectScript) - 1] = '\0'; + } + }; +} + +} // namespace + +void registerPostProcessScriptBindings(sol::state& lua, ECS::World** worldPtr); + +void registerPostProcessScriptBindings(sol::state& lua, ECS::World** worldPtr) { + registerPostProcessBindings(lua, worldPtr); +} + +} // namespace Caffeine::Script diff --git a/src/script/ScriptEngine.cpp b/src/script/ScriptEngine.cpp index 5cf9f14..968f3ae 100644 --- a/src/script/ScriptEngine.cpp +++ b/src/script/ScriptEngine.cpp @@ -18,6 +18,9 @@ namespace Caffeine::Script { +void registerProceduralScriptBindings(sol::state& lua, ECS::World** worldPtr); +void registerPostProcessScriptBindings(sol::state& lua, ECS::World** worldPtr); + // ============================================================================ // Internal implementation (Pimpl) — all sol2 types are here // ============================================================================ @@ -616,6 +619,8 @@ bool ScriptEngine::init(const InitParams& params) { registerDebugBindings(lua); registerMathBindings(lua); + registerProceduralScriptBindings(lua, &m_impl->m_world); + registerPostProcessScriptBindings(lua, &m_impl->m_world); { sol::table pt = lua["caffeine"]["particles"]; diff --git a/src/terrain/TerrainCache.cpp b/src/terrain/TerrainCache.cpp index 230f0ca..43bfb51 100644 --- a/src/terrain/TerrainCache.cpp +++ b/src/terrain/TerrainCache.cpp @@ -5,7 +5,7 @@ #include "terrain/TerrainResolution.hpp" #include "terrain/TerrainSerializer.hpp" #include "terrain/TerrainGpuTextures.hpp" -#include "terrain/generation/TerrainGenerator.hpp" +#include "terrain/TerrainCollisionMeshBuilder.hpp" #include "assets/MeshCache.hpp" #include "core/WorldUnits.hpp" @@ -52,27 +52,13 @@ void TerrainCache::syncTextureToFilter(ECS::World& world, ECS::Entity entity, } } -void TerrainCache::generateTerrain(ECS::World& world, ECS::Entity entity, - ECS::TerrainComponent& terrain) { - if (terrain.resolutionX < 129 || terrain.resolutionZ < 129) { - terrain.resolutionX = std::max(terrain.resolutionX, 257u); - terrain.resolutionZ = std::max(terrain.resolutionZ, 257u); - } - if (terrain.splatResolutionScale < 2) { - terrain.splatResolutionScale = 2; - } - if (terrain.maxHeight < 4.0f) { - terrain.maxHeight = Caffeine::WorldUnits::kDefaultTerrainHeightM; +void TerrainCache::rebuildCollisionMesh(TerrainEntry& entry, const ECS::TerrainComponent& component) { + if (!component.buildCollisionMesh || entry.heightmap.empty()) { + entry.collisionMesh.reset(); + return; } - if (terrain.worldSizeX < 16.0f) terrain.worldSizeX = Caffeine::WorldUnits::kDefaultTerrainSizeM; - if (terrain.worldSizeZ < 16.0f) terrain.worldSizeZ = Caffeine::WorldUnits::kDefaultTerrainSizeM; - - TerrainEntry& entry = ensureEntry(entity); - TerrainGenerator::generate(entry.heightmap, entry.splatmap, terrain, terrain.generation); - terrain.dataRevision++; - terrain.splatRevision++; - rebuildMesh(entity, entry, terrain); - syncTextureToFilter(world, entity, terrain); + entry.collisionMesh = std::make_unique( + TerrainCollisionMeshBuilder::build(entry.heightmap, component)); } void TerrainCache::initializeEntity(ECS::World& world, ECS::Entity entity) { @@ -139,6 +125,7 @@ void TerrainCache::rebuildMesh(ECS::Entity entity, TerrainEntry& entry, component.meshRevision = component.dataRevision; entry.builtRevision = component.dataRevision; + rebuildCollisionMesh(entry, component); (void)entity; } @@ -250,6 +237,18 @@ const TerrainSplatmap* TerrainCache::splatmapFor(ECS::Entity entity) const { return it != m_entries.end() ? &it->second.splatmap : nullptr; } +Assets::Mesh3D* TerrainCache::collisionMeshFor(ECS::Entity entity) { + auto it = m_entries.find(entity.id()); + if (it == m_entries.end() || !it->second.collisionMesh) return nullptr; + return it->second.collisionMesh.get(); +} + +const Assets::Mesh3D* TerrainCache::collisionMeshFor(ECS::Entity entity) const { + auto it = m_entries.find(entity.id()); + if (it == m_entries.end() || !it->second.collisionMesh) return nullptr; + return it->second.collisionMesh.get(); +} + Assets::Mesh3D* TerrainCache::meshFor(ECS::Entity entity) { auto it = m_entries.find(entity.id()); if (it == m_entries.end()) return nullptr; diff --git a/src/terrain/TerrainCache.hpp b/src/terrain/TerrainCache.hpp index 0c87728..958a230 100644 --- a/src/terrain/TerrainCache.hpp +++ b/src/terrain/TerrainCache.hpp @@ -51,7 +51,9 @@ class TerrainCache { void syncTextureToFilter(ECS::World& world, ECS::Entity entity, const ECS::TerrainComponent& terrain); void repairTexturePaths(ECS::TerrainComponent& terrain); - void generateTerrain(ECS::World& world, ECS::Entity entity, ECS::TerrainComponent& terrain); + + Assets::Mesh3D* collisionMeshFor(ECS::Entity entity); + const Assets::Mesh3D* collisionMeshFor(ECS::Entity entity) const; #ifdef CF_HAS_SDL3 void syncGpuTextures(RHI::RenderDevice* device, ECS::Entity entity, @@ -75,6 +77,7 @@ class TerrainCache { TerrainHeightmap heightmap; TerrainSplatmap splatmap; std::unique_ptr mesh; + std::unique_ptr collisionMesh; std::vector chunks; std::vector chunkActiveLods; bool useChunks = false; @@ -83,6 +86,7 @@ class TerrainCache { TerrainEntry& ensureEntry(ECS::Entity entity); void rebuildMesh(ECS::Entity entity, TerrainEntry& entry, ECS::TerrainComponent& component); + void rebuildCollisionMesh(TerrainEntry& entry, const ECS::TerrainComponent& component); #ifdef CF_HAS_SDL3 void releaseMeshGpu(Assets::Mesh3D& mesh); #endif diff --git a/src/terrain/TerrainCollisionMeshBuilder.cpp b/src/terrain/TerrainCollisionMeshBuilder.cpp new file mode 100644 index 0000000..262847c --- /dev/null +++ b/src/terrain/TerrainCollisionMeshBuilder.cpp @@ -0,0 +1,29 @@ +#include "terrain/TerrainCollisionMeshBuilder.hpp" + +#include "terrain/TerrainMeshBuilder.hpp" + +#include + +namespace Caffeine::Terrain { + +Assets::Mesh3D TerrainCollisionMeshBuilder::build(const TerrainHeightmap& heightmap, + const ECS::TerrainComponent& settings) { + if (heightmap.empty()) return {}; + + const u32 step = std::max(1u, settings.collisionSampleStep); + const u32 lastX = heightmap.resolutionX() - 1; + const u32 lastZ = heightmap.resolutionZ() - 1; + + Assets::Mesh3D mesh = + TerrainMeshBuilder::buildRegion(heightmap, settings, 0, 0, lastX, lastZ, step); + + // Collision meshes only need positions; strip extras to save memory. + for (auto& vertex : mesh.vertices) { + vertex.normal = Vec3(0.0f, 1.0f, 0.0f); + vertex.texcoord = Vec2(0.0f, 0.0f); + vertex.tangent = Vec4(1.0f, 0.0f, 0.0f, 1.0f); + } + return mesh; +} + +} // namespace Caffeine::Terrain diff --git a/src/terrain/TerrainCollisionMeshBuilder.hpp b/src/terrain/TerrainCollisionMeshBuilder.hpp new file mode 100644 index 0000000..f0f03a1 --- /dev/null +++ b/src/terrain/TerrainCollisionMeshBuilder.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include "assets/MeshTypes.hpp" +#include "ecs/TerrainComponents.hpp" +#include "terrain/TerrainHeightmap.hpp" + +namespace Caffeine::Terrain { + +/// Coarse triangle mesh for physics/collision queries (decimated heightfield). +class TerrainCollisionMeshBuilder { +public: + static Assets::Mesh3D build(const TerrainHeightmap& heightmap, + const ECS::TerrainComponent& settings); +}; + +} // namespace Caffeine::Terrain diff --git a/src/terrain/TerrainHeightmapImporter.cpp b/src/terrain/TerrainHeightmapImporter.cpp new file mode 100644 index 0000000..a99c671 --- /dev/null +++ b/src/terrain/TerrainHeightmapImporter.cpp @@ -0,0 +1,134 @@ +#include "terrain/TerrainHeightmapImporter.hpp" + +#include "ecs/TerrainComponents.hpp" +#include "terrain/TerrainCache.hpp" + +#include +#include +#include + +namespace Caffeine::Terrain { +namespace { + +bool parseHeightLine(const std::string& line, std::vector& row) { + std::istringstream stream(line); + f32 value = 0.0f; + while (stream >> value) { + row.push_back(value); + } + return !row.empty(); +} + +} // namespace + +HeightmapImportResult loadHeightmapTextFile(const std::filesystem::path& path, + std::vector& absoluteHeights, + u32& outGridX, u32& outGridZ) { + HeightmapImportResult result; + std::ifstream file(path); + if (!file.is_open()) { + result.error = "Failed to open heightmap file: " + path.string(); + return result; + } + + std::vector> rows; + std::string line; + while (std::getline(file, line)) { + if (line.empty()) continue; + std::vector row; + if (!parseHeightLine(line, row)) continue; + rows.push_back(std::move(row)); + } + + if (rows.empty()) { + result.error = "Heightmap file is empty: " + path.string(); + return result; + } + + const u32 gridZ = static_cast(rows.size()); + const u32 gridX = static_cast(rows[0].size()); + if (gridX < 2 || gridZ < 2) { + result.error = "Heightmap must be at least 2x2"; + return result; + } + + for (const auto& row : rows) { + if (row.size() != gridX) { + result.error = "Inconsistent row width in heightmap"; + return result; + } + } + + absoluteHeights.resize(static_cast(gridX) * static_cast(gridZ)); + f32 minH = 1e9f; + f32 maxH = -1e9f; + for (u32 z = 0; z < gridZ; ++z) { + for (u32 x = 0; x < gridX; ++x) { + const f32 h = rows[z][x]; + absoluteHeights[static_cast(z) * gridX + x] = h; + minH = std::min(minH, h); + maxH = std::max(maxH, h); + } + } + + outGridX = gridX; + outGridZ = gridZ; + result.ok = true; + result.gridSize = std::max(gridX, gridZ); + result.minHeight = minH; + result.maxHeight = maxH; + return result; +} + +bool applyAbsoluteHeights(ECS::World& world, ECS::Entity entity, + const std::vector& absoluteHeights, u32 gridX, u32 gridZ, + f32 worldSizeX, f32 worldSizeZ, f32 minHeight, f32 maxHeight) { + auto* terrain = world.get(entity); + if (!terrain || absoluteHeights.empty() || gridX < 2 || gridZ < 2) return false; + + const f32 range = std::max(maxHeight - minHeight, 0.001f); + terrain->resolutionX = gridX; + terrain->resolutionZ = gridZ; + terrain->worldSizeX = worldSizeX; + terrain->worldSizeZ = worldSizeZ; + terrain->maxHeight = range; + + TerrainCache& cache = TerrainCache::instance(); + TerrainHeightmap* hm = cache.heightmapFor(entity); + if (!hm) { + cache.initializeEntity(world, entity); + hm = cache.heightmapFor(entity); + } + if (!hm) return false; + + hm->resize(gridX, gridZ, false); + for (u32 z = 0; z < gridZ; ++z) { + for (u32 x = 0; x < gridX; ++x) { + const f32 abs = absoluteHeights[static_cast(z) * gridX + x]; + hm->setNormalized(x, z, (abs - minHeight) / range); + } + } + + terrain->dataRevision++; + cache.syncEntity(world, entity); + return true; +} + +bool importHeightmapFile(ECS::World& world, ECS::Entity entity, + const std::filesystem::path& path, + f32 worldSizeX, f32 worldSizeZ) { + std::vector heights; + u32 gridX = 0; + u32 gridZ = 0; + const HeightmapImportResult loaded = loadHeightmapTextFile(path, heights, gridX, gridZ); + if (!loaded.ok) return false; + + const f32 span = std::max(worldSizeX, worldSizeZ); + if (worldSizeX <= 0.0f) worldSizeX = span > 0.0f ? span : static_cast(loaded.gridSize); + if (worldSizeZ <= 0.0f) worldSizeZ = worldSizeX; + + return applyAbsoluteHeights(world, entity, heights, gridX, gridZ, worldSizeX, worldSizeZ, + loaded.minHeight, loaded.maxHeight); +} + +} // namespace Caffeine::Terrain diff --git a/src/terrain/TerrainHeightmapImporter.hpp b/src/terrain/TerrainHeightmapImporter.hpp new file mode 100644 index 0000000..5e08aec --- /dev/null +++ b/src/terrain/TerrainHeightmapImporter.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "core/Types.hpp" +#include "ecs/Entity.hpp" +#include "ecs/World.hpp" +#include "terrain/TerrainHeightmap.hpp" + +#include +#include + +namespace Caffeine::Terrain { + +struct HeightmapImportResult { + bool ok = false; + std::string error; + u32 gridSize = 0; + f32 worldSize = 0.0f; + f32 minHeight = 0.0f; + f32 maxHeight = 0.0f; +}; + +/// Loads a whitespace-separated height grid (rows = Z, columns = X) in world meters. +HeightmapImportResult loadHeightmapTextFile(const std::filesystem::path& path, + std::vector& absoluteHeights, + u32& outGridX, u32& outGridZ); + +/// Writes absolute heights into a terrain entity heightmap (normalized 0–1) and rebuilds meshes. +bool applyAbsoluteHeights(ECS::World& world, ECS::Entity entity, + const std::vector& absoluteHeights, u32 gridX, u32 gridZ, + f32 worldSizeX, f32 worldSizeZ, f32 minHeight, f32 maxHeight); + +bool importHeightmapFile(ECS::World& world, ECS::Entity entity, + const std::filesystem::path& path, + f32 worldSizeX, f32 worldSizeZ); + +} // namespace Caffeine::Terrain diff --git a/src/terrain/TerrainMeshBuilder.cpp b/src/terrain/TerrainMeshBuilder.cpp index ae923da..fd33ef8 100644 --- a/src/terrain/TerrainMeshBuilder.cpp +++ b/src/terrain/TerrainMeshBuilder.cpp @@ -6,6 +6,66 @@ namespace Caffeine::Terrain { namespace { +void computeMeshTangents(Assets::Mesh3D& mesh) { + if (mesh.vertices.empty() || mesh.indices.size() < 3) return; + + std::vector tan1(mesh.vertices.size(), Vec3::zero()); + std::vector tan2(mesh.vertices.size(), Vec3::zero()); + + for (size_t i = 0; i + 2 < mesh.indices.size(); i += 3) { + const u32 i0 = mesh.indices[i]; + const u32 i1 = mesh.indices[i + 1]; + const u32 i2 = mesh.indices[i + 2]; + if (i0 >= mesh.vertices.size() || i1 >= mesh.vertices.size() || i2 >= mesh.vertices.size()) { + continue; + } + + const Vec3& p0 = mesh.vertices[i0].position; + const Vec3& p1 = mesh.vertices[i1].position; + const Vec3& p2 = mesh.vertices[i2].position; + const Vec2& uv0 = mesh.vertices[i0].texcoord; + const Vec2& uv1 = mesh.vertices[i1].texcoord; + const Vec2& uv2 = mesh.vertices[i2].texcoord; + + const Vec3 edge1 = p1 - p0; + const Vec3 edge2 = p2 - p0; + const f32 du1 = uv1.x - uv0.x; + const f32 dv1 = uv1.y - uv0.y; + const f32 du2 = uv2.x - uv0.x; + const f32 dv2 = uv2.y - uv0.y; + const f32 denom = du1 * dv2 - du2 * dv1; + if (std::fabs(denom) < 1e-8f) continue; + + const f32 r = 1.0f / denom; + const Vec3 sdir((dv2 * edge1.x - dv1 * edge2.x) * r, + (dv2 * edge1.y - dv1 * edge2.y) * r, + (dv2 * edge1.z - dv1 * edge2.z) * r); + const Vec3 tdir((du1 * edge2.x - du2 * edge1.x) * r, + (du1 * edge2.y - du2 * edge1.y) * r, + (du1 * edge2.z - du2 * edge1.z) * r); + + tan1[i0] = tan1[i0] + sdir; + tan1[i1] = tan1[i1] + sdir; + tan1[i2] = tan1[i2] + sdir; + tan2[i0] = tan2[i0] + tdir; + tan2[i1] = tan2[i1] + tdir; + tan2[i2] = tan2[i2] + tdir; + } + + for (size_t i = 0; i < mesh.vertices.size(); ++i) { + const Vec3& n = mesh.vertices[i].normal; + Vec3 t = tan1[i]; + if (t.lengthSquared() < 1e-12f) { + mesh.vertices[i].tangent = Vec4(1.0f, 0.0f, 0.0f, 1.0f); + continue; + } + t = t - n * n.dot(t); + t = t.normalized(); + const f32 w = (n.cross(t).dot(tan2[i]) < 0.0f) ? -1.0f : 1.0f; + mesh.vertices[i].tangent = Vec4(t.x, t.y, t.z, w); + } +} + Vec2 tiledUv(f32 worldX, f32 worldZ, const ECS::TerrainComponent& settings) { const f32 halfX = settings.worldSizeX * 0.5f; const f32 halfZ = settings.worldSizeZ * 0.5f; @@ -63,7 +123,6 @@ Assets::Mesh3D TerrainMeshBuilder::buildRegion(const TerrainHeightmap& heightmap vertex.normal = heightmap.sampleNormalBilinear(u, v, settings.worldSizeX, settings.worldSizeZ, settings.maxHeight); vertex.texcoord = tiledUv(px, pz, settings); - vertex.tangent = Vec4(1.0f, 0.0f, 0.0f, 1.0f); mesh.vertices.push_back(vertex); minY = std::min(minY, height); @@ -96,6 +155,7 @@ Assets::Mesh3D TerrainMeshBuilder::buildRegion(const TerrainHeightmap& heightmap mesh.bounds.min = Vec3(localMinX, minY, localMinZ); mesh.bounds.max = Vec3(localMaxX, maxY, localMaxZ); mesh.flipTextureV = false; + computeMeshTangents(mesh); return mesh; } diff --git a/src/terrain/generation/GeologicalSimulator.cpp b/src/terrain/generation/GeologicalSimulator.cpp deleted file mode 100644 index 4448862..0000000 --- a/src/terrain/generation/GeologicalSimulator.cpp +++ /dev/null @@ -1,558 +0,0 @@ -#include "terrain/generation/GeologicalSimulator.hpp" - -#include "terrain/generation/TerrainNoise.hpp" - -#include -#include -#include -#include - -namespace Caffeine::Terrain { -namespace { - -constexpr f32 kPi = 3.14159265f; - -f32 smoothstep(f32 edge0, f32 edge1, f32 x) { - const f32 t = std::clamp((x - edge0) / std::max(edge1 - edge0, 0.0001f), 0.0f, 1.0f); - return t * t * (3.0f - 2.0f * t); -} - -u32 cellIndex(u32 x, u32 z, u32 resX) { return z * resX + x; } - -void addHeightBilinear(TerrainHeightmap& heightmap, f32 u, f32 v, f32 delta) { - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - if (resX < 2 || resZ < 2) return; - - u = std::clamp(u, 0.0f, 1.0f); - v = std::clamp(v, 0.0f, 1.0f); - const f32 fx = u * static_cast(resX - 1); - const f32 fz = v * static_cast(resZ - 1); - const u32 x0 = static_cast(std::floor(fx)); - const u32 z0 = static_cast(std::floor(fz)); - const u32 x1 = std::min(x0 + 1, resX - 1); - const u32 z1 = std::min(z0 + 1, resZ - 1); - const f32 tx = fx - static_cast(x0); - const f32 tz = fz - static_cast(z0); - - const f32 w00 = (1.0f - tx) * (1.0f - tz); - const f32 w10 = tx * (1.0f - tz); - const f32 w01 = (1.0f - tx) * tz; - const f32 w11 = tx * tz; - - heightmap.setNormalized(x0, z0, heightmap.sampleNormalized(x0, z0) + delta * w00); - heightmap.setNormalized(x1, z0, heightmap.sampleNormalized(x1, z0) + delta * w10); - heightmap.setNormalized(x0, z1, heightmap.sampleNormalized(x0, z1) + delta * w01); - heightmap.setNormalized(x1, z1, heightmap.sampleNormalized(x1, z1) + delta * w11); -} - -f32 sampleHeightBilinear(const TerrainHeightmap& heightmap, f32 u, f32 v) { - return heightmap.sampleBilinear(std::clamp(u, 0.0f, 1.0f), std::clamp(v, 0.0f, 1.0f)); -} - -void computeGradient(const TerrainHeightmap& heightmap, f32 u, f32 v, f32& gradU, f32& gradV) { - const f32 step = 1.0f / static_cast(std::max(heightmap.resolutionX(), heightmap.resolutionZ())); - gradU = (sampleHeightBilinear(heightmap, u + step, v) - sampleHeightBilinear(heightmap, u - step, v)) * - 0.5f; - gradV = (sampleHeightBilinear(heightmap, u, v + step) - sampleHeightBilinear(heightmap, u, v - step)) * - 0.5f; -} - -void applyThermalStep(TerrainHeightmap& heightmap, f32 talus, f32 intensity) { - if (intensity <= 0.0f) return; - - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - std::vector scratch(heightmap.heights().size()); - - for (u32 z = 1; z + 1 < resZ; ++z) { - for (u32 x = 1; x + 1 < resX; ++x) { - const f32 center = heightmap.sampleNormalized(x, z); - f32 totalDiff = 0.0f; - f32 neighborCount = 0.0f; - - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dz == 0) continue; - const u32 nx = static_cast(static_cast(x) + dx); - const u32 nz = static_cast(static_cast(z) + dz); - const f32 neighbor = heightmap.sampleNormalized(nx, nz); - const f32 diff = center - neighbor; - if (diff > talus) { - totalDiff += diff - talus; - ++neighborCount; - } - } - } - - f32 moved = 0.0f; - if (neighborCount > 0.0f) { - moved = totalDiff * 0.25f * intensity / neighborCount; - } - scratch[cellIndex(x, z, resX)] = center - moved; - } - } - - for (u32 z = 1; z + 1 < resZ; ++z) { - for (u32 x = 1; x + 1 < resX; ++x) { - heightmap.setNormalized(x, z, scratch[cellIndex(x, z, resX)]); - } - } -} - -void simulateHydraulicDroplets(TerrainHeightmap& heightmap, const ECS::TerrainGenerationSettings& settings, - const ECS::TerrainSimulationSettings& sim, u32 dropletCount, f32 waterScale) { - if (dropletCount == 0 || waterScale <= 0.0f) return; - - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const f32 erode = std::clamp(settings.hydraulicErode, 0.0f, 1.0f); - const f32 deposit = std::clamp(settings.hydraulicDeposit, 0.0f, 1.0f); - const f32 rain = std::max(settings.hydraulicRain, 0.0001f); - const f32 inertia = std::clamp(settings.hydraulicInertia, 0.0f, 1.0f); - const f32 evaporation = std::clamp(settings.hydraulicEvaporation, 0.001f, 0.5f); - const u32 maxSteps = std::max(sim.maxDropletSteps, settings.hydraulicMaxSteps); - - std::mt19937 rng(settings.seed + dropletCount * 17u + static_cast(waterScale * 1000.0f)); - std::uniform_real_distribution distU(0.02f, 0.98f); - std::uniform_real_distribution distV(0.02f, 0.98f); - - for (u32 droplet = 0; droplet < dropletCount; ++droplet) { - f32 u = distU(rng); - f32 v = distV(rng); - f32 water = rain * (0.5f + sim.humidity); - f32 sediment = 0.0f; - f32 speed = 1.0f; - f32 dirU = 0.0f; - f32 dirV = 0.0f; - - for (u32 step = 0; step < maxSteps; ++step) { - const f32 height = sampleHeightBilinear(heightmap, u, v); - f32 gradU = 0.0f; - f32 gradV = 0.0f; - computeGradient(heightmap, u, v, gradU, gradV); - - f32 downhillU = -gradU; - f32 downhillV = -gradV; - const f32 downhillLen = std::sqrt(downhillU * downhillU + downhillV * downhillV); - if (downhillLen > 0.0001f) { - downhillU /= downhillLen; - downhillV /= downhillLen; - } - - dirU = dirU * inertia + downhillU * (1.0f - inertia); - dirV = dirV * inertia + downhillV * (1.0f - inertia); - const f32 dirLen = std::sqrt(dirU * dirU + dirV * dirV); - if (dirLen > 0.0001f) { - dirU /= dirLen; - dirV /= dirLen; - } - - const f32 stepSize = 1.0f / static_cast(std::max(resX, resZ)); - const f32 nextU = u + dirU * stepSize * 2.5f; - const f32 nextV = v + dirV * stepSize * 2.5f; - if (nextU < 0.0f || nextU > 1.0f || nextV < 0.0f || nextV > 1.0f) { - addHeightBilinear(heightmap, u, v, sediment * deposit); - break; - } - - const f32 nextHeight = sampleHeightBilinear(heightmap, nextU, nextV); - const f32 slope = std::max(height - nextHeight, 0.0f); - if (slope < 0.00001f && sediment > 0.0f) { - addHeightBilinear(heightmap, u, v, sediment * deposit); - break; - } - - const f32 capacity = std::max(slope, 0.0001f) * speed * water * (0.3f + speed * 0.5f); - if (capacity > sediment) { - const f32 erodeAmount = - std::min((capacity - sediment) * erode * 0.5f, slope * 0.25f) * waterScale; - addHeightBilinear(heightmap, u, v, -erodeAmount); - sediment += erodeAmount; - } else { - const f32 depositAmount = (sediment - capacity) * deposit * 0.5f * waterScale; - addHeightBilinear(heightmap, u, v, depositAmount); - sediment -= depositAmount; - } - - u = nextU; - v = nextV; - water *= (1.0f - evaporation); - speed = std::sqrt(speed * speed + slope * 4.0f); - if (water < 0.001f) break; - } - } -} - -void applyTectonicPulse(TerrainHeightmap& heightmap, u32 seed, f32 activity, u32 iteration) { - if (activity <= 0.0f) return; - - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const f32 pulse = activity * 0.00002f * std::sin(static_cast(iteration) * 0.07f); - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const f32 nx = static_cast(x) / static_cast(resX); - const f32 nz = static_cast(z) / static_cast(resZ); - const f32 plate = - sampleNoise2D(TerrainNoiseAlgorithm::Perlin, nx * 3.0f, nz * 3.0f, seed + 701u); - const f32 edge = 1.0f - smoothstep(0.2f, 0.45f, std::abs(plate - 0.5f) * 2.0f); - const f32 uplift = pulse * edge; - heightmap.setNormalized(x, z, - std::clamp(heightmap.sampleNormalized(x, z) + uplift, 0.0f, 1.0f)); - } - } -} - -void applyWindErosion(TerrainHeightmap& heightmap, f32 windDirectionDeg, f32 intensity, - f32 humidity) { - if (intensity <= 0.0f) return; - - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const f32 windRad = windDirectionDeg * kPi / 180.0f; - const f32 windX = std::cos(windRad); - const f32 windZ = std::sin(windRad); - const f32 dryFactor = 1.0f - std::clamp(humidity, 0.0f, 1.0f); - - for (u32 z = 1; z + 1 < resZ; ++z) { - for (u32 x = 1; x + 1 < resX; ++x) { - const f32 height = heightmap.sampleNormalized(x, z); - if (height > 0.55f) continue; - - const f32 hL = heightmap.sampleNormalized(x - 1, z); - const f32 hR = heightmap.sampleNormalized(x + 1, z); - const f32 hD = heightmap.sampleNormalized(x, z - 1); - const f32 hU = heightmap.sampleNormalized(x, z + 1); - const f32 slopeX = (hR - hL) * 0.5f; - const f32 slopeZ = (hU - hD) * 0.5f; - const f32 exposure = std::max(slopeX * windX + slopeZ * windZ, 0.0f); - - if (exposure > 0.02f) { - const f32 removed = exposure * intensity * dryFactor * 0.0004f; - heightmap.setNormalized(x, z, std::clamp(height - removed, 0.0f, 1.0f)); - - const i32 lx = static_cast(x) - static_cast(windX * 2.0f); - const i32 lz = static_cast(z) - static_cast(windZ * 2.0f); - if (lx >= 0 && lz >= 0 && lx < static_cast(resX) && lz < static_cast(resZ)) { - const u32 depositX = static_cast(lx); - const u32 depositZ = static_cast(lz); - heightmap.setNormalized( - depositX, depositZ, - std::clamp(heightmap.sampleNormalized(depositX, depositZ) + removed * 0.6f, 0.0f, - 1.0f)); - } - } - } - } -} - -void applyGlacialErosion(TerrainHeightmap& heightmap, f32 temperature, f32 intensity) { - if (intensity <= 0.0f || temperature > 0.45f) return; - - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const i32 radius = 4; - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const f32 height = heightmap.sampleNormalized(x, z); - if (height < 0.65f) continue; - - for (i32 dz = -radius; dz <= radius; ++dz) { - for (i32 dx = -radius; dx <= radius; ++dx) { - const i32 nx = static_cast(x) + dx; - const i32 nz = static_cast(z) + dz; - if (nx < 0 || nz < 0 || nx >= static_cast(resX) || nz >= static_cast(resZ)) { - continue; - } - const f32 dist = std::sqrt(static_cast(dx * dx + dz * dz)); - const f32 falloff = 1.0f - std::exp(-dist / static_cast(radius)); - const f32 carve = falloff * intensity * 0.00006f * (height - 0.55f); - heightmap.setNormalized( - static_cast(nx), static_cast(nz), - std::clamp(heightmap.sampleNormalized(static_cast(nx), static_cast(nz)) - - carve, - 0.0f, 1.0f)); - } - } - } - } -} - -void carveRiverNetwork(TerrainHeightmap& heightmap, const std::vector& precipitation, - f32 intensity) { - if (intensity <= 0.0f) return; - - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const u32 cellCount = resX * resZ; - if (precipitation.size() != cellCount) return; - - std::vector flowDirection(cellCount, UINT32_MAX); - std::vector flowAccum(cellCount, 1.0f); - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const u32 idx = cellIndex(x, z, resX); - const f32 height = heightmap.sampleNormalized(x, z); - f32 lowest = height; - u32 best = UINT32_MAX; - - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dz == 0) continue; - const i32 nx = static_cast(x) + dx; - const i32 nz = static_cast(z) + dz; - if (nx < 0 || nz < 0 || nx >= static_cast(resX) || - nz >= static_cast(resZ)) { - continue; - } - const f32 neighbor = - heightmap.sampleNormalized(static_cast(nx), static_cast(nz)); - if (neighbor < lowest) { - lowest = neighbor; - best = cellIndex(static_cast(nx), static_cast(nz), resX); - } - } - } - flowDirection[idx] = best; - } - } - - std::vector order(cellCount); - for (u32 i = 0; i < cellCount; ++i) order[i] = i; - std::sort(order.begin(), order.end(), [&](u32 a, u32 b) { - return heightmap.heights()[a] > heightmap.heights()[b]; - }); - - for (u32 idx : order) { - const u32 downstream = flowDirection[idx]; - if (downstream == UINT32_MAX) continue; - flowAccum[downstream] += flowAccum[idx] * precipitation[idx]; - } - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const u32 idx = cellIndex(x, z, resX); - if (flowAccum[idx] < 4.0f) continue; - const f32 carve = std::log(flowAccum[idx]) * intensity * 0.00008f; - heightmap.setNormalized(x, z, - std::clamp(heightmap.sampleNormalized(x, z) - carve, 0.0f, 1.0f)); - } - } -} - -std::vector buildPrecipitationMap(const TerrainHeightmap& heightmap, - const ECS::TerrainSimulationSettings& sim, u32 seed) { - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - std::vector precipitation(resX * resZ, sim.rainfallBase); - - const f32 windRad = sim.windDirection * kPi / 180.0f; - const f32 windX = std::cos(windRad); - const f32 windZ = std::sin(windRad); - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const f32 u = static_cast(x) / static_cast(resX - 1); - const f32 v = static_cast(z) / static_cast(resZ - 1); - const f32 height = heightmap.sampleNormalized(x, z); - - const f32 hL = heightmap.sampleNormalized(std::max(x, 1u) - 1, z); - const f32 hR = heightmap.sampleNormalized(std::min(x + 1, resX - 1), z); - const f32 hD = heightmap.sampleNormalized(x, std::max(z, 1u) - 1); - const f32 hU = heightmap.sampleNormalized(x, std::min(z + 1, resZ - 1)); - const f32 slopeX = (hR - hL) * 0.5f; - const f32 slopeZ = (hU - hD) * 0.5f; - const f32 windward = std::max(slopeX * windX + slopeZ * windZ, 0.0f); - - const f32 altitude = smoothstep(0.0f, 0.7f, height); - const f32 moistureNoise = - sampleNoise2D(TerrainNoiseAlgorithm::Perlin, u * 6.0f, v * 6.0f, seed + 313u); - - f32 rain = sim.rainfallBase * sim.humidity; - rain *= (0.55f + altitude * 0.45f); - rain *= (0.5f + windward * 2.5f); - rain *= (0.7f + moistureNoise * 0.6f); - precipitation[cellIndex(x, z, resX)] = std::clamp(rain, 0.0f, 1.0f); - } - } - - return precipitation; -} - -f32 measureAverageDelta(const TerrainHeightmap& before, const TerrainHeightmap& after) { - const auto& a = before.heights(); - const auto& b = after.heights(); - if (a.size() != b.size() || a.empty()) return 0.0f; - - f32 sum = 0.0f; - for (size_t i = 0; i < a.size(); ++i) { - sum += std::abs(b[i] - a[i]); - } - return sum / static_cast(a.size()); -} - -} // namespace - -void applyEnvironmentPreset(ECS::TerrainGenerationSettings& settings, - ECS::TerrainEnvironment environment) { - settings.simulation.environment = environment; - settings.simulation.enabled = true; - settings.useRidgedNoise = true; - settings.domainWarp = true; - settings.domainWarpPasses = 2; - - switch (environment) { - case ECS::TerrainEnvironment::TropicalWet: - settings.simulation.totalIterations = 300; - settings.simulation.tectonicActivity = 0.25f; - settings.simulation.erosionWater = 0.75f; - settings.simulation.erosionThermal = 0.35f; - settings.simulation.erosionGlacial = 0.0f; - settings.simulation.erosionWind = 0.08f; - settings.simulation.erosionBiological = 0.5f; - settings.simulation.temperature = 0.88f; - settings.simulation.humidity = 0.92f; - settings.simulation.windDirection = 45.0f; - settings.simulation.rainfallBase = 0.06f; - settings.simulation.dropletsPerIteration = 14; - settings.octaves = 5; - settings.persistence = 0.45f; - settings.amplitude = 0.28f; - settings.domainWarpStrength = 0.18f; - settings.ridgedBlend = 0.35f; - break; - case ECS::TerrainEnvironment::AridDesert: - settings.simulation.totalIterations = 220; - settings.simulation.tectonicActivity = 0.15f; - settings.simulation.erosionWater = 0.12f; - settings.simulation.erosionThermal = 0.4f; - settings.simulation.erosionGlacial = 0.0f; - settings.simulation.erosionWind = 0.55f; - settings.simulation.erosionBiological = 0.0f; - settings.simulation.temperature = 0.75f; - settings.simulation.humidity = 0.08f; - settings.simulation.windDirection = 315.0f; - settings.simulation.rainfallBase = 0.01f; - settings.simulation.dropletsPerIteration = 6; - settings.octaves = 5; - settings.persistence = 0.45f; - settings.amplitude = 0.26f; - settings.domainWarpStrength = 0.15f; - settings.ridgedBlend = 0.3f; - break; - case ECS::TerrainEnvironment::AlpineGlacial: - settings.simulation.totalIterations = 350; - settings.simulation.tectonicActivity = 0.35f; - settings.simulation.erosionWater = 0.55f; - settings.simulation.erosionThermal = 0.4f; - settings.simulation.erosionGlacial = 0.35f; - settings.simulation.erosionWind = 0.12f; - settings.simulation.erosionBiological = 0.08f; - settings.simulation.temperature = 0.25f; - settings.simulation.humidity = 0.55f; - settings.simulation.windDirection = 270.0f; - settings.simulation.rainfallBase = 0.03f; - settings.simulation.dropletsPerIteration = 12; - settings.octaves = 5; - settings.persistence = 0.42f; - settings.amplitude = 0.3f; - settings.domainWarpStrength = 0.2f; - settings.ridgedBlend = 0.5f; - break; - case ECS::TerrainEnvironment::Coastal: - settings.simulation.totalIterations = 260; - settings.simulation.tectonicActivity = 0.4f; - settings.simulation.erosionWater = 0.8f; - settings.simulation.erosionThermal = 0.35f; - settings.simulation.erosionGlacial = 0.0f; - settings.simulation.erosionWind = 0.35f; - settings.simulation.erosionBiological = 0.45f; - settings.simulation.temperature = 0.5f; - settings.simulation.humidity = 0.75f; - settings.simulation.windDirection = 90.0f; - settings.simulation.rainfallBase = 0.05f; - settings.simulation.dropletsPerIteration = 20; - settings.octaves = 7; - settings.persistence = 0.42f; - settings.amplitude = 0.33f; - settings.domainWarpStrength = 0.22f; - break; - case ECS::TerrainEnvironment::Volcanic: - settings.simulation.totalIterations = 280; - settings.simulation.tectonicActivity = 0.45f; - settings.simulation.erosionWater = 0.85f; - settings.simulation.erosionThermal = 0.7f; - settings.simulation.erosionGlacial = 0.0f; - settings.simulation.erosionWind = 0.15f; - settings.simulation.erosionBiological = 0.5f; - settings.simulation.temperature = 0.85f; - settings.simulation.humidity = 0.7f; - settings.simulation.windDirection = 45.0f; - settings.simulation.rainfallBase = 0.06f; - settings.simulation.dropletsPerIteration = 26; - settings.useRidgedNoise = false; - settings.octaves = 7; - settings.persistence = 0.5f; - settings.amplitude = 0.48f; - settings.domainWarpStrength = 0.35f; - break; - case ECS::TerrainEnvironment::Custom: - default: - break; - } -} - -GeologicalSimulationResult GeologicalSimulator::run(TerrainHeightmap& heightmap, - const ECS::TerrainComponent& /*terrain*/, - ECS::TerrainGenerationSettings& settings) { - GeologicalSimulationResult result; - const auto& sim = settings.simulation; - if (!sim.enabled || sim.totalIterations == 0) return result; - - const std::vector precipitation = buildPrecipitationMap(heightmap, sim, settings.seed); - TerrainHeightmap snapshot = heightmap; - - for (u32 iteration = 0; iteration < sim.totalIterations; ++iteration) { - if (sim.tectonicActivity > 0.0f && iteration % 60 == 0) { - applyTectonicPulse(heightmap, settings.seed, sim.tectonicActivity, iteration); - } - - if (settings.hydraulicErosion && sim.erosionWater > 0.0f) { - simulateHydraulicDroplets(heightmap, settings, sim, sim.dropletsPerIteration, - sim.erosionWater * 0.65f); - } - - if (settings.thermalErosion && sim.erosionThermal > 0.0f && iteration % 2 == 0) { - applyThermalStep(heightmap, settings.thermalTalus, sim.erosionThermal * 0.04f); - } - - if (sim.erosionWind > 0.0f && iteration % 4 == 0) { - applyWindErosion(heightmap, sim.windDirection, sim.erosionWind * 0.35f, sim.humidity); - } - - if (sim.erosionGlacial > 0.0f && iteration % 8 == 0) { - applyGlacialErosion(heightmap, sim.temperature, sim.erosionGlacial * 0.012f); - } - - if (iteration % 80 == 0) { - carveRiverNetwork(heightmap, precipitation, sim.erosionWater * 0.25f); - } - - result.averageDelta = measureAverageDelta(snapshot, heightmap); - snapshot = heightmap; - result.iterationsRun = iteration + 1; - - if (sim.autoConvergence && result.averageDelta < sim.convergenceThreshold) { - result.converged = true; - break; - } - } - - return result; -} - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/GeologicalSimulator.hpp b/src/terrain/generation/GeologicalSimulator.hpp deleted file mode 100644 index 414e479..0000000 --- a/src/terrain/generation/GeologicalSimulator.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "ecs/TerrainComponents.hpp" -#include "terrain/TerrainHeightmap.hpp" - -namespace Caffeine::Terrain { - -struct GeologicalSimulationResult { - u32 iterationsRun = 0; - f32 averageDelta = 0.0f; - bool converged = false; -}; - -class GeologicalSimulator { -public: - static GeologicalSimulationResult run(TerrainHeightmap& heightmap, - const ECS::TerrainComponent& terrain, - ECS::TerrainGenerationSettings& settings); -}; - -void applyEnvironmentPreset(ECS::TerrainGenerationSettings& settings, - ECS::TerrainEnvironment environment); - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/TerrainFractal.cpp b/src/terrain/generation/TerrainFractal.cpp deleted file mode 100644 index 8f1737c..0000000 --- a/src/terrain/generation/TerrainFractal.cpp +++ /dev/null @@ -1,276 +0,0 @@ -#include "terrain/generation/TerrainFractal.hpp" - -#include -#include -#include -#include - -namespace Caffeine::Terrain { -namespace { - -struct Complex { - f32 r = 0.0f; - f32 i = 0.0f; -}; - -u32 nextDiamondSquareSide(u32 target) { - if (target < 3) return 3; - u32 n = 1; - while ((1u << n) + 1u < target) { - ++n; - } - return (1u << n) + 1u; -} - -u32 nextPowerOfTwo(u32 value) { - u32 p = 1; - while (p < value) { - p <<= 1u; - } - return p; -} - -f32 gaussianRandom(std::mt19937& rng) { - std::uniform_real_distribution dist(0.0001f, 1.0f); - const f32 u1 = dist(rng); - const f32 u2 = dist(rng); - return std::sqrt(-2.0f * std::log(u1)) * std::cos(6.2831853f * u2); -} - -void normalizeHeightmap(std::vector& heights) { - if (heights.empty()) return; - f32 minH = heights[0]; - f32 maxH = heights[0]; - for (f32 h : heights) { - minH = std::min(minH, h); - maxH = std::max(maxH, h); - } - const f32 range = std::max(maxH - minH, 0.0001f); - for (f32& h : heights) { - h = std::clamp((h - minH) / range, 0.0f, 1.0f); - } -} - -void copyGridToHeightmap(TerrainHeightmap& heightmap, const std::vector& grid, u32 gridSize) { - const u32 targetX = heightmap.resolutionX(); - const u32 targetZ = heightmap.resolutionZ(); - const f32 spanX = static_cast(std::max(1u, targetX - 1)); - const f32 spanZ = static_cast(std::max(1u, targetZ - 1)); - const f32 gridSpan = static_cast(std::max(1u, gridSize - 1)); - - for (u32 z = 0; z < targetZ; ++z) { - for (u32 x = 0; x < targetX; ++x) { - const f32 u = static_cast(x) / spanX; - const f32 v = static_cast(z) / spanZ; - const f32 gx = u * gridSpan; - const f32 gz = v * gridSpan; - - const u32 x0 = static_cast(gx); - const u32 z0 = static_cast(gz); - const u32 x1 = std::min(x0 + 1, gridSize - 1); - const u32 z1 = std::min(z0 + 1, gridSize - 1); - const f32 tx = gx - static_cast(x0); - const f32 tz = gz - static_cast(z0); - - const f32 h00 = grid[z0 * gridSize + x0]; - const f32 h10 = grid[z0 * gridSize + x1]; - const f32 h01 = grid[z1 * gridSize + x0]; - const f32 h11 = grid[z1 * gridSize + x1]; - const f32 hx0 = h00 + (h10 - h00) * tx; - const f32 hx1 = h01 + (h11 - h01) * tx; - const f32 h = hx0 + (hx1 - hx0) * tz; - heightmap.setNormalized(x, z, h); - } - } -} - -void diamondSquareCore(std::vector& grid, u32 size, u32 seed, f32 roughness) { - roughness = std::clamp(roughness, 0.05f, 0.95f); - std::mt19937 rng(seed); - std::uniform_real_distribution dist(-1.0f, 1.0f); - - auto at = [&](u32 x, u32 z) -> f32& { return grid[z * size + x]; }; - - at(0, 0) = dist(rng); - at(size - 1, 0) = dist(rng); - at(0, size - 1) = dist(rng); - at(size - 1, size - 1) = dist(rng); - - f32 scale = 1.0f; - u32 step = size - 1; - while (step > 1) { - const u32 half = step / 2; - const f32 displacement = scale * std::pow(2.0f, -roughness); - - for (u32 z = half; z < size; z += step) { - for (u32 x = half; x < size; x += step) { - const f32 avg = (at(x - half, z - half) + at(x + half, z - half) + - at(x - half, z + half) + at(x + half, z + half)) * - 0.25f; - at(x, z) = avg + dist(rng) * displacement; - } - } - - for (u32 z = 0; z < size; z += half) { - const u32 shift = ((z / half) % 2 == 0) ? half : 0; - for (u32 x = shift; x < size; x += step) { - f32 sum = 0.0f; - u32 count = 0; - auto accumulate = [&](u32 nx, u32 nz) { - if (nx < size && nz < size) { - sum += at(nx, nz); - ++count; - } - }; - accumulate(x - half, z); - accumulate(x + half, z); - accumulate(x, z - half); - accumulate(x, z + half); - at(x, z) = sum / static_cast(std::max(1u, count)) + dist(rng) * displacement; - } - } - - step = half; - scale *= 0.5f; - } - - normalizeHeightmap(grid); -} - -void fftInPlace(std::vector& data, bool inverse) { - const size_t n = data.size(); - if (n < 2) return; - - size_t j = 0; - for (size_t i = 1; i < n; ++i) { - size_t bit = n >> 1; - for (; j & bit; bit >>= 1) { - j ^= bit; - } - j ^= bit; - if (i < j) { - std::swap(data[i], data[j]); - } - } - - const f32 sign = inverse ? 1.0f : -1.0f; - for (size_t len = 2; len <= n; len <<= 1) { - const f32 angle = sign * 6.2831853f / static_cast(len); - const f32 wlenR = std::cos(angle); - const f32 wlenI = std::sin(angle); - for (size_t i = 0; i < n; i += len) { - f32 wR = 1.0f; - f32 wI = 0.0f; - for (size_t k = 0; k < len / 2; ++k) { - const Complex u = data[i + k]; - const Complex v{data[i + k + len / 2].r * wR - data[i + k + len / 2].i * wI, - data[i + k + len / 2].r * wI + data[i + k + len / 2].i * wR}; - data[i + k].r = u.r + v.r; - data[i + k].i = u.i + v.i; - data[i + k + len / 2].r = u.r - v.r; - data[i + k + len / 2].i = u.i - v.i; - const f32 nextWR = wR * wlenR - wI * wlenI; - wI = wR * wlenI + wI * wlenR; - wR = nextWR; - } - } - } - - if (inverse) { - const f32 invN = 1.0f / static_cast(n); - for (Complex& c : data) { - c.r *= invN; - c.i *= invN; - } - } -} - -void fft2d(std::vector& grid, u32 width, u32 height, bool inverse) { - std::vector row(width); - for (u32 y = 0; y < height; ++y) { - for (u32 x = 0; x < width; ++x) { - row[x] = grid[y * width + x]; - } - fftInPlace(row, inverse); - for (u32 x = 0; x < width; ++x) { - grid[y * width + x] = row[x]; - } - } - - std::vector col(height); - for (u32 x = 0; x < width; ++x) { - for (u32 y = 0; y < height; ++y) { - col[y] = grid[y * width + x]; - } - fftInPlace(col, inverse); - for (u32 y = 0; y < height; ++y) { - grid[y * width + x] = col[y]; - } - } -} - -} // namespace - -bool isGridHeightModel(TerrainHeightModel model) { - return model == TerrainHeightModel::DiamondSquare || model == TerrainHeightModel::SpectralFFT; -} - -bool isPointSampleHeightModel(TerrainHeightModel model) { - return !isGridHeightModel(model) && model != TerrainHeightModel::HeightfieldMultiply; -} - -void generateDiamondSquare(TerrainHeightmap& heightmap, u32 seed, f32 roughness) { - const u32 targetX = heightmap.resolutionX(); - const u32 targetZ = heightmap.resolutionZ(); - const u32 gridSize = nextDiamondSquareSide(std::max(targetX, targetZ)); - - std::vector grid(static_cast(gridSize) * static_cast(gridSize), 0.0f); - diamondSquareCore(grid, gridSize, seed, roughness); - copyGridToHeightmap(heightmap, grid, gridSize); -} - -void generateSpectralFft(TerrainHeightmap& heightmap, u32 seed, f32 spectralExponent) { - spectralExponent = std::clamp(spectralExponent, 0.5f, 4.0f); - const u32 targetX = heightmap.resolutionX(); - const u32 targetZ = heightmap.resolutionZ(); - const u32 fftW = nextPowerOfTwo(std::max(2u, targetX)); - const u32 fftH = nextPowerOfTwo(std::max(2u, targetZ)); - - std::mt19937 rng(seed); - std::vector spectrum(static_cast(fftW) * static_cast(fftH)); - for (u32 y = 0; y < fftH; ++y) { - for (u32 x = 0; x < fftW; ++x) { - spectrum[y * fftW + x] = {gaussianRandom(rng), 0.0f}; - } - } - - fft2d(spectrum, fftW, fftH, false); - - const f32 centerX = static_cast(fftW) * 0.5f; - const f32 centerY = static_cast(fftH) * 0.5f; - for (u32 y = 0; y < fftH; ++y) { - for (u32 x = 0; x < fftW; ++x) { - f32 dx = static_cast(x) - centerX; - f32 dy = static_cast(y) - centerY; - if (dx < 0.0f) dx = -dx; - if (dy < 0.0f) dy = -dy; - const f32 freq = std::sqrt(dx * dx + dy * dy) + 1.0f; - const f32 filter = 1.0f / std::pow(freq, spectralExponent); - spectrum[y * fftW + x].r *= filter; - spectrum[y * fftW + x].i *= filter; - } - } - - fft2d(spectrum, fftW, fftH, true); - - std::vector grid(static_cast(fftW) * static_cast(fftH)); - for (u32 y = 0; y < fftH; ++y) { - for (u32 x = 0; x < fftW; ++x) { - grid[y * fftW + x] = spectrum[y * fftW + x].r; - } - } - normalizeHeightmap(grid); - copyGridToHeightmap(heightmap, grid, fftW); -} - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/TerrainFractal.hpp b/src/terrain/generation/TerrainFractal.hpp deleted file mode 100644 index 6460c6d..0000000 --- a/src/terrain/generation/TerrainFractal.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "core/Types.hpp" -#include "terrain/TerrainHeightmap.hpp" -#include "terrain/generation/TerrainNoise.hpp" - -namespace Caffeine::Terrain { - -// Diamond-Square on a (2^N + 1) grid. Output is normalized to [0, 1]. -// roughness maps to the H parameter (higher = smoother, typical 0.45–0.75). -void generateDiamondSquare(TerrainHeightmap& heightmap, u32 seed, f32 roughness); - -// FFT spectral synthesis with 1/f^beta filtering. Smooth, rolling terrain; tileable. -void generateSpectralFft(TerrainHeightmap& heightmap, u32 seed, f32 spectralExponent); - -bool isGridHeightModel(TerrainHeightModel model); -bool isPointSampleHeightModel(TerrainHeightModel model); - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/TerrainGenerator.cpp b/src/terrain/generation/TerrainGenerator.cpp deleted file mode 100644 index 820dd12..0000000 --- a/src/terrain/generation/TerrainGenerator.cpp +++ /dev/null @@ -1,881 +0,0 @@ -#include "terrain/generation/TerrainGenerator.hpp" - -#include "terrain/generation/GeologicalSimulator.hpp" -#include "terrain/generation/TerrainFractal.hpp" -#include "terrain/generation/TerrainHydrology.hpp" -#include "terrain/TerrainResolution.hpp" - -#include -#include -#include - -namespace Caffeine::Terrain { -namespace { - -f32 smoothstep(f32 edge0, f32 edge1, f32 x) { - const f32 t = std::clamp((x - edge0) / std::max(edge1 - edge0, 0.0001f), 0.0f, 1.0f); - return t * t * (3.0f - 2.0f * t); -} - -void sanitizeGenerationSettings(ECS::TerrainGenerationSettings& settings) { - settings.noiseScale = std::clamp(settings.noiseScale, 0.005f, 0.25f); - settings.amplitude = std::clamp(settings.amplitude, 0.08f, 1.0f); - settings.baseHeight = std::clamp(settings.baseHeight, 0.0f, 0.85f); - settings.octaves = std::max(1u, settings.octaves); - settings.slopeWeightAlpha = std::clamp(settings.slopeWeightAlpha, 0.0f, 0.35f); - if (settings.amplitude < 0.08f) { - settings.amplitude = 0.28f; - } - settings.fractalRoughness = std::clamp(settings.fractalRoughness, 0.05f, 0.95f); - settings.spectralExponent = std::clamp(settings.spectralExponent, 0.5f, 4.0f); -} - -f32 samplePointHeightNoise(const ECS::TerrainGenerationSettings& gen, TerrainHeightModel model, f32 nx, - f32 nz) { - if (gen.useRidgedNoise && model == TerrainHeightModel::RollingHills) { - model = TerrainHeightModel::Hybrid; - } - return sampleHeightModel2D(model, gen.noiseAlgorithm, nx, nz, gen.seed, gen.octaves, - gen.persistence, gen.lacunarity, gen.ridgedBlend, gen.ridgeOffset, - gen.ridgeGain, gen.multiplicativeContrast); -} - -class BaseNoiseModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Base Noise"; } - - void apply(TerrainGeneratorContext& ctx) override { - const auto& terrain = ctx.terrain; - const auto& gen = ctx.settings; - auto& heightmap = ctx.heightmap; - - heightmap.resize(terrain.resolutionX, terrain.resolutionZ); - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const f32 scale = std::clamp(gen.noiseScale, 0.005f, 0.25f); - const f32 amplitude = std::clamp(gen.amplitude, 0.08f, 1.0f); - const f32 spanX = static_cast(std::max(1u, resX - 1)); - const f32 spanZ = static_cast(std::max(1u, resZ - 1)); - - TerrainHeightModel model = gen.heightModel; - if (gen.useRidgedNoise && model == TerrainHeightModel::RollingHills) { - model = TerrainHeightModel::Hybrid; - } - - if (model == TerrainHeightModel::DiamondSquare) { - generateDiamondSquare(heightmap, gen.seed, gen.fractalRoughness); - } else if (model == TerrainHeightModel::SpectralFFT) { - generateSpectralFft(heightmap, gen.seed + 31u, gen.spectralExponent); - } else if (model == TerrainHeightModel::HeightfieldMultiply) { - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const f32 u = static_cast(x) / spanX; - const f32 v = static_cast(z) / spanZ; - f32 nx = u * spanX * scale; - f32 nz = v * spanZ * scale; - - if (gen.domainWarp) { - if (gen.fractalDomainWarp) { - warpDomainFractal2D(nx, nz, gen.seed + 17u, - std::max(gen.domainWarpScale, 1.0f), - gen.domainWarpStrength, std::max(2u, gen.octaves), nx, - nz); - } else { - warpDomain2DMultiPass(nx, nz, gen.seed + 17u, gen.domainWarpStrength, - std::max(1u, gen.domainWarpPasses), nx, nz); - } - } - - const f32 layerA = - samplePointHeightNoise(gen, gen.multiplyLayerA, nx, nz); - const f32 layerB = - samplePointHeightNoise(gen, gen.multiplyLayerB, nx + 113.0f, nz - 47.0f); - const f32 contrast = std::clamp(gen.multiplicativeContrast, 0.5f, 3.0f); - const f32 product = - std::pow(std::clamp(layerA * layerB, 0.0f, 1.0f), 1.0f / contrast); - heightmap.setNormalized(x, z, product); - } - } - } else { - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const f32 u = static_cast(x) / spanX; - const f32 v = static_cast(z) / spanZ; - f32 nx = u * spanX * scale; - f32 nz = v * spanZ * scale; - - if (gen.domainWarp) { - if (gen.fractalDomainWarp) { - warpDomainFractal2D(nx, nz, gen.seed + 17u, - std::max(gen.domainWarpScale, 1.0f), - gen.domainWarpStrength, std::max(2u, gen.octaves), nx, - nz); - } else { - warpDomain2DMultiPass(nx, nz, gen.seed + 17u, gen.domainWarpStrength, - std::max(1u, gen.domainWarpPasses), nx, nz); - } - } - - const f32 noise = samplePointHeightNoise(gen, model, nx, nz); - const f32 height = std::clamp(gen.baseHeight + noise * amplitude, 0.0f, 1.0f); - heightmap.setNormalized(x, z, height); - } - } - } - } -}; - -class SmoothFilterModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Smooth"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.smoothPass || ctx.settings.smoothIterations == 0) return; - - auto& heightmap = ctx.heightmap; - std::vector scratch(heightmap.heights().size()); - - for (u32 iter = 0; iter < ctx.settings.smoothIterations; ++iter) { - for (u32 z = 0; z < heightmap.resolutionZ(); ++z) { - for (u32 x = 0; x < heightmap.resolutionX(); ++x) { - f32 sum = 0.0f; - u32 count = 0; - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - const i32 sx = static_cast(x) + dx; - const i32 sz = static_cast(z) + dz; - if (sx < 0 || sz < 0 || - sx >= static_cast(heightmap.resolutionX()) || - sz >= static_cast(heightmap.resolutionZ())) { - continue; - } - sum += heightmap.sampleNormalized(static_cast(sx), - static_cast(sz)); - ++count; - } - } - scratch[z * heightmap.resolutionX() + x] = - count > 0 ? sum / static_cast(count) : 0.0f; - } - } - heightmap.heights() = scratch; - } - } -}; - -f32 pingpong01(f32 value) { - value = std::fmod(value, 2.0f); - if (value < 0.0f) value += 2.0f; - return 1.0f - std::abs(value - 1.0f); -} - -class MicroSculptErosionModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Micro Sculpt Erosion"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.microSculpt) return; - - const auto& gen = ctx.settings; - auto& heightmap = ctx.heightmap; - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const f32 spanX = static_cast(std::max(1u, resX - 1)); - const f32 spanZ = static_cast(std::max(1u, resZ - 1)); - const f32 scale = std::clamp(gen.noiseScale, 0.005f, 0.25f); - const f32 sculptStrength = std::clamp(gen.microSculptStrength, 0.0f, 1.0f); - const f32 riverCarve = std::clamp(gen.microRiverCarve, 0.0f, 1.0f); - const f32 plateau = std::clamp(gen.plateauSharpen, 0.0f, 1.0f); - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const f32 u = static_cast(x) / spanX; - const f32 v = static_cast(z) / spanZ; - const f32 wx = u * spanX * scale; - const f32 wz = v * spanZ * scale; - - f32 terrain = heightmap.sampleNormalized(x, z); - - const f32 biome = - sampleFbm2D(gen.noiseAlgorithm, wx + 500.0f, wz + 500.0f, gen.seed + 4u, 1u, - 0.5f, 2.0f); - const f32 erosionSoftness = - std::clamp(biome * 0.6f - 0.1f + gen.microSculptSoftness, 0.0f, 1.5f); - - f32 erosion = - sampleFbm2D(gen.noiseAlgorithm, wx, wz, gen.seed + 1u, 3u, 0.5f, 1.8f); - erosion = smoothstep(0.0f, 1.0f, erosion); - erosion = std::pow(erosion, 1.0f + erosionSoftness); - erosion = std::clamp(pingpong01(erosion * 2.0f) - 0.3f, 0.0f, 1.0f); - - terrain *= std::lerp(1.0f, erosion, sculptStrength * terrain); - - if (riverCarve > 0.001f) { - f32 rivers = sampleFbm2D(gen.noiseAlgorithm, wx, wz, gen.seed + 77u, 4u, 0.35f, - 2.0f); - rivers = (std::abs(rivers) - 0.5f) * 2.0f; - rivers = pingpong01(rivers); - rivers = 1.0f - smoothstep(0.44f, 0.62f, rivers); - terrain -= rivers * riverCarve * 0.35f; - } - - const f32 altitudeNoise = biome * 1.4f - 0.75f; - terrain = terrain + gen.baseHeight * 0.35f + altitudeNoise * 0.18f; - - const f32 squared = terrain * terrain; - const f32 cubed = squared * terrain; - terrain = std::lerp(squared, cubed, plateau); - - // High-frequency surface grain — breaks up overly smooth slopes. - const f32 grain = - sampleFbm2D(gen.noiseAlgorithm, wx * 3.6f, wz * 3.6f, gen.seed + 211u, 4u, - 0.55f, 2.35f); - const f32 grain2 = - sampleFbm2D(gen.noiseAlgorithm, wx * 9.0f, wz * 9.0f, gen.seed + 313u, 3u, - 0.45f, 2.6f); - const f32 surfaceGrain = (grain * 0.65f + grain2 * 0.35f - 0.5f); - const f32 slopeMask = std::clamp(terrain * 1.35f, 0.0f, 1.0f); - terrain += surfaceGrain * sculptStrength * 0.11f * slopeMask; - - heightmap.setNormalized(x, z, std::clamp(terrain, 0.0f, 1.0f)); - } - } - } -}; - -class ThermalErosionModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Thermal Erosion"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.thermalErosion || ctx.settings.thermalIterations == 0) return; - - auto& heightmap = ctx.heightmap; - const f32 talus = std::max(ctx.settings.thermalTalus, 0.0001f); - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - - for (u32 iter = 0; iter < ctx.settings.thermalIterations; ++iter) { - for (u32 z = 1; z + 1 < resZ; ++z) { - for (u32 x = 1; x + 1 < resX; ++x) { - const f32 center = heightmap.sampleNormalized(x, z); - f32 totalDiff = 0.0f; - f32 neighborCount = 0.0f; - - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dz == 0) continue; - const u32 nx = static_cast(static_cast(x) + dx); - const u32 nz = static_cast(static_cast(z) + dz); - const f32 neighbor = heightmap.sampleNormalized(nx, nz); - const f32 diff = center - neighbor; - if (diff > talus) { - totalDiff += diff - talus; - ++neighborCount; - } - } - } - - if (neighborCount > 0.0f) { - const f32 moved = totalDiff * 0.25f / neighborCount; - heightmap.setNormalized(x, z, center - moved); - } - } - } - } - } -}; - -class HydraulicErosionModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Hydraulic Erosion"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.hydraulicErosion || ctx.settings.hydraulicIterations == 0) return; - - auto& heightmap = ctx.heightmap; - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const f32 erode = std::clamp(ctx.settings.hydraulicErode, 0.0f, 1.0f); - const f32 deposit = std::clamp(ctx.settings.hydraulicDeposit, 0.0f, 1.0f); - const f32 rain = std::max(ctx.settings.hydraulicRain, 0.0001f); - const f32 inertia = std::clamp(ctx.settings.hydraulicInertia, 0.0f, 1.0f); - const f32 evaporation = std::clamp(ctx.settings.hydraulicEvaporation, 0.001f, 0.5f); - const u32 maxSteps = std::max(1u, ctx.settings.hydraulicMaxSteps); - - std::mt19937 rng(ctx.settings.seed + 4099u); - std::uniform_real_distribution distU(0.02f, 0.98f); - std::uniform_real_distribution distV(0.02f, 0.98f); - - for (u32 droplet = 0; droplet < ctx.settings.hydraulicIterations; ++droplet) { - f32 u = distU(rng); - f32 v = distV(rng); - f32 water = rain; - f32 sediment = 0.0f; - f32 speed = 1.0f; - f32 dirU = 0.0f; - f32 dirV = 0.0f; - - for (u32 step = 0; step < maxSteps; ++step) { - const f32 height = heightmap.sampleBilinear(u, v); - const f32 stepSize = 1.0f / static_cast(std::max(resX, resZ)); - const f32 gradU = - (heightmap.sampleBilinear(std::min(u + stepSize, 1.0f), v) - - heightmap.sampleBilinear(std::max(u - stepSize, 0.0f), v)) * - 0.5f; - const f32 gradV = - (heightmap.sampleBilinear(u, std::min(v + stepSize, 1.0f)) - - heightmap.sampleBilinear(u, std::max(v - stepSize, 0.0f))) * - 0.5f; - - f32 downhillU = -gradU; - f32 downhillV = -gradV; - const f32 downhillLen = std::sqrt(downhillU * downhillU + downhillV * downhillV); - if (downhillLen > 0.0001f) { - downhillU /= downhillLen; - downhillV /= downhillLen; - } - - dirU = dirU * inertia + downhillU * (1.0f - inertia); - dirV = dirV * inertia + downhillV * (1.0f - inertia); - const f32 dirLen = std::sqrt(dirU * dirU + dirV * dirV); - if (dirLen > 0.0001f) { - dirU /= dirLen; - dirV /= dirLen; - } - - const f32 nextU = u + dirU * stepSize * 2.5f; - const f32 nextV = v + dirV * stepSize * 2.5f; - if (nextU < 0.0f || nextU > 1.0f || nextV < 0.0f || nextV > 1.0f) break; - - const f32 nextHeight = heightmap.sampleBilinear(nextU, nextV); - const f32 slope = std::max(height - nextHeight, 0.0f); - if (slope < 0.00001f) break; - - const u32 ix = static_cast(std::clamp(u * static_cast(resX - 1), 0.0f, - static_cast(resX - 1))); - const u32 iz = static_cast(std::clamp(v * static_cast(resZ - 1), 0.0f, - static_cast(resZ - 1))); - - const f32 capacity = std::max(slope, 0.0001f) * speed * water * (0.3f + speed * 0.5f); - if (capacity > sediment) { - const f32 erodeAmount = - std::min((capacity - sediment) * erode * 0.5f, slope * 0.25f); - heightmap.setNormalized(ix, iz, heightmap.sampleNormalized(ix, iz) - erodeAmount); - sediment += erodeAmount; - } else { - const f32 depositAmount = (sediment - capacity) * deposit * 0.5f; - heightmap.setNormalized(ix, iz, - heightmap.sampleNormalized(ix, iz) + depositAmount); - sediment -= depositAmount; - } - - u = nextU; - v = nextV; - water *= (1.0f - evaporation); - speed = std::sqrt(speed * speed + slope * 4.0f); - if (water < 0.001f) break; - } - } - } -}; - -class HeightQuantizeModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Height Quantize"; } - - void apply(TerrainGeneratorContext& ctx) override { - const f32 steps = ctx.settings.heightQuantize; - if (steps <= 1.0f) return; - - auto& heightmap = ctx.heightmap; - for (u32 z = 0; z < heightmap.resolutionZ(); ++z) { - for (u32 x = 0; x < heightmap.resolutionX(); ++x) { - const f32 h = heightmap.sampleNormalized(x, z); - const f32 quantized = std::round(h * steps) / steps; - heightmap.setNormalized(x, z, quantized); - } - } - } -}; - -class StylizedCurveModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Stylized Curve"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (ctx.settings.style != ECS::TerrainGenStyle::Stylized) return; - - auto& heightmap = ctx.heightmap; - for (u32 z = 0; z < heightmap.resolutionZ(); ++z) { - for (u32 x = 0; x < heightmap.resolutionX(); ++x) { - f32 h = heightmap.sampleNormalized(x, z); - h = h * h * (3.0f - 2.0f * h); - if (h > 0.55f) { - h = 0.55f + (h - 0.55f) * 1.35f; - } - heightmap.setNormalized(x, z, std::clamp(h, 0.0f, 1.0f)); - } - } - } -}; - -void blurSplatmap(TerrainSplatmap& splatmap, u32 passes) { - if (passes == 0 || splatmap.empty()) return; - - const u32 resX = splatmap.resolutionX(); - const u32 resZ = splatmap.resolutionZ(); - std::vector scratch(splatmap.weights().size()); - - for (u32 pass = 0; pass < passes; ++pass) { - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - Vec4 sum(0.0f, 0.0f, 0.0f, 0.0f); - u32 count = 0; - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - const i32 sx = static_cast(x) + dx; - const i32 sz = static_cast(z) + dz; - if (sx < 0 || sz < 0 || sx >= static_cast(resX) || - sz >= static_cast(resZ)) { - continue; - } - sum += splatmap.sample(static_cast(sx), static_cast(sz)); - ++count; - } - } - Vec4 averaged = count > 0 ? sum / static_cast(count) : Vec4(0, 0, 0, 1); - const f32 weightSum = averaged.x + averaged.y + averaged.z + averaged.w; - if (weightSum > 0.0001f) { - averaged /= weightSum; - } - scratch[z * resX + x] = averaged; - } - } - splatmap.weights() = scratch; - } -} - -void smoothHeightmap(TerrainHeightmap& heightmap, u32 iterations) { - if (iterations == 0 || heightmap.empty()) return; - - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - std::vector scratch(heightmap.heights().size()); - - for (u32 iter = 0; iter < iterations; ++iter) { - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - f32 sum = 0.0f; - u32 count = 0; - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - const i32 sx = static_cast(x) + dx; - const i32 sz = static_cast(z) + dz; - if (sx < 0 || sz < 0 || sx >= static_cast(resX) || - sz >= static_cast(resZ)) { - continue; - } - sum += heightmap.sampleNormalized(static_cast(sx), static_cast(sz)); - ++count; - } - } - scratch[z * resX + x] = count > 0 ? sum / static_cast(count) : 0.0f; - } - } - heightmap.heights() = scratch; - } -} - -f32 sampleMoistureBilinear(const std::vector& moistureMap, u32 resX, u32 resZ, f32 u, f32 v) { - if (moistureMap.size() != static_cast(resX) * static_cast(resZ) || resX < 2 || - resZ < 2) { - return 0.5f; - } - - u = std::clamp(u, 0.0f, 1.0f); - v = std::clamp(v, 0.0f, 1.0f); - const f32 fx = u * static_cast(resX - 1); - const f32 fz = v * static_cast(resZ - 1); - const u32 x0 = static_cast(std::floor(fx)); - const u32 z0 = static_cast(std::floor(fz)); - const u32 x1 = std::min(x0 + 1, resX - 1); - const u32 z1 = std::min(z0 + 1, resZ - 1); - const f32 tx = fx - static_cast(x0); - const f32 tz = fz - static_cast(z0); - - const f32 h00 = moistureMap[z0 * resX + x0]; - const f32 h10 = moistureMap[z0 * resX + x1]; - const f32 h01 = moistureMap[z1 * resX + x0]; - const f32 h11 = moistureMap[z1 * resX + x1]; - const f32 ix0 = h00 + (h10 - h00) * tx; - const f32 ix1 = h01 + (h11 - h01) * tx; - return ix0 + (ix1 - ix0) * tz; -} - -class RiverTracingModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "River Tracing"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.hydrology.traceRivers || !ctx.state) return; - - const auto& climate = ctx.settings.climate; - const auto& hydrology = ctx.settings.hydrology; - auto& heightmap = ctx.heightmap; - - ctx.state->moistureMap = - buildMoistureMap(heightmap, climate, hydrology, {}, ctx.settings.seed); - ctx.state->riverNetwork = - traceRiverNetwork(heightmap, hydrology, ctx.state->moistureMap, ctx.settings.seed); - carveRiverChannels(heightmap, ctx.state->riverNetwork, hydrology); - depositSedimentInValleys(heightmap, ctx.state->riverNetwork, hydrology.sedimentDepositStrength); - ctx.state->moistureMap = - buildMoistureMap(heightmap, climate, hydrology, ctx.state->riverNetwork, ctx.settings.seed); - } -}; - -class AutoSplatModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Auto Splat"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.autoSplat) return; - - const auto& terrain = ctx.terrain; - auto& heightmap = ctx.heightmap; - auto& splatmap = ctx.splatmap; - const auto& climate = ctx.settings.climate; - - const u32 splatResX = splatResolutionX(terrain); - const u32 splatResZ = splatResolutionZ(terrain); - splatmap.resize(splatResX, splatResZ); - - TerrainHeightmap heightForSplat = heightmap; - smoothHeightmap(heightForSplat, 2); - - std::vector moistureMap; - if (ctx.settings.useClimateBiomes) { - if (ctx.state && !ctx.state->moistureMap.empty()) { - moistureMap = ctx.state->moistureMap; - } else { - moistureMap = buildMoistureMap(heightmap, climate, ctx.settings.hydrology, - ctx.state ? ctx.state->riverNetwork : RiverNetwork{}, - ctx.settings.seed); - } - } - - const u32 moistureResX = heightmap.resolutionX(); - const u32 moistureResZ = heightmap.resolutionZ(); - const f32 blend = std::max(ctx.settings.splatBlendRange, 0.05f); - - for (u32 z = 0; z < splatmap.resolutionZ(); ++z) { - for (u32 x = 0; x < splatmap.resolutionX(); ++x) { - const f32 u = static_cast(x) / static_cast(splatmap.resolutionX() - 1); - const f32 v = static_cast(z) / static_cast(splatmap.resolutionZ() - 1); - const f32 height = heightForSplat.sampleBilinear(u, v); - const Vec3 normal = - heightForSplat.sampleNormalBilinear(u, v, terrain.worldSizeX, terrain.worldSizeZ, - terrain.maxHeight); - const f32 slope = 1.0f - std::clamp(normal.y, 0.0f, 1.0f); - - Vec4 weights; - if (ctx.settings.useClimateBiomes) { - const f32 moisture = - sampleMoistureBilinear(moistureMap, moistureResX, moistureResZ, u, v); - weights = assignBiomeSplatWeights(height, moisture, slope, climate.temperature, - blend, climate.seaLevel); - } else { - const f32 slopeFactor = smoothstep(0.25f, 0.55f, slope); - f32 sand = 1.0f - smoothstep(0.06f, 0.06f + blend * 1.5f, height); - f32 grass = smoothstep(0.1f, 0.1f + blend * 1.5f, height) * - (1.0f - smoothstep(0.62f, 0.62f + blend * 1.5f, height)) * - (1.0f - slopeFactor * 0.85f); - f32 rock = slopeFactor * smoothstep(0.18f, 0.18f + blend, slope); - rock += smoothstep(0.45f, 0.45f + blend, height) * slopeFactor * 0.35f; - f32 snow = smoothstep(0.66f, 0.66f + blend * 1.5f, height) * - (1.0f - slopeFactor * 0.65f); - const f32 sum = grass + rock + sand + snow; - weights = sum > 0.0001f ? Vec4(grass / sum, rock / sum, sand / sum, snow / sum) - : Vec4(0, 0, 0, 1); - } - - splatmap.set(x, z, weights); - } - } - - blurSplatmap(splatmap, ctx.settings.splatBlurPasses); - } -}; - -class PostSimulationSmoothModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Post Simulation Smooth"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.simulation.enabled || ctx.settings.postSimSmoothIterations == 0) return; - smoothHeightmap(ctx.heightmap, ctx.settings.postSimSmoothIterations); - } -}; - -class ExponentialSlopeWeightingModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Slope Weighting"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.slopeWeighting || ctx.settings.slopeWeightAlpha <= 0.0f) return; - - auto& heightmap = ctx.heightmap; - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - if (resX < 3 || resZ < 3) return; - - const f32 alpha = ctx.settings.slopeWeightAlpha; - std::vector scratch(heightmap.heights().size()); - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const f32 center = heightmap.sampleNormalized(x, z); - const u32 xL = x > 0 ? x - 1 : x; - const u32 xR = x + 1 < resX ? x + 1 : x; - const u32 zD = z > 0 ? z - 1 : z; - const u32 zU = z + 1 < resZ ? z + 1 : z; - - const f32 dhdx = (heightmap.sampleNormalized(xR, z) - heightmap.sampleNormalized(xL, z)) * - 0.5f; - const f32 dhdz = - (heightmap.sampleNormalized(x, zU) - heightmap.sampleNormalized(x, zD)) * 0.5f; - const f32 slope = std::sqrt(dhdx * dhdx + dhdz * dhdz); - scratch[z * resX + x] = std::clamp(center * std::exp(-alpha * slope), 0.0f, 1.0f); - } - } - - heightmap.heights() = scratch; - } -}; - -class NormalizeHeightModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Normalize Height"; } - - void apply(TerrainGeneratorContext& ctx) override { - auto& heights = ctx.heightmap.heights(); - if (heights.empty()) return; - f32 minH = heights[0]; - f32 maxH = heights[0]; - for (f32 h : heights) { - minH = std::min(minH, h); - maxH = std::max(maxH, h); - } - const f32 range = maxH - minH; - if (range < 0.02f) return; - // Stretch local relief into the authored maxHeight (meters). Without this, - // geological simulation leaves a nearly flat 0–1 band. - for (f32& h : heights) { - h = std::clamp((h - minH) / range, 0.0f, 1.0f); - } - } -}; - -class GeologicalSimulationModule final : public ITerrainGeneratorModule { -public: - const char* name() const override { return "Geological Simulation"; } - - void apply(TerrainGeneratorContext& ctx) override { - if (!ctx.settings.simulation.enabled) return; - GeologicalSimulator::run(ctx.heightmap, ctx.terrain, ctx.settings); - } -}; - -BaseNoiseModule g_baseNoise; -MicroSculptErosionModule g_microSculpt; -SmoothFilterModule g_smooth; -ThermalErosionModule g_thermal; -HydraulicErosionModule g_hydraulic; -HeightQuantizeModule g_quantize; -StylizedCurveModule g_stylized; -AutoSplatModule g_autoSplat; -GeologicalSimulationModule g_geological; -PostSimulationSmoothModule g_postSimSmooth; -ExponentialSlopeWeightingModule g_slopeWeighting; -RiverTracingModule g_riverTracing; -NormalizeHeightModule g_normalizeHeight; - -} // namespace - -void applyGenerationStylePreset(ECS::TerrainGenerationSettings& settings, ECS::TerrainGenStyle style) { - if (style == ECS::TerrainGenStyle::Custom) { - sanitizeGenerationSettings(settings); - settings.style = style; - return; - } - - const u32 seed = settings.seed; - settings = ECS::TerrainGenerationSettings{}; - settings.seed = seed; - settings.style = style; - - switch (style) { - case ECS::TerrainGenStyle::Realistic: - settings.noiseAlgorithm = TerrainNoiseAlgorithm::Simplex; - settings.heightModel = TerrainHeightModel::Hybrid; - settings.noiseScale = 0.018f; - settings.octaves = 6; - settings.persistence = 0.48f; - settings.lacunarity = 2.05f; - settings.amplitude = 0.85f; - settings.baseHeight = 0.12f; - settings.ridgedBlend = 0.45f; - settings.domainWarp = true; - settings.fractalDomainWarp = true; - settings.domainWarpStrength = 0.18f; - settings.domainWarpScale = 40.0f; - settings.slopeWeighting = false; - settings.slopeWeightAlpha = 0.04f; - settings.microSculpt = true; - settings.microSculptStrength = 0.48f; - settings.microSculptSoftness = 0.32f; - settings.plateauSharpen = 0.5f; - settings.thermalErosion = true; - settings.thermalIterations = 18; - settings.thermalTalus = 0.008f; - settings.hydraulicErosion = true; - settings.hydraulicIterations = 6000; - settings.hydraulicErode = 0.22f; - settings.hydraulicDeposit = 0.28f; - settings.hydrology.traceRivers = false; - settings.microRiverCarve = 0.15f; - settings.smoothPass = false; - settings.smoothIterations = 0; - settings.useClimateBiomes = true; - settings.simulation.enabled = false; - break; - case ECS::TerrainGenStyle::LowPoly: - settings.noiseAlgorithm = TerrainNoiseAlgorithm::Value; - settings.heightModel = TerrainHeightModel::FractalFBM; - settings.noiseScale = 0.022f; - settings.octaves = 3; - settings.persistence = 0.5f; - settings.lacunarity = 2.0f; - settings.amplitude = 0.9f; - settings.baseHeight = 0.1f; - settings.domainWarp = false; - settings.slopeWeighting = false; - settings.thermalErosion = false; - settings.hydraulicErosion = false; - settings.hydrology.traceRivers = false; - settings.smoothPass = false; - settings.heightQuantize = 8.0f; - settings.simulation.enabled = false; - break; - case ECS::TerrainGenStyle::Stylized: - settings.noiseAlgorithm = TerrainNoiseAlgorithm::Perlin; - settings.heightModel = TerrainHeightModel::RidgedMountains; - settings.noiseScale = 0.02f; - settings.octaves = 5; - settings.persistence = 0.5f; - settings.lacunarity = 2.1f; - settings.amplitude = 0.9f; - settings.baseHeight = 0.08f; - settings.domainWarp = true; - settings.fractalDomainWarp = true; - settings.domainWarpStrength = 0.1f; - settings.slopeWeighting = false; - settings.thermalErosion = false; - settings.hydraulicErosion = false; - settings.hydrology.traceRivers = false; - settings.smoothPass = true; - settings.smoothIterations = 1; - settings.simulation.enabled = false; - break; - case ECS::TerrainGenStyle::UltraRealistic: - settings.noiseAlgorithm = TerrainNoiseAlgorithm::Simplex; - settings.heightModel = TerrainHeightModel::Hybrid; - settings.noiseScale = 0.012f; - settings.octaves = 7; - settings.persistence = 0.52f; - settings.lacunarity = 2.08f; - settings.amplitude = 1.0f; - settings.baseHeight = 0.08f; - settings.ridgedBlend = 0.5f; - settings.domainWarp = true; - settings.fractalDomainWarp = true; - settings.domainWarpStrength = 0.2f; - settings.domainWarpScale = 36.0f; - settings.slopeWeighting = true; - settings.slopeWeightAlpha = 0.05f; - settings.microSculpt = true; - settings.microSculptStrength = 0.68f; - settings.microSculptSoftness = 0.22f; - settings.microRiverCarve = 0.32f; - settings.plateauSharpen = 0.35f; - settings.thermalErosion = false; - settings.hydraulicErosion = false; - settings.hydrology.traceRivers = false; - settings.smoothPass = false; - settings.smoothIterations = 0; - settings.postSimSmoothIterations = 2; - settings.useClimateBiomes = true; - settings.simulation.enabled = true; - settings.simulation.environment = ECS::TerrainEnvironment::AlpineGlacial; - settings.simulation.totalIterations = 420; - settings.simulation.dropletsPerIteration = 18; - settings.simulation.maxDropletSteps = 96; - settings.simulation.tectonicActivity = 0.62f; - settings.simulation.erosionWater = 0.92f; - settings.simulation.erosionThermal = 0.58f; - settings.simulation.erosionGlacial = 0.18f; - settings.simulation.erosionWind = 0.14f; - settings.simulation.erosionBiological = 0.42f; - settings.simulation.temperature = 0.42f; - settings.simulation.humidity = 0.72f; - break; - case ECS::TerrainGenStyle::Custom: - break; - } -} - -void TerrainGeneratorPipeline::addModule(ITerrainGeneratorModule& module) { - m_modules.push_back(&module); -} - -void TerrainGeneratorPipeline::clear() { m_modules.clear(); } - -void TerrainGeneratorPipeline::run(TerrainGeneratorContext& ctx) const { - for (ITerrainGeneratorModule* module : m_modules) { - if (module) module->apply(ctx); - } -} - -void TerrainGenerator::generate(TerrainHeightmap& heightmap, TerrainSplatmap& splatmap, - const ECS::TerrainComponent& terrain, - ECS::TerrainGenerationSettings& settings) { - sanitizeGenerationSettings(settings); - - TerrainGenerationState state; - TerrainGeneratorContext ctx{heightmap, splatmap, terrain, settings, &state}; - - TerrainGeneratorPipeline pipeline; - pipeline.addModule(g_baseNoise); - pipeline.addModule(g_stylized); - if (settings.simulation.enabled) { - pipeline.addModule(g_geological); - pipeline.addModule(g_postSimSmooth); - } else { - pipeline.addModule(g_thermal); - if (settings.hydrology.traceRivers) { - pipeline.addModule(g_riverTracing); - } else { - pipeline.addModule(g_hydraulic); - } - } - pipeline.addModule(g_slopeWeighting); - pipeline.addModule(g_smooth); - pipeline.addModule(g_microSculpt); - pipeline.addModule(g_normalizeHeight); - pipeline.addModule(g_quantize); - pipeline.addModule(g_autoSplat); - pipeline.run(ctx); -} - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/TerrainGenerator.hpp b/src/terrain/generation/TerrainGenerator.hpp deleted file mode 100644 index 3ebf8d5..0000000 --- a/src/terrain/generation/TerrainGenerator.hpp +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include "ecs/TerrainComponents.hpp" -#include "terrain/TerrainHeightmap.hpp" -#include "terrain/TerrainSplatmap.hpp" -#include "terrain/generation/TerrainGeneratorTypes.hpp" -#include "terrain/generation/TerrainHydrology.hpp" - -#include - -namespace Caffeine::Terrain { - -struct TerrainGenerationState { - std::vector moistureMap; - RiverNetwork riverNetwork; -}; - -struct TerrainGeneratorContext { - TerrainHeightmap& heightmap; - TerrainSplatmap& splatmap; - const ECS::TerrainComponent& terrain; - ECS::TerrainGenerationSettings& settings; - TerrainGenerationState* state = nullptr; -}; - -class ITerrainGeneratorModule { -public: - virtual ~ITerrainGeneratorModule() = default; - virtual const char* name() const = 0; - virtual void apply(TerrainGeneratorContext& ctx) = 0; -}; - -class TerrainGeneratorPipeline { -public: - void addModule(ITerrainGeneratorModule& module); - void clear(); - void run(TerrainGeneratorContext& ctx) const; - -private: - std::vector m_modules; -}; - -void applyGenerationStylePreset(ECS::TerrainGenerationSettings& settings, ECS::TerrainGenStyle style); - -class TerrainGenerator { -public: - static void generate(TerrainHeightmap& heightmap, TerrainSplatmap& splatmap, - const ECS::TerrainComponent& terrain, - ECS::TerrainGenerationSettings& settings); -}; - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/TerrainHydrology.cpp b/src/terrain/generation/TerrainHydrology.cpp deleted file mode 100644 index 45f8dcf..0000000 --- a/src/terrain/generation/TerrainHydrology.cpp +++ /dev/null @@ -1,345 +0,0 @@ -#include "terrain/generation/TerrainHydrology.hpp" - -#include "terrain/generation/TerrainNoise.hpp" - -#include -#include -#include -#include - -namespace Caffeine::Terrain { -namespace { - -f32 smoothstep(f32 edge0, f32 edge1, f32 x) { - const f32 t = std::clamp((x - edge0) / std::max(edge1 - edge0, 0.0001f), 0.0f, 1.0f); - return t * t * (3.0f - 2.0f * t); -} - -u32 cellIndex(u32 x, u32 z, u32 resX) { return z * resX + x; } - -bool isSteep(const TerrainHeightmap& heightmap, u32 x, u32 z) { - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const f32 center = heightmap.sampleNormalized(x, z); - f32 maxDiff = 0.0f; - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dz == 0) continue; - const i32 nx = static_cast(x) + dx; - const i32 nz = static_cast(z) + dz; - if (nx < 0 || nz < 0 || nx >= static_cast(resX) || nz >= static_cast(resZ)) { - continue; - } - maxDiff = std::max(maxDiff, std::abs(center - heightmap.sampleNormalized(static_cast(nx), - static_cast(nz)))); - } - } - return maxDiff > 0.04f; -} - -std::pair findSteepestDownhill(const TerrainHeightmap& heightmap, u32 x, u32 z) { - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const f32 height = heightmap.sampleNormalized(x, z); - u32 bestX = x; - u32 bestZ = z; - f32 lowest = height; - - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dz == 0) continue; - const i32 nx = static_cast(x) + dx; - const i32 nz = static_cast(z) + dz; - if (nx < 0 || nz < 0 || nx >= static_cast(resX) || nz >= static_cast(resZ)) { - continue; - } - const f32 neighbor = heightmap.sampleNormalized(static_cast(nx), static_cast(nz)); - if (neighbor < lowest) { - lowest = neighbor; - bestX = static_cast(nx); - bestZ = static_cast(nz); - } - } - } - - return {bestX, bestZ}; -} - -std::vector buildWaterDistanceField(const TerrainHeightmap& heightmap, f32 seaLevel) { - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const u32 cellCount = resX * resZ; - std::vector distance(cellCount, 1e6f); - std::queue> queue; - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - if (heightmap.sampleNormalized(x, z) <= seaLevel) { - distance[cellIndex(x, z, resX)] = 0.0f; - queue.emplace(x, z); - } - } - } - - while (!queue.empty()) { - const auto [cx, cz] = queue.front(); - queue.pop(); - const u32 idx = cellIndex(cx, cz, resX); - const f32 current = distance[idx]; - - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dz == 0) continue; - const i32 nx = static_cast(cx) + dx; - const i32 nz = static_cast(cz) + dz; - if (nx < 0 || nz < 0 || nx >= static_cast(resX) || nz >= static_cast(resZ)) { - continue; - } - const u32 nidx = cellIndex(static_cast(nx), static_cast(nz), resX); - const f32 step = (dx != 0 && dz != 0) ? 1.414f : 1.0f; - if (current + step < distance[nidx]) { - distance[nidx] = current + step; - queue.emplace(static_cast(nx), static_cast(nz)); - } - } - } - } - - return distance; -} - -} // namespace - -std::vector buildMoistureMap(const TerrainHeightmap& heightmap, - const ECS::TerrainClimateSettings& climate, - const ECS::TerrainHydrologySettings& hydrology, - const RiverNetwork& rivers, - u32 seed) { - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - const u32 cellCount = resX * resZ; - std::vector moisture(cellCount, climate.baseHumidity); - const std::vector waterDistance = buildWaterDistanceField(heightmap, climate.seaLevel); - - const f32 windRad = climate.prevailingWindAngle * 3.14159265f / 180.0f; - const f32 windX = std::cos(windRad); - const f32 windZ = std::sin(windRad); - const f32 radius = std::max(hydrology.waterMoistureRadius, 1.0f); - - std::vector riverProximity(cellCount, 0.0f); - for (const RiverChannel& channel : rivers.channels) { - for (const auto& [px, pz] : channel.points) { - const u32 ix = static_cast(std::clamp(px, 0.0f, static_cast(resX - 1))); - const u32 iz = static_cast(std::clamp(pz, 0.0f, static_cast(resZ - 1))); - for (i32 dz = -3; dz <= 3; ++dz) { - for (i32 dx = -3; dx <= 3; ++dx) { - const i32 nx = static_cast(ix) + dx; - const i32 nz = static_cast(iz) + dz; - if (nx < 0 || nz < 0 || nx >= static_cast(resX) || nz >= static_cast(resZ)) { - continue; - } - const u32 nidx = cellIndex(static_cast(nx), static_cast(nz), resX); - const f32 dist = std::sqrt(static_cast(dx * dx + dz * dz)); - riverProximity[nidx] = std::max(riverProximity[nidx], 1.0f - dist / 4.0f); - } - } - } - } - - for (u32 z = 0; z < resZ; ++z) { - for (u32 x = 0; x < resX; ++x) { - const u32 idx = cellIndex(x, z, resX); - const f32 height = heightmap.sampleNormalized(x, z); - const f32 waterMoisture = - std::clamp(1.0f - waterDistance[idx] / radius, 0.0f, 1.0f); - const f32 elevationMoisture = height * 0.25f; - - f32 rainShadow = 0.0f; - for (u32 step = 1; step <= hydrology.rainShadowSteps; ++step) { - const i32 checkX = static_cast(x) - static_cast(windX * static_cast(step)); - const i32 checkZ = static_cast(z) - static_cast(windZ * static_cast(step)); - if (checkX < 0 || checkZ < 0 || checkX >= static_cast(resX) || - checkZ >= static_cast(resZ)) { - break; - } - const f32 blocker = heightmap.sampleNormalized(static_cast(checkX), static_cast(checkZ)); - if (blocker > 0.7f) { - rainShadow = std::max(rainShadow, (blocker - 0.7f) * 0.5f); - } - } - - const f32 noise = - sampleNoise2D(TerrainNoiseAlgorithm::Perlin, static_cast(x) * 0.08f, - static_cast(z) * 0.08f, seed + 811u); - - f32 value = waterMoisture * 0.35f + elevationMoisture * 0.2f + - (1.0f - rainShadow) * climate.baseHumidity * 0.25f + - riverProximity[idx] * 0.2f; - value += (noise - 0.5f) * 0.08f; - moisture[idx] = std::clamp(value, 0.0f, 1.0f); - } - } - - return moisture; -} - -RiverNetwork traceRiverNetwork(TerrainHeightmap& heightmap, - const ECS::TerrainHydrologySettings& hydrology, - const std::vector& moistureMap, u32 seed) { - RiverNetwork network; - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - if (resX < 3 || resZ < 3) return network; - - std::vector> candidates; - for (u32 z = 1; z + 1 < resZ; ++z) { - for (u32 x = 1; x + 1 < resX; ++x) { - const f32 height = heightmap.sampleNormalized(x, z); - if (height < hydrology.riverSourceMinHeight || height > hydrology.riverSourceMaxHeight) { - continue; - } - if (isSteep(heightmap, x, z)) continue; - const f32 moisture = moistureMap.empty() ? 0.5f : moistureMap[cellIndex(x, z, resX)]; - if (moisture < 0.55f) continue; - candidates.emplace_back(x, z); - } - } - - std::mt19937 rng(seed + 4243u); - if (candidates.size() > hydrology.maxRiverSources) { - std::shuffle(candidates.begin(), candidates.end(), rng); - candidates.resize(hydrology.maxRiverSources); - } - - std::vector visited(static_cast(resX) * static_cast(resZ), 0); - - for (const auto& [sx, sz] : candidates) { - RiverChannel channel; - u32 x = sx; - u32 z = sz; - f32 flow = moistureMap.empty() ? 0.08f : moistureMap[cellIndex(x, z, resX)] * 0.1f; - - for (u32 step = 0; step < resX + resZ; ++step) { - channel.points.emplace_back(static_cast(x), static_cast(z)); - const u32 idx = cellIndex(x, z, resX); - if (visited[idx] != 0) { - flow += 0.02f; - } - visited[idx] = 1; - - const f32 height = heightmap.sampleNormalized(x, z); - if (height <= hydrology.riverSourceMinHeight * 0.5f) break; - - const auto [nx, nz] = findSteepestDownhill(heightmap, x, z); - if (nx == x && nz == z) break; - - heightmap.setNormalized(x, z, std::clamp(height - hydrology.riverCarveStrength * flow, 0.0f, 1.0f)); - x = nx; - z = nz; - } - - channel.flowRate = flow; - if (channel.points.size() >= 4) { - network.channels.push_back(std::move(channel)); - } - } - - return network; -} - -void carveRiverChannels(TerrainHeightmap& heightmap, const RiverNetwork& network, - const ECS::TerrainHydrologySettings& hydrology) { - for (const RiverChannel& channel : network.channels) { - for (const auto& [px, pz] : channel.points) { - const u32 x = static_cast(std::clamp(px, 0.0f, static_cast(heightmap.resolutionX() - 1))); - const u32 z = static_cast(std::clamp(pz, 0.0f, static_cast(heightmap.resolutionZ() - 1))); - const f32 carve = hydrology.riverCarveStrength * channel.flowRate * 2.5f; - heightmap.setNormalized(x, z, - std::clamp(heightmap.sampleNormalized(x, z) - carve, 0.0f, 1.0f)); - } - } -} - -void depositSedimentInValleys(TerrainHeightmap& heightmap, const RiverNetwork& network, f32 strength) { - if (strength <= 0.0f) return; - const u32 resX = heightmap.resolutionX(); - const u32 resZ = heightmap.resolutionZ(); - - for (const RiverChannel& channel : network.channels) { - if (channel.points.size() < 2) continue; - const auto& end = channel.points.back(); - const u32 x = static_cast(std::clamp(end.first, 0.0f, static_cast(resX - 1))); - const u32 z = static_cast(std::clamp(end.second, 0.0f, static_cast(resZ - 1))); - heightmap.setNormalized(x, z, std::clamp(heightmap.sampleNormalized(x, z) + strength * 0.5f, 0.0f, 1.0f)); - - for (i32 dz = -2; dz <= 2; ++dz) { - for (i32 dx = -2; dx <= 2; ++dx) { - const i32 nx = static_cast(x) + dx; - const i32 nz = static_cast(z) + dz; - if (nx < 0 || nz < 0 || nx >= static_cast(resX) || nz >= static_cast(resZ)) { - continue; - } - const f32 dist = std::sqrt(static_cast(dx * dx + dz * dz)); - const f32 deposit = strength * (1.0f - dist / 3.0f) * 0.15f; - if (deposit > 0.0f) { - heightmap.setNormalized( - static_cast(nx), static_cast(nz), - std::clamp(heightmap.sampleNormalized(static_cast(nx), static_cast(nz)) + - deposit, - 0.0f, 1.0f)); - } - } - } - } -} - -Vec4 assignBiomeSplatWeights(f32 elevation, f32 moisture, f32 slope, f32 temperature, f32 blend, - f32 seaLevel) { - const f32 b = std::max(blend, 0.05f); - - f32 sand = 0.0f; - f32 grass = 0.0f; - f32 rock = 0.0f; - f32 snow = 0.0f; - - if (elevation < seaLevel + 0.04f) { - sand = 1.0f; - } else if (elevation < seaLevel + 0.08f) { - sand = smoothstep(seaLevel, seaLevel + 0.08f, elevation); - grass = 1.0f - sand; - } else if (elevation > 0.95f) { - snow = 1.0f; - } else if (elevation > 0.78f) { - snow = smoothstep(0.78f, 0.95f, elevation); - rock = slope * 0.6f + (1.0f - moisture) * 0.2f; - grass = std::max(0.0f, 1.0f - snow - rock) * moisture; - } else if (moisture < 0.25f && elevation < 0.7f) { - sand = smoothstep(0.25f, 0.1f, moisture) * (1.0f - slope * 0.5f); - rock = slope * smoothstep(0.2f, 0.2f + b, slope); - grass = std::max(0.0f, 1.0f - sand - rock) * smoothstep(0.15f, 0.35f, moisture); - } else if (moisture > 0.6f && elevation < 0.7f) { - grass = (1.0f - slope * 0.7f) * smoothstep(0.45f, 0.7f, moisture); - rock = slope * smoothstep(0.25f, 0.25f + b, slope); - sand = smoothstep(seaLevel + 0.02f, seaLevel + 0.1f, elevation) * 0.2f; - } else { - grass = (1.0f - slope * 0.55f) * smoothstep(0.35f, 0.7f, elevation) * - (1.0f - smoothstep(0.68f, 0.78f, elevation)); - rock = slope * smoothstep(0.2f, 0.2f + b, slope) + - smoothstep(0.65f, 0.75f, elevation) * 0.35f; - sand = (1.0f - moisture) * 0.25f; - } - - if (temperature < 0.35f && elevation > 0.62f) { - const f32 coldSnow = smoothstep(0.62f, 0.82f, elevation) * (1.0f - temperature); - snow = std::max(snow, coldSnow); - grass *= (1.0f - coldSnow); - } - - const f32 sum = grass + rock + sand + snow; - if (sum > 0.0001f) { - return Vec4(grass / sum, rock / sum, sand / sum, snow / sum); - } - return Vec4(0.0f, 0.0f, 0.0f, 1.0f); -} - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/TerrainHydrology.hpp b/src/terrain/generation/TerrainHydrology.hpp deleted file mode 100644 index 22caed8..0000000 --- a/src/terrain/generation/TerrainHydrology.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include "ecs/TerrainComponents.hpp" -#include "math/Vec4.hpp" -#include "terrain/TerrainHeightmap.hpp" -#include "terrain/generation/TerrainGeneratorTypes.hpp" - -#include - -namespace Caffeine::Terrain { - -struct RiverChannel { - std::vector> points; - f32 flowRate = 0.0f; -}; - -struct RiverNetwork { - std::vector channels; -}; - -std::vector buildMoistureMap(const TerrainHeightmap& heightmap, const ECS::TerrainClimateSettings& climate, - const ECS::TerrainHydrologySettings& hydrology, const RiverNetwork& rivers, - u32 seed); - -RiverNetwork traceRiverNetwork(TerrainHeightmap& heightmap, const ECS::TerrainHydrologySettings& hydrology, - const std::vector& moistureMap, u32 seed); - -void carveRiverChannels(TerrainHeightmap& heightmap, const RiverNetwork& network, - const ECS::TerrainHydrologySettings& hydrology); - -void depositSedimentInValleys(TerrainHeightmap& heightmap, const RiverNetwork& network, f32 strength); - -Vec4 assignBiomeSplatWeights(f32 elevation, f32 moisture, f32 slope, f32 temperature, f32 blend, - f32 seaLevel); - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/TerrainNoise.cpp b/src/terrain/generation/TerrainNoise.cpp deleted file mode 100644 index 50e8a24..0000000 --- a/src/terrain/generation/TerrainNoise.cpp +++ /dev/null @@ -1,310 +0,0 @@ -#include "terrain/generation/TerrainNoise.hpp" - -#include -#include -#include - -namespace Caffeine::Terrain { -namespace { - -constexpr f32 kInv255 = 1.0f / 255.0f; - -u32 hash2D(i32 x, i32 z, u32 seed) { - u32 n = static_cast(x) * 374761393u + static_cast(z) * 668265263u + seed * 362437u; - n = (n ^ (n >> 13u)) * 1274126177u; - n ^= n >> 16u; - return n; -} - -f32 fade(f32 t) { return t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f); } -f32 lerp(f32 a, f32 b, f32 t) { return a + (b - a) * t; } - -f32 grad2D(u32 hash, f32 x, f32 z) { - const u32 h = hash & 7u; - const f32 u = h < 4 ? x : z; - const f32 v = h < 4 ? z : x; - return ((h & 1u) ? -u : u) + ((h & 2u) ? -2.0f * v : 2.0f * v); -} - -f32 valueNoise2D(f32 x, f32 z, u32 seed) { - const i32 x0 = static_cast(std::floor(x)); - const i32 z0 = static_cast(std::floor(z)); - const i32 x1 = x0 + 1; - const i32 z1 = z0 + 1; - - const f32 tx = x - static_cast(x0); - const f32 tz = z - static_cast(z0); - - const f32 v00 = static_cast(hash2D(x0, z0, seed) & 0xFFFFu) / 65535.0f; - const f32 v10 = static_cast(hash2D(x1, z0, seed) & 0xFFFFu) / 65535.0f; - const f32 v01 = static_cast(hash2D(x0, z1, seed) & 0xFFFFu) / 65535.0f; - const f32 v11 = static_cast(hash2D(x1, z1, seed) & 0xFFFFu) / 65535.0f; - - const f32 sx = fade(tx); - const f32 sz = fade(tz); - const f32 ix0 = lerp(v00, v10, sx); - const f32 ix1 = lerp(v01, v11, sx); - return lerp(ix0, ix1, sz); -} - -f32 perlinNoise2D(f32 x, f32 z, u32 seed) { - const i32 x0 = static_cast(std::floor(x)); - const i32 z0 = static_cast(std::floor(z)); - const i32 x1 = x0 + 1; - const i32 z1 = z0 + 1; - - const f32 tx = x - static_cast(x0); - const f32 tz = z - static_cast(z0); - - const f32 g00 = grad2D(hash2D(x0, z0, seed), tx, tz); - const f32 g10 = grad2D(hash2D(x1, z0, seed), tx - 1.0f, tz); - const f32 g01 = grad2D(hash2D(x0, z1, seed), tx, tz - 1.0f); - const f32 g11 = grad2D(hash2D(x1, z1, seed), tx - 1.0f, tz - 1.0f); - - const f32 sx = fade(tx); - const f32 sz = fade(tz); - const f32 ix0 = lerp(g00, g10, sx); - const f32 ix1 = lerp(g01, g11, sx); - const f32 n = lerp(ix0, ix1, sz); - return std::clamp((n + 1.0f) * 0.5f, 0.0f, 1.0f); -} - -f32 simplexNoise2D(f32 xin, f32 zin, u32 seed) { - constexpr f32 F2 = 0.366025403f; - constexpr f32 G2 = 0.211324865f; - - const f32 s = (xin + zin) * F2; - const i32 i = static_cast(std::floor(xin + s)); - const i32 j = static_cast(std::floor(zin + s)); - - const f32 t = static_cast(i + j) * G2; - const f32 x0 = xin - (static_cast(i) - t); - const f32 z0 = zin - (static_cast(j) - t); - - i32 i1 = 0; - i32 j1 = 0; - if (x0 > z0) { - i1 = 1; - j1 = 0; - } else { - i1 = 0; - j1 = 1; - } - - const f32 x1 = x0 - static_cast(i1) + G2; - const f32 z1 = z0 - static_cast(j1) + G2; - const f32 x2 = x0 - 1.0f + 2.0f * G2; - const f32 z2 = z0 - 1.0f + 2.0f * G2; - - auto contrib = [&](f32 x, f32 z, i32 cx, i32 cz) { - f32 t = 0.5f - x * x - z * z; - if (t < 0.0f) return 0.0f; - t *= t; - return t * t * grad2D(hash2D(cx, cz, seed), x, z); - }; - - f32 n = contrib(x0, z0, i, j); - n += contrib(x1, z1, i + i1, j + j1); - n += contrib(x2, z2, i + 1, j + 1); - return std::clamp(n * 0.5f + 0.5f, 0.0f, 1.0f); -} - -f32 worleyNoise2D(f32 x, f32 z, u32 seed) { - const i32 cellX = static_cast(std::floor(x)); - const i32 cellZ = static_cast(std::floor(z)); - f32 minDist = 1.0f; - - for (i32 dz = -1; dz <= 1; ++dz) { - for (i32 dx = -1; dx <= 1; ++dx) { - const i32 cx = cellX + dx; - const i32 cz = cellZ + dz; - const u32 h = hash2D(cx, cz, seed); - const f32 px = static_cast(cx) + static_cast(h & 0xFFu) * kInv255; - const f32 pz = static_cast(cz) + static_cast((h >> 8) & 0xFFu) * kInv255; - const f32 distX = x - px; - const f32 distZ = z - pz; - const f32 dist = std::sqrt(distX * distX + distZ * distZ); - minDist = std::min(minDist, dist); - } - } - - return std::clamp(minDist, 0.0f, 1.0f); -} - -} // namespace - -f32 sampleNoise2D(TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed) { - switch (type) { - case TerrainNoiseAlgorithm::Perlin: - return perlinNoise2D(x, z, seed); - case TerrainNoiseAlgorithm::Simplex: - return simplexNoise2D(x, z, seed); - case TerrainNoiseAlgorithm::Value: - return valueNoise2D(x, z, seed); - case TerrainNoiseAlgorithm::Worley: - return worleyNoise2D(x, z, seed); - } - return 0.0f; -} - -f32 sampleFbm2D(TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed, u32 octaves, - f32 persistence, f32 lacunarity) { - octaves = std::max(1u, octaves); - persistence = std::clamp(persistence, 0.01f, 1.0f); - lacunarity = std::max(1.01f, lacunarity); - - f32 sum = 0.0f; - f32 amp = 1.0f; - f32 freq = 1.0f; - f32 maxAmp = 0.0f; - - for (u32 i = 0; i < octaves; ++i) { - sum += sampleNoise2D(type, x * freq, z * freq, seed + i * 131u) * amp; - maxAmp += amp; - amp *= persistence; - freq *= lacunarity; - } - - return maxAmp > 0.0f ? std::clamp(sum / maxAmp, 0.0f, 1.0f) : 0.0f; -} - -f32 sampleRidgedMultifractal2D(TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed, u32 octaves, - f32 persistence, f32 lacunarity, f32 ridgeOffset, f32 ridgeGain) { - octaves = std::max(1u, octaves); - persistence = std::clamp(persistence, 0.01f, 1.0f); - lacunarity = std::max(1.01f, lacunarity); - ridgeOffset = std::clamp(ridgeOffset, 0.1f, 2.0f); - ridgeGain = std::clamp(ridgeGain, 0.5f, 3.0f); - - f32 result = 0.0f; - f32 amplitude = 1.0f; - f32 frequency = 1.0f; - f32 weight = 1.0f; - f32 maxAmp = 0.0f; - - for (u32 i = 0; i < octaves; ++i) { - const f32 centered = sampleNoise2D(type, x * frequency, z * frequency, seed + i * 131u) * 2.0f - - 1.0f; - const f32 ridgeDelta = ridgeOffset - std::abs(centered); - f32 signal = std::max(0.0f, 1.0f - ridgeDelta * ridgeDelta); - signal *= weight; - result += signal * amplitude; - weight = std::pow(std::clamp(signal * ridgeGain, 0.0f, 1.0f), 0.8f); - maxAmp += amplitude; - amplitude *= persistence; - frequency *= lacunarity; - } - - return maxAmp > 0.0f ? std::clamp(result / maxAmp, 0.0f, 1.0f) : 0.0f; -} - -f32 sampleMultiplicativeNoise2D(TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed, u32 octaves, - f32 lacunarity, f32 contrast) { - octaves = std::max(1u, octaves); - lacunarity = std::max(1.01f, lacunarity); - contrast = std::clamp(contrast, 0.25f, 4.0f); - - f32 product = 1.0f; - f32 frequency = 1.0f; - for (u32 i = 0; i < octaves; ++i) { - const f32 layer = - std::pow(std::clamp(sampleNoise2D(type, x * frequency, z * frequency, seed + i * 97u), - 0.001f, 1.0f), - contrast); - product *= layer; - frequency *= lacunarity; - } - - return std::clamp(std::pow(product, 1.0f / static_cast(octaves)), 0.0f, 1.0f); -} - -f32 sampleHeightModel2D(TerrainHeightModel model, TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed, - u32 octaves, f32 persistence, f32 lacunarity, f32 ridgedBlend, - f32 ridgeOffset, f32 ridgeGain, f32 multiplicativeContrast) { - const f32 fbm = sampleFbm2D(type, x, z, seed, octaves, persistence, lacunarity); - switch (model) { - case TerrainHeightModel::RollingHills: - case TerrainHeightModel::FractalFBM: - return fbm; - case TerrainHeightModel::RidgedMountains: - return sampleRidgedMultifractal2D(type, x, z, seed + 53u, octaves, persistence, - lacunarity, ridgeOffset, ridgeGain); - case TerrainHeightModel::Multiplicative: - return sampleMultiplicativeNoise2D(type, x, z, seed + 211u, octaves, lacunarity, - multiplicativeContrast); - case TerrainHeightModel::Hybrid: { - const f32 ridged = sampleRidgedMultifractal2D(type, x, z, seed + 53u, octaves, - persistence, lacunarity, ridgeOffset, - ridgeGain); - const f32 blend = std::clamp(ridgedBlend, 0.0f, 1.0f); - return ridged * blend + fbm * (1.0f - blend); - } - case TerrainHeightModel::Combined: { - const f32 mult = sampleMultiplicativeNoise2D(type, x, z, seed + 211u, octaves, lacunarity, - multiplicativeContrast); - const f32 blend = std::clamp(ridgedBlend, 0.0f, 1.0f); - return fbm * (1.0f - blend) + mult * blend; - } - case TerrainHeightModel::DiamondSquare: - case TerrainHeightModel::SpectralFFT: - case TerrainHeightModel::HeightfieldMultiply: - return 0.5f; - } - return fbm; -} - -void warpDomain2D(f32 x, f32 z, u32 seed, f32 strength, f32& outX, f32& outZ) { - strength = std::max(0.0f, strength); - const f32 warpX = (sampleNoise2D(TerrainNoiseAlgorithm::Perlin, x + 17.0f, z + 3.0f, seed + 91u) - - 0.5f) * - 2.0f; - const f32 warpZ = (sampleNoise2D(TerrainNoiseAlgorithm::Perlin, x + 5.0f, z + 29.0f, seed + 173u) - - 0.5f) * - 2.0f; - outX = x + warpX * strength; - outZ = z + warpZ * strength; -} - -void warpDomainFractal2D(f32 x, f32 z, u32 seed, f32 warpScale, f32 strength, u32 octaves, - f32& outX, f32& outZ) { - octaves = std::max(1u, octaves); - warpScale = std::max(warpScale, 0.0001f); - strength = std::max(0.0f, strength); - - f32 warpX = 0.0f; - f32 warpZ = 0.0f; - f32 amplitude = 1.0f; - f32 frequency = 1.0f; - f32 maxAmp = 0.0f; - - for (u32 i = 0; i < octaves; ++i) { - const f32 sx = x * frequency / warpScale; - const f32 sz = z * frequency / warpScale; - warpX += (sampleNoise2D(TerrainNoiseAlgorithm::Perlin, sx + 17.0f, sz + 3.0f, seed + 1000u + i) - - 0.5f) * - 2.0f * amplitude; - warpZ += (sampleNoise2D(TerrainNoiseAlgorithm::Perlin, sx + 5.0f, sz + 29.0f, seed + 2000u + i) - - 0.5f) * - 2.0f * amplitude; - maxAmp += amplitude; - amplitude *= 0.5f; - frequency *= 2.0f; - } - - const f32 invMax = maxAmp > 0.0f ? 1.0f / maxAmp : 1.0f; - outX = x + warpX * invMax * strength; - outZ = z + warpZ * invMax * strength; -} - -void warpDomain2DMultiPass(f32 x, f32 z, u32 seed, f32 strength, u32 passes, f32& outX, - f32& outZ) { - passes = std::max(1u, passes); - outX = x; - outZ = z; - for (u32 pass = 0; pass < passes; ++pass) { - const f32 passStrength = strength * (pass == 0 ? 1.0f : 0.5f); - warpDomain2D(outX, outZ, seed + pass * 997u, passStrength, outX, outZ); - } -} - -} // namespace Caffeine::Terrain diff --git a/src/terrain/generation/TerrainNoise.hpp b/src/terrain/generation/TerrainNoise.hpp deleted file mode 100644 index 2803455..0000000 --- a/src/terrain/generation/TerrainNoise.hpp +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include "core/Types.hpp" - -namespace Caffeine::Terrain { - -enum class TerrainNoiseAlgorithm : u8 { - Perlin = 0, - Simplex = 1, - Value = 2, - Worley = 3, -}; - -enum class TerrainHeightModel : u8 { - RollingHills = 0, - FractalFBM = 1, - RidgedMountains = 2, - Multiplicative = 3, - Hybrid = 4, - Combined = 5, - DiamondSquare = 6, - SpectralFFT = 7, - HeightfieldMultiply = 8, -}; - -// Returns noise in [0, 1]. -f32 sampleNoise2D(TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed); - -// Fractal Brownian motion — combines octaves into [0, 1]. -f32 sampleFbm2D(TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed, u32 octaves, - f32 persistence, f32 lacunarity); - -// Ridged multifractal — sharp connected ridges in [0, 1]. -f32 sampleRidgedMultifractal2D(TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed, u32 octaves, - f32 persistence, f32 lacunarity, f32 ridgeOffset = 1.0f, - f32 ridgeGain = 1.5f); - -f32 sampleMultiplicativeNoise2D(TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed, u32 octaves, - f32 lacunarity, f32 contrast); - -f32 sampleHeightModel2D(TerrainHeightModel model, TerrainNoiseAlgorithm type, f32 x, f32 z, u32 seed, - u32 octaves, f32 persistence, f32 lacunarity, f32 ridgedBlend, - f32 ridgeOffset, f32 ridgeGain, f32 multiplicativeContrast); - -void warpDomain2D(f32 x, f32 z, u32 seed, f32 strength, f32& outX, f32& outZ); - -void warpDomainFractal2D(f32 x, f32 z, u32 seed, f32 warpScale, f32 strength, u32 octaves, - f32& outX, f32& outZ); - -void warpDomain2DMultiPass(f32 x, f32 z, u32 seed, f32 strength, u32 passes, f32& outX, - f32& outZ); - -} // namespace Caffeine::Terrain From cfbc012dc0711c0a25c06a8a7cd648e5d4743cba Mon Sep 17 00:00:00 2001 From: lyeepedro Date: Tue, 22 Sep 2026 18:02:04 +0100 Subject: [PATCH 2/5] Improve GPU viewport rendering quality, shadows, and texture LOD. Add Phong GPU pipeline with directional/point/spot shadows, centralized texture cache with distance-based quality tiers, HiDPI-aware viewport rendering, GPU wireframe mode, and camera-motion performance mitigations. Fix procedural mesh shading seams and document the full rendering session. Co-authored-by: Cursor --- CMakeLists.txt | 5 + apps/doppio/main.cpp | 20 + apps/runtime/main.cpp | 93 +-- docs/README.md | 6 + docs/assets/asset-manager.md | 80 ++- docs/assets/mesh-loading.md | 39 +- docs/editor/scene-viewport.md | 177 +++++ docs/plans/2026-09-21-rendering-roadmap.md | 44 +- .../2026-09-22-engine-pillars-roadmap.md | 195 +++++- .../2026-09-22-phong-gpu-handles-session.md | 115 ++++ ...9-22-viewport-rendering-quality-session.md | 198 ++++++ docs/rendering/materials-phong.md | 110 +++ docs/rendering/shadow-mapping.md | 93 +++ docs/rendering/texture-quality-lod.md | 178 +++++ docs/terrain/terrain-system.md | 23 +- src/assets/AssetHandle.hpp | 10 +- src/assets/AssetManager.cpp | 190 ++++-- src/assets/AssetManager.hpp | 103 ++- src/assets/AssetTypes.hpp | 18 +- src/assets/HotReloader.cpp | 11 +- src/assets/MeshLoader.cpp | 47 +- src/assets/MeshLoader.hpp | 44 ++ src/assets/MeshNormals.hpp | 48 ++ src/assets/MeshTypes.hpp | 15 + src/core/GameLoop.cpp | 2 + src/core/TimerScheduler.cpp | 74 ++ src/core/TimerScheduler.hpp | 44 ++ src/ecs/CameraComponents.hpp | 22 + src/ecs/Components.hpp | 39 ++ src/ecs/MeshComponents.hpp | 25 +- src/ecs/TerrainComponents.hpp | 2 + src/editor/AssetBrowser.cpp | 6 + src/editor/AssetBrowser.hpp | 1 + src/editor/AssetPreviewRenderer.cpp | 10 +- src/editor/AssetPreviewRenderer.hpp | 1 + src/editor/CameraPreviewPanel.cpp | 26 +- src/editor/CameraPreviewPanel.hpp | 1 + src/editor/ComponentRegistry.cpp | 3 + src/editor/EditorCameraMath.hpp | 31 + src/editor/EditorContext.hpp | 6 + src/editor/EditorIcons.cpp | 4 + src/editor/EditorPreferences.cpp | 8 + src/editor/EditorPreferences.hpp | 4 + src/editor/GameplayPreviewPanel.cpp | 14 +- src/editor/HierarchyPanel.cpp | 167 +++-- src/editor/HierarchyPanel.hpp | 1 + src/editor/ImGuiGpuTexture.hpp | 51 ++ src/editor/ImGuiIntegration.hpp | 1 + src/editor/InspectorPanel.cpp | 36 +- src/editor/PluginSymbolExports.cpp | 26 + src/editor/PluginSymbolExports.hpp | 8 + src/editor/PreviewRenderer.cpp | 3 +- src/editor/SceneEditor.cpp | 13 +- src/editor/SceneSerializer.cpp | 39 +- src/editor/SceneViewport.cpp | 318 +++++---- src/editor/SceneViewport.hpp | 14 + src/editor/SettingsPanel.cpp | 32 + src/editor/TransformGizmo.cpp | 10 +- src/render/GpuDirectionalShadowMap.cpp | 49 +- src/render/GpuDirectionalShadowMap.hpp | 16 +- src/render/GpuPointShadowMap.cpp | 13 +- src/render/GpuPointShadowMap.hpp | 7 + src/render/GpuProceduralMeshes.cpp | 292 +++++++- src/render/GpuProceduralMeshes.hpp | 4 + src/render/GpuSceneRenderer.cpp | 639 +++++++++++++++--- src/render/GpuSceneRenderer.hpp | 40 +- src/render/GpuSpotShadowMap.cpp | 67 ++ src/render/GpuSpotShadowMap.hpp | 42 ++ src/render/GpuTextureCache.cpp | 323 +++++++++ src/render/GpuTextureCache.hpp | 70 ++ src/render/TextureQuality.hpp | 64 ++ src/render/shaders/scene_lit.frag | 184 ++++- src/render/shaders/scene_lit.vert | 11 +- src/render/shaders/terrain_lit.frag | 173 ++++- src/rhi/RenderDevice.cpp | 6 +- src/rhi/RenderDevice.hpp | 8 +- src/runtime/RuntimeSceneRenderer.cpp | 96 ++- src/runtime/RuntimeSceneRenderer.hpp | 32 +- src/scene/Camera3DSystem.cpp | 79 +++ src/scene/Camera3DSystem.hpp | 22 + src/scene/CpuDirectionalShadowMap.cpp | 9 + src/scene/LightingSystem.cpp | 43 +- src/scene/LightingSystem.hpp | 9 +- src/terrain/TerrainCache.cpp | 55 +- src/terrain/TerrainCache.hpp | 3 + src/terrain/TerrainGpuTextures.cpp | 136 ++-- src/terrain/TerrainGpuTextures.hpp | 3 +- tests/CMakeLists.txt | 42 +- tests/cap_test_helpers.hpp | 120 ++++ tests/editor/phase1_integration_test.cpp | 47 +- tests/test_animation.cpp | 6 +- tests/test_assetmanager.cpp | 86 +++ tests/test_cap_loader.cpp | 205 ++---- tests/test_scenemanager.cpp | 84 ++- 94 files changed, 5068 insertions(+), 921 deletions(-) create mode 100644 docs/editor/scene-viewport.md create mode 100644 docs/plans/2026-09-22-phong-gpu-handles-session.md create mode 100644 docs/plans/2026-09-22-viewport-rendering-quality-session.md create mode 100644 docs/rendering/materials-phong.md create mode 100644 docs/rendering/shadow-mapping.md create mode 100644 docs/rendering/texture-quality-lod.md create mode 100644 src/assets/MeshNormals.hpp create mode 100644 src/core/TimerScheduler.cpp create mode 100644 src/core/TimerScheduler.hpp create mode 100644 src/editor/EditorCameraMath.hpp create mode 100644 src/editor/ImGuiGpuTexture.hpp create mode 100644 src/editor/PluginSymbolExports.cpp create mode 100644 src/editor/PluginSymbolExports.hpp create mode 100644 src/render/GpuSpotShadowMap.cpp create mode 100644 src/render/GpuSpotShadowMap.hpp create mode 100644 src/render/GpuTextureCache.cpp create mode 100644 src/render/GpuTextureCache.hpp create mode 100644 src/render/TextureQuality.hpp create mode 100644 src/scene/Camera3DSystem.cpp create mode 100644 src/scene/Camera3DSystem.hpp create mode 100644 tests/cap_test_helpers.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 834954f..dcd15b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,7 @@ endif() # ═══════════════════════════════════════════════════════════════════ add_library(caffeine-core src/core/Timer.cpp + src/core/TimerScheduler.cpp src/core/GameLoop.cpp src/core/io/BlobLoader.cpp src/core/io/CafWriter.cpp @@ -71,6 +72,7 @@ add_library(caffeine-core src/scene/EnvironmentSystem.cpp src/scene/CpuDirectionalShadowMap.cpp src/scene/LightingSystem.cpp + src/scene/Camera3DSystem.cpp src/scene/TerrainSystem.cpp src/terrain/TerrainHeightmap.cpp src/terrain/TerrainMeshBuilder.cpp @@ -174,9 +176,11 @@ else() src/rhi/CommandBuffer.cpp src/render/ShaderBytecode.cpp src/render/ShaderCompiler.cpp + src/render/GpuTextureCache.cpp src/render/GpuSceneRenderer.cpp src/render/GpuPointShadowMap.cpp src/render/GpuDirectionalShadowMap.cpp + src/render/GpuSpotShadowMap.cpp src/render/GpuProceduralMeshes.cpp ) target_link_libraries(caffeine-core PUBLIC SDL3::SDL3 ${CMAKE_DL_LIBS}) @@ -464,6 +468,7 @@ with open(sys.argv[1], 'w') as f: src/editor/EntityDebugger.cpp src/editor/PluginSystem.cpp src/editor/PluginServiceRegistry.cpp + src/editor/PluginSymbolExports.cpp src/editor/TestUIMapper.cpp src/editor/TestRequestHandler.cpp src/editor/AssetCooker.cpp diff --git a/apps/doppio/main.cpp b/apps/doppio/main.cpp index 595336f..7c8cf40 100644 --- a/apps/doppio/main.cpp +++ b/apps/doppio/main.cpp @@ -26,10 +26,21 @@ #include #include #include +#include + +namespace { +volatile std::sig_atomic_t g_interruptRequested = 0; + +void onInterrupt(int) { + g_interruptRequested = 1; +} +} // namespace int main(int argc, char** argv) { Caffeine::Debug::installCrashHandler(); Caffeine::Debug::setCrashBreadcrumb("main"); + std::signal(SIGINT, onInterrupt); + std::signal(SIGTERM, onInterrupt); std::string scenePath; std::string projectPath; bool testMode = false; @@ -180,6 +191,9 @@ int main(int argc, char** argv) { projectDialog.init(); while (projectDialog.isOpen() && !projectSelected) { + if (g_interruptRequested) { + break; + } SDL_Event event; while (SDL_PollEvent(&event)) { imgui.processEvent(event); @@ -249,6 +263,12 @@ int main(int argc, char** argv) { bool running = true; Uint64 lastFrameTime = SDL_GetTicksNS(); while (running && editor.isOpen()) { + if (g_interruptRequested) { + editor.onQuitRequested(); + running = false; + break; + } + SDL_Event event; while (SDL_PollEvent(&event)) { imgui.processEvent(event); diff --git a/apps/runtime/main.cpp b/apps/runtime/main.cpp index 4df836f..0f62a92 100644 --- a/apps/runtime/main.cpp +++ b/apps/runtime/main.cpp @@ -22,7 +22,6 @@ #include "ui/UIRenderer.hpp" #include "scene/EnvironmentSystem.hpp" #include "procedural/ProceduralWorldSystem.hpp" -#include "render/GpuSceneRenderer.hpp" #include "render/SkyboxRenderer.hpp" #include "math/Mat4.hpp" #include "math/Quat.hpp" @@ -139,47 +138,6 @@ void pumpRuntimeInput(Caffeine::Input::InputManager& input) { constexpr float kDegToRad = 3.14159265f / 180.0f; Caffeine::Render::SkyboxRenderer g_skyboxRenderer; -Caffeine::Render::GpuSceneRenderer g_gpuScene; -Caffeine::RHI::Texture* g_gpuColor = nullptr; -Caffeine::RHI::Texture* g_gpuDepth = nullptr; -uint32_t g_gpuW = 0; -uint32_t g_gpuH = 0; - -void destroyGpuCanvas(Caffeine::RHI::RenderDevice& device) { - if (g_gpuColor) { - device.destroyTexture(g_gpuColor); - g_gpuColor = nullptr; - } - if (g_gpuDepth) { - device.destroyTexture(g_gpuDepth); - g_gpuDepth = nullptr; - } - g_gpuW = 0; - g_gpuH = 0; -} - -void resizeGpuCanvas(Caffeine::RHI::RenderDevice& device, uint32_t width, uint32_t height) { - if (width < 1 || height < 1) return; - if (g_gpuW == width && g_gpuH == height && g_gpuColor && g_gpuDepth) return; - destroyGpuCanvas(device); - - Caffeine::RHI::TextureDesc colorDesc; - colorDesc.width = width; - colorDesc.height = height; - colorDesc.format = Caffeine::RHI::TextureFormat::R8G8B8A8_UNORM; - colorDesc.usage = Caffeine::RHI::TextureUsage::Sampler | Caffeine::RHI::TextureUsage::ColorTarget; - g_gpuColor = device.createTexture(colorDesc); - - Caffeine::RHI::TextureDesc depthDesc; - depthDesc.width = width; - depthDesc.height = height; - depthDesc.format = Caffeine::RHI::TextureFormat::D32_FLOAT; - depthDesc.usage = Caffeine::RHI::TextureUsage::DepthStencil; - g_gpuDepth = device.createTexture(depthDesc); - - g_gpuW = width; - g_gpuH = height; -} Caffeine::Mat4 buildLocalMatrix3D(const Caffeine::ECS::Position3D* p, const Caffeine::ECS::Rotation3D* r, @@ -357,31 +315,20 @@ void renderGameView(Caffeine::ECS::World& world, Caffeine::Editor::EditorContext IM_COL32(20, 20, 24, 255), IM_COL32(34, 38, 50, 255), IM_COL32(34, 38, 50, 255)); } - bool drewGpu = false; - if (cmd && g_gpuScene.isReady()) { - const uint32_t w = static_cast(std::max(panelSize.x, 8.0f)); - const uint32_t h = static_cast(std::max(panelSize.y, 8.0f)); - resizeGpuCanvas(device, w, h); - Caffeine::Render::GpuSceneCamera gpuCam; - gpuCam.position = camPos; - gpuCam.focus = camPos + entityForward(world, cameraEntity).normalized(); - gpuCam.view = view; - gpuCam.proj = proj; - gpuCam.fovRad = cam3D->fov * kDegToRad; - gpuCam.nearClip = std::max(cam3D->nearClip, 0.05f); - gpuCam.farClip = std::max(cam3D->farClip, 50.0f); - const uint32_t drawn = - g_gpuScene.renderWithCamera(cmd, world, gpuCam, g_gpuColor, g_gpuDepth, w, h, projectRoot); - if (drawn > 0 && g_gpuColor && g_gpuColor->handle) { - dl->AddImage(reinterpret_cast(g_gpuColor->handle), origin, - ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), ImVec2(0, 0), - ImVec2(1, 1)); - drewGpu = true; - } - } - if (!drewGpu) { - renderer.render(world, ctx, dl, vp, camPos, origin, panelSize, cameraEntity); - } + Caffeine::Render::GpuSceneCamera gpuCam; + gpuCam.position = camPos; + gpuCam.focus = camPos + entityForward(world, cameraEntity).normalized(); + gpuCam.view = view; + gpuCam.proj = proj; + gpuCam.fovRad = cam3D->fov * kDegToRad; + gpuCam.nearClip = std::max(cam3D->nearClip, 0.05f); + gpuCam.farClip = std::max(cam3D->farClip, 50.0f); + +#ifdef CF_HAS_SDL3 + renderer.setFrameCommandBuffer(cmd); +#endif + renderer.render(world, ctx, dl, vp, camPos, origin, panelSize, cameraEntity, projectRoot, + &gpuCam); Caffeine::UI::drawWidgets(world, dl, origin, panelSize); @@ -450,11 +397,12 @@ int main(int argc, char** argv) { } ImGui_ImplSDLGPU3_CreateDeviceObjects(); - if (!g_gpuScene.init(&device)) { - std::fprintf(stderr, "GpuSceneRenderer::init failed — CPU mesh fallback\n"); - } - Caffeine::Runtime::RuntimeSceneRenderer renderer; +#ifdef CF_HAS_SDL3 + if (!renderer.init(&device)) { + std::fprintf(stderr, "RuntimeSceneRenderer GPU init failed — CPU mesh fallback\n"); + } +#endif Caffeine::Editor::EditorContext editorCtx; editorCtx.viewMode = Caffeine::Editor::EditorContext::ViewMode::Mode3D; #endif @@ -608,8 +556,7 @@ int main(int argc, char** argv) { scriptEngine.shutdown(); #ifdef CF_HAS_IMGUI g_skyboxRenderer.releaseGpuTextures(); - g_gpuScene.shutdown(); - destroyGpuCanvas(device); + renderer.shutdown(); imgui.shutdown(); #endif device.shutdown(); diff --git a/docs/README.md b/docs/README.md index dc7a274..9542dde 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ |-----------|-----------| | **Engine Pillars** | [`plans/2026-09-22-engine-pillars-roadmap.md`](plans/2026-09-22-engine-pillars-roadmap.md) | Pilares fundamentais: Phong 3D, Bullet3, OpenAL, ECS, handles, networking, PBR, animação, scripting, Android | | **Rendering Roadmap** | [`plans/2026-09-21-rendering-roadmap.md`](plans/2026-09-21-rendering-roadmap.md) | Plano P0–P6: GPU único, texturas, LOD, PBR, sombras, reflexos, volumétricos | +| **Sessão 2026-09-22 (Phong)** | [`plans/2026-09-22-phong-gpu-handles-session.md`](plans/2026-09-22-phong-gpu-handles-session.md) | Changelog: Phong GPU, sombras, handles, normal maps, inspector | +| **Sessão 2026-09-22 (Viewport)** | [`plans/2026-09-22-viewport-rendering-quality-session.md`](plans/2026-09-22-viewport-rendering-quality-session.md) | HiDPI, wireframe GPU, LOD texturas, performance câmara | ### Engine Core @@ -58,6 +60,9 @@ | **Batch Renderer** | [`rendering/batch-renderer.md`](rendering/batch-renderer.md) | Sprite batching, Texture Atlas, Radix Sort, Persistent Mapped Buffers | | **Camera 2D** | [`rendering/camera-2d.md`](rendering/camera-2d.md) | Projeção ortográfica, follow, shake, bounds | | **Camera 3D** | [`rendering/camera-3d.md`](rendering/camera-3d.md) | Projeção perspectiva, lookAt, frustum culling | +| **Materiais Phong** | [`rendering/materials-phong.md`](rendering/materials-phong.md) | Iluminação Phong, normal maps, shininess (meshes + terreno) | +| **Shadow Mapping** | [`rendering/shadow-mapping.md`](rendering/shadow-mapping.md) | Sombras GPU direcionais e pontuais | +| **Texture Quality LOD** | [`rendering/texture-quality-lod.md`](rendering/texture-quality-lod.md) | LOD de texturas por distância aos visualizadores | ### Assets @@ -124,6 +129,7 @@ | Módulo | Documentação | Descrição | |--------|-------------|-----------| | **Scene Editor** | [`editor/scene-editor.md`](editor/scene-editor.md) | Entity Inspector, Hierarchy, Gizmos, drag-and-drop | +| **Scene Viewport** | [`editor/scene-viewport.md`](editor/scene-viewport.md) | Renderização 3D GPU, HiDPI, wireframe, LOD texturas | ### Scripting diff --git a/docs/assets/asset-manager.md b/docs/assets/asset-manager.md index f38161d..de30892 100644 --- a/docs/assets/asset-manager.md +++ b/docs/assets/asset-manager.md @@ -52,10 +52,11 @@ namespace Caffeine::Assets { // LoadStatus — estado de carregamento de um asset // ============================================================================ enum class LoadStatus : u8 { - Unloaded, + Pending, // slot created, load not yet scheduled Loading, - Loaded, - Failed + Ready, // payload resolved and safe to use + Failed, + Invalid // slot invalidado (LRU/GC); generation foi incrementada }; // ============================================================================ @@ -94,21 +95,24 @@ struct CacheStats { u32 textureCount; u32 audioCount; u32 pendingJobs; + u32 evictedCount; // entradas removidas por LRU desde o boot f32 cacheHitRate; }; // ============================================================================ -// AssetHandle — handle RAII com ref counting +// AssetHandle — handle RAII com ref counting + generation counter // // - Copiar um handle incrementa o ref count do asset. // - O destructor decrementa o ref count. // - collectGarbage() descarrega assets com refCount == 0. +// - invalidateEntry() (LRU/GC) incrementa generation → handles antigos ficam Invalid. +// - Hot-reload NÃO incrementa generation — handles activos continuam válidos. // ============================================================================ template class AssetHandle { public: AssetHandle(); - AssetHandle(AssetManager* mgr, u32 id); + AssetHandle(AssetManager* mgr, u32 id, u16 generation); ~AssetHandle(); AssetHandle(const AssetHandle& o); @@ -117,15 +121,18 @@ public: AssetHandle(AssetHandle&& o) noexcept; AssetHandle& operator=(AssetHandle&& o) noexcept; - bool isValid() const; // handle aponta para uma entrada válida - bool isReady() const; // asset foi carregado (status == Loaded) - const T* get() const; // nullptr se não estiver pronto ainda + bool isValid() const; // id + generation coincidem com a entrada + bool isReady() const; // isValid() && status == Ready + const T* get() const; // nullptr se não estiver pronto ou generation stale explicit operator bool() const; - u32 id() const; + u32 id() const; + u16 generation() const; }; +using AssetInvalidationCallback = void (*)(const InvalidatedAsset& info, void* userData); + // ============================================================================ // AssetManager — gerenciador central // ============================================================================ @@ -149,8 +156,12 @@ public: void collectGarbage(); // descarrega assets sem referências CacheStats cacheStats() const; // estatísticas do cache + // ── Invalidation callbacks (GPU textures, etc.) ──────────── + void registerInvalidationCallback(AssetInvalidationCallback cb, void* userData); + void unregisterInvalidationCallback(AssetInvalidationCallback cb, void* userData); + // ── Frame tick ───────────────────────────────────────────── - void tick(u64 frameIndex = 0); // atualiza frame index + hot-reload + void tick(u64 frameIndex = 0); // atualiza frame index + hot-reload + LRU private: // incRef / decRef / getStatus — usados por AssetHandle @@ -184,6 +195,7 @@ AssetEntry ├── payload : const void* (pixels brutos, PCM, bytecode) ├── resolved : ResolvedData { Texture, AudioClip, ShaderBlob } ├── refCount : std::atomic +├── generation : u16 (incrementada em invalidateEntry, não em hot-reload) ├── sizeBytes : u64 └── lastAccessFrame : u64 ``` @@ -198,13 +210,33 @@ Em builds **debug** (`#ifdef CF_DEBUG`): 1. `tick()` chama `checkHotReload()` a cada frame 2. Para cada asset carregado, compara `last_write_time` do arquivo no disco -3. Se mudou: descarrega o asset antigo e recarrega via `loadInternal` -4. `AssetHandle::get()` automaticamente aponta para a nova versão +3. Se mudou: `clearEntryData()` + `loadInternal()` **sem** incrementar `generation` +4. `AssetHandle::get()` continua válido e aponta para o payload recarregado **Zero overhead em release:** O hot-reload é compilado condicionalmente — não existe em builds de produção. --- +## LRU Eviction e Invalidação + +Quando `totalCachedBytes > maxCacheBytes` (após load ou `collectGarbage`): + +1. `evictLruToFitBudget()` escolhe entradas com `refCount == 0` e menor `lastAccessFrame` +2. `invalidateEntry()` limpa payload, incrementa `generation`, notifica callbacks +3. Handles antigos passam a `LoadStatus::Invalid`; `get()` devolve `nullptr` + +**Callbacks:** subsistemas GPU (ex.: `TerrainGpuTextureCache`) registam `registerInvalidationCallback` para libertar texturas quando o asset correspondente é evicted. + +```cpp +void onAssetInvalidated(const InvalidatedAsset& info, void* userData) { + if (info.type == AssetType::Texture) { + // libertar GPUTexture associada a info.id / info.generation + } +} +``` + +--- + ## Exemplos de Uso ```cpp @@ -247,7 +279,7 @@ const Texture* atlas = uiAtlas.get(); // pronto imediatamente ## Testes -12 casos de teste em `tests/test_assetmanager.cpp`: +21 casos de teste em `tests/test_assetmanager.cpp` (inclui HotReloader): | Teste | O que verifica | |-------|---------------| @@ -255,16 +287,19 @@ const Texture* atlas = uiAtlas.get(); // pronto imediatamente | `AssetManager - loadSync Texture` | Textura carregada, campos width/height/pixels corretos | | `AssetManager - loadSync AudioClip` | AudioClip carregado, campos sampleRate/channels corretos | | `AssetManager - loadSync ShaderBlob` | ShaderBlob carregado, campos stage/bytecode corretos | -| `AssetManager - second load is cache hit` | Cache hit rate > 0 no segundo load | +| `AssetManager - second load of same path is cache hit` | Cache hit rate > 0 no segundo load | | `AssetManager - cacheStats counts textures and audio` | textureCount/audioCount corretos | | `AssetManager - collectGarbage unloads unreferenced assets` | Asset descarregado após handle sair de escopo | -| `AssetManager - collectGarbage does not unload referenced` | Asset mantido enquanto handle existe | -| `AssetManager - handle copy increments ref` | Cópia de handle mantém asset vivo | -| `AssetManager - loadAsync becomes ready` | Async loading via JobSystem completa | -| `AssetManager - failed load returns Failed status` | Path inexistente retorna Failed | +| `AssetManager - collectGarbage does not unload referenced assets` | Asset mantido enquanto handle existe | +| `AssetManager - handle copy increments ref, original still valid` | Cópia de handle mantém asset vivo | +| `AssetManager - loadAsync returns handle that becomes ready` | Async loading via JobSystem completa | +| `AssetManager - failed load returns handle with Failed status` | Path inexistente retorna Failed | +| `AssetManager - stale handle after eviction returns nullptr` | Generation stale após LRU | +| `AssetManager - invalidation callback fires on collectGarbage` | Callback notificado em invalidação | +| `AssetManager - LRU evicts unreferenced assets when over budget` | Eviction por `maxCacheBytes` | +| `AssetManager - reload after eviction works with new generation` | Novo load após eviction | | `AssetManager - tick advances frame index` | tick() não crasha | - -**Total:** 46 assertions. +| `HotReloader - *` | Lifecycle e integração com reload | --- @@ -288,7 +323,10 @@ const Texture* atlas = uiAtlas.get(); // pronto imediatamente | `loadAsync` aceita `JobSystem*` nullable | Fallback sync quando não há JobSystem (útil em testes) | | Zero-copy resolved views | Sem overhead de conversão; structs são preenchidas no load | | `collectGarbage()` explícito | Evita pausas inesperadas; jogo controla quando descarregar | -| `tick()` como ponto único | Unifica frame counter e hot-reload num só método | +| Generation counter | Detecta handles stale após LRU sem dangling pointers | +| Hot-reload sem bump de generation | Handles activos sobrevivem a reload em debug | +| LRU por `lastAccessFrame` | Pressão de memória previsível com `maxCacheMB` | +| `tick()` como ponto único | Unifica frame counter, hot-reload e eviction num só método | | Hot-reload só em CF_DEBUG | Zero custo em release | | `std::unordered_map` + `std::vector` | Simplicidade sobre performance de cache; HashMap customizado seria over-engineering nesta fase | | `std::mutex` (não lock-free) | Contenção baixa; simplicidade de implementação | diff --git a/docs/assets/mesh-loading.md b/docs/assets/mesh-loading.md index 6019a4a..72f0208 100644 --- a/docs/assets/mesh-loading.md +++ b/docs/assets/mesh-loading.md @@ -12,6 +12,10 @@ Carregamento de malhas 3D nos formatos `.obj` e `.gltf`, convertendo para o formato interno `.caf` (Mesh). Shaders HLSL (Windows Direct3D) / GLSL (Linux/macOS OpenGL via SDL_GPU). +**Normal mapping:** após import, `computeMeshTangents()` preenche `Vertex3D::tangent` (vec4 com handedness em `.w`). O editor liga normal maps via `MeshFilterComponent::customNormalPath` no shader Phong [`materials-phong.md`](../rendering/materials-phong.md). + +**Smooth shading:** meshes importadas sem normais recebem `computeSmoothNormals()` em `MeshLoader.cpp`. Primitivas GPU procedurais (`GpuProceduralMeshes.cpp`) usam **normais analíticas** — não chamar `computeSmoothNormals()` nelas (evita costuras em esfera/torus/cápsula). Ver [`plans/2026-09-22-viewport-rendering-quality-session.md`](../plans/2026-09-22-viewport-rendering-quality-session.md). + --- ## API Implementada @@ -145,22 +149,32 @@ public: --- -## Componente ECS para Mesh +## Componentes ECS (editor / runtime) ```cpp -namespace Caffeine::Components { +namespace Caffeine::ECS { -struct MeshRenderer { - FixedString<128> meshPath; - Assets::AssetHandle mesh; - Assets::Material3D* material = nullptr; - bool castShadows = true; - bool receiveShadows = true; +struct MeshFilterComponent { + MeshPrimitive primitive = MeshPrimitive::Cube; + std::string customMeshPath; + std::string customTexturePath; + std::string customNormalPath; // normal map (relativo ao projeto) + std::string customMaterialPath; + f32 shininess = 32.0f; // expoente especular Phong +}; + +struct MeshRendererComponent { + std::string meshPath; + std::string materialPath; + bool castShadows = true; + bool receiveShadows = true; }; -} // namespace Caffeine::Components +} // namespace Caffeine::ECS ``` +**Inspector:** campos Albedo Texture, Normal Map e Shininess em `MeshFilterComponent`. Serialização em `SceneSerializer` (blob após `customMaterialPath`). + --- ## Exemplos de Uso @@ -186,7 +200,9 @@ meshSystem.onUpdate(world, 0.016f); ## Critério de Aceitação -- [x] Vertex3D struct definido (position, normal, texcoord, tangent) +- [x] Vertex3D struct definido (position, normal, texcoord, tangent vec4) +- [x] `computeMeshTangents()` após import OBJ/glTF +- [x] Normal map + shininess em `MeshFilterComponent` (GPU Phong) - [x] Mesh3D struct com vertices, indices, subMeshes, bounds - [x] MeshLoader::fromMemory cria mesh de vértices e índices - [x] MeshLoader::parseOBJ lê formato .obj (v, vt, vn, f) @@ -219,4 +235,5 @@ meshSystem.onUpdate(world, 0.016f); - [`docs/architecture_specs.md`](../architecture_specs.md) — §15 Math Library - [`rendering/rhi.md`](../rendering/rhi.md) — Buffer/Shader creation -- [Índice de Tópicos Transversais]() +- [`rendering/texture-quality-lod.md`](../rendering/texture-quality-lod.md) — LOD de texturas no render GPU +- [`plans/2026-09-22-viewport-rendering-quality-session.md`](../plans/2026-09-22-viewport-rendering-quality-session.md) — costuras de shading em primitivas diff --git a/docs/editor/scene-viewport.md b/docs/editor/scene-viewport.md new file mode 100644 index 0000000..f0e0fd8 --- /dev/null +++ b/docs/editor/scene-viewport.md @@ -0,0 +1,177 @@ +# Scene Viewport — Renderização 3D + +> **Namespace:** `Caffeine::Editor::SceneViewport` +> **Ficheiro:** `src/editor/SceneViewport.cpp` +> **Status:** ✅ Implementado (GPU-first em 3D) + +--- + +## Visão geral + +O **Scene Viewport** renderiza a cena 3D num target offscreen GPU e compõe o resultado numa janela ImGui. Overlays (grid, gizmos, frustums de câmara, wireframe de seleção) usam `ImDrawList` com projeção 3D consistente. + +--- + +## Modos de preview de mesh + +| Modo | GPU | CPU overlay | +|------|-----|-------------| +| **Textured** | `GpuSceneRenderer` Phong + sombras (se estável) | Só entidade **selecionada** (contorno) | +| **Wireframe** | `GpuSceneRenderer` pipeline `FillMode::Line` | Só entidade **selecionada** | + +Alternar: botão **Textured / Wireframe** na barra do viewport. + +**Importante:** em Textured, o CPU **não** redesenha meshes não seleccionados — evita double render e queda de FPS. + +--- + +## Pipeline por frame (3D) + +``` +1. resizeCanvasIfNeeded(imguiFramebufferSize(viewportSize)) // HiDPI, cap 1920px +2. syncTerrainMeshes(world) +3. GpuSceneRenderer::render(cmd, world, ctx, colorTarget, depthTarget, opts) + ├── gatherMeshDraws (frustum cull, distância a visualizadores) + ├── shadow passes (se enableShadows) + └── renderMeshes (Phong ou wireframe) +4. ImGui: skybox → grid (atrás) → AddImage(GPU) → overlays + ├── drawEmptyEntities (marcadores, seleção) + ├── drawCameraFrustums + ├── drawLightGizmos + └── TransformGizmo +``` + +Ordem de desenho garante que o grid fica **atrás** do GPU (desenhado antes do `AddImage`), e gizmos **à frente**. + +--- + +## Câmara do editor + +| Parâmetro | Fonte | +|-----------|--------| +| Posição | `editorCameraPosition(camYaw, camPitch, camDistance, camFocus)` | +| View | `Mat4::lookAt(camPos, camFocus, up)` | +| FOV | 60° (1.0472 rad) — alinhado com `GpuSceneRenderer` | +| Projeção overlay | `computeVP3D(viewportSize, ctx)` | + +Ver [`EditorCameraMath.hpp`](../../src/editor/EditorCameraMath.hpp). + +--- + +## Projeção de linhas 3D + +Funções estáticas em `SceneViewport`: + +```cpp +static bool projectWorldToViewport(const Mat4& vp, Vec3 worldPos, + ImVec2 origin, ImVec2 viewportSize, ImVec2& screenOut); +static void drawViewportWorldLine(ImDrawList* dl, const Mat4& vp, ImVec2 origin, + ImVec2 viewportSize, Vec3 a, Vec3 b, + ImU32 color, float thickness); +``` + +- Endpoints fora do ecrã são permitidos (ImGui clipa) +- Se um endpoint está atrás do near plane (`w <= 0.1`), o segmento é **clipped** em clip space — evita linhas para coordenadas inválidas + +Usado por: grid 3D, frustums `Camera3D`, wireframe CPU, anéis de primitivas. + +--- + +## HiDPI e resolução + +```cpp +// ImGuiGpuTexture.hpp +ImVec2 fb = imguiFramebufferSize(viewportSize, 1920); +resizeCanvasIfNeeded((u32)fb.x, (u32)fb.y); +``` + +- `DisplayFramebufferScale` do ImGui reflecte DPI do monitor +- Cap no maior lado evita render 4K+ no painel do editor +- `AddImage` estica o target GPU para o tamanho lógico do ImGui (downscale suave) + +--- + +## Opções GPU (`GpuSceneRenderOptions`) + +Construídas em `SceneViewport::render()`: + +| Campo | Comportamento no viewport | +|-------|---------------------------| +| `wireframeMeshes` | `true` em modo Wireframe | +| `enableShadows` | `false` se câmara em movimento rápido (`m_editorCamMotion >= 1.25`) | +| `textureQuality` | Copiado de `EditorContext` | +| `textureQualityViewers` | Câmara editor + posições de todas `Camera3DComponent` | + +### Detecção de movimento de câmara + +```cpp +frameMotion = |Δyaw|*14 + |Δpitch|*14 + |Δpos| +m_editorCamMotion = m_editorCamMotion * 0.75 + frameMotion * 0.25 +``` + +Sombras voltam quando a câmara estabiliza (~200 ms). + +--- + +## Anti-aliasing de overlays + +No início do draw do viewport: + +```cpp +drawList->Flags |= ImDrawListFlags_AntiAliasedLines; +drawList->Flags |= ImDrawListFlags_AntiAliasedFill; +``` + +Afecta grid, gizmos e linhas ImGui — **não** substitui MSAA no pass GPU (pendente). + +--- + +## Camera Preview GPU + +`renderCameraPreviewGpu()` usa o mesmo `GpuSceneRenderer` com: + +- `textureQualityViewers = { cameraPos }` (só a câmara do painel) +- Targets `m_previewColorTarget` / `m_previewDepthTarget` + +--- + +## Preferências relacionadas + +**Settings → Viewport:** + +- Move speed / Orbit sensitivity +- Show grid +- **Texture quality** (raio, falloff, mínimo) — ver [`texture-quality-lod.md`](../rendering/texture-quality-lod.md) + +Campos em `EditorContext` sincronizados via `SettingsPanel::applyPreferencesToContext()`. + +--- + +## Ficheiros principais + +| Ficheiro | Responsabilidade | +|----------|------------------| +| `src/editor/SceneViewport.cpp` | UI, input câmara, composição | +| `src/editor/SceneViewport.hpp` | API pública, raster CPU auxiliar | +| `src/editor/ImGuiGpuTexture.hpp` | `imguiFramebufferSize()` | +| `src/render/GpuSceneRenderer.cpp` | Pass GPU cena | +| `src/editor/EditorCameraMath.hpp` | Orbit / look direction | + +--- + +## Limitações conhecidas + +| Item | Notas | +|------|-------| +| MSAA no pass GPU | Não implementado; silhuetas podem serrilhar | +| Gizmos ImGui | Sem depth test contra cena GPU (intencional — sempre visíveis) | +| Runtime / Gameplay Preview | Mesma API GPU; shadows sempre ligadas salvo lógica própria | + +--- + +## Ver também + +- [Sessão 2026-09-22 — Viewport & Quality](../plans/2026-09-22-viewport-rendering-quality-session.md) +- [Texture Quality LOD](../rendering/texture-quality-lod.md) +- [Materials Phong](../rendering/materials-phong.md) +- [Shadow Mapping](../rendering/shadow-mapping.md) diff --git a/docs/plans/2026-09-21-rendering-roadmap.md b/docs/plans/2026-09-21-rendering-roadmap.md index eacde93..cc1c0d0 100644 --- a/docs/plans/2026-09-21-rendering-roadmap.md +++ b/docs/plans/2026-09-21-rendering-roadmap.md @@ -2,7 +2,7 @@ > **Objetivo:** renderizar ambientes 3D com alto volume de polígonos e texturas de forma **otimizada, consistente e estável** (sem spikes de FPS). > **Critério de “completo”:** cena densa (terreno + centenas de meshes texturizados + iluminação dinâmica + sombras) a 60 FPS estáveis no editor e no runtime. -> **Estado atual:** pipeline híbrido GPU+CPU+ImGui; sombras GPU existem mas não estão ligadas; mesh LOD é stub; texturas sem mips/streaming. +> **Estado atual (2026-09-22):** viewport 3D GPU com Phong + CSM + sombras point/spot; runtime GPU-first; `GpuTextureCache` com mips, refcount e **LOD de texturas por distância**; wireframe GPU (`FillMode::Line`); HiDPI com cap 1920px; mesh geometry LOD é stub; batching pendente. --- @@ -10,11 +10,12 @@ | Sintoma | Causa raiz | Ficheiros | |---------|-----------|-----------| -| Spike ao mover câmara | CPU raster + `stbi_load` por textura; cache fraco | `SceneViewport.cpp`, `MeshCpuRasterizer` | +| Spike ao mover câmara | Shadow passes + resolução HiDPI alta | `GpuSceneRenderer.cpp`, `SceneViewport.cpp` | +| Spike ao mover câmara (mitigado) | Skip shadows em movimento rápido; cap resolução 1920px | `SceneViewport.cpp` | | Terreno pesado | Rebuild mesh / upload GPU síncrono | `TerrainCache.cpp`, `GpuSceneRenderer.cpp` | | Muitos draw calls | 1 `drawIndexed` por mesh, sem batching | `GpuSceneRenderer.cpp` | -| Sombras inconsistentes | CPU shadow maps no editor; GPU shadows mortos | `CpuDirectionalShadowMap.*`, `GpuDirectionalShadowMap.*` | -| Materiais “flat” | Shader Lambert sem PBR; normal map não ligado | `scene_lit.frag`, `terrain_lit.frag` | +| Sombras inconsistentes | ~~Runtime CPU shadows~~ resolvido; tuning CSM/PCF | `GpuDirectionalShadowMap.*`, `scene_lit.frag` | +| Materiais “flat” | Phong ligado; PBR metallic/roughness pendente | `scene_lit.frag`, `terrain_lit.frag` | --- @@ -46,9 +47,9 @@ Cada fase tem entregável testável. **Não avançar** para reflexos/volumétric | Tarefa | Descrição | Ficheiros | |--------|-----------|-----------| | P0.1 | GPU como default para **todos** os meshes texturizados | `SceneViewport.cpp`, `GpuSceneRenderer.cpp` | -| P0.2 | Remover fallback CPU exceto wireframe/debug | `SceneViewport.cpp` | +| P0.2 | Remover fallback CPU exceto wireframe/debug | `SceneViewport.cpp` | ✅ (GPU texturizado + wireframe `FillMode::Line`) | | P0.3 | Upload de buffers **antes** do render pass (já parcial) | `GpuSceneRenderer.cpp` | -| P0.4 | Runtime play mode usar `GpuSceneRenderer` (hoje CPU-only) | `RuntimeSceneRenderer.cpp` | +| P0.4 | Runtime play mode usar `GpuSceneRenderer` (hoje CPU-only) | `RuntimeSceneRenderer.cpp` | ✅ | | P0.5 | Profiler markers por pass (shadow, opaque, terrain, composite) | `Profiler.hpp`, viewport | **Métrica de sucesso:** viewport 3D sem `MeshCpuRasterizer` em modo shaded; FPS estável ±5% ao orbitar câmara. @@ -63,15 +64,16 @@ Cada fase tem entregável testável. **Não avançar** para reflexos/volumétric | Tarefa | Descrição | Ficheiros | |--------|-----------|-----------| -| P1.1 | `GpuTextureCache` central (path → `GPUTexture`, refcount) | novo `src/render/GpuTextureCache.*` | -| P1.2 | Geração de mipmaps no upload | `RenderDevice.cpp` | -| P1.3 | Bind albedo + normal + ORM no `scene_lit` | `scene_lit.frag`, `MeshComponents.hpp` | +| P1.1 | `GpuTextureCache` central (path → `GPUTexture`, refcount) | `src/render/GpuTextureCache.*` | ✅ | +| P1.2 | Geração de mipmaps no upload | `GpuTextureCache.cpp`, `RenderDevice.cpp` | ✅ | +| P1.3 | Bind albedo + normal + ORM no `scene_lit` | `scene_lit.frag`, `MeshComponents.hpp` | ✅ albedo + normal + shininess | | P1.4 | Async decode (job system) + upload no frame N+1 | `JobSystem`, `AssetManager` | | P1.5 | Atlas opcional para props repetidos (instancing) | `TextureAtlas.hpp` (estender) | +| P1.6 | LOD de texturas por distância aos visualizadores (tiers em cache) | `TextureQuality.hpp`, `GpuTextureCache.*` | ✅ | **Métrica:** 0 `stbi_load` durante render loop; memória GPU estável após warm-up. -**Doc:** [`assets/asset-manager.md`](../assets/asset-manager.md) + nova `docs/rendering/materials-pbr.md` +**Doc:** [`assets/asset-manager.md`](../assets/asset-manager.md), [`rendering/texture-quality-lod.md`](../rendering/texture-quality-lod.md), nova `docs/rendering/materials-pbr.md` --- @@ -103,7 +105,7 @@ Cada fase tem entregável testável. **Não avançar** para reflexos/volumétric | P3.2 | PBR: metallic/roughness, Fresnel, GGX | `scene_lit.frag` | | P3.3 | IBL básico (cubemap skybox como ambiente) | `SkyboxRenderer`, shader | | P3.4 | Tone mapping (ACES) + exposure | shader + `PostProcess` GPU futuro | -| P3.5 | Spot lights no GPU (hoje só CPU) | `scene_lit.frag` | +| P3.5 | Spot lights no GPU (hoje só CPU) | `scene_lit.frag` | ✅ | **Métrica:** esfera de referência + terreno com resposta física plausível; sem banding em gradientes. @@ -117,18 +119,19 @@ Cada fase tem entregável testável. **Não avançar** para reflexos/volumétric | Tarefa | Descrição | Ficheiros | |--------|-----------|-----------| -| P4.1 | Ligar `renderDirectionalShadows` em `renderWithCamera` | `GpuSceneRenderer.cpp` | -| P4.2 | CSM 2–4 cascatas para sol | `GpuDirectionalShadowMap.*` | -| P4.3 | PCF / PCSS no `scene_lit.frag` | shaders | -| P4.4 | Terreno `castShadows = true` no gather GPU | `GpuSceneRenderer.cpp` | -| P4.5 | Spot shadow maps (1–2 luzes) | novo ou estender GPU shadow | -| P4.6 | Desligar CPU shadow path no editor quando GPU OK | `SceneViewport.cpp` | +| P4.1 | Ligar `renderDirectionalShadows` em `renderWithCamera` | `GpuSceneRenderer.cpp` | ✅ | +| P4.2 | CSM 2–4 cascatas para sol | `GpuDirectionalShadowMap.*` | ✅ | +| P4.3 | PCF / PCSS no `scene_lit.frag` | shaders | ✅ PCF 3×3 | +| P4.4 | Terreno `castShadows` no gather GPU | `GpuSceneRenderer.cpp` | ✅ | +| P4.5 | Spot shadow maps (1–2 luzes) | `GpuSpotShadowMap.*` | ✅ | +| P4.6 | Desligar CPU shadow path no editor quando GPU OK | `SceneViewport.cpp` | ✅ | +| P4.7 | Point light cubemap shadows (até 2) | `GpuPointShadowMap.*` | ✅ | -**Infra existente (não ligada):** `GpuDirectionalShadowMap`, `GpuPointShadowMap`, `shadow_depth.*` +**Infra ligada:** `GpuDirectionalShadowMap`, `GpuPointShadowMap`, `shadow_depth.*` — ver [`rendering/shadow-mapping.md`](../rendering/shadow-mapping.md) **Métrica:** sombras sem acne/ peter-panning visível; custo < 3 ms @ 1080p. -**Doc:** nova `docs/rendering/shadow-mapping.md` +**Doc:** [`rendering/shadow-mapping.md`](../rendering/shadow-mapping.md), [`rendering/materials-phong.md`](../rendering/materials-phong.md) --- @@ -203,3 +206,6 @@ Criar `assets/benchmarks/dense_outdoor.caf`: | [`rendering/batch-renderer.md`](../rendering/batch-renderer.md) | Sprites 2D (não 3D) | | [`terrain/terrain-system.md`](../terrain/terrain-system.md) | Terreno LOD/chunks | | [`plans/2026-05-23-3d-mesh-enhancements.md`](2026-05-23-3d-mesh-enhancements.md) | Plano anterior meshes | +| [`plans/2026-09-22-viewport-rendering-quality-session.md`](2026-09-22-viewport-rendering-quality-session.md) | Sessão viewport: HiDPI, wireframe GPU, LOD texturas | +| [`rendering/texture-quality-lod.md`](../rendering/texture-quality-lod.md) | LOD de texturas por distância | +| [`editor/scene-viewport.md`](../editor/scene-viewport.md) | Pipeline do Scene Viewport 3D | diff --git a/docs/plans/2026-09-22-engine-pillars-roadmap.md b/docs/plans/2026-09-22-engine-pillars-roadmap.md index 08390ca..fe453ae 100644 --- a/docs/plans/2026-09-22-engine-pillars-roadmap.md +++ b/docs/plans/2026-09-22-engine-pillars-roadmap.md @@ -55,15 +55,15 @@ flowchart LR ### 1. Rendering 3D Phong (sombras dinâmicas, bump mapping, câmaras) -**Estado:** 🟡 Parcial — pipeline GPU híbrido; Lambert/PBR parcial; sombras CPU no editor; normal maps não ligados. +**Estado:** 🟡 Parcial — Phong GPU + CSM (4 cascatas) + sombras point/spot GPU; normal maps mesh+terreno; runtime GPU-first; `GpuTextureCache` com mips e **LOD de texturas por distância**; viewport HiDPI (cap 1920px), wireframe GPU (`FillMode::Line`), skip shadows em movimento rápido; CPU shadows off no viewport GPU. Falta: mesh geometry LOD/batching, 60 FPS gate. **Deve ter de forma sólida:** | Requisito | Critério de aceitação | |-----------|----------------------| | Iluminação Phong (ambient + diffuse + specular) | Materiais com `shininess`, luzes direcionais/pontuais estáveis a 60 FPS | -| Sombras dinâmicas | Shadow mapping GPU para luz direcional principal; sem fallback CPU no caminho default | -| Bump / normal mapping | Normal maps em meshes e terreno; tangentes corretas no vertex buffer | +| Sombras dinâmicas | Shadow mapping GPU direcional CSM (2) + point cubemap (2) + spot (2); sem fallback CPU no viewport GPU | +| Bump / normal mapping | Normal maps em meshes (TBN) e terreno (triplanar); tangentes no vertex buffer | | Sistema de câmaras 3D | Componente `Camera3D` + follow/orbit/FPS; editor e runtime partilham a mesma API | | Pipeline único GPU | Sem dual-path CPU raster no viewport shaded (ver rendering roadmap P0) | @@ -71,13 +71,13 @@ flowchart LR **Relação com PBR:** Phong é o **degrau imediato** (P0 rendering). O deferred PBR (P1) evolui sobre a mesma infraestrutura de G-buffer, shadow maps e materiais — não substitui o trabalho de câmaras, sombras e normal mapping. -**Docs:** [`rendering/camera-3d.md`](../rendering/camera-3d.md), [`2026-09-21-rendering-roadmap.md`](2026-09-21-rendering-roadmap.md) +**Docs:** [`rendering/materials-phong.md`](../rendering/materials-phong.md), [`rendering/shadow-mapping.md`](../rendering/shadow-mapping.md), [`rendering/texture-quality-lod.md`](../rendering/texture-quality-lod.md), [`editor/scene-viewport.md`](../editor/scene-viewport.md), [`2026-09-21-rendering-roadmap.md`](2026-09-21-rendering-roadmap.md), [`2026-09-22-phong-gpu-handles-session.md`](2026-09-22-phong-gpu-handles-session.md), [`2026-09-22-viewport-rendering-quality-session.md`](2026-09-22-viewport-rendering-quality-session.md) --- ### 2. Sistema de eventos (timers + tipos customizados) -**Estado:** 🟡 Parcial — `EventBus` tipado com `publish` / `publishDeferred` existe; timers de engine e tipos custom registáveis ainda limitados. +**Estado:** 🟡 Parcial — `EventBus` tipado + `TimerScheduler` (`scheduleOnce` / `scheduleRepeating` no fixed timestep). Tipos custom runtime e thread-safety doc ainda limitados. **Deve ter de forma sólida:** @@ -157,7 +157,7 @@ flowchart LR ### 6. Gestão de recursos com handles e reference counting -**Estado:** 🟡 Parcial — `AssetManager`, caches de mesh/textura; handles tipados e refcount explícito incompletos. +**Estado:** 🟡 Parcial — generation counter, refcount RAII (`AssetHandle`), LRU, invalidation callbacks; `GpuTextureCache` GPU com mips. Falta: async decode→upload N+1, `.caf` checksum header. **Deve ter de forma sólida:** @@ -297,12 +297,12 @@ flowchart LR | # | Pilar | Tier | Estado | Backend / notas | |---|-------|------|--------|-----------------| -| 1 | Phong 3D + sombras + bump + câmaras | P0 | 🟡 Parcial | GPU pipeline; ver rendering roadmap | -| 2 | Eventos + timers + tipos custom | P0 | 🟡 Parcial | `EventBus` existe | +| 1 | Phong 3D + sombras + bump + câmaras | P0 | 🟡 Parcial | CSM + point/spot GPU; `GpuTextureCache`; `Camera3DControllerComponent` | +| 2 | Eventos + timers + tipos custom | P0 | 🟡 Parcial | `EventBus` + `TimerScheduler` | | 3 | Rigid body 3D | P0 | 🔴 Não iniciado | **Bullet3** | | 4 | Áudio 3D | P0 | 🟡 Parcial | **OpenAL** | | 5 | ECS próprio + editor/runtime | P0 | 🟢 Avançado | Core da engine | -| 6 | Handles + refcount | P0 | 🟡 Parcial | `AssetManager` | +| 6 | Handles + refcount | P0 | 🟡 Parcial | Generation + RAII + LRU + `GpuTextureCache` | | 7 | Networking + serialização | P1 | 🔴 Não iniciado | Binário custom | | 8 | Deferred PBR | P1 | 🟡 Parcial | Após Phong sólido | | 9 | Animação | P1 | 🟡 Parcial | 2D + skeletal | @@ -332,3 +332,180 @@ flowchart LR - **Antes de implementar** um subsistema: abrir o pilar correspondente e verificar critérios de aceitação. - **Ao fechar uma feature:** atualizar coluna “Estado” neste ficheiro e linkar doc técnica em `docs//`. - **Rendering:** detalhe fase-a-fase continua em [`2026-09-21-rendering-roadmap.md`](2026-09-21-rendering-roadmap.md); este doc define *o quê*; aquele define *como*. + +--- + +## Critérios quantitativos de “production-ready” + +| Métrica | Alvo | Medição | +|---------|------|---------| +| Frame time | ≤16 ms (60 FPS) no 95th percentile | Profiling em hardware mínimo alvo | +| Memory baseline | ≤512 MB idle | Process working set (Windows/Linux) | +| Load time | ≤3 s cena 50 MB | Cold boot, SSD | +| Crash rate | ≤0,1% sessões | Telemetria em beta fechado | +| Build reproducibility | 100% determinístico | CI hash comparison | + +--- + +## Caminho crítico de dependências + +```mermaid +flowchart TD + subgraph CriticalPath["⏱️ Caminho Crítico"] + Handles["6. Handles + Refcount"] --> Rendering["1. Rendering Phong"] + Rendering --> Anim["9. Animação"] + Anim --> Scene["10. Scene Manager"] + Scene --> Production["🎮 Ready for 3D Games"] + end + + subgraph Blockers["🔴 Bloqueadores Conhecidos"] + Physics["3. Bullet3"] -.->|block| Gameplay["Gameplay systems"] + Audio["4. OpenAL 3D"] -.->|block| Immersion["Imersão completa"] + end + + subgraph NiceToHave["✨ Pós-Lançamento 1.0"] + NET["7. Networking"] + PBR["8. Deferred PBR"] + SCRIPT["11. C# / Python"] + end +``` + +--- + +## Análise técnica e melhorias + +### ECS — requisitos adicionais + +| Área | Recomendação | +|------|--------------| +| Reflection | Macro-based codegen em tempo de compilação (C++20 modules ou generators) em vez de RTTI manual | +| Serialization | Schema versioning com forward/backward compatibility desde o início (field numbering estilo protobuf) | +| Cache locality | Documentar layout de memória (SoA vs AoS) e alignment para SIMD | +| Multi-threading | Ownership model claro: quem owns ECS data? Job system acessa via snapshots? | + +**Adicionar aos critérios de aceitação (pilar 5):** + +- **Schema evolution:** compatibilidade entre versões `.cscene` (major.minor.patch) +- **Storage strategy:** Hybrid archetype + chunk-based (semelhante EnTT/SparseSet) +- **Query cache:** Compiled query plans reutilizados entre frames +- **Hot reload safety:** Versionamento de componentes durante play mode + +--- + +### Rendering — transição Phong → PBR + +| Aspecto | Problema | Solução | +|---------|----------|---------| +| Shader reuse | Phong shaders não compatíveis com G-buffer | Material abstraction layer que traduz Phong→PBR automaticamente | +| Shadow maps | CPU shadows no editor ≠ GPU shadows no runtime | Unificar shadow pipeline desde P0 (GPU-only mesmo no editor) | +| Memory budget | Deferred requer ~2× VRAM do forward | Documentar VRAM budgets por plataforma (desktop mínimo 4 GB) | +| TAA/PostFX | Não mencionado | PostFX pipeline (bloom, SSAO, TAA) como parte do P1 | + +**Critério de aceitação adicional (pilar 8):** + +> Migração automática de materiais Phong→PBR via converter tool que mapeia `shininess↔roughness`, `specular↔metallic` com validação visual ≤5% diferença RMS. + +--- + +### Física — política de determinismo e backend + +| Decisão | Opção A | Opção B | Recomendação | +|---------|---------|---------|--------------| +| Backend | Bullet3 | Jolt Physics | Jolt tem melhor multithreading e C++20; **Bullet3 mantido por decisão explícita** até reavaliação | +| Sync transform | Pull (physics→render) | Push (render→physics) | Pull com interpolação para evitar stutter | +| Terrain collision | Heightfield | Mesh collision | Heightfield LOD para performance escalável | +| Determinismo | Documentado | Cross-platform verified | Teste x86/arm/Windows/Linux com tolerance <0,001% | + +**Política de determinismo (pilar 3):** + +- Fixed timestep: 1/60 s (configurável até 1/240 s) +- Float precision: single (`float`) com **quantização opcional** para replay +- Cross-platform tolerance: posição <0,001 unidades, rotação <0,1 graus +- Rollback network: support documentado para rollback windows ≥16 frames + +--- + +### Handles — protocolo de segurança + +| Mecanismo | Descrição | +|-----------|-----------| +| **Generation counter** | `TextureHandle = { id: u32, generation: u16 }` — previne uso após asset removido | +| **Invalidation callback** | Sistema registra listeners quando handle fica inválido | +| **Async load states** | `{ Pending, Loading, Ready, Failed, Invalid }` | +| **Memory pressure** | LRU eviction com callbacks para libertar recursos críticos | +| **Bundle format** | `.caf` com checksum SHA-256 + schema version header | + +**Estado atual (2026-09-22):** `AssetHandle = { id: u32, generation: u16 }` com validação em `get()`; `LoadStatus` explícito (`Pending | Loading | Ready | Failed | Invalid`); generation incrementada em `collectGarbage` (evict); hot-reload mantém generation para handles activos. + +--- + +### Networking — arquitetura proposta (P1) + +| Componente | Protocolo | Notas | +|------------|-----------|-------| +| Transporte | ENet ou Nakama | Reliable UDP built-in | +| Serialização | FlatBuffers ou Cap'n Proto | Zero-copy parsing (alternativa ao binário custom) | +| Authority model | Server-authoritative | Client-prediction + server reconciliation | +| Tick rate | 30 Hz (mínimo), 60 Hz (recomendado) | Trade-off bandwidth × responsiveness | +| Reconciliation | Interpolation lag 100 ms + prediction | Buffer de 2–3 s para entities | + +**Critério de aceitação adicional:** + +> Test de stress: 100 entidades simuladas, 32 players simultâneos, latência 150 ms, packet loss 5% → <10% entities dessincronizadas após 5 minutos. + +--- + +### Animação — separação 2D vs 3D + +| Subsystem | P0 | P1 | P2 | +|-----------|----|----|-----| +| Sprite animation | ✅ | — | — | +| Skeletal import (glTF/FBX) | — | ✅ | — | +| GPU skinning | — | ✅ | — | +| Blend shapes / morph targets | — | ⚠️ Parcial | ✅ Completo | +| IK solvers | — | — | ✅ | +| Retargeting between rigs | — | — | ✅ | + +**Critério adicional (pilar 9):** + +> Import pipeline glTF 2.0 com validation: bones ≤255, weights ≤4 per vertex, animations ≥30 fps sample rate. + +--- + +### Scripting — comparativo + +| Critério | Lua (atual) | C# | Python | +|----------|-------------|----|--------| +| Performance | ✅ 2–5× C++ | ⚠️ 3–8× (GC stalls) | ❌ 10–50× | +| GC predictability | ✅ Manual/yield | ❌ Geracional | ❌ Reference counting | +| Tooling | ⚠️ VSCode + LSP | ✅ Rider/VS full | ⚠️ VSCode | +| Bindings ECS | Manual/cbindgen | Generated (source gen) | ctypes/CFFI | +| Hot reload | ✅ Native | ⚠️ Assembly load | ✅ | +| Sandboxing | ⚠️ `lua_open()` | ⚠️ AppDomain | ✅ `ast.parse` | +| Team skill | ⚠️ Curva de aprendizado | ✅ Common em game dev | ✅ Common outside games | + +**Recomendação:** manter **Lua como primary scripting layer** (gameplay, AI, UI). C# como secondary layer apenas se parceiro empresarial exigir, complexidade gameplay >50k linhas, ou tooling interno em C# predominante. + +--- + +## Documentação técnica pendente + +| Documento | Prioridade | Conteúdo | +|-----------|------------|----------| +| `docs/architecture/threat-model.md` | P0 | Security (anti-tamper, cheat prevention) | +| `docs/performance/profiling-guidelines.md` | P0 | Como profilear CPU/GPU/memory | +| `docs/assets/pipeline-spec.md` | P0 | Import formats, compression, validation rules | +| `docs/builds/platform-support-matrix.md` | P1 | Compilers, SDKs, minimum versions | +| `docs/networking/protocol-spec.md` | P1 | Wire format, message IDs, versioning | + +--- + +## Estado atual da branch `feature/editor-plugin-sdk` + +Esta branch isola o **Plugin SDK** do core do Doppio: + +- Terrain, procedural tools, post-process e git UI movidos para plugins `.so` +- `PluginServiceRegistry` genérico — core não hardcoda feature services +- Ver [`docs/editor/plugin-sdk.md`](../editor/plugin-sdk.md) + +**Impacto nos pilares:** o pilar 10 (Scene + tipos user) e o pilar 5 (ECS reflection) beneficiam diretamente desta arquitetura — plugins registam drawers e serializers sem recompilar o editor. diff --git a/docs/plans/2026-09-22-phong-gpu-handles-session.md b/docs/plans/2026-09-22-phong-gpu-handles-session.md new file mode 100644 index 0000000..4c71570 --- /dev/null +++ b/docs/plans/2026-09-22-phong-gpu-handles-session.md @@ -0,0 +1,115 @@ +# Sessão 2026-09-22 — Phong GPU, Handles e Sombras + +> **Branch:** `feature/editor-plugin-sdk` +> **Objetivo:** avançar pilares P0 (rendering Phong + handles) e tarefas P1 (sombras pontuais GPU, normal maps no terreno). +> **Build verificado:** `caffeine-core`, `caffeine-runtime`, `doppio` compilam com sucesso. + +--- + +## Resumo executivo + +| Prioridade | Item | Estado | +|------------|------|--------| +| P0 | Remover fallback CPU shadows no editor (`SceneViewport`) | ✅ | +| P0 | Inspector UI para `customNormalPath` e `shininess` (mesh + terreno) | ✅ | +| P1 | Point light shadows GPU | ✅ | +| P1 | Terreno com normal maps em `terrain_lit.frag` | ✅ | +| P0 | Asset handles com generation + LRU + invalidation callbacks | ✅ | + +--- + +## 1. Asset handles (Pilar 6) + +### O que mudou + +- `AssetHandle` passou a guardar `{ id, generation }`. `get()` devolve `nullptr` se a geração não coincidir. +- `LoadStatus::Invalid` quando o slot foi invalidado (LRU ou GC). +- **Hot-reload** (`clearEntryData`): recarrega payload **sem** incrementar geração — handles activos continuam válidos. +- **Invalidação** (`invalidateEntry`): incrementa geração, limpa payload, notifica callbacks. +- **LRU eviction**: quando `m_cachedBytes > m_maxCacheBytes`, remove entradas não referenciadas pela ordem `lastAccessFrame`. +- `registerInvalidationCallback` / `unregisterInvalidationCallback` para libertar recursos GPU quando um asset é evicted. +- `CacheStats::evictedCount` para telemetria. + +### Ficheiros + +- `src/assets/AssetHandle.hpp`, `AssetTypes.hpp`, `AssetManager.{hpp,cpp}` +- `tests/test_assetmanager.cpp` — testes de geração, LRU, callbacks + +### Documentação + +- [`assets/asset-manager.md`](../assets/asset-manager.md) + +--- + +## 2. Rendering Phong + sombras GPU (Pilar 1) + +### Meshes (`scene_lit.*`) + +- Shader Phong: ambient + diffuse + specular com `shininess`. +- Normal mapping via TBN (`scene_lit.vert` exporta tangent/bitangent). +- Tangentes calculadas em `MeshLoader::computeMeshTangents()` para OBJ e glTF. +- `MeshFilterComponent`: `customNormalPath`, `shininess` (default 32). +- Sombras direcionais: depth pass + PCF 3×3 no fragment shader. +- Sombras pontuais: até 2 luzes, cubemap depth (`GpuPointShadowMap`), sample no `scene_lit.frag`. + +### Terreno (`terrain_lit.frag`) + +- Mesmo modelo Phong que meshes. +- Normal map triplanar via `uNormalMap` (sampler slot 5). +- `TerrainComponent`: `normalMapPath`, `shininess` (default 16). +- `TerrainGpuTextureCache` carrega e invalida `normalMap` em sync com `texturePath`. +- Sombras: direcionais (slots 6–7) + pontuais (slots 8–9). +- `castShadows` / `receiveShadows` respeitados no gather GPU. + +### Editor viewport + +- Com GPU 3D texturizado activo, `gatherSceneLighting(..., buildCpuShadowMaps = false)`. +- `drawSceneMeshesForCamera` usa `collectSceneLights` apenas — sem CPU shadow maps. +- CPU raster permanece para wireframe/debug e entidades vazias quando GPU não está activo. + +### Inspector + serialização + +| Componente | Campos novos | Blob | +|------------|--------------|------| +| `MeshFilterComponent` | Normal Map, Shininess | append após `customMaterialPath` | +| `TerrainComponent` | Normal Map, Shininess | blob versão **10** | + +### Ficheiros principais + +- `src/render/GpuSceneRenderer.cpp`, `GpuPointShadowMap.{hpp,cpp}` +- `src/render/shaders/scene_lit.{vert,frag}`, `terrain_lit.frag` +- `src/scene/LightingSystem.{hpp,cpp}` +- `src/editor/SceneViewport.cpp`, `InspectorPanel.cpp`, `SceneSerializer.cpp` +- `src/terrain/TerrainGpuTextures.cpp`, `src/ecs/TerrainComponents.hpp` + +### Documentação + +- [`rendering/materials-phong.md`](../rendering/materials-phong.md) +- [`rendering/shadow-mapping.md`](../rendering/shadow-mapping.md) +- [`terrain/terrain-system.md`](../terrain/terrain-system.md) +- [`assets/mesh-loading.md`](../assets/mesh-loading.md) + +--- + +## 3. Continuação (mesma branch) + +| Item | Estado | +|------|--------| +| Runtime GPU shadows | ✅ `RuntimeSceneRenderer` integra `GpuSceneRenderer` | +| CSM 4 cascatas (luz dir 0) | ✅ atlas 1024², splits práticos | +| Spot shadows GPU (2 luzes) | ✅ `GpuSpotShadowMap` + loop spot nos shaders | +| `test_scenemanager.cpp` | ✅ usa `Transform`/`Acceleration2D`; compila | +| Componentes ECS em falta nos testes | ✅ `Position2D`, `Velocity2D`, `Scale2D`, `Rotation`, `Health` | + +## 4. Próximos passos sugeridos + +1. **PCSS / cascade blending** — qualidade visual das sombras. +2. **PBR** — metallic/roughness sobre a base Phong. +3. **Suite completa** — outros testes (`test_cap_loader`, integração editor) ainda com erros pré-existentes. + +--- + +## Links + +- [Engine Pillars Roadmap](2026-09-22-engine-pillars-roadmap.md) +- [Rendering Roadmap P0–P6](2026-09-21-rendering-roadmap.md) diff --git a/docs/plans/2026-09-22-viewport-rendering-quality-session.md b/docs/plans/2026-09-22-viewport-rendering-quality-session.md new file mode 100644 index 0000000..8f499e6 --- /dev/null +++ b/docs/plans/2026-09-22-viewport-rendering-quality-session.md @@ -0,0 +1,198 @@ +# Sessão 2026-09-22 — Viewport 3D, Qualidade Visual e Performance + +> **Objetivo:** corrigir artefactos visuais no Scene Viewport (costuras de shading, aspecto 2.5D, serrilhamento), melhorar FPS em movimento rápido de câmara e introduzir LOD de texturas por distância aos visualizadores. +> **Build verificado:** `doppio` compila após as alterações. + +--- + +## Resumo executivo + +| Área | Item | Estado | +|------|------|--------| +| Meshes procedurais | Remover `computeSmoothNormals()` em primitivas com normais analíticas | ✅ | +| Viewport HiDPI | Render offscreen na resolução do framebuffer (com cap 1920px) | ✅ | +| Linhas 3D | Clipping no near plane (`drawViewportWorldLine`) | ✅ | +| Wireframe GPU | Pipeline `SDL_GPU_FILLMODE_LINE` com depth test | ✅ | +| Anti-aliasing UI | `AntiAliasedLines` / `AntiAliasedFill` no draw list do viewport | ✅ | +| FPS câmara | Suspender shadow passes durante movimento rápido | ✅ | +| LOD texturas | Qualidade por distância a visualizadores (viewport + Camera 3D) | ✅ | +| Preferências | Sliders em Settings → Viewport | ✅ | + +--- + +## 1. Costuras de shading em primitivas curvas + +### Problema + +Esfera, torus e cápsula mostravam uma **linha vertical** na iluminação — costura longitudinal. + +### Causa + +`finishCurvedPrimitive()` chamava `Assets::computeSmoothNormals()` **depois** de as meshes já terem normais analíticas corretas. Vértices duplicados na costura (`lon=0` vs `lon=segments`, mesma posição, índices diferentes) recebiam médias de faces diferentes. + +### Correção + +Removido `finishCurvedPrimitive()` e a dependência de `MeshNormals.hpp` em `GpuProceduralMeshes.cpp`. Primitivas curvas (esfera, cilindro, cápsula, cone, torus) mantêm apenas normais analíticas. + +### Política de shading + +| Tipo | Shading | +|------|---------| +| Esfera, cilindro, cápsula, cone, torus | Smooth (normais analíticas) | +| Cubo, plano, pirâmide | Facetado | +| Terreno | Facetado por chunk (LOD de geometria) | +| GLB sem normais | `computeSmoothNormals()` em `MeshLoader.cpp` (mantido) | + +### Ficheiros + +- `src/render/GpuProceduralMeshes.cpp` +- `src/assets/MeshNormals.hpp` (utilitário; não usado em procedurais GPU) +- `src/ecs/MeshComponents.hpp` — `meshPrimitiveUsesSmoothShading()` + +--- + +## 2. Viewport HiDPI e resolução offscreen + +### Problema + +O viewport renderizava em pixels **lógicos** do ImGui enquanto a janela usa `SDL_WINDOW_HIGH_PIXEL_DENSITY` — metade da resolução real, upscale visível (serrilhamento). + +### Correção + +`imguiFramebufferSize()` em `ImGuiGpuTexture.hpp` multiplica por `ImGui::GetIO().DisplayFramebufferScale`. + +**Cap:** maior lado limitado a **1920px** para evitar targets 4K+ no editor. + +Aplicado em: + +- `SceneViewport` (canvas principal) +- `GameplayPreviewPanel` +- `CameraPreviewPanel` + +--- + +## 3. Linhas 3D estáveis (grid, frustums, wireframe CPU) + +### Problema + +Grid, frustum de câmara e wireframes CPU desenhavam segmentos com um endpoint atrás do near plane projetado para `(-10000, -10000)` — linhas “quebradas” e dependentes do ângulo da câmara. + +### Correção + +Novos helpers em `SceneViewport`: + +- `projectWorldToViewport()` — projeta mundo → pixels; falha só atrás do near plane +- `drawViewportWorldLine()` — clip no near plane em clip space (mesma lógica que o grid) + +Usados em: `drawGrid3D`, `drawCameraFrustums`, `drawSegment` (wireframe CPU em 3D). + +--- + +## 4. Wireframe GPU com profundidade real + +### Problema + +Modo **Wireframe** desligava a GPU e desenhava apenas overlay ImGui sem Z-buffer — objetos atravessavam-se, aspecto 2.5D. + +### Correção + +- `RHI::FillMode::Line` → `SDL_GPU_FILLMODE_LINE` em `RenderDevice` +- Pipeline `m_wireframePipeline` em `GpuSceneRenderer` +- Scene Viewport activa GPU em **Textured e Wireframe**; overlay CPU só para seleção/destaque + +`GpuSceneRenderOptions::wireframeMeshes` selecciona o pipeline. + +--- + +## 5. Performance — movimento rápido de câmara + +### Problema + +Orbit/zoom rápido causava quedas de FPS (profiler: picos até ~850 ms). + +### Causas + +1. Shadow passes redesenham a cena inteira (4 cascatas direcionais + cubemaps point + spot) +2. Resolução offscreen alta em HiDPI + +### Correções + +| Medida | Implementação | +|--------|----------------| +| Skip shadows em movimento | `m_editorCamMotion` em `SceneViewport`; `enableShadows = false` se motion > 1.25 | +| Cap resolução | `imguiFramebufferSize(..., maxDim=1920)` | +| GPU só em Textured (anterior) | Wireframe também usa GPU desde esta sessão | + +### API + +```cpp +struct GpuSceneRenderOptions { + bool wireframeMeshes = false; + bool enableShadows = true; + TextureQualitySettings textureQuality{}; + std::vector textureQualityViewers; +}; +``` + +--- + +## 6. LOD de texturas por distância aos visualizadores + +### Comportamento + +- **Não remove** texturas — reduz resolução em cache GPU +- Distância = mínimo entre a posição do objeto e cada **visualizador**: + - Câmara do Scene Viewport (orbit) + - Todas as entidades com `Camera3DComponent` + - Camera Preview usa só a câmara do painel +- Dentro de `fullRadius` → tier 0 (100%) +- Além do raio → decaimento linear até `minScale` ao longo de `falloffDistance` +- Tiers em cache: 100% → 50% → 25% → 12.5% (mín. 16px) + +### Configuração (Settings → Viewport → Texture quality) + +| Campo | Default | Descrição | +|-------|---------|-----------| +| `texture_quality_enabled` | true | Liga/desliga LOD | +| `texture_quality_radius` | 35 m | Raio de resolução total | +| `texture_quality_falloff` | 100 m | Distância de decaimento | +| `texture_quality_min_scale` | 0.25 | Escala mínima (1/4 res) | + +Persistido em `~/.config/caffeine/editor_preferences.json`. + +### Ficheiros + +- `src/render/TextureQuality.hpp` +- `src/render/GpuTextureCache.{hpp,cpp}` — `acquire(..., qualityTier)` +- `src/render/GpuSceneRenderer.cpp` — `MeshDraw::viewerDistance` +- `src/editor/EditorContext.hpp`, `EditorPreferences.{hpp,cpp}` +- `src/editor/SettingsPanel.cpp` + +### Documentação técnica + +- [`rendering/texture-quality-lod.md`](../rendering/texture-quality-lod.md) +- [`editor/scene-viewport.md`](../editor/scene-viewport.md) + +--- + +## 7. Primitivas e editor (sessão anterior relacionada) + +Itens da mesma linha de trabalho já no código (referência): + +| Item | Ficheiros | +|------|-----------| +| Cápsula ≠ cilindro | `GpuProceduralMeshes.cpp` — `buildCapsule()` | +| Cone, pirâmide, torus | `MeshComponents.hpp`, `HierarchyPanel`, `InspectorPanel` | +| Fix orbit câmara eixo Y | `EditorCameraMath.hpp`, `SceneViewport.cpp` | +| Menu Create no Hierarchy | `HierarchyPanel.cpp` | +| GPU shutdown / ASan | `apps/doppio/main.cpp`, cleanup ordem em `SceneEditor` | + +--- + +## Links + +- [Scene Viewport](../editor/scene-viewport.md) +- [Texture Quality LOD](../rendering/texture-quality-lod.md) +- [Engine Pillars Roadmap](2026-09-22-engine-pillars-roadmap.md) +- [Rendering Roadmap](2026-09-21-rendering-roadmap.md) +- [Sessão Phong GPU](2026-09-22-phong-gpu-handles-session.md) diff --git a/docs/rendering/materials-phong.md b/docs/rendering/materials-phong.md new file mode 100644 index 0000000..0238c29 --- /dev/null +++ b/docs/rendering/materials-phong.md @@ -0,0 +1,110 @@ +# Materiais Phong (3D) + +> **Namespace:** `Caffeine::Render`, `Caffeine::ECS` +> **Shaders:** `src/render/shaders/scene_lit.{vert,frag}`, `terrain_lit.frag` +> **Renderer:** `GpuSceneRenderer` +> **Status:** ✅ Implementado (meshes + terreno) + +--- + +## Visão geral + +A Caffeine usa **Phong clássico** como modelo de iluminação imediato antes da evolução para PBR deferred: + +``` +final = ambient + Σ (diffuse + specular) × shadowAttenuation +``` + +- **Diffuse:** `max(dot(N, L), 0) × albedo × lightColor` +- **Specular:** `pow(max(dot(R, V), 0), shininess) × lightColor` +- **Normal mapping:** perturbação de `N` via TBN e textura normal (meshes) ou triplanar (terreno) + +--- + +## Componentes ECS + +### MeshFilterComponent + +```cpp +struct MeshFilterComponent { + MeshPrimitive primitive = MeshPrimitive::Cube; + std::string customMeshPath; + std::string customTexturePath; + std::string customNormalPath; // caminho relativo ao projeto + std::string customMaterialPath; + f32 shininess = 32.0f; // expoente especular +}; +``` + +**Inspector (Doppio):** Albedo Texture, Normal Map, Shininess (slider 1–128). + +### TerrainComponent + +```cpp +char normalMapPath[256] = {}; +f32 shininess = 16.0f; +bool castShadows = true; +bool receiveShadows = true; +``` + +**Inspector:** Normal Map, Shininess, Albedo Texture (splat layers). + +--- + +## Pipeline GPU + +```mermaid +flowchart LR + A[Shadow passes] --> B[Opaque pass] + B --> C[scene_lit / terrain_lit] + C --> D[Framebuffer viewport] +``` + +1. `renderDirectionalShadows()` — até 2 luzes direcionais com `castShadows`. +2. `renderPointShadows()` — até 2 luzes pontuais (cubemap depth). +3. Pass principal com UBO de luzes + UBO de sombras + materiais. + +### Samplers (meshes — `scene_lit.frag`) + +| Slot | Textura | +|------|---------| +| 0 | Albedo | +| 1 | Normal map | +| 2–3 | Shadow maps direcionais | +| 4–5 | Cubemaps sombra pontual | + +### Samplers (terreno — `terrain_lit.frag`) + +| Slot | Textura | +|------|---------| +| 0–4 | Splat + camadas | +| 5 | Normal map triplanar | +| 6–7 | Shadow maps direcionais | +| 8–9 | Cubemaps sombra pontual | + +--- + +## Tangentes + +`MeshLoader::computeMeshTangents()` calcula tangentes por vértice (MikkTSpace simplificado) após import OBJ/glTF. O vertex shader constrói a matriz TBN: + +``` +TBN = mat3(normalize(T), normalize(B), normalize(N)) +N' = normalize(TBN * (sample(normalMap) * 2 - 1))) +``` + +--- + +## Caminho CPU (legado) + +No editor, quando o viewport 3D **não** usa GPU texturizado (`gpuTextured3D == false`), o raster CPU ainda pode avaliar iluminação difusa com shadow maps CPU. O caminho shaded default com GPU activo **não** constrói CPU shadows. + +Ver [`shadow-mapping.md`](shadow-mapping.md) e [`plans/2026-09-21-rendering-roadmap.md`](../plans/2026-09-21-rendering-roadmap.md) P0. + +--- + +## Referências + +- [`assets/mesh-loading.md`](../assets/mesh-loading.md) — tangentes e import +- [`terrain/terrain-system.md`](../terrain/terrain-system.md) — normal map no terreno +- [`shadow-mapping.md`](shadow-mapping.md) — passes de sombra GPU diff --git a/docs/rendering/shadow-mapping.md b/docs/rendering/shadow-mapping.md new file mode 100644 index 0000000..abc98f0 --- /dev/null +++ b/docs/rendering/shadow-mapping.md @@ -0,0 +1,93 @@ +# Shadow Mapping (GPU) + +> **Namespace:** `Caffeine::Render` +> **Ficheiros:** `GpuDirectionalShadowMap.*`, `GpuPointShadowMap.*`, `GpuSceneRenderer.cpp` +> **Shaders:** `shadow_depth.*`, `scene_lit.frag`, `terrain_lit.frag` +> **Status:** ✅ Direcional CSM (4 cascatas, luz 0) + point + spot (até 2 cada) + +--- + +## Visão geral + +Sombras dinâmicas são renderizadas em passes separados antes do pass opaco principal. O fragment shader faz PCF 3×3 sobre mapas de profundidade. + +```mermaid +flowchart TD + L[Scene lights gather] --> D[Directional shadow pass] + D --> P[Point shadow pass cubemap] + P --> O[Opaque Phong pass] + O --> V[Viewport / runtime] +``` + +--- + +## Luzes suportadas + +| Tipo | Pass | Limite | Shader sample | +|------|------|--------|---------------| +| Direcional | Atlas 1024² (4×512 cascatas na luz 0) | 2 | `sampleDirShadowCSM()` PCF 3×3 | +| Pontual | Cubemap depth | 2 | `samplePointShadow()` | +| Spot | Perspective depth 2D | 2 | `sampleSpotShadow()` | + +Apenas luzes com `castShadows == true` entram nos passes GPU. + +--- + +## SceneShadowUBO (std140) + +Espelhado em C++ (`GpuSceneRenderer.cpp`) e GLSL: + +```glsl +// Direcional: mat4 view-projection × 2 + flags +// Point: vec3 position × 2, float radius × 2, float valid × 2 +``` + +`uDirShadowValid` / `uPointShadowValid` permitem ao shader ignorar slots não atribuídos. + +--- + +## GpuPointShadowMap + +- `setSlot(index, position, radius)` após render do cubemap. +- `clearSlot(index)` quando a luz deixa de projectar sombra. +- Cubemap ligado nos slots 4–5 (meshes) ou 8–9 (terreno). + +--- + +## Editor vs runtime + +| Contexto | CPU shadows | GPU shadows | +|----------|-------------|-------------| +| Editor 3D GPU texturizado | ❌ desligado | ✅ | +| Editor wireframe / sem GPU | ✅ opcional | — | +| Runtime (`RuntimeSceneRenderer`) | ❌ fallback CPU sem sombras | ✅ GPU-first via `GpuSceneRenderer` | + +`SceneViewport` passa `buildCpuShadowMaps = !gpuTextured3D` a `gatherSceneLighting`. + +--- + +## CSM (cascaded shadow maps) + +- **Luz direcional slot 0:** 4 cascatas em atlas 1024² (quadrantes 512²). +- Splits práticos (λ=0.5) entre `nearClip` e `shadowDistance`. +- `SceneShadowUBO`: `uDirShadowVP[8]`, `uDirCascadeSplits[2]`, `uCameraView`. +- **Luz slot 1:** mapa único (sem cascatas). + +## Spot shadows GPU + +- `GpuSpotShadowMap` — até 2 luzes, 512², VP de `buildSpotLightVP()` (mesma matemática que CPU). +- Iluminação spot no `scene_lit.frag` / `terrain_lit.frag` com cone + shadow map. + +## Próximos passos (roadmap P4) + +1. **PCSS** — soft shadows em cascatas. +2. **CSM na luz secundária** — slot 1 com cascatas. +3. **Inter-cascade blending** — reduzir seams entre cascatas. + +--- + +## Referências + +- [`materials-phong.md`](materials-phong.md) +- [`plans/2026-09-21-rendering-roadmap.md`](../plans/2026-09-21-rendering-roadmap.md) — fase P4 +- [`plans/2026-09-22-phong-gpu-handles-session.md`](../plans/2026-09-22-phong-gpu-handles-session.md) diff --git a/docs/rendering/texture-quality-lod.md b/docs/rendering/texture-quality-lod.md new file mode 100644 index 0000000..4a0f9e2 --- /dev/null +++ b/docs/rendering/texture-quality-lod.md @@ -0,0 +1,178 @@ +# Texture Quality LOD — Distância aos Visualizadores + +> **Namespace:** `Caffeine::Render` +> **Ficheiros:** `src/render/TextureQuality.hpp`, `src/render/GpuTextureCache.{hpp,cpp}` +> **Status:** ✅ Implementado (editor + runtime GPU) + +--- + +## Visão geral + +O sistema de **texture quality LOD** reduz a resolução das texturas GPU conforme a distância entre o objeto e os **visualizadores** (câmaras). As texturas **não são removidas** — apenas entram em cache com dimensões menores. + +Objetivos: + +- Menor uso de memória GPU em cenas grandes +- Menos banda de textura em objetos distantes +- Qualidade total perto das câmaras (editor, `Camera3D`, Camera Preview) + +--- + +## Visualizadores + +A distância usada para cada draw é o **mínimo** entre a posição do objeto e cada visualizador activo: + +| Contexto | Visualizadores | +|----------|----------------| +| Scene Viewport | Câmara do editor (orbit) + todas as entidades com `Camera3DComponent` | +| Camera Preview | Só a posição da câmara do painel | +| Runtime | Posição da câmara activa (quando `textureQualityViewers` é preenchido) | + +```cpp +f32 dist = distanceToNearestViewer(worldPos, options.textureQualityViewers); +u32 tier = textureQualityTier(dist, options.textureQuality); +``` + +--- + +## Curva de qualidade + +Configuração em `TextureQualitySettings`: + +| Campo | Default | Descrição | +|-------|---------|-----------| +| `enabled` | `true` | Liga/desliga o LOD | +| `fullRadius` | `35.0` m | Dentro deste raio → tier 0 (100%) | +| `falloffDistance` | `100.0` m | Decaimento linear além do raio | +| `minScale` | `0.25` | Escala mínima no limite do falloff (1/4 res) | + +### Escala contínua + +``` +distance <= fullRadius → scale = 1.0 +distance >= fullRadius + falloff → scale = minScale +entre os dois → interpolação linear +``` + +### Tiers discretos (cache GPU) + +| Tier | Escala aprox. | Exemplo (1024² base) | +|------|---------------|----------------------| +| 0 | 100% | 1024 × 1024 | +| 1 | 50% | 512 × 512 | +| 2 | 25% | 256 × 256 | +| 3 | 12.5% | 128 × 128 (mín. 16 px) | + +Limites em `textureQualityTier()`: `>= 0.75` → tier 0, `>= 0.375` → tier 1, `>= 0.1875` → tier 2, senão tier 3. + +--- + +## GpuTextureCache + +Cada tier é uma entrada separada no cache: + +``` +textures/albedo.png → tier 0 +textures/albedo.png#q1 → tier 1 +textures/albedo.png#q2 → tier 2 +textures/albedo.png#q3 → tier 3 +``` + +### API + +```cpp +GpuTexture* acquire(const std::string& path, u32 qualityTier = 0); +GpuTexture* acquireFromPixels(const std::string& key, const u8* pixels, + u32 w, u32 h, u32 qualityTier = 0); +``` + +No upload (`loadTexture`): + +1. `stbi_load` da imagem original (tier 0) ou reutilização em memória +2. `downscaleRgba()` com `halveRgbaImage()` por cada nível de tier +3. Upload GPU com dimensões reduzidas + geração de mipmaps + +`release()` e `invalidate()` limpam **todos** os tiers (`0 .. kTextureQualityMaxTier`). + +--- + +## Integração no render + +### GpuSceneRenderOptions + +```cpp +struct GpuSceneRenderOptions { + bool wireframeMeshes = false; + bool enableShadows = true; + TextureQualitySettings textureQuality{}; + std::vector textureQualityViewers; +}; +``` + +### GpuSceneRenderer + +Em `gatherMeshDraws()`, cada `MeshDraw` recebe `viewerDistance` (centro do AABB ou chunk de terreno). + +Em `resolveMeshAlbedo()` e binding de splat do terreno: + +```cpp +const u32 tier = textureQualityTier(draw.viewerDistance, options.textureQuality); +auto* tex = m_textureCache.acquire(path, tier); +``` + +--- + +## Configuração no editor + +**Settings → Viewport → Texture quality** + +| Preferência JSON | Campo `EditorContext` | +|------------------|----------------------| +| `texture_quality_enabled` | `textureQualityEnabled` | +| `texture_quality_radius` | `textureQualityRadius` | +| `texture_quality_falloff` | `textureQualityFalloff` | +| `texture_quality_min_scale` | `textureQualityMinScale` | + +Persistido em `~/.config/caffeine/editor_preferences.json`. + +Ficheiros: `EditorPreferences.{hpp,cpp}`, `SettingsPanel.cpp`, `EditorContext.hpp`. + +--- + +## Diagrama de fluxo + +``` +Viewer positions + │ + ▼ +distanceToNearestViewer(meshCenter) + │ + ▼ +textureQualityTier(distance, settings) + │ + ▼ +GpuTextureCache::acquire(path, tier) + │ + ├── cache hit → bind texture existente + └── cache miss → load + downscale + upload GPU +``` + +--- + +## Limitações + +| Item | Notas | +|------|-------| +| Sem transição suave | Troca de tier é discreta; sem blend entre resoluções | +| Downscale no load | Não usa filtros GPU separados; box-filter em CPU (`halveRgbaImage`) | +| Terreno | Splat layers usam o mesmo tier por chunk | +| Normal maps | Seguem o mesmo tier que o albedo | + +--- + +## Ver também + +- [Scene Viewport](../editor/scene-viewport.md) — visualizadores e `GpuSceneRenderOptions` +- [Sessão 2026-09-22](../plans/2026-09-22-viewport-rendering-quality-session.md) — changelog completo +- [Asset Manager](../assets/asset-manager.md) — carregamento de imagens +- [Rendering Roadmap](../plans/2026-09-21-rendering-roadmap.md) — P1/P2 LOD diff --git a/docs/terrain/terrain-system.md b/docs/terrain/terrain-system.md index 894d930..74be093 100644 --- a/docs/terrain/terrain-system.md +++ b/docs/terrain/terrain-system.md @@ -38,6 +38,12 @@ struct TerrainComponent { u32 collisionSampleStep = 4; // passo do mesh de colisão bool buildCollisionMesh = true; + bool castShadows = true; + bool receiveShadows = true; + char texturePath[256]; // albedo legacy / fallback + char normalMapPath[256]; // normal map triplanar (GPU) + f32 shininess = 16.0f; // Phong specular + char terrainDataPath[256]; // caminho .cterrain no projeto }; ``` @@ -77,16 +83,29 @@ TerrainCache::initializeEntity / syncEntity | Painel | Função | |--------|--------| -| **Inspector → Terrain** | Resolução, tamanho, LOD, splat, colisão | +| **Inspector → Terrain** | Resolução, tamanho, LOD, splat, colisão, Normal Map, Shininess | | **Terrain Editor** | Sculpt (raise/lower/smooth/flatten/noise), splat paint, export `.cterrain` | Geração procedural: painel **Terrain Generator** (plugin). --- +## Renderização GPU (Phong) + +Com `CF_HAS_SDL3`, o terreno usa `terrain_lit.frag`: + +- Iluminação Phong (ambient + diffuse + specular) com `shininess`. +- Normal map **triplanar** via `TerrainGpuTextureCache::normalMap` (sampler slot 5). +- Sombras direcionais (slots 6–7) e pontuais cubemap (slots 8–9). +- `castShadows` / `receiveShadows` respeitados em `GpuSceneRenderer::gatherMeshDraws`. + +Ver [`rendering/materials-phong.md`](../rendering/materials-phong.md) e [`rendering/shadow-mapping.md`](../rendering/shadow-mapping.md). + +--- + ## Serialização -`SceneSerializer` grava `TerrainComponent` no blob de cena (versão **9**). Campos de geração legados (v2–v8) são lidos e descartados para compatibilidade. Dados de height/splat pesados vivem em `.cterrain` referenciado por `terrainDataPath`. +`SceneSerializer` grava `TerrainComponent` no blob de cena (versão **10**). Versão 9 adicionou `collisionSampleStep` e `buildCollisionMesh`; versão 10 acrescenta `normalMapPath` e `shininess`. Campos de geração legados (v2–v8) são lidos e descartados para compatibilidade. Dados de height/splat pesados vivem em `.cterrain` referenciado por `terrainDataPath`. --- diff --git a/src/assets/AssetHandle.hpp b/src/assets/AssetHandle.hpp index b03ae09..c544e56 100644 --- a/src/assets/AssetHandle.hpp +++ b/src/assets/AssetHandle.hpp @@ -12,7 +12,7 @@ template class AssetHandle { public: AssetHandle(); - AssetHandle(AssetManager* mgr, u32 id); + AssetHandle(AssetManager* mgr, u32 id, u16 generation); ~AssetHandle(); AssetHandle(const AssetHandle& o); @@ -27,13 +27,15 @@ class AssetHandle { explicit operator bool() const; - u32 id() const { return m_id; } + u32 id() const { return m_id; } + u16 generation() const { return m_generation; } private: void reset(); - AssetManager* m_mgr = nullptr; - u32 m_id = ~u32(0); + AssetManager* m_mgr = nullptr; + u32 m_id = ~u32(0); + u16 m_generation = 0; }; } // namespace Caffeine::Assets diff --git a/src/assets/AssetManager.cpp b/src/assets/AssetManager.cpp index d368e69..42cac58 100644 --- a/src/assets/AssetManager.cpp +++ b/src/assets/AssetManager.cpp @@ -2,8 +2,10 @@ #include "core/io/BlobLoader.hpp" #include "tools/PipelineTypes.hpp" +#include #include #include +#include #ifdef CF_DEBUG #include @@ -45,20 +47,79 @@ u32 AssetManager::acquireOrCreate(const char* path, AssetType type) { u32 id = static_cast(m_assets.size()); auto entry = std::make_unique(); - entry->path = key; - entry->cafType = type; + entry->path = key; + entry->cafType = type; + entry->generation = 1; + entry->status.store(LoadStatus::Pending, std::memory_order_release); m_assets.push_back(std::move(entry)); m_pathIndex[key] = id; ++m_totalLoads; return id; } +void AssetManager::clearEntryData(AssetEntry& e) { + m_cachedBytes.fetch_sub(e.sizeBytes, std::memory_order_relaxed); + e.allocator.reset(); + e.header = nullptr; + e.metadata = nullptr; + e.payload = nullptr; + e.resolved = {}; + e.sizeBytes = 0; +} + +void AssetManager::touchEntry(AssetEntry& e) { + e.lastAccessFrame = m_frame; +} + +void AssetManager::notifyInvalidated(u32 id, u16 generation, AssetType type) { + if (m_invalidationListeners.empty()) return; + + const InvalidatedAsset info{id, generation, type}; + for (const auto& listener : m_invalidationListeners) { + if (listener.callback) { + listener.callback(info, listener.userData); + } + } +} + +void AssetManager::invalidateEntry(u32 id, AssetEntry& e) { + const u16 invalidatedGeneration = e.generation; + const AssetType type = e.cafType; + clearEntryData(e); + ++e.generation; + if (e.generation == 0) e.generation = 1; + e.status.store(LoadStatus::Invalid, std::memory_order_release); + ++m_evictedCount; + notifyInvalidated(id, invalidatedGeneration, type); +} + +void AssetManager::evictLruToFitBudget() { + while (m_cachedBytes.load(std::memory_order_relaxed) > m_maxCacheBytes) { + u32 victimId = ~u32(0); + u64 oldestFrame = std::numeric_limits::max(); + + for (u32 i = 0; i < static_cast(m_assets.size()); ++i) { + const auto& entry = m_assets[i]; + if (!entry) continue; + if (entry->refCount.load(std::memory_order_acquire) != 0) continue; + if (entry->status.load(std::memory_order_acquire) != LoadStatus::Ready) continue; + if (entry->lastAccessFrame <= oldestFrame) { + oldestFrame = entry->lastAccessFrame; + victimId = i; + } + } + + if (victimId == ~u32(0)) break; + invalidateEntry(victimId, *m_assets[victimId]); + } +} + void AssetManager::scheduleLoad(u32 id) { { std::lock_guard lock(m_mutex); AssetEntry& e = *m_assets[id]; LoadStatus status = e.status.load(std::memory_order_acquire); - if (status == LoadStatus::Loading || status == LoadStatus::Loaded) return; + if (status == LoadStatus::Loading || status == LoadStatus::Ready) return; e.status.store(LoadStatus::Loading, std::memory_order_release); } @@ -82,9 +143,7 @@ void AssetManager::loadInternal(u32 id) { if (id >= static_cast(m_assets.size())) return; eptr = m_assets[id].get(); LoadStatus status = eptr->status.load(std::memory_order_acquire); - if (status == LoadStatus::Loading || status == LoadStatus::Loaded) { - if (status != LoadStatus::Loading) return; - } + if (status == LoadStatus::Ready) return; eptr->status.store(LoadStatus::Loading, std::memory_order_release); } @@ -126,7 +185,13 @@ void AssetManager::loadInternal(u32 id) { e.lastWriteTime = getFileWriteTime(e.path); #endif - e.status.store(LoadStatus::Loaded, std::memory_order_release); + e.status.store(LoadStatus::Ready, std::memory_order_release); + e.lastAccessFrame = m_frame; + + { + std::lock_guard lock(m_mutex); + evictLruToFitBudget(); + } } void AssetManager::resolveEntry(AssetEntry& e) { @@ -176,11 +241,11 @@ void AssetManager::resolveShader(AssetEntry& e) { void AssetManager::resolveMesh(AssetEntry& e) { const auto* meta = static_cast(e.metadata); const u8* payload = static_cast(e.payload); - + u64 vertexDataSize = static_cast(meta->vertexCount) * sizeof(Vertex3D); const Vertex3D* vertices = reinterpret_cast(payload); const u32* indices = reinterpret_cast(payload + vertexDataSize); - + e.resolved.mesh = Mesh{ vertices, meta->vertexCount, @@ -200,20 +265,14 @@ void AssetManager::resolvePrefab(AssetEntry& e) { void AssetManager::collectGarbage() { std::lock_guard lock(m_mutex); - for (auto& entry : m_assets) { + for (u32 i = 0; i < static_cast(m_assets.size()); ++i) { + auto& entry = m_assets[i]; if (!entry) continue; if (entry->refCount.load(std::memory_order_acquire) != 0) continue; - if (entry->status.load(std::memory_order_acquire) != LoadStatus::Loaded) continue; - - m_cachedBytes.fetch_sub(entry->sizeBytes, std::memory_order_relaxed); - entry->allocator.reset(); - entry->header = nullptr; - entry->metadata = nullptr; - entry->payload = nullptr; - entry->resolved = {}; - entry->sizeBytes = 0; - entry->status.store(LoadStatus::Unloaded, std::memory_order_release); + if (entry->status.load(std::memory_order_acquire) != LoadStatus::Ready) continue; + invalidateEntry(i, *entry); } + evictLruToFitBudget(); } CacheStats AssetManager::cacheStats() const { @@ -225,7 +284,7 @@ CacheStats AssetManager::cacheStats() const { std::lock_guard lock(m_mutex); for (const auto& entry : m_assets) { if (!entry) continue; - if (entry->status.load(std::memory_order_relaxed) != LoadStatus::Loaded) continue; + if (entry->status.load(std::memory_order_relaxed) != LoadStatus::Ready) continue; switch (entry->cafType) { case AssetType::Texture: ++stats.textureCount; break; case AssetType::Audio: ++stats.audioCount; break; @@ -235,37 +294,81 @@ CacheStats AssetManager::cacheStats() const { stats.cacheHitRate = (m_totalLoads > 0) ? static_cast(m_cacheHits) / static_cast(m_totalLoads) : 0.0f; + stats.evictedCount = static_cast(m_evictedCount); return stats; } void AssetManager::tick(u64 frameIndex) { m_frame = frameIndex; + { + std::lock_guard lock(m_mutex); + evictLruToFitBudget(); + } #ifdef CF_DEBUG checkHotReload(); #endif } -void AssetManager::incRef(u32 id) { +void AssetManager::registerInvalidationCallback(AssetInvalidationCallback callback, + void* userData) { + if (!callback) return; std::lock_guard lock(m_mutex); - if (id < static_cast(m_assets.size()) && m_assets[id]) { - m_assets[id]->refCount.fetch_add(1, std::memory_order_relaxed); + for (const auto& listener : m_invalidationListeners) { + if (listener.callback == callback && listener.userData == userData) return; } + m_invalidationListeners.push_back({callback, userData}); } -void AssetManager::decRef(u32 id) { +void AssetManager::unregisterInvalidationCallback(AssetInvalidationCallback callback, + void* userData) { std::lock_guard lock(m_mutex); - if (id < static_cast(m_assets.size()) && m_assets[id]) { - m_assets[id]->refCount.fetch_sub(1, std::memory_order_acq_rel); - } + m_invalidationListeners.erase( + std::remove_if(m_invalidationListeners.begin(), m_invalidationListeners.end(), + [&](const InvalidationListener& listener) { + return listener.callback == callback && + listener.userData == userData; + }), + m_invalidationListeners.end()); +} + +void AssetManager::incRef(u32 id, u16 generation) { + std::lock_guard lock(m_mutex); + if (id >= static_cast(m_assets.size()) || !m_assets[id]) return; + AssetEntry& e = *m_assets[id]; + if (e.generation != generation) return; + touchEntry(e); + e.refCount.fetch_add(1, std::memory_order_relaxed); } -LoadStatus AssetManager::getStatus(u32 id) const { +void AssetManager::decRef(u32 id, u16 generation) { + std::lock_guard lock(m_mutex); + if (id >= static_cast(m_assets.size()) || !m_assets[id]) return; + AssetEntry& e = *m_assets[id]; + if (e.generation != generation) return; + e.refCount.fetch_sub(1, std::memory_order_acq_rel); +} + +LoadStatus AssetManager::getStatus(u32 id, u16 generation) const { std::lock_guard lock(m_mutex); if (id >= static_cast(m_assets.size()) || !m_assets[id]) { - return LoadStatus::Unloaded; + return LoadStatus::Invalid; } - return m_assets[id]->status.load(std::memory_order_acquire); + const AssetEntry& e = *m_assets[id]; + if (e.generation != generation) return LoadStatus::Invalid; + return e.status.load(std::memory_order_acquire); +} + +bool AssetManager::isHandleAlive(u32 id, u16 generation) const { + std::lock_guard lock(m_mutex); + if (id >= static_cast(m_assets.size()) || !m_assets[id]) return false; + return m_assets[id]->generation == generation; +} + +u16 AssetManager::entryGeneration(u32 id) const { + std::lock_guard lock(m_mutex); + if (id >= static_cast(m_assets.size()) || !m_assets[id]) return 0; + return m_assets[id]->generation; } u32 AssetManager::reloadAsset(const char* path) { @@ -279,16 +382,8 @@ u32 AssetManager::reloadAsset(const char* path) { auto it = m_pathIndex.find(key); if (it == m_pathIndex.end()) return ~0u; id = it->second; - - auto& e = *m_assets[id]; - m_cachedBytes.fetch_sub(e.sizeBytes, std::memory_order_relaxed); - e.allocator.reset(); - e.header = nullptr; - e.metadata = nullptr; - e.payload = nullptr; - e.resolved = {}; - e.sizeBytes = 0; - e.status.store(LoadStatus::Unloaded, std::memory_order_release); + clearEntryData(*m_assets[id]); + m_assets[id]->status.store(LoadStatus::Pending, std::memory_order_release); } loadInternal(id); @@ -310,7 +405,7 @@ void AssetManager::checkHotReload() { for (u32 i = 0; i < static_cast(m_assets.size()); ++i) { const auto& e = m_assets[i]; if (!e) continue; - if (e->status.load(std::memory_order_acquire) != LoadStatus::Loaded) continue; + if (e->status.load(std::memory_order_acquire) != LoadStatus::Ready) continue; u64 current = getFileWriteTime(e->path); if (current != 0 && current != e->lastWriteTime) { toReload.push_back(i); @@ -321,15 +416,8 @@ void AssetManager::checkHotReload() { for (u32 id : toReload) { { std::lock_guard lock(m_mutex); - auto& e = *m_assets[id]; - m_cachedBytes.fetch_sub(e.sizeBytes, std::memory_order_relaxed); - e.allocator.reset(); - e.header = nullptr; - e.metadata = nullptr; - e.payload = nullptr; - e.resolved = {}; - e.sizeBytes = 0; - e.status.store(LoadStatus::Unloaded, std::memory_order_release); + clearEntryData(*m_assets[id]); + m_assets[id]->status.store(LoadStatus::Pending, std::memory_order_release); } loadInternal(id); } diff --git a/src/assets/AssetManager.hpp b/src/assets/AssetManager.hpp index 9d0f180..af2ab4b 100644 --- a/src/assets/AssetManager.hpp +++ b/src/assets/AssetManager.hpp @@ -7,6 +7,7 @@ #include "threading/JobSystem.hpp" #include +#include #include #include #include @@ -32,34 +33,43 @@ class AssetManager { AssetHandle loadAsync(const char* path) { u32 id = acquireOrCreate(path, AssetTypeTrait::cafType); scheduleLoad(id); - return AssetHandle(this, id); + return makeHandle(id); } template AssetHandle loadSync(const char* path) { u32 id = acquireOrCreate(path, AssetTypeTrait::cafType); loadInternal(id); - return AssetHandle(this, id); + return makeHandle(id); } void collectGarbage(); CacheStats cacheStats() const; void tick(u64 frameIndex = 0); + void registerInvalidationCallback(AssetInvalidationCallback callback, + void* userData = nullptr); + void unregisterInvalidationCallback(AssetInvalidationCallback callback, + void* userData = nullptr); + // Reload an already-loaded asset from disk by its loaded .caf path. // Returns the asset ID, or ~0u if not found. Thread-safe. u32 reloadAsset(const char* path); - void incRef(u32 id); - void decRef(u32 id); - LoadStatus getStatus(u32 id) const; + void incRef(u32 id, u16 generation); + void decRef(u32 id, u16 generation); + LoadStatus getStatus(u32 id, u16 generation) const; + bool isHandleAlive(u32 id, u16 generation) const; + u16 entryGeneration(u32 id) const; template - const T* getResolved(u32 id) const { + const T* getResolved(u32 id, u16 generation) const { std::lock_guard lock(m_mutex); - if (id >= static_cast(m_assets.size())) return nullptr; + if (id >= static_cast(m_assets.size()) || !m_assets[id]) return nullptr; const AssetEntry& e = *m_assets[id]; - if (e.status.load(std::memory_order_acquire) != LoadStatus::Loaded) return nullptr; + if (e.generation != generation) return nullptr; + if (e.status.load(std::memory_order_acquire) != LoadStatus::Ready) return nullptr; + e.lastAccessFrame = m_frame; if constexpr (std::is_same_v) return &e.resolved.texture; if constexpr (std::is_same_v) return &e.resolved.audio; if constexpr (std::is_same_v) return &e.resolved.shader; @@ -80,7 +90,8 @@ class AssetManager { struct AssetEntry { std::string path; AssetType cafType = AssetType::Unknown; - std::atomic status { LoadStatus::Unloaded }; + std::atomic status { LoadStatus::Pending }; + u16 generation = 1; std::unique_ptr allocator; const CafHeader* header = nullptr; const void* metadata = nullptr; @@ -88,16 +99,26 @@ class AssetManager { ResolvedData resolved {}; std::atomic refCount { 0 }; u64 sizeBytes = 0; - u64 lastAccessFrame = 0; + mutable u64 lastAccessFrame = 0; #ifdef CF_DEBUG u64 lastWriteTime = 0; #endif }; + template + AssetHandle makeHandle(u32 id) { + return AssetHandle(this, id, entryGeneration(id)); + } + u32 acquireOrCreate(const char* path, AssetType type); void scheduleLoad(u32 id); void loadInternal(u32 id); - void resolveEntry(AssetEntry& e); + void clearEntryData(AssetEntry& e); + void invalidateEntry(u32 id, AssetEntry& e); + void evictLruToFitBudget(); + void touchEntry(AssetEntry& e); + void notifyInvalidated(u32 id, u16 generation, AssetType type); + void resolveEntry(AssetEntry& e); void resolveTexture(AssetEntry& e); void resolveAudio(AssetEntry& e); void resolveShader(AssetEntry& e); @@ -120,8 +141,16 @@ class AssetManager { std::atomic m_cachedBytes { 0 }; std::atomic m_pendingJobs { 0 }; + struct InvalidationListener { + AssetInvalidationCallback callback = nullptr; + void* userData = nullptr; + }; + + std::vector m_invalidationListeners; + u64 m_totalLoads = 0; u64 m_cacheHits = 0; + u64 m_evictedCount = 0; u64 m_frame = 0; }; @@ -131,13 +160,13 @@ class AssetManager { template AssetHandle::AssetHandle() - : m_mgr(nullptr), m_id(~u32(0)) {} + : m_mgr(nullptr), m_id(~u32(0)), m_generation(0) {} template -AssetHandle::AssetHandle(AssetManager* mgr, u32 id) - : m_mgr(mgr), m_id(id) +AssetHandle::AssetHandle(AssetManager* mgr, u32 id, u16 generation) + : m_mgr(mgr), m_id(id), m_generation(generation) { - if (m_mgr && m_id != ~u32(0)) m_mgr->incRef(m_id); + if (m_mgr && m_id != ~u32(0)) m_mgr->incRef(m_id, m_generation); } template @@ -145,56 +174,61 @@ AssetHandle::~AssetHandle() { reset(); } template AssetHandle::AssetHandle(const AssetHandle& o) - : m_mgr(o.m_mgr), m_id(o.m_id) + : m_mgr(o.m_mgr), m_id(o.m_id), m_generation(o.m_generation) { - if (m_mgr && m_id != ~u32(0)) m_mgr->incRef(m_id); + if (m_mgr && m_id != ~u32(0)) m_mgr->incRef(m_id, m_generation); } template AssetHandle& AssetHandle::operator=(const AssetHandle& o) { if (this != &o) { reset(); - m_mgr = o.m_mgr; - m_id = o.m_id; - if (m_mgr && m_id != ~u32(0)) m_mgr->incRef(m_id); + m_mgr = o.m_mgr; + m_id = o.m_id; + m_generation = o.m_generation; + if (m_mgr && m_id != ~u32(0)) m_mgr->incRef(m_id, m_generation); } return *this; } template AssetHandle::AssetHandle(AssetHandle&& o) noexcept - : m_mgr(o.m_mgr), m_id(o.m_id) + : m_mgr(o.m_mgr), m_id(o.m_id), m_generation(o.m_generation) { - o.m_mgr = nullptr; - o.m_id = ~u32(0); + o.m_mgr = nullptr; + o.m_id = ~u32(0); + o.m_generation = 0; } template AssetHandle& AssetHandle::operator=(AssetHandle&& o) noexcept { if (this != &o) { reset(); - m_mgr = o.m_mgr; - m_id = o.m_id; - o.m_mgr = nullptr; - o.m_id = ~u32(0); + m_mgr = o.m_mgr; + m_id = o.m_id; + m_generation = o.m_generation; + o.m_mgr = nullptr; + o.m_id = ~u32(0); + o.m_generation = 0; } return *this; } template bool AssetHandle::isValid() const { - return m_mgr != nullptr && m_id != ~u32(0); + return m_mgr != nullptr && m_id != ~u32(0) && + m_mgr->isHandleAlive(m_id, m_generation); } template bool AssetHandle::isReady() const { - return isValid() && m_mgr->getStatus(m_id) == LoadStatus::Loaded; + return isValid() && m_mgr->getStatus(m_id, m_generation) == LoadStatus::Ready; } template const T* AssetHandle::get() const { - if (!isReady()) return nullptr; - return m_mgr->getResolved(m_id); + if (!isValid()) return nullptr; + return m_mgr->getResolved(m_id, m_generation); } template @@ -202,9 +236,10 @@ AssetHandle::operator bool() const { return isReady(); } template void AssetHandle::reset() { - if (m_mgr && m_id != ~u32(0)) m_mgr->decRef(m_id); - m_mgr = nullptr; - m_id = ~u32(0); + if (m_mgr && m_id != ~u32(0)) m_mgr->decRef(m_id, m_generation); + m_mgr = nullptr; + m_id = ~u32(0); + m_generation = 0; } } // namespace Caffeine::Assets diff --git a/src/assets/AssetTypes.hpp b/src/assets/AssetTypes.hpp index 433f11b..7c865db 100644 --- a/src/assets/AssetTypes.hpp +++ b/src/assets/AssetTypes.hpp @@ -19,10 +19,11 @@ using namespace Caffeine; // Load state of a single asset slot // ============================================================================ enum class LoadStatus : u8 { - Unloaded = 0, - Loading, - Loaded, - Failed + Pending = 0, // slot created, load not yet scheduled + Loading = 1, // async or sync load in progress + Ready = 2, // payload resolved and safe to use + Failed = 3, // load attempted but file missing or corrupt + Invalid = 4, // evicted or hot-reloaded; generation was bumped }; // ============================================================================ @@ -72,12 +73,21 @@ struct Prefab { // ============================================================================ // CacheStats — returned by AssetManager::cacheStats() // ============================================================================ +struct InvalidatedAsset { + u32 id = ~u32(0); + u16 generation = 0; + AssetType type = AssetType::Unknown; +}; + +using AssetInvalidationCallback = void (*)(const InvalidatedAsset& info, void* userData); + struct CacheStats { u64 totalCachedBytes = 0; u64 maxCacheBytes = 0; u32 textureCount = 0; u32 audioCount = 0; u32 pendingJobs = 0; + u32 evictedCount = 0; f32 cacheHitRate = 0.0f; }; diff --git a/src/assets/HotReloader.cpp b/src/assets/HotReloader.cpp index fe989fb..5ca60ad 100644 --- a/src/assets/HotReloader.cpp +++ b/src/assets/HotReloader.cpp @@ -66,10 +66,11 @@ void HotReloader::ReloadOne(const std::string& cafPath) { u32 assetId = m_Manager->reloadAsset(cafPath.c_str()); if (assetId == ~0u) return; - auto status = m_Manager->getStatus(assetId); - if (status != LoadStatus::Loaded) return; + const u16 generation = m_Manager->entryGeneration(assetId); + auto status = m_Manager->getStatus(assetId, generation); + if (status != LoadStatus::Ready) return; - auto* tex = m_Manager->getResolved(assetId); + auto* tex = m_Manager->getResolved(assetId, generation); HotReloadedAsset info; info.assetId = assetId; @@ -80,9 +81,9 @@ void HotReloader::ReloadOne(const std::string& cafPath) { // type. This is a lightweight heuristic for the notification only. if (tex) { info.type = AssetType::Texture; - } else if (m_Manager->getResolved(assetId)) { + } else if (m_Manager->getResolved(assetId, generation)) { info.type = AssetType::Audio; - } else if (m_Manager->getResolved(assetId)) { + } else if (m_Manager->getResolved(assetId, generation)) { info.type = AssetType::Shader; } diff --git a/src/assets/MeshLoader.cpp b/src/assets/MeshLoader.cpp index c727890..28e6a1a 100644 --- a/src/assets/MeshLoader.cpp +++ b/src/assets/MeshLoader.cpp @@ -1,5 +1,6 @@ #include "assets/MeshLoader.hpp" #include "assets/MeshLOD.hpp" +#include "assets/MeshNormals.hpp" #define TINYGLTF_IMPLEMENTATION #define TINYGLTF_NO_STB_IMAGE_WRITE @@ -119,12 +120,44 @@ Mesh3D* MeshLoader::parseGLTF(const u8* data, usize dataLen, const char* filenam if (outError) *outError = "glTF nao contem meshes"; return nullptr; } - + auto mesh = new Mesh3D(); mesh->flipTextureV = false; + mesh->materials.resize(model.materials.size()); + for (size_t mi = 0; mi < model.materials.size(); ++mi) { + const auto& gmat = model.materials[mi]; + MeshSurfaceMaterial& mat = mesh->materials[mi]; + const auto& pbr = gmat.pbrMetallicRoughness; + mat.albedoColor = Color{ + static_cast(pbr.baseColorFactor[0]), + static_cast(pbr.baseColorFactor[1]), + static_cast(pbr.baseColorFactor[2]), + static_cast(pbr.baseColorFactor[3]), + }; + mat.metallic = static_cast(pbr.metallicFactor); + mat.roughness = static_cast(pbr.roughnessFactor); + mat.doubleSided = gmat.doubleSided; + + const int texIndex = pbr.baseColorTexture.index; + if (texIndex < 0 || texIndex >= static_cast(model.textures.size())) continue; + + const int imageIndex = model.textures[texIndex].source; + if (imageIndex < 0 || imageIndex >= static_cast(model.images.size())) continue; + + const auto& image = model.images[imageIndex]; + if (!image.image.empty()) { + mat.albedoPixels = image.image; + mat.albedoWidth = static_cast(image.width); + mat.albedoHeight = static_cast(image.height); + mat.albedoChannels = image.component > 0 ? image.component : 4; + } else if (!image.uri.empty()) { + mat.albedoPath = basePath + image.uri; + } + } std::vector vertices; std::vector indices; int primaryMaterialIndex = -1; + bool generateSmoothNormals = false; if (!basePath.empty()) { ensureGltfImagesLoaded(model, basePath); @@ -145,6 +178,9 @@ Mesh3D* MeshLoader::parseGLTF(const u8* data, usize dataLen, const char* filenam if (posIt == primitive.attributes.end()) { continue; } + if (normIt == primitive.attributes.end()) { + generateSmoothNormals = true; + } const auto& posAccessor = model.accessors[posIt->second]; const auto& posBufferView = model.bufferViews[posAccessor.bufferView]; @@ -260,9 +296,14 @@ Mesh3D* MeshLoader::parseGLTF(const u8* data, usize dataLen, const char* filenam } MeshLOD::generateLODs(mesh, 3); - + + if (generateSmoothNormals) { + computeSmoothNormals(*mesh); + } + + computeMeshTangents(*mesh); computeBounds(*mesh); - + return mesh; } diff --git a/src/assets/MeshLoader.hpp b/src/assets/MeshLoader.hpp index 4d99b7f..7a9fb83 100644 --- a/src/assets/MeshLoader.hpp +++ b/src/assets/MeshLoader.hpp @@ -155,6 +155,7 @@ class MeshLoader { submesh.materialIndex = 0; mesh->subMeshes.push_back(submesh); + computeMeshTangents(*mesh); computeBounds(*mesh); return mesh; @@ -219,6 +220,49 @@ class MeshLoader { #endif private: + static void computeMeshTangents(Mesh3D& mesh) { + if (mesh.vertices.empty() || mesh.indices.size() < 3) return; + + std::vector tan1(mesh.vertices.size(), Vec3(0.0f, 0.0f, 0.0f)); + std::vector tan2(mesh.vertices.size(), Vec3(0.0f, 0.0f, 0.0f)); + + for (usize i = 0; i + 2 < mesh.indices.size(); i += 3) { + const Vertex3D& v0 = mesh.vertices[mesh.indices[i]]; + const Vertex3D& v1 = mesh.vertices[mesh.indices[i + 1]]; + const Vertex3D& v2 = mesh.vertices[mesh.indices[i + 2]]; + + const Vec3 e1 = v1.position - v0.position; + const Vec3 e2 = v2.position - v0.position; + const Vec2 duv1 = v1.texcoord - v0.texcoord; + const Vec2 duv2 = v2.texcoord - v0.texcoord; + + const f32 denom = duv1.x * duv2.y - duv2.x * duv1.y; + if (std::abs(denom) < 1e-8f) continue; + const f32 r = 1.0f / denom; + const Vec3 tangent = (e1 * duv2.y - e2 * duv1.y) * r; + const Vec3 bitangent = (e2 * duv1.x - e1 * duv2.x) * r; + + tan1[mesh.indices[i]] += tangent; + tan1[mesh.indices[i + 1]] += tangent; + tan1[mesh.indices[i + 2]] += tangent; + tan2[mesh.indices[i]] += bitangent; + tan2[mesh.indices[i + 1]] += bitangent; + tan2[mesh.indices[i + 2]] += bitangent; + } + + for (usize i = 0; i < mesh.vertices.size(); ++i) { + const Vec3& n = mesh.vertices[i].normal; + Vec3 t = tan1[i]; + if (t.lengthSquared() < 1e-8f) { + mesh.vertices[i].tangent = Vec4(1.0f, 0.0f, 0.0f, 1.0f); + continue; + } + t = (t - n * n.dot(t)).normalized(); + const f32 w = (n.cross(t).dot(tan2[i]) < 0.0f) ? -1.0f : 1.0f; + mesh.vertices[i].tangent = Vec4(t.x, t.y, t.z, w); + } + } + static void computeBounds(Mesh3D& mesh) { if (mesh.vertices.empty()) { mesh.bounds.min = Vec3(0.0f, 0.0f, 0.0f); diff --git a/src/assets/MeshNormals.hpp b/src/assets/MeshNormals.hpp new file mode 100644 index 0000000..8fb3c76 --- /dev/null +++ b/src/assets/MeshNormals.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "assets/MeshTypes.hpp" + +#include + +namespace Caffeine::Assets { + +/// Recomputes per-vertex normals by averaging adjacent face normals. +/// Shared vertices on curved surfaces become smooth; isolated vertices (cube faces, +/// pyramid facets, terrain-style hard edges) keep their facet shading. +inline void computeSmoothNormals(Mesh3D& mesh) { + if (mesh.vertices.empty() || mesh.indices.empty()) return; + + for (auto& vertex : mesh.vertices) { + vertex.normal = Vec3(0.0f, 0.0f, 0.0f); + } + + for (size_t i = 0; i + 2 < mesh.indices.size(); i += 3) { + const u32 i0 = mesh.indices[i + 0]; + const u32 i1 = mesh.indices[i + 1]; + const u32 i2 = mesh.indices[i + 2]; + if (i0 >= mesh.vertices.size() || i1 >= mesh.vertices.size() || i2 >= mesh.vertices.size()) { + continue; + } + + const Vec3& a = mesh.vertices[i0].position; + const Vec3& b = mesh.vertices[i1].position; + const Vec3& c = mesh.vertices[i2].position; + Vec3 faceN = (b - a).cross(c - a); + if (faceN.lengthSquared() < 1e-12f) continue; + + faceN = faceN.normalized(); + mesh.vertices[i0].normal += faceN; + mesh.vertices[i1].normal += faceN; + mesh.vertices[i2].normal += faceN; + } + + for (auto& vertex : mesh.vertices) { + if (vertex.normal.lengthSquared() > 1e-12f) { + vertex.normal = vertex.normal.normalized(); + } else { + vertex.normal = Vec3(0.0f, 1.0f, 0.0f); + } + } +} + +} // namespace Caffeine::Assets diff --git a/src/assets/MeshTypes.hpp b/src/assets/MeshTypes.hpp index e2529aa..6d91fd4 100644 --- a/src/assets/MeshTypes.hpp +++ b/src/assets/MeshTypes.hpp @@ -4,6 +4,7 @@ #include "math/Vec3.hpp" #include "math/Vec4.hpp" #include "containers/FixedString.hpp" +#include #include #ifdef CF_HAS_SDL3 @@ -46,10 +47,24 @@ struct SubMesh { FixedString<64> name; }; +/// Per-surface material data extracted from glTF (or defaults for procedural meshes). +struct MeshSurfaceMaterial { + Color albedoColor = Color::white(); + f32 roughness = 0.5f; + f32 metallic = 0.0f; + bool doubleSided = false; + std::vector albedoPixels; + u32 albedoWidth = 0; + u32 albedoHeight = 0; + int albedoChannels = 0; + std::string albedoPath; +}; + struct Mesh3D { std::vector vertices; std::vector indices; std::vector subMeshes; + std::vector materials; Rect3D bounds; u32 lodCount = 1; diff --git a/src/core/GameLoop.cpp b/src/core/GameLoop.cpp index 9cd1df7..0718d94 100644 --- a/src/core/GameLoop.cpp +++ b/src/core/GameLoop.cpp @@ -1,4 +1,5 @@ #include "GameLoop.hpp" +#include "TimerScheduler.hpp" #include namespace Caffeine { @@ -75,6 +76,7 @@ void GameLoop::setCallbacks(IGameCallbacks* callbacks) { } void GameLoop::processFixedUpdate(f64 dt) { + TimerScheduler::instance().tick(dt); if (m_callbacks) m_callbacks->onFixedUpdate(dt); if (onFixedUpdate) onFixedUpdate(dt); } diff --git a/src/core/TimerScheduler.cpp b/src/core/TimerScheduler.cpp new file mode 100644 index 0000000..94b9e80 --- /dev/null +++ b/src/core/TimerScheduler.cpp @@ -0,0 +1,74 @@ +#include "TimerScheduler.hpp" + +#include + +namespace Caffeine { + +TimerScheduler& TimerScheduler::instance() { + static TimerScheduler scheduler; + return scheduler; +} + +TimerHandle TimerScheduler::scheduleOnce(f64 delaySeconds, std::function callback) { + if (!callback) return 0; + TimerEntry entry; + entry.handle = m_nextHandle++; + entry.remaining = std::max(delaySeconds, 0.0); + entry.repeating = false; + entry.callback = std::move(callback); + m_timers.push_back(std::move(entry)); + return entry.handle; +} + +TimerHandle TimerScheduler::scheduleRepeating(f64 intervalSeconds, std::function callback) { + if (!callback) return 0; + TimerEntry entry; + entry.handle = m_nextHandle++; + entry.remaining = std::max(intervalSeconds, 0.0); + entry.interval = std::max(intervalSeconds, 0.0); + entry.repeating = true; + entry.callback = std::move(callback); + m_timers.push_back(std::move(entry)); + return entry.handle; +} + +void TimerScheduler::cancel(TimerHandle handle) { + if (handle == 0) return; + for (auto& timer : m_timers) { + if (timer.handle == handle) { + timer.active = false; + return; + } + } +} + +void TimerScheduler::cancelAll() { + m_timers.clear(); +} + +void TimerScheduler::tick(f64 fixedDeltaTime) { + if (m_timers.empty()) return; + + for (auto& timer : m_timers) { + if (!timer.active || !timer.callback) continue; + timer.remaining -= fixedDeltaTime; + if (timer.remaining > 0.0) continue; + + timer.callback(); + + if (timer.repeating) { + timer.remaining += timer.interval; + if (timer.remaining <= 0.0) { + timer.remaining = timer.interval; + } + } else { + timer.active = false; + } + } + + m_timers.erase(std::remove_if(m_timers.begin(), m_timers.end(), + [](const TimerEntry& t) { return !t.active; }), + m_timers.end()); +} + +} // namespace Caffeine diff --git a/src/core/TimerScheduler.hpp b/src/core/TimerScheduler.hpp new file mode 100644 index 0000000..17b51b2 --- /dev/null +++ b/src/core/TimerScheduler.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "Types.hpp" +#include +#include + +namespace Caffeine { + +using TimerHandle = u64; + +/// Engine timer scheduler — scheduleOnce / scheduleRepeating with fixed-timestep integration. +class TimerScheduler { +public: + static TimerScheduler& instance(); + + /// Fire once after delay seconds (wall time, decremented each tick). + TimerHandle scheduleOnce(f64 delaySeconds, std::function callback); + + /// Fire every interval seconds. First fire after one interval. + TimerHandle scheduleRepeating(f64 intervalSeconds, std::function callback); + + void cancel(TimerHandle handle); + void cancelAll(); + + /// Call from GameLoop fixed update with fixedDeltaTime. + void tick(f64 fixedDeltaTime); + + usize activeCount() const { return m_timers.size(); } + +private: + struct TimerEntry { + TimerHandle handle = 0; + f64 remaining = 0.0; + f64 interval = 0.0; + bool repeating = false; + bool active = true; + std::function callback; + }; + + TimerHandle m_nextHandle = 1; + std::vector m_timers; +}; + +} // namespace Caffeine diff --git a/src/ecs/CameraComponents.hpp b/src/ecs/CameraComponents.hpp index af6f3af..d902508 100644 --- a/src/ecs/CameraComponents.hpp +++ b/src/ecs/CameraComponents.hpp @@ -1,5 +1,6 @@ #pragma once #include "core/Types.hpp" +#include "ecs/Entity.hpp" #include "math/Vec3.hpp" #include "math/Vec4.hpp" @@ -23,4 +24,25 @@ struct CameraActiveComponent { bool is2D = true; }; +enum class Camera3DControlMode : u8 { + None = 0, + Orbit = 1, + FPS = 2, + Follow = 3, +}; + +/// Drives Position3D / Rotation3D for entities with Camera3DComponent. +struct Camera3DControllerComponent { + Camera3DControlMode mode = Camera3DControlMode::None; + Vec3 orbitTarget = {0.0f, 0.0f, 0.0f}; + f32 orbitDistance = 10.0f; + f32 azimuth = 0.0f; + f32 elevation = 30.0f; + Entity followTarget; + Vec3 followOffset = {0.0f, 2.0f, -5.0f}; + f32 followSmoothing = 0.1f; + f32 mouseSensitivity = 0.15f; + f32 moveSpeed = 5.0f; +}; + } // namespace Caffeine::ECS diff --git a/src/ecs/Components.hpp b/src/ecs/Components.hpp index 5b0f1e0..864128a 100644 --- a/src/ecs/Components.hpp +++ b/src/ecs/Components.hpp @@ -21,6 +21,45 @@ struct Transform { Vec3 scale = {1.0f, 1.0f, 1.0f}; }; +struct Position2D { + f32 x = 0.0f; + f32 y = 0.0f; + + Position2D() = default; + Position2D(f32 x_, f32 y_) : x(x_), y(y_) {} +}; + +struct Velocity2D { + f32 x = 0.0f; + f32 y = 0.0f; + + Velocity2D() = default; + Velocity2D(f32 x_, f32 y_) : x(x_), y(y_) {} +}; + +struct Scale2D { + f32 x = 1.0f; + f32 y = 1.0f; + + Scale2D() = default; + Scale2D(f32 x_, f32 y_) : x(x_), y(y_) {} +}; + +struct Rotation { + f32 angle = 0.0f; + + Rotation() = default; + explicit Rotation(f32 angle_) : angle(angle_) {} +}; + +struct Health { + u32 current = 100; + u32 max = 100; + + Health() = default; + Health(u32 current_, u32 max_) : current(current_), max(max_) {} +}; + struct Acceleration2D { f32 x = 0.0f; f32 y = 0.0f; diff --git a/src/ecs/MeshComponents.hpp b/src/ecs/MeshComponents.hpp index 55949ca..f422148 100644 --- a/src/ecs/MeshComponents.hpp +++ b/src/ecs/MeshComponents.hpp @@ -26,14 +26,37 @@ enum class MeshPrimitive : u8 { Sphere, Capsule, Cylinder, - Plane + Plane, + Cone, + Pyramid, + Torus, }; +/// Curved primitives use smooth vertex normals; flat primitives keep per-face shading. +inline bool meshPrimitiveUsesSmoothShading(MeshPrimitive primitive) { + switch (primitive) { + case MeshPrimitive::Sphere: + case MeshPrimitive::Capsule: + case MeshPrimitive::Cylinder: + case MeshPrimitive::Cone: + case MeshPrimitive::Torus: + return true; + case MeshPrimitive::Custom: + case MeshPrimitive::Cube: + case MeshPrimitive::Plane: + case MeshPrimitive::Pyramid: + default: + return false; + } +} + struct MeshFilterComponent { MeshPrimitive primitive = MeshPrimitive::Cube; std::string customMeshPath; std::string customTexturePath; + std::string customNormalPath; std::string customMaterialPath; + f32 shininess = 32.0f; }; } // namespace Caffeine::ECS diff --git a/src/ecs/TerrainComponents.hpp b/src/ecs/TerrainComponents.hpp index 002feaa..9e49d9b 100644 --- a/src/ecs/TerrainComponents.hpp +++ b/src/ecs/TerrainComponents.hpp @@ -22,7 +22,9 @@ struct TerrainComponent { bool receiveShadows = true; char texturePath[256] = "kenney_prototype-textures/PNG/Light/texture_07.png"; + char normalMapPath[256] = {}; f32 textureTileSize = 8.0f; + f32 shininess = 16.0f; bool useSplatmap = true; char splatLayerPaths[kTerrainSplatLayerCount][256] = { diff --git a/src/editor/AssetBrowser.cpp b/src/editor/AssetBrowser.cpp index d7c05f8..7f0ad77 100644 --- a/src/editor/AssetBrowser.cpp +++ b/src/editor/AssetBrowser.cpp @@ -70,6 +70,12 @@ void AssetBrowser::init(const ProjectConfig& projectConfig) { refresh(); } +void AssetBrowser::shutdownGpu() { +#ifdef CF_HAS_IMGUI + m_previewRenderer.shutdownGpu(); +#endif +} + void AssetBrowser::refresh() { if (m_browseMode == BrowseMode::CapFile) { loadCapFile(m_currentCapPath); diff --git a/src/editor/AssetBrowser.hpp b/src/editor/AssetBrowser.hpp index 1c13053..b08e09d 100644 --- a/src/editor/AssetBrowser.hpp +++ b/src/editor/AssetBrowser.hpp @@ -77,6 +77,7 @@ class AssetBrowser { void init(const char* rootPath); void init(const ProjectConfig& projectConfig); void refresh(); + void shutdownGpu(); // Search void setSearchFilter(const char* filter); diff --git a/src/editor/AssetPreviewRenderer.cpp b/src/editor/AssetPreviewRenderer.cpp index 252feb6..e6d3cb4 100644 --- a/src/editor/AssetPreviewRenderer.cpp +++ b/src/editor/AssetPreviewRenderer.cpp @@ -1,5 +1,6 @@ #include "editor/AssetPreviewRenderer.hpp" #include "editor/EditorIcons.hpp" +#include "editor/ImGuiGpuTexture.hpp" #include "assets/MeshCache.hpp" #include "assets/MeshImportValidator.hpp" #include "math/Vec3.hpp" @@ -125,10 +126,17 @@ bool loadWavPeaks(const std::filesystem::path& path, std::vector(std::max(panelSize.x, 8.0f)), - static_cast(std::max(panelSize.y, 8.0f)), + static_cast(std::max(fbSize.x, 8.0f)), + static_cast(std::max(fbSize.y, 8.0f)), projectRoot); RHI::Texture* preview = viewport.cameraPreviewColorTarget(); if (gpuOk && preview && preview->handle) { diff --git a/src/editor/CameraPreviewPanel.hpp b/src/editor/CameraPreviewPanel.hpp index d415ad4..e83a420 100644 --- a/src/editor/CameraPreviewPanel.hpp +++ b/src/editor/CameraPreviewPanel.hpp @@ -25,6 +25,7 @@ class CameraPreviewPanel { bool isOpen() const { return m_open; } void open() { m_open = true; } void close() { m_open = false; } + void shutdownGpu(); private: #ifdef CF_HAS_IMGUI diff --git a/src/editor/ComponentRegistry.cpp b/src/editor/ComponentRegistry.cpp index 3cba395..1007e61 100644 --- a/src/editor/ComponentRegistry.cpp +++ b/src/editor/ComponentRegistry.cpp @@ -14,6 +14,8 @@ #include "animation/AnimationComponents.hpp" #include "ecs/PostProcessComponents.hpp" #include "editor/ComponentTypeRegistry.hpp" +#include "ecs/Components3D.hpp" +#include "scene/LightingSystem.hpp" namespace Caffeine::Editor { @@ -164,6 +166,7 @@ void registerAllComponents(ComponentRegistry& reg) { w.add(e); w.add(e); if (!w.has(e)) w.add(e); + Scene::applyDefaultSunOrientation(w, e); } }); reg.registerComponent({ diff --git a/src/editor/EditorCameraMath.hpp b/src/editor/EditorCameraMath.hpp new file mode 100644 index 0000000..162e875 --- /dev/null +++ b/src/editor/EditorCameraMath.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "math/Vec3.hpp" + +#include + +namespace Caffeine::Editor { + +/// Offset from orbit focus to camera position (positive pitch = camera above focus). +inline Vec3 editorOrbitOffset(f32 yaw, f32 pitch, f32 distance) { + const f32 sinY = std::sin(yaw); + const f32 cosY = std::cos(yaw); + const f32 sinP = std::sin(pitch); + const f32 cosP = std::cos(pitch); + return Vec3(sinY * cosP, sinP, -cosY * cosP) * distance; +} + +/// Direction from camera toward orbit focus. +inline Vec3 editorLookDirection(f32 yaw, f32 pitch) { + const f32 sinY = std::sin(yaw); + const f32 cosY = std::cos(yaw); + const f32 sinP = std::sin(pitch); + const f32 cosP = std::cos(pitch); + return Vec3(-sinY * cosP, -sinP, cosY * cosP); +} + +inline Vec3 editorCameraPosition(f32 yaw, f32 pitch, f32 distance, const Vec3& focus) { + return focus + editorOrbitOffset(yaw, pitch, distance); +} + +} // namespace Caffeine::Editor diff --git a/src/editor/EditorContext.hpp b/src/editor/EditorContext.hpp index 9bc164a..224db1a 100644 --- a/src/editor/EditorContext.hpp +++ b/src/editor/EditorContext.hpp @@ -144,6 +144,12 @@ class EditorContext { return std::clamp(reach * 6.0f + 400.0f, 400.0f, 4000.0f); } + // ── Texture quality (distance-based LOD around viewers) ───────────── + bool textureQualityEnabled = true; + f32 textureQualityRadius = 35.0f; + f32 textureQualityFalloff = 100.0f; + f32 textureQualityMinScale = 0.25f; + // ── Skybox (3D viewport) ─────────────────────────────────────────── bool skyboxEnabled = true; int skyboxIndex = 0; diff --git a/src/editor/EditorIcons.cpp b/src/editor/EditorIcons.cpp index 482326b..127f9a0 100644 --- a/src/editor/EditorIcons.cpp +++ b/src/editor/EditorIcons.cpp @@ -1,4 +1,5 @@ #include "editor/EditorIcons.hpp" +#include "editor/ImGuiGpuTexture.hpp" #include "editor/EditorPaths.hpp" #include @@ -30,6 +31,9 @@ void EditorIcons::init() { void EditorIcons::shutdown() { #ifdef CF_HAS_IMGUI + for (auto& [_, entry] : s_cache) { + destroyImGuiTexture(entry.texture); + } s_cache.clear(); #endif } diff --git a/src/editor/EditorPreferences.cpp b/src/editor/EditorPreferences.cpp index 5de95e8..c70c48d 100644 --- a/src/editor/EditorPreferences.cpp +++ b/src/editor/EditorPreferences.cpp @@ -92,6 +92,10 @@ EditorPreferences EditorPreferences::load() { readBoolField(json, "uniform_scale_default", prefs.uniformScaleDefault); readFloatField(json, "camera_move_speed", prefs.cameraMoveSpeed); readFloatField(json, "camera_orbit_speed", prefs.cameraOrbitSpeed); + readBoolField(json, "texture_quality_enabled", prefs.textureQualityEnabled); + readFloatField(json, "texture_quality_radius", prefs.textureQualityRadius); + readFloatField(json, "texture_quality_falloff", prefs.textureQualityFalloff); + readFloatField(json, "texture_quality_min_scale", prefs.textureQualityMinScale); readBoolField(json, "show_fps_status_bar", prefs.showFPSInStatusBar); readBoolField(json, "confirm_scene_close", prefs.confirmOnSceneClose); readBoolField(json, "reopen_last_scene", prefs.reopenLastSceneOnStartup); @@ -119,6 +123,10 @@ bool EditorPreferences::save() const { file << " \"uniform_scale_default\": " << (uniformScaleDefault ? "true" : "false") << ",\n"; file << " \"camera_move_speed\": " << cameraMoveSpeed << ",\n"; file << " \"camera_orbit_speed\": " << cameraOrbitSpeed << ",\n"; + file << " \"texture_quality_enabled\": " << (textureQualityEnabled ? "true" : "false") << ",\n"; + file << " \"texture_quality_radius\": " << textureQualityRadius << ",\n"; + file << " \"texture_quality_falloff\": " << textureQualityFalloff << ",\n"; + file << " \"texture_quality_min_scale\": " << textureQualityMinScale << ",\n"; file << " \"show_fps_status_bar\": " << (showFPSInStatusBar ? "true" : "false") << ",\n"; file << " \"confirm_scene_close\": " << (confirmOnSceneClose ? "true" : "false") << ",\n"; file << " \"reopen_last_scene\": " << (reopenLastSceneOnStartup ? "true" : "false") << "\n"; diff --git a/src/editor/EditorPreferences.hpp b/src/editor/EditorPreferences.hpp index 36e40e9..bad2abe 100644 --- a/src/editor/EditorPreferences.hpp +++ b/src/editor/EditorPreferences.hpp @@ -23,6 +23,10 @@ struct EditorPreferences { bool uniformScaleDefault = true; f32 cameraMoveSpeed = 5.0f; f32 cameraOrbitSpeed = 0.005f; + bool textureQualityEnabled = true; + f32 textureQualityRadius = 35.0f; + f32 textureQualityFalloff = 100.0f; + f32 textureQualityMinScale = 0.25f; bool showFPSInStatusBar = true; bool confirmOnSceneClose = true; bool reopenLastSceneOnStartup = true; diff --git a/src/editor/GameplayPreviewPanel.cpp b/src/editor/GameplayPreviewPanel.cpp index e432112..56b4253 100644 --- a/src/editor/GameplayPreviewPanel.cpp +++ b/src/editor/GameplayPreviewPanel.cpp @@ -1,5 +1,6 @@ #include "editor/GameplayPreviewPanel.hpp" #include "editor/EditorPanelUtils.hpp" +#include "editor/ImGuiGpuTexture.hpp" #include "ui/UIRenderer.hpp" #include "render/PostProcessRenderer.hpp" @@ -92,6 +93,10 @@ void GameplayPreviewPanel::shutdown() { m_renderer.shutdown(); } #ifdef CF_HAS_IMGUI + for (auto& [_, entry] : m_texCache2D) { + destroyImGuiTexture(entry.texture); + } + m_texCache2D.clear(); m_skyboxRenderer.releaseGpuTextures(); #endif m_colorTarget = nullptr; @@ -197,10 +202,11 @@ void GameplayPreviewPanel::render(ECS::World& world, EditorContext& ctx) { } else #ifdef CF_HAS_SDL3 if (found3D && cam && m_ready && m_frameCmd && m_renderer.isReady()) { - u32 w = static_cast(panelSize.x); - u32 h = static_cast(panelSize.y); - w = std::clamp(w, 8u, 1280u); - h = std::clamp(h, 8u, 720u); + const ImVec2 fbSize = imguiFramebufferSize(panelSize); + u32 w = static_cast(fbSize.x); + u32 h = static_cast(fbSize.y); + w = std::clamp(w, 8u, 2560u); + h = std::clamp(h, 8u, 1440u); resizeCanvas(w, h); const Mat4 worldMatrix = Scene::computeWorldMatrix(world, cameraEntity); diff --git a/src/editor/HierarchyPanel.cpp b/src/editor/HierarchyPanel.cpp index 43f8f8b..610c8ed 100644 --- a/src/editor/HierarchyPanel.cpp +++ b/src/editor/HierarchyPanel.cpp @@ -6,6 +6,11 @@ #include "ui/UIComponents.hpp" #include "scene/HierarchySystem.hpp" #include "ecs/TerrainComponents.hpp" +#include "ecs/LightComponents.hpp" +#include "ecs/CameraComponents.hpp" +#include "ecs/Components3D.hpp" +#include "scene/LightingSystem.hpp" +#include "math/Quat.hpp" #include "terrain/TerrainCache.hpp" #include #include @@ -100,12 +105,11 @@ void HierarchyPanel::renderSearchBar() { void HierarchyPanel::renderToolbar() { if (ImGui::Button("+##hierarchy_add", ImVec2(24, 0))) { - m_context->beginUndo(EditorCommand::AddEntity, u32_max, *m_world); - ECS::Entity e = m_world->create(); - setEntityName(*m_world, e, "New Entity"); - m_world->add(e); - m_context->selectEntity(e); - m_context->endUndo(*m_world); + ImGui::OpenPopup("hierarchy_create_entity"); + } + if (ImGui::BeginPopup("hierarchy_create_entity")) { + renderCreateEntityMenuItems(); + ImGui::EndPopup(); } ImGui::SameLine(); if (ImGui::Button("Presets##hierarchy_presets", ImVec2(72, 0))) { @@ -334,6 +338,8 @@ void HierarchyPanel::createEntityWithType(ECS::World& world, const char* name, c else if (strcmp(componentType, "DirectionalLight") == 0) { world.add(e); world.add(e); + world.add(e); + Scene::applyDefaultSunOrientation(world, e); } else if (strcmp(componentType, "PointLight") == 0) { world.add(e); @@ -429,6 +435,33 @@ void HierarchyPanel::createEntityWithType(ECS::World& world, const char* name, c world.add(e, mf); world.add(e); } + else if (strcmp(componentType, "Cone3D") == 0) { + world.add(e); + world.add(e); + world.add(e); + ECS::MeshFilterComponent mf; + mf.primitive = ECS::MeshPrimitive::Cone; + world.add(e, mf); + world.add(e); + } + else if (strcmp(componentType, "Pyramid3D") == 0) { + world.add(e); + world.add(e); + world.add(e); + ECS::MeshFilterComponent mf; + mf.primitive = ECS::MeshPrimitive::Pyramid; + world.add(e, mf); + world.add(e); + } + else if (strcmp(componentType, "Torus3D") == 0) { + world.add(e); + world.add(e); + world.add(e); + ECS::MeshFilterComponent mf; + mf.primitive = ECS::MeshPrimitive::Torus; + world.add(e, mf); + world.add(e); + } else if (strcmp(componentType, "Skybox") == 0) { ECS::SkyboxComponent sky; sky.presetIndex = 0; @@ -497,72 +530,78 @@ void HierarchyPanel::createEntityWithType(ECS::World& world, const char* name, c m_context->endUndo(world); } -void HierarchyPanel::renderEmptyContextMenu() { - if (ImGui::BeginPopupContextWindow("hierarchy_empty_ctx")) { - if (ImGui::BeginMenu("Create Entity")) { - if (ImGui::MenuItem("Empty Entity")) { - m_context->beginUndo(EditorCommand::AddEntity, u32_max, *m_world); - ECS::Entity e = m_world->create(); - setEntityName(*m_world, e, "New Entity"); - m_world->add(e); - m_context->selectEntity(e); - m_context->endUndo(*m_world); - } - ImGui::Separator(); - - if (ImGui::BeginMenu("2D Objects")) { - if (ImGui::MenuItem("Sprite")) createEntityWithType(*m_world, "Sprite", "Sprite2D"); - if (ImGui::MenuItem("Sprite (Box Collider)")) createEntityWithType(*m_world, "Sprite", "Sprite2DBox"); - if (ImGui::MenuItem("Sprite (Circle Collider)")) createEntityWithType(*m_world, "Sprite", "Sprite2DCircle"); - if (ImGui::MenuItem("Sprite (Capsule Collider)")) createEntityWithType(*m_world, "Sprite", "Sprite2DCapsule"); - ImGui::EndMenu(); - } +void HierarchyPanel::renderCreateEntityMenuItems() { + if (ImGui::MenuItem("Empty Entity")) { + m_context->beginUndo(EditorCommand::AddEntity, u32_max, *m_world); + ECS::Entity e = m_world->create(); + setEntityName(*m_world, e, "New Entity"); + m_world->add(e); + m_context->selectEntity(e); + m_context->endUndo(*m_world); + } + ImGui::Separator(); - if (ImGui::BeginMenu("3D Objects")) { - if (ImGui::MenuItem("Cube")) createEntityWithType(*m_world, "Cube", "Cube3D"); - if (ImGui::MenuItem("Sphere")) createEntityWithType(*m_world, "Sphere", "Sphere3D"); - if (ImGui::MenuItem("Capsule")) createEntityWithType(*m_world, "Capsule", "Capsule3D"); - if (ImGui::MenuItem("Cylinder")) createEntityWithType(*m_world, "Cylinder", "Cylinder3D"); - if (ImGui::MenuItem("Plane")) createEntityWithType(*m_world, "Plane", "Plane3D"); - ImGui::EndMenu(); - } + if (ImGui::BeginMenu("2D Objects")) { + if (ImGui::MenuItem("Sprite")) createEntityWithType(*m_world, "Sprite", "Sprite2D"); + if (ImGui::MenuItem("Sprite (Box Collider)")) createEntityWithType(*m_world, "Sprite", "Sprite2DBox"); + if (ImGui::MenuItem("Sprite (Circle Collider)")) createEntityWithType(*m_world, "Sprite", "Sprite2DCircle"); + if (ImGui::MenuItem("Sprite (Capsule Collider)")) createEntityWithType(*m_world, "Sprite", "Sprite2DCapsule"); + ImGui::EndMenu(); + } - if (ImGui::BeginMenu("Camera")) { - if (ImGui::MenuItem("Camera 2D")) createEntityWithType(*m_world, "Camera 2D", "Camera2D"); - if (ImGui::MenuItem("Camera 3D")) createEntityWithType(*m_world, "Camera 3D", "Camera3D"); - ImGui::EndMenu(); - } + if (ImGui::BeginMenu("3D Objects")) { + if (ImGui::MenuItem("Cube")) createEntityWithType(*m_world, "Cube", "Cube3D"); + if (ImGui::MenuItem("Sphere")) createEntityWithType(*m_world, "Sphere", "Sphere3D"); + if (ImGui::MenuItem("Capsule")) createEntityWithType(*m_world, "Capsule", "Capsule3D"); + if (ImGui::MenuItem("Cylinder")) createEntityWithType(*m_world, "Cylinder", "Cylinder3D"); + if (ImGui::MenuItem("Plane")) createEntityWithType(*m_world, "Plane", "Plane3D"); + if (ImGui::MenuItem("Cone")) createEntityWithType(*m_world, "Cone", "Cone3D"); + if (ImGui::MenuItem("Pyramid")) createEntityWithType(*m_world, "Pyramid", "Pyramid3D"); + if (ImGui::MenuItem("Torus")) createEntityWithType(*m_world, "Torus", "Torus3D"); + ImGui::EndMenu(); + } - if (ImGui::BeginMenu("Light")) { - if (ImGui::MenuItem("Directional Light")) createEntityWithType(*m_world, "Directional Light", "DirectionalLight"); - if (ImGui::MenuItem("Point Light")) createEntityWithType(*m_world, "Point Light", "PointLight"); - if (ImGui::MenuItem("Spot Light")) createEntityWithType(*m_world, "Spot Light", "SpotLight"); - ImGui::EndMenu(); - } + if (ImGui::BeginMenu("Camera")) { + if (ImGui::MenuItem("Camera 2D")) createEntityWithType(*m_world, "Camera 2D", "Camera2D"); + if (ImGui::MenuItem("Camera 3D")) createEntityWithType(*m_world, "Camera 3D", "Camera3D"); + ImGui::EndMenu(); + } - if (ImGui::BeginMenu("Environment")) { - if (ImGui::MenuItem("Skybox")) createEntityWithType(*m_world, "Skybox", "Skybox"); - if (ImGui::MenuItem("Terrain")) createEntityWithType(*m_world, "Terrain", "Terrain"); - ImGui::EndMenu(); - } + if (ImGui::BeginMenu("Light")) { + if (ImGui::MenuItem("Directional Light")) createEntityWithType(*m_world, "Directional Light", "DirectionalLight"); + if (ImGui::MenuItem("Point Light")) createEntityWithType(*m_world, "Point Light", "PointLight"); + if (ImGui::MenuItem("Spot Light")) createEntityWithType(*m_world, "Spot Light", "SpotLight"); + ImGui::EndMenu(); + } - if (ImGui::BeginMenu("UI")) { - if (ImGui::MenuItem("Canvas")) createEntityWithType(*m_world, "Canvas", "UICanvas"); - if (ImGui::MenuItem("Panel")) createEntityWithType(*m_world, "Panel", "UIPanel"); - if (ImGui::MenuItem("Label")) createEntityWithType(*m_world, "Label", "UILabel"); - if (ImGui::MenuItem("Button")) createEntityWithType(*m_world, "Button", "UIButton"); - if (ImGui::MenuItem("Progress Bar")) createEntityWithType(*m_world, "Progress Bar", "UIProgressBar"); - if (ImGui::MenuItem("Slider")) createEntityWithType(*m_world, "Slider", "UISlider"); - ImGui::EndMenu(); - } + if (ImGui::BeginMenu("Environment")) { + if (ImGui::MenuItem("Skybox")) createEntityWithType(*m_world, "Skybox", "Skybox"); + if (ImGui::MenuItem("Terrain")) createEntityWithType(*m_world, "Terrain", "Terrain"); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("UI")) { + if (ImGui::MenuItem("Canvas")) createEntityWithType(*m_world, "Canvas", "UICanvas"); + if (ImGui::MenuItem("Panel")) createEntityWithType(*m_world, "Panel", "UIPanel"); + if (ImGui::MenuItem("Label")) createEntityWithType(*m_world, "Label", "UILabel"); + if (ImGui::MenuItem("Button")) createEntityWithType(*m_world, "Button", "UIButton"); + if (ImGui::MenuItem("Progress Bar")) createEntityWithType(*m_world, "Progress Bar", "UIProgressBar"); + if (ImGui::MenuItem("Slider")) createEntityWithType(*m_world, "Slider", "UISlider"); + ImGui::EndMenu(); + } - ImGui::Separator(); + ImGui::Separator(); - if (ImGui::BeginMenu("System")) { - if (ImGui::MenuItem("Game Manager")) createEntityWithType(*m_world, "Game Manager", "GameManager"); - ImGui::EndMenu(); - } + if (ImGui::BeginMenu("System")) { + if (ImGui::MenuItem("Game Manager")) createEntityWithType(*m_world, "Game Manager", "GameManager"); + ImGui::EndMenu(); + } +} +void HierarchyPanel::renderEmptyContextMenu() { + if (ImGui::BeginPopupContextWindow("hierarchy_empty_ctx")) { + if (ImGui::BeginMenu("Create Entity")) { + renderCreateEntityMenuItems(); ImGui::EndMenu(); } if (m_context->clipboardEntity.isValid()) { diff --git a/src/editor/HierarchyPanel.hpp b/src/editor/HierarchyPanel.hpp index 7e61382..f09c7cd 100644 --- a/src/editor/HierarchyPanel.hpp +++ b/src/editor/HierarchyPanel.hpp @@ -41,6 +41,7 @@ class HierarchyPanel { void renderToolbar(); void renderEntityNode(ECS::Entity entity); void renderEmptyContextMenu(); + void renderCreateEntityMenuItems(); void handleDeleteKey(); void createEntityWithType(ECS::World& world, const char* name, const char* componentType); diff --git a/src/editor/ImGuiGpuTexture.hpp b/src/editor/ImGuiGpuTexture.hpp new file mode 100644 index 0000000..9092ae4 --- /dev/null +++ b/src/editor/ImGuiGpuTexture.hpp @@ -0,0 +1,51 @@ +#pragma once + +#ifdef CF_HAS_IMGUI +#ifdef CF_HAS_SDL3 + +#include +#include +#include +#include + +namespace Caffeine::Editor { + +/// Logical ImGui size → framebuffer pixels (HiDPI / DisplayFramebufferScale). +/// maxDim caps the longest edge to avoid 4K+ offscreen targets in editor panels. +inline ImVec2 imguiFramebufferSize(ImVec2 logicalSize, int maxDim = 1920) { + const ImVec2 scale = ImGui::GetIO().DisplayFramebufferScale; + float w = logicalSize.x * scale.x; + float h = logicalSize.y * scale.y; + if (w < 1.0f) w = 1.0f; + if (h < 1.0f) h = 1.0f; + const float longest = w > h ? w : h; + if (maxDim > 0 && longest > static_cast(maxDim)) { + const float shrink = static_cast(maxDim) / longest; + w *= shrink; + h *= shrink; + } + return ImVec2(w, h); +} + +/// Releases GPU backing for a user-owned ImTextureData (sprites, icons, previews). +inline void destroyImGuiTexture(std::unique_ptr& texture) { + if (!texture) return; + if (texture->GetTexID() != ImTextureID_Invalid) { + texture->UnusedFrames = 1; + texture->SetStatus(ImTextureStatus_WantDestroy); + ImGui_ImplSDLGPU3_UpdateTexture(texture.get()); + } + texture.reset(); +} + +inline void clearRetiredImGuiTextures(std::vector>& retired) { + for (auto& texture : retired) { + destroyImGuiTexture(texture); + } + retired.clear(); +} + +} // namespace Caffeine::Editor + +#endif // CF_HAS_SDL3 +#endif // CF_HAS_IMGUI diff --git a/src/editor/ImGuiIntegration.hpp b/src/editor/ImGuiIntegration.hpp index 3162479..e320995 100644 --- a/src/editor/ImGuiIntegration.hpp +++ b/src/editor/ImGuiIntegration.hpp @@ -41,6 +41,7 @@ class ImGuiIntegration { void shutdown() { if (!m_initialized) return; + ImGui_ImplSDLGPU3_DestroyDeviceObjects(); ImGui_ImplSDLGPU3_Shutdown(); ImGui_ImplSDL3_Shutdown(); ImGui::DestroyContext(); diff --git a/src/editor/InspectorPanel.cpp b/src/editor/InspectorPanel.cpp index 1ab58ff..14894fc 100644 --- a/src/editor/InspectorPanel.cpp +++ b/src/editor/InspectorPanel.cpp @@ -606,21 +606,42 @@ void InspectorPanel::drawMeshFilter(ECS::World& world, ECS::Entity e, EditorCont return; } - static const char* primitiveNames[] = { "Custom", "Cube", "Sphere", "Capsule", "Cylinder", "Plane" }; + static const char* primitiveNames[] = { + "Custom", "Cube", "Sphere", "Capsule", "Cylinder", "Plane", "Cone", "Pyramid", "Torus"}; int current = static_cast(mf->primitive); - if (ImGui::Combo("Primitive", ¤t, primitiveNames, 6)) { + if (ImGui::Combo("Primitive", ¤t, primitiveNames, IM_ARRAYSIZE(primitiveNames))) { mf->primitive = static_cast(current); ctx.isDirty = true; } + if (mf->primitive != ECS::MeshPrimitive::Custom) { + if (!world.has(e)) { + world.add(e); + } + if (auto* scale = world.get(e)) { + if (ImGui::DragFloat3("Shape Size", &scale->scale.x, 0.05f, 0.01f, 1000.0f, "%.2f")) { + ctx.isDirty = true; + } + ImGui::TextDisabled("Non-uniform scale stretches the primitive."); + } + } if (mf->primitive == ECS::MeshPrimitive::Custom) { if (Widgets::AssetField("Mesh", mf->customMeshPath, ".obj;.fbx;.gltf;.glb", resolveProjectRoot(ctx))) ctx.isDirty = true; if (world.has(e)) { ImGui::TextDisabled("Texture managed by Terrain component"); - } else if (Widgets::AssetField("Mesh Texture", mf->customTexturePath, ".png;.jpg;.jpeg", + } else if (Widgets::AssetField("Albedo Texture", mf->customTexturePath, ".png;.jpg;.jpeg", resolveProjectRoot(ctx))) { ctx.isDirty = true; } + if (!world.has(e)) { + if (Widgets::AssetField("Normal Map", mf->customNormalPath, ".png;.jpg;.jpeg", + resolveProjectRoot(ctx))) { + ctx.isDirty = true; + } + if (ImGui::SliderFloat("Shininess", &mf->shininess, 1.0f, 128.0f)) { + ctx.isDirty = true; + } + } if (Widgets::AssetField("Material", mf->customMaterialPath, ".mat", resolveProjectRoot(ctx))) ctx.isDirty = true; if (!world.has(e) && !mf->customTexturePath.empty()) { @@ -1029,6 +1050,15 @@ void InspectorPanel::drawTerrain(ECS::World& world, ECS::Entity e, EditorContext ImGui::Separator(); ImGui::TextUnformatted("Surface"); + if (ImGui::InputText("Normal Map", terrain->normalMapPath, sizeof(terrain->normalMapPath))) { +#ifdef CF_HAS_SDL3 + Terrain::TerrainGpuTextureCache::instance().invalidateEntity(e, nullptr); +#endif + ctx.isDirty = true; + } + if (ImGui::SliderFloat("Shininess", &terrain->shininess, 1.0f, 128.0f)) { + ctx.isDirty = true; + } if (ImGui::InputText("Albedo Texture", terrain->texturePath, sizeof(terrain->texturePath))) { Terrain::TerrainCache::instance().repairTexturePaths(*terrain); Terrain::TerrainCache::instance().syncTextureToFilter(world, e, *terrain); diff --git a/src/editor/PluginSymbolExports.cpp b/src/editor/PluginSymbolExports.cpp new file mode 100644 index 0000000..9b33e70 --- /dev/null +++ b/src/editor/PluginSymbolExports.cpp @@ -0,0 +1,26 @@ +#include "editor/PluginSymbolExports.hpp" + +#include "terrain/TerrainHeightmapImporter.hpp" + +#include + +namespace Caffeine::Editor { + +// dlopen'd plugins resolve core symbols from the doppio executable (-rdynamic). +// Keep global references so the linker pulls in TerrainHeightmapImporter and exports +// the symbols (function-local statics were optimized away). +[[gnu::used]] void* g_pluginTerrainSymbols[] = { + reinterpret_cast(&Terrain::importHeightmapFile), + reinterpret_cast(&Terrain::applyAbsoluteHeights), + reinterpret_cast(&Terrain::loadHeightmapTextFile), +}; + +void anchorPluginHostSymbols() { + for (void* symbol : g_pluginTerrainSymbols) { + if (symbol == nullptr) { + std::abort(); + } + } +} + +} // namespace Caffeine::Editor diff --git a/src/editor/PluginSymbolExports.hpp b/src/editor/PluginSymbolExports.hpp new file mode 100644 index 0000000..a250742 --- /dev/null +++ b/src/editor/PluginSymbolExports.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace Caffeine::Editor { + +/// Keeps plugin-resolved symbols in the doppio binary export table. +void anchorPluginHostSymbols(); + +} // namespace Caffeine::Editor diff --git a/src/editor/PreviewRenderer.cpp b/src/editor/PreviewRenderer.cpp index ddb2aa8..0766947 100644 --- a/src/editor/PreviewRenderer.cpp +++ b/src/editor/PreviewRenderer.cpp @@ -1,4 +1,5 @@ #include "editor/PreviewRenderer.hpp" +#include "editor/ImGuiGpuTexture.hpp" #include "render/ShaderBytecode.hpp" #include "math/Mat4.hpp" @@ -41,7 +42,7 @@ bool PreviewRenderer::init(RHI::RenderDevice* device) { void PreviewRenderer::shutdown() { #ifdef CF_HAS_IMGUI - m_previewTexture.reset(); + destroyImGuiTexture(m_previewTexture); m_cachedAlbedo = Vec4(-1.0f, -1.0f, -1.0f, -1.0f); m_cachedMetallic = -1.0f; m_cachedRoughness = -1.0f; diff --git a/src/editor/SceneEditor.cpp b/src/editor/SceneEditor.cpp index b0c6ca4..6ff1d36 100644 --- a/src/editor/SceneEditor.cpp +++ b/src/editor/SceneEditor.cpp @@ -9,6 +9,8 @@ #include "debug/Profiler.hpp" #include "debug/LogSystem.hpp" #include "editor/PluginSystem.hpp" +#include "editor/PluginSymbolExports.hpp" +#include "render/GpuTextureCache.hpp" #include "editor/EntityPresetRegistry.hpp" #include "editor/EditorPaths.hpp" #include "scene/HierarchySystem.hpp" @@ -137,6 +139,7 @@ bool SceneEditor::init(RHI::RenderDevice* device, Assets::AssetManager* assetMan ? (std::filesystem::current_path() / "plugins") : (projectConfig.RootPath / "plugins"); const std::filesystem::path bundledPluginsDir = EditorPaths::bundledPluginsDirectory(); + anchorPluginHostSymbols(); PluginManager::instance().initialize(pluginsDir, &m_inspector, &m_commandPalette, &m_ctx, bundledPluginsDir); @@ -197,7 +200,13 @@ void SceneEditor::shutdown() { PluginManager::instance().shutdown(); #ifdef CF_HAS_SDL3 if (m_renderDevice) { + m_viewport.shutdown(); + m_gameplayPreview.shutdown(); + m_cameraPreview.shutdownGpu(); m_materialEditor.shutdownGpu(); + m_assetBrowser.shutdownGpu(); + EditorIcons::shutdown(); + Render::GpuTextureCache::instance().releaseAll(m_renderDevice); Assets::MeshCache::getInstance().releaseGpuResources(m_renderDevice); Render::GpuProceduralMeshes::releaseGpuResources(m_renderDevice); Terrain::TerrainCache::instance().releaseGpuResources(m_renderDevice); @@ -205,10 +214,6 @@ void SceneEditor::shutdown() { #endif Terrain::TerrainCache::instance().clear(); m_tabManager.clearAll(); - m_viewport.shutdown(); -#ifdef CF_HAS_SDL3 - m_gameplayPreview.shutdown(); -#endif m_audioPreview.shutdown(); m_scriptFileWatcher.stop(); m_scriptWatcherStarted = false; diff --git a/src/editor/SceneSerializer.cpp b/src/editor/SceneSerializer.cpp index 61baa36..00ab271 100644 --- a/src/editor/SceneSerializer.cpp +++ b/src/editor/SceneSerializer.cpp @@ -28,7 +28,7 @@ namespace IO = SceneSerializerIO; namespace { -constexpr u32 kTerrainBlobVersion = 9; +constexpr u32 kTerrainBlobVersion = 10; std::filesystem::path projectRootFromScenePath(const std::string& scenePath) { if (scenePath.empty()) return {}; @@ -150,7 +150,9 @@ void SceneSerializer::collectMeshFilterComponents( u32 meshPathLen = static_cast(mf.customMeshPath.size()); u32 texturePathLen = static_cast(mf.customTexturePath.size()); u32 materialPathLen = static_cast(mf.customMaterialPath.size()); - std::vector data(13 + meshPathLen + texturePathLen + materialPathLen); + u32 normalPathLen = static_cast(mf.customNormalPath.size()); + std::vector data(17 + meshPathLen + texturePathLen + materialPathLen + normalPathLen + + sizeof(f32)); u32 offset = 0; memcpy(data.data() + offset, &prim, 1); offset += 1; memcpy(data.data() + offset, &meshPathLen, 4); offset += 4; @@ -166,7 +168,15 @@ void SceneSerializer::collectMeshFilterComponents( memcpy(data.data() + offset, &materialPathLen, 4); offset += 4; if (materialPathLen > 0) { memcpy(data.data() + offset, mf.customMaterialPath.data(), materialPathLen); + offset += materialPathLen; } + memcpy(data.data() + offset, &normalPathLen, 4); + offset += 4; + if (normalPathLen > 0) { + memcpy(data.data() + offset, mf.customNormalPath.data(), normalPathLen); + offset += normalPathLen; + } + memcpy(data.data() + offset, &mf.shininess, sizeof(f32)); entries.push_back({e.id(), std::move(data)}); }); } @@ -489,6 +499,8 @@ std::vector SceneSerializer::serializeTerrainComponent(const ECS::TerrainCom } appendU32(data, terrain.collisionSampleStep); appendU8(data, terrain.buildCollisionMesh ? 1 : 0); + appendString(data, terrain.normalMapPath); + appendF32(data, terrain.shininess); return data; } @@ -718,6 +730,13 @@ bool SceneSerializer::deserializeTerrainComponent(const u8* data, u32 size, terrain.buildCollisionMesh = buildCollision != 0; } + if (blobVersion >= 10) { + if (!readString(cursor, end, terrain.normalMapPath, sizeof(terrain.normalMapPath))) { + return false; + } + if (!readF32(cursor, end, terrain.shininess)) return false; + } + return cursor <= end; } @@ -1254,14 +1273,28 @@ bool SceneSerializer::applyMeshFilterComponent(ECS::Entity e, const u8* data, u3 } } + u32 materialPathLen = 0; const u32 materialOffset = textureOffset + 4 + texturePathLen; if (materialOffset + 4 <= size) { - u32 materialPathLen = 0; memcpy(&materialPathLen, data + materialOffset, 4); if (materialOffset + 4 + materialPathLen <= size && materialPathLen > 0) { mf.customMaterialPath.assign(reinterpret_cast(data + materialOffset + 4), materialPathLen); } } + + const u32 normalOffset = materialOffset + 4 + materialPathLen; + if (normalOffset + 4 <= size) { + u32 normalPathLen = 0; + memcpy(&normalPathLen, data + normalOffset, 4); + if (normalOffset + 4 + normalPathLen <= size && normalPathLen > 0) { + mf.customNormalPath.assign(reinterpret_cast(data + normalOffset + 4), + normalPathLen); + } + const u32 shininessOffset = normalOffset + 4 + normalPathLen; + if (shininessOffset + sizeof(f32) <= size) { + memcpy(&mf.shininess, data + shininessOffset, sizeof(f32)); + } + } return true; } diff --git a/src/editor/SceneViewport.cpp b/src/editor/SceneViewport.cpp index 548f711..e658b2f 100644 --- a/src/editor/SceneViewport.cpp +++ b/src/editor/SceneViewport.cpp @@ -6,6 +6,8 @@ #include "assets/MaterialCache.hpp" #include "editor/PrefabSystem.hpp" #include "editor/EditorContext.hpp" +#include "editor/EditorCameraMath.hpp" +#include "editor/ImGuiGpuTexture.hpp" #include "editor/TestInstrumentation.hpp" #include "editor/TestUIMapper.hpp" #include "editor/TestRequestHandler.hpp" @@ -90,9 +92,8 @@ struct ViewportRay { ViewportRay computeViewportRay(const EditorContext& ctx, ImVec2 viewportOrigin, ImVec2 viewportSize, ImVec2 mousePos) { - const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); - const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 camPos = ctx.camFocus + Vec3(sinY * cosP, -sinP, -cosY * cosP) * ctx.camDistance; + const Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); const Mat4 view = Mat4::lookAt(camPos, ctx.camFocus, Vec3(0.0f, 1.0f, 0.0f)); const f32 aspect = viewportSize.x / std::max(viewportSize.y, 1.0f); const Mat4 proj = Mat4::perspective(1.0472f, aspect, 0.1f, ctx.cameraFarPlane()); @@ -180,6 +181,9 @@ EntityFocusBounds computeEntityFocusBounds(ECS::World& world, ECS::Entity entity break; case ECS::MeshPrimitive::Capsule: case ECS::MeshPrimitive::Cylinder: + case ECS::MeshPrimitive::Cone: + case ECS::MeshPrimitive::Pyramid: + case ECS::MeshPrimitive::Torus: bounds.radius = std::max(bounds.radius, 2.0f); break; case ECS::MeshPrimitive::Plane: @@ -639,8 +643,15 @@ bool SceneViewport::renderCameraPreviewGpu(RHI::CommandBuffer* cmd, ECS::World& camera.nearClip = nearClip; camera.farClip = farClip; + Render::GpuSceneRenderOptions previewOpts; + previewOpts.textureQuality.enabled = ctx.textureQualityEnabled; + previewOpts.textureQuality.fullRadius = ctx.textureQualityRadius; + previewOpts.textureQuality.falloffDistance = ctx.textureQualityFalloff; + previewOpts.textureQuality.minScale = ctx.textureQualityMinScale; + previewOpts.textureQualityViewers.push_back(cameraPos); m_gpuSceneRenderer.renderWithCamera( - cmd, world, camera, m_previewColorTarget, m_previewDepthTarget, width, height, projectRoot); + cmd, world, camera, m_previewColorTarget, m_previewDepthTarget, width, height, projectRoot, + previewOpts); (void)ctx; return m_previewColorTarget && m_previewColorTarget->handle != nullptr; } @@ -720,7 +731,8 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { return; } - resizeCanvasIfNeeded((u32)viewportSize.x, (u32)viewportSize.y); + const ImVec2 framebufferSize = imguiFramebufferSize(viewportSize); + resizeCanvasIfNeeded((u32)framebufferSize.x, (u32)framebufferSize.y); if (ctx.viewMode == EditorContext::ViewMode::Mode3D) { Scene::syncTerrainMeshes(world); @@ -728,16 +740,44 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { const std::string projectRoot = resolveProjectRootFromScenePath(ctx.currentScenePath); u32 gpuMeshDrawCount = 0; - // Textured meshes are composited via CPU raster on top of the skybox. The GPU - // offscreen pass sits under that skybox layer, so enabling it disables the CPU - // overlay and makes meshes vanish after the first successful GPU upload. + const bool wireframePreview = (m_meshPreviewMode == MeshPreviewMode::Wireframe); const bool gpuSceneActive = m_frameCmd && m_gpuSceneReady && m_useGpuScene && m_colorTarget && m_depthTarget && ctx.viewMode == EditorContext::ViewMode::Mode3D; if (gpuSceneActive) { + const Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); + const f32 yawDelta = std::abs(ctx.camYaw - m_lastEditorCamYaw); + const f32 pitchDelta = std::abs(ctx.camPitch - m_lastEditorCamPitch); + const f32 posDelta = (camPos - m_lastEditorCamPos).length(); + const f32 frameMotion = yawDelta * 14.0f + pitchDelta * 14.0f + posDelta; + m_editorCamMotion = m_editorCamMotion * 0.75f + frameMotion * 0.25f; + m_lastEditorCamYaw = ctx.camYaw; + m_lastEditorCamPitch = ctx.camPitch; + m_lastEditorCamPos = camPos; + + Render::GpuSceneRenderOptions gpuOpts; + gpuOpts.wireframeMeshes = wireframePreview; + // Shadow passes re-draw the whole scene (x4 cascades + point faces). Skip while orbiting. + gpuOpts.enableShadows = !wireframePreview && m_editorCamMotion < 1.25f; + gpuOpts.textureQuality.enabled = ctx.textureQualityEnabled; + gpuOpts.textureQuality.fullRadius = ctx.textureQualityRadius; + gpuOpts.textureQuality.falloffDistance = ctx.textureQualityFalloff; + gpuOpts.textureQuality.minScale = ctx.textureQualityMinScale; + gpuOpts.textureQualityViewers.push_back(camPos); + ECS::ComponentQuery camQuery; + camQuery.with(); + world.forEach(camQuery, + [&](ECS::Entity entity, ECS::Camera3DComponent&) { + Vec3 camEntityPos; + if (tryGetEntityPosition(world, entity, camEntityPos)) { + gpuOpts.textureQualityViewers.push_back(camEntityPos); + } + }); + Caffeine::Debug::setCrashBreadcrumb("SceneViewport::gpuSceneRenderer"); gpuMeshDrawCount = m_gpuSceneRenderer.render(m_frameCmd, world, ctx, m_colorTarget, m_depthTarget, m_lastCanvasWidth, - m_lastCanvasHeight, projectRoot); + m_lastCanvasHeight, projectRoot, gpuOpts); Caffeine::Debug::setCrashBreadcrumb("SceneViewport::gpuSceneRenderer.done"); } ImGui::Dummy(viewportSize); @@ -780,7 +820,8 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { if (is3D) { const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 camPos = ctx.camFocus + Vec3(sinY * cosP, -sinP, -cosY * cosP) * ctx.camDistance; + const Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); const Mat4 view = Mat4::lookAt(camPos, ctx.camFocus, Vec3(0.0f, 1.0f, 0.0f)); const f32 aspect = viewportSize.x / std::max(viewportSize.y, 1.0f); const Mat4 proj = Mat4::perspective(1.0472f, aspect, 0.1f, ctx.cameraFarPlane()); @@ -881,7 +922,8 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - Vec3 camPos = ctx.camFocus + Vec3(sinY * cosP, -sinP, -cosY * cosP) * ctx.camDistance; + Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); Mat4 view = Mat4::lookAt(camPos, ctx.camFocus, Vec3(0.0f, 1.0f, 0.0f)); f32 aspect = vpSize.x / std::max(vpSize.y, 1.0f); Mat4 proj = Mat4::perspective(1.0472f, aspect, 0.1f, ctx.cameraFarPlane()); @@ -994,6 +1036,8 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { } ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->Flags |= ImDrawListFlags_AntiAliasedLines; + drawList->Flags |= ImDrawListFlags_AntiAliasedFill; ImVec2 origin = ImGui::GetItemRectMin(); m_terrainBrushHitValid = false; @@ -1148,6 +1192,11 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { if (ImGui::Button(texturedPreview ? "Textured" : "Wireframe")) { m_meshPreviewMode = texturedPreview ? MeshPreviewMode::Wireframe : MeshPreviewMode::Textured; } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip(texturedPreview + ? "GPU-lit meshes (fast with many objects)" + : "GPU wireframe with depth (fast with many objects)"); + } if (ImGui::IsItemHovered()) ImGui::SetTooltip("Toggle 3D preview style (white/gray textured vs wireframe)"); if (texturedPreview) { ImGui::PopStyleColor(2); @@ -1376,7 +1425,7 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { if (is3DIso && panButton == ImGuiMouseButton_Middle) { const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 forward(-sinY * cosP, sinP, cosY * cosP); + const Vec3 forward = editorLookDirection(ctx.camYaw, ctx.camPitch); const Vec3 worldUp(0.0f, 1.0f, 0.0f); Vec3 right = forward.cross(worldUp); if (right.lengthSquared() < 1e-6f) right = Vec3(1.0f, 0.0f, 0.0f); @@ -1401,9 +1450,7 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { ImGui::IsKeyDown(ImGuiKey_RightShift)) ? 4.0f : 1.0f; const f32 dt = std::max(ImGui::GetIO().DeltaTime, 0.0001f); const f32 speed = std::max(18.0f, ctx.camDistance * 1.35f) * dt * moveBoost; - const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); - const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 lookDir(-sinY * cosP, sinP, cosY * cosP); + const Vec3 lookDir = editorLookDirection(ctx.camYaw, ctx.camPitch); const Vec3 worldUp(0.0f, 1.0f, 0.0f); Vec3 right = lookDir.cross(worldUp); if (right.lengthSquared() < 1e-8f) right = Vec3(1.0f, 0.0f, 0.0f); @@ -1425,15 +1472,13 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { if (ctx.viewMode == EditorContext::ViewMode::Mode3D) { const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 lookDir(-sinY * cosP, sinP, cosY * cosP); + const Vec3 lookDir = editorLookDirection(ctx.camYaw, ctx.camPitch); const Vec3 camPos = ctx.camFocus - lookDir * ctx.camDistance; ctx.camYaw += delta.x * 0.005f; ctx.camPitch += delta.y * 0.005f; ctx.camPitch = std::clamp(ctx.camPitch, EditorContext::kCamPitchMin, EditorContext::kCamPitchMax); - const f32 nSinY = std::sin(ctx.camYaw), nCosY = std::cos(ctx.camYaw); - const f32 nSinP = std::sin(ctx.camPitch), nCosP = std::cos(ctx.camPitch); - const Vec3 newLook(-nSinY * nCosP, nSinP, nCosY * nCosP); + const Vec3 newLook = editorLookDirection(ctx.camYaw, ctx.camPitch); ctx.camFocus = camPos + newLook * ctx.camDistance; } else if (ctx.viewMode == EditorContext::ViewMode::Isometric) { ctx.camYaw += delta.x * 0.005f; @@ -1448,7 +1493,7 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { ctx.viewMode == EditorContext::ViewMode::Isometric) { const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 lookDir(-sinY * cosP, sinP, cosY * cosP); + const Vec3 lookDir = editorLookDirection(ctx.camYaw, ctx.camPitch); if (ImGui::IsKeyDown(ImGuiKey_LeftAlt) || ImGui::IsKeyDown(ImGuiKey_RightAlt)) { const f32 zoomFactor = (scroll > 0) ? 0.85f : 1.18f; ctx.camDistance *= zoomFactor; @@ -1540,12 +1585,8 @@ ImVec2 SceneViewport::projectToScreen(Vec3 p, ImVec2 origin, ImVec2 viewportSize } Mat4 SceneViewport::computeVP3D(ImVec2 viewportSize, const EditorContext& ctx) { - f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); - f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - Vec3 camPos; - camPos.x = ctx.camFocus.x + sinY * cosP * ctx.camDistance; - camPos.y = ctx.camFocus.y - sinP * ctx.camDistance; - camPos.z = ctx.camFocus.z - cosY * cosP * ctx.camDistance; + const Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); Mat4 view = Mat4::lookAt(camPos, ctx.camFocus, Vec3(0.0f, 1.0f, 0.0f)); f32 aspect = viewportSize.x / std::max(viewportSize.y, 1.0f); Mat4 proj = Mat4::perspective(1.0472f, aspect, 0.1f, ctx.cameraFarPlane()); @@ -1554,15 +1595,56 @@ Mat4 SceneViewport::computeVP3D(ImVec2 viewportSize, const EditorContext& ctx) { ImVec2 SceneViewport::projectToScreenVP(Vec3 p, ImVec2 origin, ImVec2 viewportSize, const Mat4& vp) { - Vec4 clip = vp.transformVec4(Vec4(p.x, p.y, p.z, 1.0f)); - if (clip.w <= 0.1f) return ImVec2(-10000.0f, -10000.0f); - f32 ndcX = clip.x / clip.w; - f32 ndcY = clip.y / clip.w; - // Off-screen NDC coords are allowed — ImGui clips line segments automatically. - return ImVec2( - origin.x + (ndcX + 1.0f) * 0.5f * viewportSize.x, - origin.y + (1.0f - ndcY) * 0.5f * viewportSize.y - ); + ImVec2 screen{}; + if (!projectWorldToViewport(vp, p, origin, viewportSize, screen)) { + return ImVec2(-10000.0f, -10000.0f); + } + return screen; +} + +bool SceneViewport::projectWorldToViewport(const Mat4& vp, Vec3 worldPos, ImVec2 origin, + ImVec2 viewportSize, ImVec2& screenOut) { + Vec4 clip = vp.transformVec4(Vec4(worldPos.x, worldPos.y, worldPos.z, 1.0f)); + if (clip.w <= 0.1f) return false; + const f32 ndcX = clip.x / clip.w; + const f32 ndcY = clip.y / clip.w; + screenOut.x = origin.x + (ndcX + 1.0f) * 0.5f * viewportSize.x; + screenOut.y = origin.y + (1.0f - ndcY) * 0.5f * viewportSize.y; + return true; +} + +void SceneViewport::drawViewportWorldLine(ImDrawList* dl, const Mat4& vp, ImVec2 origin, + ImVec2 viewportSize, Vec3 a, Vec3 b, ImU32 color, + float thickness) { + if (!dl) return; + + ImVec2 sa{}; + ImVec2 sb{}; + const bool va = projectWorldToViewport(vp, a, origin, viewportSize, sa); + const bool vb = projectWorldToViewport(vp, b, origin, viewportSize, sb); + if (!va && !vb) return; + if (va && vb) { + dl->AddLine(sa, sb, color, thickness); + return; + } + + constexpr f32 wMin = 0.1f; + Vec4 clipA = vp.transformVec4(Vec4(a.x, a.y, a.z, 1.0f)); + Vec4 clipB = vp.transformVec4(Vec4(b.x, b.y, b.z, 1.0f)); + if (clipA.w < wMin) { + const f32 t = (wMin - clipA.w) / (clipB.w - clipA.w); + Vec4 clipped(clipA.x + t * (clipB.x - clipA.x), clipA.y + t * (clipB.y - clipA.y), + clipA.z + t * (clipB.z - clipA.z), wMin); + sa.x = origin.x + (clipped.x / wMin + 1.0f) * 0.5f * viewportSize.x; + sa.y = origin.y + (1.0f - clipped.y / wMin) * 0.5f * viewportSize.y; + } else { + const f32 t = (wMin - clipB.w) / (clipA.w - clipB.w); + Vec4 clipped(clipB.x + t * (clipA.x - clipB.x), clipB.y + t * (clipA.y - clipB.y), + clipB.z + t * (clipA.z - clipB.z), wMin); + sb.x = origin.x + (clipped.x / wMin + 1.0f) * 0.5f * viewportSize.x; + sb.y = origin.y + (1.0f - clipped.y / wMin) * 0.5f * viewportSize.y; + } + dl->AddLine(sa, sb, color, thickness); } // ── Gizmo drawing ───────────────────────────────────────────────── @@ -1745,8 +1827,15 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV if (!ctx.currentScenePath.empty()) { projectRoot = resolveProjectRootFromScenePath(ctx.currentScenePath); } + const bool wireMode = (m_meshPreviewMode == MeshPreviewMode::Wireframe); + const bool gpuTextured3D = (ctx.viewMode == EditorContext::ViewMode::Mode3D && !wireMode && + m_gpuSceneReady && m_useGpuScene && m_lastGpuSceneActive); + const bool gpuWireframe3D = (ctx.viewMode == EditorContext::ViewMode::Mode3D && wireMode && + m_gpuSceneReady && m_useGpuScene && m_lastGpuSceneActive); + Scene::SceneLighting sceneLighting; - Scene::gatherSceneLighting(world, ctx.camFocus, projectRoot, sceneLighting); + Scene::gatherSceneLighting(world, ctx.camFocus, projectRoot, sceneLighting, + ECS::Entity::INVALID, !gpuTextured3D); auto lightColorAt = [&](const Vec3& p, const Vec3& n, bool receiveShadows) -> Vec3 { return Scene::evaluateDiffuseLighting(sceneLighting.lights, sceneLighting.shadows, p, n, @@ -1795,7 +1884,11 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV }; auto drawSegment = [&](const Vec3& a, const Vec3& b, ImU32 col, float thickness) { - dl->AddLine(projectCached(a), projectCached(b), col, thickness); + if (ctx.viewMode == EditorContext::ViewMode::Mode3D) { + drawViewportWorldLine(dl, vpCache3D, origin, viewportSize, a, b, col, thickness); + } else { + dl->AddLine(projectCached(a), projectCached(b), col, thickness); + } }; auto drawRing = [&](const Mat4& worldMatrix, const Vec3& axisA, const Vec3& axisB, @@ -1810,9 +1903,6 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV } }; - const bool gpuTextured3D = (ctx.viewMode == EditorContext::ViewMode::Mode3D && - m_meshPreviewMode == MeshPreviewMode::Textured && - m_gpuSceneReady && m_useGpuScene); const bool useMeshRaster = (ctx.viewMode == EditorContext::ViewMode::Mode3D && m_meshPreviewMode == MeshPreviewMode::Textured && !gpuTextured3D); @@ -1828,14 +1918,19 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV const Mat4 worldMatrix = entityMatrix(world, entity); const bool selected = (ctx.selectedEntity == entity); - const bool wireMode = (m_meshPreviewMode == MeshPreviewMode::Wireframe); + if (gpuTextured3D && !selected) { + return true; + } + if (gpuWireframe3D && !selected) { + return true; + } const ImU32 wireCol = selected ? IM_COL32(110, 210, 255, 255) : IM_COL32(170, 190, 230, 210); const ImU32 polyCol = selected ? IM_COL32(95, 170, 255, 210) : IM_COL32(130, 150, 190, 170); const float thickness = selected ? 2.0f : 1.25f; const float polyThickness = selected ? 1.5f : 1.0f; - const bool drawContour = wireMode || selected; - const bool drawPolygons = wireMode; + const bool drawContour = (wireMode && !gpuWireframe3D) || selected; + const bool drawPolygons = wireMode && !gpuWireframe3D; const int densityLevel = static_cast(m_wireframeDensity); const int spherePolySegments = (densityLevel == 0) ? 8 : (densityLevel == 1 ? 12 : 18); const int sidePolySlices = (densityLevel == 0) ? 4 : (densityLevel == 1 ? 8 : 14); @@ -1848,7 +1943,7 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV f32 ry = wp.y - ctx.camFocus.y; f32 rz = wp.z - ctx.camFocus.z; f32 vz = -sinY * rx + cosY * rz; - f32 vz2 = -sinP * ry + cosP * vz; + f32 vz2 = sinP * ry + cosP * vz; return vz2; } if (ctx.viewMode == EditorContext::ViewMode::Isometric) { @@ -1898,7 +1993,7 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV wp[i] = worldMatrix.transformPoint(corners[i]); } - if (m_meshPreviewMode == MeshPreviewMode::Textured) { + if (m_meshPreviewMode == MeshPreviewMode::Textured && !(gpuTextured3D && !wireMode)) { struct FaceDraw { int faceIndex; f32 depth; @@ -1989,12 +2084,8 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV break; } - f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); - f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - Vec3 camPos; - camPos.x = ctx.camFocus.x + sinY * cosP * ctx.camDistance; - camPos.y = ctx.camFocus.y - sinP * ctx.camDistance; - camPos.z = ctx.camFocus.z - cosY * cosP * ctx.camDistance; + const Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); Mat4 viewMat = Mat4::lookAt(camPos, ctx.camFocus, Vec3(0.0f, 1.0f, 0.0f)); f32 aspectR = viewportSize.x / std::max(viewportSize.y, 1.0f); Mat4 projMat = Mat4::perspective(1.0472f, aspectR, 0.1f, ctx.cameraFarPlane()); @@ -2094,7 +2185,7 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV worldMatrix.transformPoint(corners[3]) }; - if (m_meshPreviewMode == MeshPreviewMode::Textured) { + if (m_meshPreviewMode == MeshPreviewMode::Textured && !(gpuTextured3D && !wireMode)) { Vec3 nWorld = matrixAxis(worldMatrix, 1, Vec3::up()); Vec3 center = (wp[0] + wp[1] + wp[2] + wp[3]) * 0.25f; const Vec3 lit = @@ -2122,7 +2213,7 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV ImVec2 cs = projectToScreen(center, origin, viewportSize, ctx); ImVec2 pxs = projectToScreen(px, origin, viewportSize, ctx); f32 rad = std::sqrt((pxs.x - cs.x) * (pxs.x - cs.x) + (pxs.y - cs.y) * (pxs.y - cs.y)); - if (m_meshPreviewMode == MeshPreviewMode::Textured) { + if (m_meshPreviewMode == MeshPreviewMode::Textured && !(gpuTextured3D && !wireMode)) { const Vec3 lit = lightColorAt(center, entityAxis(world, entity, 2, Vec3(0, 0, 1)), Scene::meshReceivesShadows(world, entity)); dl->AddCircleFilled(cs, rad, toColor(Vec3(lit.x * 0.78f, lit.y * 0.78f, lit.z * 0.78f), 0.90f), 24); @@ -2143,7 +2234,7 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV case ECS::MeshPrimitive::Cylinder: { Mat4 topMatrix = worldMatrix * Mat4::translation(0.0f, 0.5f, 0.0f); Mat4 bottomMatrix = worldMatrix * Mat4::translation(0.0f, -0.5f, 0.0f); - if (m_meshPreviewMode == MeshPreviewMode::Textured) { + if (m_meshPreviewMode == MeshPreviewMode::Textured && !(gpuTextured3D && !wireMode)) { Vec3 cTop = topMatrix.transformPoint(Vec3(0,0,0)); Vec3 cBottom = bottomMatrix.transformPoint(Vec3(0,0,0)); Vec3 cMid = (cTop + cBottom) * 0.5f; @@ -2176,7 +2267,7 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV case ECS::MeshPrimitive::Capsule: { Mat4 topMatrix = worldMatrix * Mat4::translation(0.0f, 0.25f, 0.0f); Mat4 bottomMatrix = worldMatrix * Mat4::translation(0.0f, -0.25f, 0.0f); - if (m_meshPreviewMode == MeshPreviewMode::Textured) { + if (m_meshPreviewMode == MeshPreviewMode::Textured && !(gpuTextured3D && !wireMode)) { Vec3 cTop = topMatrix.transformPoint(Vec3(0,0,0)); Vec3 cBottom = bottomMatrix.transformPoint(Vec3(0,0,0)); Vec3 cMid = (cTop + cBottom) * 0.5f; @@ -2191,6 +2282,10 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV drawRing(bottomMatrix, Vec3(1, 0, 0), Vec3(0, 0, 1), 0.5f, 24, wireCol, thickness); drawRing(worldMatrix, Vec3(1, 0, 0), Vec3(0, 1, 0), 0.5f, 24, wireCol, thickness); drawRing(worldMatrix, Vec3(0, 1, 0), Vec3(0, 0, 1), 0.5f, 24, wireCol, thickness); + Mat4 topCapMatrix = worldMatrix * Mat4::translation(0.0f, 0.75f, 0.0f); + Mat4 bottomCapMatrix = worldMatrix * Mat4::translation(0.0f, -0.75f, 0.0f); + drawRing(topCapMatrix, Vec3(1, 0, 0), Vec3(0, 1, 0), 0.25f, 16, wireCol, thickness); + drawRing(bottomCapMatrix, Vec3(1, 0, 0), Vec3(0, 1, 0), 0.25f, 16, wireCol, thickness); } for (int i = 0; i < 4; ++i) { const f32 a = (3.14159265f * 0.5f) * static_cast(i); @@ -2208,6 +2303,42 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV } break; } + case ECS::MeshPrimitive::Cone: { + const Vec3 apex = worldMatrix.transformPoint(Vec3(0.0f, 0.5f, 0.0f)); + Mat4 baseMatrix = worldMatrix * Mat4::translation(0.0f, -0.5f, 0.0f); + if (drawContour) { + drawRing(baseMatrix, Vec3(1, 0, 0), Vec3(0, 0, 1), 0.5f, 24, wireCol, thickness); + } + for (int i = 0; i < 4; ++i) { + const f32 a = (3.14159265f * 0.5f) * static_cast(i); + const Vec3 rim(std::cos(a) * 0.5f, 0.0f, std::sin(a) * 0.5f); + drawEdge(apex, baseMatrix.transformPoint(rim)); + } + break; + } + case ECS::MeshPrimitive::Pyramid: { + const Vec3 apex = worldMatrix.transformPoint(Vec3(0.0f, 0.5f, 0.0f)); + const Vec3 b0 = worldMatrix.transformPoint(Vec3(-0.5f, -0.5f, -0.5f)); + const Vec3 b1 = worldMatrix.transformPoint(Vec3(0.5f, -0.5f, -0.5f)); + const Vec3 b2 = worldMatrix.transformPoint(Vec3(0.5f, -0.5f, 0.5f)); + const Vec3 b3 = worldMatrix.transformPoint(Vec3(-0.5f, -0.5f, 0.5f)); + drawEdge(b0, b1); + drawEdge(b1, b2); + drawEdge(b2, b3); + drawEdge(b3, b0); + drawEdge(apex, b0); + drawEdge(apex, b1); + drawEdge(apex, b2); + drawEdge(apex, b3); + break; + } + case ECS::MeshPrimitive::Torus: { + if (drawContour) { + drawRing(worldMatrix, Vec3(1, 0, 0), Vec3(0, 0, 1), 0.5f, 28, wireCol, thickness); + drawRing(worldMatrix, Vec3(1, 0, 0), Vec3(0, 1, 0), 0.2f, 20, wireCol, thickness); + } + break; + } } return true; @@ -2448,7 +2579,7 @@ void SceneViewport::drawGizmo(ECS::World& world, EditorContext& ctx, ImVec2 orig const float len = dir.length(); if (len > 1e-6f) dir = dir / len; float vzc = -sinY * dir.x + cosY * dir.z; - return -sinP * dir.y + cosP * vzc; + return sinP * dir.y + cosP * vzc; }; auto axisForeshorten = [&](const Vec3& axis) -> float { if (!is3D) return 1.f; @@ -2457,7 +2588,7 @@ void SceneViewport::drawGizmo(ECS::World& world, EditorContext& ctx, ImVec2 orig if (len > 1e-6f) dir = dir / len; float vx = cosY * dir.x + sinY * dir.z; float vzc = -sinY * dir.x + cosY * dir.z; - float vy2 = cosP * dir.y + sinP * vzc; + float vy2 = cosP * dir.y - sinP * vzc; return std::sqrt(vx * vx + vy2 * vy2); }; auto norm2D = [](ImVec2 v) -> ImVec2 { @@ -2733,11 +2864,7 @@ bool SceneViewport::drawSkybox(ImDrawList* drawList, ImVec2 origin, ImVec2 viewp if (ctx.viewMode != EditorContext::ViewMode::Mode3D) return false; Render::SkyboxCamera skyCamera; - const f32 sinY = std::sin(ctx.camYaw); - const f32 cosY = std::cos(ctx.camYaw); - const f32 sinP = std::sin(ctx.camPitch); - const f32 cosP = std::cos(ctx.camPitch); - skyCamera.forward = Vec3(-sinY * cosP, sinP, cosY * cosP).normalized(); + skyCamera.forward = editorLookDirection(ctx.camYaw, ctx.camPitch).normalized(); const Vec3 worldUp(0.0f, 1.0f, 0.0f); skyCamera.right = skyCamera.forward.cross(worldUp); if (skyCamera.right.lengthSquared() < 1e-6f) { @@ -2806,25 +2933,8 @@ void SceneViewport::drawGrid3D(ImDrawList* dl, ImVec2 origin, ImVec2 viewportSiz ImU32 axisColorZ = IM_COL32(60, 60, 220, 200); Mat4 vp = computeVP3D(viewportSize, ctx); - const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); - const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 camPos( - ctx.camFocus.x + sinY * cosP * ctx.camDistance, - ctx.camFocus.y - sinP * ctx.camDistance, - ctx.camFocus.z - cosY * cosP * ctx.camDistance); - - // Projects a world point to screen. Returns false only if behind the near plane (w <= 0.1). - // Off-screen (NDC > 1) coords are intentionally allowed — ImGui clips them automatically, - // which is required for lines whose far endpoint is outside the viewport but still in front. - auto projectLine = [&](Vec3 worldPt, ImVec2& screenOut) -> bool { - Vec4 clip = vp.transformVec4(Vec4(worldPt.x, worldPt.y, worldPt.z, 1.0f)); - if (clip.w <= 0.1f) return false; - f32 ndcX = clip.x / clip.w; - f32 ndcY = clip.y / clip.w; - screenOut.x = origin.x + (ndcX + 1.0f) * 0.5f * viewportSize.x; - screenOut.y = origin.y + (1.0f - ndcY) * 0.5f * viewportSize.y; - return true; - }; + const Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); auto fadeColor = [&](ImU32 color, const Vec3& a, const Vec3& b, bool major) -> ImU32 { const Vec3 mid = (a + b) * 0.5f; @@ -2837,33 +2947,8 @@ void SceneViewport::drawGrid3D(ImDrawList* dl, ImVec2 origin, ImVec2 viewportSiz }; auto drawLine3D = [&](Vec3 a, Vec3 b, ImU32 color, float thickness, bool major = false) { - ImVec2 sa, sb; - bool va = projectLine(a, sa); - bool vb = projectLine(b, sb); - if (!va && !vb) return; - const ImU32 faded = fadeColor(color, a, b, major); - if (va && vb) { - dl->AddLine(sa, sb, faded, thickness); - return; - } - // One endpoint is behind the near plane — clip the segment in clip space. - Vec4 clipA = vp.transformVec4(Vec4(a.x, a.y, a.z, 1.0f)); - Vec4 clipB = vp.transformVec4(Vec4(b.x, b.y, b.z, 1.0f)); - const f32 wMin = 0.1f; - if (clipA.w < wMin) { - f32 t = (wMin - clipA.w) / (clipB.w - clipA.w); - Vec4 clipped(clipA.x + t*(clipB.x-clipA.x), clipA.y + t*(clipB.y-clipA.y), - clipA.z + t*(clipB.z-clipA.z), wMin); - sa.x = origin.x + (clipped.x/wMin + 1.0f) * 0.5f * viewportSize.x; - sa.y = origin.y + (1.0f - clipped.y/wMin) * 0.5f * viewportSize.y; - } else { - f32 t = (wMin - clipB.w) / (clipA.w - clipB.w); - Vec4 clipped(clipB.x + t*(clipA.x-clipB.x), clipB.y + t*(clipA.y-clipB.y), - clipB.z + t*(clipA.z-clipB.z), wMin); - sb.x = origin.x + (clipped.x/wMin + 1.0f) * 0.5f * viewportSize.x; - sb.y = origin.y + (1.0f - clipped.y/wMin) * 0.5f * viewportSize.y; - } - dl->AddLine(sa, sb, faded, thickness); + drawViewportWorldLine(dl, vp, origin, viewportSize, a, b, fadeColor(color, a, b, major), + thickness); }; float visibleRange = ctx.camDistance; @@ -3540,11 +3625,11 @@ void SceneViewport::drawSceneMeshesForCamera( projectRoot = resolveProjectRootFromScenePath(ctx.currentScenePath); } Scene::SceneLighting sceneLighting; - Scene::gatherSceneLighting(world, camPos, projectRoot, sceneLighting, skipEntity); + Scene::collectSceneLights(world, sceneLighting.lights); - auto lightColorAt = [&](const Vec3& p, const Vec3& n, bool receiveShadows) -> Vec3 { + auto lightColorAt = [&](const Vec3& p, const Vec3& n, bool /*receiveShadows*/) -> Vec3 { return Scene::evaluateDiffuseLighting(sceneLighting.lights, sceneLighting.shadows, p, n, - receiveShadows, Vec3(0.22f, 0.22f, 0.24f)); + false, Vec3(0.22f, 0.22f, 0.24f)); }; MeshCpuRasterizer meshRaster; @@ -3640,8 +3725,8 @@ std::string SceneViewport::resolveSpritePath(const std::string& spriteName, cons } void SceneViewport::releaseSpriteTextures() { - auto destroyEntry = [this](SpriteTextureCacheEntry& entry) { - retireImTexture(entry.texture); + auto destroyEntry = [](SpriteTextureCacheEntry& entry) { + destroyImGuiTexture(entry.texture); entry.width = 0; entry.height = 0; entry.loadFailed = false; @@ -3654,7 +3739,7 @@ void SceneViewport::releaseSpriteTextures() { destroyEntry(m_meshRasterTexture); destroyEntry(m_camMeshRasterTexture); m_skyboxRenderer.releaseGpuTextures(); - pruneRetiredTextures(); + clearRetiredImGuiTextures(m_retiredTextures); } void SceneViewport::drawCameraFrustums(ECS::World& world, EditorContext& ctx, ImVec2 origin, ImVec2 viewportSize) { @@ -3704,10 +3789,7 @@ void SceneViewport::drawCameraFrustums(ECS::World& world, EditorContext& ctx, Im return projectToScreenVP(p, origin, viewportSize, vp); }; auto drawFrustumLine = [&](const Vec3& a, const Vec3& b, ImU32 col, float thickness) { - ImVec2 sa = projectFrustum(a); - ImVec2 sb = projectFrustum(b); - if (sa.x < -5000.0f && sb.x < -5000.0f) return; - dl->AddLine(sa, sb, col, thickness); + drawViewportWorldLine(dl, vp, origin, viewportSize, a, b, col, thickness); }; ECS::ComponentQuery q3d; diff --git a/src/editor/SceneViewport.hpp b/src/editor/SceneViewport.hpp index 21eec90..4f82ff4 100644 --- a/src/editor/SceneViewport.hpp +++ b/src/editor/SceneViewport.hpp @@ -141,6 +141,16 @@ class SceneViewport { static ImVec2 projectToScreenVP(Vec3 worldPos, ImVec2 origin, ImVec2 viewportSize, const Mat4& vp); +#ifdef CF_HAS_IMGUI + /// Projects world space to viewport pixels. Returns false when behind the near plane. + static bool projectWorldToViewport(const Mat4& vp, Vec3 worldPos, ImVec2 origin, + ImVec2 viewportSize, ImVec2& screenOut); + /// Draws a world-space line with near-plane clipping (stable at grazing angles). + static void drawViewportWorldLine(ImDrawList* dl, const Mat4& vp, ImVec2 origin, + ImVec2 viewportSize, Vec3 a, Vec3 b, ImU32 color, + float thickness); +#endif + #ifdef CF_HAS_IMGUI void drawSceneMeshesForCamera(ECS::World& world, EditorContext& ctx, ImDrawList* dl, const Mat4& vp, const Vec3& camPos, @@ -276,6 +286,10 @@ class SceneViewport { u32 m_lastCanvasHeight = 0; u32 m_previewCanvasWidth = 0; u32 m_previewCanvasHeight = 0; + f32 m_lastEditorCamYaw = 0.0f; + f32 m_lastEditorCamPitch = 0.0f; + Vec3 m_lastEditorCamPos{}; + f32 m_editorCamMotion = 0.0f; #endif }; diff --git a/src/editor/SettingsPanel.cpp b/src/editor/SettingsPanel.cpp index 79c2ce4..44bd48a 100644 --- a/src/editor/SettingsPanel.cpp +++ b/src/editor/SettingsPanel.cpp @@ -63,6 +63,10 @@ void SettingsPanel::applyPreferencesToContext(EditorContext& ctx) { ctx.uniformScale = m_preferences.uniformScaleDefault; ctx.snapToGrid = m_preferences.snapEnabled; ctx.snapGridSize = m_preferences.snapIncrement; + ctx.textureQualityEnabled = m_preferences.textureQualityEnabled; + ctx.textureQualityRadius = m_preferences.textureQualityRadius; + ctx.textureQualityFalloff = m_preferences.textureQualityFalloff; + ctx.textureQualityMinScale = m_preferences.textureQualityMinScale; } void SettingsPanel::render() { @@ -310,6 +314,34 @@ void SettingsPanel::renderViewportSettings() { if (ImGui::Checkbox("Show grid in viewport", &m_preferences.showGrid)) { savePreferences(); } + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 1.0f, 1.0f), "Texture quality"); + ImGui::Separator(); + ImGui::TextWrapped( + "Full-resolution textures near scene viewers (viewport camera + scene cameras). " + "Quality decays smoothly beyond the radius."); + if (ImGui::Checkbox("Distance-based texture quality", &m_preferences.textureQualityEnabled)) { + savePreferences(); + if (m_editorContext) applyPreferencesToContext(*m_editorContext); + } + if (m_preferences.textureQualityEnabled) { + if (ImGui::DragFloat("Full quality radius (m)", &m_preferences.textureQualityRadius, 1.0f, + 5.0f, 500.0f, "%.0f m")) { + savePreferences(); + if (m_editorContext) applyPreferencesToContext(*m_editorContext); + } + if (ImGui::DragFloat("Falloff distance (m)", &m_preferences.textureQualityFalloff, 2.0f, + 10.0f, 1000.0f, "%.0f m")) { + savePreferences(); + if (m_editorContext) applyPreferencesToContext(*m_editorContext); + } + if (ImGui::SliderFloat("Minimum quality", &m_preferences.textureQualityMinScale, 0.125f, + 1.0f, "%.2f")) { + savePreferences(); + if (m_editorContext) applyPreferencesToContext(*m_editorContext); + } + } #endif } diff --git a/src/editor/TransformGizmo.cpp b/src/editor/TransformGizmo.cpp index 01b9073..724eb96 100644 --- a/src/editor/TransformGizmo.cpp +++ b/src/editor/TransformGizmo.cpp @@ -1,4 +1,5 @@ #include "editor/TransformGizmo.hpp" +#include "editor/EditorCameraMath.hpp" #include "editor/SceneViewport.hpp" #include "ecs/Components.hpp" #include "ecs/Components3D.hpp" @@ -144,8 +145,8 @@ void TransformGizmo::onImGuiRender(ECS::World& world, ECS::Entity entity, Editor float vx = cosY * ax + sinY * az; float vy = ay; float vzc = -sinY * ax + cosY * az; - float vy2 = cosP * vy + sinP * vzc; - float vz2 = -sinP * vy + cosP * vzc; + float vy2 = cosP * vy - sinP * vzc; + float vz2 = sinP * vy + cosP * vzc; float sdx = vx, sdy = -vy2; float smag = std::sqrt(sdx*sdx + sdy*sdy); float len = handleLen * std::max(smag, 0.4f); @@ -202,9 +203,8 @@ void TransformGizmo::onImGuiRender(ECS::World& world, ECS::Entity entity, Editor Vec2 mousePosGlm(mousePos.x, mousePos.y); // Build VP matrix for raycasting - f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); - f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - Vec3 camPos = ctx.camFocus + Vec3(sinY * cosP, -sinP, -cosY * cosP) * ctx.camDistance; + const Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); Mat4 view = Mat4::lookAt(camPos, ctx.camFocus, Vec3(0.0f, 1.0f, 0.0f)); f32 aspect = vpSize.x / std::max(vpSize.y, 1.0f); Mat4 proj = Mat4::perspective(1.0472f, aspect, 0.1f, 10000.0f); diff --git a/src/render/GpuDirectionalShadowMap.cpp b/src/render/GpuDirectionalShadowMap.cpp index f83d5d5..ede3437 100644 --- a/src/render/GpuDirectionalShadowMap.cpp +++ b/src/render/GpuDirectionalShadowMap.cpp @@ -1,5 +1,7 @@ #include "render/GpuDirectionalShadowMap.hpp" +#include + namespace Caffeine::Render { bool GpuDirectionalShadowMap::init(RHI::RenderDevice* device) { @@ -12,8 +14,8 @@ bool GpuDirectionalShadowMap::init(RHI::RenderDevice* device) { for (u32 i = 0; i < kMaxDirectionalShadowLights; ++i) { RHI::TextureDesc desc; - desc.width = kResolution; - desc.height = kResolution; + desc.width = kAtlasResolution; + desc.height = kAtlasResolution; desc.format = RHI::TextureFormat::R16_FLOAT; desc.usage = RHI::TextureUsage::Sampler | RHI::TextureUsage::ColorTarget; desc.mipLevels = 1; @@ -23,7 +25,10 @@ bool GpuDirectionalShadowMap::init(RHI::RenderDevice* device) { return false; } m_valid[i] = false; - m_lightVP[i] = Mat4::identity(); + m_cascadeCount[i] = 1; + for (u32 c = 0; c < kMaxCascades; ++c) { + m_cascadeVP[i][c] = Mat4::identity(); + } } m_initialized = true; @@ -38,6 +43,7 @@ void GpuDirectionalShadowMap::shutdown() { m_textures[i] = nullptr; } m_valid[i] = false; + m_cascadeCount[i] = 1; } } m_device = nullptr; @@ -49,10 +55,43 @@ RHI::Texture* GpuDirectionalShadowMap::texture(u32 slot) const { return m_textures[slot]; } -void GpuDirectionalShadowMap::setSlot(u32 slot, const Mat4& lightVP, bool isValid) { +const Mat4& GpuDirectionalShadowMap::cascadeVP(u32 slot, u32 cascade) const { + if (slot >= kMaxDirectionalShadowLights || cascade >= kMaxCascades) { + static const Mat4 kIdentity = Mat4::identity(); + return kIdentity; + } + return m_cascadeVP[slot][cascade]; +} + +u32 GpuDirectionalShadowMap::cascadeCount(u32 slot) const { + if (slot >= kMaxDirectionalShadowLights) return 1; + return std::max(1u, m_cascadeCount[slot]); +} + +const f32* GpuDirectionalShadowMap::cascadeSplits(u32 slot) const { + if (slot >= kMaxDirectionalShadowLights) return m_cascadeSplits[0]; + return m_cascadeSplits[slot]; +} + +void GpuDirectionalShadowMap::setCascadeData(u32 slot, u32 cascadeCount, const Mat4* vps, + const f32* splits, bool isValid) { if (slot >= kMaxDirectionalShadowLights) return; - m_lightVP[slot] = lightVP; + m_cascadeCount[slot] = std::clamp(cascadeCount, 1u, kMaxCascades); + for (u32 c = 0; c < kMaxCascades; ++c) { + m_cascadeVP[slot][c] = (vps && c < m_cascadeCount[slot]) ? vps[c] : Mat4::identity(); + } + if (splits) { + for (u32 i = 0; i <= kMaxCascades; ++i) { + m_cascadeSplits[slot][i] = splits[i]; + } + } m_valid[slot] = isValid; } +void GpuDirectionalShadowMap::clearSlot(u32 slot) { + if (slot >= kMaxDirectionalShadowLights) return; + m_valid[slot] = false; + m_cascadeCount[slot] = 1; +} + } // namespace Caffeine::Render diff --git a/src/render/GpuDirectionalShadowMap.hpp b/src/render/GpuDirectionalShadowMap.hpp index 4bf8f23..da72eec 100644 --- a/src/render/GpuDirectionalShadowMap.hpp +++ b/src/render/GpuDirectionalShadowMap.hpp @@ -10,7 +10,9 @@ namespace Caffeine::Render { class GpuDirectionalShadowMap { public: static constexpr u32 kMaxDirectionalShadowLights = 2; - static constexpr u32 kResolution = 512; + static constexpr u32 kMaxCascades = 4; + static constexpr u32 kCascadeResolution = 512; + static constexpr u32 kAtlasResolution = 1024; bool init(RHI::RenderDevice* device); void shutdown(); @@ -18,15 +20,21 @@ class GpuDirectionalShadowMap { bool isInitialized() const { return m_initialized; } RHI::Texture* texture(u32 slot) const; - const Mat4& lightVP(u32 slot) const { return m_lightVP[slot]; } + const Mat4& cascadeVP(u32 slot, u32 cascade) const; + u32 cascadeCount(u32 slot) const; + const f32* cascadeSplits(u32 slot) const; bool valid(u32 slot) const { return slot < kMaxDirectionalShadowLights && m_valid[slot]; } - void setSlot(u32 slot, const Mat4& lightVP, bool isValid); + void setCascadeData(u32 slot, u32 cascadeCount, const Mat4* vps, const f32* splits, + bool isValid); + void clearSlot(u32 slot); private: RHI::RenderDevice* m_device = nullptr; RHI::Texture* m_textures[kMaxDirectionalShadowLights]{}; - Mat4 m_lightVP[kMaxDirectionalShadowLights]{}; + Mat4 m_cascadeVP[kMaxDirectionalShadowLights][kMaxCascades]{}; + f32 m_cascadeSplits[kMaxDirectionalShadowLights][kMaxCascades + 1]{}; + u32 m_cascadeCount[kMaxDirectionalShadowLights]{}; bool m_valid[kMaxDirectionalShadowLights]{}; bool m_initialized = false; }; diff --git a/src/render/GpuPointShadowMap.cpp b/src/render/GpuPointShadowMap.cpp index 50da4c4..ed74c1b 100644 --- a/src/render/GpuPointShadowMap.cpp +++ b/src/render/GpuPointShadowMap.cpp @@ -23,6 +23,9 @@ bool GpuPointShadowMap::init(RHI::RenderDevice* device) { shutdown(); return false; } + m_valid[i] = false; + m_lightPos[i] = Vec3(0.0f, 0.0f, 0.0f); + m_radius[i] = 0.0f; } m_initialized = true; @@ -36,6 +39,7 @@ void GpuPointShadowMap::shutdown() { m_device->destroyTexture(m_cubemaps[i]); m_cubemaps[i] = nullptr; } + m_valid[i] = false; } } m_device = nullptr; @@ -47,8 +51,15 @@ RHI::Texture* GpuPointShadowMap::cubemap(u32 slot) const { return m_cubemaps[slot]; } +void GpuPointShadowMap::setSlot(u32 slot, const Vec3& position, f32 radius, bool isValid) { + if (slot >= kMaxPointShadowLights) return; + m_lightPos[slot] = position; + m_radius[slot] = radius; + m_valid[slot] = isValid; +} + void GpuPointShadowMap::clearSlot(u32 slot) { - (void)slot; + setSlot(slot, Vec3(0.0f, 0.0f, 0.0f), 0.0f, false); } } // namespace Caffeine::Render diff --git a/src/render/GpuPointShadowMap.hpp b/src/render/GpuPointShadowMap.hpp index 8d88cca..04b9e63 100644 --- a/src/render/GpuPointShadowMap.hpp +++ b/src/render/GpuPointShadowMap.hpp @@ -18,12 +18,19 @@ class GpuPointShadowMap { bool isReady() const { return m_initialized; } RHI::Texture* cubemap(u32 slot) const; + const Vec3& lightPosition(u32 slot) const { return m_lightPos[slot]; } + f32 radius(u32 slot) const { return m_radius[slot]; } + bool valid(u32 slot) const { return slot < kMaxPointShadowLights && m_valid[slot]; } + void setSlot(u32 slot, const Vec3& position, f32 radius, bool isValid); void clearSlot(u32 slot); private: RHI::RenderDevice* m_device = nullptr; RHI::Texture* m_cubemaps[kMaxPointShadowLights] = {}; + Vec3 m_lightPos[kMaxPointShadowLights]{}; + f32 m_radius[kMaxPointShadowLights]{}; + bool m_valid[kMaxPointShadowLights]{}; bool m_initialized = false; }; diff --git a/src/render/GpuProceduralMeshes.cpp b/src/render/GpuProceduralMeshes.cpp index d1bc493..d48f3d8 100644 --- a/src/render/GpuProceduralMeshes.cpp +++ b/src/render/GpuProceduralMeshes.cpp @@ -1,7 +1,7 @@ #include "render/GpuProceduralMeshes.hpp" #include -#include +#include // std::pair namespace Caffeine::Render { namespace { @@ -31,6 +31,33 @@ void addQuad(std::vector& vertices, std::vector& indices, indices.push_back(base + 3); } +void emitTriangle(std::vector& vertices, std::vector& indices, u32 i0, + u32 i1, u32 i2) { + const Vec3 a = vertices[i0].position; + const Vec3 b = vertices[i1].position; + const Vec3 c = vertices[i2].position; + const Vec3 outward = a + b + c; + Vec3 n = (b - a).cross(c - a); + if (n.dot(outward) < 0.0f) std::swap(i1, i2); + indices.push_back(i0); + indices.push_back(i1); + indices.push_back(i2); +} + +void emitQuad(std::vector& vertices, std::vector& indices, u32 i0, u32 i1, + u32 i2, u32 i3) { + emitTriangle(vertices, indices, i0, i1, i2); + emitTriangle(vertices, indices, i0, i2, i3); +} + +Vec3 radialNormalXZ(f32 angle) { return Vec3(std::cos(angle), 0.0f, std::sin(angle)); } + +Vec3 coneSideNormal(f32 angle, f32 radius, f32 height) { + Vec3 n(std::cos(angle) * height, radius, std::sin(angle) * height); + if (n.lengthSquared() > 1e-8f) n = n.normalized(); + return n; +} + void buildCube(Assets::Mesh3D& mesh) { mesh.vertices.clear(); mesh.indices.clear(); @@ -102,18 +129,20 @@ void buildCylinder(Assets::Mesh3D& mesh, int segments = 24) { const f32 radius = 0.5f; const f32 halfH = 0.5f; + const u32 sideBase = static_cast(mesh.vertices.size()); + for (int i = 0; i <= segments; ++i) { + const f32 a = (2.0f * 3.14159265f * static_cast(i)) / static_cast(segments); + const Vec3 n = radialNormalXZ(a); + const f32 u = static_cast(i) / static_cast(segments); + mesh.vertices.push_back(vtx({n.x * radius, halfH, n.z * radius}, n, {u, 1.0f})); + mesh.vertices.push_back(vtx({n.x * radius, -halfH, n.z * radius}, n, {u, 0.0f})); + } for (int i = 0; i < segments; ++i) { - const f32 a0 = (2.0f * 3.14159265f * static_cast(i)) / static_cast(segments); - const f32 a1 = - (2.0f * 3.14159265f * static_cast((i + 1) % segments)) / static_cast(segments); - const Vec3 n0(std::cos(a0), 0.0f, std::sin(a0)); - const Vec3 n1(std::cos(a1), 0.0f, std::sin(a1)); - const Vec3 t0(n0.x * radius, halfH, n0.z * radius); - const Vec3 t1(n1.x * radius, halfH, n1.z * radius); - const Vec3 b0(n0.x * radius, -halfH, n0.z * radius); - const Vec3 b1(n1.x * radius, -halfH, n1.z * radius); - - addQuad(mesh.vertices, mesh.indices, b0, b1, t1, t0, n0); + const u32 t0 = sideBase + static_cast(i * 2); + const u32 t1 = sideBase + static_cast((i + 1) * 2); + const u32 b0 = t0 + 1; + const u32 b1 = t1 + 1; + emitQuad(mesh.vertices, mesh.indices, t0, t1, b1, b0); } const u32 topCenter = static_cast(mesh.vertices.size()); @@ -169,20 +198,244 @@ void buildCylinder(Assets::Mesh3D& mesh, int segments = 24) { mesh.bounds = {{-radius, -halfH, -radius}, {radius, halfH, radius}}; } +void appendTriangle(std::vector& vertices, std::vector& indices, Vec3 p0, + Vec3 p1, Vec3 p2) { + const u32 base = static_cast(vertices.size()); + Vec3 n = (p1 - p0).cross(p2 - p0); + if (n.lengthSquared() > 1e-8f) n = n.normalized(); + vertices.push_back(vtx(p0, n, {0.0f, 0.0f})); + vertices.push_back(vtx(p1, n, {1.0f, 0.0f})); + vertices.push_back(vtx(p2, n, {0.5f, 1.0f})); + indices.push_back(base + 0); + indices.push_back(base + 1); + indices.push_back(base + 2); +} + +void buildCapsule(Assets::Mesh3D& mesh, int segments = 24) { + mesh.vertices.clear(); + mesh.indices.clear(); + const f32 radius = 0.5f; + const f32 cylHalf = 0.25f; + const int latSegments = segments / 2; + const int lonSegments = segments; + constexpr f32 kPi = 3.14159265f; + + auto sphereNormal = [](f32 theta, f32 phi) { + return Vec3(std::sin(theta) * std::cos(phi), std::cos(theta), + std::sin(theta) * std::sin(phi)); + }; + + const u32 bottomBase = static_cast(mesh.vertices.size()); + for (int lat = 0; lat <= latSegments; ++lat) { + const f32 theta = + kPi - static_cast(lat) * (kPi * 0.5f) / static_cast(latSegments); + for (int lon = 0; lon <= lonSegments; ++lon) { + const f32 phi = (2.0f * kPi * static_cast(lon)) / static_cast(lonSegments); + const Vec3 n = sphereNormal(theta, phi); + const Vec3 p(n.x * radius, -cylHalf + n.y * radius, n.z * radius); + mesh.vertices.push_back(vtx(p, n, {static_cast(lon) / static_cast(lonSegments), + static_cast(lat) / static_cast(latSegments)})); + } + } + for (int lat = 0; lat < latSegments; ++lat) { + for (int lon = 0; lon < lonSegments; ++lon) { + const u32 row = static_cast(lat * (lonSegments + 1)); + const u32 next = static_cast((lat + 1) * (lonSegments + 1)); + const u32 i0 = bottomBase + row + static_cast(lon); + const u32 i1 = bottomBase + row + static_cast(lon + 1); + const u32 i2 = bottomBase + next + static_cast(lon + 1); + const u32 i3 = bottomBase + next + static_cast(lon); + emitQuad(mesh.vertices, mesh.indices, i0, i1, i2, i3); + } + } + + const u32 topRingBase = static_cast(mesh.vertices.size()); + for (int lon = 0; lon <= lonSegments; ++lon) { + const f32 phi = (2.0f * kPi * static_cast(lon)) / static_cast(lonSegments); + const Vec3 n = radialNormalXZ(phi); + mesh.vertices.push_back(vtx({n.x * radius, cylHalf, n.z * radius}, n, + {static_cast(lon) / static_cast(lonSegments), 0.5f})); + } + + const u32 bottomEquatorRow = bottomBase + static_cast(latSegments * (lonSegments + 1)); + for (int lon = 0; lon < lonSegments; ++lon) { + const u32 b0 = bottomEquatorRow + static_cast(lon); + const u32 b1 = bottomEquatorRow + static_cast(lon + 1); + const u32 t0 = topRingBase + static_cast(lon); + const u32 t1 = topRingBase + static_cast(lon + 1); + emitQuad(mesh.vertices, mesh.indices, b0, b1, t1, t0); + } + + const u32 topCapBase = static_cast(mesh.vertices.size()); + for (int lat = 1; lat <= latSegments; ++lat) { + const f32 theta = + kPi * 0.5f - static_cast(lat) * (kPi * 0.5f) / static_cast(latSegments); + for (int lon = 0; lon <= lonSegments; ++lon) { + const f32 phi = (2.0f * kPi * static_cast(lon)) / static_cast(lonSegments); + const Vec3 n = sphereNormal(theta, phi); + const Vec3 p(n.x * radius, cylHalf + n.y * radius, n.z * radius); + mesh.vertices.push_back(vtx(p, n, {static_cast(lon) / static_cast(lonSegments), + static_cast(lat) / static_cast(latSegments)})); + } + } + for (int lat = 0; lat < latSegments; ++lat) { + for (int lon = 0; lon < lonSegments; ++lon) { + const u32 i0 = (lat == 0) ? (topRingBase + static_cast(lon)) + : (topCapBase + static_cast((lat - 1) * (lonSegments + 1) + lon)); + const u32 i1 = (lat == 0) ? (topRingBase + static_cast(lon + 1)) + : (topCapBase + static_cast((lat - 1) * (lonSegments + 1) + lon + 1)); + const u32 i2 = + topCapBase + static_cast(lat * (lonSegments + 1) + lon + 1); + const u32 i3 = topCapBase + static_cast(lat * (lonSegments + 1) + lon); + emitQuad(mesh.vertices, mesh.indices, i0, i1, i2, i3); + } + } + + mesh.bounds = {{-radius, -cylHalf - radius, -radius}, {radius, cylHalf + radius, radius}}; +} + +void buildCone(Assets::Mesh3D& mesh, int segments = 24) { + mesh.vertices.clear(); + mesh.indices.clear(); + const f32 radius = 0.5f; + const f32 apexY = 0.5f; + const f32 baseY = -0.5f; + const f32 height = apexY - baseY; + + const u32 apexIdx = static_cast(mesh.vertices.size()); + mesh.vertices.push_back(vtx({0.0f, apexY, 0.0f}, Vec3(0.0f, 1.0f, 0.0f), {0.5f, 1.0f})); + + const u32 mantleRing = static_cast(mesh.vertices.size()); + for (int i = 0; i <= segments; ++i) { + const f32 a = (2.0f * 3.14159265f * static_cast(i)) / static_cast(segments); + const Vec3 n = coneSideNormal(a, radius, height); + mesh.vertices.push_back( + vtx({std::cos(a) * radius, baseY, std::sin(a) * radius}, n, + {static_cast(i) / static_cast(segments), 0.0f})); + } + for (int i = 0; i < segments; ++i) { + emitTriangle(mesh.vertices, mesh.indices, apexIdx, mantleRing + static_cast(i + 1), + mantleRing + static_cast(i)); + } + + const u32 baseCenter = static_cast(mesh.vertices.size()); + mesh.vertices.push_back(vtx({0.0f, baseY, 0.0f}, {0.0f, -1.0f, 0.0f}, {0.5f, 0.5f})); + const u32 capRing = static_cast(mesh.vertices.size()); + for (int i = 0; i <= segments; ++i) { + const f32 a = (2.0f * 3.14159265f * static_cast(i)) / static_cast(segments); + mesh.vertices.push_back( + vtx({std::cos(a) * radius, baseY, std::sin(a) * radius}, {0.0f, -1.0f, 0.0f}, + {static_cast(i) / static_cast(segments), 0.0f})); + } + for (int i = 0; i < segments; ++i) { + emitTriangle(mesh.vertices, mesh.indices, baseCenter, capRing + static_cast(i), + capRing + static_cast(i + 1)); + } + + mesh.bounds = {{-radius, baseY, -radius}, {radius, apexY, radius}}; +} + +void buildPyramid(Assets::Mesh3D& mesh) { + mesh.vertices.clear(); + mesh.indices.clear(); + const f32 half = 0.5f; + const f32 baseY = -0.5f; + const f32 apexY = 0.5f; + const Vec3 apex(0.0f, apexY, 0.0f); + const Vec3 b0(-half, baseY, -half); + const Vec3 b1(half, baseY, -half); + const Vec3 b2(half, baseY, half); + const Vec3 b3(-half, baseY, half); + + appendTriangle(mesh.vertices, mesh.indices, apex, b1, b0); + appendTriangle(mesh.vertices, mesh.indices, apex, b2, b1); + appendTriangle(mesh.vertices, mesh.indices, apex, b3, b2); + appendTriangle(mesh.vertices, mesh.indices, apex, b0, b3); + appendTriangle(mesh.vertices, mesh.indices, b0, b2, b1); + appendTriangle(mesh.vertices, mesh.indices, b0, b3, b2); + + mesh.bounds = {{-half, baseY, -half}, {half, apexY, half}}; +} + +void buildTorus(Assets::Mesh3D& mesh, int segments = 24, int tubeSegments = 16) { + mesh.vertices.clear(); + mesh.indices.clear(); + const f32 majorR = 0.35f; + const f32 minorR = 0.15f; + constexpr f32 kPi = 3.14159265f; + + auto torusFrame = [&](f32 u, f32 v) { + const f32 cu = std::cos(u); + const f32 su = std::sin(u); + const f32 cv = std::cos(v); + const f32 sv = std::sin(v); + const Vec3 normal(cu * cv, sv, su * cv); + const Vec3 center(majorR * cu, 0.0f, majorR * su); + return std::pair(center + normal * minorR, normal); + }; + + const u32 gridBase = static_cast(mesh.vertices.size()); + for (int i = 0; i <= segments; ++i) { + const f32 u = (2.0f * kPi * static_cast(i)) / static_cast(segments); + for (int j = 0; j <= tubeSegments; ++j) { + const f32 v = (2.0f * kPi * static_cast(j)) / static_cast(tubeSegments); + const auto [p, n] = torusFrame(u, v); + mesh.vertices.push_back(vtx(p, n, {static_cast(i) / static_cast(segments), + static_cast(j) / static_cast(tubeSegments)})); + } + } + const int rowStride = tubeSegments + 1; + for (int i = 0; i < segments; ++i) { + for (int j = 0; j < tubeSegments; ++j) { + const u32 row = static_cast(i * rowStride); + const u32 next = static_cast((i + 1) * rowStride); + const u32 i0 = gridBase + row + static_cast(j); + const u32 i1 = gridBase + row + static_cast(j + 1); + const u32 i2 = gridBase + next + static_cast(j + 1); + const u32 i3 = gridBase + next + static_cast(j); + emitQuad(mesh.vertices, mesh.indices, i0, i1, i2, i3); + } + } + + const f32 extent = majorR + minorR; + mesh.bounds = {{-extent, -minorR, -extent}, {extent, minorR, extent}}; +} + Assets::Mesh3D g_cube; Assets::Mesh3D g_plane; Assets::Mesh3D g_sphere; Assets::Mesh3D g_cylinder; +Assets::Mesh3D g_capsule; +Assets::Mesh3D g_cone; +Assets::Mesh3D g_pyramid; +Assets::Mesh3D g_torus; bool g_built = false; } // namespace void GpuProceduralMeshes::ensureBuilt() { if (g_built) return; + auto resetMesh = [](Assets::Mesh3D& mesh) { + mesh.vertexBuffer = nullptr; + mesh.indexBuffer = nullptr; + }; + resetMesh(g_cube); + resetMesh(g_plane); + resetMesh(g_sphere); + resetMesh(g_cylinder); + resetMesh(g_capsule); + resetMesh(g_cone); + resetMesh(g_pyramid); + resetMesh(g_torus); + buildCube(g_cube); buildPlane(g_plane); buildSphere(g_sphere); buildCylinder(g_cylinder); + buildCapsule(g_capsule); + buildCone(g_cone); + buildPyramid(g_pyramid); + buildTorus(g_torus); g_built = true; } @@ -190,6 +443,10 @@ Assets::Mesh3D& GpuProceduralMeshes::cube() { return g_cube; } Assets::Mesh3D& GpuProceduralMeshes::plane() { return g_plane; } Assets::Mesh3D& GpuProceduralMeshes::sphere() { return g_sphere; } Assets::Mesh3D& GpuProceduralMeshes::cylinder() { return g_cylinder; } +Assets::Mesh3D& GpuProceduralMeshes::capsule() { return g_capsule; } +Assets::Mesh3D& GpuProceduralMeshes::cone() { return g_cone; } +Assets::Mesh3D& GpuProceduralMeshes::pyramid() { return g_pyramid; } +Assets::Mesh3D& GpuProceduralMeshes::torus() { return g_torus; } #ifdef CF_HAS_SDL3 void GpuProceduralMeshes::releaseGpuResources(RHI::RenderDevice* device) { @@ -209,6 +466,10 @@ void GpuProceduralMeshes::releaseGpuResources(RHI::RenderDevice* device) { releaseMesh(g_plane); releaseMesh(g_sphere); releaseMesh(g_cylinder); + releaseMesh(g_capsule); + releaseMesh(g_cone); + releaseMesh(g_pyramid); + releaseMesh(g_torus); } #endif @@ -218,8 +479,11 @@ Assets::Mesh3D* GpuProceduralMeshes::get(ECS::MeshPrimitive primitive) { case ECS::MeshPrimitive::Cube: return &g_cube; case ECS::MeshPrimitive::Plane: return &g_plane; case ECS::MeshPrimitive::Sphere: return &g_sphere; - case ECS::MeshPrimitive::Cylinder: - case ECS::MeshPrimitive::Capsule: return &g_cylinder; + case ECS::MeshPrimitive::Cylinder: return &g_cylinder; + case ECS::MeshPrimitive::Capsule: return &g_capsule; + case ECS::MeshPrimitive::Cone: return &g_cone; + case ECS::MeshPrimitive::Pyramid: return &g_pyramid; + case ECS::MeshPrimitive::Torus: return &g_torus; case ECS::MeshPrimitive::Custom: return nullptr; } return nullptr; diff --git a/src/render/GpuProceduralMeshes.hpp b/src/render/GpuProceduralMeshes.hpp index 6322517..d5bfa15 100644 --- a/src/render/GpuProceduralMeshes.hpp +++ b/src/render/GpuProceduralMeshes.hpp @@ -23,6 +23,10 @@ class GpuProceduralMeshes { static Assets::Mesh3D& plane(); static Assets::Mesh3D& sphere(); static Assets::Mesh3D& cylinder(); + static Assets::Mesh3D& capsule(); + static Assets::Mesh3D& cone(); + static Assets::Mesh3D& pyramid(); + static Assets::Mesh3D& torus(); }; } // namespace Caffeine::Render diff --git a/src/render/GpuSceneRenderer.cpp b/src/render/GpuSceneRenderer.cpp index 21ef9a2..3c601d5 100644 --- a/src/render/GpuSceneRenderer.cpp +++ b/src/render/GpuSceneRenderer.cpp @@ -2,14 +2,18 @@ #include "render/ShaderBytecode.hpp" #include "render/GpuProceduralMeshes.hpp" #include "editor/EditorContext.hpp" +#include "editor/EditorCameraMath.hpp" #include "ecs/MeshComponents.hpp" #include "ecs/ComponentQuery.hpp" #include "scene/HierarchySystem.hpp" #include "scene/SceneComponents.hpp" #include "scene/CpuDirectionalShadowMap.hpp" #include "assets/MeshCache.hpp" +#include "assets/MeshImportValidator.hpp" +#include #include "ecs/TerrainComponents.hpp" #include "terrain/TerrainCache.hpp" +#include "render/GpuTextureCache.hpp" #include "terrain/TerrainGpuTextures.hpp" #include "terrain/TerrainLodSystem.hpp" #include "spatial/Octree.hpp" @@ -19,6 +23,7 @@ #include #include +#include #include namespace Caffeine::Render { @@ -37,12 +42,39 @@ struct LightingUBO { float albedo[4]; float metallic; float roughness; + float shininess; + float padFlags; // std140: vec4 uFlags must start at offset 64 + float flags[4]; int dirCount; int pointCount; + int spotCount; + int alignPad; float dirData[16]; float dirColor[16]; float pointData[16]; float pointColor[16]; + float spotData[16]; + float spotDir[16]; + float spotColor[16]; + float spotAngle[16]; +}; + +static_assert(offsetof(LightingUBO, flags) == 64, "LightingUBO.flags must match GLSL std140 vec4"); +static_assert(offsetof(LightingUBO, dirCount) == 80, "LightingUBO.dirCount must match GLSL std140"); +static_assert(offsetof(LightingUBO, dirData) == 96, "LightingUBO.dirData must match GLSL std140"); + +struct SceneShadowUBO { + float dirShadowVP[128]; + float dirCascadeSplits[8]; + float dirShadowValid[4]; + float cameraView[16]; + float pointShadowPos[8]; + float pointShadowRadius[4]; + float pointShadowValid[4]; + float spotShadowVP[32]; + float spotShadowPos[8]; + float spotShadowParams[8]; + float spotShadowValid[4]; }; struct ShadowUBO { @@ -103,6 +135,75 @@ Mat4 buildDirectionalLightVP(const Vec3& lightDirection, const Vec3& focus, f32 return proj * view; } +void buildDirectionalCascadeVPs(const GpuSceneCamera& camera, const Vec3& lightDirection, + f32 shadowDistance, u32 cascadeCount, Mat4* outVP, f32* outSplits) { + const f32 nearClip = std::max(camera.nearClip, 0.05f); + const f32 farClip = std::min(std::max(shadowDistance, nearClip + 1.0f), camera.farClip); + const f32 lambda = 0.5f; + const f32 ratio = farClip / nearClip; + + outSplits[0] = nearClip; + for (u32 i = 1; i <= cascadeCount; ++i) { + const f32 p = static_cast(i) / static_cast(cascadeCount); + const f32 logSplit = nearClip * std::pow(ratio, p); + const f32 uniformSplit = nearClip + (farClip - nearClip) * p; + outSplits[i] = lambda * logSplit + (1.0f - lambda) * uniformSplit; + } + + Vec3 lightDir = lightDirection; + const f32 len = lightDir.length(); + if (len < 1e-6f) { + for (u32 c = 0; c < cascadeCount; ++c) { + outVP[c] = Mat4::identity(); + } + return; + } + lightDir = lightDir / len; + + Vec3 camForward = camera.focus - camera.position; + if (camForward.lengthSquared() < 1e-8f) { + camForward = Vec3(0.0f, 0.0f, -1.0f); + } else { + camForward = camForward.normalized(); + } + const f32 tanHalfFov = std::tan(camera.fovRad * 0.5f); + + for (u32 c = 0; c < cascadeCount; ++c) { + const f32 splitNear = outSplits[c]; + const f32 splitFar = outSplits[c + 1]; + const f32 midDist = (splitNear + splitFar) * 0.5f; + const f32 extent = std::max(8.0f, splitFar * tanHalfFov * 2.5f); + const Vec3 focus = camera.position + camForward * midDist; + const Vec3 eye = focus - lightDir * extent; + Vec3 up(0.0f, 1.0f, 0.0f); + if (std::abs(lightDir.dot(up)) > 0.95f) { + up = Vec3(0.0f, 0.0f, 1.0f); + } + const Mat4 view = Mat4::lookAt(eye, focus, up); + const Mat4 proj = Mat4::ortho(-extent, extent, -extent, extent, 0.1f, extent * 4.0f); + outVP[c] = proj * view; + } +} + +Mat4 buildSpotLightVP(const Vec3& position, const Vec3& direction, f32 radius, f32 angleDegrees) { + Vec3 lightDir = direction; + const f32 len = lightDir.length(); + if (len < 1e-6f || radius <= 0.01f || angleDegrees <= 1.0f) { + return Mat4::identity(); + } + lightDir = lightDir / len; + + const Vec3 target = position + lightDir * radius; + Vec3 up(0.0f, 1.0f, 0.0f); + if (std::abs(lightDir.dot(up)) > 0.95f) { + up = Vec3(0.0f, 0.0f, 1.0f); + } + const Mat4 view = Mat4::lookAt(position, target, up); + const Mat4 proj = + Mat4::perspective(angleDegrees * 3.14159265f / 180.0f, 1.0f, 0.1f, radius); + return proj * view; +} + RHI::Shader* createShaderFromBuiltin(RHI::RenderDevice* device, BuiltinShader shader, RHI::ShaderStage stage, u32 numUniformBuffers, u32 numSamplers = 0) { @@ -121,6 +222,76 @@ RHI::Shader* createShaderFromBuiltin(RHI::RenderDevice* device, BuiltinShader sh return device->createShader(desc); } +struct ResolvedMeshAlbedo { + RHI::Texture* texture = nullptr; + Vec4 factor{1.0f, 1.0f, 1.0f, 1.0f}; + f32 metallic = 0.0f; + f32 roughness = 0.5f; +}; + +Vec3 meshWorldCenter(const Mat4& worldMatrix, const Assets::Mesh3D* mesh) { + if (!mesh) return worldMatrix.transformPoint(Vec3(0.0f, 0.0f, 0.0f)); + const Vec3 localCenter = (mesh->bounds.min + mesh->bounds.max) * 0.5f; + return worldMatrix.transformPoint(localCenter); +} + +ResolvedMeshAlbedo resolveMeshAlbedo(RHI::RenderDevice* device, const Assets::Mesh3D* mesh, + const std::string& meshPath, const std::string& projectRoot, + const std::string& customTexturePath, u32 materialIndex, + u32 qualityTier) { + ResolvedMeshAlbedo result; + auto& cache = GpuTextureCache::instance(); + + if (!customTexturePath.empty()) { + result.texture = cache.acquire(device, customTexturePath, projectRoot, qualityTier); + if (result.texture) return result; + } + + if (mesh && materialIndex < mesh->materials.size()) { + const Assets::MeshSurfaceMaterial& mat = mesh->materials[materialIndex]; + result.factor = Vec4(mat.albedoColor.r, mat.albedoColor.g, mat.albedoColor.b, + mat.albedoColor.a); + result.metallic = mat.metallic; + result.roughness = mat.roughness; + + if (!mat.albedoPath.empty()) { + result.texture = cache.acquire(device, mat.albedoPath, projectRoot, qualityTier); + if (result.texture) return result; + } + if (!mat.albedoPixels.empty() && mat.albedoWidth > 0 && mat.albedoHeight > 0) { + const std::string key = meshPath + "#mat" + std::to_string(materialIndex); + result.texture = cache.acquireFromPixels(device, key, mat.albedoPixels.data(), + mat.albedoWidth, mat.albedoHeight, + mat.albedoChannels, qualityTier); + if (result.texture) return result; + } + return result; + } + + if (!meshPath.empty()) { + const std::string pngPath = + std::filesystem::path(meshPath).replace_extension(".png").string(); + result.texture = cache.acquire(device, pngPath, projectRoot, qualityTier); + if (result.texture) return result; + + for (const std::string& uri : Assets::MeshImportValidator::listGltfExternalUris(meshPath)) { + const std::string texPath = + (std::filesystem::path(meshPath).parent_path() / uri).string(); + result.texture = cache.acquire(device, texPath, projectRoot, qualityTier); + if (result.texture) return result; + } + } + + if (mesh && mesh->textureWidth > 0 && !mesh->baseColorTexture.empty()) { + const std::string key = meshPath + "#embedded"; + result.texture = cache.acquireFromPixels(device, key, mesh->baseColorTexture.data(), + mesh->textureWidth, mesh->textureHeight, + mesh->textureChannels, qualityTier); + } + + return result; +} + void fillMeshPipelineDesc(RHI::GraphicsPipelineDesc& pipe, RHI::VertexBufferLayoutDesc& layout, RHI::VertexAttributeDesc* attrs, RHI::TextureFormat colorFormat, bool depthTest, bool depthWrite) { @@ -149,6 +320,7 @@ bool GpuSceneRenderer::init(RHI::RenderDevice* device) { m_device = device; if (!m_pointShadows.init(device)) return false; if (!m_directionalShadows.init(device)) return false; + if (!m_spotShadows.init(device)) return false; if (!createPipelines()) { shutdown(); return false; @@ -171,6 +343,7 @@ void GpuSceneRenderer::shutdown() { if (m_shadowVert) m_device->destroyShader(m_shadowVert); if (m_shadowFrag) m_device->destroyShader(m_shadowFrag); if (m_scenePipeline) m_device->destroyPipeline(m_scenePipeline); + if (m_wireframePipeline) m_device->destroyPipeline(m_wireframePipeline); if (m_terrainPipeline) m_device->destroyPipeline(m_terrainPipeline); if (m_shadowPipeline) m_device->destroyPipeline(m_shadowPipeline); if (m_sampler) m_device->destroySampler(m_sampler); @@ -182,12 +355,14 @@ void GpuSceneRenderer::shutdown() { m_shadowVert = nullptr; m_shadowFrag = nullptr; m_scenePipeline = nullptr; + m_wireframePipeline = nullptr; m_terrainPipeline = nullptr; m_shadowPipeline = nullptr; m_sampler = nullptr; m_repeatSampler = nullptr; m_pointShadows.shutdown(); m_directionalShadows.shutdown(); + m_spotShadows.shutdown(); m_device = nullptr; m_ready = false; } @@ -209,9 +384,9 @@ bool GpuSceneRenderer::createPipelines() { m_sceneVert = createShaderFromBuiltin(m_device, BuiltinShader::SceneLitVertex, RHI::ShaderStage::Vertex, 1); m_sceneFrag = createShaderFromBuiltin(m_device, BuiltinShader::SceneLitFragment, - RHI::ShaderStage::Fragment, 1, 1); + RHI::ShaderStage::Fragment, 2, 8); m_terrainFrag = createShaderFromBuiltin(m_device, BuiltinShader::TerrainLitFragment, - RHI::ShaderStage::Fragment, 2, 5); + RHI::ShaderStage::Fragment, 3, 12); m_shadowVert = createShaderFromBuiltin(m_device, BuiltinShader::ShadowDepthVertex, RHI::ShaderStage::Vertex, 1); m_shadowFrag = createShaderFromBuiltin(m_device, BuiltinShader::ShadowDepthFragment, @@ -226,6 +401,9 @@ bool GpuSceneRenderer::createPipelines() { RHI::GraphicsPipelineDesc sceneDesc{}; fillMeshPipelineDesc(sceneDesc, layout, attrs, RHI::TextureFormat::R8G8B8A8_UNORM, true, true); m_scenePipeline = m_device->createGraphicsPipeline(m_sceneVert, m_sceneFrag, sceneDesc); + RHI::GraphicsPipelineDesc wireDesc = sceneDesc; + wireDesc.fillMode = RHI::FillMode::Line; + m_wireframePipeline = m_device->createGraphicsPipeline(m_sceneVert, m_sceneFrag, wireDesc); m_terrainPipeline = m_device->createGraphicsPipeline(m_sceneVert, m_terrainFrag, sceneDesc); // Color-only shadow pass (no depth attachment) for cubemap / 2D shadow maps. @@ -233,12 +411,13 @@ bool GpuSceneRenderer::createPipelines() { fillMeshPipelineDesc(shadowDesc, layout, attrs, RHI::TextureFormat::R16_FLOAT, false, false); m_shadowPipeline = m_device->createGraphicsPipeline(m_shadowVert, m_shadowFrag, shadowDesc); - return m_scenePipeline && m_terrainPipeline && m_shadowPipeline; + return m_scenePipeline && m_wireframePipeline && m_terrainPipeline && m_shadowPipeline; } void GpuSceneRenderer::pushShadowDraw(RHI::CommandBuffer* cmd, const MeshDraw& draw, - const Mat4& mvp, const Vec3& lightPos, int mode) { - if (!m_shadowPipeline || !draw.mesh) return; + const Mat4& mvp, const Vec3& lightPos, int mode, + u32 indexCount, u32 firstIndex) { + if (!m_shadowPipeline || !draw.mesh || indexCount == 0) return; ShadowUBO ubo{}; std::memcpy(ubo.mvp, mvp.data(), sizeof(ubo.mvp)); @@ -252,7 +431,22 @@ void GpuSceneRenderer::pushShadowDraw(RHI::CommandBuffer* cmd, const MeshDraw& d cmd->pushUniformData(RHI::ShaderStage::Vertex, 0, &ubo, sizeof(ubo)); cmd->bindVertexBuffer(draw.mesh->vertexBuffer); cmd->bindIndexBuffer(draw.mesh->indexBuffer); - cmd->drawIndexed(static_cast(draw.mesh->indices.size())); + cmd->drawIndexed(indexCount, firstIndex, 0); +} + +void GpuSceneRenderer::pushShadowDrawsForMesh(RHI::CommandBuffer* cmd, const MeshDraw& draw, + const Mat4& mvp, const Vec3& lightPos, int mode) { + if (!draw.mesh || draw.mesh->indices.empty()) return; + + if (draw.mesh->subMeshes.size() > 1) { + for (const Assets::SubMesh& submesh : draw.mesh->subMeshes) { + if (submesh.indexCount == 0) continue; + pushShadowDraw(cmd, draw, mvp, lightPos, mode, submesh.indexCount, submesh.indexOffset); + } + return; + } + + pushShadowDraw(cmd, draw, mvp, lightPos, mode, static_cast(draw.mesh->indices.size()), 0); } bool GpuSceneRenderer::ensureMeshUploaded(Assets::Mesh3D* mesh) { @@ -275,10 +469,14 @@ bool GpuSceneRenderer::ensureMeshUploaded(Assets::Mesh3D* mesh) { void GpuSceneRenderer::renderDirectionalShadows(RHI::CommandBuffer* cmd, ECS::World& world, const Scene::SceneLighting& lighting, const std::vector& draws, - const Vec3& focus) { + const GpuSceneCamera& camera) { (void)world; if (!m_shadowPipeline || draws.empty()) return; + const f32 cascadeSize = + static_cast(GpuDirectionalShadowMap::kCascadeResolution); + const f32 atlasSize = static_cast(GpuDirectionalShadowMap::kAtlasResolution); + u32 shadowSlot = 0; for (const auto& dir : lighting.lights.directionals) { if (!dir.castShadows || shadowSlot >= GpuDirectionalShadowMap::kMaxDirectionalShadowLights) { @@ -288,8 +486,61 @@ void GpuSceneRenderer::renderDirectionalShadows(RHI::CommandBuffer* cmd, ECS::Wo RHI::Texture* map = m_directionalShadows.texture(shadowSlot); if (!map) continue; - const Mat4 lightVP = buildDirectionalLightVP(dir.direction, focus, dir.shadowDistance); - m_directionalShadows.setSlot(shadowSlot, lightVP, true); + const u32 cascadeCount = + shadowSlot == 0 ? GpuDirectionalShadowMap::kMaxCascades : 1u; + Mat4 cascadeVPs[GpuDirectionalShadowMap::kMaxCascades]{}; + f32 splits[GpuDirectionalShadowMap::kMaxCascades + 1]{}; + buildDirectionalCascadeVPs(camera, dir.direction, dir.shadowDistance, cascadeCount, + cascadeVPs, splits); + m_directionalShadows.setCascadeData(shadowSlot, cascadeCount, cascadeVPs, splits, true); + + RHI::RenderPassDesc pass; + pass.colorTarget = map; + pass.clearColor[0] = 1.0f; + pass.clearColor[1] = 1.0f; + pass.clearColor[2] = 1.0f; + pass.clearColor[3] = 1.0f; + cmd->beginRenderPass(pass); + + for (u32 cascade = 0; cascade < cascadeCount; ++cascade) { + const f32 offsetX = (cascade % 2) * cascadeSize; + const f32 offsetY = (cascade / 2) * cascadeSize; + const f32 viewportW = cascadeCount > 1 ? cascadeSize : atlasSize; + const f32 viewportH = cascadeCount > 1 ? cascadeSize : atlasSize; + cmd->setViewport(offsetX, offsetY, viewportW, viewportH); + + for (const auto& draw : draws) { + if (!draw.castShadows || !draw.mesh || !ensureMeshUploaded(draw.mesh)) continue; + pushShadowDrawsForMesh(cmd, draw, cascadeVPs[cascade] * draw.worldMatrix, + camera.focus, 1); + } + } + cmd->endRenderPass(); + ++shadowSlot; + } + + for (; shadowSlot < GpuDirectionalShadowMap::kMaxDirectionalShadowLights; ++shadowSlot) { + m_directionalShadows.clearSlot(shadowSlot); + } +} + +void GpuSceneRenderer::renderSpotShadows(RHI::CommandBuffer* cmd, ECS::World& world, + const Scene::SceneLighting& lighting, + const std::vector& draws) { + (void)world; + if (!m_shadowPipeline || draws.empty()) return; + + u32 shadowSlot = 0; + for (const auto& spot : lighting.lights.spots) { + if (!spot.castShadows || shadowSlot >= GpuSpotShadowMap::kMaxSpotShadowLights) continue; + + RHI::Texture* map = m_spotShadows.texture(shadowSlot); + if (!map) continue; + + const Mat4 lightVP = buildSpotLightVP(spot.position, spot.direction, spot.radius, spot.angle); + const f32 halfAngle = std::clamp(spot.angle * 3.14159265f / 180.0f * 0.5f, 0.01f, 1.5533f); + m_spotShadows.setSlot(shadowSlot, lightVP, spot.position, spot.radius, + std::cos(halfAngle), true); RHI::RenderPassDesc pass; pass.colorTarget = map; @@ -298,19 +549,19 @@ void GpuSceneRenderer::renderDirectionalShadows(RHI::CommandBuffer* cmd, ECS::Wo pass.clearColor[2] = 1.0f; pass.clearColor[3] = 1.0f; cmd->beginRenderPass(pass); - cmd->setViewport(0, 0, static_cast(GpuDirectionalShadowMap::kResolution), - static_cast(GpuDirectionalShadowMap::kResolution)); + cmd->setViewport(0, 0, static_cast(GpuSpotShadowMap::kResolution), + static_cast(GpuSpotShadowMap::kResolution)); for (const auto& draw : draws) { if (!draw.castShadows || !draw.mesh || !ensureMeshUploaded(draw.mesh)) continue; - pushShadowDraw(cmd, draw, lightVP * draw.worldMatrix, focus, 1); + pushShadowDrawsForMesh(cmd, draw, lightVP * draw.worldMatrix, spot.position, 1); } cmd->endRenderPass(); ++shadowSlot; } - for (; shadowSlot < GpuDirectionalShadowMap::kMaxDirectionalShadowLights; ++shadowSlot) { - m_directionalShadows.setSlot(shadowSlot, Mat4::identity(), false); + for (; shadowSlot < GpuSpotShadowMap::kMaxSpotShadowLights; ++shadowSlot) { + m_spotShadows.clearSlot(shadowSlot); } } @@ -351,21 +602,31 @@ void GpuSceneRenderer::renderPointShadows(RHI::CommandBuffer* cmd, ECS::World& w for (const auto& draw : draws) { if (!draw.castShadows || !draw.mesh || !ensureMeshUploaded(draw.mesh)) continue; - pushShadowDraw(cmd, draw, proj * view * draw.worldMatrix, point.position, 0); + pushShadowDrawsForMesh(cmd, draw, proj * view * draw.worldMatrix, point.position, 0); } cmd->endRenderPass(); } + m_pointShadows.setSlot(shadowSlot, point.position, point.radius, true); ++shadowSlot; } + + for (; shadowSlot < GpuPointShadowMap::kMaxPointShadowLights; ++shadowSlot) { + m_pointShadows.clearSlot(shadowSlot); + } } u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vector& draws, - const Mat4& vp, const Vec3& cameraPos, + const Mat4& vp, const Vec3& cameraPos, const Mat4& cameraView, const Scene::SceneLighting& lighting, RHI::Texture* colorTarget, RHI::Texture* depthTarget, - u32 width, u32 height) { + u32 width, u32 height, const std::string& projectRoot, + const GpuSceneRenderOptions& options) { Caffeine::Debug::setCrashBreadcrumb("GpuSceneRenderer::renderMeshes"); - if (!m_scenePipeline || draws.empty()) return 0; + const bool wireframeMeshes = options.wireframeMeshes; + if ((!wireframeMeshes && !m_scenePipeline) || + (wireframeMeshes && !m_wireframePipeline) || draws.empty()) { + return 0; + } u32 drawnMeshes = 0; @@ -378,15 +639,88 @@ u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vector(cascades); + shadowUbo.dirShadowValid[slot] = 1.0f; + } + } + for (u32 slot = 0; slot < GpuPointShadowMap::kMaxPointShadowLights; ++slot) { + if (m_pointShadows.valid(slot)) { + const Vec3 pos = m_pointShadows.lightPosition(slot); + shadowUbo.pointShadowPos[slot * 4 + 0] = pos.x; + shadowUbo.pointShadowPos[slot * 4 + 1] = pos.y; + shadowUbo.pointShadowPos[slot * 4 + 2] = pos.z; + shadowUbo.pointShadowRadius[slot] = m_pointShadows.radius(slot); + shadowUbo.pointShadowValid[slot] = 1.0f; + } + } + for (u32 slot = 0; slot < GpuSpotShadowMap::kMaxSpotShadowLights; ++slot) { + if (m_spotShadows.valid(slot)) { + const Vec3 pos = m_spotShadows.lightPosition(slot); + std::memcpy(shadowUbo.spotShadowVP + slot * 16, m_spotShadows.lightVP(slot).data(), + sizeof(float) * 16); + shadowUbo.spotShadowPos[slot * 4 + 0] = pos.x; + shadowUbo.spotShadowPos[slot * 4 + 1] = pos.y; + shadowUbo.spotShadowPos[slot * 4 + 2] = pos.z; + shadowUbo.spotShadowParams[slot * 4 + 0] = m_spotShadows.radius(slot); + shadowUbo.spotShadowParams[slot * 4 + 1] = m_spotShadows.cosHalfAngle(slot); + shadowUbo.spotShadowValid[slot] = 1.0f; + } + } + + RHI::Texture* whiteTex = GpuTextureCache::instance().whiteTexture(m_device); + cmd->beginRenderPass(pass); - cmd->bindPipeline(m_scenePipeline); + cmd->bindPipeline(wireframeMeshes ? m_wireframePipeline : m_scenePipeline); cmd->setViewport(0, 0, static_cast(width), static_cast(height)); + auto bindShadowTextures = [&](u32 dirBase, u32 pointBase, u32 spotBase) { + for (u32 slot = 0; slot < GpuDirectionalShadowMap::kMaxDirectionalShadowLights; ++slot) { + RHI::Texture* shadowTex = m_directionalShadows.texture(slot); + if (shadowTex) { + cmd->bindTexture(shadowTex, dirBase + slot, m_sampler); + } else if (whiteTex) { + cmd->bindTexture(whiteTex, dirBase + slot, m_sampler); + } + } + for (u32 slot = 0; slot < GpuPointShadowMap::kMaxPointShadowLights; ++slot) { + RHI::Texture* cube = m_pointShadows.cubemap(slot); + if (cube) { + cmd->bindTexture(cube, pointBase + slot, m_sampler); + } else if (whiteTex) { + cmd->bindTexture(whiteTex, pointBase + slot, m_sampler); + } + } + for (u32 slot = 0; slot < GpuSpotShadowMap::kMaxSpotShadowLights; ++slot) { + RHI::Texture* spotTex = m_spotShadows.texture(slot); + if (spotTex) { + cmd->bindTexture(spotTex, spotBase + slot, m_sampler); + } else if (whiteTex) { + cmd->bindTexture(whiteTex, spotBase + slot, m_sampler); + } + } + }; + bindShadowTextures(2, 4, 6); + for (const auto& draw : draws) { if (!draw.mesh || draw.mesh->indices.empty()) continue; if (!draw.mesh->vertexBuffer || !draw.mesh->indexBuffer) continue; - if (draw.isTerrain && m_terrainPipeline) { + if (wireframeMeshes) { + cmd->bindPipeline(m_wireframePipeline); + } else if (draw.isTerrain && m_terrainPipeline) { cmd->bindPipeline(m_terrainPipeline); } else { cmd->bindPipeline(m_scenePipeline); @@ -396,40 +730,83 @@ u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vector(std::min(lighting.lights.directionals.size(), size_t{4})); - for (int i = 0; i < lights.dirCount; ++i) { - const auto& d = lighting.lights.directionals[i]; - lights.dirData[i * 4 + 0] = d.direction.x; - lights.dirData[i * 4 + 1] = d.direction.y; - lights.dirData[i * 4 + 2] = d.direction.z; - lights.dirData[i * 4 + 3] = d.intensity; - lights.dirColor[i * 4 + 0] = d.color.x; - lights.dirColor[i * 4 + 1] = d.color.y; - lights.dirColor[i * 4 + 2] = d.color.z; - } + if (wireframeMeshes) { + lights.ambient[0] = 0.72f; + lights.ambient[1] = 0.78f; + lights.ambient[2] = 0.88f; + lights.albedo[0] = 0.55f; + lights.albedo[1] = 0.62f; + lights.albedo[2] = 0.74f; + lights.metallic = 0.0f; + lights.roughness = 1.0f; + lights.shininess = 1.0f; + lights.flags[0] = 0.0f; + lights.flags[1] = 0.0f; + lights.dirCount = 0; + lights.pointCount = 0; + lights.spotCount = 0; + } else { + lights.ambient[0] = draw.isTerrain ? 0.28f : 0.32f; + lights.ambient[1] = draw.isTerrain ? 0.28f : 0.32f; + lights.ambient[2] = draw.isTerrain ? 0.30f : 0.34f; + lights.albedo[0] = draw.albedo.x; + lights.albedo[1] = draw.albedo.y; + lights.albedo[2] = draw.albedo.z; + lights.metallic = draw.metallic; + lights.roughness = draw.roughness; + lights.shininess = draw.shininess; + lights.flags[0] = draw.receiveShadows ? 1.0f : 0.0f; + lights.flags[1] = draw.customNormalPath.empty() ? 0.0f : 1.0f; + + lights.dirCount = static_cast(std::min(lighting.lights.directionals.size(), size_t{4})); + for (int i = 0; i < lights.dirCount; ++i) { + const auto& d = lighting.lights.directionals[i]; + lights.dirData[i * 4 + 0] = d.direction.x; + lights.dirData[i * 4 + 1] = d.direction.y; + lights.dirData[i * 4 + 2] = d.direction.z; + lights.dirData[i * 4 + 3] = d.intensity; + lights.dirColor[i * 4 + 0] = d.color.x; + lights.dirColor[i * 4 + 1] = d.color.y; + lights.dirColor[i * 4 + 2] = d.color.z; + } + + lights.pointCount = static_cast(std::min(lighting.lights.points.size(), size_t{4})); + for (int i = 0; i < lights.pointCount; ++i) { + const auto& p = lighting.lights.points[i]; + lights.pointData[i * 4 + 0] = p.position.x; + lights.pointData[i * 4 + 1] = p.position.y; + lights.pointData[i * 4 + 2] = p.position.z; + lights.pointData[i * 4 + 3] = p.radius; + lights.pointColor[i * 4 + 0] = p.color.x; + lights.pointColor[i * 4 + 1] = p.color.y; + lights.pointColor[i * 4 + 2] = p.color.z; + lights.pointColor[i * 4 + 3] = p.intensity; + } - lights.pointCount = static_cast(std::min(lighting.lights.points.size(), size_t{4})); - for (int i = 0; i < lights.pointCount; ++i) { - const auto& p = lighting.lights.points[i]; - lights.pointData[i * 4 + 0] = p.position.x; - lights.pointData[i * 4 + 1] = p.position.y; - lights.pointData[i * 4 + 2] = p.position.z; - lights.pointData[i * 4 + 3] = p.radius; - lights.pointColor[i * 4 + 0] = p.color.x; - lights.pointColor[i * 4 + 1] = p.color.y; - lights.pointColor[i * 4 + 2] = p.color.z; + lights.spotCount = static_cast(std::min(lighting.lights.spots.size(), size_t{4})); + for (int i = 0; i < lights.spotCount; ++i) { + const auto& s = lighting.lights.spots[i]; + lights.spotData[i * 4 + 0] = s.position.x; + lights.spotData[i * 4 + 1] = s.position.y; + lights.spotData[i * 4 + 2] = s.position.z; + lights.spotData[i * 4 + 3] = s.radius; + lights.spotDir[i * 4 + 0] = s.direction.x; + lights.spotDir[i * 4 + 1] = s.direction.y; + lights.spotDir[i * 4 + 2] = s.direction.z; + lights.spotDir[i * 4 + 3] = s.intensity; + lights.spotColor[i * 4 + 0] = s.color.x; + lights.spotColor[i * 4 + 1] = s.color.y; + lights.spotColor[i * 4 + 2] = s.color.z; + const f32 halfAngle = + std::clamp(s.angle * 3.14159265f / 180.0f * 0.5f, 0.01f, 1.5533f); + lights.spotAngle[i * 4 + 0] = std::cos(halfAngle); + } } - if (draw.isTerrain) { + if (!wireframeMeshes && draw.isTerrain) { + // Terrain is matte: no Phong specular (shininess is ignored in terrain_lit.frag). + lights.roughness = 1.0f; + lights.shininess = 1.0f; TerrainMaterialUBO mat{}; if (draw.terrainSettings) { mat.worldSize[0] = draw.terrainSettings->worldSizeX; @@ -442,9 +819,10 @@ u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vectorbindTexture(whiteTex, slot, m_repeatSampler); } if (gpu && gpu->useSplatmap) { @@ -453,36 +831,116 @@ u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vectorbindTexture(gpu->splatMap, 0, m_sampler); } for (u32 i = 0; i < ECS::kTerrainSplatLayerCount; ++i) { - RHI::Texture* layerTex = gpu->layers[i] ? gpu->layers[i] : whiteTex; + RHI::Texture* layerTex = whiteTex; + if (!gpu->cachedLayerPaths[i].empty()) { + layerTex = texCache.acquire(m_device, gpu->cachedLayerPaths[i], projectRoot, + texTier); + } else if (gpu->layers[i]) { + layerTex = gpu->layers[i]; + } if (layerTex) { cmd->bindTexture(layerTex, 1 + i, m_repeatSampler); } } } else if (gpu) { - RHI::Texture* albedoTex = gpu->albedo ? gpu->albedo : whiteTex; + RHI::Texture* albedoTex = whiteTex; + if (!gpu->cachedAlbedoPath.empty()) { + albedoTex = + texCache.acquire(m_device, gpu->cachedAlbedoPath, projectRoot, texTier); + } else if (gpu->albedo) { + albedoTex = gpu->albedo; + } if (albedoTex) { mat.flags[1] = 1.0f; cmd->bindTexture(albedoTex, 4, m_repeatSampler); } } - cmd->pushUniformData(RHI::ShaderStage::Fragment, 1, &mat, sizeof(mat)); - } else { - RHI::Texture* whiteTex = - Terrain::TerrainGpuTextureCache::instance().whiteTexture(m_device); - RHI::Texture* albedo = draw.albedoMap ? draw.albedoMap : whiteTex; - if (albedo) cmd->bindTexture(albedo, 0, m_repeatSampler); + if (gpu) { + RHI::Texture* normalTex = nullptr; + if (!gpu->cachedNormalPath.empty()) { + normalTex = + texCache.acquire(m_device, gpu->cachedNormalPath, projectRoot, texTier); + } else if (gpu->normalMap) { + normalTex = gpu->normalMap; + } + if (normalTex) { + lights.flags[1] = 1.0f; + cmd->bindTexture(normalTex, 5, m_repeatSampler); + } + } + bindShadowTextures(6, 8, 10); + cmd->pushUniformData(RHI::ShaderStage::Fragment, 2, &mat, sizeof(mat)); } + cmd->pushUniformData(RHI::ShaderStage::Fragment, 1, &shadowUbo, sizeof(shadowUbo)); + VertexUBO vubo{}; const Mat4 mvp = vp * draw.worldMatrix; std::memcpy(vubo.mvp, mvp.data(), sizeof(vubo.mvp)); std::memcpy(vubo.model, draw.worldMatrix.data(), sizeof(vubo.model)); cmd->pushUniformData(RHI::ShaderStage::Vertex, 0, &vubo, sizeof(vubo)); - cmd->pushUniformData(RHI::ShaderStage::Fragment, 0, &lights, sizeof(lights)); - cmd->bindVertexBuffer(draw.mesh->vertexBuffer); cmd->bindIndexBuffer(draw.mesh->indexBuffer); - cmd->drawIndexed(static_cast(draw.mesh->indices.size())); + + auto drawMeshRange = [&](u32 firstIndex, u32 indexCount, u32 materialIndex) { + if (indexCount == 0) return; + + if (wireframeMeshes) { + bindShadowTextures(2, 4, 6); + if (whiteTex) { + cmd->bindTexture(whiteTex, 0, m_sampler); + cmd->bindTexture(whiteTex, 1, m_sampler); + } + } else if (!draw.isTerrain) { + bindShadowTextures(2, 4, 6); + const u32 texTier = + textureQualityTier(draw.viewerDistance, options.textureQuality); + const ResolvedMeshAlbedo albedo = + resolveMeshAlbedo(m_device, draw.mesh, draw.meshPath, projectRoot, + draw.customTexturePath, materialIndex, texTier); + lights.albedo[0] = draw.albedo.x * albedo.factor.x; + lights.albedo[1] = draw.albedo.y * albedo.factor.y; + lights.albedo[2] = draw.albedo.z * albedo.factor.z; + if (!draw.mesh->materials.empty() && + materialIndex < draw.mesh->materials.size()) { + lights.metallic = albedo.metallic; + lights.roughness = albedo.roughness; + } + + RHI::Texture* albedoTex = albedo.texture ? albedo.texture : whiteTex; + if (albedoTex) cmd->bindTexture(albedoTex, 0, m_repeatSampler); + RHI::Texture* normalTex = whiteTex; + if (!draw.customNormalPath.empty()) { + normalTex = GpuTextureCache::instance().acquire( + m_device, draw.customNormalPath, projectRoot, texTier); + } + if (normalTex) { + cmd->bindTexture(normalTex, 1, m_repeatSampler); + } + } + + cmd->pushUniformData(RHI::ShaderStage::Fragment, 0, &lights, sizeof(lights)); + cmd->drawIndexed(indexCount, firstIndex, 0); + }; + + if (draw.isTerrain) { + if (wireframeMeshes) { + for (u32 slot = 0; slot < 6; ++slot) { + if (whiteTex) cmd->bindTexture(whiteTex, slot, m_repeatSampler); + } + bindShadowTextures(6, 8, 10); + } + cmd->pushUniformData(RHI::ShaderStage::Fragment, 0, &lights, sizeof(lights)); + cmd->drawIndexed(static_cast(draw.mesh->indices.size())); + } else if (draw.mesh->subMeshes.size() > 1) { + for (const Assets::SubMesh& submesh : draw.mesh->subMeshes) { + drawMeshRange(submesh.indexOffset, submesh.indexCount, submesh.materialIndex); + } + } else { + const u32 materialIndex = + draw.mesh->subMeshes.empty() ? 0u : draw.mesh->subMeshes[0].materialIndex; + drawMeshRange(0, static_cast(draw.mesh->indices.size()), materialIndex); + } ++drawnMeshes; } @@ -492,8 +950,15 @@ u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vector GpuSceneRenderer::gatherMeshDraws( ECS::World& world, const Vec3& cameraPos, const Spatial::Frustum& frustum, - const std::string& projectRoot) { + const std::string& projectRoot, const GpuSceneRenderOptions& options) { std::vector draws; + const std::vector& viewers = options.textureQualityViewers; + auto viewerDistanceFor = [&](const Vec3& worldPos) -> f32 { + if (!viewers.empty()) { + return distanceToNearestViewer(worldPos, viewers); + } + return (worldPos - cameraPos).length(); + }; ECS::ComponentQuery q; q.with(); @@ -516,11 +981,15 @@ std::vector GpuSceneRenderer::gatherMeshDraws( draw.worldMatrix = worldMatrix; draw.terrainSettings = terrain; draw.isTerrain = true; - draw.castShadows = false; - draw.receiveShadows = Scene::meshReceivesShadows(world, entity); + draw.castShadows = terrain->castShadows; + draw.receiveShadows = terrain->receiveShadows; + draw.shininess = terrain->shininess; if (auto* renderer = world.get(entity)) { draw.receiveShadows = renderer->receiveShadows; } + const Vec3 chunkCenter = worldMatrix.transformPoint( + (chunk.localBoundsMin + chunk.localBoundsMax) * 0.5f); + draw.viewerDistance = viewerDistanceFor(chunkCenter); draws.push_back(draw); } return; @@ -569,10 +1038,14 @@ std::vector GpuSceneRenderer::gatherMeshDraws( draw.castShadows = renderer->castShadows; draw.receiveShadows = renderer->receiveShadows; } - if (!filter.customTexturePath.empty()) { - draw.albedoMap = Terrain::TerrainGpuTextureCache::instance().textureFromPath( - m_device, filter.customTexturePath, projectRoot); + draw.shininess = filter.shininess; + draw.customTexturePath = filter.customTexturePath; + draw.customNormalPath = filter.customNormalPath; + if (filter.primitive == ECS::MeshPrimitive::Custom) { + const std::string& resolved = Assets::MeshCache::getInstance().getResolvedPath(); + draw.meshPath = resolved.empty() ? filter.customMeshPath : resolved; } + draw.viewerDistance = viewerDistanceFor(meshWorldCenter(worldMatrix, mesh)); draws.push_back(draw); }); @@ -582,7 +1055,8 @@ std::vector GpuSceneRenderer::gatherMeshDraws( u32 GpuSceneRenderer::renderWithCamera(RHI::CommandBuffer* cmd, ECS::World& world, const GpuSceneCamera& camera, RHI::Texture* colorTarget, RHI::Texture* depthTarget, u32 width, u32 height, - const std::string& projectRoot) { + const std::string& projectRoot, + const GpuSceneRenderOptions& options) { Caffeine::Debug::setCrashBreadcrumb("GpuSceneRenderer::renderWithCamera"); if (!m_ready || !cmd || !colorTarget || !depthTarget || width < 1 || height < 1) return 0; @@ -593,7 +1067,7 @@ u32 GpuSceneRenderer::renderWithCamera(RHI::CommandBuffer* cmd, ECS::World& worl camera.nearClip, camera.farClip); std::vector draws = - gatherMeshDraws(world, camera.position, frustum, projectRoot); + gatherMeshDraws(world, camera.position, frustum, projectRoot, options); // Upload GPU buffers before any render pass. Creating/uploading buffers // while a pass is recording is a common NVIDIA driver SIGSEGV. @@ -609,20 +1083,25 @@ u32 GpuSceneRenderer::renderWithCamera(RHI::CommandBuffer* cmd, ECS::World& worl lighting.clear(); Scene::collectSceneLights(world, lighting.lights); - return renderMeshes(cmd, draws, vp, camera.position, lighting, colorTarget, depthTarget, width, - height); + if (!options.wireframeMeshes && options.enableShadows) { + renderDirectionalShadows(cmd, world, lighting, draws, camera); + renderPointShadows(cmd, world, lighting, draws); + renderSpotShadows(cmd, world, lighting, draws); + } + + return renderMeshes(cmd, draws, vp, camera.position, camera.view, lighting, colorTarget, + depthTarget, width, height, projectRoot, options); } u32 GpuSceneRenderer::render(RHI::CommandBuffer* cmd, ECS::World& world, const Editor::EditorContext& ctx, RHI::Texture* colorTarget, RHI::Texture* depthTarget, u32 width, u32 height, - const std::string& projectRoot) { + const std::string& projectRoot, const GpuSceneRenderOptions& options) { if (!m_ready || !cmd || !colorTarget || !depthTarget || width < 1 || height < 1) return 0; if (ctx.viewMode != Editor::EditorContext::ViewMode::Mode3D) return 0; - const f32 sinY = std::sin(ctx.camYaw), cosY = std::cos(ctx.camYaw); - const f32 sinP = std::sin(ctx.camPitch), cosP = std::cos(ctx.camPitch); - const Vec3 cameraPos = ctx.camFocus + Vec3(sinY * cosP, -sinP, -cosY * cosP) * ctx.camDistance; + const Vec3 cameraPos = + Editor::editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); const f32 aspect = static_cast(width) / static_cast(std::max(height, 1u)); const f32 farPlane = ctx.cameraFarPlane(); @@ -636,7 +1115,7 @@ u32 GpuSceneRenderer::render(RHI::CommandBuffer* cmd, ECS::World& world, camera.farClip = farPlane; return renderWithCamera(cmd, world, camera, colorTarget, depthTarget, width, height, - projectRoot); + projectRoot, options); } } // namespace Caffeine::Render diff --git a/src/render/GpuSceneRenderer.hpp b/src/render/GpuSceneRenderer.hpp index 8e74e1d..d62bcac 100644 --- a/src/render/GpuSceneRenderer.hpp +++ b/src/render/GpuSceneRenderer.hpp @@ -11,6 +11,8 @@ #include "../assets/MeshTypes.hpp" #include "render/GpuPointShadowMap.hpp" #include "render/GpuDirectionalShadowMap.hpp" +#include "render/GpuSpotShadowMap.hpp" +#include "render/TextureQuality.hpp" #include "spatial/Octree.hpp" #include @@ -32,6 +34,13 @@ struct GpuSceneCamera { f32 farClip = 10000.0f; }; +struct GpuSceneRenderOptions { + bool wireframeMeshes = false; + bool enableShadows = true; + TextureQualitySettings textureQuality{}; + std::vector textureQualityViewers; +}; + class GpuSceneRenderer { public: bool init(RHI::RenderDevice* device); @@ -41,11 +50,13 @@ class GpuSceneRenderer { u32 render(RHI::CommandBuffer* cmd, ECS::World& world, const Editor::EditorContext& ctx, RHI::Texture* colorTarget, RHI::Texture* depthTarget, u32 width, u32 height, - const std::string& projectRoot); + const std::string& projectRoot, + const GpuSceneRenderOptions& options = {}); u32 renderWithCamera(RHI::CommandBuffer* cmd, ECS::World& world, const GpuSceneCamera& camera, RHI::Texture* colorTarget, RHI::Texture* depthTarget, u32 width, - u32 height, const std::string& projectRoot); + u32 height, const std::string& projectRoot, + const GpuSceneRenderOptions& options = {}); private: struct MeshDraw { @@ -56,10 +67,14 @@ class GpuSceneRenderer { Vec4 albedo{1, 1, 1, 1}; f32 metallic = 0.0f; f32 roughness = 0.5f; - RHI::Texture* albedoMap = nullptr; + f32 shininess = 32.0f; + std::string meshPath; + std::string customTexturePath; + std::string customNormalPath; bool castShadows = true; bool receiveShadows = true; bool isTerrain = false; + f32 viewerDistance = 0.0f; }; bool createPipelines(); @@ -67,20 +82,27 @@ class GpuSceneRenderer { void renderDirectionalShadows(RHI::CommandBuffer* cmd, ECS::World& world, const Scene::SceneLighting& lighting, const std::vector& draws, - const Vec3& focus); + const GpuSceneCamera& camera); void renderPointShadows(RHI::CommandBuffer* cmd, ECS::World& world, const Scene::SceneLighting& lighting, const std::vector& draws); + void renderSpotShadows(RHI::CommandBuffer* cmd, ECS::World& world, + const Scene::SceneLighting& lighting, + const std::vector& draws); u32 renderMeshes(RHI::CommandBuffer* cmd, const std::vector& draws, - const Mat4& vp, const Vec3& cameraPos, + const Mat4& vp, const Vec3& cameraPos, const Mat4& cameraView, const Scene::SceneLighting& lighting, RHI::Texture* colorTarget, RHI::Texture* depthTarget, - u32 width, u32 height); + u32 width, u32 height, const std::string& projectRoot, + const GpuSceneRenderOptions& options); void pushShadowDraw(RHI::CommandBuffer* cmd, const MeshDraw& draw, const Mat4& mvp, - const Vec3& lightPos, int mode); + const Vec3& lightPos, int mode, u32 indexCount, u32 firstIndex = 0); + void pushShadowDrawsForMesh(RHI::CommandBuffer* cmd, const MeshDraw& draw, const Mat4& mvp, + const Vec3& lightPos, int mode); std::vector gatherMeshDraws(ECS::World& world, const Vec3& cameraPos, const Spatial::Frustum& frustum, - const std::string& projectRoot); + const std::string& projectRoot, + const GpuSceneRenderOptions& options); RHI::RenderDevice* m_device = nullptr; RHI::Shader* m_sceneVert = nullptr; @@ -89,12 +111,14 @@ class GpuSceneRenderer { RHI::Shader* m_shadowVert = nullptr; RHI::Shader* m_shadowFrag = nullptr; RHI::Pipeline* m_scenePipeline = nullptr; + RHI::Pipeline* m_wireframePipeline = nullptr; RHI::Pipeline* m_terrainPipeline = nullptr; RHI::Pipeline* m_shadowPipeline = nullptr; RHI::Sampler* m_sampler = nullptr; RHI::Sampler* m_repeatSampler = nullptr; GpuPointShadowMap m_pointShadows; GpuDirectionalShadowMap m_directionalShadows; + GpuSpotShadowMap m_spotShadows; bool m_ready = false; }; diff --git a/src/render/GpuSpotShadowMap.cpp b/src/render/GpuSpotShadowMap.cpp new file mode 100644 index 0000000..e2ef824 --- /dev/null +++ b/src/render/GpuSpotShadowMap.cpp @@ -0,0 +1,67 @@ +#include "render/GpuSpotShadowMap.hpp" + +namespace Caffeine::Render { + +bool GpuSpotShadowMap::init(RHI::RenderDevice* device) { + if (!device || !device->isInitialized()) { + return false; + } + + shutdown(); + m_device = device; + + for (u32 i = 0; i < kMaxSpotShadowLights; ++i) { + RHI::TextureDesc desc; + desc.width = kResolution; + desc.height = kResolution; + desc.format = RHI::TextureFormat::R16_FLOAT; + desc.usage = RHI::TextureUsage::Sampler | RHI::TextureUsage::ColorTarget; + desc.mipLevels = 1; + m_textures[i] = m_device->createTexture(desc); + if (!m_textures[i]) { + shutdown(); + return false; + } + m_valid[i] = false; + m_lightVP[i] = Mat4::identity(); + } + + m_initialized = true; + return true; +} + +void GpuSpotShadowMap::shutdown() { + if (m_device) { + for (u32 i = 0; i < kMaxSpotShadowLights; ++i) { + if (m_textures[i]) { + m_device->destroyTexture(m_textures[i]); + m_textures[i] = nullptr; + } + m_valid[i] = false; + } + } + m_device = nullptr; + m_initialized = false; +} + +RHI::Texture* GpuSpotShadowMap::texture(u32 slot) const { + if (slot >= kMaxSpotShadowLights) return nullptr; + return m_textures[slot]; +} + +void GpuSpotShadowMap::setSlot(u32 slot, const Mat4& lightVP, const Vec3& position, f32 radius, + f32 cosHalfAngle, bool isValid) { + if (slot >= kMaxSpotShadowLights) return; + m_lightVP[slot] = lightVP; + m_lightPos[slot] = position; + m_radius[slot] = radius; + m_cosHalfAngle[slot] = cosHalfAngle; + m_valid[slot] = isValid; +} + +void GpuSpotShadowMap::clearSlot(u32 slot) { + if (slot >= kMaxSpotShadowLights) return; + m_valid[slot] = false; +} + +} // namespace Caffeine::Render diff --git a/src/render/GpuSpotShadowMap.hpp b/src/render/GpuSpotShadowMap.hpp new file mode 100644 index 0000000..4dbbfdb --- /dev/null +++ b/src/render/GpuSpotShadowMap.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "core/Types.hpp" +#include "math/Mat4.hpp" +#include "math/Vec3.hpp" +#include "rhi/RenderDevice.hpp" + +namespace Caffeine::Render { + +class GpuSpotShadowMap { +public: + static constexpr u32 kMaxSpotShadowLights = 2; + static constexpr u32 kResolution = 512; + + bool init(RHI::RenderDevice* device); + void shutdown(); + + bool isInitialized() const { return m_initialized; } + + RHI::Texture* texture(u32 slot) const; + const Mat4& lightVP(u32 slot) const { return m_lightVP[slot]; } + const Vec3& lightPosition(u32 slot) const { return m_lightPos[slot]; } + f32 radius(u32 slot) const { return m_radius[slot]; } + f32 cosHalfAngle(u32 slot) const { return m_cosHalfAngle[slot]; } + bool valid(u32 slot) const { return slot < kMaxSpotShadowLights && m_valid[slot]; } + + void setSlot(u32 slot, const Mat4& lightVP, const Vec3& position, f32 radius, + f32 cosHalfAngle, bool isValid); + void clearSlot(u32 slot); + +private: + RHI::RenderDevice* m_device = nullptr; + RHI::Texture* m_textures[kMaxSpotShadowLights]{}; + Mat4 m_lightVP[kMaxSpotShadowLights]{}; + Vec3 m_lightPos[kMaxSpotShadowLights]{}; + f32 m_radius[kMaxSpotShadowLights]{}; + f32 m_cosHalfAngle[kMaxSpotShadowLights]{}; + bool m_valid[kMaxSpotShadowLights]{}; + bool m_initialized = false; +}; + +} // namespace Caffeine::Render diff --git a/src/render/GpuTextureCache.cpp b/src/render/GpuTextureCache.cpp new file mode 100644 index 0000000..e65eef0 --- /dev/null +++ b/src/render/GpuTextureCache.cpp @@ -0,0 +1,323 @@ +#include "render/GpuTextureCache.hpp" + +#ifdef CF_HAS_SDL3 + +#include "assets/MeshCache.hpp" +#include "render/TextureQuality.hpp" + +#include + +#include +#include +#include +#include + +namespace Caffeine::Render { +namespace { + +void halveRgbaImage(const u8* src, u32 srcW, u32 srcH, std::vector& dst, u32& dstW, u32& dstH) { + dstW = std::max(1u, srcW / 2); + dstH = std::max(1u, srcH / 2); + dst.resize(static_cast(dstW) * static_cast(dstH) * 4); + for (u32 y = 0; y < dstH; ++y) { + for (u32 x = 0; x < dstW; ++x) { + const u32 sx = x * 2; + const u32 sy = y * 2; + u32 r = 0, g = 0, b = 0, a = 0; + u32 count = 0; + for (u32 oy = 0; oy < 2 && sy + oy < srcH; ++oy) { + for (u32 ox = 0; ox < 2 && sx + ox < srcW; ++ox) { + const size_t idx = + (static_cast(sy + oy) * static_cast(srcW) + + static_cast(sx + ox)) * + 4; + r += src[idx + 0]; + g += src[idx + 1]; + b += src[idx + 2]; + a += src[idx + 3]; + ++count; + } + } + if (count == 0) count = 1; + const size_t out = (static_cast(y) * static_cast(dstW) + + static_cast(x)) * + 4; + dst[out + 0] = static_cast(r / count); + dst[out + 1] = static_cast(g / count); + dst[out + 2] = static_cast(b / count); + dst[out + 3] = static_cast(a / count); + } + } +} + +} // namespace + +GpuTextureCache& GpuTextureCache::instance() { + static GpuTextureCache cache; + return cache; +} + +void GpuTextureCache::downscaleRgba(std::vector& rgba, u32& width, u32& height, u32 qualityTier) { + for (u32 tier = 0; tier < qualityTier; ++tier) { + if (width <= 16 && height <= 16) break; + std::vector smaller; + u32 newW = 0; + u32 newH = 0; + halveRgbaImage(rgba.data(), width, height, smaller, newW, newH); + rgba = std::move(smaller); + width = newW; + height = newH; + } +} + +std::string GpuTextureCache::resolvePath(const std::string& path, + const std::string& projectRoot) const { + return Assets::MeshCache::resolveTexturePath(path, projectRoot); +} + +u64 GpuTextureCache::fileTimestamp(const std::string& path) const { + std::error_code ec; + const auto ft = std::filesystem::last_write_time(path, ec); + if (ec) return 0; + return static_cast(ft.time_since_epoch().count()); +} + +GpuTextureCache::Entry* GpuTextureCache::findByTexture(RHI::Texture* texture) { + if (!texture) return nullptr; + for (auto& [_, entry] : m_cache) { + if (entry.texture == texture) return &entry; + } + return nullptr; +} + +void GpuTextureCache::destroyEntry(RHI::RenderDevice* device, Entry& entry) { + if (device && entry.texture) { + device->destroyTexture(entry.texture); + } + entry.texture = nullptr; + entry.refCount = 0; +} + +RHI::Texture* GpuTextureCache::loadTexture(RHI::RenderDevice* device, const std::string& resolved, + u32 qualityTier) { + int width = 0; + int height = 0; + int channels = 0; + u8* pixels = stbi_load(resolved.c_str(), &width, &height, &channels, 4); + if (!pixels || width < 1 || height < 1) { + if (pixels) stbi_image_free(pixels); + return nullptr; + } + + u32 uploadW = static_cast(width); + u32 uploadH = static_cast(height); + std::vector rgba; + if (qualityTier > 0) { + rgba.assign(pixels, pixels + static_cast(width) * static_cast(height) * 4); + stbi_image_free(pixels); + pixels = nullptr; + downscaleRgba(rgba, uploadW, uploadH, qualityTier); + } + + RHI::TextureDesc desc; + desc.width = uploadW; + desc.height = uploadH; + desc.format = RHI::TextureFormat::R8G8B8A8_UNORM; + desc.usage = RHI::TextureUsage::Sampler; + desc.mipLevels = 1; + + RHI::Texture* texture = device->createTexture(desc); + if (!texture) { + if (pixels) stbi_image_free(pixels); + return nullptr; + } + + const bool uploaded = pixels + ? device->uploadTexture(texture, pixels, uploadW, uploadH, 4, 0) + : device->uploadTexture(texture, rgba.data(), uploadW, uploadH, 4, 0); + if (pixels) stbi_image_free(pixels); + if (!uploaded) { + device->destroyTexture(texture); + return nullptr; + } + + return texture; +} + +RHI::Texture* GpuTextureCache::acquireFromPixels(RHI::RenderDevice* device, + const std::string& cacheKey, const u8* pixels, + u32 width, u32 height, int channels, + u32 qualityTier) { + if (!device || !pixels || width < 1 || height < 1 || cacheKey.empty()) return nullptr; + + const std::string tieredKey = textureCacheKeyForTier(cacheKey, qualityTier); + auto it = m_cache.find(tieredKey); + if (it != m_cache.end() && it->second.texture) { + it->second.refCount++; + return it->second.texture; + } + + const int srcChannels = channels > 0 ? channels : 4; + const size_t pixelCount = static_cast(width) * static_cast(height); + std::vector rgba(pixelCount * 4); + if (srcChannels == 4) { + std::memcpy(rgba.data(), pixels, pixelCount * 4); + } else if (srcChannels == 3) { + for (size_t i = 0; i < pixelCount; ++i) { + rgba[i * 4 + 0] = pixels[i * 3 + 0]; + rgba[i * 4 + 1] = pixels[i * 3 + 1]; + rgba[i * 4 + 2] = pixels[i * 3 + 2]; + rgba[i * 4 + 3] = 255; + } + } else if (srcChannels == 1) { + for (size_t i = 0; i < pixelCount; ++i) { + const u8 v = pixels[i]; + rgba[i * 4 + 0] = v; + rgba[i * 4 + 1] = v; + rgba[i * 4 + 2] = v; + rgba[i * 4 + 3] = 255; + } + } else { + return nullptr; + } + + u32 uploadW = width; + u32 uploadH = height; + downscaleRgba(rgba, uploadW, uploadH, qualityTier); + + RHI::TextureDesc desc; + desc.width = uploadW; + desc.height = uploadH; + desc.format = RHI::TextureFormat::R8G8B8A8_UNORM; + desc.usage = RHI::TextureUsage::Sampler; + desc.mipLevels = 1; + + RHI::Texture* texture = device->createTexture(desc); + if (!texture) return nullptr; + + if (!device->uploadTexture(texture, rgba.data(), uploadW, uploadH, 4, 0)) { + device->destroyTexture(texture); + return nullptr; + } + + Entry entry; + entry.texture = texture; + entry.refCount = 1; + entry.width = uploadW; + entry.height = uploadH; + entry.mipLevels = 1; + m_cache[tieredKey] = entry; + return texture; +} + +RHI::Texture* GpuTextureCache::acquire(RHI::RenderDevice* device, const std::string& path, + const std::string& projectRoot, u32 qualityTier) { + if (!device || path.empty()) return nullptr; + + const std::string resolved = resolvePath(path, projectRoot); + if (resolved.empty()) return nullptr; + + const std::string cacheKey = textureCacheKeyForTier(resolved, qualityTier); + const u64 stamp = fileTimestamp(resolved); + auto it = m_cache.find(cacheKey); + if (it != m_cache.end() && it->second.texture) { + Entry& entry = it->second; + if (stamp != 0 && entry.fileStamp != stamp && entry.refCount == 0) { + destroyEntry(device, entry); + m_cache.erase(it); + } else { + entry.refCount++; + return entry.texture; + } + } + + RHI::Texture* texture = loadTexture(device, resolved, qualityTier); + if (!texture) return nullptr; + + Entry entry; + entry.texture = texture; + entry.refCount = 1; + entry.fileStamp = stamp; + entry.width = texture->width; + entry.height = texture->height; + entry.mipLevels = 1; + m_cache[cacheKey] = entry; + return texture; +} + +void GpuTextureCache::release(RHI::RenderDevice* device, const std::string& resolvedPath) { + if (resolvedPath.empty()) return; + for (u32 tier = 0; tier <= kTextureQualityMaxTier; ++tier) { + const std::string key = textureCacheKeyForTier(resolvedPath, tier); + auto it = m_cache.find(key); + if (it == m_cache.end()) continue; + Entry& entry = it->second; + if (entry.refCount > 0) entry.refCount--; + if (entry.refCount == 0) { + destroyEntry(device, entry); + m_cache.erase(it); + } + } +} + +void GpuTextureCache::release(RHI::RenderDevice* device, RHI::Texture* texture) { + if (!texture || texture == m_white) return; + Entry* entry = findByTexture(texture); + if (!entry) return; + for (auto it = m_cache.begin(); it != m_cache.end(); ++it) { + if (&it->second == entry) { + if (entry->refCount > 0) entry->refCount--; + if (entry->refCount == 0) { + destroyEntry(device, *entry); + m_cache.erase(it); + } + return; + } + } +} + +RHI::Texture* GpuTextureCache::whiteTexture(RHI::RenderDevice* device) { + if (m_white) return m_white; + if (!device) return nullptr; + + RHI::TextureDesc desc; + desc.width = 1; + desc.height = 1; + desc.format = RHI::TextureFormat::R8G8B8A8_UNORM; + desc.usage = RHI::TextureUsage::Sampler; + desc.mipLevels = 1; + m_white = device->createTexture(desc); + if (!m_white) return nullptr; + const u8 white[4] = {255, 255, 255, 255}; + device->uploadTexture(m_white, white, 1, 1, 4, 0); + return m_white; +} + +void GpuTextureCache::invalidate(const std::string& resolvedPath) { + for (u32 tier = 0; tier <= kTextureQualityMaxTier; ++tier) { + const std::string key = textureCacheKeyForTier(resolvedPath, tier); + auto it = m_cache.find(key); + if (it != m_cache.end()) { + it->second.fileStamp = 0; + } + } + auto it = m_cache.find(resolvedPath); + if (it != m_cache.end()) { + it->second.fileStamp = 0; + } +} + +void GpuTextureCache::releaseAll(RHI::RenderDevice* device) { + for (auto& [_, entry] : m_cache) { + destroyEntry(device, entry); + } + m_cache.clear(); + if (m_white) { + if (device) device->destroyTexture(m_white); + m_white = nullptr; + } +} + +} // namespace Caffeine::Render + +#endif diff --git a/src/render/GpuTextureCache.hpp b/src/render/GpuTextureCache.hpp new file mode 100644 index 0000000..87efec3 --- /dev/null +++ b/src/render/GpuTextureCache.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "core/Types.hpp" + +#ifdef CF_HAS_SDL3 + +#include "rhi/RenderDevice.hpp" + +#include +#include +#include + +namespace Caffeine::Render { + +/// Global GPU texture cache: path → RHI::Texture with refcount and mipmaps. +/// Decode happens once per path; render loop must only call acquire() (cache hit). +class GpuTextureCache { +public: + static GpuTextureCache& instance(); + + /// Returns a cached 2D texture (RGBA8). Increments refcount. + /// qualityTier: 0=full, 1=half, 2=quarter, 3=eighth resolution. + RHI::Texture* acquire(RHI::RenderDevice* device, const std::string& path, + const std::string& projectRoot, u32 qualityTier = 0); + + /// Uploads pixel data under a stable cache key (embedded glTF images, etc.). + RHI::Texture* acquireFromPixels(RHI::RenderDevice* device, const std::string& cacheKey, + const u8* pixels, u32 width, u32 height, int channels, + u32 qualityTier = 0); + + /// Decrement refcount for a resolved filesystem path. + void release(RHI::RenderDevice* device, const std::string& resolvedPath); + + /// Decrement refcount by texture pointer (linear lookup). + void release(RHI::RenderDevice* device, RHI::Texture* texture); + + RHI::Texture* whiteTexture(RHI::RenderDevice* device); + + /// Drop cache entry if file changed on disk (next acquire reloads). + void invalidate(const std::string& resolvedPath); + + void releaseAll(RHI::RenderDevice* device); + + usize cachedEntryCount() const { return m_cache.size(); } + +private: + struct Entry { + RHI::Texture* texture = nullptr; + u32 refCount = 0; + u64 fileStamp = 0; + u32 width = 0; + u32 height = 0; + u32 mipLevels = 1; + }; + + std::string resolvePath(const std::string& path, const std::string& projectRoot) const; + u64 fileTimestamp(const std::string& path) const; + Entry* findByTexture(RHI::Texture* texture); + RHI::Texture* loadTexture(RHI::RenderDevice* device, const std::string& resolved, + u32 qualityTier); + static void downscaleRgba(std::vector& rgba, u32& width, u32& height, u32 qualityTier); + void destroyEntry(RHI::RenderDevice* device, Entry& entry); + + std::unordered_map m_cache; + RHI::Texture* m_white = nullptr; +}; + +} // namespace Caffeine::Render + +#endif diff --git a/src/render/TextureQuality.hpp b/src/render/TextureQuality.hpp new file mode 100644 index 0000000..bc3ca53 --- /dev/null +++ b/src/render/TextureQuality.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "math/Vec3.hpp" + +#include +#include +#include +#include + +namespace Caffeine::Render { + +struct TextureQualitySettings { + bool enabled = true; + f32 fullRadius = 35.0f; // full resolution within this distance (meters) + f32 falloffDistance = 100.0f; // linear decay over this range beyond fullRadius + f32 minScale = 0.25f; // minimum resolution scale at far edge (0.25 = 1/4 res) +}; + +/// 0 = full, 1 = half, 2 = quarter, 3 = eighth (min 16px). +inline u32 kTextureQualityMaxTier = 3; + +inline f32 textureQualityScale(f32 distance, const TextureQualitySettings& settings) { + if (!settings.enabled) return 1.0f; + if (distance <= settings.fullRadius) return 1.0f; + if (settings.falloffDistance <= 1e-4f) { + return std::clamp(settings.minScale, 0.0625f, 1.0f); + } + const f32 t = + std::clamp((distance - settings.fullRadius) / settings.falloffDistance, 0.0f, 1.0f); + const f32 minScale = std::clamp(settings.minScale, 0.0625f, 1.0f); + return 1.0f - t * (1.0f - minScale); +} + +inline u32 textureQualityTier(f32 distance, const TextureQualitySettings& settings) { + const f32 scale = textureQualityScale(distance, settings); + if (scale >= 0.75f) return 0; + if (scale >= 0.375f) return 1; + if (scale >= 0.1875f) return 2; + return kTextureQualityMaxTier; +} + +inline f32 distanceToNearestViewer(const Vec3& worldPos, const std::vector& viewers) { + if (viewers.empty()) return 0.0f; + f32 best = 1e30f; + for (const Vec3& viewer : viewers) { + best = std::min(best, (worldPos - viewer).length()); + } + return best; +} + +inline std::string textureCacheKeyForTier(const std::string& baseKey, u32 tier) { + if (tier == 0) return baseKey; + return baseKey + "#q" + std::to_string(tier); +} + +inline u32 textureDimensionForTier(u32 fullDim, u32 tier) { + u32 dim = std::max(1u, fullDim); + for (u32 i = 0; i < tier; ++i) { + dim = std::max(16u, dim / 2); + } + return dim; +} + +} // namespace Caffeine::Render diff --git a/src/render/shaders/scene_lit.frag b/src/render/shaders/scene_lit.frag index 491161e..fce24d9 100644 --- a/src/render/shaders/scene_lit.frag +++ b/src/render/shaders/scene_lit.frag @@ -3,6 +3,8 @@ layout(location = 0) in vec3 v_worldPos; layout(location = 1) in vec3 v_normal; layout(location = 2) in vec2 v_texCoord; +layout(location = 3) in vec3 v_tangent; +layout(location = 4) in vec3 v_bitangent; layout(set = 3, binding = 0) uniform LightingUBO { vec4 uCameraPos; @@ -10,20 +12,125 @@ layout(set = 3, binding = 0) uniform LightingUBO { vec4 uAlbedo; float uMetallic; float uRoughness; + float uShininess; + vec4 uFlags; // x=receiveShadows, y=useNormalMap int uDirCount; int uPointCount; + int uSpotCount; + int uAlignPad; vec4 uDirData[4]; vec4 uDirColor[4]; vec4 uPointData[4]; vec4 uPointColor[4]; + vec4 uSpotData[4]; + vec4 uSpotDir[4]; + vec4 uSpotColor[4]; + vec4 uSpotAngle[4]; } lights; +layout(set = 3, binding = 1) uniform ShadowUBO { + mat4 uDirShadowVP[8]; + vec4 uDirCascadeSplits[2]; + vec4 uDirShadowValid; + mat4 uCameraView; + vec4 uPointShadowPos[2]; + vec4 uPointShadowRadius; + vec4 uPointShadowValid; + mat4 uSpotShadowVP[2]; + vec4 uSpotShadowPos[2]; + vec4 uSpotShadowParams[2]; + vec4 uSpotShadowValid; +} shadows; + layout(set = 2, binding = 0) uniform sampler2D uAlbedoMap; +layout(set = 2, binding = 1) uniform sampler2D uNormalMap; +layout(set = 2, binding = 2) uniform sampler2D uShadowMap0; +layout(set = 2, binding = 3) uniform sampler2D uShadowMap1; +layout(set = 2, binding = 4) uniform samplerCube uPointShadow0; +layout(set = 2, binding = 5) uniform samplerCube uPointShadow1; +layout(set = 2, binding = 6) uniform sampler2D uSpotShadow0; +layout(set = 2, binding = 7) uniform sampler2D uSpotShadow1; layout(location = 0) out vec4 outColor; +float sampleShadowMapAtlas(sampler2D shadowMap, mat4 lightVP, vec3 worldPos, int cascade, + int cascadeCount) { + vec4 clip = lightVP * vec4(worldPos, 1.0); + if (clip.w <= 1e-5) return 1.0; + vec3 ndc = clip.xyz / clip.w; + if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0 || ndc.z < -1.0 || ndc.z > 1.0) { + return 1.0; + } + + vec2 uv = vec2(ndc.x * 0.5 + 0.5, 1.0 - (ndc.y * 0.5 + 0.5)); + if (cascadeCount > 1) { + vec2 offset = vec2(float(cascade % 2) * 0.5, float(cascade / 2) * 0.5); + uv = uv * 0.5 + offset; + } + + const float bias = 0.005; + float depth = ndc.z; + float shadow = 0.0; + vec2 atlasSize = vec2(textureSize(shadowMap, 0)); + vec2 texel = vec2(0.5 / atlasSize.x, 0.5 / atlasSize.y); + if (cascadeCount > 1) { + texel *= 0.5; + } + for (int y = -1; y <= 1; ++y) { + for (int x = -1; x <= 1; ++x) { + vec2 offset = vec2(float(x), float(y)) * texel; + float stored = texture(shadowMap, uv + offset).r; + shadow += (depth - bias > stored) ? 0.0 : 1.0; + } + } + return shadow / 9.0; +} + +int selectDirCascade(float viewDepth, vec4 splitData) { + int count = int(splitData.w); + if (count <= 1) return 0; + if (viewDepth < splitData.x) return 0; + if (viewDepth < splitData.y) return 1; + if (viewDepth < splitData.z) return 2; + return 3; +} + +float sampleDirShadow(sampler2D shadowMap, int lightIndex, vec3 worldPos) { + vec4 splitData = shadows.uDirCascadeSplits[lightIndex]; + float viewDepth = (shadows.uCameraView * vec4(worldPos, 1.0)).z; + if (viewDepth < 0.0) viewDepth = -viewDepth; + int cascade = selectDirCascade(viewDepth, splitData); + mat4 lightVP = shadows.uDirShadowVP[lightIndex * 4 + cascade]; + return sampleShadowMapAtlas(shadowMap, lightVP, worldPos, cascade, int(splitData.w)); +} + +float samplePointShadow(samplerCube shadowCube, vec3 worldPos, vec3 lightPos, float radius) { + vec3 toFrag = worldPos - lightPos; + float dist = length(toFrag); + if (dist > radius) return 1.0; + float stored = texture(shadowCube, toFrag).r; + const float bias = 0.05; + return (dist - bias > stored) ? 0.0 : 1.0; +} + +float sampleSpotShadow(sampler2D shadowMap, mat4 lightVP, vec3 worldPos) { + return sampleShadowMapAtlas(shadowMap, lightVP, worldPos, 0, 1); +} + +vec3 surfaceNormal() { + vec3 N = normalize(v_normal); + if (lights.uFlags.y < 0.5) return N; + + vec3 T = normalize(v_tangent); + vec3 B = normalize(v_bitangent); + vec3 map = texture(uNormalMap, v_texCoord).xyz * 2.0 - 1.0; + mat3 tbn = mat3(T, B, N); + return normalize(tbn * map); +} + void main() { - vec3 n = normalize(v_normal); + vec3 n = surfaceNormal(); + vec3 viewDir = normalize(lights.uCameraPos.xyz - v_worldPos); vec3 surfaceAlbedo = texture(uAlbedoMap, v_texCoord).rgb * lights.uAlbedo.rgb; vec3 ambient = lights.uAmbient.rgb * surfaceAlbedo; vec3 color = ambient; @@ -31,8 +138,24 @@ void main() { for (int i = 0; i < lights.uDirCount; ++i) { vec3 dir = normalize(-lights.uDirData[i].xyz); float ndotl = max(dot(n, dir), 0.0); + vec3 diffuse = lights.uDirColor[i].rgb * lights.uDirData[i].w * ndotl; - color += diffuse * surfaceAlbedo; + + vec3 halfVec = normalize(dir + viewDir); + float ndoth = max(dot(n, halfVec), 0.0); + float spec = pow(ndoth, max(lights.uShininess, 1.0)); + vec3 specular = lights.uDirColor[i].rgb * spec * (1.0 - lights.uRoughness); + + float shadow = 1.0; + if (lights.uFlags.x > 0.5 && lights.uDirData[i].w > 0.0) { + if (i == 0 && shadows.uDirShadowValid.x > 0.5) { + shadow = sampleDirShadow(uShadowMap0, 0, v_worldPos); + } else if (i == 1 && shadows.uDirShadowValid.y > 0.5) { + shadow = sampleDirShadow(uShadowMap1, 1, v_worldPos); + } + } + + color += (diffuse * surfaceAlbedo + specular) * shadow; } for (int i = 0; i < lights.uPointCount; ++i) { @@ -44,8 +167,61 @@ void main() { vec3 ldir = toLight / dist; float atten = 1.0 - smoothstep(radius * 0.75, radius, dist); float ndotl = max(dot(n, ldir), 0.0); - vec3 diffuse = lights.uPointColor[i].rgb * lights.uPointData[i].w * ndotl * atten; - color += diffuse * surfaceAlbedo; + vec3 diffuse = lights.uPointColor[i].rgb * lights.uPointColor[i].a * ndotl * atten; + + vec3 halfVec = normalize(ldir + viewDir); + float ndoth = max(dot(n, halfVec), 0.0); + float spec = pow(ndoth, max(lights.uShininess, 1.0)); + vec3 specular = lights.uPointColor[i].rgb * spec * (1.0 - lights.uRoughness); + + float shadow = 1.0; + if (lights.uFlags.x > 0.5) { + if (i == 0 && shadows.uPointShadowValid.x > 0.5) { + shadow = samplePointShadow(uPointShadow0, v_worldPos, + shadows.uPointShadowPos[0].xyz, shadows.uPointShadowRadius.x); + } else if (i == 1 && shadows.uPointShadowValid.y > 0.5) { + shadow = samplePointShadow(uPointShadow1, v_worldPos, + shadows.uPointShadowPos[1].xyz, shadows.uPointShadowRadius.y); + } + } + + color += (diffuse * surfaceAlbedo + specular) * shadow; + } + + for (int i = 0; i < lights.uSpotCount; ++i) { + vec3 toPoint = v_worldPos - lights.uSpotData[i].xyz; + float dist = length(toPoint); + float radius = max(lights.uSpotData[i].w, 0.001); + if (dist > radius) continue; + + vec3 fromLight = toPoint / dist; + vec3 spotDir = normalize(lights.uSpotDir[i].xyz); + float cone = dot(spotDir, fromLight); + float cosHalfAngle = lights.uSpotAngle[i].x; + if (cone < cosHalfAngle) continue; + + float spotAtten = (cone - cosHalfAngle) / max(0.0001, 1.0 - cosHalfAngle); + float rangeAtten = 1.0 - (dist / radius); + vec3 ldir = -fromLight; + float ndotl = max(dot(n, ldir), 0.0); + vec3 diffuse = lights.uSpotColor[i].rgb * lights.uSpotDir[i].w * ndotl * spotAtten * + rangeAtten * rangeAtten; + + vec3 halfVec = normalize(ldir + viewDir); + float ndoth = max(dot(n, halfVec), 0.0); + float spec = pow(ndoth, max(lights.uShininess, 1.0)); + vec3 specular = lights.uSpotColor[i].rgb * spec * (1.0 - lights.uRoughness); + + float shadow = 1.0; + if (lights.uFlags.x > 0.5) { + if (i == 0 && shadows.uSpotShadowValid.x > 0.5) { + shadow = sampleSpotShadow(uSpotShadow0, shadows.uSpotShadowVP[0], v_worldPos); + } else if (i == 1 && shadows.uSpotShadowValid.y > 0.5) { + shadow = sampleSpotShadow(uSpotShadow1, shadows.uSpotShadowVP[1], v_worldPos); + } + } + + color += (diffuse * surfaceAlbedo + specular) * shadow; } outColor = vec4(color, 1.0); diff --git a/src/render/shaders/scene_lit.vert b/src/render/shaders/scene_lit.vert index 64dc139..d8c0421 100644 --- a/src/render/shaders/scene_lit.vert +++ b/src/render/shaders/scene_lit.vert @@ -13,11 +13,20 @@ layout(set = 1, binding = 0) uniform VertexUBO { layout(location = 0) out vec3 v_worldPos; layout(location = 1) out vec3 v_normal; layout(location = 2) out vec2 v_texCoord; +layout(location = 3) out vec3 v_tangent; +layout(location = 4) out vec3 v_bitangent; void main() { vec4 worldPos = ubo.uModel * vec4(aPosition, 1.0); v_worldPos = worldPos.xyz; - v_normal = normalize(mat3(ubo.uModel) * aNormal); + + vec3 N = normalize(mat3(ubo.uModel) * aNormal); + vec3 T = normalize(mat3(ubo.uModel) * aTangent.xyz); + vec3 B = cross(N, T) * aTangent.w; + + v_normal = N; + v_tangent = T; + v_bitangent = B; v_texCoord = aTexCoord; gl_Position = ubo.uMVP * vec4(aPosition, 1.0); } diff --git a/src/render/shaders/terrain_lit.frag b/src/render/shaders/terrain_lit.frag index 6850fcd..5351b7b 100644 --- a/src/render/shaders/terrain_lit.frag +++ b/src/render/shaders/terrain_lit.frag @@ -3,6 +3,8 @@ layout(location = 0) in vec3 v_worldPos; layout(location = 1) in vec3 v_normal; layout(location = 2) in vec2 v_texCoord; +layout(location = 3) in vec3 v_tangent; +layout(location = 4) in vec3 v_bitangent; layout(set = 3, binding = 0) uniform LightingUBO { vec4 uCameraPos; @@ -10,15 +12,37 @@ layout(set = 3, binding = 0) uniform LightingUBO { vec4 uAlbedo; float uMetallic; float uRoughness; + float uShininess; + vec4 uFlags; // x=receiveShadows, y=useNormalMap int uDirCount; int uPointCount; + int uSpotCount; + int uAlignPad; vec4 uDirData[4]; vec4 uDirColor[4]; vec4 uPointData[4]; vec4 uPointColor[4]; + vec4 uSpotData[4]; + vec4 uSpotDir[4]; + vec4 uSpotColor[4]; + vec4 uSpotAngle[4]; } lights; -layout(set = 3, binding = 1) uniform TerrainMaterialUBO { +layout(set = 3, binding = 1) uniform ShadowUBO { + mat4 uDirShadowVP[8]; + vec4 uDirCascadeSplits[2]; + vec4 uDirShadowValid; + mat4 uCameraView; + vec4 uPointShadowPos[2]; + vec4 uPointShadowRadius; + vec4 uPointShadowValid; + mat4 uSpotShadowVP[2]; + vec4 uSpotShadowPos[2]; + vec4 uSpotShadowParams[2]; + vec4 uSpotShadowValid; +} shadows; + +layout(set = 3, binding = 2) uniform TerrainMaterialUBO { vec4 uWorldSize; vec4 uFlags; mat4 uModelInv; @@ -29,9 +53,75 @@ layout(set = 2, binding = 1) uniform sampler2D uLayer0; layout(set = 2, binding = 2) uniform sampler2D uLayer1; layout(set = 2, binding = 3) uniform sampler2D uLayer2; layout(set = 2, binding = 4) uniform sampler2D uLayer3; +layout(set = 2, binding = 5) uniform sampler2D uNormalMap; +layout(set = 2, binding = 6) uniform sampler2D uShadowMap0; +layout(set = 2, binding = 7) uniform sampler2D uShadowMap1; +layout(set = 2, binding = 8) uniform samplerCube uPointShadow0; +layout(set = 2, binding = 9) uniform samplerCube uPointShadow1; +layout(set = 2, binding = 10) uniform sampler2D uSpotShadow0; +layout(set = 2, binding = 11) uniform sampler2D uSpotShadow1; layout(location = 0) out vec4 outColor; +float sampleShadowMapAtlas(sampler2D shadowMap, mat4 lightVP, vec3 worldPos, int cascade, + int cascadeCount) { + vec4 clip = lightVP * vec4(worldPos, 1.0); + if (clip.w <= 1e-5) return 1.0; + vec3 ndc = clip.xyz / clip.w; + if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0 || ndc.z < -1.0 || ndc.z > 1.0) return 1.0; + vec2 uv = vec2(ndc.x * 0.5 + 0.5, 1.0 - (ndc.y * 0.5 + 0.5)); + if (cascadeCount > 1) { + vec2 offset = vec2(float(cascade % 2) * 0.5, float(cascade / 2) * 0.5); + uv = uv * 0.5 + offset; + } + const float bias = 0.005; + float depth = ndc.z; + float shadow = 0.0; + vec2 atlasSize = vec2(textureSize(shadowMap, 0)); + vec2 texel = vec2(0.5 / atlasSize.x, 0.5 / atlasSize.y); + if (cascadeCount > 1) { + texel *= 0.5; + } + for (int y = -1; y <= 1; ++y) { + for (int x = -1; x <= 1; ++x) { + vec2 offset = vec2(float(x), float(y)) * texel; + float stored = texture(shadowMap, uv + offset).r; + shadow += (depth - bias > stored) ? 0.0 : 1.0; + } + } + return shadow / 9.0; +} + +int selectDirCascade(float viewDepth, vec4 splitData) { + int count = int(splitData.w); + if (count <= 1) return 0; + if (viewDepth < splitData.x) return 0; + if (viewDepth < splitData.y) return 1; + if (viewDepth < splitData.z) return 2; + return 3; +} + +float sampleDirShadow(sampler2D shadowMap, int lightIndex, vec3 worldPos) { + vec4 splitData = shadows.uDirCascadeSplits[lightIndex]; + float viewDepth = (shadows.uCameraView * vec4(worldPos, 1.0)).z; + if (viewDepth < 0.0) viewDepth = -viewDepth; + int cascade = selectDirCascade(viewDepth, splitData); + mat4 lightVP = shadows.uDirShadowVP[lightIndex * 4 + cascade]; + return sampleShadowMapAtlas(shadowMap, lightVP, worldPos, cascade, int(splitData.w)); +} + +float sampleSpotShadow(sampler2D shadowMap, mat4 lightVP, vec3 worldPos) { + return sampleShadowMapAtlas(shadowMap, lightVP, worldPos, 0, 1); +} + +float samplePointShadow(samplerCube shadowCube, vec3 worldPos, vec3 lightPos, float radius) { + vec3 toFrag = worldPos - lightPos; + float dist = length(toFrag); + if (dist > radius) return 1.0; + float stored = texture(shadowCube, toFrag).r; + return (dist - 0.05 > stored) ? 0.0 : 1.0; +} + vec3 triplanarSample(sampler2D tex, vec3 localPos, vec3 blend, float tileScale) { vec3 scaled = localPos / max(tileScale, 0.1); vec3 xProj = texture(tex, scaled.yz).rgb; @@ -70,17 +160,40 @@ vec3 sampleTerrainAlbedo(vec3 n) { return applyDetailVariation(triplanarSample(uLayer3, local, blend, tileScale), local); } +vec3 sampleTerrainNormal(vec3 n) { + if (lights.uFlags.y < 0.5) return normalize(n); + const float tileScale = max(terrainMat.uWorldSize.z, 0.1); + vec3 blend = abs(normalize(n)); + blend = max(blend, vec3(0.0001)); + blend /= (blend.x + blend.y + blend.z); + vec3 local = (terrainMat.uModelInv * vec4(v_worldPos, 1.0)).xyz; + vec3 map = triplanarSample(uNormalMap, local, blend, tileScale) * 2.0 - 1.0; + vec3 T = normalize(v_tangent); + vec3 B = normalize(v_bitangent); + mat3 tbn = mat3(T, B, normalize(n)); + return normalize(tbn * map); +} + void main() { - vec3 n = normalize(v_normal); - vec3 surfaceAlbedo = sampleTerrainAlbedo(n); - vec3 ambient = lights.uAmbient.rgb * surfaceAlbedo; - vec3 color = ambient; + vec3 n = sampleTerrainNormal(v_normal); + vec3 surfaceAlbedo = sampleTerrainAlbedo(n) * lights.uAlbedo.rgb; + vec3 color = lights.uAmbient.rgb * surfaceAlbedo; + const float kDiffuseScale = 0.65; for (int i = 0; i < lights.uDirCount; ++i) { vec3 dir = normalize(-lights.uDirData[i].xyz); float ndotl = max(dot(n, dir), 0.0); - vec3 diffuse = lights.uDirColor[i].rgb * lights.uDirData[i].w * ndotl; - color += diffuse * surfaceAlbedo; + vec3 diffuse = lights.uDirColor[i].rgb * lights.uDirData[i].w * ndotl * kDiffuseScale; + + float shadow = 1.0; + if (lights.uFlags.x > 0.5) { + if (i == 0 && shadows.uDirShadowValid.x > 0.5) { + shadow = sampleDirShadow(uShadowMap0, 0, v_worldPos); + } else if (i == 1 && shadows.uDirShadowValid.y > 0.5) { + shadow = sampleDirShadow(uShadowMap1, 1, v_worldPos); + } + } + color += diffuse * surfaceAlbedo * shadow; } for (int i = 0; i < lights.uPointCount; ++i) { @@ -88,13 +201,51 @@ void main() { float dist = length(toLight); float radius = max(lights.uPointData[i].w, 0.001); if (dist > radius) continue; - vec3 ldir = toLight / dist; float atten = 1.0 - smoothstep(radius * 0.75, radius, dist); float ndotl = max(dot(n, ldir), 0.0); - vec3 diffuse = lights.uPointColor[i].rgb * lights.uPointData[i].w * ndotl * atten; - color += diffuse * surfaceAlbedo; + vec3 diffuse = lights.uPointColor[i].rgb * lights.uPointColor[i].a * ndotl * atten * + kDiffuseScale; + + float shadow = 1.0; + if (lights.uFlags.x > 0.5) { + if (i == 0 && shadows.uPointShadowValid.x > 0.5) { + shadow = samplePointShadow(uPointShadow0, v_worldPos, + shadows.uPointShadowPos[0].xyz, shadows.uPointShadowRadius.x); + } else if (i == 1 && shadows.uPointShadowValid.y > 0.5) { + shadow = samplePointShadow(uPointShadow1, v_worldPos, + shadows.uPointShadowPos[1].xyz, shadows.uPointShadowRadius.y); + } + } + color += diffuse * surfaceAlbedo * shadow; + } + + for (int i = 0; i < lights.uSpotCount; ++i) { + vec3 toPoint = v_worldPos - lights.uSpotData[i].xyz; + float dist = length(toPoint); + float radius = max(lights.uSpotData[i].w, 0.001); + if (dist > radius) continue; + vec3 fromLight = toPoint / dist; + vec3 spotDir = normalize(lights.uSpotDir[i].xyz); + float cone = dot(spotDir, fromLight); + float cosHalfAngle = lights.uSpotAngle[i].x; + if (cone < cosHalfAngle) continue; + float spotAtten = (cone - cosHalfAngle) / max(0.0001, 1.0 - cosHalfAngle); + float rangeAtten = 1.0 - (dist / radius); + vec3 ldir = -fromLight; + float ndotl = max(dot(n, ldir), 0.0); + vec3 diffuse = lights.uSpotColor[i].rgb * lights.uSpotDir[i].w * ndotl * spotAtten * + rangeAtten * rangeAtten * kDiffuseScale; + float shadow = 1.0; + if (lights.uFlags.x > 0.5) { + if (i == 0 && shadows.uSpotShadowValid.x > 0.5) { + shadow = sampleSpotShadow(uSpotShadow0, shadows.uSpotShadowVP[0], v_worldPos); + } else if (i == 1 && shadows.uSpotShadowValid.y > 0.5) { + shadow = sampleSpotShadow(uSpotShadow1, shadows.uSpotShadowVP[1], v_worldPos); + } + } + color += diffuse * surfaceAlbedo * shadow; } - outColor = vec4(color, 1.0); + outColor = vec4(clamp(color, 0.0, 1.0), 1.0); } diff --git a/src/rhi/RenderDevice.cpp b/src/rhi/RenderDevice.cpp index 10546c4..f549f1f 100644 --- a/src/rhi/RenderDevice.cpp +++ b/src/rhi/RenderDevice.cpp @@ -313,7 +313,7 @@ Pipeline* RenderDevice::createGraphicsPipeline(Shader* vertexShader, Shader* fra vertexInput.num_vertex_attributes = attrCount; SDL_GPURasterizerState rasterizer{}; - rasterizer.fill_mode = SDL_GPU_FILLMODE_FILL; + rasterizer.fill_mode = static_cast(desc.fillMode); rasterizer.cull_mode = SDL_GPU_CULLMODE_NONE; rasterizer.front_face = SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE; rasterizer.enable_depth_bias = false; @@ -477,7 +477,7 @@ bool RenderDevice::uploadBuffer(Buffer* buffer, const void* data, u64 size, u64 } bool RenderDevice::uploadTexture(Texture* texture, const void* pixels, u32 width, u32 height, - u32 bytesPerPixel) { + u32 bytesPerPixel, u32 mipLevel) { if (!m_device || !texture || !texture->handle || !pixels || width < 1 || height < 1) { return false; } @@ -530,7 +530,7 @@ bool RenderDevice::uploadTexture(Texture* texture, const void* pixels, u32 width SDL_GPUTextureRegion dst{}; dst.texture = texture->handle; - dst.mip_level = 0; + dst.mip_level = mipLevel; dst.layer = 0; dst.x = 0; dst.y = 0; diff --git a/src/rhi/RenderDevice.hpp b/src/rhi/RenderDevice.hpp index 3acbc19..9283126 100644 --- a/src/rhi/RenderDevice.hpp +++ b/src/rhi/RenderDevice.hpp @@ -70,6 +70,11 @@ enum class ShaderStage : u8 { Fragment = SDL_GPU_SHADERSTAGE_FRAGMENT }; +enum class FillMode : u8 { + Fill = SDL_GPU_FILLMODE_FILL, + Line = SDL_GPU_FILLMODE_LINE, +}; + enum class ShaderBytecodeFormat : u8 { SPIRV, DXBC, @@ -106,6 +111,7 @@ struct GraphicsPipelineDesc { bool depthTest = true; bool depthWrite = true; bool enableBlend = false; + FillMode fillMode = FillMode::Fill; }; struct SamplerDesc { @@ -223,7 +229,7 @@ class RenderDevice { bool uploadBuffer(Buffer* buffer, const void* data, u64 size, u64 offset = 0); bool uploadTexture(Texture* texture, const void* pixels, u32 width, u32 height, - u32 bytesPerPixel = 4); + u32 bytesPerPixel = 4, u32 mipLevel = 0); void destroyTexture(Texture* tex); void destroyShader(Shader* shader); diff --git a/src/runtime/RuntimeSceneRenderer.cpp b/src/runtime/RuntimeSceneRenderer.cpp index fdafe18..d01a8b5 100644 --- a/src/runtime/RuntimeSceneRenderer.cpp +++ b/src/runtime/RuntimeSceneRenderer.cpp @@ -12,8 +12,12 @@ #include "render/GpuProceduralMeshes.hpp" #include "scene/HierarchySystem.hpp" #include "scene/SceneComponents.hpp" -#include "scene/CpuDirectionalShadowMap.hpp" #include "scene/LightingSystem.hpp" +#include "scene/TerrainSystem.hpp" +#include "scene/EnvironmentSystem.hpp" +#ifdef CF_HAS_SDL3 +#include +#endif #include #include @@ -342,11 +346,75 @@ void blitRasterizer(ImDrawList* dl, ImVec2 origin, ImVec2 panelSize, MeshCpuRast } // namespace +#ifdef CF_HAS_SDL3 +bool RuntimeSceneRenderer::init(RHI::RenderDevice* device) { + if (!device || !device->isInitialized()) return false; + shutdown(); + m_device = device; + m_gpuReady = m_gpuRenderer.init(device); + return m_gpuReady; +} + +void RuntimeSceneRenderer::shutdown() { + if (m_device) { + if (m_colorTarget) { + m_device->destroyTexture(m_colorTarget); + m_colorTarget = nullptr; + } + if (m_depthTarget) { + m_device->destroyTexture(m_depthTarget); + m_depthTarget = nullptr; + } + } + m_gpuRenderer.shutdown(); + m_skyboxRenderer.releaseGpuTextures(); + m_device = nullptr; + m_frameCmd = nullptr; + m_canvasW = 0; + m_canvasH = 0; + m_gpuReady = false; +} + +void RuntimeSceneRenderer::resizeCanvas(u32 width, u32 height) { + if (!m_device || width < 1 || height < 1) return; + if (m_canvasW == width && m_canvasH == height && m_colorTarget && m_depthTarget) return; + + if (m_colorTarget) { + m_device->destroyTexture(m_colorTarget); + m_colorTarget = nullptr; + } + if (m_depthTarget) { + m_device->destroyTexture(m_depthTarget); + m_depthTarget = nullptr; + } + + RHI::TextureDesc colorDesc; + colorDesc.width = width; + colorDesc.height = height; + colorDesc.format = RHI::TextureFormat::R8G8B8A8_UNORM; + colorDesc.usage = RHI::TextureUsage::Sampler | RHI::TextureUsage::ColorTarget; + m_colorTarget = m_device->createTexture(colorDesc); + + RHI::TextureDesc depthDesc; + depthDesc.width = width; + depthDesc.height = height; + depthDesc.format = RHI::TextureFormat::D32_FLOAT; + depthDesc.usage = RHI::TextureUsage::DepthStencil; + m_depthTarget = m_device->createTexture(depthDesc); + + m_canvasW = width; + m_canvasH = height; +} +#endif + void RuntimeSceneRenderer::render(ECS::World& world, Editor::EditorContext& ctx, ImDrawList* dl, const Mat4& vp, const Vec3& camPos, ImVec2 origin, - ImVec2 panelSize, ECS::Entity skipEntity) { - std::string projectRoot = std::filesystem::current_path().string(); - { + ImVec2 panelSize, ECS::Entity skipEntity, + const std::string& projectRootIn, + const Render::GpuSceneCamera* gpuCamera) { + std::string projectRoot = projectRootIn; + if (projectRoot.empty()) { + projectRoot = std::filesystem::current_path().string(); std::filesystem::path probe = std::filesystem::current_path(); for (int i = 0; i < 5; ++i) { if (std::filesystem::exists(probe / "project.caffeine")) { @@ -358,8 +426,26 @@ void RuntimeSceneRenderer::render(ECS::World& world, Editor::EditorContext& ctx, } } +#ifdef CF_HAS_SDL3 + if (gpuCamera && m_gpuReady && m_frameCmd && m_device) { + Scene::syncTerrainMeshes(world); + const u32 w = static_cast(std::max(panelSize.x, 8.0f)); + const u32 h = static_cast(std::max(panelSize.y, 8.0f)); + resizeCanvas(w, h); + const u32 drawn = + m_gpuRenderer.renderWithCamera(m_frameCmd, world, *gpuCamera, m_colorTarget, + m_depthTarget, w, h, projectRoot); + if (drawn > 0 && m_colorTarget && m_colorTarget->handle) { + dl->AddImage(reinterpret_cast(m_colorTarget->handle), origin, + ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), ImVec2(0, 0), + ImVec2(1, 1)); + return; + } + } +#endif + Scene::SceneLighting sceneLighting; - Scene::gatherSceneLighting(world, ctx.camFocus, projectRoot, sceneLighting, skipEntity); + Scene::gatherSceneLighting(world, camPos, projectRoot, sceneLighting, skipEntity, true); auto lightColorAt = [&](const Vec3& p, const Vec3& n, bool receiveShadows) -> Vec3 { return Scene::evaluateDiffuseLighting(sceneLighting.lights, sceneLighting.shadows, p, n, diff --git a/src/runtime/RuntimeSceneRenderer.hpp b/src/runtime/RuntimeSceneRenderer.hpp index dd43336..cb83272 100644 --- a/src/runtime/RuntimeSceneRenderer.hpp +++ b/src/runtime/RuntimeSceneRenderer.hpp @@ -10,21 +10,51 @@ #ifdef CF_HAS_IMGUI #include #include +#include #include #include #endif +#ifdef CF_HAS_SDL3 +#include "render/GpuSceneRenderer.hpp" +#include "render/SkyboxRenderer.hpp" +#include "rhi/RenderDevice.hpp" +#include "rhi/CommandBuffer.hpp" +#endif + namespace Caffeine::Runtime { #ifdef CF_HAS_IMGUI class RuntimeSceneRenderer { public: +#ifdef CF_HAS_SDL3 + bool init(RHI::RenderDevice* device); + void shutdown(); + void setFrameCommandBuffer(RHI::CommandBuffer* cmd) { m_frameCmd = cmd; } +#endif + void render(ECS::World& world, Editor::EditorContext& ctx, ImDrawList* dl, const Mat4& vp, const Vec3& camPos, ImVec2 origin, ImVec2 panelSize, - ECS::Entity skipEntity = ECS::Entity::INVALID); + ECS::Entity skipEntity = ECS::Entity::INVALID, + const std::string& projectRoot = {}, + const Render::GpuSceneCamera* gpuCamera = nullptr); private: +#ifdef CF_HAS_SDL3 + void resizeCanvas(u32 width, u32 height); + + RHI::RenderDevice* m_device = nullptr; + RHI::CommandBuffer* m_frameCmd = nullptr; + RHI::Texture* m_colorTarget = nullptr; + RHI::Texture* m_depthTarget = nullptr; + u32 m_canvasW = 0; + u32 m_canvasH = 0; + Render::GpuSceneRenderer m_gpuRenderer; + Render::SkyboxRenderer m_skyboxRenderer; + bool m_gpuReady = false; +#endif + struct RasterTexture { std::unique_ptr texture; int width = 0; diff --git a/src/scene/Camera3DSystem.cpp b/src/scene/Camera3DSystem.cpp new file mode 100644 index 0000000..471c61c --- /dev/null +++ b/src/scene/Camera3DSystem.cpp @@ -0,0 +1,79 @@ +#include "scene/Camera3DSystem.hpp" + +#include "ecs/CameraComponents.hpp" +#include "ecs/Components3D.hpp" +#include "math/Math.hpp" +#include "math/Quat.hpp" +#include "render/Camera3D.hpp" + +namespace Caffeine::Scene { + +void updateCamera3DControllers(ECS::World& world, f64 dt, const Camera3DInput& input) { + const f32 delta = static_cast(dt); + + ECS::ComponentQuery query; + query.with().with().with(); + + world.forEach( + query, [&](ECS::Entity entity, ECS::Camera3DControllerComponent& ctrl, ECS::Position3D& pos, + ECS::Rotation3D& rot) { + if (ctrl.mode == ECS::Camera3DControlMode::None) return; + + const auto* active = world.get(entity); + if (active && active->is2D) return; + + Render::Camera3D camera; + if (const auto* cam3 = world.get(entity)) { + camera.setFOV(cam3->fov); + camera.setAspect(cam3->aspectRatio); + camera.setNearFar(cam3->nearClip, cam3->farClip); + } + camera.setPosition(pos.position); + camera.setRotation(Quat(rot.quaternion.x, rot.quaternion.y, rot.quaternion.z, + rot.quaternion.w)); + + switch (ctrl.mode) { + case ECS::Camera3DControlMode::Orbit: + camera.setOrbitTarget(ctrl.orbitTarget); + camera.setOrbitDistance(ctrl.orbitDistance); + if (input.mouseDeltaX != 0.0f || input.mouseDeltaY != 0.0f) { + camera.orbit(input.mouseDeltaX * ctrl.mouseSensitivity, + -input.mouseDeltaY * ctrl.mouseSensitivity); + } + if (input.zoomDelta != 0.0f) { + camera.zoom(input.zoomDelta); + } + ctrl.orbitDistance = (camera.position() - ctrl.orbitTarget).length(); + break; + + case ECS::Camera3DControlMode::FPS: + if (input.mouseDeltaX != 0.0f || input.mouseDeltaY != 0.0f) { + camera.rotateFPS(-input.mouseDeltaY * ctrl.mouseSensitivity, + input.mouseDeltaX * ctrl.mouseSensitivity); + } + if (input.moveX != 0.0f || input.moveY != 0.0f || input.moveZ != 0.0f) { + camera.moveFPS({input.moveX * ctrl.moveSpeed * delta, + input.moveY * ctrl.moveSpeed * delta, + input.moveZ * ctrl.moveSpeed * delta}); + } + break; + + case ECS::Camera3DControlMode::Follow: + if (ctrl.followTarget.isValid()) { + camera.follow(ctrl.followTarget, ctrl.followOffset, ctrl.followSmoothing); + camera.update(dt, world); + } + break; + + default: + break; + } + + pos.position = camera.position(); + const Quat q = camera.rotation(); + rot.quaternion = Vec4(q.x, q.y, q.z, q.w); + }); +} + + +} // namespace Caffeine::Scene diff --git a/src/scene/Camera3DSystem.hpp b/src/scene/Camera3DSystem.hpp new file mode 100644 index 0000000..947306a --- /dev/null +++ b/src/scene/Camera3DSystem.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "core/Types.hpp" +#include "ecs/World.hpp" + +namespace Caffeine::Scene { + +/// Per-frame input for Camera3DControllerComponent (editor or runtime). +struct Camera3DInput { + f32 mouseDeltaX = 0.0f; + f32 mouseDeltaY = 0.0f; + f32 moveX = 0.0f; + f32 moveY = 0.0f; + f32 moveZ = 0.0f; + f32 zoomDelta = 0.0f; +}; + +/// Updates entities with Camera3DControllerComponent + Position3D + Rotation3D. +/// Active camera (CameraActiveComponent, is2D=false) is updated when present. +void updateCamera3DControllers(ECS::World& world, f64 dt, const Camera3DInput& input = {}); + +} // namespace Caffeine::Scene diff --git a/src/scene/CpuDirectionalShadowMap.cpp b/src/scene/CpuDirectionalShadowMap.cpp index 4a9987f..b4376e9 100644 --- a/src/scene/CpuDirectionalShadowMap.cpp +++ b/src/scene/CpuDirectionalShadowMap.cpp @@ -384,9 +384,18 @@ void rasterizeSceneShadowCasters(ECS::World& world, const Mat4& lightVP, int res rasterizeSphere(lightVP, resolution, depth, worldMatrix); break; case ECS::MeshPrimitive::Cylinder: + rasterizeCylinder(lightVP, resolution, depth, worldMatrix); + break; case ECS::MeshPrimitive::Capsule: + rasterizeSphere(lightVP, resolution, depth, worldMatrix); + break; + case ECS::MeshPrimitive::Cone: + case ECS::MeshPrimitive::Pyramid: rasterizeCylinder(lightVP, resolution, depth, worldMatrix); break; + case ECS::MeshPrimitive::Torus: + rasterizeSphere(lightVP, resolution, depth, worldMatrix); + break; case ECS::MeshPrimitive::Custom: if (meshFilter.customMeshPath.empty()) return; if (Assets::Mesh3D* loadedMesh = diff --git a/src/scene/LightingSystem.cpp b/src/scene/LightingSystem.cpp index 7d3ac54..e8a8543 100644 --- a/src/scene/LightingSystem.cpp +++ b/src/scene/LightingSystem.cpp @@ -2,6 +2,7 @@ #include "ecs/ComponentQuery.hpp" #include "ecs/MeshComponents.hpp" +#include "ecs/Components3D.hpp" #include "math/Mat4.hpp" #include "math/Quat.hpp" #include "scene/HierarchySystem.hpp" @@ -72,6 +73,37 @@ Vec3 clampLighting(const Vec3& v, f32 minVal, f32 maxVal) { } // namespace +Vec3 defaultSunDirection() { + return Vec3(0.35f, -0.82f, 0.45f).normalized(); +} + +bool directionalLightHasExplicitAim(ECS::World& world, ECS::Entity entity) { + if (auto* r = world.get(entity)) { + if (std::abs(r->quaternion.x) > 1e-4f || std::abs(r->quaternion.y) > 1e-4f || + std::abs(r->quaternion.z) > 1e-4f || std::abs(r->quaternion.w - 1.0f) > 1e-4f) { + return true; + } + } + if (auto* t = world.get(entity)) { + if (t->rotation.lengthSquared() > 1e-6f) return true; + } + return false; +} + +void applyDefaultSunOrientation(ECS::World& world, ECS::Entity entity) { + const Vec3 sun = defaultSunDirection(); + const Quat q = Quat::lookAt(-1.0f * sun, Vec3(0.0f, 1.0f, 0.0f)); + ECS::Rotation3D* rotation = world.get(entity); + if (!rotation) rotation = &world.add(entity); + rotation->quaternion = Vec4(q.x, q.y, q.z, q.w); + + if (auto* t = world.get(entity)) { + const Vec3 euler = q.toEuler(); + constexpr f32 kRadToDeg = 180.0f / 3.14159265f; + t->rotation = Vec3(euler.x * kRadToDeg, euler.y * kRadToDeg, euler.z * kRadToDeg); + } +} + void LightingData::clear() { directionals.clear(); points.clear(); @@ -98,7 +130,10 @@ void collectSceneLights(ECS::World& world, LightingData& out) { world.forEach( q, [&](ECS::Entity e, ECS::LightComponent& lc, ECS::DirectionalLightComponent& dl) { if (isEffectivelyDisabled(world, e)) return; - const Vec3 dir = entityForward(world, e).normalized(); + Vec3 dir = entityForward(world, e).normalized(); + if (!directionalLightHasExplicitAim(world, e)) { + dir = defaultSunDirection(); + } out.directionals.push_back( {dir, lc.color, lc.intensity, dl.shadowDistance, dl.castShadows}); }); @@ -155,10 +190,12 @@ void buildSceneShadowMaps(ECS::World& world, const LightingData& lights, const V } void gatherSceneLighting(ECS::World& world, const Vec3& focus, const std::string& projectRoot, - SceneLighting& out, ECS::Entity skipEntity) { + SceneLighting& out, ECS::Entity skipEntity, bool buildCpuShadowMaps) { out.clear(); collectSceneLights(world, out.lights); - buildSceneShadowMaps(world, out.lights, focus, projectRoot, out.shadows, skipEntity); + if (buildCpuShadowMaps) { + buildSceneShadowMaps(world, out.lights, focus, projectRoot, out.shadows, skipEntity); + } } Vec3 evaluateDiffuseLighting(const LightingData& lights, const SceneShadowMaps& shadows, diff --git a/src/scene/LightingSystem.hpp b/src/scene/LightingSystem.hpp index dba3336..6e27dbf 100644 --- a/src/scene/LightingSystem.hpp +++ b/src/scene/LightingSystem.hpp @@ -63,12 +63,19 @@ struct SceneLighting { void collectSceneLights(ECS::World& world, LightingData& out); +/// Default key-light direction used when a directional light has identity rotation +/// (local -Z). Identity sun lights the horizon, not the floor. +Vec3 defaultSunDirection(); +bool directionalLightHasExplicitAim(ECS::World& world, ECS::Entity entity); +void applyDefaultSunOrientation(ECS::World& world, ECS::Entity entity); + void buildSceneShadowMaps(ECS::World& world, const LightingData& lights, const Vec3& focus, const std::string& projectRoot, SceneShadowMaps& out, ECS::Entity skipEntity = ECS::Entity::INVALID); void gatherSceneLighting(ECS::World& world, const Vec3& focus, const std::string& projectRoot, - SceneLighting& out, ECS::Entity skipEntity = ECS::Entity::INVALID); + SceneLighting& out, ECS::Entity skipEntity = ECS::Entity::INVALID, + bool buildCpuShadowMaps = true); Vec3 evaluateDiffuseLighting(const LightingData& lights, const SceneShadowMaps& shadows, const Vec3& worldPos, const Vec3& worldNormal, bool receiveShadows, diff --git a/src/terrain/TerrainCache.cpp b/src/terrain/TerrainCache.cpp index 43bfb51..9937afa 100644 --- a/src/terrain/TerrainCache.cpp +++ b/src/terrain/TerrainCache.cpp @@ -107,11 +107,31 @@ void TerrainCache::releaseMeshGpu(Assets::Mesh3D& mesh) { mesh.indexBuffer = nullptr; } } + +void TerrainCache::releaseChunkGpu(TerrainChunk& chunk) { + for (auto& lodMesh : chunk.lodMeshes) { + if (lodMesh) releaseMeshGpu(*lodMesh); + } +} + +void TerrainCache::releaseChunksGpu(std::vector& chunks) { + for (TerrainChunk& chunk : chunks) { + releaseChunkGpu(chunk); + } +} + +void TerrainCache::releaseEntryGpu(TerrainEntry& entry) { + releaseChunksGpu(entry.chunks); + if (entry.mesh) releaseMeshGpu(*entry.mesh); +} #endif void TerrainCache::rebuildMesh(ECS::Entity entity, TerrainEntry& entry, ECS::TerrainComponent& component) { entry.useChunks = component.useChunks; +#ifdef CF_HAS_SDL3 + releaseEntryGpu(entry); +#endif if (entry.useChunks) { TerrainLodSystem::rebuildChunks(entry.chunks, entry.heightmap, component); entry.chunkActiveLods.assign(entry.chunks.size(), 0); @@ -140,12 +160,24 @@ void TerrainCache::rebuildRegion(ECS::Entity entity, if (entry.heightmap.empty()) return; if (entry.useChunks && !entry.chunks.empty()) { +#ifdef CF_HAS_SDL3 + for (TerrainChunk& chunk : entry.chunks) { + const bool overlapsX = chunk.startVertexX <= maxVertexX && minVertexX <= chunk.endVertexX; + const bool overlapsZ = chunk.startVertexZ <= maxVertexZ && minVertexZ <= chunk.endVertexZ; + if (overlapsX && overlapsZ) { + releaseChunkGpu(chunk); + } + } +#endif TerrainLodSystem::rebuildChunksInRegion(entry.chunks, entry.heightmap, settings, minVertexX, minVertexZ, maxVertexX, maxVertexZ); return; } if (entry.mesh) { +#ifdef CF_HAS_SDL3 + releaseMeshGpu(*entry.mesh); +#endif *entry.mesh = TerrainMeshBuilder::build(entry.heightmap, settings); } } @@ -204,15 +236,21 @@ void TerrainCache::syncEntity(ECS::World& world, ECS::Entity entity) { } void TerrainCache::removeEntity(ECS::Entity entity) { + auto it = m_entries.find(entity.id()); + if (it == m_entries.end()) return; #ifdef CF_HAS_SDL3 - TerrainGpuTextureCache::instance().removeEntity(entity, nullptr); + releaseEntryGpu(it->second); + TerrainGpuTextureCache::instance().removeEntity(entity, m_gpuDevice); #endif - m_entries.erase(entity.id()); + m_entries.erase(it); } void TerrainCache::clear() { #ifdef CF_HAS_SDL3 - TerrainGpuTextureCache::instance().releaseAll(nullptr); + for (auto& [_, entry] : m_entries) { + releaseEntryGpu(entry); + } + TerrainGpuTextureCache::instance().releaseAll(m_gpuDevice); #endif m_entries.clear(); } @@ -352,15 +390,8 @@ void TerrainCache::releaseGpuResources(RHI::RenderDevice* device) { m_gpuDevice = device; TerrainGpuTextureCache::instance().releaseAll(device); - for (auto& pair : m_entries) { - if (pair.second.mesh) { - releaseMeshGpu(*pair.second.mesh); - } - for (auto& chunk : pair.second.chunks) { - for (auto& lodMesh : chunk.lodMeshes) { - if (lodMesh) releaseMeshGpu(*lodMesh); - } - } + for (auto& [_, entry] : m_entries) { + releaseEntryGpu(entry); } } #endif diff --git a/src/terrain/TerrainCache.hpp b/src/terrain/TerrainCache.hpp index 958a230..6b4ad91 100644 --- a/src/terrain/TerrainCache.hpp +++ b/src/terrain/TerrainCache.hpp @@ -89,6 +89,9 @@ class TerrainCache { void rebuildCollisionMesh(TerrainEntry& entry, const ECS::TerrainComponent& component); #ifdef CF_HAS_SDL3 void releaseMeshGpu(Assets::Mesh3D& mesh); + void releaseChunkGpu(TerrainChunk& chunk); + void releaseChunksGpu(std::vector& chunks); + void releaseEntryGpu(TerrainEntry& entry); #endif std::unordered_map m_entries; diff --git a/src/terrain/TerrainGpuTextures.cpp b/src/terrain/TerrainGpuTextures.cpp index 4c42542..02032e0 100644 --- a/src/terrain/TerrainGpuTextures.cpp +++ b/src/terrain/TerrainGpuTextures.cpp @@ -3,10 +3,9 @@ #ifdef CF_HAS_SDL3 #include "assets/MeshCache.hpp" +#include "render/GpuTextureCache.hpp" #include "terrain/TerrainSplatmap.hpp" -#include - #include #include #include @@ -15,10 +14,6 @@ namespace Caffeine::Terrain { namespace { -std::string resolveTextureFile(const std::string& path, const std::string& projectRoot) { - return Assets::MeshCache::resolveTexturePath(path, projectRoot); -} - RHI::Texture* createTexture2D(RHI::RenderDevice* device, u32 width, u32 height, RHI::TextureFormat format) { RHI::TextureDesc desc; @@ -29,6 +24,12 @@ RHI::Texture* createTexture2D(RHI::RenderDevice* device, u32 width, u32 height, return device->createTexture(desc); } +void releaseCachedPath(RHI::RenderDevice* device, RHI::Texture*& texture) { + if (!texture) return; + Render::GpuTextureCache::instance().release(device, texture); + texture = nullptr; +} + } // namespace TerrainGpuTextureCache& TerrainGpuTextureCache::instance() { @@ -37,64 +38,26 @@ TerrainGpuTextureCache& TerrainGpuTextureCache::instance() { } RHI::Texture* TerrainGpuTextureCache::ensureWhiteTexture(RHI::RenderDevice* device) { - if (m_whiteTexture) return m_whiteTexture; - m_whiteTexture = createTexture2D(device, 1, 1, RHI::TextureFormat::R8G8B8A8_UNORM); - if (!m_whiteTexture) return nullptr; - const u8 white[4] = {255, 255, 255, 255}; - device->uploadTexture(m_whiteTexture, white, 1, 1, 4); - return m_whiteTexture; + return Render::GpuTextureCache::instance().whiteTexture(device); } RHI::Texture* TerrainGpuTextureCache::loadTexture(RHI::RenderDevice* device, const std::string& path, const std::string& projectRoot) { - const std::string resolved = resolveTextureFile(path, projectRoot); - if (resolved.empty()) return nullptr; - - int width = 0; - int height = 0; - int channels = 0; - u8* pixels = stbi_load(resolved.c_str(), &width, &height, &channels, 4); - if (!pixels || width < 1 || height < 1) { - if (pixels) stbi_image_free(pixels); - return nullptr; - } - - RHI::Texture* texture = - createTexture2D(device, static_cast(width), static_cast(height), - RHI::TextureFormat::R8G8B8A8_UNORM); - if (!texture) { - stbi_image_free(pixels); - return nullptr; - } - - if (!device->uploadTexture(texture, pixels, static_cast(width), - static_cast(height), 4)) { - device->destroyTexture(texture); - stbi_image_free(pixels); - return nullptr; - } - - stbi_image_free(pixels); - return texture; + return Render::GpuTextureCache::instance().acquire(device, path, projectRoot); } void TerrainGpuTextureCache::releaseTextures(TerrainGpuTextures& gpu, RHI::RenderDevice* device) { - auto releaseOwned = [&](RHI::Texture*& texture) { - if (!texture) return; - // m_whiteTexture is shared across entities and bound at draw time as a fallback. - // Never destroy it here — releaseAll() owns its lifetime. - if (device && texture != m_whiteTexture) { - device->destroyTexture(texture); - } - texture = nullptr; - }; + if (device && gpu.splatMap) { + device->destroyTexture(gpu.splatMap); + gpu.splatMap = nullptr; + } - releaseOwned(gpu.splatMap); for (u32 i = 0; i < ECS::kTerrainSplatLayerCount; ++i) { - releaseOwned(gpu.layers[i]); + releaseCachedPath(device, gpu.layers[i]); } - releaseOwned(gpu.albedo); + releaseCachedPath(device, gpu.albedo); + releaseCachedPath(device, gpu.normalMap); } void TerrainGpuTextureCache::sync(RHI::RenderDevice* device, ECS::Entity entity, @@ -107,12 +70,10 @@ void TerrainGpuTextureCache::sync(RHI::RenderDevice* device, ECS::Entity entity, gpu.useSplatmap = terrain.useSplatmap; RHI::Texture* fallback = ensureWhiteTexture(device); - auto destroyOwned = [&](RHI::Texture*& texture) { + auto destroyOwned = [&](RHI::Texture*& texture, std::string& cachedPath) { if (!texture) return; - if (texture != m_whiteTexture) { - device->destroyTexture(texture); - } - texture = nullptr; + releaseCachedPath(device, texture); + cachedPath.clear(); }; if (terrain.useSplatmap && splatmap && !splatmap->empty()) { @@ -121,19 +82,20 @@ void TerrainGpuTextureCache::sync(RHI::RenderDevice* device, ECS::Entity entity, const std::string path = terrain.splatLayerPaths[i]; if (gpu.cachedLayerPaths[i] != path) { layerPathsChanged = true; + destroyOwned(gpu.layers[i], gpu.cachedLayerPaths[i]); gpu.cachedLayerPaths[i] = path; - destroyOwned(gpu.layers[i]); } if (!gpu.layers[i] || gpu.layers[i] == fallback) { - if (gpu.layers[i] == fallback) { - gpu.layers[i] = nullptr; - } + if (gpu.layers[i] == fallback) gpu.layers[i] = nullptr; gpu.layers[i] = loadTexture(device, path, projectRoot); } } if (layerPathsChanged || gpu.uploadedSplatRevision != terrain.splatRevision || !gpu.splatMap) { - destroyOwned(gpu.splatMap); + if (gpu.splatMap) { + device->destroyTexture(gpu.splatMap); + gpu.splatMap = nullptr; + } const u32 resX = splatmap->resolutionX(); const u32 resZ = splatmap->resolutionZ(); @@ -148,7 +110,7 @@ void TerrainGpuTextureCache::sync(RHI::RenderDevice* device, ECS::Entity entity, pixels[i * 4 + 2] = static_cast(std::clamp(w.z, 0.0f, 1.0f) * 255.0f); pixels[i * 4 + 3] = static_cast(std::clamp(w.w, 0.0f, 1.0f) * 255.0f); } - device->uploadTexture(gpu.splatMap, pixels.data(), resX, resZ, 4); + device->uploadTexture(gpu.splatMap, pixels.data(), resX, resZ, 4, 0); } gpu.uploadedSplatRevision = terrain.splatRevision; } @@ -162,16 +124,23 @@ void TerrainGpuTextureCache::sync(RHI::RenderDevice* device, ECS::Entity entity, const std::string albedoPath = terrain.texturePath; if (gpu.cachedAlbedoPath != albedoPath) { + destroyOwned(gpu.albedo, gpu.cachedAlbedoPath); gpu.cachedAlbedoPath = albedoPath; - destroyOwned(gpu.albedo); } if (!gpu.albedo || gpu.albedo == fallback) { - if (gpu.albedo == fallback) { - gpu.albedo = nullptr; - } + if (gpu.albedo == fallback) gpu.albedo = nullptr; gpu.albedo = loadTexture(device, albedoPath, projectRoot); } } + + const std::string normalPath = terrain.normalMapPath; + if (gpu.cachedNormalPath != normalPath) { + destroyOwned(gpu.normalMap, gpu.cachedNormalPath); + gpu.cachedNormalPath = normalPath; + } + if (!normalPath.empty() && !gpu.normalMap) { + gpu.normalMap = loadTexture(device, normalPath, projectRoot); + } } const TerrainGpuTextures* TerrainGpuTextureCache::get(ECS::Entity entity) const { @@ -182,14 +151,15 @@ const TerrainGpuTextures* TerrainGpuTextureCache::get(ECS::Entity entity) const bool TerrainGpuTextureCache::hasRenderableTextures(ECS::Entity entity) const { const TerrainGpuTextures* gpu = get(entity); if (!gpu) return false; + RHI::Texture* white = m_whiteTexture; if (gpu->useSplatmap) { if (!gpu->splatMap) return false; for (u32 i = 0; i < ECS::kTerrainSplatLayerCount; ++i) { - if (gpu->layers[i] && gpu->layers[i] != m_whiteTexture) return true; + if (gpu->layers[i] && gpu->layers[i] != white) return true; } return false; } - return gpu->albedo != nullptr && gpu->albedo != m_whiteTexture; + return gpu->albedo != nullptr && gpu->albedo != white; } void TerrainGpuTextureCache::invalidateEntity(ECS::Entity entity, RHI::RenderDevice* device) { @@ -200,22 +170,19 @@ void TerrainGpuTextureCache::invalidateEntity(ECS::Entity entity, RHI::RenderDev it->second.cachedLayerPaths[i].clear(); } it->second.cachedAlbedoPath.clear(); + it->second.cachedNormalPath.clear(); it->second.uploadedSplatRevision = 0; } RHI::Texture* TerrainGpuTextureCache::whiteTexture(RHI::RenderDevice* device) { - return ensureWhiteTexture(device); + m_whiteTexture = ensureWhiteTexture(device); + return m_whiteTexture; } RHI::Texture* TerrainGpuTextureCache::textureFromPath(RHI::RenderDevice* device, const std::string& path, const std::string& projectRoot) { - if (!device || path.empty()) return nullptr; - auto it = m_sharedTextures.find(path); - if (it != m_sharedTextures.end()) return it->second; - RHI::Texture* tex = loadTexture(device, path, projectRoot); - if (tex) m_sharedTextures[path] = tex; - return tex; + return loadTexture(device, path, projectRoot); } void TerrainGpuTextureCache::removeEntity(ECS::Entity entity, RHI::RenderDevice* device) { @@ -230,20 +197,7 @@ void TerrainGpuTextureCache::releaseAll(RHI::RenderDevice* device) { releaseTextures(gpu, device); } m_entries.clear(); - - for (auto& [_, tex] : m_sharedTextures) { - if (device && tex && tex != m_whiteTexture) { - device->destroyTexture(tex); - } - } - m_sharedTextures.clear(); - - if (m_whiteTexture) { - if (device) { - device->destroyTexture(m_whiteTexture); - } - m_whiteTexture = nullptr; - } + m_whiteTexture = nullptr; } } // namespace Caffeine::Terrain diff --git a/src/terrain/TerrainGpuTextures.hpp b/src/terrain/TerrainGpuTextures.hpp index 80a664f..b1d25d4 100644 --- a/src/terrain/TerrainGpuTextures.hpp +++ b/src/terrain/TerrainGpuTextures.hpp @@ -18,9 +18,11 @@ struct TerrainGpuTextures { RHI::Texture* splatMap = nullptr; RHI::Texture* layers[ECS::kTerrainSplatLayerCount] = {}; RHI::Texture* albedo = nullptr; + RHI::Texture* normalMap = nullptr; u32 uploadedSplatRevision = 0; std::string cachedLayerPaths[ECS::kTerrainSplatLayerCount]; std::string cachedAlbedoPath; + std::string cachedNormalPath; bool useSplatmap = false; }; @@ -48,7 +50,6 @@ class TerrainGpuTextureCache { void releaseTextures(TerrainGpuTextures& gpu, RHI::RenderDevice* device); std::unordered_map m_entries; - std::unordered_map m_sharedTextures; RHI::Texture* m_whiteTexture = nullptr; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 664a8a8..9d1fade 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,8 +31,10 @@ set(CAFFEINE_TEST_SOURCES ../src/editor/SceneSerializer.cpp ../src/editor/DragDropSystem.cpp ../src/editor/AssetBrowser.cpp + ../src/editor/AssetPreviewRenderer.cpp ../src/editor/SceneTabManager.cpp ../src/editor/AnimationTimeline.cpp + ../src/editor/AnimatorController.cpp ../src/editor/TilemapEditor.cpp ../src/editor/CommandPalette.cpp ../src/editor/BuildSystem.cpp @@ -42,8 +44,39 @@ set(CAFFEINE_TEST_SOURCES ../src/editor/CapLoader.cpp ../src/editor/AudioWaveformRenderer.cpp ../src/editor/ProjectManager.cpp + ../src/editor/ProjectStartupDialog.cpp + ../src/editor/FilePicker.cpp ../src/editor/SettingsPanel.cpp ../src/editor/LayoutManager.cpp + ../src/editor/EditorPaths.cpp + ../src/editor/EditorIcons.cpp + ../src/editor/EditorSpinner.cpp + ../src/editor/EditorPreferences.cpp + ../src/editor/EditorTheme.cpp + ../src/editor/EditorContext.cpp + ../src/editor/HierarchyPanel.cpp + ../src/editor/SceneViewport.cpp + ../src/editor/InspectorPanel.cpp + ../src/editor/ComponentRegistry.cpp + ../src/editor/SceneEditor.cpp + ../src/editor/TransformGizmo.cpp + ../src/editor/PrefabSystem.cpp + ../src/editor/MaterialSerializer.cpp + ../src/editor/EntityPresetTypes.cpp + ../src/editor/EntityPresetRegistry.cpp + ../src/editor/EntityPresetPackages.cpp + ../src/editor/EntityPresetFactories.cpp + ../src/editor/EntityPresetsPanel.cpp + ../src/editor/TerrainEditorPanel.cpp + ../src/editor/EntityDebugger.cpp + ../src/editor/PluginSystem.cpp + ../src/editor/PluginServiceRegistry.cpp + ../src/editor/TestUIMapper.cpp + ../src/editor/TestRequestHandler.cpp + ../src/editor/ScriptEditorWindow.cpp + ../src/editor/CameraPreviewPanel.cpp + ../src/editor/GameplayPreviewPanel.cpp + ../src/assets/MaterialCache.cpp ) if(SDL3_FOUND) @@ -75,13 +108,20 @@ endif() add_executable(CaffeineTest ${CAFFEINE_TEST_SOURCES}) -target_link_libraries(CaffeineTest PRIVATE caffeine-core caf-pack-lib) +target_link_libraries(CaffeineTest PRIVATE caffeine-core) +if(CAF_PACK_AVAILABLE) + target_link_libraries(CaffeineTest PRIVATE caf-pack-lib) + target_compile_definitions(CaffeineTest PRIVATE CF_HAS_CAF_PACK=1) +endif() # Link ImGui and test engine if SDL3 is available (needed for editor panels) if(SDL3_FOUND) target_link_libraries(CaffeineTest PRIVATE ImGui ImNodes ImGuiTestEngine) target_compile_definitions(CaffeineTest PRIVATE CF_HAS_IMGUI=1 IMGUI_TEST_ENGINE_ENABLE_COROUTINE_STDTHREAD_IMPL=1) target_include_directories(CaffeineTest PRIVATE ${imgui_test_engine_SOURCE_DIR}) + if(DEFINED nanosvg_SOURCE_DIR) + target_include_directories(CaffeineTest PRIVATE ${nanosvg_SOURCE_DIR}/src) + endif() endif() target_include_directories(CaffeineTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/Catch2 ${CMAKE_SOURCE_DIR}/caf-pack/include) diff --git a/tests/cap_test_helpers.hpp b/tests/cap_test_helpers.hpp new file mode 100644 index 0000000..616d605 --- /dev/null +++ b/tests/cap_test_helpers.hpp @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Caffeine::Test { + +constexpr uint32_t CAP_MAGIC = 0x4341502F; +constexpr uint32_t CAP_VERSION = 1; +constexpr uint32_t CAF_MAGIC = 0x43414621; +constexpr uint32_t CAF_VERSION = 1; + +struct CapHeader { + uint32_t magic = CAP_MAGIC; + uint32_t version = CAP_VERSION; + uint32_t assetCount = 0; + uint32_t reserved1 = 0; + uint64_t tableOffset = 64; + uint64_t tableSize = 0; + uint64_t dataOffset = 0; + uint32_t totalSize = 0; + uint64_t crc64 = 0; + uint32_t reserved2 = 0; + uint32_t reserved3 = 0; +}; + +struct CapEntry { + uint64_t hashID = 0; + uint64_t offset = 0; + uint32_t size = 0; + uint32_t reserved = 0; +}; + +struct CafHeader { + uint32_t magic = CAF_MAGIC; + uint32_t version = CAF_VERSION; + uint8_t assetType = 0; + uint8_t reserved[7] = {0}; + uint32_t payloadSize = 0; + uint32_t flags = 0; + uint64_t crc64 = 0; +}; + +enum class CafAssetType : uint8_t { + Unknown = 0, + Texture = 1, + Audio = 2, + Mesh = 3, + Script = 4, + Animation = 5, + Tileset = 6 +}; + +inline void writeEmptyCapFile(const std::filesystem::path& path) { + std::ofstream file(path, std::ios::binary); + CapHeader capHeader{}; + capHeader.magic = CAP_MAGIC; + capHeader.version = CAP_VERSION; + capHeader.assetCount = 0; + capHeader.tableOffset = 64; + capHeader.tableSize = 0; + capHeader.dataOffset = 64; + capHeader.totalSize = 64; + capHeader.crc64 = 0; + file.write(reinterpret_cast(&capHeader), sizeof(CapHeader)); +} + +inline void writeSampleCapFile(const std::filesystem::path& path) { + std::ofstream file(path, std::ios::binary); + + CapHeader capHeader; + capHeader.magic = CAP_MAGIC; + capHeader.version = CAP_VERSION; + capHeader.assetCount = 2; + capHeader.tableOffset = 64; + capHeader.tableSize = 2 * sizeof(CapEntry); + capHeader.dataOffset = 64 + capHeader.tableSize; + + CafHeader caf1; + caf1.magic = CAF_MAGIC; + caf1.version = CAF_VERSION; + caf1.assetType = static_cast(CafAssetType::Texture); + caf1.payloadSize = 16; + + CafHeader caf2; + caf2.magic = CAF_MAGIC; + caf2.version = CAF_VERSION; + caf2.assetType = static_cast(CafAssetType::Audio); + caf2.payloadSize = 32; + + const uint64_t caf1Offset = capHeader.dataOffset; + const uint64_t caf2Offset = caf1Offset + sizeof(CafHeader) + caf1.payloadSize; + capHeader.totalSize = static_cast(caf2Offset + sizeof(CafHeader) + caf2.payloadSize); + + file.write(reinterpret_cast(&capHeader), sizeof(capHeader)); + + CapEntry entry1; + entry1.hashID = 0x12345678ABCDEF00; + entry1.offset = caf1Offset; + entry1.size = static_cast(sizeof(CafHeader) + caf1.payloadSize); + + CapEntry entry2; + entry2.hashID = 0xFEDCBA9876543210; + entry2.offset = caf2Offset; + entry2.size = static_cast(sizeof(CafHeader) + caf2.payloadSize); + + file.write(reinterpret_cast(&entry1), sizeof(entry1)); + file.write(reinterpret_cast(&entry2), sizeof(entry2)); + file.write(reinterpret_cast(&caf1), sizeof(caf1)); + std::vector payload1(16, 0xAA); + file.write(reinterpret_cast(payload1.data()), payload1.size()); + file.write(reinterpret_cast(&caf2), sizeof(caf2)); + std::vector payload2(32, 0xBB); + file.write(reinterpret_cast(payload2.data()), payload2.size()); +} + +} // namespace Caffeine::Test diff --git a/tests/editor/phase1_integration_test.cpp b/tests/editor/phase1_integration_test.cpp index 645ee3d..aff86c9 100644 --- a/tests/editor/phase1_integration_test.cpp +++ b/tests/editor/phase1_integration_test.cpp @@ -1,14 +1,14 @@ #include "catch.hpp" +#include "cap_test_helpers.hpp" #include "editor/CapLoader.hpp" #include "editor/AssetBrowser.hpp" #include "editor/AudioWaveformRenderer.hpp" #include #include -#include using namespace Caffeine; using namespace Caffeine::Editor; -using namespace Caffeine::Assets; +using namespace Caffeine::Test; TEST_CASE("Phase 1: Asset Browser Initialization", "[phase1][asset_browser]") { auto testProjectPath = std::filesystem::temp_directory_path() / "caffeine_test_phase1"; @@ -20,17 +20,18 @@ TEST_CASE("Phase 1: Asset Browser Initialization", "[phase1][asset_browser]") { SECTION("AssetBrowser initializes in Filesystem mode") { AssetBrowser browser; browser.init((testProjectPath / "assets").string().c_str()); - + REQUIRE(browser.browseMode() == AssetBrowser::BrowseMode::Filesystem); REQUIRE(browser.entryCount() == 0); } - // Cleanup if (std::filesystem::exists(testProjectPath)) { std::filesystem::remove_all(testProjectPath); } } +#ifdef CF_HAS_CAF_PACK + TEST_CASE("Phase 1: CAP File Browsing", "[phase1][cap_loader]") { auto testProjectPath = std::filesystem::temp_directory_path() / "caffeine_test_phase1"; if (std::filesystem::exists(testProjectPath)) { @@ -40,29 +41,15 @@ TEST_CASE("Phase 1: CAP File Browsing", "[phase1][cap_loader]") { SECTION("AssetBrowser can switch to CAP mode") { auto capPath = testProjectPath / "game.cap"; - - std::ofstream file(capPath, std::ios::binary); - CapHeader capHeader{}; - capHeader.magic = CAP_MAGIC; - capHeader.version = CAP_VERSION; - capHeader.assetCount = 0; - capHeader.tableOffset = 64; - capHeader.tableSize = 0; - capHeader.dataOffset = 64; - capHeader.totalSize = 64; - capHeader.crc64 = 0; - - file.write(reinterpret_cast(&capHeader), sizeof(CapHeader)); - file.close(); + writeEmptyCapFile(capPath); AssetBrowser browser; browser.init((testProjectPath / "assets").string().c_str()); browser.loadCapFile(capPath); - + REQUIRE(browser.browseMode() == AssetBrowser::BrowseMode::CapFile); } - // Cleanup if (std::filesystem::exists(testProjectPath)) { std::filesystem::remove_all(testProjectPath); } @@ -77,36 +64,24 @@ TEST_CASE("Phase 1: CAP Loader Robustness", "[phase1][cap_loader]") { SECTION("CapLoader handles empty CAP files") { auto capPath = testProjectPath / "empty.cap"; - - std::ofstream file(capPath, std::ios::binary); - CapHeader capHeader{}; - capHeader.magic = CAP_MAGIC; - capHeader.version = CAP_VERSION; - capHeader.assetCount = 0; - capHeader.tableOffset = 64; - capHeader.tableSize = 0; - capHeader.dataOffset = 64; - capHeader.totalSize = 64; - capHeader.crc64 = 0; - - file.write(reinterpret_cast(&capHeader), sizeof(CapHeader)); - file.close(); + writeEmptyCapFile(capPath); auto assets = CapLoader::loadCap(capPath); REQUIRE(assets.empty()); } - // Cleanup if (std::filesystem::exists(testProjectPath)) { std::filesystem::remove_all(testProjectPath); } } +#endif + TEST_CASE("Phase 1: Audio Waveform Rendering", "[phase1][audio_waveform]") { SECTION("AudioWaveformRenderer generates waveform from empty blob") { std::vector emptyBlob(1024, 0); auto waveform = AudioWaveformRenderer::generateWaveform(emptyBlob); - + REQUIRE(waveform.sampleRate == 0); REQUIRE(!waveform.isStereo); } diff --git a/tests/test_animation.cpp b/tests/test_animation.cpp index e1c3267..5b8d392 100644 --- a/tests/test_animation.cpp +++ b/tests/test_animation.cpp @@ -69,7 +69,7 @@ TEST_CASE("AnimationTransition - defaults", "[animation]") { AnimationTransition t; REQUIRE(approxEq(t.blendTime, 0.1f)); REQUIRE(t.hasExitTime == false); - REQUIRE(!t.condition); + REQUIRE(!t.legacyCondition); } // ============================================================================ @@ -245,7 +245,7 @@ TEST_CASE("AnimationSystem - transition fires when condition is true", "[animati idle.clip = &idleClip; AnimationTransition toWalk; toWalk.toState = "walk"; - toWalk.condition = []() { return true; }; + toWalk.legacyCondition = []() { return true; }; idle.transitions.push_back(toWalk); AnimationState walk; @@ -278,7 +278,7 @@ TEST_CASE("AnimationSystem - transition does not fire when condition is false", idle.clip = &idleClip; AnimationTransition t; t.toState = "walk"; - t.condition = []() { return false; }; + t.legacyCondition = []() { return false; }; idle.transitions.push_back(t); Animator anim; diff --git a/tests/test_assetmanager.cpp b/tests/test_assetmanager.cpp index 313ca88..54f8b86 100644 --- a/tests/test_assetmanager.cpp +++ b/tests/test_assetmanager.cpp @@ -232,6 +232,92 @@ TEST_CASE("AssetManager - failed load returns handle with Failed status", "[asse REQUIRE(handle.isValid()); REQUIRE_FALSE(handle.isReady()); REQUIRE(handle.get() == nullptr); + REQUIRE(mgr.getStatus(handle.id(), handle.generation()) == LoadStatus::Failed); +} + +TEST_CASE("AssetManager - stale handle after eviction returns nullptr", "[assets]") { + TestFixture fix; + AssetManager mgr(nullptr, ""); + + u32 id = 0; + u16 gen = 0; + { + auto loaded = mgr.loadSync(kTexturePath); + id = loaded.id(); + gen = loaded.generation(); + REQUIRE(loaded.isReady()); + REQUIRE(gen == 1); + } + mgr.collectGarbage(); + + AssetHandle stale(&mgr, id, gen); + REQUIRE_FALSE(stale.isReady()); + REQUIRE(stale.get() == nullptr); + REQUIRE(mgr.getStatus(id, gen) == LoadStatus::Invalid); + REQUIRE(mgr.entryGeneration(id) == 2); +} + +TEST_CASE("AssetManager - invalidation callback fires on collectGarbage", "[assets]") { + TestFixture fix; + AssetManager mgr(nullptr, ""); + + std::pair captured{~0u, 0}; + mgr.registerInvalidationCallback( + [](const InvalidatedAsset& info, void* userData) { + auto* out = static_cast*>(userData); + out->first = info.id; + out->second = info.generation; + }, + &captured); + + u32 id = 0; + u16 gen = 0; + { + auto handle = mgr.loadSync(kTexturePath); + id = handle.id(); + gen = handle.generation(); + REQUIRE(gen == 1); + } + mgr.collectGarbage(); + + REQUIRE(captured.first == id); + REQUIRE(captured.second == gen); +} + +TEST_CASE("AssetManager - LRU evicts unreferenced assets when over budget", "[assets]") { + TestFixture fix; + AssetManager mgr(nullptr, "", 0); + + { + auto first = mgr.loadSync(kTexturePath); + REQUIRE(first.isReady()); + } + + auto second = mgr.loadSync(kAudioPath); + REQUIRE(second.isReady()); + + CacheStats stats = mgr.cacheStats(); + REQUIRE(stats.evictedCount >= 1); +} + +TEST_CASE("AssetManager - reload after eviction works with new generation", "[assets]") { + TestFixture fix; + AssetManager mgr(nullptr, ""); + + u32 id = 0; + u16 gen = 0; + { + auto first = mgr.loadSync(kTexturePath); + id = first.id(); + gen = first.generation(); + } + mgr.collectGarbage(); + + auto second = mgr.loadSync(kTexturePath); + REQUIRE(second.id() == id); + REQUIRE(second.generation() == gen + 1); + REQUIRE(second.isReady()); + REQUIRE(second.get() != nullptr); } TEST_CASE("AssetManager - tick advances frame index", "[assets]") { diff --git a/tests/test_cap_loader.cpp b/tests/test_cap_loader.cpp index 9c33694..ea3b21b 100644 --- a/tests/test_cap_loader.cpp +++ b/tests/test_cap_loader.cpp @@ -1,118 +1,26 @@ #include "catch.hpp" +#include "cap_test_helpers.hpp" #include "../src/editor/CapLoader.hpp" #include #include -#include using namespace Caffeine; using namespace Caffeine::Editor; +using namespace Caffeine::Test; -constexpr uint32_t CAP_MAGIC = 0x4341502F; -constexpr uint32_t CAP_VERSION = 1; -constexpr uint32_t CAF_MAGIC = 0x43414621; -constexpr uint32_t CAF_VERSION = 1; - -struct CapHeader { - uint32_t magic = CAP_MAGIC; - uint32_t version = CAP_VERSION; - uint32_t assetCount = 0; - uint32_t reserved1 = 0; - uint64_t tableOffset = 64; - uint64_t tableSize = 0; - uint64_t dataOffset = 0; - uint32_t totalSize = 0; - uint64_t crc64 = 0; - uint32_t reserved2 = 0; - uint32_t reserved3 = 0; -}; - -struct CapEntry { - uint64_t hashID = 0; - uint64_t offset = 0; - uint32_t size = 0; - uint32_t reserved = 0; -}; - -struct CafHeader { - uint32_t magic = CAF_MAGIC; - uint32_t version = CAF_VERSION; - uint8_t assetType = 0; - uint8_t reserved[7] = {0}; - uint32_t payloadSize = 0; - uint32_t flags = 0; - uint64_t crc64 = 0; -}; - -enum class CafAssetType : uint8_t { - Unknown = 0, - Texture = 1, - Audio = 2, - Mesh = 3, - Script = 4, - Animation = 5, - Tileset = 6 -}; - -// Helper to create a minimal valid CAP file for testing -static void createTestCapFile(const std::filesystem::path& path) { - std::ofstream file(path.string(), std::ios::binary); - REQUIRE(file.is_open()); - - // Write CAP header - CapHeader capHeader; - capHeader.magic = CAP_MAGIC; - capHeader.version = CAP_VERSION; - capHeader.assetCount = 2; - capHeader.tableOffset = 64; - capHeader.tableSize = 2 * sizeof(CapEntry); - capHeader.dataOffset = 64 + capHeader.tableSize; - - // Create two test CAF blobs - CafHeader caf1; - caf1.magic = CAF_MAGIC; - caf1.version = CAF_VERSION; - caf1.assetType = static_cast(CafAssetType::Texture); - caf1.payloadSize = 16; - - CafHeader caf2; - caf2.magic = CAF_MAGIC; - caf2.version = CAF_VERSION; - caf2.assetType = static_cast(CafAssetType::Audio); - caf2.payloadSize = 32; - - uint64_t caf1Offset = capHeader.dataOffset; - uint64_t caf2Offset = caf1Offset + sizeof(CafHeader) + caf1.payloadSize; - - capHeader.totalSize = caf2Offset + sizeof(CafHeader) + caf2.payloadSize; - - // Write CAP header - file.write(reinterpret_cast(&capHeader), sizeof(capHeader)); - - // Write entry table - CapEntry entry1; - entry1.hashID = 0x12345678ABCDEF00; - entry1.offset = caf1Offset; - entry1.size = sizeof(CafHeader) + caf1.payloadSize; - - CapEntry entry2; - entry2.hashID = 0xFEDCBA9876543210; - entry2.offset = caf2Offset; - entry2.size = sizeof(CafHeader) + caf2.payloadSize; - - file.write(reinterpret_cast(&entry1), sizeof(entry1)); - file.write(reinterpret_cast(&entry2), sizeof(entry2)); - - // Write first CAF blob (texture) - file.write(reinterpret_cast(&caf1), sizeof(caf1)); - std::vector payload1(16, 0xAA); - file.write(reinterpret_cast(payload1.data()), payload1.size()); - - // Write second CAF blob (audio) - file.write(reinterpret_cast(&caf2), sizeof(caf2)); - std::vector payload2(32, 0xBB); - file.write(reinterpret_cast(payload2.data()), payload2.size()); - - file.close(); +#ifdef CF_HAS_CAF_PACK +#include "caffeine/CafTypes.hpp" +using CafAssetTypeEnum = Caffeine::Assets::CafAssetType; +#else +using CafAssetTypeEnum = CafAssetType; +#endif + +static uint8_t assetTypeValue(const CapLoader::LoadedAsset& asset) { +#ifdef CF_HAS_CAF_PACK + return static_cast(asset.type); +#else + return asset.type; +#endif } TEST_CASE("CapLoader - loadCap returns empty vector for non-existent file", "[editor][caploader]") { @@ -121,105 +29,102 @@ TEST_CASE("CapLoader - loadCap returns empty vector for non-existent file", "[ed } TEST_CASE("CapLoader - loadCap returns empty vector for invalid magic", "[editor][caploader]") { - std::filesystem::path tempPath = "/tmp/test_cap_invalid_magic.cap"; - - // Create file with invalid magic - std::ofstream file(tempPath.string(), std::ios::binary); + std::filesystem::path tempPath = std::filesystem::temp_directory_path() / "test_cap_invalid_magic.cap"; + + std::ofstream file(tempPath, std::ios::binary); CapHeader header; - header.magic = 0xDEADBEEF; // Invalid magic + header.magic = 0xDEADBEEF; file.write(reinterpret_cast(&header), sizeof(header)); file.close(); - + auto assets = CapLoader::loadCap(tempPath); REQUIRE(assets.empty()); - + std::filesystem::remove(tempPath); } TEST_CASE("CapLoader - loadCap returns empty vector for unsupported version", "[editor][caploader]") { - std::filesystem::path tempPath = "/tmp/test_cap_bad_version.cap"; - - std::ofstream file(tempPath.string(), std::ios::binary); + std::filesystem::path tempPath = std::filesystem::temp_directory_path() / "test_cap_bad_version.cap"; + + std::ofstream file(tempPath, std::ios::binary); CapHeader header; header.magic = CAP_MAGIC; - header.version = 999; // Unsupported version + header.version = 999; file.write(reinterpret_cast(&header), sizeof(header)); file.close(); - + auto assets = CapLoader::loadCap(tempPath); REQUIRE(assets.empty()); - + std::filesystem::remove(tempPath); } +#ifdef CF_HAS_CAF_PACK + TEST_CASE("CapLoader - loadCap extracts correct number of assets", "[editor][caploader]") { - std::filesystem::path tempPath = "/tmp/test_cap_valid.cap"; - createTestCapFile(tempPath); - + std::filesystem::path tempPath = std::filesystem::temp_directory_path() / "test_cap_valid.cap"; + writeSampleCapFile(tempPath); + auto assets = CapLoader::loadCap(tempPath); REQUIRE(assets.size() == 2); - + std::filesystem::remove(tempPath); } TEST_CASE("CapLoader - loadCap extracts asset with correct hashID", "[editor][caploader]") { - std::filesystem::path tempPath = "/tmp/test_cap_hashid.cap"; - createTestCapFile(tempPath); - + std::filesystem::path tempPath = std::filesystem::temp_directory_path() / "test_cap_hashid.cap"; + writeSampleCapFile(tempPath); + auto assets = CapLoader::loadCap(tempPath); REQUIRE_FALSE(assets.empty()); REQUIRE(assets[0].hashID == 0x12345678ABCDEF00); REQUIRE(assets[1].hashID == 0xFEDCBA9876543210); - + std::filesystem::remove(tempPath); } TEST_CASE("CapLoader - loadCap identifies asset types correctly", "[editor][caploader]") { - std::filesystem::path tempPath = "/tmp/test_cap_types.cap"; - createTestCapFile(tempPath); - + std::filesystem::path tempPath = std::filesystem::temp_directory_path() / "test_cap_types.cap"; + writeSampleCapFile(tempPath); + auto assets = CapLoader::loadCap(tempPath); REQUIRE(assets.size() == 2); - REQUIRE(assets[0].type == Assets::CafAssetType::Texture); - REQUIRE(assets[1].type == Assets::CafAssetType::Audio); - + REQUIRE(assetTypeValue(assets[0]) == static_cast(CafAssetTypeEnum::Texture)); + REQUIRE(assetTypeValue(assets[1]) == static_cast(CafAssetTypeEnum::Audio)); + std::filesystem::remove(tempPath); } TEST_CASE("CapLoader - loadCap stores CAF blob data", "[editor][caploader]") { - std::filesystem::path tempPath = "/tmp/test_cap_blobs.cap"; - createTestCapFile(tempPath); - + std::filesystem::path tempPath = std::filesystem::temp_directory_path() / "test_cap_blobs.cap"; + writeSampleCapFile(tempPath); + auto assets = CapLoader::loadCap(tempPath); REQUIRE(assets.size() == 2); - - // First asset should have CafHeader + 16 bytes payload REQUIRE(assets[0].cafBlob.size() == sizeof(CafHeader) + 16); - - // Second asset should have CafHeader + 32 bytes payload REQUIRE(assets[1].cafBlob.size() == sizeof(CafHeader) + 32); - + std::filesystem::remove(tempPath); } TEST_CASE("CapLoader - loadCap parses CAF metadata correctly", "[editor][caploader]") { - std::filesystem::path tempPath = "/tmp/test_cap_metadata.cap"; - createTestCapFile(tempPath); - + std::filesystem::path tempPath = std::filesystem::temp_directory_path() / "test_cap_metadata.cap"; + writeSampleCapFile(tempPath); + auto assets = CapLoader::loadCap(tempPath); REQUIRE(assets.size() == 2); - - // Check first asset metadata + REQUIRE(assets[0].metadata.magic == CAF_MAGIC); REQUIRE(assets[0].metadata.version == CAF_VERSION); REQUIRE(assets[0].metadata.assetType == static_cast(CafAssetType::Texture)); REQUIRE(assets[0].metadata.payloadSize == 16); - - // Check second asset metadata + REQUIRE(assets[1].metadata.magic == CAF_MAGIC); REQUIRE(assets[1].metadata.version == CAF_VERSION); REQUIRE(assets[1].metadata.assetType == static_cast(CafAssetType::Audio)); REQUIRE(assets[1].metadata.payloadSize == 32); - + std::filesystem::remove(tempPath); } + +#endif diff --git a/tests/test_scenemanager.cpp b/tests/test_scenemanager.cpp index cb3f717..a5684d8 100644 --- a/tests/test_scenemanager.cpp +++ b/tests/test_scenemanager.cpp @@ -53,12 +53,12 @@ TEST_CASE("SceneSerializer - memory output has valid CafHeader magic", "[scene]" REQUIRE(hdr.type == AssetType::Scene); } -TEST_CASE("SceneSerializer - round-trip Position2D", "[scene]") { +TEST_CASE("SceneSerializer - round-trip Transform", "[scene]") { World world; Entity e = world.create(); - world.add(e); - world.get(e)->x = 3.0f; - world.get(e)->y = 7.0f; + world.add(e); + world.get(e)->position.x = 3.0f; + world.get(e)->position.y = 7.0f; std::vector bytes; SceneSerializer(world).serializeToMemory(bytes); @@ -67,21 +67,22 @@ TEST_CASE("SceneSerializer - round-trip Position2D", "[scene]") { REQUIRE(SceneSerializer(world2).deserializeFromMemory(bytes)); bool found = false; - ComponentQuery q; q.with(); - world2.forEach(q, [&](Entity, Position2D& p) { - REQUIRE(approxEq(p.x, 3.0f)); - REQUIRE(approxEq(p.y, 7.0f)); + ComponentQuery q; + q.with(); + world2.forEach(q, [&](Entity, Transform& t) { + REQUIRE(approxEq(t.position.x, 3.0f)); + REQUIRE(approxEq(t.position.y, 7.0f)); found = true; }); REQUIRE(found); } -TEST_CASE("SceneSerializer - round-trip Health", "[scene]") { +TEST_CASE("SceneSerializer - round-trip Acceleration2D", "[scene]") { World world; Entity e = world.create(); - world.add(e); - world.get(e)->current = 50; - world.get(e)->max = 200; + world.add(e); + world.get(e)->x = 50.0f; + world.get(e)->y = 200.0f; std::vector bytes; SceneSerializer(world).serializeToMemory(bytes); @@ -90,10 +91,11 @@ TEST_CASE("SceneSerializer - round-trip Health", "[scene]") { SceneSerializer(world2).deserializeFromMemory(bytes); bool found = false; - ComponentQuery q; q.with(); - world2.forEach(q, [&](Entity, Health& h) { - REQUIRE(h.current == 50u); - REQUIRE(h.max == 200u); + ComponentQuery q; + q.with(); + world2.forEach(q, [&](Entity, Acceleration2D& a) { + REQUIRE(approxEq(a.x, 50.0f)); + REQUIRE(approxEq(a.y, 200.0f)); found = true; }); REQUIRE(found); @@ -103,7 +105,7 @@ TEST_CASE("SceneSerializer - round-trip entity count", "[scene]") { World world; for (int i = 0; i < 5; ++i) { Entity e = world.create(); - world.add(e); + world.add(e); } std::vector bytes; @@ -118,12 +120,12 @@ TEST_CASE("SceneSerializer - round-trip entity count", "[scene]") { TEST_CASE("SceneSerializer - round-trip multiple component types on same entity", "[scene]") { World world; Entity e = world.create(); - world.add(e); - world.get(e)->x = 1.0f; - world.get(e)->y = 2.0f; - world.add(e); - world.get(e)->current = 75; - world.get(e)->max = 100; + world.add(e); + world.get(e)->position.x = 1.0f; + world.get(e)->position.y = 2.0f; + world.add(e); + world.get(e)->x = 75.0f; + world.get(e)->y = 100.0f; std::vector bytes; SceneSerializer(world).serializeToMemory(bytes); @@ -132,10 +134,11 @@ TEST_CASE("SceneSerializer - round-trip multiple component types on same entity" SceneSerializer(world2).deserializeFromMemory(bytes); bool found = false; - ComponentQuery q; q.with(); - world2.forEach(q, [&](Entity, Position2D& p, Health& h) { - REQUIRE(approxEq(p.x, 1.0f)); - REQUIRE(h.current == 75u); + ComponentQuery q; + q.with(); + world2.forEach(q, [&](Entity, Transform& t, Acceleration2D& a) { + REQUIRE(approxEq(t.position.x, 1.0f)); + REQUIRE(approxEq(a.x, 75.0f)); found = true; }); REQUIRE(found); @@ -154,7 +157,8 @@ TEST_CASE("SceneSerializer - round-trip WorldTransform", "[scene]") { SceneSerializer(world2).deserializeFromMemory(bytes); bool found = false; - ComponentQuery q; q.with(); + ComponentQuery q; + q.with(); world2.forEach(q, [&](Entity, WorldTransform& wt) { REQUIRE(approxEq(wt.matrix.data()[12], 5.0f)); REQUIRE(approxEq(wt.matrix.data()[13], 10.0f)); @@ -178,7 +182,8 @@ TEST_CASE("SceneSerializer - round-trip Parent dirty flag", "[scene]") { SceneSerializer(world2).deserializeFromMemory(bytes); bool found = false; - ComponentQuery q; q.with(); + ComponentQuery q; + q.with(); world2.forEach(q, [&](Entity, Parent& p) { REQUIRE(p.dirty == false); REQUIRE(p.parent.isValid()); @@ -248,23 +253,28 @@ TEST_CASE("WorldTransform - 3 levels of hierarchy propagated correctly", "[scene Entity child = world.create(); Entity grand = world.create(); - world.add(root); world.get(root)->x = 10.0f; + world.add(root); + world.get(root)->position.x = 10.0f; world.add(root); - world.add(child); world.get(child)->x = 5.0f; + world.add(child); + world.get(child)->position.x = 5.0f; world.add(child); - world.add(child); world.get(child)->parent = root; + world.add(child); + world.get(child)->parent = root; - world.add(grand); world.get(grand)->x = 2.0f; + world.add(grand); + world.get(grand)->position.x = 2.0f; world.add(grand); - world.add(grand); world.get(grand)->parent = child; + world.add(grand); + world.get(grand)->parent = child; auto propagate = [&](Entity e) { - Position2D* pos = world.get(e); + Transform* pos = world.get(e); WorldTransform* wt = world.get(e); - Parent* par = world.get(e); + Parent* par = world.get(e); if (!pos || !wt) return; - Mat4 local = Mat4::translation(pos->x, pos->y, 0.0f); + Mat4 local = Mat4::translation(pos->position); if (par && par->parent.isValid()) { WorldTransform* pwt = world.get(par->parent); wt->matrix = pwt ? pwt->matrix * local : local; From b26fbfacbc2dcec536cd2e17d38136971d2c26e9 Mon Sep 17 00:00:00 2001 From: LyeZinho Date: Wed, 23 Sep 2026 00:56:17 +0100 Subject: [PATCH 3/5] Improve editor viewport performance and add hierarchical profiler. Raise Doppio FPS from ~10 to ~100 by deduplicating GPU scene work, caching terrain binds, and separating CPU/GPU mesh paths. Add scope-tree profiler UI, motion-adaptive skybox raster, and preview GPU caching to keep camera movement smooth. Co-authored-by: Cursor --- CHANGELOG.md | 636 ++++++++++++++++++ README.md | 1 + docs/HISTORY.md | 9 + docs/README.md | 1 + docs/editor/scene-viewport.md | 55 +- docs/performance/README.md | 7 + .../editor-viewport-performance.md | 234 +++++++ docs/rendering/shadow-mapping.md | 26 +- src/debug/Profiler.cpp | 147 ++-- src/debug/Profiler.hpp | 52 +- src/editor/CameraPreviewPanel.cpp | 167 +++-- src/editor/CameraPreviewPanel.hpp | 11 + src/editor/EditorPanelUtils.hpp | 10 + src/editor/GameplayPreviewPanel.cpp | 75 ++- src/editor/GameplayPreviewPanel.hpp | 6 + src/editor/ProfilerWindow.hpp | 173 ++++- src/editor/SceneEditor.cpp | 88 ++- src/editor/SceneViewport.cpp | 184 +++-- src/editor/SceneViewport.hpp | 9 +- src/render/GpuSceneRenderer.cpp | 118 ++-- src/render/GpuSceneRenderer.hpp | 2 + src/render/SkyboxRenderer.cpp | 30 +- src/render/SkyboxRenderer.hpp | 6 +- src/terrain/TerrainCache.cpp | 6 +- src/terrain/TerrainCache.hpp | 3 +- src/terrain/TerrainLodSystem.cpp | 6 +- src/terrain/TerrainLodSystem.hpp | 3 +- tests/CMakeLists.txt | 1 + tests/test_debug.cpp | 96 ++- 29 files changed, 1800 insertions(+), 362 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/performance/README.md create mode 100644 docs/performance/editor-viewport-performance.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a24ac2b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,636 @@ +# Changelog + +Todas as alterações notáveis do projeto **Caffeine Engine** / **Doppio IDE** estão documentadas neste ficheiro. + +O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/) e o histórico completo de **419 commits** (desde 2026-04-06) está no [Apêndice](#apêndice--histórico-completo-de-commits). + +Legenda de categorias: **Added** · **Changed** · **Fixed** · **Removed** · **Deprecated** · **Security** + +--- + +## [Unreleased] + +_Nada por agora._ + +--- + +## [2026-09-23] — Performance do editor, profiler hierárquico & skybox + +### Added +- **Profiler hierárquico** — árvore de scopes com `self ms` (exclusivo), `total ms` (inclusivo), `% frame`, `% parent` e caminho completo no tooltip. +- Documentação [`docs/performance/editor-viewport-performance.md`](docs/performance/editor-viewport-performance.md) e índice em [`docs/performance/README.md`](docs/performance/README.md). +- `CF_PROFILE_SCOPE` granular em `SceneEditor`, `SceneViewport`, `GpuSceneRenderer` (`renderWithCamera`, `gather`, `shadows`, `draw`). +- Cache de GPU nos previews (Gameplay / Camera) — re-render só quando câmera ou tamanho mudam. +- Skybox **motion-adaptive**: raster a 384 px enquanto a câmera move; resolução completa após parar. + +### Changed +- **Editor viewport ~10 → ~100 FPS** — separação GPU/CPU por frame, shadow UBO uma vez por pass, cache de texturas de terreno, LOD ×2, cap 1280 px, sombras off no editor. +- `GpuSceneRenderer`: samplers de sombra **sempre** ligados (evita SIGSEGV NVIDIA); passes de sombra omitidos no editor. +- `SceneViewport`: skip de `gpuPass` quando câmera/canvas/modo inalterados. +- Previews: throttle GPU (intervalo 2 quando parado), caps de skybox reduzidos (256–640 px). +- Profiler UI: refresh da árvore a cada 15 frames; `Expand All` off por defeito. + +### Fixed +- Skybox a piscar e atrasar o terreno nos previews (sky + GPU dessincronizados). +- Erros ImGui `Missing TreePop()` / `PushID/PopID` na árvore do Profiler. +- Stutters ao mover câmera após remoção do throttle do skybox (raster adaptativo + menos GPU redundante). +- Correção de link em `tests/CMakeLists.txt` (`PluginSymbolExports.cpp`) para `CaffeineTest`. + +--- + +## [2026-09-22] — Plugins opcionais & qualidade GPU + +### Added +- **Plugin SDK** público (`include/caffeine/plugin/`) com host API, service registry e carregamento dinâmico (`dlopen` + `-rdynamic`). +- Plugins opcionais: **Git**, **Terrain Generator**, **Procedural Tools**, **Post Processing**, **Hello** (demo). +- Painel Git com status, stage/unstage, commit, branches, remotes, stash, log e diff. +- Sistema procedural modular (terrain profile, structure spawner, benchmarks Lua). +- Post-processing modular por efeito + presets benchmark + bindings Lua. +- Terrain heightmap importer, collision mesh builder, algoritmo ultra-realistic (Node.js). +- Roadmaps: engine pillars, rendering, Phong GPU handles, viewport quality. +- Documentação: `plugin-sdk.md`, `git-plugin.md`, terrain, post-processing. + +### Changed +- Doppio core **lean**: geração de terrain e UI de features movidas para plugins. +- `CAFFEINE_BUNDLE_DEFAULT_PLUGINS` (OFF por defeito) — plugins não copiados automaticamente. +- Inspector delega drawers de Post Process ao plugin; mensagens quando plugin ausente. +- Preview de post-process no **Scene Viewport** + overlays mais visíveis. +- Command palette: atalho **Ctrl+L** (mantém Ctrl+Shift+P). +- IDs estáveis de plugin (stem do `.so`) — corrige crash SIGSEGV ao carregar múltiplos plugins. +- Melhorias GPU: Phong materials, shadow maps (directional/point/spot), texture LOD/cache, `GpuSceneRenderer`, shaders `scene_lit`/`terrain_lit`. +- `TimerScheduler`, `Camera3DSystem`, `PluginSymbolExports` para símbolos exportados a plugins. +- Viewport: pan/zoom 2D melhorado, click-to-select, qualidade de rendering. + +### Removed +- Geração procedural de terrain embutida no core (`src/terrain/generation/*`). +- Bridges builtin terrain/procedural do Doppio (`TerrainBridge`, `ProceduralBridge` no core). + +--- + +## [2026-09-18] — Runtime, build pipeline & polish + +### Added +- Pipeline de build do jogo (`BuildDialog`, `BuildSystem`, 7 estágios). +- Executável `caffeine-runtime` para play sem editor. +- Sistema de plugins inicial no Doppio. +- Melhorias de workflow: startup dialog, asset browser, project manager. + +### Changed +- Refinamentos de UX do editor e correções gerais ("major fix" ×3). + +--- + +## [2026-05-24] — Testes UI & seleção 3D + +### Added +- Framework de testes UI JSON (`TestUIMapper`, `TestRequestHandler`, headless Doppio). +- Raycasting de seleção 3D, multi-select, focus camera (double-click). +- Gizmo com raycast em eixos finitos. + +--- + +## [2026-05-23] — Mesh, prefabs & rendering 3D + +### Added +- Pipeline glTF/GLB → `.caf`, mesh cache, LOD framework. +- Drag-and-drop de meshes no viewport, "Save as Prefab". +- Rendering 3D de meshes glTF com texturas embutidas e fill semi-transparente. + +--- + +## [2026-05-20 — 2026-05-22] — Viewport 3D, animação & ECS + +### Added +- View modes 2D / 3D / Isometric, orbit camera, grid 3D, gizmos XYZ. +- Componente `Transform` unificado (remove Position2D/Rotation/Scale2D). +- Animator Controller, timeline com scrubber, parâmetros nomeados. +- Hierarquia com herança de transform, auto-expand no drag-drop. + +### Changed +- Removidos `Health` e `Velocity2D` (migrado para `RigidBody2D`). + +--- + +## [2026-05-17 — 2026-05-19] — Play mode, inspector & scripting + +### Added +- **Play / Pause / Stop** com snapshot de entidades. +- Component registry unificado, Add Component pesquisável. +- Camera Preview panel, frustum Camera2D/3D no viewport. +- Collider debug overlay, tileset loading, dual scripting Lua + C++. +- CAP browsing, waveform preview, drag-drop auto-pack no Asset Browser. +- Grid no viewport, componentes Camera/Light/MeshRenderer. + +--- + +## [2026-05-14 — 2026-05-16] — Doppio IDE (Caffeine Studio) + +### Added +- Split **caffeine-core** + **doppio** executável. +- Painéis: Hierarchy, Inspector, Viewport, Asset Browser, Scene Editor orchestrator. +- Undo/redo snapshot-based, SceneSerializer binário, Transform Gizmos. +- Drag-and-drop AssetBrowser→Viewport, ProjectManager, SceneTabManager. +- Asset Pipeline (TextureCompiler, HotReloader), FileWatcher nativo Windows. +- Script Editor, NativeScriptComponent, sandboxing Lua, ECS Lua bindings. +- Particle System, Animation Timeline, Tilemap Editor, Command Palette. +- Material Editor (ShaderGraph + ImNodes), Audio Preview, BuildDialog. +- ProjectStartupDialog com toasts e projetos recentes. + +### Changed +- Refactor dos painéis editor para `.hpp/.cpp` com design por membros. + +--- + +## [2026-05-07 — 2026-05-11] — ECS, sistemas & editor base + +### Added +- **ECS** archetype-based, CommandBuffer, ComponentQuery. +- **EventBus** pub/sub com dispatch diferido. +- **AssetManager** async/sync, cache, hot-reload. +- **BatchRenderer**, TextureAtlas, Camera2D. +- **SceneManager** + SceneSerializer, Physics2D, UI System, Audio (SDL3 spatial). +- Animation2D state machine, MeshLoader OBJ, Asset Pipeline encoders. +- ImGui integration (Profiler, Console, StatsOverlay). +- Quaternion/SLERP, Camera3D (FPS/Orbital/Follow), Octree, Frustum culling. +- Skeletal animation (bones, blend tree). +- Lua scripting (sol2), `caffeine-studio` executable. + +--- + +## [2026-04-24] — Build scripts & binários + +### Added +- Build manager Python, scripts `.sh` / `.bat` / `.fish`. +- Gestão de versões e binários. + +--- + +## [2026-04-14] — Concorrência & RHI + +### Added +- High-resolution Timer (µs). +- Game Loop fixed timestep (60 Hz) + interpolação. +- Input System action-based remapeável. +- Debug tools (Log, Profiler, DebugDraw). +- Job System work-stealing (RF2.2–RF2.6). +- **RHI** sobre SDL_GPU: RenderDevice, CommandBuffer (RF3.1–RF3.2). + +--- + +## [2026-04-07] — Fase 1: Fundação atómica + +### Added +- Tipos core (`u8`–`u64`, `f32`, platform defines). +- Allocators custom (Linear, Pool, Stack, `IAllocator`). +- Containers (`Vector`, `HashMap`, `FixedString`, `StringView`). +- Math (`Vec2`–`Vec4`, `Mat4`). +- CMake + Catch2 test suite. +- CI GitHub Actions (build + test). + +### Changed +- Documentação consolidada em `docs/`, diagramas Mermaid no ROADMAP. + +--- + +## [2026-04-06] — Início do projeto + +### Added +- Repositório inicial, README, dev manifesto, roadmap. +- Estrutura de documentação e planos de fases 0–6. + +--- + + +--- + +## Apêndice — Histórico completo de commits + +> 419 commits desde 2026-04-06 até 2026-09-22. + +### 2026-04 (81 commits) + +- `2026-04-06` [`387aeaa`] Initial commit +- `2026-04-06` [`53b9af6`] Create README.md +- `2026-04-06` [`8d2a6b1`] Update README.md +- `2026-04-06` [`dc6bac5`] Create dev_manifesto.md +- `2026-04-06` [`629d4c1`] Create roadmap.md +- `2026-04-06` [`ee8b146`] Update README.md +- `2026-04-06` [`d8e8abe`] feat/major refactor +- `2026-04-06` [`020038d`] Update README.md +- `2026-04-06` [`068c964`] Rename MASTER.md to README.md +- `2026-04-06` [`5f34a2d`] feat/major refactor +- `2026-04-06` [`d8e8d61`] Update README.md +- `2026-04-06` [`06bdae7`] docs: convert ASCII diagrams to Mermaid in ROADMAP.md +- `2026-04-06` [`8d58880`] feat/major refactor +- `2026-04-07` [`2b97375`] feat(core): add core types and platform definitions +- `2026-04-07` [`339cef7`] feat(memory): add custom allocator system +- `2026-04-07` [`7ffde40`] feat(containers): add custom containers and math types +- `2026-04-07` [`79fa9c9`] feat(build): add main include header and CMake setup +- `2026-04-07` [`ede0f3e`] feat(tests): add test system design doc +- `2026-04-07` [`5fc9cf8`] fix(ci): use correct std versions per compiler +- `2026-04-07` [`73cece3`] fix(ci): use integer std versions (20/17 instead of c++20/c++17) +- `2026-04-07` [`5c469a0`] fix(tests): replace MSVC-only /W4 flags with cross-platform equivalents +- `2026-04-07` [`2933910`] fix(tests): add Catch2 subdirectory to include path +- `2026-04-07` [`92cf593`] fix(containers): move HashMap constructor implementation out of class body +- `2026-04-07` [`adab94f`] fix(tests): qualify all Math namespace functions to avoid ambiguity with std:: +- `2026-04-07` [`ff95152`] fix(tests): fix CF_INLINE test - macro expands to keywords, not value +- `2026-04-07` [`cef1270`] fix(tests): add CATCH_CONFIG_MAIN to provide main() function +- `2026-04-07` [`49cf0d0`] fix(tests): remove benchmarks - Catch2 v2 doesn't support BENCHMARK +- `2026-04-07` [`11fd8d6`] fix(ci): fix executable paths - tests are in build/tests/ +- `2026-04-07` [`c530caa`] simplify(ci): minimal test workflow - build + run only +- `2026-04-07` [`70e8314`] fix(ci): skip stress tests in CI - 1M allocations is too slow +- `2026-04-07` [`f78952f`] fix(containers): add null allocator checks to prevent segfault +- `2026-04-07` [`be79322`] fix(containers): support Vector without allocator - use global new/delete +- `2026-04-07` [`8e6f522`] fix(tests): correct failing test assertions +- `2026-04-07` [`56b6975`] docs(tests): add test summary with architecture principles +- `2026-04-07` [`f858054`] Merge pull request #2 from devscafecommunity/feature/fase-1-test-system +- `2026-04-07` [`3e27864`] docs: consolidate documentation into docs/ directory +- `2026-04-07` [`1456645`] Merge pull request #3 from devscafecommunity/feature/fase-1-atomic-foundation +- `2026-04-07` [`5c2f3fc`] docs: add Phase 1 technical documentation with cross-references +- `2026-04-07` [`29e360d`] docs: add Phase 1 detailed review document +- `2026-04-07` [`e4269f3`] docs: move Phase1 review to docs/reviews/ and add stress test workflow +- `2026-04-07` [`f4ea9b6`] Update docs/containers/vector.md +- `2026-04-07` [`fe41527`] Update docs/containers/vector.md +- `2026-04-07` [`b13753b`] Apply suggestion from @Copilot +- `2026-04-07` [`a6fcaf9`] Apply suggestion from @Copilot +- `2026-04-08` [`62b6d0f`] docs: add 4.2 Caffeine Binary System (.caf) asset format section +- `2026-04-08` [`2dfdaee`] Update README.md +- `2026-04-11` [`aa75781`] Merge pull request #4 from devscafecommunity/feature/fase-1-atomic-foundation +- `2026-04-11` [`c80fb7f`] feat/setup documentation guidelines for job system +- `2026-04-14` [`fea12c2`] feat/setup guide docs +- `2026-04-14` [`6866ed3`] feat(timer): implement high-resolution timer with RF2.1 microsecond precision +- `2026-04-14` [`66a91d1`] Merge pull request #20 from devscafecommunity/9-high-resolution-timer +- `2026-04-14` [`eb4edae`] feat(timer): port high-resolution timer from main branch +- `2026-04-14` [`6a17572`] feat(gameloop): implement fixed timestep game loop with accumulator pattern (RF2.7, RF2.8) +- `2026-04-14` [`04d39cf`] Merge remote-tracking branch 'origin/main' into 11-the-master-game-loop +- `2026-04-14` [`ee19801`] Merge pull request #21 from devscafecommunity/11-the-master-game-loop +- `2026-04-14` [`467d7e5`] feat(input): implement action-based input system with remappable bindings (RF2.9) +- `2026-04-14` [`4f36538`] Merge pull request #24 from devscafecommunity/22-input-system +- `2026-04-14` [`63f116e`] feat/debug tools +- `2026-04-14` [`5275a05`] Merge pull request #25 from devscafecommunity/23-debug-tools +- `2026-04-14` [`9630ee2`] build(rhi): integrate SDL3 dependency with find_package and DLL auto-copy +- `2026-04-14` [`6e3bcb6`] feat(rhi): implement RenderDevice and CommandBuffer abstraction over SDL_GPU (RF3.1, RF3.2) +- `2026-04-14` [`fc2a10c`] build(rhi): make SDL3 optional so CI builds without it +- `2026-04-14` [`166c910`] Merge pull request #46 from devscafecommunity/12-rendering-hardware-interface-rhi +- `2026-04-14` [`7f34033`] feat(threading): implement Job System with work-stealing thread pool (RF2.2-RF2.6) +- `2026-04-14` [`34e9cc8`] Merge pull request #47 from devscafecommunity/10-job-system-worker-threads +- `2026-04-15` [`57c45b0`] docs: consolidate legacy plans/ into fase2-6 structure +- `2026-04-15` [`033b08a`] Merge pull request #49 from devscafecommunity/48-validate-and-standardize-systems +- `2026-04-16` [`d0a8329`] Update README.md +- `2026-04-16` [`c7e3e1d`] docs(readme): update documentation structure with phase details and status +- `2026-04-16` [`13fa742`] Merge branch 'main' into 48-validate-and-standardize-systems +- `2026-04-16` [`c48c78f`] Merge pull request #50 from devscafecommunity/48-validate-and-standardize-systems +- `2026-04-24` [`283b483`] feat: add Python build manager core +- `2026-04-24` [`c86b199`] fix: correct build_manager spec compliance (missing methods, return types, directories) +- `2026-04-24` [`ad2aa24`] feat: add Windows build script (.bat) +- `2026-04-24` [`e5347b0`] feat: add Unix build script (.sh) +- `2026-04-24` [`e6e8873`] docs: add scripts documentation +- `2026-04-24` [`72a8a84`] feat: add Fish shell build script (.fish) +- `2026-04-24` [`c22f359`] feat: add binary management scripts +- `2026-04-24` [`1897f13`] feat: add version manager for binaries +- `2026-04-24` [`3406df0`] docs: add implementation summary for binary management scripts +- `2026-04-24` [`02ca6f7`] Merge pull request #52 from devscafecommunity/51-compilation-and-binary-managment-scripts + +### 2026-05 (329 commits) + +- `2026-05-07` [`67ee38a`] Task 14: Expose ECS Core in Caffeine.hpp public API +- `2026-05-07` [`6e9ba1b`] Task 15: Add comprehensive ECS documentation and examples +- `2026-05-07` [`4761f52`] Add ECS development artifacts and planning documents +- `2026-05-07` [`756de69`] Fix: Include in ComponentSet.hpp to enable std::hash specialization +- `2026-05-07` [`50fe0ee`] Merge pull request #53 from devscafecommunity/32-ecs-core +- `2026-05-07` [`bec1364`] feat(io): implement .caf Caffeine Asset Format (Issue #28) +- `2026-05-07` [`5900f39`] fix(io): address validation findings from engineering requirements audit +- `2026-05-07` [`1cde368`] Merge pull request #54 from devscafecommunity/28-formato-caf-caffeine-asset-format +- `2026-05-08` [`07ec865`] feat(events): implement EventBus with pub/sub, deferred dispatch, and thread-safe queue +- `2026-05-08` [`6143b62`] docs: update events.md and caf-format.md to reflect implemented state +- `2026-05-08` [`67ddda7`] Merge pull request #55 from devscafecommunity/17-event-bus +- `2026-05-08` [`ec86b12`] feat(assets): implement AssetManager with async/sync loading, cache, and hot-reload +- `2026-05-08` [`209b1fe`] Merge pull request #56 from devscafecommunity/26-asset-manager +- `2026-05-08` [`c787dcb`] feat(render): implement Camera2D system (RF3.7) +- `2026-05-08` [`6c1f893`] Merge pull request #57 from devscafecommunity/29-camera-system +- `2026-05-08` [`8f228ed`] feat(render): implement BatchRenderer and TextureAtlas (#27) +- `2026-05-08` [`91e3d68`] Merge pull request #58 from devscafecommunity/27-batch-renderer +- `2026-05-08` [`5835df2`] feat(scene): implement SceneManager and SceneSerializer (RF4.5) +- `2026-05-08` [`33fbe84`] fix(scene): remove Color static const members, fix GCC default arg error +- `2026-05-08` [`03fa4f3`] fix(scene): add explicit default ctor to TransitionConfig for GCC compat +- `2026-05-08` [`14acfd3`] fix(scene): move TransitionType+TransitionConfig to namespace scope, fix GCC nested class error +- `2026-05-08` [`4ec9f23`] Merge pull request #59 from devscafecommunity/34-scene-manager +- `2026-05-08` [`e858991`] feat(physics): implement Physics2D system (RF4.10) +- `2026-05-08` [`fb69c78`] fix(physics): qualify ComponentQuery as ECS::ComponentQuery for GCC +- `2026-05-08` [`c7da097`] fix(physics): use std::fabs/sqrt/floor/fmin/fmax for GCC compatibility +- `2026-05-08` [`645d402`] fix(physics): fix std::fabsf in test file for GCC +- `2026-05-08` [`6ddd514`] fix(physics): replace Entity::get() with World::get(entity) for GCC compat +- `2026-05-08` [`8c69ef4`] fix(physics): fix layerMask test - set collider layerMask to exclude query layer +- `2026-05-08` [`a97fb1f`] Merge pull request #60 from devscafecommunity/33-physics-2d +- `2026-05-08` [`90970b6`] feat(ui): implement UI system — RF4.11 +- `2026-05-08` [`412d36f`] fix(ui): replace c_str() with cStr() for FixedString GCC compat +- `2026-05-08` [`145fe34`] fix(ui): exclude Canvas from hitTest — layout roots are not interactive targets +- `2026-05-08` [`c143f95`] Merge pull request #61 from devscafecommunity/35-ui-system +- `2026-05-08` [`416cee1`] feat(audio): implement AudioSystem with SDL3 streaming and spatial 2D audio +- `2026-05-08` [`d191486`] Merge pull request #62 from devscafecommunity/31-audio-system +- `2026-05-08` [`7dc9ae7`] feat(animation): implement AnimationSystem with sprite state machine (RF4.9) +- `2026-05-08` [`1e2e05d`] Merge pull request #63 from devscafecommunity/30-animation-system +- `2026-05-08` [`30096cb`] feat(mesh): implement MeshLoader with OBJ parser and MeshSystem (RF5.2) +- `2026-05-08` [`b69fcfa`] Merge pull request #64 from devscafecommunity/40-mesh-loading +- `2026-05-08` [`68589d8`] feat(editor): implement Dear ImGui integration with ProfilerWindow, ConsoleWindow, StatsOverlay (RF6.1, RF6.2) +- `2026-05-08` [`6fb645c`] Merge pull request #65 from devscafecommunity/42-embedded-ui-dear-imgui +- `2026-05-08` [`dbc1559`] feat(pipeline): implement Asset Pipeline with TextureEncoder, AudioEncoder, MeshEncoder, AssetManifest (RF6.5) +- `2026-05-08` [`055f3bc`] fix(pipeline): fix AssetManifest load parsing multiple entries +- `2026-05-09` [`31e055a`] fix(pipeline): fix AssetManifest load parsing of string fields (id, path, type) +- `2026-05-09` [`928cc4c`] fix(pipeline): remove orphaned brace from AssetManifest load +- `2026-05-09` [`e41c8d9`] Merge pull request #66 from devscafecommunity/41-asset-pipeline +- `2026-05-09` [`bc46871`] feat(math): implement Quaternion with SLERP/NLERP, Euler, axis-angle (RF5.1) +- `2026-05-10` [`69bc719`] Update README.md +- `2026-05-10` [`5669b0f`] Update README.md +- `2026-05-10` [`6aa80de`] fix(tests): correct Quat toEuler pitch/yaw, FP comparisons, PoolAllocator assertions, CF_ASSERT signal handling +- `2026-05-10` [`4209091`] Merge pull request #67 from devscafecommunity/36-3d-math-extension +- `2026-05-10` [`46af94d`] feat(editor): implement EditorContext and NameComponent +- `2026-05-10` [`0be4783`] feat(editor): implement Hierarchy, Inspector, Viewport, and AssetBrowser panels +- `2026-05-10` [`b0ee6a5`] feat(editor): implement SceneEditor orchestrator +- `2026-05-10` [`d0d272d`] feat(editor): integrate editor includes into Caffeine.hpp and mark RF6.3/RF6.4 complete +- `2026-05-10` [`f0130e2`] Merge pull request #68 from devscafecommunity/43-scene-editor +- `2026-05-10` [`c461c5c`] feat(spatial): implement Octree with AABB3D, Frustum, and spatial queries +- `2026-05-10` [`ef1b64b`] feat(spatial): integrate Octree module into main engine header +- `2026-05-10` [`97fb937`] docs(spatial): update spatial partitioning documentation for Octree module +- `2026-05-10` [`1025e0b`] Merge pull request #69 from devscafecommunity/43-scene-editor +- `2026-05-10` [`97af1c6`] feat(math): add Mat4::inverted() using cofactor expansion +- `2026-05-10` [`228162c`] feat(camera3d): implement Camera3D with FPS, Orbital, Follow modes and frustum culling +- `2026-05-10` [`c556d52`] test(camera3d): add 20+ test cases for Camera3D +- `2026-05-10` [`6de934c`] fix(camera3d): correct Mat4::inverted() transpose bug and Camera3D sync directions +- `2026-05-10` [`b364116`] Merge pull request #70 from devscafecommunity/37-camera-3d +- `2026-05-10` [`e305f90`] feat(skeletal-animation): implement Bone/Skeleton, keyframe interpolation, blend tree, and ECS system +- `2026-05-10` [`b1efbc3`] Merge pull request #71 from devscafecommunity/39-skeletal-animation +- `2026-05-11` [`578605a`] feat(script): implement Lua scripting with sol2 and fix SIGSEGV from archetype mutation +- `2026-05-11` [`f6f5ecf`] Merge pull request #72 from devscafecommunity/39-skeletal-animation +- `2026-05-11` [`e32eb57`] docs: remove old phase-based documentation structure +- `2026-05-11` [`70848a6`] docs: reorganize documentation into functional modules +- `2026-05-11` [`9ccda52`] docs: add Caffeine Studio IDE planning (M1-M4 milestones) +- `2026-05-11` [`493b651`] Merge pull request #73 from devscafecommunity/docs/docs-reorganization +- `2026-05-11` [`cdf9b4f`] ci: add release workflow for v0.0.1-beta with core, ide, and bundle artifacts +- `2026-05-11` [`bf024d7`] ci: fix release workflow - separate core/full builds to avoid ImGui API errors +- `2026-05-11` [`f78401f`] feat(ide): add caffeine-studio executable and fix ImGui 1.91.9 API compatibility +- `2026-05-11` [`76c5db5`] fix(ide): fix ImGui 1.91.9 API compat and AssetManager constructor +- `2026-05-11` [`1848649`] fix(ide): change InspectorPanel HashMap key from ComponentID to u32 +- `2026-05-14` [`5bb352b`] docs(planning): add T0 - core/IDE decoupling into caffeine-engine and doppio +- `2026-05-14` [`1e4a782`] refactor(build): split into caffeine-core library and doppio IDE executable (T0) +- `2026-05-14` [`d6f939f`] fix(tools): add missing include in TextureEncoder.hpp +- `2026-05-14` [`80aaa56`] Merge pull request #82 from devscafecommunity/74-caffeine-studio-ide-plano-de-desenvolvimento +- `2026-05-14` [`881e136`] feat(editor): implement EditorContext with snapshot-based undo/redo +- `2026-05-14` [`4c924cd`] Merge pull request #83 from devscafecommunity/75-editorcontext-undoredo +- `2026-05-14` [`7b425ed`] feat(editor): implement dedicated HierarchyPanel with search, context menu, delete key +- `2026-05-14` [`806a30b`] Fix build: add missing HierarchyPanel.hpp include in test_editor.cpp +- `2026-05-14` [`98df3de`] Merge pull request #84 from devscafecommunity/76-hierarchy-panel +- `2026-05-14` [`7361406`] refactor(editor): split SceneViewport into .hpp/.cpp with member-based design (RF6.4) +- `2026-05-14` [`1fc0865`] Merge pull request #85 from devscafecommunity/78-scene-viewport +- `2026-05-14` [`e0e4bbe`] refactor(editor): split AssetBrowser into .hpp/.cpp with filesystem navigation (RF6.5) +- `2026-05-14` [`5b91eda`] Merge pull request #86 from devscafecommunity/79-asset-browser-v1 +- `2026-05-14` [`c27ae3e`] refactor(editor): split InspectorPanel into .hpp/.cpp with component drawers (RF6.3) +- `2026-05-14` [`eb74478`] Merge pull request #87 from devscafecommunity/77-inspector-panel +- `2026-05-14` [`c262926`] feat(editor): split SceneEditor into .hpp/.cpp with dockspace, menus, console, profiler (RF6.6) +- `2026-05-14` [`9cdd3c2`] Merge pull request #88 from devscafecommunity/80-scene-editor-orchestrator +- `2026-05-14` [`64f0f86`] feat(editor): SceneSerializer with binary save/load + unsaved changes popup (RF6.7) +- `2026-05-14` [`0a07a0e`] Merge pull request #89 from devscafecommunity/81-saveload-scene +- `2026-05-14` [`ede11e9`] docs: add Transform Gizmos design document +- `2026-05-14` [`2dc4298`] docs: add Transform Gizmos implementation plan +- `2026-05-14` [`671a48d`] feat(editor): implement Transform Gizmos with translate/rotate/scale modes +- `2026-05-14` [`cbb395c`] Merge origin/main into 39-skeletal-animation +- `2026-05-14` [`52ce138`] fix: remove glm dependency, use Caffeine::Vec2/Vec3 instead +- `2026-05-14` [`ddbd1c4`] Merge pull request #97 from devscafecommunity/39-skeletal-animation +- `2026-05-15` [`0260fdf`] feat(editor): DragDropSystem with AssetBrowser→Viewport and Inspector asset drops (RF6.2) +- `2026-05-15` [`227a87e`] chore: retrigger CI +- `2026-05-15` [`77b0c05`] Merge pull request #98 from devscafecommunity/91-drag-and-drop +- `2026-05-15` [`4431659`] docs: add Asset Pipeline design document +- `2026-05-15` [`dc842ae`] feat(core): add native Windows FileWatcher (ReadDirectoryChangesW) +- `2026-05-15` [`36aa01d`] feat(assets): add AssetPipeline with IAssetCompiler interface and manifest system +- `2026-05-15` [`d4be1cd`] feat(assets): add TextureCompiler with stb_image PNG/JPG to .caf conversion +- `2026-05-15` [`4b3c025`] fix(core): guard FileWatcher Windows API behind _WIN32 for cross-platform build +- `2026-05-15` [`15ff33d`] Merge pull request #99 from devscafecommunity/92-asset-pipeline-integration +- `2026-05-15` [`e095133`] feat(assets): implement HotReloader for .caf asset hot-reloading +- `2026-05-15` [`03dd9f2`] docs: add HotReloader design document +- `2026-05-15` [`fb3eb55`] Merge pull request #100 from devscafecommunity/93-hot-reload-de-assets +- `2026-05-15` [`a9ed4b3`] feat(editor): implement ProjectManager for project lifecycle +- `2026-05-15` [`5aec7d4`] fix: add missing include for std::find on GCC/Linux +- `2026-05-15` [`65d7246`] Merge pull request #101 from devscafecommunity/94-project-manager +- `2026-05-15` [`8292dc7`] feat(editor): implement AssetBrowser v2 with data/UI separation +- `2026-05-15` [`a3e5119`] Merge pull request #102 from devscafecommunity/95-asset-browser-v2 +- `2026-05-15` [`137e577`] feat(editor): add SceneTabManager with data/UI layers +- `2026-05-15` [`7301491`] feat(editor): integrate SceneTabManager into SceneEditor +- `2026-05-15` [`2aa4a9b`] test(editor): add SceneTabManager tests +- `2026-05-15` [`81eaf5a`] Merge pull request #103 from devscafecommunity/96-multiple-scene-tabs +- `2026-05-15` [`466de9e`] build: add caffeine-ui interface library and caffeine-combined executable targets +- `2026-05-15` [`a071c6d`] fix(assets,audio): fix MeshLoader createBuffer signature and SDL3 audio device constant +- `2026-05-15` [`858c074`] fix(rhi): fix frame lifecycle — acquire swapchain in beginFrame, create real render pass in beginRenderPass +- `2026-05-15` [`88fbcc4`] fix(editor): fix doppio startup crash — enable docking, guard null viewport texture, add imgui_internal +- `2026-05-15` [`3a91665`] feat(apps): add caffeine-combined app demonstrating core + ui integration +- `2026-05-15` [`3c9885a`] Merge pull request #104 from devscafecommunity/96-multiple-scene-tabs +- `2026-05-15` [`30eff55`] feat(editor): add native Script Editor for .lua files +- `2026-05-15` [`737d0cd`] fix(doppio): fix SceneViewport default Config + add ScriptEditorWindow to build +- `2026-05-15` [`fa32f00`] Merge pull request #113 from devscafecommunity/105-31-lua-vm +- `2026-05-15` [`b03e195`] feat(script): add NativeScriptComponent for C++ scripts +- `2026-05-15` [`e620b92`] Merge pull request #114 from devscafecommunity/106-32-script-component-system +- `2026-05-15` [`a00f557`] feat(script): complete sandboxing implementation +- `2026-05-15` [`824c359`] Merge pull request #115 from devscafecommunity/108-34-sandboxing +- `2026-05-15` [`ca8dbc1`] feat(script): add addTransform/addSprite aliases to match spec API +- `2026-05-15` [`cd301c2`] Merge pull request #116 from devscafecommunity/107-33-ecs-lua-bindings +- `2026-05-15` [`0122d6b`] feat(editor): add ScriptComponent to InspectorPanel +- `2026-05-16` [`b78c2d1`] Merge pull request #117 from devscafecommunity/110-36-editor-script-integration +- `2026-05-16` [`fb3853e`] feat(graphics): add Particle System +- `2026-05-16` [`47e431e`] Merge pull request #118 from devscafecommunity/111-37-particle-system +- `2026-05-16` [`a0dfb46`] feat(editor): add Animation Timeline Editor panel +- `2026-05-16` [`a982a0d`] Merge pull request #127 from devscafecommunity/119-41-animation-timeline-editor +- `2026-05-16` [`a615c03`] feat(editor): add Tilemap Editor panel +- `2026-05-16` [`ec33ede`] Merge pull request #128 from devscafecommunity/120-42-tilemap-editor +- `2026-05-16` [`7e93ba4`] feat(editor): add Command Palette +- `2026-05-16` [`55a6eda`] feat(editor): integrate new panels into SceneEditor +- `2026-05-16` [`5e090b1`] fix: suppress unused parameter warnings +- `2026-05-16` [`46f12b5`] fix: resolve CI build warnings and test failures +- `2026-05-16` [`9aae1b8`] Merge pull request #129 from devscafecommunity/feature/command-palette +- `2026-05-16` [`59f7db8`] docs: add Material & Shader Editor design document +- `2026-05-16` [`4a292f8`] feat(editor): add ShaderNode hierarchy with code generation +- `2026-05-16` [`9b58e63`] feat(editor): add ShaderGraph with GLSL compilation +- `2026-05-16` [`458804b`] feat(editor): add Material Editor panel with ImNodes graph +- `2026-05-16` [`380282a`] feat(editor): add PreviewRenderer with ImGui fallback +- `2026-05-16` [`b108fcf`] feat(editor): add edge case handling for node deletion and empty graph +- `2026-05-16` [`00e2dce`] docs: add implementation plan, remove fmt dependency from shader files +- `2026-05-16` [`93348df`] Merge branch 'main' into 122-43-material-shader-editor +- `2026-05-16` [`90f6d9a`] Merge pull request #130 from devscafecommunity/122-43-material-shader-editor +- `2026-05-16` [`8ae3c74`] feat(editor): add Audio Preview & Spatial Placement (#121) +- `2026-05-16` [`7fbca5d`] fix(editor): add ImNodes CreateContext and fix query ordering to prevent MaterialEditorPanel crash +- `2026-05-16` [`8400763`] test: add E2E testing infrastructure with imgui_test_engine and DoppioTest +- `2026-05-16` [`81855ed`] docs: complete frontend audit - implement drawRigidBody2D, document unfinished stubs +- `2026-05-16` [`477d959`] feat: add universal build script for easy project compilation +- `2026-05-16` [`d65b19b`] docs: add BuildSystem Integration design document +- `2026-05-16` [`80d5611`] feat: implement BuildSystem integration with 7-stage pipeline, BuildDialog UI, and AssetCooker stubs +- `2026-05-16` [`81e8245`] fix: add imgui_test_engine sources to doppio executable linking +- `2026-05-16` [`0bf8067`] feat: implement ProjectStartupDialog - critical project startup workflow +- `2026-05-16` [`c1c7d67`] feat: add drag-drop support to ScriptEditorWindow - drag .lua files from Asset Browser +- `2026-05-16` [`0413395`] feat: add drag-drop support to AudioPreviewPanel - drag .wav/.ogg files from Asset Browser +- `2026-05-16` [`60d5957`] feat: implement AnimationTimeline playback with delta time - add frame timing and advance animations during render +- `2026-05-16` [`238637f`] feat: implement TilemapEditor visual grid canvas with tile painting and tools - adds interactive grid display with brush, bucket, eraser, and picker tools +- `2026-05-16` [`e118639`] fix: use BeginPopupModal for ProjectStartupDialog to ensure proper rendering +- `2026-05-16` [`d4e48d7`] Fix ProjectStartupDialog: switch from modal popup to regular window, simplify UI +- `2026-05-16` [`a135349`] Fix: ProjectStartupDialog ImGui rendering +- `2026-05-16` [`4cb2f45`] docs: add ProjectStartupDialog tabs design specification +- `2026-05-16` [`a4aef38`] docs: add ProjectStartupDialog tabs implementation plan +- `2026-05-16` [`6ef22e6`] feat: add toast notification data structure to ProjectStartupDialog +- `2026-05-16` [`946af26`] feat: implement toast notification methods (showToast, updateToasts) +- `2026-05-16` [`af1a708`] feat: implement toast notification rendering with ImGui +- `2026-05-16` [`fa5b334`] feat: refactor render() into tab-based architecture with BeginTabBar +- `2026-05-16` [`6e7b7f9`] feat: add recent projects state variables to ProjectStartupDialog +- `2026-05-16` [`690affe`] feat: implement Open Recent tab with search filtering +- `2026-05-16` [`4f1031f`] feat: implement Browse Projects tab with results list +- `2026-05-16` [`27177fc`] feat: implement file picker for project creation and browsing +- `2026-05-16` [`ba207d4`] fix: prevent state pollution between multiple file pickers +- `2026-05-16` [`6096d96`] fix: file picker state management and integration +- `2026-05-16` [`67e85c5`] fix: prevent file picker from re-opening after cancellation +- `2026-05-16` [`ec1a61b`] fix: call ImGui::End() regardless of Begin() return value +- `2026-05-16` [`feea0ee`] fix: ImGui ID conflicts in Open Recent and Browse tabs +- `2026-05-16` [`107ed2c`] fix: close ProjectStartupDialog when project is selected +- `2026-05-16` [`adf49c4`] docs: add comprehensive session summary for ProjectStartupDialog implementation +- `2026-05-17` [`23d3f57`] chore: add .worktrees/ to gitignore +- `2026-05-17` [`fc6094d`] docs: add three-phase design for doppio editor integration + phase 2-3 features +- `2026-05-17` [`45db648`] docs: add detailed three-phase implementation plan with bite-sized tasks +- `2026-05-17` [`fc4c941`] feat: add CAP file browsing mode to Asset Browser +- `2026-05-17` [`9abec52`] feat: add audio waveform generator for Asset Browser previews +- `2026-05-17` [`715a95c`] feat: add drag-drop auto-pack import to Asset Browser +- `2026-05-17` [`b2e2108`] feat: add placeholder for game.cap auto-load on project open +- `2026-05-17` [`5c2187b`] feat: add Phase 1 integration tests with Catch2 +- `2026-05-17` [`bb5135d`] chore: update caf-pack submodule reference (Task 2.1: Mesh Processor) +- `2026-05-17` [`7ed1c73`] chore: update caf-pack submodule reference (Task 2.2: Compression) +- `2026-05-17` [`24a517c`] Submodule: Update caf-pack to include Task 2.3 (Asset ID Header Generator) +- `2026-05-17` [`4ad5118`] Task 3.1: Implement async asset loading infrastructure +- `2026-05-17` [`33ec08d`] Task 3.2: Document complete ecosystem workflow +- `2026-05-17` [`0c7f14d`] Task 3.3: Add Phase 2 & 3 verification tests +- `2026-05-17` [`9103dc0`] fix: add missing CapLoader and AudioWaveformRenderer to DoppioTest +- `2026-05-17` [`2a5de94`] fix: update caf-pack submodule to use FNV-1a per specification +- `2026-05-17` [`4596a2d`] feat: add grid rendering to Scene Viewport +- `2026-05-17` [`d5fb975`] feat: add Camera2D and Camera3D components +- `2026-05-17` [`45b9d6b`] feat: add Light components (Directional, Point, Spot) +- `2026-05-17` [`c5f5bd0`] feat: add MeshRenderer components +- `2026-05-17` [`770c4a0`] feat: add entity type selector UI to Hierarchy Panel +- `2026-05-17` [`99152ef`] fix: rename ECS components to avoid namespace collisions +- `2026-05-18` [`6eaad41`] Fix namespace collision and build errors - remove using namespace Caffeine from editor headers +- `2026-05-18` [`fa7b949`] Fix caf-pack subdirectory reference in CMakeLists.txt +- `2026-05-18` [`0ccdfb8`] fix: make caf-pack submodule optional in CMake build +- `2026-05-18` [`57bd09d`] fix: make editor dependencies optional for headless build +- `2026-05-18` [`88d768a`] fix: wrap caf-pack dependencies in AssetBrowser and disable tests +- `2026-05-18` [`e097e67`] fix: resolve ProjectStartupDialog linker errors in doppio +- `2026-05-18` [`2767410`] fix: enable FilePicker on Windows for Browse and Recent tabs +- `2026-05-18` [`40a575b`] feat: apply cleaner default layout profile on IDE startup +- `2026-05-18` [`05ef449`] fix: increase FilePicker window size to reduce scrolling +- `2026-05-18` [`417a0d4`] feat: enable Lua scripting by default for editor build +- `2026-05-18` [`ac7125a`] feat(editor): add play mode state and system members to SceneEditor +- `2026-05-18` [`d30b98d`] feat(editor): implement play/pause/stop mode with entity snapshot and system ticking +- `2026-05-18` [`dd6e12f`] feat(editor): add physics collider debug overlay to scene viewport +- `2026-05-18` [`ae5161d`] feat(editor): add script component inspector drawer with path and load button +- `2026-05-18` [`1f47794`] feat(editor): add tileset asset loading with texture-based tile palette +- `2026-05-18` [`59929fa`] feat(editor): wire tileset load command into command palette +- `2026-05-18` [`5f33437`] feat(editor): add Collider2D, Velocity2D and Health inspector drawers +- `2026-05-18` [`52ab107`] fix(editor): sync recent projects list from ProjectManager on every render +- `2026-05-18` [`b0ddf27`] feat: multi-select, copy/paste/duplicate, physics debug toggle, snap to grid, undo coverage, settings apply +- `2026-05-18` [`71ccdf6`] feat: add GameObject primitives, UI components, and UISystem integration +- `2026-05-18` [`3cd1d10`] docs: add Inspector 2.0 implementation plan +- `2026-05-18` [`092717e`] feat(ecs): add Transform component and DisabledTag +- `2026-05-18` [`0bdef6d`] feat(editor): add InspectorWidgets helper library +- `2026-05-18` [`71eef32`] feat(editor): add ComponentRegistry with all component entries +- `2026-05-18` [`7b4148c`] feat(inspector): unified Transform drawer and ComponentHeader lifecycle +- `2026-05-18` [`79fea16`] feat(inspector): asset picker fields for Sprite, AudioSource, MeshFilter +- `2026-05-18` [`8d97efe`] feat(inspector): searchable Add Component menu via ComponentRegistry +- `2026-05-18` [`9790e25`] feat(hierarchy): new entities created with Transform component +- `2026-05-19` [`70c0a23`] fix: 4 editor bugs - transform sync, gizmo, component popup, tab flickering +- `2026-05-19` [`de326a0`] feat: dual script system - Lua and C++ scripts +- `2026-05-19` [`60f61e2`] refactor: move game scripts from scripts/ to assets/scripts/ +- `2026-05-19` [`3191715`] feat(editor): move play/pause/stop buttons into main menu bar +- `2026-05-19` [`b03e83f`] fix(editor): add missing Position2D constraint in enterPlayMode query to prevent segfault +- `2026-05-19` [`2e4979c`] feat(editor): empty entities now include Transform/Position2D/Rotation/Scale2D by default +- `2026-05-19` [`d4101f3`] feat(viewport): draw diamond marker for empty entities with Position2D but no Sprite +- `2026-05-19` [`59434b2`] fix(editor): add Transform/Position2D to entities created via toolbar + button +- `2026-05-19` [`d035dac`] feat(editor): collider debug color per-collider + ColorEdit4 in inspector +- `2026-05-19` [`800b1aa`] fix(physics): remove Collider2D dependency from integrate/sleep; auto-add Velocity2D with RigidBody2D +- `2026-05-19` [`454fef1`] fix(collider): world-unit sizes — default 1x1, drag step 0.01 so colliders appear at correct scale +- `2026-05-19` [`cdc2ad4`] fix(physics): correct gravity (-9.81 not *60), disable sleep under gravity, lower sleep threshold to 0.05 +- `2026-05-19` [`b781a2f`] fix(collider): default size 1x1 radius 0.5 in ComponentRegistry (was hardcoded 64x64) +- `2026-05-19` [`1d88045`] feat(editor): Camera Preview dock panel — shows scene from camera POV, No Cameras detected fallback +- `2026-05-19` [`1563e7a`] chore: remove stale example script files +- `2026-05-20` [`ad87924`] feat(editor): Camera2D auto-adds Position2D; draw camera frustum in scene viewport +- `2026-05-20` [`ce447ae`] feat(ecs): unify Transform component, remove Position2D/Rotation/Scale2D +- `2026-05-20` [`5b6e004`] feat(editor): add ViewMode + camera orbit state to EditorContext +- `2026-05-20` [`1005469`] feat(viewport): 3D/2D/Iso modes, XYZ gizmos, orbit camera, 3D grid +- `2026-05-20` [`08b802a`] fix(viewport): infinite grid, 3D pan, projected gizmo axes +- `2026-05-20` [`654a565`] fix(viewport): extend grid to horizon, normalize gizmo axis lengths +- `2026-05-20` [`2269540`] fix(viewport): depth-clip grid, arrow key navigation, revert middle mouse +- `2026-05-20` [`874e3e2`] fix(viewport): arrow key ux, grid quadrant, gizmo Z fallback, nav widget label +- `2026-05-20` [`1467b5c`] fix(viewport): near-plane clip grid lines instead of skipping +- `2026-05-20` [`2897f47`] fix(viewport): camera-oriented mini gizmo, gizmo Z/Y overlap guard, arrow key directions +- `2026-05-20` [`13d241a`] docs: add formal LaTeX specification for Caffeine Engine internals +- `2026-05-20` [`d478a11`] refactor(docs): split caffeine-internals into folder structure with sub-files +- `2026-05-20` [`2745396`] fix(docs): prevent orphan headings and split lists with needspace + widow/club penalties +- `2026-05-20` [`91d2b3d`] docs: complete caffeine-internals LaTeX spec with 11 new chapters +- `2026-05-20` [`b455de7`] fix(editor): TransformGizmo absolute drag, MaterialEditorPanel layout + fix(docs): LaTeX errors, add provisional logo to cover +- `2026-05-20` [`f118777`] Changes +- `2026-05-21` [`94be56e`] docs(latex): fix LaTeX syntax errors in specbox environments and compile PDF +- `2026-05-21` [`a6a7662`] fix(gizmo): world-space axes \u2014 remove hardcoded screen-space fallback, show dot when axis collapses toward camera +- `2026-05-21` [`bf30b8b`] fix(editor): TransformGizmo analytic foreshortening for 3D world-space axes +- `2026-05-21` [`21df694`] fix(editor): drawGizmo 3D analytic foreshortening with depth sort and alpha +- `2026-05-21` [`f249394`] feat(editor): gizmo 3D rotate rings, axis hover highlight, X/Y/Z key constraint +- `2026-05-21` [`50a45a6`] fix(editor): gizmo local space + inspector 3D rotation/scale fields +- `2026-05-21` [`12d4e94`] feat(editor): entity hierarchy with transform inheritance +- `2026-05-21` [`56092cd`] feat(editor): auto-expand parent after drag-drop + Unparent action +- `2026-05-21` [`943c412`] feat(hierarchy): transform/disabled/layer inheritance +- `2026-05-22` [`b63c0b1`] feat(animation): named parameter system with SetBool/SetFloat/SetTrigger and HashMap range-for +- `2026-05-22` [`5f59e97`] feat(editor): AnimationTimeline ruler, scrubber and keyframe diamonds +- `2026-05-22` [`1376a53`] feat(editor): AnimatorController window with state machine canvas and internal animator +- `2026-05-22` [`a0b511d`] feat(editor): wire AnimatorController into SceneEditor docking, command palette and View menu +- `2026-05-22` [`56fe630`] refactor(ecs): remove game-specific Health struct and migrate Velocity2D into RigidBody2D +- `2026-05-22` [`a90dcbd`] refactor(editor): remove Health and Velocity2D from inspector, registry, serializer and hierarchy +- `2026-05-22` [`ee2c2e4`] refactor(engine): remove Health and Velocity2D from scene serializer and Lua scripting bindings +- `2026-05-22` [`d9fbb23`] feat(editor): AssetBrowser updates +- `2026-05-22` [`d9fb901`] docs(animation): add scripting reference chapter 11 covering animation API +- `2026-05-23` [`bf94a16`] fix: support Position3D/Rotation3D in light gizmo rendering +- `2026-05-23` [`4ab0a9d`] docs: add chapter on polygons and 3D representations to internal reference +- `2026-05-23` [`3fb5eaa`] feat: implement glTF/glb mesh encoding to .caf format +- `2026-05-23` [`351e759`] feat: add mesh asset resolution to runtime AssetManager +- `2026-05-23` [`e925027`] feat: add prefab asset resolution to runtime AssetManager +- `2026-05-23` [`95c61e1`] feat: enable mesh drag-and-drop in scene viewport +- `2026-05-23` [`2892631`] feat: add 'Save as Prefab' command to inspector +- `2026-05-23` [`01aa9cd`] fix: update PrefabSerializer to use CAF format instead of custom binary +- `2026-05-23` [`3ee31f7`] feat: complete mesh and prefab pipeline wiring with editor integration +- `2026-05-23` [`883b45a`] wiring: integrate mesh and prefab components with editor systems +- `2026-05-23` [`0f33e1d`] feat: Implement 3D glTF mesh rendering in viewport +- `2026-05-23` [`03d5d30`] feat: add mesh caching system to avoid reloading per frame +- `2026-05-23` [`dbc6319`] feat: integrate mesh cache into viewport rendering +- `2026-05-23` [`0d222a3`] feat: add filled geometry rendering with semi-transparent blue fill +- `2026-05-23` [`d6568a9`] feat: load and display embedded glTF textures in viewport +- `2026-05-23` [`826fae3`] feat: add LOD level generation framework for mesh simplification +- `2026-05-23` [`53bfd40`] docs: add detailed implementation plan for gizmo raycasting with VP-inverse +- `2026-05-23` [`6737367`] feat(gizmo): add screenToWorldRay helper for VP-inverse raycasting +- `2026-05-23` [`a4dd94e`] feat(gizmo): add rayToAxisSegmentDistance for finite axis picking +- `2026-05-23` [`bafbc47`] feat(gizmo): integrate raycasting into intersectTest for world-space picking +- `2026-05-23` [`561ec8e`] fix(gizmo): add edge case handling for VP inverse and axis length validation +- `2026-05-23` [`9a569f3`] docs(gizmo): add performance & threshold tuning analysis +- `2026-05-23` [`3d8c1a9`] feat(selection): add rayIntersectsAABB and raycastSelectEntity functions +- `2026-05-23` [`3044f14`] feat(selection): integrate click detection and raycasting into viewport +- `2026-05-23` [`6f200fc`] fix(selection): add edge case handling for invalid AABBs and point entities +- `2026-05-23` [`f76d062`] feat(editor): add delete key to remove selected entity with undo +- `2026-05-23` [`f3e8a10`] feat(editor): add multi-select with Shift+Click (toggle selection) +- `2026-05-23` [`638db40`] feat(editor): add double-click to focus camera on selected entity +- `2026-05-24` [`3ce2bf0`] feat: add TestUIMapper and TestRequestHandler headers for UI test framework +- `2026-05-24` [`ccb42ea`] feat: implement TestUIMapper::captureViewportState and clickAtCoordinate +- `2026-05-24` [`91b0f7e`] feat: implement TestRequestHandler JSON parsing and response generation +- `2026-05-24` [`2d0886b`] feat: integrate TestRequestHandler into SceneViewport render loop +- `2026-05-24` [`3b7346c`] feat: add JSON-based UI test framework with headless Doppio +- `2026-05-24` [`5950274`] Content update +- `2026-05-26` [`38668c8`] fix: correct 3D rendering pipeline and add automated UI tests +- `2026-05-29` [`0a7146b`] fix(math): correct Mat4::transformVector() for non-diagonal matrices + +### 2026-09 (9 commits) + +- `2026-09-18` [`716a990`] feat(editor): add build pipeline, runtime, plugins, and editor UX fixes +- `2026-09-18` [`72cc4dc`] feat(editor): polish project workflow, asset browser, and editor UX +- `2026-09-18` [`461971e`] Major changes +- `2026-09-18` [`90abf19`] major fix +- `2026-09-19` [`33b9bd2`] major fix +- `2026-09-20` [`60e83a2`] major fix +- `2026-09-21` [`d9aa52d`] updating documentation +- `2026-09-22` [`ba774aa`] Add optional editor plugin SDK and move feature tooling out of Doppio core. +- `2026-09-22` [`cfbc012`] Improve GPU viewport rendering quality, shadows, and texture LOD. diff --git a/README.md b/README.md index 57ac06a..35cfc1e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Caffeine é uma engine de jogos desenvolvida em **C++** sobre a camada do **SDL3 | Documento | Descrição | Link | |-----------|-----------|------| +| **CHANGELOG.md** | Histórico de alterações e commits | [`CHANGELOG.md`](CHANGELOG.md) | | **MASTER.md** | Documentação unificada completa | [`docs/MASTER.md`](docs/MASTER.md) | | **ROADMAP.md** | Roadmap das 6 fases de desenvolvimento | [`docs/ROADMAP.md`](docs/ROADMAP.md) | | **SPECS.md** | Regras e padrões de desenvolvimento | [`docs/SPECS.md`](docs/SPECS.md) | diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 61b16d0..26f1537 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -2,6 +2,15 @@ > Artefatos de desenvolvimento comprimidos — fases, roadmaps, planos e revisões. > Para documentação técnica real da engine, consulte o [`README.md`](README.md). +> Para o registo cronológico completo de commits, consulte o [`CHANGELOG.md`](../CHANGELOG.md) na raiz do repositório. + +--- + +## Performance — Editor Viewport (2026-09-23) + +Investigação de FPS no Doppio: **~10 FPS → ~100 FPS** após corrigir redundância em `GpuSceneRenderer::draw` (shadow UBO + binds por chunk de terreno), LOD/cap de resolução, skybox throttle, separação GPU/CPU por frame. + +**Documentação:** [`performance/editor-viewport-performance.md`](performance/editor-viewport-performance.md) --- diff --git a/docs/README.md b/docs/README.md index 9542dde..35b27e2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ | **Rendering Roadmap** | [`plans/2026-09-21-rendering-roadmap.md`](plans/2026-09-21-rendering-roadmap.md) | Plano P0–P6: GPU único, texturas, LOD, PBR, sombras, reflexos, volumétricos | | **Sessão 2026-09-22 (Phong)** | [`plans/2026-09-22-phong-gpu-handles-session.md`](plans/2026-09-22-phong-gpu-handles-session.md) | Changelog: Phong GPU, sombras, handles, normal maps, inspector | | **Sessão 2026-09-22 (Viewport)** | [`plans/2026-09-22-viewport-rendering-quality-session.md`](plans/2026-09-22-viewport-rendering-quality-session.md) | HiDPI, wireframe GPU, LOD texturas, performance câmara | +| **Performance — Editor Viewport** | [`performance/editor-viewport-performance.md`](performance/editor-viewport-performance.md) | FPS Doppio, GPU/CPU exclusivo, sombras, profiler, SIGSEGV NVIDIA | ### Engine Core diff --git a/docs/editor/scene-viewport.md b/docs/editor/scene-viewport.md index f0e0fd8..58b524f 100644 --- a/docs/editor/scene-viewport.md +++ b/docs/editor/scene-viewport.md @@ -2,7 +2,8 @@ > **Namespace:** `Caffeine::Editor::SceneViewport` > **Ficheiro:** `src/editor/SceneViewport.cpp` -> **Status:** ✅ Implementado (GPU-first em 3D) +> **Status:** ✅ Implementado (GPU-first em 3D) +> **Performance:** ~10 → ~100 FPS no editor (ver [performance](../performance/editor-viewport-performance.md)) --- @@ -16,32 +17,32 @@ O **Scene Viewport** renderiza a cena 3D num target offscreen GPU e compõe o re | Modo | GPU | CPU overlay | |------|-----|-------------| -| **Textured** | `GpuSceneRenderer` Phong + sombras (se estável) | Só entidade **selecionada** (contorno) | -| **Wireframe** | `GpuSceneRenderer` pipeline `FillMode::Line` | Só entidade **selecionada** | +| **Textured** | `GpuSceneRenderer` Phong (sem shadow passes no editor) | Marcadores, gizmos, grid — **não** redesenha meshes | +| **Wireframe** | `GpuSceneRenderer` pipeline `FillMode::Line` | Idem | Alternar: botão **Textured / Wireframe** na barra do viewport. -**Importante:** em Textured, o CPU **não** redesenha meshes não seleccionados — evita double render e queda de FPS. +**Importante:** com GPU activo (`gpuOwnsSceneMeshes`), o CPU **não** rasteriza meshes nem terreno no mesmo frame. Ver [performance do viewport](../performance/editor-viewport-performance.md). --- ## Pipeline por frame (3D) ``` -1. resizeCanvasIfNeeded(imguiFramebufferSize(viewportSize)) // HiDPI, cap 1920px +1. resizeCanvasIfNeeded(imguiFramebufferSize(viewportSize, 1280)) 2. syncTerrainMeshes(world) 3. GpuSceneRenderer::render(cmd, world, ctx, colorTarget, depthTarget, opts) - ├── gatherMeshDraws (frustum cull, distância a visualizadores) - ├── shadow passes (se enableShadows) - └── renderMeshes (Phong ou wireframe) -4. ImGui: skybox → grid (atrás) → AddImage(GPU) → overlays - ├── drawEmptyEntities (marcadores, seleção) + ├── gatherMeshDraws (frustum cull, LOD terreno ×2 no editor) + ├── shadow passes — omitidos no editor (enableShadows=false) + └── renderMeshes (samplers dummy sempre ligados; Phong ou wireframe) +4. ImGui: skybox (CPU, throttled) → AddImage(GPU) → grid → overlays + ├── drawEmptyEntities (marcadores apenas; sem meshes se GPU activo) ├── drawCameraFrustums ├── drawLightGizmos └── TransformGizmo ``` -Ordem de desenho garante que o grid fica **atrás** do GPU (desenhado antes do `AddImage`), e gizmos **à frente**. +Ordem: skybox CPU → composite GPU → grid por cima do GPU (se activo) → gizmos à frente. --- @@ -81,12 +82,12 @@ Usado por: grid 3D, frustums `Camera3D`, wireframe CPU, anéis de primitivas. ```cpp // ImGuiGpuTexture.hpp -ImVec2 fb = imguiFramebufferSize(viewportSize, 1920); +ImVec2 fb = imguiFramebufferSize(viewportSize, 1280); resizeCanvasIfNeeded((u32)fb.x, (u32)fb.y); ``` - `DisplayFramebufferScale` do ImGui reflecte DPI do monitor -- Cap no maior lado evita render 4K+ no painel do editor +- Cap **1280** px no maior lado (editor); previews usam **960** - `AddImage` estica o target GPU para o tamanho lógico do ImGui (downscale suave) --- @@ -98,18 +99,11 @@ Construídas em `SceneViewport::render()`: | Campo | Comportamento no viewport | |-------|---------------------------| | `wireframeMeshes` | `true` em modo Wireframe | -| `enableShadows` | `false` se câmara em movimento rápido (`m_editorCamMotion >= 1.25`) | -| `textureQuality` | Copiado de `EditorContext` | -| `textureQualityViewers` | Câmara editor + posições de todas `Camera3DComponent` | +| `enableShadows` | **`false`** no editor (shadow passes omitidos; samplers dummy ligados) | +| `terrainLodDistanceScale` | **`2.0`** — LOD de terreno mais agressivo | +| `textureQuality.enabled` | **`false`** no editor (menos churn por draw) | -### Detecção de movimento de câmara - -```cpp -frameMotion = |Δyaw|*14 + |Δpitch|*14 + |Δpos| -m_editorCamMotion = m_editorCamMotion * 0.75 + frameMotion * 0.25 -``` - -Sombras voltam quando a câmara estabiliza (~200 ms). +> **Nota:** `enableShadows=false` desliga apenas os **passes** de sombra. Os samplers continuam ligados — ver [shadow-mapping.md](../rendering/shadow-mapping.md#editor-vs-runtime). --- @@ -126,12 +120,12 @@ Afecta grid, gizmos e linhas ImGui — **não** substitui MSAA no pass GPU (pend --- -## Camera Preview GPU - -`renderCameraPreviewGpu()` usa o mesmo `GpuSceneRenderer` com: +## Camera / Gameplay Preview GPU -- `textureQualityViewers = { cameraPos }` (só a câmara do painel) -- Targets `m_previewColorTarget` / `m_previewDepthTarget` +- `renderCameraPreviewGpu()` — partilha `GpuSceneRenderer` do viewport; targets `m_previewColorTarget` +- `GameplayPreviewPanel` — renderer próprio, mesmo command buffer +- **Um caminho por frame:** GPU **ou** `drawSceneMeshesForCamera()` (CPU), nunca ambos +- Throttle: `editorPanelWorthGpuRender(origin, size, 2)` — ver `EditorPanelUtils.hpp` --- @@ -165,12 +159,13 @@ Campos em `EditorContext` sincronizados via `SettingsPanel::applyPreferencesToCo |------|-------| | MSAA no pass GPU | Não implementado; silhuetas podem serrilhar | | Gizmos ImGui | Sem depth test contra cena GPU (intencional — sempre visíveis) | -| Runtime / Gameplay Preview | Mesma API GPU; shadows sempre ligadas salvo lógica própria | +| Runtime play | `enableShadows` conforme contexto; ver `RuntimeSceneRenderer` | --- ## Ver também +- [**Performance — Editor Viewport**](../performance/editor-viewport-performance.md) - [Sessão 2026-09-22 — Viewport & Quality](../plans/2026-09-22-viewport-rendering-quality-session.md) - [Texture Quality LOD](../rendering/texture-quality-lod.md) - [Materials Phong](../rendering/materials-phong.md) diff --git a/docs/performance/README.md b/docs/performance/README.md new file mode 100644 index 0000000..b7ae481 --- /dev/null +++ b/docs/performance/README.md @@ -0,0 +1,7 @@ +# Performance + +Documentação de profiling e otimizações da engine e do editor. + +| Documento | Descrição | +|-----------|-----------| +| [Editor Viewport & GPU Scene](editor-viewport-performance.md) | Investigação FPS Doppio, regras GPU/CPU, sombras, profiler scopes, SIGSEGV NVIDIA | diff --git a/docs/performance/editor-viewport-performance.md b/docs/performance/editor-viewport-performance.md new file mode 100644 index 0000000..f69c856 --- /dev/null +++ b/docs/performance/editor-viewport-performance.md @@ -0,0 +1,234 @@ +# Performance — Editor Viewport & GPU Scene + +> **Data:** 2026-09-23 +> **Contexto:** Investigação de quedas de FPS no Doppio (~12 FPS, ~77 ms em `SceneEditor::render`) +> **Status:** ✅ Otimizações aplicadas + instrumentação de profiler + +--- + +## Resumo + +O editor renderizava a mesma geometria em **CPU e GPU** no mesmo frame, executava **vários passes GPU** (viewport + previews) com sombras e binds redundantes, e rasterizava o **skybox em CPU** a resolução quase total do painel a cada micro-movimento de câmera. + +As correções seguem duas regras: + +1. **Meshes/terreno:** GPU **ou** CPU por frame — nunca os dois. +2. **Shadow passes** (redraw da cena) são independentes dos **shadow samplers** (texturas ligadas no shader). + +--- + +## Resultado validado + +| Métrica | Antes | Depois | +|---------|-------|--------| +| **FPS** | **~10** | **~100** | +| Frame time | ~77–213 ms | ~10 ms | + +Ganho medido no Doppio com cena 3D + terreno chunked, após o pacote de correções abaixo. + +--- + +## Sintomas observados (profiler, antes) + +| Métrica | Valor | Interpretação | +|---------|-------|-----------------| +| Frame time | ~77–213 ms | CPU baixo (~5%), GPU ~30% → gargalo em draw calls / driver | +| `SceneEditor::render` | ~55 ms avg | Scope único engolia todos os painéis | +| `GpuSceneRenderer::draw` | ~54 ms avg | Pass principal da cena (terreno + meshes) | +| `SceneViewport::skybox` | 0.9 ms avg, **80 ms max** | Picos ao re-rasterizar equirect em CPU | +| `cameraPreview` / `gameplayPreview` | ~0 ms após throttle | Previews deixaram de competir com o viewport | + +--- + +## Arquitetura: um caminho de render por frame + +### Scene Viewport (3D) + +| Camada | Responsável | +|--------|-------------| +| Meshes + terreno | **GPU** (`GpuSceneRenderer`) quando `m_useGpuScene` ativo | +| Skybox | CPU (`SkyboxRenderer`) como fundo; GPU limpa com alpha 0 | +| Grid, gizmos, frustums, marcadores | CPU (`ImDrawList`) — overlays de editor | + +Quando `gpuOwnsSceneMeshes` está ativo, `drawEmptyEntities()` **não** rasteriza meshes na CPU (inclui terreno e seleção). Só marcadores de entidades vazias e debug de chunks. + +### Camera / Gameplay Preview + +- **GPU path:** `renderCameraPreviewGpu()` ou `GameplayPreviewPanel::m_renderer` — sem `drawSceneMeshesForCamera()` no mesmo frame. +- **CPU fallback:** só quando o pass GPU falha ou não está disponível (`#ifdef CF_HAS_SDL3`). +- Throttle: `editorPanelWorthGpuRender(..., frameInterval=2)` — re-render GPU a cada 2 frames; reutiliza textura anterior. + +--- + +## Causa raiz em `GpuSceneRenderer::draw` + +Para **cada chunk de terreno**, o loop fazia trabalho que deveria ser **uma vez por pass** ou **uma vez por entidade**: + +| Operação | Problema | +|----------|----------| +| `pushUniformData(shadowUbo)` | Mesmo UBO repetido com sombras desligadas | +| `bindShadowTextures()` | 6+ samplers por chunk | +| `texCache.acquire()` | Lookup de splat/albedo/normal por chunk | + +Com **50–200 chunks** visíveis → **milhares de operações redundantes por frame**. + +--- + +## Correções aplicadas (10 → 100 FPS) + +| # | Correção | Impacto | +|---|----------|---------| +| 1 | `shadowUbo` **uma vez por pass** (não por draw) | Menos uploads de uniform por frame | +| 2 | Shadow **passes** off no editor; **samplers** sempre ligados | Sem redraw × cascatas; sem SIGSEGV NVIDIA | +| 3 | **Cache de texturas de terreno** — bind só na 1ª vez por entidade | De O(chunks) → O(terrenos) binds de textura | +| 4 | `terrainLodDistanceScale = 2` no editor | Menos chunks visíveis / menos draws | +| 5 | Resolução GPU cap **1280 px** no viewport | Menos pixels no offscreen target | +| 6 | `textureQuality.enabled = false` no editor | Menos churn de mip/tier por draw | +| 7 | Skybox cap **512–1024 px** (sem throttle de frames) | Raster CPU mais barato sem desync céu/terreno | + +Complementares (mesma sessão): separação GPU/CPU por frame, skip GPU em previews ocultos, profiler granular. + +--- + +## Otimizações no `GpuSceneRenderer` + +### 1. Shadow passes vs shadow samplers + +| Conceito | Editor | Runtime / play | +|----------|--------|----------------| +| **Shadow passes** (`renderDirectionalShadows`, etc.) | ❌ Desligados | ✅ Quando `enableShadows=true` | +| **Shadow samplers** (`bindShadowTextures`) | ✅ **Sempre** ligados (white / dummy) | ✅ Sempre | + +**⚠️ Regra crítica (SIGSEGV NVIDIA):** +Desligar `enableShadows` **não** pode omitir `bindShadowTextures()`. Os shaders `scene_lit.frag` / `terrain_lit.frag` declaram samplers de sombra; slots não ligados → crash no driver. + +```cpp +// Correto: passes condicionais, binds sempre +if (options.enableShadows) { + renderDirectionalShadows(...); +} +bindShadowTextures(2, 4, 6); // início do pass + ao trocar mesh ↔ terreno +``` + +### 2. Uniform de sombra uma vez por pass + +`SceneShadowUBO` é enviado **uma vez** antes do loop de draws (não por mesh/chunk). + +### 3. Cache de texturas de terreno + +Texturas de splat/albedo/normal são ligadas **uma vez por entidade de terreno**, não por chunk LOD. + +### 4. LOD de terreno no editor + +`GpuSceneRenderOptions::terrainLodDistanceScale = 2.0f` no Scene Viewport — distância efectiva dobrada → LOD mais grosseiro, menos chunks visíveis. + +--- + +## Otimizações no Scene Viewport + +| Opção | Valor no editor | Motivo | +|-------|-----------------|--------| +| `enableShadows` | `false` | Evita 4+ redraws da cena por cascata | +| `terrainLodDistanceScale` | `2.0` | Menos chunks GPU | +| `textureQuality.enabled` | `false` | Menos churn de mips/tiers por draw | +| `imguiFramebufferSize` cap | **1280** px | Menos pixels no offscreen target | +| Skybox `maxRasterDim` | 512–1024 | Raster CPU mais barato; re-rasteriza a cada movimento de câmera | + +--- + +## Otimizações nos previews + +Helper: `editorPanelWorthGpuRender()` em `EditorPanelUtils.hpp` + +- Ignora painéis colapsados, < 32 px, ou fora de `IsRectVisible` +- Skybox desenhado **todo frame** nos previews (antes do `AddImage` GPU) +- GPU só é pulado quando o painel está colapsado / invisível / < 32 px +- Resolução GPU cap **960** px nos previews +- Canvas sempre inicializado antes do primeiro draw (`resizeCanvas` se targets nulos) + +--- + +## Instrumentação (`CF_PROFILE_SCOPE`) + +O profiler mantém uma **árvore hierárquica** de scopes (stack trace de consumo). Cada nó regista: + +| Métrica | Significado | +|---------|-------------| +| **self ms** | Tempo exclusivo (sem filhos) — onde procurar gargalos | +| **total ms** | Tempo inclusivo (scope + filhos) | +| **% frame** | `avg self` vs último frame time | +| **% parent** | `avg self` vs `avg total` do pai | + +Scopes adicionados para diagnóstico no painel **Profiler**: + +### SceneEditor + +- `SceneEditor::render` (raiz do frame do editor) +- `SceneEditor::propagateTransforms` +- `SceneEditor::hierarchy`, `inspector`, `viewport`, `assetBrowser`, `console`, `profiler` +- `SceneEditor::cameraPreview`, `gameplayPreview`, `materialEditor`, `terrainEditor`, `plugins`, … + +### SceneViewport + +- `SceneViewport::render` +- `SceneViewport::gpuPass` +- `SceneViewport::skybox` +- `SceneViewport::sprites`, `SceneViewport::entities` + +### GpuSceneRenderer + +- `GpuSceneRenderer::renderWithCamera` +- `GpuSceneRenderer::gather` +- `GpuSceneRenderer::shadows` (quando passes activos) +- `GpuSceneRenderer::draw` + +**Como usar:** abrir Profiler → **Reset Stats** → orbitar câmera / abrir previews → expandir a árvore em **Scope tree** → ordenar por **self ms** (activo por defeito). Passar o rato sobre um scope mostra o caminho completo (`SceneEditor::render > SceneEditor::viewport > …`). + +--- + +## Pipeline actual (3D) + +``` +1. imguiFramebufferSize(viewportSize, 1280) +2. syncTerrainMeshes(world) // só se resolução/revisão mudou +3. GpuSceneRenderer::render(...) + ├── gatherMeshDraws (frustum + LOD terreno ×2) + ├── (sem shadow passes no editor) + └── renderMeshes — bind samplers dummy, 1× shadow UBO +4. ImGui: skybox (CPU, cap resolução) → AddImage(GPU) → grid → overlays CPU +``` + +--- + +## Ficheiros principais + +| Ficheiro | Alterações | +|----------|------------| +| `src/render/GpuSceneRenderer.cpp` | Shadow UBO 1×, cache terreno, binds obrigatórios | +| `src/render/GpuSceneRenderer.hpp` | `terrainLodDistanceScale` | +| `src/editor/SceneViewport.cpp` | GPU-only meshes, opções editor, scopes | +| `src/editor/CameraPreviewPanel.cpp` | GPU-only / CPU fallback, throttle | +| `src/editor/GameplayPreviewPanel.cpp` | Idem + resizeCanvas seguro | +| `src/editor/EditorPanelUtils.hpp` | `editorPanelWorthGpuRender()` | +| `src/render/SkyboxRenderer.cpp` | Cap resolução; raster sincronizado com câmera | +| `src/terrain/TerrainLodSystem.cpp` | `lodDistanceScale` no gather | + +--- + +## Limitações e trabalho futuro + +| Item | Notas | +|------|-------| +| `GpuSceneRenderer::draw` ainda domina frame time em cenas com muito terreno | Próximo passo: instancing/batching de chunks | +| Skybox continua CPU | Migrar para cubemap GPU ou shader fullscreen | +| MSAA no pass GPU | Pendente | +| Profiler próprio pode picar ~30 ms | `SceneEditor::profiler` ao redesenhar tabela | + +--- + +## Ver também + +- [Scene Viewport](../editor/scene-viewport.md) +- [Shadow Mapping](../rendering/shadow-mapping.md) +- [Texture Quality LOD](../rendering/texture-quality-lod.md) +- [Sessão 2026-09-22 — Viewport](../plans/2026-09-22-viewport-rendering-quality-session.md) diff --git a/docs/rendering/shadow-mapping.md b/docs/rendering/shadow-mapping.md index abc98f0..2ee2420 100644 --- a/docs/rendering/shadow-mapping.md +++ b/docs/rendering/shadow-mapping.md @@ -56,13 +56,27 @@ Espelhado em C++ (`GpuSceneRenderer.cpp`) e GLSL: ## Editor vs runtime -| Contexto | CPU shadows | GPU shadows | -|----------|-------------|-------------| -| Editor 3D GPU texturizado | ❌ desligado | ✅ | -| Editor wireframe / sem GPU | ✅ opcional | — | -| Runtime (`RuntimeSceneRenderer`) | ❌ fallback CPU sem sombras | ✅ GPU-first via `GpuSceneRenderer` | +| Contexto | Shadow **passes** | Shadow **samplers** ligados | +|----------|-------------------|----------------------------| +| Editor Scene Viewport | ❌ `enableShadows=false` | ✅ sempre (`bindShadowTextures`) | +| Editor previews | ❌ | ✅ | +| Runtime (`RuntimeSceneRenderer`) | ✅ quando `enableShadows=true` | ✅ | -`SceneViewport` passa `buildCpuShadowMaps = !gpuTextured3D` a `gatherSceneLighting`. +### ⚠️ Regra de segurança (SIGSEGV NVIDIA) + +`enableShadows=false` **omite** `renderDirectionalShadows` / point / spot — não omite `bindShadowTextures()`. + +Os fragment shaders declaram samplers de sombra mesmo quando `uDirShadowValid=0`. Slots não ligados podem causar **segmentation fault** no driver NVIDIA. + +```cpp +// GpuSceneRenderer::renderMeshes — padrão correcto +if (options.enableShadows) { + renderDirectionalShadows(...); +} +bindShadowTextures(2, 4, 6); // obrigatório no início do pass opaco +``` + +Com GPU activo no editor, `gatherSceneLighting` com CPU shadow maps **não** corre para meshes (`gpuOwnsSceneMeshes`). --- diff --git a/src/debug/Profiler.cpp b/src/debug/Profiler.cpp index 8062ccf..bf42a05 100644 --- a/src/debug/Profiler.cpp +++ b/src/debug/Profiler.cpp @@ -4,94 +4,127 @@ namespace Caffeine::Debug { +Profiler::Profiler() { + initRoot(); +} + Profiler& Profiler::instance() { static Profiler s; return s; } -void Profiler::beginScope(const char* name) { - if (!m_enabled) return; - - InternalScopeData* scope = findOrCreateScope(name); - if (!scope) return; +void Profiler::initRoot() { + m_nodeCount = 1; + m_activeDepth = 0; + m_nodes[0] = ScopeNode{}; + m_nodes[0].name = ""; + m_nodes[0].parent = kInvalidNode; +} - scope->activeTimer.reset(); - scope->activeTimer.start(); +void Profiler::beginScope(const char* name) { + if (!m_enabled || !name) return; + if (m_activeDepth >= MAX_SCOPE_DEPTH) return; + + const u32 parentIndex = + (m_activeDepth > 0) ? m_activeStack[m_activeDepth - 1].nodeIndex : 0u; + const u32 nodeIndex = findOrCreateChild(parentIndex, name); + if (nodeIndex == kInvalidNode) return; + + ActiveScope& active = m_activeStack[m_activeDepth++]; + active.nodeIndex = nodeIndex; + active.childrenMs = 0.0; + active.timer.reset(); + active.timer.start(); } void Profiler::endScope(const char* name) { if (!m_enabled) return; + if (m_activeDepth == 0) return; + + ActiveScope& active = m_activeStack[m_activeDepth - 1]; + ScopeNode& node = m_nodes[active.nodeIndex]; + + if (name && node.name && std::strcmp(node.name, name) != 0) { + // RAII scopes should always match; still close the innermost scope. + } - InternalScopeData* scope = findScope(name); - if (!scope) return; + active.timer.stop(); + const f64 elapsedMs = active.timer.elapsed().millis(); + const f64 selfMs = std::max(0.0, elapsedMs - active.childrenMs); - scope->activeTimer.stop(); - f64 ms = scope->activeTimer.elapsed().millis(); + node.callCount++; + node.totalMs += elapsedMs; + node.selfMs += selfMs; + if (elapsedMs < node.minMs) node.minMs = elapsedMs; + if (elapsedMs > node.maxMs) node.maxMs = elapsedMs; - scope->callCount++; - scope->totalMs += ms; - if (ms < scope->minMs) scope->minMs = ms; - if (ms > scope->maxMs) scope->maxMs = ms; + if (m_activeDepth > 1) { + m_activeStack[m_activeDepth - 2].childrenMs += elapsedMs; + } + + m_activeDepth--; } void Profiler::report(Vector& out) const { out.clear(); - for (usize i = 0; i < m_scopeCount; ++i) { - const auto& s = m_scopes[i]; - ScopeStats stats; - stats.name = s.name; - stats.callCount = s.callCount; - stats.totalMs = s.totalMs; - stats.avgMs = (s.callCount > 0) ? s.totalMs / static_cast(s.callCount) : 0.0; - stats.minMs = s.minMs; - stats.maxMs = s.maxMs; - out.pushBack(stats); + out.resize(m_nodeCount); + + for (usize i = 0; i < m_nodeCount; ++i) { + const ScopeNode& node = m_nodes[i]; + ScopeStats& stats = out[i]; + stats.name = node.name; + stats.nodeIndex = static_cast(i); + stats.parentIndex = node.parent; + stats.firstChildIndex = node.firstChild; + stats.nextSiblingIndex = node.nextSibling; + stats.callCount = node.callCount; + stats.totalMs = node.totalMs; + stats.selfMs = node.selfMs; + stats.avgMs = (node.callCount > 0) ? node.totalMs / static_cast(node.callCount) : 0.0; + stats.avgSelfMs = + (node.callCount > 0) ? node.selfMs / static_cast(node.callCount) : 0.0; + stats.minMs = (node.callCount > 0) ? node.minMs : 0.0; + stats.maxMs = node.maxMs; + + stats.depth = 0; + u32 parent = node.parent; + while (parent != kInvalidNode && parent < m_nodeCount) { + stats.depth++; + parent = m_nodes[parent].parent; + } } } void Profiler::reset() { - m_scopeCount = 0; - for (usize i = 0; i < MAX_SCOPES; ++i) { - m_scopes[i] = InternalScopeData{}; - } + initRoot(); } usize Profiler::scopeCount() const { - return m_scopeCount; + return (m_nodeCount > 0) ? m_nodeCount - 1 : 0; } -Profiler::InternalScopeData* Profiler::findScope(const char* name) { - for (usize i = 0; i < m_scopeCount; ++i) { - if (strcmp(m_scopes[i].name, name) == 0) { - return &m_scopes[i]; - } - } - return nullptr; -} +u32 Profiler::findOrCreateChild(u32 parentIndex, const char* name) { + if (parentIndex >= m_nodeCount) return kInvalidNode; -const Profiler::InternalScopeData* Profiler::findScope(const char* name) const { - for (usize i = 0; i < m_scopeCount; ++i) { - if (strcmp(m_scopes[i].name, name) == 0) { - return &m_scopes[i]; + u32 child = m_nodes[parentIndex].firstChild; + while (child != kInvalidNode) { + if (m_nodes[child].name == name + || (m_nodes[child].name && std::strcmp(m_nodes[child].name, name) == 0)) { + return child; } + child = m_nodes[child].nextSibling; } - return nullptr; -} - -Profiler::InternalScopeData* Profiler::findOrCreateScope(const char* name) { - InternalScopeData* existing = findScope(name); - if (existing) return existing; - if (m_scopeCount >= MAX_SCOPES) return nullptr; + if (m_nodeCount >= MAX_SCOPE_NODES) return kInvalidNode; - auto& scope = m_scopes[m_scopeCount]; - scope.name = name; - scope.callCount = 0; - scope.totalMs = 0.0; - scope.minMs = 1e18; - scope.maxMs = 0.0; - ++m_scopeCount; - return &scope; + const u32 index = static_cast(m_nodeCount++); + ScopeNode& node = m_nodes[index]; + node = ScopeNode{}; + node.parent = parentIndex; + node.name = name; + node.nextSibling = m_nodes[parentIndex].firstChild; + m_nodes[parentIndex].firstChild = index; + return index; } ProfileScope::ProfileScope(const char* name) : m_name(name) { diff --git a/src/debug/Profiler.hpp b/src/debug/Profiler.hpp index 59e3a4f..7f6fe49 100644 --- a/src/debug/Profiler.hpp +++ b/src/debug/Profiler.hpp @@ -2,7 +2,6 @@ #include "../core/Types.hpp" #include "../core/Timer.hpp" -#include "../containers/HashMap.hpp" #include "../containers/Vector.hpp" namespace Caffeine::Debug { @@ -14,16 +13,27 @@ class Profiler { void beginScope(const char* name); void endScope(const char* name); + static constexpr u32 kInvalidNode = static_cast(-1); + struct ScopeStats { const char* name = nullptr; + u32 nodeIndex = kInvalidNode; + u32 parentIndex = kInvalidNode; + u32 firstChildIndex = kInvalidNode; + u32 nextSiblingIndex = kInvalidNode; + u32 depth = 0; u64 callCount = 0; - f64 totalMs = 0.0; + f64 totalMs = 0.0; // inclusive (scope + children) + f64 selfMs = 0.0; // exclusive (scope only) f64 avgMs = 0.0; - f64 minMs = 1e18; + f64 avgSelfMs = 0.0; + f64 minMs = 0.0; f64 maxMs = 0.0; }; + // Fills out[0..nodeCount()-1]; index 0 is the virtual root (no samples). void report(Vector& out) const; + void reset(); void setEnabled(bool enabled) { m_enabled = enabled; } @@ -32,29 +42,41 @@ class Profiler { usize scopeCount() const; private: - Profiler() = default; + Profiler(); ~Profiler() = default; Profiler(const Profiler&) = delete; Profiler& operator=(const Profiler&) = delete; - struct InternalScopeData { + struct ScopeNode { + u32 parent = kInvalidNode; + u32 firstChild = kInvalidNode; + u32 nextSibling = kInvalidNode; const char* name = nullptr; u64 callCount = 0; - f64 totalMs = 0.0; - f64 minMs = 1e18; - f64 maxMs = 0.0; - Core::Timer activeTimer; + f64 totalMs = 0.0; + f64 selfMs = 0.0; + f64 minMs = 1e18; + f64 maxMs = 0.0; }; + struct ActiveScope { + u32 nodeIndex = kInvalidNode; + f64 childrenMs = 0.0; + Core::Timer timer; + }; + + static constexpr usize MAX_SCOPE_NODES = 512; + static constexpr usize MAX_SCOPE_DEPTH = 64; + bool m_enabled = true; - static constexpr usize MAX_SCOPES = 256; - InternalScopeData m_scopes[MAX_SCOPES]{}; - usize m_scopeCount = 0; + ScopeNode m_nodes[MAX_SCOPE_NODES]{}; + usize m_nodeCount = 0; + ActiveScope m_activeStack[MAX_SCOPE_DEPTH]{}; + usize m_activeDepth = 0; - InternalScopeData* findScope(const char* name); - const InternalScopeData* findScope(const char* name) const; - InternalScopeData* findOrCreateScope(const char* name); + u32 findOrCreateChild(u32 parentIndex, const char* name); + void initRoot(); }; class ProfileScope { diff --git a/src/editor/CameraPreviewPanel.cpp b/src/editor/CameraPreviewPanel.cpp index 13b9fb9..f8ac0fb 100644 --- a/src/editor/CameraPreviewPanel.cpp +++ b/src/editor/CameraPreviewPanel.cpp @@ -165,6 +165,79 @@ void drawLine3D(ImDrawList* dl, const Mat4& vp, ImVec2 origin, ImVec2 panelSize, dl->AddLine(sa, sb, col, thickness); } +std::string resolveProjectRoot(const EditorContext& ctx) { + if (ctx.currentScenePath.empty()) return {}; + const auto sceneDir = std::filesystem::path(ctx.currentScenePath).parent_path(); + std::string projectRoot = sceneDir.parent_path().string(); + if (projectRoot.empty()) projectRoot = sceneDir.string(); + return projectRoot; +} + +#ifdef CF_HAS_SDL3 +bool renderCameraPreviewGpuOnly(SceneViewport& viewport, ECS::World& world, EditorContext& ctx, + ImDrawList* dl, ImVec2 origin, ImVec2 panelSize, + const Mat4& view, const Mat4& proj, const Vec3& camPos, + const Vec3& focus, f32 fovRad, f32 nearClip, f32 farClip, + const Render::SkyboxCamera& skyCamera, + Render::SkyboxRenderer& skyboxRenderer, + CameraPreviewGpuCache& cache, + const char* labelText, ImU32 labelColor) { + if (!viewport.cameraPreviewGpuReady() || !viewport.frameCommandBuffer()) { + return false; + } + + RHI::Texture* preview = viewport.cameraPreviewColorTarget(); + const bool camChanged = (camPos - cache.lastCamPos).length() > 0.001f + || (focus - cache.lastFocus).length() > 0.001f + || std::abs(fovRad - cache.lastFov) > 0.001f; + const ImVec2 fbSizeProbe = imguiFramebufferSize(panelSize, 960); + const u32 probeW = static_cast(std::max(fbSizeProbe.x, 8.0f)); + const u32 probeH = static_cast(std::max(fbSizeProbe.y, 8.0f)); + const bool sizeChanged = probeW != cache.lastW || probeH != cache.lastH; + const u32 gpuInterval = camChanged ? 1u : 2u; + const bool rerunGpu = (camChanged || sizeChanged || !cache.hasFrame) + && editorPanelWorthGpuRender(origin, panelSize, gpuInterval); + if (rerunGpu) { + const ImVec2 fbSize = imguiFramebufferSize(panelSize, 960); + const bool gpuOk = viewport.renderCameraPreviewGpu( + viewport.frameCommandBuffer(), world, ctx, view, proj, camPos, focus, + fovRad, nearClip, farClip, + static_cast(std::max(fbSize.x, 8.0f)), + static_cast(std::max(fbSize.y, 8.0f)), + resolveProjectRoot(ctx)); + if (!gpuOk) { + return false; + } + preview = viewport.cameraPreviewColorTarget(); + cache.lastCamPos = camPos; + cache.lastFocus = focus; + cache.lastFov = fovRad; + cache.lastW = probeW; + cache.lastH = probeH; + cache.hasFrame = true; + } + if (!preview || !preview->handle) { + return false; + } + + { + const int longest = static_cast(std::max(panelSize.x, panelSize.y)); + const int skyCap = std::clamp(longest, 256, 512); + viewport.drawSkyboxForView(dl, origin, panelSize, world, ctx, skyCamera, false, + &skyboxRenderer, skyCap); + } + dl->AddImage(reinterpret_cast(preview->handle), origin, + ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), + ImVec2(0, 0), ImVec2(1, 1)); + if (labelText && labelText[0] != '\0') { + dl->AddText(ImVec2(origin.x + 6.0f, origin.y + 4.0f), labelColor, labelText); + } + dl->AddRect(origin, ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), + IM_COL32(80, 140, 255, 160), 0.0f, 0, 1.5f); + return true; +} +#endif + void drawCubeWireframe(ImDrawList* dl, const Mat4& vp, ImVec2 origin, ImVec2 panelSize, const Mat4& worldMatrix, ImU32 col, float thickness) { const std::array corners = {{ @@ -210,9 +283,16 @@ void CameraPreviewPanel::onImGuiRender(ECS::World& world, EditorContext& ctx, Sc } editorPanelDetachTabButton(m_detached); - Scene::propagateTransforms(world); + if (ImGui::IsWindowCollapsed()) { + ImGui::End(); + return; + } ImVec2 panelSize = ImGui::GetContentRegionAvail(); + if (panelSize.x < 32.0f || panelSize.y < 32.0f) { + ImGui::End(); + return; + } if (panelSize.x < 4.0f) panelSize.x = 4.0f; if (panelSize.y < 4.0f) panelSize.y = 4.0f; @@ -357,17 +437,12 @@ void CameraPreviewPanel::renderEditorCameraFallback(ECS::World& world, EditorCon ImVec2 origin, ImVec2 panelSize) { ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->PushClipRect(origin, - ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), - true); - const Vec3 camPos = editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); const Mat4 view = Mat4::lookAt(camPos, ctx.camFocus, Vec3(0.0f, 1.0f, 0.0f)); const f32 aspect = panelSize.x / std::max(panelSize.y, 1.0f); const f32 farPlane = ctx.cameraFarPlane(); const Mat4 proj = Mat4::perspective(1.0472f, aspect, 0.1f, farPlane); - const Mat4 vp = proj * view; Render::SkyboxCamera skyCamera; skyCamera.forward = editorLookDirection(ctx.camYaw, ctx.camPitch).normalized(); @@ -382,6 +457,22 @@ void CameraPreviewPanel::renderEditorCameraFallback(ECS::World& world, EditorCon skyCamera.fovY = 1.0472f; skyCamera.aspect = aspect; +#ifdef CF_HAS_SDL3 + if (renderCameraPreviewGpuOnly(viewport, world, ctx, dl, origin, panelSize, view, proj, camPos, + ctx.camFocus, 1.0472f, 0.1f, farPlane, skyCamera, + m_skyboxRenderer, m_gpuCache, + "Editor Camera (sem Camera3D na cena)", + IM_COL32(200, 200, 210, 230))) { + return; + } +#endif + + // CPU-only fallback when GPU preview is unavailable or failed. + const Mat4 vp = proj * view; + dl->PushClipRect(origin, + ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), + true); + if (!viewport.drawSkyboxForView(dl, origin, panelSize, world, ctx, skyCamera, false, &m_skyboxRenderer)) { dl->AddRectFilledMultiColor( @@ -430,66 +521,36 @@ void CameraPreviewPanel::renderCamera3DView(ECS::World& world, EditorContext& ct const f32 farClip = std::max(cam.farClip, 50.0f); const Mat4 proj = Mat4::perspective(fovRad, aspect, cam.nearClip, farClip); + const Mat4 worldMatrix = entityMatrix(world, cameraEntity); + Render::SkyboxCamera skyCamera; + skyCamera.forward = entityForward(world, cameraEntity).normalized(); + skyCamera.right = matrixAxis(worldMatrix, 0, Vec3(1.0f, 0.0f, 0.0f)); + skyCamera.up = matrixAxis(worldMatrix, 1, Vec3(0.0f, 1.0f, 0.0f)); + skyCamera.fovY = fovRad; + skyCamera.aspect = aspect; + #ifdef CF_HAS_SDL3 - if (viewport.cameraPreviewGpuReady() && viewport.frameCommandBuffer()) { - std::string projectRoot; - if (!ctx.currentScenePath.empty()) { - const auto sceneDir = std::filesystem::path(ctx.currentScenePath).parent_path(); - projectRoot = sceneDir.parent_path().string(); - if (projectRoot.empty()) projectRoot = sceneDir.string(); - } + { const Vec3 focus = camPos + entityForward(world, cameraEntity).normalized(); - const ImVec2 fbSize = imguiFramebufferSize(panelSize); - const bool gpuOk = viewport.renderCameraPreviewGpu( - viewport.frameCommandBuffer(), world, ctx, view, proj, camPos, focus, - fovRad, cam.nearClip, farClip, - static_cast(std::max(fbSize.x, 8.0f)), - static_cast(std::max(fbSize.y, 8.0f)), - projectRoot); - RHI::Texture* preview = viewport.cameraPreviewColorTarget(); - if (gpuOk && preview && preview->handle) { - const Mat4 worldMatrix = entityMatrix(world, cameraEntity); - Render::SkyboxCamera skyCamera; - skyCamera.forward = entityForward(world, cameraEntity).normalized(); - skyCamera.right = matrixAxis(worldMatrix, 0, Vec3(1.0f, 0.0f, 0.0f)); - skyCamera.up = matrixAxis(worldMatrix, 1, Vec3(0.0f, 1.0f, 0.0f)); - skyCamera.fovY = fovRad; - skyCamera.aspect = aspect; - viewport.drawSkyboxForView(dl, origin, panelSize, world, ctx, skyCamera, false, - &m_skyboxRenderer); - dl->AddImage(reinterpret_cast(preview->handle), origin, - ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), - ImVec2(0, 0), ImVec2(1, 1)); - const char* name = getEntityName(world, cameraEntity); - if (ctx.isPlayMode) { - dl->AddText(ImVec2(origin.x + 6.0f, origin.y + 4.0f), - IM_COL32(180, 255, 180, 230), "PLAY"); - } else if (name && name[0] != '\0') { - dl->AddText(ImVec2(origin.x + 6.0f, origin.y + 4.0f), - IM_COL32(200, 200, 210, 230), name); - } - dl->AddRect(origin, - ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), - IM_COL32(80, 140, 255, 160), 0.0f, 0, 1.5f); + const char* camName = getEntityName(world, cameraEntity); + const char* gpuLabel = ctx.isPlayMode ? "PLAY" : camName; + const ImU32 gpuLabelColor = ctx.isPlayMode ? IM_COL32(180, 255, 180, 230) + : IM_COL32(200, 200, 210, 230); + if (renderCameraPreviewGpuOnly(viewport, world, ctx, dl, origin, panelSize, view, proj, + camPos, focus, fovRad, cam.nearClip, farClip, skyCamera, + m_skyboxRenderer, m_gpuCache, gpuLabel, gpuLabelColor)) { return; } } #endif + // CPU-only fallback when GPU preview is unavailable or failed. const Mat4 vp = proj * view; dl->PushClipRect(origin, ImVec2(origin.x + panelSize.x, origin.y + panelSize.y), true); - const Mat4 worldMatrix = entityMatrix(world, cameraEntity); - Render::SkyboxCamera skyCamera; - skyCamera.forward = entityForward(world, cameraEntity).normalized(); - skyCamera.right = matrixAxis(worldMatrix, 0, Vec3(1.0f, 0.0f, 0.0f)); - skyCamera.up = matrixAxis(worldMatrix, 1, Vec3(0.0f, 1.0f, 0.0f)); - skyCamera.fovY = fovRad; - skyCamera.aspect = aspect; - if (!viewport.drawSkyboxForView(dl, origin, panelSize, world, ctx, skyCamera, false, &m_skyboxRenderer)) { dl->AddRectFilledMultiColor( diff --git a/src/editor/CameraPreviewPanel.hpp b/src/editor/CameraPreviewPanel.hpp index e83a420..8486ec7 100644 --- a/src/editor/CameraPreviewPanel.hpp +++ b/src/editor/CameraPreviewPanel.hpp @@ -5,6 +5,7 @@ #include "editor/EditorContext.hpp" #include "editor/Camera2DPreviewRenderer.hpp" #include "render/SkyboxRenderer.hpp" +#include "math/Vec3.hpp" #ifdef CF_HAS_IMGUI #include @@ -18,6 +19,15 @@ namespace Caffeine::Editor { class SceneViewport; +struct CameraPreviewGpuCache { + Vec3 lastCamPos{}; + Vec3 lastFocus{}; + f32 lastFov = 0.0f; + u32 lastW = 0; + u32 lastH = 0; + bool hasFrame = false; +}; + class CameraPreviewPanel { public: void onImGuiRender(ECS::World& world, EditorContext& ctx, SceneViewport& viewport); @@ -42,6 +52,7 @@ class CameraPreviewPanel { std::unordered_map m_texCache; Render::SkyboxRenderer m_skyboxRenderer; + CameraPreviewGpuCache m_gpuCache; std::string resolveSpritePath(const std::string& name, const EditorContext& ctx) const; #endif diff --git a/src/editor/EditorPanelUtils.hpp b/src/editor/EditorPanelUtils.hpp index 5591a19..4318d4d 100644 --- a/src/editor/EditorPanelUtils.hpp +++ b/src/editor/EditorPanelUtils.hpp @@ -66,6 +66,16 @@ inline void editorPanelDetachTabButton(bool& detached) { ImGui::PopID(); } +// Skip GPU work for collapsed/hidden/throttled dock panels; still safe to blit the last target. +inline bool editorPanelWorthGpuRender(ImVec2 origin, ImVec2 size, u32 frameInterval = 1) { + if (size.x < 32.0f || size.y < 32.0f) return false; + if (ImGui::IsWindowCollapsed()) return false; + const ImVec2 max(origin.x + size.x, origin.y + size.y); + if (!ImGui::IsRectVisible(origin, max)) return false; + if (frameInterval > 1u && (ImGui::GetFrameCount() % frameInterval) != 0u) return false; + return true; +} + #endif } // namespace Caffeine::Editor diff --git a/src/editor/GameplayPreviewPanel.cpp b/src/editor/GameplayPreviewPanel.cpp index 56b4253..e172ae8 100644 --- a/src/editor/GameplayPreviewPanel.cpp +++ b/src/editor/GameplayPreviewPanel.cpp @@ -62,7 +62,7 @@ std::string projectRootFromContext(const EditorContext& ctx) { void drawSceneSkybox(ImDrawList* dl, ImVec2 origin, ImVec2 size, ECS::World& world, const EditorContext& ctx, const Render::SkyboxCamera& camera, - Render::SkyboxRenderer& renderer) { + Render::SkyboxRenderer& renderer, int maxRasterDim) { const std::string projectRoot = projectRootFromContext(ctx); std::filesystem::path texturePath; const Scene::ActiveSkybox active = Scene::findActiveSkybox(world); @@ -72,7 +72,7 @@ void drawSceneSkybox(ImDrawList* dl, ImVec2 origin, ImVec2 size, ECS::World& wor texturePath = Scene::resolveBuiltinSkyboxPath(ctx.skyboxIndex); } if (texturePath.empty()) return; - renderer.draw(dl, origin, size, camera, texturePath.string()); + renderer.draw(dl, origin, size, camera, texturePath.string(), maxRasterDim); } } // namespace @@ -106,6 +106,7 @@ void GameplayPreviewPanel::shutdown() { m_width = 0; m_height = 0; m_ready = false; + m_hasGpuFrame = false; } void GameplayPreviewPanel::resizeCanvas(u32 width, u32 height) { @@ -145,7 +146,16 @@ void GameplayPreviewPanel::render(ECS::World& world, EditorContext& ctx) { } editorPanelDetachTabButton(m_detached); + if (ImGui::IsWindowCollapsed()) { + ImGui::End(); + return; + } + ImVec2 panelSize = ImGui::GetContentRegionAvail(); + if (panelSize.x < 32.0f || panelSize.y < 32.0f) { + ImGui::End(); + return; + } if (panelSize.x < 8.0f) panelSize.x = 8.0f; if (panelSize.y < 8.0f) panelSize.y = 8.0f; @@ -153,8 +163,6 @@ void GameplayPreviewPanel::render(ECS::World& world, EditorContext& ctx) { ImDrawList* dl = ImGui::GetWindowDrawList(); ImGui::InvisibleButton("##gameplay_preview", panelSize); - Scene::propagateTransforms(world); - ECS::Entity cameraEntity; ECS::Camera3DComponent* cam = nullptr; const bool found3D = findGameplayCamera(world, cameraEntity, cam); @@ -202,13 +210,12 @@ void GameplayPreviewPanel::render(ECS::World& world, EditorContext& ctx) { } else #ifdef CF_HAS_SDL3 if (found3D && cam && m_ready && m_frameCmd && m_renderer.isReady()) { - const ImVec2 fbSize = imguiFramebufferSize(panelSize); - u32 w = static_cast(fbSize.x); - u32 h = static_cast(fbSize.y); - w = std::clamp(w, 8u, 2560u); - h = std::clamp(h, 8u, 1440u); - resizeCanvas(w, h); - + const ImVec2 fbSize = imguiFramebufferSize(panelSize, 960); + u32 targetW = std::clamp(static_cast(fbSize.x), 8u, 1280u); + u32 targetH = std::clamp(static_cast(fbSize.y), 8u, 720u); + if (!m_colorTarget || !m_depthTarget || m_width != targetW || m_height != targetH) { + resizeCanvas(targetW, targetH); + } const Mat4 worldMatrix = Scene::computeWorldMatrix(world, cameraEntity); Vec3 position = worldMatrix.transformPoint(Vec3(0, 0, 0)); Vec3 right(worldMatrix(0, 0), worldMatrix(1, 0), worldMatrix(2, 0)); @@ -238,19 +245,45 @@ void GameplayPreviewPanel::render(ECS::World& world, EditorContext& ctx) { camera.fovRad = cam->fov * kDegToRad; camera.nearClip = std::max(cam->nearClip, 0.05f); camera.farClip = std::max(cam->farClip, 50.0f); - const f32 aspect = static_cast(w) / static_cast(std::max(h, 1u)); + const u32 renderW = std::max(m_width, 1u); + const u32 renderH = std::max(m_height, 1u); + const f32 aspect = static_cast(renderW) / static_cast(renderH); camera.proj = Mat4::perspective(camera.fovRad, aspect, camera.nearClip, camera.farClip); - m_renderer.renderWithCamera(m_frameCmd, world, camera, m_colorTarget, m_depthTarget, w, h, - projectRootFromContext(ctx)); + Render::GpuSceneRenderOptions previewOpts; + previewOpts.enableShadows = false; + previewOpts.wireframeMeshes = false; + + const bool camChanged = (position - m_lastCamPos).length() > 0.001f + || (forward - m_lastCamForward).length() > 0.001f + || std::abs(camera.fovRad - m_lastFovRad) > 0.001f; + const bool sizeChanged = targetW != m_lastTargetW || targetH != m_lastTargetH; + const u32 gpuInterval = camChanged ? 1u : 2u; + const bool rerunGpu = (camChanged || sizeChanged || !m_hasGpuFrame) + && editorPanelWorthGpuRender(origin, panelSize, gpuInterval); + + { + Render::SkyboxCamera skyCamera; + skyCamera.forward = forward; + skyCamera.right = right; + skyCamera.up = up; + skyCamera.fovY = camera.fovRad; + skyCamera.aspect = aspect; + const int longest = static_cast(std::max(panelSize.x, panelSize.y)); + const int skyCap = std::clamp(longest, 256, 512); + drawSceneSkybox(dl, origin, panelSize, world, ctx, skyCamera, m_skyboxRenderer, skyCap); + } - Render::SkyboxCamera skyCamera; - skyCamera.forward = forward; - skyCamera.right = right; - skyCamera.up = up; - skyCamera.fovY = camera.fovRad; - skyCamera.aspect = aspect; - drawSceneSkybox(dl, origin, panelSize, world, ctx, skyCamera, m_skyboxRenderer); + if (rerunGpu) { + m_renderer.renderWithCamera(m_frameCmd, world, camera, m_colorTarget, m_depthTarget, + m_width, m_height, projectRootFromContext(ctx), previewOpts); + m_lastCamPos = position; + m_lastCamForward = forward; + m_lastFovRad = camera.fovRad; + m_lastTargetW = targetW; + m_lastTargetH = targetH; + m_hasGpuFrame = true; + } if (m_colorTarget && m_colorTarget->handle) { dl->AddImage(reinterpret_cast(m_colorTarget->handle), origin, diff --git a/src/editor/GameplayPreviewPanel.hpp b/src/editor/GameplayPreviewPanel.hpp index 90811b6..dc31d30 100644 --- a/src/editor/GameplayPreviewPanel.hpp +++ b/src/editor/GameplayPreviewPanel.hpp @@ -45,6 +45,12 @@ class GameplayPreviewPanel { u32 m_height = 0; Render::GpuSceneRenderer m_renderer; bool m_ready = false; + Vec3 m_lastCamPos{}; + Vec3 m_lastCamForward{}; + f32 m_lastFovRad = 0.0f; + u32 m_lastTargetW = 0; + u32 m_lastTargetH = 0; + bool m_hasGpuFrame = false; #endif #ifdef CF_HAS_IMGUI Render::SkyboxRenderer m_skyboxRenderer; diff --git a/src/editor/ProfilerWindow.hpp b/src/editor/ProfilerWindow.hpp index 09fde7f..6ad525a 100644 --- a/src/editor/ProfilerWindow.hpp +++ b/src/editor/ProfilerWindow.hpp @@ -5,6 +5,9 @@ #include "containers/Vector.hpp" #include #include +#include +#include +#include #ifdef CF_HAS_IMGUI #include @@ -50,6 +53,11 @@ class ProfilerWindow { ImGui::SameLine(); if (ImGui::Button("Reset Stats")) { Debug::Profiler::instance().reset(); + m_forceScopeRefresh = true; + } + ImGui::SameLine(); + if (ImGui::Button(m_expandAll ? "Collapse All" : "Expand All")) { + m_expandAll = !m_expandAll; } ImGui::Text("Frame: %.2f ms FPS: %.1f", lastMs, fps); @@ -81,36 +89,171 @@ class ProfilerWindow { nullptr, 0.0f, maxVal, ImVec2(-1, 80)); ImGui::TextDisabled("Budget: 16.7 ms (60 FPS)"); - Vector scopes; - profiler.report(scopes); - if (scopes.empty()) { + if (m_forceScopeRefresh || (ImGui::GetFrameCount() % 15u) == 0u) { + profiler.report(m_cachedScopes); + m_forceScopeRefresh = false; + } + + const Vector& scopes = m_cachedScopes; + usize sampledScopes = 0; + for (usize i = 1; i < scopes.size(); ++i) { + if (scopes[i].callCount > 0) { + sampledScopes++; + } + } + + ImGui::SeparatorText("Scope tree"); + ImGui::TextDisabled( + "Hierarchical call stack — self ms = exclusive, total ms = inclusive. " + "Instrument with CF_PROFILE_SCOPE."); + ImGui::Checkbox("Sort by self time", &m_sortBySelf); + ImGui::SameLine(); + ImGui::Checkbox("Hide empty scopes", &m_hideEmptyScopes); + + if (sampledScopes == 0) { ImGui::TextDisabled("No scope samples yet. Instrument hot paths with CF_PROFILE_SCOPE."); - } else if (ImGui::BeginTable("scopes", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Resizable)) { + } else if (ImGui::BeginTable("scope_tree", 7, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg + | ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY, + ImVec2(-1.0f, 260.0f))) { + ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableSetupColumn("Scope"); - ImGui::TableSetupColumn("avg ms"); - ImGui::TableSetupColumn("max ms"); + ImGui::TableSetupColumn("self ms"); ImGui::TableSetupColumn("total ms"); + ImGui::TableSetupColumn("% frame"); + ImGui::TableSetupColumn("% parent"); + ImGui::TableSetupColumn("max ms"); ImGui::TableSetupColumn("calls"); ImGui::TableHeadersRow(); - for (usize i = 0; i < scopes.size(); ++i) { - const auto& s = scopes[i]; - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(s.name ? s.name : ""); - ImGui::TableSetColumnIndex(1); ImGui::Text("%.3f", static_cast(s.avgMs)); - ImGui::TableSetColumnIndex(2); ImGui::Text("%.3f", static_cast(s.maxMs)); - ImGui::TableSetColumnIndex(3); ImGui::Text("%.3f", static_cast(s.totalMs)); - ImGui::TableSetColumnIndex(4); ImGui::Text("%llu", static_cast(s.callCount)); - } + + const f64 frameBudgetMs = (lastMs > 0.0f) ? static_cast(lastMs) : 16.7; + renderScopeChildren(scopes, 0u, frameBudgetMs, 0.0); + ImGui::EndTable(); } } ImGui::End(); } + +private: + static void appendScopePath(const Vector& scopes, u32 nodeIndex, + char* out, usize outCap) { + if (nodeIndex == Debug::Profiler::kInvalidNode || nodeIndex >= scopes.size() || outCap == 0) { + if (outCap > 0) out[0] = '\0'; + return; + } + + const auto& node = scopes[nodeIndex]; + if (node.parentIndex != Debug::Profiler::kInvalidNode && node.parentIndex != 0u + && node.parentIndex < scopes.size()) { + appendScopePath(scopes, node.parentIndex, out, outCap); + const usize len = std::strlen(out); + if (len + 3 < outCap) { + std::snprintf(out + len, outCap - len, " > "); + } + } + + const usize len = std::strlen(out); + if (node.name && len + 1 < outCap) { + std::snprintf(out + len, outCap - len, "%s", node.name); + } + } + + void renderScopeChildren(const Vector& scopes, u32 parentIndex, + f64 frameBudgetMs, f64 parentTotalMs) { + Vector children; + u32 child = scopes[parentIndex].firstChildIndex; + while (child != Debug::Profiler::kInvalidNode && child < scopes.size()) { + if (!m_hideEmptyScopes || scopes[child].callCount > 0) { + children.pushBack(child); + } + child = scopes[child].nextSiblingIndex; + } + + std::sort(children.begin(), children.end(), [&](u32 a, u32 b) { + const auto& sa = scopes[a]; + const auto& sb = scopes[b]; + if (m_sortBySelf) { + if (sa.selfMs != sb.selfMs) return sa.selfMs > sb.selfMs; + return sa.totalMs > sb.totalMs; + } + if (sa.totalMs != sb.totalMs) return sa.totalMs > sb.totalMs; + return sa.selfMs > sb.selfMs; + }); + + for (u32 nodeIndex : children) { + renderScopeNode(scopes, nodeIndex, frameBudgetMs, parentTotalMs); + } + } + + void renderScopeNode(const Vector& scopes, u32 nodeIndex, + f64 frameBudgetMs, f64 parentTotalMs) { + const auto& scope = scopes[nodeIndex]; + bool hasChildren = false; + for (u32 c = scope.firstChildIndex; c != Debug::Profiler::kInvalidNode && c < scopes.size(); + c = scopes[c].nextSiblingIndex) { + if (!m_hideEmptyScopes || scopes[c].callCount > 0) { + hasChildren = true; + break; + } + } + + const f64 avgSelf = scope.avgSelfMs; + const f64 avgTotal = scope.avgMs; + const f64 pctFrame = (frameBudgetMs > 0.0) ? (avgSelf / frameBudgetMs) * 100.0 : 0.0; + const f64 parentDenom = std::max(parentTotalMs, 0.001); + const f64 pctParent = (parentTotalMs > 0.0) ? (avgSelf / parentDenom) * 100.0 : pctFrame; + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth; + if (!hasChildren) { + flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; + } else if (m_expandAll) { + flags |= ImGuiTreeNodeFlags_DefaultOpen; + } + + const bool open = ImGui::TreeNodeEx( + (void*)(static_cast(nodeIndex + 1u)), + flags, + "%s", + scope.name ? scope.name : "?"); + if (ImGui::IsItemHovered()) { + char path[512]; + path[0] = '\0'; + appendScopePath(scopes, nodeIndex, path, sizeof(path)); + ImGui::SetTooltip("%s\navg self: %.3f ms avg total: %.3f ms", path, avgSelf, avgTotal); + } + + ImGui::TableSetColumnIndex(1); + ImGui::Text("%.3f", static_cast(avgSelf)); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%.3f", static_cast(avgTotal)); + ImGui::TableSetColumnIndex(3); + ImGui::Text("%.1f%%", static_cast(pctFrame)); + ImGui::TableSetColumnIndex(4); + ImGui::Text("%.1f%%", static_cast(pctParent)); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%.3f", static_cast(scope.maxMs)); + ImGui::TableSetColumnIndex(6); + ImGui::Text("%llu", static_cast(scope.callCount)); + + if (hasChildren && open) { + renderScopeChildren(scopes, nodeIndex, frameBudgetMs, avgTotal); + ImGui::TreePop(); + } + } #endif private: bool m_open = true; bool m_paused = false; + bool m_expandAll = false; + bool m_sortBySelf = true; + bool m_hideEmptyScopes = true; + bool m_forceScopeRefresh = true; + Vector m_cachedScopes; std::array m_frameTimes {}; u32 m_frameIdx = 0; }; diff --git a/src/editor/SceneEditor.cpp b/src/editor/SceneEditor.cpp index 6ff1d36..604762c 100644 --- a/src/editor/SceneEditor.cpp +++ b/src/editor/SceneEditor.cpp @@ -719,7 +719,6 @@ void SceneEditor::render(f32 deltaTime) { profile.tilemapEditorOpen ? m_tilemapEditor.open() : m_tilemapEditor.close(); profile.animationTimelineOpen ? m_animationTimeline.open() : m_animationTimeline.close(); profile.animatorControllerOpen ? m_animatorController.open() : m_animatorController.close(); - m_materialEditor.open(); m_layoutNeedsRebuild = false; m_dockingSetup = true; @@ -727,30 +726,78 @@ void SceneEditor::render(f32 deltaTime) { renderMainMenuBar(*activeWorld); - Scene::propagateTransforms(*activeWorld); + { + CF_PROFILE_SCOPE("SceneEditor::propagateTransforms"); + Scene::propagateTransforms(*activeWorld); + } // Render panels - m_hierarchy.render(*activeWorld, m_ctx); - m_inspector.render(*activeWorld, m_ctx); + { + CF_PROFILE_SCOPE("SceneEditor::hierarchy"); + m_hierarchy.render(*activeWorld, m_ctx); + } + { + CF_PROFILE_SCOPE("SceneEditor::inspector"); + m_inspector.render(*activeWorld, m_ctx); + } renderPlaybar(*activeWorld); m_viewport.setFrameCommandBuffer(m_frameCmd); #ifdef CF_HAS_SDL3 m_gameplayPreview.setFrameCommandBuffer(m_frameCmd); #endif - m_viewport.render(*activeWorld, m_ctx); - m_assetBrowser.render(*activeWorld, m_ctx); - m_console.render(); - m_profiler.render(Debug::Profiler::instance()); - m_entityDebugger.render(*activeWorld, m_ctx); - m_scriptEditor.render(); - m_settingsPanel.render(); - m_materialEditor.onImGuiRender(); - m_terrainEditor.render(*activeWorld, m_ctx); + { + CF_PROFILE_SCOPE("SceneEditor::viewport"); + m_viewport.render(*activeWorld, m_ctx); + } + { + CF_PROFILE_SCOPE("SceneEditor::assetBrowser"); + m_assetBrowser.render(*activeWorld, m_ctx); + } + { + CF_PROFILE_SCOPE("SceneEditor::console"); + m_console.render(); + } + { + CF_PROFILE_SCOPE("SceneEditor::profiler"); + m_profiler.render(Debug::Profiler::instance()); + } + { + CF_PROFILE_SCOPE("SceneEditor::entityDebugger"); + m_entityDebugger.render(*activeWorld, m_ctx); + } + { + CF_PROFILE_SCOPE("SceneEditor::scriptEditor"); + m_scriptEditor.render(); + } + { + CF_PROFILE_SCOPE("SceneEditor::settings"); + m_settingsPanel.render(); + } + { + CF_PROFILE_SCOPE("SceneEditor::materialEditor"); + m_materialEditor.onImGuiRender(); + } + { + CF_PROFILE_SCOPE("SceneEditor::terrainEditor"); + m_terrainEditor.render(*activeWorld, m_ctx); + } m_entityPresets.setProjectRoot(m_currentProjectConfig.RootPath); - m_entityPresets.render(*activeWorld, m_ctx); - m_audioPreview.onImGuiRender(); - m_cameraPreview.onImGuiRender(*activeWorld, m_ctx, m_viewport); - m_gameplayPreview.render(*activeWorld, m_ctx); + { + CF_PROFILE_SCOPE("SceneEditor::entityPresets"); + m_entityPresets.render(*activeWorld, m_ctx); + } + { + CF_PROFILE_SCOPE("SceneEditor::audioPreview"); + m_audioPreview.onImGuiRender(); + } + { + CF_PROFILE_SCOPE("SceneEditor::cameraPreview"); + m_cameraPreview.onImGuiRender(*activeWorld, m_ctx, m_viewport); + } + { + CF_PROFILE_SCOPE("SceneEditor::gameplayPreview"); + m_gameplayPreview.render(*activeWorld, m_ctx); + } m_viewport.setFrameCommandBuffer(nullptr); #ifdef CF_HAS_SDL3 m_gameplayPreview.setFrameCommandBuffer(nullptr); @@ -760,8 +807,11 @@ void SceneEditor::render(f32 deltaTime) { m_tilemapEditor.render(); m_commandPalette.render(); m_buildDialog.render(); - PluginManager::instance().renderPanels(); - PluginManager::instance().renderManagerUI(); + { + CF_PROFILE_SCOPE("SceneEditor::plugins"); + PluginManager::instance().renderPanels(); + PluginManager::instance().renderManagerUI(); + } ImGui::End(); // DockSpace diff --git a/src/editor/SceneViewport.cpp b/src/editor/SceneViewport.cpp index e658b2f..0eff4cf 100644 --- a/src/editor/SceneViewport.cpp +++ b/src/editor/SceneViewport.cpp @@ -644,6 +644,7 @@ bool SceneViewport::renderCameraPreviewGpu(RHI::CommandBuffer* cmd, ECS::World& camera.farClip = farClip; Render::GpuSceneRenderOptions previewOpts; + previewOpts.enableShadows = false; previewOpts.textureQuality.enabled = ctx.textureQualityEnabled; previewOpts.textureQuality.fullRadius = ctx.textureQualityRadius; previewOpts.textureQuality.falloffDistance = ctx.textureQualityFalloff; @@ -731,8 +732,11 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { return; } - const ImVec2 framebufferSize = imguiFramebufferSize(viewportSize); - resizeCanvasIfNeeded((u32)framebufferSize.x, (u32)framebufferSize.y); + const ImVec2 framebufferSize = imguiFramebufferSize(viewportSize, 1280); + const u32 targetCanvasW = static_cast(framebufferSize.x); + const u32 targetCanvasH = static_cast(framebufferSize.y); + const bool canvasResized = m_gpuCacheWidth != targetCanvasW || m_gpuCacheHeight != targetCanvasH; + resizeCanvasIfNeeded(targetCanvasW, targetCanvasH); if (ctx.viewMode == EditorContext::ViewMode::Mode3D) { Scene::syncTerrainMeshes(world); @@ -746,39 +750,43 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { if (gpuSceneActive) { const Vec3 camPos = editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); - const f32 yawDelta = std::abs(ctx.camYaw - m_lastEditorCamYaw); - const f32 pitchDelta = std::abs(ctx.camPitch - m_lastEditorCamPitch); - const f32 posDelta = (camPos - m_lastEditorCamPos).length(); - const f32 frameMotion = yawDelta * 14.0f + pitchDelta * 14.0f + posDelta; - m_editorCamMotion = m_editorCamMotion * 0.75f + frameMotion * 0.25f; - m_lastEditorCamYaw = ctx.camYaw; - m_lastEditorCamPitch = ctx.camPitch; - m_lastEditorCamPos = camPos; - - Render::GpuSceneRenderOptions gpuOpts; - gpuOpts.wireframeMeshes = wireframePreview; - // Shadow passes re-draw the whole scene (x4 cascades + point faces). Skip while orbiting. - gpuOpts.enableShadows = !wireframePreview && m_editorCamMotion < 1.25f; - gpuOpts.textureQuality.enabled = ctx.textureQualityEnabled; - gpuOpts.textureQuality.fullRadius = ctx.textureQualityRadius; - gpuOpts.textureQuality.falloffDistance = ctx.textureQualityFalloff; - gpuOpts.textureQuality.minScale = ctx.textureQualityMinScale; - gpuOpts.textureQualityViewers.push_back(camPos); - ECS::ComponentQuery camQuery; - camQuery.with(); - world.forEach(camQuery, - [&](ECS::Entity entity, ECS::Camera3DComponent&) { - Vec3 camEntityPos; - if (tryGetEntityPosition(world, entity, camEntityPos)) { - gpuOpts.textureQualityViewers.push_back(camEntityPos); - } - }); - - Caffeine::Debug::setCrashBreadcrumb("SceneViewport::gpuSceneRenderer"); - gpuMeshDrawCount = m_gpuSceneRenderer.render(m_frameCmd, world, ctx, m_colorTarget, - m_depthTarget, m_lastCanvasWidth, - m_lastCanvasHeight, projectRoot, gpuOpts); - Caffeine::Debug::setCrashBreadcrumb("SceneViewport::gpuSceneRenderer.done"); + const f32 camMotion = std::abs(ctx.camYaw - m_lastEditorCamYaw) + + std::abs(ctx.camPitch - m_lastEditorCamPitch) + + std::abs(ctx.camDistance - m_lastEditorCamDistance) + + (camPos - m_lastEditorCamPos).length() + + (ctx.camFocus - m_lastEditorCamFocus).length(); + m_editorCamMotion = camMotion; + const bool editorCameraMoved = camMotion > 0.0001f; + const bool previewModeChanged = m_meshPreviewMode != m_lastMeshPreviewMode; + const bool gpuNeedsRedraw = + editorCameraMoved || canvasResized || previewModeChanged || !m_hasValidGpuFrame; + + if (gpuNeedsRedraw) { + CF_PROFILE_SCOPE("SceneViewport::gpuPass"); + Render::GpuSceneRenderOptions gpuOpts; + gpuOpts.wireframeMeshes = wireframePreview; + // Editor viewport: shadows multiply scene draws (4 cascades × lights). Runtime/gameplay + // previews keep their own lighting; shadows belong in play mode / runtime, not edit mode. + gpuOpts.enableShadows = false; + gpuOpts.terrainLodDistanceScale = 2.0f; + gpuOpts.textureQuality.enabled = false; + + Caffeine::Debug::setCrashBreadcrumb("SceneViewport::gpuSceneRenderer"); + gpuMeshDrawCount = m_gpuSceneRenderer.render(m_frameCmd, world, ctx, m_colorTarget, + m_depthTarget, m_lastCanvasWidth, + m_lastCanvasHeight, projectRoot, gpuOpts); + Caffeine::Debug::setCrashBreadcrumb("SceneViewport::gpuSceneRenderer.done"); + + m_lastEditorCamYaw = ctx.camYaw; + m_lastEditorCamPitch = ctx.camPitch; + m_lastEditorCamDistance = ctx.camDistance; + m_lastEditorCamPos = camPos; + m_lastEditorCamFocus = ctx.camFocus; + m_lastMeshPreviewMode = m_meshPreviewMode; + m_gpuCacheWidth = m_lastCanvasWidth; + m_gpuCacheHeight = m_lastCanvasHeight; + m_hasValidGpuFrame = true; + } } ImGui::Dummy(viewportSize); m_lastGpuSceneActive = gpuSceneActive; @@ -1118,14 +1126,17 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { const ImVec2 viewportMax(origin.x + viewportSize.x, origin.y + viewportSize.y); if (ctx.viewMode == EditorContext::ViewMode::Mode3D) { + CF_PROFILE_SCOPE("SceneViewport::skybox"); if (!drawSkybox(drawList, origin, viewportSize, world, ctx)) { drawList->AddRectFilledMultiColor( origin, viewportMax, IM_COL32(26, 26, 31, 255), IM_COL32(26, 26, 31, 255), IM_COL32(42, 48, 62, 255), IM_COL32(42, 48, 62, 255)); } - // Grid behind GPU terrain so it is occluded instead of looking transparent. - drawGrid(drawList, origin, viewportSize, ctx); + // Skip CPU grid under the GPU composite — it was fully covered and wasted draw calls. + if (!m_lastGpuSceneActive) { + drawGrid(drawList, origin, viewportSize, ctx); + } } else { drawList->AddRectFilled(origin, viewportMax, IM_COL32(26, 26, 31, 255)); } @@ -1136,6 +1147,9 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { if (m_lastGpuSceneActive && m_colorTarget && m_colorTarget->handle) { drawList->AddImage(reinterpret_cast(m_colorTarget->handle), origin, viewportMax, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32_WHITE); + if (m_config.grid) { + drawGrid(drawList, origin, viewportSize, ctx); + } } #endif @@ -1375,8 +1389,14 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { if (ctx.viewMode != EditorContext::ViewMode::Mode3D) { drawGrid(drawList, origin, viewportSize, ctx); } - drawSprites(world, ctx, origin, viewportSize); - drawEmptyEntities(world, ctx, origin, viewportSize); + { + CF_PROFILE_SCOPE("SceneViewport::sprites"); + drawSprites(world, ctx, origin, viewportSize); + } + { + CF_PROFILE_SCOPE("SceneViewport::entities"); + drawEmptyEntities(world, ctx, origin, viewportSize); + } drawPhysicsDebug(world, ctx, origin, viewportSize); drawCameraFrustums(world, ctx, origin, viewportSize); drawLightGizmos(world, ctx, origin, viewportSize); @@ -1530,26 +1550,30 @@ void SceneViewport::render(ECS::World& world, EditorContext& ctx) { UI::drawWidgets(world, drawList, origin, viewportSize); } - ECS::Entity previewCamera = ctx.selectedEntity; - if (!previewCamera.isValid() || - (!world.has(previewCamera) && - !world.has(previewCamera))) { - ECS::ComponentQuery cameraQuery; - cameraQuery.with(); - world.forEach(cameraQuery, [&](ECS::Entity e, ECS::Camera3DComponent&) { - if (!previewCamera.isValid()) previewCamera = e; - }); - if (!previewCamera.isValid()) { - cameraQuery = {}; - cameraQuery.with(); - world.forEach(cameraQuery, [&](ECS::Entity e, ECS::Camera2DComponent&) { + if (ctx.viewMode == EditorContext::ViewMode::Mode3D) { + ECS::Entity previewCamera = ctx.selectedEntity; + if (!previewCamera.isValid() || + (!world.has(previewCamera) && + !world.has(previewCamera))) { + ECS::ComponentQuery cameraQuery; + cameraQuery.with(); + world.forEach(cameraQuery, [&](ECS::Entity e, ECS::Camera3DComponent&) { if (!previewCamera.isValid()) previewCamera = e; }); + if (!previewCamera.isValid()) { + cameraQuery = {}; + cameraQuery.with(); + world.forEach(cameraQuery, [&](ECS::Entity e, ECS::Camera2DComponent&) { + if (!previewCamera.isValid()) previewCamera = e; + }); + } + } + if (const ECS::PostProcessComponent* fx = + Render::findPostProcessForCamera(world, previewCamera)) { + if (fx->enabled) { + Render::applyPostProcessOverlay(drawList, origin, viewportSize, *fx); + } } - } - if (const ECS::PostProcessComponent* fx = - Render::findPostProcessForCamera(world, previewCamera)) { - Render::applyPostProcessOverlay(drawList, origin, viewportSize, *fx); } ImGui::End(); @@ -1832,10 +1856,14 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV m_gpuSceneReady && m_useGpuScene && m_lastGpuSceneActive); const bool gpuWireframe3D = (ctx.viewMode == EditorContext::ViewMode::Mode3D && wireMode && m_gpuSceneReady && m_useGpuScene && m_lastGpuSceneActive); + // GPU and CPU must not rasterize the same meshes in one frame. + const bool gpuOwnsSceneMeshes = gpuTextured3D || gpuWireframe3D; Scene::SceneLighting sceneLighting; - Scene::gatherSceneLighting(world, ctx.camFocus, projectRoot, sceneLighting, - ECS::Entity::INVALID, !gpuTextured3D); + if (!gpuOwnsSceneMeshes) { + Scene::gatherSceneLighting(world, ctx.camFocus, projectRoot, sceneLighting, + ECS::Entity::INVALID, true); + } auto lightColorAt = [&](const Vec3& p, const Vec3& n, bool receiveShadows) -> Vec3 { return Scene::evaluateDiffuseLighting(sceneLighting.lights, sceneLighting.shadows, p, n, @@ -1918,10 +1946,25 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV const Mat4 worldMatrix = entityMatrix(world, entity); const bool selected = (ctx.selectedEntity == entity); - if (gpuTextured3D && !selected) { - return true; - } - if (gpuWireframe3D && !selected) { + if (gpuOwnsSceneMeshes) { + if (ctx.terrainShowChunkDebug && world.has(entity)) { + auto* terrain = world.get(entity); + if (terrain) { + const Vec3 camPos = + editorCameraPosition(ctx.camYaw, ctx.camPitch, ctx.camDistance, ctx.camFocus); + const f32 aspectFrustum = viewportSize.x / std::max(viewportSize.y, 1.0f); + const Spatial::Frustum frustum = Spatial::Frustum::fromCamera( + camPos, ctx.camFocus, Vec3(0.0f, 1.0f, 0.0f), 1.0472f, aspectFrustum, + 0.1f, ctx.cameraFarPlane()); + m_terrainCullStats = {}; + m_terrainDrawChunks.clear(); + Terrain::TerrainCache::instance().gatherDrawMeshes( + entity, *terrain, worldMatrix, camPos, frustum, m_terrainDrawChunks, + &m_terrainCullStats); + drawTerrainChunkDebug(dl, worldMatrix, m_terrainDrawChunks, + origin, viewportSize, ctx); + } + } return true; } const ImU32 wireCol = selected ? IM_COL32(110, 210, 255, 255) : IM_COL32(170, 190, 230, 210); @@ -2347,6 +2390,12 @@ void SceneViewport::drawEmptyEntities(ECS::World& world, EditorContext& ctx, ImV auto drawEntity = [&](ECS::Entity entity) { if (Scene::isEffectivelyDisabled(world, entity)) return; if (world.has(entity)) return; + if (gpuOwnsSceneMeshes && world.has(entity)) { + if (ctx.terrainShowChunkDebug && world.has(entity)) { + drawMeshPreview(entity); + } + return; + } if (drawMeshPreview(entity)) return; @@ -2835,7 +2884,8 @@ bool SceneViewport::drawSkyboxForView(ImDrawList* drawList, ImVec2 origin, ImVec ECS::World& world, const EditorContext& ctx, const Render::SkyboxCamera& camera, bool respectEditorToggle, - Render::SkyboxRenderer* renderer) { + Render::SkyboxRenderer* renderer, + int skyboxMaxRasterDim) { if (!drawList) return false; if (respectEditorToggle && !ctx.skyboxEnabled) return false; @@ -2856,7 +2906,11 @@ bool SceneViewport::drawSkyboxForView(ImDrawList* drawList, ImVec2 origin, ImVec const std::string textureKey = texturePath.string(); Render::SkyboxRenderer& target = renderer ? *renderer : m_skyboxRenderer; - return target.draw(drawList, origin, viewportSize, camera, textureKey); + const int longestEdge = static_cast(std::max(viewportSize.x, viewportSize.y)); + const int skyRasterCap = skyboxMaxRasterDim > 0 + ? skyboxMaxRasterDim + : std::clamp(longestEdge, 384, 640); + return target.draw(drawList, origin, viewportSize, camera, textureKey, skyRasterCap); } bool SceneViewport::drawSkybox(ImDrawList* drawList, ImVec2 origin, ImVec2 viewportSize, @@ -3614,6 +3668,8 @@ void SceneViewport::drawSceneMeshesForCamera( ImVec2 panelSize, ECS::Entity skipEntity, int maxRasterDim) { + // CPU-only mesh rasterization for camera previews. Callers must choose either this + // or renderCameraPreviewGpu() per frame — never both. // Viewport already syncs terrain in 3D mode. Avoid rebuilding meshes mid-frame // while GPU commands from the viewport pass may still reference chunk buffers. if (ctx.viewMode != EditorContext::ViewMode::Mode3D) { diff --git a/src/editor/SceneViewport.hpp b/src/editor/SceneViewport.hpp index 4f82ff4..66963e3 100644 --- a/src/editor/SceneViewport.hpp +++ b/src/editor/SceneViewport.hpp @@ -161,7 +161,8 @@ class SceneViewport { ECS::World& world, const EditorContext& ctx, const Render::SkyboxCamera& camera, bool respectEditorToggle = true, - Render::SkyboxRenderer* renderer = nullptr); + Render::SkyboxRenderer* renderer = nullptr, + int skyboxMaxRasterDim = 0); #endif private: @@ -288,8 +289,14 @@ class SceneViewport { u32 m_previewCanvasHeight = 0; f32 m_lastEditorCamYaw = 0.0f; f32 m_lastEditorCamPitch = 0.0f; + f32 m_lastEditorCamDistance = 0.0f; Vec3 m_lastEditorCamPos{}; + Vec3 m_lastEditorCamFocus{}; f32 m_editorCamMotion = 0.0f; + MeshPreviewMode m_lastMeshPreviewMode = MeshPreviewMode::Textured; + u32 m_gpuCacheWidth = 0; + u32 m_gpuCacheHeight = 0; + bool m_hasValidGpuFrame = false; #endif }; diff --git a/src/render/GpuSceneRenderer.cpp b/src/render/GpuSceneRenderer.cpp index 3c601d5..4f88a5c 100644 --- a/src/render/GpuSceneRenderer.cpp +++ b/src/render/GpuSceneRenderer.cpp @@ -1,4 +1,5 @@ #include "render/GpuSceneRenderer.hpp" +#include "debug/Profiler.hpp" #include "render/ShaderBytecode.hpp" #include "render/GpuProceduralMeshes.hpp" #include "editor/EditorContext.hpp" @@ -685,6 +686,9 @@ u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vectorbeginRenderPass(pass); cmd->bindPipeline(wireframeMeshes ? m_wireframePipeline : m_scenePipeline); cmd->setViewport(0, 0, static_cast(width), static_cast(height)); + if (!wireframeMeshes) { + cmd->pushUniformData(RHI::ShaderStage::Fragment, 1, &shadowUbo, sizeof(shadowUbo)); + } auto bindShadowTextures = [&](u32 dirBase, u32 pointBase, u32 spotBase) { for (u32 slot = 0; slot < GpuDirectionalShadowMap::kMaxDirectionalShadowLights; ++slot) { @@ -712,8 +716,12 @@ u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vectorindices.empty()) continue; if (!draw.mesh->vertexBuffer || !draw.mesh->indexBuffer) continue; @@ -821,59 +829,69 @@ u32 GpuSceneRenderer::renderMeshes(RHI::CommandBuffer* cmd, const std::vectorbindTexture(whiteTex, slot, m_repeatSampler); - } if (gpu && gpu->useSplatmap) { mat.flags[0] = 1.0f; - if (gpu->splatMap) { - cmd->bindTexture(gpu->splatMap, 0, m_sampler); + } else if (gpu && (!gpu->cachedAlbedoPath.empty() || gpu->albedo)) { + mat.flags[1] = 1.0f; + } + if (gpu && (!gpu->cachedNormalPath.empty() || gpu->normalMap)) { + lights.flags[1] = 1.0f; + } + + const bool rebindTerrainTextures = draw.entity != lastTerrainTexEntity + || texTier != lastTerrainTexTier; + if (rebindTerrainTextures) { + lastTerrainTexEntity = draw.entity; + lastTerrainTexTier = texTier; + auto& texCache = GpuTextureCache::instance(); + for (u32 slot = 0; slot < 6; ++slot) { + if (whiteTex) cmd->bindTexture(whiteTex, slot, m_repeatSampler); } - for (u32 i = 0; i < ECS::kTerrainSplatLayerCount; ++i) { - RHI::Texture* layerTex = whiteTex; - if (!gpu->cachedLayerPaths[i].empty()) { - layerTex = texCache.acquire(m_device, gpu->cachedLayerPaths[i], projectRoot, - texTier); - } else if (gpu->layers[i]) { - layerTex = gpu->layers[i]; + if (gpu && gpu->useSplatmap) { + if (gpu->splatMap) { + cmd->bindTexture(gpu->splatMap, 0, m_sampler); } - if (layerTex) { - cmd->bindTexture(layerTex, 1 + i, m_repeatSampler); + for (u32 i = 0; i < ECS::kTerrainSplatLayerCount; ++i) { + RHI::Texture* layerTex = whiteTex; + if (!gpu->cachedLayerPaths[i].empty()) { + layerTex = texCache.acquire(m_device, gpu->cachedLayerPaths[i], + projectRoot, texTier); + } else if (gpu->layers[i]) { + layerTex = gpu->layers[i]; + } + if (layerTex) { + cmd->bindTexture(layerTex, 1 + i, m_repeatSampler); + } + } + } else if (gpu) { + RHI::Texture* albedoTex = whiteTex; + if (!gpu->cachedAlbedoPath.empty()) { + albedoTex = + texCache.acquire(m_device, gpu->cachedAlbedoPath, projectRoot, texTier); + } else if (gpu->albedo) { + albedoTex = gpu->albedo; + } + if (albedoTex) { + cmd->bindTexture(albedoTex, 4, m_repeatSampler); } } - } else if (gpu) { - RHI::Texture* albedoTex = whiteTex; - if (!gpu->cachedAlbedoPath.empty()) { - albedoTex = - texCache.acquire(m_device, gpu->cachedAlbedoPath, projectRoot, texTier); - } else if (gpu->albedo) { - albedoTex = gpu->albedo; - } - if (albedoTex) { - mat.flags[1] = 1.0f; - cmd->bindTexture(albedoTex, 4, m_repeatSampler); - } - } - if (gpu) { - RHI::Texture* normalTex = nullptr; - if (!gpu->cachedNormalPath.empty()) { - normalTex = - texCache.acquire(m_device, gpu->cachedNormalPath, projectRoot, texTier); - } else if (gpu->normalMap) { - normalTex = gpu->normalMap; - } - if (normalTex) { - lights.flags[1] = 1.0f; - cmd->bindTexture(normalTex, 5, m_repeatSampler); + if (gpu) { + RHI::Texture* normalTex = nullptr; + if (!gpu->cachedNormalPath.empty()) { + normalTex = + texCache.acquire(m_device, gpu->cachedNormalPath, projectRoot, texTier); + } else if (gpu->normalMap) { + normalTex = gpu->normalMap; + } + if (normalTex) { + cmd->bindTexture(normalTex, 5, m_repeatSampler); + } } } bindShadowTextures(6, 8, 10); cmd->pushUniformData(RHI::ShaderStage::Fragment, 2, &mat, sizeof(mat)); } - cmd->pushUniformData(RHI::ShaderStage::Fragment, 1, &shadowUbo, sizeof(shadowUbo)); - VertexUBO vubo{}; const Mat4 mvp = vp * draw.worldMatrix; std::memcpy(vubo.mvp, mvp.data(), sizeof(vubo.mvp)); @@ -972,7 +990,7 @@ std::vector GpuSceneRenderer::gatherMeshDraws( std::vector terrainChunks; cache.gatherDrawMeshes(entity, *terrain, worldMatrix, cameraPos, frustum, - terrainChunks); + terrainChunks, nullptr, options.terrainLodDistanceScale); for (const Terrain::TerrainDrawChunk& chunk : terrainChunks) { if (!chunk.mesh || chunk.mesh->vertices.empty()) continue; MeshDraw draw; @@ -1057,6 +1075,7 @@ u32 GpuSceneRenderer::renderWithCamera(RHI::CommandBuffer* cmd, ECS::World& worl RHI::Texture* depthTarget, u32 width, u32 height, const std::string& projectRoot, const GpuSceneRenderOptions& options) { + CF_PROFILE_SCOPE("GpuSceneRenderer::renderWithCamera"); Caffeine::Debug::setCrashBreadcrumb("GpuSceneRenderer::renderWithCamera"); if (!m_ready || !cmd || !colorTarget || !depthTarget || width < 1 || height < 1) return 0; @@ -1066,8 +1085,11 @@ u32 GpuSceneRenderer::renderWithCamera(RHI::CommandBuffer* cmd, ECS::World& worl camera.position, camera.focus, Vec3(0.0f, 1.0f, 0.0f), camera.fovRad, aspect, camera.nearClip, camera.farClip); - std::vector draws = - gatherMeshDraws(world, camera.position, frustum, projectRoot, options); + std::vector draws; + { + CF_PROFILE_SCOPE("GpuSceneRenderer::gather"); + draws = gatherMeshDraws(world, camera.position, frustum, projectRoot, options); + } // Upload GPU buffers before any render pass. Creating/uploading buffers // while a pass is recording is a common NVIDIA driver SIGSEGV. @@ -1084,13 +1106,17 @@ u32 GpuSceneRenderer::renderWithCamera(RHI::CommandBuffer* cmd, ECS::World& worl Scene::collectSceneLights(world, lighting.lights); if (!options.wireframeMeshes && options.enableShadows) { + CF_PROFILE_SCOPE("GpuSceneRenderer::shadows"); renderDirectionalShadows(cmd, world, lighting, draws, camera); renderPointShadows(cmd, world, lighting, draws); renderSpotShadows(cmd, world, lighting, draws); } - return renderMeshes(cmd, draws, vp, camera.position, camera.view, lighting, colorTarget, - depthTarget, width, height, projectRoot, options); + { + CF_PROFILE_SCOPE("GpuSceneRenderer::draw"); + return renderMeshes(cmd, draws, vp, camera.position, camera.view, lighting, colorTarget, + depthTarget, width, height, projectRoot, options); + } } u32 GpuSceneRenderer::render(RHI::CommandBuffer* cmd, ECS::World& world, diff --git a/src/render/GpuSceneRenderer.hpp b/src/render/GpuSceneRenderer.hpp index d62bcac..d1d2857 100644 --- a/src/render/GpuSceneRenderer.hpp +++ b/src/render/GpuSceneRenderer.hpp @@ -37,6 +37,8 @@ struct GpuSceneCamera { struct GpuSceneRenderOptions { bool wireframeMeshes = false; bool enableShadows = true; + /// Values > 1 bias terrain toward coarser LOD (editor preview). + f32 terrainLodDistanceScale = 1.0f; TextureQualitySettings textureQuality{}; std::vector textureQualityViewers; }; diff --git a/src/render/SkyboxRenderer.cpp b/src/render/SkyboxRenderer.cpp index e63b4c9..ad9622f 100644 --- a/src/render/SkyboxRenderer.cpp +++ b/src/render/SkyboxRenderer.cpp @@ -31,6 +31,7 @@ namespace { // Cap GPU upload size; match viewport resolution up to this limit. constexpr int kSkyboxRasterMaxDim = 2048; +constexpr int kSkyboxRasterMinDim = 256; constexpr f32 kTwoPi = 6.2831853f; u8 sampleChannelBilinear(const u8* pixels, u32 width, u32 height, f32 u, f32 v, u32 channel) { @@ -101,13 +102,14 @@ bool SkyboxRenderer::loadSource(const std::string& path, SourceImage& out) { } void SkyboxRenderer::renderPixels(SourceImage& source, FrameCache& frame, ImVec2 origin, - ImVec2 panelSize, const SkyboxCamera& camera) { + ImVec2 panelSize, const SkyboxCamera& camera, int maxRasterDim) { const int panelW = std::max(1, static_cast(panelSize.x)); const int panelH = std::max(1, static_cast(panelSize.y)); + const int rasterCap = std::clamp(maxRasterDim, kSkyboxRasterMinDim, kSkyboxRasterMaxDim); int renderW = panelW; int renderH = panelH; - if (renderW > kSkyboxRasterMaxDim || renderH > kSkyboxRasterMaxDim) { - const f32 scale = static_cast(kSkyboxRasterMaxDim) / + if (renderW > rasterCap || renderH > rasterCap) { + const f32 scale = static_cast(rasterCap) / static_cast(std::max(renderW, renderH)); renderW = std::max(1, static_cast(static_cast(renderW) * scale)); renderH = std::max(1, static_cast(static_cast(renderH) * scale)); @@ -217,10 +219,12 @@ void SkyboxRenderer::releaseGpuTextures() { destroyGpuTexture(m_gpu); m_frame.valid = false; m_frame.gpuDirty = false; + m_settledFrames = 0; } bool SkyboxRenderer::draw(ImDrawList* drawList, ImVec2 origin, ImVec2 panelSize, - const SkyboxCamera& camera, const std::string& texturePath) { + const SkyboxCamera& camera, const std::string& texturePath, + int maxRasterDim) { if (!drawList || texturePath.empty()) return false; auto& source = m_sources[texturePath]; @@ -228,15 +232,29 @@ bool SkyboxRenderer::draw(ImDrawList* drawList, ImVec2 origin, ImVec2 panelSize, const int panelW = std::max(1, static_cast(panelSize.x)); const int panelH = std::max(1, static_cast(panelSize.y)); + const int rasterCap = std::clamp(maxRasterDim, kSkyboxRasterMinDim, kSkyboxRasterMaxDim); + const bool cameraChanged = !m_frame.camera.nearlyEqual(camera, 0.006f); + if (cameraChanged) { + m_settledFrames = 0; + } else if (m_settledFrames < 8u) { + m_settledFrames++; + } + // Cheaper raster while orbiting; full quality after the camera settles. + constexpr int kMotionRasterCap = 384; + const int effectiveCap = + (m_settledFrames < 2u) ? std::min(rasterCap, kMotionRasterCap) : rasterCap; const bool needsRender = !m_frame.valid || m_frame.texturePath != texturePath || m_frame.panelW != panelW || m_frame.panelH != panelH - || !m_frame.camera.nearlyEqual(camera); + || m_frame.renderW > effectiveCap + || m_frame.renderH > effectiveCap + || cameraChanged + || (m_settledFrames == 2u && effectiveCap > kMotionRasterCap); if (needsRender) { m_frame.texturePath = texturePath; - renderPixels(source, m_frame, origin, panelSize, camera); + renderPixels(source, m_frame, origin, panelSize, camera, effectiveCap); } else { m_frame.origin = origin; } diff --git a/src/render/SkyboxRenderer.hpp b/src/render/SkyboxRenderer.hpp index 074915e..8c88b56 100644 --- a/src/render/SkyboxRenderer.hpp +++ b/src/render/SkyboxRenderer.hpp @@ -28,7 +28,8 @@ struct SkyboxCamera { class SkyboxRenderer { public: bool draw(ImDrawList* drawList, ImVec2 origin, ImVec2 panelSize, - const SkyboxCamera& camera, const std::string& texturePath); + const SkyboxCamera& camera, const std::string& texturePath, + int maxRasterDim = 1024); void releaseGpuTextures(); private: @@ -63,12 +64,13 @@ class SkyboxRenderer { bool loadSource(const std::string& path, SourceImage& out); void renderPixels(SourceImage& source, FrameCache& frame, ImVec2 origin, ImVec2 panelSize, - const SkyboxCamera& camera); + const SkyboxCamera& camera, int maxRasterDim); void blitFrame(ImDrawList* drawList, FrameCache& frame, GpuTexture& gpu); std::unordered_map m_sources; FrameCache m_frame; GpuTexture m_gpu; + u32 m_settledFrames = 0; }; #endif diff --git a/src/terrain/TerrainCache.cpp b/src/terrain/TerrainCache.cpp index 9937afa..e50ec40 100644 --- a/src/terrain/TerrainCache.cpp +++ b/src/terrain/TerrainCache.cpp @@ -343,14 +343,16 @@ void TerrainCache::gatherDrawMeshes(ECS::Entity entity, const Vec3& cameraPos, const Spatial::Frustum& frustum, std::vector& outDraws, - TerrainCullStats* stats) { + TerrainCullStats* stats, + f32 lodDistanceScale) { auto it = m_entries.find(entity.id()); if (it == m_entries.end()) return; TerrainEntry& entry = it->second; if (entry.useChunks && !entry.chunks.empty()) { TerrainLodSystem::gatherDrawMeshes(entry.chunks, entry.chunkActiveLods, settings, - worldMatrix, cameraPos, frustum, outDraws, stats); + worldMatrix, cameraPos, frustum, outDraws, stats, + lodDistanceScale); return; } diff --git a/src/terrain/TerrainCache.hpp b/src/terrain/TerrainCache.hpp index 6b4ad91..4de3737 100644 --- a/src/terrain/TerrainCache.hpp +++ b/src/terrain/TerrainCache.hpp @@ -46,7 +46,8 @@ class TerrainCache { const Vec3& cameraPos, const Spatial::Frustum& frustum, std::vector& outDraws, - TerrainCullStats* stats = nullptr); + TerrainCullStats* stats = nullptr, + f32 lodDistanceScale = 1.0f); void syncTextureToFilter(ECS::World& world, ECS::Entity entity, const ECS::TerrainComponent& terrain); diff --git a/src/terrain/TerrainLodSystem.cpp b/src/terrain/TerrainLodSystem.cpp index 8a42e45..f279ece 100644 --- a/src/terrain/TerrainLodSystem.cpp +++ b/src/terrain/TerrainLodSystem.cpp @@ -152,7 +152,9 @@ void TerrainLodSystem::gatherDrawMeshes(std::vector& chunks, const Vec3& cameraPos, const Spatial::Frustum& frustum, std::vector& outDraws, - TerrainCullStats* stats) { + TerrainCullStats* stats, + f32 lodDistanceScale) { + lodDistanceScale = std::max(lodDistanceScale, 1.0f); if (activeLods.size() != chunks.size()) { activeLods.assign(chunks.size(), 0); } @@ -170,7 +172,7 @@ void TerrainLodSystem::gatherDrawMeshes(std::vector& chunks, const Vec3 centerLocal = (chunk.localBoundsMin + chunk.localBoundsMax) * 0.5f; const Vec3 centerWorld = worldMatrix.transformPoint(centerLocal); - const f32 distance = (centerWorld - cameraPos).length(); + const f32 distance = (centerWorld - cameraPos).length() * lodDistanceScale; const Spatial::AABB3D worldBounds = worldBoundsFromLocal(worldMatrix, chunk.localBoundsMin, chunk.localBoundsMax); diff --git a/src/terrain/TerrainLodSystem.hpp b/src/terrain/TerrainLodSystem.hpp index 45a63ce..2b14fb7 100644 --- a/src/terrain/TerrainLodSystem.hpp +++ b/src/terrain/TerrainLodSystem.hpp @@ -75,7 +75,8 @@ class TerrainLodSystem { const Vec3& cameraPos, const Spatial::Frustum& frustum, std::vector& outDraws, - TerrainCullStats* stats = nullptr); + TerrainCullStats* stats = nullptr, + f32 lodDistanceScale = 1.0f); }; } // namespace Caffeine::Terrain diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9d1fade..9e80709 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -71,6 +71,7 @@ set(CAFFEINE_TEST_SOURCES ../src/editor/EntityDebugger.cpp ../src/editor/PluginSystem.cpp ../src/editor/PluginServiceRegistry.cpp + ../src/editor/PluginSymbolExports.cpp ../src/editor/TestUIMapper.cpp ../src/editor/TestRequestHandler.cpp ../src/editor/ScriptEditorWindow.cpp diff --git a/tests/test_debug.cpp b/tests/test_debug.cpp index 15f957d..e4a3e7d 100644 --- a/tests/test_debug.cpp +++ b/tests/test_debug.cpp @@ -256,10 +256,16 @@ TEST_CASE("Profiler - beginScope/endScope records a call", "[debug][profiler]") Vector stats; prof.report(stats); - REQUIRE(stats.size() == 1); - REQUIRE(std::string(stats[0].name) == "TestScope"); - REQUIRE(stats[0].callCount == 1); - REQUIRE(stats[0].totalMs > 0.0); + bool found = false; + for (usize i = 0; i < stats.size(); ++i) { + if (stats[i].name && std::string(stats[i].name) == "TestScope") { + REQUIRE(stats[i].callCount == 1); + REQUIRE(stats[i].totalMs > 0.0); + REQUIRE(stats[i].selfMs > 0.0); + found = true; + } + } + REQUIRE(found); } TEST_CASE("Profiler - multiple calls accumulate stats", "[debug][profiler]") { @@ -276,10 +282,16 @@ TEST_CASE("Profiler - multiple calls accumulate stats", "[debug][profiler]") { Vector stats; prof.report(stats); - REQUIRE(stats.size() == 1); - REQUIRE(stats[0].callCount == 3); - REQUIRE(stats[0].avgMs > 0.0); - REQUIRE(stats[0].totalMs >= stats[0].avgMs); + bool found = false; + for (usize i = 0; i < stats.size(); ++i) { + if (stats[i].name && std::string(stats[i].name) == "Loop") { + REQUIRE(stats[i].callCount == 3); + REQUIRE(stats[i].avgMs > 0.0); + REQUIRE(stats[i].totalMs >= stats[i].avgMs); + found = true; + } + } + REQUIRE(found); } TEST_CASE("Profiler - min/max tracked correctly", "[debug][profiler]") { @@ -298,9 +310,15 @@ TEST_CASE("Profiler - min/max tracked correctly", "[debug][profiler]") { Vector stats; prof.report(stats); - REQUIRE(stats.size() == 1); - REQUIRE(stats[0].minMs < stats[0].maxMs); - REQUIRE(stats[0].minMs > 0.0); + bool found = false; + for (usize i = 0; i < stats.size(); ++i) { + if (stats[i].name && std::string(stats[i].name) == "MinMax") { + REQUIRE(stats[i].minMs < stats[i].maxMs); + REQUIRE(stats[i].minMs > 0.0); + found = true; + } + } + REQUIRE(found); } TEST_CASE("Profiler - multiple named scopes are independent", "[debug][profiler]") { @@ -320,15 +338,13 @@ TEST_CASE("Profiler - multiple named scopes are independent", "[debug][profiler] Vector stats; prof.report(stats); - REQUIRE(stats.size() == 2); - bool foundA = false, foundB = false; for (usize i = 0; i < stats.size(); ++i) { - if (std::string(stats[i].name) == "ScopeA") { + if (stats[i].name && std::string(stats[i].name) == "ScopeA") { REQUIRE(stats[i].callCount == 2); foundA = true; } - if (std::string(stats[i].name) == "ScopeB") { + if (stats[i].name && std::string(stats[i].name) == "ScopeB") { REQUIRE(stats[i].callCount == 1); foundB = true; } @@ -337,6 +353,35 @@ TEST_CASE("Profiler - multiple named scopes are independent", "[debug][profiler] REQUIRE(foundB); } +TEST_CASE("Profiler - nested scopes build hierarchy and self time", "[debug][profiler]") { + auto& prof = Profiler::instance(); + prof.reset(); + prof.setEnabled(true); + + prof.beginScope("Parent"); + prof.beginScope("Child"); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + prof.endScope("Child"); + prof.endScope("Parent"); + + Vector stats; + prof.report(stats); + + const Profiler::ScopeStats* parent = nullptr; + const Profiler::ScopeStats* child = nullptr; + for (usize i = 0; i < stats.size(); ++i) { + if (stats[i].name && std::string(stats[i].name) == "Parent") parent = &stats[i]; + if (stats[i].name && std::string(stats[i].name) == "Child") child = &stats[i]; + } + REQUIRE(parent != nullptr); + REQUIRE(child != nullptr); + REQUIRE(child->parentIndex == parent->nodeIndex); + REQUIRE(parent->firstChildIndex == child->nodeIndex); + REQUIRE(child->selfMs > 0.0); + REQUIRE(parent->totalMs >= child->totalMs); + REQUIRE(parent->selfMs < parent->totalMs); +} + // ============================================================================ // Profiler — Enable/disable // ============================================================================ @@ -352,7 +397,9 @@ TEST_CASE("Profiler - disabled profiler does not record", "[debug][profiler]") { Vector stats; prof.report(stats); - REQUIRE(stats.size() == 0); + for (usize i = 0; i < stats.size(); ++i) { + REQUIRE(stats[i].callCount == 0); + } prof.setEnabled(true); } @@ -370,7 +417,9 @@ TEST_CASE("Profiler - reset clears all stats", "[debug][profiler]") { Vector stats; prof.report(stats); - REQUIRE(stats.size() == 0); + for (usize i = 0; i < stats.size(); ++i) { + REQUIRE(stats[i].callCount == 0); + } } // ============================================================================ @@ -390,10 +439,15 @@ TEST_CASE("ProfileScope - RAII records scope", "[debug][profiler]") { Vector stats; prof.report(stats); - REQUIRE(stats.size() == 1); - REQUIRE(std::string(stats[0].name) == "RAIIScope"); - REQUIRE(stats[0].callCount == 1); - REQUIRE(stats[0].totalMs > 0.0); + bool found = false; + for (usize i = 0; i < stats.size(); ++i) { + if (stats[i].name && std::string(stats[i].name) == "RAIIScope") { + REQUIRE(stats[i].callCount == 1); + REQUIRE(stats[i].totalMs > 0.0); + found = true; + } + } + REQUIRE(found); } // ============================================================================ From 2035e46c66160f2f25f6a2f1e233b05ad18d08a4 Mon Sep 17 00:00:00 2001 From: LyeZinho Date: Wed, 23 Sep 2026 00:58:34 +0100 Subject: [PATCH 4/5] Bump caf-pack submodule --- caf-pack | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caf-pack b/caf-pack index 89759d2..677d202 160000 --- a/caf-pack +++ b/caf-pack @@ -1 +1 @@ -Subproject commit 89759d2de2f7774ad0de9031ec9b6fd5550209d2 +Subproject commit 677d202424b1c453f57de1d718b2164b793286c5 From 067ea20e34a382f7b932e15a4e2b034cbe500381 Mon Sep 17 00:00:00 2001 From: lyeepedro Date: Wed, 23 Sep 2026 12:44:02 +0100 Subject: [PATCH 5/5] Restore .gitmodules for caf-pack, Convoy, and WaveShaper submodules. --- .gitmodules | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .gitmodules diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e9f25f0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "caf-pack"] + path = caf-pack + url = git@github.com:devscafecommunity/caf-pack.git +[submodule "Convoy"] + path = Convoy + url = git@github.com:devscafecommunity/convoy.git +[submodule "WaveShaper"] + path = WaveShaper + url = git@github.com:devscafecommunity/waveshaper.git