From 65a7310b5671cd8909dae53e733a2a62e38ca4f7 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Sun, 18 Oct 2026 12:56:52 +0200 Subject: [PATCH] feat: add spot light shadows with a tilled buffer --- destrum/assets_src/shaders/lights.glsl | 13 +- destrum/assets_src/shaders/mesh.frag | 36 ++- destrum/assets_src/shaders/scene_data.glsl | 5 + destrum/assets_src/shaders/shadow.vert | 12 +- destrum/assets_src/shaders/shadows.glsl | 62 +++-- destrum/include/destrum/Graphics/Frustum.h | 7 + destrum/include/destrum/Graphics/Light.h | 12 +- .../Graphics/Pipelines/LightCullingPipeline.h | 2 +- .../Graphics/Pipelines/ShadowMapPipeline.h | 41 +++- destrum/include/destrum/Graphics/Renderer.h | 10 + destrum/src/Components/LightComponent.cpp | 4 + destrum/src/Graphics/Frustum.cpp | 68 ++++++ .../Pipelines/LightCullingPipeline.cpp | 4 +- .../Graphics/Pipelines/ShadowMapPipeline.cpp | 215 +++++++++++++----- destrum/src/Graphics/Renderer.cpp | 81 ++++++- lightkeeper/include/Lightkeeper.h | 1 - lightkeeper/src/Lightkeeper.cpp | 20 -- lightkeeper/src/scenes/MainScene.cpp | 18 ++ tests/destrum_tests.cpp | 30 +++ 19 files changed, 519 insertions(+), 122 deletions(-) diff --git a/destrum/assets_src/shaders/lights.glsl b/destrum/assets_src/shaders/lights.glsl index 1f269e2..02c4f4f 100644 --- a/destrum/assets_src/shaders/lights.glsl +++ b/destrum/assets_src/shaders/lights.glsl @@ -7,6 +7,10 @@ #define TYPE_POINT_LIGHT 1 #define TYPE_SPOT_LIGHT 2 +#define NO_SHADOW 0xFFFFFFFFu +// Must match ShadowMapPipeline::MAX_SPOT_SHADOWS +#define MAX_SPOT_SHADOWS 16 + // Forward+ tiling, must match LightCullingPipeline::TILE_SIZE / MAX_LIGHTS_PER_TILE #define LIGHT_TILE_SIZE 16 #define MAX_LIGHTS_PER_TILE 255 @@ -27,10 +31,17 @@ struct Light { float spotInnerCos; float spotOuterCos; - uint castShadows; + // NO_SHADOW, or the light's slot in the shadow data of its type + uint shadowIndex; float padding; }; +// Must match GPUSpotShadow in Light.h +struct SpotShadow { + mat4 lightViewProj; + float normalOffsetPerMeter; +}; + layout (buffer_reference, std430) readonly buffer LightsBuffer { Light data[]; }; diff --git a/destrum/assets_src/shaders/mesh.frag b/destrum/assets_src/shaders/mesh.frag index 724bb05..27d306b 100644 --- a/destrum/assets_src/shaders/mesh.frag +++ b/destrum/assets_src/shaders/mesh.frag @@ -34,6 +34,32 @@ vec3 calculateNormal(MaterialData material, uint samplerID) return normalize(mat3(t, b, n) * tangentNormal); } +float calculateShadow(Light light, vec3 fragPos, vec3 geometryNormal) +{ + if (light.shadowIndex == NO_SHADOW) { + return 1.0; + } + + if (light.type == TYPE_DIRECTIONAL_LIGHT) { + return calculateDirectionalShadow( + fragPos, geometryNormal, + pcs.sceneData.lightViewProj, + pcs.sceneData.shadowMapId, + pcs.sceneData.shadowNormalOffset); + } + + if (light.type == TYPE_SPOT_LIGHT) { + return calculateSpotShadow( + fragPos, geometryNormal, light.position, + pcs.sceneData.spotShadows[light.shadowIndex], + light.shadowIndex, + pcs.sceneData.spotShadowAtlasId, + pcs.sceneData.spotShadowTilesPerRow); + } + + return 1.0; +} + void main() { MaterialData material = pcs.sceneData.materials.data[pcs.materialID]; @@ -73,19 +99,13 @@ void main() uint tileOffset = (tileID.y * pcs.sceneData.tileCountX + tileID.x) * LIGHT_TILE_STRIDE; uint tileLightCount = pcs.sceneData.tileLightIndices.data[tileOffset]; + vec3 geometryNormal = normalize(inNormal); vec3 fragColor = vec3(0.0); for (uint i = 0; i < tileLightCount; i++) { uint lightIndex = pcs.sceneData.tileLightIndices.data[tileOffset + 1 + i]; Light light = pcs.sceneData.lights.data[lightIndex]; - float shadow = 1.0; - if (light.castShadows != 0) { - shadow = calculateDirectionalShadow( - fragPos, normalize(inNormal), - pcs.sceneData.lightViewProj, - pcs.sceneData.shadowMapId, - pcs.sceneData.shadowNormalOffset); - } + float shadow = calculateShadow(light, fragPos, geometryNormal); fragColor += calculateLight(light, fragPos, n, v, diffuseColor, roughness, metallic, f0, shadow); diff --git a/destrum/assets_src/shaders/scene_data.glsl b/destrum/assets_src/shaders/scene_data.glsl index 54d62ca..ae30f81 100644 --- a/destrum/assets_src/shaders/scene_data.glsl +++ b/destrum/assets_src/shaders/scene_data.glsl @@ -39,6 +39,11 @@ layout (buffer_reference, scalar) readonly buffer SceneDataBuffer { float shadowNormalOffset; uint padding; mat4 lightViewProj; + + // spot shadows + uint spotShadowAtlasId; + uint spotShadowTilesPerRow; + SpotShadow spotShadows[MAX_SPOT_SHADOWS]; } sceneDataBuffer; #endif // SCENE_DATA_GLSL diff --git a/destrum/assets_src/shaders/shadow.vert b/destrum/assets_src/shaders/shadow.vert index 667caae..132454f 100644 --- a/destrum/assets_src/shaders/shadow.vert +++ b/destrum/assets_src/shaders/shadow.vert @@ -1,12 +1,20 @@ #version 460 #extension GL_GOOGLE_include_directive : require +#extension GL_EXT_buffer_reference : require +#extension GL_EXT_scalar_block_layout : require -#include "mesh_pcs.glsl" +#include "vertex.glsl" + +layout (push_constant, scalar) uniform constants +{ + mat4 modelToLightClip; + VertexBuffer vertexBuffer; +} pcs; void main() { Vertex v = pcs.vertexBuffer.vertices[gl_VertexIndex]; - gl_Position = pcs.sceneData.lightViewProj * pcs.transform * vec4(v.position, 1.0f); + gl_Position = pcs.modelToLightClip * vec4(v.position, 1.0f); } diff --git a/destrum/assets_src/shaders/shadows.glsl b/destrum/assets_src/shaders/shadows.glsl index e27e2c4..7c2d677 100644 --- a/destrum/assets_src/shaders/shadows.glsl +++ b/destrum/assets_src/shaders/shadows.glsl @@ -1,7 +1,30 @@ #ifndef SHADOWS_GLSL #define SHADOWS_GLSL -// expects bindless.glsl to be included before this file (it has no include guard) +// expects bindless.glsl and lights.glsl to be included before this file + +// Returns how much of a 3x3 area around uv is lit, every tap stays inside [minUV, maxUV] +float sampleShadowPCF(uint shadowMapID, vec2 uv, float depth, vec2 minUV, vec2 maxUV) +{ + vec2 texelSize = 1.0 / vec2(textureSize(sampler2DShadow(textures[shadowMapID], samplers[SHADOW_SAMPLER_ID]), 0)); + + float shadowed = 0.0; + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + vec2 sampleUV = clamp(uv + vec2(x, y) * texelSize, minUV, maxUV); + shadowed += sampleTexture2DShadow(shadowMapID, vec3(sampleUV, depth)); + } + } + + // the shadow sampler compares with GREATER_OR_EQUAL (it was set up for reversed Z), + // with regular depth that returns 1 when the fragment is behind the occluder + return 1.0 - shadowed / 9.0; +} + +bool isOutsideShadowMap(vec2 uv, float depth) +{ + return any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || depth > 1.0; +} // Returns 1.0 when fully lit, 0.0 when fully in shadow float calculateDirectionalShadow(vec3 fragPos, vec3 n, mat4 lightViewProj, uint shadowMapID, float normalOffset) @@ -11,24 +34,37 @@ float calculateDirectionalShadow(vec3 fragPos, vec3 n, mat4 lightViewProj, uint vec3 lightNDC = lightClip.xyz / lightClip.w; vec2 uv = lightNDC.xy * 0.5 + 0.5; - // outside the shadow map counts as lit - if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || lightNDC.z > 1.0) { + if (isOutsideShadowMap(uv, lightNDC.z)) { return 1.0; } - vec2 texelSize = 1.0 / vec2(textureSize(sampler2DShadow(textures[shadowMapID], samplers[SHADOW_SAMPLER_ID]), 0)); + return sampleShadowPCF(shadowMapID, uv, lightNDC.z, vec2(0.0), vec2(1.0)); +} - // 3x3 PCF, every tap is already bilinearly filtered by the compare sampler - float shadowed = 0.0; - for (int x = -1; x <= 1; x++) { - for (int y = -1; y <= 1; y++) { - shadowed += sampleTexture2DShadow(shadowMapID, vec3(uv + vec2(x, y) * texelSize, lightNDC.z)); - } +float calculateSpotShadow(vec3 fragPos, vec3 n, vec3 lightPos, SpotShadow spotShadow, + uint shadowIndex, uint atlasID, uint tilesPerRow) +{ + float normalOffset = distance(fragPos, lightPos) * spotShadow.normalOffsetPerMeter; + vec4 lightClip = spotShadow.lightViewProj * vec4(fragPos + n * normalOffset, 1.0); + if (lightClip.w <= 0.0) { + return 1.0; } - // the shadow sampler compares with GREATER_OR_EQUAL (it was set up for reversed Z), - // with regular depth that returns 1 when the fragment is behind the occluder - return 1.0 - shadowed / 9.0; + vec3 lightNDC = lightClip.xyz / lightClip.w; + vec2 tileUV = lightNDC.xy * 0.5 + 0.5; + if (isOutsideShadowMap(tileUV, lightNDC.z)) { + return 1.0; + } + + vec2 tile = vec2(shadowIndex % tilesPerRow, shadowIndex / tilesPerRow); + float tileScale = 1.0 / float(tilesPerRow); + vec2 atlasTexelSize = 1.0 / vec2(textureSize(sampler2DShadow(textures[atlasID], samplers[SHADOW_SAMPLER_ID]), 0)); + + vec2 atlasUV = (tile + tileUV) * tileScale; + vec2 tileMinUV = tile * tileScale + atlasTexelSize; + vec2 tileMaxUV = (tile + 1.0) * tileScale - atlasTexelSize; + + return sampleShadowPCF(atlasID, atlasUV, lightNDC.z, tileMinUV, tileMaxUV); } #endif // SHADOWS_GLSL diff --git a/destrum/include/destrum/Graphics/Frustum.h b/destrum/include/destrum/Graphics/Frustum.h index 16706c1..0b8571e 100644 --- a/destrum/include/destrum/Graphics/Frustum.h +++ b/destrum/include/destrum/Graphics/Frustum.h @@ -1,6 +1,8 @@ #ifndef FRUSTUM_H #define FRUSTUM_H +#include + #include #include "Camera.h" @@ -71,7 +73,12 @@ namespace edge std::array calculateFrustumCornersWorldSpace(const Camera& camera); Frustum createFrustumFromCamera(const Camera& camera); + Frustum createFrustumFromViewProj(const glm::mat4& viewProj); + std::array calculateFrustumCorners(const glm::mat4& viewProj); + bool isInFrustum(const Frustum& frustum, const Sphere& s); + // Conservative: frustums that only come close can still count as overlapping + bool frustumsOverlap(const Frustum& frustum, const glm::mat4& otherViewProj); bool isInFrustum(const Frustum& frustum, const AABB& aabb); Sphere calculateBoundingSphereWorld(const glm::mat4& transform, const Sphere& s, bool hasSkeleton); } diff --git a/destrum/include/destrum/Graphics/Light.h b/destrum/include/destrum/Graphics/Light.h index e196535..905b794 100644 --- a/destrum/include/destrum/Graphics/Light.h +++ b/destrum/include/destrum/Graphics/Light.h @@ -3,9 +3,12 @@ #include +#include #include #include +inline constexpr std::uint32_t NO_SHADOW = 0xFFFFFFFF; + enum class LightType : std::uint32_t { Directional = 0, Point = 1, @@ -26,7 +29,7 @@ struct Light { float innerConeAngle{0.35f}; float outerConeAngle{0.5f}; - // directional lights only, the first one submitted each frame gets the shadow map + // directional and spot lights, point lights don't have shadows yet bool castShadows{false}; }; @@ -42,10 +45,15 @@ struct GPULight { float spotInnerCos; float spotOuterCos; - std::uint32_t castShadows; + std::uint32_t shadowIndex; float padding; }; +struct GPUSpotShadow { + glm::mat4 lightViewProj; + float normalOffsetPerMeter; +}; + static_assert(sizeof(GPULight) == 64, "GPULight must match the struct in lights.glsl"); #endif //LIGHT_H diff --git a/destrum/include/destrum/Graphics/Pipelines/LightCullingPipeline.h b/destrum/include/destrum/Graphics/Pipelines/LightCullingPipeline.h index 0898daf..7c2a2e1 100644 --- a/destrum/include/destrum/Graphics/Pipelines/LightCullingPipeline.h +++ b/destrum/include/destrum/Graphics/Pipelines/LightCullingPipeline.h @@ -30,7 +30,7 @@ public: void resize(GfxDevice& gfxDevice, glm::ivec2 drawImageSize); void beginDrawing(std::size_t frameIndex); - void appendLight(const Light& light); + void appendLight(const Light& light, std::uint32_t shadowIndex); void flushCurrentFrame(VkCommandBuffer cmd, GfxDevice& gfxDevice); diff --git a/destrum/include/destrum/Graphics/Pipelines/ShadowMapPipeline.h b/destrum/include/destrum/Graphics/Pipelines/ShadowMapPipeline.h index f54e6d3..4949e32 100644 --- a/destrum/include/destrum/Graphics/Pipelines/ShadowMapPipeline.h +++ b/destrum/include/destrum/Graphics/Pipelines/ShadowMapPipeline.h @@ -8,33 +8,46 @@ #include #include +#include #include #include #include -#include class GfxDevice; +struct GPUImage; class RenderResources; -// Renders the depth of the scene as seen from a directional light, centered around the camera +// Renders scene depth from the lights: one map for the directional light, one atlas tile per spot light class ShadowMapPipeline final { public: static constexpr std::uint32_t SHADOW_MAP_SIZE = 2048; + static constexpr std::uint32_t SPOT_SHADOW_TILE_SIZE = 1024; + static constexpr std::uint32_t SPOT_SHADOW_TILES_PER_ROW = 4; + static constexpr std::uint32_t MAX_SPOT_SHADOWS = SPOT_SHADOW_TILES_PER_ROW * SPOT_SHADOW_TILES_PER_ROW; void init(GfxDevice& gfxDevice, RenderResources& resources); void cleanup(VkDevice device); - void draw( + void drawDirectionalShadow( VkCommandBuffer cmd, const RenderResources& resources, - const GPUBuffer& sceneDataBuffer, const std::vector& drawCommands, - const std::vector& sortedDrawCommands); + const std::vector& sortedDrawCommands, + const glm::mat4& lightViewProj); - [[nodiscard]] glm::mat4 calculateLightViewProj(const glm::vec3& lightDirection, const glm::vec3& center) const; + void drawSpotShadows( + VkCommandBuffer cmd, + const RenderResources& resources, + const std::vector& drawCommands, + const std::vector& sortedDrawCommands, + const std::vector& lightViewProjs); + + [[nodiscard]] glm::mat4 calculateDirectionalViewProj(const glm::vec3& lightDirection, const glm::vec3& center) const; + [[nodiscard]] static GPUSpotShadow calculateSpotShadow(const Light& spotLight); [[nodiscard]] ImageID getShadowMapId() const { return shadowMapId; } + [[nodiscard]] ImageID getSpotShadowAtlasId() const { return spotShadowAtlasId; } [[nodiscard]] float getNormalOffset() const; // half the width of the area around the camera that receives shadows @@ -44,18 +57,26 @@ public: private: struct PushConstants { - glm::mat4 transform; - VkDeviceAddress sceneDataBuffer; + glm::mat4 modelToLightClip; VkDeviceAddress vertexBuffer; - std::uint32_t materialId; - std::uint32_t padding; }; + void beginDepthRendering(VkCommandBuffer cmd, const RenderResources& resources, const GPUImage& depthImage); + void endDepthRendering(VkCommandBuffer cmd, const GPUImage& depthImage); + void setRenderRegion(VkCommandBuffer cmd, const VkRect2D& region); + void drawShadowCasters( + VkCommandBuffer cmd, + const RenderResources& resources, + const std::vector& drawCommands, + const std::vector& sortedDrawCommands, + const glm::mat4& lightViewProj); + VkPipelineLayout m_pipelineLayout{VK_NULL_HANDLE}; std::unique_ptr m_pipeline; VkFormat shadowMapFormat{VK_FORMAT_D32_SFLOAT}; ImageID shadowMapId{NULL_IMAGE_ID}; + ImageID spotShadowAtlasId{NULL_IMAGE_ID}; float shadowDistance{25.0f}; // how far in front of / behind the camera (along the light) casters are still captured diff --git a/destrum/include/destrum/Graphics/Renderer.h b/destrum/include/destrum/Graphics/Renderer.h index 80a2055..a1c41e5 100644 --- a/destrum/include/destrum/Graphics/Renderer.h +++ b/destrum/include/destrum/Graphics/Renderer.h @@ -17,8 +17,10 @@ #include +#include #include #include +#include #include @@ -112,6 +114,8 @@ public: private: void createDrawImage(GfxDevice& gfxDevice, const glm::ivec2& drawImageSize, bool firstCreate); + void uploadLights(const Camera& camera); + [[nodiscard]] std::uint32_t assignShadowIndex(const Light& light, const Frustum& cameraFrustum); RenderResources* resources = nullptr; @@ -156,6 +160,10 @@ private: float shadowNormalOffset; std::uint32_t padding; glm::mat4 lightViewProj; + + std::uint32_t spotShadowAtlasId; + std::uint32_t spotShadowTilesPerRow; + std::array spotShadows; }; NBuffer sceneDataBuffer; @@ -170,6 +178,8 @@ private: std::unique_ptr shadowMapPipeline; std::optional shadowLightDirection; + std::vector submittedLights; + std::vector spotShadows; bool renderLightHeatmap{false}; bool initialized{false}; diff --git a/destrum/src/Components/LightComponent.cpp b/destrum/src/Components/LightComponent.cpp index 9266139..5f77eef 100644 --- a/destrum/src/Components/LightComponent.cpp +++ b/destrum/src/Components/LightComponent.cpp @@ -118,6 +118,10 @@ void LightComponent::ImGuiInspector() { return; } + if (m_Type == LightType::Spot) { + ImGui::Checkbox("Cast Shadows", &m_CastShadows); + } + float range = m_Range; if (ImGui::DragFloat("Range", &range, 0.05f, 0.01f)) { SetRange(range); diff --git a/destrum/src/Graphics/Frustum.cpp b/destrum/src/Graphics/Frustum.cpp index e8c15ef..a6413f7 100644 --- a/destrum/src/Graphics/Frustum.cpp +++ b/destrum/src/Graphics/Frustum.cpp @@ -55,6 +55,74 @@ Frustum edge::createFrustumFromCamera(const Camera& camera) { return frustum; } +namespace { + glm::vec4 matrixRow(const glm::mat4& matrix, int row) + { + return {matrix[0][row], matrix[1][row], matrix[2][row], matrix[3][row]}; + } + + Frustum::Plane planeFromCoefficients(const glm::vec4& coefficients) + { + const glm::vec3 normal{coefficients}; + const float length = glm::length(normal); + + Frustum::Plane plane; + plane.normal = normal / length; + plane.distance = -coefficients.w / length; + return plane; + } +} + +Frustum edge::createFrustumFromViewProj(const glm::mat4& viewProj) +{ + const glm::vec4 rowX = matrixRow(viewProj, 0); + const glm::vec4 rowY = matrixRow(viewProj, 1); + const glm::vec4 rowZ = matrixRow(viewProj, 2); + const glm::vec4 rowW = matrixRow(viewProj, 3); + + Frustum frustum; + frustum.leftFace = planeFromCoefficients(rowW + rowX); + frustum.rightFace = planeFromCoefficients(rowW - rowX); + frustum.bottomFace = planeFromCoefficients(rowW + rowY); + frustum.topFace = planeFromCoefficients(rowW - rowY); + frustum.nearFace = planeFromCoefficients(rowZ); + frustum.farFace = planeFromCoefficients(rowW - rowZ); + return frustum; +} + +std::array edge::calculateFrustumCorners(const glm::mat4& viewProj) +{ + const glm::mat4 clipToWorld = glm::inverse(viewProj); + + std::array corners{}; + std::size_t cornerIndex = 0; + for (const float x : {-1.0f, 1.0f}) { + for (const float y : {-1.0f, 1.0f}) { + for (const float z : {0.0f, 1.0f}) { + const glm::vec4 corner = clipToWorld * glm::vec4{x, y, z, 1.0f}; + corners[cornerIndex++] = glm::vec3{corner} / corner.w; + } + } + } + return corners; +} + +bool edge::frustumsOverlap(const Frustum& frustum, const glm::mat4& otherViewProj) +{ + const auto otherCorners = calculateFrustumCorners(otherViewProj); + + for (int planeIndex = 0; planeIndex < 6; ++planeIndex) { + const Frustum::Plane& plane = frustum.getPlane(planeIndex); + const bool allCornersOutside = std::ranges::all_of(otherCorners, [&plane](const glm::vec3& corner) { + return plane.getSignedDistanceToPlane(corner) < 0.0f; + }); + if (allCornersOutside) { + return false; + } + } + return true; +} + bool isOnOrForwardPlane(const Frustum::Plane& plane, const Sphere& sphere) { return plane.getSignedDistanceToPlane(sphere.center) > -sphere.radius; diff --git a/destrum/src/Graphics/Pipelines/LightCullingPipeline.cpp b/destrum/src/Graphics/Pipelines/LightCullingPipeline.cpp index cec7660..e0889e0 100644 --- a/destrum/src/Graphics/Pipelines/LightCullingPipeline.cpp +++ b/destrum/src/Graphics/Pipelines/LightCullingPipeline.cpp @@ -131,7 +131,7 @@ void LightCullingPipeline::beginDrawing(std::size_t frameIndex) getCurrentFrameData().lightsBuffer.clear(); } -void LightCullingPipeline::appendLight(const Light& light) +void LightCullingPipeline::appendLight(const Light& light, std::uint32_t shadowIndex) { auto& lightsBuffer = getCurrentFrameData().lightsBuffer; if (lightsBuffer.size >= lightsBuffer.capacity) @@ -158,7 +158,7 @@ void LightCullingPipeline::appendLight(const Light& light) .type = static_cast(light.type), .spotInnerCos = std::cos(light.innerConeAngle), .spotOuterCos = std::cos(light.outerConeAngle), - .castShadows = light.castShadows ? 1u : 0u, + .shadowIndex = shadowIndex, .padding = 0.0f, }; lightsBuffer.append(gpuLight); diff --git a/destrum/src/Graphics/Pipelines/ShadowMapPipeline.cpp b/destrum/src/Graphics/Pipelines/ShadowMapPipeline.cpp index ea67194..bf66af9 100644 --- a/destrum/src/Graphics/Pipelines/ShadowMapPipeline.cpp +++ b/destrum/src/Graphics/Pipelines/ShadowMapPipeline.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -11,11 +12,43 @@ #include #include #include +#include #include #include "volk.h" #include "spdlog/spdlog.h" +namespace +{ + constexpr float MAX_SPOT_SHADOW_FIELD_OF_VIEW = glm::radians(170.0f); + constexpr float SPOT_SHADOW_NEAR_PLANE = 0.05f; + constexpr float NORMAL_OFFSET_IN_TEXELS = 1.5f; + + glm::vec3 upVectorFor(const glm::vec3& direction) + { + return std::abs(direction.y) > 0.99f ? glm::vec3{0.0f, 0.0f, 1.0f} : glm::vec3{0.0f, 1.0f, 0.0f}; + } + + float spotShadowFieldOfView(const Light& spotLight) + { + return std::min(spotLight.outerConeAngle * 2.0f, MAX_SPOT_SHADOW_FIELD_OF_VIEW); + } + + VkRect2D spotShadowTileRegion(std::uint32_t shadowIndex) + { + const std::uint32_t column = shadowIndex % ShadowMapPipeline::SPOT_SHADOW_TILES_PER_ROW; + const std::uint32_t row = shadowIndex / ShadowMapPipeline::SPOT_SHADOW_TILES_PER_ROW; + + return VkRect2D{ + .offset = { + static_cast(column * ShadowMapPipeline::SPOT_SHADOW_TILE_SIZE), + static_cast(row * ShadowMapPipeline::SPOT_SHADOW_TILE_SIZE), + }, + .extent = {ShadowMapPipeline::SPOT_SHADOW_TILE_SIZE, ShadowMapPipeline::SPOT_SHADOW_TILE_SIZE}, + }; + } +} + void ShadowMapPipeline::init(GfxDevice& gfxDevice, RenderResources& resources) { try @@ -77,14 +110,32 @@ void ShadowMapPipeline::init(GfxDevice& gfxDevice, RenderResources& resources) pipelineConfig ); - const auto createInfo = vkutil::CreateImageInfo{ - .format = shadowMapFormat, - .usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, - .extent = VkExtent3D{SHADOW_MAP_SIZE, SHADOW_MAP_SIZE, 1}, - }; + const auto depthUsage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; - shadowMapId = resources.createImage(gfxDevice, createInfo, "shadow map", nullptr, shadowMapId); + shadowMapId = resources.createImage( + gfxDevice, + { + .format = shadowMapFormat, + .usage = depthUsage, + .extent = VkExtent3D{SHADOW_MAP_SIZE, SHADOW_MAP_SIZE, 1}, + }, + "shadow map", + nullptr, + shadowMapId); spdlog::info("Created {0}x{0} shadow map with id {1}", SHADOW_MAP_SIZE, shadowMapId); + + constexpr std::uint32_t spotShadowAtlasSize = SPOT_SHADOW_TILE_SIZE * SPOT_SHADOW_TILES_PER_ROW; + spotShadowAtlasId = resources.createImage( + gfxDevice, + { + .format = shadowMapFormat, + .usage = depthUsage, + .extent = VkExtent3D{spotShadowAtlasSize, spotShadowAtlasSize, 1}, + }, + "spot shadow atlas", + nullptr, + spotShadowAtlasId); + spdlog::info("Created {0}x{0} spot shadow atlas with id {1}", spotShadowAtlasSize, spotShadowAtlasId); } catch (...) { @@ -104,14 +155,13 @@ void ShadowMapPipeline::cleanup(VkDevice device) m_pipelineLayout = VK_NULL_HANDLE; shadowMapId = NULL_IMAGE_ID; + spotShadowAtlasId = NULL_IMAGE_ID; } -glm::mat4 ShadowMapPipeline::calculateLightViewProj(const glm::vec3& lightDirection, const glm::vec3& center) const +glm::mat4 ShadowMapPipeline::calculateDirectionalViewProj(const glm::vec3& lightDirection, const glm::vec3& center) const { const glm::vec3 direction = glm::normalize(lightDirection); - const glm::vec3 up = std::abs(direction.y) > 0.99f ? glm::vec3{0.0f, 0.0f, 1.0f} : glm::vec3{0.0f, 1.0f, 0.0f}; - - const glm::mat4 lightView = glm::lookAt(glm::vec3{0.0f}, direction, up); + const glm::mat4 lightView = glm::lookAt(glm::vec3{0.0f}, direction, upVectorFor(direction)); const float texelSize = (2.0f * shadowDistance) / static_cast(SHADOW_MAP_SIZE); glm::vec3 lightSpaceCenter = glm::vec3(lightView * glm::vec4(center, 1.0f)); @@ -129,18 +179,36 @@ glm::mat4 ShadowMapPipeline::calculateLightViewProj(const glm::vec3& lightDirect return lightProj * lightView; } +GPUSpotShadow ShadowMapPipeline::calculateSpotShadow(const Light& spotLight) +{ + const glm::vec3 direction = glm::normalize(spotLight.direction); + const float fieldOfView = spotShadowFieldOfView(spotLight); + const float farPlane = std::max(spotLight.range, SPOT_SHADOW_NEAR_PLANE * 2.0f); + + const glm::mat4 lightView = glm::lookAt(spotLight.position, spotLight.position + direction, upVectorFor(direction)); + const glm::mat4 lightProj = glm::perspectiveRH_ZO(fieldOfView, 1.0f, SPOT_SHADOW_NEAR_PLANE, farPlane); + + const float texelSizeAtOneMeter = + 2.0f * std::tan(fieldOfView * 0.5f) / static_cast(SPOT_SHADOW_TILE_SIZE); + + return GPUSpotShadow{ + .lightViewProj = lightProj * lightView, + .normalOffsetPerMeter = texelSizeAtOneMeter * NORMAL_OFFSET_IN_TEXELS, + }; +} + float ShadowMapPipeline::getNormalOffset() const { const float texelSize = (2.0f * shadowDistance) / static_cast(SHADOW_MAP_SIZE); - return texelSize * 1.5f; + return texelSize * NORMAL_OFFSET_IN_TEXELS; } -void ShadowMapPipeline::draw( +void ShadowMapPipeline::drawDirectionalShadow( VkCommandBuffer cmd, const RenderResources& resources, - const GPUBuffer& sceneDataBuffer, const std::vector& drawCommands, - const std::vector& sortedDrawCommands) + const std::vector& sortedDrawCommands, + const glm::mat4& lightViewProj) { if (!m_pipeline || shadowMapId == NULL_IMAGE_ID) { @@ -149,16 +217,55 @@ void ShadowMapPipeline::draw( const auto& shadowMap = resources.getImage(shadowMapId); + beginDepthRendering(cmd, resources, shadowMap); + setRenderRegion(cmd, VkRect2D{.offset = {}, .extent = shadowMap.getExtent2D()}); + drawShadowCasters(cmd, resources, drawCommands, sortedDrawCommands, lightViewProj); + endDepthRendering(cmd, shadowMap); +} + +void ShadowMapPipeline::drawSpotShadows( + VkCommandBuffer cmd, + const RenderResources& resources, + const std::vector& drawCommands, + const std::vector& sortedDrawCommands, + const std::vector& lightViewProjs) +{ + if (!m_pipeline || spotShadowAtlasId == NULL_IMAGE_ID || lightViewProjs.empty()) + { + return; + } + + assert(lightViewProjs.size() <= MAX_SPOT_SHADOWS); + const auto& spotShadowAtlas = resources.getImage(spotShadowAtlasId); + + beginDepthRendering(cmd, resources, spotShadowAtlas); + for (std::uint32_t shadowIndex = 0; shadowIndex < lightViewProjs.size(); ++shadowIndex) + { + vkutil::cmdBeginLabelFormatted(cmd, vkutil::labelColors::shadow, "Spot shadow %u", shadowIndex); + + setRenderRegion(cmd, spotShadowTileRegion(shadowIndex)); + drawShadowCasters(cmd, resources, drawCommands, sortedDrawCommands, lightViewProjs[shadowIndex]); + + vkutil::cmdEndLabel(cmd); + } + endDepthRendering(cmd, spotShadowAtlas); +} + +void ShadowMapPipeline::beginDepthRendering( + VkCommandBuffer cmd, + const RenderResources& resources, + const GPUImage& depthImage) +{ vkutil::transitionImage( cmd, - shadowMap.image, - shadowMap.layout, + depthImage.image, + depthImage.layout, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL); - shadowMap.setLayout(VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL); + depthImage.setLayout(VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL); auto renderInfo = vkutil::createRenderingInfo({ - .renderExtent = shadowMap.getExtent2D(), - .depthImageView = shadowMap.imageView, + .renderExtent = depthImage.getExtent2D(), + .depthImageView = depthImage.imageView, .depthImageClearValue = 1.f, }); renderInfo.renderingInfo.pDepthAttachment = &renderInfo.depthAttachment; @@ -167,31 +274,49 @@ void ShadowMapPipeline::draw( m_pipeline->bind(cmd); resources.bindBindlessDescSet(cmd, m_pipelineLayout); + vkCmdSetPolygonModeEXT(cmd, VK_POLYGON_MODE_FILL); +} +void ShadowMapPipeline::endDepthRendering(VkCommandBuffer cmd, const GPUImage& depthImage) +{ + vkCmdEndRendering(cmd); + + vkutil::transitionImage( + cmd, + depthImage.image, + depthImage.layout, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + depthImage.setLayout(VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); +} + +void ShadowMapPipeline::setRenderRegion(VkCommandBuffer cmd, const VkRect2D& region) +{ const auto viewport = VkViewport{ - .x = 0.f, - .y = 0.f, - .width = static_cast(SHADOW_MAP_SIZE), - .height = static_cast(SHADOW_MAP_SIZE), + .x = static_cast(region.offset.x), + .y = static_cast(region.offset.y), + .width = static_cast(region.extent.width), + .height = static_cast(region.extent.height), .minDepth = 0.f, .maxDepth = 1.f, }; vkCmdSetViewport(cmd, 0, 1, &viewport); + vkCmdSetScissor(cmd, 0, 1, ®ion); +} - const auto scissor = VkRect2D{ - .offset = {}, - .extent = shadowMap.getExtent2D(), - }; - vkCmdSetScissor(cmd, 0, 1, &scissor); - - vkCmdSetPolygonModeEXT(cmd, VK_POLYGON_MODE_FILL); - +void ShadowMapPipeline::drawShadowCasters( + VkCommandBuffer cmd, + const RenderResources& resources, + const std::vector& drawCommands, + const std::vector& sortedDrawCommands, + const glm::mat4& lightViewProj) +{ const MeshCache& meshCache = resources.meshes(); - MeshID prevMeshId = NULL_MESH_ID; + const Frustum lightFrustum = edge::createFrustumFromViewProj(lightViewProj); + MeshID previousMeshId = NULL_MESH_ID; - const auto drawOne = [&](const MeshDrawCommand& dc) + const auto drawCaster = [&](const MeshDrawCommand& dc) { - if (!dc.castShadow) + if (!dc.castShadow || !edge::isInFrustum(lightFrustum, dc.worldBoundingSphere)) { return; } @@ -208,21 +333,18 @@ void ShadowMapPipeline::draw( dc.materialId, dc.skinnedMesh != nullptr ? ", skinned" : ""); - if (dc.meshId != prevMeshId) + if (dc.meshId != previousMeshId) { - prevMeshId = dc.meshId; + previousMeshId = dc.meshId; vkCmdBindIndexBuffer(cmd, mesh.indexBuffer.buffer, 0, VK_INDEX_TYPE_UINT32); } const auto pushConstants = PushConstants{ - .transform = dc.transformMatrix, - .sceneDataBuffer = sceneDataBuffer.address, + .modelToLightClip = lightViewProj * dc.transformMatrix, .vertexBuffer = dc.skinnedMesh != nullptr ? dc.skinnedMesh->skinnedVertexBuffer.address : mesh.vertexBuffer.address, - .materialId = dc.materialId, - .padding = 0, }; vkCmdPushConstants( @@ -243,23 +365,14 @@ void ShadowMapPipeline::draw( for (std::size_t drawIndex : sortedDrawCommands) { assert(drawIndex < drawCommands.size()); - drawOne(drawCommands[drawIndex]); + drawCaster(drawCommands[drawIndex]); } } else { for (const MeshDrawCommand& dc : drawCommands) { - drawOne(dc); + drawCaster(dc); } } - - vkCmdEndRendering(cmd); - - vkutil::transitionImage( - cmd, - shadowMap.image, - shadowMap.layout, - VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - shadowMap.setLayout(VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); } diff --git a/destrum/src/Graphics/Renderer.cpp b/destrum/src/Graphics/Renderer.cpp index bf281c8..6eeb0ba 100644 --- a/destrum/src/Graphics/Renderer.cpp +++ b/destrum/src/Graphics/Renderer.cpp @@ -70,6 +70,8 @@ void GameRenderer::beginDrawing(GfxDevice& gfxDevice) skinningPipeline->beginDrawing(gfxDevice.getCurrentFrameIndex()); lightCullingPipeline->beginDrawing(gfxDevice.getCurrentFrameIndex()); shadowLightDirection.reset(); + submittedLights.clear(); + spotShadows.clear(); Renderer2D::GetInstance().Clear(); } @@ -126,15 +128,26 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera& TracyVkZone(gfxDevice.getTracyVkCtx(), cmd, "Lights Buffer Flush"); vkutil::ScopedCmdLabel label(cmd, "Lights Buffer Flush", vkutil::labelColors::transfer); + uploadLights(sceneData.camera); lightCullingPipeline->flushCurrentFrame(cmd, gfxDevice); } const glm::mat4 lightViewProj = shadowLightDirection - ? shadowMapPipeline->calculateLightViewProj( + ? shadowMapPipeline->calculateDirectionalViewProj( *shadowLightDirection, sceneData.camera.GetPosition()) : glm::mat4{1.0f}; + std::array gpuSpotShadows{}; + std::ranges::copy(spotShadows, gpuSpotShadows.begin()); + + std::vector spotShadowViewProjs; + spotShadowViewProjs.reserve(spotShadows.size()); + for (const GPUSpotShadow& spotShadow : spotShadows) + { + spotShadowViewProjs.push_back(spotShadow.lightViewProj); + } + // TODO: there has to be a better way to do this const auto gpuSceneData = GPUSceneData{ .view = sceneData.camera.GetViewMatrix(), @@ -156,6 +169,9 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera& .shadowNormalOffset = shadowMapPipeline->getNormalOffset(), .padding = 0, .lightViewProj = lightViewProj, + .spotShadowAtlasId = static_cast(shadowMapPipeline->getSpotShadowAtlasId()), + .spotShadowTilesPerRow = ShadowMapPipeline::SPOT_SHADOW_TILES_PER_ROW, + .spotShadows = gpuSpotShadows, }; { @@ -212,12 +228,25 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera& TracyVkZone(gfxDevice.getTracyVkCtx(), cmd, "Shadow Map"); vkutil::ScopedCmdLabel label(cmd, "Shadow Map", vkutil::labelColors::shadow); - shadowMapPipeline->draw( + shadowMapPipeline->drawDirectionalShadow( cmd, *resources, - sceneDataBuffer.getBuffer(), meshDrawCommands, - sortedMeshDrawCommands); + sortedMeshDrawCommands, + lightViewProj); + } + + if (!spotShadowViewProjs.empty()) + { + TracyVkZone(gfxDevice.getTracyVkCtx(), cmd, "Spot Shadows"); + vkutil::ScopedCmdLabel label(cmd, "Spot Shadows", vkutil::labelColors::shadow); + + shadowMapPipeline->drawSpotShadows( + cmd, + *resources, + meshDrawCommands, + sortedMeshDrawCommands, + spotShadowViewProjs); } { @@ -400,6 +429,8 @@ void GameRenderer::cleanup(GfxDevice& gfxDevice) lightCullingPipeline.reset(); shadowMapPipeline.reset(); shadowLightDirection.reset(); + submittedLights.clear(); + spotShadows.clear(); pendingMaterialUploads.clear(); meshDrawCommands.clear(); sortedMeshDrawCommands.clear(); @@ -444,18 +475,46 @@ void GameRenderer::drawSkinnedMesh(MeshID id, void GameRenderer::submitLight(const Light& light) { - Light submittedLight = light; + submittedLights.push_back(light); +} - // there's a single shadow map, the first shadow casting directional light gets it - submittedLight.castShadows = - light.castShadows && light.type == LightType::Directional && !shadowLightDirection; +void GameRenderer::uploadLights(const Camera& camera) +{ + const Frustum cameraFrustum = edge::createFrustumFromCamera(camera); - if (submittedLight.castShadows) + for (const Light& light : submittedLights) { - shadowLightDirection = light.direction; + lightCullingPipeline->appendLight(light, assignShadowIndex(light, cameraFrustum)); + } +} + +std::uint32_t GameRenderer::assignShadowIndex(const Light& light, const Frustum& cameraFrustum) +{ + if (!light.castShadows) + { + return NO_SHADOW; } - lightCullingPipeline->appendLight(submittedLight); + // there's a single directional shadow map, the first shadow casting directional light gets it + if (light.type == LightType::Directional && !shadowLightDirection) + { + shadowLightDirection = light.direction; + return 0; + } + + if (light.type == LightType::Spot && spotShadows.size() < ShadowMapPipeline::MAX_SPOT_SHADOWS) + { + const GPUSpotShadow spotShadow = ShadowMapPipeline::calculateSpotShadow(light); + if (!edge::frustumsOverlap(cameraFrustum, spotShadow.lightViewProj)) + { + return NO_SHADOW; + } + + spotShadows.push_back(spotShadow); + return static_cast(spotShadows.size() - 1); + } + + return NO_SHADOW; } const GPUImage& GameRenderer::getDrawImage() const diff --git a/lightkeeper/include/Lightkeeper.h b/lightkeeper/include/Lightkeeper.h index 3e188eb..8e2e4ab 100644 --- a/lightkeeper/include/Lightkeeper.h +++ b/lightkeeper/include/Lightkeeper.h @@ -32,7 +32,6 @@ public: private: void setupSkybox(); void drawCrosshair(); - void drawViewModeHint(); void updateViewMode(); [[nodiscard]] CameraComponent* getSceneCamera() const; [[nodiscard]] Camera& getViewCamera(); diff --git a/lightkeeper/src/Lightkeeper.cpp b/lightkeeper/src/Lightkeeper.cpp index c929472..db9ddcb 100644 --- a/lightkeeper/src/Lightkeeper.cpp +++ b/lightkeeper/src/Lightkeeper.cpp @@ -103,25 +103,6 @@ void LightKeeper::updateViewMode() } } -void LightKeeper::drawViewModeHint() -{ - if (isPlayerViewActive() || mainScene.playerController == nullptr) { - return; - } - - constexpr std::string_view hint = "Free camera RMB look WASD / Q E move F5 play"; - constexpr float hintSize = 18.0f; - const VkExtent3D extent = renderer.getDrawImage().extent; - const glm::vec2 hintExtent = hudFont.MeasureText(hint, hintSize); - - Renderer2D::GetInstance().DrawText( - hudFont, - hint, - {16.0f, static_cast(extent.height) - hintExtent.y - 12.0f}, - hintSize, - TextStyle{.color = {1.0f, 1.0f, 1.0f, 0.85f}, .outline = 2.0f, .outlineColor = {0.0f, 0.0f, 0.0f, 0.6f}}); -} - void LightKeeper::drawCrosshair() { if (!isPlayerViewActive()) { @@ -185,7 +166,6 @@ void LightKeeper::customDraw() renderer.beginDrawing(gfxDevice); submitDebugDraws(); drawCrosshair(); - drawViewModeHint(); if (isPlayerViewActive()) { diff --git a/lightkeeper/src/scenes/MainScene.cpp b/lightkeeper/src/scenes/MainScene.cpp index 5cb117e..dc556cc 100644 --- a/lightkeeper/src/scenes/MainScene.cpp +++ b/lightkeeper/src/scenes/MainScene.cpp @@ -60,6 +60,23 @@ namespace { sun->SetCastShadows(true); } + void CreateHelmetSpotlight(Scene& scene) + { + const glm::vec3 position{2.0f, 3.5f, -2.5f}; + const glm::vec3 target{2.0f, GroundHeight, 0.0f}; + + GameObject* spotObj = scene.CreateGameObject("HelmetSpotlight"); + spotObj->GetTransform().SetWorldPosition(position); + spotObj->GetTransform().SetWorldRotation(glm::quatLookAt(glm::normalize(target - position), glm::vec3{0.0f, 1.0f, 0.0f})); + + auto* spot = spotObj->AddComponent(LightType::Spot); + spot->SetColor(glm::vec3{1.0f, 0.85f, 0.7f}); + spot->SetIntensity(25.0f); + spot->SetRange(12.0f); + spot->SetConeAngles(glm::radians(20.0f), glm::radians(30.0f)); + spot->SetCastShadows(true); + } + void CreateGround(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources) { const auto planeModel = AssetManager::GetInstance().LoadModel("game://plane.glb", StaticModelOptions()); @@ -443,6 +460,7 @@ MainSceneObjects BuildMainScene(Scene& scene, GfxDevice& gfxDevice, RenderResour CreateGround(scene, gfxDevice, resources); CreateParkourCourse(scene, gfxDevice, resources); CreateFlightHelmet(scene, gfxDevice, resources); + CreateHelmetSpotlight(scene); CreateTree(scene, gfxDevice, resources); // CreateCapybara(scene, gfxDevice, resources); CreateTriggerZone(scene); diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp index 80ff290..835b84f 100644 --- a/tests/destrum_tests.cpp +++ b/tests/destrum_tests.cpp @@ -35,6 +35,8 @@ #include #include #include +#include +#include #include #include #include @@ -511,6 +513,33 @@ namespace { SceneManager::GetInstance().Destroy(); } + void testFrustumFromViewProj() + { + const auto lookingAlongPositiveZ = [](const glm::vec3& position, float range) { + const glm::mat4 view = glm::lookAt(position, position + glm::vec3{0.0f, 0.0f, 1.0f}, glm::vec3{0.0f, 1.0f, 0.0f}); + return glm::perspectiveRH_ZO(glm::radians(60.0f), 1.0f, 0.1f, range) * view; + }; + + const glm::mat4 viewProj = lookingAlongPositiveZ(glm::vec3{0.0f}, 20.0f); + const Frustum frustum = edge::createFrustumFromViewProj(viewProj); + + check(edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, 10.0f}, 0.5f}), "a sphere in front must be inside"); + check(!edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, -5.0f}, 0.5f}), "a sphere behind must be outside"); + check(!edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, 30.0f}, 0.5f}), "a sphere past the far plane must be outside"); + check(!edge::isInFrustum(frustum, Sphere{{15.0f, 0.0f, 10.0f}, 0.5f}), "a sphere to the side must be outside"); + + const auto corners = edge::calculateFrustumCorners(viewProj); + const bool cornersOnNearAndFar = std::ranges::all_of(corners, [](const glm::vec3& corner) { + return std::abs(corner.z - 0.1f) < 0.001f || std::abs(corner.z - 20.0f) < 0.01f; + }); + check(cornersOnNearAndFar, "frustum corners must lie on the near and far planes"); + + check(edge::frustumsOverlap(frustum, lookingAlongPositiveZ(glm::vec3{0.0f, 0.0f, 5.0f}, 10.0f)), + "a frustum in front of the camera must overlap"); + check(!edge::frustumsOverlap(frustum, lookingAlongPositiveZ(glm::vec3{0.0f, 0.0f, -30.0f}, 10.0f)), + "a frustum entirely behind the camera must not overlap"); + } + void testRenderer2DShapes() { auto& r2d = Renderer2D::GetInstance(); @@ -1305,6 +1334,7 @@ int main() testPrimitiveBoxMesh(); testColliderCenterOffset(); testCapsuleColliderDebugLines(); + testFrustumFromViewProj(); testRenderer2DShapes(); testFontAtlasAndLayout(); std::cout << "destrum tests passed\n";