diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 4f8b700e..1763d49b 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -4,71 +4,38 @@ 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 \ No newline at end of file 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..76d9435e 100644 --- a/ICE/Assets/CMakeLists.txt +++ b/ICE/Assets/CMakeLists.txt @@ -8,18 +8,25 @@ 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/GPURegistry.cpp + src/AudioClip.cpp src/Texture.cpp) -target_link_libraries(${PROJECT_NAME} - PUBLIC - graphics +target_link_libraries(${PROJECT_NAME} + PUBLIC + graphics util - io storage + math + # GPURegistry stores its GPU resources in HandlePools (see ICE/Container). + container + # Async import stages loads on the JobScheduler (header-only Multithreading module). + multithreading ) target_include_directories(${PROJECT_NAME} PUBLIC diff --git a/ICE/Assets/include/Asset.h b/ICE/Assets/include/Asset.h index 4ef9e3ca..104367a6 100644 --- a/ICE/Assets/include/Asset.h +++ b/ICE/Assets/include/Asset.h @@ -14,7 +14,7 @@ namespace ICE { typedef unsigned long long AssetUID; -enum class AssetType { EModel, EMesh, EMaterial, ETex2D, ETexCube, EShader, EOther }; +enum class AssetType { EModel, EMesh, EMaterial, ETex2D, ETexCube, EShader, EAudioClip, EOther }; class Asset : public Resource { 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/AudioClip.h b/ICE/Assets/include/AudioClip.h new file mode 100644 index 00000000..02bffe71 --- /dev/null +++ b/ICE/Assets/include/AudioClip.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include + +#include "Asset.h" + +namespace ICE { + +// A CPU-side sound: interleaved signed 16-bit PCM plus its format. This is the audio counterpart +// of Mesh/Texture -- pure data, no device object. The backend-resident buffer uploaded from it is +// owned by AudioRegistry, exactly as GPURegistry owns the GPU upload of a Mesh. +// +// 16-bit interleaved is the one format every OpenAL implementation accepts without extensions, so +// the decoders normalize to it (see AudioDecoder.h) and nothing downstream has to convert. +class AudioClip : public Asset { + public: + AudioClip() = default; + AudioClip(std::vector samples, uint32_t channels, uint32_t sampleRate); + + // Streaming clip: format and length are known from the file header, but no PCM is resident -- + // it is decoded incrementally at playback time. This is how multi-minute music avoids holding + // tens of megabytes of samples in RAM. `frameCount` may be 0 if the format cannot report a + // length cheaply; nothing depends on it being exact. + static AudioClip Streaming(uint32_t channels, uint32_t sampleRate, uint64_t frameCount); + + AssetType getType() const override { return AssetType::EAudioClip; } + std::string getTypeName() const override { return "AudioClip"; } + + const std::vector& samples() const { return m_samples; } + uint32_t getChannels() const { return m_channels; } + uint32_t getSampleRate() const { return m_sample_rate; } + + uint64_t getFrameCount() const; + double getDuration() const; + std::size_t sizeBytes() const { return m_samples.size() * sizeof(int16_t); } + + // OpenAL only spatializes MONO buffers -- a stereo buffer plays flat, at full volume, + // ignoring listener and source position entirely. This is the single most common "why is my + // 3D audio not working" cause, so the check is part of the asset's public surface and is + // enforced where a clip is used spatially rather than being left to callers to remember. + bool isMono() const { return m_channels == 1; } + + // Decoded on demand rather than held in memory. A streaming clip has no samples() to upload: + // playback goes through IAudioStream and a queued-buffer voice instead. + bool isStreaming() const { return m_streaming; } + + // A clip with nothing usable: a failed decode, or a default-constructed placeholder. A + // streaming clip is NOT empty despite having no samples -- its content lives in its source + // file, so emptiness is decided by the format instead. + bool isEmpty() const { return m_streaming ? m_channels == 0 : (m_samples.empty() || m_channels == 0); } + + private: + std::vector m_samples; // interleaved, channels * frameCount entries; empty if streaming + uint32_t m_channels = 0; + uint32_t m_sample_rate = 0; + bool m_streaming = false; + uint64_t m_streaming_frames = 0; // header-reported length; only meaningful when streaming +}; + +} // namespace ICE 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..807f30db 100644 --- a/ICE/Assets/src/AssetPath.cpp +++ b/ICE/Assets/src/AssetPath.cpp @@ -4,6 +4,8 @@ #include "AssetPath.h" +#include +#include #include #include #include @@ -16,7 +18,38 @@ std::unordered_map AssetPath::typenames = {{typeid {typeid(Mesh), "Meshes"}, {typeid(Model), "Models"}, {typeid(Material), "Materials"}, - {typeid(Shader), "Shaders"}}; + {typeid(Shader), "Shaders"}, + {typeid(AudioClip), "Audio"}}; + +std::unordered_map AssetPath::prefixes = {{"Textures", typeid(Texture2D)}, + {"CubeMaps", typeid(TextureCube)}, + {"Meshes", typeid(Mesh)}, + {"Models", typeid(Model)}, + {"Materials", typeid(Material)}, + {"Shaders", typeid(Shader)}, + {"Audio", typeid(AudioClip)}}; + +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; @@ -27,10 +60,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 +77,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/AudioClip.cpp b/ICE/Assets/src/AudioClip.cpp new file mode 100644 index 00000000..3ed24175 --- /dev/null +++ b/ICE/Assets/src/AudioClip.cpp @@ -0,0 +1,36 @@ +#include "AudioClip.h" + +namespace ICE { + +AudioClip::AudioClip(std::vector samples, uint32_t channels, uint32_t sampleRate) + : m_samples(std::move(samples)), + m_channels(channels), + m_sample_rate(sampleRate) {} + +AudioClip AudioClip::Streaming(uint32_t channels, uint32_t sampleRate, uint64_t frameCount) { + AudioClip clip; + clip.m_channels = channels; + clip.m_sample_rate = sampleRate; + clip.m_streaming = true; + clip.m_streaming_frames = frameCount; + return clip; +} + +uint64_t AudioClip::getFrameCount() const { + if (m_streaming) { + return m_streaming_frames; // from the file header; no samples are resident + } + if (m_channels == 0) { + return 0; + } + return static_cast(m_samples.size()) / m_channels; +} + +double AudioClip::getDuration() const { + if (m_sample_rate == 0) { + return 0.0; + } + return static_cast(getFrameCount()) / m_sample_rate; +} + +} // namespace ICE 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/Audio/CMakeLists.txt b/ICE/Audio/CMakeLists.txt new file mode 100644 index 00000000..4b59cd4a --- /dev/null +++ b/ICE/Audio/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.19) +project(audio) + +message(STATUS "Building ${PROJECT_NAME} module") + +add_library(${PROJECT_NAME} STATIC) + +target_sources(${PROJECT_NAME} PRIVATE + src/audio_decoders_impl.cpp + src/AudioDecoder.cpp + src/AudioClipLoader.cpp + src/AudioRegistry.cpp + src/AudioEngine.cpp + src/FileAudioStream.cpp +) + +# Backend-agnostic audio layer. Deliberately does NOT link `graphics`, `scene` or `system`: the +# only scene coupling in the audio stack lives in AudioSystem (the `system` module). See +# docs/module_dependencies.md. +target_link_libraries(${PROJECT_NAME} + PUBLIC + assets # AudioClip is a CPU-side Asset, and this is where AssetUID/AssetPath live + math + container # VoicePool is a HandlePool (generational handles, same as the GPU pools) + multithreading # streaming decode runs as detached jobs on the shared JobScheduler + PRIVATE + dr_libs # wav / mp3 / flac decoding -- implementation detail, not in the public headers + stb_vorbis # ogg vorbis decoding +) + +target_include_directories(${PROJECT_NAME} PUBLIC + $ + $) + +enable_testing() +add_subdirectory(test) diff --git a/ICE/Audio/include/AudioClipLoader.h b/ICE/Audio/include/AudioClipLoader.h new file mode 100644 index 00000000..37571992 --- /dev/null +++ b/ICE/Audio/include/AudioClipLoader.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace ICE { + +// Decodes WAV/MP3/FLAC/OGG into an AudioClip. Lives in the `audio` module (not `io`, where the +// other loaders are) because the decoder libraries are a PRIVATE dependency of this module and +// must not leak into another module's include path. It is registered onto the bank from +// io/DefaultLoaders.cpp along with the rest. +// +// This is a pure loader -- it decodes and returns, without touching the bank -- so it works with +// AssetBank::requestAsset's convenience overload and gets background decoding on the JobScheduler +// for free. +class AudioClipLoader : public IAssetLoader { + public: + // Returns nullptr on an unreadable file, an unsupported extension, or a decode failure. The + // bank rejects a null result rather than inserting an unusable asset. + std::shared_ptr load(const std::vector& files) override; + + // Encoded-file size at or above which a clip is loaded as a STREAM rather than decoded into + // memory. Compared against the file on disk (knowable without decoding), so it is a proxy for + // decoded size rather than an exact budget -- which is fine, since the point is only to keep + // music out of resident memory while short effects stay resident and instantly playable. + // + // Settable so an application can tune it (or a test can force either path). + static std::size_t streamingThresholdBytes(); + static void setStreamingThresholdBytes(std::size_t bytes); +}; + +} // namespace ICE diff --git a/ICE/Audio/include/AudioDecoder.h b/ICE/Audio/include/AudioDecoder.h new file mode 100644 index 00000000..1c91e39c --- /dev/null +++ b/ICE/Audio/include/AudioDecoder.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +namespace ICE { + +// Decoded PCM, in the one format every OpenAL implementation accepts without extensions: +// signed 16-bit, interleaved. Float output would need AL_EXT_FLOAT32, so the conversion is done +// here, once, at decode time rather than being pushed onto the backend. +struct DecodedAudio { + std::vector samples; // interleaved, channels * frameCount entries + uint32_t channels = 0; + uint32_t sampleRate = 0; + uint64_t frameCount = 0; + + double duration() const { return sampleRate == 0 ? 0.0 : static_cast(frameCount) / sampleRate; } +}; + +// Decode a WAV / MP3 / FLAC / OGG file to interleaved 16-bit PCM, dispatching on the file +// extension. Returns nullopt if the extension is unknown or the decode fails; the caller logs. +// +// This is a blocking, CPU-bound whole-file decode -- it is what AssetBank::requestAsset stages on +// the JobScheduler off the main thread. Streaming (incremental decode for music) is separate and +// does not go through here. +std::optional DecodeAudioFile(const std::filesystem::path& file); + +// Extensions DecodeAudioFile understands, lowercase and dot-prefixed (".wav", ...). Used by the +// asset browser to filter importable files. +const std::vector& SupportedAudioExtensions(); + +// Average multi-channel audio down to one channel in place. Needed because OpenAL positions MONO +// buffers only -- a stereo buffer is played flat at full volume, ignoring the listener entirely. +// A clip intended for 3D playback therefore has to be mono before it reaches the device, and doing +// it here (once, at import) beats discovering it as a "3D audio doesn't work" bug later. +// No-op if the audio is already mono. +void DownmixToMono(DecodedAudio& audio); + +} // namespace ICE diff --git a/ICE/Audio/include/AudioEngine.h b/ICE/Audio/include/AudioEngine.h new file mode 100644 index 00000000..95b9c385 --- /dev/null +++ b/ICE/Audio/include/AudioEngine.h @@ -0,0 +1,184 @@ +#pragma once + +#include +#include +#include +#include + +#include "AudioRegistry.h" +#include "IAudioBackend.h" + +namespace ICE { + +// The common knobs for a fire-and-forget sound, so callers don't have to fill a whole VoiceDesc: +// engine.audio()->play(clipId, {.volume = 0.5f, .loop = true}); +struct PlayParams { + float volume = 1.0f; + float pitch = 1.0f; + bool loop = false; + BusId bus = BusId::SFX; + uint8_t priority = 128; + // Ramp from silence up to `volume` over this many seconds. 0 starts at full volume. + float fadeInSeconds = 0.0f; +}; + +// The engine's audio facade and the owner of voice POLICY. The backend reports that it is out of +// sources; deciding which existing sound dies so a new one can play happens here, in one place, +// independent of which backend is attached. +// +// Everything on this class is main-thread only. The backend mixes on its own thread, but no call +// here touches that thread's state directly. +class AudioEngine { + public: + AudioEngine(const std::shared_ptr& backend, const std::shared_ptr& bank); + ~AudioEngine(); + + AudioEngine(const AudioEngine&) = delete; + AudioEngine& operator=(const AudioEngine&) = delete; + + // --- Playback ------------------------------------------------------------------------------- + // 2D (head-relative, unattenuated) playback -- music, UI, narration. Works with stereo clips. + VoiceHandle play(AssetUID clip, const PlayParams& params = {}); + + // Positional playback. The clip MUST be mono: OpenAL silently refuses to spatialize a stereo + // buffer and plays it flat at full volume instead. A stereo clip here is logged and played 2D + // rather than pretending to be positioned. + VoiceHandle playAt(AssetUID clip, const Eigen::Vector3f& position, const PlayParams& params = {}); + + // Full control, for callers that build their own VoiceDesc (the phase 2 AudioSystem). + VoiceHandle playVoice(const VoiceDesc& desc); + + void stop(VoiceHandle voice); + void stopAll(); + void pause(VoiceHandle voice); + void resume(VoiceHandle voice); + bool isPlaying(VoiceHandle voice) const; + + // --- Fades ---------------------------------------------------------------------------------- + // Ramp a live voice's gain over `seconds`. Gains here are the voice's AUTHORED gain (the same + // scale as PlayParams::volume); bus and master scaling are applied on top as usual, so a fade + // and a mixer change compose instead of fighting. + // + // Ramps are advanced in update(), so they cost nothing until they are used and are unaffected + // by how the backend mixes. + void fadeTo(VoiceHandle voice, float targetGain, float seconds); + void fadeIn(VoiceHandle voice, float targetGain, float seconds); + // Ramp to silence and then STOP the voice, releasing it. This is the one that matters for + // music: stopping outright produces an audible click. + void fadeOut(VoiceHandle voice, float seconds); + + // Fade `current` out while starting `clip` faded in over the same interval, and return the new + // voice. The outgoing voice is released when its ramp completes. Passing an invalid `current` + // just starts the new sound, so this works for the first track too. + VoiceHandle crossfadeTo(VoiceHandle current, AssetUID clip, float seconds, const PlayParams& params = {}); + + bool isFading(VoiceHandle voice) const; + + // Update a live voice (position, gain, pitch, ...). No-op for a stale handle. + void setVoiceParams(VoiceHandle voice, const VoiceParams& params); + // Null for a stale handle. Points at engine-owned storage; valid until the voice ends. + const VoiceParams* getVoiceParams(VoiceHandle voice) const; + + // --- Global -------------------------------------------------------------------------------- + void setMasterGain(float gain); + float getMasterGain() const { return m_master_gain; } + void setMuted(bool muted); + bool isMuted() const { return m_muted; } + + // Silence everything without touching the user's mute setting -- used for "pause audio while + // the window is in the background". Kept separate from setMuted precisely so that un-focusing + // and re-focusing cannot clobber a mute the user chose, and vice versa. + void setSuspended(bool suspended); + bool isSuspended() const { return m_suspended; } + + // --- Mixer buses --------------------------------------------------------------------------- + // A voice's audible gain is (its own gain) x (its bus gain) x (master gain), zeroed if the bus + // or the master is muted. OpenAL has no native submix, so buses are applied as a gain + // multiplier on the way to the device rather than as a real graph -- inaudible difference for + // level control, and it keeps the backend seam free of mixer concepts. + // + // Changing a bus re-pushes only the voices routed to it; per-voice gains are the source of + // truth, so repeated calls never compound. + void setBusGain(BusId bus, float gain); + float getBusGain(BusId bus) const; + void setBusMuted(BusId bus, bool muted); + bool isBusMuted(BusId bus) const; + + void setListener(const ListenerState& listener); + const ListenerState& getListener() const { return m_listener; } + + // Per-frame: drives backend housekeeping and reclaims voices that have run to their end. + void update(double delta); + + AudioRegistry& getRegistry() { return *m_registry; } + IAudioBackend& getBackend() { return *m_backend; } + + std::size_t getActiveVoiceCount() const { return m_active.size(); } + // Cumulative count of voices killed to make room. A steadily climbing number means the pool is + // undersized for the scene -- worth surfacing in the editor's audio panel. + std::size_t getStolenVoiceCount() const { return m_stolen_count; } + + private: + // A linear gain ramp on a voice's authored gain. `active` false means no ramp is running. + struct Fade { + bool active = false; + float from = 0.0f; + float to = 0.0f; + float elapsed = 0.0f; + float duration = 0.0f; + // Release the voice once the ramp lands (fade-out / the outgoing half of a crossfade). + bool stopAtEnd = false; + }; + + struct ActiveVoice { + VoiceHandle handle; + VoiceDesc desc; + Fade fade; + }; + + // Advance every running ramp by `delta` and push the resulting gains. Voices whose fade-out + // completed are stopped here. + void advanceFades(double delta); + + // Free the least valuable playing voice so a new one can start. Returns false when nothing is + // a worse candidate than the incoming sound, in which case the new sound is simply dropped -- + // killing an *more* important sound to play a less important one is never right. + bool steal(const VoiceDesc& incoming); + + // Distance-attenuated gain, used to rank voices for stealing. Mirrors the inverse-distance + // model (the engine default); an exact match with the backend's curve is unnecessary, since + // this only has to order voices sensibly. + float audibility(const VoiceDesc& desc) const; + + // Gain actually pushed to the backend: the voice's own gain scaled by its bus and the master, + // with either mute forcing silence. + float effectiveGain(const VoiceDesc& desc) const; + + // Re-push the device gain for one live voice from its stored authored gain. + void repushGain(const ActiveVoice& voice); + + static constexpr std::size_t kBusCount = static_cast(BusId::Count); + static std::size_t busIndex(BusId bus) { + auto i = static_cast(bus); + return i < kBusCount ? i : static_cast(BusId::SFX); + } + + std::vector::iterator find(VoiceHandle voice); + std::vector::const_iterator find(VoiceHandle voice) const; + + std::shared_ptr m_backend; + std::unique_ptr m_registry; + std::vector m_active; + ListenerState m_listener; + float m_master_gain = 1.0f; + bool m_muted = false; + bool m_suspended = false; + std::array m_bus_gain{}; + std::array m_bus_muted{}; + std::size_t m_stolen_count = 0; + // Clips already reported as un-spatializable (stereo), so the warning fires once per clip + // rather than on every play call. + std::unordered_set m_warned_stereo; +}; + +} // namespace ICE diff --git a/ICE/Audio/include/AudioFactory.h b/ICE/Audio/include/AudioFactory.h new file mode 100644 index 00000000..21eae78b --- /dev/null +++ b/ICE/Audio/include/AudioFactory.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +#include "IAudioBackend.h" + +namespace ICE { + +// Backend-selection seam, the audio counterpart of GraphicsFactory. Core depends on this, never on +// a concrete backend, so swapping OpenAL for something else is a one-line change at the +// composition point. +class AudioFactory { + public: + virtual ~AudioFactory() = default; + virtual std::shared_ptr createBackend() const = 0; + virtual std::string name() const = 0; +}; + +} // namespace ICE diff --git a/ICE/Audio/include/AudioRegistry.h b/ICE/Audio/include/AudioRegistry.h new file mode 100644 index 00000000..299e1c91 --- /dev/null +++ b/ICE/Audio/include/AudioRegistry.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#include +#include + +#include "IAudioBackend.h" + +namespace ICE { +class IAudioStream; + +// Owns the backend-resident audio buffers uploaded from AudioClip assets, keyed by AssetUID. The +// direct counterpart of GPURegistry: assets stay CPU-only, the device-side object lives here, and +// eviction is driven by the AssetBank removal listener so a re-imported clip drops its stale +// upload without `assets` needing to know the audio layer exists. +// +// Uploads are lazy: the first getBuffer() for a UID uploads, subsequent ones hit the map. +class AudioRegistry { + public: + AudioRegistry(const std::shared_ptr& backend, const std::shared_ptr& bank); + ~AudioRegistry(); + + // Single owner of the buffer map and holder of a self-referential listener registration: + // copying would double-unregister and not own its own listener. + AudioRegistry(const AudioRegistry&) = delete; + AudioRegistry& operator=(const AudioRegistry&) = delete; + + // Upload-on-first-use. Returns a null handle if the UID is unknown, is not an AudioClip, is + // still loading (async import), or failed to decode. + AudioBufferHandle getBuffer(AssetUID clip); + + // The CPU-side clip behind a UID, or nullptr if it is unknown, of another type, or still + // loading. Callers need this for format questions the buffer handle cannot answer -- above all + // whether the clip is mono, which decides if it can be spatialized at all. + std::shared_ptr getClip(AssetUID clip) const; + + // Open a fresh decoder over a streaming clip's source file. Each call returns an INDEPENDENT + // stream with its own read position, so the same music can play twice at once (a crossfade + // between a track and itself, for instance). Null unless the clip exists, is streaming, and + // its source file can be opened. + std::shared_ptr openStream(AssetUID clip) const; + + // Release the buffer uploaded from `clip`, if any. Invoked via the AssetBank removal listener; + // safe to call for a UID that was never uploaded. + void evict(AssetUID clip); + + // Drop every uploaded buffer (used at shutdown, before the backend goes away). + void clear(); + + std::size_t residentBufferCount() const { return m_buffers.size(); } + + private: + std::shared_ptr m_backend; + std::shared_ptr m_asset_bank; + std::unordered_map m_buffers; + AssetBank::RemovalListenerHandle m_listener = 0; +}; + +} // namespace ICE diff --git a/ICE/Audio/include/AudioTypes.h b/ICE/Audio/include/AudioTypes.h new file mode 100644 index 00000000..6a9966b6 --- /dev/null +++ b/ICE/Audio/include/AudioTypes.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +#include + +namespace ICE { + +// A playing (or paused) sound instance. Generational, so a handle to a one-shot that has since +// finished and had its slot recycled resolves to nothing rather than silently controlling whatever +// sound now occupies that slot. This matters more here than for GPU resources: one-shot voices are +// recycled constantly, and gameplay code routinely holds a handle across frames. +struct VoiceTag {}; +using VoiceHandle = Handle; + +// A backend-resident audio buffer uploaded from an AudioClip (an ALuint, for the OpenAL backend). +// Owned by AudioRegistry. +struct AudioBufferTag {}; +using AudioBufferHandle = Handle; + +enum class PlaybackState { Stopped, Playing, Paused }; + +// Mixer routing. The bus graph itself (per-bus gain, mute/solo) is phase 3; the id travels with +// voices from phase 1 so adding the graph later is additive rather than a signature change. +enum class BusId : uint8_t { Master = 0, Music = 1, SFX = 2, UI = 3, Voice = 4, Count = 5 }; + +// How gain falls off with distance. NOTE: OpenAL's distance model is a *global* context setting +// (alDistanceModel), not per-source -- per-source you only get reference/max distance and rolloff. +// The engine therefore treats this as a project-wide setting; see AudioDeviceConfig. +enum class AttenuationModel { None, InverseDistance, LinearDistance, ExponentDistance }; + +// Per-voice mutable state. Everything here can change every frame while a voice plays. +struct VoiceParams { + Eigen::Vector3f position = Eigen::Vector3f::Zero(); + Eigen::Vector3f velocity = Eigen::Vector3f::Zero(); // for Doppler (phase 2) + float gain = 1.0f; + float pitch = 1.0f; + bool looping = false; + // False makes the voice head-relative at the origin, i.e. plain 2D playback at full volume -- + // the right mode for music and UI. True requires a mono clip (see AudioClip::isMono). + bool spatial = false; + float minDistance = 1.0f; // AL_REFERENCE_DISTANCE: no attenuation closer than this + float maxDistance = 500.0f; // AL_MAX_DISTANCE + float rolloff = 1.0f; // AL_ROLLOFF_FACTOR +}; + +// What a voice is being acquired for. Immutable for the voice's lifetime. +struct VoiceDesc { + AssetUID clip = NO_ASSET_ID; + BusId bus = BusId::SFX; + // Higher survives. When every source is in use, the pool steals the lowest-priority voice, + // breaking ties by audibility (see AudioEngine::steal). + uint8_t priority = 128; + VoiceParams params; +}; + +struct ListenerState { + Eigen::Vector3f position = Eigen::Vector3f::Zero(); + Eigen::Vector3f velocity = Eigen::Vector3f::Zero(); + Eigen::Vector3f forward = -Eigen::Vector3f::UnitZ(); + Eigen::Vector3f up = Eigen::Vector3f::UnitY(); + float gain = 1.0f; +}; + +struct AudioDeviceConfig { + // Voices to request from the driver. OpenAL Soft's default context allocates 255 mono but only + // ONE stereo source, which would cap non-spatialized playback (music + UI) at a single + // simultaneous sound -- so both are requested explicitly at context creation. + int monoVoices = 128; + int stereoVoices = 16; + bool preferHRTF = true; + AttenuationModel attenuation = AttenuationModel::InverseDistance; + float dopplerFactor = 1.0f; + float speedOfSound = 343.3f; // m/s +}; + +} // namespace ICE diff --git a/ICE/Audio/include/IAudioBackend.h b/ICE/Audio/include/IAudioBackend.h new file mode 100644 index 00000000..3699e338 --- /dev/null +++ b/ICE/Audio/include/IAudioBackend.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include + +#include "AudioTypes.h" + +namespace ICE { +class AudioClip; +class IAudioStream; +class JobScheduler; + +// Engine-level audio seam, mirroring RendererAPI/IPhysicsBackend: the engine drives an +// IAudioBackend without knowing whether the mixer underneath is OpenAL, a null stub, or a test +// mock. A new backend plugs in via ICEEngine::setAudioBackend with no change to core. +// +// Division of responsibility: the backend is MECHANISM (open the device, own the sources, push +// parameters), AudioEngine is POLICY (which voice to steal, bus gains, one-shot bookkeeping). A +// backend never decides that a sound is unimportant; it just reports that it is out of voices. +class IAudioBackend { + public: + virtual ~IAudioBackend() = default; + + // Returns false if no device could be opened -- the expected outcome on headless CI. The + // engine then falls back to NullAudioBackend rather than treating audio as fatal. + virtual bool initialize(const AudioDeviceConfig& config) = 0; + virtual void shutdown() = 0; + + virtual bool isAvailable() const = 0; + virtual std::string deviceName() const = 0; + + // --- Buffers (owned by AudioRegistry) ------------------------------------------------------- + // Upload a decoded clip. Returns a null handle if the clip is empty or the upload fails. + virtual AudioBufferHandle uploadClip(const AudioClip& clip) = 0; + virtual void releaseBuffer(AudioBufferHandle buffer) = 0; + + // --- Voices --------------------------------------------------------------------------------- + // Start `buffer` playing. Returns a null handle when no source is free; the caller (AudioEngine) + // decides whether to steal and retry. Never blocks, never allocates a device object -- the + // source set is fixed at initialize(). + virtual VoiceHandle acquireVoice(AudioBufferHandle buffer, const VoiceDesc& desc) = 0; + + // Start a voice fed incrementally from `stream` rather than from a resident buffer. Used for + // music and other long sounds. Looping is driven by rewinding the stream (desc.params.looping), + // because AL_LOOPING on a queued-buffer source would loop one queued chunk, not the sound. + // + // Backends that cannot stream return a null handle; the caller falls back to silence rather + // than to a multi-megabyte resident decode. + virtual VoiceHandle acquireStreamingVoice(const std::shared_ptr& stream, const VoiceDesc& desc) = 0; + + virtual void releaseVoice(VoiceHandle voice) = 0; + + virtual void setVoiceParams(VoiceHandle voice, const VoiceParams& params) = 0; + virtual void setVoiceState(VoiceHandle voice, PlaybackState state) = 0; + // False once the sound has run to its end (or the handle is stale). This is what AudioEngine + // polls in update() to reclaim finished one-shots. + virtual bool isVoiceActive(VoiceHandle voice) const = 0; + + // Number of voices currently in use / the hard ceiling imposed by the device. + virtual std::size_t activeVoiceCount() const = 0; + virtual std::size_t voiceCapacity() const = 0; + + virtual void setListener(const ListenerState& listener) = 0; + + // Per-frame bookkeeping. Mixing happens on the backend's own thread; this is main-thread + // housekeeping only (reclaiming stopped sources, refilling streaming buffer queues). + virtual void update(double delta) = 0; + + // Optional scheduler used to decode streaming audio off the main thread. Without one, streams + // decode inline in update() -- correct, but a multi-millisecond spike on the frame that + // happens to need a refill. Null detaches it. + virtual void setScheduler(const std::shared_ptr& /*scheduler*/) {} + + // Diagnostics: how many streaming voices have starved (run out of decoded audio while still + // playing). Non-zero means decoding is not keeping up -- the number worth watching when tuning + // chunk size or count. + virtual std::size_t streamUnderrunCount() const { return 0; } + + // --- Phase 5 seams -------------------------------------------------------------------------- + // Defaulted to no-ops, following GraphicsFactory::createTexture2D: declaring them now means + // reverb and occlusion plug in without a later signature change rippling through the stack. + virtual void setReverbPreset(int /*preset*/) {} + virtual void setVoiceOcclusion(VoiceHandle /*voice*/, float /*occlusion01*/) {} +}; + +} // namespace ICE diff --git a/ICE/Audio/include/IAudioStream.h b/ICE/Audio/include/IAudioStream.h new file mode 100644 index 00000000..6e7f34bc --- /dev/null +++ b/ICE/Audio/include/IAudioStream.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +namespace ICE { + +// An incremental source of PCM, for sounds too long to hold in memory (music, ambience, dialogue). +// Where AudioClip is the whole decoded buffer, this hands out frames a chunk at a time. +// +// THREADING: a stream is driven by exactly one decode job at a time (the backend guarantees this +// with a single in-flight guard per voice), so implementations need no internal locking -- but they +// must not assume the calls all come from the *same* thread, since successive jobs may run on +// different workers. Plain member state is fine; thread_local or thread-affine handles are not. +class IAudioStream { + public: + virtual ~IAudioStream() = default; + + virtual uint32_t getChannels() const = 0; + virtual uint32_t getSampleRate() const = 0; + // Total frames in the source, or 0 when the format cannot report it cheaply. + virtual uint64_t getTotalFrames() const = 0; + + // Read up to `maxFrames` interleaved frames into `out` (which must hold + // maxFrames * getChannels() int16s). Returns the number of frames actually written; a short + // read or 0 means end of stream. + virtual uint64_t read(int16_t* out, uint64_t maxFrames) = 0; + + // Seek back to the start. This is how looping is done for streamed sounds -- AL_LOOPING cannot + // be used on a queued-buffer source, since it would loop the individual queued buffer rather + // than the underlying sound. + virtual void rewind() = 0; + + virtual bool isValid() const = 0; +}; + +// Open a WAV/MP3/FLAC/OGG file for incremental decoding. Returns nullptr if the file cannot be +// opened or its extension is unsupported. +std::shared_ptr OpenAudioFileStream(const std::filesystem::path& file); + +} // namespace ICE diff --git a/ICE/Audio/include/NullAudioBackend.h b/ICE/Audio/include/NullAudioBackend.h new file mode 100644 index 00000000..e39df1fa --- /dev/null +++ b/ICE/Audio/include/NullAudioBackend.h @@ -0,0 +1,72 @@ +#pragma once + +#include "IAudioBackend.h" + +namespace ICE { + +// Silent reference backend, mirroring NullPhysicsBackend. Two jobs: +// * the fallback when no audio device can be opened, so a headless machine (CI, a server build) +// runs the full engine with audio "attached" but inert instead of failing; +// * the template a real backend is written against. +// +// It honours the voice-accounting contract -- a fixed capacity, handles that go stale on release -- +// so code paths that depend on acquisition failing under load behave identically here. +class NullAudioBackend : public IAudioBackend { + public: + explicit NullAudioBackend(std::size_t capacity = 32) : m_capacity(capacity) {} + + bool initialize(const AudioDeviceConfig&) override { return true; } + void shutdown() override { + m_voices = {}; + m_buffers = {}; + } + + bool isAvailable() const override { return false; } // audible output: none + std::string deviceName() const override { return "null"; } + + AudioBufferHandle uploadClip(const AudioClip&) override { return m_buffers.insert(0); } + void releaseBuffer(AudioBufferHandle buffer) override { m_buffers.erase(buffer); } + + VoiceHandle acquireVoice(AudioBufferHandle, const VoiceDesc&) override { + if (m_voices.size() >= m_capacity) { + return {}; + } + return m_voices.insert(PlaybackState::Playing); + } + // Streaming behaves exactly like a resident voice here: silent either way, but it keeps the + // voice accounting identical so code paths that depend on acquisition failing under load + // behave the same on the null backend. + VoiceHandle acquireStreamingVoice(const std::shared_ptr&, const VoiceDesc&) override { + if (m_voices.size() >= m_capacity) { + return {}; + } + return m_voices.insert(PlaybackState::Playing); + } + void releaseVoice(VoiceHandle voice) override { m_voices.erase(voice); } + + void setVoiceParams(VoiceHandle, const VoiceParams&) override {} + void setVoiceState(VoiceHandle voice, PlaybackState state) override { + if (auto* s = m_voices.get(voice)) { + *s = state; + } + } + // A null voice never finishes on its own -- there is no clock driving it. Callers stop it + // explicitly, which keeps the silent path deterministic for tests. + bool isVoiceActive(VoiceHandle voice) const override { + const auto* s = m_voices.get(voice); + return s != nullptr && *s != PlaybackState::Stopped; + } + + std::size_t activeVoiceCount() const override { return m_voices.size(); } + std::size_t voiceCapacity() const override { return m_capacity; } + + void setListener(const ListenerState&) override {} + void update(double) override {} + + private: + HandlePool m_voices; + HandlePool m_buffers; + std::size_t m_capacity; +}; + +} // namespace ICE diff --git a/ICE/Audio/src/AudioClipLoader.cpp b/ICE/Audio/src/AudioClipLoader.cpp new file mode 100644 index 00000000..903d052c --- /dev/null +++ b/ICE/Audio/src/AudioClipLoader.cpp @@ -0,0 +1,72 @@ +#include "AudioClipLoader.h" + +#include + +#include "AudioDecoder.h" +#include "IAudioStream.h" + +namespace ICE { +namespace { +// 1 MiB of encoded audio is roughly 30-60s of MP3/Vorbis -- comfortably past the point where +// holding the decoded PCM resident stops being worthwhile. +std::size_t g_streaming_threshold_bytes = 1024 * 1024; +} // namespace + +std::size_t AudioClipLoader::streamingThresholdBytes() { + return g_streaming_threshold_bytes; +} + +void AudioClipLoader::setStreamingThresholdBytes(std::size_t bytes) { + g_streaming_threshold_bytes = bytes; +} + +std::shared_ptr AudioClipLoader::load(const std::vector& files) { + if (files.empty()) { + Logger::Log(Logger::ERROR, "Audio", "AudioClipLoader called with no source file."); + return nullptr; + } + // One clip, one file. Extra sources would be a multi-variant clip, which the asset model does + // not express yet; ignoring them silently would hide an import mistake. + if (files.size() > 1) { + Logger::Log(Logger::WARNING, "Audio", "AudioClipLoader got %zu sources for one clip; using '%s' and ignoring the rest.", + files.size(), files.front().string().c_str()); + } + + const auto& file = files.front(); + + // Large files stream instead of decoding into RAM: a few minutes of music decodes to tens of + // megabytes of PCM, which is not worth resident memory for something played once at a time. + // The threshold is on the ENCODED size, since that is knowable without decoding first. + std::error_code size_error; + const auto encoded_size = std::filesystem::file_size(file, size_error); + if (!size_error && encoded_size >= AudioClipLoader::streamingThresholdBytes()) { + auto stream = OpenAudioFileStream(file); + if (stream != nullptr) { + auto clip = std::make_shared( + AudioClip::Streaming(stream->getChannels(), stream->getSampleRate(), stream->getTotalFrames())); + clip->setSources(files); + Logger::Log(Logger::DEBUG, "Audio", "Streaming '%s': %u ch @ %u Hz, %.2fs (%.1f MiB encoded)", file.string().c_str(), + clip->getChannels(), clip->getSampleRate(), clip->getDuration(), encoded_size / (1024.0 * 1024.0)); + return clip; + } + // Falling through to a full decode is the right failure mode: the file may still be + // decodable in one shot even if the streaming path could not open it. + Logger::Log(Logger::WARNING, "Audio", "Could not open '%s' for streaming; decoding it fully instead.", file.string().c_str()); + } + + auto decoded = DecodeAudioFile(file); + if (!decoded.has_value()) { + Logger::Log(Logger::ERROR, "Audio", "Could not decode audio file '%s' (unsupported format or corrupt data).", + file.string().c_str()); + return nullptr; + } + + auto clip = std::make_shared(std::move(decoded->samples), decoded->channels, decoded->sampleRate); + clip->setSources(files); + + Logger::Log(Logger::DEBUG, "Audio", "Loaded '%s': %u ch @ %u Hz, %.2fs", file.string().c_str(), clip->getChannels(), + clip->getSampleRate(), clip->getDuration()); + return clip; +} + +} // namespace ICE diff --git a/ICE/Audio/src/AudioDecoder.cpp b/ICE/Audio/src/AudioDecoder.cpp new file mode 100644 index 00000000..a69f9b91 --- /dev/null +++ b/ICE/Audio/src/AudioDecoder.cpp @@ -0,0 +1,124 @@ +#include "AudioDecoder.h" + +#include +#include +#include +#include + +// Declarations only -- every implementation macro is defined exactly once, in +// audio_decoders_impl.cpp. Including these without the macros yields the prototypes. +#include +#include +#include + +// stb_vorbis has no separate header; STB_VORBIS_HEADER_ONLY is how it exposes just the prototypes. +// Without it this TU would emit a second copy of every stb_vorbis symbol and fail to link. +#define STB_VORBIS_HEADER_ONLY +#include +#undef STB_VORBIS_HEADER_ONLY + +namespace ICE { +namespace { + +// Adopt a decoder-owned buffer of interleaved int16 into a DecodedAudio, then release it with the +// decoder's own deallocator. Every branch below produces its buffer the same way, so the copy and +// the free live in one place. +template +DecodedAudio adopt(const int16_t* data, uint32_t channels, uint32_t sampleRate, uint64_t frames, FreeFn&& release) { + DecodedAudio out; + out.channels = channels; + out.sampleRate = sampleRate; + out.frameCount = frames; + out.samples.assign(data, data + frames * channels); + release(); + return out; +} + +std::string lowerExtension(const std::filesystem::path& file) { + std::string ext = file.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return ext; +} + +} // namespace + +const std::vector& SupportedAudioExtensions() { + static const std::vector extensions = {".wav", ".mp3", ".flac", ".ogg"}; + return extensions; +} + +void DownmixToMono(DecodedAudio& audio) { + if (audio.channels <= 1 || audio.samples.empty()) { + return; + } + const uint32_t channels = audio.channels; + const std::size_t frames = audio.samples.size() / channels; + + std::vector mono(frames); + for (std::size_t frame = 0; frame < frames; ++frame) { + // Accumulate in int32: summing several int16 channels overflows 16 bits before the divide. + int32_t sum = 0; + for (uint32_t c = 0; c < channels; ++c) { + sum += audio.samples[frame * channels + c]; + } + mono[frame] = static_cast(sum / static_cast(channels)); + } + + audio.samples = std::move(mono); + audio.channels = 1; + audio.frameCount = frames; +} + +std::optional DecodeAudioFile(const std::filesystem::path& file) { + const std::string ext = lowerExtension(file); + const std::string path = file.string(); + + if (ext == ".wav") { + unsigned int channels = 0; + unsigned int sampleRate = 0; + drwav_uint64 frames = 0; + drwav_int16* data = drwav_open_file_and_read_pcm_frames_s16(path.c_str(), &channels, &sampleRate, &frames, nullptr); + if (data == nullptr) { + return std::nullopt; + } + return adopt(data, channels, sampleRate, frames, [data] { drwav_free(data, nullptr); }); + } + + if (ext == ".mp3") { + drmp3_config config{}; + drmp3_uint64 frames = 0; + drmp3_int16* data = drmp3_open_file_and_read_pcm_frames_s16(path.c_str(), &config, &frames, nullptr); + if (data == nullptr) { + return std::nullopt; + } + return adopt(data, config.channels, config.sampleRate, frames, [data] { drmp3_free(data, nullptr); }); + } + + if (ext == ".flac") { + unsigned int channels = 0; + unsigned int sampleRate = 0; + drflac_uint64 frames = 0; + drflac_int16* data = drflac_open_file_and_read_pcm_frames_s16(path.c_str(), &channels, &sampleRate, &frames, nullptr); + if (data == nullptr) { + return std::nullopt; + } + return adopt(data, channels, sampleRate, frames, [data] { drflac_free(data, nullptr); }); + } + + if (ext == ".ogg") { + int channels = 0; + int sampleRate = 0; + short* data = nullptr; + // Returns the frame count, or -1 on failure. Allocates with malloc, so it frees with free. + const int frames = stb_vorbis_decode_filename(path.c_str(), &channels, &sampleRate, &data); + if (frames < 0 || data == nullptr) { + return std::nullopt; + } + return adopt(data, static_cast(channels), static_cast(sampleRate), static_cast(frames), + [data] { std::free(data); }); + } + + return std::nullopt; +} + +} // namespace ICE diff --git a/ICE/Audio/src/AudioEngine.cpp b/ICE/Audio/src/AudioEngine.cpp new file mode 100644 index 00000000..5264b630 --- /dev/null +++ b/ICE/Audio/src/AudioEngine.cpp @@ -0,0 +1,410 @@ +#include "AudioEngine.h" + +#include +#include + +#include + +namespace ICE { + +AudioEngine::AudioEngine(const std::shared_ptr& backend, const std::shared_ptr& bank) + : m_backend(backend), + m_registry(std::make_unique(backend, bank)) { + m_bus_gain.fill(1.0f); + m_bus_muted.fill(false); +} + +AudioEngine::~AudioEngine() { + stopAll(); + // Buffers must go before the backend does, while it can still release them. + m_registry.reset(); +} + +VoiceHandle AudioEngine::play(AssetUID clip, const PlayParams& params) { + VoiceDesc desc; + desc.clip = clip; + desc.bus = params.bus; + desc.priority = params.priority; + desc.params.gain = params.volume; + desc.params.pitch = params.pitch; + desc.params.looping = params.loop; + desc.params.spatial = false; + VoiceHandle voice = playVoice(desc); + if (voice.valid() && params.fadeInSeconds > 0.0f) { + fadeIn(voice, params.volume, params.fadeInSeconds); + } + return voice; +} + +VoiceHandle AudioEngine::playAt(AssetUID clip, const Eigen::Vector3f& position, const PlayParams& params) { + VoiceDesc desc; + desc.clip = clip; + desc.bus = params.bus; + desc.priority = params.priority; + desc.params.gain = params.volume; + desc.params.pitch = params.pitch; + desc.params.looping = params.loop; + desc.params.spatial = true; + desc.params.position = position; + VoiceHandle voice = playVoice(desc); + if (voice.valid() && params.fadeInSeconds > 0.0f) { + fadeIn(voice, params.volume, params.fadeInSeconds); + } + return voice; +} + +VoiceHandle AudioEngine::playVoice(const VoiceDesc& desc) { + if (m_backend == nullptr || desc.clip == NO_ASSET_ID) { + return {}; + } + + auto clip_asset = m_registry->getClip(desc.clip); + const bool streaming = clip_asset != nullptr && clip_asset->isStreaming(); + + // A resident clip needs its buffer uploaded up front; a streaming one is fed incrementally and + // has no buffer to upload at all. + AudioBufferHandle buffer; + if (!streaming) { + buffer = m_registry->getBuffer(desc.clip); + if (!buffer.valid()) { + return {}; // unknown, still loading, or failed to decode -- silent, not fatal + } + } + + VoiceDesc effective = desc; + + // Spatialization silently does nothing for a stereo buffer in OpenAL: it plays flat at full + // volume, ignoring position entirely. Rather than let that look like a positioning bug, demote + // it to explicit 2D playback and say so -- once per clip, since this is usually an import + // mistake that would otherwise spam a per-frame log. + if (effective.params.spatial) { + auto clip = m_registry->getClip(desc.clip); + if (clip != nullptr && !clip->isMono()) { + if (m_warned_stereo.insert(desc.clip).second) { + Logger::Log(Logger::WARNING, "Audio", + "Clip %llu is %u-channel and cannot be spatialized (OpenAL positions mono buffers only); " + "playing it 2D. Re-import it as mono if it should be positional.", + (unsigned long long) desc.clip, clip->getChannels()); + } + effective.params.spatial = false; + } + } + + // Each streaming voice opens its own decoder handle, so the same music clip can legitimately + // play twice at once (e.g. mid-crossfade) with independent read positions. + auto acquire = [&]() -> VoiceHandle { + if (!streaming) { + return m_backend->acquireVoice(buffer, effective); + } + auto stream = m_registry->openStream(desc.clip); + return stream == nullptr ? VoiceHandle{} : m_backend->acquireStreamingVoice(stream, effective); + }; + + VoiceHandle voice = acquire(); + if (!voice.valid()) { + if (!steal(effective)) { + return {}; // everything playing is more important than this + } + voice = acquire(); + if (!voice.valid()) { + return {}; + } + } + + // Apply bus/master gain on the way to the device; m_active keeps the voice's own authored gain + // so later bus or master changes recompute from it rather than compounding. + VoiceParams device_params = effective.params; + device_params.gain = effectiveGain(effective); + m_backend->setVoiceParams(voice, device_params); + m_backend->setVoiceState(voice, PlaybackState::Playing); + + ActiveVoice active{voice, effective, Fade{}}; + m_active.push_back(active); + return voice; +} + +void AudioEngine::stop(VoiceHandle voice) { + auto it = find(voice); + if (it == m_active.end()) { + return; + } + m_backend->setVoiceState(voice, PlaybackState::Stopped); + m_backend->releaseVoice(voice); + m_active.erase(it); +} + +void AudioEngine::stopAll() { + if (m_backend == nullptr) { + return; + } + for (const auto& v : m_active) { + m_backend->setVoiceState(v.handle, PlaybackState::Stopped); + m_backend->releaseVoice(v.handle); + } + m_active.clear(); +} + +void AudioEngine::pause(VoiceHandle voice) { + if (find(voice) != m_active.end()) { + m_backend->setVoiceState(voice, PlaybackState::Paused); + } +} + +void AudioEngine::resume(VoiceHandle voice) { + if (find(voice) != m_active.end()) { + m_backend->setVoiceState(voice, PlaybackState::Playing); + } +} + +bool AudioEngine::isPlaying(VoiceHandle voice) const { + return find(voice) != m_active.end() && m_backend->isVoiceActive(voice); +} + +void AudioEngine::setVoiceParams(VoiceHandle voice, const VoiceParams& params) { + auto it = find(voice); + if (it == m_active.end()) { + return; + } + it->desc.params = params; + repushGain(*it); +} + +const VoiceParams* AudioEngine::getVoiceParams(VoiceHandle voice) const { + auto it = find(voice); + return it == m_active.end() ? nullptr : &it->desc.params; +} + +void AudioEngine::setMasterGain(float gain) { + m_master_gain = std::clamp(gain, 0.0f, 1.0f); + // Re-push every live voice's gain: the stored per-voice gain is the source of truth, so this + // is idempotent and never compounds. + for (const auto& v : m_active) { + repushGain(v); + } +} + +void AudioEngine::setMuted(bool muted) { + if (m_muted == muted) { + return; + } + m_muted = muted; + setMasterGain(m_master_gain); // re-push through the same path +} + +void AudioEngine::setSuspended(bool suspended) { + if (m_suspended == suspended) { + return; + } + m_suspended = suspended; + setMasterGain(m_master_gain); // same re-push path; effectiveGain folds in the new state +} + +void AudioEngine::setBusGain(BusId bus, float gain) { + m_bus_gain[busIndex(bus)] = std::clamp(gain, 0.0f, 1.0f); + for (const auto& v : m_active) { + if (busIndex(v.desc.bus) == busIndex(bus)) { + repushGain(v); + } + } +} + +float AudioEngine::getBusGain(BusId bus) const { + return m_bus_gain[busIndex(bus)]; +} + +void AudioEngine::setBusMuted(BusId bus, bool muted) { + if (m_bus_muted[busIndex(bus)] == muted) { + return; + } + m_bus_muted[busIndex(bus)] = muted; + setBusGain(bus, m_bus_gain[busIndex(bus)]); // re-push through the same path +} + +bool AudioEngine::isBusMuted(BusId bus) const { + return m_bus_muted[busIndex(bus)]; +} + +void AudioEngine::repushGain(const ActiveVoice& voice) { + VoiceParams device_params = voice.desc.params; + device_params.gain = effectiveGain(voice.desc); + m_backend->setVoiceParams(voice.handle, device_params); +} + +void AudioEngine::setListener(const ListenerState& listener) { + m_listener = listener; + if (m_backend != nullptr) { + m_backend->setListener(listener); + } +} + +void AudioEngine::fadeTo(VoiceHandle voice, float targetGain, float seconds) { + auto it = find(voice); + if (it == m_active.end()) { + return; + } + if (seconds <= 0.0f) { + // Degenerate ramp: apply immediately rather than dividing by zero in advanceFades. + it->desc.params.gain = std::clamp(targetGain, 0.0f, 1.0f); + it->fade = Fade{}; + repushGain(*it); + return; + } + it->fade.active = true; + it->fade.from = it->desc.params.gain; + it->fade.to = std::clamp(targetGain, 0.0f, 1.0f); + it->fade.elapsed = 0.0f; + it->fade.duration = seconds; + it->fade.stopAtEnd = false; +} + +void AudioEngine::fadeIn(VoiceHandle voice, float targetGain, float seconds) { + auto it = find(voice); + if (it == m_active.end()) { + return; + } + it->desc.params.gain = 0.0f; // start silent, then ramp up + repushGain(*it); + fadeTo(voice, targetGain, seconds); +} + +void AudioEngine::fadeOut(VoiceHandle voice, float seconds) { + auto it = find(voice); + if (it == m_active.end()) { + return; + } + if (seconds <= 0.0f) { + stop(voice); + return; + } + fadeTo(voice, 0.0f, seconds); + // fadeTo cleared stopAtEnd; re-find because fadeTo may have reallocated nothing but is safer. + auto again = find(voice); + if (again != m_active.end()) { + again->fade.stopAtEnd = true; + } +} + +VoiceHandle AudioEngine::crossfadeTo(VoiceHandle current, AssetUID clip, float seconds, const PlayParams& params) { + PlayParams incoming = params; + incoming.fadeInSeconds = seconds; + VoiceHandle next = play(clip, incoming); + // Only retire the outgoing track once the new one actually started; otherwise a failed load + // would leave silence where there used to be music. + if (next.valid()) { + fadeOut(current, seconds); + } + return next; +} + +bool AudioEngine::isFading(VoiceHandle voice) const { + auto it = find(voice); + return it != m_active.end() && it->fade.active; +} + +void AudioEngine::advanceFades(double delta) { + std::vector finished; + for (auto& v : m_active) { + if (!v.fade.active) { + continue; + } + v.fade.elapsed += static_cast(delta); + const float t = std::clamp(v.fade.elapsed / v.fade.duration, 0.0f, 1.0f); + v.desc.params.gain = v.fade.from + (v.fade.to - v.fade.from) * t; + repushGain(v); + + if (t >= 1.0f) { + v.fade.active = false; + if (v.fade.stopAtEnd) { + finished.push_back(v.handle); + } + } + } + // Stop outside the loop: stop() erases from m_active and would invalidate the iteration. + for (VoiceHandle handle : finished) { + stop(handle); + } +} + +void AudioEngine::update(double delta) { + if (m_backend == nullptr) { + return; + } + m_backend->update(delta); + advanceFades(delta); + + // Reclaim one-shots that have run to their end. Looping voices stay active until stopped. + std::erase_if(m_active, [this](const ActiveVoice& v) { + if (m_backend->isVoiceActive(v.handle)) { + return false; + } + m_backend->releaseVoice(v.handle); + return true; + }); +} + +bool AudioEngine::steal(const VoiceDesc& incoming) { + if (m_active.empty()) { + return false; + } + + const float incoming_audibility = audibility(incoming); + auto victim = m_active.end(); + for (auto it = m_active.begin(); it != m_active.end(); ++it) { + if (victim == m_active.end()) { + victim = it; + continue; + } + // Lowest priority first; ties broken by which is quieter at the listener. + if (it->desc.priority < victim->desc.priority || + (it->desc.priority == victim->desc.priority && audibility(it->desc) < audibility(victim->desc))) { + victim = it; + } + } + + // Never kill something more important than what is arriving. + if (victim->desc.priority > incoming.priority || + (victim->desc.priority == incoming.priority && audibility(victim->desc) >= incoming_audibility)) { + return false; + } + + m_backend->setVoiceState(victim->handle, PlaybackState::Stopped); + m_backend->releaseVoice(victim->handle); + m_active.erase(victim); + ++m_stolen_count; + return true; +} + +float AudioEngine::audibility(const VoiceDesc& desc) const { + const float gain = desc.params.gain; + if (!desc.params.spatial) { + return gain; // 2D sounds play at full volume wherever the listener is + } + const float distance = (desc.params.position - m_listener.position).norm(); + if (distance <= desc.params.minDistance) { + return gain; + } + if (distance >= desc.params.maxDistance) { + return 0.0f; + } + // Inverse-distance rolloff, matching the engine's default attenuation model. This only has to + // ORDER voices sensibly, so an exact match with the backend's curve is not required. + const float denom = desc.params.minDistance + desc.params.rolloff * (distance - desc.params.minDistance); + return denom <= 0.0f ? gain : gain * (desc.params.minDistance / denom); +} + +float AudioEngine::effectiveGain(const VoiceDesc& desc) const { + if (m_muted || m_suspended || m_bus_muted[busIndex(desc.bus)]) { + return 0.0f; + } + return desc.params.gain * m_bus_gain[busIndex(desc.bus)] * m_master_gain; +} + +std::vector::iterator AudioEngine::find(VoiceHandle voice) { + return std::find_if(m_active.begin(), m_active.end(), [voice](const ActiveVoice& v) { return v.handle == voice; }); +} + +std::vector::const_iterator AudioEngine::find(VoiceHandle voice) const { + return std::find_if(m_active.begin(), m_active.end(), [voice](const ActiveVoice& v) { return v.handle == voice; }); +} + +} // namespace ICE diff --git a/ICE/Audio/src/AudioRegistry.cpp b/ICE/Audio/src/AudioRegistry.cpp new file mode 100644 index 00000000..ba7757c8 --- /dev/null +++ b/ICE/Audio/src/AudioRegistry.cpp @@ -0,0 +1,87 @@ +#include "AudioRegistry.h" + +#include + +#include "IAudioStream.h" + +namespace ICE { + +AudioRegistry::AudioRegistry(const std::shared_ptr& backend, const std::shared_ptr& bank) + : m_backend(backend), + m_asset_bank(bank) { + if (m_asset_bank != nullptr) { + m_listener = m_asset_bank->addRemovalListener([this](AssetUID id) { evict(id); }); + } +} + +AudioRegistry::~AudioRegistry() { + if (m_asset_bank != nullptr && m_listener != 0) { + m_asset_bank->removeRemovalListener(m_listener); + } + clear(); +} + +AudioBufferHandle AudioRegistry::getBuffer(AssetUID clip) { + if (clip == NO_ASSET_ID || m_backend == nullptr || m_asset_bank == nullptr) { + return {}; + } + if (auto it = m_buffers.find(clip); it != m_buffers.end()) { + return it->second; + } + + // Null covers all of: unknown UID, wrong asset type, and an async import still in flight + // (AssetBank::getAsset returns nullptr until the load is Ready). Callers just get no sound this + // frame and retry next frame, which is the desired behaviour for a clip that is still loading. + auto asset = m_asset_bank->getAsset(clip); + if (asset == nullptr || asset->isEmpty()) { + return {}; + } + + AudioBufferHandle handle = m_backend->uploadClip(*asset); + if (!handle.valid()) { + Logger::Log(Logger::ERROR, "Audio", "Failed to upload audio clip %llu to the audio device.", (unsigned long long) clip); + return {}; + } + m_buffers.emplace(clip, handle); + return handle; +} + +std::shared_ptr AudioRegistry::getClip(AssetUID clip) const { + if (clip == NO_ASSET_ID || m_asset_bank == nullptr) { + return nullptr; + } + return m_asset_bank->getAsset(clip); +} + +std::shared_ptr AudioRegistry::openStream(AssetUID clip) const { + auto asset = getClip(clip); + if (asset == nullptr || !asset->isStreaming()) { + return nullptr; + } + const auto sources = asset->getSources(); + if (sources.empty()) { + Logger::Log(Logger::ERROR, "Audio", "Streaming clip %llu has no source file to read from.", (unsigned long long) clip); + return nullptr; + } + return OpenAudioFileStream(sources.front()); +} + +void AudioRegistry::evict(AssetUID clip) { + auto it = m_buffers.find(clip); + if (it == m_buffers.end()) { + return; + } + m_backend->releaseBuffer(it->second); + m_buffers.erase(it); +} + +void AudioRegistry::clear() { + if (m_backend != nullptr) { + for (const auto& [uid, handle] : m_buffers) { + m_backend->releaseBuffer(handle); + } + } + m_buffers.clear(); +} + +} // namespace ICE diff --git a/ICE/Audio/src/FileAudioStream.cpp b/ICE/Audio/src/FileAudioStream.cpp new file mode 100644 index 00000000..90c9a7a9 --- /dev/null +++ b/ICE/Audio/src/FileAudioStream.cpp @@ -0,0 +1,176 @@ +#include + +#include +#include +#include + +#include "IAudioStream.h" + +// Declarations only -- the implementations live in audio_decoders_impl.cpp. +#include +#include +#include + +#define STB_VORBIS_HEADER_ONLY +#include +#undef STB_VORBIS_HEADER_ONLY + +namespace ICE { +namespace { + +std::string lowerExtension(const std::filesystem::path& file) { + std::string ext = file.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return ext; +} + +// Each decoder keeps its own open handle; the shape is identical, so the differences are confined +// to these four small classes rather than leaking into the streaming machinery. + +class WavStream : public IAudioStream { + public: + explicit WavStream(const std::filesystem::path& file) { m_ok = drwav_init_file(&m_wav, file.string().c_str(), nullptr) == DRWAV_TRUE; } + ~WavStream() override { + if (m_ok) { + drwav_uninit(&m_wav); + } + } + uint32_t getChannels() const override { return m_ok ? m_wav.channels : 0; } + uint32_t getSampleRate() const override { return m_ok ? m_wav.sampleRate : 0; } + uint64_t getTotalFrames() const override { return m_ok ? m_wav.totalPCMFrameCount : 0; } + uint64_t read(int16_t* out, uint64_t maxFrames) override { return m_ok ? drwav_read_pcm_frames_s16(&m_wav, maxFrames, out) : 0; } + void rewind() override { + if (m_ok) { + drwav_seek_to_pcm_frame(&m_wav, 0); + } + } + bool isValid() const override { return m_ok; } + + private: + drwav m_wav{}; + bool m_ok = false; +}; + +class Mp3Stream : public IAudioStream { + public: + explicit Mp3Stream(const std::filesystem::path& file) { m_ok = drmp3_init_file(&m_mp3, file.string().c_str(), nullptr) == DRMP3_TRUE; } + ~Mp3Stream() override { + if (m_ok) { + drmp3_uninit(&m_mp3); + } + } + uint32_t getChannels() const override { return m_ok ? m_mp3.channels : 0; } + uint32_t getSampleRate() const override { return m_ok ? m_mp3.sampleRate : 0; } + // Counting MP3 frames requires a full scan, so it is done once here rather than per call. + uint64_t getTotalFrames() const override { + if (!m_ok) { + return 0; + } + if (m_total_frames == 0) { + m_total_frames = drmp3_get_pcm_frame_count(&m_mp3); + drmp3_seek_to_pcm_frame(&m_mp3, 0); // the scan moved the read cursor + } + return m_total_frames; + } + uint64_t read(int16_t* out, uint64_t maxFrames) override { return m_ok ? drmp3_read_pcm_frames_s16(&m_mp3, maxFrames, out) : 0; } + void rewind() override { + if (m_ok) { + drmp3_seek_to_pcm_frame(&m_mp3, 0); + } + } + bool isValid() const override { return m_ok; } + + private: + mutable drmp3 m_mp3{}; + mutable uint64_t m_total_frames = 0; + bool m_ok = false; +}; + +class FlacStream : public IAudioStream { + public: + explicit FlacStream(const std::filesystem::path& file) { m_flac = drflac_open_file(file.string().c_str(), nullptr); } + ~FlacStream() override { + if (m_flac != nullptr) { + drflac_close(m_flac); + } + } + uint32_t getChannels() const override { return m_flac ? m_flac->channels : 0; } + uint32_t getSampleRate() const override { return m_flac ? m_flac->sampleRate : 0; } + uint64_t getTotalFrames() const override { return m_flac ? m_flac->totalPCMFrameCount : 0; } + uint64_t read(int16_t* out, uint64_t maxFrames) override { return m_flac ? drflac_read_pcm_frames_s16(m_flac, maxFrames, out) : 0; } + void rewind() override { + if (m_flac != nullptr) { + drflac_seek_to_pcm_frame(m_flac, 0); + } + } + bool isValid() const override { return m_flac != nullptr; } + + private: + drflac* m_flac = nullptr; +}; + +class VorbisStream : public IAudioStream { + public: + explicit VorbisStream(const std::filesystem::path& file) { + int error = 0; + m_vorbis = stb_vorbis_open_filename(file.string().c_str(), &error, nullptr); + if (m_vorbis != nullptr) { + m_info = stb_vorbis_get_info(m_vorbis); + } + } + ~VorbisStream() override { + if (m_vorbis != nullptr) { + stb_vorbis_close(m_vorbis); + } + } + uint32_t getChannels() const override { return m_vorbis ? static_cast(m_info.channels) : 0; } + uint32_t getSampleRate() const override { return m_vorbis ? m_info.sample_rate : 0; } + uint64_t getTotalFrames() const override { return m_vorbis ? stb_vorbis_stream_length_in_samples(m_vorbis) : 0; } + uint64_t read(int16_t* out, uint64_t maxFrames) override { + if (m_vorbis == nullptr) { + return 0; + } + // Returns frames (not shorts) despite taking a short count. + const int frames = stb_vorbis_get_samples_short_interleaved(m_vorbis, m_info.channels, out, + static_cast(maxFrames * m_info.channels)); + return frames < 0 ? 0 : static_cast(frames); + } + void rewind() override { + if (m_vorbis != nullptr) { + stb_vorbis_seek_start(m_vorbis); + } + } + bool isValid() const override { return m_vorbis != nullptr; } + + private: + stb_vorbis* m_vorbis = nullptr; + stb_vorbis_info m_info{}; +}; + +} // namespace + +std::shared_ptr OpenAudioFileStream(const std::filesystem::path& file) { + const std::string ext = lowerExtension(file); + + std::shared_ptr stream; + if (ext == ".wav") { + stream = std::make_shared(file); + } else if (ext == ".mp3") { + stream = std::make_shared(file); + } else if (ext == ".flac") { + stream = std::make_shared(file); + } else if (ext == ".ogg") { + stream = std::make_shared(file); + } else { + Logger::Log(Logger::ERROR, "Audio", "Cannot stream '%s': unsupported format.", file.string().c_str()); + return nullptr; + } + + if (!stream->isValid()) { + Logger::Log(Logger::ERROR, "Audio", "Could not open '%s' for streaming.", file.string().c_str()); + return nullptr; + } + return stream; +} + +} // namespace ICE diff --git a/ICE/Audio/src/audio_decoders_impl.cpp b/ICE/Audio/src/audio_decoders_impl.cpp new file mode 100644 index 00000000..1c2e027f --- /dev/null +++ b/ICE/Audio/src/audio_decoders_impl.cpp @@ -0,0 +1,21 @@ +// Single translation unit that instantiates the header-only audio decoders, mirroring the +// stb_image_impl.cpp precedent in the assets module. Nothing else in the engine may define these +// implementation macros -- doing so would produce duplicate symbols at link time. +// +// OpenAL is playback-only (it accepts raw PCM and nothing else), so decoding is the engine's +// responsibility. AudioDecoder.cpp is the only consumer of these. + +#define DR_WAV_IMPLEMENTATION +#include + +#define DR_MP3_IMPLEMENTATION +#include + +#define DR_FLAC_IMPLEMENTATION +#include + +// stb_vorbis ships as a .c file. It is compiled here as part of this TU so the whole decoder set +// is confined to one object file. STB_VORBIS_NO_STDIO is deliberately NOT set: the loader decodes +// straight from a path. +#define STB_VORBIS_NO_PUSHDATA_API +#include diff --git a/ICE/Audio/test/AudioDecoderTest.cpp b/ICE/Audio/test/AudioDecoderTest.cpp new file mode 100644 index 00000000..ea62d68c --- /dev/null +++ b/ICE/Audio/test/AudioDecoderTest.cpp @@ -0,0 +1,54 @@ +#include + +#include + +#include + +using namespace ICE; + +// Phase 0 scope: these prove the decoder dependencies are fetched, compiled into the single +// implementation TU, and reachable through the public header -- and that the failure paths return +// nullopt rather than crashing or returning a half-built buffer. Round-trip tests against real +// encoded fixtures land in phase 1 alongside AudioClipLoader, which is what will consume them. + +TEST(AudioDecoderTest, SupportedExtensionsAreLowercaseAndDotted) { + const auto& extensions = SupportedAudioExtensions(); + ASSERT_FALSE(extensions.empty()); + for (const auto& ext : extensions) { + EXPECT_EQ(ext.front(), '.') << ext << " should be dot-prefixed"; + EXPECT_EQ(ext, std::string(ext.begin(), ext.end())) << ext << " should already be lowercase"; + EXPECT_EQ(ext.find_first_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), std::string::npos); + } +} + +TEST(AudioDecoderTest, CoversTheFourPlannedFormats) { + const auto& extensions = SupportedAudioExtensions(); + for (const char* expected : {".wav", ".mp3", ".flac", ".ogg"}) { + EXPECT_NE(std::find(extensions.begin(), extensions.end(), expected), extensions.end()) << expected << " missing"; + } +} + +TEST(AudioDecoderTest, UnknownExtensionReturnsNullopt) { + EXPECT_FALSE(DecodeAudioFile("nonexistent.xyz").has_value()); +} + +// Each branch dispatches into a different decoder library; a missing file must be rejected by the +// library itself rather than faulting. This is what actually links all four decoders in. +TEST(AudioDecoderTest, MissingFileReturnsNulloptForEveryFormat) { + for (const auto& ext : SupportedAudioExtensions()) { + EXPECT_FALSE(DecodeAudioFile("definitely_not_here" + ext).has_value()) << "for " << ext; + } +} + +TEST(AudioDecoderTest, ExtensionMatchIsCaseInsensitive) { + // Uppercase must reach the decoder (and fail on the missing file), not fall through to the + // unknown-extension branch. Both return nullopt, so assert on reaching the same outcome for a + // path that exists in neither case -- the real assertion is that this does not crash. + EXPECT_FALSE(DecodeAudioFile("missing.WAV").has_value()); + EXPECT_FALSE(DecodeAudioFile("missing.Ogg").has_value()); +} + +TEST(AudioDecoderTest, DurationIsZeroForAnEmptyResult) { + DecodedAudio empty; + EXPECT_DOUBLE_EQ(empty.duration(), 0.0); // must not divide by a zero sample rate +} diff --git a/ICE/Audio/test/AudioEngineTest.cpp b/ICE/Audio/test/AudioEngineTest.cpp new file mode 100644 index 00000000..cac55b4d --- /dev/null +++ b/ICE/Audio/test/AudioEngineTest.cpp @@ -0,0 +1,502 @@ +#include + +#include +#include +#include + +#include + +#include "MockAudioBackend.h" + +using namespace ICE; + +namespace { + +// A bank holding one mono and one stereo clip, so tests can exercise both the spatial and the +// "this clip cannot be spatialized" paths. +struct Fixture { + std::shared_ptr bank = std::make_shared(); + std::shared_ptr backend; + std::unique_ptr audio; + AssetUID mono = NO_ASSET_ID; + AssetUID stereo = NO_ASSET_ID; + + explicit Fixture(std::size_t capacity = 4) : backend(std::make_shared(capacity)) { + backend->initialize({}); + // 100 frames of silence is enough: nothing here inspects sample values. + bank->addAsset("mono", std::make_shared(std::vector(100, 0), 1, 44100)); + bank->addAsset("stereo", std::make_shared(std::vector(200, 0), 2, 44100)); + mono = bank->getUID(AssetPath::WithTypePrefix("mono")); + stereo = bank->getUID(AssetPath::WithTypePrefix("stereo")); + audio = std::make_unique(backend, bank); + } +}; + +} // namespace + +TEST(AudioEngineTest, PlayingAnUnknownClipIsSilentNotFatal) { + Fixture f; + EXPECT_FALSE(f.audio->play(NO_ASSET_ID).valid()); + EXPECT_FALSE(f.audio->play(123456).valid()); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u); +} + +TEST(AudioEngineTest, PlayStartsAVoiceAndUploadsTheClipOnce) { + Fixture f; + auto a = f.audio->play(f.mono); + auto b = f.audio->play(f.mono); + ASSERT_TRUE(a.valid()); + ASSERT_TRUE(b.valid()); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 2u); + // Both voices share one uploaded buffer -- the registry caches by UID. + EXPECT_EQ(f.backend->uploadCount, 1); +} + +TEST(AudioEngineTest, TwoDPlaybackIsNotSpatialized) { + Fixture f; + auto v = f.audio->play(f.mono); + ASSERT_NE(f.backend->voice(v), nullptr); + EXPECT_FALSE(f.backend->voice(v)->params.spatial); +} + +TEST(AudioEngineTest, PlayAtMarksTheVoiceSpatialAndCarriesPosition) { + Fixture f; + auto v = f.audio->playAt(f.mono, {1.0f, 2.0f, 3.0f}); + ASSERT_NE(f.backend->voice(v), nullptr); + EXPECT_TRUE(f.backend->voice(v)->params.spatial); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.position.y(), 2.0f); +} + +// OpenAL will not spatialize a stereo buffer -- it plays flat at full volume. Silently accepting +// that looks exactly like a broken 3D positioning bug, so the engine demotes it explicitly. +TEST(AudioEngineTest, StereoClipRequestedSpatiallyIsDemotedTo2D) { + Fixture f; + auto v = f.audio->playAt(f.stereo, {10.0f, 0.0f, 0.0f}); + ASSERT_TRUE(v.valid()); + ASSERT_NE(f.backend->voice(v), nullptr); + EXPECT_FALSE(f.backend->voice(v)->params.spatial) << "a stereo clip must not be sent to the device as spatial"; +} + +TEST(AudioEngineTest, StopReleasesTheVoiceAndTheHandleGoesStale) { + Fixture f; + auto v = f.audio->play(f.mono); + f.audio->stop(v); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u); + EXPECT_FALSE(f.audio->isPlaying(v)); + EXPECT_EQ(f.backend->voice(v), nullptr) << "handle must not resolve after release"; + f.audio->stop(v); // double-stop must be harmless +} + +TEST(AudioEngineTest, UpdateReclaimsFinishedOneShots) { + Fixture f; + auto v = f.audio->play(f.mono); + f.backend->voice(v)->state = PlaybackState::Playing; + EXPECT_EQ(f.audio->getActiveVoiceCount(), 1u); + + f.backend->finish(v); + f.audio->update(0.016); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u); + EXPECT_EQ(f.backend->updateCount, 1); +} + +TEST(AudioEngineTest, LoopingVoicesAreNotReclaimed) { + Fixture f; + auto v = f.audio->play(f.mono, {.loop = true}); + for (int i = 0; i < 10; ++i) { + f.audio->update(0.016); + } + EXPECT_EQ(f.audio->getActiveVoiceCount(), 1u); +} + +// --- Voice pool exhaustion and stealing --------------------------------------------------------- + +TEST(AudioEngineTest, LowerPriorityVoiceIsStolenWhenThePoolIsFull) { + Fixture f(2); + auto quiet = f.audio->play(f.mono, {.priority = 10}); + auto loud = f.audio->play(f.mono, {.priority = 200}); + ASSERT_TRUE(quiet.valid()); + ASSERT_TRUE(loud.valid()); + + auto important = f.audio->play(f.mono, {.priority = 250}); + ASSERT_TRUE(important.valid()) << "a high-priority sound must displace a low-priority one"; + EXPECT_EQ(f.audio->getStolenVoiceCount(), 1u); + EXPECT_FALSE(f.audio->isPlaying(quiet)) << "the lowest-priority voice should be the victim"; + EXPECT_TRUE(f.audio->isPlaying(loud)); +} + +TEST(AudioEngineTest, AMoreImportantSoundIsNeverKilledForALessImportantOne) { + Fixture f(2); + f.audio->play(f.mono, {.priority = 200}); + f.audio->play(f.mono, {.priority = 200}); + + auto trivial = f.audio->play(f.mono, {.priority = 5}); + EXPECT_FALSE(trivial.valid()) << "the incoming sound should be dropped, not steal a better one"; + EXPECT_EQ(f.audio->getStolenVoiceCount(), 0u); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 2u); +} + +TEST(AudioEngineTest, EqualPriorityTieIsBrokenByAudibility) { + Fixture f(2); + f.audio->setListener({}); // listener at the origin + // Same priority; the distant one is quieter at the listener and should lose. + auto near_ = f.audio->playAt(f.mono, {1.0f, 0.0f, 0.0f}, {.priority = 100}); + auto far_ = f.audio->playAt(f.mono, {900.0f, 0.0f, 0.0f}, {.priority = 100}); + + auto incoming = f.audio->playAt(f.mono, {2.0f, 0.0f, 0.0f}, {.priority = 100}); + ASSERT_TRUE(incoming.valid()); + EXPECT_TRUE(f.audio->isPlaying(near_)) << "the closer, louder voice should survive"; + EXPECT_FALSE(f.audio->isPlaying(far_)); +} + +// --- Gain, mute, listener ----------------------------------------------------------------------- + +TEST(AudioEngineTest, MasterGainScalesVoiceGainWithoutCompounding) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 0.5f}); + f.audio->setMasterGain(0.5f); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.25f); + + // Re-applying must recompute from the stored per-voice gain, not multiply again. + f.audio->setMasterGain(0.5f); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.25f); + + f.audio->setMasterGain(1.0f); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.5f); +} + +TEST(AudioEngineTest, MuteSilencesAndUnmuteRestores) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 0.8f}); + f.audio->setMuted(true); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.0f); + f.audio->setMuted(false); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.8f); +} + +TEST(AudioEngineTest, SetListenerReachesTheBackend) { + Fixture f; + ListenerState listener; + listener.position = {5.0f, 0.0f, 0.0f}; + f.audio->setListener(listener); + EXPECT_EQ(f.backend->setListenerCount, 1); + EXPECT_FLOAT_EQ(f.backend->lastListener.position.x(), 5.0f); +} + +TEST(AudioEngineTest, StaleHandleOperationsAreNoOps) { + Fixture f; + auto v = f.audio->play(f.mono); + f.audio->stop(v); + // Every one of these must tolerate a handle whose voice is long gone. + f.audio->pause(v); + f.audio->resume(v); + f.audio->setVoiceParams(v, VoiceParams{}); + EXPECT_EQ(f.audio->getVoiceParams(v), nullptr); + EXPECT_FALSE(f.audio->isPlaying(v)); +} + +// --- Registry / eviction ------------------------------------------------------------------------ + +TEST(AudioRegistryTest, RemovingAnAssetEvictsItsBuffer) { + Fixture f; + f.audio->play(f.mono); + EXPECT_EQ(f.backend->residentBuffers(), 1u); + + f.bank->removeAsset(AssetPath::WithTypePrefix("mono")); + EXPECT_EQ(f.backend->releaseBufferCount, 1) << "the AssetBank removal listener should evict the upload"; + EXPECT_EQ(f.backend->residentBuffers(), 0u); +} + +TEST(AudioRegistryTest, ReimportUploadsAFreshBuffer) { + Fixture f; + f.audio->play(f.mono); + f.bank->removeAsset(AssetPath::WithTypePrefix("mono")); + + f.bank->addAsset("mono", std::make_shared(std::vector(50, 0), 1, 22050)); + AssetUID reimported = f.bank->getUID(AssetPath::WithTypePrefix("mono")); + ASSERT_TRUE(f.audio->play(reimported).valid()); + EXPECT_EQ(f.backend->uploadCount, 2); +} + +// --- Null backend ------------------------------------------------------------------------------- + +TEST(NullAudioBackendTest, EngineRunsFullyOnASilentBackend) { + auto bank = std::make_shared(); + bank->addAsset("mono", std::make_shared(std::vector(100, 0), 1, 44100)); + AssetUID clip = bank->getUID(AssetPath::WithTypePrefix("mono")); + + auto backend = std::make_shared(2); + backend->initialize({}); + AudioEngine audio(backend, bank); + + // This is the headless-CI path: everything must work, just inaudibly. + auto v = audio.play(clip); + EXPECT_TRUE(v.valid()); + EXPECT_TRUE(audio.isPlaying(v)); + audio.update(0.016); + EXPECT_EQ(audio.getActiveVoiceCount(), 1u); + + audio.setMasterGain(0.5f); + audio.setListener({}); + audio.stop(v); + EXPECT_EQ(audio.getActiveVoiceCount(), 0u); +} + +TEST(NullAudioBackendTest, RespectsItsCapacityAndStealingStillApplies) { + auto bank = std::make_shared(); + bank->addAsset("c", std::make_shared(std::vector(10, 0), 1, 8000)); + AssetUID clip = bank->getUID(AssetPath::WithTypePrefix("c")); + + auto backend = std::make_shared(1); + backend->initialize({}); + AudioEngine audio(backend, bank); + + ASSERT_TRUE(audio.play(clip, {.priority = 10}).valid()); + EXPECT_TRUE(audio.play(clip, {.priority = 250}).valid()); + EXPECT_EQ(audio.getStolenVoiceCount(), 1u); + EXPECT_EQ(audio.getActiveVoiceCount(), 1u); +} + +// --- AudioClip ---------------------------------------------------------------------------------- + +TEST(AudioClipTest, DerivesFrameCountAndDurationFromTheSampleBuffer) { + AudioClip stereo(std::vector(200, 0), 2, 100); + EXPECT_EQ(stereo.getFrameCount(), 100u); + EXPECT_DOUBLE_EQ(stereo.getDuration(), 1.0); + EXPECT_FALSE(stereo.isMono()); + + AudioClip mono(std::vector(100, 0), 1, 100); + EXPECT_EQ(mono.getFrameCount(), 100u); + EXPECT_TRUE(mono.isMono()); +} + +TEST(AudioClipTest, DefaultConstructedClipIsEmptyAndDivisionSafe) { + AudioClip clip; + EXPECT_TRUE(clip.isEmpty()); + EXPECT_EQ(clip.getFrameCount(), 0u); // must not divide by zero channels + EXPECT_DOUBLE_EQ(clip.getDuration(), 0.0); // must not divide by a zero sample rate +} + +// --- Mixer buses (phase 3) ---------------------------------------------------------------------- + +TEST(AudioBusTest, BusGainScalesOnlyItsOwnVoices) { + Fixture f; + auto music = f.audio->play(f.mono, {.volume = 1.0f, .bus = BusId::Music}); + auto sfx = f.audio->play(f.mono, {.volume = 1.0f, .bus = BusId::SFX}); + + f.audio->setBusGain(BusId::Music, 0.25f); + + EXPECT_FLOAT_EQ(f.backend->voice(music)->params.gain, 0.25f); + EXPECT_FLOAT_EQ(f.backend->voice(sfx)->params.gain, 1.0f) << "another bus must be untouched"; +} + +TEST(AudioBusTest, BusMasterAndVoiceGainsMultiply) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 0.5f, .bus = BusId::UI}); + f.audio->setBusGain(BusId::UI, 0.5f); + f.audio->setMasterGain(0.5f); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.125f); +} + +TEST(AudioBusTest, RepeatedBusChangesDoNotCompound) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 1.0f, .bus = BusId::SFX}); + for (int i = 0; i < 5; ++i) { + f.audio->setBusGain(BusId::SFX, 0.5f); + } + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.5f) << "per-voice gain is the source of truth"; +} + +TEST(AudioBusTest, MutingABusSilencesItAndUnmuteRestores) { + Fixture f; + auto music = f.audio->play(f.mono, {.volume = 0.8f, .bus = BusId::Music}); + auto sfx = f.audio->play(f.mono, {.volume = 0.8f, .bus = BusId::SFX}); + + f.audio->setBusMuted(BusId::Music, true); + EXPECT_FLOAT_EQ(f.backend->voice(music)->params.gain, 0.0f); + EXPECT_FLOAT_EQ(f.backend->voice(sfx)->params.gain, 0.8f); + + f.audio->setBusMuted(BusId::Music, false); + EXPECT_FLOAT_EQ(f.backend->voice(music)->params.gain, 0.8f); +} + +TEST(AudioBusTest, BusGainAppliesToVoicesStartedAfterTheChange) { + Fixture f; + f.audio->setBusGain(BusId::Voice, 0.5f); + auto v = f.audio->play(f.mono, {.volume = 1.0f, .bus = BusId::Voice}); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.5f); +} + +TEST(AudioBusTest, DefaultsAreUnityAndUnmuted) { + Fixture f; + for (int i = 0; i < static_cast(BusId::Count); ++i) { + EXPECT_FLOAT_EQ(f.audio->getBusGain(static_cast(i)), 1.0f); + EXPECT_FALSE(f.audio->isBusMuted(static_cast(i))); + } +} + +// Master mute must win regardless of bus state, and vice versa -- neither can un-silence the other. +TEST(AudioBusTest, MasterMuteOverridesAnUnmutedBus) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 1.0f, .bus = BusId::SFX}); + f.audio->setMuted(true); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.0f); + f.audio->setBusGain(BusId::SFX, 1.0f); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.0f) << "a bus change must not defeat the master mute"; +} + +// --- Fades (phase 4) ---------------------------------------------------------------------------- + +TEST(AudioFadeTest, FadeToRampsLinearlyAndLands) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 1.0f}); + f.audio->fadeTo(v, 0.0f, 1.0f); + EXPECT_TRUE(f.audio->isFading(v)); + + f.audio->update(0.5); + EXPECT_NEAR(f.backend->voice(v)->params.gain, 0.5f, 1e-4f) << "halfway through a 1s ramp"; + + f.audio->update(0.5); + EXPECT_NEAR(f.backend->voice(v)->params.gain, 0.0f, 1e-4f); + EXPECT_FALSE(f.audio->isFading(v)) << "the ramp should end once it lands"; +} + +TEST(AudioFadeTest, FadeInStartsSilent) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 1.0f, .fadeInSeconds = 1.0f}); + ASSERT_TRUE(v.valid()); + EXPECT_NEAR(f.backend->voice(v)->params.gain, 0.0f, 1e-4f) << "a fade-in must not start at full volume"; + f.audio->update(0.5); + EXPECT_NEAR(f.backend->voice(v)->params.gain, 0.5f, 1e-4f); +} + +TEST(AudioFadeTest, FadeOutStopsTheVoiceWhenItLands) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 1.0f}); + f.audio->fadeOut(v, 1.0f); + + f.audio->update(0.5); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 1u) << "still ramping"; + + f.audio->update(0.6); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u) << "the voice should be released once silent"; + EXPECT_FALSE(f.audio->isPlaying(v)); +} + +TEST(AudioFadeTest, ZeroLengthFadeAppliesImmediately) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 1.0f}); + f.audio->fadeTo(v, 0.25f, 0.0f); // must not divide by zero + EXPECT_NEAR(f.backend->voice(v)->params.gain, 0.25f, 1e-4f); + EXPECT_FALSE(f.audio->isFading(v)); + + auto w = f.audio->play(f.mono); + f.audio->fadeOut(w, 0.0f); + EXPECT_FALSE(f.audio->isPlaying(w)) << "a zero-length fade-out is just a stop"; +} + +// A fade sets the voice's AUTHORED gain, so bus and master scaling still apply on top -- the two +// compose rather than one overwriting the other. +TEST(AudioFadeTest, FadeComposesWithBusAndMasterGain) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 1.0f, .bus = BusId::Music}); + f.audio->setBusGain(BusId::Music, 0.5f); + f.audio->setMasterGain(0.5f); + + f.audio->fadeTo(v, 0.5f, 1.0f); + f.audio->update(1.0); + // authored 0.5 x bus 0.5 x master 0.5 + EXPECT_NEAR(f.backend->voice(v)->params.gain, 0.125f, 1e-4f); +} + +TEST(AudioFadeTest, FadingAStaleHandleIsHarmless) { + Fixture f; + auto v = f.audio->play(f.mono); + f.audio->stop(v); + f.audio->fadeTo(v, 0.5f, 1.0f); + f.audio->fadeOut(v, 1.0f); + f.audio->fadeIn(v, 1.0f, 1.0f); + EXPECT_FALSE(f.audio->isFading(v)); +} + +TEST(AudioFadeTest, CrossfadeStartsTheNewTrackAndRetiresTheOld) { + Fixture f; + auto first = f.audio->play(f.mono, {.volume = 1.0f, .bus = BusId::Music}); + auto second = f.audio->crossfadeTo(first, f.mono, 1.0f, {.volume = 1.0f, .bus = BusId::Music}); + ASSERT_TRUE(second.valid()); + EXPECT_NE(first, second); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 2u) << "both play during the crossfade"; + + f.audio->update(0.5); + EXPECT_NEAR(f.backend->voice(first)->params.gain, 0.5f, 1e-4f) << "old track fading out"; + EXPECT_NEAR(f.backend->voice(second)->params.gain, 0.5f, 1e-4f) << "new track fading in"; + + f.audio->update(0.6); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 1u) << "only the new track survives"; + EXPECT_TRUE(f.audio->isPlaying(second)); +} + +// If the incoming track cannot start, the outgoing one must keep playing rather than leaving +// silence where there used to be music. +TEST(AudioFadeTest, CrossfadeToAnUnplayableClipKeepsTheCurrentTrack) { + Fixture f; + auto current = f.audio->play(f.mono, {.volume = 1.0f}); + auto next = f.audio->crossfadeTo(current, NO_ASSET_ID, 1.0f); + EXPECT_FALSE(next.valid()); + f.audio->update(2.0); + EXPECT_TRUE(f.audio->isPlaying(current)) << "the current track must not have been faded out"; +} + +// --- Suspend (focus loss) ----------------------------------------------------------------------- + +TEST(AudioSuspendTest, SuspendSilencesWithoutTouchingMute) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 0.8f}); + + f.audio->setSuspended(true); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.0f); + EXPECT_FALSE(f.audio->isMuted()) << "suspension is not the user's mute setting"; + + f.audio->setSuspended(false); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.8f); +} + +// The editor mutes deliberately; alt-tabbing away and back must not undo that. +TEST(AudioSuspendTest, ResumingDoesNotClobberAUserMute) { + Fixture f; + auto v = f.audio->play(f.mono, {.volume = 0.8f}); + f.audio->setMuted(true); + + f.audio->setSuspended(true); + f.audio->setSuspended(false); + + EXPECT_TRUE(f.audio->isMuted()); + EXPECT_FLOAT_EQ(f.backend->voice(v)->params.gain, 0.0f) << "still muted after regaining focus"; +} + +// --- Streaming routing -------------------------------------------------------------------------- + +TEST(AudioStreamingTest, AStreamingClipTakesTheStreamingPath) { + auto bank = std::make_shared(); + // A streaming clip carries format but no samples; playback must not try to upload a buffer. + bank->addAsset("music", std::make_shared(AudioClip::Streaming(2, 44100, 44100 * 120))); + AssetUID id = bank->getUID(AssetPath::WithTypePrefix("music")); + + auto backend = std::make_shared(4); + backend->initialize({}); + AudioEngine audio(backend, bank); + + // No source file behind this clip, so the stream cannot open and playback is silent -- but it + // must have gone down the streaming branch, never uploading a buffer. + audio.play(id); + EXPECT_EQ(backend->uploadCount, 0) << "a streaming clip must not be uploaded as a resident buffer"; +} + +TEST(AudioClipTest, StreamingClipIsNotEmptyDespiteHavingNoSamples) { + AudioClip clip = AudioClip::Streaming(2, 48000, 48000 * 60); + EXPECT_TRUE(clip.isStreaming()); + EXPECT_FALSE(clip.isEmpty()) << "its content lives in the source file, not in samples()"; + EXPECT_TRUE(clip.samples().empty()); + EXPECT_EQ(clip.getFrameCount(), 48000u * 60u); + EXPECT_DOUBLE_EQ(clip.getDuration(), 60.0); + EXPECT_FALSE(clip.isMono()); +} diff --git a/ICE/Audio/test/AudioStreamTest.cpp b/ICE/Audio/test/AudioStreamTest.cpp new file mode 100644 index 00000000..e6e76f98 --- /dev/null +++ b/ICE/Audio/test/AudioStreamTest.cpp @@ -0,0 +1,182 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +using namespace ICE; +namespace fs = std::filesystem; + +namespace { + +// A WAV whose sample values encode their own frame index, so a test can tell exactly where in the +// stream a chunk came from -- which is what makes rewind and loop-continuity checkable rather than +// just "some audio came back". +fs::path writeRampWav(const std::string& name, uint32_t frames, uint16_t channels = 1, uint32_t rate = 8000) { + fs::path path = fs::temp_directory_path() / name; + std::vector pcm(static_cast(frames) * channels); + for (uint32_t i = 0; i < frames; ++i) { + for (uint16_t c = 0; c < channels; ++c) { + pcm[i * channels + c] = static_cast(i % 30000); + } + } + const uint32_t data_bytes = static_cast(pcm.size() * 2); + + std::ofstream out(path, std::ios::binary); + auto u32 = [&](uint32_t v) { out.write(reinterpret_cast(&v), 4); }; + auto u16 = [&](uint16_t v) { out.write(reinterpret_cast(&v), 2); }; + out.write("RIFF", 4); + u32(36 + data_bytes); + out.write("WAVE", 4); + out.write("fmt ", 4); + u32(16); + u16(1); // PCM + u16(channels); + u32(rate); + u32(rate * channels * 2); + u16(static_cast(channels * 2)); + u16(16); + out.write("data", 4); + u32(data_bytes); + out.write(reinterpret_cast(pcm.data()), data_bytes); + return path; +} + +// Owns a generated WAV and deletes it when the test ends. +// +// DECLARE THIS BEFORE ANY STREAM THAT READS IT. Locals destruct in reverse declaration order, so +// declaring the TempWav first means the decoder handle is closed before the file is deleted. +// Windows refuses to delete a file that still has an open handle; POSIX allows it, which is why +// deleting the file with the stream still alive passed locally and failed on the Windows runner. +// +// Deletion is also non-throwing: failing to clean up a temp file is not a reason to fail a test +// that already verified what it set out to (and on Windows an AV scanner can hold a handle open +// briefly regardless of what this process does). +struct TempWav { + fs::path path; + + TempWav(const std::string& name, uint32_t frames, uint16_t channels = 1, uint32_t rate = 8000) + : path(writeRampWav(name, frames, channels, rate)) {} + + ~TempWav() { + std::error_code ec; + fs::remove(path, ec); + } + + TempWav(const TempWav&) = delete; + TempWav& operator=(const TempWav&) = delete; +}; + +} // namespace + +TEST(AudioStreamTest, ReportsFormatFromTheHeader) { + TempWav wav("ice_stream_fmt.wav", 1000, 2, 22050); + auto stream = OpenAudioFileStream(wav.path); + ASSERT_NE(stream, nullptr); + EXPECT_EQ(stream->getChannels(), 2u); + EXPECT_EQ(stream->getSampleRate(), 22050u); + EXPECT_EQ(stream->getTotalFrames(), 1000u); +} + +TEST(AudioStreamTest, ReadsIncrementallyInOrder) { + TempWav wav("ice_stream_seq.wav", 500); + auto stream = OpenAudioFileStream(wav.path); + ASSERT_NE(stream, nullptr); + + std::vector buffer(100); + EXPECT_EQ(stream->read(buffer.data(), 100), 100u); + EXPECT_EQ(buffer[0], 0) << "first chunk starts at frame 0"; + EXPECT_EQ(buffer[99], 99); + + EXPECT_EQ(stream->read(buffer.data(), 100), 100u); + EXPECT_EQ(buffer[0], 100) << "the second read must continue where the first stopped"; +} + +TEST(AudioStreamTest, ShortReadAtEndThenZero) { + TempWav wav("ice_stream_eof.wav", 150); + auto stream = OpenAudioFileStream(wav.path); + ASSERT_NE(stream, nullptr); + + std::vector buffer(100); + EXPECT_EQ(stream->read(buffer.data(), 100), 100u); + EXPECT_EQ(stream->read(buffer.data(), 100), 50u) << "a partial final chunk"; + EXPECT_EQ(stream->read(buffer.data(), 100), 0u) << "exhausted"; +} + +// Looping a streamed sound is the decoder rewinding, so this is the loop point: after a rewind the +// very next sample must be frame 0 again, with no gap. +TEST(AudioStreamTest, RewindReturnsToTheStart) { + TempWav wav("ice_stream_rewind.wav", 200); + auto stream = OpenAudioFileStream(wav.path); + ASSERT_NE(stream, nullptr); + + std::vector buffer(200); + ASSERT_EQ(stream->read(buffer.data(), 200), 200u); + EXPECT_EQ(stream->read(buffer.data(), 10), 0u); + + stream->rewind(); + ASSERT_EQ(stream->read(buffer.data(), 10), 10u); + EXPECT_EQ(buffer[0], 0) << "rewind must resume at frame 0"; + EXPECT_EQ(buffer[9], 9); +} + +// The seamless-loop case: filling a chunk larger than what remains must wrap into the rewound +// stream and leave NO silence at the join. +TEST(AudioStreamTest, LoopFillAcrossTheEndHasNoGap) { + const uint32_t total = 120; + TempWav wav("ice_stream_loop.wav", total); + auto stream = OpenAudioFileStream(wav.path); + ASSERT_NE(stream, nullptr); + + // Mirrors the backend's loop-fill: read, and on a short read rewind and top the chunk up. + std::vector chunk(200, -1); + uint64_t got = stream->read(chunk.data(), 200); + ASSERT_EQ(got, total); + stream->rewind(); + while (got < 200) { + const uint64_t more = stream->read(chunk.data() + got, 200 - got); + ASSERT_GT(more, 0u); + got += more; + } + + EXPECT_EQ(got, 200u); + EXPECT_EQ(chunk[total - 1], static_cast(total - 1)) << "last frame before the join"; + EXPECT_EQ(chunk[total], 0) << "the join must continue straight into frame 0, not silence"; + EXPECT_EQ(chunk[total + 1], 1); +} + +TEST(AudioStreamTest, UnsupportedOrMissingFileReturnsNull) { + EXPECT_EQ(OpenAudioFileStream("does_not_exist.wav"), nullptr); + EXPECT_EQ(OpenAudioFileStream("thing.xyz"), nullptr); +} + +// --- Loader threshold --------------------------------------------------------------------------- + +TEST(AudioStreamTest, LoaderStreamsFilesAtOrAboveTheThreshold) { + TempWav big("ice_stream_big.wav", 40000); // ~80 KB of PCM + const std::size_t original = AudioClipLoader::streamingThresholdBytes(); + + AudioClipLoader loader; + + AudioClipLoader::setStreamingThresholdBytes(1024); // force the streaming path + auto streamed = loader.load({big.path}); + ASSERT_NE(streamed, nullptr); + EXPECT_TRUE(streamed->isStreaming()); + EXPECT_TRUE(streamed->samples().empty()) << "a streaming clip holds no resident PCM"; + EXPECT_EQ(streamed->getChannels(), 1u); + EXPECT_EQ(streamed->getFrameCount(), 40000u) << "length still comes from the header"; + + AudioClipLoader::setStreamingThresholdBytes(100u * 1024 * 1024); // force the resident path + auto resident = loader.load({big.path}); + ASSERT_NE(resident, nullptr); + EXPECT_FALSE(resident->isStreaming()); + EXPECT_EQ(resident->getFrameCount(), 40000u); + EXPECT_FALSE(resident->samples().empty()); + + AudioClipLoader::setStreamingThresholdBytes(original); +} diff --git a/ICE/Audio/test/CMakeLists.txt b/ICE/Audio/test/CMakeLists.txt new file mode 100644 index 00000000..61631a67 --- /dev/null +++ b/ICE/Audio/test/CMakeLists.txt @@ -0,0 +1,52 @@ +cmake_minimum_required(VERSION 3.19) +project(audio-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +add_executable(AudioDecoderTestSuite + AudioDecoderTest.cpp +) + +add_test(NAME AudioDecoderTestSuite + COMMAND AudioDecoderTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(AudioDecoderTestSuite + PRIVATE + gtest_main + audio +) + +# Voice pool, stealing policy, gain/mute, buffer eviction and the silent-backend fallback. Runs +# entirely on MockAudioBackend/NullAudioBackend, so it needs no audio device -- which is what makes +# it meaningful on CI. +add_executable(AudioEngineTestSuite + AudioEngineTest.cpp +) + +add_test(NAME AudioEngineTestSuite + COMMAND AudioEngineTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(AudioEngineTestSuite + PRIVATE + gtest_main + audio +) + +# Incremental decoding: sequential reads, rewind (the loop point), short reads at EOF, and the +# loader's resident-vs-streaming threshold. Pure decode, so no audio device is required. +add_executable(AudioStreamTestSuite + AudioStreamTest.cpp +) + +add_test(NAME AudioStreamTestSuite + COMMAND AudioStreamTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(AudioStreamTestSuite + PRIVATE + gtest_main + audio +) diff --git a/ICE/Audio/test/MockAudioBackend.h b/ICE/Audio/test/MockAudioBackend.h new file mode 100644 index 00000000..c68bffbf --- /dev/null +++ b/ICE/Audio/test/MockAudioBackend.h @@ -0,0 +1,133 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace ICE { + +// Recording backend for tests: behaves like a real one (fixed capacity, generational handles that +// go stale on release) but records what it was told to do instead of making sound. This is what +// lets the voice-pool, stealing and eviction logic be tested exactly as it will run, with no audio +// device present -- which is the situation on CI. +class MockAudioBackend : public IAudioBackend { + public: + struct VoiceRecord { + AudioBufferHandle buffer; + VoiceDesc desc; + VoiceParams params; + PlaybackState state = PlaybackState::Stopped; + // Set by the test to simulate a one-shot reaching its end; AudioEngine::update should then + // reclaim the voice. + bool finished = false; + }; + + explicit MockAudioBackend(std::size_t capacity = 4) : m_capacity(capacity) {} + + bool initialize(const AudioDeviceConfig& config) override { + initialized = true; + lastConfig = config; + return initializeSucceeds; + } + void shutdown() override { initialized = false; } + bool isAvailable() const override { return initialized; } + std::string deviceName() const override { return "mock"; } + + AudioBufferHandle uploadClip(const AudioClip&) override { + ++uploadCount; + return m_buffers.insert(0); + } + void releaseBuffer(AudioBufferHandle buffer) override { + if (m_buffers.erase(buffer)) { + ++releaseBufferCount; + } + } + + VoiceHandle acquireVoice(AudioBufferHandle buffer, const VoiceDesc& desc) override { + if (m_voices.size() >= m_capacity) { + ++acquireFailures; + return {}; + } + return m_voices.insert(VoiceRecord{buffer, desc, desc.params, PlaybackState::Stopped, false}); + } + VoiceHandle acquireStreamingVoice(const std::shared_ptr& stream, const VoiceDesc& desc) override { + ++streamingAcquireCount; + lastStream = stream; + if (m_voices.size() >= m_capacity) { + ++acquireFailures; + return {}; + } + return m_voices.insert(VoiceRecord{AudioBufferHandle{}, desc, desc.params, PlaybackState::Stopped, false}); + } + + void releaseVoice(VoiceHandle voice) override { + if (m_voices.erase(voice)) { + ++releaseVoiceCount; + } + } + + void setVoiceParams(VoiceHandle voice, const VoiceParams& params) override { + if (auto* v = m_voices.get(voice)) { + v->params = params; + } + } + void setVoiceState(VoiceHandle voice, PlaybackState state) override { + if (auto* v = m_voices.get(voice)) { + v->state = state; + } + } + bool isVoiceActive(VoiceHandle voice) const override { + const auto* v = m_voices.get(voice); + return v != nullptr && !v->finished && v->state != PlaybackState::Stopped; + } + + std::size_t activeVoiceCount() const override { return m_voices.size(); } + std::size_t voiceCapacity() const override { return m_capacity; } + + void setListener(const ListenerState& listener) override { + lastListener = listener; + ++setListenerCount; + } + void update(double delta) override { + ++updateCount; + lastDelta = delta; + } + + // --- test helpers --------------------------------------------------------------------------- + VoiceRecord* voice(VoiceHandle h) { return m_voices.get(h); } + void finish(VoiceHandle h) { + if (auto* v = m_voices.get(h)) { + v->finished = true; + } + } + std::vector liveDescs() { + std::vector out; + m_voices.forEachHandle([&](VoiceHandle, VoiceRecord& v) { out.push_back(v.desc); }); + return out; + } + std::size_t residentBuffers() const { return m_buffers.size(); } + + bool initialized = false; + bool initializeSucceeds = true; + AudioDeviceConfig lastConfig{}; + ListenerState lastListener{}; + double lastDelta = 0.0; + int uploadCount = 0; + int releaseBufferCount = 0; + int releaseVoiceCount = 0; + int acquireFailures = 0; + int setListenerCount = 0; + int updateCount = 0; + int streamingAcquireCount = 0; + std::shared_ptr lastStream; + + private: + HandlePool m_voices; + HandlePool m_buffers; + std::size_t m_capacity; +}; + +} // namespace ICE diff --git a/ICE/AudioAPI/CMakeLists.txt b/ICE/AudioAPI/CMakeLists.txt new file mode 100644 index 00000000..666bf4e1 --- /dev/null +++ b/ICE/AudioAPI/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.19) +project(audio_api) + +message(STATUS "Building ${PROJECT_NAME} backends") + +# Container directory only -- no meta-target. See ICE/AudioAPI/OpenAL/CMakeLists.txt for why the +# graphics_api meta-target pattern is not repeated here. +add_subdirectory(OpenAL) diff --git a/ICE/AudioAPI/OpenAL/CMakeLists.txt b/ICE/AudioAPI/OpenAL/CMakeLists.txt new file mode 100644 index 00000000..16e7ed91 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.19) +project(audio_api_openal) + +message(STATUS "Building ${PROJECT_NAME} module") + +add_library(${PROJECT_NAME} STATIC) + +target_sources(${PROJECT_NAME} PRIVATE + src/OpenALDevice.cpp + src/OpenALBackend.cpp +) + +# Depends on the audio interface layer in one direction only. Note there is deliberately no +# `audio_api` meta-target mirroring `graphics_api`: that pairing is a tracked baseline cycle +# (graphics_api <-> graphics_api_OpenGL, see docs/module_dependencies.md), and there is no reason +# to reproduce it here. Backends link `audio`; consumers link the backend they want. +target_link_libraries(${PROJECT_NAME} + PUBLIC + audio + PRIVATE + OpenAL::OpenAL +) + +target_include_directories(${PROJECT_NAME} PUBLIC + $ + $) + +enable_testing() +add_subdirectory(test) diff --git a/ICE/AudioAPI/OpenAL/include/OpenALAudioFactory.h b/ICE/AudioAPI/OpenAL/include/OpenALAudioFactory.h new file mode 100644 index 00000000..31f09094 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/include/OpenALAudioFactory.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include "OpenALBackend.h" + +namespace ICE { + +// Concrete factory for the OpenAL backend, mirroring OpenGLFactory on the graphics side. Selecting +// a different audio backend later means handing the engine a different factory -- nothing in core +// names OpenAL. +class OpenALAudioFactory : public AudioFactory { + public: + std::shared_ptr createBackend() const override { return std::make_shared(); } + std::string name() const override { return "OpenAL"; } +}; + +} // namespace ICE diff --git a/ICE/AudioAPI/OpenAL/include/OpenALBackend.h b/ICE/AudioAPI/OpenAL/include/OpenALBackend.h new file mode 100644 index 00000000..4d9df9d7 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/include/OpenALBackend.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +#include + +#include "OpenALDevice.h" + +namespace ICE { + +// IAudioBackend on OpenAL Soft. +// +// Threading: OpenAL Soft mixes on its own internal thread, and while its al* entry points are +// thread-safe in practice, the OpenAL spec makes no such guarantee and the calls take locks. Every +// method here is therefore MAIN-THREAD ONLY, driven by AudioEngine. (The phase 4 streaming refill +// thread will be the single, explicit exception, touching only its own sources.) +// +// The source set is allocated once in initialize() and never grows: alGenSources can fail +// mid-frame, and there is no graceful recovery from that during gameplay. Running out of voices is +// reported by acquireVoice returning a null handle, which AudioEngine answers with its stealing +// policy. +class OpenALBackend : public IAudioBackend { + public: + OpenALBackend(); + ~OpenALBackend() override; + + bool initialize(const AudioDeviceConfig& config) override; + void shutdown() override; + + bool isAvailable() const override; + std::string deviceName() const override; + + AudioBufferHandle uploadClip(const AudioClip& clip) override; + void releaseBuffer(AudioBufferHandle buffer) override; + + VoiceHandle acquireVoice(AudioBufferHandle buffer, const VoiceDesc& desc) override; + VoiceHandle acquireStreamingVoice(const std::shared_ptr& stream, const VoiceDesc& desc) override; + void releaseVoice(VoiceHandle voice) override; + + void setVoiceParams(VoiceHandle voice, const VoiceParams& params) override; + void setVoiceState(VoiceHandle voice, PlaybackState state) override; + bool isVoiceActive(VoiceHandle voice) const override; + + std::size_t activeVoiceCount() const override; + std::size_t voiceCapacity() const override; + + void setListener(const ListenerState& listener) override; + void update(double delta) override; + void setScheduler(const std::shared_ptr& scheduler) override; + std::size_t streamUnderrunCount() const override; + + // Device capabilities, for logging and the editor's audio panel. + const OpenALDeviceInfo& deviceInfo() const; + + private: + // Advance one streaming voice: unqueue spent buffers, queue newly decoded chunks, keep the + // decoder ahead, and restart the source if it ran dry. Main thread only. + void pumpStream(VoiceHandle handle); + + struct Impl; + std::unique_ptr m_impl; +}; + +} // namespace ICE diff --git a/ICE/AudioAPI/OpenAL/include/OpenALDevice.h b/ICE/AudioAPI/OpenAL/include/OpenALDevice.h new file mode 100644 index 00000000..3c3eb9e0 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/include/OpenALDevice.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include +#include +#include + +namespace ICE { + +// Describes the device that was actually opened, for logging and for the editor's audio settings +// panel. Populated by OpenALDevice::open(). +struct OpenALDeviceInfo { + std::string deviceName; + std::string version; + std::string renderer; + bool hrtfAvailable = false; + bool efxAvailable = false; // ALC_EXT_EFX -- reverb and occlusion filters (phase 5) + int monoSources = 0; // upper bound on simultaneously playing 3D voices + int stereoSources = 0; +}; + +// RAII wrapper over the ALCdevice/ALCcontext pair. Deliberately exposes no OpenAL types: the +// OpenAL::OpenAL link is PRIVATE to this module, so must not leak into a public header. +// +// This is the seam the phase 1 OpenALBackend is built on. On its own it does nothing but open, +// interrogate and close the default device -- which is exactly what proves the dependency links +// and a device is reachable on each platform. +class OpenALDevice { + public: + OpenALDevice(); + ~OpenALDevice(); + + OpenALDevice(const OpenALDevice&) = delete; + OpenALDevice& operator=(const OpenALDevice&) = delete; + + // Open the default device and make its context current. Returns false if no device is + // available -- the expected case on a headless CI machine, and the engine's cue to fall back + // to the null backend rather than treating audio as fatal. + // + // The config's voice counts are passed as context attributes. This matters: OpenAL Soft's + // DEFAULT context allocates 255 mono but only ONE stereo source, which would silently cap all + // non-spatialized playback (music + UI together) at a single simultaneous sound. + bool open(const AudioDeviceConfig& config = {}); + void close(); + + bool isOpen() const; + const OpenALDeviceInfo& info() const; + + // Devices the driver advertises. Empty if enumeration is unsupported. + static std::vector enumerateDevices(); + + private: + struct Impl; + std::unique_ptr m_impl; +}; + +} // namespace ICE diff --git a/ICE/AudioAPI/OpenAL/src/ALCheck.h b/ICE/AudioAPI/OpenAL/src/ALCheck.h new file mode 100644 index 00000000..65c04cc2 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/src/ALCheck.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include // ALC_HRTF_SOFT and the other OpenAL Soft extension tokens +#include + +// alGetError() is a global, per-context error flag with no call-site association: an unchecked +// error silently persists and is then misattributed to whatever call happens to check next. Every +// al* call in this module goes through AL_CHECK so a failure names the operation that caused it. +// +// Retrofitting this is miserable, so it exists from the first call onward. +#define AL_CHECK(call) \ + do { \ + (call); \ + if (ALenum _al_err = alGetError(); _al_err != AL_NO_ERROR) { \ + ICE::Logger::Log(ICE::Logger::ERROR, "Audio", "%s failed: %s (0x%x)", #call, \ + ICE::alErrorString(_al_err), _al_err); \ + } \ + } while (0) + +// Same idea for the ALC (context/device) layer, whose errors live on the device rather than the +// current context. +#define ALC_CHECK(device, call) \ + do { \ + (call); \ + if (ALCenum _alc_err = alcGetError(device); _alc_err != ALC_NO_ERROR) { \ + ICE::Logger::Log(ICE::Logger::ERROR, "Audio", "%s failed: %s (0x%x)", #call, \ + ICE::alcErrorString(_alc_err), _alc_err); \ + } \ + } while (0) + +namespace ICE { + +inline const char* alErrorString(ALenum error) { + switch (error) { + case AL_NO_ERROR: return "AL_NO_ERROR"; + case AL_INVALID_NAME: return "AL_INVALID_NAME"; + case AL_INVALID_ENUM: return "AL_INVALID_ENUM"; + case AL_INVALID_VALUE: return "AL_INVALID_VALUE"; + case AL_INVALID_OPERATION: return "AL_INVALID_OPERATION"; + case AL_OUT_OF_MEMORY: return "AL_OUT_OF_MEMORY"; + default: return "unknown AL error"; + } +} + +inline const char* alcErrorString(ALCenum error) { + switch (error) { + case ALC_NO_ERROR: return "ALC_NO_ERROR"; + case ALC_INVALID_DEVICE: return "ALC_INVALID_DEVICE"; + case ALC_INVALID_CONTEXT: return "ALC_INVALID_CONTEXT"; + case ALC_INVALID_ENUM: return "ALC_INVALID_ENUM"; + case ALC_INVALID_VALUE: return "ALC_INVALID_VALUE"; + case ALC_OUT_OF_MEMORY: return "ALC_OUT_OF_MEMORY"; + default: return "unknown ALC error"; + } +} + +} // namespace ICE diff --git a/ICE/AudioAPI/OpenAL/src/OpenALBackend.cpp b/ICE/AudioAPI/OpenAL/src/OpenALBackend.cpp new file mode 100644 index 00000000..d7dd3898 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/src/OpenALBackend.cpp @@ -0,0 +1,561 @@ +#include "OpenALBackend.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "ALCheck.h" + +namespace ICE { +namespace { + +ALenum distanceModelFor(AttenuationModel model) { + switch (model) { + case AttenuationModel::None: return AL_NONE; + case AttenuationModel::LinearDistance: return AL_LINEAR_DISTANCE_CLAMPED; + case AttenuationModel::ExponentDistance: return AL_EXPONENT_DISTANCE_CLAMPED; + case AttenuationModel::InverseDistance: + default: return AL_INVERSE_DISTANCE_CLAMPED; + } +} + +ALenum formatFor(const AudioClip& clip) { + // The decoders normalize everything to 16-bit, so only the channel count varies here. + return clip.getChannels() == 1 ? AL_FORMAT_MONO16 : AL_FORMAT_STEREO16; +} + +} // namespace + +// Streaming tuning. Four chunks of ~0.35s gives well over a second of buffered audio -- enough to +// absorb a stalled frame or a slow decode without the source running dry, while keeping the memory +// per streaming voice modest (a few hundred KB). +constexpr std::size_t kStreamChunks = 4; +constexpr double kStreamChunkSeconds = 0.35; + +// Per-chunk handoff between the decode job (producer) and update() (consumer). +enum class ChunkState : uint8_t { Empty, Filling, Ready }; + +// Shared state for one streaming voice, held by shared_ptr so an in-flight decode job keeps it +// alive even if the voice is released mid-decode. That is what makes teardown lock-free: release +// just sets `cancelled` and drops the backend's reference; the job observes the flag, stops, and +// the state dies with the last reference. Nothing ever joins a decode job, so nothing can deadlock. +struct StreamState { + std::shared_ptr stream; + + struct Chunk { + std::vector pcm; // sized for kStreamChunkSeconds + uint64_t frames = 0; // valid frames in pcm + std::atomic state{ChunkState::Empty}; // producer/consumer handoff + }; + std::array chunks; + + std::atomic decoding{false}; // exactly one decode job in flight at a time + std::atomic cancelled{false}; // set by releaseVoice; the job bails out + std::atomic eof{false}; // stream exhausted and not looping + bool looping = false; + + uint32_t channels = 0; + uint32_t sample_rate = 0; + ALenum format = AL_FORMAT_MONO16; +}; + +// A voice is a borrowed source id plus the buffer it is playing. The id comes from the fixed set +// allocated at initialize() and returns to the free list on release. A streaming voice instead +// owns its own queue of AL buffers, fed from `stream`. +struct OpenALVoice { + ALuint source = 0; + AudioBufferHandle buffer; + + // Streaming only (null for a resident voice). + std::shared_ptr stream; + std::vector queue_buffers; // owned by this voice, deleted on release + std::vector free_buffers; // subset of queue_buffers not currently queued on the source +}; + +struct OpenALBackend::Impl { + OpenALDevice device; + AudioDeviceConfig config; + bool initialized = false; + + HandlePool buffers; + HandlePool voices; + + // Source ids allocated up front and handed out by acquireVoice. + std::vector all_sources; + std::vector free_sources; + + std::shared_ptr scheduler; // optional; decode runs inline without it + std::size_t underruns = 0; +}; + +namespace { +// Decode into every Empty chunk we can, in place. Runs on a worker thread (or inline when there is +// no scheduler) and touches ONLY the stream and its staging chunks -- never OpenAL. That is the +// invariant that keeps every al* call on the main thread. +void decodeChunks(const std::shared_ptr& state) { + for (auto& chunk : state->chunks) { + if (state->cancelled.load(std::memory_order_acquire)) { + break; + } + ChunkState expected = ChunkState::Empty; + if (!chunk.state.compare_exchange_strong(expected, ChunkState::Filling, std::memory_order_acq_rel)) { + continue; // already Ready, or being consumed + } + + const uint64_t capacity_frames = chunk.pcm.size() / state->channels; + uint64_t got = state->stream->read(chunk.pcm.data(), capacity_frames); + + // End of the sound: rewind and keep filling the same chunk so the loop point falls inside + // a chunk rather than leaving a silent gap at the queue boundary. + if (got < capacity_frames) { + if (state->looping) { + state->stream->rewind(); + while (got < capacity_frames) { + const uint64_t more = state->stream->read(chunk.pcm.data() + got * state->channels, capacity_frames - got); + if (more == 0) { + break; // empty or unreadable stream; avoid spinning forever + } + got += more; + } + } else if (got == 0) { + state->eof.store(true, std::memory_order_release); + chunk.state.store(ChunkState::Empty, std::memory_order_release); + break; + } + } + + chunk.frames = got; + chunk.state.store(got > 0 ? ChunkState::Ready : ChunkState::Empty, std::memory_order_release); + } + state->decoding.store(false, std::memory_order_release); +} +} // namespace + +OpenALBackend::OpenALBackend() : m_impl(std::make_unique()) {} + +OpenALBackend::~OpenALBackend() { + shutdown(); +} + +bool OpenALBackend::initialize(const AudioDeviceConfig& config) { + if (m_impl->initialized) { + return true; + } + m_impl->config = config; + + if (!m_impl->device.open(config)) { + return false; // caller falls back to NullAudioBackend + } + + // Allocate the whole source set now. The device reports its own ceiling, so never ask for more + // than it will give -- alGenSources would fail and leave us in a half-allocated state. + const auto& info = m_impl->device.info(); + const int requested = config.monoVoices + config.stereoVoices; + const int available = info.monoSources > 0 ? info.monoSources : requested; + const int count = std::min(requested, available); + + m_impl->all_sources.resize(static_cast(count)); + alGetError(); // clear any stale flag before a call whose result we branch on + alGenSources(count, m_impl->all_sources.data()); + if (ALenum err = alGetError(); err != AL_NO_ERROR) { + Logger::Log(Logger::ERROR, "Audio", "alGenSources(%d) failed: %s -- audio disabled.", count, alErrorString(err)); + m_impl->all_sources.clear(); + m_impl->device.close(); + return false; + } + m_impl->free_sources.assign(m_impl->all_sources.begin(), m_impl->all_sources.end()); + + // Distance model is a GLOBAL context setting in OpenAL, not per-source -- per-source you only + // get reference/max distance and rolloff. Hence a project-wide setting rather than a + // per-component one. + AL_CHECK(alDistanceModel(distanceModelFor(config.attenuation))); + AL_CHECK(alDopplerFactor(config.dopplerFactor)); + AL_CHECK(alSpeedOfSound(config.speedOfSound)); + + m_impl->initialized = true; + Logger::Log(Logger::INFO, "Audio", "OpenAL backend ready: %d voices on '%s'.", count, info.deviceName.c_str()); + return true; +} + +void OpenALBackend::shutdown() { + if (!m_impl->initialized) { + return; + } + // Sources first (they reference buffers), then buffers, then the device. + for (ALuint source : m_impl->all_sources) { + alSourceStop(source); + alSourcei(source, AL_BUFFER, 0); + } + if (!m_impl->all_sources.empty()) { + alDeleteSources(static_cast(m_impl->all_sources.size()), m_impl->all_sources.data()); + } + m_impl->all_sources.clear(); + m_impl->free_sources.clear(); + m_impl->voices = {}; + + // AudioRegistry::clear() releases buffers before the backend is torn down, so normally there + // is nothing left here. Delete any stragglers rather than leaking them into the driver. + std::vector leftover; + m_impl->buffers.forEachHandle([&](AudioBufferHandle, ALuint& id) { leftover.push_back(id); }); + if (!leftover.empty()) { + Logger::Log(Logger::DEBUG, "Audio", "Releasing %zu audio buffer(s) still resident at shutdown.", leftover.size()); + alDeleteBuffers(static_cast(leftover.size()), leftover.data()); + } + m_impl->buffers = {}; + + m_impl->device.close(); + m_impl->initialized = false; +} + +bool OpenALBackend::isAvailable() const { + return m_impl->initialized && m_impl->device.isOpen(); +} + +std::string OpenALBackend::deviceName() const { + return m_impl->device.info().deviceName; +} + +const OpenALDeviceInfo& OpenALBackend::deviceInfo() const { + return m_impl->device.info(); +} + +AudioBufferHandle OpenALBackend::uploadClip(const AudioClip& clip) { + if (!isAvailable() || clip.isEmpty()) { + return {}; + } + + ALuint id = 0; + alGetError(); + alGenBuffers(1, &id); + if (ALenum err = alGetError(); err != AL_NO_ERROR) { + Logger::Log(Logger::ERROR, "Audio", "alGenBuffers failed: %s", alErrorString(err)); + return {}; + } + + const auto& samples = clip.samples(); + alBufferData(id, formatFor(clip), samples.data(), static_cast(samples.size() * sizeof(int16_t)), + static_cast(clip.getSampleRate())); + if (ALenum err = alGetError(); err != AL_NO_ERROR) { + Logger::Log(Logger::ERROR, "Audio", "alBufferData failed: %s", alErrorString(err)); + alDeleteBuffers(1, &id); + return {}; + } + + return m_impl->buffers.insert(id); +} + +void OpenALBackend::releaseBuffer(AudioBufferHandle buffer) { + ALuint* id = m_impl->buffers.get(buffer); + if (id == nullptr) { + return; + } + + // OpenAL refuses to delete a buffer that is still attached to a source, so stop and detach any + // voice playing it first. This is the path an asset re-import takes (AssetBank fires its + // removal listener -> AudioRegistry::evict -> here) while the old sound may still be audible. + std::vector playing; + m_impl->voices.forEachHandle([&](VoiceHandle handle, OpenALVoice& voice) { + if (voice.buffer == buffer) { + playing.push_back(handle); + } + }); + for (VoiceHandle handle : playing) { + releaseVoice(handle); + } + + AL_CHECK(alDeleteBuffers(1, id)); + m_impl->buffers.erase(buffer); +} + +VoiceHandle OpenALBackend::acquireVoice(AudioBufferHandle buffer, const VoiceDesc& desc) { + if (!isAvailable() || m_impl->free_sources.empty()) { + return {}; // out of voices -- AudioEngine decides whether to steal + } + ALuint* buffer_id = m_impl->buffers.get(buffer); + if (buffer_id == nullptr) { + return {}; + } + + const ALuint source = m_impl->free_sources.back(); + m_impl->free_sources.pop_back(); + + AL_CHECK(alSourcei(source, AL_BUFFER, static_cast(*buffer_id))); + VoiceHandle handle = m_impl->voices.insert(OpenALVoice{source, buffer}); + setVoiceParams(handle, desc.params); + return handle; +} + +VoiceHandle OpenALBackend::acquireStreamingVoice(const std::shared_ptr& stream, const VoiceDesc& desc) { + if (!isAvailable() || stream == nullptr || !stream->isValid() || m_impl->free_sources.empty()) { + return {}; + } + const uint32_t channels = stream->getChannels(); + const uint32_t rate = stream->getSampleRate(); + if (channels == 0 || rate == 0) { + return {}; + } + + auto state = std::make_shared(); + state->stream = stream; + state->channels = channels; + state->sample_rate = rate; + state->format = channels == 1 ? AL_FORMAT_MONO16 : AL_FORMAT_STEREO16; + state->looping = desc.params.looping; + + const auto frames_per_chunk = static_cast(kStreamChunkSeconds * rate); + for (auto& chunk : state->chunks) { + chunk.pcm.resize(frames_per_chunk * channels); + } + + // Prime synchronously so playback can begin this frame rather than after a decode round-trip. + decodeChunks(state); + + const ALuint source = m_impl->free_sources.back(); + m_impl->free_sources.pop_back(); + + OpenALVoice voice; + voice.source = source; + voice.stream = state; + voice.queue_buffers.resize(kStreamChunks); + alGetError(); + alGenBuffers(static_cast(kStreamChunks), voice.queue_buffers.data()); + if (ALenum err = alGetError(); err != AL_NO_ERROR) { + Logger::Log(Logger::ERROR, "Audio", "alGenBuffers for a streaming voice failed: %s", alErrorString(err)); + m_impl->free_sources.push_back(source); + return {}; + } + // All of this voice's buffers start unqueued and available to fill. + voice.free_buffers = voice.queue_buffers; + + // A streaming source must have no static buffer attached, and AL_LOOPING must stay off: on a + // queued-buffer source it would loop the individual queued chunk instead of the sound. + AL_CHECK(alSourcei(source, AL_BUFFER, 0)); + AL_CHECK(alSourcei(source, AL_LOOPING, AL_FALSE)); + + VoiceHandle handle = m_impl->voices.insert(std::move(voice)); + setVoiceParams(handle, desc.params); + pumpStream(handle); // queue the primed chunks + return handle; +} + +// Move decoded chunks into the source's AL queue and keep the decoder ahead of playback. Main +// thread only -- every al* call in the streaming path lives here. +void OpenALBackend::pumpStream(VoiceHandle handle) { + OpenALVoice* v = m_impl->voices.get(handle); + if (v == nullptr || v->stream == nullptr) { + return; + } + auto& state = v->stream; + + // 1. Reclaim buffers the device has finished with. + ALint processed = 0; + alGetSourcei(v->source, AL_BUFFERS_PROCESSED, &processed); + while (processed-- > 0) { + ALuint done = 0; + AL_CHECK(alSourceUnqueueBuffers(v->source, 1, &done)); + v->free_buffers.push_back(done); + } + + // 2. Queue every ready chunk into a free buffer. + for (auto& chunk : state->chunks) { + if (v->free_buffers.empty()) { + break; + } + if (chunk.state.load(std::memory_order_acquire) != ChunkState::Ready) { + continue; + } + const ALuint buffer = v->free_buffers.back(); + v->free_buffers.pop_back(); + + AL_CHECK(alBufferData(buffer, state->format, chunk.pcm.data(), + static_cast(chunk.frames * state->channels * sizeof(int16_t)), + static_cast(state->sample_rate))); + AL_CHECK(alSourceQueueBuffers(v->source, 1, &buffer)); + chunk.state.store(ChunkState::Empty, std::memory_order_release); + } + + // 3. Keep the decoder ahead. One job at a time per stream, so the stream object is never + // touched concurrently and needs no lock of its own. + bool expected = false; + if (!state->eof.load(std::memory_order_acquire) && state->decoding.compare_exchange_strong(expected, true)) { + auto captured = state; // shared_ptr: outlives the voice if it is released mid-decode + if (m_impl->scheduler) { + m_impl->scheduler->submit([captured] { decodeChunks(captured); }); + } else { + decodeChunks(captured); // no scheduler: correct, but spikes this frame + } + } + + // 4. Underrun recovery. A source that ran dry stops on its own; restart it once audio is + // queued again. Without this a single late refill would silence the music permanently. + ALint queued = 0; + ALint state_al = 0; + alGetSourcei(v->source, AL_BUFFERS_QUEUED, &queued); + alGetSourcei(v->source, AL_SOURCE_STATE, &state_al); + if (queued > 0 && state_al == AL_STOPPED) { + ++m_impl->underruns; + AL_CHECK(alSourcePlay(v->source)); + } +} + +void OpenALBackend::releaseVoice(VoiceHandle voice) { + OpenALVoice* v = m_impl->voices.get(voice); + if (v == nullptr) { + return; // stale handle -- exactly what the generation check is for + } + AL_CHECK(alSourceStop(v->source)); + + if (v->stream != nullptr) { + // Tell any in-flight decode job to stop. We do NOT wait for it: the job holds its own + // shared_ptr to the stream state, so it can finish harmlessly against state that no longer + // belongs to a voice. Nothing joins, so a stop during a refill cannot deadlock. + v->stream->cancelled.store(true, std::memory_order_release); + + // Unqueue everything before deleting: OpenAL refuses to delete a queued buffer. + ALint processed = 0; + alGetSourcei(v->source, AL_BUFFERS_PROCESSED, &processed); + while (processed-- > 0) { + ALuint done = 0; + alSourceUnqueueBuffers(v->source, 1, &done); + } + AL_CHECK(alSourcei(v->source, AL_BUFFER, 0)); // detaches any still-queued buffers + if (!v->queue_buffers.empty()) { + AL_CHECK(alDeleteBuffers(static_cast(v->queue_buffers.size()), v->queue_buffers.data())); + } + } else { + AL_CHECK(alSourcei(v->source, AL_BUFFER, 0)); // detach so the buffer can be deleted later + } + + m_impl->free_sources.push_back(v->source); + m_impl->voices.erase(voice); +} + +void OpenALBackend::setVoiceParams(VoiceHandle voice, const VoiceParams& params) { + OpenALVoice* v = m_impl->voices.get(voice); + if (v == nullptr) { + return; + } + const ALuint source = v->source; + + AL_CHECK(alSourcef(source, AL_GAIN, params.gain)); + AL_CHECK(alSourcef(source, AL_PITCH, params.pitch)); + if (v->stream != nullptr) { + // Never set AL_LOOPING on a queued-buffer source: it would loop whichever chunk is + // currently queued instead of the sound. Streamed looping is the decoder rewinding. + v->stream->looping = params.looping; + } else { + AL_CHECK(alSourcei(source, AL_LOOPING, params.looping ? AL_TRUE : AL_FALSE)); + } + + if (params.spatial) { + AL_CHECK(alSourcei(source, AL_SOURCE_RELATIVE, AL_FALSE)); + AL_CHECK(alSource3f(source, AL_POSITION, params.position.x(), params.position.y(), params.position.z())); + AL_CHECK(alSource3f(source, AL_VELOCITY, params.velocity.x(), params.velocity.y(), params.velocity.z())); + AL_CHECK(alSourcef(source, AL_REFERENCE_DISTANCE, params.minDistance)); + AL_CHECK(alSourcef(source, AL_MAX_DISTANCE, params.maxDistance)); + AL_CHECK(alSourcef(source, AL_ROLLOFF_FACTOR, params.rolloff)); + } else { + // Head-relative at the origin: plays flat at full gain regardless of where the listener is + // or faces. This is the correct mode for music and UI, and also where a stereo clip ends + // up (OpenAL will not spatialize one). + AL_CHECK(alSourcei(source, AL_SOURCE_RELATIVE, AL_TRUE)); + AL_CHECK(alSource3f(source, AL_POSITION, 0.0f, 0.0f, 0.0f)); + AL_CHECK(alSource3f(source, AL_VELOCITY, 0.0f, 0.0f, 0.0f)); + AL_CHECK(alSourcef(source, AL_ROLLOFF_FACTOR, 0.0f)); + } +} + +void OpenALBackend::setVoiceState(VoiceHandle voice, PlaybackState state) { + OpenALVoice* v = m_impl->voices.get(voice); + if (v == nullptr) { + return; + } + switch (state) { + case PlaybackState::Playing: AL_CHECK(alSourcePlay(v->source)); break; + case PlaybackState::Paused: AL_CHECK(alSourcePause(v->source)); break; + case PlaybackState::Stopped: AL_CHECK(alSourceStop(v->source)); break; + } +} + +bool OpenALBackend::isVoiceActive(VoiceHandle voice) const { + const OpenALVoice* v = m_impl->voices.get(voice); + if (v == nullptr) { + return false; + } + ALint state = 0; + alGetSourcei(v->source, AL_SOURCE_STATE, &state); + if (state == AL_PLAYING || state == AL_PAUSED) { + return true; + } + + // A streaming voice that momentarily ran dry reports AL_STOPPED even though the sound is not + // over. Reporting it inactive would make AudioEngine reclaim it and the music would vanish on + // the first hitch. It is finished only once the decoder hit EOF *and* the queue has drained. + if (v->stream != nullptr && !v->stream->eof.load(std::memory_order_acquire)) { + return true; + } + if (v->stream != nullptr) { + ALint queued = 0; + alGetSourcei(v->source, AL_BUFFERS_QUEUED, &queued); + ALint processed = 0; + alGetSourcei(v->source, AL_BUFFERS_PROCESSED, &processed); + return queued > processed; // audio still pending on the device + } + return false; +} + +std::size_t OpenALBackend::activeVoiceCount() const { + return m_impl->voices.size(); +} + +std::size_t OpenALBackend::voiceCapacity() const { + return m_impl->all_sources.size(); +} + +void OpenALBackend::setListener(const ListenerState& listener) { + if (!isAvailable()) { + return; + } + AL_CHECK(alListener3f(AL_POSITION, listener.position.x(), listener.position.y(), listener.position.z())); + AL_CHECK(alListener3f(AL_VELOCITY, listener.velocity.x(), listener.velocity.y(), listener.velocity.z())); + AL_CHECK(alListenerf(AL_GAIN, listener.gain)); + + // AL_ORIENTATION is six floats: the "at" vector followed by "up". + const ALfloat orientation[6] = {listener.forward.x(), listener.forward.y(), listener.forward.z(), + listener.up.x(), listener.up.y(), listener.up.z()}; + AL_CHECK(alListenerfv(AL_ORIENTATION, orientation)); +} + +void OpenALBackend::update(double /*delta*/) { + // Mixing runs on OpenAL's own thread and finished-voice reclamation is driven by AudioEngine + // polling isVoiceActive. What remains here is the streaming refill: all of it main-thread, with + // only the decode itself pushed onto the scheduler. + if (!isAvailable()) { + return; + } + std::vector streaming; + m_impl->voices.forEachHandle([&](VoiceHandle handle, OpenALVoice& voice) { + if (voice.stream != nullptr) { + streaming.push_back(handle); + } + }); + for (VoiceHandle handle : streaming) { + pumpStream(handle); + } +} + +void OpenALBackend::setScheduler(const std::shared_ptr& scheduler) { + m_impl->scheduler = scheduler; +} + +std::size_t OpenALBackend::streamUnderrunCount() const { + return m_impl->underruns; +} + +} // namespace ICE diff --git a/ICE/AudioAPI/OpenAL/src/OpenALDevice.cpp b/ICE/AudioAPI/OpenAL/src/OpenALDevice.cpp new file mode 100644 index 00000000..42fcfe13 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/src/OpenALDevice.cpp @@ -0,0 +1,109 @@ +#include "OpenALDevice.h" + +#include "ALCheck.h" + +namespace ICE { + +struct OpenALDevice::Impl { + ALCdevice* device = nullptr; + ALCcontext* context = nullptr; + OpenALDeviceInfo info; +}; + +OpenALDevice::OpenALDevice() : m_impl(std::make_unique()) {} + +OpenALDevice::~OpenALDevice() { + close(); +} + +bool OpenALDevice::open(const AudioDeviceConfig& config) { + if (m_impl->device != nullptr) { + return true; // idempotent + } + + m_impl->device = alcOpenDevice(nullptr); // default device + if (m_impl->device == nullptr) { + Logger::Log(Logger::WARNING, "Audio", "No OpenAL device available; audio will be silent."); + return false; + } + + // Ask for the voice counts we actually need. Without this the driver's defaults apply, which + // on OpenAL Soft means a single stereo source -- see the note in OpenALDevice::open's docs. + // ALC_HRTF_SOFT comes from ALC_SOFT_HRTF; requesting it on a device without the extension is + // harmless (the attribute is ignored). + const ALCint attributes[] = { + ALC_MONO_SOURCES, config.monoVoices, + ALC_STEREO_SOURCES, config.stereoVoices, + ALC_HRTF_SOFT, config.preferHRTF ? ALC_TRUE : ALC_FALSE, + 0 // list terminator + }; + + m_impl->context = alcCreateContext(m_impl->device, attributes); + if (m_impl->context == nullptr || alcMakeContextCurrent(m_impl->context) == ALC_FALSE) { + Logger::Log(Logger::ERROR, "Audio", "Opened an OpenAL device but could not create/attach its context."); + close(); + return false; + } + + auto& info = m_impl->info; + const ALCchar* name = alcGetString(m_impl->device, ALC_ALL_DEVICES_SPECIFIER); + info.deviceName = name != nullptr ? name : "unknown"; + const ALchar* version = alGetString(AL_VERSION); + info.version = version != nullptr ? version : "unknown"; + const ALchar* renderer = alGetString(AL_RENDERER); + info.renderer = renderer != nullptr ? renderer : "unknown"; + + // Capabilities the later phases depend on. Probed once here so a missing extension is a + // startup log line rather than a mystery at the point of use. + info.hrtfAvailable = alcIsExtensionPresent(m_impl->device, "ALC_SOFT_HRTF") == ALC_TRUE; + info.efxAvailable = alcIsExtensionPresent(m_impl->device, "ALC_EXT_EFX") == ALC_TRUE; + + // The hard ceiling on simultaneous voices -- this is why the voice pool and its stealing + // policy are mandatory rather than an optimization (see the phase 1 VoicePool). + alcGetIntegerv(m_impl->device, ALC_MONO_SOURCES, 1, &info.monoSources); + alcGetIntegerv(m_impl->device, ALC_STEREO_SOURCES, 1, &info.stereoSources); + + Logger::Log(Logger::INFO, "Audio", "OpenAL device '%s' (%s, %s) -- %d mono / %d stereo sources, HRTF %s, EFX %s", + info.deviceName.c_str(), info.version.c_str(), info.renderer.c_str(), info.monoSources, info.stereoSources, + info.hrtfAvailable ? "yes" : "no", info.efxAvailable ? "yes" : "no"); + return true; +} + +void OpenALDevice::close() { + if (m_impl->context != nullptr) { + alcMakeContextCurrent(nullptr); + alcDestroyContext(m_impl->context); + m_impl->context = nullptr; + } + if (m_impl->device != nullptr) { + alcCloseDevice(m_impl->device); + m_impl->device = nullptr; + } +} + +bool OpenALDevice::isOpen() const { + return m_impl->context != nullptr; +} + +const OpenALDeviceInfo& OpenALDevice::info() const { + return m_impl->info; +} + +std::vector OpenALDevice::enumerateDevices() { + std::vector devices; + if (alcIsExtensionPresent(nullptr, "ALC_ENUMERATE_ALL_EXT") != ALC_TRUE) { + return devices; + } + // A double-null-terminated list of null-terminated strings. + const ALCchar* list = alcGetString(nullptr, ALC_ALL_DEVICES_SPECIFIER); + if (list == nullptr) { + return devices; + } + for (const ALCchar* entry = list; *entry != '\0';) { + devices.emplace_back(entry); + entry += devices.back().size() + 1; + } + return devices; +} + +} // namespace ICE diff --git a/ICE/AudioAPI/OpenAL/test/CMakeLists.txt b/ICE/AudioAPI/OpenAL/test/CMakeLists.txt new file mode 100644 index 00000000..6dfb0d09 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/test/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.19) +project(audio-openal-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +# Exercises the streaming buffer-queue machinery against a REAL OpenAL device: priming, underrun +# tolerance, looping by rewind, and releasing a voice while a decode job is in flight. Each test +# skips itself when no device can be opened (the normal state on CI), so the suite is safe to run +# everywhere; the device-free paths are covered by the audio module's null/mock suites. +add_executable(OpenALStreamingTestSuite + OpenALStreamingTest.cpp +) + +add_test(NAME OpenALStreamingTestSuite + COMMAND OpenALStreamingTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(OpenALStreamingTestSuite + PRIVATE + gtest_main + audio_api_openal +) diff --git a/ICE/AudioAPI/OpenAL/test/OpenALStreamingTest.cpp b/ICE/AudioAPI/OpenAL/test/OpenALStreamingTest.cpp new file mode 100644 index 00000000..c37af923 --- /dev/null +++ b/ICE/AudioAPI/OpenAL/test/OpenALStreamingTest.cpp @@ -0,0 +1,258 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace ICE; + +namespace { + +// A synthetic stream, so these tests exercise the queue/refill machinery without touching the +// filesystem. `blockFor` lets a test hold a decode in progress, which is how the stop-during-refill +// race is made deterministic instead of hoped for. +class FakeStream : public IAudioStream { + public: + explicit FakeStream(uint64_t totalFrames, uint32_t channels = 1, uint32_t rate = 8000) + : m_total(totalFrames), + m_channels(channels), + m_rate(rate) {} + + uint32_t getChannels() const override { return m_channels; } + uint32_t getSampleRate() const override { return m_rate; } + uint64_t getTotalFrames() const override { return m_total; } + bool isValid() const override { return true; } + + uint64_t read(int16_t* out, uint64_t maxFrames) override { + ++readCalls; + if (blockFor.load() > 0) { + inRead.store(true); + std::this_thread::sleep_for(std::chrono::milliseconds(blockFor.load())); + inRead.store(false); + } + const uint64_t remaining = m_total > m_pos ? m_total - m_pos : 0; + const uint64_t got = std::min(remaining, maxFrames); + for (uint64_t i = 0; i < got * m_channels; ++i) { + out[i] = 0; + } + m_pos += got; + return got; + } + + void rewind() override { + ++rewinds; + m_pos = 0; + } + + std::atomic blockFor{0}; // ms to stall inside read() + std::atomic inRead{false}; + std::atomic readCalls{0}; + std::atomic rewinds{0}; + + private: + uint64_t m_total; + uint64_t m_pos = 0; + uint32_t m_channels; + uint32_t m_rate; +}; + +// These need a real device. CI usually has none, and that is a legitimate environment rather than a +// failure -- the null backend covers that path in the audio suite. +std::shared_ptr makeBackend() { + auto backend = std::make_shared(); + if (!backend->initialize({})) { + return nullptr; + } + return backend; +} + +#define REQUIRE_DEVICE(backend) \ + if ((backend) == nullptr) { \ + GTEST_SKIP() << "no audio device available on this machine"; \ + } + +} // namespace + +TEST(OpenALStreamingTest, AcquiresAStreamingVoiceAndPrimesIt) { + auto backend = makeBackend(); + REQUIRE_DEVICE(backend); + + auto stream = std::make_shared(8000 * 5); + VoiceDesc desc; + desc.params.spatial = false; + + VoiceHandle voice = backend->acquireStreamingVoice(stream, desc); + ASSERT_TRUE(voice.valid()); + EXPECT_GT(stream->readCalls.load(), 0) << "the queue should be primed before playback starts"; + EXPECT_EQ(backend->activeVoiceCount(), 1u); + + backend->releaseVoice(voice); + EXPECT_EQ(backend->activeVoiceCount(), 0u); + backend->shutdown(); +} + +TEST(OpenALStreamingTest, RejectsAnInvalidStream) { + auto backend = makeBackend(); + REQUIRE_DEVICE(backend); + EXPECT_FALSE(backend->acquireStreamingVoice(nullptr, VoiceDesc{}).valid()); + backend->shutdown(); +} + +// A streaming voice must not be reported inactive just because the source momentarily ran dry; +// AudioEngine would reclaim it and the music would vanish on the first hitch. +TEST(OpenALStreamingTest, StaysActiveWhileTheStreamHasMoreAudio) { + auto backend = makeBackend(); + REQUIRE_DEVICE(backend); + + auto stream = std::make_shared(8000 * 30); // 30s: far from exhausted + VoiceDesc desc; + desc.params.spatial = false; + VoiceHandle voice = backend->acquireStreamingVoice(stream, desc); + ASSERT_TRUE(voice.valid()); + + backend->setVoiceState(voice, PlaybackState::Playing); + for (int i = 0; i < 5; ++i) { + backend->update(0.016); + EXPECT_TRUE(backend->isVoiceActive(voice)) << "iteration " << i; + } + backend->releaseVoice(voice); + backend->shutdown(); +} + +TEST(OpenALStreamingTest, LoopingRewindsRatherThanEnding) { + auto backend = makeBackend(); + REQUIRE_DEVICE(backend); + + // Shorter than one chunk, so the very first fill has to wrap. + auto stream = std::make_shared(100); + VoiceDesc desc; + desc.params.spatial = false; + desc.params.looping = true; + + VoiceHandle voice = backend->acquireStreamingVoice(stream, desc); + ASSERT_TRUE(voice.valid()); + EXPECT_GT(stream->rewinds.load(), 0) << "a looping stream shorter than a chunk must rewind to fill it"; + + backend->releaseVoice(voice); + backend->shutdown(); +} + +TEST(OpenALStreamingTest, NonLoopingStreamEventuallyGoesInactive) { + auto backend = makeBackend(); + REQUIRE_DEVICE(backend); + + auto stream = std::make_shared(50); // a few ms of audio + VoiceDesc desc; + desc.params.spatial = false; + VoiceHandle voice = backend->acquireStreamingVoice(stream, desc); + ASSERT_TRUE(voice.valid()); + backend->setVoiceState(voice, PlaybackState::Playing); + + bool went_inactive = false; + for (int i = 0; i < 200 && !went_inactive; ++i) { + backend->update(0.016); + went_inactive = !backend->isVoiceActive(voice); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + EXPECT_TRUE(went_inactive) << "a finished, non-looping stream must not stay active forever"; + + backend->releaseVoice(voice); + backend->shutdown(); +} + +// The concurrency case the design is built around: releasing a voice while a decode job is running +// must not block, deadlock, or use freed state. The job holds its own shared_ptr to the stream +// state, so release just cancels and walks away. +TEST(OpenALStreamingTest, ReleaseDuringAnInFlightDecodeDoesNotDeadlock) { + auto backend = makeBackend(); + REQUIRE_DEVICE(backend); + + auto scheduler = std::make_shared(2); + backend->setScheduler(scheduler); + + auto stream = std::make_shared(8000 * 60); + VoiceDesc desc; + desc.params.spatial = false; + + VoiceHandle voice = backend->acquireStreamingVoice(stream, desc); + ASSERT_TRUE(voice.valid()); + backend->setVoiceState(voice, PlaybackState::Playing); + + // Make the next decode slow, then kick one off and tear the voice down while it runs. + stream->blockFor.store(150); + backend->update(0.016); + + const auto start = std::chrono::steady_clock::now(); + backend->releaseVoice(voice); + const auto elapsed = std::chrono::steady_clock::now() - start; + + EXPECT_LT(std::chrono::duration_cast(elapsed).count(), 100) + << "releaseVoice must not wait for the decode job to finish"; + EXPECT_EQ(backend->activeVoiceCount(), 0u); + + // Let the orphaned job finish against its own (still-alive) state before the scheduler dies. + std::this_thread::sleep_for(std::chrono::milliseconds(400)); + backend->shutdown(); +} + +TEST(OpenALStreamingTest, DecodesOnTheSchedulerWhenOneIsAttached) { + auto backend = makeBackend(); + REQUIRE_DEVICE(backend); + + auto scheduler = std::make_shared(2); + backend->setScheduler(scheduler); + + auto stream = std::make_shared(8000 * 60); + VoiceDesc desc; + desc.params.spatial = false; + VoiceHandle voice = backend->acquireStreamingVoice(stream, desc); + ASSERT_TRUE(voice.valid()); + + const int before = stream->readCalls.load(); + backend->update(0.016); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + EXPECT_GT(stream->readCalls.load(), before) << "the detached decode job should have run"; + + backend->releaseVoice(voice); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + backend->shutdown(); +} + +// Many streaming voices at once: buffers and sources must all come back, with no leak into the +// driver and no cross-talk between voices' buffer queues. +TEST(OpenALStreamingTest, ManyStreamingVoicesAcquireAndReleaseCleanly) { + auto backend = makeBackend(); + REQUIRE_DEVICE(backend); + + std::vector voices; + for (int i = 0; i < 8; ++i) { + auto stream = std::make_shared(8000 * 10); + VoiceDesc desc; + desc.params.spatial = false; + VoiceHandle v = backend->acquireStreamingVoice(stream, desc); + ASSERT_TRUE(v.valid()) << "voice " << i; + voices.push_back(v); + } + EXPECT_EQ(backend->activeVoiceCount(), 8u); + + for (int i = 0; i < 3; ++i) { + backend->update(0.016); + } + for (VoiceHandle v : voices) { + backend->releaseVoice(v); + } + EXPECT_EQ(backend->activeVoiceCount(), 0u); + + // The sources must be back in the pool: a fresh voice still acquires. + auto stream = std::make_shared(8000); + VoiceHandle again = backend->acquireStreamingVoice(stream, VoiceDesc{}); + EXPECT_TRUE(again.valid()); + backend->releaseVoice(again); + backend->shutdown(); +} diff --git a/ICE/AudioSystem/CMakeLists.txt b/ICE/AudioSystem/CMakeLists.txt new file mode 100644 index 00000000..7a4a8904 --- /dev/null +++ b/ICE/AudioSystem/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.19) +project(audio_system) + +message(STATUS "Building ${PROJECT_NAME} module") + +add_library(${PROJECT_NAME} STATIC) + +target_sources(${PROJECT_NAME} PRIVATE + src/AudioSystem.cpp +) + +# The ECS half of the audio stack: it is the ONLY place where the audio layer meets the scene. +# +# Why this is its own module rather than living in `system` beside RenderSystem: `system` sits +# inside the pre-existing assets <-> graphics <-> scene <-> system cycle (see +# docs/module_dependencies.md). Adding `system -> audio` pulls `audio` into that SCC and the +# module_cycle_check test rejects it -- verified, it reports `audio->assets` and `system->audio` as +# new back-edges. Sitting above both instead keeps `audio` free of any renderer dependency and adds +# no cycle. Fold this into `system` once the assets/graphics debt is paid down. +target_link_libraries(${PROJECT_NAME} + PUBLIC + audio + system + components +) + +target_include_directories(${PROJECT_NAME} PUBLIC + $ + $) + +enable_testing() +add_subdirectory(test) diff --git a/ICE/AudioSystem/include/AudioSystem.h b/ICE/AudioSystem/include/AudioSystem.h new file mode 100644 index 00000000..eff1a312 --- /dev/null +++ b/ICE/AudioSystem/include/AudioSystem.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace ICE { + +// Drives positional audio from the ECS: pushes the listener's world pose to the audio engine, then +// reconciles every AudioSourceComponent's authored state against its live voice. +// +// Runs at AudioSystemOrder (350) -- after SceneGraphSystem (300), so every world matrix it reads is +// final for the frame, and before RenderSystem (400), so a frame's audio and visuals derive from +// the same state. +// +// Listener selection, in order: +// 1. an entity with an active AudioListenerComponent (explicit intent wins), else +// 2. the listener entity set via setListenerEntity() -- how the engine passes the scene's active +// camera, so a single-viewpoint game needs no setup at all, else +// 3. no listener: spatial sources are still positioned, relative to the origin. +class AudioSystem : public System { + public: + AudioSystem(const std::shared_ptr& registry, AudioEngine* audio); + + void update(double delta) override; + void onEntityRemoved(Entity e) override; + + int updateOrder() const override { return AudioSystemOrder; } + + // Fallback listener used when no entity carries an AudioListenerComponent. The engine points + // this at the scene's active camera entity on activation. NULL_ENTITY disables the fallback. + void setListenerEntity(Entity e) { m_fallback_listener = e; } + Entity getListenerEntity() const { return m_fallback_listener; } + + // The entity that actually drove the listener last update (NULL_ENTITY if none did). Exposed + // for the editor's audio panel and for tests. + Entity getActiveListener() const { return m_active_listener; } + + std::vector getSignatures(const ComponentManager& comp_manager) const override { + // Sources need a transform to be positioned; listeners need one to be oriented. Two + // signatures rather than one, so an entity carrying either is tracked. + Signature source_sig; + source_sig.set(comp_manager.getComponentType()); + source_sig.set(comp_manager.getComponentType()); + + Signature listener_sig; + listener_sig.set(comp_manager.getComponentType()); + listener_sig.set(comp_manager.getComponentType()); + + return {source_sig, listener_sig}; + } + + private: + // Find and push the listener pose. Returns the entity used, or NULL_ENTITY. + Entity updateListener(double delta); + + // Reconcile one source's authored state with its live voice. + void updateSource(Entity e, AudioSourceComponent& source, TransformComponent& transform, double delta); + + // Start a voice for `source`, recording its handle in the component. + void startVoice(Entity e, AudioSourceComponent& source, const Eigen::Vector3f& world_position); + + // Translate the component's authored fields into device parameters. + VoiceParams buildParams(const AudioSourceComponent& source, const Eigen::Vector3f& world_position, + const Eigen::Vector3f& velocity) const; + + static VoiceHandle handleOf(const AudioSourceComponent& source) { + return VoiceHandle{source.voice_index, source.voice_generation}; + } + static void setHandle(AudioSourceComponent& source, VoiceHandle handle) { + source.voice_index = handle.index; + source.voice_generation = handle.generation; + } + static void clearHandle(AudioSourceComponent& source) { setHandle(source, VoiceHandle{}); } + + // Per-frame world position delta, in units/second, for Doppler. Returns zero on the first frame + // a thing is seen (no previous sample) and whenever delta is degenerate -- a spurious huge + // velocity would produce an audible pitch glitch on spawn. + static Eigen::Vector3f velocityFrom(const Eigen::Vector3f& current, Eigen::Vector3f& last, bool& has_last, double delta); + + // Non-owning: the Registry owns this system, so a shared_ptr here would form an ownership + // cycle (same reason SceneGraphSystem holds a raw Scene*). + Registry* m_registry = nullptr; + AudioEngine* m_audio = nullptr; + + Entity m_fallback_listener = NULL_ENTITY; + Entity m_active_listener = NULL_ENTITY; + bool m_warned_multiple_listeners = false; + + // Previous listener position when the listener is the camera-entity fallback. An explicit + // AudioListenerComponent caches this in the component instead; the fallback entity has no + // audio component to hold it, so it lives here. + Eigen::Vector3f m_fallback_last_position = Eigen::Vector3f::Zero(); + bool m_fallback_has_last_position = false; +}; + +} // namespace ICE diff --git a/ICE/AudioSystem/src/AudioSystem.cpp b/ICE/AudioSystem/src/AudioSystem.cpp new file mode 100644 index 00000000..8c0e78c2 --- /dev/null +++ b/ICE/AudioSystem/src/AudioSystem.cpp @@ -0,0 +1,210 @@ +#include "AudioSystem.h" + +#include + +namespace ICE { + +AudioSystem::AudioSystem(const std::shared_ptr& registry, AudioEngine* audio) + : m_registry(registry.get()), + m_audio(audio) {} + +void AudioSystem::update(double delta) { + if (m_audio == nullptr || m_registry == nullptr) { + return; + } + + // Listener first: source gains and stealing decisions are ranked by distance to it, so a stale + // listener would mis-rank everything this frame. + m_active_listener = updateListener(delta); + + m_registry->each( + [&](Entity e, AudioSourceComponent& source, TransformComponent& transform) { updateSource(e, source, transform, delta); }); +} + +Entity AudioSystem::updateListener(double delta) { + Entity chosen = NULL_ENTITY; + AudioListenerComponent* chosen_component = nullptr; + int active_count = 0; + + // An explicit AudioListenerComponent always beats the camera fallback: it is the author saying + // "hear from here, not from the camera". + m_registry->each([&](Entity e, AudioListenerComponent& listener, TransformComponent&) { + if (!listener.active) { + return; + } + ++active_count; + if (chosen == NULL_ENTITY) { + chosen = e; + chosen_component = &listener; + } + }); + + if (active_count > 1 && !m_warned_multiple_listeners) { + m_warned_multiple_listeners = true; + Logger::Log(Logger::WARNING, "Audio", "%d active AudioListenerComponents in the scene; using entity %u. A scene has one ear.", + active_count, chosen); + } + + if (chosen == NULL_ENTITY) { + chosen = m_fallback_listener; // the scene's active camera, supplied by the engine + } + if (chosen == NULL_ENTITY || !m_registry->isAlive(chosen)) { + return NULL_ENTITY; + } + + auto* transform = m_registry->tryGetComponent(chosen); + if (transform == nullptr) { + return NULL_ENTITY; // a camera with no transform has no pose to hear from + } + + const Eigen::Matrix4f world = transform->getWorldMatrix(); + ListenerState state; + state.position = world.block<3, 1>(0, 3); + // OpenAL's AL_ORIENTATION is (at, up). The engine's convention matches the camera's: -Z is + // forward, +Y is up, so those come straight off the rotation columns of the world matrix. + state.forward = -world.block<3, 1>(0, 2).normalized(); + state.up = world.block<3, 1>(0, 1).normalized(); + state.gain = chosen_component != nullptr ? chosen_component->volume : 1.0f; + + if (chosen_component != nullptr) { + state.velocity = + velocityFrom(state.position, chosen_component->last_world_position, chosen_component->has_last_position, delta); + } else { + // Camera-fallback listener: there is no component to cache the previous position in, so + // keep it on the system itself. + state.velocity = velocityFrom(state.position, m_fallback_last_position, m_fallback_has_last_position, delta); + } + + m_audio->setListener(state); + return chosen; +} + +void AudioSystem::updateSource(Entity e, AudioSourceComponent& source, TransformComponent& transform, double delta) { + const Eigen::Vector3f world_position = transform.getWorldMatrix().block<3, 1>(0, 3); + const Eigen::Vector3f velocity = velocityFrom(world_position, source.last_world_position, source.has_last_position, delta); + + // playOnAwake fires exactly once, the first time the source is seen -- not every time it stops, + // which would make a one-shot ambient sound restart forever. + if (source.playOnAwake && !source.awake_handled) { + source.awake_handled = true; + source.state = AudioSourceState::Playing; + } + + // A Stopped/Paused -> Playing transition is a FRESH play request, so the "already ran to + // completion" latch resets. Detected from the state delta rather than from play() being called, + // so assigning `state` directly behaves identically. + const bool state_changed = source.state != source.last_state; + if (state_changed && source.state == AudioSourceState::Playing) { + source.voice_started = false; + } + source.last_state = source.state; + + const VoiceHandle voice = handleOf(source); + const bool has_voice = m_audio->getVoiceParams(voice) != nullptr; + + switch (source.state) { + case AudioSourceState::Stopped: + if (has_voice) { + m_audio->stop(voice); + } + clearHandle(source); + source.voice_started = false; + break; + + case AudioSourceState::Paused: + if (has_voice && state_changed) { + m_audio->pause(voice); + } + break; + + case AudioSourceState::Playing: + if (has_voice) { + if (state_changed) { + m_audio->resume(voice); // covers Paused -> Playing + } + m_audio->setVoiceParams(voice, buildParams(source, world_position, velocity)); + } else if (source.voice_started) { + // A voice existed and is now gone: the sound ran to its end (or was stolen). Settle + // into Stopped instead of restarting -- without this latch a one-shot would replay + // every frame forever. + source.state = AudioSourceState::Stopped; + source.last_state = AudioSourceState::Stopped; + source.voice_started = false; + clearHandle(source); + } else { + // Never started yet. A failure here is not final: the clip may still be decoding + // (async import) or the pool may be momentarily full, so we retry next frame. + startVoice(e, source, world_position); + } + break; + } +} + +void AudioSystem::startVoice(Entity e, AudioSourceComponent& source, const Eigen::Vector3f& world_position) { + if (source.clip == NO_ASSET_ID) { + return; + } + + VoiceDesc desc; + desc.clip = source.clip; + desc.priority = source.priority; + desc.bus = static_cast(source.bus < static_cast(BusId::Count) ? source.bus : static_cast(BusId::SFX)); + desc.params = buildParams(source, world_position, Eigen::Vector3f::Zero()); + + VoiceHandle voice = m_audio->playVoice(desc); + if (!voice.valid()) { + // Out of voices, or the clip is still decoding. voice_started stays false so the source + // retries next frame -- which is exactly what an async import needs: it becomes audible the + // moment its clip lands. + clearHandle(source); + return; + } + setHandle(source, voice); + source.voice_started = true; +} + +VoiceParams AudioSystem::buildParams(const AudioSourceComponent& source, const Eigen::Vector3f& world_position, + const Eigen::Vector3f& velocity) const { + VoiceParams params; + params.position = world_position; + params.velocity = velocity; + params.gain = source.volume; + params.pitch = source.pitch; + params.looping = source.loop; + params.spatial = source.spatial; + params.minDistance = source.minDistance; + params.maxDistance = source.maxDistance; + params.rolloff = source.rolloff; + return params; +} + +void AudioSystem::onEntityRemoved(Entity e) { + if (m_audio == nullptr || m_registry == nullptr) { + return; + } + // The component may already be gone (that is often why the entity stopped matching), so probe + // rather than assume. When it is gone, its voice is reclaimed by AudioEngine::update once the + // sound ends -- handles are generational, so nothing dangles either way. + if (auto* source = m_registry->tryGetComponent(e)) { + VoiceHandle voice = handleOf(*source); + if (voice.valid()) { + m_audio->stop(voice); + clearHandle(*source); + } + } +} + +Eigen::Vector3f AudioSystem::velocityFrom(const Eigen::Vector3f& current, Eigen::Vector3f& last, bool& has_last, double delta) { + if (!has_last || delta <= 0.0) { + // First sighting (or a stalled frame): reporting (current - 0) / dt would be an enormous + // bogus velocity and an audible Doppler shriek on spawn. + last = current; + has_last = true; + return Eigen::Vector3f::Zero(); + } + const Eigen::Vector3f velocity = (current - last) / static_cast(delta); + last = current; + return velocity; +} + +} // namespace ICE diff --git a/ICE/AudioSystem/test/AudioSystemTest.cpp b/ICE/AudioSystem/test/AudioSystemTest.cpp new file mode 100644 index 00000000..a589bb61 --- /dev/null +++ b/ICE/AudioSystem/test/AudioSystemTest.cpp @@ -0,0 +1,398 @@ +#include + +#include +#include +#include + +#include +#include + +#include "MockAudioBackend.h" + +using namespace ICE; + +namespace { + +// A registry with an audio system wired to a mock backend, plus one mono clip to play. +struct Fixture { + std::shared_ptr bank = std::make_shared(); + std::shared_ptr backend = std::make_shared(8); + std::unique_ptr audio; + std::shared_ptr registry = std::make_shared(); + std::shared_ptr system; + AssetUID clip = NO_ASSET_ID; + + Fixture() { + backend->initialize({}); + bank->addAsset("mono", std::make_shared(std::vector(100, 0), 1, 44100)); + clip = bank->getUID(AssetPath::WithTypePrefix("mono")); + audio = std::make_unique(backend, bank); + system = std::make_shared(registry, audio.get()); + registry->addSystem(system); + } + + // Create an entity with a transform at `position`. No parent matrix is needed: it defaults to + // identity and getWorldMatrix() recomputes lazily whenever a setter marks the transform dirty, + // which is what SceneGraphSystem relies on in a real frame too. + Entity entityAt(const Eigen::Vector3f& position) { + Entity e = registry->createEntity(); + registry->addComponent(e, TransformComponent(position)); + return e; + } + + void moveTo(Entity e, const Eigen::Vector3f& position) { registry->getComponent(e)->setPosition(position); } + + AudioSourceComponent* source(Entity e) { return registry->getComponent(e); } +}; + +constexpr double kFrame = 1.0 / 60.0; + +} // namespace + +// --- Listener selection ------------------------------------------------------------------------- + +TEST(AudioSystemTest, FallbackListenerIsUsedWhenNoListenerComponentExists) { + Fixture f; + Entity camera = f.entityAt({1.0f, 2.0f, 3.0f}); + f.system->setListenerEntity(camera); + + f.system->update(kFrame); + + EXPECT_EQ(f.system->getActiveListener(), camera); + EXPECT_FLOAT_EQ(f.backend->lastListener.position.y(), 2.0f); +} + +TEST(AudioSystemTest, ExplicitListenerComponentOverridesTheCameraFallback) { + Fixture f; + Entity camera = f.entityAt({100.0f, 0.0f, 0.0f}); + Entity ears = f.entityAt({5.0f, 0.0f, 0.0f}); + f.registry->addComponent(ears, AudioListenerComponent{}); + f.system->setListenerEntity(camera); + + f.system->update(kFrame); + + EXPECT_EQ(f.system->getActiveListener(), ears) << "an explicit listener must win over the camera"; + EXPECT_FLOAT_EQ(f.backend->lastListener.position.x(), 5.0f); +} + +TEST(AudioSystemTest, InactiveListenerComponentFallsBackToTheCamera) { + Fixture f; + Entity camera = f.entityAt({100.0f, 0.0f, 0.0f}); + Entity ears = f.entityAt({5.0f, 0.0f, 0.0f}); + AudioListenerComponent listener; + listener.active = false; + f.registry->addComponent(ears, listener); + f.system->setListenerEntity(camera); + + f.system->update(kFrame); + + EXPECT_EQ(f.system->getActiveListener(), camera); +} + +TEST(AudioSystemTest, ListenerOrientationComesFromTheWorldMatrix) { + Fixture f; + Entity camera = f.entityAt({0.0f, 0.0f, 0.0f}); + // Yaw 90 degrees about +Y: the -Z forward axis should swing to -X. + f.registry->getComponent(camera)->setRotation( + Eigen::Quaternionf(Eigen::AngleAxisf(static_cast(M_PI) / 2.0f, Eigen::Vector3f::UnitY()))); + f.system->setListenerEntity(camera); + + f.system->update(kFrame); + + const auto& forward = f.backend->lastListener.forward; + const auto& up = f.backend->lastListener.up; + EXPECT_NEAR(forward.x(), -1.0f, 1e-4f); + EXPECT_NEAR(forward.z(), 0.0f, 1e-4f); + EXPECT_NEAR(up.y(), 1.0f, 1e-4f) << "up must remain +Y under a yaw"; +} + +TEST(AudioSystemTest, NoListenerIsNotFatal) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + AudioSourceComponent source(f.clip); + source.playOnAwake = true; + f.registry->addComponent(e, source); + + f.system->update(kFrame); // no listener entity set at all + + EXPECT_EQ(f.system->getActiveListener(), NULL_ENTITY); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 1u) << "sources should still play without a listener"; +} + +// --- Source lifecycle --------------------------------------------------------------------------- + +TEST(AudioSystemTest, PlayOnAwakeStartsExactlyOnce) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + AudioSourceComponent source(f.clip); + source.playOnAwake = true; + f.registry->addComponent(e, source); + + f.system->update(kFrame); + ASSERT_EQ(f.audio->getActiveVoiceCount(), 1u); + VoiceHandle first{f.source(e)->voice_index, f.source(e)->voice_generation}; + + // Let the one-shot finish and settle. + f.backend->finish(first); + f.audio->update(kFrame); + f.system->update(kFrame); + EXPECT_EQ(f.source(e)->state, AudioSourceState::Stopped); + + // It must NOT re-arm: playOnAwake is a one-time event, not a loop. + for (int i = 0; i < 5; ++i) { + f.system->update(kFrame); + } + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u); +} + +// The bug this guards: a finished one-shot has its voice reclaimed by AudioEngine, so the source +// sees state == Playing with no voice and would restart it every single frame, forever. +TEST(AudioSystemTest, FinishedOneShotSettlesToStoppedInsteadOfRestarting) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + f.registry->addComponent(e, AudioSourceComponent(f.clip)); + f.source(e)->play(); + + f.system->update(kFrame); + VoiceHandle voice{f.source(e)->voice_index, f.source(e)->voice_generation}; + ASSERT_TRUE(voice.valid()); + + f.backend->finish(voice); + f.audio->update(kFrame); + f.system->update(kFrame); + + EXPECT_EQ(f.source(e)->state, AudioSourceState::Stopped); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u); + + // Many frames later, still silent. + for (int i = 0; i < 20; ++i) { + f.system->update(kFrame); + f.audio->update(kFrame); + } + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u); + EXPECT_EQ(f.backend->uploadCount, 1); +} + +TEST(AudioSystemTest, ReplayAfterFinishingWorks) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + f.registry->addComponent(e, AudioSourceComponent(f.clip)); + + f.source(e)->play(); + f.system->update(kFrame); + VoiceHandle first{f.source(e)->voice_index, f.source(e)->voice_generation}; + f.backend->finish(first); + f.audio->update(kFrame); + f.system->update(kFrame); + ASSERT_EQ(f.source(e)->state, AudioSourceState::Stopped); + + // A fresh request must be honoured -- the "already finished" latch has to reset. + f.source(e)->play(); + f.system->update(kFrame); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 1u); +} + +TEST(AudioSystemTest, DirectStateAssignmentBehavesLikePlay) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + f.registry->addComponent(e, AudioSourceComponent(f.clip)); + + // Gameplay code that sets the field rather than calling play() must work identically. + f.source(e)->state = AudioSourceState::Playing; + f.system->update(kFrame); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 1u); +} + +TEST(AudioSystemTest, StoppingASourceReleasesItsVoice) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + f.registry->addComponent(e, AudioSourceComponent(f.clip)); + f.source(e)->play(); + f.system->update(kFrame); + ASSERT_EQ(f.audio->getActiveVoiceCount(), 1u); + + f.source(e)->stop(); + f.system->update(kFrame); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u); + EXPECT_FALSE(VoiceHandle({f.source(e)->voice_index, f.source(e)->voice_generation}).valid()); +} + +TEST(AudioSystemTest, PauseAndResumeKeepTheSameVoice) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + AudioSourceComponent source(f.clip); + source.loop = true; // so it cannot end on its own mid-test + f.registry->addComponent(e, source); + f.source(e)->play(); + f.system->update(kFrame); + VoiceHandle voice{f.source(e)->voice_index, f.source(e)->voice_generation}; + + f.source(e)->pause(); + f.system->update(kFrame); + EXPECT_EQ(f.backend->voice(voice)->state, PlaybackState::Paused); + + f.source(e)->play(); + f.system->update(kFrame); + EXPECT_EQ(f.backend->voice(voice)->state, PlaybackState::Playing); + VoiceHandle after{f.source(e)->voice_index, f.source(e)->voice_generation}; + EXPECT_EQ(voice, after) << "pause/resume must not recycle the voice"; +} + +TEST(AudioSystemTest, SourceWithNoClipIsSilentAndHarmless) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + f.registry->addComponent(e, AudioSourceComponent{}); // NO_ASSET_ID + f.source(e)->play(); + f.system->update(kFrame); + EXPECT_EQ(f.audio->getActiveVoiceCount(), 0u); +} + +// --- Positioning and Doppler -------------------------------------------------------------------- + +TEST(AudioSystemTest, SourcePositionTracksTheEntityWorldTransform) { + Fixture f; + Entity listener = f.entityAt({0.0f, 0.0f, 0.0f}); + f.system->setListenerEntity(listener); + + Entity e = f.entityAt({3.0f, 0.0f, 0.0f}); + AudioSourceComponent source(f.clip); + source.loop = true; + f.registry->addComponent(e, source); + f.source(e)->play(); + f.system->update(kFrame); + + VoiceHandle voice{f.source(e)->voice_index, f.source(e)->voice_generation}; + EXPECT_FLOAT_EQ(f.backend->voice(voice)->params.position.x(), 3.0f); + + f.moveTo(e, {9.0f, 0.0f, 0.0f}); + f.system->update(kFrame); + EXPECT_FLOAT_EQ(f.backend->voice(voice)->params.position.x(), 9.0f) << "a moving entity must move its sound"; +} + +TEST(AudioSystemTest, VelocityIsZeroOnTheFirstFrameThenReflectsMotion) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + AudioSourceComponent source(f.clip); + source.loop = true; + f.registry->addComponent(e, source); + f.source(e)->play(); + + f.system->update(kFrame); + VoiceHandle voice{f.source(e)->voice_index, f.source(e)->voice_generation}; + // A spawn must not produce a huge bogus velocity (which would be an audible Doppler shriek). + EXPECT_FLOAT_EQ(f.backend->voice(voice)->params.velocity.norm(), 0.0f); + + // Move +6 units over one 1/60s frame -> +360 units/second along X. + f.moveTo(e, {6.0f, 0.0f, 0.0f}); + f.system->update(kFrame); + EXPECT_NEAR(f.backend->voice(voice)->params.velocity.x(), 360.0f, 0.5f); +} + +TEST(AudioSystemTest, RecedingSourceHasOppositeVelocitySignToApproaching) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + AudioSourceComponent source(f.clip); + source.loop = true; + f.registry->addComponent(e, source); + f.source(e)->play(); + f.system->update(kFrame); + VoiceHandle voice{f.source(e)->voice_index, f.source(e)->voice_generation}; + + f.moveTo(e, {1.0f, 0.0f, 0.0f}); + f.system->update(kFrame); + const float receding = f.backend->voice(voice)->params.velocity.x(); + + f.moveTo(e, {0.0f, 0.0f, 0.0f}); + f.system->update(kFrame); + const float approaching = f.backend->voice(voice)->params.velocity.x(); + + EXPECT_GT(receding, 0.0f); + EXPECT_LT(approaching, 0.0f); +} + +TEST(AudioSystemTest, AuthoredFieldsReachTheDevice) { + Fixture f; + Entity e = f.entityAt({0.0f, 0.0f, 0.0f}); + AudioSourceComponent source(f.clip); + source.loop = true; + source.volume = 0.25f; + source.pitch = 1.5f; + source.minDistance = 2.0f; + source.maxDistance = 40.0f; + source.rolloff = 0.5f; + source.spatial = true; + f.registry->addComponent(e, source); + f.source(e)->play(); + f.system->update(kFrame); + + VoiceHandle voice{f.source(e)->voice_index, f.source(e)->voice_generation}; + const auto& params = f.backend->voice(voice)->params; + EXPECT_FLOAT_EQ(params.gain, 0.25f); + EXPECT_FLOAT_EQ(params.pitch, 1.5f); + EXPECT_TRUE(params.looping); + EXPECT_TRUE(params.spatial); + EXPECT_FLOAT_EQ(params.minDistance, 2.0f); + EXPECT_FLOAT_EQ(params.maxDistance, 40.0f); + EXPECT_FLOAT_EQ(params.rolloff, 0.5f); +} + +TEST(AudioSystemTest, NonSpatialSourceIsNotPositioned) { + Fixture f; + Entity e = f.entityAt({50.0f, 0.0f, 0.0f}); + AudioSourceComponent source(f.clip); + source.spatial = false; + source.loop = true; + f.registry->addComponent(e, source); + f.source(e)->play(); + f.system->update(kFrame); + + VoiceHandle voice{f.source(e)->voice_index, f.source(e)->voice_generation}; + EXPECT_FALSE(f.backend->voice(voice)->params.spatial); +} + +// --- Downmix ------------------------------------------------------------------------------------ + +TEST(AudioDownmixTest, StereoAveragesToMono) { + DecodedAudio audio; + audio.channels = 2; + audio.sampleRate = 44100; + audio.samples = {100, 300, -200, 0}; // two frames: (100,300) and (-200,0) + audio.frameCount = 2; + + DownmixToMono(audio); + + EXPECT_EQ(audio.channels, 1u); + EXPECT_EQ(audio.frameCount, 2u); + ASSERT_EQ(audio.samples.size(), 2u); + EXPECT_EQ(audio.samples[0], 200); + EXPECT_EQ(audio.samples[1], -100); +} + +TEST(AudioDownmixTest, MonoIsUnchanged) { + DecodedAudio audio; + audio.channels = 1; + audio.sampleRate = 8000; + audio.samples = {1, 2, 3}; + audio.frameCount = 3; + + DownmixToMono(audio); + + EXPECT_EQ(audio.channels, 1u); + ASSERT_EQ(audio.samples.size(), 3u); + EXPECT_EQ(audio.samples[2], 3); +} + +// Summing several near-full-scale int16 channels overflows 16 bits before the divide; the +// accumulator has to be wider. +TEST(AudioDownmixTest, LoudStereoDoesNotOverflow) { + DecodedAudio audio; + audio.channels = 2; + audio.sampleRate = 44100; + audio.samples = {32000, 32000, -32000, -32000}; + audio.frameCount = 2; + + DownmixToMono(audio); + + EXPECT_EQ(audio.samples[0], 32000); + EXPECT_EQ(audio.samples[1], -32000); +} diff --git a/ICE/AudioSystem/test/CMakeLists.txt b/ICE/AudioSystem/test/CMakeLists.txt new file mode 100644 index 00000000..d09aa0d9 --- /dev/null +++ b/ICE/AudioSystem/test/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.19) +project(audio-system-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +add_executable(AudioSystemTestSuite + AudioSystemTest.cpp +) + +add_test(NAME AudioSystemTestSuite + COMMAND AudioSystemTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(AudioSystemTestSuite + PRIVATE + gtest_main + audio_system +) + +# Reuses MockAudioBackend from the audio module's suite rather than duplicating it. +target_include_directories(AudioSystemTestSuite PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../Audio/test) diff --git a/ICE/CMakeLists.txt b/ICE/CMakeLists.txt index 294fb2be..3fbdffff 100644 --- a/ICE/CMakeLists.txt +++ b/ICE/CMakeLists.txt @@ -3,23 +3,29 @@ project(ICE) message(STATUS "Building ${PROJECT_NAME}") -add_compile_definitions(NOMINMAX) - add_subdirectory(Assets) +add_subdirectory(Audio) +add_subdirectory(AudioAPI) +add_subdirectory(AudioSystem) add_subdirectory(Components) +add_subdirectory(Container) add_subdirectory(Core) add_subdirectory(Entity) 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 audio audio_api_openal audio_system container 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 +44,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/AudioListenerComponent.h b/ICE/Components/include/AudioListenerComponent.h new file mode 100644 index 00000000..dfcef927 --- /dev/null +++ b/ICE/Components/include/AudioListenerComponent.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "Component.h" + +namespace ICE { + +// Marks the entity whose world transform is the ear of the scene: its position, forward and up +// become the OpenAL listener, and every spatial source is mixed relative to it. +// +// Attaching this is OPTIONAL. By default AudioSystem uses the scene's active camera entity, which +// is what a single-viewpoint game wants and needs no setup. Add this component to decouple hearing +// from seeing -- a third-person game that should hear from the character rather than the orbiting +// camera, for example. If several entities carry one, the first active one found wins (and +// AudioSystem says so, since it is almost always a mistake). +struct AudioListenerComponent : public Component { + AudioListenerComponent() = default; + + // Scales every sound this listener hears; the master volume control that survives scene loads. + float volume = 1.0f; + + // Lets a listener be disabled without removing the component -- e.g. switching between two + // authored viewpoints. + bool active = true; + + // --- runtime only: never serialized ----------------------------------------------------- + // Previous frame's world position, for the listener's Doppler velocity. + Eigen::Vector3f last_world_position = Eigen::Vector3f::Zero(); + bool has_last_position = false; +}; + +} // namespace ICE diff --git a/ICE/Components/include/AudioSourceComponent.h b/ICE/Components/include/AudioSourceComponent.h new file mode 100644 index 00000000..a8e560a4 --- /dev/null +++ b/ICE/Components/include/AudioSourceComponent.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include + +#include + +#include "Component.h" + +namespace ICE { + +// Requested playback state for a source. This is the AUTHORED intent; whether a voice is actually +// sounding is a runtime question answered by AudioSystem (a source can be Playing while inaudible +// because its clip is still loading or its voice was stolen). +enum class AudioSourceState { Stopped, Playing, Paused }; + +// Attach alongside a TransformComponent to make an entity emit sound. The entity's world transform +// (resolved by SceneGraphSystem) supplies the position, so parenting a source under a moving entity +// makes it travel with no special-case code -- exactly as a camera entity gives a follow camera. +// +// Mirrors RenderComponent: it names an asset by UID and holds only plain data, so it stays free of +// any dependency on the audio backend. +struct AudioSourceComponent : public Component { + AudioSourceComponent() = default; + explicit AudioSourceComponent(AssetUID clip_id) : clip(clip_id) {} + + AssetUID clip = NO_ASSET_ID; + + float volume = 1.0f; + float pitch = 1.0f; + bool loop = false; + + // Start on the first frame the source is seen by AudioSystem. The usual way to author ambient + // loops and music without any gameplay code. + bool playOnAwake = false; + + // Positional playback. Requires a MONO clip -- OpenAL does not spatialize stereo buffers, it + // plays them flat at full volume. AudioSystem reports a clip that violates this rather than + // letting it look like a positioning bug. + bool spatial = true; + + // Distance model parameters. No attenuation within minDistance; silent beyond maxDistance. + float minDistance = 1.0f; + float maxDistance = 500.0f; + float rolloff = 1.0f; + + // Higher survives when the voice pool is exhausted (see AudioEngine's stealing policy). + uint8_t priority = 128; + + // Mixer routing. Stored as a plain integer so this header stays independent of the audio + // module's BusId enum; AudioSystem maps it across. + uint8_t bus = 2; // BusId::SFX + + AudioSourceState state = AudioSourceState::Stopped; + + // --- runtime only: never serialized ----------------------------------------------------- + // Index+generation of the live voice, as an opaque pair so this header does not depend on the + // audio module's VoiceHandle. Zero generation means "no voice". + uint32_t voice_index = 0; + uint32_t voice_generation = 0; + // Previous frame's world position, for the Doppler velocity estimate. Invalid until the source + // has been seen once (tracked by has_last_position). + Eigen::Vector3f last_world_position = Eigen::Vector3f::Zero(); + bool has_last_position = false; + // Set once playOnAwake has been honoured, so it fires exactly once rather than restarting the + // sound every time the source stops. + bool awake_handled = false; + // True once a voice has actually been created for the CURRENT play request. This is what + // distinguishes "has not started yet" (keep trying -- the clip may still be decoding) from + // "has already finished" (do not restart). Without it a one-shot would loop forever: the voice + // ends, AudioEngine reclaims it, and the source sees state == Playing with no voice again. + bool voice_started = false; + // Previous frame's state, so AudioSystem can detect a fresh play request even when gameplay + // assigns `state` directly instead of calling play(). + AudioSourceState last_state = AudioSourceState::Stopped; + + // Convenience for gameplay code: request playback from the next AudioSystem update. Assigning + // `state` directly works too -- AudioSystem detects the transition either way. + void play() { state = AudioSourceState::Playing; } + void stop() { state = AudioSourceState::Stopped; } + void pause() { state = AudioSourceState::Paused; } +}; + +} // namespace ICE 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/Container/CMakeLists.txt b/ICE/Container/CMakeLists.txt new file mode 100644 index 00000000..0005ae1d --- /dev/null +++ b/ICE/Container/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.19) +project(container) + +message(STATUS "Building ${PROJECT_NAME} module") + +# Generic, dependency-free containers shared across layers. Header-only and -- critically -- links +# NOTHING, so any module may depend on it without risking a cycle. HandlePool lived in `graphics` +# until the audio layer needed the same generational-handle pool for its voice pool; `util` (the +# obvious home) is not a leaf -- it reaches up to `graphics`/`core` via EngineHelper.h, so moving +# HandlePool there would have forced `graphics -> util` and closed a new cycle. See +# docs/module_dependencies.md. +add_library(${PROJECT_NAME} INTERFACE) + +target_include_directories(${PROJECT_NAME} INTERFACE + $ + $) + +enable_testing() +add_subdirectory(test) diff --git a/ICE/Container/include/HandlePool.h b/ICE/Container/include/HandlePool.h new file mode 100644 index 00000000..cc9cfd26 --- /dev/null +++ b/ICE/Container/include/HandlePool.h @@ -0,0 +1,131 @@ +#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; } + + // Visit every live entry as fn(HandleType, T&), skipping freed slots. Needed whenever a caller + // has to find entries by a property of the value rather than by handle -- e.g. locating the + // voices currently playing a buffer that is about to be deleted. + // + // Do not insert or erase from within the callback: the loop bound is the slot count at entry, + // and erasing invalidates the iteration's assumptions. Collect handles, then act after. + template + void forEachHandle(Fn&& fn) { + for (uint32_t idx = 0; idx < static_cast(m_slots.size()); ++idx) { + Slot& s = m_slots[idx]; + if (s.alive) { + fn(HandleType{idx, s.generation}, s.value); + } + } + } + + 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/Container/test/CMakeLists.txt b/ICE/Container/test/CMakeLists.txt new file mode 100644 index 00000000..eff64192 --- /dev/null +++ b/ICE/Container/test/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.19) +project(container-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +add_executable(HandlePoolTestSuite + HandlePoolTest.cpp +) + +add_test(NAME HandlePoolTestSuite + COMMAND HandlePoolTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(HandlePoolTestSuite + PRIVATE + gtest_main + container +) diff --git a/ICE/Container/test/HandlePoolTest.cpp b/ICE/Container/test/HandlePoolTest.cpp new file mode 100644 index 00000000..d81fa024 --- /dev/null +++ b/ICE/Container/test/HandlePoolTest.cpp @@ -0,0 +1,81 @@ +#include + +#include +#include + +#include "HandlePool.h" + +using namespace ICE; + +namespace { +struct TestTag {}; +struct OtherTag {}; +} // namespace + +// Tagging is what makes handles to different resource kinds mutually unassignable. The concrete +// GPU tags (MeshHandle/TextureHandle/ShaderHandle) are asserted in the graphics suite, next to +// GpuHandle.h itself; here we assert the underlying container property. +static_assert(!std::is_same_v, Handle>, "differently-tagged handles must be distinct types"); + +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/Core/CMakeLists.txt b/ICE/Core/CMakeLists.txt index 939e0f13..260457fa 100644 --- a/ICE/Core/CMakeLists.txt +++ b/ICE/Core/CMakeLists.txt @@ -11,16 +11,22 @@ target_sources(${PROJECT_NAME} PRIVATE target_link_libraries(${PROJECT_NAME} PUBLIC assets + audio + audio_api_openal + audio_system components entity graphics 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..55b1ca0f 100644 --- a/ICE/Core/include/ICEEngine.h +++ b/ICE/Core/include/ICEEngine.h @@ -5,27 +5,131 @@ #pragma once #include +#include +#include #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); + + // Replace the audio backend. The engine attaches the OpenAL backend by default on the first + // audio() call, so this is only needed to select a different one (or to force the null backend + // in a test/headless build). Initialized immediately; any existing audio engine is torn down. + void setAudioBackend(const std::shared_ptr& backend); + + // The engine's audio service, created on first call (it needs a project for the asset bank + // that clips are resolved against). Play sounds through it: + // engine.audio()->play(project.audioClip("gunshot")); + // engine.audio()->playAt(clipId, {10, 0, 4}); + // Backed by OpenAL when a device is available and by a silent null backend when one is not, so + // this never returns null once a project exists and calling it is always safe. Pumped once per + // frame in step(). Null only if there is no project yet. + AudioEngine* audio(); + + // Copy the live mixer levels back onto the project so the next writeToFile persists them. + // Call before saving; the editor does this from its save path. + void storeProjectMixer(); + + // 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 +156,42 @@ 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(); + + // Push the project's persisted mixer levels onto the audio engine (on project adoption). + void applyProjectMixer(); + std::shared_ptr m_graphics_factory; std::shared_ptr ctx; std::shared_ptr api; @@ -60,10 +199,41 @@ 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; + + // Engine-owned audio (see audio()); lazily created on first use and pumped in step(). The + // backend outlives the AudioEngine built on it, so it is declared first and destroyed last. + std::shared_ptr m_audio_backend; + std::unique_ptr m_audio; + 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..522dfd21 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,394 @@ #include #include #include +#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); }); + // Silence audio while the window is in the background. Uses setSuspended rather than + // setMuted so that regaining focus cannot undo a mute the user (or the editor) set. + m_window->setFocusCallback([this](bool focused) { + if (m_audio) { + m_audio->setSuspended(!focused); } }); + // 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); + } + + // Audio housekeeping runs after the systems so it observes this frame's final state + // (positions the scene graph just resolved, voices gameplay just started). Mixing itself + // happens on the backend's own thread; this only reclaims finished voices. + if (m_audio) { + ICE_PROFILE_SCOPE("audio"); + m_audio->update(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); + } + // Re-activation re-points the ear as well as the eye: the scene's active camera entity may + // have changed since this scene was last activated. + if (auto aus = registry->tryGetSystem()) { + aus->setListenerEntity(scene->getActiveCamera()); + } + 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); + + // Positional audio. audio() lazily builds the audio service (and its backend) on first use, so + // a scene only pays for it if something asks. The listener defaults to the scene's active + // camera entity -- hear from where you see, unless an AudioListenerComponent says otherwise. + if (auto* audio_engine = audio()) { + auto aus = std::make_shared(registry, audio_engine); + aus->setListenerEntity(scene->getActiveCamera()); + registry->addSystem(aus); + } 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::setAudioBackend(const std::shared_ptr& backend) { + // Tear the old service down first: AudioEngine holds an AudioRegistry whose buffers belong to + // the outgoing backend and must be released while it is still alive. + m_audio.reset(); + if (m_audio_backend) { + m_audio_backend->shutdown(); + } + + m_audio_backend = backend; + if (!m_audio_backend) { + return; + } + if (!m_audio_backend->initialize(AudioDeviceConfig{})) { + Logger::Log(Logger::WARNING, "Audio", "Audio backend failed to initialize; falling back to the null backend."); + m_audio_backend = std::make_shared(); + m_audio_backend->initialize(AudioDeviceConfig{}); + } + if (project) { + m_audio = std::make_unique(m_audio_backend, project->getAssetBank()); + } +} + +AudioEngine* ICEEngine::audio() { + if (m_audio) { + return m_audio.get(); + } + // Clips are resolved through the project's asset bank, so there is nothing to attach to yet. + if (!project) { + return nullptr; + } + if (!m_audio_backend) { + // Default backend, chosen here so nothing in core names a concrete audio API beyond this + // one line. A machine with no audio device is normal (CI, a server build), so a failed + // open degrades to silence rather than being treated as an error. + m_audio_backend = OpenALAudioFactory{}.createBackend(); + if (!m_audio_backend->initialize(AudioDeviceConfig{})) { + m_audio_backend = std::make_shared(); + m_audio_backend->initialize(AudioDeviceConfig{}); + } + } + // If a scheduler already exists, streaming decode goes on it rather than inline. + m_audio_backend->setScheduler(m_scheduler); + m_audio = std::make_unique(m_audio_backend, project->getAssetBank()); + return m_audio.get(); +} + +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); + } + // Streaming audio decodes on the same pool; without it a music refill decodes inline and + // spikes whichever frame needs it. + if (m_audio_backend) { + m_audio_backend->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); + } + if (m_audio_backend) { + m_audio_backend->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() { @@ -99,7 +436,45 @@ void ICEEngine::setProject(const std::shared_ptr &project) { this->project = project; this->camera->getPosition() = project->getCameraPosition(); this->camera->getRotation() = project->getCameraRotation(); + // A new project brings its own asset bank, so any audio service built against the previous + // one is stale. Drop it; audio() rebuilds it (keeping the initialized backend) on next use. + m_audio.reset(); setupScene(camera); + applyProjectMixer(); +} + +void ICEEngine::applyProjectMixer() { + if (!project) { + return; + } + auto* audio_engine = audio(); + if (audio_engine == nullptr) { + return; + } + // Empty means the project never authored a mix; leave the engine at its defaults. + const auto& gains = project->getBusGains(); + for (std::size_t i = 0; i < gains.size() && i < static_cast(BusId::Count); ++i) { + audio_engine->setBusGain(static_cast(i), gains[i]); + } + const auto& mutes = project->getBusMutes(); + for (std::size_t i = 0; i < mutes.size() && i < static_cast(BusId::Count); ++i) { + audio_engine->setBusMuted(static_cast(i), mutes[i]); + } +} + +void ICEEngine::storeProjectMixer() { + if (!project || !m_audio) { + return; + } + const auto count = static_cast(BusId::Count); + std::vector gains(count); + std::vector mutes(count); + for (std::size_t i = 0; i < count; ++i) { + gains[i] = m_audio->getBusGain(static_cast(i)); + mutes[i] = m_audio->isBusMuted(static_cast(i)); + } + project->setBusGains(gains); + project->setBusMutes(mutes); } EngineConfig &ICEEngine::getConfig() { 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..7eb660a5 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 @@ -18,6 +17,9 @@ target_link_libraries(${PROJECT_NAME} math entity scene + # GpuHandle.h builds its typed handles on HandlePool, which lives in the dependency-free + # `container` leaf module (see ICE/Container/CMakeLists.txt). + container ) target_include_directories(${PROJECT_NAME} PUBLIC @@ -27,4 +29,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/ForwardPipeline.h b/ICE/Graphics/include/ForwardPipeline.h new file mode 100644 index 00000000..a4b3effc --- /dev/null +++ b/ICE/Graphics/include/ForwardPipeline.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include "GeometryPass.h" +#include "Pipeline.h" +#include "PresentPass.h" +#include "RenderFeature.h" + +namespace ICE { + +// The engine's default pipeline: forward geometry into the scene colour, then the app-registered +// features, then the present composite. This is the whole frame the renderer used to hardcode -- +// now just one pipeline definition, replaceable by an application. It owns its built-in passes. +class ForwardPipeline : public Pipeline { + public: + ForwardPipeline(const std::shared_ptr& api, const std::shared_ptr& factory, + const std::shared_ptr& gpu_registry) + : m_geometry(api, factory, gpu_registry) {} + + void build(RenderGraph& graph, const PipelineContext& ctx) override { + // Geometry creates + writes the scene colour; its handle is threaded to everything that + // reads it. + m_geometry.setRenderSize(ctx.render_width, ctx.render_height); + addPassToGraph(graph, m_geometry, {}, ctx.api, ctx.frame); + const auto scene_color = m_geometry.color(); + + // Application passes/features run in the middle, over the scene colour. + if (ctx.features) { + for (const auto& feature : *ctx.features) { + addFeaturePasses(graph, *feature, scene_color, ctx.api, ctx.frame); + } + } + + // Present composites the scene colour to the backbuffer (declares the graph output). + addPassToGraph(graph, m_present, scene_color, ctx.api, ctx.frame); + } + + private: + GeometryPass m_geometry; + PresentPass m_present; +}; +} // namespace ICE diff --git a/ICE/Graphics/include/ForwardRenderer.h b/ICE/Graphics/include/ForwardRenderer.h index 5fb71c58..d10e88f9 100644 --- a/ICE/Graphics/include/ForwardRenderer.h +++ b/ICE/Graphics/include/ForwardRenderer.h @@ -6,15 +6,21 @@ #include #include +#include #include -#include +#include +#include +#include #include #include "Camera.h" +#include "ForwardPipeline.h" +#include "FrameContext.h" #include "Framebuffer.h" -#include "GeometryPass.h" +#include "InstanceData.h" #include "RenderCommand.h" +#include "RenderGraph.h" #include "Renderer.h" #include "RendererConfig.h" @@ -22,28 +28,88 @@ namespace ICE { class ForwardRenderer : public Renderer { 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 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; + + void setPipeline(std::unique_ptr pipeline) override { + if (pipeline) { + m_pipeline = std::move(pipeline); + m_graph_dirty = true; // rebuild the graph with the new pipeline next frame + } + } + private: + // Tear down and rebuild the frame's graph by handing it to the pipeline (which declares the + // passes), 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); + + // Refresh the per-frame conduit handed to passes (see FrameContext). Called each frame before + // the graph executes; its address is stable so compiled pass callbacks read fresh data. + void updateFrameContext(); std::shared_ptr m_api; + std::shared_ptr m_factory; + std::shared_ptr m_gpu_registry; std::vector m_render_commands; - GeometryPass m_geometry_pass; + // Per-frame data + services passed to render passes (Phase 1 of the scriptable-pipeline + // migration). Populated but not yet consumed by any shipped pass. + FrameContext m_frame_context; + + // The pipeline that assembles the frame's graph (Phase 4). Owns the built-in geometry/present + // passes; defaults to ForwardPipeline. Replaceable (Phase 6) to define a custom frame. + std::unique_ptr m_pipeline; + 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), handed to passes through + // the frame context. + Camera* m_frame_camera = nullptr; + + // The current render size (set by resize, from the viewport). The geometry pass creates the + // scene-colour target at this size each rebuild. + uint32_t m_render_width = 1; + uint32_t m_render_height = 1; + + // Application-registered features, in registration order. Their passes are added to the graph + // each time it is rebuilt (see rebuildGraph()). + std::vector> m_features; + + // The full-screen quad the present/post passes draw, shared through the frame context. + std::shared_ptr m_present_quad; std::shared_ptr m_camera_ubo; std::shared_ptr m_light_ubo; @@ -52,6 +118,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/FrameContext.h b/ICE/Graphics/include/FrameContext.h new file mode 100644 index 00000000..4494a11c --- /dev/null +++ b/ICE/Graphics/include/FrameContext.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include "RenderCommand.h" + +namespace ICE { +class RendererAPI; +class GraphicsFactory; +class GPURegistry; +class Camera; +class UniformBuffer; +class VertexArray; +class Framebuffer; +class ShaderProgram; + +// Per-frame data + backend services handed to every render pass (through PassContext::frame()). +// +// The renderer builds one of these each frame and its address is stable, so the graph's compiled +// pass callbacks read *fresh* per-frame data through it every frame without a recompile. Passes read +// from it; they never own it. This is the conduit that lets passes reach the visible set and backend +// services without depending on the concrete renderer -- the groundwork for geometry/present +// becoming ordinary passes (see docs/scriptable_render_pipeline_plan.md). +// +// Phase 1 of that migration: populated by the renderer but not yet consumed by any shipped pass +// (geometry and present still run through the renderer's internal path). Fields are filled in as +// later phases need them; any left null simply have no consumer yet. +struct FrameContext { + // --- Backend services --- + RendererAPI* api = nullptr; // draw calls + GL state + GraphicsFactory* factory = nullptr; // backend resource creation + GPURegistry* gpu = nullptr; // resolve mesh/shader/texture handles + + // --- Per-frame data --- + Camera* camera = nullptr; // the frame's main view + const std::vector* commands = nullptr; // the sorted visible set + + // --- Shared GPU resources a pass may need --- + UniformBuffer* cameraUBO = nullptr; // a pass uploads the camera it draws from + UniformBuffer* lightUBO = nullptr; + // shared_ptr because RendererAPI::renderVertexArray takes one; for present / post-process passes. + std::shared_ptr fullscreenQuad; + + // --- Present --- + // Destination: the editor's render-to-texture target, or null for the window's default + // framebuffer. The full-screen composite shader the present pass blits with. + Framebuffer* outputTarget = nullptr; + ShaderProgram* presentShader = nullptr; +}; +} // namespace ICE 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..17395892 100644 --- a/ICE/Graphics/include/GeometryPass.h +++ b/ICE/Graphics/include/GeometryPass.h @@ -1,24 +1,62 @@ #pragma once -#include +#include + +#include +#include +#include #include "Framebuffer.h" #include "GraphicsFactory.h" #include "RenderCommand.h" -#include "RenderPass.h" +#include "RenderFeature.h" namespace ICE { -class GeometryPass : public RenderPass { + +// Draws the frame's opaque + transparent geometry into the scene-colour target. +// +// As of the scriptable-pipeline migration (Phase 3) this is an ordinary render pass: it CREATES the +// scene-colour framebuffer -- the graph owns and allocates it -- and reads the sorted visible set +// from the frame context, instead of owning its own framebuffer and being driven directly by the +// renderer. Its output handle (color()) is threaded to the passes that read it (features, present). +class GeometryPass : public IRenderPass { public: - GeometryPass(const std::shared_ptr& api, const std::shared_ptr& factory, const FrameBufferFormat& format); - void submit(std::vector* commands) { m_render_queue = commands; } - void execute() override; - std::shared_ptr getResult() const; - void resize(int w, int h); + GeometryPass(const std::shared_ptr& api, const std::shared_ptr& factory, + const std::shared_ptr& gpu_registry); + + const char* name() const override { return "geometry"; } + + // Create the scene-colour target (graph-allocated, at the current render size) and write it. + void setup(RenderGraphBuilder& builder) override; + + // Clear the graph-bound target and draw the frame context's visible set into it. + void execute(PassContext& ctx) override; + + // The scene-colour handle this pass created; valid after setup(), for the passes that read it. + // Regenerated on every rebuild (handles are per-compile). + RenderResourceHandle color() const { return m_color; } + + // The size the scene-colour target is created at. Set by the renderer (from its viewport) before + // the graph is rebuilt. + void setRenderSize(uint32_t width, uint32_t height) { + m_width = width; + m_height = height; + } private: + // Draw a command queue into the currently bound target (state-cached; instance/bone reuse). + void drawCommands(const std::vector& queue); + std::shared_ptr m_api; - std::shared_ptr m_framebuffer; - std::vector* m_render_queue; + std::shared_ptr m_factory; + std::shared_ptr m_gpu_registry; + // Reused every draw for per-instance data instead of allocating a fresh GL buffer per command. + std::shared_ptr m_instance_buffer; + // Reused scratch for packing a skinned mesh's bone palette into a contiguous id-indexed array. + std::vector m_bone_palette; + + RenderResourceHandle m_color; + uint32_t m_width = 1; + uint32_t m_height = 1; }; -} // namespace ICE \ No newline at end of file +} // namespace ICE 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/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/Pipeline.h b/ICE/Graphics/include/Pipeline.h new file mode 100644 index 00000000..cc1842b2 --- /dev/null +++ b/ICE/Graphics/include/Pipeline.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace ICE { +class RenderGraph; +class RendererAPI; +class RenderFeature; +struct FrameContext; + +// Per-rebuild inputs a pipeline needs to assemble the frame's graph: the backend API, the per-frame +// context passes read at execute time, the current render size (for sizing targets), and the +// application-registered features to weave in. +struct PipelineContext { + std::shared_ptr api; + const FrameContext* frame = nullptr; + uint32_t render_width = 1; + uint32_t render_height = 1; + const std::vector>* features = nullptr; // app-registered; may be null/empty +}; + +// Assembles the frame: declares the passes (and their resources) into `graph`. The renderer owns one +// pipeline and calls build() whenever the graph is (re)built -- there are no privileged passes; even +// geometry and present are declared here. Ships as ForwardPipeline; an application can replace it to +// define a custom frame (deferred shading, post-process chains, ...). +class Pipeline { + public: + virtual ~Pipeline() = default; + virtual void build(RenderGraph& graph, const PipelineContext& ctx) = 0; +}; +} // namespace ICE diff --git a/ICE/Graphics/include/PresentPass.h b/ICE/Graphics/include/PresentPass.h new file mode 100644 index 00000000..fed81a43 --- /dev/null +++ b/ICE/Graphics/include/PresentPass.h @@ -0,0 +1,52 @@ +#pragma once + +#include "Framebuffer.h" +#include "RenderFeature.h" +#include "ShaderProgram.h" +#include "VertexArray.h" + +namespace ICE { + +// The frame's final composite, as an ordinary render pass (scriptable-pipeline migration, Phase 2): +// it samples the scene colour and blits it full-screen to the backbuffer -- the window's default +// framebuffer, or the editor's render-to-texture target. Everything else (the present shader, the +// destination, the full-screen quad) comes from the FrameContext, so it depends on no renderer +// internals and could be swapped out or reordered by a custom pipeline like any other pass. +class PresentPass : public IRenderPass { + public: + const char* name() const override { return "present"; } + + void setup(RenderGraphBuilder& builder) override { + m_color = builder.read(builder.sceneColor()); // ordered after everything that wrote the scene + builder.presentsToBackbuffer(); // graph output; binds the real target in execute + } + + void execute(PassContext& ctx) override { + auto scene_fb = ctx.get(m_color); + const FrameContext& f = ctx.frame(); + if (!scene_fb || !f.presentShader || !f.fullscreenQuad || !ctx.api()) { + return; + } + // Destination: the editor's render-to-texture target if one is set, else the window's default + // framebuffer. Size the viewport to it (the target for the editor, the scene size otherwise). + if (f.outputTarget) { + f.outputTarget->bind(); + ctx.api()->setViewport(0, 0, static_cast(f.outputTarget->getFormat().width), + static_cast(f.outputTarget->getFormat().height)); + } else { + ctx.api()->bindDefaultFramebuffer(); + ctx.api()->setViewport(0, 0, static_cast(scene_fb->getFormat().width), static_cast(scene_fb->getFormat().height)); + } + ctx.api()->clear(); + f.presentShader->bind(); + scene_fb->bindAttachment(0); + f.presentShader->loadInt("uTexture", 0); + f.fullscreenQuad->bind(); + f.fullscreenQuad->getIndexBuffer()->bind(); + ctx.api()->renderVertexArray(f.fullscreenQuad); + } + + private: + RenderResourceHandle m_color; +}; +} // 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..6566a285 --- /dev/null +++ b/ICE/Graphics/include/RenderFeature.h @@ -0,0 +1,275 @@ +// +// 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 "FrameContext.h" +#include "GraphicsAPI.h" +#include "RenderGraph.h" + +namespace ICE { + +// 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; } + + // Declare that this pass composites the frame to the backbuffer -- the window's default + // framebuffer, or the editor's render-to-texture target. The backbuffer is not a graph-managed + // framebuffer (there is no Framebuffer object for it), so the pass gets no graph-bound target and + // binds the real destination itself (see FrameContext::outputTarget). Declaring it makes this + // pass the graph's output, so it -- and everything it reads -- is never culled. + void presentsToBackbuffer() { + m_pass.write(kBackbuffer); + m_graph.setOutput(kBackbuffer); + } + + // The graph being built, for the rare pass that needs more than this builder exposes. + RenderGraph& graph() { return m_graph; } + + // Reserved resource name for the on-screen/editor destination declared by presentsToBackbuffer(). + static constexpr const char* kBackbuffer = "backbuffer"; + + 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, std::shared_ptr target, + const FrameContext* frame = nullptr) + : m_graph(graph), + m_api(api), + m_target(std::move(target)), + m_frame(frame) {} + + // 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; } + + RendererAPI* api() const { return m_api.get(); } + + // The frame's data + services (visible set, camera, shared GPU resources). Valid when the graph + // was built by a renderer; null in graph-logic-only tests. As geometry/present become ordinary + // passes, this becomes how they reach the visible set instead of calling back into the renderer. + const FrameContext& frame() const { return *m_frame; } + bool hasFrame() const { return m_frame != nullptr; } + + private: + RenderGraph& m_graph; + std::shared_ptr m_api; + std::shared_ptr m_target; + const FrameContext* m_frame = nullptr; // per-frame data conduit; see frame() +}; + +// 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, const FrameContext* frame = 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, target, frame](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)); + } + } + // `frame` points at the renderer's stable per-frame context; it carries fresh data each + // frame even though this callback was captured once at compile time. + PassContext ctx(graph, api, fb, frame); + 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, const FrameContext* frame = nullptr) { + for (const auto& pass : feature.passes()) { + addPassToGraph(graph, *pass, scene_color, api, frame); + } +} +} // 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..fba4dbad 100644 --- a/ICE/Graphics/include/Renderer.h +++ b/ICE/Graphics/include/Renderer.h @@ -9,10 +9,18 @@ #include #include #include +#include +#include + +#include +#include +#include #include "Camera.h" #include "Context.h" #include "Framebuffer.h" +#include "Pipeline.h" +#include "RenderFeature.h" #include "RendererConfig.h" namespace ICE { @@ -41,19 +49,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; }; @@ -66,16 +80,80 @@ struct Light { LightType type; }; +// Everything the renderer needs to draw one frame, produced by the RenderSystem and handed over in a +// single drawFrame() call. The system produces the visible set; the renderer owns the frame's +// structure (prepare + graph + present). +struct FrameInputs { + Camera *camera = nullptr; // the frame's view + std::vector drawables; // the culled, visible geometry + std::vector lights; + std::optional skybox; + std::shared_ptr outputTarget; // present destination; null = window default framebuffer + std::shared_ptr presentShader; +}; + 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 std::shared_ptr render() = 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 composite are all graph passes. Nothing is returned: what reaches the + // screen (or an off-screen target) is decided entirely by the pipeline's present pass. + virtual void render() = 0; virtual void endFrame() = 0; + + // Draw one frame from a gathered input bundle: the single call the RenderSystem makes. The + // default orchestrates the frame through the primitives above (configure present, submit the + // visible set, prepare, render, end), so a backend gets it for free; a backend may override to + // consume the inputs more directly. + virtual void drawFrame(FrameInputs inputs) { + setPresentTarget(inputs.outputTarget); + setPresentShader(inputs.presentShader); + if (inputs.skybox) { + submitSkybox(*inputs.skybox); + } + for (auto &drawable : inputs.drawables) { + submitDrawable(std::move(drawable)); + } + for (const auto &light : inputs.lights) { + submitLight(light); + } + prepareFrame(*inputs.camera); + render(); + endFrame(); + } + + // 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) {} + + // Replace the pipeline that assembles the frame's graph. The engine installs a default + // (ForwardPipeline); an application swaps in its own to define a custom frame -- deferred + // shading, post-process chains, a different pass order -- since no pass is privileged. The graph + // is rebuilt with the new pipeline on the next frame. No-op for backends without a graph. + virtual void setPipeline(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..6133acac 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_factory(factory), + m_gpu_registry(gpu_registry), + m_pipeline(std::make_unique(api, factory, gpu_registry)), + 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 and handed to passes through the frame context (frame.camera). + 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,206 @@ 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()); + // The geometry pass reads the sorted visible set from the frame context (m_frame_context.commands + // points at m_render_commands), so there is no separate submit step. +} - if (!a_transparent && b_transparent) { - return true; - } else { - return false; - } - }); +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))); + } +} - m_geometry_pass.submit(&m_render_commands); +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::rebuildGraph() { + // The renderer no longer hardcodes the frame's passes (Phase 4): it hands the pipeline the graph + // and the per-rebuild context, and the pipeline declares the passes (geometry -> features -> + // present for ForwardPipeline). Application features are woven in by the pipeline. + m_graph.reset(); + + PipelineContext ctx; + ctx.api = m_api; + ctx.frame = &m_frame_context; + ctx.render_width = m_render_width; + ctx.render_height = m_render_height; + ctx.features = &m_features; + m_pipeline->build(m_graph, ctx); + + m_graph.compile(); +} + +void ForwardRenderer::render() { + // 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, a + // newly registered pass/feature, or a new pipeline). The present pass composites to the frame's + // target, so there is no separate present step and nothing to hand back: what reaches the screen + // (or the editor's render-to-texture target) is entirely the pipeline's business. + if (m_graph_dirty) { + rebuildGraph(); + m_graph_dirty = false; + } + updateFrameContext(); + m_graph.execute(); } -std::shared_ptr ForwardRenderer::render() { - m_geometry_pass.execute(); - auto result = m_geometry_pass.getResult(); - return result; +void ForwardRenderer::updateFrameContext() { + // Refresh the per-frame conduit. Its address is stable (a member), so the graph's compiled pass + // callbacks -- captured once -- read this frame's data through it. The geometry pass reads the + // visible set from here; the present pass reads the shader/target/quad. + m_frame_context.api = m_api.get(); + m_frame_context.factory = m_factory.get(); + m_frame_context.gpu = m_gpu_registry.get(); + m_frame_context.camera = m_frame_camera; + m_frame_context.commands = &m_render_commands; + m_frame_context.cameraUBO = m_camera_ubo.get(); + m_frame_context.lightUBO = m_light_ubo.get(); + m_frame_context.fullscreenQuad = m_present_quad; + m_frame_context.outputTarget = m_present_target.get(); + m_frame_context.presentShader = m_present_shader.get(); } 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); + // The scene-colour target is graph-owned and sized from here: record the size for the geometry + // pass's create<>, and re-dirty so the next rebuild reallocates at the new size. This is the one + // path that must reliably re-dirty the graph -- RenderSystem::setViewport calls us on every + // framebuffer resize. + m_render_width = width; + m_render_height = height; + 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..b416f988 100644 --- a/ICE/Graphics/src/GeometryPass.cpp +++ b/ICE/Graphics/src/GeometryPass.cpp @@ -1,75 +1,141 @@ #include "GeometryPass.h" +#include + +#include + +#include "InstanceData.h" + namespace ICE { -GeometryPass::GeometryPass(const std::shared_ptr& api, const std::shared_ptr& factory, const FrameBufferFormat& format) - : m_api(api) { - m_framebuffer = factory->createFramebuffer(format); +GeometryPass::GeometryPass(const std::shared_ptr& api, const std::shared_ptr& factory, + const std::shared_ptr& gpu_registry) + : m_api(api), + m_factory(factory), + m_gpu_registry(gpu_registry) { + m_instance_buffer = factory->createVertexBuffer(); } -void GeometryPass::execute() { - m_framebuffer->bind(); - m_api->setViewport(0, 0, m_framebuffer->getFormat().width, m_framebuffer->getFormat().height); +void GeometryPass::setup(RenderGraphBuilder& builder) { + // Create the scene colour as a graph-owned render target at the current render size; write it so + // this pass produces it (features/present that read it are ordered after) and so the graph binds + // it (and sizes the viewport) before execute(). + ResourceDescriptor desc; + desc.width = m_width; + desc.height = m_height; + desc.debug_name = "scene_color"; + m_color = builder.create(desc); // create<> sets desc.type = RenderTarget + builder.write(m_color); +} + +void GeometryPass::execute(PassContext& ctx) { + // The graph bound our target (scene_color) and sized the viewport to it. Clear it and draw the + // frame's visible set. GPU-timed, as the geometry pass has always been. + m_api->beginGPUTimer(); m_api->clear(); - std::shared_ptr current_shader; - std::shared_ptr current_material; - std::shared_ptr current_mesh; + if (ctx.hasFrame() && ctx.frame().commands) { + drawCommands(*ctx.frame().commands); + } + Profiler::get().addSample("GPU::geometry", m_api->endGPUTimer()); +} + +void GeometryPass::drawCommands(const std::vector& queue) { + 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) { + for (const auto& command : queue) { auto& shader = command.shader; auto& material = 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,17 +147,20 @@ 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); } -} -std::shared_ptr GeometryPass::getResult() const { - return m_framebuffer; + // Restore the globally-on blend state expected by other passes (final blit, editor + // picking), since opaque commands turned it off. + m_api->setBlend(true); } - -void GeometryPass::resize(int w, int h) { - m_framebuffer->resize(w, h); -} - -} // namespace ICE \ No newline at end of file +} // namespace ICE diff --git a/ICE/Graphics/test/CMakeLists.txt b/ICE/Graphics/test/CMakeLists.txt new file mode 100644 index 00000000..135abc46 --- /dev/null +++ b/ICE/Graphics/test/CMakeLists.txt @@ -0,0 +1,84 @@ +cmake_minimum_required(VERSION 3.19) +project(graphics-tests) + +message(STATUS "Building ${PROJECT_NAME} suite") +include(CTest) + +# The generic HandlePool suite moved to ICE/Container/test along with HandlePool.h itself; what +# remains here is the graphics-specific tagging built on top of it. +add_executable(GpuHandleTestSuite + GpuHandleTest.cpp +) + +add_test(NAME GpuHandleTestSuite + COMMAND GpuHandleTestSuite + WORKING_DIRECTORY $) + +target_link_libraries(GpuHandleTestSuite + PRIVATE + gtest_main + graphics +) + +# 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/GpuHandleTest.cpp b/ICE/Graphics/test/GpuHandleTest.cpp new file mode 100644 index 00000000..2b44ff40 --- /dev/null +++ b/ICE/Graphics/test/GpuHandleTest.cpp @@ -0,0 +1,20 @@ +#include + +#include + +#include "GpuHandle.h" + +using namespace ICE; + +// The typed GPU handles must be distinct types so a MeshHandle can't be used where a TextureHandle +// is expected. The generic HandlePool behaviour these are built on is covered by the container +// suite (ICE/Container/test), which is where HandlePool.h now lives. +static_assert(!std::is_same_v, "GPU handle tags must be distinct"); +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(GpuHandleTest, DefaultConstructedHandlesAreInvalid) { + EXPECT_FALSE(MeshHandle{}.valid()); + EXPECT_FALSE(TextureHandle{}.valid()); + EXPECT_FALSE(ShaderHandle{}.valid()); +} diff --git a/ICE/Graphics/test/RenderFeatureTest.cpp b/ICE/Graphics/test/RenderFeatureTest.cpp new file mode 100644 index 00000000..d97b2bb5 --- /dev/null +++ b/ICE/Graphics/test/RenderFeatureTest.cpp @@ -0,0 +1,626 @@ +#include + +#include +#include +#include +#include + +#include "PerspectiveCamera.h" +#include "Pipeline.h" +#include "PresentPass.h" +#include "RenderFeature.h" +#include "Renderer.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); +} + +namespace { +// Records what it saw through ctx.frame(), so a test can assert the conduit delivered it. +class FrameProbePass : public IRenderPass { + public: + const char* name() const override { return "frame_probe"; } + void setup(RenderGraphBuilder& b) override { b.write(b.sceneColor()); } + void execute(PassContext& ctx) override { + saw_frame = ctx.hasFrame(); + if (saw_frame) { + camera = ctx.frame().camera; + commands = ctx.frame().commands; + } + } + bool saw_frame = false; + Camera* camera = nullptr; + const std::vector* commands = nullptr; +}; +} // namespace + +// Phase 1 of the scriptable-pipeline migration: the FrameContext the renderer injects reaches a pass +// through PassContext::frame(), carrying this frame's data. (The pointers are sentinels -- only +// compared, never dereferenced -- so no GL or real camera is needed.) +TEST(RenderFeatureTest, FrameContextReachesPassThroughContext) { + RenderGraph graph(nullptr); + auto scene_fb = std::make_shared(); + auto scene_color = graph.importResource("scene_color", scene_fb); + + std::vector commands; + FrameContext frame; + frame.camera = reinterpret_cast(0xC0FFEE); + frame.commands = &commands; + + FrameProbePass probe; + addPassToGraph(graph, probe, scene_color, /*api=*/nullptr, &frame); + graph.setOutput(scene_color); + graph.compile(); + graph.execute(); + + EXPECT_TRUE(probe.saw_frame); + EXPECT_EQ(probe.camera, reinterpret_cast(0xC0FFEE)); + EXPECT_EQ(probe.commands, &commands); +} + +// A graph built without a renderer (the graph-logic tests) has no frame: hasFrame() is false rather +// than frame() dereferencing null. +TEST(RenderFeatureTest, NoFrameContextIsSafe) { + RenderGraph graph(nullptr); + auto scene_fb = std::make_shared(); + auto scene_color = graph.importResource("scene_color", scene_fb); + + FrameProbePass probe; + probe.saw_frame = true; // ensure execute() actually flips it to false + addPassToGraph(graph, probe, scene_color, /*api=*/nullptr); // no frame threaded + graph.setOutput(scene_color); + graph.compile(); + graph.execute(); + + EXPECT_FALSE(probe.saw_frame); +} + +// Phase 2: PresentPass declares the right resources in setup() -- it reads the scene colour and +// writes the backbuffer output. (Structure only; execute() does GL and is not run here.) +TEST(RenderFeatureTest, PresentPassDeclaresSceneColorAndBackbuffer) { + RenderGraph graph(nullptr); + auto scene_fb = std::make_shared(); + auto scene_color = graph.importResource("scene_color", scene_fb); + + auto& node = graph.addPass("present"); + RenderGraphBuilder builder(graph, node, scene_color); + PresentPass present; + present.setup(builder); + + const auto& reads = node.getReads(); + const auto& writes = node.getWrites(); + EXPECT_NE(std::find(reads.begin(), reads.end(), "scene_color"), reads.end()); + EXPECT_NE(std::find(writes.begin(), writes.end(), "backbuffer"), writes.end()); + // It declares no framebuffer *handle* write, so the graph binds it no target -- it binds the + // real backbuffer itself in execute(). + EXPECT_FALSE(builder.targetHandle().valid()); +} + +// presentsToBackbuffer() makes a pass the graph's output, so it -- and the scene pass it reads -- +// survive culling and run. +TEST(RenderFeatureTest, PresentsToBackbufferSurvivesCulling) { + struct Presenter : public IRenderPass { + explicit Presenter(bool* ran) : m_ran(ran) {} + const char* name() const override { return "presenter"; } + void setup(RenderGraphBuilder& b) override { + b.read(b.sceneColor()); + b.presentsToBackbuffer(); + } + void execute(PassContext&) override { *m_ran = true; } + bool* m_ran; + }; + + RenderGraph graph(nullptr); + auto scene_fb = std::make_shared(); + auto scene_color = graph.importResource("scene_color", scene_fb); + + bool scene_ran = false; + auto& scene = graph.addPass("scene"); + scene.write("scene_color"); + scene.setExecuteCallback([&](const RenderGraphPass&) { scene_ran = true; }); + + bool present_ran = false; + Presenter presenter(&present_ran); + addPassToGraph(graph, presenter, scene_color, nullptr); + + graph.compile(); + graph.execute(); + + EXPECT_TRUE(present_ran); // not culled: presentsToBackbuffer() made it the output + EXPECT_TRUE(scene_ran); // its input producer is kept too +} + +namespace { +// GL-free recording passes mirroring ForwardPipeline's built-ins, for a Pipeline structure test. +class RecGeometry : public IRenderPass { + public: + explicit RecGeometry(std::vector* order) : m_order(order) {} + const char* name() const override { return "geometry"; } + void setup(RenderGraphBuilder& b) override { + m_color = b.create({.width = 4, .height = 4, .debug_name = "scene_color"}); + b.write(m_color); + } + void execute(PassContext&) override { m_order->push_back("geometry"); } + RenderResourceHandle color() const { return m_color; } + + private: + std::vector* m_order; + RenderResourceHandle m_color; +}; + +class RecFeaturePass : public IRenderPass { + public: + explicit RecFeaturePass(std::vector* order) : m_order(order) {} + const char* name() const override { return "feature"; } + void setup(RenderGraphBuilder& b) override { + b.read(b.sceneColor()); + b.write(b.sceneColor()); // write so present is ordered after it + } + void execute(PassContext&) override { m_order->push_back("feature"); } + + private: + std::vector* m_order; +}; + +class RecFeature : public RenderFeature { + public: + explicit RecFeature(std::vector* order) { addPass(order); } + const char* name() const override { return "rec"; } +}; + +class RecPresent : public IRenderPass { + public: + explicit RecPresent(std::vector* order) : m_order(order) {} + const char* name() const override { return "present"; } + void setup(RenderGraphBuilder& b) override { + b.read(b.sceneColor()); + b.presentsToBackbuffer(); + } + void execute(PassContext&) override { m_order->push_back("present"); } + + private: + std::vector* m_order; +}; + +// A pipeline mirroring ForwardPipeline's assembly (geometry -> features -> present) with the passes +// above -- proving the Pipeline abstraction assembles and orders a frame with no privileged passes. +class RecPipeline : public Pipeline { + public: + explicit RecPipeline(std::vector* order) : m_geo(order), m_present(order) {} + void build(RenderGraph& graph, const PipelineContext& ctx) override { + addPassToGraph(graph, m_geo, {}, ctx.api, ctx.frame); + const auto scene_color = m_geo.color(); + if (ctx.features) { + for (const auto& feature : *ctx.features) { + addFeaturePasses(graph, *feature, scene_color, ctx.api, ctx.frame); + } + } + addPassToGraph(graph, m_present, scene_color, ctx.api, ctx.frame); + } + + RecGeometry m_geo; + RecPresent m_present; +}; +} // namespace + +// Phase 4: a pipeline assembles the whole frame -- geometry creates the scene colour, features run +// over it, present composites -- with no privileged passes, and the graph orders them by the +// resources they exchange. (GL-free: a null factory leaves targets virtual; passes just record.) +TEST(RenderFeatureTest, PipelineAssemblesGeometryFeaturesPresentInOrder) { + std::vector order; + std::vector> features; + features.push_back(std::make_unique(&order)); + + RecPipeline pipeline(&order); + RenderGraph graph(nullptr); + PipelineContext ctx; + ctx.api = nullptr; + ctx.frame = nullptr; + ctx.render_width = 4; + ctx.render_height = 4; + ctx.features = &features; + + pipeline.build(graph, ctx); + graph.compile(); + graph.execute(); + + ASSERT_EQ(order.size(), 3u); + EXPECT_EQ(order[0], "geometry"); // creates scene_color + EXPECT_EQ(order[1], "feature"); // reads+writes it + EXPECT_EQ(order[2], "present"); // reads it, is the output +} + +namespace { +// Records the primitive calls the default Renderer::drawFrame() makes, in order. +class CallRecordingRenderer : public Renderer { + public: + std::vector calls; + int drawables = 0, lights = 0; + + void submitSkybox(const Skybox&) override { calls.push_back("skybox"); } + void submitDrawable(Drawable) override { + calls.push_back("drawable"); + drawables++; + } + void submitLight(const Light&) override { + calls.push_back("light"); + lights++; + } + void setPresentTarget(const std::shared_ptr&) override { calls.push_back("target"); } + void setPresentShader(const std::shared_ptr&) override { calls.push_back("shader"); } + void prepareFrame(Camera&) override { calls.push_back("prepare"); } + void render() override { calls.push_back("render"); } + void endFrame() override { calls.push_back("end"); } + void resize(uint32_t, uint32_t) override {} + void setClearColor(Eigen::Vector4f) override {} + void setViewport(int, int, int, int) override {} +}; +} // namespace + +// Phase 5: the default drawFrame() orchestrates the frame through the primitives -- configure +// present, submit the visible set, then prepare/render/end -- so the RenderSystem makes one call. +TEST(RenderFeatureTest, DrawFrameOrchestratesThePrimitives) { + PerspectiveCamera camera(60.0, 16.0 / 9.0, 0.01, 100.0); + FrameInputs inputs; + inputs.camera = &camera; + inputs.skybox = Skybox{}; + inputs.drawables.resize(2); // two visible drawables + inputs.lights.resize(3); + + CallRecordingRenderer renderer; + renderer.drawFrame(std::move(inputs)); + + EXPECT_EQ(renderer.drawables, 2); + EXPECT_EQ(renderer.lights, 3); + // present is configured, the visible set submitted, then prepare -> render -> end. + const std::vector expected = {"target", "shader", "skybox", "drawable", "drawable", + "light", "light", "light", "prepare", "render", "end"}; + EXPECT_EQ(renderer.calls, expected); +} + +namespace { +// A pipeline-owned overlay pass (not an app feature): reads + writes the scene colour. +class OverlayPass : public IRenderPass { + public: + explicit OverlayPass(std::vector* order) : m_order(order) {} + const char* name() const override { return "overlay"; } + void setup(RenderGraphBuilder& b) override { + b.read(b.sceneColor()); + b.write(b.sceneColor()); + } + void execute(PassContext&) override { m_order->push_back("overlay"); } + + private: + std::vector* m_order; +}; + +// A custom pipeline: geometry -> overlay -> present, with the overlay hardcoded in the pipeline (no +// app features at all). Proves a pipeline defines its own structure and that geometry/present are +// not privileged -- a Phase 6 replacement is free to restructure the frame. +class OverlayPipeline : public Pipeline { + public: + explicit OverlayPipeline(std::vector* order) : m_geo(order), m_overlay(order), m_present(order) {} + void build(RenderGraph& graph, const PipelineContext& ctx) override { + addPassToGraph(graph, m_geo, {}, ctx.api, ctx.frame); + const auto scene_color = m_geo.color(); + addPassToGraph(graph, m_overlay, scene_color, ctx.api, ctx.frame); + addPassToGraph(graph, m_present, scene_color, ctx.api, ctx.frame); + } + + RecGeometry m_geo; + OverlayPass m_overlay; + RecPresent m_present; +}; +} // namespace + +// Phase 6: a custom pipeline restructures the frame -- here inserting a pipeline-owned overlay pass +// between geometry and present, with no app features. This is what setPipeline() installs; the graph +// orders the passes by the scene colour they exchange. +TEST(RenderFeatureTest, CustomPipelineDefinesItsOwnStructure) { + std::vector order; + OverlayPipeline pipeline(&order); + + RenderGraph graph(nullptr); + PipelineContext ctx; + ctx.api = nullptr; + ctx.frame = nullptr; + ctx.render_width = 4; + ctx.render_height = 4; + ctx.features = nullptr; + + pipeline.build(graph, ctx); + graph.compile(); + graph.execute(); + + ASSERT_EQ(order.size(), 3u); + EXPECT_EQ(order[0], "geometry"); + EXPECT_EQ(order[1], "overlay"); // the pipeline's own pass, between geometry and present + EXPECT_EQ(order[2], "present"); +} 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..a5cd146a --- /dev/null +++ b/ICE/Graphics/test/RenderGraphPoolingTest.cpp @@ -0,0 +1,234 @@ +#include + +#include +#include +#include + +#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) { + 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); + } + graph.setOutput(scene_color); + graph.compile(); + } +}; +} // namespace + +// 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..1c468803 100644 --- a/ICE/GraphicsAPI/OpenGL/src/OpenGLShader.cpp +++ b/ICE/GraphicsAPI/OpenGL/src/OpenGLShader.cpp @@ -8,15 +8,42 @@ #include #include +#include namespace ICE { +namespace { +#ifdef __APPLE__ +// macOS caps OpenGL at 4.1 / GLSL 410, which has no `layout(binding = N)` qualifier on +// uniform blocks. Rewrite the version directive and lift the bindings out of the source, +// so the caller can apply them with glUniformBlockBinding once the program is linked. +// The shaders stay the single source of truth for which point each block binds to. +const std::regex k_version_directive(R"(#version\s+420\s+core)"); +const std::regex k_ubo_binding(R"(layout\s*\(\s*std140\s*,\s*binding\s*=\s*(\d+)\s*\)\s*uniform\s+(\w+))"); + +std::string lowerToGLSL410(const std::string &source, std::unordered_map &block_bindings) { + std::string out = std::regex_replace(source, k_version_directive, "#version 410 core"); + for (auto it = std::sregex_iterator(out.begin(), out.end(), k_ubo_binding), end = std::sregex_iterator(); it != end; ++it) { + block_bindings[(*it)[2].str()] = static_cast(std::stoul((*it)[1].str())); + } + return std::regex_replace(out, k_ubo_binding, "layout(std140) uniform $2"); +} +#else +// Everywhere else the context is >= 4.2 and the shaders are used exactly as authored. +std::string lowerToGLSL410(const std::string &source, std::unordered_map &) { + return source; +} +#endif +} // namespace + OpenGLShader::OpenGLShader(const Shader &shader_asset) { m_programID = glCreateProgram(); Logger::Log(Logger::VERBOSE, "Graphics", "Compiling shader..."); + std::vector stage_shaders; + std::unordered_map ubo_bindings; for (const auto& [stage, source] : shader_asset.getStageSources()) { - compileAndAttachStage(stage, source.second); + stage_shaders.push_back(compileAndAttachStage(stage, lowerToGLSL410(source.second, ubo_bindings))); } glLinkProgram(m_programID); @@ -31,6 +58,28 @@ OpenGLShader::OpenGLShader(const Shader &shader_asset) { glGetProgramInfoLog(m_programID, maxLength, &maxLength, &errorLog[0]); Logger::Log(Logger::FATAL, "Graphics", "Shader linking error: %s", errorLog.data()); } + + // Bind each block to the point its stripped `layout(binding = N)` asked for. Empty, + // and so a no-op, wherever the qualifier could be left in the source. + for (const auto& [name, point] : ubo_bindings) { + GLuint index = glGetUniformBlockIndex(m_programID, name.c_str()); + if (index != GL_INVALID_INDEX) { + glUniformBlockBinding(m_programID, index, point); + } + } + + // 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 +102,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 +158,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 +188,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..1368a574 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 @@ -21,6 +22,7 @@ target_link_libraries(${PROJECT_NAME} PUBLIC scene assets + audio # AudioClipLoader, registered in DefaultLoaders alongside the built-in loaders graphics_api assimp platform 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..2c7613b4 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,41 @@ 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); + + // Import an audio file into the project: copies `src` into the project's Audio folder and + // registers it in the asset bank under `name`. The synchronous counterpart of importModel. + // Returns the new clip's UID (NO_ASSET_ID if the decode failed). + // + // Pass for_3d = true for anything meant to be positional. OpenAL spatializes MONO buffers + // only -- a stereo clip is played flat at full volume, ignoring the listener -- so a 3D import + // is downmixed to mono here, at import, rather than failing mysteriously at playback. Leave it + // false for music, UI and narration, which should keep their stereo image. + AssetUID importAudio(const std::string& name, const fs::path& src, bool for_3d = false); + + // Asynchronous audio import: reserves the UID now (state Loading) and decodes off the main + // thread, publishing the clip in AssetBank::pump(). AudioClipLoader is a pure loader (it never + // touches the bank), so this needs only the generic requestAsset overload. Playing the + // returned UID before it is Ready is safe -- it is simply silent until the decode lands. + AssetUID requestAudio(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; + AssetUID audioClip(const std::string& name) const; + std::vector> getScenes(); void setScenes(const std::vector>& scenes); @@ -50,6 +86,23 @@ 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); + + // Authored mixer levels, indexed by BusId, persisted with the project. Held here rather than in + // EngineConfig (which is only the recently-opened-projects list) because a mix is part of a + // project's content, not an application preference. The engine applies these to its AudioEngine + // when the project is adopted, and reads them back before a save. + const std::vector& getBusGains() const { return m_bus_gains; } + const std::vector& getBusMutes() const { return m_bus_mutes; } + void setBusGains(const std::vector& gains) { m_bus_gains = gains; } + void setBusMutes(const std::vector& mutes) { m_bus_mutes = mutes; } + static json dumpVec3(const Eigen::Vector3f& v); static json dumpVec4(const Eigen::Vector4f& v); @@ -90,14 +143,26 @@ class Project { fs::path m_shaders_directory; fs::path m_textures_directory; fs::path m_cubemaps_directory; + fs::path m_audio_directory; std::string m_name; + // Per-bus gain/mute, parallel to the BusId enum. Empty means "never authored"; the engine then + // leaves its AudioEngine at defaults (unity gain, unmuted). + std::vector m_bus_gains; + std::vector m_bus_mutes; + 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..72437c3c --- /dev/null +++ b/ICE/IO/src/DefaultLoaders.cpp @@ -0,0 +1,25 @@ +#include "DefaultLoaders.h" + +#include + +#include "AssetBank.h" +#include "AudioClipLoader.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()); + // The AudioClip loader lives in the `audio` module (its decoders are a private dependency + // there); its "Audio" path prefix is pre-registered alongside the other built-ins. + 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..7fd5deea 100644 --- a/ICE/IO/src/Project.cpp +++ b/ICE/IO/src/Project.cpp @@ -4,26 +4,48 @@ #include "Project.h" +#include +#include +#include +#include #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", "Audio"}; + 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"; @@ -33,6 +55,7 @@ Project::Project(const fs::path &base_directory, const std::string &m_name) m_cubemaps_directory = m_base_directory / assets_folder / "Cubemaps"; m_models_directory = m_base_directory / assets_folder / "Models"; m_meshes_directory = m_base_directory / assets_folder / "Meshes"; + m_audio_directory = m_base_directory / assets_folder / "Audio"; m_scenes_directory = m_base_directory / "Scenes"; } @@ -49,6 +72,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 +82,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,12 +160,57 @@ void Project::writeToFile(const std::shared_ptr &editorCamera) { vec.push_back(dumpAsset(asset_id, texture)); } j["cubeMaps"] = vec; + vec.clear(); + + // Audio clips persist by source path like textures and meshes: the decoded PCM is rebuilt by + // AudioClipLoader on load rather than being written into the project file. + for (const auto &[asset_id, clip] : m_asset_bank->getAll()) { + vec.push_back(dumpAsset(asset_id, clip)); + } + j["audioClips"] = vec; + vec.clear(); + + if (!m_bus_gains.empty() || !m_bus_mutes.empty()) { + json mixer; + mixer["bus_gains"] = m_bus_gains; + mixer["bus_mutes"] = m_bus_mutes; + j["audioMixer"] = mixer; + } + + // 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(); + // Ensure the scenes folder exists before writing into it, as the material/shader exports above + // already do for theirs. Without this an absent Scenes/ directory made the ofstream fail + // silently and every scene was dropped from the save with no error. + fs::create_directories(m_scenes_directory); + for (const auto &s : m_scenes) { outstream.open(m_scenes_directory / (s->getName() + ".ics")); + if (!outstream.is_open()) { + Logger::Log(Logger::ERROR, "IO", "Could not write scene file '%s'", s->getName().c_str()); + continue; + } j.clear(); j["m_name"] = s->getName(); @@ -190,7 +259,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; @@ -202,6 +271,34 @@ void Project::writeToFile(const std::shared_ptr &editorCamera) { scjson["skeleton_entity"] = sc.skeleton_entity; entity["skinningComponent"] = scjson; } + if (s->getRegistry()->entityHasComponent(e)) { + const AudioSourceComponent &asc = *s->getRegistry()->getComponent(e); + json ajson; + ajson["clip"] = asc.clip; + ajson["volume"] = asc.volume; + ajson["pitch"] = asc.pitch; + ajson["loop"] = asc.loop; + ajson["play_on_awake"] = asc.playOnAwake; + ajson["spatial"] = asc.spatial; + ajson["min_distance"] = asc.minDistance; + ajson["max_distance"] = asc.maxDistance; + ajson["rolloff"] = asc.rolloff; + ajson["priority"] = asc.priority; + ajson["bus"] = asc.bus; + // Only the authored fields are written. The live voice handle, the Doppler + // position cache and the playOnAwake/completion latches are runtime state: saving + // them would restore a scene mid-playback pointing at a voice that no longer + // exists. `state` is deliberately excluded too -- playOnAwake is the authored way + // to start a sound, so a scene always loads quiescent. + entity["audioSourceComponent"] = ajson; + } + if (s->getRegistry()->entityHasComponent(e)) { + const AudioListenerComponent &alc = *s->getRegistry()->getComponent(e); + json ljson; + ljson["volume"] = alc.volume; + ljson["active"] = alc.active; + entity["audioListenerComponent"] = ljson; + } entities.push_back(entity); } j["entities"] = entities; @@ -227,8 +324,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"]; @@ -248,11 +354,60 @@ void Project::loadFromFile() { loadAssetsOfType(material); loadAssetsOfType(meshes); loadAssetsOfType(models); + // Absent in projects written before audio existed; those clips (if any) come back through the + // generic "assets" section below, which routes by path prefix and handles them correctly. + if (j.contains("audioClips")) { + loadAssetsOfType(j["audioClips"]); + } + if (j.contains("audioMixer")) { + m_bus_gains = j["audioMixer"].value("bus_gains", std::vector{}); + m_bus_mutes = j["audioMixer"].value("bus_mutes", std::vector{}); + } + + // 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 +451,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; @@ -308,6 +463,34 @@ void Project::loadFromFile() { sc.skeleton_entity = skj["skeleton_entity"]; scene.getRegistry()->addComponent(e, sc); } + if (!jentity["audioSourceComponent"].is_null()) { + json aj = jentity["audioSourceComponent"]; + AudioSourceComponent asc; + // .value() throughout: a field added after a project was last saved must default + // rather than throw, so older scenes keep loading. + asc.clip = aj.value("clip", (AssetUID) NO_ASSET_ID); + asc.volume = aj.value("volume", 1.0f); + asc.pitch = aj.value("pitch", 1.0f); + asc.loop = aj.value("loop", false); + asc.playOnAwake = aj.value("play_on_awake", false); + asc.spatial = aj.value("spatial", true); + asc.minDistance = aj.value("min_distance", 1.0f); + asc.maxDistance = aj.value("max_distance", 500.0f); + asc.rolloff = aj.value("rolloff", 1.0f); + asc.priority = aj.value("priority", (uint8_t) 128); + asc.bus = aj.value("bus", (uint8_t) 2); + // Runtime fields keep their defaults: no voice, no cached position, latches clear. + // playOnAwake then starts the sound on the first AudioSystem update, exactly as it + // would for a freshly authored source. + scene.getRegistry()->addComponent(e, asc); + } + if (!jentity["audioListenerComponent"].is_null()) { + json lj = jentity["audioListenerComponent"]; + AudioListenerComponent alc; + alc.volume = lj.value("volume", 1.0f); + alc.active = lj.value("active", true); + scene.getRegistry()->addComponent(e, alc); + } } for (json jentity : scenejson["entities"]) { Entity e = jentity["id"]; @@ -326,7 +509,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 +525,86 @@ 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::importAudio(const std::string &name, const fs::path &src, bool for_3d) { + // Copy the source file into /Assets/Audio (keeping its extension) and register it. + copyAssetFile("Audio", name, src); + fs::path dst = m_audio_directory / (name + src.extension().string()); + + if (!for_3d) { + m_asset_bank->addAsset(name, {dst}); + return audioClip(name); + } + + // 3D import: decode here so the result can be folded to mono before it ever reaches the device. + auto decoded = DecodeAudioFile(dst); + if (!decoded.has_value()) { + Logger::Log(Logger::ERROR, "IO", "Could not decode audio '%s' for 3D import.", dst.string().c_str()); + return NO_ASSET_ID; + } + const uint32_t original_channels = decoded->channels; + DownmixToMono(*decoded); + if (original_channels > 1) { + Logger::Log(Logger::INFO, "IO", "Downmixed '%s' from %u channels to mono so it can be spatialized.", name.c_str(), + original_channels); + } + + auto clip = std::make_shared(std::move(decoded->samples), decoded->channels, decoded->sampleRate); + clip->setSources({dst}); + m_asset_bank->addAsset(name, clip); + return audioClip(name); +} + +AssetUID Project::requestAudio(const std::string &name, const std::vector &sources) { + // AudioClipLoader neither reads nor mutates the bank, so the generic requestAsset overload + // covers this entirely: it stages the decode on the scheduler and publishes the result in + // pump(). Contrast requestModel, which needs an explicit stage/commit split because the model + // loader adds sub-assets. + return m_asset_bank->requestAsset(name, sources); +} + +AssetUID Project::mesh(const std::string &name) const { + return m_asset_bank->getUID(AssetPath::WithTypePrefix(name)); +} + +AssetUID Project::audioClip(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 +612,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 +631,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 +644,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 +665,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 +679,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..32429c90 --- /dev/null +++ b/ICE/IO/test/ProjectTest.cpp @@ -0,0 +1,358 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#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); +} + +// --- Audio persistence (phase 3) ---------------------------------------------------------------- + +namespace { +// Write a tiny mono WAV so the round-trip exercises the real AudioClipLoader rather than a stub: +// clips persist by source path, so load must be able to decode the file again. +void writeMonoWav(const fs::path &path, int frames = 64) { + std::vector pcm(frames, 1234); + const uint32_t data_bytes = static_cast(pcm.size() * 2); + std::ofstream out(path, std::ios::binary); + auto u32 = [&](uint32_t v) { out.write(reinterpret_cast(&v), 4); }; + auto u16 = [&](uint16_t v) { out.write(reinterpret_cast(&v), 2); }; + out.write("RIFF", 4); + u32(36 + data_bytes); + out.write("WAVE", 4); + out.write("fmt ", 4); + u32(16); + u16(1); // PCM + u16(1); // mono + u32(44100); + u32(44100 * 2); + u16(2); + u16(16); + out.write("data", 4); + u32(data_bytes); + out.write(reinterpret_cast(pcm.data()), data_bytes); +} +} // namespace + +TEST(ProjectTest, AudioClipRoundTrips) { + fs::path base = freshBase(); + auto cam = makeCamera(); + + fs::path wav = base / "tone.wav"; + writeMonoWav(wav); + + AssetUID uid = NO_ASSET_ID; + { + Project proj(base, "Proj"); + fs::create_directories(proj.getBaseDirectory()); + uid = proj.importAudio("tone", wav); + ASSERT_NE(uid, NO_ASSET_ID); + proj.writeToFile(cam); + } + { + Project proj(base, "Proj"); + proj.loadFromFile(); + auto clip = proj.getAssetBank()->getAsset("tone"); + ASSERT_NE(clip, nullptr) << "the clip must be decoded again from its persisted source path"; + EXPECT_EQ(clip->getChannels(), 1u); + EXPECT_EQ(clip->getSampleRate(), 44100u); + EXPECT_EQ(proj.audioClip("tone"), uid) << "UIDs must be stable across save/load"; + } + std::error_code ec; + fs::remove_all(base, ec); +} + +// A stereo source imported for 3D is folded to mono at import, and stays mono across a round-trip +// (the downmixed PCM is what gets written to the project's Audio folder). +TEST(ProjectTest, ThreeDAudioImportIsMono) { + fs::path base = freshBase(); + fs::path wav = base / "stereo.wav"; + + // Hand-built stereo WAV. + { + const int frames = 32; + std::vector pcm(frames * 2, 500); + const uint32_t data_bytes = static_cast(pcm.size() * 2); + std::ofstream out(wav, std::ios::binary); + auto u32 = [&](uint32_t v) { out.write(reinterpret_cast(&v), 4); }; + auto u16 = [&](uint16_t v) { out.write(reinterpret_cast(&v), 2); }; + out.write("RIFF", 4); + u32(36 + data_bytes); + out.write("WAVE", 4); + out.write("fmt ", 4); + u32(16); + u16(1); + u16(2); + u32(44100); + u32(44100 * 4); + u16(4); + u16(16); + out.write("data", 4); + u32(data_bytes); + out.write(reinterpret_cast(pcm.data()), data_bytes); + } + + Project proj(base, "Proj"); + fs::create_directories(proj.getBaseDirectory()); + + AssetUID flat = proj.importAudio("music", wav, false); + ASSERT_NE(flat, NO_ASSET_ID); + EXPECT_EQ(proj.getAssetBank()->getAsset(flat)->getChannels(), 2u) << "a 2D import keeps its stereo image"; + + AssetUID spatial = proj.importAudio("footstep", wav, true); + ASSERT_NE(spatial, NO_ASSET_ID); + auto clip = proj.getAssetBank()->getAsset(spatial); + EXPECT_TRUE(clip->isMono()) << "a 3D import must be mono or OpenAL will not spatialize it"; + EXPECT_EQ(clip->getFrameCount(), 32u) << "downmix must preserve the frame count"; + + std::error_code ec; + fs::remove_all(base, ec); +} + +TEST(ProjectTest, AudioComponentsRoundTrip) { + fs::path base = freshBase(); + auto cam = makeCamera(); + fs::path wav = base / "tone.wav"; + writeMonoWav(wav); + + AssetUID clip_uid = NO_ASSET_ID; + { + Project proj(base, "Proj"); + fs::create_directories(proj.getBaseDirectory()); + clip_uid = proj.importAudio("tone", wav); + + Scene scene("MainScene"); + // createEntity() already registers the entity; calling addEntity() on it as well would + // list it twice and duplicate its components on load. + Entity e = scene.createEntity(); + scene.setAlias(e, "Emitter"); + scene.getRegistry()->addComponent(e, TransformComponent(Eigen::Vector3f(1, 2, 3))); + + AudioSourceComponent source(clip_uid); + source.volume = 0.4f; + source.pitch = 1.25f; + source.loop = true; + source.playOnAwake = true; + source.spatial = true; + source.minDistance = 2.5f; + source.maxDistance = 60.0f; + source.rolloff = 0.75f; + source.priority = 200; + source.bus = 1; + // Runtime state that must NOT survive the round-trip. + source.voice_index = 7; + source.voice_generation = 9; + source.state = AudioSourceState::Playing; + scene.getRegistry()->addComponent(e, source); + + AudioListenerComponent listener; + listener.volume = 0.8f; + listener.active = false; + scene.getRegistry()->addComponent(e, listener); + + proj.addScene(scene); + proj.setCurrentScene(proj.getScenes()[0]); + proj.writeToFile(cam); + } + { + Project proj(base, "Proj"); + proj.loadFromFile(); + auto scene = proj.getCurrentScene(); + ASSERT_NE(scene, nullptr); + + Entity found = NULL_ENTITY; + for (Entity e : scene->getRegistry()->getEntities()) { + if (scene->getRegistry()->entityHasComponent(e)) { + found = e; + break; + } + } + ASSERT_NE(found, NULL_ENTITY) << "the AudioSourceComponent should have been persisted"; + + auto *source = scene->getRegistry()->getComponent(found); + EXPECT_EQ(source->clip, clip_uid); + EXPECT_FLOAT_EQ(source->volume, 0.4f); + EXPECT_FLOAT_EQ(source->pitch, 1.25f); + EXPECT_TRUE(source->loop); + EXPECT_TRUE(source->playOnAwake); + EXPECT_TRUE(source->spatial); + EXPECT_FLOAT_EQ(source->minDistance, 2.5f); + EXPECT_FLOAT_EQ(source->maxDistance, 60.0f); + EXPECT_FLOAT_EQ(source->rolloff, 0.75f); + EXPECT_EQ(source->priority, 200); + EXPECT_EQ(source->bus, 1); + + // Runtime state must come back clean: a restored scene that pointed at a voice from the + // previous run would be controlling whatever sound now occupies that slot. + EXPECT_EQ(source->voice_index, 0u); + EXPECT_EQ(source->voice_generation, 0u); + EXPECT_FALSE(source->voice_started); + EXPECT_FALSE(source->awake_handled); + EXPECT_EQ(source->state, AudioSourceState::Stopped) << "a scene must load quiescent; playOnAwake starts it"; + + ASSERT_TRUE(scene->getRegistry()->entityHasComponent(found)); + auto *listener = scene->getRegistry()->getComponent(found); + EXPECT_FLOAT_EQ(listener->volume, 0.8f); + EXPECT_FALSE(listener->active); + } + std::error_code ec; + fs::remove_all(base, ec); +} + +TEST(ProjectTest, MixerLevelsRoundTrip) { + fs::path base = freshBase(); + auto cam = makeCamera(); + { + Project proj(base, "Proj"); + fs::create_directories(proj.getBaseDirectory()); + proj.setBusGains({1.0f, 0.5f, 0.25f, 0.75f, 1.0f}); + proj.setBusMutes({false, true, false, false, false}); + proj.writeToFile(cam); + } + { + Project proj(base, "Proj"); + proj.loadFromFile(); + ASSERT_EQ(proj.getBusGains().size(), 5u); + EXPECT_FLOAT_EQ(proj.getBusGains()[1], 0.5f); + EXPECT_FLOAT_EQ(proj.getBusGains()[2], 0.25f); + ASSERT_EQ(proj.getBusMutes().size(), 5u); + EXPECT_TRUE(proj.getBusMutes()[1]); + EXPECT_FALSE(proj.getBusMutes()[0]); + } + std::error_code ec; + fs::remove_all(base, ec); +} + +// A project written before audio existed has no "audioClips"/"audioMixer" keys. Loading it must +// not throw -- older projects have to keep opening. +TEST(ProjectTest, ProjectWithoutAudioSectionsStillLoads) { + fs::path base = freshBase(); + auto cam = makeCamera(); + { + Project proj(base, "Proj"); + fs::create_directories(proj.getBaseDirectory()); + proj.writeToFile(cam); + } + // Strip the audio keys, mimicking a pre-audio project file. + fs::path file = base / "Proj" / "Proj.ice"; + json j; + { + std::ifstream in(file); + ASSERT_TRUE(in.is_open()); + in >> j; + } + j.erase("audioClips"); + j.erase("audioMixer"); + { + std::ofstream out(file); + out << j.dump(4); + } + { + Project proj(base, "Proj"); + proj.loadFromFile(); // must not throw + EXPECT_TRUE(proj.getBusGains().empty()) << "an unauthored mix stays empty, leaving engine defaults"; + } + 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..b1826af7 --- /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&), 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..4072523c 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,29 @@ 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 audio and rendering consume the final transforms. +enum SystemUpdateOrder : int { + ScriptSystemOrder = 100, + AnimationSystemOrder = 200, + SceneGraphSystemOrder = 300, + // Audio runs after the scene graph so listener/source poses are final world transforms, and + // before rendering so a frame's audio and visuals are derived from the same state. + AudioSystemOrder = 350, + 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 +54,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 +62,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 +86,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 +95,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 +118,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..fe32803d 100644 --- a/ICE/System/src/RenderSystem.cpp +++ b/ICE/System/src/RenderSystem.cpp @@ -4,92 +4,193 @@ #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) { + // Gather the frame's inputs and hand them to the renderer in one drawFrame() call (Phase 5). This + // system only produces the visible set; the renderer owns the frame's structure. + FrameInputs inputs; + inputs.camera = m_camera.get(); auto view_mat = m_camera->lookThrough(); 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}}, - }); + inputs.skybox = Skybox{ + .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): collect the survivors in queue order (the renderer sorts them anyway). + for (size_t i = 0; i < jobs.size(); ++i) { + if (visible[i]) { + inputs.drawables.push_back(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++) { @@ -99,36 +200,32 @@ void RenderSystem::update(double delta) { auto lc = m_registry->getComponent(light); auto tc = m_registry->getComponent(light); - m_renderer->submitLight(Light{.position = tc->getPosition(), + inputs.lights.push_back(Light{.position = tc->getPosition(), .rotation = tc->getRotationEulerDeg(), .color = lc->color, .distance_dropoff = lc->distance_dropoff, .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(); + // Present target + shader for the frame. m_target (nullptr = default framebuffer) is honoured by + // the renderer's present pass, preserving the editor's render-to-texture path. Resolve the + // full-screen composite shader once and reuse it. + if (!m_lastpass_shader) { + m_lastpass_shader = m_gpu_bank->getShader(AssetPath::WithTypePrefix("lastpass")); } + inputs.outputTarget = m_target; + inputs.presentShader = 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); + // One hand-off: the renderer prepares, runs the graph (geometry -> features -> present), and ends + // the frame. + m_renderer->drawFrame(std::move(inputs)); } 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 +250,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..e464e4c7 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; @@ -21,9 +26,11 @@ class GLFWWindow : public Window { void setSwapInterval(int interval) override; void makeContextCurrent() override; void setResizeCallback(const WindowResizeCallback& callback) override; + void setFocusCallback(const WindowFocusCallback& callback) override; std::pair getSize() const override; void windowResized(int w, int h); + void windowFocusChanged(bool focused); private: GLFWwindow* m_handle; @@ -33,6 +40,8 @@ class GLFWWindow : public Window { std::shared_ptr m_keyboard_handler; WindowResizeCallback m_resize_callback = [](int, int) { }; + WindowFocusCallback m_focus_callback = [](bool) { + }; }; } // namespace ICE \ No newline at end of file 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..71becea8 --- /dev/null +++ b/ICE/Util/include/InputManager.h @@ -0,0 +1,45 @@ +#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(); + float getMouseAngle(); + + // 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/include/Window.h b/ICE/Util/include/Window.h index 39855f29..56e30ece 100644 --- a/ICE/Util/include/Window.h +++ b/ICE/Util/include/Window.h @@ -8,6 +8,8 @@ namespace ICE { using WindowResizeCallback = std::function; +// Fired when the window gains (true) or loses (false) input focus. +using WindowFocusCallback = std::function; class Window { public: @@ -23,6 +25,9 @@ class Window { virtual void setSwapInterval(int interval) = 0; virtual void makeContextCurrent() = 0; virtual void setResizeCallback(const WindowResizeCallback &callback) = 0; + // Optional: backends that cannot report focus simply never invoke the callback, so callers must + // treat "never called" as "always focused" rather than depending on an initial event. + virtual void setFocusCallback(const WindowFocusCallback & /*callback*/) {} virtual std::pair getSize() const = 0; }; } // namespace ICE \ No newline at end of file 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..6345023e 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); @@ -39,6 +47,20 @@ GLFWWindow::GLFWWindow(int width, int height, const std::string& title) : m_widt GLFWWindow* self = (GLFWWindow*) glfwGetWindowUserPointer(w); self->windowResized(width, height); }); + + glfwSetWindowFocusCallback(m_handle, [](GLFWwindow* w, int focused) { + GLFWWindow* self = (GLFWWindow*) glfwGetWindowUserPointer(w); + self->windowFocusChanged(focused == GLFW_TRUE); + }); +} + +GLFWWindow::~GLFWWindow() { + if (m_handle != nullptr) { + glfwDestroyWindow(m_handle); + } + if (--s_window_count == 0) { + glfwTerminate(); + } } std::pair, std::shared_ptr> GLFWWindow::getInputHandlers() const { @@ -90,4 +112,12 @@ void GLFWWindow::windowResized(int w, int h) { m_resize_callback(w, h); } +void GLFWWindow::setFocusCallback(const WindowFocusCallback& callback) { + m_focus_callback = callback; +} + +void GLFWWindow::windowFocusChanged(bool focused) { + m_focus_callback(focused); +} + } // namespace ICE \ No newline at end of file diff --git a/ICE/Util/src/InputManager.cpp b/ICE/Util/src/InputManager.cpp new file mode 100644 index 00000000..6cd1712d --- /dev/null +++ b/ICE/Util/src/InputManager.cpp @@ -0,0 +1,92 @@ +#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; +} + +float InputManager::getMouseAngle() { + auto [ww, wh] = m_window->getSize(); + return std::atan2(m_x - ww/2.0, m_y - wh/2.0); +} + +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/CMakeLists.txt b/ICEBERG/CMakeLists.txt index af3084e8..b3ada1eb 100644 --- a/ICEBERG/CMakeLists.txt +++ b/ICEBERG/CMakeLists.txt @@ -31,6 +31,21 @@ add_definitions(-DIMGUI_DEFINE_MATH_OPERATORS) target_link_libraries(${PROJECT_NAME} PUBLIC ICE DearImXML glfw) +# Configure-time copy (first-time setup). NOTE: file(COPY) runs only at CMake configure, so on its +# own it leaves these staged copies stale when files are added or edited and only the build is +# re-run -- which is exactly how a newly added widget XML goes missing at runtime. Same hazard the +# ICEFIELD Assets copy documents. file(COPY ${ICE_ROOT_SOURCE_DIR}/Assets DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/XML DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/EditorAssets DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) + +# Re-sync after every build so added/edited XML layouts, shaders and editor assets 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 + ${CMAKE_CURRENT_SOURCE_DIR}/XML ${CMAKE_CURRENT_BINARY_DIR}/XML + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${ICE_ROOT_SOURCE_DIR}/Assets ${CMAKE_CURRENT_BINARY_DIR}/Assets + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/EditorAssets ${CMAKE_CURRENT_BINARY_DIR}/EditorAssets + COMMENT "Syncing editor XML/Assets to the ICEBERG build directory") 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..64f30408 100644 --- a/ICEBERG/UI/AddComponentPopup.h +++ b/ICEBERG/UI/AddComponentPopup.h @@ -1,6 +1,11 @@ #pragma once +#include +#include +#include +#include #include +#include #include #include "Components/ComboBox.h" @@ -24,6 +29,12 @@ class AddComponentPopup : public Dialog { if (!registry->entityHasComponent(entity)) { values.push_back("Animation Component"); } + if (!registry->entityHasComponent(entity)) { + values.push_back("Audio Source Component"); + } + if (!registry->entityHasComponent(entity)) { + values.push_back("Audio Listener Component"); + } m_components_combo.setValues(values); } @@ -44,6 +55,12 @@ class AddComponentPopup : public Dialog { if (m_components_combo.getSelectedItem() == "Animation Component") m_registry->addComponent(m_entity, ICE::AnimationComponent{"", 0.0, 1.0, true, true}); + + if (m_components_combo.getSelectedItem() == "Audio Source Component") + m_registry->addComponent(m_entity, ICE::AudioSourceComponent{}); + + if (m_components_combo.getSelectedItem() == "Audio Listener Component") + m_registry->addComponent(m_entity, ICE::AudioListenerComponent{}); done(DialogResult::Ok); } ImGui::SameLine(); 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/AudioMixerWidget.h b/ICEBERG/UI/AudioMixerWidget.h new file mode 100644 index 00000000..30f6e1a6 --- /dev/null +++ b/ICEBERG/UI/AudioMixerWidget.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +#include +#include + +#include "Widget.h" + +// Mixer levels for the master output and each bus, plus the editor's global audio toggle. +// +// NOTE ON THE MUTE DEFAULT: this editor has no play mode -- a loaded scene is always live, so its +// AudioSystem runs and any `play on awake` source starts immediately on open. Rather than have a +// project greet you with ambient loops, editor audio starts MUTED and this panel is where you turn +// it on. If a play/stop mode is added later, that becomes the natural thing to key muting off +// instead, and this default should go away. +class AudioMixerWidget : public Widget { + public: + void setAudioEngine(ICE::AudioEngine* audio) { m_audio = audio; } + + void open() { m_open = true; } + bool isOpen() const { return m_open; } + + void render() override { + if (!m_open) { + return; + } + if (ImGui::Begin("Audio Mixer", &m_open)) { + if (m_audio == nullptr) { + ImGui::TextDisabled("No audio service (open a project first)."); + ImGui::End(); + return; + } + + bool muted = m_audio->isMuted(); + if (ImGui::Checkbox("Mute editor audio", &muted)) { + m_audio->setMuted(muted); + } + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("There is no play mode, so scenes are always live.\nEditor audio starts muted."); + } + + ImGui::Separator(); + + float master = m_audio->getMasterGain(); + if (ImGui::SliderFloat("Master", &master, 0.0f, 1.0f)) { + m_audio->setMasterGain(master); + } + + ImGui::Separator(); + for (std::size_t i = 0; i < kBusNames.size(); ++i) { + const auto bus = static_cast(i); + ImGui::PushID(static_cast(i)); + + bool bus_muted = m_audio->isBusMuted(bus); + if (ImGui::Checkbox("##mute", &bus_muted)) { + m_audio->setBusMuted(bus, bus_muted); + } + ImGui::SameLine(); + + float gain = m_audio->getBusGain(bus); + if (ImGui::SliderFloat(kBusNames[i], &gain, 0.0f, 1.0f)) { + m_audio->setBusGain(bus, gain); + } + ImGui::PopID(); + } + + ImGui::Separator(); + // A steadily climbing steal count means the voice pool is undersized for the scene -- + // worth seeing while authoring rather than discovering as sounds mysteriously dropping. + ImGui::Text("Voices: %zu active", m_audio->getActiveVoiceCount()); + ImGui::Text("Stolen: %zu", m_audio->getStolenVoiceCount()); + ImGui::TextDisabled("Device: %s", m_audio->getBackend().deviceName().c_str()); + } + ImGui::End(); + } + + private: + // Parallel to the BusId enum; Master is index 0 and controlled by the master slider above, so + // it is listed here only for completeness of the routing table. + static inline const std::array kBusNames = {"Master bus", "Music", "SFX", "UI", "Voice"}; + + ICE::AudioEngine* m_audio = nullptr; + bool m_open = false; +}; diff --git a/ICEBERG/UI/AudioSourceComponentWidget.h b/ICEBERG/UI/AudioSourceComponentWidget.h new file mode 100644 index 00000000..add3800b --- /dev/null +++ b/ICEBERG/UI/AudioSourceComponentWidget.h @@ -0,0 +1,164 @@ +#pragma once +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "Widget.h" + +class AudioSourceComponentWidget : public Widget, ImXML::XMLEventHandler { + public: + explicit AudioSourceComponentWidget() : m_xml_tree(ImXML::XMLReader().read("XML/AudioSourceComponentWidget.xml")) { + m_xml_renderer.addDynamicBind("float_volume", {&m_volume, 1, ImXML::Float}); + m_xml_renderer.addDynamicBind("float_pitch", {&m_pitch, 1, ImXML::Float}); + m_xml_renderer.addDynamicBind("bool_loop", {&m_loop, 1, ImXML::Bool}); + m_xml_renderer.addDynamicBind("bool_awake", {&m_play_on_awake, 1, ImXML::Bool}); + m_xml_renderer.addDynamicBind("bool_spatial", {&m_spatial, 1, ImXML::Bool}); + m_xml_renderer.addDynamicBind("float_min_distance", {&m_min_distance, 1, ImXML::Float}); + m_xml_renderer.addDynamicBind("float_max_distance", {&m_max_distance, 1, ImXML::Float}); + m_xml_renderer.addDynamicBind("float_rolloff", {&m_rolloff, 1, ImXML::Float}); + } + + void onNodeBegin(ImXML::XMLNode& node) override { + if (node.arg("id") == "clip_combo") { + const std::string preview = m_selected_index >= 0 && m_selected_index < (int) m_clip_names.size() + ? m_clip_names[m_selected_index] + : ""; + if (ImGui::BeginCombo("##audio_clip_combo", preview.c_str())) { + for (int i = 0; i < (int) m_clip_names.size(); ++i) { + if (ImGui::Selectable(m_clip_names[i].c_str(), i == m_selected_index)) { + m_selected_index = i; + m_clip_changed = true; + } + } + ImGui::EndCombo(); + } + } + } + void onNodeEnd(ImXML::XMLNode& node) override {} + void onEvent(ImXML::XMLNode& node) override { + if (node.arg("id") == "btn_remove" && m_on_remove) { + m_on_remove(); + } + } + + void onRemove(const std::function& f) { m_on_remove = f; } + // Fired when the editor should audition the selected clip (preview button below). + void onPreview(const std::function& f) { m_on_preview = f; } + + void render() override { + if (m_asc == nullptr) { + return; + } + m_xml_renderer.render(m_xml_tree, *this); + + if (m_clip_changed && m_selected_index >= 0 && m_selected_index < (int) m_clip_ids.size()) { + m_asc->clip = m_clip_ids[m_selected_index]; + m_clip_changed = false; + cacheSelectedClipInfo(); // so the channel/duration readout follows the new selection + } + + // A 3D source with a stereo clip is the single most common audio authoring mistake: + // OpenAL positions mono buffers only, so it would play flat at full volume and look like + // broken 3D. Say so where the mistake is made rather than only in the log at play time. + if (m_spatial && m_selected_channels > 1) { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.2f, 1.0f), "Clip is %d-channel: cannot be spatialized.", m_selected_channels); + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.2f, 1.0f), "Re-import it with '3D' checked to downmix to mono."); + } + if (m_selected_index >= 0 && m_selected_duration > 0.0f) { + ImGui::TextDisabled("%.2fs, %d ch", m_selected_duration, m_selected_channels); + } + + if (ImGui::Button("Preview") && m_on_preview && m_asc->clip != NO_ASSET_ID) { + m_on_preview(m_asc->clip); + } + + m_asc->volume = m_volume; + m_asc->pitch = m_pitch; + m_asc->loop = m_loop; + m_asc->playOnAwake = m_play_on_awake; + m_asc->spatial = m_spatial; + m_asc->minDistance = m_min_distance; + m_asc->maxDistance = m_max_distance; + m_asc->rolloff = m_rolloff; + } + + // Per-frame pointer refresh only (see TransformComponentWidget): does not rebuild the clip list + // or re-read authored values, so typing into a field is not fought by the refresh. + void refreshComponent(ICE::AudioSourceComponent* asc) { m_asc = asc; } + + void setAudioSourceComponent(ICE::AudioSourceComponent* asc, const std::vector& clip_names, + const std::vector& clip_ids, const std::vector& clip_channels, + const std::vector& clip_durations) { + m_asc = asc; + m_clip_names = clip_names; + m_clip_ids = clip_ids; + m_clip_channels = clip_channels; + m_clip_durations = clip_durations; + m_selected_index = -1; + if (asc == nullptr) { + return; + } + for (int i = 0; i < (int) m_clip_ids.size(); ++i) { + if (m_clip_ids[i] == asc->clip) { + m_selected_index = i; + break; + } + } + m_volume = asc->volume; + m_pitch = asc->pitch; + m_loop = asc->loop; + m_play_on_awake = asc->playOnAwake; + m_spatial = asc->spatial; + m_min_distance = asc->minDistance; + m_max_distance = asc->maxDistance; + m_rolloff = asc->rolloff; + cacheSelectedClipInfo(); + } + + private: + void cacheSelectedClipInfo() { + m_selected_channels = 0; + m_selected_duration = 0.0f; + if (m_selected_index < 0) { + return; + } + if (m_selected_index < (int) m_clip_channels.size()) { + m_selected_channels = m_clip_channels[m_selected_index]; + } + if (m_selected_index < (int) m_clip_durations.size()) { + m_selected_duration = m_clip_durations[m_selected_index]; + } + } + + ICE::AudioSourceComponent* m_asc = nullptr; + std::function m_on_remove; + std::function m_on_preview; + + std::vector m_clip_names; + std::vector m_clip_ids; + std::vector m_clip_channels; + std::vector m_clip_durations; + int m_selected_index = -1; + bool m_clip_changed = false; + int m_selected_channels = 0; + float m_selected_duration = 0.0f; + + float m_volume = 1.0f; + float m_pitch = 1.0f; + bool m_loop = false; + bool m_play_on_awake = false; + bool m_spatial = true; + float m_min_distance = 1.0f; + float m_max_distance = 500.0f; + float m_rolloff = 1.0f; + + ImXML::XMLTree m_xml_tree; + ImXML::XMLRenderer m_xml_renderer; +}; 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..fb5b4425 100644 --- a/ICEBERG/UI/InspectorWidget.h +++ b/ICEBERG/UI/InspectorWidget.h @@ -2,6 +2,7 @@ #include #include "AnimationComponentWidget.h" +#include "AudioSourceComponentWidget.h" #include "Components/InputText.h" #include "Components/UniformInputs.h" #include "LightComponentWidget.h" @@ -13,6 +14,13 @@ 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"); }); + m_as_widget.onRemove([this] { callback("remove_audio_source_component_clicked"); }); + m_as_widget.onPreview([this](ICE::AssetUID clip) { callback("preview_audio_clip", clip); }); } void render() override { @@ -27,6 +35,7 @@ class InspectorWidget : public Widget { m_rc_widget.render(); m_lc_widget.render(); m_ac_widget.render(); + m_as_widget.render(); if (ImGui::Button("Add Component...")) { callback("add_component_clicked"); @@ -36,6 +45,19 @@ 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, + ICE::AudioSourceComponent* as) { + 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); + m_as_widget.refreshComponent(as); + } + void setEntityName(const std::string& name) { m_input_entity_name.setText(name); } void setTransformComponent(ICE::TransformComponent* tc) { @@ -50,12 +72,18 @@ class InspectorWidget : public Widget { const std::vector& material_paths, const std::vector& material_ids) { m_rc_widget.setRenderComponent(rc, meshes_paths, meshes_ids, material_paths, material_ids); } + void setAudioSourceComponent(ICE::AudioSourceComponent* as, const std::vector& clip_names, + const std::vector& clip_ids, const std::vector& clip_channels, + const std::vector& clip_durations) { + m_as_widget.setAudioSourceComponent(as, clip_names, clip_ids, clip_channels, clip_durations); + } private: TransformComponentWidget m_tc_widget; RenderComponentWidget m_rc_widget; LightComponentWidget m_lc_widget; AnimationComponentWidget m_ac_widget; + AudioSourceComponentWidget m_as_widget; bool m_entity_selected = false; 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/XML/AudioSourceComponentWidget.xml b/ICEBERG/XML/AudioSourceComponentWidget.xml new file mode 100644 index 00000000..87ef9971 --- /dev/null +++ b/ICEBERG/XML/AudioSourceComponentWidget.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + +
+
diff --git a/ICEBERG/XML/EditorWidget.xml b/ICEBERG/XML/EditorWidget.xml index 83f7b320..6414b733 100644 --- a/ICEBERG/XML/EditorWidget.xml +++ b/ICEBERG/XML/EditorWidget.xml @@ -16,10 +16,15 @@ + + + + + diff --git a/ICEBERG/include/Assets.h b/ICEBERG/include/Assets.h index 02f59b31..a5261a19 100644 --- a/ICEBERG/include/Assets.h +++ b/ICEBERG/include/Assets.h @@ -20,7 +20,11 @@ class Assets : public Controller { void createSubfolderView(AssetView *parent_view, const std::vector &path, const Thumbnail &thumbnail, const std::string &full_path); - const std::vector m_asset_categories = {"Models", "Meshes", "Materials", "Textures2D", "TextureCubes", "Shaders", "Others"}; + // ORDER-SENSITIVE: rebuildViewer() tags each category with static_cast(index), so + // this list must stay positionally aligned with the AssetType enum in Asset.h. Adding a kind + // in one place without the other silently mislabels every category after it. + const std::vector m_asset_categories = {"Models", "Meshes", "Materials", "Textures2D", + "TextureCubes", "Shaders", "Audio", "Others"}; std::vector m_asset_views; int m_current_category_index = 0; @@ -35,5 +39,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..1694878b 100644 --- a/ICEBERG/include/AssetsRenderer.h +++ b/ICEBERG/include/AssetsRenderer.h @@ -16,8 +16,30 @@ 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; + // A cached offscreen renderer plus the target it presents into. render() no longer hands back a + // framebuffer (the pipeline decides what it composites), so each preview drives an explicit + // render-to-texture target and reads that back for ImGui. + struct Preview { + ICE::ForwardRenderer renderer; + std::shared_ptr target; + Preview(const std::shared_ptr& api, const std::shared_ptr& factory, + const std::shared_ptr& bank) + : renderer(api, factory, bank), + target(factory->createFramebuffer({256, 256, 1})) { + renderer.resize(256, 256); + } + }; + + std::unordered_map m_renderers; std::shared_ptr m_api; std::shared_ptr m_factory; std::shared_ptr m_bank; diff --git a/ICEBERG/include/Editor.h b/ICEBERG/include/Editor.h index 7341de73..d14cf625 100644 --- a/ICEBERG/include/Editor.h +++ b/ICEBERG/include/Editor.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include @@ -8,6 +10,7 @@ #include #include +#include #include #include "Assets.h" @@ -22,23 +25,57 @@ class Editor : public Controller { bool update() override; private: + // Audio import needs a decision the generic path cannot make: a clip meant for 3D playback has + // to be mono, because OpenAL only spatializes mono buffers. Asking here -- via two menu entries + // -- puts that choice at the moment of import instead of surfacing it as broken 3D later. + // The 3D path is synchronous because the downmix happens during decode. + bool importAudioAsset(bool for_3d) { + std::filesystem::path file = open_native_dialog({{"Audio", "*.wav;*.mp3;*.flac;*.ogg"}}); + if (file.empty()) { + 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); + } + if (for_3d) { + return m_engine->getProject()->importAudio(import_name, file, true) != NO_ASSET_ID; + } + m_engine->getProject()->copyAssetFile("Audio", import_name, file); + std::vector sources = {m_engine->getProject()->getBaseDirectory() / "Assets" / "Audio" / + (import_name + file.extension().string())}; + m_engine->getAssetBank()->requestAsset(import_name, sources); + return true; + } + 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); @@ -53,6 +90,10 @@ class Editor : public Controller { ICE::Entity m_selected_entity = 0; bool m_entity_transform_changed = false; + AudioMixerWidget m_audio_mixer; + // Latches the one-time "start muted" default (see Editor::update). + bool m_audio_initialized = false; + //Popups MaterialEditor m_material_popup; ShaderEditor m_shader_popup; 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..8571e2dd 100644 --- a/ICEBERG/include/Inspector.h +++ b/ICEBERG/include/Inspector.h @@ -16,10 +16,19 @@ 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, AudioSource }; + 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; + // The inspector's clip audition voice. Kept so a second Preview click replaces the first + // rather than layering sounds on top of each other. + ICE::VoiceHandle m_preview_voice; }; 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..34e9f27d 100644 --- a/ICEBERG/src/Assets.cpp +++ b/ICEBERG/src/Assets.cpp @@ -1,5 +1,6 @@ #include "Assets.h" +#include #include #include @@ -12,15 +13,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 +80,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; @@ -89,6 +100,8 @@ void Assets::rebuildViewer() { category = "TextureCubes"; } else if (std::dynamic_pointer_cast(entry.asset)) { category = "Shaders"; + } else if (std::dynamic_pointer_cast(entry.asset)) { + category = "Audio"; } else { category = "Others"; } @@ -138,6 +151,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..ce96f2f9 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,8 +52,7 @@ std::pair AssetsRenderer::getPreview(const std::shared_ptr(60.0, 1.0, 0.01, 10000.0); @@ -57,26 +60,27 @@ std::pair AssetsRenderer::getPreview(const std::shared_ptrup(1); camera->pitch(-30); - auto& renderer = m_renderers.at(key); + auto& preview = m_renderers.at(key); + auto& renderer = preview.renderer; for (int i = 0; i < meshes.size(); i++) { - std::unordered_map> 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}); + // Present the frame into our own target (a render-to-texture blit) and read that back: render() + // returns nothing now, so we drive an explicit target rather than assuming a resource name. + renderer.setPresentTarget(preview.target); + renderer.setPresentShader(m_bank->getShader(ICE::AssetPath::WithTypePrefix("lastpass"))); renderer.prepareFrame(*camera); - auto fb = renderer.render(); + renderer.render(); renderer.endFrame(); - return {static_cast(0) + fb->getTexture(), true}; + return {static_cast(0) + preview.target->getTexture(), true}; } \ No newline at end of file diff --git a/ICEBERG/src/Editor.cpp b/ICEBERG/src/Editor.cpp index 95ae1f99..23b20613 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) { @@ -25,7 +28,15 @@ Editor::Editor(const std::shared_ptr& engine, const std::shared_ ui.registerCallback("import_texture2d_menu", [this] { importAsset({{"Images", "*.png;*.jpg;*.jpeg"}}); }); ui.registerCallback("import_cubemap_menu", [this] { importAsset({{"Images", "*.png;*.jpg;*.jpeg"}}); }); ui.registerCallback("import_model_menu", [this] { importAsset({{"Models", "*.glb;*.fbx;*.obj"}}); }); - ui.registerCallback("save_menu", [this] { m_engine->getProject()->writeToFile(m_engine->getCamera()); }); + ui.registerCallback("import_audio_menu", [this] { importAudioAsset(false); }); + ui.registerCallback("import_audio_3d_menu", [this] { importAudioAsset(true); }); + ui.registerCallback("audio_mixer_menu", [this] { m_audio_mixer.open(); }); + ui.registerCallback("save_menu", [this] { + // Harvest the live mixer levels onto the project first, so a mix tweaked in the Audio + // Mixer panel is part of what gets written. + m_engine->storeProjectMixer(); + m_engine->getProject()->writeToFile(m_engine->getCamera()); + }); ui.registerCallback("exit_menu", [this] { m_engine->getProject()->writeToFile(m_engine->getCamera()); m_engine->getWindow()->close(); @@ -34,6 +45,19 @@ Editor::Editor(const std::shared_ptr& engine, const std::shared_ bool Editor::update() { ui.render(); + // The audio service is built lazily on first use, so bind it every frame rather than once at + // construction (where there may be no project yet). + if (auto* audio = m_engine->audio()) { + if (!m_audio_initialized) { + // This editor has no play mode: a loaded scene is live and its `play on awake` sources + // would start the moment a project opens. Start muted so opening a project is quiet; + // the Audio > Mixer panel un-mutes. Revisit if a play/stop mode is ever added. + audio->setMuted(true); + m_audio_initialized = true; + } + m_audio_mixer.setAudioEngine(audio); + } + m_audio_mixer.render(); m_viewport->update(); m_hierarchy->update(); m_inspector->update(); @@ -42,6 +66,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/Iceberg.cpp b/ICEBERG/src/Iceberg.cpp index d47c9c18..89acb0f2 100644 --- a/ICEBERG/src/Iceberg.cpp +++ b/ICEBERG/src/Iceberg.cpp @@ -106,7 +106,11 @@ int main(int argc, char const* argv[]) { io.Fonts->Build(); ImGui_ImplGlfw_InitForOpenGL(static_cast(window->getHandle()), true); +#ifdef __APPLE__ + ImGui_ImplOpenGL3_Init("#version 410 core"); // macOS caps OpenGL at 4.1 +#else ImGui_ImplOpenGL3_Init("#version 420 core"); +#endif { auto& style{ImGui::GetStyle()}; diff --git a/ICEBERG/src/Inspector.cpp b/ICEBERG/src/Inspector.cpp index 86a6eac1..cf3c17d6 100644 --- a/ICEBERG/src/Inspector.cpp +++ b/ICEBERG/src/Inspector.cpp @@ -1,4 +1,7 @@ #include "Inspector.h" +#include +#include +#include Inspector::Inspector(const std::shared_ptr& engine) : m_engine(engine) { ui.registerCallback("entity_name_changed", [this](std::string text) { @@ -9,18 +12,70 @@ 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; }); + ui.registerCallback("remove_audio_source_component_clicked", [this] { m_pending_remove = PendingRemove::AudioSource; }); + // Audition a clip straight from the inspector, without entering play mode. Routed through the + // engine's audio service as a plain 2D one-shot at high priority so it is never voice-stolen. + ui.registerCallback("preview_audio_clip", [this](ICE::AssetUID clip) { + if (auto* audio = m_engine->audio()) { + audio->stop(m_preview_voice); + m_preview_voice = audio->play(clip, {.priority = 255}); + } }); } 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); + } + auto as = registry->tryGetComponent(e); + ui.refreshComponents(tc, lc, rc, ac, as); + } + 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); + } else if (m_pending_remove == PendingRemove::AudioSource) { + // Silence the live voice before the component goes away. AudioSystem::onEntityRemoved + // only fires when the entity stops matching, which a plain component removal may not + // trigger before the next frame -- so a looping sound could otherwise outlive its source. + if (auto* audio = m_engine->audio()) { + auto* asc = registry->tryGetComponent(m_selected_entity); + if (asc != nullptr) { + audio->stop(ICE::VoiceHandle{asc->voice_index, asc->voice_generation}); + } + } + 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 +87,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) { @@ -54,6 +112,7 @@ void Inspector::setSelectedEntity(ICE::Entity e, bool force_refesh) { ui.setLightComponent(nullptr); ui.setAnimationComponent(nullptr, {}); ui.setRenderComponent(nullptr, {}, {}, {}, {}); + ui.setAudioSourceComponent(nullptr, {}, {}, {}, {}); if (registry->entityHasComponent(e)) { auto tc = registry->getComponent(e); @@ -94,4 +153,22 @@ void Inspector::setSelectedEntity(ICE::Entity e, bool force_refesh) { auto lc = registry->getComponent(e); ui.setLightComponent(lc); } + if (registry->entityHasComponent(e)) { + auto as = registry->getComponent(e); + + // Channel count and duration travel alongside the names so the widget can warn about a + // stereo clip on a 3D source without reaching back into the asset bank each frame. + std::vector clip_names; + std::vector clip_ids; + std::vector clip_channels; + std::vector clip_durations; + for (const auto& [id, clip] : m_engine->getAssetBank()->getAll()) { + clip_ids.push_back(id); + clip_names.push_back(m_engine->getAssetBank()->getName(id).toString()); + clip_channels.push_back(static_cast(clip->getChannels())); + clip_durations.push_back(static_cast(clip->getDuration())); + } + + ui.setAudioSourceComponent(as, clip_names, clip_ids, clip_channels, clip_durations); + } } 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..7e36de1b 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,68 @@ 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) + + +# --- Audio ------------------------------------------------------------------------------------- +# OpenAL Soft is the audio backend (3D spatialization, HRTF, EFX reverb/filters). Built STATIC to +# preserve this project's "every dependency links static" property: ICE is itself LGPL-2.1, the +# same license as OpenAL Soft, so static linking imposes no obligation ICE does not already carry. +message(STATUS "Fetching OpenAL Soft") +set(ALSOFT_UTILS OFF CACHE BOOL "" FORCE) +set(ALSOFT_EXAMPLES OFF CACHE BOOL "" FORCE) +set(ALSOFT_TESTS OFF CACHE BOOL "" FORCE) +set(ALSOFT_INSTALL OFF CACHE BOOL "" FORCE) +set(ALSOFT_INSTALL_CONFIG OFF CACHE BOOL "" FORCE) +set(ALSOFT_INSTALL_HRTF_DATA OFF CACHE BOOL "" FORCE) +set(ALSOFT_INSTALL_AMBDEC_PRESETS OFF CACHE BOOL "" FORCE) +set(ALSOFT_INSTALL_EXAMPLES OFF CACHE BOOL "" FORCE) +set(ALSOFT_INSTALL_UTILS OFF CACHE BOOL "" FORCE) +set(LIBTYPE "STATIC" CACHE STRING "" FORCE) +FetchContent_Declare( + openal + GIT_REPOSITORY https://github.com/kcat/openal-soft.git + GIT_TAG 1.25.2 + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE) +FetchContent_MakeAvailable(openal) + +# Audio decoders. OpenAL is playback-only -- it takes raw PCM and nothing else -- so decoding is +# ours. All four are single-header and public domain, matching the vendored stb_image precedent. +# +# NOTE: neither upstream publishes release tags, so these are pinned to specific commits rather +# than a branch. Bump deliberately; do NOT switch these to `master` (non-reproducible builds). +message(STATUS "Fetching dr_libs (wav/mp3/flac decoders)") +FetchContent_Declare( + dr_libs + GIT_REPOSITORY https://github.com/mackron/dr_libs.git + GIT_TAG 34a89ffe6bfc4d78db6888fef76cd408dba18185 + GIT_PROGRESS TRUE) +FetchContent_MakeAvailable(dr_libs) + +add_library(dr_libs INTERFACE) +target_include_directories(dr_libs INTERFACE ${dr_libs_SOURCE_DIR}) + +message(STATUS "Fetching stb (stb_vorbis)") +FetchContent_Declare( + stb + GIT_REPOSITORY https://github.com/nothings/stb.git + GIT_TAG 31c1ad37456438565541f4919958214b6e762fb4 + GIT_PROGRESS TRUE) +FetchContent_MakeAvailable(stb) + +add_library(stb_vorbis INTERFACE) +target_include_directories(stb_vorbis INTERFACE ${stb_SOURCE_DIR}) + +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..f285fd66 --- /dev/null +++ b/docs/module_dependencies.md @@ -0,0 +1,96 @@ +# 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 container + │ │ │ + │ entity │ (generic containers; links NOTHING) + │ │ │ + └──────► asset ◄────┘ (CPU-only: Mesh/Material/Texture/AudioClip data) + │ │ + rhi / renderer ◄────┘ └────► audio (device/voice/mixer abstraction) + (graphics, graphics_api; │ + GPU types live here) audio_api_openal (OpenAL Soft backend) + │ │ + scene │ + │ │ + system (RenderSystem/...) │ + │ │ │ + │ └──────► audio_system ◄──────┘ (AudioSystem: the ECS/audio meeting point) + │ │ + io ◄──────────────────┘ + │ + core +``` + +### `container` + +Dependency-free leaf holding generic containers (`HandlePool`). It exists because two layers need +the same generational-handle pool: `graphics` (GPU resource handles) and `audio` (the voice pool). +`util` would have been the natural home, but it is **not** a leaf — `EngineHelper.h` includes +`ICEEngine.h`, so `util` reaches all the way to `core` (this is the tracked `util → graphics` +back-edge below). Putting `HandlePool` there would have forced `graphics → util` and closed a new +cycle. Once the `util → graphics` debt is paid, folding `container` back into `util` is reasonable. + +### `audio` / `audio_api_openal` + +`audio` is the backend-agnostic layer (device, voices, mixer buses, `AudioRegistry`). It links +`assets`, `math` and `container` and deliberately **does not** link `graphics`, `scene` or +`system` — the only scene coupling in the audio stack lives in `AudioSystem` (see below). + +Note there is intentionally no `audio_api` meta-target mirroring `graphics_api`: that pairing is +one of the baseline cycles below (`graphics_api ↔ graphics_api_OpenGL`). Backends link `audio` in +one direction only, and consumers link the backend they want. + +### `audio_system` + +Holds `AudioSystem`, the ECS half of the audio stack and the single place where audio meets the +scene. It links `audio` + `system` + `components`. + +The obvious home would be `system`, beside `RenderSystem` and `AnimationSystem` — but `system` sits +*inside* the pre-existing `assets ↔ graphics ↔ scene ↔ system` cycle, so adding `system → audio` +drags `audio` into that SCC. This is not hypothetical: the guard was run with that edge in place +and rejected it, reporting `audio->assets` and `system->audio` as new back-edges. Sitting above +both instead keeps `audio` free of any renderer dependency and adds no cycle. + +**Fold `audio_system` into `system` once the `assets ↔ graphics` debt is paid down** — at that +point the separation stops earning its keep. + +## 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.