Revision control
Copy as Markdown
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
#include <QtTest>
#include <QtQuick3DUtils/private/qssgmesh_p.h>
#include <QtGui/QVector2D>
#include <QtGui/QVector3D>
class tst_mesh : public QObject
{
Q_OBJECT
private slots:
void weldVertices();
void weldVerticesComparesEveryStream();
void levelsOfDetailWithoutNormals();
void levelsOfDetailClearsSplitVertices();
void levelsOfDetailFromUnweldedMesh();
void levelsOfDetailReachTheFinestLevel();
void tangentsFollowTheTextureCoordinates();
};
// Two triangles given as six separate vertices, where three of them repeat a
// position and normal that another vertex already has, so welding has to find
// exactly four unique vertices.
static void makeQuadSoup(QVector<QVector3D> &positions, QVector<QVector3D> &normals, QVector<quint32> &indexes)
{
const QVector3D up(0.0f, 0.0f, 1.0f);
positions = { { 0, 0, 0 }, { 1, 0, 0 }, { 1, 1, 0 }, { 0, 0, 0 }, { 1, 1, 0 }, { 0, 1, 0 } };
normals = { up, up, up, up, up, up };
indexes = { 0, 1, 2, 3, 4, 5 };
}
static QVector<unsigned int> weld(const QVector<QVector3D> &positions,
const QVector<QVector3D> &normals,
const QVector<quint32> &indexes,
size_t *uniqueCount)
{
const QSSGMesh::MeshVertexStream streams[] = {
{ positions.constData(), sizeof(QVector3D), sizeof(QVector3D) },
{ normals.constData(), sizeof(QVector3D), sizeof(QVector3D) }
};
QVector<unsigned int> remap(positions.size());
*uniqueCount = QSSGMesh::generateVertexRemap(remap.data(),
indexes.constData(),
indexes.size(),
positions.size(),
streams,
std::size(streams));
return remap;
}
void tst_mesh::weldVertices()
{
QVector<QVector3D> positions;
QVector<QVector3D> normals;
QVector<quint32> indexes;
makeQuadSoup(positions, normals, indexes);
size_t uniqueCount = 0;
const QVector<unsigned int> remap = weld(positions, normals, indexes, &uniqueCount);
QCOMPARE(uniqueCount, size_t(4));
QVector<QVector3D> weldedPositions(uniqueCount);
QSSGMesh::remapVertexBuffer(weldedPositions.data(),
positions.constData(),
positions.size(),
sizeof(QVector3D),
remap.constData());
QVector<unsigned int> weldedIndexes(indexes.size());
QSSGMesh::remapIndexBuffer(weldedIndexes.data(),
indexes.constData(),
indexes.size(),
remap.constData());
// The whole point of the remap: drawing the welded buffers has to produce
// the same geometry as drawing the original ones.
QCOMPARE(weldedIndexes.size(), indexes.size());
for (qsizetype i = 0; i < indexes.size(); ++i) {
QVERIFY(weldedIndexes[i] < uniqueCount);
QCOMPARE(weldedPositions[weldedIndexes[i]], positions[indexes[i]]);
}
}
void tst_mesh::weldVerticesComparesEveryStream()
{
QVector<QVector3D> positions;
QVector<QVector3D> normals;
QVector<quint32> indexes;
makeQuadSoup(positions, normals, indexes);
// A hard edge is a shared position carrying two different normals, so
// these two vertices must survive as separate ones even though welding on
// position alone would merge them.
normals[3] = QVector3D(0.0f, 1.0f, 0.0f);
size_t uniqueCount = 0;
weld(positions, normals, indexes, &uniqueCount);
QCOMPARE(uniqueCount, size_t(5));
}
// A height field, so that simplification has something to lose and normals
// actually vary across the surface.
static void makeHeightField(int quadsPerSide,
QVector<QVector3D> &positions,
QVector<QVector3D> &normals,
QVector<quint32> &indexes)
{
for (int y = 0; y <= quadsPerSide; ++y) {
for (int x = 0; x <= quadsPerSide; ++x) {
const float fx = float(x);
const float fy = float(y);
positions.append(QVector3D(fx, fy, std::sin(fx * 0.5f) * std::cos(fy * 0.5f)));
// The analytic normal of z = sin(x/2)cos(y/2)
const QVector3D normal(-0.5f * std::cos(fx * 0.5f) * std::cos(fy * 0.5f),
0.5f * std::sin(fx * 0.5f) * std::sin(fy * 0.5f),
1.0f);
normals.append(normal.normalized());
}
}
const int stride = quadsPerSide + 1;
for (int y = 0; y < quadsPerSide; ++y) {
for (int x = 0; x < quadsPerSide; ++x) {
const quint32 i0 = quint32(y * stride + x);
const quint32 i1 = i0 + 1;
const quint32 i2 = i0 + quint32(stride);
const quint32 i3 = i2 + 1;
indexes << i0 << i1 << i2 << i1 << i3 << i2;
}
}
}
// Normals are optional in glTF, so the importer calls this with an empty
// normals vector. Correcting normals then has nothing to read, and must be
// skipped rather than indexing past the end of the vector.
void tst_mesh::levelsOfDetailWithoutNormals()
{
QVector<QVector3D> positions;
QVector<QVector3D> normals;
QVector<quint32> indexes;
makeHeightField(12, positions, normals, indexes);
QVector<QSSGMesh::MeshVertexSplit> splitVertices;
const auto lods = QSSGMesh::generateMeshLevelsOfDetail(positions, {}, indexes, splitVertices);
// No normals means no normal correction, so no vertex can have been split
QVERIFY(splitVertices.isEmpty());
for (const auto &lod : lods) {
for (const quint32 index : lod.indexes)
QVERIFY(index < quint32(positions.size()));
}
}
// The split vertices are reported positionally, starting at positions.size(),
// so anything left over from an earlier call would make the caller append the
// wrong vertices.
void tst_mesh::levelsOfDetailClearsSplitVertices()
{
QVector<QVector3D> positions;
QVector<QVector3D> normals;
QVector<quint32> indexes;
makeHeightField(12, positions, normals, indexes);
constexpr quint32 sentinel = 123456;
QVector<QSSGMesh::MeshVertexSplit> splitVertices;
splitVertices.append({ sentinel, QVector3D(1.0f, 0.0f, 0.0f) });
const auto lods = QSSGMesh::generateMeshLevelsOfDetail(positions, normals, indexes, splitVertices);
for (const auto &split : splitVertices)
QVERIFY(split.sourceIndex != sentinel);
const quint32 vertexCount = quint32(positions.size() + splitVertices.size());
for (const auto &lod : lods) {
for (const quint32 index : lod.indexes)
QVERIFY(index < vertexCount);
}
}
// An edge can only collapse when the faces around it share its vertices, so a
// vertex buffer that repeats a position pins every edge touching it. Assets are
// commonly exported with one vertex per triangle corner, which pins the whole
// mesh, and simplification then returns the input unchanged - which used to
// leave the asset with no levels at all.
void tst_mesh::levelsOfDetailFromUnweldedMesh()
{
QVector<QVector3D> positions;
QVector<QVector3D> normals;
QVector<quint32> indexes;
makeHeightField(24, positions, normals, indexes);
// Give every triangle corner a vertex of its own
QVector<QVector3D> soupPositions;
QVector<QVector3D> soupNormals;
QVector<quint32> soupIndexes;
for (const quint32 index : indexes) {
soupIndexes.append(quint32(soupPositions.size()));
soupPositions.append(positions.at(index));
soupNormals.append(normals.at(index));
}
QCOMPARE(soupPositions.size(), soupIndexes.size());
QVector<QSSGMesh::MeshVertexSplit> splitVertices;
const auto lods = QSSGMesh::generateMeshLevelsOfDetail(soupPositions, soupNormals, soupIndexes,
splitVertices);
QVERIFY(!lods.isEmpty());
const quint32 vertexCount = quint32(soupPositions.size() + splitVertices.size());
qsizetype previousSize = 0;
for (const auto &lod : lods) {
// Levels are produced coarsest first, and each one has to be a real
// reduction of the original
QCOMPARE(lod.indexes.size() % 3, 0);
QVERIFY(lod.indexes.size() > previousSize);
QVERIFY(lod.indexes.size() < soupIndexes.size());
previousSize = lod.indexes.size();
for (const quint32 index : lod.indexes)
QVERIFY(index < vertexCount);
}
}
// The index target doubles after every level, which used to step over the three
// quarter limit and end the loop while a level still fitted under it. A 12x12
// height field, 864 indexes, is sized to hit that case.
void tst_mesh::levelsOfDetailReachTheFinestLevel()
{
QVector<QVector3D> positions;
QVector<QVector3D> normals;
QVector<quint32> indexes;
makeHeightField(12, positions, normals, indexes);
// Sized so that the doubled target overshoots the limit
QCOMPARE(indexes.size(), 864);
QVector<QSSGMesh::MeshVertexSplit> splitVertices;
const auto lods = QSSGMesh::generateMeshLevelsOfDetail(positions, normals, indexes, splitVertices);
QVERIFY(!lods.isEmpty());
// Levels come back from the smallest index count to the largest
const quint32 indexCount = quint32(indexes.size());
const quint32 finest = quint32(lods.last().indexes.size());
// A level half again this size would still fit, so stopping here means one
// was left ungenerated
QVERIFY2(finest * 2 >= indexCount,
qPrintable(QStringLiteral("finest level %1 of %2 indexes leaves room for another")
.arg(finest).arg(indexCount)));
QVERIFY(finest < indexCount);
}
// A quad in the xy plane whose texture coordinates run along +x and +y, so the
// tangent has to come out along +x and the bitangent along +y.
void tst_mesh::tangentsFollowTheTextureCoordinates()
{
const QVector<QVector3D> positions = { { 0, 0, 0 }, { 1, 0, 0 }, { 1, 1, 0 }, { 0, 1, 0 } };
const QVector<QVector3D> normals(4, QVector3D(0, 0, 1));
const QVector<QVector2D> uvs = { { 0, 0 }, { 1, 0 }, { 1, 1 }, { 0, 1 } };
const QVector<quint32> indexes = { 0, 1, 2, 0, 2, 3 };
QVector<float> tangents(indexes.size() * 4);
QSSGMesh::generateTangents(tangents.data(), indexes.constData(), size_t(indexes.size()),
reinterpret_cast<const float *>(positions.constData()),
size_t(positions.size()), sizeof(QVector3D),
reinterpret_cast<const float *>(normals.constData()), sizeof(QVector3D),
reinterpret_cast<const float *>(uvs.constData()), sizeof(QVector2D));
for (qsizetype corner = 0; corner < indexes.size(); ++corner) {
const float *t = tangents.constData() + corner * 4;
const QVector3D tangent(t[0], t[1], t[2]);
QVERIFY2(qFuzzyCompare(tangent, QVector3D(1, 0, 0)),
qPrintable(QStringLiteral("corner %1 tangent %2 %3 %4")
.arg(corner).arg(t[0]).arg(t[1]).arg(t[2])));
// The sign is what turns the tangent and the normal into the bitangent
const QVector3D bitangent = QVector3D::crossProduct(normals.at(indexes.at(corner)), tangent) * t[3];
QVERIFY(qFuzzyCompare(bitangent, QVector3D(0, 1, 0)));
}
}
QTEST_APPLESS_MAIN(tst_mesh)
#include "tst_mesh.moc"