feat: split tests into per-suite files
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user