From d32636e91b1f72a87640f3e557651762ef9f79c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Wed, 30 Sep 2026 11:11:23 -0600 Subject: [PATCH] Unit tests: Add a spline/Bezier tessellation test Compares the software tessellator's positions, normals, UVs and colors against a longhand double-precision reference (Bernstein polynomials, Cox-de Boor with clamped knots, finite-difference normals), across Bezier and spline surfaces, edge types, poles and patch facing, so it can be optimized with something independent to be wrong against. Also reports vertices per second. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + android/jni/Android.mk | 1 + unittest/TestSplineTessellation.cpp | 479 ++++++++++++++++++++++++++++ unittest/UnitTest.cpp | 2 + unittest/UnitTests.vcxproj | 1 + unittest/UnitTests.vcxproj.filters | 1 + 6 files changed, 485 insertions(+) create mode 100644 unittest/TestSplineTessellation.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fc87b0ce15..590e82f13a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1373,6 +1373,7 @@ if(UNITTEST) unittest/TestZipSlip.cpp unittest/TestLzrc.cpp unittest/TestMpegCsc.cpp + unittest/TestSplineTessellation.cpp unittest/TestDemangle.cpp unittest/TestTextureReplacer.cpp unittest/TestRiscVEmitter.cpp diff --git a/android/jni/Android.mk b/android/jni/Android.mk index b2f58d6ca8..1d653b5579 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -1058,6 +1058,7 @@ ifeq ($(UNITTEST),1) $(SRC)/unittest/TestDemangle.cpp \ $(SRC)/unittest/TestLzrc.cpp \ $(SRC)/unittest/TestMpegCsc.cpp \ + $(SRC)/unittest/TestSplineTessellation.cpp \ $(SRC)/unittest/TestZipSlip.cpp \ $(SRC)/unittest/UnitTest.cpp diff --git a/unittest/TestSplineTessellation.cpp b/unittest/TestSplineTessellation.cpp new file mode 100644 index 0000000000..6917d29583 --- /dev/null +++ b/unittest/TestSplineTessellation.cpp @@ -0,0 +1,479 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +// Correctness and speed of the software Bezier/spline tessellation in GPU/Common/SplineCommon.cpp, +// so that it can be optimized with a quick way to see that it still gets the right answer. +// +// The reference evaluates the surfaces the textbook way, in double precision: Bernstein polynomials +// for Bezier patches, and Cox-de Boor for the cubic B-splines, with the knots clamped at an "open" +// edge. Normals come from finite differences, taken a hair inside the patch so that they also come +// out as the limit at a pole. It shares nothing with the tessellator on purpose. + +#include +#include +#include + +#include "Core/Config.h" +#include "Core/ConfigValues.h" +#include "GPU/Common/SplineCommon.h" +#include "GPU/ge_constants.h" + +#include "unittest/UnitTest.h" + +using namespace Spline; + +namespace { + +struct D3 { + double x = 0.0, y = 0.0, z = 0.0; + D3 operator+(const D3 &o) const { return { x + o.x, y + o.y, z + o.z }; } + D3 operator-(const D3 &o) const { return { x - o.x, y - o.y, z - o.z }; } + D3 operator*(double s) const { return { x * s, y * s, z * s }; } + double Length() const { return sqrt(x * x + y * y + z * z); } +}; + +D3 Cross(const D3 &a, const D3 &b) { + return { a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x }; +} + +// Everything a control point or a tessellated vertex carries, as doubles. +struct RefVertex { + D3 pos; + double uv[2]{}; + double color[4]{}; // 0-255 +}; + +double Bernstein(int i, double t) { + const double s = 1.0 - t; + switch (i) { + case 0: return s * s * s; + case 1: return 3.0 * t * s * s; + case 2: return 3.0 * t * t * s; + default: return t * t * t; + } +} + +// The knots of a cubic B-spline with count control points: uniform integers, so that the curve runs +// over [0, count - 3]. An open edge repeats its end knot four times, pulling the curve through the +// end control point. type bit 0 opens the first edge, bit 1 the last. +std::vector SplineKnots(int count, int type) { + const int n = count - 3; + std::vector knots(count + 4); + for (int j = 0; j < (int)knots.size(); j++) { + knots[j] = j - 3; + } + if (type & 1) { + for (int j = 0; j < 4; j++) { + knots[j] = 0.0; + } + } + if (type & 2) { + for (int j = n + 3; j < n + 7; j++) { + knots[j] = n; + } + } + return knots; +} + +// Cox-de Boor, for one basis function. +double BSplineBasis(const std::vector &knots, int i, int degree, double t) { + if (degree == 0) { + return knots[i] <= t && t < knots[i + 1] ? 1.0 : 0.0; + } + double result = 0.0; + const double d1 = knots[i + degree] - knots[i]; + if (d1 != 0.0) { + result += (t - knots[i]) / d1 * BSplineBasis(knots, i, degree - 1, t); + } + const double d2 = knots[i + degree + 1] - knots[i + 1]; + if (d2 != 0.0) { + result += (knots[i + degree + 1] - t) / d2 * BSplineBasis(knots, i + 1, degree - 1, t); + } + return result; +} + +// The four control points that affect a parameter along one axis, and their weights. Only these +// are evaluated, and nothing is allocated, so the test stays quick in a debug build. +struct Weights4 { + int first; + double w[4]; +}; + +// A surface to evaluate: either one Bezier patch (4x4 control points), or a whole spline. +struct RefSurface { + bool bezier = true; + int pointsU = 4, pointsV = 4; + std::vector points; // pointsU * pointsV, u fastest. + int firstU = 0, firstV = 0; // Bezier: the patch's first control point. + std::vector knotsU, knotsV; // Spline. + double endU = 1.0, endV = 1.0; // The parameter range is [0, end]. + + Weights4 Weights(bool alongU, double t) const { + Weights4 out; + if (bezier) { + out.first = alongU ? firstU : firstV; + for (int i = 0; i < 4; i++) { + out.w[i] = Bernstein(i, t); + } + return out; + } + // The basis is half-open, so step back from the very end of the range. + const double end = alongU ? endU : endV; + const double tt = std::min(t, end - 1e-9); + const std::vector &knots = alongU ? knotsU : knotsV; + // The span tt is in: knots[span] <= tt < knots[span + 1]. Basis functions span - 3 to span + // are the only ones that aren't zero there. + int span = 3; + while (span + 1 < (int)knots.size() - 4 && knots[span + 1] <= tt) { + span++; + } + out.first = span - 3; + for (int i = 0; i < 4; i++) { + out.w[i] = BSplineBasis(knots, out.first + i, 3, tt); + } + return out; + } + + RefVertex Evaluate(double u, double v, bool posOnly = false) const { + const Weights4 wu = Weights(true, u); + const Weights4 wv = Weights(false, v); + RefVertex out; + for (int j = 0; j < 4; j++) { + for (int i = 0; i < 4; i++) { + const double w = wu.w[i] * wv.w[j]; + const RefVertex &p = points[(wv.first + j) * pointsU + wu.first + i]; + out.pos = out.pos + p.pos * w; + if (posOnly) { + continue; + } + for (int c = 0; c < 2; c++) { + out.uv[c] += p.uv[c] * w; + } + for (int c = 0; c < 4; c++) { + out.color[c] += p.color[c] * w; + } + } + } + return out; + } + + // The surface normal, cross(dP/du, dP/dv), from central differences at a point moved a little + // towards the middle - at a pole the derivatives vanish, and this gives the limit. + D3 Normal(double u, double v) const { + const double inset = 1e-4, h = 1e-6; + u += (u < endU * 0.5) ? inset : -inset; + v += (v < endV * 0.5) ? inset : -inset; + const D3 du = (Evaluate(u + h, v, true).pos - Evaluate(u - h, v, true).pos) * (0.5 / h); + const D3 dv = (Evaluate(u, v + h, true).pos - Evaluate(u, v - h, true).pos) * (0.5 / h); + const D3 n = Cross(du, dv); + return n * (1.0 / n.Length()); + } +}; + +struct TestCase { + const char *name; + bool bezier; + int pointsU, pointsV; + int tessU, tessV; + int typeU = 0, typeV = 0; // Spline only. + u32 vertType = GE_VTYPE_POS_FLOAT | GE_VTYPE_NRM_FLOAT | GE_VTYPE_TC_FLOAT | GE_VTYPE_COL_8888; + bool patchFacing = false; + int poleEdge = -1; // Collapse this edge to one point: 0 = first row (v = 0), 1 = first column (u = 0). +}; + +// A bumpy, uneven grid of control points with varying UVs and colors, so that a mixed-up weight or +// attribute shows. +std::vector MakeControlPoints(const TestCase &tc) { + std::vector points(tc.pointsU * tc.pointsV); + for (int j = 0; j < tc.pointsV; j++) { + for (int i = 0; i < tc.pointsU; i++) { + SimpleVertex &p = points[j * tc.pointsU + i]; + p.pos = Vec3Packedf(i * 0.5f + 0.07f * j, 0.4f * sinf(i * 0.7f + j * 0.3f) + 0.1f * i, j * 0.45f - 0.05f * i); + p.nrm = Vec3Packedf(0.0f, 1.0f, 0.0f); + p.uv[0] = i * 0.25f + 0.02f * j; + p.uv[1] = j * 0.3f; + p.color[0] = (u8)(40 + 13 * i); + p.color[1] = (u8)(200 - 11 * j); + p.color[2] = (u8)(90 + 7 * i + 5 * j); + p.color[3] = (u8)(255 - 9 * i); + } + } + if (tc.poleEdge == 0) { + for (int i = 1; i < tc.pointsU; i++) { + points[i].pos = points[0].pos; + } + } else if (tc.poleEdge == 1) { + for (int j = 1; j < tc.pointsV; j++) { + points[j * tc.pointsU].pos = points[0].pos; + } + } + return points; +} + +RefSurface MakeReference(const TestCase &tc, const std::vector &points) { + RefSurface ref; + ref.bezier = tc.bezier; + ref.pointsU = tc.pointsU; + ref.pointsV = tc.pointsV; + for (const SimpleVertex &p : points) { + RefVertex r; + r.pos = { p.pos.x, p.pos.y, p.pos.z }; + r.uv[0] = p.uv[0]; + r.uv[1] = p.uv[1]; + for (int c = 0; c < 4; c++) { + r.color[c] = p.color[c]; + } + ref.points.push_back(r); + } + if (!tc.bezier) { + ref.knotsU = SplineKnots(tc.pointsU, tc.typeU); + ref.knotsV = SplineKnots(tc.pointsV, tc.typeV); + ref.endU = tc.pointsU - 3; + ref.endV = tc.pointsV - 3; + } + return ref; +} + +struct Output { + std::vector vertices; + std::vector indices; + int indexCount = 0; + int tessU = 0, tessV = 0; // As the surface ended up, after Init. + int patchesU = 0, patchesV = 0; +}; + +template +void Tessellate(Surface &surface, u32 vertType, const std::vector &points, int numVerts, int numIndices, Output &out) { + const int numPoints = surface.num_points_u * surface.num_points_v; + std::vector pointers(numPoints); + for (int i = 0; i < numPoints; i++) { + pointers[i] = &points[i]; + } + std::vector pos(numPoints); + std::vector tex(numPoints); + std::vector col(numPoints); + ControlPoints cpoints; + cpoints.pos = pos.data(); + cpoints.tex = tex.data(); + cpoints.col = col.data(); + cpoints.Convert(pointers.data(), numPoints); + + out.vertices.assign(numVerts, SimpleVertex{}); + out.indices.assign(numIndices, 0); + OutputBuffers buffers; + buffers.vertices = out.vertices.data(); + buffers.indices = out.indices.data(); + buffers.count = 0; + SoftwareTessellation(buffers, surface, vertType, cpoints); + out.indexCount = buffers.count; + out.tessU = surface.tess_u; + out.tessV = surface.tess_v; + out.patchesU = surface.num_patches_u; + out.patchesV = surface.num_patches_v; +} + +void RunTessellator(const TestCase &tc, const std::vector &points, Output &out) { + const int maxVertices = 65536; + if (tc.bezier) { + BezierSurface surface{}; + surface.num_points_u = tc.pointsU; + surface.num_points_v = tc.pointsV; + surface.tess_u = tc.tessU; + surface.tess_v = tc.tessV; + surface.num_patches_u = (tc.pointsU - 1) / 3; + surface.num_patches_v = (tc.pointsV - 1) / 3; + surface.primType = GE_PATCHPRIM_TRIANGLES; + surface.patchFacing = tc.patchFacing; + surface.Init(maxVertices); + const int patches = surface.num_patches_u * surface.num_patches_v; + Tessellate(surface, tc.vertType, points, (surface.tess_u + 1) * (surface.tess_v + 1) * patches, surface.tess_u * surface.tess_v * 6 * patches, out); + } else { + SplineSurface surface{}; + surface.num_points_u = tc.pointsU; + surface.num_points_v = tc.pointsV; + surface.tess_u = tc.tessU; + surface.tess_v = tc.tessV; + surface.type_u = tc.typeU; + surface.type_v = tc.typeV; + surface.num_patches_u = tc.pointsU - 3; + surface.num_patches_v = tc.pointsV - 3; + surface.primType = GE_PATCHPRIM_TRIANGLES; + surface.patchFacing = tc.patchFacing; + surface.Init(maxVertices); + const int divU = surface.num_patches_u * surface.tess_u; + const int divV = surface.num_patches_v * surface.tess_v; + Tessellate(surface, tc.vertType, points, (divU + 1) * (divV + 1), divU * divV * 6, out); + } +} + +bool CheckVertex(const TestCase &tc, const RefSurface &ref, const SimpleVertex &got, const SimpleVertex &defaultPoint, + double u, double v, double genU, double genV, int index) { + const RefVertex want = ref.Evaluate(u, v); + const D3 gotPos = { got.pos.x, got.pos.y, got.pos.z }; + const double posError = (gotPos - want.pos).Length(); + if (posError > 1e-4) { + printf("%s: vertex %d at (%.3f, %.3f): pos %f %f %f, want %f %f %f\n", tc.name, index, u, v, + gotPos.x, gotPos.y, gotPos.z, want.pos.x, want.pos.y, want.pos.z); + return false; + } + + const bool hasTex = (tc.vertType & GE_VTYPE_TC_MASK) != 0; + const double wantU = hasTex ? want.uv[0] : genU; + const double wantV = hasTex ? want.uv[1] : genV; + if (fabs(got.uv[0] - wantU) > 1e-4 || fabs(got.uv[1] - wantV) > 1e-4) { + printf("%s: vertex %d at (%.3f, %.3f): uv %f %f, want %f %f\n", tc.name, index, u, v, got.uv[0], got.uv[1], wantU, wantV); + return false; + } + + const bool hasColor = (tc.vertType & GE_VTYPE_COL_MASK) != 0; + for (int c = 0; c < 4; c++) { + const double wantC = hasColor ? want.color[c] : defaultPoint.color[c]; + if (fabs(got.color[c] - wantC) > 1.001) { + printf("%s: vertex %d at (%.3f, %.3f): color channel %d is %d, want %.2f\n", tc.name, index, u, v, c, got.color[c], wantC); + return false; + } + } + + const bool hasNormal = (tc.vertType & GE_VTYPE_NRM_MASK) != 0; + D3 wantN = { 0.0, 0.0, 1.0 }; + if (hasNormal) { + wantN = ref.Normal(u, v); + if (tc.patchFacing) { + wantN = wantN * -1.0; + } + } + const D3 gotN = { got.nrm.x, got.nrm.y, got.nrm.z }; + const double dot = gotN.x * wantN.x + gotN.y * wantN.y + gotN.z * wantN.z; + if (!(fabs(gotN.Length() - 1.0) < 1e-4 && dot > 0.9999)) { + printf("%s: vertex %d at (%.3f, %.3f): normal %f %f %f, want %f %f %f\n", tc.name, index, u, v, + gotN.x, gotN.y, gotN.z, wantN.x, wantN.y, wantN.z); + return false; + } + return true; +} + +bool CheckIndices(const TestCase &tc, const Output &out, int expectedCount) { + if (out.indexCount != expectedCount) { + printf("%s: %d indices, want %d\n", tc.name, out.indexCount, expectedCount); + return false; + } + for (int i = 0; i < out.indexCount; i++) { + if (out.indices[i] >= out.vertices.size()) { + printf("%s: index %d is %d, past the %d vertices\n", tc.name, i, out.indices[i], (int)out.vertices.size()); + return false; + } + } + return true; +} + +bool CheckCase(const TestCase &tc) { + const std::vector points = MakeControlPoints(tc); + Output out; + RunTessellator(tc, points, out); + + if (tc.bezier) { + const int verticesPerPatch = (out.tessU + 1) * (out.tessV + 1); + for (int pv = 0; pv < out.patchesV; pv++) { + for (int pu = 0; pu < out.patchesU; pu++) { + RefSurface ref = MakeReference(tc, points); + ref.firstU = pu * 3; + ref.firstV = pv * 3; + const int base = (pv * out.patchesU + pu) * verticesPerPatch; + for (int tv = 0; tv <= out.tessV; tv++) { + for (int tu = 0; tu <= out.tessU; tu++) { + const int index = base + tv * (out.tessU + 1) + tu; + const double u = (double)tu / out.tessU, v = (double)tv / out.tessV; + if (!CheckVertex(tc, ref, out.vertices[index], points[0], u, v, pu + u, pv + v, index)) { + return false; + } + } + } + } + } + return CheckIndices(tc, out, out.tessU * out.tessV * 6 * out.patchesU * out.patchesV); + } + + const RefSurface ref = MakeReference(tc, points); + const int divU = out.patchesU * out.tessU, divV = out.patchesV * out.tessV; + for (int iv = 0; iv <= divV; iv++) { + for (int iu = 0; iu <= divU; iu++) { + const int index = iv * (divU + 1) + iu; + const double u = (double)iu / out.tessU, v = (double)iv / out.tessV; + if (!CheckVertex(tc, ref, out.vertices[index], points[0], u, v, u, v, index)) { + return false; + } + } + } + return CheckIndices(tc, out, divU * divV * 6); +} + +} // namespace + +bool TestSplineTessellation() { + const int savedQuality = g_Config.iSplineBezierQuality; + g_Config.iSplineBezierQuality = (int)SplineQuality::HIGH_QUALITY; + + const u32 posOnly = GE_VTYPE_POS_FLOAT; + const u32 posNrm = GE_VTYPE_POS_FLOAT | GE_VTYPE_NRM_FLOAT; + + std::vector cases = { + { "bezier, one patch", true, 4, 4, 8, 8 }, + { "bezier, 2x3 patches, uneven tessellation", true, 7, 10, 5, 3 }, + { "bezier, no attributes", true, 7, 4, 4, 6, 0, 0, posOnly }, + { "bezier, normals only", true, 4, 7, 6, 2, 0, 0, posNrm }, + { "bezier, patch facing", true, 4, 4, 5, 5, 0, 0, posNrm, true }, + { "bezier, pole at v = 0", true, 4, 4, 8, 8, 0, 0, posNrm, false, 0 }, + { "bezier, pole at u = 0", true, 4, 4, 8, 8, 0, 0, posNrm, false, 1 }, + { "spline, pole at v = 0, open", false, 5, 6, 4, 4, 3, 3, posNrm, false, 0 }, + }; + // Each edge type on each axis, one axis with three patches and the other with two. + cases.push_back({ "spline, closed/open", false, 6, 5, 4, 6, 0, 3 }); + cases.push_back({ "spline, open first/open last", false, 6, 5, 4, 6, 1, 2 }); + cases.push_back({ "spline, open last/open first", false, 6, 5, 4, 6, 2, 1 }); + cases.push_back({ "spline, open/closed", false, 6, 5, 4, 6, 3, 0 }); + // A single patch, where both ends' knots meet in the same span. + cases.push_back({ "spline, one patch, closed", false, 4, 4, 3, 3, 0, 0 }); + cases.push_back({ "spline, one patch, open first", false, 4, 4, 3, 3, 1, 1 }); + cases.push_back({ "spline, one patch, open last", false, 4, 4, 3, 3, 2, 2 }); + cases.push_back({ "spline, one patch, open", false, 4, 4, 3, 3, 3, 3 }); + cases.push_back({ "spline, no attributes", false, 7, 5, 2, 5, 1, 2, posOnly }); + cases.push_back({ "spline, patch facing", false, 5, 5, 4, 4, 0, 3, posNrm, true }); + + bool ok = true; + for (const TestCase &tc : cases) { + if (!CheckCase(tc)) { + ok = false; + } + } + + // Speed, for whoever is optimizing this. A big spline and a batch of Bezier patches, the two + // shapes games send, with every attribute sampled. + { + TestCase spline = { "speed, spline", false, 10, 10, 8, 8, 3, 3 }; + TestCase bezier = { "speed, bezier", true, 10, 10, 8, 8 }; + for (const TestCase *tc : { &spline, &bezier }) { + const std::vector points = MakeControlPoints(*tc); + Output out; + const double callsPerSecond = CallsPerSecond([&] { RunTessellator(*tc, points, out); }, 0.1, 1); + printf("%s: %dx%d control points, %d vertices: %.2f Mverts/s\n", tc->name, tc->pointsU, tc->pointsV, + (int)out.vertices.size(), callsPerSecond * out.vertices.size() / 1000000.0); + } + } + + g_Config.iSplineBezierQuality = savedQuality; + return ok; +} diff --git a/unittest/UnitTest.cpp b/unittest/UnitTest.cpp index 0398d1879f..8831ccf636 100644 --- a/unittest/UnitTest.cpp +++ b/unittest/UnitTest.cpp @@ -2942,6 +2942,7 @@ bool TestVFS(); bool TestZipSlip(); bool TestLzrc(); bool TestMpegCsc(); +bool TestSplineTessellation(); bool TestDemangle(); // The 8.3 short names games read out of d_private. These aren't verified against hardware yet (no @@ -3163,6 +3164,7 @@ TestItem availableTests[] = { TEST_ITEM(ZipSlip), TEST_ITEM(Lzrc), TEST_ITEM(MpegCsc), + TEST_ITEM(SplineTessellation), TEST_ITEM(Demangle), TEST_ITEM(TextureReplacer), TEST_ITEM(UITabOrder), diff --git a/unittest/UnitTests.vcxproj b/unittest/UnitTests.vcxproj index e77fb4ba3f..3602781f19 100644 --- a/unittest/UnitTests.vcxproj +++ b/unittest/UnitTests.vcxproj @@ -296,6 +296,7 @@ + diff --git a/unittest/UnitTests.vcxproj.filters b/unittest/UnitTests.vcxproj.filters index 9d249d43dd..2bd5c4dd27 100644 --- a/unittest/UnitTests.vcxproj.filters +++ b/unittest/UnitTests.vcxproj.filters @@ -19,6 +19,7 @@ +