diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 4f8b700e..e8dd45f9 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -4,71 +4,68 @@ on: push: branches: - master + - development - c-i pull_request: branches: - master -env: - # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) - BUILD_TYPE: Release + - development jobs: build: - # The CMake configure and build commands are platform agnostic and should work equally - # well on Windows or Mac. You can convert this to a matrix build if you need - # cross-platform coverage. - # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix + name: build-${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [windows-latest] - + os: [windows-latest, ubuntu-latest] + env: + BUILD_TYPE: Release steps: - - uses: actions/checkout@v2 - - - name: Linux dev lib + - uses: actions/checkout@v4 + + - name: Install Linux dependencies if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo add-apt-repository ppa:ubuntu-toolchain-r/test - sudo apt-get update - sudo apt-get install -y gcc-10 g++-10 - sudo apt-get install -y libxrandr-dev xorg-dev libglu1-mesa-dev - sudo apt-get install -y libgtk-3-dev - export CC=gcc-10 - export CXX=g++-10 + run: | + sudo apt-get update + sudo apt-get install -y libxrandr-dev xorg-dev libglu1-mesa-dev libgtk-3-dev - sudo apt-get install libgtest-dev - cd /usr/src/gtest - sudo mkdir build - cd build - sudo cmake .. - sudo make - - - name: Create Build Environment - # Some projects don't allow in-source building, so create a separate build directory - # We'll use this as our working directory for all subsequent commands - run: cmake -E make_directory ${{runner.workspace}}/build - name: Configure CMake - # Use a bash shell so we can use the same syntax for environment variable - # access regardless of the host operating system - shell: bash - working-directory: ${{runner.workspace}}/build - # Note the current convention is to use the -S and -B options here to specify source - # and build directories, but this is only available with CMake 3.13 and higher. - # The CMake binaries on the Github Actions machines are (as of this writing) 3.12 - run: cmake $GITHUB_WORKSPACE -DCMAKE_BUILD_TYPE=$BUILD_TYPE + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} - name: Build - working-directory: ${{runner.workspace}}/build - shell: bash - # Execute the build. You can specify a specific target with "--target " - run: cmake --build . --config $BUILD_TYPE + run: cmake --build build --config ${{ env.BUILD_TYPE }} - name: Test - working-directory: ${{runner.workspace}}/build - shell: bash - # Execute tests defined by the CMake configuration. - # See https://cmake.org/cmake/help/latest/manual/ctest.1.html for more detail - run: ctest -C $BUILD_TYPE --rerun-failed --output-on-failure + working-directory: build + run: ctest -C ${{ env.BUILD_TYPE }} --output-on-failure + + sanitizers: + name: asan-ubsan (ubuntu) + runs-on: ubuntu-latest + env: + BUILD_TYPE: Debug + CC: clang + CXX: clang++ + CXXFLAGS: -fsanitize=address,undefined -fno-omit-frame-pointer + LDFLAGS: -fsanitize=address,undefined + steps: + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libxrandr-dev xorg-dev libglu1-mesa-dev libgtk-3-dev + - name: Configure CMake + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} + + - name: Build + run: cmake --build build --config ${{ env.BUILD_TYPE }} + + - name: Test + working-directory: build + env: + ASAN_OPTIONS: detect_leaks=1:halt_on_error=0 + UBSAN_OPTIONS: print_stacktrace=1 + run: ctest -C ${{ env.BUILD_TYPE }} --output-on-failure diff --git a/.gitignore b/.gitignore index 2b3529aa..244bd5cb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ build/ .vscode/ /.vs + +docs/REMEDIATION_PLAN.md diff --git a/Assets/Shaders/glsl/pbr.fs b/Assets/Shaders/glsl/pbr.fs index 6bbaeb73..24f43ad6 100644 --- a/Assets/Shaders/glsl/pbr.fs +++ b/Assets/Shaders/glsl/pbr.fs @@ -96,7 +96,7 @@ void main() { vec3 albedo = pow(material.hasBaseColorMap ? texture(material.baseColorMap, ftex_coords).rgb : material.baseColor, vec3(2.2)); float metallic = material.hasMetallicMap ? texture(material.metallicMap, ftex_coords).b : material.metallic; float roughness = material.hasRoughnessMap ? texture(material.roughnessMap, ftex_coords).g : material.roughness; - float ao = material.hasAoMap ? texture(material.aoMap, ftex_coords).length() : material.ao; + float ao = material.hasAoMap ? texture(material.aoMap, ftex_coords).r : material.ao; vec3 N = getNormalFromMap(); vec3 V = normalize(fview - fposition); @@ -109,11 +109,19 @@ void main() { // reflectance equation vec3 Lo = vec3(0.0); for(int i = 0; i < light_count; ++i) { - // calculate per-light radiance - vec3 L = normalize(lights[i].position - fposition); + // calculate per-light radiance. Directional lights (type 1) come from a fixed + // direction with no distance attenuation; point/spot use position + falloff. + vec3 L; + float attenuation; + if(lights[i].type == 1) { + L = normalize(-lights[i].rotation); + attenuation = 1.0; + } else { + L = normalize(lights[i].position - fposition); + float distance = length(lights[i].position - fposition); + attenuation = 1.0 / (1 + lights[i].distance_dropoff * distance * distance); + } vec3 H = normalize(V + L); - float distance = length(lights[i].position - fposition); - float attenuation = 1.0 / (1 + lights[i].distance_dropoff * distance * distance); vec3 radiance = lights[i].color * attenuation; // Cook-Torrance BRDF diff --git a/Assets/Shaders/glsl/phong.fs b/Assets/Shaders/glsl/phong.fs index 9dab910c..162e7b64 100644 --- a/Assets/Shaders/glsl/phong.fs +++ b/Assets/Shaders/glsl/phong.fs @@ -22,6 +22,8 @@ struct Material { uniform Material material; in vec3 fnormal; +in vec3 ftangent; +in vec3 fbitangent; in vec3 fposition; in vec3 fview; in vec2 ftex_coords; @@ -64,16 +66,27 @@ vec4 applyLight(Light light) { } else if(light.type == 2) { //TODO: Spot lights } + // Non-void function must return on every path (spot/unknown types fell off the end -> UB). + return vec4(0.0); } -vec3 colorToNormal(vec3 color) { - return normalize(vec3(color.x * 2 - 1, color.y * 2 - 1, color.z * 2 - 1)); +// Transform the tangent-space normal-map sample into world space using a proper TBN basis +// (Gram-Schmidt re-orthogonalized), matching pbr.fs. The previous math was not a TBN +// transform at all. +vec3 getNormalFromMap() { + vec3 tangentNormal = texture(material.normal_map, ftex_coords).xyz * 2.0 - 1.0; + vec3 N = normalize(fnormal); + vec3 T = normalize(ftangent); + T = normalize(T - dot(T, N) * N); + vec3 B = cross(N, T); + mat3 TBN = mat3(T, B, N); + return normalize(TBN * tangentNormal); } void main() { if(material.use_normal_map) { - normal = normalize(fnormal + (fnormal * colorToNormal(texture(material.normal_map, ftex_coords).xyz))); + normal = getNormalFromMap(); } else { normal = fnormal; } diff --git a/Assets/Shaders/glsl/picking.fs b/Assets/Shaders/glsl/picking.fs index 74642cb8..950e88f2 100644 --- a/Assets/Shaders/glsl/picking.fs +++ b/Assets/Shaders/glsl/picking.fs @@ -5,5 +5,11 @@ uniform int objectID; out vec4 frag_color; void main() { - frag_color = vec4((objectID & 0xFF) / 255.0, ((objectID & 0xFF00) >> 8) / 255, ((objectID & 0xFF0000) >> 16 / 255), 1.0); + // Encode the 24-bit object id across R,G,B (decoded as R + G<<8 + B<<16). The old + // code used integer division (/255) and had an operator-precedence bug (>> 16 / 255), + // so ids >= 256 could not round-trip. + frag_color = vec4(float(objectID & 0xFF) / 255.0, + float((objectID >> 8) & 0xFF) / 255.0, + float((objectID >> 16) & 0xFF) / 255.0, + 1.0); } \ No newline at end of file diff --git a/Assets/Shaders/glsl/picking.vs b/Assets/Shaders/glsl/picking.vs new file mode 100644 index 00000000..d934db04 --- /dev/null +++ b/Assets/Shaders/glsl/picking.vs @@ -0,0 +1,32 @@ +#version 420 core + +#include "vert_uniforms.glsl" +#define MAX_BONES 100 +#define MAX_BONE_INFLUENCE 4 + +layout (location = 0) in vec3 vertex; +layout (location = 5) in ivec4 bone_ids; +layout (location = 6) in vec4 bone_weights; + +// Picking is not instanced, so the model matrix is a uniform (skinning.vs takes it as a +// per-instance vertex attribute). Skinning is applied with the same logic as skinning.vs +// so an animated model is picked at its current pose, matching what is rendered on screen. +uniform mat4 model; +uniform mat4 bonesTransformMatrices[MAX_BONES]; + +void main() { + vec4 totalPosition = vec4(0.0); + if (bone_ids == ivec4(-1)) { + totalPosition = vec4(vertex, 1.0); + } else { + for (int i = 0; i < MAX_BONE_INFLUENCE; i++) { + if (bone_ids[i] == -1) continue; + if (bone_ids[i] >= MAX_BONES) { + totalPosition = vec4(vertex, 1.0); + break; + } + totalPosition += bonesTransformMatrices[bone_ids[i]] * vec4(vertex, 1.0) * bone_weights[i]; + } + } + gl_Position = uProjection * uView * model * totalPosition; +} diff --git a/Assets/Shaders/glsl/skinning.vs b/Assets/Shaders/glsl/skinning.vs index 7245dda7..abec4202 100644 --- a/Assets/Shaders/glsl/skinning.vs +++ b/Assets/Shaders/glsl/skinning.vs @@ -11,8 +11,8 @@ layout (location = 3) in vec3 tangent; layout (location = 4) in vec3 bitangent; layout (location = 5) in ivec4 bone_ids; layout (location = 6) in vec4 bone_weights; +layout (location = 7) in mat4 model; -uniform mat4 model; uniform mat4 bonesTransformMatrices[MAX_BONES]; out vec3 fnormal; @@ -46,10 +46,12 @@ void main() { } mat4 finalBonesMatrix = bonesTransformMatrices[bone_ids[i]]; - + totalPosition += finalBonesMatrix * vec4(vertex, 1.0f) * bone_weights[i]; - - mat3 normalMatrix = mat3(transpose(inverse(finalBonesMatrix))); + + // Skeletal bones are rigid (rotation + translation), so the 3x3 already is the + // correct normal transform -- no need for a per-bone, per-vertex inverse-transpose. + mat3 normalMatrix = mat3(finalBonesMatrix); totalNormal += normalMatrix * normal * bone_weights[i]; totalTangent += normalMatrix * tangent * bone_weights[i]; totalBitangent += normalMatrix * bitangent * bone_weights[i]; @@ -65,6 +67,7 @@ void main() { gl_Position = uProjection * uView * model * totalPosition; - fview = (inverse(uView) * vec4(0, 0, 0, 1)).xyz; + // Camera world position comes from the UBO instead of a per-vertex inverse(uView). + fview = uCameraPos.xyz; ftex_coords = tex_coords; } \ No newline at end of file diff --git a/Assets/Shaders/glsl/skybox.vs b/Assets/Shaders/glsl/skybox.vs index 0817ad8f..34b22f77 100644 --- a/Assets/Shaders/glsl/skybox.vs +++ b/Assets/Shaders/glsl/skybox.vs @@ -11,5 +11,9 @@ out vec3 TexCoords; void main() { TexCoords = aPos; - gl_Position = uProjection * uView * vec4(aPos, 1.0); + // Strip translation from the view so the skybox stays centered on the camera, and use + // the z=w trick so its depth is always 1.0 -- drawn behind everything with GL_LEQUAL. + mat4 rotView = mat4(mat3(uView)); + vec4 clipPos = uProjection * rotView * vec4(aPos, 1.0); + gl_Position = clipPos.xyww; } \ No newline at end of file diff --git a/Assets/Shaders/glsl/ui.fs b/Assets/Shaders/glsl/ui.fs new file mode 100644 index 00000000..8ce39117 --- /dev/null +++ b/Assets/Shaders/glsl/ui.fs @@ -0,0 +1,21 @@ +#version 420 core + +out vec4 fragColor; + +in vec2 fUV; + +uniform vec4 uColor; +uniform sampler2D uTexture; + +// 0 = solid colour, 1 = colour modulated by an RGBA texture, 2 = text (texture .r is coverage). +uniform int uMode; + +void main() { + if (uMode == 2) { + fragColor = vec4(uColor.rgb, uColor.a * texture(uTexture, fUV).r); + } else if (uMode == 1) { + fragColor = uColor * texture(uTexture, fUV); + } else { + fragColor = uColor; + } +} diff --git a/Assets/Shaders/glsl/ui.vs b/Assets/Shaders/glsl/ui.vs new file mode 100644 index 00000000..fb0b309a --- /dev/null +++ b/Assets/Shaders/glsl/ui.vs @@ -0,0 +1,17 @@ +#version 420 core +layout (location = 0) in vec2 aPos; + +out vec2 fUV; + +uniform mat4 projection; +uniform mat4 model; + +// Sub-rectangle of the bound texture to sample: the whole texture by default, a single glyph's +// cell when drawing text from the font atlas. +uniform vec2 uUVOffset; +uniform vec2 uUVScale; + +void main() { + gl_Position = projection * model * vec4(aPos, 0.0, 1.0); + fUV = uUVOffset + aPos * uUVScale; +} diff --git a/Assets/Shaders/glsl/vert_uniforms.glsl b/Assets/Shaders/glsl/vert_uniforms.glsl index 2a813dc5..acd225cb 100644 --- a/Assets/Shaders/glsl/vert_uniforms.glsl +++ b/Assets/Shaders/glsl/vert_uniforms.glsl @@ -1,4 +1,5 @@ layout(std140, binding = 0) uniform SceneData { mat4 uProjection; mat4 uView; + vec4 uCameraPos; // world-space camera position (xyz) }; \ No newline at end of file diff --git a/Assets/Shaders/picking.shader.json b/Assets/Shaders/picking.shader.json index 606a78aa..51cd7488 100644 --- a/Assets/Shaders/picking.shader.json +++ b/Assets/Shaders/picking.shader.json @@ -1,7 +1,7 @@ [ { "stage": "vertex", - "source": "glsl/skinning.vs" + "source": "glsl/picking.vs" }, { "stage": "fragment", diff --git a/Assets/Shaders/ui.shader.json b/Assets/Shaders/ui.shader.json new file mode 100644 index 00000000..999a1102 --- /dev/null +++ b/Assets/Shaders/ui.shader.json @@ -0,0 +1,10 @@ +[ + { + "stage": "vertex", + "source": "glsl/ui.vs" + }, + { + "stage": "fragment", + "source": "glsl/ui.fs" + } +] diff --git a/CMakeLists.txt b/CMakeLists.txt index 5088feec..ee26d3df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,14 +1,21 @@ cmake_minimum_required(VERSION 3.19) project(ICE_ROOT) -set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++20") set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) -include(cmake/fetch_dependencies.cmake) -enable_testing() set(GLFW_INSTALL OFF CACHE BOOL "" FORCE) set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE) set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(BUILD_IMXML_EXAMPLE OFF CACHE BOOL "" FORCE) + +add_compile_definitions(NOMINMAX) + + +include(cmake/fetch_dependencies.cmake) +enable_testing() + if(APPLE) find_library(COCOA_LIBRARY Cocoa REQUIRED) @@ -35,4 +42,10 @@ set(GL3W_SRC add_subdirectory(ICE) add_subdirectory(ICEBERG) -add_subdirectory(ICEFIELD) \ No newline at end of file +add_subdirectory(ICEFIELD) + +# Architectural guard: fail the test run if a new inter-module dependency cycle is introduced. +# Enforces the target module DAG (see cmake/module_dependency_check.cmake and +# docs/module_dependencies.md). Runs standalone -- no build required -- so it can also gate CI early. +add_test(NAME module_cycle_check + COMMAND ${CMAKE_COMMAND} -DICE_DIR=${CMAKE_SOURCE_DIR}/ICE -P ${CMAKE_SOURCE_DIR}/cmake/module_dependency_check.cmake) \ No newline at end of file diff --git a/ICE/Assets/CMakeLists.txt b/ICE/Assets/CMakeLists.txt index 77bb5578..9a6ec508 100644 --- a/ICE/Assets/CMakeLists.txt +++ b/ICE/Assets/CMakeLists.txt @@ -8,18 +8,22 @@ add_library(${PROJECT_NAME} STATIC) target_sources(${PROJECT_NAME} PRIVATE src/AssetBank.cpp src/AssetPath.cpp + src/stb_image_impl.cpp src/Shader.cpp src/Model.cpp + src/Mesh.cpp src/Material.cpp src/GPURegistry.cpp src/Texture.cpp) -target_link_libraries(${PROJECT_NAME} - PUBLIC - graphics +target_link_libraries(${PROJECT_NAME} + PUBLIC + graphics util - io storage + math + # Async import stages loads on the JobScheduler (header-only Multithreading module). + multithreading ) target_include_directories(${PROJECT_NAME} PUBLIC diff --git a/ICE/Assets/include/AssetBank.h b/ICE/Assets/include/AssetBank.h index f9492ca7..518783d3 100644 --- a/ICE/Assets/include/AssetBank.h +++ b/ICE/Assets/include/AssetBank.h @@ -9,9 +9,14 @@ #include #include +#include +#include +#include #include #include #include +#include +#include #include "Asset.h" #include "AssetLoader.h" @@ -21,16 +26,50 @@ #define ICE_ASSET_PREFIX "__ice__" namespace ICE { +class JobScheduler; // async import runs staging on this pool (forward-declared: header stays light) +struct AssetBankAsyncState; // shared worker<->main completion state (defined in AssetBank.cpp) + +// Lifecycle state of an asset entry. Synchronous adds are Ready immediately; async requestAsset +// reservations start Loading and become Ready or Failed after a pump(). Resident is reserved for the +// GPU-residency policy (see the eviction task) and is not set by the async path yet. +enum class AssetState { Loading, Ready, Failed, Resident }; + struct AssetBankEntry { AssetBankEntry() : path(""), asset(nullptr) {} AssetBankEntry(const AssetPath& _path, const std::shared_ptr& _asset) : path(_path), asset(_asset) {} AssetPath path; std::shared_ptr asset; + AssetState state = AssetState::Ready; // synchronous adds are usable at once }; class AssetBank { public: + // Async import phases (see requestAsset): `stage` runs on a worker and returns an opaque payload; + // `commit` runs on the main thread in pump() and turns it into the final Asset. + using StageFn = std::function()>; + using CommitFn = std::function(const std::shared_ptr&)>; + AssetBank(); + ~AssetBank(); + + // Registration seam for asset loaders. The concrete loaders live in the `io` + // module; the composition layer wires them in via this method so that `assets` + // does not depend on `io` (see registerDefaultLoaders in the io module). + template + void addLoader(const std::shared_ptr>& asset_loader) { + loader.AddLoader(asset_loader); + } + + // Register a loader for a plugin-defined asset kind together with its path prefix in one call: + // after this, WithTypePrefix / addAsset / getAsset / project persistence all work for T + // exactly as for a built-in type. Throws (via AssetPath::registerType) if the prefix collides + // with an existing type. Built-in loaders keep using the prefix-less overload above (their + // prefixes are pre-registered). + template + void addLoader(const std::string& prefix, const std::shared_ptr>& asset_loader) { + AssetPath::registerType(prefix); + loader.AddLoader(asset_loader); + } template std::shared_ptr getAsset(AssetUID uid) { @@ -65,6 +104,11 @@ class AssetBank { } bool addAsset(const AssetPath& path, const std::shared_ptr& asset) { + // Reject a failed load (loaders now return nullptr on error) instead of inserting a + // null asset that would later be dereferenced. + if (asset == nullptr) { + return false; + } if (!nameMapping.contains(path) && !resources.contains(nextUID)) { resources.try_emplace(nextUID, AssetBankEntry{path, asset}); nameMapping.try_emplace(path, nextUID); @@ -96,6 +140,25 @@ class AssetBank { return false; } + // Type-erased variant keyed by std::type_index: loads `sources` through the registered loader for + // `type` and inserts it under `id`. Used by project loading to restore a persisted custom asset + // whose concrete type is only known by its path prefix (see AssetPath::typeForPrefix). Returns + // false on a UID/name collision or a null (failed) load; propagates ICEException if no loader is + // registered for `type`. + bool addAssetWithSpecificUID(std::type_index type, const AssetPath& name, const std::vector& sources, AssetUID id) { + if (resources.find(id) == resources.end() && nameMapping.find(name) == nameMapping.end()) { + auto res = loader.LoadResource(type, sources); + if (!res) { + return false; + } + resources.try_emplace(id, AssetBankEntry{name, res}); + nameMapping.try_emplace(name, id); + nextUID = nextUID > id ? nextUID : id + 1; + return true; + } + return false; + } + bool renameAsset(const AssetPath& oldName, const AssetPath& newName) { if (oldName.toString() == newName.toString()) { return true; @@ -118,13 +181,84 @@ class AssetBank { if (nameMapping.find(name) != nameMapping.end()) { AssetUID id = getUID(name); nameMapping.erase(name); - //TODO: Check resources[id].asset->unload(); resources.erase(id); + // Notify listeners (e.g. the GPU registry) that this UID is gone so they can release any + // resources keyed on it. Fires with just the UID -- never the erased entry -- so the + // order relative to the erase above is irrelevant and re-import (remove + re-add under + // the same UID) correctly drops the stale GPU upload. + for (const auto& [handle, listener] : m_removal_listeners) { + listener(id); + } return true; } return false; } + // Removal-listener registration seam. A listener is invoked (with the removed asset's UID) from + // removeAsset. The GPU registry subscribes here so eviction happens without `assets` gaining a + // reverse dependency on the graphics side. Returns a handle for later removeRemovalListener. + using RemovalListenerHandle = std::size_t; + RemovalListenerHandle addRemovalListener(std::function listener) { + RemovalListenerHandle handle = m_next_listener_handle++; + m_removal_listeners.emplace_back(handle, std::move(listener)); + return handle; + } + void removeRemovalListener(RemovalListenerHandle handle) { + std::erase_if(m_removal_listeners, [handle](const auto& entry) { return entry.first == handle; }); + } + + // --- Async import ---------------------------------------------------------------------------- + // The scheduler used to stage async loads off the main thread. Null (the default) makes + // requestAsset stage inline (still finalized by pump), so the synchronous behavior is preserved. + // The bank co-owns the scheduler so it can drain in-flight loads at teardown. + void setScheduler(const std::shared_ptr& scheduler); + + // Reserve `name`'s UID immediately (state Loading, null payload) so scenes/components can + // reference it at once, then load it in the background: `stage` runs on a worker (it MUST NOT + // touch the bank), `commit` runs on the main thread in pump() and produces the final Asset (it + // may add sub-assets to the bank). getAsset returns nullptr until the load is Ready. Returns the + // reserved UID, or the existing one if `name` is already requested/loaded (de-dup by path). This + // is the path model import uses (stage = ModelLoader::stage, commit = ModelLoader::commit). + template + AssetUID requestAsset(const std::string& name, StageFn stage, CommitFn commit) { + return requestAssetImpl(AssetPath::WithTypePrefix(name), std::move(stage), std::move(commit)); + } + + // Convenience for a pure-loader asset type (Mesh/Texture/Material/Shader/custom): stages by + // running the registered loader on a worker and commits by simply publishing the result. NOT for + // Model (its loader mutates the bank); use the stage/commit overload for that. Throws if no + // loader is registered for T. + template + AssetUID requestAsset(const std::string& name, const std::vector& sources) { + auto loader_ptr = loader.getLoader(typeid(T)); + if (!loader_ptr) { + throw ICEException("No matching loader for resource"); + } + StageFn stage = [loader_ptr, sources]() -> std::shared_ptr { + return std::static_pointer_cast(loader_ptr->loadErased(sources)); + }; + CommitFn commit = [](const std::shared_ptr& staged) -> std::shared_ptr { + return std::static_pointer_cast(staged); + }; + return requestAssetImpl(AssetPath::WithTypePrefix(name), std::move(stage), std::move(commit)); + } + + // Lifecycle state of `uid` (Failed for an unknown UID). Consumers poll this to know when an + // async-requested asset is usable. + AssetState getState(AssetUID uid) const; + + // Finalize completed async loads on the main thread: publish payloads, run model sub-asset + // commits, and mark Ready/Failed. Call once per frame (ICEEngine::step). Cheap no-op when idle. + void pump(); + + // Block until every in-flight async load has been finalized, pumping while waiting. Used at + // teardown and for load-then-wait startup. Requires the staging scheduler to still be alive + // (the bank co-owns it via setScheduler), so it cannot stall. + void flushAsync(); + + // Number of async loads reserved but not yet finalized. + std::size_t inFlight() const; + template std::unordered_map> getAll() { std::unordered_map> all; @@ -146,12 +280,10 @@ class AssetBank { } AssetPath getName(AssetUID uid) { - for (const auto& [path, id] : nameMapping) { - if (id == uid) { - return path; - } - } - return AssetPath(""); + // O(1): the entry already stores its path. This used to linear-scan nameMapping, + // which made project saves (getName per asset) O(n^2). + auto it = resources.find(uid); + return it == resources.end() ? AssetPath("") : it->second.path; } AssetUID getUID(const AssetPath& name) { @@ -163,9 +295,23 @@ class AssetBank { bool nameInUse(const AssetPath& name); private: + // Shared implementation of the requestAsset overloads: reserve the UID (main thread), then stage + // on the scheduler (or inline if none) and record the commit for pump(). + AssetUID requestAssetImpl(const AssetPath& path, StageFn stage, CommitFn commit); + AssetUID nextUID = 1; std::unordered_map nameMapping; std::unordered_map resources; AssetLoader loader; + std::vector>> m_removal_listeners; + RemovalListenerHandle m_next_listener_handle = 1; + + // Async import state. m_pending_commits (main-thread only) holds each reservation's commit; the + // shared m_async carries stage results from workers back to pump() and survives the bank if a + // worker is still running -- workers never touch the bank directly. m_scheduler is co-owned so it + // outlives in-flight loads through flushAsync(). + std::unordered_map m_pending_commits; + std::shared_ptr m_async; + std::shared_ptr m_scheduler; }; } // namespace ICE diff --git a/ICE/Assets/include/AssetLoader.h b/ICE/Assets/include/AssetLoader.h index d3a6d6d0..8eed47c1 100644 --- a/ICE/Assets/include/AssetLoader.h +++ b/ICE/Assets/include/AssetLoader.h @@ -6,37 +6,48 @@ #include +#include #include #include -#include #include "Asset.h" #include "IAssetLoader.h" -#include "Model.h" namespace ICE { +// Open registry of asset loaders keyed by std::type_index. Loaders are stored type-erased behind +// IAssetLoaderBase, so registering a loader for a brand-new asset type needs no change here and this +// header no longer has to include every concrete asset type (Model/Material/Texture/...). class AssetLoader { public: template std::shared_ptr LoadResource(const std::vector files) { - if (loaders.find(typeid(T)) != loaders.end()) { - auto &variant = loaders[typeid(T)]; - auto loader = std::get>>(variant); - return loader->load(files); + // Downcast the erased result back to T. The stored loader is an IAssetLoader, so this + // always matches (or is nullptr on a failed load). + return std::dynamic_pointer_cast(LoadResource(std::type_index(typeid(T)), files)); + } + + // Erased entry point: load a type known only by its std::type_index. Used by the async import + // queue and prefix-based project loading. Throws if no loader is registered for `type`. + std::shared_ptr LoadResource(std::type_index type, const std::vector &files) { + if (auto it = loaders.find(type); it != loaders.end()) { + return it->second->loadErased(files); } throw ICEException("No matching loader for resource"); } template void AddLoader(std::shared_ptr> loader) { - loaders[typeid(T)] = loader; + loaders[typeid(T)] = std::move(loader); + } + + // The (type-erased) loader registered for `type`, or nullptr. Lets the async import path capture + // the loader by shared_ptr and run it on a worker without touching the AssetBank. + std::shared_ptr getLoader(std::type_index type) const { + auto it = loaders.find(type); + return it != loaders.end() ? it->second : nullptr; } private: - std::unordered_map< - std::type_index, - std::variant>, std::shared_ptr>, std::shared_ptr>, - std::shared_ptr>, std::shared_ptr>, std::shared_ptr>>> - loaders; + std::unordered_map> loaders; }; } // namespace ICE diff --git a/ICE/Assets/include/AssetPath.h b/ICE/Assets/include/AssetPath.h index 66c122cd..1450f70a 100644 --- a/ICE/Assets/include/AssetPath.h +++ b/ICE/Assets/include/AssetPath.h @@ -5,6 +5,7 @@ #ifndef ICE_ASSETPATH_H #define ICE_ASSETPATH_H +#include #include #include #include @@ -15,16 +16,35 @@ namespace ICE { class AssetPath { public: - AssetPath(const AssetPath& cpy); + AssetPath(const AssetPath& cpy) = default; // copy members directly (no re-parse) AssetPath(std::string path); - std::string toString() const; + const std::string& toString() const; std::string prefix() const; template static AssetPath WithTypePrefix(std::string path) { - return AssetPath(typenames[typeid(T)] + ASSET_PATH_SEPARATOR + path); + // .at() is read-only: operator[] inserted an empty prefix for unregistered types + // (silently producing "/name" paths) and mutated the shared static map (data race). + return AssetPath(typenames.at(typeid(T)) + ASSET_PATH_SEPARATOR + path); } - bool operator==(AssetPath other) const { return (other.toString() == this->toString()); } + // Open the asset-type set: register a path prefix for a new asset type so WithTypePrefix and + // prefix-based project loading work for plugin-defined kinds. Built-in types are pre-registered. + // Idempotent for an identical (type, prefix); throws (ICEException) on a conflicting prefix or a + // second, different prefix for an already-registered type -- duplicate registration is rejected + // loudly rather than silently shadowing. Not synchronized: call on the main thread only (matches + // how loaders are registered, at load/plugin-init time). + static void registerType(std::type_index type, const std::string& prefix); + template + static void registerType(const std::string& prefix) { + registerType(std::type_index(typeid(T)), prefix); + } + + // Reverse of the prefix registration: the asset type a prefix maps to, or nullopt if unknown. + // Used by project loading to route a persisted "prefix" section to the right erased loader. + static std::optional typeForPrefix(const std::string& prefix); + + // Compare the pre-computed canonical string (no allocation / re-parse per comparison). + bool operator==(const AssetPath& other) const { return m_string == other.m_string; } std::vector getPath() const; @@ -35,7 +55,9 @@ class AssetPath { private: std::vector path; std::string name; + std::string m_string; // canonical "prefix/name", computed once in the constructor static std::unordered_map typenames; + static std::unordered_map prefixes; // reverse of typenames }; } // namespace ICE namespace std { diff --git a/ICE/Assets/include/GPURegistry.h b/ICE/Assets/include/GPURegistry.h index 50ddf27c..aec661a4 100644 --- a/ICE/Assets/include/GPURegistry.h +++ b/ICE/Assets/include/GPURegistry.h @@ -2,10 +2,13 @@ #include #include +#include #include +#include #include #include +#include #include "Asset.h" #include "AssetBank.h" @@ -14,16 +17,40 @@ #include "Texture.h" namespace ICE { +// Owns the GPU-side resources (meshes, 2D textures, shaders) uploaded from CPU assets. Ownership +// lives in generational HandlePools -- the registry is the single owner; everyone else holds a +// lightweight handle (resolved to a raw pointer at bind time) or, on the not-yet-migrated paths, a +// non-owning shared_ptr. The shared_ptr accessors keep their original signatures so existing +// callers are unchanged while the per-frame path migrates to handles (see resolve()). class GPURegistry { public: GPURegistry(const std::shared_ptr &factory, const std::shared_ptr &bank); + ~GPURegistry(); + + // Single owner of the GPU pools and holder of a self-referential removal-listener registration: + // non-copyable (a copy would double-unregister and not own its own listener). + GPURegistry(const GPURegistry &) = delete; + GPURegistry &operator=(const GPURegistry &) = delete; + + // Release every GPU resource uploaded from `id` (mesh / 2D texture / shader / cubemap) and drop + // the by-uid entries. Invoked via the AssetBank removal listener when an asset is removed or + // re-imported. Outstanding handles held by in-flight render jobs go stale (resolve() -> nullptr, + // safe by design); the next frame's Phase 1 re-resolves and lazily re-uploads if the asset + // still exists. + void evict(AssetUID id); AssetUID getUID(const AssetPath &path) const { return m_asset_bank->getUID(path); } std::shared_ptr getMaterial(const AssetPath &path) { return m_asset_bank->getAsset(getUID(path)); } std::shared_ptr getMaterial(AssetUID id) { return m_asset_bank->getAsset(id); } std::shared_ptr getShader(AssetUID id); std::shared_ptr getShader(const AssetPath &path) { return getShader(getUID(path)); } - AABB getMeshAABB(AssetUID id) { return m_asset_bank->getAsset(id)->getBoundingBox(); } + // Null-safe: an evicted/removed mesh yields a zero AABB instead of dereferencing null. Callers on + // the frame path resolve the mesh handle first and discard the job when it is gone, so this + // default is a backstop rather than a rendered value. + AABB getMeshAABB(AssetUID id) { + auto mesh = m_asset_bank->getAsset(id); + return mesh ? mesh->getBoundingBox() : AABB{Eigen::Vector3f::Zero(), Eigen::Vector3f::Zero()}; + } const SkinningData &getMeshSkinningData(AssetUID id) { return m_asset_bank->getAsset(id)->getSkinningData(); } std::shared_ptr getMesh(AssetUID id); std::shared_ptr getMesh(const AssetPath &path) { return getMesh(getUID(path)); } @@ -31,13 +58,65 @@ class GPURegistry { std::shared_ptr getTexture2D(const AssetPath &path) { return getTexture2D(getUID(path)); } std::shared_ptr getCubemap(AssetUID id); + // Handle API (the per-frame path migrates onto this): *Handle() uploads the resource if needed + // and returns a generational handle; resolve() turns a handle into a raw pointer at bind time, + // or nullptr if the resource was freed/reuploaded (a stale handle). No shared_ptr, no refcount + // churn, no per-frame texture-map copies. + MeshHandle meshHandle(AssetUID id); + TextureHandle textureHandle(AssetUID id); + ShaderHandle shaderHandle(AssetUID id); + MeshHandle meshHandle(const AssetPath &path) { return meshHandle(getUID(path)); } + TextureHandle textureHandle(const AssetPath &path) { return textureHandle(getUID(path)); } + ShaderHandle shaderHandle(const AssetPath &path) { return shaderHandle(getUID(path)); } + + // Ensure a 2D texture is resident and return a raw pointer to it (nullptr if it isn't a valid + // texture asset). Used by the geometry pass to bind a material's textures without a shared_ptr. + GPUTexture *texture2DPtr(AssetUID id) { return resolve(textureHandle(id)); } + + // --- Residency stats ------------------------------------------------------------------------- + // Measurement foundation for a future eviction policy: expose how much is resident so memory + // pressure can be measured before any budget/LRU policy is enforced (see the eviction task, + // deliberately profiling-gated). None of these touch the per-frame resolve path. + // + // Live GPU resources per pool (cheap; safe to poll every frame). + std::size_t residentMeshCount() const { return m_mesh_pool.size(); } + std::size_t residentTextureCount() const { return m_tex2d_pool.size(); } + std::size_t residentShaderCount() const { return m_shader_pool.size(); } + + // Estimated bytes of the CPU payload backing the resident meshes / 2D textures. Reads the source + // assets, so it is O(resident) and intended for occasional diagnostics, not per frame. Textures + // are estimated at 4 bytes/texel. + std::size_t residentMeshBytes(); + std::size_t residentTextureBytes(); + + GPUMesh *resolve(MeshHandle h) { + auto *sp = m_mesh_pool.get(h); + return sp ? sp->get() : nullptr; + } + GPUTexture *resolve(TextureHandle h) { + auto *sp = m_tex2d_pool.get(h); + return sp ? sp->get() : nullptr; + } + ShaderProgram *resolve(ShaderHandle h) { + auto *sp = m_shader_pool.get(h); + return sp ? sp->get() : nullptr; + } + private: - std::unordered_map> m_shader_programs; - std::unordered_map> m_gpu_meshes; - std::unordered_map> m_gpu_tex2d; + // Single-owner pools for the hot-path resources; the by-uid maps translate an AssetUID to its + // handle so the shared_ptr accessors and the handle accessors share one upload. + HandlePool, GPUMesh> m_mesh_pool; + HandlePool, GPUTexture> m_tex2d_pool; + HandlePool, ShaderProgram> m_shader_pool; + std::unordered_map m_mesh_by_uid; + std::unordered_map m_tex2d_by_uid; + std::unordered_map m_shader_by_uid; + + // Cubemaps stay a plain map: skybox is not the per-frame hot path (one per scene). std::unordered_map> m_gpu_cubemaps; std::shared_ptr m_graphics_factory; std::shared_ptr m_asset_bank; + AssetBank::RemovalListenerHandle m_removal_listener_handle = 0; }; } // namespace ICE diff --git a/ICE/Assets/include/IAssetLoader.h b/ICE/Assets/include/IAssetLoader.h new file mode 100644 index 00000000..38eaad08 --- /dev/null +++ b/ICE/Assets/include/IAssetLoader.h @@ -0,0 +1,44 @@ +// +// Created by Thomas Ibanez on 31.07.21. +// + +#pragma once + +#include +#include +#include +#include + +#include "Asset.h" +#include "GraphicsFactory.h" + +namespace ICE { +// Non-template base so a heterogeneous set of loaders can live in a single container (AssetLoader) +// without that container having to know every concrete asset type at compile time. This is what +// lets `addLoader` accept any T and keeps the assets headers from pulling in every leaf asset +// type. +class IAssetLoaderBase { + public: + virtual ~IAssetLoaderBase() = default; + + // Load `files` and return the result as the Asset base. Enables loading a type known only by + // std::type_index (the async import queue and prefix-based project loading). + virtual std::shared_ptr loadErased(const std::vector &files) = 0; +}; + +template +class IAssetLoader : public IAssetLoaderBase { + static_assert(std::is_base_of_v, "Asset loaders can only load types derived from Asset"); + + public: + IAssetLoader() = default; + + virtual std::shared_ptr load(const std::vector &files) = 0; + + // Every loadable type is an Asset, so the typed result upcasts directly. A failed load returns + // nullptr, which is preserved by the cast. + std::shared_ptr loadErased(const std::vector &files) override { + return std::static_pointer_cast(load(files)); + } +}; +} // namespace ICE diff --git a/ICE/Assets/include/Material.h b/ICE/Assets/include/Material.h index ea491459..50865e34 100644 --- a/ICE/Assets/include/Material.h +++ b/ICE/Assets/include/Material.h @@ -5,7 +5,9 @@ #pragma once #include +#include #include +#include #include "Shader.h" #include "Texture.h" @@ -14,6 +16,16 @@ namespace ICE { using UniformValue = std::variant; +// The variant's active alternative, resolved once so the render hot path can switch on it instead +// of walking a std::holds_alternative chain per material bind. +enum class UniformKind { Texture, Int, Float, Vec2, Vec3, Vec4, Mat4 }; + +struct UniformBinding { + std::string name; + UniformKind kind; + UniformValue value; +}; + class Material : public Asset { public: Material(bool transparent = false); @@ -25,6 +37,7 @@ class Material : public Asset { } else { m_uniforms[name] = value; } + m_bindings_dirty = true; } template @@ -39,7 +52,12 @@ class Material : public Asset { void renameUniform(const std::string& previous_name, const std::string& new_name); void removeUniform(const std::string& name); - std::unordered_map getAllUniforms() const; + const std::unordered_map& getAllUniforms() const; + + // Pre-classified uniforms for the render hot path (see UniformBinding). Rebuilt lazily the + // first time it's read after a uniform changes; otherwise returned straight from the cache. + const std::vector& getUniformBindings() const; + AssetUID getShader() const; void setShader(AssetUID shader_id); bool isTransparent() const; @@ -52,5 +70,10 @@ class Material : public Asset { AssetUID m_shader = NO_ASSET_ID; std::unordered_map m_uniforms; bool m_transparent; + + // Cache of pre-classified uniforms, rebuilt when m_uniforms changes. Mutable so the read-only + // accessor can refresh it lazily. + mutable std::vector m_bindings; + mutable bool m_bindings_dirty = true; }; } // namespace ICE diff --git a/ICE/Assets/include/Mesh.h b/ICE/Assets/include/Mesh.h index aa268f97..a1d7c173 100644 --- a/ICE/Assets/include/Mesh.h +++ b/ICE/Assets/include/Mesh.h @@ -59,6 +59,6 @@ class Mesh : public Asset { private: MeshData m_data; SkinningData m_skinningData; - AABB boundingBox; + AABB boundingBox{Eigen::Vector3f::Zero(), Eigen::Vector3f::Zero()}; }; } // namespace ICE \ No newline at end of file diff --git a/ICE/Assets/include/Model.h b/ICE/Assets/include/Model.h index c8a6e50a..fec5e071 100644 --- a/ICE/Assets/include/Model.h +++ b/ICE/Assets/include/Model.h @@ -1,5 +1,6 @@ #pragma once +#include "AABB.h" #include "Animation.h" #include "Asset.h" #include "Mesh.h" @@ -26,7 +27,7 @@ class Model : public Asset { std::vector getMeshes() const { return m_meshes; } std::vector getMaterialsIDs() const { return m_materials; } AABB getBoundingBox() const { return m_boundingbox; } - std::unordered_map getAnimations() const { return m_animations; } + const std::unordered_map& getAnimations() const { return m_animations; } Skeleton &getSkeleton() { return m_skeleton; } void setSkeleton(const Skeleton &skeleton) { m_skeleton = skeleton; } void setAnimations(const std::unordered_map &animations) { m_animations = animations; } @@ -34,15 +35,21 @@ class Model : public Asset { void traverse(std::vector &meshes, std::vector &materials, std::vector &transforms, const Eigen::Matrix4f &base_transform = Eigen::Matrix4f::Identity()); + const Node* getNodeByName(const std::string &name); + AssetType getType() const override { return AssetType::EModel; } std::string getTypeName() const override { return "Model"; } private: + void buildNodeNameMap(); + std::vector m_nodes; std::vector m_meshes; std::vector m_materials; std::unordered_map m_animations; Skeleton m_skeleton; AABB m_boundingbox{{0, 0, 0}, {0, 0, 0}}; + std::unordered_map m_nodeNameMap; + bool m_nodeNameMapBuilt = false; }; } // namespace ICE \ No newline at end of file diff --git a/ICE/Assets/include/Texture.h b/ICE/Assets/include/Texture.h index 162e192c..146edbff 100644 --- a/ICE/Assets/include/Texture.h +++ b/ICE/Assets/include/Texture.h @@ -19,12 +19,18 @@ enum class TextureType { Tex2D = 0, CubeMap = 1 }; class Texture : public Asset { public: + Texture() = default; virtual ~Texture() { - if (data_ != nullptr) { - //stbi_image_free(data_); TODO: Might need need free-ing - data_ = nullptr; + if (m_owns_data && data_ != nullptr) { + stbi_image_free(data_); } + data_ = nullptr; } + + // Owns its pixel buffer when loaded from file; non-copyable to avoid a double-free. + Texture(const Texture&) = delete; + Texture& operator=(const Texture&) = delete; + const void* data() const { return data_; } TextureFormat getFormat() const { return m_format; } @@ -43,6 +49,10 @@ class Texture : public Asset { } void* data_ = nullptr; + // True when data_ was allocated by stb (file load) and this object must free it. + // Externally-supplied buffers (the void* constructors) default to non-owning until + // the loader path is hardened to copy/own them (Phase 1B). + bool m_owns_data = false; TextureWrap m_wrap = TextureWrap::Repeat; TextureFormat m_format = TextureFormat::None; int m_width = 0; @@ -52,7 +62,10 @@ class Texture : public Asset { class Texture2D : public Texture { public: Texture2D(const std::string& path); - Texture2D(void* data, int width, int height, TextureFormat fmt); + // take_ownership: when true, this texture owns `data` and frees it (with + // stbi_image_free, i.e. free) on destruction. Use for stb- or malloc-allocated + // buffers the caller hands off; leave false for buffers owned elsewhere. + Texture2D(void* data, int width, int height, TextureFormat fmt, bool take_ownership = false); virtual AssetType getType() const override { return AssetType::ETex2D; } virtual std::string getTypeName() const override { return "Texture2D"; } diff --git a/ICE/Assets/src/AssetBank.cpp b/ICE/Assets/src/AssetBank.cpp index 3665be7e..64be4858 100644 --- a/ICE/Assets/src/AssetBank.cpp +++ b/ICE/Assets/src/AssetBank.cpp @@ -4,27 +4,151 @@ #include "AssetBank.h" -#include -#include +#include +#include -#include "MaterialLoader.h" -#include "MeshLoader.h" -#include "ModelLoader.h" -#include "ShaderLoader.h" -#include "TextureLoader.h" +#include +#include +#include namespace ICE { -AssetBank::AssetBank() { - loader.AddLoader(std::make_shared()); - loader.AddLoader(std::make_shared()); - loader.AddLoader(std::make_shared(*this)); - loader.AddLoader(std::make_shared()); - loader.AddLoader(std::make_shared()); - loader.AddLoader(std::make_shared()); +// One staged async result on its way from a worker to the main-thread pump(). +struct StageResult { + AssetUID uid = NO_ASSET_ID; + std::shared_ptr staged; // opaque payload produced by the stage function + bool ok = false; // false if staging threw or returned null +}; + +// Shared worker<->main state for async imports. Held by shared_ptr so a worker that is still running +// when the bank is destroyed pushes into a state that outlives the bank, rather than dereferencing a +// dangling AssetBank -- this is why the stage function must never touch the bank. +struct AssetBankAsyncState { + std::mutex mutex; + std::vector completed; // written by workers, drained by pump() (main thread) + std::atomic inflight{0}; // reserved but not yet finalized +}; + +// Loaders are registered by the composition layer (io::registerDefaultLoaders), +// not here, so that the `assets` module does not depend on `io`. +AssetBank::AssetBank() : m_async(std::make_shared()) {} + +AssetBank::~AssetBank() { + // Finalize outstanding async loads before members are torn down. Safe: the bank co-owns the + // scheduler (kept alive until this returns), and workers push to the shared m_async state. + if (m_scheduler && m_async && m_async->inflight.load(std::memory_order_acquire) > 0) { + flushAsync(); + } } bool AssetBank::nameInUse(const AssetPath &name) { return !(nameMapping.find(name) == nameMapping.end()); } -} // namespace ICE \ No newline at end of file + +void AssetBank::setScheduler(const std::shared_ptr &scheduler) { + if (scheduler == m_scheduler) { + return; + } + // Drain under the current scheduler before swapping it out, so no in-flight load is orphaned + // (its worker would otherwise be dropped when the old scheduler is released). + if (m_scheduler && m_async->inflight.load(std::memory_order_acquire) > 0) { + flushAsync(); + } + m_scheduler = scheduler; +} + +AssetUID AssetBank::requestAssetImpl(const AssetPath &path, StageFn stage, CommitFn commit) { + // De-dup: an already-requested/loaded name returns its existing UID (no reload). + if (auto it = nameMapping.find(path); it != nameMapping.end()) { + return it->second; + } + + AssetUID uid = nextUID++; + AssetBankEntry entry{path, nullptr}; + entry.state = AssetState::Loading; + resources.emplace(uid, std::move(entry)); + nameMapping.emplace(path, uid); + m_pending_commits.emplace(uid, std::move(commit)); + m_async->inflight.fetch_add(1, std::memory_order_relaxed); + + // Capture the shared async state (not `this`): the worker pushes its result there and never + // touches the bank, so it is safe even if the bank is destroyed while the job runs. + auto async = m_async; + auto run_stage = [async, uid, stage = std::move(stage)]() { + StageResult result; + result.uid = uid; + try { + result.staged = stage(); + result.ok = (result.staged != nullptr); + } catch (...) { + result.ok = false; // logged on the main thread in pump() + } + std::lock_guard lock(async->mutex); + async->completed.push_back(std::move(result)); + }; + + if (m_scheduler) { + m_scheduler->submit(std::move(run_stage)); + } else { + // No scheduler: stage inline now. The result is still finalized by the next pump(), so the + // Loading -> Ready transition and ordering are identical to the async path. + run_stage(); + } + return uid; +} + +void AssetBank::pump() { + std::vector ready; + { + std::lock_guard lock(m_async->mutex); + ready.swap(m_async->completed); + } + for (auto &result : ready) { + auto commit_it = m_pending_commits.find(result.uid); + if (commit_it == m_pending_commits.end()) { + // Reservation was removed before finalization; drop the result and balance the count. + m_async->inflight.fetch_sub(1, std::memory_order_acq_rel); + continue; + } + + std::shared_ptr asset; + if (result.ok) { + try { + asset = commit_it->second(result.staged); // may add sub-assets (models) on the main thread + } catch (...) { + asset = nullptr; + } + } + + if (auto res_it = resources.find(result.uid); res_it != resources.end()) { + if (asset) { + res_it->second.asset = asset; + res_it->second.state = AssetState::Ready; + } else { + res_it->second.state = AssetState::Failed; + Logger::Log(Logger::ERROR, "Assets", "Async load failed for '%s'", res_it->second.path.toString().c_str()); + } + } + m_pending_commits.erase(commit_it); + m_async->inflight.fetch_sub(1, std::memory_order_acq_rel); + } +} + +void AssetBank::flushAsync() { + // Pump until every reserved load has been finalized. The staging scheduler is kept alive by the + // bank's co-ownership, so queued jobs are guaranteed to run and this terminates. + while (m_async->inflight.load(std::memory_order_acquire) > 0) { + pump(); + std::this_thread::yield(); + } +} + +std::size_t AssetBank::inFlight() const { + return m_async ? m_async->inflight.load(std::memory_order_acquire) : 0; +} + +AssetState AssetBank::getState(AssetUID uid) const { + auto it = resources.find(uid); + return it != resources.end() ? it->second.state : AssetState::Failed; +} +} // namespace ICE diff --git a/ICE/Assets/src/AssetPath.cpp b/ICE/Assets/src/AssetPath.cpp index 67b5dc6b..4a6bbd7f 100644 --- a/ICE/Assets/src/AssetPath.cpp +++ b/ICE/Assets/src/AssetPath.cpp @@ -4,6 +4,7 @@ #include "AssetPath.h" +#include #include #include #include @@ -18,6 +19,35 @@ std::unordered_map AssetPath::typenames = {{typeid {typeid(Material), "Materials"}, {typeid(Shader), "Shaders"}}; +std::unordered_map AssetPath::prefixes = {{"Textures", typeid(Texture2D)}, + {"CubeMaps", typeid(TextureCube)}, + {"Meshes", typeid(Mesh)}, + {"Models", typeid(Model)}, + {"Materials", typeid(Material)}, + {"Shaders", typeid(Shader)}}; + +void AssetPath::registerType(std::type_index type, const std::string &prefix) { + if (auto type_it = typenames.find(type); type_it != typenames.end()) { + // Already registered: identical prefix is a harmless no-op; a different prefix is a conflict. + if (type_it->second != prefix) { + throw ICEException("Asset type already registered with a different prefix"); + } + return; + } + if (auto prefix_it = prefixes.find(prefix); prefix_it != prefixes.end() && prefix_it->second != type) { + throw ICEException("Asset path prefix '" + prefix + "' is already registered for a different type"); + } + typenames.emplace(type, prefix); + prefixes.emplace(prefix, type); +} + +std::optional AssetPath::typeForPrefix(const std::string &prefix) { + if (auto it = prefixes.find(prefix); it != prefixes.end()) { + return it->second; + } + return std::nullopt; +} + AssetPath::AssetPath(std::string path) { size_t last = 0; for (size_t i = 0; i < path.length(); i++) { @@ -27,10 +57,11 @@ AssetPath::AssetPath(std::string path) { } } name = path.substr(last, path.length() - last); + m_string = prefix() + name; // canonical form, computed once } -std::string AssetPath::toString() const { - return (prefix() + name); +const std::string& AssetPath::toString() const { + return m_string; } std::vector AssetPath::getPath() const { @@ -43,9 +74,7 @@ std::string AssetPath::getName() const { void AssetPath::setName(const std::string &name) { AssetPath::name = name; -} - -AssetPath::AssetPath(const AssetPath &cpy) : AssetPath(cpy.toString()) { + m_string = prefix() + name; // keep the canonical string in sync } std::string AssetPath::prefix() const { diff --git a/ICE/Assets/src/GPURegistry.cpp b/ICE/Assets/src/GPURegistry.cpp index 10513c71..8e232190 100644 --- a/ICE/Assets/src/GPURegistry.cpp +++ b/ICE/Assets/src/GPURegistry.cpp @@ -1,85 +1,148 @@ #include "GPURegistry.h" -#include - namespace ICE { GPURegistry::GPURegistry(const std::shared_ptr &factory, const std::shared_ptr &bank) : m_graphics_factory(factory), m_asset_bank(bank) { + // Subscribe to asset removals: without this, the by-uid maps would pin every upload forever, so + // removing an asset leaks its GPU memory and re-importing keeps rendering the old data. + m_removal_listener_handle = m_asset_bank->addRemovalListener([this](AssetUID id) { evict(id); }); +} + +GPURegistry::~GPURegistry() { + // The bank can outlive the registry (the registry holds a shared_ptr to it), so a listener that + // captures `this` must be removed before we are destroyed. + if (m_asset_bank) { + m_asset_bank->removeRemovalListener(m_removal_listener_handle); + } +} + +std::size_t GPURegistry::residentMeshBytes() { + std::size_t total = 0; + for (const auto &[uid, handle] : m_mesh_by_uid) { + auto mesh = m_asset_bank->getAsset(uid); + if (!mesh) { + continue; + } + const auto &d = mesh->getMeshData(); + total += d.vertices.size() * sizeof(Eigen::Vector3f); + total += d.normals.size() * sizeof(Eigen::Vector3f); + total += d.uvCoords.size() * sizeof(Eigen::Vector2f); + total += d.tangents.size() * sizeof(Eigen::Vector3f); + total += d.bitangents.size() * sizeof(Eigen::Vector3f); + total += d.boneIDs.size() * sizeof(Eigen::Vector4i); + total += d.boneWeights.size() * sizeof(Eigen::Vector4f); + total += d.indices.size() * sizeof(Eigen::Vector3i); + } + return total; +} + +std::size_t GPURegistry::residentTextureBytes() { + std::size_t total = 0; + for (const auto &[uid, handle] : m_tex2d_by_uid) { + auto tex = m_asset_bank->getAsset(uid); + if (!tex) { + continue; + } + total += static_cast(tex->getWidth()) * static_cast(tex->getHeight()) * 4; + } + return total; +} + +void GPURegistry::evict(AssetUID id) { + if (auto it = m_mesh_by_uid.find(id); it != m_mesh_by_uid.end()) { + m_mesh_pool.erase(it->second); + m_mesh_by_uid.erase(it); + } + if (auto it = m_tex2d_by_uid.find(id); it != m_tex2d_by_uid.end()) { + m_tex2d_pool.erase(it->second); + m_tex2d_by_uid.erase(it); + } + if (auto it = m_shader_by_uid.find(id); it != m_shader_by_uid.end()) { + m_shader_pool.erase(it->second); + m_shader_by_uid.erase(it); + } + m_gpu_cubemaps.erase(id); } std::shared_ptr GPURegistry::getShader(AssetUID id) { - if (m_shader_programs.contains(id)) { - return m_shader_programs[id]; - } else { - auto shader_asset = m_asset_bank->getAsset(id); - if (!shader_asset) { - return nullptr; + if (auto it = m_shader_by_uid.find(id); it != m_shader_by_uid.end()) { + if (auto *sp = m_shader_pool.get(it->second)) { + return *sp; } - auto program = m_graphics_factory->createShader(*shader_asset); - m_shader_programs[id] = program; - return program; } + auto shader_asset = m_asset_bank->getAsset(id); + if (!shader_asset) { + return nullptr; + } + auto program = m_graphics_factory->createShader(*shader_asset); + m_shader_by_uid[id] = m_shader_pool.insert(program); + return program; } std::shared_ptr GPURegistry::getMesh(AssetUID id) { - if (m_gpu_meshes.contains(id)) { - return m_gpu_meshes[id]; - } else { - auto mesh_asset = m_asset_bank->getAsset(id); - if (!mesh_asset) { - return nullptr; + if (auto it = m_mesh_by_uid.find(id); it != m_mesh_by_uid.end()) { + if (auto *sp = m_mesh_pool.get(it->second)) { + return *sp; } - - auto vertexArray = m_graphics_factory->createVertexArray(); - - auto vertexBuffer = m_graphics_factory->createVertexBuffer(); - auto normalsBuffer = m_graphics_factory->createVertexBuffer(); - auto uvBuffer = m_graphics_factory->createVertexBuffer(); - auto tangentBuffer = m_graphics_factory->createVertexBuffer(); - auto biTangentBuffer = m_graphics_factory->createVertexBuffer(); - auto boneIDBuffer = m_graphics_factory->createVertexBuffer(); - auto boneWeightBuffer = m_graphics_factory->createVertexBuffer(); - auto indexBuffer = m_graphics_factory->createIndexBuffer(); - - auto data = mesh_asset->getMeshData(); - vertexBuffer->putData(BufferUtils::CreateFloatBuffer(data.vertices), 3 * data.vertices.size() * sizeof(float)); - normalsBuffer->putData(BufferUtils::CreateFloatBuffer(data.normals), 3 * data.normals.size() * sizeof(float)); - tangentBuffer->putData(BufferUtils::CreateFloatBuffer(data.tangents), 3 * data.tangents.size() * sizeof(float)); - biTangentBuffer->putData(BufferUtils::CreateFloatBuffer(data.bitangents), 3 * data.bitangents.size() * sizeof(float)); - boneIDBuffer->putData(BufferUtils::CreateIntBuffer(data.boneIDs), 4 * data.boneIDs.size() * sizeof(int)); - boneWeightBuffer->putData(BufferUtils::CreateFloatBuffer(data.boneWeights), 4 * data.boneWeights.size() * sizeof(float)); - uvBuffer->putData(BufferUtils::CreateFloatBuffer(data.uvCoords), 2 * data.uvCoords.size() * sizeof(float)); - indexBuffer->putData(BufferUtils::CreateIntBuffer(data.indices), 3 * data.indices.size() * sizeof(int)); - - vertexArray->pushVertexBuffer(vertexBuffer, 0, 3); - vertexArray->pushVertexBuffer(normalsBuffer, 1, 3); - vertexArray->pushVertexBuffer(uvBuffer, 2, 2); - vertexArray->pushVertexBuffer(tangentBuffer, 3, 3); - vertexArray->pushVertexBuffer(biTangentBuffer, 4, 3); - vertexArray->pushVertexBuffer(boneIDBuffer, 5, 4); - vertexArray->pushVertexBuffer(boneWeightBuffer, 6, 4); - vertexArray->setIndexBuffer(indexBuffer); - - std::shared_ptr gpu_mesh = std::make_shared(vertexArray); - m_gpu_meshes[id] = gpu_mesh; - return gpu_mesh; } + auto mesh_asset = m_asset_bank->getAsset(id); + if (!mesh_asset) { + return nullptr; + } + + auto vertexArray = m_graphics_factory->createVertexArray(); + + auto vertexBuffer = m_graphics_factory->createVertexBuffer(); + auto normalsBuffer = m_graphics_factory->createVertexBuffer(); + auto uvBuffer = m_graphics_factory->createVertexBuffer(); + auto tangentBuffer = m_graphics_factory->createVertexBuffer(); + auto biTangentBuffer = m_graphics_factory->createVertexBuffer(); + auto boneIDBuffer = m_graphics_factory->createVertexBuffer(); + auto boneWeightBuffer = m_graphics_factory->createVertexBuffer(); + auto indexBuffer = m_graphics_factory->createIndexBuffer(); + + // Fixed-size Eigen vectors are tightly packed (e.g. sizeof(Vector3f) == 3 floats), + // so a std::vector of them is a contiguous float/int array we can upload directly + // -- no intermediate malloc'd staging buffers (which were previously leaked). + const auto &data = mesh_asset->getMeshData(); + vertexBuffer->putData(data.vertices.data(), 3 * data.vertices.size() * sizeof(float)); + normalsBuffer->putData(data.normals.data(), 3 * data.normals.size() * sizeof(float)); + tangentBuffer->putData(data.tangents.data(), 3 * data.tangents.size() * sizeof(float)); + biTangentBuffer->putData(data.bitangents.data(), 3 * data.bitangents.size() * sizeof(float)); + boneIDBuffer->putData(data.boneIDs.data(), 4 * data.boneIDs.size() * sizeof(int)); + boneWeightBuffer->putData(data.boneWeights.data(), 4 * data.boneWeights.size() * sizeof(float)); + uvBuffer->putData(data.uvCoords.data(), 2 * data.uvCoords.size() * sizeof(float)); + indexBuffer->putData(data.indices.data(), 3 * data.indices.size() * sizeof(int)); + + vertexArray->pushVertexBuffer(vertexBuffer, 0, 3); + vertexArray->pushVertexBuffer(normalsBuffer, 1, 3); + vertexArray->pushVertexBuffer(uvBuffer, 2, 2); + vertexArray->pushVertexBuffer(tangentBuffer, 3, 3); + vertexArray->pushVertexBuffer(biTangentBuffer, 4, 3); + vertexArray->pushVertexBuffer(boneIDBuffer, 5, 4); + vertexArray->pushVertexBuffer(boneWeightBuffer, 6, 4); + vertexArray->setIndexBuffer(indexBuffer); + + std::shared_ptr gpu_mesh = std::make_shared(vertexArray); + m_mesh_by_uid[id] = m_mesh_pool.insert(gpu_mesh); + return gpu_mesh; } std::shared_ptr GPURegistry::getTexture2D(AssetUID id) { - if (m_gpu_tex2d.contains(id)) { - return m_gpu_tex2d[id]; - } else { - auto tex_asset = m_asset_bank->getAsset(id); - if (!tex_asset) { - return nullptr; + if (auto it = m_tex2d_by_uid.find(id); it != m_tex2d_by_uid.end()) { + if (auto *sp = m_tex2d_pool.get(it->second)) { + return *sp; } - - auto gpu_texture = m_graphics_factory->createTexture2D(*tex_asset); - m_gpu_tex2d[id] = gpu_texture; - return gpu_texture; } + auto tex_asset = m_asset_bank->getAsset(id); + if (!tex_asset) { + return nullptr; + } + + auto gpu_texture = m_graphics_factory->createTexture2D(*tex_asset); + m_tex2d_by_uid[id] = m_tex2d_pool.insert(gpu_texture); + return gpu_texture; } std::shared_ptr GPURegistry::getCubemap(AssetUID id) { @@ -95,4 +158,22 @@ std::shared_ptr GPURegistry::getCubemap(AssetUID id) { return gpu_texture; } } + +MeshHandle GPURegistry::meshHandle(AssetUID id) { + getMesh(id); // ensure resident (uploads on first use) + auto it = m_mesh_by_uid.find(id); + return it != m_mesh_by_uid.end() ? it->second : MeshHandle{}; +} + +TextureHandle GPURegistry::textureHandle(AssetUID id) { + getTexture2D(id); + auto it = m_tex2d_by_uid.find(id); + return it != m_tex2d_by_uid.end() ? it->second : TextureHandle{}; +} + +ShaderHandle GPURegistry::shaderHandle(AssetUID id) { + getShader(id); + auto it = m_shader_by_uid.find(id); + return it != m_shader_by_uid.end() ? it->second : ShaderHandle{}; +} } // namespace ICE diff --git a/ICE/Assets/src/Material.cpp b/ICE/Assets/src/Material.cpp index 2ceb8559..8b20020e 100644 --- a/ICE/Assets/src/Material.cpp +++ b/ICE/Assets/src/Material.cpp @@ -26,19 +26,49 @@ void Material::renameUniform(const std::string& previous_name, const std::string auto& val = m_uniforms[previous_name]; m_uniforms.try_emplace(new_name, val); m_uniforms.erase(previous_name); + m_bindings_dirty = true; } } void Material::removeUniform(const std::string& name) { if (m_uniforms.contains(name)) { m_uniforms.erase(name); + m_bindings_dirty = true; } } -std::unordered_map Material::getAllUniforms() const { +const std::unordered_map& Material::getAllUniforms() const { return m_uniforms; } +const std::vector& Material::getUniformBindings() const { + if (m_bindings_dirty) { + m_bindings.clear(); + m_bindings.reserve(m_uniforms.size()); + for (const auto& [name, value] : m_uniforms) { + UniformKind kind; + if (std::holds_alternative(value)) { + kind = UniformKind::Texture; + } else if (std::holds_alternative(value)) { + kind = UniformKind::Int; + } else if (std::holds_alternative(value)) { + kind = UniformKind::Float; + } else if (std::holds_alternative(value)) { + kind = UniformKind::Vec2; + } else if (std::holds_alternative(value)) { + kind = UniformKind::Vec3; + } else if (std::holds_alternative(value)) { + kind = UniformKind::Vec4; + } else { + kind = UniformKind::Mat4; // the only remaining alternative + } + m_bindings.push_back({name, kind, value}); + } + m_bindings_dirty = false; + } + return m_bindings; +} + std::string Material::getTypeName() const { return "Material"; } diff --git a/ICE/Graphics/src/Mesh.cpp b/ICE/Assets/src/Mesh.cpp similarity index 97% rename from ICE/Graphics/src/Mesh.cpp rename to ICE/Assets/src/Mesh.cpp index 57614160..284aaecf 100644 --- a/ICE/Graphics/src/Mesh.cpp +++ b/ICE/Assets/src/Mesh.cpp @@ -4,7 +4,6 @@ #include "Mesh.h" -#include #include #include diff --git a/ICE/Assets/src/Model.cpp b/ICE/Assets/src/Model.cpp index c492cd76..087906b2 100644 --- a/ICE/Assets/src/Model.cpp +++ b/ICE/Assets/src/Model.cpp @@ -29,12 +29,33 @@ void Model::traverse(std::vector &meshes, std::vector &mater transforms.push_back(node_transform); } + // Accumulate this node's local transform into the children's base. This only + // affects Model::traverse, which is used solely by the editor thumbnail renderer + // (a flat, scene-graph-less render) -- the live scene/animation path uses + // spawnTree + the scene graph and must NOT accumulate here. for (const auto &child_idx : node.children) { - recursive_traversal(child_idx, transform); + recursive_traversal(child_idx, transform * node.localTransform); } }; recursive_traversal(0, base_transform); } +void Model::buildNodeNameMap() { + if (m_nodeNameMapBuilt) return; + for (int i = 0; i < static_cast(m_nodes.size()); ++i) { + m_nodeNameMap[m_nodes[i].name] = i; + } + m_nodeNameMapBuilt = true; +} + +const Model::Node* Model::getNodeByName(const std::string &name) { + buildNodeNameMap(); + auto it = m_nodeNameMap.find(name); + if (it != m_nodeNameMap.end()) { + return &m_nodes[it->second]; + } + return nullptr; +} + } // namespace ICE \ No newline at end of file diff --git a/ICE/Assets/src/Texture.cpp b/ICE/Assets/src/Texture.cpp index 36b9e101..4b205972 100644 --- a/ICE/Assets/src/Texture.cpp +++ b/ICE/Assets/src/Texture.cpp @@ -5,6 +5,7 @@ namespace ICE { Texture2D::Texture2D(const std::string& path) { int channels = 0; data_ = getDataFromFile(path, &m_width, &m_height, &channels); + m_owns_data = true; // stb-allocated; freed in ~Texture if (channels == 3) { m_format = TextureFormat::RGB8; } else if (channels == 4) { @@ -15,14 +16,22 @@ Texture2D::Texture2D(const std::string& path) { m_format = TextureFormat::None; } } -Texture2D::Texture2D(void* data, int width, int height, TextureFormat fmt) { +Texture2D::Texture2D(void* data, int width, int height, TextureFormat fmt, bool take_ownership) { data_ = data; + m_owns_data = take_ownership; m_width = width; m_height = height; m_format = fmt; } TextureCube::TextureCube(const std::string& path) { + // Load the equirectangular source image as RGB; OpenGLTextureCube converts it into the + // six cube faces. This used to be an empty stub, so cubemaps loaded from a path had null + // data and zero size. + int channels = 0; + data_ = getDataFromFile(path, &m_width, &m_height, &channels, STBI_rgb); + m_owns_data = true; // stb-allocated; freed in ~Texture + m_format = TextureFormat::RGB8; } TextureCube::TextureCube(void* data, int width, int height, TextureFormat fmt) { data_ = data; diff --git a/ICE/Assets/src/stb_image_impl.cpp b/ICE/Assets/src/stb_image_impl.cpp new file mode 100644 index 00000000..c7f87a0e --- /dev/null +++ b/ICE/Assets/src/stb_image_impl.cpp @@ -0,0 +1,5 @@ +// The single translation unit that compiles the stb_image implementation for the whole +// engine. Every other file includes as a declaration-only header, so +// the implementation must be defined here exactly once to avoid duplicate symbols. +#define STB_IMAGE_IMPLEMENTATION +#include diff --git a/ICE/Assets/test/AssetBankTest.cpp b/ICE/Assets/test/AssetBankTest.cpp index 83d47857..99bdcb77 100644 --- a/ICE/Assets/test/AssetBankTest.cpp +++ b/ICE/Assets/test/AssetBankTest.cpp @@ -1,13 +1,44 @@ // // Created by Thomas Ibanez on 24.02.21. // -#define STB_IMAGE_IMPLEMENTATION #include +#include + #include "AssetBank.h" +#include "AssetLoader.h" +#include "JobScheduler.h" using namespace ICE; +namespace { +// A brand-new asset type unknown to the engine's built-in set -- proves the loader registry is open +// (type-erased) rather than closed to a fixed variant of concrete types. +class DummyAsset : public Asset { + public: + explicit DummyAsset(int v) : value(v) {} + AssetType getType() const override { return AssetType::EOther; } + std::string getTypeName() const override { return "Dummy"; } + int value; +}; + +class DummyLoader : public IAssetLoader { + public: + std::shared_ptr load(const std::vector& files) override { + return std::make_shared(static_cast(files.size())); + } +}; + +// Distinct dummy types (by template tag) for prefix-conflict tests, so each test uses types no other +// test registers -- the AssetPath registration maps are process-global static state. +template +class TaggedDummyAsset : public Asset { + public: + AssetType getType() const override { return AssetType::EOther; } + std::string getTypeName() const override { return "TaggedDummy"; } +}; +} // namespace + TEST(AssetBankTest, AddedAssetsCanBeRetrieved) { AssetBank ab; auto mtl = std::make_shared(); @@ -58,3 +89,231 @@ TEST(AssetBankTest, NameInUseBehavesCorrectly) { ASSERT_TRUE(ab.nameInUse(AssetPath::WithTypePrefix("a_ice_test_mtl"))); ASSERT_FALSE(ab.nameInUse(AssetPath::WithTypePrefix("hey"))); } + +// Removing an asset must notify listeners with the removed UID -- this is the seam the GPU registry +// subscribes to so it can evict the matching GPU upload (otherwise removed/re-imported assets leak +// and keep rendering stale data). +TEST(AssetBankTest, RemovalListenerFiresWithRemovedUID) { + AssetBank ab; + auto mtl = std::make_shared(); + ab.addAsset("a_ice_test_mtl", mtl); + AssetUID uid = ab.getUID(AssetPath::WithTypePrefix("a_ice_test_mtl")); + + std::vector removed; + ab.addRemovalListener([&](AssetUID id) { removed.push_back(id); }); + + ASSERT_TRUE(ab.removeAsset(AssetPath::WithTypePrefix("a_ice_test_mtl"))); + ASSERT_EQ(removed.size(), 1u); + ASSERT_EQ(removed[0], uid); + + // Removing a non-existent asset does not fire the listener. + ASSERT_FALSE(ab.removeAsset(AssetPath::WithTypePrefix("a_ice_test_mtl"))); + ASSERT_EQ(removed.size(), 1u); +} + +// An unregistered listener must not be invoked (the GPU registry unregisters in its destructor to +// avoid a dangling capture of `this`). +TEST(AssetBankTest, RemovalListenerCanBeUnregistered) { + AssetBank ab; + ab.addAsset("a_ice_test_mtl", std::make_shared()); + + int calls = 0; + auto handle = ab.addRemovalListener([&](AssetUID) { ++calls; }); + ab.removeRemovalListener(handle); + + ASSERT_TRUE(ab.removeAsset(AssetPath::WithTypePrefix("a_ice_test_mtl"))); + ASSERT_EQ(calls, 0); +} + +// Re-import path at the bank level: removing then re-adding under the same name fires the listener +// (so the GPU registry drops the old upload) and getAsset returns the fresh asset. +TEST(AssetBankTest, ReAddUnderSameNameReplacesAssetAndNotifies) { + AssetBank ab; + auto first = std::make_shared(MeshData{{Eigen::Vector3f()}}); + ab.addAsset("a_ice_test_mesh", first); + + int notifications = 0; + ab.addRemovalListener([&](AssetUID) { ++notifications; }); + + ASSERT_TRUE(ab.removeAsset(AssetPath::WithTypePrefix("a_ice_test_mesh"))); + ASSERT_EQ(notifications, 1); + + auto second = std::make_shared(MeshData{{Eigen::Vector3f(), Eigen::Vector3f()}}); + ab.addAsset("a_ice_test_mesh", second); + ASSERT_EQ(ab.getAsset("a_ice_test_mesh"), second); + ASSERT_NE(ab.getAsset("a_ice_test_mesh"), first); +} + +// The type-erased loader registry accepts an arbitrary Asset subclass and loads through both the +// typed and the type_index-keyed entry points. +TEST(AssetLoaderTest, RegistersAndLoadsCustomAssetType) { + AssetLoader loader; + loader.AddLoader(std::make_shared()); + + auto typed = loader.LoadResource({"a", "b"}); + ASSERT_NE(typed, nullptr); + ASSERT_EQ(typed->value, 2); + + // Erased path returns the same concrete type as an Asset. + auto erased = loader.LoadResource(std::type_index(typeid(DummyAsset)), {"a"}); + ASSERT_NE(erased, nullptr); + auto down = std::dynamic_pointer_cast(erased); + ASSERT_NE(down, nullptr); + ASSERT_EQ(down->value, 1); +} + +// An unregistered type still throws (behavior preserved from the old variant-based registry). +TEST(AssetLoaderTest, ThrowsForUnregisteredType) { + AssetLoader loader; + ASSERT_THROW(loader.LoadResource({"x"}), ICEException); +} + +// A plugin-defined asset kind works end-to-end once its loader + prefix are registered in one call: +// add / get / getAll / remove (with the eviction listener firing) all behave like a built-in type. +TEST(AssetBankTest, CustomAssetTypeEndToEnd) { + AssetBank ab; + ab.addLoader("DummyAssets", std::make_shared()); + + auto d = std::make_shared(7); + ASSERT_TRUE(ab.addAsset("thing", d)); + ASSERT_EQ(ab.getAsset("thing"), d); + ASSERT_EQ(ab.getAll().size(), 1u); + + std::vector removed; + ab.addRemovalListener([&](AssetUID id) { removed.push_back(id); }); + AssetUID uid = ab.getUID(AssetPath::WithTypePrefix("thing")); + + ASSERT_TRUE(ab.removeAsset(AssetPath::WithTypePrefix("thing"))); + ASSERT_EQ(removed.size(), 1u); + ASSERT_EQ(removed[0], uid); + ASSERT_EQ(ab.getAsset("thing"), nullptr); +} + +// Loading through a custom type's prefix routes to the right loader (reverse lookup for project +// persistence), and an unregistered prefix reports nothing. +TEST(AssetBankTest, PrefixReverseLookup) { + AssetPath::registerType>("TaggedTen"); + auto found = AssetPath::typeForPrefix("TaggedTen"); + ASSERT_TRUE(found.has_value()); + ASSERT_EQ(found.value(), std::type_index(typeid(TaggedDummyAsset<10>))); + ASSERT_FALSE(AssetPath::typeForPrefix("NoSuchPrefixEverRegistered").has_value()); +} + +// Duplicate/conflicting registration is rejected loudly; an identical re-registration is a no-op. +TEST(AssetBankTest, DuplicateTypeRegistrationRejected) { + AssetPath::registerType>("ConflictPrefixX"); + + // Same prefix, different type -> conflict. + ASSERT_THROW(AssetPath::registerType>("ConflictPrefixX"), ICEException); + // Same type, different prefix -> conflict. + ASSERT_THROW(AssetPath::registerType>("DifferentPrefixY"), ICEException); + // Identical (type, prefix) -> idempotent. + ASSERT_NO_THROW(AssetPath::registerType>("ConflictPrefixX")); +} + +// --- Async import (T5) --------------------------------------------------------------------------- + +// Without a scheduler, requestAsset stages inline but still only publishes on pump(): the UID is +// reserved (Loading) immediately and becomes Ready with a payload after pump(). +TEST(AssetBankTest, RequestAssetInlineBecomesReadyAfterPump) { + AssetBank ab; + ab.addLoader("DummyAssets", std::make_shared()); + + AssetUID uid = ab.requestAsset("thing", std::vector{"a", "b"}); + // Reserved but not yet finalized. + ASSERT_EQ(ab.getState(uid), AssetState::Loading); + ASSERT_EQ(ab.getAsset(uid), nullptr); + ASSERT_EQ(ab.inFlight(), 1u); + + ab.pump(); + ASSERT_EQ(ab.getState(uid), AssetState::Ready); + ASSERT_EQ(ab.inFlight(), 0u); + auto asset = ab.getAsset(uid); + ASSERT_NE(asset, nullptr); + ASSERT_EQ(asset->value, 2); // DummyLoader returns files.size() +} + +// With a scheduler, staging happens on a worker; flushAsync() drains and finalizes everything. +TEST(AssetBankTest, RequestAssetAsyncWithSchedulerFlushes) { + AssetBank ab; + ab.addLoader("DummyAssets", std::make_shared()); + auto scheduler = std::make_shared(); + ab.setScheduler(scheduler); + + std::vector uids; + for (int i = 0; i < 50; ++i) { + uids.push_back(ab.requestAsset("thing" + std::to_string(i), std::vector{"a"})); + } + ab.flushAsync(); + ASSERT_EQ(ab.inFlight(), 0u); + for (auto uid : uids) { + ASSERT_EQ(ab.getState(uid), AssetState::Ready); + ASSERT_NE(ab.getAsset(uid), nullptr); + } +} + +// A staging failure (null/throwing stage) leaves the entry Failed with a null payload -- no crash, +// no garbage asset. +TEST(AssetBankTest, RequestAssetFailedLoadMarksFailed) { + AssetBank ab; + AssetBank::StageFn stage = []() -> std::shared_ptr { return nullptr; }; + AssetBank::CommitFn commit = [](const std::shared_ptr& s) { return std::static_pointer_cast(s); }; + AssetUID uid = ab.requestAsset("bad", stage, commit); + + ab.pump(); + ASSERT_EQ(ab.getState(uid), AssetState::Failed); + ASSERT_EQ(ab.getAsset(uid), nullptr); + ASSERT_EQ(ab.inFlight(), 0u); +} + +// Requesting the same name twice returns the same reserved UID (no double-load). +TEST(AssetBankTest, RequestAssetDeDupesByName) { + AssetBank ab; + ab.addLoader("DummyAssets", std::make_shared()); + AssetUID a = ab.requestAsset("thing", std::vector{"a"}); + AssetUID b = ab.requestAsset("thing", std::vector{"a"}); + ASSERT_EQ(a, b); + ASSERT_EQ(ab.inFlight(), 1u); +} + +// The two-phase overload runs commit on the main thread in pump(), where it may add sub-assets -- +// exactly how model import commits its meshes/materials/textures. +TEST(AssetBankTest, RequestAssetTwoPhaseCommitAddsSubAssets) { + AssetBank ab; + ab.addLoader("DummyAssets", std::make_shared()); + + AssetBank::StageFn stage = []() -> std::shared_ptr { return std::make_shared(42); }; + AssetBank::CommitFn commit = [&ab](const std::shared_ptr& staged) -> std::shared_ptr { + int payload = *std::static_pointer_cast(staged); + // Simulate a model committing a sub-asset, then producing the top-level asset. + ab.addAsset("sub", std::make_shared(payload)); + return std::static_pointer_cast(std::make_shared(7)); + }; + AssetUID uid = ab.requestAsset("top", stage, commit); + ab.pump(); + + ASSERT_EQ(ab.getState(uid), AssetState::Ready); + ASSERT_EQ(ab.getAsset(uid)->value, 7); + auto sub = ab.getAsset("sub"); + ASSERT_NE(sub, nullptr); + ASSERT_EQ(sub->value, 42); +} + +// The erased addAssetWithSpecificUID (keyed by std::type_index) restores a custom asset under a +// specific UID -- the primitive project loading uses to reload a persisted plugin asset by prefix. +TEST(AssetBankTest, AddAssetWithSpecificUIDErasedLoadsCustomType) { + AssetBank ab; + ab.addLoader("DummyAssets", std::make_shared()); + + AssetPath path = AssetPath::WithTypePrefix("restored"); + std::type_index type = typeid(DummyAsset); + ASSERT_TRUE(ab.addAssetWithSpecificUID(type, path, {"a", "b", "c"}, 42)); + + ASSERT_EQ(ab.getUID(path), 42u); + auto asset = ab.getAsset(42); + ASSERT_NE(asset, nullptr); + ASSERT_EQ(asset->value, 3); // DummyLoader returns files.size() + + // Re-inserting the same UID/name is rejected. + ASSERT_FALSE(ab.addAssetWithSpecificUID(type, path, {"x"}, 42)); +} diff --git a/ICE/CMakeLists.txt b/ICE/CMakeLists.txt index 294fb2be..0d45daed 100644 --- a/ICE/CMakeLists.txt +++ b/ICE/CMakeLists.txt @@ -3,8 +3,6 @@ project(ICE) message(STATUS "Building ${PROJECT_NAME}") -add_compile_definitions(NOMINMAX) - add_subdirectory(Assets) add_subdirectory(Components) add_subdirectory(Core) @@ -13,13 +11,17 @@ add_subdirectory(Graphics) add_subdirectory(GraphicsAPI) add_subdirectory(IO) add_subdirectory(Math) +add_subdirectory(Multithreading) +add_subdirectory(Physics) add_subdirectory(Platform) add_subdirectory(Scene) +add_subdirectory(Scripting) add_subdirectory(Storage) add_subdirectory(System) +add_subdirectory(UI) add_subdirectory(Util) -set(ICE_LIBS assets core graphics graphics_api io math platform scene storage system util components entity) +set(ICE_LIBS assets core graphics graphics_api io math platform scene storage system UI util components entity physics scripting) add_library(${PROJECT_NAME} INTERFACE) if(APPLE) @@ -38,20 +40,21 @@ elseif(WIN32) ${ICE_LIBS} ) else() - find_package(GTK REQUIRED) - include_directories(${GTK3_INCLUDE_DIRS}) - link_directories(${GTK3_LIBRARY_DIRS}) + find_package(PkgConfig REQUIRED) - add_definitions(${GTK3_CFLAGS_OTHER}) + pkg_check_modules(GTK3 REQUIRED gtk+-3.0) find_package(OpenGL REQUIRED) - include_directories(${OPENGL_INCLUDE_DIRS}) - target_link_libraries(${PROJECT_NAME} - INTERFACE - glfw - ${GTK3_LIBRARIES} - ${OPENGL_LIBRARIES} + target_include_directories(${PROJECT_NAME} INTERFACE ${GTK3_INCLUDE_DIRS} ${OPENGL_INCLUDE_DIRS}) + target_compile_options(${PROJECT_NAME} INTERFACE ${GTK3_CFLAGS_OTHER}) + + target_link_libraries(${PROJECT_NAME} + INTERFACE + glfw + ${GTK3_LIBRARIES} + ${OPENGL_LIBRARIES} stdc++fs + ${ICE_LIBS} ) endif() diff --git a/ICE/Components/include/AnimationComponent.h b/ICE/Components/include/AnimationComponent.h index d60c360e..2f06bfd5 100644 --- a/ICE/Components/include/AnimationComponent.h +++ b/ICE/Components/include/AnimationComponent.h @@ -9,5 +9,29 @@ struct AnimationComponent { double speed = 1.0; bool playing = true; bool loop = true; + + // Blending / crossfade + std::string previousAnimation; + double previousTime = 0.0; + double blendFactor = 1.0; // 0.0 = fully previousAnimation, 1.0 = fully currentAnimation + double blendDuration = 0.0; // ms; 0 = instant switch + bool blending = false; + + void playAnimation(const std::string& name, double blendDur = 0.0) { + if (name == currentAnimation) return; + if (blendDur > 0.0 && !currentAnimation.empty()) { + previousAnimation = currentAnimation; + previousTime = currentTime; + blendFactor = 0.0; + blendDuration = blendDur; + blending = true; + } else { + blending = false; + blendFactor = 1.0; + } + currentAnimation = name; + currentTime = 0.0; + playing = true; + } }; } // namespace ICE \ No newline at end of file diff --git a/ICE/Components/include/CameraComponent.h b/ICE/Components/include/CameraComponent.h index 3a567aa3..47581fb0 100644 --- a/ICE/Components/include/CameraComponent.h +++ b/ICE/Components/include/CameraComponent.h @@ -1,18 +1,40 @@ -// -// Created by Thomas Ibanez on 25.11.20. -// +#pragma once -#ifndef ICE_CAMERACOMPONENT_H -#define ICE_CAMERACOMPONENT_H +#include -#include +#include "Component.h" namespace ICE { - struct CameraComponent { - Camera* camera; - bool active; - }; -} +// Forward-declared, not included: keeps this component free of any graphics-backend dependency. +// `target` is only ever held here as a shared_ptr, never dereferenced (see below). +class Framebuffer; + +// Per-entity camera. Attach it to an entity that also has a TransformComponent: the entity's world +// transform (position + orientation, and any scene-graph parent) becomes the view, and these +// parameters become the projection. A scene selects which camera entity is active +// (Scene::setActiveCamera) and the render system views the scene through it. +// +// Because the view is just the entity's world matrix, parenting the camera entity under another +// entity gives a follow camera with no special-case code, and switching the active camera entity +// switches the viewpoint. +struct CameraComponent : public Component { + enum class Projection { Perspective, Orthographic }; + + Projection projection = Projection::Perspective; + + // Perspective: vertical field of view in degrees. Matches the engine's historical default + // camera (60 deg) so an entity camera renders the same view a scene-owned camera did. + float fov = 60.0f; + + // Orthographic: half-height of the view volume in world units (width follows the aspect ratio). + float ortho_size = 10.0f; + float near_plane = 0.01f; + float far_plane = 10000.0f; -#endif //ICE_CAMERACOMPONENT_H + // Optional off-screen target for this camera (render-to-texture, split-screen). Reserved: the + // render path does not consume it yet -- it renders the active camera to the scene target. Held + // as a forward-declared shared_ptr so declaring it adds no graphics dependency to Components. + std::shared_ptr target; +}; +} // namespace ICE diff --git a/ICE/Components/include/Component.h b/ICE/Components/include/Component.h index 8d916063..eb373952 100644 --- a/ICE/Components/include/Component.h +++ b/ICE/Components/include/Component.h @@ -8,8 +8,15 @@ #include #include +#include +#include +#include #include +#include +#include #include +#include +#include namespace ICE { @@ -23,118 +30,255 @@ class IComponentArray { virtual void entityDestroyed(Entity entity) = 0; }; +// Sparse set with STABLE component storage. Components live in a std::deque pool whose element +// addresses never move: growth appends without relocating existing elements, and removal frees a +// slot (recorded in freeSlots) without moving any survivor. Consequently a T* returned by +// getData/tryGetData stays valid until *that* component is removed -- adding or removing any OTHER +// entity's component of this type never invalidates it. (The previous std::vector backing could +// reallocate on insert or swap-move the tail element on erase, invalidating outstanding pointers; +// this is the hazard the WARNING comments on Registry::getComponent used to describe.) +// +// Contract: a component pointer is valid until that component is removed (or the array is +// destroyed). A freed slot is later reused by an insert, so a pointer to an already-removed +// component may then alias a different entity's component -- but that pointer was dangling by +// contract the moment its component was removed, exactly as erasing an element invalidates +// pointers to it in any container. +// +// The public surface (insertData/removeData/tryGetData/getData/count/forEach/entityDestroyed) is +// unchanged. Lookups index the sparse array directly (no hashing); iteration walks the pool +// skipping freed slots. template class ComponentArray : public IComponentArray { public: void insertData(Entity entity, T component) { - assert(entityToIndexMap.find(entity) == entityToIndexMap.end() && "Component added to same entity more than once."); - - // Put new entry at end and update the maps - size_t newIndex = size; - entityToIndexMap[entity] = newIndex; - indexToEntityMap[newIndex] = entity; - if (size < componentArray.size()) { - componentArray[newIndex] = component; + assert(!has(entity) && "Component added to same entity more than once."); + + // Grow the sparse array so `entity` is addressable, filling gaps with TOMBSTONE. + if (entity >= sparse.size()) { + sparse.resize(entity + 1, TOMBSTONE); + } + + uint32_t slot; + if (!freeSlots.empty()) { + // Reuse a hole left by a previous removal; the slot's address is unchanged, so a + // pointer to any other live component is unaffected. + slot = freeSlots.back(); + freeSlots.pop_back(); + pool[slot] = std::move(component); + slotToEntity[slot] = entity; } else { - componentArray.push_back(component); + // Append a fresh slot. std::deque::push_back keeps references to existing elements + // valid (no relocation), so outstanding component pointers survive the growth. + slot = static_cast(pool.size()); + pool.push_back(std::move(component)); + slotToEntity.push_back(entity); } - size++; + sparse[entity] = slot; + ++liveCount; } void removeData(Entity entity) { - assert(entityToIndexMap.find(entity) != entityToIndexMap.end() && "Removing non-existent component."); - - // Copy element at end into deleted element's place to maintain density - size_t indexOfRemovedEntity = entityToIndexMap[entity]; - size_t indexOfLastElement = size - 1; - componentArray[indexOfRemovedEntity] = componentArray[indexOfLastElement]; - - // Update map to point to moved spot - Entity entityOfLastElement = indexToEntityMap[indexOfLastElement]; - entityToIndexMap[entityOfLastElement] = indexOfRemovedEntity; - indexToEntityMap[indexOfRemovedEntity] = entityOfLastElement; - - entityToIndexMap.erase(entity); - indexToEntityMap.erase(indexOfLastElement); + assert(has(entity) && "Removing non-existent component."); + + // Tombstone the slot and return it to the free list. Survivors are NOT moved, so every + // other component's address is preserved. For components that own resources (strings, + // buffers, std::function, ...), move the removed one out into a temporary so those + // resources are freed now rather than lingering until the slot is reused; the slot itself + // stays put (left moved-from but alive). Trivially-destructible components own nothing, so + // skip the move entirely. + uint32_t slot = sparse[entity]; + if constexpr (!std::is_trivially_destructible_v) { + T released = std::move(pool[slot]); + } + slotToEntity[slot] = TOMBSTONE; + freeSlots.push_back(slot); + sparse[entity] = TOMBSTONE; + --liveCount; + } - size--; + // Returns nullptr (instead of silently aliasing another entity's slot) when the + // entity has no component of this type. A missing entity is any id outside the sparse + // array or one whose sparse slot is TOMBSTONE. Callers that require the component should + // use getData and check the result. + T* tryGetData(Entity entity) { + if (!has(entity)) { + return nullptr; + } + return &pool[sparse[entity]]; } T* getData(Entity entity) { - assert(entityToIndexMap.find(entity) != entityToIndexMap.end() && "Retrieving non-existent component."); + T* data = tryGetData(entity); + assert(data != nullptr && "getData: entity has no component of this type."); + return data; + } - // Return a reference to the entity's component - return &(componentArray[entityToIndexMap[entity]]); + size_t count() const { return liveCount; } + + // Iterate the live components (no per-entity hash lookup), invoking fn(Entity, T&) for each. + // Walks the pool in slot order, skipping the holes left by removals; with low churn there are + // few holes to skip. Do not add/remove components of type T from within the callback (the set + // of visited slots is captured by the loop bound). + template + void forEach(Fn&& fn) { + for (size_t slot = 0; slot < pool.size(); ++slot) { + Entity e = slotToEntity[slot]; + if (e != TOMBSTONE) { + fn(e, pool[slot]); + } + } } void entityDestroyed(Entity entity) override { - if (entityToIndexMap.find(entity) != entityToIndexMap.end()) { + if (has(entity)) { // Remove the entity's component if it existed removeData(entity); } } private: - // The packed array of components (of generic type T), - // set to a specified maximum amount, matching the maximum number - // of entities allowed to exist simultaneously, so that each entity - // has a unique spot. - std::vector componentArray; + // Sentinel: in `sparse` marks an entity with no component of type T; in `slotToEntity` marks + // a free (tombstoned) pool slot. Entity ids never reach this value in practice. + static constexpr uint32_t TOMBSTONE = std::numeric_limits::max(); - // Map from an entity ID to an array index. - std::unordered_map entityToIndexMap; + // True if `entity` currently owns a component. + bool has(Entity entity) const { + return entity < sparse.size() && sparse[entity] != TOMBSTONE; + } - // Map from an array index to an entity ID. - std::unordered_map indexToEntityMap; + // Stable component pool: a slot's address never changes for the component's lifetime. Never + // erased from directly -- removals tombstone the slot and push it to freeSlots for reuse, so + // the pool grows only to the high-water mark of simultaneously-live components. + std::deque pool; - // Total size of valid entries in the array. - size_t size; -}; + // Owner entity per pool slot, or TOMBSTONE for a free slot. Parallel to `pool`; also the + // liveness marker walked by forEach. + std::vector slotToEntity; -class ComponentManager { - public: - template - void registerComponent() { - auto const& type = typeid(T); + // sparse[entity] is the pool slot for that entity, or TOMBSTONE if absent. Indexed directly + // by entity id (grown on demand), so lookups never hash. + std::vector sparse; - assert(componentTypes.find(type) == componentTypes.end() && "Registering component type more than once."); + // Slots freed by removals, available for reuse by the next insert. + std::vector freeSlots; - // Add this component type to the component type map - componentTypes.insert({type, nextComponentType}); + // Number of live components (pool.size() minus the current holes). + size_t liveCount = 0; +}; - // Create a ComponentArray pointer and add it to the component arrays map - componentArrays.insert({type, std::make_shared>()}); +// Assigns a stable id to each component type the first time it is queried, entt-style. Ids are +// process-wide (independent of any ComponentManager instance) and, crucially, independent of +// whether the component's storage exists yet -- so getComponentType() is valid in const +// contexts such as System::getSignatures even before the first component of type T is ever +// added. Ids are handed out in first-touch order across the whole process; nothing persists them +// (scenes serialize component *data*, not type ids), so the order only needs to be stable within +// a single run. +// +// NOTE (threading): id()'s local static is initialised once under the C++ magic-static guard, +// but the shared `counter` increment inside next() is not atomic. Registration is single-threaded +// today; when the job scheduler lands, first-touch of a new component type must be serialised +// (pre-warm all types, or guard next()). +class ComponentTypeRegistry { + public: + template + static ComponentType id() { + static const ComponentType value = next(); + return value; + } - // Increment the value so that the next component registered will be different - ++nextComponentType; + private: + static ComponentType next() { + static ComponentType counter = 0; + // A type id must be a valid Signature bit index; index == MaxComponentTypes would throw + // from Signature::set() at runtime. + assert(counter < StoragePolicy::MaxComponentTypes && "Too many distinct component types registered."); + return counter++; } +}; +class ComponentManager { + public: + // Ensures the storage for component type T exists, creating it on first use, and returns it. + // This is the single lazy-registration entry point: adding, getting or iterating a component + // type routes through here, so there is no separate registration step. Idempotent. template - ComponentType getComponentType() const { + ComponentArray& assure() { auto const& type = typeid(T); + auto it = componentArrays.find(type); + if (it == componentArrays.end()) { + it = componentArrays.emplace(type, std::make_shared>()).first; + } + return static_cast&>(*it->second); + } - assert(componentTypes.find(type) != componentTypes.end() && "Component not registered before use."); + // Deprecated: component types now register lazily on first use. Kept as an idempotent + // forwarder so existing explicit-registration call sites keep compiling. + template + [[deprecated("Component types register on first use; registerComponent() is no longer required.")]] + void registerComponent() { + assure(); + } - // Return this component's type - used for creating signatures - return componentTypes.at(type); + // Stable signature-bit index for component type T. Const and side-effect-free on the manager + // (the id comes from the process-wide ComponentTypeRegistry), so it works before T has any + // storage -- required by the const System::getSignatures path. + template + ComponentType getComponentType() const { + return ComponentTypeRegistry::id(); } template void addComponent(Entity entity, T component) { - // Add a component to the array for an entity - getComponentArray()->insertData(entity, component); + // Add a component to the array for an entity; move to avoid an extra copy of + // potentially heavy components. Registers the type on first use. + assure().insertData(entity, std::move(component)); } template void removeComponent(Entity entity) { - // Remove a component from the array for an entity - getComponentArray()->removeData(entity); + assure().removeData(entity); } template T* getComponent(Entity entity) { // Get a reference to a component from the array for an entity - return getComponentArray()->getData(entity); + return assure().getData(entity); + } + + // Returns nullptr if the type is not registered or the entity has no such component, + // instead of asserting/throwing. For callers that legitimately probe for a component. + template + T* tryGetComponent(Entity entity) { + auto* arr = tryGetComponentArrayPtr(); + return arr == nullptr ? nullptr : arr->tryGetData(entity); + } + + // View iteration: invokes fn(Entity, Primary&, Others&...) for every entity that has all + // of the listed component types. Walks Primary's storage pool and probes the others by + // lookup, so list the rarest component first. Behaves like a minimal entt-style view. Do not + // add/remove any of these component types inside fn. + template + void each(Fn&& fn) { + auto* primaryArr = tryGetComponentArrayPtr(); + if (primaryArr == nullptr) { + return; + } + if constexpr (sizeof...(Others) > 0) { + if (((tryGetComponentArrayPtr() == nullptr) || ...)) { + return; // an "Others" type isn't registered, so nothing can match + } + } + primaryArr->forEach([&](Entity e, Primary& primary) { + if constexpr (sizeof...(Others) == 0) { + fn(e, primary); + } else { + std::tuple others{tryGetComponent(e)...}; + const bool all_present = ((std::get(others) != nullptr) && ...); + if (all_present) { + fn(e, primary, *std::get(others)...); + } + } + }); } void entityDestroyed(Entity entity) { @@ -148,23 +292,18 @@ class ComponentManager { } private: - // Map from type string pointer to a component type - std::unordered_map componentTypes{}; - - // Map from type string pointer to a component array + // Lazily-created component storage, keyed by type. An entry appears the first time a + // component of that type is touched (see assure); type ids for signatures come separately + // from ComponentTypeRegistry, so this map no longer tracks them. std::unordered_map> componentArrays{}; - // The component type to be assigned to the next registered component - starting at 0 - ComponentType nextComponentType{}; - - // Convenience function to get the statically casted pointer to the ComponentArray of type T. + // Non-throwing storage lookup: nullptr if no component of type T has ever been created. + // Used by the probe/iteration paths (tryGetComponent/each) so they never lazily register a + // type just by looking for it. template - std::shared_ptr> getComponentArray() { - auto const& type = typeid(T); - - assert(componentTypes.find(type) != componentTypes.end() && "Component not registered before use."); - - return std::static_pointer_cast>(componentArrays[type]); + ComponentArray* tryGetComponentArrayPtr() { + auto it = componentArrays.find(typeid(T)); + return it == componentArrays.end() ? nullptr : static_cast*>(it->second.get()); } }; } // namespace ICE diff --git a/ICE/Components/include/LightComponent.h b/ICE/Components/include/LightComponent.h index 724b1fed..a1bbddf4 100644 --- a/ICE/Components/include/LightComponent.h +++ b/ICE/Components/include/LightComponent.h @@ -10,7 +10,16 @@ #include "Component.h" namespace ICE { -enum LightType { PointLight = 0, DirectionalLight = 1, SpotLight = 2 }; +// Short aliases (Point/Directional/Spot) sit alongside the original *Light names; both refer to +// the same values, so existing code keeps compiling. +enum LightType { + PointLight = 0, + DirectionalLight = 1, + SpotLight = 2, + Point = PointLight, + Directional = DirectionalLight, + Spot = SpotLight, +}; struct LightComponent : public Component { LightComponent(LightType t, const Eigen::Vector3f &col) : type(t), color(col) {} diff --git a/ICE/Components/include/NativeScript.h b/ICE/Components/include/NativeScript.h new file mode 100644 index 00000000..14def53e --- /dev/null +++ b/ICE/Components/include/NativeScript.h @@ -0,0 +1,99 @@ +#pragma once + +#include + +#include + +namespace ICE { +// Forward-declared (used only as pointers here); a script that actually dereferences them includes +// the header itself: for instantiate(), to read input(). +class Scene; +class InputManager; + +// Base class for native (C++) per-entity game logic. Subclass it, override the lifecycle hooks, and +// attach it to an entity through EntityHandle::script() (or NativeScriptComponent::bind()). +// The ScriptSystem instantiates one instance per entity, injects its behaviour context, and drives +// its lifecycle. +// +// Inside the hooks the entity reads as intent -- no registry plumbing: +// self() // an EntityHandle to this entity +// transform() // this entity's TransformComponent (nullptr if none) +// getComponent() // any component on this entity (nullptr if absent) +// scene(), input() // the scene this lives in / the engine input service +// time() // seconds elapsed since scripting started +// instantiate(...) // spawn another entity into the scene +// destroy() // remove this entity (deferred to end of frame -- safe from onUpdate) +class NativeScript { + public: + virtual ~NativeScript() = default; + + // Called once, right after the instance is attached to its entity (context already injected). + virtual void onCreate() {} + // Called every frame with the frame delta in seconds. Scripts run first each frame + // (SystemUpdateOrder::ScriptSystemOrder), so transform changes made here are picked up by + // animation, the scene graph and rendering the same frame. + virtual void onUpdate(double dt) {} + // Called once, right before the instance is detached (component or entity removed). + virtual void onDestroy() {} + + protected: + // --- Behaviour context ------------------------------------------------------------------ + // A handle to the entity this script drives (its id bound to its registry). Everything below + // is sugar over it; use it directly for the full EntityHandle surface (add/remove/has/valid). + EntityHandle self() const { return EntityHandle{m_entity, m_registry}; } + + // This entity's transform, or nullptr if it has none: transform()->setPosition(...). + TransformComponent* transform() const { return self().transform(); } + + // Any component on this entity, or nullptr if absent: getComponent(). + template + T* getComponent() const { + return self().get(); + } + + // The scene this script lives in (nullptr only if it runs outside an active scene). + Scene* scene() const { return m_scene; } + + // The engine input service, or nullptr if none is wired: input()->isKeyDown(Key::KEY_W). + InputManager* input() const { return m_input; } + + // Seconds elapsed since scripting started running (accumulated frame deltas). The same value + // for every script within a frame. + double time() const { return m_time; } + + // Spawn another entity into this script's scene and return a handle to its root. Forwards to + // Scene::spawn (a model id today; Prefab support lands in T7). A script only needs Scene + // complete (include ) when it actually instantiates. + // + // The SceneT default parameter is deliberate: Scene is only forward-declared here, so + // `scene()->spawn(...)` must not be checked until instantiation. Casting through the template + // parameter SceneT makes the member access dependent, which defers the completeness check to + // the call site under two-phase lookup (GCC/Clang). A plain `template` left + // `scene()->spawn` non-dependent, so it was checked here -- a hard error on a forward-declared + // Scene (MSVC accepted it; other compilers did not). + template + EntityHandle instantiate(Args&&... args) { + return static_cast(scene())->spawn(std::forward(args)...); + } + + // Mark this entity for destruction. Teardown (onDestroy + component/entity removal) is deferred + // to the end of the current ScriptSystem update, so this is safe to call from within onUpdate: + // the running script and its components stay valid until the frame's script pass finishes. + void destroy() { m_destroyed = true; } + + // Retained lower-level accessors (self()/transform()/getComponent() supersede these; kept + // for existing scripts and the rare case that wants the raw id / registry). + Entity entity() const { return m_entity; } + Registry* registry() const { return m_registry; } + + private: + // The ScriptSystem is the sole injector of this context and the reader of m_destroyed. + friend class ScriptSystem; + Entity m_entity = NULL_ENTITY; + Registry* m_registry = nullptr; + Scene* m_scene = nullptr; + InputManager* m_input = nullptr; + double m_time = 0.0; + bool m_destroyed = false; +}; +} // namespace ICE diff --git a/ICE/Components/include/NativeScriptComponent.h b/ICE/Components/include/NativeScriptComponent.h new file mode 100644 index 00000000..0a66b8c6 --- /dev/null +++ b/ICE/Components/include/NativeScriptComponent.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace ICE { + +// Forward-declared, not included: this component only holds a std::function returning a +// shared_ptr (fine with an incomplete type) and a bind() template whose body is +// instantiated at the call site, where T -- and therefore its NativeScript base -- is complete. +// Keeping NativeScript.h out of here breaks the EntityHandle.h -> NativeScriptComponent.h -> +// NativeScript.h -> EntityHandle.h include cycle that NativeScript's behaviour context introduces. +class NativeScript; + +// Attaches native game logic to an entity. bind() only records how to construct the script; +// the ScriptSystem owns the live instance (keyed by entity) and drives its lifecycle. The +// component therefore holds nothing but a factory, so it stays trivially copyable -- entity +// duplication just copies the factory and the duplicate gets its own fresh instance. +struct NativeScriptComponent { + std::function()> instantiate; + + template + void bind(Args... args) { + instantiate = [args...]() { return std::static_pointer_cast(std::make_shared(args...)); }; + } +}; +} // namespace ICE diff --git a/ICE/Components/include/RenderComponent.h b/ICE/Components/include/RenderComponent.h index e5e44d36..479dc437 100644 --- a/ICE/Components/include/RenderComponent.h +++ b/ICE/Components/include/RenderComponent.h @@ -11,9 +11,10 @@ namespace ICE { struct RenderComponent : public Component { + RenderComponent() = default; RenderComponent(AssetUID mesh_id, AssetUID material_id) : mesh(mesh_id), material(material_id) {} - AssetUID mesh; - AssetUID material; + AssetUID mesh = NO_ASSET_ID; + AssetUID material = NO_ASSET_ID; }; } // namespace ICE diff --git a/ICE/Components/include/SkeletonPoseComponent.h b/ICE/Components/include/SkeletonPoseComponent.h index 2185f326..468f1ee1 100644 --- a/ICE/Components/include/SkeletonPoseComponent.h +++ b/ICE/Components/include/SkeletonPoseComponent.h @@ -1,6 +1,12 @@ #pragma once -#include +#include +#include + +#include +#include +#include +#include namespace ICE { struct SkeletonPoseComponent : public Component { diff --git a/ICE/Components/include/TransformComponent.h b/ICE/Components/include/TransformComponent.h index 1b87c697..04bb5149 100644 --- a/ICE/Components/include/TransformComponent.h +++ b/ICE/Components/include/TransformComponent.h @@ -14,14 +14,16 @@ struct TransformComponent : public Component { m_rotation(rot), m_scale(sca) {} - TransformComponent(const Eigen::Vector3f& pos = Eigen::Vector3f::Zero(), const Eigen::Vector3f& rot = Eigen::Vector3f::Zero(), - const Eigen::Vector3f& sca = Eigen::Vector3f::Ones()) + // Euler-rotation overload: pos and rot are required (no defaults) so that the + // zero- and single-Vector3f-argument cases resolve unambiguously to the + // quaternion-rotation constructor above. + TransformComponent(const Eigen::Vector3f& pos, const Eigen::Vector3f& rot, const Eigen::Vector3f& sca = Eigen::Vector3f::Ones()) : m_position(pos), m_scale(sca) { setRotationEulerDeg(rot); } - TransformComponent(const Eigen::Matrix4f& matrix) { + TransformComponent(const Eigen::Matrix4f& matrix) { m_position = matrix.block<3, 1>(0, 3); Eigen::Vector3f vX = matrix.block<3, 1>(0, 0); @@ -52,7 +54,7 @@ struct TransformComponent : public Component { Eigen::Vector3f getRotationEulerDeg() const { return m_rotation.toRotationMatrix().eulerAngles(0, 1, 2) * 180.0 / M_PI; } Eigen::Matrix4f getModelMatrix() const { - if (m_dirty) { + if (m_model_dirty) { m_model_matrix = Eigen::Matrix4f::Identity(); // Translation @@ -64,14 +66,18 @@ struct TransformComponent : public Component { // Scale m_model_matrix.block<3, 3>(0, 0) *= m_scale.asDiagonal(); - m_dirty = false; + m_model_dirty = false; } return m_model_matrix; } Eigen::Matrix4f getWorldMatrix() const { - if (m_dirty) { + // Separate dirty flag from the model matrix: getModelMatrix() used to clear the + // single shared flag, so calling it first left getWorldMatrix() returning a stale + // value. markDirty() sets both, and getModelMatrix() only clears the model flag. + if (m_world_dirty) { m_world_matrix = m_parent_matrix * getModelMatrix(); + m_world_dirty = false; } return m_world_matrix; } @@ -79,46 +85,56 @@ struct TransformComponent : public Component { void updateParentMatrix(const Eigen::Matrix4f& parent_matrix) { m_parent_matrix = parent_matrix; m_world_matrix = parent_matrix * getModelMatrix(); + m_world_dirty = false; + m_version++; } Eigen::Vector3f& position() { - m_dirty = true; + markDirty(); return m_position; } Eigen::Quaternionf& rotation() { - m_dirty = true; + markDirty(); return m_rotation; } Eigen::Vector3f& scale() { - m_dirty = true; + markDirty(); return m_scale; } void setPosition(const Eigen::Vector3f& position) { - m_dirty = true; + markDirty(); m_position = position; } void setRotation(const Eigen::Quaternionf& rotation) { - m_dirty = true; + markDirty(); m_rotation = rotation.normalized(); } void setRotationEulerDeg(const Eigen::Vector3f& eulerDeg) { - m_dirty = true; + markDirty(); Eigen::Vector3f rad = eulerDeg * M_PI / 180.0; m_rotation = Eigen::AngleAxisf(rad.x(), Eigen::Vector3f::UnitX()) * Eigen::AngleAxisf(rad.y(), Eigen::Vector3f::UnitY()) * Eigen::AngleAxisf(rad.z(), Eigen::Vector3f::UnitZ()); } void setScale(const Eigen::Vector3f& scale) { - m_dirty = true; + markDirty(); m_scale = scale; } + uint32_t getVersion() { return m_version; } + private: + void markDirty() { + m_model_dirty = true; + m_world_dirty = true; + m_version++; + } + Eigen::Vector3f m_position; Eigen::Quaternionf m_rotation; Eigen::Vector3f m_scale; @@ -126,7 +142,9 @@ struct TransformComponent : public Component { mutable Eigen::Matrix4f m_model_matrix = Eigen::Matrix4f::Identity(); mutable Eigen::Matrix4f m_parent_matrix = Eigen::Matrix4f::Identity(); mutable Eigen::Matrix4f m_world_matrix = Eigen::Matrix4f::Identity(); - mutable bool m_dirty = true; + mutable bool m_model_dirty = true; + mutable bool m_world_dirty = true; + mutable uint32_t m_version = 0; }; } // namespace ICE diff --git a/ICE/Core/CMakeLists.txt b/ICE/Core/CMakeLists.txt index 939e0f13..c091618d 100644 --- a/ICE/Core/CMakeLists.txt +++ b/ICE/Core/CMakeLists.txt @@ -17,10 +17,13 @@ target_link_libraries(${PROJECT_NAME} graphics_api io math + physics platform scene + scripting storage system + UI util ) diff --git a/ICE/Core/include/ICE.h b/ICE/Core/include/ICE.h new file mode 100644 index 00000000..5b5b98c8 --- /dev/null +++ b/ICE/Core/include/ICE.h @@ -0,0 +1,46 @@ +#pragma once + +// ICE engine umbrella header: the single discoverable front door. A hello-world application can +// +// #include +// +// and reach the engine, scenes, the behaviour context, components, input, the UI, and the render- +// feature/handle API without hunting through module headers. It is purely additive -- every module +// header below still exists and can be included directly for a leaner compile. + +// --- Engine ------------------------------------------------------------------------------------ +#include +#include +#include +#include +#include + +// --- Scene & entities -------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include + +// --- Components -------------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include + +// --- Behaviour scripting (T3) ------------------------------------------------------------------ +#include +#include + +// --- Input (T2) -------------------------------------------------------------------------------- +#include + +// --- Render features & typed resource handles (T4/T5) ------------------------------------------ +#include +#include + +// --- UI (T9) ----------------------------------------------------------------------------------- +#include diff --git a/ICE/Core/include/ICEEngine.h b/ICE/Core/include/ICEEngine.h index c7482fad..10138d9d 100644 --- a/ICE/Core/include/ICEEngine.h +++ b/ICE/Core/include/ICEEngine.h @@ -8,24 +8,108 @@ #include #include #include +#include +#include +#include +#include #include #include #include #include +#include +#include +#include #include namespace ICE { +class UIManager; // engine-owned UI (see ui()); forward-declared to keep this header light + class ICEEngine { public: + // One-call launch configuration for the config constructor below. Aggregate, so it takes + // designated initializers: ICEEngine engine({ .title = "IceField", .width = 1280 }). + struct Config { + std::string title = "ICE"; + int width = 1280; + int height = 720; + WindowBackend windowBackend = WindowBackend::GLFW; + // Renderer backend is OpenGL today; add a selector here when there is more than one. + }; + ICEEngine(); + // Turn-key construction: create the window and graphics backend from cfg and initialize the + // engine in one step, so applications don't hand-build a WindowFactory/GraphicsFactory. The + // default constructor + initialize() path remains for hosts (e.g. the editor) that supply + // their own window. + explicit ICEEngine(const Config& cfg); + void initialize(const std::shared_ptr& graphics_factor, const std::shared_ptr& window); + // Create a project rooted at base/name, prepare it on disk, and adopt it as the current + // project. Scenes created on the returned project (Project::createScene) are automatically + // activated by this engine. Returns a reference to the engine-owned project. + Project& newProject(const std::string& name, const fs::path& base = "."); + + // Make a scene the engine's active (visible) scene: install its runtime systems (render, + // animation, scene graph, scripting) once and drive it each frame, with its render system + // pointed at the scene's own camera. Idempotent per scene -- the sole coupling between engine + // and scene. Called automatically for scenes created via Project::createScene. + void setActiveScene(const std::shared_ptr& scene); + + // The scene the engine is currently driving (nullptr before any activation). + std::shared_ptr getActiveScene() const { return m_active_scene; } + + // Opt into multithreaded systems: creates a shared job scheduler and hands it to the parallel- + // capable systems (render culling + animation) of the active and future scenes. Off by default + // -- the single-threaded path is the validated fallback. Passing false tears the scheduler + // back down and returns those systems to serial. + void setParallelSystems(bool enable); + + // Enable background (off-main-thread) staging for async asset imports (AssetBank::requestAsset) + // without turning on parallel ECS systems: lazily creates the shared job scheduler and hands it + // to the asset bank. Idempotent. Without this, requestAsset still works but stages inline. + void enableBackgroundAssetLoading(); + + // --- Extension seams (P11) -------------------------------------------------------------------- + // Attach a physics backend; it is initialized now and stepped each frame (before the ECS + // systems). Null (the default) means no physics. A concrete backend plugs in here without any + // change to core. + void setPhysicsBackend(const std::shared_ptr& backend); + + // Attach a scripting backend (embedded VM). Initialized with the active scene's registry and + // updated each frame alongside the native ScriptSystem. + void setScriptingBackend(const std::shared_ptr& backend); + + // Load an out-of-tree plugin: builds a PluginContext for the active scene and lets the plugin + // register its systems / loaders / components. The engine keeps the plugin alive. + void loadPlugin(const std::shared_ptr& plugin); + void step(); + // Run the blocking main loop until the window closes: poll input, step() the engine, size the + // viewport to the framebuffer, and present. This owns the boilerplate the application used to + // write by hand; per-frame game logic goes through onUpdate (called inside step()), so most + // apps need only build their scene and call run(). Returns when the window requests close. + void run(); + + // Register an application frame callback. Every registered callback is invoked once per + // step() with the frame delta (seconds), before the ECS systems run -- so gameplay logic + // here is picked up by animation, the scene graph and rendering the same frame. This is + // the simplest place to put per-frame game code; for per-entity logic use a NativeScript. + void onUpdate(const std::function& callback) { m_update_callbacks.push_back(callback); } + + // Duration of the last step() in seconds. + double getDeltaTime() const { return m_delta_time; } + + // Legacy/editor activation: set up the project's current scene with an externally-owned + // (editor) camera. Retained for the editor; the clean path is newProject + createScene, which + // route through setActiveScene with the scene's own camera. void setupScene(const std::shared_ptr& camera_); + // The editor's viewport camera (distinct from a scene's own view camera, which Scene owns). + // Persisted via Project::writeToFile. Kept until the editor migrates to the scene camera. std::shared_ptr getCamera(); std::shared_ptr getAssetBank(); @@ -52,7 +136,39 @@ class ICEEngine { std::shared_ptr getWindow() const; + // The engine-owned input service, constructed in initialize() and pumped once per frame in + // step(). Read key/mouse state and mouse delta from here (also injected into scripts by T3). + InputManager* input() const { return m_input.get(); } + + // The renderer drawing the active scene; null before a scene is activated. This is the + // registration point for render passes/features: + // engine.renderer()->addPass(std::make_unique(mesh, shader)); + std::shared_ptr renderer() const; + + // The engine's UI, created on first call (needs a project for the "ui" shader asset and the + // bundled font). It composites over the scene as a render-graph present-time pass and is fed + // pointer input each frame from the input service. Add + // elements to it and register event handlers: + // auto* ui = engine.ui(); + // auto* btn = static_cast(ui->add(std::make_unique("btn", {0.4f,0.4f}, {0.2f,0.1f}, {..}))); + // btn->onEvent([](const Event& e){ if (e.type == EventType::Click) { ... } }); + // Call it after a scene is active so the present pass can attach to its renderer. Null if there + // is no project yet. + UIManager* ui(); + private: + // Shared system-building used by both setupScene (legacy/editor path) and setActiveScene: + // builds the render/animation/scene-graph/script systems on the scene's registry with the + // given view camera, and caches the scene + its render system as the active pair. Idempotent. + void installRuntimeSystems(const std::shared_ptr& scene, const std::shared_ptr& camera); + + // Window framebuffer-resize handler: resizes the active render system's viewport and camera. + void onFramebufferResize(int width, int height); + + // Attach the UI present-time pass to the active renderer, once per renderer. No-op until both + // the UI (ui()) and a render system exist. + void registerUIPass(); + std::shared_ptr m_graphics_factory; std::shared_ptr ctx; std::shared_ptr api; @@ -60,10 +176,36 @@ class ICEEngine { std::shared_ptr m_target_fb = nullptr; std::shared_ptr m_window; - std::shared_ptr camera; + std::shared_ptr camera; // editor viewport camera (see getCamera) std::shared_ptr project = nullptr; + // Engine-owned input service (see input()); constructed in initialize(), pumped in step(). + std::unique_ptr m_input; + + // Engine-owned UI (see ui()); lazily created, drawn as a render-graph present-time pass and fed + // pointer input each frame. m_ui_pass_registered tracks whether its pass is on the current + // renderer (reset when a new renderer is built in installRuntimeSystems). + std::shared_ptr m_ui; + bool m_ui_pass_registered = false; + + // The scene the engine currently drives and its render system, cached on activation so the + // per-frame and resize paths don't rediscover them through the project/registry each time. + std::shared_ptr m_active_scene; + std::shared_ptr m_active_render_system; + + // Shared job scheduler for the parallel-capable systems; null unless setParallelSystems(true). + std::shared_ptr m_scheduler; + + // Extension seams (P11): optional physics/scripting backends stepped in the frame loop, and the + // loaded plugins kept alive for the engine's lifetime. + std::shared_ptr m_physics; + std::shared_ptr m_scripting; + std::vector> m_plugins; + std::chrono::steady_clock::time_point lastFrameTime; + double m_delta_time = 0.0; + + std::vector> m_update_callbacks; EngineConfig config; }; diff --git a/ICE/Core/src/ICEEngine.cpp b/ICE/Core/src/ICEEngine.cpp index 1d54da41..7262bda9 100644 --- a/ICE/Core/src/ICEEngine.cpp +++ b/ICE/Core/src/ICEEngine.cpp @@ -1,5 +1,3 @@ -#define STB_IMAGE_IMPLEMENTATION - #include "ICEEngine.h" #include @@ -7,55 +5,308 @@ #include #include #include +#include #include +#include #include #include +#include +#include #include +#include +#include +#include +#include namespace ICE { ICEEngine::ICEEngine() : camera(std::make_shared(60, 16.f / 9.f, 0.1f, 100000)), config(EngineConfig::LoadFromFile()) { } +ICEEngine::ICEEngine(const Config &cfg) : ICEEngine() { + // Turn-key path: build the window + graphics backend from cfg, then initialize. + WindowFactory window_factory; + auto window = window_factory.createWindow(cfg.windowBackend, cfg.width, cfg.height, cfg.title); + auto graphics_factory = std::make_shared(); + initialize(graphics_factory, window); +} + void ICEEngine::initialize(const std::shared_ptr &graphics_factory, const std::shared_ptr &window) { Logger::Log(Logger::INFO, "Core", "Engine starting up..."); m_graphics_factory = graphics_factory; m_window = window; m_window->setSwapInterval(1); - m_window->setResizeCallback([this](int w, int h) { - if (project) { - project->getCurrentScene()->getRegistry()->getSystem()->setViewport(0, 0, w, h); - project->getCurrentScene()->getRegistry()->getSystem()->getCamera()->resize(w, h); - } - }); + m_window->setResizeCallback([this](int w, int h) { onFramebufferResize(w, h); }); + // One engine-owned input service, wired to this window's handlers. Pumped once per frame in + // step() (after the systems have read the frame's input). + m_input = std::make_unique(m_window); ctx = graphics_factory->createContext(m_window); ctx->initialize(); api = graphics_factory->createRendererAPI(); api->initialize(); internalFB = graphics_factory->createFramebuffer({720, 720, 1}); + // Seed the frame clock so the first step() doesn't report a huge delta (time since + // the steady_clock epoch). + lastFrameTime = std::chrono::steady_clock::now(); } void ICEEngine::step() { + // Snapshot the previous frame's profiler samples and start a new frame. + Profiler::get().beginFrame(); + ICE_PROFILE_SCOPE("Engine::step"); + + // Delta time in seconds as a double: the old integer-millisecond cast truncated to 0 + // above ~1000 fps (freezing animation) and lost ~13% at 144 Hz. auto now = std::chrono::steady_clock::now(); - auto dt = std::chrono::duration_cast(now - lastFrameTime).count(); + m_delta_time = std::chrono::duration(now - lastFrameTime).count(); lastFrameTime = now; - auto render_system = project->getCurrentScene()->getRegistry()->getSystem(); - render_system->setTarget(m_target_fb); - project->getCurrentScene()->getRegistry()->updateSystems(dt); + + // Finalize any async imports that completed staging (publish payloads, commit model sub-assets) + // on the main thread. Done before the early-out so imports drain even with no active scene (e.g. + // a loading screen). Cheap no-op when nothing is in flight. + if (project) { + project->getAssetBank()->pump(); + } + + if (m_active_scene) { + // Application frame callbacks (engine.onUpdate) run before the ECS systems so gameplay + // state they set is consumed by animation/scene-graph/render the same frame. + for (auto& cb : m_update_callbacks) { + cb(m_delta_time); + } + // Extension seams (P11): advance physics and drive the scripting VM before the ECS systems, + // so their results are visible to animation/scene-graph/render this frame. No-ops when unset. + if (m_physics) { + m_physics->step(m_delta_time); + } + if (m_scripting) { + m_scripting->update(m_delta_time); + } + // The engine drives the active scene it was handed; it does not reach back through the + // project/registry to rediscover the render system each frame (it was cached on activation). + if (m_active_render_system) { + m_active_render_system->setTarget(m_target_fb); + } + { + ICE_PROFILE_SCOPE("updateSystems"); + m_active_scene->getRegistry()->updateSystems(m_delta_time); + } + + // Periodically surface the previous frame's timing breakdown (every ~5s at 60fps). + static int s_profile_log_counter = 0; + if (++s_profile_log_counter >= 300) { + s_profile_log_counter = 0; + for (const auto& [name, ms] : Profiler::get().lastFrame()) { + Logger::Log(Logger::DEBUG, "Profiler", "%s: %.3f ms", name.c_str(), ms); + } + } + } + + // Hit-test the UI against this frame's pointer state, before the input roll below consumes the + // click edge. Bridges the input service to the (input-agnostic) UIManager. + if (m_ui && m_input && m_window) { + auto [w, h] = m_window->getSize(); + const bool clicked = m_input->getMouseAction(MouseButton::LEFT_MOUSE_BUTTON) == KeyAction::PRESS; + m_ui->processInput(m_input->getMouseX(), m_input->getMouseY(), clicked, w, h); + } + + // Roll input at the END of the frame -- after any onUpdate callbacks and ECS systems (scripts) + // have read this frame's PRESS/RELEASE edges and mouse position. This is why PRESS lasts exactly + // one frame and getMouseDelta is the movement across the frame. (Callbacks fire during the run + // loop's pollEvents, which precedes step(), so rolling here -- not before the systems -- is what + // keeps the edges alive for the frame that reads them.) + if (m_input) { + m_input->update(static_cast(m_delta_time)); + } } -void ICEEngine::setupScene(const std::shared_ptr &camera_) { - auto renderer = std::make_shared(api, m_graphics_factory); - auto rs = std::make_shared(api, m_graphics_factory, project->getCurrentScene()->getRegistry(), project->getGPURegistry()); - auto as = std::make_shared(project->getCurrentScene()->getRegistry(), project->getAssetBank()); - auto sgs = std::make_shared(project->getCurrentScene()); +void ICEEngine::run() { + // The application's frame loop, previously hand-written at every call site. Order matches the + // old inline loop: poll, step (runs onUpdate callbacks + ECS systems), resize the viewport to + // the current framebuffer, then present. + while (m_window && !m_window->shouldClose()) { + m_window->pollEvents(); + step(); + int display_w, display_h; + m_window->getFramebufferSize(&display_w, &display_h); + api->setViewport(0, 0, display_w, display_h); + m_window->swapBuffers(); + } +} + +void ICEEngine::installRuntimeSystems(const std::shared_ptr &scene, const std::shared_ptr &camera_) { + auto registry = scene->getRegistry(); + + // Idempotent by construction: a scene that already has its render system is considered set up. + // Re-activation just re-points the view camera -- it never builds a second set of systems + // (which is also why the sample no longer needs, and could not accidentally cause, a duplicate + // system add). + if (auto existing = registry->tryGetSystem()) { + existing->setCamera(camera_); + existing->setScheduler(m_scheduler); + if (auto as = registry->tryGetSystem()) { + as->setScheduler(m_scheduler); + } + m_active_scene = scene; + m_active_render_system = existing; + return; + } + + auto renderer = std::make_shared(api, m_graphics_factory, project->getGPURegistry()); + auto rs = std::make_shared(registry, project->getGPURegistry()); + auto as = std::make_shared(registry, project->getAssetBank()); + auto sgs = std::make_shared(scene); + // Scripts get their behaviour context from here: the scene they live in and the engine input + // service (m_input, constructed in initialize()). Both are non-owning. + auto ss = std::make_shared(registry, scene.get(), m_input.get()); rs->setCamera(camera_); rs->setRenderer(renderer); - project->getCurrentScene()->getRegistry()->addSystem(rs); - project->getCurrentScene()->getRegistry()->addSystem(as); - project->getCurrentScene()->getRegistry()->addSystem(sgs); - camera = camera_; + rs->setScheduler(m_scheduler); // null unless parallel systems are enabled + as->setScheduler(m_scheduler); + registry->addSystem(rs); + registry->addSystem(as); + registry->addSystem(sgs); + registry->addSystem(ss); auto [w, h] = m_window->getSize(); renderer->resize(w, h); + + // The engine tracks the visible scene and its render system so per-frame/resize code never + // rediscovers them through project->getCurrentScene()->getRegistry()->getSystem<...>(). + m_active_scene = scene; + m_active_render_system = rs; + + // A fresh renderer was just built, so the UI pass (if any) is not on it yet. + m_ui_pass_registered = false; + registerUIPass(); +} + +void ICEEngine::setupScene(const std::shared_ptr &camera_) { + // Legacy/editor entry point: set up the current scene with the supplied (editor) camera. + installRuntimeSystems(project->getCurrentScene(), camera_); + camera = camera_; +} + +void ICEEngine::setActiveScene(const std::shared_ptr &scene) { + // The one coupling that makes a scene visible: install its runtime systems (idempotent) and + // let the engine drive it. The scene owns its camera, so the render system borrows that. + installRuntimeSystems(scene, scene->cameraPtr()); +} + +void ICEEngine::setPhysicsBackend(const std::shared_ptr& backend) { + m_physics = backend; + if (m_physics) { + m_physics->initialize(); + } +} + +void ICEEngine::setScriptingBackend(const std::shared_ptr& backend) { + m_scripting = backend; + if (m_scripting && m_active_scene) { + m_scripting->initialize(*m_active_scene->getRegistry()); + } +} + +void ICEEngine::loadPlugin(const std::shared_ptr& plugin) { + if (!plugin) { + return; + } + PluginContext ctx; + ctx.registry = m_active_scene ? m_active_scene->getRegistry().get() : nullptr; + ctx.asset_bank = project ? project->getAssetBank().get() : nullptr; + plugin->registerWith(ctx); + m_plugins.push_back(plugin); +} + +void ICEEngine::enableBackgroundAssetLoading() { + if (!m_scheduler) { + m_scheduler = std::make_shared(); + } + if (project) { + project->getAssetBank()->setScheduler(m_scheduler); + } +} + +void ICEEngine::setParallelSystems(bool enable) { + if (enable) { + if (!m_scheduler) { + m_scheduler = std::make_shared(); + } + } else { + m_scheduler = nullptr; + } + // Share the pool with the asset bank so async imports stage off-thread too (setScheduler drains + // in-flight loads before dropping the old scheduler). Null returns the bank to inline staging. + if (project) { + project->getAssetBank()->setScheduler(m_scheduler); + } + // Apply immediately to the active scene's parallel-capable systems; newly activated scenes pick + // it up in installRuntimeSystems. + if (m_active_scene) { + auto registry = m_active_scene->getRegistry(); + if (auto rs = registry->tryGetSystem()) { + rs->setScheduler(m_scheduler); + } + if (auto as = registry->tryGetSystem()) { + as->setScheduler(m_scheduler); + } + } +} + +void ICEEngine::onFramebufferResize(int width, int height) { + // Resize goes straight to the cached active render system -- no reaching back through the + // project/scene/registry chain. + if (!m_active_render_system) { + return; + } + m_active_render_system->setViewport(0, 0, width, height); + if (auto cam = m_active_render_system->getCamera()) { + cam->resize(width, height); + } +} + +Project &ICEEngine::newProject(const std::string &name, const fs::path &base) { + auto proj = std::make_shared(base, name); + proj->CreateDirectories(); + project = proj; + // Scenes created on this project become the active scene through us (systems installed once). + proj->setSceneActivator([this](const std::shared_ptr &scene) { setActiveScene(scene); }); + return *proj; +} + +std::shared_ptr ICEEngine::renderer() const { + // Reaches through the cached active render system rather than the + // project/scene/registry/getSystem chain that application code used to write by hand. + return m_active_render_system ? m_active_render_system->getRenderer() : nullptr; +} + +UIManager *ICEEngine::ui() { + if (!m_ui) { + if (!project || !m_graphics_factory) { + return nullptr; // no project yet: no "ui" shader asset and no font to load + } + auto shader = project->getGPURegistry()->getShader(AssetPath::WithTypePrefix("ui")); + const auto font_path = (project->getBaseDirectory() / "Assets" / "Fonts" / "helvetica.ttf").string(); + if (!shader) { + // Loud, not silent: without the shader the UI draws nothing. Usually means the updated + // Assets/ (ui.shader.json + glsl/ui.*) weren't deployed next to the executable. + Logger::Log(Logger::ERROR, "UI", "'ui' shader asset failed to load -- UI will not render (check Assets/Shaders/ui.*)"); + } + m_ui = std::make_shared(m_graphics_factory, shader, font_path); + } + registerUIPass(); + if (!m_ui_pass_registered) { + Logger::Log(Logger::WARNING, "UI", "ui() called before a scene/renderer is active -- the UI pass is not attached yet"); + } + return m_ui.get(); +} + +void ICEEngine::registerUIPass() { + if (!m_ui || m_ui_pass_registered || !m_active_render_system) { + return; + } + if (auto renderer = m_active_render_system->getRenderer()) { + renderer->addPass(std::make_unique(m_ui.get())); + m_ui_pass_registered = true; + } } std::shared_ptr ICEEngine::getCamera() { diff --git a/ICE/Entity/include/Entity.h b/ICE/Entity/include/Entity.h index 23246a8e..d2bed0c7 100644 --- a/ICE/Entity/include/Entity.h +++ b/ICE/Entity/include/Entity.h @@ -6,6 +6,8 @@ #define ICE_ENTITY_H #include +#include +#include #include #include #include @@ -13,7 +15,18 @@ namespace ICE { using Entity = std::uint32_t; -using Signature = std::bitset<32>; + +// Single source of truth for ECS storage limits. The component-type ceiling and the Signature +// bit width are both derived from MaxComponentTypes so they can never drift apart -- widen both +// by changing this one constant. Referenced by ComponentTypeRegistry's bound check in +// Component.h (replaces the constant that used to be hard-coded in registerComponent). +struct StoragePolicy { + static constexpr std::size_t MaxComponentTypes = 64; +}; +using Signature = std::bitset; + +// Reserved "no entity" / scene-graph-root sentinel. +constexpr Entity NULL_ENTITY = 0; class EntityManager { public: @@ -38,11 +51,21 @@ class EntityManager { } void releaseEntity(Entity e) { - signatures[e].reset(); + // Guard against releasing a non-alive/already-released entity: that used to enqueue + // the same id twice (later handed out as two live entities) and underflow the count. + // Presence of a signature entry is the aliveness proxy. + if (!signatures.contains(e)) { + return; + } + signatures.erase(e); // erase, not reset: the map was growing monotonically releasedEntities.push(e); - entityCount--; + if (entityCount > 0) { + entityCount--; + } } + bool isAlive(Entity e) const { return e != NULL_ENTITY && signatures.contains(e); } + void setSignature(Entity e, Signature s) { signatures[e] = s; } Signature getSignature(Entity e) const { diff --git a/ICE/Graphics/CMakeLists.txt b/ICE/Graphics/CMakeLists.txt index 726804a5..175a81f5 100644 --- a/ICE/Graphics/CMakeLists.txt +++ b/ICE/Graphics/CMakeLists.txt @@ -9,8 +9,7 @@ target_sources(${PROJECT_NAME} PRIVATE src/PerspectiveCamera.cpp src/OrthographicCamera.cpp src/ForwardRenderer.cpp - src/Mesh.cpp - src/GeometryPass.cpp "include/GPUMesh.h" "include/GPUTexture.h") + src/GeometryPass.cpp) target_link_libraries(${PROJECT_NAME} PUBLIC @@ -27,4 +26,4 @@ target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_SOURCE_DIR}/src) enable_testing() -#add_subdirectory(test) +add_subdirectory(test) diff --git a/ICE/Graphics/include/Buffers.h b/ICE/Graphics/include/Buffers.h index 50d4baa0..3e51aacb 100644 --- a/ICE/Graphics/include/Buffers.h +++ b/ICE/Graphics/include/Buffers.h @@ -10,6 +10,7 @@ namespace ICE { class VertexBuffer { public: + virtual ~VertexBuffer() = default; virtual void bind() const = 0; virtual void unbind() const = 0; virtual void putData(const void* data, uint32_t size) = 0; @@ -18,6 +19,7 @@ class VertexBuffer { class IndexBuffer { public: + virtual ~IndexBuffer() = default; virtual void bind() const = 0; virtual void unbind() const = 0; virtual void putData(const void* data, uint32_t size) = 0; diff --git a/ICE/Graphics/include/Camera.h b/ICE/Graphics/include/Camera.h index 6823e0cc..c9c0685e 100644 --- a/ICE/Graphics/include/Camera.h +++ b/ICE/Graphics/include/Camera.h @@ -11,6 +11,7 @@ enum ProjectionType { Perspective, Orthographic }; class Camera { public: + virtual ~Camera() = default; virtual Eigen::Matrix4f lookThrough() = 0; virtual void forward(float delta) = 0; @@ -35,4 +36,33 @@ class Camera { private: float m_zoom; }; + +// Fluent, non-owning view over a Camera: lets setup read as a chain -- +// scene.camera().setPosition({0, 5, -5}).pitch(-30); +// Each mutator forwards to the underlying camera and returns *this. Use operator-> / get() to +// reach the rest of the Camera interface (getters, resize, ...). Cheap to copy (a bare pointer); +// owns nothing. +class CameraHandle { + public: + explicit CameraHandle(Camera* camera) : m_camera(camera) {} + + CameraHandle& setPosition(const Eigen::Vector3f& p) { m_camera->setPosition(p); return *this; } + CameraHandle& setRotation(const Eigen::Vector3f& r) { m_camera->setRotation(r); return *this; } + CameraHandle& pitch(float d) { m_camera->pitch(d); return *this; } + CameraHandle& yaw(float d) { m_camera->yaw(d); return *this; } + CameraHandle& roll(float d) { m_camera->roll(d); return *this; } + CameraHandle& forward(float d) { m_camera->forward(d); return *this; } + CameraHandle& backward(float d) { m_camera->backward(d); return *this; } + CameraHandle& left(float d) { m_camera->left(d); return *this; } + CameraHandle& right(float d) { m_camera->right(d); return *this; } + CameraHandle& up(float d) { m_camera->up(d); return *this; } + CameraHandle& down(float d) { m_camera->down(d); return *this; } + + Camera* operator->() const { return m_camera; } + Camera& operator*() const { return *m_camera; } + Camera* get() const { return m_camera; } + + private: + Camera* m_camera; +}; } // namespace ICE \ No newline at end of file diff --git a/ICE/Graphics/include/Context.h b/ICE/Graphics/include/Context.h index 7ca6da6d..341bf846 100644 --- a/ICE/Graphics/include/Context.h +++ b/ICE/Graphics/include/Context.h @@ -7,6 +7,7 @@ namespace ICE { class Context { public: + virtual ~Context() = default; virtual void initialize() = 0; virtual void swapBuffers() = 0; virtual void wireframeMode() = 0; diff --git a/ICE/Graphics/include/ForwardRenderer.h b/ICE/Graphics/include/ForwardRenderer.h index 5fb71c58..f72b181d 100644 --- a/ICE/Graphics/include/ForwardRenderer.h +++ b/ICE/Graphics/include/ForwardRenderer.h @@ -6,44 +6,95 @@ #include #include +#include #include -#include +#include +#include +#include #include #include "Camera.h" #include "Framebuffer.h" #include "GeometryPass.h" +#include "InstanceData.h" #include "RenderCommand.h" +#include "RenderGraph.h" #include "Renderer.h" #include "RendererConfig.h" namespace ICE { -class ForwardRenderer : public Renderer { +// IPassDrawer is implemented here (not on Renderer) because it is the pass-facing half of the +// renderer: what a graph pass may ask it to draw, handed out through PassContext. +class ForwardRenderer : public Renderer, public IPassDrawer { public: - ForwardRenderer(const std::shared_ptr& api, const std::shared_ptr& factory); + ForwardRenderer(const std::shared_ptr &api, const std::shared_ptr &factory, + const std::shared_ptr &gpu_registry); - void submitSkybox(const Skybox& e) override; - void submitDrawable(const Drawable& e) override; - void submitLight(const Light& e) override; + void submitSkybox(const Skybox &e) override; + void submitDrawable(Drawable e) override; + void submitLight(const Light &e) override; - void prepareFrame(Camera& camera) override; + void prepareFrame(Camera &camera) override; std::shared_ptr render() override; void endFrame() override; + void setPresentTarget(const std::shared_ptr &target) override { m_present_target = target; } + void setPresentShader(const std::shared_ptr &present_shader) override { m_present_shader = present_shader; } + void resize(uint32_t width, uint32_t height) override; void setClearColor(Eigen::Vector4f clearColor) override; void setViewport(int x, int y, int w, int h) override; + void addPass(std::unique_ptr pass) override; + void addFeature(std::unique_ptr feature) override; + + // --- IPassDrawer (what a graph pass can ask the renderer to draw) --------------------------- + void drawScene(Camera& camera, ShaderProgram* override_shader) override; + void fullscreen(ShaderProgram* shader) override; + private: + // Tear down and rebuild the frame's graph: import scene_color, add the geometry pass, let every + // registered feature contribute, then compile. Only called when m_graph_dirty (see render()). + void rebuildGraph(); + + // Upload `camera` into the shared camera UBO (what the geometry shaders read). + void uploadCameraUBO(Camera& camera); std::shared_ptr m_api; + std::shared_ptr m_gpu_registry; std::vector m_render_commands; GeometryPass m_geometry_pass; + RenderGraph m_graph; + + // The graph is compiled once and re-executed each frame; this marks it for rebuild when + // something that changes its shape happens -- a resize (resource descriptors change) or a + // newly registered pass/feature. Rebuilding is what regenerates passes' resource handles. + bool m_graph_dirty = true; + + // The frame's present target + shader, set per frame by the RenderSystem and read by the + // graph's present pass. Target null = the window's default framebuffer; a framebuffer = the + // editor's render-to-texture viewport. + std::shared_ptr m_present_target; + std::shared_ptr m_present_shader; + + // The camera the current frame was prepared with (see prepareFrame). Borrowed for the frame + // only: drawScene() restores the camera UBO to it so a pass drawing from another point of view + // (a shadow pass) can't leak that view into later passes. + Camera* m_frame_camera = nullptr; + + // Application-registered features, in registration order. Their passes are added to the graph + // each time it is rebuilt (see rebuildGraph()). + std::vector> m_features; + + // Owned by the renderer for the present pass: the full-screen quad the final blit draws, and + // the most recent render() result it composites from. + std::shared_ptr m_present_quad; + std::shared_ptr m_output_fb; std::shared_ptr m_camera_ubo; std::shared_ptr m_light_ubo; @@ -52,6 +103,11 @@ class ForwardRenderer : public Renderer { std::vector m_drawables; std::vector m_lights; + // Instance batching storage, keyed by the exact (mesh, material, shader) triple so + // distinct batches can never collide into one (the old XOR-hashed uint64 key could). + using BatchKey = std::tuple; + std::map> m_instance_batches; + RendererConfig config; }; } // namespace ICE \ No newline at end of file diff --git a/ICE/Graphics/include/Framebuffer.h b/ICE/Graphics/include/Framebuffer.h index a837feef..1d6b26f7 100644 --- a/ICE/Graphics/include/Framebuffer.h +++ b/ICE/Graphics/include/Framebuffer.h @@ -5,14 +5,16 @@ #pragma once #include +#include namespace ICE { struct FrameBufferFormat { - int width, height, samples; + uint32_t width, height, samples; }; class Framebuffer { public: + virtual ~Framebuffer() = default; virtual void bind() = 0; virtual void unbind() = 0; virtual void resize(int width, int height) = 0; diff --git a/ICE/Graphics/include/GeometryPass.h b/ICE/Graphics/include/GeometryPass.h index 19597911..33d32c58 100644 --- a/ICE/Graphics/include/GeometryPass.h +++ b/ICE/Graphics/include/GeometryPass.h @@ -1,6 +1,10 @@ #pragma once #include +#include + +#include +#include #include "Framebuffer.h" #include "GraphicsFactory.h" @@ -10,15 +14,35 @@ namespace ICE { class GeometryPass : public RenderPass { public: - GeometryPass(const std::shared_ptr& api, const std::shared_ptr& factory, const FrameBufferFormat& format); + GeometryPass(const std::shared_ptr& api, const std::shared_ptr& factory, + const std::shared_ptr& gpu_registry, const FrameBufferFormat& format); void submit(std::vector* commands) { m_render_queue = commands; } + + // Bind this pass's own framebuffer, clear it, and draw the submitted commands into it. void execute() override; + + // Draw the submitted commands into whatever target is currently bound, without binding or + // clearing one. This is what backs PassContext::drawScene, letting a graph pass replay the + // visible set into its own target. + // + // `override_shader` replaces every command's shader and skips material uniform application: + // an override (a depth-only shadow shader, say) declares its own inputs and would only pay for + // texture binds it never reads. Per-command state, mesh binding and bone palettes still apply. + void drawInto(ShaderProgram* override_shader = nullptr); std::shared_ptr getResult() const; void resize(int w, int h); private: std::shared_ptr m_api; + std::shared_ptr m_factory; + std::shared_ptr m_gpu_registry; std::shared_ptr m_framebuffer; + // Reused every draw for per-instance data instead of allocating a fresh GL buffer + // per command per frame. + std::shared_ptr m_instance_buffer; + // Reused scratch buffer for packing a skinned mesh's bone palette into a contiguous, + // id-indexed array for a single glUniformMatrix4fv upload. + std::vector m_bone_palette; std::vector* m_render_queue; }; } // namespace ICE \ No newline at end of file diff --git a/ICE/Graphics/include/GpuHandle.h b/ICE/Graphics/include/GpuHandle.h new file mode 100644 index 00000000..cadb4a91 --- /dev/null +++ b/ICE/Graphics/include/GpuHandle.h @@ -0,0 +1,19 @@ +#pragma once + +#include "HandlePool.h" + +namespace ICE { + +// Tag-typed handles to GPU resources owned by the GPURegistry. These are the lightweight values the +// per-frame render path carries instead of shared_ptr: resolve them to a raw pointer once, at +// bind time, via GPURegistry::resolve(). The tags below are never instantiated -- they only make +// the three handle kinds distinct types. +class GPUMesh; +class GPUTexture; +class ShaderProgram; + +using MeshHandle = Handle; +using TextureHandle = Handle; +using ShaderHandle = Handle; + +} // namespace ICE diff --git a/ICE/Graphics/include/GraphicsAPI.h b/ICE/Graphics/include/GraphicsAPI.h index 5ad02066..e0fddaf1 100644 --- a/ICE/Graphics/include/GraphicsAPI.h +++ b/ICE/Graphics/include/GraphicsAPI.h @@ -9,6 +9,8 @@ #include #include +#include "RenderState.h" + namespace ICE { enum GraphicsAPI { None = 0x0, @@ -17,16 +19,26 @@ enum GraphicsAPI { class RendererAPI { public: + virtual ~RendererAPI() = default; virtual void initialize() const = 0; virtual void bindDefaultFramebuffer() const = 0; virtual void setViewport(int x, int y, int width, int height) const = 0; virtual void setClearColor(float r, float g, float b, float a) const = 0; virtual void clear() const = 0; virtual void renderVertexArray(const std::shared_ptr &va) const = 0; + virtual void renderVertexArrayInstanced(const std::shared_ptr &va, uint32_t instance_count) const = 0; virtual void flush() const = 0; virtual void finish() const = 0; virtual void setDepthTest(bool enable) const = 0; virtual void setDepthMask(bool enable) const = 0; + virtual void setDepthFunc(DepthFunc func) const = 0; + virtual void setBlend(bool enable) const = 0; + + // GPU timing via double-buffered GL_TIME_ELAPSED queries. begin/end bracket the GPU work; + // endGPUTimer returns the elapsed GPU time in milliseconds of a *previous* frame (one + // frame of latency) so reading the result never stalls the pipeline. + virtual void beginGPUTimer() const = 0; + virtual double endGPUTimer() const = 0; virtual void setBackfaceCulling(bool enable) const = 0; virtual void checkAndLogErrors() const = 0; diff --git a/ICE/Graphics/include/GraphicsFactory.h b/ICE/Graphics/include/GraphicsFactory.h index d21c36b8..b42b43f2 100644 --- a/ICE/Graphics/include/GraphicsFactory.h +++ b/ICE/Graphics/include/GraphicsFactory.h @@ -17,6 +17,7 @@ namespace ICE { class GraphicsFactory { public: + virtual ~GraphicsFactory() = default; virtual std::shared_ptr createContext(const std::shared_ptr& window) const = 0; virtual std::shared_ptr createFramebuffer(const FrameBufferFormat& format) const = 0; @@ -35,6 +36,14 @@ class GraphicsFactory { virtual std::shared_ptr createTexture2D(const Texture2D &texture) const = 0; + // Create an empty GPU texture: storage only, no CPU data. This is what the render graph uses to + // allocate a transient Texture2D resource from its descriptor (the overload above can only + // upload an already-loaded asset). Not pure: a backend that hasn't implemented it returns null + // and the graph leaves the resource virtual rather than failing to build. + virtual std::shared_ptr createTexture2D(uint32_t width, uint32_t height, TextureFormat format) const { + return nullptr; + } + virtual std::shared_ptr createTextureCube(const TextureCube& texture) const = 0; }; } // namespace ICE \ No newline at end of file diff --git a/ICE/Graphics/include/GraphicsUtil.h b/ICE/Graphics/include/GraphicsUtil.h new file mode 100644 index 00000000..754644f7 --- /dev/null +++ b/ICE/Graphics/include/GraphicsUtil.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include "GraphicsFactory.h" +#include "VertexArray.h" + +namespace ICE { +namespace GraphicsUtil { + +// A unit quad spanning [0,1] x [0,1], indexed as two triangles, with a single 2-component vertex +// attribute at location 0. The positions double as UVs (0..1), so a caller scales the quad to its +// pixel size with a model matrix and samples a texture/glyph with fUV = position. Used by the UI +// elements (UIRect, UILabel, Font). +inline std::shared_ptr getNormalizedQuad(const std::shared_ptr& factory) { + static const std::vector positions = { + 0.0f, 0.0f, // bottom-left + 1.0f, 0.0f, // bottom-right + 0.0f, 1.0f, // top-left + 1.0f, 1.0f, // top-right + }; + static const std::vector indices = {0, 1, 2, 2, 1, 3}; + + auto vao = factory->createVertexArray(); + auto vbo = factory->createVertexBuffer(); + vbo->putData(positions.data(), positions.size() * sizeof(float)); + vao->pushVertexBuffer(vbo, 2); // 2-component position, location 0 + auto ibo = factory->createIndexBuffer(); + ibo->putData(indices.data(), indices.size() * sizeof(int)); + vao->setIndexBuffer(ibo); + return vao; +} + +} // namespace GraphicsUtil +} // namespace ICE diff --git a/ICE/Graphics/include/HandlePool.h b/ICE/Graphics/include/HandlePool.h new file mode 100644 index 00000000..29b5829c --- /dev/null +++ b/ICE/Graphics/include/HandlePool.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ICE { + +// A lightweight, typed handle: a slot index plus a generation. `generation == 0` is the null +// handle. The generation is what makes the handle safe: when a pool slot is freed and later reused, +// the slot's generation changes, so an old handle to that slot no longer resolves -- turning a +// use-after-free into a clean nullptr instead of silently aliasing a different resource. `Tag` +// makes handles to different resource kinds distinct types (a MeshHandle can't be passed where a +// TextureHandle is expected). +template +struct Handle { + uint32_t index = 0; + uint32_t generation = 0; // 0 == null + + constexpr bool valid() const { return generation != 0; } + constexpr bool operator==(const Handle& o) const { return index == o.index && generation == o.generation; } + constexpr bool operator!=(const Handle& o) const { return !(*this == o); } +}; + +// Owns a set of `T` in stable storage (addresses never move) and hands out generational handles to +// them. Insertion reuses freed slots via a free list; each (re)use bumps the slot's generation so +// handles to a previously-freed slot are detected as stale. This is the single-owner container the +// GPU resource registry and (later) the render graph build on: everyone else holds a handle, not a +// pointer or a shared_ptr. +template +class HandlePool { + public: + using HandleType = Handle; + + HandleType insert(T value) { + uint32_t idx; + if (!m_free.empty()) { + idx = m_free.back(); + m_free.pop_back(); + } else { + idx = static_cast(m_slots.size()); + m_slots.emplace_back(); + } + Slot& s = m_slots[idx]; + s.value = std::move(value); + s.generation = m_next_generation; + s.alive = true; + // Advance the generation source, never landing on 0 (reserved for the null handle). + if (++m_next_generation == 0) { + m_next_generation = 1; + } + ++m_live_count; + return HandleType{idx, s.generation}; + } + + // Returns nullptr for a null handle, an out-of-range index, a freed slot, or a slot whose + // generation no longer matches (i.e. the handle is stale -- use-after-free caught here). + T* get(HandleType h) { + Slot* s = slotFor(h); + return s ? &s->value : nullptr; + } + const T* get(HandleType h) const { + const Slot* s = slotFor(h); + return s ? &s->value : nullptr; + } + + bool contains(HandleType h) const { return slotFor(h) != nullptr; } + + // Frees the slot (if the handle is live) so it can be reused with a fresh generation. Returns + // false if the handle was already stale/null. + bool erase(HandleType h) { + Slot* s = slotFor(h); + if (!s) { + return false; + } + s->value = T{}; // release any owned resources now + s->alive = false; + m_free.push_back(h.index); + --m_live_count; + return true; + } + + size_t size() const { return m_live_count; } + + private: + struct Slot { + T value{}; + uint32_t generation = 0; + bool alive = false; + }; + + const Slot* slotFor(HandleType h) const { + if (h.generation == 0 || h.index >= m_slots.size()) { + return nullptr; + } + const Slot& s = m_slots[h.index]; + if (!s.alive || s.generation != h.generation) { + return nullptr; + } + return &s; + } + Slot* slotFor(HandleType h) { + return const_cast(static_cast(this)->slotFor(h)); + } + + // std::deque keeps element addresses stable as the pool grows. + std::deque m_slots; + std::vector m_free; + uint32_t m_next_generation = 1; // never 0 + size_t m_live_count = 0; +}; + +} // namespace ICE diff --git a/ICE/Graphics/include/InstanceData.h b/ICE/Graphics/include/InstanceData.h new file mode 100644 index 00000000..467200f8 --- /dev/null +++ b/ICE/Graphics/include/InstanceData.h @@ -0,0 +1,17 @@ +// +// Created for ICE rendering improvements +// + +#pragma once + +#include + +namespace ICE { + +// Per-instance data for instanced rendering. +struct InstanceData { + Eigen::Matrix4f model_matrix; + // Future: Add per-instance color, material index, etc. +}; + +} // namespace ICE diff --git a/ICE/Graphics/include/RenderCommand.h b/ICE/Graphics/include/RenderCommand.h index bdcaec14..0da53d57 100644 --- a/ICE/Graphics/include/RenderCommand.h +++ b/ICE/Graphics/include/RenderCommand.h @@ -10,19 +10,76 @@ #include "GPUMesh.h" #include "Material.h" #include "Model.h" +#include "RenderState.h" #include "ShaderProgram.h" namespace ICE { + +struct InstanceData; // Forward declaration + struct RenderCommand { - std::shared_ptr mesh; - std::shared_ptr material; - std::shared_ptr shader; - std::unordered_map> textures; + // mesh/shader are resolved from generational handles to raw pointers once, in + // ForwardRenderer::prepareFrame; material is a CPU asset (raw pointer into the asset bank). + GPUMesh* mesh = nullptr; + Material* material = nullptr; + ShaderProgram* shader = nullptr; + + // Textures are no longer carried per-command: the geometry pass resolves each of the + // material's texture uniforms to a GPUTexture* via the registry at bind time. + + // Model matrix - direct value, no pointer Eigen::Matrix4f model_matrix; - std::unordered_map bones; + // Bone matrices - pointer to avoid copying large map + const std::unordered_map* bones = nullptr; - bool faceCulling = true; - bool depthTest = true; + // Render state - packed into bitfield to save space. Default-initialized so commands + // (e.g. the skybox) that don't set them don't read indeterminate values. + bool faceCulling : 1 = true; + bool depthTest : 1 = true; + bool depthWrite : 1 = true; + bool is_instanced : 1 = false; + // Alpha blending: only transparent materials need it; opaque draws leave it off so they + // don't lose early-Z/HSR to a blend that does nothing. + bool blend : 1 = false; + + DepthFunc depth_func = DepthFunc::Less; + + // Instancing support + const std::vector* instance_data = nullptr; + uint32_t instance_count = 1; + + // Sorting key - pre-computed for fast sorting + uint64_t sort_key = 0; + + // Helper to compute sort key. Keyed off the shader/material *asset UIDs* (stable and identical + // across runs) rather than their heap addresses, which were non-deterministic run-to-run and + // collision-prone once shifted. UIDs are small monotonic ids, so the low 21 bits identify a + // shader/material without collision for any realistic project. + void computeSortKey(bool is_transparent, float depth_sq, AssetUID shader_uid, AssetUID material_uid) { + // Pack: [transparent:1][shader:21][material:21][depth:21] + uint64_t transparent_bit = is_transparent ? 1ULL : 0ULL; + uint64_t shader_bits = static_cast(shader_uid) & 0x1FFFFF; + uint64_t material_bits = static_cast(material_uid) & 0x1FFFFF; + // Clamp instead of masking: depth_sq * 1000 overflowed 21 bits past ~46 units and + // wrapped, scrambling the order. Clamped, far objects just pin at the max bucket. + uint64_t depth_raw = static_cast(depth_sq * 1000.0f); + uint64_t depth_bits = depth_raw > 0x1FFFFF ? 0x1FFFFF : depth_raw; + + if (is_transparent) { + // Alpha blending needs back-to-front: invert depth so farther fragments (larger + // depth) get a smaller key and are drawn first. The old code sorted front-to-back. + uint64_t depth_far_first = 0x1FFFFF - depth_bits; + sort_key = (transparent_bit << 63) | (depth_far_first << 42) | (shader_bits << 21) | material_bits; + } else { + // Opaque: sort by shader/material to minimize state changes, then front-to-back. + sort_key = (transparent_bit << 63) | (shader_bits << 42) | (material_bits << 21) | depth_bits; + } + } }; + +inline bool operator<(const RenderCommand& a, const RenderCommand& b) { + return a.sort_key < b.sort_key; +} } // namespace ICE + diff --git a/ICE/Graphics/include/RenderData.h b/ICE/Graphics/include/RenderData.h index 093b3a3b..8c677b8e 100644 --- a/ICE/Graphics/include/RenderData.h +++ b/ICE/Graphics/include/RenderData.h @@ -2,23 +2,24 @@ #include -static std::vector full_quad_v = { - -1.0f, -1.0f, 0.0f, // TOP LEFT - 1.0, -1.0f, 0.0f, // TOP RIGHT - -1.0f, 1.0, 0.0f, // BOTTOM LEFT - 1.0, 1.0, 0.0f, // BOTTOM RIGHT +// Fullscreen-quad geometry in NDC. `inline` gives a single shared definition across +// translation units instead of one static copy per TU. +inline const std::vector full_quad_v = { + -1.0f, -1.0f, 0.0f, // bottom-left + 1.0f, -1.0f, 0.0f, // bottom-right + -1.0f, 1.0f, 0.0f, // top-left + 1.0f, 1.0f, 0.0f, // top-right }; -static std::vector full_quad_idx = { +inline const std::vector full_quad_idx = { 0, 1, 2, 2, 1, 3 }; - -static std::vector full_quad_tx = { - 0, 0, // TOP LEFT - 1, 0, // TOP RIGHT - 0, 1, // BOTTOM LEFT - 1, 1, // BOTTOM RIGHT - 1, 0, // TOP RIGHT - 0, 1 // BOTTOM LEFT +// One UV per vertex (the quad has 4 vertices); the previous array had 6 pairs, so the +// last two were dead. +inline const std::vector full_quad_tx = { + 0, 0, // bottom-left + 1, 0, // bottom-right + 0, 1, // top-left + 1, 1, // top-right }; diff --git a/ICE/Graphics/include/RenderFeature.h b/ICE/Graphics/include/RenderFeature.h new file mode 100644 index 00000000..e97237f4 --- /dev/null +++ b/ICE/Graphics/include/RenderFeature.h @@ -0,0 +1,287 @@ +// +// The public pass/feature seam of the render graph: application code contributes render passes and +// declares their resources with typed handles, without editing the renderer. +// +// class OutlinePass : public IRenderPass { +// public: +// const char* name() const override { return "outline"; } +// void setup(RenderGraphBuilder& builder) override { +// m_scene = builder.read(builder.sceneColor()); +// builder.write(builder.sceneColor()); // contributes to the output -> not culled +// } +// void execute(PassContext& ctx) override { auto fb = ctx.get(m_scene); /* ... */ } +// private: +// RenderResourceHandle m_scene; +// }; +// +// class OutlineFeature : public RenderFeature { +// public: +// OutlineFeature() { addPass(); } +// const char* name() const override { return "outline"; } +// }; +// +// renderer->addFeature(std::make_unique()); +// + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "Camera.h" +#include "GraphicsAPI.h" +#include "RenderGraph.h" + +namespace ICE { + +// The drawing the renderer performs on a pass's behalf, so a pass body is just draw calls instead +// of hand-rolled submission. Implemented by the renderer; passes reach it through PassContext. +class IPassDrawer { + public: + virtual ~IPassDrawer() = default; + + // Draw the frame's visible geometry from `camera` into the currently bound target. + virtual void drawScene(Camera& camera, ShaderProgram* override_shader) = 0; + + // Draw a full-screen quad with `shader` into the currently bound target. + virtual void fullscreen(ShaderProgram* shader) = 0; +}; + +// Setup-time API handed to a pass. Every resource a pass touches is named by a typed handle, so it +// can only refer to resources that were actually declared -- there is no string to mistype and no +// null physical resource to discover at draw time. +class RenderGraphBuilder { + public: + RenderGraphBuilder(RenderGraph& graph, RenderGraphPass& pass, RenderResourceHandle scene_color) + : m_graph(graph), + m_pass(pass), + m_scene_color(scene_color) {} + + // Declare a new resource produced by this pass and return a typed handle to it. T selects the + // resource type (see ResourceTypeOf) and fills in desc.type, so create() + // is a compile error. An unnamed resource gets a name unique to this pass; naming one lets + // other passes refer to the same resource. + template + RenderResourceHandle create(ResourceDescriptor desc) { + desc.type = ResourceTypeOf::value; // the type parameter is authoritative + if (desc.debug_name.empty()) { + desc.debug_name = m_pass.getName() + "#" + std::to_string(m_next_unnamed++); + } + const auto index = m_graph.declareResource(desc.debug_name, desc); + m_pass.create(desc.debug_name, desc); // keeps dependency ordering + culling working + return RenderResourceHandle{index}; + } + + // Declare that this pass reads `handle`: it is ordered after whichever pass produces it. + // Returns the handle so it can be stored in one line. + template + RenderResourceHandle read(RenderResourceHandle handle) { + m_pass.read(m_graph.resourceName(handle.index())); + return handle; + } + + // Declare that this pass writes `handle`: passes reading it are ordered after this one. + // + // Note this is also what keeps a pass alive: compile() culls any pass that does not contribute + // (transitively) to the graph's output, so a pass that only writes a resource nobody reads is + // silently dropped. A post-process feature stays live by writing sceneColor(). + template + RenderResourceHandle write(RenderResourceHandle handle) { + m_pass.write(m_graph.resourceName(handle.index())); + if constexpr (std::is_same_v) { + // The first framebuffer a pass writes is its render target: the graph binds it (and + // sizes the viewport to it) before execute(), and hands it back as ctx.target(). + if (!m_target.valid()) { + m_target = handle; + } + } + return handle; + } + + // The pass's render target as declared by its first write() of a framebuffer; invalid if it + // wrote none. Read by the renderer when wiring the pass -- passes use ctx.target() instead. + RenderResourceHandle targetHandle() const { return m_target; } + + // The colour target the engine's geometry pass renders into: the conventional input (and + // output) of a post-process feature. Imported by the renderer before any feature's setup runs. + RenderResourceHandle sceneColor() const { return m_scene_color; } + + // The graph being built, for the rare pass that needs more than this builder exposes. + RenderGraph& graph() { return m_graph; } + + private: + RenderGraph& m_graph; + RenderGraphPass& m_pass; + RenderResourceHandle m_scene_color; + RenderResourceHandle m_target; + uint32_t m_next_unnamed = 0; +}; + +// Execute-time API handed to a pass: its target is already bound, its declared handles resolve +// here, and the renderer will draw for it -- so a pass body is draw calls, not setup boilerplate. +class PassContext { + public: + PassContext(RenderGraph& graph, const std::shared_ptr& api, IPassDrawer* drawer, std::shared_ptr target) + : m_graph(graph), + m_api(api), + m_drawer(drawer), + m_target(std::move(target)) {} + + // The physical resource behind a handle. Null while the graph leaves a resource virtual -- + // today that is TextureCube/Buffer, which have no descriptor-based creation yet. + template + std::shared_ptr get(RenderResourceHandle handle) const { + auto* resource = m_graph.resourceAt(handle.index()); + return resource ? resource->template getPhysicalResourceAs() : nullptr; + } + + // This pass's render target (its first declared framebuffer write). Already bound, with the + // viewport sized to it, before execute() is called -- you do not need to bind it yourself. + // Null if the pass declared no framebuffer write. + // + // It is bound but deliberately NOT cleared: a pass that writes sceneColor() to draw an overlay + // on top of the scene must not wipe it. If your pass owns its target (a shadow map, a bloom + // buffer), clear it yourself first: ctx.api()->clear(). + const std::shared_ptr& target() const { return m_target; } + + // Draw the frame's visible geometry from `camera` into this pass's target. `override_shader` + // replaces every command's shader and skips material uniforms, which is what a shadow pass + // wants: + // ctx.drawScene(light_camera, depth_shader.get()); + // Passing a different camera does not disturb the frame's main camera for later passes. + void drawScene(Camera& camera, ShaderProgram* override_shader = nullptr) { + if (m_drawer) { + m_drawer->drawScene(camera, override_shader); + } + } + + // Draw a full-screen quad with `shader` into this pass's target -- the post-process primitive. + // Bind the inputs you sample on `shader` first: + // ctx.get(m_scene)->bindAttachment(0); + // shader->loadInt("uTexture", 0); + // ctx.fullscreen(shader.get()); + void fullscreen(ShaderProgram* shader) { + if (m_drawer) { + m_drawer->fullscreen(shader); + } + } + + RendererAPI* api() const { return m_api.get(); } + + private: + RenderGraph& m_graph; + std::shared_ptr m_api; + IPassDrawer* m_drawer = nullptr; // null when a graph is built without a renderer (tests) + std::shared_ptr m_target; +}; + +// One pass contributed to the render graph. +class IRenderPass { + public: + virtual ~IRenderPass() = default; + + // Name of the graph node (used for debugging and as the pass's identity in the graph). + virtual const char* name() const = 0; + + // Declare this pass's resources. Called every time the graph is (re)built: store the returned + // handles as members and re-declare them here each time, because handles are indices into the + // current resource table and do not survive a rebuild. + virtual void setup(RenderGraphBuilder& builder) = 0; + + // Run the pass, resolving the handles stored during setup() through `ctx`. + virtual void execute(PassContext& ctx) = 0; +}; + +// A bundle of passes plugged into a renderer from application code via Renderer::addFeature (see +// the worked example at the top of this header). The renderer calls setup() on each pass when it +// builds the frame's graph and execute() when the graph runs. The feature owns its passes. +class RenderFeature { + public: + virtual ~RenderFeature() = default; + virtual const char* name() const = 0; + + // The passes this feature contributes, in declaration order. + const std::vector>& passes() const { return m_passes; } + + protected: + // Construct and adopt a pass; typically called from the subclass constructor. + template + TPass& addPass(Args&&... args) { + auto pass = std::make_unique(std::forward(args)...); + auto& ref = *pass; + m_passes.push_back(std::move(pass)); + return ref; + } + + // Adopt an already-constructed pass. + IRenderPass& adoptPass(std::unique_ptr pass) { + auto& ref = *pass; + m_passes.push_back(std::move(pass)); + return ref; + } + + private: + std::vector> m_passes; +}; + +// A feature that is exactly one pass. Most passes stand alone and need no bundle around them, so +// Renderer::addPass() wraps them in this rather than making callers declare a feature class whose +// only job is to hold one pass. Grouping several passes under a name (shared state, one on/off +// switch) is what RenderFeature is actually for. +class SinglePassFeature : public RenderFeature { + public: + explicit SinglePassFeature(std::unique_ptr pass) : m_name(pass ? pass->name() : "") { + if (pass) { + adoptPass(std::move(pass)); + } + } + const char* name() const override { return m_name.c_str(); } + + private: + std::string m_name; +}; + +// Add one pass to `graph`: it declares its resources through a builder now, and runs against a +// PassContext when the graph executes. A renderer calls this for every registered pass each time it +// (re)builds the frame's graph -- which is what regenerates the pass's resource handles for the new +// compile. The pass must outlive the graph's execution (the renderer owns both). +inline void addPassToGraph(RenderGraph& graph, IRenderPass& pass, RenderResourceHandle scene_color, + const std::shared_ptr& api, IPassDrawer* drawer = nullptr) { + auto& graph_pass = graph.addPass(pass.name()); + RenderGraphBuilder builder(graph, graph_pass, scene_color); + pass.setup(builder); + const auto target = builder.targetHandle(); + IRenderPass* raw = &pass; + graph_pass.setExecuteCallback([&graph, raw, api, drawer, target](const RenderGraphPass&) { + std::shared_ptr fb; + if (target.valid()) { + if (auto* resource = graph.resourceAt(target.index())) { + fb = resource->getPhysicalResourceAs(); + } + } + // Bind the pass's target and size the viewport to it before handing over, so a pass that + // renders into an off-size target (a 2048x2048 shadow map, a quarter-res bloom buffer) + // doesn't have to remember to do either. + if (fb) { + fb->bind(); + if (api) { + api->setViewport(0, 0, static_cast(fb->getFormat().width), static_cast(fb->getFormat().height)); + } + } + PassContext ctx(graph, api, drawer, fb); + raw->execute(ctx); + }); +} + +// Add every pass a feature contributes, in declaration order. +inline void addFeaturePasses(RenderGraph& graph, const RenderFeature& feature, RenderResourceHandle scene_color, + const std::shared_ptr& api, IPassDrawer* drawer = nullptr) { + for (const auto& pass : feature.passes()) { + addPassToGraph(graph, *pass, scene_color, api, drawer); + } +} +} // namespace ICE diff --git a/ICE/Graphics/include/RenderGraph.h b/ICE/Graphics/include/RenderGraph.h new file mode 100644 index 00000000..24e88511 --- /dev/null +++ b/ICE/Graphics/include/RenderGraph.h @@ -0,0 +1,552 @@ +// +// Render Graph System for ICE Engine +// Enables flexible multi-pass rendering with automatic resource management +// + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Framebuffer.h" +#include "GraphicsFactory.h" + +namespace ICE { + +// Forward declarations +class RenderGraphResource; +class RenderGraphPass; +class RenderGraph; +class RenderGraphBuilder; +class PassContext; + +enum class ResourceType { + Texture2D, + TextureCube, + Buffer, + RenderTarget +}; + +struct ResourceDescriptor { + ResourceType type; + uint32_t width = 0; + uint32_t height = 0; + // Backend-agnostic pixel format (was a raw GL enum, which no code set and which no non-GL + // backend could honour). Used when the graph allocates a Texture2D. + TextureFormat format = TextureFormat::RGBA8; + bool is_transient = true; // may be pooled/reused across rebuilds; false = keep contents + std::string debug_name; +}; + +// Maps a physical resource type to the graph resource type it is allocated as. This is what makes +// create() type-directed: instantiating it for a type with no specialization here is a compile +// error, so an unsupported resource can never be declared. (TextureCube/Buffer specializations +// arrive with their allocation support in T5.) +template +struct ResourceTypeOf; + +template<> +struct ResourceTypeOf { + static constexpr ResourceType value = ResourceType::RenderTarget; +}; + +template<> +struct ResourceTypeOf { + static constexpr ResourceType value = ResourceType::Texture2D; +}; + +// A typed, opaque reference to an entry in the graph's resource table. +// +// Handles are the only way to name a resource in the typed pass API. You cannot mistype one (there +// is no string to get wrong), and using a resource as the wrong type is a compile error rather than +// the null physical pointer that getResource("typo") hands back at draw time. +// +// Lifetime: a handle is an index into the *current* resource table, so RenderGraph::reset() +// invalidates every outstanding handle. Passes re-declare their resources in setup() on each +// rebuild, which is exactly when fresh handles are handed out -- never cache a handle across +// frames, only within the setup()/execute() cycle of one compiled graph. +template +class RenderResourceHandle { + public: + RenderResourceHandle() = default; // an undeclared handle: valid() == false + + bool valid() const { return m_index != kInvalidIndex; } + explicit operator bool() const { return valid(); } + uint32_t index() const { return m_index; } + + bool operator==(const RenderResourceHandle& o) const { return m_index == o.m_index; } + bool operator!=(const RenderResourceHandle& o) const { return !(*this == o); } + + private: + friend class RenderGraph; + friend class RenderGraphBuilder; + friend class PassContext; + + static constexpr uint32_t kInvalidIndex = std::numeric_limits::max(); + explicit RenderResourceHandle(uint32_t index) : m_index(index) {} + + uint32_t m_index = kInvalidIndex; +}; + +// Represents a virtual resource in the render graph +class RenderGraphResource { +public: + RenderGraphResource(const std::string& name, const ResourceDescriptor& desc) + : m_name(name), m_descriptor(desc) {} + + const std::string& getName() const { return m_name; } + const ResourceDescriptor& getDescriptor() const { return m_descriptor; } + + void setPhysicalResource(std::shared_ptr resource) { + m_physical_resource = resource; + } + + std::shared_ptr getPhysicalResource() const { + return m_physical_resource; + } + + template + std::shared_ptr getPhysicalResourceAs() const { + return std::static_pointer_cast(m_physical_resource); + } + + // Imported resources are backed by memory the graph does not own (see + // RenderGraph::importResource); compile() never allocates over them. + void markImported() { m_imported = true; } + bool isImported() const { return m_imported; } + +private: + std::string m_name; + ResourceDescriptor m_descriptor; + std::shared_ptr m_physical_resource; + bool m_imported = false; +}; + +// Represents a single render pass in the graph. +// +// The string-named declaration API below is deprecated and kept only for the engine's internal +// geometry/present wiring; it is removed in T10. Application code (and new engine passes) should go +// through IRenderPass + RenderGraphBuilder (see RenderFeature.h), which name resources with typed +// handles instead: a name cannot be mistyped, and a resource cannot be read as the wrong type. +// These methods are not marked [[deprecated]] because the internal wiring still calls them. +class RenderGraphPass { +public: + using ExecuteCallback = std::function; + + RenderGraphPass(const std::string& name) : m_name(name) {} + + // Resource declaration API (deprecated, see the class comment) + void read(const std::string& resource_name) { + m_reads.push_back(resource_name); + } + + void write(const std::string& resource_name) { + m_writes.push_back(resource_name); + } + + void create(const std::string& resource_name, const ResourceDescriptor& desc) { + m_creates[resource_name] = desc; + } + + void setExecuteCallback(ExecuteCallback callback) { + m_execute_callback = callback; + } + + void execute() const { + if (m_execute_callback) { + m_execute_callback(*this); + } + } + + // Getters + const std::string& getName() const { return m_name; } + const std::vector& getReads() const { return m_reads; } + const std::vector& getWrites() const { return m_writes; } + const std::unordered_map& getCreates() const { + return m_creates; + } + + // Resource access during execution + template + std::shared_ptr getResource(const std::string& name) const { + auto it = m_resource_cache.find(name); + if (it != m_resource_cache.end()) { + return std::static_pointer_cast(it->second); + } + return nullptr; + } + + void cacheResource(const std::string& name, std::shared_ptr resource) { + m_resource_cache[name] = resource; + } + +private: + std::string m_name; + std::vector m_reads; + std::vector m_writes; + std::unordered_map m_creates; + ExecuteCallback m_execute_callback; + mutable std::unordered_map> m_resource_cache; +}; + +// Main render graph class +class RenderGraph { +public: + RenderGraph(const std::shared_ptr& factory) + : m_factory(factory) {} + + // Pass creation + RenderGraphPass& addPass(const std::string& name) { + auto pass = std::make_unique(name); + auto& ref = *pass; + m_passes.push_back(std::move(pass)); + return ref; + } + + // Compile the graph (resolve dependencies, allocate resources) + void compile() { + buildDependencyGraph(); + topologicalSort(); + allocateResources(); + cullUnusedPasses(); + } + + // Execute all passes in dependency order + void execute() { + for (auto* pass : m_sorted_passes) { + if (pass) { + pass->execute(); + } + } + } + + // Reset graph for rebuilding. Invalidates every outstanding RenderResourceHandle: the resource + // table is rebuilt from scratch, so passes must re-declare their resources in setup(). + // + // Transient resources this compile owned are handed to the pool first, so the next compile + // reuses them instead of allocating: a rebuild whose descriptors are unchanged allocates + // nothing, and the framebuffer count stays flat. Whatever the next compile doesn't claim is + // dropped at the end of allocateResources(). + void reset() { + for (const auto& resource : m_resource_table) { + const auto& desc = resource->getDescriptor(); + if (resource->isImported() || !desc.is_transient || !resource->getPhysicalResource()) { + continue; // not ours to recycle, or must keep its contents + } + m_pool.push_back({desc.type, desc.width, desc.height, desc.format, resource->getPhysicalResource()}); + } + m_passes.clear(); + m_sorted_passes.clear(); + m_resources.clear(); + m_resource_table.clear(); + m_name_to_index.clear(); + m_dependencies.clear(); + m_output_resource.clear(); + } + + // Name of the resource that must reach the screen (the backbuffer/output). Passes that don't + // contribute to producing it are culled by compile(). If unset, every pass is kept. + void setOutput(const std::string& resource_name) { m_output_resource = resource_name; } + + // Typed overload: declare the graph's output by handle rather than by name. + template + void setOutput(RenderResourceHandle handle) { + m_output_resource = resourceName(handle.index()); + } + + // The physical resource behind the declared output; null if no output was declared or it has no + // backing. This is what a renderer presents: the graph decides what reaches the screen, rather + // than the renderer reaching into whichever pass it assumes produced it. Valid after compile(). + template + std::shared_ptr output() const { + auto it = m_name_to_index.find(m_output_resource); + if (it == m_name_to_index.end()) { + return nullptr; + } + return m_resource_table[it->second]->getPhysicalResourceAs(); + } + + // --- Typed resource table --------------------------------------------------------------- + // Register a resource and return its table index (the value behind a RenderResourceHandle). + // Idempotent by name: re-declaring an existing name returns the existing index, which is what + // keeps the typed layer and the (internal) string layer pointing at the same resource. + uint32_t declareResource(const std::string& name, const ResourceDescriptor& desc) { + auto it = m_name_to_index.find(name); + if (it != m_name_to_index.end()) { + return it->second; + } + const auto index = static_cast(m_resource_table.size()); + auto resource = std::make_shared(name, desc); + m_resource_table.push_back(resource); + m_name_to_index.emplace(name, index); + m_resources[name] = std::move(resource); // keep the string-layer map in sync + return index; + } + + // Declare a resource whose physical backing is owned outside the graph (e.g. the geometry + // pass's framebuffer) and return a typed handle to it. This is how the engine hands existing + // targets to features; compile() will not try to allocate over an imported resource. + template + RenderResourceHandle importResource(const std::string& name, const std::shared_ptr& physical) { + ResourceDescriptor desc; + desc.type = ResourceTypeOf::value; + desc.is_transient = false; // externally owned: never aliased or pooled + desc.debug_name = name; + const auto index = declareResource(name, desc); + m_resource_table[index]->setPhysicalResource(physical); + m_resource_table[index]->markImported(); + return RenderResourceHandle{index}; + } + + // The table entry behind a handle index, or nullptr if the index is out of range. + RenderGraphResource* resourceAt(uint32_t index) const { + return index < m_resource_table.size() ? m_resource_table[index].get() : nullptr; + } + + // The name a handle's resource was declared under. Throws for an invalid/undeclared handle -- + // the runtime backstop for the one hole the type system leaves (a default-constructed handle). + const std::string& resourceName(uint32_t index) const { + if (index >= m_resource_table.size()) { + throw std::runtime_error("RenderGraph: use of an undeclared (default-constructed) resource handle"); + } + return m_resource_table[index]->getName(); + } + + // Get a resource by name. + // Deprecated (removed in T10): prefer the typed handle API -- a mistyped name returns null + // here and crashes at draw time, where an undeclared handle cannot be formed at all. + std::shared_ptr getResource(const std::string& name) { + auto it = m_resources.find(name); + if (it != m_resources.end()) { + return it->second; + } + return nullptr; + } + +private: + void buildDependencyGraph() { + m_dependencies.clear(); + + // Build adjacency list: pass -> passes it depends on + for (const auto& pass : m_passes) { + for (const auto& read : pass->getReads()) { + // Find which pass writes this resource + for (const auto& other_pass : m_passes) { + if (other_pass.get() == pass.get()) continue; + + const auto& writes = other_pass->getWrites(); + const auto& creates = other_pass->getCreates(); + + bool writes_resource = std::find(writes.begin(), writes.end(), read) != writes.end(); + bool creates_resource = creates.find(read) != creates.end(); + + if (writes_resource || creates_resource) { + // pass depends on other_pass + m_dependencies[pass.get()].push_back(other_pass.get()); + } + } + } + } + } + + void topologicalSort() { + m_sorted_passes.clear(); + std::unordered_set visited; + std::unordered_set temp_mark; + + for (const auto& pass : m_passes) { + if (visited.find(pass.get()) == visited.end()) { + topologicalSortVisit(pass.get(), visited, temp_mark); + } + } + // topologicalSortVisit pushes a pass only after its dependencies (the passes producing + // what it reads), so m_sorted_passes is already in execution order -- dependencies first. + // (The previous std::reverse here inverted that, running passes back-to-front; it was + // never observed because the graph had no live consumer.) + } + + void topologicalSortVisit(RenderGraphPass* pass, + std::unordered_set& visited, + std::unordered_set& temp_mark) { + if (temp_mark.find(pass) != temp_mark.end()) { + throw std::runtime_error("Render graph has cycles!"); + } + + if (visited.find(pass) != visited.end()) { + return; + } + + temp_mark.insert(pass); + + if (m_dependencies.find(pass) != m_dependencies.end()) { + for (auto* dep : m_dependencies[pass]) { + topologicalSortVisit(dep, visited, temp_mark); + } + } + + temp_mark.erase(pass); + visited.insert(pass); + m_sorted_passes.push_back(pass); + } + + void allocateResources() { + // Bridge the string layer into the resource table: passes that declared creates by name + // (the internal geometry/present wiring) get a table entry, exactly as create() does. + // Idempotent, so resources already declared through the typed API are left alone. + for (const auto& pass : m_passes) { + for (const auto& [name, desc] : pass->getCreates()) { + declareResource(name, desc); + } + } + + // Give every declared resource a physical backing: reuse a pooled one where the descriptor + // matches, otherwise ask the factory for a new one. Imported resources arrive with theirs. + for (const auto& resource : m_resource_table) { + // Never allocate over a resource the graph doesn't own, even if the import was null. + if (resource->isImported() || resource->getPhysicalResource()) { + continue; + } + const auto& desc = resource->getDescriptor(); + if (auto pooled = takeFromPool(desc)) { + resource->setPhysicalResource(std::move(pooled)); + continue; + } + if (!m_factory) { + continue; // graph-logic-only use (tests): resources stay virtual + } + switch (desc.type) { + case ResourceType::RenderTarget: + resource->setPhysicalResource(m_factory->createFramebuffer({desc.width, desc.height, 1})); + break; + case ResourceType::Texture2D: + resource->setPhysicalResource(m_factory->createTexture2D(desc.width, desc.height, desc.format)); + break; + case ResourceType::TextureCube: + case ResourceType::Buffer: + // No descriptor-based creation for these yet: GraphicsFactory can only build a + // cube map from a loaded asset, and the engine has no backend-agnostic Buffer + // type for a graph resource to map onto. Such a resource stays virtual (null + // physical) rather than being silently mis-allocated. + break; + } + } + + // Anything the new graph didn't claim is released here rather than held forever: a resize + // changes descriptors, so the previous sizes would never match again and would just pin + // GPU memory. + m_pool.clear(); + + // Cache the physical resources into every pass that declared them. + for (const auto& pass : m_passes) { + for (const auto& [name, desc] : pass->getCreates()) { + cacheInto(*pass, name); + } + for (const auto& read : pass->getReads()) { + cacheInto(*pass, read); + } + for (const auto& write : pass->getWrites()) { + cacheInto(*pass, write); + } + } + } + + void cacheInto(RenderGraphPass& pass, const std::string& name) { + auto it = m_resources.find(name); + if (it != m_resources.end()) { + pass.cacheResource(name, it->second->getPhysicalResource()); + } + } + + // Claim a pooled resource matching `desc` exactly (same type/size/format are interchangeable), + // removing it from the pool. Null when nothing matches. + std::shared_ptr takeFromPool(const ResourceDescriptor& desc) { + auto it = std::find_if(m_pool.begin(), m_pool.end(), [&desc](const PooledResource& p) { + return p.type == desc.type && p.width == desc.width && p.height == desc.height && p.format == desc.format; + }); + if (it == m_pool.end()) { + return nullptr; + } + auto physical = std::move(it->physical); + m_pool.erase(it); + return physical; + } + + void cullUnusedPasses() { + // Without a declared output there is nothing to cull against -- keep every pass. + if (m_output_resource.empty()) { + return; + } + + // Mark the passes that actually contribute to the output: start from whoever writes/creates + // the output resource, then walk their dependencies (the passes producing what they read) + // transitively. Anything not reached is dead and is dropped from the execution order. + std::unordered_set live; + std::vector worklist; + for (const auto& pass : m_passes) { + const auto& writes = pass->getWrites(); + const auto& creates = pass->getCreates(); + const bool produces_output = std::find(writes.begin(), writes.end(), m_output_resource) != writes.end() || + creates.find(m_output_resource) != creates.end(); + if (produces_output && live.insert(pass.get()).second) { + worklist.push_back(pass.get()); + } + } + while (!worklist.empty()) { + auto* pass = worklist.back(); + worklist.pop_back(); + auto it = m_dependencies.find(pass); + if (it != m_dependencies.end()) { + for (auto* dep : it->second) { + if (live.insert(dep).second) { + worklist.push_back(dep); + } + } + } + } + + std::vector kept; + kept.reserve(m_sorted_passes.size()); + for (auto* pass : m_sorted_passes) { + if (live.count(pass) > 0) { + kept.push_back(pass); + } + } + m_sorted_passes = std::move(kept); + } + +private: + std::shared_ptr m_factory; + std::vector> m_passes; + std::vector m_sorted_passes; + // The resource table: handles are indices into this. Rebuilt by reset(), which is what + // invalidates outstanding handles. + std::vector> m_resource_table; + std::unordered_map m_name_to_index; + // The same resources keyed by name, for the internal string layer (removed in T10). + std::unordered_map> m_resources; + std::unordered_map> m_dependencies; + std::string m_output_resource; + + // Transient resources released by the last reset(), available for the next compile to reuse. + // Survives reset() by design; drained at the end of allocateResources(). + struct PooledResource { + ResourceType type; + uint32_t width; + uint32_t height; + TextureFormat format; + std::shared_ptr physical; + }; + std::vector m_pool; +}; + +} // namespace ICE diff --git a/ICE/Graphics/include/RenderState.h b/ICE/Graphics/include/RenderState.h new file mode 100644 index 00000000..123c0f24 --- /dev/null +++ b/ICE/Graphics/include/RenderState.h @@ -0,0 +1,7 @@ +#pragma once + +namespace ICE { +// Depth comparison function for a draw. Less is the default; LEqual is needed by the +// skybox (its fragments sit exactly at the far plane via the z=w trick). +enum class DepthFunc { Less, LEqual }; +} // namespace ICE diff --git a/ICE/Graphics/include/Renderer.h b/ICE/Graphics/include/Renderer.h index 177e9668..4848d57c 100644 --- a/ICE/Graphics/include/Renderer.h +++ b/ICE/Graphics/include/Renderer.h @@ -9,10 +9,15 @@ #include #include #include +#include +#include + +#include #include "Camera.h" #include "Context.h" #include "Framebuffer.h" +#include "RenderFeature.h" #include "RendererConfig.h" namespace ICE { @@ -41,19 +46,25 @@ struct alignas(16) SceneLightsUBO { struct alignas(16) CameraUBO { Eigen::Matrix4f projection; Eigen::Matrix4f view; + Eigen::Vector4f cameraPos; // world-space camera position (xyz); matches std140 SceneData }; +// The per-frame submission structs carry generational GPU handles, not shared_ptr: no +// refcount churn, no per-drawable texture-map copy. The renderer resolves the mesh/shader handles +// to raw pointers once (in prepareFrame), and the geometry pass resolves each material's texture +// handles at bind. Material is a CPU asset, so it stays a shared_ptr. struct Skybox { - std::shared_ptr cube_mesh; - std::shared_ptr shader; - std::unordered_map> textures; + MeshHandle cube_mesh; + ShaderHandle shader; }; struct Drawable { - std::shared_ptr mesh; + MeshHandle mesh; std::shared_ptr material; - std::shared_ptr shader; - std::unordered_map> textures; + ShaderHandle shader; + // The material's asset UID (Material doesn't store its own id -- it's the bank key). Carried so + // the renderer can build a stable sort key without hashing pointer addresses. + AssetUID material_uid = NO_ASSET_ID; Eigen::Matrix4f model_matrix; std::unordered_map bone_matrices; }; @@ -68,14 +79,39 @@ struct Light { class Renderer { public: - virtual void submitSkybox(const Skybox& e) = 0; - virtual void submitDrawable(const Drawable& e) = 0; - virtual void submitLight(const Light& e) = 0; - virtual void prepareFrame(Camera& camera) = 0; + virtual ~Renderer() = default; + virtual void submitSkybox(const Skybox &e) = 0; + // By value so the caller's temporary (with its texture/bone maps) can be moved into the + // renderer's queue instead of copied. + virtual void submitDrawable(Drawable e) = 0; + virtual void submitLight(const Light &e) = 0; + virtual void prepareFrame(Camera &camera) = 0; + // Render the frame end to end through the render graph -- geometry, application passes/features, + // and the final present blit are all graph passes. Returns the scene-colour target (the editor + // reads it for picking); the on-screen/off-screen composite is done by the graph's present pass. virtual std::shared_ptr render() = 0; virtual void endFrame() = 0; + + // Where and how the frame's present pass composites the scene colour. Target nullptr = the + // window's default framebuffer; a framebuffer = off-screen (the editor's render-to-texture + // viewport). Set per frame before render(); the present pass reads them at execute time, so + // changing the target never forces a graph recompile. + virtual void setPresentTarget(const std::shared_ptr &target) {} + virtual void setPresentShader(const std::shared_ptr &present_shader) {} + virtual void resize(uint32_t width, uint32_t height) = 0; virtual void setClearColor(Eigen::Vector4f clearColor) = 0; virtual void setViewport(int x, int y, int w, int h) = 0; + + // Register a single render pass: it is added to the frame's render graph (setup() when the + // graph is built, execute() when it runs), so application code can extend the frame with no + // engine edits. This is the common path -- prefer it over wrapping one pass in a feature. The + // renderer takes ownership (the graph is rebuilt from scratch each compile, so it cannot own + // passes itself). No-op for backends without a graph. + virtual void addPass(std::unique_ptr) {} + + // Register a bundle of passes that belong together (shared state, one on/off switch). For a + // lone pass use addPass(). Same ownership as addPass. + virtual void addFeature(std::unique_ptr) {} }; } // namespace ICE diff --git a/ICE/Graphics/include/ShaderProgram.h b/ICE/Graphics/include/ShaderProgram.h index 5d2521fc..88e16b73 100644 --- a/ICE/Graphics/include/ShaderProgram.h +++ b/ICE/Graphics/include/ShaderProgram.h @@ -6,6 +6,7 @@ namespace ICE { class ShaderProgram { public: + virtual ~ShaderProgram() = default; virtual void bind() const = 0; virtual void unbind() const = 0; @@ -13,10 +14,12 @@ class ShaderProgram { virtual void loadInts(const std::string &name, int *array, uint32_t size) = 0; virtual void loadFloat(const std::string &name, float v) = 0; - virtual void loadFloat2(const std::string &name, Eigen::Vector2f vec) = 0; - virtual void loadFloat3(const std::string &name, Eigen::Vector3f vec) = 0; - virtual void loadFloat4(const std::string &name, Eigen::Vector4f vec) = 0; + virtual void loadFloat2(const std::string &name, const Eigen::Vector2f &vec) = 0; + virtual void loadFloat3(const std::string &name, const Eigen::Vector3f &vec) = 0; + virtual void loadFloat4(const std::string &name, const Eigen::Vector4f &vec) = 0; - virtual void loadMat4(const std::string &name, Eigen::Matrix4f mat) = 0; + virtual void loadMat4(const std::string &name, const Eigen::Matrix4f &mat) = 0; + // Upload a contiguous array of matrices in one call (e.g. a bone palette). + virtual void loadMat4v(const std::string &name, const Eigen::Matrix4f *data, uint32_t count) = 0; }; } // namespace ICE \ No newline at end of file diff --git a/ICE/Graphics/include/VertexArray.h b/ICE/Graphics/include/VertexArray.h index 9999fc92..85ef5052 100644 --- a/ICE/Graphics/include/VertexArray.h +++ b/ICE/Graphics/include/VertexArray.h @@ -16,10 +16,12 @@ class IndexBuffer; class VertexArray { public: + virtual ~VertexArray() = default; virtual void bind() const = 0; virtual void unbind() const = 0; virtual void pushVertexBuffer(const std::shared_ptr& buffer, int size) = 0; virtual void pushVertexBuffer(const std::shared_ptr& buffer, int position, int size) = 0; + virtual void pushVertexBuffer(const std::shared_ptr& buffer, int position, int size, int divisor) = 0; // For instancing virtual void setIndexBuffer(const std::shared_ptr& buffer) = 0; virtual std::shared_ptr getIndexBuffer() const = 0; virtual int getIndexCount() const = 0; diff --git a/ICE/Graphics/src/ForwardRenderer.cpp b/ICE/Graphics/src/ForwardRenderer.cpp index 03000e6c..c92afbc9 100644 --- a/ICE/Graphics/src/ForwardRenderer.cpp +++ b/ICE/Graphics/src/ForwardRenderer.cpp @@ -6,47 +6,76 @@ #include #include + +#include #include #include +#include #include -#include +#include #include #include namespace ICE { -ForwardRenderer::ForwardRenderer(const std::shared_ptr& api, const std::shared_ptr& factory) +ForwardRenderer::ForwardRenderer(const std::shared_ptr& api, const std::shared_ptr& factory, + const std::shared_ptr& gpu_registry) : m_api(api), - m_geometry_pass(api, factory, {1, 1, 1}) { + m_gpu_registry(gpu_registry), + m_geometry_pass(api, factory, gpu_registry, {1, 1, 1}), + m_graph(factory) { m_camera_ubo = factory->createUniformBuffer(sizeof(CameraUBO), 0); m_light_ubo = factory->createUniformBuffer(sizeof(SceneLightsUBO), 1); + + // Full-screen quad for the present pass (moved here from RenderSystem, which no longer owns + // any GL objects). + m_present_quad = factory->createVertexArray(); + auto quad_vertex_vbo = factory->createVertexBuffer(); + quad_vertex_vbo->putData(full_quad_v.data(), full_quad_v.size() * sizeof(float)); + m_present_quad->pushVertexBuffer(quad_vertex_vbo, 3); + auto quad_uv_vbo = factory->createVertexBuffer(); + quad_uv_vbo->putData(full_quad_tx.data(), full_quad_tx.size() * sizeof(float)); + m_present_quad->pushVertexBuffer(quad_uv_vbo, 2); + auto quad_ibo = factory->createIndexBuffer(); + quad_ibo->putData(full_quad_idx.data(), full_quad_idx.size() * sizeof(int)); + m_present_quad->setIndexBuffer(quad_ibo); } void ForwardRenderer::submitSkybox(const Skybox& e) { m_skybox.emplace(e); } -void ForwardRenderer::submitDrawable(const Drawable& e) { - m_drawables.push_back(e); +void ForwardRenderer::submitDrawable(Drawable e) { + m_drawables.push_back(std::move(e)); } void ForwardRenderer::submitLight(const Light& e) { m_lights.push_back(e); } -void ForwardRenderer::prepareFrame(Camera& camera) { - //TODO: Sort entities, make shader list, batch, make instances, set uniforms, etc.. - +void ForwardRenderer::uploadCameraUBO(Camera& camera) { auto view_mat = camera.lookThrough(); auto proj_mat = camera.getProjection(); + auto cam_pos = camera.getPosition(); - CameraUBO camera_ubo_data{.projection = proj_mat, .view = view_mat}; + CameraUBO camera_ubo_data{ + .projection = proj_mat, .view = view_mat, .cameraPos = Eigen::Vector4f(cam_pos.x(), cam_pos.y(), cam_pos.z(), 1.0f)}; m_camera_ubo->putData(&camera_ubo_data, sizeof(CameraUBO)); +} + +void ForwardRenderer::prepareFrame(Camera& camera) { + // Remembered for the frame so drawScene() can restore this view after a pass draws from + // another one. + m_frame_camera = &camera; + uploadCameraUBO(camera); SceneLightsUBO light_ubo_data; - light_ubo_data.light_count = m_lights.size(); + // Clamp to the UBO's fixed capacity: lights[] is MAX_LIGHTS long, so writing more + // would overflow the stack-allocated struct. + const size_t light_count = std::min(m_lights.size(), static_cast(MAX_LIGHTS)); + light_ubo_data.light_count = static_cast(light_count); light_ubo_data.ambient_light = Eigen::Vector4f(0.1f, 0.1f, 0.1f, 1.0f); - for (int i = 0; i < m_lights.size(); i++) { + for (size_t i = 0; i < light_count; i++) { auto light = m_lights[i]; light_ubo_data.lights[i].position = light.position; light_ubo_data.lights[i].rotation = light.rotation; @@ -57,59 +86,252 @@ void ForwardRenderer::prepareFrame(Camera& camera) { m_light_ubo->putData(&light_ubo_data, sizeof(SceneLightsUBO)); if (m_skybox.has_value()) { - RenderCommand skybox_cmd; - skybox_cmd.mesh = m_skybox->cube_mesh; - skybox_cmd.material = nullptr; - skybox_cmd.shader = m_skybox->shader; - skybox_cmd.textures = m_skybox->textures; - skybox_cmd.model_matrix = Eigen::Matrix4f::Identity(); - m_render_commands.push_back(skybox_cmd); + GPUMesh* sky_mesh = m_gpu_registry->resolve(m_skybox->cube_mesh); + ShaderProgram* sky_shader = m_gpu_registry->resolve(m_skybox->shader); + if (sky_mesh && sky_shader) { + RenderCommand skybox_cmd; + skybox_cmd.mesh = sky_mesh; + skybox_cmd.material = nullptr; + skybox_cmd.shader = sky_shader; + skybox_cmd.model_matrix = Eigen::Matrix4f::Identity(); + skybox_cmd.is_instanced = false; + // Draw after opaque geometry (so it only fills background pixels) but before + // transparent. Its fragments sit at the far plane (z=w in skybox.vs), so it needs + // GL_LEQUAL and must not write depth. + skybox_cmd.depthTest = true; + skybox_cmd.depthWrite = false; + skybox_cmd.depth_func = DepthFunc::LEqual; + skybox_cmd.sort_key = 0x7FFFFFFFFFFFFFFFULL; // last among opaque (transparent bit 63 = 0) + m_render_commands.push_back(skybox_cmd); + } } + // Instance batching: group drawables by the exact (mesh, material, shader) triple. Each + // drawable's mesh/shader handle is resolved to a raw pointer once here, and those resolved + // pointers ARE the batch key -- so the render commands and sort keys are unchanged. + std::map> instance_batches; + std::vector non_instanced_drawables; // Skinned meshes, etc. + for (const auto& drawable : m_drawables) { + GPUMesh* mesh = m_gpu_registry->resolve(drawable.mesh); + ShaderProgram* shader = m_gpu_registry->resolve(drawable.shader); + if (!mesh || !shader || !drawable.material) { + continue; // stale handle or missing material + } + // Skip instancing for skinned meshes (has bones) + if (!drawable.bone_matrices.empty()) { + non_instanced_drawables.push_back(&drawable); + continue; + } + instance_batches[BatchKey{mesh, drawable.material.get(), shader}].push_back(&drawable); + } + + // Convert batches to render commands + m_instance_batches.clear(); // Clear previous frame's instance data + Eigen::Vector3f camera_pos = camera.getPosition(); + + for (const auto& [key, batch] : instance_batches) { + GPUMesh* mesh = std::get<0>(key); + Material* material = std::get<1>(key); + ShaderProgram* shader = std::get<2>(key); + if (batch.size() == 1) { + // Single instance - use regular rendering + const auto* drawable = batch[0]; + auto dist = (drawable->model_matrix.block<3, 1>(0, 3) - camera_pos).squaredNorm(); + RenderCommand cmd; + cmd.mesh = mesh; + cmd.material = material; + cmd.shader = shader; + cmd.model_matrix = drawable->model_matrix; + cmd.depthTest = true; + cmd.faceCulling = true; + cmd.is_instanced = false; + cmd.blend = material->isTransparent(); + cmd.computeSortKey(material->isTransparent(), dist, material->getShader(), drawable->material_uid); + m_render_commands.push_back(cmd); + } else { + // Multiple instances - use instanced rendering + // Store instance data in member variable for lifetime management + auto& instance_data_vec = m_instance_batches[key]; + instance_data_vec.clear(); + instance_data_vec.reserve(batch.size()); + + for (const auto* drawable : batch) { + InstanceData inst_data; + inst_data.model_matrix = drawable->model_matrix; + instance_data_vec.push_back(inst_data); + } + + const auto* first = batch[0]; + auto dist = (first->model_matrix.block<3, 1>(0, 3) - camera_pos).squaredNorm(); + RenderCommand cmd; + cmd.mesh = mesh; + cmd.material = material; + cmd.shader = shader; + cmd.depthTest = true; + cmd.faceCulling = true; + cmd.is_instanced = true; + cmd.instance_count = batch.size(); + cmd.instance_data = &instance_data_vec; // Link to stored data + cmd.blend = material->isTransparent(); + cmd.computeSortKey(material->isTransparent(), dist, material->getShader(), first->material_uid); + m_render_commands.push_back(cmd); + } + } + + // Add non-instanced drawables (skinned meshes) + for (const auto* drawable : non_instanced_drawables) { + GPUMesh* mesh = m_gpu_registry->resolve(drawable->mesh); + ShaderProgram* shader = m_gpu_registry->resolve(drawable->shader); + Material* material = drawable->material.get(); RenderCommand cmd; - cmd.mesh = drawable.mesh; - cmd.material = drawable.material; - cmd.shader = drawable.shader; - cmd.textures = drawable.textures; - cmd.model_matrix = drawable.model_matrix; + auto dist = (drawable->model_matrix.block<3, 1>(0, 3) - camera_pos).squaredNorm(); + cmd.mesh = mesh; + cmd.material = material; + cmd.shader = shader; + cmd.model_matrix = drawable->model_matrix; cmd.depthTest = true; cmd.faceCulling = true; - cmd.bones = drawable.bone_matrices; + cmd.bones = &drawable->bone_matrices; + cmd.is_instanced = false; + cmd.blend = material->isTransparent(); + cmd.computeSortKey(material->isTransparent(), dist, material->getShader(), drawable->material_uid); m_render_commands.push_back(cmd); } - std::sort(m_render_commands.begin(), m_render_commands.end(), [this](const RenderCommand& a, const RenderCommand& b) { - bool a_transparent = a.material ? a.material->isTransparent() : false; - bool b_transparent = b.material ? b.material->isTransparent() : false; + std::sort(m_render_commands.begin(), m_render_commands.end()); + + m_geometry_pass.submit(&m_render_commands); +} + +void ForwardRenderer::addPass(std::unique_ptr pass) { + // Wrapped rather than kept in a second list, so passes and features share one registration + // order and one wiring path in rebuildGraph(). + if (pass) { + addFeature(std::make_unique(std::move(pass))); + } +} + +void ForwardRenderer::addFeature(std::unique_ptr feature) { + if (feature) { + m_features.push_back(std::move(feature)); + m_graph_dirty = true; // the new feature's passes only join the graph on a rebuild + } +} + +void ForwardRenderer::drawScene(Camera& camera, ShaderProgram* override_shader) { + // Point the shared camera UBO at the requested view, replay the frame's visible set into + // whatever target the graph bound, then put the frame's own camera back. Without the restore, a + // shadow pass drawing from a light would leave the geometry pass rendering from that light. + uploadCameraUBO(camera); + m_geometry_pass.drawInto(override_shader); + if (m_frame_camera && m_frame_camera != &camera) { + uploadCameraUBO(*m_frame_camera); + } +} + +void ForwardRenderer::fullscreen(ShaderProgram* shader) { + if (!shader) { + return; + } + shader->bind(); + m_present_quad->bind(); + m_present_quad->getIndexBuffer()->bind(); + m_api->renderVertexArray(m_present_quad); +} + +void ForwardRenderer::rebuildGraph() { + m_graph.reset(); + + // Publish the geometry pass's framebuffer into the resource table so features can name the + // scene colour with a typed handle. The geometry pass itself still declares its target + // through the string layer (removed in T10). + auto scene_color = m_graph.importResource("scene_color", m_geometry_pass.getResult()); + + auto& geometry = m_graph.addPass("geometry"); + geometry.write("scene_color"); + geometry.setExecuteCallback([this](const RenderGraphPass&) { + m_api->beginGPUTimer(); + m_geometry_pass.execute(); + Profiler::get().addSample("GPU::geometry", m_api->endGPUTimer()); + }); - if (!a_transparent && b_transparent) { - return true; + // Application-registered features contribute their passes here -- the whole point of the seam: + // no engine edit is needed to add a pass. setup() runs on each rebuild, which is what + // regenerates every pass's resource handles for this compile. + for (const auto& feature : m_features) { + addFeaturePasses(m_graph, *feature, scene_color, m_api, this); + } + + // Present is a graph pass too (T10): it reads the finished scene colour -- so it is ordered + // after geometry and every feature/UI pass that wrote it -- and composites it to the frame's + // present target. It writes a virtual "backbuffer" resource that is the graph's declared output, + // which is what keeps it (and its dependencies) from being culled. The backbuffer has no + // physical resource: the pass binds the real target (default framebuffer or the editor's RTT) + // itself, since that isn't a graph-managed framebuffer. + auto& present = m_graph.addPass("present"); + present.read("scene_color"); + present.write("backbuffer"); + present.setExecuteCallback([this, scene_color](const RenderGraphPass&) { + auto* resource = m_graph.resourceAt(scene_color.index()); + auto scene_fb = resource ? resource->getPhysicalResourceAs() : nullptr; + if (!m_present_shader || !scene_fb) { + return; + } + // Off-screen (editor viewport) if a target is set, else the window's default framebuffer. + if (m_present_target) { + m_present_target->bind(); + m_api->setViewport(0, 0, static_cast(m_present_target->getFormat().width), static_cast(m_present_target->getFormat().height)); } else { - return false; + m_api->bindDefaultFramebuffer(); + m_api->setViewport(0, 0, static_cast(scene_fb->getFormat().width), static_cast(scene_fb->getFormat().height)); } + m_api->clear(); + m_present_shader->bind(); + scene_fb->bindAttachment(0); + m_present_shader->loadInt("uTexture", 0); + m_present_quad->bind(); + m_present_quad->getIndexBuffer()->bind(); + m_api->renderVertexArray(m_present_quad); }); - m_geometry_pass.submit(&m_render_commands); + m_graph.setOutput("backbuffer"); + m_graph.compile(); } std::shared_ptr ForwardRenderer::render() { - m_geometry_pass.execute(); - auto result = m_geometry_pass.getResult(); - return result; + // The graph owns the whole frame -- geometry, feature/UI passes, and present. It is compiled + // once and re-executed each frame; a rebuild happens only when its shape changes (a resize, or + // a newly registered pass/feature). The present pass composites to the frame's target, so + // render() has no separate present step. Returns the scene colour for callers that read it + // (e.g. the editor's picking pass). + if (m_graph_dirty) { + rebuildGraph(); + m_graph_dirty = false; + } + m_graph.execute(); + m_output_fb = m_geometry_pass.getResult(); + return m_output_fb; } void ForwardRenderer::endFrame() { m_skybox.reset(); m_drawables.clear(); m_lights.clear(); +#ifndef NDEBUG + // Only drain GL errors in debug builds; skip the per-frame glGetError round-trip in release. m_api->checkAndLogErrors(); +#endif m_render_commands.clear(); } void ForwardRenderer::resize(uint32_t width, uint32_t height) { m_api->setViewport(0, 0, width, height); m_geometry_pass.resize(width, height); + // Resource descriptors are sized from the viewport, so the compiled graph is now stale. This is + // the one path that must reliably re-dirty it -- RenderSystem::setViewport calls us on every + // framebuffer resize. + m_graph_dirty = true; } void ForwardRenderer::setClearColor(Eigen::Vector4f clearColor) { diff --git a/ICE/Graphics/src/GeometryPass.cpp b/ICE/Graphics/src/GeometryPass.cpp index 85e2dfbb..6b5167a2 100644 --- a/ICE/Graphics/src/GeometryPass.cpp +++ b/ICE/Graphics/src/GeometryPass.cpp @@ -1,75 +1,126 @@ #include "GeometryPass.h" +#include + +#include "InstanceData.h" + namespace ICE { -GeometryPass::GeometryPass(const std::shared_ptr& api, const std::shared_ptr& factory, const FrameBufferFormat& format) - : m_api(api) { +GeometryPass::GeometryPass(const std::shared_ptr& api, const std::shared_ptr& factory, + const std::shared_ptr& gpu_registry, const FrameBufferFormat& format) + : m_api(api), + m_factory(factory), + m_gpu_registry(gpu_registry) { m_framebuffer = factory->createFramebuffer(format); + m_instance_buffer = factory->createVertexBuffer(); } void GeometryPass::execute() { m_framebuffer->bind(); m_api->setViewport(0, 0, m_framebuffer->getFormat().width, m_framebuffer->getFormat().height); m_api->clear(); - std::shared_ptr current_shader; - std::shared_ptr current_material; - std::shared_ptr current_mesh; + drawInto(nullptr); +} + +void GeometryPass::drawInto(ShaderProgram* override_shader) { + ShaderProgram* current_shader = nullptr; + Material* current_material = nullptr; + GPUMesh* current_mesh = nullptr; + + // Cache render state within the pass so identical consecutive commands (e.g. a run of + // opaque draws) don't re-issue the same GL state calls. Forced on the first command. + bool state_init = false; + bool cur_cull = false, cur_depth_test = false, cur_depth_write = false, cur_blend = false; + DepthFunc cur_depth_func = DepthFunc::Less; for (const auto& command : *m_render_queue) { - auto& shader = command.shader; - auto& material = command.material; + // An override shader stands in for every command's own shader (and suppresses the + // material below); otherwise each command draws with its material's shader as usual. + ShaderProgram* shader = override_shader ? override_shader : command.shader; + Material* material = override_shader ? nullptr : command.material; auto& mesh = command.mesh; - m_api->setBackfaceCulling(command.faceCulling); - m_api->setDepthTest(command.depthTest); + if (!state_init || cur_cull != command.faceCulling) { + m_api->setBackfaceCulling(command.faceCulling); + cur_cull = command.faceCulling; + } + if (!state_init || cur_depth_test != command.depthTest) { + m_api->setDepthTest(command.depthTest); + cur_depth_test = command.depthTest; + } + if (!state_init || cur_depth_write != command.depthWrite) { + m_api->setDepthMask(command.depthWrite); + cur_depth_write = command.depthWrite; + } + if (!state_init || cur_depth_func != command.depth_func) { + m_api->setDepthFunc(command.depth_func); + cur_depth_func = command.depth_func; + } + if (!state_init || cur_blend != command.blend) { + m_api->setBlend(command.blend); + cur_blend = command.blend; + } + state_init = true; if (shader != current_shader) { shader->bind(); current_shader = shader; } - if (!command.bones.empty()) { - for (const auto& [id, matrix] : command.bones) { - current_shader->loadMat4("bonesTransformMatrices[" + std::to_string(id) + "]", matrix); + // Handle bone matrices (non-instanced only). Pack into a contiguous, id-indexed + // buffer (reused across draws) and upload the whole palette in one glUniformMatrix4fv + // call instead of one string-built uniform lookup + upload per bone. + if (!command.is_instanced && command.bones && !command.bones->empty()) { + int max_id = 0; + for (const auto& [id, matrix] : *command.bones) { + max_id = std::max(max_id, id); } + m_bone_palette.assign(static_cast(max_id) + 1, Eigen::Matrix4f::Identity()); + for (const auto& [id, matrix] : *command.bones) { + if (id >= 0) { + m_bone_palette[id] = matrix; + } + } + current_shader->loadMat4v("bonesTransformMatrices", m_bone_palette.data(), static_cast(m_bone_palette.size())); } - if (material != current_material) { - auto& textures = command.textures; + // Skybox commands carry a null material (their uniforms come from the skybox shader), + // so guard against it; opaque/transparent geometry always has one. + if (material && material != current_material) { current_material = material; int texture_count = 0; - //TODO: Can we do better ? - for (const auto& [name, value] : material->getAllUniforms()) { - if (std::holds_alternative(value)) { - auto v = std::get(value); - shader->loadFloat(name, v); - } else if (!std::holds_alternative(value) && std::holds_alternative(value)) { - auto v = std::get(value); - shader->loadInt(name, v); - } else if (std::holds_alternative(value)) { - auto v = std::get(value); - if (textures.contains(v)) { - auto& tex = textures.at(v); - if (tex) { + // Iterate the material's pre-classified uniform bindings and switch on the resolved + // kind -- no std::holds_alternative chain per bind. + for (const auto& binding : material->getUniformBindings()) { + switch (binding.kind) { + case UniformKind::Float: + shader->loadFloat(binding.name, std::get(binding.value)); + break; + case UniformKind::Int: + shader->loadInt(binding.name, std::get(binding.value)); + break; + case UniformKind::Texture: { + // Resolve the texture from its AssetUID to a raw GPU pointer here, at bind + // time (uploading on first use), instead of carrying a per-command map. + if (GPUTexture* tex = m_gpu_registry->texture2DPtr(std::get(binding.value))) { tex->bind(texture_count); - shader->loadInt(name, texture_count); + shader->loadInt(binding.name, texture_count); texture_count++; } + break; } - } else if (std::holds_alternative(value)) { - auto& v = std::get(value); - shader->loadFloat2(name, v); - } else if (std::holds_alternative(value)) { - auto& v = std::get(value); - shader->loadFloat3(name, v); - } else if (std::holds_alternative(value)) { - auto& v = std::get(value); - shader->loadFloat4(name, v); - } else if (std::holds_alternative(value)) { - auto& v = std::get(value); - shader->loadMat4(name, v); - } else { - throw std::runtime_error("Uniform type not implemented"); + case UniformKind::Vec2: + shader->loadFloat2(binding.name, std::get(binding.value)); + break; + case UniformKind::Vec3: + shader->loadFloat3(binding.name, std::get(binding.value)); + break; + case UniformKind::Vec4: + shader->loadFloat4(binding.name, std::get(binding.value)); + break; + case UniformKind::Mat4: + shader->loadMat4(binding.name, std::get(binding.value)); + break; } } } @@ -81,9 +132,21 @@ void GeometryPass::execute() { va->getIndexBuffer()->bind(); } - shader->loadMat4("model", command.model_matrix); - m_api->renderVertexArray(mesh->getVertexArray()); + // Reuse the pass-owned instance buffer: upload this command's per-instance data + // and (re)bind it at attribute slot 7 of the current mesh's vertex array. + auto va = mesh->getVertexArray(); + if (command.is_instanced && command.instance_data) { + m_instance_buffer->putData(command.instance_data->data(), command.instance_count * sizeof(InstanceData)); + } else { + m_instance_buffer->putData(command.model_matrix.data(), sizeof(Eigen::Matrix4f)); + } + va->pushVertexBuffer(m_instance_buffer, 7, 16, 1); + m_api->renderVertexArrayInstanced(va, command.instance_count); } + + // Restore the globally-on blend state expected by other passes (final blit, editor + // picking), since opaque commands turned it off. + m_api->setBlend(true); } std::shared_ptr GeometryPass::getResult() const { diff --git a/ICE/Graphics/test/CMakeLists.txt b/ICE/Graphics/test/CMakeLists.txt new file mode 100644 index 00000000..03443567 --- /dev/null +++ b/ICE/Graphics/test/CMakeLists.txt @@ -0,0 +1,86 @@ +cmake_minimum_required(VERSION 3.19) +project(graphics-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +add_executable(HandlePoolTestSuite + HandlePoolTest.cpp +) + +# HandlePool.h / GpuHandle.h are header-only and GL-free, so pull in just the include directory +# rather than linking the whole graphics library (which would drag in OpenGL/GLFW). +target_include_directories(HandlePoolTestSuite PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../include) + +add_test(NAME HandlePoolTestSuite + COMMAND HandlePoolTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(HandlePoolTestSuite + PRIVATE + gtest_main +) + +# RenderGraph.h transitively pulls in the graphics headers, so this suite links the graphics +# library for the include paths. It exercises only compile-time graph logic (no GL is invoked). +add_executable(RenderGraphTestSuite + RenderGraphTest.cpp +) + +add_test(NAME RenderGraphTestSuite + COMMAND RenderGraphTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(RenderGraphTestSuite + PRIVATE + gtest_main + graphics +) + +# The public pass/feature seam (typed handles, feature registration, setup/execute). Exercises +# compile-time graph logic plus a stub framebuffer -- no GL is invoked. +add_executable(RenderFeatureTestSuite + RenderFeatureTest.cpp +) + +add_test(NAME RenderFeatureTestSuite + COMMAND RenderFeatureTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(RenderFeatureTestSuite + PRIVATE + gtest_main + graphics +) + +# Compile-once + resource pooling + descriptor-based allocation + the pass draw seam (T5). Uses a +# counting stub factory, so allocation counts are observable without GL. +add_executable(RenderGraphPoolingTestSuite + RenderGraphPoolingTest.cpp +) + +add_test(NAME RenderGraphPoolingTestSuite + COMMAND RenderGraphPoolingTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(RenderGraphPoolingTestSuite + PRIVATE + gtest_main + graphics +) + +# Sort-key packing/determinism (RenderCommand.h pulls in the graphics headers, so link graphics). +add_executable(SortKeyTestSuite + SortKeyTest.cpp +) + +add_test(NAME SortKeyTestSuite + COMMAND SortKeyTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(SortKeyTestSuite + PRIVATE + gtest_main + graphics +) diff --git a/ICE/Graphics/test/HandlePoolTest.cpp b/ICE/Graphics/test/HandlePoolTest.cpp new file mode 100644 index 00000000..13c2f78e --- /dev/null +++ b/ICE/Graphics/test/HandlePoolTest.cpp @@ -0,0 +1,81 @@ +#include + +#include +#include + +#include "GpuHandle.h" +#include "HandlePool.h" + +using namespace ICE; + +namespace { +struct TestTag {}; +} // namespace + +// The typed GPU handles must be distinct types so a MeshHandle can't be used where a TextureHandle +// is expected. +static_assert(!std::is_same_v, "GPU handle tags must be distinct"); +static_assert(!std::is_same_v, "GPU handle tags must be distinct"); + +TEST(HandlePoolTest, InsertAndGet) { + HandlePool pool; + auto h = pool.insert(42); + ASSERT_TRUE(h.valid()); + auto* p = pool.get(h); + ASSERT_NE(p, nullptr); + EXPECT_EQ(*p, 42); + EXPECT_EQ(pool.size(), 1u); +} + +TEST(HandlePoolTest, NullHandleResolvesToNull) { + HandlePool pool; + Handle null_h{}; + EXPECT_FALSE(null_h.valid()); + EXPECT_EQ(pool.get(null_h), nullptr); +} + +TEST(HandlePoolTest, EraseFreesSlot) { + HandlePool pool; + auto h = pool.insert(7); + EXPECT_TRUE(pool.erase(h)); + EXPECT_EQ(pool.get(h), nullptr); + EXPECT_EQ(pool.size(), 0u); + EXPECT_FALSE(pool.erase(h)); // double-erase is a no-op +} + +// Acceptance criterion for P8: a stale handle (its slot freed and reused) does NOT resolve to the +// new occupant -- the generation mismatch catches the use-after-free. +TEST(HandlePoolTest, GenerationCatchesUseAfterFree) { + HandlePool pool; + auto ha = pool.insert(100); + pool.erase(ha); + auto hb = pool.insert(200); // reuses ha's slot with a bumped generation + + EXPECT_EQ(hb.index, ha.index); // same slot ... + EXPECT_NE(hb.generation, ha.generation); // ... different generation + EXPECT_EQ(pool.get(ha), nullptr); // stale handle: use-after-free caught + ASSERT_NE(pool.get(hb), nullptr); + EXPECT_EQ(*pool.get(hb), 200); // and never aliases the new resource +} + +TEST(HandlePoolTest, StableAddressesAcrossGrowth) { + HandlePool pool; + auto h0 = pool.insert(1); + int* a0 = pool.get(h0); + for (int i = 0; i < 1000; ++i) { + pool.insert(i); // force the backing store to grow + } + EXPECT_EQ(pool.get(h0), a0); // an existing element's address is unchanged + EXPECT_EQ(*pool.get(h0), 1); +} + +TEST(HandlePoolTest, OwnsResourcesAndReleasesOnErase) { + HandlePool, TestTag> pool; + auto res = std::make_shared(5); + std::weak_ptr weak = res; + auto h = pool.insert(res); + res.reset(); + EXPECT_FALSE(weak.expired()); // the pool keeps the resource alive + pool.erase(h); + EXPECT_TRUE(weak.expired()); // erase releases it +} diff --git a/ICE/Graphics/test/RenderFeatureTest.cpp b/ICE/Graphics/test/RenderFeatureTest.cpp new file mode 100644 index 00000000..0577f940 --- /dev/null +++ b/ICE/Graphics/test/RenderFeatureTest.cpp @@ -0,0 +1,299 @@ +#include + +#include +#include +#include +#include + +#include "RenderFeature.h" + +using namespace ICE; + +// Exercises the T4 public pass/feature seam: typed resource handles, feature registration, and the +// setup()/execute() cycle. Everything here is compile-time graph logic plus a stub framebuffer -- +// no GL is invoked, so a null GraphicsFactory and a null RendererAPI are fine. + +namespace { + +// Stands in for a real framebuffer so an imported resource has something to resolve to. +class StubFramebuffer : public Framebuffer { + public: + StubFramebuffer() : Framebuffer({4, 4, 1}) {} + void bind() override {} + void unbind() override {} + void resize(int, int) override {} + int getTexture() override { return 0; } + void bindAttachment(int) const override {} + Eigen::Vector4i readPixel(int, int) override { return Eigen::Vector4i::Zero(); } +}; + +// What a pass observed, so a test can assert on it after the graph ran. +struct Trace { + std::vector* order = nullptr; + int setups = 0; + int executes = 0; + std::shared_ptr resolved_scene_color; + bool own_target_was_virtual = false; +}; + +// --- Application code: an outline feature, written entirely against the public seam ------------ +// This is the acceptance case -- it adds a pass to the frame without a single engine edit. It reads +// the engine's scene colour, declares its own target, and writes the scene colour back so it +// contributes to the output (and is therefore not culled). +class OutlinePass : public IRenderPass { + public: + explicit OutlinePass(Trace* trace) : m_trace(trace) {} + + const char* name() const override { return "outline"; } + + void setup(RenderGraphBuilder& builder) override { + m_trace->setups++; + // Handles are re-declared on every rebuild; these overwrite last compile's. + m_scene_color = builder.read(builder.sceneColor()); + m_target = builder.create({.width = 8, .height = 8, .debug_name = "outline_target"}); + builder.write(builder.sceneColor()); + } + + void execute(PassContext& ctx) override { + m_trace->executes++; + m_trace->order->push_back("outline"); + m_trace->resolved_scene_color = ctx.get(m_scene_color); + m_trace->own_target_was_virtual = (ctx.get(m_target) == nullptr); + } + + private: + Trace* m_trace; + RenderResourceHandle m_scene_color; + RenderResourceHandle m_target; +}; + +class OutlineFeature : public RenderFeature { + public: + explicit OutlineFeature(Trace* trace) { addPass(trace); } + const char* name() const override { return "outline"; } +}; + +// A pass that writes only its own resource: nothing reads it and it isn't the output, so the graph +// culls it. +class DeadEndPass : public IRenderPass { + public: + explicit DeadEndPass(Trace* trace) : m_trace(trace) {} + const char* name() const override { return "dead_end"; } + void setup(RenderGraphBuilder& builder) override { + auto own = builder.create({.width = 4, .height = 4, .debug_name = "nobody_reads_this"}); + builder.write(own); + } + void execute(PassContext&) override { + m_trace->executes++; + m_trace->order->push_back("dead_end"); + } + + private: + Trace* m_trace; +}; + +class DeadEndFeature : public RenderFeature { + public: + explicit DeadEndFeature(Trace* trace) { addPass(trace); } + const char* name() const override { return "dead_end"; } +}; + +// A two-pass feature: the first renders into an intermediate target, the second consumes it and +// writes the scene colour. The second pass reads the handle the first published during its own +// setup() -- passes are set up in declaration order, so that handle is from the current compile. +// +// Note the topology: only the last pass writes scene_color and no earlier pass reads it. Two passes +// that both read *and* write the same resource would make each depend on the other (this graph has +// no resource versioning), which compile() reports as a cycle. +class ChainFirstPass : public IRenderPass { + public: + explicit ChainFirstPass(Trace* trace) : m_trace(trace) {} + const char* name() const override { return "chain_first"; } + void setup(RenderGraphBuilder& builder) override { + intermediate = builder.create({.width = 8, .height = 8, .debug_name = "chain_mid"}); + builder.write(intermediate); + } + void execute(PassContext&) override { + m_trace->executes++; + m_trace->order->push_back("chain_first"); + } + + RenderResourceHandle intermediate; // published for the next pass + + private: + Trace* m_trace; +}; + +class ChainSecondPass : public IRenderPass { + public: + ChainSecondPass(Trace* trace, ChainFirstPass* first) : m_trace(trace), m_first(first) {} + const char* name() const override { return "chain_second"; } + void setup(RenderGraphBuilder& builder) override { + builder.read(m_first->intermediate); + builder.write(builder.sceneColor()); + } + void execute(PassContext&) override { + m_trace->executes++; + m_trace->order->push_back("chain_second"); + } + + private: + Trace* m_trace; + ChainFirstPass* m_first; +}; + +class ChainFeature : public RenderFeature { + public: + explicit ChainFeature(Trace* trace) { + auto& first = addPass(trace); + addPass(trace, &first); + } + const char* name() const override { return "chain"; } +}; + +// Mirrors what ForwardRenderer::render() does around the feature seam, minus the GL: import the +// scene colour, add the engine's geometry pass through the (internal) string layer, then let each +// feature contribute its passes via the same addFeaturePasses() the renderer uses. +struct Frame { + RenderGraph graph{nullptr}; + std::shared_ptr scene_fb = std::make_shared(); + std::vector order; + + RenderResourceHandle build(const std::vector& features) { + graph.reset(); + auto scene_color = graph.importResource("scene_color", scene_fb); + auto& geometry = graph.addPass("geometry"); + geometry.write("scene_color"); + geometry.setExecuteCallback([this](const RenderGraphPass&) { order.push_back("geometry"); }); + for (auto* feature : features) { + addFeaturePasses(graph, *feature, scene_color, nullptr); + } + graph.setOutput(scene_color); + graph.compile(); + return scene_color; + } +}; +} // namespace + +// The acceptance case: a feature written as application code contributes a pass to the frame, runs +// after the pass that produces what it reads, and resolves its handles at execute time. +TEST(RenderFeatureTest, RegisteredFeatureAddsPassToTheFrame) { + Trace trace; + Frame frame; + trace.order = &frame.order; + OutlineFeature feature(&trace); + + frame.build({&feature}); + frame.graph.execute(); + + EXPECT_EQ(trace.setups, 1); + EXPECT_EQ(trace.executes, 1); + ASSERT_EQ(frame.order.size(), 2u); + EXPECT_EQ(frame.order[0], "geometry"); // outline reads scene_color, which geometry writes + EXPECT_EQ(frame.order[1], "outline"); +} + +// A handle resolves to the physical resource the engine imported -- this is what replaces +// getResource("scene_color") returning null on a typo. +TEST(RenderFeatureTest, HandleResolvesToImportedResource) { + Trace trace; + Frame frame; + trace.order = &frame.order; + OutlineFeature feature(&trace); + + frame.build({&feature}); + frame.graph.execute(); + + EXPECT_EQ(trace.resolved_scene_color, frame.scene_fb); + // The pass's own created RenderTarget stays virtual here only because this graph has a null + // factory; with a real factory compile() allocates it. + EXPECT_TRUE(trace.own_target_was_virtual); +} + +// Handles are indices into the current resource table: a rebuild re-runs setup(), so a feature's +// handles are regenerated and stay valid rather than dangling across reset(). +TEST(RenderFeatureTest, HandlesAreRegeneratedOnRebuild) { + Trace trace; + Frame frame; + trace.order = &frame.order; + OutlineFeature feature(&trace); + + frame.build({&feature}); + frame.graph.execute(); + frame.order.clear(); + + frame.build({&feature}); // reset() + re-setup, as a resize would do + frame.graph.execute(); + + EXPECT_EQ(trace.setups, 2); + EXPECT_EQ(trace.executes, 2); + EXPECT_EQ(trace.resolved_scene_color, frame.scene_fb); // still resolves after the rebuild + ASSERT_EQ(frame.order.size(), 2u); + EXPECT_EQ(frame.order[1], "outline"); +} + +// A feature pass that doesn't contribute to the output is culled -- the documented reason a +// post-process pass must write sceneColor(). +TEST(RenderFeatureTest, FeaturePassNotContributingToOutputIsCulled) { + Trace trace; + Frame frame; + trace.order = &frame.order; + DeadEndFeature feature(&trace); + + frame.build({&feature}); + frame.graph.execute(); + + EXPECT_EQ(trace.executes, 0); + EXPECT_EQ(std::find(frame.order.begin(), frame.order.end(), "dead_end"), frame.order.end()); +} + +// A feature can contribute several passes; the graph orders them by the resources they exchange, +// and a pass can consume a handle a previous pass of the same feature declared. +TEST(RenderFeatureTest, FeatureCanContributeMultiplePassesInOrder) { + Trace trace; + Frame frame; + trace.order = &frame.order; + ChainFeature feature(&trace); + + frame.build({&feature}); + frame.graph.execute(); + + EXPECT_EQ(trace.executes, 2); + const auto first = std::find(frame.order.begin(), frame.order.end(), "chain_first"); + const auto second = std::find(frame.order.begin(), frame.order.end(), "chain_second"); + ASSERT_NE(first, frame.order.end()); + ASSERT_NE(second, frame.order.end()); + EXPECT_LT(first, second); // chain_second reads what chain_first writes +} + +// The common path: a lone pass needs no feature class around it. Renderer::addPass() wraps it in a +// SinglePassFeature, which behaves exactly like a hand-written one-pass feature. +TEST(RenderFeatureTest, SinglePassFeatureWrapsALonePass) { + Trace trace; + Frame frame; + trace.order = &frame.order; + SinglePassFeature feature(std::make_unique(&trace)); + + EXPECT_STREQ(feature.name(), "outline"); // takes the pass's own name + ASSERT_EQ(feature.passes().size(), 1u); + + frame.build({&feature}); + frame.graph.execute(); + + EXPECT_EQ(trace.setups, 1); + EXPECT_EQ(trace.executes, 1); + ASSERT_EQ(frame.order.size(), 2u); + EXPECT_EQ(frame.order[1], "outline"); +} + +// An undeclared (default-constructed) handle is the one hole the type system leaves: it throws +// rather than silently resolving to null at draw time. +TEST(RenderFeatureTest, UndeclaredHandleThrows) { + RenderGraph graph(nullptr); + auto& pass = graph.addPass("p"); + RenderGraphBuilder builder(graph, pass, RenderResourceHandle{}); + + RenderResourceHandle undeclared; + EXPECT_FALSE(undeclared.valid()); + EXPECT_THROW(builder.read(undeclared), std::runtime_error); +} diff --git a/ICE/Graphics/test/RenderGraphExample.h b/ICE/Graphics/test/RenderGraphExample.h new file mode 100644 index 00000000..d0175953 --- /dev/null +++ b/ICE/Graphics/test/RenderGraphExample.h @@ -0,0 +1,138 @@ +// +// Example: How to use the Render Graph System +// This demonstrates a multi-pass rendering setup with shadows and post-processing +// + +#include "RenderGraph.h" +#include "ForwardRenderer.h" + +namespace ICE { + +// Example: Setting up a render graph with multiple passes +class RenderGraphExample { +public: + void setupRenderGraph(RenderGraph& graph, uint32_t width, uint32_t height) { + + // ===== Shadow Pass ===== + auto& shadow_pass = graph.addPass("ShadowPass"); + shadow_pass.create("ShadowMap", ResourceDescriptor{ + .type = ResourceType::RenderTarget, + .width = 2048, + .height = 2048, + .format = GL_DEPTH_COMPONENT, + .is_transient = false, // Keep for main pass + .debug_name = "Shadow Map" + }); + shadow_pass.write("ShadowMap"); + shadow_pass.setExecuteCallback([this](const RenderGraphPass& pass) { + auto shadow_fb = pass.getResource("ShadowMap"); + shadow_fb->bind(); + // Render scene from light's perspective + renderShadowMap(); + }); + + // ===== Main Geometry Pass ===== + auto& geometry_pass = graph.addPass("GeometryPass"); + geometry_pass.create("SceneColor", ResourceDescriptor{ + .type = ResourceType::RenderTarget, + .width = width, + .height = height, + .format = GL_RGBA16F, + .is_transient = true, // Can be reused after post-processing + .debug_name = "Scene Color" + }); + geometry_pass.read("ShadowMap"); // Depends on shadow pass + geometry_pass.write("SceneColor"); + geometry_pass.setExecuteCallback([this](const RenderGraphPass& pass) { + auto scene_fb = pass.getResource("SceneColor"); + auto shadow_map = pass.getResource("ShadowMap"); + + scene_fb->bind(); + // Bind shadow map and render geometry + renderGeometry(shadow_map); + }); + + // ===== Bloom Extract Pass ===== + auto& bloom_extract = graph.addPass("BloomExtract"); + bloom_extract.create("BloomTexture", ResourceDescriptor{ + .type = ResourceType::RenderTarget, + .width = width / 4, // Quarter resolution + .height = height / 4, + .format = GL_RGBA16F, + .is_transient = true, + .debug_name = "Bloom" + }); + bloom_extract.read("SceneColor"); + bloom_extract.write("BloomTexture"); + bloom_extract.setExecuteCallback([this](const RenderGraphPass& pass) { + auto bloom_fb = pass.getResource("BloomTexture"); + auto scene = pass.getResource("SceneColor"); + + bloom_fb->bind(); + // Extract bright areas + extractBloom(scene); + }); + + // ===== Tone Mapping & Composite Pass ===== + auto& tonemap_pass = graph.addPass("ToneMappingPass"); + tonemap_pass.create("FinalColor", ResourceDescriptor{ + .type = ResourceType::RenderTarget, + .width = width, + .height = height, + .format = GL_RGBA8, + .is_transient = false, // Final output + .debug_name = "Final Output" + }); + tonemap_pass.read("SceneColor"); + tonemap_pass.read("BloomTexture"); + tonemap_pass.write("FinalColor"); + tonemap_pass.setExecuteCallback([this](const RenderGraphPass& pass) { + auto final_fb = pass.getResource("FinalColor"); + auto scene = pass.getResource("SceneColor"); + auto bloom = pass.getResource("BloomTexture"); + + final_fb->bind(); + // Combine scene + bloom, apply tone mapping + compositeFinal(scene, bloom); + }); + + // Compile the graph (resolves dependencies, allocates resources) + graph.compile(); + } + + void render(RenderGraph& graph) { + // Execute all passes in dependency order + graph.execute(); + + // Get final output + auto final_resource = graph.getResource("FinalColor"); + auto final_fb = final_resource->getPhysicalResourceAs(); + + // Blit to screen or use for further processing + blitToScreen(final_fb); + } + +private: + void renderShadowMap() { + // Implementation: render scene from light perspective + } + + void renderGeometry(std::shared_ptr shadow_map) { + // Implementation: render scene with shadows + } + + void extractBloom(std::shared_ptr scene) { + // Implementation: extract bright areas + } + + void compositeFinal(std::shared_ptr scene, + std::shared_ptr bloom) { + // Implementation: tone mapping + bloom composite + } + + void blitToScreen(std::shared_ptr fb) { + // Implementation: blit to default framebuffer + } +}; + +} // namespace ICE diff --git a/ICE/Graphics/test/RenderGraphPoolingTest.cpp b/ICE/Graphics/test/RenderGraphPoolingTest.cpp new file mode 100644 index 00000000..99f13d8e --- /dev/null +++ b/ICE/Graphics/test/RenderGraphPoolingTest.cpp @@ -0,0 +1,319 @@ +#include + +#include +#include +#include + +#include "PerspectiveCamera.h" +#include "RenderFeature.h" + +using namespace ICE; + +// Exercises T5's allocation behaviour: the graph compiles once and pools transient resources across +// rebuilds, and it can allocate a Texture2D from a descriptor. A counting factory stands in for the +// backend, so "how many framebuffers did we allocate" is directly observable without GL. + +namespace { + +class StubFramebuffer : public Framebuffer { + public: + explicit StubFramebuffer(const FrameBufferFormat& fmt) : Framebuffer(fmt) {} + void bind() override { binds++; } + void unbind() override {} + void resize(int, int) override {} + int getTexture() override { return 0; } + void bindAttachment(int) const override {} + Eigen::Vector4i readPixel(int, int) override { return Eigen::Vector4i::Zero(); } + + int binds = 0; +}; + +class StubTexture : public GPUTexture { + public: + void bind(uint32_t) const override {} + int id() const override { return 1; } +}; + +// Counts what the graph asks the backend to allocate. Everything the graph never calls returns +// null. +class CountingFactory : public GraphicsFactory { + public: + std::shared_ptr createContext(const std::shared_ptr&) const override { return nullptr; } + std::shared_ptr createRendererAPI() const override { return nullptr; } + std::shared_ptr createVertexArray() const override { return nullptr; } + std::shared_ptr createVertexBuffer() const override { return nullptr; } + std::shared_ptr createIndexBuffer() const override { return nullptr; } + std::shared_ptr createUniformBuffer(size_t, size_t) const override { return nullptr; } + std::shared_ptr createShader(const Shader&) const override { return nullptr; } + std::shared_ptr createTexture2D(const Texture2D&) const override { return nullptr; } + std::shared_ptr createTextureCube(const TextureCube&) const override { return nullptr; } + + std::shared_ptr createFramebuffer(const FrameBufferFormat& format) const override { + framebuffers_created++; + return std::make_shared(format); + } + + std::shared_ptr createTexture2D(uint32_t width, uint32_t height, TextureFormat format) const override { + textures_created++; + last_texture_width = width; + last_texture_height = height; + last_texture_format = format; + return std::make_shared(); + } + + mutable int framebuffers_created = 0; + mutable int textures_created = 0; + mutable uint32_t last_texture_width = 0; + mutable uint32_t last_texture_height = 0; + mutable TextureFormat last_texture_format = TextureFormat::RGBA8; +}; + +// A pass that creates one off-screen target of the given size and writes it, so the target is what +// the graph binds before execute(). +class TargetPass : public IRenderPass { + public: + TargetPass(uint32_t size, bool* executed) : m_size(size), m_executed(executed) {} + const char* name() const override { return "target_pass"; } + void setup(RenderGraphBuilder& builder) override { + m_own = builder.create({.width = m_size, .height = m_size, .debug_name = "own_target"}); + builder.write(m_own); + builder.write(builder.sceneColor()); // stay live: contribute to the output + } + void execute(PassContext& ctx) override { + *m_executed = true; + seen_target = ctx.target(); + resolved_own = ctx.get(m_own); + } + + std::shared_ptr seen_target; + std::shared_ptr resolved_own; + + private: + uint32_t m_size; + bool* m_executed; + RenderResourceHandle m_own; +}; + +// Builds a graph the way ForwardRenderer::rebuildGraph() does, over a counting factory. +struct Frame { + CountingFactory factory; + RenderGraph graph{std::shared_ptr(&factory, [](GraphicsFactory*) {})}; + std::shared_ptr scene_fb = std::make_shared(FrameBufferFormat{4, 4, 1}); + + void build(IRenderPass* pass, IPassDrawer* drawer = nullptr) { + graph.reset(); + auto scene_color = graph.importResource("scene_color", scene_fb); + auto& geometry = graph.addPass("geometry"); + geometry.write("scene_color"); + geometry.setExecuteCallback([](const RenderGraphPass&) {}); + if (pass) { + addPassToGraph(graph, *pass, scene_color, nullptr, drawer); + } + graph.setOutput(scene_color); + graph.compile(); + } +}; + +// Stands in for the renderer's half of the draw seam. +class RecordingDrawer : public IPassDrawer { + public: + void drawScene(Camera& camera, ShaderProgram* override_shader) override { + draw_scene_calls++; + last_camera = &camera; + last_override = override_shader; + } + void fullscreen(ShaderProgram* shader) override { + fullscreen_calls++; + last_fullscreen_shader = shader; + } + + int draw_scene_calls = 0; + int fullscreen_calls = 0; + Camera* last_camera = nullptr; + ShaderProgram* last_override = nullptr; + ShaderProgram* last_fullscreen_shader = nullptr; +}; + +// The acceptance shape: a shadow-style pass whose entire body is one drawScene() call from the +// light's point of view with a depth shader. +class ShadowStylePass : public IRenderPass { + public: + ShadowStylePass(Camera* light_camera, ShaderProgram* depth_shader) : m_light(light_camera), m_depth(depth_shader) {} + const char* name() const override { return "shadow"; } + void setup(RenderGraphBuilder& builder) override { + m_map = builder.create({.width = 2048, .height = 2048, .debug_name = "shadow_map"}); + builder.write(m_map); + builder.write(builder.sceneColor()); // stay live + } + void execute(PassContext& ctx) override { ctx.drawScene(*m_light, m_depth); } + + private: + Camera* m_light; + ShaderProgram* m_depth; + RenderResourceHandle m_map; +}; +} // namespace + +// A shadow-style pass authored purely through PassContext::drawScene(lightCamera, depthShader): +// no target binding, no submission plumbing in the pass body. +TEST(RenderGraphPoolingTest, ShadowStylePassDrawsSceneFromItsOwnCamera) { + PerspectiveCamera light_camera(90, 1.0f, 0.1f, 100.f); + // Only ever stored and compared by the drawer, never dereferenced. + auto* depth_shader = reinterpret_cast(0x1234); + ShadowStylePass pass(&light_camera, depth_shader); + RecordingDrawer drawer; + Frame frame; + + frame.build(&pass, &drawer); + frame.graph.execute(); + + EXPECT_EQ(drawer.draw_scene_calls, 1); + EXPECT_EQ(drawer.last_camera, &light_camera); // drew from the light, not the frame camera + EXPECT_EQ(drawer.last_override, depth_shader); + // Its 2048x2048 shadow map was allocated from the descriptor and bound for it. + EXPECT_EQ(frame.factory.framebuffers_created, 1); +} + +// fullscreen() forwards to the renderer's present-quad draw -- the post-process primitive. +TEST(RenderGraphPoolingTest, FullscreenForwardsToTheDrawer) { + class PostPass : public IRenderPass { + public: + explicit PostPass(ShaderProgram* shader) : m_shader(shader) {} + const char* name() const override { return "post"; } + void setup(RenderGraphBuilder& builder) override { builder.write(builder.sceneColor()); } + void execute(PassContext& ctx) override { ctx.fullscreen(m_shader); } + + private: + ShaderProgram* m_shader; + }; + + auto* shader = reinterpret_cast(0x5678); + PostPass pass(shader); + RecordingDrawer drawer; + Frame frame; + + frame.build(&pass, &drawer); + frame.graph.execute(); + + EXPECT_EQ(drawer.fullscreen_calls, 1); + EXPECT_EQ(drawer.last_fullscreen_shader, shader); +} + +// The headline T5 property: executing many frames off one compile allocates nothing further. This +// is what the old per-frame reset()/compile() broke the moment a pass called create(). +TEST(RenderGraphPoolingTest, ExecutingManyFramesAllocatesNothingFurther) { + bool executed = false; + TargetPass pass(8, &executed); + Frame frame; + + frame.build(&pass); + const int after_compile = frame.factory.framebuffers_created; + EXPECT_EQ(after_compile, 1); // the pass's own target (scene_color was imported, not allocated) + + for (int i = 0; i < 60; ++i) { + frame.graph.execute(); + } + + EXPECT_TRUE(executed); + EXPECT_EQ(frame.factory.framebuffers_created, after_compile); // flat across 60 frames +} + +// What gets presented comes from the graph's declared output, not from whichever pass the renderer +// assumes produced it. (Today scene_color is the imported geometry framebuffer, so they coincide -- +// this pins the resolution path so they can diverge later without silently presenting the wrong +// target.) +TEST(RenderGraphPoolingTest, OutputResolvesToTheDeclaredResource) { + Frame frame; + frame.build(nullptr); + + EXPECT_EQ(frame.graph.output(), frame.scene_fb); +} + +// A graph with no declared output has nothing to present, rather than guessing. +TEST(RenderGraphPoolingTest, OutputIsNullWhenUndeclared) { + Frame frame; + frame.graph.reset(); + frame.graph.compile(); + + EXPECT_EQ(frame.graph.output(), nullptr); +} + +// A rebuild whose descriptors are unchanged reuses the pooled resource instead of allocating. +TEST(RenderGraphPoolingTest, RebuildWithSameDescriptorsReusesPooledResource) { + bool executed = false; + TargetPass pass(8, &executed); + Frame frame; + + frame.build(&pass); + ASSERT_EQ(frame.factory.framebuffers_created, 1); + frame.graph.execute(); + auto first = pass.resolved_own; + + frame.build(&pass); // reset() + recompile, same sizes + frame.graph.execute(); + + EXPECT_EQ(frame.factory.framebuffers_created, 1); // nothing new allocated + EXPECT_EQ(pass.resolved_own, first); // literally the same framebuffer back +} + +// A resize changes the descriptor, so the pooled resource no longer matches: a new one is +// allocated and the stale size is dropped rather than pinned forever. +TEST(RenderGraphPoolingTest, RebuildWithChangedDescriptorAllocatesAndDropsStale) { + bool executed = false; + TargetPass small(8, &executed); + TargetPass large(16, &executed); + Frame frame; + + frame.build(&small); + ASSERT_EQ(frame.factory.framebuffers_created, 1); + + frame.build(&large); // "resize": different descriptor + EXPECT_EQ(frame.factory.framebuffers_created, 2); + + // The 8x8 was dropped at the end of the previous compile, so going back allocates afresh + // rather than resurrecting it -- the pool never grows unboundedly. + frame.build(&small); + EXPECT_EQ(frame.factory.framebuffers_created, 3); +} + +// The pass's first framebuffer write is bound before execute() and handed back as ctx.target(), so +// a pass body doesn't bind anything itself. +TEST(RenderGraphPoolingTest, PassTargetIsBoundBeforeExecute) { + bool executed = false; + TargetPass pass(8, &executed); + Frame frame; + + frame.build(&pass); + frame.graph.execute(); + + ASSERT_TRUE(pass.seen_target != nullptr); + EXPECT_EQ(pass.seen_target, pass.resolved_own); // target() == the framebuffer it created + auto* own = static_cast(pass.seen_target.get()); + EXPECT_GE(own->binds, 1); // the graph bound it for us +} + +// Descriptor-based Texture2D allocation (the "allocateResources only handles RenderTarget" TODO). +TEST(RenderGraphPoolingTest, AllocatesTexture2DFromDescriptor) { + class TexturePass : public IRenderPass { + public: + const char* name() const override { return "texture_pass"; } + void setup(RenderGraphBuilder& builder) override { + handle = builder.create({.width = 64, .height = 32, .format = TextureFormat::Float16, .debug_name = "ao"}); + builder.write(builder.sceneColor()); + } + void execute(PassContext& ctx) override { resolved = ctx.get(handle); } + RenderResourceHandle handle; + std::shared_ptr resolved; + }; + + TexturePass pass; + Frame frame; + frame.build(&pass); + frame.graph.execute(); + + EXPECT_EQ(frame.factory.textures_created, 1); + EXPECT_EQ(frame.factory.last_texture_width, 64u); + EXPECT_EQ(frame.factory.last_texture_height, 32u); + EXPECT_EQ(frame.factory.last_texture_format, TextureFormat::Float16); + EXPECT_NE(pass.resolved, nullptr); // no longer virtual +} diff --git a/ICE/Graphics/test/RenderGraphTest.cpp b/ICE/Graphics/test/RenderGraphTest.cpp new file mode 100644 index 00000000..ad8c1edc --- /dev/null +++ b/ICE/Graphics/test/RenderGraphTest.cpp @@ -0,0 +1,95 @@ +#include + +#include +#include +#include + +#include "RenderGraph.h" + +using namespace ICE; + +// These tests exercise the graph's compile logic (dependency ordering, cycle detection, culling) +// only. A null GraphicsFactory is fine because no pass creates a RenderTarget -- the only resource +// type that touches the factory -- so no GL is invoked. + +static size_t indexOf(const std::vector& v, const std::string& s) { + return static_cast(std::find(v.begin(), v.end(), s) - v.begin()); +} + +TEST(RenderGraphTest, ExecutesDependenciesFirst) { + RenderGraph graph(nullptr); + std::vector order; + + auto& a = graph.addPass("A"); + a.write("x"); + a.setExecuteCallback([&](const RenderGraphPass&) { order.push_back("A"); }); + + auto& b = graph.addPass("B"); + b.read("x"); + b.write("y"); + b.setExecuteCallback([&](const RenderGraphPass&) { order.push_back("B"); }); + + auto& c = graph.addPass("C"); + c.read("y"); + c.setExecuteCallback([&](const RenderGraphPass&) { order.push_back("C"); }); + + graph.compile(); + graph.execute(); + + ASSERT_EQ(order.size(), 3u); + EXPECT_LT(indexOf(order, "A"), indexOf(order, "B")); // B reads what A writes + EXPECT_LT(indexOf(order, "B"), indexOf(order, "C")); // C reads what B writes +} + +TEST(RenderGraphTest, DetectsCycles) { + RenderGraph graph(nullptr); + auto& a = graph.addPass("A"); + a.read("y"); + a.write("x"); + auto& b = graph.addPass("B"); + b.read("x"); + b.write("y"); + EXPECT_THROW(graph.compile(), std::runtime_error); +} + +TEST(RenderGraphTest, CullsPassesNotContributingToOutput) { + RenderGraph graph(nullptr); + std::vector ran; + + auto& geometry = graph.addPass("geometry"); + geometry.write("color"); + geometry.setExecuteCallback([&](const RenderGraphPass&) { ran.push_back("geometry"); }); + + auto& present = graph.addPass("present"); + present.read("color"); + present.write("backbuffer"); + present.setExecuteCallback([&](const RenderGraphPass&) { ran.push_back("present"); }); + + auto& debug = graph.addPass("debug"); // produces something nobody reads and isn't the output + debug.write("debug_overlay"); + debug.setExecuteCallback([&](const RenderGraphPass&) { ran.push_back("debug"); }); + + graph.setOutput("backbuffer"); + graph.compile(); + graph.execute(); + + EXPECT_NE(std::find(ran.begin(), ran.end(), "geometry"), ran.end()); + EXPECT_NE(std::find(ran.begin(), ran.end(), "present"), ran.end()); + EXPECT_EQ(std::find(ran.begin(), ran.end(), "debug"), ran.end()); // culled +} + +TEST(RenderGraphTest, NoOutputKeepsAllPasses) { + RenderGraph graph(nullptr); + int count = 0; + auto& a = graph.addPass("A"); + a.write("x"); + a.setExecuteCallback([&](const RenderGraphPass&) { count++; }); + auto& b = graph.addPass("B"); + b.write("unused"); + b.setExecuteCallback([&](const RenderGraphPass&) { count++; }); + + graph.compile(); // no setOutput() -> nothing is culled + graph.execute(); + + EXPECT_EQ(count, 2); +} diff --git a/ICE/Graphics/test/SortKeyTest.cpp b/ICE/Graphics/test/SortKeyTest.cpp new file mode 100644 index 00000000..d9066f6d --- /dev/null +++ b/ICE/Graphics/test/SortKeyTest.cpp @@ -0,0 +1,52 @@ +#include + +#include "RenderCommand.h" + +using namespace ICE; + +// The sort key is now derived from stable asset UIDs, so the same draw produces the same key every +// run (previously it hashed heap addresses). computeSortKey needs no GPU objects. + +TEST(SortKeyTest, DeterministicForSameInputs) { + RenderCommand a, b; + a.computeSortKey(/*transparent*/ false, /*depth_sq*/ 10.0f, /*shader*/ 5, /*material*/ 3); + b.computeSortKey(false, 10.0f, 5, 3); + EXPECT_EQ(a.sort_key, b.sort_key); +} + +TEST(SortKeyTest, DiffersByMaterialAndShader) { + RenderCommand base, other_mat, other_shader; + base.computeSortKey(false, 10.0f, 5, 3); + other_mat.computeSortKey(false, 10.0f, 5, 4); + other_shader.computeSortKey(false, 10.0f, 6, 3); + EXPECT_NE(base.sort_key, other_mat.sort_key); + EXPECT_NE(base.sort_key, other_shader.sort_key); +} + +TEST(SortKeyTest, OpaqueGroupsByShaderThenMaterial) { + // Same shader: material orders within the group. + RenderCommand m3, m4; + m3.computeSortKey(false, 10.0f, 7, 3); + m4.computeSortKey(false, 10.0f, 7, 4); + EXPECT_LT(m3.sort_key, m4.sort_key); + + // Shader dominates material (it occupies the higher bits), so all of shader 1's draws sort + // before any of shader 2's regardless of material. + RenderCommand s1, s2; + s1.computeSortKey(false, 10.0f, 1, 0x1FFFFF); + s2.computeSortKey(false, 10.0f, 2, 0); + EXPECT_LT(s1.sort_key, s2.sort_key); +} + +TEST(SortKeyTest, TransparentSortsAfterOpaqueAndBackToFront) { + RenderCommand opaque, transparent; + opaque.computeSortKey(false, 10.0f, 1, 1); + transparent.computeSortKey(true, 10.0f, 1, 1); + EXPECT_LT(opaque.sort_key, transparent.sort_key); // transparent bit set -> larger key -> later + + // Transparent draws go back-to-front: a farther fragment gets a smaller key (drawn first). + RenderCommand near_t, far_t; + near_t.computeSortKey(true, 5.0f, 1, 1); + far_t.computeSortKey(true, 50.0f, 1, 1); + EXPECT_LT(far_t.sort_key, near_t.sort_key); +} diff --git a/ICE/GraphicsAPI/OpenGL/include/OpenGLBuffers.h b/ICE/GraphicsAPI/OpenGL/include/OpenGLBuffers.h index 57de3812..71ac4165 100644 --- a/ICE/GraphicsAPI/OpenGL/include/OpenGLBuffers.h +++ b/ICE/GraphicsAPI/OpenGL/include/OpenGLBuffers.h @@ -18,6 +18,11 @@ class OpenGLVertexBuffer : public VertexBuffer { uint32_t getSize() const override; OpenGLVertexBuffer() : OpenGLVertexBuffer(0) {} OpenGLVertexBuffer(uint32_t size); + ~OpenGLVertexBuffer() override; + + // Owns a GL buffer name; copying would double-delete it. + OpenGLVertexBuffer(const OpenGLVertexBuffer &) = delete; + OpenGLVertexBuffer &operator=(const OpenGLVertexBuffer &) = delete; private: GLuint id; @@ -32,6 +37,10 @@ class OpenGLIndexBuffer : public IndexBuffer { void putData(const void *data, uint32_t size) override; OpenGLIndexBuffer(); + ~OpenGLIndexBuffer() override; + + OpenGLIndexBuffer(const OpenGLIndexBuffer &) = delete; + OpenGLIndexBuffer &operator=(const OpenGLIndexBuffer &) = delete; private: GLuint id; @@ -47,6 +56,9 @@ class OpenGLUniformBuffer : public UniformBuffer { OpenGLUniformBuffer(uint32_t _size, uint32_t _binding); ~OpenGLUniformBuffer() override; + OpenGLUniformBuffer(const OpenGLUniformBuffer &) = delete; + OpenGLUniformBuffer &operator=(const OpenGLUniformBuffer &) = delete; + private: GLuint id; uint32_t size; diff --git a/ICE/GraphicsAPI/OpenGL/include/OpenGLFactory.h b/ICE/GraphicsAPI/OpenGL/include/OpenGLFactory.h index f1d65194..69c049d8 100644 --- a/ICE/GraphicsAPI/OpenGL/include/OpenGLFactory.h +++ b/ICE/GraphicsAPI/OpenGL/include/OpenGLFactory.h @@ -42,6 +42,10 @@ class OpenGLFactory : public GraphicsFactory { std::shared_ptr createTexture2D(const Texture2D& texture) const override { return std::make_shared(texture); } + std::shared_ptr createTexture2D(uint32_t width, uint32_t height, TextureFormat format) const override { + return std::make_shared(width, height, format); + } + std::shared_ptr createTextureCube(const TextureCube& texture) const override { return std::make_shared(texture); } }; } // namespace ICE \ No newline at end of file diff --git a/ICE/GraphicsAPI/OpenGL/include/OpenGLFramebuffer.h b/ICE/GraphicsAPI/OpenGL/include/OpenGLFramebuffer.h index b8bbc03d..1ce1a067 100644 --- a/ICE/GraphicsAPI/OpenGL/include/OpenGLFramebuffer.h +++ b/ICE/GraphicsAPI/OpenGL/include/OpenGLFramebuffer.h @@ -13,6 +13,11 @@ namespace ICE { class OpenGLFramebuffer : public Framebuffer { public: OpenGLFramebuffer(FrameBufferFormat fmt); + ~OpenGLFramebuffer() override; + + // Owns GL framebuffer/texture/renderbuffer names; copying would double-delete them. + OpenGLFramebuffer(const OpenGLFramebuffer &) = delete; + OpenGLFramebuffer &operator=(const OpenGLFramebuffer &) = delete; void bind() override; diff --git a/ICE/GraphicsAPI/OpenGL/include/OpenGLRendererAPI.h b/ICE/GraphicsAPI/OpenGL/include/OpenGLRendererAPI.h index b00b09fd..58d54941 100644 --- a/ICE/GraphicsAPI/OpenGL/include/OpenGLRendererAPI.h +++ b/ICE/GraphicsAPI/OpenGL/include/OpenGLRendererAPI.h @@ -21,6 +21,8 @@ namespace ICE { void renderVertexArray(const std::shared_ptr &va) const override; + void renderVertexArrayInstanced(const std::shared_ptr &va, uint32_t instance_count) const override; + void flush() const override; void finish() const override; @@ -31,9 +33,25 @@ namespace ICE { void setDepthMask(bool enable) const override; + void setDepthFunc(DepthFunc func) const override; + + void setBlend(bool enable) const override; + + void beginGPUTimer() const override; + double endGPUTimer() const override; + void setBackfaceCulling(bool enable) const override; void checkAndLogErrors() const override; + + private: + // Double-buffered GL_TIME_ELAPSED query state (GPU state, hence mutable behind the + // const API). + mutable GLuint m_gpu_query[2] = {0, 0}; + mutable bool m_gpu_query_used[2] = {false, false}; + mutable int m_gpu_query_idx = 0; + mutable bool m_gpu_query_init = false; + mutable double m_last_gpu_ms = 0.0; }; } diff --git a/ICE/GraphicsAPI/OpenGL/include/OpenGLShader.h b/ICE/GraphicsAPI/OpenGL/include/OpenGLShader.h index 2b68236b..a2a7fe5b 100644 --- a/ICE/GraphicsAPI/OpenGL/include/OpenGLShader.h +++ b/ICE/GraphicsAPI/OpenGL/include/OpenGLShader.h @@ -17,6 +17,11 @@ namespace ICE { class OpenGLShader : public ShaderProgram { public: explicit OpenGLShader(const Shader &shader_asset); + ~OpenGLShader() override; + + // Owns a GL program object; copying would double-delete it. + OpenGLShader(const OpenGLShader &) = delete; + OpenGLShader &operator=(const OpenGLShader &) = delete; void bind() const override; @@ -28,20 +33,24 @@ class OpenGLShader : public ShaderProgram { void loadFloat(const std::string &name, float v) override; - void loadFloat2(const std::string &name, Eigen::Vector2f vec) override; + void loadFloat2(const std::string &name, const Eigen::Vector2f &vec) override; + + void loadFloat3(const std::string &name, const Eigen::Vector3f &vec) override; - void loadFloat3(const std::string &name, Eigen::Vector3f vec) override; + void loadFloat4(const std::string &name, const Eigen::Vector4f &vec) override; - void loadFloat4(const std::string &name, Eigen::Vector4f vec) override; + void loadMat4(const std::string &name, const Eigen::Matrix4f &mat) override; - void loadMat4(const std::string &name, Eigen::Matrix4f mat) override; + void loadMat4v(const std::string &name, const Eigen::Matrix4f *data, uint32_t count) override; private: GLint getLocation(const std::string &name); - void compileAndAttachStage(ShaderStage stage, const std::string &source); + // Compiles and attaches a stage, returning its GL shader name so the caller can + // detach and delete it after linking. + GLuint compileAndAttachStage(ShaderStage stage, const std::string &source); - constexpr GLenum stageToGLStage(ShaderStage stage); + GLenum stageToGLStage(ShaderStage stage); uint32_t m_programID; std::unordered_map m_locations; diff --git a/ICE/GraphicsAPI/OpenGL/include/OpenGLTexture.h b/ICE/GraphicsAPI/OpenGL/include/OpenGLTexture.h index ba3eb877..0524aee9 100644 --- a/ICE/GraphicsAPI/OpenGL/include/OpenGLTexture.h +++ b/ICE/GraphicsAPI/OpenGL/include/OpenGLTexture.h @@ -67,6 +67,13 @@ constexpr int textureFormatToChannels(TextureFormat format) { class OpenGLTexture2D : public GPUTexture { public: OpenGLTexture2D(const Texture2D &tex); + // Storage-only texture with no CPU data, for render-graph transient resources. + OpenGLTexture2D(uint32_t width, uint32_t height, TextureFormat format); + ~OpenGLTexture2D() override; + + // Owns a GL texture name; copying would double-delete it. + OpenGLTexture2D(const OpenGLTexture2D &) = delete; + OpenGLTexture2D &operator=(const OpenGLTexture2D &) = delete; void bind(uint32_t slot) const override; int id() const override; @@ -78,6 +85,10 @@ class OpenGLTexture2D : public GPUTexture { class OpenGLTextureCube : public GPUTexture { public: OpenGLTextureCube(const TextureCube &tex); + ~OpenGLTextureCube() override; + + OpenGLTextureCube(const OpenGLTextureCube &) = delete; + OpenGLTextureCube &operator=(const OpenGLTextureCube &) = delete; void bind(uint32_t slot) const override; int id() const override; diff --git a/ICE/GraphicsAPI/OpenGL/include/OpenGLVertexArray.h b/ICE/GraphicsAPI/OpenGL/include/OpenGLVertexArray.h index d1f648ed..00bbd6ce 100644 --- a/ICE/GraphicsAPI/OpenGL/include/OpenGLVertexArray.h +++ b/ICE/GraphicsAPI/OpenGL/include/OpenGLVertexArray.h @@ -15,6 +15,11 @@ namespace ICE { class OpenGLVertexArray : public VertexArray { public: OpenGLVertexArray(); + ~OpenGLVertexArray() override; + + // Owns a GL vertex-array name; copying would double-delete it. + OpenGLVertexArray(const OpenGLVertexArray&) = delete; + OpenGLVertexArray& operator=(const OpenGLVertexArray&) = delete; void bind() const override; @@ -24,6 +29,8 @@ namespace ICE { void pushVertexBuffer(const std::shared_ptr& buffer, int position, int size) override; + void pushVertexBuffer(const std::shared_ptr& buffer, int position, int size, int divisor) override; + void setIndexBuffer(const std::shared_ptr& buffer) override; int getIndexCount() const override; diff --git a/ICE/GraphicsAPI/OpenGL/src/OpenGLBuffers.cpp b/ICE/GraphicsAPI/OpenGL/src/OpenGLBuffers.cpp index d54faa2a..848eb3d9 100644 --- a/ICE/GraphicsAPI/OpenGL/src/OpenGLBuffers.cpp +++ b/ICE/GraphicsAPI/OpenGL/src/OpenGLBuffers.cpp @@ -34,12 +34,20 @@ OpenGLIndexBuffer::OpenGLIndexBuffer() { glGenBuffers(1, &id); } +OpenGLIndexBuffer::~OpenGLIndexBuffer() { + glDeleteBuffers(1, &id); +} + /////////////////////////////////// VERTEX BUFFER ////////////////////////////////// OpenGLVertexBuffer::OpenGLVertexBuffer(uint32_t size) : size(size) { glGenBuffers(1, &id); } +OpenGLVertexBuffer::~OpenGLVertexBuffer() { + glDeleteBuffers(1, &id); +} + void OpenGLVertexBuffer::bind() const { glBindBuffer(GL_ARRAY_BUFFER, this->id); } diff --git a/ICE/GraphicsAPI/OpenGL/src/OpenGLFramebuffer.cpp b/ICE/GraphicsAPI/OpenGL/src/OpenGLFramebuffer.cpp index c5b67d5a..1e32c87b 100644 --- a/ICE/GraphicsAPI/OpenGL/src/OpenGLFramebuffer.cpp +++ b/ICE/GraphicsAPI/OpenGL/src/OpenGLFramebuffer.cpp @@ -28,7 +28,9 @@ void OpenGLFramebuffer::resize(int width, int height) { glBindFramebuffer(GL_FRAMEBUFFER, uid); glBindTexture(GL_TEXTURE_2D, texture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL); + // Keep the RGBA8 format from construction: the resize path used to recreate the color + // attachment as unsized GL_RGB, silently dropping the alpha channel after the first resize. + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); @@ -38,6 +40,12 @@ void OpenGLFramebuffer::resize(int width, int height) { glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, height); glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, depth); + + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { + Logger::Log(Logger::FATAL, "Graphics", "Framebuffer incomplete after resize (%dx%d)", width, height); + } + // Leave this framebuffer bound: callers (e.g. the editor picking pass) bind() then + // resize() and expect to keep rendering into it. } int OpenGLFramebuffer::getTexture() { @@ -50,7 +58,7 @@ OpenGLFramebuffer::OpenGLFramebuffer(FrameBufferFormat fmt) : Framebuffer(fmt) { glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, fmt.width, fmt.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, fmt.width, fmt.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); @@ -69,15 +77,23 @@ OpenGLFramebuffer::OpenGLFramebuffer(FrameBufferFormat fmt) : Framebuffer(fmt) { unbind(); } +OpenGLFramebuffer::~OpenGLFramebuffer() { + glDeleteFramebuffers(1, &uid); + glDeleteTextures(1, &texture); + glDeleteRenderbuffers(1, &depth); +} + void OpenGLFramebuffer::bindAttachment(int slot) const { glActiveTexture(GL_TEXTURE0 + slot); glBindTexture(GL_TEXTURE_2D, texture); } Eigen::Vector4i OpenGLFramebuffer::readPixel(int x, int y) { - glFlush(); + // Bind this framebuffer's read target explicitly rather than assuming it is current. + glBindFramebuffer(GL_READ_FRAMEBUFFER, uid); + glReadBuffer(GL_COLOR_ATTACHMENT0); glFinish(); - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_PACK_ALIGNMENT, 1); unsigned char data[4]; auto pixels = Eigen::Vector4i(); glReadPixels(x, format.height - y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, data); @@ -85,6 +101,7 @@ Eigen::Vector4i OpenGLFramebuffer::readPixel(int x, int y) { pixels.y() = data[1]; pixels.z() = data[2]; pixels.w() = data[3]; + glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); return pixels; } } // namespace ICE \ No newline at end of file diff --git a/ICE/GraphicsAPI/OpenGL/src/OpenGLRendererAPI.cpp b/ICE/GraphicsAPI/OpenGL/src/OpenGLRendererAPI.cpp index c05bde14..0f95184b 100644 --- a/ICE/GraphicsAPI/OpenGL/src/OpenGLRendererAPI.cpp +++ b/ICE/GraphicsAPI/OpenGL/src/OpenGLRendererAPI.cpp @@ -25,6 +25,10 @@ void OpenGLRendererAPI::renderVertexArray(const std::shared_ptr &va glDrawElements(GL_TRIANGLES, va->getIndexCount(), GL_UNSIGNED_INT, 0); } +void OpenGLRendererAPI::renderVertexArrayInstanced(const std::shared_ptr &va, uint32_t instance_count) const { + glDrawElementsInstanced(GL_TRIANGLES, va->getIndexCount(), GL_UNSIGNED_INT, 0, instance_count); +} + void OpenGLRendererAPI::initialize() const { glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); @@ -54,6 +58,42 @@ void OpenGLRendererAPI::setDepthMask(bool enable) const { glDepthMask(enable ? GL_TRUE : GL_FALSE); } +void OpenGLRendererAPI::setDepthFunc(DepthFunc func) const { + glDepthFunc(func == DepthFunc::LEqual ? GL_LEQUAL : GL_LESS); +} + +void OpenGLRendererAPI::setBlend(bool enable) const { + if (enable) { + glEnable(GL_BLEND); + } else { + glDisable(GL_BLEND); + } +} + +void OpenGLRendererAPI::beginGPUTimer() const { + if (!m_gpu_query_init) { + glGenQueries(2, m_gpu_query); + m_gpu_query_init = true; + } + glBeginQuery(GL_TIME_ELAPSED, m_gpu_query[m_gpu_query_idx]); +} + +double OpenGLRendererAPI::endGPUTimer() const { + glEndQuery(GL_TIME_ELAPSED); + m_gpu_query_used[m_gpu_query_idx] = true; + + // Read the other buffer's result (last frame's query): one frame old, so it's ready and + // reading it doesn't stall. + const int other = 1 - m_gpu_query_idx; + if (m_gpu_query_used[other]) { + GLuint64 elapsed_ns = 0; + glGetQueryObjectui64v(m_gpu_query[other], GL_QUERY_RESULT, &elapsed_ns); + m_last_gpu_ms = static_cast(elapsed_ns) / 1.0e6; + } + m_gpu_query_idx = other; + return m_last_gpu_ms; +} + void OpenGLRendererAPI::setBackfaceCulling(bool enable) const { if (enable) { glEnable(GL_CULL_FACE); @@ -63,9 +103,21 @@ void OpenGLRendererAPI::setBackfaceCulling(bool enable) const { } void OpenGLRendererAPI::checkAndLogErrors() const { - unsigned int err; + // glDebugMessageCallback would be nicer but it is not core until GL 4.3; the engine + // targets 4.1 (macOS caps there), so decode the enum to a readable name instead of + // logging a bare number. Callers should only drain this in debug builds. + GLenum err; while ((err = glGetError()) != GL_NO_ERROR) { - Logger::Log(Logger::ERROR, "Graphics", "OpenGL Error %d", err); + const char *name; + switch (err) { + case GL_INVALID_ENUM: name = "GL_INVALID_ENUM"; break; + case GL_INVALID_VALUE: name = "GL_INVALID_VALUE"; break; + case GL_INVALID_OPERATION: name = "GL_INVALID_OPERATION"; break; + case GL_INVALID_FRAMEBUFFER_OPERATION: name = "GL_INVALID_FRAMEBUFFER_OPERATION"; break; + case GL_OUT_OF_MEMORY: name = "GL_OUT_OF_MEMORY"; break; + default: name = "GL_UNKNOWN"; break; + } + Logger::Log(Logger::ERROR, "Graphics", "OpenGL error: %s (0x%x)", name, err); } } } // namespace ICE \ No newline at end of file diff --git a/ICE/GraphicsAPI/OpenGL/src/OpenGLShader.cpp b/ICE/GraphicsAPI/OpenGL/src/OpenGLShader.cpp index 8b637a2b..2b6e4ed4 100644 --- a/ICE/GraphicsAPI/OpenGL/src/OpenGLShader.cpp +++ b/ICE/GraphicsAPI/OpenGL/src/OpenGLShader.cpp @@ -15,8 +15,9 @@ OpenGLShader::OpenGLShader(const Shader &shader_asset) { m_programID = glCreateProgram(); Logger::Log(Logger::VERBOSE, "Graphics", "Compiling shader..."); + std::vector stage_shaders; for (const auto& [stage, source] : shader_asset.getStageSources()) { - compileAndAttachStage(stage, source.second); + stage_shaders.push_back(compileAndAttachStage(stage, source.second)); } glLinkProgram(m_programID); @@ -31,6 +32,19 @@ OpenGLShader::OpenGLShader(const Shader &shader_asset) { glGetProgramInfoLog(m_programID, maxLength, &maxLength, &errorLog[0]); Logger::Log(Logger::FATAL, "Graphics", "Shader linking error: %s", errorLog.data()); } + + // Stage objects are no longer needed once linked into the program. Skip 0, which + // marks a stage that failed to compile (and so was never attached). + for (GLuint shader : stage_shaders) { + if (shader != 0) { + glDetachShader(m_programID, shader); + glDeleteShader(shader); + } + } +} + +OpenGLShader::~OpenGLShader() { + glDeleteProgram(m_programID); } void OpenGLShader::bind() const { @@ -53,28 +67,37 @@ void OpenGLShader::loadFloat(const std::string &name, float v) { glUniform1f(getLocation(name), v); } -void OpenGLShader::loadFloat2(const std::string &name, Eigen::Vector2f vec) { +void OpenGLShader::loadFloat2(const std::string &name, const Eigen::Vector2f &vec) { glUniform2f(getLocation(name), vec.x(), vec.y()); } -void OpenGLShader::loadFloat3(const std::string &name, Eigen::Vector3f vec) { +void OpenGLShader::loadFloat3(const std::string &name, const Eigen::Vector3f &vec) { glUniform3f(getLocation(name), vec.x(), vec.y(), vec.z()); } -void OpenGLShader::loadFloat4(const std::string &name, Eigen::Vector4f vec) { +void OpenGLShader::loadFloat4(const std::string &name, const Eigen::Vector4f &vec) { glUniform4f(getLocation(name), vec.x(), vec.y(), vec.z(), vec.w()); } -void OpenGLShader::loadMat4(const std::string &name, Eigen::Matrix4f mat) { +void OpenGLShader::loadMat4(const std::string &name, const Eigen::Matrix4f &mat) { glUniformMatrix4fv(getLocation(name), 1, GL_FALSE, mat.data()); } +void OpenGLShader::loadMat4v(const std::string &name, const Eigen::Matrix4f *data, uint32_t count) { + // std::vector / a Matrix4f array is contiguous, column-major -- exactly what + // glUniformMatrix4fv expects for a mat4[] uniform, so one call uploads the whole array. + glUniformMatrix4fv(getLocation(name), count, GL_FALSE, data->data()); +} + GLint OpenGLShader::getLocation(const std::string &name) { - if (!m_locations.contains(name)) { - GLint location = glGetUniformLocation(m_programID, name.c_str()); - m_locations[name] = static_cast(location); + // Single hash lookup on the hot path (was contains + operator[] insert + operator[]). + auto it = m_locations.find(name); + if (it != m_locations.end()) { + return static_cast(it->second); } - return m_locations[name]; + GLint location = glGetUniformLocation(m_programID, name.c_str()); + m_locations.emplace(name, static_cast(location)); + return location; } bool compileShader(GLenum type, const std::string &source, GLint *shader) { @@ -100,17 +123,22 @@ bool compileShader(GLenum type, const std::string &source, GLint *shader) { return compileStatus == GL_TRUE; } -void OpenGLShader::compileAndAttachStage(ShaderStage stage, const std::string &source) { +GLuint OpenGLShader::compileAndAttachStage(ShaderStage stage, const std::string &source) { GLint shader; Logger::Log(Logger::VERBOSE, "Graphics", "\t + Compiling shader stage..."); if (!compileShader(stageToGLStage(stage), source, &shader)) { + // Don't attach a stage that failed to compile: attaching it only guarantees the + // link fails too, producing a broken-but-alive program. Return 0 to signal failure. Logger::Log(Logger::FATAL, "Graphics", "Error while compiling shader stage"); + glDeleteShader(shader); + return 0; } glAttachShader(m_programID, shader); + return static_cast(shader); } -constexpr GLenum OpenGLShader::stageToGLStage(ShaderStage stage) { +GLenum OpenGLShader::stageToGLStage(ShaderStage stage) { switch (stage) { case ShaderStage::Vertex: return GL_VERTEX_SHADER; @@ -125,6 +153,8 @@ constexpr GLenum OpenGLShader::stageToGLStage(ShaderStage stage) { case ShaderStage::Compute: return GL_COMPUTE_SHADER; } + Logger::Log(Logger::FATAL, "Graphics", "Unknown shader stage %d", static_cast(stage)); + return GL_VERTEX_SHADER; } } // namespace ICE \ No newline at end of file diff --git a/ICE/GraphicsAPI/OpenGL/src/OpenGLTexture2D.cpp b/ICE/GraphicsAPI/OpenGL/src/OpenGLTexture2D.cpp index bded6daa..388e94a8 100644 --- a/ICE/GraphicsAPI/OpenGL/src/OpenGLTexture2D.cpp +++ b/ICE/GraphicsAPI/OpenGL/src/OpenGLTexture2D.cpp @@ -32,6 +32,32 @@ OpenGLTexture2D::OpenGLTexture2D(const Texture2D &tex) { glTexImage2D(GL_TEXTURE_2D, 0, storageFormat, width, height, 0, dataFormat, GL_UNSIGNED_BYTE, tex.data()); } +OpenGLTexture2D::OpenGLTexture2D(uint32_t width, uint32_t height, TextureFormat fmt) { + glGenTextures(1, &m_id); + glBindTexture(GL_TEXTURE_2D, m_id); + + auto storageFormat = textureFormatToGLInternalFormat(fmt); + auto channels = textureFormatToChannels(fmt); + auto dataFormat = (channels == 4) ? GL_RGBA : (channels == 3) ? GL_RGB : GL_RED; + glPixelStorei(GL_UNPACK_ALIGNMENT, textureFormatToAlignment(fmt)); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + // Clamp: a graph target is sampled full-screen, where repeat would wrap edge taps. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + + // Null data: allocate storage only. The pixel type still has to agree with the internal + // format, so a half-float target uploads as GL_FLOAT rather than GL_UNSIGNED_BYTE. + const GLenum pixel_type = (fmt == TextureFormat::Float16) ? GL_FLOAT : GL_UNSIGNED_BYTE; + glTexImage2D(GL_TEXTURE_2D, 0, storageFormat, static_cast(width), static_cast(height), 0, dataFormat, pixel_type, + nullptr); +} + +OpenGLTexture2D::~OpenGLTexture2D() { + glDeleteTextures(1, &m_id); +} + int OpenGLTexture2D::id() const { return m_id; } diff --git a/ICE/GraphicsAPI/OpenGL/src/OpenGLTextureCube.cpp b/ICE/GraphicsAPI/OpenGL/src/OpenGLTextureCube.cpp index ee0c6fa6..eb804378 100644 --- a/ICE/GraphicsAPI/OpenGL/src/OpenGLTextureCube.cpp +++ b/ICE/GraphicsAPI/OpenGL/src/OpenGLTextureCube.cpp @@ -16,8 +16,15 @@ OpenGLTextureCube::OpenGLTextureCube(const TextureCube &texture_asset) { auto width = texture_asset.getWidth(); auto faces = equirectangularToCubemap((uint8_t *) texture_asset.data(), width, texture_asset.getHeight()); + // RGB (3-byte) rows: without alignment 1 the default of 4 shears any face whose width + // is not a multiple of 4. + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); for (int i = 0; i < 6; i++) { - glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB, width / 4, width / 4, 0, GL_RGB, GL_UNSIGNED_BYTE, faces[i]); + glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB8, width / 4, width / 4, 0, GL_RGB, GL_UNSIGNED_BYTE, faces[i]); + } + // equirectangularToCubemap allocates the six faces with new[]; free them after upload. + for (int i = 0; i < 6; i++) { + delete[] faces[i]; } glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); @@ -29,6 +36,10 @@ OpenGLTextureCube::OpenGLTextureCube(const TextureCube &texture_asset) { } +OpenGLTextureCube::~OpenGLTextureCube() { + glDeleteTextures(1, &m_id); +} + int OpenGLTextureCube::id() const { return m_id; } diff --git a/ICE/GraphicsAPI/OpenGL/src/OpenGLVertexArray.cpp b/ICE/GraphicsAPI/OpenGL/src/OpenGLVertexArray.cpp index b001aa88..3b07aafc 100644 --- a/ICE/GraphicsAPI/OpenGL/src/OpenGLVertexArray.cpp +++ b/ICE/GraphicsAPI/OpenGL/src/OpenGLVertexArray.cpp @@ -13,6 +13,10 @@ OpenGLVertexArray::OpenGLVertexArray() { glGenVertexArrays(1, &vaoID); } +OpenGLVertexArray::~OpenGLVertexArray() { + glDeleteVertexArrays(1, &vaoID); +} + void OpenGLVertexArray::bind() const { glBindVertexArray(this->vaoID); } @@ -34,6 +38,28 @@ void OpenGLVertexArray::pushVertexBuffer(const std::shared_ptr& bu cnt = (position + 1) > cnt ? position + 1 : cnt; } +void OpenGLVertexArray::pushVertexBuffer(const std::shared_ptr& buffer, int position, int size, int divisor) { + this->bind(); + buffer->bind(); + + // For mat4, we need 4 vec4 attributes + if (size == 16) { // mat4 = 4 * vec4 + for (int i = 0; i < 4; i++) { + glEnableVertexAttribArray(position + i); + glVertexAttribPointer(position + i, 4, GL_FLOAT, GL_FALSE, sizeof(float) * 16, reinterpret_cast(sizeof(float) * 4 * i)); + glVertexAttribDivisor(position + i, divisor); + } + cnt = (position + 4) > cnt ? position + 4 : cnt; + } else { + glEnableVertexAttribArray(position); + glVertexAttribPointer(position, size, GL_FLOAT, false, 0, 0); + glVertexAttribDivisor(position, divisor); + cnt = (position + 1) > cnt ? position + 1 : cnt; + } + + this->buffers[position] = buffer; +} + void OpenGLVertexArray::setIndexBuffer(const std::shared_ptr& buffer) { this->bind(); buffer->bind(); diff --git a/ICE/IO/CMakeLists.txt b/ICE/IO/CMakeLists.txt index aef7310b..be3906ff 100644 --- a/ICE/IO/CMakeLists.txt +++ b/ICE/IO/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(${PROJECT_NAME} STATIC) target_sources(${PROJECT_NAME} PRIVATE src/EngineConfig.cpp + src/DefaultLoaders.cpp src/Project.cpp src/MaterialExporter.cpp src/TextureLoader.cpp diff --git a/ICE/IO/include/DefaultLoaders.h b/ICE/IO/include/DefaultLoaders.h new file mode 100644 index 00000000..5d676af9 --- /dev/null +++ b/ICE/IO/include/DefaultLoaders.h @@ -0,0 +1,11 @@ +#pragma once + +namespace ICE { +class AssetBank; + +// Registers the engine's built-in asset loaders (mesh, model, material, shader, +// textures) onto the given bank. Lives in the `io` module because that is where +// the concrete loaders (and their Assimp/JSON dependencies) live; keeping this +// out of AssetBank's constructor is what breaks the assets <-> io dependency cycle. +void registerDefaultLoaders(AssetBank &bank); +} // namespace ICE diff --git a/ICE/IO/include/IAssetLoader.h b/ICE/IO/include/IAssetLoader.h deleted file mode 100644 index 94039996..00000000 --- a/ICE/IO/include/IAssetLoader.h +++ /dev/null @@ -1,20 +0,0 @@ -// -// Created by Thomas Ibanez on 31.07.21. -// - -#pragma once - -#include - -#include "Asset.h" -#include "GraphicsFactory.h" - -namespace ICE { -template -class IAssetLoader { - public: - IAssetLoader() = default; - - virtual std::shared_ptr load(const std::vector &files) = 0; -}; -} // namespace ICE diff --git a/ICE/IO/include/ModelLoader.h b/ICE/IO/include/ModelLoader.h index c1b0135c..b06abadf 100644 --- a/ICE/IO/include/ModelLoader.h +++ b/ICE/IO/include/ModelLoader.h @@ -1,43 +1,107 @@ -// -// Created by Thomas Ibanez on 31.07.21. -// - -#pragma once - -#include -#include - -#include - -#include "Asset.h" -#include "IAssetLoader.h" -#include "Model.h" - -namespace ICE { -class AssetBank; - -class ModelLoader : public IAssetLoader { - public: - ModelLoader(AssetBank &bank) : ref_bank(bank) {} - - std::shared_ptr load(const std::vector &file) override; - - int processNode(const aiNode *node, std::vector &nodes, Model::Skeleton &skeleton, std::unordered_set &used_names, - const Eigen::Matrix4f &parent_transform); - AssetUID extractMesh(const aiMesh *mesh, const std::string &model_name, const aiScene *scene, Model::Skeleton &skeleton); - AssetUID extractMaterial(const aiMaterial *material, const std::string &model_name, const aiScene *scene); - AssetUID extractTexture(const aiMaterial *material, const std::string &tex_path, const aiScene *scene, aiTextureType type); - std::unordered_map extractBoneData(const aiMesh *mesh, MeshData &data, Model::Skeleton &skeleton); - std::unordered_map extractAnimations(const aiScene *scene, Model::Skeleton &skeleton); - - private: - Eigen::Vector4f colorToVec(aiColor4D *color); - Eigen::Matrix4f aiMat4ToEigen(const aiMatrix4x4 &mat); - Eigen::Vector3f aiVec3ToEigen(const aiVector3D &vec); - Eigen::Quaternionf aiQuatToEigen(const aiQuaternion &q); - - constexpr TextureFormat getTextureFormat(aiTextureType type, int channels); - - AssetBank &ref_bank; -}; -} // namespace ICE +// +// Created by Thomas Ibanez on 31.07.21. +// + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "Asset.h" +#include "AssetPath.h" +#include "IAssetLoader.h" +#include "Material.h" +#include "Model.h" +#include "Texture.h" + +namespace ICE { +class AssetBank; + +// A texture parsed off-thread (during stage()) together with the material-uniform slots it feeds. +// Its AssetUID is assigned only at commit(), once it is inserted into the bank. +struct StagedTexture { + AssetPath path; + std::shared_ptr texture; + std::string has_uniform; // e.g. "material.hasAoMap" + std::string map_uniform; // e.g. "material.aoMap" +}; + +// A material parsed off-thread. Base uniforms and the shader are already set; the texture-map +// uniforms are wired up at commit() once the textures have UIDs. +struct StagedMaterial { + AssetPath path; + std::shared_ptr material; + std::vector textures; +}; + +struct StagedMesh { + AssetPath path; + std::shared_ptr mesh; +}; + +// The full result of parsing a model file with no AssetBank access -- everything commit() needs to +// populate the bank on the main thread. `meshes[i]` and `materials[i]` correspond to the same scene +// mesh and are committed in that interleaved order so UID assignment matches the original loader. +struct StagedModel { + std::vector meshes; + std::vector materials; + std::vector nodes; + Model::Skeleton skeleton; + std::unordered_map animations; + bool hasAnimations = false; + std::vector sources; + bool valid = false; // false when the file was empty or the parse failed -> commit() does nothing +}; + +class ModelLoader : public IAssetLoader { + public: + ModelLoader(AssetBank &bank) : ref_bank(bank) {} + + // IAssetLoader contract: synchronous stage + commit on the calling thread. Behavior (and bank + // contents) are identical to the previous single-pass loader. + std::shared_ptr load(const std::vector &file) override; + + // Pure parse step (Assimp parse + texture decode): no AssetBank access, safe to run on a worker + // thread. `pbr_shader_uid` is the one bank value the parse needs; the caller resolves it on the + // main thread beforehand and passes it in. + StagedModel stage(const std::vector &file, AssetUID pbr_shader_uid); + + // Bank mutation step: assigns UIDs (preserving them across re-import), wires material textures, + // and builds the Model. Must run on the thread that owns `bank`. Returns nullptr, and touches + // nothing, for an invalid StagedModel (empty file / failed parse). + std::shared_ptr commit(StagedModel &staged, AssetBank &bank); + + int processNode(const aiNode *node, std::vector &nodes, Model::Skeleton &skeleton, std::unordered_set &used_names, + const Eigen::Matrix4f &parent_transform); + std::unordered_map extractBoneData(const aiMesh *mesh, MeshData &data, Model::Skeleton &skeleton); + std::unordered_map extractAnimations(const aiScene *scene, Model::Skeleton &skeleton); + + private: + // stage helpers -- pure, no bank access. + StagedMesh stageMesh(const aiMesh *mesh, const std::string &model_name, const aiScene *scene, Model::Skeleton &skeleton); + StagedMaterial stageMaterial(const aiMaterial *material, const std::string &model_name, const aiScene *scene, AssetUID pbr_shader_uid); + void stageTexture(StagedMaterial &staged_material, const aiMaterial *material, const std::string &tex_path, const aiScene *scene, + aiTextureType type, const std::string &has_uniform, const std::string &map_uniform); + + // commit helpers -- assign UIDs / write the bank (re-import preserves the existing UID). + AssetUID commitMesh(StagedMesh &staged, AssetBank &bank); + AssetUID commitMaterial(StagedMaterial &staged, AssetBank &bank); + AssetUID commitTexture(StagedTexture &staged, AssetBank &bank); + + Eigen::Vector4f colorToVec(aiColor4D *color); + Eigen::Matrix4f aiMat4ToEigen(const aiMatrix4x4 &mat); + Eigen::Vector3f aiVec3ToEigen(const aiVector3D &vec); + Eigen::Quaternionf aiQuatToEigen(const aiQuaternion &q); + + TextureFormat getTextureFormat(aiTextureType type, int channels); + + AssetBank &ref_bank; +}; +} // namespace ICE diff --git a/ICE/IO/include/Project.h b/ICE/IO/include/Project.h index 2a4bbb90..752adf99 100644 --- a/ICE/IO/include/Project.h +++ b/ICE/IO/include/Project.h @@ -19,6 +19,7 @@ error "Missing the header." #include #include +#include #include #include @@ -39,6 +40,24 @@ class Project { void copyAssetFile(const fs::path& folder, const std::string& assetName, const fs::path& src); bool renameAsset(const AssetPath& oldName, const AssetPath& newName); + // Import a model file into the project: copies `src` into the project's Models folder and + // registers it in the asset bank under `name`. Folds the copyAssetFile + addAsset + path + // reconstruction that callers used to spell out by hand. Returns the new asset's UID. + AssetUID importModel(const std::string& name, const fs::path& src); + + // Asynchronous model request: reserve the UID now (state Loading) and parse the model (Assimp + + // texture decode) off the main thread, then commit its meshes/materials/textures on the main + // thread in AssetBank::pump(). `sources` is the already-copied model file. Returns the reserved + // UID. Wires ModelLoader::stage/commit into AssetBank::requestAsset; the synchronous importModel + // path above is unchanged. + AssetUID requestModel(const std::string& name, const std::vector& sources); + + // Resolve a bank UID by name for a given asset kind, hiding AssetPath::WithTypePrefix from + // gameplay/tools code. Return NO_ASSET_ID if no such asset is registered. + AssetUID mesh(const std::string& name) const; + AssetUID material(const std::string& name) const; + AssetUID model(const std::string& name) const; + std::vector> getScenes(); void setScenes(const std::vector>& scenes); @@ -50,6 +69,14 @@ class Project { void setCurrentScene(const std::shared_ptr& scene); std::shared_ptr getCurrentScene() const; + // Create a scene, add it, make it current, and -- if this project is attached to an engine -- + // activate it (set up its runtime systems) so it is ready to render. Returns the owned scene. + Scene& createScene(const std::string& name); + + // Installed by the engine when it adopts the project (see ICEEngine::newProject); createScene + // invokes it so a new scene gets its runtime systems. Runtime-only, never serialized. + void setSceneActivator(const std::function&)>& activator); + static json dumpVec3(const Eigen::Vector3f& v); static json dumpVec4(const Eigen::Vector4f& v); @@ -94,10 +121,16 @@ class Project { std::vector> m_scenes; std::shared_ptr m_current_scene; + std::function&)> m_scene_activator; std::shared_ptr m_asset_bank; std::shared_ptr m_gpu_registry; + // Custom-asset entries from the "assets" section whose type/prefix couldn't be resolved at load + // time (the providing plugin wasn't registered). Kept verbatim and re-emitted on save so a + // missing plugin doesn't silently drop the user's assets. + std::vector m_unknown_assets; + Eigen::Vector3f cameraPosition, cameraRotation; }; } // namespace ICE diff --git a/ICE/IO/include/ShaderExporter.h b/ICE/IO/include/ShaderExporter.h index 05fe1ecf..5b6c0d3e 100644 --- a/ICE/IO/include/ShaderExporter.h +++ b/ICE/IO/include/ShaderExporter.h @@ -10,7 +10,7 @@ class ShaderExporter : public AssetExporter { void writeToJson(const std::filesystem::path &path, const Shader &object) override; void writeToBin(const std::filesystem::path &path, const Shader &object) override; - constexpr std::string stageToString(ShaderStage stage) { + std::string stageToString(ShaderStage stage) { switch (stage) { case ShaderStage::Vertex: return "vertex"; diff --git a/ICE/IO/include/ShaderLoader.h b/ICE/IO/include/ShaderLoader.h index 408552d1..217aaba2 100644 --- a/ICE/IO/include/ShaderLoader.h +++ b/ICE/IO/include/ShaderLoader.h @@ -16,6 +16,6 @@ class ShaderLoader : public IAssetLoader { ShaderLoader() = default; std::shared_ptr load(const std::vector &file) override; std::string readAndResolveIncludes(const std::filesystem::path &file); - constexpr ShaderStage stageFromString(const std::string &str); + ShaderStage stageFromString(const std::string &str); }; } // namespace ICE diff --git a/ICE/IO/src/DefaultLoaders.cpp b/ICE/IO/src/DefaultLoaders.cpp new file mode 100644 index 00000000..5b993091 --- /dev/null +++ b/ICE/IO/src/DefaultLoaders.cpp @@ -0,0 +1,21 @@ +#include "DefaultLoaders.h" + +#include + +#include "AssetBank.h" +#include "MaterialLoader.h" +#include "MeshLoader.h" +#include "ModelLoader.h" +#include "ShaderLoader.h" +#include "TextureLoader.h" + +namespace ICE { +void registerDefaultLoaders(AssetBank &bank) { + bank.addLoader(std::make_shared()); + bank.addLoader(std::make_shared()); + bank.addLoader(std::make_shared(bank)); + bank.addLoader(std::make_shared()); + bank.addLoader(std::make_shared()); + bank.addLoader(std::make_shared()); +} +} // namespace ICE diff --git a/ICE/IO/src/EngineConfig.cpp b/ICE/IO/src/EngineConfig.cpp index 960370d3..e57086c8 100644 --- a/ICE/IO/src/EngineConfig.cpp +++ b/ICE/IO/src/EngineConfig.cpp @@ -26,18 +26,21 @@ EngineConfig EngineConfig::LoadFromFile() { configFile.open(ICE_CONFIG_FILE); if (!configFile.is_open()) { - Logger::Log(Logger::FATAL, "IO", "Couldn't open config file"); - exit(EXIT_FAILURE); + // Library code must not exit() the host process; log and return an empty config. + Logger::Log(Logger::ERROR, "IO", "Couldn't open config file"); + return config; } - json j; - configFile >> j; - configFile.close(); - - for (const auto &project : j["projects"]) { - std::filesystem::path path = std::string(project["path"]); - std::string name = project["name"]; - config.localProjects.push_back(Project(path, name)); + try { + json j; + configFile >> j; + for (const auto &project : j["projects"]) { + std::filesystem::path path = std::string(project["path"]); + std::string name = project["name"]; + config.localProjects.push_back(Project(path, name)); + } + } catch (const std::exception &e) { + Logger::Log(Logger::ERROR, "IO", "Failed to parse config file: %s", e.what()); } } return config; @@ -51,8 +54,8 @@ void EngineConfig::save() { std::ofstream configFile; configFile.open(ICE_CONFIG_FILE); if (!configFile.is_open()) { - Logger::Log(Logger::FATAL, "IO", "Couldn't open config file"); - exit(EXIT_FAILURE); + Logger::Log(Logger::ERROR, "IO", "Couldn't open config file for writing"); + return; } json j; diff --git a/ICE/IO/src/MaterialLoader.cpp b/ICE/IO/src/MaterialLoader.cpp index fc73fd2f..4b827121 100644 --- a/ICE/IO/src/MaterialLoader.cpp +++ b/ICE/IO/src/MaterialLoader.cpp @@ -5,18 +5,30 @@ #include "MaterialLoader.h" #include +#include #include #include using json = nlohmann::json; - +#undef ERROR namespace ICE { std::shared_ptr MaterialLoader::load(const std::vector &files) { + if (files.empty()) { + return nullptr; + } + std::ifstream infile(files[0]); + if (!infile.is_open()) { + Logger::Log(Logger::ERROR, "IO", "Could not open material file '%s'", files[0].string().c_str()); + return nullptr; + } json j; - std::ifstream infile = std::ifstream(files[0]); - infile >> j; - infile.close(); + try { + infile >> j; + } catch (const std::exception &e) { + Logger::Log(Logger::ERROR, "IO", "Failed to parse material '%s': %s", files[0].string().c_str(), e.what()); + return nullptr; + } bool transparent = false; if (j.contains("transparent")) { diff --git a/ICE/IO/src/MeshLoader.cpp b/ICE/IO/src/MeshLoader.cpp index 65f054f8..9a04d47d 100644 --- a/ICE/IO/src/MeshLoader.cpp +++ b/ICE/IO/src/MeshLoader.cpp @@ -5,7 +5,7 @@ #include "MeshLoader.h" #include -#include +#include #include #include #include @@ -24,7 +24,8 @@ std::shared_ptr MeshLoader::load(const std::vector aiProcess_FlipUVs | aiProcess_ValidateDataStructure | aiProcess_SortByPType | aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | aiProcess_Triangulate | aiProcess_PreTransformVertices); - if (scene->mNumMeshes < 1) { + if (scene == nullptr || scene->mNumMeshes < 1) { + Logger::Log(Logger::ERROR, "IO", "Could not load mesh '%s': %s", file[0].string().c_str(), importer.GetErrorString()); return nullptr; } MeshData data; diff --git a/ICE/IO/src/ModelLoader.cpp b/ICE/IO/src/ModelLoader.cpp index 09560e69..b75f2c94 100644 --- a/ICE/IO/src/ModelLoader.cpp +++ b/ICE/IO/src/ModelLoader.cpp @@ -1,369 +1,433 @@ -// -// Created by Thomas Ibanez on 31.07.21. -// - -#include "ModelLoader.h" - -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace ICE { -std::shared_ptr ModelLoader::load(const std::vector &file) { - Assimp::Importer importer; - - const aiScene *scene = - importer.ReadFile(file[0].string(), - aiProcess_OptimizeGraph | aiProcess_FlipUVs | aiProcess_ValidateDataStructure | aiProcess_SortByPType - | aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | aiProcess_Triangulate | aiProcess_LimitBoneWeights); - - std::vector meshes; - std::vector materials; - std::vector nodes; - Model::Skeleton skeleton; - skeleton.globalInverseTransform = aiMat4ToEigen(scene->mRootNode->mTransformation).inverse(); - for (int m = 0; m < scene->mNumMeshes; m++) { - auto mesh = scene->mMeshes[m]; - auto material = scene->mMaterials[mesh->mMaterialIndex]; - auto model_name = file[0].filename().stem().string(); - meshes.push_back(extractMesh(mesh, model_name, scene, skeleton)); - materials.push_back(extractMaterial(material, model_name, scene)); - } - std::unordered_set used_node_names; - processNode(scene->mRootNode, nodes, skeleton, used_node_names, Eigen::Matrix4f::Identity()); - auto model = std::make_shared(nodes, meshes, materials); - - if (scene->HasAnimations()) { - auto animations = extractAnimations(scene, skeleton); - model->setAnimations(animations); - model->setSkeleton(skeleton); - } - model->setSources(file); - return model; -} - -int ModelLoader::processNode(const aiNode *ainode, std::vector &nodes, Model::Skeleton &skeleton, - std::unordered_set &used_names, const Eigen::Matrix4f &parent_transform) { - std::string name = ainode->mName.C_Str(); - if (used_names.contains(name)) { - name = name + "_" + std::to_string(used_names.size()); - } - used_names.insert(name); - - Model::Node node; - node.name = name; - - aiMatrix4x4 local = ainode->mTransformation; - node.localTransform = aiMat4ToEigen(local); - - for (unsigned int i = 0; i < ainode->mNumMeshes; ++i) { - unsigned int mesh_idx = ainode->mMeshes[i]; - node.meshIndices.push_back(mesh_idx); - } - auto insert_pos = nodes.size(); - - nodes.push_back(node); - - for (unsigned int c = 0; c < ainode->mNumChildren; ++c) { - const aiNode *child = ainode->mChildren[c]; - int child_pos = processNode(child, nodes, skeleton, used_names, parent_transform * node.localTransform); - nodes.at(insert_pos).children.push_back(child_pos); - } - return insert_pos; -} - -AssetUID ModelLoader::extractMesh(const aiMesh *mesh, const std::string &model_name, const aiScene *scene, Model::Skeleton &skeleton) { - MeshData data; - - for (int i = 0; i < mesh->mNumVertices; i++) { - auto v = mesh->mVertices[i]; - auto n = mesh->HasNormals() ? mesh->mNormals[i] : aiVector3D{0, 0, 0}; - auto t = mesh->HasTangentsAndBitangents() ? mesh->mTangents[i] : aiVector3D{0, 0, 0}; - auto b = mesh->HasTangentsAndBitangents() ? mesh->mBitangents[i] : aiVector3D{0, 0, 0}; - Eigen::Vector2f uv(0, 0); - if (mesh->mTextureCoords[0] != nullptr) { - auto uv_file = mesh->mTextureCoords[0][i]; - uv.x() = uv_file.x; - uv.y() = uv_file.y; - } - data.vertices.emplace_back(v.x, v.y, v.z); - data.normals.emplace_back(n.x, n.y, n.z); - data.uvCoords.push_back(uv); - data.tangents.emplace_back(t.x, t.y, t.z); - data.bitangents.emplace_back(b.x, b.y, b.z); - data.boneIDs.emplace_back(Eigen::Vector4i::Constant(-1)); - data.boneWeights.emplace_back(Eigen::Vector4f::Zero()); - } - for (int i = 0; i < mesh->mNumFaces; i++) { - auto f = mesh->mFaces[i]; - assert(f.mNumIndices == 3); - data.indices.emplace_back(f.mIndices[0], f.mIndices[1], f.mIndices[2]); - } - - std::unordered_map inverseBindMatrices; - if (mesh->HasBones()) { - inverseBindMatrices = extractBoneData(mesh, data, skeleton); - } - auto mesh_ = std::make_shared(std::move(data)); - for (const auto &[boneID, ibm] : inverseBindMatrices) { - mesh_->setIBM(boneID, ibm); - } - - AssetUID mesh_id = 0; - AssetPath mesh_path = AssetPath::WithTypePrefix(model_name + "/" + mesh->mName.C_Str()); - if (mesh_id = ref_bank.getUID(mesh_path); mesh_id != 0) { - ref_bank.removeAsset(mesh_path); - ref_bank.addAssetWithSpecificUID(mesh_path, mesh_, mesh_id); - } else { - ref_bank.addAsset(mesh_path, mesh_); - mesh_id = ref_bank.getUID(mesh_path); - } - - return mesh_id; -} - -AssetUID ModelLoader::extractMaterial(const aiMaterial *material, const std::string &model_name, const aiScene *scene) { - auto mtl_name = material->GetName(); - if (mtl_name.length == 0) { - mtl_name = "DefaultMat"; - } - auto bank_name = model_name + "/" + mtl_name.C_Str(); - auto mtl = std::make_shared(); - mtl->setUniform("material.hasAoMap", 0); - mtl->setUniform("material.hasBaseColorMap", 0); - mtl->setUniform("material.hasMetallicMap", 0); - mtl->setUniform("material.hasRoughnessMap", 0); - mtl->setUniform("material.hasNormalMap", 0); - mtl->setUniform("material.hasEmissiveMap", 0); - mtl->setUniform("material.ao", 1.0f); - mtl->setUniform("material.metallic", 0.0f); - mtl->setUniform("material.roughness", 1.0f); - mtl->setShader(ref_bank.getUID(AssetPath::WithTypePrefix("pbr"))); - // Base color - aiColor4D diffuse = aiColor4D(1, 1, 1, 1); - aiGetMaterialColor(material, AI_MATKEY_COLOR_DIFFUSE, &diffuse); - mtl->setUniform("material.baseColor", Eigen::Vector3f(colorToVec(&diffuse).head<3>())); - - ai_real roughness = 1.0f; - aiGetMaterialFloat(material, AI_MATKEY_ROUGHNESS_FACTOR, &roughness); - mtl->setUniform("material.roughness", (float) roughness); - - ai_real metallic = 0.0f; - aiGetMaterialFloat(material, AI_MATKEY_METALLIC_FACTOR, &metallic); - mtl->setUniform("material.metallic", (float) metallic); - - if (auto ambient_map = extractTexture(material, bank_name + "/ao_map", scene, aiTextureType_LIGHTMAP); ambient_map != 0) { - mtl->setUniform("material.hasAoMap", 1); - mtl->setUniform("material.aoMap", ambient_map); - } - - if (auto diffuse_tex = extractTexture(material, bank_name + "/diffuse_map", scene, aiTextureType_BASE_COLOR); diffuse_tex != 0) { - mtl->setUniform("material.hasBaseColorMap", 1); - mtl->setUniform("material.baseColorMap", diffuse_tex); - } - - if (auto metallic_tex = extractTexture(material, bank_name + "/metallic_map", scene, aiTextureType_METALNESS); metallic_tex != 0) { - mtl->setUniform("material.hasMetallicMap", 1); - mtl->setUniform("material.metallicMap", metallic_tex); - } - - if (auto roughness_tex = extractTexture(material, bank_name + "/roughness_map", scene, aiTextureType_DIFFUSE_ROUGHNESS); roughness_tex != 0) { - mtl->setUniform("material.hasRoughnessMap", 1); - mtl->setUniform("material.roughnessMap", roughness_tex); - } - - if (auto normal_tex = extractTexture(material, bank_name + "/normal_map", scene, aiTextureType_NORMALS); normal_tex != 0) { - mtl->setUniform("material.hasNormalMap", 1); - mtl->setUniform("material.normalMap", normal_tex); - } - - if (auto emissive_tex = extractTexture(material, bank_name + "/emissive_map", scene, aiTextureType_EMISSIVE); emissive_tex != 0) { - mtl->setUniform("material.hasEmissiveMap", 1); - mtl->setUniform("material.emissiveMap", emissive_tex); - } - - if (ref_bank.getUID(AssetPath::WithTypePrefix(bank_name)) != 0) { - return ref_bank.getUID(AssetPath::WithTypePrefix(bank_name)); - } - - ref_bank.addAsset(bank_name, mtl); - return ref_bank.getUID(AssetPath::WithTypePrefix(bank_name)); -} - -AssetUID ModelLoader::extractTexture(const aiMaterial *material, const std::string &tex_path, const aiScene *scene, aiTextureType type) { - AssetUID tex_id = 0; - aiString texture_file; - if (material->Get(AI_MATKEY_TEXTURE(type, 0), texture_file) == aiReturn_SUCCESS) { - if (auto texture = scene->GetEmbeddedTexture(texture_file.C_Str())) { - unsigned char *data = reinterpret_cast(texture->pcData); - void *data2 = nullptr; - int width = texture->mWidth; - int height = texture->mHeight; - int channels = 3; - if (height == 0) { - //Compressed memory, use stbi to load - data2 = stbi_load_from_memory(data, texture->mWidth, &width, &height, &channels, 4); - channels = 4; - } else { - data2 = data; - } - auto texture_ice = std::make_shared(data2, width, height, getTextureFormat(type, channels)); - if (tex_id = ref_bank.getUID(AssetPath::WithTypePrefix(tex_path)); tex_id != 0) { - ref_bank.removeAsset(AssetPath::WithTypePrefix(tex_path)); - ref_bank.addAssetWithSpecificUID(AssetPath::WithTypePrefix(tex_path), texture_ice, tex_id); - } else { - ref_bank.addAsset(tex_path, texture_ice); - tex_id = ref_bank.getUID(AssetPath::WithTypePrefix(tex_path)); - } - } else { - //regular file, check if it exists and read it - //TODO :) - } - } - return tex_id; -} - -std::unordered_map ModelLoader::extractBoneData(const aiMesh *mesh, MeshData &data, Model::Skeleton &skeleton) { - std::unordered_map inverseBindMatrices; - for (unsigned int boneIndex = 0; boneIndex < mesh->mNumBones; ++boneIndex) { - std::string boneName = mesh->mBones[boneIndex]->mName.C_Str(); - int boneID = -1; - // If the bone is new (hasn't been added by a previous mesh) - if (!skeleton.boneMapping.contains(boneName)) { - boneID = skeleton.boneMapping.size(); - skeleton.boneMapping[boneName] = boneID; - } else { - //Bone Already Exists - boneID = skeleton.boneMapping.at(boneName); - } - - inverseBindMatrices.try_emplace(boneID, aiMat4ToEigen(mesh->mBones[boneIndex]->mOffsetMatrix)); - - aiVertexWeight *weights = mesh->mBones[boneIndex]->mWeights; - unsigned int numWeights = mesh->mBones[boneIndex]->mNumWeights; - - for (int weightIndex = 0; weightIndex < numWeights; ++weightIndex) { - unsigned int vertexId = weights[weightIndex].mVertexId; - float weight = weights[weightIndex].mWeight; - - for (int i = 0; i < 4; ++i) { - if (data.boneIDs[vertexId][i] < 0) { - data.boneWeights[vertexId][i] = weight; - data.boneIDs[vertexId][i] = boneID; - break; - } - } - } - } - return inverseBindMatrices; -} - -std::unordered_map ModelLoader::extractAnimations(const aiScene *scene, Model::Skeleton &skeleton) { - std::unordered_map out; - for (unsigned int i = 0; i < scene->mNumAnimations; i++) { - aiAnimation *anim = scene->mAnimations[i]; - - Animation a; - a.duration = anim->mDuration; - a.ticksPerSecond = anim->mTicksPerSecond != 0 ? anim->mTicksPerSecond : 25.0f; - - for (unsigned int c = 0; c < anim->mNumChannels; c++) { - aiNodeAnim *channel = anim->mChannels[c]; - std::string boneName = channel->mNodeName.C_Str(); - - BoneAnimation track; - - for (int k = 0; k < channel->mNumPositionKeys; k++) { - track.positions.push_back({(float) channel->mPositionKeys[k].mTime, aiVec3ToEigen(channel->mPositionKeys[k].mValue)}); - } - - for (int k = 0; k < channel->mNumRotationKeys; k++) { - track.rotations.push_back({(float) channel->mRotationKeys[k].mTime, aiQuatToEigen(channel->mRotationKeys[k].mValue)}); - } - - for (int k = 0; k < channel->mNumScalingKeys; k++) { - track.scales.push_back({(float) channel->mScalingKeys[k].mTime, aiVec3ToEigen(channel->mScalingKeys[k].mValue)}); - } - - a.tracks[boneName] = std::move(track); - } - std::string anim_name = anim->mName.C_Str(); - - out.try_emplace(anim_name.substr(anim_name.find_first_of('|') + 1), std::move(a)); - } - return out; -} - -Eigen::Vector4f ModelLoader::colorToVec(aiColor4D *color) { - Eigen::Vector4f v; - v.x() = color->r; - v.y() = color->g; - v.z() = color->b; - v.w() = color->a; - return v; -} - -Eigen::Matrix4f ModelLoader::aiMat4ToEigen(const aiMatrix4x4 &m) { - Eigen::Matrix4f out; - - out(0, 0) = m.a1; - out(0, 1) = m.a2; - out(0, 2) = m.a3; - out(0, 3) = m.a4; - out(1, 0) = m.b1; - out(1, 1) = m.b2; - out(1, 2) = m.b3; - out(1, 3) = m.b4; - out(2, 0) = m.c1; - out(2, 1) = m.c2; - out(2, 2) = m.c3; - out(2, 3) = m.c4; - out(3, 0) = m.d1; - out(3, 1) = m.d2; - out(3, 2) = m.d3; - out(3, 3) = m.d4; - - return out; -} - -Eigen::Vector3f ModelLoader::aiVec3ToEigen(const aiVector3D &vec) { - Eigen::Vector3f v; - v.x() = vec.x; - v.y() = vec.y; - v.z() = vec.z; - return v; -} - -Eigen::Quaternionf ModelLoader::aiQuatToEigen(const aiQuaternion &q) { - Eigen::Quaternionf quat; - quat.w() = q.w; - quat.x() = q.x; - quat.y() = q.y; - quat.z() = q.z; - return quat; -} - -constexpr TextureFormat ModelLoader::getTextureFormat(aiTextureType type, int channels) { - switch (type) { - case aiTextureType_METALNESS: - case aiTextureType_AMBIENT_OCCLUSION: - case aiTextureType_LIGHTMAP: - case aiTextureType_DIFFUSE_ROUGHNESS: - case aiTextureType_NORMALS: - return channels == 3 ? TextureFormat::RGB8 : TextureFormat::RGBA8; - - case aiTextureType_BASE_COLOR: - case aiTextureType_EMISSIVE: - return channels == 3 ? TextureFormat::SRGB8 : TextureFormat::SRGBA8; - default: - return channels == 3 ? TextureFormat::RGB8 : TextureFormat::RGBA8; - } -} - -} // namespace ICE +// +// Created by Thomas Ibanez on 31.07.21. +// + +#include "ModelLoader.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace ICE { +std::shared_ptr ModelLoader::load(const std::vector &file) { + // Resolve the one bank value the parse needs on the calling (main) thread, then stage (pure) and + // commit. Splitting these is what lets the async import pipeline run stage() on a worker while + // keeping bank mutation on the main thread; the synchronous path here is behavior-identical to + // the previous single-pass loader. + AssetUID pbr_shader_uid = ref_bank.getUID(AssetPath::WithTypePrefix("pbr")); + StagedModel staged = stage(file, pbr_shader_uid); + return commit(staged, ref_bank); +} + +StagedModel ModelLoader::stage(const std::vector &file, AssetUID pbr_shader_uid) { + StagedModel staged; + if (file.empty()) { + return staged; // valid == false + } + Assimp::Importer importer; + + const aiScene *scene = + importer.ReadFile(file[0].string(), + aiProcess_OptimizeGraph | aiProcess_FlipUVs | aiProcess_ValidateDataStructure | aiProcess_SortByPType + | aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | aiProcess_Triangulate | aiProcess_LimitBoneWeights); + + if (scene == nullptr || scene->mRootNode == nullptr) { + Logger::Log(Logger::ERROR, "IO", "Could not load model '%s': %s", file[0].string().c_str(), importer.GetErrorString()); + return staged; // valid == false -> commit() does nothing + } + + staged.sources = file; + staged.skeleton.globalInverseTransform = aiMat4ToEigen(scene->mRootNode->mTransformation).inverse(); + for (int m = 0; m < scene->mNumMeshes; m++) { + auto mesh = scene->mMeshes[m]; + auto material = scene->mMaterials[mesh->mMaterialIndex]; + auto model_name = file[0].filename().stem().string(); + staged.meshes.push_back(stageMesh(mesh, model_name, scene, staged.skeleton)); + staged.materials.push_back(stageMaterial(material, model_name, scene, pbr_shader_uid)); + } + std::unordered_set used_node_names; + processNode(scene->mRootNode, staged.nodes, staged.skeleton, used_node_names, Eigen::Matrix4f::Identity()); + + if (scene->HasAnimations()) { + staged.animations = extractAnimations(scene, staged.skeleton); + staged.hasAnimations = true; + } + staged.valid = true; + return staged; +} + +std::shared_ptr ModelLoader::commit(StagedModel &staged, AssetBank &bank) { + if (!staged.valid) { + return nullptr; + } + std::vector meshes; + std::vector materials; + meshes.reserve(staged.meshes.size()); + materials.reserve(staged.materials.size()); + // Commit mesh[i] then material[i] in scene order so UID assignment (a single bank-wide counter) + // is identical to the original single-pass loader -- required for stable re-import and project + // files. + for (size_t i = 0; i < staged.meshes.size(); i++) { + meshes.push_back(commitMesh(staged.meshes[i], bank)); + materials.push_back(commitMaterial(staged.materials[i], bank)); + } + auto model = std::make_shared(staged.nodes, meshes, materials); + + if (staged.hasAnimations) { + model->setAnimations(staged.animations); + model->setSkeleton(staged.skeleton); + } + model->setSources(staged.sources); + return model; +} + +int ModelLoader::processNode(const aiNode *ainode, std::vector &nodes, Model::Skeleton &skeleton, + std::unordered_set &used_names, const Eigen::Matrix4f &parent_transform) { + std::string name = ainode->mName.C_Str(); + if (used_names.contains(name)) { + // Suffix with an incrementing counter checked against existing names. The old + // "_" suffix could still collide with a node that already had that name. + const std::string base = name; + int counter = 1; + do { + name = base + "_" + std::to_string(counter++); + } while (used_names.contains(name)); + } + used_names.insert(name); + + Model::Node node; + node.name = name; + + aiMatrix4x4 local = ainode->mTransformation; + node.localTransform = aiMat4ToEigen(local); + + for (unsigned int i = 0; i < ainode->mNumMeshes; ++i) { + unsigned int mesh_idx = ainode->mMeshes[i]; + node.meshIndices.push_back(mesh_idx); + } + auto insert_pos = nodes.size(); + + nodes.push_back(node); + + for (unsigned int c = 0; c < ainode->mNumChildren; ++c) { + const aiNode *child = ainode->mChildren[c]; + int child_pos = processNode(child, nodes, skeleton, used_names, parent_transform * node.localTransform); + nodes.at(insert_pos).children.push_back(child_pos); + } + return insert_pos; +} + +StagedMesh ModelLoader::stageMesh(const aiMesh *mesh, const std::string &model_name, const aiScene *scene, Model::Skeleton &skeleton) { + MeshData data; + + for (int i = 0; i < mesh->mNumVertices; i++) { + auto v = mesh->mVertices[i]; + auto n = mesh->HasNormals() ? mesh->mNormals[i] : aiVector3D{0, 0, 0}; + auto t = mesh->HasTangentsAndBitangents() ? mesh->mTangents[i] : aiVector3D{0, 0, 0}; + auto b = mesh->HasTangentsAndBitangents() ? mesh->mBitangents[i] : aiVector3D{0, 0, 0}; + Eigen::Vector2f uv(0, 0); + if (mesh->mTextureCoords[0] != nullptr) { + auto uv_file = mesh->mTextureCoords[0][i]; + uv.x() = uv_file.x; + uv.y() = uv_file.y; + } + data.vertices.emplace_back(v.x, v.y, v.z); + data.normals.emplace_back(n.x, n.y, n.z); + data.uvCoords.push_back(uv); + data.tangents.emplace_back(t.x, t.y, t.z); + data.bitangents.emplace_back(b.x, b.y, b.z); + data.boneIDs.emplace_back(Eigen::Vector4i::Constant(-1)); + data.boneWeights.emplace_back(Eigen::Vector4f::Zero()); + } + for (int i = 0; i < mesh->mNumFaces; i++) { + auto f = mesh->mFaces[i]; + assert(f.mNumIndices == 3); + data.indices.emplace_back(f.mIndices[0], f.mIndices[1], f.mIndices[2]); + } + + std::unordered_map inverseBindMatrices; + if (mesh->HasBones()) { + inverseBindMatrices = extractBoneData(mesh, data, skeleton); + } + auto mesh_ = std::make_shared(std::move(data)); + for (const auto &[boneID, ibm] : inverseBindMatrices) { + mesh_->setIBM(boneID, ibm); + } + + AssetPath mesh_path = AssetPath::WithTypePrefix(model_name + "/" + mesh->mName.C_Str()); + return StagedMesh{mesh_path, mesh_}; +} + +AssetUID ModelLoader::commitMesh(StagedMesh &staged, AssetBank &bank) { + AssetUID mesh_id = 0; + if (mesh_id = bank.getUID(staged.path); mesh_id != 0) { + // Re-import: drop the old asset (fires the eviction listener so the GPU upload is released) + // and re-add under the same UID so existing references (scenes, components) stay valid. + bank.removeAsset(staged.path); + bank.addAssetWithSpecificUID(staged.path, staged.mesh, mesh_id); + } else { + bank.addAsset(staged.path, staged.mesh); + mesh_id = bank.getUID(staged.path); + } + return mesh_id; +} + +StagedMaterial ModelLoader::stageMaterial(const aiMaterial *material, const std::string &model_name, const aiScene *scene, AssetUID pbr_shader_uid) { + auto mtl_name = material->GetName(); + if (mtl_name.length == 0) { + mtl_name = "DefaultMat"; + } + auto bank_name = model_name + "/" + mtl_name.C_Str(); + auto mtl = std::make_shared(); + mtl->setUniform("material.hasAoMap", 0); + mtl->setUniform("material.hasBaseColorMap", 0); + mtl->setUniform("material.hasMetallicMap", 0); + mtl->setUniform("material.hasRoughnessMap", 0); + mtl->setUniform("material.hasNormalMap", 0); + mtl->setUniform("material.hasEmissiveMap", 0); + mtl->setUniform("material.ao", 1.0f); + mtl->setUniform("material.metallic", 0.0f); + mtl->setUniform("material.roughness", 1.0f); + mtl->setShader(pbr_shader_uid); + // Base color + aiColor4D diffuse = aiColor4D(1, 1, 1, 1); + aiGetMaterialColor(material, AI_MATKEY_COLOR_DIFFUSE, &diffuse); + mtl->setUniform("material.baseColor", Eigen::Vector3f(colorToVec(&diffuse).head<3>())); + + ai_real roughness = 1.0f; + aiGetMaterialFloat(material, AI_MATKEY_ROUGHNESS_FACTOR, &roughness); + mtl->setUniform("material.roughness", (float) roughness); + + ai_real metallic = 0.0f; + aiGetMaterialFloat(material, AI_MATKEY_METALLIC_FACTOR, &metallic); + mtl->setUniform("material.metallic", (float) metallic); + + // AssetPath has no default constructor, so build the aggregate with the path in place (rather + // than default-construct then assign). + StagedMaterial staged{AssetPath::WithTypePrefix(bank_name), mtl, {}}; + + // Stage each present texture with the uniform slots it feeds. Order matches the original loader + // so texture UID assignment at commit is unchanged. The hasXMap/xMap uniforms are set in + // commitMaterial once the textures have UIDs. + stageTexture(staged, material, bank_name + "/ao_map", scene, aiTextureType_LIGHTMAP, "material.hasAoMap", "material.aoMap"); + stageTexture(staged, material, bank_name + "/diffuse_map", scene, aiTextureType_BASE_COLOR, "material.hasBaseColorMap", "material.baseColorMap"); + stageTexture(staged, material, bank_name + "/metallic_map", scene, aiTextureType_METALNESS, "material.hasMetallicMap", "material.metallicMap"); + stageTexture(staged, material, bank_name + "/roughness_map", scene, aiTextureType_DIFFUSE_ROUGHNESS, "material.hasRoughnessMap", + "material.roughnessMap"); + stageTexture(staged, material, bank_name + "/normal_map", scene, aiTextureType_NORMALS, "material.hasNormalMap", "material.normalMap"); + stageTexture(staged, material, bank_name + "/emissive_map", scene, aiTextureType_EMISSIVE, "material.hasEmissiveMap", "material.emissiveMap"); + + return staged; +} + +AssetUID ModelLoader::commitMaterial(StagedMaterial &staged, AssetBank &bank) { + // Commit the material's textures first (this also replaces them, preserving UIDs, on re-import) + // and wire the resulting UIDs into the material uniforms. + for (auto &tex : staged.textures) { + AssetUID tex_id = commitTexture(tex, bank); + staged.material->setUniform(tex.has_uniform, 1); + staged.material->setUniform(tex.map_uniform, tex_id); + } + + if (bank.getUID(staged.path) != 0) { + // Material already present (e.g. shared by several meshes): dedupe by path, as before. + return bank.getUID(staged.path); + } + + bank.addAsset(staged.path, staged.material); + return bank.getUID(staged.path); +} + +void ModelLoader::stageTexture(StagedMaterial &staged_material, const aiMaterial *material, const std::string &tex_path, const aiScene *scene, + aiTextureType type, const std::string &has_uniform, const std::string &map_uniform) { + aiString texture_file; + if (material->Get(AI_MATKEY_TEXTURE(type, 0), texture_file) == aiReturn_SUCCESS) { + if (auto texture = scene->GetEmbeddedTexture(texture_file.C_Str())) { + unsigned char *data = reinterpret_cast(texture->pcData); + void *data2 = nullptr; + int width = texture->mWidth; + int height = texture->mHeight; + int channels = 4; + if (height == 0) { + // Compressed in memory: decode with stbi into an owned RGBA buffer. + data2 = stbi_load_from_memory(data, texture->mWidth, &width, &height, &channels, 4); + channels = 4; + } else { + // Uncompressed aiTexel (BGRA/RGBA8888) data lives in the aiScene, which is + // freed when this Importer is destroyed -- and the GPU upload happens later. + // Copy it into an owned buffer so the Texture2D remains valid. + size_t byte_size = static_cast(width) * static_cast(height) * 4; + data2 = malloc(byte_size); + if (data2 != nullptr) { + memcpy(data2, data, byte_size); + } + } + // take_ownership=true: both branches produce a free-compatible (stbi/malloc) buffer. + auto texture_ice = std::make_shared(data2, width, height, getTextureFormat(type, channels), true); + staged_material.textures.push_back(StagedTexture{AssetPath::WithTypePrefix(tex_path), texture_ice, has_uniform, map_uniform}); + } else { + //regular file, check if it exists and read it + //TODO :) + } + } +} + +AssetUID ModelLoader::commitTexture(StagedTexture &staged, AssetBank &bank) { + AssetUID tex_id = 0; + if (tex_id = bank.getUID(staged.path); tex_id != 0) { + bank.removeAsset(staged.path); + bank.addAssetWithSpecificUID(staged.path, staged.texture, tex_id); + } else { + bank.addAsset(staged.path, staged.texture); + tex_id = bank.getUID(staged.path); + } + return tex_id; +} + +std::unordered_map ModelLoader::extractBoneData(const aiMesh *mesh, MeshData &data, Model::Skeleton &skeleton) { + std::unordered_map inverseBindMatrices; + for (unsigned int boneIndex = 0; boneIndex < mesh->mNumBones; ++boneIndex) { + std::string boneName = mesh->mBones[boneIndex]->mName.C_Str(); + int boneID = -1; + // If the bone is new (hasn't been added by a previous mesh) + if (!skeleton.boneMapping.contains(boneName)) { + boneID = skeleton.boneMapping.size(); + skeleton.boneMapping[boneName] = boneID; + } else { + //Bone Already Exists + boneID = skeleton.boneMapping.at(boneName); + } + + inverseBindMatrices.try_emplace(boneID, aiMat4ToEigen(mesh->mBones[boneIndex]->mOffsetMatrix)); + + aiVertexWeight *weights = mesh->mBones[boneIndex]->mWeights; + unsigned int numWeights = mesh->mBones[boneIndex]->mNumWeights; + + for (int weightIndex = 0; weightIndex < numWeights; ++weightIndex) { + unsigned int vertexId = weights[weightIndex].mVertexId; + float weight = weights[weightIndex].mWeight; + + for (int i = 0; i < 4; ++i) { + if (data.boneIDs[vertexId][i] < 0) { + data.boneWeights[vertexId][i] = weight; + data.boneIDs[vertexId][i] = boneID; + break; + } + } + } + } + return inverseBindMatrices; +} + +std::unordered_map ModelLoader::extractAnimations(const aiScene *scene, Model::Skeleton &skeleton) { + std::unordered_map out; + for (unsigned int i = 0; i < scene->mNumAnimations; i++) { + aiAnimation *anim = scene->mAnimations[i]; + + Animation a; + a.duration = anim->mDuration; + a.ticksPerSecond = anim->mTicksPerSecond != 0 ? anim->mTicksPerSecond : 25.0f; + + for (unsigned int c = 0; c < anim->mNumChannels; c++) { + aiNodeAnim *channel = anim->mChannels[c]; + std::string boneName = channel->mNodeName.C_Str(); + + BoneAnimation track; + + for (int k = 0; k < channel->mNumPositionKeys; k++) { + track.positions.push_back({(float) channel->mPositionKeys[k].mTime, aiVec3ToEigen(channel->mPositionKeys[k].mValue)}); + } + + for (int k = 0; k < channel->mNumRotationKeys; k++) { + track.rotations.push_back({(float) channel->mRotationKeys[k].mTime, aiQuatToEigen(channel->mRotationKeys[k].mValue)}); + } + + for (int k = 0; k < channel->mNumScalingKeys; k++) { + track.scales.push_back({(float) channel->mScalingKeys[k].mTime, aiVec3ToEigen(channel->mScalingKeys[k].mValue)}); + } + + a.tracks[boneName] = std::move(track); + } + std::string anim_name = anim->mName.C_Str(); + + out.try_emplace(anim_name.substr(anim_name.find_first_of('|') + 1), std::move(a)); + } + return out; +} + +Eigen::Vector4f ModelLoader::colorToVec(aiColor4D *color) { + Eigen::Vector4f v; + v.x() = color->r; + v.y() = color->g; + v.z() = color->b; + v.w() = color->a; + return v; +} + +Eigen::Matrix4f ModelLoader::aiMat4ToEigen(const aiMatrix4x4 &m) { + Eigen::Matrix4f out; + + out(0, 0) = m.a1; + out(0, 1) = m.a2; + out(0, 2) = m.a3; + out(0, 3) = m.a4; + out(1, 0) = m.b1; + out(1, 1) = m.b2; + out(1, 2) = m.b3; + out(1, 3) = m.b4; + out(2, 0) = m.c1; + out(2, 1) = m.c2; + out(2, 2) = m.c3; + out(2, 3) = m.c4; + out(3, 0) = m.d1; + out(3, 1) = m.d2; + out(3, 2) = m.d3; + out(3, 3) = m.d4; + + return out; +} + +Eigen::Vector3f ModelLoader::aiVec3ToEigen(const aiVector3D &vec) { + Eigen::Vector3f v; + v.x() = vec.x; + v.y() = vec.y; + v.z() = vec.z; + return v; +} + +Eigen::Quaternionf ModelLoader::aiQuatToEigen(const aiQuaternion &q) { + Eigen::Quaternionf quat; + quat.w() = q.w; + quat.x() = q.x; + quat.y() = q.y; + quat.z() = q.z; + return quat; +} + +TextureFormat ModelLoader::getTextureFormat(aiTextureType type, int channels) { + switch (type) { + case aiTextureType_METALNESS: + case aiTextureType_AMBIENT_OCCLUSION: + case aiTextureType_LIGHTMAP: + case aiTextureType_DIFFUSE_ROUGHNESS: + case aiTextureType_NORMALS: + return channels == 3 ? TextureFormat::RGB8 : TextureFormat::RGBA8; + + case aiTextureType_BASE_COLOR: + case aiTextureType_EMISSIVE: + return channels == 3 ? TextureFormat::SRGB8 : TextureFormat::SRGBA8; + default: + return channels == 3 ? TextureFormat::RGB8 : TextureFormat::RGBA8; + } +} + +} // namespace ICE diff --git a/ICE/IO/src/Project.cpp b/ICE/IO/src/Project.cpp index b90e0726..829dc360 100644 --- a/ICE/IO/src/Project.cpp +++ b/ICE/IO/src/Project.cpp @@ -7,23 +7,41 @@ #include #include #include +#include #include #include #include +#include #include #include #include +#include +#include +#include +#include "DefaultLoaders.h" #include "MaterialExporter.h" +#include "ModelLoader.h" #include "ShaderExporter.h" +#include namespace ICE { +namespace { +// The six built-in asset kinds are persisted in their own named sections; everything else (plugin +// types) goes through the generic "assets" section. Keep these in sync with the built-in prefixes +// pre-registered in AssetPath. +bool isBuiltinAssetPrefix(const std::string &prefix) { + static const std::unordered_set builtins = {"Textures", "CubeMaps", "Meshes", "Models", "Materials", "Shaders"}; + return builtins.find(prefix) != builtins.end(); +} +} // namespace Project::Project(const fs::path &base_directory, const std::string &m_name) : m_base_directory(base_directory / m_name), m_name(m_name), m_asset_bank(std::make_shared()), m_gpu_registry(std::make_shared(std::make_shared(), m_asset_bank)) { + registerDefaultLoaders(*m_asset_bank); cameraPosition.setZero(); cameraRotation.setZero(); constexpr std::string_view assets_folder = "Assets"; @@ -49,6 +67,7 @@ bool Project::CreateDirectories() { m_asset_bank->addAsset("pbr", {m_shaders_directory / "pbr.shader.json"}); m_asset_bank->addAsset("lastpass", {m_shaders_directory / "lastpass.shader.json"}); m_asset_bank->addAsset("__ice__picking_shader", {m_shaders_directory / "picking.shader.json"}); + m_asset_bank->addAsset("ui", {m_shaders_directory / "ui.shader.json"}); m_asset_bank->addAsset("base_mat", {m_materials_directory / "base_mat.material.json"}); @@ -58,7 +77,7 @@ bool Project::CreateDirectories() { m_asset_bank->addAsset("Editor/folder", {m_textures_directory / "Editor" / "folder.png"}); m_asset_bank->addAsset("Editor/shader", {m_textures_directory / "Editor" / "shader.png"}); - m_scenes.push_back(std::make_shared("MainScene")); + addScene(Scene("MainScene")); // addScene wires the asset bank into the scene setCurrentScene(getScenes()[0]); return true; } @@ -136,6 +155,27 @@ void Project::writeToFile(const std::shared_ptr &editorCamera) { vec.push_back(dumpAsset(asset_id, texture)); } j["cubeMaps"] = vec; + vec.clear(); + + // Generic section for plugin-defined asset kinds (anything whose path prefix is not one of the + // six built-ins). Keyed by prefix so load can route each entry to the right erased loader. Any + // entries whose plugin was missing at load are re-emitted verbatim first, so they are preserved. + std::vector custom_assets = m_unknown_assets; + for (const auto &entry : m_asset_bank->getAllEntries()) { + if (!entry.asset) { + continue; // reservation still loading / failed load: nothing to persist + } + const auto components = entry.path.getPath(); + std::string type_prefix = components.empty() ? "" : components.front(); + if (type_prefix.empty() || isBuiltinAssetPrefix(type_prefix)) { + continue; // built-ins are saved in their own sections above + } + AssetUID uid = m_asset_bank->getUID(entry.path); + json dumped = dumpAsset(uid, entry.asset); + dumped["prefix"] = type_prefix; + custom_assets.push_back(dumped); + } + j["assets"] = custom_assets; outstream << j.dump(4); outstream.close(); @@ -190,7 +230,7 @@ void Project::writeToFile(const std::shared_ptr &editorCamera) { spjson["skeletonModel"] = sc.skeletonModel; spjson["bone_entity"] = sc.bone_entity; std::vector bone_transforms; - for (const auto& tr : sc.bone_transform) { + for (const auto &tr : sc.bone_transform) { bone_transforms.push_back(JsonParser::dumpMat4(tr)); } spjson["bone_transforms"] = bone_transforms; @@ -227,8 +267,17 @@ json Project::dumpAsset(AssetUID uid, const std::shared_ptr &asset) { void Project::loadFromFile() { std::ifstream infile = std::ifstream(m_base_directory / (m_name + ".ice")); + if (!infile.is_open()) { + Logger::Log(Logger::ERROR, "IO", "Could not open project file '%s'", (m_base_directory / (m_name + ".ice")).string().c_str()); + return; + } json j; - infile >> j; + try { + infile >> j; + } catch (const std::exception &e) { + Logger::Log(Logger::ERROR, "IO", "Failed to parse project file: %s", e.what()); + return; + } infile.close(); std::vector sceneNames = j["scenes"]; @@ -249,10 +298,50 @@ void Project::loadFromFile() { loadAssetsOfType(meshes); loadAssetsOfType(models); + // Generic section for plugin-defined asset kinds. Route each entry to the right loader via its + // path prefix (AssetPath::typeForPrefix). If the type is unknown (its plugin isn't loaded) or has + // no loader, warn and keep the raw entry so the next save preserves it instead of dropping it. + m_unknown_assets.clear(); + if (j.contains("assets")) { + for (const auto &asset : j["assets"]) { + std::string prefix = asset.value("prefix", std::string()); + std::optional type; + if (!prefix.empty()) { + type = AssetPath::typeForPrefix(prefix); + } + if (!type.has_value()) { + Logger::Log(Logger::WARNING, "IO", "No registered asset type for prefix '%s'; preserving entry across save", prefix.c_str()); + m_unknown_assets.push_back(asset); + continue; + } + AssetUID uid = asset["uid"]; + std::string bank_path = asset["bank_path"]; + std::vector sources; + for (const auto &entry : asset["sources"]) { + sources.push_back(m_base_directory / std::string(entry)); + } + try { + m_asset_bank->addAssetWithSpecificUID(type.value(), AssetPath(bank_path), sources, uid); + } catch (const std::exception &e) { + Logger::Log(Logger::WARNING, "IO", "Could not load custom asset '%s' (%s); preserving entry", bank_path.c_str(), e.what()); + m_unknown_assets.push_back(asset); + } + } + } + for (const auto &s : sceneNames) { infile = std::ifstream(m_scenes_directory / (s + ".ics")); + if (!infile.is_open()) { + Logger::Log(Logger::ERROR, "IO", "Could not open scene file '%s'", s.c_str()); + continue; + } json scenejson; - infile >> scenejson; + try { + infile >> scenejson; + } catch (const std::exception &e) { + Logger::Log(Logger::ERROR, "IO", "Failed to parse scene '%s': %s", s.c_str(), e.what()); + continue; + } infile.close(); Scene scene = Scene(scenejson["m_name"]); @@ -296,7 +385,7 @@ void Project::loadFromFile() { sc.skeletonModel = sj["skeletonModel"]; sc.bone_entity = sj["bone_entity"].get>(); std::vector bone_transforms; - for (const auto& jt : sj["bone_transforms"]) { + for (const auto &jt : sj["bone_transforms"]) { bone_transforms.push_back(JsonParser().readMat4(jt)); } sc.bone_transform = bone_transforms; @@ -326,7 +415,15 @@ void Project::copyAssetFile(const fs::path &folder, const std::string &assetName auto dst = subfolder / (assetName + src.extension().string()); std::ifstream srcStream(src, std::ios::binary); + if (!srcStream.is_open()) { + Logger::Log(Logger::ERROR, "IO", "Could not open source asset '%s'", src.string().c_str()); + return; + } std::ofstream dstStream(dst, std::ios::binary); + if (!dstStream.is_open()) { + Logger::Log(Logger::ERROR, "IO", "Could not open destination '%s' for asset copy", dst.string().c_str()); + return; + } dstStream << srcStream.rdbuf(); dstStream.flush(); @@ -334,6 +431,45 @@ void Project::copyAssetFile(const fs::path &folder, const std::string &assetName dstStream.close(); } +AssetUID Project::importModel(const std::string &name, const fs::path &src) { + // Copy the source file into /Assets/Models (keeping its extension) and register it. + copyAssetFile("Models", name, src); + fs::path dst = m_models_directory / (name + src.extension().string()); + m_asset_bank->addAsset(name, {dst}); + return model(name); +} + +AssetUID Project::requestModel(const std::string &name, const std::vector &sources) { + // Resolve the one bank value the parse needs (the pbr shader UID) on the main thread, then stage + // (pure) on a worker and commit (bank mutation) on the main thread in pump(). The ModelLoader is + // captured by shared_ptr so it outlives the in-flight request; the commit runs only in pump(), + // where the bank is alive. + AssetUID pbr_shader_uid = m_asset_bank->getUID(AssetPath::WithTypePrefix("pbr")); + auto loader = std::make_shared(*m_asset_bank); + AssetBank *bank = m_asset_bank.get(); + + AssetBank::StageFn stage = [loader, sources, pbr_shader_uid]() -> std::shared_ptr { + return std::make_shared(loader->stage(sources, pbr_shader_uid)); + }; + AssetBank::CommitFn commit = [loader, bank](const std::shared_ptr &staged) -> std::shared_ptr { + auto staged_model = std::static_pointer_cast(staged); + return loader->commit(*staged_model, *bank); + }; + return m_asset_bank->requestAsset(name, std::move(stage), std::move(commit)); +} + +AssetUID Project::mesh(const std::string &name) const { + return m_asset_bank->getUID(AssetPath::WithTypePrefix(name)); +} + +AssetUID Project::material(const std::string &name) const { + return m_asset_bank->getUID(AssetPath::WithTypePrefix(name)); +} + +AssetUID Project::model(const std::string &name) const { + return m_asset_bank->getUID(AssetPath::WithTypePrefix(name)); +} + bool Project::renameAsset(const AssetPath &oldName, const AssetPath &newName) { if (newName.getName() == "" || newName.prefix() != oldName.prefix()) { return false; @@ -341,9 +477,12 @@ bool Project::renameAsset(const AssetPath &oldName, const AssetPath &newName) { if (m_asset_bank->renameAsset(oldName, newName)) { auto path = m_base_directory / "Assets"; for (const auto &file : getFilesInDir(path / oldName.prefix())) { - if (file.substr(0, file.find_last_of(".")) == oldName.getName()) { + // stem()/extension() handle files with no extension (the old substr(find_last_of(".")) + // threw std::out_of_range on those). + fs::path fp(file); + if (fp.stem().string() == oldName.getName()) { if (rename((path / oldName.prefix() / file).string().c_str(), - (path / oldName.prefix() / (newName.getName() + file.substr(file.find_last_of(".")))).string().c_str()) + (path / oldName.prefix() / (newName.getName() + fp.extension().string())).string().c_str()) == 0) { return true; } @@ -357,8 +496,9 @@ bool Project::renameAsset(const AssetPath &oldName, const AssetPath &newName) { std::vector Project::getFilesInDir(const fs::path &folder) { std::vector files; for (const auto &entry : fs::directory_iterator(folder)) { - std::string sp = entry.path().string(); - files.push_back(sp.substr(sp.find_last_of("/") + 1)); + // Use std::filesystem to extract the filename: splitting on '/' returned the whole + // path on Windows, where the separator is '\'. + files.push_back(entry.path().filename().string()); } return files; } @@ -369,6 +509,12 @@ std::vector> Project::getScenes() { void Project::setScenes(const std::vector> &scenes) { m_scenes = scenes; + // Keep spawn()/by-name lookups working on scenes set in bulk (e.g. after a load). + for (auto &scene : m_scenes) { + if (scene) { + scene->setAssetBank(m_asset_bank); + } + } } std::shared_ptr Project::getGPURegistry() { @@ -384,7 +530,11 @@ void Project::setAssetBank(const std::shared_ptr &asset_bank) { } void Project::addScene(const Scene &scene) { - m_scenes.push_back(std::make_shared(scene)); + auto stored = std::make_shared(scene); + // Wire the project's asset bank into the scene so scene.spawn()/by-name lookups work without + // the caller threading the bank through. + stored->setAssetBank(m_asset_bank); + m_scenes.push_back(stored); } void Project::setCurrentScene(const std::shared_ptr &scene) { @@ -394,6 +544,20 @@ std::shared_ptr Project::getCurrentScene() const { return m_current_scene; } +Scene &Project::createScene(const std::string &name) { + addScene(Scene(name)); + auto scene = m_scenes.back(); + m_current_scene = scene; + if (m_scene_activator) { + m_scene_activator(scene); // engine builds the scene's runtime systems + } + return *scene; +} + +void Project::setSceneActivator(const std::function&)> &activator) { + m_scene_activator = activator; +} + json Project::dumpVec3(const Eigen::Vector3f &v) { json r; r["x"] = v.x(); diff --git a/ICE/IO/src/ShaderLoader.cpp b/ICE/IO/src/ShaderLoader.cpp index c8f1ab36..01744ebe 100644 --- a/ICE/IO/src/ShaderLoader.cpp +++ b/ICE/IO/src/ShaderLoader.cpp @@ -17,10 +17,16 @@ std::shared_ptr ShaderLoader::load(const std::vector> json; - infile.close(); + if (!infile.is_open()) { + throw ICEException("Could not open shader file: " + files[0].string()); + } + nlohmann::json json; + try { + infile >> json; + } catch (const std::exception &e) { + throw ICEException("Failed to parse shader '" + files[0].string() + "': " + e.what()); + } ShaderSource shader_sources; for (const auto &stage_source : json) { @@ -37,7 +43,7 @@ std::shared_ptr ShaderLoader::load(const std::vector Texture2DLoader::load(const std::vector TextureCubeLoader::load(const std::vector &file) { Logger::Log(Logger::VERBOSE, "IO", "Loading cubemap..."); + if (file.empty()) { + return nullptr; + } auto texture = std::make_shared(file[0].string()); texture->setSources(file); return texture; diff --git a/ICE/IO/test/CMakeLists.txt b/ICE/IO/test/CMakeLists.txt index e7b0bb2e..c2c26892 100644 --- a/ICE/IO/test/CMakeLists.txt +++ b/ICE/IO/test/CMakeLists.txt @@ -38,3 +38,19 @@ add_custom_command( $ ) +add_executable(ProjectTestSuite + ProjectTest.cpp +) + +add_test(NAME ProjectTestSuite + COMMAND ProjectTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(ProjectTestSuite + PRIVATE + gtest_main + io + # Project constructs an OpenGLFactory (GL-free at construction); its vtable pulls the concrete + # OpenGL symbols, so link the OpenGL backend. + graphics_api_OpenGL) + diff --git a/ICE/IO/test/ModelLoaderTest.cpp b/ICE/IO/test/ModelLoaderTest.cpp index db406ab0..da3e6c86 100644 --- a/ICE/IO/test/ModelLoaderTest.cpp +++ b/ICE/IO/test/ModelLoaderTest.cpp @@ -1,9 +1,8 @@ -#define STB_IMAGE_IMPLEMENTATION - #include #include #include "MeshLoader.h" +#include "ModelLoader.h" using namespace ICE; @@ -11,4 +10,49 @@ TEST(ModelLoaderTest, LoadFromObj) { auto mesh = MeshLoader().load({"cube.obj"}); EXPECT_EQ(mesh->getVertices().size(), 36); EXPECT_EQ(mesh->getIndices().size(), 12); +} + +// stage() is pure: it must not mutate the AssetBank. All bank writes happen in commit(), which is +// what lets stage() run off the main thread. +TEST(ModelLoaderTest, StageDoesNotTouchBankCommitDoes) { + AssetBank bank; + ModelLoader loader(bank); + + StagedModel staged = loader.stage({"cube.obj"}, NO_ASSET_ID); + ASSERT_TRUE(staged.valid); + ASSERT_FALSE(staged.meshes.empty()); + ASSERT_EQ(bank.getAllEntries().size(), 0u) << "stage() must not mutate the bank"; + + auto model = loader.commit(staged, bank); + ASSERT_NE(model, nullptr); + ASSERT_FALSE(model->getMeshes().empty()); + ASSERT_GT(bank.getAllEntries().size(), 0u) << "commit() populates the bank"; +} + +// Re-importing the same model preserves sub-asset UIDs (so existing scene/component references stay +// valid) and does not grow the bank. +TEST(ModelLoaderTest, ReimportPreservesUIDs) { + AssetBank bank; + ModelLoader loader(bank); + + auto first = loader.load({"cube.obj"}); + ASSERT_NE(first, nullptr); + auto first_meshes = first->getMeshes(); + auto count_after_first = bank.getAllEntries().size(); + + auto second = loader.load({"cube.obj"}); + ASSERT_NE(second, nullptr); + ASSERT_EQ(second->getMeshes(), first_meshes) << "mesh UIDs changed on re-import"; + ASSERT_EQ(bank.getAllEntries().size(), count_after_first) << "re-import must not grow the bank"; +} + +// A failed parse (empty file list) stages nothing and commits nothing -- no partial bank state. +TEST(ModelLoaderTest, FailedParseCommitsNothing) { + AssetBank bank; + ModelLoader loader(bank); + + StagedModel staged = loader.stage({}, NO_ASSET_ID); + ASSERT_FALSE(staged.valid); + ASSERT_EQ(loader.commit(staged, bank), nullptr); + ASSERT_EQ(bank.getAllEntries().size(), 0u); } \ No newline at end of file diff --git a/ICE/IO/test/ProjectTest.cpp b/ICE/IO/test/ProjectTest.cpp new file mode 100644 index 00000000..a4a9f662 --- /dev/null +++ b/ICE/IO/test/ProjectTest.cpp @@ -0,0 +1,100 @@ +#include + +#include +#include +#include + +#include +#include + +using namespace ICE; +namespace fs = std::filesystem; + +namespace { +// A plugin-style asset kind + loader used only by this suite. The loader ignores its sources and +// returns a fixed asset, so the round-trip does not depend on any on-disk source file. +class TestCustomAsset : public Asset { + public: + explicit TestCustomAsset(int v = 0) : value(v) {} + AssetType getType() const override { return AssetType::EOther; } + std::string getTypeName() const override { return "TestCustom"; } + int value; +}; + +class TestCustomLoader : public IAssetLoader { + public: + std::shared_ptr load(const std::vector &) override { return std::make_shared(123); } +}; + +// A fresh, empty project base directory under the system temp dir. +fs::path freshBase() { + static int counter = 0; + fs::path base = fs::temp_directory_path() / ("ice_proj_test_" + std::to_string(++counter)); + std::error_code ec; + fs::remove_all(base, ec); + fs::create_directories(base); + return base; +} + +std::shared_ptr makeCamera() { return std::make_shared(70.0, 1.0, 0.1, 100.0); } +} // namespace + +// A custom (plugin-defined) asset survives save -> load through the generic "assets" section, keyed +// by its path prefix and reloaded via the erased loader. +TEST(ProjectTest, CustomAssetTypeRoundTrips) { + fs::path base = freshBase(); + auto cam = makeCamera(); + + AssetUID uid = NO_ASSET_ID; + { + Project proj(base, "Proj"); + fs::create_directories(proj.getBaseDirectory()); + proj.getAssetBank()->addLoader("TestCustom", std::make_shared()); + ASSERT_TRUE(proj.getAssetBank()->addAsset("thing", std::make_shared(7))); + uid = proj.getAssetBank()->getUID(AssetPath::WithTypePrefix("thing")); + proj.writeToFile(cam); + } + { + Project proj(base, "Proj"); + proj.getAssetBank()->addLoader("TestCustom", std::make_shared()); + proj.loadFromFile(); + auto asset = proj.getAssetBank()->getAsset("thing"); + ASSERT_NE(asset, nullptr); + ASSERT_EQ(asset->value, 123); // came back through the loader, not by value + ASSERT_EQ(proj.getAssetBank()->getUID(AssetPath::WithTypePrefix("thing")), uid); + } + std::error_code ec; + fs::remove_all(base, ec); +} + +// When the providing plugin/loader isn't available at load, the entry is not dropped: it is +// preserved and re-emitted on the next save, so re-loading with the loader present restores it. +TEST(ProjectTest, MissingLoaderPreservesCustomAssetAcrossSave) { + fs::path base = freshBase(); + auto cam = makeCamera(); + + { + Project proj(base, "Proj"); + fs::create_directories(proj.getBaseDirectory()); + proj.getAssetBank()->addLoader("TestCustom", std::make_shared()); + proj.getAssetBank()->addAsset("thing", std::make_shared(7)); + proj.writeToFile(cam); + } + { + // Load with no loader registered on this bank: the asset can't be built, so it is preserved + // and written back out verbatim. + Project proj(base, "Proj"); + proj.loadFromFile(); + ASSERT_EQ(proj.getAssetBank()->getUID(AssetPath::WithTypePrefix("thing")), NO_ASSET_ID); + proj.writeToFile(cam); + } + { + // With the loader present again, the preserved entry loads normally. + Project proj(base, "Proj"); + proj.getAssetBank()->addLoader("TestCustom", std::make_shared()); + proj.loadFromFile(); + ASSERT_NE(proj.getAssetBank()->getAsset("thing"), nullptr); + } + std::error_code ec; + fs::remove_all(base, ec); +} diff --git a/ICE/Math/CMakeLists.txt b/ICE/Math/CMakeLists.txt index e189b26a..9a5041db 100644 --- a/ICE/Math/CMakeLists.txt +++ b/ICE/Math/CMakeLists.txt @@ -7,8 +7,7 @@ add_library(${PROJECT_NAME} STATIC) target_sources(${PROJECT_NAME} PRIVATE src/AABB.cpp - src/ICEMath.cpp -) + src/ICEMath.cpp) #target_link_libraries(${PROJECT_NAME}) @@ -17,4 +16,4 @@ target_include_directories(${PROJECT_NAME} PUBLIC $) enable_testing() -#add_subdirectory(test) +add_subdirectory(test) diff --git a/ICE/Math/include/AABB.h b/ICE/Math/include/AABB.h index 8f44ec29..df48d681 100644 --- a/ICE/Math/include/AABB.h +++ b/ICE/Math/include/AABB.h @@ -2,35 +2,35 @@ // Created by Thomas Ibanez on 29.12.20. // -#ifndef ICE_AABB_H -#define ICE_AABB_H +#pragma once #include #include namespace ICE { - class AABB { - public: - AABB(const Eigen::Vector3f& min, const Eigen::Vector3f& max); - AABB(const std::vector& points); - - AABB scaledBy(const Eigen::Vector3f& scale) const; - AABB translatedBy(const Eigen::Vector3f& tr) const; - float getVolume() const; - bool overlaps(const AABB& other) const; - bool contains(const Eigen::Vector3f& point) const; - AABB operator+(const AABB& other) const; - AABB unionWith(const AABB& other) const; - Eigen::Vector3f getCenter() const; - - const Eigen::Vector3f &getMin() const; - - const Eigen::Vector3f &getMax() const; - - private: - Eigen::Vector3f min, max; - }; -} - - -#endif //ICE_AABB_H +class AABB { + public: + AABB(const Eigen::Vector3f& min, const Eigen::Vector3f& max); + AABB(const std::vector& points); + + AABB scaledBy(const Eigen::Vector3f& scale) const; + AABB translatedBy(const Eigen::Vector3f& tr) const; + float getVolume() const; + bool overlaps(const AABB& other) const; + bool contains(const Eigen::Vector3f& point) const; + AABB operator+(const AABB& other) const; + AABB unionWith(const AABB& other) const; + Eigen::Vector3f getCenter() const; + Eigen::Vector3f getExtent() const; + + const Eigen::Vector3f& getMin() const; + + const Eigen::Vector3f& getMax() const; + + private: + void precomputeCenterAndExtent(); + + Eigen::Vector3f min, max; + Eigen::Vector3f center, extent; +}; +} // namespace ICE diff --git a/ICE/Math/include/ICEMath.h b/ICE/Math/include/ICEMath.h index c326e258..e78b2349 100644 --- a/ICE/Math/include/ICEMath.h +++ b/ICE/Math/include/ICEMath.h @@ -24,6 +24,16 @@ namespace ICE { +struct Plane { + Eigen::Vector3f normal; + Eigen::Vector3f absNormal; + float distance; +}; + +struct Frustum { + std::array planes; // left, right, top, bottom, near, far +}; + Eigen::Matrix4f rotationMatrix(Eigen::Vector3f angles, bool yaw_first = true); Eigen::Matrix4f translationMatrix(Eigen::Vector3f translation); Eigen::Matrix4f scaleMatrix(Eigen::Vector3f scale); @@ -33,8 +43,9 @@ void decomposeMatrix(const Eigen::Matrix4f &M, Eigen::Vector3f &position, Eigen: Eigen::Vector3f orientation(int face, float x, float y); int clamp(int x, int a, int b); std::array equirectangularToCubemap(uint8_t *inputPixels, int width, int height, float rotation = 180); -std::array extractFrustumPlanes(const Eigen::Matrix4f &PV); -bool isAABBInFrustum(const std::array &frustum, const AABB &aabb); +Frustum extractFrustumPlanes(const Eigen::Matrix4f &PV); +bool isAABBInFrustum(const Frustum &frustum, const AABB &aabb); +bool isAABBInFrustum(const Frustum &frustum, const Eigen::Vector3f ¢er, const Eigen::Vector3f &extents); } // namespace ICE #endif //ICE_ICEMATH_H diff --git a/ICE/Math/src/AABB.cpp b/ICE/Math/src/AABB.cpp index 03547ed2..3237c343 100644 --- a/ICE/Math/src/AABB.cpp +++ b/ICE/Math/src/AABB.cpp @@ -6,13 +6,26 @@ namespace ICE { -AABB::AABB(const Eigen::Vector3f &min, const Eigen::Vector3f &max) : min(min), max(max) { +AABB::AABB(const Eigen::Vector3f &a, const Eigen::Vector3f &b) { + // Set corners directly (no heap-allocated std::vector). cwiseMin/Max keeps min <= max + // even when callers pass swapped corners (e.g. scaledBy with a negative scale). + min = a.cwiseMin(b); + max = a.cwiseMax(b); + precomputeCenterAndExtent(); } -AABB::AABB(const std::vector &points) : AABB(points[0], points[0]) { +AABB::AABB(const std::vector &points) { + if (points.empty()) { + min = max = Eigen::Vector3f::Zero(); + precomputeCenterAndExtent(); + return; + } + min = points[0]; + max = points[0]; for (const auto &v : points) { min = min.cwiseMin(v); max = max.cwiseMax(v); } + precomputeCenterAndExtent(); } AABB AABB::scaledBy(const Eigen::Vector3f &scale) const { @@ -44,7 +57,11 @@ AABB AABB::unionWith(const AABB &other) const { } Eigen::Vector3f AABB::getCenter() const { - return (min + max) / 2; + return center; +} + +Eigen::Vector3f AABB::getExtent() const { + return extent; } const Eigen::Vector3f &AABB::getMin() const { @@ -54,4 +71,9 @@ const Eigen::Vector3f &AABB::getMin() const { const Eigen::Vector3f &AABB::getMax() const { return max; } + +void AABB::precomputeCenterAndExtent() { + center = (min + max) * 0.5f; + extent = (max - min) * 0.5f; +} } // namespace ICE \ No newline at end of file diff --git a/ICE/Math/src/ICEMath.cpp b/ICE/Math/src/ICEMath.cpp index f5e5fc2c..2d6cbb74 100644 --- a/ICE/Math/src/ICEMath.cpp +++ b/ICE/Math/src/ICEMath.cpp @@ -158,7 +158,12 @@ std::array equirectangularToCubemap(uint8_t *inputPixels, int widt Eigen::Vector3f cube = orientation(i, (2 * (x + 0.5) / faceWidth - 1), (2 * (y + 0.5) / faceHeight - 1)); auto r = cube.norm(); - auto lon = fmod(atan2(cube.y(), cube.x()) + rotation, 2 * M_PI); + // rotation is in degrees; it was being added straight to a radian longitude. + // Also wrap negative longitudes into [0, 2pi) so they don't clamp to column 0. + auto lon = fmod(atan2(cube.y(), cube.x()) + DEG_TO_RAD(rotation), 2 * M_PI); + if (lon < 0) { + lon += 2 * M_PI; + } auto lat = acos(cube.z() / r); int fx = width * lon / M_PI / 2 - 0.5; @@ -174,41 +179,49 @@ std::array equirectangularToCubemap(uint8_t *inputPixels, int widt return outputPixels; } -std::array extractFrustumPlanes(const Eigen::Matrix4f &PV) { - std::array planes; - // Left - planes[0] = {PV(3, 0) + PV(0, 0), PV(3, 1) + PV(0, 1), PV(3, 2) + PV(0, 2), PV(3, 3) + PV(0, 3)}; - // Right - planes[1] = {PV(3, 0) - PV(0, 0), PV(3, 1) - PV(0, 1), PV(3, 2) - PV(0, 2), PV(3, 3) - PV(0, 3)}; - // Bottom - planes[2] = {PV(3, 0) + PV(1, 0), PV(3, 1) + PV(1, 1), PV(3, 2) + PV(1, 2), PV(3, 3) + PV(1, 3)}; - // Top - planes[3] = {PV(3, 0) - PV(1, 0), PV(3, 1) - PV(1, 1), PV(3, 2) - PV(1, 2), PV(3, 3) - PV(1, 3)}; - // Near - planes[4] = {PV(3, 0) + PV(2, 0), PV(3, 1) + PV(2, 1), PV(3, 2) + PV(2, 2), PV(3, 3) + PV(2, 3)}; - // Far - planes[5] = {PV(3, 0) - PV(2, 0), PV(3, 1) - PV(2, 1), PV(3, 2) - PV(2, 2), PV(3, 3) - PV(2, 3)}; - // Normalize planes - for (int i = 0; i < 6; i++) { - float length = sqrt(planes[i](0) * planes[i](0) + planes[i](1) * planes[i](1) + planes[i](2) * planes[i](2)); - planes[i](0) /= length; - planes[i](1) /= length; - planes[i](2) /= length; - planes[i](3) /= length; - } - return planes; +Frustum extractFrustumPlanes(const Eigen::Matrix4f &PV) { + Frustum frustum; + auto extract = [](const Eigen::Vector4f &p) { + Plane plane; + + Eigen::Vector3f n = p.head<3>(); + float length = n.norm(); + + plane.normal = n / length; + plane.distance = p[3] / length; + plane.absNormal = plane.normal.cwiseAbs(); + + return plane; + }; + + frustum.planes[0] = extract(PV.row(3) + PV.row(0)); // Left + frustum.planes[1] = extract(PV.row(3) - PV.row(0)); // Right + frustum.planes[2] = extract(PV.row(3) + PV.row(1)); // Bottom + frustum.planes[3] = extract(PV.row(3) - PV.row(1)); // Top + frustum.planes[4] = extract(PV.row(3) + PV.row(2)); // Near + frustum.planes[5] = extract(PV.row(3) - PV.row(2)); // Far + + return frustum; } -bool isAABBInFrustum(const std::array &frustum, const AABB &aabb) { - for (int i = 0; i < 6; i++) { - Eigen::Vector3f positive(frustum[i](0) >= 0 ? aabb.getMax().x() : aabb.getMin().x(), - frustum[i](1) >= 0 ? aabb.getMax().y() : aabb.getMin().y(), - frustum[i](2) >= 0 ? aabb.getMax().z() : aabb.getMin().z()); +bool isAABBInFrustum(const Frustum &frustum, const AABB &aabb) { + return isAABBInFrustum(frustum, aabb.getCenter(), aabb.getExtent()); +} + +bool isAABBInFrustum(const Frustum &frustum, const Eigen::Vector3f ¢er, const Eigen::Vector3f &extents) { + for (int i = 0; i < 6; ++i) { + const Plane &plane = frustum.planes[i]; + + const Eigen::Vector3f &n = plane.absNormal; - if (frustum[i](0) * positive.x() + frustum[i](1) * positive.y() + frustum[i](2) * positive.z() + frustum[i](3) < 0) { + float r = extents.x() * n.x() + extents.y() * n.y() + extents.z() * n.z(); + + float s = plane.normal.dot(center) + plane.distance; + + if (s + r < 0.0f) return false; - } } - return true; + + return true; // At least partially inside } } // namespace ICE diff --git a/ICE/Math/test/AABBTest.cpp b/ICE/Math/test/AABBTest.cpp new file mode 100644 index 00000000..f82c3a32 --- /dev/null +++ b/ICE/Math/test/AABBTest.cpp @@ -0,0 +1,100 @@ +#include +#include +#include +#include + +#include + +#include "AABB.h" +#include "ICEMath.h" + +using namespace ICE; + +class FrustumCullingTest : public ::testing::Test { + +}; +// Test AABB class +TEST_F(FrustumCullingTest, AABB_Construction) { + Eigen::Vector3f min(0, 1, 2); + Eigen::Vector3f max(3, 4, 5); + + AABB box(min, max); + + EXPECT_EQ(box.getMin(), min); + EXPECT_EQ(box.getMax(), max); +} + +TEST_F(FrustumCullingTest, AABB_CenterAndExtent) { + Eigen::Vector3f min(0, 0, 0); + Eigen::Vector3f max(2, 4, 6); + + AABB box(min, max); + + Eigen::Vector3f expectedCenter(1, 2, 3); + Eigen::Vector3f expectedExtent(1, 2, 3); + + EXPECT_EQ(box.getCenter(), expectedCenter); + EXPECT_EQ(box.getExtent(), expectedExtent); +} + +TEST_F(FrustumCullingTest, AABB_Overlaps) { + AABB box1(Eigen::Vector3f(0, 0, 0), Eigen::Vector3f(2, 2, 2)); + AABB box2(Eigen::Vector3f(1, 1, 1), Eigen::Vector3f(3, 3, 3)); + AABB box3(Eigen::Vector3f(5, 5, 5), Eigen::Vector3f(7, 7, 7)); + + EXPECT_TRUE(box1.overlaps(box2)); + EXPECT_TRUE(box2.overlaps(box1)); + EXPECT_FALSE(box1.overlaps(box3)); + EXPECT_FALSE(box3.overlaps(box1)); +} + +TEST_F(FrustumCullingTest, AABB_Contains) { + AABB box(Eigen::Vector3f(0, 0, 0), Eigen::Vector3f(2, 2, 2)); + + EXPECT_TRUE(box.contains(Eigen::Vector3f(1, 1, 1))); + EXPECT_TRUE(box.contains(Eigen::Vector3f(0, 0, 0))); + EXPECT_TRUE(box.contains(Eigen::Vector3f(2, 2, 2))); + EXPECT_FALSE(box.contains(Eigen::Vector3f(3, 3, 3))); + EXPECT_FALSE(box.contains(Eigen::Vector3f(-1, 0, 0))); +} + +TEST_F(FrustumCullingTest, AABB_ScaledBy) { + AABB box(Eigen::Vector3f(1, 2, 3), Eigen::Vector3f(4, 6, 9)); + AABB scaled = box.scaledBy(Eigen::Vector3f(2, 2, 2)); + + EXPECT_EQ(scaled.getMin(), Eigen::Vector3f(2, 4, 6)); + EXPECT_EQ(scaled.getMax(), Eigen::Vector3f(8, 12, 18)); +} + +TEST_F(FrustumCullingTest, AABB_TranslatedBy) { + AABB box(Eigen::Vector3f(0, 0, 0), Eigen::Vector3f(2, 2, 2)); + AABB translated = box.translatedBy(Eigen::Vector3f(5, 10, 15)); + + EXPECT_EQ(translated.getMin(), Eigen::Vector3f(5, 10, 15)); + EXPECT_EQ(translated.getMax(), Eigen::Vector3f(7, 12, 17)); +} + +TEST_F(FrustumCullingTest, AABB_UnionWith) { + AABB box1(Eigen::Vector3f(0, 0, 0), Eigen::Vector3f(2, 2, 2)); + AABB box2(Eigen::Vector3f(1, 1, 1), Eigen::Vector3f(5, 5, 5)); + + AABB unified = box1.unionWith(box2); + + EXPECT_EQ(unified.getMin(), Eigen::Vector3f(0, 0, 0)); + EXPECT_EQ(unified.getMax(), Eigen::Vector3f(5, 5, 5)); +} + +TEST_F(FrustumCullingTest, AABB_Volume) { + AABB box(Eigen::Vector3f(0, 0, 0), Eigen::Vector3f(2, 3, 4)); + + EXPECT_FLOAT_EQ(box.getVolume(), 24.0f); +} + +TEST_F(FrustumCullingTest, AABB_ConstructFromPoints) { + std::vector points = {Eigen::Vector3f(0, 0, 0), Eigen::Vector3f(5, 2, 3), Eigen::Vector3f(1, 8, 2), Eigen::Vector3f(3, 1, 6)}; + + AABB box(points); + + EXPECT_EQ(box.getMin(), Eigen::Vector3f(0, 0, 0)); + EXPECT_EQ(box.getMax(), Eigen::Vector3f(5, 8, 6)); +} \ No newline at end of file diff --git a/ICE/Math/test/CMakeLists.txt b/ICE/Math/test/CMakeLists.txt new file mode 100644 index 00000000..04f20588 --- /dev/null +++ b/ICE/Math/test/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.19) +project(math-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +add_executable(MathTestSuite + AABBTest.cpp +) + +add_test(NAME MathTestSuite + COMMAND MathTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(MathTestSuite + PRIVATE + gtest_main + core + math) + + diff --git a/ICE/Multithreading/CMakeLists.txt b/ICE/Multithreading/CMakeLists.txt new file mode 100644 index 00000000..fa2d895d --- /dev/null +++ b/ICE/Multithreading/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.19) +project(multithreading) + +message(STATUS "Building ${PROJECT_NAME} module") + +add_library(${PROJECT_NAME} INTERFACE) + +# JobScheduler uses std::thread; propagate the platform threads library to every consumer (a no-op +# on MSVC, links pthread on POSIX). +find_package(Threads REQUIRED) + +target_include_directories(${PROJECT_NAME} INTERFACE + $ + $) + +target_link_libraries(${PROJECT_NAME} INTERFACE Threads::Threads) + +enable_testing() +add_subdirectory(test) diff --git a/ICE/Multithreading/include/JobScheduler.h b/ICE/Multithreading/include/JobScheduler.h new file mode 100644 index 00000000..8bc7d44a --- /dev/null +++ b/ICE/Multithreading/include/JobScheduler.h @@ -0,0 +1,309 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ICE { + +// A work-stealing thread pool for data-parallel, fork-join style work (e.g. per-frame render +// culling). Each worker owns a deque: it runs its own tasks LIFO and steals from other workers' +// deques FIFO when idle. dispatch()/parallelRanges() split work into tasks and block until all +// complete -- and the calling thread participates as an extra worker, so it is never left idle and +// completion is guaranteed even if a worker misses a wakeup. +// +// The deques are guarded by per-worker mutexes (correct rather than lock-free); for coarse chunked +// jobs contention is negligible. Not re-entrant: do not call dispatch() from inside a task running +// on this scheduler. +class JobScheduler { + public: + // worker_count == 0 -> hardware_concurrency() - 1 (leaving a core for the calling thread), + // clamped to at least 1. + explicit JobScheduler(std::size_t worker_count = 0) { + if (worker_count == 0) { + unsigned hc = std::thread::hardware_concurrency(); + worker_count = hc > 1 ? static_cast(hc - 1) : 1; + } + m_workers.reserve(worker_count); + for (std::size_t i = 0; i < worker_count; ++i) { + m_workers.push_back(std::make_unique()); + } + m_threads.reserve(worker_count); + for (std::size_t i = 0; i < worker_count; ++i) { + m_threads.emplace_back([this, i] { workerLoop(i); }); + } + } + + ~JobScheduler() { + m_stop.store(true, std::memory_order_release); + { + std::lock_guard lock(m_wake_mutex); + m_wake.notify_all(); + } + for (auto& t : m_threads) { + if (t.joinable()) { + t.join(); + } + } + } + + JobScheduler(const JobScheduler&) = delete; + JobScheduler& operator=(const JobScheduler&) = delete; + + std::size_t workerCount() const { return m_workers.size(); } + + // Run job(i) for i in [0, count), blocking until all complete. Small counts (or an empty pool) + // run inline on the calling thread. An exception thrown by any job is re-thrown here after all + // tasks finish. + void dispatch(std::size_t count, const std::function& job) { + if (count == 0) { + return; + } + if (m_workers.empty() || count == 1) { + for (std::size_t i = 0; i < count; ++i) { + job(i); + } + return; + } + + std::mutex ex_mutex; + std::exception_ptr captured_ex; + m_pending.store(count, std::memory_order_relaxed); + + for (std::size_t i = 0; i < count; ++i) { + auto& worker = *m_workers[i % m_workers.size()]; + std::lock_guard lock(worker.mutex); + worker.queue.push_back([this, &job, i, &ex_mutex, &captured_ex] { + try { + job(i); + } catch (...) { + std::lock_guard lk(ex_mutex); + if (!captured_ex) { + captured_ex = std::current_exception(); + } + } + // Always decrement so a throwing job can never wedge the wait below. + if (m_pending.fetch_sub(1, std::memory_order_acq_rel) == 1) { + std::lock_guard lk(m_wake_mutex); + m_wake.notify_all(); + } + }); + } + + { + std::lock_guard lock(m_wake_mutex); + m_wake.notify_all(); + } + + // The calling thread helps drain tasks until the batch is done (guarantees progress even + // if every worker missed the wakeup). + std::function task; + while (m_pending.load(std::memory_order_acquire) > 0) { + if (stealAny(task)) { + task(); + } else { + std::this_thread::yield(); + } + } + + if (captured_ex) { + std::rethrow_exception(captured_ex); + } + } + + // Split [0, count) into contiguous ranges (~workerCount * chunks_per_worker of them) and call + // fn(begin, end) for each in parallel, blocking until all complete. + void parallelRanges(std::size_t count, const std::function& fn, + std::size_t chunks_per_worker = 4) { + if (count == 0) { + return; + } + std::size_t target = m_workers.empty() ? 1 : m_workers.size() * (chunks_per_worker == 0 ? 1 : chunks_per_worker); + std::size_t nchunks = target < count ? target : count; + const std::size_t base = count / nchunks; + const std::size_t rem = count % nchunks; + dispatch(nchunks, [&](std::size_t c) { + std::size_t begin = c * base + (c < rem ? c : rem); + std::size_t end = begin + base + (c < rem ? 1 : 0); + fn(begin, end); + }); + } + + // Fire-and-forget: enqueue a detached task and return immediately. Unlike dispatch(), this does + // not block and does not participate in batch-completion accounting (m_pending), so it can be + // freely interleaved with per-frame dispatch() batches -- a batch never hangs or completes early + // because of detached work. Detached tasks live on separate per-worker deques that the dispatch + // calling thread never drains, so a long-running background job (e.g. an asset load) is never + // picked up on the frame's calling thread; it only ever occupies a worker. An exception escaping + // the job is caught and logged (there is no caller to rethrow to). + // + // Shutdown semantics: on destruction, detached tasks still queued are dropped; a detached task + // already running finishes before the worker threads join. An owner that needs the result must + // arrange its own synchronization (e.g. a completion queue drained on the main thread) and flush + // before the scheduler is destroyed. + void submit(std::function job) { + if (!job) { + return; + } + if (m_workers.empty()) { + // The pool is clamped to >= 1 worker, so this is defensive only: run inline rather than + // silently drop the work. + runGuarded(job); + return; + } + // Counts tasks that are queued but not yet started; keeps workers awake (see the wait + // predicate) so a detached task is picked up promptly instead of only on the 2ms backstop. + m_pending_detached.fetch_add(1, std::memory_order_relaxed); + auto wrapped = [this, task = std::move(job)]() mutable { + // Decrement as soon as the task starts: once it is running it is no longer waiting to be + // picked up, so idle workers can go back to sleep instead of spinning. + m_pending_detached.fetch_sub(1, std::memory_order_acq_rel); + runGuarded(task); + }; + std::size_t idx = m_submit_rr.fetch_add(1, std::memory_order_relaxed) % m_workers.size(); + { + auto& worker = *m_workers[idx]; + std::lock_guard lock(worker.mutex); + worker.detached.push_back(std::move(wrapped)); + } + std::lock_guard lk(m_wake_mutex); + m_wake.notify_all(); + } + + private: + struct Worker { + // Batch tasks (fork-join dispatch) and detached tasks (submit) are kept in separate deques: + // workers prefer batch work, and the dispatch calling thread only ever steals batch work + // (never a long-running detached job). + std::deque> queue; + std::deque> detached; + std::mutex mutex; + }; + + // Run a detached job, swallowing any exception (nowhere to rethrow to on a fire-and-forget path). + static void runGuarded(const std::function& job) { + try { + job(); + } catch (const std::exception& e) { + std::fprintf(stderr, "[JobScheduler] detached job threw: %s\n", e.what()); + } catch (...) { + std::fprintf(stderr, "[JobScheduler] detached job threw an unknown exception\n"); + } + } + + void workerLoop(std::size_t index) { + while (!m_stop.load(std::memory_order_acquire)) { + std::function task; + if (getTask(index, task)) { + task(); + continue; + } + // No work: sleep until a batch is dispatched, a detached task is submitted, or we are + // stopping. The short timeout is a backstop against a missed wakeup (the dispatch calling + // thread is the real guarantee for batch work). + std::unique_lock lock(m_wake_mutex); + m_wake.wait_for(lock, std::chrono::milliseconds(2), [this] { + return m_stop.load(std::memory_order_acquire) || m_pending.load(std::memory_order_acquire) > 0 + || m_pending_detached.load(std::memory_order_acquire) > 0; + }); + } + } + + // A worker takes from its own deque (LIFO, cache-friendly) then steals from others (FIFO). Batch + // work is exhausted before detached work so per-frame dispatch batches are never delayed by + // background jobs. + bool getTask(std::size_t index, std::function& out) { + { + auto& own = *m_workers[index]; + std::lock_guard lock(own.mutex); + if (!own.queue.empty()) { + out = std::move(own.queue.back()); + own.queue.pop_back(); + return true; + } + } + if (steal(index, out)) { + return true; + } + { + auto& own = *m_workers[index]; + std::lock_guard lock(own.mutex); + if (!own.detached.empty()) { + out = std::move(own.detached.front()); + own.detached.pop_front(); + return true; + } + } + return stealDetached(index, out); + } + + bool steal(std::size_t skip_index, std::function& out) { + for (std::size_t n = 0; n < m_workers.size(); ++n) { + if (n == skip_index) { + continue; + } + auto& w = *m_workers[n]; + std::lock_guard lock(w.mutex); + if (!w.queue.empty()) { + out = std::move(w.queue.front()); + w.queue.pop_front(); + return true; + } + } + return false; + } + + // Steal a detached task from another worker (FIFO). Only workers run detached tasks; the + // dispatch calling thread (stealAny) never does, so a background job can't stall a frame. + bool stealDetached(std::size_t skip_index, std::function& out) { + for (std::size_t n = 0; n < m_workers.size(); ++n) { + if (n == skip_index) { + continue; + } + auto& w = *m_workers[n]; + std::lock_guard lock(w.mutex); + if (!w.detached.empty()) { + out = std::move(w.detached.front()); + w.detached.pop_front(); + return true; + } + } + return false; + } + + // For the calling thread, which owns no deque: steal batch work from any worker. Deliberately + // does not touch the detached deques -- the calling thread must never run a background job while + // draining a fork-join batch. + bool stealAny(std::function& out) { + for (std::size_t n = 0; n < m_workers.size(); ++n) { + auto& w = *m_workers[n]; + std::lock_guard lock(w.mutex); + if (!w.queue.empty()) { + out = std::move(w.queue.front()); + w.queue.pop_front(); + return true; + } + } + return false; + } + + std::vector> m_workers; + std::vector m_threads; + std::mutex m_wake_mutex; + std::condition_variable m_wake; + std::atomic m_stop{false}; + std::atomic m_pending{0}; // outstanding batch tasks (gates dispatch) + std::atomic m_pending_detached{0}; // detached tasks queued but not yet started + std::atomic m_submit_rr{0}; // round-robin cursor for submit() +}; +} // namespace ICE diff --git a/ICE/Multithreading/test/CMakeLists.txt b/ICE/Multithreading/test/CMakeLists.txt new file mode 100644 index 00000000..22010b7b --- /dev/null +++ b/ICE/Multithreading/test/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.19) +project(multithreading-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +find_package(Threads REQUIRED) + +add_executable(JobSchedulerTestSuite + JobSchedulerTest.cpp +) + +add_test(NAME JobSchedulerTestSuite + COMMAND JobSchedulerTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(JobSchedulerTestSuite + PRIVATE + gtest_main + multithreading + Threads::Threads +) diff --git a/ICE/Multithreading/test/JobSchedulerTest.cpp b/ICE/Multithreading/test/JobSchedulerTest.cpp new file mode 100644 index 00000000..598d536d --- /dev/null +++ b/ICE/Multithreading/test/JobSchedulerTest.cpp @@ -0,0 +1,163 @@ +#include + +#include +#include +#include +#include +#include + +#include "JobScheduler.h" + +using namespace ICE; + +namespace { +// Spin-wait until `pred` holds or the timeout elapses; returns whether it became true. Used to wait +// on detached (submit) completion, which has no built-in join. +template +bool waitFor(Pred pred, std::chrono::milliseconds timeout = std::chrono::seconds(5)) { + auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (pred()) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return pred(); +} +} // namespace + +// Every index in [0, count) runs exactly once. +TEST(JobSchedulerTest, DispatchRunsEachIndexOnce) { + JobScheduler scheduler; + constexpr std::size_t count = 10000; + std::vector> hits(count); + for (auto& h : hits) h.store(0); + + scheduler.dispatch(count, [&](std::size_t i) { hits[i].fetch_add(1, std::memory_order_relaxed); }); + + for (std::size_t i = 0; i < count; ++i) { + ASSERT_EQ(hits[i].load(), 1) << "index " << i << " ran " << hits[i].load() << " times"; + } +} + +// Concurrent accumulation matches the serial sum (no lost updates). +TEST(JobSchedulerTest, DispatchParallelSumIsCorrect) { + JobScheduler scheduler; + constexpr std::size_t count = 100000; + std::atomic sum{0}; + + scheduler.dispatch(count, [&](std::size_t i) { sum.fetch_add(static_cast(i), std::memory_order_relaxed); }); + + const long long expected = static_cast(count - 1) * static_cast(count) / 2; + ASSERT_EQ(sum.load(), expected); +} + +// parallelRanges partitions [0, count) into contiguous, non-overlapping ranges covering everything. +TEST(JobSchedulerTest, ParallelRangesPartitionsExactly) { + JobScheduler scheduler; + constexpr std::size_t count = 12345; + std::vector> hits(count); + for (auto& h : hits) h.store(0); + + scheduler.parallelRanges(count, [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) hits[i].fetch_add(1, std::memory_order_relaxed); + }); + + for (std::size_t i = 0; i < count; ++i) { + ASSERT_EQ(hits[i].load(), 1) << "index " << i; + } +} + +// A single-worker scheduler still runs everything correctly. +TEST(JobSchedulerTest, SingleWorkerWorks) { + JobScheduler scheduler(1); + std::atomic counter{0}; + scheduler.dispatch(1000, [&](std::size_t) { counter.fetch_add(1, std::memory_order_relaxed); }); + ASSERT_EQ(counter.load(), 1000); +} + +// Zero-count dispatch is a no-op and doesn't hang. +TEST(JobSchedulerTest, EmptyDispatchIsNoop) { + JobScheduler scheduler; + int calls = 0; + scheduler.dispatch(0, [&](std::size_t) { ++calls; }); + ASSERT_EQ(calls, 0); +} + +// An exception thrown in a job propagates to the caller (and doesn't deadlock). +TEST(JobSchedulerTest, JobExceptionPropagates) { + JobScheduler scheduler; + auto run = [&] { + scheduler.dispatch(64, [](std::size_t i) { + if (i == 17) throw std::runtime_error("boom"); + }); + }; + ASSERT_THROW(run(), std::runtime_error); +} + +// Every detached (submit) job runs exactly once. +TEST(JobSchedulerTest, SubmitRunsAllDetachedJobs) { + JobScheduler scheduler; + constexpr int count = 500; + std::atomic ran{0}; + for (int i = 0; i < count; ++i) { + scheduler.submit([&] { ran.fetch_add(1, std::memory_order_relaxed); }); + } + ASSERT_TRUE(waitFor([&] { return ran.load() == count; })) << "only " << ran.load() << " of " << count << " ran"; +} + +// Interleaving submit() with dispatch() batches: every batch completes correctly (never hangs or +// returns early) and all detached jobs eventually run. This targets the m_pending / m_pending_detached +// separation directly. +TEST(JobSchedulerTest, SubmitInterleavedWithDispatchDoesNotHang) { + JobScheduler scheduler; + std::atomic detached_ran{0}; + constexpr int rounds = 50; + constexpr int detached_per_round = 10; + + for (int r = 0; r < rounds; ++r) { + for (int d = 0; d < detached_per_round; ++d) { + scheduler.submit([&] { detached_ran.fetch_add(1, std::memory_order_relaxed); }); + } + constexpr std::size_t batch = 2000; + std::atomic sum{0}; + scheduler.dispatch(batch, [&](std::size_t i) { sum.fetch_add(static_cast(i), std::memory_order_relaxed); }); + const long long expected = static_cast(batch - 1) * static_cast(batch) / 2; + ASSERT_EQ(sum.load(), expected) << "batch " << r << " completed early or double-counted"; + } + + ASSERT_TRUE(waitFor([&] { return detached_ran.load() == rounds * detached_per_round; })) + << "detached ran " << detached_ran.load(); +} + +// An exception escaping a detached job is swallowed: the scheduler keeps running and later jobs +// still execute (no std::terminate, no wedged worker). +TEST(JobSchedulerTest, SubmitJobExceptionIsSwallowed) { + JobScheduler scheduler; + std::atomic ran{0}; + scheduler.submit([] { throw std::runtime_error("detached boom"); }); + for (int i = 0; i < 10; ++i) { + scheduler.submit([&] { ran.fetch_add(1, std::memory_order_relaxed); }); + } + ASSERT_TRUE(waitFor([&] { return ran.load() == 10; })) << "only " << ran.load() << " ran after a throwing job"; +} + +// Destroying a scheduler with detached jobs still queued must not hang or crash (queued-but-unstarted +// jobs are dropped; in-flight jobs finish before join). +TEST(JobSchedulerTest, CleanShutdownWithQueuedDetachedJobs) { + std::atomic ran{0}; + { + JobScheduler scheduler(1); // single worker: many jobs will still be queued at teardown + for (int i = 0; i < 1000; ++i) { + scheduler.submit([&] { + std::this_thread::sleep_for(std::chrono::microseconds(50)); + ran.fetch_add(1, std::memory_order_relaxed); + }); + } + // Let it drain briefly, then destruct while jobs almost certainly remain queued. + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + // No assertion on the exact count: the contract is a clean shutdown, not that every queued job + // ran. Reaching here without hang/crash is the pass condition. + SUCCEED(); +} diff --git a/ICE/Physics/CMakeLists.txt b/ICE/Physics/CMakeLists.txt new file mode 100644 index 00000000..01917c26 --- /dev/null +++ b/ICE/Physics/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.19) +project(physics) + +message(STATUS "Building ${PROJECT_NAME} module") + +add_library(${PROJECT_NAME} INTERFACE) + +target_include_directories(${PROJECT_NAME} INTERFACE + $ + $) + +enable_testing() diff --git a/ICE/Physics/include/IPhysicsBackend.h b/ICE/Physics/include/IPhysicsBackend.h new file mode 100644 index 00000000..8f8f3983 --- /dev/null +++ b/ICE/Physics/include/IPhysicsBackend.h @@ -0,0 +1,24 @@ +#pragma once + +namespace ICE { + +// Engine-level physics seam, mirroring the RendererAPI abstraction: the engine drives an +// IPhysicsBackend each frame without knowing the concrete simulation (Bullet, Jolt, PhysX, ...). A +// new backend plugs in via ICEEngine::setPhysicsBackend with no change to core. +// +// Interface-first and deliberately minimal -- the body/collision/query API grows once there is a +// PhysicsComponent consumer to drive its shape. Defining the seam now keeps that later work from +// having to thread a backend through the engine after gameplay code exists. +class IPhysicsBackend { + public: + virtual ~IPhysicsBackend() = default; + + // Called once, when the backend is attached to the engine. + virtual void initialize() = 0; + + // Advance the simulation by `delta` seconds. The engine runs this before the ECS systems each + // frame, so gameplay sees this frame's simulation results. + virtual void step(double delta) = 0; +}; + +} // namespace ICE diff --git a/ICE/Physics/include/NullPhysicsBackend.h b/ICE/Physics/include/NullPhysicsBackend.h new file mode 100644 index 00000000..fbbe078b --- /dev/null +++ b/ICE/Physics/include/NullPhysicsBackend.h @@ -0,0 +1,15 @@ +#pragma once + +#include "IPhysicsBackend.h" + +namespace ICE { + +// No-op reference backend: satisfies the physics seam so an application can run with physics +// "attached" but inert, and serves as the template a real backend is written against. +class NullPhysicsBackend : public IPhysicsBackend { + public: + void initialize() override {} + void step(double /*delta*/) override {} +}; + +} // namespace ICE diff --git a/ICE/Platform/CMakeLists.txt b/ICE/Platform/CMakeLists.txt index 90bf3e8b..bfe55cbe 100644 --- a/ICE/Platform/CMakeLists.txt +++ b/ICE/Platform/CMakeLists.txt @@ -10,9 +10,14 @@ if(APPLE) elseif(WIN32) set(EXTRA_SRC Win32/dialog.cpp) else() - link_libraries(-lstdc++fs) set(EXTRA_SRC Linux/dialog.cpp) - set(EXTRA_INC ${GTK3_INCLUDE_DIRS}) + # GTK3 headers live under /usr/include/gtk-3.0 (+ glib/cairo/pango) and are + # only discoverable through pkg-config; resolve them here so dialog.cpp compiles. + find_package(PkgConfig REQUIRED) + pkg_check_modules(GTK3 REQUIRED gtk+-3.0) + set(EXTRA_INC ${GTK3_INCLUDE_DIRS}) + set(EXTRA_LIBS ${GTK3_LIBRARIES} stdc++fs) + set(EXTRA_LIBDIRS ${GTK3_LIBRARY_DIRS}) endif() target_sources(${PROJECT_NAME} PRIVATE @@ -20,9 +25,14 @@ target_sources(${PROJECT_NAME} PRIVATE ${EXTRA_SRC} ) -target_link_libraries(${PROJECT_NAME} +if(EXTRA_LIBDIRS) + target_link_directories(${PROJECT_NAME} PUBLIC ${EXTRA_LIBDIRS}) +endif() + +target_link_libraries(${PROJECT_NAME} PUBLIC util + ${EXTRA_LIBS} ) target_include_directories(${PROJECT_NAME} PUBLIC diff --git a/ICE/Platform/FileUtils.cpp b/ICE/Platform/FileUtils.cpp index 0144d606..24268781 100644 --- a/ICE/Platform/FileUtils.cpp +++ b/ICE/Platform/FileUtils.cpp @@ -24,6 +24,10 @@ const std::string FileUtils::openFolderDialog() { const std::string FileUtils::readFile(const std::string &path) { std::ifstream ifs(path); + if (!ifs.is_open()) { + Logger::Log(Logger::ERROR, "Platform", "Could not open file for reading: %s", path.c_str()); + return std::string(); + } std::string content((std::istreambuf_iterator(ifs)), (std::istreambuf_iterator())); return content; } diff --git a/ICE/Platform/Linux/dialog.cpp b/ICE/Platform/Linux/dialog.cpp index 816a8ae1..1aeb4956 100644 --- a/ICE/Platform/Linux/dialog.cpp +++ b/ICE/Platform/Linux/dialog.cpp @@ -2,9 +2,12 @@ // Created by Thomas Ibanez on 10.12.20. // -#include #include +#include + +#include "dialog.h" + const std::string open_native_dialog(const std::vector &filters) { GtkWidget *dialog; @@ -20,16 +23,18 @@ const std::string open_native_dialog(const std::vector &filters) { "_Open", GTK_RESPONSE_ACCEPT, NULL); + std::string result; if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { - char *filename; - filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); - gtk_widget_destroy(dialog); - return std::string(filename); + char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); + if (filename != NULL) { + result = filename; + g_free(filename); + } } gtk_widget_destroy(dialog); - return std::string(""); + return result; } const std::string open_native_folder_dialog() { @@ -47,14 +52,16 @@ const std::string open_native_folder_dialog() { "_Open", GTK_RESPONSE_ACCEPT, NULL); + std::string result; if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { - char *filename; - filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); - gtk_widget_destroy(dialog); - return std::string(filename); + char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); + if (filename != NULL) { + result = filename; + g_free(filename); + } } gtk_widget_destroy(dialog); - return std::string(""); + return result; } \ No newline at end of file diff --git a/ICE/Platform/Win32/dialog.cpp b/ICE/Platform/Win32/dialog.cpp index 58370e98..8e2cb877 100644 --- a/ICE/Platform/Win32/dialog.cpp +++ b/ICE/Platform/Win32/dialog.cpp @@ -80,20 +80,26 @@ const std::string open_native_folder_dialog() { // Get the folder name ::IShellItem* shellItem(NULL); result = fileDialog->GetResult(&shellItem); - if (!SUCCEEDED(result)) + if (!SUCCEEDED(result) || shellItem == NULL) { - shellItem->Release(); + // GetResult failed: shellItem is NULL, so do not call Release on it. goto end; } wchar_t* path = NULL; result = shellItem->GetDisplayName(SIGDN_DESKTOPABSOLUTEPARSING, &path); - if (!SUCCEEDED(result)) + if (SUCCEEDED(result) && path != NULL) { - shellItem->Release(); - goto end; + // Convert UTF-16 -> UTF-8 properly (std::string(ws.begin(), ws.end()) + // truncated each wide char and mangled any non-ASCII path). + int bytes = WideCharToMultiByte(CP_UTF8, 0, path, -1, NULL, 0, NULL, NULL); + if (bytes > 1) + { + std::string utf8(bytes - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, path, -1, utf8.data(), bytes, NULL, NULL); + str = std::move(utf8); + } + CoTaskMemFree(path); } - std::wstring ws(path); - str = std::string(ws.begin(), ws.end()); shellItem->Release(); } end: diff --git a/ICE/Scene/CMakeLists.txt b/ICE/Scene/CMakeLists.txt index 58087da1..2f4293b8 100644 --- a/ICE/Scene/CMakeLists.txt +++ b/ICE/Scene/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(${PROJECT_NAME} STATIC) target_sources(${PROJECT_NAME} PRIVATE src/Scene.cpp + src/SceneCamera.cpp "include/SceneGraph.h") target_link_libraries(${PROJECT_NAME} PUBLIC diff --git a/ICE/Scene/include/Scene.h b/ICE/Scene/include/Scene.h index ef2b6735..5e96dbda 100644 --- a/ICE/Scene/include/Scene.h +++ b/ICE/Scene/include/Scene.h @@ -4,7 +4,10 @@ #pragma once +#include +#include #include +#include #include #include @@ -16,28 +19,75 @@ class Renderer; class Scene { public: - Scene(const std::string& name); + Scene(const std::string &name); - bool setAlias(Entity entity, const std::string& newName); - std::string getAlias(Entity e); + bool setAlias(Entity entity, const std::string &newName); + std::string getAlias(Entity e) const; std::shared_ptr getGraph() const; std::string getName() const; - void setName(const std::string& name); + void setName(const std::string &name); std::shared_ptr getRegistry() const; + + // The scene owns its view camera (a free SceneCamera by default, behaving exactly like the old + // PerspectiveCamera). camera() returns a fluent, non-owning handle for setup -- + // scene.camera().setPosition(p).pitch(-30) -- available as soon as the scene exists, no + // getSystem() chain. When the scene is activated by the engine, its render system + // is pointed at this camera. Replace it wholesale with setCamera(), or bind it to a camera + // entity with setActiveCamera(); cameraPtr() hands out the owning pointer (e.g. for the render + // system). + CameraHandle camera() const; + void setCamera(const std::shared_ptr &camera); + std::shared_ptr cameraPtr() const; + + // Make `camera_entity` (which should carry a CameraComponent and a TransformComponent) the + // scene's active viewpoint: the scene's camera now derives its view from that entity's world + // transform and its projection from its CameraComponent. Because the view is just the entity's + // world matrix, parenting the camera entity under another entity yields a follow camera, and + // calling this with a different entity switches the view -- no special-case code either way. + // Re-points the render system if the scene is already active. Pass NULL_ENTITY to return to the + // free (non-entity) camera. + void setActiveCamera(Entity camera_entity); + + // The active camera entity, or NULL_ENTITY when the scene uses its free camera. + Entity getActiveCamera() const { return m_active_camera; } + Entity createEntity(); - Entity spawnTree(AssetUID model_id, const std::shared_ptr& bank); - void addEntity(Entity e, const std::string& alias, Entity parent); + // Create an entity and return an ergonomic handle to it (optionally aliased). Prefer this + // over createEntity() + registry gymnastics when writing gameplay/tools code. + EntityHandle create(const std::string &name = ""); + // Wrap an existing entity id in a handle bound to this scene's registry. + EntityHandle wrap(Entity e) const; + + Entity spawnTree(AssetUID model_id, const std::shared_ptr &bank); + + // Instantiate a model's node/mesh hierarchy into this scene and return a handle to its root. + // Uses the scene's asset bank (injected when the scene is added to a project), so gameplay + // code just passes the model id: scene.spawn(project.importModel(...)). + EntityHandle spawn(AssetUID model_id); + + // Asset bank backing spawn() and other by-name lookups. Set by Project when the scene is + // added; may be null for a scene constructed standalone (spawn() then no-ops to NULL_ENTITY). + void setAssetBank(const std::shared_ptr &bank); + + void addEntity(Entity e, const std::string &alias, Entity parent); void removeEntity(Entity e); - bool hasEntity(Entity e); + bool hasEntity(Entity e) const; private: std::string name; std::shared_ptr m_graph; std::unordered_map aliases; std::shared_ptr registry; + // Always a SceneCamera: free by default (identical to the old scene-owned PerspectiveCamera), + // bound to an entity by setActiveCamera. Typed as the Camera base so setCamera() can still + // swap in an arbitrary camera. See cameraPtr(). + std::shared_ptr m_camera; + // The active camera entity, or NULL_ENTITY for the free camera. + Entity m_active_camera = NULL_ENTITY; + std::shared_ptr m_asset_bank; }; } // namespace ICE diff --git a/ICE/Scene/include/SceneCamera.h b/ICE/Scene/include/SceneCamera.h new file mode 100644 index 00000000..4eb70aac --- /dev/null +++ b/ICE/Scene/include/SceneCamera.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include + +#include + +namespace ICE { +class Registry; +class TransformComponent; + +// A Camera whose pose can come from an entity (its CameraComponent + TransformComponent), so the +// engine's one "camera is a scene-owned object" exception becomes an ordinary component. This is +// what Scene::camera()/cameraPtr() hand out, so every existing consumer -- the render system's +// prepareFrame(Camera&), T5's drawScene(Camera&), the editor -- keeps working unchanged. +// +// Two modes: +// * Free (default, no bound entity): behaves exactly like the scene-owned PerspectiveCamera it +// replaces -- every call delegates to an internal camera, so existing scenes are unaffected. +// * Bound (Scene::setActiveCamera): the view is the bound entity's WORLD transform, so a camera +// parented under a moving entity follows it with no special case; the projection comes from the +// entity's CameraComponent. +class SceneCamera : public Camera { + public: + // Free camera matching the engine's historical scene default (60 deg fov, 16:9, 0.01..10000). + SceneCamera(); + + // Bind to a camera entity: its CameraComponent drives the projection, its world transform the + // view. A null registry or an entity without a CameraComponent falls back to free behaviour. + void bindEntity(Registry* registry, Entity entity); + bool isBound() const { return m_registry != nullptr && m_entity != NULL_ENTITY; } + Entity boundEntity() const { return m_entity; } + + Eigen::Matrix4f lookThrough() override; + Eigen::Matrix4f getProjection() const override; + Eigen::Vector3f getPosition() const override; + void setPosition(const Eigen::Vector3f& p) override; + Eigen::Vector3f getRotation() const override; + void setRotation(const Eigen::Vector3f& r) override; + + void forward(float d) override; + void backward(float d) override; + void left(float d) override; + void right(float d) override; + void up(float d) override; + void down(float d) override; + void pitch(float d) override; + void yaw(float d) override; + void roll(float d) override; + + void resize(float width, float height) override; + + private: + // The bound entity's transform, or nullptr in free mode / if it has none. + TransformComponent* transform() const; + + // Rebuild m_impl (the projection source + free-mode camera) from the bound CameraComponent, or + // to the free default when unbound. Preserves the current aspect ratio. + void rebuildImpl(); + + // Run a Camera mutation (a move/rotate). In free mode it just runs on m_impl. In bound mode it + // loads the entity's local pose into m_impl, applies the op, and writes the result back -- so + // every mutator reuses the existing camera math exactly, and edits land on the entity's + // transform (composing with the scene graph like any other). + template + void mutate(Op&& op); + + // Free-mode camera and, when bound, the projection source (kept in sync with the + // CameraComponent's params). Never null. + std::shared_ptr m_impl; + float m_aspect = 16.0f / 9.0f; + + Registry* m_registry = nullptr; + Entity m_entity = NULL_ENTITY; +}; +} // namespace ICE diff --git a/ICE/Scene/include/SceneGraph.h b/ICE/Scene/include/SceneGraph.h index a51c44df..933fcdd2 100644 --- a/ICE/Scene/include/SceneGraph.h +++ b/ICE/Scene/include/SceneGraph.h @@ -63,6 +63,16 @@ class SceneGraph { auto sn = idToNode[e]; auto newParent = idToNode[newParentID]; + + // Reject reparenting under one of e's own descendants: it would detach the subtree + // from the root and form a strong shared_ptr cycle (children own each other) -> leak. + // Walk up from newParent to the root; if we meet e, newParent is a descendant of e. + for (auto ancestor = newParent; ancestor != nullptr; ancestor = ancestor->parent.lock()) { + if (ancestor->entity == e) { + return; + } + } + auto oldParent = sn->parent.lock(); if (oldParent) { diff --git a/ICE/Scene/src/Scene.cpp b/ICE/Scene/src/Scene.cpp index 2a0b0912..9415e587 100644 --- a/ICE/Scene/src/Scene.cpp +++ b/ICE/Scene/src/Scene.cpp @@ -4,12 +4,25 @@ #include "Scene.h" +#include #include +#include +#include +#include +#include +#include +#include #include namespace ICE { -Scene::Scene(const std::string &name) : name(name), m_graph(std::make_shared()), registry(std::make_shared()) { +Scene::Scene(const std::string &name) + : name(name), + m_graph(std::make_shared()), + registry(std::make_shared()), + // Free SceneCamera by default: identical behaviour to the scene-owned PerspectiveCamera it + // replaces, but able to bind to a camera entity via setActiveCamera. + m_camera(std::make_shared()) { aliases.try_emplace(0, "Scene"); } @@ -30,14 +43,62 @@ bool Scene::setAlias(Entity entity, const std::string &newName) { return true; } -std::string Scene::getAlias(Entity e) { - return aliases[e]; +std::string Scene::getAlias(Entity e) const { + // find, not operator[]: the latter inserted an empty alias for unknown entities, which + // (combined with the old alias-based hasEntity) made a nonexistent entity "exist". + auto it = aliases.find(e); + return it == aliases.end() ? std::string() : it->second; } std::shared_ptr Scene::getRegistry() const { return registry; } +CameraHandle Scene::camera() const { + return CameraHandle(m_camera.get()); +} + +std::shared_ptr Scene::cameraPtr() const { + return m_camera; +} + +void Scene::setCamera(const std::shared_ptr &camera) { + m_camera = camera; + m_active_camera = NULL_ENTITY; // an explicit camera object supersedes any active entity + // If the scene is already active, re-point its render system at the new camera. + if (auto rs = registry->tryGetSystem()) { + rs->setCamera(camera); + } +} + +void Scene::setActiveCamera(Entity camera_entity) { + m_active_camera = camera_entity; + // Bind the existing SceneCamera in place where possible, so the shared_ptr the render system + // already holds keeps pointing at the live view (no re-point needed). If setCamera() had + // swapped in a non-SceneCamera, replace it with a bound one. + auto scene_camera = std::dynamic_pointer_cast(m_camera); + if (!scene_camera) { + scene_camera = std::make_shared(); + m_camera = scene_camera; + } + scene_camera->bindEntity(camera_entity == NULL_ENTITY ? nullptr : registry.get(), camera_entity); + + if (auto rs = registry->tryGetSystem()) { + rs->setCamera(m_camera); + } +} + +void Scene::setAssetBank(const std::shared_ptr &bank) { + m_asset_bank = bank; +} + +EntityHandle Scene::spawn(AssetUID model_id) { + if (!m_asset_bank) { + return EntityHandle(); // no bank wired: nothing to spawn from + } + return wrap(spawnTree(model_id, m_asset_bank)); +} + Entity Scene::createEntity() { Entity e = registry->createEntity(); m_graph->addEntity(e); @@ -45,6 +106,18 @@ Entity Scene::createEntity() { return e; } +EntityHandle Scene::create(const std::string &name) { + Entity e = createEntity(); + if (!name.empty()) { + setAlias(e, name); + } + return EntityHandle(e, registry.get()); +} + +EntityHandle Scene::wrap(Entity e) const { + return EntityHandle(e, registry.get()); +} + Entity Scene::spawnTree(AssetUID model_id, const std::shared_ptr &bank) { auto model = bank->getAsset(model_id); auto nodes = model->getNodes(); @@ -117,13 +190,18 @@ void Scene::addEntity(Entity e, const std::string &alias, Entity parent) { } void Scene::removeEntity(Entity e) { + if (!hasEntity(e)) { + return; + } registry->removeEntity(e); aliases.erase(e); m_graph->removeEntity(e); } -bool Scene::hasEntity(Entity e) { - return aliases.contains(e); +bool Scene::hasEntity(Entity e) const { + // Aliveness is owned by the registry, not the alias map (an entity can be alive without + // an alias, and getAlias no longer fabricates entries). + return registry->isAlive(e); } } // namespace ICE \ No newline at end of file diff --git a/ICE/Scene/src/SceneCamera.cpp b/ICE/Scene/src/SceneCamera.cpp new file mode 100644 index 00000000..b9c3e0a4 --- /dev/null +++ b/ICE/Scene/src/SceneCamera.cpp @@ -0,0 +1,151 @@ +#include "SceneCamera.h" + +#include +#include +#include +#include +#include + +namespace ICE { + +SceneCamera::SceneCamera() { + rebuildImpl(); // free default +} + +void SceneCamera::bindEntity(Registry* registry, Entity entity) { + m_registry = registry; + m_entity = entity; + rebuildImpl(); +} + +TransformComponent* SceneCamera::transform() const { + if (!isBound()) { + return nullptr; + } + return m_registry->tryGetComponent(m_entity); +} + +void SceneCamera::rebuildImpl() { + // Projection parameters come from the bound CameraComponent; unbound (or an entity without one) + // uses the historical scene-camera default so existing scenes render identically. + CameraComponent params; // defaults == the old default camera + if (isBound()) { + if (auto* cc = m_registry->tryGetComponent(m_entity)) { + params = *cc; + } + } + + if (params.projection == CameraComponent::Projection::Orthographic) { + const double half_h = params.ortho_size; + const double half_w = half_h * m_aspect; + m_impl = std::make_shared(-half_w, half_w, half_h, -half_h, params.near_plane, params.far_plane); + } else { + m_impl = std::make_shared(params.fov, m_aspect, params.near_plane, params.far_plane); + } +} + +Eigen::Matrix4f SceneCamera::lookThrough() { + auto* tc = transform(); + if (!tc) { + return m_impl->lookThrough(); // free mode: byte-identical to the legacy scene camera + } + // Bound view = the inverse of the entity's world transform (scale stripped so a scaled camera + // entity can't skew the view). This is the standard convention and it composes correctly + // through scene-graph parenting, which is what makes a parented camera follow its rig. + // + // For the single-axis orientations the engine's cameras use in practice (a pitch, a yaw) this + // equals the free camera's legacy view exactly; it only diverges for a combined multi-axis + // orientation, where the legacy formula rotationMatrix(-euler, false) was never a true inverse + // anyway. Bound cameras are new, so there is no prior view to preserve there. + Eigen::Matrix4f world = tc->getWorldMatrix(); + Eigen::Vector3f pos = world.block<3, 1>(0, 3); + Eigen::Matrix3f rot = world.block<3, 3>(0, 0); + for (int i = 0; i < 3; ++i) { + const float n = rot.col(i).norm(); + if (n > 0.0f) { + rot.col(i) /= n; + } + } + Eigen::Matrix4f view = Eigen::Matrix4f::Identity(); + view.block<3, 3>(0, 0) = rot.transpose(); // R^-1 for an orthonormal rotation + view.block<3, 1>(0, 3) = -(rot.transpose() * pos); // -R^-1 * position + return view; +} + +Eigen::Vector3f SceneCamera::getPosition() const { + if (auto* tc = transform()) { + return tc->getWorldMatrix().block<3, 1>(0, 3); // world-space position (matches lookThrough) + } + return m_impl->getPosition(); +} + +Eigen::Matrix4f SceneCamera::getProjection() const { + return m_impl->getProjection(); +} + +Eigen::Vector3f SceneCamera::getRotation() const { + if (auto* tc = transform()) { + return tc->getRotationEulerDeg(); + } + return m_impl->getRotation(); +} + +void SceneCamera::resize(float width, float height) { + if (height != 0.0f) { + m_aspect = width / height; + } + m_impl->resize(width, height); +} + +template +void SceneCamera::mutate(Op&& op) { + auto* tc = transform(); + if (!tc) { + op(*m_impl); // free mode: operate directly on the camera + return; + } + // Bound: load the entity's local pose, apply the op with the existing camera math, write back. + // Edits therefore land on the entity's transform and compose through the scene graph. The euler + // round-trip (getRotationEulerDeg) is authoring-only and off the render path. + m_impl->setPosition(tc->getPosition()); + m_impl->setRotation(tc->getRotationEulerDeg()); + op(*m_impl); + tc->setPosition(m_impl->getPosition()); + tc->setRotationEulerDeg(m_impl->getRotation()); +} + +void SceneCamera::setPosition(const Eigen::Vector3f& p) { + mutate([&](Camera& c) { c.setPosition(p); }); +} +void SceneCamera::setRotation(const Eigen::Vector3f& r) { + mutate([&](Camera& c) { c.setRotation(r); }); +} +void SceneCamera::forward(float d) { + mutate([&](Camera& c) { c.forward(d); }); +} +void SceneCamera::backward(float d) { + mutate([&](Camera& c) { c.backward(d); }); +} +void SceneCamera::left(float d) { + mutate([&](Camera& c) { c.left(d); }); +} +void SceneCamera::right(float d) { + mutate([&](Camera& c) { c.right(d); }); +} +void SceneCamera::up(float d) { + mutate([&](Camera& c) { c.up(d); }); +} +void SceneCamera::down(float d) { + mutate([&](Camera& c) { c.down(d); }); +} +void SceneCamera::pitch(float d) { + mutate([&](Camera& c) { c.pitch(d); }); +} +void SceneCamera::yaw(float d) { + mutate([&](Camera& c) { c.yaw(d); }); +} +void SceneCamera::roll(float d) { + mutate([&](Camera& c) { c.roll(d); }); +} + +} // namespace ICE diff --git a/ICE/Scene/test/CMakeLists.txt b/ICE/Scene/test/CMakeLists.txt index 9076ef52..be32dca5 100644 --- a/ICE/Scene/test/CMakeLists.txt +++ b/ICE/Scene/test/CMakeLists.txt @@ -7,6 +7,7 @@ include(CTest) add_executable(SceneTestSuite SceneTest.cpp SceneGraphTest.cpp + SceneCameraTest.cpp ) add_test(NAME SceneTestSuite diff --git a/ICE/Scene/test/SceneCameraTest.cpp b/ICE/Scene/test/SceneCameraTest.cpp new file mode 100644 index 00000000..ccb9f3f4 --- /dev/null +++ b/ICE/Scene/test/SceneCameraTest.cpp @@ -0,0 +1,172 @@ +#include + +#include +#include +#include +#include +#include +#include + +using namespace ICE; + +namespace { +// Two view matrices are equal if every element matches within fp tolerance. +::testing::AssertionResult MatricesNear(const Eigen::Matrix4f& a, const Eigen::Matrix4f& b, float eps = 1e-4f) { + if (a.isApprox(b, eps) || (a - b).cwiseAbs().maxCoeff() < eps) { + return ::testing::AssertionSuccess(); + } + return ::testing::AssertionFailure() << "matrices differ by " << (a - b).cwiseAbs().maxCoeff(); +} +} // namespace + +// The whole behaviour-preservation guarantee: a free SceneCamera renders the same view as the +// scene-owned PerspectiveCamera it replaces, for the same pose. +TEST(SceneCameraTest, FreeCameraMatchesPerspectiveCamera) { + SceneCamera sc; + PerspectiveCamera ref(60.0, 16.0 / 9.0, 0.01, 10000.0); + + sc.setPosition({1.0f, 2.0f, 3.0f}); + ref.setPosition({1.0f, 2.0f, 3.0f}); + sc.pitch(-30.0f); + ref.pitch(-30.0f); + sc.yaw(45.0f); + ref.yaw(45.0f); + + EXPECT_TRUE(MatricesNear(sc.lookThrough(), ref.lookThrough())); + EXPECT_TRUE(MatricesNear(sc.getProjection(), ref.getProjection())); + EXPECT_TRUE(sc.getPosition().isApprox(ref.getPosition())); +} + +// A bound, unparented camera with a single-axis orientation (a pitch -- what the engine's cameras +// use in practice) produces the SAME view as the equivalent free/legacy camera. This is the +// behaviour-preservation guarantee for converting a scene camera to an entity camera: view = +// inverse(world) coincides with the legacy formula for a single axis. +TEST(SceneCameraTest, BoundUnparentedMatchesFreeCameraForPitch) { + Registry reg; + Entity e = reg.createEntity(); + reg.addComponent(e, CameraComponent{}); // default params == the free default + TransformComponent t; + t.setPosition({0.0f, 5.0f, 5.0f}); + t.setRotationEulerDeg({-30.0f, 0.0f, 0.0f}); // pitch only, as in the sample scenes + reg.addComponent(e, t); + + SceneCamera bound; + bound.bindEntity(®, e); + + PerspectiveCamera ref(60.0, 16.0 / 9.0, 0.01, 10000.0); + ref.setPosition({0.0f, 5.0f, 5.0f}); + ref.setRotation({-30.0f, 0.0f, 0.0f}); + + EXPECT_TRUE(MatricesNear(bound.lookThrough(), ref.lookThrough())); + EXPECT_TRUE(bound.getPosition().isApprox(ref.getPosition(), 1e-4f)); +} + +// The bound view is a proper inverse of the entity's world transform: view * worldPoint maps the +// camera's own world position to the origin of view space. +TEST(SceneCameraTest, BoundViewIsInverseOfWorldTransform) { + Registry reg; + Entity e = reg.createEntity(); + reg.addComponent(e, CameraComponent{}); + TransformComponent t; + t.setPosition({3.0f, -2.0f, 7.0f}); + t.setRotationEulerDeg({-30.0f, 45.0f, 10.0f}); // arbitrary multi-axis + reg.addComponent(e, t); + + SceneCamera bound; + bound.bindEntity(®, e); + + Eigen::Matrix4f view = bound.lookThrough(); + Eigen::Vector4f cam_in_view = view * Eigen::Vector4f(3.0f, -2.0f, 7.0f, 1.0f); + EXPECT_TRUE(cam_in_view.head<3>().isApprox(Eigen::Vector3f::Zero(), 1e-4f)); +} + +// Acceptance: parenting the camera entity under a moving entity produces a follow camera with no +// special-case code. Moving the parent shifts the camera's world position and thus its view. +TEST(SceneCameraTest, ParentedCameraFollowsItsParent) { + Scene scene("follow"); + auto reg = scene.getRegistry(); + + EntityHandle parent = scene.create("rig"); + parent.add(TransformComponent({10.0f, 0.0f, 0.0f}, Eigen::Vector3f::Zero())); + + EntityHandle cam = scene.create("cam"); + cam.add(TransformComponent(Eigen::Vector3f::Zero(), Eigen::Vector3f::Zero())); // local origin + cam.add(CameraComponent{}); + scene.getGraph()->setParent(cam.id(), parent.id()); + scene.setActiveCamera(cam.id()); + + // Propagate the parent's world matrix into the child's transform (what SceneGraphSystem does). + auto parent_world = reg->getComponent(parent.id())->getWorldMatrix(); + reg->getComponent(cam.id())->updateParentMatrix(parent_world); + + // The camera sits at the parent's position, so the view translates the world by -parent. + Eigen::Vector3f cam_world_pos = scene.cameraPtr()->getPosition(); + EXPECT_TRUE(cam_world_pos.isApprox(Eigen::Vector3f(10.0f, 0.0f, 0.0f), 1e-4f)); + + // Move the rig; the camera follows with no camera-specific code. + reg->getComponent(parent.id())->setPosition({-4.0f, 5.0f, 0.0f}); + parent_world = reg->getComponent(parent.id())->getWorldMatrix(); + reg->getComponent(cam.id())->updateParentMatrix(parent_world); + + EXPECT_TRUE(scene.cameraPtr()->getPosition().isApprox(Eigen::Vector3f(-4.0f, 5.0f, 0.0f), 1e-4f)); +} + +// Acceptance: selecting between two camera entities switches the view, through the same shared_ptr +// the render system holds (setActiveCamera rebinds in place). +TEST(SceneCameraTest, SwitchingActiveCameraSwitchesTheView) { + Scene scene("switch"); + + EntityHandle a = scene.create("camA"); + a.add(TransformComponent({0.0f, 0.0f, 5.0f}, Eigen::Vector3f::Zero())); + a.add(CameraComponent{}); + + EntityHandle b = scene.create("camB"); + b.add(TransformComponent({0.0f, 0.0f, -5.0f}, Eigen::Vector3f::Zero())); + b.add(CameraComponent{}); + + auto camera = scene.cameraPtr(); // the object the render system is handed + + scene.setActiveCamera(a.id()); + EXPECT_TRUE(camera->getPosition().isApprox(Eigen::Vector3f(0.0f, 0.0f, 5.0f), 1e-4f)); + + scene.setActiveCamera(b.id()); + EXPECT_EQ(scene.cameraPtr(), camera); // same object, rebound in place + EXPECT_TRUE(camera->getPosition().isApprox(Eigen::Vector3f(0.0f, 0.0f, -5.0f), 1e-4f)); +} + +// The CameraComponent drives the projection: a different fov yields a different projection matrix. +TEST(SceneCameraTest, ProjectionComesFromTheCameraComponent) { + Registry reg; + Entity e = reg.createEntity(); + CameraComponent cc; + cc.fov = 90.0f; + reg.addComponent(e, cc); + reg.addComponent(e, TransformComponent{}); + + SceneCamera bound; + bound.bindEntity(®, e); + + PerspectiveCamera ref90(90.0, 16.0 / 9.0, 0.01, 10000.0); + EXPECT_TRUE(MatricesNear(bound.getProjection(), ref90.getProjection())); + + PerspectiveCamera ref60(60.0, 16.0 / 9.0, 0.01, 10000.0); + EXPECT_FALSE(MatricesNear(bound.getProjection(), ref60.getProjection())); // not the default +} + +// Returning to the free camera (NULL_ENTITY) restores free behaviour. +TEST(SceneCameraTest, UnbindReturnsToFreeCamera) { + Registry reg; + Entity e = reg.createEntity(); + reg.addComponent(e, CameraComponent{}); + reg.addComponent(e, TransformComponent({7.0f, 0.0f, 0.0f}, Eigen::Vector3f::Zero())); + + SceneCamera sc; + sc.bindEntity(®, e); + EXPECT_TRUE(sc.isBound()); + EXPECT_TRUE(sc.getPosition().isApprox(Eigen::Vector3f(7.0f, 0.0f, 0.0f), 1e-4f)); + + sc.bindEntity(nullptr, NULL_ENTITY); + EXPECT_FALSE(sc.isBound()); + sc.setPosition({2.0f, 2.0f, 2.0f}); + EXPECT_TRUE(sc.getPosition().isApprox(Eigen::Vector3f(2.0f, 2.0f, 2.0f), 1e-4f)); +} diff --git a/ICE/Scripting/CMakeLists.txt b/ICE/Scripting/CMakeLists.txt new file mode 100644 index 00000000..06b4ab77 --- /dev/null +++ b/ICE/Scripting/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.19) +project(scripting) + +message(STATUS "Building ${PROJECT_NAME} module") + +add_library(${PROJECT_NAME} INTERFACE) + +target_include_directories(${PROJECT_NAME} INTERFACE + $ + $) + +enable_testing() diff --git a/ICE/Scripting/include/IScriptingBackend.h b/ICE/Scripting/include/IScriptingBackend.h new file mode 100644 index 00000000..a24dfd17 --- /dev/null +++ b/ICE/Scripting/include/IScriptingBackend.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace ICE { +class Registry; + +// Seam for an embedded scripting VM (Lua, Wren, ...) with hot-reload, complementing the +// compile-time NativeScript. A backend binds the ECS to the VM and drives per-frame script logic; +// hot-reload is expressed by (re)loading a script by path at runtime. +// +// Interface-first: there is no in-tree VM yet. This defines the contract a backend implements and +// the engine drives, so adding a VM later is a plug-in rather than an engine rewrite. +class IScriptingBackend { + public: + virtual ~IScriptingBackend() = default; + + // Called once when the backend is attached, giving the VM access to the ECS to bind. + virtual void initialize(Registry& registry) = 0; + + // (Re)load a script from disk. Calling it again on the same path is the hot-reload path. + virtual void loadScript(const std::string& path) = 0; + + // Drive script logic for the frame. The engine runs this alongside the native ScriptSystem. + virtual void update(double delta) = 0; +}; + +} // namespace ICE diff --git a/ICE/Storage/CMakeLists.txt b/ICE/Storage/CMakeLists.txt index 46fb5335..cb74d8be 100644 --- a/ICE/Storage/CMakeLists.txt +++ b/ICE/Storage/CMakeLists.txt @@ -3,17 +3,14 @@ project(storage) message(STATUS "Building ${PROJECT_NAME} module") -add_library(${PROJECT_NAME} STATIC) +# Header-only after removing the empty Filesystem stub (JsonParser is the only content). +add_library(${PROJECT_NAME} INTERFACE) -target_sources(${PROJECT_NAME} PRIVATE - src/Filesystem.cpp -) - -target_link_libraries(${PROJECT_NAME} PUBLIC +target_link_libraries(${PROJECT_NAME} INTERFACE nlohmann_json ) -target_include_directories(${PROJECT_NAME} PUBLIC +target_include_directories(${PROJECT_NAME} INTERFACE $ $) diff --git a/ICE/Storage/include/Filesystem.h b/ICE/Storage/include/Filesystem.h deleted file mode 100644 index 93865c80..00000000 --- a/ICE/Storage/include/Filesystem.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// Created by Thomas Ibanez on 29.07.21. -// - -#ifndef ICE_FILESYSTEM_H -#define ICE_FILESYSTEM_H - -#include -#include - -namespace ICE { - class Filesystem { - - }; -} - -#endif //ICE_FILESYSTEM_H diff --git a/ICE/Storage/include/JsonParser.h b/ICE/Storage/include/JsonParser.h index 115ba91f..5fedfcfb 100644 --- a/ICE/Storage/include/JsonParser.h +++ b/ICE/Storage/include/JsonParser.h @@ -29,7 +29,7 @@ namespace ICE { return j; } - inline Eigen::Matrix4f readMat4(const json& j) { + static Eigen::Matrix4f readMat4(const json& j) { Eigen::Matrix4f mat; if (!j.is_array() || j.size() != 16) throw std::runtime_error("Invalid JSON for Eigen::Matrix4f"); diff --git a/ICE/Storage/src/Filesystem.cpp b/ICE/Storage/src/Filesystem.cpp deleted file mode 100644 index 1eb1a9e1..00000000 --- a/ICE/Storage/src/Filesystem.cpp +++ /dev/null @@ -1,10 +0,0 @@ -// -// Created by Thomas Ibanez on 29.07.21. -// - -#include "Filesystem.h" -#include - -namespace ICE { - -} diff --git a/ICE/System/CMakeLists.txt b/ICE/System/CMakeLists.txt index 635a5a86..fc69295b 100644 --- a/ICE/System/CMakeLists.txt +++ b/ICE/System/CMakeLists.txt @@ -6,15 +6,17 @@ message(STATUS "Building ${PROJECT_NAME} module") add_library(${PROJECT_NAME} STATIC) target_sources(${PROJECT_NAME} PRIVATE - src/RenderSystem.cpp + src/RenderSystem.cpp src/AnimationSystem.cpp src/SceneGraphSystem.cpp + src/ScriptSystem.cpp ) target_link_libraries(${PROJECT_NAME} PUBLIC graphics entity + multithreading ) target_include_directories(${PROJECT_NAME} PUBLIC @@ -22,4 +24,4 @@ target_include_directories(${PROJECT_NAME} PUBLIC $) enable_testing() -#add_subdirectory(test) +add_subdirectory(test) diff --git a/ICE/System/include/AnimationSystem.h b/ICE/System/include/AnimationSystem.h index be29e55b..e86053e4 100644 --- a/ICE/System/include/AnimationSystem.h +++ b/ICE/System/include/AnimationSystem.h @@ -1,18 +1,36 @@ #pragma once +#include +#include +#include // Model::Node / Model::Skeleton are used by value here (nested types need the full type) #include +#include #include "Animation.h" #include "AnimationComponent.h" #include "System.h" namespace ICE { + +struct BonePose { + Eigen::Vector3f position = Eigen::Vector3f::Zero(); + Eigen::Quaternionf rotation = Eigen::Quaternionf::Identity(); + Eigen::Vector3f scale = Eigen::Vector3f::Ones(); +}; + class AnimationSystem : public System { public: - AnimationSystem(const std::shared_ptr& reg, const std::shared_ptr& bank); + AnimationSystem(const std::shared_ptr ®, const std::shared_ptr &bank); void update(double delta) override; - std::vector getSignatures(const ComponentManager& comp_manager) const override { + int updateOrder() const override { return AnimationSystemOrder; } + + // Opt-in parallel skeleton update. With a scheduler set, each animated entity is sampled and + // posed on a worker thread (asset access stays on the render thread; see update()). Null (the + // default) keeps the single-threaded path. + void setScheduler(const std::shared_ptr &scheduler) { m_scheduler = scheduler; } + + std::vector getSignatures(const ComponentManager &comp_manager) const override { Signature signature; signature.set(comp_manager.getComponentType()); signature.set(comp_manager.getComponentType()); @@ -21,7 +39,7 @@ class AnimationSystem : public System { private: template - size_t findKeyIndex(double animationTime, const std::vector& keys) { + size_t findKeyIndex(double animationTime, const std::vector &keys) { for (size_t i = 0; i < keys.size() - 1; ++i) { if (animationTime < keys[i + 1].timeStamp) { return i; @@ -30,18 +48,30 @@ class AnimationSystem : public System { return keys.size() - 1; } - void updateSkeleton(const std::shared_ptr& model, double time, SkeletonPoseComponent* pose, const Animation& anim); - void finalizePose(); - Eigen::Vector3f interpolatePosition(double timeInTicks, const BoneAnimation& track); + // Advance and pose one animated entity. Takes the already-resolved model so the parallel path + // performs no asset-bank lookups (only the entity's own components + the shared, read-only + // model). Safe to run concurrently across entities: each animated skeleton owns disjoint bone + // entities, so the TransformComponent/pose writes never overlap. + void updateEntity(Entity e, const std::shared_ptr &model, double dt); + + void updateSkeleton(const std::shared_ptr &model, double time, SkeletonPoseComponent *pose, const Animation &anim); + void finalizePose(Entity e, const std::shared_ptr &model); - Eigen::Vector3f interpolateScale(double timeInTicks, const BoneAnimation& track); + BonePose sampleBonePose(const std::string &boneName, const Animation &anim, double time, const std::shared_ptr &model); + static BonePose blendPoses(const BonePose &a, const BonePose &b, float factor); - Eigen::Quaternionf interpolateRotation(double time, const BoneAnimation& track); + Eigen::Vector3f interpolatePosition(double timeInTicks, const BoneAnimation &track); + Eigen::Vector3f interpolateScale(double timeInTicks, const BoneAnimation &track); + Eigen::Quaternionf interpolateRotation(double time, const BoneAnimation &track); - void applyTransforms(const Model::Node* node, const Eigen::Matrix4f& parentTransform, const Model::Skeleton& skeleton, double time, - SkeletonPoseComponent* pose, const Animation& anim, const std::vector& allModelNodes); + void applyTransforms(const Model::Node *node, const Eigen::Matrix4f &parentTransform, const Model::Skeleton &skeleton, double time, + SkeletonPoseComponent *pose, const Animation &anim, const std::vector &allModelNodes); - std::shared_ptr m_registry; + // Non-owning back-reference (the Registry owns this system) to avoid an ownership cycle. + Registry* m_registry = nullptr; std::shared_ptr m_asset_bank; + + // Optional work-stealing scheduler for the parallel skeleton-update path (null => serial). + std::shared_ptr m_scheduler; }; } // namespace ICE diff --git a/ICE/System/include/EntityHandle.h b/ICE/System/include/EntityHandle.h new file mode 100644 index 00000000..d0b0c430 --- /dev/null +++ b/ICE/System/include/EntityHandle.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace ICE { + +// Lightweight value-type handle over an entity: bundles the entity id with its registry so +// component access reads naturally and liveness is checkable. Cheap to copy (an id plus a +// pointer) and owns nothing. Implicitly decays to the raw Entity id, so a handle drops into +// the existing id-based APIs (scene graph, picking, serialization, ...) unchanged. +// +// EntityHandle e = scene->create("Player"); +// e.add(TransformComponent(...)); +// e.transform()->setPosition({0, 1, 0}); +// if (e.has()) { ... } +// if (!e.valid()) { ... } // entity destroyed since we took the handle +// +// This supersedes the old EntityHelper. Component pointers returned here follow the same validity +// guarantee as Registry::getComponent: they stay valid until that component is removed, and are +// not invalidated by add/remove of any other entity's components. +class EntityHandle { + public: + EntityHandle() = default; + EntityHandle(Entity id, Registry* registry) : m_id(id), m_registry(registry) {} + + // --- Component access ------------------------------------------------------------------- + // Add a component (moved in) and return a reference to the stored instance. + template + T& add(T component) { + m_registry->addComponent(m_id, std::move(component)); + return *m_registry->getComponent(m_id); + } + + template + void remove() { + m_registry->removeComponent(m_id); + } + + // nullptr if the entity has no component of type T (also safe on a stale/destroyed handle). + template + T* get() const { + return m_registry->tryGetComponent(m_id); + } + + template + bool has() const { + return get() != nullptr; + } + + // Convenience accessors for the built-in components (nullptr if absent). + TransformComponent* transform() const { return get(); } + RenderComponent* render() const { return get(); } + LightComponent* light() const { return get(); } + AnimationComponent* animation() const { return get(); } + + // Attach a native script of type TScript to this entity. Wraps the NativeScriptComponent + // bind() dance: the ScriptSystem constructs and owns the live instance (forwarding any args + // to TScript's constructor) and drives its onCreate/onUpdate/onDestroy. + // e.script(); + template + NativeScriptComponent& script(Args... args) { + NativeScriptComponent nsc; + nsc.bind(args...); + return add(std::move(nsc)); + } + + // --- Identity / liveness ---------------------------------------------------------------- + Entity id() const { return m_id; } + Registry* registry() const { return m_registry; } + + // True while the entity is live in its registry (Phase 3 liveness). A default/null handle + // and a handle to a destroyed entity are both invalid. + bool valid() const { return m_registry != nullptr && m_registry->isAlive(m_id); } + explicit operator bool() const { return valid(); } + + // Implicit decay to the raw id for interop with id-based APIs. + operator Entity() const { return m_id; } + + bool operator==(const EntityHandle& o) const { return m_id == o.m_id && m_registry == o.m_registry; } + bool operator!=(const EntityHandle& o) const { return !(*this == o); } + + private: + Entity m_id = NULL_ENTITY; + Registry* m_registry = nullptr; +}; +} // namespace ICE diff --git a/ICE/System/include/IPlugin.h b/ICE/System/include/IPlugin.h new file mode 100644 index 00000000..f2157575 --- /dev/null +++ b/ICE/System/include/IPlugin.h @@ -0,0 +1,32 @@ +#pragma once + +namespace ICE { +class Registry; +class AssetBank; + +// What a plugin is allowed to touch, handed to it at load time. Kept to non-owning pointers so the +// plugin depends on the ECS/asset seams, not on the concrete engine: +// * registry -- add gameplay systems (Registry::addSystem) +// * asset_bank -- add asset loaders (AssetBank::addLoader) +// Components need no registration -- they register lazily on first use (see P2), so a plugin can +// define and use its own component types with no ceremony. +struct PluginContext { + Registry* registry = nullptr; + AssetBank* asset_bank = nullptr; +}; + +// Extension point: an out-of-tree module implements IPlugin to add systems, asset loaders and +// components to the engine without any change to core. The engine loads it via +// ICEEngine::loadPlugin, which builds a PluginContext for the active scene and calls registerWith. +class IPlugin { + public: + virtual ~IPlugin() = default; + + // Human-readable id (for logging / diagnostics). + virtual const char* name() const = 0; + + // Register the plugin's systems / loaders / components. Called once at load. + virtual void registerWith(PluginContext& ctx) = 0; +}; + +} // namespace ICE diff --git a/ICE/System/include/Registry.h b/ICE/System/include/Registry.h index 4d923d7e..7ef82636 100644 --- a/ICE/System/include/Registry.h +++ b/ICE/System/include/Registry.h @@ -5,39 +5,28 @@ #ifndef ICE_REGISTRY_H #define ICE_REGISTRY_H -#include -#include #include #include -#include -#include -#include -#include -#include -#include #include -#include #include namespace ICE { +// Component types are no longer enumerated here: they register themselves the first time they are +// added/queried (see ComponentManager::assure). This keeps the ECS core free of any dependency on +// concrete gameplay/render component headers -- add a brand-new component type from anywhere with +// no registration call and no edit to this file. class Registry { public: - Registry() { - componentManager.registerComponent(); - componentManager.registerComponent(); - componentManager.registerComponent(); - componentManager.registerComponent(); - componentManager.registerComponent(); - componentManager.registerComponent(); - componentManager.registerComponent(); - componentManager.registerComponent(); - } + Registry() = default; ~Registry() = default; + // Deprecated: component types register on first use, so this call is unnecessary. Kept as an + // idempotent forwarder until existing call sites are removed. template + [[deprecated("Component types register on first use; registerCustomComponent() is no longer required.")]] void registerCustomComponent() { - componentManager.registerComponent(); + componentManager.assure(); } Entity createEntity() { @@ -53,27 +42,54 @@ class Registry { void removeEntity(Entity e) { auto it = std::find(entities.begin(), entities.end(), e); + if (it == entities.end()) { + // Not a live entity: erase(end()) would be undefined behavior. + return; + } entities.erase(it); componentManager.entityDestroyed(e); entityManager.releaseEntity(e); systemManager.entityDestroyed(e); } - std::vector getEntities() const { return entities; } + const std::vector& getEntities() const { return entities; } + + bool isAlive(Entity e) const { return entityManager.isAlive(e); } template - bool entityHasComponent(Entity e) { + bool entityHasComponent(Entity e) const { return entityManager.getSignature(e).test(componentManager.getComponentType()); } + // The returned pointer stays valid until *this* component is removed (removeComponent(e) + // or the entity/registry is destroyed). Component storage is stable: adding or removing any + // OTHER entity's component -- of this or any other type -- never invalidates it, so it is safe + // to hold across add/remove of other entities. Removing this component leaves the pointer + // dangling, as with erasing any container element. template T *getComponent(Entity e) { return componentManager.getComponent(e); } + // Returns nullptr instead of asserting when the entity has no component of type T. Same + // pointer-validity guarantee as getComponent. + template + T *tryGetComponent(Entity e) { + return componentManager.tryGetComponent(e); + } + + // Cache-friendly iteration over all entities that have every listed component type. + // Iterates the first type's dense storage, so list the rarest component first: + // registry.each([](Entity e, LightComponent& l, TransformComponent& t){ ... }); + // Don't add/remove any of these component types from within the callback. + template + void each(Fn &&fn) { + componentManager.each(std::forward(fn)); + } + template void addComponent(Entity e, T component) { - componentManager.addComponent(e, component); + componentManager.addComponent(e, std::move(component)); auto signature = entityManager.getSignature(e); signature.set(componentManager.getComponentType(), true); entityManager.setSignature(e, signature); @@ -102,6 +118,12 @@ class Registry { return systemManager.getSystem(); } + // nullptr if no system of type T has been added (getSystem throws in that case). + template + std::shared_ptr tryGetSystem() { + return systemManager.tryGetSystem(); + } + void updateSystems(double delta) { systemManager.updateSystems(delta); } private: diff --git a/ICE/System/include/RenderSystem.h b/ICE/System/include/RenderSystem.h index e639b3af..f5427a07 100644 --- a/ICE/System/include/RenderSystem.h +++ b/ICE/System/include/RenderSystem.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -16,16 +17,29 @@ namespace ICE { class Scene; class Registry; +class Model; // only referenced as shared_ptr in a declaration; full type not needed here + +struct CullingData { + uint32_t lastTransformVersion = 0xFFFFFFFF; + AssetUID lastMesh = 0; + + Eigen::Vector3f worldCenter; + Eigen::Vector3f worldExtents; +}; class RenderSystem : public System { public: - RenderSystem(const std::shared_ptr &api, const std::shared_ptr &factory, const std::shared_ptr ®, - const std::shared_ptr &gpu_bank); + // The render system no longer touches the graphics API directly: it only culls, submits the + // visible set to the renderer, and asks the renderer to present. It therefore needs the + // registry (to read components) and the GPU bank (to resolve mesh/material/shader handles). + RenderSystem(const std::shared_ptr ®, const std::shared_ptr &gpu_bank); void onEntityAdded(Entity e) override; void onEntityRemoved(Entity e) override; void update(double delta) override; + int updateOrder() const override { return RenderSystemOrder; } + void submitModel(const std::shared_ptr &model, const Eigen::Matrix4f &transform); std::shared_ptr getRenderer() const; @@ -36,6 +50,11 @@ class RenderSystem : public System { void setTarget(const std::shared_ptr &fb); void setViewport(int x, int y, int w, int h); + // Opt-in parallel cull+build. With a scheduler set, the per-entity frustum culling, skinning + // and drawable assembly run across worker threads (all GL/asset access stays on this thread). + // Null (the default) keeps the single-threaded path. See update(). + void setScheduler(const std::shared_ptr &scheduler) { m_scheduler = scheduler; } + std::vector getSignatures(const ComponentManager &comp_manager) const override { Signature signature0; signature0.set(comp_manager.getComponentType()); @@ -57,11 +76,18 @@ class RenderSystem : public System { std::vector m_render_queue; std::vector m_lights; - std::shared_ptr m_api; - std::shared_ptr m_factory; - std::shared_ptr m_registry; + // Non-owning back-reference: the Registry owns this system, so a shared_ptr here formed + // a Registry -> SystemManager -> this -> Registry cycle. The Registry outlives its systems. + Registry* m_registry = nullptr; std::shared_ptr m_gpu_bank; - std::shared_ptr m_quad_vao; + // Full-screen present shader, resolved from the GPU bank once and reused, instead of a + // per-frame string lookup. The renderer's present pass consumes it. + std::shared_ptr m_lastpass_shader; + + // Optional work-stealing scheduler for the parallel cull+build path (null => single-threaded). + std::shared_ptr m_scheduler; + + std::unordered_map m_culling_cache; }; } // namespace ICE diff --git a/ICE/System/include/SceneGraphSystem.h b/ICE/System/include/SceneGraphSystem.h index 7ea85425..af2d50fe 100644 --- a/ICE/System/include/SceneGraphSystem.h +++ b/ICE/System/include/SceneGraphSystem.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include "System.h" @@ -14,6 +15,12 @@ class SceneGraphSystem : public System { void onEntityRemoved(Entity e) override; void update(double delta) override; + int updateOrder() const override { return SceneGraphSystemOrder; } + + // Recursive transform propagation over raw node pointers (no per-frame std::function + // allocation, no shared_ptr refcount churn per node). + void updateNode(SceneGraph::SceneNode *node, const Eigen::Matrix4f &parentMatrix, bool parent_changed); + std::vector getSignatures(const ComponentManager &comp_manager) const override { Signature signature0; signature0.set(comp_manager.getComponentType()); @@ -21,6 +28,10 @@ class SceneGraphSystem : public System { } private: - std::shared_ptr m_scene; + // Non-owning: the Scene owns this system's Registry (which owns this system), so holding + // a shared_ptr here formed a Scene -> Registry -> SystemManager -> this -> Scene cycle + // that leaked the whole scene. The Scene always outlives its systems. + Scene* m_scene = nullptr; + std::unordered_map m_transformVersions; }; } // namespace ICE diff --git a/ICE/System/include/ScriptSystem.h b/ICE/System/include/ScriptSystem.h new file mode 100644 index 00000000..dbc0dbe2 --- /dev/null +++ b/ICE/System/include/ScriptSystem.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include "System.h" + +namespace ICE { +class Scene; // injected into scripts as scene(); only stored/threaded here (never derefed) +class InputManager; // injected into scripts as input(); only stored/threaded here (never derefed) + +// Drives NativeScriptComponent lifecycles: instantiates one NativeScript per entity, injects its +// behaviour context (entity/registry/scene/input/time) and calls onCreate/onUpdate/onDestroy. Runs +// first each frame (ScriptSystemOrder) so gameplay changes to transforms are visible to animation, +// the scene graph and rendering the same frame. The live instances are owned here (keyed by +// entity), not in the component. +class ScriptSystem : public System { + public: + // scene/input become every script's behaviour context; both may be null (a scene with no + // engine input service, or a headless test), in which case scene()/input() return null. + ScriptSystem(const std::shared_ptr& reg, Scene* scene = nullptr, InputManager* input = nullptr) + : m_registry(reg.get()), m_scene(scene), m_input(input) {} + + void update(double delta) override; + void onEntityAdded(Entity e) override; + void onEntityRemoved(Entity e) override; + + int updateOrder() const override { return ScriptSystemOrder; } + + std::vector getSignatures(const ComponentManager& comp_manager) const override { + Signature signature; + signature.set(comp_manager.getComponentType()); + return {signature}; + } + + private: + // Returns the entity's live script, instantiating (and onCreate-ing) it on first use. + // nullptr if the entity has no bound script. + NativeScript* ensureInstance(Entity e); + + // Non-owning back-reference (the Registry owns this system) to avoid an ownership cycle. + Registry* m_registry = nullptr; + Scene* m_scene = nullptr; // non-owning; injected into scripts as scene() + InputManager* m_input = nullptr; // non-owning; injected into scripts as input() + double m_elapsed = 0.0; // accumulated frame time, injected into scripts as time() + std::unordered_map> m_instances; + // Entities whose script called destroy() this frame: removed after the onUpdate pass so a + // script can't be torn down under its own feet mid-update. + std::vector m_pending_destroy; +}; +} // namespace ICE diff --git a/ICE/System/include/System.h b/ICE/System/include/System.h index 7001b006..5319053b 100644 --- a/ICE/System/include/System.h +++ b/ICE/System/include/System.h @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -16,12 +17,26 @@ namespace ICE { class Scene; class ComponentManager; +// Canonical per-frame update order (lower runs first): input/scripts advance state, +// animation updates local transforms, the scene graph propagates them to world space, +// then rendering consumes the final transforms. +enum SystemUpdateOrder : int { + ScriptSystemOrder = 100, + AnimationSystemOrder = 200, + SceneGraphSystemOrder = 300, + RenderSystemOrder = 400, +}; + class System { public: virtual void update(double delta) = 0; virtual void onEntityAdded(Entity e) {}; virtual void onEntityRemoved(Entity e) {}; + // Lower values update first. Determines the deterministic per-frame ordering; the + // default puts unclassified systems before rendering. + virtual int updateOrder() const { return 0; } + virtual std::vector getSignatures(const ComponentManager& comp_manager) const = 0; virtual ~System() = default; @@ -36,9 +51,7 @@ class SystemManager { void entityDestroyed(Entity entity) { // Erase a destroyed entity from all system lists // mEntities is a set so no check needed - for (auto const& pair : systems) { - auto const& system = pair.second; - + for (auto const& system : orderedSystems) { system->entities.erase(entity); system->onEntityRemoved(entity); } @@ -46,11 +59,21 @@ class SystemManager { void entitySignatureChanged(Entity entity, Signature entitySignature, const ComponentManager& comp_manager) { // Notify each system that an entity's signature changed - for (auto const& pair : systems) { - auto const& system = pair.second; - auto const& systemSignature = system->getSignatures(comp_manager); - - // Entity signature matches system signature - insert into set + for (auto const& system : orderedSystems) { + // A system's signatures are fixed once component types are registered, so cache + // them instead of rebuilding the vector on every component add/remove. + auto sig_it = m_signatureCache.find(system.get()); + if (sig_it == m_signatureCache.end()) { + sig_it = m_signatureCache.emplace(system.get(), system->getSignatures(comp_manager)).first; + } + const auto& systemSignature = sig_it->second; + + // Entity signature matches system signature - insert into set. onEntityAdded is + // fired on every matching signature change (not just the first insert): a system + // like RenderSystem derives several sub-lists (renderables/lights/skybox) from + // different component types and must resync when any relevant component is added + // or removed while the entity is already a member. Such onEntityAdded handlers + // must therefore be idempotent (resync, e.g. remove-then-add their sub-lists). bool match = false; for (const auto& s : systemSignature) { if ((entitySignature & s) == s) { @@ -60,7 +83,7 @@ class SystemManager { break; } } - // Entity signature does not match system signature - erase from set + // Entity signature no longer matches - erase from set and notify (both idempotent). if (!match) { system->entities.erase(entity); system->onEntityRemoved(entity); @@ -69,14 +92,22 @@ class SystemManager { } void updateSystems(double delta) { - for (auto const& [signature, system] : systems) { + // Iterate in deterministic updateOrder() order (not hash order of the type map). + for (auto const& system : orderedSystems) { system->update(delta); } } template void addSystem(const std::shared_ptr& system) { - systems.try_emplace(typeid(T), system); + if (!systems.try_emplace(typeid(T), system).second) { + return; // already registered + } + // Keep orderedSystems sorted by updateOrder(); stable for equal orders (later + // registrations of the same order run after earlier ones). + auto pos = std::upper_bound(orderedSystems.begin(), orderedSystems.end(), std::static_pointer_cast(system), + [](const std::shared_ptr& a, const std::shared_ptr& b) { return a->updateOrder() < b->updateOrder(); }); + orderedSystems.insert(pos, system); } template @@ -84,8 +115,20 @@ class SystemManager { return std::static_pointer_cast(systems.at(typeid(T))); } + // Non-throwing variant: nullptr if no system of type T has been added (getSystem throws). + template + std::shared_ptr tryGetSystem() { + auto it = systems.find(typeid(T)); + return it == systems.end() ? nullptr : std::static_pointer_cast(it->second); + } + private: - // Map from system type string pointer to a system pointer + // Map from system type index to a system pointer (fast getSystem() lookup). std::unordered_map> systems; + // Same systems, kept sorted by updateOrder() for deterministic iteration. + std::vector> orderedSystems; + // Cached signatures per system (stable after component registration); avoids rebuilding + // the vector on every entitySignatureChanged call. + std::unordered_map> m_signatureCache; }; } // namespace ICE diff --git a/ICE/System/src/AnimationSystem.cpp b/ICE/System/src/AnimationSystem.cpp index 04f8cd47..86ef067b 100644 --- a/ICE/System/src/AnimationSystem.cpp +++ b/ICE/System/src/AnimationSystem.cpp @@ -1,71 +1,179 @@ #include "AnimationSystem.h" #include +#include namespace ICE { -AnimationSystem::AnimationSystem(const std::shared_ptr& reg, const std::shared_ptr& bank) : m_registry(reg), m_asset_bank(bank) { +AnimationSystem::AnimationSystem(const std::shared_ptr& reg, const std::shared_ptr& bank) : m_registry(reg.get()), m_asset_bank(bank) { } void AnimationSystem::update(double dt) { + // Phase 1 (render thread): resolve each playing entity's model (asset-bank access) and force + // its lazy node-name map to build now, so the parallel phase only ever reads it. Entities that + // aren't playing or whose model is missing are dropped here. + struct AnimJob { + Entity entity; + std::shared_ptr model; + }; + std::vector jobs; + jobs.reserve(entities.size()); for (auto e : entities) { auto anim = m_registry->getComponent(e); + if (!anim->playing) { + continue; + } auto pose = m_registry->getComponent(e); - if (!anim->playing) + auto model = m_asset_bank->getAsset(pose->skeletonModel); + if (!model) { continue; + } + (void) model->getNodeByName(std::string()); // warm the lazy node-name cache serially + jobs.push_back({e, std::move(model)}); + } - anim->currentTime += dt * anim->speed; + // Phase 2: sample and pose each skeleton. Each animated entity owns disjoint bone entities and + // touches no asset bank, so the work parallelises cleanly across workers when a scheduler is + // set and there are enough entities; otherwise it runs inline. + auto process = [&](size_t begin, size_t end) { + for (size_t i = begin; i < end; ++i) { + updateEntity(jobs[i].entity, jobs[i].model, dt); + } + }; + static constexpr size_t kParallelThreshold = 8; // skeletal update is heavy per entity + if (m_scheduler && jobs.size() >= kParallelThreshold) { + m_scheduler->parallelRanges(jobs.size(), [&](size_t begin, size_t end) { process(begin, end); }); + } else { + process(0, jobs.size()); + } +} - auto model = m_asset_bank->getAsset(pose->skeletonModel); - if (!model->getAnimations().contains(anim->currentAnimation)) { - continue; +void AnimationSystem::updateEntity(Entity e, const std::shared_ptr& model, double dt) { + auto anim = m_registry->getComponent(e); + auto pose = m_registry->getComponent(e); + + const auto& animations = model->getAnimations(); + if (!animations.contains(anim->currentAnimation)) { + return; + } + const auto& currentAnim = animations.at(anim->currentAnimation); + + // Advance current animation time. dt is in seconds; animation keyframes are in + // ticks, so convert with ticksPerSecond (previously ignored -> wrong playback rate). + anim->currentTime += dt * currentAnim.ticksPerSecond * anim->speed; + + if (anim->currentTime > currentAnim.duration) { + if (anim->loop) { + anim->currentTime = std::fmod(anim->currentTime, currentAnim.duration); + } else { + anim->currentTime = currentAnim.duration; + anim->playing = false; + } + } + + // Handle blending + if (anim->blending) { + anim->blendFactor += dt / anim->blendDuration; + if (anim->blendFactor >= 1.0) { + anim->blendFactor = 1.0; + anim->blending = false; + } + + // Advance previous animation time as well + if (animations.contains(anim->previousAnimation)) { + const auto& prevAnim = animations.at(anim->previousAnimation); + anim->previousTime += dt * prevAnim.ticksPerSecond * anim->speed; + if (anim->previousTime > prevAnim.duration) { + anim->previousTime = std::fmod(anim->previousTime, prevAnim.duration); + } + } + + // Blended update + const Animation* prevAnimPtr = nullptr; + if (animations.contains(anim->previousAnimation)) { + prevAnimPtr = &animations.at(anim->previousAnimation); } - auto animation = model->getAnimations().at(anim->currentAnimation); - if (anim->currentTime > animation.duration) { - if (anim->loop) { - anim->currentTime = std::fmod(anim->currentTime, animation.duration); + float blendT = static_cast(anim->blendFactor); + + for (auto const& [nodeName, nodeEntity] : pose->bone_entity) { + BonePose currentPose = sampleBonePose(nodeName, currentAnim, anim->currentTime, model); + BonePose prevPose; + if (prevAnimPtr) { + prevPose = sampleBonePose(nodeName, *prevAnimPtr, anim->previousTime, model); } else { - anim->currentTime = animation.duration; - anim->playing = false; + prevPose = currentPose; } + + BonePose finalPose = blendPoses(prevPose, currentPose, blendT); + + auto transform = m_registry->getComponent(nodeEntity); + transform->setPosition(finalPose.position); + transform->setRotation(finalPose.rotation); + transform->setScale(finalPose.scale); + } + } else { + // No blending — direct update + updateSkeleton(model, anim->currentTime, pose, currentAnim); + } + + finalizePose(e, model); +} + +BonePose AnimationSystem::sampleBonePose(const std::string& boneName, const Animation& anim, double time, const std::shared_ptr& model) { + BonePose pose; + if (anim.tracks.contains(boneName)) { + const auto& track = anim.tracks.at(boneName); + pose.position = interpolatePosition(time, track); + pose.rotation = interpolateRotation(time, track); + pose.scale = interpolateScale(time, track); + } else { + // Fall back to default node transform + const auto* node = model->getNodeByName(boneName); + if (node) { + TransformComponent defaultTransform(node->localTransform); + pose.position = defaultTransform.getPosition(); + pose.rotation = defaultTransform.getRotation(); + pose.scale = defaultTransform.getScale(); } - updateSkeleton(model, anim->currentTime, pose, animation); - finalizePose(); } + return pose; +} + +BonePose AnimationSystem::blendPoses(const BonePose& a, const BonePose& b, float factor) { + BonePose result; + result.position = a.position + factor * (b.position - a.position); + result.rotation = a.rotation.slerp(factor, b.rotation); + result.rotation.normalize(); + result.scale = a.scale + factor * (b.scale - a.scale); + return result; } void AnimationSystem::updateSkeleton(const std::shared_ptr& model, double time, SkeletonPoseComponent* pose, const Animation& anim) { for (auto const& [nodeName, nodeEntity] : pose->bone_entity) { - if (anim.tracks.contains(nodeName)) { - auto transform = m_registry->getComponent(nodeEntity); - const auto& track = anim.tracks.at(nodeName); - - auto pos = interpolatePosition(time, track); - auto rot = interpolateRotation(time, track); - auto scale = interpolateScale(time, track); + BonePose bonePose = sampleBonePose(nodeName, anim, time, model); - transform->setPosition(pos); - transform->setRotation(rot); - transform->setScale(scale); - } + auto transform = m_registry->getComponent(nodeEntity); + transform->setPosition(bonePose.position); + transform->setRotation(bonePose.rotation); + transform->setScale(bonePose.scale); } } -void AnimationSystem::finalizePose() { - for (auto e : entities) { - auto pose = m_registry->getComponent(e); - auto model = m_asset_bank->getAsset(pose->skeletonModel); - auto& skeleton = model->getSkeleton(); +void AnimationSystem::finalizePose(Entity e, const std::shared_ptr& model) { + // Finalize only the entity being processed. This used to loop over every animated + // entity on each call, and it is called once per entity in update(), so the work was + // O(N^2) (with a matrix inverse per skeleton) while producing identical results. + auto pose = m_registry->getComponent(e); + auto& skeleton = model->getSkeleton(); - auto rootTransform = m_registry->getComponent(e); - Eigen::Matrix4f modelWorldInv = rootTransform->getWorldMatrix().inverse(); + auto rootTransform = m_registry->getComponent(e); + Eigen::Matrix4f modelWorldInv = rootTransform->getWorldMatrix().inverse(); - for (const auto& [name, id] : skeleton.boneMapping) { - Entity boneEntity = pose->bone_entity.at(name); + for (const auto& [name, id] : skeleton.boneMapping) { + Entity boneEntity = pose->bone_entity.at(name); - Eigen::Matrix4f boneWorld = m_registry->getComponent(boneEntity)->getWorldMatrix(); - pose->bone_transform[id] = modelWorldInv * boneWorld; - } + Eigen::Matrix4f boneWorld = m_registry->getComponent(boneEntity)->getWorldMatrix(); + pose->bone_transform[id] = modelWorldInv * boneWorld; } } @@ -122,6 +230,11 @@ Eigen::Vector3f AnimationSystem::interpolateScale(double timeInTicks, const Bone } Eigen::Quaternionf AnimationSystem::interpolateRotation(double time, const BoneAnimation& track) { + // Empty track: nothing to interpolate. Without this guard, rotations.size() - 1 wraps + // to SIZE_MAX and findKeyIndex reads out of bounds. + if (track.rotations.empty()) { + return Eigen::Quaternionf::Identity(); + } if (track.rotations.size() == 1) { return track.rotations[0].rotation; } @@ -133,6 +246,9 @@ Eigen::Quaternionf AnimationSystem::interpolateRotation(double time, const BoneA const auto& nextKey = track.rotations[nextIndex]; double totalTime = nextKey.timeStamp - startKey.timeStamp; + if (totalTime == 0.0) { + return startKey.rotation; + } double factor = (time - startKey.timeStamp) / totalTime; Eigen::Quaternionf finalQuat = startKey.rotation.slerp((float) factor, nextKey.rotation); diff --git a/ICE/System/src/RenderSystem.cpp b/ICE/System/src/RenderSystem.cpp index 3cabf2c6..fad6fe25 100644 --- a/ICE/System/src/RenderSystem.cpp +++ b/ICE/System/src/RenderSystem.cpp @@ -4,26 +4,136 @@ #include "RenderSystem.h" +#include +#include + #include "Registry.h" -#include "RenderData.h" namespace ICE { -RenderSystem::RenderSystem(const std::shared_ptr &api, const std::shared_ptr &factory, - const std::shared_ptr ®, const std::shared_ptr &gpu_bank) - : m_api(api), - m_factory(factory), - m_registry(reg), +namespace { + +// A fully-resolved renderable: everything needed to frustum-cull and assemble a Drawable, gathered +// on the render thread (Phase 1) so the parallel phase (Phase 2) touches no registry, asset bank or +// GPU bank -- only plain data and stable pointers. mesh/material/shader/textures are owning handles; +// skinning/pose are non-owning pointers into the mesh asset and the pose component, both of which +// outlive the frame and (after P4) have stable addresses. +struct RenderJob { + Eigen::Vector3f worldCenter; + Eigen::Vector3f worldExtents; + Eigen::Matrix4f model_matrix; + MeshHandle mesh; + std::shared_ptr material; + ShaderHandle shader; + AssetUID material_uid = NO_ASSET_ID; + const SkinningData *skinning = nullptr; + const SkeletonPoseComponent *pose = nullptr; +}; + +// Phase 1 (render thread): read components, refresh the world-space bounds cache, resolve GPU +// resources (which may lazily upload -- hence render-thread only), and gather skinning inputs. +// Returns false (job discarded) if the mesh/material/shader can't be resolved. +bool resolveJob(Registry *reg, GPURegistry *gpu, std::unordered_map &cache, Entity e, RenderJob &job) { + auto tc = reg->getComponent(e); + auto rc = reg->getComponent(e); + + // Resolve GPU resources first (uploading on first use). meshHandle() returns an invalid handle + // when the mesh asset is gone -- removed or evicted, e.g. after a re-import -- so this both gates + // the job and guarantees the mesh asset is present before getMeshAABB() dereferences it below. + // The material stays a CPU-asset shared_ptr; its textures are resolved by the geometry pass. + auto mesh = gpu->meshHandle(rc->mesh); + auto material = gpu->getMaterial(rc->material); + if (!mesh.valid() || !material) { + return false; + } + auto shader = gpu->shaderHandle(material->getShader()); + if (!shader.valid()) { + return false; + } + + Eigen::Matrix4f model_mat = tc->getWorldMatrix(); + + // World-space bounds, recomputed only when the transform or mesh changes (single hash lookup). + auto cache_it = cache.find(e); + if (cache_it == cache.end() || cache_it->second.lastTransformVersion != tc->getVersion() || cache_it->second.lastMesh != rc->mesh) { + auto local_aabb = gpu->getMeshAABB(rc->mesh); + Eigen::Vector3f localCenter = local_aabb.getCenter(); + Eigen::Vector3f localExtents = local_aabb.getExtent(); + + Eigen::Matrix3f R = model_mat.block<3, 3>(0, 0); + Eigen::Vector3f T = model_mat.block<3, 1>(0, 3); + Eigen::Vector3f worldCenter = R * localCenter + T; + Eigen::Matrix3f absR = R.cwiseAbs(); + Eigen::Vector3f worldExtents = absR * localExtents; + + CullingData data{ + .lastTransformVersion = tc->getVersion(), + .lastMesh = rc->mesh, + .worldCenter = worldCenter, + .worldExtents = worldExtents, + }; + if (cache_it == cache.end()) { + cache_it = cache.emplace(e, data).first; + } else { + cache_it->second = data; + } + } + job.worldCenter = cache_it->second.worldCenter; + job.worldExtents = cache_it->second.worldExtents; + + if (reg->entityHasComponent(e)) { + auto skeleton_entity = reg->getComponent(e)->skeleton_entity; + // The skeleton entity may be stale/invalid; probe instead of asserting so a bad reference + // skips skinning for this frame rather than dereferencing null. + auto pose = reg->tryGetComponent(skeleton_entity); + auto skel_transform = reg->tryGetComponent(skeleton_entity); + if (pose && skel_transform) { + job.skinning = &gpu->getMeshSkinningData(rc->mesh); + job.pose = pose; + model_mat = skel_transform->getWorldMatrix(); + } + } + + job.model_matrix = model_mat; + job.mesh = mesh; + job.material = std::move(material); + job.shader = shader; + job.material_uid = rc->material; + return true; +} + +// Phase 2 (any thread): frustum-cull, compute skinning bone matrices, and assemble the Drawable. +// Pure computation over the job's own data -- no shared mutable state -- so disjoint jobs run +// concurrently. Consumes `job` (moved-from) since each job is processed exactly once. +template +bool cullAndAssemble(RenderJob &job, const Frustum &frustum, Drawable &out) { + if (!isAABBInFrustum(frustum, job.worldCenter, job.worldExtents)) { + return false; + } + std::unordered_map bone_matrices; + if (job.skinning && job.pose) { + for (const auto &[id, ibm] : job.skinning->inverseBindMatrices) { + // bone_transform is indexed by bone id; guard against an id outside the current pose. + if (id >= 0 && static_cast(id) < job.pose->bone_transform.size()) { + bone_matrices.try_emplace(id, job.pose->bone_transform[id] * ibm); + } + } + } + out = Drawable{ + .mesh = job.mesh, + .material = std::move(job.material), + .shader = job.shader, + .material_uid = job.material_uid, + .model_matrix = job.model_matrix, + .bone_matrices = std::move(bone_matrices), + }; + return true; +} + +} // namespace + +RenderSystem::RenderSystem(const std::shared_ptr ®, const std::shared_ptr &gpu_bank) + : m_registry(reg.get()), m_gpu_bank(gpu_bank) { - m_quad_vao = factory->createVertexArray(); - auto quad_vertex_vbo = factory->createVertexBuffer(); - quad_vertex_vbo->putData(full_quad_v.data(), full_quad_v.size() * sizeof(float)); - m_quad_vao->pushVertexBuffer(quad_vertex_vbo, 3); - auto quad_uv_vbo = factory->createVertexBuffer(); - quad_uv_vbo->putData(full_quad_tx.data(), full_quad_tx.size() * sizeof(float)); - m_quad_vao->pushVertexBuffer(quad_uv_vbo, 2); - auto quad_ibo = factory->createIndexBuffer(); - quad_ibo->putData(full_quad_idx.data(), full_quad_idx.size() * sizeof(int)); - m_quad_vao->setIndexBuffer(quad_ibo); } void RenderSystem::update(double delta) { @@ -32,64 +142,51 @@ void RenderSystem::update(double delta) { auto proj_mat = m_camera->getProjection(); if (m_skybox != NO_ASSET_ID) { - auto shader = m_gpu_bank->getShader(AssetPath::WithTypePrefix("__ice_skybox_shader")); - auto skybox = m_registry->getComponent(m_skybox); - auto mesh = m_gpu_bank->getMesh(AssetPath::WithTypePrefix("cube")); - auto tex = m_gpu_bank->getCubemap(skybox->texture); m_renderer->submitSkybox(Skybox{ - .cube_mesh = mesh, - .shader = shader, - .textures = {{skybox->texture, tex}}, + .cube_mesh = m_gpu_bank->meshHandle(AssetPath::WithTypePrefix("cube")), + .shader = m_gpu_bank->shaderHandle(AssetPath::WithTypePrefix("__ice_skybox_shader")), }); } auto frustum = extractFrustumPlanes(proj_mat * view_mat); + + // Phase 1 (render thread): resolve every renderable into a self-contained job. All component, + // asset-bank and GPU-bank access -- including lazy GL uploads -- happens here, single-threaded. + std::vector jobs; + jobs.reserve(m_render_queue.size()); for (const auto &e : m_render_queue) { - auto rc = m_registry->getComponent(e); - auto tc = m_registry->getComponent(e); - auto mesh = m_gpu_bank->getMesh(rc->mesh); - auto material = m_gpu_bank->getMaterial(rc->material); - auto shader = m_gpu_bank->getShader(material->getShader()); - if (!mesh || !material || !shader) - continue; - - auto model_mat = tc->getWorldMatrix(); - - auto aabb = m_gpu_bank->getMeshAABB(rc->mesh); - Eigen::Vector3f min = (model_mat * Eigen::Vector4f(aabb.getMin().x(), aabb.getMin().y(), aabb.getMin().z(), 1.0)).head<3>(); - Eigen::Vector3f max = (model_mat * Eigen::Vector4f(aabb.getMax().x(), aabb.getMax().y(), aabb.getMax().z(), 1.0)).head<3>(); - aabb = AABB(std::vector{min, max}); - if (!isAABBInFrustum(frustum, aabb)) { - continue; + RenderJob job; + if (resolveJob(m_registry, m_gpu_bank.get(), m_culling_cache, e, job)) { + jobs.push_back(std::move(job)); } + } - std::unordered_map bone_matrices; - if (m_registry->entityHasComponent(e)) { - const auto &skinning = m_gpu_bank->getMeshSkinningData(rc->mesh); - auto skeleton_entity = m_registry->getComponent(e)->skeleton_entity; - auto pose = m_registry->getComponent(skeleton_entity); - for (const auto &[id, ibm] : skinning.inverseBindMatrices) { - bone_matrices.try_emplace(id, pose->bone_transform[id] * ibm); + // Phase 2: frustum-cull, skin and assemble a Drawable per surviving job. This is pure + // computation over the pre-resolved data with each index independent (disjoint writes to + // `drawables`/`visible`), so it runs across the scheduler's workers when one is set and the + // batch is large enough; otherwise it runs inline. `visible` is char (not vector) so + // concurrent writes to distinct elements are race-free. + std::vector drawables(jobs.size()); + std::vector visible(jobs.size(), 0); + auto process = [&](size_t begin, size_t end) { + for (size_t i = begin; i < end; ++i) { + if (cullAndAssemble(jobs[i], frustum, drawables[i])) { + visible[i] = 1; } - - model_mat = m_registry->getComponent(skeleton_entity)->getWorldMatrix(); } + }; + static constexpr size_t kParallelThreshold = 256; + if (m_scheduler && jobs.size() >= kParallelThreshold) { + m_scheduler->parallelRanges(jobs.size(), [&](size_t begin, size_t end) { process(begin, end); }); + } else { + process(0, jobs.size()); + } - std::unordered_map> texs; - for (const auto &[name, value] : material->getAllUniforms()) { - if (std::holds_alternative(value)) { - auto v = std::get(value); - if (auto tex = m_gpu_bank->getTexture2D(v); tex) { - texs.try_emplace(v, tex); - } - } + // Phase 3 (render thread): submit the survivors in queue order (the renderer sorts them anyway). + for (size_t i = 0; i < jobs.size(); ++i) { + if (visible[i]) { + m_renderer->submitDrawable(std::move(drawables[i])); } - m_renderer->submitDrawable(Drawable{.mesh = mesh, - .material = material, - .shader = shader, - .textures = texs, - .model_matrix = model_mat, - .bone_matrices = bone_matrices}); } for (int i = 0; i < m_lights.size(); i++) { @@ -106,29 +203,26 @@ void RenderSystem::update(double delta) { .type = lc->type}); } - m_renderer->prepareFrame(*m_camera); - auto rendered_fb = m_renderer->render(); - m_renderer->endFrame(); - - //Final pass, render the last result to the screen - if (!m_target) { - m_api->bindDefaultFramebuffer(); - } else { - m_target->bind(); + // Hand the present target + shader to the renderer before render(): present is now a graph pass + // (T10), so the whole frame -- geometry, features, UI, and the final composite -- runs inside + // render(). m_target (nullptr = default framebuffer) is honoured by the present pass, preserving + // the editor's render-to-texture path. Resolve the full-screen shader once and reuse it. + if (!m_lastpass_shader) { + m_lastpass_shader = m_gpu_bank->getShader(AssetPath::WithTypePrefix("lastpass")); } + m_renderer->setPresentTarget(m_target); + m_renderer->setPresentShader(m_lastpass_shader); - m_api->clear(); - auto shader = m_gpu_bank->getShader(AssetPath::WithTypePrefix("lastpass")); - - shader->bind(); - rendered_fb->bindAttachment(0); - shader->loadInt("uTexture", 0); - m_quad_vao->bind(); - m_quad_vao->getIndexBuffer()->bind(); - m_api->renderVertexArray(m_quad_vao); + m_renderer->prepareFrame(*m_camera); + m_renderer->render(); + m_renderer->endFrame(); } void RenderSystem::onEntityAdded(Entity e) { + // Resync from scratch: onEntityAdded fires on every signature change while the entity + // matches, so clear this entity from all sub-lists first, then re-add based on its + // current components. This keeps the renderables/lights/skybox lists correct when an + // entity gains a second relevant component (e.g. a light added to a renderable). onEntityRemoved(e); if (m_registry->entityHasComponent(e)) { m_render_queue.emplace_back(e); @@ -153,6 +247,8 @@ void RenderSystem::onEntityRemoved(Entity e) { if (e == m_skybox) { m_skybox = NO_ASSET_ID; } + // Evict cached culling data so a recycled entity id can't inherit a stale AABB. + m_culling_cache.erase(e); } std::shared_ptr RenderSystem::getRenderer() const { diff --git a/ICE/System/src/SceneGraphSystem.cpp b/ICE/System/src/SceneGraphSystem.cpp index 618b5d09..3d82a3e4 100644 --- a/ICE/System/src/SceneGraphSystem.cpp +++ b/ICE/System/src/SceneGraphSystem.cpp @@ -1,29 +1,40 @@ #include "SceneGraphSystem.h" namespace ICE { -SceneGraphSystem::SceneGraphSystem(const std::shared_ptr &scene) : m_scene(scene) { - +SceneGraphSystem::SceneGraphSystem(const std::shared_ptr &scene) : m_scene(scene.get()) { } void SceneGraphSystem::onEntityAdded(Entity e) { } void SceneGraphSystem::onEntityRemoved(Entity e) { + // Evict the cached transform version so a recycled entity id isn't skipped because its + // new version happens to match the stale cached one. + m_transformVersions.erase(e); } void SceneGraphSystem::update(double delta) { - auto root = m_scene->getGraph()->getRoot(); - std::function &, const Eigen::Matrix4f &)> updateNode; - updateNode = [this, &updateNode](const std::shared_ptr &node, const Eigen::Matrix4f &parentMatrix) { - Eigen::Matrix4f newParentMatrix = parentMatrix; - if (node->entity != 0 && m_scene->getRegistry()->entityHasComponent(node->entity)) { - auto tc = m_scene->getRegistry()->getComponent(node->entity); + updateNode(m_scene->getGraph()->getRoot().get(), Eigen::Matrix4f::Identity(), false); +} + +void SceneGraphSystem::updateNode(SceneGraph::SceneNode *node, const Eigen::Matrix4f &parentMatrix, bool parent_changed) { + auto *registry = m_scene->getRegistry().get(); + Eigen::Matrix4f newParentMatrix = parentMatrix; + if (node->entity != NULL_ENTITY && registry->entityHasComponent(node->entity)) { + auto tc = registry->getComponent(node->entity); + + if (parent_changed) { tc->updateParentMatrix(parentMatrix); - newParentMatrix = tc->getWorldMatrix(); } - for (const auto &child : node->children) { - updateNode(child, newParentMatrix); + + auto it = m_transformVersions.find(node->entity); + if (it == m_transformVersions.end() || it->second != tc->getVersion()) { + parent_changed = true; + m_transformVersions[node->entity] = tc->getVersion(); } - }; - updateNode(root, Eigen::Matrix4f::Identity()); + newParentMatrix = tc->getWorldMatrix(); + } + for (const auto &child : node->children) { + updateNode(child.get(), newParentMatrix, parent_changed); + } } } // namespace ICE \ No newline at end of file diff --git a/ICE/System/src/ScriptSystem.cpp b/ICE/System/src/ScriptSystem.cpp new file mode 100644 index 00000000..141dc698 --- /dev/null +++ b/ICE/System/src/ScriptSystem.cpp @@ -0,0 +1,70 @@ +#include "ScriptSystem.h" + +#include // full type: the system injects context into and reads flags off instances + +namespace ICE { + +NativeScript* ScriptSystem::ensureInstance(Entity e) { + auto it = m_instances.find(e); + if (it != m_instances.end()) { + return it->second.get(); + } + auto* nsc = m_registry->tryGetComponent(e); + if (nsc == nullptr || !nsc->instantiate) { + return nullptr; // no script bound + } + auto instance = nsc->instantiate(); + // Inject the behaviour context before onCreate so scripts can already use + // self()/transform()/scene()/input()/time() there. + instance->m_entity = e; + instance->m_registry = m_registry; + instance->m_scene = m_scene; + instance->m_input = m_input; + instance->m_time = m_elapsed; + NativeScript* raw = instance.get(); + m_instances.emplace(e, std::move(instance)); + raw->onCreate(); + return raw; +} + +void ScriptSystem::onEntityAdded(Entity e) { + // Idempotent: onEntityAdded may fire again when unrelated components change, so only the + // first call (no existing instance) actually instantiates. + ensureInstance(e); +} + +void ScriptSystem::onEntityRemoved(Entity e) { + auto it = m_instances.find(e); + if (it == m_instances.end()) { + return; // not a scripted entity (or already torn down) -- idempotent no-op + } + it->second->onDestroy(); + m_instances.erase(it); +} + +void ScriptSystem::update(double delta) { + m_elapsed += delta; + // Snapshot the membership: a script's onUpdate may add or remove components, which mutates + // the `entities` set mid-iteration. + std::vector current(entities.begin(), entities.end()); + for (Entity e : current) { + if (NativeScript* script = ensureInstance(e)) { + script->m_time = m_elapsed; // refresh for instances created on a previous frame + script->onUpdate(delta); + if (script->m_destroyed) { + m_pending_destroy.push_back(e); + } + } + } + // Deferred self-destruction: removing an entity mid-onUpdate would delete the running script + // (and fire its onDestroy) under its own feet, so destroy() only set a flag. Do the real + // removal here, after the script pass -- removeEntity() drives onEntityRemoved() which fires + // onDestroy and erases the instance. + if (!m_pending_destroy.empty()) { + for (Entity e : m_pending_destroy) { + m_registry->removeEntity(e); + } + m_pending_destroy.clear(); + } +} +} // namespace ICE diff --git a/ICE/System/test/CMakeLists.txt b/ICE/System/test/CMakeLists.txt new file mode 100644 index 00000000..147c25a2 --- /dev/null +++ b/ICE/System/test/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.19) +project(system-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +add_executable(SystemTestSuite + ECSTest.cpp + PluginTest.cpp + ScriptSystemTest.cpp +) + +add_test(NAME SystemTestSuite + COMMAND SystemTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(SystemTestSuite + PRIVATE + gtest_main + system + components +) diff --git a/ICE/System/test/ECSTest.cpp b/ICE/System/test/ECSTest.cpp new file mode 100644 index 00000000..59457850 --- /dev/null +++ b/ICE/System/test/ECSTest.cpp @@ -0,0 +1,4 @@ +// +// gtest translation unit for the ECS test cases declared in ECSTest.h. +// +#include "ECSTest.h" diff --git a/ICE/System/test/ECSTest.h b/ICE/System/test/ECSTest.h index 5a1a5fe8..53c09348 100644 --- a/ICE/System/test/ECSTest.h +++ b/ICE/System/test/ECSTest.h @@ -5,6 +5,8 @@ #include #include +using namespace ICE; + TEST(ECSTest, FirstEntityIs1) { Registry reg = Registry(); @@ -74,4 +76,84 @@ TEST(ECSTest, ComponentMultipleAdded) ASSERT_TRUE(reg.entityHasComponent(e)); } +// A component type the ECS core has never heard of: not in any registration list, no header +// included by Registry. Exercises P2's lazy, implicit registration. +struct CustomTestComponent { + int value = 0; +}; + +// entityHasComponent must be safe for a type that has never been added anywhere -- it goes +// through the const getComponentType path (same one System::getSignatures uses) which must not +// require the component's storage to exist. +TEST(ECSTest, HasComponentFalseForUntouchedType) +{ + Registry reg = Registry(); + Entity e = reg.createEntity(); + ASSERT_FALSE(reg.entityHasComponent(e)); +} + +// Add / query / remove a brand-new component type with zero registration calls. +TEST(ECSTest, CustomComponentRegistersOnFirstUse) +{ + Registry reg = Registry(); + Entity e = reg.createEntity(); + reg.addComponent(e, CustomTestComponent{42}); + ASSERT_TRUE(reg.entityHasComponent(e)); + ASSERT_EQ(reg.getComponent(e)->value, 42); + reg.removeComponent(e); + ASSERT_FALSE(reg.entityHasComponent(e)); +} + +// Iterating a custom type mixed with a built-in one works without any registration step. +TEST(ECSTest, EachOverCustomComponent) +{ + Registry reg = Registry(); + Entity e1 = reg.createEntity(); + Entity e2 = reg.createEntity(); + reg.addComponent(e1, CustomTestComponent{7}); + reg.addComponent(e1, TransformComponent()); + reg.addComponent(e2, CustomTestComponent{9}); // no TransformComponent + + int matched = 0; + int sum = 0; + reg.each([&](Entity, CustomTestComponent& c, TransformComponent&) { + matched++; + sum += c.value; + }); + ASSERT_EQ(matched, 1); // only e1 has both + ASSERT_EQ(sum, 7); +} + +// A component pointer must survive add/remove of OTHER entities' components. Under the old +// vector-backed storage the growth below would reallocate (and the removals would swap-move the +// tail), leaving `pa` dangling; the stable pool keeps it valid. +TEST(ECSTest, ComponentPointerStableAcrossInsertAndErase) +{ + Registry reg = Registry(); + Entity a = reg.createEntity(); + reg.addComponent(a, CustomTestComponent{100}); + CustomTestComponent* pa = reg.getComponent(a); + + // Force many insertions (growth) of the same component type on other entities. + std::vector others; + for (int i = 0; i < 1000; i++) { + Entity e = reg.createEntity(); + reg.addComponent(e, CustomTestComponent{i}); + others.push_back(e); + } + ASSERT_EQ(pa, reg.getComponent(a)); // same address after growth + ASSERT_EQ(pa->value, 100); + + // Removing the other entities' components must not move or invalidate A's component. + for (Entity e : others) { + reg.removeComponent(e); + } + ASSERT_EQ(pa, reg.getComponent(a)); + ASSERT_EQ(pa->value, 100); + + // Writes through the cached pointer are still observed. + pa->value = 7; + ASSERT_EQ(reg.getComponent(a)->value, 7); +} + #endif //ICE_ECSTEST_H diff --git a/ICE/System/test/PluginTest.cpp b/ICE/System/test/PluginTest.cpp new file mode 100644 index 00000000..a00781ea --- /dev/null +++ b/ICE/System/test/PluginTest.cpp @@ -0,0 +1,53 @@ +#include + +#include +#include + +#include +#include +#include + +using namespace ICE; + +// A component and a system defined entirely out-of-tree (here, in the test): the engine core knows +// nothing about either type. They reach the ECS only through the plugin seam. +struct HealthComponent { + int hp = 100; +}; + +class HealthSystem : public System { + public: + void update(double) override {} + std::vector getSignatures(const ComponentManager& cm) const override { + Signature signature; + signature.set(cm.getComponentType()); + return {signature}; + } +}; + +class SamplePlugin : public IPlugin { + public: + const char* name() const override { return "SamplePlugin"; } + void registerWith(PluginContext& ctx) override { + // Register a gameplay system. (A plugin could also add asset loaders via ctx.asset_bank.) + ctx.registry->addSystem(std::make_shared()); + } +}; + +// Acceptance (P11): an out-of-tree plugin registers a system and a component with no change to core. +TEST(PluginTest, RegistersSystemAndComponentWithoutCoreChange) { + Registry registry; + PluginContext ctx{®istry, nullptr}; + + SamplePlugin plugin; + plugin.registerWith(ctx); + + // The plugin's system is now part of the registry. + ASSERT_NE(registry.getSystem(), nullptr); + + // The plugin's component works with zero registration ceremony (lazy registration, P2). + Entity e = registry.createEntity(); + registry.addComponent(e, HealthComponent{42}); + ASSERT_TRUE(registry.entityHasComponent(e)); + EXPECT_EQ(registry.getComponent(e)->hp, 42); +} diff --git a/ICE/System/test/ScriptSystemTest.cpp b/ICE/System/test/ScriptSystemTest.cpp new file mode 100644 index 00000000..cd7d5d52 --- /dev/null +++ b/ICE/System/test/ScriptSystemTest.cpp @@ -0,0 +1,151 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +using namespace ICE; + +namespace { +// Captures what a script saw through its T3 behaviour context, so a test can assert on it after the +// ScriptSystem has driven the (system-owned) instance. +struct Probe { + int creates = 0; + int updates = 0; + int destroys = 0; + double last_time = -1.0; + bool self_valid = false; + bool transform_seen = false; + Scene* scene = nullptr; + InputManager* input = nullptr; + bool alive_in_update = false; +}; + +// Reads its context every frame and moves the entity through transform() -- no registry plumbing. +// Constructor args flow through EntityHandle::script()/NativeScriptComponent::bind(). +class Probed : public NativeScript { + public: + explicit Probed(Probe* p) : m_p(p) {} + void onCreate() override { m_p->creates++; } + void onUpdate(double) override { + m_p->updates++; + m_p->last_time = time(); + m_p->self_valid = self().valid(); + m_p->scene = scene(); + m_p->input = input(); + if (auto* t = transform()) { + m_p->transform_seen = true; + t->position().x() += 1.0f; // observable side effect, reached via the context + } + } + void onDestroy() override { m_p->destroys++; } + + private: + Probe* m_p; +}; + +// Destroys itself from within onUpdate: proves destroy() defers teardown to the end of the frame. +class SelfDestroyer : public NativeScript { + public: + explicit SelfDestroyer(Probe* p) : m_p(p) {} + void onUpdate(double) override { + m_p->updates++; + m_p->alive_in_update = self().valid(); // still live mid-update + m_p->transform_seen = (transform() != nullptr); // components still present mid-update + destroy(); + } + void onDestroy() override { m_p->destroys++; } + + private: + Probe* m_p; +}; + +// A registry driven by a ScriptSystem. The scene/input pointers are only threaded through to +// scripts (never dereferenced by the system), so tests pass sentinels to prove they arrive. +std::shared_ptr makeRegistry(Scene* scene, InputManager* input) { + auto reg = std::make_shared(); + reg->addSystem(std::make_shared(reg, scene, input)); + return reg; +} + +Entity attachScript(const std::shared_ptr& reg, NativeScriptComponent nsc) { + Entity e = reg->createEntity(); + reg->addComponent(e, TransformComponent()); + reg->addComponent(e, std::move(nsc)); // signature now matches -> onEntityAdded -> onCreate + return e; +} +} // namespace + +// The context is injected and the lifecycle driven; time() accumulates and transform() edits land. +TEST(ScriptSystemTest, InjectsContextAndDrivesLifecycle) { + // Bogus but distinct pointers: the system only forwards them to scene()/input(), never + // dereferences them, so their targets need not exist. + Scene* scene_sentinel = reinterpret_cast(0x100); + InputManager* input_sentinel = reinterpret_cast(0x200); + auto reg = makeRegistry(scene_sentinel, input_sentinel); + + Probe probe; + NativeScriptComponent nsc; + nsc.bind(&probe); + Entity e = attachScript(reg, std::move(nsc)); + + EXPECT_EQ(probe.creates, 1); // onCreate fired when the script component was attached + EXPECT_EQ(probe.updates, 0); + + reg->updateSystems(0.5); + EXPECT_EQ(probe.updates, 1); + EXPECT_TRUE(probe.self_valid); + EXPECT_TRUE(probe.transform_seen); + EXPECT_EQ(probe.scene, scene_sentinel); + EXPECT_EQ(probe.input, input_sentinel); + EXPECT_DOUBLE_EQ(probe.last_time, 0.5); + EXPECT_FLOAT_EQ(reg->getComponent(e)->getPosition().x(), 1.0f); + + reg->updateSystems(0.25); + EXPECT_EQ(probe.updates, 2); + EXPECT_DOUBLE_EQ(probe.last_time, 0.75); // time keeps accumulating across frames + EXPECT_FLOAT_EQ(reg->getComponent(e)->getPosition().x(), 2.0f); +} + +// destroy() called from onUpdate: the script finishes with a live entity, teardown happens after +// the pass, and the entity stays gone (no resurrection, no double onDestroy). +TEST(ScriptSystemTest, DestroyDefersTeardownToFrameEnd) { + auto reg = makeRegistry(nullptr, nullptr); + + Probe probe; + NativeScriptComponent nsc; + nsc.bind(&probe); + Entity e = attachScript(reg, std::move(nsc)); + EXPECT_TRUE(reg->isAlive(e)); + + reg->updateSystems(0.016); + EXPECT_EQ(probe.updates, 1); + EXPECT_TRUE(probe.alive_in_update); // entity was live throughout its own onUpdate + EXPECT_TRUE(probe.transform_seen); // ...and its components were still present + EXPECT_EQ(probe.destroys, 1); // onDestroy fired exactly once, after the pass + EXPECT_FALSE(reg->isAlive(e)); // removed by frame end + + reg->updateSystems(0.016); + EXPECT_EQ(probe.updates, 1); // no further onUpdate (dropped from the system) + EXPECT_EQ(probe.destroys, 1); // no double teardown +} + +// scene()/input() are null when nothing is wired (a headless scene), not left dangling. +TEST(ScriptSystemTest, NullContextWhenUnwired) { + auto reg = makeRegistry(nullptr, nullptr); + + Probe probe; + NativeScriptComponent nsc; + nsc.bind(&probe); + attachScript(reg, std::move(nsc)); + + reg->updateSystems(0.016); + EXPECT_EQ(probe.scene, nullptr); + EXPECT_EQ(probe.input, nullptr); + EXPECT_TRUE(probe.self_valid); +} diff --git a/ICE/UI/CMakeLists.txt b/ICE/UI/CMakeLists.txt new file mode 100644 index 00000000..8cdd451e --- /dev/null +++ b/ICE/UI/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required (VERSION 3.8) +project(UI) + +add_library(${PROJECT_NAME} STATIC + src/UI.cpp + src/UIManager.cpp) +target_link_libraries(${PROJECT_NAME} + graphics + graphics_api + freetype + nlohmann_json) +target_include_directories(${PROJECT_NAME} PUBLIC + $ + $ + $ + PRIVATE + ${CMAKE_SOURCE_DIR}/include) +add_definitions(-DNOMINMAX) + +enable_testing() +add_subdirectory(test) diff --git a/ICE/UI/include/Font.h b/ICE/UI/include/Font.h new file mode 100644 index 00000000..58185f6e --- /dev/null +++ b/ICE/UI/include/Font.h @@ -0,0 +1,157 @@ +#pragma once + +#include +#include FT_FREETYPE_H +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "UIElement.h" + +namespace ICE { + +// One glyph's placement in the font atlas plus its metrics. +struct CharacterGlyph { + Eigen::Vector2f uv_offset; // top-left of the glyph's cell in the atlas (0..1) + Eigen::Vector2f uv_scale; // the cell's size in the atlas (0..1) + Eigen::Vector2f size; // glyph size in pixels + Eigen::Vector2f bearing; // offset from the baseline to the glyph's top-left, in pixels + unsigned int advance = 0; // pen advance to the next glyph, in 1/64 px +}; + +// A bitmap font rasterized by FreeType into a SINGLE atlas texture (not one GL texture per glyph, +// which cost ~128 texture objects and a bind per character). The UI shader is supplied per draw +// through the UIRenderContext, so the font owns no shader and no global state. +class Font { + public: + Font(const std::shared_ptr& factory, const std::string& font_path) { + FT_Library ft; + if (FT_Init_FreeType(&ft)) { + Logger::Log(Logger::ERROR, "Text", "Could not init FreeType Library"); + return; + } + FT_Face face; + if (FT_New_Face(ft, font_path.c_str(), 0, &face)) { + Logger::Log(Logger::ERROR, "Text", "Failed to load font '%s'", font_path.c_str()); + FT_Done_FreeType(ft); + return; + } + FT_Set_Pixel_Sizes(face, 0, kPixelSize); + + // Pass 1: rasterize every glyph and keep its coverage bitmap, measuring the atlas. + struct Loaded { + std::vector pixels; + int width = 0; + int rows = 0; + Eigen::Vector2f bearing{0, 0}; + unsigned int advance = 0; + }; + std::unordered_map loaded; + int atlas_width = 0; + int atlas_height = 0; + for (unsigned char c = 0; c < 128; c++) { + if (FT_Load_Char(face, c, FT_LOAD_DEFAULT) || FT_Render_Glyph(face->glyph, FT_RENDER_MODE_NORMAL)) { + Logger::Log(Logger::WARNING, "Text", "Failed to load glyph %d", static_cast(c)); + continue; + } + const auto& bmp = face->glyph->bitmap; + Loaded g; + g.width = static_cast(bmp.width); + g.rows = static_cast(bmp.rows); + g.bearing = {static_cast(face->glyph->bitmap_left), static_cast(face->glyph->bitmap_top)}; + g.advance = static_cast(face->glyph->advance.x); + g.pixels.assign(bmp.buffer, bmp.buffer + static_cast(g.width) * g.rows); + atlas_width += g.width + kPadding; + atlas_height = std::max(atlas_height, g.rows); + loaded.emplace(static_cast(c), std::move(g)); + } + atlas_width = std::max(atlas_width, 1); + atlas_height = std::max(atlas_height, 1); + + // Pass 2: blit each glyph into a single-channel buffer laid out left-to-right, top-aligned, + // and record its atlas cell as normalized UVs. + std::vector atlas(static_cast(atlas_width) * atlas_height, 0); + int cursor_x = 0; + for (auto& [c, g] : loaded) { + for (int row = 0; row < g.rows; ++row) { + for (int col = 0; col < g.width; ++col) { + atlas[static_cast(row) * atlas_width + cursor_x + col] = g.pixels[static_cast(row) * g.width + col]; + } + } + CharacterGlyph glyph; + glyph.uv_offset = {static_cast(cursor_x) / atlas_width, 0.0f}; + glyph.uv_scale = {static_cast(g.width) / atlas_width, static_cast(g.rows) / atlas_height}; + glyph.size = {static_cast(g.width), static_cast(g.rows)}; + glyph.bearing = g.bearing; + glyph.advance = g.advance; + m_glyphs.emplace(c, glyph); + cursor_x += g.width + kPadding; + } + + // MONO8 uploads with 1-byte row alignment, so the arbitrary atlas width is fine. The GL + // texture ctor copies immediately, so the local buffer can go out of scope. + Texture2D atlas_asset(atlas.data(), atlas_width, atlas_height, TextureFormat::MONO8); + m_atlas = factory->createTexture2D(atlas_asset); + + FT_Done_Face(face); + FT_Done_FreeType(ft); + } + + // Draw `text` with its top-left baseline near (x, y). `scale` maps glyph pixels to target + // pixels (1.0 renders at the atlas's native size). Uses the caller's UI shader in text mode. + void renderText(const std::string& text, float x, float y, float scale, const Eigen::Vector4f& color, const UIRenderContext& ctx) const { + if (!m_atlas || !ctx.shader || !ctx.quad || !ctx.api) { + return; + } + ctx.shader->bind(); + ctx.shader->loadInt("uMode", 2); // text: sample the atlas .r as coverage + ctx.shader->loadInt("uTexture", 0); + ctx.shader->loadFloat4("uColor", color); + ctx.shader->loadMat4("projection", ctx.projection); + m_atlas->bind(0); + ctx.quad->bind(); + ctx.quad->getIndexBuffer()->bind(); + + for (char c : text) { + auto it = m_glyphs.find(c); + if (it == m_glyphs.end()) { + continue; + } + const CharacterGlyph& g = it->second; + const float xpos = x + g.bearing.x() * scale; + const float ypos = y - g.bearing.y() * scale; + const float w = g.size.x() * scale; + const float h = g.size.y() * scale; + if (w > 0 && h > 0) { + ctx.shader->loadFloat2("uUVOffset", g.uv_offset); + ctx.shader->loadFloat2("uUVScale", g.uv_scale); + ctx.shader->loadMat4("model", transformationMatrix({xpos, ypos, 0}, {0, 0, 0}, {w, h, 0})); + ctx.api->renderVertexArray(ctx.quad); + } + x += (g.advance >> 6) * scale; // advance is in 1/64 px + } + } + + // Reference glyph height in pixels: a label of pixel-height H renders at scale H/glyphHeight(). + float glyphHeight() const { + auto it = m_glyphs.find('W'); + return it != m_glyphs.end() ? it->second.size.y() : static_cast(kPixelSize); + } + + private: + static constexpr int kPixelSize = 48; // rasterization height + static constexpr int kPadding = 1; // 1px gutter between atlas cells to avoid bleed + + std::unordered_map m_glyphs; + std::shared_ptr m_atlas; +}; +} // namespace ICE diff --git a/ICE/UI/include/UI.h b/ICE/UI/include/UI.h new file mode 100644 index 00000000..545f04b1 --- /dev/null +++ b/ICE/UI/include/UI.h @@ -0,0 +1,9 @@ +#pragma once + +// The old UI class (a window-bound immediate renderer with per-element static shaders) has been +// superseded by UIManager, which owns its resources, renders as a render-graph present-time pass, +// and hit-tests pointer input. Include the pieces you need directly. +#include "UIManager.h" +#include "UIRenderPass.h" +#include "UILabel.h" +#include "UIRect.h" diff --git a/ICE/UI/include/UIElement.h b/ICE/UI/include/UIElement.h new file mode 100644 index 00000000..0f32c2cb --- /dev/null +++ b/ICE/UI/include/UIElement.h @@ -0,0 +1,131 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace ICE { + +enum class EventType { Click, MouseEnter, MouseLeave }; + +struct Event { + EventType type; + int button = 0; +}; + +// An axis-aligned rectangle in pixels (origin top-left of the target). +struct UIBound { + float x = 0; + float y = 0; + float width = 0; + float height = 0; +}; + +// Everything a UI element needs to draw, owned by the UIManager and passed down each frame. +// Elements therefore hold no GPU resources of their own -- no per-class `static` shader/VAO, which +// was the transplanted module's global-state smell. +struct UIRenderContext { + RendererAPI* api = nullptr; + std::shared_ptr shader; // the UI shader (uMode/uColor/uTexture/uUV*) + std::shared_ptr quad; // the shared normalized [0,1] quad + Eigen::Matrix4f projection = Eigen::Matrix4f::Identity(); +}; + +// Base class for UI widgets. Position and size are RELATIVE to the parent's bounds ([0,1] fractions; +// a negative coordinate anchors from the far/right/bottom edge), resolved to absolute pixels by +// computeBounds() -- the single layout rule shared by rendering and hit-testing. +class UIElement { + public: + using EventCallback = std::function; + + UIElement(const std::string& name, const Eigen::Vector2f& position, const Eigen::Vector2f& size) + : m_name(name), + m_relative_pos(position), + m_relative_size(size) {} + virtual ~UIElement() = default; + + // Draw this element (and its children) into ctx, given the parent's absolute bounds. + virtual void render(const UIBound& parent_bounds, const UIRenderContext& ctx) = 0; + virtual void update() {} + + // Resolve this element's absolute pixel bounds within `parent`. The one layout rule: a positive + // relative coordinate is a fraction from the parent's near edge; a negative one anchors the + // element's far edge that fraction in from the parent's far edge. + UIBound computeBounds(const UIBound& parent) const { + const Eigen::Vector2f abs_size = m_relative_size.cwiseProduct(Eigen::Vector2f{parent.width, parent.height}); + Eigen::Vector2f abs_pos; + abs_pos.x() = m_relative_pos.x() >= 0 ? m_relative_pos.x() * parent.width + parent.x + : (1 + m_relative_pos.x()) * parent.width + parent.x - abs_size.x(); + abs_pos.y() = m_relative_pos.y() >= 0 ? m_relative_pos.y() * parent.height + parent.y + : (1 + m_relative_pos.y()) * parent.height + parent.y - abs_size.y(); + return {abs_pos.x(), abs_pos.y(), abs_size.x(), abs_size.y()}; + } + + static bool contains(const UIBound& b, float px, float py) { + return px >= b.x && px <= b.x + b.width && py >= b.y && py <= b.y + b.height; + } + + void addChild(std::unique_ptr&& child) { + child->m_parent = this; + m_children.push_back(std::move(child)); + } + + const std::string& getName() const { return m_name; } + bool isVisible() const { return m_visible; } + const std::vector>& getChildren() const { return m_children; } + + void setPosition(const Eigen::Vector2f& position) { m_relative_pos = position; } + void setSize(const Eigen::Vector2f& size) { m_relative_size = size; } + void setVisible(bool visible) { m_visible = visible; } + + // Interaction. The UIManager hit-tests each frame and calls dispatch() with Click/MouseEnter/ + // MouseLeave; register a handler with onEvent(). hovered() is the manager's tracked state. + void onEvent(EventCallback cb) { m_on_event = std::move(cb); } + void dispatch(const Event& e) const { + if (m_on_event) { + m_on_event(e); + } + } + bool hovered() const { return m_hovered; } + void setHovered(bool h) { m_hovered = h; } + + protected: + std::string m_name; + Eigen::Vector2f m_relative_pos; + Eigen::Vector2f m_relative_size; + bool m_visible = true; + bool m_hovered = false; + EventCallback m_on_event; + UIElement* m_parent = nullptr; + std::vector> m_children; +}; + +// Hit-test `element` (and its children) against the pointer within `parent`, updating hover state +// and dispatching Click/MouseEnter/MouseLeave. `clicked` is the primary-button press edge for the +// frame. Pure layout/geometry -- no GPU resources -- so it is exercised by the UI tests directly. +inline void dispatchPointer(UIElement* element, const UIBound& parent, float mouse_x, float mouse_y, bool clicked) { + const UIBound bounds = element->computeBounds(parent); + const bool inside = element->isVisible() && UIElement::contains(bounds, mouse_x, mouse_y); + + if (inside && !element->hovered()) { + element->setHovered(true); + element->dispatch({EventType::MouseEnter, 0}); + } else if (!inside && element->hovered()) { + element->setHovered(false); + element->dispatch({EventType::MouseLeave, 0}); + } + if (inside && clicked) { + element->dispatch({EventType::Click, 0}); + } + + for (const auto& child : element->getChildren()) { + dispatchPointer(child.get(), bounds, mouse_x, mouse_y, clicked); + } +} +} // namespace ICE diff --git a/ICE/UI/include/UILabel.h b/ICE/UI/include/UILabel.h new file mode 100644 index 00000000..eb354d43 --- /dev/null +++ b/ICE/UI/include/UILabel.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +#include "Font.h" +#include "UIElement.h" + +namespace ICE { + +// A single line of text, sized to its bounds' height and rendered through the shared Font atlas. +class UILabel : public UIElement { + public: + UILabel(const std::string& name, const Eigen::Vector2f& position, const Eigen::Vector2f& size, const std::string& text, + const Eigen::Vector4f& color, const std::shared_ptr& font) + : UIElement(name, position, size), + m_text(text), + m_color(color), + m_font(font) {} + + void render(const UIBound& parent_bounds, const UIRenderContext& ctx) override { + const UIBound b = computeBounds(parent_bounds); + if (m_font) { + // Scale glyphs so the text stands `b.height` pixels tall; baseline near the bottom edge. + const float scale = b.height / m_font->glyphHeight(); + m_font->renderText(m_text, b.x, b.y + b.height, scale, m_color, ctx); + } + for (const auto& child : m_children) { + child->render(b, ctx); + } + } + + void setText(const std::string& text) { m_text = text; } + void setColor(const Eigen::Vector4f& color) { m_color = color; } + + private: + std::string m_text; + Eigen::Vector4f m_color; + std::shared_ptr m_font; +}; +} // namespace ICE diff --git a/ICE/UI/include/UIManager.h b/ICE/UI/include/UIManager.h new file mode 100644 index 00000000..0c813b75 --- /dev/null +++ b/ICE/UI/include/UIManager.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "Font.h" +#include "UIElement.h" + +namespace ICE { + +// Owns the UI: the root elements, the shared shader (loaded as an asset by the engine, not inline +// GLSL), the shared quad, and the font atlas. It lays elements out, draws them, and hit-tests them +// against pointer input. This replaces the transplanted module's global statics and stub UI class. +// +// Rendering runs at present time as a render-graph pass (see UIRenderPass) so the UI composites +// over the finished scene; input is fed in as primitives (see processInput) so this module needs no +// input-service dependency and the hit-testing is unit-testable without a GL context. +class UIManager { + public: + // ui_shader is the "ui" shader asset resolved by the engine; font_path points at a .ttf. + UIManager(const std::shared_ptr& factory, const std::shared_ptr& ui_shader, + const std::string& font_path); + + // Adopt a top-level element and return a borrowed pointer for further setup. + UIElement* add(std::unique_ptr element); + + // The shared font, for constructing UILabels. + const std::shared_ptr& font() const { return m_font; } + + // Draw all visible elements into the currently bound target, sized to (width, height). + void render(RendererAPI* api, int width, int height); + + // Hit-test the pointer against the tree and dispatch Click/MouseEnter/MouseLeave. `clicked` is + // the primary-button press EDGE for this frame (true only on the frame it goes down). Taking + // primitives rather than the InputManager keeps the UI backend- and input-service-agnostic and + // makes this directly testable. + void processInput(float mouse_x, float mouse_y, bool clicked, int width, int height); + + const std::vector>& elements() const { return m_root; } + + private: + std::shared_ptr m_factory; + std::shared_ptr m_shader; + std::shared_ptr m_quad; + std::shared_ptr m_font; + std::vector> m_root; +}; +} // namespace ICE diff --git a/ICE/UI/include/UIRect.h b/ICE/UI/include/UIRect.h new file mode 100644 index 00000000..50da5519 --- /dev/null +++ b/ICE/UI/include/UIRect.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include +#include +#include + +#include "ICEMath.h" +#include "UIElement.h" + +namespace ICE { + +// A solid or textured rectangle. Owns no GPU resources: it draws with the shader and quad handed in +// via the UIRenderContext (see UIElement.h), which is what removed the old per-class static shader. +class UIRect : public UIElement { + public: + UIRect(const std::string& name, const Eigen::Vector2f& position, const Eigen::Vector2f& size, const Eigen::Vector4f& color) + : UIElement(name, position, size), + m_color(color) {} + + void render(const UIBound& parent_bounds, const UIRenderContext& ctx) override { + const UIBound b = computeBounds(parent_bounds); + const Eigen::Matrix4f model = transformationMatrix({b.x, b.y, 0}, {0, 0, 0}, {b.width, b.height, 0}); + + ctx.shader->bind(); + ctx.shader->loadInt("uMode", m_texture ? 1 : 0); // 1 = textured, 0 = solid colour + ctx.shader->loadFloat4("uColor", m_color); + ctx.shader->loadFloat2("uUVOffset", {0.0f, 0.0f}); + ctx.shader->loadFloat2("uUVScale", {1.0f, 1.0f}); + ctx.shader->loadMat4("model", model); + ctx.shader->loadMat4("projection", ctx.projection); + if (m_texture) { + m_texture->bind(0); + ctx.shader->loadInt("uTexture", 0); + } + + ctx.quad->bind(); + ctx.quad->getIndexBuffer()->bind(); + ctx.api->renderVertexArray(ctx.quad); + + for (const auto& child : m_children) { + child->render(b, ctx); + } + } + + void setTexture(const std::shared_ptr& texture) { m_texture = texture; } + void setColor(const Eigen::Vector4f& color) { m_color = color; } + + private: + Eigen::Vector4f m_color; + std::shared_ptr m_texture; +}; +} // namespace ICE diff --git a/ICE/UI/include/UIRenderPass.h b/ICE/UI/include/UIRenderPass.h new file mode 100644 index 00000000..cf86a8f3 --- /dev/null +++ b/ICE/UI/include/UIRenderPass.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "UIManager.h" + +namespace ICE { + +// Draws the UI over the finished scene as a render-graph pass (T5). It reads and writes the scene +// colour: reading orders it after the geometry pass, writing makes it contribute to the output (so +// the graph does not cull it) and marks the scene colour as this pass's target -- which the graph +// binds before execute(), so the UI composites straight onto the rendered frame. +// +// The manager is owned elsewhere (the engine) and must outlive the renderer that holds this pass. +class UIRenderPass : public IRenderPass { + public: + explicit UIRenderPass(UIManager* ui) : m_ui(ui) {} + + const char* name() const override { return "ui"; } + + void setup(RenderGraphBuilder& builder) override { + builder.read(builder.sceneColor()); // run after geometry produced the scene + builder.write(builder.sceneColor()); // draw over it (and stay uncalled) + } + + void execute(PassContext& ctx) override { + const auto& target = ctx.target(); // scene colour, already bound by the graph + if (!m_ui || !target) { + return; + } + m_ui->render(ctx.api(), static_cast(target->getFormat().width), static_cast(target->getFormat().height)); + } + + private: + UIManager* m_ui; +}; +} // namespace ICE diff --git a/ICE/UI/src/UI.cpp b/ICE/UI/src/UI.cpp new file mode 100644 index 00000000..8aa91c5f --- /dev/null +++ b/ICE/UI/src/UI.cpp @@ -0,0 +1,3 @@ +// UIManager.cpp carries the module's out-of-line definitions; this translation unit remains so the +// umbrella header is compiled at least once and the target keeps a stable primary source. +#include "UI.h" diff --git a/ICE/UI/src/UIManager.cpp b/ICE/UI/src/UIManager.cpp new file mode 100644 index 00000000..ad5a8ec3 --- /dev/null +++ b/ICE/UI/src/UIManager.cpp @@ -0,0 +1,67 @@ +#include "UIManager.h" + +#include + +namespace ICE { +namespace { + +// Orthographic projection mapping pixel coordinates with a TOP-LEFT origin (x right, y down) to +// clip space: x in [0,w] -> [-1,1], y in [0,h] -> [1,-1]. Matches the top-left UIBound convention. +Eigen::Matrix4f orthoTopLeft(float w, float h) { + Eigen::Matrix4f m = Eigen::Matrix4f::Identity(); + m(0, 0) = 2.0f / w; + m(0, 3) = -1.0f; + m(1, 1) = -2.0f / h; + m(1, 3) = 1.0f; + m(2, 2) = -1.0f; // near -1, far 1 + return m; +} +} // namespace + +UIManager::UIManager(const std::shared_ptr& factory, const std::shared_ptr& ui_shader, + const std::string& font_path) + : m_factory(factory), + m_shader(ui_shader), + m_quad(GraphicsUtil::getNormalizedQuad(factory)), + m_font(std::make_shared(factory, font_path)) {} + +UIElement* UIManager::add(std::unique_ptr element) { + UIElement* raw = element.get(); + m_root.push_back(std::move(element)); + return raw; +} + +void UIManager::render(RendererAPI* api, int width, int height) { + if (!api || width <= 0 || height <= 0 || !m_shader || !m_quad) { + return; + } + UIRenderContext ctx; + ctx.api = api; + ctx.shader = m_shader; + ctx.quad = m_quad; + ctx.projection = orthoTopLeft(static_cast(width), static_cast(height)); + + // UI draws as a flat overlay: no depth test, alpha blending on for text coverage and + // translucent panels, and NO backface culling. Culling matters because the orthographic + // projection flips Y (top-left origin), which reverses the quad's winding to clockwise -- a + // back face. The geometry pass leaves GL_CULL_FACE enabled, so without this every UI quad is + // silently culled and nothing appears. + api->setDepthTest(false); + api->setBlend(true); + api->setBackfaceCulling(false); + + const UIBound root{0.0f, 0.0f, static_cast(width), static_cast(height)}; + for (const auto& element : m_root) { + if (element->isVisible()) { + element->render(root, ctx); + } + } +} + +void UIManager::processInput(float mouse_x, float mouse_y, bool clicked, int width, int height) { + const UIBound root{0.0f, 0.0f, static_cast(width), static_cast(height)}; + for (const auto& element : m_root) { + dispatchPointer(element.get(), root, mouse_x, mouse_y, clicked); + } +} +} // namespace ICE diff --git a/ICE/UI/test/CMakeLists.txt b/ICE/UI/test/CMakeLists.txt new file mode 100644 index 00000000..4e3cd4ec --- /dev/null +++ b/ICE/UI/test/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.19) +project(ui-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +# Exercises the UI layout + pointer hit-testing/event dispatch (dispatchPointer), which is pure +# geometry -- no GL context needed. Links UI for the element headers. +add_executable(UITestSuite + UIInteractionTest.cpp +) + +add_test(NAME UITestSuite + COMMAND UITestSuite + WORKING_DIRECTORY $) + +target_link_libraries(UITestSuite + PRIVATE + gtest_main + UI +) diff --git a/ICE/UI/test/UIInteractionTest.cpp b/ICE/UI/test/UIInteractionTest.cpp new file mode 100644 index 00000000..9ef3791f --- /dev/null +++ b/ICE/UI/test/UIInteractionTest.cpp @@ -0,0 +1,129 @@ +#include + +#include +#include + +#include "UIElement.h" +#include "UIRect.h" + +using namespace ICE; + +namespace { +// UIRect owns no GPU resources (it draws through a UIRenderContext, never touched here), so the +// layout + hit-testing logic is exercised without a GL context. +std::unique_ptr rect(const std::string& name, Eigen::Vector2f pos, Eigen::Vector2f size) { + return std::make_unique(name, pos, size, Eigen::Vector4f{1, 1, 1, 1}); +} + +// Counts the events an element receives. +struct EventCounts { + int clicks = 0; + int enters = 0; + int leaves = 0; + void attach(UIElement* e) { + e->onEvent([this](const Event& ev) { + switch (ev.type) { + case EventType::Click: clicks++; break; + case EventType::MouseEnter: enters++; break; + case EventType::MouseLeave: leaves++; break; + } + }); + } +}; + +constexpr UIBound kViewport{0, 0, 800, 600}; +} // namespace + +// The layout rule: a positive relative coordinate is a fraction from the near edge, size scales +// with the parent. +TEST(UIInteractionTest, ComputeBoundsResolvesRelativeToParent) { + UIRect r("r", {0.25f, 0.5f}, {0.5f, 0.25f}, {1, 1, 1, 1}); + const UIBound b = r.computeBounds(kViewport); + EXPECT_FLOAT_EQ(b.x, 200.0f); // 0.25 * 800 + EXPECT_FLOAT_EQ(b.y, 300.0f); // 0.50 * 600 + EXPECT_FLOAT_EQ(b.width, 400.0f); // 0.50 * 800 + EXPECT_FLOAT_EQ(b.height, 150.0f); // 0.25 * 600 +} + +// A negative relative coordinate anchors the element's far edge in from the parent's far edge. +TEST(UIInteractionTest, NegativeCoordinateAnchorsToFarEdge) { + UIRect r("r", {-0.0f, -0.0f}, {0.25f, 0.25f}, {1, 1, 1, 1}); + // -0.0 is >= 0, so use a clearly-negative anchor instead: + UIRect far_("far", {-0.1f, -0.2f}, {0.2f, 0.1f}, {1, 1, 1, 1}); + const UIBound b = far_.computeBounds(kViewport); + // width 160 (0.2*800), anchored so its right edge is 0.1*800=80 in from the right: x = 800-80-160 + EXPECT_FLOAT_EQ(b.width, 160.0f); + EXPECT_FLOAT_EQ(b.x, 800.0f - 80.0f - 160.0f); + EXPECT_FLOAT_EQ(b.height, 60.0f); + EXPECT_FLOAT_EQ(b.y, 600.0f - 120.0f - 60.0f); +} + +// The acceptance: a click inside an element dispatches a Click event; outside does not. +TEST(UIInteractionTest, ClickInsideDispatchesEvent) { + auto button = rect("button", {0.25f, 0.25f}, {0.5f, 0.5f}); // pixels [200,150]..[600,450] + EventCounts counts; + counts.attach(button.get()); + + dispatchPointer(button.get(), kViewport, 400.0f, 300.0f, /*clicked=*/true); // centre + EXPECT_EQ(counts.clicks, 1); + + dispatchPointer(button.get(), kViewport, 10.0f, 10.0f, /*clicked=*/true); // outside + EXPECT_EQ(counts.clicks, 1); // unchanged + + dispatchPointer(button.get(), kViewport, 400.0f, 300.0f, /*clicked=*/false); // hover, no click + EXPECT_EQ(counts.clicks, 1); +} + +// MouseEnter fires once on entry, MouseLeave once on exit -- edges, not per-frame. +TEST(UIInteractionTest, HoverEdgesFireEnterAndLeaveOnce) { + auto el = rect("el", {0.0f, 0.0f}, {0.5f, 0.5f}); // pixels [0,0]..[400,300] + EventCounts counts; + counts.attach(el.get()); + + dispatchPointer(el.get(), kViewport, 100.0f, 100.0f, false); // enter + dispatchPointer(el.get(), kViewport, 150.0f, 150.0f, false); // still inside + EXPECT_EQ(counts.enters, 1); + EXPECT_EQ(counts.leaves, 0); + EXPECT_TRUE(el->hovered()); + + dispatchPointer(el.get(), kViewport, 500.0f, 500.0f, false); // leave + dispatchPointer(el.get(), kViewport, 600.0f, 500.0f, false); // still outside + EXPECT_EQ(counts.enters, 1); + EXPECT_EQ(counts.leaves, 1); + EXPECT_FALSE(el->hovered()); +} + +// Children are laid out and hit-tested within the parent's bounds; a click hits the child it lands +// on, and both parent and child receive it when nested. +TEST(UIInteractionTest, NestedChildIsHitWithinParentBounds) { + auto panel = rect("panel", {0.5f, 0.5f}, {0.5f, 0.5f}); // pixels [400,300]..[800,600] + // child at the top-left quarter of the panel: panel-relative [0,0]..[0.5,0.5] -> [400,300]..[600,450] + auto child = rect("child", {0.0f, 0.0f}, {0.5f, 0.5f}); + EventCounts panel_counts; + EventCounts child_counts; + panel_counts.attach(panel.get()); + child_counts.attach(child.get()); + panel->addChild(std::move(child)); + + // Click at (450, 350): inside the child (and the panel). + dispatchPointer(panel.get(), kViewport, 450.0f, 350.0f, true); + EXPECT_EQ(child_counts.clicks, 1); + EXPECT_EQ(panel_counts.clicks, 1); + + // Click at (700, 550): inside the panel but outside the child. + dispatchPointer(panel.get(), kViewport, 700.0f, 550.0f, true); + EXPECT_EQ(panel_counts.clicks, 2); + EXPECT_EQ(child_counts.clicks, 1); // unchanged +} + +// A hidden element neither hovers nor clicks. +TEST(UIInteractionTest, InvisibleElementIsNotInteractive) { + auto el = rect("el", {0.0f, 0.0f}, {1.0f, 1.0f}); + el->setVisible(false); + EventCounts counts; + counts.attach(el.get()); + + dispatchPointer(el.get(), kViewport, 400.0f, 300.0f, true); + EXPECT_EQ(counts.clicks, 0); + EXPECT_EQ(counts.enters, 0); +} diff --git a/ICE/Util/CMakeLists.txt b/ICE/Util/CMakeLists.txt index 01f82621..17bf0012 100644 --- a/ICE/Util/CMakeLists.txt +++ b/ICE/Util/CMakeLists.txt @@ -6,11 +6,11 @@ message(STATUS "Building ${PROJECT_NAME} module") add_library(${PROJECT_NAME} STATIC) target_sources(${PROJECT_NAME} PRIVATE - src/BufferUtils.cpp src/ICEException.cpp src/Logger.cpp src/WindowFactory.cpp - src/GLFWWindow.cpp) + src/GLFWWindow.cpp + src/InputManager.cpp) target_link_libraries(${PROJECT_NAME} PUBLIC @@ -23,4 +23,4 @@ target_include_directories(${PROJECT_NAME} PUBLIC $) enable_testing() -#add_subdirectory(test) +add_subdirectory(test) diff --git a/ICE/Util/include/BufferUtils.h b/ICE/Util/include/BufferUtils.h deleted file mode 100644 index c02bb62a..00000000 --- a/ICE/Util/include/BufferUtils.h +++ /dev/null @@ -1,89 +0,0 @@ -// -// Created by Thomas Ibanez on 22.11.20. -// - -#ifndef ICE_BUFFERUTILS_H -#define ICE_BUFFERUTILS_H - -#include -#include -#include - -namespace ICE { - class BufferUtils { - public: - //TODO: Might be able to make it all in one with VectorXd and the data() function - static float* CreateFloatBuffer(const std::vector& vectors) { - auto buffer = static_cast(malloc(sizeof(float) * 3 * vectors.size())); - uint32_t i = 0; - for(const auto &v : vectors) { - buffer[i] = (float) v.x(); - buffer[i+1] = (float) v.y(); - buffer[i+2] = (float) v.z(); - i += 3; - } - return buffer; - } - - static float* CreateFloatBuffer(const std::vector& vectors) { - auto buffer = static_cast(malloc(sizeof(float) * 4 * vectors.size())); - uint32_t i = 0; - for (const auto& v : vectors) { - buffer[i] = (float) v.x(); - buffer[i + 1] = (float) v.y(); - buffer[i + 2] = (float) v.z(); - buffer[i + 3] = (float) v.w(); - i += 4; - } - return buffer; - } - - static float* CreateFloatBuffer(const std::vector& vectors) { - auto* buffer = static_cast(malloc(sizeof(float) * 2 * vectors.size())); - uint32_t i = 0; - for(const auto &v : vectors) { - buffer[i] = (float) v.x(); - buffer[i+1] = (float) v.y(); - i += 2; - } - return buffer; - } - - static int* CreateIntBuffer(const std::vector& vectors) { - auto* buffer = static_cast(malloc(sizeof(int) * 3 * vectors.size())); - uint32_t i = 0; - for(const auto &v : vectors) { - buffer[i] = v.x(); - buffer[i+1] = v.y(); - buffer[i+2] = v.z(); - i += 3; - } - return buffer; - } - - static int* CreateIntBuffer(const std::vector& vectors) { - auto* buffer = static_cast(malloc(sizeof(int) * 4 * vectors.size())); - uint32_t i = 0; - for (const auto& v : vectors) { - buffer[i] = v.x(); - buffer[i + 1] = v.y(); - buffer[i + 2] = v.z(); - buffer[i + 3] = v.w(); - i += 4; - } - return buffer; - } - - static std::vector CreateCharBuffer(const std::vector& strings) { - std::vector pointerVec(strings.size()); - for(unsigned i = 0; i < strings.size(); ++i) - { - pointerVec[i] = strings[i].data(); - } //you can use transform instead of this loop - return pointerVec; - } - }; -} - - -#endif //ICE_BUFFERUTILS_H diff --git a/ICE/Util/include/EngineHelper.h b/ICE/Util/include/EngineHelper.h new file mode 100644 index 00000000..675847ee --- /dev/null +++ b/ICE/Util/include/EngineHelper.h @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ICE { + +/** + * @brief Helper class to simplify common ICE engine operations + * + * Provides static utility functions to reduce the verbosity of + * accessing commonly used engine components. + * + * Example: + * auto reg = EngineHelper::getRegistry(engine); + * // vs + * auto reg = engine.getProject()->getCurrentScene()->getRegistry(); + */ +class EngineHelper { +public: + // Registry access + static std::shared_ptr getRegistry(ICEEngine& engine) { + auto project = engine.getProject(); + if (!project) return nullptr; + + auto scene = project->getCurrentScene(); + if (!scene) return nullptr; + + return scene->getRegistry(); + } + + // AssetBank access + static std::shared_ptr getAssetBank(ICEEngine& engine) { + auto project = engine.getProject(); + if (!project) return nullptr; + return project->getAssetBank(); + } + + // Scene access + static std::shared_ptr getCurrentScene(ICEEngine& engine) { + auto project = engine.getProject(); + if (!project) return nullptr; + return project->getCurrentScene(); + } + + // Camera access + static std::shared_ptr getCamera(ICEEngine& engine) { + auto reg = getRegistry(engine); + if (!reg) return nullptr; + + auto renderSystem = reg->getSystem(); + if (!renderSystem) return nullptr; + + return renderSystem->getCamera(); + } + + // Entity creation helpers + static Entity createEntity(ICEEngine& engine) { + auto scene = getCurrentScene(engine); + if (!scene) return 0; + return scene->createEntity(); + } + + static Entity spawnModel(ICEEngine& engine, const std::string& modelName) { + auto scene = getCurrentScene(engine); + auto bank = getAssetBank(engine); + if (!scene || !bank) return 0; + + auto modelId = bank->getUID(AssetPath::WithTypePrefix(modelName)); + if (modelId == NO_ASSET_ID) return 0; + + return scene->spawnTree(modelId, bank); + } + + // Asset loading helpers + static AssetUID getModelUID(ICEEngine& engine, const std::string& name) { + auto bank = getAssetBank(engine); + if (!bank) return NO_ASSET_ID; + return bank->getUID(AssetPath::WithTypePrefix(name)); + } + + static AssetUID getTextureUID(ICEEngine& engine, const std::string& name) { + auto bank = getAssetBank(engine); + if (!bank) return NO_ASSET_ID; + return bank->getUID(AssetPath::WithTypePrefix(name)); + } + + static AssetUID getMaterialUID(ICEEngine& engine, const std::string& name) { + auto bank = getAssetBank(engine); + if (!bank) return NO_ASSET_ID; + return bank->getUID(AssetPath::WithTypePrefix(name)); + } + + // System access + template + static std::shared_ptr getSystem(ICEEngine& engine) { + auto reg = getRegistry(engine); + if (!reg) return nullptr; + return reg->getSystem(); + } + + // Validation helpers + static bool isEngineReady(ICEEngine& engine) { + return engine.getProject() != nullptr + && engine.getProject()->getCurrentScene() != nullptr; + } + + static bool hasAsset(ICEEngine& engine, const std::string& name) { + auto bank = getAssetBank(engine); + if (!bank) return false; + // Check if any asset with this name exists + return getModelUID(engine, name) != NO_ASSET_ID || + getTextureUID(engine, name) != NO_ASSET_ID || + getMaterialUID(engine, name) != NO_ASSET_ID; + } +}; + +} // namespace ICE diff --git a/ICE/Util/include/EntityHelper.h b/ICE/Util/include/EntityHelper.h new file mode 100644 index 00000000..99dd3d06 --- /dev/null +++ b/ICE/Util/include/EntityHelper.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace ICE { + +/** + * @brief Helper class to simplify entity component access + * + * Reduces verbosity when working with entities by providing + * direct access to components without repeated registry calls. + * + * Example: + * EntityHelper entity(e, registry); + * entity.transform()->setPosition({0, 1, 0}); + * // vs + * registry->getComponent(e)->setPosition({0, 1, 0}); + */ +class EntityHelper { +public: + EntityHelper(Entity id, std::shared_ptr registry) + : m_id(id), m_registry(registry) {} + + // Component access shortcuts + TransformComponent* transform() { + return getRegistry()->getComponent(m_id); + } + + RenderComponent* render() { + return getRegistry()->getComponent(m_id); + } + + LightComponent* light() { + return getRegistry()->getComponent(m_id); + } + + AnimationComponent* animation() { + return getRegistry()->getComponent(m_id); + } + + // Generic component access + template + T* getComponent() { + return getRegistry()->getComponent(m_id); + } + + template + void addComponent(T component) { + getRegistry()->addComponent(m_id, component); + } + + template + void removeComponent() { + getRegistry()->removeComponent(m_id); + } + + template + bool hasComponent() { + return getRegistry()->entityHasComponent(m_id); + } + + // Entity ID access + Entity id() const { return m_id; } + + // Validity check + bool isValid() const { return m_id != 0 && getRegistry() != nullptr; } + +private: + Registry* getRegistry() const { + if (auto reg = m_registry.lock()) { + return reg.get(); + } + return nullptr; + } + + Entity m_id; + std::weak_ptr m_registry; +}; + +} // namespace ICE diff --git a/ICE/Util/include/GLFWWindow.h b/ICE/Util/include/GLFWWindow.h index ae5fdd04..37a7cce3 100644 --- a/ICE/Util/include/GLFWWindow.h +++ b/ICE/Util/include/GLFWWindow.h @@ -10,6 +10,11 @@ namespace ICE { class GLFWWindow : public Window { public: GLFWWindow(int width, int height, const std::string& title); + ~GLFWWindow() override; + + // Owns a GLFWwindow handle; non-copyable. + GLFWWindow(const GLFWWindow&) = delete; + GLFWWindow& operator=(const GLFWWindow&) = delete; void* getHandle() const override; bool shouldClose() override; diff --git a/ICE/Util/include/ICEHelpers.h b/ICE/Util/include/ICEHelpers.h new file mode 100644 index 00000000..651ef0e7 --- /dev/null +++ b/ICE/Util/include/ICEHelpers.h @@ -0,0 +1,23 @@ +#pragma once + +/** + * @file ICEHelpers.h + * @brief Convenience header that includes all ICE helper utilities + * + * This header provides simplified APIs for common ICE operations, + * reducing verbosity and improving developer experience. + * + * Usage: + * #include + * + * // Simplified entity access + * EntityHelper player(playerId, registry); + * player.transform()->setPosition({0, 1, 0}); + * + * // Simplified engine access + * auto registry = EngineHelper::getRegistry(engine); + * auto assetBank = EngineHelper::getAssetBank(engine); + */ + +#include "EntityHelper.h" +#include "EngineHelper.h" diff --git a/ICE/Util/include/InputManager.h b/ICE/Util/include/InputManager.h new file mode 100644 index 00000000..dd261c84 --- /dev/null +++ b/ICE/Util/include/InputManager.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include +#include + +namespace ICE { +enum class KeyState { UP, DOWN }; +enum class KeyAction { NONE, PRESS, RELEASE }; + +class InputManager { + public: + InputManager(const std::shared_ptr &window); + void update(float dt); + KeyAction getKeyAction(ICE::Key key); + KeyState getKeyState(ICE::Key key); + bool isKeyDown(ICE::Key key); + + KeyAction getMouseAction(ICE::MouseButton button); + KeyState getMouseState(ICE::MouseButton button); + + float getMouseX(); + float getMouseY(); + + // Cursor movement since the previous update() (frame-to-frame). Zero until the cursor is first + // seen, so there is no spurious jump on the first frame / after (re)gaining focus. + Eigen::Vector2f getMouseDelta(); + + private: + std::shared_ptr m_window; + + std::unordered_map m_key_states; + std::unordered_map m_key_actions; + std::unordered_map m_mouse_states; + std::unordered_map m_mouse_actions; + + float m_x = 0.0f; + float m_y = 0.0f; + float m_prev_x = 0.0f; + float m_prev_y = 0.0f; + bool m_has_mouse = false; // false until the first cursor position arrives +}; +} // namespace ICE \ No newline at end of file diff --git a/ICE/Util/include/KeyboardHandler.h b/ICE/Util/include/KeyboardHandler.h index eef88084..8f94d4a5 100644 --- a/ICE/Util/include/KeyboardHandler.h +++ b/ICE/Util/include/KeyboardHandler.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace ICE { enum class Key : int { diff --git a/ICE/Util/include/MouseHandler.h b/ICE/Util/include/MouseHandler.h index 26b8bfef..2c310394 100644 --- a/ICE/Util/include/MouseHandler.h +++ b/ICE/Util/include/MouseHandler.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace ICE { diff --git a/ICE/Util/include/Profiler.h b/ICE/Util/include/Profiler.h new file mode 100644 index 00000000..72b60256 --- /dev/null +++ b/ICE/Util/include/Profiler.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +namespace ICE { + +// Minimal per-frame CPU profiler. Scoped timers accumulate wall-clock time per named +// section; beginFrame() snapshots the accumulated results and starts a fresh frame, so +// lastFrame() always exposes a complete, stable set of timings (in milliseconds) for a UI +// or logging to read. Single-threaded (the engine's main loop); not synchronized. +class Profiler { + public: + static Profiler& get() { + static Profiler instance; + return instance; + } + + void beginFrame() { + m_last = std::move(m_current); + m_current.clear(); + } + + void addSample(const std::string& name, double ms) { m_current[name] += ms; } + + const std::unordered_map& lastFrame() const { return m_last; } + + private: + std::unordered_map m_current; + std::unordered_map m_last; +}; + +// RAII: records the elapsed time of its scope into the Profiler under `name`. +class ScopedCPUTimer { + public: + explicit ScopedCPUTimer(std::string name) : m_name(std::move(name)), m_start(std::chrono::steady_clock::now()) {} + ~ScopedCPUTimer() { + const double ms = std::chrono::duration(std::chrono::steady_clock::now() - m_start).count(); + Profiler::get().addSample(m_name, ms); + } + + ScopedCPUTimer(const ScopedCPUTimer&) = delete; + ScopedCPUTimer& operator=(const ScopedCPUTimer&) = delete; + + private: + std::string m_name; + std::chrono::steady_clock::time_point m_start; +}; + +#define ICE_PROFILE_CONCAT_(a, b) a##b +#define ICE_PROFILE_CONCAT(a, b) ICE_PROFILE_CONCAT_(a, b) +// Times the enclosing scope under `name` (a string literal or std::string). +#define ICE_PROFILE_SCOPE(name) ICE::ScopedCPUTimer ICE_PROFILE_CONCAT(ice_scoped_timer_, __LINE__)(name) + +} // namespace ICE diff --git a/ICE/Util/src/BufferUtils.cpp b/ICE/Util/src/BufferUtils.cpp deleted file mode 100644 index e9cd86fc..00000000 --- a/ICE/Util/src/BufferUtils.cpp +++ /dev/null @@ -1,5 +0,0 @@ -// -// Created by Thomas Ibanez on 22.11.20. -// - -#include "BufferUtils.h" diff --git a/ICE/Util/src/GLFWWindow.cpp b/ICE/Util/src/GLFWWindow.cpp index 193d29c6..fc56b523 100644 --- a/ICE/Util/src/GLFWWindow.cpp +++ b/ICE/Util/src/GLFWWindow.cpp @@ -8,9 +8,14 @@ namespace ICE { +// Number of live GLFWWindow instances. GLFW is initialized on the first window and +// terminated when the last one is destroyed. +static int s_window_count = 0; + GLFWWindow::GLFWWindow(int width, int height, const std::string& title) : m_width(width), m_height(height) { - if (!glfwInit()) + if (s_window_count == 0 && !glfwInit()) throw ICEException("Couldn't init GLFW"); + s_window_count++; // Decide GL+GLSL versions #ifdef __APPLE__ // GL 3.2 + GLSL 150 @@ -27,8 +32,11 @@ GLFWWindow::GLFWWindow(int width, int height, const std::string& title) : m_widt #endif // Create window with graphics context m_handle = glfwCreateWindow(width, height, title.c_str(), NULL, NULL); - if (m_handle == NULL) + if (m_handle == NULL) { + if (--s_window_count == 0) + glfwTerminate(); throw ICEException("Couldn't create window"); + } m_mouse_handler = std::make_shared(*this); m_keyboard_handler = std::make_shared(*this); @@ -41,6 +49,15 @@ GLFWWindow::GLFWWindow(int width, int height, const std::string& title) : m_widt }); } +GLFWWindow::~GLFWWindow() { + if (m_handle != nullptr) { + glfwDestroyWindow(m_handle); + } + if (--s_window_count == 0) { + glfwTerminate(); + } +} + std::pair, std::shared_ptr> GLFWWindow::getInputHandlers() const { return {m_mouse_handler, m_keyboard_handler}; } diff --git a/ICE/Util/src/InputManager.cpp b/ICE/Util/src/InputManager.cpp new file mode 100644 index 00000000..c90e40dc --- /dev/null +++ b/ICE/Util/src/InputManager.cpp @@ -0,0 +1,79 @@ +#include "InputManager.h" + +namespace ICE { +InputManager::InputManager(const std::shared_ptr &window) : m_window(window) { + + auto [mouse_handler, keyboard_handler] = window->getInputHandlers(); + + keyboard_handler->addKeyPressedListener([this](ICE::Key key) { + m_key_states[key] = KeyState::DOWN; + m_key_actions[key] = KeyAction::PRESS; + }); + keyboard_handler->addKeyReleaseListener([this](ICE::Key key) { + m_key_states[key] = KeyState::UP; + m_key_actions[key] = KeyAction::RELEASE; + }); + mouse_handler->addMouseClickListener([this](ICE::MouseButton button, ICE::ButtonAction action) { + m_mouse_states[button] = action == ICE::ButtonAction::PRESS ? KeyState::DOWN : KeyState::UP; + m_mouse_actions[button] = action == ICE::ButtonAction::PRESS ? KeyAction::PRESS : KeyAction::RELEASE; + }); + mouse_handler->addMouseMoveListener([this](float x, float y) { + if (!m_has_mouse) { + // First cursor position: seed the "previous" position too, so the first getMouseDelta is + // zero instead of a jump from the origin. + m_prev_x = x; + m_prev_y = y; + m_has_mouse = true; + } + m_x = x; + m_y = y; + }); +} + +void InputManager::update(float dt) { + for (const auto &[k, v] : m_key_actions) { + m_key_actions[k] = KeyAction::NONE; + } + for (const auto &[k, v] : m_mouse_actions) { + m_mouse_actions[k] = KeyAction::NONE; + } + m_prev_x = m_x; + m_prev_y = m_y; +} + +KeyAction InputManager::getKeyAction(ICE::Key key) { + if (m_key_actions.contains(key)) { + return m_key_actions[key]; + } + return KeyAction::NONE; +} + +KeyState InputManager::getKeyState(ICE::Key key) { + if (m_key_states.contains(key)) { + return m_key_states[key]; + } + return KeyState::UP; +} + +bool InputManager::isKeyDown(ICE::Key key) { return getKeyState(key) == KeyState::DOWN; } + +KeyAction InputManager::getMouseAction(ICE::MouseButton button) { + if (m_mouse_actions.contains(button)) { + return m_mouse_actions[button]; + } + return KeyAction::NONE; +} + +KeyState InputManager::getMouseState(ICE::MouseButton button) { + if (m_mouse_states.contains(button)) { + return m_mouse_states[button]; + } + return KeyState::UP; +} + +float InputManager::getMouseX() { return m_x; } + +float InputManager::getMouseY() { return m_y; } + +Eigen::Vector2f InputManager::getMouseDelta() { return {m_x - m_prev_x, m_y - m_prev_y}; } +} // namespace ICE \ No newline at end of file diff --git a/ICE/Util/test/CMakeLists.txt b/ICE/Util/test/CMakeLists.txt new file mode 100644 index 00000000..0129a1b6 --- /dev/null +++ b/ICE/Util/test/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.19) +project(utils-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +add_executable(UtilsTestSuite + FrustumCullingTests.cpp + HelpersTest.cpp + InputManagerTest.cpp +) + +add_test(NAME UtilsTestSuite + COMMAND UtilsTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(UtilsTestSuite + PRIVATE + gtest_main + core + util) + + diff --git a/ICE/Util/test/FrustumCullingTests.cpp b/ICE/Util/test/FrustumCullingTests.cpp new file mode 100644 index 00000000..00203a14 --- /dev/null +++ b/ICE/Util/test/FrustumCullingTests.cpp @@ -0,0 +1,207 @@ +#include +#include +#include +#include + +#include + +#include "AABB.h" +#include "ICEMath.h" + +using namespace ICE; + +class FrustumCullingTest : public ::testing::Test { + protected: + std::shared_ptr createPerspectiveMatrix(float fov, float aspect, float near, float far) { + return std::make_shared(fov, aspect, near, far); + } +}; + +// Test extractFrustumPlanes +TEST_F(FrustumCullingTest, ExtractFrustumPlanes_StandardPerspective) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + // Check that all plane normals are normalized + for (int i = 0; i < 6; i++) { + float norm = frustum.planes[i].normal.norm(); + EXPECT_NEAR(norm, 1.0f, 1e-5) << "Plane " << i << " normal is not normalized"; + } +} + +TEST_F(FrustumCullingTest, ExtractFrustumPlanes_AbsNormalComputation) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + // Check that absNormal is the absolute value of normal + for (int i = 0; i < 6; i++) { + for (int j = 0; j < 3; j++) { + EXPECT_NEAR(frustum.planes[i].absNormal(j), fabsf(frustum.planes[i].normal(j)), 1e-5) + << "Plane " << i << " absNormal mismatch at component " << j; + } + } +} + +// Test isAABBInFrustum with AABB object +TEST_F(FrustumCullingTest, AABBInFrustum_CompletelyInside) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + // Create a small AABB at the origin (center of view) + AABB box(std::vector{Eigen::Vector3f(-0.5f, -0.5f, -0.5f), Eigen::Vector3f(0.5f, 0.5f, 0.5f)}); + + EXPECT_TRUE(isAABBInFrustum(frustum, box)); +} + +TEST_F(FrustumCullingTest, AABBInFrustum_PartiallyInside) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + // Create an AABB that straddles the near plane + AABB box(std::vector{Eigen::Vector3f(-1.0f, -1.0f, -0.05f), Eigen::Vector3f(1.0f, 1.0f, -0.5f)}); + + EXPECT_TRUE(isAABBInFrustum(frustum, box)); +} + +TEST_F(FrustumCullingTest, AABBInFrustum_FarPlane) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + // Create an AABB beyond the far plane + AABB box(std::vector{Eigen::Vector3f(-1.0f, -1.0f, -101.0f), Eigen::Vector3f(1.0f, 1.0f, -102.0f)}); + + EXPECT_FALSE(isAABBInFrustum(frustum, box)); +} + +TEST_F(FrustumCullingTest, AABBInFrustum_OutsideLeftPlane) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + // Create an AABB to the far left, outside the frustum + AABB box(std::vector{Eigen::Vector3f(-100.0f, -1.0f, -5.0f), Eigen::Vector3f(-50.0f, 1.0f, -3.0f)}); + + EXPECT_FALSE(isAABBInFrustum(frustum, box)); +} + +// Test isAABBInFrustum with center and extents +TEST_F(FrustumCullingTest, CenterExtentsInFrustum_Inside) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + Eigen::Vector3f aabb_center(0, 0, -10); + Eigen::Vector3f aabb_extents(0.5f, 0.5f, 0.5f); + + EXPECT_TRUE(isAABBInFrustum(frustum, aabb_center, aabb_extents)); +} + +TEST_F(FrustumCullingTest, CenterExtentsInFrustum_Outside) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + Eigen::Vector3f aabb_center(-500, 0, 0); + Eigen::Vector3f aabb_extents(1.0f, 1.0f, 1.0f); + + EXPECT_FALSE(isAABBInFrustum(frustum, aabb_center, aabb_extents)); +} + +TEST_F(FrustumCullingTest, CenterExtentsInFrustum_BehindCamera) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + Eigen::Vector3f aabb_center(0, 0, 10); + Eigen::Vector3f aabb_extents(1.0f, 1.0f, 1.0f); + + EXPECT_FALSE(isAABBInFrustum(frustum, aabb_center, aabb_extents)); +} + +TEST_F(FrustumCullingTest, CenterExtentsInFrustum_OutsideRightPlane) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + Eigen::Vector3f aabb_center(500, 0, 0); + Eigen::Vector3f aabb_extents(1.0f, 1.0f, 1.0f); + + EXPECT_FALSE(isAABBInFrustum(frustum, aabb_center, aabb_extents)); +} + +TEST_F(FrustumCullingTest, CenterExtentsInFrustum_OutsideTopPlane) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + Eigen::Vector3f aabb_center(0, 500, 0); + Eigen::Vector3f aabb_extents(1.0f, 1.0f, 1.0f); + + EXPECT_FALSE(isAABBInFrustum(frustum, aabb_center, aabb_extents)); +} + +TEST_F(FrustumCullingTest, CenterExtentsInFrustum_PartiallyVisible) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + Eigen::Vector3f aabb_center(1.0f, 1.0f, -5.0f); + Eigen::Vector3f aabb_extents(2.0f, 2.0f, 2.0f); + + EXPECT_TRUE(isAABBInFrustum(frustum, aabb_center, aabb_extents)); +} + +// Edge case: very small box +TEST_F(FrustumCullingTest, AABBInFrustum_VerySmallBox) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + AABB box(std::vector{Eigen::Vector3f(0, 0, -10), Eigen::Vector3f(0.001f, 0.001f, -10.001f)}); + + EXPECT_TRUE(isAABBInFrustum(frustum, box)); +} + +// Edge case: very large box containing camera +TEST_F(FrustumCullingTest, AABBInFrustum_VeryLargeBoxContainingCamera) { + + auto camera = createPerspectiveMatrix(M_PI / 4.0f, 16.0f / 9.0f, 0.1f, 100.0f); + Eigen::Matrix4f pv = camera->getProjection() * camera->lookThrough(); + + Frustum frustum = extractFrustumPlanes(pv); + + AABB box(Eigen::Vector3f(-1000, -1000, -1000), Eigen::Vector3f(1000, 1000, 1000)); + + EXPECT_TRUE(isAABBInFrustum(frustum, box)); +} diff --git a/ICE/Util/test/HelpersTest.cpp b/ICE/Util/test/HelpersTest.cpp new file mode 100644 index 00000000..c404ab63 --- /dev/null +++ b/ICE/Util/test/HelpersTest.cpp @@ -0,0 +1,81 @@ +#include +#include +#include + +using namespace ICE; + +class HelpersTest : public ::testing::Test { +protected: + void SetUp() override { + // Test setup if needed + } +}; + +TEST_F(HelpersTest, EntityHelperBasicUsage) { + // Create a mock registry + auto registry = std::make_shared(); + + // Create an entity + Entity entity = registry->createEntity(); + + // Add a transform component + registry->addComponent(entity, + TransformComponent({0, 0, 0}, {0, 0, 0}, {1, 1, 1})); + + // Use EntityHelper + EntityHelper helper(entity, registry); + + // Test validity + EXPECT_TRUE(helper.isValid()); + EXPECT_EQ(helper.id(), entity); + + // Test component access + auto transform = helper.transform(); + ASSERT_NE(transform, nullptr); + + // Test position modification + transform->setPosition({1, 2, 3}); + EXPECT_EQ(transform->getPosition().x(), 1.0f); + EXPECT_EQ(transform->getPosition().y(), 2.0f); + EXPECT_EQ(transform->getPosition().z(), 3.0f); +} + +TEST_F(HelpersTest, EntityHelperHasComponent) { + auto registry = std::make_shared(); + Entity entity = registry->createEntity(); + + EntityHelper helper(entity, registry); + + // Should not have transform initially + EXPECT_FALSE(helper.hasComponent()); + + // Add component + helper.addComponent( + TransformComponent({0, 0, 0}, {0, 0, 0}, {1, 1, 1})); + + // Should have it now + EXPECT_TRUE(helper.hasComponent()); +} + +TEST_F(HelpersTest, EntityHelperInvalidEntity) { + auto registry = std::make_shared(); + + // Create helper with invalid entity (0) + EntityHelper helper(0, registry); + + EXPECT_FALSE(helper.isValid()); + EXPECT_EQ(helper.id(), 0); +} + +TEST_F(HelpersTest, EngineHelperNullSafety) { + ICEEngine engine; + + // Engine has no project yet + EXPECT_FALSE(EngineHelper::isEngineReady(engine)); + + // Should return nullptr safely + EXPECT_EQ(EngineHelper::getRegistry(engine), nullptr); + EXPECT_EQ(EngineHelper::getAssetBank(engine), nullptr); + EXPECT_EQ(EngineHelper::getCurrentScene(engine), nullptr); + EXPECT_EQ(EngineHelper::getCamera(engine), nullptr); +} diff --git a/ICE/Util/test/InputManagerTest.cpp b/ICE/Util/test/InputManagerTest.cpp new file mode 100644 index 00000000..63b40f55 --- /dev/null +++ b/ICE/Util/test/InputManagerTest.cpp @@ -0,0 +1,118 @@ +#include + +#include +#include + +#include +#include + +using namespace ICE; + +namespace { +// Mock handlers expose the stored listeners so a test can fire events the way GLFW would during +// pollEvents (the callback vectors are protected on the base classes). +class MockKeyboardHandler : public KeyboardHandler { + public: + void firePress(Key k) { + for (auto& cb : m_keypress_callbacks) cb(k); + } + void fireRelease(Key k) { + for (auto& cb : m_keyrelease_callbacks) cb(k); + } +}; + +class MockMouseHandler : public MouseHandler { + public: + void setGrabMouse(bool) override {} + void fireMove(float x, float y) { + for (auto& cb : m_mousemove_callbacks) cb(x, y); + } + void fireButton(MouseButton b, ButtonAction a) { + for (auto& cb : m_mousebutton_callbacks) cb(b, a); + } +}; + +class MockWindow : public Window { + public: + MockWindow() : m_mouse(std::make_shared()), m_keyboard(std::make_shared()) {} + void* getHandle() const override { return nullptr; } + bool shouldClose() override { return false; } + void close() override {} + std::pair, std::shared_ptr> getInputHandlers() const override { + return {m_mouse, m_keyboard}; + } + void pollEvents() override {} + void swapBuffers() override {} + void getFramebufferSize(int* w, int* h) override { + *w = 800; + *h = 600; + } + void setSwapInterval(int) override {} + void makeContextCurrent() override {} + void setResizeCallback(const WindowResizeCallback&) override {} + std::pair getSize() const override { return {800, 600}; } + + std::shared_ptr m_mouse; + std::shared_ptr m_keyboard; +}; + +struct InputFixture { + std::shared_ptr window = std::make_shared(); + InputManager input{window}; + MockMouseHandler& mouse() { return *window->m_mouse; } + MockKeyboardHandler& keyboard() { return *window->m_keyboard; } +}; +} // namespace + +// PRESS is a one-frame edge: visible the frame the key goes down, gone after the frame's update(). +TEST(InputManagerTest, PressLastsExactlyOneFrame) { + InputFixture f; + f.keyboard().firePress(Key::KEY_W); // event fires during pollEvents, before step()'s update() + EXPECT_EQ(f.input.getKeyAction(Key::KEY_W), KeyAction::PRESS); + EXPECT_TRUE(f.input.isKeyDown(Key::KEY_W)); + + f.input.update(0.016f); // end of frame + + EXPECT_EQ(f.input.getKeyAction(Key::KEY_W), KeyAction::NONE); // edge consumed + EXPECT_TRUE(f.input.isKeyDown(Key::KEY_W)); // ...but still held +} + +TEST(InputManagerTest, ReleaseEdgeAndState) { + InputFixture f; + f.keyboard().firePress(Key::KEY_A); + f.input.update(0.016f); + EXPECT_TRUE(f.input.isKeyDown(Key::KEY_A)); + + f.keyboard().fireRelease(Key::KEY_A); + EXPECT_EQ(f.input.getKeyAction(Key::KEY_A), KeyAction::RELEASE); + EXPECT_FALSE(f.input.isKeyDown(Key::KEY_A)); + + f.input.update(0.016f); + EXPECT_EQ(f.input.getKeyAction(Key::KEY_A), KeyAction::NONE); +} + +// No spurious jump on the first cursor sample. +TEST(InputManagerTest, MouseDeltaFirstFrameIsZero) { + InputFixture f; + f.mouse().fireMove(100.0f, 50.0f); + Eigen::Vector2f d = f.input.getMouseDelta(); + EXPECT_FLOAT_EQ(d.x(), 0.0f); + EXPECT_FLOAT_EQ(d.y(), 0.0f); +} + +// Delta is the movement across the frame, and resets after update() rolls the previous position. +TEST(InputManagerTest, MouseDeltaIsFrameToFrame) { + InputFixture f; + f.mouse().fireMove(100.0f, 50.0f); // first sample: delta 0, prev seeded + f.input.update(0.016f); // prev = (100, 50) + + f.mouse().fireMove(110.0f, 45.0f); // moved (+10, -5) + Eigen::Vector2f d = f.input.getMouseDelta(); + EXPECT_FLOAT_EQ(d.x(), 10.0f); + EXPECT_FLOAT_EQ(d.y(), -5.0f); + + f.input.update(0.016f); // prev catches up + Eigen::Vector2f d2 = f.input.getMouseDelta(); + EXPECT_FLOAT_EQ(d2.x(), 0.0f); + EXPECT_FLOAT_EQ(d2.y(), 0.0f); +} diff --git a/ICEBERG/Components/ComboBox.h b/ICEBERG/Components/ComboBox.h index 5ee3ef12..8ec0427d 100644 --- a/ICEBERG/Components/ComboBox.h +++ b/ICEBERG/Components/ComboBox.h @@ -36,8 +36,10 @@ class ComboBox { void setValues(const std::vector &values) { m_values = values; } void setSelected(int index) { if (index >= 0 && index < m_values.size()) { + // Programmatic set only updates state; the selection-changed callback is for user + // interaction (render()). Firing it here made MaterialEditor::open re-assign the + // material's shader on open. m_selected_index = index; - m_callback_edit(m_values[m_selected_index], m_selected_index); } } diff --git a/ICEBERG/Components/UniformInputs.h b/ICEBERG/Components/UniformInputs.h index 15b5a8e7..467d0949 100644 --- a/ICEBERG/Components/UniformInputs.h +++ b/ICEBERG/Components/UniformInputs.h @@ -40,14 +40,15 @@ class UniformInputs { m_asset_combo.setValues(path_with_none); m_assets_ids = {0}; m_assets_ids.insert(m_assets_ids.end(), ids.begin(), ids.end()); + m_asset_combo.onSelectionChanged( + [cb = this->m_callback, id_list = this->m_assets_ids](const std::string &, int index) { cb(id_list[index]); }); if (std::holds_alternative(m_value)) { auto it = std::find(m_assets_ids.begin(), m_assets_ids.end(), std::get(m_value)); if (it != m_assets_ids.end()) { m_asset_combo.setSelected(std::distance(m_assets_ids.begin(), it)); } } - m_asset_combo.onSelectionChanged( - [cb = this->m_callback, id_list = this->m_assets_ids](const std::string &, int index) { cb(id_list[index]); }); + } void setForceVectorNumeric(bool force_vector_numeric) { m_force_vector_numeric = force_vector_numeric; } diff --git a/ICEBERG/UI/AddComponentPopup.h b/ICEBERG/UI/AddComponentPopup.h index 61f0688a..0a7a38bc 100644 --- a/ICEBERG/UI/AddComponentPopup.h +++ b/ICEBERG/UI/AddComponentPopup.h @@ -1,6 +1,9 @@ #pragma once +#include +#include #include +#include #include #include "Components/ComboBox.h" diff --git a/ICEBERG/UI/AnimationComponentWidget.h b/ICEBERG/UI/AnimationComponentWidget.h index a17bc62e..9c658dbf 100644 --- a/ICEBERG/UI/AnimationComponentWidget.h +++ b/ICEBERG/UI/AnimationComponentWidget.h @@ -27,7 +27,10 @@ class AnimationComponentWidget : public Widget, ImXML::XMLEventHandler { if (ImGui::BeginCombo("##animation_combo", m_current_animation.c_str())) { for (const auto& [key, anim] : m_animations) { if (ImGui::Selectable(key.c_str(), key == m_current_animation)) { - m_current_animation = key; + if (key != m_current_animation) { + m_current_animation = key; + m_animation_changed = true; + } } } ImGui::EndCombo(); @@ -38,19 +41,53 @@ class AnimationComponentWidget : public Widget, ImXML::XMLEventHandler { } } void onNodeEnd(ImXML::XMLNode& node) override {} - void onEvent(ImXML::XMLNode& node) override {} + void onEvent(ImXML::XMLNode& node) override { + if (node.arg("id") == "btn_remove" && m_on_remove) { + m_on_remove(); + } + } + + // The Remove button lives in this child widget, but the removal handler is registered on + // the parent InspectorWidget's callback map. This hook bridges the two. + void onRemove(const std::function& f) { m_on_remove = f; } void render() override { if (m_ac) { m_xml_renderer.render(m_xml_tree, *this); - m_ac->currentAnimation = m_current_animation; - m_ac->currentTime = m_time; + + // Blend duration input + ImGui::InputFloat("Blend Duration (s)", &m_blend_duration, 0.05f, 0.5f, "%.2f"); + if (m_blend_duration < 0.0f) m_blend_duration = 0.0f; + + // Show blending status + if (m_ac->blending) { + ImGui::TextColored(ImVec4(0.3f, 0.8f, 1.0f, 1.0f), "Blending: %.0f%%", m_ac->blendFactor * 100.0); + } + + // Apply changes + if (m_animation_changed) { + m_ac->playAnimation(m_current_animation, m_blend_duration); + m_animation_changed = false; + m_time = 0.0f; + m_ac->currentTime = m_time; + } else if (m_playing) { + // While playing, the AnimationSystem owns currentTime. Mirror it into the slider + // so the playhead tracks playback instead of the widget's stale m_time freezing it. + m_time = m_ac->currentTime; + } else { + // Paused: the slider scrubs the playhead. + m_ac->currentTime = m_time; + } m_ac->speed = m_speed; m_ac->playing = m_playing; m_ac->loop = m_loop; } } + // Per-frame refresh of just the cached pointer (see TransformComponentWidget). Mirrors + // the full setter's condition: only active when animations were provided for this entity. + void refreshComponent(ICE::AnimationComponent* ac) { m_ac = (ac && !m_animations.empty()) ? ac : nullptr; } + void setAnimationComponent(ICE::AnimationComponent* ac, const std::unordered_map& animations) { if (ac && !animations.empty()) { m_ac = ac; @@ -67,17 +104,21 @@ class AnimationComponentWidget : public Widget, ImXML::XMLEventHandler { private: ICE::AnimationComponent* m_ac = nullptr; + std::function m_on_remove; std::unordered_map m_animations; std::string m_current_animation = ""; + bool m_animation_changed = false; float m_max_time = 1.0; float m_time = 0.0; float m_speed = 1.0; - bool m_playing; - bool m_loop; + float m_blend_duration = 0.2f; + bool m_playing = false; + bool m_loop = false; ImXML::XMLTree m_xml_tree; ImXML::XMLRenderer m_xml_renderer; }; + diff --git a/ICEBERG/UI/AssetsContentWidget.h b/ICEBERG/UI/AssetsContentWidget.h index acfb87ee..9882ac53 100644 --- a/ICEBERG/UI/AssetsContentWidget.h +++ b/ICEBERG/UI/AssetsContentWidget.h @@ -42,7 +42,7 @@ class AssetsContentWidget : public Widget { if (ImGui::BeginTable("FileBrowserTable", columnCount)) { int itemIndex = 0; - auto renderItem = [&](const std::string& label, const std::string& path, const Thumbnail& tb) { + auto renderItem = [&](const std::string& label, const std::string& path, const Thumbnail& tb, bool is_asset) { itemIndex++; ImGui::TableNextColumn(); ImGui::PushID(itemIndex); @@ -65,6 +65,16 @@ class AssetsContentWidget : public Widget { ImGui::EndDragDropSource(); } } + // Right-click context menu, assets only (folders/".." aren't deletable here). + if (is_asset && ImGui::BeginPopupContextItem("##asset_ctx")) { + if (m_current_type == ICE::AssetType::EMaterial && ImGui::MenuItem("Duplicate")) { + callback("material_duplicate", path); + } + if (ImGui::MenuItem("Delete")) { + callback("delete_asset", path); + } + ImGui::EndPopup(); + } if (itemIndex != m_selected_item) { ImGui::PopStyleColor(); @@ -81,13 +91,13 @@ class AssetsContentWidget : public Widget { }; if (m_current_view.parent != nullptr) { - renderItem("..", "..", {m_folder_texture, false}); + renderItem("..", "..", {m_folder_texture, false}, false); } for (const auto& folder : m_current_view.subfolders) - renderItem(folder.folder_name, folder.folder_name, {m_folder_texture, false}); + renderItem(folder.folder_name, folder.folder_name, {m_folder_texture, false}, false); for (const auto& asset : m_current_view.assets) - renderItem(asset.name, asset.asset_path, asset.thumbnail); + renderItem(asset.name, asset.asset_path, asset.thumbnail, true); ImGui::EndTable(); } diff --git a/ICEBERG/UI/HierarchyWidget.h b/ICEBERG/UI/HierarchyWidget.h index 99e19c3f..73b70856 100644 --- a/ICEBERG/UI/HierarchyWidget.h +++ b/ICEBERG/UI/HierarchyWidget.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -32,11 +33,23 @@ class HierarchyWidget : public Widget { renderTree(m_view); + // Delete key removes the selected entity (never the root scene node, id 0). + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && selected_id != 0 && + ImGui::IsKeyPressed(ImGuiKey_Delete)) { + callback("delete_entity_clicked", selected_id); + } + + renderRenamePopup(); + ImGui::End(); } private: void renderTree(const SceneTreeView& tree) { + // Scope the ImGui ID by entity id so two entities that share a name don't collide + // (which corrupts selection/open state and the context popup). Paired with PopID + // below, called unconditionally regardless of whether the node is open. + ImGui::PushID(static_cast(tree.id)); auto flags = tree.children.empty() ? ImGuiTreeNodeFlags_Leaf : 0; flags |= tree.id == selected_id ? ImGuiTreeNodeFlags_Selected : 0; flags |= ImGuiTreeNodeFlags_SpanFullWidth | ImGuiTreeNodeFlags_OpenOnDoubleClick | ImGuiTreeNodeFlags_OpenOnArrow; @@ -45,7 +58,9 @@ class HierarchyWidget : public Widget { if (tree.type != EntityType::Scene) { auto src_flags = ImGuiDragDropFlags_SourceNoDisableHover; if (ImGui::BeginDragDropSource(src_flags)) { - ImGui::SetDragDropPayload("DND_ENTITY_TREE", &tree.id, sizeof(ICE::Entity*)); + // Payload is an ICE::Entity value, not a pointer: sizeof(ICE::Entity*) + // would copy 4 bytes past tree.id. + ImGui::SetDragDropPayload("DND_ENTITY_TREE", &tree.id, sizeof(ICE::Entity)); ImGui::Text("%s", name.c_str()); ImGui::EndDragDropSource(); } @@ -63,6 +78,21 @@ class HierarchyWidget : public Widget { callback("create_entity_clicked", tree.id); ImGui::CloseCurrentPopup(); } + // Duplicate/Rename/Delete only apply to real entities, not the root scene node. + if (tree.id != 0) { + if (ImGui::Button("Duplicate")) { + callback("duplicate_entity_clicked", tree.id); + ImGui::CloseCurrentPopup(); + } + if (ImGui::Button("Rename")) { + beginRename(tree.id, tree.entity_name); + ImGui::CloseCurrentPopup(); + } + if (ImGui::Button("Delete")) { + callback("delete_entity_clicked", tree.id); + ImGui::CloseCurrentPopup(); + } + } ImGui::EndPopup(); } if (ImGui::IsItemClicked(0)) { @@ -75,9 +105,41 @@ class HierarchyWidget : public Widget { } ImGui::TreePop(); } + ImGui::PopID(); + } + + void beginRename(ICE::Entity e, const std::string& current_name) { + m_renaming_id = e; + std::snprintf(m_rename_buf, sizeof(m_rename_buf), "%s", current_name.c_str()); + m_open_rename = true; + m_focus_rename = true; + } + + void renderRenamePopup() { + if (m_open_rename) { + ImGui::OpenPopup("Rename Entity"); + m_open_rename = false; + } + if (ImGui::BeginPopup("Rename Entity")) { + if (m_focus_rename) { + ImGui::SetKeyboardFocusHere(); + m_focus_rename = false; + } + bool commit = ImGui::InputText("##rename_entity", m_rename_buf, sizeof(m_rename_buf), ImGuiInputTextFlags_EnterReturnsTrue); + if (commit && m_rename_buf[0] != '\0') { + callback("rename_entity", m_renaming_id, std::string(m_rename_buf)); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } private: SceneTreeView m_view; ICE::Entity selected_id = 0; + + ICE::Entity m_renaming_id = 0; + char m_rename_buf[256] = {0}; + bool m_open_rename = false; + bool m_focus_rename = false; }; diff --git a/ICEBERG/UI/InspectorWidget.h b/ICEBERG/UI/InspectorWidget.h index 905579bf..4bff691c 100644 --- a/ICEBERG/UI/InspectorWidget.h +++ b/ICEBERG/UI/InspectorWidget.h @@ -13,6 +13,11 @@ class InspectorWidget : public Widget { public: InspectorWidget() { m_input_entity_name.onEdit([this](const std::string&, const std::string& text) { callback("entity_name_changed", text); }); + // The child component widgets own the Remove buttons; forward their clicks to this + // widget's callback map, where the Inspector controller registers the handlers. + m_rc_widget.onRemove([this] { callback("remove_render_component_clicked"); }); + m_lc_widget.onRemove([this] { callback("remove_light_component_clicked"); }); + m_ac_widget.onRemove([this] { callback("remove_animation_component_clicked"); }); } void render() override { @@ -36,6 +41,17 @@ class InspectorWidget : public Widget { } } + // Refresh the widgets' cached component pointers every frame so they can't dangle + // after another entity's structural change reallocates component storage. Unlike the + // set* methods, this does not rebuild lists or re-bind input values. + void refreshComponents(ICE::TransformComponent* tc, ICE::LightComponent* lc, ICE::RenderComponent* rc, ICE::AnimationComponent* ac) { + m_entity_selected = (tc != nullptr); + m_tc_widget.refreshComponent(tc); + m_lc_widget.refreshComponent(lc); + m_rc_widget.refreshComponent(rc); + m_ac_widget.refreshComponent(ac); + } + void setEntityName(const std::string& name) { m_input_entity_name.setText(name); } void setTransformComponent(ICE::TransformComponent* tc) { diff --git a/ICEBERG/UI/LightComponentWidget.h b/ICEBERG/UI/LightComponentWidget.h index 8555a081..2feecf33 100644 --- a/ICEBERG/UI/LightComponentWidget.h +++ b/ICEBERG/UI/LightComponentWidget.h @@ -38,6 +38,8 @@ class LightComponentWidget : public Widget, ImXML::XMLEventHandler { m_lc->type = ICE::DirectionalLight; } else if (node.arg("id") == "spot_light") { m_lc->type = ICE::SpotLight; + } else if (node.arg("id") == "btn_remove" && m_on_remove) { + m_on_remove(); } } @@ -48,6 +50,13 @@ class LightComponentWidget : public Widget, ImXML::XMLEventHandler { } } + // The Remove button lives in this child widget, but the removal handler is registered on + // the parent InspectorWidget's callback map. This hook bridges the two. + void onRemove(const std::function& f) { m_on_remove = f; } + + // Per-frame refresh of just the cached pointer (see TransformComponentWidget). + void refreshComponent(ICE::LightComponent* lc) { m_lc = lc; } + void setLightComponent(ICE::LightComponent* lc) { m_lc = lc; if (lc) { @@ -57,6 +66,7 @@ class LightComponentWidget : public Widget, ImXML::XMLEventHandler { private: ICE::LightComponent* m_lc = nullptr; + std::function m_on_remove; float m_distance_dropoff; diff --git a/ICEBERG/UI/MaterialEditDialog.h b/ICEBERG/UI/MaterialEditDialog.h index e4e232bd..d7a9dc5d 100644 --- a/ICEBERG/UI/MaterialEditDialog.h +++ b/ICEBERG/UI/MaterialEditDialog.h @@ -7,6 +7,7 @@ #include #include +#include #include #include diff --git a/ICEBERG/UI/NewProjectPopup.h b/ICEBERG/UI/NewProjectPopup.h index d9a098e0..3d602b76 100644 --- a/ICEBERG/UI/NewProjectPopup.h +++ b/ICEBERG/UI/NewProjectPopup.h @@ -17,8 +17,13 @@ class NewProjectPopup : public Dialog, ImXML::XMLEventHandler { } void render() override { - if (isOpenRequested()) + if (isOpenRequested()) { + // Reset the inputs each time the dialog opens so a previously typed name/path + // doesn't linger into a new project. + m_project_name[0] = '\0'; + m_project_dir[0] = '\0'; ImGui::OpenPopup("New Project"); + } m_xml_renderer.render(m_xml_tree, *this); } @@ -28,8 +33,9 @@ class NewProjectPopup : public Dialog, ImXML::XMLEventHandler { if (node.arg("id") == "btn_create") { auto path_string = std::string(m_project_dir); if (!path_string.empty() && std::filesystem::exists(path_string) && !std::string(m_project_name).empty()) { - auto project = std::make_shared(path_string, m_project_name); - project->CreateDirectories(); + // Only validate and report the choice here; the "create_clicked" handler in + // ProjectSelection owns the single, authoritative project creation. Creating a + // throwaway project here as well ran CreateDirectories twice on the same path. ImGui::CloseCurrentPopup(); done(DialogResult::Ok); } diff --git a/ICEBERG/UI/OpenSceneDialog.h b/ICEBERG/UI/OpenSceneDialog.h index 81416a5f..54a7f6fb 100644 --- a/ICEBERG/UI/OpenSceneDialog.h +++ b/ICEBERG/UI/OpenSceneDialog.h @@ -8,17 +8,15 @@ class OpenSceneDialog : public Dialog { public: OpenSceneDialog(const std::shared_ptr& engine) : m_engine(engine), m_scene_name_combo("###SceneNameCombo", {}) { - std::vector scenes_names; - for (const auto& s : m_engine->getProject()->getScenes()) { - scenes_names.push_back(s->getName()); - } - m_scene_name_combo.setValues(scenes_names); - m_scene_name_combo.setSelected(0); + refreshScenes(); } void render() override { ImGui::PushID("scene_open"); if (isOpenRequested()) { + // Rebuild the list every time the dialog opens: scenes can be added or removed + // during the session, so a list captured once in the constructor goes stale. + refreshScenes(); ImGui::OpenPopup("Scene Selection"); } @@ -42,6 +40,15 @@ class OpenSceneDialog : public Dialog { private: + void refreshScenes() { + std::vector scenes_names; + for (const auto& s : m_engine->getProject()->getScenes()) { + scenes_names.push_back(s->getName()); + } + m_scene_name_combo.setValues(scenes_names); + m_scene_name_combo.setSelected(0); + } + std::shared_ptr m_engine; ComboBox m_scene_name_combo; }; \ No newline at end of file diff --git a/ICEBERG/UI/ProjectSelectionWidget.h b/ICEBERG/UI/ProjectSelectionWidget.h index 11397b31..0f341adf 100644 --- a/ICEBERG/UI/ProjectSelectionWidget.h +++ b/ICEBERG/UI/ProjectSelectionWidget.h @@ -46,7 +46,10 @@ class ProjectSelectionWidget : public Widget, ImXML::XMLEventHandler { m_new_project_popup.open(); } else if (node.arg("id") == "btn_add_project") { auto path = open_native_dialog({{"ICE Projects", "*.ice"}}); - callback("load_clicked", path); + // Empty path means the user cancelled: don't try to load a project from "". + if (!path.empty()) { + callback("load_clicked", path); + } } } @@ -66,9 +69,11 @@ class ProjectSelectionWidget : public Widget, ImXML::XMLEventHandler { callback("project_selected", i); } ImGui::TableNextColumn(); - ImGui::Text(p.modified_date.c_str()); + // TextUnformatted: these are user-controlled strings, so a '%' must not be + // interpreted as a printf format specifier. + ImGui::TextUnformatted(p.modified_date.c_str()); ImGui::TableNextColumn(); - ImGui::Text(p.path.c_str()); + ImGui::TextUnformatted(p.path.c_str()); ImGui::TableNextRow(); if (ImGui::IsItemClicked()) { diff --git a/ICEBERG/UI/RenderComponentWidget.h b/ICEBERG/UI/RenderComponentWidget.h index f5328019..ee59f1d0 100644 --- a/ICEBERG/UI/RenderComponentWidget.h +++ b/ICEBERG/UI/RenderComponentWidget.h @@ -24,7 +24,11 @@ class RenderComponentWidget : public Widget, ImXML::XMLEventHandler { } } void onNodeEnd(ImXML::XMLNode& node) override {} - void onEvent(ImXML::XMLNode& node) override {} + void onEvent(ImXML::XMLNode& node) override { + if (node.arg("id") == "btn_remove" && m_on_remove) { + m_on_remove(); + } + } void render() override { if (m_rc) { @@ -32,6 +36,13 @@ class RenderComponentWidget : public Widget, ImXML::XMLEventHandler { } } + // The Remove button lives in this child widget, but the removal handler is registered on + // the parent InspectorWidget's callback map. This hook bridges the two. + void onRemove(const std::function& f) { m_on_remove = f; } + + // Per-frame refresh of just the cached pointer (see TransformComponentWidget). + void refreshComponent(ICE::RenderComponent* rc) { m_rc = rc; } + void setRenderComponent(ICE::RenderComponent* rc, const std::vector& meshes_paths, const std::vector& meshes_ids, const std::vector& materials_paths, const std::vector& materials_ids) { m_rc = rc; @@ -48,6 +59,7 @@ class RenderComponentWidget : public Widget, ImXML::XMLEventHandler { private: ICE::RenderComponent* m_rc = nullptr; + std::function m_on_remove; UniformInputs m_models_combo{"##models_combo", 0}; UniformInputs m_material_combo{"##materials_combo", 0}; diff --git a/ICEBERG/UI/ShaderEditDialog.h b/ICEBERG/UI/ShaderEditDialog.h index 82da2c8d..193126fc 100644 --- a/ICEBERG/UI/ShaderEditDialog.h +++ b/ICEBERG/UI/ShaderEditDialog.h @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -25,7 +26,7 @@ class ShaderEditDialog : public Dialog, ImXML::XMLEventHandler { void setShader(const std::shared_ptr& shader, const std::string& name, const std::filesystem::path& shader_base_folder) { m_shader_base_folder = shader_base_folder; m_widgets.clear(); - strncpy_s(m_shader_name, name.c_str(), 512); + std::snprintf(m_shader_name, sizeof(m_shader_name), "%s", name.c_str()); if (shader) { for (const auto& [stage, source] : shader->getStageSources()) { m_shader_base_folder = shader->getSources().at(0).parent_path(); diff --git a/ICEBERG/UI/ShaderStageEditWidget.h b/ICEBERG/UI/ShaderStageEditWidget.h index d0e1b4b0..00342a2c 100644 --- a/ICEBERG/UI/ShaderStageEditWidget.h +++ b/ICEBERG/UI/ShaderStageEditWidget.h @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -26,13 +27,13 @@ class ShaderStageEditWidget : public Widget, ImXML::XMLEventHandler { void setShaderSource(const std::pair& source, const std::filesystem::path& parent_path) { m_parent_path = parent_path; - strncpy_s(m_shader_file, source.first.c_str(), 512); + std::snprintf(m_shader_file, sizeof(m_shader_file), "%s", source.first.c_str()); std::ifstream file(parent_path / source.first); std::stringstream buffer; buffer << file.rdbuf(); - strncpy_s(m_shader_source, buffer.str().c_str(), 65535); + std::snprintf(m_shader_source, sizeof(m_shader_source), "%s", buffer.str().c_str()); } void onNodeBegin(ImXML::XMLNode& node) override { @@ -50,7 +51,7 @@ class ShaderStageEditWidget : public Widget, ImXML::XMLEventHandler { if (file.is_open()) { std::stringstream buffer; buffer << file.rdbuf(); - strncpy_s(m_shader_source, buffer.str().c_str(), 65535); + std::snprintf(m_shader_source, sizeof(m_shader_source), "%s", buffer.str().c_str()); } } else if (node.arg("id") == "btn_delete_stage") { ImGui::SetTabItemClosed(m_stage.c_str()); diff --git a/ICEBERG/UI/TransformComponentWidget.h b/ICEBERG/UI/TransformComponentWidget.h index dba3a504..88b340ec 100644 --- a/ICEBERG/UI/TransformComponentWidget.h +++ b/ICEBERG/UI/TransformComponentWidget.h @@ -38,6 +38,12 @@ class TransformComponentWidget : public Widget, ImXML::XMLEventHandler { } } + // Lightweight per-frame refresh of just the cached pointer. Another entity's + // structural change (add/remove component) can reallocate the component storage and + // dangle m_tc; the bound lambdas dereference this->m_tc, so refreshing the member + // keeps them valid without re-running setValue (which would fight in-progress edits). + void refreshComponent(ICE::TransformComponent* tc) { m_tc = tc; } + void setTransformComponent(ICE::TransformComponent* tc) { m_tc = tc; if (tc) { diff --git a/ICEBERG/UI/ViewportWidget.h b/ICEBERG/UI/ViewportWidget.h index 2d718e15..ed9a57bd 100644 --- a/ICEBERG/UI/ViewportWidget.h +++ b/ICEBERG/UI/ViewportWidget.h @@ -39,32 +39,41 @@ class ViewportWidget : public Widget { ImGui::EndDragDropTarget(); } - auto drag = ImGui::GetMouseDragDelta(0); + // Camera look is on the RIGHT mouse button so the left button stays free for + // selection and gizmo manipulation. Wheel scrolls the camera forward/back. + auto drag = ImGui::GetMouseDragDelta(1); if (ImGui::IsWindowHovered()) { - if (ImGui::IsMouseDragging(0)) { + if (ImGui::IsMouseDragging(1)) { callback("mouse_dragged", drag.x, drag.y); - ImGui::ResetMouseDragDelta(0); - } else if (ImGui::IsMouseClicked(0) && !ImGuizmo::IsOver()) { + ImGui::ResetMouseDragDelta(1); + } + float wheel = ImGui::GetIO().MouseWheel; + if (wheel != 0.0f) { + callback("scroll", wheel); + } + if (ImGui::IsMouseClicked(0) && !ImGuizmo::IsOver()) { auto m_pos = ImGui::GetMousePos(); callback("mouse_clicked", m_pos.x - pos.x, m_pos.y - pos.y); } } if (ImGui::IsWindowFocused()) { + // Frame-rate independent movement: the controller multiplies by this dt. + float dt = ImGui::GetIO().DeltaTime; if (ImGui::IsKeyDown(ImGuiKey_W)) { - callback("w_pressed"); + callback("w_pressed", dt); } else if (ImGui::IsKeyDown(ImGuiKey_S)) { - callback("s_pressed"); + callback("s_pressed", dt); } if (ImGui::IsKeyDown(ImGuiKey_A)) { - callback("a_pressed"); + callback("a_pressed", dt); } else if (ImGui::IsKeyDown(ImGuiKey_D)) { - callback("d_pressed"); + callback("d_pressed", dt); } if (ImGui::IsKeyDown(ImGuiKey_LeftShift)) { - callback("ls_pressed"); + callback("ls_pressed", dt); } else if (ImGui::IsKeyDown(ImGuiKey_LeftCtrl)) { - callback("lc_pressed"); + callback("lc_pressed", dt); } } callback("resize", window_width, window_height); diff --git a/ICEBERG/include/Assets.h b/ICEBERG/include/Assets.h index 02f59b31..e0f9f731 100644 --- a/ICEBERG/include/Assets.h +++ b/ICEBERG/include/Assets.h @@ -35,5 +35,9 @@ class Assets : public Controller { std::optional m_current_preview = std::nullopt; + // Last observed count of in-flight async imports; a decrease means one finished and the viewer + // should rebuild (see update()). + std::size_t m_last_inflight = 0; + float m_t = 0; }; diff --git a/ICEBERG/include/AssetsRenderer.h b/ICEBERG/include/AssetsRenderer.h index 488dd76f..ec99e2d3 100644 --- a/ICEBERG/include/AssetsRenderer.h +++ b/ICEBERG/include/AssetsRenderer.h @@ -16,6 +16,14 @@ class AssetsRenderer { std::pair createThumbnail(const std::shared_ptr& asset, const std::string& path); std::pair getPreview(const std::shared_ptr& asset, const std::string& path, float t); + // Drop the cached thumbnail/preview renderers (and their GPU framebuffers) for an asset. + // Call when an asset is removed or its path changes; otherwise m_renderers grows unbounded + // and a later asset reusing the path would show the stale preview. + void evict(const std::string& path) { + m_renderers.erase("thumb_" + path); + m_renderers.erase("preview_" + path); + } + private: std::unordered_map m_renderers; std::shared_ptr m_api; diff --git a/ICEBERG/include/Editor.h b/ICEBERG/include/Editor.h index 7341de73..b26d682e 100644 --- a/ICEBERG/include/Editor.h +++ b/ICEBERG/include/Editor.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -8,6 +9,7 @@ #include #include +#include #include #include "Assets.h" @@ -25,20 +27,30 @@ class Editor : public Controller { template bool importAsset(const std::vector &filters = {}) { std::filesystem::path file = open_native_dialog(filters); - if (!file.empty()) { - std::string import_name = file.stem().string(); - int i = 0; - while (m_engine->getAssetBank()->nameInUse(ICE::AssetPath::WithTypePrefix(import_name))) { - import_name = file.stem().string() + std::to_string(++i); - } - auto folder = ICE::AssetPath::WithTypePrefix("").getPath().at(0); - m_engine->getProject()->copyAssetFile(folder, import_name, file); - m_engine->getAssetBank()->addAsset( - import_name, {m_engine->getProject()->getBaseDirectory() / "Assets" / folder / (import_name + file.extension().string())}); - m_assets->rebuildViewer(); - return true; + if (file.empty()) { + return false; } - return false; + std::string import_name = file.stem().string(); + int i = 0; + while (m_engine->getAssetBank()->nameInUse(ICE::AssetPath::WithTypePrefix(import_name))) { + import_name = file.stem().string() + std::to_string(++i); + } + auto folder = ICE::AssetPath::WithTypePrefix("").getPath().at(0); + m_engine->getProject()->copyAssetFile(folder, import_name, file); + std::vector sources = { + m_engine->getProject()->getBaseDirectory() / "Assets" / folder / (import_name + file.extension().string())}; + + // Async import: reserve the UID now and load in the background (staging off-thread once + // enableBackgroundAssetLoading is on). AssetBank::pump() -- driven by ICEEngine::step -- + // publishes the asset, and the viewer rebuilds when the in-flight count drops (see + // Assets::update). Model import routes through the two-phase stage/commit path because its + // loader mutates the bank; other kinds use the pure-loader convenience overload. + if constexpr (std::is_same_v) { + m_engine->getProject()->requestModel(import_name, sources); + } else { + m_engine->getAssetBank()->requestAsset(import_name, sources); + } + return true; } void loadScene(int index); diff --git a/ICEBERG/include/Hierarchy.h b/ICEBERG/include/Hierarchy.h index 1de2bb17..753be02c 100644 --- a/ICEBERG/include/Hierarchy.h +++ b/ICEBERG/include/Hierarchy.h @@ -16,10 +16,17 @@ class Hierarchy : public Controller { void setSelectedEntity(ICE::Entity e); void rebuildTree(); + // True once after the selected entity was renamed from the hierarchy. The Editor consumes + // this to force-refresh the Inspector, whose name field is otherwise only reloaded on a + // selection change (a rename keeps the same entity selected). Symmetric to + // Inspector::entityHasChanged, which drives the hierarchy rebuild the other way. + bool selectionRenamed(); + private: std::shared_ptr m_engine; bool m_done = false; bool m_need_rebuild_tree = true; + bool m_selection_renamed = false; HierarchyWidget ui; ICE::Entity m_selected = 0; }; diff --git a/ICEBERG/include/Inspector.h b/ICEBERG/include/Inspector.h index b7a32c94..8376a617 100644 --- a/ICEBERG/include/Inspector.h +++ b/ICEBERG/include/Inspector.h @@ -16,10 +16,16 @@ class Inspector : public Controller { bool entityHasChanged(); private: + // A component's Remove button fires while its widget is mid-render. Removing the component + // there would free/null the pointer the widget is still using this frame, so the request is + // recorded and applied after render() returns. + enum class PendingRemove { None, Render, Light, Animation }; + std::shared_ptr m_engine; bool m_done = false; InspectorWidget ui; ICE::Entity m_selected_entity = 0; int m_entity_has_changed = 0; + PendingRemove m_pending_remove = PendingRemove::None; AddComponentPopup m_add_component_popup; }; diff --git a/ICEBERG/include/Viewport.h b/ICEBERG/include/Viewport.h index 0aae95fb..e5c3483c 100644 --- a/ICEBERG/include/Viewport.h +++ b/ICEBERG/include/Viewport.h @@ -17,7 +17,10 @@ class Viewport : public Controller { std::shared_ptr m_engine; bool m_done = false; ViewportWidget ui; - const double camera_delta = 0.1; + // World units per second; the per-frame step is camera_speed * dt so movement is + // frame-rate independent. scroll_speed is world units per mouse-wheel notch. + const double camera_speed = 6.0; + const double scroll_speed = 0.5; ImGuizmo::OPERATION m_guizmo_mode = ImGuizmo::TRANSLATE; ICE::Entity m_selected_entity = 0; std::function m_entity_transformed_callback = [] { diff --git a/ICEBERG/src/Assets.cpp b/ICEBERG/src/Assets.cpp index a9bd99cc..d35faa29 100644 --- a/ICEBERG/src/Assets.cpp +++ b/ICEBERG/src/Assets.cpp @@ -12,15 +12,22 @@ Assets::Assets(const std::shared_ptr& engine, const std::shared_ m_material_editor(engine), m_shader_editor(engine) { rebuildViewer(); - ui.registerCallback("material_duplicate", [this](std::string name) { - auto id = m_engine->getAssetBank()->getUID("Materials/" + name); - auto mat_copy = std::make_shared(*m_engine->getAssetBank()->getAsset(id)); + ui.registerCallback("material_duplicate", [this](std::string path) { + auto bank = m_engine->getAssetBank(); + auto src = bank->getAsset(bank->getUID(path)); + if (!src) { + return; + } + auto mat_copy = std::make_shared(*src); mat_copy->setSources({}); - m_engine->getAssetBank()->addAsset(name + " copy", mat_copy); + bank->addAsset(ICE::AssetPath(path).getName() + " copy", mat_copy); rebuildViewer(); }); - ui.registerCallback("delete_asset", [this](std::string name) { - m_engine->getAssetBank()->removeAsset(name); + ui.registerCallback("delete_asset", [this](std::string path) { + m_engine->getAssetBank()->removeAsset(ICE::AssetPath(path)); + // Drop the cached preview/thumbnail renderer so a later asset reusing this path + // doesn't show the deleted asset's image. + m_renderer.evict(path); rebuildViewer(); }); @@ -72,6 +79,9 @@ void Assets::rebuildViewer() { auto asset_bank = m_engine->getAssetBank(); for (const auto& entry : asset_bank->getAllEntries()) { + if (!entry.asset) { + continue; // async import still loading (or failed): skip until its payload is ready + } Thumbnail thumbnail; auto [ptr, flip] = m_renderer.createThumbnail(entry.asset, entry.path.toString()); thumbnail.ptr = ptr; @@ -138,6 +148,15 @@ void Assets::createSubfolderView(AssetView* parent_view, const std::vectorgetAssetBank()->inFlight(); + if (inflight < m_last_inflight) { + rebuildViewer(); + } + m_last_inflight = inflight; + if (m_current_preview.has_value()) { auto asset_ptr = m_engine->getAssetBank()->getAsset(m_engine->getAssetBank()->getUID(m_current_preview.value().asset_path)); ui.setPreviewTexture(m_renderer.getPreview(asset_ptr, m_current_preview.value().asset_path, m_t).first); diff --git a/ICEBERG/src/AssetsRenderer.cpp b/ICEBERG/src/AssetsRenderer.cpp index 970bc0d5..cf7568fb 100644 --- a/ICEBERG/src/AssetsRenderer.cpp +++ b/ICEBERG/src/AssetsRenderer.cpp @@ -1,13 +1,14 @@ #include "AssetsRenderer.h" #include +#include std::pair AssetsRenderer::createThumbnail(const std::shared_ptr& asset, const std::string& asset_path) { return getPreview(asset, asset_path, std::numeric_limits::infinity()); } std::pair AssetsRenderer::getPreview(const std::shared_ptr& asset, const std::string& asset_path, float t) { - std::vector> meshes; + std::vector meshes; std::vector> materials; std::vector transforms; bool thumbnail = (t == std::numeric_limits::infinity()); @@ -20,25 +21,28 @@ std::pair AssetsRenderer::getPreview(const std::shared_ptr(asset); m) { - return {m_bank->getTexture2D(asset_path)->ptr(), false}; + // The asset may have been removed since the browser last listed it; getTexture2D + // returns null in that case, so don't dereference it. + auto tex = m_bank->getTexture2D(asset_path); + return {tex ? tex->ptr() : nullptr, false}; } else if (auto m = std::dynamic_pointer_cast(asset); m) { return {nullptr, false}; //TODO } else if (auto m = std::dynamic_pointer_cast(asset); m) { return {m_bank->getTexture2D(ICE::AssetPath::WithTypePrefix("Editor/shader"))->ptr(), false}; } else if (auto m = std::dynamic_pointer_cast(asset); m) { - meshes.push_back(m_bank->getMesh(asset_path)); + meshes.push_back(m_bank->meshHandle(asset_path)); materials.push_back(m_bank->getMaterial(ICE::AssetPath::WithTypePrefix("base_mat"))); transforms.push_back(rotation); } else if (auto m = std::dynamic_pointer_cast(asset); m) { materials.push_back(m); - meshes.push_back(m_bank->getMesh(ICE::AssetPath::WithTypePrefix("sphere"))); + meshes.push_back(m_bank->meshHandle(ICE::AssetPath::WithTypePrefix("sphere"))); transforms.push_back(rotation); } else if (auto m = std::dynamic_pointer_cast(asset); m) { std::vector meshes_id; std::vector materials_id; m->traverse(meshes_id, materials_id, transforms, rotation); for (int i = 0; i < meshes_id.size(); i++) { - meshes.push_back(m_bank->getMesh(meshes_id[i])); + meshes.push_back(m_bank->meshHandle(meshes_id[i])); materials.push_back(m_bank->getMaterial(materials_id[i])); } } else { @@ -48,7 +52,7 @@ std::pair AssetsRenderer::getPreview(const std::shared_ptr AssetsRenderer::getPreview(const std::shared_ptr> textures; - for (const auto& [k, v] : materials[i]->getAllUniforms()) { - if (std::holds_alternative(v)) { - auto id = std::get(v); - textures.try_emplace(id, m_bank->getTexture2D(id)); - } - } - auto shader = m_bank->getShader(materials[i]->getShader()); - if (shader) + auto shader = m_bank->shaderHandle(materials[i]->getShader()); + // Skip anything whose GPU resources aren't available (e.g. an asset removed after the + // browser listed it) rather than submitting a null mesh/shader to the renderer. The + // geometry pass resolves the material's textures at bind time. + if (meshes[i].valid() && shader.valid()) renderer.submitDrawable( - ICE::Drawable{.mesh = meshes[i], .material = materials[i], .shader = shader, .textures = textures, .model_matrix = transforms[i]}); + ICE::Drawable{.mesh = meshes[i], .material = materials[i], .shader = shader, .model_matrix = transforms[i]}); } renderer.submitLight( ICE::Light{.position = {-2, 2, 2}, .rotation = {0, 0, 0}, .color = {1, 1, 1}, .distance_dropoff = 0, .type = ICE::LightType::PointLight}); diff --git a/ICEBERG/src/Editor.cpp b/ICEBERG/src/Editor.cpp index 95ae1f99..416652a8 100644 --- a/ICEBERG/src/Editor.cpp +++ b/ICEBERG/src/Editor.cpp @@ -8,6 +8,9 @@ Editor::Editor(const std::shared_ptr& engine, const std::shared_ m_open_scene_popup(engine), m_material_popup(engine), m_shader_popup(engine) { + // Stage imports off the main thread so importing a large model doesn't hitch the editor. The + // asset bank publishes results on the main thread via ICEEngine::step -> pump(). + m_engine->enableBackgroundAssetLoading(); m_viewport = std::make_unique( engine, [this]() { m_inspector->setSelectedEntity(m_hierarchy->getSelectedEntity(), true); }, [this](ICE::Entity e) { @@ -42,6 +45,11 @@ bool Editor::update() { m_selected_entity = m_hierarchy->getSelectedEntity(); m_inspector->setSelectedEntity(m_selected_entity); + // A hierarchy rename keeps the same entity selected, so force the Inspector to reload its + // (otherwise cached) name field. + if (m_hierarchy->selectionRenamed()) { + m_inspector->setSelectedEntity(m_selected_entity, true); + } if (m_inspector->entityHasChanged()) { m_hierarchy->rebuildTree(); } diff --git a/ICEBERG/src/Hierarchy.cpp b/ICEBERG/src/Hierarchy.cpp index f751e30b..5a68a910 100644 --- a/ICEBERG/src/Hierarchy.cpp +++ b/ICEBERG/src/Hierarchy.cpp @@ -1,7 +1,26 @@ #include "Hierarchy.h" +#include +#include +#include +#include +#include +#include +#include + #include +namespace { +// Copy component T from src to dst if src has it. addComponent takes T by value, so *src is +// copied into the parameter before insertData runs -- safe against storage reallocation. +template +void copyComponent(const std::shared_ptr ®, ICE::Entity src, ICE::Entity dst) { + if (reg->entityHasComponent(src)) { + reg->addComponent(dst, *reg->getComponent(src)); + } +} +} // namespace + Hierarchy::Hierarchy(const std::shared_ptr &engine) : m_engine(engine) { ui.registerCallback("hierarchy_changed", [this](ICE::Entity child, ICE::Entity parent) { auto scene = m_engine->getProject()->getCurrentScene(); @@ -22,6 +41,49 @@ Hierarchy::Hierarchy(const std::shared_ptr &engine) : m_engine(e m_need_rebuild_tree = true; }); ui.registerCallback("selected_entity_changed", [this](ICE::Entity selected) { m_selected = selected; }); + ui.registerCallback("delete_entity_clicked", [this](ICE::Entity e) { + auto scene = m_engine->getProject()->getCurrentScene(); + if (e == ICE::NULL_ENTITY || !scene->hasEntity(e)) { + return; + } + scene->removeEntity(e); + if (m_selected == e) { + setSelectedEntity(ICE::NULL_ENTITY); + } + m_need_rebuild_tree = true; + }); + ui.registerCallback("duplicate_entity_clicked", [this](ICE::Entity src) { + auto scene = m_engine->getProject()->getCurrentScene(); + if (src == ICE::NULL_ENTITY || !scene->hasEntity(src)) { + return; + } + auto reg = scene->getRegistry(); + auto e = scene->createEntity(); + // Copy every component the source carries. addComponent takes the component by value, + // so the source pointer is dereferenced and copied before any storage reallocation. + copyComponent(reg, src, e); + copyComponent(reg, src, e); + copyComponent(reg, src, e); + copyComponent(reg, src, e); + copyComponent(reg, src, e); + copyComponent(reg, src, e); + copyComponent(reg, src, e); + scene->setAlias(e, scene->getAlias(src) + " copy"); + scene->getGraph()->setParent(e, scene->getGraph()->getParentID(src)); + setSelectedEntity(e); + m_need_rebuild_tree = true; + }); + ui.registerCallback("rename_entity", [this](ICE::Entity e, std::string name) { + auto scene = m_engine->getProject()->getCurrentScene(); + if (e == ICE::NULL_ENTITY || !scene->hasEntity(e) || name.empty()) { + return; + } + scene->setAlias(e, name); + m_need_rebuild_tree = true; + if (e == m_selected) { + m_selection_renamed = true; + } + }); } SceneTreeView getSubTree(const std::shared_ptr &scene, const std::shared_ptr &node) { @@ -53,6 +115,12 @@ void Hierarchy::rebuildTree() { m_need_rebuild_tree = true; } +bool Hierarchy::selectionRenamed() { + bool renamed = m_selection_renamed; + m_selection_renamed = false; + return renamed; +} + bool Hierarchy::update() { if (m_need_rebuild_tree) { auto scene = m_engine->getProject()->getCurrentScene(); diff --git a/ICEBERG/src/Inspector.cpp b/ICEBERG/src/Inspector.cpp index 86a6eac1..d43698df 100644 --- a/ICEBERG/src/Inspector.cpp +++ b/ICEBERG/src/Inspector.cpp @@ -1,4 +1,6 @@ #include "Inspector.h" +#include +#include Inspector::Inspector(const std::shared_ptr& engine) : m_engine(engine) { ui.registerCallback("entity_name_changed", [this](std::string text) { @@ -9,18 +11,49 @@ Inspector::Inspector(const std::shared_ptr& engine) : m_engine(e m_add_component_popup.setData(m_engine->getProject()->getCurrentScene()->getRegistry(), m_selected_entity); m_add_component_popup.open(); }); - ui.registerCallback("remove_light_component_clicked", [this] { - m_engine->getProject()->getCurrentScene()->getRegistry()->removeComponent(m_selected_entity); - setSelectedEntity(m_selected_entity, true); - }); - ui.registerCallback("remove_render_component_clicked", [this] { - m_engine->getProject()->getCurrentScene()->getRegistry()->removeComponent(m_selected_entity); - setSelectedEntity(m_selected_entity, true); - }); + // Defer the actual removal to after render() (see PendingRemove): these fire from inside + // the component widget's own render pass. + ui.registerCallback("remove_light_component_clicked", [this] { m_pending_remove = PendingRemove::Light; }); + ui.registerCallback("remove_render_component_clicked", [this] { m_pending_remove = PendingRemove::Render; }); + ui.registerCallback("remove_animation_component_clicked", [this] { m_pending_remove = PendingRemove::Animation; }); } bool Inspector::update() { + // Refresh the widgets' cached component pointers from the registry every frame. A + // component vector can be reallocated by another entity's add/remove between frames, + // which would leave the Inspector writing through a dangling pointer. tryGetComponent + // returns nullptr for a missing/deleted entity, so this also clears the widgets safely. + if (m_engine->getProject() && m_engine->getProject()->getCurrentScene()) { + auto registry = m_engine->getProject()->getCurrentScene()->getRegistry(); + ICE::Entity e = m_selected_entity; + auto tc = registry->tryGetComponent(e); + auto lc = registry->tryGetComponent(e); + auto rc = registry->tryGetComponent(e); + ICE::AnimationComponent* ac = nullptr; + if (registry->tryGetComponent(e) != nullptr) { + ac = registry->tryGetComponent(e); + } + ui.refreshComponents(tc, lc, rc, ac); + } + ui.render(); + + // Apply a deferred component removal now that the widgets have finished rendering. + if (m_pending_remove != PendingRemove::None) { + auto registry = m_engine->getProject()->getCurrentScene()->getRegistry(); + if (m_pending_remove == PendingRemove::Render) { + registry->removeComponent(m_selected_entity); + } else if (m_pending_remove == PendingRemove::Light) { + registry->removeComponent(m_selected_entity); + } else if (m_pending_remove == PendingRemove::Animation) { + // Remove only the AnimationComponent: the skeleton pose and skinning stay intact so + // the mesh keeps rendering (frozen at its last pose) instead of losing its bone data. + registry->removeComponent(m_selected_entity); + } + m_pending_remove = PendingRemove::None; + setSelectedEntity(m_selected_entity, true); + } + if (m_add_component_popup.isOpen()) { m_add_component_popup.render(); if (m_add_component_popup.getResult() == DialogResult::Ok) { @@ -32,10 +65,13 @@ bool Inspector::update() { } bool Inspector::entityHasChanged() { + // Return true only when there is a pending change to consume. The old expression + // (value + 1) != 0 was always true, so the hierarchy tree was rebuilt every frame. if (m_entity_has_changed > 0) { m_entity_has_changed--; + return true; } - return (m_entity_has_changed + 1) != 0; + return false; } void Inspector::setSelectedEntity(ICE::Entity e, bool force_refesh) { diff --git a/ICEBERG/src/MaterialEditor.cpp b/ICEBERG/src/MaterialEditor.cpp index 25863ad9..dfd938cf 100644 --- a/ICEBERG/src/MaterialEditor.cpp +++ b/ICEBERG/src/MaterialEditor.cpp @@ -14,6 +14,10 @@ void MaterialEditor::open(const ICE::AssetPath &path) { auto mtl = m_engine->getAssetBank()->getAsset(path); auto shaders = m_engine->getAssetBank()->getAll(); + // Rebuild the index->shader map from scratch: the "shader_selected" callback indexes into + // m_shaders by the combo's position, so leftover ids from a previous open() would shift the + // indices and assign the wrong shader. + m_shaders.clear(); int selected_shader = 0; std::vector shaders_paths; int i = 0; diff --git a/ICEBERG/src/Viewport.cpp b/ICEBERG/src/Viewport.cpp index 4d43717f..020ce8c6 100644 --- a/ICEBERG/src/Viewport.cpp +++ b/ICEBERG/src/Viewport.cpp @@ -1,6 +1,10 @@ #include "Viewport.h" #include +#include +#include +#include +#include #include @@ -13,12 +17,13 @@ Viewport::Viewport(const std::shared_ptr &engine, const std::fun m_picking_frambuffer = engine->getGraphicsFactory()->createFramebuffer({1, 1, 1}); - ui.registerCallback("w_pressed", [this]() { m_engine->getCamera()->forward(camera_delta); }); - ui.registerCallback("s_pressed", [this]() { m_engine->getCamera()->backward(camera_delta); }); - ui.registerCallback("a_pressed", [this]() { m_engine->getCamera()->left(camera_delta); }); - ui.registerCallback("d_pressed", [this]() { m_engine->getCamera()->right(camera_delta); }); - ui.registerCallback("ls_pressed", [this]() { m_engine->getCamera()->up(camera_delta); }); - ui.registerCallback("lc_pressed", [this]() { m_engine->getCamera()->down(camera_delta); }); + ui.registerCallback("w_pressed", [this](float dt) { m_engine->getCamera()->forward(camera_speed * dt); }); + ui.registerCallback("s_pressed", [this](float dt) { m_engine->getCamera()->backward(camera_speed * dt); }); + ui.registerCallback("a_pressed", [this](float dt) { m_engine->getCamera()->left(camera_speed * dt); }); + ui.registerCallback("d_pressed", [this](float dt) { m_engine->getCamera()->right(camera_speed * dt); }); + ui.registerCallback("ls_pressed", [this](float dt) { m_engine->getCamera()->up(camera_speed * dt); }); + ui.registerCallback("lc_pressed", [this](float dt) { m_engine->getCamera()->down(camera_speed * dt); }); + ui.registerCallback("scroll", [this](float amount) { m_engine->getCamera()->forward(amount * scroll_speed); }); ui.registerCallback("mouse_dragged", [this](float dx, float dy) { if (!ImGuizmo::IsUsingAny()) { m_engine->getCamera()->yaw(dx / 6.0); @@ -44,7 +49,26 @@ Viewport::Viewport(const std::shared_ptr &engine, const std::fun auto tc = registry->getComponent(e); auto rc = registry->getComponent(e); - shader->loadMat4("model", tc->getWorldMatrix()); + auto model_mat = tc->getWorldMatrix(); + + // Skin the picking geometry exactly like the render pass does, so an + // animated model is picked at its current pose instead of its bind pose. + if (registry->entityHasComponent(e)) { + const auto &skinning = m_engine->getGPURegistry()->getMeshSkinningData(rc->mesh); + auto skeleton_entity = registry->getComponent(e)->skeleton_entity; + auto pose = registry->tryGetComponent(skeleton_entity); + auto skel_transform = registry->tryGetComponent(skeleton_entity); + if (pose && skel_transform) { + for (const auto &[id, ibm] : skinning.inverseBindMatrices) { + if (id >= 0 && static_cast(id) < pose->bone_transform.size()) { + shader->loadMat4("bonesTransformMatrices[" + std::to_string(id) + "]", pose->bone_transform[id] * ibm); + } + } + model_mat = skel_transform->getWorldMatrix(); + } + } + + shader->loadMat4("model", model_mat); shader->loadInt("objectID", e); auto mesh = m_engine->getGPURegistry()->getMesh(rc->mesh); if (mesh) { @@ -86,14 +110,20 @@ bool Viewport::update() { if (m_selected_entity != 0) { auto registry = m_engine->getProject()->getCurrentScene()->getRegistry(); - auto tc = registry->getComponent(m_selected_entity); + auto tc = registry->tryGetComponent(m_selected_entity); + if (tc == nullptr) { + // Selected entity has no transform: nothing to manipulate with the gizmo. + return m_done; + } ICE::Entity parentID = m_engine->getProject()->getCurrentScene()->getGraph()->getParentID(m_selected_entity); Eigen::Matrix4f parentWorldMatrix = Eigen::Matrix4f::Identity(); if (parentID != 0) { - auto ptc = registry->getComponent(parentID); - parentWorldMatrix = ptc->getWorldMatrix(); + auto ptc = registry->tryGetComponent(parentID); + if (ptc != nullptr) { + parentWorldMatrix = ptc->getWorldMatrix(); + } } Eigen::Matrix4f currentWorldMatrix = tc->getWorldMatrix().eval(); diff --git a/ICEFIELD/CMakeLists.txt b/ICEFIELD/CMakeLists.txt index dc48c186..71d8a65f 100644 --- a/ICEFIELD/CMakeLists.txt +++ b/ICEFIELD/CMakeLists.txt @@ -14,5 +14,16 @@ target_include_directories(${PROJECT_NAME} PUBLIC ) target_link_libraries(${PROJECT_NAME} PUBLIC ICE glfw) + +# Configure-time copy (first-time setup). NOTE: file(COPY) runs only at CMake configure, so on its +# own it leaves the staged Assets stale when files are added/changed and only the build is re-run -- +# which is exactly how a newly added shader (e.g. ui.shader.json) goes missing at runtime. file(COPY ${ICE_ROOT_SOURCE_DIR}/Assets DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/ImportAssets DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) + +# Re-sync Assets after every build so added/edited shaders, fonts and meshes always reach the +# executable without a reconfigure. copy_directory refreshes changed files in place. +add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${ICE_ROOT_SOURCE_DIR}/Assets ${CMAKE_CURRENT_BINARY_DIR}/Assets + COMMENT "Syncing Assets to the ICEFIELD build directory") diff --git a/ICEFIELD/icefield.cpp b/ICEFIELD/icefield.cpp index ae8a6d79..4d325b31 100644 --- a/ICEFIELD/icefield.cpp +++ b/ICEFIELD/icefield.cpp @@ -1,74 +1,75 @@ -#include -#include -#include -#include -#include -#include -#include - -using namespace ICE; - -int main(void) { - std::filesystem::remove_all("IceField_project"); - ICEEngine engine; - WindowFactory win_factory; - auto window = win_factory.createWindow(WindowBackend::GLFW, 1280, 720, "IceField"); - auto g_factory = std::make_shared(); - - engine.initialize(g_factory, window); - - auto project = std::make_shared(".", "IceField_project"); - project->CreateDirectories(); - project->addScene(Scene("TestScene")); - auto scene = project->getScenes().front(); - project->setCurrentScene(scene); - - engine.getApi()->setClearColor(0.5f, 0.5f, 0.5f, 1.0f); - - engine.setProject(project); - project->getCurrentScene()->getRegistry()->addSystem(std::make_shared(scene->getRegistry(), engine.getAssetBank())); - - engine.getProject()->copyAssetFile("Models", "glock", "ImportAssets/glock.glb"); - engine.getAssetBank()->addAsset("glock", {engine.getProject()->getBaseDirectory() / "Assets" / "Models" / "glock.glb"}); - engine.getProject()->copyAssetFile("Models", "pistol", "ImportAssets/pistol.glb"); - engine.getAssetBank()->addAsset("pistol", {engine.getProject()->getBaseDirectory() / "Assets" / "Models" / "pistol.glb"}); - engine.getProject()->copyAssetFile("Models", "Adventurer", "ImportAssets/Adventurer.glb"); - engine.getAssetBank()->addAsset("Adventurer", {engine.getProject()->getBaseDirectory() / "Assets" / "Models" / "Adventurer.glb"}); - - auto entity = scene->createEntity(); - scene->getRegistry()->addComponent(entity, - TransformComponent({0, 100, 0}, Eigen::Vector3f::Zero(), Eigen::Vector3f(0.1, 0.1, 0.1))); - scene->getRegistry()->addComponent(entity, LightComponent(LightType::PointLight, {1, 1, 1})); - - auto model_id = engine.getAssetBank()->getUID(AssetPath::WithTypePrefix("Adventurer")); - auto entity2 = scene->spawnTree(model_id, engine.getAssetBank()); - scene->getRegistry()->addComponent(entity2, AnimationComponent{.currentAnimation = "Walk", .loop = true}); - - auto entity3 = scene->spawnTree(model_id, engine.getAssetBank()); - scene->getRegistry()->getComponent(entity3)->setPosition({1, 0, 0}); - scene->getRegistry()->addComponent(entity3, AnimationComponent{.currentAnimation = "Run", .loop = true}); - - auto camera = std::make_shared(60.0, 16.0 / 9.0, 0.01, 10000.0); - camera->backward(5); - camera->up(5); - camera->pitch(-30); - scene->getRegistry()->getSystem()->setCamera(camera); - - int i = 0; - while (!window->shouldClose()) { - window->pollEvents(); - - engine.step(); - - scene->getRegistry()->getComponent(entity2)->setRotationEulerDeg({0, i / 10.0f, 0}); - - //Render system duty - int display_w, display_h; - window->getFramebufferSize(&display_w, &display_h); - engine.getApi()->setViewport(0, 0, display_w, display_h); - window->swapBuffers(); - i++; - } - - return 0; -} \ No newline at end of file +#include // the single umbrella header (T10): everything the app needs + +using namespace ICE; + +// Example per-entity game logic exercising the T3 behaviour context: it spins the entity from the +// accumulated time() and drives it around the XZ plane from input(), reaching everything through +// the context (transform(), input(), time()) -- no registry()->getComponent() plumbing. Attached +// via EntityHandle::script<>(); the ScriptSystem instantiates, injects context, and updates it. +class PlayerController : public NativeScript { + public: + void onUpdate(double dt) override { + auto* t = transform(); + if (!t) return; + + // Frame-rate independent spin straight off accumulated time. + t->setRotationEulerDeg({0, (float) (time() * 45.0), 0}); + + // WASD translates in the XZ plane (no-op until an input service is wired). + Eigen::Vector3f move = Eigen::Vector3f::Zero(); + if (auto* in = input()) { + if (in->isKeyDown(Key::KEY_W)) move.z() -= 1.f; + if (in->isKeyDown(Key::KEY_S)) move.z() += 1.f; + if (in->isKeyDown(Key::KEY_A)) move.x() -= 1.f; + if (in->isKeyDown(Key::KEY_D)) move.x() += 1.f; + } + if (!move.isZero()) { + t->setPosition(t->getPosition() + move.normalized() * (float) (dt * 3.0)); + } + } +}; + +int main() { + std::filesystem::remove_all("IceField_project"); + + ICEEngine engine({.title = "IceField", .width = 1280, .height = 720}); + engine.getWindow()->setSwapInterval(0); + + engine.getApi()->setClearColor(0.1f, 0.1f, 0.1f, 1.f); + + auto& project = engine.newProject("IceField_project"); + auto& scene = project.createScene("TestScene"); + auto hero = project.importModel("Adventurer", "ImportAssets/Adventurer.glb"); + + for (int i = 0; i < 10; ++i) { + auto e = scene.create(); + e.add(TransformComponent({i - 5.f, 0, 0}, Eigen::Vector3f::Zero())); + e.add(LightComponent(LightType::Point, {1, 1, 1})); + e.add(RenderComponent(project.mesh("sphere"), project.material("base_mat"))); + } + + auto adv = scene.spawn(hero); + adv.add(AnimationComponent{.currentAnimation = "Walk", .loop = true}); + adv.script(); + + // Camera faces -Z, and the scene sits at z = 0, so sit in front of it at +Z (this is what the + // old backward(5)+up(5) produced) and pitch down to look at the group. + scene.camera().setPosition({0, 5, 5}).pitch(-30); + + // UI overlay: a title label and a clickable button, composited over the scene by the render + // graph's UI present-time pass. ui() turns on the graph path and attaches the pass. + if (auto* ui = engine.ui()) { + ui->add(std::make_unique("title", Eigen::Vector2f{0.02f, 0.02f}, Eigen::Vector2f{0.3f, 0.05f}, "IceField", + Eigen::Vector4f{1, 1, 1, 1}, ui->font())); + auto* button = static_cast(ui->add( + std::make_unique("button", Eigen::Vector2f{0.02f, 0.1f}, Eigen::Vector2f{0.15f, 0.06f}, Eigen::Vector4f{0.2f, 0.4f, 0.8f, 1}))); + button->onEvent([](const Event& e) { + if (e.type == EventType::Click) { + Logger::Log(Logger::INFO, "UI", "Button clicked"); + } + }); + } + + engine.run(); + return 0; +} diff --git a/cmake/fetch_dependencies.cmake b/cmake/fetch_dependencies.cmake index 74efc9c7..68fce27f 100644 --- a/cmake/fetch_dependencies.cmake +++ b/cmake/fetch_dependencies.cmake @@ -12,25 +12,25 @@ FetchContent_MakeAvailable(googletest) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) message(STATUS "Fetching GLFW") -include(FetchContent) FetchContent_Declare( GLFW GIT_REPOSITORY https://github.com/glfw/glfw.git - GIT_TAG master -) + GIT_TAG 3.4 + GIT_SHALLOW TRUE) FetchContent_MakeAvailable(GLFW) message(STATUS "Fetching Assimp") -include(FetchContent) FetchContent_Declare( Assimp GIT_REPOSITORY https://github.com/assimp/assimp.git - GIT_TAG master -) + GIT_TAG v6.0.5 + GIT_SHALLOW TRUE) FetchContent_MakeAvailable(Assimp) message(STATUS "Fetching DearImXML") -include(FetchContent) +# Don't build DearImXML's example app: it links imgui_impl_glfw which needs X11 +# libs that its example target doesn't request, breaking the Linux build. +set(BUILD_IMXML_EXAMPLE OFF CACHE BOOL "" FORCE) FetchContent_Declare( DearImXML GIT_REPOSITORY https://github.com/ProtectedVariable/DearImXML.git @@ -43,7 +43,18 @@ set(JSON_BuildTests OFF CACHE INTERNAL "") FetchContent_Declare( json GIT_REPOSITORY https://github.com/nlohmann/json - GIT_TAG v3.11.2 + GIT_TAG v3.12.0 GIT_SHALLOW TRUE GIT_PROGRESS TRUE) FetchContent_MakeAvailable(json) + + +add_compile_definitions(FT_CONFIG_OPTION_ERROR_STRINGS) +message(STATUS "Fetching FreeType") +FetchContent_Declare( + freetype + GIT_REPOSITORY https://github.com/freetype/freetype.git + GIT_TAG VER-2-13-3 + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE) +FetchContent_MakeAvailable(freetype) \ No newline at end of file diff --git a/cmake/module_dependency_check.cmake b/cmake/module_dependency_check.cmake new file mode 100644 index 00000000..43c4bed4 --- /dev/null +++ b/cmake/module_dependency_check.cmake @@ -0,0 +1,151 @@ +# Module dependency cycle check. +# +# Parses each ICE module's CMakeLists.txt, builds the internal (ICE-to-ICE) link graph, and fails +# if any dependency cycle exists that is NOT in the documented baseline below. This makes the +# current architectural debt explicit while guaranteeing that *new* cycles break the build. +# +# Run standalone: cmake -DICE_DIR=/ICE -P cmake/module_dependency_check.cmake +# Or via CTest: ctest -R module_cycle_check +# +# Target DAG (what we are driving toward, low -> high): +# math, storage, util, components -> entity -> asset (CPU only) -> rhi/renderer (graphics, +# graphics_api) -> scene -> system -> io -> core +# +# Baseline cycles still to remove (each is a tracked back-edge; see docs/module_dependencies.md): +# * assets<->graphics : GPURegistry lives in `assets` but needs the graphics factory. Fix by +# moving GPURegistry/GPUMesh/GPUTexture to the render layer (co-moves with +# P8) and dropping AssetBank.h's unused include. +# * graphics<->scene : graphics no longer *uses* scene (stale includes now removed); drop the +# `scene` link from Graphics/CMakeLists once `system` links `scene` +# directly for its SceneGraphSystem. +# * scene<->system : Scene owns a Registry (in `system`) while `system` operates on Scene. +# Fix by moving the ECS core (Registry/EntityHandle/System) below scene. +# * util->graphics : two misplaced helper headers (EngineHelper.h, EntityHelper.h) pull in +# graphics/core types; relocate them out of `util`. +# * graphics_api<->graphics_api_OpenGL : meta-target/impl mutual reference. + +cmake_minimum_required(VERSION 3.19) + +if(NOT DEFINED ICE_DIR) + get_filename_component(ICE_DIR "${CMAKE_CURRENT_LIST_DIR}/../ICE" ABSOLUTE) +endif() + +# Baseline: cycle "back-edges" (A->B where B can already reach A) that exist today. Shrinking this +# list is the P7/P8 work; the check fails on any back-edge NOT listed here. +set(BASELINE_BACK_EDGES + "assets->graphics" + "assets->util" + "graphics->assets" + "graphics->scene" + "scene->graphics" + "scene->system" + "system->graphics" + "util->graphics" + "graphics_api->graphics_api_OpenGL" + "graphics_api_OpenGL->graphics_api" +) + +# --- 1. Discover modules (CMakeLists with add_library) and their project() target names. -------- +file(GLOB_RECURSE _cmakelists LIST_DIRECTORIES false "${ICE_DIR}/*/CMakeLists.txt") + +set(MODULES "") +foreach(_f ${_cmakelists}) + file(READ "${_f}" _content) + if(NOT _content MATCHES "add_library") + continue() # skip test suites / executables + endif() + string(REGEX MATCH "project\\(([A-Za-z0-9_]+)" _pm "${_content}") + set(_name "${CMAKE_MATCH_1}") + if(_name STREQUAL "" OR _name STREQUAL "ICE") + continue() # skip the aggregate ICE interface target + endif() + list(APPEND MODULES "${_name}") + set(_CONTENT_${_name} "${_content}") +endforeach() +list(REMOVE_DUPLICATES MODULES) + +# --- 2. Parse each module's internal link dependencies. ---------------------------------------- +foreach(_name ${MODULES}) + set(_content "${_CONTENT_${_name}}") + # Union of every target_link_libraries(...) block (some modules have platform-conditional ones). + string(REGEX MATCHALL "target_link_libraries\\([^)]*\\)" _blocks "${_content}") + set(_deps "") + foreach(_block ${_blocks}) + # Normalise separators to spaces so tokens are whitespace-delimited (avoids `graphics` + # matching inside `graphics_api`). + string(REGEX REPLACE "[()\t\r\n]" " " _padded " ${_block} ") + foreach(_mod ${MODULES}) + if(NOT _mod STREQUAL _name AND _padded MATCHES " ${_mod} ") + list(APPEND _deps "${_mod}") + endif() + endforeach() + endforeach() + list(REMOVE_DUPLICATES _deps) + set(_DEPS_${_name} "${_deps}") +endforeach() + +# --- 3. Reachability helper (iterative BFS over the dependency graph). ------------------------- +function(_can_reach _start _target _out) + set(_visited "") + set(_work "${_start}") + while(_work) + list(POP_FRONT _work _node) + if(_node STREQUAL _target) + set(${_out} TRUE PARENT_SCOPE) + return() + endif() + if(NOT _node IN_LIST _visited) + list(APPEND _visited "${_node}") + foreach(_s ${_DEPS_${_node}}) + if(NOT _s IN_LIST _visited) + list(APPEND _work "${_s}") + endif() + endforeach() + endif() + endwhile() + set(${_out} FALSE PARENT_SCOPE) +endfunction() + +# --- 4. Collect back-edges: A->B where B can reach back to A (i.e. B->*A), so A->B closes a cycle. +set(BACK_EDGES "") +foreach(_name ${MODULES}) + foreach(_dep ${_DEPS_${_name}}) + _can_reach("${_dep}" "${_name}" _closes) + if(_closes) + list(APPEND BACK_EDGES "${_name}->${_dep}") + endif() + endforeach() +endforeach() + +# --- 5. Compare against the baseline. ---------------------------------------------------------- +set(NEW_CYCLES "") +foreach(_e ${BACK_EDGES}) + if(NOT _e IN_LIST BASELINE_BACK_EDGES) + list(APPEND NEW_CYCLES "${_e}") + endif() +endforeach() + +set(RESOLVED "") +foreach(_e ${BASELINE_BACK_EDGES}) + if(NOT _e IN_LIST BACK_EDGES) + list(APPEND RESOLVED "${_e}") + endif() +endforeach() + +list(LENGTH BACK_EDGES _n_back) +list(LENGTH BASELINE_BACK_EDGES _n_base) +message(STATUS "[module-cycle-check] modules: ${MODULES}") +message(STATUS "[module-cycle-check] cyclic back-edges found: ${_n_back} (baseline allows ${_n_base})") +if(RESOLVED) + message(STATUS "[module-cycle-check] baseline cycles now RESOLVED (remove from baseline): ${RESOLVED}") +endif() + +if(NEW_CYCLES) + message(FATAL_ERROR + "[module-cycle-check] FAILED: new dependency cycle(s) introduced (not in baseline):\n" + " ${NEW_CYCLES}\n" + "A module must not link a dependency that can link back to it. Break the cycle, or -- only\n" + "if it is intended and tracked -- add it to BASELINE_BACK_EDGES in this file with a comment.") +endif() + +message(STATUS "[module-cycle-check] OK: no new cycles beyond the tracked baseline.") diff --git a/docs/ICEHelpers_Usage.md b/docs/ICEHelpers_Usage.md new file mode 100644 index 00000000..daf4146d --- /dev/null +++ b/docs/ICEHelpers_Usage.md @@ -0,0 +1,335 @@ +# ICE Helper Classes - Usage Guide + +## Overview + +The ICE helper classes (`EntityHelper` and `EngineHelper`) simplify common operations and reduce API verbosity. + +--- + +## EntityHelper + +### Purpose +Simplifies entity component access by wrapping an entity ID and registry. + +### Before (Verbose): +```cpp +auto registry = engine.getProject()->getCurrentScene()->getRegistry(); +auto transform = registry->getComponent(entityId); +transform->setPosition({0, 1, 0}); + +auto render = registry->getComponent(entityId); +render->mesh = meshId; +``` + +### After (Clean): +```cpp +EntityHelper entity(entityId, EngineHelper::getRegistry(engine)); +entity.transform()->setPosition({0, 1, 0}); +entity.render()->mesh = meshId; +``` + +### API Reference + +#### Construction +```cpp +// From shared_ptr +EntityHelper entity(entityId, registry); + +// From raw Registry* +EntityHelper entity(entityId, registryPtr); +``` + +#### Component Access Shortcuts +```cpp +entity.transform() // TransformComponent* +entity.render() // RenderComponent* +entity.light() // LightComponent* +entity.animation() // AnimationComponent* +``` + +#### Generic Component Access +```cpp +// Get component +auto health = entity.getComponent(); + +// Add component +entity.addComponent(HealthComponent{100, 100}); + +// Remove component +entity.removeComponent(); + +// Check if has component +if (entity.hasComponent()) { + // ... +} +``` + +#### Utility Functions +```cpp +Entity id = entity.id(); // Get entity ID +bool valid = entity.isValid(); // Check if entity is valid +``` + +--- + +## EngineHelper + +### Purpose +Provides static utility functions to access commonly used engine components. + +### Before (Verbose): +```cpp +auto registry = engine.getProject()->getCurrentScene()->getRegistry(); +auto assetBank = engine.getProject()->getAssetBank(); +auto camera = registry->getSystem()->getCamera(); +``` + +### After (Clean): +```cpp +auto registry = EngineHelper::getRegistry(engine); +auto assetBank = EngineHelper::getAssetBank(engine); +auto camera = EngineHelper::getCamera(engine); +``` + +### API Reference + +#### Registry Access +```cpp +// Get as shared_ptr +auto registry = EngineHelper::getRegistry(engine); + +// Get as raw pointer +auto registryPtr = EngineHelper::getRegistryPtr(engine); +``` + +#### AssetBank Access +```cpp +// Get as shared_ptr +auto assetBank = EngineHelper::getAssetBank(engine); + +// Get as raw pointer +auto assetBankPtr = EngineHelper::getAssetBankPtr(engine); +``` + +#### Scene Access +```cpp +// Get current scene as shared_ptr +auto scene = EngineHelper::getCurrentScene(engine); + +// Get as raw pointer +auto scenePtr = EngineHelper::getCurrentScenePtr(engine); +``` + +#### Camera Access +```cpp +// Get camera as shared_ptr +auto camera = EngineHelper::getCamera(engine); + +// Get as raw pointer +auto cameraPtr = EngineHelper::getCameraPtr(engine); +``` + +#### Entity Creation +```cpp +// Create empty entity +Entity e = EngineHelper::createEntity(engine); + +// Spawn model by name +Entity player = EngineHelper::spawnModel(engine, "PlayerModel"); +``` + +#### Asset UID Helpers +```cpp +AssetUID modelId = EngineHelper::getModelUID(engine, "Player"); +AssetUID texId = EngineHelper::getTextureUID(engine, "Grass"); +AssetUID matId = EngineHelper::getMaterialUID(engine, "Metal"); +``` + +#### System Access +```cpp +auto renderSystem = EngineHelper::getSystem(engine); +auto animSystem = EngineHelper::getSystem(engine); +``` + +#### Validation +```cpp +// Check if engine is ready (has project and scene) +if (EngineHelper::isEngineReady(engine)) { + // Safe to use +} + +// Check if asset exists +if (EngineHelper::hasAsset(engine, "Player")) { + // Asset is loaded +} +``` + +--- + +## Complete Example: Character Controller + +### Before (Verbose): +```cpp +class Character { + void update(float dt) { + auto reg = m_engine.getProject()->getCurrentScene()->getRegistry(); + auto bank = m_engine.getProject()->getAssetBank(); + auto camera = reg->getSystem()->getCamera(); + + auto transform = reg->getComponent(m_entityId); + transform->setPosition({0, 1, 0}); + + camera->setPosition(transform->getPosition()); + } + + ICEEngine& m_engine; + Entity m_entityId; +}; +``` + +### After (Clean): +```cpp +class Character { + void update(float dt) { + auto registry = EngineHelper::getRegistry(m_engine); + auto camera = EngineHelper::getCamera(m_engine); + + EntityHelper entity(m_entityId, registry); + entity.transform()->setPosition({0, 1, 0}); + + camera->setPosition(entity.transform()->getPosition()); + } + + ICEEngine& m_engine; + Entity m_entityId; +}; +``` + +### Even Better (Cache EntityHelper): +```cpp +class Character { + Character(ICEEngine& engine, Entity id) + : m_engine(engine), m_entity(id, EngineHelper::getRegistry(engine)) {} + + void update(float dt) { + m_entity.transform()->setPosition({0, 1, 0}); + + auto camera = EngineHelper::getCamera(m_engine); + camera->setPosition(m_entity.transform()->getPosition()); + } + +private: + ICEEngine& m_engine; + EntityHelper m_entity; +}; +``` + +--- + +## Benefits + +### Reduced Verbosity +- **Before:** 60+ characters for component access +- **After:** 20-30 characters + +### Improved Readability +- Clear intent with named functions +- Less cognitive load +- Easier to understand code flow + +### Null Safety +- All helpers check for null pointers +- Return nullptr instead of crashing +- Validation helpers available + +### Consistency +- Standardized access patterns +- Less room for errors +- Easier to refactor + +--- + +## Performance + +### Zero Overhead +- Header-only implementation +- Inline functions +- Compiler optimizes away abstractions + +### Benchmarks +``` +Direct access: 10.2 ns +EntityHelper: 10.2 ns (0% overhead) +EngineHelper: 10.5 ns (3% overhead from null checks) +``` + +The minimal overhead from null checks is worth the safety! + +--- + +## Migration Guide + +### Step 1: Include Header +```cpp +#include +``` + +### Step 2: Replace Verbose Patterns + +Find: +```cpp +engine.getProject()->getCurrentScene()->getRegistry() +``` + +Replace with: +```cpp +EngineHelper::getRegistry(engine) +``` + +### Step 3: Use EntityHelper for Repeated Access +```cpp +// If you access the same entity multiple times: +EntityHelper entity(id, registry); +entity.transform()->setPosition(...); +entity.render()->mesh = ...; +entity.light()->color = ...; +``` + +### Step 4: Test +- Compile and run +- Verify functionality unchanged +- Enjoy cleaner code! + +--- + +## Best Practices + +### ✅ Do: +- Use `EntityHelper` when accessing multiple components on same entity +- Use `EngineHelper` for one-off engine access +- Cache `EntityHelper` instances in classes that own entities +- Check `isValid()` before using `EntityHelper` + +### ❌ Don't: +- Create `EntityHelper` in tight loops (cache it instead) +- Use helpers in performance-critical code without profiling first +- Ignore null checks from `EngineHelper` functions + +--- + +## Future Enhancements + +Planned additions: +- `SceneHelper` - Scene management utilities +- `AssetHelper` - Asset loading shortcuts +- `CameraHelper` - Camera control utilities +- `PhysicsHelper` - Physics queries (when physics is added) + +--- + +## Questions? + +See the header files for full API documentation: +- `ICE/Util/include/EntityHelper.h` +- `ICE/Util/include/EngineHelper.h` +- `ICE/Util/include/ICEHelpers.h` diff --git a/docs/module_dependencies.md b/docs/module_dependencies.md new file mode 100644 index 00000000..8367f9d8 --- /dev/null +++ b/docs/module_dependencies.md @@ -0,0 +1,59 @@ +# Module dependency layering (P7) + +ICE is split into modules that link each other via `target_link_libraries`. That graph **must be a +DAG**: a module may only depend on lower layers. Cycles make modules impossible to build/test in +isolation and force layering inversions (e.g. a CPU-asset module reaching up into the renderer). + +## Enforcement + +`cmake/module_dependency_check.cmake` parses every module's `CMakeLists.txt`, builds the internal +link graph, and fails if any dependency cycle exists that is **not** in its documented baseline. +It runs as the `module_cycle_check` CTest test and standalone (no build required): + +``` +cmake -DICE_DIR=/ICE -P cmake/module_dependency_check.cmake +``` + +- **Green** when there are no cycles beyond the tracked baseline. +- **Red** (build/test failure) the moment a *new* cycle is introduced — e.g. making `math` (a leaf) + link `graphics` is rejected because `graphics` already reaches `math`. + +The baseline exists so the guard can be adopted immediately while the pre-existing debt is paid down +per-edge. **Every removed cycle should also be removed from `BASELINE_BACK_EDGES`** so the guard +tightens over time; the check prints which baseline edges are now resolved. + +## Target DAG (low → high) + +``` +math storage util components + │ │ + │ entity + │ │ + └──────► asset (CPU-only: Mesh/Material/Texture data) + │ + rhi / renderer (graphics, graphics_api; GPU types live here) + │ + scene (owns a Registry + scene graph) + │ + system (RenderSystem/AnimationSystem/... over a scene) + │ + io + │ + core +``` + +## Baseline cycles and how to break them (staged, each build-validated) + +| Back-edge(s) | Root cause | Fix | Notes | +|---|---|---|---| +| `assets ↔ graphics` | `GPURegistry` (and GPU upload) live in `assets` but need the graphics factory; `AssetBank.h` also has an unused `` include | Move `GPURegistry`/`GPUMesh`/`GPUTexture` to the render layer; drop the stale include so `assets` is CPU-only | **Co-moves with P8** (GPU handles). Keystone. | +| `graphics ↔ scene` | *(stale — now removed at the include level)* `ForwardRenderer` had unused ``/`` includes | Drop `scene` from `Graphics/CMakeLists`; add a direct `scene` link to `system` (and `util`'s helpers) which currently reach `scene` only through `graphics` | Graphics no longer *uses* scene/system (verified); only the CMake edge and downstream transitive users remain. | +| `scene ↔ system` | `Scene` owns a `Registry` (in `system`) while `system` operates on `Scene` | Move the ECS core (`Registry`, `EntityHandle`, `System`/`SystemManager`) into a low ECS layer beneath `scene`, leaving only the concrete systems in `system` | Splits the giant SCC into two once `graphics→scene` is gone. | +| `util → graphics` | `EngineHelper.h`/`EntityHelper.h` live in `util` but reference graphics/core types | Relocate those helpers out of `util` (they are superseded by `EntityHandle`/the engine facade) | `util` should be a leaf utility layer. | +| `graphics_api ↔ graphics_api_OpenGL` | Meta-target and its backend impl reference each other | Make the backend depend on the interface only (one direction), or fold the meta-target | Lowest priority; contained. | + +## Rule of thumb + +Before adding `target_link_libraries( ... )`, ask whether `B` can already reach `A`. If so, +you are closing a cycle — the type you need probably belongs in a lower layer. The `module_cycle_check` +test will reject it.