diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8217c15 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Store text files with LF line endings and let git pick the checkout line endings. +* text=auto + +*.png binary +*.jpg binary +*.gif binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9d82bf9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Something in the plug-in does not work as described +labels: bug +--- + +**What happened** +A clear description of the problem. + +**Steps to reproduce** +1. +2. +3. + +**What you expected** + +**Versions** +- Spatial Clustering version: +- Wwise version (for example 2025.1.10): +- Platform (authoring and/or runtime, for example Windows x64): +- Game engine and Wwise integration, if any: + +**Bus setup** +The bus channel configuration, the plug-in's parameter values, and roughly how many inputs reach the bus and of which kind (mono 3D one-shots, ambiences, ...). + +**Capture Log** +Any messages from Spatial Clustering in the Wwise Capture Log. + +**Anything else** +Screenshots of the Audio Object List or 3D Viewer, or a small project that shows the problem, help a lot. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2b9568d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest something the plug-in could do +labels: enhancement +--- + +**Use case** +What are you trying to do, and in what kind of scene? + +**What you would like** +What the plug-in could do to help. + +**What you do today** +Any workaround you use now. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e015c2f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +**What changed and why** + + +**Testing** +- [ ] The plug-in builds and was tested at runtime in Wwise or a game. + +**Docs** +- [ ] If a parameter changed: `SpatialClustering.xml`, the help page in `wwise/WwisePlugin/res/Md/en`, and `docs/parameters.md` are updated. diff --git a/.gitignore b/.gitignore index 810f3ce..0c14250 100644 --- a/.gitignore +++ b/.gitignore @@ -475,3 +475,14 @@ ASALocalRun/ .localhistory/ # End of https://www.gitignore.io/api/archives,linux,xcode,macos,windows,visualstudio,premake-gmake + +### Spatial Clustering ### +# Help pages and the missing-help report written by wp.py build Documentation +wwise/WwisePlugin/res/Html/ +wwise/Output/ +# Visual Studio 2026 solutions generated by wp.py premake +*.slnx +# Release bundles written by scripts/package.py +dist/ +# Personal editor settings +.vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..ab4c4dd --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,212 @@ +# Copyright 2026 CCP ehf. +# +# This software was developed by CCP Games for spatial audio object clustering +# in EVE Online and EVE Frontier. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This license does not grant any rights to CCP's trademarks or game content. +# EVE Online and EVE Frontier are registered trademarks of CCP ehf. + +# Builds the runtime plug-in as a static library for games that build with CMake, and on +# Windows optionally the authoring plug-in. The premake scripts in wwise/ remain the build +# for the Wwise Launcher bundle. + +cmake_minimum_required(VERSION 3.16) + +file(STRINGS wwise/SpatialClusteringVersion.h SPATIALCLUSTERING_VERSION_LINE + REGEX "#define SPATIALCLUSTERING_VERSION_STRING") +string(REGEX MATCH "[0-9]+\\.[0-9]+\\.[0-9]+" SPATIALCLUSTERING_VERSION "${SPATIALCLUSTERING_VERSION_LINE}") + +project(SpatialClustering VERSION ${SPATIALCLUSTERING_VERSION} LANGUAGES CXX) + +option(SPATIALCLUSTERING_BUILD_AUTHORING "Build the Wwise authoring plug-in (Windows only)" OFF) +option(SPATIALCLUSTERING_BUILD_SHARED "Build the runtime plug-in as a dynamic library that the sound engine loads at run time" OFF) + +# A game that adds the plug-in with add_subdirectory links it into its own binaries, so by +# default only a standalone build installs it. +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(SPATIALCLUSTERING_STANDALONE ON) +else() + set(SPATIALCLUSTERING_STANDALONE OFF) +endif() +option(SPATIALCLUSTERING_INSTALL "Generate the install rules" ${SPATIALCLUSTERING_STANDALONE}) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +# The Wwise SDK comes from a Wwise CMake package (such as the wwise vcpkg port) when one is +# found, and otherwise from the SDK folder of a Wwise install. +if(NOT TARGET Wwise::Wwise) + find_package(Wwise CONFIG QUIET) +endif() +if(NOT TARGET Wwise::Wwise) + set(WWISE_SDK_DIR "$ENV{WWISESDK}" CACHE PATH "SDK folder of a Wwise install, the one that contains include/AK") + if(NOT EXISTS "${WWISE_SDK_DIR}/include/AK/AkWwiseSDKVersion.h") + message(FATAL_ERROR "Wwise SDK not found. Set WWISE_SDK_DIR, or the WWISESDK environment variable, to the SDK folder of a Wwise install.") + endif() + add_library(Wwise::Wwise INTERFACE IMPORTED) + set_target_properties(Wwise::Wwise PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${WWISE_SDK_DIR}/include" + INTERFACE_COMPILE_DEFINITIONS "$<$:AK_OPTIMIZED>") +endif() + +set(SPATIALCLUSTERING_RUNTIME_SOURCES + core/src/ClusterEngine.cpp + core/src/ClusterEngine.h + wwise/SoundEnginePlugin/SpatialClusteringFX.cpp + wwise/SoundEnginePlugin/SpatialClusteringFX.h + wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp + wwise/SoundEnginePlugin/SpatialClusteringFXParams.h + wwise/SpatialClusteringConfig.h) + +# Games include the factory header as , like the +# Audiokinetic plug-ins. +set(SPATIALCLUSTERING_INCLUDE_DIR ${CMAKE_CURRENT_BINARY_DIR}/include) +configure_file(wwise/SoundEnginePlugin/SpatialClusteringFXFactory.h + ${SPATIALCLUSTERING_INCLUDE_DIR}/AK/Plugin/SpatialClusteringFXFactory.h COPYONLY) + +# Runtime plug-in. It takes AK_OPTIMIZED from Wwise::Wwise, so it matches the sound engine +# libraries of the configuration. Games link Wwise themselves, so it is not part of the +# installed interface. +add_library(SpatialClusteringFX STATIC ${SPATIALCLUSTERING_RUNTIME_SOURCES}) +add_library(SpatialClustering::SpatialClusteringFX ALIAS SpatialClusteringFX) +target_include_directories(SpatialClusteringFX + PUBLIC + $ + $ + PRIVATE + core/src) +target_link_libraries(SpatialClusteringFX PRIVATE $) +target_compile_features(SpatialClusteringFX PRIVATE cxx_std_17) +target_compile_options(SpatialClusteringFX PRIVATE $<$:/utf-8>) + +if(SPATIALCLUSTERING_INSTALL) + install(TARGETS SpatialClusteringFX EXPORT SpatialClusteringTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} COMPONENT Runtime) + install(FILES ${SPATIALCLUSTERING_INCLUDE_DIR}/AK/Plugin/SpatialClusteringFXFactory.h + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/AK/Plugin COMPONENT Runtime) + + set(SPATIALCLUSTERING_CONFIG_DIR ${CMAKE_INSTALL_DATADIR}/SpatialClustering) + install(EXPORT SpatialClusteringTargets + NAMESPACE SpatialClustering:: + DESTINATION ${SPATIALCLUSTERING_CONFIG_DIR} + COMPONENT Runtime) + write_basic_package_version_file(${CMAKE_CURRENT_BINARY_DIR}/SpatialClusteringConfigVersion.cmake + COMPATIBILITY SameMajorVersion) + install(FILES + cmake/SpatialClusteringConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/SpatialClusteringConfigVersion.cmake + DESTINATION ${SPATIALCLUSTERING_CONFIG_DIR} + COMPONENT Runtime) +endif() + +# Runtime plug-in as a dynamic library. The sound engine loads it from next to the executable, +# or from AkInitSettings::szPluginDLLPath, when a SoundBank needs it and it is not linked in. +# Release builds it for the Release sound engine and RelWithDebInfo for the Profile one. +if(SPATIALCLUSTERING_BUILD_SHARED) + add_library(SpatialClusteringShared SHARED + wwise/SoundEnginePlugin/SpatialClusteringFXShared.cpp + wwise/SoundEnginePlugin/SpatialClusteringFXFactory.h) + set_target_properties(SpatialClusteringShared PROPERTIES OUTPUT_NAME SpatialClustering) + target_link_libraries(SpatialClusteringShared PRIVATE SpatialClusteringFX Wwise::Wwise) + target_compile_features(SpatialClusteringShared PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(SpatialClusteringFX PRIVATE /Z7) + target_compile_options(SpatialClusteringShared PRIVATE /utf-8 /Z7) + target_link_options(SpatialClusteringShared PRIVATE /DEBUG /INCREMENTAL:NO /OPT:REF /OPT:ICF) + endif() + + if(SPATIALCLUSTERING_INSTALL) + # The library only, without the import library that nothing links against. + if(WIN32) + set(SPATIALCLUSTERING_SHARED_DIR ${CMAKE_INSTALL_BINDIR}) + else() + set(SPATIALCLUSTERING_SHARED_DIR ${CMAKE_INSTALL_LIBDIR}) + endif() + install(FILES $ + DESTINATION ${SPATIALCLUSTERING_SHARED_DIR} + COMPONENT SharedRuntime) + if(MSVC) + install(FILES $ + DESTINATION ${SPATIALCLUSTERING_SHARED_DIR} + COMPONENT SharedRuntime + OPTIONAL) + endif() + endif() +endif() + +# Authoring plug-in. Install it into a Wwise install, or a copy of one, with +# cmake --install --config Release --component Authoring --prefix +if(SPATIALCLUSTERING_BUILD_AUTHORING) + if(NOT WIN32) + message(FATAL_ERROR "The authoring plug-in builds on Windows only.") + endif() + + # Wwise Authoring runs the Profile sound engine, so the runtime plug-in it embeds never + # gets AK_OPTIMIZED and only takes the SDK headers from Wwise::Wwise. + set(SPATIALCLUSTERING_WWISE_INCLUDE_DIRS $) + + add_library(SpatialClusteringFXAuthoring STATIC ${SPATIALCLUSTERING_RUNTIME_SOURCES}) + target_include_directories(SpatialClusteringFXAuthoring PRIVATE core/src ${SPATIALCLUSTERING_WWISE_INCLUDE_DIRS}) + target_compile_definitions(SpatialClusteringFXAuthoring PRIVATE WIN64 WIN32_LEAN_AND_MEAN AK_ENABLE_ASSERTS) + target_compile_features(SpatialClusteringFXAuthoring PRIVATE cxx_std_17) + target_compile_options(SpatialClusteringFXAuthoring PRIVATE /utf-8 /Zc:inline) + + # The GUI uses MFC through AK::Wwise::Plugin::PluginMFCWindows. The definitions are the + # ones from the Wwise SDK property sheets for authoring plug-ins. + set(CMAKE_MFC_FLAG 2) + add_library(SpatialClusteringAuthoring SHARED + wwise/WwisePlugin/SpatialClusteringPlugin.cpp + wwise/WwisePlugin/SpatialClusteringPlugin.h + wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.cpp + wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.h + wwise/WwisePlugin/resource.h + wwise/WwisePlugin/SpatialClustering.def + wwise/WwisePlugin/SpatialClustering.rc) + set_target_properties(SpatialClusteringAuthoring PROPERTIES OUTPUT_NAME SpatialClustering) + target_include_directories(SpatialClusteringAuthoring PRIVATE ${SPATIALCLUSTERING_WWISE_INCLUDE_DIRS}) + target_compile_definitions(SpatialClusteringAuthoring PRIVATE + WIN64 WIN32_LEAN_AND_MEAN AK_ENABLE_ASSERTS + _WINDOWS _USRDLL _WINDLL _AFXDLL UNICODE _UNICODE WWISE_AUTHORING AUDIOKINETIC + _CRT_STDIO_ARBITRARY_WIDE_SPECIFIERS _CRT_SECURE_NO_DEPRECATE _CRT_NON_CONFORMING_SWPRINTFS) + target_compile_features(SpatialClusteringAuthoring PRIVATE cxx_std_17) + target_compile_options(SpatialClusteringAuthoring PRIVATE /utf-8 /Zc:inline /Z7) + target_link_options(SpatialClusteringAuthoring PRIVATE /DEBUG /OPT:REF /OPT:ICF) + target_link_libraries(SpatialClusteringAuthoring PRIVATE SpatialClusteringFXAuthoring) + + if(SPATIALCLUSTERING_INSTALL) + # Same layout as a Wwise install: the plug-in and its XML next to the other authoring + # plug-ins, and the property help where the property editor looks for it. + set(SPATIALCLUSTERING_AUTHORING_PLUGIN_DIR Authoring/x64/Release/bin/Plugins) + install(FILES + $ + wwise/WwisePlugin/SpatialClustering.xml + DESTINATION ${SPATIALCLUSTERING_AUTHORING_PLUGIN_DIR} + COMPONENT Authoring) + install(FILES $ + DESTINATION ${SPATIALCLUSTERING_AUTHORING_PLUGIN_DIR} + COMPONENT Authoring + OPTIONAL) + + # wp.py build Documentation generates the help pages from wwise/WwisePlugin/res/Md. + set(SPATIALCLUSTERING_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/wwise/WwisePlugin/res/Html) + if(EXISTS ${SPATIALCLUSTERING_HELP_DIR}) + install(DIRECTORY ${SPATIALCLUSTERING_HELP_DIR}/ + DESTINATION Authoring/Data/Plugins/SpatialClustering/Html + COMPONENT Authoring) + else() + message(WARNING "wwise/WwisePlugin/res/Html does not exist, so the property help is not installed. Run wp.py build Documentation in the wwise folder to generate it.") + endif() + endif() +endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0a69cc1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing + +We welcome contributions: feature requests, bug fixes, documentation improvements, or new functionality. + +## Opening an issue + +- Search existing open and closed issues before creating a new one. +- For bug reports, use the bug report template. Include the plug-in and Wwise versions, the platform, steps to reproduce and any Capture Log messages from the plug-in. +- For feature requests, include the use case. + +## Submitting a pull request + +- Fork the repository and create a feature branch. +- Build and test your changes at runtime before submitting. +- Submit a PR with a clear description of what changed and why. + +Contributions are accepted under the [Apache License 2.0](LICENSE.txt), the license of this project. + +## Building and testing your changes + +[Building from source](docs/building.md) covers the toolchain, the plug-in build and ways to try the plug-in in a game. + +## Where things are + +| Path | Contents | +|---|---| +| `core/src/ClusterEngine.h/.cpp` | The clustering. Plain C++17 with no Wwise headers; all memory goes through a `ClusterAllocator`. | +| `wwise/` | The Wwise plug-in project. Run `wp.py` from this folder. | +| `wwise/SoundEnginePlugin/SpatialClusteringFX.h/.cpp` | The Wwise object processor: reads the input objects, runs the engine, creates and updates output objects and mixes the inputs into them with gain ramps. | +| `wwise/SoundEnginePlugin/SpatialClusteringFXParams.h/.cpp` | Parameters, read from banks and RTPCs. | +| `wwise/WwisePlugin/` | The authoring plug-in, the property definitions (`SpatialClustering.xml`) and the property help (`res/Md`). | +| `wwise/SpatialClusteringVersion.h` | The plug-in version. | +| `wwise/SpatialClusteringConfig.h` | The company and plug-in IDs. | +| `scripts/package.py` | Builds and packages a release. | +| `docs/` | User documentation. | + +When you add or change a parameter, update `SpatialClustering.xml`, `GetBankParameters` in `SpatialClusteringPlugin.cpp`, `SpatialClusteringFXParams`, the help page in `wwise/WwisePlugin/res/Md/en` and the table in `docs/parameters.md`. The order in which `GetBankParameters` writes the values must match the order in which `SpatialClusteringFXParams` reads them. diff --git a/NOTICE b/NOTICE index 6994614..3470f8a 100644 --- a/NOTICE +++ b/NOTICE @@ -1,9 +1,13 @@ -Spatial Audio Object Clustering Plugin for Wwise +Spatial Clustering for Wwise Copyright 2026 Fenris Creations -This product includes software developed for audio object clustering -in spatial audio applications, specifically designed for large-scale -space battle scenarios in EVE Online and EVE Frontier. +This product was developed by CCP Games for audio object clustering in +spatial audio applications, specifically designed for large-scale space +battle scenarios in EVE Online and EVE Frontier. -This software includes the following third-party components: -- Wwise SDK (Copyright Audiokinetic Inc.) \ No newline at end of file +This product is built with the Audiokinetic Wwise SDK, which is not +included in this product and is licensed separately by Audiokinetic Inc. + +Some files are derived from the plug-in templates in the Wwise SDK +(Copyright Audiokinetic Inc.). They are used under the Apache License, +Version 2.0, and keep their original copyright headers. diff --git a/README.md b/README.md index adfe526..605a64a 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,88 @@ -# Spatial Clustering Plugin for Wwise +# Spatial Clustering for Wwise [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE.txt) -[![Wwise SDK](https://img.shields.io/badge/Wwise%20SDK-2025.1%2B-green.svg)](https://www.audiokinetic.com/download/) +[![Wwise](https://img.shields.io/badge/Wwise-2025.1-green.svg)](https://www.audiokinetic.com/download/) -## Overview - -The Spatial Clustering plugin dynamically groups audio objects by proximity, reducing system audio object consumption in computationally demanding scenarios. - -Unlike traditional Effect Plug-Ins which are agnostic to Audio Objects, Object Processors have direct control over Audio Objects (AkAudioObject) passing through a bus, enabling the processing of their metadata and spatial characteristics. +Spatial Clustering is a Wwise plug-in that merges audio objects that reach the listener from similar directions into fewer output objects. This reduces system audio object consumption in computationally demanding scenarios. ## Features -- **Dynamic Object Clustering**: Automatically groups nearby spatial audio objects in real-time using a density-aware clustering algorithm -- **Resource Optimization**: Significantly reduces active audio object count while preserving spatial accuracy -- **Adaptive Cluster Management**: Automatically adjusts cluster count based on scene complexity and handles rapid object movement +- **Clustering by direction**: inputs are grouped by their angle from the listener, so the same settings work at any world scale. +- **Persistent clusters**: inputs stay in their cluster from frame to frame instead of being regrouped every frame. +- **Crossfaded moves**: an input that changes cluster fades from one to the other, so there are no clicks or jumps. +- **Output budget**: an optional limit on the number of outputs. Over the limit, the closest clusters merge. +- **Passthrough and exclusions**: ambiences, beds and 2D sounds pass through unchanged, and high-priority or nearby sounds can be kept out of clusters. +- **Real-time safe**: an update for 512 inputs takes about 20 to 60 µs. ## Perfect For -- Large-scale battle scenes with multiple sound sources -- Particle system audio (rain, debris, ambient effects) -- Crowd simulations -- Dense environmental sound design -- Vehicle/machinery sounds with multiple components +- Large-scale battle scenes with many weapon, impact and explosion one-shots +- Debris and particle-driven one-shots +- Crowds of sources such as footsteps or voices +- Vehicle and machinery sounds with multiple components ## Why We Built This -Most spatial audio endpoints (Dolby, Sonic, DTS) offer up to 128 concurrent spatial audio objects. In EVE Online and Frontier's massive space battles, these resources get depleted rapidly, as each spaceship generates multiple audio objects for its engines, turrets, missile launchers, and combat effects. +Most spatial audio endpoints, such as Dolby Atmos, Windows Sonic and DTS, offer up to 128 concurrent spatial audio objects. In the massive space battles of EVE Online and EVE Frontier, these run out fast, because each ship has audio objects for its engines, turrets, missile launchers and combat effects. -We developed this plugin to mix nearby sources into a single output positioned at their centroid. Human spatial resolution is limited at small angular separations, so a listener can't really tell several close sources apart from one source at the average position. The freed audio object slots then become available for sounds elsewhere in the scene. +The plug-in mixes sources that come from nearly the same direction into one output at their average direction. A listener can't tell several close sources apart from one source at their average position, and the freed objects become available for sounds elsewhere in the scene. -### Results -The plugin ships as part of the EVE Online and EVE Frontier audio engine. In intense battles we measured up to 60% reduction in audio thread CPU usage. +The plug-in ships in the EVE Online and EVE Frontier audio engine. In intense battles we measured up to 60% less audio thread CPU usage. In an EVE Frontier test with 10 ships firing at the player, the scene used all 128 system audio objects without the plug-in, and 10 to 11 clusters with the default settings. ## In-Game Impact -### Without Plugin +### Without the Plug-in ![Before Clustering](https://github.com/user-attachments/assets/7782ff8f-1e5e-4326-8b58-f78d59cbdbd0) Scene with 20 ships firing at the player: -- System audio object consumption is constantly being exceeded -- Text in each unique location is obscured since quite a lot of objects are stacked upon each other -- No control over which objects are spatialized and which are redirected into the main mix. +- System audio object consumption is constantly exceeded. +- Objects are stacked on top of each other, so their labels overlap. +- No control over which objects are spatialized and which fall back into the main mix. -### With Plugin +### With the Plug-in ![After Clustering](https://github.com/user-attachments/assets/70c196bf-144b-405e-af78-914b2f76d4c8) -Same scene with plugin enabled: -- System audio object consumption is drastically reduced (20-24 objects) -- Clear, distinct spatial positioning for each cluster -- More resources for sound designers to add unique locations of spatialized objects - -## Best Practices - -### Bus Architecture -- Place the plugin strategically in busses that need clustering (e.g. turrets, missiles, engines) -- Use tighter clustering for dense sources (turret fire) vs spread-out sounds (shields) - -![Wwise_wZagotM3pz](https://github.com/user-attachments/assets/88c217c1-87c9-4132-aba8-04eede2d46e1) - -### Profiling -- In Audio Devices -> System monitor the system audio object consumption in real-time -- From the Wwise Authoring tool use Views->Profiler->Audio Object 3D Viewer profiler for a visual spatial representation of the clusters -- Go to Views->Profiler->Audio Object List and select different busses to change the focus of the Audio Object 3D Viewer - -![Wwise_TWJ1fyWlc5](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) - -### Debugging -To place breakpoints and debug the plugin follow the steps [here](https://www.audiokinetic.com/qa/7840/wwise-sdk-how-step-through-code-using-visual-studio-debugger) +Same scene with the plug-in enabled: +- System audio object consumption drops to 20 to 24 objects. +- Each cluster has a clear, distinct position. +- Without the plug-in, objects over the endpoint's budget fall back into the bed and lose their exact position. With it, every cluster is an audio object at its own position. +- Sound designers get objects back for other spatialized sounds. +## Install -## Implementation +1. From [Releases](https://github.com/carbonengine/spatial-audio-clustering/releases), download `SpatialClustering__Wwise..zip` for your Wwise version. The plug-in only works with the Wwise version it was built for. +2. Extract the zip. +3. In the Wwise Launcher, open **Plug-ins**, choose **Add from directory** and select the `bundle` folder inside the extracted `SpatialClustering__Wwise.` folder. Then install Spatial Clustering into your Wwise install. -### Object Management -- Objects within a defined distance threshold are grouped into clusters -- Each cluster is represented by a single spatial output audio object positioned at the cluster's centroid -- Each input object's buffer is mixed into a single output buffer using Wwise's `MixNinNChannels()` API -- Objects that are too far from any cluster or don't have spatialization remain independent +Upgrading from 2.0.0 or earlier? Remove the old plug-in first, see [Upgrading](docs/integration.md#upgrading-from-200-or-earlier). No release for your Wwise version? [Build it from source](docs/building.md). -### Dynamic Clustering -- Number of clusters is automatically determined based on number of input objects per frame -- Clusters are created and destroyed dynamically as objects move -- Cluster merging ensures clusters from previous frames merge with current frames if within radius -- Smooth transitions prevent audio artifacts when objects change clusters -- Interpolation for rapid position changes of input objects in clusters +## Quick Start -### Clustering Algorithm +1. Set the channel configuration of a bus to **Audio Objects**. +2. In the bus's **Effects** tab, add the **Spatial Clustering** effect. -For the purpose of this project we developed a density-aware spatial clustering algorithm for real-time 3D audio. Given a set of audio object positions and a distance threshold, it groups nearby objects into clusters each frame and represents each cluster as a single output object at the group's centroid. + ![Spatial Clustering inserted on an Audio Objects bus in Wwise](https://github.com/user-attachments/assets/88c217c1-87c9-4132-aba8-04eede2d46e1) -1. **Initialization**: - Initial cluster centers are picked based on local object density using Gaussian weighting, biased toward the listener position. Additional centers are placed using K-means++ and selection stops early if all remaining objects are already within range of an existing center. +3. Route the sounds you want merged to that bus. Mono and stereo sounds with 3D spatialization and Speaker Panning / 3D Spatialization Mix at 100 are clustered. Everything else passes through. +4. Start with the default settings. Watch the clusters in the Audio Object 3D Viewer, then tune the [parameters](docs/parameters.md). Most scenes only need **Cluster Width**, plus **Object Limit** for a fixed object budget. -2. **Assignment**: - Each object is assigned to its nearest cluster center if it falls within the distance threshold. Objects too far from any center are grouped with other nearby distant objects to form new clusters. + ![Clusters in the Wwise Audio Object profiler views](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) -3. **Update Step**: - Cluster centers are recalculated as the average position of their members. Empty clusters are removed. The algorithm iterates until assignments stabilize or SSE improvement becomes negligible. +## Documentation -## Getting Started +- [How it works](docs/how-it-works.md): which inputs are clustered, how clusters form, move and merge, and performance. +- [Parameters](docs/parameters.md): ranges, defaults and what each one does. +- [Best practices](docs/best-practices.md): bus setup and profiling. +- [Use it in your game](docs/integration.md): linking the runtime plug-in and upgrading from 2.0.0. +- [Building from source](docs/building.md): toolchain, build, CMake, packaging and debugging. -See Audiokinetic's [Creating Audio Plug-ins](https://www.audiokinetic.com/en/public-library/2025.1.7_9143/?source=SDK&id=effectplugin_tools_newplugin.html) for the full toolchain setup (SDK install, environment variables, compiler requirements). +## Compatibility -### Prerequisites +| Version | Wwise | Authoring | Runtime | +|---|---|---|---| +| 3.0.0 | 2025.1 | Windows x64 | Windows x64, Visual Studio 2026 | -- A C++ toolchain supported by Wwise's plugin build system -- Wwise 2025.1+ (it will work for previous versions although you might need to adapt some of the code) -- Python 3 - -### Building from Source - -1. Generate solutions: - -``` -py -3 %WWISEROOT%\Scripts\Build\Plugins\wp.py premake Authoring_Windows -``` - -2. Build the required solutions: - - `ObjectCluster_Windows_vc170_static.sln` (Static library) - - `ObjectCluster_Windows_vc170_shared.sln` (Runtime DLL) - - `ObjectCluster_Authoring_Windows_vc170.sln` (Authoring DLL) +Other platforms are not released yet. They can be built from source with the matching Wwise deployment platform. ## Contributing @@ -136,17 +100,7 @@ Contributions are welcome. Please read the Carbon Engine [contributing guide](ht ### Building and Testing Your Changes -Wwise offers a free personal license that includes the C++ SDK and CLI tools needed to build this plugin. To get started, see [Creating Audio Plug-ins](https://www.audiokinetic.com/en/public-library/2025.1.7_9143/?source=SDK&id=effectplugin_tools_newplugin.html) in the Wwise docs. - -Once the plugin builds, you'll want a scene to exercise it in. A few options: -- **Unreal Engine 5 or Unity** with the Wwise integration. -- **[Wwise Integration Demo Sample](https://www.audiokinetic.com/en/public-library/2025.1.7_9143/?source=SDK&id=soundengine_integration_samplecode.html)**: a lightweight executable with various demos that you can build and modify yourself. -- **[Wwise sample games](https://www.audiokinetic.com/en/learning/samples/?searchToken=eyJwZ0Nfc2FtIjoiMSIsInBnU19zYW0iOiIxMDAiLCJzYW1fc3QiOiIifQ%3D%3D)** provided by Audiokinetic. - -We don't yet ship a dedicated sample scene for spatial audio testing. That's on the list to add. - - -## License and Legal Notices +## License © 2026 Fenris Creations diff --git a/SoundEnginePlugin/KMeansTest.h b/SoundEnginePlugin/KMeansTest.h deleted file mode 100644 index 25ea246..0000000 --- a/SoundEnginePlugin/KMeansTest.h +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Example K-means clustering implementation for educational/testing purposes. - * This is a simplified version to demonstrate the concepts used in - * the production implementation of the Object Cluster Plugin. - */ - -#include -#include -#include -#include -#include -#include - -struct Point { - double x, y, z; -}; - -class KMeans { -private: - std::vector centroids; - unsigned int seed; - double tolerance; - std::vector labels; // Stores the label for each point - std::vector> clusters; // store points in each cluster - -public: - KMeans(unsigned int n_clusters, double tolerance = 0.001) - : centroids(n_clusters), tolerance(tolerance) { - std::random_device rd; - seed = rd(); - } - - void initializeCentroids(const std::vector& points) { - std::default_random_engine generator(seed); - std::uniform_int_distribution distribution(0, points.size() - 1); - - for (auto& centroid : centroids) { - centroid = points[distribution(generator)]; - } - } - - double calculateDistance(const Point& a, const Point& b) { - return std::sqrt(std::pow(a.x - b.x, 2) + std::pow(a.y - b.y, 2) + std::pow(a.z - b.z, 2)); - } - - bool assignPointsToClusters(const std::vector& points, std::vector& labels) { - bool changed = false; - clusters.clear(); // Clear previous cluster assignments - clusters.resize(centroids.size()); // Prepare clusters for new assignments - - for (size_t i = 0; i < points.size(); ++i) { - double min_distance = std::numeric_limits::max(); - int closest_centroid = -1; - for (size_t j = 0; j < centroids.size(); ++j) { - double distance = calculateDistance(points[i], centroids[j]); - if (distance < min_distance) { - min_distance = distance; - closest_centroid = j; - } - } - if (labels[i] != closest_centroid) { - labels[i] = closest_centroid; - changed = true; - } - clusters[closest_centroid].push_back(points[i]); // Assign point to its cluster - } - return changed; - } - - bool updateCentroids(const std::vector& points, const std::vector& labels) { - std::vector new_centroids(centroids.size(), { 0, 0, 0 }); - std::vector counts(centroids.size(), 0); - for (size_t i = 0; i < points.size(); ++i) { - new_centroids[labels[i]].x += points[i].x; - new_centroids[labels[i]].y += points[i].y; - new_centroids[labels[i]].z += points[i].z; - counts[labels[i]]++; - } - - bool changed = false; - for (size_t i = 0; i < centroids.size(); ++i) { - if (counts[i] > 0) { - Point new_centroid = { new_centroids[i].x / counts[i], new_centroids[i].y / counts[i], new_centroids[i].z / counts[i] }; - if (calculateDistance(centroids[i], new_centroid) > tolerance) { - centroids[i] = new_centroid; - changed = true; - } - } - } - return changed; - } - - void fit(const std::vector& points, unsigned int max_iterations = 100) { - labels.resize(points.size(), -1); - initializeCentroids(points); - - for (unsigned int i = 0; i < max_iterations; ++i) { - bool changed = assignPointsToClusters(points, labels); - if (!changed || !updateCentroids(points, labels)) { - break; // Convergence achieved - } - } - } - - const std::vector& getLabels() const { - return labels; - } - - const std::vector>& getClusters() const { - return clusters; - } -}; - - -int main() { - - std::vector sample_in_0 = { - {-11.4860, 5.5058, -9.0644}, - {-11.1426, 4.8875, -8.1357}, - { 10.9231, 0.3666, -3.5779}, - {-12.9427, 8.3412, -5.9818}, - {-10.0903, 5.3482, -8.7261}, - {-11.5049, 6.7360, -7.7034}, - { 5.7238, -14.0290, 4.2147}, - { 5.2085, -15.4755, 3.4246}, - { 11.2001, 0.9678, -1.0240}, - { 11.3687, -0.6454, -3.8518}, - { 11.0813, 0.1786, -3.1050}, - { 6.5185, -13.3054, 4.1873}, - { 11.4567, 0.0249, -3.0829}, - { 7.1574, -14.5416, 3.9704}, - { 8.4637, -13.5819, 2.3469}, - }; - - std::vector sample_in_1 = { - { -3.3311, -17.2487, 0.6263}, - { -2.5003, -19.0799, -1.0433}, - { -2.5524, -20.3855, -1.0302}, - { 12.2500, -0.1451, -0.7969}, - { 12.8404, -0.2285, 1.5140}, - {-25.5839, 0.3063, 6.1142}, - {-24.7935, 0.5205, 5.9570}, - { -3.0146, -18.5141, -1.0926}, - { 10.1120, -1.2544, -0.1439}, - {-23.4916, -0.8105, 6.6513}, - { -5.4538, -20.6453, 0.1573}, - { 10.0714, -1.0966, -1.0421}, - {-24.5679, 2.1275, 5.7701}, - { 11.2001, -1.5779, 1.0244}, - {-24.4899, -0.2897, 7.3753}, - }; - - // Initialize - KMeans kmeans_0(6); - KMeans kmeans_1(6); - - // Fit test vectors - kmeans_0.fit(sample_in_0); - kmeans_1.fit(sample_in_1); - - auto printClusters = [](const KMeans& kmeans) { - const auto& clusters = kmeans.getClusters(); - for (size_t i = 0; i < clusters.size(); ++i) { - std::cout << "Cluster " << i << ":\n"; - for (const auto& point : clusters[i]) { - std::cout << " (" << point.x << ", " << point.y << ", " << point.z << ")\n"; - } - } - }; - - std::cout << "Clusters for sample_in_0:\n"; - printClusters(kmeans_0); - - std::cout << "\nClusters for sample_in_1:\n"; - printClusters(kmeans_1); -} - diff --git a/SoundEnginePlugin/ObjectClusterFX.cpp b/SoundEnginePlugin/ObjectClusterFX.cpp deleted file mode 100644 index 535a555..0000000 --- a/SoundEnginePlugin/ObjectClusterFX.cpp +++ /dev/null @@ -1,582 +0,0 @@ -/******************************************************************************* -The content of this file includes portions of the AUDIOKINETIC Wwise Technology -released in source code form as part of the SDK installer package. - -Commercial License Usage - -Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology -may use this file in accordance with the end user license agreement provided -with the software or, alternatively, in accordance with the terms contained in a -written agreement between you and Audiokinetic Inc. - -Apache License Usage - -Alternatively, this file may be used under the Apache License, Version 2.0 (the -"Apache License"); you may not use this file except in compliance with the -Apache License. You may obtain a copy of the Apache License at -http://www.apache.org/licenses/LICENSE-2.0. - -Unless required by applicable law or agreed to in writing, software distributed -under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES -OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for -the specific language governing permissions and limitations under the License. - - Copyright (c) 2023 Audiokinetic Inc. - Copyright (c) 2024 CCP ehf. - -This implementation was developed by CCP Games for spatial audio object clustering -in EVE Online and EVE Frontier. This implementation does not grant any rights to -CCP's trademarks or game content. EVE Online and EVE Frontier are registered -trademarks of CCP ehf. -*******************************************************************************/ - -#include "ObjectClusterFX.h" -#include "../ObjectClusterConfig.h" -#include - -AK::IAkPlugin* CreateObjectClusterFX(AK::IAkPluginMemAlloc* in_pAllocator) -{ - return AK_PLUGIN_NEW(in_pAllocator, ObjectClusterFX()); -} - -AK::IAkPluginParam* CreateObjectClusterFXParams(AK::IAkPluginMemAlloc* in_pAllocator) -{ - return AK_PLUGIN_NEW(in_pAllocator, ObjectClusterFXParams()); -} - -AK_IMPLEMENT_PLUGIN_FACTORY(ObjectClusterFX, AkPluginTypeEffect, ObjectClusterConfig::CompanyID, ObjectClusterConfig::PluginID) - - -ObjectClusterFX::ObjectClusterFX() - : m_pParams(nullptr) - , m_pAllocator(nullptr) - , m_pContext(nullptr) - , m_clusterer(std::make_unique()) - , m_utilities(std::make_unique()) -{ -} - -ObjectClusterFX::~ObjectClusterFX() -{ - FreeAllVolumes(); -} - -AKRESULT ObjectClusterFX::Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffectPluginContext* in_pContext, AK::IAkPluginParam* in_pParams, AkAudioFormat& in_rFormat) -{ - m_pParams = (ObjectClusterFXParams*)in_pParams; - m_mapInObjsToOutObjs.Init(in_pAllocator); - m_pContext = in_pContext; - m_pAllocator = in_pAllocator; - - in_rFormat.channelConfig.SetObject(); - - // Set min-max values for the distance threshold - // These are specific to eve & frontier ships, adjust accordingly - m_clusterer->setMinDistanceThreshold(1.f); - m_clusterer->setMaxDistanceThreshold(1000.f); - - return AK_Success; -} - -AKRESULT ObjectClusterFX::Term(AK::IAkPluginMemAlloc* in_pAllocator) -{ - FreeAllVolumes(); - - AK_PLUGIN_DELETE(in_pAllocator, this); - return AK_Success; -} - -AKRESULT ObjectClusterFX::Reset() -{ - FreeAllVolumes(); - m_clusters.clear(); - m_tempBuffers.clear(); - m_tempObjects.clear(); - - return AK_Success; -} - -AKRESULT ObjectClusterFX::GetPluginInfo(AkPluginInfo& out_rPluginInfo) -{ - out_rPluginInfo.eType = AkPluginTypeEffect; - out_rPluginInfo.bIsInPlace = false; - out_rPluginInfo.bCanProcessObjects = true; - out_rPluginInfo.uBuildVersion = AK_WWISESDK_VERSION_COMBINED; - - return AK_Success; -} - -void ObjectClusterFX::Execute( - const AkAudioObjects& inObjects, - const AkAudioObjects& outObjects -) { - AKASSERT(inObjects.uNumObjects > 0); - - PrepareAudioObjects(inObjects); - ProcessAudioObjects(inObjects); - UpdateClusterPositions(inObjects); -} - -void ObjectClusterFX::PrepareAudioObjects(const AkAudioObjects& inObjects) -{ - FeedPositionsToClusterer(inObjects); - std::unordered_map>*, AkAudioObjectID> clusterOutputObjects; - - // Get current outputs at start - AkAudioObjects existingOutputs = GetCurrentOutputObjects(); - - // Update existing objects and map clusters to existing outputs - for (AkUInt32 i = 0; i < inObjects.uNumObjects; ++i) { - AkAudioObject* inobj = inObjects.ppObjects[i]; - AkAudioObjectID key = inobj->key; - GeneratedObject* pEntry = m_mapInObjsToOutObjs.Exists(key); - if (pEntry) { - pEntry->index = i; - } - } - - // For each cluster, try to find if any of its objects are using an existing output - if (existingOutputs.uNumObjects > 0) { - for (const auto& cluster : m_clusters) { - for (const auto& objId : cluster.second) { - GeneratedObject* pEntry = m_mapInObjsToOutObjs.Exists(objId); - if (pEntry && pEntry->isClustered) { - // Verify this output still exists - for (AkUInt32 i = 0; i < existingOutputs.uNumObjects; i++) { - if (existingOutputs.ppObjects[i] && - existingOutputs.ppObjects[i]->key == pEntry->outputObjKey) { - clusterOutputObjects[&cluster] = pEntry->outputObjKey; - break; - } - } - if (clusterOutputObjects.find(&cluster) != clusterOutputObjects.end()) { - break; // Found valid output for this cluster - } - } - } - } - } - - // Handle new objects - for (AkUInt32 i = 0; i < inObjects.uNumObjects; ++i) { - AkAudioObject* inobj = inObjects.ppObjects[i]; - AkAudioObjectID key = inobj->key; - - GeneratedObject* pEntry = m_mapInObjsToOutObjs.Exists(key); - if (!pEntry) { // Only handle new objects - pEntry = m_mapInObjsToOutObjs.AddInput(key); - if (!pEntry) continue; - - bool isPositionedObject = (inobj->positioning.behavioral.spatMode == AK_SpatializationMode_PositionOnly || - inobj->positioning.behavioral.spatMode == AK_SpatializationMode_PositionAndOrientation); - - if (isPositionedObject) { - // Find which cluster this object belongs to - const std::pair>* assignedCluster = nullptr; - for (const auto& cluster : m_clusters) { - auto it = std::find(cluster.second.begin(), cluster.second.end(), key); - if (it != cluster.second.end()) { - assignedCluster = &cluster; - break; - } - } - - if (assignedCluster) { - // Check if we have an existing output for this cluster - auto outputObjIt = clusterOutputObjects.find(assignedCluster); - if (outputObjIt != clusterOutputObjects.end()) { - // Use existing cluster output - pEntry->outputObjKey = outputObjIt->second; - pEntry->isClustered = true; - } - else { - // Create new output for this cluster - pEntry->outputObjKey = m_utilities->CreateOutputObject(inobj, inObjects, i, m_pContext, &assignedCluster->first); - clusterOutputObjects[assignedCluster] = pEntry->outputObjKey; - pEntry->isClustered = true; - } - } - else { - // Try to find nearest existing cluster - AkAudioObjectID bestClusterKey; - if (FindBestCluster(inobj->positioning.threeD.xform.Position(), existingOutputs, bestClusterKey) == AK_Success) { - pEntry->outputObjKey = bestClusterKey; - pEntry->isClustered = true; - } - else { - // Only mark as unclustered if we really can't find a suitable cluster - pEntry->outputObjKey = m_utilities->CreateOutputObject(inobj, inObjects, i, m_pContext, nullptr); - pEntry->isClustered = false; - } - } - } - else { - // Not a spatial object - create unclustered output - pEntry->outputObjKey = m_utilities->CreateOutputObject(inobj, inObjects, i, m_pContext, nullptr); - pEntry->isClustered = false; - } - pEntry->index = i; - } - } - - m_tempBuffers.clear(); - m_tempObjects.clear(); -} - -void ObjectClusterFX::ProcessAudioObjects(const AkAudioObjects& inObjects) -{ - if (inObjects.uNumObjects == 0) { - return; - } - - AkAudioObjects outputObjects = GetCurrentOutputObjects(); - - if (outputObjects.uNumObjects > 0) { - auto clusterStates = ReadClusterStates(inObjects); - m_utilities->ClearBuffers(outputObjects); - - auto it = m_mapInObjsToOutObjs.Begin(); - while (it != m_mapInObjsToOutObjs.End()) { - if ((*it).pUserData && (*it).pUserData->index >= 0) { - AkAudioObject* inObj = inObjects.ppObjects[(*it).pUserData->index]; - AkAudioBuffer* inBuf = inObjects.ppObjectBuffers[(*it).pUserData->index]; - - // Find matching output object - AkAudioObject* outObj = nullptr; - AkAudioBuffer* outBuf = nullptr; - - for (AkUInt32 i = 0; i < outputObjects.uNumObjects; i++) { - if (outputObjects.ppObjects[i] && - (*it).pUserData->outputObjKey != AK_INVALID_AUDIO_OBJECT_ID && - outputObjects.ppObjects[i]->key == (*it).pUserData->outputObjKey) { - outObj = outputObjects.ppObjects[i]; - outBuf = outputObjects.ppObjectBuffers[i]; - break; - } - } - - // Process if output was found - if (outObj && outBuf) { - if ((*it).pUserData->isClustered) { - ProcessClusteredObject( - inObj, - inBuf, - outObj, - outBuf, - (*it).pUserData, - clusterStates[(*it).pUserData->outputObjKey]); - } - else { - ProcessUnclustered( - inObj, - inBuf, - outObj, - outBuf); - } - } - - (*it).pUserData->index = -1; - ++it; - } - else { - - if ((*it).pUserData && (*it).pUserData->volumeMatrix) { - FreeVolume((*it).pUserData->volumeMatrix); - } - it = m_mapInObjsToOutObjs.EraseSwap(it); - } - } - } - - m_tempBuffers.clear(); - m_tempObjects.clear(); -} - -void ObjectClusterFX::ProcessClusteredObject( - const AkAudioObject* inObj, - AkAudioBuffer* inBuf, - AkAudioObject* outObj, - AkAudioBuffer* outBuf, - GeneratedObject* userData, - const ClusterState& clusterState) -{ - - if (inBuf->uValidFrames > 0) { - MixToCluster(inObj, inBuf, outBuf, inObj->cumulativeGain, userData); - } - - // Set clustered object's name - std::string objName = "Cluster" + std::to_string(userData->outputObjKey); - outObj->SetName(m_pAllocator, objName.c_str()); - - // Update buffer state - bool allInputsDone = (clusterState.activeInputCount == 0); - bool hasValidFrames = (clusterState.maxFrames > 0); - - outBuf->eState = (allInputsDone && !hasValidFrames) ? AK_NoMoreData : AK_DataReady; - outBuf->uValidFrames = hasValidFrames ? clusterState.maxFrames : 0; -} - -void ObjectClusterFX::ProcessUnclustered( - const AkAudioObject* inObj, - AkAudioBuffer* inBuf, - AkAudioObject* outObj, - AkAudioBuffer* outBuf) { - - m_utilities->CopyBuffer(inBuf, outBuf); - outObj->positioning.threeD.xform.SetPosition(inObj->positioning.threeD.xform.Position()); - outObj->arCustomMetadata.Copy(inObj->arCustomMetadata); - outBuf->eState = inBuf->eState; - outBuf->uValidFrames = inBuf->uValidFrames; - outObj->SetName(m_pAllocator, "Not clustered"); -} - -void ObjectClusterFX::MixToCluster(const AkAudioObject* inObject, AkAudioBuffer* inBuffer, AkAudioBuffer* outBuffer, const AkRamp& cumulativeGain, GeneratedObject* pGeneratedObject) -{ - if (inBuffer->uValidFrames == 0 || inBuffer->NumChannels() == 0 || outBuffer->NumChannels() == 0) { - return; - } - - AkUInt32 uTransmixSize = AK::SpeakerVolumes::Matrix::GetRequiredSize(inBuffer->NumChannels(), outBuffer->NumChannels()); - - AK::SpeakerVolumes::MatrixPtr currentVolumes = (AK::SpeakerVolumes::MatrixPtr)AkAllocaSIMD(uTransmixSize); - AK::SpeakerVolumes::Matrix::Zero(currentVolumes, inBuffer->NumChannels(), outBuffer->NumChannels()); - - AKRESULT result = m_pContext->GetMixerCtx()->ComputePositioning( - inObject->positioning, - inBuffer->GetChannelConfig(), - outBuffer->GetChannelConfig(), - currentVolumes - ); - - // If mixVolumes doesn't exist, allocate and set it to the current volumes - if (pGeneratedObject->volumeMatrix == nullptr) { - AKRESULT eResult = AllocateVolumes(pGeneratedObject->volumeMatrix, inBuffer->NumChannels(), outBuffer->NumChannels()); - if (eResult == AK_Success) { - AKPLATFORM::AkMemCpy(pGeneratedObject->volumeMatrix, currentVolumes, uTransmixSize); - } - } - - AK_GET_PLUGIN_SERVICE_MIXER(m_pContext->GlobalContext())->MixNinNChannels( - inBuffer, - outBuffer, - cumulativeGain.fPrev, - cumulativeGain.fNext, - pGeneratedObject->volumeMatrix, - currentVolumes - ); - - AKPLATFORM::AkMemCpy(pGeneratedObject->volumeMatrix, currentVolumes, uTransmixSize); -} - -void ObjectClusterFX::FeedPositionsToClusterer(const AkAudioObjects& inObjects) -{ - - if (m_lastDistanceThreshold != m_pParams->RTPC.distanceThreshold) { - m_clusterer->setDistanceThreshold(m_pParams->RTPC.distanceThreshold); - m_lastDistanceThreshold = m_pParams->RTPC.distanceThreshold; - } - - std::vector objectPositions; - objectPositions.reserve(inObjects.uNumObjects); - - for (AkUInt32 i = 0; i < inObjects.uNumObjects; ++i) { - AkAudioObject* inobj = inObjects.ppObjects[i]; - - // Check if this is either position-only or position+orientation - bool shouldCluster = (inobj->positioning.behavioral.spatMode == AK_SpatializationMode_PositionOnly || - inobj->positioning.behavioral.spatMode == AK_SpatializationMode_PositionAndOrientation); - - if (shouldCluster) { - objectPositions.push_back({ inobj->positioning.threeD.xform.Position(), inobj->key }); - } - } - // Perform clustering only if there are objects - m_clusters.clear(); - if (!objectPositions.empty()) { - m_clusterer->performClustering(objectPositions); - - auto tempClusters = m_clusterer->getClusters(); - m_clusters.reserve(tempClusters.size()); - - for (const auto& pair : tempClusters) { - m_clusters.emplace_back(pair.first, pair.second); - } - } -} - -AKRESULT ObjectClusterFX::AllocateVolumes(AK::SpeakerVolumes::MatrixPtr& volumeMatrix, - AkUInt32 in_uNumChannelsIn, - AkUInt32 in_uNumChannelsOut) -{ - AkUInt32 size = AK::SpeakerVolumes::Matrix::GetRequiredSize(in_uNumChannelsIn, in_uNumChannelsOut); - float* matrix = static_cast(m_pAllocator->Malloc(size, __FILE__, __LINE__)); - - if (!matrix) { - return AK_InsufficientMemory; - } - - volumeMatrix = matrix; - return AK_Success; -} - -const std::pair>* ObjectClusterFX::GetCluster(AkAudioObjectID objectId) const -{ - for (const auto& cluster : m_clusters) { - for (const auto& id : cluster.second) { - if (id == objectId) { - return &cluster; - } - } - } - return nullptr; -} - -void ObjectClusterFX::FreeVolume(AK::SpeakerVolumes::MatrixPtr& volumeMatrix) -{ - if (volumeMatrix) { - m_pAllocator->Free(volumeMatrix); - volumeMatrix = nullptr; - } -} - -void ObjectClusterFX::FreeAllVolumes() -{ - auto it = m_mapInObjsToOutObjs.Begin(); - while (it != m_mapInObjsToOutObjs.End()) { - if ((*it).pUserData && (*it).pUserData->volumeMatrix) { - FreeVolume((*it).pUserData->volumeMatrix); - } - ++it; - } -} - -void ObjectClusterFX::UpdateClusterPositions(const AkAudioObjects& inObjects) -{ - AkAudioObjects outputObjects = GetCurrentOutputObjects(); - if (outputObjects.uNumObjects == 0) return; - - // Track which clusters we've already processed to avoid duplicates - std::set processedClusters; - - auto it = m_mapInObjsToOutObjs.Begin(); - while (it != m_mapInObjsToOutObjs.End()) { - if ((*it).pUserData && (*it).pUserData->isClustered) { - AkAudioObjectID clusterKey = (*it).pUserData->outputObjKey; - - if (processedClusters.find(clusterKey) == processedClusters.end()) { - // Find the corresponding cluster - const std::pair>* cluster = GetCluster((*it).key); - if (cluster) { - AkVector meanPosition = m_utilities->CalculateMeanPosition(cluster->second, inObjects); - - // Find and update the output object for this cluster - for (AkUInt32 i = 0; i < outputObjects.uNumObjects; i++) { - if (outputObjects.ppObjects[i] && - outputObjects.ppObjects[i]->key == clusterKey) { - outputObjects.ppObjects[i]->positioning.threeD.xform.SetPosition(meanPosition); - } - } - processedClusters.insert(clusterKey); - } - } - } - ++it; - } -} - -AKRESULT ObjectClusterFX::FindBestCluster(const AkVector& position, const AkAudioObjects& existingOutputs, AkAudioObjectID& outClusterKey) -{ - const float thresholdSquared = m_pParams->RTPC.distanceThreshold * m_pParams->RTPC.distanceThreshold; - float closestDistanceSquared = thresholdSquared; - outClusterKey = AK_INVALID_AUDIO_OBJECT_ID; - - for (AkUInt32 i = 0; i < existingOutputs.uNumObjects; ++i) - { - const AkAudioObject* outObj = existingOutputs.ppObjects[i]; - if (!outObj) continue; - - const AkVector outObjPos = outObj->positioning.threeD.xform.Position(); - - // Check all possible matches for this output object - auto existingEntry = m_mapInObjsToOutObjs.Begin(); - while (existingEntry != m_mapInObjsToOutObjs.End()) - { - const GeneratedObject* userData = (*existingEntry).pUserData; - if (userData && - userData->isClustered && - userData->outputObjKey == outObj->key) // Found matching cluster - { - const float distanceSquared = m_utilities->GetDistanceSquared(outObjPos, position); - if (distanceSquared < closestDistanceSquared) { - closestDistanceSquared = distanceSquared; - outClusterKey = outObj->key; - } - } - ++existingEntry; - } - } - - return (outClusterKey != AK_INVALID_AUDIO_OBJECT_ID) ? AK_Success : AK_Fail; -} - -std::unordered_map ObjectClusterFX::ReadClusterStates(const AkAudioObjects& inObjects) -{ - std::unordered_map clusterStates; - - for (AkUInt32 i = 0; i < inObjects.uNumObjects; ++i) { - AkAudioObject* inObj = inObjects.ppObjects[i]; - AkAudioBuffer* inBuf = inObjects.ppObjectBuffers[i]; - - auto it = m_mapInObjsToOutObjs.Exists(inObj->key); - if (it && it->isClustered) { - AkAudioObjectID clusterID = it->outputObjKey; - auto& state = clusterStates[clusterID]; - - if (inBuf->eState != AK_NoMoreData) { - state.activeInputCount++; - } - if (inBuf->uValidFrames > 0) { - state.maxFrames = std::max(state.maxFrames, inBuf->uValidFrames); - } - } - } - - return clusterStates; -} - -AkAudioObjects ObjectClusterFX::GetCurrentOutputObjects() -{ - AkAudioObjects outputObjects{}; - outputObjects.uNumObjects = 0; - outputObjects.ppObjectBuffers = nullptr; - outputObjects.ppObjects = nullptr; - - if (!m_pContext) { - return outputObjects; - } - - // First call to get count - m_pContext->GetOutputObjects(outputObjects); - - if (outputObjects.uNumObjects > 0) { - try { - m_tempBuffers.resize(outputObjects.uNumObjects); - m_tempObjects.resize(outputObjects.uNumObjects); - - outputObjects.ppObjectBuffers = m_tempBuffers.data(); - outputObjects.ppObjects = m_tempObjects.data(); - - // Second call to get actual data - m_pContext->GetOutputObjects(outputObjects); - } - catch (const std::bad_alloc&) { - // Reset on allocation failure - outputObjects.uNumObjects = 0; - outputObjects.ppObjectBuffers = nullptr; - outputObjects.ppObjects = nullptr; - } - } - - return outputObjects; -} diff --git a/SoundEnginePlugin/ObjectClusterFX.h b/SoundEnginePlugin/ObjectClusterFX.h deleted file mode 100644 index ae28d94..0000000 --- a/SoundEnginePlugin/ObjectClusterFX.h +++ /dev/null @@ -1,249 +0,0 @@ -/******************************************************************************* -The content of this file includes portions of the AUDIOKINETIC Wwise Technology -released in source code form as part of the SDK installer package. - -Commercial License Usage - -Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology -may use this file in accordance with the end user license agreement provided -with the software or, alternatively, in accordance with the terms contained in a -written agreement between you and Audiokinetic Inc. - -Apache License Usage - -Alternatively, this file may be used under the Apache License, Version 2.0 (the -"Apache License"); you may not use this file except in compliance with the -Apache License. You may obtain a copy of the Apache License at -http://www.apache.org/licenses/LICENSE-2.0. - -Unless required by applicable law or agreed to in writing, software distributed -under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES -OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for -the specific language governing permissions and limitations under the License. - - Copyright (c) 2023 Audiokinetic Inc. - Copyright (c) 2024 CCP ehf. - -This implementation was developed by CCP Games for spatial audio object clustering -in EVE Online and EVE Frontier. This implementation does not grant any rights to -CCP's trademarks or game content. EVE Online and EVE Frontier are registered -trademarks of CCP ehf. -*******************************************************************************/ - -#ifndef ObjectClusterFX_H -#define ObjectClusterFX_H - -#include "ObjectClusterFXParams.h" -#include -#include -#include -#include -#include -#include "SpatialClustering.h" -#include "Utilities.h" - -/** - * @struct GeneratedObject - * @brief Represents a generated audio object with its properties and state - */ -struct GeneratedObject { - AK::SpeakerVolumes::MatrixPtr volumeMatrix = nullptr; - AkAudioObjectID outputObjKey; - int index; - bool isClustered = false; -}; - -/** - * @struct ClusterState - * @brief Maintains the state of an audio object cluster - */ -struct ClusterState { - AkUInt32 activeInputCount = 0; - AkUInt16 maxFrames = 0; -}; - -/** - * @class ObjectClusterFX - * @brief Audio object clustering effect plugin - * @details Implements audio object clustering and processing functionality as an out-of-place plugin - */ -class ObjectClusterFX : public AK::IAkOutOfPlaceObjectPlugin -{ -public: - ObjectClusterFX(); - ~ObjectClusterFX(); - - /** - * @brief Initializes the plugin - * @param in_pAllocator Memory allocator interface - * @param in_pContext Plugin context interface - * @param in_pParams Plugin parameters - * @param in_rFormat Audio format - * @return AKRESULT Status code indicating success or failure - */ - AKRESULT Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffectPluginContext* in_pContext, AK::IAkPluginParam* in_pParams, AkAudioFormat& in_rFormat) override; - - /** - * @brief Terminates the plugin and releases resources - * @param in_pAllocator Memory allocator interface - * @return AKRESULT Status code indicating success or failure - */ - AKRESULT Term(AK::IAkPluginMemAlloc* in_pAllocator) override; - - /** - * @brief Resets the plugin to its initial state - * @return AKRESULT Status code indicating success or failure - */ - AKRESULT Reset() override; - - /** - * @brief Retrieves plugin information - * @param out_rPluginInfo Structure to be filled with plugin information - * @return AKRESULT Status code indicating success or failure - */ - AKRESULT GetPluginInfo(AkPluginInfo& out_rPluginInfo) override; - - /** - * @brief Executes the audio processing - * @param inObjects Input audio objects and buffers - * @param outObjects Output audio objects and buffers - */ - void Execute(const AkAudioObjects& inObjects, const AkAudioObjects& outObjects) override; - -private: - ObjectClusterFXParams* m_pParams; - AK::IAkPluginMemAlloc* m_pAllocator; - AK::IAkEffectPluginContext* m_pContext; - - /** - * @brief Updates the clusterer with input object positions - * @param inObjects Input audio objects - */ - void FeedPositionsToClusterer(const AkAudioObjects& inObjects); - - /** - * @brief Prepares audio objects for processing - * @param inObjects Input audio objects - */ - void PrepareAudioObjects(const AkAudioObjects& inObjects); - - /** - * @brief Processes all audio objects - * @param inObjects Input audio objects - */ - void ProcessAudioObjects(const AkAudioObjects& inObjects); - - /** - * @brief Processes a clustered audio object - * @param inObj Input audio object - * @param inBuf Input audio buffer - * @param outObj Output audio object - * @param outBuf Output audio buffer - * @param userData Generated object data - * @param clusterState Current cluster state - */ - void ProcessClusteredObject( - const AkAudioObject* inObj, - AkAudioBuffer* inBuf, - AkAudioObject* outObj, - AkAudioBuffer* outBuf, - GeneratedObject* userData, - const ClusterState& clusterState); - - /** - * @brief Processes an unclustered audio object - * @param inObj Input audio object - * @param inBuf Input audio buffer - * @param outObj Output audio object - * @param outBuf Output audio buffer - */ - void ProcessUnclustered( - const AkAudioObject* inObj, - AkAudioBuffer* inBuf, - AkAudioObject* outObj, - AkAudioBuffer* outBuf); - - /** - * @brief Mixes audio into a cluster - * @param inObject Input audio object - * @param inBuffer Input audio buffer - * @param outBuffer Output audio buffer - * @param cumulativeGain Cumulative gain ramp - * @param pGeneratedObject Generated object data - */ - void MixToCluster( - const AkAudioObject* inObject, - AkAudioBuffer* inBuffer, - AkAudioBuffer* outBuffer, - const AkRamp& cumulativeGain, - GeneratedObject* pGeneratedObject); - - /** - * @brief Gets current output objects - * @return Current output audio objects - */ - AkAudioObjects GetCurrentOutputObjects(); - - /** - * @brief Reads cluster states from input objects - * @param inObjects Input audio objects - * @return Map of cluster states - */ - std::unordered_map ReadClusterStates(const AkAudioObjects& inObjects); - - /** - * @brief Finds the best cluster for a position - * @param position Position to find cluster for - * @param existingOutputs Existing output objects - * @param outClusterKey Output cluster identifier - * @return AKRESULT Status code indicating success or failure - */ - AKRESULT FindBestCluster( - const AkVector& position, - const AkAudioObjects& existingOutputs, - AkAudioObjectID& outClusterKey); - - /** - * @brief Gets the cluster containing an object - * @param objectId Object identifier - * @return Pointer to cluster pair or nullptr if not found - */ - const std::pair>* GetCluster(AkAudioObjectID objectId) const; - - /** - * @brief Allocates volume matrix memory - * @param volumeMatrix Volume matrix pointer - * @param in_uNumChannelsIn Number of input channels - * @param in_uNumChannelsOut Number of output channels - * @return AKRESULT Status code indicating success or failure - */ - AKRESULT AllocateVolumes(AK::SpeakerVolumes::MatrixPtr& volumeMatrix, AkUInt32 in_uNumChannelsIn, AkUInt32 in_uNumChannelsOut); - - /** - * @brief Frees volume matrix memory - * @param volumeMatrix Volume matrix to free - */ - void FreeVolume(AK::SpeakerVolumes::MatrixPtr& volumeMatrix); - - /** - * @brief Frees all volume matrices - */ - void FreeAllVolumes(); - - std::unique_ptr m_clusterer; - std::unique_ptr m_utilities; - std::vector m_tempBuffers; - std::vector m_tempObjects; - - float m_lastDistanceThreshold = -1.0f; - - /// Maps that hold clustering data - std::vector>> m_clusters; - - /// Maps input objects to their corresponding output objects and processing information - AkMixerInputMap m_mapInObjsToOutObjs; - - void UpdateClusterPositions(const AkAudioObjects& inObjects); -}; - -#endif // ObjectClusterFX_H \ No newline at end of file diff --git a/SoundEnginePlugin/ObjectClusterFXParams.cpp b/SoundEnginePlugin/ObjectClusterFXParams.cpp deleted file mode 100644 index 753ac8a..0000000 --- a/SoundEnginePlugin/ObjectClusterFXParams.cpp +++ /dev/null @@ -1,101 +0,0 @@ -/******************************************************************************* -The content of this file includes portions of the AUDIOKINETIC Wwise Technology -released in source code form as part of the SDK installer package. - -Commercial License Usage - -Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology -may use this file in accordance with the end user license agreement provided -with the software or, alternatively, in accordance with the terms contained in a -written agreement between you and Audiokinetic Inc. - -Apache License Usage - -Alternatively, this file may be used under the Apache License, Version 2.0 (the -"Apache License"); you may not use this file except in compliance with the -Apache License. You may obtain a copy of the Apache License at -http://www.apache.org/licenses/LICENSE-2.0. - -Unless required by applicable law or agreed to in writing, software distributed -under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES -OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for -the specific language governing permissions and limitations under the License. - - Copyright (c) 2023 Audiokinetic Inc. -*******************************************************************************/ - -#include "ObjectClusterFXParams.h" - -#include - -ObjectClusterFXParams::ObjectClusterFXParams() -{ -} - -ObjectClusterFXParams::~ObjectClusterFXParams() -{ -} - -ObjectClusterFXParams::ObjectClusterFXParams(const ObjectClusterFXParams& in_rParams) -{ - RTPC = in_rParams.RTPC; - NonRTPC = in_rParams.NonRTPC; - m_paramChangeHandler.SetAllParamChanges(); -} - -AK::IAkPluginParam* ObjectClusterFXParams::Clone(AK::IAkPluginMemAlloc* in_pAllocator) -{ - return AK_PLUGIN_NEW(in_pAllocator, ObjectClusterFXParams(*this)); -} - -AKRESULT ObjectClusterFXParams::Init(AK::IAkPluginMemAlloc* in_pAllocator, const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) -{ - if (in_ulBlockSize == 0) - { - // Initialize default parameters here - RTPC.distanceThreshold = 200.f; - - m_paramChangeHandler.SetAllParamChanges(); - return AK_Success; - } - - return SetParamsBlock(in_pParamsBlock, in_ulBlockSize); -} - -AKRESULT ObjectClusterFXParams::Term(AK::IAkPluginMemAlloc* in_pAllocator) -{ - AK_PLUGIN_DELETE(in_pAllocator, this); - return AK_Success; -} - -AKRESULT ObjectClusterFXParams::SetParamsBlock(const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) -{ - AKRESULT eResult = AK_Success; - AkUInt8* pParamsBlock = (AkUInt8*)in_pParamsBlock; - - RTPC.distanceThreshold = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - - CHECKBANKDATASIZE(in_ulBlockSize, eResult); - m_paramChangeHandler.SetAllParamChanges(); - - return eResult; -} - -AKRESULT ObjectClusterFXParams::SetParam(AkPluginParamID in_paramID, const void* in_pValue, AkUInt32 in_ulParamSize) -{ - AKRESULT eResult = AK_Success; - - // Handle parameter change here - switch (in_paramID) - { - case DISTANCE_THRESHOLD: - RTPC.distanceThreshold = *((AkReal32*)in_pValue); - m_paramChangeHandler.SetParamChange(DISTANCE_THRESHOLD); - break; - default: - eResult = AK_InvalidParameter; - break; - } - - return eResult; -} diff --git a/SoundEnginePlugin/ObjectClusterFXParams.h b/SoundEnginePlugin/ObjectClusterFXParams.h deleted file mode 100644 index f7356d9..0000000 --- a/SoundEnginePlugin/ObjectClusterFXParams.h +++ /dev/null @@ -1,77 +0,0 @@ -/******************************************************************************* -The content of this file includes portions of the AUDIOKINETIC Wwise Technology -released in source code form as part of the SDK installer package. - -Commercial License Usage - -Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology -may use this file in accordance with the end user license agreement provided -with the software or, alternatively, in accordance with the terms contained in a -written agreement between you and Audiokinetic Inc. - -Apache License Usage - -Alternatively, this file may be used under the Apache License, Version 2.0 (the -"Apache License"); you may not use this file except in compliance with the -Apache License. You may obtain a copy of the Apache License at -http://www.apache.org/licenses/LICENSE-2.0. - -Unless required by applicable law or agreed to in writing, software distributed -under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES -OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for -the specific language governing permissions and limitations under the License. - - Copyright (c) 2023 Audiokinetic Inc. -*******************************************************************************/ - -#ifndef ObjectClusterFXParams_H -#define ObjectClusterFXParams_H - -#include -#include - -// Add parameters IDs here, those IDs should map to the AudioEnginePropertyID -// attributes in the xml property definition. -static const AkPluginParamID DISTANCE_THRESHOLD = 0; -static const AkUInt32 NUM_PARAMS = 1; - -struct ObjectClusterRTPCParams -{ - AkReal32 distanceThreshold; -}; - -struct ObjectClusterNonRTPCParams -{ -}; - -struct ObjectClusterFXParams - : public AK::IAkPluginParam -{ - ObjectClusterFXParams(); - ObjectClusterFXParams(const ObjectClusterFXParams& in_rParams); - - ~ObjectClusterFXParams(); - - /// Create a duplicate of the parameter node instance in its current state. - IAkPluginParam* Clone(AK::IAkPluginMemAlloc* in_pAllocator) override; - - /// Initialize the plug-in parameter node interface. - /// Initializes the internal parameter structure to default values or with the provided parameter block if it is valid. - AKRESULT Init(AK::IAkPluginMemAlloc* in_pAllocator, const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) override; - - /// Called by the sound engine when a parameter node is terminated. - AKRESULT Term(AK::IAkPluginMemAlloc* in_pAllocator) override; - - /// Set all plug-in parameters at once using a parameter block. - AKRESULT SetParamsBlock(const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) override; - - /// Update a single parameter at a time and perform the necessary actions on the parameter changes. - AKRESULT SetParam(AkPluginParamID in_paramID, const void* in_pValue, AkUInt32 in_ulParamSize) override; - - AK::AkFXParameterChangeHandler m_paramChangeHandler; - - ObjectClusterRTPCParams RTPC; - ObjectClusterNonRTPCParams NonRTPC; -}; - -#endif // ObjectClusterFXParams_H diff --git a/SoundEnginePlugin/SpatialClustering.cpp b/SoundEnginePlugin/SpatialClustering.cpp deleted file mode 100644 index aceda74..0000000 --- a/SoundEnginePlugin/SpatialClustering.cpp +++ /dev/null @@ -1,410 +0,0 @@ -/* - * Copyright 2024 CCP ehf. - * - * This software was developed by CCP Games for spatial audio object clustering - * in EVE Online and EVE Frontier. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * This license does not grant any rights to CCP's trademarks or game content. - * EVE Online and EVE Frontier are registered trademarks of CCP ehf. - */ - -#include "SpatialClustering.h" - -template -T clamp(T value, T min, T max) { - return std::max(min, std::min(max, value)); -} -unsigned int SpatialClusterer::determineMaxClusters(unsigned int numObjects) { - return static_cast(std::sqrt(numObjects)); -} - -void SpatialClusterer::initializeCentroids(const std::vector& objects) { - if (objects.empty()) return; - - std::vector objectsMetadata; - objectsMetadata.reserve(objects.size()); - - // Calculate local density including origin region - const float densityRadius = m_distanceThreshold * 0.5f; - const float densityRadiusSq = densityRadius * densityRadius; - - // Track density around origin specifically - float originDensity = 0.0f; - std::vector nearOriginObjects; - - for (const auto& obj : objects) { - float localDensity = 0.0f; - - // Calculate density contribution to origin - float distToOriginSq = m_utilities.GetDistanceSquared(obj.position, AkVector{ 0,0,0 }); - if (distToOriginSq < densityRadiusSq) { - nearOriginObjects.push_back(&obj); - originDensity += calculateGaussianWeight(distToOriginSq, densityRadiusSq); - } - - // Calculate local density relative to other points - for (const auto& neighbor : objects) { - float distSq = m_utilities.GetDistanceSquared(obj.position, neighbor.position); - if (distSq < densityRadiusSq) { - localDensity += calculateGaussianWeight(distSq, densityRadiusSq); - } - } - - objectsMetadata.push_back({ obj, localDensity, std::numeric_limits::max() }); - } - - // Sort by density - std::sort(objectsMetadata.begin(), objectsMetadata.end(), - [](const ObjectMetadata& a, const ObjectMetadata& b) { - return a.localDensity > b.localDensity; - }); - - centroids.clear(); - - // If we have significant density near origin, calculate optimal centroid position - if (!nearOriginObjects.empty()) { - AkVector originCluster{ 0,0,0 }; - float totalWeight = 0.0f; - - // Calculate weighted average position for objects near origin - for (const auto* obj : nearOriginObjects) { - float weight = calculateGaussianWeight( - m_utilities.GetDistanceSquared(obj->position, AkVector{ 0,0,0 }), - densityRadiusSq - ); - originCluster.X += obj->position.X * weight; - originCluster.Y += obj->position.Y * weight; - originCluster.Z += obj->position.Z * weight; - totalWeight += weight; - } - - if (totalWeight > 0.0f) { - originCluster.X /= totalWeight; - originCluster.Y /= totalWeight; - originCluster.Z /= totalWeight; - centroids.push_back(originCluster); - } - } - - // Add highest density point if we haven't added an origin cluster - if (centroids.empty() && !objectsMetadata.empty()) { - centroids.push_back(objectsMetadata[0].object.position); - } - - // Continue with k-means++ initialization for remaining centroids - while (centroids.size() < maxClusters) { - float maxMinDistance = 0.0f; - size_t bestCandidate = 0; - bool candidateFound = false; - - // Find point with maximum minimum distance to existing centroids - for (size_t i = 0; i < objectsMetadata.size(); ++i) { - float minDist = std::numeric_limits::max(); - for (const auto& centroid : centroids) { - float dist = calculateDistance(objectsMetadata[i].object.position, centroid); - minDist = std::min(minDist, dist); - } - - if (minDist > maxMinDistance) { - maxMinDistance = minDist; - bestCandidate = i; - candidateFound = true; - } - } - - if (!candidateFound || maxMinDistance < m_distanceThreshold) { - break; - } - - centroids.push_back(objectsMetadata[bestCandidate].object.position); - } -} - -float SpatialClusterer::calculateDistance(const AkVector& a, const AkVector& b) const { - return std::sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y) + (a.Z - b.Z) * (a.Z - b.Z)); -} - -bool SpatialClusterer::assignPointsToClusters(const std::vector& objects) { - if (objects.empty()) return false; - - bool changed = false; - std::vector> newClusters(centroids.size()); - unassignedPoints.clear(); - - // Assign points to nearest centroid if within threshold - for (const auto& obj : objects) { - float minDistance = std::numeric_limits::max(); - int closestCentroid = -1; - - // Find closest centroid - for (size_t j = 0; j < centroids.size(); ++j) { - float distance = calculateDistance(obj.position, centroids[j]); - if (distance < minDistance) { - minDistance = distance; - closestCentroid = j; - } - } - - // If closest centroid is within threshold, assign to cluster - if (closestCentroid >= 0 && minDistance <= m_distanceThreshold) { - newClusters[closestCentroid].push_back(obj); - if (labels[&obj - &objects[0]] != closestCentroid) { - labels[&obj - &objects[0]] = closestCentroid; - changed = true; - } - } - else { - // Point is too far from any existing cluster - unassignedPoints.push_back(obj); - labels[&obj - &objects[0]] = -1; - changed = true; - } - } - - // Remove empty clusters - auto it = std::remove_if(newClusters.begin(), newClusters.end(), - [](const std::vector& cluster) { return cluster.empty(); }); - newClusters.erase(it, newClusters.end()); - - // Form new clusters from unassigned points if they're close to each other - while (!unassignedPoints.empty()) { - std::vector newCluster; - newCluster.push_back(unassignedPoints[0]); - AkVector clusterCenter = unassignedPoints[0].position; - - // Find all points within m_distanceThreshold of the first point - // No increment in loop control: when we remove a point, next point slides into current position - for (size_t i = 1; i < unassignedPoints.size();) { - if (calculateDistance(unassignedPoints[i].position, clusterCenter) <= m_distanceThreshold) { - newCluster.push_back(unassignedPoints[i]); - unassignedPoints.erase(unassignedPoints.begin() + i); - } - else { - ++i; - } - } - unassignedPoints.erase(unassignedPoints.begin()); - - if (newCluster.size() > 1) { - newClusters.push_back(std::move(newCluster)); - changed = true; - } - } - - // Update centroids - centroids.clear(); - for (const auto& cluster : newClusters) { - centroids.push_back(calculateCentroid(cluster)); - } - - clusters = std::move(newClusters); - return changed; -} - -void SpatialClusterer::adjustClusterCount() { - // Remove empty clusters - auto it = std::remove_if(clusters.begin(), clusters.end(), - [](const std::vector& cluster) { return cluster.empty(); }); - clusters.erase(it, clusters.end()); - - // Remove corresponding centroids - centroids.resize(clusters.size()); - - // Try to create new clusters from unassigned points - while (!unassignedPoints.empty() && clusters.size() < maxClusters) { - AkVector newCentroid = unassignedPoints[0].position; - std::vector newCluster; - - auto unassignedIt = unassignedPoints.begin(); - while (unassignedIt != unassignedPoints.end()) { - if (calculateDistance(unassignedIt->position, newCentroid) <= m_distanceThreshold) { - newCluster.push_back(*unassignedIt); - unassignedIt = unassignedPoints.erase(unassignedIt); - } - else { - ++unassignedIt; - } - } - - if (!newCluster.empty()) { - clusters.push_back(std::move(newCluster)); - centroids.push_back(newCentroid); - } - else { - break; // No more clusters can be formed - } - } -} - -float SpatialClusterer::calculateGaussianWeight(float distanceSquared, float radiusSquared) const -{ - return std::exp(-distanceSquared / (2.0f * radiusSquared)); -} - -void SpatialClusterer::setMinDistanceThreshold(float newValue) -{ - if (newValue != m_minThreshold) { - m_minThreshold = newValue; - } -} - -void SpatialClusterer::setMaxDistanceThreshold(float newValue) -{ - if (newValue != m_maxThreshold) { - m_maxThreshold = newValue; - } -} - -bool SpatialClusterer::updateCentroids() { - if (clusters.empty()) return false; - - bool changed = false; - std::vector newCentroids; - newCentroids.reserve(clusters.size()); - - for (const auto& cluster : clusters) { - if (cluster.empty()) continue; - - AkVector newCentroid = calculateCentroid(cluster); - newCentroids.push_back(newCentroid); - } - - // Check if any centroids moved significantly - for (size_t i = 0; i < newCentroids.size(); ++i) { - if (calculateDistance(centroids[i], newCentroids[i]) > m_tolerance) { - changed = true; - break; - } - } - - if (changed) { - centroids = std::move(newCentroids); - } - - return changed; -} - -void SpatialClusterer::adjustClusterCount(unsigned int numObjects) { - if (numObjects == 0) { - return; - } - maxClusters = determineMaxClusters(numObjects); - centroids.resize(maxClusters); -} - -float SpatialClusterer::calculateSSE() const { - float sse = 0.0f; - for (size_t i = 0; i < clusters.size(); ++i) { - for (const auto& obj : clusters[i]) { - float distance = calculateDistance(obj.position, centroids[i]); - sse += distance * distance; // Still square here as SSE is defined with squared distances - } - } - return sse; -} - -AkVector SpatialClusterer::calculateCentroid(const std::vector& cluster) -{ - AkVector centroid = { 0, 0, 0 }; - if (cluster.empty()) return centroid; - - for (const auto& obj : cluster) { - centroid.X += obj.position.X; - centroid.Y += obj.position.Y; - centroid.Z += obj.position.Z; - } - centroid.X /= cluster.size(); - centroid.Y /= cluster.size(); - centroid.Z /= cluster.size(); - - return centroid; -} - -SpatialClusterer::SpatialClusterer(float tolerance, float distanceThreshold, float minDistanceThreshold, float maxDistanceThreshold) - : m_tolerance(tolerance), - m_distanceThreshold(distanceThreshold), - m_minThreshold(minDistanceThreshold), - m_maxThreshold(maxDistanceThreshold) -{ - std::random_device rd; - seed = rd(); -} - -void SpatialClusterer::setTolerance(float newValue) { - m_tolerance = clamp(newValue, 0.001f, 1.0f); -} - -void SpatialClusterer::setDistanceThreshold(float newValue) { - m_distanceThreshold = clamp(newValue, m_minThreshold, m_maxThreshold); -} - -void SpatialClusterer::performClustering(const std::vector& objects, unsigned int max_iterations) { - labels.resize(objects.size(), -1); - maxClusters = determineMaxClusters(objects.size()); - initializeCentroids(objects); - - for (unsigned int iter = 0; iter < max_iterations; ++iter) { - - bool changed = assignPointsToClusters(objects); - adjustClusterCount(); - bool centroidsUpdated = updateCentroids(); - - float current_sse = calculateSSE(); - sse_values.push_back(current_sse); - - // Check for convergence - if ((!changed && !centroidsUpdated) || - (iter > 0 && std::abs(sse_values[iter] - sse_values[iter - 1]) < m_tolerance * sse_values[iter - 1])) { - break; - } - } - - adjustClusterCount(); -} - -const std::vector& SpatialClusterer::getLabels() const { - return labels; -} - -const std::vector& SpatialClusterer::getCentroids() const { - return centroids; -} - -std::map> SpatialClusterer::getClusters() const { - std::map> clusterMap; - - for (const auto& cluster : clusters) { - if (!cluster.empty()) { - AkVector centroid = { 0, 0, 0 }; - for (const auto& obj : cluster) { - centroid.X += obj.position.X; - centroid.Y += obj.position.Y; - centroid.Z += obj.position.Z; - } - centroid.X /= cluster.size(); - centroid.Y /= cluster.size(); - centroid.Z /= cluster.size(); - - std::vector objectIDs; - for (const auto& obj : cluster) { - objectIDs.push_back(obj.key); - } - - clusterMap[centroid] = std::move(objectIDs); - } - } - - return clusterMap; -} \ No newline at end of file diff --git a/SoundEnginePlugin/SpatialClustering.h b/SoundEnginePlugin/SpatialClustering.h deleted file mode 100644 index 4170609..0000000 --- a/SoundEnginePlugin/SpatialClustering.h +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright 2024 CCP ehf. - * - * This software was developed by CCP Games for spatial audio object clustering - * in EVE Online and EVE Frontier. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * This license does not grant any rights to CCP's trademarks or game content. - * EVE Online and EVE Frontier are registered trademarks of CCP ehf. - */ - -#pragma once -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "Utilities.h" - -#undef min -#undef max - -/** - * @brief Clamps a value between a minimum and maximum. - * @tparam T The type of the value and bounds. - * @param value The value to clamp. - * @param min The minimum allowed value. - * @param max The maximum allowed value. - * @return The clamped value. - */ -template -T clamp(T value, T min, T max); - -/** - * @brief Compares two AkVector objects. - * - * @param a The first AkVector to compare. - * @param b The second AkVector to compare. - * @return true if a is considered less than b, false otherwise. - */ -inline bool operator<(const AkVector& a, const AkVector& b) { - if (a.X != b.X) return a.X < b.X; - if (a.Y != b.Y) return a.Y < b.Y; - return a.Z < b.Z; -} - -/** - * @brief Represents a position and key for an audio object. - */ -struct ObjectPosition { - AkVector position; ///< The 3D position of the audio object. - AkAudioObjectID key; ///< The unique identifier of the audio object. -}; - -/** - * @brief Holds metadata about an object used during cluster initialization. - */ -struct ObjectMetadata { - ObjectPosition object; - float localDensity; // Density of objects in local neighborhood - float minDistanceToCentroid; // Min distance to any existing centroid -}; - -/** - * @brief Density-aware spatial clustering for audio objects in 3D space. - * - * Given a set of audio object positions and a distance threshold, this groups nearby objects into - * clusters each frame and represents each cluster by a single centroid. Cluster count is derived - * from input density and seeding favors high-density regions and the listener position. - * - * 1. **Initialization:** Initial cluster centers are picked based on local object density using - * Gaussian weighting, biased toward the listener position. Additional centers are placed using - * K-means++ and selection stops early once all remaining objects are within range of an existing - * center. - * 2. **Assignment:** Each object is assigned to its nearest cluster center if within the distance - * threshold. Objects too far from any center are grouped with other nearby distant objects to - * form new clusters. - * 3. **Update Step:** Cluster centers are recalculated as the average position of their members. - * Empty clusters are removed, and new clusters may form from unassigned points if they are - * sufficiently close together. - * 4. **Convergence:** This iterative process continues until either cluster assignments stabilize, - * the change in Sum of Squared Errors (SSE) falls below the tolerance threshold, or the maximum - * iterations are reached. - * 5. **Result:** A dynamic set of clusters, each represented by its centroid. The number of - * clusters varies based on the spatial distribution of inputs and the distance threshold. - */ - -class SpatialClusterer { -private: - - unsigned int maxClusters; ///< Maximum number of clusters. - unsigned int seed; ///< Seed for the random number generator. - float m_tolerance; ///< Tolerance for convergence. - float m_distanceThreshold; ///< Maximum distance for a point to be considered in a cluster. - float m_minThreshold; ///< Minimun value for the distance threshold. - float m_maxThreshold; ///< Maximum value for the distance threshold. - std::vector centroids; ///< The centroids of the clusters. - std::vector labels; ///< Labels assigning each point to a cluster. - std::vector> clusters; ///< The resulting clusters. - std::vector sse_values; /// Sum of squared errors values - std::vector unassignedPoints; /// A vector for unassigned points. - Utilities m_utilities; - - - /** - * @brief Retrieves points that couldn't be assigned to any cluster due to distance constraints. - * @return A const reference to the vector of unassigned object positions. - */ - const std::vector& getUnassignedPoints() const { - return unassignedPoints; - } - - /** - * @brief Determines the maximum number of clusters based on the number of objects. - * @param numObjects The number of objects to cluster. - * @return The maximum number of clusters. - */ - unsigned int determineMaxClusters(unsigned int numObjects); - - /** - * @brief Initializes the cluster centers. - * @param objects The objects to initialize centers from. - */ - void initializeCentroids(const std::vector& objects); - - /** - * @brief Calculates the squared distance between two points. - * @param a The first point. - * @param b The second point. - * @return The squared distance between the points. - */ - float calculateDistance(const AkVector& a, const AkVector& b) const; - - /** - * @brief Assigns points to the nearest cluster. - * @param objects The objects to assign to clusters. - * @return True if any assignments changed, false otherwise. - */ - bool assignPointsToClusters(const std::vector& objects); - - /** - * @brief Updates the centroids based on the current cluster assignments. - * @return True if any centroid changed significantly, false otherwise. - */ - bool updateCentroids(); - - /** - * @brief Adjusts the number of clusters based on the number of objects. - * @param numObjects The number of objects to cluster. - */ - void adjustClusterCount(unsigned int numObjects); - - /** - * @brief Calculates the Sum of Squared Errors (SSE) for the current clustering. - * - * The Sum of Squared Errors is a measure of the quality of the clustering. It is calculated - * by summing the squared Euclidean distances between each data point and its assigned - * centroid. A lower SSE indicates a better clustering result, as it means the data points - * are closer to their centroids on average. - * - * @return The calculated Sum of Squared Errors for the current clustering. - */ - float calculateSSE() const; - - /** - * @brief Calculates the centroid of a cluster of object positions. - * - * This function computes the average position (centroid) of all objects in a given cluster. - * If the cluster is empty, it returns a zero vector. - * - * @param cluster A vector of ObjectPosition representing a cluster of objects. - * @return AkVector The calculated centroid of the cluster. - * - */ - - AkVector calculateCentroid(const std::vector& cluster); - - /** - * @brief Adjusts the number and composition of clusters based on the distance threshold. - * - * This method performs the following operations: - * 1. Removes empty clusters to optimize cluster count. - * 2. Attempts to create new clusters from unassigned points, respecting the distance threshold. - */ - void adjustClusterCount(); - - /** - * @brief Calculates the Gaussian weight based on squared distance and radius - * @param distanceSquared The squared distance between two points - * @param radiusSquared The squared radius of influence - * @return The calculated weight - */ - - float calculateGaussianWeight(float distanceSquared, float radiusSquared) const; - - - -public: - /** - * @brief Constructs a SpatialClusterer instance. - * @param tolerance The convergence tolerance. - * @param distanceThreshold The maximum distance for a point to be in a cluster. - */ - SpatialClusterer(float tolerance = 0.01f, - float distanceThreshold = 100.0f, - float minDistanceThreshold = 10.f, - float maxDistanceThreshold = 1000.f); - - /** - * @brief Sets the convergence tolerance. - * @param newValue The new tolerance value. - */ - void setTolerance(float newValue); - - /** - * @brief Sets the distance threshold. - * @param newValue The new distance threshold value. - */ - void setDistanceThreshold(float newValue); - - /** - * @brief Sets the internal minimun distance threshold value. - */ - void setMinDistanceThreshold(float newValue); - - /** - * @brief Sets the internal maximum distance threshold value. - */ - void setMaxDistanceThreshold(float newValue); - - /** - * @brief Performs spatial clustering on the given objects. - * @param objects The objects to cluster. - * @param max_iterations The maximum number of iterations. - */ - void performClustering(const std::vector& objects, unsigned int max_iterations = 20); - - /** - * @brief Gets the cluster labels for each object. - * @return The cluster labels. - */ - const std::vector& getLabels() const; - - /** - * @brief Gets the centroids of the clusters. - * @return The centroids. - */ - const std::vector& getCentroids() const; - - /** - * @brief Gets the resulting clusters as a map of AkVectors to object IDs. - * @return The clusters. - */ - std::map> getClusters() const; -}; \ No newline at end of file diff --git a/SoundEnginePlugin/Utilities.cpp b/SoundEnginePlugin/Utilities.cpp deleted file mode 100644 index 4d4dc1f..0000000 --- a/SoundEnginePlugin/Utilities.cpp +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2024 CCP ehf. - * - * This software was developed by CCP Games for spatial audio object clustering - * in EVE Online and EVE Frontier. - * - * The content of this file includes portions of the AUDIOKINETIC Wwise Technology - * released in source code form as part of the SDK installer package. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * This license does not grant any rights to CCP's trademarks or game content. - * EVE Online and EVE Frontier are registered trademarks of CCP ehf. - */ - -#include "Utilities.h" -#include -#include -#include - -Utilities::Utilities() = default; - -Utilities::~Utilities() = default; - -void Utilities::ClearBuffers(AkAudioObjects& audioObjects) -{ - for (AkUInt32 i = 0; i < audioObjects.uNumObjects; ++i) { - AkAudioBuffer* pBuffer = audioObjects.ppObjectBuffers[i]; - for (AkUInt32 j = 0; j < pBuffer->NumChannels(); ++j) { - // Clear the buffer to zero - std::fill(pBuffer->GetChannel(j), pBuffer->GetChannel(j) + pBuffer->MaxFrames(), 0.0f); - } - } -} - -void Utilities::CopyBuffer(AkAudioBuffer* inBuffer, AkAudioBuffer* outBuffer) -{ - for (AkUInt32 j = 0; j < inBuffer->NumChannels(); ++j) - { - AkReal32* pInBuf = inBuffer->GetChannel(j); - AkReal32* outBuf = outBuffer->GetChannel(j); - memcpy(outBuf, pInBuf, inBuffer->uValidFrames * sizeof(AkReal32)); - } -} - -AkAudioObjectID Utilities::CreateOutputObject(const AkAudioObject* inobj, const AkAudioObjects& inObjects, const AkUInt32 index, AK::IAkEffectPluginContext* m_pContext, const AkVector* clusterPosition) -{ - AkAudioObjectID outputObjKey = AK_INVALID_AUDIO_OBJECT_ID; - AkUInt32 numObjsOut = 1; - // Allocate space for a new output object - AkAudioObject* newObject = (AkAudioObject*)AkAlloca(sizeof(AkAudioObject*)); - AkAudioObjects outputObjects; - outputObjects.uNumObjects = numObjsOut; - outputObjects.ppObjectBuffers = nullptr; - outputObjects.ppObjects = &newObject; - if (m_pContext->CreateOutputObjects(inObjects.ppObjectBuffers[index]->GetChannelConfig(), outputObjects) == AK_Success) - { - AkAudioObject* pObject = newObject; - outputObjKey = pObject->key; - - pObject->positioning.behavioral.spatMode = inobj->positioning.behavioral.spatMode; - pObject->positioning.threeD = inobj->positioning.threeD; - - // Set the position - if (clusterPosition) - { - pObject->positioning.threeD.xform.SetPosition(*clusterPosition); - } - else - { - pObject->positioning.threeD.xform.SetPosition(inobj->positioning.threeD.xform.Position()); - } - } - return outputObjKey; -} - -float Utilities::GetDistanceSquared(const AkVector& v1, const AkVector& v2) -{ - float dx = v1.X - v2.X; - float dy = v1.Y - v2.Y; - float dz = v1.Z - v2.Z; - return dx * dx + dy * dy + dz * dz; -} - -AkVector Utilities::CalculateMeanPosition(const std::vector& clusterObjects, const AkAudioObjects& inObjects) -{ - AkVector sumPosition; - sumPosition.X = 0.0f; - sumPosition.Y = 0.0f; - sumPosition.Z = 0.0f; - int validObjectCount = 0; - - // Sum up positions of all objects in the cluster - for (const auto& objId : clusterObjects) { - for (AkUInt32 i = 0; i < inObjects.uNumObjects; i++) { - if (inObjects.ppObjects[i]->key == objId) { - const AkVector& pos = inObjects.ppObjects[i]->positioning.threeD.xform.Position(); - sumPosition.X += pos.X; - sumPosition.Y += pos.Y; - sumPosition.Z += pos.Z; - validObjectCount++; - break; - } - } - } - - // Calculate mean position - if (validObjectCount > 0) { - sumPosition.X /= validObjectCount; - sumPosition.Y /= validObjectCount; - sumPosition.Z /= validObjectCount; - } - return sumPosition; -} - - diff --git a/SoundEnginePlugin/Utilities.h b/SoundEnginePlugin/Utilities.h deleted file mode 100644 index 5fcc39d..0000000 --- a/SoundEnginePlugin/Utilities.h +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2024 CCP ehf. - * - * This software was developed by CCP Games for spatial audio object clustering - * in EVE Online and EVE Frontier. - * - * The content of this file includes portions of the AUDIOKINETIC Wwise Technology - * released in source code form as part of the SDK installer package. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * This license does not grant any rights to CCP's trademarks or game content. - * EVE Online and EVE Frontier are registered trademarks of CCP ehf. - */ - -#pragma once - -#include -#include -#include "ObjectClusterFXParams.h" -#include - -/** - * @class DSPUtilities - * @brief Utility class for various DSP (Digital Signal Processing) operations. - */ -class Utilities { -public: - - /** - * @brief Default constructor - */ - Utilities(); - - /** - * @brief Default destructor - */ - ~Utilities(); - - /** - * @brief Retrieves the buffers from an AkAudioObjects instance and clears them. - * @param audioObjects The audio objects whose buffers need to be cleared. - */ - void ClearBuffers(AkAudioObjects& outputObjects); - - /** - * @brief Copies the contents of one audio buffer to another. - * @param inBuffer The input buffer. - * @param outBuffer The output buffer. - */ - void CopyBuffer(AkAudioBuffer* inBuffer, AkAudioBuffer* outBuffer); - - /** - * @brief Creates an output audio object. - * @param inobj The input audio object. - * @param inObjects The collection of input audio objects. - * @param index The index of the input object in the collection. - * @param m_pContext The plugin context. - * @return The ID of the created output audio object. - */ - AkAudioObjectID CreateOutputObject(const AkAudioObject* inobj, const AkAudioObjects& inObjects, const AkUInt32 index, AK::IAkEffectPluginContext* m_pContext, const AkVector* clusterPosition); - - /** - * @brief Calculates the squared distance between two 3D vectors - * @param v1 First vector - * @param v2 Second vector - * @return The squared Euclidean distance between the vectors - */ - float GetDistanceSquared(const AkVector& v1, const AkVector& v2); - - - /** - * @brief Calculates the average position of a group of audio objects in a cluster. - * - * @param clusterObjects Vector of audio object IDs in the cluster - * @param inObjects Input audio objects containing position data - * @return AkVector The mean position of all objects in the cluster - */ - AkVector CalculateMeanPosition( - const std::vector& clusterObjects, - const AkAudioObjects& inObjects); -}; \ No newline at end of file diff --git a/WwisePlugin/ObjectCluster.def b/WwisePlugin/ObjectCluster.def deleted file mode 100644 index 7926626..0000000 --- a/WwisePlugin/ObjectCluster.def +++ /dev/null @@ -1,3 +0,0 @@ -LIBRARY "ObjectCluster" - -EXPORTS diff --git a/WwisePlugin/ObjectCluster.rc b/WwisePlugin/ObjectCluster.rc deleted file mode 100644 index 44acb21..0000000 Binary files a/WwisePlugin/ObjectCluster.rc and /dev/null differ diff --git a/WwisePlugin/ObjectCluster.xml b/WwisePlugin/ObjectCluster.xml deleted file mode 100644 index 4c4359f..0000000 --- a/WwisePlugin/ObjectCluster.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - true - true - true - - - - - - - - 200 - 0 - - - - 1 - 1000 - - - - - - - diff --git a/WwisePlugin/ObjectClusterPlugin.cpp b/WwisePlugin/ObjectClusterPlugin.cpp deleted file mode 100644 index 829aed7..0000000 --- a/WwisePlugin/ObjectClusterPlugin.cpp +++ /dev/null @@ -1,56 +0,0 @@ -/******************************************************************************* -The content of this file includes portions of the AUDIOKINETIC Wwise Technology -released in source code form as part of the SDK installer package. - -Commercial License Usage - -Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology -may use this file in accordance with the end user license agreement provided -with the software or, alternatively, in accordance with the terms contained in a -written agreement between you and Audiokinetic Inc. - -Apache License Usage - -Alternatively, this file may be used under the Apache License, Version 2.0 (the -"Apache License"); you may not use this file except in compliance with the -Apache License. You may obtain a copy of the Apache License at -http://www.apache.org/licenses/LICENSE-2.0. - -Unless required by applicable law or agreed to in writing, software distributed -under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES -OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for -the specific language governing permissions and limitations under the License. - - Copyright (c) 2023 Audiokinetic Inc. -*******************************************************************************/ - -#include "ObjectClusterPlugin.h" -#include "../SoundEnginePlugin/ObjectClusterFXFactory.h" - - -ObjectClusterPlugin::ObjectClusterPlugin() -{ -} - -ObjectClusterPlugin::~ObjectClusterPlugin() -{ -} - -bool ObjectClusterPlugin::GetBankParameters(const GUID & in_guidPlatform, AK::Wwise::Plugin::DataWriter& in_dataWriter) const -{ - // Write bank data here - in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:distanceThreshold")); - - return true; -} - -DEFINE_AUDIOPLUGIN_CONTAINER(ObjectCluster); // Create a PluginContainer structure that contains the info for our plugin -EXPORT_AUDIOPLUGIN_CONTAINER(ObjectCluster); // This is a DLL, we want to have a standardized name -ADD_AUDIOPLUGIN_CLASS_TO_CONTAINER( // Add our CLI class to the PluginContainer - ObjectCluster, // Name of the plug-in container for this shared library - ObjectClusterPlugin, // Authoring plug-in class to add to the plug-in container - ObjectClusterFX // Corresponding Sound Engine plug-in class -); -DEFINE_PLUGIN_REGISTER_HOOK - -DEFINEDUMMYASSERTHOOK; // Placeholder assert hook for Wwise plug-ins using AKASSERT (cassert used by default) diff --git a/WwisePlugin/res/Md/en/Placeholder.md b/WwisePlugin/res/Md/en/Placeholder.md deleted file mode 100644 index 8c96348..0000000 --- a/WwisePlugin/res/Md/en/Placeholder.md +++ /dev/null @@ -1,7 +0,0 @@ -##Placeholder Parameter - -The description of what this parameter does... - -**Note**: Additional note goes here... - -Units: x
\ No newline at end of file diff --git a/cmake/SpatialClusteringConfig.cmake b/cmake/SpatialClusteringConfig.cmake new file mode 100644 index 0000000..ab535dd --- /dev/null +++ b/cmake/SpatialClusteringConfig.cmake @@ -0,0 +1,8 @@ +# Copyright 2026 CCP ehf. +# +# Licensed under the Apache License, Version 2.0. See LICENSE.txt in the repository root. + +# Provides SpatialClustering::SpatialClusteringFX, the runtime plug-in as a static library. +# Link it next to the Wwise sound engine and include +# in one source file to register the plug-in. +include(${CMAKE_CURRENT_LIST_DIR}/SpatialClusteringTargets.cmake) diff --git a/core/src/ClusterEngine.cpp b/core/src/ClusterEngine.cpp new file mode 100644 index 0000000..3af9f51 --- /dev/null +++ b/core/src/ClusterEngine.cpp @@ -0,0 +1,2262 @@ +/* + * Copyright 2026 CCP ehf. + * + * This software was developed by CCP Games for spatial audio object clustering + * in EVE Online and EVE Frontier. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This license does not grant any rights to CCP's trademarks or game content. + * EVE Online and EVE Frontier are registered trademarks of CCP ehf. + */ + +#include "ClusterEngine.h" + +#include +#include +#include + +namespace +{ +/** @brief Index value that marks a missing slot, node, cluster or partner. */ +constexpr uint32_t kNone = 0xFFFFFFFFu; + +/** @brief Largest number of inputs the engine accepts. */ +constexpr uint32_t kMaxCapacity = 1u << 24; + +/** @brief Smallest number of slots reserved when the engine grows. */ +constexpr uint32_t kMinGrowth = 16; + +/** @brief Number of clusters reserved for each slot. */ +constexpr uint32_t kClustersPerSlot = 3; + +/** @brief Alignment in bytes of each array in the memory block. */ +constexpr size_t kBlockAlignment = 16; + +/** @brief Distance from the listener below which an input has no direction. */ +constexpr float kMinRange = 1e-4f; + +/** @brief Weight added to the loudness so that silent inputs still count. */ +constexpr float kLoudnessFloor = 1e-6f; + +/** @brief Largest accepted input loudness. */ +constexpr float kMaxLoudness = 1e9f; + +/** @brief Largest accepted loudness smoothing coefficient. */ +constexpr float kMaxSmoothing = 0.999999f; + +/** @brief Ratio of a circle's circumference to its diameter. */ +constexpr double kPi = 3.14159265358979323846; + +/** @brief Number of degrees in one radian. */ +constexpr double kDegreesPerRadian = 57.295779513082320876798; + +/** @brief Number of radians in one degree. */ +constexpr double kRadiansPerDegree = 0.017453292519943295769237; + +/** @brief Share of the total weight below which a summed direction counts as cancelled out. */ +constexpr double kCancelRatio = 1e-6; + +/** @brief Distance larger than any distance between two clusters. */ +constexpr double kAnyDistance = 1e30; + +/** @brief Cosine below every real cosine, used when no angle limit applies. */ +constexpr double kNoCosineLimit = -2.0; + +/** @brief Largest distance weight the engine accepts, as a safety cap. */ +constexpr double kDistanceWeightCap = 1e6; + +/** @brief Largest leave ratio the engine accepts, as a safety cap. */ +constexpr double kLeaveRatioCap = 1e6; + +/** @brief Margin added to the direction Z band of the merge partner search. */ +constexpr float kBandSlack = 1e-5f; + +/** @brief Bit of a merge class that marks clusters of inputs with no direction. */ +constexpr uint64_t kCentredClassBit = 1ull << 32; + +/** @brief Returns the smaller of two values. */ +template +T MinOf(T in_a, T in_b) +{ + return in_b < in_a ? in_b : in_a; +} + +/** @brief Returns the larger of two values. */ +template +T MaxOf(T in_a, T in_b) +{ + return in_a < in_b ? in_b : in_a; +} + +/** @brief Returns the angle in degrees for a cosine, from 0 to 180. */ +double AngleFromDot(double in_dot) +{ + if (in_dot >= 1.0) + { + return 0.0; + } + if (in_dot <= -1.0) + { + return 180.0; + } + return std::acos(in_dot) * kDegreesPerRadian; +} + +/** @brief Returns the cosine of an angle in degrees, or kNoCosineLimit for angles above 180 degrees. */ +double CosineLimit(double in_angle) +{ + if (in_angle > 180.0) + { + return kNoCosineLimit; + } + return std::cos(in_angle * kRadiansPerDegree); +} + +/** @brief Returns the base 2 logarithm of a range, with the range raised to at least kMinRange. */ +float LogRange(float in_range) +{ + return std::log2(MaxOf(in_range, kMinRange)); +} + +/** @brief Mixes the bits of an input key for the slot table. */ +uint64_t MixKey(uint64_t in_key) +{ + uint64_t mixed = in_key + 0x9E3779B97F4A7C15ull; + mixed = (mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9ull; + mixed = (mixed ^ (mixed >> 27)) * 0x94D049BB133111EBull; + return mixed ^ (mixed >> 31); +} + +/** @brief Returns the index of the lowest set bit of a nonzero word. */ +uint32_t LowestSetBit(uint64_t in_word) +{ + uint32_t bit = 0; + if ((in_word & 0xFFFFFFFFull) == 0) + { + bit += 32; + in_word >>= 32; + } + if ((in_word & 0xFFFFull) == 0) + { + bit += 16; + in_word >>= 16; + } + if ((in_word & 0xFFull) == 0) + { + bit += 8; + in_word >>= 8; + } + if ((in_word & 0xFull) == 0) + { + bit += 4; + in_word >>= 4; + } + if ((in_word & 0x3ull) == 0) + { + bit += 2; + in_word >>= 2; + } + if ((in_word & 0x1ull) == 0) + { + bit += 1; + } + return bit; +} + +/** @brief Clears and returns the lowest set bit of a bit set, or kNone if no bit is set. */ +uint32_t TakeLowestFree(uint64_t* io_pWords, uint32_t in_capacity) +{ + const uint32_t wordCount = (in_capacity + 63u) / 64u; + for (uint32_t word = 0; word < wordCount; ++word) + { + if (io_pWords[word] != 0) + { + const uint32_t bit = LowestSetBit(io_pWords[word]); + io_pWords[word] &= ~(1ull << bit); + return word * 64u + bit; + } + } + return kNone; +} + +/** @brief Sets one bit of a bit set. */ +void MarkFree(uint64_t* io_pWords, uint32_t in_index) +{ + io_pWords[in_index / 64u] |= 1ull << (in_index % 64u); +} + +/** @brief Sorts items in place with a heap sort, using a function that returns true if the first item comes before the second. */ +template +void HeapSort(Item* io_pItems, uint32_t in_count, IsBefore in_isBefore) +{ + auto siftDown = [io_pItems, &in_isBefore](uint32_t in_root, uint32_t in_end) { + uint32_t root = in_root; + for (;;) + { + const uint32_t left = root * 2u + 1u; + if (left >= in_end) + { + return; + } + uint32_t largest = left; + const uint32_t right = left + 1u; + if (right < in_end && in_isBefore(io_pItems[left], io_pItems[right])) + { + largest = right; + } + if (!in_isBefore(io_pItems[root], io_pItems[largest])) + { + return; + } + const Item swap = io_pItems[root]; + io_pItems[root] = io_pItems[largest]; + io_pItems[largest] = swap; + root = largest; + } + }; + if (in_count < 2) + { + return; + } + for (uint32_t start = in_count / 2u; start-- > 0;) + { + siftDown(start, in_count); + } + for (uint32_t end = in_count - 1u; end > 0; --end) + { + const Item swap = io_pItems[0]; + io_pItems[0] = io_pItems[end]; + io_pItems[end] = swap; + siftDown(0, end); + } +} + +/** @brief Sorts indices by the values they refer to, with equal values in index order. */ +void SortIndicesByValue(uint32_t* io_pItems, uint32_t in_count, const float* in_pValues) +{ + HeapSort(io_pItems, in_count, [in_pValues](uint32_t in_first, uint32_t in_second) { + return in_pValues[in_first] < in_pValues[in_second] || + (in_pValues[in_first] == in_pValues[in_second] && in_first < in_second); + }); +} + +/** @brief Returns the smallest power of two that is not less than a value. */ +uint32_t NextPowerOfTwo(uint32_t in_value) +{ + uint32_t power = 1; + while (power < in_value) + { + power <<= 1; + } + return power; +} + +/** @brief Adds an aligned array of the given size to a block layout and returns its offset. */ +size_t TakeBytes(size_t& io_size, size_t in_bytes) +{ + const size_t offset = io_size; + io_size = (io_size + in_bytes + kBlockAlignment - 1) & ~(kBlockAlignment - 1); + return offset; +} + +/** @brief Returns the bit pattern of a float. */ +uint32_t FloatBits(float in_value) +{ + uint32_t bits = 0; + memcpy(&bits, &in_value, sizeof(bits)); + return bits; +} + +/** @brief Returns true if a float is neither infinite nor NaN. */ +bool IsFiniteFloat(float in_value) +{ + return (FloatBits(in_value) & 0x7F800000u) != 0x7F800000u; +} + +/** @brief Returns true if a float is positive infinity. */ +bool IsPositiveInfinity(float in_value) +{ + return FloatBits(in_value) == 0x7F800000u; +} + +/** @brief Returns true if a position has only finite coordinates. */ +bool IsFinitePosition(const ClusterVec3& in_position) +{ + return IsFiniteFloat(in_position.x) && IsFiniteFloat(in_position.y) && IsFiniteFloat(in_position.z); +} + +/** @brief Returns a parameter limited to a range, or a fallback value if it is not finite. */ +double ReadParam(float in_value, double in_fallback, double in_low, double in_high) +{ + if (!IsFiniteFloat(in_value)) + { + return in_fallback; + } + return MinOf(MaxOf(static_cast(in_value), in_low), in_high); +} + +/** @brief Returns the merge class of a cluster, which combines its group and centred flag. */ +uint64_t MergeClass(uint32_t in_group, bool in_centred) +{ + return static_cast(in_group) | (in_centred ? kCentredClassBit : 0ull); +} +} + +struct ClusterEngine::Settings +{ + double joinAngle; ///< Join angle in degrees. + double leaveAngle; ///< Angle in degrees beyond which a home input starts to leave its cluster. + double cosJoin; ///< Cosine of the join angle. + double cosLeave; ///< Cosine of the leave angle. + double distanceScale; ///< Degrees added to the angle per doubling of distance. + double nearFieldRadius; ///< Inputs closer to the listener than this distance are excluded; 0 excludes none. + float fadeStep; ///< Gain change per frame of a crossfade. + float loudnessSmoothing; ///< Per-frame smoothing coefficient of the input loudness. + uint32_t dwellFrames; ///< Frames a change must last before a home input leaves or two clusters merge. + uint32_t maxOutputs; ///< Maximum number of outputs; 0 means no limit. + uint32_t excludePriorityAbove; ///< Inputs with a higher priority are excluded. + bool loudnessWeighting; ///< Weights cluster positions by input loudness. +}; + +struct ClusterEngine::Slot +{ + uint64_t key; ///< Key of the input. + float dirX; ///< Direction X of the input from the listener. + float dirY; ///< Direction Y of the input from the listener. + float dirZ; ///< Direction Z of the input from the listener. + float range; ///< Distance of the input from the listener. + float logRange; ///< Base 2 logarithm of the range, or 0 when distance does not count. + float loudness; ///< Smoothed loudness of the input. + float weight; ///< Weight of the input in cluster positions. + float fadeStart; ///< Gain in the home cluster at the start of the frame. + float fadeEnd; ///< Gain in the home cluster at the end of the frame. + uint32_t inputIndex; ///< Index of the input in this frame's input array. + uint32_t seenFrame; ///< Last update in which the input was present. + uint32_t placedFrame; ///< Last update in which the input was placed as a new input. + uint32_t priority; ///< Playback priority of the input. + uint32_t group; ///< Group of the input. + uint32_t leaveFrames; ///< Consecutive updates in which the input was outside its home cluster. + uint32_t primaryLink; ///< Which of the two nodes of the slot, 0 or 1, links the input to its home cluster. + bool active; ///< True while the slot holds an input. + bool fresh; ///< True until the loudness of the input has been read once. + bool clusterable; ///< False for passthrough inputs. + bool excluded; ///< True for inputs that keep a cluster of their own. + bool centred; ///< True for inputs too close to the listener to have a direction. + bool fading; ///< True while the input fades from its previous cluster into its home cluster. + bool pending; ///< True while the input waits to be placed in this update. +}; + +struct ClusterEngine::Node +{ + uint32_t cluster; ///< Cluster the node is linked into, or kNone. + uint32_t prev; ///< Previous node in the list of the cluster, or kNone. + uint32_t next; ///< Next node in the list of the cluster, or kNone. +}; + +struct ClusterEngine::Cluster +{ + float dirX; ///< Direction X of the cluster from the listener. + float dirY; ///< Direction Y of the cluster from the listener. + float dirZ; ///< Direction Z of the cluster from the listener. + float logRange; ///< Base 2 logarithm of the range of the cluster. + uint32_t group; ///< Group of the inputs the cluster takes. + bool used; ///< True while the cluster ID is taken. + bool dissolving; ///< True once the cluster has no home inputs or merges into another cluster. + bool excluded; ///< True if a home input is excluded, so that no other input joins and the cluster never merges. + bool centred; ///< True if the cluster takes inputs with no direction. + bool reported; ///< True once the cluster has been reported. + bool busy; ///< True once the cluster took part in a merge in this update. + bool changed; ///< True if the inputs of the cluster changed since it was last refreshed. + float range; ///< Weighted average distance of the inputs from the listener. + float radius; ///< Largest angle in degrees between a contributing input and the cluster direction. + float homeRadius; ///< Largest angle in degrees between a home input and the cluster direction. + uint32_t head; ///< First node in the list of the cluster, or kNone. + uint32_t tail; ///< Last node in the list of the cluster, or kNone. + uint32_t nodeCount; ///< Number of contributing inputs. + uint32_t homeCount; ///< Number of inputs whose home is this cluster. + uint32_t priority; ///< Highest priority among the contributing inputs. + uint32_t generation; ///< Counter that tells apart the clusters that used this ID. + uint32_t mergeTarget; ///< Cluster this cluster merges into, or kNone. + uint32_t mergeTargetGeneration; ///< Generation of the merge target when the merge started. + uint32_t candidate; ///< Nearest mergeable cluster in recent updates, or kNone. + uint32_t candidateGeneration; ///< Generation of the candidate. + uint32_t candidateFrames; ///< Consecutive updates in which the candidate was the nearest mergeable cluster. + uint32_t representative; ///< Input index of the contributing input with the largest weighted gain. + double sumX; ///< Sum of direction X of the inputs, each times its gain and weight. + double sumY; ///< Sum of direction Y of the inputs, each times its gain and weight. + double sumZ; ///< Sum of direction Z of the inputs, each times its gain and weight. + double sumRange; ///< Sum of the ranges of the inputs, each times its gain and weight. + double sumWeight; ///< Sum of the gains times the weights of the inputs. +}; + +struct ClusterEngine::HashEntry +{ + uint64_t key; ///< Input key. + uint32_t slot; ///< Slot that holds the input, or kNone for an empty entry. +}; + +struct ClusterEngine::PendingEntry +{ + uint64_t key; ///< Input key. + uint32_t index; ///< Input index before slots are assigned, slot index after. +}; + +struct ClusterEngine::BudgetEntry +{ + double cost; ///< Budget cost of the merge, or a lower bound of it. + uint32_t index; ///< Listed cluster the entry belongs to. + uint32_t version; ///< Version of the listed cluster when the entry was pushed. +}; + +ClusterEngine::ClusterEngine() = default; + +ClusterEngine::~ClusterEngine() +{ + Term(); +} + +bool ClusterEngine::Init(ClusterAllocator* in_pAllocator, uint32_t in_reserveInputs) +{ + Term(); + if (in_pAllocator == nullptr || in_reserveInputs > kMaxCapacity) + { + return false; + } + m_pAllocator = in_pAllocator; + if (in_reserveInputs == 0) + { + return true; + } + return Reserve(in_reserveInputs, in_reserveInputs * kClustersPerSlot); +} + +void ClusterEngine::Term() +{ + if (m_pBlock != nullptr && m_pAllocator != nullptr) + { + m_pAllocator->Free(m_pBlock); + } + m_pAllocator = nullptr; + m_pBlock = nullptr; + m_pSlots = nullptr; + m_pNodes = nullptr; + m_pHash = nullptr; + m_pFreeSlots = nullptr; + m_pPending = nullptr; + m_pContributions = nullptr; + m_pClusters = nullptr; + m_pFreeClusters = nullptr; + m_pReleasedClusters = nullptr; + m_pInfos = nullptr; + m_pScratchIds = nullptr; + m_pScratchX = nullptr; + m_pScratchY = nullptr; + m_pScratchZ = nullptr; + m_pScratchLogRange = nullptr; + m_pScratchClass = nullptr; + m_pScratchPartner = nullptr; + m_pScratchOrder = nullptr; + m_pScratchCost = nullptr; + m_pScratchAlive = nullptr; + m_pScratchBound = nullptr; + m_pScratchCosLimit = nullptr; + m_pScratchVersion = nullptr; + m_pBudgetHeap = nullptr; + m_slotCapacity = 0; + m_clusterCapacity = 0; + m_hashMask = 0; + ClearState(); +} + +void ClusterEngine::Reset() +{ + ClearState(); +} + +bool ClusterEngine::Update(const ClusterInput* in_pInputs, uint32_t in_numInputs, const ClusterParams& in_params) +{ + if (in_numInputs > 0 && in_pInputs == nullptr) + { + return false; + } + if (!EnsureCapacity(in_numInputs)) + { + return false; + } + + Settings settings; + MakeSettings(in_params, settings); + ++m_frame; + m_overBudget = false; + + MapInputs(in_pInputs, in_numInputs); + UpdateSlots(in_pInputs, settings); + AdvanceFades(settings); + RefreshClusters(); + ProcessLeaves(settings); + PlacePendingInputs(settings); + MergeNearbyClusters(settings); + EnforceBudget(settings); + BuildReport(); + FlushReleasedClusters(); + return true; +} + +uint32_t ClusterEngine::GetClusterCount() const +{ + return m_infoCount; +} + +const ClusterInfo& ClusterEngine::GetCluster(uint32_t in_index) const +{ + static const ClusterInfo s_empty = {}; + return in_index < m_infoCount ? m_pInfos[in_index] : s_empty; +} + +uint32_t ClusterEngine::GetContributionCount() const +{ + return m_contributionCount; +} + +const ClusterContribution& ClusterEngine::GetContribution(uint32_t in_index) const +{ + static const ClusterContribution s_empty = {}; + return in_index < m_contributionCount ? m_pContributions[in_index] : s_empty; +} + +bool ClusterEngine::IsOverBudget() const +{ + return m_overBudget; +} + +void ClusterEngine::MakeSettings(const ClusterParams& in_params, Settings& out_settings) +{ + const double joinAngle = ReadParam(in_params.joinAngle, 0.0, 0.0, 180.0); + const double leaveRatio = ReadParam(in_params.leaveRatio, 1.0, 1.0, kLeaveRatioCap); + const double distanceWeight = ReadParam(in_params.distanceWeight, 0.0, 0.0, kDistanceWeightCap); + const double nearFieldRadius = IsPositiveInfinity(in_params.nearFieldRadius) + ? DBL_MAX + : ReadParam(in_params.nearFieldRadius, 0.0, 0.0, FLT_MAX); + const float smoothing = static_cast(ReadParam(in_params.loudnessSmoothing, 0.0, 0.0, kMaxSmoothing)); + + out_settings.joinAngle = joinAngle; + out_settings.leaveAngle = joinAngle * leaveRatio; + out_settings.cosJoin = CosineLimit(joinAngle); + out_settings.cosLeave = CosineLimit(out_settings.leaveAngle); + out_settings.distanceScale = distanceWeight * joinAngle; + out_settings.nearFieldRadius = nearFieldRadius; + out_settings.fadeStep = 1.f / static_cast(MaxOf(in_params.crossfadeFrames, 1u)); + out_settings.loudnessSmoothing = smoothing; + out_settings.dwellFrames = in_params.dwellFrames; + out_settings.maxOutputs = in_params.maxOutputs; + out_settings.excludePriorityAbove = in_params.excludePriorityAbove; + out_settings.loudnessWeighting = in_params.loudnessWeighting; +} + +bool ClusterEngine::EnsureCapacity(uint32_t in_numInputs) +{ + if (in_numInputs > kMaxCapacity) + { + return false; + } + if (in_numInputs <= m_slotCapacity && m_liveClusters + in_numInputs <= m_clusterCapacity) + { + return true; + } + + uint32_t slotCapacity = m_slotCapacity; + if (in_numInputs > slotCapacity) + { + slotCapacity = MaxOf(slotCapacity * 2u, kMinGrowth); + while (slotCapacity < in_numInputs) + { + slotCapacity *= 2u; + } + } + uint32_t clusterCapacity = MaxOf(m_clusterCapacity, slotCapacity * kClustersPerSlot); + if (m_liveClusters + in_numInputs > clusterCapacity) + { + clusterCapacity = MaxOf(clusterCapacity * 2u, m_liveClusters + in_numInputs); + } + return Reserve(slotCapacity, clusterCapacity); +} + +bool ClusterEngine::Reserve(uint32_t in_slotCapacity, uint32_t in_clusterCapacity) +{ + if (m_pAllocator == nullptr) + { + return false; + } + + const uint32_t hashCapacity = NextPowerOfTwo(MaxOf(in_slotCapacity * 2u, kMinGrowth)); + const uint32_t slotWords = (in_slotCapacity + 63u) / 64u; + const uint32_t clusterWords = (in_clusterCapacity + 63u) / 64u; + const size_t nodeCount = static_cast(in_slotCapacity) * 2u; + + size_t size = 0; + const size_t slotsAt = TakeBytes(size, sizeof(Slot) * in_slotCapacity); + const size_t nodesAt = TakeBytes(size, sizeof(Node) * nodeCount); + const size_t hashAt = TakeBytes(size, sizeof(HashEntry) * hashCapacity); + const size_t freeSlotsAt = TakeBytes(size, sizeof(uint64_t) * slotWords); + const size_t pendingAt = TakeBytes(size, sizeof(PendingEntry) * in_slotCapacity); + const size_t contributionsAt = TakeBytes(size, sizeof(ClusterContribution) * nodeCount); + const size_t clustersAt = TakeBytes(size, sizeof(Cluster) * in_clusterCapacity); + const size_t freeClustersAt = TakeBytes(size, sizeof(uint64_t) * clusterWords); + const size_t releasedAt = TakeBytes(size, sizeof(uint32_t) * in_clusterCapacity); + const size_t infosAt = TakeBytes(size, sizeof(ClusterInfo) * in_clusterCapacity); + const size_t scratchIdsAt = TakeBytes(size, sizeof(uint32_t) * in_clusterCapacity); + const size_t scratchXAt = TakeBytes(size, sizeof(float) * in_clusterCapacity); + const size_t scratchYAt = TakeBytes(size, sizeof(float) * in_clusterCapacity); + const size_t scratchZAt = TakeBytes(size, sizeof(float) * in_clusterCapacity); + const size_t scratchLogRangeAt = TakeBytes(size, sizeof(float) * in_clusterCapacity); + const size_t scratchClassAt = TakeBytes(size, sizeof(uint64_t) * in_clusterCapacity); + const size_t scratchPartnerAt = TakeBytes(size, sizeof(uint32_t) * in_clusterCapacity); + const size_t scratchOrderAt = TakeBytes(size, sizeof(uint32_t) * in_clusterCapacity); + const size_t scratchCostAt = TakeBytes(size, sizeof(double) * in_clusterCapacity); + const size_t scratchAliveAt = TakeBytes(size, sizeof(uint8_t) * in_clusterCapacity); + const size_t scratchBoundAt = TakeBytes(size, sizeof(uint8_t) * in_clusterCapacity); + const size_t scratchCosLimitAt = TakeBytes(size, sizeof(double) * in_clusterCapacity); + const size_t scratchVersionAt = TakeBytes(size, sizeof(uint32_t) * in_clusterCapacity); + const size_t budgetHeapAt = TakeBytes(size, sizeof(BudgetEntry) * in_clusterCapacity); + + void* pBlock = m_pAllocator->Alloc(size); + if (pBlock == nullptr) + { + return false; + } + memset(pBlock, 0, size); + char* pBase = static_cast(pBlock); + + Slot* pSlots = reinterpret_cast(pBase + slotsAt); + Node* pNodes = reinterpret_cast(pBase + nodesAt); + Cluster* pClusters = reinterpret_cast(pBase + clustersAt); + if (m_slotCapacity > 0) + { + memcpy(pSlots, m_pSlots, sizeof(Slot) * m_slotCapacity); + memcpy(pNodes, m_pNodes, sizeof(Node) * m_slotCapacity * 2u); + } + for (size_t node = static_cast(m_slotCapacity) * 2u; node < nodeCount; ++node) + { + pNodes[node].cluster = kNone; + pNodes[node].prev = kNone; + pNodes[node].next = kNone; + } + if (m_clusterCapacity > 0) + { + memcpy(pClusters, m_pClusters, sizeof(Cluster) * m_clusterCapacity); + } + + void* pOldBlock = m_pBlock; + m_pBlock = pBlock; + m_pSlots = pSlots; + m_pNodes = pNodes; + m_pHash = reinterpret_cast(pBase + hashAt); + m_pFreeSlots = reinterpret_cast(pBase + freeSlotsAt); + m_pPending = reinterpret_cast(pBase + pendingAt); + m_pContributions = reinterpret_cast(pBase + contributionsAt); + m_pClusters = pClusters; + m_pFreeClusters = reinterpret_cast(pBase + freeClustersAt); + m_pReleasedClusters = reinterpret_cast(pBase + releasedAt); + m_pInfos = reinterpret_cast(pBase + infosAt); + m_pScratchIds = reinterpret_cast(pBase + scratchIdsAt); + m_pScratchX = reinterpret_cast(pBase + scratchXAt); + m_pScratchY = reinterpret_cast(pBase + scratchYAt); + m_pScratchZ = reinterpret_cast(pBase + scratchZAt); + m_pScratchLogRange = reinterpret_cast(pBase + scratchLogRangeAt); + m_pScratchClass = reinterpret_cast(pBase + scratchClassAt); + m_pScratchPartner = reinterpret_cast(pBase + scratchPartnerAt); + m_pScratchOrder = reinterpret_cast(pBase + scratchOrderAt); + m_pScratchCost = reinterpret_cast(pBase + scratchCostAt); + m_pScratchAlive = reinterpret_cast(pBase + scratchAliveAt); + m_pScratchBound = reinterpret_cast(pBase + scratchBoundAt); + m_pScratchCosLimit = reinterpret_cast(pBase + scratchCosLimitAt); + m_pScratchVersion = reinterpret_cast(pBase + scratchVersionAt); + m_pBudgetHeap = reinterpret_cast(pBase + budgetHeapAt); + m_budgetHeapSize = 0; + m_slotCapacity = in_slotCapacity; + m_clusterCapacity = in_clusterCapacity; + m_hashMask = hashCapacity - 1u; + m_infoCount = 0; + m_contributionCount = 0; + + for (uint32_t entry = 0; entry < hashCapacity; ++entry) + { + m_pHash[entry].slot = kNone; + } + for (uint32_t slot = 0; slot < m_slotEnd; ++slot) + { + if (m_pSlots[slot].active) + { + InsertSlotKey(m_pSlots[slot].key, slot); + } + } + RebuildFreeSlots(); + RebuildFreeClusters(); + + if (pOldBlock != nullptr) + { + m_pAllocator->Free(pOldBlock); + } + return true; +} + +void ClusterEngine::ClearState() +{ + if (m_slotCapacity > 0) + { + memset(m_pSlots, 0, sizeof(Slot) * m_slotCapacity); + for (uint32_t node = 0; node < m_slotCapacity * 2u; ++node) + { + m_pNodes[node].cluster = kNone; + m_pNodes[node].prev = kNone; + m_pNodes[node].next = kNone; + } + for (uint32_t entry = 0; entry <= m_hashMask; ++entry) + { + m_pHash[entry].slot = kNone; + } + } + if (m_clusterCapacity > 0) + { + memset(m_pClusters, 0, sizeof(Cluster) * m_clusterCapacity); + } + m_slotEnd = 0; + m_clusterEnd = 0; + m_liveClusters = 0; + m_activeClusters = 0; + m_releasedCount = 0; + m_pendingCount = 0; + m_passthroughCount = 0; + m_infoCount = 0; + m_contributionCount = 0; + m_frame = 0; + m_overBudget = false; + RebuildFreeSlots(); + RebuildFreeClusters(); +} + +void ClusterEngine::RebuildFreeSlots() +{ + const uint32_t wordCount = (m_slotCapacity + 63u) / 64u; + for (uint32_t word = 0; word < wordCount; ++word) + { + m_pFreeSlots[word] = 0; + } + for (uint32_t slot = 0; slot < m_slotCapacity; ++slot) + { + if (!m_pSlots[slot].active) + { + MarkFree(m_pFreeSlots, slot); + } + } +} + +void ClusterEngine::RebuildFreeClusters() +{ + const uint32_t wordCount = (m_clusterCapacity + 63u) / 64u; + for (uint32_t word = 0; word < wordCount; ++word) + { + m_pFreeClusters[word] = 0; + } + for (uint32_t cluster = 0; cluster < m_clusterCapacity; ++cluster) + { + if (!m_pClusters[cluster].used) + { + MarkFree(m_pFreeClusters, cluster); + } + } +} + +uint32_t ClusterEngine::FindSlot(uint64_t in_key) const +{ + if (m_pHash == nullptr) + { + return kNone; + } + uint32_t entry = static_cast(MixKey(in_key)) & m_hashMask; + while (m_pHash[entry].slot != kNone) + { + if (m_pHash[entry].key == in_key) + { + return m_pHash[entry].slot; + } + entry = (entry + 1u) & m_hashMask; + } + return kNone; +} + +void ClusterEngine::InsertSlotKey(uint64_t in_key, uint32_t in_slot) +{ + uint32_t entry = static_cast(MixKey(in_key)) & m_hashMask; + while (m_pHash[entry].slot != kNone) + { + entry = (entry + 1u) & m_hashMask; + } + m_pHash[entry].key = in_key; + m_pHash[entry].slot = in_slot; +} + +void ClusterEngine::RemoveSlotKey(uint64_t in_key) +{ + uint32_t hole = static_cast(MixKey(in_key)) & m_hashMask; + while (m_pHash[hole].slot != kNone && m_pHash[hole].key != in_key) + { + hole = (hole + 1u) & m_hashMask; + } + if (m_pHash[hole].slot == kNone) + { + return; + } + uint32_t entry = hole; + for (;;) + { + entry = (entry + 1u) & m_hashMask; + if (m_pHash[entry].slot == kNone) + { + break; + } + const uint32_t home = static_cast(MixKey(m_pHash[entry].key)) & m_hashMask; + const uint32_t distanceFromHome = (entry - home) & m_hashMask; + const uint32_t distanceFromHole = (entry - hole) & m_hashMask; + if (distanceFromHome >= distanceFromHole) + { + m_pHash[hole] = m_pHash[entry]; + hole = entry; + } + } + m_pHash[hole].slot = kNone; +} + +uint32_t ClusterEngine::AllocateSlot() +{ + const uint32_t slot = TakeLowestFree(m_pFreeSlots, m_slotCapacity); + if (slot != kNone && slot >= m_slotEnd) + { + m_slotEnd = slot + 1u; + } + return slot; +} + +void ClusterEngine::FreeSlot(uint32_t in_slot) +{ + m_pSlots[in_slot].active = false; + MarkFree(m_pFreeSlots, in_slot); + while (m_slotEnd > 0 && !m_pSlots[m_slotEnd - 1u].active) + { + --m_slotEnd; + } +} + +uint32_t ClusterEngine::AllocateCluster() +{ + const uint32_t cluster = TakeLowestFree(m_pFreeClusters, m_clusterCapacity); + if (cluster != kNone && cluster >= m_clusterEnd) + { + m_clusterEnd = cluster + 1u; + } + return cluster; +} + +void ClusterEngine::ReleaseCluster(uint32_t in_cluster) +{ + Cluster& cluster = m_pClusters[in_cluster]; + if (!cluster.dissolving) + { + --m_activeClusters; + } + cluster.used = false; + cluster.dissolving = false; + --m_liveClusters; + m_pReleasedClusters[m_releasedCount++] = in_cluster; +} + +void ClusterEngine::FlushReleasedClusters() +{ + for (uint32_t index = 0; index < m_releasedCount; ++index) + { + MarkFree(m_pFreeClusters, m_pReleasedClusters[index]); + } + m_releasedCount = 0; + while (m_clusterEnd > 0 && !m_pClusters[m_clusterEnd - 1u].used) + { + --m_clusterEnd; + } +} + +void ClusterEngine::MapInputs(const ClusterInput* in_pInputs, uint32_t in_numInputs) +{ + m_pendingCount = 0; + for (uint32_t inputIndex = 0; inputIndex < in_numInputs; ++inputIndex) + { + const uint64_t key = in_pInputs[inputIndex].key; + const uint32_t slotIndex = FindSlot(key); + if (slotIndex == kNone) + { + m_pPending[m_pendingCount].key = key; + m_pPending[m_pendingCount].index = inputIndex; + ++m_pendingCount; + continue; + } + Slot& slot = m_pSlots[slotIndex]; + if (slot.seenFrame == m_frame) + { + continue; + } + slot.seenFrame = m_frame; + slot.inputIndex = inputIndex; + } + + for (uint32_t slotIndex = 0; slotIndex < m_slotEnd; ++slotIndex) + { + Slot& slot = m_pSlots[slotIndex]; + if (!slot.active) + { + continue; + } + if (slot.seenFrame != m_frame) + { + ReleaseSlot(slotIndex); + continue; + } + const ClusterInput& input = in_pInputs[slot.inputIndex]; + slot.group = input.group; + if (slot.clusterable != input.clusterable) + { + DetachSlot(slotIndex); + slot.clusterable = input.clusterable; + if (slot.clusterable) + { + m_pPending[m_pendingCount].key = slot.key; + m_pPending[m_pendingCount].index = slot.inputIndex; + ++m_pendingCount; + } + } + } + + SortPendingByKey(); + AssignPendingSlots(in_pInputs); +} + +void ClusterEngine::SortPendingByKey() +{ + HeapSort(m_pPending, m_pendingCount, [](const PendingEntry& in_first, const PendingEntry& in_second) { + return in_first.key < in_second.key || (in_first.key == in_second.key && in_first.index < in_second.index); + }); +} + +void ClusterEngine::AssignPendingSlots(const ClusterInput* in_pInputs) +{ + uint32_t written = 0; + bool hasPrevious = false; + uint64_t previousKey = 0; + for (uint32_t read = 0; read < m_pendingCount; ++read) + { + const uint32_t inputIndex = m_pPending[read].index; + const ClusterInput& input = in_pInputs[inputIndex]; + if (hasPrevious && input.key == previousKey) + { + continue; + } + hasPrevious = true; + previousKey = input.key; + + uint32_t slotIndex = FindSlot(input.key); + if (slotIndex == kNone) + { + slotIndex = AllocateSlot(); + InitSlot(slotIndex, inputIndex, input); + InsertSlotKey(input.key, slotIndex); + } + Slot& slot = m_pSlots[slotIndex]; + if (!slot.clusterable) + { + continue; + } + slot.pending = true; + m_pPending[written].key = slot.key; + m_pPending[written].index = slotIndex; + ++written; + } + m_pendingCount = written; +} + +void ClusterEngine::InitSlot(uint32_t in_slot, uint32_t in_inputIndex, const ClusterInput& in_input) +{ + Slot& slot = m_pSlots[in_slot]; + memset(&slot, 0, sizeof(Slot)); + slot.key = in_input.key; + slot.dirZ = 1.f; + slot.fadeStart = 1.f; + slot.fadeEnd = 1.f; + slot.inputIndex = in_inputIndex; + slot.seenFrame = m_frame; + slot.priority = in_input.priority; + slot.group = in_input.group; + slot.active = true; + slot.fresh = true; + slot.clusterable = in_input.clusterable; +} + +void ClusterEngine::ReleaseSlot(uint32_t in_slot) +{ + DetachSlot(in_slot); + RemoveSlotKey(m_pSlots[in_slot].key); + FreeSlot(in_slot); +} + +void ClusterEngine::DetachSlot(uint32_t in_slot) +{ + Slot& slot = m_pSlots[in_slot]; + const uint32_t primaryNode = GetPrimaryNode(in_slot); + if (m_pNodes[primaryNode].cluster != kNone) + { + DropHomeInput(m_pNodes[primaryNode].cluster); + DetachNode(primaryNode); + } + if (slot.fading) + { + DetachNode(GetSourceNode(in_slot)); + } + slot.fading = false; + slot.pending = false; + slot.fadeStart = 1.f; + slot.fadeEnd = 1.f; + slot.leaveFrames = 0; +} + +void ClusterEngine::UpdateSlots(const ClusterInput* in_pInputs, const Settings& in_settings) +{ + m_passthroughCount = 0; + for (uint32_t slotIndex = 0; slotIndex < m_slotEnd; ++slotIndex) + { + Slot& slot = m_pSlots[slotIndex]; + if (!slot.active) + { + continue; + } + const ClusterInput& input = in_pInputs[slot.inputIndex]; + slot.priority = input.priority; + if (!slot.clusterable) + { + ++m_passthroughCount; + continue; + } + + const bool finite = IsFinitePosition(input.position); + double range = 0.0; + slot.centred = false; + if (finite) + { + const double x = input.position.x; + const double y = input.position.y; + const double z = input.position.z; + range = std::sqrt(x * x + y * y + z * z); + if (range < kMinRange) + { + slot.centred = true; + slot.dirX = 0.f; + slot.dirY = 0.f; + slot.dirZ = 1.f; + } + else + { + slot.dirX = static_cast(x / range); + slot.dirY = static_cast(y / range); + slot.dirZ = static_cast(z / range); + } + } + slot.range = static_cast(MinOf(range, static_cast(FLT_MAX))); + slot.logRange = in_settings.distanceScale > 0.0 ? LogRange(slot.range) : 0.f; + + const float loudness = IsFiniteFloat(input.loudness) ? MinOf(MaxOf(input.loudness, 0.f), kMaxLoudness) : 0.f; + if (slot.fresh) + { + slot.loudness = loudness; + slot.fresh = false; + } + else + { + const float smoothing = in_settings.loudnessSmoothing; + slot.loudness = smoothing * slot.loudness + (1.f - smoothing) * loudness; + } + slot.weight = in_settings.loudnessWeighting ? slot.loudness + kLoudnessFloor : 1.f; + const bool nearField = in_settings.nearFieldRadius > 0.0 && range < in_settings.nearFieldRadius; + slot.excluded = !finite || nearField || slot.priority > in_settings.excludePriorityAbove; + } +} + +void ClusterEngine::AdvanceFades(const Settings& in_settings) +{ + for (uint32_t slotIndex = 0; slotIndex < m_slotEnd; ++slotIndex) + { + Slot& slot = m_pSlots[slotIndex]; + if (!slot.active || !slot.fading) + { + continue; + } + if (slot.fadeEnd >= 1.f) + { + DetachNode(GetSourceNode(slotIndex)); + slot.fading = false; + slot.fadeStart = 1.f; + slot.fadeEnd = 1.f; + continue; + } + slot.fadeStart = slot.fadeEnd; + slot.fadeEnd = MinOf(1.f, slot.fadeEnd + in_settings.fadeStep); + } +} + +void ClusterEngine::RefreshClusters() +{ + for (uint32_t cluster = 0; cluster < m_clusterEnd; ++cluster) + { + if (m_pClusters[cluster].used) + { + RefreshCluster(cluster); + } + } +} + +void ClusterEngine::RefreshCluster(uint32_t in_cluster) +{ + Cluster& cluster = m_pClusters[in_cluster]; + double sumX = 0.0; + double sumY = 0.0; + double sumZ = 0.0; + double sumRange = 0.0; + double sumWeight = 0.0; + uint32_t priority = 0; + bool excluded = false; + uint32_t representative = kNone; + double bestLoudnessEnd = -1.0; + double bestLoudnessStart = -1.0; + + for (uint32_t node = cluster.head; node != kNone; node = m_pNodes[node].next) + { + const Slot& slot = m_pSlots[node >> 1u]; + float gainStart = 0.f; + float gainEnd = 0.f; + GetNodeGains(node, gainStart, gainEnd); + const double share = static_cast(gainEnd) * slot.weight; + sumX += share * slot.dirX; + sumY += share * slot.dirY; + sumZ += share * slot.dirZ; + sumRange += share * slot.range; + sumWeight += share; + priority = MaxOf(priority, slot.priority); + excluded = excluded || (slot.excluded && IsPrimaryNode(node)); + + const double shareStart = static_cast(gainStart) * slot.weight; + if (share > bestLoudnessEnd || (share == bestLoudnessEnd && shareStart > bestLoudnessStart)) + { + bestLoudnessEnd = share; + bestLoudnessStart = shareStart; + representative = slot.inputIndex; + } + } + + cluster.sumX = sumX; + cluster.sumY = sumY; + cluster.sumZ = sumZ; + cluster.sumRange = sumRange; + cluster.sumWeight = sumWeight; + ApplySums(cluster); + + float minDot = 1.f; + float minHomeDot = 1.f; + for (uint32_t node = cluster.head; node != kNone; node = m_pNodes[node].next) + { + const Slot& slot = m_pSlots[node >> 1u]; + const float dot = slot.dirX * cluster.dirX + slot.dirY * cluster.dirY + slot.dirZ * cluster.dirZ; + minDot = MinOf(minDot, dot); + if (IsPrimaryNode(node)) + { + minHomeDot = MinOf(minHomeDot, dot); + } + } + cluster.radius = static_cast(AngleFromDot(minDot)); + cluster.homeRadius = minHomeDot == minDot ? cluster.radius : static_cast(AngleFromDot(minHomeDot)); + cluster.priority = priority; + cluster.excluded = excluded; + cluster.representative = representative; + cluster.changed = false; +} + +void ClusterEngine::ApplySums(Cluster& io_cluster) +{ + if (!(io_cluster.sumWeight > 0.0)) + { + return; + } + const double length = std::sqrt(io_cluster.sumX * io_cluster.sumX + io_cluster.sumY * io_cluster.sumY + + io_cluster.sumZ * io_cluster.sumZ); + if (length > kCancelRatio * io_cluster.sumWeight) + { + io_cluster.dirX = static_cast(io_cluster.sumX / length); + io_cluster.dirY = static_cast(io_cluster.sumY / length); + io_cluster.dirZ = static_cast(io_cluster.sumZ / length); + } + const double range = MaxOf(io_cluster.sumRange / io_cluster.sumWeight, 0.0); + io_cluster.range = static_cast(MinOf(range, static_cast(FLT_MAX))); + io_cluster.logRange = LogRange(io_cluster.range); +} + +void ClusterEngine::ProcessLeaves(const Settings& in_settings) +{ + for (uint32_t slotIndex = 0; slotIndex < m_slotEnd; ++slotIndex) + { + Slot& slot = m_pSlots[slotIndex]; + if (!slot.active || !slot.clusterable || slot.pending || slot.fading) + { + continue; + } + const uint32_t clusterIndex = m_pNodes[GetPrimaryNode(slotIndex)].cluster; + if (clusterIndex == kNone) + { + continue; + } + Cluster& cluster = m_pClusters[clusterIndex]; + if (cluster.dissolving) + { + StartMove(slotIndex, FindFollowDestination(slotIndex, clusterIndex, in_settings), in_settings); + continue; + } + if (slot.group != cluster.group) + { + if (cluster.nodeCount > 1) + { + StartMove(slotIndex, FindPlacement(slotIndex, clusterIndex, in_settings), in_settings); + continue; + } + cluster.group = slot.group; + } + if (cluster.homeCount <= 1) + { + cluster.centred = slot.centred; + } + if (!IsOutsideCluster(slot, cluster, in_settings)) + { + slot.leaveFrames = 0; + continue; + } + if (slot.leaveFrames < kNone) + { + ++slot.leaveFrames; + } + if (slot.leaveFrames < in_settings.dwellFrames) + { + continue; + } + const uint32_t destination = FindLeaveDestination(slotIndex, clusterIndex, in_settings); + if (destination == kNone) + { + slot.leaveFrames = 0; + continue; + } + StartMove(slotIndex, destination, in_settings); + } +} + +void ClusterEngine::PlacePendingInputs(const Settings& in_settings) +{ + for (uint32_t index = 0; index < m_pendingCount; ++index) + { + const uint32_t slotIndex = m_pPending[index].index; + m_pSlots[slotIndex].pending = false; + m_pSlots[slotIndex].placedFrame = m_frame; + JoinCluster(slotIndex, FindPlacement(slotIndex, kNone, in_settings)); + } + m_pendingCount = 0; +} + +void ClusterEngine::MergeNearbyClusters(const Settings& in_settings) +{ + const uint32_t count = GatherMergeableClusters(); + for (uint32_t index = 0; index < count; ++index) + { + m_pScratchPartner[index] = kNone; + m_pScratchCost[index] = kAnyDistance; + m_pScratchOrder[index] = index; + } + SortIndicesByValue(m_pScratchOrder, count, m_pScratchZ); + + auto keepCloser = [this](uint32_t in_index, uint32_t in_other, double in_distance) { + if (in_distance < m_pScratchCost[in_index] || + (in_distance == m_pScratchCost[in_index] && in_other < m_pScratchPartner[in_index])) + { + m_pScratchCost[in_index] = in_distance; + m_pScratchPartner[in_index] = in_other; + } + }; + const float band = static_cast(2.0 * std::sin(0.5 * in_settings.joinAngle * kRadiansPerDegree)) + kBandSlack; + for (uint32_t low = 0; low < count; ++low) + { + const uint32_t first = m_pScratchOrder[low]; + for (uint32_t high = low + 1u; high < count; ++high) + { + const uint32_t second = m_pScratchOrder[high]; + if (m_pScratchZ[second] - m_pScratchZ[first] > band) + { + break; + } + double distance = 0.0; + if (!CanMergePair(first, second, in_settings, distance)) + { + continue; + } + keepCloser(first, second, distance); + keepCloser(second, first, distance); + } + } + + for (uint32_t index = 0; index < count; ++index) + { + Cluster& cluster = m_pClusters[m_pScratchIds[index]]; + if (cluster.busy) + { + continue; + } + uint32_t partner = m_pScratchPartner[index]; + if (partner != kNone && m_pClusters[m_pScratchIds[partner]].busy) + { + partner = FindMergePartner(index, count, in_settings); + } + if (partner == kNone) + { + cluster.candidate = kNone; + cluster.candidateFrames = 0; + continue; + } + const uint32_t partnerId = m_pScratchIds[partner]; + const uint32_t partnerGeneration = m_pClusters[partnerId].generation; + if (cluster.candidate == partnerId && cluster.candidateGeneration == partnerGeneration) + { + if (cluster.candidateFrames < kNone) + { + ++cluster.candidateFrames; + } + } + else + { + cluster.candidate = partnerId; + cluster.candidateGeneration = partnerGeneration; + cluster.candidateFrames = 1; + } + if (cluster.candidateFrames >= in_settings.dwellFrames) + { + MergePair(m_pScratchIds[index], partnerId, in_settings); + } + } +} + +void ClusterEngine::EnforceBudget(const Settings& in_settings) +{ + if (in_settings.maxOutputs == 0 || GetOutputDemand() <= in_settings.maxOutputs) + { + return; + } + + const uint32_t count = GatherMergeableClusters(); + m_budgetHeapSize = 0; + for (uint32_t index = 0; index < count; ++index) + { + m_pScratchAlive[index] = 1; + m_pScratchVersion[index] = 0; + m_pScratchOrder[index] = index; + } + FindAllBudgetPartners(count, in_settings); + for (uint32_t index = 0; index < count; ++index) + { + PushBudgetEntry(index, count); + } + + uint32_t listCount = count; + while (GetOutputDemand() > in_settings.maxOutputs) + { + const uint32_t pick = PopBudgetPick(listCount, in_settings); + if (pick == kNone) + { + m_overBudget = true; + return; + } + + const uint32_t partner = m_pScratchPartner[pick]; + const Cluster& pickCluster = m_pClusters[m_pScratchIds[pick]]; + const Cluster& partnerCluster = m_pClusters[m_pScratchIds[partner]]; + const bool pickIsSource = pickCluster.sumWeight < partnerCluster.sumWeight || + (pickCluster.sumWeight == partnerCluster.sumWeight && m_pScratchIds[pick] > m_pScratchIds[partner]); + const uint32_t sourceIndex = pickIsSource ? pick : partner; + const uint32_t targetIndex = pickIsSource ? partner : pick; + + MergeInto(m_pScratchIds[sourceIndex], m_pScratchIds[targetIndex], in_settings); + m_pScratchAlive[sourceIndex] = 0; + listCount = RemoveFromBudgetList(sourceIndex, listCount); + StoreScratchCluster(targetIndex, m_pScratchIds[targetIndex]); + UpdateBudgetPartners(sourceIndex, targetIndex, listCount, in_settings); + } +} + +uint32_t ClusterEngine::RemoveFromBudgetList(uint32_t in_index, uint32_t in_listCount) +{ + uint32_t low = 0; + uint32_t high = in_listCount; + while (low < high) + { + const uint32_t middle = low + (high - low) / 2u; + if (m_pScratchOrder[middle] < in_index) + { + low = middle + 1u; + } + else + { + high = middle; + } + } + if (low >= in_listCount || m_pScratchOrder[low] != in_index) + { + return in_listCount; + } + memmove(m_pScratchOrder + low, m_pScratchOrder + low + 1u, sizeof(uint32_t) * (in_listCount - low - 1u)); + return in_listCount - 1u; +} + +void ClusterEngine::FindAllBudgetPartners(uint32_t in_count, const Settings& in_settings) +{ + for (uint32_t index = 0; index < in_count; ++index) + { + m_pScratchPartner[index] = kNone; + m_pScratchCost[index] = kAnyDistance; + m_pScratchCosLimit[index] = kNoCosineLimit; + m_pScratchBound[index] = 0; + } + for (uint32_t first = 0; first < in_count; ++first) + { + const uint64_t firstClass = m_pScratchClass[first]; + const float firstX = m_pScratchX[first]; + const float firstY = m_pScratchY[first]; + const float firstZ = m_pScratchZ[first]; + for (uint32_t second = first + 1u; second < in_count; ++second) + { + if (m_pScratchClass[second] != firstClass) + { + continue; + } + const double dot = firstX * m_pScratchX[second] + firstY * m_pScratchY[second] + firstZ * m_pScratchZ[second]; + if (dot <= m_pScratchCosLimit[first] && dot <= m_pScratchCosLimit[second]) + { + continue; + } + const double cost = GetBudgetCost(first, second, in_settings); + if (dot > m_pScratchCosLimit[first] && cost < m_pScratchCost[first]) + { + SetBudgetPartner(first, second, cost); + } + if (dot > m_pScratchCosLimit[second] && cost < m_pScratchCost[second]) + { + SetBudgetPartner(second, first, cost); + } + } + } +} + +void ClusterEngine::SetBudgetPartner(uint32_t in_index, uint32_t in_partner, double in_cost) +{ + m_pScratchPartner[in_index] = in_partner; + m_pScratchCost[in_index] = in_cost; + m_pScratchBound[in_index] = 0; + m_pScratchCosLimit[in_index] = CosineLimit(in_cost / (1.0 + m_pClusters[m_pScratchIds[in_index]].priority / 100.0)); +} + +void ClusterEngine::UpdateBudgetPartners(uint32_t in_source, uint32_t in_target, uint32_t in_listCount, const Settings& in_settings) +{ + const uint64_t targetClass = m_pScratchClass[in_target]; + const float targetX = m_pScratchX[in_target]; + const float targetY = m_pScratchY[in_target]; + const float targetZ = m_pScratchZ[in_target]; + const double targetFactor = 1.0 + m_pClusters[m_pScratchIds[in_target]].priority / 100.0; + uint32_t targetPartner = kNone; + double targetCost = kAnyDistance; + double targetCosLimit = kNoCosineLimit; + for (uint32_t position = 0; position < in_listCount; ++position) + { + const uint32_t index = m_pScratchOrder[position]; + if (index == in_target) + { + continue; + } + bool changed = false; + const uint32_t best = m_pScratchPartner[index]; + if (m_pScratchBound[index] == 0 && (best == in_source || best == in_target)) + { + m_pScratchBound[index] = 1; + m_pScratchPartner[index] = kNone; + changed = true; + } + if (m_pScratchClass[index] == targetClass) + { + const double dot = targetX * m_pScratchX[index] + targetY * m_pScratchY[index] + targetZ * m_pScratchZ[index]; + const bool checkTarget = dot > targetCosLimit; + const bool checkIndex = dot > m_pScratchCosLimit[index]; + if (checkTarget || checkIndex) + { + const double cost = GetBudgetCost(in_target, index, in_settings); + if (checkTarget && cost < targetCost) + { + targetPartner = index; + targetCost = cost; + targetCosLimit = CosineLimit(cost / targetFactor); + } + const double current = m_pScratchCost[index]; + const bool cheaper = m_pScratchBound[index] != 0 + ? cost < current + : cost < current || (cost == current && in_target < m_pScratchPartner[index]); + if (checkIndex && cheaper) + { + SetBudgetPartner(index, in_target, cost); + changed = true; + } + } + } + if (changed) + { + PushBudgetEntry(index, in_listCount); + } + } + m_pScratchPartner[in_target] = targetPartner; + m_pScratchCost[in_target] = targetCost; + m_pScratchCosLimit[in_target] = targetCosLimit; + m_pScratchBound[in_target] = 0; + PushBudgetEntry(in_target, in_listCount); +} + +uint32_t ClusterEngine::PopBudgetPick(uint32_t in_listCount, const Settings& in_settings) +{ + while (m_budgetHeapSize > 0) + { + const BudgetEntry top = m_pBudgetHeap[0]; + --m_budgetHeapSize; + m_pBudgetHeap[0] = m_pBudgetHeap[m_budgetHeapSize]; + SiftBudgetDown(0); + if (m_pScratchAlive[top.index] == 0 || m_pScratchVersion[top.index] != top.version) + { + continue; + } + if (m_pScratchBound[top.index] != 0) + { + FindBudgetPartner(top.index, in_listCount, in_settings); + PushBudgetEntry(top.index, in_listCount); + continue; + } + return top.index; + } + return kNone; +} + +void ClusterEngine::PushBudgetEntry(uint32_t in_index, uint32_t in_listCount) +{ + ++m_pScratchVersion[in_index]; + if (m_pScratchBound[in_index] == 0 && m_pScratchPartner[in_index] == kNone) + { + return; + } + if (m_budgetHeapSize >= m_clusterCapacity) + { + RebuildBudgetHeap(in_listCount); + return; + } + const uint32_t position = m_budgetHeapSize++; + BudgetEntry& entry = m_pBudgetHeap[position]; + entry.cost = m_pScratchCost[in_index]; + entry.index = in_index; + entry.version = m_pScratchVersion[in_index]; + SiftBudgetUp(position); +} + +void ClusterEngine::RebuildBudgetHeap(uint32_t in_listCount) +{ + m_budgetHeapSize = 0; + for (uint32_t position = 0; position < in_listCount; ++position) + { + const uint32_t index = m_pScratchOrder[position]; + if (m_pScratchBound[index] == 0 && m_pScratchPartner[index] == kNone) + { + continue; + } + BudgetEntry& entry = m_pBudgetHeap[m_budgetHeapSize++]; + entry.cost = m_pScratchCost[index]; + entry.index = index; + entry.version = m_pScratchVersion[index]; + } + for (uint32_t position = m_budgetHeapSize / 2u; position-- > 0;) + { + SiftBudgetDown(position); + } +} + +void ClusterEngine::SiftBudgetUp(uint32_t in_position) +{ + uint32_t position = in_position; + const BudgetEntry entry = m_pBudgetHeap[position]; + while (position > 0) + { + const uint32_t parent = (position - 1u) / 2u; + if (!IsBudgetEntryBefore(entry, m_pBudgetHeap[parent])) + { + break; + } + m_pBudgetHeap[position] = m_pBudgetHeap[parent]; + position = parent; + } + m_pBudgetHeap[position] = entry; +} + +void ClusterEngine::SiftBudgetDown(uint32_t in_position) +{ + if (in_position >= m_budgetHeapSize) + { + return; + } + uint32_t position = in_position; + const BudgetEntry entry = m_pBudgetHeap[position]; + for (;;) + { + const uint32_t left = position * 2u + 1u; + if (left >= m_budgetHeapSize) + { + break; + } + uint32_t child = left; + const uint32_t right = left + 1u; + if (right < m_budgetHeapSize && IsBudgetEntryBefore(m_pBudgetHeap[right], m_pBudgetHeap[left])) + { + child = right; + } + if (!IsBudgetEntryBefore(m_pBudgetHeap[child], entry)) + { + break; + } + m_pBudgetHeap[position] = m_pBudgetHeap[child]; + position = child; + } + m_pBudgetHeap[position] = entry; +} + +bool ClusterEngine::IsBudgetEntryBefore(const BudgetEntry& in_first, const BudgetEntry& in_second) +{ + return in_first.cost < in_second.cost || (in_first.cost == in_second.cost && in_first.index < in_second.index); +} + +void ClusterEngine::BuildReport() +{ + m_infoCount = 0; + m_contributionCount = 0; + for (uint32_t clusterIndex = 0; clusterIndex < m_clusterEnd; ++clusterIndex) + { + Cluster& cluster = m_pClusters[clusterIndex]; + if (!cluster.used) + { + continue; + } + if (cluster.changed) + { + RefreshCluster(clusterIndex); + } + + ClusterInfo& info = m_pInfos[m_infoCount]; + info.id = clusterIndex; + info.isNew = !cluster.reported; + info.position.x = cluster.dirX * cluster.range; + info.position.y = cluster.dirY * cluster.range; + info.position.z = cluster.dirZ * cluster.range; + info.angularRadius = cluster.radius; + info.priority = cluster.priority; + info.memberCount = cluster.nodeCount; + info.representative = cluster.representative; + cluster.reported = true; + + for (uint32_t node = cluster.head; node != kNone; node = m_pNodes[node].next) + { + ClusterContribution& contribution = m_pContributions[m_contributionCount++]; + contribution.input = m_pSlots[node >> 1u].inputIndex; + contribution.cluster = m_infoCount; + GetNodeGains(node, contribution.gainStart, contribution.gainEnd); + } + ++m_infoCount; + } +} + +bool ClusterEngine::IsOutsideCluster(const Slot& in_slot, const Cluster& in_cluster, const Settings& in_settings) const +{ + if (in_cluster.homeCount <= 1) + { + return false; + } + if (in_slot.excluded || in_slot.centred != in_cluster.centred) + { + return true; + } + const float dot = in_slot.dirX * in_cluster.dirX + in_slot.dirY * in_cluster.dirY + in_slot.dirZ * in_cluster.dirZ; + if (in_settings.distanceScale <= 0.0) + { + return dot < in_settings.cosLeave; + } + const double rangeTerm = + in_settings.distanceScale * std::fabs(static_cast(in_slot.logRange) - in_cluster.logRange); + const double halfChord = std::sqrt(MaxOf(0.0, 0.5 * (1.0 - dot))); + if (rangeTerm + kPi * halfChord * kDegreesPerRadian <= in_settings.leaveAngle) + { + return false; + } + if (rangeTerm + 2.0 * halfChord * kDegreesPerRadian > in_settings.leaveAngle) + { + return true; + } + return AngleFromDot(dot) + rangeTerm > in_settings.leaveAngle; +} + +uint32_t ClusterEngine::FindLeaveDestination(uint32_t in_slot, uint32_t in_from, const Settings& in_settings) +{ + if (m_pSlots[in_slot].excluded) + { + return CreateCluster(in_slot); + } + const uint32_t nearest = FindNearestCluster(in_slot, in_from, in_settings.joinAngle, in_settings); + if (nearest != kNone) + { + return nearest; + } + if (CanCreateCluster(in_settings)) + { + return CreateCluster(in_slot); + } + return kNone; +} + +uint32_t ClusterEngine::FindFollowDestination(uint32_t in_slot, uint32_t in_from, const Settings& in_settings) +{ + const Slot& slot = m_pSlots[in_slot]; + if (!slot.excluded) + { + const uint32_t target = ResolveMergeTarget(in_from); + if (target != kNone && IsJoinable(m_pClusters[target], slot.group, slot.centred)) + { + return target; + } + } + return FindPlacement(in_slot, in_from, in_settings); +} + +uint32_t ClusterEngine::FindPlacement(uint32_t in_slot, uint32_t in_exclude, const Settings& in_settings) +{ + if (m_pSlots[in_slot].excluded) + { + return CreateCluster(in_slot); + } + uint32_t nearest = FindNearestCluster(in_slot, in_exclude, in_settings.joinAngle, in_settings); + if (nearest != kNone) + { + return nearest; + } + if (!CanCreateCluster(in_settings)) + { + nearest = FindNearestCluster(in_slot, in_exclude, in_settings.leaveAngle, in_settings); + if (nearest != kNone) + { + return nearest; + } + } + return CreateCluster(in_slot); +} + +uint32_t ClusterEngine::ResolveMergeTarget(uint32_t in_cluster) const +{ + uint32_t target = m_pClusters[in_cluster].mergeTarget; + uint32_t generation = m_pClusters[in_cluster].mergeTargetGeneration; + for (uint32_t step = 0; step < m_clusterCapacity && target != kNone; ++step) + { + const Cluster& cluster = m_pClusters[target]; + if (!cluster.used || cluster.generation != generation) + { + return kNone; + } + if (!cluster.dissolving) + { + return target; + } + generation = cluster.mergeTargetGeneration; + target = cluster.mergeTarget; + } + return kNone; +} + +uint32_t ClusterEngine::FindNearestCluster(uint32_t in_slot, uint32_t in_exclude, double in_maxDistance, + const Settings& in_settings) const +{ + const Slot& slot = m_pSlots[in_slot]; + const double cosLimit = CosineLimit(in_maxDistance); + const bool useDistance = in_settings.distanceScale > 0.0; + double bestScore = useDistance ? in_maxDistance : cosLimit; + double cosBest = cosLimit; + uint32_t best = kNone; + for (uint32_t clusterIndex = 0; clusterIndex < m_clusterEnd; ++clusterIndex) + { + const Cluster& cluster = m_pClusters[clusterIndex]; + if (clusterIndex == in_exclude || !IsJoinable(cluster, slot.group, slot.centred)) + { + continue; + } + const double dot = slot.dirX * cluster.dirX + slot.dirY * cluster.dirY + slot.dirZ * cluster.dirZ; + if (dot <= cosBest) + { + continue; + } + if (useDistance) + { + const double rangeTerm = in_settings.distanceScale * std::fabs(static_cast(slot.logRange) - cluster.logRange); + if (rangeTerm >= bestScore) + { + continue; + } + const double distance = AngleFromDot(dot) + rangeTerm; + if (distance < bestScore) + { + bestScore = distance; + cosBest = CosineLimit(distance); + best = clusterIndex; + } + } + else if (dot > bestScore) + { + bestScore = dot; + best = clusterIndex; + } + } + return best; +} + +bool ClusterEngine::IsJoinable(const Cluster& in_cluster, uint32_t in_group, bool in_centred) const +{ + return in_cluster.used && !in_cluster.dissolving && !in_cluster.excluded && in_cluster.group == in_group && + in_cluster.centred == in_centred; +} + +bool ClusterEngine::CanCreateCluster(const Settings& in_settings) const +{ + return in_settings.maxOutputs == 0 || GetOutputDemand() < in_settings.maxOutputs; +} + +uint32_t ClusterEngine::GetOutputDemand() const +{ + return m_passthroughCount + m_activeClusters; +} + +uint32_t ClusterEngine::CreateCluster(uint32_t in_slot) +{ + const Slot& slot = m_pSlots[in_slot]; + const uint32_t clusterIndex = AllocateCluster(); + Cluster& cluster = m_pClusters[clusterIndex]; + const uint32_t generation = cluster.generation + 1u; + memset(&cluster, 0, sizeof(Cluster)); + cluster.dirX = slot.dirX; + cluster.dirY = slot.dirY; + cluster.dirZ = slot.dirZ; + cluster.range = slot.range; + cluster.logRange = LogRange(slot.range); + cluster.head = kNone; + cluster.tail = kNone; + cluster.group = slot.group; + cluster.priority = slot.priority; + cluster.generation = generation; + cluster.mergeTarget = kNone; + cluster.candidate = kNone; + cluster.representative = slot.inputIndex; + cluster.used = true; + cluster.excluded = slot.excluded; + cluster.centred = slot.centred; + ++m_liveClusters; + ++m_activeClusters; + return clusterIndex; +} + +void ClusterEngine::JoinCluster(uint32_t in_slot, uint32_t in_cluster) +{ + Slot& slot = m_pSlots[in_slot]; + AttachNode(GetPrimaryNode(in_slot), in_cluster); + ++m_pClusters[in_cluster].homeCount; + slot.fading = false; + slot.fadeStart = 1.f; + slot.fadeEnd = 1.f; + slot.leaveFrames = 0; + AddDecisionWeight(m_pClusters[in_cluster], slot, 1.0); +} + +void ClusterEngine::StartMove(uint32_t in_slot, uint32_t in_cluster, const Settings& in_settings) +{ + Slot& slot = m_pSlots[in_slot]; + const uint32_t from = m_pNodes[GetPrimaryNode(in_slot)].cluster; + AddDecisionWeight(m_pClusters[from], slot, -1.0); + DropHomeInput(from); + slot.primaryLink ^= 1u; + AttachNode(GetPrimaryNode(in_slot), in_cluster); + ++m_pClusters[in_cluster].homeCount; + AddDecisionWeight(m_pClusters[in_cluster], slot, 1.0); + slot.fading = true; + slot.fadeStart = 0.f; + slot.fadeEnd = MinOf(1.f, in_settings.fadeStep); + slot.leaveFrames = 0; +} + +void ClusterEngine::MergeInto(uint32_t in_source, uint32_t in_target, const Settings& in_settings) +{ + Cluster& source = m_pClusters[in_source]; + if (!source.dissolving) + { + source.dissolving = true; + --m_activeClusters; + } + source.mergeTarget = in_target; + source.mergeTargetGeneration = m_pClusters[in_target].generation; + uint32_t node = source.head; + while (node != kNone) + { + const uint32_t next = m_pNodes[node].next; + const uint32_t slotIndex = node >> 1u; + const Slot& slot = m_pSlots[slotIndex]; + if (IsPrimaryNode(node) && !slot.fading) + { + if (slot.placedFrame == m_frame) + { + RehomeNewInput(slotIndex, in_target); + } + else + { + StartMove(slotIndex, in_target, in_settings); + } + } + node = next; + } +} + +void ClusterEngine::RehomeNewInput(uint32_t in_slot, uint32_t in_cluster) +{ + const uint32_t primaryNode = GetPrimaryNode(in_slot); + const uint32_t from = m_pNodes[primaryNode].cluster; + AddDecisionWeight(m_pClusters[from], m_pSlots[in_slot], -1.0); + DropHomeInput(from); + DetachNode(primaryNode); + JoinCluster(in_slot, in_cluster); +} + +void ClusterEngine::MergePair(uint32_t in_first, uint32_t in_second, const Settings& in_settings) +{ + Cluster& first = m_pClusters[in_first]; + Cluster& second = m_pClusters[in_second]; + const bool firstIsSource = + first.sumWeight < second.sumWeight || (first.sumWeight == second.sumWeight && in_first > in_second); + if (firstIsSource) + { + MergeInto(in_first, in_second, in_settings); + } + else + { + MergeInto(in_second, in_first, in_settings); + } + first.busy = true; + second.busy = true; + first.candidate = kNone; + first.candidateFrames = 0; + second.candidate = kNone; + second.candidateFrames = 0; +} + +void ClusterEngine::AddDecisionWeight(Cluster& io_cluster, const Slot& in_slot, double in_sign) +{ + const double share = in_sign * in_slot.weight; + io_cluster.changed = true; + io_cluster.sumX += share * in_slot.dirX; + io_cluster.sumY += share * in_slot.dirY; + io_cluster.sumZ += share * in_slot.dirZ; + io_cluster.sumRange += share * in_slot.range; + io_cluster.sumWeight += share; + if (io_cluster.sumWeight <= kCancelRatio * std::fabs(share)) + { + io_cluster.sumX = 0.0; + io_cluster.sumY = 0.0; + io_cluster.sumZ = 0.0; + io_cluster.sumRange = 0.0; + io_cluster.sumWeight = 0.0; + } + ApplySums(io_cluster); + if (in_sign > 0.0) + { + io_cluster.priority = MaxOf(io_cluster.priority, in_slot.priority); + } +} + +void ClusterEngine::RefreshHomeRadius(Cluster& io_cluster) const +{ + float minDot = 1.f; + for (uint32_t node = io_cluster.head; node != kNone; node = m_pNodes[node].next) + { + if (IsPrimaryNode(node)) + { + const Slot& slot = m_pSlots[node >> 1u]; + minDot = MinOf(minDot, slot.dirX * io_cluster.dirX + slot.dirY * io_cluster.dirY + slot.dirZ * io_cluster.dirZ); + } + } + io_cluster.homeRadius = static_cast(AngleFromDot(minDot)); +} + +uint32_t ClusterEngine::GatherMergeableClusters() +{ + uint32_t count = 0; + for (uint32_t clusterIndex = 0; clusterIndex < m_clusterEnd; ++clusterIndex) + { + Cluster& cluster = m_pClusters[clusterIndex]; + if (!cluster.used) + { + continue; + } + cluster.busy = false; + if (cluster.dissolving || cluster.excluded) + { + cluster.candidate = kNone; + cluster.candidateFrames = 0; + continue; + } + if (cluster.changed) + { + RefreshHomeRadius(cluster); + } + m_pScratchIds[count] = clusterIndex; + StoreScratchCluster(count, clusterIndex); + ++count; + } + return count; +} + +void ClusterEngine::StoreScratchCluster(uint32_t in_index, uint32_t in_cluster) +{ + const Cluster& cluster = m_pClusters[in_cluster]; + m_pScratchX[in_index] = cluster.dirX; + m_pScratchY[in_index] = cluster.dirY; + m_pScratchZ[in_index] = cluster.dirZ; + m_pScratchLogRange[in_index] = cluster.logRange; + m_pScratchClass[in_index] = MergeClass(cluster.group, cluster.centred); +} + +bool ClusterEngine::CanMergePair(uint32_t in_first, uint32_t in_second, const Settings& in_settings, double& out_distance) const +{ + if (m_pScratchClass[in_first] != m_pScratchClass[in_second]) + { + return false; + } + const double dot = m_pScratchX[in_first] * m_pScratchX[in_second] + m_pScratchY[in_first] * m_pScratchY[in_second] + + m_pScratchZ[in_first] * m_pScratchZ[in_second]; + if (dot <= in_settings.cosJoin) + { + return false; + } + const double distance = + AngleFromDot(dot) + + in_settings.distanceScale * std::fabs(static_cast(m_pScratchLogRange[in_first]) - m_pScratchLogRange[in_second]); + if (distance >= in_settings.joinAngle) + { + return false; + } + if (PredictMergedRadius(m_pClusters[m_pScratchIds[in_first]], m_pClusters[m_pScratchIds[in_second]]) > in_settings.joinAngle) + { + return false; + } + out_distance = distance; + return true; +} + +uint32_t ClusterEngine::FindMergePartner(uint32_t in_index, uint32_t in_count, const Settings& in_settings) const +{ + uint32_t best = kNone; + double bestDistance = kAnyDistance; + for (uint32_t other = 0; other < in_count; ++other) + { + if (other == in_index || m_pClusters[m_pScratchIds[other]].busy) + { + continue; + } + double distance = 0.0; + if (CanMergePair(in_index, other, in_settings, distance) && distance < bestDistance) + { + bestDistance = distance; + best = other; + } + } + return best; +} + +double ClusterEngine::PredictMergedRadius(const Cluster& in_first, const Cluster& in_second) const +{ + const double sumX = in_first.sumX + in_second.sumX; + const double sumY = in_first.sumY + in_second.sumY; + const double sumZ = in_first.sumZ + in_second.sumZ; + const double length = std::sqrt(sumX * sumX + sumY * sumY + sumZ * sumZ); + if (!(length > kCancelRatio * (in_first.sumWeight + in_second.sumWeight))) + { + return 180.0; + } + const double mergedX = sumX / length; + const double mergedY = sumY / length; + const double mergedZ = sumZ / length; + const double firstShift = AngleFromDot(mergedX * in_first.dirX + mergedY * in_first.dirY + mergedZ * in_first.dirZ); + const double secondShift = AngleFromDot(mergedX * in_second.dirX + mergedY * in_second.dirY + mergedZ * in_second.dirZ); + return MaxOf(firstShift + in_first.homeRadius, secondShift + in_second.homeRadius); +} + +double ClusterEngine::GetBudgetCost(uint32_t in_first, uint32_t in_second, const Settings& in_settings) const +{ + const double dot = m_pScratchX[in_first] * m_pScratchX[in_second] + m_pScratchY[in_first] * m_pScratchY[in_second] + + m_pScratchZ[in_first] * m_pScratchZ[in_second]; + const double distance = + AngleFromDot(dot) + + in_settings.distanceScale * std::fabs(static_cast(m_pScratchLogRange[in_first]) - m_pScratchLogRange[in_second]); + const uint32_t priority = + MaxOf(m_pClusters[m_pScratchIds[in_first]].priority, m_pClusters[m_pScratchIds[in_second]].priority); + return distance * (1.0 + priority / 100.0); +} + +void ClusterEngine::FindBudgetPartner(uint32_t in_index, uint32_t in_listCount, const Settings& in_settings) +{ + const double ownFactor = 1.0 + m_pClusters[m_pScratchIds[in_index]].priority / 100.0; + const uint64_t ownClass = m_pScratchClass[in_index]; + const float ownX = m_pScratchX[in_index]; + const float ownY = m_pScratchY[in_index]; + const float ownZ = m_pScratchZ[in_index]; + uint32_t best = kNone; + double bestCost = kAnyDistance; + double cosBound = kNoCosineLimit; + for (uint32_t position = 0; position < in_listCount; ++position) + { + const uint32_t other = m_pScratchOrder[position]; + if (other == in_index || m_pScratchClass[other] != ownClass) + { + continue; + } + const double dot = ownX * m_pScratchX[other] + ownY * m_pScratchY[other] + ownZ * m_pScratchZ[other]; + if (dot <= cosBound) + { + continue; + } + const double cost = GetBudgetCost(in_index, other, in_settings); + if (cost < bestCost) + { + bestCost = cost; + best = other; + cosBound = CosineLimit(bestCost / ownFactor); + } + } + m_pScratchPartner[in_index] = best; + m_pScratchCost[in_index] = bestCost; + m_pScratchCosLimit[in_index] = cosBound; + m_pScratchBound[in_index] = 0; +} + +uint32_t ClusterEngine::GetPrimaryNode(uint32_t in_slot) const +{ + return in_slot * 2u + m_pSlots[in_slot].primaryLink; +} + +uint32_t ClusterEngine::GetSourceNode(uint32_t in_slot) const +{ + return in_slot * 2u + (m_pSlots[in_slot].primaryLink ^ 1u); +} + +bool ClusterEngine::IsPrimaryNode(uint32_t in_node) const +{ + return (in_node & 1u) == m_pSlots[in_node >> 1u].primaryLink; +} + +void ClusterEngine::GetNodeGains(uint32_t in_node, float& out_gainStart, float& out_gainEnd) const +{ + const Slot& slot = m_pSlots[in_node >> 1u]; + if ((in_node & 1u) == slot.primaryLink) + { + out_gainStart = slot.fadeStart; + out_gainEnd = slot.fadeEnd; + } + else + { + out_gainStart = 1.f - slot.fadeStart; + out_gainEnd = 1.f - slot.fadeEnd; + } +} + +void ClusterEngine::AttachNode(uint32_t in_node, uint32_t in_cluster) +{ + Cluster& cluster = m_pClusters[in_cluster]; + Node& node = m_pNodes[in_node]; + node.cluster = in_cluster; + node.prev = cluster.tail; + node.next = kNone; + if (cluster.tail != kNone) + { + m_pNodes[cluster.tail].next = in_node; + } + else + { + cluster.head = in_node; + } + cluster.tail = in_node; + ++cluster.nodeCount; +} + +void ClusterEngine::DetachNode(uint32_t in_node) +{ + Node& node = m_pNodes[in_node]; + const uint32_t clusterIndex = node.cluster; + Cluster& cluster = m_pClusters[clusterIndex]; + if (node.prev != kNone) + { + m_pNodes[node.prev].next = node.next; + } + else + { + cluster.head = node.next; + } + if (node.next != kNone) + { + m_pNodes[node.next].prev = node.prev; + } + else + { + cluster.tail = node.prev; + } + node.cluster = kNone; + node.prev = kNone; + node.next = kNone; + --cluster.nodeCount; + if (cluster.nodeCount == 0) + { + ReleaseCluster(clusterIndex); + } +} + +void ClusterEngine::DropHomeInput(uint32_t in_cluster) +{ + Cluster& cluster = m_pClusters[in_cluster]; + --cluster.homeCount; + if (cluster.homeCount == 0 && !cluster.dissolving) + { + cluster.dissolving = true; + --m_activeClusters; + } +} diff --git a/core/src/ClusterEngine.h b/core/src/ClusterEngine.h new file mode 100644 index 0000000..e967166 --- /dev/null +++ b/core/src/ClusterEngine.h @@ -0,0 +1,416 @@ +/* + * Copyright 2026 CCP ehf. + * + * This software was developed by CCP Games for spatial audio object clustering + * in EVE Online and EVE Frontier. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This license does not grant any rights to CCP's trademarks or game content. + * EVE Online and EVE Frontier are registered trademarks of CCP ehf. + */ + +#pragma once + +#include +#include + +/** @brief 3D vector used by the cluster engine. */ +struct ClusterVec3 +{ + float x; ///< X coordinate. + float y; ///< Y coordinate. + float z; ///< Z coordinate. +}; + +/** @brief Memory interface the cluster engine allocates through. */ +class ClusterAllocator +{ +public: + /** @brief Destroys the allocator. */ + virtual ~ClusterAllocator() = default; + + /** @brief Allocates a block of memory, or returns nullptr. */ + virtual void* Alloc(size_t in_size) = 0; + + /** @brief Frees a block returned by Alloc. */ + virtual void Free(void* in_pBlock) = 0; +}; + +/** @brief Settings for one cluster engine update. */ +struct ClusterParams +{ + float joinAngle = 15.f; ///< Angle in degrees under which an input may join a cluster. + float leaveRatio = 1.5f; ///< Leave angle as a multiple of the join angle. + uint32_t dwellFrames = 10; ///< Frames a change must last before an input leaves its cluster or two clusters merge. + uint32_t crossfadeFrames = 3; ///< Frames over which a moving input fades between clusters. + uint32_t maxOutputs = 0; ///< Maximum number of outputs, passthrough inputs included; 0 means no limit. + float nearFieldRadius = 0.f; ///< Inputs closer to the listener than this distance are never merged. + float distanceWeight = 0.f; ///< At 1, a doubling of distance counts as much as the join angle. + uint32_t excludePriorityAbove = 100; ///< Inputs with a higher priority are never merged. + bool loudnessWeighting = true; ///< Weights cluster positions by input loudness. + float loudnessSmoothing = 0.f; ///< Per-frame smoothing coefficient for input loudness, from 0 up to but not including 1. +}; + +/** @brief One input object for a cluster engine update. */ +struct ClusterInput +{ + uint64_t key; ///< Key that identifies the input across frames. + ClusterVec3 position; ///< Position relative to the listener. + float loudness; ///< RMS level of the input in this frame. + uint32_t priority; ///< Playback priority from 0 to 100. + uint32_t group; ///< Only inputs with the same group share a cluster. + bool clusterable; ///< False for inputs that always pass through. +}; + +/** @brief One cluster reported by the cluster engine for the current frame. */ +struct ClusterInfo +{ + uint32_t id; ///< Cluster ID, reused only after the cluster is gone. + bool isNew; ///< True on the first frame the cluster is reported. + ClusterVec3 position; ///< Weighted position of the cluster relative to the listener. + float angularRadius; ///< Largest angle in degrees between a contributing input and the cluster direction. + uint32_t priority; ///< Highest priority among the contributing inputs. + uint32_t memberCount; ///< Number of inputs that contribute to the cluster in this frame. + uint32_t representative; ///< Index in this frame's input array of the representative input: the loudest one when loudness weighting is on, otherwise the first input at full gain. +}; + +/** @brief Gain ramp with which one input is mixed into one cluster for the current frame. */ +struct ClusterContribution +{ + uint32_t input; ///< Index in this frame's input array. + uint32_t cluster; ///< Index in this frame's reported clusters. + float gainStart; ///< Gain at the start of the frame. + float gainEnd; ///< Gain at the end of the frame. +}; + +/** @brief Groups input objects into persistent clusters by direction from the listener. */ +class ClusterEngine +{ +public: + /** @brief Creates an engine with no memory. */ + ClusterEngine(); + + /** @brief Frees all memory. */ + ~ClusterEngine(); + + /** @brief Engines cannot be copied. */ + ClusterEngine(const ClusterEngine&) = delete; + + /** @brief Engines cannot be copied. */ + ClusterEngine& operator=(const ClusterEngine&) = delete; + + /** @brief Sets the allocator and reserves memory for the given number of inputs. */ + bool Init(ClusterAllocator* in_pAllocator, uint32_t in_reserveInputs); + + /** @brief Frees all memory. */ + void Term(); + + /** @brief Forgets all inputs and clusters. */ + void Reset(); + + /** @brief Updates the clusters from this frame's inputs and returns false if memory ran out. */ + bool Update(const ClusterInput* in_pInputs, uint32_t in_numInputs, const ClusterParams& in_params); + + /** @brief Returns the number of clusters reported for this frame. */ + uint32_t GetClusterCount() const; + + /** @brief Returns a cluster reported for this frame. */ + const ClusterInfo& GetCluster(uint32_t in_index) const; + + /** @brief Returns the number of contributions for this frame. */ + uint32_t GetContributionCount() const; + + /** @brief Returns a contribution for this frame. */ + const ClusterContribution& GetContribution(uint32_t in_index) const; + + /** @brief Returns true if this frame needed more outputs than maxOutputs allows. */ + bool IsOverBudget() const; + +private: + /** @brief Parameters of one update, checked and converted for internal use. */ + struct Settings; + + /** @brief Persistent state of one input, found by key. */ + struct Slot; + + /** @brief Link of one input into one cluster. */ + struct Node; + + /** @brief Persistent state of one cluster. */ + struct Cluster; + + /** @brief Entry of the table that maps input keys to slots. */ + struct HashEntry; + + /** @brief Input that needs a cluster in this update, with its key for sorting. */ + struct PendingEntry; + + /** @brief Entry of the heap that orders budget merges by cost. */ + struct BudgetEntry; + + /** @brief Converts the public parameters into internal settings. */ + static void MakeSettings(const ClusterParams& in_params, Settings& out_settings); + + /** @brief Grows the memory so that the given number of inputs fits. */ + bool EnsureCapacity(uint32_t in_numInputs); + + /** @brief Moves all state into one new block with the given capacities. */ + bool Reserve(uint32_t in_slotCapacity, uint32_t in_clusterCapacity); + + /** @brief Forgets all inputs and clusters and keeps the memory. */ + void ClearState(); + + /** @brief Marks every inactive slot as free. */ + void RebuildFreeSlots(); + + /** @brief Marks every unused cluster as free. */ + void RebuildFreeClusters(); + + /** @brief Returns the slot for a key, or an invalid index. */ + uint32_t FindSlot(uint64_t in_key) const; + + /** @brief Adds a key to the slot table. */ + void InsertSlotKey(uint64_t in_key, uint32_t in_slot); + + /** @brief Removes a key from the slot table. */ + void RemoveSlotKey(uint64_t in_key); + + /** @brief Takes the lowest free slot. */ + uint32_t AllocateSlot(); + + /** @brief Returns a slot to the free set. */ + void FreeSlot(uint32_t in_slot); + + /** @brief Takes the lowest free cluster. */ + uint32_t AllocateCluster(); + + /** @brief Frees a cluster so that its ID can be reused after this update. */ + void ReleaseCluster(uint32_t in_cluster); + + /** @brief Makes the clusters freed in this update available again. */ + void FlushReleasedClusters(); + + /** @brief Matches inputs to slots, releases missing inputs and lists inputs that need a cluster. */ + void MapInputs(const ClusterInput* in_pInputs, uint32_t in_numInputs); + + /** @brief Sorts the pending list by input key. */ + void SortPendingByKey(); + + /** @brief Creates slots for new pending inputs and turns the pending list into slot indices. */ + void AssignPendingSlots(const ClusterInput* in_pInputs); + + /** @brief Fills a new slot from an input. */ + void InitSlot(uint32_t in_slot, uint32_t in_inputIndex, const ClusterInput& in_input); + + /** @brief Removes an input from its clusters and frees its slot. */ + void ReleaseSlot(uint32_t in_slot); + + /** @brief Removes an input from its clusters without a fade. */ + void DetachSlot(uint32_t in_slot); + + /** @brief Reads position, loudness, weight and exclusion state of every input. */ + void UpdateSlots(const ClusterInput* in_pInputs, const Settings& in_settings); + + /** @brief Advances every crossfade by one frame and drops finished sources. */ + void AdvanceFades(const Settings& in_settings); + + /** @brief Recomputes position, radius, priority and exclusion state of every cluster. */ + void RefreshClusters(); + + /** @brief Recomputes one cluster from the gains of its contributions. */ + void RefreshCluster(uint32_t in_cluster); + + /** @brief Moves home inputs that changed group, stayed outside their cluster long enough, or belong to a dissolving cluster. */ + void ProcessLeaves(const Settings& in_settings); + + /** @brief Places every new input into a cluster. */ + void PlacePendingInputs(const Settings& in_settings); + + /** @brief Merges cluster pairs that stayed close long enough. */ + void MergeNearbyClusters(const Settings& in_settings); + + /** @brief Merges the cheapest cluster pairs until the output count fits maxOutputs. */ + void EnforceBudget(const Settings& in_settings); + + /** @brief Fills the reported clusters and contributions. */ + void BuildReport(); + + /** @brief Returns true if a home input is far enough from its cluster, or no longer matches it, to start leaving. */ + bool IsOutsideCluster(const Slot& in_slot, const Cluster& in_cluster, const Settings& in_settings) const; + + /** @brief Returns the destination for a home input that leaves its cluster, or an invalid index. */ + uint32_t FindLeaveDestination(uint32_t in_slot, uint32_t in_from, const Settings& in_settings); + + /** @brief Returns the destination for a home input of a dissolving cluster. */ + uint32_t FindFollowDestination(uint32_t in_slot, uint32_t in_from, const Settings& in_settings); + + /** @brief Returns the cluster for an input that must be placed, creating one if no cluster is close enough. */ + uint32_t FindPlacement(uint32_t in_slot, uint32_t in_exclude, const Settings& in_settings); + + /** @brief Follows the merge targets of a dissolving cluster to a live cluster, or returns an invalid index. */ + uint32_t ResolveMergeTarget(uint32_t in_cluster) const; + + /** @brief Returns the nearest joinable cluster closer than a distance, or an invalid index. */ + uint32_t FindNearestCluster(uint32_t in_slot, uint32_t in_exclude, double in_maxDistance, const Settings& in_settings) const; + + /** @brief Returns true if an input of the given group and centred flag may join the cluster. */ + bool IsJoinable(const Cluster& in_cluster, uint32_t in_group, bool in_centred) const; + + /** @brief Returns true if one more cluster fits maxOutputs. */ + bool CanCreateCluster(const Settings& in_settings) const; + + /** @brief Returns the number of outputs the budget counts. */ + uint32_t GetOutputDemand() const; + + /** @brief Creates an empty cluster with the position, group and centred flag of an input. */ + uint32_t CreateCluster(uint32_t in_slot); + + /** @brief Adds a placed input to a cluster at full gain. */ + void JoinCluster(uint32_t in_slot, uint32_t in_cluster); + + /** @brief Starts a crossfade of a home input from its cluster into another cluster. */ + void StartMove(uint32_t in_slot, uint32_t in_cluster, const Settings& in_settings); + + /** @brief Moves an input placed in this update into another cluster at full gain, without a fade. */ + void RehomeNewInput(uint32_t in_slot, uint32_t in_cluster); + + /** @brief Dissolves a cluster and starts moving its settled home inputs into another cluster. */ + void MergeInto(uint32_t in_source, uint32_t in_target, const Settings& in_settings); + + /** @brief Merges the lighter of two clusters into the other. */ + void MergePair(uint32_t in_first, uint32_t in_second, const Settings& in_settings); + + /** @brief Adds or removes the full weight of an input to the position of a cluster within this update. */ + void AddDecisionWeight(Cluster& io_cluster, const Slot& in_slot, double in_sign); + + /** @brief Sets the largest angle between a home input of a cluster and its current direction. */ + void RefreshHomeRadius(Cluster& io_cluster) const; + + /** @brief Sets direction and range of a cluster from its sums. */ + static void ApplySums(Cluster& io_cluster); + + /** @brief Lists the mergeable clusters in the scratch arrays and returns their count. */ + uint32_t GatherMergeableClusters(); + + /** @brief Copies the position of a listed cluster into the scratch arrays. */ + void StoreScratchCluster(uint32_t in_index, uint32_t in_cluster); + + /** @brief Returns true if two listed clusters may merge and gives their distance. */ + bool CanMergePair(uint32_t in_first, uint32_t in_second, const Settings& in_settings, double& out_distance) const; + + /** @brief Returns the nearest listed cluster that may merge with a listed cluster and is not busy. */ + uint32_t FindMergePartner(uint32_t in_index, uint32_t in_count, const Settings& in_settings) const; + + /** @brief Returns an upper bound of the angular radius of two clusters merged into one. */ + double PredictMergedRadius(const Cluster& in_first, const Cluster& in_second) const; + + /** @brief Returns the budget merge cost of two listed clusters. */ + double GetBudgetCost(uint32_t in_first, uint32_t in_second, const Settings& in_settings) const; + + /** @brief Finds the cheapest budget partner of a listed cluster among the clusters in the budget list. */ + void FindBudgetPartner(uint32_t in_index, uint32_t in_listCount, const Settings& in_settings); + + /** @brief Finds the cheapest budget partner of every listed cluster in one pass over the pairs. */ + void FindAllBudgetPartners(uint32_t in_count, const Settings& in_settings); + + /** @brief Removes a listed cluster from the budget list, which stays in index order, and returns the new length. */ + uint32_t RemoveFromBudgetList(uint32_t in_index, uint32_t in_listCount); + + /** @brief Sets the budget partner and cost of a listed cluster. */ + void SetBudgetPartner(uint32_t in_index, uint32_t in_partner, double in_cost); + + /** @brief Finds the new budget partner of a merge target and updates the budget partners of the other listed clusters. */ + void UpdateBudgetPartners(uint32_t in_source, uint32_t in_target, uint32_t in_listCount, const Settings& in_settings); + + /** @brief Pops budget heap entries until it finds the cluster of the cheapest merge, or returns an invalid index. */ + uint32_t PopBudgetPick(uint32_t in_listCount, const Settings& in_settings); + + /** @brief Pushes the current budget cost of a listed cluster and makes its older heap entries stale. */ + void PushBudgetEntry(uint32_t in_index, uint32_t in_listCount); + + /** @brief Refills the budget heap with one entry per listed cluster that can still merge. */ + void RebuildBudgetHeap(uint32_t in_listCount); + + /** @brief Moves a budget heap entry up until its parent comes before it. */ + void SiftBudgetUp(uint32_t in_position); + + /** @brief Moves a budget heap entry down until it comes before its children. */ + void SiftBudgetDown(uint32_t in_position); + + /** @brief Returns true if a budget heap entry must be popped before another. */ + static bool IsBudgetEntryBefore(const BudgetEntry& in_first, const BudgetEntry& in_second); + + /** @brief Returns the index of the node that links an input to its home cluster. */ + uint32_t GetPrimaryNode(uint32_t in_slot) const; + + /** @brief Returns the index of the node through which an input fades out of its previous cluster. */ + uint32_t GetSourceNode(uint32_t in_slot) const; + + /** @brief Returns true if the node links its input to its home cluster. */ + bool IsPrimaryNode(uint32_t in_node) const; + + /** @brief Returns the gain ramp of a node for this frame. */ + void GetNodeGains(uint32_t in_node, float& out_gainStart, float& out_gainEnd) const; + + /** @brief Appends a node to the node list of a cluster. */ + void AttachNode(uint32_t in_node, uint32_t in_cluster); + + /** @brief Removes a node from its cluster and frees the cluster once it is empty. */ + void DetachNode(uint32_t in_node); + + /** @brief Removes one home input from a cluster and dissolves the cluster when none is left. */ + void DropHomeInput(uint32_t in_cluster); + + ClusterAllocator* m_pAllocator = nullptr; ///< Allocator for all engine memory. + void* m_pBlock = nullptr; ///< Single memory block that holds all arrays. + Slot* m_pSlots = nullptr; ///< Input slots. + Node* m_pNodes = nullptr; ///< Two nodes per slot. + HashEntry* m_pHash = nullptr; ///< Open addressing table from key to slot. + uint64_t* m_pFreeSlots = nullptr; ///< Bit set of free slots. + PendingEntry* m_pPending = nullptr; ///< Inputs that need a cluster in this update. + ClusterContribution* m_pContributions = nullptr; ///< Contributions reported for this frame. + Cluster* m_pClusters = nullptr; ///< Clusters indexed by ID. + uint64_t* m_pFreeClusters = nullptr; ///< Bit set of free cluster IDs. + uint32_t* m_pReleasedClusters = nullptr; ///< Cluster IDs freed in this update. + ClusterInfo* m_pInfos = nullptr; ///< Clusters reported for this frame. + uint32_t* m_pScratchIds = nullptr; ///< Cluster IDs listed for merging. + float* m_pScratchX = nullptr; ///< Direction X of listed clusters. + float* m_pScratchY = nullptr; ///< Direction Y of listed clusters. + float* m_pScratchZ = nullptr; ///< Direction Z of listed clusters. + float* m_pScratchLogRange = nullptr; ///< Base 2 logarithm of the range of listed clusters. + uint64_t* m_pScratchClass = nullptr; ///< Merge class of listed clusters, which combines group and centred flag. + uint32_t* m_pScratchPartner = nullptr; ///< Best merge partner of listed clusters. + uint32_t* m_pScratchOrder = nullptr; ///< Listed clusters sorted by direction Z, or the budget list of clusters that can still merge. + double* m_pScratchCost = nullptr; ///< Distance or cost to the best partner of listed clusters. + uint8_t* m_pScratchAlive = nullptr; ///< Nonzero for listed clusters that can still merge. + uint8_t* m_pScratchBound = nullptr; ///< Nonzero for listed clusters whose cost is only a lower bound of the cost to their best partner. + double* m_pScratchCosLimit = nullptr; ///< Cosine of the largest angle at which a listed cluster can still find a cheaper partner. + uint32_t* m_pScratchVersion = nullptr; ///< Version of the newest budget heap entry of listed clusters. + BudgetEntry* m_pBudgetHeap = nullptr; ///< Binary min-heap of budget merge costs. + uint32_t m_slotCapacity = 0; ///< Number of slots. + uint32_t m_clusterCapacity = 0; ///< Number of clusters. + uint32_t m_hashMask = 0; ///< Size of the slot table minus one. + uint32_t m_slotEnd = 0; ///< One past the highest active slot. + uint32_t m_clusterEnd = 0; ///< One past the highest used cluster ID. + uint32_t m_liveClusters = 0; ///< Number of used clusters. + uint32_t m_activeClusters = 0; ///< Number of clusters that have home inputs and are not dissolving. + uint32_t m_releasedCount = 0; ///< Number of cluster IDs freed in this update. + uint32_t m_pendingCount = 0; ///< Number of inputs that need a cluster in this update. + uint32_t m_passthroughCount = 0; ///< Number of passthrough inputs in this update. + uint32_t m_infoCount = 0; ///< Number of clusters reported for this frame. + uint32_t m_contributionCount = 0; ///< Number of contributions reported for this frame. + uint32_t m_budgetHeapSize = 0; ///< Number of entries in the budget heap. + uint32_t m_frame = 0; ///< Update counter. + bool m_overBudget = false; ///< True if this frame needed more outputs than allowed. +}; diff --git a/docs/best-practices.md b/docs/best-practices.md new file mode 100644 index 0000000..2c134ad --- /dev/null +++ b/docs/best-practices.md @@ -0,0 +1,17 @@ +# Best Practices + +## Bus Setup + +- Insert the plug-in as an Effect on an Audio Objects bus. It cannot be inserted on sounds or containers and cannot be used for offline rendering. +- Use it on busses that carry many one-shots that are expensive to render as objects, such as weapon fire, impacts and debris. +- Inputs with more than two channels, such as ambience loops, always pass through untouched. If you set an Object Limit, route ambiences to another bus, since passthrough outputs count toward the limit. +- Keep important sounds, such as dialogue or the player's own weapons, out of clusters by giving them a priority above the Priority Threshold, or by routing them to another bus. +- Use the Proximity Radius to keep sounds close to the listener separate. +- Use separate busses with their own settings for different categories, for example a smaller Cluster Width for dense sources such as turret fire and a larger one for spread-out sounds such as shields. + +## Profiling + +- In **Audio Devices > System**, monitor the system audio object consumption in real time. +- In **Views > Profiler > Audio Object List**, select the bus. The Post-Effects stage shows the plug-in's outputs, `Cluster ` and the passthrough outputs. The Pre-Effects stage shows its inputs. +- Selections in the Audio Object List drive the Audio Object 3D Viewer, the Audio Object Meter and the Audio Object Metadata views. Use the 3D Viewer to see the clusters in space. +- Start a capture and check the Capture Log. The plug-in posts a message when it is not on an Audio Objects bus, when it needs more outputs than the Object Limit allows, when it cannot create output objects and when it runs out of memory. The last three can repeat every frame, so each is posted at most once every 5 seconds. diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..2780670 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,91 @@ +# Building from Source + +See Audiokinetic's [Creating Audio Plug-ins](https://www.audiokinetic.com/en/public-library/2025.1.7_9143/?source=SDK&id=effectplugin_tools_newplugin.html) for the full toolchain setup. Wwise offers a free personal license that includes the C++ SDK and the tools needed to build this plug-in. + +## Prerequisites + +- Wwise 2025.1 with the SDK (C++) and the Windows deployment platform for Visual Studio 2026 (vc180), installed through the Audiokinetic Launcher. The Windows plug-in build scripts and SDK libraries come with that deployment platform. +- Visual Studio 2026 with the C++ desktop workload for the runtime plug-in, and the Windows 11 SDK 10.0.22621.0, an individual component in the Visual Studio Installer. The Wwise 2025.1 build scripts target that SDK version, and the build fails if only a newer SDK is installed. +- For the authoring plug-in, the vc170 toolset with MFC: Visual Studio 2022, or the MSVC v143 build tools and C++ MFC for v143 in Visual Studio 2026. The Wwise 2025.1 plug-in scripts do not accept Visual Studio 2026 for the Authoring target. +- Python 3, with the `markdown` and `jinja2` packages for the property help pages: `python -m pip install markdown jinja2`. + +## Build + +Run these commands from the `wwise` folder in a Developer Command Prompt for VS 2026, with `WWISEROOT` set to your Wwise install: + +``` +python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" premake Windows_vc180 -t vc180 +python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" build Windows_vc180 -c Release -x x64 -t vc180 + +python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" premake Authoring -t vc170 +python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" build Authoring -c Release -x x64 -t vc170 + +python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" build Documentation +``` + +Repeat the `build Windows_vc180` line with `-c Debug` and `-c Profile` for the other runtime configurations. + +With only Visual Studio 2026 installed, `wp.py build Authoring` stops with "Could not find the Visual Studio Build Tools for vc170", because it looks for a Visual Studio 2022 installation to find MSBuild. The v143 toolset in Visual Studio 2026 builds the plug-in fine; run MSBuild on the solution that premake generated instead. `scripts/package.py` does this for you. + +``` +msbuild SpatialClustering_Authoring_Windows_vc170.sln /t:Build /p:Configuration=Release /p:Platform=x64 /m +``` + +The build copies its outputs into the Wwise install: +- Runtime plug-in: `%WWISEROOT%\SDK\x64_vc180\\lib\SpatialClusteringFX.lib` for static linking, and `%WWISEROOT%\SDK\x64_vc180\\bin\SpatialClustering.dll` with its `.pdb` for dynamic loading. +- Factory header: `%WWISEROOT%\SDK\include\AK\Plugin\SpatialClusteringFXFactory.h`, copied by the authoring build. +- Authoring plug-in: `%WWISEROOT%\Authoring\x64\Release\bin\Plugins\SpatialClustering.dll` and `SpatialClustering.xml`. +- Property help: `%WWISEROOT%\Authoring\Data\Plugins\SpatialClustering\Html\`, built from `wwise\WwisePlugin\res\Md`. + +To also copy the runtime DLL somewhere else after each build, set `CUSTOM_WWISE_PLUGIN_DLL_PATH` for both premake and build. The DLL is then copied to `\\\\\`, for example `\Windows\x64\v145\Release\`. + +## Build with CMake + +The root `CMakeLists.txt` builds the runtime plug-in as a static library and installs it as the `SpatialClustering` CMake package, for games that build their Wwise plug-ins from source. It uses a `Wwise::Wwise` target when one exists or a `Wwise` CMake package provides it, such as a vcpkg port. Otherwise it uses the SDK folder in `WWISE_SDK_DIR`, which defaults to the `WWISESDK` environment variable, and defines `AK_OPTIMIZED` in the Release configuration like the Release sound engine libraries. + +The library has no debug suffix, so install Debug and Release builds to separate prefixes. + +With `-DSPATIALCLUSTERING_BUILD_SHARED=ON` it also builds the runtime plug-in as `SpatialClustering.dll` for dynamic loading, and the `SharedRuntime` install component installs it with its PDB. Build Release for the Release sound engine and RelWithDebInfo for the Profile sound engine, since only Release defines `AK_OPTIMIZED`, and install each to its own prefix: + +``` +cmake -S . -B build/shared -G "Visual Studio 18 2026" -A x64 -DSPATIALCLUSTERING_BUILD_SHARED=ON -DWWISE_SDK_DIR= +cmake --build build/shared --config Release --target SpatialClusteringShared +cmake --install build/shared --config Release --component SharedRuntime --prefix +``` + +A game can also add the repository to its own build with `add_subdirectory`, for example as a git subtree, and link `SpatialClustering::SpatialClusteringFX`. The plug-in then builds with the game's configurations and its `Wwise::Wwise` target, and generates no install rules unless `SPATIALCLUSTERING_INSTALL` is on. + +With `-DSPATIALCLUSTERING_BUILD_AUTHORING=ON` it also builds the authoring plug-in, on Windows only. The `Authoring` install component copies it into a Wwise install with the same layout as the Launcher, which is useful for a Wwise install kept in source control: the plug-in is built against that install's exact SDK. Use the vc170 toolset, `-T v143`, because Wwise Authoring ships the Visual Studio 2022 runtime. The Visual Studio 2026 generator needs CMake 4.2 or later, such as the one that comes with Visual Studio 2026. + +``` +cmake -S . -B build/authoring -G "Visual Studio 18 2026" -A x64 -T v143 -DSPATIALCLUSTERING_BUILD_AUTHORING=ON -DWWISE_SDK_DIR= +cmake --build build/authoring --config Release +cmake --install build/authoring --config Release --component Authoring --prefix +``` + +The install copies `SpatialClustering.dll`, `.pdb` and `.xml` to `Authoring\x64\Release\bin\Plugins`, and the property help to `Authoring\Data\Plugins\SpatialClustering\Html`. Generate the help with `wp.py build Documentation` before configuring, since CMake only picks it up at configure time. + +## Package a Release + +`scripts/package.py` builds the runtime plug-in in Debug, Profile and Release, the authoring plug-in and the help, and packages them as a Wwise Launcher bundle. Run it from the repository root, in the same kind of command prompt: + +``` +python scripts\package.py +``` + +It packages what is in the Wwise install that `WWISEROOT` points to, and writes `dist\SpatialClustering__Wwise.\` with the bundle and the license files, plus a zip of that folder for the GitHub release. `--skip-build` packages what is already built, without building the help either. + +The plug-in version is set in `wwise/SpatialClusteringVersion.h`. Update both the major, minor and patch numbers and the version string: `package.py` reads the numbers and CMake reads the string. The bundle version puts the Wwise version first and the plug-in version last, because the Launcher reads the first two numbers as the Wwise version the bundle is for: plug-in 3.0.0 built against Wwise 2025.1 becomes bundle version 2025.1.300.0. The minor version must stay below 100. + +## Testing in a Game + +Once the plug-in builds, you'll want a scene to exercise it in. A few options: +- **Unreal Engine 5 or Unity** with the Wwise integration. +- **[Wwise Integration Demo Sample](https://www.audiokinetic.com/en/public-library/2025.1.7_9143/?source=SDK&id=soundengine_integration_samplecode.html)**: a lightweight executable with various demos that you can build and modify yourself. +- **[Wwise sample games](https://www.audiokinetic.com/en/learning/samples/?searchToken=eyJwZ0Nfc2FtIjoiMSIsInBnU19zYW0iOiIxMDAiLCJzYW1fc3QiOiIifQ%3D%3D)** provided by Audiokinetic. + +We don't ship a dedicated sample scene for spatial audio testing yet. + +## Debugging + +To place breakpoints and debug the plug-in, follow [these steps](https://www.audiokinetic.com/qa/7840/wwise-sdk-how-step-through-code-using-visual-studio-debugger). diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..742990b --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,65 @@ +# How It Works + +Spatial Clustering is an object processor. Unlike traditional Effect plug-ins, which are agnostic to Audio Objects, object processors have direct control over the Audio Objects (`AkAudioObject`) passing through a bus, so they can process their metadata and spatial characteristics. + +Every audio frame the plug-in reads all audio objects on its bus, updates the clusters and writes one output object per cluster and one per passthrough input. + +## Inputs + +- An input can be clustered when it has one or two channels, uses 3D spatialization (Position or Position + Orientation) and has Speaker Panning / 3D Spatialization Mix at 100. +- Cluster outputs are mono. A stereo input is mixed down with each channel at -3 dB, the Wwise stereo to mono downmix. +- Every other input passes through: it gets its own output with the same channel configuration, audio, positioning, custom metadata and priority. This covers inputs with more than two channels such as ambiences and beds, 2D sounds and partly spatialized sounds. +- Inputs only share a cluster when they carry the same set of custom metadata plug-in types. + +## Direction from the Listener + +On an Audio Objects bus, object positions are relative to the listener. The plug-in compares the directions of the inputs, so "nearby" means "within a given angle as heard by the listener". With **Distance Sensitivity** above 0, a difference in distance is added to the angle on a log scale: at 1, a doubling of distance counts as much as the **Cluster Width**. + +Inputs at the listener position, closer than 0.0001 game units, such as sounds on the listener's own game object, have no direction. They share clusters only with each other, and their clusters never merge with clusters that have a direction. + +## Joining and Leaving + +- A new input joins the nearest cluster within the **Cluster Width**. If there is none, it starts a new cluster. +- When the **Object Limit** is reached, a new input with no cluster within the **Cluster Width** joins the nearest cluster within 1.5 × the **Cluster Width**. If there is none either, it still starts a new cluster, and the output budget merges the closest pair of clusters in the same frame. +- An input leaves its cluster only after it has been farther than 1.5 × the **Cluster Width** from the cluster for the **Hold Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. With a Cluster Width of 120° or more, inputs never leave because of their direction. +- A leaving input joins another cluster within the **Cluster Width**, or starts its own cluster. While the **Object Limit** is reached and no other cluster is within the **Cluster Width**, it stays in its cluster and tries again after another Hold Time. +- An input whose custom metadata plug-in types change leaves its cluster right away, with a crossfade, unless it is the only input in the cluster. +- A cluster with a single input follows that input, and the input never leaves it. The cluster can still merge with another one. + +## Crossfaded Moves + +When an input moves to another cluster, its gain in the old cluster ramps down while its gain in the new cluster ramps up over the **Crossfade Time**. The two gains always add up to 1. + +A switch between clustered and passthrough, for example when an RTPC moves Speaker Panning / 3D Spatialization Mix below 100, happens without a crossfade. + +## Merging + +When a cluster's nearest neighbor stays within the **Cluster Width** for the **Hold Time**, the two clusters merge. The quieter cluster fades into the louder one. A merge only happens if the merged cluster would still fit within the **Cluster Width**. + +## Output Budget + +With an **Object Limit** above 0, the plug-in counts clusters plus passthrough outputs. When the count is over the limit, it merges the closest pairs of clusters right away, without waiting for the Hold Time. A higher priority makes a pair count as farther apart, so it merges later. + +- Budget merges have no angle limit, so a tight budget can produce wide clusters. +- At the limit, a new input that is farther than 1.5 × the **Cluster Width** from every cluster gets a cluster of its own, and the closest pair of clusters merges instead. A sound far from all others is therefore not pulled into a distant cluster. +- Passthrough inputs, excluded inputs, clusters at the listener position and clusters with different custom metadata cannot be merged. If the limit cannot be reached, the plug-in posts a message to the Capture Log. +- A cluster that has been merged keeps its output until the inputs moving out of it have faded, so the number of outputs can be above the limit for up to two crossfades. + +## Cluster Outputs + +- **Position**: the average direction of the contributing inputs, at their average distance. Each input counts by its RMS level, smoothed over about 50 ms, so a cluster sits closer to its louder inputs. +- **One contributing input**: the output copies the input's positioning. +- **Several contributing inputs, including inputs fading in or out**: the output uses Position spatialization at the cluster position with the default orientation. Its spread is the larger of the representative input's spread and **Cluster Spread** × cluster angular radius / 180°. The representative is the loudest input, and it can change from frame to frame. +- **Priority**: the highest priority among the contributing inputs. +- **Custom metadata**: copied from the representative input. +- **Name**: cluster outputs appear as `Cluster ` in the profiler. Passthrough outputs are named `Passthrough`, or keep the name an upstream object processor gave their input. + +## Exclusions + +Inputs with a priority above the **Priority Threshold**, inputs within the **Proximity Radius** of the listener and inputs with an invalid position get a cluster of their own that no other input joins and that never merges. Like every cluster, its output is mono. An input that becomes excluded while it is in a cluster leaves it after the Hold Time, with a crossfade. + +## Performance + +All memory comes from the Wwise plug-in allocator. Room for 64 inputs is reserved in Init, and it grows when more inputs or outputs are needed. + +In the benchmark, an update for 512 inputs takes about 20 to 60 µs. Lowering the Object Limit far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.6 ms for 512 inputs and 1.3 to 4 ms for 1024 inputs. diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..179eb84 --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,17 @@ +# Use It in Your Game + +The runtime plug-in must come from the same Wwise version as your game, since the sound engine rejects plug-ins built for another version. Installing the bundle puts the runtime files in your Wwise install's `SDK` folder. + +- **Static linking**: link `SpatialClusteringFX.lib` from `SDK\x64_vc180\\lib` and include `` in one source file. +- **Dynamic loading**: ship `SpatialClustering.dll` from `SDK\x64_vc180\\bin` next to the executable, or in the folder set in `AkInitSettings::szPluginDLLPath`. The sound engine loads it when the Init SoundBank lists the plug-in and the game does not link it statically, so the game needs no code change. To build the DLL against your game's exact SDK, see [Build with CMake](building.md#build-with-cmake). +- **CMake**: build the plug-in from source with your game, see [Build with CMake](building.md#build-with-cmake). Link `SpatialClustering::SpatialClusteringFX`, from `find_package(SpatialClustering CONFIG)` or from `add_subdirectory` on this repository, and include `` in one source file. +- **Unity and Unreal**: add the plug-in to your project's Wwise integration the way Audiokinetic describes for plug-ins, using the same files. + +## Upgrading from 2.0.0 or Earlier + +Version 3.0.0 renames the plug-in from ObjectCluster to Spatial Clustering and replaces the clustering and its parameters. + +1. Remove the old plug-in from your Wwise install, since both versions use the same company and plug-in IDs. Delete `ObjectCluster.dll` and `ObjectCluster.xml` from `Authoring\x64\Release\bin\Plugins`, and `ObjectCluster.dll` and `ObjectClusterFX.lib` from the `SDK` folders you built into. +2. Install Spatial Clustering. Wwise projects find the effect as before, but the parameters are new, so check the settings of every Spatial Clustering effect. +3. In your game, link `SpatialClusteringFX.lib` and include `SpatialClusteringFXFactory.h` instead of `ObjectClusterFX.lib` and `ObjectClusterFXFactory.h`, or ship `SpatialClustering.dll` instead of `ObjectCluster.dll`. +4. Regenerate your SoundBanks. diff --git a/docs/parameters.md b/docs/parameters.md new file mode 100644 index 0000000..8aa7d93 --- /dev/null +++ b/docs/parameters.md @@ -0,0 +1,20 @@ +# Parameters + +All parameters can be driven by RTPCs. The Wwise property editor groups them like the table below, and its help panel has the same descriptions. Most scenes only need **Cluster Width**, and **Object Limit** when you have a fixed object budget. + +| Group | Parameter | Range | Default | Description | +|---|---|---|---|---| +| Clustering | Cluster Width, degrees | 0 to 180 | 15 | Largest angle between an input and a cluster for the input to join it. 0 turns off merging by angle. | +| Clustering | Object Limit | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | +| Exclusions | Priority Threshold | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | +| Exclusions | Proximity Radius, game units | 0 to 1,000,000 | 0 | Inputs closer to the listener than this are never merged. The slider goes to 1000; type larger values. | +| Advanced | Hold Time, ms | 0 to 5000 | 200 | Time an input must stay outside its cluster before it leaves, and time two clusters must stay close before they merge. | +| Advanced | Crossfade Time, ms | 0 to 1000 | 50 | Time over which a moving input fades between clusters. | +| Advanced | Distance Sensitivity | 0 to 1 | 0 | How much a difference in distance counts against the Cluster Width. 0 uses the angle only. | +| Advanced | Cluster Spread | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | + +Hold Time and Crossfade Time are rounded to whole audio frames. At 48 kHz with 1024-sample buffers, one frame is about 21 ms. A crossfade always lasts at least one frame. + +The same controls cover the whole range. A Cluster Width of 180 with the Object Limit at 1 mixes all clusterable inputs into one object, as long as no passthrough, excluded or listener-position inputs need outputs of their own and all inputs carry the same custom metadata. A Cluster Width of 0 with a Object Limit limit only merges when the limit is reached. + +See [How it works](how-it-works.md) for what each parameter does in detail. diff --git a/scripts/package.py b/scripts/package.py new file mode 100644 index 0000000..7975b95 --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,183 @@ +# Copyright 2026 CCP ehf. +# +# This software was developed by CCP Games for spatial audio object clustering +# in EVE Online and EVE Frontier. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This license does not grant any rights to CCP's trademarks or game content. +# EVE Online and EVE Frontier are registered trademarks of CCP ehf. + +"""Builds the Spatial Clustering plug-in and packages it as a Wwise Launcher bundle. + +Run it from a Developer Command Prompt for VS 2026, with WWISEROOT set to the Wwise +install to build against: + + python scripts/package.py build everything, then package + python scripts/package.py --skip-build package what is already built + +The result is dist/SpatialClustering__Wwise./ and a zip of it. +Users install it with Wwise Launcher > Plug-ins > Add from directory, pointing at the +bundle folder inside. + +wp.py writes its build outputs into the Wwise install itself, as for any plug-in build. +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tarfile +from pathlib import Path + +PLUGIN_NAME = "SpatialClustering" +REPO_ROOT = Path(__file__).resolve().parent.parent +WWISE_PROJECT = REPO_ROOT / "wwise" +DIST_DIR = REPO_ROOT / "dist" +VERSION_HEADER = WWISE_PROJECT / "SpatialClusteringVersion.h" + +# (wp.py platform, toolset, configurations). The authoring plug-in only ships in Release, +# since generate-bundle marks Debug authoring packages as needing a Wwise debug license. +BUILDS = [ + ("Windows_vc180", "vc180", ["Debug", "Profile", "Release"]), + ("Authoring", "vc170", ["Release"]), +] +PACKAGES = ["Common", "Windows_vc180", "Authoring", "Documentation"] +EXTRA_FILES = ["LICENSE.txt", "NOTICE"] + + +def fail(message): + print(f"error: {message}", file=sys.stderr) + sys.exit(1) + + +def read_defines(path, names): + """Returns the integer value of each #define in names, read from a header.""" + text = path.read_text(encoding="utf-8") + values = [] + for name in names: + match = re.search(rf"^#define\s+{name}\s+(\d+)\s*$", text, re.MULTILINE) + if not match: + fail(f"{name} is not defined in {path}") + values.append(int(match.group(1))) + return values + + +def run_wp(wp_py, *args): + command = [sys.executable, str(wp_py), *args] + print(f"\n> wp.py {' '.join(args)}", flush=True) + result = subprocess.run(command, cwd=WWISE_PROJECT) + if result.returncode != 0: + fail(f"wp.py {' '.join(args)} failed with exit code {result.returncode}") + + +def has_visual_studio_2022(): + """Returns True if wp.py can find the Visual Studio 2022 MSBuild it uses for vc170 projects.""" + program_files = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + vswhere = Path(program_files) / "Microsoft Visual Studio" / "Installer" / "vswhere.exe" + if not vswhere.is_file(): + return False + result = subprocess.run( + [str(vswhere), "-version", "[17.0,18.0)", "-requires", "Microsoft.Component.MSBuild", "-prerelease", + "-property", "installationPath"], + capture_output=True, text=True) + return bool(result.stdout.strip()) + + +def run_msbuild(solution, configuration): + """Builds a solution that wp.py premake generated, with the same MSBuild arguments wp.py uses.""" + if not shutil.which("msbuild"): + fail("msbuild is not on PATH; run this script from a Developer Command Prompt for VS 2026") + command = ["msbuild", solution, "/t:Build", f"/p:Configuration={configuration}", "/p:Platform=x64", "/m", + "/verbosity:minimal"] + print(f"\n> {' '.join(command)}", flush=True) + result = subprocess.run(command, cwd=WWISE_PROJECT) + if result.returncode != 0: + fail(f"msbuild {solution} failed with exit code {result.returncode}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--skip-build", action="store_true", help="package the plug-in that is already built") + args = parser.parse_args() + + if "WWISEROOT" not in os.environ: + fail("WWISEROOT is not set; set it to the Wwise install to build against") + wwise_root = Path(os.environ["WWISEROOT"]) + wp_py = wwise_root / "Scripts" / "Build" / "Plugins" / "wp.py" + if not wp_py.is_file(): + fail(f"{wp_py} does not exist") + + major, minor, patch = read_defines( + VERSION_HEADER, + ["SPATIALCLUSTERING_VERSION_MAJOR", "SPATIALCLUSTERING_VERSION_MINOR", "SPATIALCLUSTERING_VERSION_PATCH"]) + if minor > 99: + fail("the minor version must be below 100 to fit the bundle version") + wwise_year, wwise_major = read_defines( + wwise_root / "SDK" / "include" / "AK" / "AkWwiseSDKVersion.h", + ["AK_WWISESDK_VERSION_MAJOR", "AK_WWISESDK_VERSION_MINOR"]) + + # The Launcher reads year.major of the bundle version as the Wwise version the bundle targets, + # so the plug-in version goes in the last two numbers: 3.1.2 on Wwise 2025.1 is 2025.1.301.2. + plugin_version = f"{major}.{minor}.{patch}" + bundle_version = f"{wwise_year}.{wwise_major}.{major * 100 + minor}.{patch}" + print(f"Spatial Clustering {plugin_version} for Wwise {wwise_year}.{wwise_major} (bundle version {bundle_version})") + + if not args.skip_build: + for platform, toolset, configurations in BUILDS: + run_wp(wp_py, "premake", platform, "-t", toolset) + for configuration in configurations: + if platform == "Authoring" and not has_visual_studio_2022(): + # wp.py only looks for a Visual Studio 2022 installation to build vc170 projects. With the + # v143 toolset installed in Visual Studio 2026 instead, MSBuild from VS 2026 builds the solution. + run_msbuild(f"{PLUGIN_NAME}_Authoring_Windows_{toolset}.sln", configuration) + else: + run_wp(wp_py, "build", platform, "-c", configuration, "-x", "x64", "-t", toolset) + run_wp(wp_py, "build", "Documentation") + + # generate-bundle lists every package in the folder, so remove those left by earlier runs. + for stale in [*WWISE_PROJECT.glob(f"{PLUGIN_NAME}_*.tar.xz"), WWISE_PROJECT / "bundle.json"]: + stale.unlink(missing_ok=True) + + for platform in PACKAGES: + run_wp(wp_py, "package", platform, "--version", bundle_version) + # wp.py writes an archive for every configuration, also the ones not built, such as Debug authoring. + for package in WWISE_PROJECT.glob(f"{PLUGIN_NAME}_*.tar.xz"): + with tarfile.open(package, "r:xz") as archive: + empty = not any(member.isfile() for member in archive.getmembers()) + if empty: + print(f"Leaving out empty package {package.name}") + package.unlink() + run_wp(wp_py, "generate-bundle", "--version", bundle_version) + + out_dir = DIST_DIR / f"{PLUGIN_NAME}_{plugin_version}_Wwise{wwise_year}.{wwise_major}" + if out_dir.exists(): + shutil.rmtree(out_dir) + bundle_dir = out_dir / "bundle" + bundle_dir.mkdir(parents=True) + for package in [*WWISE_PROJECT.glob(f"{PLUGIN_NAME}_*.tar.xz"), WWISE_PROJECT / "bundle.json"]: + shutil.move(str(package), bundle_dir / package.name) + for name in EXTRA_FILES: + shutil.copy2(REPO_ROOT / name, out_dir / name) + archive = shutil.make_archive(str(out_dir), "zip", root_dir=DIST_DIR, base_dir=out_dir.name) + + print(f"\nBundle folder: {bundle_dir}") + for item in sorted(bundle_dir.iterdir()): + print(f" {item.name}") + print(f"Release zip: {archive}") + + +if __name__ == "__main__": + main() diff --git a/FactoryAssets/Manifest.xml b/wwise/FactoryAssets/Manifest.xml similarity index 90% rename from FactoryAssets/Manifest.xml rename to wwise/FactoryAssets/Manifest.xml index c61df72..5d69d98 100644 --- a/FactoryAssets/Manifest.xml +++ b/wwise/FactoryAssets/Manifest.xml @@ -28,8 +28,8 @@ the specific language governing permissions and limitations under the License. See https://www.audiokinetic.com/library/edge/?source=Help&id=import_factory_assets for more information on factory assets --> - + - + diff --git a/PremakePlugin.lua b/wwise/PremakePlugin.lua similarity index 88% rename from PremakePlugin.lua rename to wwise/PremakePlugin.lua index ad557c4..fa526db 100644 --- a/PremakePlugin.lua +++ b/wwise/PremakePlugin.lua @@ -29,8 +29,10 @@ if not _AK_PREMAKE then end local Plugin = {} -Plugin.name = "ObjectCluster" -Plugin.factoryheader = "../SoundEnginePlugin/ObjectClusterFXFactory.h" +Plugin.name = "SpatialClustering" +Plugin.factoryheader = "../SoundEnginePlugin/SpatialClusteringFXFactory.h" +Plugin.appleteamid = "" +Plugin.signtoolargs = {} Plugin.sdk = {} Plugin.sdk.static = {} Plugin.sdk.shared = {} @@ -39,7 +41,7 @@ Plugin.authoring = {} -- SDK STATIC PLUGIN SECTION Plugin.sdk.static.includedirs = -- https://github.com/premake/premake-core/wiki/includedirs { - os.getenv("WWISESDK") .. "/include" + "../../core/src", } Plugin.sdk.static.files = -- https://github.com/premake/premake-core/wiki/files { @@ -47,10 +49,12 @@ Plugin.sdk.static.files = -- https://github.com/premake/premake-core/wiki/files "**.h", "**.hpp", "**.c", + "../../core/src/ClusterEngine.cpp", + "../../core/src/ClusterEngine.h", } Plugin.sdk.static.excludes = -- https://github.com/premake/premake-core/wiki/removefiles { - "ObjectClusterFXShared.cpp" + "SpatialClusteringFXShared.cpp" } Plugin.sdk.static.links = -- https://github.com/premake/premake-core/wiki/links { @@ -66,12 +70,11 @@ Plugin.sdk.static.defines = -- https://github.com/premake/premake-core/wiki/defi -- SDK SHARED PLUGIN SECTION Plugin.sdk.shared.includedirs = { - os.getenv("WWISESDK") .. "/include" } Plugin.sdk.shared.files = { - "ObjectClusterFXShared.cpp", - "ObjectClusterFXFactory.h", + "SpatialClusteringFXShared.cpp", + "SpatialClusteringFXFactory.h", } Plugin.sdk.shared.excludes = { @@ -89,7 +92,6 @@ Plugin.sdk.shared.defines = -- AUTHORING PLUGIN SECTION Plugin.authoring.includedirs = { - os.getenv("WWISESDK") .. "/include" } Plugin.authoring.files = { @@ -97,8 +99,8 @@ Plugin.authoring.files = "**.h", "**.hpp", "**.c", - "ObjectCluster.def", - "ObjectCluster.xml", + "SpatialClustering.def", + "SpatialClustering.xml", "**.rc", } Plugin.authoring.excludes = diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp new file mode 100644 index 0000000..7c983f9 --- /dev/null +++ b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp @@ -0,0 +1,921 @@ +/******************************************************************************* +The content of this file includes portions of the AUDIOKINETIC Wwise Technology +released in source code form as part of the SDK installer package. + +Commercial License Usage + +Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology +may use this file in accordance with the end user license agreement provided +with the software or, alternatively, in accordance with the terms contained in a +written agreement between you and Audiokinetic Inc. + +Apache License Usage + +Alternatively, this file may be used under the Apache License, Version 2.0 (the +"Apache License"); you may not use this file except in compliance with the +Apache License. You may obtain a copy of the Apache License at +http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software distributed +under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for +the specific language governing permissions and limitations under the License. + + Copyright (c) 2023 Audiokinetic Inc. + Copyright (c) 2024 CCP ehf. + +This implementation was developed by CCP Games for spatial audio object clustering +in EVE Online and EVE Frontier. This implementation does not grant any rights to +CCP's trademarks or game content. EVE Online and EVE Frontier are registered +trademarks of CCP ehf. +*******************************************************************************/ + +#include "SpatialClusteringFX.h" +#include "../SpatialClusteringConfig.h" + +#include +#include +#include +#include + +#include + +AK::IAkPlugin* CreateSpatialClusteringFX(AK::IAkPluginMemAlloc* in_pAllocator) +{ + return AK_PLUGIN_NEW(in_pAllocator, SpatialClusteringFX()); +} + +AK::IAkPluginParam* CreateSpatialClusteringFXParams(AK::IAkPluginMemAlloc* in_pAllocator) +{ + return AK_PLUGIN_NEW(in_pAllocator, SpatialClusteringFXParams()); +} + +AK_IMPLEMENT_PLUGIN_FACTORY(SpatialClusteringFX, AkPluginTypeEffect, SpatialClusteringConfig::CompanyID, SpatialClusteringConfig::PluginID) + +namespace +{ + /** @brief Number of inputs and outputs that Init reserves memory for. */ + const AkUInt32 kInitialCapacity = 64; + + /** @brief Time constant in seconds of the input loudness smoothing. */ + const AkReal32 kLoudnessTimeConstant = 0.05f; + + /** @brief Minimum time in seconds between two monitor messages of the same kind. */ + const AkReal32 kMessageIntervalSeconds = 5.f; + + /** @brief Angular radius in degrees of a cluster whose spread is 1, since a spread of 0.5 covers 180 degrees. */ + const AkReal32 kFullSpreadRadius = 180.f; + + /** @brief Name of passthrough outputs whose input has no name. */ + const char kPassthroughName[] = "Passthrough"; + + /** @brief Start of the name of cluster outputs. */ + const char kClusterNamePrefix[] = "Cluster "; + + /** @brief Message posted when the plug-in is not on a bus. */ + const char kNotOnBusMessage[] = "Spatial Clustering only runs on busses. Insert it on an Audio Objects bus."; + + /** @brief Message posted when the bus is not an Audio Objects bus. */ + const char kNotObjectBusMessage[] = "Spatial Clustering is not on an Audio Objects bus, so there is nothing to cluster and the bus signal passes through. Set the bus channel configuration to Audio Objects."; + + /** @brief Message posted when a plug-in service is missing. */ + const char kMissingServiceMessage[] = "Spatial Clustering could not get the hash table service from the sound engine."; + + /** @brief Message posted when output objects cannot be created. */ + const char kCreateFailedMessage[] = "Spatial Clustering could not create output objects, so some inputs are silent."; + + /** @brief Message posted when memory runs out. */ + const char kOutOfMemoryMessage[] = "Spatial Clustering ran out of memory, so some audio may drop out."; + + /** @brief Message posted when the inputs that are left cannot be merged down to the Object Limit. */ + const char kOverBudgetMessage[] = "Spatial Clustering needs more outputs than the Object Limit allows because the remaining inputs cannot be merged (passthrough inputs, excluded inputs or different custom metadata)."; + + /** @brief Returns a value limited to a range. */ + template + T Clamp(T in_value, T in_min, T in_max) + { + return in_value < in_min ? in_min : (in_value > in_max ? in_max : in_value); + } + + /** @brief Converts a time in milliseconds to a number of frames, rounded to the nearest frame. */ + AkUInt32 MillisecondsToFrames(AkReal32 in_fMilliseconds, AkReal32 in_fFrameSeconds) + { + return static_cast(in_fMilliseconds * 0.001f / in_fFrameSeconds + 0.5f); + } + + /** @brief Largest number of channels of an input that can be clustered. */ + constexpr AkUInt32 kMaxClusterableChannels = 2; + + /** @brief Returns true for mono and stereo inputs that are fully spatialized in 3D. */ + bool IsClusterable(const AkAudioObject& in_object, const AkAudioBuffer& in_buffer) + { + const AkBehavioralPositioningData& behavioral = in_object.positioning.behavioral; + const Ak3DSpatializationMode spatMode = behavioral.spatMode; + const AkUInt32 uNumChannels = in_buffer.NumChannels(); + return uNumChannels >= 1 && uNumChannels <= kMaxClusterableChannels + && (spatMode == AK_SpatializationMode_PositionOnly || spatMode == AK_SpatializationMode_PositionAndOrientation) + && behavioral.panSpatMix >= 1.f; + } + + /** + * @brief Returns the gain applied to each channel of an input when it is mixed down to a + * mono cluster output: 1 for mono and -3 dB for stereo, the Wwise stereo to mono downmix. + */ + AkReal32 DownmixGain(AkUInt32 in_uNumChannels) + { + return in_uNumChannels > 1 ? 1.f / std::sqrt(static_cast(in_uNumChannels)) : 1.f; + } + + /** @brief Returns the RMS level of the mono downmix of a buffer over the valid frames. */ + AkReal32 ComputeRms(AkAudioBuffer& in_buffer) + { + const AkUInt32 uFrames = in_buffer.uValidFrames; + const AkUInt32 uNumChannels = in_buffer.NumChannels(); + if (uFrames == 0 || uNumChannels == 0) + return 0.f; + + const AkReal32 fGain = DownmixGain(uNumChannels); + AkReal32 fSum = 0.f; + for (AkUInt32 i = 0; i < uFrames; ++i) + { + AkReal32 fSample = 0.f; + for (AkUInt32 channel = 0; channel < uNumChannels; ++channel) + fSample += in_buffer.GetChannel(channel)[i]; + fSample *= fGain; + fSum += fSample * fSample; + } + + const AkReal32 fRms = std::sqrt(fSum / static_cast(uFrames)); + return std::isfinite(fRms) ? fRms : 0.f; + } + + /** @brief Returns a hash of the custom metadata plug-in types that does not depend on their order, or 0 when there is none. */ + AkUInt32 HashCustomMetadata(const AkAudioObject& in_object) + { + const AkAudioObject::ArrayCustomMetadata& metadata = in_object.arCustomMetadata; + if (metadata.IsEmpty()) + return 0; + + AkUInt64 uSum = 0; + for (AkAudioObject::ArrayCustomMetadata::Iterator it = metadata.Begin(); it != metadata.End(); ++it) + uSum += AkHashMurMurMix64(static_cast((*it).pluginID)); + + const AkUInt32 uHash = static_cast(uSum ^ (uSum >> 32)); + return uHash != 0 ? uHash : 1; + } + + /** @brief Adds one channel to another with a gain that ramps linearly over the frames. */ + void MixRamp(const AkSampleType* in_pInput, AkSampleType* io_pOutput, AkUInt32 in_uFrames, AkReal32 in_fGainStart, AkReal32 in_fGainEnd) + { + if (in_uFrames == 0) + return; + + const AkReal32 fStep = (in_fGainEnd - in_fGainStart) / static_cast(in_uFrames); + for (AkUInt32 i = 0; i < in_uFrames; ++i) + io_pOutput[i] += in_pInput[i] * (in_fGainStart + fStep * static_cast(i)); + } + + /** @brief Writes "Cluster " into a character array. */ + template + void FormatClusterName(AkUInt32 in_uId, char (&out_szName)[N]) + { + AkUInt32 uLength = 0; + for (const char* pChar = kClusterNamePrefix; *pChar != '\0' && uLength + 1 < N; ++pChar) + out_szName[uLength++] = *pChar; + + char aDigits[10]; + AkUInt32 uNumDigits = 0; + do + { + aDigits[uNumDigits++] = static_cast('0' + in_uId % 10); + in_uId /= 10; + } while (in_uId != 0); + + while (uNumDigits > 0 && uLength + 1 < N) + out_szName[uLength++] = aDigits[--uNumDigits]; + + out_szName[uLength] = '\0'; + } + + /** @brief Returns the slot of a key in a hash table, or -1 when the key is missing. */ + template + AkInt32 FindSlot(const AK::AkHashTable& in_table, AkUInt64 in_uKey) + { + return AK::HashTable::GetFirstSlotForKey(&in_table, in_uKey); + } + + /** @brief Returns true if a hash table holds memory. */ + bool HasMemory(const AK::AkHashTableBase& in_table) + { + return in_table.pKeys != nullptr || in_table.uNumReservedEntries != 0; + } +} + +void SpatialClusteringAllocator::Init(AK::IAkPluginMemAlloc* in_pAllocator) +{ + m_pAllocator = in_pAllocator; +} + +void* SpatialClusteringAllocator::Alloc(size_t in_size) +{ + if (m_pAllocator == nullptr) + return nullptr; + + return AK_PLUGIN_ALLOC(m_pAllocator, in_size); +} + +void SpatialClusteringAllocator::Free(void* in_pBlock) +{ + if (m_pAllocator != nullptr && in_pBlock != nullptr) + AK_PLUGIN_FREE(m_pAllocator, in_pBlock); +} + +SpatialClusteringFX::SpatialClusteringFX() + : m_pParams(nullptr) + , m_pAllocator(nullptr) + , m_pContext(nullptr) + , m_pPriorityService(nullptr) + , m_pHashTableService(nullptr) + , m_bEngineInitialized(false) + , m_fClusterSpread(1.f) + , m_uSampleRate(0) + , m_uParamsMaxFrames(0) + , m_uMessageInterval(1) + , m_uFrame(0) + , m_uNumOutputs(0) + , m_aMessageFrames{} + , m_aMessagePosted{} +{ +} + +SpatialClusteringFX::~SpatialClusteringFX() +{ +} + +AKRESULT SpatialClusteringFX::Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffectPluginContext* in_pContext, AK::IAkPluginParam* in_pParams, AkAudioFormat& io_rFormat) +{ + m_pParams = static_cast(in_pParams); + m_pAllocator = in_pAllocator; + m_pContext = in_pContext; + + if (in_pContext->GetMixerCtx() == nullptr) + { + in_pContext->PostMonitorMessage(kNotOnBusMessage, AK::Monitor::ErrorLevel_Error); + return AK_Fail; + } + + if (io_rFormat.channelConfig.eConfigType != AK_ChannelConfigType_Objects) + in_pContext->PostMonitorMessage(kNotObjectBusMessage, AK::Monitor::ErrorLevel_Error); + + io_rFormat.channelConfig.SetObject(); + + AK::IAkGlobalPluginContext* pGlobalContext = in_pContext->GlobalContext(); + m_pPriorityService = AK_GET_PLUGIN_SERVICE_AUDIO_OBJECT_PRIORITY(pGlobalContext); + m_pHashTableService = AK_GET_PLUGIN_SERVICE_HASHTABLE(pGlobalContext); + if (m_pHashTableService == nullptr) + { + in_pContext->PostMonitorMessage(kMissingServiceMessage, AK::Monitor::ErrorLevel_Error); + return AK_Fail; + } + + m_uSampleRate = pGlobalContext->GetSampleRate(); + if (m_uSampleRate == 0) + m_uSampleRate = io_rFormat.uSampleRate; + + m_inputs.Init(in_pAllocator); + m_inputPriorities.Init(in_pAllocator); + m_requests.Init(in_pAllocator); + m_newObjects.Init(in_pAllocator); + m_outputObjects.Init(in_pAllocator); + m_outputBuffers.Init(in_pAllocator); + m_clusterOutputs.Init(in_pAllocator); + m_clusterValidFrames.Init(in_pAllocator); + m_clusterHasData.Init(in_pAllocator); + m_outputWritten.Init(in_pAllocator); + m_priorityObjects.Init(in_pAllocator); + m_priorityValues.Init(in_pAllocator); + + const bool bArraysReserved = m_inputs.Reserve(kInitialCapacity) == AK_Success + && m_inputPriorities.Reserve(kInitialCapacity) == AK_Success + && m_requests.Reserve(kInitialCapacity) == AK_Success + && m_newObjects.Reserve(kInitialCapacity) == AK_Success + && m_outputObjects.Reserve(kInitialCapacity + 1) == AK_Success + && m_outputBuffers.Reserve(kInitialCapacity + 1) == AK_Success + && m_clusterOutputs.Reserve(kInitialCapacity) == AK_Success + && m_clusterValidFrames.Reserve(kInitialCapacity) == AK_Success + && m_clusterHasData.Reserve(kInitialCapacity) == AK_Success + && m_outputWritten.Reserve(kInitialCapacity + 1) == AK_Success + && m_priorityObjects.Reserve(kInitialCapacity) == AK_Success + && m_priorityValues.Reserve(kInitialCapacity) == AK_Success; + + const bool bTablesReserved = bArraysReserved + && m_pHashTableService->Init(&m_passthroughs, in_pAllocator, kInitialCapacity) == AK_Success + && m_pHashTableService->Init(&m_clusters, in_pAllocator, kInitialCapacity) == AK_Success + && m_pHashTableService->Init(&m_outputIndices, in_pAllocator, kInitialCapacity) == AK_Success; + + m_engineAllocator.Init(in_pAllocator); + m_bEngineInitialized = bTablesReserved; + if (!bTablesReserved || !m_engine.Init(&m_engineAllocator, kInitialCapacity)) + { + ReleaseMemory(); + return AK_InsufficientMemory; + } + + m_uParamsMaxFrames = 0; + return AK_Success; +} + +AKRESULT SpatialClusteringFX::Term(AK::IAkPluginMemAlloc* in_pAllocator) +{ + ReleaseMemory(); + AK_PLUGIN_DELETE(in_pAllocator, this); + return AK_Success; +} + +AKRESULT SpatialClusteringFX::Reset() +{ + if (m_bEngineInitialized) + m_engine.Reset(); + + if (m_pHashTableService != nullptr) + { + if (HasMemory(m_passthroughs)) + m_pHashTableService->Reset(&m_passthroughs); + if (HasMemory(m_clusters)) + m_pHashTableService->Reset(&m_clusters); + if (HasMemory(m_outputIndices)) + m_pHashTableService->Reset(&m_outputIndices); + } + + m_requests.RemoveAll(); + m_priorityObjects.RemoveAll(); + m_priorityValues.RemoveAll(); + m_uNumOutputs = 0; + m_uParamsMaxFrames = 0; + return AK_Success; +} + +AKRESULT SpatialClusteringFX::GetPluginInfo(AkPluginInfo& out_rPluginInfo) +{ + out_rPluginInfo.eType = AkPluginTypeEffect; + out_rPluginInfo.bIsInPlace = false; + out_rPluginInfo.bCanProcessObjects = true; + out_rPluginInfo.uBuildVersion = AK_WWISESDK_VERSION_COMBINED; + return AK_Success; +} + +void SpatialClusteringFX::Execute(const AkAudioObjects& in_objects, const AkAudioObjects& out_objects) +{ + ++m_uFrame; + + const AkUInt32 uExistingOutputs = out_objects.uNumObjects; + const AkUInt16 uMaxFrames = in_objects.uNumObjects > 0 + ? in_objects.ppObjectBuffers[0]->MaxFrames() + : m_pContext->GlobalContext()->GetMaxBufferLength(); + UpdateClusterParams(uMaxFrames); + + if (!PrepareInputs(in_objects) || !m_engine.Update(m_inputs.Data(), in_objects.uNumObjects, m_clusterParams)) + { + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + KeepOutputsSilent(uExistingOutputs); + return; + } + + if (m_engine.IsOverBudget()) + PostRateLimitedMessage(MessageKind_OverBudget, kOverBudgetMessage, AK::Monitor::ErrorLevel_Message); + + const AkUInt32 uCreated = CreateOutputs(in_objects); + + if (!FetchOutputs(uExistingOutputs + uCreated)) + { + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + return; + } + + if (!BuildOutputIndex()) + { + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + SilenceOutputs(); + return; + } + + m_priorityObjects.RemoveAll(); + m_priorityValues.RemoveAll(); + + ProcessPassthroughs(in_objects); + ProcessClusters(in_objects); + ReleaseUnwrittenOutputs(); + ApplyPriorities(); + RemoveStaleEntries(); +} + +void SpatialClusteringFX::ReleaseMemory() +{ + if (m_bEngineInitialized) + m_engine.Term(); + m_bEngineInitialized = false; + + m_inputs.Term(); + m_inputPriorities.Term(); + m_requests.Term(); + m_newObjects.Term(); + m_outputObjects.Term(); + m_outputBuffers.Term(); + m_clusterOutputs.Term(); + m_clusterValidFrames.Term(); + m_clusterHasData.Term(); + m_outputWritten.Term(); + m_priorityObjects.Term(); + m_priorityValues.Term(); + + if (m_pHashTableService != nullptr && m_pAllocator != nullptr) + { + if (HasMemory(m_passthroughs)) + m_pHashTableService->Term(&m_passthroughs, m_pAllocator); + if (HasMemory(m_clusters)) + m_pHashTableService->Term(&m_clusters, m_pAllocator); + if (HasMemory(m_outputIndices)) + m_pHashTableService->Term(&m_outputIndices, m_pAllocator); + } + + m_passthroughs = AK::AkHashTable(); + m_clusters = AK::AkHashTable(); + m_outputIndices = AK::AkHashTable(); + m_uNumOutputs = 0; +} + +void SpatialClusteringFX::UpdateClusterParams(AkUInt16 in_uMaxFrames) +{ + if (in_uMaxFrames == m_uParamsMaxFrames && !m_pParams->m_paramChangeHandler.HasAnyChanged()) + return; + + m_uParamsMaxFrames = in_uMaxFrames; + + const SpatialClusteringRTPCParams& rtpc = m_pParams->RTPC; + const AkReal32 fFrameLength = static_cast(in_uMaxFrames > 0 ? in_uMaxFrames : 1); + const AkReal32 fSampleRate = static_cast(m_uSampleRate > 0 ? m_uSampleRate : 48000); + const AkReal32 fFrameSeconds = fFrameLength / fSampleRate; + + // The leave ratio (1.5) and loudness weighting (on) keep the ClusterEngine defaults. + m_clusterParams.joinAngle = Clamp(rtpc.clusterWidth, 0.f, PARAM_CLUSTER_WIDTH_MAX); + m_clusterParams.dwellFrames = MillisecondsToFrames(Clamp(rtpc.holdTime, 0.f, PARAM_HOLD_TIME_MAX), fFrameSeconds); + const AkUInt32 uCrossfadeFrames = MillisecondsToFrames(Clamp(rtpc.crossfadeTime, 0.f, PARAM_CROSSFADE_TIME_MAX), fFrameSeconds); + m_clusterParams.crossfadeFrames = uCrossfadeFrames > 0 ? uCrossfadeFrames : 1; + m_clusterParams.maxOutputs = static_cast(Clamp(rtpc.objectLimit, 0, PARAM_OBJECT_LIMIT_MAX)); + m_clusterParams.nearFieldRadius = Clamp(rtpc.proximityRadius, 0.f, PARAM_PROXIMITY_RADIUS_MAX); + m_clusterParams.distanceWeight = Clamp(rtpc.distanceSensitivity, 0.f, 1.f); + m_clusterParams.excludePriorityAbove = static_cast(Clamp(rtpc.priorityThreshold, 0, PARAM_PRIORITY_MAX)); + m_clusterParams.loudnessSmoothing = std::exp(-fFrameSeconds / kLoudnessTimeConstant); + m_fClusterSpread = Clamp(rtpc.clusterSpread, 0.f, 1.f); + + const AkUInt32 uMessageInterval = static_cast(kMessageIntervalSeconds / fFrameSeconds); + m_uMessageInterval = uMessageInterval > 0 ? uMessageInterval : 1; + + m_pParams->m_paramChangeHandler.ResetAllParamChanges(); +} + +bool SpatialClusteringFX::PrepareInputs(const AkAudioObjects& in_objects) +{ + const AkUInt32 uNumInputs = in_objects.uNumObjects; + if (!m_inputs.Resize(uNumInputs) || !m_inputPriorities.Resize(uNumInputs)) + return false; + + if (uNumInputs > 0 && m_pPriorityService != nullptr) + { + m_pPriorityService->GetPriorities(in_objects.ppObjects, uNumInputs, m_inputPriorities.Data()); + } + else + { + for (AkUInt32 i = 0; i < uNumInputs; ++i) + m_inputPriorities[i] = AK_DEFAULT_PRIORITY; + } + + for (AkUInt32 i = 0; i < uNumInputs; ++i) + { + const AkAudioObject& object = *in_objects.ppObjects[i]; + AkAudioBuffer& buffer = *in_objects.ppObjectBuffers[i]; + const AkVector& position = object.positioning.threeD.xform.Position(); + + ClusterInput& input = m_inputs[i]; + input.key = object.key; + input.position = ClusterVec3{ position.X, position.Y, position.Z }; + input.clusterable = IsClusterable(object, buffer); + input.loudness = input.clusterable ? ComputeRms(buffer) : 0.f; + input.priority = static_cast(Clamp(static_cast(m_inputPriorities[i]), 0, PARAM_PRIORITY_MAX)); + input.group = HashCustomMetadata(object); + } + + return true; +} + +AkUInt32 SpatialClusteringFX::CreateOutputs(const AkAudioObjects& in_objects) +{ + m_requests.RemoveAll(); + bool bOutOfMemory = false; + + for (AkUInt32 i = 0; i < in_objects.uNumObjects; ++i) + { + if (m_inputs[i].clusterable) + continue; + + const AkUInt32 uChannelConfig = in_objects.ppObjectBuffers[i]->GetChannelConfig().Serialize(); + const AkInt32 iSlot = FindSlot(m_passthroughs, m_inputs[i].key); + if (iSlot >= 0) + { + if (m_passthroughs.ValueAt(static_cast(iSlot)).channelConfig == uChannelConfig) + continue; + m_pHashTableService->RemoveSlot(&m_passthroughs, iSlot); + } + + if (!AddRequest(uChannelConfig, i, false)) + bOutOfMemory = true; + } + + AkChannelConfig monoConfig; + monoConfig.SetStandard(AK_SPEAKER_SETUP_MONO); + const AkUInt32 uMonoConfig = monoConfig.Serialize(); + + const AkUInt32 uNumClusters = m_engine.GetClusterCount(); + for (AkUInt32 c = 0; c < uNumClusters; ++c) + { + const ClusterInfo& info = m_engine.GetCluster(c); + const AkInt32 iSlot = FindSlot(m_clusters, static_cast(info.id)); + if (iSlot >= 0) + { + if (!info.isNew) + continue; + m_pHashTableService->RemoveSlot(&m_clusters, iSlot); + } + + if (!AddRequest(uMonoConfig, c, true)) + bOutOfMemory = true; + } + + if (bOutOfMemory) + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + + AkUInt32 uCreated = 0; + const AkUInt32 uNumRequests = m_requests.Length(); + for (AkUInt32 r = 0; r < uNumRequests; ++r) + { + if (m_requests[r].done) + continue; + + const AkUInt32 uChannelConfig = m_requests[r].channelConfig; + AkUInt32 uBatchSize = 0; + for (AkUInt32 k = r; k < uNumRequests; ++k) + { + if (!m_requests[k].done && m_requests[k].channelConfig == uChannelConfig) + ++uBatchSize; + } + + bool bCreated = false; + if (m_newObjects.Resize(uBatchSize)) + { + AkChannelConfig channelConfig; + channelConfig.Deserialize(uChannelConfig); + AkAudioObjects newObjects(uBatchSize, nullptr, m_newObjects.Data()); + bCreated = m_pContext->CreateOutputObjects(channelConfig, newObjects) == AK_Success; + if (!bCreated) + PostRateLimitedMessage(MessageKind_CreateFailed, kCreateFailedMessage, AK::Monitor::ErrorLevel_Error); + } + else + { + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + } + + AkUInt32 uObject = 0; + for (AkUInt32 k = r; k < uNumRequests; ++k) + { + OutputRequest& request = m_requests[k]; + if (request.done || request.channelConfig != uChannelConfig) + continue; + + request.done = true; + if (bCreated) + RegisterOutput(in_objects, request, m_newObjects[uObject++]); + } + + if (bCreated) + uCreated += uBatchSize; + } + + return uCreated; +} + +bool SpatialClusteringFX::AddRequest(AkUInt32 in_uChannelConfig, AkUInt32 in_uSource, bool in_bIsCluster) +{ + OutputRequest* pRequest = m_requests.AddLast(); + if (pRequest == nullptr) + return false; + + pRequest->channelConfig = in_uChannelConfig; + pRequest->source = in_uSource; + pRequest->isCluster = in_bIsCluster; + pRequest->done = false; + return true; +} + +void SpatialClusteringFX::RegisterOutput(const AkAudioObjects& in_objects, const OutputRequest& in_request, AkAudioObject* in_pObject) +{ + if (in_request.isCluster) + { + const ClusterInfo& info = m_engine.GetCluster(in_request.source); + ClusterEntry* pEntry = static_cast(m_pHashTableService->AddKey(&m_clusters, m_pAllocator, static_cast(info.id))); + if (pEntry == nullptr) + { + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + return; + } + + pEntry->outputKey = in_pObject->key; + pEntry->lastFrame = m_uFrame - 1; + + char szName[32]; + FormatClusterName(info.id, szName); + in_pObject->SetName(m_pAllocator, szName); + return; + } + + const AkAudioObject& input = *in_objects.ppObjects[in_request.source]; + PassthroughEntry* pEntry = static_cast(m_pHashTableService->AddKey(&m_passthroughs, m_pAllocator, input.key)); + if (pEntry == nullptr) + { + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + return; + } + + pEntry->outputKey = in_pObject->key; + pEntry->channelConfig = in_request.channelConfig; + pEntry->lastFrame = m_uFrame - 1; + + const char* szInputName = input.objectName.Get(); + in_pObject->SetName(m_pAllocator, (szInputName != nullptr && szInputName[0] != '\0') ? szInputName : kPassthroughName); +} + +bool SpatialClusteringFX::FetchOutputs(AkUInt32 in_uExpected) +{ + m_uNumOutputs = 0; + + AkUInt32 uCapacity = in_uExpected + 1; + if (!m_outputObjects.Resize(uCapacity) || !m_outputBuffers.Resize(uCapacity)) + return false; + + AkAudioObjects outputs(uCapacity, m_outputBuffers.Data(), m_outputObjects.Data()); + m_pContext->GetOutputObjects(outputs); + AkUInt32 uNumOutputs = outputs.uNumObjects; + if (uNumOutputs >= uCapacity) + { + AkAudioObjects count; + m_pContext->GetOutputObjects(count); + uCapacity = count.uNumObjects; + if (!m_outputObjects.Resize(uCapacity) || !m_outputBuffers.Resize(uCapacity)) + return false; + + AkAudioObjects allOutputs(uCapacity, m_outputBuffers.Data(), m_outputObjects.Data()); + m_pContext->GetOutputObjects(allOutputs); + uNumOutputs = allOutputs.uNumObjects; + } + + if (!m_outputWritten.Resize(uNumOutputs)) + return false; + + for (AkUInt32 i = 0; i < uNumOutputs; ++i) + m_outputWritten[i] = 0; + + m_uNumOutputs = uNumOutputs; + return true; +} + +bool SpatialClusteringFX::BuildOutputIndex() +{ + m_pHashTableService->Reset(&m_outputIndices); + + for (AkUInt32 i = 0; i < m_uNumOutputs; ++i) + { + AkUInt32* pIndex = static_cast(m_pHashTableService->AddKey(&m_outputIndices, m_pAllocator, m_outputObjects[i]->key)); + if (pIndex == nullptr) + return false; + *pIndex = i; + } + + return true; +} + +AkUInt32 SpatialClusteringFX::FindOutput(AkAudioObjectID in_key) const +{ + const AkInt32 iSlot = FindSlot(m_outputIndices, in_key); + return iSlot >= 0 ? m_outputIndices.ValueAt(static_cast(iSlot)) : kInvalidIndex; +} + +void SpatialClusteringFX::ProcessPassthroughs(const AkAudioObjects& in_objects) +{ + for (AkUInt32 i = 0; i < in_objects.uNumObjects; ++i) + { + if (m_inputs[i].clusterable) + continue; + + const AkInt32 iSlot = FindSlot(m_passthroughs, m_inputs[i].key); + if (iSlot < 0) + continue; + + PassthroughEntry& entry = m_passthroughs.ValueAt(static_cast(iSlot)); + const AkUInt32 uOutput = FindOutput(entry.outputKey); + if (uOutput == kInvalidIndex) + continue; + + entry.lastFrame = m_uFrame; + m_outputWritten[uOutput] = 1; + + AkAudioObject* pInObject = in_objects.ppObjects[i]; + AkAudioBuffer* pInBuffer = in_objects.ppObjectBuffers[i]; + AkAudioObject* pOutObject = m_outputObjects[uOutput]; + AkAudioBuffer* pOutBuffer = m_outputBuffers[uOutput]; + + const AkUInt32 uNumChannels = AkMin(pInBuffer->NumChannels(), pOutBuffer->NumChannels()); + const AkUInt16 uFrames = AkMin(pInBuffer->uValidFrames, pOutBuffer->MaxFrames()); + for (AkUInt32 channel = 0; channel < uNumChannels; ++channel) + AKPLATFORM::AkMemCpy(pOutBuffer->GetChannel(channel), pInBuffer->GetChannel(channel), static_cast(uFrames * sizeof(AkSampleType))); + + pOutBuffer->uValidFrames = uFrames; + pOutBuffer->eState = pInBuffer->eState; + pOutObject->positioning = pInObject->positioning; + if (pOutObject->arCustomMetadata.Copy(pInObject->arCustomMetadata) != AK_Success) + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + + QueuePriority(pOutObject, m_inputPriorities[i]); + } +} + +void SpatialClusteringFX::ProcessClusters(const AkAudioObjects& in_objects) +{ + const AkUInt32 uNumClusters = m_engine.GetClusterCount(); + if (!m_clusterOutputs.Resize(uNumClusters) || !m_clusterValidFrames.Resize(uNumClusters) || !m_clusterHasData.Resize(uNumClusters)) + { + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + return; + } + + for (AkUInt32 c = 0; c < uNumClusters; ++c) + { + const ClusterInfo& info = m_engine.GetCluster(c); + m_clusterOutputs[c] = kInvalidIndex; + m_clusterValidFrames[c] = 0; + m_clusterHasData[c] = 0; + + const AkInt32 iSlot = FindSlot(m_clusters, static_cast(info.id)); + if (iSlot < 0 || info.representative >= in_objects.uNumObjects) + continue; + + ClusterEntry& entry = m_clusters.ValueAt(static_cast(iSlot)); + const AkUInt32 uOutput = FindOutput(entry.outputKey); + if (uOutput == kInvalidIndex || m_outputBuffers[uOutput]->NumChannels() == 0) + continue; + + entry.lastFrame = m_uFrame; + m_clusterOutputs[c] = uOutput; + + AkAudioBuffer* pOutBuffer = m_outputBuffers[uOutput]; + const AkUInt32 uNumChannels = pOutBuffer->NumChannels(); + for (AkUInt32 channel = 0; channel < uNumChannels; ++channel) + AKPLATFORM::AkMemSet(pOutBuffer->GetChannel(channel), 0, static_cast(pOutBuffer->MaxFrames() * sizeof(AkSampleType))); + + AkAudioObject* pOutObject = m_outputObjects[uOutput]; + const AkAudioObject* pRepresentative = in_objects.ppObjects[info.representative]; + pOutObject->positioning = pRepresentative->positioning; + if (info.memberCount > 1) + { + Ak3dData& threeD = pOutObject->positioning.threeD; + pOutObject->positioning.behavioral.spatMode = AK_SpatializationMode_PositionOnly; + threeD.xform.SetPosition(info.position.x, info.position.y, info.position.z); + threeD.xform.SetOrientation( + AK_DEFAULT_LISTENER_FRONT_X, AK_DEFAULT_LISTENER_FRONT_Y, AK_DEFAULT_LISTENER_FRONT_Z, + AK_DEFAULT_TOP_X, AK_DEFAULT_TOP_Y, AK_DEFAULT_TOP_Z); + const AkReal32 fSizeSpread = m_fClusterSpread * Clamp(info.angularRadius / kFullSpreadRadius, 0.f, 1.f); + threeD.spread = AkMax(pRepresentative->positioning.threeD.spread, fSizeSpread); + } + + if (pOutObject->arCustomMetadata.Copy(pRepresentative->arCustomMetadata) != AK_Success) + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + + QueuePriority(pOutObject, static_cast(Clamp(info.priority, 0, static_cast(PARAM_PRIORITY_MAX)))); + } + + const AkUInt32 uNumContributions = m_engine.GetContributionCount(); + for (AkUInt32 k = 0; k < uNumContributions; ++k) + { + const ClusterContribution& contribution = m_engine.GetContribution(k); + if (contribution.cluster >= uNumClusters || contribution.input >= in_objects.uNumObjects) + continue; + + const AkUInt32 uOutput = m_clusterOutputs[contribution.cluster]; + AkAudioBuffer* pInBuffer = in_objects.ppObjectBuffers[contribution.input]; + if (uOutput == kInvalidIndex || pInBuffer->NumChannels() == 0) + continue; + + AkAudioBuffer* pOutBuffer = m_outputBuffers[uOutput]; + const AkUInt16 uFrames = AkMin(pInBuffer->uValidFrames, pOutBuffer->MaxFrames()); + const AkUInt32 uNumChannels = pInBuffer->NumChannels(); + const AkReal32 fDownmix = DownmixGain(uNumChannels); + for (AkUInt32 channel = 0; channel < uNumChannels; ++channel) + MixRamp(pInBuffer->GetChannel(channel), pOutBuffer->GetChannel(0), uFrames, + contribution.gainStart * fDownmix, contribution.gainEnd * fDownmix); + if (uFrames > m_clusterValidFrames[contribution.cluster]) + m_clusterValidFrames[contribution.cluster] = uFrames; + if (pInBuffer->eState != AK_NoMoreData) + m_clusterHasData[contribution.cluster] = 1; + } + + for (AkUInt32 c = 0; c < uNumClusters; ++c) + { + const AkUInt32 uOutput = m_clusterOutputs[c]; + if (uOutput == kInvalidIndex) + continue; + + AkAudioBuffer* pOutBuffer = m_outputBuffers[uOutput]; + pOutBuffer->uValidFrames = m_clusterValidFrames[c]; + pOutBuffer->eState = m_clusterHasData[c] != 0 ? AK_DataReady : AK_NoMoreData; + m_outputWritten[uOutput] = 1; + } +} + +void SpatialClusteringFX::ReleaseUnwrittenOutputs() +{ + for (AkUInt32 i = 0; i < m_uNumOutputs; ++i) + { + if (m_outputWritten[i] != 0) + continue; + + AkAudioBuffer* pBuffer = m_outputBuffers[i]; + const AkUInt32 uNumChannels = pBuffer->NumChannels(); + for (AkUInt32 channel = 0; channel < uNumChannels; ++channel) + AKPLATFORM::AkMemSet(pBuffer->GetChannel(channel), 0, static_cast(pBuffer->MaxFrames() * sizeof(AkSampleType))); + + pBuffer->uValidFrames = 0; + pBuffer->eState = AK_NoMoreData; + } +} + +void SpatialClusteringFX::QueuePriority(AkAudioObject* in_pObject, AkPriority in_priority) +{ + if (m_pPriorityService == nullptr) + return; + + if (m_priorityObjects.AddLast(in_pObject) == nullptr) + return; + + if (m_priorityValues.AddLast(in_priority) == nullptr) + m_priorityObjects.RemoveLast(); +} + +void SpatialClusteringFX::ApplyPriorities() +{ + const AkUInt32 uCount = m_priorityObjects.Length(); + if (m_pPriorityService == nullptr || uCount == 0) + return; + + m_pPriorityService->SetPriorities(m_priorityObjects.Data(), uCount, m_priorityValues.Data()); +} + +void SpatialClusteringFX::RemoveStaleEntries() +{ + const AkUInt32 uFrame = m_uFrame; + + m_pHashTableService->RemoveIf( + static_cast*>(&m_passthroughs), + [this, uFrame](AkUInt32 in_uSlot) { return m_passthroughs.ValueAt(in_uSlot).lastFrame != uFrame; }); + + m_pHashTableService->RemoveIf( + static_cast*>(&m_clusters), + [this, uFrame](AkUInt32 in_uSlot) { return m_clusters.ValueAt(in_uSlot).lastFrame != uFrame; }); +} + +void SpatialClusteringFX::KeepOutputsSilent(AkUInt32 in_uExpected) +{ + if (FetchOutputs(in_uExpected)) + SilenceOutputs(); +} + +void SpatialClusteringFX::SilenceOutputs() +{ + for (AkUInt32 i = 0; i < m_uNumOutputs; ++i) + { + AkAudioBuffer* pBuffer = m_outputBuffers[i]; + const AkUInt32 uNumChannels = pBuffer->NumChannels(); + for (AkUInt32 channel = 0; channel < uNumChannels; ++channel) + AKPLATFORM::AkMemSet(pBuffer->GetChannel(channel), 0, static_cast(pBuffer->MaxFrames() * sizeof(AkSampleType))); + + pBuffer->uValidFrames = pBuffer->MaxFrames(); + pBuffer->eState = AK_DataReady; + } +} + +void SpatialClusteringFX::PostRateLimitedMessage(MessageKind in_kind, const char* in_szMessage, AK::Monitor::ErrorLevel in_level) +{ + if (m_aMessagePosted[in_kind] && m_uFrame - m_aMessageFrames[in_kind] < m_uMessageInterval) + return; + + m_aMessagePosted[in_kind] = true; + m_aMessageFrames[in_kind] = m_uFrame; + m_pContext->PostMonitorMessage(in_szMessage, in_level); +} diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFX.h b/wwise/SoundEnginePlugin/SpatialClusteringFX.h new file mode 100644 index 0000000..a891cec --- /dev/null +++ b/wwise/SoundEnginePlugin/SpatialClusteringFX.h @@ -0,0 +1,246 @@ +/******************************************************************************* +The content of this file includes portions of the AUDIOKINETIC Wwise Technology +released in source code form as part of the SDK installer package. + +Commercial License Usage + +Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology +may use this file in accordance with the end user license agreement provided +with the software or, alternatively, in accordance with the terms contained in a +written agreement between you and Audiokinetic Inc. + +Apache License Usage + +Alternatively, this file may be used under the Apache License, Version 2.0 (the +"Apache License"); you may not use this file except in compliance with the +Apache License. You may obtain a copy of the Apache License at +http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software distributed +under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for +the specific language governing permissions and limitations under the License. + + Copyright (c) 2023 Audiokinetic Inc. + Copyright (c) 2024 CCP ehf. + +This implementation was developed by CCP Games for spatial audio object clustering +in EVE Online and EVE Frontier. This implementation does not grant any rights to +CCP's trademarks or game content. EVE Online and EVE Frontier are registered +trademarks of CCP ehf. +*******************************************************************************/ + +#ifndef SpatialClusteringFX_H +#define SpatialClusteringFX_H + +#include "ClusterEngine.h" +#include "SpatialClusteringFXParams.h" + +#include +#include +#include +#include + +/** @brief Cluster engine allocator that allocates through the Wwise plug-in allocator. */ +class SpatialClusteringAllocator final : public ClusterAllocator +{ +public: + /** @brief Sets the Wwise plug-in allocator to allocate through. */ + void Init(AK::IAkPluginMemAlloc* in_pAllocator); + + /** @brief Allocates a block of memory, or returns nullptr. */ + void* Alloc(size_t in_size) override; + + /** @brief Frees a block returned by Alloc. */ + void Free(void* in_pBlock) override; + +private: + AK::IAkPluginMemAlloc* m_pAllocator = nullptr; ///< Wwise plug-in allocator. +}; + +/** + * @class SpatialClusteringFX + * @brief Audio object clustering effect plugin + * @details Implements audio object clustering and processing functionality as an out-of-place plugin + */ +class SpatialClusteringFX + : public AK::IAkOutOfPlaceObjectPlugin +{ +public: + /** @brief Creates the effect. */ + SpatialClusteringFX(); + + /** @brief Destroys the effect. */ + ~SpatialClusteringFX(); + + /** + * @brief Initializes the plugin + * @param in_pAllocator Memory allocator interface + * @param in_pContext Plugin context interface + * @param in_pParams Plugin parameters + * @param io_rFormat Audio format + * @return AKRESULT Status code indicating success or failure + */ + AKRESULT Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffectPluginContext* in_pContext, AK::IAkPluginParam* in_pParams, AkAudioFormat& io_rFormat) override; + + /** + * @brief Terminates the plugin and releases resources + * @param in_pAllocator Memory allocator interface + * @return AKRESULT Status code indicating success or failure + */ + AKRESULT Term(AK::IAkPluginMemAlloc* in_pAllocator) override; + + /** + * @brief Resets the plugin to its initial state + * @return AKRESULT Status code indicating success or failure + */ + AKRESULT Reset() override; + + /** + * @brief Retrieves plugin information + * @param out_rPluginInfo Structure to be filled with plugin information + * @return AKRESULT Status code indicating success or failure + */ + AKRESULT GetPluginInfo(AkPluginInfo& out_rPluginInfo) override; + + /** + * @brief Executes the audio processing + * @param in_objects Input audio objects and buffers + * @param out_objects Output audio objects and buffers + */ + void Execute(const AkAudioObjects& in_objects, const AkAudioObjects& out_objects) override; + +private: + /** @brief Array type that allocates through the Wwise plug-in allocator. */ + template + using PluginArray = AkArray; + + /** @brief Output object that carries one passthrough input. */ + struct PassthroughEntry + { + AkAudioObjectID outputKey; ///< Key of the output object. + AkUInt32 channelConfig; ///< Serialized channel configuration of the output object. + AkUInt32 lastFrame; ///< Frame in which the output object was last written. + }; + + /** @brief Output object that carries one cluster. */ + struct ClusterEntry + { + AkAudioObjectID outputKey; ///< Key of the output object. + AkUInt32 lastFrame; ///< Frame in which the output object was last written. + }; + + /** @brief Output object to create in the current frame. */ + struct OutputRequest + { + AkUInt32 channelConfig; ///< Serialized channel configuration of the new output object. + AkUInt32 source; ///< Input index for a passthrough output, or cluster index for a cluster output. + bool isCluster; ///< True if the output object carries a cluster. + bool done; ///< True once the request has been handled. + }; + + /** @brief Kinds of monitor messages, each rate-limited on its own. */ + enum MessageKind : AkUInt32 + { + MessageKind_CreateFailed, ///< Output objects could not be created. + MessageKind_OutOfMemory, ///< Memory ran out. + MessageKind_OverBudget, ///< More outputs were needed than the maximum allows. + MessageKind_Count ///< Number of message kinds. + }; + + /** @brief Frees all memory and leaves the effect ready for another Init. */ + void ReleaseMemory(); + + /** @brief Recomputes the cluster engine settings when parameters or the buffer length change. */ + void UpdateClusterParams(AkUInt16 in_uMaxFrames); + + /** @brief Fills the cluster engine inputs for this frame and returns false if memory ran out. */ + bool PrepareInputs(const AkAudioObjects& in_objects); + + /** @brief Creates output objects for new passthrough inputs and new clusters and returns how many were created. */ + AkUInt32 CreateOutputs(const AkAudioObjects& in_objects); + + /** @brief Adds an output object to create in this frame and returns false if memory ran out. */ + bool AddRequest(AkUInt32 in_uChannelConfig, AkUInt32 in_uSource, bool in_bIsCluster); + + /** @brief Records and names one new output object. */ + void RegisterOutput(const AkAudioObjects& in_objects, const OutputRequest& in_request, AkAudioObject* in_pObject); + + /** @brief Fetches all output objects into the output arrays and returns false if memory ran out. */ + bool FetchOutputs(AkUInt32 in_uExpected); + + /** @brief Builds the lookup from output key to output index and returns false if memory ran out. */ + bool BuildOutputIndex(); + + /** @brief Returns the index of an output object in the output arrays, or kInvalidIndex. */ + AkUInt32 FindOutput(AkAudioObjectID in_key) const; + + /** @brief Copies each passthrough input to its output object. */ + void ProcessPassthroughs(const AkAudioObjects& in_objects); + + /** @brief Mixes the clusters into their output objects and sets their metadata. */ + void ProcessClusters(const AkAudioObjects& in_objects); + + /** @brief Writes an empty last frame to every fetched output object that was not written in this frame, so that the sound engine destroys it. */ + void ReleaseUnwrittenOutputs(); + + /** @brief Queues a priority to set on an output object. */ + void QueuePriority(AkAudioObject* in_pObject, AkPriority in_priority); + + /** @brief Sets the queued priorities on the output objects. */ + void ApplyPriorities(); + + /** @brief Forgets output objects that were not written in this frame. */ + void RemoveStaleEntries(); + + /** @brief Fetches all output objects and keeps them alive with silence for this frame. */ + void KeepOutputsSilent(AkUInt32 in_uExpected); + + /** @brief Writes silence to the fetched output objects and keeps them alive for this frame. */ + void SilenceOutputs(); + + /** @brief Posts a monitor message unless one of the same kind was posted recently. */ + void PostRateLimitedMessage(MessageKind in_kind, const char* in_szMessage, AK::Monitor::ErrorLevel in_level); + + /** @brief Index value that marks a missing output object. */ + static constexpr AkUInt32 kInvalidIndex = 0xFFFFFFFF; + + SpatialClusteringFXParams* m_pParams; ///< Parameter node. + AK::IAkPluginMemAlloc* m_pAllocator; ///< Wwise plug-in allocator. + AK::IAkEffectPluginContext* m_pContext; ///< Effect context. + AK::IAkPluginServiceAudioObjectPriority* m_pPriorityService; ///< Service that reads and writes object priorities. + AK::IAkPluginServiceHashTable* m_pHashTableService; ///< Service that manages the hash tables. + + SpatialClusteringAllocator m_engineAllocator; ///< Allocator the cluster engine allocates through. + ClusterEngine m_engine; ///< Cluster engine. + bool m_bEngineInitialized; ///< True once Init was called on the cluster engine. + ClusterParams m_clusterParams; ///< Cluster engine settings for the current parameters. + AkReal32 m_fClusterSpread; ///< Share of the cluster's angular size used as its minimum spread. + AkUInt32 m_uSampleRate; ///< Sample rate in Hz. + AkUInt16 m_uParamsMaxFrames; ///< Buffer length the cluster engine settings were computed for. + AkUInt32 m_uMessageInterval; ///< Minimum number of frames between two messages of the same kind. + AkUInt32 m_uFrame; ///< Number of the current frame. + AkUInt32 m_uNumOutputs; ///< Number of output objects fetched in this frame. + + AkUInt32 m_aMessageFrames[MessageKind_Count]; ///< Frame in which each kind of message was last posted. + bool m_aMessagePosted[MessageKind_Count]; ///< True for each kind of message that was posted at least once. + + PluginArray m_inputs; ///< Cluster engine inputs for this frame. + PluginArray m_inputPriorities; ///< Priority of each input for this frame. + PluginArray m_requests; ///< Output objects to create in this frame. + PluginArray m_newObjects; ///< Objects returned by one call to CreateOutputObjects. + PluginArray m_outputObjects; ///< Output objects for this frame. + PluginArray m_outputBuffers; ///< Output buffers for this frame. + PluginArray m_clusterOutputs; ///< Output index of each reported cluster for this frame. + PluginArray m_clusterValidFrames; ///< Valid frames of each reported cluster for this frame. + PluginArray m_clusterHasData; ///< Nonzero for each reported cluster with an input that has not ended in this frame. + PluginArray m_outputWritten; ///< Nonzero for each fetched output object written in this frame. + PluginArray m_priorityObjects; ///< Output objects whose priority is set in this frame. + PluginArray m_priorityValues; ///< Priorities to set on the output objects. + + AK::AkHashTable m_passthroughs; ///< Passthrough outputs by input key. + AK::AkHashTable m_clusters; ///< Cluster outputs by cluster ID. + AK::AkHashTable m_outputIndices; ///< Output index by output key for this frame. +}; + +#endif // SpatialClusteringFX_H diff --git a/SoundEnginePlugin/ObjectClusterFXFactory.h b/wwise/SoundEnginePlugin/SpatialClusteringFXFactory.h similarity index 89% rename from SoundEnginePlugin/ObjectClusterFXFactory.h rename to wwise/SoundEnginePlugin/SpatialClusteringFXFactory.h index fe2a706..1aa3ed3 100644 --- a/SoundEnginePlugin/ObjectClusterFXFactory.h +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXFactory.h @@ -24,9 +24,9 @@ the specific language governing permissions and limitations under the License. Copyright (c) 2023 Audiokinetic Inc. *******************************************************************************/ -#ifndef ObjectClusterFXFactory_H -#define ObjectClusterFXFactory_H +#ifndef SpatialClusteringFXFactory_H +#define SpatialClusteringFXFactory_H -AK_STATIC_LINK_PLUGIN(ObjectClusterFX) +AK_STATIC_LINK_PLUGIN(SpatialClusteringFX) -#endif // ObjectClusterFXFactory_H +#endif // SpatialClusteringFXFactory_H diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp new file mode 100644 index 0000000..69698db --- /dev/null +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp @@ -0,0 +1,195 @@ +/******************************************************************************* +The content of this file includes portions of the AUDIOKINETIC Wwise Technology +released in source code form as part of the SDK installer package. + +Commercial License Usage + +Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology +may use this file in accordance with the end user license agreement provided +with the software or, alternatively, in accordance with the terms contained in a +written agreement between you and Audiokinetic Inc. + +Apache License Usage + +Alternatively, this file may be used under the Apache License, Version 2.0 (the +"Apache License"); you may not use this file except in compliance with the +Apache License. You may obtain a copy of the Apache License at +http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software distributed +under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for +the specific language governing permissions and limitations under the License. + + Copyright (c) 2023 Audiokinetic Inc. +*******************************************************************************/ + +#include "SpatialClusteringFXParams.h" + +#include +#include + +#include + +namespace +{ + /** @brief Default values of the parameters that RTPCs can drive. */ + const SpatialClusteringRTPCParams kDefaultRTPCParams = { 15.f, 0, 100, 0.f, 200.f, 50.f, 0.f, 1.f }; + + /** @brief Returns true if every real parameter is a finite number. */ + bool AreFinite(const SpatialClusteringRTPCParams& in_params) + { + return std::isfinite(in_params.clusterWidth) + && std::isfinite(in_params.proximityRadius) + && std::isfinite(in_params.holdTime) + && std::isfinite(in_params.crossfadeTime) + && std::isfinite(in_params.distanceSensitivity) + && std::isfinite(in_params.clusterSpread); + } + + /** @brief Reads a value sent as a float and returns false if it is missing or not finite. */ + bool ReadReal(const void* in_pValue, AkUInt32 in_ulParamSize, AkReal32& out_value) + { + if (in_pValue == nullptr || in_ulParamSize < sizeof(AkReal32)) + return false; + + AkReal32 value; + AKPLATFORM::AkMemCpy(&value, in_pValue, sizeof(AkReal32)); + if (!std::isfinite(value)) + return false; + + out_value = value; + return true; + } + + /** @brief Rounds a float to the nearest integer between 0 and a limit. */ + AkInt32 RoundToRange(AkReal32 in_value, AkReal32 in_limit) + { + const AkReal32 clamped = in_value < 0.f ? 0.f : (in_value > in_limit ? in_limit : in_value); + return static_cast(clamped + 0.5f); + } +} + +SpatialClusteringFXParams::SpatialClusteringFXParams() + : RTPC(kDefaultRTPCParams) +{ +} + +SpatialClusteringFXParams::~SpatialClusteringFXParams() +{ +} + +SpatialClusteringFXParams::SpatialClusteringFXParams(const SpatialClusteringFXParams& in_rParams) +{ + RTPC = in_rParams.RTPC; + m_paramChangeHandler.SetAllParamChanges(); +} + +AK::IAkPluginParam* SpatialClusteringFXParams::Clone(AK::IAkPluginMemAlloc* in_pAllocator) +{ + return AK_PLUGIN_NEW(in_pAllocator, SpatialClusteringFXParams(*this)); +} + +AKRESULT SpatialClusteringFXParams::Init(AK::IAkPluginMemAlloc* in_pAllocator, const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) +{ + (void)in_pAllocator; + + if (in_ulBlockSize == 0) + { + // Initialize default parameters here + RTPC = kDefaultRTPCParams; + m_paramChangeHandler.SetAllParamChanges(); + return AK_Success; + } + + return SetParamsBlock(in_pParamsBlock, in_ulBlockSize); +} + +AKRESULT SpatialClusteringFXParams::Term(AK::IAkPluginMemAlloc* in_pAllocator) +{ + AK_PLUGIN_DELETE(in_pAllocator, this); + return AK_Success; +} + +AKRESULT SpatialClusteringFXParams::SetParamsBlock(const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) +{ + if (in_pParamsBlock == nullptr || in_ulBlockSize != PARAMS_BLOCK_SIZE) + return AK_InvalidParameter; + + AKRESULT eResult = AK_Success; + AkUInt8* pParamsBlock = (AkUInt8*)in_pParamsBlock; + + SpatialClusteringRTPCParams rtpc; + rtpc.clusterWidth = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.objectLimit = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); + rtpc.priorityThreshold = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); + rtpc.proximityRadius = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.holdTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.crossfadeTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.distanceSensitivity = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.clusterSpread = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + CHECKBANKDATASIZE(in_ulBlockSize, eResult); + + if (!AreFinite(rtpc)) + return AK_InvalidParameter; + + RTPC = rtpc; + m_paramChangeHandler.SetAllParamChanges(); + + return eResult; +} + +AKRESULT SpatialClusteringFXParams::SetParam(AkPluginParamID in_paramID, const void* in_pValue, AkUInt32 in_ulParamSize) +{ + AkReal32 value = 0.f; + + // Handle parameter change here + switch (in_paramID) + { + case PARAM_CLUSTER_WIDTH_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.clusterWidth = value; + break; + case PARAM_HOLD_TIME_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.holdTime = value; + break; + case PARAM_CROSSFADE_TIME_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.crossfadeTime = value; + break; + case PARAM_OBJECT_LIMIT_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.objectLimit = RoundToRange(value, static_cast(PARAM_OBJECT_LIMIT_MAX)); + break; + case PARAM_PROXIMITY_RADIUS_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.proximityRadius = value; + break; + case PARAM_DISTANCE_SENSITIVITY_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.distanceSensitivity = value; + break; + case PARAM_PRIORITY_THRESHOLD_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.priorityThreshold = RoundToRange(value, static_cast(PARAM_PRIORITY_MAX)); + break; + case PARAM_CLUSTER_SPREAD_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.clusterSpread = value; + break; + default: + return AK_InvalidParameter; + } + + m_paramChangeHandler.SetParamChange(in_paramID); + return AK_Success; +} diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h new file mode 100644 index 0000000..3f2b2d1 --- /dev/null +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h @@ -0,0 +1,118 @@ +/******************************************************************************* +The content of this file includes portions of the AUDIOKINETIC Wwise Technology +released in source code form as part of the SDK installer package. + +Commercial License Usage + +Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology +may use this file in accordance with the end user license agreement provided +with the software or, alternatively, in accordance with the terms contained in a +written agreement between you and Audiokinetic Inc. + +Apache License Usage + +Alternatively, this file may be used under the Apache License, Version 2.0 (the +"Apache License"); you may not use this file except in compliance with the +Apache License. You may obtain a copy of the Apache License at +http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software distributed +under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for +the specific language governing permissions and limitations under the License. + + Copyright (c) 2023 Audiokinetic Inc. +*******************************************************************************/ + +#ifndef SpatialClusteringFXParams_H +#define SpatialClusteringFXParams_H + +#include +#include + +// Add parameters IDs here, those IDs should map to the AudioEnginePropertyID +// attributes in the xml property definition. They follow the order of the +// property editor, which is also the order of the parameter block. +/// ID of the cluster width parameter. +static const AkPluginParamID PARAM_CLUSTER_WIDTH_ID = 0; +/// ID of the object limit parameter. +static const AkPluginParamID PARAM_OBJECT_LIMIT_ID = 1; +/// ID of the priority threshold parameter. +static const AkPluginParamID PARAM_PRIORITY_THRESHOLD_ID = 2; +/// ID of the proximity radius parameter. +static const AkPluginParamID PARAM_PROXIMITY_RADIUS_ID = 3; +/// ID of the hold time parameter. +static const AkPluginParamID PARAM_HOLD_TIME_ID = 4; +/// ID of the crossfade time parameter. +static const AkPluginParamID PARAM_CROSSFADE_TIME_ID = 5; +/// ID of the distance sensitivity parameter. +static const AkPluginParamID PARAM_DISTANCE_SENSITIVITY_ID = 6; +/// ID of the cluster spread parameter. +static const AkPluginParamID PARAM_CLUSTER_SPREAD_ID = 7; +/// Number of parameters. +static const AkUInt32 NUM_PARAMS = 8; + +/// Largest cluster width in degrees. +static const AkReal32 PARAM_CLUSTER_WIDTH_MAX = 180.f; +/// Largest hold time in milliseconds. +static const AkReal32 PARAM_HOLD_TIME_MAX = 5000.f; +/// Largest crossfade time in milliseconds. +static const AkReal32 PARAM_CROSSFADE_TIME_MAX = 1000.f; +/// Largest object limit. +static const AkInt32 PARAM_OBJECT_LIMIT_MAX = 512; +/// Largest proximity radius in game units. +static const AkReal32 PARAM_PROXIMITY_RADIUS_MAX = 1000000.f; +/// Largest playback priority, which is also the largest Priority Threshold value. +static const AkInt32 PARAM_PRIORITY_MAX = 100; + +/// Size in bytes of the parameter block that the authoring plug-in writes to banks. +static const AkUInt32 PARAMS_BLOCK_SIZE = 6 * sizeof(AkReal32) + 2 * sizeof(AkInt32); + +/** @brief Parameters of the effect, all of which RTPCs can drive. */ +struct SpatialClusteringRTPCParams +{ + AkReal32 clusterWidth; ///< Angle in degrees under which an input may join a cluster. + AkInt32 objectLimit; ///< Maximum number of outputs, passthrough inputs included; 0 means no limit. + AkInt32 priorityThreshold; ///< Inputs with a higher priority are never merged. + AkReal32 proximityRadius; ///< Inputs closer to the listener than this distance in game units are never merged. + AkReal32 holdTime; ///< Time in milliseconds a change must last before an input leaves its cluster or two clusters merge. + AkReal32 crossfadeTime; ///< Time in milliseconds over which a moving input fades between clusters. + AkReal32 distanceSensitivity; ///< How much a difference in distance counts against the cluster width. + AkReal32 clusterSpread; ///< Share of the cluster's angular size used as its minimum spread. +}; + +/** @brief Parameter node of the Spatial Clustering effect. */ +struct SpatialClusteringFXParams + : public AK::IAkPluginParam +{ + /** @brief Creates a parameter node with default values. */ + SpatialClusteringFXParams(); + + /** @brief Creates a copy of another parameter node. */ + SpatialClusteringFXParams(const SpatialClusteringFXParams& in_rParams); + + /** @brief Destroys the parameter node. */ + ~SpatialClusteringFXParams(); + + /// Create a duplicate of the parameter node instance in its current state. + IAkPluginParam* Clone(AK::IAkPluginMemAlloc* in_pAllocator) override; + + /// Initialize the plug-in parameter node interface. + /// Initializes the internal parameter structure to default values or with the provided parameter block if it is valid. + AKRESULT Init(AK::IAkPluginMemAlloc* in_pAllocator, const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) override; + + /// Called by the sound engine when a parameter node is terminated. + AKRESULT Term(AK::IAkPluginMemAlloc* in_pAllocator) override; + + /// Set all plug-in parameters at once using a parameter block. + AKRESULT SetParamsBlock(const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) override; + + /// Update a single parameter at a time and perform the necessary actions on the parameter changes. + AKRESULT SetParam(AkPluginParamID in_paramID, const void* in_pValue, AkUInt32 in_ulParamSize) override; + + AK::AkFXParameterChangeHandler m_paramChangeHandler; ///< Tracks which parameters changed since the effect last read them. + + SpatialClusteringRTPCParams RTPC; ///< Parameters that RTPCs can drive. +}; + +#endif // SpatialClusteringFXParams_H diff --git a/SoundEnginePlugin/ObjectClusterFXShared.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFXShared.cpp similarity index 95% rename from SoundEnginePlugin/ObjectClusterFXShared.cpp rename to wwise/SoundEnginePlugin/SpatialClusteringFXShared.cpp index 14a9acb..702ec2a 100644 --- a/SoundEnginePlugin/ObjectClusterFXShared.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXShared.cpp @@ -25,9 +25,9 @@ the specific language governing permissions and limitations under the License. *******************************************************************************/ #include -#include "ObjectClusterFXFactory.h" +#include "SpatialClusteringFXFactory.h" #include -DEFINEDUMMYASSERTHOOK; +DEFINE_PLUGIN_ASSERT_HOOK; DEFINE_PLUGIN_REGISTER_HOOK; diff --git a/ObjectClusterConfig.h b/wwise/SpatialClusteringConfig.h similarity index 92% rename from ObjectClusterConfig.h rename to wwise/SpatialClusteringConfig.h index db3fb2a..bbd07cc 100644 --- a/ObjectClusterConfig.h +++ b/wwise/SpatialClusteringConfig.h @@ -24,17 +24,17 @@ the specific language governing permissions and limitations under the License. Copyright (c) 2023 Audiokinetic Inc. *******************************************************************************/ -#ifndef ObjectClusterConfig_H -#define ObjectClusterConfig_H +#ifndef SpatialClusteringConfig_H +#define SpatialClusteringConfig_H // NB: In order to properly change the CompanyID / PluginID, you must change them: // - In this file; // - In the xml file located in the WwisePlugin directory; // - In the xml file located in the FactoryAssets directory. -namespace ObjectClusterConfig +namespace SpatialClusteringConfig { static const unsigned short CompanyID = 64; static const unsigned short PluginID = 6973; } -#endif // ObjectClusterConfig_H +#endif // SpatialClusteringConfig_H diff --git a/wwise/SpatialClusteringVersion.h b/wwise/SpatialClusteringVersion.h new file mode 100644 index 0000000..13acf2c --- /dev/null +++ b/wwise/SpatialClusteringVersion.h @@ -0,0 +1,34 @@ +/* + * Copyright 2026 CCP ehf. + * + * This software was developed by CCP Games for spatial audio object clustering + * in EVE Online and EVE Frontier. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This license does not grant any rights to CCP's trademarks or game content. + * EVE Online and EVE Frontier are registered trademarks of CCP ehf. + */ + +#ifndef SpatialClusteringVersion_H +#define SpatialClusteringVersion_H + +// The plug-in version, kept only here. The authoring plug-in's resource script uses it for the +// DLL version, and scripts/package.py reads it to version the Wwise Launcher bundle. +// Keep this file to plain #defines, since the resource compiler includes it. +#define SPATIALCLUSTERING_VERSION_MAJOR 3 +#define SPATIALCLUSTERING_VERSION_MINOR 0 +#define SPATIALCLUSTERING_VERSION_PATCH 0 +#define SPATIALCLUSTERING_VERSION_STRING "3.0.0" + +#endif // SpatialClusteringVersion_H diff --git a/wwise/WwisePlugin/SpatialClustering.def b/wwise/WwisePlugin/SpatialClustering.def new file mode 100644 index 0000000..30f444f --- /dev/null +++ b/wwise/WwisePlugin/SpatialClustering.def @@ -0,0 +1,3 @@ +LIBRARY "SpatialClustering" + +EXPORTS diff --git a/wwise/WwisePlugin/SpatialClustering.rc b/wwise/WwisePlugin/SpatialClustering.rc new file mode 100644 index 0000000..7b13b1a --- /dev/null +++ b/wwise/WwisePlugin/SpatialClustering.rc @@ -0,0 +1,101 @@ +// Microsoft Visual C++ generated resource script. +// + +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" +#include "../SpatialClusteringVersion.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE 9, 1 + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "#include ""../SpatialClusteringVersion.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +VS_VERSION_INFO VERSIONINFO + FILEVERSION SPATIALCLUSTERING_VERSION_MAJOR,SPATIALCLUSTERING_VERSION_MINOR,SPATIALCLUSTERING_VERSION_PATCH,0 + PRODUCTVERSION SPATIALCLUSTERING_VERSION_MAJOR,SPATIALCLUSTERING_VERSION_MINOR,SPATIALCLUSTERING_VERSION_PATCH,0 + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x2L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "CCP ehf." + VALUE "FileDescription", "Spatial Clustering authoring plug-in for Wwise" + VALUE "FileVersion", SPATIALCLUSTERING_VERSION_STRING + VALUE "InternalName", "SpatialClustering" + VALUE "LegalCopyright", "Copyright 2024-2026 CCP ehf. Licensed under the Apache License 2.0." + VALUE "OriginalFilename", "SpatialClustering.dll" + VALUE "ProductName", "Spatial Clustering" + VALUE "ProductVersion", SPATIALCLUSTERING_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/wwise/WwisePlugin/SpatialClustering.xml b/wwise/WwisePlugin/SpatialClustering.xml new file mode 100644 index 0000000..b763e2f --- /dev/null +++ b/wwise/WwisePlugin/SpatialClustering.xml @@ -0,0 +1,158 @@ + + + + + + + + + true + false + false + + + + + + + + + 15 + 0 + + + + 0 + 180 + + + + + + + + 0 + 1 + + + + 0 + 512 + + + + + + + + 100 + 2 + + + + 0 + 100 + + + + + + + + 0 + 3 + + + + 0 + 1000000 + + + + + + + + 200 + 4 + + + + 0 + 5000 + + + + + + + + 50 + 5 + + + + 0 + 1000 + + + + + + + + 0 + 6 + + + + 0 + 1 + + + + + + + + 1 + 7 + + + + 0 + 1 + + + + + + + diff --git a/wwise/WwisePlugin/SpatialClusteringPlugin.cpp b/wwise/WwisePlugin/SpatialClusteringPlugin.cpp new file mode 100644 index 0000000..55c644a --- /dev/null +++ b/wwise/WwisePlugin/SpatialClusteringPlugin.cpp @@ -0,0 +1,60 @@ +/******************************************************************************* +The content of this file includes portions of the AUDIOKINETIC Wwise Technology +released in source code form as part of the SDK installer package. + +Commercial License Usage + +Licensees holding valid commercial licenses to the AUDIOKINETIC Wwise Technology +may use this file in accordance with the end user license agreement provided +with the software or, alternatively, in accordance with the terms contained in a +written agreement between you and Audiokinetic Inc. + +Apache License Usage + +Alternatively, this file may be used under the Apache License, Version 2.0 (the +"Apache License"); you may not use this file except in compliance with the +Apache License. You may obtain a copy of the Apache License at +http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software distributed +under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. See the Apache License for +the specific language governing permissions and limitations under the License. + + Copyright (c) 2023 Audiokinetic Inc. +*******************************************************************************/ + +#include "SpatialClusteringPlugin.h" +#include "../SoundEnginePlugin/SpatialClusteringFXFactory.h" + +SpatialClusteringPlugin::SpatialClusteringPlugin() +{ +} + +SpatialClusteringPlugin::~SpatialClusteringPlugin() +{ +} + +bool SpatialClusteringPlugin::GetBankParameters(const GUID& in_guidPlatform, AK::Wwise::Plugin::DataWriter& in_dataWriter) const +{ + // Write bank data here + return in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:clusterWidth")) + && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:objectLimit")) + && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:priorityThreshold")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:proximityRadius")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:holdTime")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:crossfadeTime")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:distanceSensitivity")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:clusterSpread")); +} + +AK_DEFINE_PLUGIN_CONTAINER(SpatialClustering); // Create a PluginContainer structure that contains the info for our plugin +AK_EXPORT_PLUGIN_CONTAINER(SpatialClustering); // This is a DLL, we want to have a standardized name +AK_ADD_PLUGIN_CLASS_TO_CONTAINER( // Add our CLI class to the PluginContainer + SpatialClustering, // Name of the plug-in container for this shared library + SpatialClusteringPlugin, // Authoring plug-in class to add to the plug-in container + SpatialClusteringFX // Corresponding Sound Engine plug-in class +); +DEFINE_PLUGIN_REGISTER_HOOK + +DEFINE_PLUGIN_ASSERT_HOOK; // Placeholder assert hook for Wwise plug-ins using AKASSERT (cassert used by default) diff --git a/WwisePlugin/ObjectClusterPlugin.h b/wwise/WwisePlugin/SpatialClusteringPlugin.h similarity index 79% rename from WwisePlugin/ObjectClusterPlugin.h rename to wwise/WwisePlugin/SpatialClusteringPlugin.h index 14047a0..66c4938 100644 --- a/WwisePlugin/ObjectClusterPlugin.h +++ b/wwise/WwisePlugin/SpatialClusteringPlugin.h @@ -30,17 +30,20 @@ the specific language governing permissions and limitations under the License. /// See https://www.audiokinetic.com/library/edge/?source=SDK&id=plugin__dll.html /// for the documentation about Authoring plug-ins -class ObjectClusterPlugin final +class SpatialClusteringPlugin final : public AK::Wwise::Plugin::AudioPlugin { public: - ObjectClusterPlugin(); - ~ObjectClusterPlugin(); + /** @brief Creates the authoring plug-in. */ + SpatialClusteringPlugin(); + + /** @brief Destroys the authoring plug-in. */ + ~SpatialClusteringPlugin(); /// This function is called by Wwise to obtain parameters that will be written to a bank. /// Because these can be changed at run-time, the parameter block should stay relatively small. // Larger data should be put in the Data Block. - bool GetBankParameters(const GUID & in_guidPlatform, AK::Wwise::Plugin::DataWriter& in_dataWriter) const override; + bool GetBankParameters(const GUID& in_guidPlatform, AK::Wwise::Plugin::DataWriter& in_dataWriter) const override; }; -DECLARE_AUDIOPLUGIN_CONTAINER(ObjectCluster); // Exposes our PluginContainer structure that contains the info for our plugin +AK_DECLARE_PLUGIN_CONTAINER(SpatialClustering); // Exposes our PluginContainer structure that contains the info for our plugin diff --git a/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp b/wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.cpp similarity index 76% rename from WwisePlugin/Win32/ObjectClusterPluginGUI.cpp rename to wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.cpp index ccf0149..2a2abec 100644 --- a/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp +++ b/wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.cpp @@ -24,14 +24,14 @@ the specific language governing permissions and limitations under the License. Copyright (c) 2023 Audiokinetic Inc. *******************************************************************************/ -#include "ObjectClusterPluginGUI.h" +#include "SpatialClusteringPluginGUI.h" -ObjectClusterPluginGUI::ObjectClusterPluginGUI() +SpatialClusteringPluginGUI::SpatialClusteringPluginGUI() { } -ADD_AUDIOPLUGIN_CLASS_TO_CONTAINER( - ObjectCluster, // Name of the plug-in container for this shared library - ObjectClusterPluginGUI, // Authoring plug-in class to add to the plug-in container - ObjectClusterFX // Corresponding Sound Engine plug-in class +AK_ADD_PLUGIN_CLASS_TO_CONTAINER( + SpatialClustering, // Name of the plug-in container for this shared library + SpatialClusteringPluginGUI, // Authoring plug-in class to add to the plug-in container + SpatialClusteringFX // Corresponding Sound Engine plug-in class ); diff --git a/WwisePlugin/Win32/ObjectClusterPluginGUI.h b/wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.h similarity index 92% rename from WwisePlugin/Win32/ObjectClusterPluginGUI.h rename to wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.h index 6956676..e6e8aec 100644 --- a/WwisePlugin/Win32/ObjectClusterPluginGUI.h +++ b/wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.h @@ -25,13 +25,13 @@ the specific language governing permissions and limitations under the License. *******************************************************************************/ #pragma once -#include "../ObjectClusterPlugin.h" +#include "../SpatialClusteringPlugin.h" -class ObjectClusterPluginGUI final +class SpatialClusteringPluginGUI final : public AK::Wwise::Plugin::PluginMFCWindows<> , public AK::Wwise::Plugin::GUIWindows { public: - ObjectClusterPluginGUI(); + SpatialClusteringPluginGUI(); }; diff --git a/wwise/WwisePlugin/res/Md/en/clusterSpread.md b/wwise/WwisePlugin/res/Md/en/clusterSpread.md new file mode 100644 index 0000000..d982cd8 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/clusterSpread.md @@ -0,0 +1,5 @@ +## Cluster Spread + +Share of the cluster's angular size used as its minimum spread. An output with several contributing inputs uses the larger of the representative input's spread and Cluster Spread × (cluster angular radius / 180°). + +At 0, the output uses the representative input's spread only. An output with one contributing input always copies that input's positioning. diff --git a/wwise/WwisePlugin/res/Md/en/clusterWidth.md b/wwise/WwisePlugin/res/Md/en/clusterWidth.md new file mode 100644 index 0000000..de28582 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/clusterWidth.md @@ -0,0 +1,9 @@ +## Cluster Width + +Largest angle, as heard from the listener, between an input and a cluster for the input to join that cluster. A larger angle merges more inputs into fewer outputs. A smaller angle keeps more spatial detail. + +At 0, inputs are not merged by angle. They merge only when the Object Limit requires it. + +**Note**: With Distance Sensitivity above 0, a difference in distance from the listener is added to the angle before it is compared with the Cluster Width. + +Units: degrees diff --git a/wwise/WwisePlugin/res/Md/en/crossfadeTime.md b/wwise/WwisePlugin/res/Md/en/crossfadeTime.md new file mode 100644 index 0000000..3aff91b --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/crossfadeTime.md @@ -0,0 +1,7 @@ +## Crossfade Time + +Time over which an input fades out of its old cluster and into its new one when it moves. The two gains always add up to 1, so the input keeps its level during the move. When two clusters merge, the quieter one fades into the louder one over the same time. + +**Note**: The time is rounded to whole audio frames, and a crossfade always lasts at least one frame. A switch between clustered and passthrough happens without a crossfade. + +Units: milliseconds diff --git a/wwise/WwisePlugin/res/Md/en/distanceSensitivity.md b/wwise/WwisePlugin/res/Md/en/distanceSensitivity.md new file mode 100644 index 0000000..7aca020 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/distanceSensitivity.md @@ -0,0 +1,5 @@ +## Distance Sensitivity + +How much a difference in distance from the listener counts against the Cluster Width. At 0, only the angle counts. + +Above 0, the difference in distance is added to the angle on a log scale. At 1, a doubling of distance counts as much as the Cluster Width. diff --git a/wwise/WwisePlugin/res/Md/en/holdTime.md b/wwise/WwisePlugin/res/Md/en/holdTime.md new file mode 100644 index 0000000..cca08a6 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/holdTime.md @@ -0,0 +1,7 @@ +## Hold Time + +Time an input must stay outside its cluster before it leaves, and time two clusters must stay within the Cluster Width of each other before they merge. + +**Note**: The time is rounded to whole audio frames. At 48 kHz with 1024-sample buffers, one frame is about 21 ms. + +Units: milliseconds diff --git a/wwise/WwisePlugin/res/Md/en/objectLimit.md b/wwise/WwisePlugin/res/Md/en/objectLimit.md new file mode 100644 index 0000000..709532c --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/objectLimit.md @@ -0,0 +1,9 @@ +## Object Limit + +Largest number of output objects, passthrough outputs included. 0 means no limit. + +When the count is over the limit, the closest pairs of clusters merge right away, without waiting for the Hold Time. A higher priority makes a pair count as farther apart, so it merges later. + +**Note**: Passthrough inputs, excluded inputs, inputs at the listener position and clusters with different custom metadata cannot be merged. If the limit cannot be reached, the plug-in posts a message to the Capture Log. A merged cluster keeps its output until the inputs moving out of it have faded, so the number of outputs can be above the limit for up to two crossfades. + +Units: output objects diff --git a/wwise/WwisePlugin/res/Md/en/priorityThreshold.md b/wwise/WwisePlugin/res/Md/en/priorityThreshold.md new file mode 100644 index 0000000..ae92a81 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/priorityThreshold.md @@ -0,0 +1,5 @@ +## Priority Threshold + +Inputs with a playback priority above this value get an output of their own and are never merged. At 100, no input is excluded. + +**Note**: An input that becomes excluded while it is in a cluster leaves it after the Hold Time, with a crossfade. diff --git a/wwise/WwisePlugin/res/Md/en/proximityRadius.md b/wwise/WwisePlugin/res/Md/en/proximityRadius.md new file mode 100644 index 0000000..d2b10b9 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/proximityRadius.md @@ -0,0 +1,7 @@ +## Proximity Radius + +Inputs closer to the listener than this distance get an output of their own and are never merged. + +**Note**: The slider goes to 1000. Type larger values in the field. + +Units: game units diff --git a/WwisePlugin/resource.h b/wwise/WwisePlugin/resource.h similarity index 91% rename from WwisePlugin/resource.h rename to wwise/WwisePlugin/resource.h index d1969ee..ba49ac2 100644 --- a/WwisePlugin/resource.h +++ b/wwise/WwisePlugin/resource.h @@ -1,6 +1,6 @@ //{{NO_DEPENDENCIES}} // Microsoft Visual C++ generated include file. -// Used by ObjectCluster.rc +// Used by SpatialClustering.rc // Next default values for new objects // diff --git a/bundle_template.json b/wwise/bundle_template.json similarity index 76% rename from bundle_template.json rename to wwise/bundle_template.json index d4de9cb..09a76a4 100644 --- a/bundle_template.json +++ b/wwise/bundle_template.json @@ -2,19 +2,19 @@ // for the documentation about the bundle_template.json format { // Unique identifier of this bundle. Id must be unique across all existing plugins and versions. - "id": "CCPGames.ObjectCluster.${year}_${major}_${minor}_${build}", + "id": "CCPGames.SpatialClustering.${year}_${major}_${minor}_${build}", // Name displayed in the Wwise Launcher for this bundle - "name": "ObjectCluster", + "name": "Spatial Clustering", // Tag uniquely identifying the plug-in (format [0-9A-z_]+, 50 characters maximum) - "tag": "ObjectCluster", + "tag": "SpatialClustering", // Description displayed in the Wwise Launcher for this bundle - "description": "groups audio objects based on their positions", + "description": "Merges audio objects that reach the listener from similar directions into fewer outputs, to save system audio objects. Insert it as an Effect on an Audio Objects bus.", // Vendor name displayed in the Wwise Launcher for this bundle - "vendor": "CCPGames", + "vendor": "CCP Games", // Base64 representation of an image to be displayed in the Wwise Launcher for this bundle (PNG, JPEG or GIF) "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", @@ -59,12 +59,16 @@ // Online documentation links for this bundle "links": [ - // { - // "displayName": string, // Name displayed in the Wwise Launcher for this link - // "id": string, // Unique identifier of this link - // "url": string // Target URL of this link - // }, - // ... + { + "displayName": "Documentation", + "id": "documentation", + "url": "https://github.com/carbonengine/spatial-audio-clustering#readme" + }, + { + "displayName": "Release notes", + "id": "releases", + "url": "https://github.com/carbonengine/spatial-audio-clustering/releases" + } ], // Local documentation files for this bundle