From df2285c636b8398bbc7a6f6bf67890c6558ce051 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Sat, 10 Oct 2026 11:39:49 +0000 Subject: [PATCH 01/26] add ClusterEngine to group audio objects into persistent clusters by direction from the listener - inputs join a cluster within the join angle and leave it beyond the leave angle after the dwell time - moves between clusters are crossfaded through per-input gain ramps - nearby clusters merge after the dwell time, and the cheapest pairs merge when outputs exceed the budget - cluster positions are loudness weighted, inputs near the listener or above a priority are never merged - all memory comes from one block through a ClusterAllocator, nothing is allocated per frame --- SoundEnginePlugin/ClusterEngine.cpp | 2262 +++++++++++++++++++++++++++ SoundEnginePlugin/ClusterEngine.h | 416 +++++ 2 files changed, 2678 insertions(+) create mode 100644 SoundEnginePlugin/ClusterEngine.cpp create mode 100644 SoundEnginePlugin/ClusterEngine.h diff --git a/SoundEnginePlugin/ClusterEngine.cpp b/SoundEnginePlugin/ClusterEngine.cpp new file mode 100644 index 0000000..3af9f51 --- /dev/null +++ b/SoundEnginePlugin/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/SoundEnginePlugin/ClusterEngine.h b/SoundEnginePlugin/ClusterEngine.h new file mode 100644 index 0000000..3100c08 --- /dev/null +++ b/SoundEnginePlugin/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 = 5.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. +}; From 8d08735cd4557cb20f7c519d28ba8743eac01b4c Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Sat, 10 Oct 2026 11:39:49 +0000 Subject: [PATCH 02/26] add a standalone harness to test, benchmark and run scenarios on ClusterEngine - cluster_harness test runs the assertion suite and is registered with CTest - cluster_harness bench times Update for 64 to 1024 inputs - cluster_harness scenario prints or writes per-frame results for seeded scenes - builds with CMake and needs no Wwise SDK --- harness/CMakeLists.txt | 57 ++ harness/src/Bench.cpp | 287 ++++++ harness/src/FrameAnalysis.cpp | 232 +++++ harness/src/FrameAnalysis.h | 130 +++ harness/src/HarnessMath.h | 29 + harness/src/HarnessModes.h | 42 + harness/src/Main.cpp | 96 ++ harness/src/ScenarioMode.cpp | 209 ++++ harness/src/Scenarios.cpp | 704 +++++++++++++ harness/src/Scenarios.h | 126 +++ harness/src/Tests.cpp | 1807 +++++++++++++++++++++++++++++++++ 11 files changed, 3719 insertions(+) create mode 100644 harness/CMakeLists.txt create mode 100644 harness/src/Bench.cpp create mode 100644 harness/src/FrameAnalysis.cpp create mode 100644 harness/src/FrameAnalysis.h create mode 100644 harness/src/HarnessMath.h create mode 100644 harness/src/HarnessModes.h create mode 100644 harness/src/Main.cpp create mode 100644 harness/src/ScenarioMode.cpp create mode 100644 harness/src/Scenarios.cpp create mode 100644 harness/src/Scenarios.h create mode 100644 harness/src/Tests.cpp diff --git a/harness/CMakeLists.txt b/harness/CMakeLists.txt new file mode 100644 index 0000000..29d4bbd --- /dev/null +++ b/harness/CMakeLists.txt @@ -0,0 +1,57 @@ +# 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. + +cmake_minimum_required(VERSION 3.16) +project(ClusterHarness LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +set(ENGINE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../SoundEnginePlugin) + +add_executable(cluster_harness + src/Main.cpp + src/HarnessMath.h + src/HarnessModes.h + src/Scenarios.cpp + src/Scenarios.h + src/FrameAnalysis.cpp + src/FrameAnalysis.h + src/ScenarioMode.cpp + src/Bench.cpp + src/Tests.cpp + ${ENGINE_DIR}/ClusterEngine.cpp + ${ENGINE_DIR}/ClusterEngine.h) + +target_include_directories(cluster_harness PRIVATE ${ENGINE_DIR} src) + +if(MSVC) + target_compile_options(cluster_harness PRIVATE /W4 /permissive-) +else() + target_compile_options(cluster_harness PRIVATE -Wall -Wextra -Wpedantic) +endif() + +enable_testing() +add_test(NAME cluster_engine_tests COMMAND cluster_harness test) diff --git a/harness/src/Bench.cpp b/harness/src/Bench.cpp new file mode 100644 index 0000000..699783d --- /dev/null +++ b/harness/src/Bench.cpp @@ -0,0 +1,287 @@ +/* + * 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 "FrameAnalysis.h" +#include "HarnessMath.h" +#include "HarnessModes.h" +#include "Scenarios.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** @brief Parameter variant the benchmark runs. */ +struct BenchConfig +{ + const char* name; ///< Label printed in the table. + uint32_t maxOutputs; ///< Value of maxOutputs. + float distanceWeight; ///< Value of distanceWeight. +}; + +/** @brief Measurements of one benchmark run. */ +struct BenchResult +{ + double firstMicros = 0.0; ///< Time of the first update, where every input is new. + TimingSummary timing; ///< Times of the measured updates. + double meanClusters = 0.0; ///< Mean number of reported clusters. + uint32_t maxClusters = 0; ///< Largest number of reported clusters. + uint64_t moves = 0; ///< Existing inputs that started fading into another cluster. + double frameSeconds = 0.0; ///< Frame length of the scenario in seconds. + bool failed = false; ///< True if an update failed. +}; + +/** @brief Parameter variant of the budget drop benchmark. */ +struct DropConfig +{ + const char* name; ///< Label printed in the table. + float joinAngle; ///< Value of joinAngle. +}; + +/** @brief Measurements of the budget drop benchmark for one input count. */ +struct DropResult +{ + double meanClusters = 0.0; ///< Mean number of clusters before the drop. + double meanMicros = 0.0; ///< Mean time of the update that lowers maxOutputs. + double maxMicros = 0.0; ///< Largest time of the update that lowers maxOutputs. + bool overBudget = false; ///< True if any run could not reach the new maxOutputs. + bool failed = false; ///< True if an update failed. +}; + +/** @brief Number of frames that settle the clusters before the budget drop. */ +constexpr int kDropSettleFrames = 30; + +/** @brief Number of runs with different inputs per budget drop measurement. */ +constexpr uint32_t kDropRuns = 5; + +/** @brief Value of maxOutputs after the budget drop. */ +constexpr uint32_t kDropMaxOutputs = 16; + +/** @brief Runs the benchmark scenario for one input count and parameter variant. */ +BenchResult RunOne(uint32_t in_inputs, const BenchConfig& in_config, uint32_t in_seed, int in_warmup, int in_frames) +{ + Scenario scenario; + BuildBenchScenario(in_inputs, in_seed, in_warmup + in_frames, scenario); + scenario.params.maxOutputs = in_config.maxOutputs; + scenario.params.distanceWeight = in_config.distanceWeight; + + CountingAllocator allocator; + ClusterEngine engine; + engine.Init(&allocator, in_inputs); + ScenarioPlayer player(scenario, in_seed ^ 0x0BADu, true); + FrameTracker tracker; + FrameView view; + std::vector times; + times.reserve(static_cast(in_frames)); + BenchResult result; + result.frameSeconds = scenario.frameSeconds; + double clusterSum = 0.0; + + for (int frame = 0; frame < in_warmup + in_frames; ++frame) + { + const std::vector& inputs = player.NextFrame(); + const auto start = std::chrono::steady_clock::now(); + const bool updated = engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); + const auto stop = std::chrono::steady_clock::now(); + if (!updated) + { + result.failed = true; + break; + } + const double micros = std::chrono::duration(stop - start).count(); + if (frame == 0) + { + result.firstMicros = micros; + } + if (frame < in_warmup) + { + continue; + } + times.push_back(micros); + CaptureFrame(engine, inputs, updated, view); + const FrameMetrics metrics = tracker.Observe(frame, view, micros); + clusterSum += metrics.clusters; + result.maxClusters = std::max(result.maxClusters, metrics.clusters); + result.moves += metrics.moves; + } + engine.Term(); + if (times.empty()) + { + return result; + } + result.timing = SummarizeTimings(times); + result.meanClusters = clusterSum / static_cast(times.size()); + return result; +} + +/** @brief Returns inputs in uniformly random directions and distances from 10 to 510. */ +std::vector MakeRandomInputs(uint32_t in_count, std::mt19937& io_random) +{ + std::uniform_real_distribution unit(0.0, 1.0); + std::vector inputs; + for (uint32_t index = 0; index < in_count; ++index) + { + const double z = unit(io_random) * 2.0 - 1.0; + const double azimuth = unit(io_random) * 2.0 * kPi; + const double planar = std::sqrt(std::max(0.0, 1.0 - z * z)); + const double range = 10.0 + unit(io_random) * 500.0; + ClusterInput input; + input.key = index + 1u; + input.position = {static_cast(planar * std::cos(azimuth) * range), + static_cast(planar * std::sin(azimuth) * range), static_cast(z * range)}; + input.loudness = static_cast(0.01 + unit(io_random)); + input.priority = 50; + input.group = 0; + input.clusterable = true; + inputs.push_back(input); + } + return inputs; +} + +/** @brief Times the update that lowers maxOutputs from 0 to kDropMaxOutputs after the clusters have settled. */ +DropResult RunDrop(uint32_t in_inputs, const DropConfig& in_config, uint32_t in_seed) +{ + DropResult result; + double clusterSum = 0.0; + double timeSum = 0.0; + for (uint32_t run = 0; run < kDropRuns; ++run) + { + std::mt19937 random(in_seed + run * 7919u); + const std::vector inputs = MakeRandomInputs(in_inputs, random); + CountingAllocator allocator; + ClusterEngine engine; + engine.Init(&allocator, in_inputs); + ClusterParams params; + params.joinAngle = in_config.joinAngle; + for (int frame = 0; frame < kDropSettleFrames; ++frame) + { + result.failed = !engine.Update(inputs.data(), in_inputs, params) || result.failed; + } + clusterSum += engine.GetClusterCount(); + params.maxOutputs = kDropMaxOutputs; + const auto start = std::chrono::steady_clock::now(); + const bool updated = engine.Update(inputs.data(), in_inputs, params); + const auto stop = std::chrono::steady_clock::now(); + const double micros = std::chrono::duration(stop - start).count(); + result.failed = !updated || result.failed; + result.overBudget = engine.IsOverBudget() || result.overBudget; + timeSum += micros; + result.maxMicros = std::max(result.maxMicros, micros); + engine.Term(); + } + result.meanClusters = clusterSum / kDropRuns; + result.meanMicros = timeSum / kDropRuns; + return result; +} +} + +int RunBench(const std::vector& in_args) +{ + uint32_t frames = 2000; + uint32_t warmup = 200; + uint32_t seed = 4242; + for (size_t index = 0; index < in_args.size(); ++index) + { + const std::string& arg = in_args[index]; + const bool hasValue = index + 1 < in_args.size(); + bool valid = false; + if (arg == "--frames" && hasValue) + { + valid = ParseNumber(in_args[++index], frames) && frames > 0; + } + else if (arg == "--warmup" && hasValue) + { + valid = ParseNumber(in_args[++index], warmup); + } + else if (arg == "--seed" && hasValue) + { + valid = ParseNumber(in_args[++index], seed); + } + if (!valid) + { + std::printf("bad argument '%s'\n", arg.c_str()); + return 2; + } + } + + const BenchConfig configs[] = { + {"default", 0, 0.f}, + {"distance0.5", 0, 0.5f}, + {"budget64", 64, 0.f}, + {"budget64+distance0.5", 64, 0.5f}, + }; + const uint32_t sizes[] = {64, 128, 256, 512, 1024}; + + std::printf("seed %u, warmup %u frames, measured %u frames, input order shuffled every frame\n", seed, warmup, frames); + std::printf("time per Update in microseconds; 'first' is the first frame where every input is new\n\n"); + std::printf("%-20s %6s %9s %9s %9s %9s %9s %10s %8s %10s\n", "config", "inputs", "first", "mean", "p50", "p99", "max", + "clusters", "max_cl", "moves/s"); + bool failed = false; + for (const BenchConfig& config : configs) + { + for (uint32_t size : sizes) + { + const BenchResult result = RunOne(size, config, seed, static_cast(warmup), static_cast(frames)); + if (result.failed) + { + std::printf("%-20s %6u Update failed\n", config.name, size); + failed = true; + continue; + } + const double seconds = static_cast(frames) * result.frameSeconds; + std::printf("%-20s %6u %9.2f %9.2f %9.2f %9.2f %9.2f %10.1f %8u %10.1f\n", config.name, size, result.firstMicros, + result.timing.mean, result.timing.p50, result.timing.p99, result.timing.max, result.meanClusters, + result.maxClusters, static_cast(result.moves) / seconds); + } + } + + const DropConfig dropConfigs[] = { + {"join5", 5.f}, + {"join2", 2.f}, + }; + const uint32_t dropSizes[] = {512, 1024}; + std::printf("\nbudget drop: time of the Update that lowers maxOutputs from 0 to %u, after %d frames with uniformly random " + "inputs, over %u runs\n", + kDropMaxOutputs, kDropSettleFrames, kDropRuns); + std::printf("%-20s %6s %10s %10s %10s\n", "config", "inputs", "clusters", "mean_us", "max_us"); + for (const DropConfig& config : dropConfigs) + { + for (uint32_t size : dropSizes) + { + const DropResult result = RunDrop(size, config, seed); + if (result.failed || result.overBudget) + { + std::printf("%-20s %6u %s\n", config.name, size, result.failed ? "Update failed" : "maxOutputs not reached"); + failed = true; + continue; + } + std::printf("%-20s %6u %10.1f %10.1f %10.1f\n", config.name, size, result.meanClusters, result.meanMicros, + result.maxMicros); + } + } + return failed ? 1 : 0; +} diff --git a/harness/src/FrameAnalysis.cpp b/harness/src/FrameAnalysis.cpp new file mode 100644 index 0000000..06c174a --- /dev/null +++ b/harness/src/FrameAnalysis.cpp @@ -0,0 +1,232 @@ +/* + * 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 "FrameAnalysis.h" +#include "HarnessMath.h" + +#include +#include +#include + +void* CountingAllocator::Alloc(size_t in_size) +{ + if (m_failing) + { + return nullptr; + } + void* pBlock = std::malloc(in_size == 0 ? 1 : in_size); + if (pBlock == nullptr) + { + return nullptr; + } + m_blocks[pBlock] = in_size; + m_outstandingBytes += in_size; + ++m_allocCount; + return pBlock; +} + +void CountingAllocator::Free(void* in_pBlock) +{ + const auto found = m_blocks.find(in_pBlock); + if (found == m_blocks.end()) + { + ++m_badFreeCount; + return; + } + m_outstandingBytes -= found->second; + m_blocks.erase(found); + std::free(in_pBlock); +} + +void CountingAllocator::SetFailing(bool in_failing) +{ + m_failing = in_failing; +} + +uint64_t CountingAllocator::GetAllocCount() const +{ + return m_allocCount; +} + +size_t CountingAllocator::GetOutstandingBlocks() const +{ + return m_blocks.size(); +} + +size_t CountingAllocator::GetOutstandingBytes() const +{ + return m_outstandingBytes; +} + +uint64_t CountingAllocator::GetBadFreeCount() const +{ + return m_badFreeCount; +} + +void CaptureFrame(const ClusterEngine& in_engine, const std::vector& in_inputs, bool in_updated, FrameView& out_view) +{ + out_view.inputs = in_inputs; + out_view.updated = in_updated; + out_view.overBudget = in_engine.IsOverBudget(); + out_view.clusters.resize(in_engine.GetClusterCount()); + for (uint32_t index = 0; index < in_engine.GetClusterCount(); ++index) + { + out_view.clusters[index] = in_engine.GetCluster(index); + } + out_view.contributions.resize(in_engine.GetContributionCount()); + for (uint32_t index = 0; index < in_engine.GetContributionCount(); ++index) + { + out_view.contributions[index] = in_engine.GetContribution(index); + } +} + +bool IsFiniteVec(const ClusterVec3& in_v) +{ + return std::isfinite(in_v.x) && std::isfinite(in_v.y) && std::isfinite(in_v.z); +} + +double LengthOf(const ClusterVec3& in_v) +{ + const double x = in_v.x; + const double y = in_v.y; + const double z = in_v.z; + return std::sqrt(x * x + y * y + z * z); +} + +double AngleBetween(const ClusterVec3& in_a, const ClusterVec3& in_b) +{ + if (!IsFiniteVec(in_a) || !IsFiniteVec(in_b)) + { + return 0.0; + } + const double lengthA = LengthOf(in_a); + const double lengthB = LengthOf(in_b); + if (lengthA <= 0.0 || lengthB <= 0.0) + { + return 0.0; + } + const double dot = (static_cast(in_a.x) * in_b.x + static_cast(in_a.y) * in_b.y + + static_cast(in_a.z) * in_b.z) / + (lengthA * lengthB); + return std::acos(std::max(-1.0, std::min(1.0, dot))) * kDegreesPerRadian; +} + +bool IsExcludedInput(const ClusterInput& in_input, const ClusterParams& in_params) +{ + if (!in_input.clusterable) + { + return false; + } + if (!IsFiniteVec(in_input.position)) + { + return true; + } + const bool nearField = in_params.nearFieldRadius > 0.f && LengthOf(in_input.position) < in_params.nearFieldRadius; + return nearField || in_input.priority > in_params.excludePriorityAbove; +} + +std::vector FindHomeClusters(const FrameView& in_view) +{ + std::vector home(in_view.inputs.size(), -1); + std::vector best(in_view.inputs.size(), -1.f); + for (const ClusterContribution& contribution : in_view.contributions) + { + if (contribution.input >= in_view.inputs.size()) + { + continue; + } + if (contribution.gainEnd >= contribution.gainStart && contribution.gainEnd > 0.f && + contribution.gainEnd > best[contribution.input]) + { + best[contribution.input] = contribution.gainEnd; + home[contribution.input] = static_cast(contribution.cluster); + } + } + return home; +} + +TimingSummary SummarizeTimings(const std::vector& in_micros) +{ + TimingSummary summary; + if (in_micros.empty()) + { + return summary; + } + std::vector sorted = in_micros; + std::sort(sorted.begin(), sorted.end()); + double sum = 0.0; + for (double time : in_micros) + { + sum += time; + } + summary.mean = sum / static_cast(in_micros.size()); + summary.p50 = sorted[sorted.size() / 2]; + summary.p99 = sorted[std::min(sorted.size() - 1, static_cast(static_cast(sorted.size()) * 0.99))]; + summary.max = sorted.back(); + return summary; +} + +FrameMetrics FrameTracker::Observe(int in_frame, const FrameView& in_view, double in_updateMicroseconds) +{ + FrameMetrics metrics; + metrics.frame = in_frame; + metrics.inputs = static_cast(in_view.inputs.size()); + metrics.clusters = static_cast(in_view.clusters.size()); + metrics.overBudget = in_view.overBudget; + metrics.updateMicroseconds = in_updateMicroseconds; + + std::unordered_set clusterable; + for (const ClusterInput& input : in_view.inputs) + { + if (input.clusterable) + { + clusterable.insert(input.key); + } + else + { + ++metrics.passthrough; + } + } + metrics.outputs = metrics.clusters + metrics.passthrough; + for (const ClusterInfo& cluster : in_view.clusters) + { + if (cluster.isNew) + { + ++metrics.newClusters; + } + } + for (const ClusterContribution& contribution : in_view.contributions) + { + const ClusterInput& input = in_view.inputs[contribution.input]; + const ClusterInfo& cluster = in_view.clusters[contribution.cluster]; + if (contribution.gainStart == 0.f && m_previousClusterable.count(input.key) != 0) + { + ++metrics.moves; + } + if (contribution.gainEnd > 0.f) + { + metrics.maxMemberAngle = std::max(metrics.maxMemberAngle, AngleBetween(input.position, cluster.position)); + } + } + m_previousClusterable.swap(clusterable); + return metrics; +} diff --git a/harness/src/FrameAnalysis.h b/harness/src/FrameAnalysis.h new file mode 100644 index 0000000..84e6c5a --- /dev/null +++ b/harness/src/FrameAnalysis.h @@ -0,0 +1,130 @@ +/* + * 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 "ClusterEngine.h" + +#include +#include +#include +#include +#include + +/** @brief Allocator that counts blocks and bytes and can be told to fail. */ +class CountingAllocator final : public ClusterAllocator +{ +public: + /** @brief Allocates a block with malloc, or returns nullptr when failing is enabled. */ + void* Alloc(size_t in_size) override; + + /** @brief Frees a block returned by Alloc. */ + void Free(void* in_pBlock) override; + + /** @brief Makes every following Alloc fail while set. */ + void SetFailing(bool in_failing); + + /** @brief Returns the number of successful Alloc calls. */ + uint64_t GetAllocCount() const; + + /** @brief Returns the number of blocks not yet freed. */ + size_t GetOutstandingBlocks() const; + + /** @brief Returns the number of bytes not yet freed. */ + size_t GetOutstandingBytes() const; + + /** @brief Returns the number of Free calls with a pointer that was not allocated here. */ + uint64_t GetBadFreeCount() const; + +private: + std::unordered_map m_blocks; ///< Live blocks and their sizes. + size_t m_outstandingBytes = 0; ///< Bytes not yet freed. + uint64_t m_allocCount = 0; ///< Successful Alloc calls. + uint64_t m_badFreeCount = 0; ///< Free calls with unknown pointers. + bool m_failing = false; ///< Makes Alloc fail while set. +}; + +/** @brief Copy of the engine output of one frame together with its inputs. */ +struct FrameView +{ + std::vector inputs; ///< Inputs given to the update. + std::vector clusters; ///< Reported clusters. + std::vector contributions; ///< Reported contributions. + bool overBudget = false; ///< Value of IsOverBudget. + bool updated = false; ///< Return value of Update. +}; + +/** @brief Copies the current engine output into a frame view. */ +void CaptureFrame(const ClusterEngine& in_engine, const std::vector& in_inputs, bool in_updated, FrameView& out_view); + +/** @brief Returns the angle in degrees between two vectors, or zero if either is zero or not finite. */ +double AngleBetween(const ClusterVec3& in_a, const ClusterVec3& in_b); + +/** @brief Returns true if a position has only finite coordinates. */ +bool IsFiniteVec(const ClusterVec3& in_v); + +/** @brief Returns the length of a vector. */ +double LengthOf(const ClusterVec3& in_v); + +/** @brief Returns true if the engine treats an input as excluded under the given parameters. */ +bool IsExcludedInput(const ClusterInput& in_input, const ClusterParams& in_params); + +/** @brief Returns, per input, the index of its home cluster among the reported clusters, or -1. */ +std::vector FindHomeClusters(const FrameView& in_view); + +/** @brief Summary of a series of timings in microseconds. */ +struct TimingSummary +{ + double mean = 0.0; ///< Mean time. + double p50 = 0.0; ///< Median time. + double p99 = 0.0; ///< 99th percentile time. + double max = 0.0; ///< Largest time. +}; + +/** @brief Returns the mean, median, 99th percentile and largest of a series of timings, or zeros if it is empty. */ +TimingSummary SummarizeTimings(const std::vector& in_micros); + +/** @brief Metrics of one frame of a scenario run. */ +struct FrameMetrics +{ + int frame = 0; ///< Frame index. + uint32_t inputs = 0; ///< Number of inputs. + uint32_t passthrough = 0; ///< Number of passthrough inputs. + uint32_t clusters = 0; ///< Number of reported clusters. + uint32_t outputs = 0; ///< Clusters plus passthrough inputs. + uint32_t moves = 0; ///< Existing inputs that started fading into another cluster. + uint32_t newClusters = 0; ///< Clusters reported for the first time. + double maxMemberAngle = 0.0; ///< Largest angle between a member and its cluster in degrees. + bool overBudget = false; ///< Value of IsOverBudget. + double updateMicroseconds = 0; ///< Time spent in Update. +}; + +/** @brief Follows engine output over frames and computes per-frame metrics. */ +class FrameTracker +{ +public: + /** @brief Computes the metrics of a frame and remembers the frame for the next one. */ + FrameMetrics Observe(int in_frame, const FrameView& in_view, double in_updateMicroseconds); + +private: + std::unordered_set m_previousClusterable; ///< Keys of clusterable inputs in the previous frame. +}; diff --git a/harness/src/HarnessMath.h b/harness/src/HarnessMath.h new file mode 100644 index 0000000..b133d66 --- /dev/null +++ b/harness/src/HarnessMath.h @@ -0,0 +1,29 @@ +/* + * 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 + +/** @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; diff --git a/harness/src/HarnessModes.h b/harness/src/HarnessModes.h new file mode 100644 index 0000000..3ecdf3b --- /dev/null +++ b/harness/src/HarnessModes.h @@ -0,0 +1,42 @@ +/* + * 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 +#include + +/** @brief Reads a decimal number that fits 32 bits and returns false if the text is not one. */ +bool ParseNumber(const std::string& in_text, uint32_t& out_value); + +/** @brief Runs the assertion suite and returns the process exit code. */ +int RunTests(const std::vector& in_args); + +/** @brief Runs the benchmark and returns the process exit code. */ +int RunBench(const std::vector& in_args); + +/** @brief Runs one scenario, prints its per-frame metrics and returns the process exit code. */ +int RunScenarioMode(const std::vector& in_args); + +/** @brief Prints the scenario names with their seeds and descriptions. */ +int RunListMode(); diff --git a/harness/src/Main.cpp b/harness/src/Main.cpp new file mode 100644 index 0000000..0902007 --- /dev/null +++ b/harness/src/Main.cpp @@ -0,0 +1,96 @@ +/* + * 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 "HarnessModes.h" + +#include +#include +#include +#include +#include + +namespace +{ +void PrintUsage() +{ + std::printf( + "usage:\n" + " cluster_harness test [name filter]\n" + " runs the assertion suite, or the tests whose name contains the filter; exits nonzero on failure\n" + " cluster_harness bench [--frames N] [--warmup N] [--seed N]\n" + " prints mean and p99 microseconds per Update for 64 to 1024 inputs\n" + " cluster_harness scenario [--csv file] [--seed N] [--frames N] [--shuffle] [--quiet]\n" + " prints per-frame metrics of one scenario\n" + " cluster_harness list\n" + " prints the scenario names, default seeds and descriptions\n"); +} +} + +bool ParseNumber(const std::string& in_text, uint32_t& out_value) +{ + if (in_text.empty() || in_text[0] < '0' || in_text[0] > '9') + { + return false; + } + errno = 0; + char* pEnd = nullptr; + const unsigned long long value = std::strtoull(in_text.c_str(), &pEnd, 10); + if (errno != 0 || pEnd == nullptr || *pEnd != '\0' || value > 0xFFFFFFFFull) + { + return false; + } + out_value = static_cast(value); + return true; +} + +int main(int in_argc, char** in_argv) +{ + if (in_argc < 2) + { + PrintUsage(); + return 2; + } + const std::string mode = in_argv[1]; + std::vector args; + for (int index = 2; index < in_argc; ++index) + { + args.emplace_back(in_argv[index]); + } + if (mode == "test") + { + return RunTests(args); + } + if (mode == "bench") + { + return RunBench(args); + } + if (mode == "scenario") + { + return RunScenarioMode(args); + } + if (mode == "list") + { + return RunListMode(); + } + PrintUsage(); + return 2; +} diff --git a/harness/src/ScenarioMode.cpp b/harness/src/ScenarioMode.cpp new file mode 100644 index 0000000..2b05797 --- /dev/null +++ b/harness/src/ScenarioMode.cpp @@ -0,0 +1,209 @@ +/* + * 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 "FrameAnalysis.h" +#include "HarnessModes.h" +#include "Scenarios.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +void PrintMetrics(const FrameMetrics& in_metrics) +{ + std::printf("%6d %6u %6u %6u %6u %6u %6u %9.3f %4d %10.2f\n", in_metrics.frame, in_metrics.inputs, in_metrics.passthrough, + in_metrics.clusters, in_metrics.outputs, in_metrics.moves, in_metrics.newClusters, in_metrics.maxMemberAngle, + in_metrics.overBudget ? 1 : 0, in_metrics.updateMicroseconds); +} + +void WriteCsvRow(std::ofstream& io_csv, const FrameMetrics& in_metrics) +{ + io_csv << in_metrics.frame << ',' << in_metrics.inputs << ',' << in_metrics.passthrough << ',' << in_metrics.clusters << ',' + << in_metrics.outputs << ',' << in_metrics.moves << ',' << in_metrics.newClusters << ',' << in_metrics.maxMemberAngle + << ',' << (in_metrics.overBudget ? 1 : 0) << ',' << in_metrics.updateMicroseconds << '\n'; +} +} + +int RunListMode() +{ + for (const std::string& name : GetScenarioNames()) + { + Scenario scenario; + BuildScenario(name, GetDefaultSeed(name), scenario); + std::printf("%-12s seed %-6u frames %-5d %s\n", name.c_str(), scenario.seed, scenario.frames, scenario.description.c_str()); + } + return 0; +} + +int RunScenarioMode(const std::vector& in_args) +{ + if (in_args.empty()) + { + std::printf("scenario mode needs a name; run 'cluster_harness list' to see them\n"); + return 2; + } + const std::string name = in_args[0]; + uint32_t seed = GetDefaultSeed(name); + uint32_t frames = 0; + bool hasFrames = false; + bool shuffle = false; + bool quiet = false; + std::string csvPath; + for (size_t index = 1; index < in_args.size(); ++index) + { + const std::string& arg = in_args[index]; + const bool hasValue = index + 1 < in_args.size(); + bool valid = true; + if (arg == "--csv" && hasValue) + { + csvPath = in_args[++index]; + } + else if (arg == "--seed" && hasValue) + { + valid = ParseNumber(in_args[++index], seed); + } + else if (arg == "--frames" && hasValue) + { + valid = ParseNumber(in_args[++index], frames); + hasFrames = true; + } + else if (arg == "--shuffle") + { + shuffle = true; + } + else if (arg == "--quiet") + { + quiet = true; + } + else + { + valid = false; + } + if (!valid) + { + std::printf("bad argument '%s'\n", arg.c_str()); + return 2; + } + } + + Scenario scenario; + if (!BuildScenario(name, seed, scenario)) + { + std::printf("unknown scenario '%s'; run 'cluster_harness list' to see them\n", name.c_str()); + return 2; + } + if (hasFrames) + { + scenario.frames = static_cast(frames); + } + + const uint32_t orderSeed = seed ^ 0x0BADu; + std::printf("scenario %s: %s\n", scenario.name.c_str(), scenario.description.c_str()); + std::printf("seed %u, order seed %u, frames %d, frame length %.3f ms, shuffle %s\n", seed, orderSeed, scenario.frames, + scenario.frameSeconds * 1000.0, (shuffle || scenario.shuffle) ? "on" : "off"); + + std::ofstream csv; + if (!csvPath.empty()) + { + csv.open(csvPath); + if (!csv) + { + std::printf("cannot open '%s'\n", csvPath.c_str()); + return 1; + } + csv << "frame,inputs,passthrough,clusters,outputs,moves,new_clusters,max_member_angle,over_budget,update_us\n"; + } + + CountingAllocator allocator; + ClusterEngine engine; + engine.Init(&allocator, 256); + ScenarioPlayer player(scenario, orderSeed, shuffle || scenario.shuffle); + FrameTracker tracker; + FrameView view; + std::vector times; + double clusterSum = 0.0; + uint32_t clusterMax = 0; + uint64_t moveSum = 0; + uint64_t movesAfterSettle = 0; + double angleMax = 0.0; + int overBudgetFrames = 0; + bool failed = false; + + if (!quiet) + { + std::printf(" frame inputs pass. clust. output moves newcl. max_angle over update_us\n"); + } + for (int frame = 0; frame < scenario.frames; ++frame) + { + const std::vector& inputs = player.NextFrame(); + const auto start = std::chrono::steady_clock::now(); + const bool updated = engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); + const auto stop = std::chrono::steady_clock::now(); + const double micros = std::chrono::duration(stop - start).count(); + if (!updated) + { + std::printf("Update failed at frame %d\n", frame); + failed = true; + break; + } + CaptureFrame(engine, inputs, updated, view); + const FrameMetrics metrics = tracker.Observe(frame, view, micros); + times.push_back(micros); + clusterSum += metrics.clusters; + clusterMax = std::max(clusterMax, metrics.clusters); + moveSum += metrics.moves; + if (scenario.settleFrame >= 0 && frame > scenario.settleFrame) + { + movesAfterSettle += metrics.moves; + } + angleMax = std::max(angleMax, metrics.maxMemberAngle); + overBudgetFrames += metrics.overBudget ? 1 : 0; + if (!quiet) + { + PrintMetrics(metrics); + } + if (csv.is_open()) + { + WriteCsvRow(csv, metrics); + } + } + engine.Term(); + + if (!times.empty()) + { + const TimingSummary timing = SummarizeTimings(times); + std::printf("summary: clusters mean %.1f max %u, moves %llu", clusterSum / times.size(), clusterMax, + static_cast(moveSum)); + if (scenario.settleFrame >= 0) + { + std::printf(" (%llu after frame %d)", static_cast(movesAfterSettle), scenario.settleFrame); + } + std::printf(", max member angle %.3f, over budget frames %d, update mean %.2f us p99 %.2f us\n", angleMax, + overBudgetFrames, timing.mean, timing.p99); + } + return failed ? 1 : 0; +} diff --git a/harness/src/Scenarios.cpp b/harness/src/Scenarios.cpp new file mode 100644 index 0000000..1dc3117 --- /dev/null +++ b/harness/src/Scenarios.cpp @@ -0,0 +1,704 @@ +/* + * 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 "Scenarios.h" +#include "HarnessMath.h" + +#include +#include +#include + +namespace +{ +double ToRadians(double in_degrees) +{ + return in_degrees * kPi / 180.0; +} + +Vec3 Add(const Vec3& in_a, const Vec3& in_b) +{ + return {in_a.x + in_b.x, in_a.y + in_b.y, in_a.z + in_b.z}; +} + +Vec3 Scale(const Vec3& in_v, double in_s) +{ + return {in_v.x * in_s, in_v.y * in_s, in_v.z * in_s}; +} + +double Dot(const Vec3& in_a, const Vec3& in_b) +{ + return in_a.x * in_b.x + in_a.y * in_b.y + in_a.z * in_b.z; +} + +Vec3 Cross(const Vec3& in_a, const Vec3& in_b) +{ + return {in_a.y * in_b.z - in_a.z * in_b.y, in_a.z * in_b.x - in_a.x * in_b.z, in_a.x * in_b.y - in_a.y * in_b.x}; +} + +double Length(const Vec3& in_v) +{ + return std::sqrt(Dot(in_v, in_v)); +} + +Vec3 Normalize(const Vec3& in_v) +{ + const double length = Length(in_v); + return length > 0.0 ? Scale(in_v, 1.0 / length) : Vec3{0.0, 0.0, 1.0}; +} + +double AngleDegrees(const Vec3& in_a, const Vec3& in_b) +{ + const double cosine = Dot(Normalize(in_a), Normalize(in_b)); + return std::acos(std::max(-1.0, std::min(1.0, cosine))) * kDegreesPerRadian; +} + +double Uniform(std::mt19937& io_random, double in_low, double in_high) +{ + const double unit = (static_cast(io_random()) + 0.5) / 4294967296.0; + return in_low + (in_high - in_low) * unit; +} + +int UniformInt(std::mt19937& io_random, int in_low, int in_high) +{ + const uint32_t span = static_cast(in_high - in_low) + 1u; + return in_low + static_cast(io_random() % span); +} + +int Poisson(std::mt19937& io_random, double in_mean) +{ + const double limit = std::exp(-in_mean); + double product = Uniform(io_random, 0.0, 1.0); + int count = 0; + while (product > limit) + { + ++count; + product *= Uniform(io_random, 0.0, 1.0); + } + return count; +} + +double LogUniform(std::mt19937& io_random, double in_low, double in_high) +{ + return std::exp(Uniform(io_random, std::log(in_low), std::log(in_high))); +} + +Vec3 RandomDirection(std::mt19937& io_random) +{ + const double z = Uniform(io_random, -1.0, 1.0); + const double azimuth = Uniform(io_random, 0.0, 2.0 * kPi); + const double planar = std::sqrt(std::max(0.0, 1.0 - z * z)); + return {planar * std::cos(azimuth), planar * std::sin(azimuth), z}; +} + +Vec3 AnyPerpendicular(const Vec3& in_direction) +{ + const Vec3 helper = std::fabs(in_direction.x) < 0.9 ? Vec3{1.0, 0.0, 0.0} : Vec3{0.0, 1.0, 0.0}; + return Normalize(Cross(in_direction, helper)); +} + +Vec3 RotateAround(const Vec3& in_v, const Vec3& in_axis, double in_radians) +{ + const double cosine = std::cos(in_radians); + const double sine = std::sin(in_radians); + const Vec3 cross = Cross(in_axis, in_v); + const double along = Dot(in_axis, in_v); + return Add(Add(Scale(in_v, cosine), Scale(cross, sine)), Scale(in_axis, along * (1.0 - cosine))); +} + +Vec3 OffsetDirection(const Vec3& in_direction, double in_maxDegrees, std::mt19937& io_random) +{ + const Vec3 direction = Normalize(in_direction); + const double angle = ToRadians(in_maxDegrees) * std::sqrt(Uniform(io_random, 0.0, 1.0)); + const double azimuth = Uniform(io_random, 0.0, 2.0 * kPi); + const Vec3 side = AnyPerpendicular(direction); + const Vec3 tilt = RotateAround(side, direction, azimuth); + return RotateAround(direction, Normalize(Cross(direction, tilt)), angle); +} + +Vec3 PickSeparatedDirection(std::vector& io_used, double in_minDegrees, std::mt19937& io_random) +{ + Vec3 candidate = RandomDirection(io_random); + for (int attempt = 0; attempt < 10000; ++attempt) + { + bool separated = true; + for (const Vec3& used : io_used) + { + if (AngleDegrees(used, candidate) < in_minDegrees) + { + separated = false; + break; + } + } + if (separated) + { + break; + } + candidate = RandomDirection(io_random); + } + io_used.push_back(candidate); + return candidate; +} + +/** @brief Produces unique random input keys from a seed. */ +class KeySource +{ +public: + /** @brief Creates a key source for a seed. */ + explicit KeySource(uint32_t in_seed) + : m_random(in_seed ^ 0x5EEDu) + { + } + + /** @brief Returns a key that this source has not returned before. */ + uint64_t Next() + { + for (;;) + { + const uint64_t key = (static_cast(m_random()) << 32) | m_random(); + if (m_used.insert(key).second) + { + return key; + } + } + } + +private: + std::mt19937 m_random; ///< Random source for the keys. + std::unordered_set m_used; ///< Keys returned so far. +}; + +int SecondsToFrames(const Scenario& in_scenario, double in_seconds) +{ + return std::max(1, static_cast(std::lround(in_seconds / in_scenario.frameSeconds))); +} + +/** @brief Centre of a group of sources, which the members copy their motion from. */ +struct Centre +{ + Vec3 direction; ///< Unit direction of the centre from the listener. + double range = 1.0; ///< Distance of the centre from the listener. + Motion motion = Motion::Static; ///< Motion model of the members. + Vec3 axis; ///< Unit rotation axis of orbiting members. + double degreesPerSecond = 0.0; ///< Rotation speed of orbiting members. +}; + +SourceSpec& AddMember(Scenario& io_scenario, KeySource& io_keys, std::mt19937& io_random, const Centre& in_centre, + double in_spreadDegrees, double in_rangeJitter, uint32_t in_group) +{ + SourceSpec source; + source.key = io_keys.Next(); + source.group = in_group; + source.priority = static_cast(UniformInt(io_random, 10, 90)); + source.loudness = static_cast(Uniform(io_random, 0.02, 0.5)); + const Vec3 direction = OffsetDirection(in_centre.direction, in_spreadDegrees, io_random); + const double range = in_centre.range * Uniform(io_random, 1.0 - in_rangeJitter, 1.0 + in_rangeJitter); + source.origin = Scale(direction, range); + source.motion = in_centre.motion; + source.axis = in_centre.axis; + source.degreesPerSecond = in_centre.degreesPerSecond; + io_scenario.sources.push_back(source); + return io_scenario.sources.back(); +} + +void AddOneShots(Scenario& io_scenario, KeySource& io_keys, std::mt19937& io_random, const std::vector& in_centres, + double in_ratePerSecond, double in_spreadDegrees, double in_passthroughShare) +{ + const double perFrame = in_ratePerSecond * io_scenario.frameSeconds; + for (int frame = 0; frame < io_scenario.frames; ++frame) + { + const int count = Poisson(io_random, perFrame); + for (int index = 0; index < count; ++index) + { + const Centre& centre = in_centres[static_cast(UniformInt(io_random, 0, static_cast(in_centres.size()) - 1))]; + SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, in_spreadDegrees, 0.3, 0); + source.startFrame = frame; + source.endFrame = frame + SecondsToFrames(io_scenario, Uniform(io_random, 0.2, 2.0)); + source.loudness = static_cast(Uniform(io_random, 0.01, 0.8)); + source.priority = static_cast(UniformInt(io_random, 0, 100)); + source.clusterable = Uniform(io_random, 0.0, 1.0) >= in_passthroughShare; + } + } +} + +std::vector MakeStaticCentres(std::mt19937& io_random, int in_count, double in_minSeparation, double in_minRange, + double in_maxRange) +{ + std::vector used; + std::vector centres; + for (int index = 0; index < in_count; ++index) + { + Centre centre; + centre.direction = PickSeparatedDirection(used, in_minSeparation, io_random); + centre.range = LogUniform(io_random, in_minRange, in_maxRange); + centres.push_back(centre); + } + return centres; +} + +void BuildStatic(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "static groups with fixed members"; + io_scenario.frames = 400; + io_scenario.settleFrame = 60; + for (const Centre& centre : MakeStaticCentres(io_random, 12, 25.0, 10.0, 1000.0)) + { + const int members = UniformInt(io_random, 3, 8); + for (int member = 0; member < members; ++member) + { + AddMember(io_scenario, io_keys, io_random, centre, 2.0, 0.1, 0); + } + } +} + +void BuildCrossing(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "groups orbiting in opposite directions that cross and separate"; + io_scenario.frames = 1500; + for (int index = 0; index < 8; ++index) + { + Centre centre; + const double tilt = ToRadians(Uniform(io_random, -4.0, 4.0)); + centre.axis = Normalize(Vec3{0.0, std::cos(tilt), std::sin(tilt)}); + const Vec3 start = AnyPerpendicular(centre.axis); + centre.direction = RotateAround(start, centre.axis, Uniform(io_random, 0.0, 2.0 * kPi)); + centre.range = LogUniform(io_random, 20.0, 400.0); + centre.motion = Motion::Orbit; + const double speed = Uniform(io_random, 20.0, 40.0); + centre.degreesPerSecond = (index % 2 == 0) ? speed : -speed; + const int members = UniformInt(io_random, 3, 6); + for (int member = 0; member < members; ++member) + { + AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, 0); + } + } +} + +void BuildOneShots(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys, double in_passthroughShare) +{ + io_scenario.description = "persistent groups plus random one-shots of 0.2 to 2 s around the group centres"; + io_scenario.frames = 2000; + const std::vector centres = MakeStaticCentres(io_random, 10, 20.0, 20.0, 500.0); + for (const Centre& centre : centres) + { + for (int member = 0; member < 3; ++member) + { + SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, 0); + source.clusterable = Uniform(io_random, 0.0, 1.0) >= in_passthroughShare; + } + } + AddOneShots(io_scenario, io_keys, io_random, centres, 100.0, 4.0, in_passthroughShare); +} + +void BuildJitter(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "static groups whose members jitter by up to 0.25 degrees every frame"; + io_scenario.frames = 1000; + io_scenario.settleFrame = 60; + for (const Centre& centre : MakeStaticCentres(io_random, 10, 30.0, 10.0, 1000.0)) + { + const int members = UniformInt(io_random, 3, 6); + for (int member = 0; member < members; ++member) + { + SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, 0); + source.jitterDegrees = 0.25; + } + } +} + +void BuildPassBy(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "sources that fly past a static group, join it and leave it again"; + io_scenario.frames = 800; + Centre centre; + centre.direction = {0.0, 0.0, 1.0}; + centre.range = 100.0; + for (int member = 0; member < 6; ++member) + { + SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, 1.0, 0.05, 0); + source.loudness = 0.2f; + } + + SourceSpec first; + first.key = io_keys.Next(); + first.loudness = 0.3f; + first.motion = Motion::Linear; + first.origin = {-400.0, 3.0, 100.0}; + first.velocity = {100.0, 0.0, 0.0}; + io_scenario.sources.push_back(first); + + SourceSpec second; + second.key = io_keys.Next(); + second.loudness = 0.1f; + second.motion = Motion::Linear; + second.origin = {300.0, -2.0, 60.0}; + second.velocity = {-80.0, 0.0, 0.0}; + io_scenario.sources.push_back(second); +} + +void BuildSqueeze(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "many groups with maxOutputs lowered and raised while running"; + io_scenario.frames = 600; + for (const Centre& centre : MakeStaticCentres(io_random, 24, 12.0, 10.0, 1000.0)) + { + for (int member = 0; member < 4; ++member) + { + AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, 0); + } + } + for (int index = 0; index < 4; ++index) + { + SourceSpec source; + source.key = io_keys.Next(); + source.clusterable = false; + source.origin = Scale(RandomDirection(io_random), 50.0); + io_scenario.sources.push_back(source); + } + const uint32_t budgets[] = {12, 20, 8}; + const int frames[] = {100, 300, 450}; + for (int index = 0; index < 3; ++index) + { + ParamChange change; + change.frame = frames[index]; + change.params = io_scenario.params; + change.params.maxOutputs = budgets[index]; + io_scenario.changes.push_back(change); + } +} + +void BuildNearField(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "sources around the listener inside and outside the near field, and high priority sources"; + io_scenario.frames = 1200; + io_scenario.params.nearFieldRadius = 5.f; + io_scenario.params.excludePriorityAbove = 80; + for (int index = 0; index < 18; ++index) + { + const bool inside = index < 12; + SourceSpec source; + source.key = io_keys.Next(); + source.loudness = static_cast(Uniform(io_random, 0.05, 0.5)); + const Vec3 direction = RandomDirection(io_random); + source.origin = Scale(direction, inside ? Uniform(io_random, 1.0, 4.5) : Uniform(io_random, 6.0, 10.0)); + source.motion = Motion::Orbit; + source.axis = AnyPerpendicular(direction); + source.degreesPerSecond = inside ? Uniform(io_random, 30.0, 90.0) : Uniform(io_random, 5.0, 20.0); + io_scenario.sources.push_back(source); + } + for (int index = 0; index < 2; ++index) + { + SourceSpec source; + source.key = io_keys.Next(); + source.loudness = 0.2f; + source.motion = Motion::Linear; + source.origin = {-50.0, 1.0 + index, 2.0}; + source.velocity = {20.0 + 5.0 * index, 0.0, 0.0}; + io_scenario.sources.push_back(source); + } + Centre centre; + centre.direction = Normalize(Vec3{0.3, 0.2, 1.0}); + centre.range = 50.0; + const uint32_t priorities[] = {90, 90, 40, 40, 40}; + for (uint32_t priority : priorities) + { + SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.05, 0); + source.priority = priority; + } +} + +void BuildGroups(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "three group IDs that share the same directions"; + io_scenario.frames = 400; + io_scenario.settleFrame = 60; + for (const Centre& centre : MakeStaticCentres(io_random, 4, 30.0, 20.0, 400.0)) + { + for (uint32_t group = 0; group < 3; ++group) + { + for (int member = 0; member < 4; ++member) + { + AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, group * 7u + 1u); + } + } + } +} + +void BuildCloud(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "dense cloud of slowly drifting sources inside a 60 degree cone"; + io_scenario.frames = 1500; + for (int index = 0; index < 200; ++index) + { + SourceSpec source; + source.key = io_keys.Next(); + source.loudness = static_cast(Uniform(io_random, 0.01, 0.5)); + source.priority = static_cast(UniformInt(io_random, 0, 100)); + const Vec3 direction = OffsetDirection(Vec3{0.0, 0.0, 1.0}, 30.0, io_random); + source.origin = Scale(direction, LogUniform(io_random, 10.0, 1000.0)); + source.motion = Motion::Orbit; + source.axis = Normalize(Cross(direction, RandomDirection(io_random))); + source.degreesPerSecond = Uniform(io_random, 1.0, 6.0); + io_scenario.sources.push_back(source); + } +} + +void BuildStress(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "about 1024 inputs around 64 slowly orbiting centres"; + io_scenario.frames = 1000; + std::vector centres; + std::vector used; + for (int index = 0; index < 64; ++index) + { + Centre centre; + centre.direction = PickSeparatedDirection(used, 8.0, io_random); + centre.range = LogUniform(io_random, 10.0, 1000.0); + centre.motion = Motion::Orbit; + centre.axis = Normalize(Cross(centre.direction, RandomDirection(io_random))); + centre.degreesPerSecond = Uniform(io_random, 2.0, 10.0); + centres.push_back(centre); + for (int member = 0; member < 12; ++member) + { + AddMember(io_scenario, io_keys, io_random, centre, 3.0, 0.2, 0); + } + } + AddOneShots(io_scenario, io_keys, io_random, centres, 233.0, 3.0, 0.0); +} + +struct ScenarioEntry +{ + const char* name; + uint32_t seed; +}; + +const ScenarioEntry kScenarios[] = { + {"static", 1001}, {"crossing", 1002}, {"oneshots", 1003}, {"jitter", 1004}, + {"passby", 1005}, {"squeeze", 1006}, {"nearfield", 1007}, {"groups", 1008}, + {"passthrough", 1009}, {"shuffle", 1010}, {"cloud", 1011}, {"stress", 1012}, +}; +} + +const std::vector& GetScenarioNames() +{ + static const std::vector s_names = [] { + std::vector names; + for (const ScenarioEntry& entry : kScenarios) + { + names.push_back(entry.name); + } + return names; + }(); + return s_names; +} + +uint32_t GetDefaultSeed(const std::string& in_name) +{ + for (const ScenarioEntry& entry : kScenarios) + { + if (in_name == entry.name) + { + return entry.seed; + } + } + return 1; +} + +bool BuildScenario(const std::string& in_name, uint32_t in_seed, Scenario& out_scenario) +{ + out_scenario = Scenario(); + out_scenario.name = in_name; + out_scenario.seed = in_seed; + std::mt19937 random(in_seed); + KeySource keys(in_seed); + + if (in_name == "static") + { + BuildStatic(out_scenario, random, keys); + } + else if (in_name == "crossing") + { + BuildCrossing(out_scenario, random, keys); + } + else if (in_name == "oneshots") + { + BuildOneShots(out_scenario, random, keys, 0.0); + } + else if (in_name == "jitter") + { + BuildJitter(out_scenario, random, keys); + } + else if (in_name == "passby") + { + BuildPassBy(out_scenario, random, keys); + } + else if (in_name == "squeeze") + { + BuildSqueeze(out_scenario, random, keys); + } + else if (in_name == "nearfield") + { + BuildNearField(out_scenario, random, keys); + } + else if (in_name == "groups") + { + BuildGroups(out_scenario, random, keys); + } + else if (in_name == "passthrough") + { + out_scenario.params.maxOutputs = 32; + BuildOneShots(out_scenario, random, keys, 0.1); + out_scenario.description = "one-shots where a tenth of the inputs pass through, with maxOutputs 32"; + } + else if (in_name == "shuffle") + { + BuildOneShots(out_scenario, random, keys, 0.0); + out_scenario.shuffle = true; + out_scenario.description = "one-shots with the input order shuffled every frame"; + } + else if (in_name == "cloud") + { + BuildCloud(out_scenario, random, keys); + } + else if (in_name == "stress") + { + BuildStress(out_scenario, random, keys); + } + else + { + return false; + } + return true; +} + +void BuildBenchScenario(uint32_t in_inputs, uint32_t in_seed, int in_frames, Scenario& out_scenario) +{ + out_scenario = Scenario(); + out_scenario.name = "bench"; + out_scenario.description = "fixed input count around slowly orbiting centres with respawning one-shots"; + out_scenario.seed = in_seed; + out_scenario.frames = in_frames; + std::mt19937 random(in_seed); + KeySource keys(in_seed); + + const int centreCount = std::max(8, std::min(128, static_cast(in_inputs / 4u))); + std::vector centres; + std::vector used; + for (int index = 0; index < centreCount; ++index) + { + Centre centre; + centre.direction = PickSeparatedDirection(used, 8.0, random); + centre.range = LogUniform(random, 10.0, 1000.0); + centre.motion = Motion::Orbit; + centre.axis = Normalize(Cross(centre.direction, RandomDirection(random))); + centre.degreesPerSecond = Uniform(random, 2.0, 10.0); + centres.push_back(centre); + } + + const uint32_t persistent = in_inputs * 3u / 4u; + for (uint32_t index = 0; index < persistent; ++index) + { + AddMember(out_scenario, keys, random, centres[index % centres.size()], 3.0, 0.2, 0); + } + for (uint32_t index = persistent; index < in_inputs; ++index) + { + int frame = 0; + while (frame < in_frames) + { + const Centre& centre = centres[static_cast(UniformInt(random, 0, centreCount - 1))]; + SourceSpec& source = AddMember(out_scenario, keys, random, centre, 3.0, 0.3, 0); + source.startFrame = frame; + source.endFrame = frame + SecondsToFrames(out_scenario, Uniform(random, 0.2, 2.0)); + source.loudness = static_cast(Uniform(random, 0.01, 0.8)); + frame = source.endFrame; + } + } +} + +ScenarioPlayer::ScenarioPlayer(const Scenario& in_scenario, uint32_t in_orderSeed, bool in_shuffle) + : m_scenario(in_scenario) + , m_jitterRandom(in_scenario.seed ^ 0x717E7u) + , m_orderRandom(in_orderSeed) + , m_shuffle(in_shuffle) + , m_params(in_scenario.params) +{ +} + +const std::vector& ScenarioPlayer::NextFrame() +{ + ++m_frame; + while (m_nextChange < m_scenario.changes.size() && m_scenario.changes[m_nextChange].frame <= m_frame) + { + m_params = m_scenario.changes[m_nextChange].params; + ++m_nextChange; + } + + const double seconds = m_frame * m_scenario.frameSeconds; + m_inputs.clear(); + for (const SourceSpec& source : m_scenario.sources) + { + if (m_frame < source.startFrame || m_frame >= source.endFrame) + { + continue; + } + Vec3 position = source.origin; + if (source.motion == Motion::Orbit) + { + position = RotateAround(source.origin, source.axis, ToRadians(source.degreesPerSecond * seconds)); + } + else if (source.motion == Motion::Linear) + { + position = Add(source.origin, Scale(source.velocity, seconds)); + } + if (source.jitterDegrees > 0.0) + { + position = Scale(OffsetDirection(position, source.jitterDegrees, m_jitterRandom), Length(position)); + } + + ClusterInput input; + input.key = source.key; + input.position = {static_cast(position.x), static_cast(position.y), static_cast(position.z)}; + input.loudness = source.loudness; + input.priority = source.priority; + input.group = source.group; + input.clusterable = source.clusterable; + m_inputs.push_back(input); + } + if (m_shuffle) + { + for (size_t index = m_inputs.size(); index > 1; --index) + { + const size_t other = m_orderRandom() % index; + std::swap(m_inputs[index - 1], m_inputs[other]); + } + } + return m_inputs; +} + +const ClusterParams& ScenarioPlayer::GetParams() const +{ + return m_params; +} + +int ScenarioPlayer::GetFrame() const +{ + return m_frame; +} diff --git a/harness/src/Scenarios.h b/harness/src/Scenarios.h new file mode 100644 index 0000000..a419e3b --- /dev/null +++ b/harness/src/Scenarios.h @@ -0,0 +1,126 @@ +/* + * 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 "ClusterEngine.h" + +#include +#include +#include +#include +#include + +/** @brief Double precision vector for scenario motion. */ +struct Vec3 +{ + double x = 0.0; ///< X coordinate. + double y = 0.0; ///< Y coordinate. + double z = 0.0; ///< Z coordinate. +}; + +/** @brief How a scenario source moves over time. */ +enum class Motion +{ + Static, ///< Stays at its origin. + Orbit, ///< Rotates around an axis through the listener. + Linear ///< Moves along a straight line. +}; + +/** @brief One sound source of a scenario. */ +struct SourceSpec +{ + uint64_t key = 0; ///< Input key. + uint32_t group = 0; ///< Input group. + bool clusterable = true; ///< False for passthrough inputs. + uint32_t priority = 50; ///< Input priority. + float loudness = 0.1f; ///< RMS level. + int startFrame = 0; ///< First frame in which the source plays. + int endFrame = INT_MAX; ///< Frame in which the source no longer plays. + Motion motion = Motion::Static; ///< Motion model. + Vec3 origin; ///< Position at frame zero. + Vec3 axis; ///< Unit rotation axis of an orbiting source. + double degreesPerSecond = 0.0; ///< Rotation speed of an orbiting source. + Vec3 velocity; ///< Velocity of a linear source in units per second. + double jitterDegrees = 0.0; ///< Largest random direction offset applied each frame. +}; + +/** @brief Parameters that apply from a given frame on. */ +struct ParamChange +{ + int frame = 0; ///< First frame with the new parameters. + ClusterParams params; ///< New parameters. +}; + +/** @brief Complete description of a generated scenario. */ +struct Scenario +{ + std::string name; ///< Scenario name. + std::string description; ///< One line description. + uint32_t seed = 0; ///< Seed the scenario was generated from. + double frameSeconds = 512.0 / 48000.0; ///< Frame length in seconds. + int frames = 0; ///< Number of frames to run. + int settleFrame = -1; ///< Frame after which no moves are expected, or -1. + bool shuffle = false; ///< Shuffles the input order every frame. + ClusterParams params; ///< Parameters at frame zero. + std::vector changes; ///< Later parameter changes in frame order. + std::vector sources; ///< All sources. +}; + +/** @brief Returns the names of all scenarios. */ +const std::vector& GetScenarioNames(); + +/** @brief Returns the default seed of a scenario. */ +uint32_t GetDefaultSeed(const std::string& in_name); + +/** @brief Builds a scenario by name and returns false if the name is unknown. */ +bool BuildScenario(const std::string& in_name, uint32_t in_seed, Scenario& out_scenario); + +/** @brief Builds the benchmark scenario with exactly the given number of inputs in every frame. */ +void BuildBenchScenario(uint32_t in_inputs, uint32_t in_seed, int in_frames, Scenario& out_scenario); + +/** @brief Produces the engine inputs of a scenario frame by frame. */ +class ScenarioPlayer +{ +public: + /** @brief Prepares playback of a scenario with a seed for the input order. */ + ScenarioPlayer(const Scenario& in_scenario, uint32_t in_orderSeed, bool in_shuffle); + + /** @brief Builds the inputs of the next frame. */ + const std::vector& NextFrame(); + + /** @brief Returns the parameters of the frame last built. */ + const ClusterParams& GetParams() const; + + /** @brief Returns the index of the frame last built. */ + int GetFrame() const; + +private: + const Scenario& m_scenario; ///< Scenario being played. + std::mt19937 m_jitterRandom; ///< Random source for direction jitter. + std::mt19937 m_orderRandom; ///< Random source for the input order. + bool m_shuffle = false; ///< Shuffles the input order every frame. + std::vector m_inputs; ///< Inputs of the frame last built. + ClusterParams m_params; ///< Parameters of the frame last built. + size_t m_nextChange = 0; ///< Index of the next parameter change. + int m_frame = -1; ///< Index of the frame last built. +}; diff --git a/harness/src/Tests.cpp b/harness/src/Tests.cpp new file mode 100644 index 0000000..5103978 --- /dev/null +++ b/harness/src/Tests.cpp @@ -0,0 +1,1807 @@ +/* + * 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 "FrameAnalysis.h" +#include "HarnessMath.h" +#include "HarnessModes.h" +#include "Scenarios.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define EXPECT(context, condition, ...) \ + do \ + { \ + if (!(condition)) \ + { \ + (context).Fail(__VA_ARGS__); \ + } \ + } while (false) + +namespace +{ +/** @brief Number of failed checks per test that are printed. */ +constexpr int kMaxReportedFailures = 12; + +/** @brief Counts and prints the failed checks of one test. */ +class TestContext +{ +public: + /** @brief Creates a context for the test with the given name. */ + explicit TestContext(const char* in_name) + : m_name(in_name) + { + } + + /** @brief Records a failed check and prints its message unless too many were printed. */ + void Fail(const char* in_format, ...) + { + ++m_failures; + if (m_failures > kMaxReportedFailures) + { + return; + } + std::printf(" FAIL %s: ", m_name); + va_list args; + va_start(args, in_format); + std::vprintf(in_format, args); + va_end(args); + std::printf("\n"); + } + + /** @brief Returns the number of failed checks. */ + int GetFailures() const + { + return m_failures; + } + +private: + const char* m_name; ///< Name of the test. + int m_failures = 0; ///< Number of failed checks. +}; + +ClusterInput MakeInput(uint64_t in_key, double in_degrees, double in_range, float in_loudness = 0.1f, uint32_t in_group = 0, + uint32_t in_priority = 50, bool in_clusterable = true) +{ + ClusterInput input; + input.key = in_key; + const double radians = in_degrees * kPi / 180.0; + input.position = {static_cast(std::sin(radians) * in_range), 0.f, static_cast(std::cos(radians) * in_range)}; + input.loudness = in_loudness; + input.priority = in_priority; + input.group = in_group; + input.clusterable = in_clusterable; + return input; +} + +ClusterInput MakeRawInput(uint64_t in_key, float in_x, float in_y, float in_z, float in_loudness = 0.1f) +{ + ClusterInput input; + input.key = in_key; + input.position = {in_x, in_y, in_z}; + input.loudness = in_loudness; + input.priority = 50; + input.group = 0; + input.clusterable = true; + return input; +} + +/** @brief One contribution of an input, with the cluster it goes to. */ +struct Membership +{ + int cluster = -1; ///< Index of the cluster in the frame's reported clusters. + uint32_t id = 0; ///< ID of the cluster. + float gainStart = 0.f; ///< Gain at the start of the frame. + float gainEnd = 0.f; ///< Gain at the end of the frame. +}; + +std::vector FindMemberships(const FrameView& in_view, uint64_t in_key) +{ + std::vector result; + for (const ClusterContribution& contribution : in_view.contributions) + { + if (contribution.input < in_view.inputs.size() && in_view.inputs[contribution.input].key == in_key) + { + Membership membership; + membership.cluster = static_cast(contribution.cluster); + membership.id = in_view.clusters[contribution.cluster].id; + membership.gainStart = contribution.gainStart; + membership.gainEnd = contribution.gainEnd; + result.push_back(membership); + } + } + return result; +} + +int HomeIdOf(const FrameView& in_view, uint64_t in_key) +{ + const std::vector home = FindHomeClusters(in_view); + for (size_t index = 0; index < in_view.inputs.size(); ++index) + { + if (in_view.inputs[index].key == in_key) + { + return home[index] < 0 ? -1 : static_cast(in_view.clusters[static_cast(home[index])].id); + } + } + return -1; +} + +float SanitizedLoudness(float in_loudness) +{ + if (!std::isfinite(in_loudness) || in_loudness <= 0.f) + { + return 0.f; + } + return std::min(in_loudness, 1e9f); +} + +/** @brief Checks the engine output of consecutive frames against the rules every frame must follow. */ +class InvariantChecker +{ +public: + /** @brief Checks one frame against the rules and against the previous frame. */ + void Check(TestContext& io_context, const char* in_label, int in_frame, const FrameView& in_view, const ClusterParams& in_params) + { + const size_t inputCount = in_view.inputs.size(); + const size_t clusterCount = in_view.clusters.size(); + EXPECT(io_context, in_view.updated, "%s frame %d: Update returned false", in_label, in_frame); + + std::unordered_set seenKeys; + std::vector isDuplicate(inputCount, false); + std::unordered_map groups; + for (size_t index = 0; index < inputCount; ++index) + { + const ClusterInput& input = in_view.inputs[index]; + isDuplicate[index] = !seenKeys.insert(input.key).second; + if (isDuplicate[index]) + { + continue; + } + groups[input.key] = input.group; + const auto previous = m_previousGroups.find(input.key); + if (previous != m_previousGroups.end() && previous->second != input.group) + { + m_groupChangeFrames[input.key] = in_frame; + } + } + m_maxCrossfadeFrames = std::max(m_maxCrossfadeFrames, in_params.crossfadeFrames); + const int groupChangeAllowance = 2 * static_cast(m_maxCrossfadeFrames) + 2; + + std::unordered_set ids; + for (size_t index = 0; index < clusterCount; ++index) + { + const ClusterInfo& cluster = in_view.clusters[index]; + ids.insert(cluster.id); + EXPECT(io_context, index == 0 || cluster.id > in_view.clusters[index - 1].id, + "[ids] %s frame %d: cluster ids not strictly ascending at %zu", in_label, in_frame, index); + const bool wasPresent = m_previousIds.count(cluster.id) != 0; + EXPECT(io_context, cluster.isNew == !wasPresent, "[is-new] %s frame %d: cluster %u isNew %d but present before %d", + in_label, in_frame, cluster.id, cluster.isNew ? 1 : 0, wasPresent ? 1 : 0); + EXPECT(io_context, IsFiniteVec(cluster.position), "[report] %s frame %d: cluster %u position not finite", in_label, + in_frame, cluster.id); + EXPECT(io_context, cluster.angularRadius >= 0.f && cluster.angularRadius <= 180.f, + "[report] %s frame %d: cluster %u radius %f", in_label, in_frame, cluster.id, cluster.angularRadius); + } + + std::vector sumStart(inputCount, 0.0); + std::vector sumEnd(inputCount, 0.0); + std::vector contributionCount(inputCount, 0); + std::vector perCluster(clusterCount, 0); + std::vector maxPriority(clusterCount, 0); + std::vector representativeFound(clusterCount, false); + std::vector clusterGroup(clusterCount, -1); + std::vector bestShare(clusterCount, -1.0); + std::unordered_map> pairs; + + for (const ClusterContribution& contribution : in_view.contributions) + { + const bool valid = contribution.input < inputCount && contribution.cluster < clusterCount; + EXPECT(io_context, valid, "[report] %s frame %d: contribution index out of range", in_label, in_frame); + if (!valid) + { + continue; + } + const ClusterInput& input = in_view.inputs[contribution.input]; + const ClusterInfo& cluster = in_view.clusters[contribution.cluster]; + const float gainStart = contribution.gainStart; + const float gainEnd = contribution.gainEnd; + EXPECT(io_context, std::isfinite(gainStart) && std::isfinite(gainEnd) && gainStart >= 0.f && gainStart <= 1.f && + gainEnd >= 0.f && gainEnd <= 1.f && (gainStart > 0.f || gainEnd > 0.f), + "[report] %s frame %d: bad gains %f %f", in_label, in_frame, gainStart, gainEnd); + EXPECT(io_context, input.clusterable, "[passthrough] %s frame %d: passthrough input %zu has a contribution", + in_label, in_frame, static_cast(contribution.input)); + EXPECT(io_context, !isDuplicate[contribution.input], "[duplicates] %s frame %d: repeated key has a contribution", + in_label, in_frame); + + sumStart[contribution.input] += gainStart; + sumEnd[contribution.input] += gainEnd; + ++contributionCount[contribution.input]; + ++perCluster[contribution.cluster]; + maxPriority[contribution.cluster] = std::max(maxPriority[contribution.cluster], input.priority); + if (contribution.input == cluster.representative) + { + representativeFound[contribution.cluster] = true; + } + const auto groupChange = m_groupChangeFrames.find(input.key); + const bool recentGroupChange = + groupChange != m_groupChangeFrames.end() && in_frame - groupChange->second <= groupChangeAllowance; + if (!recentGroupChange) + { + if (clusterGroup[contribution.cluster] < 0) + { + clusterGroup[contribution.cluster] = input.group; + } + EXPECT(io_context, clusterGroup[contribution.cluster] == static_cast(input.group), + "[groups] %s frame %d: cluster %u mixes groups", in_label, in_frame, cluster.id); + } + + const double weight = in_params.loudnessWeighting ? SanitizedLoudness(input.loudness) + 1e-6 : 1.0; + bestShare[contribution.cluster] = std::max(bestShare[contribution.cluster], weight * gainEnd); + + if (IsFiniteVec(input.position) && LengthOf(input.position) > 1e-3 && LengthOf(cluster.position) > 1e-3) + { + const double angle = AngleBetween(input.position, cluster.position); + EXPECT(io_context, angle <= cluster.angularRadius + 0.05, + "[report] %s frame %d: member at %.3f deg outside radius %.3f of cluster %u", in_label, in_frame, + angle, cluster.angularRadius, cluster.id); + } + + pairs[input.key][cluster.id] = gainEnd; + const auto previous = m_previousPairs.find(input.key); + const bool hadPair = previous != m_previousPairs.end() && previous->second.count(cluster.id) != 0; + if (hadPair) + { + const float previousEnd = previous->second.at(cluster.id); + EXPECT(io_context, std::fabs(gainStart - previousEnd) <= 1e-6f, + "[continuity] %s frame %d: key %llx cluster %u starts at %f after ending at %f", in_label, in_frame, + static_cast(input.key), cluster.id, gainStart, previousEnd); + } + else if (m_previousClusterable.count(input.key) != 0) + { + EXPECT(io_context, gainStart == 0.f, "[continuity] %s frame %d: key %llx new pair starts at %f", in_label, + in_frame, static_cast(input.key), gainStart); + } + else + { + EXPECT(io_context, gainStart == 1.f && gainEnd == 1.f, + "[continuity] %s frame %d: new key %llx starts with %f %f", in_label, in_frame, + static_cast(input.key), gainStart, gainEnd); + } + } + + for (size_t index = 0; index < clusterCount; ++index) + { + const ClusterInfo& cluster = in_view.clusters[index]; + EXPECT(io_context, perCluster[index] > 0, "[report] %s frame %d: cluster %u has no contribution", in_label, in_frame, + cluster.id); + EXPECT(io_context, perCluster[index] == cluster.memberCount, "[report] %s frame %d: cluster %u memberCount %u vs %u", + in_label, in_frame, cluster.id, cluster.memberCount, perCluster[index]); + EXPECT(io_context, representativeFound[index], "[report] %s frame %d: cluster %u representative not a member", + in_label, in_frame, cluster.id); + EXPECT(io_context, cluster.priority == maxPriority[index], "[report] %s frame %d: cluster %u priority %u vs %u", + in_label, in_frame, cluster.id, cluster.priority, maxPriority[index]); + if (representativeFound[index] && in_params.loudnessSmoothing == 0.f && cluster.representative < inputCount) + { + const ClusterInput& representative = in_view.inputs[cluster.representative]; + double representativeEnd = 0.0; + for (const ClusterContribution& contribution : in_view.contributions) + { + if (contribution.cluster == index && contribution.input == cluster.representative) + { + representativeEnd = contribution.gainEnd; + } + } + const double weight = + in_params.loudnessWeighting ? SanitizedLoudness(representative.loudness) + 1e-6 : 1.0; + EXPECT(io_context, weight * representativeEnd >= bestShare[index] * (1.0 - 1e-5), + "[report] %s frame %d: cluster %u representative is not the loudest", in_label, in_frame, cluster.id); + } + } + + std::unordered_set clusterable; + for (size_t index = 0; index < inputCount; ++index) + { + const ClusterInput& input = in_view.inputs[index]; + if (!input.clusterable || isDuplicate[index]) + { + EXPECT(io_context, contributionCount[index] == 0, "[passthrough] %s frame %d: input %zu has %d contributions", + in_label, in_frame, index, contributionCount[index]); + continue; + } + clusterable.insert(input.key); + EXPECT(io_context, contributionCount[index] == 1 || contributionCount[index] == 2, + "[gain-sum] %s frame %d: input %zu has %d contributions", in_label, in_frame, index, contributionCount[index]); + EXPECT(io_context, std::fabs(sumStart[index] - 1.0) <= 1e-5 && std::fabs(sumEnd[index] - 1.0) <= 1e-5, + "[gain-sum] %s frame %d: input %zu sums %f %f", in_label, in_frame, index, sumStart[index], sumEnd[index]); + } + + for (const auto& previous : m_previousPairs) + { + if (clusterable.count(previous.first) == 0) + { + continue; + } + const auto current = pairs.find(previous.first); + for (const auto& pair : previous.second) + { + const bool stillThere = current != pairs.end() && current->second.count(pair.first) != 0; + EXPECT(io_context, stillThere || pair.second <= 1e-6f, + "[continuity] %s frame %d: key %llx left cluster %u at gain %f without a fade", in_label, in_frame, + static_cast(previous.first), pair.first, pair.second); + } + } + + for (auto change = m_groupChangeFrames.begin(); change != m_groupChangeFrames.end();) + { + change = in_frame - change->second > groupChangeAllowance ? m_groupChangeFrames.erase(change) : std::next(change); + } + m_previousPairs.swap(pairs); + m_previousIds.swap(ids); + m_previousClusterable.swap(clusterable); + m_previousGroups.swap(groups); + } + +private: + std::unordered_map> m_previousPairs; ///< End gain per cluster ID of each input key in the previous frame. + std::unordered_set m_previousIds; ///< Cluster IDs reported in the previous frame. + std::unordered_set m_previousClusterable; ///< Keys of clusterable inputs in the previous frame. + std::unordered_map m_previousGroups; ///< Group of each input key in the previous frame. + std::unordered_map m_groupChangeFrames; ///< Frame of the recent group change of each input key. + uint32_t m_maxCrossfadeFrames = 1; ///< Longest crossfade in frames seen so far. +}; + +/** @brief Engine with a counting allocator that can check every frame it computes. */ +class TestEngine +{ +public: + /** @brief Creates an engine that checks every frame when a test context is given. */ + explicit TestEngine(uint32_t in_reserve = 64, TestContext* io_pContext = nullptr, const char* in_label = "") + : m_pContext(io_pContext) + , m_label(in_label) + { + m_engine.Init(&m_allocator, in_reserve); + } + + /** @brief Runs one update, checks it when a test context was given and returns the frame. */ + const FrameView& Step(const std::vector& in_inputs, const ClusterParams& in_params) + { + const bool updated = m_engine.Update(in_inputs.data(), static_cast(in_inputs.size()), in_params); + CaptureFrame(m_engine, in_inputs, updated, m_view); + if (m_pContext != nullptr) + { + m_checker.Check(*m_pContext, m_label, m_frame, m_view, in_params); + } + ++m_frame; + return m_view; + } + + /** @brief Returns the engine. */ + ClusterEngine& GetEngine() + { + return m_engine; + } + + /** @brief Returns the allocator of the engine. */ + CountingAllocator& GetAllocator() + { + return m_allocator; + } + +private: + CountingAllocator m_allocator; ///< Allocator of the engine. + ClusterEngine m_engine; ///< Engine under test. + FrameView m_view; ///< Frame of the last update. + InvariantChecker m_checker; ///< Checks every frame when a test context is given. + TestContext* m_pContext; ///< Test context for the checks, or nullptr. + const char* m_label; ///< Label printed with failed checks. + int m_frame = 0; ///< Number of updates run. +}; + +/** @brief Order in which a scenario hands its inputs to the engine. */ +enum class Order +{ + Scenario, ///< Shuffled only if the scenario asks for it. + Sorted, ///< Never shuffled. + Shuffled ///< Shuffled every frame. +}; + +/** @brief Options for playing a scenario. */ +struct RunOptions +{ + Order order = Order::Scenario; ///< Order of the inputs. + uint32_t orderSeed = 77; ///< Seed of the shuffle. + int maxFrames = -1; ///< Largest number of frames to play, or -1 for the whole scenario. + uint32_t reserve = 64; ///< Number of inputs the engine reserves memory for. +}; + +using FrameCallback = std::function; + +void PlayScenario(const Scenario& in_scenario, const RunOptions& in_options, const FrameCallback& in_callback) +{ + CountingAllocator allocator; + ClusterEngine engine; + engine.Init(&allocator, in_options.reserve); + const bool shuffle = in_options.order == Order::Shuffled || (in_options.order == Order::Scenario && in_scenario.shuffle); + ScenarioPlayer player(in_scenario, in_options.orderSeed, shuffle); + const int frames = in_options.maxFrames >= 0 ? std::min(in_options.maxFrames, in_scenario.frames) : in_scenario.frames; + FrameView view; + for (int frame = 0; frame < frames; ++frame) + { + const std::vector& inputs = player.NextFrame(); + const bool updated = engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); + CaptureFrame(engine, inputs, updated, view); + in_callback(frame, view, player.GetParams()); + } + engine.Term(); +} + +Scenario LoadScenario(const std::string& in_name, uint32_t in_seed = 0) +{ + Scenario scenario; + BuildScenario(in_name, in_seed == 0 ? GetDefaultSeed(in_name) : in_seed, scenario); + return scenario; +} + +/** @brief Engine output of one frame in a form that does not depend on the input order. */ +struct CanonicalFrame +{ + std::vector> clusters; ///< Clusters with the representative as a key. + std::vector> contributions; ///< Contributions by input key and cluster ID, sorted. + bool overBudget = false; ///< Value of IsOverBudget. + + /** @brief Returns true if both frames are equal. */ + bool operator==(const CanonicalFrame& in_other) const + { + return clusters == in_other.clusters && contributions == in_other.contributions && overBudget == in_other.overBudget; + } +}; + +CanonicalFrame MakeCanonical(const FrameView& in_view) +{ + CanonicalFrame canonical; + canonical.overBudget = in_view.overBudget; + for (const ClusterInfo& cluster : in_view.clusters) + { + const uint64_t representativeKey = + cluster.representative < in_view.inputs.size() ? in_view.inputs[cluster.representative].key : ~0ull; + canonical.clusters.emplace_back(cluster.id, cluster.isNew, cluster.position.x, cluster.position.y, cluster.position.z, + cluster.angularRadius, cluster.priority, cluster.memberCount, representativeKey); + } + for (const ClusterContribution& contribution : in_view.contributions) + { + canonical.contributions.emplace_back(in_view.inputs[contribution.input].key, in_view.clusters[contribution.cluster].id, + contribution.gainStart, contribution.gainEnd); + } + std::sort(canonical.contributions.begin(), canonical.contributions.end()); + return canonical; +} + +void TestInvariants(TestContext& io_context) +{ + for (const std::string& name : GetScenarioNames()) + { + const Scenario scenario = LoadScenario(name); + InvariantChecker checker; + PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { + checker.Check(io_context, name.c_str(), in_frame, in_view, in_params); + }); + } +} + +void TestInvariantsParameterSweep(TestContext& io_context) +{ + struct Variant + { + const char* label; + std::function apply; + }; + const std::vector variants = { + {"dwell0-fade0", [](ClusterParams& io_params) { io_params.dwellFrames = 0; io_params.crossfadeFrames = 0; }}, + {"dwell1-fade1", [](ClusterParams& io_params) { io_params.dwellFrames = 1; io_params.crossfadeFrames = 1; }}, + {"dwell30-fade10", [](ClusterParams& io_params) { io_params.dwellFrames = 30; io_params.crossfadeFrames = 10; }}, + {"distance0.5", [](ClusterParams& io_params) { io_params.distanceWeight = 0.5f; }}, + {"loudness-off", [](ClusterParams& io_params) { io_params.loudnessWeighting = false; }}, + {"smoothing0.9", [](ClusterParams& io_params) { io_params.loudnessSmoothing = 0.9f; }}, + {"join15-leave1", [](ClusterParams& io_params) { io_params.joinAngle = 15.f; io_params.leaveRatio = 1.f; }}, + {"join180-max1", [](ClusterParams& io_params) { io_params.joinAngle = 180.f; io_params.maxOutputs = 1; }}, + {"join0", [](ClusterParams& io_params) { io_params.joinAngle = 0.f; }}, + {"max6", [](ClusterParams& io_params) { io_params.maxOutputs = 6; }}, + {"nearfield50", [](ClusterParams& io_params) { io_params.nearFieldRadius = 50.f; io_params.excludePriorityAbove = 70; }}, + }; + const char* names[] = {"cloud", "crossing", "oneshots", "nearfield"}; + for (const char* name : names) + { + for (const Variant& variant : variants) + { + Scenario scenario = LoadScenario(name, GetDefaultSeed(name) + 17u); + variant.apply(scenario.params); + for (ParamChange& change : scenario.changes) + { + variant.apply(change.params); + } + const std::string label = std::string(name) + "/" + variant.label; + InvariantChecker checker; + RunOptions options; + options.maxFrames = 500; + options.order = Order::Shuffled; + PlayScenario(scenario, options, [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { + checker.Check(io_context, label.c_str(), in_frame, in_view, in_params); + }); + } + } +} + +void TestOrderIndependence(TestContext& io_context) +{ + const char* names[] = {"oneshots", "crossing", "groups", "nearfield", "squeeze", "cloud", "passthrough", "passby"}; + for (const char* name : names) + { + const Scenario scenario = LoadScenario(name); + const int frames = std::min(scenario.frames, 800); + CountingAllocator allocators[3]; + ClusterEngine engines[3]; + ScenarioPlayer players[3] = {ScenarioPlayer(scenario, 1, false), ScenarioPlayer(scenario, 99, true), + ScenarioPlayer(scenario, 12345, true)}; + for (int index = 0; index < 3; ++index) + { + engines[index].Init(&allocators[index], 64); + } + int mismatches = 0; + for (int frame = 0; frame < frames && mismatches < 3; ++frame) + { + CanonicalFrame canonical[3]; + for (int index = 0; index < 3; ++index) + { + const std::vector& inputs = players[index].NextFrame(); + const bool updated = + engines[index].Update(inputs.data(), static_cast(inputs.size()), players[index].GetParams()); + FrameView view; + CaptureFrame(engines[index], inputs, updated, view); + canonical[index] = MakeCanonical(view); + } + for (int index = 1; index < 3; ++index) + { + if (!(canonical[0] == canonical[index])) + { + ++mismatches; + io_context.Fail("%s frame %d: shuffled run %d differs from sorted run", name, frame, index); + } + } + } + } +} + +void TestStaticSettles(TestContext& io_context) +{ + struct Expectation + { + const char* name; + size_t clusters; + }; + const Expectation expectations[] = {{"static", 12}, {"groups", 12}, {"jitter", 10}}; + for (const Expectation& expectation : expectations) + { + const Scenario scenario = LoadScenario(expectation.name); + FrameTracker tracker; + std::vector settledIds; + PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams&) { + const FrameMetrics metrics = tracker.Observe(in_frame, in_view, 0.0); + if (in_frame < scenario.settleFrame) + { + return; + } + std::vector ids; + for (const ClusterInfo& cluster : in_view.clusters) + { + ids.push_back(cluster.id); + } + if (in_frame == scenario.settleFrame) + { + settledIds = ids; + EXPECT(io_context, ids.size() == expectation.clusters, "%s: %zu clusters after settling, expected %zu", + expectation.name, ids.size(), expectation.clusters); + return; + } + EXPECT(io_context, metrics.moves == 0, "%s frame %d: %u moves after settling", expectation.name, in_frame, + metrics.moves); + EXPECT(io_context, metrics.newClusters == 0, "%s frame %d: %u new clusters after settling", expectation.name, + in_frame, metrics.newClusters); + EXPECT(io_context, ids == settledIds, "%s frame %d: cluster set changed after settling", expectation.name, in_frame); + }); + } +} + +void TestJitterMakesNoMoves(TestContext& io_context) +{ + for (uint32_t seedOffset = 0; seedOffset < 3; ++seedOffset) + { + const Scenario scenario = LoadScenario("jitter", GetDefaultSeed("jitter") + seedOffset * 31u); + FrameTracker tracker; + uint64_t moves = 0; + PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams&) { + const FrameMetrics metrics = tracker.Observe(in_frame, in_view, 0.0); + if (in_frame > scenario.settleFrame) + { + moves += metrics.moves + metrics.newClusters; + } + }); + EXPECT(io_context, moves == 0, "jitter seed %u: %llu moves or new clusters after settling", scenario.seed, + static_cast(moves)); + } +} + +struct ProbeResult +{ + bool joinedBase = false; + bool overBudget = false; + size_t clusters = 0; + float gainStart = 0.f; + float gainEnd = 0.f; +}; + +ProbeResult RunJoinProbe(TestContext& io_context, const ClusterParams& in_settleParams, const ClusterParams& in_probeParams, + const ClusterInput& in_probe) +{ + TestEngine engine(64, &io_context, "join-probe"); + std::vector base = {MakeInput(1, 0.0, 10.0), MakeInput(2, 0.5, 10.0), MakeInput(3, -0.5, 10.0)}; + for (int frame = 0; frame < 20; ++frame) + { + engine.Step(base, in_settleParams); + } + const int baseId = HomeIdOf(engine.Step(base, in_settleParams), 1); + std::vector withProbe = base; + withProbe.push_back(in_probe); + const FrameView& view = engine.Step(withProbe, in_probeParams); + ProbeResult result; + const std::vector memberships = FindMemberships(view, in_probe.key); + result.joinedBase = memberships.size() == 1 && static_cast(memberships[0].id) == baseId; + result.overBudget = view.overBudget; + result.clusters = view.clusters.size(); + if (!memberships.empty()) + { + result.gainStart = memberships[0].gainStart; + result.gainEnd = memberships[0].gainEnd; + } + return result; +} + +void TestJoinsWithinJoinAngle(TestContext& io_context) +{ + const ClusterParams params; + ClusterParams budget = params; + budget.maxOutputs = 1; + ClusterParams distance = params; + distance.distanceWeight = 1.f; + + ProbeResult result = RunJoinProbe(io_context, params, params, MakeInput(10, 3.0, 10.0)); + EXPECT(io_context, result.joinedBase && result.clusters == 1, "input at 3 deg did not join a cluster at 0 deg"); + EXPECT(io_context, result.gainStart == 1.f && result.gainEnd == 1.f, "joining input does not start at full gain"); + + result = RunJoinProbe(io_context, params, params, MakeInput(11, 4.9, 10.0)); + EXPECT(io_context, result.joinedBase, "input at 4.9 deg did not join with joinAngle 5"); + + result = RunJoinProbe(io_context, params, params, MakeInput(12, 5.1, 10.0)); + EXPECT(io_context, !result.joinedBase && result.clusters == 2, "input at 5.1 deg joined with joinAngle 5"); + + result = RunJoinProbe(io_context, params, params, MakeInput(13, -40.0, 10.0)); + EXPECT(io_context, !result.joinedBase && result.clusters == 2, "input at -40 deg joined without a budget"); + + result = RunJoinProbe(io_context, params, budget, MakeInput(14, -40.0, 10.0)); + EXPECT(io_context, result.joinedBase && result.clusters == 1 && !result.overBudget, + "input at -40 deg was not forced into the only cluster by maxOutputs 1"); + + result = RunJoinProbe(io_context, params, params, MakeInput(15, 0.0, 10.0, 0.1f, 2)); + EXPECT(io_context, !result.joinedBase && result.clusters == 2, "input of another group joined"); + + result = RunJoinProbe(io_context, distance, distance, MakeInput(16, 1.0, 40.0)); + EXPECT(io_context, !result.joinedBase, "input at 1 deg and 4x the range joined with distanceWeight 1"); + + result = RunJoinProbe(io_context, distance, distance, MakeInput(17, 1.0, 11.0)); + EXPECT(io_context, result.joinedBase, "input at 1 deg and 1.1x the range did not join with distanceWeight 1"); + + ClusterParams nearField = params; + nearField.nearFieldRadius = 5.f; + result = RunJoinProbe(io_context, nearField, nearField, MakeInput(18, 0.0, 30.0)); + EXPECT(io_context, result.joinedBase, "input outside the near field did not join"); + result = RunJoinProbe(io_context, nearField, nearField, MakeInput(19, 0.0, 4.0)); + EXPECT(io_context, !result.joinedBase && result.clusters == 2, "input inside the near field joined"); +} + +void TestLeaveBound(TestContext& io_context) +{ + const char* names[] = {"crossing", "passby", "cloud", "oneshots"}; + for (const char* name : names) + { + const Scenario scenario = LoadScenario(name); + const ClusterParams& params = scenario.params; + const double leaveAngle = static_cast(params.joinAngle) * std::max(1.0f, params.leaveRatio); + const int allowed = static_cast(params.dwellFrames + params.crossfadeFrames) + 3; + std::unordered_map outside; + int worst = 0; + PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { + const std::vector home = FindHomeClusters(in_view); + std::unordered_map next; + for (size_t index = 0; index < in_view.inputs.size(); ++index) + { + const ClusterInput& input = in_view.inputs[index]; + if (!input.clusterable || home[index] < 0 || IsExcludedInput(input, in_params)) + { + continue; + } + const ClusterInfo& cluster = in_view.clusters[static_cast(home[index])]; + if (AngleBetween(input.position, cluster.position) <= leaveAngle + 0.05) + { + continue; + } + const auto previous = outside.find(input.key); + const int count = (previous == outside.end() ? 0 : previous->second) + 1; + next[input.key] = count; + worst = std::max(worst, count); + EXPECT(io_context, count <= allowed, "%s frame %d: key %llx outside leaveAngle for %d frames", name, in_frame, + static_cast(input.key), count); + } + outside.swap(next); + }); + EXPECT(io_context, worst <= allowed, "%s: worst stay outside leaveAngle %d frames", name, worst); + } +} + +void TestBudgetRespected(TestContext& io_context) +{ + const char* names[] = {"squeeze", "passthrough"}; + for (const char* name : names) + { + const Scenario scenario = LoadScenario(name); + int budgetFrames = 0; + PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { + if (in_params.maxOutputs == 0) + { + return; + } + ++budgetFrames; + uint32_t passthrough = 0; + for (const ClusterInput& input : in_view.inputs) + { + passthrough += input.clusterable ? 0u : 1u; + } + std::vector hasSteadyMember(in_view.clusters.size(), false); + bool allSteady = true; + for (const ClusterContribution& contribution : in_view.contributions) + { + const bool steady = contribution.gainStart == 1.f && contribution.gainEnd == 1.f; + allSteady = allSteady && steady; + if (steady) + { + hasSteadyMember[contribution.cluster] = true; + } + } + const uint32_t steadyClusters = + static_cast(std::count(hasSteadyMember.begin(), hasSteadyMember.end(), true)); + const bool feasible = passthrough < in_params.maxOutputs; + EXPECT(io_context, !in_view.overBudget || !feasible, "%s frame %d: over budget although %u passthrough < %u", name, + in_frame, passthrough, in_params.maxOutputs); + if (in_view.overBudget) + { + return; + } + EXPECT(io_context, steadyClusters + passthrough <= in_params.maxOutputs, + "%s frame %d: %u settled clusters + %u passthrough > %u", name, in_frame, steadyClusters, passthrough, + in_params.maxOutputs); + if (allSteady) + { + EXPECT(io_context, in_view.clusters.size() + passthrough <= in_params.maxOutputs, + "%s frame %d: %zu clusters + %u passthrough > %u without fades", name, in_frame, in_view.clusters.size(), + passthrough, in_params.maxOutputs); + } + }); + EXPECT(io_context, budgetFrames > 0, "%s: no frame ran with a budget", name); + } +} + +void TestBudgetOverflowFlagged(TestContext& io_context) +{ + ClusterParams params; + params.nearFieldRadius = 5.f; + params.maxOutputs = 3; + { + TestEngine engine(64, &io_context, "overflow"); + std::vector inputs; + for (uint64_t key = 1; key <= 6; ++key) + { + inputs.push_back(MakeInput(key, static_cast(key) * 0.5, 1.0)); + } + for (int frame = 0; frame < 30; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, view.overBudget, "frame %d: six excluded inputs with maxOutputs 3 not flagged", frame); + EXPECT(io_context, view.clusters.size() == 6, "frame %d: six excluded inputs gave %zu clusters", frame, + view.clusters.size()); + for (const ClusterInfo& cluster : view.clusters) + { + EXPECT(io_context, cluster.memberCount == 1, "frame %d: excluded cluster %u has %u members", frame, cluster.id, + cluster.memberCount); + } + } + } + { + ClusterParams groups; + groups.maxOutputs = 2; + TestEngine engine(64, &io_context, "overflow"); + std::vector inputs; + for (uint32_t group = 0; group < 3; ++group) + { + inputs.push_back(MakeInput(10 + group * 2, 0.0, 10.0, 0.1f, group)); + inputs.push_back(MakeInput(11 + group * 2, 30.0, 10.0, 0.1f, group)); + } + for (int frame = 0; frame < 30; ++frame) + { + const FrameView& view = engine.Step(inputs, groups); + EXPECT(io_context, view.overBudget, "frame %d: three groups with maxOutputs 2 not flagged", frame); + EXPECT(io_context, frame < 5 || view.clusters.size() == 3, "frame %d: three groups gave %zu clusters", frame, + view.clusters.size()); + } + } + { + ClusterParams passthrough; + passthrough.maxOutputs = 2; + TestEngine engine(64, &io_context, "overflow"); + std::vector inputs; + for (uint64_t key = 1; key <= 4; ++key) + { + inputs.push_back(MakeInput(key, key * 20.0, 10.0, 0.1f, 0, 50, false)); + } + inputs.push_back(MakeInput(20, 0.0, 10.0)); + inputs.push_back(MakeInput(21, 90.0, 10.0)); + for (int frame = 0; frame < 30; ++frame) + { + const FrameView& view = engine.Step(inputs, passthrough); + EXPECT(io_context, view.overBudget, "frame %d: four passthrough inputs with maxOutputs 2 not flagged", frame); + EXPECT(io_context, view.clusters.size() == 1, "frame %d: clusterable inputs did not share one cluster", frame); + } + } +} + +bool IsAlone(const FrameView& in_view, size_t in_input) +{ + int count = 0; + uint32_t cluster = 0; + for (const ClusterContribution& contribution : in_view.contributions) + { + if (contribution.input == in_input) + { + ++count; + cluster = contribution.cluster; + } + } + return count == 1 && in_view.clusters[cluster].memberCount == 1; +} + +void TestExcludedInputsAlone(TestContext& io_context) +{ + const Scenario scenario = LoadScenario("nearfield"); + const int allowed = static_cast(scenario.params.dwellFrames + 2 * scenario.params.crossfadeFrames) + 2; + std::unordered_map excludedSince; + std::unordered_map excludedFromStart; + std::unordered_set seen; + int excludedChecks = 0; + PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { + for (size_t index = 0; index < in_view.inputs.size(); ++index) + { + const ClusterInput& input = in_view.inputs[index]; + const bool firstFrame = seen.insert(input.key).second; + if (!IsExcludedInput(input, in_params)) + { + excludedSince.erase(input.key); + excludedFromStart[input.key] = false; + continue; + } + if (excludedSince.count(input.key) == 0) + { + excludedSince[input.key] = in_frame; + if (firstFrame) + { + excludedFromStart[input.key] = true; + } + } + const bool fromStart = excludedFromStart[input.key]; + if (fromStart || in_frame - excludedSince[input.key] > allowed) + { + ++excludedChecks; + EXPECT(io_context, IsAlone(in_view, index), "nearfield frame %d: excluded key %llx shares its cluster", in_frame, + static_cast(input.key)); + } + } + }); + EXPECT(io_context, excludedChecks > 1000, "nearfield: only %d excluded checks ran", excludedChecks); + + ClusterParams params; + params.excludePriorityAbove = 80; + TestEngine engine(64, &io_context, "priority-exclude"); + std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 0.3, 10.0, 0.1f, 0, 95), + MakeInput(3, -0.3, 10.0), MakeInput(4, 0.1, 10.0, 0.1f, 0, 81)}; + for (int frame = 0; frame < 40; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, IsAlone(view, 1) && IsAlone(view, 3), "frame %d: high priority inputs share a cluster", frame); + EXPECT(io_context, view.clusters.size() == 3, "frame %d: expected 3 clusters, got %zu", frame, view.clusters.size()); + } + + params.maxOutputs = 2; + for (int frame = 0; frame < 40; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, IsAlone(view, 1) && IsAlone(view, 3), "frame %d: high priority inputs share under a budget", frame); + EXPECT(io_context, view.overBudget, "frame %d: two excluded inputs plus one cluster with maxOutputs 2 not flagged", frame); + } +} + +void TestGroupsNeverShare(TestContext& io_context) +{ + const Scenario scenario = LoadScenario("groups"); + PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams&) { + std::vector groups(in_view.clusters.size(), -1); + for (const ClusterContribution& contribution : in_view.contributions) + { + const long long group = in_view.inputs[contribution.input].group; + long long& clusterGroup = groups[contribution.cluster]; + EXPECT(io_context, clusterGroup < 0 || clusterGroup == group, "groups frame %d: cluster %u mixes groups", in_frame, + in_view.clusters[contribution.cluster].id); + clusterGroup = group; + } + }); + + ClusterParams params; + params.joinAngle = 180.f; + params.maxOutputs = 1; + TestEngine engine(64, &io_context, "groups-wide"); + std::vector inputs = {MakeInput(1, 0.0, 10.0, 0.1f, 1), MakeInput(2, 0.0, 10.0, 0.1f, 2), + MakeInput(3, 1.0, 10.0, 0.1f, 1), MakeInput(4, 1.0, 10.0, 0.1f, 2)}; + for (int frame = 0; frame < 20; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, view.clusters.size() == 2, "frame %d: two groups with joinAngle 180 gave %zu clusters", frame, + view.clusters.size()); + EXPECT(io_context, view.overBudget, "frame %d: two groups with maxOutputs 1 not flagged", frame); + } +} + +void TestNoAllocationsInUpdate(TestContext& io_context) +{ + const char* names[] = {"oneshots", "crossing", "nearfield", "squeeze", "cloud", "passthrough", "stress"}; + for (const char* name : names) + { + const Scenario scenario = LoadScenario(name); + uint32_t peak = 0; + { + ScenarioPlayer dryRun(scenario, 1, false); + for (int frame = 0; frame < scenario.frames; ++frame) + { + peak = std::max(peak, static_cast(dryRun.NextFrame().size())); + } + } + CountingAllocator allocator; + { + ClusterEngine engine; + EXPECT(io_context, engine.Init(&allocator, peak), "%s: Init failed", name); + const uint64_t afterInit = allocator.GetAllocCount(); + EXPECT(io_context, afterInit == 1, "%s: Init made %llu allocations", name, static_cast(afterInit)); + ScenarioPlayer player(scenario, 5, true); + for (int frame = 0; frame < scenario.frames; ++frame) + { + const std::vector& inputs = player.NextFrame(); + engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); + if (allocator.GetAllocCount() != afterInit) + { + io_context.Fail("%s frame %d: Update allocated with %zu inputs and %u reserved", name, frame, inputs.size(), + peak); + break; + } + } + + std::vector grown; + for (uint32_t index = 0; index <= peak; ++index) + { + grown.push_back(MakeInput(1000000 + index, index * 0.7, 10.0 + index)); + } + InvariantChecker checker; + FrameView view; + for (int frame = 0; frame < 5; ++frame) + { + const bool updated = engine.Update(grown.data(), static_cast(grown.size()), scenario.params); + EXPECT(io_context, updated, "%s: Update failed to grow", name); + CaptureFrame(engine, grown, updated, view); + checker.Check(io_context, name, frame, view, scenario.params); + } + EXPECT(io_context, allocator.GetAllocCount() == afterInit + 1 && allocator.GetOutstandingBlocks() == 1, + "%s: growing did not replace the block once (allocs %llu, blocks %zu)", name, + static_cast(allocator.GetAllocCount()), allocator.GetOutstandingBlocks()); + engine.Term(); + } + EXPECT(io_context, allocator.GetOutstandingBytes() == 0 && allocator.GetBadFreeCount() == 0, + "%s: %zu bytes outstanding after Term", name, allocator.GetOutstandingBytes()); + } +} + +void TestAllocationFailureKeepsState(TestContext& io_context) +{ + const ClusterParams params; + std::vector small; + for (uint64_t key = 1; key <= 8; ++key) + { + small.push_back(MakeInput(key, key * 2.0, 10.0)); + } + std::vector large = small; + for (uint64_t key = 100; key < 120; ++key) + { + large.push_back(MakeInput(key, key * 3.0, 20.0)); + } + + TestEngine failing(8); + TestEngine reference(8); + for (int frame = 0; frame < 5; ++frame) + { + failing.Step(small, params); + reference.Step(small, params); + } + FrameView before; + CaptureFrame(failing.GetEngine(), small, true, before); + failing.GetAllocator().SetFailing(true); + const bool updated = failing.GetEngine().Update(large.data(), static_cast(large.size()), params); + EXPECT(io_context, !updated, "Update succeeded although the allocator failed"); + FrameView after; + CaptureFrame(failing.GetEngine(), small, true, after); + EXPECT(io_context, MakeCanonical(before) == MakeCanonical(after), "failed Update changed the reported frame"); + failing.GetAllocator().SetFailing(false); + + for (int frame = 0; frame < 20; ++frame) + { + const FrameView& first = failing.Step(small, params); + const CanonicalFrame left = MakeCanonical(first); + const FrameView& second = reference.Step(small, params); + EXPECT(io_context, left == MakeCanonical(second), "frame %d after a failed Update differs from the reference", frame); + } + EXPECT(io_context, failing.GetEngine().Update(large.data(), static_cast(large.size()), params), + "Update failed after the allocator recovered"); +} + +void TestResetClearsState(TestContext& io_context) +{ + const Scenario warmup = LoadScenario("oneshots"); + const Scenario after = LoadScenario("crossing"); + TestEngine resetEngine(128); + TestEngine freshEngine(128); + { + ScenarioPlayer player(warmup, 3, true); + for (int frame = 0; frame < 300; ++frame) + { + resetEngine.Step(player.NextFrame(), player.GetParams()); + } + } + const uint64_t allocationsBefore = resetEngine.GetAllocator().GetAllocCount(); + resetEngine.GetEngine().Reset(); + EXPECT(io_context, resetEngine.GetEngine().GetClusterCount() == 0 && resetEngine.GetEngine().GetContributionCount() == 0, + "Reset kept reported clusters"); + EXPECT(io_context, resetEngine.GetAllocator().GetOutstandingBlocks() == 1, "Reset freed memory"); + + ScenarioPlayer leftPlayer(after, 8, true); + ScenarioPlayer rightPlayer(after, 8, true); + for (int frame = 0; frame < 300; ++frame) + { + const FrameView& left = resetEngine.Step(leftPlayer.NextFrame(), leftPlayer.GetParams()); + if (frame == 0) + { + for (const ClusterInfo& cluster : left.clusters) + { + EXPECT(io_context, cluster.isNew, "cluster %u not new on the first frame after Reset", cluster.id); + } + } + const CanonicalFrame leftCanonical = MakeCanonical(left); + const FrameView& right = freshEngine.Step(rightPlayer.NextFrame(), rightPlayer.GetParams()); + if (!(leftCanonical == MakeCanonical(right))) + { + io_context.Fail("frame %d after Reset differs from a fresh engine", frame); + break; + } + } + EXPECT(io_context, resetEngine.GetAllocator().GetAllocCount() == allocationsBefore, "Reset or later updates allocated"); +} + +void TestTermBalance(TestContext& io_context) +{ + const Scenario scenario = LoadScenario("oneshots"); + { + CountingAllocator allocator; + ClusterEngine engine; + engine.Init(&allocator, 256); + ScenarioPlayer player(scenario, 1, false); + for (int frame = 0; frame < 100; ++frame) + { + const std::vector& inputs = player.NextFrame(); + engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); + } + engine.Term(); + EXPECT(io_context, allocator.GetOutstandingBlocks() == 0 && allocator.GetOutstandingBytes() == 0, + "Term left %zu blocks", allocator.GetOutstandingBlocks()); + engine.Term(); + EXPECT(io_context, allocator.GetBadFreeCount() == 0, "second Term freed twice"); + } + { + CountingAllocator allocator; + ClusterEngine engine; + engine.Init(&allocator, 32); + engine.Init(&allocator, 64); + EXPECT(io_context, allocator.GetOutstandingBlocks() == 1, "second Init kept %zu blocks", allocator.GetOutstandingBlocks()); + engine.Term(); + EXPECT(io_context, allocator.GetOutstandingBlocks() == 0, "Term after a second Init left memory"); + } + { + CountingAllocator allocator; + { + ClusterEngine engine; + engine.Init(&allocator, 0); + ScenarioPlayer player(scenario, 1, false); + for (int frame = 0; frame < 100; ++frame) + { + const std::vector& inputs = player.NextFrame(); + EXPECT(io_context, engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()), + "Update failed to grow from an empty reserve"); + } + EXPECT(io_context, allocator.GetOutstandingBlocks() == 1, "growing kept %zu blocks", allocator.GetOutstandingBlocks()); + } + EXPECT(io_context, allocator.GetOutstandingBlocks() == 0 && allocator.GetBadFreeCount() == 0, + "destructor left %zu blocks", allocator.GetOutstandingBlocks()); + } + { + ClusterEngine engine; + engine.Term(); + engine.Reset(); + const ClusterInput input = MakeInput(1, 0.0, 1.0); + EXPECT(io_context, !engine.Update(&input, 1, ClusterParams{}), "Update without Init succeeded"); + EXPECT(io_context, engine.Update(nullptr, 0, ClusterParams{}), "empty Update without Init failed"); + EXPECT(io_context, engine.GetClusterCount() == 0 && engine.GetContributionCount() == 0, "engine without Init reports"); + EXPECT(io_context, !engine.Init(nullptr, 4), "Init without allocator succeeded"); + } +} + +void TestDegenerateInputs(TestContext& io_context) +{ + const ClusterParams params; + { + ClusterParams wide = params; + wide.joinAngle = 180.f; + wide.maxOutputs = 1; + TestEngine engine; + InvariantChecker checker; + const std::vector inputs = {MakeRawInput(1, 0.f, 0.f, 10.f), MakeRawInput(2, 0.f, 0.f, -10.f)}; + for (int frame = 0; frame < 10; ++frame) + { + const FrameView& view = engine.Step(inputs, wide); + checker.Check(io_context, "opposite-budget", frame, view, wide); + EXPECT(io_context, view.clusters.size() == 1, "opposite inputs with maxOutputs 1 gave %zu clusters", view.clusters.size()); + } + wide.maxOutputs = 0; + TestEngine unlimited; + for (int frame = 0; frame < 10; ++frame) + { + const FrameView& view = unlimited.Step(inputs, wide); + EXPECT(io_context, view.clusters.size() == 2, "opposite inputs with joinAngle 180 gave %zu clusters", + view.clusters.size()); + } + } + { + const float nan = std::numeric_limits::quiet_NaN(); + const float inf = std::numeric_limits::infinity(); + std::vector inputs = {MakeRawInput(1, nan, 0.f, 1.f), MakeRawInput(2, inf, 0.f, 0.f), + MakeRawInput(3, 0.f, -inf, 0.f), MakeRawInput(4, 0.f, 0.f, 10.f), + MakeRawInput(5, 0.1f, 0.f, 10.f), MakeRawInput(6, 0.f, 0.f, 0.f), + MakeRawInput(0, 1e30f, 0.f, 0.f), MakeRawInput(~0ull, FLT_MAX, FLT_MAX, FLT_MAX), + MakeRawInput(7, -FLT_MAX, 0.f, 1.f, nan), MakeRawInput(8, 1e-30f, 0.f, 0.f, -1.f), + MakeRawInput(9, 0.f, 1.f, 0.f, inf), MakeRawInput(10, 0.f, 1.f, 1e-3f, 1e30f)}; + TestEngine engine; + InvariantChecker checker; + for (int frame = 0; frame < 20; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + checker.Check(io_context, "non-finite", frame, view, params); + for (size_t index : {size_t(0), size_t(1), size_t(2)}) + { + const std::vector memberships = FindMemberships(view, inputs[index].key); + EXPECT(io_context, memberships.size() == 1, "non-finite input %zu has %zu memberships", index, memberships.size()); + if (memberships.size() == 1) + { + const ClusterInfo& cluster = view.clusters[static_cast(memberships[0].cluster)]; + EXPECT(io_context, cluster.memberCount == 1, "non-finite input %zu shares a cluster", index); + EXPECT(io_context, cluster.position.x == 0.f && cluster.position.y == 0.f && cluster.position.z == 0.f, + "non-finite input %zu not at the origin", index); + } + } + EXPECT(io_context, HomeIdOf(view, 4) == HomeIdOf(view, 5), "finite neighbours did not share a cluster"); + EXPECT(io_context, HomeIdOf(view, 6) >= 0 && HomeIdOf(view, 6) == HomeIdOf(view, 8), + "inputs at and next to the listener position did not share a cluster"); + } + inputs[0] = MakeRawInput(1, 0.f, 0.f, 10.f); + bool joined = false; + for (int frame = 20; frame < 60; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + checker.Check(io_context, "recovered", frame, view, params); + joined = joined || (HomeIdOf(view, 1) == HomeIdOf(view, 4)); + } + EXPECT(io_context, joined, "input with a recovered position never joined its neighbours"); + } + { + ClusterParams broken = params; + broken.joinAngle = std::numeric_limits::quiet_NaN(); + broken.leaveRatio = std::numeric_limits::quiet_NaN(); + broken.distanceWeight = std::numeric_limits::infinity(); + broken.nearFieldRadius = std::numeric_limits::quiet_NaN(); + broken.loudnessSmoothing = 5.f; + Scenario scenario = LoadScenario("cloud"); + scenario.params = broken; + InvariantChecker checker; + RunOptions options; + options.maxFrames = 200; + PlayScenario(scenario, options, [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { + checker.Check(io_context, "bad-params", in_frame, in_view, in_params); + }); + broken.joinAngle = 1e30f; + broken.leaveRatio = -3.f; + broken.distanceWeight = -1.f; + broken.nearFieldRadius = std::numeric_limits::infinity(); + scenario.params = broken; + InvariantChecker secondChecker; + PlayScenario(scenario, options, [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { + secondChecker.Check(io_context, "extreme-params", in_frame, in_view, in_params); + EXPECT(io_context, in_view.clusters.size() == in_view.inputs.size(), + "infinite near field did not exclude every input (%zu clusters, %zu inputs)", in_view.clusters.size(), + in_view.inputs.size()); + }); + } + { + TestEngine engine; + InvariantChecker checker; + const std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(1, 30.0, 10.0), MakeInput(2, 0.2, 10.0), + MakeInput(2, 60.0, 10.0)}; + for (int frame = 0; frame < 10; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + checker.Check(io_context, "duplicates", frame, view, params); + EXPECT(io_context, view.clusters.size() == 1, "duplicate keys gave %zu clusters", view.clusters.size()); + } + } + { + TestEngine engine; + const FrameView& empty = engine.Step({}, params); + EXPECT(io_context, empty.updated && empty.clusters.empty() && empty.contributions.empty(), "empty update reported output"); + EXPECT(io_context, !engine.GetEngine().Update(nullptr, 3, params), "Update with null inputs succeeded"); + engine.Step({MakeInput(1, 0.0, 10.0), MakeInput(2, 1.0, 10.0)}, params); + const FrameView& cleared = engine.Step({}, params); + EXPECT(io_context, cleared.clusters.empty(), "clusters survived an empty update"); + } +} + +void TestCrossfadeRamp(TestContext& io_context) +{ + for (uint32_t fade : {4u, 1u, 0u}) + { + ClusterParams params; + params.dwellFrames = 4; + params.crossfadeFrames = fade; + TestEngine engine(64, &io_context, "crossfade"); + std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 0.3, 10.0), MakeInput(3, -0.3, 10.0)}; + for (int frame = 0; frame < 10; ++frame) + { + engine.Step(inputs, params); + } + const int oldId = HomeIdOf(engine.Step(inputs, params), 3); + inputs[2] = MakeInput(3, 20.0, 10.0); + const int moveFrame = static_cast(params.dwellFrames) - 1; + const uint32_t steps = std::max(fade, 1u); + for (int frame = 0; frame < moveFrame; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + const std::vector memberships = FindMemberships(view, 3); + EXPECT(io_context, memberships.size() == 1 && static_cast(memberships[0].id) == oldId, + "fade %u: input moved %d frames after leaving, before the dwell time", fade, frame); + } + int newId = -1; + for (uint32_t step = 0; step <= steps; ++step) + { + const FrameView& view = engine.Step(inputs, params); + const std::vector memberships = FindMemberships(view, 3); + if (step == steps) + { + EXPECT(io_context, memberships.size() == 1 && static_cast(memberships[0].id) == newId && + memberships[0].gainStart == 1.f && memberships[0].gainEnd == 1.f, + "fade %u: input did not settle in the new cluster after %u frames", fade, steps); + continue; + } + EXPECT(io_context, memberships.size() == 2, "fade %u step %u: %zu memberships while fading", fade, step, + memberships.size()); + if (memberships.size() != 2) + { + break; + } + const Membership& source = static_cast(memberships[0].id) == oldId ? memberships[0] : memberships[1]; + const Membership& target = static_cast(memberships[0].id) == oldId ? memberships[1] : memberships[0]; + if (step == 0) + { + newId = static_cast(target.id); + EXPECT(io_context, view.clusters[static_cast(target.cluster)].isNew, "fade %u: target cluster not new", + fade); + } + const float expectedStart = static_cast(step) / steps; + const float expectedEnd = static_cast(step + 1) / steps; + EXPECT(io_context, std::fabs(target.gainStart - expectedStart) < 1e-6f && std::fabs(target.gainEnd - expectedEnd) < 1e-6f, + "fade %u step %u: target gains %f %f, expected %f %f", fade, step, target.gainStart, target.gainEnd, + expectedStart, expectedEnd); + EXPECT(io_context, std::fabs(source.gainStart - (1.f - expectedStart)) < 1e-6f && + std::fabs(source.gainEnd - (1.f - expectedEnd)) < 1e-6f, + "fade %u step %u: source gains %f %f", fade, step, source.gainStart, source.gainEnd); + } + } +} + +void TestMergeAfterDwell(TestContext& io_context) +{ + ClusterParams narrow; + narrow.joinAngle = 3.f; + narrow.dwellFrames = 6; + narrow.crossfadeFrames = 2; + ClusterParams wide = narrow; + wide.joinAngle = 6.f; + TestEngine engine(64, &io_context, "merge"); + const std::vector inputs = {MakeInput(1, 0.0, 10.0, 0.5f), MakeInput(2, 0.2, 10.0, 0.5f), + MakeInput(3, 4.5, 10.0, 0.05f), MakeInput(4, 4.7, 10.0, 0.05f)}; + for (int frame = 0; frame < 10; ++frame) + { + const FrameView& view = engine.Step(inputs, narrow); + EXPECT(io_context, view.clusters.size() == 2, "frame %d: expected two clusters with joinAngle 3", frame); + } + const FrameView& settled = engine.Step(inputs, narrow); + const int heavyId = HomeIdOf(settled, 1); + const int lightId = HomeIdOf(settled, 3); + for (int frame = 0; frame < static_cast(wide.dwellFrames) - 1; ++frame) + { + const FrameView& view = engine.Step(inputs, wide); + EXPECT(io_context, HomeIdOf(view, 3) == lightId, "merge started after %d frames, before the dwell time", frame + 1); + } + const FrameView& merging = engine.Step(inputs, wide); + EXPECT(io_context, HomeIdOf(merging, 3) == heavyId && HomeIdOf(merging, 4) == heavyId, + "lighter cluster did not start merging into the heavier one after the dwell time"); + EXPECT(io_context, HomeIdOf(merging, 1) == heavyId, "heavier cluster members moved"); + for (int frame = 0; frame < 3; ++frame) + { + engine.Step(inputs, wide); + } + const FrameView& merged = engine.Step(inputs, wide); + EXPECT(io_context, merged.clusters.size() == 1 && static_cast(merged.clusters[0].id) == heavyId, + "after the merge %zu clusters remain", merged.clusters.size()); +} + +void TestMergeWaitsForFadingMembers(TestContext& io_context) +{ + ClusterParams params; + params.joinAngle = 4.f; + params.dwellFrames = 2; + params.crossfadeFrames = 10; + TestEngine engine(64, &io_context, "merge-fading"); + std::vector inputs = {MakeInput(1, 0.0, 10.0, 0.5f), MakeInput(2, 0.2, 10.0, 0.5f), + MakeInput(3, 8.0, 10.0, 0.05f), MakeInput(4, 8.2, 10.0, 0.05f), + MakeInput(5, 0.1, 10.0, 0.01f)}; + for (int frame = 0; frame < 10; ++frame) + { + engine.Step(inputs, params); + } + const FrameView& settled = engine.Step(inputs, params); + const int heavyId = HomeIdOf(settled, 1); + const int lightId = HomeIdOf(settled, 3); + EXPECT(io_context, heavyId != lightId && HomeIdOf(settled, 5) == heavyId, + "setup did not give two clusters with key 5 in the heavy one"); + + inputs[4] = MakeInput(5, 8.1, 10.0, 0.01f); + bool moving = false; + for (int frame = 0; frame < 10 && !moving; ++frame) + { + moving = HomeIdOf(engine.Step(inputs, params), 5) == lightId; + } + EXPECT(io_context, moving, "key 5 never started moving into the light cluster"); + + ClusterParams wide = params; + wide.joinAngle = 9.f; + bool mergedWhileFading = false; + bool followed = false; + for (int frame = 0; frame < 40; ++frame) + { + const FrameView& view = engine.Step(inputs, wide); + const std::vector memberships = FindMemberships(view, 5); + const int home = HomeIdOf(view, 5); + if (HomeIdOf(view, 3) == heavyId && home == lightId) + { + mergedWhileFading = true; + } + if (!followed && home == heavyId) + { + followed = true; + for (const Membership& membership : memberships) + { + EXPECT(io_context, static_cast(membership.id) != lightId || membership.gainStart == 1.f, + "key 5 left the dissolving cluster before its fade into it finished"); + } + } + } + EXPECT(io_context, mergedWhileFading, "the clusters did not merge while key 5 was still fading in"); + EXPECT(io_context, followed, "key 5 never followed the merge"); + const FrameView& merged = engine.Step(inputs, wide); + EXPECT(io_context, merged.clusters.size() == 1 && HomeIdOf(merged, 5) == heavyId, + "after following, %zu clusters remain", merged.clusters.size()); +} + +void TestGroupChangeCrossfades(TestContext& io_context) +{ + const ClusterParams params; + TestEngine engine(64, &io_context, "group-change"); + std::vector inputs = {MakeInput(1, 0.0, 10.0, 0.1f, 1), MakeInput(2, 0.5, 10.0, 0.1f, 1), + MakeInput(3, -0.5, 10.0, 0.1f, 1)}; + for (int frame = 0; frame < 10; ++frame) + { + engine.Step(inputs, params); + } + const int oldId = HomeIdOf(engine.Step(inputs, params), 3); + inputs[2].group = 2; + const uint32_t steps = std::max(params.crossfadeFrames, 1u); + for (uint32_t step = 0; step < steps; ++step) + { + const FrameView& view = engine.Step(inputs, params); + const std::vector memberships = FindMemberships(view, 3); + EXPECT(io_context, memberships.size() == 2, "step %u: input that changed group has %zu memberships, expected a crossfade", + step, memberships.size()); + if (memberships.size() != 2) + { + break; + } + const Membership& source = static_cast(memberships[0].id) == oldId ? memberships[0] : memberships[1]; + const Membership& target = static_cast(memberships[0].id) == oldId ? memberships[1] : memberships[0]; + const float expectedStart = static_cast(step) / steps; + const float expectedEnd = static_cast(step + 1) / steps; + EXPECT(io_context, static_cast(source.id) == oldId && static_cast(target.id) != oldId, + "step %u: input that changed group does not fade out of its old cluster", step); + EXPECT(io_context, std::fabs(target.gainStart - expectedStart) < 1e-6f && std::fabs(target.gainEnd - expectedEnd) < 1e-6f, + "step %u: target gains %f %f, expected %f %f", step, target.gainStart, target.gainEnd, expectedStart, expectedEnd); + } + int aloneId = -1; + for (int frame = 0; frame < 10; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + const std::vector memberships = FindMemberships(view, 3); + EXPECT(io_context, memberships.size() == 1 && memberships[0].gainStart == 1.f && memberships[0].gainEnd == 1.f, + "frame %d: input that changed group is not settled at full gain", frame); + EXPECT(io_context, view.clusters.size() == 2, "frame %d: expected two clusters after the group change", frame); + EXPECT(io_context, HomeIdOf(view, 3) != HomeIdOf(view, 1), "frame %d: changed input still shares", frame); + aloneId = HomeIdOf(view, 3); + } + + inputs[2].group = 5; + for (int frame = 0; frame < 5; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + const std::vector memberships = FindMemberships(view, 3); + EXPECT(io_context, memberships.size() == 1 && static_cast(memberships[0].id) == aloneId && + memberships[0].gainStart == 1.f && memberships[0].gainEnd == 1.f, + "frame %d: input alone in its cluster moved when its group changed", frame); + } + + inputs[1].clusterable = false; + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, FindMemberships(view, 2).empty(), "input that became passthrough still has a contribution"); + + ClusterParams slow; + slow.dwellFrames = 2; + slow.crossfadeFrames = 20; + TestEngine fading(64, &io_context, "group-change-mid-fade"); + inputs = {MakeInput(1, 0.0, 10.0, 0.5f), MakeInput(2, 0.5, 10.0, 0.5f), MakeInput(3, -0.5, 10.0, 0.1f), + MakeInput(10, 30.0, 10.0, 0.5f), MakeInput(20, 60.0, 10.0, 0.5f)}; + for (int frame = 0; frame < 10; ++frame) + { + fading.Step(inputs, slow); + } + inputs[2] = MakeInput(3, 29.0, 10.0, 0.1f); + bool fadeStarted = false; + for (int frame = 0; frame < 5; ++frame) + { + fadeStarted = fadeStarted || FindMemberships(fading.Step(inputs, slow), 3).size() == 2; + } + EXPECT(io_context, fadeStarted, "setup did not start a fade of key 3"); + inputs[2].group = 9; + for (int frame = 0; frame < 60; ++frame) + { + fading.Step(inputs, slow); + } + const FrameView& settled = fading.Step(inputs, slow); + const std::vector memberships = FindMemberships(settled, 3); + EXPECT(io_context, memberships.size() == 1 && memberships[0].gainEnd == 1.f && + settled.clusters[static_cast(memberships[0].cluster)].memberCount == 1, + "input whose group changed during a fade did not end alone in its own cluster"); +} + +void TestCentredInputsShare(TestContext& io_context) +{ + for (float offset : {0.f, 1e-5f}) + { + std::vector inputs; + for (uint64_t key = 1; key <= 40; ++key) + { + inputs.push_back(MakeRawInput(key, 0.f, 0.f, offset)); + } + ClusterParams params; + TestEngine unlimited(64, &io_context, "centred"); + for (int frame = 0; frame < 30; ++frame) + { + const FrameView& view = unlimited.Step(inputs, params); + EXPECT(io_context, view.clusters.size() == 1, "offset %g frame %d: 40 inputs at the listener gave %zu clusters", + offset, frame, view.clusters.size()); + } + params.maxOutputs = 8; + TestEngine budget(64, &io_context, "centred-budget"); + for (int frame = 0; frame < 30; ++frame) + { + const FrameView& view = budget.Step(inputs, params); + EXPECT(io_context, !view.overBudget && view.clusters.size() <= 8, + "offset %g frame %d: 40 inputs at the listener with maxOutputs 8 gave %zu clusters, over budget %d", offset, + frame, view.clusters.size(), view.overBudget ? 1 : 0); + } + params.joinAngle = 0.f; + TestEngine noJoin(64, &io_context, "centred-join0"); + for (int frame = 0; frame < 30; ++frame) + { + const FrameView& view = noJoin.Step(inputs, params); + EXPECT(io_context, !view.overBudget && view.clusters.size() <= 8, + "offset %g frame %d: 40 inputs at the listener with joinAngle 0 and maxOutputs 8 gave %zu clusters", offset, + frame, view.clusters.size()); + } + ClusterParams nearField; + nearField.nearFieldRadius = 1.f; + TestEngine excluded(64, &io_context, "centred-near-field"); + for (int frame = 0; frame < 5; ++frame) + { + const FrameView& view = excluded.Step(inputs, nearField); + EXPECT(io_context, view.clusters.size() == 40, + "offset %g frame %d: 40 inputs inside the near field gave %zu clusters", offset, frame, view.clusters.size()); + } + } + + ClusterParams wide; + wide.joinAngle = 180.f; + TestEngine mixed(64, &io_context, "centred-mixed"); + std::vector inputs = {MakeRawInput(1, 0.f, 0.f, 0.f), MakeRawInput(2, 0.f, 0.f, 0.f), MakeInput(3, 0.0, 10.0), + MakeInput(4, 90.0, 10.0)}; + for (int frame = 0; frame < 30; ++frame) + { + const FrameView& view = mixed.Step(inputs, wide); + EXPECT(io_context, view.clusters.size() == 2 && HomeIdOf(view, 1) == HomeIdOf(view, 2) && + HomeIdOf(view, 3) == HomeIdOf(view, 4) && HomeIdOf(view, 1) != HomeIdOf(view, 3), + "frame %d: inputs at the listener shared a cluster with inputs that have a direction", frame); + } + inputs[1] = MakeInput(2, 45.0, 10.0); + for (int frame = 0; frame < 30; ++frame) + { + mixed.Step(inputs, wide); + } + const FrameView& moved = mixed.Step(inputs, wide); + EXPECT(io_context, HomeIdOf(moved, 2) == HomeIdOf(moved, 3) && HomeIdOf(moved, 1) != HomeIdOf(moved, 3), + "input that moved away from the listener did not join the inputs with a direction"); +} + +void TestBudgetFarInputGetsOwnCluster(TestContext& io_context) +{ + ClusterParams params; + params.maxOutputs = 3; + TestEngine engine(64, &io_context, "budget-far"); + std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 6.0, 10.0), MakeInput(3, 12.0, 10.0)}; + for (int frame = 0; frame < 30; ++frame) + { + engine.Step(inputs, params); + } + EXPECT(io_context, engine.Step(inputs, params).clusters.size() == 3, "setup did not give three clusters"); + inputs.push_back(MakeInput(4, 180.0, 10.0)); + double worst = 0.0; + for (int frame = 0; frame < 600; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, !view.overBudget, "frame %d: flagged over budget although two clusters can merge", frame); + const std::vector far = FindMemberships(view, 4); + EXPECT(io_context, far.size() == 1 && view.clusters[static_cast(far[0].cluster)].memberCount == 1, + "frame %d: input at 180 deg does not have a cluster of its own", frame); + EXPECT(io_context, HomeIdOf(view, 1) == HomeIdOf(view, 2) && HomeIdOf(view, 3) != HomeIdOf(view, 1), + "frame %d: the clusters at 0 and 6 deg did not merge", frame); + EXPECT(io_context, frame < static_cast(params.crossfadeFrames) || view.clusters.size() == 3, + "frame %d: %zu clusters with maxOutputs 3", frame, view.clusters.size()); + const std::vector home = FindHomeClusters(view); + for (size_t index = 0; index < view.inputs.size(); ++index) + { + if (home[index] >= 0) + { + worst = std::max(worst, AngleBetween(view.inputs[index].position, + view.clusters[static_cast(home[index])].position)); + } + } + } + EXPECT(io_context, worst <= 12.05, "largest angle between an input and its cluster %.3f deg, above 12 deg", worst); +} + +void TestOverBudgetOnlyWhenUnmet(TestContext& io_context) +{ + ClusterParams params; + params.maxOutputs = 2; + TestEngine engine(64, &io_context, "budget-flag"); + std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 90.0, 10.0)}; + for (int frame = 0; frame < 20; ++frame) + { + engine.Step(inputs, params); + } + inputs.push_back(MakeInput(3, 45.0, 10.0, 0.1f, 7)); + for (int frame = 0; frame < 10; ++frame) + { + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, !view.overBudget, "frame %d: flagged over budget although the group 0 clusters can merge", frame); + EXPECT(io_context, frame < static_cast(params.crossfadeFrames) || view.clusters.size() == 2, + "frame %d: %zu clusters with maxOutputs 2", frame, view.clusters.size()); + } +} + +void TestLoudnessWeighting(TestContext& io_context) +{ + const double loudAngle = -2.0; + const double quietAngle = 2.0; + auto expectedAngle = [&](double in_loudWeight, double in_quietWeight) { + const double loud = loudAngle * kPi / 180.0; + const double quiet = quietAngle * kPi / 180.0; + const double x = in_loudWeight * std::sin(loud) + in_quietWeight * std::sin(quiet); + const double z = in_loudWeight * std::cos(loud) + in_quietWeight * std::cos(quiet); + return std::atan2(x, z) * kDegreesPerRadian; + }; + auto clusterAngle = [](const FrameView& in_view) { + const ClusterVec3& position = in_view.clusters[0].position; + return std::atan2(static_cast(position.x), static_cast(position.z)) * kDegreesPerRadian; + }; + + ClusterParams params; + std::vector inputs = {MakeInput(1, loudAngle, 10.0, 1.0f), MakeInput(2, quietAngle, 10.0, 0.001f)}; + { + TestEngine engine(64, &io_context, "loudness"); + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, view.clusters.size() == 1, "two inputs 4 deg apart gave %zu clusters", view.clusters.size()); + const double expected = expectedAngle(1.0 + 1e-6, 0.001 + 1e-6); + EXPECT(io_context, std::fabs(clusterAngle(view) - expected) < 0.01, "weighted angle %f, expected %f", + clusterAngle(view), expected); + EXPECT(io_context, view.clusters[0].representative == 0, "representative is not the loudest input"); + } + { + params.loudnessWeighting = false; + TestEngine engine(64, &io_context, "loudness"); + const FrameView& view = engine.Step(inputs, params); + EXPECT(io_context, std::fabs(clusterAngle(view)) < 0.01, "unweighted angle %f, expected 0", clusterAngle(view)); + } + { + params.loudnessWeighting = true; + params.loudnessSmoothing = 0.9f; + TestEngine engine(64, &io_context, "loudness"); + for (int frame = 0; frame < 5; ++frame) + { + engine.Step(inputs, params); + } + inputs[1].loudness = 1.0f; + const FrameView& view = engine.Step(inputs, params); + const double smoothed = 0.9 * 0.001 + 0.1 * 1.0; + const double expected = expectedAngle(1.0 + 1e-6, smoothed + 1e-6); + EXPECT(io_context, std::fabs(clusterAngle(view) - expected) < 0.01, "smoothed angle %f, expected %f", clusterAngle(view), + expected); + + TestEngine fresh(64, &io_context, "loudness-fresh"); + const std::vector single = {MakeInput(1, loudAngle, 10.0, 1.0f)}; + for (int frame = 0; frame < 5; ++frame) + { + fresh.Step(single, params); + } + const std::vector loudPair = {MakeInput(1, loudAngle, 10.0, 1.0f), MakeInput(2, quietAngle, 10.0, 1.0f)}; + const FrameView& first = fresh.Step(loudPair, params); + EXPECT(io_context, first.clusters.size() == 1 && std::fabs(clusterAngle(first)) < 0.01, + "first frame of a new input did not use its raw loudness (angle %f)", clusterAngle(first)); + } +} + +/** @brief Test with the name used to select it. */ +struct TestCase +{ + const char* name; ///< Name of the test. + void (*function)(TestContext&); ///< Function that runs the test. +}; + +const TestCase kTests[] = { + {"invariants_all_scenarios", TestInvariants}, + {"invariants_parameter_sweep", TestInvariantsParameterSweep}, + {"order_independence", TestOrderIndependence}, + {"static_scene_settles", TestStaticSettles}, + {"jitter_makes_no_moves", TestJitterMakesNoMoves}, + {"joins_within_join_angle", TestJoinsWithinJoinAngle}, + {"leave_within_dwell_and_fade", TestLeaveBound}, + {"budget_respected", TestBudgetRespected}, + {"budget_overflow_flagged", TestBudgetOverflowFlagged}, + {"over_budget_only_when_unmet", TestOverBudgetOnlyWhenUnmet}, + {"budget_far_input_alone", TestBudgetFarInputGetsOwnCluster}, + {"excluded_inputs_alone", TestExcludedInputsAlone}, + {"centred_inputs_share", TestCentredInputsShare}, + {"groups_never_share", TestGroupsNeverShare}, + {"no_allocations_in_update", TestNoAllocationsInUpdate}, + {"allocation_failure_keeps_state", TestAllocationFailureKeepsState}, + {"reset_clears_state", TestResetClearsState}, + {"term_returns_allocator_balance", TestTermBalance}, + {"degenerate_inputs", TestDegenerateInputs}, + {"crossfade_ramp", TestCrossfadeRamp}, + {"merge_after_dwell", TestMergeAfterDwell}, + {"merge_waits_for_fading_members", TestMergeWaitsForFadingMembers}, + {"group_change_crossfades", TestGroupChangeCrossfades}, + {"loudness_weighting", TestLoudnessWeighting}, +}; +} + +int RunTests(const std::vector& in_args) +{ + const std::string filter = in_args.empty() ? std::string() : in_args[0]; + int failedTests = 0; + int ranTests = 0; + for (const TestCase& test : kTests) + { + if (!filter.empty() && std::string(test.name).find(filter) == std::string::npos) + { + continue; + } + ++ranTests; + TestContext context(test.name); + const auto start = std::chrono::steady_clock::now(); + test.function(context); + const double seconds = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + if (context.GetFailures() == 0) + { + std::printf("PASS %-32s %7.2f s\n", test.name, seconds); + } + else + { + std::printf("FAIL %-32s %7.2f s (%d failed checks)\n", test.name, seconds, context.GetFailures()); + ++failedTests; + } + } + std::printf("%d of %d tests passed\n", ranTests - failedTests, ranTests); + return failedTests == 0 && ranTests > 0 ? 0 : 1; +} From 27212c8f0f2381f21c2aae8da10508ff93c4fbd3 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Sat, 10 Oct 2026 11:39:50 +0000 Subject: [PATCH 03/26] rewrite ObjectClusterFX around ClusterEngine - mono 3D inputs are clustered, multichannel and partly 2D inputs pass through untouched - cluster outputs are mono and mixed with the engine's gain ramps, outputs are created in one batch and fetched once per frame - clusters carry the highest member priority, a spread from their angular size and the loudest member's custom metadata - new parameters: join angle, leave ratio, dwell time, crossfade time, max outputs, near-field radius, distance weight, exclude priority above, spread from size and loudness weighting - the parameter block is validated, and the plugin posts Capture Log messages for a missing object bus, failed output creation, memory and the output budget - the authoring plugin writes every parameter and uses the Wwise 2025 macro names - remove SpatialClusterer, Utilities and KMeansTest --- SoundEnginePlugin/KMeansTest.h | 177 --- SoundEnginePlugin/ObjectClusterFX.cpp | 1125 +++++++++++------- SoundEnginePlugin/ObjectClusterFX.h | 323 +++-- SoundEnginePlugin/ObjectClusterFXParams.cpp | 145 ++- SoundEnginePlugin/ObjectClusterFXParams.h | 70 +- SoundEnginePlugin/ObjectClusterFXShared.cpp | 2 +- SoundEnginePlugin/SpatialClustering.cpp | 410 ------- SoundEnginePlugin/SpatialClustering.h | 270 ----- SoundEnginePlugin/Utilities.cpp | 126 -- SoundEnginePlugin/Utilities.h | 92 -- WwisePlugin/ObjectCluster.xml | 128 +- WwisePlugin/ObjectClusterPlugin.cpp | 24 +- WwisePlugin/ObjectClusterPlugin.h | 7 +- WwisePlugin/Win32/ObjectClusterPluginGUI.cpp | 2 +- bundle_template.json | 2 +- 15 files changed, 1224 insertions(+), 1679 deletions(-) delete mode 100644 SoundEnginePlugin/KMeansTest.h delete mode 100644 SoundEnginePlugin/SpatialClustering.cpp delete mode 100644 SoundEnginePlugin/SpatialClustering.h delete mode 100644 SoundEnginePlugin/Utilities.cpp delete mode 100644 SoundEnginePlugin/Utilities.h 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 index 535a555..8aff317 100644 --- a/SoundEnginePlugin/ObjectClusterFX.cpp +++ b/SoundEnginePlugin/ObjectClusterFX.cpp @@ -32,7 +32,13 @@ trademarks of CCP ehf. #include "ObjectClusterFX.h" #include "../ObjectClusterConfig.h" + #include +#include +#include +#include + +#include AK::IAkPlugin* CreateObjectClusterFX(AK::IAkPluginMemAlloc* in_pAllocator) { @@ -46,53 +52,286 @@ AK::IAkPluginParam* CreateObjectClusterFXParams(AK::IAkPluginMemAlloc* in_pAlloc AK_IMPLEMENT_PLUGIN_FACTORY(ObjectClusterFX, AkPluginTypeEffect, ObjectClusterConfig::CompanyID, ObjectClusterConfig::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[] = "ObjectCluster 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[] = "ObjectCluster 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[] = "ObjectCluster could not get the hash table service from the sound engine."; + + /** @brief Message posted when output objects cannot be created. */ + const char kCreateFailedMessage[] = "ObjectCluster could not create output objects, so some inputs are silent."; + + /** @brief Message posted when memory runs out. */ + const char kOutOfMemoryMessage[] = "ObjectCluster ran out of memory, so some audio may drop out."; + + /** @brief Message posted when the inputs that are left cannot be merged down to Max Outputs. */ + const char kOverBudgetMessage[] = "ObjectCluster needs more outputs than Max Outputs 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 Returns true for mono 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; + return in_buffer.NumChannels() == 1 + && (spatMode == AK_SpatializationMode_PositionOnly || spatMode == AK_SpatializationMode_PositionAndOrientation) + && behavioral.panSpatMix >= 1.f; + } + + /** @brief Returns the RMS level of the first channel over the valid frames. */ + AkReal32 ComputeRms(AkAudioBuffer& in_buffer) + { + const AkUInt32 uFrames = in_buffer.uValidFrames; + if (uFrames == 0) + return 0.f; + + const AkSampleType* pSamples = in_buffer.GetChannel(0); + AkReal32 fSum = 0.f; + for (AkUInt32 i = 0; i < uFrames; ++i) + fSum += pSamples[i] * pSamples[i]; + + 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 ObjectClusterAllocator::Init(AK::IAkPluginMemAlloc* in_pAllocator) +{ + m_pAllocator = in_pAllocator; +} + +void* ObjectClusterAllocator::Alloc(size_t in_size) +{ + if (m_pAllocator == nullptr) + return nullptr; + + return AK_PLUGIN_ALLOC(m_pAllocator, in_size); +} + +void ObjectClusterAllocator::Free(void* in_pBlock) +{ + if (m_pAllocator != nullptr && in_pBlock != nullptr) + AK_PLUGIN_FREE(m_pAllocator, in_pBlock); +} ObjectClusterFX::ObjectClusterFX() : m_pParams(nullptr) , m_pAllocator(nullptr) , m_pContext(nullptr) - , m_clusterer(std::make_unique()) - , m_utilities(std::make_unique()) + , m_pPriorityService(nullptr) + , m_pHashTableService(nullptr) + , m_bEngineInitialized(false) + , m_fSpreadFromSize(1.f) + , m_uSampleRate(0) + , m_uParamsMaxFrames(0) + , m_uMessageInterval(1) + , m_uFrame(0) + , m_uNumOutputs(0) + , m_aMessageFrames{} + , m_aMessagePosted{} { } ObjectClusterFX::~ObjectClusterFX() { - FreeAllVolumes(); } -AKRESULT ObjectClusterFX::Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffectPluginContext* in_pContext, AK::IAkPluginParam* in_pParams, AkAudioFormat& in_rFormat) +AKRESULT ObjectClusterFX::Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffectPluginContext* in_pContext, AK::IAkPluginParam* in_pParams, AkAudioFormat& io_rFormat) { - m_pParams = (ObjectClusterFXParams*)in_pParams; - m_mapInObjsToOutObjs.Init(in_pAllocator); - m_pContext = in_pContext; + 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(); - in_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; + } - // 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); + 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 ObjectClusterFX::Term(AK::IAkPluginMemAlloc* in_pAllocator) { - FreeAllVolumes(); - + ReleaseMemory(); AK_PLUGIN_DELETE(in_pAllocator, this); return AK_Success; } AKRESULT ObjectClusterFX::Reset() { - FreeAllVolumes(); - m_clusters.clear(); - m_tempBuffers.clear(); - m_tempObjects.clear(); + 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; } @@ -102,481 +341,559 @@ AKRESULT ObjectClusterFX::GetPluginInfo(AkPluginInfo& out_rPluginInfo) 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); +void ObjectClusterFX::Execute(const AkAudioObjects& in_objects, const AkAudioObjects& out_objects) +{ + ++m_uFrame; - PrepareAudioObjects(inObjects); - ProcessAudioObjects(inObjects); - UpdateClusterPositions(inObjects); -} + const AkUInt32 uExistingOutputs = out_objects.uNumObjects; + const AkUInt16 uMaxFrames = in_objects.uNumObjects > 0 + ? in_objects.ppObjectBuffers[0]->MaxFrames() + : m_pContext->GlobalContext()->GetMaxBufferLength(); + UpdateClusterParams(uMaxFrames); -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; - } + 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; } - // 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 - } - } - } - } + 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; } - // 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; - } + if (!BuildOutputIndex()) + { + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); + SilenceOutputs(); + return; } - m_tempBuffers.clear(); - m_tempObjects.clear(); + m_priorityObjects.RemoveAll(); + m_priorityValues.RemoveAll(); + + ProcessPassthroughs(in_objects); + ProcessClusters(in_objects); + ReleaseUnwrittenOutputs(); + ApplyPriorities(); + RemoveStaleEntries(); } -void ObjectClusterFX::ProcessAudioObjects(const AkAudioObjects& inObjects) +void ObjectClusterFX::ReleaseMemory() { - if (inObjects.uNumObjects == 0) { + 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 ObjectClusterFX::UpdateClusterParams(AkUInt16 in_uMaxFrames) +{ + if (in_uMaxFrames == m_uParamsMaxFrames && !m_pParams->m_paramChangeHandler.HasAnyChanged()) return; + + m_uParamsMaxFrames = in_uMaxFrames; + + const ObjectClusterRTPCParams& 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; + + m_clusterParams.joinAngle = Clamp(rtpc.joinAngle, 0.f, PARAM_JOIN_ANGLE_MAX); + m_clusterParams.leaveRatio = Clamp(rtpc.leaveRatio, PARAM_LEAVE_RATIO_MIN, PARAM_LEAVE_RATIO_MAX); + m_clusterParams.dwellFrames = MillisecondsToFrames(Clamp(rtpc.dwellTime, 0.f, PARAM_DWELL_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.maxOutputs, 0, PARAM_MAX_OUTPUTS_MAX)); + m_clusterParams.nearFieldRadius = Clamp(rtpc.nearFieldRadius, 0.f, PARAM_NEAR_FIELD_RADIUS_MAX); + m_clusterParams.distanceWeight = Clamp(rtpc.distanceWeight, 0.f, 1.f); + m_clusterParams.excludePriorityAbove = static_cast(Clamp(rtpc.excludePriorityAbove, 0, PARAM_PRIORITY_MAX)); + m_clusterParams.loudnessWeighting = m_pParams->NonRTPC.loudnessWeighting; + m_clusterParams.loudnessSmoothing = std::exp(-fFrameSeconds / kLoudnessTimeConstant); + m_fSpreadFromSize = Clamp(rtpc.spreadFromSize, 0.f, 1.f); + + const AkUInt32 uMessageInterval = static_cast(kMessageIntervalSeconds / fFrameSeconds); + m_uMessageInterval = uMessageInterval > 0 ? uMessageInterval : 1; + + m_pParams->m_paramChangeHandler.ResetAllParamChanges(); +} + +bool ObjectClusterFX::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; } - 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); - } - } + const bool bLoudnessWeighting = m_clusterParams.loudnessWeighting; + 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.loudness = (bLoudnessWeighting && buffer.NumChannels() == 1) ? ComputeRms(buffer) : 0.f; + input.priority = static_cast(Clamp(static_cast(m_inputPriorities[i]), 0, PARAM_PRIORITY_MAX)); + input.group = HashCustomMetadata(object); + input.clusterable = IsClusterable(object, buffer); } - m_tempBuffers.clear(); - m_tempObjects.clear(); + return true; } -void ObjectClusterFX::ProcessClusteredObject( - const AkAudioObject* inObj, - AkAudioBuffer* inBuf, - AkAudioObject* outObj, - AkAudioBuffer* outBuf, - GeneratedObject* userData, - const ClusterState& clusterState) +AkUInt32 ObjectClusterFX::CreateOutputs(const AkAudioObjects& in_objects) { + m_requests.RemoveAll(); + bool bOutOfMemory = false; - if (inBuf->uValidFrames > 0) { - MixToCluster(inObj, inBuf, outBuf, inObj->cumulativeGain, userData); - } + for (AkUInt32 i = 0; i < in_objects.uNumObjects; ++i) + { + if (m_inputs[i].clusterable) + continue; - // Set clustered object's name - std::string objName = "Cluster" + std::to_string(userData->outputObjKey); - outObj->SetName(m_pAllocator, objName.c_str()); + 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); + } - // Update buffer state - bool allInputsDone = (clusterState.activeInputCount == 0); - bool hasValidFrames = (clusterState.maxFrames > 0); + if (!AddRequest(uChannelConfig, i, false)) + bOutOfMemory = true; + } - outBuf->eState = (allInputsDone && !hasValidFrames) ? AK_NoMoreData : AK_DataReady; - outBuf->uValidFrames = hasValidFrames ? clusterState.maxFrames : 0; -} + AkChannelConfig monoConfig; + monoConfig.SetStandard(AK_SPEAKER_SETUP_MONO); + const AkUInt32 uMonoConfig = monoConfig.Serialize(); -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"); -} + 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); + } -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; + if (!AddRequest(uMonoConfig, c, true)) + bOutOfMemory = true; } - AkUInt32 uTransmixSize = AK::SpeakerVolumes::Matrix::GetRequiredSize(inBuffer->NumChannels(), outBuffer->NumChannels()); + if (bOutOfMemory) + PostRateLimitedMessage(MessageKind_OutOfMemory, kOutOfMemoryMessage, AK::Monitor::ErrorLevel_Error); - AK::SpeakerVolumes::MatrixPtr currentVolumes = (AK::SpeakerVolumes::MatrixPtr)AkAllocaSIMD(uTransmixSize); - AK::SpeakerVolumes::Matrix::Zero(currentVolumes, inBuffer->NumChannels(), outBuffer->NumChannels()); + AkUInt32 uCreated = 0; + const AkUInt32 uNumRequests = m_requests.Length(); + for (AkUInt32 r = 0; r < uNumRequests; ++r) + { + if (m_requests[r].done) + continue; - AKRESULT result = m_pContext->GetMixerCtx()->ComputePositioning( - inObject->positioning, - inBuffer->GetChannelConfig(), - outBuffer->GetChannelConfig(), - currentVolumes - ); + 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); + } - // 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); + 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; } - AK_GET_PLUGIN_SERVICE_MIXER(m_pContext->GlobalContext())->MixNinNChannels( - inBuffer, - outBuffer, - cumulativeGain.fPrev, - cumulativeGain.fNext, - pGeneratedObject->volumeMatrix, - currentVolumes - ); + return uCreated; +} - AKPLATFORM::AkMemCpy(pGeneratedObject->volumeMatrix, currentVolumes, uTransmixSize); +bool ObjectClusterFX::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 ObjectClusterFX::FeedPositionsToClusterer(const AkAudioObjects& inObjects) +void ObjectClusterFX::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; - if (m_lastDistanceThreshold != m_pParams->RTPC.distanceThreshold) { - m_clusterer->setDistanceThreshold(m_pParams->RTPC.distanceThreshold); - m_lastDistanceThreshold = m_pParams->RTPC.distanceThreshold; + char szName[32]; + FormatClusterName(info.id, szName); + in_pObject->SetName(m_pAllocator, szName); + return; } - std::vector objectPositions; - objectPositions.reserve(inObjects.uNumObjects); + 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; + } - for (AkUInt32 i = 0; i < inObjects.uNumObjects; ++i) { - AkAudioObject* inobj = inObjects.ppObjects[i]; + pEntry->outputKey = in_pObject->key; + pEntry->channelConfig = in_request.channelConfig; + pEntry->lastFrame = m_uFrame - 1; - // 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); + const char* szInputName = input.objectName.Get(); + in_pObject->SetName(m_pAllocator, (szInputName != nullptr && szInputName[0] != '\0') ? szInputName : kPassthroughName); +} - 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); +bool ObjectClusterFX::FetchOutputs(AkUInt32 in_uExpected) +{ + m_uNumOutputs = 0; - auto tempClusters = m_clusterer->getClusters(); - m_clusters.reserve(tempClusters.size()); + AkUInt32 uCapacity = in_uExpected + 1; + if (!m_outputObjects.Resize(uCapacity) || !m_outputBuffers.Resize(uCapacity)) + return false; - for (const auto& pair : tempClusters) { - m_clusters.emplace_back(pair.first, pair.second); - } + 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; } -AKRESULT ObjectClusterFX::AllocateVolumes(AK::SpeakerVolumes::MatrixPtr& volumeMatrix, - AkUInt32 in_uNumChannelsIn, - AkUInt32 in_uNumChannelsOut) +bool ObjectClusterFX::BuildOutputIndex() { - AkUInt32 size = AK::SpeakerVolumes::Matrix::GetRequiredSize(in_uNumChannelsIn, in_uNumChannelsOut); - float* matrix = static_cast(m_pAllocator->Malloc(size, __FILE__, __LINE__)); + m_pHashTableService->Reset(&m_outputIndices); - if (!matrix) { - return AK_InsufficientMemory; + 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; } - volumeMatrix = matrix; - return AK_Success; + return true; } -const std::pair>* ObjectClusterFX::GetCluster(AkAudioObjectID objectId) const +AkUInt32 ObjectClusterFX::FindOutput(AkAudioObjectID in_key) const { - for (const auto& cluster : m_clusters) { - for (const auto& id : cluster.second) { - if (id == objectId) { - return &cluster; - } - } - } - return nullptr; + const AkInt32 iSlot = FindSlot(m_outputIndices, in_key); + return iSlot >= 0 ? m_outputIndices.ValueAt(static_cast(iSlot)) : kInvalidIndex; } -void ObjectClusterFX::FreeVolume(AK::SpeakerVolumes::MatrixPtr& volumeMatrix) +void ObjectClusterFX::ProcessPassthroughs(const AkAudioObjects& in_objects) { - if (volumeMatrix) { - m_pAllocator->Free(volumeMatrix); - volumeMatrix = nullptr; + 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 ObjectClusterFX::FreeAllVolumes() +void ObjectClusterFX::ProcessClusters(const AkAudioObjects& in_objects) { - auto it = m_mapInObjsToOutObjs.Begin(); - while (it != m_mapInObjsToOutObjs.End()) { - if ((*it).pUserData && (*it).pUserData->volumeMatrix) { - FreeVolume((*it).pUserData->volumeMatrix); - } - ++it; + 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; } -} -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); - } - } + 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_fSpreadFromSize * Clamp(info.angularRadius / kFullSpreadRadius, 0.f, 1.f); + threeD.spread = AkMax(pRepresentative->positioning.threeD.spread, fSizeSpread); } - ++it; + + 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()); + MixRamp(pInBuffer->GetChannel(0), pOutBuffer->GetChannel(0), uFrames, contribution.gainStart, contribution.gainEnd); + 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; } } -AKRESULT ObjectClusterFX::FindBestCluster(const AkVector& position, const AkAudioObjects& existingOutputs, AkAudioObjectID& outClusterKey) +void ObjectClusterFX::ReleaseUnwrittenOutputs() { - 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) + for (AkUInt32 i = 0; i < m_uNumOutputs; ++i) { - const AkAudioObject* outObj = existingOutputs.ppObjects[i]; - if (!outObj) continue; + if (m_outputWritten[i] != 0) + continue; - const AkVector outObjPos = outObj->positioning.threeD.xform.Position(); + 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))); - // 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; - } + pBuffer->uValidFrames = 0; + pBuffer->eState = AK_NoMoreData; } +} + +void ObjectClusterFX::QueuePriority(AkAudioObject* in_pObject, AkPriority in_priority) +{ + if (m_pPriorityService == nullptr) + return; + + if (m_priorityObjects.AddLast(in_pObject) == nullptr) + return; - return (outClusterKey != AK_INVALID_AUDIO_OBJECT_ID) ? AK_Success : AK_Fail; + if (m_priorityValues.AddLast(in_priority) == nullptr) + m_priorityObjects.RemoveLast(); } -std::unordered_map ObjectClusterFX::ReadClusterStates(const AkAudioObjects& inObjects) +void ObjectClusterFX::ApplyPriorities() { - 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); - } - } - } + const AkUInt32 uCount = m_priorityObjects.Length(); + if (m_pPriorityService == nullptr || uCount == 0) + return; - return clusterStates; + m_pPriorityService->SetPriorities(m_priorityObjects.Data(), uCount, m_priorityValues.Data()); } -AkAudioObjects ObjectClusterFX::GetCurrentOutputObjects() +void ObjectClusterFX::RemoveStaleEntries() { - AkAudioObjects outputObjects{}; - outputObjects.uNumObjects = 0; - outputObjects.ppObjectBuffers = nullptr; - outputObjects.ppObjects = nullptr; + const AkUInt32 uFrame = m_uFrame; - if (!m_pContext) { - return outputObjects; - } + m_pHashTableService->RemoveIf( + static_cast*>(&m_passthroughs), + [this, uFrame](AkUInt32 in_uSlot) { return m_passthroughs.ValueAt(in_uSlot).lastFrame != uFrame; }); - // First call to get count - m_pContext->GetOutputObjects(outputObjects); + m_pHashTableService->RemoveIf( + static_cast*>(&m_clusters), + [this, uFrame](AkUInt32 in_uSlot) { return m_clusters.ValueAt(in_uSlot).lastFrame != uFrame; }); +} - if (outputObjects.uNumObjects > 0) { - try { - m_tempBuffers.resize(outputObjects.uNumObjects); - m_tempObjects.resize(outputObjects.uNumObjects); +void ObjectClusterFX::KeepOutputsSilent(AkUInt32 in_uExpected) +{ + if (FetchOutputs(in_uExpected)) + SilenceOutputs(); +} - outputObjects.ppObjectBuffers = m_tempBuffers.data(); - outputObjects.ppObjects = m_tempObjects.data(); +void ObjectClusterFX::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))); - // 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; - } + pBuffer->uValidFrames = pBuffer->MaxFrames(); + pBuffer->eState = AK_DataReady; } +} + +void ObjectClusterFX::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; - return outputObjects; + m_aMessagePosted[in_kind] = true; + m_aMessageFrames[in_kind] = m_uFrame; + m_pContext->PostMonitorMessage(in_szMessage, in_level); } diff --git a/SoundEnginePlugin/ObjectClusterFX.h b/SoundEnginePlugin/ObjectClusterFX.h index ae28d94..3394e14 100644 --- a/SoundEnginePlugin/ObjectClusterFX.h +++ b/SoundEnginePlugin/ObjectClusterFX.h @@ -33,33 +33,29 @@ trademarks of CCP ehf. #ifndef ObjectClusterFX_H #define ObjectClusterFX_H +#include "ClusterEngine.h" #include "ObjectClusterFXParams.h" + #include -#include -#include -#include -#include -#include "SpatialClustering.h" -#include "Utilities.h" +#include +#include +#include -/** - * @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; -}; +/** @brief Cluster engine allocator that allocates through the Wwise plug-in allocator. */ +class ObjectClusterAllocator final : public ClusterAllocator +{ +public: + /** @brief Sets the Wwise plug-in allocator to allocate through. */ + void Init(AK::IAkPluginMemAlloc* in_pAllocator); -/** - * @struct ClusterState - * @brief Maintains the state of an audio object cluster - */ -struct ClusterState { - AkUInt32 activeInputCount = 0; - AkUInt16 maxFrames = 0; + /** @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. }; /** @@ -67,10 +63,14 @@ struct ClusterState { * @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 +class ObjectClusterFX + : public AK::IAkOutOfPlaceObjectPlugin { public: + /** @brief Creates the effect. */ ObjectClusterFX(); + + /** @brief Destroys the effect. */ ~ObjectClusterFX(); /** @@ -78,10 +78,10 @@ class ObjectClusterFX : public AK::IAkOutOfPlaceObjectPlugin * @param in_pAllocator Memory allocator interface * @param in_pContext Plugin context interface * @param in_pParams Plugin parameters - * @param in_rFormat Audio format + * @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& in_rFormat) override; + 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 @@ -105,145 +105,142 @@ class ObjectClusterFX : public AK::IAkOutOfPlaceObjectPlugin /** * @brief Executes the audio processing - * @param inObjects Input audio objects and buffers - * @param outObjects Output audio objects and buffers + * @param in_objects Input audio objects and buffers + * @param out_objects Output audio objects and buffers */ - void Execute(const AkAudioObjects& inObjects, const AkAudioObjects& outObjects) override; + void Execute(const AkAudioObjects& in_objects, const AkAudioObjects& out_objects) 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); + /** @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; + + ObjectClusterFXParams* 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. + + ObjectClusterAllocator 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_fSpreadFromSize; ///< 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 // ObjectClusterFX_H \ No newline at end of file +#endif // ObjectClusterFX_H diff --git a/SoundEnginePlugin/ObjectClusterFXParams.cpp b/SoundEnginePlugin/ObjectClusterFXParams.cpp index 753ac8a..e320bdf 100644 --- a/SoundEnginePlugin/ObjectClusterFXParams.cpp +++ b/SoundEnginePlugin/ObjectClusterFXParams.cpp @@ -27,9 +27,57 @@ the specific language governing permissions and limitations under the License. #include "ObjectClusterFXParams.h" #include +#include + +#include + +namespace +{ + /** @brief Default values of the parameters that RTPCs can drive. */ + const ObjectClusterRTPCParams kDefaultRTPCParams = { 5.f, 1.5f, 200.f, 50.f, 0, 0.f, 0.f, 100, 1.f }; + + /** @brief Default value of the loudness weighting parameter. */ + const bool kDefaultLoudnessWeighting = true; + + /** @brief Returns true if every real parameter is a finite number. */ + bool AreFinite(const ObjectClusterRTPCParams& in_params) + { + return std::isfinite(in_params.joinAngle) + && std::isfinite(in_params.leaveRatio) + && std::isfinite(in_params.dwellTime) + && std::isfinite(in_params.crossfadeTime) + && std::isfinite(in_params.nearFieldRadius) + && std::isfinite(in_params.distanceWeight) + && std::isfinite(in_params.spreadFromSize); + } + + /** @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); + } +} ObjectClusterFXParams::ObjectClusterFXParams() + : RTPC(kDefaultRTPCParams) { + NonRTPC.loudnessWeighting = kDefaultLoudnessWeighting; } ObjectClusterFXParams::~ObjectClusterFXParams() @@ -50,11 +98,13 @@ AK::IAkPluginParam* ObjectClusterFXParams::Clone(AK::IAkPluginMemAlloc* in_pAllo AKRESULT ObjectClusterFXParams::Init(AK::IAkPluginMemAlloc* in_pAllocator, const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) { + (void)in_pAllocator; + if (in_ulBlockSize == 0) { // Initialize default parameters here - RTPC.distanceThreshold = 200.f; - + RTPC = kDefaultRTPCParams; + NonRTPC.loudnessWeighting = kDefaultLoudnessWeighting; m_paramChangeHandler.SetAllParamChanges(); return AK_Success; } @@ -70,12 +120,30 @@ AKRESULT ObjectClusterFXParams::Term(AK::IAkPluginMemAlloc* in_pAllocator) AKRESULT ObjectClusterFXParams::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; - RTPC.distanceThreshold = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - + ObjectClusterRTPCParams rtpc; + rtpc.joinAngle = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.leaveRatio = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.dwellTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.crossfadeTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.maxOutputs = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); + rtpc.nearFieldRadius = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.distanceWeight = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.excludePriorityAbove = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); + rtpc.spreadFromSize = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + const AkUInt8 loudnessWeighting = READBANKDATA(AkUInt8, pParamsBlock, in_ulBlockSize); CHECKBANKDATASIZE(in_ulBlockSize, eResult); + + if (!AreFinite(rtpc)) + return AK_InvalidParameter; + + RTPC = rtpc; + NonRTPC.loudnessWeighting = loudnessWeighting != 0; m_paramChangeHandler.SetAllParamChanges(); return eResult; @@ -83,19 +151,74 @@ AKRESULT ObjectClusterFXParams::SetParamsBlock(const void* in_pParamsBlock, AkUI AKRESULT ObjectClusterFXParams::SetParam(AkPluginParamID in_paramID, const void* in_pValue, AkUInt32 in_ulParamSize) { - AKRESULT eResult = AK_Success; + AkReal32 value = 0.f; // Handle parameter change here switch (in_paramID) { - case DISTANCE_THRESHOLD: - RTPC.distanceThreshold = *((AkReal32*)in_pValue); - m_paramChangeHandler.SetParamChange(DISTANCE_THRESHOLD); + case PARAM_JOIN_ANGLE_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.joinAngle = value; break; - default: - eResult = AK_InvalidParameter; + case PARAM_LEAVE_RATIO_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.leaveRatio = value; + break; + case PARAM_DWELL_TIME_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.dwellTime = value; + break; + case PARAM_CROSSFADE_TIME_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.crossfadeTime = value; + break; + case PARAM_MAX_OUTPUTS_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.maxOutputs = RoundToRange(value, static_cast(PARAM_MAX_OUTPUTS_MAX)); break; + case PARAM_NEAR_FIELD_RADIUS_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.nearFieldRadius = value; + break; + case PARAM_DISTANCE_WEIGHT_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.distanceWeight = value; + break; + case PARAM_EXCLUDE_PRIORITY_ABOVE_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.excludePriorityAbove = RoundToRange(value, static_cast(PARAM_PRIORITY_MAX)); + break; + case PARAM_SPREAD_FROM_SIZE_ID: + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + RTPC.spreadFromSize = value; + break; + case PARAM_LOUDNESS_WEIGHTING_ID: + if (in_pValue == nullptr || in_ulParamSize == 0) + return AK_InvalidParameter; + if (in_ulParamSize == sizeof(AkReal32)) + { + if (!ReadReal(in_pValue, in_ulParamSize, value)) + return AK_InvalidParameter; + NonRTPC.loudnessWeighting = value != 0.f; + } + else + { + NonRTPC.loudnessWeighting = *static_cast(in_pValue) != 0; + } + break; + default: + return AK_InvalidParameter; } - return eResult; + m_paramChangeHandler.SetParamChange(in_paramID); + return AK_Success; } diff --git a/SoundEnginePlugin/ObjectClusterFXParams.h b/SoundEnginePlugin/ObjectClusterFXParams.h index f7356d9..1d397c1 100644 --- a/SoundEnginePlugin/ObjectClusterFXParams.h +++ b/SoundEnginePlugin/ObjectClusterFXParams.h @@ -32,24 +32,80 @@ the specific language governing permissions and limitations under the License. // 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; - +/// ID of the join angle parameter. +static const AkPluginParamID PARAM_JOIN_ANGLE_ID = 0; +/// ID of the leave ratio parameter. +static const AkPluginParamID PARAM_LEAVE_RATIO_ID = 1; +/// ID of the dwell time parameter. +static const AkPluginParamID PARAM_DWELL_TIME_ID = 2; +/// ID of the crossfade time parameter. +static const AkPluginParamID PARAM_CROSSFADE_TIME_ID = 3; +/// ID of the maximum number of outputs parameter. +static const AkPluginParamID PARAM_MAX_OUTPUTS_ID = 4; +/// ID of the near-field radius parameter. +static const AkPluginParamID PARAM_NEAR_FIELD_RADIUS_ID = 5; +/// ID of the distance weight parameter. +static const AkPluginParamID PARAM_DISTANCE_WEIGHT_ID = 6; +/// ID of the parameter that sets the priority above which inputs are never merged. +static const AkPluginParamID PARAM_EXCLUDE_PRIORITY_ABOVE_ID = 7; +/// ID of the spread from cluster size parameter. +static const AkPluginParamID PARAM_SPREAD_FROM_SIZE_ID = 8; +/// ID of the loudness weighting parameter. +static const AkPluginParamID PARAM_LOUDNESS_WEIGHTING_ID = 9; +/// Number of parameters. +static const AkUInt32 NUM_PARAMS = 10; + +/// Largest join angle in degrees. +static const AkReal32 PARAM_JOIN_ANGLE_MAX = 180.f; +/// Smallest leave ratio. +static const AkReal32 PARAM_LEAVE_RATIO_MIN = 1.f; +/// Largest leave ratio. +static const AkReal32 PARAM_LEAVE_RATIO_MAX = 4.f; +/// Largest dwell time in milliseconds. +static const AkReal32 PARAM_DWELL_TIME_MAX = 5000.f; +/// Largest crossfade time in milliseconds. +static const AkReal32 PARAM_CROSSFADE_TIME_MAX = 1000.f; +/// Largest maximum number of outputs. +static const AkInt32 PARAM_MAX_OUTPUTS_MAX = 512; +/// Largest near-field radius in game units. +static const AkReal32 PARAM_NEAR_FIELD_RADIUS_MAX = 1000000.f; +/// Largest playback priority, which is also the largest Exclude Priority Above 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 = 7 * sizeof(AkReal32) + 2 * sizeof(AkInt32) + sizeof(AkUInt8); + +/** @brief Parameters that RTPCs can drive. */ struct ObjectClusterRTPCParams { - AkReal32 distanceThreshold; + AkReal32 joinAngle; ///< Angle in degrees under which an input may join a cluster. + AkReal32 leaveRatio; ///< Leave angle as a multiple of the join angle. + AkReal32 dwellTime; ///< 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. + AkInt32 maxOutputs; ///< Maximum number of outputs, passthrough inputs included; 0 means no limit. + AkReal32 nearFieldRadius; ///< Inputs closer to the listener than this distance in game units are never merged. + AkReal32 distanceWeight; ///< How much a difference in distance counts against the join angle. + AkInt32 excludePriorityAbove; ///< Inputs with a higher priority are never merged. + AkReal32 spreadFromSize; ///< Share of the cluster's angular size used as its minimum spread. }; +/** @brief Parameters that RTPCs cannot drive. */ struct ObjectClusterNonRTPCParams { + bool loudnessWeighting; ///< Weights cluster positions by input loudness. }; +/** @brief Parameter node of the ObjectCluster effect. */ struct ObjectClusterFXParams : public AK::IAkPluginParam { + /** @brief Creates a parameter node with default values. */ ObjectClusterFXParams(); + + /** @brief Creates a copy of another parameter node. */ ObjectClusterFXParams(const ObjectClusterFXParams& in_rParams); + /** @brief Destroys the parameter node. */ ~ObjectClusterFXParams(); /// Create a duplicate of the parameter node instance in its current state. @@ -68,10 +124,10 @@ struct ObjectClusterFXParams /// 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; + AK::AkFXParameterChangeHandler m_paramChangeHandler; ///< Tracks which parameters changed since the effect last read them. - ObjectClusterRTPCParams RTPC; - ObjectClusterNonRTPCParams NonRTPC; + ObjectClusterRTPCParams RTPC; ///< Parameters that RTPCs can drive. + ObjectClusterNonRTPCParams NonRTPC; ///< Parameters that RTPCs cannot drive. }; #endif // ObjectClusterFXParams_H diff --git a/SoundEnginePlugin/ObjectClusterFXShared.cpp b/SoundEnginePlugin/ObjectClusterFXShared.cpp index 14a9acb..6dd030e 100644 --- a/SoundEnginePlugin/ObjectClusterFXShared.cpp +++ b/SoundEnginePlugin/ObjectClusterFXShared.cpp @@ -29,5 +29,5 @@ the specific language governing permissions and limitations under the License. #include -DEFINEDUMMYASSERTHOOK; +DEFINE_PLUGIN_ASSERT_HOOK; DEFINE_PLUGIN_REGISTER_HOOK; 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.xml b/WwisePlugin/ObjectCluster.xml index 4c4359f..b27ed4d 100644 --- a/WwisePlugin/ObjectCluster.xml +++ b/WwisePlugin/ObjectCluster.xml @@ -34,26 +34,144 @@ for the documentation about the plugin xml format true - true - true + false + false - - - 200 + + + + 5 0 + + + + 0 + 180 + + + + + + + + 1.5 + 1 1 + 4 + + + + + + + + 200 + 2 + + + + 0 + 5000 + + + + + + + + 50 + 3 + + + + 0 1000 + + + + 0 + 4 + + + + 0 + 512 + + + + + + + + 0 + 5 + + + + 0 + 1000000 + + + + + + + + 0 + 6 + + + + 0 + 1 + + + + + + + + 100 + 7 + + + + 0 + 100 + + + + + + + + 1 + 8 + + + + 0 + 1 + + + + + + + true + 9 + diff --git a/WwisePlugin/ObjectClusterPlugin.cpp b/WwisePlugin/ObjectClusterPlugin.cpp index 829aed7..ce2677e 100644 --- a/WwisePlugin/ObjectClusterPlugin.cpp +++ b/WwisePlugin/ObjectClusterPlugin.cpp @@ -27,7 +27,6 @@ the specific language governing permissions and limitations under the License. #include "ObjectClusterPlugin.h" #include "../SoundEnginePlugin/ObjectClusterFXFactory.h" - ObjectClusterPlugin::ObjectClusterPlugin() { } @@ -36,21 +35,28 @@ ObjectClusterPlugin::~ObjectClusterPlugin() { } -bool ObjectClusterPlugin::GetBankParameters(const GUID & in_guidPlatform, AK::Wwise::Plugin::DataWriter& in_dataWriter) const +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; + return in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:joinAngle")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:leaveRatio")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:dwellTime")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:crossfadeTime")) + && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:maxOutputs")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:nearFieldRadius")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:distanceWeight")) + && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:excludePriorityAbove")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:spreadFromSize")) + && in_dataWriter.WriteBool(m_propertySet.GetBool(in_guidPlatform, "CCP:loudnessWeighting")); } -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 +AK_DEFINE_PLUGIN_CONTAINER(ObjectCluster); // Create a PluginContainer structure that contains the info for our plugin +AK_EXPORT_PLUGIN_CONTAINER(ObjectCluster); // This is a DLL, we want to have a standardized name +AK_ADD_PLUGIN_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) +DEFINE_PLUGIN_ASSERT_HOOK; // Placeholder assert hook for Wwise plug-ins using AKASSERT (cassert used by default) diff --git a/WwisePlugin/ObjectClusterPlugin.h b/WwisePlugin/ObjectClusterPlugin.h index 14047a0..28ac1d0 100644 --- a/WwisePlugin/ObjectClusterPlugin.h +++ b/WwisePlugin/ObjectClusterPlugin.h @@ -34,13 +34,16 @@ class ObjectClusterPlugin final : public AK::Wwise::Plugin::AudioPlugin { public: + /** @brief Creates the authoring plug-in. */ ObjectClusterPlugin(); + + /** @brief Destroys the authoring plug-in. */ ~ObjectClusterPlugin(); /// 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(ObjectCluster); // Exposes our PluginContainer structure that contains the info for our plugin diff --git a/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp b/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp index ccf0149..c892476 100644 --- a/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp +++ b/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp @@ -30,7 +30,7 @@ ObjectClusterPluginGUI::ObjectClusterPluginGUI() { } -ADD_AUDIOPLUGIN_CLASS_TO_CONTAINER( +AK_ADD_PLUGIN_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 diff --git a/bundle_template.json b/bundle_template.json index d4de9cb..3d3cac0 100644 --- a/bundle_template.json +++ b/bundle_template.json @@ -11,7 +11,7 @@ "tag": "ObjectCluster", // Description displayed in the Wwise Launcher for this bundle - "description": "groups audio objects based on their positions", + "description": "merges audio objects by direction from the listener", // Vendor name displayed in the Wwise Launcher for this bundle "vendor": "CCPGames", From b129256399301e8243d95e5d2fdbf4bf5e551d5b Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Sat, 10 Oct 2026 11:40:02 +0000 Subject: [PATCH 04/26] align the premake script with the wwise 2025 scaffold --- PremakePlugin.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/PremakePlugin.lua b/PremakePlugin.lua index ad557c4..8a86ca0 100644 --- a/PremakePlugin.lua +++ b/PremakePlugin.lua @@ -31,6 +31,8 @@ end local Plugin = {} Plugin.name = "ObjectCluster" Plugin.factoryheader = "../SoundEnginePlugin/ObjectClusterFXFactory.h" +Plugin.appleteamid = "" +Plugin.signtoolargs = {} Plugin.sdk = {} Plugin.sdk.static = {} Plugin.sdk.shared = {} @@ -39,7 +41,6 @@ Plugin.authoring = {} -- SDK STATIC PLUGIN SECTION Plugin.sdk.static.includedirs = -- https://github.com/premake/premake-core/wiki/includedirs { - os.getenv("WWISESDK") .. "/include" } Plugin.sdk.static.files = -- https://github.com/premake/premake-core/wiki/files { @@ -66,7 +67,6 @@ 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 = { @@ -89,7 +89,6 @@ Plugin.sdk.shared.defines = -- AUTHORING PLUGIN SECTION Plugin.authoring.includedirs = { - os.getenv("WWISESDK") .. "/include" } Plugin.authoring.files = { From 8489ab6b076cc69497e41690d5c240a58b87158e Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Sat, 10 Oct 2026 11:40:02 +0000 Subject: [PATCH 05/26] rewrite the readme for the new clustering - how the clustering works, the parameters with their ranges and defaults, and bus setup - profiling tips, the Capture Log messages and building for Wwise 2025 - how to run the harness without a game --- README.md | 191 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 141 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 2d7d248..5bbb706 100644 --- a/README.md +++ b/README.md @@ -5,29 +5,32 @@ ## Overview -The Spatial Clustering plugin dynamically groups audio objects by proximity, reducing system audio object consumption in computationally demanding scenarios. +The Spatial Clustering plugin (ObjectCluster) merges audio objects that reach the listener from similar directions into fewer output objects. This reduces 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. ## 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**: an input stays in its cluster from frame to frame and only moves when it has been outside the cluster for a set time. +- **Crossfaded moves**: an input that changes cluster fades out of the old cluster and into the new one, so there are no clicks or jumps. +- **Output budget**: an optional limit on the number of outputs. When the limit is reached, the closest clusters are merged. +- **Passthrough**: multichannel inputs (ambiences, beds) and inputs without 3D spatialization pass through with their audio and metadata unchanged. +- **Exclusions**: high priority inputs and inputs close to the listener can be kept out of clusters. +- **Real-time safe**: memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs in the [harness](#testing-without-a-game) bench. Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs in the same bench. ## 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 mono sources such as footsteps or voices +- Vehicle and machinery sounds with multiple mono 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. -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. +We developed this plugin to mix nearby sources into a single output positioned at their average direction. 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. ### 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. @@ -50,75 +53,162 @@ Same scene with plugin enabled: - Clear, distinct spatial positioning for each cluster - More resources for sound designers to add unique locations of spatialized objects +## How It Works + +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 is mono, uses 3D spatialization (Position or Position + Orientation) and has Speaker Panning / 3D Spatialization Mix at 100. +- Every other input passes through: it gets its own output with the same channel configuration, audio, positioning, custom metadata and priority. This covers stereo and multichannel ambiences, beds, 2D sounds and partly spatialized sounds. +- Inputs only share a cluster when they carry the same set of custom metadata plug-in types; a cluster output carries the custom metadata of its representative input. + +### 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 Weight** 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 **Join Angle**. + +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. + +### Joining and leaving + +- A new input joins the nearest cluster within the **Join Angle**. If there is none, it starts a new cluster. +- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within **Join Angle** × **Leave Ratio**. 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 **Join Angle** × **Leave Ratio** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. +- A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster. +- 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 never loses it. + +### 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, so the input keeps its level during the move. + +A switch between clustered and passthrough, for example when an RTPC moves Speaker Panning / 3D Spatialization Mix below 100, happens without a crossfade. + +### Merging + +Two clusters that stay within the **Join Angle** of each other for the **Dwell Time** merge. The lighter cluster fades into the other one; with **Loudness Weighting** on, the lighter cluster is the quieter one, otherwise it is the one with fewer inputs. A merge only happens if the merged cluster would still fit within the **Join Angle**. + +### Output budget + +With **Max Outputs** 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 dwell time. A higher priority makes a pair count as farther apart, so it merges later. + +- At the limit, a new input that is farther than **Join Angle** × **Leave Ratio** 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 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. + +### Cluster outputs + +- **Position**: the average direction of the contributing inputs, at their average distance. With **Loudness Weighting** on, each input counts by its RMS level, smoothed over about 50 ms. +- **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 spread of the representative input (the loudest one when **Loudness Weighting** is on, otherwise the first input at full gain) and **Spread From Cluster Size** × (cluster angular radius / 180°). +- **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 **Exclude Priority Above**, and inputs closer to the listener than **Near-Field Radius**, keep an output of their own and are never merged. An input that becomes excluded while it is in a cluster leaves it after the Dwell Time, with a crossfade. + +## Parameters + +All parameters except Loudness Weighting can be driven by RTPCs. + +| Parameter | Range | Default | Description | +|---|---|---|---| +| Join Angle (degrees) | 0 to 180 | 5 | Largest angle between an input and a cluster for the input to join it. 0 turns off merging by angle. | +| Leave Ratio (x Join Angle) | 1 to 4 | 1.5 | Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. | +| Dwell 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. | +| Crossfade Time (ms) | 0 to 1000 | 50 | Time over which a moving input fades between clusters. | +| Max Outputs (0 = no limit) | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | +| Near-Field 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. | +| Distance Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | +| Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | +| Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | +| Loudness Weighting | on or off | on | Moves each cluster toward its louder inputs. | + +Dwell 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 Join Angle of 180 with Max Outputs at 1 mixes all clusterable inputs into one object, as long as no passthrough or excluded inputs need outputs of their own and all inputs carry the same custom metadata. A Join Angle of 0 with a Max Outputs limit only merges when the limit is reached. + ## 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) +### Bus Setup +- Insert the plug-in as an Effect on an Audio Objects bus (bus channel configuration set to Audio Objects). It cannot be inserted on sounds or containers and cannot be used for offline rendering. +- Use it on busses that carry many mono one-shots that are expensive to render as objects, such as weapon fire, impacts and debris. +- Multichannel inputs such as ambience loops always pass through untouched. If you set Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. +- Use Near-Field Radius to keep sounds close to the listener separate. +- Use separate busses with their own settings for different categories, for example a smaller Join Angle for dense sources (turret fire) and a larger one for 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 +- 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 for a visual spatial representation of the clusters. +- 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 Max Outputs allows, when it cannot create output objects and when it runs out of memory. Repeated messages of the same kind are posted at most once every 5 seconds. +- The runtime plug-in must be built for the same Wwise SDK version as the game, since the sound engine rejects plug-ins built for another version. A game that loads plug-in DLLs looks for ObjectCluster.dll next to the executable, or in the folder set in AkInitSettings::szPluginDLLPath. A game that links plug-ins statically links ObjectClusterFX.lib and includes ObjectClusterFXFactory.h in one source file. ![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) - ## Implementation -### 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 +| Path | Contents | +|---|---| +| `SoundEnginePlugin/ClusterEngine.h/.cpp` | The clustering. Plain C++17 with no Wwise headers; all memory goes through a `ClusterAllocator`. | +| `SoundEnginePlugin/ObjectClusterFX.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. | +| `SoundEnginePlugin/ObjectClusterFXParams.h/.cpp` | Parameters, read from banks and RTPCs. | +| `WwisePlugin/` | The authoring plug-in and the property definitions (`ObjectCluster.xml`). | +| `harness/` | Standalone tests, scenarios and benchmark for the engine. Not part of the plug-in build. | -### 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 +## Getting Started -### Clustering Algorithm +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). -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. +### Prerequisites -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. +- 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. +- 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, and the authoring plug-in uses MFC. +- Python 3. -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. +### Building from Source -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. +Run these commands from the repository root in a Developer Command Prompt for VS 2026, with `WWISEROOT` set to your Wwise install: -## Getting Started +``` +python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" premake Windows_vc180 -t vc180 +python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" build Windows_vc180 -c Profile -x x64 -t vc180 +python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" build Windows_vc180 -c Release -x x64 -t vc180 -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). +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 +``` -### Prerequisites +The build copies its outputs into the Wwise install: +- Runtime plug-in: `%WWISEROOT%\SDK\x64_vc180\\lib\ObjectClusterFX.lib` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\ObjectCluster.dll` (dynamic). +- Authoring plug-in: `%WWISEROOT%\Authoring\x64\Release\bin\Plugins\ObjectCluster.dll` and `ObjectCluster.xml`. -- 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 +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 `\\\\\`. -### Building from Source +## Testing Without a Game -1. Generate solutions: +The `harness` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 12 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough and more) and a benchmark, which also times an update that lowers Max Outputs. ``` -py -3 %WWISEROOT%\Scripts\Build\Plugins\wp.py premake Authoring_Windows +cmake -S harness -B harness/build +cmake --build harness/build --config Release +cd harness/build +ctest -C Release +Release\cluster_harness bench +Release\cluster_harness list +Release\cluster_harness scenario oneshots --csv oneshots.csv ``` -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) +Run `cluster_harness` with no arguments to see all modes and options. On Linux and macOS the program is in `harness/build` instead of `harness/build/Release`. ## Contributing @@ -131,6 +221,7 @@ We welcome contributions: feature requests, bug fixes, documentation improvement ### Submitting a Pull Request - Fork the repository and create a feature branch +- Run the harness tests, and include bench results if you change `ClusterEngine` - Build and test your changes at runtime before submitting. - Submit a PR with a clear description of what changed and why @@ -138,7 +229,7 @@ We welcome contributions: feature requests, bug fixes, documentation improvement 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: +Changes to the clustering itself can be tested first with the [harness](#testing-without-a-game). 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. From b887c4f5200dfb5fa33af79109273b035457c85f Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 09:46:44 +0000 Subject: [PATCH 06/26] move the engine and its tests to core/ and the wwise project to wwise/ --- README.md | 34 +++++++++--------- .../src}/ClusterEngine.cpp | 0 .../src}/ClusterEngine.h | 0 {harness => core/tests}/CMakeLists.txt | 18 +++++----- {harness => core/tests}/src/Bench.cpp | 4 +-- {harness => core/tests}/src/FrameAnalysis.cpp | 2 +- {harness => core/tests}/src/FrameAnalysis.h | 0 {harness => core/tests}/src/Main.cpp | 10 +++--- .../tests/src/MathConstants.h | 0 .../HarnessModes.h => core/tests/src/Modes.h | 0 {harness => core/tests}/src/ScenarioMode.cpp | 6 ++-- {harness => core/tests}/src/Scenarios.cpp | 2 +- {harness => core/tests}/src/Scenarios.h | 0 {harness => core/tests}/src/Tests.cpp | 4 +-- .../FactoryAssets}/Manifest.xml | 0 .../ObjectClusterConfig.h | 0 PremakePlugin.lua => wwise/PremakePlugin.lua | 3 ++ .../SoundEnginePlugin}/ObjectClusterFX.cpp | 0 .../SoundEnginePlugin}/ObjectClusterFX.h | 0 .../ObjectClusterFXFactory.h | 0 .../ObjectClusterFXParams.cpp | 0 .../ObjectClusterFXParams.h | 0 .../ObjectClusterFXShared.cpp | 0 .../WwisePlugin}/ObjectCluster.def | 0 .../WwisePlugin}/ObjectCluster.rc | Bin .../WwisePlugin}/ObjectCluster.xml | 0 .../WwisePlugin}/ObjectClusterPlugin.cpp | 0 .../WwisePlugin}/ObjectClusterPlugin.h | 0 .../Win32/ObjectClusterPluginGUI.cpp | 0 .../Win32/ObjectClusterPluginGUI.h | 0 .../WwisePlugin}/res/Md/en/Placeholder.md | 0 {WwisePlugin => wwise/WwisePlugin}/resource.h | 0 .../bundle_template.json | 0 33 files changed, 43 insertions(+), 40 deletions(-) rename {SoundEnginePlugin => core/src}/ClusterEngine.cpp (100%) rename {SoundEnginePlugin => core/src}/ClusterEngine.h (100%) rename {harness => core/tests}/CMakeLists.txt (74%) rename {harness => core/tests}/src/Bench.cpp (99%) rename {harness => core/tests}/src/FrameAnalysis.cpp (99%) rename {harness => core/tests}/src/FrameAnalysis.h (100%) rename {harness => core/tests}/src/Main.cpp (89%) rename harness/src/HarnessMath.h => core/tests/src/MathConstants.h (100%) rename harness/src/HarnessModes.h => core/tests/src/Modes.h (100%) rename {harness => core/tests}/src/ScenarioMode.cpp (96%) rename {harness => core/tests}/src/Scenarios.cpp (99%) rename {harness => core/tests}/src/Scenarios.h (100%) rename {harness => core/tests}/src/Tests.cpp (99%) rename {FactoryAssets => wwise/FactoryAssets}/Manifest.xml (100%) rename ObjectClusterConfig.h => wwise/ObjectClusterConfig.h (100%) rename PremakePlugin.lua => wwise/PremakePlugin.lua (97%) rename {SoundEnginePlugin => wwise/SoundEnginePlugin}/ObjectClusterFX.cpp (100%) rename {SoundEnginePlugin => wwise/SoundEnginePlugin}/ObjectClusterFX.h (100%) rename {SoundEnginePlugin => wwise/SoundEnginePlugin}/ObjectClusterFXFactory.h (100%) rename {SoundEnginePlugin => wwise/SoundEnginePlugin}/ObjectClusterFXParams.cpp (100%) rename {SoundEnginePlugin => wwise/SoundEnginePlugin}/ObjectClusterFXParams.h (100%) rename {SoundEnginePlugin => wwise/SoundEnginePlugin}/ObjectClusterFXShared.cpp (100%) rename {WwisePlugin => wwise/WwisePlugin}/ObjectCluster.def (100%) rename {WwisePlugin => wwise/WwisePlugin}/ObjectCluster.rc (100%) rename {WwisePlugin => wwise/WwisePlugin}/ObjectCluster.xml (100%) rename {WwisePlugin => wwise/WwisePlugin}/ObjectClusterPlugin.cpp (100%) rename {WwisePlugin => wwise/WwisePlugin}/ObjectClusterPlugin.h (100%) rename {WwisePlugin => wwise/WwisePlugin}/Win32/ObjectClusterPluginGUI.cpp (100%) rename {WwisePlugin => wwise/WwisePlugin}/Win32/ObjectClusterPluginGUI.h (100%) rename {WwisePlugin => wwise/WwisePlugin}/res/Md/en/Placeholder.md (100%) rename {WwisePlugin => wwise/WwisePlugin}/resource.h (100%) rename bundle_template.json => wwise/bundle_template.json (100%) diff --git a/README.md b/README.md index 5bbb706..a7e0e91 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Unlike traditional Effect Plug-Ins which are agnostic to Audio Objects, Object P - **Output budget**: an optional limit on the number of outputs. When the limit is reached, the closest clusters are merged. - **Passthrough**: multichannel inputs (ambiences, beds) and inputs without 3D spatialization pass through with their audio and metadata unchanged. - **Exclusions**: high priority inputs and inputs close to the listener can be kept out of clusters. -- **Real-time safe**: memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs in the [harness](#testing-without-a-game) bench. Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs in the same bench. +- **Real-time safe**: memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs in the [benchmark](#testing-without-a-game). Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs in the same bench. ## Perfect For @@ -158,11 +158,11 @@ To place breakpoints and debug the plugin follow the steps [here](https://www.au | Path | Contents | |---|---| -| `SoundEnginePlugin/ClusterEngine.h/.cpp` | The clustering. Plain C++17 with no Wwise headers; all memory goes through a `ClusterAllocator`. | -| `SoundEnginePlugin/ObjectClusterFX.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. | -| `SoundEnginePlugin/ObjectClusterFXParams.h/.cpp` | Parameters, read from banks and RTPCs. | -| `WwisePlugin/` | The authoring plug-in and the property definitions (`ObjectCluster.xml`). | -| `harness/` | Standalone tests, scenarios and benchmark for the engine. Not part of the plug-in build. | +| `core/src/ClusterEngine.h/.cpp` | The clustering. Plain C++17 with no Wwise headers; all memory goes through a `ClusterAllocator`. | +| `wwise/SoundEnginePlugin/ObjectClusterFX.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/ObjectClusterFXParams.h/.cpp` | Parameters, read from banks and RTPCs. | +| `wwise/WwisePlugin/` | The authoring plug-in and the property definitions (`ObjectCluster.xml`). | +| `core/tests/` | Standalone tests, scenarios and benchmark for the engine. Not part of the plug-in build. | ## Getting Started @@ -177,7 +177,7 @@ See Audiokinetic's [Creating Audio Plug-ins](https://www.audiokinetic.com/en/pub ### Building from Source -Run these commands from the repository root in a Developer Command Prompt for VS 2026, with `WWISEROOT` set to your Wwise install: +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 @@ -196,19 +196,19 @@ To also copy the runtime DLL somewhere else after each build, set `CUSTOM_WWISE_ ## Testing Without a Game -The `harness` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 12 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough and more) and a benchmark, which also times an update that lowers Max Outputs. +The `core/tests` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 12 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough and more) and a benchmark, which also times an update that lowers Max Outputs. ``` -cmake -S harness -B harness/build -cmake --build harness/build --config Release -cd harness/build +cmake -S core/tests -B build/tests +cmake --build build/tests --config Release +cd build/tests ctest -C Release -Release\cluster_harness bench -Release\cluster_harness list -Release\cluster_harness scenario oneshots --csv oneshots.csv +Release\cluster_tests bench +Release\cluster_tests list +Release\cluster_tests scenario oneshots --csv oneshots.csv ``` -Run `cluster_harness` with no arguments to see all modes and options. On Linux and macOS the program is in `harness/build` instead of `harness/build/Release`. +Run `cluster_tests` with no arguments to see all modes and options. On Linux and macOS the program is in `build/tests` instead of `build/tests/Release`. ## Contributing @@ -221,7 +221,7 @@ We welcome contributions: feature requests, bug fixes, documentation improvement ### Submitting a Pull Request - Fork the repository and create a feature branch -- Run the harness tests, and include bench results if you change `ClusterEngine` +- Run the tests in `core/tests`, and include bench results if you change `ClusterEngine` - Build and test your changes at runtime before submitting. - Submit a PR with a clear description of what changed and why @@ -229,7 +229,7 @@ We welcome contributions: feature requests, bug fixes, documentation improvement 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. -Changes to the clustering itself can be tested first with the [harness](#testing-without-a-game). Once the plugin builds, you'll want a scene to exercise it in. A few options: +Changes to the clustering itself can be tested first with the [test program](#testing-without-a-game). 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. diff --git a/SoundEnginePlugin/ClusterEngine.cpp b/core/src/ClusterEngine.cpp similarity index 100% rename from SoundEnginePlugin/ClusterEngine.cpp rename to core/src/ClusterEngine.cpp diff --git a/SoundEnginePlugin/ClusterEngine.h b/core/src/ClusterEngine.h similarity index 100% rename from SoundEnginePlugin/ClusterEngine.h rename to core/src/ClusterEngine.h diff --git a/harness/CMakeLists.txt b/core/tests/CMakeLists.txt similarity index 74% rename from harness/CMakeLists.txt rename to core/tests/CMakeLists.txt index 29d4bbd..e7bc869 100644 --- a/harness/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -19,7 +19,7 @@ # EVE Online and EVE Frontier are registered trademarks of CCP ehf. cmake_minimum_required(VERSION 3.16) -project(ClusterHarness LANGUAGES CXX) +project(ClusterTests LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -29,12 +29,12 @@ if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() -set(ENGINE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../SoundEnginePlugin) +set(ENGINE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../src) -add_executable(cluster_harness +add_executable(cluster_tests src/Main.cpp - src/HarnessMath.h - src/HarnessModes.h + src/MathConstants.h + src/Modes.h src/Scenarios.cpp src/Scenarios.h src/FrameAnalysis.cpp @@ -45,13 +45,13 @@ add_executable(cluster_harness ${ENGINE_DIR}/ClusterEngine.cpp ${ENGINE_DIR}/ClusterEngine.h) -target_include_directories(cluster_harness PRIVATE ${ENGINE_DIR} src) +target_include_directories(cluster_tests PRIVATE ${ENGINE_DIR} src) if(MSVC) - target_compile_options(cluster_harness PRIVATE /W4 /permissive-) + target_compile_options(cluster_tests PRIVATE /W4 /permissive-) else() - target_compile_options(cluster_harness PRIVATE -Wall -Wextra -Wpedantic) + target_compile_options(cluster_tests PRIVATE -Wall -Wextra -Wpedantic) endif() enable_testing() -add_test(NAME cluster_engine_tests COMMAND cluster_harness test) +add_test(NAME cluster_engine_tests COMMAND cluster_tests test) diff --git a/harness/src/Bench.cpp b/core/tests/src/Bench.cpp similarity index 99% rename from harness/src/Bench.cpp rename to core/tests/src/Bench.cpp index 699783d..aff1231 100644 --- a/harness/src/Bench.cpp +++ b/core/tests/src/Bench.cpp @@ -21,8 +21,8 @@ */ #include "FrameAnalysis.h" -#include "HarnessMath.h" -#include "HarnessModes.h" +#include "MathConstants.h" +#include "Modes.h" #include "Scenarios.h" #include diff --git a/harness/src/FrameAnalysis.cpp b/core/tests/src/FrameAnalysis.cpp similarity index 99% rename from harness/src/FrameAnalysis.cpp rename to core/tests/src/FrameAnalysis.cpp index 06c174a..8843dfd 100644 --- a/harness/src/FrameAnalysis.cpp +++ b/core/tests/src/FrameAnalysis.cpp @@ -21,7 +21,7 @@ */ #include "FrameAnalysis.h" -#include "HarnessMath.h" +#include "MathConstants.h" #include #include diff --git a/harness/src/FrameAnalysis.h b/core/tests/src/FrameAnalysis.h similarity index 100% rename from harness/src/FrameAnalysis.h rename to core/tests/src/FrameAnalysis.h diff --git a/harness/src/Main.cpp b/core/tests/src/Main.cpp similarity index 89% rename from harness/src/Main.cpp rename to core/tests/src/Main.cpp index 0902007..a2243bf 100644 --- a/harness/src/Main.cpp +++ b/core/tests/src/Main.cpp @@ -20,7 +20,7 @@ * EVE Online and EVE Frontier are registered trademarks of CCP ehf. */ -#include "HarnessModes.h" +#include "Modes.h" #include #include @@ -34,13 +34,13 @@ void PrintUsage() { std::printf( "usage:\n" - " cluster_harness test [name filter]\n" + " cluster_tests test [name filter]\n" " runs the assertion suite, or the tests whose name contains the filter; exits nonzero on failure\n" - " cluster_harness bench [--frames N] [--warmup N] [--seed N]\n" + " cluster_tests bench [--frames N] [--warmup N] [--seed N]\n" " prints mean and p99 microseconds per Update for 64 to 1024 inputs\n" - " cluster_harness scenario [--csv file] [--seed N] [--frames N] [--shuffle] [--quiet]\n" + " cluster_tests scenario [--csv file] [--seed N] [--frames N] [--shuffle] [--quiet]\n" " prints per-frame metrics of one scenario\n" - " cluster_harness list\n" + " cluster_tests list\n" " prints the scenario names, default seeds and descriptions\n"); } } diff --git a/harness/src/HarnessMath.h b/core/tests/src/MathConstants.h similarity index 100% rename from harness/src/HarnessMath.h rename to core/tests/src/MathConstants.h diff --git a/harness/src/HarnessModes.h b/core/tests/src/Modes.h similarity index 100% rename from harness/src/HarnessModes.h rename to core/tests/src/Modes.h diff --git a/harness/src/ScenarioMode.cpp b/core/tests/src/ScenarioMode.cpp similarity index 96% rename from harness/src/ScenarioMode.cpp rename to core/tests/src/ScenarioMode.cpp index 2b05797..44653bb 100644 --- a/harness/src/ScenarioMode.cpp +++ b/core/tests/src/ScenarioMode.cpp @@ -21,7 +21,7 @@ */ #include "FrameAnalysis.h" -#include "HarnessModes.h" +#include "Modes.h" #include "Scenarios.h" #include @@ -63,7 +63,7 @@ int RunScenarioMode(const std::vector& in_args) { if (in_args.empty()) { - std::printf("scenario mode needs a name; run 'cluster_harness list' to see them\n"); + std::printf("scenario mode needs a name; run 'cluster_tests list' to see them\n"); return 2; } const std::string name = in_args[0]; @@ -113,7 +113,7 @@ int RunScenarioMode(const std::vector& in_args) Scenario scenario; if (!BuildScenario(name, seed, scenario)) { - std::printf("unknown scenario '%s'; run 'cluster_harness list' to see them\n", name.c_str()); + std::printf("unknown scenario '%s'; run 'cluster_tests list' to see them\n", name.c_str()); return 2; } if (hasFrames) diff --git a/harness/src/Scenarios.cpp b/core/tests/src/Scenarios.cpp similarity index 99% rename from harness/src/Scenarios.cpp rename to core/tests/src/Scenarios.cpp index 1dc3117..ea173c6 100644 --- a/harness/src/Scenarios.cpp +++ b/core/tests/src/Scenarios.cpp @@ -21,7 +21,7 @@ */ #include "Scenarios.h" -#include "HarnessMath.h" +#include "MathConstants.h" #include #include diff --git a/harness/src/Scenarios.h b/core/tests/src/Scenarios.h similarity index 100% rename from harness/src/Scenarios.h rename to core/tests/src/Scenarios.h diff --git a/harness/src/Tests.cpp b/core/tests/src/Tests.cpp similarity index 99% rename from harness/src/Tests.cpp rename to core/tests/src/Tests.cpp index 5103978..7b40764 100644 --- a/harness/src/Tests.cpp +++ b/core/tests/src/Tests.cpp @@ -21,8 +21,8 @@ */ #include "FrameAnalysis.h" -#include "HarnessMath.h" -#include "HarnessModes.h" +#include "MathConstants.h" +#include "Modes.h" #include "Scenarios.h" #include diff --git a/FactoryAssets/Manifest.xml b/wwise/FactoryAssets/Manifest.xml similarity index 100% rename from FactoryAssets/Manifest.xml rename to wwise/FactoryAssets/Manifest.xml diff --git a/ObjectClusterConfig.h b/wwise/ObjectClusterConfig.h similarity index 100% rename from ObjectClusterConfig.h rename to wwise/ObjectClusterConfig.h diff --git a/PremakePlugin.lua b/wwise/PremakePlugin.lua similarity index 97% rename from PremakePlugin.lua rename to wwise/PremakePlugin.lua index 8a86ca0..d7a6c83 100644 --- a/PremakePlugin.lua +++ b/wwise/PremakePlugin.lua @@ -41,6 +41,7 @@ Plugin.authoring = {} -- SDK STATIC PLUGIN SECTION Plugin.sdk.static.includedirs = -- https://github.com/premake/premake-core/wiki/includedirs { + "../../core/src", } Plugin.sdk.static.files = -- https://github.com/premake/premake-core/wiki/files { @@ -48,6 +49,8 @@ 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 { diff --git a/SoundEnginePlugin/ObjectClusterFX.cpp b/wwise/SoundEnginePlugin/ObjectClusterFX.cpp similarity index 100% rename from SoundEnginePlugin/ObjectClusterFX.cpp rename to wwise/SoundEnginePlugin/ObjectClusterFX.cpp diff --git a/SoundEnginePlugin/ObjectClusterFX.h b/wwise/SoundEnginePlugin/ObjectClusterFX.h similarity index 100% rename from SoundEnginePlugin/ObjectClusterFX.h rename to wwise/SoundEnginePlugin/ObjectClusterFX.h diff --git a/SoundEnginePlugin/ObjectClusterFXFactory.h b/wwise/SoundEnginePlugin/ObjectClusterFXFactory.h similarity index 100% rename from SoundEnginePlugin/ObjectClusterFXFactory.h rename to wwise/SoundEnginePlugin/ObjectClusterFXFactory.h diff --git a/SoundEnginePlugin/ObjectClusterFXParams.cpp b/wwise/SoundEnginePlugin/ObjectClusterFXParams.cpp similarity index 100% rename from SoundEnginePlugin/ObjectClusterFXParams.cpp rename to wwise/SoundEnginePlugin/ObjectClusterFXParams.cpp diff --git a/SoundEnginePlugin/ObjectClusterFXParams.h b/wwise/SoundEnginePlugin/ObjectClusterFXParams.h similarity index 100% rename from SoundEnginePlugin/ObjectClusterFXParams.h rename to wwise/SoundEnginePlugin/ObjectClusterFXParams.h diff --git a/SoundEnginePlugin/ObjectClusterFXShared.cpp b/wwise/SoundEnginePlugin/ObjectClusterFXShared.cpp similarity index 100% rename from SoundEnginePlugin/ObjectClusterFXShared.cpp rename to wwise/SoundEnginePlugin/ObjectClusterFXShared.cpp diff --git a/WwisePlugin/ObjectCluster.def b/wwise/WwisePlugin/ObjectCluster.def similarity index 100% rename from WwisePlugin/ObjectCluster.def rename to wwise/WwisePlugin/ObjectCluster.def diff --git a/WwisePlugin/ObjectCluster.rc b/wwise/WwisePlugin/ObjectCluster.rc similarity index 100% rename from WwisePlugin/ObjectCluster.rc rename to wwise/WwisePlugin/ObjectCluster.rc diff --git a/WwisePlugin/ObjectCluster.xml b/wwise/WwisePlugin/ObjectCluster.xml similarity index 100% rename from WwisePlugin/ObjectCluster.xml rename to wwise/WwisePlugin/ObjectCluster.xml diff --git a/WwisePlugin/ObjectClusterPlugin.cpp b/wwise/WwisePlugin/ObjectClusterPlugin.cpp similarity index 100% rename from WwisePlugin/ObjectClusterPlugin.cpp rename to wwise/WwisePlugin/ObjectClusterPlugin.cpp diff --git a/WwisePlugin/ObjectClusterPlugin.h b/wwise/WwisePlugin/ObjectClusterPlugin.h similarity index 100% rename from WwisePlugin/ObjectClusterPlugin.h rename to wwise/WwisePlugin/ObjectClusterPlugin.h diff --git a/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp b/wwise/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp similarity index 100% rename from WwisePlugin/Win32/ObjectClusterPluginGUI.cpp rename to wwise/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp diff --git a/WwisePlugin/Win32/ObjectClusterPluginGUI.h b/wwise/WwisePlugin/Win32/ObjectClusterPluginGUI.h similarity index 100% rename from WwisePlugin/Win32/ObjectClusterPluginGUI.h rename to wwise/WwisePlugin/Win32/ObjectClusterPluginGUI.h diff --git a/WwisePlugin/res/Md/en/Placeholder.md b/wwise/WwisePlugin/res/Md/en/Placeholder.md similarity index 100% rename from WwisePlugin/res/Md/en/Placeholder.md rename to wwise/WwisePlugin/res/Md/en/Placeholder.md diff --git a/WwisePlugin/resource.h b/wwise/WwisePlugin/resource.h similarity index 100% rename from WwisePlugin/resource.h rename to wwise/WwisePlugin/resource.h diff --git a/bundle_template.json b/wwise/bundle_template.json similarity index 100% rename from bundle_template.json rename to wwise/bundle_template.json From bcde443d9f80ce1d68bcbaea32124ef27f65291f Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 09:49:02 +0000 Subject: [PATCH 07/26] rename the plugin from ObjectCluster to SpatialClustering --- README.md | 14 +-- wwise/FactoryAssets/Manifest.xml | 4 +- wwise/PremakePlugin.lua | 14 +-- ...tClusterFX.cpp => SpatialClusteringFX.cpp} | 86 +++++++++--------- ...bjectClusterFX.h => SpatialClusteringFX.h} | 22 ++--- ...Factory.h => SpatialClusteringFXFactory.h} | 8 +- ...rams.cpp => SpatialClusteringFXParams.cpp} | 26 +++--- ...FXParams.h => SpatialClusteringFXParams.h} | 24 ++--- ...ared.cpp => SpatialClusteringFXShared.cpp} | 2 +- ...sterConfig.h => SpatialClusteringConfig.h} | 8 +- wwise/WwisePlugin/ObjectCluster.def | 3 - wwise/WwisePlugin/SpatialClustering.def | 3 + ...{ObjectCluster.rc => SpatialClustering.rc} | Bin ...bjectCluster.xml => SpatialClustering.xml} | 2 +- ...Plugin.cpp => SpatialClusteringPlugin.cpp} | 20 ++-- ...sterPlugin.h => SpatialClusteringPlugin.h} | 8 +- ...GUI.cpp => SpatialClusteringPluginGUI.cpp} | 10 +- ...uginGUI.h => SpatialClusteringPluginGUI.h} | 6 +- wwise/WwisePlugin/resource.h | 2 +- wwise/bundle_template.json | 8 +- 20 files changed, 135 insertions(+), 135 deletions(-) rename wwise/SoundEnginePlugin/{ObjectClusterFX.cpp => SpatialClusteringFX.cpp} (89%) rename wwise/SoundEnginePlugin/{ObjectClusterFX.h => SpatialClusteringFX.h} (95%) rename wwise/SoundEnginePlugin/{ObjectClusterFXFactory.h => SpatialClusteringFXFactory.h} (89%) rename wwise/SoundEnginePlugin/{ObjectClusterFXParams.cpp => SpatialClusteringFXParams.cpp} (87%) rename wwise/SoundEnginePlugin/{ObjectClusterFXParams.h => SpatialClusteringFXParams.h} (91%) rename wwise/SoundEnginePlugin/{ObjectClusterFXShared.cpp => SpatialClusteringFXShared.cpp} (97%) rename wwise/{ObjectClusterConfig.h => SpatialClusteringConfig.h} (92%) delete mode 100644 wwise/WwisePlugin/ObjectCluster.def create mode 100644 wwise/WwisePlugin/SpatialClustering.def rename wwise/WwisePlugin/{ObjectCluster.rc => SpatialClustering.rc} (100%) rename wwise/WwisePlugin/{ObjectCluster.xml => SpatialClustering.xml} (99%) rename wwise/WwisePlugin/{ObjectClusterPlugin.cpp => SpatialClusteringPlugin.cpp} (75%) rename wwise/WwisePlugin/{ObjectClusterPlugin.h => SpatialClusteringPlugin.h} (89%) rename wwise/WwisePlugin/Win32/{ObjectClusterPluginGUI.cpp => SpatialClusteringPluginGUI.cpp} (78%) rename wwise/WwisePlugin/Win32/{ObjectClusterPluginGUI.h => SpatialClusteringPluginGUI.h} (92%) diff --git a/README.md b/README.md index a7e0e91..1e6d4be 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Overview -The Spatial Clustering plugin (ObjectCluster) merges audio objects that reach the listener from similar directions into fewer output objects. This reduces system audio object consumption in computationally demanding scenarios. +The Spatial Clustering plugin merges audio objects that reach the listener from similar directions into fewer output objects. This reduces 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. @@ -147,7 +147,7 @@ The same controls cover the whole range: a Join Angle of 180 with Max Outputs at - 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 for a visual spatial representation of the clusters. - 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 Max Outputs allows, when it cannot create output objects and when it runs out of memory. Repeated messages of the same kind are posted at most once every 5 seconds. -- The runtime plug-in must be built for the same Wwise SDK version as the game, since the sound engine rejects plug-ins built for another version. A game that loads plug-in DLLs looks for ObjectCluster.dll next to the executable, or in the folder set in AkInitSettings::szPluginDLLPath. A game that links plug-ins statically links ObjectClusterFX.lib and includes ObjectClusterFXFactory.h in one source file. +- The runtime plug-in must be built for the same Wwise SDK version as the game, since the sound engine rejects plug-ins built for another version. A game that loads plug-in DLLs looks for SpatialClustering.dll next to the executable, or in the folder set in AkInitSettings::szPluginDLLPath. A game that links plug-ins statically links SpatialClusteringFX.lib and includes SpatialClusteringFXFactory.h in one source file. ![Wwise_TWJ1fyWlc5](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) @@ -159,9 +159,9 @@ To place breakpoints and debug the plugin follow the steps [here](https://www.au | Path | Contents | |---|---| | `core/src/ClusterEngine.h/.cpp` | The clustering. Plain C++17 with no Wwise headers; all memory goes through a `ClusterAllocator`. | -| `wwise/SoundEnginePlugin/ObjectClusterFX.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/ObjectClusterFXParams.h/.cpp` | Parameters, read from banks and RTPCs. | -| `wwise/WwisePlugin/` | The authoring plug-in and the property definitions (`ObjectCluster.xml`). | +| `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 and the property definitions (`SpatialClustering.xml`). | | `core/tests/` | Standalone tests, scenarios and benchmark for the engine. Not part of the plug-in build. | ## Getting Started @@ -189,8 +189,8 @@ python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" build Authoring -c Release -x x ``` The build copies its outputs into the Wwise install: -- Runtime plug-in: `%WWISEROOT%\SDK\x64_vc180\\lib\ObjectClusterFX.lib` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\ObjectCluster.dll` (dynamic). -- Authoring plug-in: `%WWISEROOT%\Authoring\x64\Release\bin\Plugins\ObjectCluster.dll` and `ObjectCluster.xml`. +- Runtime plug-in: `%WWISEROOT%\SDK\x64_vc180\\lib\SpatialClusteringFX.lib` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\SpatialClustering.dll` (dynamic). +- Authoring plug-in: `%WWISEROOT%\Authoring\x64\Release\bin\Plugins\SpatialClustering.dll` and `SpatialClustering.xml`. 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 `\\\\\`. diff --git a/wwise/FactoryAssets/Manifest.xml b/wwise/FactoryAssets/Manifest.xml index c61df72..5d69d98 100644 --- a/wwise/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/wwise/PremakePlugin.lua b/wwise/PremakePlugin.lua index d7a6c83..fa526db 100644 --- a/wwise/PremakePlugin.lua +++ b/wwise/PremakePlugin.lua @@ -29,8 +29,8 @@ 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 = {} @@ -54,7 +54,7 @@ Plugin.sdk.static.files = -- https://github.com/premake/premake-core/wiki/files } 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 { @@ -73,8 +73,8 @@ Plugin.sdk.shared.includedirs = } Plugin.sdk.shared.files = { - "ObjectClusterFXShared.cpp", - "ObjectClusterFXFactory.h", + "SpatialClusteringFXShared.cpp", + "SpatialClusteringFXFactory.h", } Plugin.sdk.shared.excludes = { @@ -99,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/ObjectClusterFX.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp similarity index 89% rename from wwise/SoundEnginePlugin/ObjectClusterFX.cpp rename to wwise/SoundEnginePlugin/SpatialClusteringFX.cpp index 8aff317..f31e428 100644 --- a/wwise/SoundEnginePlugin/ObjectClusterFX.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp @@ -30,8 +30,8 @@ CCP's trademarks or game content. EVE Online and EVE Frontier are registered trademarks of CCP ehf. *******************************************************************************/ -#include "ObjectClusterFX.h" -#include "../ObjectClusterConfig.h" +#include "SpatialClusteringFX.h" +#include "../SpatialClusteringConfig.h" #include #include @@ -40,17 +40,17 @@ trademarks of CCP ehf. #include -AK::IAkPlugin* CreateObjectClusterFX(AK::IAkPluginMemAlloc* in_pAllocator) +AK::IAkPlugin* CreateSpatialClusteringFX(AK::IAkPluginMemAlloc* in_pAllocator) { - return AK_PLUGIN_NEW(in_pAllocator, ObjectClusterFX()); + return AK_PLUGIN_NEW(in_pAllocator, SpatialClusteringFX()); } -AK::IAkPluginParam* CreateObjectClusterFXParams(AK::IAkPluginMemAlloc* in_pAllocator) +AK::IAkPluginParam* CreateSpatialClusteringFXParams(AK::IAkPluginMemAlloc* in_pAllocator) { - return AK_PLUGIN_NEW(in_pAllocator, ObjectClusterFXParams()); + return AK_PLUGIN_NEW(in_pAllocator, SpatialClusteringFXParams()); } -AK_IMPLEMENT_PLUGIN_FACTORY(ObjectClusterFX, AkPluginTypeEffect, ObjectClusterConfig::CompanyID, ObjectClusterConfig::PluginID) +AK_IMPLEMENT_PLUGIN_FACTORY(SpatialClusteringFX, AkPluginTypeEffect, SpatialClusteringConfig::CompanyID, SpatialClusteringConfig::PluginID) namespace { @@ -73,22 +73,22 @@ namespace const char kClusterNamePrefix[] = "Cluster "; /** @brief Message posted when the plug-in is not on a bus. */ - const char kNotOnBusMessage[] = "ObjectCluster only runs on busses. Insert it on an Audio Objects 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[] = "ObjectCluster 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."; + 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[] = "ObjectCluster could not get the hash table service from the sound engine."; + 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[] = "ObjectCluster could not create output objects, so some inputs are silent."; + 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[] = "ObjectCluster ran out of memory, so some audio may drop 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 Max Outputs. */ - const char kOverBudgetMessage[] = "ObjectCluster needs more outputs than Max Outputs allows because the remaining inputs cannot be merged (passthrough inputs, excluded inputs or different custom metadata)."; + const char kOverBudgetMessage[] = "Spatial Clustering needs more outputs than Max Outputs 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 @@ -191,12 +191,12 @@ namespace } } -void ObjectClusterAllocator::Init(AK::IAkPluginMemAlloc* in_pAllocator) +void SpatialClusteringAllocator::Init(AK::IAkPluginMemAlloc* in_pAllocator) { m_pAllocator = in_pAllocator; } -void* ObjectClusterAllocator::Alloc(size_t in_size) +void* SpatialClusteringAllocator::Alloc(size_t in_size) { if (m_pAllocator == nullptr) return nullptr; @@ -204,13 +204,13 @@ void* ObjectClusterAllocator::Alloc(size_t in_size) return AK_PLUGIN_ALLOC(m_pAllocator, in_size); } -void ObjectClusterAllocator::Free(void* in_pBlock) +void SpatialClusteringAllocator::Free(void* in_pBlock) { if (m_pAllocator != nullptr && in_pBlock != nullptr) AK_PLUGIN_FREE(m_pAllocator, in_pBlock); } -ObjectClusterFX::ObjectClusterFX() +SpatialClusteringFX::SpatialClusteringFX() : m_pParams(nullptr) , m_pAllocator(nullptr) , m_pContext(nullptr) @@ -228,13 +228,13 @@ ObjectClusterFX::ObjectClusterFX() { } -ObjectClusterFX::~ObjectClusterFX() +SpatialClusteringFX::~SpatialClusteringFX() { } -AKRESULT ObjectClusterFX::Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffectPluginContext* in_pContext, AK::IAkPluginParam* in_pParams, AkAudioFormat& io_rFormat) +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_pParams = static_cast(in_pParams); m_pAllocator = in_pAllocator; m_pContext = in_pContext; @@ -305,14 +305,14 @@ AKRESULT ObjectClusterFX::Init(AK::IAkPluginMemAlloc* in_pAllocator, AK::IAkEffe return AK_Success; } -AKRESULT ObjectClusterFX::Term(AK::IAkPluginMemAlloc* in_pAllocator) +AKRESULT SpatialClusteringFX::Term(AK::IAkPluginMemAlloc* in_pAllocator) { ReleaseMemory(); AK_PLUGIN_DELETE(in_pAllocator, this); return AK_Success; } -AKRESULT ObjectClusterFX::Reset() +AKRESULT SpatialClusteringFX::Reset() { if (m_bEngineInitialized) m_engine.Reset(); @@ -335,7 +335,7 @@ AKRESULT ObjectClusterFX::Reset() return AK_Success; } -AKRESULT ObjectClusterFX::GetPluginInfo(AkPluginInfo& out_rPluginInfo) +AKRESULT SpatialClusteringFX::GetPluginInfo(AkPluginInfo& out_rPluginInfo) { out_rPluginInfo.eType = AkPluginTypeEffect; out_rPluginInfo.bIsInPlace = false; @@ -344,7 +344,7 @@ AKRESULT ObjectClusterFX::GetPluginInfo(AkPluginInfo& out_rPluginInfo) return AK_Success; } -void ObjectClusterFX::Execute(const AkAudioObjects& in_objects, const AkAudioObjects& out_objects) +void SpatialClusteringFX::Execute(const AkAudioObjects& in_objects, const AkAudioObjects& out_objects) { ++m_uFrame; @@ -389,7 +389,7 @@ void ObjectClusterFX::Execute(const AkAudioObjects& in_objects, const AkAudioObj RemoveStaleEntries(); } -void ObjectClusterFX::ReleaseMemory() +void SpatialClusteringFX::ReleaseMemory() { if (m_bEngineInitialized) m_engine.Term(); @@ -424,14 +424,14 @@ void ObjectClusterFX::ReleaseMemory() m_uNumOutputs = 0; } -void ObjectClusterFX::UpdateClusterParams(AkUInt16 in_uMaxFrames) +void SpatialClusteringFX::UpdateClusterParams(AkUInt16 in_uMaxFrames) { if (in_uMaxFrames == m_uParamsMaxFrames && !m_pParams->m_paramChangeHandler.HasAnyChanged()) return; m_uParamsMaxFrames = in_uMaxFrames; - const ObjectClusterRTPCParams& rtpc = m_pParams->RTPC; + 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; @@ -455,7 +455,7 @@ void ObjectClusterFX::UpdateClusterParams(AkUInt16 in_uMaxFrames) m_pParams->m_paramChangeHandler.ResetAllParamChanges(); } -bool ObjectClusterFX::PrepareInputs(const AkAudioObjects& in_objects) +bool SpatialClusteringFX::PrepareInputs(const AkAudioObjects& in_objects) { const AkUInt32 uNumInputs = in_objects.uNumObjects; if (!m_inputs.Resize(uNumInputs) || !m_inputPriorities.Resize(uNumInputs)) @@ -490,7 +490,7 @@ bool ObjectClusterFX::PrepareInputs(const AkAudioObjects& in_objects) return true; } -AkUInt32 ObjectClusterFX::CreateOutputs(const AkAudioObjects& in_objects) +AkUInt32 SpatialClusteringFX::CreateOutputs(const AkAudioObjects& in_objects) { m_requests.RemoveAll(); bool bOutOfMemory = false; @@ -585,7 +585,7 @@ AkUInt32 ObjectClusterFX::CreateOutputs(const AkAudioObjects& in_objects) return uCreated; } -bool ObjectClusterFX::AddRequest(AkUInt32 in_uChannelConfig, AkUInt32 in_uSource, bool in_bIsCluster) +bool SpatialClusteringFX::AddRequest(AkUInt32 in_uChannelConfig, AkUInt32 in_uSource, bool in_bIsCluster) { OutputRequest* pRequest = m_requests.AddLast(); if (pRequest == nullptr) @@ -598,7 +598,7 @@ bool ObjectClusterFX::AddRequest(AkUInt32 in_uChannelConfig, AkUInt32 in_uSource return true; } -void ObjectClusterFX::RegisterOutput(const AkAudioObjects& in_objects, const OutputRequest& in_request, AkAudioObject* in_pObject) +void SpatialClusteringFX::RegisterOutput(const AkAudioObjects& in_objects, const OutputRequest& in_request, AkAudioObject* in_pObject) { if (in_request.isCluster) { @@ -635,7 +635,7 @@ void ObjectClusterFX::RegisterOutput(const AkAudioObjects& in_objects, const Out in_pObject->SetName(m_pAllocator, (szInputName != nullptr && szInputName[0] != '\0') ? szInputName : kPassthroughName); } -bool ObjectClusterFX::FetchOutputs(AkUInt32 in_uExpected) +bool SpatialClusteringFX::FetchOutputs(AkUInt32 in_uExpected) { m_uNumOutputs = 0; @@ -669,7 +669,7 @@ bool ObjectClusterFX::FetchOutputs(AkUInt32 in_uExpected) return true; } -bool ObjectClusterFX::BuildOutputIndex() +bool SpatialClusteringFX::BuildOutputIndex() { m_pHashTableService->Reset(&m_outputIndices); @@ -684,13 +684,13 @@ bool ObjectClusterFX::BuildOutputIndex() return true; } -AkUInt32 ObjectClusterFX::FindOutput(AkAudioObjectID in_key) const +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 ObjectClusterFX::ProcessPassthroughs(const AkAudioObjects& in_objects) +void SpatialClusteringFX::ProcessPassthroughs(const AkAudioObjects& in_objects) { for (AkUInt32 i = 0; i < in_objects.uNumObjects; ++i) { @@ -729,7 +729,7 @@ void ObjectClusterFX::ProcessPassthroughs(const AkAudioObjects& in_objects) } } -void ObjectClusterFX::ProcessClusters(const AkAudioObjects& in_objects) +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)) @@ -817,7 +817,7 @@ void ObjectClusterFX::ProcessClusters(const AkAudioObjects& in_objects) } } -void ObjectClusterFX::ReleaseUnwrittenOutputs() +void SpatialClusteringFX::ReleaseUnwrittenOutputs() { for (AkUInt32 i = 0; i < m_uNumOutputs; ++i) { @@ -834,7 +834,7 @@ void ObjectClusterFX::ReleaseUnwrittenOutputs() } } -void ObjectClusterFX::QueuePriority(AkAudioObject* in_pObject, AkPriority in_priority) +void SpatialClusteringFX::QueuePriority(AkAudioObject* in_pObject, AkPriority in_priority) { if (m_pPriorityService == nullptr) return; @@ -846,7 +846,7 @@ void ObjectClusterFX::QueuePriority(AkAudioObject* in_pObject, AkPriority in_pri m_priorityObjects.RemoveLast(); } -void ObjectClusterFX::ApplyPriorities() +void SpatialClusteringFX::ApplyPriorities() { const AkUInt32 uCount = m_priorityObjects.Length(); if (m_pPriorityService == nullptr || uCount == 0) @@ -855,7 +855,7 @@ void ObjectClusterFX::ApplyPriorities() m_pPriorityService->SetPriorities(m_priorityObjects.Data(), uCount, m_priorityValues.Data()); } -void ObjectClusterFX::RemoveStaleEntries() +void SpatialClusteringFX::RemoveStaleEntries() { const AkUInt32 uFrame = m_uFrame; @@ -868,13 +868,13 @@ void ObjectClusterFX::RemoveStaleEntries() [this, uFrame](AkUInt32 in_uSlot) { return m_clusters.ValueAt(in_uSlot).lastFrame != uFrame; }); } -void ObjectClusterFX::KeepOutputsSilent(AkUInt32 in_uExpected) +void SpatialClusteringFX::KeepOutputsSilent(AkUInt32 in_uExpected) { if (FetchOutputs(in_uExpected)) SilenceOutputs(); } -void ObjectClusterFX::SilenceOutputs() +void SpatialClusteringFX::SilenceOutputs() { for (AkUInt32 i = 0; i < m_uNumOutputs; ++i) { @@ -888,7 +888,7 @@ void ObjectClusterFX::SilenceOutputs() } } -void ObjectClusterFX::PostRateLimitedMessage(MessageKind in_kind, const char* in_szMessage, AK::Monitor::ErrorLevel in_level) +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; diff --git a/wwise/SoundEnginePlugin/ObjectClusterFX.h b/wwise/SoundEnginePlugin/SpatialClusteringFX.h similarity index 95% rename from wwise/SoundEnginePlugin/ObjectClusterFX.h rename to wwise/SoundEnginePlugin/SpatialClusteringFX.h index 3394e14..07fb86a 100644 --- a/wwise/SoundEnginePlugin/ObjectClusterFX.h +++ b/wwise/SoundEnginePlugin/SpatialClusteringFX.h @@ -30,11 +30,11 @@ CCP's trademarks or game content. EVE Online and EVE Frontier are registered trademarks of CCP ehf. *******************************************************************************/ -#ifndef ObjectClusterFX_H -#define ObjectClusterFX_H +#ifndef SpatialClusteringFX_H +#define SpatialClusteringFX_H #include "ClusterEngine.h" -#include "ObjectClusterFXParams.h" +#include "SpatialClusteringFXParams.h" #include #include @@ -42,7 +42,7 @@ trademarks of CCP ehf. #include /** @brief Cluster engine allocator that allocates through the Wwise plug-in allocator. */ -class ObjectClusterAllocator final : public ClusterAllocator +class SpatialClusteringAllocator final : public ClusterAllocator { public: /** @brief Sets the Wwise plug-in allocator to allocate through. */ @@ -59,19 +59,19 @@ class ObjectClusterAllocator final : public ClusterAllocator }; /** - * @class ObjectClusterFX + * @class SpatialClusteringFX * @brief Audio object clustering effect plugin * @details Implements audio object clustering and processing functionality as an out-of-place plugin */ -class ObjectClusterFX +class SpatialClusteringFX : public AK::IAkOutOfPlaceObjectPlugin { public: /** @brief Creates the effect. */ - ObjectClusterFX(); + SpatialClusteringFX(); /** @brief Destroys the effect. */ - ~ObjectClusterFX(); + ~SpatialClusteringFX(); /** * @brief Initializes the plugin @@ -205,13 +205,13 @@ class ObjectClusterFX /** @brief Index value that marks a missing output object. */ static constexpr AkUInt32 kInvalidIndex = 0xFFFFFFFF; - ObjectClusterFXParams* m_pParams; ///< Parameter node. + 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. - ObjectClusterAllocator m_engineAllocator; ///< Allocator the cluster engine allocates through. + 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. @@ -243,4 +243,4 @@ class ObjectClusterFX AK::AkHashTable m_outputIndices; ///< Output index by output key for this frame. }; -#endif // ObjectClusterFX_H +#endif // SpatialClusteringFX_H diff --git a/wwise/SoundEnginePlugin/ObjectClusterFXFactory.h b/wwise/SoundEnginePlugin/SpatialClusteringFXFactory.h similarity index 89% rename from wwise/SoundEnginePlugin/ObjectClusterFXFactory.h rename to wwise/SoundEnginePlugin/SpatialClusteringFXFactory.h index fe2a706..1aa3ed3 100644 --- a/wwise/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/ObjectClusterFXParams.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp similarity index 87% rename from wwise/SoundEnginePlugin/ObjectClusterFXParams.cpp rename to wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp index e320bdf..12c53fa 100644 --- a/wwise/SoundEnginePlugin/ObjectClusterFXParams.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp @@ -24,7 +24,7 @@ the specific language governing permissions and limitations under the License. Copyright (c) 2023 Audiokinetic Inc. *******************************************************************************/ -#include "ObjectClusterFXParams.h" +#include "SpatialClusteringFXParams.h" #include #include @@ -34,13 +34,13 @@ the specific language governing permissions and limitations under the License. namespace { /** @brief Default values of the parameters that RTPCs can drive. */ - const ObjectClusterRTPCParams kDefaultRTPCParams = { 5.f, 1.5f, 200.f, 50.f, 0, 0.f, 0.f, 100, 1.f }; + const SpatialClusteringRTPCParams kDefaultRTPCParams = { 5.f, 1.5f, 200.f, 50.f, 0, 0.f, 0.f, 100, 1.f }; /** @brief Default value of the loudness weighting parameter. */ const bool kDefaultLoudnessWeighting = true; /** @brief Returns true if every real parameter is a finite number. */ - bool AreFinite(const ObjectClusterRTPCParams& in_params) + bool AreFinite(const SpatialClusteringRTPCParams& in_params) { return std::isfinite(in_params.joinAngle) && std::isfinite(in_params.leaveRatio) @@ -74,29 +74,29 @@ namespace } } -ObjectClusterFXParams::ObjectClusterFXParams() +SpatialClusteringFXParams::SpatialClusteringFXParams() : RTPC(kDefaultRTPCParams) { NonRTPC.loudnessWeighting = kDefaultLoudnessWeighting; } -ObjectClusterFXParams::~ObjectClusterFXParams() +SpatialClusteringFXParams::~SpatialClusteringFXParams() { } -ObjectClusterFXParams::ObjectClusterFXParams(const ObjectClusterFXParams& in_rParams) +SpatialClusteringFXParams::SpatialClusteringFXParams(const SpatialClusteringFXParams& in_rParams) { RTPC = in_rParams.RTPC; NonRTPC = in_rParams.NonRTPC; m_paramChangeHandler.SetAllParamChanges(); } -AK::IAkPluginParam* ObjectClusterFXParams::Clone(AK::IAkPluginMemAlloc* in_pAllocator) +AK::IAkPluginParam* SpatialClusteringFXParams::Clone(AK::IAkPluginMemAlloc* in_pAllocator) { - return AK_PLUGIN_NEW(in_pAllocator, ObjectClusterFXParams(*this)); + return AK_PLUGIN_NEW(in_pAllocator, SpatialClusteringFXParams(*this)); } -AKRESULT ObjectClusterFXParams::Init(AK::IAkPluginMemAlloc* in_pAllocator, const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) +AKRESULT SpatialClusteringFXParams::Init(AK::IAkPluginMemAlloc* in_pAllocator, const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) { (void)in_pAllocator; @@ -112,13 +112,13 @@ AKRESULT ObjectClusterFXParams::Init(AK::IAkPluginMemAlloc* in_pAllocator, const return SetParamsBlock(in_pParamsBlock, in_ulBlockSize); } -AKRESULT ObjectClusterFXParams::Term(AK::IAkPluginMemAlloc* in_pAllocator) +AKRESULT SpatialClusteringFXParams::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 SpatialClusteringFXParams::SetParamsBlock(const void* in_pParamsBlock, AkUInt32 in_ulBlockSize) { if (in_pParamsBlock == nullptr || in_ulBlockSize != PARAMS_BLOCK_SIZE) return AK_InvalidParameter; @@ -126,7 +126,7 @@ AKRESULT ObjectClusterFXParams::SetParamsBlock(const void* in_pParamsBlock, AkUI AKRESULT eResult = AK_Success; AkUInt8* pParamsBlock = (AkUInt8*)in_pParamsBlock; - ObjectClusterRTPCParams rtpc; + SpatialClusteringRTPCParams rtpc; rtpc.joinAngle = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); rtpc.leaveRatio = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); rtpc.dwellTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); @@ -149,7 +149,7 @@ AKRESULT ObjectClusterFXParams::SetParamsBlock(const void* in_pParamsBlock, AkUI return eResult; } -AKRESULT ObjectClusterFXParams::SetParam(AkPluginParamID in_paramID, const void* in_pValue, AkUInt32 in_ulParamSize) +AKRESULT SpatialClusteringFXParams::SetParam(AkPluginParamID in_paramID, const void* in_pValue, AkUInt32 in_ulParamSize) { AkReal32 value = 0.f; diff --git a/wwise/SoundEnginePlugin/ObjectClusterFXParams.h b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h similarity index 91% rename from wwise/SoundEnginePlugin/ObjectClusterFXParams.h rename to wwise/SoundEnginePlugin/SpatialClusteringFXParams.h index 1d397c1..df14881 100644 --- a/wwise/SoundEnginePlugin/ObjectClusterFXParams.h +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h @@ -24,8 +24,8 @@ the specific language governing permissions and limitations under the License. Copyright (c) 2023 Audiokinetic Inc. *******************************************************************************/ -#ifndef ObjectClusterFXParams_H -#define ObjectClusterFXParams_H +#ifndef SpatialClusteringFXParams_H +#define SpatialClusteringFXParams_H #include #include @@ -76,7 +76,7 @@ static const AkInt32 PARAM_PRIORITY_MAX = 100; static const AkUInt32 PARAMS_BLOCK_SIZE = 7 * sizeof(AkReal32) + 2 * sizeof(AkInt32) + sizeof(AkUInt8); /** @brief Parameters that RTPCs can drive. */ -struct ObjectClusterRTPCParams +struct SpatialClusteringRTPCParams { AkReal32 joinAngle; ///< Angle in degrees under which an input may join a cluster. AkReal32 leaveRatio; ///< Leave angle as a multiple of the join angle. @@ -90,23 +90,23 @@ struct ObjectClusterRTPCParams }; /** @brief Parameters that RTPCs cannot drive. */ -struct ObjectClusterNonRTPCParams +struct SpatialClusteringNonRTPCParams { bool loudnessWeighting; ///< Weights cluster positions by input loudness. }; -/** @brief Parameter node of the ObjectCluster effect. */ -struct ObjectClusterFXParams +/** @brief Parameter node of the Spatial Clustering effect. */ +struct SpatialClusteringFXParams : public AK::IAkPluginParam { /** @brief Creates a parameter node with default values. */ - ObjectClusterFXParams(); + SpatialClusteringFXParams(); /** @brief Creates a copy of another parameter node. */ - ObjectClusterFXParams(const ObjectClusterFXParams& in_rParams); + SpatialClusteringFXParams(const SpatialClusteringFXParams& in_rParams); /** @brief Destroys the parameter node. */ - ~ObjectClusterFXParams(); + ~SpatialClusteringFXParams(); /// Create a duplicate of the parameter node instance in its current state. IAkPluginParam* Clone(AK::IAkPluginMemAlloc* in_pAllocator) override; @@ -126,8 +126,8 @@ struct ObjectClusterFXParams AK::AkFXParameterChangeHandler m_paramChangeHandler; ///< Tracks which parameters changed since the effect last read them. - ObjectClusterRTPCParams RTPC; ///< Parameters that RTPCs can drive. - ObjectClusterNonRTPCParams NonRTPC; ///< Parameters that RTPCs cannot drive. + SpatialClusteringRTPCParams RTPC; ///< Parameters that RTPCs can drive. + SpatialClusteringNonRTPCParams NonRTPC; ///< Parameters that RTPCs cannot drive. }; -#endif // ObjectClusterFXParams_H +#endif // SpatialClusteringFXParams_H diff --git a/wwise/SoundEnginePlugin/ObjectClusterFXShared.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFXShared.cpp similarity index 97% rename from wwise/SoundEnginePlugin/ObjectClusterFXShared.cpp rename to wwise/SoundEnginePlugin/SpatialClusteringFXShared.cpp index 6dd030e..702ec2a 100644 --- a/wwise/SoundEnginePlugin/ObjectClusterFXShared.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXShared.cpp @@ -25,7 +25,7 @@ the specific language governing permissions and limitations under the License. *******************************************************************************/ #include -#include "ObjectClusterFXFactory.h" +#include "SpatialClusteringFXFactory.h" #include diff --git a/wwise/ObjectClusterConfig.h b/wwise/SpatialClusteringConfig.h similarity index 92% rename from wwise/ObjectClusterConfig.h rename to wwise/SpatialClusteringConfig.h index db3fb2a..bbd07cc 100644 --- a/wwise/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/WwisePlugin/ObjectCluster.def b/wwise/WwisePlugin/ObjectCluster.def deleted file mode 100644 index 7926626..0000000 --- a/wwise/WwisePlugin/ObjectCluster.def +++ /dev/null @@ -1,3 +0,0 @@ -LIBRARY "ObjectCluster" - -EXPORTS 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/ObjectCluster.rc b/wwise/WwisePlugin/SpatialClustering.rc similarity index 100% rename from wwise/WwisePlugin/ObjectCluster.rc rename to wwise/WwisePlugin/SpatialClustering.rc diff --git a/wwise/WwisePlugin/ObjectCluster.xml b/wwise/WwisePlugin/SpatialClustering.xml similarity index 99% rename from wwise/WwisePlugin/ObjectCluster.xml rename to wwise/WwisePlugin/SpatialClustering.xml index b27ed4d..be6e2db 100644 --- a/wwise/WwisePlugin/ObjectCluster.xml +++ b/wwise/WwisePlugin/SpatialClustering.xml @@ -29,7 +29,7 @@ See https://www.audiokinetic.com/library/edge/?source=SDK&id=plugin__xml.html for the documentation about the plugin xml format --> - + diff --git a/wwise/WwisePlugin/ObjectClusterPlugin.cpp b/wwise/WwisePlugin/SpatialClusteringPlugin.cpp similarity index 75% rename from wwise/WwisePlugin/ObjectClusterPlugin.cpp rename to wwise/WwisePlugin/SpatialClusteringPlugin.cpp index ce2677e..9c2feb7 100644 --- a/wwise/WwisePlugin/ObjectClusterPlugin.cpp +++ b/wwise/WwisePlugin/SpatialClusteringPlugin.cpp @@ -24,18 +24,18 @@ the specific language governing permissions and limitations under the License. Copyright (c) 2023 Audiokinetic Inc. *******************************************************************************/ -#include "ObjectClusterPlugin.h" -#include "../SoundEnginePlugin/ObjectClusterFXFactory.h" +#include "SpatialClusteringPlugin.h" +#include "../SoundEnginePlugin/SpatialClusteringFXFactory.h" -ObjectClusterPlugin::ObjectClusterPlugin() +SpatialClusteringPlugin::SpatialClusteringPlugin() { } -ObjectClusterPlugin::~ObjectClusterPlugin() +SpatialClusteringPlugin::~SpatialClusteringPlugin() { } -bool ObjectClusterPlugin::GetBankParameters(const GUID& in_guidPlatform, AK::Wwise::Plugin::DataWriter& in_dataWriter) const +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:joinAngle")) @@ -50,12 +50,12 @@ bool ObjectClusterPlugin::GetBankParameters(const GUID& in_guidPlatform, AK::Wwi && in_dataWriter.WriteBool(m_propertySet.GetBool(in_guidPlatform, "CCP:loudnessWeighting")); } -AK_DEFINE_PLUGIN_CONTAINER(ObjectCluster); // Create a PluginContainer structure that contains the info for our plugin -AK_EXPORT_PLUGIN_CONTAINER(ObjectCluster); // This is a DLL, we want to have a standardized name +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 - 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 + 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 diff --git a/wwise/WwisePlugin/ObjectClusterPlugin.h b/wwise/WwisePlugin/SpatialClusteringPlugin.h similarity index 89% rename from wwise/WwisePlugin/ObjectClusterPlugin.h rename to wwise/WwisePlugin/SpatialClusteringPlugin.h index 28ac1d0..66c4938 100644 --- a/wwise/WwisePlugin/ObjectClusterPlugin.h +++ b/wwise/WwisePlugin/SpatialClusteringPlugin.h @@ -30,15 +30,15 @@ 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: /** @brief Creates the authoring plug-in. */ - ObjectClusterPlugin(); + SpatialClusteringPlugin(); /** @brief Destroys the authoring plug-in. */ - ~ObjectClusterPlugin(); + ~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. @@ -46,4 +46,4 @@ class ObjectClusterPlugin final bool GetBankParameters(const GUID& in_guidPlatform, AK::Wwise::Plugin::DataWriter& in_dataWriter) const override; }; -AK_DECLARE_PLUGIN_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/wwise/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp b/wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.cpp similarity index 78% rename from wwise/WwisePlugin/Win32/ObjectClusterPluginGUI.cpp rename to wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.cpp index c892476..2a2abec 100644 --- a/wwise/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() { } AK_ADD_PLUGIN_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 + 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/wwise/WwisePlugin/Win32/ObjectClusterPluginGUI.h b/wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.h similarity index 92% rename from wwise/WwisePlugin/Win32/ObjectClusterPluginGUI.h rename to wwise/WwisePlugin/Win32/SpatialClusteringPluginGUI.h index 6956676..e6e8aec 100644 --- a/wwise/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/resource.h b/wwise/WwisePlugin/resource.h index d1969ee..ba49ac2 100644 --- a/wwise/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/wwise/bundle_template.json b/wwise/bundle_template.json index 3d3cac0..1c983de 100644 --- a/wwise/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": "merges audio objects by direction from the listener", // 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=", From 42c4a90bcd5c13538f5200fe59147b9b41d73420 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 09:50:16 +0000 Subject: [PATCH 08/26] add a single plugin version and show it in the authoring dll's version info --- wwise/SpatialClusteringVersion.h | 34 +++++++++++++++++++++++++ wwise/WwisePlugin/SpatialClustering.rc | Bin 2668 -> 2574 bytes 2 files changed, 34 insertions(+) create mode 100644 wwise/SpatialClusteringVersion.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.rc b/wwise/WwisePlugin/SpatialClustering.rc index 44acb21e0a7d9c78d7865f0621591ad33d38a04c..7b13b1a209f316b7fce7999d700336b59bcdce15 100644 GIT binary patch literal 2574 zcmcgu-*4J55Pr{JaZ6uXl|l+=QXfNNvaBINIAN_+31#4brPM+Bp{`Q@_??pw!dlog zvH1b;-Pw2d-RC=cJ(%!inq*0o!<=VD7=v+r4QnP?8s=;TDa(=~T{6g)Dc|N@rPouG z3oe#%v0|X^)ZLAW;uVXyV4zQ_ADFhcm=WFdT<38?A15Qvp?LU=ONP?xfz`wsrO7ko z8wNgk@NL&{0+T?$Nui8=ANmUy7`+U9FY9)DbQ|V8jE%U+a+Y$jp0hOLi6}EXWAqsO zMIjKe6L6g!FCbza^K1i`f#9;dG{-7uR}Ds)EN%p!s!Fad_3;B%k$S<*APduRuHfwr z+Dc!aSqtI@SBj&%R-ju1?mBQ=UUQI~?asY>VxB!ENr6_qajPh|2a*x7Y*ztXExJBw zszO{-QToSh_pDGGs;E!tlTfR>TKdzpX&-9b4vx8Pd1#C;m{?&|aAC5|>)Iiz{1Z%T z+Lw6F>0(Z1)b`wk?T$UDH}bavW7{FM0O(ZrZQa3_M}0E0U8^}a(Z71L&I!Hkp48*T z#+Qz!z;x!Bf#Ls0k21lrqg!;M(|gc<3?fH963Om`Nk)NHVNjZ&`WCe;&e)+;lTum5 zO6*az9BP_2bSi=WI3+0R*IOElY6%|P6L{o!#yzMsmyK!6V`hsesra}zl02ymwV`&W z4ZkDTrBJRzGbx$tPC!64lILwGUfuASsU6%JQ(&8@yFb~om7YyjO$2O>(!Oq+uzGPODQ7bN6V9OR^yi4L;FaOZ5!`9gE zlkIED*PFadez$?X)*s&B$7k390uEm?kzwzY-8(JkjlK<+ICg3c^t;-}xOwP-V|)@n zQ3L%h#_lSPKa6vlCaYqZ|B_q_@l&V%0$AA|CAhoqnp5*IJ;l75PJSASEXGk<(nE4x zj)lI4FSx$4?7jz`?f1j!yz^Hnr_|vL-ZjSHX~a`SYYeAb-6&v=(NBG9V0CI(f(4($A<1yN?6#wL1btC_jIO~+UlsP zBb}>r&{o*1G_4m+@rec z4D zHEDATMDlm;bMsonciZb`BfEcgwK?mdmW3EEv$F1xeS-P39eJl@b&ZnJeWRo<{ijPs z#XbBz&b*j*z8%w7Pg;&TYah;7^~b~5p4BpkFm#J&~N-(zX!yaAxWI6VLW From 436d015afe074d48768578ec9378a9ecd98e97c6 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 09:52:40 +0000 Subject: [PATCH 09/26] add help pages for every parameter in the wwise property editor --- .gitignore | 5 +++++ wwise/WwisePlugin/SpatialClustering.xml | 20 +++++++++---------- wwise/WwisePlugin/res/Md/en/Placeholder.md | 7 ------- wwise/WwisePlugin/res/Md/en/crossfadeTime.md | 7 +++++++ wwise/WwisePlugin/res/Md/en/distanceWeight.md | 5 +++++ wwise/WwisePlugin/res/Md/en/dwellTime.md | 7 +++++++ .../res/Md/en/excludePriorityAbove.md | 5 +++++ wwise/WwisePlugin/res/Md/en/joinAngle.md | 9 +++++++++ wwise/WwisePlugin/res/Md/en/leaveRatio.md | 9 +++++++++ .../res/Md/en/loudnessWeighting.md | 7 +++++++ wwise/WwisePlugin/res/Md/en/maxOutputs.md | 9 +++++++++ .../WwisePlugin/res/Md/en/nearFieldRadius.md | 7 +++++++ wwise/WwisePlugin/res/Md/en/spreadFromSize.md | 5 +++++ 13 files changed, 85 insertions(+), 17 deletions(-) delete mode 100644 wwise/WwisePlugin/res/Md/en/Placeholder.md create mode 100644 wwise/WwisePlugin/res/Md/en/crossfadeTime.md create mode 100644 wwise/WwisePlugin/res/Md/en/distanceWeight.md create mode 100644 wwise/WwisePlugin/res/Md/en/dwellTime.md create mode 100644 wwise/WwisePlugin/res/Md/en/excludePriorityAbove.md create mode 100644 wwise/WwisePlugin/res/Md/en/joinAngle.md create mode 100644 wwise/WwisePlugin/res/Md/en/leaveRatio.md create mode 100644 wwise/WwisePlugin/res/Md/en/loudnessWeighting.md create mode 100644 wwise/WwisePlugin/res/Md/en/maxOutputs.md create mode 100644 wwise/WwisePlugin/res/Md/en/nearFieldRadius.md create mode 100644 wwise/WwisePlugin/res/Md/en/spreadFromSize.md diff --git a/.gitignore b/.gitignore index 810f3ce..1a7ca70 100644 --- a/.gitignore +++ b/.gitignore @@ -475,3 +475,8 @@ 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/ diff --git a/wwise/WwisePlugin/SpatialClustering.xml b/wwise/WwisePlugin/SpatialClustering.xml index be6e2db..dc1e65e 100644 --- a/wwise/WwisePlugin/SpatialClustering.xml +++ b/wwise/WwisePlugin/SpatialClustering.xml @@ -42,7 +42,7 @@ for the documentation about the plugin xml format - + 5 0 @@ -56,7 +56,7 @@ for the documentation about the plugin xml format - + 1.5 1 @@ -70,7 +70,7 @@ for the documentation about the plugin xml format - + 200 2 @@ -84,7 +84,7 @@ for the documentation about the plugin xml format - + 50 3 @@ -98,7 +98,7 @@ for the documentation about the plugin xml format - + 0 4 @@ -112,7 +112,7 @@ for the documentation about the plugin xml format - + 0 5 @@ -126,7 +126,7 @@ for the documentation about the plugin xml format - + 0 6 @@ -140,7 +140,7 @@ for the documentation about the plugin xml format - + 100 7 @@ -154,7 +154,7 @@ for the documentation about the plugin xml format - + 1 8 @@ -168,7 +168,7 @@ for the documentation about the plugin xml format - + true 9 diff --git a/wwise/WwisePlugin/res/Md/en/Placeholder.md b/wwise/WwisePlugin/res/Md/en/Placeholder.md deleted file mode 100644 index 8c96348..0000000 --- a/wwise/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/wwise/WwisePlugin/res/Md/en/crossfadeTime.md b/wwise/WwisePlugin/res/Md/en/crossfadeTime.md new file mode 100644 index 0000000..aea3525 --- /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 lighter one fades into the other 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/distanceWeight.md b/wwise/WwisePlugin/res/Md/en/distanceWeight.md new file mode 100644 index 0000000..2344311 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/distanceWeight.md @@ -0,0 +1,5 @@ +## Distance Weight + +How much a difference in distance from the listener counts against the Join Angle. 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 Join Angle. diff --git a/wwise/WwisePlugin/res/Md/en/dwellTime.md b/wwise/WwisePlugin/res/Md/en/dwellTime.md new file mode 100644 index 0000000..a90c9ad --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/dwellTime.md @@ -0,0 +1,7 @@ +## Dwell Time + +Time an input must stay outside its cluster before it leaves, and time two clusters must stay within the Join Angle 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/excludePriorityAbove.md b/wwise/WwisePlugin/res/Md/en/excludePriorityAbove.md new file mode 100644 index 0000000..0951e43 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/excludePriorityAbove.md @@ -0,0 +1,5 @@ +## Exclude Priority Above + +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 Dwell Time, with a crossfade. diff --git a/wwise/WwisePlugin/res/Md/en/joinAngle.md b/wwise/WwisePlugin/res/Md/en/joinAngle.md new file mode 100644 index 0000000..58d97ee --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/joinAngle.md @@ -0,0 +1,9 @@ +## Join Angle + +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 Max Outputs requires it. + +**Note**: With Distance Weight above 0, a difference in distance from the listener is added to the angle before it is compared with the Join Angle. + +Units: degrees diff --git a/wwise/WwisePlugin/res/Md/en/leaveRatio.md b/wwise/WwisePlugin/res/Md/en/leaveRatio.md new file mode 100644 index 0000000..648ea81 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/leaveRatio.md @@ -0,0 +1,9 @@ +## Leave Ratio + +Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. An input leaves only after it has been farther than Join Angle × Leave Ratio from its cluster for the Dwell Time. + +The gap between the join and leave angles stops inputs from switching back and forth between clusters. + +**Note**: When the Max Outputs limit is reached, a new input with no cluster within the Join Angle joins the nearest cluster within Join Angle × Leave Ratio. + +Units: × Join Angle diff --git a/wwise/WwisePlugin/res/Md/en/loudnessWeighting.md b/wwise/WwisePlugin/res/Md/en/loudnessWeighting.md new file mode 100644 index 0000000..1a7f246 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/loudnessWeighting.md @@ -0,0 +1,7 @@ +## Loudness Weighting + +When on, each input counts by its RMS level, smoothed over about 50 ms, so clusters move toward their louder inputs. When two clusters merge, the quieter one fades into the other. The loudest input is the representative whose spread and custom metadata the output uses. + +When off, every input counts the same. When two clusters merge, the one with fewer inputs fades into the other, and the representative is the first input at full gain. + +**Note**: This is the only parameter that cannot be driven by an RTPC. diff --git a/wwise/WwisePlugin/res/Md/en/maxOutputs.md b/wwise/WwisePlugin/res/Md/en/maxOutputs.md new file mode 100644 index 0000000..de62382 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/maxOutputs.md @@ -0,0 +1,9 @@ +## Max Outputs + +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 Dwell Time. A higher priority makes a pair count as farther apart, so it merges later. + +**Note**: Passthrough inputs, excluded inputs 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. + +Units: output objects diff --git a/wwise/WwisePlugin/res/Md/en/nearFieldRadius.md b/wwise/WwisePlugin/res/Md/en/nearFieldRadius.md new file mode 100644 index 0000000..bca7272 --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/nearFieldRadius.md @@ -0,0 +1,7 @@ +## Near-Field 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/wwise/WwisePlugin/res/Md/en/spreadFromSize.md b/wwise/WwisePlugin/res/Md/en/spreadFromSize.md new file mode 100644 index 0000000..2f2cefc --- /dev/null +++ b/wwise/WwisePlugin/res/Md/en/spreadFromSize.md @@ -0,0 +1,5 @@ +## Spread From Cluster Size + +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 Spread From Cluster Size × (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. From b7834572de8aaff24decbc23b2be72aac70c03e1 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 09:54:17 +0000 Subject: [PATCH 10/26] add a script that builds and packages the plugin as a wwise launcher bundle --- .gitignore | 2 + scripts/package.py | 145 +++++++++++++++++++++++++++++++++++++ wwise/bundle_template.json | 18 +++-- 3 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 scripts/package.py diff --git a/.gitignore b/.gitignore index 1a7ca70..9f0fc9b 100644 --- a/.gitignore +++ b/.gitignore @@ -480,3 +480,5 @@ ASALocalRun/ # Help pages and the missing-help report written by wp.py build Documentation wwise/WwisePlugin/res/Html/ wwise/Output/ +# Release bundles written by scripts/package.py +dist/ diff --git a/scripts/package.py b/scripts/package.py new file mode 100644 index 0000000..823e4d6 --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,145 @@ +# 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 +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", "CHANGELOG.md"] + + +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 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: + 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) + 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/wwise/bundle_template.json b/wwise/bundle_template.json index 1c983de..eb1f64c 100644 --- a/wwise/bundle_template.json +++ b/wwise/bundle_template.json @@ -11,7 +11,7 @@ "tag": "SpatialClustering", // Description displayed in the Wwise Launcher for this bundle - "description": "merges audio objects by direction from the listener", + "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": "CCP Games", @@ -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": "Releases and changelog", + "id": "releases", + "url": "https://github.com/carbonengine/spatial-audio-clustering/releases" + } ], // Local documentation files for this bundle From 8b643863d4aebe55b7c8ca003cf2e40637ae050f Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 09:57:34 +0000 Subject: [PATCH 11/26] split the docs out of the readme and add a changelog, contributing guide and issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 30 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 14 ++ .github/PULL_REQUEST_TEMPLATE.md | 10 ++ CHANGELOG.md | 34 ++++ CONTRIBUTING.md | 39 ++++ NOTICE | 18 +- README.md | 208 ++++------------------ docs/best-practices.md | 21 +++ docs/building.md | 73 ++++++++ docs/how-it-works.md | 61 +++++++ docs/parameters.md | 22 +++ 11 files changed, 346 insertions(+), 184 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/best-practices.md create mode 100644 docs/building.md create mode 100644 docs/how-it-works.md create mode 100644 docs/parameters.md 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..9b1d519 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +**What changed and why** + + +**Testing** +- [ ] The tests in `core/tests` pass (`ctest -C Release`). +- [ ] If `ClusterEngine` changed: benchmark results before and after (`cluster_tests bench`). +- [ ] 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`, `docs/parameters.md` and `CHANGELOG.md` are updated. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ac5840c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 3.0.0 (unreleased) + +Built for Wwise 2025.1. + +### Changed +- The plug-in is now called **Spatial Clustering** (`SpatialClustering`); it was ObjectCluster. The DLL, static library, factory header and Launcher bundle use the new name. +- New clustering engine. Inputs are grouped by their direction from the listener into clusters that persist from frame to frame. Moves between clusters are crossfaded, and an optional output budget merges the closest clusters. Multichannel and 2D inputs pass through unchanged, and high-priority and near-field inputs can be kept out of clusters. See [How it works](docs/how-it-works.md). +- The Distance Threshold parameter is replaced by Join Angle, Leave Ratio, Dwell Time, Crossfade Time, Max Outputs, Near-Field Radius, Distance Weight, Exclude Priority Above, Spread From Cluster Size and Loudness Weighting. See [Parameters](docs/parameters.md). + +### Added +- Release bundle for the Wwise Launcher. +- Help for every parameter in the Wwise property editor. +- Version information in the authoring plug-in DLL. +- A standalone program in `core/tests` with tests, scenarios and a benchmark for the clustering engine. + +### Upgrading from 2.0.0 or earlier +- Remove the old plug-in from your Wwise install before installing this one, since both use the same plug-in IDs. Uninstall it in the Wwise Launcher if you installed it there. Otherwise 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. +- The plug-in keeps its company and plug-in IDs (64 and 6973), so Wwise projects refer to it the same way. The parameters are new, so check the settings of every Spatial Clustering effect after upgrading. +- 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`. Then regenerate your SoundBanks. + +## 2.0.0 (2026-05-21) + +- Upgrade to the Wwise 2025.1 SDK, with minor fixes. +- Update the Apache License 2.0 format. + +## 1.0.1 (2025-07-11) + +- Fix a crash with the Wwise 2023 SDK by removing unnecessary copying of metadata while processing objects that are not clustered. + +## 1.0.0 (2025-03-06) + +- First release of the plug-in, as ObjectCluster. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1479805 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# 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. +- Run the tests in `core/tests`, and include benchmark results if you change `ClusterEngine`. +- 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, the test program and ways to try the plug-in in a game. Changes to the clustering itself can be tested first with the test program in `core/tests`, which does not need the Wwise SDK. + +## 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`. | +| `core/tests/` | Standalone tests, scenarios and benchmark for the engine. Not part of the plug-in build. | +| `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 `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 0bfe774..e6025ec 100644 --- a/NOTICE +++ b/NOTICE @@ -1,9 +1,13 @@ -Spatial Audio Object Clustering Plugin for Wwise -Copyright 2024 CCP Games +Spatial Clustering for Wwise +Copyright 2024-2026 CCP ehf. -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 1e6d4be..e0e2301 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,9 @@ -# 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 merges audio objects that reach the listener from similar directions into fewer output objects. This reduces 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 @@ -17,7 +13,7 @@ Unlike traditional Effect Plug-Ins which are agnostic to Audio Objects, Object P - **Output budget**: an optional limit on the number of outputs. When the limit is reached, the closest clusters are merged. - **Passthrough**: multichannel inputs (ambiences, beds) and inputs without 3D spatialization pass through with their audio and metadata unchanged. - **Exclusions**: high priority inputs and inputs close to the listener can be kept out of clusters. -- **Real-time safe**: memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs in the [benchmark](#testing-without-a-game). Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs in the same bench. +- **Real-time safe**: memory comes from the Wwise plug-in allocator, and an update for 512 inputs takes about 30 to 60 µs. See [Performance](docs/how-it-works.md#performance). ## Perfect For @@ -53,189 +49,47 @@ Same scene with plugin enabled: - Clear, distinct spatial positioning for each cluster - More resources for sound designers to add unique locations of spatialized objects -## How It Works - -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 is mono, uses 3D spatialization (Position or Position + Orientation) and has Speaker Panning / 3D Spatialization Mix at 100. -- Every other input passes through: it gets its own output with the same channel configuration, audio, positioning, custom metadata and priority. This covers stereo and multichannel ambiences, beds, 2D sounds and partly spatialized sounds. -- Inputs only share a cluster when they carry the same set of custom metadata plug-in types; a cluster output carries the custom metadata of its representative input. - -### 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 Weight** 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 **Join Angle**. - -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. - -### Joining and leaving - -- A new input joins the nearest cluster within the **Join Angle**. If there is none, it starts a new cluster. -- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within **Join Angle** × **Leave Ratio**. 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 **Join Angle** × **Leave Ratio** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. -- A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster. -- 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 never loses it. - -### 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, so the input keeps its level during the move. +## Install -A switch between clustered and passthrough, for example when an RTPC moves Speaker Panning / 3D Spatialization Mix below 100, happens without a crossfade. +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, such as 2025.1. +2. Extract the zip. +3. In the Wwise Launcher, open **Plug-ins**, choose **Add from directory** and select the `bundle` folder from the zip. Then install Spatial Clustering into your Wwise install. -### Merging +Upgrading from 2.0.0 or earlier? Remove the old plug-in first, as described in the [changelog](CHANGELOG.md#upgrading-from-200-or-earlier). -Two clusters that stay within the **Join Angle** of each other for the **Dwell Time** merge. The lighter cluster fades into the other one; with **Loudness Weighting** on, the lighter cluster is the quieter one, otherwise it is the one with fewer inputs. A merge only happens if the merged cluster would still fit within the **Join Angle**. +No release for your Wwise version? [Build it from source](docs/building.md). -### Output budget +## Quick start -With **Max Outputs** 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 dwell time. A higher priority makes a pair count as farther apart, so it merges later. +1. Set the channel configuration of a bus to **Audio Objects**. +2. In the bus's **Effects** tab, add the **Spatial Clustering** effect. +3. Route the sounds you want merged to that bus. Mono sounds with 3D spatialization are clustered; everything else passes through. +4. Start with the default settings, then look at the clusters in the [profiler views](docs/best-practices.md#profiling) and tune the [parameters](docs/parameters.md). -- At the limit, a new input that is farther than **Join Angle** × **Leave Ratio** 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 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. +## Use it in your game -### Cluster outputs +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. -- **Position**: the average direction of the contributing inputs, at their average distance. With **Loudness Weighting** on, each input counts by its RMS level, smoothed over about 50 ms. -- **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 spread of the representative input (the loudest one when **Loudness Weighting** is on, otherwise the first input at full gain) and **Spread From Cluster Size** × (cluster angular radius / 180°). -- **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. +- **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`. +- **Unity and Unreal**: add the plug-in to your project's Wwise integration the way Audiokinetic describes for plug-ins, using the same files. -### Exclusions +## Compatibility -Inputs with a priority above **Exclude Priority Above**, and inputs closer to the listener than **Near-Field Radius**, keep an output of their own and are never merged. An input that becomes excluded while it is in a cluster leaves it after the Dwell Time, with a crossfade. - -## Parameters - -All parameters except Loudness Weighting can be driven by RTPCs. - -| Parameter | Range | Default | Description | +| Version | Wwise | Authoring | Runtime | |---|---|---|---| -| Join Angle (degrees) | 0 to 180 | 5 | Largest angle between an input and a cluster for the input to join it. 0 turns off merging by angle. | -| Leave Ratio (x Join Angle) | 1 to 4 | 1.5 | Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. | -| Dwell 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. | -| Crossfade Time (ms) | 0 to 1000 | 50 | Time over which a moving input fades between clusters. | -| Max Outputs (0 = no limit) | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | -| Near-Field 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. | -| Distance Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | -| Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | -| Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | -| Loudness Weighting | on or off | on | Moves each cluster toward its louder inputs. | - -Dwell 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 Join Angle of 180 with Max Outputs at 1 mixes all clusterable inputs into one object, as long as no passthrough or excluded inputs need outputs of their own and all inputs carry the same custom metadata. A Join Angle of 0 with a Max Outputs limit only merges when the limit is reached. - -## Best Practices - -### Bus Setup -- Insert the plug-in as an Effect on an Audio Objects bus (bus channel configuration set to Audio Objects). It cannot be inserted on sounds or containers and cannot be used for offline rendering. -- Use it on busses that carry many mono one-shots that are expensive to render as objects, such as weapon fire, impacts and debris. -- Multichannel inputs such as ambience loops always pass through untouched. If you set Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. -- Use Near-Field Radius to keep sounds close to the listener separate. -- Use separate busses with their own settings for different categories, for example a smaller Join Angle for dense sources (turret fire) and a larger one for 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. -- 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 for a visual spatial representation of the clusters. -- 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 Max Outputs allows, when it cannot create output objects and when it runs out of memory. Repeated messages of the same kind are posted at most once every 5 seconds. -- The runtime plug-in must be built for the same Wwise SDK version as the game, since the sound engine rejects plug-ins built for another version. A game that loads plug-in DLLs looks for SpatialClustering.dll next to the executable, or in the folder set in AkInitSettings::szPluginDLLPath. A game that links plug-ins statically links SpatialClusteringFX.lib and includes SpatialClusteringFXFactory.h in one source file. - -![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) - -## Implementation - -| Path | Contents | -|---|---| -| `core/src/ClusterEngine.h/.cpp` | The clustering. Plain C++17 with no Wwise headers; all memory goes through a `ClusterAllocator`. | -| `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 and the property definitions (`SpatialClustering.xml`). | -| `core/tests/` | Standalone tests, scenarios and benchmark for the engine. Not part of the plug-in build. | - -## Getting Started - -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). - -### 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. -- 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, and the authoring plug-in uses MFC. -- Python 3. - -### Building from Source - -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 Profile -x x64 -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 -``` - -The build copies its outputs into the Wwise install: -- Runtime plug-in: `%WWISEROOT%\SDK\x64_vc180\\lib\SpatialClusteringFX.lib` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\SpatialClustering.dll` (dynamic). -- Authoring plug-in: `%WWISEROOT%\Authoring\x64\Release\bin\Plugins\SpatialClustering.dll` and `SpatialClustering.xml`. - -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 `\\\\\`. - -## Testing Without a Game - -The `core/tests` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 12 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough and more) and a benchmark, which also times an update that lowers Max Outputs. - -``` -cmake -S core/tests -B build/tests -cmake --build build/tests --config Release -cd build/tests -ctest -C Release -Release\cluster_tests bench -Release\cluster_tests list -Release\cluster_tests scenario oneshots --csv oneshots.csv -``` - -Run `cluster_tests` with no arguments to see all modes and options. On Linux and macOS the program is in `build/tests` instead of `build/tests/Release`. - -## Contributing - -We welcome contributions: feature requests, bug fixes, documentation improvements, or new functionality. Contribution guidelines and PR templates are still being finalized as we open source the wider engine, so please bear with us while that work lands. - -### Opening an Issue -- Search existing open and closed issues before creating a new one -- For feature requests, include the use case -- For bug reports, include steps to reproduce and expected behavior - -### Submitting a Pull Request -- Fork the repository and create a feature branch -- Run the tests in `core/tests`, and include bench results if you change `ClusterEngine` -- Build and test your changes at runtime before submitting. -- Submit a PR with a clear description of what changed and why - -### 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. +| 3.0.0 | 2025.1 | Windows x64 | Windows x64 (Visual Studio 2026) | -Changes to the clustering itself can be tested first with the [test program](#testing-without-a-game). 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. +Other platforms are not released yet. Building for them needs the matching Wwise deployment platform; see [Building from source](docs/building.md). -We don't yet ship a dedicated sample scene for spatial audio testing. That's on the list to add. +## Documentation +- [How it works](docs/how-it-works.md): inputs, clusters, merging, the output budget and performance. +- [Parameters](docs/parameters.md): ranges, defaults and what each one does. +- [Best practices](docs/best-practices.md): bus setup and profiling. +- [Building from source](docs/building.md): build, package a release, run the tests, debug. +- [Changelog](CHANGELOG.md) +- [Contributing](CONTRIBUTING.md) ## License and Legal Notices diff --git a/docs/best-practices.md b/docs/best-practices.md new file mode 100644 index 0000000..ba13ee6 --- /dev/null +++ b/docs/best-practices.md @@ -0,0 +1,21 @@ +# Best practices + +## Bus setup + +- Insert the plug-in as an Effect on an Audio Objects bus (bus channel configuration set to Audio Objects). It cannot be inserted on sounds or containers and cannot be used for offline rendering. +- Use it on busses that carry many mono one-shots that are expensive to render as objects, such as weapon fire, impacts and debris. +- Multichannel inputs such as ambience loops always pass through untouched. If you set Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. +- Use Near-Field Radius to keep sounds close to the listener separate. +- Use separate busses with their own settings for different categories, for example a smaller Join Angle for dense sources (turret fire) and a larger one for spread-out sounds (shields). + +![Spatial Clustering inserted on an Audio Objects bus in Wwise](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. +- 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 for a visual spatial representation of the clusters. +- 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 Max Outputs allows, when it cannot create output objects and when it runs out of memory. Repeated messages of the same kind are posted at most once every 5 seconds. + +![Clusters in the Wwise Audio Object profiler views](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..8f82a36 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,73 @@ +# 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 (SDK install, environment variables, compiler requirements). 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. +- 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, and the authoring plug-in uses MFC. +- 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 Profile -x x64 -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 +``` + +The build copies its outputs into the Wwise install: +- Runtime plug-in: `%WWISEROOT%\SDK\x64_vc180\\lib\SpatialClusteringFX.lib` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\SpatialClustering.dll` (dynamic). +- 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 `\\\\\`. + +## Package a release + +`scripts/package.py` builds everything above (Debug, Profile and Release for the runtime plug-in) and packages it as a Wwise Launcher bundle. Run it from the repository root, in the same kind of command prompt: + +``` +python scripts\package.py +``` + +It writes `dist\SpatialClustering__Wwise.\` with the bundle, the license files and the changelog, and a zip of that folder for the GitHub release. `--skip-build` packages what is already built. + +The plug-in version is set in `wwise/SpatialClusteringVersion.h`. 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. + +## Testing without a game + +The `core/tests` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 12 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough and more) and a benchmark, which also times an update that lowers Max Outputs. + +``` +cmake -S core/tests -B build/tests +cmake --build build/tests --config Release +cd build/tests +ctest -C Release +Release\cluster_tests bench +Release\cluster_tests list +Release\cluster_tests scenario oneshots --csv oneshots.csv +``` + +Run `cluster_tests` with no arguments to see all modes and options. On Linux and macOS the program is in `build/tests` instead of `build/tests/Release`. + +## 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 yet ship a dedicated sample scene for spatial audio testing. That's on the list to add. + +## Debugging + +To place breakpoints and debug the plug-in, follow the steps [here](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..b8105f8 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,61 @@ +# 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 is mono, uses 3D spatialization (Position or Position + Orientation) and has Speaker Panning / 3D Spatialization Mix at 100. +- Every other input passes through: it gets its own output with the same channel configuration, audio, positioning, custom metadata and priority. This covers stereo and multichannel ambiences, beds, 2D sounds and partly spatialized sounds. +- Inputs only share a cluster when they carry the same set of custom metadata plug-in types; a cluster output carries the custom metadata of its representative input. + +## 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 Weight** 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 **Join Angle**. + +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. + +## Joining and leaving + +- A new input joins the nearest cluster within the **Join Angle**. If there is none, it starts a new cluster. +- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within **Join Angle** × **Leave Ratio**. 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 **Join Angle** × **Leave Ratio** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. +- A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster. +- 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 never loses it. + +## 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, so the input keeps its level during the move. + +A switch between clustered and passthrough, for example when an RTPC moves Speaker Panning / 3D Spatialization Mix below 100, happens without a crossfade. + +## Merging + +Two clusters that stay within the **Join Angle** of each other for the **Dwell Time** merge. The lighter cluster fades into the other one; with **Loudness Weighting** on, the lighter cluster is the quieter one, otherwise it is the one with fewer inputs. A merge only happens if the merged cluster would still fit within the **Join Angle**. + +## Output budget + +With **Max Outputs** 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 dwell time. A higher priority makes a pair count as farther apart, so it merges later. + +- At the limit, a new input that is farther than **Join Angle** × **Leave Ratio** 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 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. + +## Cluster outputs + +- **Position**: the average direction of the contributing inputs, at their average distance. With **Loudness Weighting** on, each input counts by its RMS level, smoothed over about 50 ms. +- **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 spread of the representative input (the loudest one when **Loudness Weighting** is on, otherwise the first input at full gain) and **Spread From Cluster Size** × (cluster angular radius / 180°). +- **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 **Exclude Priority Above**, and inputs closer to the listener than **Near-Field Radius**, keep an output of their own and are never merged. An input that becomes excluded while it is in a cluster leaves it after the Dwell Time, with a crossfade. + +## Performance + +Memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs in the [benchmark](building.md#testing-without-a-game). Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs in the same benchmark. diff --git a/docs/parameters.md b/docs/parameters.md new file mode 100644 index 0000000..795d264 --- /dev/null +++ b/docs/parameters.md @@ -0,0 +1,22 @@ +# Parameters + +All parameters except Loudness Weighting can be driven by RTPCs. The same descriptions are in the help panel of the Wwise property editor. + +| Parameter | Range | Default | Description | +|---|---|---|---| +| Join Angle (degrees) | 0 to 180 | 5 | Largest angle between an input and a cluster for the input to join it. 0 turns off merging by angle. | +| Leave Ratio (x Join Angle) | 1 to 4 | 1.5 | Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. | +| Dwell 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. | +| Crossfade Time (ms) | 0 to 1000 | 50 | Time over which a moving input fades between clusters. | +| Max Outputs (0 = no limit) | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | +| Near-Field 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. | +| Distance Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | +| Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | +| Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | +| Loudness Weighting | on or off | on | Moves each cluster toward its louder inputs. | + +Dwell 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 Join Angle of 180 with Max Outputs at 1 mixes all clusterable inputs into one object, as long as no passthrough or excluded inputs need outputs of their own and all inputs carry the same custom metadata. A Join Angle of 0 with a Max Outputs limit only merges when the limit is reached. + +See [How it works](how-it-works.md) for what each parameter does to the clusters. From 6edff65ccf37a81a9e0e2406fc8da128e1c64719 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 09:58:45 +0000 Subject: [PATCH 12/26] run the core tests on windows, linux and macos in ci, and add a .gitattributes --- .gitattributes | 6 ++++++ .github/workflows/tests.yml | 27 +++++++++++++++++++++++++++ .gitignore | 2 ++ 3 files changed, 35 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/tests.yml 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/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f8207b0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +# Builds the clustering engine's test program and runs its tests. This needs no Wwise SDK, +# so it runs on GitHub-hosted runners. The Wwise plug-in itself is built with scripts/package.py. +name: Tests + +on: + push: + pull_request: + +jobs: + core-tests: + name: Core tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: cmake -S core/tests -B build/tests -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build/tests --config Release + + - name: Test + run: ctest --test-dir build/tests -C Release --output-on-failure diff --git a/.gitignore b/.gitignore index 9f0fc9b..2ec055f 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,5 @@ wwise/WwisePlugin/res/Html/ wwise/Output/ # Release bundles written by scripts/package.py dist/ +# Personal editor settings +.vscode/ From 6e4c5ca099c9eb9156854f5d830eda9e4a3a44f9 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 10:25:11 +0000 Subject: [PATCH 13/26] add a battle scenario and a viewer that plays scenarios back without and with clustering --- CHANGELOG.md | 2 +- core/tests/src/Main.cpp | 5 +- core/tests/src/ScenarioMode.cpp | 164 +++++++++ core/tests/src/Scenarios.cpp | 115 +++++++ core/tests/viewer.html | 593 ++++++++++++++++++++++++++++++++ docs/building.md | 13 +- 6 files changed, 888 insertions(+), 4 deletions(-) create mode 100644 core/tests/viewer.html diff --git a/CHANGELOG.md b/CHANGELOG.md index ac5840c..51733bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Built for Wwise 2025.1. - Release bundle for the Wwise Launcher. - Help for every parameter in the Wwise property editor. - Version information in the authoring plug-in DLL. -- A standalone program in `core/tests` with tests, scenarios and a benchmark for the clustering engine. +- A standalone program in `core/tests` with tests, scenarios and a benchmark for the clustering engine, and a browser viewer that plays back a scenario frame by frame. ### Upgrading from 2.0.0 or earlier - Remove the old plug-in from your Wwise install before installing this one, since both use the same plug-in IDs. Uninstall it in the Wwise Launcher if you installed it there. Otherwise 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. diff --git a/core/tests/src/Main.cpp b/core/tests/src/Main.cpp index a2243bf..00645e4 100644 --- a/core/tests/src/Main.cpp +++ b/core/tests/src/Main.cpp @@ -38,8 +38,9 @@ void PrintUsage() " runs the assertion suite, or the tests whose name contains the filter; exits nonzero on failure\n" " cluster_tests bench [--frames N] [--warmup N] [--seed N]\n" " prints mean and p99 microseconds per Update for 64 to 1024 inputs\n" - " cluster_tests scenario [--csv file] [--seed N] [--frames N] [--shuffle] [--quiet]\n" - " prints per-frame metrics of one scenario\n" + " cluster_tests scenario [--csv file] [--trace file] [--seed N] [--frames N] [--join-angle D] [--max-outputs N]\n" + " [--shuffle] [--quiet]\n" + " prints per-frame metrics of one scenario; --trace writes every frame as JSON for viewer.html\n" " cluster_tests list\n" " prints the scenario names, default seeds and descriptions\n"); } diff --git a/core/tests/src/ScenarioMode.cpp b/core/tests/src/ScenarioMode.cpp index 44653bb..7bfe4eb 100644 --- a/core/tests/src/ScenarioMode.cpp +++ b/core/tests/src/ScenarioMode.cpp @@ -27,12 +27,129 @@ #include #include #include +#include #include #include +#include #include namespace { +/** @brief Writes every frame of a scenario run as JSON for viewer.html. + * + * Each frame holds: + * - "p": maxOutputs, joinAngle and leaveRatio; + * - "i": inputs as [id, x, y, z, loudness, flags, priority], where id is stable across frames and flags + * is 1 for clusterable and 2 for excluded; + * - "c": clusters as [cluster ID, x, y, z, angular radius, member count]; + * - "l": contributions as [input index, cluster index, gain at the end of the frame]. + */ +class TraceWriter +{ +public: + ~TraceWriter() + { + Close(); + } + + /** @brief Creates the file and writes the scenario header, and returns false if the file cannot be created. */ + bool Open(const std::string& in_path, const Scenario& in_scenario) + { + m_file.open(in_path); + if (!m_file) + { + return false; + } + Write("{\"scenario\":\"%s\",\"description\":\"%s\",\"seed\":%u,\"frameSeconds\":%.6f,\"frames\":[\n", + Escape(in_scenario.name).c_str(), Escape(in_scenario.description).c_str(), in_scenario.seed, in_scenario.frameSeconds); + return true; + } + + /** @brief Writes one frame. */ + void WriteFrame(const FrameView& in_view, const ClusterParams& in_params) + { + Write("%s{\"p\":[%u,%g,%g],\"i\":[", m_frames++ > 0 ? ",\n" : "", in_params.maxOutputs, in_params.joinAngle, + in_params.leaveRatio); + for (size_t index = 0; index < in_view.inputs.size(); ++index) + { + const ClusterInput& input = in_view.inputs[index]; + const uint32_t id = m_ids.emplace(input.key, static_cast(m_ids.size())).first->second; + const int flags = (input.clusterable ? 1 : 0) | (IsExcludedInput(input, in_params) ? 2 : 0); + Write("%s[%u,%.2f,%.2f,%.2f,%.3f,%d,%u]", index > 0 ? "," : "", id, input.position.x, input.position.y, + input.position.z, input.loudness, flags, input.priority); + } + Write("],\"c\":["); + for (size_t index = 0; index < in_view.clusters.size(); ++index) + { + const ClusterInfo& cluster = in_view.clusters[index]; + Write("%s[%u,%.2f,%.2f,%.2f,%.2f,%u]", index > 0 ? "," : "", cluster.id, cluster.position.x, cluster.position.y, + cluster.position.z, cluster.angularRadius, cluster.memberCount); + } + Write("],\"l\":["); + for (size_t index = 0; index < in_view.contributions.size(); ++index) + { + const ClusterContribution& contribution = in_view.contributions[index]; + Write("%s[%u,%u,%.3f]", index > 0 ? "," : "", contribution.input, contribution.cluster, contribution.gainEnd); + } + Write("]}"); + } + + /** @brief Ends the JSON and closes the file. */ + void Close() + { + if (m_file.is_open()) + { + Write("\n]}\n"); + m_file.close(); + } + } + +private: + template + void Write(const char* in_format, Args... in_args) + { + char buffer[512]; + const int length = std::snprintf(buffer, sizeof(buffer), in_format, in_args...); + m_file.write(buffer, std::min(length, static_cast(sizeof(buffer)) - 1)); + } + + void Write(const char* in_text) + { + m_file << in_text; + } + + static std::string Escape(const std::string& in_text) + { + std::string escaped; + for (char character : in_text) + { + if (character == '"' || character == '\\') + { + escaped += '\\'; + } + escaped += character; + } + return escaped; + } + + std::ofstream m_file; ///< Trace file. + std::unordered_map m_ids; ///< Stable small ID of each input key seen so far. + int m_frames = 0; ///< Number of frames written. +}; + +/** @brief Reads an angle in degrees from 0 to 180 and returns false if the text is not one. */ +bool ParseAngle(const std::string& in_text, float& out_degrees) +{ + char* pEnd = nullptr; + const double value = std::strtod(in_text.c_str(), &pEnd); + if (in_text.empty() || pEnd != in_text.c_str() + in_text.size() || !(value >= 0.0 && value <= 180.0)) + { + return false; + } + out_degrees = static_cast(value); + return true; +} + void PrintMetrics(const FrameMetrics& in_metrics) { std::printf("%6d %6u %6u %6u %6u %6u %6u %9.3f %4d %10.2f\n", in_metrics.frame, in_metrics.inputs, in_metrics.passthrough, @@ -73,6 +190,11 @@ int RunScenarioMode(const std::vector& in_args) bool shuffle = false; bool quiet = false; std::string csvPath; + std::string tracePath; + float joinAngle = 0.f; + bool hasJoinAngle = false; + uint32_t maxOutputs = 0; + bool hasMaxOutputs = false; for (size_t index = 1; index < in_args.size(); ++index) { const std::string& arg = in_args[index]; @@ -82,6 +204,10 @@ int RunScenarioMode(const std::vector& in_args) { csvPath = in_args[++index]; } + else if (arg == "--trace" && hasValue) + { + tracePath = in_args[++index]; + } else if (arg == "--seed" && hasValue) { valid = ParseNumber(in_args[++index], seed); @@ -91,6 +217,16 @@ int RunScenarioMode(const std::vector& in_args) valid = ParseNumber(in_args[++index], frames); hasFrames = true; } + else if (arg == "--join-angle" && hasValue) + { + valid = ParseAngle(in_args[++index], joinAngle); + hasJoinAngle = true; + } + else if (arg == "--max-outputs" && hasValue) + { + valid = ParseNumber(in_args[++index], maxOutputs); + hasMaxOutputs = true; + } else if (arg == "--shuffle") { shuffle = true; @@ -120,6 +256,22 @@ int RunScenarioMode(const std::vector& in_args) { scenario.frames = static_cast(frames); } + // Overrides apply for the whole run, including after the scenario's own parameter changes. + const auto applyOverrides = [&](ClusterParams& io_params) { + if (hasJoinAngle) + { + io_params.joinAngle = joinAngle; + } + if (hasMaxOutputs) + { + io_params.maxOutputs = maxOutputs; + } + }; + applyOverrides(scenario.params); + for (ParamChange& change : scenario.changes) + { + applyOverrides(change.params); + } const uint32_t orderSeed = seed ^ 0x0BADu; std::printf("scenario %s: %s\n", scenario.name.c_str(), scenario.description.c_str()); @@ -138,6 +290,13 @@ int RunScenarioMode(const std::vector& in_args) csv << "frame,inputs,passthrough,clusters,outputs,moves,new_clusters,max_member_angle,over_budget,update_us\n"; } + TraceWriter trace; + if (!tracePath.empty() && !trace.Open(tracePath, scenario)) + { + std::printf("cannot open '%s'\n", tracePath.c_str()); + return 1; + } + CountingAllocator allocator; ClusterEngine engine; engine.Init(&allocator, 256); @@ -190,8 +349,13 @@ int RunScenarioMode(const std::vector& in_args) { WriteCsvRow(csv, metrics); } + if (!tracePath.empty()) + { + trace.WriteFrame(view, player.GetParams()); + } } engine.Term(); + trace.Close(); if (!times.empty()) { diff --git a/core/tests/src/Scenarios.cpp b/core/tests/src/Scenarios.cpp index ea173c6..704377b 100644 --- a/core/tests/src/Scenarios.cpp +++ b/core/tests/src/Scenarios.cpp @@ -484,6 +484,116 @@ void BuildStress(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_k AddOneShots(io_scenario, io_keys, io_random, centres, 233.0, 3.0, 0.0); } +/** @brief Ship of the battle scenario, which its engine and weapon voices move with. */ +struct Ship +{ + Motion motion = Motion::Orbit; ///< Orbit around the listener or a straight fly-by. + Vec3 origin; ///< Position at frame zero. + Vec3 axis; ///< Unit rotation axis of an orbiting ship. + double degreesPerSecond = 0.0; ///< Rotation speed of an orbiting ship. + Vec3 velocity; ///< Velocity of a flying-by ship in units per second. +}; + +/** @brief Returns an offset in a random direction with a random length between two radii. */ +Vec3 RandomOffset(std::mt19937& io_random, double in_minRadius, double in_maxRadius) +{ + // Separate statements keep the order of the random draws the same on every compiler. + const Vec3 direction = RandomDirection(io_random); + return Scale(direction, Uniform(io_random, in_minRadius, in_maxRadius)); +} + +/** @brief Adds a voice at an offset from a ship's centre that moves with the ship. */ +SourceSpec& AddShipVoice(Scenario& io_scenario, KeySource& io_keys, const Ship& in_ship, const Vec3& in_offset) +{ + SourceSpec source; + source.key = io_keys.Next(); + source.motion = in_ship.motion; + source.axis = in_ship.axis; + source.degreesPerSecond = in_ship.degreesPerSecond; + source.velocity = in_ship.velocity; + source.origin = Add(in_ship.origin, in_offset); + io_scenario.sources.push_back(source); + return io_scenario.sources.back(); +} + +void BuildBattle(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) +{ + io_scenario.description = "14 ships orbiting and flying past the listener, each with engine voices and 2 to 6 weapons that " + "play 2 to 6 voices per shot, plus two ambience beds, for 8 s"; + io_scenario.frames = SecondsToFrames(io_scenario, 8.0); + // Plug-in default settings. + io_scenario.params.dwellFrames = static_cast(SecondsToFrames(io_scenario, 0.2)); + io_scenario.params.crossfadeFrames = static_cast(SecondsToFrames(io_scenario, 0.05)); + io_scenario.params.loudnessSmoothing = static_cast(std::exp(-io_scenario.frameSeconds / 0.05)); + const double seconds = io_scenario.frames * io_scenario.frameSeconds; + + for (int index = 0; index < 14; ++index) + { + Ship ship; + if (index % 3 == 2) + { + // Passes the listener at its closest distance at a random time during the scenario. + ship.motion = Motion::Linear; + const Vec3 travel = RandomDirection(io_random); + const Vec3 side = RotateAround(AnyPerpendicular(travel), travel, Uniform(io_random, 0.0, 2.0 * kPi)); + const Vec3 closest = Scale(side, Uniform(io_random, 10.0, 80.0)); + ship.velocity = Scale(travel, Uniform(io_random, 50.0, 180.0)); + ship.origin = Add(closest, Scale(ship.velocity, -Uniform(io_random, 1.0, seconds - 1.0))); + } + else + { + ship.motion = Motion::Orbit; + // Mostly around the horizon, like ships around the player. + const Vec3 direction = RandomDirection(io_random); + ship.origin = Scale(Normalize(Vec3{direction.x, direction.y * 0.4, direction.z}), LogUniform(io_random, 40.0, 800.0)); + const double tilt = ToRadians(Uniform(io_random, -20.0, 20.0)); + ship.axis = Normalize(Vec3{std::sin(tilt), std::cos(tilt), 0.0}); + const double speed = Uniform(io_random, 4.0, 25.0); + ship.degreesPerSecond = Uniform(io_random, 0.0, 1.0) < 0.5 ? -speed : speed; + } + + // Voices sit up to 8 units from the ship's centre, so a ship covers a wider angle the closer it is. + const int engines = UniformInt(io_random, 1, 3); + for (int engine = 0; engine < engines; ++engine) + { + SourceSpec& voice = AddShipVoice(io_scenario, io_keys, ship, RandomOffset(io_random, 0.0, 8.0)); + voice.loudness = static_cast(Uniform(io_random, 0.05, 0.3)); + voice.priority = static_cast(UniformInt(io_random, 30, 60)); + } + + // Each weapon fires at its own rate, and every shot plays several voices at the weapon's hardpoint. + const int weapons = UniformInt(io_random, 2, 6); + for (int weapon = 0; weapon < weapons; ++weapon) + { + const Vec3 hardpoint = RandomOffset(io_random, 2.0, 8.0); + const int voicesPerShot = UniformInt(io_random, 2, 6); + const double interval = Uniform(io_random, 0.4, 1.5); + for (double shot = Uniform(io_random, 0.0, interval); shot < seconds; shot += interval * Uniform(io_random, 0.8, 1.2)) + { + for (int layer = 0; layer < voicesPerShot; ++layer) + { + SourceSpec& voice = AddShipVoice(io_scenario, io_keys, ship, Add(hardpoint, RandomOffset(io_random, 0.0, 1.0))); + const double start = shot + Uniform(io_random, 0.0, 0.05); + voice.startFrame = static_cast(std::lround(start / io_scenario.frameSeconds)); + voice.endFrame = voice.startFrame + SecondsToFrames(io_scenario, Uniform(io_random, 0.15, 1.2)); + voice.loudness = static_cast(Uniform(io_random, 0.05, 0.9)); + voice.priority = static_cast(UniformInt(io_random, 20, 90)); + } + } + } + } + + for (int index = 0; index < 2; ++index) + { + SourceSpec bed; + bed.key = io_keys.Next(); + bed.clusterable = false; + bed.loudness = 0.1f; + bed.origin = Scale(RandomDirection(io_random), 30.0); + io_scenario.sources.push_back(bed); + } +} + struct ScenarioEntry { const char* name; @@ -494,6 +604,7 @@ const ScenarioEntry kScenarios[] = { {"static", 1001}, {"crossing", 1002}, {"oneshots", 1003}, {"jitter", 1004}, {"passby", 1005}, {"squeeze", 1006}, {"nearfield", 1007}, {"groups", 1008}, {"passthrough", 1009}, {"shuffle", 1010}, {"cloud", 1011}, {"stress", 1012}, + {"battle", 1013}, }; } @@ -582,6 +693,10 @@ bool BuildScenario(const std::string& in_name, uint32_t in_seed, Scenario& out_s { BuildStress(out_scenario, random, keys); } + else if (in_name == "battle") + { + BuildBattle(out_scenario, random, keys); + } else { return false; diff --git a/core/tests/viewer.html b/core/tests/viewer.html new file mode 100644 index 0000000..777f9ed --- /dev/null +++ b/core/tests/viewer.html @@ -0,0 +1,593 @@ + + + + + + +Spatial Clustering Before and After + + + +
+

Spatial Clustering: before and after

+ No trace loaded + + + +
+
+
+

Make a trace with the test program, then open it here or drop it on the page:

+

cluster_tests scenario battle --trace battle.json

+

Add --seed 42 (any number) for a different random battle.

+
+ + +
+
Drop the trace file
+ + + + + + + diff --git a/docs/building.md b/docs/building.md index 8f82a36..0e60130 100644 --- a/docs/building.md +++ b/docs/building.md @@ -45,7 +45,7 @@ The plug-in version is set in `wwise/SpatialClusteringVersion.h`. The bundle ver ## Testing without a game -The `core/tests` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 12 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough and more) and a benchmark, which also times an update that lowers Max Outputs. +The `core/tests` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 13 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough, a space battle and more) and a benchmark, which also times an update that lowers Max Outputs. ``` cmake -S core/tests -B build/tests @@ -59,6 +59,17 @@ Release\cluster_tests scenario oneshots --csv oneshots.csv Run `cluster_tests` with no arguments to see all modes and options. On Linux and macOS the program is in `build/tests` instead of `build/tests/Release`. +### Watching a scenario + +`--trace` writes every frame of a scenario to a JSON file, which `core/tests/viewer.html` plays back in a browser. Open the viewer and drop the trace on it. + +``` +Release\cluster_tests scenario battle --trace battle.json +Release\cluster_tests scenario battle --seed 42 --join-angle 10 --max-outputs 12 --trace battle.json +``` + +The viewer shows the scene from above twice, side by side: without clustering, where every voice needs its own audio object, and with Spatial Clustering. It counts the objects in both against an endpoint limit (128 by default, editable on the page), marks the voices that would fall back to the main mix without clustering, and sums up the peak, average and saving over the whole run. `--seed` picks a different random scene, and `--join-angle` and `--max-outputs` override the scenario's settings. Adding `#t=3.2` to the viewer's address opens it paused at that time. + ## Testing in a game Once the plug-in builds, you'll want a scene to exercise it in. A few options: From 04b2cae55b401a05ba601736b01e5d8c129655d0 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 12:55:02 +0000 Subject: [PATCH 14/26] make the release script build the authoring plugin without visual studio 2022 and drop empty packages --- docs/building.md | 8 +++++++- scripts/package.py | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/building.md b/docs/building.md index 0e60130..886588c 100644 --- a/docs/building.md +++ b/docs/building.md @@ -5,7 +5,7 @@ See Audiokinetic's [Creating Audio Plug-ins](https://www.audiokinetic.com/en/pub ## 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. +- 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 for vc170 and vc180, 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, and the authoring plug-in uses MFC. - Python 3, with the `markdown` and `jinja2` packages for the property help pages (`python -m pip install markdown jinja2`). @@ -24,6 +24,12 @@ python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" build Authoring -c Release -x x python "%WWISEROOT%\Scripts\Build\Plugins\wp.py" build Documentation ``` +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` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\SpatialClustering.dll` (dynamic). - Authoring plug-in: `%WWISEROOT%\Authoring\x64\Release\bin\Plugins\SpatialClustering.dll` and `SpatialClustering.xml`. diff --git a/scripts/package.py b/scripts/package.py index 823e4d6..48822e9 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -39,6 +39,7 @@ import shutil import subprocess import sys +import tarfile from pathlib import Path PLUGIN_NAME = "SpatialClustering" @@ -82,6 +83,31 @@ def run_wp(wp_py, *args): 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") @@ -113,7 +139,12 @@ def main(): for platform, toolset, configurations in BUILDS: run_wp(wp_py, "premake", platform, "-t", toolset) for configuration in configurations: - run_wp(wp_py, "build", platform, "-c", configuration, "-x", "x64", "-t", toolset) + 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. @@ -122,6 +153,13 @@ def main(): 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}" From caf53d8a17b0fe4490d660d56c861b6115e837b0 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 12:55:12 +0000 Subject: [PATCH 15/26] ignore the visual studio 2026 solutions that premake generates --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 2ec055f..0c14250 100644 --- a/.gitignore +++ b/.gitignore @@ -480,6 +480,8 @@ ASALocalRun/ # 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 From 75a577fc47a285dc5eb3b4bdd576ff0a7e0c930f Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Thu, 15 Oct 2026 13:17:03 +0000 Subject: [PATCH 16/26] fold the docs back into the readme and drop the changelog file --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CHANGELOG.md | 34 ----- CONTRIBUTING.md | 5 +- README.md | 219 +++++++++++++++++++++++++++++-- docs/best-practices.md | 21 --- docs/building.md | 90 ------------- docs/how-it-works.md | 61 --------- docs/parameters.md | 22 ---- scripts/package.py | 2 +- wwise/bundle_template.json | 2 +- 10 files changed, 210 insertions(+), 248 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 docs/best-practices.md delete mode 100644 docs/building.md delete mode 100644 docs/how-it-works.md delete mode 100644 docs/parameters.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9b1d519..70ac772 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,4 +7,4 @@ - [ ] 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`, `docs/parameters.md` and `CHANGELOG.md` are updated. +- [ ] If a parameter changed: `SpatialClustering.xml`, the help page in `wwise/WwisePlugin/res/Md/en`, and the Parameters table in `README.md` are updated. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 51733bd..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,34 +0,0 @@ -# Changelog - -## 3.0.0 (unreleased) - -Built for Wwise 2025.1. - -### Changed -- The plug-in is now called **Spatial Clustering** (`SpatialClustering`); it was ObjectCluster. The DLL, static library, factory header and Launcher bundle use the new name. -- New clustering engine. Inputs are grouped by their direction from the listener into clusters that persist from frame to frame. Moves between clusters are crossfaded, and an optional output budget merges the closest clusters. Multichannel and 2D inputs pass through unchanged, and high-priority and near-field inputs can be kept out of clusters. See [How it works](docs/how-it-works.md). -- The Distance Threshold parameter is replaced by Join Angle, Leave Ratio, Dwell Time, Crossfade Time, Max Outputs, Near-Field Radius, Distance Weight, Exclude Priority Above, Spread From Cluster Size and Loudness Weighting. See [Parameters](docs/parameters.md). - -### Added -- Release bundle for the Wwise Launcher. -- Help for every parameter in the Wwise property editor. -- Version information in the authoring plug-in DLL. -- A standalone program in `core/tests` with tests, scenarios and a benchmark for the clustering engine, and a browser viewer that plays back a scenario frame by frame. - -### Upgrading from 2.0.0 or earlier -- Remove the old plug-in from your Wwise install before installing this one, since both use the same plug-in IDs. Uninstall it in the Wwise Launcher if you installed it there. Otherwise 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. -- The plug-in keeps its company and plug-in IDs (64 and 6973), so Wwise projects refer to it the same way. The parameters are new, so check the settings of every Spatial Clustering effect after upgrading. -- 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`. Then regenerate your SoundBanks. - -## 2.0.0 (2026-05-21) - -- Upgrade to the Wwise 2025.1 SDK, with minor fixes. -- Update the Apache License 2.0 format. - -## 1.0.1 (2025-07-11) - -- Fix a crash with the Wwise 2023 SDK by removing unnecessary copying of metadata while processing objects that are not clustered. - -## 1.0.0 (2025-03-06) - -- First release of the plug-in, as ObjectCluster. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1479805..f74f49f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ Contributions are accepted under the [Apache License 2.0](LICENSE.txt), the lice ## Building and testing your changes -[Building from source](docs/building.md) covers the toolchain, the plug-in build, the test program and ways to try the plug-in in a game. Changes to the clustering itself can be tested first with the test program in `core/tests`, which does not need the Wwise SDK. +[Building from Source](README.md#building-from-source) in the README covers the toolchain, the plug-in build, the test program and ways to try the plug-in in a game. Changes to the clustering itself can be tested first with the test program in `core/tests`, which does not need the Wwise SDK. ## Where things are @@ -34,6 +34,5 @@ Contributions are accepted under the [Apache License 2.0](LICENSE.txt), the lice | `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 `docs/parameters.md`. The order in which `GetBankParameters` writes the values must match the order in which `SpatialClusteringFXParams` reads them. +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 Parameters table in `README.md`. The order in which `GetBankParameters` writes the values must match the order in which `SpatialClusteringFXParams` reads them. diff --git a/README.md b/README.md index e0e2301..a999592 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Spatial Clustering is a Wwise plug-in that merges audio objects that reach the l - **Output budget**: an optional limit on the number of outputs. When the limit is reached, the closest clusters are merged. - **Passthrough**: multichannel inputs (ambiences, beds) and inputs without 3D spatialization pass through with their audio and metadata unchanged. - **Exclusions**: high priority inputs and inputs close to the listener can be kept out of clusters. -- **Real-time safe**: memory comes from the Wwise plug-in allocator, and an update for 512 inputs takes about 30 to 60 µs. See [Performance](docs/how-it-works.md#performance). +- **Real-time safe**: memory comes from the Wwise plug-in allocator, and an update for 512 inputs takes about 30 to 60 µs. See [Performance](#performance). ## Perfect For @@ -55,18 +55,18 @@ Same scene with plugin enabled: 2. Extract the zip. 3. In the Wwise Launcher, open **Plug-ins**, choose **Add from directory** and select the `bundle` folder from the zip. Then install Spatial Clustering into your Wwise install. -Upgrading from 2.0.0 or earlier? Remove the old plug-in first, as described in the [changelog](CHANGELOG.md#upgrading-from-200-or-earlier). +Upgrading from 2.0.0 or earlier? Uninstall the old version first, since both versions use the same plug-in IDs. The 3.0.0 release notes list the renamed files. -No release for your Wwise version? [Build it from source](docs/building.md). +No release for your Wwise version? [Build it from source](#building-from-source). -## Quick start +## Quick Start 1. Set the channel configuration of a bus to **Audio Objects**. 2. In the bus's **Effects** tab, add the **Spatial Clustering** effect. 3. Route the sounds you want merged to that bus. Mono sounds with 3D spatialization are clustered; everything else passes through. -4. Start with the default settings, then look at the clusters in the [profiler views](docs/best-practices.md#profiling) and tune the [parameters](docs/parameters.md). +4. Start with the default settings, then look at the clusters in the [profiler views](#profiling) and tune the [parameters](#parameters). -## Use it in your game +## 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. @@ -80,16 +80,207 @@ The runtime plug-in must come from the same Wwise version as your game, since th |---|---|---|---| | 3.0.0 | 2025.1 | Windows x64 | Windows x64 (Visual Studio 2026) | -Other platforms are not released yet. Building for them needs the matching Wwise deployment platform; see [Building from source](docs/building.md). +Other platforms are not released yet. Building for them needs the matching Wwise deployment platform; see [Building from Source](#building-from-source). -## Documentation +## How It Works -- [How it works](docs/how-it-works.md): inputs, clusters, merging, the output budget and performance. -- [Parameters](docs/parameters.md): ranges, defaults and what each one does. -- [Best practices](docs/best-practices.md): bus setup and profiling. -- [Building from source](docs/building.md): build, package a release, run the tests, debug. -- [Changelog](CHANGELOG.md) -- [Contributing](CONTRIBUTING.md) +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 is mono, uses 3D spatialization (Position or Position + Orientation) and has Speaker Panning / 3D Spatialization Mix at 100. +- Every other input passes through: it gets its own output with the same channel configuration, audio, positioning, custom metadata and priority. This covers stereo and multichannel ambiences, beds, 2D sounds and partly spatialized sounds. +- Inputs only share a cluster when they carry the same set of custom metadata plug-in types; a cluster output carries the custom metadata of its representative input. + +### 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 Weight** 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 **Join Angle**. + +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. + +### Joining and leaving + +- A new input joins the nearest cluster within the **Join Angle**. If there is none, it starts a new cluster. +- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within **Join Angle** × **Leave Ratio**. 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 **Join Angle** × **Leave Ratio** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. +- A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster. +- 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 never loses it. + +### 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, so the input keeps its level during the move. + +A switch between clustered and passthrough, for example when an RTPC moves Speaker Panning / 3D Spatialization Mix below 100, happens without a crossfade. + +### Merging + +Two clusters that stay within the **Join Angle** of each other for the **Dwell Time** merge. The lighter cluster fades into the other one; with **Loudness Weighting** on, the lighter cluster is the quieter one, otherwise it is the one with fewer inputs. A merge only happens if the merged cluster would still fit within the **Join Angle**. + +### Output budget + +With **Max Outputs** 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 dwell time. A higher priority makes a pair count as farther apart, so it merges later. + +- At the limit, a new input that is farther than **Join Angle** × **Leave Ratio** 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 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. + +### Cluster outputs + +- **Position**: the average direction of the contributing inputs, at their average distance. With **Loudness Weighting** on, each input counts by its RMS level, smoothed over about 50 ms. +- **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 spread of the representative input (the loudest one when **Loudness Weighting** is on, otherwise the first input at full gain) and **Spread From Cluster Size** × (cluster angular radius / 180°). +- **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 **Exclude Priority Above**, and inputs closer to the listener than **Near-Field Radius**, keep an output of their own and are never merged. An input that becomes excluded while it is in a cluster leaves it after the Dwell Time, with a crossfade. + +### Performance + +Memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs in the [benchmark](#testing-without-a-game). Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs in the same benchmark. + +## Parameters + +All parameters except Loudness Weighting can be driven by RTPCs. The same descriptions are in the help panel of the Wwise property editor. + +| Parameter | Range | Default | Description | +|---|---|---|---| +| Join Angle (degrees) | 0 to 180 | 5 | Largest angle between an input and a cluster for the input to join it. 0 turns off merging by angle. | +| Leave Ratio (x Join Angle) | 1 to 4 | 1.5 | Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. | +| Dwell 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. | +| Crossfade Time (ms) | 0 to 1000 | 50 | Time over which a moving input fades between clusters. | +| Max Outputs (0 = no limit) | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | +| Near-Field 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. | +| Distance Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | +| Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | +| Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | +| Loudness Weighting | on or off | on | Moves each cluster toward its louder inputs. | + +Dwell 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 Join Angle of 180 with Max Outputs at 1 mixes all clusterable inputs into one object, as long as no passthrough or excluded inputs need outputs of their own and all inputs carry the same custom metadata. A Join Angle of 0 with a Max Outputs limit only merges when the limit is reached. + +## Best Practices + +### Bus Setup + +- Insert the plug-in as an Effect on an Audio Objects bus (bus channel configuration set to Audio Objects). It cannot be inserted on sounds or containers and cannot be used for offline rendering. +- Use it on busses that carry many mono one-shots that are expensive to render as objects, such as weapon fire, impacts and debris. +- Multichannel inputs such as ambience loops always pass through untouched. If you set Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. +- Use Near-Field Radius to keep sounds close to the listener separate. +- Use separate busses with their own settings for different categories, for example a smaller Join Angle for dense sources (turret fire) and a larger one for spread-out sounds (shields). + +![Spatial Clustering inserted on an Audio Objects bus in Wwise](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. +- 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 for a visual spatial representation of the clusters. +- 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 Max Outputs allows, when it cannot create output objects and when it runs out of memory. Repeated messages of the same kind are posted at most once every 5 seconds. + +![Clusters in the Wwise Audio Object profiler views](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) + +## 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 (SDK install, environment variables, compiler requirements). 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 for vc170 and vc180, 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, and the authoring plug-in uses MFC. +- 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 Profile -x x64 -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 +``` + +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` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\SpatialClustering.dll` (dynamic). +- 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 `\\\\\`. + +### Package a Release + +`scripts/package.py` builds everything above (Debug, Profile and Release for the runtime plug-in) and packages it as a Wwise Launcher bundle. Run it from the repository root, in the same kind of command prompt: + +``` +python scripts\package.py +``` + +It writes `dist\SpatialClustering__Wwise.\` with the bundle and the license files, and a zip of that folder for the GitHub release. `--skip-build` packages what is already built. + +The plug-in version is set in `wwise/SpatialClusteringVersion.h`. 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. + +### Testing Without a Game + +The `core/tests` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 13 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough, a space battle and more) and a benchmark, which also times an update that lowers Max Outputs. + +``` +cmake -S core/tests -B build/tests +cmake --build build/tests --config Release +cd build/tests +ctest -C Release +Release\cluster_tests bench +Release\cluster_tests list +Release\cluster_tests scenario oneshots --csv oneshots.csv +``` + +Run `cluster_tests` with no arguments to see all modes and options. On Linux and macOS the program is in `build/tests` instead of `build/tests/Release`. + +#### Watching a Scenario + +`--trace` writes every frame of a scenario to a JSON file, which `core/tests/viewer.html` plays back in a browser. Open the viewer and drop the trace on it. + +``` +Release\cluster_tests scenario battle --trace battle.json +Release\cluster_tests scenario battle --seed 42 --join-angle 10 --max-outputs 12 --trace battle.json +``` + +The viewer shows the scene from above twice, side by side: without clustering, where every voice needs its own audio object, and with Spatial Clustering. It counts the objects in both against an endpoint limit (128 by default, editable on the page), marks the voices that would fall back to the main mix without clustering, and sums up the peak, average and saving over the whole run. `--seed` picks a different random scene, and `--join-angle` and `--max-outputs` override the scenario's settings. Adding `#t=3.2` to the viewer's address opens it paused at that time. + +### 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 yet ship a dedicated sample scene for spatial audio testing. That's on the list to add. + +### Debugging + +To place breakpoints and debug the plug-in, follow the steps [here](https://www.audiokinetic.com/qa/7840/wwise-sdk-how-step-through-code-using-visual-studio-debugger). + +## Contributing + +We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for how to open issues and pull requests. ## License and Legal Notices diff --git a/docs/best-practices.md b/docs/best-practices.md deleted file mode 100644 index ba13ee6..0000000 --- a/docs/best-practices.md +++ /dev/null @@ -1,21 +0,0 @@ -# Best practices - -## Bus setup - -- Insert the plug-in as an Effect on an Audio Objects bus (bus channel configuration set to Audio Objects). It cannot be inserted on sounds or containers and cannot be used for offline rendering. -- Use it on busses that carry many mono one-shots that are expensive to render as objects, such as weapon fire, impacts and debris. -- Multichannel inputs such as ambience loops always pass through untouched. If you set Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. -- Use Near-Field Radius to keep sounds close to the listener separate. -- Use separate busses with their own settings for different categories, for example a smaller Join Angle for dense sources (turret fire) and a larger one for spread-out sounds (shields). - -![Spatial Clustering inserted on an Audio Objects bus in Wwise](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. -- 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 for a visual spatial representation of the clusters. -- 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 Max Outputs allows, when it cannot create output objects and when it runs out of memory. Repeated messages of the same kind are posted at most once every 5 seconds. - -![Clusters in the Wwise Audio Object profiler views](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) diff --git a/docs/building.md b/docs/building.md deleted file mode 100644 index 886588c..0000000 --- a/docs/building.md +++ /dev/null @@ -1,90 +0,0 @@ -# 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 (SDK install, environment variables, compiler requirements). 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 for vc170 and vc180, 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, and the authoring plug-in uses MFC. -- 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 Profile -x x64 -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 -``` - -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` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\SpatialClustering.dll` (dynamic). -- 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 `\\\\\`. - -## Package a release - -`scripts/package.py` builds everything above (Debug, Profile and Release for the runtime plug-in) and packages it as a Wwise Launcher bundle. Run it from the repository root, in the same kind of command prompt: - -``` -python scripts\package.py -``` - -It writes `dist\SpatialClustering__Wwise.\` with the bundle, the license files and the changelog, and a zip of that folder for the GitHub release. `--skip-build` packages what is already built. - -The plug-in version is set in `wwise/SpatialClusteringVersion.h`. 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. - -## Testing without a game - -The `core/tests` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 13 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough, a space battle and more) and a benchmark, which also times an update that lowers Max Outputs. - -``` -cmake -S core/tests -B build/tests -cmake --build build/tests --config Release -cd build/tests -ctest -C Release -Release\cluster_tests bench -Release\cluster_tests list -Release\cluster_tests scenario oneshots --csv oneshots.csv -``` - -Run `cluster_tests` with no arguments to see all modes and options. On Linux and macOS the program is in `build/tests` instead of `build/tests/Release`. - -### Watching a scenario - -`--trace` writes every frame of a scenario to a JSON file, which `core/tests/viewer.html` plays back in a browser. Open the viewer and drop the trace on it. - -``` -Release\cluster_tests scenario battle --trace battle.json -Release\cluster_tests scenario battle --seed 42 --join-angle 10 --max-outputs 12 --trace battle.json -``` - -The viewer shows the scene from above twice, side by side: without clustering, where every voice needs its own audio object, and with Spatial Clustering. It counts the objects in both against an endpoint limit (128 by default, editable on the page), marks the voices that would fall back to the main mix without clustering, and sums up the peak, average and saving over the whole run. `--seed` picks a different random scene, and `--join-angle` and `--max-outputs` override the scenario's settings. Adding `#t=3.2` to the viewer's address opens it paused at that time. - -## 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 yet ship a dedicated sample scene for spatial audio testing. That's on the list to add. - -## Debugging - -To place breakpoints and debug the plug-in, follow the steps [here](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 deleted file mode 100644 index b8105f8..0000000 --- a/docs/how-it-works.md +++ /dev/null @@ -1,61 +0,0 @@ -# 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 is mono, uses 3D spatialization (Position or Position + Orientation) and has Speaker Panning / 3D Spatialization Mix at 100. -- Every other input passes through: it gets its own output with the same channel configuration, audio, positioning, custom metadata and priority. This covers stereo and multichannel ambiences, beds, 2D sounds and partly spatialized sounds. -- Inputs only share a cluster when they carry the same set of custom metadata plug-in types; a cluster output carries the custom metadata of its representative input. - -## 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 Weight** 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 **Join Angle**. - -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. - -## Joining and leaving - -- A new input joins the nearest cluster within the **Join Angle**. If there is none, it starts a new cluster. -- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within **Join Angle** × **Leave Ratio**. 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 **Join Angle** × **Leave Ratio** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. -- A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster. -- 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 never loses it. - -## 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, so the input keeps its level during the move. - -A switch between clustered and passthrough, for example when an RTPC moves Speaker Panning / 3D Spatialization Mix below 100, happens without a crossfade. - -## Merging - -Two clusters that stay within the **Join Angle** of each other for the **Dwell Time** merge. The lighter cluster fades into the other one; with **Loudness Weighting** on, the lighter cluster is the quieter one, otherwise it is the one with fewer inputs. A merge only happens if the merged cluster would still fit within the **Join Angle**. - -## Output budget - -With **Max Outputs** 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 dwell time. A higher priority makes a pair count as farther apart, so it merges later. - -- At the limit, a new input that is farther than **Join Angle** × **Leave Ratio** 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 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. - -## Cluster outputs - -- **Position**: the average direction of the contributing inputs, at their average distance. With **Loudness Weighting** on, each input counts by its RMS level, smoothed over about 50 ms. -- **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 spread of the representative input (the loudest one when **Loudness Weighting** is on, otherwise the first input at full gain) and **Spread From Cluster Size** × (cluster angular radius / 180°). -- **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 **Exclude Priority Above**, and inputs closer to the listener than **Near-Field Radius**, keep an output of their own and are never merged. An input that becomes excluded while it is in a cluster leaves it after the Dwell Time, with a crossfade. - -## Performance - -Memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs in the [benchmark](building.md#testing-without-a-game). Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs in the same benchmark. diff --git a/docs/parameters.md b/docs/parameters.md deleted file mode 100644 index 795d264..0000000 --- a/docs/parameters.md +++ /dev/null @@ -1,22 +0,0 @@ -# Parameters - -All parameters except Loudness Weighting can be driven by RTPCs. The same descriptions are in the help panel of the Wwise property editor. - -| Parameter | Range | Default | Description | -|---|---|---|---| -| Join Angle (degrees) | 0 to 180 | 5 | Largest angle between an input and a cluster for the input to join it. 0 turns off merging by angle. | -| Leave Ratio (x Join Angle) | 1 to 4 | 1.5 | Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. | -| Dwell 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. | -| Crossfade Time (ms) | 0 to 1000 | 50 | Time over which a moving input fades between clusters. | -| Max Outputs (0 = no limit) | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | -| Near-Field 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. | -| Distance Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | -| Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | -| Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | -| Loudness Weighting | on or off | on | Moves each cluster toward its louder inputs. | - -Dwell 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 Join Angle of 180 with Max Outputs at 1 mixes all clusterable inputs into one object, as long as no passthrough or excluded inputs need outputs of their own and all inputs carry the same custom metadata. A Join Angle of 0 with a Max Outputs limit only merges when the limit is reached. - -See [How it works](how-it-works.md) for what each parameter does to the clusters. diff --git a/scripts/package.py b/scripts/package.py index 48822e9..7975b95 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -55,7 +55,7 @@ ("Authoring", "vc170", ["Release"]), ] PACKAGES = ["Common", "Windows_vc180", "Authoring", "Documentation"] -EXTRA_FILES = ["LICENSE.txt", "NOTICE", "CHANGELOG.md"] +EXTRA_FILES = ["LICENSE.txt", "NOTICE"] def fail(message): diff --git a/wwise/bundle_template.json b/wwise/bundle_template.json index eb1f64c..09a76a4 100644 --- a/wwise/bundle_template.json +++ b/wwise/bundle_template.json @@ -65,7 +65,7 @@ "url": "https://github.com/carbonengine/spatial-audio-clustering#readme" }, { - "displayName": "Releases and changelog", + "displayName": "Release notes", "id": "releases", "url": "https://github.com/carbonengine/spatial-audio-clustering/releases" } From 476754b8b670177877aa77f1751cf3541cfff508 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 08:53:09 +0000 Subject: [PATCH 17/26] add a cmake build for the runtime plug-in package and the authoring plug-in --- CMakeLists.txt | 163 ++++++++++++++++++++++++++++ README.md | 15 +++ cmake/SpatialClusteringConfig.cmake | 8 ++ 3 files changed, 186 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 cmake/SpatialClusteringConfig.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..285409e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,163 @@ +# 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) + +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>) + +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) + +# 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) + + # 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() diff --git a/README.md b/README.md index a999592..bbe9bbe 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ The runtime plug-in must come from the same Wwise version as your game, since th - **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`. +- **CMake**: build the plug-in from source with your game, see [Build with CMake](#build-with-cmake). Link `SpatialClustering::SpatialClusteringFX` from `find_package(SpatialClustering CONFIG)` 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. ## Compatibility @@ -226,6 +227,20 @@ The build copies its outputs into the Wwise install: 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 `\\\\\`. +### 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 takes the Wwise SDK from a `Wwise` CMake package, such as the one a vcpkg port provides, or else from `WWISE_SDK_DIR`, which defaults to the `WWISESDK` environment variable. The Release configuration defines `AK_OPTIMIZED`, like the Release sound engine libraries. + +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: + +``` +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 first with `wp.py build Documentation`. + ### Package a Release `scripts/package.py` builds everything above (Debug, Profile and Release for the runtime plug-in) and packages it as a Wwise Launcher bundle. Run it from the repository root, in the same kind of command prompt: 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) From e915b40c069f0849e99fe95e0f2d7eb503f61718 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 09:42:10 +0000 Subject: [PATCH 18/26] cluster stereo inputs by mixing them down to the mono cluster output --- README.md | 17 ++++---- .../SoundEnginePlugin/SpatialClusteringFX.cpp | 42 +++++++++++++++---- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index bbe9bbe..04bd7d0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Spatial Clustering is a Wwise plug-in that merges audio objects that reach the l - **Persistent clusters**: an input stays in its cluster from frame to frame and only moves when it has been outside the cluster for a set time. - **Crossfaded moves**: an input that changes cluster fades out of the old cluster and into the new one, so there are no clicks or jumps. - **Output budget**: an optional limit on the number of outputs. When the limit is reached, the closest clusters are merged. -- **Passthrough**: multichannel inputs (ambiences, beds) and inputs without 3D spatialization pass through with their audio and metadata unchanged. +- **Passthrough**: inputs with more than two channels (ambiences, beds) and inputs without 3D spatialization pass through with their audio and metadata unchanged. - **Exclusions**: high priority inputs and inputs close to the listener can be kept out of clusters. - **Real-time safe**: memory comes from the Wwise plug-in allocator, and an update for 512 inputs takes about 30 to 60 µs. See [Performance](#performance). @@ -19,8 +19,8 @@ Spatial Clustering is a Wwise plug-in that merges audio objects that reach the l - Large-scale battle scenes with many weapon, impact and explosion one-shots - Debris and particle-driven one-shots -- Crowds of mono sources such as footsteps or voices -- Vehicle and machinery sounds with multiple mono components +- Crowds of sources such as footsteps or voices +- Vehicle and machinery sounds with multiple components ## Why We Built This @@ -63,7 +63,7 @@ No release for your Wwise version? [Build it from source](#building-from-source) 1. Set the channel configuration of a bus to **Audio Objects**. 2. In the bus's **Effects** tab, add the **Spatial Clustering** effect. -3. Route the sounds you want merged to that bus. Mono sounds with 3D spatialization are clustered; everything else passes through. +3. Route the sounds you want merged to that bus. Mono and stereo sounds with 3D spatialization are clustered; everything else passes through. 4. Start with the default settings, then look at the clusters in the [profiler views](#profiling) and tune the [parameters](#parameters). ## Use It in Your Game @@ -91,8 +91,9 @@ Every audio frame the plug-in reads all audio objects on its bus, updates the cl ### Inputs -- An input can be clustered when it is mono, uses 3D spatialization (Position or Position + Orientation) and has Speaker Panning / 3D Spatialization Mix at 100. -- Every other input passes through: it gets its own output with the same channel configuration, audio, positioning, custom metadata and priority. This covers stereo and multichannel ambiences, beds, 2D sounds and partly spatialized sounds. +- An input can be clustered when it is mono or stereo, 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, and its spread carries over to the cluster output (see [Cluster outputs](#cluster-outputs)). +- 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; a cluster output carries the custom metadata of its representative input. ### Direction from the listener @@ -171,8 +172,8 @@ The same controls cover the whole range: a Join Angle of 180 with Max Outputs at ### Bus Setup - Insert the plug-in as an Effect on an Audio Objects bus (bus channel configuration set to Audio Objects). It cannot be inserted on sounds or containers and cannot be used for offline rendering. -- Use it on busses that carry many mono one-shots that are expensive to render as objects, such as weapon fire, impacts and debris. -- Multichannel inputs such as ambience loops always pass through untouched. If you set Max Outputs, route ambiences to another bus, since passthrough outputs count toward the limit. +- 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 Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. - Use Near-Field Radius to keep sounds close to the listener separate. - Use separate busses with their own settings for different categories, for example a smaller Join Angle for dense sources (turret fire) and a larger one for spread-out sounds (shields). diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp index f31e428..e5bb449 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp @@ -103,27 +103,47 @@ namespace return static_cast(in_fMilliseconds * 0.001f / in_fFrameSeconds + 0.5f); } - /** @brief Returns true for mono inputs that are fully spatialized in 3D. */ + /** @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; - return in_buffer.NumChannels() == 1 + 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 RMS level of the first channel over the valid frames. */ + /** + * @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; - if (uFrames == 0) + const AkUInt32 uNumChannels = in_buffer.NumChannels(); + if (uFrames == 0 || uNumChannels == 0) return 0.f; - const AkSampleType* pSamples = in_buffer.GetChannel(0); + const AkReal32 fGain = DownmixGain(uNumChannels); AkReal32 fSum = 0.f; for (AkUInt32 i = 0; i < uFrames; ++i) - fSum += pSamples[i] * pSamples[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; @@ -481,10 +501,10 @@ bool SpatialClusteringFX::PrepareInputs(const AkAudioObjects& in_objects) ClusterInput& input = m_inputs[i]; input.key = object.key; input.position = ClusterVec3{ position.X, position.Y, position.Z }; - input.loudness = (bLoudnessWeighting && buffer.NumChannels() == 1) ? ComputeRms(buffer) : 0.f; + input.clusterable = IsClusterable(object, buffer); + input.loudness = (bLoudnessWeighting && input.clusterable) ? ComputeRms(buffer) : 0.f; input.priority = static_cast(Clamp(static_cast(m_inputPriorities[i]), 0, PARAM_PRIORITY_MAX)); input.group = HashCustomMetadata(object); - input.clusterable = IsClusterable(object, buffer); } return true; @@ -797,7 +817,11 @@ void SpatialClusteringFX::ProcessClusters(const AkAudioObjects& in_objects) AkAudioBuffer* pOutBuffer = m_outputBuffers[uOutput]; const AkUInt16 uFrames = AkMin(pInBuffer->uValidFrames, pOutBuffer->MaxFrames()); - MixRamp(pInBuffer->GetChannel(0), pOutBuffer->GetChannel(0), uFrames, contribution.gainStart, contribution.gainEnd); + 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) From d9e6041bcc1f3923bcc4f73e30b0cf415536b84d Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 10:47:24 +0000 Subject: [PATCH 19/26] default the join angle to 15 degrees --- README.md | 2 +- core/src/ClusterEngine.h | 2 +- core/tests/src/Tests.cpp | 6 +++++- wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp | 2 +- wwise/WwisePlugin/SpatialClustering.xml | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 04bd7d0..d33b683 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ All parameters except Loudness Weighting can be driven by RTPCs. The same descri | Parameter | Range | Default | Description | |---|---|---|---| -| Join Angle (degrees) | 0 to 180 | 5 | Largest angle between an input and a cluster for the input to join it. 0 turns off merging by angle. | +| Join Angle (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. | | Leave Ratio (x Join Angle) | 1 to 4 | 1.5 | Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. | | Dwell 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. | | Crossfade Time (ms) | 0 to 1000 | 50 | Time over which a moving input fades between clusters. | diff --git a/core/src/ClusterEngine.h b/core/src/ClusterEngine.h index 3100c08..e967166 100644 --- a/core/src/ClusterEngine.h +++ b/core/src/ClusterEngine.h @@ -50,7 +50,7 @@ class ClusterAllocator /** @brief Settings for one cluster engine update. */ struct ClusterParams { - float joinAngle = 5.f; ///< Angle in degrees under which an input may join a cluster. + 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. diff --git a/core/tests/src/Tests.cpp b/core/tests/src/Tests.cpp index 7b40764..3d4b5f5 100644 --- a/core/tests/src/Tests.cpp +++ b/core/tests/src/Tests.cpp @@ -698,7 +698,9 @@ ProbeResult RunJoinProbe(TestContext& io_context, const ClusterParams& in_settle void TestJoinsWithinJoinAngle(TestContext& io_context) { - const ClusterParams params; + // The probes below are laid out around a 5 degree join angle. + ClusterParams params; + params.joinAngle = 5.f; ClusterParams budget = params; budget.maxOutputs = 1; ClusterParams distance = params; @@ -1316,6 +1318,7 @@ void TestCrossfadeRamp(TestContext& io_context) for (uint32_t fade : {4u, 1u, 0u}) { ClusterParams params; + params.joinAngle = 5.f; params.dwellFrames = 4; params.crossfadeFrames = fade; TestEngine engine(64, &io_context, "crossfade"); @@ -1626,6 +1629,7 @@ void TestCentredInputsShare(TestContext& io_context) void TestBudgetFarInputGetsOwnCluster(TestContext& io_context) { ClusterParams params; + params.joinAngle = 5.f; params.maxOutputs = 3; TestEngine engine(64, &io_context, "budget-far"); std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 6.0, 10.0), MakeInput(3, 12.0, 10.0)}; diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp index 12c53fa..797e8c8 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp @@ -34,7 +34,7 @@ the specific language governing permissions and limitations under the License. namespace { /** @brief Default values of the parameters that RTPCs can drive. */ - const SpatialClusteringRTPCParams kDefaultRTPCParams = { 5.f, 1.5f, 200.f, 50.f, 0, 0.f, 0.f, 100, 1.f }; + const SpatialClusteringRTPCParams kDefaultRTPCParams = { 15.f, 1.5f, 200.f, 50.f, 0, 0.f, 0.f, 100, 1.f }; /** @brief Default value of the loudness weighting parameter. */ const bool kDefaultLoudnessWeighting = true; diff --git a/wwise/WwisePlugin/SpatialClustering.xml b/wwise/WwisePlugin/SpatialClustering.xml index dc1e65e..8801b95 100644 --- a/wwise/WwisePlugin/SpatialClustering.xml +++ b/wwise/WwisePlugin/SpatialClustering.xml @@ -44,7 +44,7 @@ for the documentation about the plugin xml format - 5 + 15 0 From 152134c7cd85d16a40a3de56257ac590ab28102a Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 11:30:44 +0000 Subject: [PATCH 20/26] drop the leave ratio and loudness weighting parameters and group the rest in the property editor --- README.md | 41 +++++---- .../SoundEnginePlugin/SpatialClusteringFX.cpp | 6 +- .../SpatialClusteringFXParams.cpp | 39 ++------- .../SpatialClusteringFXParams.h | 51 ++++------- wwise/WwisePlugin/SpatialClustering.xml | 85 +++++++------------ wwise/WwisePlugin/SpatialClusteringPlugin.cpp | 10 +-- wwise/WwisePlugin/res/Md/en/leaveRatio.md | 9 -- .../res/Md/en/loudnessWeighting.md | 7 -- 8 files changed, 82 insertions(+), 166 deletions(-) delete mode 100644 wwise/WwisePlugin/res/Md/en/leaveRatio.md delete mode 100644 wwise/WwisePlugin/res/Md/en/loudnessWeighting.md diff --git a/README.md b/README.md index d33b683..2783bcb 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Most spatial audio endpoints (Dolby, Sonic, DTS) offer up to 128 concurrent spat We developed this plugin to mix nearby sources into a single output positioned at their average direction. 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. ### 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 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. 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 @@ -47,6 +47,7 @@ Scene with 20 ships firing at the player: Same scene with plugin enabled: - System audio object consumption is drastically reduced (20-24 objects) - Clear, distinct spatial positioning for each cluster +- More accurate positioning overall: without the plug-in, objects over the endpoint's budget are mixed into the bed and lose their exact position. With it, every cluster is a system audio object at its own position. A crowded scene can sound less wide than without the plug-in, because the overflow no longer smears into the bed, but each sound is placed more precisely. - More resources for sound designers to add unique locations of spatialized objects ## Install @@ -105,8 +106,8 @@ Inputs at the listener position (closer than 0.0001 game units), such as sounds ### Joining and leaving - A new input joins the nearest cluster within the **Join Angle**. If there is none, it starts a new cluster. -- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within **Join Angle** × **Leave Ratio**. 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 **Join Angle** × **Leave Ratio** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. +- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within 1.5 × the **Join Angle**. 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 **Join Angle** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. - A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster. - 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 never loses it. @@ -119,21 +120,21 @@ A switch between clustered and passthrough, for example when an RTPC moves Speak ### Merging -Two clusters that stay within the **Join Angle** of each other for the **Dwell Time** merge. The lighter cluster fades into the other one; with **Loudness Weighting** on, the lighter cluster is the quieter one, otherwise it is the one with fewer inputs. A merge only happens if the merged cluster would still fit within the **Join Angle**. +Two clusters that stay within the **Join Angle** of each other for the **Dwell Time** merge. The quieter cluster fades into the louder one. A merge only happens if the merged cluster would still fit within the **Join Angle**. ### Output budget With **Max Outputs** 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 dwell time. A higher priority makes a pair count as farther apart, so it merges later. -- At the limit, a new input that is farther than **Join Angle** × **Leave Ratio** 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. +- At the limit, a new input that is farther than 1.5 × the **Join Angle** 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 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. ### Cluster outputs -- **Position**: the average direction of the contributing inputs, at their average distance. With **Loudness Weighting** on, each input counts by its RMS level, smoothed over about 50 ms. +- **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 spread of the representative input (the loudest one when **Loudness Weighting** is on, otherwise the first input at full gain) and **Spread From Cluster Size** × (cluster angular radius / 180°). +- **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 spread of the representative input (the loudest one) and **Spread From Cluster Size** × (cluster angular radius / 180°). - **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. @@ -148,20 +149,18 @@ Memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in I ## Parameters -All parameters except Loudness Weighting can be driven by RTPCs. The same descriptions are in the help panel of the Wwise property editor. - -| Parameter | Range | Default | Description | -|---|---|---|---| -| Join Angle (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. | -| Leave Ratio (x Join Angle) | 1 to 4 | 1.5 | Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. | -| Dwell 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. | -| Crossfade Time (ms) | 0 to 1000 | 50 | Time over which a moving input fades between clusters. | -| Max Outputs (0 = no limit) | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | -| Near-Field 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. | -| Distance Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | -| Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | -| Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | -| Loudness Weighting | on or off | on | Moves each cluster toward its louder inputs. | +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 **Join Angle**, and **Max Outputs** when you have a fixed object budget. + +| Group | Parameter | Range | Default | Description | +|---|---|---|---|---| +| Clustering | Join Angle (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 | Max Outputs (0 = no limit) | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | +| Protection | Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | +| Protection | Near-Field 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 | Dwell 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 Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | +| Advanced | Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | Dwell 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. diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp index e5bb449..08141d3 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp @@ -456,8 +456,8 @@ void SpatialClusteringFX::UpdateClusterParams(AkUInt16 in_uMaxFrames) 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.joinAngle, 0.f, PARAM_JOIN_ANGLE_MAX); - m_clusterParams.leaveRatio = Clamp(rtpc.leaveRatio, PARAM_LEAVE_RATIO_MIN, PARAM_LEAVE_RATIO_MAX); m_clusterParams.dwellFrames = MillisecondsToFrames(Clamp(rtpc.dwellTime, 0.f, PARAM_DWELL_TIME_MAX), fFrameSeconds); const AkUInt32 uCrossfadeFrames = MillisecondsToFrames(Clamp(rtpc.crossfadeTime, 0.f, PARAM_CROSSFADE_TIME_MAX), fFrameSeconds); m_clusterParams.crossfadeFrames = uCrossfadeFrames > 0 ? uCrossfadeFrames : 1; @@ -465,7 +465,6 @@ void SpatialClusteringFX::UpdateClusterParams(AkUInt16 in_uMaxFrames) m_clusterParams.nearFieldRadius = Clamp(rtpc.nearFieldRadius, 0.f, PARAM_NEAR_FIELD_RADIUS_MAX); m_clusterParams.distanceWeight = Clamp(rtpc.distanceWeight, 0.f, 1.f); m_clusterParams.excludePriorityAbove = static_cast(Clamp(rtpc.excludePriorityAbove, 0, PARAM_PRIORITY_MAX)); - m_clusterParams.loudnessWeighting = m_pParams->NonRTPC.loudnessWeighting; m_clusterParams.loudnessSmoothing = std::exp(-fFrameSeconds / kLoudnessTimeConstant); m_fSpreadFromSize = Clamp(rtpc.spreadFromSize, 0.f, 1.f); @@ -491,7 +490,6 @@ bool SpatialClusteringFX::PrepareInputs(const AkAudioObjects& in_objects) m_inputPriorities[i] = AK_DEFAULT_PRIORITY; } - const bool bLoudnessWeighting = m_clusterParams.loudnessWeighting; for (AkUInt32 i = 0; i < uNumInputs; ++i) { const AkAudioObject& object = *in_objects.ppObjects[i]; @@ -502,7 +500,7 @@ bool SpatialClusteringFX::PrepareInputs(const AkAudioObjects& in_objects) input.key = object.key; input.position = ClusterVec3{ position.X, position.Y, position.Z }; input.clusterable = IsClusterable(object, buffer); - input.loudness = (bLoudnessWeighting && input.clusterable) ? ComputeRms(buffer) : 0.f; + 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); } diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp index 797e8c8..46bb3fa 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp @@ -34,19 +34,15 @@ the specific language governing permissions and limitations under the License. namespace { /** @brief Default values of the parameters that RTPCs can drive. */ - const SpatialClusteringRTPCParams kDefaultRTPCParams = { 15.f, 1.5f, 200.f, 50.f, 0, 0.f, 0.f, 100, 1.f }; - - /** @brief Default value of the loudness weighting parameter. */ - const bool kDefaultLoudnessWeighting = true; + 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.joinAngle) - && std::isfinite(in_params.leaveRatio) + && std::isfinite(in_params.nearFieldRadius) && std::isfinite(in_params.dwellTime) && std::isfinite(in_params.crossfadeTime) - && std::isfinite(in_params.nearFieldRadius) && std::isfinite(in_params.distanceWeight) && std::isfinite(in_params.spreadFromSize); } @@ -77,7 +73,6 @@ namespace SpatialClusteringFXParams::SpatialClusteringFXParams() : RTPC(kDefaultRTPCParams) { - NonRTPC.loudnessWeighting = kDefaultLoudnessWeighting; } SpatialClusteringFXParams::~SpatialClusteringFXParams() @@ -87,7 +82,6 @@ SpatialClusteringFXParams::~SpatialClusteringFXParams() SpatialClusteringFXParams::SpatialClusteringFXParams(const SpatialClusteringFXParams& in_rParams) { RTPC = in_rParams.RTPC; - NonRTPC = in_rParams.NonRTPC; m_paramChangeHandler.SetAllParamChanges(); } @@ -104,7 +98,6 @@ AKRESULT SpatialClusteringFXParams::Init(AK::IAkPluginMemAlloc* in_pAllocator, c { // Initialize default parameters here RTPC = kDefaultRTPCParams; - NonRTPC.loudnessWeighting = kDefaultLoudnessWeighting; m_paramChangeHandler.SetAllParamChanges(); return AK_Success; } @@ -128,22 +121,19 @@ AKRESULT SpatialClusteringFXParams::SetParamsBlock(const void* in_pParamsBlock, SpatialClusteringRTPCParams rtpc; rtpc.joinAngle = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - rtpc.leaveRatio = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - rtpc.dwellTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - rtpc.crossfadeTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); rtpc.maxOutputs = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); + rtpc.excludePriorityAbove = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); rtpc.nearFieldRadius = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.dwellTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + rtpc.crossfadeTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); rtpc.distanceWeight = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - rtpc.excludePriorityAbove = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); rtpc.spreadFromSize = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - const AkUInt8 loudnessWeighting = READBANKDATA(AkUInt8, pParamsBlock, in_ulBlockSize); CHECKBANKDATASIZE(in_ulBlockSize, eResult); if (!AreFinite(rtpc)) return AK_InvalidParameter; RTPC = rtpc; - NonRTPC.loudnessWeighting = loudnessWeighting != 0; m_paramChangeHandler.SetAllParamChanges(); return eResult; @@ -161,11 +151,6 @@ AKRESULT SpatialClusteringFXParams::SetParam(AkPluginParamID in_paramID, const v return AK_InvalidParameter; RTPC.joinAngle = value; break; - case PARAM_LEAVE_RATIO_ID: - if (!ReadReal(in_pValue, in_ulParamSize, value)) - return AK_InvalidParameter; - RTPC.leaveRatio = value; - break; case PARAM_DWELL_TIME_ID: if (!ReadReal(in_pValue, in_ulParamSize, value)) return AK_InvalidParameter; @@ -201,20 +186,6 @@ AKRESULT SpatialClusteringFXParams::SetParam(AkPluginParamID in_paramID, const v return AK_InvalidParameter; RTPC.spreadFromSize = value; break; - case PARAM_LOUDNESS_WEIGHTING_ID: - if (in_pValue == nullptr || in_ulParamSize == 0) - return AK_InvalidParameter; - if (in_ulParamSize == sizeof(AkReal32)) - { - if (!ReadReal(in_pValue, in_ulParamSize, value)) - return AK_InvalidParameter; - NonRTPC.loudnessWeighting = value != 0.f; - } - else - { - NonRTPC.loudnessWeighting = *static_cast(in_pValue) != 0; - } - break; default: return AK_InvalidParameter; } diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h index df14881..3731571 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h @@ -31,36 +31,29 @@ the specific language governing permissions and limitations under the License. #include // Add parameters IDs here, those IDs should map to the AudioEnginePropertyID -// attributes in the xml property definition. +// 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 join angle parameter. static const AkPluginParamID PARAM_JOIN_ANGLE_ID = 0; -/// ID of the leave ratio parameter. -static const AkPluginParamID PARAM_LEAVE_RATIO_ID = 1; -/// ID of the dwell time parameter. -static const AkPluginParamID PARAM_DWELL_TIME_ID = 2; -/// ID of the crossfade time parameter. -static const AkPluginParamID PARAM_CROSSFADE_TIME_ID = 3; /// ID of the maximum number of outputs parameter. -static const AkPluginParamID PARAM_MAX_OUTPUTS_ID = 4; +static const AkPluginParamID PARAM_MAX_OUTPUTS_ID = 1; +/// ID of the parameter that sets the priority above which inputs are never merged. +static const AkPluginParamID PARAM_EXCLUDE_PRIORITY_ABOVE_ID = 2; /// ID of the near-field radius parameter. -static const AkPluginParamID PARAM_NEAR_FIELD_RADIUS_ID = 5; +static const AkPluginParamID PARAM_NEAR_FIELD_RADIUS_ID = 3; +/// ID of the dwell time parameter. +static const AkPluginParamID PARAM_DWELL_TIME_ID = 4; +/// ID of the crossfade time parameter. +static const AkPluginParamID PARAM_CROSSFADE_TIME_ID = 5; /// ID of the distance weight parameter. static const AkPluginParamID PARAM_DISTANCE_WEIGHT_ID = 6; -/// ID of the parameter that sets the priority above which inputs are never merged. -static const AkPluginParamID PARAM_EXCLUDE_PRIORITY_ABOVE_ID = 7; /// ID of the spread from cluster size parameter. -static const AkPluginParamID PARAM_SPREAD_FROM_SIZE_ID = 8; -/// ID of the loudness weighting parameter. -static const AkPluginParamID PARAM_LOUDNESS_WEIGHTING_ID = 9; +static const AkPluginParamID PARAM_SPREAD_FROM_SIZE_ID = 7; /// Number of parameters. -static const AkUInt32 NUM_PARAMS = 10; +static const AkUInt32 NUM_PARAMS = 8; /// Largest join angle in degrees. static const AkReal32 PARAM_JOIN_ANGLE_MAX = 180.f; -/// Smallest leave ratio. -static const AkReal32 PARAM_LEAVE_RATIO_MIN = 1.f; -/// Largest leave ratio. -static const AkReal32 PARAM_LEAVE_RATIO_MAX = 4.f; /// Largest dwell time in milliseconds. static const AkReal32 PARAM_DWELL_TIME_MAX = 5000.f; /// Largest crossfade time in milliseconds. @@ -73,28 +66,21 @@ static const AkReal32 PARAM_NEAR_FIELD_RADIUS_MAX = 1000000.f; 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 = 7 * sizeof(AkReal32) + 2 * sizeof(AkInt32) + sizeof(AkUInt8); +static const AkUInt32 PARAMS_BLOCK_SIZE = 6 * sizeof(AkReal32) + 2 * sizeof(AkInt32); -/** @brief Parameters that RTPCs can drive. */ +/** @brief Parameters of the effect, all of which RTPCs can drive. */ struct SpatialClusteringRTPCParams { AkReal32 joinAngle; ///< Angle in degrees under which an input may join a cluster. - AkReal32 leaveRatio; ///< Leave angle as a multiple of the join angle. - AkReal32 dwellTime; ///< 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. AkInt32 maxOutputs; ///< Maximum number of outputs, passthrough inputs included; 0 means no limit. + AkInt32 excludePriorityAbove; ///< Inputs with a higher priority are never merged. AkReal32 nearFieldRadius; ///< Inputs closer to the listener than this distance in game units are never merged. + AkReal32 dwellTime; ///< 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 distanceWeight; ///< How much a difference in distance counts against the join angle. - AkInt32 excludePriorityAbove; ///< Inputs with a higher priority are never merged. AkReal32 spreadFromSize; ///< Share of the cluster's angular size used as its minimum spread. }; -/** @brief Parameters that RTPCs cannot drive. */ -struct SpatialClusteringNonRTPCParams -{ - bool loudnessWeighting; ///< Weights cluster positions by input loudness. -}; - /** @brief Parameter node of the Spatial Clustering effect. */ struct SpatialClusteringFXParams : public AK::IAkPluginParam @@ -126,8 +112,7 @@ struct SpatialClusteringFXParams AK::AkFXParameterChangeHandler m_paramChangeHandler; ///< Tracks which parameters changed since the effect last read them. - SpatialClusteringRTPCParams RTPC; ///< Parameters that RTPCs can drive. - SpatialClusteringNonRTPCParams NonRTPC; ///< Parameters that RTPCs cannot drive. + SpatialClusteringRTPCParams RTPC; ///< Parameters that RTPCs can drive. }; #endif // SpatialClusteringFXParams_H diff --git a/wwise/WwisePlugin/SpatialClustering.xml b/wwise/WwisePlugin/SpatialClustering.xml index 8801b95..5bfd731 100644 --- a/wwise/WwisePlugin/SpatialClustering.xml +++ b/wwise/WwisePlugin/SpatialClustering.xml @@ -42,7 +42,7 @@ for the documentation about the plugin xml format - + 15 0 @@ -55,78 +55,78 @@ for the documentation about the plugin xml format - - - - 1.5 + + + + 0 1 - - 1 - 4 + + 0 + 512 - - - - 200 + + + + 100 2 - + 0 - 5000 + 100 - - - - 50 + + + + 0 3 0 - 1000 + 1000000 - - - - 0 + + + + 200 4 - + 0 - 512 + 5000 - - - - 0 + + + + 50 5 0 - 1000000 + 1000 - + 0 6 @@ -139,25 +139,11 @@ for the documentation about the plugin xml format - - - - 100 - 7 - - - - 0 - 100 - - - - - + 1 - 8 + 7 @@ -167,11 +153,6 @@ for the documentation about the plugin xml format - - - true - 9 -
diff --git a/wwise/WwisePlugin/SpatialClusteringPlugin.cpp b/wwise/WwisePlugin/SpatialClusteringPlugin.cpp index 9c2feb7..3123cfe 100644 --- a/wwise/WwisePlugin/SpatialClusteringPlugin.cpp +++ b/wwise/WwisePlugin/SpatialClusteringPlugin.cpp @@ -39,15 +39,13 @@ bool SpatialClusteringPlugin::GetBankParameters(const GUID& in_guidPlatform, AK: { // Write bank data here return in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:joinAngle")) - && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:leaveRatio")) - && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:dwellTime")) - && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:crossfadeTime")) && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:maxOutputs")) + && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:excludePriorityAbove")) && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:nearFieldRadius")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:dwellTime")) + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:crossfadeTime")) && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:distanceWeight")) - && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:excludePriorityAbove")) - && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:spreadFromSize")) - && in_dataWriter.WriteBool(m_propertySet.GetBool(in_guidPlatform, "CCP:loudnessWeighting")); + && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:spreadFromSize")); } AK_DEFINE_PLUGIN_CONTAINER(SpatialClustering); // Create a PluginContainer structure that contains the info for our plugin diff --git a/wwise/WwisePlugin/res/Md/en/leaveRatio.md b/wwise/WwisePlugin/res/Md/en/leaveRatio.md deleted file mode 100644 index 648ea81..0000000 --- a/wwise/WwisePlugin/res/Md/en/leaveRatio.md +++ /dev/null @@ -1,9 +0,0 @@ -## Leave Ratio - -Angle at which an input starts to leave its cluster, as a multiple of the Join Angle. An input leaves only after it has been farther than Join Angle × Leave Ratio from its cluster for the Dwell Time. - -The gap between the join and leave angles stops inputs from switching back and forth between clusters. - -**Note**: When the Max Outputs limit is reached, a new input with no cluster within the Join Angle joins the nearest cluster within Join Angle × Leave Ratio. - -Units: × Join Angle diff --git a/wwise/WwisePlugin/res/Md/en/loudnessWeighting.md b/wwise/WwisePlugin/res/Md/en/loudnessWeighting.md deleted file mode 100644 index 1a7f246..0000000 --- a/wwise/WwisePlugin/res/Md/en/loudnessWeighting.md +++ /dev/null @@ -1,7 +0,0 @@ -## Loudness Weighting - -When on, each input counts by its RMS level, smoothed over about 50 ms, so clusters move toward their louder inputs. When two clusters merge, the quieter one fades into the other. The loudest input is the representative whose spread and custom metadata the output uses. - -When off, every input counts the same. When two clusters merge, the one with fewer inputs fades into the other, and the representative is the first input at full gain. - -**Note**: This is the only parameter that cannot be driven by an RTPC. From 423d54090e703b533b17ac0b40c9c3ba3aea8fe5 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 11:53:26 +0000 Subject: [PATCH 21/26] move the core tests, scenarios, benchmark, viewer and their ci to a separate pull request --- .github/PULL_REQUEST_TEMPLATE.md | 2 - .github/workflows/tests.yml | 27 - CONTRIBUTING.md | 4 +- README.md | 29 +- core/tests/CMakeLists.txt | 57 - core/tests/src/Bench.cpp | 287 ----- core/tests/src/FrameAnalysis.cpp | 232 ---- core/tests/src/FrameAnalysis.h | 130 --- core/tests/src/Main.cpp | 97 -- core/tests/src/MathConstants.h | 29 - core/tests/src/Modes.h | 42 - core/tests/src/ScenarioMode.cpp | 373 ------ core/tests/src/Scenarios.cpp | 819 -------------- core/tests/src/Scenarios.h | 126 --- core/tests/src/Tests.cpp | 1811 ------------------------------ core/tests/viewer.html | 593 ---------- 16 files changed, 2 insertions(+), 4656 deletions(-) delete mode 100644 .github/workflows/tests.yml delete mode 100644 core/tests/CMakeLists.txt delete mode 100644 core/tests/src/Bench.cpp delete mode 100644 core/tests/src/FrameAnalysis.cpp delete mode 100644 core/tests/src/FrameAnalysis.h delete mode 100644 core/tests/src/Main.cpp delete mode 100644 core/tests/src/MathConstants.h delete mode 100644 core/tests/src/Modes.h delete mode 100644 core/tests/src/ScenarioMode.cpp delete mode 100644 core/tests/src/Scenarios.cpp delete mode 100644 core/tests/src/Scenarios.h delete mode 100644 core/tests/src/Tests.cpp delete mode 100644 core/tests/viewer.html diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 70ac772..37504f7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,8 +2,6 @@ **Testing** -- [ ] The tests in `core/tests` pass (`ctest -C Release`). -- [ ] If `ClusterEngine` changed: benchmark results before and after (`cluster_tests bench`). - [ ] The plug-in builds and was tested at runtime in Wwise or a game. **Docs** diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index f8207b0..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Builds the clustering engine's test program and runs its tests. This needs no Wwise SDK, -# so it runs on GitHub-hosted runners. The Wwise plug-in itself is built with scripts/package.py. -name: Tests - -on: - push: - pull_request: - -jobs: - core-tests: - name: Core tests (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - steps: - - uses: actions/checkout@v4 - - - name: Configure - run: cmake -S core/tests -B build/tests -DCMAKE_BUILD_TYPE=Release - - - name: Build - run: cmake --build build/tests --config Release - - - name: Test - run: ctest --test-dir build/tests -C Release --output-on-failure diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f74f49f..330ee61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,6 @@ We welcome contributions: feature requests, bug fixes, documentation improvement ## Submitting a pull request - Fork the repository and create a feature branch. -- Run the tests in `core/tests`, and include benchmark results if you change `ClusterEngine`. - Build and test your changes at runtime before submitting. - Submit a PR with a clear description of what changed and why. @@ -19,14 +18,13 @@ Contributions are accepted under the [Apache License 2.0](LICENSE.txt), the lice ## Building and testing your changes -[Building from Source](README.md#building-from-source) in the README covers the toolchain, the plug-in build, the test program and ways to try the plug-in in a game. Changes to the clustering itself can be tested first with the test program in `core/tests`, which does not need the Wwise SDK. +[Building from Source](README.md#building-from-source) in the README 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`. | -| `core/tests/` | Standalone tests, scenarios and benchmark for the engine. Not part of the plug-in build. | | `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. | diff --git a/README.md b/README.md index 2783bcb..dbff2b9 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Inputs with a priority above **Exclude Priority Above**, and inputs closer to th ### Performance -Memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs in the [benchmark](#testing-without-a-game). Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs in the same benchmark. +Memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs. Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs. ## Parameters @@ -253,33 +253,6 @@ It writes `dist\SpatialClustering__Wwise.\` with the bundl The plug-in version is set in `wwise/SpatialClusteringVersion.h`. 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. -### Testing Without a Game - -The `core/tests` folder builds a standalone program around `ClusterEngine`. It does not need the Wwise SDK. It contains an assertion test suite, 13 seeded scenarios (static groups, crossing groups, one-shots, jitter, budget changes, near field, passthrough, a space battle and more) and a benchmark, which also times an update that lowers Max Outputs. - -``` -cmake -S core/tests -B build/tests -cmake --build build/tests --config Release -cd build/tests -ctest -C Release -Release\cluster_tests bench -Release\cluster_tests list -Release\cluster_tests scenario oneshots --csv oneshots.csv -``` - -Run `cluster_tests` with no arguments to see all modes and options. On Linux and macOS the program is in `build/tests` instead of `build/tests/Release`. - -#### Watching a Scenario - -`--trace` writes every frame of a scenario to a JSON file, which `core/tests/viewer.html` plays back in a browser. Open the viewer and drop the trace on it. - -``` -Release\cluster_tests scenario battle --trace battle.json -Release\cluster_tests scenario battle --seed 42 --join-angle 10 --max-outputs 12 --trace battle.json -``` - -The viewer shows the scene from above twice, side by side: without clustering, where every voice needs its own audio object, and with Spatial Clustering. It counts the objects in both against an endpoint limit (128 by default, editable on the page), marks the voices that would fall back to the main mix without clustering, and sums up the peak, average and saving over the whole run. `--seed` picks a different random scene, and `--join-angle` and `--max-outputs` override the scenario's settings. Adding `#t=3.2` to the viewer's address opens it paused at that time. - ### Testing in a Game Once the plug-in builds, you'll want a scene to exercise it in. A few options: diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt deleted file mode 100644 index e7bc869..0000000 --- a/core/tests/CMakeLists.txt +++ /dev/null @@ -1,57 +0,0 @@ -# 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. - -cmake_minimum_required(VERSION 3.16) -project(ClusterTests LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Release) -endif() - -set(ENGINE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../src) - -add_executable(cluster_tests - src/Main.cpp - src/MathConstants.h - src/Modes.h - src/Scenarios.cpp - src/Scenarios.h - src/FrameAnalysis.cpp - src/FrameAnalysis.h - src/ScenarioMode.cpp - src/Bench.cpp - src/Tests.cpp - ${ENGINE_DIR}/ClusterEngine.cpp - ${ENGINE_DIR}/ClusterEngine.h) - -target_include_directories(cluster_tests PRIVATE ${ENGINE_DIR} src) - -if(MSVC) - target_compile_options(cluster_tests PRIVATE /W4 /permissive-) -else() - target_compile_options(cluster_tests PRIVATE -Wall -Wextra -Wpedantic) -endif() - -enable_testing() -add_test(NAME cluster_engine_tests COMMAND cluster_tests test) diff --git a/core/tests/src/Bench.cpp b/core/tests/src/Bench.cpp deleted file mode 100644 index aff1231..0000000 --- a/core/tests/src/Bench.cpp +++ /dev/null @@ -1,287 +0,0 @@ -/* - * 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 "FrameAnalysis.h" -#include "MathConstants.h" -#include "Modes.h" -#include "Scenarios.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace -{ -/** @brief Parameter variant the benchmark runs. */ -struct BenchConfig -{ - const char* name; ///< Label printed in the table. - uint32_t maxOutputs; ///< Value of maxOutputs. - float distanceWeight; ///< Value of distanceWeight. -}; - -/** @brief Measurements of one benchmark run. */ -struct BenchResult -{ - double firstMicros = 0.0; ///< Time of the first update, where every input is new. - TimingSummary timing; ///< Times of the measured updates. - double meanClusters = 0.0; ///< Mean number of reported clusters. - uint32_t maxClusters = 0; ///< Largest number of reported clusters. - uint64_t moves = 0; ///< Existing inputs that started fading into another cluster. - double frameSeconds = 0.0; ///< Frame length of the scenario in seconds. - bool failed = false; ///< True if an update failed. -}; - -/** @brief Parameter variant of the budget drop benchmark. */ -struct DropConfig -{ - const char* name; ///< Label printed in the table. - float joinAngle; ///< Value of joinAngle. -}; - -/** @brief Measurements of the budget drop benchmark for one input count. */ -struct DropResult -{ - double meanClusters = 0.0; ///< Mean number of clusters before the drop. - double meanMicros = 0.0; ///< Mean time of the update that lowers maxOutputs. - double maxMicros = 0.0; ///< Largest time of the update that lowers maxOutputs. - bool overBudget = false; ///< True if any run could not reach the new maxOutputs. - bool failed = false; ///< True if an update failed. -}; - -/** @brief Number of frames that settle the clusters before the budget drop. */ -constexpr int kDropSettleFrames = 30; - -/** @brief Number of runs with different inputs per budget drop measurement. */ -constexpr uint32_t kDropRuns = 5; - -/** @brief Value of maxOutputs after the budget drop. */ -constexpr uint32_t kDropMaxOutputs = 16; - -/** @brief Runs the benchmark scenario for one input count and parameter variant. */ -BenchResult RunOne(uint32_t in_inputs, const BenchConfig& in_config, uint32_t in_seed, int in_warmup, int in_frames) -{ - Scenario scenario; - BuildBenchScenario(in_inputs, in_seed, in_warmup + in_frames, scenario); - scenario.params.maxOutputs = in_config.maxOutputs; - scenario.params.distanceWeight = in_config.distanceWeight; - - CountingAllocator allocator; - ClusterEngine engine; - engine.Init(&allocator, in_inputs); - ScenarioPlayer player(scenario, in_seed ^ 0x0BADu, true); - FrameTracker tracker; - FrameView view; - std::vector times; - times.reserve(static_cast(in_frames)); - BenchResult result; - result.frameSeconds = scenario.frameSeconds; - double clusterSum = 0.0; - - for (int frame = 0; frame < in_warmup + in_frames; ++frame) - { - const std::vector& inputs = player.NextFrame(); - const auto start = std::chrono::steady_clock::now(); - const bool updated = engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); - const auto stop = std::chrono::steady_clock::now(); - if (!updated) - { - result.failed = true; - break; - } - const double micros = std::chrono::duration(stop - start).count(); - if (frame == 0) - { - result.firstMicros = micros; - } - if (frame < in_warmup) - { - continue; - } - times.push_back(micros); - CaptureFrame(engine, inputs, updated, view); - const FrameMetrics metrics = tracker.Observe(frame, view, micros); - clusterSum += metrics.clusters; - result.maxClusters = std::max(result.maxClusters, metrics.clusters); - result.moves += metrics.moves; - } - engine.Term(); - if (times.empty()) - { - return result; - } - result.timing = SummarizeTimings(times); - result.meanClusters = clusterSum / static_cast(times.size()); - return result; -} - -/** @brief Returns inputs in uniformly random directions and distances from 10 to 510. */ -std::vector MakeRandomInputs(uint32_t in_count, std::mt19937& io_random) -{ - std::uniform_real_distribution unit(0.0, 1.0); - std::vector inputs; - for (uint32_t index = 0; index < in_count; ++index) - { - const double z = unit(io_random) * 2.0 - 1.0; - const double azimuth = unit(io_random) * 2.0 * kPi; - const double planar = std::sqrt(std::max(0.0, 1.0 - z * z)); - const double range = 10.0 + unit(io_random) * 500.0; - ClusterInput input; - input.key = index + 1u; - input.position = {static_cast(planar * std::cos(azimuth) * range), - static_cast(planar * std::sin(azimuth) * range), static_cast(z * range)}; - input.loudness = static_cast(0.01 + unit(io_random)); - input.priority = 50; - input.group = 0; - input.clusterable = true; - inputs.push_back(input); - } - return inputs; -} - -/** @brief Times the update that lowers maxOutputs from 0 to kDropMaxOutputs after the clusters have settled. */ -DropResult RunDrop(uint32_t in_inputs, const DropConfig& in_config, uint32_t in_seed) -{ - DropResult result; - double clusterSum = 0.0; - double timeSum = 0.0; - for (uint32_t run = 0; run < kDropRuns; ++run) - { - std::mt19937 random(in_seed + run * 7919u); - const std::vector inputs = MakeRandomInputs(in_inputs, random); - CountingAllocator allocator; - ClusterEngine engine; - engine.Init(&allocator, in_inputs); - ClusterParams params; - params.joinAngle = in_config.joinAngle; - for (int frame = 0; frame < kDropSettleFrames; ++frame) - { - result.failed = !engine.Update(inputs.data(), in_inputs, params) || result.failed; - } - clusterSum += engine.GetClusterCount(); - params.maxOutputs = kDropMaxOutputs; - const auto start = std::chrono::steady_clock::now(); - const bool updated = engine.Update(inputs.data(), in_inputs, params); - const auto stop = std::chrono::steady_clock::now(); - const double micros = std::chrono::duration(stop - start).count(); - result.failed = !updated || result.failed; - result.overBudget = engine.IsOverBudget() || result.overBudget; - timeSum += micros; - result.maxMicros = std::max(result.maxMicros, micros); - engine.Term(); - } - result.meanClusters = clusterSum / kDropRuns; - result.meanMicros = timeSum / kDropRuns; - return result; -} -} - -int RunBench(const std::vector& in_args) -{ - uint32_t frames = 2000; - uint32_t warmup = 200; - uint32_t seed = 4242; - for (size_t index = 0; index < in_args.size(); ++index) - { - const std::string& arg = in_args[index]; - const bool hasValue = index + 1 < in_args.size(); - bool valid = false; - if (arg == "--frames" && hasValue) - { - valid = ParseNumber(in_args[++index], frames) && frames > 0; - } - else if (arg == "--warmup" && hasValue) - { - valid = ParseNumber(in_args[++index], warmup); - } - else if (arg == "--seed" && hasValue) - { - valid = ParseNumber(in_args[++index], seed); - } - if (!valid) - { - std::printf("bad argument '%s'\n", arg.c_str()); - return 2; - } - } - - const BenchConfig configs[] = { - {"default", 0, 0.f}, - {"distance0.5", 0, 0.5f}, - {"budget64", 64, 0.f}, - {"budget64+distance0.5", 64, 0.5f}, - }; - const uint32_t sizes[] = {64, 128, 256, 512, 1024}; - - std::printf("seed %u, warmup %u frames, measured %u frames, input order shuffled every frame\n", seed, warmup, frames); - std::printf("time per Update in microseconds; 'first' is the first frame where every input is new\n\n"); - std::printf("%-20s %6s %9s %9s %9s %9s %9s %10s %8s %10s\n", "config", "inputs", "first", "mean", "p50", "p99", "max", - "clusters", "max_cl", "moves/s"); - bool failed = false; - for (const BenchConfig& config : configs) - { - for (uint32_t size : sizes) - { - const BenchResult result = RunOne(size, config, seed, static_cast(warmup), static_cast(frames)); - if (result.failed) - { - std::printf("%-20s %6u Update failed\n", config.name, size); - failed = true; - continue; - } - const double seconds = static_cast(frames) * result.frameSeconds; - std::printf("%-20s %6u %9.2f %9.2f %9.2f %9.2f %9.2f %10.1f %8u %10.1f\n", config.name, size, result.firstMicros, - result.timing.mean, result.timing.p50, result.timing.p99, result.timing.max, result.meanClusters, - result.maxClusters, static_cast(result.moves) / seconds); - } - } - - const DropConfig dropConfigs[] = { - {"join5", 5.f}, - {"join2", 2.f}, - }; - const uint32_t dropSizes[] = {512, 1024}; - std::printf("\nbudget drop: time of the Update that lowers maxOutputs from 0 to %u, after %d frames with uniformly random " - "inputs, over %u runs\n", - kDropMaxOutputs, kDropSettleFrames, kDropRuns); - std::printf("%-20s %6s %10s %10s %10s\n", "config", "inputs", "clusters", "mean_us", "max_us"); - for (const DropConfig& config : dropConfigs) - { - for (uint32_t size : dropSizes) - { - const DropResult result = RunDrop(size, config, seed); - if (result.failed || result.overBudget) - { - std::printf("%-20s %6u %s\n", config.name, size, result.failed ? "Update failed" : "maxOutputs not reached"); - failed = true; - continue; - } - std::printf("%-20s %6u %10.1f %10.1f %10.1f\n", config.name, size, result.meanClusters, result.meanMicros, - result.maxMicros); - } - } - return failed ? 1 : 0; -} diff --git a/core/tests/src/FrameAnalysis.cpp b/core/tests/src/FrameAnalysis.cpp deleted file mode 100644 index 8843dfd..0000000 --- a/core/tests/src/FrameAnalysis.cpp +++ /dev/null @@ -1,232 +0,0 @@ -/* - * 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 "FrameAnalysis.h" -#include "MathConstants.h" - -#include -#include -#include - -void* CountingAllocator::Alloc(size_t in_size) -{ - if (m_failing) - { - return nullptr; - } - void* pBlock = std::malloc(in_size == 0 ? 1 : in_size); - if (pBlock == nullptr) - { - return nullptr; - } - m_blocks[pBlock] = in_size; - m_outstandingBytes += in_size; - ++m_allocCount; - return pBlock; -} - -void CountingAllocator::Free(void* in_pBlock) -{ - const auto found = m_blocks.find(in_pBlock); - if (found == m_blocks.end()) - { - ++m_badFreeCount; - return; - } - m_outstandingBytes -= found->second; - m_blocks.erase(found); - std::free(in_pBlock); -} - -void CountingAllocator::SetFailing(bool in_failing) -{ - m_failing = in_failing; -} - -uint64_t CountingAllocator::GetAllocCount() const -{ - return m_allocCount; -} - -size_t CountingAllocator::GetOutstandingBlocks() const -{ - return m_blocks.size(); -} - -size_t CountingAllocator::GetOutstandingBytes() const -{ - return m_outstandingBytes; -} - -uint64_t CountingAllocator::GetBadFreeCount() const -{ - return m_badFreeCount; -} - -void CaptureFrame(const ClusterEngine& in_engine, const std::vector& in_inputs, bool in_updated, FrameView& out_view) -{ - out_view.inputs = in_inputs; - out_view.updated = in_updated; - out_view.overBudget = in_engine.IsOverBudget(); - out_view.clusters.resize(in_engine.GetClusterCount()); - for (uint32_t index = 0; index < in_engine.GetClusterCount(); ++index) - { - out_view.clusters[index] = in_engine.GetCluster(index); - } - out_view.contributions.resize(in_engine.GetContributionCount()); - for (uint32_t index = 0; index < in_engine.GetContributionCount(); ++index) - { - out_view.contributions[index] = in_engine.GetContribution(index); - } -} - -bool IsFiniteVec(const ClusterVec3& in_v) -{ - return std::isfinite(in_v.x) && std::isfinite(in_v.y) && std::isfinite(in_v.z); -} - -double LengthOf(const ClusterVec3& in_v) -{ - const double x = in_v.x; - const double y = in_v.y; - const double z = in_v.z; - return std::sqrt(x * x + y * y + z * z); -} - -double AngleBetween(const ClusterVec3& in_a, const ClusterVec3& in_b) -{ - if (!IsFiniteVec(in_a) || !IsFiniteVec(in_b)) - { - return 0.0; - } - const double lengthA = LengthOf(in_a); - const double lengthB = LengthOf(in_b); - if (lengthA <= 0.0 || lengthB <= 0.0) - { - return 0.0; - } - const double dot = (static_cast(in_a.x) * in_b.x + static_cast(in_a.y) * in_b.y + - static_cast(in_a.z) * in_b.z) / - (lengthA * lengthB); - return std::acos(std::max(-1.0, std::min(1.0, dot))) * kDegreesPerRadian; -} - -bool IsExcludedInput(const ClusterInput& in_input, const ClusterParams& in_params) -{ - if (!in_input.clusterable) - { - return false; - } - if (!IsFiniteVec(in_input.position)) - { - return true; - } - const bool nearField = in_params.nearFieldRadius > 0.f && LengthOf(in_input.position) < in_params.nearFieldRadius; - return nearField || in_input.priority > in_params.excludePriorityAbove; -} - -std::vector FindHomeClusters(const FrameView& in_view) -{ - std::vector home(in_view.inputs.size(), -1); - std::vector best(in_view.inputs.size(), -1.f); - for (const ClusterContribution& contribution : in_view.contributions) - { - if (contribution.input >= in_view.inputs.size()) - { - continue; - } - if (contribution.gainEnd >= contribution.gainStart && contribution.gainEnd > 0.f && - contribution.gainEnd > best[contribution.input]) - { - best[contribution.input] = contribution.gainEnd; - home[contribution.input] = static_cast(contribution.cluster); - } - } - return home; -} - -TimingSummary SummarizeTimings(const std::vector& in_micros) -{ - TimingSummary summary; - if (in_micros.empty()) - { - return summary; - } - std::vector sorted = in_micros; - std::sort(sorted.begin(), sorted.end()); - double sum = 0.0; - for (double time : in_micros) - { - sum += time; - } - summary.mean = sum / static_cast(in_micros.size()); - summary.p50 = sorted[sorted.size() / 2]; - summary.p99 = sorted[std::min(sorted.size() - 1, static_cast(static_cast(sorted.size()) * 0.99))]; - summary.max = sorted.back(); - return summary; -} - -FrameMetrics FrameTracker::Observe(int in_frame, const FrameView& in_view, double in_updateMicroseconds) -{ - FrameMetrics metrics; - metrics.frame = in_frame; - metrics.inputs = static_cast(in_view.inputs.size()); - metrics.clusters = static_cast(in_view.clusters.size()); - metrics.overBudget = in_view.overBudget; - metrics.updateMicroseconds = in_updateMicroseconds; - - std::unordered_set clusterable; - for (const ClusterInput& input : in_view.inputs) - { - if (input.clusterable) - { - clusterable.insert(input.key); - } - else - { - ++metrics.passthrough; - } - } - metrics.outputs = metrics.clusters + metrics.passthrough; - for (const ClusterInfo& cluster : in_view.clusters) - { - if (cluster.isNew) - { - ++metrics.newClusters; - } - } - for (const ClusterContribution& contribution : in_view.contributions) - { - const ClusterInput& input = in_view.inputs[contribution.input]; - const ClusterInfo& cluster = in_view.clusters[contribution.cluster]; - if (contribution.gainStart == 0.f && m_previousClusterable.count(input.key) != 0) - { - ++metrics.moves; - } - if (contribution.gainEnd > 0.f) - { - metrics.maxMemberAngle = std::max(metrics.maxMemberAngle, AngleBetween(input.position, cluster.position)); - } - } - m_previousClusterable.swap(clusterable); - return metrics; -} diff --git a/core/tests/src/FrameAnalysis.h b/core/tests/src/FrameAnalysis.h deleted file mode 100644 index 84e6c5a..0000000 --- a/core/tests/src/FrameAnalysis.h +++ /dev/null @@ -1,130 +0,0 @@ -/* - * 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 "ClusterEngine.h" - -#include -#include -#include -#include -#include - -/** @brief Allocator that counts blocks and bytes and can be told to fail. */ -class CountingAllocator final : public ClusterAllocator -{ -public: - /** @brief Allocates a block with malloc, or returns nullptr when failing is enabled. */ - void* Alloc(size_t in_size) override; - - /** @brief Frees a block returned by Alloc. */ - void Free(void* in_pBlock) override; - - /** @brief Makes every following Alloc fail while set. */ - void SetFailing(bool in_failing); - - /** @brief Returns the number of successful Alloc calls. */ - uint64_t GetAllocCount() const; - - /** @brief Returns the number of blocks not yet freed. */ - size_t GetOutstandingBlocks() const; - - /** @brief Returns the number of bytes not yet freed. */ - size_t GetOutstandingBytes() const; - - /** @brief Returns the number of Free calls with a pointer that was not allocated here. */ - uint64_t GetBadFreeCount() const; - -private: - std::unordered_map m_blocks; ///< Live blocks and their sizes. - size_t m_outstandingBytes = 0; ///< Bytes not yet freed. - uint64_t m_allocCount = 0; ///< Successful Alloc calls. - uint64_t m_badFreeCount = 0; ///< Free calls with unknown pointers. - bool m_failing = false; ///< Makes Alloc fail while set. -}; - -/** @brief Copy of the engine output of one frame together with its inputs. */ -struct FrameView -{ - std::vector inputs; ///< Inputs given to the update. - std::vector clusters; ///< Reported clusters. - std::vector contributions; ///< Reported contributions. - bool overBudget = false; ///< Value of IsOverBudget. - bool updated = false; ///< Return value of Update. -}; - -/** @brief Copies the current engine output into a frame view. */ -void CaptureFrame(const ClusterEngine& in_engine, const std::vector& in_inputs, bool in_updated, FrameView& out_view); - -/** @brief Returns the angle in degrees between two vectors, or zero if either is zero or not finite. */ -double AngleBetween(const ClusterVec3& in_a, const ClusterVec3& in_b); - -/** @brief Returns true if a position has only finite coordinates. */ -bool IsFiniteVec(const ClusterVec3& in_v); - -/** @brief Returns the length of a vector. */ -double LengthOf(const ClusterVec3& in_v); - -/** @brief Returns true if the engine treats an input as excluded under the given parameters. */ -bool IsExcludedInput(const ClusterInput& in_input, const ClusterParams& in_params); - -/** @brief Returns, per input, the index of its home cluster among the reported clusters, or -1. */ -std::vector FindHomeClusters(const FrameView& in_view); - -/** @brief Summary of a series of timings in microseconds. */ -struct TimingSummary -{ - double mean = 0.0; ///< Mean time. - double p50 = 0.0; ///< Median time. - double p99 = 0.0; ///< 99th percentile time. - double max = 0.0; ///< Largest time. -}; - -/** @brief Returns the mean, median, 99th percentile and largest of a series of timings, or zeros if it is empty. */ -TimingSummary SummarizeTimings(const std::vector& in_micros); - -/** @brief Metrics of one frame of a scenario run. */ -struct FrameMetrics -{ - int frame = 0; ///< Frame index. - uint32_t inputs = 0; ///< Number of inputs. - uint32_t passthrough = 0; ///< Number of passthrough inputs. - uint32_t clusters = 0; ///< Number of reported clusters. - uint32_t outputs = 0; ///< Clusters plus passthrough inputs. - uint32_t moves = 0; ///< Existing inputs that started fading into another cluster. - uint32_t newClusters = 0; ///< Clusters reported for the first time. - double maxMemberAngle = 0.0; ///< Largest angle between a member and its cluster in degrees. - bool overBudget = false; ///< Value of IsOverBudget. - double updateMicroseconds = 0; ///< Time spent in Update. -}; - -/** @brief Follows engine output over frames and computes per-frame metrics. */ -class FrameTracker -{ -public: - /** @brief Computes the metrics of a frame and remembers the frame for the next one. */ - FrameMetrics Observe(int in_frame, const FrameView& in_view, double in_updateMicroseconds); - -private: - std::unordered_set m_previousClusterable; ///< Keys of clusterable inputs in the previous frame. -}; diff --git a/core/tests/src/Main.cpp b/core/tests/src/Main.cpp deleted file mode 100644 index 00645e4..0000000 --- a/core/tests/src/Main.cpp +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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 "Modes.h" - -#include -#include -#include -#include -#include - -namespace -{ -void PrintUsage() -{ - std::printf( - "usage:\n" - " cluster_tests test [name filter]\n" - " runs the assertion suite, or the tests whose name contains the filter; exits nonzero on failure\n" - " cluster_tests bench [--frames N] [--warmup N] [--seed N]\n" - " prints mean and p99 microseconds per Update for 64 to 1024 inputs\n" - " cluster_tests scenario [--csv file] [--trace file] [--seed N] [--frames N] [--join-angle D] [--max-outputs N]\n" - " [--shuffle] [--quiet]\n" - " prints per-frame metrics of one scenario; --trace writes every frame as JSON for viewer.html\n" - " cluster_tests list\n" - " prints the scenario names, default seeds and descriptions\n"); -} -} - -bool ParseNumber(const std::string& in_text, uint32_t& out_value) -{ - if (in_text.empty() || in_text[0] < '0' || in_text[0] > '9') - { - return false; - } - errno = 0; - char* pEnd = nullptr; - const unsigned long long value = std::strtoull(in_text.c_str(), &pEnd, 10); - if (errno != 0 || pEnd == nullptr || *pEnd != '\0' || value > 0xFFFFFFFFull) - { - return false; - } - out_value = static_cast(value); - return true; -} - -int main(int in_argc, char** in_argv) -{ - if (in_argc < 2) - { - PrintUsage(); - return 2; - } - const std::string mode = in_argv[1]; - std::vector args; - for (int index = 2; index < in_argc; ++index) - { - args.emplace_back(in_argv[index]); - } - if (mode == "test") - { - return RunTests(args); - } - if (mode == "bench") - { - return RunBench(args); - } - if (mode == "scenario") - { - return RunScenarioMode(args); - } - if (mode == "list") - { - return RunListMode(); - } - PrintUsage(); - return 2; -} diff --git a/core/tests/src/MathConstants.h b/core/tests/src/MathConstants.h deleted file mode 100644 index b133d66..0000000 --- a/core/tests/src/MathConstants.h +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 - -/** @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; diff --git a/core/tests/src/Modes.h b/core/tests/src/Modes.h deleted file mode 100644 index 3ecdf3b..0000000 --- a/core/tests/src/Modes.h +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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 -#include - -/** @brief Reads a decimal number that fits 32 bits and returns false if the text is not one. */ -bool ParseNumber(const std::string& in_text, uint32_t& out_value); - -/** @brief Runs the assertion suite and returns the process exit code. */ -int RunTests(const std::vector& in_args); - -/** @brief Runs the benchmark and returns the process exit code. */ -int RunBench(const std::vector& in_args); - -/** @brief Runs one scenario, prints its per-frame metrics and returns the process exit code. */ -int RunScenarioMode(const std::vector& in_args); - -/** @brief Prints the scenario names with their seeds and descriptions. */ -int RunListMode(); diff --git a/core/tests/src/ScenarioMode.cpp b/core/tests/src/ScenarioMode.cpp deleted file mode 100644 index 7bfe4eb..0000000 --- a/core/tests/src/ScenarioMode.cpp +++ /dev/null @@ -1,373 +0,0 @@ -/* - * 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 "FrameAnalysis.h" -#include "Modes.h" -#include "Scenarios.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace -{ -/** @brief Writes every frame of a scenario run as JSON for viewer.html. - * - * Each frame holds: - * - "p": maxOutputs, joinAngle and leaveRatio; - * - "i": inputs as [id, x, y, z, loudness, flags, priority], where id is stable across frames and flags - * is 1 for clusterable and 2 for excluded; - * - "c": clusters as [cluster ID, x, y, z, angular radius, member count]; - * - "l": contributions as [input index, cluster index, gain at the end of the frame]. - */ -class TraceWriter -{ -public: - ~TraceWriter() - { - Close(); - } - - /** @brief Creates the file and writes the scenario header, and returns false if the file cannot be created. */ - bool Open(const std::string& in_path, const Scenario& in_scenario) - { - m_file.open(in_path); - if (!m_file) - { - return false; - } - Write("{\"scenario\":\"%s\",\"description\":\"%s\",\"seed\":%u,\"frameSeconds\":%.6f,\"frames\":[\n", - Escape(in_scenario.name).c_str(), Escape(in_scenario.description).c_str(), in_scenario.seed, in_scenario.frameSeconds); - return true; - } - - /** @brief Writes one frame. */ - void WriteFrame(const FrameView& in_view, const ClusterParams& in_params) - { - Write("%s{\"p\":[%u,%g,%g],\"i\":[", m_frames++ > 0 ? ",\n" : "", in_params.maxOutputs, in_params.joinAngle, - in_params.leaveRatio); - for (size_t index = 0; index < in_view.inputs.size(); ++index) - { - const ClusterInput& input = in_view.inputs[index]; - const uint32_t id = m_ids.emplace(input.key, static_cast(m_ids.size())).first->second; - const int flags = (input.clusterable ? 1 : 0) | (IsExcludedInput(input, in_params) ? 2 : 0); - Write("%s[%u,%.2f,%.2f,%.2f,%.3f,%d,%u]", index > 0 ? "," : "", id, input.position.x, input.position.y, - input.position.z, input.loudness, flags, input.priority); - } - Write("],\"c\":["); - for (size_t index = 0; index < in_view.clusters.size(); ++index) - { - const ClusterInfo& cluster = in_view.clusters[index]; - Write("%s[%u,%.2f,%.2f,%.2f,%.2f,%u]", index > 0 ? "," : "", cluster.id, cluster.position.x, cluster.position.y, - cluster.position.z, cluster.angularRadius, cluster.memberCount); - } - Write("],\"l\":["); - for (size_t index = 0; index < in_view.contributions.size(); ++index) - { - const ClusterContribution& contribution = in_view.contributions[index]; - Write("%s[%u,%u,%.3f]", index > 0 ? "," : "", contribution.input, contribution.cluster, contribution.gainEnd); - } - Write("]}"); - } - - /** @brief Ends the JSON and closes the file. */ - void Close() - { - if (m_file.is_open()) - { - Write("\n]}\n"); - m_file.close(); - } - } - -private: - template - void Write(const char* in_format, Args... in_args) - { - char buffer[512]; - const int length = std::snprintf(buffer, sizeof(buffer), in_format, in_args...); - m_file.write(buffer, std::min(length, static_cast(sizeof(buffer)) - 1)); - } - - void Write(const char* in_text) - { - m_file << in_text; - } - - static std::string Escape(const std::string& in_text) - { - std::string escaped; - for (char character : in_text) - { - if (character == '"' || character == '\\') - { - escaped += '\\'; - } - escaped += character; - } - return escaped; - } - - std::ofstream m_file; ///< Trace file. - std::unordered_map m_ids; ///< Stable small ID of each input key seen so far. - int m_frames = 0; ///< Number of frames written. -}; - -/** @brief Reads an angle in degrees from 0 to 180 and returns false if the text is not one. */ -bool ParseAngle(const std::string& in_text, float& out_degrees) -{ - char* pEnd = nullptr; - const double value = std::strtod(in_text.c_str(), &pEnd); - if (in_text.empty() || pEnd != in_text.c_str() + in_text.size() || !(value >= 0.0 && value <= 180.0)) - { - return false; - } - out_degrees = static_cast(value); - return true; -} - -void PrintMetrics(const FrameMetrics& in_metrics) -{ - std::printf("%6d %6u %6u %6u %6u %6u %6u %9.3f %4d %10.2f\n", in_metrics.frame, in_metrics.inputs, in_metrics.passthrough, - in_metrics.clusters, in_metrics.outputs, in_metrics.moves, in_metrics.newClusters, in_metrics.maxMemberAngle, - in_metrics.overBudget ? 1 : 0, in_metrics.updateMicroseconds); -} - -void WriteCsvRow(std::ofstream& io_csv, const FrameMetrics& in_metrics) -{ - io_csv << in_metrics.frame << ',' << in_metrics.inputs << ',' << in_metrics.passthrough << ',' << in_metrics.clusters << ',' - << in_metrics.outputs << ',' << in_metrics.moves << ',' << in_metrics.newClusters << ',' << in_metrics.maxMemberAngle - << ',' << (in_metrics.overBudget ? 1 : 0) << ',' << in_metrics.updateMicroseconds << '\n'; -} -} - -int RunListMode() -{ - for (const std::string& name : GetScenarioNames()) - { - Scenario scenario; - BuildScenario(name, GetDefaultSeed(name), scenario); - std::printf("%-12s seed %-6u frames %-5d %s\n", name.c_str(), scenario.seed, scenario.frames, scenario.description.c_str()); - } - return 0; -} - -int RunScenarioMode(const std::vector& in_args) -{ - if (in_args.empty()) - { - std::printf("scenario mode needs a name; run 'cluster_tests list' to see them\n"); - return 2; - } - const std::string name = in_args[0]; - uint32_t seed = GetDefaultSeed(name); - uint32_t frames = 0; - bool hasFrames = false; - bool shuffle = false; - bool quiet = false; - std::string csvPath; - std::string tracePath; - float joinAngle = 0.f; - bool hasJoinAngle = false; - uint32_t maxOutputs = 0; - bool hasMaxOutputs = false; - for (size_t index = 1; index < in_args.size(); ++index) - { - const std::string& arg = in_args[index]; - const bool hasValue = index + 1 < in_args.size(); - bool valid = true; - if (arg == "--csv" && hasValue) - { - csvPath = in_args[++index]; - } - else if (arg == "--trace" && hasValue) - { - tracePath = in_args[++index]; - } - else if (arg == "--seed" && hasValue) - { - valid = ParseNumber(in_args[++index], seed); - } - else if (arg == "--frames" && hasValue) - { - valid = ParseNumber(in_args[++index], frames); - hasFrames = true; - } - else if (arg == "--join-angle" && hasValue) - { - valid = ParseAngle(in_args[++index], joinAngle); - hasJoinAngle = true; - } - else if (arg == "--max-outputs" && hasValue) - { - valid = ParseNumber(in_args[++index], maxOutputs); - hasMaxOutputs = true; - } - else if (arg == "--shuffle") - { - shuffle = true; - } - else if (arg == "--quiet") - { - quiet = true; - } - else - { - valid = false; - } - if (!valid) - { - std::printf("bad argument '%s'\n", arg.c_str()); - return 2; - } - } - - Scenario scenario; - if (!BuildScenario(name, seed, scenario)) - { - std::printf("unknown scenario '%s'; run 'cluster_tests list' to see them\n", name.c_str()); - return 2; - } - if (hasFrames) - { - scenario.frames = static_cast(frames); - } - // Overrides apply for the whole run, including after the scenario's own parameter changes. - const auto applyOverrides = [&](ClusterParams& io_params) { - if (hasJoinAngle) - { - io_params.joinAngle = joinAngle; - } - if (hasMaxOutputs) - { - io_params.maxOutputs = maxOutputs; - } - }; - applyOverrides(scenario.params); - for (ParamChange& change : scenario.changes) - { - applyOverrides(change.params); - } - - const uint32_t orderSeed = seed ^ 0x0BADu; - std::printf("scenario %s: %s\n", scenario.name.c_str(), scenario.description.c_str()); - std::printf("seed %u, order seed %u, frames %d, frame length %.3f ms, shuffle %s\n", seed, orderSeed, scenario.frames, - scenario.frameSeconds * 1000.0, (shuffle || scenario.shuffle) ? "on" : "off"); - - std::ofstream csv; - if (!csvPath.empty()) - { - csv.open(csvPath); - if (!csv) - { - std::printf("cannot open '%s'\n", csvPath.c_str()); - return 1; - } - csv << "frame,inputs,passthrough,clusters,outputs,moves,new_clusters,max_member_angle,over_budget,update_us\n"; - } - - TraceWriter trace; - if (!tracePath.empty() && !trace.Open(tracePath, scenario)) - { - std::printf("cannot open '%s'\n", tracePath.c_str()); - return 1; - } - - CountingAllocator allocator; - ClusterEngine engine; - engine.Init(&allocator, 256); - ScenarioPlayer player(scenario, orderSeed, shuffle || scenario.shuffle); - FrameTracker tracker; - FrameView view; - std::vector times; - double clusterSum = 0.0; - uint32_t clusterMax = 0; - uint64_t moveSum = 0; - uint64_t movesAfterSettle = 0; - double angleMax = 0.0; - int overBudgetFrames = 0; - bool failed = false; - - if (!quiet) - { - std::printf(" frame inputs pass. clust. output moves newcl. max_angle over update_us\n"); - } - for (int frame = 0; frame < scenario.frames; ++frame) - { - const std::vector& inputs = player.NextFrame(); - const auto start = std::chrono::steady_clock::now(); - const bool updated = engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); - const auto stop = std::chrono::steady_clock::now(); - const double micros = std::chrono::duration(stop - start).count(); - if (!updated) - { - std::printf("Update failed at frame %d\n", frame); - failed = true; - break; - } - CaptureFrame(engine, inputs, updated, view); - const FrameMetrics metrics = tracker.Observe(frame, view, micros); - times.push_back(micros); - clusterSum += metrics.clusters; - clusterMax = std::max(clusterMax, metrics.clusters); - moveSum += metrics.moves; - if (scenario.settleFrame >= 0 && frame > scenario.settleFrame) - { - movesAfterSettle += metrics.moves; - } - angleMax = std::max(angleMax, metrics.maxMemberAngle); - overBudgetFrames += metrics.overBudget ? 1 : 0; - if (!quiet) - { - PrintMetrics(metrics); - } - if (csv.is_open()) - { - WriteCsvRow(csv, metrics); - } - if (!tracePath.empty()) - { - trace.WriteFrame(view, player.GetParams()); - } - } - engine.Term(); - trace.Close(); - - if (!times.empty()) - { - const TimingSummary timing = SummarizeTimings(times); - std::printf("summary: clusters mean %.1f max %u, moves %llu", clusterSum / times.size(), clusterMax, - static_cast(moveSum)); - if (scenario.settleFrame >= 0) - { - std::printf(" (%llu after frame %d)", static_cast(movesAfterSettle), scenario.settleFrame); - } - std::printf(", max member angle %.3f, over budget frames %d, update mean %.2f us p99 %.2f us\n", angleMax, - overBudgetFrames, timing.mean, timing.p99); - } - return failed ? 1 : 0; -} diff --git a/core/tests/src/Scenarios.cpp b/core/tests/src/Scenarios.cpp deleted file mode 100644 index 704377b..0000000 --- a/core/tests/src/Scenarios.cpp +++ /dev/null @@ -1,819 +0,0 @@ -/* - * 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 "Scenarios.h" -#include "MathConstants.h" - -#include -#include -#include - -namespace -{ -double ToRadians(double in_degrees) -{ - return in_degrees * kPi / 180.0; -} - -Vec3 Add(const Vec3& in_a, const Vec3& in_b) -{ - return {in_a.x + in_b.x, in_a.y + in_b.y, in_a.z + in_b.z}; -} - -Vec3 Scale(const Vec3& in_v, double in_s) -{ - return {in_v.x * in_s, in_v.y * in_s, in_v.z * in_s}; -} - -double Dot(const Vec3& in_a, const Vec3& in_b) -{ - return in_a.x * in_b.x + in_a.y * in_b.y + in_a.z * in_b.z; -} - -Vec3 Cross(const Vec3& in_a, const Vec3& in_b) -{ - return {in_a.y * in_b.z - in_a.z * in_b.y, in_a.z * in_b.x - in_a.x * in_b.z, in_a.x * in_b.y - in_a.y * in_b.x}; -} - -double Length(const Vec3& in_v) -{ - return std::sqrt(Dot(in_v, in_v)); -} - -Vec3 Normalize(const Vec3& in_v) -{ - const double length = Length(in_v); - return length > 0.0 ? Scale(in_v, 1.0 / length) : Vec3{0.0, 0.0, 1.0}; -} - -double AngleDegrees(const Vec3& in_a, const Vec3& in_b) -{ - const double cosine = Dot(Normalize(in_a), Normalize(in_b)); - return std::acos(std::max(-1.0, std::min(1.0, cosine))) * kDegreesPerRadian; -} - -double Uniform(std::mt19937& io_random, double in_low, double in_high) -{ - const double unit = (static_cast(io_random()) + 0.5) / 4294967296.0; - return in_low + (in_high - in_low) * unit; -} - -int UniformInt(std::mt19937& io_random, int in_low, int in_high) -{ - const uint32_t span = static_cast(in_high - in_low) + 1u; - return in_low + static_cast(io_random() % span); -} - -int Poisson(std::mt19937& io_random, double in_mean) -{ - const double limit = std::exp(-in_mean); - double product = Uniform(io_random, 0.0, 1.0); - int count = 0; - while (product > limit) - { - ++count; - product *= Uniform(io_random, 0.0, 1.0); - } - return count; -} - -double LogUniform(std::mt19937& io_random, double in_low, double in_high) -{ - return std::exp(Uniform(io_random, std::log(in_low), std::log(in_high))); -} - -Vec3 RandomDirection(std::mt19937& io_random) -{ - const double z = Uniform(io_random, -1.0, 1.0); - const double azimuth = Uniform(io_random, 0.0, 2.0 * kPi); - const double planar = std::sqrt(std::max(0.0, 1.0 - z * z)); - return {planar * std::cos(azimuth), planar * std::sin(azimuth), z}; -} - -Vec3 AnyPerpendicular(const Vec3& in_direction) -{ - const Vec3 helper = std::fabs(in_direction.x) < 0.9 ? Vec3{1.0, 0.0, 0.0} : Vec3{0.0, 1.0, 0.0}; - return Normalize(Cross(in_direction, helper)); -} - -Vec3 RotateAround(const Vec3& in_v, const Vec3& in_axis, double in_radians) -{ - const double cosine = std::cos(in_radians); - const double sine = std::sin(in_radians); - const Vec3 cross = Cross(in_axis, in_v); - const double along = Dot(in_axis, in_v); - return Add(Add(Scale(in_v, cosine), Scale(cross, sine)), Scale(in_axis, along * (1.0 - cosine))); -} - -Vec3 OffsetDirection(const Vec3& in_direction, double in_maxDegrees, std::mt19937& io_random) -{ - const Vec3 direction = Normalize(in_direction); - const double angle = ToRadians(in_maxDegrees) * std::sqrt(Uniform(io_random, 0.0, 1.0)); - const double azimuth = Uniform(io_random, 0.0, 2.0 * kPi); - const Vec3 side = AnyPerpendicular(direction); - const Vec3 tilt = RotateAround(side, direction, azimuth); - return RotateAround(direction, Normalize(Cross(direction, tilt)), angle); -} - -Vec3 PickSeparatedDirection(std::vector& io_used, double in_minDegrees, std::mt19937& io_random) -{ - Vec3 candidate = RandomDirection(io_random); - for (int attempt = 0; attempt < 10000; ++attempt) - { - bool separated = true; - for (const Vec3& used : io_used) - { - if (AngleDegrees(used, candidate) < in_minDegrees) - { - separated = false; - break; - } - } - if (separated) - { - break; - } - candidate = RandomDirection(io_random); - } - io_used.push_back(candidate); - return candidate; -} - -/** @brief Produces unique random input keys from a seed. */ -class KeySource -{ -public: - /** @brief Creates a key source for a seed. */ - explicit KeySource(uint32_t in_seed) - : m_random(in_seed ^ 0x5EEDu) - { - } - - /** @brief Returns a key that this source has not returned before. */ - uint64_t Next() - { - for (;;) - { - const uint64_t key = (static_cast(m_random()) << 32) | m_random(); - if (m_used.insert(key).second) - { - return key; - } - } - } - -private: - std::mt19937 m_random; ///< Random source for the keys. - std::unordered_set m_used; ///< Keys returned so far. -}; - -int SecondsToFrames(const Scenario& in_scenario, double in_seconds) -{ - return std::max(1, static_cast(std::lround(in_seconds / in_scenario.frameSeconds))); -} - -/** @brief Centre of a group of sources, which the members copy their motion from. */ -struct Centre -{ - Vec3 direction; ///< Unit direction of the centre from the listener. - double range = 1.0; ///< Distance of the centre from the listener. - Motion motion = Motion::Static; ///< Motion model of the members. - Vec3 axis; ///< Unit rotation axis of orbiting members. - double degreesPerSecond = 0.0; ///< Rotation speed of orbiting members. -}; - -SourceSpec& AddMember(Scenario& io_scenario, KeySource& io_keys, std::mt19937& io_random, const Centre& in_centre, - double in_spreadDegrees, double in_rangeJitter, uint32_t in_group) -{ - SourceSpec source; - source.key = io_keys.Next(); - source.group = in_group; - source.priority = static_cast(UniformInt(io_random, 10, 90)); - source.loudness = static_cast(Uniform(io_random, 0.02, 0.5)); - const Vec3 direction = OffsetDirection(in_centre.direction, in_spreadDegrees, io_random); - const double range = in_centre.range * Uniform(io_random, 1.0 - in_rangeJitter, 1.0 + in_rangeJitter); - source.origin = Scale(direction, range); - source.motion = in_centre.motion; - source.axis = in_centre.axis; - source.degreesPerSecond = in_centre.degreesPerSecond; - io_scenario.sources.push_back(source); - return io_scenario.sources.back(); -} - -void AddOneShots(Scenario& io_scenario, KeySource& io_keys, std::mt19937& io_random, const std::vector& in_centres, - double in_ratePerSecond, double in_spreadDegrees, double in_passthroughShare) -{ - const double perFrame = in_ratePerSecond * io_scenario.frameSeconds; - for (int frame = 0; frame < io_scenario.frames; ++frame) - { - const int count = Poisson(io_random, perFrame); - for (int index = 0; index < count; ++index) - { - const Centre& centre = in_centres[static_cast(UniformInt(io_random, 0, static_cast(in_centres.size()) - 1))]; - SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, in_spreadDegrees, 0.3, 0); - source.startFrame = frame; - source.endFrame = frame + SecondsToFrames(io_scenario, Uniform(io_random, 0.2, 2.0)); - source.loudness = static_cast(Uniform(io_random, 0.01, 0.8)); - source.priority = static_cast(UniformInt(io_random, 0, 100)); - source.clusterable = Uniform(io_random, 0.0, 1.0) >= in_passthroughShare; - } - } -} - -std::vector MakeStaticCentres(std::mt19937& io_random, int in_count, double in_minSeparation, double in_minRange, - double in_maxRange) -{ - std::vector used; - std::vector centres; - for (int index = 0; index < in_count; ++index) - { - Centre centre; - centre.direction = PickSeparatedDirection(used, in_minSeparation, io_random); - centre.range = LogUniform(io_random, in_minRange, in_maxRange); - centres.push_back(centre); - } - return centres; -} - -void BuildStatic(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "static groups with fixed members"; - io_scenario.frames = 400; - io_scenario.settleFrame = 60; - for (const Centre& centre : MakeStaticCentres(io_random, 12, 25.0, 10.0, 1000.0)) - { - const int members = UniformInt(io_random, 3, 8); - for (int member = 0; member < members; ++member) - { - AddMember(io_scenario, io_keys, io_random, centre, 2.0, 0.1, 0); - } - } -} - -void BuildCrossing(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "groups orbiting in opposite directions that cross and separate"; - io_scenario.frames = 1500; - for (int index = 0; index < 8; ++index) - { - Centre centre; - const double tilt = ToRadians(Uniform(io_random, -4.0, 4.0)); - centre.axis = Normalize(Vec3{0.0, std::cos(tilt), std::sin(tilt)}); - const Vec3 start = AnyPerpendicular(centre.axis); - centre.direction = RotateAround(start, centre.axis, Uniform(io_random, 0.0, 2.0 * kPi)); - centre.range = LogUniform(io_random, 20.0, 400.0); - centre.motion = Motion::Orbit; - const double speed = Uniform(io_random, 20.0, 40.0); - centre.degreesPerSecond = (index % 2 == 0) ? speed : -speed; - const int members = UniformInt(io_random, 3, 6); - for (int member = 0; member < members; ++member) - { - AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, 0); - } - } -} - -void BuildOneShots(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys, double in_passthroughShare) -{ - io_scenario.description = "persistent groups plus random one-shots of 0.2 to 2 s around the group centres"; - io_scenario.frames = 2000; - const std::vector centres = MakeStaticCentres(io_random, 10, 20.0, 20.0, 500.0); - for (const Centre& centre : centres) - { - for (int member = 0; member < 3; ++member) - { - SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, 0); - source.clusterable = Uniform(io_random, 0.0, 1.0) >= in_passthroughShare; - } - } - AddOneShots(io_scenario, io_keys, io_random, centres, 100.0, 4.0, in_passthroughShare); -} - -void BuildJitter(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "static groups whose members jitter by up to 0.25 degrees every frame"; - io_scenario.frames = 1000; - io_scenario.settleFrame = 60; - for (const Centre& centre : MakeStaticCentres(io_random, 10, 30.0, 10.0, 1000.0)) - { - const int members = UniformInt(io_random, 3, 6); - for (int member = 0; member < members; ++member) - { - SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, 0); - source.jitterDegrees = 0.25; - } - } -} - -void BuildPassBy(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "sources that fly past a static group, join it and leave it again"; - io_scenario.frames = 800; - Centre centre; - centre.direction = {0.0, 0.0, 1.0}; - centre.range = 100.0; - for (int member = 0; member < 6; ++member) - { - SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, 1.0, 0.05, 0); - source.loudness = 0.2f; - } - - SourceSpec first; - first.key = io_keys.Next(); - first.loudness = 0.3f; - first.motion = Motion::Linear; - first.origin = {-400.0, 3.0, 100.0}; - first.velocity = {100.0, 0.0, 0.0}; - io_scenario.sources.push_back(first); - - SourceSpec second; - second.key = io_keys.Next(); - second.loudness = 0.1f; - second.motion = Motion::Linear; - second.origin = {300.0, -2.0, 60.0}; - second.velocity = {-80.0, 0.0, 0.0}; - io_scenario.sources.push_back(second); -} - -void BuildSqueeze(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "many groups with maxOutputs lowered and raised while running"; - io_scenario.frames = 600; - for (const Centre& centre : MakeStaticCentres(io_random, 24, 12.0, 10.0, 1000.0)) - { - for (int member = 0; member < 4; ++member) - { - AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, 0); - } - } - for (int index = 0; index < 4; ++index) - { - SourceSpec source; - source.key = io_keys.Next(); - source.clusterable = false; - source.origin = Scale(RandomDirection(io_random), 50.0); - io_scenario.sources.push_back(source); - } - const uint32_t budgets[] = {12, 20, 8}; - const int frames[] = {100, 300, 450}; - for (int index = 0; index < 3; ++index) - { - ParamChange change; - change.frame = frames[index]; - change.params = io_scenario.params; - change.params.maxOutputs = budgets[index]; - io_scenario.changes.push_back(change); - } -} - -void BuildNearField(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "sources around the listener inside and outside the near field, and high priority sources"; - io_scenario.frames = 1200; - io_scenario.params.nearFieldRadius = 5.f; - io_scenario.params.excludePriorityAbove = 80; - for (int index = 0; index < 18; ++index) - { - const bool inside = index < 12; - SourceSpec source; - source.key = io_keys.Next(); - source.loudness = static_cast(Uniform(io_random, 0.05, 0.5)); - const Vec3 direction = RandomDirection(io_random); - source.origin = Scale(direction, inside ? Uniform(io_random, 1.0, 4.5) : Uniform(io_random, 6.0, 10.0)); - source.motion = Motion::Orbit; - source.axis = AnyPerpendicular(direction); - source.degreesPerSecond = inside ? Uniform(io_random, 30.0, 90.0) : Uniform(io_random, 5.0, 20.0); - io_scenario.sources.push_back(source); - } - for (int index = 0; index < 2; ++index) - { - SourceSpec source; - source.key = io_keys.Next(); - source.loudness = 0.2f; - source.motion = Motion::Linear; - source.origin = {-50.0, 1.0 + index, 2.0}; - source.velocity = {20.0 + 5.0 * index, 0.0, 0.0}; - io_scenario.sources.push_back(source); - } - Centre centre; - centre.direction = Normalize(Vec3{0.3, 0.2, 1.0}); - centre.range = 50.0; - const uint32_t priorities[] = {90, 90, 40, 40, 40}; - for (uint32_t priority : priorities) - { - SourceSpec& source = AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.05, 0); - source.priority = priority; - } -} - -void BuildGroups(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "three group IDs that share the same directions"; - io_scenario.frames = 400; - io_scenario.settleFrame = 60; - for (const Centre& centre : MakeStaticCentres(io_random, 4, 30.0, 20.0, 400.0)) - { - for (uint32_t group = 0; group < 3; ++group) - { - for (int member = 0; member < 4; ++member) - { - AddMember(io_scenario, io_keys, io_random, centre, 1.5, 0.1, group * 7u + 1u); - } - } - } -} - -void BuildCloud(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "dense cloud of slowly drifting sources inside a 60 degree cone"; - io_scenario.frames = 1500; - for (int index = 0; index < 200; ++index) - { - SourceSpec source; - source.key = io_keys.Next(); - source.loudness = static_cast(Uniform(io_random, 0.01, 0.5)); - source.priority = static_cast(UniformInt(io_random, 0, 100)); - const Vec3 direction = OffsetDirection(Vec3{0.0, 0.0, 1.0}, 30.0, io_random); - source.origin = Scale(direction, LogUniform(io_random, 10.0, 1000.0)); - source.motion = Motion::Orbit; - source.axis = Normalize(Cross(direction, RandomDirection(io_random))); - source.degreesPerSecond = Uniform(io_random, 1.0, 6.0); - io_scenario.sources.push_back(source); - } -} - -void BuildStress(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "about 1024 inputs around 64 slowly orbiting centres"; - io_scenario.frames = 1000; - std::vector centres; - std::vector used; - for (int index = 0; index < 64; ++index) - { - Centre centre; - centre.direction = PickSeparatedDirection(used, 8.0, io_random); - centre.range = LogUniform(io_random, 10.0, 1000.0); - centre.motion = Motion::Orbit; - centre.axis = Normalize(Cross(centre.direction, RandomDirection(io_random))); - centre.degreesPerSecond = Uniform(io_random, 2.0, 10.0); - centres.push_back(centre); - for (int member = 0; member < 12; ++member) - { - AddMember(io_scenario, io_keys, io_random, centre, 3.0, 0.2, 0); - } - } - AddOneShots(io_scenario, io_keys, io_random, centres, 233.0, 3.0, 0.0); -} - -/** @brief Ship of the battle scenario, which its engine and weapon voices move with. */ -struct Ship -{ - Motion motion = Motion::Orbit; ///< Orbit around the listener or a straight fly-by. - Vec3 origin; ///< Position at frame zero. - Vec3 axis; ///< Unit rotation axis of an orbiting ship. - double degreesPerSecond = 0.0; ///< Rotation speed of an orbiting ship. - Vec3 velocity; ///< Velocity of a flying-by ship in units per second. -}; - -/** @brief Returns an offset in a random direction with a random length between two radii. */ -Vec3 RandomOffset(std::mt19937& io_random, double in_minRadius, double in_maxRadius) -{ - // Separate statements keep the order of the random draws the same on every compiler. - const Vec3 direction = RandomDirection(io_random); - return Scale(direction, Uniform(io_random, in_minRadius, in_maxRadius)); -} - -/** @brief Adds a voice at an offset from a ship's centre that moves with the ship. */ -SourceSpec& AddShipVoice(Scenario& io_scenario, KeySource& io_keys, const Ship& in_ship, const Vec3& in_offset) -{ - SourceSpec source; - source.key = io_keys.Next(); - source.motion = in_ship.motion; - source.axis = in_ship.axis; - source.degreesPerSecond = in_ship.degreesPerSecond; - source.velocity = in_ship.velocity; - source.origin = Add(in_ship.origin, in_offset); - io_scenario.sources.push_back(source); - return io_scenario.sources.back(); -} - -void BuildBattle(Scenario& io_scenario, std::mt19937& io_random, KeySource& io_keys) -{ - io_scenario.description = "14 ships orbiting and flying past the listener, each with engine voices and 2 to 6 weapons that " - "play 2 to 6 voices per shot, plus two ambience beds, for 8 s"; - io_scenario.frames = SecondsToFrames(io_scenario, 8.0); - // Plug-in default settings. - io_scenario.params.dwellFrames = static_cast(SecondsToFrames(io_scenario, 0.2)); - io_scenario.params.crossfadeFrames = static_cast(SecondsToFrames(io_scenario, 0.05)); - io_scenario.params.loudnessSmoothing = static_cast(std::exp(-io_scenario.frameSeconds / 0.05)); - const double seconds = io_scenario.frames * io_scenario.frameSeconds; - - for (int index = 0; index < 14; ++index) - { - Ship ship; - if (index % 3 == 2) - { - // Passes the listener at its closest distance at a random time during the scenario. - ship.motion = Motion::Linear; - const Vec3 travel = RandomDirection(io_random); - const Vec3 side = RotateAround(AnyPerpendicular(travel), travel, Uniform(io_random, 0.0, 2.0 * kPi)); - const Vec3 closest = Scale(side, Uniform(io_random, 10.0, 80.0)); - ship.velocity = Scale(travel, Uniform(io_random, 50.0, 180.0)); - ship.origin = Add(closest, Scale(ship.velocity, -Uniform(io_random, 1.0, seconds - 1.0))); - } - else - { - ship.motion = Motion::Orbit; - // Mostly around the horizon, like ships around the player. - const Vec3 direction = RandomDirection(io_random); - ship.origin = Scale(Normalize(Vec3{direction.x, direction.y * 0.4, direction.z}), LogUniform(io_random, 40.0, 800.0)); - const double tilt = ToRadians(Uniform(io_random, -20.0, 20.0)); - ship.axis = Normalize(Vec3{std::sin(tilt), std::cos(tilt), 0.0}); - const double speed = Uniform(io_random, 4.0, 25.0); - ship.degreesPerSecond = Uniform(io_random, 0.0, 1.0) < 0.5 ? -speed : speed; - } - - // Voices sit up to 8 units from the ship's centre, so a ship covers a wider angle the closer it is. - const int engines = UniformInt(io_random, 1, 3); - for (int engine = 0; engine < engines; ++engine) - { - SourceSpec& voice = AddShipVoice(io_scenario, io_keys, ship, RandomOffset(io_random, 0.0, 8.0)); - voice.loudness = static_cast(Uniform(io_random, 0.05, 0.3)); - voice.priority = static_cast(UniformInt(io_random, 30, 60)); - } - - // Each weapon fires at its own rate, and every shot plays several voices at the weapon's hardpoint. - const int weapons = UniformInt(io_random, 2, 6); - for (int weapon = 0; weapon < weapons; ++weapon) - { - const Vec3 hardpoint = RandomOffset(io_random, 2.0, 8.0); - const int voicesPerShot = UniformInt(io_random, 2, 6); - const double interval = Uniform(io_random, 0.4, 1.5); - for (double shot = Uniform(io_random, 0.0, interval); shot < seconds; shot += interval * Uniform(io_random, 0.8, 1.2)) - { - for (int layer = 0; layer < voicesPerShot; ++layer) - { - SourceSpec& voice = AddShipVoice(io_scenario, io_keys, ship, Add(hardpoint, RandomOffset(io_random, 0.0, 1.0))); - const double start = shot + Uniform(io_random, 0.0, 0.05); - voice.startFrame = static_cast(std::lround(start / io_scenario.frameSeconds)); - voice.endFrame = voice.startFrame + SecondsToFrames(io_scenario, Uniform(io_random, 0.15, 1.2)); - voice.loudness = static_cast(Uniform(io_random, 0.05, 0.9)); - voice.priority = static_cast(UniformInt(io_random, 20, 90)); - } - } - } - } - - for (int index = 0; index < 2; ++index) - { - SourceSpec bed; - bed.key = io_keys.Next(); - bed.clusterable = false; - bed.loudness = 0.1f; - bed.origin = Scale(RandomDirection(io_random), 30.0); - io_scenario.sources.push_back(bed); - } -} - -struct ScenarioEntry -{ - const char* name; - uint32_t seed; -}; - -const ScenarioEntry kScenarios[] = { - {"static", 1001}, {"crossing", 1002}, {"oneshots", 1003}, {"jitter", 1004}, - {"passby", 1005}, {"squeeze", 1006}, {"nearfield", 1007}, {"groups", 1008}, - {"passthrough", 1009}, {"shuffle", 1010}, {"cloud", 1011}, {"stress", 1012}, - {"battle", 1013}, -}; -} - -const std::vector& GetScenarioNames() -{ - static const std::vector s_names = [] { - std::vector names; - for (const ScenarioEntry& entry : kScenarios) - { - names.push_back(entry.name); - } - return names; - }(); - return s_names; -} - -uint32_t GetDefaultSeed(const std::string& in_name) -{ - for (const ScenarioEntry& entry : kScenarios) - { - if (in_name == entry.name) - { - return entry.seed; - } - } - return 1; -} - -bool BuildScenario(const std::string& in_name, uint32_t in_seed, Scenario& out_scenario) -{ - out_scenario = Scenario(); - out_scenario.name = in_name; - out_scenario.seed = in_seed; - std::mt19937 random(in_seed); - KeySource keys(in_seed); - - if (in_name == "static") - { - BuildStatic(out_scenario, random, keys); - } - else if (in_name == "crossing") - { - BuildCrossing(out_scenario, random, keys); - } - else if (in_name == "oneshots") - { - BuildOneShots(out_scenario, random, keys, 0.0); - } - else if (in_name == "jitter") - { - BuildJitter(out_scenario, random, keys); - } - else if (in_name == "passby") - { - BuildPassBy(out_scenario, random, keys); - } - else if (in_name == "squeeze") - { - BuildSqueeze(out_scenario, random, keys); - } - else if (in_name == "nearfield") - { - BuildNearField(out_scenario, random, keys); - } - else if (in_name == "groups") - { - BuildGroups(out_scenario, random, keys); - } - else if (in_name == "passthrough") - { - out_scenario.params.maxOutputs = 32; - BuildOneShots(out_scenario, random, keys, 0.1); - out_scenario.description = "one-shots where a tenth of the inputs pass through, with maxOutputs 32"; - } - else if (in_name == "shuffle") - { - BuildOneShots(out_scenario, random, keys, 0.0); - out_scenario.shuffle = true; - out_scenario.description = "one-shots with the input order shuffled every frame"; - } - else if (in_name == "cloud") - { - BuildCloud(out_scenario, random, keys); - } - else if (in_name == "stress") - { - BuildStress(out_scenario, random, keys); - } - else if (in_name == "battle") - { - BuildBattle(out_scenario, random, keys); - } - else - { - return false; - } - return true; -} - -void BuildBenchScenario(uint32_t in_inputs, uint32_t in_seed, int in_frames, Scenario& out_scenario) -{ - out_scenario = Scenario(); - out_scenario.name = "bench"; - out_scenario.description = "fixed input count around slowly orbiting centres with respawning one-shots"; - out_scenario.seed = in_seed; - out_scenario.frames = in_frames; - std::mt19937 random(in_seed); - KeySource keys(in_seed); - - const int centreCount = std::max(8, std::min(128, static_cast(in_inputs / 4u))); - std::vector centres; - std::vector used; - for (int index = 0; index < centreCount; ++index) - { - Centre centre; - centre.direction = PickSeparatedDirection(used, 8.0, random); - centre.range = LogUniform(random, 10.0, 1000.0); - centre.motion = Motion::Orbit; - centre.axis = Normalize(Cross(centre.direction, RandomDirection(random))); - centre.degreesPerSecond = Uniform(random, 2.0, 10.0); - centres.push_back(centre); - } - - const uint32_t persistent = in_inputs * 3u / 4u; - for (uint32_t index = 0; index < persistent; ++index) - { - AddMember(out_scenario, keys, random, centres[index % centres.size()], 3.0, 0.2, 0); - } - for (uint32_t index = persistent; index < in_inputs; ++index) - { - int frame = 0; - while (frame < in_frames) - { - const Centre& centre = centres[static_cast(UniformInt(random, 0, centreCount - 1))]; - SourceSpec& source = AddMember(out_scenario, keys, random, centre, 3.0, 0.3, 0); - source.startFrame = frame; - source.endFrame = frame + SecondsToFrames(out_scenario, Uniform(random, 0.2, 2.0)); - source.loudness = static_cast(Uniform(random, 0.01, 0.8)); - frame = source.endFrame; - } - } -} - -ScenarioPlayer::ScenarioPlayer(const Scenario& in_scenario, uint32_t in_orderSeed, bool in_shuffle) - : m_scenario(in_scenario) - , m_jitterRandom(in_scenario.seed ^ 0x717E7u) - , m_orderRandom(in_orderSeed) - , m_shuffle(in_shuffle) - , m_params(in_scenario.params) -{ -} - -const std::vector& ScenarioPlayer::NextFrame() -{ - ++m_frame; - while (m_nextChange < m_scenario.changes.size() && m_scenario.changes[m_nextChange].frame <= m_frame) - { - m_params = m_scenario.changes[m_nextChange].params; - ++m_nextChange; - } - - const double seconds = m_frame * m_scenario.frameSeconds; - m_inputs.clear(); - for (const SourceSpec& source : m_scenario.sources) - { - if (m_frame < source.startFrame || m_frame >= source.endFrame) - { - continue; - } - Vec3 position = source.origin; - if (source.motion == Motion::Orbit) - { - position = RotateAround(source.origin, source.axis, ToRadians(source.degreesPerSecond * seconds)); - } - else if (source.motion == Motion::Linear) - { - position = Add(source.origin, Scale(source.velocity, seconds)); - } - if (source.jitterDegrees > 0.0) - { - position = Scale(OffsetDirection(position, source.jitterDegrees, m_jitterRandom), Length(position)); - } - - ClusterInput input; - input.key = source.key; - input.position = {static_cast(position.x), static_cast(position.y), static_cast(position.z)}; - input.loudness = source.loudness; - input.priority = source.priority; - input.group = source.group; - input.clusterable = source.clusterable; - m_inputs.push_back(input); - } - if (m_shuffle) - { - for (size_t index = m_inputs.size(); index > 1; --index) - { - const size_t other = m_orderRandom() % index; - std::swap(m_inputs[index - 1], m_inputs[other]); - } - } - return m_inputs; -} - -const ClusterParams& ScenarioPlayer::GetParams() const -{ - return m_params; -} - -int ScenarioPlayer::GetFrame() const -{ - return m_frame; -} diff --git a/core/tests/src/Scenarios.h b/core/tests/src/Scenarios.h deleted file mode 100644 index a419e3b..0000000 --- a/core/tests/src/Scenarios.h +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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 "ClusterEngine.h" - -#include -#include -#include -#include -#include - -/** @brief Double precision vector for scenario motion. */ -struct Vec3 -{ - double x = 0.0; ///< X coordinate. - double y = 0.0; ///< Y coordinate. - double z = 0.0; ///< Z coordinate. -}; - -/** @brief How a scenario source moves over time. */ -enum class Motion -{ - Static, ///< Stays at its origin. - Orbit, ///< Rotates around an axis through the listener. - Linear ///< Moves along a straight line. -}; - -/** @brief One sound source of a scenario. */ -struct SourceSpec -{ - uint64_t key = 0; ///< Input key. - uint32_t group = 0; ///< Input group. - bool clusterable = true; ///< False for passthrough inputs. - uint32_t priority = 50; ///< Input priority. - float loudness = 0.1f; ///< RMS level. - int startFrame = 0; ///< First frame in which the source plays. - int endFrame = INT_MAX; ///< Frame in which the source no longer plays. - Motion motion = Motion::Static; ///< Motion model. - Vec3 origin; ///< Position at frame zero. - Vec3 axis; ///< Unit rotation axis of an orbiting source. - double degreesPerSecond = 0.0; ///< Rotation speed of an orbiting source. - Vec3 velocity; ///< Velocity of a linear source in units per second. - double jitterDegrees = 0.0; ///< Largest random direction offset applied each frame. -}; - -/** @brief Parameters that apply from a given frame on. */ -struct ParamChange -{ - int frame = 0; ///< First frame with the new parameters. - ClusterParams params; ///< New parameters. -}; - -/** @brief Complete description of a generated scenario. */ -struct Scenario -{ - std::string name; ///< Scenario name. - std::string description; ///< One line description. - uint32_t seed = 0; ///< Seed the scenario was generated from. - double frameSeconds = 512.0 / 48000.0; ///< Frame length in seconds. - int frames = 0; ///< Number of frames to run. - int settleFrame = -1; ///< Frame after which no moves are expected, or -1. - bool shuffle = false; ///< Shuffles the input order every frame. - ClusterParams params; ///< Parameters at frame zero. - std::vector changes; ///< Later parameter changes in frame order. - std::vector sources; ///< All sources. -}; - -/** @brief Returns the names of all scenarios. */ -const std::vector& GetScenarioNames(); - -/** @brief Returns the default seed of a scenario. */ -uint32_t GetDefaultSeed(const std::string& in_name); - -/** @brief Builds a scenario by name and returns false if the name is unknown. */ -bool BuildScenario(const std::string& in_name, uint32_t in_seed, Scenario& out_scenario); - -/** @brief Builds the benchmark scenario with exactly the given number of inputs in every frame. */ -void BuildBenchScenario(uint32_t in_inputs, uint32_t in_seed, int in_frames, Scenario& out_scenario); - -/** @brief Produces the engine inputs of a scenario frame by frame. */ -class ScenarioPlayer -{ -public: - /** @brief Prepares playback of a scenario with a seed for the input order. */ - ScenarioPlayer(const Scenario& in_scenario, uint32_t in_orderSeed, bool in_shuffle); - - /** @brief Builds the inputs of the next frame. */ - const std::vector& NextFrame(); - - /** @brief Returns the parameters of the frame last built. */ - const ClusterParams& GetParams() const; - - /** @brief Returns the index of the frame last built. */ - int GetFrame() const; - -private: - const Scenario& m_scenario; ///< Scenario being played. - std::mt19937 m_jitterRandom; ///< Random source for direction jitter. - std::mt19937 m_orderRandom; ///< Random source for the input order. - bool m_shuffle = false; ///< Shuffles the input order every frame. - std::vector m_inputs; ///< Inputs of the frame last built. - ClusterParams m_params; ///< Parameters of the frame last built. - size_t m_nextChange = 0; ///< Index of the next parameter change. - int m_frame = -1; ///< Index of the frame last built. -}; diff --git a/core/tests/src/Tests.cpp b/core/tests/src/Tests.cpp deleted file mode 100644 index 3d4b5f5..0000000 --- a/core/tests/src/Tests.cpp +++ /dev/null @@ -1,1811 +0,0 @@ -/* - * 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 "FrameAnalysis.h" -#include "MathConstants.h" -#include "Modes.h" -#include "Scenarios.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define EXPECT(context, condition, ...) \ - do \ - { \ - if (!(condition)) \ - { \ - (context).Fail(__VA_ARGS__); \ - } \ - } while (false) - -namespace -{ -/** @brief Number of failed checks per test that are printed. */ -constexpr int kMaxReportedFailures = 12; - -/** @brief Counts and prints the failed checks of one test. */ -class TestContext -{ -public: - /** @brief Creates a context for the test with the given name. */ - explicit TestContext(const char* in_name) - : m_name(in_name) - { - } - - /** @brief Records a failed check and prints its message unless too many were printed. */ - void Fail(const char* in_format, ...) - { - ++m_failures; - if (m_failures > kMaxReportedFailures) - { - return; - } - std::printf(" FAIL %s: ", m_name); - va_list args; - va_start(args, in_format); - std::vprintf(in_format, args); - va_end(args); - std::printf("\n"); - } - - /** @brief Returns the number of failed checks. */ - int GetFailures() const - { - return m_failures; - } - -private: - const char* m_name; ///< Name of the test. - int m_failures = 0; ///< Number of failed checks. -}; - -ClusterInput MakeInput(uint64_t in_key, double in_degrees, double in_range, float in_loudness = 0.1f, uint32_t in_group = 0, - uint32_t in_priority = 50, bool in_clusterable = true) -{ - ClusterInput input; - input.key = in_key; - const double radians = in_degrees * kPi / 180.0; - input.position = {static_cast(std::sin(radians) * in_range), 0.f, static_cast(std::cos(radians) * in_range)}; - input.loudness = in_loudness; - input.priority = in_priority; - input.group = in_group; - input.clusterable = in_clusterable; - return input; -} - -ClusterInput MakeRawInput(uint64_t in_key, float in_x, float in_y, float in_z, float in_loudness = 0.1f) -{ - ClusterInput input; - input.key = in_key; - input.position = {in_x, in_y, in_z}; - input.loudness = in_loudness; - input.priority = 50; - input.group = 0; - input.clusterable = true; - return input; -} - -/** @brief One contribution of an input, with the cluster it goes to. */ -struct Membership -{ - int cluster = -1; ///< Index of the cluster in the frame's reported clusters. - uint32_t id = 0; ///< ID of the cluster. - float gainStart = 0.f; ///< Gain at the start of the frame. - float gainEnd = 0.f; ///< Gain at the end of the frame. -}; - -std::vector FindMemberships(const FrameView& in_view, uint64_t in_key) -{ - std::vector result; - for (const ClusterContribution& contribution : in_view.contributions) - { - if (contribution.input < in_view.inputs.size() && in_view.inputs[contribution.input].key == in_key) - { - Membership membership; - membership.cluster = static_cast(contribution.cluster); - membership.id = in_view.clusters[contribution.cluster].id; - membership.gainStart = contribution.gainStart; - membership.gainEnd = contribution.gainEnd; - result.push_back(membership); - } - } - return result; -} - -int HomeIdOf(const FrameView& in_view, uint64_t in_key) -{ - const std::vector home = FindHomeClusters(in_view); - for (size_t index = 0; index < in_view.inputs.size(); ++index) - { - if (in_view.inputs[index].key == in_key) - { - return home[index] < 0 ? -1 : static_cast(in_view.clusters[static_cast(home[index])].id); - } - } - return -1; -} - -float SanitizedLoudness(float in_loudness) -{ - if (!std::isfinite(in_loudness) || in_loudness <= 0.f) - { - return 0.f; - } - return std::min(in_loudness, 1e9f); -} - -/** @brief Checks the engine output of consecutive frames against the rules every frame must follow. */ -class InvariantChecker -{ -public: - /** @brief Checks one frame against the rules and against the previous frame. */ - void Check(TestContext& io_context, const char* in_label, int in_frame, const FrameView& in_view, const ClusterParams& in_params) - { - const size_t inputCount = in_view.inputs.size(); - const size_t clusterCount = in_view.clusters.size(); - EXPECT(io_context, in_view.updated, "%s frame %d: Update returned false", in_label, in_frame); - - std::unordered_set seenKeys; - std::vector isDuplicate(inputCount, false); - std::unordered_map groups; - for (size_t index = 0; index < inputCount; ++index) - { - const ClusterInput& input = in_view.inputs[index]; - isDuplicate[index] = !seenKeys.insert(input.key).second; - if (isDuplicate[index]) - { - continue; - } - groups[input.key] = input.group; - const auto previous = m_previousGroups.find(input.key); - if (previous != m_previousGroups.end() && previous->second != input.group) - { - m_groupChangeFrames[input.key] = in_frame; - } - } - m_maxCrossfadeFrames = std::max(m_maxCrossfadeFrames, in_params.crossfadeFrames); - const int groupChangeAllowance = 2 * static_cast(m_maxCrossfadeFrames) + 2; - - std::unordered_set ids; - for (size_t index = 0; index < clusterCount; ++index) - { - const ClusterInfo& cluster = in_view.clusters[index]; - ids.insert(cluster.id); - EXPECT(io_context, index == 0 || cluster.id > in_view.clusters[index - 1].id, - "[ids] %s frame %d: cluster ids not strictly ascending at %zu", in_label, in_frame, index); - const bool wasPresent = m_previousIds.count(cluster.id) != 0; - EXPECT(io_context, cluster.isNew == !wasPresent, "[is-new] %s frame %d: cluster %u isNew %d but present before %d", - in_label, in_frame, cluster.id, cluster.isNew ? 1 : 0, wasPresent ? 1 : 0); - EXPECT(io_context, IsFiniteVec(cluster.position), "[report] %s frame %d: cluster %u position not finite", in_label, - in_frame, cluster.id); - EXPECT(io_context, cluster.angularRadius >= 0.f && cluster.angularRadius <= 180.f, - "[report] %s frame %d: cluster %u radius %f", in_label, in_frame, cluster.id, cluster.angularRadius); - } - - std::vector sumStart(inputCount, 0.0); - std::vector sumEnd(inputCount, 0.0); - std::vector contributionCount(inputCount, 0); - std::vector perCluster(clusterCount, 0); - std::vector maxPriority(clusterCount, 0); - std::vector representativeFound(clusterCount, false); - std::vector clusterGroup(clusterCount, -1); - std::vector bestShare(clusterCount, -1.0); - std::unordered_map> pairs; - - for (const ClusterContribution& contribution : in_view.contributions) - { - const bool valid = contribution.input < inputCount && contribution.cluster < clusterCount; - EXPECT(io_context, valid, "[report] %s frame %d: contribution index out of range", in_label, in_frame); - if (!valid) - { - continue; - } - const ClusterInput& input = in_view.inputs[contribution.input]; - const ClusterInfo& cluster = in_view.clusters[contribution.cluster]; - const float gainStart = contribution.gainStart; - const float gainEnd = contribution.gainEnd; - EXPECT(io_context, std::isfinite(gainStart) && std::isfinite(gainEnd) && gainStart >= 0.f && gainStart <= 1.f && - gainEnd >= 0.f && gainEnd <= 1.f && (gainStart > 0.f || gainEnd > 0.f), - "[report] %s frame %d: bad gains %f %f", in_label, in_frame, gainStart, gainEnd); - EXPECT(io_context, input.clusterable, "[passthrough] %s frame %d: passthrough input %zu has a contribution", - in_label, in_frame, static_cast(contribution.input)); - EXPECT(io_context, !isDuplicate[contribution.input], "[duplicates] %s frame %d: repeated key has a contribution", - in_label, in_frame); - - sumStart[contribution.input] += gainStart; - sumEnd[contribution.input] += gainEnd; - ++contributionCount[contribution.input]; - ++perCluster[contribution.cluster]; - maxPriority[contribution.cluster] = std::max(maxPriority[contribution.cluster], input.priority); - if (contribution.input == cluster.representative) - { - representativeFound[contribution.cluster] = true; - } - const auto groupChange = m_groupChangeFrames.find(input.key); - const bool recentGroupChange = - groupChange != m_groupChangeFrames.end() && in_frame - groupChange->second <= groupChangeAllowance; - if (!recentGroupChange) - { - if (clusterGroup[contribution.cluster] < 0) - { - clusterGroup[contribution.cluster] = input.group; - } - EXPECT(io_context, clusterGroup[contribution.cluster] == static_cast(input.group), - "[groups] %s frame %d: cluster %u mixes groups", in_label, in_frame, cluster.id); - } - - const double weight = in_params.loudnessWeighting ? SanitizedLoudness(input.loudness) + 1e-6 : 1.0; - bestShare[contribution.cluster] = std::max(bestShare[contribution.cluster], weight * gainEnd); - - if (IsFiniteVec(input.position) && LengthOf(input.position) > 1e-3 && LengthOf(cluster.position) > 1e-3) - { - const double angle = AngleBetween(input.position, cluster.position); - EXPECT(io_context, angle <= cluster.angularRadius + 0.05, - "[report] %s frame %d: member at %.3f deg outside radius %.3f of cluster %u", in_label, in_frame, - angle, cluster.angularRadius, cluster.id); - } - - pairs[input.key][cluster.id] = gainEnd; - const auto previous = m_previousPairs.find(input.key); - const bool hadPair = previous != m_previousPairs.end() && previous->second.count(cluster.id) != 0; - if (hadPair) - { - const float previousEnd = previous->second.at(cluster.id); - EXPECT(io_context, std::fabs(gainStart - previousEnd) <= 1e-6f, - "[continuity] %s frame %d: key %llx cluster %u starts at %f after ending at %f", in_label, in_frame, - static_cast(input.key), cluster.id, gainStart, previousEnd); - } - else if (m_previousClusterable.count(input.key) != 0) - { - EXPECT(io_context, gainStart == 0.f, "[continuity] %s frame %d: key %llx new pair starts at %f", in_label, - in_frame, static_cast(input.key), gainStart); - } - else - { - EXPECT(io_context, gainStart == 1.f && gainEnd == 1.f, - "[continuity] %s frame %d: new key %llx starts with %f %f", in_label, in_frame, - static_cast(input.key), gainStart, gainEnd); - } - } - - for (size_t index = 0; index < clusterCount; ++index) - { - const ClusterInfo& cluster = in_view.clusters[index]; - EXPECT(io_context, perCluster[index] > 0, "[report] %s frame %d: cluster %u has no contribution", in_label, in_frame, - cluster.id); - EXPECT(io_context, perCluster[index] == cluster.memberCount, "[report] %s frame %d: cluster %u memberCount %u vs %u", - in_label, in_frame, cluster.id, cluster.memberCount, perCluster[index]); - EXPECT(io_context, representativeFound[index], "[report] %s frame %d: cluster %u representative not a member", - in_label, in_frame, cluster.id); - EXPECT(io_context, cluster.priority == maxPriority[index], "[report] %s frame %d: cluster %u priority %u vs %u", - in_label, in_frame, cluster.id, cluster.priority, maxPriority[index]); - if (representativeFound[index] && in_params.loudnessSmoothing == 0.f && cluster.representative < inputCount) - { - const ClusterInput& representative = in_view.inputs[cluster.representative]; - double representativeEnd = 0.0; - for (const ClusterContribution& contribution : in_view.contributions) - { - if (contribution.cluster == index && contribution.input == cluster.representative) - { - representativeEnd = contribution.gainEnd; - } - } - const double weight = - in_params.loudnessWeighting ? SanitizedLoudness(representative.loudness) + 1e-6 : 1.0; - EXPECT(io_context, weight * representativeEnd >= bestShare[index] * (1.0 - 1e-5), - "[report] %s frame %d: cluster %u representative is not the loudest", in_label, in_frame, cluster.id); - } - } - - std::unordered_set clusterable; - for (size_t index = 0; index < inputCount; ++index) - { - const ClusterInput& input = in_view.inputs[index]; - if (!input.clusterable || isDuplicate[index]) - { - EXPECT(io_context, contributionCount[index] == 0, "[passthrough] %s frame %d: input %zu has %d contributions", - in_label, in_frame, index, contributionCount[index]); - continue; - } - clusterable.insert(input.key); - EXPECT(io_context, contributionCount[index] == 1 || contributionCount[index] == 2, - "[gain-sum] %s frame %d: input %zu has %d contributions", in_label, in_frame, index, contributionCount[index]); - EXPECT(io_context, std::fabs(sumStart[index] - 1.0) <= 1e-5 && std::fabs(sumEnd[index] - 1.0) <= 1e-5, - "[gain-sum] %s frame %d: input %zu sums %f %f", in_label, in_frame, index, sumStart[index], sumEnd[index]); - } - - for (const auto& previous : m_previousPairs) - { - if (clusterable.count(previous.first) == 0) - { - continue; - } - const auto current = pairs.find(previous.first); - for (const auto& pair : previous.second) - { - const bool stillThere = current != pairs.end() && current->second.count(pair.first) != 0; - EXPECT(io_context, stillThere || pair.second <= 1e-6f, - "[continuity] %s frame %d: key %llx left cluster %u at gain %f without a fade", in_label, in_frame, - static_cast(previous.first), pair.first, pair.second); - } - } - - for (auto change = m_groupChangeFrames.begin(); change != m_groupChangeFrames.end();) - { - change = in_frame - change->second > groupChangeAllowance ? m_groupChangeFrames.erase(change) : std::next(change); - } - m_previousPairs.swap(pairs); - m_previousIds.swap(ids); - m_previousClusterable.swap(clusterable); - m_previousGroups.swap(groups); - } - -private: - std::unordered_map> m_previousPairs; ///< End gain per cluster ID of each input key in the previous frame. - std::unordered_set m_previousIds; ///< Cluster IDs reported in the previous frame. - std::unordered_set m_previousClusterable; ///< Keys of clusterable inputs in the previous frame. - std::unordered_map m_previousGroups; ///< Group of each input key in the previous frame. - std::unordered_map m_groupChangeFrames; ///< Frame of the recent group change of each input key. - uint32_t m_maxCrossfadeFrames = 1; ///< Longest crossfade in frames seen so far. -}; - -/** @brief Engine with a counting allocator that can check every frame it computes. */ -class TestEngine -{ -public: - /** @brief Creates an engine that checks every frame when a test context is given. */ - explicit TestEngine(uint32_t in_reserve = 64, TestContext* io_pContext = nullptr, const char* in_label = "") - : m_pContext(io_pContext) - , m_label(in_label) - { - m_engine.Init(&m_allocator, in_reserve); - } - - /** @brief Runs one update, checks it when a test context was given and returns the frame. */ - const FrameView& Step(const std::vector& in_inputs, const ClusterParams& in_params) - { - const bool updated = m_engine.Update(in_inputs.data(), static_cast(in_inputs.size()), in_params); - CaptureFrame(m_engine, in_inputs, updated, m_view); - if (m_pContext != nullptr) - { - m_checker.Check(*m_pContext, m_label, m_frame, m_view, in_params); - } - ++m_frame; - return m_view; - } - - /** @brief Returns the engine. */ - ClusterEngine& GetEngine() - { - return m_engine; - } - - /** @brief Returns the allocator of the engine. */ - CountingAllocator& GetAllocator() - { - return m_allocator; - } - -private: - CountingAllocator m_allocator; ///< Allocator of the engine. - ClusterEngine m_engine; ///< Engine under test. - FrameView m_view; ///< Frame of the last update. - InvariantChecker m_checker; ///< Checks every frame when a test context is given. - TestContext* m_pContext; ///< Test context for the checks, or nullptr. - const char* m_label; ///< Label printed with failed checks. - int m_frame = 0; ///< Number of updates run. -}; - -/** @brief Order in which a scenario hands its inputs to the engine. */ -enum class Order -{ - Scenario, ///< Shuffled only if the scenario asks for it. - Sorted, ///< Never shuffled. - Shuffled ///< Shuffled every frame. -}; - -/** @brief Options for playing a scenario. */ -struct RunOptions -{ - Order order = Order::Scenario; ///< Order of the inputs. - uint32_t orderSeed = 77; ///< Seed of the shuffle. - int maxFrames = -1; ///< Largest number of frames to play, or -1 for the whole scenario. - uint32_t reserve = 64; ///< Number of inputs the engine reserves memory for. -}; - -using FrameCallback = std::function; - -void PlayScenario(const Scenario& in_scenario, const RunOptions& in_options, const FrameCallback& in_callback) -{ - CountingAllocator allocator; - ClusterEngine engine; - engine.Init(&allocator, in_options.reserve); - const bool shuffle = in_options.order == Order::Shuffled || (in_options.order == Order::Scenario && in_scenario.shuffle); - ScenarioPlayer player(in_scenario, in_options.orderSeed, shuffle); - const int frames = in_options.maxFrames >= 0 ? std::min(in_options.maxFrames, in_scenario.frames) : in_scenario.frames; - FrameView view; - for (int frame = 0; frame < frames; ++frame) - { - const std::vector& inputs = player.NextFrame(); - const bool updated = engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); - CaptureFrame(engine, inputs, updated, view); - in_callback(frame, view, player.GetParams()); - } - engine.Term(); -} - -Scenario LoadScenario(const std::string& in_name, uint32_t in_seed = 0) -{ - Scenario scenario; - BuildScenario(in_name, in_seed == 0 ? GetDefaultSeed(in_name) : in_seed, scenario); - return scenario; -} - -/** @brief Engine output of one frame in a form that does not depend on the input order. */ -struct CanonicalFrame -{ - std::vector> clusters; ///< Clusters with the representative as a key. - std::vector> contributions; ///< Contributions by input key and cluster ID, sorted. - bool overBudget = false; ///< Value of IsOverBudget. - - /** @brief Returns true if both frames are equal. */ - bool operator==(const CanonicalFrame& in_other) const - { - return clusters == in_other.clusters && contributions == in_other.contributions && overBudget == in_other.overBudget; - } -}; - -CanonicalFrame MakeCanonical(const FrameView& in_view) -{ - CanonicalFrame canonical; - canonical.overBudget = in_view.overBudget; - for (const ClusterInfo& cluster : in_view.clusters) - { - const uint64_t representativeKey = - cluster.representative < in_view.inputs.size() ? in_view.inputs[cluster.representative].key : ~0ull; - canonical.clusters.emplace_back(cluster.id, cluster.isNew, cluster.position.x, cluster.position.y, cluster.position.z, - cluster.angularRadius, cluster.priority, cluster.memberCount, representativeKey); - } - for (const ClusterContribution& contribution : in_view.contributions) - { - canonical.contributions.emplace_back(in_view.inputs[contribution.input].key, in_view.clusters[contribution.cluster].id, - contribution.gainStart, contribution.gainEnd); - } - std::sort(canonical.contributions.begin(), canonical.contributions.end()); - return canonical; -} - -void TestInvariants(TestContext& io_context) -{ - for (const std::string& name : GetScenarioNames()) - { - const Scenario scenario = LoadScenario(name); - InvariantChecker checker; - PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { - checker.Check(io_context, name.c_str(), in_frame, in_view, in_params); - }); - } -} - -void TestInvariantsParameterSweep(TestContext& io_context) -{ - struct Variant - { - const char* label; - std::function apply; - }; - const std::vector variants = { - {"dwell0-fade0", [](ClusterParams& io_params) { io_params.dwellFrames = 0; io_params.crossfadeFrames = 0; }}, - {"dwell1-fade1", [](ClusterParams& io_params) { io_params.dwellFrames = 1; io_params.crossfadeFrames = 1; }}, - {"dwell30-fade10", [](ClusterParams& io_params) { io_params.dwellFrames = 30; io_params.crossfadeFrames = 10; }}, - {"distance0.5", [](ClusterParams& io_params) { io_params.distanceWeight = 0.5f; }}, - {"loudness-off", [](ClusterParams& io_params) { io_params.loudnessWeighting = false; }}, - {"smoothing0.9", [](ClusterParams& io_params) { io_params.loudnessSmoothing = 0.9f; }}, - {"join15-leave1", [](ClusterParams& io_params) { io_params.joinAngle = 15.f; io_params.leaveRatio = 1.f; }}, - {"join180-max1", [](ClusterParams& io_params) { io_params.joinAngle = 180.f; io_params.maxOutputs = 1; }}, - {"join0", [](ClusterParams& io_params) { io_params.joinAngle = 0.f; }}, - {"max6", [](ClusterParams& io_params) { io_params.maxOutputs = 6; }}, - {"nearfield50", [](ClusterParams& io_params) { io_params.nearFieldRadius = 50.f; io_params.excludePriorityAbove = 70; }}, - }; - const char* names[] = {"cloud", "crossing", "oneshots", "nearfield"}; - for (const char* name : names) - { - for (const Variant& variant : variants) - { - Scenario scenario = LoadScenario(name, GetDefaultSeed(name) + 17u); - variant.apply(scenario.params); - for (ParamChange& change : scenario.changes) - { - variant.apply(change.params); - } - const std::string label = std::string(name) + "/" + variant.label; - InvariantChecker checker; - RunOptions options; - options.maxFrames = 500; - options.order = Order::Shuffled; - PlayScenario(scenario, options, [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { - checker.Check(io_context, label.c_str(), in_frame, in_view, in_params); - }); - } - } -} - -void TestOrderIndependence(TestContext& io_context) -{ - const char* names[] = {"oneshots", "crossing", "groups", "nearfield", "squeeze", "cloud", "passthrough", "passby"}; - for (const char* name : names) - { - const Scenario scenario = LoadScenario(name); - const int frames = std::min(scenario.frames, 800); - CountingAllocator allocators[3]; - ClusterEngine engines[3]; - ScenarioPlayer players[3] = {ScenarioPlayer(scenario, 1, false), ScenarioPlayer(scenario, 99, true), - ScenarioPlayer(scenario, 12345, true)}; - for (int index = 0; index < 3; ++index) - { - engines[index].Init(&allocators[index], 64); - } - int mismatches = 0; - for (int frame = 0; frame < frames && mismatches < 3; ++frame) - { - CanonicalFrame canonical[3]; - for (int index = 0; index < 3; ++index) - { - const std::vector& inputs = players[index].NextFrame(); - const bool updated = - engines[index].Update(inputs.data(), static_cast(inputs.size()), players[index].GetParams()); - FrameView view; - CaptureFrame(engines[index], inputs, updated, view); - canonical[index] = MakeCanonical(view); - } - for (int index = 1; index < 3; ++index) - { - if (!(canonical[0] == canonical[index])) - { - ++mismatches; - io_context.Fail("%s frame %d: shuffled run %d differs from sorted run", name, frame, index); - } - } - } - } -} - -void TestStaticSettles(TestContext& io_context) -{ - struct Expectation - { - const char* name; - size_t clusters; - }; - const Expectation expectations[] = {{"static", 12}, {"groups", 12}, {"jitter", 10}}; - for (const Expectation& expectation : expectations) - { - const Scenario scenario = LoadScenario(expectation.name); - FrameTracker tracker; - std::vector settledIds; - PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams&) { - const FrameMetrics metrics = tracker.Observe(in_frame, in_view, 0.0); - if (in_frame < scenario.settleFrame) - { - return; - } - std::vector ids; - for (const ClusterInfo& cluster : in_view.clusters) - { - ids.push_back(cluster.id); - } - if (in_frame == scenario.settleFrame) - { - settledIds = ids; - EXPECT(io_context, ids.size() == expectation.clusters, "%s: %zu clusters after settling, expected %zu", - expectation.name, ids.size(), expectation.clusters); - return; - } - EXPECT(io_context, metrics.moves == 0, "%s frame %d: %u moves after settling", expectation.name, in_frame, - metrics.moves); - EXPECT(io_context, metrics.newClusters == 0, "%s frame %d: %u new clusters after settling", expectation.name, - in_frame, metrics.newClusters); - EXPECT(io_context, ids == settledIds, "%s frame %d: cluster set changed after settling", expectation.name, in_frame); - }); - } -} - -void TestJitterMakesNoMoves(TestContext& io_context) -{ - for (uint32_t seedOffset = 0; seedOffset < 3; ++seedOffset) - { - const Scenario scenario = LoadScenario("jitter", GetDefaultSeed("jitter") + seedOffset * 31u); - FrameTracker tracker; - uint64_t moves = 0; - PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams&) { - const FrameMetrics metrics = tracker.Observe(in_frame, in_view, 0.0); - if (in_frame > scenario.settleFrame) - { - moves += metrics.moves + metrics.newClusters; - } - }); - EXPECT(io_context, moves == 0, "jitter seed %u: %llu moves or new clusters after settling", scenario.seed, - static_cast(moves)); - } -} - -struct ProbeResult -{ - bool joinedBase = false; - bool overBudget = false; - size_t clusters = 0; - float gainStart = 0.f; - float gainEnd = 0.f; -}; - -ProbeResult RunJoinProbe(TestContext& io_context, const ClusterParams& in_settleParams, const ClusterParams& in_probeParams, - const ClusterInput& in_probe) -{ - TestEngine engine(64, &io_context, "join-probe"); - std::vector base = {MakeInput(1, 0.0, 10.0), MakeInput(2, 0.5, 10.0), MakeInput(3, -0.5, 10.0)}; - for (int frame = 0; frame < 20; ++frame) - { - engine.Step(base, in_settleParams); - } - const int baseId = HomeIdOf(engine.Step(base, in_settleParams), 1); - std::vector withProbe = base; - withProbe.push_back(in_probe); - const FrameView& view = engine.Step(withProbe, in_probeParams); - ProbeResult result; - const std::vector memberships = FindMemberships(view, in_probe.key); - result.joinedBase = memberships.size() == 1 && static_cast(memberships[0].id) == baseId; - result.overBudget = view.overBudget; - result.clusters = view.clusters.size(); - if (!memberships.empty()) - { - result.gainStart = memberships[0].gainStart; - result.gainEnd = memberships[0].gainEnd; - } - return result; -} - -void TestJoinsWithinJoinAngle(TestContext& io_context) -{ - // The probes below are laid out around a 5 degree join angle. - ClusterParams params; - params.joinAngle = 5.f; - ClusterParams budget = params; - budget.maxOutputs = 1; - ClusterParams distance = params; - distance.distanceWeight = 1.f; - - ProbeResult result = RunJoinProbe(io_context, params, params, MakeInput(10, 3.0, 10.0)); - EXPECT(io_context, result.joinedBase && result.clusters == 1, "input at 3 deg did not join a cluster at 0 deg"); - EXPECT(io_context, result.gainStart == 1.f && result.gainEnd == 1.f, "joining input does not start at full gain"); - - result = RunJoinProbe(io_context, params, params, MakeInput(11, 4.9, 10.0)); - EXPECT(io_context, result.joinedBase, "input at 4.9 deg did not join with joinAngle 5"); - - result = RunJoinProbe(io_context, params, params, MakeInput(12, 5.1, 10.0)); - EXPECT(io_context, !result.joinedBase && result.clusters == 2, "input at 5.1 deg joined with joinAngle 5"); - - result = RunJoinProbe(io_context, params, params, MakeInput(13, -40.0, 10.0)); - EXPECT(io_context, !result.joinedBase && result.clusters == 2, "input at -40 deg joined without a budget"); - - result = RunJoinProbe(io_context, params, budget, MakeInput(14, -40.0, 10.0)); - EXPECT(io_context, result.joinedBase && result.clusters == 1 && !result.overBudget, - "input at -40 deg was not forced into the only cluster by maxOutputs 1"); - - result = RunJoinProbe(io_context, params, params, MakeInput(15, 0.0, 10.0, 0.1f, 2)); - EXPECT(io_context, !result.joinedBase && result.clusters == 2, "input of another group joined"); - - result = RunJoinProbe(io_context, distance, distance, MakeInput(16, 1.0, 40.0)); - EXPECT(io_context, !result.joinedBase, "input at 1 deg and 4x the range joined with distanceWeight 1"); - - result = RunJoinProbe(io_context, distance, distance, MakeInput(17, 1.0, 11.0)); - EXPECT(io_context, result.joinedBase, "input at 1 deg and 1.1x the range did not join with distanceWeight 1"); - - ClusterParams nearField = params; - nearField.nearFieldRadius = 5.f; - result = RunJoinProbe(io_context, nearField, nearField, MakeInput(18, 0.0, 30.0)); - EXPECT(io_context, result.joinedBase, "input outside the near field did not join"); - result = RunJoinProbe(io_context, nearField, nearField, MakeInput(19, 0.0, 4.0)); - EXPECT(io_context, !result.joinedBase && result.clusters == 2, "input inside the near field joined"); -} - -void TestLeaveBound(TestContext& io_context) -{ - const char* names[] = {"crossing", "passby", "cloud", "oneshots"}; - for (const char* name : names) - { - const Scenario scenario = LoadScenario(name); - const ClusterParams& params = scenario.params; - const double leaveAngle = static_cast(params.joinAngle) * std::max(1.0f, params.leaveRatio); - const int allowed = static_cast(params.dwellFrames + params.crossfadeFrames) + 3; - std::unordered_map outside; - int worst = 0; - PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { - const std::vector home = FindHomeClusters(in_view); - std::unordered_map next; - for (size_t index = 0; index < in_view.inputs.size(); ++index) - { - const ClusterInput& input = in_view.inputs[index]; - if (!input.clusterable || home[index] < 0 || IsExcludedInput(input, in_params)) - { - continue; - } - const ClusterInfo& cluster = in_view.clusters[static_cast(home[index])]; - if (AngleBetween(input.position, cluster.position) <= leaveAngle + 0.05) - { - continue; - } - const auto previous = outside.find(input.key); - const int count = (previous == outside.end() ? 0 : previous->second) + 1; - next[input.key] = count; - worst = std::max(worst, count); - EXPECT(io_context, count <= allowed, "%s frame %d: key %llx outside leaveAngle for %d frames", name, in_frame, - static_cast(input.key), count); - } - outside.swap(next); - }); - EXPECT(io_context, worst <= allowed, "%s: worst stay outside leaveAngle %d frames", name, worst); - } -} - -void TestBudgetRespected(TestContext& io_context) -{ - const char* names[] = {"squeeze", "passthrough"}; - for (const char* name : names) - { - const Scenario scenario = LoadScenario(name); - int budgetFrames = 0; - PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { - if (in_params.maxOutputs == 0) - { - return; - } - ++budgetFrames; - uint32_t passthrough = 0; - for (const ClusterInput& input : in_view.inputs) - { - passthrough += input.clusterable ? 0u : 1u; - } - std::vector hasSteadyMember(in_view.clusters.size(), false); - bool allSteady = true; - for (const ClusterContribution& contribution : in_view.contributions) - { - const bool steady = contribution.gainStart == 1.f && contribution.gainEnd == 1.f; - allSteady = allSteady && steady; - if (steady) - { - hasSteadyMember[contribution.cluster] = true; - } - } - const uint32_t steadyClusters = - static_cast(std::count(hasSteadyMember.begin(), hasSteadyMember.end(), true)); - const bool feasible = passthrough < in_params.maxOutputs; - EXPECT(io_context, !in_view.overBudget || !feasible, "%s frame %d: over budget although %u passthrough < %u", name, - in_frame, passthrough, in_params.maxOutputs); - if (in_view.overBudget) - { - return; - } - EXPECT(io_context, steadyClusters + passthrough <= in_params.maxOutputs, - "%s frame %d: %u settled clusters + %u passthrough > %u", name, in_frame, steadyClusters, passthrough, - in_params.maxOutputs); - if (allSteady) - { - EXPECT(io_context, in_view.clusters.size() + passthrough <= in_params.maxOutputs, - "%s frame %d: %zu clusters + %u passthrough > %u without fades", name, in_frame, in_view.clusters.size(), - passthrough, in_params.maxOutputs); - } - }); - EXPECT(io_context, budgetFrames > 0, "%s: no frame ran with a budget", name); - } -} - -void TestBudgetOverflowFlagged(TestContext& io_context) -{ - ClusterParams params; - params.nearFieldRadius = 5.f; - params.maxOutputs = 3; - { - TestEngine engine(64, &io_context, "overflow"); - std::vector inputs; - for (uint64_t key = 1; key <= 6; ++key) - { - inputs.push_back(MakeInput(key, static_cast(key) * 0.5, 1.0)); - } - for (int frame = 0; frame < 30; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, view.overBudget, "frame %d: six excluded inputs with maxOutputs 3 not flagged", frame); - EXPECT(io_context, view.clusters.size() == 6, "frame %d: six excluded inputs gave %zu clusters", frame, - view.clusters.size()); - for (const ClusterInfo& cluster : view.clusters) - { - EXPECT(io_context, cluster.memberCount == 1, "frame %d: excluded cluster %u has %u members", frame, cluster.id, - cluster.memberCount); - } - } - } - { - ClusterParams groups; - groups.maxOutputs = 2; - TestEngine engine(64, &io_context, "overflow"); - std::vector inputs; - for (uint32_t group = 0; group < 3; ++group) - { - inputs.push_back(MakeInput(10 + group * 2, 0.0, 10.0, 0.1f, group)); - inputs.push_back(MakeInput(11 + group * 2, 30.0, 10.0, 0.1f, group)); - } - for (int frame = 0; frame < 30; ++frame) - { - const FrameView& view = engine.Step(inputs, groups); - EXPECT(io_context, view.overBudget, "frame %d: three groups with maxOutputs 2 not flagged", frame); - EXPECT(io_context, frame < 5 || view.clusters.size() == 3, "frame %d: three groups gave %zu clusters", frame, - view.clusters.size()); - } - } - { - ClusterParams passthrough; - passthrough.maxOutputs = 2; - TestEngine engine(64, &io_context, "overflow"); - std::vector inputs; - for (uint64_t key = 1; key <= 4; ++key) - { - inputs.push_back(MakeInput(key, key * 20.0, 10.0, 0.1f, 0, 50, false)); - } - inputs.push_back(MakeInput(20, 0.0, 10.0)); - inputs.push_back(MakeInput(21, 90.0, 10.0)); - for (int frame = 0; frame < 30; ++frame) - { - const FrameView& view = engine.Step(inputs, passthrough); - EXPECT(io_context, view.overBudget, "frame %d: four passthrough inputs with maxOutputs 2 not flagged", frame); - EXPECT(io_context, view.clusters.size() == 1, "frame %d: clusterable inputs did not share one cluster", frame); - } - } -} - -bool IsAlone(const FrameView& in_view, size_t in_input) -{ - int count = 0; - uint32_t cluster = 0; - for (const ClusterContribution& contribution : in_view.contributions) - { - if (contribution.input == in_input) - { - ++count; - cluster = contribution.cluster; - } - } - return count == 1 && in_view.clusters[cluster].memberCount == 1; -} - -void TestExcludedInputsAlone(TestContext& io_context) -{ - const Scenario scenario = LoadScenario("nearfield"); - const int allowed = static_cast(scenario.params.dwellFrames + 2 * scenario.params.crossfadeFrames) + 2; - std::unordered_map excludedSince; - std::unordered_map excludedFromStart; - std::unordered_set seen; - int excludedChecks = 0; - PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { - for (size_t index = 0; index < in_view.inputs.size(); ++index) - { - const ClusterInput& input = in_view.inputs[index]; - const bool firstFrame = seen.insert(input.key).second; - if (!IsExcludedInput(input, in_params)) - { - excludedSince.erase(input.key); - excludedFromStart[input.key] = false; - continue; - } - if (excludedSince.count(input.key) == 0) - { - excludedSince[input.key] = in_frame; - if (firstFrame) - { - excludedFromStart[input.key] = true; - } - } - const bool fromStart = excludedFromStart[input.key]; - if (fromStart || in_frame - excludedSince[input.key] > allowed) - { - ++excludedChecks; - EXPECT(io_context, IsAlone(in_view, index), "nearfield frame %d: excluded key %llx shares its cluster", in_frame, - static_cast(input.key)); - } - } - }); - EXPECT(io_context, excludedChecks > 1000, "nearfield: only %d excluded checks ran", excludedChecks); - - ClusterParams params; - params.excludePriorityAbove = 80; - TestEngine engine(64, &io_context, "priority-exclude"); - std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 0.3, 10.0, 0.1f, 0, 95), - MakeInput(3, -0.3, 10.0), MakeInput(4, 0.1, 10.0, 0.1f, 0, 81)}; - for (int frame = 0; frame < 40; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, IsAlone(view, 1) && IsAlone(view, 3), "frame %d: high priority inputs share a cluster", frame); - EXPECT(io_context, view.clusters.size() == 3, "frame %d: expected 3 clusters, got %zu", frame, view.clusters.size()); - } - - params.maxOutputs = 2; - for (int frame = 0; frame < 40; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, IsAlone(view, 1) && IsAlone(view, 3), "frame %d: high priority inputs share under a budget", frame); - EXPECT(io_context, view.overBudget, "frame %d: two excluded inputs plus one cluster with maxOutputs 2 not flagged", frame); - } -} - -void TestGroupsNeverShare(TestContext& io_context) -{ - const Scenario scenario = LoadScenario("groups"); - PlayScenario(scenario, RunOptions(), [&](int in_frame, const FrameView& in_view, const ClusterParams&) { - std::vector groups(in_view.clusters.size(), -1); - for (const ClusterContribution& contribution : in_view.contributions) - { - const long long group = in_view.inputs[contribution.input].group; - long long& clusterGroup = groups[contribution.cluster]; - EXPECT(io_context, clusterGroup < 0 || clusterGroup == group, "groups frame %d: cluster %u mixes groups", in_frame, - in_view.clusters[contribution.cluster].id); - clusterGroup = group; - } - }); - - ClusterParams params; - params.joinAngle = 180.f; - params.maxOutputs = 1; - TestEngine engine(64, &io_context, "groups-wide"); - std::vector inputs = {MakeInput(1, 0.0, 10.0, 0.1f, 1), MakeInput(2, 0.0, 10.0, 0.1f, 2), - MakeInput(3, 1.0, 10.0, 0.1f, 1), MakeInput(4, 1.0, 10.0, 0.1f, 2)}; - for (int frame = 0; frame < 20; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, view.clusters.size() == 2, "frame %d: two groups with joinAngle 180 gave %zu clusters", frame, - view.clusters.size()); - EXPECT(io_context, view.overBudget, "frame %d: two groups with maxOutputs 1 not flagged", frame); - } -} - -void TestNoAllocationsInUpdate(TestContext& io_context) -{ - const char* names[] = {"oneshots", "crossing", "nearfield", "squeeze", "cloud", "passthrough", "stress"}; - for (const char* name : names) - { - const Scenario scenario = LoadScenario(name); - uint32_t peak = 0; - { - ScenarioPlayer dryRun(scenario, 1, false); - for (int frame = 0; frame < scenario.frames; ++frame) - { - peak = std::max(peak, static_cast(dryRun.NextFrame().size())); - } - } - CountingAllocator allocator; - { - ClusterEngine engine; - EXPECT(io_context, engine.Init(&allocator, peak), "%s: Init failed", name); - const uint64_t afterInit = allocator.GetAllocCount(); - EXPECT(io_context, afterInit == 1, "%s: Init made %llu allocations", name, static_cast(afterInit)); - ScenarioPlayer player(scenario, 5, true); - for (int frame = 0; frame < scenario.frames; ++frame) - { - const std::vector& inputs = player.NextFrame(); - engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); - if (allocator.GetAllocCount() != afterInit) - { - io_context.Fail("%s frame %d: Update allocated with %zu inputs and %u reserved", name, frame, inputs.size(), - peak); - break; - } - } - - std::vector grown; - for (uint32_t index = 0; index <= peak; ++index) - { - grown.push_back(MakeInput(1000000 + index, index * 0.7, 10.0 + index)); - } - InvariantChecker checker; - FrameView view; - for (int frame = 0; frame < 5; ++frame) - { - const bool updated = engine.Update(grown.data(), static_cast(grown.size()), scenario.params); - EXPECT(io_context, updated, "%s: Update failed to grow", name); - CaptureFrame(engine, grown, updated, view); - checker.Check(io_context, name, frame, view, scenario.params); - } - EXPECT(io_context, allocator.GetAllocCount() == afterInit + 1 && allocator.GetOutstandingBlocks() == 1, - "%s: growing did not replace the block once (allocs %llu, blocks %zu)", name, - static_cast(allocator.GetAllocCount()), allocator.GetOutstandingBlocks()); - engine.Term(); - } - EXPECT(io_context, allocator.GetOutstandingBytes() == 0 && allocator.GetBadFreeCount() == 0, - "%s: %zu bytes outstanding after Term", name, allocator.GetOutstandingBytes()); - } -} - -void TestAllocationFailureKeepsState(TestContext& io_context) -{ - const ClusterParams params; - std::vector small; - for (uint64_t key = 1; key <= 8; ++key) - { - small.push_back(MakeInput(key, key * 2.0, 10.0)); - } - std::vector large = small; - for (uint64_t key = 100; key < 120; ++key) - { - large.push_back(MakeInput(key, key * 3.0, 20.0)); - } - - TestEngine failing(8); - TestEngine reference(8); - for (int frame = 0; frame < 5; ++frame) - { - failing.Step(small, params); - reference.Step(small, params); - } - FrameView before; - CaptureFrame(failing.GetEngine(), small, true, before); - failing.GetAllocator().SetFailing(true); - const bool updated = failing.GetEngine().Update(large.data(), static_cast(large.size()), params); - EXPECT(io_context, !updated, "Update succeeded although the allocator failed"); - FrameView after; - CaptureFrame(failing.GetEngine(), small, true, after); - EXPECT(io_context, MakeCanonical(before) == MakeCanonical(after), "failed Update changed the reported frame"); - failing.GetAllocator().SetFailing(false); - - for (int frame = 0; frame < 20; ++frame) - { - const FrameView& first = failing.Step(small, params); - const CanonicalFrame left = MakeCanonical(first); - const FrameView& second = reference.Step(small, params); - EXPECT(io_context, left == MakeCanonical(second), "frame %d after a failed Update differs from the reference", frame); - } - EXPECT(io_context, failing.GetEngine().Update(large.data(), static_cast(large.size()), params), - "Update failed after the allocator recovered"); -} - -void TestResetClearsState(TestContext& io_context) -{ - const Scenario warmup = LoadScenario("oneshots"); - const Scenario after = LoadScenario("crossing"); - TestEngine resetEngine(128); - TestEngine freshEngine(128); - { - ScenarioPlayer player(warmup, 3, true); - for (int frame = 0; frame < 300; ++frame) - { - resetEngine.Step(player.NextFrame(), player.GetParams()); - } - } - const uint64_t allocationsBefore = resetEngine.GetAllocator().GetAllocCount(); - resetEngine.GetEngine().Reset(); - EXPECT(io_context, resetEngine.GetEngine().GetClusterCount() == 0 && resetEngine.GetEngine().GetContributionCount() == 0, - "Reset kept reported clusters"); - EXPECT(io_context, resetEngine.GetAllocator().GetOutstandingBlocks() == 1, "Reset freed memory"); - - ScenarioPlayer leftPlayer(after, 8, true); - ScenarioPlayer rightPlayer(after, 8, true); - for (int frame = 0; frame < 300; ++frame) - { - const FrameView& left = resetEngine.Step(leftPlayer.NextFrame(), leftPlayer.GetParams()); - if (frame == 0) - { - for (const ClusterInfo& cluster : left.clusters) - { - EXPECT(io_context, cluster.isNew, "cluster %u not new on the first frame after Reset", cluster.id); - } - } - const CanonicalFrame leftCanonical = MakeCanonical(left); - const FrameView& right = freshEngine.Step(rightPlayer.NextFrame(), rightPlayer.GetParams()); - if (!(leftCanonical == MakeCanonical(right))) - { - io_context.Fail("frame %d after Reset differs from a fresh engine", frame); - break; - } - } - EXPECT(io_context, resetEngine.GetAllocator().GetAllocCount() == allocationsBefore, "Reset or later updates allocated"); -} - -void TestTermBalance(TestContext& io_context) -{ - const Scenario scenario = LoadScenario("oneshots"); - { - CountingAllocator allocator; - ClusterEngine engine; - engine.Init(&allocator, 256); - ScenarioPlayer player(scenario, 1, false); - for (int frame = 0; frame < 100; ++frame) - { - const std::vector& inputs = player.NextFrame(); - engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()); - } - engine.Term(); - EXPECT(io_context, allocator.GetOutstandingBlocks() == 0 && allocator.GetOutstandingBytes() == 0, - "Term left %zu blocks", allocator.GetOutstandingBlocks()); - engine.Term(); - EXPECT(io_context, allocator.GetBadFreeCount() == 0, "second Term freed twice"); - } - { - CountingAllocator allocator; - ClusterEngine engine; - engine.Init(&allocator, 32); - engine.Init(&allocator, 64); - EXPECT(io_context, allocator.GetOutstandingBlocks() == 1, "second Init kept %zu blocks", allocator.GetOutstandingBlocks()); - engine.Term(); - EXPECT(io_context, allocator.GetOutstandingBlocks() == 0, "Term after a second Init left memory"); - } - { - CountingAllocator allocator; - { - ClusterEngine engine; - engine.Init(&allocator, 0); - ScenarioPlayer player(scenario, 1, false); - for (int frame = 0; frame < 100; ++frame) - { - const std::vector& inputs = player.NextFrame(); - EXPECT(io_context, engine.Update(inputs.data(), static_cast(inputs.size()), player.GetParams()), - "Update failed to grow from an empty reserve"); - } - EXPECT(io_context, allocator.GetOutstandingBlocks() == 1, "growing kept %zu blocks", allocator.GetOutstandingBlocks()); - } - EXPECT(io_context, allocator.GetOutstandingBlocks() == 0 && allocator.GetBadFreeCount() == 0, - "destructor left %zu blocks", allocator.GetOutstandingBlocks()); - } - { - ClusterEngine engine; - engine.Term(); - engine.Reset(); - const ClusterInput input = MakeInput(1, 0.0, 1.0); - EXPECT(io_context, !engine.Update(&input, 1, ClusterParams{}), "Update without Init succeeded"); - EXPECT(io_context, engine.Update(nullptr, 0, ClusterParams{}), "empty Update without Init failed"); - EXPECT(io_context, engine.GetClusterCount() == 0 && engine.GetContributionCount() == 0, "engine without Init reports"); - EXPECT(io_context, !engine.Init(nullptr, 4), "Init without allocator succeeded"); - } -} - -void TestDegenerateInputs(TestContext& io_context) -{ - const ClusterParams params; - { - ClusterParams wide = params; - wide.joinAngle = 180.f; - wide.maxOutputs = 1; - TestEngine engine; - InvariantChecker checker; - const std::vector inputs = {MakeRawInput(1, 0.f, 0.f, 10.f), MakeRawInput(2, 0.f, 0.f, -10.f)}; - for (int frame = 0; frame < 10; ++frame) - { - const FrameView& view = engine.Step(inputs, wide); - checker.Check(io_context, "opposite-budget", frame, view, wide); - EXPECT(io_context, view.clusters.size() == 1, "opposite inputs with maxOutputs 1 gave %zu clusters", view.clusters.size()); - } - wide.maxOutputs = 0; - TestEngine unlimited; - for (int frame = 0; frame < 10; ++frame) - { - const FrameView& view = unlimited.Step(inputs, wide); - EXPECT(io_context, view.clusters.size() == 2, "opposite inputs with joinAngle 180 gave %zu clusters", - view.clusters.size()); - } - } - { - const float nan = std::numeric_limits::quiet_NaN(); - const float inf = std::numeric_limits::infinity(); - std::vector inputs = {MakeRawInput(1, nan, 0.f, 1.f), MakeRawInput(2, inf, 0.f, 0.f), - MakeRawInput(3, 0.f, -inf, 0.f), MakeRawInput(4, 0.f, 0.f, 10.f), - MakeRawInput(5, 0.1f, 0.f, 10.f), MakeRawInput(6, 0.f, 0.f, 0.f), - MakeRawInput(0, 1e30f, 0.f, 0.f), MakeRawInput(~0ull, FLT_MAX, FLT_MAX, FLT_MAX), - MakeRawInput(7, -FLT_MAX, 0.f, 1.f, nan), MakeRawInput(8, 1e-30f, 0.f, 0.f, -1.f), - MakeRawInput(9, 0.f, 1.f, 0.f, inf), MakeRawInput(10, 0.f, 1.f, 1e-3f, 1e30f)}; - TestEngine engine; - InvariantChecker checker; - for (int frame = 0; frame < 20; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - checker.Check(io_context, "non-finite", frame, view, params); - for (size_t index : {size_t(0), size_t(1), size_t(2)}) - { - const std::vector memberships = FindMemberships(view, inputs[index].key); - EXPECT(io_context, memberships.size() == 1, "non-finite input %zu has %zu memberships", index, memberships.size()); - if (memberships.size() == 1) - { - const ClusterInfo& cluster = view.clusters[static_cast(memberships[0].cluster)]; - EXPECT(io_context, cluster.memberCount == 1, "non-finite input %zu shares a cluster", index); - EXPECT(io_context, cluster.position.x == 0.f && cluster.position.y == 0.f && cluster.position.z == 0.f, - "non-finite input %zu not at the origin", index); - } - } - EXPECT(io_context, HomeIdOf(view, 4) == HomeIdOf(view, 5), "finite neighbours did not share a cluster"); - EXPECT(io_context, HomeIdOf(view, 6) >= 0 && HomeIdOf(view, 6) == HomeIdOf(view, 8), - "inputs at and next to the listener position did not share a cluster"); - } - inputs[0] = MakeRawInput(1, 0.f, 0.f, 10.f); - bool joined = false; - for (int frame = 20; frame < 60; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - checker.Check(io_context, "recovered", frame, view, params); - joined = joined || (HomeIdOf(view, 1) == HomeIdOf(view, 4)); - } - EXPECT(io_context, joined, "input with a recovered position never joined its neighbours"); - } - { - ClusterParams broken = params; - broken.joinAngle = std::numeric_limits::quiet_NaN(); - broken.leaveRatio = std::numeric_limits::quiet_NaN(); - broken.distanceWeight = std::numeric_limits::infinity(); - broken.nearFieldRadius = std::numeric_limits::quiet_NaN(); - broken.loudnessSmoothing = 5.f; - Scenario scenario = LoadScenario("cloud"); - scenario.params = broken; - InvariantChecker checker; - RunOptions options; - options.maxFrames = 200; - PlayScenario(scenario, options, [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { - checker.Check(io_context, "bad-params", in_frame, in_view, in_params); - }); - broken.joinAngle = 1e30f; - broken.leaveRatio = -3.f; - broken.distanceWeight = -1.f; - broken.nearFieldRadius = std::numeric_limits::infinity(); - scenario.params = broken; - InvariantChecker secondChecker; - PlayScenario(scenario, options, [&](int in_frame, const FrameView& in_view, const ClusterParams& in_params) { - secondChecker.Check(io_context, "extreme-params", in_frame, in_view, in_params); - EXPECT(io_context, in_view.clusters.size() == in_view.inputs.size(), - "infinite near field did not exclude every input (%zu clusters, %zu inputs)", in_view.clusters.size(), - in_view.inputs.size()); - }); - } - { - TestEngine engine; - InvariantChecker checker; - const std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(1, 30.0, 10.0), MakeInput(2, 0.2, 10.0), - MakeInput(2, 60.0, 10.0)}; - for (int frame = 0; frame < 10; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - checker.Check(io_context, "duplicates", frame, view, params); - EXPECT(io_context, view.clusters.size() == 1, "duplicate keys gave %zu clusters", view.clusters.size()); - } - } - { - TestEngine engine; - const FrameView& empty = engine.Step({}, params); - EXPECT(io_context, empty.updated && empty.clusters.empty() && empty.contributions.empty(), "empty update reported output"); - EXPECT(io_context, !engine.GetEngine().Update(nullptr, 3, params), "Update with null inputs succeeded"); - engine.Step({MakeInput(1, 0.0, 10.0), MakeInput(2, 1.0, 10.0)}, params); - const FrameView& cleared = engine.Step({}, params); - EXPECT(io_context, cleared.clusters.empty(), "clusters survived an empty update"); - } -} - -void TestCrossfadeRamp(TestContext& io_context) -{ - for (uint32_t fade : {4u, 1u, 0u}) - { - ClusterParams params; - params.joinAngle = 5.f; - params.dwellFrames = 4; - params.crossfadeFrames = fade; - TestEngine engine(64, &io_context, "crossfade"); - std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 0.3, 10.0), MakeInput(3, -0.3, 10.0)}; - for (int frame = 0; frame < 10; ++frame) - { - engine.Step(inputs, params); - } - const int oldId = HomeIdOf(engine.Step(inputs, params), 3); - inputs[2] = MakeInput(3, 20.0, 10.0); - const int moveFrame = static_cast(params.dwellFrames) - 1; - const uint32_t steps = std::max(fade, 1u); - for (int frame = 0; frame < moveFrame; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - const std::vector memberships = FindMemberships(view, 3); - EXPECT(io_context, memberships.size() == 1 && static_cast(memberships[0].id) == oldId, - "fade %u: input moved %d frames after leaving, before the dwell time", fade, frame); - } - int newId = -1; - for (uint32_t step = 0; step <= steps; ++step) - { - const FrameView& view = engine.Step(inputs, params); - const std::vector memberships = FindMemberships(view, 3); - if (step == steps) - { - EXPECT(io_context, memberships.size() == 1 && static_cast(memberships[0].id) == newId && - memberships[0].gainStart == 1.f && memberships[0].gainEnd == 1.f, - "fade %u: input did not settle in the new cluster after %u frames", fade, steps); - continue; - } - EXPECT(io_context, memberships.size() == 2, "fade %u step %u: %zu memberships while fading", fade, step, - memberships.size()); - if (memberships.size() != 2) - { - break; - } - const Membership& source = static_cast(memberships[0].id) == oldId ? memberships[0] : memberships[1]; - const Membership& target = static_cast(memberships[0].id) == oldId ? memberships[1] : memberships[0]; - if (step == 0) - { - newId = static_cast(target.id); - EXPECT(io_context, view.clusters[static_cast(target.cluster)].isNew, "fade %u: target cluster not new", - fade); - } - const float expectedStart = static_cast(step) / steps; - const float expectedEnd = static_cast(step + 1) / steps; - EXPECT(io_context, std::fabs(target.gainStart - expectedStart) < 1e-6f && std::fabs(target.gainEnd - expectedEnd) < 1e-6f, - "fade %u step %u: target gains %f %f, expected %f %f", fade, step, target.gainStart, target.gainEnd, - expectedStart, expectedEnd); - EXPECT(io_context, std::fabs(source.gainStart - (1.f - expectedStart)) < 1e-6f && - std::fabs(source.gainEnd - (1.f - expectedEnd)) < 1e-6f, - "fade %u step %u: source gains %f %f", fade, step, source.gainStart, source.gainEnd); - } - } -} - -void TestMergeAfterDwell(TestContext& io_context) -{ - ClusterParams narrow; - narrow.joinAngle = 3.f; - narrow.dwellFrames = 6; - narrow.crossfadeFrames = 2; - ClusterParams wide = narrow; - wide.joinAngle = 6.f; - TestEngine engine(64, &io_context, "merge"); - const std::vector inputs = {MakeInput(1, 0.0, 10.0, 0.5f), MakeInput(2, 0.2, 10.0, 0.5f), - MakeInput(3, 4.5, 10.0, 0.05f), MakeInput(4, 4.7, 10.0, 0.05f)}; - for (int frame = 0; frame < 10; ++frame) - { - const FrameView& view = engine.Step(inputs, narrow); - EXPECT(io_context, view.clusters.size() == 2, "frame %d: expected two clusters with joinAngle 3", frame); - } - const FrameView& settled = engine.Step(inputs, narrow); - const int heavyId = HomeIdOf(settled, 1); - const int lightId = HomeIdOf(settled, 3); - for (int frame = 0; frame < static_cast(wide.dwellFrames) - 1; ++frame) - { - const FrameView& view = engine.Step(inputs, wide); - EXPECT(io_context, HomeIdOf(view, 3) == lightId, "merge started after %d frames, before the dwell time", frame + 1); - } - const FrameView& merging = engine.Step(inputs, wide); - EXPECT(io_context, HomeIdOf(merging, 3) == heavyId && HomeIdOf(merging, 4) == heavyId, - "lighter cluster did not start merging into the heavier one after the dwell time"); - EXPECT(io_context, HomeIdOf(merging, 1) == heavyId, "heavier cluster members moved"); - for (int frame = 0; frame < 3; ++frame) - { - engine.Step(inputs, wide); - } - const FrameView& merged = engine.Step(inputs, wide); - EXPECT(io_context, merged.clusters.size() == 1 && static_cast(merged.clusters[0].id) == heavyId, - "after the merge %zu clusters remain", merged.clusters.size()); -} - -void TestMergeWaitsForFadingMembers(TestContext& io_context) -{ - ClusterParams params; - params.joinAngle = 4.f; - params.dwellFrames = 2; - params.crossfadeFrames = 10; - TestEngine engine(64, &io_context, "merge-fading"); - std::vector inputs = {MakeInput(1, 0.0, 10.0, 0.5f), MakeInput(2, 0.2, 10.0, 0.5f), - MakeInput(3, 8.0, 10.0, 0.05f), MakeInput(4, 8.2, 10.0, 0.05f), - MakeInput(5, 0.1, 10.0, 0.01f)}; - for (int frame = 0; frame < 10; ++frame) - { - engine.Step(inputs, params); - } - const FrameView& settled = engine.Step(inputs, params); - const int heavyId = HomeIdOf(settled, 1); - const int lightId = HomeIdOf(settled, 3); - EXPECT(io_context, heavyId != lightId && HomeIdOf(settled, 5) == heavyId, - "setup did not give two clusters with key 5 in the heavy one"); - - inputs[4] = MakeInput(5, 8.1, 10.0, 0.01f); - bool moving = false; - for (int frame = 0; frame < 10 && !moving; ++frame) - { - moving = HomeIdOf(engine.Step(inputs, params), 5) == lightId; - } - EXPECT(io_context, moving, "key 5 never started moving into the light cluster"); - - ClusterParams wide = params; - wide.joinAngle = 9.f; - bool mergedWhileFading = false; - bool followed = false; - for (int frame = 0; frame < 40; ++frame) - { - const FrameView& view = engine.Step(inputs, wide); - const std::vector memberships = FindMemberships(view, 5); - const int home = HomeIdOf(view, 5); - if (HomeIdOf(view, 3) == heavyId && home == lightId) - { - mergedWhileFading = true; - } - if (!followed && home == heavyId) - { - followed = true; - for (const Membership& membership : memberships) - { - EXPECT(io_context, static_cast(membership.id) != lightId || membership.gainStart == 1.f, - "key 5 left the dissolving cluster before its fade into it finished"); - } - } - } - EXPECT(io_context, mergedWhileFading, "the clusters did not merge while key 5 was still fading in"); - EXPECT(io_context, followed, "key 5 never followed the merge"); - const FrameView& merged = engine.Step(inputs, wide); - EXPECT(io_context, merged.clusters.size() == 1 && HomeIdOf(merged, 5) == heavyId, - "after following, %zu clusters remain", merged.clusters.size()); -} - -void TestGroupChangeCrossfades(TestContext& io_context) -{ - const ClusterParams params; - TestEngine engine(64, &io_context, "group-change"); - std::vector inputs = {MakeInput(1, 0.0, 10.0, 0.1f, 1), MakeInput(2, 0.5, 10.0, 0.1f, 1), - MakeInput(3, -0.5, 10.0, 0.1f, 1)}; - for (int frame = 0; frame < 10; ++frame) - { - engine.Step(inputs, params); - } - const int oldId = HomeIdOf(engine.Step(inputs, params), 3); - inputs[2].group = 2; - const uint32_t steps = std::max(params.crossfadeFrames, 1u); - for (uint32_t step = 0; step < steps; ++step) - { - const FrameView& view = engine.Step(inputs, params); - const std::vector memberships = FindMemberships(view, 3); - EXPECT(io_context, memberships.size() == 2, "step %u: input that changed group has %zu memberships, expected a crossfade", - step, memberships.size()); - if (memberships.size() != 2) - { - break; - } - const Membership& source = static_cast(memberships[0].id) == oldId ? memberships[0] : memberships[1]; - const Membership& target = static_cast(memberships[0].id) == oldId ? memberships[1] : memberships[0]; - const float expectedStart = static_cast(step) / steps; - const float expectedEnd = static_cast(step + 1) / steps; - EXPECT(io_context, static_cast(source.id) == oldId && static_cast(target.id) != oldId, - "step %u: input that changed group does not fade out of its old cluster", step); - EXPECT(io_context, std::fabs(target.gainStart - expectedStart) < 1e-6f && std::fabs(target.gainEnd - expectedEnd) < 1e-6f, - "step %u: target gains %f %f, expected %f %f", step, target.gainStart, target.gainEnd, expectedStart, expectedEnd); - } - int aloneId = -1; - for (int frame = 0; frame < 10; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - const std::vector memberships = FindMemberships(view, 3); - EXPECT(io_context, memberships.size() == 1 && memberships[0].gainStart == 1.f && memberships[0].gainEnd == 1.f, - "frame %d: input that changed group is not settled at full gain", frame); - EXPECT(io_context, view.clusters.size() == 2, "frame %d: expected two clusters after the group change", frame); - EXPECT(io_context, HomeIdOf(view, 3) != HomeIdOf(view, 1), "frame %d: changed input still shares", frame); - aloneId = HomeIdOf(view, 3); - } - - inputs[2].group = 5; - for (int frame = 0; frame < 5; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - const std::vector memberships = FindMemberships(view, 3); - EXPECT(io_context, memberships.size() == 1 && static_cast(memberships[0].id) == aloneId && - memberships[0].gainStart == 1.f && memberships[0].gainEnd == 1.f, - "frame %d: input alone in its cluster moved when its group changed", frame); - } - - inputs[1].clusterable = false; - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, FindMemberships(view, 2).empty(), "input that became passthrough still has a contribution"); - - ClusterParams slow; - slow.dwellFrames = 2; - slow.crossfadeFrames = 20; - TestEngine fading(64, &io_context, "group-change-mid-fade"); - inputs = {MakeInput(1, 0.0, 10.0, 0.5f), MakeInput(2, 0.5, 10.0, 0.5f), MakeInput(3, -0.5, 10.0, 0.1f), - MakeInput(10, 30.0, 10.0, 0.5f), MakeInput(20, 60.0, 10.0, 0.5f)}; - for (int frame = 0; frame < 10; ++frame) - { - fading.Step(inputs, slow); - } - inputs[2] = MakeInput(3, 29.0, 10.0, 0.1f); - bool fadeStarted = false; - for (int frame = 0; frame < 5; ++frame) - { - fadeStarted = fadeStarted || FindMemberships(fading.Step(inputs, slow), 3).size() == 2; - } - EXPECT(io_context, fadeStarted, "setup did not start a fade of key 3"); - inputs[2].group = 9; - for (int frame = 0; frame < 60; ++frame) - { - fading.Step(inputs, slow); - } - const FrameView& settled = fading.Step(inputs, slow); - const std::vector memberships = FindMemberships(settled, 3); - EXPECT(io_context, memberships.size() == 1 && memberships[0].gainEnd == 1.f && - settled.clusters[static_cast(memberships[0].cluster)].memberCount == 1, - "input whose group changed during a fade did not end alone in its own cluster"); -} - -void TestCentredInputsShare(TestContext& io_context) -{ - for (float offset : {0.f, 1e-5f}) - { - std::vector inputs; - for (uint64_t key = 1; key <= 40; ++key) - { - inputs.push_back(MakeRawInput(key, 0.f, 0.f, offset)); - } - ClusterParams params; - TestEngine unlimited(64, &io_context, "centred"); - for (int frame = 0; frame < 30; ++frame) - { - const FrameView& view = unlimited.Step(inputs, params); - EXPECT(io_context, view.clusters.size() == 1, "offset %g frame %d: 40 inputs at the listener gave %zu clusters", - offset, frame, view.clusters.size()); - } - params.maxOutputs = 8; - TestEngine budget(64, &io_context, "centred-budget"); - for (int frame = 0; frame < 30; ++frame) - { - const FrameView& view = budget.Step(inputs, params); - EXPECT(io_context, !view.overBudget && view.clusters.size() <= 8, - "offset %g frame %d: 40 inputs at the listener with maxOutputs 8 gave %zu clusters, over budget %d", offset, - frame, view.clusters.size(), view.overBudget ? 1 : 0); - } - params.joinAngle = 0.f; - TestEngine noJoin(64, &io_context, "centred-join0"); - for (int frame = 0; frame < 30; ++frame) - { - const FrameView& view = noJoin.Step(inputs, params); - EXPECT(io_context, !view.overBudget && view.clusters.size() <= 8, - "offset %g frame %d: 40 inputs at the listener with joinAngle 0 and maxOutputs 8 gave %zu clusters", offset, - frame, view.clusters.size()); - } - ClusterParams nearField; - nearField.nearFieldRadius = 1.f; - TestEngine excluded(64, &io_context, "centred-near-field"); - for (int frame = 0; frame < 5; ++frame) - { - const FrameView& view = excluded.Step(inputs, nearField); - EXPECT(io_context, view.clusters.size() == 40, - "offset %g frame %d: 40 inputs inside the near field gave %zu clusters", offset, frame, view.clusters.size()); - } - } - - ClusterParams wide; - wide.joinAngle = 180.f; - TestEngine mixed(64, &io_context, "centred-mixed"); - std::vector inputs = {MakeRawInput(1, 0.f, 0.f, 0.f), MakeRawInput(2, 0.f, 0.f, 0.f), MakeInput(3, 0.0, 10.0), - MakeInput(4, 90.0, 10.0)}; - for (int frame = 0; frame < 30; ++frame) - { - const FrameView& view = mixed.Step(inputs, wide); - EXPECT(io_context, view.clusters.size() == 2 && HomeIdOf(view, 1) == HomeIdOf(view, 2) && - HomeIdOf(view, 3) == HomeIdOf(view, 4) && HomeIdOf(view, 1) != HomeIdOf(view, 3), - "frame %d: inputs at the listener shared a cluster with inputs that have a direction", frame); - } - inputs[1] = MakeInput(2, 45.0, 10.0); - for (int frame = 0; frame < 30; ++frame) - { - mixed.Step(inputs, wide); - } - const FrameView& moved = mixed.Step(inputs, wide); - EXPECT(io_context, HomeIdOf(moved, 2) == HomeIdOf(moved, 3) && HomeIdOf(moved, 1) != HomeIdOf(moved, 3), - "input that moved away from the listener did not join the inputs with a direction"); -} - -void TestBudgetFarInputGetsOwnCluster(TestContext& io_context) -{ - ClusterParams params; - params.joinAngle = 5.f; - params.maxOutputs = 3; - TestEngine engine(64, &io_context, "budget-far"); - std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 6.0, 10.0), MakeInput(3, 12.0, 10.0)}; - for (int frame = 0; frame < 30; ++frame) - { - engine.Step(inputs, params); - } - EXPECT(io_context, engine.Step(inputs, params).clusters.size() == 3, "setup did not give three clusters"); - inputs.push_back(MakeInput(4, 180.0, 10.0)); - double worst = 0.0; - for (int frame = 0; frame < 600; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, !view.overBudget, "frame %d: flagged over budget although two clusters can merge", frame); - const std::vector far = FindMemberships(view, 4); - EXPECT(io_context, far.size() == 1 && view.clusters[static_cast(far[0].cluster)].memberCount == 1, - "frame %d: input at 180 deg does not have a cluster of its own", frame); - EXPECT(io_context, HomeIdOf(view, 1) == HomeIdOf(view, 2) && HomeIdOf(view, 3) != HomeIdOf(view, 1), - "frame %d: the clusters at 0 and 6 deg did not merge", frame); - EXPECT(io_context, frame < static_cast(params.crossfadeFrames) || view.clusters.size() == 3, - "frame %d: %zu clusters with maxOutputs 3", frame, view.clusters.size()); - const std::vector home = FindHomeClusters(view); - for (size_t index = 0; index < view.inputs.size(); ++index) - { - if (home[index] >= 0) - { - worst = std::max(worst, AngleBetween(view.inputs[index].position, - view.clusters[static_cast(home[index])].position)); - } - } - } - EXPECT(io_context, worst <= 12.05, "largest angle between an input and its cluster %.3f deg, above 12 deg", worst); -} - -void TestOverBudgetOnlyWhenUnmet(TestContext& io_context) -{ - ClusterParams params; - params.maxOutputs = 2; - TestEngine engine(64, &io_context, "budget-flag"); - std::vector inputs = {MakeInput(1, 0.0, 10.0), MakeInput(2, 90.0, 10.0)}; - for (int frame = 0; frame < 20; ++frame) - { - engine.Step(inputs, params); - } - inputs.push_back(MakeInput(3, 45.0, 10.0, 0.1f, 7)); - for (int frame = 0; frame < 10; ++frame) - { - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, !view.overBudget, "frame %d: flagged over budget although the group 0 clusters can merge", frame); - EXPECT(io_context, frame < static_cast(params.crossfadeFrames) || view.clusters.size() == 2, - "frame %d: %zu clusters with maxOutputs 2", frame, view.clusters.size()); - } -} - -void TestLoudnessWeighting(TestContext& io_context) -{ - const double loudAngle = -2.0; - const double quietAngle = 2.0; - auto expectedAngle = [&](double in_loudWeight, double in_quietWeight) { - const double loud = loudAngle * kPi / 180.0; - const double quiet = quietAngle * kPi / 180.0; - const double x = in_loudWeight * std::sin(loud) + in_quietWeight * std::sin(quiet); - const double z = in_loudWeight * std::cos(loud) + in_quietWeight * std::cos(quiet); - return std::atan2(x, z) * kDegreesPerRadian; - }; - auto clusterAngle = [](const FrameView& in_view) { - const ClusterVec3& position = in_view.clusters[0].position; - return std::atan2(static_cast(position.x), static_cast(position.z)) * kDegreesPerRadian; - }; - - ClusterParams params; - std::vector inputs = {MakeInput(1, loudAngle, 10.0, 1.0f), MakeInput(2, quietAngle, 10.0, 0.001f)}; - { - TestEngine engine(64, &io_context, "loudness"); - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, view.clusters.size() == 1, "two inputs 4 deg apart gave %zu clusters", view.clusters.size()); - const double expected = expectedAngle(1.0 + 1e-6, 0.001 + 1e-6); - EXPECT(io_context, std::fabs(clusterAngle(view) - expected) < 0.01, "weighted angle %f, expected %f", - clusterAngle(view), expected); - EXPECT(io_context, view.clusters[0].representative == 0, "representative is not the loudest input"); - } - { - params.loudnessWeighting = false; - TestEngine engine(64, &io_context, "loudness"); - const FrameView& view = engine.Step(inputs, params); - EXPECT(io_context, std::fabs(clusterAngle(view)) < 0.01, "unweighted angle %f, expected 0", clusterAngle(view)); - } - { - params.loudnessWeighting = true; - params.loudnessSmoothing = 0.9f; - TestEngine engine(64, &io_context, "loudness"); - for (int frame = 0; frame < 5; ++frame) - { - engine.Step(inputs, params); - } - inputs[1].loudness = 1.0f; - const FrameView& view = engine.Step(inputs, params); - const double smoothed = 0.9 * 0.001 + 0.1 * 1.0; - const double expected = expectedAngle(1.0 + 1e-6, smoothed + 1e-6); - EXPECT(io_context, std::fabs(clusterAngle(view) - expected) < 0.01, "smoothed angle %f, expected %f", clusterAngle(view), - expected); - - TestEngine fresh(64, &io_context, "loudness-fresh"); - const std::vector single = {MakeInput(1, loudAngle, 10.0, 1.0f)}; - for (int frame = 0; frame < 5; ++frame) - { - fresh.Step(single, params); - } - const std::vector loudPair = {MakeInput(1, loudAngle, 10.0, 1.0f), MakeInput(2, quietAngle, 10.0, 1.0f)}; - const FrameView& first = fresh.Step(loudPair, params); - EXPECT(io_context, first.clusters.size() == 1 && std::fabs(clusterAngle(first)) < 0.01, - "first frame of a new input did not use its raw loudness (angle %f)", clusterAngle(first)); - } -} - -/** @brief Test with the name used to select it. */ -struct TestCase -{ - const char* name; ///< Name of the test. - void (*function)(TestContext&); ///< Function that runs the test. -}; - -const TestCase kTests[] = { - {"invariants_all_scenarios", TestInvariants}, - {"invariants_parameter_sweep", TestInvariantsParameterSweep}, - {"order_independence", TestOrderIndependence}, - {"static_scene_settles", TestStaticSettles}, - {"jitter_makes_no_moves", TestJitterMakesNoMoves}, - {"joins_within_join_angle", TestJoinsWithinJoinAngle}, - {"leave_within_dwell_and_fade", TestLeaveBound}, - {"budget_respected", TestBudgetRespected}, - {"budget_overflow_flagged", TestBudgetOverflowFlagged}, - {"over_budget_only_when_unmet", TestOverBudgetOnlyWhenUnmet}, - {"budget_far_input_alone", TestBudgetFarInputGetsOwnCluster}, - {"excluded_inputs_alone", TestExcludedInputsAlone}, - {"centred_inputs_share", TestCentredInputsShare}, - {"groups_never_share", TestGroupsNeverShare}, - {"no_allocations_in_update", TestNoAllocationsInUpdate}, - {"allocation_failure_keeps_state", TestAllocationFailureKeepsState}, - {"reset_clears_state", TestResetClearsState}, - {"term_returns_allocator_balance", TestTermBalance}, - {"degenerate_inputs", TestDegenerateInputs}, - {"crossfade_ramp", TestCrossfadeRamp}, - {"merge_after_dwell", TestMergeAfterDwell}, - {"merge_waits_for_fading_members", TestMergeWaitsForFadingMembers}, - {"group_change_crossfades", TestGroupChangeCrossfades}, - {"loudness_weighting", TestLoudnessWeighting}, -}; -} - -int RunTests(const std::vector& in_args) -{ - const std::string filter = in_args.empty() ? std::string() : in_args[0]; - int failedTests = 0; - int ranTests = 0; - for (const TestCase& test : kTests) - { - if (!filter.empty() && std::string(test.name).find(filter) == std::string::npos) - { - continue; - } - ++ranTests; - TestContext context(test.name); - const auto start = std::chrono::steady_clock::now(); - test.function(context); - const double seconds = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); - if (context.GetFailures() == 0) - { - std::printf("PASS %-32s %7.2f s\n", test.name, seconds); - } - else - { - std::printf("FAIL %-32s %7.2f s (%d failed checks)\n", test.name, seconds, context.GetFailures()); - ++failedTests; - } - } - std::printf("%d of %d tests passed\n", ranTests - failedTests, ranTests); - return failedTests == 0 && ranTests > 0 ? 0 : 1; -} diff --git a/core/tests/viewer.html b/core/tests/viewer.html deleted file mode 100644 index 777f9ed..0000000 --- a/core/tests/viewer.html +++ /dev/null @@ -1,593 +0,0 @@ - - - - - - -Spatial Clustering Before and After - - - -
-

Spatial Clustering: before and after

- No trace loaded - - - -
-
-
-

Make a trace with the test program, then open it here or drop it on the page:

-

cluster_tests scenario battle --trace battle.json

-

Add --seed 42 (any number) for a different random battle.

-
- - -
-
Drop the trace file
- - - - - - - From a62037aff9ccb9c086f1bc0b9eba71f485462c8c Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 12:17:23 +0000 Subject: [PATCH 22/26] split the readme into a short overview and docs, and correct the docs against the code and the benchmark --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CONTRIBUTING.md | 5 +- README.md | 260 +++---------------- docs/best-practices.md | 17 ++ docs/building.md | 81 ++++++ docs/how-it-works.md | 65 +++++ docs/integration.md | 17 ++ docs/parameters.md | 20 ++ wwise/WwisePlugin/res/Md/en/crossfadeTime.md | 2 +- wwise/WwisePlugin/res/Md/en/maxOutputs.md | 2 +- 10 files changed, 244 insertions(+), 227 deletions(-) create mode 100644 docs/best-practices.md create mode 100644 docs/building.md create mode 100644 docs/how-it-works.md create mode 100644 docs/integration.md create mode 100644 docs/parameters.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 37504f7..e015c2f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,4 +5,4 @@ - [ ] 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 the Parameters table in `README.md` are updated. +- [ ] If a parameter changed: `SpatialClustering.xml`, the help page in `wwise/WwisePlugin/res/Md/en`, and `docs/parameters.md` are updated. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 330ee61..0a69cc1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ Contributions are accepted under the [Apache License 2.0](LICENSE.txt), the lice ## Building and testing your changes -[Building from Source](README.md#building-from-source) in the README covers the toolchain, the plug-in build and ways to try the plug-in in a game. +[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 @@ -32,5 +32,6 @@ Contributions are accepted under the [Apache License 2.0](LICENSE.txt), the lice | `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 Parameters table in `README.md`. The order in which `GetBankParameters` writes the values must match the order in which `SpatialClusteringFXParams` reads them. +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/README.md b/README.md index dbff2b9..bdf83cd 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,11 @@ Spatial Clustering is a Wwise plug-in that merges audio objects that reach the l ## Features - **Clustering by direction**: inputs are grouped by their angle from the listener, so the same settings work at any world scale. -- **Persistent clusters**: an input stays in its cluster from frame to frame and only moves when it has been outside the cluster for a set time. -- **Crossfaded moves**: an input that changes cluster fades out of the old cluster and into the new one, so there are no clicks or jumps. -- **Output budget**: an optional limit on the number of outputs. When the limit is reached, the closest clusters are merged. -- **Passthrough**: inputs with more than two channels (ambiences, beds) and inputs without 3D spatialization pass through with their audio and metadata unchanged. -- **Exclusions**: high priority inputs and inputs close to the listener can be kept out of clusters. -- **Real-time safe**: memory comes from the Wwise plug-in allocator, and an update for 512 inputs takes about 30 to 60 µs. See [Performance](#performance). +- **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 @@ -24,256 +23,73 @@ Spatial Clustering is a Wwise plug-in that merges audio objects that reach the l ## 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 average direction. 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. 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. +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 accurate positioning overall: without the plug-in, objects over the endpoint's budget are mixed into the bed and lose their exact position. With it, every cluster is a system audio object at its own position. A crowded scene can sound less wide than without the plug-in, because the overflow no longer smears into the bed, but each sound is placed more precisely. -- More resources for sound designers to add unique locations of spatialized objects +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 -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, such as 2025.1. +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 from the zip. Then install Spatial Clustering into your Wwise install. +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. -Upgrading from 2.0.0 or earlier? Uninstall the old version first, since both versions use the same plug-in IDs. The 3.0.0 release notes list the renamed files. - -No release for your Wwise version? [Build it from source](#building-from-source). +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). ## Quick Start 1. Set the channel configuration of a bus to **Audio Objects**. 2. In the bus's **Effects** tab, add the **Spatial Clustering** effect. -3. Route the sounds you want merged to that bus. Mono and stereo sounds with 3D spatialization are clustered; everything else passes through. -4. Start with the default settings, then look at the clusters in the [profiler views](#profiling) and tune the [parameters](#parameters). -## Use It in Your Game + ![Spatial Clustering inserted on an Audio Objects bus in Wwise](https://github.com/user-attachments/assets/88c217c1-87c9-4132-aba8-04eede2d46e1) + +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 **Join Angle**, plus **Max Outputs** for a fixed object budget. + + ![Clusters in the Wwise Audio Object profiler views](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) -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. +## Documentation -- **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`. -- **CMake**: build the plug-in from source with your game, see [Build with CMake](#build-with-cmake). Link `SpatialClustering::SpatialClusteringFX` from `find_package(SpatialClustering CONFIG)` 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. +- [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. ## Compatibility | Version | Wwise | Authoring | Runtime | |---|---|---|---| -| 3.0.0 | 2025.1 | Windows x64 | Windows x64 (Visual Studio 2026) | - -Other platforms are not released yet. Building for them needs the matching Wwise deployment platform; see [Building from Source](#building-from-source). - -## 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 is mono or stereo, 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, and its spread carries over to the cluster output (see [Cluster outputs](#cluster-outputs)). -- 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; a cluster output carries the custom metadata of its representative input. - -### 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 Weight** 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 **Join Angle**. - -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. - -### Joining and leaving - -- A new input joins the nearest cluster within the **Join Angle**. If there is none, it starts a new cluster. -- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within 1.5 × the **Join Angle**. 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 **Join Angle** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. -- A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster. -- 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 never loses it. - -### 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, so the input keeps its level during the move. - -A switch between clustered and passthrough, for example when an RTPC moves Speaker Panning / 3D Spatialization Mix below 100, happens without a crossfade. - -### Merging - -Two clusters that stay within the **Join Angle** of each other for the **Dwell Time** merge. The quieter cluster fades into the louder one. A merge only happens if the merged cluster would still fit within the **Join Angle**. - -### Output budget - -With **Max Outputs** 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 dwell time. A higher priority makes a pair count as farther apart, so it merges later. - -- At the limit, a new input that is farther than 1.5 × the **Join Angle** 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 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. - -### 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 spread of the representative input (the loudest one) and **Spread From Cluster Size** × (cluster angular radius / 180°). -- **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 **Exclude Priority Above**, and inputs closer to the listener than **Near-Field Radius**, keep an output of their own and are never merged. An input that becomes excluded while it is in a cluster leaves it after the Dwell Time, with a crossfade. - -### Performance - -Memory comes from the Wwise plug-in allocator. It is reserved for 64 inputs in Init and grows only when more inputs arrive. An update for 512 inputs takes about 30 to 60 µs. Lowering Max Outputs far below the current number of clusters merges them all in one update, which takes about 0.7 to 1.4 ms for 512 inputs and 1.5 to 3.5 ms for 1024 inputs. - -## 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 **Join Angle**, and **Max Outputs** when you have a fixed object budget. - -| Group | Parameter | Range | Default | Description | -|---|---|---|---|---| -| Clustering | Join Angle (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 | Max Outputs (0 = no limit) | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | -| Protection | Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | -| Protection | Near-Field 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 | Dwell 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 Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | -| Advanced | Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | - -Dwell 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 Join Angle of 180 with Max Outputs at 1 mixes all clusterable inputs into one object, as long as no passthrough or excluded inputs need outputs of their own and all inputs carry the same custom metadata. A Join Angle of 0 with a Max Outputs limit only merges when the limit is reached. - -## Best Practices +| 3.0.0 | 2025.1 | Windows x64 | Windows x64, Visual Studio 2026 | -### Bus Setup - -- Insert the plug-in as an Effect on an Audio Objects bus (bus channel configuration set to Audio Objects). 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 Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. -- Use Near-Field Radius to keep sounds close to the listener separate. -- Use separate busses with their own settings for different categories, for example a smaller Join Angle for dense sources (turret fire) and a larger one for spread-out sounds (shields). - -![Spatial Clustering inserted on an Audio Objects bus in Wwise](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. -- 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 for a visual spatial representation of the clusters. -- 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 Max Outputs allows, when it cannot create output objects and when it runs out of memory. Repeated messages of the same kind are posted at most once every 5 seconds. - -![Clusters in the Wwise Audio Object profiler views](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) - -## 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 (SDK install, environment variables, compiler requirements). 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 for vc170 and vc180, 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, and the authoring plug-in uses MFC. -- 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 Profile -x x64 -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 -``` - -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` (static) and `%WWISEROOT%\SDK\x64_vc180\\bin\SpatialClustering.dll` (dynamic). -- 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 `\\\\\`. - -### 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 takes the Wwise SDK from a `Wwise` CMake package, such as the one a vcpkg port provides, or else from `WWISE_SDK_DIR`, which defaults to the `WWISESDK` environment variable. The Release configuration defines `AK_OPTIMIZED`, like the Release sound engine libraries. - -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: - -``` -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 first with `wp.py build Documentation`. - -### Package a Release - -`scripts/package.py` builds everything above (Debug, Profile and Release for the runtime plug-in) and packages it as a Wwise Launcher bundle. Run it from the repository root, in the same kind of command prompt: - -``` -python scripts\package.py -``` - -It writes `dist\SpatialClustering__Wwise.\` with the bundle and the license files, and a zip of that folder for the GitHub release. `--skip-build` packages what is already built. - -The plug-in version is set in `wwise/SpatialClusteringVersion.h`. 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. - -### 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 yet ship a dedicated sample scene for spatial audio testing. That's on the list to add. - -### Debugging - -To place breakpoints and debug the plug-in, follow the steps [here](https://www.audiokinetic.com/qa/7840/wwise-sdk-how-step-through-code-using-visual-studio-debugger). +Other platforms are not released yet. They can be built from source with the matching Wwise deployment platform. ## Contributing -We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for how to open issues and pull requests. +We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md). -## License and Legal Notices +## License © 2024 - present CCP ehf. -This software was developed by CCP Games for spatial audio object clustering in EVE Online and EVE Frontier, using the Audiokinetic Wwise SDK. - -This project is licensed under the [Apache License 2.0](LICENSE.txt). +Developed by CCP Games for spatial audio object clustering in EVE Online and EVE Frontier, using the Audiokinetic Wwise SDK. Licensed under the [Apache License 2.0](LICENSE.txt). See [NOTICE](NOTICE) for third-party notices. diff --git a/docs/best-practices.md b/docs/best-practices.md new file mode 100644 index 0000000..60ae83e --- /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 Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. +- Use Near-Field Radius to keep sounds close to the listener separate. +- Use separate busses with their own settings for different categories, for example a smaller Join Angle 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 Max Outputs 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..6caeed2 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,81 @@ +# 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_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..65bec36 --- /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 Weight** 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 **Join Angle**. + +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 **Join Angle**. If there is none, it starts a new cluster. +- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within 1.5 × the **Join Angle**. 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 **Join Angle** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. With a Join Angle of 120° or more, inputs never leave because of their direction. +- A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster and tries again after another Dwell 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 **Join Angle** for the **Dwell 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 **Join Angle**. + +## Output Budget + +With **Max Outputs** 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 Dwell 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 **Join Angle** 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 **Spread From Cluster Size** × 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 **Exclude Priority Above**, inputs closer to the listener than **Near-Field Radius** 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 Dwell 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 Max Outputs 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..94cec7b --- /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`. +- **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)` 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..f07454d --- /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 **Join Angle**, and **Max Outputs** when you have a fixed object budget. + +| Group | Parameter | Range | Default | Description | +|---|---|---|---|---| +| Clustering | Join Angle, 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 | Max Outputs | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | +| Protection | Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | +| Protection | Near-Field 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 | Dwell 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 Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | +| Advanced | Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | + +Dwell 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 Join Angle of 180 with Max Outputs 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 Join Angle of 0 with a Max Outputs 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/wwise/WwisePlugin/res/Md/en/crossfadeTime.md b/wwise/WwisePlugin/res/Md/en/crossfadeTime.md index aea3525..3aff91b 100644 --- a/wwise/WwisePlugin/res/Md/en/crossfadeTime.md +++ b/wwise/WwisePlugin/res/Md/en/crossfadeTime.md @@ -1,6 +1,6 @@ ## 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 lighter one fades into the other over the same 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. diff --git a/wwise/WwisePlugin/res/Md/en/maxOutputs.md b/wwise/WwisePlugin/res/Md/en/maxOutputs.md index de62382..d23b3f5 100644 --- a/wwise/WwisePlugin/res/Md/en/maxOutputs.md +++ b/wwise/WwisePlugin/res/Md/en/maxOutputs.md @@ -4,6 +4,6 @@ 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 Dwell Time. A higher priority makes a pair count as farther apart, so it merges later. -**Note**: Passthrough inputs, excluded inputs 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 its crossfade ends, so the number of outputs can be above the limit for the length of a crossfade. +**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 From 2399bca79aed8f4624a3ccfa405ba87ab2b26049 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 12:35:15 +0000 Subject: [PATCH 23/26] rename the protection parameter group to exclusions --- docs/parameters.md | 4 ++-- wwise/WwisePlugin/SpatialClustering.xml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/parameters.md b/docs/parameters.md index f07454d..cd579c8 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -6,8 +6,8 @@ All parameters can be driven by RTPCs. The Wwise property editor groups them lik |---|---|---|---|---| | Clustering | Join Angle, 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 | Max Outputs | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | -| Protection | Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | -| Protection | Near-Field 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. | +| Exclusions | Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | +| Exclusions | Near-Field 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 | Dwell 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 Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | diff --git a/wwise/WwisePlugin/SpatialClustering.xml b/wwise/WwisePlugin/SpatialClustering.xml index 5bfd731..e1aaaf5 100644 --- a/wwise/WwisePlugin/SpatialClustering.xml +++ b/wwise/WwisePlugin/SpatialClustering.xml @@ -70,7 +70,7 @@ for the documentation about the plugin xml format - + 100 2 @@ -84,7 +84,7 @@ for the documentation about the plugin xml format - + 0 3 From 9a3605dbd15a36e95bcdc40332ad2351960ecd58 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 12:43:38 +0000 Subject: [PATCH 24/26] rename the parameters to names sound designers know: cluster width, object limit, priority threshold, proximity radius, hold time, distance sensitivity and cluster spread --- README.md | 2 +- docs/best-practices.md | 10 ++-- docs/how-it-works.md | 22 +++---- docs/parameters.md | 20 +++---- .../SoundEnginePlugin/SpatialClusteringFX.cpp | 22 +++---- wwise/SoundEnginePlugin/SpatialClusteringFX.h | 2 +- .../SpatialClusteringFXParams.cpp | 52 ++++++++-------- .../SpatialClusteringFXParams.h | 60 +++++++++---------- wwise/WwisePlugin/SpatialClustering.xml | 16 ++--- wwise/WwisePlugin/SpatialClusteringPlugin.cpp | 14 ++--- .../{spreadFromSize.md => clusterSpread.md} | 4 +- wwise/WwisePlugin/res/Md/en/clusterWidth.md | 9 +++ .../res/Md/en/distanceSensitivity.md | 5 ++ wwise/WwisePlugin/res/Md/en/distanceWeight.md | 5 -- .../res/Md/en/{dwellTime.md => holdTime.md} | 4 +- wwise/WwisePlugin/res/Md/en/joinAngle.md | 9 --- .../Md/en/{maxOutputs.md => objectLimit.md} | 4 +- ...ePriorityAbove.md => priorityThreshold.md} | 4 +- ...{nearFieldRadius.md => proximityRadius.md} | 2 +- 19 files changed, 133 insertions(+), 133 deletions(-) rename wwise/WwisePlugin/res/Md/en/{spreadFromSize.md => clusterSpread.md} (68%) create mode 100644 wwise/WwisePlugin/res/Md/en/clusterWidth.md create mode 100644 wwise/WwisePlugin/res/Md/en/distanceSensitivity.md delete mode 100644 wwise/WwisePlugin/res/Md/en/distanceWeight.md rename wwise/WwisePlugin/res/Md/en/{dwellTime.md => holdTime.md} (66%) delete mode 100644 wwise/WwisePlugin/res/Md/en/joinAngle.md rename wwise/WwisePlugin/res/Md/en/{maxOutputs.md => objectLimit.md} (79%) rename wwise/WwisePlugin/res/Md/en/{excludePriorityAbove.md => priorityThreshold.md} (71%) rename wwise/WwisePlugin/res/Md/en/{nearFieldRadius.md => proximityRadius.md} (89%) diff --git a/README.md b/README.md index 76a5f25..605a64a 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Upgrading from 2.0.0 or earlier? Remove the old plug-in first, see [Upgrading](d ![Spatial Clustering inserted on an Audio Objects bus in Wwise](https://github.com/user-attachments/assets/88c217c1-87c9-4132-aba8-04eede2d46e1) 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 **Join Angle**, plus **Max Outputs** for a fixed object budget. +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. ![Clusters in the Wwise Audio Object profiler views](https://github.com/user-attachments/assets/d46c84bb-196e-4c2a-b932-62920066b516) diff --git a/docs/best-practices.md b/docs/best-practices.md index 60ae83e..2c134ad 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -4,14 +4,14 @@ - 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 Max Outputs, 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 Exclude Priority Above, or by routing them to another bus. -- Use Near-Field Radius to keep sounds close to the listener separate. -- Use separate busses with their own settings for different categories, for example a smaller Join Angle for dense sources such as turret fire and a larger one for spread-out sounds such as shields. +- 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 Max Outputs 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. +- 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/how-it-works.md b/docs/how-it-works.md index 65bec36..742990b 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -13,16 +13,16 @@ Every audio frame the plug-in reads all audio objects on its bus, updates the cl ## 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 Weight** 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 **Join Angle**. +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 **Join Angle**. If there is none, it starts a new cluster. -- When the **Max Outputs** limit is reached, a new input with no cluster within the **Join Angle** joins the nearest cluster within 1.5 × the **Join Angle**. 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 **Join Angle** from the cluster for the **Dwell Time**. The gap between the two angles and the waiting time stop inputs from switching back and forth. With a Join Angle of 120° or more, inputs never leave because of their direction. -- A leaving input joins another cluster within the **Join Angle**, or starts its own cluster. While the **Max Outputs** limit is reached and no other cluster is within the **Join Angle**, it stays in its cluster and tries again after another Dwell Time. +- 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. @@ -34,14 +34,14 @@ A switch between clustered and passthrough, for example when an RTPC moves Speak ## Merging -When a cluster's nearest neighbor stays within the **Join Angle** for the **Dwell 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 **Join Angle**. +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 **Max Outputs** 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 Dwell Time. A higher priority makes a pair count as farther apart, so it merges later. +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 **Join Angle** 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. +- 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. @@ -49,17 +49,17 @@ With **Max Outputs** above 0, the plug-in counts clusters plus passthrough outpu - **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 **Spread From Cluster Size** × cluster angular radius / 180°. The representative is the loudest input, and it can change from frame to frame. +- **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 **Exclude Priority Above**, inputs closer to the listener than **Near-Field Radius** 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 Dwell Time, with a crossfade. +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 Max Outputs 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. +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/parameters.md b/docs/parameters.md index cd579c8..8aa7d93 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -1,20 +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 **Join Angle**, and **Max Outputs** when you have a fixed object budget. +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 | Join Angle, 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 | Max Outputs | 0 to 512 | 0 | Largest number of outputs, passthrough outputs included. 0 means no limit. | -| Exclusions | Exclude Priority Above | 0 to 100 | 100 | Inputs with a priority above this value are never merged. 100 excludes nothing. | -| Exclusions | Near-Field 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 | Dwell 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. | +| 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 Weight | 0 to 1 | 0 | How much a difference in distance counts against the Join Angle. 0 uses the angle only. | -| Advanced | Spread From Cluster Size | 0 to 1 | 1 | Share of the cluster's angular size used as its minimum spread. | +| 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. | -Dwell 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. +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 Join Angle of 180 with Max Outputs 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 Join Angle of 0 with a Max Outputs limit only merges when the limit is reached. +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/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp index 08141d3..7c983f9 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFX.cpp @@ -87,8 +87,8 @@ namespace /** @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 Max Outputs. */ - const char kOverBudgetMessage[] = "Spatial Clustering needs more outputs than Max Outputs allows because the remaining inputs cannot be merged (passthrough inputs, excluded inputs or different custom metadata)."; + /** @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 @@ -237,7 +237,7 @@ SpatialClusteringFX::SpatialClusteringFX() , m_pPriorityService(nullptr) , m_pHashTableService(nullptr) , m_bEngineInitialized(false) - , m_fSpreadFromSize(1.f) + , m_fClusterSpread(1.f) , m_uSampleRate(0) , m_uParamsMaxFrames(0) , m_uMessageInterval(1) @@ -457,16 +457,16 @@ void SpatialClusteringFX::UpdateClusterParams(AkUInt16 in_uMaxFrames) const AkReal32 fFrameSeconds = fFrameLength / fSampleRate; // The leave ratio (1.5) and loudness weighting (on) keep the ClusterEngine defaults. - m_clusterParams.joinAngle = Clamp(rtpc.joinAngle, 0.f, PARAM_JOIN_ANGLE_MAX); - m_clusterParams.dwellFrames = MillisecondsToFrames(Clamp(rtpc.dwellTime, 0.f, PARAM_DWELL_TIME_MAX), fFrameSeconds); + 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.maxOutputs, 0, PARAM_MAX_OUTPUTS_MAX)); - m_clusterParams.nearFieldRadius = Clamp(rtpc.nearFieldRadius, 0.f, PARAM_NEAR_FIELD_RADIUS_MAX); - m_clusterParams.distanceWeight = Clamp(rtpc.distanceWeight, 0.f, 1.f); - m_clusterParams.excludePriorityAbove = static_cast(Clamp(rtpc.excludePriorityAbove, 0, PARAM_PRIORITY_MAX)); + 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_fSpreadFromSize = Clamp(rtpc.spreadFromSize, 0.f, 1.f); + m_fClusterSpread = Clamp(rtpc.clusterSpread, 0.f, 1.f); const AkUInt32 uMessageInterval = static_cast(kMessageIntervalSeconds / fFrameSeconds); m_uMessageInterval = uMessageInterval > 0 ? uMessageInterval : 1; @@ -791,7 +791,7 @@ void SpatialClusteringFX::ProcessClusters(const AkAudioObjects& in_objects) 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_fSpreadFromSize * Clamp(info.angularRadius / kFullSpreadRadius, 0.f, 1.f); + const AkReal32 fSizeSpread = m_fClusterSpread * Clamp(info.angularRadius / kFullSpreadRadius, 0.f, 1.f); threeD.spread = AkMax(pRepresentative->positioning.threeD.spread, fSizeSpread); } diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFX.h b/wwise/SoundEnginePlugin/SpatialClusteringFX.h index 07fb86a..a891cec 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFX.h +++ b/wwise/SoundEnginePlugin/SpatialClusteringFX.h @@ -215,7 +215,7 @@ class SpatialClusteringFX 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_fSpreadFromSize; ///< Share of the cluster's angular size used as its minimum spread. + 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. diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp index 46bb3fa..69698db 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.cpp @@ -39,12 +39,12 @@ namespace /** @brief Returns true if every real parameter is a finite number. */ bool AreFinite(const SpatialClusteringRTPCParams& in_params) { - return std::isfinite(in_params.joinAngle) - && std::isfinite(in_params.nearFieldRadius) - && std::isfinite(in_params.dwellTime) + 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.distanceWeight) - && std::isfinite(in_params.spreadFromSize); + && 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. */ @@ -120,14 +120,14 @@ AKRESULT SpatialClusteringFXParams::SetParamsBlock(const void* in_pParamsBlock, AkUInt8* pParamsBlock = (AkUInt8*)in_pParamsBlock; SpatialClusteringRTPCParams rtpc; - rtpc.joinAngle = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - rtpc.maxOutputs = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); - rtpc.excludePriorityAbove = READBANKDATA(AkInt32, pParamsBlock, in_ulBlockSize); - rtpc.nearFieldRadius = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - rtpc.dwellTime = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); + 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.distanceWeight = READBANKDATA(AkReal32, pParamsBlock, in_ulBlockSize); - rtpc.spreadFromSize = 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)) @@ -146,45 +146,45 @@ AKRESULT SpatialClusteringFXParams::SetParam(AkPluginParamID in_paramID, const v // Handle parameter change here switch (in_paramID) { - case PARAM_JOIN_ANGLE_ID: + case PARAM_CLUSTER_WIDTH_ID: if (!ReadReal(in_pValue, in_ulParamSize, value)) return AK_InvalidParameter; - RTPC.joinAngle = value; + RTPC.clusterWidth = value; break; - case PARAM_DWELL_TIME_ID: + case PARAM_HOLD_TIME_ID: if (!ReadReal(in_pValue, in_ulParamSize, value)) return AK_InvalidParameter; - RTPC.dwellTime = value; + 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_MAX_OUTPUTS_ID: + case PARAM_OBJECT_LIMIT_ID: if (!ReadReal(in_pValue, in_ulParamSize, value)) return AK_InvalidParameter; - RTPC.maxOutputs = RoundToRange(value, static_cast(PARAM_MAX_OUTPUTS_MAX)); + RTPC.objectLimit = RoundToRange(value, static_cast(PARAM_OBJECT_LIMIT_MAX)); break; - case PARAM_NEAR_FIELD_RADIUS_ID: + case PARAM_PROXIMITY_RADIUS_ID: if (!ReadReal(in_pValue, in_ulParamSize, value)) return AK_InvalidParameter; - RTPC.nearFieldRadius = value; + RTPC.proximityRadius = value; break; - case PARAM_DISTANCE_WEIGHT_ID: + case PARAM_DISTANCE_SENSITIVITY_ID: if (!ReadReal(in_pValue, in_ulParamSize, value)) return AK_InvalidParameter; - RTPC.distanceWeight = value; + RTPC.distanceSensitivity = value; break; - case PARAM_EXCLUDE_PRIORITY_ABOVE_ID: + case PARAM_PRIORITY_THRESHOLD_ID: if (!ReadReal(in_pValue, in_ulParamSize, value)) return AK_InvalidParameter; - RTPC.excludePriorityAbove = RoundToRange(value, static_cast(PARAM_PRIORITY_MAX)); + RTPC.priorityThreshold = RoundToRange(value, static_cast(PARAM_PRIORITY_MAX)); break; - case PARAM_SPREAD_FROM_SIZE_ID: + case PARAM_CLUSTER_SPREAD_ID: if (!ReadReal(in_pValue, in_ulParamSize, value)) return AK_InvalidParameter; - RTPC.spreadFromSize = value; + RTPC.clusterSpread = value; break; default: return AK_InvalidParameter; diff --git a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h index 3731571..3f2b2d1 100644 --- a/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h +++ b/wwise/SoundEnginePlugin/SpatialClusteringFXParams.h @@ -33,36 +33,36 @@ the specific language governing permissions and limitations under the License. // 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 join angle parameter. -static const AkPluginParamID PARAM_JOIN_ANGLE_ID = 0; -/// ID of the maximum number of outputs parameter. -static const AkPluginParamID PARAM_MAX_OUTPUTS_ID = 1; -/// ID of the parameter that sets the priority above which inputs are never merged. -static const AkPluginParamID PARAM_EXCLUDE_PRIORITY_ABOVE_ID = 2; -/// ID of the near-field radius parameter. -static const AkPluginParamID PARAM_NEAR_FIELD_RADIUS_ID = 3; -/// ID of the dwell time parameter. -static const AkPluginParamID PARAM_DWELL_TIME_ID = 4; +/// 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 weight parameter. -static const AkPluginParamID PARAM_DISTANCE_WEIGHT_ID = 6; -/// ID of the spread from cluster size parameter. -static const AkPluginParamID PARAM_SPREAD_FROM_SIZE_ID = 7; +/// 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 join angle in degrees. -static const AkReal32 PARAM_JOIN_ANGLE_MAX = 180.f; -/// Largest dwell time in milliseconds. -static const AkReal32 PARAM_DWELL_TIME_MAX = 5000.f; +/// 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 maximum number of outputs. -static const AkInt32 PARAM_MAX_OUTPUTS_MAX = 512; -/// Largest near-field radius in game units. -static const AkReal32 PARAM_NEAR_FIELD_RADIUS_MAX = 1000000.f; -/// Largest playback priority, which is also the largest Exclude Priority Above value. +/// 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. @@ -71,14 +71,14 @@ static const AkUInt32 PARAMS_BLOCK_SIZE = 6 * sizeof(AkReal32) + 2 * sizeof(AkIn /** @brief Parameters of the effect, all of which RTPCs can drive. */ struct SpatialClusteringRTPCParams { - AkReal32 joinAngle; ///< Angle in degrees under which an input may join a cluster. - AkInt32 maxOutputs; ///< Maximum number of outputs, passthrough inputs included; 0 means no limit. - AkInt32 excludePriorityAbove; ///< Inputs with a higher priority are never merged. - AkReal32 nearFieldRadius; ///< Inputs closer to the listener than this distance in game units are never merged. - AkReal32 dwellTime; ///< Time in milliseconds a change must last before an input leaves its cluster or two clusters merge. + 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 distanceWeight; ///< How much a difference in distance counts against the join angle. - AkReal32 spreadFromSize; ///< Share of the cluster's angular size used as its minimum spread. + 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. */ diff --git a/wwise/WwisePlugin/SpatialClustering.xml b/wwise/WwisePlugin/SpatialClustering.xml index e1aaaf5..b763e2f 100644 --- a/wwise/WwisePlugin/SpatialClustering.xml +++ b/wwise/WwisePlugin/SpatialClustering.xml @@ -42,7 +42,7 @@ for the documentation about the plugin xml format - + 15 0 @@ -56,7 +56,7 @@ for the documentation about the plugin xml format - + 0 1 @@ -70,7 +70,7 @@ for the documentation about the plugin xml format - + 100 2 @@ -84,7 +84,7 @@ for the documentation about the plugin xml format - + 0 3 @@ -98,7 +98,7 @@ for the documentation about the plugin xml format - + 200 4 @@ -125,8 +125,8 @@ for the documentation about the plugin xml format - - + + 0 6 @@ -140,7 +140,7 @@ for the documentation about the plugin xml format - + 1 7 diff --git a/wwise/WwisePlugin/SpatialClusteringPlugin.cpp b/wwise/WwisePlugin/SpatialClusteringPlugin.cpp index 3123cfe..55c644a 100644 --- a/wwise/WwisePlugin/SpatialClusteringPlugin.cpp +++ b/wwise/WwisePlugin/SpatialClusteringPlugin.cpp @@ -38,14 +38,14 @@ 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:joinAngle")) - && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:maxOutputs")) - && in_dataWriter.WriteInt32(m_propertySet.GetInt32(in_guidPlatform, "CCP:excludePriorityAbove")) - && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:nearFieldRadius")) - && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:dwellTime")) + 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:distanceWeight")) - && in_dataWriter.WriteReal32(m_propertySet.GetReal32(in_guidPlatform, "CCP:spreadFromSize")); + && 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 diff --git a/wwise/WwisePlugin/res/Md/en/spreadFromSize.md b/wwise/WwisePlugin/res/Md/en/clusterSpread.md similarity index 68% rename from wwise/WwisePlugin/res/Md/en/spreadFromSize.md rename to wwise/WwisePlugin/res/Md/en/clusterSpread.md index 2f2cefc..d982cd8 100644 --- a/wwise/WwisePlugin/res/Md/en/spreadFromSize.md +++ b/wwise/WwisePlugin/res/Md/en/clusterSpread.md @@ -1,5 +1,5 @@ -## Spread From Cluster Size +## 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 Spread From Cluster Size × (cluster angular radius / 180°). +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/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/distanceWeight.md b/wwise/WwisePlugin/res/Md/en/distanceWeight.md deleted file mode 100644 index 2344311..0000000 --- a/wwise/WwisePlugin/res/Md/en/distanceWeight.md +++ /dev/null @@ -1,5 +0,0 @@ -## Distance Weight - -How much a difference in distance from the listener counts against the Join Angle. 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 Join Angle. diff --git a/wwise/WwisePlugin/res/Md/en/dwellTime.md b/wwise/WwisePlugin/res/Md/en/holdTime.md similarity index 66% rename from wwise/WwisePlugin/res/Md/en/dwellTime.md rename to wwise/WwisePlugin/res/Md/en/holdTime.md index a90c9ad..cca08a6 100644 --- a/wwise/WwisePlugin/res/Md/en/dwellTime.md +++ b/wwise/WwisePlugin/res/Md/en/holdTime.md @@ -1,6 +1,6 @@ -## Dwell Time +## Hold Time -Time an input must stay outside its cluster before it leaves, and time two clusters must stay within the Join Angle of each other before they merge. +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. diff --git a/wwise/WwisePlugin/res/Md/en/joinAngle.md b/wwise/WwisePlugin/res/Md/en/joinAngle.md deleted file mode 100644 index 58d97ee..0000000 --- a/wwise/WwisePlugin/res/Md/en/joinAngle.md +++ /dev/null @@ -1,9 +0,0 @@ -## Join Angle - -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 Max Outputs requires it. - -**Note**: With Distance Weight above 0, a difference in distance from the listener is added to the angle before it is compared with the Join Angle. - -Units: degrees diff --git a/wwise/WwisePlugin/res/Md/en/maxOutputs.md b/wwise/WwisePlugin/res/Md/en/objectLimit.md similarity index 79% rename from wwise/WwisePlugin/res/Md/en/maxOutputs.md rename to wwise/WwisePlugin/res/Md/en/objectLimit.md index d23b3f5..709532c 100644 --- a/wwise/WwisePlugin/res/Md/en/maxOutputs.md +++ b/wwise/WwisePlugin/res/Md/en/objectLimit.md @@ -1,8 +1,8 @@ -## Max Outputs +## 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 Dwell Time. A higher priority makes a pair count as farther apart, so it merges later. +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. diff --git a/wwise/WwisePlugin/res/Md/en/excludePriorityAbove.md b/wwise/WwisePlugin/res/Md/en/priorityThreshold.md similarity index 71% rename from wwise/WwisePlugin/res/Md/en/excludePriorityAbove.md rename to wwise/WwisePlugin/res/Md/en/priorityThreshold.md index 0951e43..ae92a81 100644 --- a/wwise/WwisePlugin/res/Md/en/excludePriorityAbove.md +++ b/wwise/WwisePlugin/res/Md/en/priorityThreshold.md @@ -1,5 +1,5 @@ -## Exclude Priority Above +## 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 Dwell Time, with a crossfade. +**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/nearFieldRadius.md b/wwise/WwisePlugin/res/Md/en/proximityRadius.md similarity index 89% rename from wwise/WwisePlugin/res/Md/en/nearFieldRadius.md rename to wwise/WwisePlugin/res/Md/en/proximityRadius.md index bca7272..d2b10b9 100644 --- a/wwise/WwisePlugin/res/Md/en/nearFieldRadius.md +++ b/wwise/WwisePlugin/res/Md/en/proximityRadius.md @@ -1,4 +1,4 @@ -## Near-Field Radius +## Proximity Radius Inputs closer to the listener than this distance get an output of their own and are never merged. From a7d7811efeeff9c2e9678dfc7655d61edaa1f1d0 Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 12:53:57 +0000 Subject: [PATCH 25/26] only generate install rules for a standalone build, so games can add the plug-in with add_subdirectory --- CMakeLists.txt | 87 ++++++++++++++++++++++++++------------------- docs/building.md | 2 ++ docs/integration.md | 2 +- 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 285409e..8fdbe21 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,15 @@ project(SpatialClustering VERSION ${SPATIALCLUSTERING_VERSION} LANGUAGES CXX) option(SPATIALCLUSTERING_BUILD_AUTHORING "Build the Wwise authoring plug-in (Windows only)" 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) @@ -81,23 +90,25 @@ target_link_libraries(SpatialClusteringFX PRIVATE $:/utf-8>) -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) +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() # Authoring plug-in. Install it into a Wwise install, or a copy of one, with # cmake --install --config Release --component Authoring --prefix @@ -138,26 +149,28 @@ if(SPATIALCLUSTERING_BUILD_AUTHORING) target_link_options(SpatialClusteringAuthoring PRIVATE /DEBUG /OPT:REF /OPT:ICF) target_link_libraries(SpatialClusteringAuthoring PRIVATE SpatialClusteringFXAuthoring) - # 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 + 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) - 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.") + 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/docs/building.md b/docs/building.md index 6caeed2..a3f4571 100644 --- a/docs/building.md +++ b/docs/building.md @@ -45,6 +45,8 @@ The root `CMakeLists.txt` builds the runtime plug-in as a static library and ins The library has no debug suffix, so install Debug and Release builds to separate prefixes. +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. ``` diff --git a/docs/integration.md b/docs/integration.md index 94cec7b..e0fddeb 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -4,7 +4,7 @@ The runtime plug-in must come from the same Wwise version as your game, since th - **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`. -- **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)` and include `` in one source file. +- **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 From 1108dd803f19670525e20399ee199b8cd61130fc Mon Sep 17 00:00:00 2001 From: Phevos Rinis Date: Fri, 16 Oct 2026 13:55:02 +0000 Subject: [PATCH 26/26] build the runtime plug-in as a dll for dynamic loading --- CMakeLists.txt | 36 ++++++++++++++++++++++++++++++++++++ docs/building.md | 8 ++++++++ docs/integration.md | 2 +- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8fdbe21..ab4c4dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ string(REGEX MATCH "[0-9]+\\.[0-9]+\\.[0-9]+" SPATIALCLUSTERING_VERSION "${SPATI 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. @@ -110,6 +111,41 @@ if(SPATIALCLUSTERING_INSTALL) 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) diff --git a/docs/building.md b/docs/building.md index a3f4571..2780670 100644 --- a/docs/building.md +++ b/docs/building.md @@ -45,6 +45,14 @@ The root `CMakeLists.txt` builds the runtime plug-in as a static library and ins 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. diff --git a/docs/integration.md b/docs/integration.md index e0fddeb..179eb84 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -3,7 +3,7 @@ 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`. +- **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.