Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a04c1884a | ||
|
|
92c1daf396 | ||
|
|
997844eaa7 | ||
|
|
36341bb5e0 |
@@ -58,11 +58,13 @@ set(SRC_FILES
|
|||||||
"src/Graphics/BindlessSetManager.cpp"
|
"src/Graphics/BindlessSetManager.cpp"
|
||||||
"src/Graphics/Camera.cpp"
|
"src/Graphics/Camera.cpp"
|
||||||
"src/Graphics/DebugDraw.cpp"
|
"src/Graphics/DebugDraw.cpp"
|
||||||
|
"src/Graphics/DeletionQueue.cpp"
|
||||||
"src/Graphics/ComputePipeline.cpp"
|
"src/Graphics/ComputePipeline.cpp"
|
||||||
"src/Graphics/Frustum.cpp"
|
"src/Graphics/Frustum.cpp"
|
||||||
"src/Graphics/GfxDevice.cpp"
|
"src/Graphics/GfxDevice.cpp"
|
||||||
"src/Graphics/Caches/ImageCache.cpp"
|
"src/Graphics/Caches/ImageCache.cpp"
|
||||||
"src/Graphics/ImageLoader.cpp"
|
"src/Graphics/ImageLoader.cpp"
|
||||||
|
"src/Graphics/IndirectDrawList.cpp"
|
||||||
"src/Graphics/ImmediateExecuter.cpp"
|
"src/Graphics/ImmediateExecuter.cpp"
|
||||||
"src/Graphics/Init.cpp"
|
"src/Graphics/Init.cpp"
|
||||||
"src/Graphics/Caches/MaterialCache.cpp"
|
"src/Graphics/Caches/MaterialCache.cpp"
|
||||||
@@ -127,7 +129,6 @@ set(SRC_FILES
|
|||||||
"src/Util/SceneInspector.cpp"
|
"src/Util/SceneInspector.cpp"
|
||||||
|
|
||||||
"src/Physics/PhysicsWorld.cpp"
|
"src/Physics/PhysicsWorld.cpp"
|
||||||
"src/Physics/SimplePhysicsWorld.cpp"
|
|
||||||
"src/Physics/PhysicsSceneBridge.cpp"
|
"src/Physics/PhysicsSceneBridge.cpp"
|
||||||
"src/Physics/JoltPhysicsWorld.cpp"
|
"src/Physics/JoltPhysicsWorld.cpp"
|
||||||
)
|
)
|
||||||
@@ -232,6 +233,7 @@ if (SPIRV_VAL_EXECUTABLE)
|
|||||||
file(RELATIVE_PATH shader_relative "${ASSETS_SRC_DIR}" "${shader_source}")
|
file(RELATIVE_PATH shader_relative "${ASSETS_SRC_DIR}" "${shader_source}")
|
||||||
list(APPEND VALIDATE_SHADER_COMMANDS
|
list(APPEND VALIDATE_SHADER_COMMANDS
|
||||||
COMMAND "${SPIRV_VAL_EXECUTABLE}"
|
COMMAND "${SPIRV_VAL_EXECUTABLE}"
|
||||||
|
--target-env vulkan1.3 --scalar-block-layout
|
||||||
"${ASSETS_RUNTIME_DIR}/${shader_relative}.spv")
|
"${ASSETS_RUNTIME_DIR}/${shader_relative}.spv")
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#ifndef INSTANCE_DATA_GLSL
|
||||||
|
#define INSTANCE_DATA_GLSL
|
||||||
|
|
||||||
|
#extension GL_EXT_buffer_reference : require
|
||||||
|
#extension GL_EXT_scalar_block_layout : require
|
||||||
|
|
||||||
|
#include "vertex.glsl"
|
||||||
|
|
||||||
|
// Must match GPUInstanceData in IndirectDrawList.h
|
||||||
|
struct InstanceData {
|
||||||
|
mat4 transform;
|
||||||
|
VertexBuffer vertexBuffer;
|
||||||
|
uint materialID;
|
||||||
|
uint padding;
|
||||||
|
};
|
||||||
|
|
||||||
|
layout (buffer_reference, scalar) readonly buffer InstanceBuffer {
|
||||||
|
InstanceData instances[];
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // INSTANCE_DATA_GLSL
|
||||||
@@ -12,6 +12,7 @@ layout (location = 0) in vec3 inPos;
|
|||||||
layout (location = 1) in vec2 inUV;
|
layout (location = 1) in vec2 inUV;
|
||||||
layout (location = 2) in vec3 inNormal;
|
layout (location = 2) in vec3 inNormal;
|
||||||
layout (location = 3) in vec4 inTangent;
|
layout (location = 3) in vec4 inTangent;
|
||||||
|
layout (location = 4) flat in uint inMaterialID;
|
||||||
|
|
||||||
layout (location = 0) out vec4 outFragColor;
|
layout (location = 0) out vec4 outFragColor;
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ float calculateShadow(Light light, vec3 fragPos, vec3 geometryNormal)
|
|||||||
|
|
||||||
void main()
|
void main()
|
||||||
{
|
{
|
||||||
MaterialData material = pcs.sceneData.materials.data[pcs.materialID];
|
MaterialData material = pcs.sceneData.materials.data[inMaterialID];
|
||||||
uint samplerID = material.textureFilteringMode;
|
uint samplerID = material.textureFilteringMode;
|
||||||
|
|
||||||
vec4 diffuse = sampleTexture2D(material.diffuseTex, samplerID, inUV) * material.baseColor;
|
vec4 diffuse = sampleTexture2D(material.diffuseTex, samplerID, inUV) * material.baseColor;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ layout (location = 0) out vec3 outPos;
|
|||||||
layout (location = 1) out vec2 outUV;
|
layout (location = 1) out vec2 outUV;
|
||||||
layout (location = 2) out vec3 outNormal;
|
layout (location = 2) out vec3 outNormal;
|
||||||
layout (location = 3) out vec4 outTangent;
|
layout (location = 3) out vec4 outTangent;
|
||||||
|
layout (location = 4) flat out uint outMaterialID;
|
||||||
|
|
||||||
// The depth pre-pass reuses this shader, positions have to match bit for bit
|
// The depth pre-pass reuses this shader, positions have to match bit for bit
|
||||||
// so the main pass can depth test against it with LESS_OR_EQUAL
|
// so the main pass can depth test against it with LESS_OR_EQUAL
|
||||||
@@ -15,9 +16,11 @@ invariant gl_Position;
|
|||||||
|
|
||||||
void main()
|
void main()
|
||||||
{
|
{
|
||||||
Vertex v = pcs.vertexBuffer.vertices[gl_VertexIndex];
|
// gl_InstanceIndex already includes the indirect command's firstInstance
|
||||||
|
InstanceData instance = pcs.instanceBuffer.instances[gl_InstanceIndex];
|
||||||
|
Vertex v = instance.vertexBuffer.vertices[gl_VertexIndex];
|
||||||
|
|
||||||
vec4 worldPos = pcs.transform * vec4(v.position, 1.0f);
|
vec4 worldPos = instance.transform * vec4(v.position, 1.0f);
|
||||||
|
|
||||||
gl_Position = pcs.sceneData.viewProj * worldPos;
|
gl_Position = pcs.sceneData.viewProj * worldPos;
|
||||||
outPos = worldPos.xyz;
|
outPos = worldPos.xyz;
|
||||||
@@ -25,9 +28,10 @@ void main()
|
|||||||
// A bit inefficient, but okay - this is needed for non-uniform scale
|
// A bit inefficient, but okay - this is needed for non-uniform scale
|
||||||
// models. See: http://www.lighthouse3d.com/tutorials/glsl-12-tutorial/the-normal-matrix/
|
// models. See: http://www.lighthouse3d.com/tutorials/glsl-12-tutorial/the-normal-matrix/
|
||||||
// Simpler case, when everything is uniform
|
// Simpler case, when everything is uniform
|
||||||
// outNormal = (pcs.transform * vec4(v.normal, 0.0)).xyz;
|
// outNormal = (instance.transform * vec4(v.normal, 0.0)).xyz;
|
||||||
outNormal = mat3(transpose(inverse(pcs.transform))) * v.normal;
|
outNormal = mat3(transpose(inverse(instance.transform))) * v.normal;
|
||||||
|
|
||||||
// w is the bitangent sign, the fragment shader builds the TBN from the interpolated vectors
|
// w is the bitangent sign, the fragment shader builds the TBN from the interpolated vectors
|
||||||
outTangent = vec4(mat3(pcs.transform) * v.tangent.xyz, v.tangent.w);
|
outTangent = vec4(mat3(instance.transform) * v.tangent.xyz, v.tangent.w);
|
||||||
|
outMaterialID = instance.materialID;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,10 @@
|
|||||||
#extension GL_EXT_scalar_block_layout: require
|
#extension GL_EXT_scalar_block_layout: require
|
||||||
|
|
||||||
#include "scene_data.glsl"
|
#include "scene_data.glsl"
|
||||||
#include "vertex.glsl"
|
#include "instance_data.glsl"
|
||||||
|
|
||||||
layout (push_constant, scalar) uniform constants
|
layout (push_constant, scalar) uniform constants
|
||||||
{
|
{
|
||||||
mat4 transform;
|
|
||||||
SceneDataBuffer sceneData;
|
SceneDataBuffer sceneData;
|
||||||
VertexBuffer vertexBuffer;
|
InstanceBuffer instanceBuffer;
|
||||||
uint materialID;
|
|
||||||
uint padding;
|
|
||||||
} pcs;
|
} pcs;
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,18 @@
|
|||||||
#extension GL_EXT_buffer_reference : require
|
#extension GL_EXT_buffer_reference : require
|
||||||
#extension GL_EXT_scalar_block_layout : require
|
#extension GL_EXT_scalar_block_layout : require
|
||||||
|
|
||||||
#include "vertex.glsl"
|
#include "instance_data.glsl"
|
||||||
|
|
||||||
layout (push_constant, scalar) uniform constants
|
layout (push_constant, scalar) uniform constants
|
||||||
{
|
{
|
||||||
mat4 modelToLightClip;
|
mat4 lightViewProj;
|
||||||
VertexBuffer vertexBuffer;
|
InstanceBuffer instanceBuffer;
|
||||||
} pcs;
|
} pcs;
|
||||||
|
|
||||||
void main()
|
void main()
|
||||||
{
|
{
|
||||||
Vertex v = pcs.vertexBuffer.vertices[gl_VertexIndex];
|
InstanceData instance = pcs.instanceBuffer.instances[gl_InstanceIndex];
|
||||||
|
Vertex v = instance.vertexBuffer.vertices[gl_VertexIndex];
|
||||||
|
|
||||||
gl_Position = pcs.modelToLightClip * vec4(v.position, 1.0f);
|
gl_Position = pcs.lightViewProj * instance.transform * vec4(v.position, 1.0f);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <memory>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ public:
|
|||||||
ImageID addImage(GPUImage image);
|
ImageID addImage(GPUImage image);
|
||||||
ImageID addImage(ImageID id, GPUImage image);
|
ImageID addImage(ImageID id, GPUImage image);
|
||||||
[[nodiscard]] const GPUImage& getImage(ImageID id) const;
|
[[nodiscard]] const GPUImage& getImage(ImageID id) const;
|
||||||
|
void removeImage(ImageID id);
|
||||||
|
|
||||||
[[nodiscard]] ImageID getFreeImageId() const;
|
[[nodiscard]] ImageID getFreeImageId() const;
|
||||||
[[nodiscard]] std::uint32_t getMaxImageCount() const {
|
[[nodiscard]] std::uint32_t getMaxImageCount() const {
|
||||||
@@ -52,6 +54,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::vector<GPUImage> images;
|
std::vector<GPUImage> images;
|
||||||
|
std::shared_ptr<std::vector<ImageID>> freeIds = std::make_shared<std::vector<ImageID>>();
|
||||||
GfxDevice& gfxDevice;
|
GfxDevice& gfxDevice;
|
||||||
|
|
||||||
struct LoadedImageInfo {
|
struct LoadedImageInfo {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public:
|
|||||||
|
|
||||||
|
|
||||||
Material& getMaterialMutable(MaterialID id);
|
Material& getMaterialMutable(MaterialID id);
|
||||||
void updateMaterialGPU(MaterialID id);
|
[[nodiscard]] MaterialData getMaterialData(MaterialID id) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
[[nodiscard]] MaterialData toMaterialData(const Material& material) const;
|
[[nodiscard]] MaterialData toMaterialData(const Material& material) const;
|
||||||
|
|||||||
@@ -16,12 +16,24 @@ public:
|
|||||||
|
|
||||||
MeshID addMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh);
|
MeshID addMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh);
|
||||||
const GPUMesh& getMesh(MeshID id) const;
|
const GPUMesh& getMesh(MeshID id) const;
|
||||||
|
[[nodiscard]] const std::vector<GPUMesh>& getMeshes() const { return meshes; }
|
||||||
const CPUMesh& getCPUMesh(MeshID id) const;
|
const CPUMesh& getCPUMesh(MeshID id) const;
|
||||||
[[nodiscard]] const std::string& getMeshKey(MeshID id) const;
|
[[nodiscard]] const std::string& getMeshKey(MeshID id) const;
|
||||||
[[nodiscard]] std::optional<MeshID> findMeshByKey(std::string_view key) const;
|
[[nodiscard]] std::optional<MeshID> findMeshByKey(std::string_view key) const;
|
||||||
|
|
||||||
|
[[nodiscard]] const GPUBuffer& getIndexBuffer() const { return indexBuffer; }
|
||||||
|
[[nodiscard]] std::uint32_t getIndexCount() const { return indexCount; }
|
||||||
|
[[nodiscard]] std::uint32_t getIndexCapacity() const { return indexCapacity; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh& gpuMesh) const;
|
void uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh& gpuMesh);
|
||||||
|
void ensureIndexCapacity(GfxDevice& gfxDevice, std::uint32_t requiredIndices);
|
||||||
|
|
||||||
|
static constexpr std::uint32_t InitialIndexCapacity = 1u << 20;
|
||||||
|
|
||||||
|
GPUBuffer indexBuffer;
|
||||||
|
std::uint32_t indexCount{0};
|
||||||
|
std::uint32_t indexCapacity{0};
|
||||||
|
|
||||||
std::vector<GPUMesh> meshes;
|
std::vector<GPUMesh> meshes;
|
||||||
std::vector<CPUMesh> cpuMeshes;
|
std::vector<CPUMesh> cpuMeshes;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#ifndef DELETIONQUEUE_H
|
||||||
|
#define DELETIONQUEUE_H
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
class DeletionQueue {
|
||||||
|
public:
|
||||||
|
void push(std::uint64_t retiredInFrame, std::function<void()>&& deleter);
|
||||||
|
|
||||||
|
void flush(std::uint64_t lastCompletedFrame);
|
||||||
|
|
||||||
|
void flushAll();
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t size() const { return entries.size(); }
|
||||||
|
[[nodiscard]] bool empty() const { return entries.empty(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Entry {
|
||||||
|
std::uint64_t frame;
|
||||||
|
std::function<void()> deleter;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::deque<Entry> entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DELETIONQUEUE_H
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#ifndef FRAMECONFIG_H
|
||||||
|
#define FRAMECONFIG_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
inline constexpr std::uint32_t FRAMES_IN_FLIGHT = 2;
|
||||||
|
|
||||||
|
#endif // FRAMECONFIG_H
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
#include <glm/vec2.hpp>
|
#include <glm/vec2.hpp>
|
||||||
#include <glm/vec4.hpp>
|
#include <glm/vec4.hpp>
|
||||||
|
|
||||||
|
#include <destrum/Graphics/DeletionQueue.h>
|
||||||
#include <destrum/Graphics/GPUImage.h>
|
#include <destrum/Graphics/GPUImage.h>
|
||||||
#include <destrum/Graphics/Swapchain.h>
|
#include <destrum/Graphics/Swapchain.h>
|
||||||
#include <destrum/Graphics/Resources/Buffer.h>
|
#include <destrum/Graphics/Resources/Buffer.h>
|
||||||
@@ -113,7 +114,8 @@ public:
|
|||||||
return memoryManager.createBuffer(allocSize, usage, memoryUsage);
|
return memoryManager.createBuffer(allocSize, usage, memoryUsage);
|
||||||
}
|
}
|
||||||
|
|
||||||
void destroyBuffer(GPUBuffer& buffer) const
|
void destroyBuffer(GPUBuffer& buffer);
|
||||||
|
void destroyBufferNow(GPUBuffer& buffer) const
|
||||||
{
|
{
|
||||||
memoryManager.destroyBuffer(buffer);
|
memoryManager.destroyBuffer(buffer);
|
||||||
}
|
}
|
||||||
@@ -148,11 +150,24 @@ public:
|
|||||||
imageManager.uploadImageData(image, pixelData, byteSize, layer);
|
imageManager.uploadImageData(image, pixelData, byteSize, layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
void destroyImage(GPUImage& image) const
|
void destroyImage(GPUImage& image);
|
||||||
|
void destroyImageNow(GPUImage& image) const
|
||||||
{
|
{
|
||||||
imageManager.destroyImage(image);
|
imageManager.destroyImage(image);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void deferDestroy(std::function<void()>&& deleter);
|
||||||
|
|
||||||
|
[[nodiscard]] std::uint64_t getFrameNumber() const
|
||||||
|
{
|
||||||
|
return frameManager.getFrameNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t getPendingDeletionCount() const
|
||||||
|
{
|
||||||
|
return deletionQueue.size();
|
||||||
|
}
|
||||||
|
|
||||||
[[nodiscard]] VkDevice getVkDevice() const
|
[[nodiscard]] VkDevice getVkDevice() const
|
||||||
{
|
{
|
||||||
return instanceManager.getDevice();
|
return instanceManager.getDevice();
|
||||||
@@ -228,6 +243,7 @@ private:
|
|||||||
ImageManager imageManager;
|
ImageManager imageManager;
|
||||||
|
|
||||||
VulkanImmediateExecutor executor;
|
VulkanImmediateExecutor executor;
|
||||||
|
DeletionQueue deletionQueue;
|
||||||
|
|
||||||
Swapchain swapchain;
|
Swapchain swapchain;
|
||||||
VkFormat swapchainFormat{VK_FORMAT_UNDEFINED};
|
VkFormat swapchainFormat{VK_FORMAT_UNDEFINED};
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#ifndef INDIRECTDRAWLIST_H
|
||||||
|
#define INDIRECTDRAWLIST_H
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <glm/mat4x4.hpp>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
#include <destrum/Graphics/FrameConfig.h>
|
||||||
|
#include <destrum/Graphics/Frustum.h>
|
||||||
|
#include <destrum/Graphics/MeshDrawCommand.h>
|
||||||
|
#include <destrum/Graphics/Resources/Buffer.h>
|
||||||
|
|
||||||
|
class GfxDevice;
|
||||||
|
struct GPUMesh;
|
||||||
|
|
||||||
|
// Must match InstanceData in instance_data.glsl
|
||||||
|
struct GPUInstanceData {
|
||||||
|
glm::mat4 transform;
|
||||||
|
VkDeviceAddress vertexBuffer;
|
||||||
|
std::uint32_t materialId;
|
||||||
|
std::uint32_t padding;
|
||||||
|
};
|
||||||
|
static_assert(sizeof(GPUInstanceData) == 80);
|
||||||
|
|
||||||
|
class IndirectDrawList {
|
||||||
|
public:
|
||||||
|
struct View {
|
||||||
|
std::uint32_t firstCommand{0};
|
||||||
|
std::uint32_t commandCount{0};
|
||||||
|
std::uint32_t instanceCount{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class Filter {
|
||||||
|
All,
|
||||||
|
ShadowCasters,
|
||||||
|
};
|
||||||
|
|
||||||
|
void cleanup(GfxDevice& gfxDevice);
|
||||||
|
|
||||||
|
void begin(std::uint32_t frameIndex);
|
||||||
|
|
||||||
|
View addView(
|
||||||
|
const std::vector<GPUMesh>& meshes,
|
||||||
|
const std::vector<MeshDrawCommand>& drawCommands,
|
||||||
|
const std::vector<std::size_t>& sortedDrawCommands,
|
||||||
|
const Frustum& frustum,
|
||||||
|
Filter filter);
|
||||||
|
|
||||||
|
void upload(GfxDevice& gfxDevice);
|
||||||
|
|
||||||
|
void draw(VkCommandBuffer cmd, const GPUBuffer& indexBuffer, const View& view) const;
|
||||||
|
|
||||||
|
[[nodiscard]] VkDeviceAddress getInstanceBufferAddress() const;
|
||||||
|
|
||||||
|
[[nodiscard]] const std::vector<GPUInstanceData>& getInstances() const { return instances; }
|
||||||
|
[[nodiscard]] const std::vector<VkDrawIndexedIndirectCommand>& getCommands() const { return commands; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct PerFrameBuffers {
|
||||||
|
GPUBuffer instances;
|
||||||
|
GPUBuffer commands;
|
||||||
|
std::size_t instanceCapacity{0};
|
||||||
|
std::size_t commandCapacity{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
static constexpr std::size_t InitialInstanceCapacity = 1024;
|
||||||
|
static constexpr std::size_t InitialCommandCapacity = 256;
|
||||||
|
|
||||||
|
std::array<PerFrameBuffers, FRAMES_IN_FLIGHT> frames{};
|
||||||
|
std::uint32_t currentFrame{0};
|
||||||
|
|
||||||
|
std::vector<GPUInstanceData> instances;
|
||||||
|
std::vector<VkDrawIndexedIndirectCommand> commands;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // INDIRECTDRAWLIST_H
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
#include <vulkan/vulkan.h>
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
#include <destrum/Graphics/FrameConfig.h>
|
||||||
|
|
||||||
class FrameManager {
|
class FrameManager {
|
||||||
public:
|
public:
|
||||||
struct FrameData {
|
struct FrameData {
|
||||||
@@ -27,10 +29,12 @@ public:
|
|||||||
[[nodiscard]] VkCommandBuffer beginFrame();
|
[[nodiscard]] VkCommandBuffer beginFrame();
|
||||||
void endFrame(VkCommandBuffer cmd);
|
void endFrame(VkCommandBuffer cmd);
|
||||||
|
|
||||||
void waitIdle() const;
|
|
||||||
|
|
||||||
[[nodiscard]] std::uint32_t getCurrentFrameIndex() const {
|
[[nodiscard]] std::uint32_t getCurrentFrameIndex() const {
|
||||||
return frameNumber % 2;
|
return static_cast<std::uint32_t>(frameNumber % FRAMES_IN_FLIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::uint64_t getFrameNumber() const {
|
||||||
|
return frameNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
[[nodiscard]] FrameData& getCurrentFrame() {
|
[[nodiscard]] FrameData& getCurrentFrame() {
|
||||||
@@ -48,9 +52,8 @@ public:
|
|||||||
void nextFrame();
|
void nextFrame();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr std::size_t FRAMES_IN_FLIGHT = 2;
|
|
||||||
std::array<FrameData, FRAMES_IN_FLIGHT> frames{};
|
std::array<FrameData, FRAMES_IN_FLIGHT> frames{};
|
||||||
std::uint32_t frameNumber{0};
|
std::uint64_t frameNumber{0};
|
||||||
VkDevice device{VK_NULL_HANDLE};
|
VkDevice device{VK_NULL_HANDLE};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ public:
|
|||||||
VkPhysicalDeviceFeatures device{
|
VkPhysicalDeviceFeatures device{
|
||||||
.imageCubeArray = VK_TRUE,
|
.imageCubeArray = VK_TRUE,
|
||||||
.geometryShader = VK_TRUE,
|
.geometryShader = VK_TRUE,
|
||||||
|
.multiDrawIndirect = VK_TRUE,
|
||||||
|
.drawIndirectFirstInstance = VK_TRUE,
|
||||||
.depthClamp = VK_TRUE,
|
.depthClamp = VK_TRUE,
|
||||||
.fillModeNonSolid = VK_TRUE,
|
.fillModeNonSolid = VK_TRUE,
|
||||||
.samplerAnisotropy = VK_TRUE,
|
.samplerAnisotropy = VK_TRUE,
|
||||||
|
|||||||
@@ -9,9 +9,8 @@
|
|||||||
#include <vulkan/vulkan.h>
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
#include <destrum/Graphics/Pipeline.h>
|
#include <destrum/Graphics/Pipeline.h>
|
||||||
#include <destrum/Graphics/Camera.h>
|
#include <destrum/Graphics/IndirectDrawList.h>
|
||||||
#include <destrum/Graphics/Resources/Buffer.h>
|
#include <destrum/Graphics/Resources/Buffer.h>
|
||||||
#include <destrum/Graphics/MeshDrawCommand.h>
|
|
||||||
|
|
||||||
class GfxDevice;
|
class GfxDevice;
|
||||||
class RenderResources;
|
class RenderResources;
|
||||||
@@ -31,19 +30,17 @@ public:
|
|||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
VkExtent2D renderExtent,
|
VkExtent2D renderExtent,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const Camera& camera,
|
|
||||||
const GPUBuffer& sceneDataBuffer,
|
const GPUBuffer& sceneDataBuffer,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands);
|
const IndirectDrawList::View& view);
|
||||||
|
|
||||||
void draw(
|
void draw(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
VkExtent2D renderExtent,
|
VkExtent2D renderExtent,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const Camera& camera,
|
|
||||||
const GPUBuffer& sceneDataBuffer,
|
const GPUBuffer& sceneDataBuffer,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands);
|
const IndirectDrawList::View& view);
|
||||||
|
|
||||||
void cleanup(VkDevice device);
|
void cleanup(VkDevice device);
|
||||||
|
|
||||||
@@ -57,11 +54,8 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
struct PushConstants {
|
struct PushConstants {
|
||||||
glm::mat4 transform;
|
|
||||||
VkDeviceAddress sceneDataBuffer;
|
VkDeviceAddress sceneDataBuffer;
|
||||||
VkDeviceAddress vertexBuffer;
|
VkDeviceAddress instanceBuffer;
|
||||||
std::uint32_t materialId;
|
|
||||||
std::uint32_t padding;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void drawMeshes(
|
void drawMeshes(
|
||||||
@@ -69,10 +63,9 @@ private:
|
|||||||
const Pipeline& pipeline,
|
const Pipeline& pipeline,
|
||||||
VkExtent2D renderExtent,
|
VkExtent2D renderExtent,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const Camera& camera,
|
|
||||||
const GPUBuffer& sceneDataBuffer,
|
const GPUBuffer& sceneDataBuffer,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands);
|
const IndirectDrawList::View& view);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
VkPipelineLayout m_pipelineLayout{VK_NULL_HANDLE};
|
VkPipelineLayout m_pipelineLayout{VK_NULL_HANDLE};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
#include <destrum/Graphics/Light.h>
|
#include <destrum/Graphics/Light.h>
|
||||||
#include <destrum/Graphics/Pipeline.h>
|
#include <destrum/Graphics/Pipeline.h>
|
||||||
#include <destrum/Graphics/ids.h>
|
#include <destrum/Graphics/ids.h>
|
||||||
#include <destrum/Graphics/MeshDrawCommand.h>
|
#include <destrum/Graphics/IndirectDrawList.h>
|
||||||
|
|
||||||
class GfxDevice;
|
class GfxDevice;
|
||||||
struct GPUImage;
|
struct GPUImage;
|
||||||
@@ -32,15 +32,15 @@ public:
|
|||||||
void drawDirectionalShadow(
|
void drawDirectionalShadow(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands,
|
const IndirectDrawList::View& view,
|
||||||
const glm::mat4& lightViewProj);
|
const glm::mat4& lightViewProj);
|
||||||
|
|
||||||
void drawSpotShadows(
|
void drawSpotShadows(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands,
|
const std::vector<IndirectDrawList::View>& views,
|
||||||
const std::vector<glm::mat4>& lightViewProjs);
|
const std::vector<glm::mat4>& lightViewProjs);
|
||||||
|
|
||||||
[[nodiscard]] glm::mat4 calculateDirectionalViewProj(const glm::vec3& lightDirection, const glm::vec3& center) const;
|
[[nodiscard]] glm::mat4 calculateDirectionalViewProj(const glm::vec3& lightDirection, const glm::vec3& center) const;
|
||||||
@@ -57,8 +57,8 @@ public:
|
|||||||
private:
|
private:
|
||||||
struct PushConstants
|
struct PushConstants
|
||||||
{
|
{
|
||||||
glm::mat4 modelToLightClip;
|
glm::mat4 lightViewProj;
|
||||||
VkDeviceAddress vertexBuffer;
|
VkDeviceAddress instanceBuffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
void beginDepthRendering(VkCommandBuffer cmd, const RenderResources& resources, const GPUImage& depthImage);
|
void beginDepthRendering(VkCommandBuffer cmd, const RenderResources& resources, const GPUImage& depthImage);
|
||||||
@@ -67,8 +67,8 @@ private:
|
|||||||
void drawShadowCasters(
|
void drawShadowCasters(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands,
|
const IndirectDrawList::View& view,
|
||||||
const glm::mat4& lightViewProj);
|
const glm::mat4& lightViewProj);
|
||||||
|
|
||||||
VkPipelineLayout m_pipelineLayout{VK_NULL_HANDLE};
|
VkPipelineLayout m_pipelineLayout{VK_NULL_HANDLE};
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public:
|
|||||||
TextureIntent intent = TextureIntent::ColorSrgb);
|
TextureIntent intent = TextureIntent::ColorSrgb);
|
||||||
|
|
||||||
ImageID addImageToCache(GPUImage image);
|
ImageID addImageToCache(GPUImage image);
|
||||||
|
void destroyImage(ImageID id);
|
||||||
|
|
||||||
// Imports a file-backed model and uploads its mesh/material resources.
|
// Imports a file-backed model and uploads its mesh/material resources.
|
||||||
// References are keyed as "source#subresource" in the resource caches.
|
// References are keyed as "source#subresource" in the resource caches.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <glm/vec3.hpp>
|
#include <glm/vec3.hpp>
|
||||||
|
|
||||||
#include <destrum/Graphics/Camera.h>
|
#include <destrum/Graphics/Camera.h>
|
||||||
|
#include <destrum/Graphics/IndirectDrawList.h>
|
||||||
#include <destrum/Graphics/Light.h>
|
#include <destrum/Graphics/Light.h>
|
||||||
#include <destrum/Graphics/Pipelines/LightCullingPipeline.h>
|
#include <destrum/Graphics/Pipelines/LightCullingPipeline.h>
|
||||||
#include <destrum/Graphics/Pipelines/LineRenderingPass.h>
|
#include <destrum/Graphics/Pipelines/LineRenderingPass.h>
|
||||||
@@ -29,6 +30,15 @@
|
|||||||
class GameRenderer
|
class GameRenderer
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
struct DrawStats
|
||||||
|
{
|
||||||
|
std::uint32_t meshDraws{0};
|
||||||
|
std::uint32_t indirectCommands{0};
|
||||||
|
std::uint32_t instances{0};
|
||||||
|
std::uint32_t mainViewCommands{0};
|
||||||
|
std::uint32_t mainViewInstances{0};
|
||||||
|
};
|
||||||
|
|
||||||
struct SceneData
|
struct SceneData
|
||||||
{
|
{
|
||||||
const Camera& camera;
|
const Camera& camera;
|
||||||
@@ -65,7 +75,6 @@ public:
|
|||||||
|
|
||||||
void setSkyboxTexture(ImageID skyboxImageId);
|
void setSkyboxTexture(ImageID skyboxImageId);
|
||||||
|
|
||||||
void flushMaterialUpdates(GfxDevice& gfxDevice);
|
|
||||||
|
|
||||||
[[nodiscard]] SkinningPipeline& getSkinningPipeline() const
|
[[nodiscard]] SkinningPipeline& getSkinningPipeline() const
|
||||||
{
|
{
|
||||||
@@ -107,6 +116,8 @@ public:
|
|||||||
return resources;
|
return resources;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] const DrawStats& getDrawStats() const { return lastDrawStats; }
|
||||||
|
|
||||||
void draw2D(
|
void draw2D(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
GfxDevice& gfxDevice,
|
GfxDevice& gfxDevice,
|
||||||
@@ -119,11 +130,15 @@ private:
|
|||||||
|
|
||||||
RenderResources* resources = nullptr;
|
RenderResources* resources = nullptr;
|
||||||
|
|
||||||
|
void recordMaterialUpdates(VkCommandBuffer cmd);
|
||||||
|
|
||||||
std::vector<MaterialID> pendingMaterialUploads;
|
std::vector<MaterialID> pendingMaterialUploads;
|
||||||
|
|
||||||
|
|
||||||
std::vector<MeshDrawCommand> meshDrawCommands;
|
std::vector<MeshDrawCommand> meshDrawCommands;
|
||||||
std::vector<std::size_t> sortedMeshDrawCommands;
|
std::vector<std::size_t> sortedMeshDrawCommands;
|
||||||
|
IndirectDrawList drawList;
|
||||||
|
DrawStats lastDrawStats;
|
||||||
|
|
||||||
VkFormat drawImageFormat{VK_FORMAT_R16G16B16A16_SFLOAT};
|
VkFormat drawImageFormat{VK_FORMAT_R16G16B16A16_SFLOAT};
|
||||||
VkFormat depthImageFormat{VK_FORMAT_D32_SFLOAT};
|
VkFormat depthImageFormat{VK_FORMAT_D32_SFLOAT};
|
||||||
|
|||||||
@@ -20,10 +20,11 @@
|
|||||||
|
|
||||||
struct GPUMesh {
|
struct GPUMesh {
|
||||||
GPUBuffer vertexBuffer;
|
GPUBuffer vertexBuffer;
|
||||||
GPUBuffer indexBuffer;
|
|
||||||
|
|
||||||
std::uint32_t numVertices{0};
|
std::uint32_t numVertices{0};
|
||||||
std::uint32_t numIndices{0};
|
std::uint32_t numIndices{0};
|
||||||
|
// offset into MeshCache::getIndexBuffer(), every mesh shares that one index buffer
|
||||||
|
std::uint32_t firstIndex{0};
|
||||||
|
|
||||||
// AABB
|
// AABB
|
||||||
glm::vec3 minPos;
|
glm::vec3 minPos;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
#include <VkBootstrap.h>
|
#include <VkBootstrap.h>
|
||||||
|
|
||||||
static constexpr int FRAMES_IN_FLIGHT = 2;
|
#include <destrum/Graphics/FrameConfig.h>
|
||||||
|
|
||||||
class Swapchain {
|
class Swapchain {
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ namespace vkutil {
|
|||||||
VkDeviceSize offset = 0,
|
VkDeviceSize offset = 0,
|
||||||
VkDeviceSize size = VK_WHOLE_SIZE);
|
VkDeviceSize size = VK_WHOLE_SIZE);
|
||||||
|
|
||||||
|
void bufferShaderReadToTransferWriteBarrier(
|
||||||
|
VkCommandBuffer cmd,
|
||||||
|
VkBuffer buffer,
|
||||||
|
VkDeviceSize offset = 0,
|
||||||
|
VkDeviceSize size = VK_WHOLE_SIZE);
|
||||||
|
|
||||||
|
void bufferHostAndTransferWriteToShaderReadBarrier(
|
||||||
|
VkCommandBuffer cmd,
|
||||||
|
VkBuffer buffer,
|
||||||
|
VkDeviceSize offset = 0,
|
||||||
|
VkDeviceSize size = VK_WHOLE_SIZE);
|
||||||
|
|
||||||
void bufferHostWriteToTransferReadBarrier(
|
void bufferHostWriteToTransferReadBarrier(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
VkBuffer buffer,
|
VkBuffer buffer,
|
||||||
|
|||||||
@@ -4,19 +4,9 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <destrum/Physics/PhysicsWorld.h>
|
#include <destrum/Physics/PhysicsWorld.h>
|
||||||
#include <destrum/Physics/SimplePhysicsWorld.h>
|
|
||||||
|
|
||||||
class GameObject;
|
class GameObject;
|
||||||
|
|
||||||
// Small helper you can store inside Scene.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
// class Scene {
|
|
||||||
// public:
|
|
||||||
// PhysicsSceneBridge& GetPhysics() { return m_Physics; }
|
|
||||||
// private:
|
|
||||||
// PhysicsSceneBridge m_Physics{std::make_unique<SimplePhysicsWorld>()};
|
|
||||||
// };
|
|
||||||
class PhysicsSceneBridge final {
|
class PhysicsSceneBridge final {
|
||||||
public:
|
public:
|
||||||
explicit PhysicsSceneBridge(std::unique_ptr<PhysicsWorld> world);
|
explicit PhysicsSceneBridge(std::unique_ptr<PhysicsWorld> world);
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
#include <destrum/Physics/PhysicsWorld.h>
|
|
||||||
|
|
||||||
class Rigidbody;
|
|
||||||
|
|
||||||
// Simple CPU placeholder backend.
|
|
||||||
//
|
|
||||||
// This is NOT a replacement for Jolt/PhysX/Bullet.
|
|
||||||
// It exists so your component/scene/render sync can be implemented and tested
|
|
||||||
// before the real backend is added.
|
|
||||||
//
|
|
||||||
// It supports:
|
|
||||||
// - static, dynamic, kinematic bodies
|
|
||||||
// - gravity
|
|
||||||
// - force/impulse integration
|
|
||||||
// - very simple sphere/box/capsule raycasts using bounding spheres
|
|
||||||
//
|
|
||||||
// It does NOT support:
|
|
||||||
// - collision response
|
|
||||||
// - contacts
|
|
||||||
// - friction
|
|
||||||
// - constraints
|
|
||||||
// - real character controllers
|
|
||||||
class SimplePhysicsWorld final : public PhysicsWorld {
|
|
||||||
public:
|
|
||||||
explicit SimplePhysicsWorld(const glm::vec3& gravity = glm::vec3{0.0f, -9.81f, 0.0f});
|
|
||||||
|
|
||||||
void Step(float fixedDt) override;
|
|
||||||
|
|
||||||
PhysicsBodyHandle CreateBody(const PhysicsBodyDesc& desc) override;
|
|
||||||
void DestroyBody(PhysicsBodyHandle body) override;
|
|
||||||
|
|
||||||
void SetBodyTransform(PhysicsBodyHandle body, const PhysicsTransform& transform) override;
|
|
||||||
PhysicsTransform GetBodyTransform(PhysicsBodyHandle body) const override;
|
|
||||||
|
|
||||||
void SetLinearVelocity(PhysicsBodyHandle body, const glm::vec3& velocity) override;
|
|
||||||
glm::vec3 GetLinearVelocity(PhysicsBodyHandle body) const override;
|
|
||||||
|
|
||||||
void AddForce(PhysicsBodyHandle body, const glm::vec3& force) override;
|
|
||||||
void AddImpulse(PhysicsBodyHandle body, const glm::vec3& impulse) override;
|
|
||||||
|
|
||||||
bool Raycast(const glm::vec3& origin,
|
|
||||||
const glm::vec3& direction,
|
|
||||||
float maxDistance,
|
|
||||||
PhysicsRaycastHit& hit,
|
|
||||||
PhysicsBodyHandle ignoreBody = {}) const override;
|
|
||||||
|
|
||||||
void SyncKinematicBodiesToPhysics(float fixedDt) override;
|
|
||||||
void SyncDynamicBodiesToTransforms();
|
|
||||||
|
|
||||||
[[nodiscard]] const glm::vec3& GetGravity() const { return m_Gravity; }
|
|
||||||
void SetGravity(const glm::vec3& gravity) { m_Gravity = gravity; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct BodyRecord {
|
|
||||||
bool alive{false};
|
|
||||||
|
|
||||||
PhysicsBodyHandle handle{};
|
|
||||||
PhysicsBodyDesc desc{};
|
|
||||||
|
|
||||||
PhysicsTransform previousTransform{};
|
|
||||||
PhysicsTransform currentTransform{};
|
|
||||||
|
|
||||||
glm::vec3 linearVelocity{0.0f};
|
|
||||||
glm::vec3 accumulatedForce{0.0f};
|
|
||||||
|
|
||||||
Rigidbody* rigidbody{nullptr};
|
|
||||||
};
|
|
||||||
|
|
||||||
[[nodiscard]] BodyRecord* FindBody(PhysicsBodyHandle body);
|
|
||||||
[[nodiscard]] const BodyRecord* FindBody(PhysicsBodyHandle body) const;
|
|
||||||
|
|
||||||
[[nodiscard]] float GetApproxBoundingRadius(const BodyRecord& body) const;
|
|
||||||
[[nodiscard]] static bool RaySphere(const glm::vec3& origin,
|
|
||||||
const glm::vec3& dirNormalized,
|
|
||||||
const glm::vec3& center,
|
|
||||||
float radius,
|
|
||||||
float maxDistance,
|
|
||||||
float& outDistance);
|
|
||||||
|
|
||||||
glm::vec3 m_Gravity{0.0f, -9.81f, 0.0f};
|
|
||||||
|
|
||||||
std::vector<BodyRecord> m_Bodies;
|
|
||||||
std::uint32_t m_NextId{0};
|
|
||||||
};
|
|
||||||
+11
-2
@@ -208,6 +208,13 @@ if (!isRunning) break;
|
|||||||
if (ImGui::Begin("Debug", &m_DebugWindows.stats))
|
if (ImGui::Begin("Debug", &m_DebugWindows.stats))
|
||||||
{
|
{
|
||||||
ImGui::Text("FPS: %.2f", avgFPS);
|
ImGui::Text("FPS: %.2f", avgFPS);
|
||||||
|
|
||||||
|
const auto& stats = renderer.getDrawStats();
|
||||||
|
ImGui::SeparatorText("Meshes");
|
||||||
|
ImGui::Text("Submitted meshes: %u", stats.meshDraws);
|
||||||
|
ImGui::Text("Camera: %u indirect draws, %u instances", stats.mainViewCommands, stats.mainViewInstances);
|
||||||
|
ImGui::Text("All views: %u indirect draws, %u instances", stats.indirectCommands, stats.instances);
|
||||||
|
ImGui::Text("Pending GPU deletions: %zu", gfxDevice.getPendingDeletionCount());
|
||||||
}
|
}
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
@@ -331,8 +338,6 @@ if (!isRunning) break;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gfxDevice.waitIdle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::cleanup()
|
void App::cleanup()
|
||||||
@@ -355,6 +360,10 @@ void App::cleanup()
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The one place we wait for the GPU on shutdown: the cleanup calls below destroy
|
||||||
|
// pipelines and other objects directly, which is only valid once nothing is in flight.
|
||||||
|
attempt([this] { gfxDevice.waitIdle(); });
|
||||||
|
|
||||||
if (customInitStarted) {
|
if (customInitStarted) {
|
||||||
attempt([this] { customCleanup(); });
|
attempt([this] { customCleanup(); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ void MeshRendererComponent::Destroy()
|
|||||||
{
|
{
|
||||||
if (m_skinnedMesh && GameState::GetInstance().HasGfxDevice()) {
|
if (m_skinnedMesh && GameState::GetInstance().HasGfxDevice()) {
|
||||||
auto& gfxDevice = GameState::GetInstance().Gfx();
|
auto& gfxDevice = GameState::GetInstance().Gfx();
|
||||||
gfxDevice.waitIdle();
|
|
||||||
gfxDevice.destroyBuffer(m_skinnedMesh->skinnedVertexBuffer);
|
gfxDevice.destroyBuffer(m_skinnedMesh->skinnedVertexBuffer);
|
||||||
}
|
}
|
||||||
m_skinnedMesh.reset();
|
m_skinnedMesh.reset();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#include <../../include/destrum/Graphics/Caches/ImageCache.h>
|
#include <destrum/Graphics/Caches/ImageCache.h>
|
||||||
|
|
||||||
#include <destrum/Graphics/GfxDevice.h>
|
#include <destrum/Graphics/GfxDevice.h>
|
||||||
|
|
||||||
#include "spdlog/spdlog.h"
|
#include "spdlog/spdlog.h"
|
||||||
|
#include <algorithm>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ ImageID ImageCache::loadImageFromFile(
|
|||||||
if (infoInserted) {
|
if (infoInserted) {
|
||||||
loadedImagesInfo.erase(id);
|
loadedImagesInfo.erase(id);
|
||||||
}
|
}
|
||||||
gfxDevice.destroyImage(image);
|
gfxDevice.destroyImageNow(image);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,7 @@ ImageID ImageCache::addImage(GPUImage image) {
|
|||||||
try {
|
try {
|
||||||
return addImage(getFreeImageId(), std::move(image));
|
return addImage(getFreeImageId(), std::move(image));
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
gfxDevice.destroyImage(image);
|
gfxDevice.destroyImageNow(image);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,10 @@ ImageID ImageCache::addImage(ImageID id, GPUImage image) {
|
|||||||
try {
|
try {
|
||||||
image.setBindlessId(static_cast<std::uint32_t>(id));
|
image.setBindlessId(static_cast<std::uint32_t>(id));
|
||||||
|
|
||||||
|
if (const auto it = std::find(freeIds->begin(), freeIds->end(), id); it != freeIds->end()) {
|
||||||
|
freeIds->erase(it);
|
||||||
|
}
|
||||||
|
|
||||||
if (id < images.size()) {
|
if (id < images.size()) {
|
||||||
gfxDevice.destroyImage(images[id]);
|
gfxDevice.destroyImage(images[id]);
|
||||||
images[id] = std::move(image);
|
images[id] = std::move(image);
|
||||||
@@ -97,17 +102,35 @@ ImageID ImageCache::addImage(ImageID id, GPUImage image) {
|
|||||||
|
|
||||||
return id;
|
return id;
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
gfxDevice.destroyImage(image);
|
gfxDevice.destroyImageNow(image);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ImageCache::removeImage(ImageID id) {
|
||||||
|
if (id >= images.size() || images[id].image == VK_NULL_HANDLE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedImagesInfo.erase(id);
|
||||||
|
gfxDevice.destroyImage(images[id]);
|
||||||
|
|
||||||
|
// In-flight frames may still sample this bindless slot, so it only becomes
|
||||||
|
// reusable once they are done. freeIds is shared so the deleter can outlive the cache.
|
||||||
|
gfxDevice.deferDestroy([freeIds = freeIds, id] {
|
||||||
|
freeIds->push_back(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const GPUImage& ImageCache::getImage(ImageID id) const {
|
const GPUImage& ImageCache::getImage(ImageID id) const {
|
||||||
assert(id != NULL_IMAGE_ID && id < images.size());
|
assert(id != NULL_IMAGE_ID && id < images.size());
|
||||||
return images.at(id);
|
return images.at(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageID ImageCache::getFreeImageId() const {
|
ImageID ImageCache::getFreeImageId() const {
|
||||||
|
if (!freeIds->empty()) {
|
||||||
|
return freeIds->back();
|
||||||
|
}
|
||||||
if (images.size() >= getMaxImageCount()) {
|
if (images.size() >= getMaxImageCount()) {
|
||||||
throw std::out_of_range("ImageCache bindless capacity exhausted");
|
throw std::out_of_range("ImageCache bindless capacity exhausted");
|
||||||
}
|
}
|
||||||
@@ -122,5 +145,6 @@ void ImageCache::destroyImages() {
|
|||||||
gfxDevice.destroyImage(image);
|
gfxDevice.destroyImage(image);
|
||||||
}
|
}
|
||||||
images.clear();
|
images.clear();
|
||||||
|
freeIds->clear();
|
||||||
loadedImagesInfo.clear();
|
loadedImagesInfo.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#include <../../include/destrum/Graphics/Caches/MaterialCache.h>
|
#include <destrum/Graphics/Caches/MaterialCache.h>
|
||||||
|
|
||||||
#include <destrum/Graphics/GfxDevice.h>
|
#include <destrum/Graphics/GfxDevice.h>
|
||||||
#include <destrum/Graphics/Util.h>
|
#include <destrum/Graphics/Util.h>
|
||||||
@@ -19,7 +19,8 @@ void MaterialCache::init(
|
|||||||
|
|
||||||
materialDataBuffer = gfxDevice.createBuffer(
|
materialDataBuffer = gfxDevice.createBuffer(
|
||||||
MAX_MATERIALS * sizeof(MaterialData),
|
MAX_MATERIALS * sizeof(MaterialData),
|
||||||
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
|
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT |
|
||||||
|
VK_BUFFER_USAGE_TRANSFER_DST_BIT,
|
||||||
VMA_MEMORY_USAGE_CPU_TO_GPU);
|
VMA_MEMORY_USAGE_CPU_TO_GPU);
|
||||||
|
|
||||||
vkutil::addDebugLabel(
|
vkutil::addDebugLabel(
|
||||||
@@ -51,12 +52,6 @@ void MaterialCache::cleanup(GfxDevice& gfxDevice)
|
|||||||
|
|
||||||
MaterialID MaterialCache::addMaterial(Material material)
|
MaterialID MaterialCache::addMaterial(Material material)
|
||||||
{
|
{
|
||||||
if (!materials.empty() && device != nullptr) {
|
|
||||||
// The material buffer is shared by all in-flight frames. A newly
|
|
||||||
// appended entry may be written while an older frame is reading it.
|
|
||||||
device->waitIdle();
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialData* data = static_cast<MaterialData*>(materialDataBuffer.info.pMappedData);
|
MaterialData* data = static_cast<MaterialData*>(materialDataBuffer.info.pMappedData);
|
||||||
|
|
||||||
const auto id = getFreeMaterialId();
|
const auto id = getFreeMaterialId();
|
||||||
@@ -67,17 +62,14 @@ MaterialID MaterialCache::addMaterial(Material material)
|
|||||||
throw std::runtime_error("MaterialCache material buffer is not mapped");
|
throw std::runtime_error("MaterialCache material buffer is not mapped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safe without waiting: no frame in flight can reference an id that didn't exist yet.
|
||||||
|
// Changing an existing material goes through GameRenderer::updateMaterialGPU.
|
||||||
data[id] = toMaterialData(material);
|
data[id] = toMaterialData(material);
|
||||||
device->getMemoryManager().flushAllocation(
|
device->getMemoryManager().flushAllocation(
|
||||||
materialDataBuffer,
|
materialDataBuffer,
|
||||||
static_cast<VkDeviceSize>(id) * sizeof(MaterialData),
|
static_cast<VkDeviceSize>(id) * sizeof(MaterialData),
|
||||||
sizeof(MaterialData));
|
sizeof(MaterialData));
|
||||||
|
|
||||||
// The caller may update multiple materials in one frame; the range flush is
|
|
||||||
// cheap for coherent allocations and required for non-coherent memory.
|
|
||||||
// The cache does not own a device reference, so the renderer flushes the
|
|
||||||
// exact range after batching updates.
|
|
||||||
|
|
||||||
const auto materialId = static_cast<MaterialID>(materials.size());
|
const auto materialId = static_cast<MaterialID>(materials.size());
|
||||||
std::string key;
|
std::string key;
|
||||||
if (!material.assetReference.empty()) {
|
if (!material.assetReference.empty()) {
|
||||||
@@ -210,17 +202,8 @@ Material& MaterialCache::getMaterialMutable(MaterialID id)
|
|||||||
return materials.at(id);
|
return materials.at(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MaterialCache::updateMaterialGPU(MaterialID id)
|
MaterialData MaterialCache::getMaterialData(MaterialID id) const
|
||||||
{
|
{
|
||||||
assert(id < materials.size());
|
assert(id < materials.size());
|
||||||
assert(materialDataBuffer.info.pMappedData && "materialDataBuffer must be mapped");
|
return toMaterialData(materials[id]);
|
||||||
|
|
||||||
MaterialData* data =
|
|
||||||
reinterpret_cast<MaterialData*>(materialDataBuffer.info.pMappedData);
|
|
||||||
|
|
||||||
data[id] = toMaterialData(materials[id]);
|
|
||||||
device->getMemoryManager().flushAllocation(
|
|
||||||
materialDataBuffer,
|
|
||||||
static_cast<VkDeviceSize>(id) * sizeof(MaterialData),
|
|
||||||
sizeof(MaterialData));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#include <destrum/Graphics/DeletionQueue.h>
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <exception>
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
void DeletionQueue::push(std::uint64_t retiredInFrame, std::function<void()>&& deleter)
|
||||||
|
{
|
||||||
|
// entries stay sorted by frame, flush only has to look at the front
|
||||||
|
assert(entries.empty() || entries.back().frame <= retiredInFrame);
|
||||||
|
entries.push_back(Entry{retiredInFrame, std::move(deleter)});
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeletionQueue::flush(std::uint64_t lastCompletedFrame)
|
||||||
|
{
|
||||||
|
while (!entries.empty() && entries.front().frame <= lastCompletedFrame) {
|
||||||
|
auto deleter = std::move(entries.front().deleter);
|
||||||
|
entries.pop_front();
|
||||||
|
try {
|
||||||
|
deleter();
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
spdlog::error("DeletionQueue: deleter threw: {}", e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeletionQueue::flushAll()
|
||||||
|
{
|
||||||
|
while (!entries.empty()) {
|
||||||
|
auto deleter = std::move(entries.front().deleter);
|
||||||
|
entries.pop_front();
|
||||||
|
try {
|
||||||
|
deleter();
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
spdlog::error("DeletionQueue: deleter threw: {}", e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include <SDL2/SDL.h>
|
#include <SDL2/SDL.h>
|
||||||
#include <SDL2/SDL_vulkan.h>
|
#include <SDL2/SDL_vulkan.h>
|
||||||
|
#include <memory>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
#include <destrum/Graphics/Init.h>
|
#include <destrum/Graphics/Init.h>
|
||||||
@@ -83,6 +84,7 @@ void GfxDevice::cleanup() {
|
|||||||
if (instanceManager.getDevice() != VK_NULL_HANDLE) {
|
if (instanceManager.getDevice() != VK_NULL_HANDLE) {
|
||||||
vkDeviceWaitIdle(instanceManager.getDevice());
|
vkDeviceWaitIdle(instanceManager.getDevice());
|
||||||
}
|
}
|
||||||
|
deletionQueue.flushAll();
|
||||||
|
|
||||||
swapchain.cleanup(instanceManager.getDevice());
|
swapchain.cleanup(instanceManager.getDevice());
|
||||||
frameManager.cleanup(instanceManager.getDevice());
|
frameManager.cleanup(instanceManager.getDevice());
|
||||||
@@ -103,7 +105,6 @@ bool GfxDevice::recreateSwapchain(int width, int height) {
|
|||||||
if (width <= 0 || height <= 0) {
|
if (width <= 0 || height <= 0) {
|
||||||
throw std::invalid_argument("GfxDevice::recreateSwapchain requires a non-zero extent");
|
throw std::invalid_argument("GfxDevice::recreateSwapchain requires a non-zero extent");
|
||||||
}
|
}
|
||||||
waitIdle();
|
|
||||||
|
|
||||||
const bool recreated = swapchain.recreateSwapchain(
|
const bool recreated = swapchain.recreateSwapchain(
|
||||||
instanceManager.getDevice(),
|
instanceManager.getDevice(),
|
||||||
@@ -129,6 +130,16 @@ VkCommandBuffer GfxDevice::beginFrame()
|
|||||||
swapchain.beginFrame(instanceManager.getDevice(), frameIndex);
|
swapchain.beginFrame(instanceManager.getDevice(), frameIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
ZoneScopedN("Flush Deletion Queue");
|
||||||
|
// The fence we just waited on belongs to frame N - FRAMES_IN_FLIGHT, and the
|
||||||
|
// queue executes submits in order, so everything retired up to that frame is free.
|
||||||
|
const auto frameNumber = frameManager.getFrameNumber();
|
||||||
|
if (frameNumber >= FRAMES_IN_FLIGHT) {
|
||||||
|
deletionQueue.flush(frameNumber - FRAMES_IN_FLIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const auto acquire = swapchain.acquireNextImage(instanceManager.getDevice(), frameIndex);
|
const auto acquire = swapchain.acquireNextImage(instanceManager.getDevice(), frameIndex);
|
||||||
if (!acquire.hasImage()) {
|
if (!acquire.hasImage()) {
|
||||||
return VK_NULL_HANDLE;
|
return VK_NULL_HANDLE;
|
||||||
@@ -240,8 +251,39 @@ void GfxDevice::endFrame(VkCommandBuffer cmd, const GPUImage& drawImage, const E
|
|||||||
FrameMark;
|
FrameMark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GfxDevice::deferDestroy(std::function<void()>&& deleter) {
|
||||||
|
if (!initialized) {
|
||||||
|
deleter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tagged with the frame being recorded (or the next one, between frames); any
|
||||||
|
// frame up to and including it may still reference the resource.
|
||||||
|
deletionQueue.push(frameManager.getFrameNumber(), std::move(deleter));
|
||||||
|
}
|
||||||
|
|
||||||
|
void GfxDevice::destroyBuffer(GPUBuffer& buffer) {
|
||||||
|
if (buffer.buffer == VK_NULL_HANDLE) {
|
||||||
|
buffer = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deferDestroy([this, retired = buffer]() mutable {
|
||||||
|
memoryManager.destroyBuffer(retired);
|
||||||
|
});
|
||||||
|
buffer = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void GfxDevice::destroyImage(GPUImage& image) {
|
||||||
|
if (image.image == VK_NULL_HANDLE && image.imageView == VK_NULL_HANDLE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto retired = std::make_shared<GPUImage>(std::move(image));
|
||||||
|
deferDestroy([this, retired] {
|
||||||
|
imageManager.destroyImage(*retired);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void GfxDevice::waitIdle() {
|
void GfxDevice::waitIdle() {
|
||||||
frameManager.waitIdle();
|
instanceManager.waitIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxDevice::immediateSubmit(ImmediateExecuteFunction&& f) const {
|
void GfxDevice::immediateSubmit(ImmediateExecuteFunction&& f) const {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
#include <destrum/Graphics/IndirectDrawList.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cassert>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include <volk.h>
|
||||||
|
|
||||||
|
#include <tracy/Tracy.hpp>
|
||||||
|
|
||||||
|
#include <destrum/Graphics/GfxDevice.h>
|
||||||
|
#include <destrum/Graphics/Resources/Mesh.h>
|
||||||
|
#include <destrum/Graphics/Util.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::size_t grownCapacity(std::size_t current, std::size_t initial, std::size_t required)
|
||||||
|
{
|
||||||
|
std::size_t capacity = std::max(current, initial);
|
||||||
|
while (capacity < required) {
|
||||||
|
capacity *= 2;
|
||||||
|
}
|
||||||
|
return capacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void IndirectDrawList::cleanup(GfxDevice& gfxDevice)
|
||||||
|
{
|
||||||
|
for (auto& frame : frames) {
|
||||||
|
gfxDevice.destroyBuffer(frame.instances);
|
||||||
|
gfxDevice.destroyBuffer(frame.commands);
|
||||||
|
frame.instanceCapacity = 0;
|
||||||
|
frame.commandCapacity = 0;
|
||||||
|
}
|
||||||
|
instances.clear();
|
||||||
|
commands.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void IndirectDrawList::begin(std::uint32_t frameIndex)
|
||||||
|
{
|
||||||
|
assert(frameIndex < FRAMES_IN_FLIGHT);
|
||||||
|
currentFrame = frameIndex;
|
||||||
|
instances.clear();
|
||||||
|
commands.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
IndirectDrawList::View IndirectDrawList::addView(
|
||||||
|
const std::vector<GPUMesh>& meshes,
|
||||||
|
const std::vector<MeshDrawCommand>& drawCommands,
|
||||||
|
const std::vector<std::size_t>& sortedDrawCommands,
|
||||||
|
const Frustum& frustum,
|
||||||
|
Filter filter)
|
||||||
|
{
|
||||||
|
ZoneScopedN("IndirectDrawList::addView");
|
||||||
|
|
||||||
|
View view{
|
||||||
|
.firstCommand = static_cast<std::uint32_t>(commands.size()),
|
||||||
|
};
|
||||||
|
MeshID batchMesh = NULL_MESH_ID;
|
||||||
|
|
||||||
|
const auto addDraw = [&](const MeshDrawCommand& dc)
|
||||||
|
{
|
||||||
|
if (filter == Filter::ShadowCasters && !dc.castShadow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!edge::isInFrustum(frustum, dc.worldBoundingSphere)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(dc.meshId < meshes.size());
|
||||||
|
assert(dc.materialId != NULL_MATERIAL_ID);
|
||||||
|
const GPUMesh& mesh = meshes[dc.meshId];
|
||||||
|
|
||||||
|
// Draws arrive sorted by mesh, so every run of the same mesh becomes one
|
||||||
|
// instanced command. Each instance carries its own transform, material and
|
||||||
|
// vertex buffer (skinned meshes point at their own skinned copy).
|
||||||
|
if (dc.meshId != batchMesh) {
|
||||||
|
batchMesh = dc.meshId;
|
||||||
|
commands.push_back(VkDrawIndexedIndirectCommand{
|
||||||
|
.indexCount = mesh.numIndices,
|
||||||
|
.instanceCount = 0,
|
||||||
|
.firstIndex = mesh.firstIndex,
|
||||||
|
.vertexOffset = 0,
|
||||||
|
.firstInstance = static_cast<std::uint32_t>(instances.size()),
|
||||||
|
});
|
||||||
|
++view.commandCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
instances.push_back(GPUInstanceData{
|
||||||
|
.transform = dc.transformMatrix,
|
||||||
|
.vertexBuffer = dc.skinnedMesh != nullptr
|
||||||
|
? dc.skinnedMesh->skinnedVertexBuffer.address
|
||||||
|
: mesh.vertexBuffer.address,
|
||||||
|
.materialId = dc.materialId,
|
||||||
|
.padding = 0,
|
||||||
|
});
|
||||||
|
++commands.back().instanceCount;
|
||||||
|
++view.instanceCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sortedDrawCommands.empty()) {
|
||||||
|
for (std::size_t drawIndex : sortedDrawCommands) {
|
||||||
|
assert(drawIndex < drawCommands.size());
|
||||||
|
addDraw(drawCommands[drawIndex]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const MeshDrawCommand& dc : drawCommands) {
|
||||||
|
addDraw(dc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
void IndirectDrawList::upload(GfxDevice& gfxDevice)
|
||||||
|
{
|
||||||
|
ZoneScopedN("IndirectDrawList::upload");
|
||||||
|
|
||||||
|
PerFrameBuffers& frame = frames[currentFrame];
|
||||||
|
|
||||||
|
// Growing is cheap thanks to the deletion queue: the old buffer may still be
|
||||||
|
// read by this slot's previous frame, so it is retired instead of destroyed.
|
||||||
|
if (instances.size() > frame.instanceCapacity || frame.instances.buffer == VK_NULL_HANDLE) {
|
||||||
|
gfxDevice.destroyBuffer(frame.instances);
|
||||||
|
frame.instanceCapacity = grownCapacity(frame.instanceCapacity, InitialInstanceCapacity, instances.size());
|
||||||
|
frame.instances = gfxDevice.createBuffer(
|
||||||
|
frame.instanceCapacity * sizeof(GPUInstanceData),
|
||||||
|
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
|
||||||
|
VMA_MEMORY_USAGE_CPU_TO_GPU);
|
||||||
|
vkutil::addDebugLabel(gfxDevice.getDevice(), frame.instances.buffer, "mesh instances");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commands.size() > frame.commandCapacity || frame.commands.buffer == VK_NULL_HANDLE) {
|
||||||
|
gfxDevice.destroyBuffer(frame.commands);
|
||||||
|
frame.commandCapacity = grownCapacity(frame.commandCapacity, InitialCommandCapacity, commands.size());
|
||||||
|
frame.commands = gfxDevice.createBuffer(
|
||||||
|
frame.commandCapacity * sizeof(VkDrawIndexedIndirectCommand),
|
||||||
|
VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT,
|
||||||
|
VMA_MEMORY_USAGE_CPU_TO_GPU);
|
||||||
|
vkutil::addDebugLabel(gfxDevice.getDevice(), frame.commands.buffer, "mesh indirect commands");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each frame in flight has its own buffers, so nothing on the GPU is reading
|
||||||
|
// these while we write. vkQueueSubmit makes the host writes visible to the GPU.
|
||||||
|
if (!instances.empty()) {
|
||||||
|
std::memcpy(frame.instances.info.pMappedData, instances.data(), instances.size() * sizeof(GPUInstanceData));
|
||||||
|
gfxDevice.getMemoryManager().flushAllocation(frame.instances, 0, instances.size() * sizeof(GPUInstanceData));
|
||||||
|
}
|
||||||
|
if (!commands.empty()) {
|
||||||
|
std::memcpy(frame.commands.info.pMappedData, commands.data(), commands.size() * sizeof(VkDrawIndexedIndirectCommand));
|
||||||
|
gfxDevice.getMemoryManager().flushAllocation(frame.commands, 0, commands.size() * sizeof(VkDrawIndexedIndirectCommand));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void IndirectDrawList::draw(VkCommandBuffer cmd, const GPUBuffer& indexBuffer, const View& view) const
|
||||||
|
{
|
||||||
|
if (view.commandCount == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PerFrameBuffers& frame = frames[currentFrame];
|
||||||
|
assert(frame.commands.buffer != VK_NULL_HANDLE && "IndirectDrawList::upload must run before draw");
|
||||||
|
|
||||||
|
vkCmdBindIndexBuffer(cmd, indexBuffer.buffer, 0, VK_INDEX_TYPE_UINT32);
|
||||||
|
vkCmdDrawIndexedIndirect(
|
||||||
|
cmd,
|
||||||
|
frame.commands.buffer,
|
||||||
|
static_cast<VkDeviceSize>(view.firstCommand) * sizeof(VkDrawIndexedIndirectCommand),
|
||||||
|
view.commandCount,
|
||||||
|
sizeof(VkDrawIndexedIndirectCommand));
|
||||||
|
}
|
||||||
|
|
||||||
|
VkDeviceAddress IndirectDrawList::getInstanceBufferAddress() const
|
||||||
|
{
|
||||||
|
return frames[currentFrame].instances.address;
|
||||||
|
}
|
||||||
@@ -83,9 +83,3 @@ void FrameManager::nextFrame() {
|
|||||||
frameNumber++;
|
frameNumber++;
|
||||||
FrameMark;
|
FrameMark;
|
||||||
}
|
}
|
||||||
|
|
||||||
void FrameManager::waitIdle() const {
|
|
||||||
if (device != VK_NULL_HANDLE) {
|
|
||||||
VK_CHECK(vkDeviceWaitIdle(device));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#include <../../include/destrum/Graphics/Caches/MeshCache.h>
|
#include <destrum/Graphics/Caches/MeshCache.h>
|
||||||
|
|
||||||
#include <destrum/Graphics/Resources/Mesh.h>
|
#include <destrum/Graphics/Resources/Mesh.h>
|
||||||
#include <destrum/Graphics/GfxDevice.h>
|
#include <destrum/Graphics/GfxDevice.h>
|
||||||
@@ -49,15 +49,73 @@ MeshID MeshCache::addMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh)
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MeshCache::uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh& gpuMesh) const
|
void MeshCache::ensureIndexCapacity(GfxDevice& gfxDevice, std::uint32_t requiredIndices)
|
||||||
|
{
|
||||||
|
if (requiredIndices <= indexCapacity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t newCapacity = std::max(indexCapacity, InitialIndexCapacity);
|
||||||
|
while (newCapacity < requiredIndices) {
|
||||||
|
newCapacity *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
GPUBuffer newBuffer = gfxDevice.createBuffer(
|
||||||
|
static_cast<std::size_t>(newCapacity) * sizeof(std::uint32_t),
|
||||||
|
VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
|
||||||
|
VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE);
|
||||||
|
vkutil::addDebugLabel(gfxDevice.getDevice(), newBuffer.buffer, "mesh indices");
|
||||||
|
|
||||||
|
if (indexCount > 0) {
|
||||||
|
try {
|
||||||
|
gfxDevice.immediateSubmit([&](VkCommandBuffer cmd) {
|
||||||
|
const VkBufferMemoryBarrier2 previousCopies{
|
||||||
|
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER_2,
|
||||||
|
.srcStageMask = VK_PIPELINE_STAGE_2_COPY_BIT,
|
||||||
|
.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT,
|
||||||
|
.dstStageMask = VK_PIPELINE_STAGE_2_COPY_BIT,
|
||||||
|
.dstAccessMask = VK_ACCESS_2_TRANSFER_READ_BIT,
|
||||||
|
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||||
|
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||||
|
.buffer = indexBuffer.buffer,
|
||||||
|
.offset = 0,
|
||||||
|
.size = VK_WHOLE_SIZE,
|
||||||
|
};
|
||||||
|
const VkDependencyInfo dependency{
|
||||||
|
.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
|
||||||
|
.bufferMemoryBarrierCount = 1,
|
||||||
|
.pBufferMemoryBarriers = &previousCopies,
|
||||||
|
};
|
||||||
|
vkCmdPipelineBarrier2(cmd, &dependency);
|
||||||
|
|
||||||
|
const VkBufferCopy copy{
|
||||||
|
.srcOffset = 0,
|
||||||
|
.dstOffset = 0,
|
||||||
|
.size = static_cast<VkDeviceSize>(indexCount) * sizeof(std::uint32_t),
|
||||||
|
};
|
||||||
|
vkCmdCopyBuffer(cmd, indexBuffer.buffer, newBuffer.buffer, 1, ©);
|
||||||
|
});
|
||||||
|
} catch (...) {
|
||||||
|
gfxDevice.destroyBufferNow(newBuffer);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frames in flight were recorded with the old buffer, the deletion queue keeps it alive for them
|
||||||
|
gfxDevice.destroyBuffer(indexBuffer);
|
||||||
|
indexBuffer = newBuffer;
|
||||||
|
indexCapacity = newCapacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MeshCache::uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh& gpuMesh)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// create index buffer
|
const auto numIndices = static_cast<std::uint32_t>(cpuMesh.indices.size());
|
||||||
|
ensureIndexCapacity(gfxDevice, indexCount + numIndices);
|
||||||
|
gpuMesh.firstIndex = indexCount;
|
||||||
|
|
||||||
const auto indexBufferSize = cpuMesh.indices.size() * sizeof(std::uint32_t);
|
const auto indexBufferSize = cpuMesh.indices.size() * sizeof(std::uint32_t);
|
||||||
gpuMesh.indexBuffer = gfxDevice.createBuffer(
|
const auto indexBufferOffset = static_cast<VkDeviceSize>(gpuMesh.firstIndex) * sizeof(std::uint32_t);
|
||||||
indexBufferSize,
|
|
||||||
VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT,
|
|
||||||
VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE);
|
|
||||||
|
|
||||||
// create vertex buffer
|
// create vertex buffer
|
||||||
const auto vertexBufferSize = cpuMesh.vertices.size() * sizeof(CPUMesh::Vertex);
|
const auto vertexBufferSize = cpuMesh.vertices.size() * sizeof(CPUMesh::Vertex);
|
||||||
@@ -81,6 +139,8 @@ void MeshCache::uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh
|
|||||||
memcpy(static_cast<char*>(vertexIndexData) + vertexBufferSize, cpuMesh.indices.data(), indexBufferSize);
|
memcpy(static_cast<char*>(vertexIndexData) + vertexBufferSize, cpuMesh.indices.data(), indexBufferSize);
|
||||||
gfxDevice.getMemoryManager().flushAllocation(vertexIndexStaging);
|
gfxDevice.getMemoryManager().flushAllocation(vertexIndexStaging);
|
||||||
|
|
||||||
|
// Writing into the shared index buffer is safe while frames are in flight:
|
||||||
|
// this range is new, they only read the ranges of meshes added before it.
|
||||||
gfxDevice.immediateSubmit([&](VkCommandBuffer cmd) {
|
gfxDevice.immediateSubmit([&](VkCommandBuffer cmd) {
|
||||||
vkutil::bufferHostWriteToTransferReadBarrier(
|
vkutil::bufferHostWriteToTransferReadBarrier(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -97,17 +157,17 @@ void MeshCache::uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh
|
|||||||
|
|
||||||
const auto indexCopy = VkBufferCopy{
|
const auto indexCopy = VkBufferCopy{
|
||||||
.srcOffset = vertexBufferSize,
|
.srcOffset = vertexBufferSize,
|
||||||
.dstOffset = 0,
|
.dstOffset = indexBufferOffset,
|
||||||
.size = indexBufferSize,
|
.size = indexBufferSize,
|
||||||
};
|
};
|
||||||
vkCmdCopyBuffer(cmd, vertexIndexStaging.buffer, gpuMesh.indexBuffer.buffer, 1, &indexCopy);
|
vkCmdCopyBuffer(cmd, vertexIndexStaging.buffer, indexBuffer.buffer, 1, &indexCopy);
|
||||||
|
|
||||||
const std::array<VkBufferMemoryBarrier2, 2> destinationBarriers{{
|
const std::array<VkBufferMemoryBarrier2, 2> destinationBarriers{{
|
||||||
{
|
{
|
||||||
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER_2,
|
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER_2,
|
||||||
.srcStageMask = VK_PIPELINE_STAGE_2_COPY_BIT,
|
.srcStageMask = VK_PIPELINE_STAGE_2_COPY_BIT,
|
||||||
.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT,
|
.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT,
|
||||||
.dstStageMask = VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT,
|
.dstStageMask = VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
|
||||||
.dstAccessMask = VK_ACCESS_2_SHADER_STORAGE_READ_BIT,
|
.dstAccessMask = VK_ACCESS_2_SHADER_STORAGE_READ_BIT,
|
||||||
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||||
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||||
@@ -119,13 +179,13 @@ void MeshCache::uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh
|
|||||||
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER_2,
|
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER_2,
|
||||||
.srcStageMask = VK_PIPELINE_STAGE_2_COPY_BIT,
|
.srcStageMask = VK_PIPELINE_STAGE_2_COPY_BIT,
|
||||||
.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT,
|
.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT,
|
||||||
.dstStageMask = VK_PIPELINE_STAGE_2_VERTEX_INPUT_BIT,
|
.dstStageMask = VK_PIPELINE_STAGE_2_INDEX_INPUT_BIT | VK_PIPELINE_STAGE_2_COPY_BIT,
|
||||||
.dstAccessMask = VK_ACCESS_2_INDEX_READ_BIT,
|
.dstAccessMask = VK_ACCESS_2_INDEX_READ_BIT | VK_ACCESS_2_TRANSFER_READ_BIT,
|
||||||
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||||
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||||
.buffer = gpuMesh.indexBuffer.buffer,
|
.buffer = indexBuffer.buffer,
|
||||||
.offset = 0,
|
.offset = indexBufferOffset,
|
||||||
.size = VK_WHOLE_SIZE,
|
.size = indexBufferSize,
|
||||||
}
|
}
|
||||||
}};
|
}};
|
||||||
const VkDependencyInfo destinationDependency{
|
const VkDependencyInfo destinationDependency{
|
||||||
@@ -136,16 +196,15 @@ void MeshCache::uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh
|
|||||||
vkCmdPipelineBarrier2(cmd, &destinationDependency);
|
vkCmdPipelineBarrier2(cmd, &destinationDependency);
|
||||||
});
|
});
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
gfxDevice.destroyBuffer(vertexIndexStaging);
|
gfxDevice.destroyBufferNow(vertexIndexStaging);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
gfxDevice.destroyBuffer(vertexIndexStaging);
|
gfxDevice.destroyBufferNow(vertexIndexStaging);
|
||||||
|
indexCount += numIndices;
|
||||||
|
|
||||||
const auto vtxBufferName = cpuMesh.name + " (vtx)";
|
const auto vtxBufferName = cpuMesh.name + " (vtx)";
|
||||||
const auto idxBufferName = cpuMesh.name + " (idx)";
|
|
||||||
vkutil::addDebugLabel(gfxDevice.getDevice(), gpuMesh.vertexBuffer.buffer, vtxBufferName.c_str());
|
vkutil::addDebugLabel(gfxDevice.getDevice(), gpuMesh.vertexBuffer.buffer, vtxBufferName.c_str());
|
||||||
vkutil::addDebugLabel(gfxDevice.getDevice(), gpuMesh.indexBuffer.buffer, idxBufferName.c_str());
|
|
||||||
|
|
||||||
if (gpuMesh.hasSkeleton) {
|
if (gpuMesh.hasSkeleton) {
|
||||||
// create skinning data buffer
|
// create skinning data buffer
|
||||||
@@ -205,16 +264,15 @@ void MeshCache::uploadMesh(GfxDevice& gfxDevice, const CPUMesh& cpuMesh, GPUMesh
|
|||||||
vkCmdPipelineBarrier2(cmd, &destinationDependency);
|
vkCmdPipelineBarrier2(cmd, &destinationDependency);
|
||||||
});
|
});
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
gfxDevice.destroyBuffer(skinningStaging);
|
gfxDevice.destroyBufferNow(skinningStaging);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
gfxDevice.destroyBuffer(skinningStaging);
|
gfxDevice.destroyBufferNow(skinningStaging);
|
||||||
}
|
}
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
gfxDevice.destroyBuffer(gpuMesh.skinningDataBuffer);
|
gfxDevice.destroyBufferNow(gpuMesh.skinningDataBuffer);
|
||||||
gfxDevice.destroyBuffer(gpuMesh.vertexBuffer);
|
gfxDevice.destroyBufferNow(gpuMesh.vertexBuffer);
|
||||||
gfxDevice.destroyBuffer(gpuMesh.indexBuffer);
|
|
||||||
gpuMesh = {};
|
gpuMesh = {};
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
@@ -246,8 +304,11 @@ std::optional<MeshID> MeshCache::findMeshByKey(std::string_view key) const
|
|||||||
|
|
||||||
void MeshCache::cleanup(GfxDevice& gfxDevice)
|
void MeshCache::cleanup(GfxDevice& gfxDevice)
|
||||||
{
|
{
|
||||||
|
gfxDevice.destroyBuffer(indexBuffer);
|
||||||
|
indexCount = 0;
|
||||||
|
indexCapacity = 0;
|
||||||
|
|
||||||
for (auto& mesh : meshes) {
|
for (auto& mesh : meshes) {
|
||||||
gfxDevice.destroyBuffer(mesh.indexBuffer);
|
|
||||||
gfxDevice.destroyBuffer(mesh.vertexBuffer);
|
gfxDevice.destroyBuffer(mesh.vertexBuffer);
|
||||||
gfxDevice.destroyBuffer(mesh.skinningDataBuffer);
|
gfxDevice.destroyBuffer(mesh.skinningDataBuffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,10 +242,6 @@ void ImguiPass::cleanup() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gfx != nullptr && gfx->getVkDevice() != VK_NULL_HANDLE) {
|
|
||||||
vkDeviceWaitIdle(gfx->getVkDevice());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vulkanInitialized) {
|
if (vulkanInitialized) {
|
||||||
releaseAllTextures();
|
releaseAllTextures();
|
||||||
ImGui_ImplVulkan_Shutdown();
|
ImGui_ImplVulkan_Shutdown();
|
||||||
|
|||||||
@@ -100,8 +100,6 @@ void LightCullingPipeline::cleanup(GfxDevice& gfxDevice)
|
|||||||
|
|
||||||
void LightCullingPipeline::resize(GfxDevice& gfxDevice, glm::ivec2 drawImageSize)
|
void LightCullingPipeline::resize(GfxDevice& gfxDevice, glm::ivec2 drawImageSize)
|
||||||
{
|
{
|
||||||
gfxDevice.waitIdle();
|
|
||||||
|
|
||||||
gfxDevice.destroyBuffer(tileLightIndicesBuffer);
|
gfxDevice.destroyBuffer(tileLightIndicesBuffer);
|
||||||
tileLightIndicesBuffer = {};
|
tileLightIndicesBuffer = {};
|
||||||
createTileBuffer(gfxDevice, drawImageSize);
|
createTileBuffer(gfxDevice, drawImageSize);
|
||||||
|
|||||||
@@ -221,7 +221,6 @@ void LineRenderingPass::ensureVertexCapacity(
|
|||||||
newCapacity *= 2;
|
newCapacity *= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
gfxDevice.waitIdle();
|
|
||||||
vertexBuffer.cleanup(gfxDevice);
|
vertexBuffer.cleanup(gfxDevice);
|
||||||
vertexBuffer.init(
|
vertexBuffer.init(
|
||||||
gfxDevice,
|
gfxDevice,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
#include <destrum/Graphics/GfxDevice.h>
|
#include <destrum/Graphics/GfxDevice.h>
|
||||||
#include <destrum/Graphics/RenderResources.h>
|
#include <destrum/Graphics/RenderResources.h>
|
||||||
#include <destrum/Graphics/Caches/MeshCache.h>
|
#include <destrum/Graphics/Caches/MeshCache.h>
|
||||||
#include <destrum/Graphics/Frustum.h>
|
|
||||||
#include <destrum/Graphics/Util.h>
|
#include <destrum/Graphics/Util.h>
|
||||||
|
|
||||||
#include "volk.h"
|
#include "volk.h"
|
||||||
@@ -119,10 +118,9 @@ void MeshPipeline::drawDepthPrepass(
|
|||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
VkExtent2D renderExtent,
|
VkExtent2D renderExtent,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const Camera& camera,
|
|
||||||
const GPUBuffer& sceneDataBuffer,
|
const GPUBuffer& sceneDataBuffer,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands)
|
const IndirectDrawList::View& view)
|
||||||
{
|
{
|
||||||
if (!m_depthPrepassPipeline)
|
if (!m_depthPrepassPipeline)
|
||||||
{
|
{
|
||||||
@@ -134,20 +132,18 @@ void MeshPipeline::drawDepthPrepass(
|
|||||||
*m_depthPrepassPipeline,
|
*m_depthPrepassPipeline,
|
||||||
renderExtent,
|
renderExtent,
|
||||||
resources,
|
resources,
|
||||||
camera,
|
|
||||||
sceneDataBuffer,
|
sceneDataBuffer,
|
||||||
drawCommands,
|
drawList,
|
||||||
sortedDrawCommands);
|
view);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MeshPipeline::draw(
|
void MeshPipeline::draw(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
VkExtent2D renderExtent,
|
VkExtent2D renderExtent,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const Camera& camera,
|
|
||||||
const GPUBuffer& sceneDataBuffer,
|
const GPUBuffer& sceneDataBuffer,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands)
|
const IndirectDrawList::View& view)
|
||||||
{
|
{
|
||||||
if (!m_pipeline)
|
if (!m_pipeline)
|
||||||
{
|
{
|
||||||
@@ -159,10 +155,9 @@ void MeshPipeline::draw(
|
|||||||
*m_pipeline,
|
*m_pipeline,
|
||||||
renderExtent,
|
renderExtent,
|
||||||
resources,
|
resources,
|
||||||
camera,
|
|
||||||
sceneDataBuffer,
|
sceneDataBuffer,
|
||||||
drawCommands,
|
drawList,
|
||||||
sortedDrawCommands);
|
view);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MeshPipeline::drawMeshes(
|
void MeshPipeline::drawMeshes(
|
||||||
@@ -170,13 +165,10 @@ void MeshPipeline::drawMeshes(
|
|||||||
const Pipeline& pipeline,
|
const Pipeline& pipeline,
|
||||||
VkExtent2D renderExtent,
|
VkExtent2D renderExtent,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const Camera& camera,
|
|
||||||
const GPUBuffer& sceneDataBuffer,
|
const GPUBuffer& sceneDataBuffer,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands)
|
const IndirectDrawList::View& view)
|
||||||
{
|
{
|
||||||
const MeshCache& meshCache = resources.meshes();
|
|
||||||
|
|
||||||
pipeline.bind(cmd);
|
pipeline.bind(cmd);
|
||||||
|
|
||||||
resources.bindBindlessDescSet(cmd, m_pipelineLayout);
|
resources.bindBindlessDescSet(cmd, m_pipelineLayout);
|
||||||
@@ -204,86 +196,32 @@ void MeshPipeline::drawMeshes(
|
|||||||
m_renderWireframe ? VK_POLYGON_MODE_LINE : VK_POLYGON_MODE_FILL
|
m_renderWireframe ? VK_POLYGON_MODE_LINE : VK_POLYGON_MODE_FILL
|
||||||
);
|
);
|
||||||
|
|
||||||
MeshID prevMeshId = NULL_MESH_ID;
|
// The same for every draw, per-draw data (transform, material, vertex buffer)
|
||||||
int actualDrawCalls = 0;
|
// lives in the instance buffer and is looked up with gl_InstanceIndex
|
||||||
|
const auto pushConstants = PushConstants{
|
||||||
const auto frustum = edge::createFrustumFromCamera(camera);
|
.sceneDataBuffer = sceneDataBuffer.address,
|
||||||
|
.instanceBuffer = drawList.getInstanceBufferAddress(),
|
||||||
const auto drawOne = [&](const MeshDrawCommand& dc)
|
|
||||||
{
|
|
||||||
if (!edge::isInFrustum(frustum, dc.worldBoundingSphere))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
++actualDrawCalls;
|
|
||||||
|
|
||||||
const auto& mesh = meshCache.getMesh(dc.meshId);
|
|
||||||
|
|
||||||
const std::string& meshKey = meshCache.getMeshKey(dc.meshId);
|
|
||||||
vkutil::cmdBeginLabelFormatted(
|
|
||||||
cmd,
|
|
||||||
vkutil::labelColors::draw,
|
|
||||||
"%s [mesh %zu, material %u%s]",
|
|
||||||
meshKey.empty() ? "Mesh" : meshKey.c_str(),
|
|
||||||
dc.meshId,
|
|
||||||
dc.materialId,
|
|
||||||
dc.skinnedMesh != nullptr ? ", skinned" : "");
|
|
||||||
|
|
||||||
if (dc.meshId != prevMeshId)
|
|
||||||
{
|
|
||||||
prevMeshId = dc.meshId;
|
|
||||||
|
|
||||||
vkCmdBindIndexBuffer(
|
|
||||||
cmd,
|
|
||||||
mesh.indexBuffer.buffer,
|
|
||||||
0,
|
|
||||||
VK_INDEX_TYPE_UINT32
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(dc.materialId != NULL_MATERIAL_ID);
|
|
||||||
|
|
||||||
const auto pushConstants = PushConstants{
|
|
||||||
.transform = dc.transformMatrix,
|
|
||||||
.sceneDataBuffer = sceneDataBuffer.address,
|
|
||||||
.vertexBuffer =
|
|
||||||
dc.skinnedMesh != nullptr
|
|
||||||
? dc.skinnedMesh->skinnedVertexBuffer.address
|
|
||||||
: mesh.vertexBuffer.address,
|
|
||||||
.materialId = dc.materialId,
|
|
||||||
.padding = 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
vkCmdPushConstants(
|
|
||||||
cmd,
|
|
||||||
m_pipelineLayout,
|
|
||||||
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
|
|
||||||
0,
|
|
||||||
sizeof(PushConstants),
|
|
||||||
&pushConstants
|
|
||||||
);
|
|
||||||
|
|
||||||
vkCmdDrawIndexed(cmd, mesh.numIndices, 1, 0, 0, 0);
|
|
||||||
|
|
||||||
vkutil::cmdEndLabel(cmd);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!sortedDrawCommands.empty())
|
vkCmdPushConstants(
|
||||||
{
|
cmd,
|
||||||
for (std::size_t drawIndex : sortedDrawCommands)
|
m_pipelineLayout,
|
||||||
{
|
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
|
||||||
assert(drawIndex < drawCommands.size());
|
0,
|
||||||
drawOne(drawCommands[drawIndex]);
|
sizeof(PushConstants),
|
||||||
}
|
&pushConstants
|
||||||
}
|
);
|
||||||
else
|
|
||||||
{
|
vkutil::cmdBeginLabelFormatted(
|
||||||
for (const MeshDrawCommand& dc : drawCommands)
|
cmd,
|
||||||
{
|
vkutil::labelColors::draw,
|
||||||
drawOne(dc);
|
"%u indirect draws, %u instances",
|
||||||
}
|
view.commandCount,
|
||||||
}
|
view.instanceCount);
|
||||||
|
|
||||||
|
drawList.draw(cmd, resources.meshes().getIndexBuffer(), view);
|
||||||
|
|
||||||
|
vkutil::cmdEndLabel(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MeshPipeline::cleanup(VkDevice device)
|
void MeshPipeline::cleanup(VkDevice device)
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ void Renderer2DPass::ensureCapacity(GfxDevice& gfxDevice, std::size_t requiredSh
|
|||||||
newCapacity *= 2;
|
newCapacity *= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
gfxDevice.waitIdle();
|
|
||||||
shapeBuffer.cleanup(gfxDevice);
|
shapeBuffer.cleanup(gfxDevice);
|
||||||
shapeBuffer.init(gfxDevice, ShapeBufferUsage, newCapacity * sizeof(Shape2D), "2d shapes");
|
shapeBuffer.init(gfxDevice, ShapeBufferUsage, newCapacity * sizeof(Shape2D), "2d shapes");
|
||||||
shapeCapacity = newCapacity;
|
shapeCapacity = newCapacity;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
#include <destrum/Graphics/GfxDevice.h>
|
#include <destrum/Graphics/GfxDevice.h>
|
||||||
#include <destrum/Graphics/RenderResources.h>
|
#include <destrum/Graphics/RenderResources.h>
|
||||||
#include <destrum/Graphics/Caches/MeshCache.h>
|
#include <destrum/Graphics/Caches/MeshCache.h>
|
||||||
#include <destrum/Graphics/Frustum.h>
|
|
||||||
#include <destrum/Graphics/Util.h>
|
#include <destrum/Graphics/Util.h>
|
||||||
|
|
||||||
#include "volk.h"
|
#include "volk.h"
|
||||||
@@ -206,8 +205,8 @@ float ShadowMapPipeline::getNormalOffset() const
|
|||||||
void ShadowMapPipeline::drawDirectionalShadow(
|
void ShadowMapPipeline::drawDirectionalShadow(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands,
|
const IndirectDrawList::View& view,
|
||||||
const glm::mat4& lightViewProj)
|
const glm::mat4& lightViewProj)
|
||||||
{
|
{
|
||||||
if (!m_pipeline || shadowMapId == NULL_IMAGE_ID)
|
if (!m_pipeline || shadowMapId == NULL_IMAGE_ID)
|
||||||
@@ -219,15 +218,15 @@ void ShadowMapPipeline::drawDirectionalShadow(
|
|||||||
|
|
||||||
beginDepthRendering(cmd, resources, shadowMap);
|
beginDepthRendering(cmd, resources, shadowMap);
|
||||||
setRenderRegion(cmd, VkRect2D{.offset = {}, .extent = shadowMap.getExtent2D()});
|
setRenderRegion(cmd, VkRect2D{.offset = {}, .extent = shadowMap.getExtent2D()});
|
||||||
drawShadowCasters(cmd, resources, drawCommands, sortedDrawCommands, lightViewProj);
|
drawShadowCasters(cmd, resources, drawList, view, lightViewProj);
|
||||||
endDepthRendering(cmd, shadowMap);
|
endDepthRendering(cmd, shadowMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ShadowMapPipeline::drawSpotShadows(
|
void ShadowMapPipeline::drawSpotShadows(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands,
|
const std::vector<IndirectDrawList::View>& views,
|
||||||
const std::vector<glm::mat4>& lightViewProjs)
|
const std::vector<glm::mat4>& lightViewProjs)
|
||||||
{
|
{
|
||||||
if (!m_pipeline || spotShadowAtlasId == NULL_IMAGE_ID || lightViewProjs.empty())
|
if (!m_pipeline || spotShadowAtlasId == NULL_IMAGE_ID || lightViewProjs.empty())
|
||||||
@@ -236,6 +235,7 @@ void ShadowMapPipeline::drawSpotShadows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert(lightViewProjs.size() <= MAX_SPOT_SHADOWS);
|
assert(lightViewProjs.size() <= MAX_SPOT_SHADOWS);
|
||||||
|
assert(views.size() == lightViewProjs.size());
|
||||||
const auto& spotShadowAtlas = resources.getImage(spotShadowAtlasId);
|
const auto& spotShadowAtlas = resources.getImage(spotShadowAtlasId);
|
||||||
|
|
||||||
beginDepthRendering(cmd, resources, spotShadowAtlas);
|
beginDepthRendering(cmd, resources, spotShadowAtlas);
|
||||||
@@ -244,7 +244,7 @@ void ShadowMapPipeline::drawSpotShadows(
|
|||||||
vkutil::cmdBeginLabelFormatted(cmd, vkutil::labelColors::shadow, "Spot shadow %u", shadowIndex);
|
vkutil::cmdBeginLabelFormatted(cmd, vkutil::labelColors::shadow, "Spot shadow %u", shadowIndex);
|
||||||
|
|
||||||
setRenderRegion(cmd, spotShadowTileRegion(shadowIndex));
|
setRenderRegion(cmd, spotShadowTileRegion(shadowIndex));
|
||||||
drawShadowCasters(cmd, resources, drawCommands, sortedDrawCommands, lightViewProjs[shadowIndex]);
|
drawShadowCasters(cmd, resources, drawList, views[shadowIndex], lightViewProjs[shadowIndex]);
|
||||||
|
|
||||||
vkutil::cmdEndLabel(cmd);
|
vkutil::cmdEndLabel(cmd);
|
||||||
}
|
}
|
||||||
@@ -306,73 +306,31 @@ void ShadowMapPipeline::setRenderRegion(VkCommandBuffer cmd, const VkRect2D& reg
|
|||||||
void ShadowMapPipeline::drawShadowCasters(
|
void ShadowMapPipeline::drawShadowCasters(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
const RenderResources& resources,
|
const RenderResources& resources,
|
||||||
const std::vector<MeshDrawCommand>& drawCommands,
|
const IndirectDrawList& drawList,
|
||||||
const std::vector<std::size_t>& sortedDrawCommands,
|
const IndirectDrawList::View& view,
|
||||||
const glm::mat4& lightViewProj)
|
const glm::mat4& lightViewProj)
|
||||||
{
|
{
|
||||||
const MeshCache& meshCache = resources.meshes();
|
const auto pushConstants = PushConstants{
|
||||||
const Frustum lightFrustum = edge::createFrustumFromViewProj(lightViewProj);
|
.lightViewProj = lightViewProj,
|
||||||
MeshID previousMeshId = NULL_MESH_ID;
|
.instanceBuffer = drawList.getInstanceBufferAddress(),
|
||||||
|
|
||||||
const auto drawCaster = [&](const MeshDrawCommand& dc)
|
|
||||||
{
|
|
||||||
if (!dc.castShadow || !edge::isInFrustum(lightFrustum, dc.worldBoundingSphere))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto& mesh = meshCache.getMesh(dc.meshId);
|
|
||||||
|
|
||||||
const std::string& meshKey = meshCache.getMeshKey(dc.meshId);
|
|
||||||
vkutil::cmdBeginLabelFormatted(
|
|
||||||
cmd,
|
|
||||||
vkutil::labelColors::draw,
|
|
||||||
"%s [mesh %zu, material %u%s]",
|
|
||||||
meshKey.empty() ? "Mesh" : meshKey.c_str(),
|
|
||||||
dc.meshId,
|
|
||||||
dc.materialId,
|
|
||||||
dc.skinnedMesh != nullptr ? ", skinned" : "");
|
|
||||||
|
|
||||||
if (dc.meshId != previousMeshId)
|
|
||||||
{
|
|
||||||
previousMeshId = dc.meshId;
|
|
||||||
vkCmdBindIndexBuffer(cmd, mesh.indexBuffer.buffer, 0, VK_INDEX_TYPE_UINT32);
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto pushConstants = PushConstants{
|
|
||||||
.modelToLightClip = lightViewProj * dc.transformMatrix,
|
|
||||||
.vertexBuffer =
|
|
||||||
dc.skinnedMesh != nullptr
|
|
||||||
? dc.skinnedMesh->skinnedVertexBuffer.address
|
|
||||||
: mesh.vertexBuffer.address,
|
|
||||||
};
|
|
||||||
|
|
||||||
vkCmdPushConstants(
|
|
||||||
cmd,
|
|
||||||
m_pipelineLayout,
|
|
||||||
VK_SHADER_STAGE_VERTEX_BIT,
|
|
||||||
0,
|
|
||||||
sizeof(PushConstants),
|
|
||||||
&pushConstants);
|
|
||||||
|
|
||||||
vkCmdDrawIndexed(cmd, mesh.numIndices, 1, 0, 0, 0);
|
|
||||||
|
|
||||||
vkutil::cmdEndLabel(cmd);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!sortedDrawCommands.empty())
|
vkCmdPushConstants(
|
||||||
{
|
cmd,
|
||||||
for (std::size_t drawIndex : sortedDrawCommands)
|
m_pipelineLayout,
|
||||||
{
|
VK_SHADER_STAGE_VERTEX_BIT,
|
||||||
assert(drawIndex < drawCommands.size());
|
0,
|
||||||
drawCaster(drawCommands[drawIndex]);
|
sizeof(PushConstants),
|
||||||
}
|
&pushConstants);
|
||||||
}
|
|
||||||
else
|
vkutil::cmdBeginLabelFormatted(
|
||||||
{
|
cmd,
|
||||||
for (const MeshDrawCommand& dc : drawCommands)
|
vkutil::labelColors::draw,
|
||||||
{
|
"%u indirect draws, %u instances",
|
||||||
drawCaster(dc);
|
view.commandCount,
|
||||||
}
|
view.instanceCount);
|
||||||
}
|
|
||||||
|
drawList.draw(cmd, resources.meshes().getIndexBuffer(), view);
|
||||||
|
|
||||||
|
vkutil::cmdEndLabel(cmd);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ ImageID RenderResources::addImageToCache(GPUImage image) {
|
|||||||
return imageCache->addImage(std::move(image));
|
return imageCache->addImage(std::move(image));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RenderResources::destroyImage(ImageID id) {
|
||||||
|
imageCache->removeImage(id);
|
||||||
|
}
|
||||||
|
|
||||||
void RenderResources::loadModelAsset(
|
void RenderResources::loadModelAsset(
|
||||||
GfxDevice& gfxDevice,
|
GfxDevice& gfxDevice,
|
||||||
std::string_view sourceReference)
|
std::string_view sourceReference)
|
||||||
@@ -322,8 +326,6 @@ void RenderResources::cleanup(GfxDevice& gfxDevice)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
gfxDevice.waitIdle();
|
|
||||||
|
|
||||||
if (materialCache) {
|
if (materialCache) {
|
||||||
materialCache->cleanup(gfxDevice);
|
materialCache->cleanup(gfxDevice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ void GameRenderer::init(GfxDevice& gfxDevice, RenderResources& _resources, glm::
|
|||||||
|
|
||||||
void GameRenderer::beginDrawing(GfxDevice& gfxDevice)
|
void GameRenderer::beginDrawing(GfxDevice& gfxDevice)
|
||||||
{
|
{
|
||||||
flushMaterialUpdates(gfxDevice);
|
|
||||||
meshDrawCommands.clear();
|
meshDrawCommands.clear();
|
||||||
skinningPipeline->beginDrawing(gfxDevice.getCurrentFrameIndex());
|
skinningPipeline->beginDrawing(gfxDevice.getCurrentFrameIndex());
|
||||||
lightCullingPipeline->beginDrawing(gfxDevice.getCurrentFrameIndex());
|
lightCullingPipeline->beginDrawing(gfxDevice.getCurrentFrameIndex());
|
||||||
@@ -80,22 +79,14 @@ void GameRenderer::endDrawing()
|
|||||||
sortedMeshDrawCommands.resize(meshDrawCommands.size());
|
sortedMeshDrawCommands.resize(meshDrawCommands.size());
|
||||||
std::iota(sortedMeshDrawCommands.begin(), sortedMeshDrawCommands.end(), 0);
|
std::iota(sortedMeshDrawCommands.begin(), sortedMeshDrawCommands.end(), 0);
|
||||||
|
|
||||||
|
// Grouping by mesh lets IndirectDrawList merge each run into one instanced draw.
|
||||||
|
// Material and vertex buffer are per instance, so they don't break a batch.
|
||||||
std::stable_sort(
|
std::stable_sort(
|
||||||
sortedMeshDrawCommands.begin(),
|
sortedMeshDrawCommands.begin(),
|
||||||
sortedMeshDrawCommands.end(),
|
sortedMeshDrawCommands.end(),
|
||||||
[this](std::size_t left, std::size_t right)
|
[this](std::size_t left, std::size_t right)
|
||||||
{
|
{
|
||||||
const auto& lhs = meshDrawCommands[left];
|
return meshDrawCommands[left].meshId < meshDrawCommands[right].meshId;
|
||||||
const auto& rhs = meshDrawCommands[right];
|
|
||||||
if (lhs.meshId != rhs.meshId)
|
|
||||||
{
|
|
||||||
return lhs.meshId < rhs.meshId;
|
|
||||||
}
|
|
||||||
if (lhs.materialId != rhs.materialId)
|
|
||||||
{
|
|
||||||
return lhs.materialId < rhs.materialId;
|
|
||||||
}
|
|
||||||
return (lhs.skinnedMesh != nullptr) < (rhs.skinnedMesh != nullptr);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +139,55 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera&
|
|||||||
spotShadowViewProjs.push_back(spotShadow.lightViewProj);
|
spotShadowViewProjs.push_back(spotShadow.lightViewProj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Culling stays on the CPU: every view (camera, directional light, each spot light)
|
||||||
|
// gets its own range of indirect commands in this frame's draw list.
|
||||||
|
IndirectDrawList::View mainView;
|
||||||
|
IndirectDrawList::View directionalShadowView;
|
||||||
|
std::vector<IndirectDrawList::View> spotShadowViews;
|
||||||
|
{
|
||||||
|
ZoneScopedN("Build Indirect Draws");
|
||||||
|
|
||||||
|
const auto& meshes = resources->meshes().getMeshes();
|
||||||
|
drawList.begin(gfxDevice.getCurrentFrameIndex());
|
||||||
|
|
||||||
|
mainView = drawList.addView(
|
||||||
|
meshes,
|
||||||
|
meshDrawCommands,
|
||||||
|
sortedMeshDrawCommands,
|
||||||
|
edge::createFrustumFromCamera(camera),
|
||||||
|
IndirectDrawList::Filter::All);
|
||||||
|
|
||||||
|
if (shadowLightDirection)
|
||||||
|
{
|
||||||
|
directionalShadowView = drawList.addView(
|
||||||
|
meshes,
|
||||||
|
meshDrawCommands,
|
||||||
|
sortedMeshDrawCommands,
|
||||||
|
edge::createFrustumFromViewProj(lightViewProj),
|
||||||
|
IndirectDrawList::Filter::ShadowCasters);
|
||||||
|
}
|
||||||
|
|
||||||
|
spotShadowViews.reserve(spotShadowViewProjs.size());
|
||||||
|
for (const glm::mat4& spotViewProj : spotShadowViewProjs)
|
||||||
|
{
|
||||||
|
spotShadowViews.push_back(drawList.addView(
|
||||||
|
meshes,
|
||||||
|
meshDrawCommands,
|
||||||
|
sortedMeshDrawCommands,
|
||||||
|
edge::createFrustumFromViewProj(spotViewProj),
|
||||||
|
IndirectDrawList::Filter::ShadowCasters));
|
||||||
|
}
|
||||||
|
|
||||||
|
drawList.upload(gfxDevice);
|
||||||
|
lastDrawStats = DrawStats{
|
||||||
|
.meshDraws = static_cast<std::uint32_t>(meshDrawCommands.size()),
|
||||||
|
.indirectCommands = static_cast<std::uint32_t>(drawList.getCommands().size()),
|
||||||
|
.instances = static_cast<std::uint32_t>(drawList.getInstances().size()),
|
||||||
|
.mainViewCommands = mainView.commandCount,
|
||||||
|
.mainViewInstances = mainView.instanceCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: there has to be a better way to do this
|
// TODO: there has to be a better way to do this
|
||||||
const auto gpuSceneData = GPUSceneData{
|
const auto gpuSceneData = GPUSceneData{
|
||||||
.view = sceneData.camera.GetViewMatrix(),
|
.view = sceneData.camera.GetViewMatrix(),
|
||||||
@@ -175,15 +215,10 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera&
|
|||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
TracyVkZone(gfxDevice.getTracyVkCtx(), cmd, "Material Buffer Barrier");
|
TracyVkZone(gfxDevice.getTracyVkCtx(), cmd, "Material Updates");
|
||||||
vkutil::ScopedCmdLabel label(cmd, "Material Buffer Barrier", vkutil::labelColors::transfer);
|
vkutil::ScopedCmdLabel label(cmd, "Material Updates", vkutil::labelColors::transfer);
|
||||||
|
|
||||||
vkutil::bufferHostWriteToShaderReadBarrier(
|
recordMaterialUpdates(cmd);
|
||||||
cmd,
|
|
||||||
resources->materials().getMaterialDataBuffer().buffer,
|
|
||||||
0,
|
|
||||||
VK_WHOLE_SIZE
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -231,8 +266,8 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera&
|
|||||||
shadowMapPipeline->drawDirectionalShadow(
|
shadowMapPipeline->drawDirectionalShadow(
|
||||||
cmd,
|
cmd,
|
||||||
*resources,
|
*resources,
|
||||||
meshDrawCommands,
|
drawList,
|
||||||
sortedMeshDrawCommands,
|
directionalShadowView,
|
||||||
lightViewProj);
|
lightViewProj);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,8 +279,8 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera&
|
|||||||
shadowMapPipeline->drawSpotShadows(
|
shadowMapPipeline->drawSpotShadows(
|
||||||
cmd,
|
cmd,
|
||||||
*resources,
|
*resources,
|
||||||
meshDrawCommands,
|
drawList,
|
||||||
sortedMeshDrawCommands,
|
spotShadowViews,
|
||||||
spotShadowViewProjs);
|
spotShadowViewProjs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,10 +301,9 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera&
|
|||||||
cmd,
|
cmd,
|
||||||
drawImage.getExtent2D(),
|
drawImage.getExtent2D(),
|
||||||
*resources,
|
*resources,
|
||||||
camera,
|
|
||||||
sceneDataBuffer.getBuffer(),
|
sceneDataBuffer.getBuffer(),
|
||||||
meshDrawCommands,
|
drawList,
|
||||||
sortedMeshDrawCommands);
|
mainView);
|
||||||
|
|
||||||
vkCmdEndRendering(cmd);
|
vkCmdEndRendering(cmd);
|
||||||
}
|
}
|
||||||
@@ -325,10 +359,9 @@ void GameRenderer::draw(VkCommandBuffer cmd, GfxDevice& gfxDevice, const Camera&
|
|||||||
cmd,
|
cmd,
|
||||||
drawImage.getExtent2D(),
|
drawImage.getExtent2D(),
|
||||||
*resources,
|
*resources,
|
||||||
camera,
|
|
||||||
sceneDataBuffer.getBuffer(),
|
sceneDataBuffer.getBuffer(),
|
||||||
meshDrawCommands,
|
drawList,
|
||||||
sortedMeshDrawCommands);
|
mainView);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -384,11 +417,6 @@ void GameRenderer::cleanup(GfxDevice& gfxDevice)
|
|||||||
{
|
{
|
||||||
VkDevice device = gfxDevice.getDevice();
|
VkDevice device = gfxDevice.getDevice();
|
||||||
|
|
||||||
if (device != VK_NULL_HANDLE)
|
|
||||||
{
|
|
||||||
vkDeviceWaitIdle(device);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skinningPipeline)
|
if (skinningPipeline)
|
||||||
skinningPipeline->cleanup(gfxDevice);
|
skinningPipeline->cleanup(gfxDevice);
|
||||||
|
|
||||||
@@ -411,6 +439,7 @@ void GameRenderer::cleanup(GfxDevice& gfxDevice)
|
|||||||
meshPipeline->cleanup(device);
|
meshPipeline->cleanup(device);
|
||||||
|
|
||||||
sceneDataBuffer.cleanup(gfxDevice);
|
sceneDataBuffer.cleanup(gfxDevice);
|
||||||
|
drawList.cleanup(gfxDevice);
|
||||||
|
|
||||||
// if (drawImageId != NULL_IMAGE_ID)
|
// if (drawImageId != NULL_IMAGE_ID)
|
||||||
// gfxDevice.destroyImage();
|
// gfxDevice.destroyImage();
|
||||||
@@ -541,25 +570,32 @@ void GameRenderer::setSkyboxTexture(ImageID skyboxImageId)
|
|||||||
skyboxPipeline->setSkyboxImage(skyboxImageId);
|
skyboxPipeline->setSkyboxImage(skyboxImageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameRenderer::flushMaterialUpdates(GfxDevice& gfxDevice)
|
void GameRenderer::recordMaterialUpdates(VkCommandBuffer cmd)
|
||||||
{
|
{
|
||||||
|
const VkBuffer materialBuffer = resources->materials().getMaterialDataBuffer().buffer;
|
||||||
|
|
||||||
if (!pendingMaterialUploads.empty())
|
if (!pendingMaterialUploads.empty())
|
||||||
{
|
{
|
||||||
// Material data is currently shared by all frame contexts. Wait before
|
// Updates go through the command buffer instead of the mapped pointer: the
|
||||||
// mutating it so an older in-flight frame cannot read the overwritten
|
// copy then lands after earlier frames finished reading the old values,
|
||||||
// range. This can later be replaced with per-frame material buffers.
|
// without the CPU having to wait for them.
|
||||||
gfxDevice.waitIdle();
|
vkutil::bufferShaderReadToTransferWriteBarrier(cmd, materialBuffer, 0, VK_WHOLE_SIZE);
|
||||||
|
|
||||||
|
for (MaterialID id : pendingMaterialUploads)
|
||||||
|
{
|
||||||
|
const MaterialData data = resources->materials().getMaterialData(id);
|
||||||
|
vkCmdUpdateBuffer(
|
||||||
|
cmd,
|
||||||
|
materialBuffer,
|
||||||
|
static_cast<VkDeviceSize>(id) * sizeof(MaterialData),
|
||||||
|
sizeof(MaterialData),
|
||||||
|
&data);
|
||||||
|
}
|
||||||
|
pendingMaterialUploads.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (MaterialID id : pendingMaterialUploads)
|
// newly added materials are written through the mapped pointer, updates by the copies above
|
||||||
{
|
vkutil::bufferHostAndTransferWriteToShaderReadBarrier(cmd, materialBuffer, 0, VK_WHOLE_SIZE);
|
||||||
resources->materials().updateMaterialGPU(id);
|
|
||||||
gfxDevice.getMemoryManager().flushAllocation(
|
|
||||||
resources->materials().getMaterialDataBuffer(),
|
|
||||||
static_cast<VkDeviceSize>(id) * sizeof(MaterialData),
|
|
||||||
sizeof(MaterialData));
|
|
||||||
}
|
|
||||||
pendingMaterialUploads.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameRenderer::createDrawImage(GfxDevice& gfxDevice,
|
void GameRenderer::createDrawImage(GfxDevice& gfxDevice,
|
||||||
@@ -589,8 +625,9 @@ void GameRenderer::createDrawImage(GfxDevice& gfxDevice,
|
|||||||
.samples = noMsaa,
|
.samples = noMsaa,
|
||||||
};
|
};
|
||||||
|
|
||||||
// reuse the same id if creating again
|
// a fresh bindless slot, the old one may still be read by frames in flight
|
||||||
drawImageId = resources->createImage(gfxDevice, createImageInfo, "draw image", nullptr, drawImageId);
|
resources->destroyImage(drawImageId);
|
||||||
|
drawImageId = resources->createImage(gfxDevice, createImageInfo, "draw image");
|
||||||
|
|
||||||
if (firstCreate)
|
if (firstCreate)
|
||||||
{
|
{
|
||||||
@@ -609,7 +646,8 @@ void GameRenderer::createDrawImage(GfxDevice& gfxDevice,
|
|||||||
.samples = noMsaa,
|
.samples = noMsaa,
|
||||||
};
|
};
|
||||||
|
|
||||||
depthImageId = resources->createImage(gfxDevice, createInfo, "depth image", nullptr, depthImageId);
|
resources->destroyImage(depthImageId);
|
||||||
|
depthImageId = resources->createImage(gfxDevice, createInfo, "depth image");
|
||||||
spdlog::info("Created depth image with id {}", depthImageId);
|
spdlog::info("Created depth image with id {}", depthImageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ void CubeMap::CreateCubeMap(
|
|||||||
vkDestroyImageView(gfxDevice.getDevice(), view, nullptr);
|
vkDestroyImageView(gfxDevice.getDevice(), view, nullptr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gfxDevice.destroyImage(cubeMapImage);
|
gfxDevice.destroyImageNow(cubeMapImage);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ bool Swapchain::recreateSwapchain(
|
|||||||
surface = surf;
|
surface = surf;
|
||||||
|
|
||||||
{
|
{
|
||||||
|
// Resizing is rare, and the old swapchain (plus its views and the renderer's
|
||||||
|
// size-dependent images) can't be destroyed while a present may still use it.
|
||||||
ZoneScopedN("vkDeviceWaitIdle");
|
ZoneScopedN("vkDeviceWaitIdle");
|
||||||
vkDeviceWaitIdle(vkbDevice);
|
vkDeviceWaitIdle(vkbDevice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,46 @@ void vkutil::bufferHostWriteToShaderReadBarrier(
|
|||||||
vkCmdPipelineBarrier2(cmd, &dep);
|
vkCmdPipelineBarrier2(cmd, &dep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void vkutil::bufferShaderReadToTransferWriteBarrier(
|
||||||
|
VkCommandBuffer cmd, VkBuffer buffer, VkDeviceSize offset, VkDeviceSize size) {
|
||||||
|
VkBufferMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER_2};
|
||||||
|
barrier.srcStageMask = VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT |
|
||||||
|
VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT |
|
||||||
|
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT;
|
||||||
|
barrier.srcAccessMask = VK_ACCESS_2_SHADER_READ_BIT;
|
||||||
|
barrier.dstStageMask = VK_PIPELINE_STAGE_2_TRANSFER_BIT;
|
||||||
|
barrier.dstAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT;
|
||||||
|
barrier.buffer = buffer;
|
||||||
|
barrier.offset = offset;
|
||||||
|
barrier.size = size;
|
||||||
|
|
||||||
|
VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};
|
||||||
|
dep.bufferMemoryBarrierCount = 1;
|
||||||
|
dep.pBufferMemoryBarriers = &barrier;
|
||||||
|
|
||||||
|
vkCmdPipelineBarrier2(cmd, &dep);
|
||||||
|
}
|
||||||
|
|
||||||
|
void vkutil::bufferHostAndTransferWriteToShaderReadBarrier(
|
||||||
|
VkCommandBuffer cmd, VkBuffer buffer, VkDeviceSize offset, VkDeviceSize size) {
|
||||||
|
VkBufferMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER_2};
|
||||||
|
barrier.srcStageMask = VK_PIPELINE_STAGE_2_HOST_BIT | VK_PIPELINE_STAGE_2_TRANSFER_BIT;
|
||||||
|
barrier.srcAccessMask = VK_ACCESS_2_HOST_WRITE_BIT | VK_ACCESS_2_TRANSFER_WRITE_BIT;
|
||||||
|
barrier.dstStageMask = VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT |
|
||||||
|
VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT |
|
||||||
|
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT;
|
||||||
|
barrier.dstAccessMask = VK_ACCESS_2_SHADER_READ_BIT;
|
||||||
|
barrier.buffer = buffer;
|
||||||
|
barrier.offset = offset;
|
||||||
|
barrier.size = size;
|
||||||
|
|
||||||
|
VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};
|
||||||
|
dep.bufferMemoryBarrierCount = 1;
|
||||||
|
dep.pBufferMemoryBarriers = &barrier;
|
||||||
|
|
||||||
|
vkCmdPipelineBarrier2(cmd, &dep);
|
||||||
|
}
|
||||||
|
|
||||||
void vkutil::bufferHostWriteToTransferReadBarrier(
|
void vkutil::bufferHostWriteToTransferReadBarrier(
|
||||||
VkCommandBuffer cmd,
|
VkCommandBuffer cmd,
|
||||||
VkBuffer buffer,
|
VkBuffer buffer,
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
#include <destrum/Physics/SimplePhysicsWorld.h>
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cmath>
|
|
||||||
#include <limits>
|
|
||||||
|
|
||||||
#include <destrum/Components/Physics/Rigidbody.h>
|
|
||||||
#include <destrum/ObjectModel/GameObject.h>
|
|
||||||
#include <destrum/ObjectModel/Transform.h>
|
|
||||||
|
|
||||||
SimplePhysicsWorld::SimplePhysicsWorld(const glm::vec3& gravity)
|
|
||||||
: m_Gravity(gravity) {
|
|
||||||
}
|
|
||||||
|
|
||||||
PhysicsBodyHandle SimplePhysicsWorld::CreateBody(const PhysicsBodyDesc& desc) {
|
|
||||||
BodyRecord record{};
|
|
||||||
record.alive = true;
|
|
||||||
record.handle = PhysicsBodyHandle{m_NextId++};
|
|
||||||
record.desc = desc;
|
|
||||||
record.previousTransform = desc.transform;
|
|
||||||
record.currentTransform = desc.transform;
|
|
||||||
|
|
||||||
if (desc.owner) {
|
|
||||||
record.rigidbody = desc.owner->GetComponent<Rigidbody>();
|
|
||||||
}
|
|
||||||
|
|
||||||
m_Bodies.emplace_back(record);
|
|
||||||
return record.handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SimplePhysicsWorld::DestroyBody(PhysicsBodyHandle body) {
|
|
||||||
if (BodyRecord* record = FindBody(body)) {
|
|
||||||
record->alive = false;
|
|
||||||
record->rigidbody = nullptr;
|
|
||||||
record->desc.owner = nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void SimplePhysicsWorld::SetBodyTransform(PhysicsBodyHandle body, const PhysicsTransform& transform) {
|
|
||||||
BodyRecord* record = FindBody(body);
|
|
||||||
if (!record) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
record->previousTransform = record->currentTransform;
|
|
||||||
record->currentTransform = transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
PhysicsTransform SimplePhysicsWorld::GetBodyTransform(PhysicsBodyHandle body) const {
|
|
||||||
const BodyRecord* record = FindBody(body);
|
|
||||||
if (!record) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return record->currentTransform;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SimplePhysicsWorld::SetLinearVelocity(PhysicsBodyHandle body, const glm::vec3& velocity) {
|
|
||||||
BodyRecord* record = FindBody(body);
|
|
||||||
if (!record) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
record->linearVelocity = velocity;
|
|
||||||
}
|
|
||||||
|
|
||||||
glm::vec3 SimplePhysicsWorld::GetLinearVelocity(PhysicsBodyHandle body) const {
|
|
||||||
const BodyRecord* record = FindBody(body);
|
|
||||||
if (!record) {
|
|
||||||
return glm::vec3{0.0f};
|
|
||||||
}
|
|
||||||
|
|
||||||
return record->linearVelocity;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SimplePhysicsWorld::AddForce(PhysicsBodyHandle body, const glm::vec3& force) {
|
|
||||||
BodyRecord* record = FindBody(body);
|
|
||||||
if (!record || record->desc.type != RigidbodyType::Dynamic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
record->accumulatedForce += force;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SimplePhysicsWorld::AddImpulse(PhysicsBodyHandle body, const glm::vec3& impulse) {
|
|
||||||
BodyRecord* record = FindBody(body);
|
|
||||||
if (!record || record->desc.type != RigidbodyType::Dynamic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const float invMass = record->desc.mass > 0.0f ? 1.0f / record->desc.mass : 0.0f;
|
|
||||||
record->linearVelocity += impulse * invMass;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SimplePhysicsWorld::Step(float fixedDt) {
|
|
||||||
if (fixedDt <= 0.0f) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (BodyRecord& body : m_Bodies) {
|
|
||||||
if (!body.alive || !body.desc.owner ||
|
|
||||||
body.desc.owner->IsBeingDestroyed() ||
|
|
||||||
!body.desc.owner->IsActiveInHierarchy()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.previousTransform = body.currentTransform;
|
|
||||||
|
|
||||||
if (body.desc.type != RigidbodyType::Dynamic) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const float invMass = body.desc.mass > 0.0f ? 1.0f / body.desc.mass : 0.0f;
|
|
||||||
|
|
||||||
glm::vec3 acceleration{0.0f};
|
|
||||||
|
|
||||||
if (body.desc.useGravity) {
|
|
||||||
acceleration += m_Gravity;
|
|
||||||
}
|
|
||||||
|
|
||||||
acceleration += body.accumulatedForce * invMass;
|
|
||||||
|
|
||||||
// Semi-implicit Euler.
|
|
||||||
body.linearVelocity += acceleration * fixedDt;
|
|
||||||
body.currentTransform.position += body.linearVelocity * fixedDt;
|
|
||||||
|
|
||||||
body.accumulatedForce = glm::vec3{0.0f};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact dead records occasionally.
|
|
||||||
m_Bodies.erase(
|
|
||||||
std::remove_if(m_Bodies.begin(), m_Bodies.end(),
|
|
||||||
[](const BodyRecord& body) { return !body.alive; }),
|
|
||||||
m_Bodies.end());
|
|
||||||
}
|
|
||||||
|
|
||||||
void SimplePhysicsWorld::SyncKinematicBodiesToPhysics(float fixedDt) {
|
|
||||||
for (BodyRecord& body : m_Bodies) {
|
|
||||||
if (!body.alive || body.desc.type != RigidbodyType::Kinematic || !body.desc.owner ||
|
|
||||||
body.desc.owner->IsBeingDestroyed() || !body.desc.owner->IsActiveInHierarchy()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Transform& transform = body.desc.owner->GetTransform();
|
|
||||||
|
|
||||||
body.previousTransform = body.currentTransform;
|
|
||||||
body.currentTransform.position = transform.GetWorldPosition();
|
|
||||||
body.currentTransform.rotation = transform.GetWorldRotation();
|
|
||||||
body.linearVelocity = fixedDt > 0.0f
|
|
||||||
? (body.currentTransform.position - body.previousTransform.position) / fixedDt
|
|
||||||
: glm::vec3{0.0f};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void SimplePhysicsWorld::SyncDynamicBodiesToTransforms() {
|
|
||||||
for (BodyRecord& body : m_Bodies) {
|
|
||||||
if (!body.alive || body.desc.type != RigidbodyType::Dynamic || !body.desc.owner ||
|
|
||||||
body.desc.owner->IsBeingDestroyed() || !body.desc.owner->IsActiveInHierarchy()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Transform& transform = body.desc.owner->GetTransform();
|
|
||||||
transform.SetWorldPosition(body.currentTransform.position);
|
|
||||||
transform.SetWorldRotation(body.currentTransform.rotation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool SimplePhysicsWorld::Raycast(const glm::vec3& origin,
|
|
||||||
const glm::vec3& direction,
|
|
||||||
float maxDistance,
|
|
||||||
PhysicsRaycastHit& hit,
|
|
||||||
PhysicsBodyHandle ignoreBody) const {
|
|
||||||
if (maxDistance <= 0.0f) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const float len = glm::length(direction);
|
|
||||||
if (len <= 0.00001f) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const glm::vec3 dir = direction / len;
|
|
||||||
|
|
||||||
bool found = false;
|
|
||||||
float bestDistance = std::numeric_limits<float>::max();
|
|
||||||
|
|
||||||
for (const BodyRecord& body : m_Bodies) {
|
|
||||||
if (!body.alive || body.desc.shape.type == PhysicsShapeType::None ||
|
|
||||||
!body.desc.owner || body.desc.owner->IsBeingDestroyed() ||
|
|
||||||
!body.desc.owner->IsActiveInHierarchy() ||
|
|
||||||
body.desc.shape.isTrigger ||
|
|
||||||
(ignoreBody.IsValid() && body.handle.id == ignoreBody.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
float distance = 0.0f;
|
|
||||||
const float radius = GetApproxBoundingRadius(body);
|
|
||||||
const glm::vec3 center = body.currentTransform.position
|
|
||||||
+ body.currentTransform.rotation * body.desc.shape.centerOffset;
|
|
||||||
|
|
||||||
if (RaySphere(origin, dir, center, radius, maxDistance, distance)) {
|
|
||||||
if (distance < bestDistance) {
|
|
||||||
bestDistance = distance;
|
|
||||||
found = true;
|
|
||||||
|
|
||||||
hit.object = body.desc.owner;
|
|
||||||
hit.body = body.handle;
|
|
||||||
hit.distance = distance;
|
|
||||||
hit.point = origin + dir * distance;
|
|
||||||
|
|
||||||
const glm::vec3 n = hit.point - center;
|
|
||||||
hit.normal = glm::length(n) > 0.00001f ? glm::normalize(n) : glm::vec3{0.0f, 1.0f, 0.0f};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
SimplePhysicsWorld::BodyRecord* SimplePhysicsWorld::FindBody(PhysicsBodyHandle body) {
|
|
||||||
for (BodyRecord& record : m_Bodies) {
|
|
||||||
if (record.alive && record.handle == body) {
|
|
||||||
return &record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SimplePhysicsWorld::BodyRecord* SimplePhysicsWorld::FindBody(PhysicsBodyHandle body) const {
|
|
||||||
for (const BodyRecord& record : m_Bodies) {
|
|
||||||
if (record.alive && record.handle == body) {
|
|
||||||
return &record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
float SimplePhysicsWorld::GetApproxBoundingRadius(const BodyRecord& body) const {
|
|
||||||
const PhysicsShapeDesc& shape = body.desc.shape;
|
|
||||||
|
|
||||||
switch (shape.type) {
|
|
||||||
case PhysicsShapeType::Box:
|
|
||||||
return glm::length(shape.halfExtents);
|
|
||||||
|
|
||||||
case PhysicsShapeType::Sphere:
|
|
||||||
return shape.radius;
|
|
||||||
|
|
||||||
case PhysicsShapeType::Capsule:
|
|
||||||
return shape.height * 0.5f;
|
|
||||||
|
|
||||||
case PhysicsShapeType::None:
|
|
||||||
default:
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool SimplePhysicsWorld::RaySphere(const glm::vec3& origin,
|
|
||||||
const glm::vec3& dirNormalized,
|
|
||||||
const glm::vec3& center,
|
|
||||||
float radius,
|
|
||||||
float maxDistance,
|
|
||||||
float& outDistance) {
|
|
||||||
const glm::vec3 oc = origin - center;
|
|
||||||
|
|
||||||
const float a = glm::dot(dirNormalized, dirNormalized);
|
|
||||||
const float b = 2.0f * glm::dot(oc, dirNormalized);
|
|
||||||
const float c = glm::dot(oc, oc) - radius * radius;
|
|
||||||
|
|
||||||
const float discriminant = b * b - 4.0f * a * c;
|
|
||||||
if (discriminant < 0.0f) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const float sqrtDisc = std::sqrt(discriminant);
|
|
||||||
const float t0 = (-b - sqrtDisc) / (2.0f * a);
|
|
||||||
const float t1 = (-b + sqrtDisc) / (2.0f * a);
|
|
||||||
|
|
||||||
float t = t0;
|
|
||||||
if (t < 0.0f) {
|
|
||||||
t = t1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t < 0.0f || t > maxDistance) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
outDistance = t;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -171,8 +171,6 @@ void LightKeeper::customDraw()
|
|||||||
|
|
||||||
void LightKeeper::customCleanup()
|
void LightKeeper::customCleanup()
|
||||||
{
|
{
|
||||||
gfxDevice.waitIdle();
|
|
||||||
|
|
||||||
SceneManager::GetInstance().Destroy();
|
SceneManager::GetInstance().Destroy();
|
||||||
|
|
||||||
if (skyboxCubemap)
|
if (skyboxCubemap)
|
||||||
|
|||||||
+12
-2
@@ -1,5 +1,13 @@
|
|||||||
add_executable(destrum_tests
|
add_executable(destrum_tests
|
||||||
destrum_tests.cpp
|
main.cpp
|
||||||
|
TestUtils.h
|
||||||
|
core_tests.cpp
|
||||||
|
serialization_tests.cpp
|
||||||
|
physics_tests.cpp
|
||||||
|
assets_tests.cpp
|
||||||
|
graphics_tests.cpp
|
||||||
|
ui_tests.cpp
|
||||||
|
gameplay_tests.cpp
|
||||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp
|
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp
|
||||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp
|
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp
|
||||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp
|
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp
|
||||||
@@ -26,4 +34,6 @@ target_link_libraries(destrum_tests PRIVATE destrum::destrum)
|
|||||||
|
|
||||||
destrum_enable_warnings(destrum_tests)
|
destrum_enable_warnings(destrum_tests)
|
||||||
|
|
||||||
add_test(NAME destrum_tests COMMAND destrum_tests)
|
foreach(suite core serialization physics assets graphics ui gameplay)
|
||||||
|
add_test(NAME destrum_tests.${suite} COMMAND destrum_tests ${suite})
|
||||||
|
endforeach()
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <destrum/ObjectModel/GameObject.h>
|
||||||
|
#include <destrum/Scene/Scene.h>
|
||||||
|
|
||||||
|
namespace test {
|
||||||
|
inline void check(bool condition, const char* message)
|
||||||
|
{
|
||||||
|
if (!condition) {
|
||||||
|
std::cerr << "FAILED: " << message << '\n';
|
||||||
|
std::exit(EXIT_FAILURE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool close(float a, float b)
|
||||||
|
{
|
||||||
|
return std::abs(a - b) < 0.001f;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string readFile(const std::filesystem::path& path)
|
||||||
|
{
|
||||||
|
std::ifstream file(path, std::ios::binary);
|
||||||
|
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
|
||||||
|
}
|
||||||
|
|
||||||
|
inline GameObject* findByName(const Scene& scene, const std::string& name)
|
||||||
|
{
|
||||||
|
for (const auto& object : scene.GetObjects()) {
|
||||||
|
if (object->GetName() == name) {
|
||||||
|
return object.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <glm/gtc/packing.hpp>
|
||||||
|
#include <destrum/Assets/AssetManager.h>
|
||||||
|
#include <destrum/Assets/AssetReference.h>
|
||||||
|
#include <destrum/Assets/ModelFile.h>
|
||||||
|
#include <destrum/Assets/TextureFile.h>
|
||||||
|
#include <destrum/FS/AssetFS.h>
|
||||||
|
#include <destrum/ObjectModel/Component.h>
|
||||||
|
#include <destrum/ObjectModel/GameObject.h>
|
||||||
|
#include <destrum/Serialization/GlmJson.h>
|
||||||
|
#include <destrum/Components/Animator.h>
|
||||||
|
#include <destrum/Graphics/Material.h>
|
||||||
|
#include <destrum/Graphics/imageLoader.h>
|
||||||
|
|
||||||
|
#include "TestUtils.h"
|
||||||
|
|
||||||
|
using namespace test;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void testAssetPathValidation()
|
||||||
|
{
|
||||||
|
const auto root = std::filesystem::temp_directory_path() / "destrum_asset_test";
|
||||||
|
std::filesystem::create_directories(root / "assets" / "engine");
|
||||||
|
std::filesystem::create_directories(root / "assets" / "game");
|
||||||
|
std::ofstream{root / "assets" / "engine" / "asset.txt"} << "asset";
|
||||||
|
std::ofstream{root / "assets" / "engine" / "cooked.asset"} << "cooked";
|
||||||
|
std::ofstream{root / "assets" / "engine" / "manifest.json"}
|
||||||
|
<< R"({"version":1,"assets":[{"src":"asset.txt","out":"cooked.asset","type":"raw","mtime_epoch_ns":-5020137004799419216,"size_bytes":355972}]})";
|
||||||
|
|
||||||
|
auto& assetFs = AssetFS::GetInstance();
|
||||||
|
assetFs.Init(root);
|
||||||
|
check(std::filesystem::exists(assetFs.GetFullPath("engine://asset.txt")),
|
||||||
|
"valid virtual asset paths must resolve");
|
||||||
|
check(assetFs.GetCookedPathForFile("engine://asset.txt").filename() == "cooked.asset",
|
||||||
|
"manifest output paths must resolve to cooked assets");
|
||||||
|
|
||||||
|
bool rejected = false;
|
||||||
|
try {
|
||||||
|
(void)assetFs.GetFullPath("engine://../outside.txt");
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
check(rejected, "asset traversal must be rejected");
|
||||||
|
|
||||||
|
const auto outside = root / "outside";
|
||||||
|
const auto link = root / "assets" / "engine" / "link";
|
||||||
|
std::filesystem::create_directories(outside);
|
||||||
|
std::error_code symlinkError;
|
||||||
|
std::filesystem::create_directory_symlink(outside, link, symlinkError);
|
||||||
|
if (!symlinkError) {
|
||||||
|
bool symlinkEscapeRejected = false;
|
||||||
|
try {
|
||||||
|
(void)assetFs.GetFullPath("engine://link/missing.txt");
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
symlinkEscapeRejected = true;
|
||||||
|
}
|
||||||
|
check(symlinkEscapeRejected,
|
||||||
|
"symlink traversal must be rejected for missing targets");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
assetFs.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAnimatorAssetReferences()
|
||||||
|
{
|
||||||
|
GameObject object{"animated"};
|
||||||
|
auto* animator = object.AddComponent<Animator>();
|
||||||
|
|
||||||
|
Skeleton skeleton;
|
||||||
|
Joint joint;
|
||||||
|
joint.id = 0;
|
||||||
|
joint.localTranslation = glm::vec3{0.0f};
|
||||||
|
joint.localRotation = glm::identity<glm::quat>();
|
||||||
|
joint.localScale = glm::vec3{1.0f};
|
||||||
|
skeleton.joints.push_back(joint);
|
||||||
|
skeleton.inverseBindMatrices.push_back(glm::mat4{1.0f});
|
||||||
|
skeleton.jointNames.push_back("root");
|
||||||
|
skeleton.assetReference.path = "engine://character.fbx";
|
||||||
|
buildParentIndex(skeleton);
|
||||||
|
animator->setSkeleton(std::move(skeleton));
|
||||||
|
|
||||||
|
auto clip = std::make_shared<SkeletalAnimation>();
|
||||||
|
clip->name = "walk";
|
||||||
|
clip->assetReference.path = "engine://walk.fbx";
|
||||||
|
clip->assetReference.subresource = "animation:0";
|
||||||
|
clip->duration = 1.0f;
|
||||||
|
SkeletalAnimation::Keyframe keyframe;
|
||||||
|
keyframe.time = 0.0f;
|
||||||
|
keyframe.translation = glm::vec3{0.0f};
|
||||||
|
keyframe.rotation = glm::identity<glm::quat>();
|
||||||
|
keyframe.scale = glm::vec3{1.0f};
|
||||||
|
SkeletalAnimation::Track track;
|
||||||
|
track.jointIndex = 0;
|
||||||
|
track.keyframes.push_back(keyframe);
|
||||||
|
clip->tracks.push_back(std::move(track));
|
||||||
|
animator->addClip(std::move(clip));
|
||||||
|
animator->play("walk");
|
||||||
|
|
||||||
|
const auto data = animator->Serialize();
|
||||||
|
check(data.contains("assetReferences"),
|
||||||
|
"Animator scenes must store asset references");
|
||||||
|
check(data.at("assetReferences").at("skeleton").at("path") == "engine://character.fbx",
|
||||||
|
"Animator serialization must store the skeleton source");
|
||||||
|
check(data.at("assetReferences").at("animations").at(0).at("asset").at("subresource") ==
|
||||||
|
"animation:0",
|
||||||
|
"Animator serialization must store the animation subresource");
|
||||||
|
check(data.dump().find("keyframes") == std::string::npos,
|
||||||
|
"Animator scene data must not embed animation keyframes");
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelDoc::ModelAsset makeTestModelAsset()
|
||||||
|
{
|
||||||
|
Skeleton skeleton;
|
||||||
|
skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"};
|
||||||
|
skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}};
|
||||||
|
skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}};
|
||||||
|
skeleton.joints = {
|
||||||
|
{.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}},
|
||||||
|
{.id = 1, .localScale = glm::vec3{2.0f}},
|
||||||
|
};
|
||||||
|
skeleton.jointNames = {"root", "spine"};
|
||||||
|
buildParentIndex(skeleton);
|
||||||
|
|
||||||
|
CPUMesh mesh;
|
||||||
|
mesh.indices = {0, 1, 2};
|
||||||
|
mesh.vertices = {
|
||||||
|
{.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}},
|
||||||
|
{.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}},
|
||||||
|
{.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f},
|
||||||
|
};
|
||||||
|
mesh.skinningData = {
|
||||||
|
{.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}},
|
||||||
|
{.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}},
|
||||||
|
{.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}},
|
||||||
|
};
|
||||||
|
mesh.name = "body";
|
||||||
|
mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"};
|
||||||
|
mesh.minPos = glm::vec3{0.0f};
|
||||||
|
mesh.maxPos = glm::vec3{1.0f};
|
||||||
|
mesh.skeleton = skeleton;
|
||||||
|
|
||||||
|
ModelDoc::MeshPrimitive primitive;
|
||||||
|
primitive.mesh = mesh;
|
||||||
|
primitive.materialIndex = 0;
|
||||||
|
primitive.nodeName = "Body";
|
||||||
|
primitive.sourceMeshIndex = 3;
|
||||||
|
primitive.skinned = true;
|
||||||
|
primitive.nodeWorldTransform = glm::mat4{3.0f};
|
||||||
|
|
||||||
|
ModelDoc::MaterialInfo material;
|
||||||
|
material.name = "skin";
|
||||||
|
material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"};
|
||||||
|
material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f};
|
||||||
|
material.emissiveColor = glm::vec3{0.1f};
|
||||||
|
material.metallicFactor = 0.2f;
|
||||||
|
material.roughnessFactor = 0.9f;
|
||||||
|
material.opacity = 0.5f;
|
||||||
|
material.textures = {{
|
||||||
|
.semantic = ModelDoc::TextureSemantic::BaseColor,
|
||||||
|
.rawPath = "skin.png",
|
||||||
|
.path = "textures/skin.png",
|
||||||
|
.assimpType = aiTextureType_DIFFUSE,
|
||||||
|
.slot = 1,
|
||||||
|
.embedded = false,
|
||||||
|
}};
|
||||||
|
|
||||||
|
SkeletalAnimation animation;
|
||||||
|
animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"};
|
||||||
|
animation.tracks = {{
|
||||||
|
.jointIndex = 1,
|
||||||
|
.keyframes = {
|
||||||
|
{.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity<glm::quat>(), .scale = glm::vec3{1.0f}},
|
||||||
|
{.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}},
|
||||||
|
},
|
||||||
|
}};
|
||||||
|
animation.duration = 0.5f;
|
||||||
|
animation.looped = false;
|
||||||
|
animation.name = "wave";
|
||||||
|
animation.startFrame = 4;
|
||||||
|
animation.events = {{2, {"footstep", "dust"}}};
|
||||||
|
|
||||||
|
ModelDoc::ModelAsset model;
|
||||||
|
model.sourcePath = "engine://character.fbx";
|
||||||
|
model.primitives = {primitive};
|
||||||
|
model.materials = {material};
|
||||||
|
model.skeleton = skeleton;
|
||||||
|
model.animations = {animation};
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool modelFileReadThrows(const std::vector<std::uint8_t>& bytes)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
(void)ModelFile::Read(bytes);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testModelFileRoundTrip()
|
||||||
|
{
|
||||||
|
const auto bytes = ModelFile::Write(makeTestModelAsset());
|
||||||
|
const auto loaded = ModelFile::Read(bytes);
|
||||||
|
|
||||||
|
check(ModelFile::Write(loaded) == bytes,
|
||||||
|
"a cooked model must write back to exactly the same bytes");
|
||||||
|
|
||||||
|
check(loaded.sourcePath == "engine://character.fbx",
|
||||||
|
"cooked models must keep their source path");
|
||||||
|
|
||||||
|
check(loaded.primitives.size() == 1, "cooked models must keep their primitives");
|
||||||
|
const auto& primitive = loaded.primitives.front();
|
||||||
|
check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned,
|
||||||
|
"cooked primitives must keep their import metadata");
|
||||||
|
check(primitive.mesh.indices == std::vector<std::uint32_t>{0, 1, 2},
|
||||||
|
"cooked meshes must keep their indices");
|
||||||
|
check(primitive.mesh.vertices.size() == 3 &&
|
||||||
|
primitive.mesh.vertices[0].uv_x == 0.25f &&
|
||||||
|
primitive.mesh.vertices[1].tangent.w == -1.0f,
|
||||||
|
"cooked meshes must keep their vertices");
|
||||||
|
check(primitive.mesh.skinningData[0].jointIds.y == 1 &&
|
||||||
|
primitive.mesh.skinningData[0].weights.x == 0.5f,
|
||||||
|
"cooked meshes must keep their skinning data");
|
||||||
|
check(primitive.mesh.skeleton.has_value() &&
|
||||||
|
primitive.mesh.skeleton->jointNames.size() == 2,
|
||||||
|
"cooked skinned meshes must keep their skeleton");
|
||||||
|
|
||||||
|
check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f,
|
||||||
|
"cooked models must keep their materials");
|
||||||
|
const auto& texture = loaded.materials.front().textures.front();
|
||||||
|
check(texture.semantic == ModelDoc::TextureSemantic::BaseColor &&
|
||||||
|
texture.path == "textures/skin.png" &&
|
||||||
|
texture.assimpType == aiTextureType_DIFFUSE &&
|
||||||
|
texture.slot == 1 &&
|
||||||
|
!texture.embedded,
|
||||||
|
"cooked materials must keep their textures");
|
||||||
|
|
||||||
|
check(loaded.skeleton.jointNames == std::vector<std::string>{"root", "spine"} &&
|
||||||
|
loaded.skeleton.parentIndex == std::vector<int>{-1, 0} &&
|
||||||
|
loaded.skeleton.joints[1].localScale == glm::vec3{2.0f},
|
||||||
|
"cooked models must keep their skeleton");
|
||||||
|
|
||||||
|
check(loaded.animations.size() == 1, "cooked models must keep their animations");
|
||||||
|
const auto& animation = loaded.animations.front();
|
||||||
|
check(animation.name == "wave" && !animation.looped && animation.startFrame == 4,
|
||||||
|
"cooked animations must keep their settings");
|
||||||
|
check(animation.tracks.front().keyframes[1].translation.y == 2.0f &&
|
||||||
|
animation.tracks.front().keyframes[1].rotation.x == 1.0f,
|
||||||
|
"cooked animations must keep their keyframes");
|
||||||
|
check(animation.events.at(2) == std::vector<std::string>{"footstep", "dust"},
|
||||||
|
"cooked animations must keep their events");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testModelFileRejectsBadData()
|
||||||
|
{
|
||||||
|
const auto bytes = ModelFile::Write(makeTestModelAsset());
|
||||||
|
|
||||||
|
auto truncated = bytes;
|
||||||
|
truncated.resize(bytes.size() / 2);
|
||||||
|
check(modelFileReadThrows(truncated), "truncated cooked models must be rejected");
|
||||||
|
|
||||||
|
auto wrongMagic = bytes;
|
||||||
|
wrongMagic[0] = 'X';
|
||||||
|
check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected");
|
||||||
|
|
||||||
|
auto wrongVersion = bytes;
|
||||||
|
wrongVersion[4] = static_cast<std::uint8_t>(wrongVersion[4] + 1);
|
||||||
|
check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected");
|
||||||
|
|
||||||
|
auto trailingData = bytes;
|
||||||
|
trailingData.push_back(0);
|
||||||
|
check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool loadModelThrows(std::string_view reference, const ModelDoc::LoadOptions& options)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
(void)AssetManager::GetInstance().LoadModel(reference, options);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testModelFileFromImportedModel()
|
||||||
|
{
|
||||||
|
const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx";
|
||||||
|
const auto imported = ModelDoc::LoadModel(source.generic_string());
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel";
|
||||||
|
ModelFile::WriteToFile(imported, path);
|
||||||
|
const auto loaded = ModelFile::ReadFromFile(path);
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
|
||||||
|
check(!imported.primitives.empty(), "the test model must import with at least one primitive");
|
||||||
|
check(loaded.primitives.size() == imported.primitives.size() &&
|
||||||
|
loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() &&
|
||||||
|
loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices,
|
||||||
|
"an imported model must survive being cooked");
|
||||||
|
check(ModelFile::Write(loaded) == ModelFile::Write(imported),
|
||||||
|
"a cooked imported model must match the import exactly");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAssetManagerLoadsCookedModels()
|
||||||
|
{
|
||||||
|
const auto root = std::filesystem::temp_directory_path() / "destrum_cooked_model_test";
|
||||||
|
const auto engineDir = root / "assets" / "engine";
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
std::filesystem::create_directories(engineDir);
|
||||||
|
std::filesystem::create_directories(root / "assets" / "game");
|
||||||
|
|
||||||
|
// The source can't be imported, so a successful load has to come from the cooked file
|
||||||
|
std::ofstream{engineDir / "model.fbx"} << "not a model";
|
||||||
|
ModelFile::WriteToFile(makeTestModelAsset(), engineDir / "model.fbx.dmodel");
|
||||||
|
std::ofstream{engineDir / "manifest.json"}
|
||||||
|
<< R"({"version":1,"assets":[{"src":"model.fbx","out":"model.fbx.dmodel","type":"model","mtime_epoch_ns":0,"size_bytes":0}]})";
|
||||||
|
|
||||||
|
auto& assetFs = AssetFS::GetInstance();
|
||||||
|
assetFs.Init(root);
|
||||||
|
|
||||||
|
const auto model = AssetManager::GetInstance().LoadModel("engine://model.fbx");
|
||||||
|
check(model.primitives.size() == 1 && model.animations.size() == 1,
|
||||||
|
"cooked models must be loaded instead of importing the source");
|
||||||
|
check(model.sourcePath == "engine://model.fbx" &&
|
||||||
|
model.primitives.front().mesh.assetReference.path == "engine://model.fbx" &&
|
||||||
|
model.primitives.front().mesh.skeleton->assetReference.path == "engine://model.fbx",
|
||||||
|
"cooked models must be referenced by their virtual path");
|
||||||
|
check(model.materials.front().textures.front().path ==
|
||||||
|
std::filesystem::weakly_canonical(engineDir / "textures/skin.png").generic_string(),
|
||||||
|
"cooked texture paths must be resolved next to the cooked model");
|
||||||
|
|
||||||
|
ModelDoc::LoadOptions withoutExtras{};
|
||||||
|
withoutExtras.loadMaterials = false;
|
||||||
|
withoutExtras.loadAnimations = false;
|
||||||
|
const auto stripped = AssetManager::GetInstance().LoadModel("engine://model.fbx", withoutExtras);
|
||||||
|
check(stripped.materials.empty() &&
|
||||||
|
stripped.animations.empty() &&
|
||||||
|
stripped.primitives.front().materialIndex == ModelDoc::InvalidIndex,
|
||||||
|
"cooked models must drop what the load options didn't ask for");
|
||||||
|
|
||||||
|
ModelDoc::LoadOptions withoutSkeleton{};
|
||||||
|
withoutSkeleton.loadSkeleton = false;
|
||||||
|
check(loadModelThrows("engine://model.fbx", withoutSkeleton),
|
||||||
|
"skinned cooked models must be imported when loaded without a skeleton");
|
||||||
|
|
||||||
|
ModelDoc::LoadOptions otherImport{};
|
||||||
|
otherImport.flipUVs = false;
|
||||||
|
check(loadModelThrows("engine://model.fbx", otherImport),
|
||||||
|
"cooked models must be imported when the import options differ from the cook options");
|
||||||
|
|
||||||
|
assetFs.Reset();
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextureFile::Texture makeTestTexture()
|
||||||
|
{
|
||||||
|
const std::vector<float> rgba{
|
||||||
|
0.0f, 0.25f, 0.5f, 1.0f,
|
||||||
|
2.0f, 16.0f, 100.0f, 1.0f,
|
||||||
|
};
|
||||||
|
return TextureFile::FromRGBA32Float(2, 1, rgba);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool textureFileReadThrows(const std::vector<std::uint8_t>& bytes)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
(void)TextureFile::Read(bytes);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testTextureFileRoundTrip()
|
||||||
|
{
|
||||||
|
const auto texture = makeTestTexture();
|
||||||
|
check(texture.pixels.size() == 2 * TextureFile::BytesPerPixel(TextureFile::PixelFormat::RGBA16Float),
|
||||||
|
"cooked textures must store every pixel");
|
||||||
|
|
||||||
|
std::uint64_t secondPixel = 0;
|
||||||
|
std::memcpy(&secondPixel, texture.pixels.data() + 8, sizeof(secondPixel));
|
||||||
|
const glm::vec4 unpacked = glm::unpackHalf4x16(secondPixel);
|
||||||
|
check(unpacked == glm::vec4{2.0f, 16.0f, 100.0f, 1.0f},
|
||||||
|
"half float conversion must keep HDR values above 1");
|
||||||
|
|
||||||
|
const auto bytes = TextureFile::Write(texture);
|
||||||
|
const auto loaded = TextureFile::Read(bytes);
|
||||||
|
check(loaded.width == 2 && loaded.height == 1 &&
|
||||||
|
loaded.format == TextureFile::PixelFormat::RGBA16Float &&
|
||||||
|
loaded.pixels == texture.pixels,
|
||||||
|
"cooked textures must round-trip");
|
||||||
|
|
||||||
|
auto truncated = bytes;
|
||||||
|
truncated.resize(bytes.size() - 1);
|
||||||
|
check(textureFileReadThrows(truncated), "truncated cooked textures must be rejected");
|
||||||
|
|
||||||
|
auto wrongMagic = bytes;
|
||||||
|
wrongMagic[0] = 'X';
|
||||||
|
check(textureFileReadThrows(wrongMagic), "files that aren't cooked textures must be rejected");
|
||||||
|
|
||||||
|
auto wrongSize = bytes;
|
||||||
|
wrongSize[8] = 3;
|
||||||
|
check(textureFileReadThrows(wrongSize), "cooked textures whose size doesn't match their pixels must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testImageLoaderReadsCookedTextures()
|
||||||
|
{
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_texture_test.dtex";
|
||||||
|
const auto texture = makeTestTexture();
|
||||||
|
TextureFile::WriteToFile(texture, path);
|
||||||
|
|
||||||
|
const auto image = util::loadImage(path, TextureIntent::ColorSrgb);
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
|
||||||
|
check(image.vkFormat == VK_FORMAT_R16G16B16A16_SFLOAT &&
|
||||||
|
image.width == 2 && image.height == 1 &&
|
||||||
|
image.byteSize == texture.pixels.size(),
|
||||||
|
"cooked textures must load as half float images");
|
||||||
|
check(image.pixels != nullptr &&
|
||||||
|
std::memcmp(image.pixels, texture.pixels.data(), texture.pixels.size()) == 0,
|
||||||
|
"cooked texture pixels must be uploaded as stored");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAssetReferenceCacheKeys()
|
||||||
|
{
|
||||||
|
const AssetReference reference{
|
||||||
|
.path = "game://CharacterMedium.fbx",
|
||||||
|
.subresource = "mesh:0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto key = reference.cacheKey();
|
||||||
|
check(key == "game://CharacterMedium.fbx#mesh:0",
|
||||||
|
"asset references must produce stable cache keys");
|
||||||
|
|
||||||
|
const auto parsed = AssetReference::fromCacheKey(key);
|
||||||
|
check(parsed.has_value() &&
|
||||||
|
parsed->path == reference.path &&
|
||||||
|
parsed->subresource == reference.subresource,
|
||||||
|
"asset cache keys must recover their source reference");
|
||||||
|
check(!AssetReference::fromCacheKey("CharacterMedium.fbx").has_value(),
|
||||||
|
"plain cache names must not be treated as file-backed assets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunAssetsTests()
|
||||||
|
{
|
||||||
|
testAssetPathValidation();
|
||||||
|
testAnimatorAssetReferences();
|
||||||
|
testModelFileRoundTrip();
|
||||||
|
testModelFileRejectsBadData();
|
||||||
|
testModelFileFromImportedModel();
|
||||||
|
testAssetManagerLoadsCookedModels();
|
||||||
|
testTextureFileRoundTrip();
|
||||||
|
testImageLoaderReadsCookedTextures();
|
||||||
|
testAssetReferenceCacheKeys();
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
#include <filesystem>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <destrum/Event.h>
|
||||||
|
#include <destrum/ObjectModel/Component.h>
|
||||||
|
#include <destrum/ObjectModel/GameObject.h>
|
||||||
|
#include <destrum/Scene/Scene.h>
|
||||||
|
#include <destrum/Scene/SceneManager.h>
|
||||||
|
#include <destrum/Serialization/ComponentFactory.h>
|
||||||
|
#include <destrum/Serialization/ComponentRegistry.h>
|
||||||
|
#include <destrum/Serialization/GlmJson.h>
|
||||||
|
#include <destrum/Serialization/SceneSerializer.h>
|
||||||
|
#include <destrum/Components/Physics/SphereCollider.h>
|
||||||
|
#include <destrum/Components/Physics/BoxCollider.h>
|
||||||
|
#include <destrum/Components/Physics/CapsuleCollider.h>
|
||||||
|
#include <destrum/Components/Physics/Collider.h>
|
||||||
|
#include <destrum/Components/Animator.h>
|
||||||
|
#include <destrum/Components/CameraComponent.h>
|
||||||
|
#include <destrum/Components/UI/Canvas.h>
|
||||||
|
#include <destrum/Components/UI/RectTransform.h>
|
||||||
|
#include <destrum/Components/UI/UIImage.h>
|
||||||
|
#include <destrum/Components/UI/UIText.h>
|
||||||
|
#include <destrum/Components/LightComponent.h>
|
||||||
|
#include <destrum/Components/Physics/Rigidbody.h>
|
||||||
|
|
||||||
|
#include "TestUtils.h"
|
||||||
|
|
||||||
|
using namespace test;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
class TestComponent final : public Component {
|
||||||
|
public:
|
||||||
|
explicit TestComponent(GameObject& owner) : Component(owner, "TestComponent") {}
|
||||||
|
|
||||||
|
void Update(float) override {}
|
||||||
|
std::string GetTypeName() const override { return "TestComponent"; }
|
||||||
|
};
|
||||||
|
|
||||||
|
class RemovingListener final : public EventListener {
|
||||||
|
public:
|
||||||
|
explicit RemovingListener(std::unique_ptr<RemovingListener>* victim)
|
||||||
|
: victim(victim) {}
|
||||||
|
|
||||||
|
void OnEvent(int) {
|
||||||
|
if (victim != nullptr) {
|
||||||
|
victim->reset();
|
||||||
|
victim = nullptr;
|
||||||
|
}
|
||||||
|
++calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
int calls{0};
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unique_ptr<RemovingListener>* victim;
|
||||||
|
};
|
||||||
|
|
||||||
|
void testTransforms()
|
||||||
|
{
|
||||||
|
GameObject parent{"parent"};
|
||||||
|
GameObject child{"child"};
|
||||||
|
|
||||||
|
parent.GetTransform().SetLocalPosition({10.f, 2.f, -4.f});
|
||||||
|
parent.GetTransform().SetLocalRotation({0.f, 0.f, 90.f});
|
||||||
|
parent.GetTransform().SetLocalScale({2.f, 2.f, 2.f});
|
||||||
|
child.GetTransform().SetLocalPosition({1.f, 0.f, 0.f});
|
||||||
|
|
||||||
|
child.GetTransform().SetParent(&parent.GetTransform(), true);
|
||||||
|
const auto worldPosition = child.GetTransform().GetWorldPosition();
|
||||||
|
check(close(worldPosition.x, 1.f) && close(worldPosition.y, 0.f),
|
||||||
|
"reparenting must preserve world position");
|
||||||
|
|
||||||
|
child.GetTransform().SetWorldPosition({20.f, 10.f, 0.f});
|
||||||
|
const auto movedPosition = child.GetTransform().GetWorldPosition();
|
||||||
|
check(close(movedPosition.x, 20.f) && close(movedPosition.y, 10.f),
|
||||||
|
"world position must account for parent rotation and scale");
|
||||||
|
|
||||||
|
parent.Destroy();
|
||||||
|
check(parent.IsBeingDestroyed() && child.IsBeingDestroyed(),
|
||||||
|
"destroying a parent must mark descendants");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneFindGameObject()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("find-object");
|
||||||
|
GameObject* pending = scene.CreateGameObject("pending");
|
||||||
|
check(scene.FindGameObject(pending->GetId()) == pending, "objects must be found while still pending");
|
||||||
|
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
GameObject* committed = scene.CreateGameObject("committed");
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
check(scene.FindGameObject(committed->GetId()) == committed, "committed objects must be found by id");
|
||||||
|
check(scene.FindGameObject(InvalidObjectId) == nullptr, "the invalid id must never match");
|
||||||
|
|
||||||
|
const ObjectId destroyedId = committed->GetId();
|
||||||
|
committed->Destroy();
|
||||||
|
check(scene.FindGameObject(destroyedId) == nullptr, "objects being destroyed must not be found");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testEventMutation()
|
||||||
|
{
|
||||||
|
Event<int> event;
|
||||||
|
int calls = 0;
|
||||||
|
event.AddListener([&](int) { ++calls; });
|
||||||
|
event.AddListener([&](int) {
|
||||||
|
++calls;
|
||||||
|
event.AddListener([&](int) { ++calls; });
|
||||||
|
});
|
||||||
|
|
||||||
|
event.Invoke(1);
|
||||||
|
check(calls == 2, "event mutation must not invalidate the current invocation");
|
||||||
|
event.Invoke(1);
|
||||||
|
check(calls == 5, "new event listeners must run on subsequent invocations");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testEventRemoval()
|
||||||
|
{
|
||||||
|
Event<int> event;
|
||||||
|
std::unique_ptr<RemovingListener> victim;
|
||||||
|
RemovingListener remover{&victim};
|
||||||
|
victim = std::make_unique<RemovingListener>(nullptr);
|
||||||
|
|
||||||
|
event.AddListener(&remover, &RemovingListener::OnEvent);
|
||||||
|
event.AddListener(victim.get(), &RemovingListener::OnEvent);
|
||||||
|
event.Invoke(1);
|
||||||
|
|
||||||
|
check(remover.calls == 1, "event remover must run once");
|
||||||
|
check(victim == nullptr, "event listener must be removable during invocation");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testComponentRemoval()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("component-removal");
|
||||||
|
GameObject* object = scene.CreateGameObject("object");
|
||||||
|
object->AddComponent<TestComponent>();
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
check(object->GetComponent<TestComponent>() != nullptr,
|
||||||
|
"test component must be discoverable before removal");
|
||||||
|
check(object->DestroyComponent<TestComponent>() != nullptr,
|
||||||
|
"destroying a component must find it");
|
||||||
|
scene.CleanupDestroyedGameObjects();
|
||||||
|
check(object->GetComponent<TestComponent>() == nullptr,
|
||||||
|
"destroyed components must be removed at the scene boundary");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testEngineComponentRegistration()
|
||||||
|
{
|
||||||
|
RegisterEngineComponents();
|
||||||
|
|
||||||
|
const char* canonicalNames[] = {
|
||||||
|
"MeshRendererComponent",
|
||||||
|
"LightComponent",
|
||||||
|
"Rotator",
|
||||||
|
"Spinner",
|
||||||
|
"OrbitAndSpin",
|
||||||
|
"Animator",
|
||||||
|
"Rigidbody",
|
||||||
|
"BoxCollider",
|
||||||
|
"SphereCollider",
|
||||||
|
"CapsuleCollider",
|
||||||
|
"Canvas",
|
||||||
|
"RectTransform",
|
||||||
|
"UIImage",
|
||||||
|
"UIText"
|
||||||
|
};
|
||||||
|
for (const char* name : canonicalNames) {
|
||||||
|
check(ComponentFactory::IsRegistered(name),
|
||||||
|
"all canonical engine component names must be registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
check(!ComponentFactory::IsRegistered("MeshRenderer"),
|
||||||
|
"MeshRenderer legacy alias must not be registered");
|
||||||
|
check(!ComponentFactory::IsRegistered("RigidBody"),
|
||||||
|
"RigidBody legacy alias must not be registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneCurrentCamera()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("cameras");
|
||||||
|
check(scene.GetCurrentCamera() == nullptr, "a scene without cameras must have no current camera");
|
||||||
|
|
||||||
|
GameObject* first = scene.CreateGameObject("first camera");
|
||||||
|
auto* firstCamera = first->AddComponent<CameraComponent>();
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
check(scene.GetCurrentCamera() == firstCamera, "the only camera in a scene must be the current camera");
|
||||||
|
|
||||||
|
GameObject* second = scene.CreateGameObject("second camera");
|
||||||
|
auto* secondCamera = second->AddComponent<CameraComponent>();
|
||||||
|
secondCamera->SetFieldOfView(60.0f);
|
||||||
|
second->GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f});
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
secondCamera->MakeCurrent();
|
||||||
|
check(scene.GetCurrentCamera() == secondCamera && !firstCamera->IsCurrent(),
|
||||||
|
"the selected camera must be the current camera");
|
||||||
|
|
||||||
|
const Camera& camera = secondCamera->GetCamera(16.0f / 9.0f);
|
||||||
|
check(camera.GetPosition() == glm::vec3{1.0f, 2.0f, 3.0f} &&
|
||||||
|
close(camera.GetForward().z, -1.0f) && camera.GetFOVY() == 60.0f,
|
||||||
|
"a camera component must place its camera at its transform, looking down -Z");
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_camera_scene_test.json";
|
||||||
|
check(SceneSerializer::Save(scene, path), "scene with cameras must save");
|
||||||
|
check(SceneSerializer::Load(scene, path), "scene with cameras must load");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
|
||||||
|
const CameraComponent* loadedCamera = scene.GetCurrentCamera();
|
||||||
|
check(loadedCamera != nullptr && loadedCamera->GetGameObject()->GetName() == "second camera" &&
|
||||||
|
loadedCamera->GetFieldOfView() == 60.0f,
|
||||||
|
"scene round-trip must preserve the current camera");
|
||||||
|
|
||||||
|
scene.GetCurrentCamera()->GetGameObject()->SetActive(false);
|
||||||
|
check(scene.GetCurrentCamera() != nullptr &&
|
||||||
|
scene.GetCurrentCamera()->GetGameObject()->GetName() == "first camera",
|
||||||
|
"an inactive current camera must fall back to another camera");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testHierarchyMoves()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("hierarchy");
|
||||||
|
GameObject* parent = scene.CreateGameObject("parent");
|
||||||
|
GameObject* a = scene.CreateGameObject("a");
|
||||||
|
GameObject* b = scene.CreateGameObject("b");
|
||||||
|
GameObject* c = scene.CreateGameObject("c");
|
||||||
|
GameObject* other = scene.CreateGameObject("other");
|
||||||
|
for (GameObject* child : {a, b, c}) {
|
||||||
|
child->GetTransform().SetParent(&parent->GetTransform(), false);
|
||||||
|
}
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
const auto childNames = [](const GameObject& object) {
|
||||||
|
std::string names;
|
||||||
|
for (const Transform* child : object.GetTransform().GetChildren()) {
|
||||||
|
names += child->GetOwner()->GetName();
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
|
||||||
|
check(scene.MoveInHierarchy(*c, parent, a) && childNames(*parent) == "cab",
|
||||||
|
"moving before a sibling must reorder the children");
|
||||||
|
check(scene.MoveInHierarchy(*c, parent) && childNames(*parent) == "abc",
|
||||||
|
"moving without a sibling must put the object last");
|
||||||
|
check(scene.MoveInHierarchy(*a, other) && childNames(*parent) == "bc" && childNames(*other) == "a",
|
||||||
|
"moving onto another object must reparent it");
|
||||||
|
check(!scene.MoveInHierarchy(*other, a) && a->GetTransform().GetParent() == &other->GetTransform(),
|
||||||
|
"an object must not be moved under its own child");
|
||||||
|
check(scene.MoveInHierarchy(*a, nullptr, parent) && a->GetTransform().GetParent() == nullptr &&
|
||||||
|
scene.GetObjects().front().get() == a,
|
||||||
|
"moving to the root must unparent and order the roots");
|
||||||
|
check(scene.MoveInHierarchy(*c, parent, b) && childNames(*parent) == "cb", "reordering must still work");
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_hierarchy_scene.json";
|
||||||
|
check(SceneSerializer::Save(scene, path), "the hierarchy scene must save");
|
||||||
|
Scene& copy = SceneManager::GetInstance().CreateScene("hierarchy copy");
|
||||||
|
check(SceneSerializer::Load(copy, path), "the hierarchy scene must load");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
copy.CommitPendingAdditions();
|
||||||
|
const GameObject* loadedParent = findByName(copy, "parent");
|
||||||
|
check(loadedParent != nullptr && childNames(*loadedParent) == "cb", "sibling order must survive a save");
|
||||||
|
check(copy.GetObjects().front()->GetName() == "a", "root order must survive a save");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunCoreTests()
|
||||||
|
{
|
||||||
|
testTransforms();
|
||||||
|
testSceneFindGameObject();
|
||||||
|
testEventMutation();
|
||||||
|
testEventRemoval();
|
||||||
|
testComponentRemoval();
|
||||||
|
testEngineComponentRegistration();
|
||||||
|
testSceneCurrentCamera();
|
||||||
|
testHierarchyMoves();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
|||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <destrum/ObjectModel/Component.h>
|
||||||
|
#include <destrum/ObjectModel/GameObject.h>
|
||||||
|
#include <destrum/Scene/Scene.h>
|
||||||
|
#include <destrum/Scene/SceneManager.h>
|
||||||
|
#include <destrum/Serialization/ComponentFactory.h>
|
||||||
|
#include <destrum/Serialization/GlmJson.h>
|
||||||
|
#include <destrum/Serialization/PrefabCache.h>
|
||||||
|
#include <destrum/Serialization/PrefabSerializer.h>
|
||||||
|
#include <destrum/Serialization/SceneSerializer.h>
|
||||||
|
#include <destrum/Components/Physics/CapsuleCollider.h>
|
||||||
|
#include <destrum/Components/Physics/Collider.h>
|
||||||
|
#include <destrum/Components/Physics/Rigidbody.h>
|
||||||
|
|
||||||
|
#include <components/Activatable.h>
|
||||||
|
#include <components/InteractableButton.h>
|
||||||
|
#include <components/Checkpoint.h>
|
||||||
|
#include <components/PlayerRespawn.h>
|
||||||
|
#include <components/WinGoal.h>
|
||||||
|
|
||||||
|
#include "TestUtils.h"
|
||||||
|
|
||||||
|
using namespace test;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
class TestActivatable final : public Activatable {
|
||||||
|
public:
|
||||||
|
explicit TestActivatable(GameObject& owner) : Activatable(owner, "TestActivatable") {}
|
||||||
|
|
||||||
|
std::string GetTypeName() const override { return "TestActivatable"; }
|
||||||
|
|
||||||
|
int activations{0};
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void OnActivate(GameObject&) override { ++activations; }
|
||||||
|
};
|
||||||
|
|
||||||
|
void testButtonTargetsRoundTrip()
|
||||||
|
{
|
||||||
|
ComponentFactory::Register<InteractableButton>("InteractableButton");
|
||||||
|
ComponentFactory::Register<TestActivatable>("TestActivatable");
|
||||||
|
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("button targets");
|
||||||
|
GameObject* door = scene.CreateGameObject("door");
|
||||||
|
door->AddComponent<TestActivatable>();
|
||||||
|
GameObject* lamp = scene.CreateGameObject("lamp");
|
||||||
|
lamp->AddComponent<TestActivatable>();
|
||||||
|
GameObject* buttonObject = scene.CreateGameObject("button");
|
||||||
|
auto* button = buttonObject->AddComponent<InteractableButton>();
|
||||||
|
button->SetCycleColors(false);
|
||||||
|
button->AddTarget(*door);
|
||||||
|
button->AddTarget(*lamp);
|
||||||
|
button->AddTarget(*door);
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
check(button->GetTargets().size() == 2, "a button must not target the same object twice");
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_button_targets_scene.json";
|
||||||
|
check(SceneSerializer::Save(scene, path), "the button scene must save");
|
||||||
|
check(SceneSerializer::Load(scene, path), "the button scene must load");
|
||||||
|
|
||||||
|
Scene& copy = SceneManager::GetInstance().CreateScene("button targets copy");
|
||||||
|
check(SceneSerializer::Load(copy, path), "the button scene must load into a second scene");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
|
||||||
|
for (Scene* loaded : {&scene, ©}) {
|
||||||
|
loaded->CommitPendingAdditions();
|
||||||
|
GameObject* loadedButton = findByName(*loaded, "button");
|
||||||
|
auto* loadedComponent = loadedButton != nullptr ? loadedButton->GetComponent<InteractableButton>() : nullptr;
|
||||||
|
check(loadedComponent != nullptr, "the button must survive a round-trip");
|
||||||
|
|
||||||
|
const auto& targets = loadedComponent->GetTargets();
|
||||||
|
check(targets.size() == 2, "button targets must survive a round-trip");
|
||||||
|
GameObject* loadedDoor = targets[0].Get(*loaded);
|
||||||
|
GameObject* loadedLamp = targets[1].Get(*loaded);
|
||||||
|
check(loadedDoor == findByName(*loaded, "door") && loadedDoor != nullptr &&
|
||||||
|
loadedLamp == findByName(*loaded, "lamp") && loadedLamp != nullptr,
|
||||||
|
"button targets must point at the loaded objects in their own scene");
|
||||||
|
|
||||||
|
loadedComponent->Interact(*loadedButton);
|
||||||
|
check(loadedDoor->GetComponent<TestActivatable>()->activations == 1 &&
|
||||||
|
loadedLamp->GetComponent<TestActivatable>()->activations == 1,
|
||||||
|
"pressing a loaded button must activate its targets");
|
||||||
|
}
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testButtonDropsMissingTargets()
|
||||||
|
{
|
||||||
|
ComponentFactory::Register<InteractableButton>("InteractableButton");
|
||||||
|
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("missing targets");
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_missing_targets_scene.json";
|
||||||
|
std::ofstream{path}
|
||||||
|
<< R"({"version":2,"objects":[{"id":5,"name":"button","components":)"
|
||||||
|
R"([{"type":"InteractableButton","data":{"cycleColors":false,"targets":[5,99]}}]}]})";
|
||||||
|
|
||||||
|
check(SceneSerializer::Load(scene, path), "a button with a dangling target must still load");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
const GameObject* object = scene.GetObjects().front().get();
|
||||||
|
const auto* button = object->GetComponent<InteractableButton>();
|
||||||
|
check(button != nullptr && button->GetTargets().size() == 1 &&
|
||||||
|
button->GetTargets().front().GetId() == object->GetId(),
|
||||||
|
"dangling targets must be dropped and valid ones remapped");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
GameObject* buildTurret(Scene& scene, const GameObject& door)
|
||||||
|
{
|
||||||
|
GameObject* turret = scene.CreateGameObject("turret");
|
||||||
|
turret->GetTransform().SetLocalPosition(glm::vec3{5.0f, 0.0f, 0.0f});
|
||||||
|
GameObject* barrel = scene.CreateGameObject("barrel");
|
||||||
|
barrel->GetTransform().SetParent(&turret->GetTransform(), false);
|
||||||
|
barrel->GetTransform().SetLocalPosition(glm::vec3{0.0f, 1.0f, 0.0f});
|
||||||
|
barrel->AddComponent<TestActivatable>();
|
||||||
|
|
||||||
|
auto* button = turret->AddComponent<InteractableButton>();
|
||||||
|
button->SetCycleColors(false);
|
||||||
|
button->AddTarget(*barrel);
|
||||||
|
button->AddTarget(door);
|
||||||
|
return turret;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameObject* onlyChild(GameObject& object)
|
||||||
|
{
|
||||||
|
const auto& children = object.GetTransform().GetChildren();
|
||||||
|
return children.size() == 1 ? children.front()->GetOwner() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testPrefabRoundTrip()
|
||||||
|
{
|
||||||
|
ComponentFactory::Register<InteractableButton>("InteractableButton");
|
||||||
|
ComponentFactory::Register<TestActivatable>("TestActivatable");
|
||||||
|
|
||||||
|
Scene& source = SceneManager::GetInstance().CreateScene("prefab source");
|
||||||
|
GameObject* door = source.CreateGameObject("door");
|
||||||
|
door->AddComponent<TestActivatable>();
|
||||||
|
GameObject* turret = buildTurret(source, *door);
|
||||||
|
source.CommitPendingAdditions();
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_turret.prefab";
|
||||||
|
check(PrefabSerializer::Save(*turret, path), "a hierarchy must save as a prefab");
|
||||||
|
|
||||||
|
Scene& target = SceneManager::GetInstance().CreateScene("prefab target");
|
||||||
|
GameObject* holder = target.CreateGameObject("holder");
|
||||||
|
GameObject* first = PrefabSerializer::Instantiate(target, path.string());
|
||||||
|
GameObject* second = PrefabSerializer::Instantiate(target, path.string(), &holder->GetTransform());
|
||||||
|
target.CommitPendingAdditions();
|
||||||
|
check(first != nullptr && second != nullptr, "a saved prefab must instantiate");
|
||||||
|
check(target.GetObjects().size() == 5, "each instance must create the root and its child");
|
||||||
|
check(first != second && first->GetId() != turret->GetId(), "instances must get fresh objects");
|
||||||
|
check(first->GetName() == "turret" && first->GetTransform().GetParent() == nullptr,
|
||||||
|
"an instance without a parent must be a scene root");
|
||||||
|
check(second->GetTransform().GetParent() == &holder->GetTransform(),
|
||||||
|
"an instance must be parented to the given transform");
|
||||||
|
check(close(first->GetTransform().GetLocalPosition().x, 5.0f),
|
||||||
|
"the root transform must come from the prefab");
|
||||||
|
|
||||||
|
for (GameObject* instance : {first, second}) {
|
||||||
|
GameObject* barrel = onlyChild(*instance);
|
||||||
|
check(barrel != nullptr && barrel->GetName() == "barrel" &&
|
||||||
|
close(barrel->GetTransform().GetLocalPosition().y, 1.0f),
|
||||||
|
"prefab children must keep their local transform");
|
||||||
|
|
||||||
|
const auto* button = instance->GetComponent<InteractableButton>();
|
||||||
|
check(button != nullptr && button->GetTargets().size() == 1 &&
|
||||||
|
button->GetTargets().front().Get(target) == barrel,
|
||||||
|
"references inside the prefab must point at the instance's own objects, outside ones are dropped");
|
||||||
|
}
|
||||||
|
|
||||||
|
check(PrefabCache::GetInstance().Get(path.string()) == PrefabCache::GetInstance().Get(path.string()),
|
||||||
|
"a cached prefab must be parsed once");
|
||||||
|
const auto before = PrefabCache::GetInstance().Get(path.string());
|
||||||
|
turret->SetName("tower");
|
||||||
|
check(PrefabSerializer::Save(*turret, path), "a prefab must save over an existing file");
|
||||||
|
const auto after = PrefabCache::GetInstance().Get(path.string());
|
||||||
|
check(after != before && after->at("name") == "tower", "saving a prefab must refresh the cache");
|
||||||
|
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
check(PrefabSerializer::Instantiate(target, path.string()) == nullptr,
|
||||||
|
"instantiating a missing prefab must fail");
|
||||||
|
|
||||||
|
PrefabCache::GetInstance().Clear();
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testPrefabDuplicate()
|
||||||
|
{
|
||||||
|
ComponentFactory::Register<InteractableButton>("InteractableButton");
|
||||||
|
ComponentFactory::Register<TestActivatable>("TestActivatable");
|
||||||
|
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("duplicate");
|
||||||
|
GameObject* door = scene.CreateGameObject("door");
|
||||||
|
door->AddComponent<TestActivatable>();
|
||||||
|
GameObject* holder = scene.CreateGameObject("holder");
|
||||||
|
GameObject* turret = buildTurret(scene, *door);
|
||||||
|
turret->GetTransform().SetParent(&holder->GetTransform(), false);
|
||||||
|
|
||||||
|
GameObject* copy = PrefabSerializer::Duplicate(*turret);
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
check(copy != nullptr && copy != turret && copy->GetName() == "turret",
|
||||||
|
"duplicate must create a new object");
|
||||||
|
check(copy->GetTransform().GetParent() == &holder->GetTransform(),
|
||||||
|
"a duplicate must keep the original's parent");
|
||||||
|
|
||||||
|
GameObject* barrel = onlyChild(*copy);
|
||||||
|
check(barrel != nullptr && barrel != onlyChild(*turret), "a duplicate must copy children");
|
||||||
|
|
||||||
|
const auto* button = copy->GetComponent<InteractableButton>();
|
||||||
|
check(button != nullptr && button->GetTargets().size() == 2 &&
|
||||||
|
button->GetTargets()[0].Get(scene) == barrel &&
|
||||||
|
button->GetTargets()[1].Get(scene) == door,
|
||||||
|
"a duplicate must remap inner references and keep outer ones");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testPrefabValidation()
|
||||||
|
{
|
||||||
|
using nlohmann::json;
|
||||||
|
check(PrefabSerializer::Validate(json::object()).has_value(), "an empty prefab must be rejected");
|
||||||
|
|
||||||
|
const json twoRoots = json::parse(R"({"version":1,"objects":[{"id":1},{"id":2}]})");
|
||||||
|
check(PrefabSerializer::Validate(twoRoots).has_value(), "a prefab with two roots must be rejected");
|
||||||
|
|
||||||
|
const json childFirst = json::parse(R"({"version":1,"objects":[{"id":2,"parent":1},{"id":1}]})");
|
||||||
|
check(PrefabSerializer::Validate(childFirst).has_value(), "the prefab root must come first");
|
||||||
|
|
||||||
|
const json valid = json::parse(R"({"version":1,"objects":[{"id":1},{"id":2,"parent":1}]})");
|
||||||
|
check(!PrefabSerializer::Validate(valid).has_value(), "a single-root prefab must validate");
|
||||||
|
|
||||||
|
const json future = json::parse(R"({"version":99,"objects":[{"id":1}]})");
|
||||||
|
check(PrefabSerializer::Validate(future).has_value(), "an unknown prefab version must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testWinGoalButton()
|
||||||
|
{
|
||||||
|
ComponentFactory::Register<InteractableButton>("InteractableButton");
|
||||||
|
ComponentFactory::Register<WinGoal>("WinGoal");
|
||||||
|
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("win goal");
|
||||||
|
GameObject* goal = scene.CreateGameObject("goal");
|
||||||
|
auto* winGoal = goal->AddComponent<WinGoal>();
|
||||||
|
GameObject* buttonObject = scene.CreateGameObject("button");
|
||||||
|
auto* button = buttonObject->AddComponent<InteractableButton>();
|
||||||
|
button->SetCycleColors(false);
|
||||||
|
button->AddTarget(*goal);
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
check(!winGoal->IsReached(), "the goal must start unreached");
|
||||||
|
button->Interact(*buttonObject);
|
||||||
|
check(winGoal->IsReached(), "pressing the win button must reach the goal");
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_win_goal_scene.json";
|
||||||
|
check(SceneSerializer::Save(scene, path), "the win goal scene must save");
|
||||||
|
check(SceneSerializer::Load(scene, path), "the win goal scene must load");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
const GameObject* loadedGoal = findByName(scene, "goal");
|
||||||
|
check(loadedGoal != nullptr && loadedGoal->GetComponent<WinGoal>()->IsReached(),
|
||||||
|
"a reached goal must stay reached after a round-trip");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testCheckpointRespawn()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("respawn");
|
||||||
|
GameObject* player = scene.CreateGameObject("player");
|
||||||
|
player->GetTransform().SetWorldPosition(glm::vec3{1.0f, 2.0f, 3.0f});
|
||||||
|
player->AddComponent<CapsuleCollider>(0.4f, 1.8f);
|
||||||
|
auto* rigidbody = player->AddComponent<Rigidbody>();
|
||||||
|
auto* respawn = player->AddComponent<PlayerRespawn>();
|
||||||
|
respawn->SetKillHeight(-10.0f);
|
||||||
|
|
||||||
|
GameObject* checkpointObject = scene.CreateGameObject("checkpoint");
|
||||||
|
checkpointObject->GetTransform().SetWorldPosition(glm::vec3{-5.0f, 4.0f, 0.0f});
|
||||||
|
auto* checkpoint = checkpointObject->AddComponent<Checkpoint>();
|
||||||
|
checkpoint->SetSpawnOffset({0.0f, 0.5f, 0.0f});
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
check(rigidbody->HasPhysicsBody(), "the player must have a physics body");
|
||||||
|
|
||||||
|
respawn->Start();
|
||||||
|
check(respawn->GetRespawnPoint() == glm::vec3(1.0f, 2.0f, 3.0f),
|
||||||
|
"the respawn point must default to where the player starts");
|
||||||
|
|
||||||
|
checkpoint->OnTriggerEnter(checkpointObject);
|
||||||
|
check(respawn->GetRespawnPoint() == glm::vec3(1.0f, 2.0f, 3.0f),
|
||||||
|
"a checkpoint must ignore objects without PlayerRespawn");
|
||||||
|
checkpoint->OnTriggerEnter(player);
|
||||||
|
check(respawn->GetRespawnPoint() == glm::vec3(-5.0f, 4.5f, 0.0f),
|
||||||
|
"touching a checkpoint must move the respawn point");
|
||||||
|
|
||||||
|
rigidbody->Teleport(glm::vec3{0.0f, -20.0f, 0.0f});
|
||||||
|
rigidbody->SetLinearVelocity(glm::vec3{0.0f, -30.0f, 0.0f});
|
||||||
|
respawn->FixedUpdate(1.0f / 60.0f);
|
||||||
|
const glm::vec3 position = player->GetTransform().GetWorldPosition();
|
||||||
|
check(close(position.x, -5.0f) && close(position.y, 4.5f) && close(position.z, 0.0f),
|
||||||
|
"falling below the kill height must respawn at the checkpoint");
|
||||||
|
check(glm::length(rigidbody->GetLinearVelocity()) < 0.001f, "respawning must stop the fall");
|
||||||
|
|
||||||
|
scene.GetPhysics().FixedUpdate(1.0f / 60.0f);
|
||||||
|
check(player->GetTransform().GetWorldPosition().y > 4.0f,
|
||||||
|
"the physics body must have moved with the teleport");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunGameplayTests()
|
||||||
|
{
|
||||||
|
testButtonTargetsRoundTrip();
|
||||||
|
testButtonDropsMissingTargets();
|
||||||
|
testPrefabRoundTrip();
|
||||||
|
testPrefabDuplicate();
|
||||||
|
testPrefabValidation();
|
||||||
|
testWinGoalButton();
|
||||||
|
testCheckpointRespawn();
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <glm/gtc/constants.hpp>
|
||||||
|
#include <destrum/ObjectModel/Component.h>
|
||||||
|
#include <destrum/ObjectModel/GameObject.h>
|
||||||
|
#include <destrum/Components/Physics/CapsuleCollider.h>
|
||||||
|
#include <destrum/Components/Physics/Collider.h>
|
||||||
|
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
|
||||||
|
#include <destrum/Graphics/Managers/Renderer2D.h>
|
||||||
|
#include <destrum/Graphics/Resources/Font.h>
|
||||||
|
#include <glm/gtc/matrix_transform.hpp>
|
||||||
|
#include <destrum/Graphics/Frustum.h>
|
||||||
|
#include <destrum/Graphics/Material.h>
|
||||||
|
#include <destrum/Graphics/Managers/LineRenderingManager.h>
|
||||||
|
#include <destrum/Graphics/DeletionQueue.h>
|
||||||
|
#include <destrum/Graphics/IndirectDrawList.h>
|
||||||
|
|
||||||
|
#include "TestUtils.h"
|
||||||
|
|
||||||
|
using namespace test;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void testLineRenderingManager()
|
||||||
|
{
|
||||||
|
auto& lineManager = LineRenderingManager::GetInstance();
|
||||||
|
lineManager.ClearLines();
|
||||||
|
|
||||||
|
lineManager.SubmitLine(
|
||||||
|
glm::vec3{0.0f},
|
||||||
|
glm::vec3{1.0f, 0.0f, 0.0f},
|
||||||
|
glm::vec4{1.0f, 0.0f, 0.0f, 1.0f});
|
||||||
|
lineManager.SubmitLine(Line{
|
||||||
|
.start = glm::vec3{1.0f},
|
||||||
|
.end = glm::vec3{2.0f},
|
||||||
|
.color = glm::vec4{0.0f, 1.0f, 0.0f, 1.0f},
|
||||||
|
});
|
||||||
|
|
||||||
|
check(lineManager.GetLines().size() == 2,
|
||||||
|
"line rendering manager must retain submitted lines");
|
||||||
|
|
||||||
|
lineManager.ClearLines();
|
||||||
|
check(lineManager.GetLines().empty(),
|
||||||
|
"line rendering manager must clear transient lines");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
r2d.Clear();
|
||||||
|
|
||||||
|
r2d.DrawRect({10.0f, 20.0f}, {30.0f, 40.0f}, glm::vec4{0.5f});
|
||||||
|
const Shape2D& rect = r2d.GetShapes().back();
|
||||||
|
check(rect.type == Shape2DType::Rect &&
|
||||||
|
rect.center == glm::vec2{25.0f, 40.0f} &&
|
||||||
|
rect.halfSize == glm::vec2{15.0f, 20.0f} &&
|
||||||
|
rect.textureId == NULL_IMAGE_ID &&
|
||||||
|
rect.outline == 0.0f,
|
||||||
|
"rects must be anchored at their top left corner and filled by default");
|
||||||
|
|
||||||
|
r2d.DrawEllipseOutline({50.0f, 50.0f}, {20.0f, 10.0f}, 2.0f, glm::vec4{1.0f});
|
||||||
|
const Shape2D& ellipse = r2d.GetShapes().back();
|
||||||
|
check(ellipse.type == Shape2DType::Ellipse &&
|
||||||
|
ellipse.center == glm::vec2{50.0f} &&
|
||||||
|
ellipse.halfSize == glm::vec2{20.0f, 10.0f} &&
|
||||||
|
ellipse.outline == 2.0f,
|
||||||
|
"ellipses must be centered with their radii as half size");
|
||||||
|
|
||||||
|
r2d.DrawTriangle({0.0f, 10.0f}, {5.0f, 0.0f}, {10.0f, 10.0f}, glm::vec4{1.0f});
|
||||||
|
const Shape2D& triangle = r2d.GetShapes().back();
|
||||||
|
check(triangle.type == Shape2DType::Triangle &&
|
||||||
|
triangle.center == glm::vec2{5.0f} &&
|
||||||
|
triangle.halfSize == glm::vec2{5.0f} &&
|
||||||
|
triangle.p0 == glm::vec2{-5.0f, 5.0f} &&
|
||||||
|
triangle.p1 == glm::vec2{0.0f, -5.0f} &&
|
||||||
|
triangle.p2 == glm::vec2{5.0f, 5.0f},
|
||||||
|
"triangle points must be stored relative to their bounding box center");
|
||||||
|
|
||||||
|
r2d.DrawLine({0.0f, 0.0f}, {0.0f, 10.0f}, 4.0f, glm::vec4{1.0f}, LineCap::Round);
|
||||||
|
const Shape2D& line = r2d.GetShapes().back();
|
||||||
|
check(line.type == Shape2DType::Rect &&
|
||||||
|
line.center == glm::vec2{0.0f, 5.0f} &&
|
||||||
|
close(line.halfSize.x, 7.0f) && close(line.halfSize.y, 2.0f) &&
|
||||||
|
close(line.rotation, glm::half_pi<float>()) &&
|
||||||
|
close(line.cornerRadius, 2.0f),
|
||||||
|
"round capped lines must be rotated rects extended by half their thickness");
|
||||||
|
|
||||||
|
check(r2d.GetShapes().size() == 4, "every draw call must submit exactly one shape");
|
||||||
|
r2d.Clear();
|
||||||
|
check(r2d.GetShapes().empty(), "clear must drop all submitted shapes");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testFontAtlasAndLayout()
|
||||||
|
{
|
||||||
|
const std::filesystem::path fontPath =
|
||||||
|
std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "fonts" / "Roboto-Medium.ttf";
|
||||||
|
const Font font = Font::LoadFromFile(fontPath, 32.0f);
|
||||||
|
|
||||||
|
const FontGlyph* a = font.GetGlyph(U'A');
|
||||||
|
const FontGlyph* space = font.GetGlyph(U' ');
|
||||||
|
check(a != nullptr && a->size.x > 0.0f && a->size.y > 0.0f && a->advance > 0.0f &&
|
||||||
|
a->uvMax.x > a->uvMin.x && a->uvMax.y > a->uvMin.y,
|
||||||
|
"printable glyphs must be rasterized into the atlas");
|
||||||
|
check(space != nullptr && space->size == glm::vec2{0.0f} && space->advance > 0.0f,
|
||||||
|
"spaces must only advance the pen");
|
||||||
|
check(font.GetGlyph(U'中') == font.GetGlyph(U'?'), "missing glyphs must fall back to '?'");
|
||||||
|
|
||||||
|
const auto& pixels = font.GetAtlasPixels();
|
||||||
|
const auto [minIt, maxIt] = std::minmax_element(pixels.begin(), pixels.end());
|
||||||
|
check(!pixels.empty() && *minIt < 128 && *maxIt > 128,
|
||||||
|
"the atlas must hold a signed distance field with the edge at 128");
|
||||||
|
|
||||||
|
const glm::vec2 one = font.MeasureText("A", 64.0f);
|
||||||
|
const glm::vec2 two = font.MeasureText("A\nAA", 64.0f);
|
||||||
|
check(close(one.x, a->advance * 2.0f) && close(one.y, font.GetLineHeight() * 2.0f),
|
||||||
|
"text must be measured at the requested size");
|
||||||
|
check(close(two.x, (a->advance * 2.0f + font.GetKerning(U'A', U'A')) * 2.0f) &&
|
||||||
|
close(two.y, one.y * 2.0f),
|
||||||
|
"multi line text must measure its widest line and every line");
|
||||||
|
|
||||||
|
std::vector<PlacedGlyph> placed;
|
||||||
|
font.Layout("A b", {100.0f, 50.0f}, 32.0f, [&](const PlacedGlyph& glyph) { placed.push_back(glyph); });
|
||||||
|
check(placed.size() == 2 && placed[0].glyph == a &&
|
||||||
|
close(placed[0].topLeft.x, 100.0f + a->offset.x) &&
|
||||||
|
close(placed[0].topLeft.y, 50.0f + font.GetAscender() + a->offset.y),
|
||||||
|
"layout must skip spaces and start on the baseline below the top left");
|
||||||
|
|
||||||
|
const std::string_view utf8 = "\xC3\xA9\xFF";
|
||||||
|
std::size_t index = 0;
|
||||||
|
check(Font::DecodeUtf8(utf8, index) == U'é' && index == 2, "utf8 must decode two byte sequences");
|
||||||
|
check(Font::DecodeUtf8(utf8, index) == 0xFFFD && index == 3, "invalid utf8 must decode to U+FFFD");
|
||||||
|
|
||||||
|
auto& r2d = Renderer2D::GetInstance();
|
||||||
|
r2d.Clear();
|
||||||
|
r2d.DrawText(font, "not uploaded", {0.0f, 0.0f}, 16.0f, glm::vec4{1.0f});
|
||||||
|
check(r2d.GetShapes().empty(), "text must not be drawn before the font atlas is uploaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testCapsuleColliderDebugLines()
|
||||||
|
{
|
||||||
|
GameObject object{"capsule"};
|
||||||
|
object.GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f});
|
||||||
|
object.GetTransform().SetWorldScale(glm::vec3{5.0f});
|
||||||
|
auto* capsule = object.AddComponent<CapsuleCollider>(0.4f, 1.8f);
|
||||||
|
capsule->SetCenterOffset({0.0f, 0.5f, 0.0f});
|
||||||
|
|
||||||
|
auto& lines = LineRenderingManager::GetInstance();
|
||||||
|
lines.ClearLines();
|
||||||
|
capsule->DrawDebugLines(glm::vec4{1.0f});
|
||||||
|
|
||||||
|
const glm::vec3 center{1.0f, 2.5f, 3.0f};
|
||||||
|
float top = -1000.0f;
|
||||||
|
float bottom = 1000.0f;
|
||||||
|
bool inside = true;
|
||||||
|
for (const Line& line : lines.GetLines()) {
|
||||||
|
for (const glm::vec3& point : {line.start, line.end}) {
|
||||||
|
const glm::vec3 local = point - center;
|
||||||
|
top = std::max(top, local.y);
|
||||||
|
bottom = std::min(bottom, local.y);
|
||||||
|
inside = inside && glm::length(glm::vec2{local.x, local.z}) <= 0.4f + 0.001f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.ClearLines();
|
||||||
|
|
||||||
|
check(inside && close(top, 0.9f) && close(bottom, -0.9f),
|
||||||
|
"capsule debug lines must match the collider shape in world units, ignoring the object scale");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testPrimitiveBoxMesh()
|
||||||
|
{
|
||||||
|
const glm::vec3 size{4.0f, 1.0f, 2.0f};
|
||||||
|
const CPUMesh box = PrimitiveMeshes::MakeBox(size);
|
||||||
|
|
||||||
|
check(box.vertices.size() == 24 && box.indices.size() == 36, "a box must have 6 faces of 2 triangles");
|
||||||
|
check(box.minPos == -size * 0.5f && box.maxPos == size * 0.5f, "a box must be centered on the origin");
|
||||||
|
|
||||||
|
float maxU = 0.0f;
|
||||||
|
bool outward = true;
|
||||||
|
for (const auto& vertex : box.vertices) {
|
||||||
|
if (vertex.normal == glm::vec3{0.0f, 0.0f, 1.0f}) {
|
||||||
|
maxU = std::max(maxU, vertex.uv_x);
|
||||||
|
}
|
||||||
|
outward = outward && glm::dot(vertex.position, vertex.normal) > 0.0f;
|
||||||
|
}
|
||||||
|
check(close(maxU, size.x), "box uvs must be in meters so textures tile at the same size");
|
||||||
|
check(outward, "box normals must point away from the center");
|
||||||
|
|
||||||
|
bool counterClockwise = true;
|
||||||
|
for (std::size_t i = 0; i < box.indices.size(); i += 3) {
|
||||||
|
const auto& a = box.vertices[box.indices[i]];
|
||||||
|
const auto& b = box.vertices[box.indices[i + 1]];
|
||||||
|
const auto& c = box.vertices[box.indices[i + 2]];
|
||||||
|
const glm::vec3 faceNormal = glm::cross(b.position - a.position, c.position - a.position);
|
||||||
|
counterClockwise = counterClockwise && glm::dot(faceNormal, a.normal) > 0.0f;
|
||||||
|
}
|
||||||
|
check(counterClockwise, "box triangles must be counter clockwise seen from outside");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSimpleColorMaterial()
|
||||||
|
{
|
||||||
|
const auto material = Material::SimpleColor(
|
||||||
|
glm::vec3{0.2f, 0.4f, 0.8f},
|
||||||
|
"blue material");
|
||||||
|
|
||||||
|
check(material.baseColor == glm::vec3{0.2f, 0.4f, 0.8f},
|
||||||
|
"simple materials must preserve their color");
|
||||||
|
check(material.metallicFactor == 0.0f &&
|
||||||
|
material.roughnessFactor == 1.0f &&
|
||||||
|
material.emissiveColor == glm::vec3{0.0f},
|
||||||
|
"simple materials must use non-metallic default factors");
|
||||||
|
check(material.diffuseTexture == NULL_IMAGE_ID,
|
||||||
|
"simple materials must not require a diffuse image");
|
||||||
|
check(material.name == "blue material",
|
||||||
|
"simple materials must preserve their optional name");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testPrimitiveMeshKeys()
|
||||||
|
{
|
||||||
|
const glm::vec3 size{0.35f, 0.12f, 3.0f};
|
||||||
|
const std::string key = PrimitiveMeshes::BoxKey(size);
|
||||||
|
check(PrimitiveMeshes::IsPrimitiveKey(key), "box keys must be primitive keys");
|
||||||
|
check(PrimitiveMeshes::MakeBox(size).name == key, "boxes must be named by their key");
|
||||||
|
|
||||||
|
const auto rebuilt = PrimitiveMeshes::MakeFromKey(key + "#3");
|
||||||
|
check(rebuilt.has_value() && rebuilt->maxPos == size * 0.5f,
|
||||||
|
"a box must rebuild exactly from its key, ignoring the duplicate suffix");
|
||||||
|
check(PrimitiveMeshes::StripDuplicateSuffix("BulbMaterial#12") == "BulbMaterial" &&
|
||||||
|
PrimitiveMeshes::StripDuplicateSuffix("game://a.glb#mesh") == "game://a.glb#mesh",
|
||||||
|
"only numeric duplicate suffixes must be stripped");
|
||||||
|
check(!PrimitiveMeshes::MakeFromKey("prim://box?size=1,2").has_value() &&
|
||||||
|
!PrimitiveMeshes::MakeFromKey("prim://box?size=1,-2,3").has_value() &&
|
||||||
|
!PrimitiveMeshes::MakeFromKey("Box").has_value(),
|
||||||
|
"invalid primitive keys must not build a mesh");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testIndirectDrawListBatchesByMesh()
|
||||||
|
{
|
||||||
|
std::vector<GPUMesh> meshes(2);
|
||||||
|
meshes[0].numIndices = 36;
|
||||||
|
meshes[0].firstIndex = 0;
|
||||||
|
meshes[0].vertexBuffer.address = 0x1000;
|
||||||
|
meshes[1].numIndices = 6;
|
||||||
|
meshes[1].firstIndex = 36;
|
||||||
|
meshes[1].vertexBuffer.address = 0x2000;
|
||||||
|
|
||||||
|
SkinnedMesh skinned;
|
||||||
|
skinned.skinnedVertexBuffer.address = 0x3000;
|
||||||
|
|
||||||
|
const auto makeDraw = [](MeshID mesh, MaterialID material, glm::vec3 position) {
|
||||||
|
MeshDrawCommand dc;
|
||||||
|
dc.meshId = mesh;
|
||||||
|
dc.materialId = material;
|
||||||
|
dc.transformMatrix = glm::translate(glm::mat4{1.0f}, position);
|
||||||
|
dc.worldBoundingSphere = Sphere{position, 0.5f};
|
||||||
|
return dc;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<MeshDrawCommand> draws{
|
||||||
|
makeDraw(0, 1, {0.0f, 0.0f, 5.0f}),
|
||||||
|
makeDraw(1, 2, {1.0f, 0.0f, 5.0f}),
|
||||||
|
makeDraw(0, 3, {-1.0f, 0.0f, 5.0f}),
|
||||||
|
makeDraw(0, 4, {0.0f, 1.0f, 5.0f}),
|
||||||
|
makeDraw(1, 2, {0.0f, 0.0f, -50.0f}),
|
||||||
|
};
|
||||||
|
draws[2].skinnedMesh = &skinned;
|
||||||
|
draws[3].castShadow = false;
|
||||||
|
const std::vector<std::size_t> sorted{0, 2, 3, 1, 4};
|
||||||
|
|
||||||
|
const glm::mat4 view = glm::lookAt(glm::vec3{0.0f}, glm::vec3{0.0f, 0.0f, 1.0f}, glm::vec3{0.0f, 1.0f, 0.0f});
|
||||||
|
const Frustum frustum = edge::createFrustumFromViewProj(
|
||||||
|
glm::perspectiveRH_ZO(glm::radians(90.0f), 1.0f, 0.1f, 20.0f) * view);
|
||||||
|
|
||||||
|
IndirectDrawList list;
|
||||||
|
list.begin(0);
|
||||||
|
const auto camera = list.addView(meshes, draws, sorted, frustum, IndirectDrawList::Filter::All);
|
||||||
|
const auto shadow = list.addView(meshes, draws, sorted, frustum, IndirectDrawList::Filter::ShadowCasters);
|
||||||
|
|
||||||
|
const auto& commands = list.getCommands();
|
||||||
|
const auto& instances = list.getInstances();
|
||||||
|
|
||||||
|
check(camera.firstCommand == 0 && camera.commandCount == 2 && camera.instanceCount == 4,
|
||||||
|
"a view must merge draws of the same mesh into one command and cull the rest");
|
||||||
|
check(commands[0].indexCount == 36 && commands[0].firstIndex == 0 && commands[0].instanceCount == 3 &&
|
||||||
|
commands[0].firstInstance == 0,
|
||||||
|
"the first batch must cover the three visible draws of mesh 0");
|
||||||
|
check(commands[1].indexCount == 6 && commands[1].firstIndex == 36 && commands[1].instanceCount == 1 &&
|
||||||
|
commands[1].firstInstance == 3,
|
||||||
|
"the second batch must start after the first batch's instances");
|
||||||
|
check(instances[0].materialId == 1 && instances[1].materialId == 3 && instances[2].materialId == 4,
|
||||||
|
"materials must be per instance so they don't split batches");
|
||||||
|
check(instances[0].vertexBuffer == 0x1000 && instances[1].vertexBuffer == 0x3000,
|
||||||
|
"skinned draws must point at their own skinned vertex buffer");
|
||||||
|
|
||||||
|
check(shadow.firstCommand == 2 && shadow.commandCount == 2 && shadow.instanceCount == 3,
|
||||||
|
"the shadow view must skip non-casters and get its own command range");
|
||||||
|
check(commands[2].instanceCount == 2 && commands[2].firstInstance == 4,
|
||||||
|
"shadow instances must follow the camera view's instances");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testDeletionQueueWaitsForFrame()
|
||||||
|
{
|
||||||
|
DeletionQueue queue;
|
||||||
|
std::vector<int> deleted;
|
||||||
|
queue.push(3, [&] { deleted.push_back(1); });
|
||||||
|
queue.push(3, [&] { deleted.push_back(2); });
|
||||||
|
queue.push(5, [&] { deleted.push_back(3); });
|
||||||
|
|
||||||
|
queue.flush(2);
|
||||||
|
check(deleted.empty(), "resources retired in frame 3 must survive until frame 3 completes");
|
||||||
|
|
||||||
|
queue.flush(3);
|
||||||
|
check(deleted == std::vector<int>{1, 2}, "deleters must run in retirement order once their frame completes");
|
||||||
|
check(queue.size() == 1, "later frames must stay queued");
|
||||||
|
|
||||||
|
queue.flushAll();
|
||||||
|
check(deleted.size() == 3 && queue.empty(), "flushAll must run every pending deleter");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunGraphicsTests()
|
||||||
|
{
|
||||||
|
testLineRenderingManager();
|
||||||
|
testFrustumFromViewProj();
|
||||||
|
testRenderer2DShapes();
|
||||||
|
testFontAtlasAndLayout();
|
||||||
|
testCapsuleColliderDebugLines();
|
||||||
|
testPrimitiveBoxMesh();
|
||||||
|
testSimpleColorMaterial();
|
||||||
|
testPrimitiveMeshKeys();
|
||||||
|
testDeletionQueueWaitsForFrame();
|
||||||
|
testIndirectDrawListBatchesByMesh();
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
void RunCoreTests();
|
||||||
|
void RunSerializationTests();
|
||||||
|
void RunPhysicsTests();
|
||||||
|
void RunAssetsTests();
|
||||||
|
void RunGraphicsTests();
|
||||||
|
void RunUiTests();
|
||||||
|
void RunGameplayTests();
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct Suite {
|
||||||
|
const char* name;
|
||||||
|
void (*run)();
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr Suite suites[] = {
|
||||||
|
{"core", RunCoreTests},
|
||||||
|
{"serialization", RunSerializationTests},
|
||||||
|
{"physics", RunPhysicsTests},
|
||||||
|
{"assets", RunAssetsTests},
|
||||||
|
{"graphics", RunGraphicsTests},
|
||||||
|
{"ui", RunUiTests},
|
||||||
|
{"gameplay", RunGameplayTests},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char* argv[])
|
||||||
|
{
|
||||||
|
const char* only = argc > 1 ? argv[1] : nullptr;
|
||||||
|
bool ranAny = false;
|
||||||
|
|
||||||
|
for (const Suite& suite : suites) {
|
||||||
|
if (only != nullptr && std::strcmp(only, suite.name) != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
suite.run();
|
||||||
|
ranAny = true;
|
||||||
|
std::cout << suite.name << " tests passed\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ranAny) {
|
||||||
|
std::cerr << "unknown test suite: " << only << '\n';
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
#include <cmath>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <destrum/ObjectModel/Component.h>
|
||||||
|
#include <destrum/ObjectModel/GameObject.h>
|
||||||
|
#include <destrum/Scene/Scene.h>
|
||||||
|
#include <destrum/Scene/SceneManager.h>
|
||||||
|
#include <destrum/Physics/JoltPhysicsWorld.h>
|
||||||
|
#include <destrum/Components/Physics/SphereCollider.h>
|
||||||
|
#include <destrum/Components/Physics/BoxCollider.h>
|
||||||
|
#include <destrum/Components/Physics/CapsuleCollider.h>
|
||||||
|
#include <destrum/Components/Physics/Collider.h>
|
||||||
|
#include <destrum/Components/LightComponent.h>
|
||||||
|
#include <destrum/Components/Physics/Rigidbody.h>
|
||||||
|
|
||||||
|
#include "TestUtils.h"
|
||||||
|
|
||||||
|
using namespace test;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void testPhysicsWorldUnits()
|
||||||
|
{
|
||||||
|
JoltPhysicsWorld world{{
|
||||||
|
.workerThreadCount = 1,
|
||||||
|
.gravity = glm::vec3{0.0f}
|
||||||
|
}};
|
||||||
|
GameObject object{"scaled-physics"};
|
||||||
|
object.GetTransform().SetLocalScale(glm::vec3{0.5f});
|
||||||
|
object.AddComponent<SphereCollider>(1.0f);
|
||||||
|
Rigidbody* rigidbody = object.AddComponent<Rigidbody>();
|
||||||
|
world.RegisterRigidbody(*rigidbody);
|
||||||
|
|
||||||
|
PhysicsRaycastHit hit;
|
||||||
|
check(world.Raycast({-2.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit),
|
||||||
|
"scaled physics bodies must remain raycastable");
|
||||||
|
check(close(hit.distance, 1.0f),
|
||||||
|
"physics collider dimensions must remain in world units");
|
||||||
|
|
||||||
|
object.GetTransform().SetLocalPosition({10.0f, 0.0f, 0.0f});
|
||||||
|
world.RefreshRigidbody(*rigidbody);
|
||||||
|
check(world.Raycast({8.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit),
|
||||||
|
"refreshing a physics body must use the current transform position");
|
||||||
|
check(close(hit.distance, 1.0f),
|
||||||
|
"refreshed physics bodies must remain aligned with rendered transforms");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testJoltSphereCollisions()
|
||||||
|
{
|
||||||
|
JoltPhysicsWorld world{{
|
||||||
|
.workerThreadCount = 1,
|
||||||
|
.gravity = {0.0f, -9.81f, 0.0f}
|
||||||
|
}};
|
||||||
|
|
||||||
|
GameObject floor{"floor"};
|
||||||
|
floor.GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f});
|
||||||
|
floor.AddComponent<BoxCollider>(glm::vec3{10.0f, 0.5f, 10.0f});
|
||||||
|
auto* floorBody = floor.AddComponent<Rigidbody>();
|
||||||
|
floorBody->SetType(RigidbodyType::Static);
|
||||||
|
world.RegisterRigidbody(*floorBody);
|
||||||
|
|
||||||
|
GameObject first{"first"};
|
||||||
|
first.AddComponent<SphereCollider>(1.5f);
|
||||||
|
auto* firstBody = first.AddComponent<Rigidbody>();
|
||||||
|
world.RegisterRigidbody(*firstBody);
|
||||||
|
first.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f});
|
||||||
|
first.GetTransform().SetLocalScale(glm::vec3{0.015f});
|
||||||
|
world.RefreshRigidbody(*firstBody);
|
||||||
|
|
||||||
|
GameObject second{"second"};
|
||||||
|
second.AddComponent<SphereCollider>(1.5f);
|
||||||
|
auto* secondBody = second.AddComponent<Rigidbody>();
|
||||||
|
world.RegisterRigidbody(*secondBody);
|
||||||
|
second.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f});
|
||||||
|
second.GetTransform().SetLocalScale(glm::vec3{0.015f});
|
||||||
|
world.RefreshRigidbody(*secondBody);
|
||||||
|
|
||||||
|
for (int step = 0; step < 240; ++step) {
|
||||||
|
world.Step(1.0f / 60.0f);
|
||||||
|
world.SyncDynamicBodiesToTransforms();
|
||||||
|
}
|
||||||
|
|
||||||
|
check(first.GetTransform().GetWorldPosition().y > 0.8f &&
|
||||||
|
second.GetTransform().GetWorldPosition().y > 0.8f,
|
||||||
|
"Jolt spheres must collide with the ground plane");
|
||||||
|
check(glm::distance(first.GetTransform().GetWorldPosition(),
|
||||||
|
second.GetTransform().GetWorldPosition()) > 2.5f,
|
||||||
|
"Jolt spheres must collide with one another instead of occupying the same point");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneJoltSphereCollisions()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("scene-physics");
|
||||||
|
|
||||||
|
GameObject* floor = scene.CreateGameObject("floor");
|
||||||
|
floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f});
|
||||||
|
floor->AddComponent<BoxCollider>(glm::vec3{10.0f, 0.5f, 10.0f});
|
||||||
|
auto* floorBody = floor->AddComponent<Rigidbody>();
|
||||||
|
floorBody->SetType(RigidbodyType::Static);
|
||||||
|
|
||||||
|
GameObject* first = scene.CreateGameObject("first");
|
||||||
|
first->AddComponent<SphereCollider>(1.5f);
|
||||||
|
first->AddComponent<Rigidbody>();
|
||||||
|
first->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f});
|
||||||
|
first->GetTransform().SetWorldScale(glm::vec3{0.015f});
|
||||||
|
|
||||||
|
GameObject* second = scene.CreateGameObject("second");
|
||||||
|
second->AddComponent<SphereCollider>(1.5f);
|
||||||
|
second->AddComponent<Rigidbody>();
|
||||||
|
second->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f});
|
||||||
|
second->GetTransform().SetWorldScale(glm::vec3{0.015f});
|
||||||
|
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
scene.GetPhysics().RefreshGameObject(*first);
|
||||||
|
scene.GetPhysics().RefreshGameObject(*second);
|
||||||
|
|
||||||
|
for (int step = 0; step < 240; ++step) {
|
||||||
|
scene.FixedUpdate(1.0f / 60.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
check(first->GetTransform().GetWorldPosition().y > 0.8f &&
|
||||||
|
second->GetTransform().GetWorldPosition().y > 0.8f,
|
||||||
|
"scene physics spheres must collide with the ground plane");
|
||||||
|
check(glm::distance(first->GetTransform().GetWorldPosition(),
|
||||||
|
second->GetTransform().GetWorldPosition()) > 2.5f,
|
||||||
|
"scene physics spheres must not overlap after settling");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
class TriggerCounter final : public Component {
|
||||||
|
public:
|
||||||
|
explicit TriggerCounter(GameObject& parent)
|
||||||
|
: Component(parent, "TriggerCounter") {}
|
||||||
|
|
||||||
|
std::string GetTypeName() const override { return "TriggerCounter"; }
|
||||||
|
void OnTriggerEnter(GameObject*) override { ++enters; }
|
||||||
|
void OnTriggerExit(GameObject*) override { ++exits; }
|
||||||
|
|
||||||
|
int enters{0};
|
||||||
|
int exits{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
void testNonPhysicsChangesKeepBodies()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("physics-refresh");
|
||||||
|
|
||||||
|
GameObject* trigger = scene.CreateGameObject("trigger");
|
||||||
|
trigger->AddComponent<BoxCollider>(glm::vec3{3.0f})->SetTrigger(true);
|
||||||
|
trigger->AddComponent<Rigidbody>()->SetType(RigidbodyType::Static);
|
||||||
|
auto* counter = trigger->AddComponent<TriggerCounter>();
|
||||||
|
|
||||||
|
GameObject* crate = scene.CreateGameObject("crate");
|
||||||
|
crate->AddComponent<BoxCollider>(glm::vec3{0.5f});
|
||||||
|
auto* crateBody = crate->AddComponent<Rigidbody>();
|
||||||
|
crateBody->SetUseGravity(false);
|
||||||
|
crateBody->SetAllowSleep(false);
|
||||||
|
|
||||||
|
GameObject* lamp = scene.CreateGameObject("lamp");
|
||||||
|
auto* light = lamp->AddComponent<LightComponent>();
|
||||||
|
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
scene.GetPhysics().RefreshGameObject(*trigger);
|
||||||
|
scene.GetPhysics().RefreshGameObject(*crate);
|
||||||
|
|
||||||
|
const auto step = [&scene] {
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
scene.FixedUpdate(1.0f / 60.0f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
step();
|
||||||
|
check(counter->enters == 1 && counter->exits == 0, "a body inside a trigger must enter it once");
|
||||||
|
|
||||||
|
light->SetEnabled(false);
|
||||||
|
light->SetEnabled(true);
|
||||||
|
crate->AddComponent<LightComponent>()->SetEnabled(false);
|
||||||
|
step();
|
||||||
|
check(counter->enters == 1 && counter->exits == 0,
|
||||||
|
"non physics components must not rebuild physics bodies");
|
||||||
|
|
||||||
|
crateBody->SetMass(5.0f);
|
||||||
|
step();
|
||||||
|
check(counter->enters == 2 && counter->exits == 1,
|
||||||
|
"rebuilding a body inside a trigger must exit the old body and enter the new one");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testKinematicBodiesMoveWithVelocity()
|
||||||
|
{
|
||||||
|
constexpr float fixedDt = 1.0f / 60.0f;
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("kinematic-velocity");
|
||||||
|
|
||||||
|
GameObject* platform = scene.CreateGameObject("platform");
|
||||||
|
platform->AddComponent<BoxCollider>(glm::vec3{2.0f, 0.25f, 2.0f});
|
||||||
|
auto* platformBody = platform->AddComponent<Rigidbody>();
|
||||||
|
platformBody->SetType(RigidbodyType::Kinematic);
|
||||||
|
|
||||||
|
GameObject* crate = scene.CreateGameObject("crate");
|
||||||
|
crate->GetTransform().SetWorldPosition({0.0f, 0.76f, 0.0f});
|
||||||
|
crate->AddComponent<BoxCollider>(glm::vec3{0.5f});
|
||||||
|
auto* crateBody = crate->AddComponent<Rigidbody>();
|
||||||
|
crateBody->SetFriction(1.0f);
|
||||||
|
crateBody->SetAllowSleep(false);
|
||||||
|
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
scene.GetPhysics().RefreshGameObject(*platform);
|
||||||
|
scene.GetPhysics().RefreshGameObject(*crate);
|
||||||
|
|
||||||
|
for (int step = 0; step < 30; ++step) {
|
||||||
|
scene.FixedUpdate(fixedDt);
|
||||||
|
}
|
||||||
|
check(glm::length(platformBody->GetLinearVelocity()) < 0.001f,
|
||||||
|
"a kinematic body that is not moved must have no velocity");
|
||||||
|
|
||||||
|
constexpr float platformSpeed = 2.0f;
|
||||||
|
for (int step = 0; step < 60; ++step) {
|
||||||
|
platform->GetTransform().SetWorldPosition(
|
||||||
|
platform->GetTransform().GetWorldPosition() + glm::vec3{platformSpeed * fixedDt, 0.0f, 0.0f});
|
||||||
|
scene.FixedUpdate(fixedDt);
|
||||||
|
}
|
||||||
|
|
||||||
|
check(close(platformBody->GetLinearVelocity().x, platformSpeed),
|
||||||
|
"a kinematic body moved through its transform must get the matching velocity");
|
||||||
|
check(crate->GetTransform().GetWorldPosition().x > 1.5f,
|
||||||
|
"a dynamic body resting on a moving kinematic platform must be carried along");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testColliderCenterOffset()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("collider-offset");
|
||||||
|
|
||||||
|
GameObject* box = scene.CreateGameObject("offset box");
|
||||||
|
box->AddComponent<BoxCollider>(glm::vec3{0.5f})->SetCenterOffset({0.0f, 5.0f, 0.0f});
|
||||||
|
box->AddComponent<Rigidbody>()->SetType(RigidbodyType::Static);
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
scene.GetPhysics().RefreshGameObject(*box);
|
||||||
|
|
||||||
|
PhysicsRaycastHit hit{};
|
||||||
|
const bool found = box->GetComponent<Rigidbody>()->GetPhysicsWorld()->Raycast(
|
||||||
|
{0.0f, 10.0f, 0.0f}, {0.0f, -1.0f, 0.0f}, 20.0f, hit);
|
||||||
|
check(found && std::abs(hit.distance - 4.5f) < 0.01f,
|
||||||
|
"a collider center offset must move the shape, not just the center of mass");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testFrozenRotationCapsule()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("frozen-capsule");
|
||||||
|
|
||||||
|
GameObject* floor = scene.CreateGameObject("floor");
|
||||||
|
floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f});
|
||||||
|
floor->AddComponent<BoxCollider>(glm::vec3{10.0f, 0.5f, 10.0f});
|
||||||
|
floor->AddComponent<Rigidbody>()->SetType(RigidbodyType::Static);
|
||||||
|
|
||||||
|
GameObject* character = scene.CreateGameObject("character");
|
||||||
|
character->AddComponent<CapsuleCollider>(0.4f, 1.8f);
|
||||||
|
auto* body = character->AddComponent<Rigidbody>();
|
||||||
|
body->SetFriction(1.0f);
|
||||||
|
body->SetFreezeRotation(true);
|
||||||
|
character->GetTransform().SetWorldPosition({0.0f, 1.0f, 0.0f});
|
||||||
|
|
||||||
|
GameObject* trigger = scene.CreateGameObject("trigger");
|
||||||
|
trigger->GetTransform().SetWorldPosition({2.0f, 0.0f, 0.0f});
|
||||||
|
trigger->AddComponent<BoxCollider>(glm::vec3{2.0f})->SetTrigger(true);
|
||||||
|
trigger->AddComponent<Rigidbody>()->SetType(RigidbodyType::Static);
|
||||||
|
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
scene.GetPhysics().RefreshGameObject(*floor);
|
||||||
|
scene.GetPhysics().RefreshGameObject(*character);
|
||||||
|
scene.GetPhysics().RefreshGameObject(*trigger);
|
||||||
|
|
||||||
|
// Sliding with friction would tip a free capsule over
|
||||||
|
body->SetLinearVelocity({4.0f, 0.0f, 0.0f});
|
||||||
|
for (int step = 0; step < 180; ++step) {
|
||||||
|
scene.FixedUpdate(1.0f / 60.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
const glm::vec3 position = character->GetTransform().GetWorldPosition();
|
||||||
|
const glm::quat rotation = character->GetTransform().GetWorldRotation();
|
||||||
|
check(std::abs(rotation.w) > 0.999f, "a capsule with frozen rotation must stay upright");
|
||||||
|
check(std::abs(position.y - (-0.5f + 0.9f)) < 0.05f, "a capsule must rest on top of the floor");
|
||||||
|
|
||||||
|
// Cast from inside the capsule (and the trigger it is standing in), like a ground check does
|
||||||
|
PhysicsRaycastHit hit{};
|
||||||
|
const bool hitGround = body->GetPhysicsWorld()->Raycast(
|
||||||
|
position - glm::vec3{0.0f, 0.7f, 0.0f}, glm::vec3{0.0f, -1.0f, 0.0f}, 0.3f, hit, body->GetBody());
|
||||||
|
check(hitGround && hit.object == floor && hit.normal.y > 0.9f,
|
||||||
|
"a ground ray must skip the caster and triggers and hit the floor");
|
||||||
|
|
||||||
|
PhysicsRaycastHit selfHit{};
|
||||||
|
check(body->GetPhysicsWorld()->Raycast(
|
||||||
|
position - glm::vec3{0.0f, 0.7f, 0.0f}, glm::vec3{0.0f, -1.0f, 0.0f}, 0.3f, selfHit) &&
|
||||||
|
selfHit.object == character,
|
||||||
|
"a ray that doesn't ignore the caster must hit it");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
class TriggerTracker final : public Component {
|
||||||
|
public:
|
||||||
|
explicit TriggerTracker(GameObject& owner)
|
||||||
|
: Component(owner, "TriggerTracker") {}
|
||||||
|
|
||||||
|
void Update(float) override {}
|
||||||
|
std::string GetTypeName() const override { return "TriggerTracker"; }
|
||||||
|
|
||||||
|
void OnTriggerEnter(GameObject* other) override {
|
||||||
|
++enterCount;
|
||||||
|
lastOther = other;
|
||||||
|
enterObjectName = other ? other->GetName() : "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnTriggerExit(GameObject*) override {
|
||||||
|
++exitCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
int enterCount{0};
|
||||||
|
int exitCount{0};
|
||||||
|
GameObject* lastOther{nullptr};
|
||||||
|
std::string enterObjectName;
|
||||||
|
};
|
||||||
|
|
||||||
|
void testTriggerZone()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("trigger-test");
|
||||||
|
|
||||||
|
// Trigger box at origin (Static rigidbody + BoxCollider as trigger).
|
||||||
|
GameObject* triggerObj = scene.CreateGameObject("TriggerZone");
|
||||||
|
triggerObj->AddComponent<BoxCollider>(glm::vec3{3.0f, 3.0f, 3.0f});
|
||||||
|
triggerObj->GetComponent<BoxCollider>()->SetTrigger(true);
|
||||||
|
auto* triggerRb = triggerObj->AddComponent<Rigidbody>();
|
||||||
|
triggerRb->SetType(RigidbodyType::Static);
|
||||||
|
auto* tracker = triggerObj->AddComponent<TriggerTracker>();
|
||||||
|
|
||||||
|
// Falling sphere above the trigger.
|
||||||
|
GameObject* sphere = scene.CreateGameObject("FallingSphere");
|
||||||
|
sphere->AddComponent<SphereCollider>(0.5f);
|
||||||
|
sphere->AddComponent<Rigidbody>();
|
||||||
|
sphere->GetTransform().SetWorldPosition({0.0f, 8.0f, 0.0f});
|
||||||
|
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
// Run physics for enough steps that the sphere falls into the trigger.
|
||||||
|
for (int step = 0; step < 120; ++step) {
|
||||||
|
scene.FixedUpdate(1.0f / 60.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
check(tracker->enterCount >= 1,
|
||||||
|
"falling sphere must trigger OnTriggerEnter at least once");
|
||||||
|
check(tracker->exitCount >= 1,
|
||||||
|
"falling sphere passing through trigger must exit");
|
||||||
|
check(tracker->enterObjectName == "FallingSphere",
|
||||||
|
"OnTriggerEnter must report the correct entering object");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunPhysicsTests()
|
||||||
|
{
|
||||||
|
testPhysicsWorldUnits();
|
||||||
|
testJoltSphereCollisions();
|
||||||
|
testSceneJoltSphereCollisions();
|
||||||
|
testNonPhysicsChangesKeepBodies();
|
||||||
|
testKinematicBodiesMoveWithVelocity();
|
||||||
|
testColliderCenterOffset();
|
||||||
|
testFrozenRotationCapsule();
|
||||||
|
testTriggerZone();
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#include <cmath>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
#include <destrum/ObjectModel/Component.h>
|
||||||
|
#include <destrum/ObjectModel/GameObject.h>
|
||||||
|
#include <destrum/Scene/Scene.h>
|
||||||
|
#include <destrum/Scene/SceneManager.h>
|
||||||
|
#include <destrum/Serialization/GlmJson.h>
|
||||||
|
#include <destrum/Serialization/SceneSerializer.h>
|
||||||
|
#include <destrum/Components/CameraComponent.h>
|
||||||
|
#include <destrum/Components/LightComponent.h>
|
||||||
|
#include <destrum/Components/Physics/Rigidbody.h>
|
||||||
|
|
||||||
|
#include "TestUtils.h"
|
||||||
|
|
||||||
|
using namespace test;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void testSceneRoundTrip()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("round-trip");
|
||||||
|
GameObject* parent = scene.CreateGameObject("parent");
|
||||||
|
GameObject* child = scene.CreateGameObject("child");
|
||||||
|
child->GetTransform().SetLocalPosition({1.f, 2.f, 3.f});
|
||||||
|
child->GetTransform().SetParent(&parent->GetTransform(), false);
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
SceneEnvironment& environment = scene.GetEnvironment();
|
||||||
|
environment.ambientColor = {0.2f, 0.3f, 0.4f};
|
||||||
|
environment.ambientIntensity = 2.0f;
|
||||||
|
environment.fogColor = {0.6f, 0.7f, 0.8f};
|
||||||
|
environment.fogDensity = 0.05f;
|
||||||
|
environment.skyboxRotationSpeed = 0.5f;
|
||||||
|
environment.skyboxRotation = 1.0f;
|
||||||
|
scene.Update(1.0f);
|
||||||
|
check(close(environment.skyboxRotation, 1.5f), "scene updates must spin the skybox");
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_scene_test.json";
|
||||||
|
check(SceneSerializer::Save(scene, path), "scene save must succeed");
|
||||||
|
check(SceneSerializer::Load(scene, path), "scene load must succeed");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
|
||||||
|
check(scene.GetObjects().size() == 2, "scene round-trip must preserve objects");
|
||||||
|
const auto* loadedChild = scene.GetObjects().at(1).get();
|
||||||
|
check(loadedChild->GetTransform().GetParent() != nullptr,
|
||||||
|
"scene round-trip must preserve hierarchy");
|
||||||
|
check(close(loadedChild->GetTransform().GetLocalPosition().x, 1.f),
|
||||||
|
"scene round-trip must preserve local transforms");
|
||||||
|
const SceneEnvironment& loadedEnvironment = scene.GetEnvironment();
|
||||||
|
check(loadedEnvironment.ambientColor == glm::vec3{0.2f, 0.3f, 0.4f} &&
|
||||||
|
close(loadedEnvironment.ambientIntensity, 2.0f) &&
|
||||||
|
loadedEnvironment.fogColor == glm::vec3{0.6f, 0.7f, 0.8f} &&
|
||||||
|
close(loadedEnvironment.fogDensity, 0.05f) &&
|
||||||
|
close(loadedEnvironment.skyboxRotationSpeed, 0.5f) &&
|
||||||
|
close(loadedEnvironment.skyboxRotation, 1.5f),
|
||||||
|
"scene round-trip must preserve the environment settings");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLegacySceneLoad()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("legacy");
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_legacy_scene.json";
|
||||||
|
std::ofstream{path}
|
||||||
|
<< R"({"version":1,"name":"legacy","objects":[{"id":9001,"name":"object","active":true,"transform":{},"parent":0,"components":[]}]})";
|
||||||
|
|
||||||
|
check(SceneSerializer::Load(scene, path),
|
||||||
|
"version 1 placeholder transforms must remain loadable");
|
||||||
|
check(scene.GetObjects().size() == 1,
|
||||||
|
"legacy scene load must create its object");
|
||||||
|
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneLoadRollback()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("rollback");
|
||||||
|
scene.CreateGameObject("original");
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_bad_scene.json";
|
||||||
|
std::ofstream{path}
|
||||||
|
<< R"({"version":2,"name":"bad","objects":[{"id":9101,"name":"bad","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"Rigidbody","enabled":true,"data":{}}]}]})";
|
||||||
|
|
||||||
|
check(!SceneSerializer::Load(scene, path),
|
||||||
|
"scene loading must reject a rigidbody without a collider");
|
||||||
|
check(scene.GetObjects().size() == 1 && scene.GetObjects().at(0)->GetName() == "original",
|
||||||
|
"failed scene loading must preserve the live scene");
|
||||||
|
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLightComponentRoundTrip()
|
||||||
|
{
|
||||||
|
GameObject object{"light"};
|
||||||
|
auto* light = object.AddComponent<LightComponent>(LightType::Spot);
|
||||||
|
light->SetColor(glm::vec3{1.0f, 0.5f, 0.25f});
|
||||||
|
light->SetIntensity(4.0f);
|
||||||
|
light->SetRange(7.5f);
|
||||||
|
light->SetConeAngles(0.2f, 0.4f);
|
||||||
|
light->SetCastShadows(true);
|
||||||
|
|
||||||
|
const auto data = light->Serialize();
|
||||||
|
|
||||||
|
GameObject loadedObject{"loaded light"};
|
||||||
|
auto* loaded = loadedObject.AddComponent<LightComponent>();
|
||||||
|
loaded->Deserialize(data);
|
||||||
|
|
||||||
|
check(loaded->GetType() == LightType::Spot,
|
||||||
|
"LightComponent must round-trip its type");
|
||||||
|
check(loaded->GetColor() == glm::vec3{1.0f, 0.5f, 0.25f},
|
||||||
|
"LightComponent must round-trip its color");
|
||||||
|
check(loaded->GetIntensity() == 4.0f && loaded->GetRange() == 7.5f,
|
||||||
|
"LightComponent must round-trip intensity and range");
|
||||||
|
check(loaded->GetInnerConeAngle() == 0.2f && loaded->GetOuterConeAngle() == 0.4f,
|
||||||
|
"LightComponent must round-trip its cone angles");
|
||||||
|
check(loaded->GetCastShadows(),
|
||||||
|
"LightComponent must round-trip its cast shadows flag");
|
||||||
|
|
||||||
|
loaded->SetConeAngles(0.5f, 0.1f);
|
||||||
|
check(loaded->GetOuterConeAngle() >= loaded->GetInnerConeAngle(),
|
||||||
|
"LightComponent outer cone must never be smaller than the inner cone");
|
||||||
|
|
||||||
|
loaded->SetRange(0.0f);
|
||||||
|
check(loaded->GetRange() > 0.0f,
|
||||||
|
"LightComponent range must stay positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneSaveRejectsNonFinite()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("non-finite");
|
||||||
|
GameObject* object = scene.CreateGameObject("object");
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_non_finite_scene.json";
|
||||||
|
check(SceneSerializer::Save(scene, path), "a finite scene must save");
|
||||||
|
const std::string goodContents = readFile(path);
|
||||||
|
|
||||||
|
object->GetTransform().SetLocalPosition({std::nanf(""), 0.0f, 0.0f});
|
||||||
|
check(!SceneSerializer::Save(scene, path), "a scene with NaN values must not save");
|
||||||
|
check(readFile(path) == goodContents, "a failed save must leave the previous file untouched");
|
||||||
|
check(!std::filesystem::exists(std::filesystem::path{path} += ".tmp"),
|
||||||
|
"a failed save must not leave its temp file behind");
|
||||||
|
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneValidationErrors()
|
||||||
|
{
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_invalid_scene.json";
|
||||||
|
std::ofstream{path}
|
||||||
|
<< R"({"version":2,"name":"bad","objects":[{"id":7,"name":"Lamp","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"NotAComponent","enabled":true,"data":{}}]}]})";
|
||||||
|
|
||||||
|
const auto result = SceneSerializer::LoadSceneFile(path);
|
||||||
|
check(!result.success, "a scene with an unknown component must not load");
|
||||||
|
check(result.errorMessage.find("\"Lamp\"") != std::string::npos &&
|
||||||
|
result.errorMessage.find("NotAComponent") != std::string::npos &&
|
||||||
|
result.errorMessage.find("unknown component type") != std::string::npos,
|
||||||
|
"scene validation errors must name the object, component and problem");
|
||||||
|
|
||||||
|
std::ofstream{path}
|
||||||
|
<< R"({"version":2,"objects":[{"id":7,"name":"Lamp","components":[{"type":"Spinner","data":["message","hi"]}]}]})";
|
||||||
|
const auto arrayData = SceneSerializer::LoadSceneFile(path);
|
||||||
|
check(!arrayData.success && arrayData.errorMessage.find("must be an object") != std::string::npos,
|
||||||
|
"component data that is not an object must be rejected with a reason");
|
||||||
|
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneLoadRestoresBindings()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("bindings");
|
||||||
|
int registered = 0;
|
||||||
|
int unregistered = 0;
|
||||||
|
scene.SetRegisterBindings([®istered] { ++registered; });
|
||||||
|
scene.SetUnregisterBindings([&unregistered] { ++unregistered; });
|
||||||
|
scene.Load();
|
||||||
|
const int registeredBefore = registered;
|
||||||
|
const int unregisteredBefore = unregistered;
|
||||||
|
scene.CreateGameObject("object");
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_bindings_scene.json";
|
||||||
|
check(SceneSerializer::Save(scene, path), "the bindings scene must save");
|
||||||
|
check(SceneSerializer::Load(scene, path), "the bindings scene must load");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
|
||||||
|
check(registered - registeredBefore == 1 && unregistered - unregisteredBefore == 1, "loading a scene in place must register its bindings again");
|
||||||
|
|
||||||
|
scene.SetRegisterBindings(nullptr);
|
||||||
|
scene.SetUnregisterBindings(nullptr);
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneLoadDropsMissingCamera()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("missing camera");
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_missing_camera_scene.json";
|
||||||
|
std::ofstream{path}
|
||||||
|
<< R"({"version":2,"currentCamera":4242,"objects":[{"id":11,"name":"object"}]})";
|
||||||
|
|
||||||
|
check(SceneSerializer::Load(scene, path), "a scene with a dangling camera id must still load");
|
||||||
|
check(scene.GetCurrentCamera() == nullptr, "a dangling current camera id must be ignored");
|
||||||
|
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testGlmJson()
|
||||||
|
{
|
||||||
|
const nlohmann::json vec = glm::vec3{1.0f, 2.5f, -3.0f};
|
||||||
|
check(vec == nlohmann::json::array({1.0f, 2.5f, -3.0f}), "vec3 must serialize as a 3 number array");
|
||||||
|
check(vec.get<glm::vec3>() == glm::vec3{1.0f, 2.5f, -3.0f}, "vec3 must round-trip through json");
|
||||||
|
|
||||||
|
const glm::quat rotation = glm::angleAxis(0.5f, glm::vec3{0.0f, 1.0f, 0.0f});
|
||||||
|
const nlohmann::json quat = rotation;
|
||||||
|
check(quat.size() == 4 && close(quat.at(3).get<float>(), rotation.w),
|
||||||
|
"quaternions must serialize as [x, y, z, w]");
|
||||||
|
check(quat.get<glm::quat>() == rotation, "quaternions must round-trip through json");
|
||||||
|
|
||||||
|
bool rejected = false;
|
||||||
|
try {
|
||||||
|
(void)nlohmann::json::array({1.0f, 2.0f}).get<glm::vec3>();
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
check(rejected, "a vec3 must reject arrays of the wrong size");
|
||||||
|
|
||||||
|
const nlohmann::json data = {{"axis", nlohmann::json::array({0, 1, 0})}};
|
||||||
|
check(data.value("axis", glm::vec3{0.0f}) == glm::vec3{0.0f, 1.0f, 0.0f} &&
|
||||||
|
data.value("missing", glm::vec3{7.0f}) == glm::vec3{7.0f},
|
||||||
|
"json value() must work with glm defaults");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSceneFileIdsAreLocal()
|
||||||
|
{
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_local_ids_scene.json";
|
||||||
|
std::ofstream{path}
|
||||||
|
<< R"({"version":2,"currentCamera":2,"objects":[)"
|
||||||
|
R"({"id":1,"name":"root"},)"
|
||||||
|
R"({"id":2,"name":"child","parent":1,"components":[{"type":"CameraComponent"}]}]})";
|
||||||
|
|
||||||
|
Scene& first = SceneManager::GetInstance().CreateScene("local ids a");
|
||||||
|
Scene& second = SceneManager::GetInstance().CreateScene("local ids b");
|
||||||
|
check(SceneSerializer::Load(first, path), "the first copy of the scene must load");
|
||||||
|
check(SceneSerializer::Load(second, path), "the second copy of the scene must load");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
first.CommitPendingAdditions();
|
||||||
|
second.CommitPendingAdditions();
|
||||||
|
|
||||||
|
std::unordered_set<ObjectId> ids;
|
||||||
|
std::size_t count = 0;
|
||||||
|
for (const Scene* scene : {&first, &second}) {
|
||||||
|
for (const auto& object : scene->GetObjects()) {
|
||||||
|
ids.insert(object->GetId());
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(count == 4 && ids.size() == 4, "loading one file into two scenes must not duplicate ids");
|
||||||
|
|
||||||
|
for (const Scene* scene : {&first, &second}) {
|
||||||
|
const CameraComponent* camera = scene->GetCurrentCamera();
|
||||||
|
check(camera != nullptr && camera->GetGameObject()->GetName() == "child" &&
|
||||||
|
camera->GetGameObject()->GetScene() == scene,
|
||||||
|
"the current camera must be remapped to the object in its own scene");
|
||||||
|
const Transform* parent = camera->GetGameObject()->GetTransform().GetParent();
|
||||||
|
check(parent != nullptr && parent->GetOwner()->GetName() == "root" &&
|
||||||
|
parent->GetOwner()->GetScene() == scene,
|
||||||
|
"parents must be remapped within each loaded scene");
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameObject* fresh = first.CreateGameObject("fresh");
|
||||||
|
check(!ids.contains(fresh->GetId()), "objects created after a load must get unused ids");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunSerializationTests()
|
||||||
|
{
|
||||||
|
testSceneRoundTrip();
|
||||||
|
testLegacySceneLoad();
|
||||||
|
testSceneLoadRollback();
|
||||||
|
testLightComponentRoundTrip();
|
||||||
|
testSceneSaveRejectsNonFinite();
|
||||||
|
testSceneValidationErrors();
|
||||||
|
testSceneLoadRestoresBindings();
|
||||||
|
testSceneLoadDropsMissingCamera();
|
||||||
|
testGlmJson();
|
||||||
|
testSceneFileIdsAreLocal();
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <destrum/ObjectModel/Component.h>
|
||||||
|
#include <destrum/ObjectModel/GameObject.h>
|
||||||
|
#include <destrum/Scene/Scene.h>
|
||||||
|
#include <destrum/Scene/SceneManager.h>
|
||||||
|
#include <destrum/Serialization/ComponentFactory.h>
|
||||||
|
#include <destrum/Serialization/ComponentRegistry.h>
|
||||||
|
#include <destrum/Serialization/GlmJson.h>
|
||||||
|
#include <destrum/Serialization/SceneSerializer.h>
|
||||||
|
#include <destrum/Components/UI/Canvas.h>
|
||||||
|
#include <destrum/Components/UI/RectTransform.h>
|
||||||
|
#include <destrum/Components/UI/UIImage.h>
|
||||||
|
#include <destrum/Components/UI/UIText.h>
|
||||||
|
#include <destrum/Graphics/Managers/Renderer2D.h>
|
||||||
|
#include <destrum/Graphics/Resources/Font.h>
|
||||||
|
|
||||||
|
#include <components/CrosshairUI.h>
|
||||||
|
#include <components/FirstPersonController.h>
|
||||||
|
#include <components/WinGoal.h>
|
||||||
|
#include <components/WinScreenUI.h>
|
||||||
|
|
||||||
|
#include "TestUtils.h"
|
||||||
|
|
||||||
|
using namespace test;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void testCanvasLayout()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("canvas");
|
||||||
|
GameObject* hud = scene.CreateGameObject("hud");
|
||||||
|
auto* canvas = hud->AddComponent<Canvas>();
|
||||||
|
const auto addChild = [&](const std::string& name, GameObject& parent) {
|
||||||
|
GameObject* child = scene.CreateGameObject(name);
|
||||||
|
child->GetTransform().SetParent(&parent.GetTransform(), false);
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
|
||||||
|
GameObject* panel = addChild("panel", *hud);
|
||||||
|
auto* panelRect = panel->AddComponent<RectTransform>();
|
||||||
|
panelRect->SetSize({100.0f, 50.0f});
|
||||||
|
panelRect->SetPosition({10.0f, 0.0f});
|
||||||
|
panel->AddComponent<UIImage>(glm::vec4{1.0f, 0.0f, 0.0f, 1.0f});
|
||||||
|
|
||||||
|
GameObject* fill = addChild("fill", *panel);
|
||||||
|
fill->AddComponent<UIImage>(glm::vec4{0.0f, 1.0f, 0.0f, 1.0f})->SetShape(UIImageShape::Ellipse);
|
||||||
|
|
||||||
|
GameObject* corner = addChild("corner", *hud);
|
||||||
|
auto* cornerRect = corner->AddComponent<RectTransform>();
|
||||||
|
cornerRect->SetAnchor({1.0f, 1.0f});
|
||||||
|
cornerRect->SetPivot({1.0f, 1.0f});
|
||||||
|
cornerRect->SetPosition({-20.0f, -20.0f});
|
||||||
|
cornerRect->SetSize({40.0f, 40.0f});
|
||||||
|
corner->AddComponent<UIImage>();
|
||||||
|
|
||||||
|
GameObject* hidden = addChild("hidden", *hud);
|
||||||
|
auto* hiddenRect = hidden->AddComponent<RectTransform>();
|
||||||
|
hiddenRect->SetSize({20.0f, 20.0f});
|
||||||
|
hidden->AddComponent<UIImage>();
|
||||||
|
hidden->SetActive(false);
|
||||||
|
|
||||||
|
GameObject* nested = addChild("nested", *hud);
|
||||||
|
nested->AddComponent<Canvas>();
|
||||||
|
nested->AddComponent<UIImage>();
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
auto& r2d = Renderer2D::GetInstance();
|
||||||
|
r2d.Clear();
|
||||||
|
canvas->Draw({1920.0f, 1080.0f});
|
||||||
|
const auto& shapes = r2d.GetShapes();
|
||||||
|
check(shapes.size() == 3, "a canvas must draw its active children and skip inactive ones and nested canvases");
|
||||||
|
check(shapes[0].type == Shape2DType::Rect && shapes[0].center == glm::vec2{970.0f, 540.0f} &&
|
||||||
|
shapes[0].halfSize == glm::vec2{50.0f, 25.0f},
|
||||||
|
"a centered rect must be placed around its anchor, offset by its position");
|
||||||
|
check(shapes[1].type == Shape2DType::Ellipse && shapes[1].center == shapes[0].center &&
|
||||||
|
shapes[1].halfSize == shapes[0].halfSize,
|
||||||
|
"an object without a RectTransform must fill its parent and draw after it");
|
||||||
|
check(shapes[2].center == glm::vec2{1880.0f, 1040.0f},
|
||||||
|
"anchors and pivot must place a rect relative to the screen corner");
|
||||||
|
check(panelRect->GetScreenRect().min == glm::vec2{920.0f, 515.0f},
|
||||||
|
"the canvas must store where it drew each rect");
|
||||||
|
check(hiddenRect->GetScreenRect().size == glm::vec2{20.0f, 20.0f},
|
||||||
|
"hidden objects must still be laid out so the editor can show them");
|
||||||
|
|
||||||
|
r2d.Clear();
|
||||||
|
canvas->Draw({3840.0f, 2160.0f});
|
||||||
|
check(r2d.GetShapes().size() == 3 && r2d.GetShapes()[0].halfSize == glm::vec2{100.0f, 50.0f} &&
|
||||||
|
r2d.GetShapes()[0].center == glm::vec2{1940.0f, 1080.0f},
|
||||||
|
"sizes and offsets must scale with the screen height");
|
||||||
|
|
||||||
|
canvas->SetScaleMode(CanvasScaleMode::ConstantPixelSize);
|
||||||
|
r2d.Clear();
|
||||||
|
canvas->Draw({3840.0f, 2160.0f});
|
||||||
|
check(r2d.GetShapes()[0].halfSize == glm::vec2{50.0f, 25.0f}, "a constant pixel size canvas must not scale");
|
||||||
|
|
||||||
|
check(Canvas::FindFor(*fill) == canvas && Canvas::FindFor(*nested) == nested->GetComponent<Canvas>(),
|
||||||
|
"FindFor must return the closest canvas on the object or its parents");
|
||||||
|
check(cornerRect->GetParentRect().size == glm::vec2{3840.0f, 2160.0f},
|
||||||
|
"the canvas must store the parent rect each rect was laid out in");
|
||||||
|
|
||||||
|
cornerRect->ApplyAnchorPreset({0.0f, -1.0f});
|
||||||
|
check(cornerRect->GetAnchorMin() == glm::vec2{0.0f, 0.0f} && cornerRect->GetAnchorMax() == glm::vec2{0.0f, 1.0f} &&
|
||||||
|
cornerRect->GetPivot() == glm::vec2{0.0f, 0.5f} && cornerRect->GetPosition() == glm::vec2{0.0f} &&
|
||||||
|
cornerRect->GetSize() == glm::vec2{40.0f, 0.0f},
|
||||||
|
"an anchor preset must pin or stretch each axis and keep the size on pinned axes");
|
||||||
|
|
||||||
|
panelRect->Stretch();
|
||||||
|
check(panelRect->ComputeRect(UIRect{.min = {10.0f, 20.0f}, .size = {30.0f, 40.0f}}, 2.0f).size == glm::vec2{30.0f, 40.0f},
|
||||||
|
"a stretched rect must fill its parent");
|
||||||
|
r2d.Clear();
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testUITextPlacement()
|
||||||
|
{
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("ui text");
|
||||||
|
auto* text = scene.CreateGameObject("text")->AddComponent<UIText>("hi", 20.0f);
|
||||||
|
const UIRect rect{.min = {100.0f, 100.0f}, .size = {200.0f, 100.0f}};
|
||||||
|
|
||||||
|
check(text->PlaceText(rect, {50.0f, 20.0f}) == glm::vec2{175.0f, 140.0f}, "text must be centered by default");
|
||||||
|
text->SetAlignment(UITextAlign::End, UITextAlign::Start);
|
||||||
|
check(text->PlaceText(rect, {50.0f, 20.0f}) == glm::vec2{250.0f, 100.0f}, "text must follow its alignment");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testHudRoundTrip()
|
||||||
|
{
|
||||||
|
RegisterEngineComponents();
|
||||||
|
ComponentFactory::Register<CrosshairUI>("CrosshairUI");
|
||||||
|
ComponentFactory::Register<WinScreenUI>("WinScreenUI");
|
||||||
|
ComponentFactory::Register<WinGoal>("WinGoal");
|
||||||
|
ComponentFactory::Register<FirstPersonController>("FirstPersonController");
|
||||||
|
|
||||||
|
Scene& scene = SceneManager::GetInstance().CreateScene("hud");
|
||||||
|
GameObject* player = scene.CreateGameObject("player");
|
||||||
|
player->AddComponent<FirstPersonController>();
|
||||||
|
GameObject* goal = scene.CreateGameObject("goal");
|
||||||
|
auto* winGoal = goal->AddComponent<WinGoal>();
|
||||||
|
|
||||||
|
GameObject* hud = scene.CreateGameObject("hud");
|
||||||
|
hud->AddComponent<Canvas>()->SetReferenceHeight(720.0f);
|
||||||
|
GameObject* crosshair = scene.CreateGameObject("crosshair");
|
||||||
|
crosshair->GetTransform().SetParent(&hud->GetTransform(), false);
|
||||||
|
auto* crosshairRect = crosshair->AddComponent<RectTransform>();
|
||||||
|
crosshairRect->SetAnchors({0.25f, 0.5f}, {0.75f, 0.5f});
|
||||||
|
crosshair->AddComponent<UIImage>()->SetOutline(3.0f);
|
||||||
|
GameObject* screen = scene.CreateGameObject("screen");
|
||||||
|
screen->GetTransform().SetParent(&hud->GetTransform(), false);
|
||||||
|
auto* label = screen->AddComponent<UIText>("YOU WIN", 64.0f);
|
||||||
|
label->SetAlignment(UITextAlign::Start, UITextAlign::End);
|
||||||
|
|
||||||
|
auto* crosshairUi = hud->AddComponent<CrosshairUI>();
|
||||||
|
crosshairUi->SetPlayer(*player);
|
||||||
|
crosshairUi->SetCrosshair(*crosshair);
|
||||||
|
auto* winScreenUi = hud->AddComponent<WinScreenUI>();
|
||||||
|
winScreenUi->SetGoal(*goal);
|
||||||
|
winScreenUi->SetScreen(*screen);
|
||||||
|
scene.CommitPendingAdditions();
|
||||||
|
|
||||||
|
crosshairUi->LateUpdate(0.0f);
|
||||||
|
winScreenUi->LateUpdate(0.0f);
|
||||||
|
check(!crosshair->IsActive(), "the crosshair must hide while the player isn't possessed");
|
||||||
|
check(!screen->IsActive(), "the win screen must hide until the goal is reached");
|
||||||
|
winGoal->Activate(*player);
|
||||||
|
winScreenUi->LateUpdate(0.0f);
|
||||||
|
check(screen->IsActive(), "the win screen must show once the goal is reached");
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "destrum_hud_scene.json";
|
||||||
|
check(SceneSerializer::Save(scene, path), "the hud scene must save");
|
||||||
|
Scene& copy = SceneManager::GetInstance().CreateScene("hud copy");
|
||||||
|
check(SceneSerializer::Load(copy, path), "the hud scene must load");
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
copy.CommitPendingAdditions();
|
||||||
|
|
||||||
|
GameObject* loadedHud = findByName(copy, "hud");
|
||||||
|
GameObject* loadedCrosshair = findByName(copy, "crosshair");
|
||||||
|
GameObject* loadedScreen = findByName(copy, "screen");
|
||||||
|
check(loadedHud != nullptr && loadedCrosshair != nullptr && loadedScreen != nullptr,
|
||||||
|
"the hud objects must survive a round-trip");
|
||||||
|
check(loadedCrosshair->GetTransform().GetParent() == &loadedHud->GetTransform(),
|
||||||
|
"ui objects must stay parented to their canvas");
|
||||||
|
|
||||||
|
const auto* loadedCanvas = loadedHud->GetComponent<Canvas>();
|
||||||
|
check(loadedCanvas != nullptr && loadedCanvas->GetReferenceHeight() == 720.0f, "canvas settings must round-trip");
|
||||||
|
const auto* loadedRect = loadedCrosshair->GetComponent<RectTransform>();
|
||||||
|
check(loadedRect != nullptr && loadedRect->GetAnchorMin() == glm::vec2{0.25f, 0.5f} &&
|
||||||
|
loadedRect->GetAnchorMax() == glm::vec2{0.75f, 0.5f},
|
||||||
|
"rect transforms must round-trip");
|
||||||
|
const auto* loadedImage = loadedCrosshair->GetComponent<UIImage>();
|
||||||
|
check(loadedImage != nullptr && loadedImage->GetOutline() == 3.0f, "ui images must round-trip");
|
||||||
|
const auto* loadedLabel = loadedScreen->GetComponent<UIText>();
|
||||||
|
check(loadedLabel != nullptr && loadedLabel->GetText() == "YOU WIN" && loadedLabel->GetFontSize() == 64.0f &&
|
||||||
|
loadedLabel->GetHorizontalAlign() == UITextAlign::Start &&
|
||||||
|
loadedLabel->GetVerticalAlign() == UITextAlign::End,
|
||||||
|
"ui text must round-trip");
|
||||||
|
|
||||||
|
const auto* loadedCrosshairUi = loadedHud->GetComponent<CrosshairUI>();
|
||||||
|
const auto* loadedWinScreenUi = loadedHud->GetComponent<WinScreenUI>();
|
||||||
|
check(loadedCrosshairUi != nullptr && loadedCrosshairUi->GetPlayer().Get(copy) == findByName(copy, "player") &&
|
||||||
|
loadedCrosshairUi->GetCrosshair().Get(copy) == loadedCrosshair,
|
||||||
|
"crosshair references must point at the loaded objects");
|
||||||
|
check(loadedWinScreenUi != nullptr && loadedWinScreenUi->GetGoal().Get(copy) == findByName(copy, "goal") &&
|
||||||
|
loadedWinScreenUi->GetScreen().Get(copy) == loadedScreen,
|
||||||
|
"win screen references must point at the loaded objects");
|
||||||
|
|
||||||
|
SceneManager::GetInstance().Destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunUiTests()
|
||||||
|
{
|
||||||
|
testCanvasLayout();
|
||||||
|
testUITextPlacement();
|
||||||
|
testHudRoundTrip();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user