#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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()) && 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 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(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 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 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 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 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{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(); }