From efb5c06d99a02c21f1abbf0fecc33ae8f3818fc1 Mon Sep 17 00:00:00 2001 From: swinston Date: Thu, 29 Oct 2026 09:57:45 -0700 Subject: [PATCH 1/4] Use source direction and listener orientation for HRTF selection (#524) --- attachments/advanced_gltf/audio_system.cpp | 35 +++++++++++++++++++ attachments/openxr_engine/shaders/hrtf.slang | 24 ++++++++----- attachments/simple_engine/audio_system.cpp | 35 +++++++++++++++++++ attachments/simple_engine/shaders/hrtf.slang | 24 ++++++++----- attachments/sync2_engine/audio_system.cpp | 35 +++++++++++++++++++ .../Subsystems/03_vulkan_audio.adoc | 30 ++++++++-------- 6 files changed, 151 insertions(+), 32 deletions(-) diff --git a/attachments/advanced_gltf/audio_system.cpp b/attachments/advanced_gltf/audio_system.cpp index 06f46b70e..bf5901bca 100644 --- a/attachments/advanced_gltf/audio_system.cpp +++ b/attachments/advanced_gltf/audio_system.cpp @@ -49,6 +49,38 @@ #include "renderer.h" #if !defined(PLATFORM_ANDROID) +// Rotate a world-space direction into the listener's frame (+X right, +Y up, +Z ahead). +// orientation is {forward, up}; right = forward x up. This is the frame the HRTF table is laid out in. +// Leaves the direction unchanged if the orientation is degenerate. +static void WorldToListenerSpace(float direction[3], const float orientation[6]) { + const float* fwd = &orientation[0]; + const float* up = &orientation[3]; + auto normalize = [](float v[3]) { + float len = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if (len <= 0.0001f) return false; + v[0] /= len; v[1] /= len; v[2] /= len; + return true; + }; + auto cross = [](const float a[3], const float b[3], float out[3]) { + out[0] = a[1] * b[2] - a[2] * b[1]; + out[1] = a[2] * b[0] - a[0] * b[2]; + out[2] = a[0] * b[1] - a[1] * b[0]; + }; + auto dot = [](const float a[3], const float b[3]) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }; + + float f[3] = {fwd[0], fwd[1], fwd[2]}; + float r[3]; + cross(f, up, r); + if (!normalize(f) || !normalize(r)) return; + float u[3]; + cross(r, f, u); + + const float local[3] = {dot(direction, r), dot(direction, u), dot(direction, f)}; + direction[0] = local[0]; + direction[1] = local[1]; + direction[2] = local[2]; +} + // OpenAL error checking utility static void CheckOpenALError(const std::string& operation) { ALenum error = alGetError(); @@ -1344,6 +1376,9 @@ bool AudioSystem::ProcessHRTF(const float* inputBuffer, float* outputBuffer, uin direction[2] = -1.0f; // Default to front } + // Rotate into the listener's frame so head turns change the result + WorldToListenerSpace(direction, listenerOrientation); + // Calculate azimuth and elevation float azimuth = std::atan2(direction[0], direction[2]); float elevation = std::asin(std::max(-1.0f, std::min(1.0f, direction[1]))); diff --git a/attachments/openxr_engine/shaders/hrtf.slang b/attachments/openxr_engine/shaders/hrtf.slang index 3ebd59fce..3a2f0d3ff 100644 --- a/attachments/openxr_engine/shaders/hrtf.slang +++ b/attachments/openxr_engine/shaders/hrtf.slang @@ -47,8 +47,9 @@ uint FindClosestHRTF(float azimuth, float elevation) { // In a real implementation, this would find the closest HRTF in the dataset // based on the azimuth and elevation angles - // Normalize azimuth to [0, 360) degrees - azimuth = fmod(azimuth + 360.0, 360.0); + // Azimuth arrives as 0 = ahead, positive = right. The table starts at -180 degrees (directly behind), + // so shift by 180 and wrap into [0, 360) to get the table's angular offset. + azimuth = fmod(azimuth + 180.0 + 360.0, 360.0); // Clamp elevation to [-90, 90] degrees elevation = clamp(elevation, -90.0, 90.0); @@ -64,13 +65,18 @@ uint FindClosestHRTF(float azimuth, float elevation) { // Helper function to calculate azimuth and elevation angles void CalculateAngles(float3 sourceDir, float3 listenerForward, float3 listenerUp, out float azimuth, out float elevation) { - // Simplified angle calculation - directly use source direction - // Calculate azimuth (horizontal angle) - angle around Y axis - azimuth = atan2(sourceDir.x, -sourceDir.z) * 57.2957795; // Convert to degrees, negate z for correct orientation - - // Calculate elevation (vertical angle) - angle from horizontal plane - float horizontalLength = sqrt(sourceDir.x * sourceDir.x + sourceDir.z * sourceDir.z); - elevation = atan2(sourceDir.y, horizontalLength) * 57.2957795; // Convert to degrees + // Express the source direction in the listener's frame: +Z ahead, +X right, +Y up + float3 f = normalize(listenerForward); + float3 r = normalize(cross(f, listenerUp)); + float3 u = cross(r, f); + float3 local = float3(dot(sourceDir, r), dot(sourceDir, u), dot(sourceDir, f)); + + // Azimuth (horizontal angle): 0 = ahead, positive = right + azimuth = atan2(local.x, local.z) * 57.2957795; // Convert to degrees + + // Elevation (vertical angle) - angle from horizontal plane + float horizontalLength = sqrt(local.x * local.x + local.z * local.z); + elevation = atan2(local.y, horizontalLength) * 57.2957795; // Convert to degrees } // Main compute shader function diff --git a/attachments/simple_engine/audio_system.cpp b/attachments/simple_engine/audio_system.cpp index 06f46b70e..bf5901bca 100644 --- a/attachments/simple_engine/audio_system.cpp +++ b/attachments/simple_engine/audio_system.cpp @@ -49,6 +49,38 @@ #include "renderer.h" #if !defined(PLATFORM_ANDROID) +// Rotate a world-space direction into the listener's frame (+X right, +Y up, +Z ahead). +// orientation is {forward, up}; right = forward x up. This is the frame the HRTF table is laid out in. +// Leaves the direction unchanged if the orientation is degenerate. +static void WorldToListenerSpace(float direction[3], const float orientation[6]) { + const float* fwd = &orientation[0]; + const float* up = &orientation[3]; + auto normalize = [](float v[3]) { + float len = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if (len <= 0.0001f) return false; + v[0] /= len; v[1] /= len; v[2] /= len; + return true; + }; + auto cross = [](const float a[3], const float b[3], float out[3]) { + out[0] = a[1] * b[2] - a[2] * b[1]; + out[1] = a[2] * b[0] - a[0] * b[2]; + out[2] = a[0] * b[1] - a[1] * b[0]; + }; + auto dot = [](const float a[3], const float b[3]) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }; + + float f[3] = {fwd[0], fwd[1], fwd[2]}; + float r[3]; + cross(f, up, r); + if (!normalize(f) || !normalize(r)) return; + float u[3]; + cross(r, f, u); + + const float local[3] = {dot(direction, r), dot(direction, u), dot(direction, f)}; + direction[0] = local[0]; + direction[1] = local[1]; + direction[2] = local[2]; +} + // OpenAL error checking utility static void CheckOpenALError(const std::string& operation) { ALenum error = alGetError(); @@ -1344,6 +1376,9 @@ bool AudioSystem::ProcessHRTF(const float* inputBuffer, float* outputBuffer, uin direction[2] = -1.0f; // Default to front } + // Rotate into the listener's frame so head turns change the result + WorldToListenerSpace(direction, listenerOrientation); + // Calculate azimuth and elevation float azimuth = std::atan2(direction[0], direction[2]); float elevation = std::asin(std::max(-1.0f, std::min(1.0f, direction[1]))); diff --git a/attachments/simple_engine/shaders/hrtf.slang b/attachments/simple_engine/shaders/hrtf.slang index 3ebd59fce..3a2f0d3ff 100644 --- a/attachments/simple_engine/shaders/hrtf.slang +++ b/attachments/simple_engine/shaders/hrtf.slang @@ -47,8 +47,9 @@ uint FindClosestHRTF(float azimuth, float elevation) { // In a real implementation, this would find the closest HRTF in the dataset // based on the azimuth and elevation angles - // Normalize azimuth to [0, 360) degrees - azimuth = fmod(azimuth + 360.0, 360.0); + // Azimuth arrives as 0 = ahead, positive = right. The table starts at -180 degrees (directly behind), + // so shift by 180 and wrap into [0, 360) to get the table's angular offset. + azimuth = fmod(azimuth + 180.0 + 360.0, 360.0); // Clamp elevation to [-90, 90] degrees elevation = clamp(elevation, -90.0, 90.0); @@ -64,13 +65,18 @@ uint FindClosestHRTF(float azimuth, float elevation) { // Helper function to calculate azimuth and elevation angles void CalculateAngles(float3 sourceDir, float3 listenerForward, float3 listenerUp, out float azimuth, out float elevation) { - // Simplified angle calculation - directly use source direction - // Calculate azimuth (horizontal angle) - angle around Y axis - azimuth = atan2(sourceDir.x, -sourceDir.z) * 57.2957795; // Convert to degrees, negate z for correct orientation - - // Calculate elevation (vertical angle) - angle from horizontal plane - float horizontalLength = sqrt(sourceDir.x * sourceDir.x + sourceDir.z * sourceDir.z); - elevation = atan2(sourceDir.y, horizontalLength) * 57.2957795; // Convert to degrees + // Express the source direction in the listener's frame: +Z ahead, +X right, +Y up + float3 f = normalize(listenerForward); + float3 r = normalize(cross(f, listenerUp)); + float3 u = cross(r, f); + float3 local = float3(dot(sourceDir, r), dot(sourceDir, u), dot(sourceDir, f)); + + // Azimuth (horizontal angle): 0 = ahead, positive = right + azimuth = atan2(local.x, local.z) * 57.2957795; // Convert to degrees + + // Elevation (vertical angle) - angle from horizontal plane + float horizontalLength = sqrt(local.x * local.x + local.z * local.z); + elevation = atan2(local.y, horizontalLength) * 57.2957795; // Convert to degrees } // Main compute shader function diff --git a/attachments/sync2_engine/audio_system.cpp b/attachments/sync2_engine/audio_system.cpp index 432285f8f..2f879dc2a 100644 --- a/attachments/sync2_engine/audio_system.cpp +++ b/attachments/sync2_engine/audio_system.cpp @@ -49,6 +49,38 @@ #include "renderer.h" #if !defined(PLATFORM_ANDROID) +// Rotate a world-space direction into the listener's frame (+X right, +Y up, +Z ahead). +// orientation is {forward, up}; right = forward x up. This is the frame the HRTF table is laid out in. +// Leaves the direction unchanged if the orientation is degenerate. +static void WorldToListenerSpace(float direction[3], const float orientation[6]) { + const float* fwd = &orientation[0]; + const float* up = &orientation[3]; + auto normalize = [](float v[3]) { + float len = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if (len <= 0.0001f) return false; + v[0] /= len; v[1] /= len; v[2] /= len; + return true; + }; + auto cross = [](const float a[3], const float b[3], float out[3]) { + out[0] = a[1] * b[2] - a[2] * b[1]; + out[1] = a[2] * b[0] - a[0] * b[2]; + out[2] = a[0] * b[1] - a[1] * b[0]; + }; + auto dot = [](const float a[3], const float b[3]) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }; + + float f[3] = {fwd[0], fwd[1], fwd[2]}; + float r[3]; + cross(f, up, r); + if (!normalize(f) || !normalize(r)) return; + float u[3]; + cross(r, f, u); + + const float local[3] = {dot(direction, r), dot(direction, u), dot(direction, f)}; + direction[0] = local[0]; + direction[1] = local[1]; + direction[2] = local[2]; +} + // OpenAL error checking utility static void CheckOpenALError(const std::string& operation) { ALenum error = alGetError(); @@ -1346,6 +1378,9 @@ bool AudioSystem::ProcessHRTF(const float* inputBuffer, float* outputBuffer, uin direction[2] = -1.0f; // Default to front } + // Rotate into the listener's frame so head turns change the result + WorldToListenerSpace(direction, listenerOrientation); + // Calculate azimuth and elevation float azimuth = std::atan2(direction[0], direction[2]); float elevation = std::asin(std::max(-1.0f, std::min(1.0f, direction[1]))); diff --git a/en/Building_a_Simple_Engine/Subsystems/03_vulkan_audio.adoc b/en/Building_a_Simple_Engine/Subsystems/03_vulkan_audio.adoc index ccd9ee011..9c8270f2c 100644 --- a/en/Building_a_Simple_Engine/Subsystems/03_vulkan_audio.adoc +++ b/en/Building_a_Simple_Engine/Subsystems/03_vulkan_audio.adoc @@ -116,7 +116,8 @@ private: void CleanupVulkanResources(); // Process audio with HRTF using Vulkan - void ProcessAudioWithVulkan(float* inputBuffer, float* outputBuffer, size_t frameCount); + void ProcessAudioWithVulkan(float* inputBuffer, float* outputBuffer, size_t frameCount, + float azimuth, float elevation); }; } // namespace Audio @@ -215,7 +216,8 @@ void AudioSystem::InitializeVulkanResources() { m_VulkanResources.commandBuffer = std::move(commandBuffers[0]); } -void AudioSystem::ProcessAudioWithVulkan(float* inputBuffer, float* outputBuffer, size_t frameCount) { +void AudioSystem::ProcessAudioWithVulkan(float* inputBuffer, float* outputBuffer, size_t frameCount, + float azimuth, float elevation) { if (!m_HRTFEnabled || !m_HRTFDatabase) { // If HRTF is disabled, just copy input to output (or do simple stereo panning) memcpy(outputBuffer, inputBuffer, frameCount * sizeof(float)); @@ -231,10 +233,9 @@ void AudioSystem::ProcessAudioWithVulkan(float* inputBuffer, float* outputBuffer memcpy(data, inputBuffer, frameCount * sizeof(float)); vkUnmapMemory(device, *m_VulkanResources.inputBufferMemory); - // Update HRTF data based on source positions - // In a real implementation, you would update this for each sound source - // For simplicity, we're just using a single HRTF filter here - const auto& hrtfData = m_HRTFDatabase->GetHRTFData(0.0f, 0.0f); + // Select the HRTF filter for this source's direction relative to the listener. + // Each source is processed separately, so the filter is updated per call. + const auto& hrtfData = m_HRTFDatabase->GetHRTFData(azimuth, elevation); vkMapMemory(device, *m_VulkanResources.hrtfBufferMemory, 0, sizeof(HRTFData), 0, &data); memcpy(data, &hrtfData, sizeof(HRTFData)); vkUnmapMemory(device, *m_VulkanResources.hrtfBufferMemory); @@ -276,13 +277,13 @@ void AudioSystem::Update(float deltaTime) { // Calculate spatial position relative to listener glm::vec3 relativePosition = source->GetPosition() - m_Listener.GetPosition(); - // Rotate relative position based on listener orientation - glm::mat3 listenerOrientation( - glm::cross(m_Listener.GetForward(), m_Listener.GetUp()), - m_Listener.GetUp(), - -m_Listener.GetForward() - ); - relativePosition = listenerOrientation * relativePosition; + // Rotate relative position into the listener's frame (+X right, +Y up, +Z ahead). + // The columns form the listener-to-world basis, so its transpose maps world to listener space. + glm::vec3 forward = glm::normalize(m_Listener.GetForward()); + glm::vec3 right = glm::normalize(glm::cross(forward, m_Listener.GetUp())); + glm::vec3 up = glm::cross(right, forward); + glm::mat3 listenerToWorld(right, up, forward); + relativePosition = glm::transpose(listenerToWorld) * relativePosition; // Calculate azimuth and elevation float distance = glm::length(relativePosition); @@ -296,7 +297,8 @@ void AudioSystem::Update(float deltaTime) { // Process audio with HRTF using Vulkan float processedAudio[2048]; // Stereo output (2 channels) - ProcessAudioWithVulkan(const_cast(audioData), processedAudio, framesToProcess); + ProcessAudioWithVulkan(const_cast(audioData), processedAudio, framesToProcess, + azimuth, elevation); // Send processed audio to the audio backend // ... From 62bd293ed5ccb154d274814adbaf1169f8bf1c71 Mon Sep 17 00:00:00 2001 From: swinston Date: Thu, 29 Oct 2026 12:13:28 -0700 Subject: [PATCH 2/4] Bump setup-android to v4 to fix the Android CI build (the removed sdkmanager 'tools' package) --- .github/workflows/simple_engine_ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/simple_engine_ci.yml b/.github/workflows/simple_engine_ci.yml index f0e84c719..7df5a7589 100644 --- a/.github/workflows/simple_engine_ci.yml +++ b/.github/workflows/simple_engine_ci.yml @@ -457,7 +457,7 @@ jobs: distribution: 'temurin' - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Cache Gradle packages uses: actions/cache@v4 From 2230dc1f23a60b2acd8976fd46bbe8d28dd65b33 Mon Sep 17 00:00:00 2001 From: swinston Date: Thu, 29 Oct 2026 12:45:52 -0700 Subject: [PATCH 3/4] Define WorldToListenerSpace on all platforms so the Android build compiles --- attachments/advanced_gltf/audio_system.cpp | 2 +- attachments/simple_engine/audio_system.cpp | 2 +- attachments/sync2_engine/audio_system.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/attachments/advanced_gltf/audio_system.cpp b/attachments/advanced_gltf/audio_system.cpp index bf5901bca..21a636a17 100644 --- a/attachments/advanced_gltf/audio_system.cpp +++ b/attachments/advanced_gltf/audio_system.cpp @@ -48,7 +48,6 @@ #include "engine.h" #include "renderer.h" -#if !defined(PLATFORM_ANDROID) // Rotate a world-space direction into the listener's frame (+X right, +Y up, +Z ahead). // orientation is {forward, up}; right = forward x up. This is the frame the HRTF table is laid out in. // Leaves the direction unchanged if the orientation is degenerate. @@ -81,6 +80,7 @@ static void WorldToListenerSpace(float direction[3], const float orientation[6]) direction[2] = local[2]; } +#if !defined(PLATFORM_ANDROID) // OpenAL error checking utility static void CheckOpenALError(const std::string& operation) { ALenum error = alGetError(); diff --git a/attachments/simple_engine/audio_system.cpp b/attachments/simple_engine/audio_system.cpp index bf5901bca..21a636a17 100644 --- a/attachments/simple_engine/audio_system.cpp +++ b/attachments/simple_engine/audio_system.cpp @@ -48,7 +48,6 @@ #include "engine.h" #include "renderer.h" -#if !defined(PLATFORM_ANDROID) // Rotate a world-space direction into the listener's frame (+X right, +Y up, +Z ahead). // orientation is {forward, up}; right = forward x up. This is the frame the HRTF table is laid out in. // Leaves the direction unchanged if the orientation is degenerate. @@ -81,6 +80,7 @@ static void WorldToListenerSpace(float direction[3], const float orientation[6]) direction[2] = local[2]; } +#if !defined(PLATFORM_ANDROID) // OpenAL error checking utility static void CheckOpenALError(const std::string& operation) { ALenum error = alGetError(); diff --git a/attachments/sync2_engine/audio_system.cpp b/attachments/sync2_engine/audio_system.cpp index 2f879dc2a..c8605f415 100644 --- a/attachments/sync2_engine/audio_system.cpp +++ b/attachments/sync2_engine/audio_system.cpp @@ -48,7 +48,6 @@ #include "engine.h" #include "renderer.h" -#if !defined(PLATFORM_ANDROID) // Rotate a world-space direction into the listener's frame (+X right, +Y up, +Z ahead). // orientation is {forward, up}; right = forward x up. This is the frame the HRTF table is laid out in. // Leaves the direction unchanged if the orientation is degenerate. @@ -81,6 +80,7 @@ static void WorldToListenerSpace(float direction[3], const float orientation[6]) direction[2] = local[2]; } +#if !defined(PLATFORM_ANDROID) // OpenAL error checking utility static void CheckOpenALError(const std::string& operation) { ALenum error = alGetError(); From 1a15bcc55aeb9e26118aeef0d4125c8778b45c40 Mon Sep 17 00:00:00 2001 From: swinston Date: Thu, 29 Oct 2026 15:16:07 -0700 Subject: [PATCH 4/4] Pin the Antora build to Node LTS; 'latest' now resolves to a Node 27 alpha that skips npm install scripts --- .github/workflows/build-antora-doc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-antora-doc.yml b/.github/workflows/build-antora-doc.yml index dd89faa3e..5f3c306a2 100644 --- a/.github/workflows/build-antora-doc.yml +++ b/.github/workflows/build-antora-doc.yml @@ -27,7 +27,7 @@ jobs: - name: "Install npm" uses: actions/setup-node@v4 with: - node-version: 'latest' + node-version: 'lts/*' cache: 'npm' cache-dependency-path: './antora-ui-khronos/package-lock.json' - name: "Install dependencies"