Revision control
Copy as Markdown
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
#include <QTest>
#include <QSignalSpy>
#include <QDir>
#include <QFile>
#include <QRegularExpression>
#include <QtQml/QQmlComponent>
#include <QtQuick/QQuickView>
#include <QtQuick3D/private/qquick3dviewport_p.h>
#include <QtQuick3D/private/qquick3drenderpass_p.h>
#include <QtQuick3D/private/qquick3dobject_p.h>
#include <QtQuick3DRuntimeRender/private/qssgrenderuserpass_p.h>
#include <QtQuick3DRuntimeRender/private/qssguserrenderpassmanager_p.h>
#include "../shared/util.h"
class tst_UserPasses : public QQuick3DDataTest
{
Q_OBJECT
private slots:
void initTestCase() override;
void testBasicRenderPass();
void testMultipleRenderPasses();
void testMultipleRenderTargets();
void testDisableInternalPasses();
void testRenderPassTextures();
void testRenderPassTextures_data();
void testSubRenderPass();
void testContentLayers();
void testRenderablesFilter();
void testRenderablesFilter_data();
void testPipelineStateOverride();
void testPipelineStateOverride_data();
void subPassOverrideMaterial();
void multipleSubPasses();
void subPassNestingDepth();
void userPassManagerScheduling();
void subPassRoleTransition();
void subPassRefCounting();
void subPassCommandDestroyed();
void referencedPassDestroyed();
void unreferencedPassReturnsToTopLevel();
void nestedPassOrdering();
void nodeParentedPassIsTopLevel();
void reparentUpdatesDepth();
void multiConsumerProducerDepth();
void slotLimitDoesNotCrash();
void depthLimitDoesNotCrash();
void testAddDefine();
void testAugmentShaderTextureBinding();
void testAugmentShaderArrayTextureBinding();
void testSkyboxPass();
void testItem2DPass();
void testDepthTestDisabled();
void testBlendEnabled();
void testPreserveColorContents();
void testViewportOverride();
void testScreenTexture();
void testCullModeOverrideSubpass_data();
void testCullModeOverrideSubpass();
void testBlendOverrideOriginalMaterial();
void noColorAttachmentDoesNotCrash();
void testAugmentShaderWithCustomMaterial();
private:
// Helper function to check if an image contains a specific color (with lighting effects)
bool imageContainsColor(const QImage &image, const QColor &color, int tolerance = 30);
// Helper to check if image contains a dominant color (e.g. mostly red, mostly green)
bool imageContainsDominantColor(const QImage &image, const QColor &color, int minValue = 100);
// Helper to check if image is not all black (something rendered)
bool imageHasNonBlackPixels(const QImage &image);
};
void tst_UserPasses::initTestCase()
{
QQuick3DDataTest::initTestCase();
if (!initialized())
QSKIP("Could not initialize visual test");
}
bool tst_UserPasses::imageContainsColor(const QImage &image, const QColor &color, int tolerance)
{
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
QColor pixel = image.pixelColor(x, y);
int rDiff = qAbs(pixel.red() - color.red());
int gDiff = qAbs(pixel.green() - color.green());
int bDiff = qAbs(pixel.blue() - color.blue());
if (rDiff <= tolerance && gDiff <= tolerance && bDiff <= tolerance) {
return true;
}
}
}
return false;
}
bool tst_UserPasses::imageContainsDominantColor(const QImage &image, const QColor &color, int minValue)
{
// Check which component is dominant in the expected color
int r = color.red();
int g = color.green();
int b = color.blue();
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
QColor pixel = image.pixelColor(x, y);
// For gray (all channels equal or similar)
if (qAbs(r - g) < 20 && qAbs(g - b) < 20 && qAbs(r - b) < 20) {
int pixelAvg = (pixel.red() + pixel.green() + pixel.blue()) / 3;
int colorAvg = (r + g + b) / 3;
if (qAbs(pixelAvg - colorAvg) < 50) {
return true;
}
}
// For red: R should be high and dominant
else if (r > g && r > b && r > 128) {
if (pixel.red() >= minValue && pixel.red() > pixel.green() && pixel.red() > pixel.blue()) {
return true;
}
}
// For green: G should be high and dominant
else if (g > r && g > b && g > 128) {
if (pixel.green() >= minValue && pixel.green() > pixel.red() && pixel.green() > pixel.blue()) {
return true;
}
}
// For blue: B should be high and dominant
else if (b > r && b > g && b > 128) {
if (pixel.blue() >= minValue && pixel.blue() > pixel.red() && pixel.blue() > pixel.green()) {
return true;
}
}
// For dark blue (blue dominant but all low values)
else if (b > r && b > g && b < 200) {
if (pixel.blue() > pixel.red() && pixel.blue() > pixel.green() && pixel.blue() >= 50) {
return true;
}
}
// For cyan (G and B high, R low)
else if (r < 128 && g > 128 && b > 128) {
if (pixel.red() < 128 && pixel.green() >= minValue && pixel.blue() >= minValue) {
return true;
}
}
// For yellow (R and G high, B low)
else if (r > 128 && g > 128 && b < 128) {
if (pixel.red() >= minValue && pixel.green() >= minValue && pixel.blue() < 128) {
return true;
}
}
// For purple/magenta (R and B high, G low)
else if (r > 64 && g < 128 && b > 64) {
if (pixel.red() >= 50 && pixel.green() < 128 && pixel.blue() >= 50) {
return true;
}
}
// For orange (R high, G medium, B low)
else if (r > 128 && g > 64 && b < 128) {
if (pixel.red() >= minValue && pixel.green() >= 50 && pixel.blue() < 128) {
return true;
}
}
}
}
return false;
}
bool tst_UserPasses::imageHasNonBlackPixels(const QImage &image)
{
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
QColor pixel = image.pixelColor(x, y);
if (pixel.red() > 10 || pixel.green() > 10 || pixel.blue() > 10) {
return true;
}
}
}
return false;
}
void tst_UserPasses::testBasicRenderPass()
{
QScopedPointer<QQuickView> view(createView(QLatin1String("basicrenderpass.qml"), QSize(400, 400)));
QVERIFY(view);
view->show();
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Get the viewport
QQuick3DViewport *viewport = view->rootObject()->findChild<QQuick3DViewport *>();
QVERIFY(viewport);
// Verify render pass is created
QQuick3DRenderPass *renderPass = view->rootObject()->findChild<QQuick3DRenderPass *>();
QVERIFY(renderPass);
// Capture the rendered image
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// Size may be scaled for high DPI displays
QVERIFY(result.width() >= 400);
QVERIFY(result.height() >= 400);
// The test validates the user pass is being used by checking the clear color
// RenderPass.clearColor = "blue", environment.clearColor = "black"
// If the user pass is working, background should be blue, not black
QVERIFY2(imageContainsDominantColor(result, Qt::blue), "Background should be blue (from pass clearColor)");
// The red cube should also be visible
QVERIFY2(imageContainsDominantColor(result, Qt::red), "Red cube should be visible");
// Validate that no green is visible (proves the imageConainsDominantColor check is valid)
QVERIFY2(!imageContainsDominantColor(result, Qt::green), "No Green should be visible");
}
void tst_UserPasses::testMultipleRenderPasses()
{
QScopedPointer<QQuickView> view(createView(QLatin1String("multiplepasses.qml"), QSize(400, 400)));
QVERIFY(view);
view->show();
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Verify multiple render passes are created
QList<QQuick3DRenderPass *> renderPasses = view->rootObject()->findChildren<QQuick3DRenderPass *>();
QVERIFY(renderPasses.size() > 1);
// Capture the rendered image
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// This test validates pass chaining:
// - pass1 renders red cube on yellow background to texture
// - pass2 uses pass1's texture on a sphere, with cyan background
// - We should see cyan background (from pass2) proving pass2 is active
// - We should see red/yellow (from pass1 texture on sphere) proving passes chain
QVERIFY2(imageContainsDominantColor(result, Qt::cyan), "Background should be cyan (from pass2)");
QVERIFY2(imageContainsDominantColor(result, Qt::red), "Red from pass1 should be visible on sphere");
QVERIFY2(imageContainsDominantColor(result, Qt::yellow), "Yellow from pass1 should be visible on sphere");
// Validate that no green is visible (proves the imageConainsDominantColor check is valid)
QVERIFY2(!imageContainsDominantColor(result, Qt::green), "No Green should be visible");
}
void tst_UserPasses::testMultipleRenderTargets()
{
QScopedPointer<QQuickView> view(createView(QLatin1String("gbufferpass.qml"), QSize(400, 400)));
QVERIFY(view);
view->show();
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Verify render pass with multiple attachments
QQuick3DRenderPass *renderPass = view->rootObject()->findChild<QQuick3DRenderPass *>("mrtPass");
QVERIFY(renderPass);
// Capture the rendered image
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// This test validates Multiple Render Targets (MRT) work:
// - ONE render pass with 3 color attachments (GBUFFER0, GBUFFER1, GBUFFER2)
// - CustomMaterial fragment shader writes:
// - RED to GBUFFER0 (attachment 0)
// - YELLOW to GBUFFER1 (attachment 1)
// - BLUE to GBUFFER2 (attachment 2)
// - We display attachment 1 (GBUFFER1)
// - Should see yellow, proving MRT works (if MRT failed, we'd see red or black)
QVERIFY2(imageContainsDominantColor(result, Qt::yellow), "Should see yellow (from GBUFFER1), proving MRT works");
QVERIFY2(!imageContainsDominantColor(result, Qt::red), "Should NOT see red (GBUFFER0 not displayed)");
QVERIFY2(!imageContainsDominantColor(result, Qt::blue), "Should NOT see blue (GBUFFER2 not displayed)");
}
void tst_UserPasses::testDisableInternalPasses()
{
QScopedPointer<QQuickView> view(createView(QLatin1String("disableinternalpasses.qml"), QSize(400, 400)));
QVERIFY(view);
view->show();
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Get the viewport
QQuick3DViewport *viewport = view->rootObject()->findChild<QQuick3DViewport *>();
QVERIFY(viewport);
// Verify DisableInternalPasses is set
QVERIFY(viewport->renderOverrides() & QQuick3DViewport::RenderOverride::DisableInternalPasses);
// Capture the rendered image
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// This test validates DisableInternalPasses works:
// - environment.clearColor = "red"
// - RenderPass.clearColor = "green"
// - With DisableInternalPasses, we should see green, not red
QVERIFY2(imageContainsDominantColor(result, Qt::green), "Background should be green (from pass), proving DisableInternalPasses works");
QVERIFY2(!imageContainsDominantColor(result, Qt::red), "Should NOT see red (from environment)");
}
void tst_UserPasses::testRenderPassTextures_data()
{
QTest::addColumn<QString>("formatName");
QTest::addColumn<bool>("isSingleChannel");
QTest::addColumn<int>("expectedColor"); // QColor as int for easy comparison
// Test all color texture formats
QTest::newRow("RGBA8") << "RGBA8" << false << static_cast<int>(Qt::yellow);
QTest::newRow("RGBA16F") << "RGBA16F" << false << static_cast<int>(Qt::yellow);
QTest::newRow("RGBA32F") << "RGBA32F" << false << static_cast<int>(Qt::yellow);
QTest::newRow("R8") << "R8" << true << static_cast<int>(Qt::white); // Single channel
QTest::newRow("R16") << "R16" << true << static_cast<int>(Qt::white); // Single channel
QTest::newRow("R16F") << "R16F" << true << static_cast<int>(Qt::white); // Single channel
QTest::newRow("R32F") << "R32F" << true << static_cast<int>(Qt::white); // Single channel
}
void tst_UserPasses::testRenderPassTextures()
{
QFETCH(QString, formatName);
QFETCH(bool, isSingleChannel);
QFETCH(int, expectedColor);
// For single-channel formats, use gray clear color (will show in red channel)
// For multi-channel formats, use distinctive color
QString clearColorValue = isSingleChannel ? "gray" : "darkBlue";
QString sphereColor = isSingleChannel ? "white" : "yellow";
// Create a temporary QML file with the specific format
QString qmlContent = QString(R"(
import QtQuick
import QtQuick3D
import QtQuick3D.Helpers
Item {
width: 400
height: 400
View3D {
id: view3D
anchors.fill: parent
renderOverrides: View3D.DisableInternalPasses
environment: SceneEnvironment {
backgroundMode: SceneEnvironment.Color
clearColor: "black"
}
PerspectiveCamera {
position: Qt.vector3d(0, 0, 300)
}
DirectionalLight {}
RenderPassTexture {
id: colorTexture
format: RenderPassTexture.%1
}
RenderPassTexture {
id: depthTexture
format: RenderPassTexture.Depth24Stencil8
}
RenderPass {
id: texturePass
materialMode: RenderPass.OriginalMaterial
clearColor: "%2"
commands: [
ColorAttachment { target: colorTexture },
DepthTextureAttachment { target: depthTexture }
]
}
SimpleQuadRenderer {
texture: Texture {
textureProvider: RenderOutputProvider {
textureSource: RenderOutputProvider.UserPassTexture
renderPass: texturePass
attachmentSelector: RenderOutputProvider.Attachment0
}
}
}
Model {
source: "#Sphere"
materials: PrincipledMaterial {
baseColor: "%3"
lighting: PrincipledMaterial.NoLighting
}
}
}
}
)")
.arg(formatName, clearColorValue, sphereColor);
// Write temporary QML file
QString tempPath = QDir::temp().filePath(QString("test_%1.qml").arg(formatName));
QFile tempFile(tempPath);
QVERIFY(tempFile.open(QIODevice::WriteOnly | QIODevice::Text));
tempFile.write(qmlContent.toUtf8());
tempFile.close();
// Load and test
QScopedPointer<QQuickView> view(new QQuickView);
view->setSource(QUrl::fromLocalFile(tempPath));
view->setResizeMode(QQuickView::SizeRootObjectToView);
view->resize(400, 400);
QVERIFY(view);
view->show();
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Capture the rendered image
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// Validate format-specific rendering
if (!isSingleChannel) {
// Multi-channel formats should show the distinctive clear color
QColor darkBlue(0, 0, 139);
QVERIFY2(imageContainsDominantColor(result, darkBlue),
qPrintable(QString("Background should be dark blue for format %1").arg(formatName)));
}
// For single-channel formats, just verify something rendered (not all black)
// These formats can only store red channel data, so color validation is limited
if (isSingleChannel) {
QVERIFY2(imageHasNonBlackPixels(result),
qPrintable(QString("Image should render for single-channel format %1").arg(formatName)));
} else {
QColor expected = QColor(static_cast<Qt::GlobalColor>(expectedColor));
QVERIFY2(imageContainsDominantColor(result, expected),
qPrintable(QString("Expected color should be visible for format %1").arg(formatName)));
}
// Cleanup
tempFile.remove();
}
void tst_UserPasses::testSubRenderPass()
{
QScopedPointer<QQuickView> view(createView(QLatin1String("subrenderpass.qml"), QSize(400, 400)));
QVERIFY(view);
view->show();
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Verify nested render passes
QList<QQuick3DRenderPass *> renderPasses = view->rootObject()->findChildren<QQuick3DRenderPass *>();
QVERIFY(renderPasses.size() >= 2);
// Capture the rendered image
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// This test validates SubRenderPass works:
// - Parent pass has magenta clear color and filters to Layer0 (nothing)
// - Child SubRenderPass filters to Layer1 and renders the cyan cone
// - If SubRenderPass wasn't executing, we'd only see magenta background
// - Seeing the cyan cone proves SubRenderPass executed
QColor magenta(255, 0, 255);
QVERIFY2(imageContainsDominantColor(result, magenta), "Background should be magenta (from parent pass)");
QVERIFY2(imageContainsDominantColor(result, Qt::cyan), "Cyan cone should be visible (proves SubRenderPass executed)");
}
void tst_UserPasses::testContentLayers()
{
QScopedPointer<QQuickView> view(createView(QLatin1String("contentlayers.qml"), QSize(400, 400)));
QVERIFY(view);
view->show();
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Capture the rendered image
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// This test validates ContentLayer filtering works:
// - Pass filters to Layer0 | Layer1 only
// - Gray background (from pass) proves pass is active
// - Layer0 (red cube) and Layer1 (green sphere) should be visible
// - Layer2 (blue cone) should NOT be visible (filtered out)
QVERIFY2(imageContainsDominantColor(result, Qt::gray), "Background should be gray (from pass)");
QVERIFY2(imageContainsDominantColor(result, Qt::red), "Red cube (Layer0) should be visible");
QVERIFY2(imageContainsDominantColor(result, Qt::green), "Green sphere (Layer1) should be visible");
QVERIFY2(!imageContainsDominantColor(result, Qt::blue), "Blue cone (Layer2) should NOT be visible (filtered)");
}
void tst_UserPasses::testRenderablesFilter_data()
{
QTest::addColumn<QString>("filterMode");
QTest::addColumn<bool>("expectRed"); // Opaque red cube
QTest::addColumn<bool>("expectGreen"); // Transparent green sphere
// Test all filter modes
QTest::newRow("Opaque") << "RenderablesFilter.Opaque" << true << false;
QTest::newRow("Transparent") << "RenderablesFilter.Transparent" << false << true;
QTest::newRow("Opaque | Transparent") << "RenderablesFilter.Opaque | RenderablesFilter.Transparent" << true << true;
QTest::newRow("None") << "RenderablesFilter.None" << false << false; // No renderables
}
void tst_UserPasses::testRenderablesFilter()
{
QFETCH(QString, filterMode);
QFETCH(bool, expectRed);
QFETCH(bool, expectGreen);
// Create a temporary QML file with the specific filter mode
QString qmlContent = QString(R"(
import QtQuick
import QtQuick3D
import QtQuick3D.Helpers
Item {
width: 400
height: 400
View3D {
id: view3D
anchors.fill: parent
renderOverrides: View3D.DisableInternalPasses
environment: SceneEnvironment {
backgroundMode: SceneEnvironment.Color
clearColor: "yellow"
}
PerspectiveCamera {
position: Qt.vector3d(0, 0, 300)
}
DirectionalLight {}
RenderPassTexture {
id: outputTexture
format: RenderPassTexture.RGBA8
}
RenderPass {
id: filterPass
materialMode: RenderPass.OriginalMaterial
clearColor: "blue"
commands: [
ColorAttachment { target: outputTexture },
DepthStencilAttachment {},
RenderablesFilter {
renderableTypes: %1
}
]
}
SimpleQuadRenderer {
texture: Texture {
textureProvider: RenderOutputProvider {
textureSource: RenderOutputProvider.UserPassTexture
renderPass: filterPass
attachmentSelector: RenderOutputProvider.Attachment0
}
}
}
// Opaque red cube
Model {
source: "#Cube"
materials: PrincipledMaterial {
baseColor: "red"
alphaMode: PrincipledMaterial.Opaque
lighting: PrincipledMaterial.NoLighting
}
}
// Transparent green sphere
Model {
source: "#Sphere"
position: Qt.vector3d(150, 0, 0)
materials: PrincipledMaterial {
baseColor: Qt.rgba(0, 1, 0, 0.5)
alphaMode: PrincipledMaterial.Blend
lighting: PrincipledMaterial.NoLighting
}
}
}
}
)")
.arg(filterMode);
// Write temporary QML file
QString tempPath = QDir::temp().filePath(QString("test_filter_%1.qml").arg(filterMode.replace(" ", "_").replace("|", "or")));
QFile tempFile(tempPath);
QVERIFY(tempFile.open(QIODevice::WriteOnly | QIODevice::Text));
tempFile.write(qmlContent.toUtf8());
tempFile.close();
// Load and test
QScopedPointer<QQuickView> view(new QQuickView);
view->setSource(QUrl::fromLocalFile(tempPath));
view->setResizeMode(QQuickView::SizeRootObjectToView);
view->resize(400, 400);
QVERIFY(view);
view->show();
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Capture the rendered image
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// Blue background should always be visible (proves pass is active)
QVERIFY2(imageContainsDominantColor(result, Qt::blue),
qPrintable(QString("Background should be blue for filter mode: %1").arg(filterMode)));
// Validate presence/absence of red cube (opaque)
if (expectRed) {
QVERIFY2(imageContainsDominantColor(result, Qt::red),
qPrintable(QString("Red cube (opaque) should be visible for filter mode: %1").arg(filterMode)));
} else {
QVERIFY2(!imageContainsDominantColor(result, Qt::red),
qPrintable(QString("Red cube (opaque) should NOT be visible for filter mode: %1").arg(filterMode)));
}
// Validate presence/absence of green sphere (transparent)
if (expectGreen) {
QVERIFY2(imageContainsDominantColor(result, Qt::green),
qPrintable(QString("Green sphere (transparent) should be visible for filter mode: %1").arg(filterMode)));
} else {
QVERIFY2(!imageContainsDominantColor(result, Qt::green),
qPrintable(QString("Green sphere (transparent) should NOT be visible for filter mode: %1").arg(filterMode)));
}
// Cleanup
tempFile.remove();
}
void tst_UserPasses::testPipelineStateOverride_data()
{
QTest::addColumn<QString>("qmlFile");
QTest::addColumn<QColor>("expectedColor");
QTest::addColumn<QString>("description");
QTest::newRow("Wireframe") << "pipelinestateoverride_wireframe.qml" << QColor("white")
<< "White edges should be visible in wireframe mode (polygonMode = Line)";
QTest::newRow("CullFront") << "pipelinestateoverride_cullfront.qml" << QColor("white")
<< "Back faces should be visible with cullMode = Front";
QTest::newRow("Scissor") << "pipelinestateoverride_scissor.qml" << QColor("cyan")
<< "Cyan cube should be clipped to bottom-left region with scissor";
}
void tst_UserPasses::testPipelineStateOverride()
{
QFETCH(QString, qmlFile);
QFETCH(QColor, expectedColor);
QFETCH(QString, description);
QQuickView view;
view.setSource(testFileUrl(qmlFile));
view.setResizeMode(QQuickView::SizeRootObjectToView);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
QImage result = grab(&view);
QVERIFY(result.width() >= 400);
QVERIFY(result.height() >= 400);
// Validate the specific pipeline state override effect
// Skip generic color check for tests with specific validation
if (!qmlFile.contains("cullfront") && !qmlFile.contains("scissor")) {
QVERIFY2(imageContainsDominantColor(result, expectedColor), qPrintable(description));
}
// Additional validation based on test type
if (qmlFile.contains("wireframe")) {
// Wireframe test: verify mostly black (not filled)
int blackPixels = 0;
int whitePixels = 0;
for (int y = 0; y < result.height(); ++y) {
for (int x = 0; x < result.width(); ++x) {
QColor pixel = result.pixelColor(x, y);
if (pixel.red() < 30 && pixel.green() < 30 && pixel.blue() < 30) {
blackPixels++;
} else if (pixel.red() > 200 || pixel.green() > 200 || pixel.blue() > 200) {
whitePixels++;
}
}
}
// Wireframe should be mostly black (not filled)
QVERIFY2(blackPixels > whitePixels * 5, "Wireframe mode should show mostly black background (not filled faces)");
} else if (qmlFile.contains("cullfront")) {
// CullFront test: front faces are culled, so the center of the cube (where the
// camera looks straight through the culled front face to the dark back face) must
// be near-black, while some back/edge faces visible at glancing angles are non-black.
const QColor center = result.pixelColor(result.width() / 2, result.height() / 2);
const int centerBrightness = (center.red() + center.green() + center.blue()) / 3;
QVERIFY2(centerBrightness < 30, "Front face should be culled: center must be dark");
int nonBlackPixels = 0;
for (int y = 0; y < result.height(); ++y) {
for (int x = 0; x < result.width(); ++x) {
QColor pixel = result.pixelColor(x, y);
int brightness = (pixel.red() + pixel.green() + pixel.blue()) / 3;
if (brightness > 20)
nonBlackPixels++;
}
}
QVERIFY2(nonBlackPixels > 100, "Some back/edge faces should be visible");
} else if (qmlFile.contains("scissor")) {
// Scissor test: verify cyan is clipped to bottom-left region
// Scissor rect is Qt.rect(100, 100, 200, 200) which appears in bottom-left
int cyanPixels = 0;
int cyanInBottomLeft = 0; // Cyan in bottom-left region
int blackInTopRight = 0; // Black in top-right region (should be clipped)
for (int y = 0; y < result.height(); ++y) {
for (int x = 0; x < result.width(); ++x) {
QColor pixel = result.pixelColor(x, y);
bool isCyan = (pixel.green() > 100 && pixel.blue() > 100 && pixel.red() < 100);
bool isBlack = (pixel.red() < 30 && pixel.green() < 30 && pixel.blue() < 30);
if (isCyan) {
cyanPixels++;
// Check if cyan appears in bottom-left region (where scissor rect is)
if (x >= 50 && x < 350 && y >= result.height() - 350 && y < result.height() - 50) {
cyanInBottomLeft++;
}
}
// Check top-right region should be black (clipped)
if (x >= result.width() - 100 && y < 100) {
if (isBlack) {
blackInTopRight++;
}
}
}
}
// Should have cyan visible (scissor allows rendering)
QVERIFY2(cyanPixels > 100, "Should have some cyan visible within scissor rectangle");
// Cyan should be concentrated in bottom-left region (where scissor rect is)
QVERIFY2(cyanInBottomLeft > 50, "Cyan should appear in bottom-left region within scissor rectangle");
// Top-right should be mostly black (clipped by scissor)
QVERIFY2(blackInTopRight > 500, "Top-right region should be black (clipped by scissor)");
}
}
// NoLighting DefaultMaterial colors are stored as linear in the RGBA16F render
// target and displayed after sRGB encoding. Pure primaries (0 or 1 in linear)
// encode identically in sRGB, but rounding and the display pipeline can shift
// channel values by up to ~50 out of 255. Use a generous fuzz that still
// clearly distinguishes red from blue or green from black.
static const int FUZZ = 50;
void tst_UserPasses::subPassOverrideMaterial()
{
// A sphere with a blue PrincipledMaterial is rendered through a SubRenderPass
// whose renderPass uses OverrideMaterial with a red NoLighting DefaultMaterial.
// The center pixel must be red, not blue, proving that the override material's
// uniform buffer properties (colour) were applied rather than the original material's.
QScopedPointer<QQuickView> view(createView(QLatin1String("subpass_overridematerial.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
const QImage result = grab(view.data());
if (result.isNull())
return;
// Center of the sphere should be red (override), not blue (original material).
QVERIFY(comparePixelNormPos(result, 0.5, 0.5, Qt::red, FUZZ));
}
void tst_UserPasses::multipleSubPasses()
{
// Two SubRenderPasses in one main pass, each targeting a different layer with
// a different override material:
// SubPass 1: red override, Layer0 -> cube at left (x = -150)
// SubPass 2: blue override, Layer1 -> sphere at right (x = +150)
// If both sub-passes correctly acquire unique userPassData slots the left
// region will be red and the right region blue. If they share a slot the
// second pass overwrites the first and both sides show the same colour.
QScopedPointer<QQuickView> view(createView(QLatin1String("multiple_subpasses.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
const QImage result = grab(view.data());
if (result.isNull())
return;
// Left quarter (cube on Layer0) should be red.
QVERIFY(comparePixelNormPos(result, 0.25, 0.5, Qt::red, FUZZ));
// Right quarter (sphere on Layer1) should be blue.
QVERIFY(comparePixelNormPos(result, 0.75, 0.5, Qt::blue, FUZZ));
}
void tst_UserPasses::subPassNestingDepth()
{
// Regression test for QTBUG-148554. A pass referenced as a SubRenderPass
// (Role::SubPass) must carry no ordering state: its nesting depth stays 0
// even though it is declared as a QML child of the top-level pass. If a
// sub-pass carried a non-zero depth and ever entered the scheduled list
// (e.g. its output consumed via a provider) it would sort ahead of genuine
// top-level passes.
QScopedPointer<QQuickView> view(createView(QLatin1String("subpass_nesting_depth.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Force a full synchronize + render so the classification runs.
const QImage result = grab(view.data());
QVERIFY(!result.isNull());
auto *mainPass = view->rootObject()->findChild<QQuick3DRenderPass *>("mainPass");
auto *subPass = view->rootObject()->findChild<QQuick3DRenderPass *>("subPass");
QVERIFY(mainPass);
QVERIFY(subPass);
auto *mainNode = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(mainPass)->spatialNode);
auto *subNode = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(subPass)->spatialNode);
QVERIFY(mainNode);
QVERIFY(subNode);
// Roles are assigned during sync: the main pass is top-level and the
// referenced pass is tagged as a sub-pass.
QCOMPARE(mainNode->role, QSSGRenderUserPass::Role::TopLevel);
QCOMPARE(subNode->role, QSSGRenderUserPass::Role::SubPass);
// The invariants under test: a sub-pass carries no ordering state, and the
// top-level pass parented to the View3D sits at depth 0.
QCOMPARE(subNode->m_nestingDepth, 0u);
QCOMPARE(mainNode->m_nestingDepth, 0u);
}
void tst_UserPasses::userPassManagerScheduling()
{
// Unit test of the manager's scheduling contract, which the scene sync and
// the RenderOutputProvider path both depend on.
auto manager = QSSGUserRenderPassManager::create();
QSSGRenderUserPass topA;
QSSGRenderUserPass topB;
QSSGRenderUserPass topC;
QSSGRenderUserPass sub;
topA.role = QSSGRenderUserPass::Role::TopLevel;
topB.role = QSSGRenderUserPass::Role::TopLevel;
topC.role = QSSGRenderUserPass::Role::TopLevel;
sub.role = QSSGRenderUserPass::Role::SubPass;
// Sync establishes the top-level set wholesale.
manager->setScheduledPasses({ &topA, &topB });
QCOMPARE(manager->scheduledUserPasses().size(), 2);
// Provider path: confirming an already-scheduled pass is a no-op.
manager->scheduleUserPass(&topA);
QCOMPARE(manager->scheduledUserPasses().size(), 2);
// A top-level pass not yet scheduled is added (first-frame fallback).
manager->scheduleUserPass(&topC);
QCOMPARE(manager->scheduledUserPasses().size(), 3);
// A sub-pass is rejected, even via the provider path (QTBUG-148554).
manager->scheduleUserPass(&sub);
QCOMPARE(manager->scheduledUserPasses().size(), 3);
QVERIFY(!manager->scheduledUserPasses().contains(&sub));
// Ordering: deeper nesting first, then lower declaration order.
topA.m_nestingDepth = 1; topA.m_declarationOrder = 0;
topB.m_nestingDepth = 3; topB.m_declarationOrder = 1;
topC.m_nestingDepth = 1; topC.m_declarationOrder = 2;
manager->setScheduledPasses({ &topA, &topB, &topC });
manager->updateUserPassOrder(true);
const auto &ordered = manager->scheduledUserPasses();
QCOMPARE(ordered.size(), 3);
QCOMPARE(ordered.at(0), &topB); // deepest nesting
QCOMPARE(ordered.at(1), &topA); // equal depth, lower declaration order first
QCOMPARE(ordered.at(2), &topC);
}
void tst_UserPasses::nestedPassOrdering()
{
// A producer declared as a QML child of its consumer gets nesting depth 1
// and renders first, into its own render target.
QScopedPointer<QQuickView> view(createView(QLatin1String("nested_pass_ordering.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
const QImage result = grab(view.data());
QVERIFY(!result.isNull());
auto *consumer = view->rootObject()->findChild<QQuick3DRenderPass *>("consumerPass");
auto *producer = view->rootObject()->findChild<QQuick3DRenderPass *>("producerPass");
QVERIFY(consumer);
QVERIFY(producer);
auto *consumerNode = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(consumer)->spatialNode);
auto *producerNode = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(producer)->spatialNode);
QVERIFY(consumerNode);
QVERIFY(producerNode);
QCOMPARE(consumerNode->role, QSSGRenderUserPass::Role::TopLevel);
QCOMPARE(producerNode->role, QSSGRenderUserPass::Role::TopLevel);
QCOMPARE(consumerNode->m_nestingDepth, 0u);
QCOMPARE(producerNode->m_nestingDepth, 1u);
// The sphere drawn by the consumer is textured with the producer's output
// (red cube on yellow), so both colors must reach the screen.
QVERIFY(imageContainsDominantColor(result, Qt::red));
QVERIFY(imageContainsDominantColor(result, Qt::yellow));
}
void tst_UserPasses::nodeParentedPassIsTopLevel()
{
// A pass declared under a Node is an active top-level pass. The Node is
// transparent for ordering: only RenderPass ancestors count towards the
// nesting depth, so the pass sits at depth 0.
QScopedPointer<QQuickView> view(createView(QLatin1String("node_parented_pass.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
const QImage result = grab(view.data());
QVERIFY(!result.isNull());
auto *grouped = view->rootObject()->findChild<QQuick3DRenderPass *>("groupedPass");
QVERIFY(grouped);
auto *groupedNode = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(grouped)->spatialNode);
QVERIFY(groupedNode);
QCOMPARE(groupedNode->role, QSSGRenderUserPass::Role::TopLevel);
QCOMPARE(groupedNode->m_nestingDepth, 0u);
// The scene renders normally alongside the grouped pass.
QVERIFY(imageContainsDominantColor(result, Qt::red));
}
void tst_UserPasses::reparentUpdatesDepth()
{
// Reparenting a pass at runtime re-derives its validity and depth.
QScopedPointer<QQuickView> view(createView(QLatin1String("reparent_depth.qml"), QSize(200, 200)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
auto *viewport = view->rootObject()->findChild<QQuick3DViewport *>("view");
auto *passA = view->rootObject()->findChild<QQuick3DRenderPass *>("passA");
auto *passB = view->rootObject()->findChild<QQuick3DRenderPass *>("passB");
auto *nodeParent = view->rootObject()->findChild<QQuick3DObject *>("nodeParent");
QVERIFY(viewport);
QVERIFY(passA);
QVERIFY(passB);
QVERIFY(nodeParent);
const auto nodeOf = [](QQuick3DRenderPass *pass) {
return static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(pass)->spatialNode);
};
// Initially both passes are children of the viewport: depth 0.
QVERIFY(!grab(view.data()).isNull());
QVERIFY(nodeOf(passA));
QCOMPARE(nodeOf(passA)->m_nestingDepth, 0u);
// Under another pass: depth 1.
passA->setParentItem(passB);
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(nodeOf(passA)->m_nestingDepth, 1u);
// Under a Node: the Node is transparent for ordering, so depth 0 again.
passA->setParentItem(nodeParent);
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(nodeOf(passA)->m_nestingDepth, 0u);
// Back under the viewport's scene root: still depth 0.
passA->setParentItem(viewport->scene());
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(nodeOf(passA)->m_nestingDepth, 0u);
}
void tst_UserPasses::multiConsumerProducerDepth()
{
// A producer nested in consumerA (depth 1) renders before all depth-0
// passes, so consumerB also sees its output without a second edge.
QScopedPointer<QQuickView> view(createView(QLatin1String("multi_consumer.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
const QImage result = grab(view.data());
QVERIFY(!result.isNull());
auto *consumerA = view->rootObject()->findChild<QQuick3DRenderPass *>("consumerA");
auto *consumerB = view->rootObject()->findChild<QQuick3DRenderPass *>("consumerB");
auto *producer = view->rootObject()->findChild<QQuick3DRenderPass *>("producerPass");
QVERIFY(consumerA);
QVERIFY(consumerB);
QVERIFY(producer);
const auto nodeOf = [](QQuick3DRenderPass *pass) {
return static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(pass)->spatialNode);
};
QVERIFY(nodeOf(consumerA));
QVERIFY(nodeOf(consumerB));
QVERIFY(nodeOf(producer));
QCOMPARE(nodeOf(producer)->m_nestingDepth, 1u);
QCOMPARE(nodeOf(consumerA)->m_nestingDepth, 0u);
QCOMPARE(nodeOf(consumerB)->m_nestingDepth, 0u);
// consumerB's sphere is textured with the producer's output (red cube on
// yellow), even though the producer is nested inside consumerA.
QVERIFY(imageContainsDominantColor(result, Qt::red));
QVERIFY(imageContainsDominantColor(result, Qt::yellow));
}
void tst_UserPasses::subPassRoleTransition()
{
// The classification is only re-derived when the flag on the scene manager is
// set (pass add/remove or command-list rebuild). Flipping which leaf pass the
// container references at runtime must re-derive the roles accordingly.
QScopedPointer<QQuickView> view(createView(QLatin1String("subpass_role_transition.qml"), QSize(200, 200)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
auto *leafA = view->rootObject()->findChild<QQuick3DRenderPass *>("leafA");
auto *leafB = view->rootObject()->findChild<QQuick3DRenderPass *>("leafB");
QVERIFY(leafA);
QVERIFY(leafB);
const auto roleOf = [](QQuick3DRenderPass *pass) {
auto *node = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(pass)->spatialNode);
return node ? node->role : QSSGRenderUserPass::Role::TopLevel;
};
// Initially the container references leafB as its sub-pass.
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(leafB), QSSGRenderUserPass::Role::SubPass);
QCOMPARE(roleOf(leafA), QSSGRenderUserPass::Role::TopLevel);
// Flip the reference: leafA becomes the sub-pass, leafB returns to top-level.
view->rootObject()->setProperty("useLeafAAsSubPass", true);
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(leafA), QSSGRenderUserPass::Role::SubPass);
QCOMPARE(roleOf(leafB), QSSGRenderUserPass::Role::TopLevel);
// And back again.
view->rootObject()->setProperty("useLeafAAsSubPass", false);
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(leafB), QSSGRenderUserPass::Role::SubPass);
QCOMPARE(roleOf(leafA), QSSGRenderUserPass::Role::TopLevel);
}
void tst_UserPasses::subPassRefCounting()
{
// Two containers reference the SAME leaf pass. The classification counts
// references, so the leaf stays a sub-pass until the LAST reference is
// dropped.
QScopedPointer<QQuickView> view(createView(QLatin1String("subpass_refcount.qml"), QSize(200, 200)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
auto *sharedLeaf = view->rootObject()->findChild<QQuick3DRenderPass *>("sharedLeaf");
QVERIFY(sharedLeaf);
const auto roleOf = [](QQuick3DRenderPass *pass) {
auto *node = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(pass)->spatialNode);
return node ? node->role : QSSGRenderUserPass::Role::TopLevel;
};
// Both references active.
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(sharedLeaf), QSSGRenderUserPass::Role::SubPass);
// Dropping one of two references keeps the leaf a sub-pass.
view->rootObject()->setProperty("refA", false);
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(sharedLeaf), QSSGRenderUserPass::Role::SubPass);
// Dropping the last reference makes it top-level again.
view->rootObject()->setProperty("refB", false);
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(sharedLeaf), QSSGRenderUserPass::Role::TopLevel);
// And re-referencing flips it back.
view->rootObject()->setProperty("refA", true);
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(sharedLeaf), QSSGRenderUserPass::Role::SubPass);
}
void tst_UserPasses::subPassCommandDestroyed()
{
// Destroying the referencing SubRenderPass command (with its container
// pass) releases the reference: the leaf returns to top-level.
QScopedPointer<QQuickView> view(createView(QLatin1String("subpass_dynamic_ref.qml"), QSize(200, 200)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
auto *viewport = view->rootObject()->findChild<QQuick3DViewport *>("view");
auto *staticLeaf = view->rootObject()->findChild<QQuick3DRenderPass *>("staticLeaf");
QVERIFY(viewport);
QVERIFY(staticLeaf);
const auto roleOf = [](QQuick3DRenderPass *pass) {
auto *node = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(pass)->spatialNode);
return node ? node->role : QSSGRenderUserPass::Role::TopLevel;
};
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(staticLeaf), QSSGRenderUserPass::Role::TopLevel);
// Create a container pass at runtime that references the static leaf.
QQmlComponent component(view->engine());
component.setData(QByteArrayLiteral(
"import QtQuick3D\n"
"RenderPass {\n"
" commands: [ SubRenderPass { objectName: \"dynSubCmd\" } ]\n"
"}\n"),
QUrl(QStringLiteral("dyncontainer.qml")));
QVERIFY2(component.isReady(), qPrintable(component.errorString()));
auto *dynContainer = qobject_cast<QQuick3DRenderPass *>(component.create());
QVERIFY(dynContainer);
dynContainer->setParentItem(viewport->scene());
auto *dynSubCmd = dynContainer->findChild<QObject *>("dynSubCmd");
QVERIFY(dynSubCmd);
QVERIFY(dynSubCmd->setProperty("renderPass", QVariant::fromValue(staticLeaf)));
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(staticLeaf), QSSGRenderUserPass::Role::SubPass);
// Destroying the container destroys the command, releasing the reference.
delete dynContainer;
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(staticLeaf), QSSGRenderUserPass::Role::TopLevel);
}
void tst_UserPasses::referencedPassDestroyed()
{
// Destroying the referenced pass while the SubRenderPass command is alive
// must not crash: the watcher resets the command's renderPass and the
// dying pass is never dereferenced.
QScopedPointer<QQuickView> view(createView(QLatin1String("subpass_dynamic_ref.qml"), QSize(200, 200)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
auto *viewport = view->rootObject()->findChild<QQuick3DViewport *>("view");
auto *staticContainer = view->rootObject()->findChild<QQuick3DRenderPass *>("staticContainer");
auto *staticSubCmd = view->rootObject()->findChild<QObject *>("staticSubCmd");
QVERIFY(viewport);
QVERIFY(staticContainer);
QVERIFY(staticSubCmd);
const auto roleOf = [](QQuick3DRenderPass *pass) {
auto *node = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(pass)->spatialNode);
return node ? node->role : QSSGRenderUserPass::Role::TopLevel;
};
// Create a leaf pass at runtime and reference it from the static command.
QQmlComponent component(view->engine());
component.setData(QByteArrayLiteral(
"import QtQuick3D\n"
"RenderPass {\n"
" commands: [ RenderablesFilter { renderableTypes: RenderablesFilter.None } ]\n"
"}\n"),
QUrl(QStringLiteral("dynleaf.qml")));
QVERIFY2(component.isReady(), qPrintable(component.errorString()));
auto *dynLeaf = qobject_cast<QQuick3DRenderPass *>(component.create());
QVERIFY(dynLeaf);
dynLeaf->setParentItem(viewport->scene());
QVERIFY(staticSubCmd->setProperty("renderPass", QVariant::fromValue(dynLeaf)));
QVERIFY(!grab(view.data()).isNull());
QCOMPARE(roleOf(dynLeaf), QSSGRenderUserPass::Role::SubPass);
// Destroy the referenced pass; the watcher clears the command's property.
delete dynLeaf;
QCOMPARE(staticSubCmd->property("renderPass").value<QQuick3DRenderPass *>(), nullptr);
// The scene keeps rendering and the container is unaffected.
const QImage result = grab(view.data());
QVERIFY(!result.isNull());
QCOMPARE(roleOf(staticContainer), QSSGRenderUserPass::Role::TopLevel);
QVERIFY(imageContainsDominantColor(result, Qt::red));
}
void tst_UserPasses::unreferencedPassReturnsToTopLevel()
{
// A pass that is no longer referenced by any SubRenderPass command must
// return to being a top-level pass, and actually render as one again:
// the provider-driven quad shows the leaf's own output, which only exists
// when the leaf renders standalone.
//
// While referenced, the provider requests the leaf every frame and the
// manager rejects it with a once-per-pass warning; the first frames render
// during window exposure, so the expectation is armed up front.
QTest::ignoreMessage(QtWarningMsg,
QRegularExpression(QStringLiteral("Ignoring request to schedule sub-pass")));
QScopedPointer<QQuickView> view(createView(QLatin1String("subpass_unreference.qml"), QSize(200, 200)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
auto *leafPass = view->rootObject()->findChild<QQuick3DRenderPass *>("leafPass");
QVERIFY(leafPass);
const auto roleOf = [](QQuick3DRenderPass *pass) {
auto *node = static_cast<QSSGRenderUserPass *>(QQuick3DObjectPrivate::get(pass)->spatialNode);
return node ? node->role : QSSGRenderUserPass::Role::TopLevel;
};
// Referenced: the leaf is a sub-pass and the quad has nothing to show.
const QImage subPassResult = grab(view.data());
QVERIFY(!subPassResult.isNull());
QCOMPARE(roleOf(leafPass), QSSGRenderUserPass::Role::SubPass);
QVERIFY(!imageContainsDominantColor(subPassResult, Qt::green));
// Unreferenced: the leaf returns to top-level and renders the green cube
// into its own attachment, which the provider now exposes.
view->rootObject()->setProperty("useAsSubPass", false);
QVERIFY(!grab(view.data()).isNull()); // settle: role flip + first standalone render
const QImage topLevelResult = grab(view.data());
QVERIFY(!topLevelResult.isNull());
QCOMPARE(roleOf(leafPass), QSSGRenderUserPass::Role::TopLevel);
QVERIFY(imageContainsDominantColor(topLevelResult, Qt::green));
}
void tst_UserPasses::slotLimitDoesNotCrash()
{
// 17 SubRenderPasses in a single main pass exceeds the effective per-frame
// pass limit. The UserRenderPass implementation tracks visited passes in a
// set shared across sibling sub-passes; once that set reaches
// MAX_SUBPASS_DEPTH (16) the renderer emits a depth-limit warning and skips
// the excess passes gracefully. Verify no crash occurs.
QQuick3DTestMessageHandler msgHandler;
QScopedPointer<QQuickView> view(createView(QLatin1String("slot_limit.qml"), QSize(100, 100)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
grab(view.data()); // ensure at least one frame is rendered
// The visited-pass set fills up before the explicit slot-limit counter is
// exhausted, so the depth-limit warning is what gets emitted.
QVERIFY2(msgHandler.messageString().contains(QLatin1String("Maximum SubRenderPass nesting depth")),
qPrintable(msgHandler.messageString()));
}
void tst_UserPasses::depthLimitDoesNotCrash()
{
// 17 levels of nested SubRenderPasses exceeds MAX_SUBPASS_DEPTH = 16.
// The renderer should emit a qWarning for the excess level and skip it
// gracefully without crashing.
QQuick3DTestMessageHandler msgHandler;
QScopedPointer<QQuickView> view(createView(QLatin1String("depth_limit.qml"), QSize(100, 100)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
grab(view.data()); // ensure at least one frame is rendered
// Verify the expected warning was emitted.
QVERIFY2(msgHandler.messageString().contains(QLatin1String("Maximum SubRenderPass nesting depth")),
qPrintable(msgHandler.messageString()));
}
void tst_UserPasses::testAddDefine()
{
// An AugmentMaterial pass injects #define MY_DEFINE 1 via AddDefine.
// The augment shader outputs green when MY_DEFINE is defined and red
// when it is absent. The test grabs a frame, verifies green pixels on
// the sphere, then removes the define by clearing its name and grabs
// again, verifying that the output changed to red.
QScopedPointer<QQuickView> view(createView(QLatin1String("adddefine.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Static check: MY_DEFINE is active → sphere should be green
QImage result1 = grab(view.data());
QVERIFY(!result1.isNull());
const bool hasGreen1 = imageContainsDominantColor(result1, Qt::green);
QVERIFY2(hasGreen1, "With AddDefine active, sphere should appear green");
// Dynamic check: clear the define name → shader should see no MY_DEFINE → red
QObject *addDefine = view->rootObject()->findChild<QObject *>(QLatin1String("testDefine"));
QVERIFY2(addDefine, "testDefine object must be found for dynamic check");
addDefine->setProperty("name", QByteArray(""));
QImage result2 = grab(view.data());
QVERIFY(!result2.isNull());
const bool hasRed2 = imageContainsDominantColor(result2, Qt::red);
QVERIFY2(hasRed2, "After clearing AddDefine name, sphere should appear red");
}
void tst_UserPasses::testAugmentShaderTextureBinding()
{
// The pass declares a sampler for every texture property, but only binds
// the ones that resolve to a GPU texture; the rest have to be filled with
// the engine's dummy texture so the shader resource bindings stay
// complete. The augment shader checks that the bound sampler still reads
// its own texture and the unbound one reads the dummy, so filling a
// sampler that was already bound -- or leaving one unfilled -- shows up as
// red rather than as a subtly wrong image.
//
// The unbound cube map covers the same ground for a samplerCube, and in
// particular that the dummy has content on every face, not just on the
// first one.
QScopedPointer<QQuickView> view(createView(QLatin1String("augment_texture_binding.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// The bound texture is backed by a sourceItem, which needs a frame to be
// rendered into before it can be sampled, so check the second frame.
QImage warmup = grab(view.data());
QVERIFY(!warmup.isNull());
QImage result = grab(view.data());
QVERIFY(!result.isNull());
QVERIFY2(imageContainsDominantColor(result, Qt::green),
"The bound texture property should still be sampled from its own texture, "
"and the unbound 2D and cube ones from the dummy");
}
void tst_UserPasses::testAugmentShaderArrayTextureBinding()
{
// Same idea for an array sampler. A sampler2DArray that nothing binds has
// to be filled with a dummy that is itself a texture array -- a plain 2D
// dummy would be a view type mismatch against what the shader declares,
// which is undefined rather than diagnosed on most backends. The material
// shader samples two layers of it and expects the dummy's opaque black.
//
// A sampler hint is only honoured for CustomMaterial properties, hence the
// custom material here, and the augment shader only forwards the verdict
// the material arrived at.
QScopedPointer<QQuickView> view(createView(QLatin1String("augment_array_texture_binding.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QImage warmup = grab(view.data());
QVERIFY(!warmup.isNull());
QImage result = grab(view.data());
QVERIFY(!result.isNull());
QVERIFY2(imageContainsDominantColor(result, Qt::green),
"The unbound array sampler should read the dummy texture array");
}
void tst_UserPasses::testSkyboxPass()
{
// A SubRenderPass with passMode: RenderPass.SkyboxPass renders the
// ProceduralSkyTextureData environment into the custom render target.
// If SkyboxPass works, the output should contain non-black sky colours.
QScopedPointer<QQuickView> view(createView(QLatin1String("skyboxpass.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QImage result = grab(view.data());
QVERIFY(!result.isNull());
// The sky texture is a gradient with distinct colours; any non-black
// pixel proves that the SkyboxPass rendered something.
const bool hasSkyColors = imageHasNonBlackPixels(result);
QVERIFY2(hasSkyColors, "SkyboxPass should render sky texture: output should not be all-black");
}
void tst_UserPasses::testItem2DPass()
{
// A Qt Quick 2D Item (red Rectangle) embedded in a 3D Node is rendered
// by a SubRenderPass with passMode: RenderPass.Item2DPass.
// If Item2DPass works, red pixels should appear in the output.
QScopedPointer<QQuickView> view(createView(QLatin1String("item2dpass.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QImage result1 = grab(view.data());
QVERIFY(!result1.isNull());
const bool hasRed1 = imageContainsDominantColor(result1, Qt::red);
QVERIFY2(hasRed1, "Item2DPass should render the embedded red Rectangle");
// Dynamic check: change the Rectangle colour to blue
// root.item2DColor is a QML property that drives the Rectangle colour
view->rootObject()->setProperty("item2DColor", QColor(Qt::blue));
QImage result2 = grab(view.data());
QVERIFY(!result2.isNull());
const bool hasBlue2 = imageContainsDominantColor(result2, Qt::blue);
QVERIFY2(hasBlue2, "After changing item colour to blue, Item2DPass output should be blue");
QVERIFY2(!imageContainsDominantColor(result2, Qt::red), "After colour change, red should no longer be dominant");
}
void tst_UserPasses::testDepthTestDisabled()
{
// Two overlapping spheres: blue (z=100, closer to camera) and red (z=-100, farther).
// Opaque objects are sorted front-to-back, so blue draws first, red draws second.
// With PipelineStateOverride { depthTestEnabled: false }, the GPU depth test is
// disabled. The second-drawn object (red) is not depth-rejected and overwrites blue
// at the overlap → center pixel should be red.
// Dynamic check: re-enable depth test → closer sphere (blue) wins → center = blue.
QScopedPointer<QQuickView> view(createView(QLatin1String("depthtestdisabled.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QImage result1 = grab(view.data());
QVERIFY(!result1.isNull());
// Static check: depth test disabled → red sphere (drawn second) should be visible
const bool hasRed1 = imageContainsDominantColor(result1, Qt::red);
QVERIFY2(hasRed1, "With depthTestEnabled:false, farther red sphere (drawn second) should be visible");
// Dynamic check: re-enable depth test → blue (closer) wins
QObject *pso = view->rootObject()->findChild<QObject *>(QLatin1String("pso"));
if (!pso) {
qWarning("testDepthTestDisabled: pso object not found, skipping dynamic check");
return;
}
pso->setProperty("depthTestEnabled", true);
QImage result2 = grab(view.data());
QVERIFY(!result2.isNull());
const bool hasBlue2 = imageContainsDominantColor(result2, Qt::blue);
QVERIFY2(hasBlue2, "After re-enabling depthTest, closer blue sphere should be visible");
}
void tst_UserPasses::testBlendEnabled()
{
// AugmentMaterial pass with clear colour blue. The augment shader outputs
// white at 50% alpha (vec4(1,1,1,0.5)). With blendEnabled: true and SrcAlpha
// blend mode, the result blends against the blue background producing a mix
// (light blue / cyan). With blendEnabled: false, the output is white (no blend).
QScopedPointer<QQuickView> view(createView(QLatin1String("blendenabled.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QImage result1 = grab(view.data());
QVERIFY(!result1.isNull());
// Static check: with blend active, center pixel should have a noticeable blue
// component (blending of white + blue) and NOT be pure white.
const QRgb centerPixel1 = result1.pixel(200, 200);
const int r1 = qRed(centerPixel1);
const int b1 = qBlue(centerPixel1);
// For alpha=0.5 blend: result = 0.5*white + 0.5*blue. The blue channel should
// clearly exceed the red channel. A 20-point gap is sufficient to account for
// color-space handling in the compositing pipeline.
const bool blendActive1 = (b1 > r1 + 20) && (b1 > 100);
QVERIFY2(blendActive1, "With blendEnabled:true, sphere should blend white + blue → blue channel dominant");
// Dynamic check: disable blend → sphere should become white (pure white)
QObject *pso = view->rootObject()->findChild<QObject *>(QLatin1String("pso"));
if (!pso) {
qWarning("testBlendEnabled: pso object not found, skipping dynamic check");
return;
}
pso->setProperty("blendEnabled", false);
QImage result2 = grab(view.data());
QVERIFY(!result2.isNull());
const QRgb centerPixel2 = result2.pixel(200, 200);
const int r2 = qRed(centerPixel2);
const int g2 = qGreen(centerPixel2);
const int b2 = qBlue(centerPixel2);
// Without blend: white sphere → all channels near 255
const bool isWhite2 = (r2 > 200 && g2 > 200 && b2 > 200);
QVERIFY2(isWhite2, "After disabling blend, white sphere should render as opaque white");
}
void tst_UserPasses::testPreserveColorContents()
{
// Two passes share one colour attachment (sharedTex).
// Pass 1 clears sharedTex to red with no geometry.
// Pass 2 has renderTargetFlags: PreserveColorContents (no clear) and renders a blue sphere.
// Expected: background area = red (pass 1 contents preserved), sphere area = blue.
// Dynamic: clear renderTargetFlags → pass 2 clears buffer → background becomes black.
QScopedPointer<QQuickView> view(createView(QLatin1String("preservecolor.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QImage result1 = grab(view.data());
QVERIFY(!result1.isNull());
// Static checks:
// Corner pixel (background, no sphere) should be red (pass 1 contents preserved).
const QRgb cornerPixel1 = result1.pixel(10, 10);
const bool cornerIsRed1 = qRed(cornerPixel1) > 200 && qGreen(cornerPixel1) < 50 && qBlue(cornerPixel1) < 50;
QVERIFY2(cornerIsRed1, "Background corner should be red (pass 1 clear preserved by PreserveColorContents)");
// Center pixel (sphere area) should be blue.
const bool hasBlue1 = imageContainsDominantColor(result1, Qt::blue);
QVERIFY2(hasBlue1, "Sphere area should be blue (pass 2 rendered on top of preserved red)");
// Dynamic check: clear renderTargetFlags → pass 2 clears to black → background = black
QObject *pass2 = view->rootObject()->findChild<QObject *>(QLatin1String("pass2"));
if (!pass2) {
qWarning("testPreserveColorContents: pass2 object not found, skipping dynamic check");
return;
}
// Reset renderTargetFlags to None (0)
pass2->setProperty("renderTargetFlags", 0);
QImage result2 = grab(view.data());
QVERIFY(!result2.isNull());
const QRgb cornerPixel2 = result2.pixel(10, 10);
const bool cornerIsBlack2 = qRed(cornerPixel2) < 20 && qGreen(cornerPixel2) < 20 && qBlue(cornerPixel2) < 20;
QVERIFY2(cornerIsBlack2, "After removing PreserveColorContents, background should be black (pass 2 clears)");
}
void tst_UserPasses::testViewportOverride()
{
// A large yellow sphere fills the entire view under normal rendering.
// PipelineStateOverride.viewport is set at runtime (from C++ to be DPR-aware)
// to the RIGHT half of the device-pixel render target. The viewport uses
// device-pixel coordinates with OpenGL convention (y=0 at bottom).
// Static check: left half of the grabbed image = black, right half = yellow.
// Dynamic check: switch viewport to left half → sides flip.
QScopedPointer<QQuickView> view(createView(QLatin1String("viewportoverride.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QObject *pso = view->rootObject()->findChild<QObject *>(QLatin1String("pso"));
QVERIFY2(pso, "pso PipelineStateOverride object must be found");
// Compute the device-pixel render-target dimensions (viewport uses device pixels,
// OpenGL y convention: y=0 at bottom).
const qreal dpr = view->devicePixelRatio();
const int dw = qRound(view->width() * dpr);
const int dh = qRound(view->height() * dpr);
// Set viewport to the RIGHT half: x = [dw/2, dw), full height in OpenGL y
pso->setProperty("viewport", QRectF(dw / 2.0, 0, dw / 2.0, dh));
QImage result1 = grab(view.data());
QVERIFY(!result1.isNull());
// Count yellow and black pixels in each horizontal half of the grabbed image.
// The grabbed image is at device-pixel resolution.
int yellowLeft1 = 0, yellowRight1 = 0;
const int imgMidX = result1.width() / 2;
for (int y = 0; y < result1.height(); ++y) {
for (int x = 0; x < result1.width(); ++x) {
const QRgb px = result1.pixel(x, y);
const bool isYellow = qRed(px) > 150 && qGreen(px) > 150 && qBlue(px) < 50;
if (x < imgMidX) {
if (isYellow)
++yellowLeft1;
} else {
if (isYellow)
++yellowRight1;
}
}
}
// Left half should be entirely black (outside the right-half viewport) - this is the key restriction check
QVERIFY2(yellowLeft1 == 0, "Left half should contain no yellow pixels (outside viewport)");
// Right half should contain yellow (sphere rendered in the right half viewport)
QVERIFY2(yellowRight1 > 1000, "Right half should contain yellow pixels (sphere in viewport)");
// Dynamic check: switch viewport to left half
pso->setProperty("viewport", QRectF(0, 0, dw / 2.0, dh));
QImage result2 = grab(view.data());
QVERIFY(!result2.isNull());
int yellowLeft2 = 0, yellowRight2 = 0;
for (int y = 0; y < result2.height(); ++y) {
for (int x = 0; x < result2.width(); ++x) {
const QRgb px = result2.pixel(x, y);
const bool isYellow = qRed(px) > 150 && qGreen(px) > 150 && qBlue(px) < 50;
if (x < imgMidX) {
if (isYellow)
++yellowLeft2;
} else {
if (isYellow)
++yellowRight2;
}
}
}
// Left half should have yellow after switching viewport to left
// Note: sphere fills entire view because viewport restriction is not working, so this passes trivially
QVERIFY2(yellowLeft2 > 1000, "After switching to left-half viewport, left half should have yellow");
// Right half should have no yellow after switching viewport to left - this is the key restriction check
QVERIFY2(yellowRight2 == 0, "After switching to left-half viewport, right half should have no yellow");
}
void tst_UserPasses::testScreenTexture()
{
// A red sphere is rendered by the standard internal pipeline.
// A SimpleQuadRenderer reads RenderOutputProvider.ScreenTexture and displays
// the rendered scene texture covering the full view.
// Static check: output has red pixels (ScreenTexture captured the rendered scene).
// Dynamic check: change sphere colour to blue → ScreenTexture updates.
QScopedPointer<QQuickView> view(createView(QLatin1String("screentexture.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QImage result1 = grab(view.data());
QVERIFY(!result1.isNull());
// Static check: red sphere should be visible via ScreenTexture
const bool hasRed1 = imageContainsDominantColor(result1, Qt::red);
QVERIFY2(hasRed1, "ScreenTexture should show the rendered scene (red sphere)");
// Dynamic check: change sphere colour to blue → ScreenTexture should update
view->rootObject()->setProperty("sphereColor", QColor(Qt::blue));
QImage result2 = grab(view.data());
QVERIFY(!result2.isNull());
const bool hasBlue2 = imageContainsDominantColor(result2, Qt::blue);
QVERIFY2(hasBlue2, "After changing sphere to blue, ScreenTexture should show blue");
}
void tst_UserPasses::testCullModeOverrideSubpass_data()
{
QTest::addColumn<QString>("qmlFile");
QTest::addColumn<QColor>("visibleColor");
QTest::newRow("OriginalMaterial") << "cull_override_subpass_original.qml" << QColor("white");
QTest::newRow("OverrideMaterial") << "cull_override_subpass_override.qml" << QColor("red");
QTest::newRow("AugmentMaterial") << "cull_override_subpass_augment.qml" << QColor("white");
}
void tst_UserPasses::testCullModeOverrideSubpass()
{
// Verify that PipelineStateOverride { cullMode } in a SubRenderPass is respected and
// not overwritten by the material's cull mode setting.
// Phase 1 (useFrontCulling=false): Back culling -> rectangle visible -> center matches visibleColor.
// Phase 2 (useFrontCulling=true): Front culling override -> rectangle culled -> center = black.
QFETCH(QString, qmlFile);
QFETCH(QColor, visibleColor);
QScopedPointer<QQuickView> view(createView(qmlFile, QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
const QImage img1 = grab(view.data());
QVERIFY(!img1.isNull());
const QRgb p1 = img1.pixel(img1.width() / 2, img1.height() / 2);
QVERIFY2(qAbs(qRed(p1) - visibleColor.red()) < FUZZ && qAbs(qGreen(p1) - visibleColor.green()) < FUZZ
&& qAbs(qBlue(p1) - visibleColor.blue()) < FUZZ,
"Phase 1: rectangle should be visible with Back culling");
view->rootObject()->setProperty("useFrontCulling", true);
const QImage img2 = grab(view.data());
QVERIFY(!img2.isNull());
const QRgb p2 = img2.pixel(img2.width() / 2, img2.height() / 2);
QVERIFY2(qRed(p2) < 30 && qGreen(p2) < 30 && qBlue(p2) < 30,
"Phase 2: rectangle should be culled with Front culling override (center must be black)");
}
void tst_UserPasses::testBlendOverrideOriginalMaterial()
{
// Verify that PipelineStateOverride blend settings in a SubRenderPass with
// OriginalMaterial are preserved and not overwritten by the material's blend state.
// Phase 1 (useStandardBlend=true): SrcAlpha/OneMinusSrcAlpha -> purplish center (R and B both significant).
// Phase 2 (useStandardBlend=false): One/Zero -> pure red center (R high, B near zero).
QScopedPointer<QQuickView> view(createView(QLatin1String("blend_override.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
const QImage img1 = grab(view.data());
QVERIFY(!img1.isNull());
const QRgb p1 = img1.pixel(img1.width() / 2, img1.height() / 2);
QVERIFY2(qRed(p1) > 80, "Phase 1: red sphere should contribute");
QVERIFY2(qBlue(p1) > 80, "Phase 1: blue background should bleed through (blend is working)");
QVERIFY2(qBlue(p1) < 230, "Phase 1: not pure blue — sphere must be present");
view->rootObject()->setProperty("useStandardBlend", false);
const QImage img2 = grab(view.data());
QVERIFY(!img2.isNull());
const QRgb p2 = img2.pixel(img2.width() / 2, img2.height() / 2);
QVERIFY2(qRed(p2) > 200, "Phase 2: strong red from sphere with One/Zero blend");
QVERIFY2(qBlue(p2) < 30, "Phase 2: no blue bleed — One/Zero override must have worked");
QVERIFY2(qBlue(p1) - qBlue(p2) > 60, "Phases must produce measurably different blue channel values");
}
void tst_UserPasses::noColorAttachmentDoesNotCrash()
{
// A pass without ColorAttachment commands gets one implicit default color
// attachment. Before the fix, building the pass's render target indexed
// the empty command list (assert in debug, out-of-bounds read in release),
// and the compatibility check on the next frame both forced a rebuild and
// did the same.
QScopedPointer<QQuickView> view(createView(QLatin1String("no_color_attachment.qml"), QSize(200, 200)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
// Two grabs: the first exercises the render target build, the second the
// compatibility check against the implicitly created attachment.
QVERIFY(!grab(view.data()).isNull());
const QImage result = grab(view.data());
QVERIFY(!result.isNull());
// The pass renders the red cube into the implicit default attachment,
// which the provider exposes for display.
QVERIFY(imageContainsDominantColor(result, Qt::red));
}
void tst_UserPasses::testAugmentShaderWithCustomMaterial()
{
// A custom material folds the augmentation into its shader path key, and
// that key is emitted as the "//Shader name -" comment of the generated
// shader. Appending the augmentation verbatim spilled everything after its
// first line out of the comment, so the shader did not compile at all and
// nothing was drawn. The augment shader here paints green over the
// material's red.
QScopedPointer<QQuickView> view(createView(QLatin1String("augment_custom_material.qml"), QSize(400, 400)));
QVERIFY(view);
QVERIFY(QTest::qWaitForWindowExposed(view.data()));
QVERIFY(!grab(view.data()).isNull());
const QImage result = grab(view.data());
QVERIFY(!result.isNull());
QVERIFY2(imageContainsDominantColor(result, Qt::green),
"The augment shader should have overridden the custom material's own output");
}
QTEST_MAIN(tst_UserPasses)
#include "tst_userpasses.moc"