Files
Destrum/tests/graphics_tests.cpp

433 lines
21 KiB
C++

#include <algorithm>
#include <array>
#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 testPrimitiveShapes()
{
using PrimitiveMeshes::Shape;
const std::array<PrimitiveMeshes::Desc, 6> descs{{
{.shape = Shape::Box, .size = {2.0f, 0.5f, 3.0f}},
{.shape = Shape::Plane, .size = {4.0f, 1.0f, 6.0f}},
{.shape = Shape::Sphere, .radius = 0.75f},
{.shape = Shape::Cylinder, .radius = 0.5f, .height = 3.0f},
{.shape = Shape::Capsule, .radius = 0.4f, .height = 2.0f},
{.shape = Shape::Capsule, .radius = 1.0f, .height = 1.0f},
}};
for (const auto& desc : descs) {
const std::string key = PrimitiveMeshes::MakeKey(desc);
const auto parsed = PrimitiveMeshes::ParseKey(key + "#2");
check(parsed.has_value() && *parsed == desc, "every primitive must rebuild exactly from its key");
const CPUMesh mesh = PrimitiveMeshes::Make(desc);
check(mesh.name == key, "primitives must be named by their key");
check(!mesh.indices.empty() && mesh.indices.size() % 3 == 0, "primitives must be made of triangles");
bool valid = true;
for (const std::uint32_t index : mesh.indices) {
valid = valid && index < mesh.vertices.size();
}
check(valid, "primitive indices must point at their own vertices");
bool outward = true;
bool tangents = true;
for (const auto& vertex : mesh.vertices) {
outward = outward && close(glm::length(vertex.normal), 1.0f) && glm::dot(vertex.position, vertex.normal) > -0.001f;
tangents = tangents && close(glm::dot(glm::vec3{vertex.tangent}, vertex.normal), 0.0f) &&
close(std::abs(vertex.tangent.w), 1.0f);
}
check(outward, "primitive normals must be unit length and point away from the center");
check(tangents, "primitive tangents must lie in the surface with a handedness of +-1");
bool counterClockwise = true;
for (std::size_t i = 0; i < mesh.indices.size(); i += 3) {
const auto& a = mesh.vertices[mesh.indices[i]];
const auto& b = mesh.vertices[mesh.indices[i + 1]];
const auto& c = mesh.vertices[mesh.indices[i + 2]];
const glm::vec3 faceNormal = glm::cross(b.position - a.position, c.position - a.position);
counterClockwise = counterClockwise && glm::dot(faceNormal, a.normal + b.normal + c.normal) >= 0.0f;
}
check(counterClockwise, "primitive triangles must be counter clockwise seen from outside");
}
const CPUMesh sphere = PrimitiveMeshes::Make(descs[2]);
check(close(sphere.maxPos.x, 0.75f) && close(sphere.minPos.y, -0.75f), "a sphere must reach its radius");
const CPUMesh plane = PrimitiveMeshes::Make(descs[1]);
check(close(plane.maxPos.x, 2.0f) && close(plane.maxPos.z, 3.0f) && close(plane.maxPos.y, 0.0f),
"a plane must be flat on y = 0 with its size on x and z");
const CPUMesh capsule = PrimitiveMeshes::Make(descs[4]);
check(close(capsule.maxPos.y, 1.0f) && close(capsule.maxPos.x, 0.4f),
"a capsule height must be its full length, like the collider");
const CPUMesh cylinder = PrimitiveMeshes::Make(descs[3]);
check(close(cylinder.maxPos.y, 1.5f) && close(cylinder.minPos.y, -1.5f), "a cylinder must be centered on its height");
check(!PrimitiveMeshes::ParseKey("prim://sphere?radius=1&radius=2").has_value() &&
!PrimitiveMeshes::ParseKey("prim://cylinder?radius=1").has_value() &&
!PrimitiveMeshes::ParseKey("prim://plane?size=1,2,3").has_value() &&
!PrimitiveMeshes::ParseKey("prim://cone?radius=1").has_value() &&
!PrimitiveMeshes::ParseKey("prim://sphere?radius=-1").has_value() &&
!PrimitiveMeshes::ParseKey("prim://sphere").has_value(),
"malformed primitive keys must be rejected");
}
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();
testPrimitiveShapes();
testDeletionQueueWaitsForFrame();
testIndirectDrawListBatchesByMesh();
}