diff --git a/CMakeLists.txt b/CMakeLists.txt index 3594dccfb..25bbb29dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ option(ALP_BUILD_UNITTESTS "include unit test targets in the buildsystem" ON) option(ALP_BUILD_GL_ENGINE "include the gl engine in the buildsystem" OFF) option(ALP_BUILD_PLAIN_RENDERER "include the plain renderer in the buildsystem" ON) option(ALP_BUILD_ALPINEAPP "include the qml app in the buildsystem" ON) +option(ALP_BUILD_TEXTURE_COMPRESSION_PREVIEW "include the texture compression preview application" OFF) set(ALP_WEBGPU_DEFAULT ON) if (APPLE OR ANDROID) set(ALP_WEBGPU_DEFAULT OFF) @@ -124,7 +125,7 @@ endif() add_subdirectory(nucleus) -if (ALP_BUILD_GL_ENGINE OR ALP_BUILD_PLAIN_RENDERER OR ALP_BUILD_ALPINEAPP) +if (ALP_BUILD_GL_ENGINE OR ALP_BUILD_PLAIN_RENDERER OR ALP_BUILD_ALPINEAPP OR ALP_BUILD_TEXTURE_COMPRESSION_PREVIEW) add_subdirectory(gl_engine) endif() if (ALP_BUILD_PLAIN_RENDERER) @@ -139,6 +140,9 @@ if (ALP_BUILD_ALPINEAPP) endif() add_subdirectory(app) endif() +if (ALP_BUILD_TEXTURE_COMPRESSION_PREVIEW) + add_subdirectory(apps/texture_compression_benchmark) +endif() if (ALP_BUILD_WEBGPU_BASE OR ALP_BUILD_WEBGPU_ENGINE OR ALP_BUILD_WEBGPU_COMPUTE OR ALP_BUILD_WEBGPU_APP) include(${CMAKE_SOURCE_DIR}/cmake/SetupWebGPUPlatform.cmake) diff --git a/app/main.cpp b/app/main.cpp index 46858055b..db84cd4f3 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -65,6 +65,25 @@ int main(int argc, char **argv) { // originalHandler = qInstallMessageHandler(filter_log); QQuickWindow::setGraphicsApi(QSGRendererInterface::GraphicsApi::OpenGLRhi); + + QSurfaceFormat fmt; + fmt.setDepthBufferSize(24); +#ifdef ALP_ENABLE_DEV_TOOLS + fmt.setOption(QSurfaceFormat::DebugContext); +#endif + + if (QOpenGLContext::openGLModuleType() == QOpenGLContext::LibGL) { + qDebug("Requesting 3.3 core context"); + fmt.setRenderableType(QSurfaceFormat::OpenGL); + fmt.setVersion(3, 3); + fmt.setProfile(QSurfaceFormat::CoreProfile); + } else { + qDebug("Requesting 3.0 context"); + fmt.setVersion(3, 0); + } + + QSurfaceFormat::setDefaultFormat(fmt); + #if defined(ALP_ENABLE_DEV_TOOLS) || defined(__ANDROID__) QApplication app(argc, argv); #else @@ -121,23 +140,6 @@ int main(int argc, char **argv) } } - QSurfaceFormat fmt; - fmt.setDepthBufferSize(24); -#ifdef ALP_ENABLE_DEV_TOOLS - fmt.setOption(QSurfaceFormat::DebugContext); -#endif - - if (QOpenGLContext::openGLModuleType() == QOpenGLContext::LibGL) { - qDebug("Requesting 3.3 core context"); - fmt.setVersion(3, 3); - fmt.setProfile(QSurfaceFormat::CoreProfile); - } else { - qDebug("Requesting 3.0 context"); - fmt.setVersion(3, 0); - } - - QSurfaceFormat::setDefaultFormat(fmt); - // create in main thread #ifdef ALP_ENABLE_DEV_TOOLS TimerFrontendManager::instance(); @@ -192,4 +194,3 @@ int main(int argc, char **argv) return app.exec(); } - diff --git a/apps/texture_compression_benchmark/CMakeLists.txt b/apps/texture_compression_benchmark/CMakeLists.txt new file mode 100644 index 000000000..d06bf3fd1 --- /dev/null +++ b/apps/texture_compression_benchmark/CMakeLists.txt @@ -0,0 +1,59 @@ +############################################################################# +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek +# SPDX-License-Identifier: GPL-3.0-or-later +############################################################################# + +project(texture-compression-preview LANGUAGES CXX) + +qt_add_executable(texture_compression_preview + main.cpp + TextureCompressionData.h + TexturePreviewItem.h + TexturePreviewItem.cpp +) + +qt_add_qml_module(texture_compression_preview + URI TextureCompressionPreview + VERSION 1.0 + RESOURCE_PREFIX /qt/qml + QML_FILES Main.qml +) + +qt_add_resources(texture_compression_preview "fonts" + BASE "${alpineapp_fonts_SOURCE_DIR}" + PREFIX "/fonts" + FILES "${alpineapp_fonts_SOURCE_DIR}/Roboto/Roboto-Regular.ttf" +) + +target_link_libraries(texture_compression_preview PUBLIC gl_engine Qt::Network Qt::Quick Qt::QuickControls2) +alp_configure_target(texture_compression_preview) + +if (ANDROID) + add_android_openssl_libraries(texture_compression_preview) + set_target_properties(texture_compression_preview PROPERTIES + QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android" + QT_ANDROID_PACKAGE_NAME "org.alpinemaps.texturecompressionpreview" + QT_ANDROID_VERSION_NAME "1.0" + QT_ANDROID_VERSION_CODE 1 + ) + install(TARGETS texture_compression_preview + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +endif() + +if (EMSCRIPTEN) + install( + FILES + "$/texture_compression_preview.js" + "$/texture_compression_preview.wasm" + "$/texture_compression_preview.html" + "$/qtloader.js" + DESTINATION "${ALP_WWW_INSTALL_DIR}/texture_compression_preview" + ) + install( + FILES "$/texture_compression_preview.worker.js" + DESTINATION "${ALP_WWW_INSTALL_DIR}/texture_compression_preview" + OPTIONAL + ) +endif() diff --git a/apps/texture_compression_benchmark/Main.qml b/apps/texture_compression_benchmark/Main.qml new file mode 100644 index 000000000..182f577ff --- /dev/null +++ b/apps/texture_compression_benchmark/Main.qml @@ -0,0 +1,158 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import TextureCompressionPreview + +ApplicationWindow { + id: root + + width: 900 + height: 900 + minimumWidth: 360 + minimumHeight: 480 + visible: true + title: qsTr("Texture Compression Preview") + LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 12 + + Label { + Layout.fillWidth: true + text: qsTr("Texture compression preview") + font.pointSize: 20 + font.weight: Font.Medium + wrapMode: Text.Wrap + } + + Label { + Layout.fillWidth: true + text: preview.status + wrapMode: Text.Wrap + } + + GridLayout { + Layout.fillWidth: true + columns: width >= 760 ? 9 : width >= 500 ? 5 : 3 + columnSpacing: 6 + rowSpacing: 6 + + Repeater { + model: preview.previewEncoders + + Button { + required property int index + required property string modelData + + Layout.fillWidth: true + text: modelData + enabled: preview.ready + highlighted: preview.previewEncoder === index + onClicked: preview.previewEncoder = index + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 300 + + Flickable { + id: previewViewport + + anchors.fill: parent + clip: true + boundsBehavior: Flickable.StopAtBounds + contentWidth: Math.max(width, previewHost.width * previewHost.scale) + contentHeight: Math.max(height, previewHost.height * previewHost.scale) + + Item { + id: previewHost + + readonly property real fittedSize: Math.min(previewViewport.width, previewViewport.height) + + x: (previewViewport.contentWidth - width) / 2 + y: (previewViewport.contentHeight - height) / 2 + width: fittedSize + height: width + + TexturePreviewItem { + id: preview + anchors.fill: parent + } + + PinchHandler { + target: previewHost + rotationAxis.enabled: false + xAxis.enabled: false + yAxis.enabled: false + scaleAxis.minimum: 1 + scaleAxis.maximum: 8 + } + } + } + + BusyIndicator { + anchors.centerIn: parent + running: preview.loading + visible: running + } + } + + RowLayout { + Layout.fillWidth: true + + Label { + Layout.fillWidth: true + text: preview.ready + ? (Number.isFinite(preview.previewPsnr) + ? qsTr("%1 — PSNR: %2 dB").arg(preview.previewName).arg(preview.previewPsnr.toFixed(2)) + : qsTr("%1 — PSNR: ∞").arg(preview.previewName)) + : "" + wrapMode: Text.Wrap + } + + Button { + text: qsTr("Description") + enabled: preview.ready + onClicked: { + if (descriptionDialogLoader.status === Loader.Ready) + descriptionDialogLoader.item.open() + else + descriptionDialogLoader.active = true + } + } + } + } + + Loader { + id: descriptionDialogLoader + + active: false + asynchronous: true + onLoaded: { + if (status === Loader.Ready) + item.open() + } + + sourceComponent: Component { + Dialog { + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(root.width - 40, 560) + modal: true + title: preview.previewName + standardButtons: Dialog.Close + + contentItem: Label { + text: preview.previewDescription + wrapMode: Text.Wrap + } + } + } + } +} diff --git a/apps/texture_compression_benchmark/TextureCompressionData.h b/apps/texture_compression_benchmark/TextureCompressionData.h new file mode 100644 index 000000000..7b9acc383 --- /dev/null +++ b/apps/texture_compression_benchmark/TextureCompressionData.h @@ -0,0 +1,47 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek + * SPDX-License-Identifier: GPL-3.0-or-later + *****************************************************************************/ + +#pragma once + +#include +#include + +namespace texture_compression_data { + +struct TileGroup { + int zoom; + int y; + int x; +}; + +constexpr std::array tile_groups { { + { 17, 45448, 71496 }, + { 16, 22832, 35144 }, + { 16, 23030, 35578 }, + { 13, 2852, 4476 }, + { 14, 5702, 8808 }, + { 15, 11574, 17670 }, + { 16, 23030, 35078 }, + { 15, 11460, 17622 }, + { 14, 5752, 8656 }, + { 16, 23084, 34746 }, + { 14, 5684, 8926 }, + { 15, 11418, 17692 }, + { 16, 22956, 34570 }, + { 15, 11358, 17904 }, + { 16, 22910, 35770 }, + { 14, 5656, 8938 }, +} }; + +inline QString tile_url(const TileGroup& group, int x_offset, int y_offset) +{ + return QStringLiteral("https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/%1/%2/%3.jpeg") + .arg(group.zoom) + .arg(group.y + y_offset) + .arg(group.x + x_offset); +} + +} // namespace texture_compression_data diff --git a/apps/texture_compression_benchmark/TexturePreviewItem.cpp b/apps/texture_compression_benchmark/TexturePreviewItem.cpp new file mode 100644 index 000000000..71ae2c841 --- /dev/null +++ b/apps/texture_compression_benchmark/TexturePreviewItem.cpp @@ -0,0 +1,465 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek + * SPDX-License-Identifier: GPL-3.0-or-later + *****************************************************************************/ + +#include "TexturePreviewItem.h" +#include "TextureCompressionData.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using Raster = radix::Raster; + +using texture_compression_data::tile_groups; +using texture_compression_data::tile_url; + +double srgbToLinear(uint8_t value) +{ + const auto normalised = double(value) / 255.0; + if (normalised <= 0.04045) + return normalised / 12.92; + return std::pow((normalised + 0.055) / 1.055, 2.4); +} + +double linearPsnr(std::span reconstructed, std::span sources) +{ + Q_ASSERT(reconstructed.size() == sources.size()); + double squared_error = 0.0; + uint64_t channel_count = 0; + for (size_t i = 0; i < sources.size(); ++i) { + for (int y = 0; y < reconstructed[i].height(); ++y) { + for (int x = 0; x < reconstructed[i].width(); ++x) { + const auto actual = reconstructed[i].pixel(x, y); + const auto expected = sources[i].pixel({ x, y }); + const std::array actual_channels { + qRed(actual) / 255.0, + qGreen(actual) / 255.0, + qBlue(actual) / 255.0, + }; + const std::array expected_channels { + srgbToLinear(expected.x), + srgbToLinear(expected.y), + srgbToLinear(expected.z), + }; + for (size_t channel = 0; channel < actual_channels.size(); ++channel) { + const auto difference = actual_channels[channel] - expected_channels[channel]; + squared_error += difference * difference; + } + } + } + channel_count += uint64_t(reconstructed[i].width()) * uint64_t(reconstructed[i].height()) * 3; + } + const auto mse = squared_error / double(channel_count); + return mse == 0.0 ? std::numeric_limits::infinity() : 10.0 * std::log10(1.0 / mse); +} + +QImage reconstruct(gl_engine::Texture& texture, unsigned resolution, unsigned layer) +{ + gl_engine::Framebuffer framebuffer( + gl_engine::Framebuffer::DepthFormat::None, { gl_engine::Framebuffer::ColourFormat::RGBA8 }, { resolution, resolution }); + framebuffer.bind(); + gl_engine::ShaderProgram shader(R"( + out highp vec2 texcoords; + void main() { + vec2 vertices[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(vertices[gl_VertexID], 0.0, 1.0); + texcoords = 0.5 * gl_Position.xy + vec2(0.5); + })", + R"( + uniform lowp sampler2DArray texture_sampler; + uniform highp float texture_layer; + in highp vec2 texcoords; + out lowp vec4 out_color; + void main() { + out_color = textureLod(texture_sampler, vec3(texcoords.x, 1.0 - texcoords.y, texture_layer), 0.0); + })", + gl_engine::ShaderCodeSource::PLAINTEXT); + shader.bind(); + texture.bind(0); + shader.set_uniform("texture_sampler", 0); + shader.set_uniform("texture_layer", float(layer)); + gl_engine::helpers::create_screen_quad_geometry().draw(); + auto result = framebuffer.read_colour_attachment(0); + gl_engine::Framebuffer::unbind(); + return result; +} + +struct GpuPreview { + const char* name; + const char* description; + gl_engine::TextureCompressor::Settings settings; +}; + +std::vector gpu_previews(nucleus::utils::ColourTexture::Format format) +{ + using Compressor = gl_engine::TextureCompressor; + const auto search = [](unsigned effort) { + return Compressor::Settings { + .dxt1_algorithm = Compressor::Dxt1Algorithm::SlowSearch, + .etc_algorithm = Compressor::EtcAlgorithm::SlowSearch, + .search_effort = effort, + }; + }; + std::vector result { + { "Slow search 0", + "Tests the average block colour with every ETC1 modifier table and keeps the lowest-error result.", + search(0) }, + { "Slow search 1", + "Tests two base colours around the block average with every ETC1 modifier table and keeps the lowest-error result.", + search(1) }, + { "Slow search 2", + "Tests three base colours around the block average with every ETC1 modifier table and keeps the lowest-error result.", + search(2) }, + { "Slow search 3", + "Tests four base colours around the block average with every ETC1 modifier table and keeps the lowest-error result.", + search(3) }, + { "Slow search 4", + "Tests five base colours around the block average with every ETC1 modifier table and keeps the lowest-error result.", + search(4) }, + { "Slow search 10", + "Tests eleven base colours around the block average with every ETC1 modifier table and keeps the lowest-error result.", + search(10) }, + }; + if (format == nucleus::utils::ColourTexture::Format::ETC1) { + result.push_back({ "Fastest", + "Evaluates horizontal and vertical ETC splits with exact modifier selection.", + { .etc_algorithm = Compressor::EtcAlgorithm::Fastest } }); + result.push_back({ "Fast", + "Refits each ETC base from its average reconstruction residual and evaluates it once.", + { .etc_algorithm = Compressor::EtcAlgorithm::Fast } }); + } + return result; +} +} // namespace + +class TexturePreviewRenderer final : public QQuickFramebufferObject::Renderer { +public: + void synchronize(QQuickFramebufferObject* item) override + { + auto* preview_item = static_cast(item); + m_item = preview_item; + m_window = preview_item->window(); + m_preview_encoder = preview_item->m_preview_encoder; + if (preview_item->m_request_serial == m_seen_serial) + return; + m_seen_serial = preview_item->m_request_serial; + m_source_images = preview_item->m_source_images; + m_pending = true; + } + + void render() override + { + m_window->beginExternalCommands(); + if (m_pending) { + m_pending = false; + const auto error = generateTextures(); + QPointer item = m_item; + const auto results = m_preview_results; + QMetaObject::invokeMethod(m_item, [item, error, results]() { + if (item) + item->publishResults(error, results); + }); + } + drawPreview(); + m_window->endExternalCommands(); + } + + QOpenGLFramebufferObject* createFramebufferObject(const QSize& size) override + { + QOpenGLFramebufferObjectFormat format; + format.setAttachment(QOpenGLFramebufferObject::NoAttachment); + return new QOpenGLFramebufferObject(size.expandedTo(QSize(1, 1)), format); + } + +private: + QString generateTextures() + { + constexpr unsigned resolution = 512; + if (m_source_images.size() != tile_groups.size()) + return QStringLiteral("Preview imagery is incomplete."); + std::vector sources; + sources.reserve(m_source_images.size()); + for (const auto& image : m_source_images) + sources.push_back(nucleus::tile::conversion::to_rgba8raster(image)); + + std::vector layers(sources.size()); + std::iota(layers.begin(), layers.end(), 0u); + const auto algorithm = gl_engine::Texture::compression_algorithm(); + const auto filter = gl_engine::Texture::Filter::MipMapLinear; + const auto mip_levels = gl_engine::TextureCompressor::mip_level_count(resolution, resolution); + const auto previews = gpu_previews(algorithm); + m_preview_results.clear(); + m_preview_results.reserve(3 + previews.size()); + m_preview_textures.clear(); + m_preview_textures.reserve(3 + previews.size()); + + auto create_texture = [&](gl_engine::Texture::Format format, gl_engine::Texture::Filter min_filter) { + auto texture = std::make_shared(gl_engine::Texture::Target::_2dArray, format); + texture->setParams(min_filter, gl_engine::Texture::Filter::Linear); + texture->allocate_array(resolution, resolution, unsigned(sources.size()), mip_levels); + return texture; + }; + auto psnr = [&](gl_engine::Texture& texture) { + std::vector reconstructed; + reconstructed.reserve(sources.size()); + for (unsigned layer = 0; layer < sources.size(); ++layer) + reconstructed.push_back(reconstruct(texture, resolution, layer)); + return linearPsnr(reconstructed, sources); + }; + + auto scratch = create_texture(gl_engine::Texture::Format::RGBA8, gl_engine::Texture::Filter::Nearest); + for (size_t layer = 0; layer < sources.size(); ++layer) + scratch->upload(sources[layer], unsigned(layer)); + scratch->generate_mipmaps(); + + auto reference = create_texture(gl_engine::Texture::Format::SRGBA8, gl_engine::Texture::Filter::Linear); + for (size_t layer = 0; layer < sources.size(); ++layer) + reference->upload(sources[layer], unsigned(layer)); + m_preview_textures.push_back(reference); + m_preview_results.push_back({ QStringLiteral("Ref"), + QStringLiteral("The original uncompressed texture array used as the visual and PSNR reference."), + std::numeric_limits::infinity() }); + + auto copied = create_texture(gl_engine::Texture::Format::SRGBA8, filter); + gl_engine::TextureCompressor copy_compressor(scratch, copied); + if (const auto result = copy_compressor.compress(layers); !result) + return QString::fromStdString(result.error()); + m_preview_textures.push_back(copied); + m_preview_results.push_back({ QStringLiteral("GPU copy"), + QStringLiteral("The RGBA8 scratch texture copied through the portable RGBA8 framebuffer path."), + psnr(*copied) }); + + auto goofy = create_texture(gl_engine::Texture::Format::CompressedRGBA8, filter); + for (size_t layer = 0; layer < sources.size(); ++layer) { + const auto compressed = nucleus::utils::generate_mipmapped_colour_texture(sources[layer], algorithm); + goofy->upload(compressed, unsigned(layer)); + } + m_preview_textures.push_back(goofy); + m_preview_results.push_back({ QStringLiteral("Goofy"), + QStringLiteral("CPU reference compressed by Goofy into the device's active ETC1 or DXT1 block format."), + psnr(*goofy) }); + + for (const auto& preview : previews) { + auto texture = create_texture(gl_engine::Texture::Format::CompressedRGBA8, filter); + gl_engine::TextureCompressor compressor(scratch, texture, preview.settings); + if (const auto result = compressor.compress(layers); !result) + return QString::fromStdString(result.error()); + m_preview_textures.push_back(texture); + m_preview_results.push_back( + { QString::fromLatin1(preview.name), QString::fromLatin1(preview.description), psnr(*texture) }); + } + return {}; + } + + void drawPreview() + { + if (m_preview_encoder < 0 || size_t(m_preview_encoder) >= m_preview_textures.size() + || !m_preview_textures[size_t(m_preview_encoder)]) + return; + if (!m_preview_shader) { + m_preview_shader = std::make_unique(R"( + out highp vec2 texcoords; + void main() { + highp vec2 vertices[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(vertices[gl_VertexID], 0.0, 1.0); + texcoords = 0.5 * gl_Position.xy + vec2(0.5); + })", + R"( + uniform lowp sampler2DArray texture_sampler; + in highp vec2 texcoords; + out lowp vec4 out_color; + highp vec3 linear_to_srgb(highp vec3 linear) { + return mix(12.92 * linear, + 1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055, + step(vec3(0.0031308), linear)); + } + void main() { + highp vec2 grid_position = texcoords * 4.0; + highp ivec2 cell = min(ivec2(grid_position), ivec2(3)); + highp float layer = float((3 - cell.y) * 4 + cell.x); + highp vec2 tile_coordinates = fract(grid_position); + highp vec4 linear_color = textureLod(texture_sampler, + vec3(tile_coordinates.x, 1.0 - tile_coordinates.y, layer), 0.0); + out_color = vec4(linear_to_srgb(linear_color.rgb), linear_color.a); + })", + gl_engine::ShaderCodeSource::PLAINTEXT); + m_preview_geometry = gl_engine::helpers::create_screen_quad_geometry(); + } + + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + framebufferObject()->bind(); + f->glViewport(0, 0, framebufferObject()->width(), framebufferObject()->height()); + f->glDisable(GL_BLEND); + f->glDisable(GL_CULL_FACE); + f->glDisable(GL_DEPTH_TEST); + f->glDisable(GL_SCISSOR_TEST); + m_preview_shader->bind(); + m_preview_textures[size_t(m_preview_encoder)]->bind(0); + m_preview_shader->set_uniform("texture_sampler", 0); + m_preview_geometry.draw(); + m_preview_shader->release(); + } + + QPointer m_item; + QQuickWindow* m_window = nullptr; + unsigned m_seen_serial = 0; + int m_preview_encoder = 0; + bool m_pending = false; + std::vector m_source_images; + std::vector m_preview_results; + std::vector> m_preview_textures; + std::unique_ptr m_preview_shader; + gl_engine::helpers::ScreenQuadGeometry m_preview_geometry; +}; + +TexturePreviewItem::TexturePreviewItem(QQuickItem* parent) + : QQuickFramebufferObject(parent) + , m_network_manager(new QNetworkAccessManager(this)) +{ + setMirrorVertically(true); + downloadImages(); +} + +QQuickFramebufferObject::Renderer* TexturePreviewItem::createRenderer() const { return new TexturePreviewRenderer; } + +QString TexturePreviewItem::status() const { return m_status; } +bool TexturePreviewItem::loading() const { return m_loading; } +bool TexturePreviewItem::ready() const { return !m_preview_results.empty(); } +int TexturePreviewItem::previewEncoder() const { return m_preview_encoder; } + +void TexturePreviewItem::setPreviewEncoder(int value) +{ + value = m_preview_results.empty() ? 0 : std::clamp(value, 0, int(m_preview_results.size()) - 1); + if (m_preview_encoder == value) + return; + m_preview_encoder = value; + emit previewEncoderChanged(); + emit previewDetailsChanged(); + update(); +} + +QStringList TexturePreviewItem::previewEncoders() const +{ + QStringList result; + result.reserve(qsizetype(m_preview_results.size())); + for (const auto& preview : m_preview_results) + result.push_back(preview.name); + return result; +} + +QString TexturePreviewItem::previewName() const +{ + return ready() ? m_preview_results[size_t(m_preview_encoder)].name : QString {}; +} + +QString TexturePreviewItem::previewDescription() const +{ + return ready() ? m_preview_results[size_t(m_preview_encoder)].description : QString {}; +} + +double TexturePreviewItem::previewPsnr() const +{ + return ready() ? m_preview_results[size_t(m_preview_encoder)].psnr : 0.0; +} + +void TexturePreviewItem::downloadImages() +{ + m_downloaded_tiles.resize(tile_groups.size() * 4); + m_downloads_remaining = int(m_downloaded_tiles.size()); + for (size_t group_index = 0; group_index < tile_groups.size(); ++group_index) { + for (int y = 0; y < 2; ++y) { + for (int x = 0; x < 2; ++x) { + const auto tile_index = group_index * 4 + size_t(y * 2 + x); + const auto url = tile_url(tile_groups[group_index], x, y); + auto* reply = m_network_manager->get(QNetworkRequest(QUrl(url))); + connect(reply, &QNetworkReply::finished, this, [this, reply, tile_index, url]() { + if (reply->error() == QNetworkReply::NoError) { + const auto image = nucleus::utils::image_loader::rgba8(reply->readAll()); + if (image && image->size() == glm::uvec2(256u)) + m_downloaded_tiles[tile_index] = nucleus::tile::conversion::to_QImage(*image); + } + if (m_downloaded_tiles[tile_index].isNull()) + m_status = QStringLiteral("Unable to download preview tile: %1").arg(url); + reply->deleteLater(); + --m_downloads_remaining; + if (m_downloads_remaining > 0) { + if (!m_status.startsWith(QStringLiteral("Unable"))) { + m_status = QStringLiteral("Downloading preview imagery… %1/%2") + .arg(int(m_downloaded_tiles.size()) - m_downloads_remaining) + .arg(m_downloaded_tiles.size()); + } + emit statusChanged(); + return; + } + if (std::ranges::any_of(m_downloaded_tiles, [](const QImage& image) { return image.isNull(); })) { + m_loading = false; + emit statusChanged(); + return; + } + stitchImages(); + }); + } + } + } +} + +void TexturePreviewItem::stitchImages() +{ + m_source_images.clear(); + m_source_images.reserve(tile_groups.size()); + for (size_t group_index = 0; group_index < tile_groups.size(); ++group_index) { + QImage stitched(512, 512, QImage::Format_RGBA8888); + QPainter painter(&stitched); + for (int y = 0; y < 2; ++y) { + for (int x = 0; x < 2; ++x) + painter.drawImage(QPoint(x * 256, y * 256), m_downloaded_tiles[group_index * 4 + size_t(y * 2 + x)]); + } + m_source_images.push_back(std::move(stitched)); + } + m_downloaded_tiles.clear(); + m_status = QStringLiteral("Generating compressed texture arrays…"); + ++m_request_serial; + emit statusChanged(); + update(); +} + +void TexturePreviewItem::publishResults(const QString& error, const std::vector& results) +{ + m_preview_results = results; + m_preview_encoder = std::clamp(m_preview_encoder, 0, std::max(0, int(m_preview_results.size()) - 1)); + m_status = error.isEmpty() ? QStringLiteral("Texture previews ready.") : error; + m_loading = false; + emit statusChanged(); + emit previewResultsChanged(); + emit previewDetailsChanged(); + update(); +} diff --git a/apps/texture_compression_benchmark/TexturePreviewItem.h b/apps/texture_compression_benchmark/TexturePreviewItem.h new file mode 100644 index 000000000..e71aceebd --- /dev/null +++ b/apps/texture_compression_benchmark/TexturePreviewItem.h @@ -0,0 +1,71 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek + * SPDX-License-Identifier: GPL-3.0-or-later + *****************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include +#include + +class QNetworkAccessManager; + +class TexturePreviewItem : public QQuickFramebufferObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(QString status READ status NOTIFY statusChanged) + Q_PROPERTY(bool loading READ loading NOTIFY statusChanged) + Q_PROPERTY(bool ready READ ready NOTIFY previewResultsChanged) + Q_PROPERTY(int previewEncoder READ previewEncoder WRITE setPreviewEncoder NOTIFY previewEncoderChanged) + Q_PROPERTY(QStringList previewEncoders READ previewEncoders NOTIFY previewResultsChanged) + Q_PROPERTY(QString previewName READ previewName NOTIFY previewDetailsChanged) + Q_PROPERTY(QString previewDescription READ previewDescription NOTIFY previewDetailsChanged) + Q_PROPERTY(double previewPsnr READ previewPsnr NOTIFY previewDetailsChanged) + +public: + struct PreviewResult { + QString name; + QString description; + double psnr = 0.0; + }; + + explicit TexturePreviewItem(QQuickItem* parent = nullptr); + Renderer* createRenderer() const override; + + [[nodiscard]] QString status() const; + [[nodiscard]] bool loading() const; + [[nodiscard]] bool ready() const; + [[nodiscard]] int previewEncoder() const; + void setPreviewEncoder(int value); + [[nodiscard]] QStringList previewEncoders() const; + [[nodiscard]] QString previewName() const; + [[nodiscard]] QString previewDescription() const; + [[nodiscard]] double previewPsnr() const; + +signals: + void statusChanged(); + void previewEncoderChanged(); + void previewResultsChanged(); + void previewDetailsChanged(); + +private: + friend class TexturePreviewRenderer; + void downloadImages(); + void stitchImages(); + void publishResults(const QString& error, const std::vector& results); + + unsigned m_request_serial = 0; + int m_downloads_remaining = 0; + QNetworkAccessManager* m_network_manager = nullptr; + std::vector m_downloaded_tiles; + std::vector m_source_images; + QString m_status = QStringLiteral("Downloading preview imagery…"); + bool m_loading = true; + int m_preview_encoder = 0; + std::vector m_preview_results; +}; diff --git a/apps/texture_compression_benchmark/android/AndroidManifest.xml b/apps/texture_compression_benchmark/android/AndroidManifest.xml new file mode 100644 index 000000000..d00435557 --- /dev/null +++ b/apps/texture_compression_benchmark/android/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/texture_compression_benchmark/main.cpp b/apps/texture_compression_benchmark/main.cpp new file mode 100644 index 000000000..81b023fd1 --- /dev/null +++ b/apps/texture_compression_benchmark/main.cpp @@ -0,0 +1,41 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek + * SPDX-License-Identifier: GPL-3.0-or-later + *****************************************************************************/ + +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) +{ + QQuickWindow::setGraphicsApi(QSGRendererInterface::GraphicsApi::OpenGLRhi); + + QSurfaceFormat format; +#if QT_CONFIG(opengles2) + format.setVersion(3, 0); +#else + format.setRenderableType(QSurfaceFormat::OpenGL); + format.setVersion(3, 3); + format.setProfile(QSurfaceFormat::CoreProfile); +#endif + QSurfaceFormat::setDefaultFormat(format); + + QGuiApplication application(argc, argv); + QCoreApplication::setOrganizationName(QStringLiteral("AlpineMaps.org")); + QCoreApplication::setApplicationName(QStringLiteral("TextureCompressionPreview")); + QGuiApplication::setApplicationDisplayName(QStringLiteral("Texture Compression Preview")); + QFontDatabase::addApplicationFont(QStringLiteral(":/fonts/Roboto/Roboto-Regular.ttf")); + application.setFont(QFont(QStringLiteral("Roboto"), 12, QFont::Normal)); + + QQmlApplicationEngine engine; + engine.loadFromModule("TextureCompressionPreview", "Main"); + if (engine.rootObjects().isEmpty()) + return -1; + return application.exec(); +} diff --git a/gl_engine/CMakeLists.txt b/gl_engine/CMakeLists.txt index e71be0c13..879db9307 100644 --- a/gl_engine/CMakeLists.txt +++ b/gl_engine/CMakeLists.txt @@ -41,6 +41,7 @@ qt_add_library(gl_engine STATIC ShadowMapping.h ShadowMapping.cpp GpuAsyncQueryTimer.h GpuAsyncQueryTimer.cpp Texture.h Texture.cpp + TextureCompressor.h TextureCompressor.cpp TrackManager.h TrackManager.cpp Context.h Context.cpp TileGeometry.h TileGeometry.cpp @@ -87,6 +88,8 @@ qt_add_resources(gl_engine "shaders" shaders/tile_id.glsl shaders/track.frag shaders/track.vert + shaders/texture_compress.frag + shaders/texture_copy.frag shaders/turbo_colormap.glsl shaders/intersection.glsl shaders/eaws.glsl diff --git a/gl_engine/Framebuffer.cpp b/gl_engine/Framebuffer.cpp index 1dfd439e2..ec5998d26 100644 --- a/gl_engine/Framebuffer.cpp +++ b/gl_engine/Framebuffer.cpp @@ -56,6 +56,10 @@ QOpenGLTexture::TextureFormat internal_format_qt(Framebuffer::ColourFormat f) // return QOpenGLTexture::TextureFormat::RGBA16F; case Framebuffer::ColourFormat::R32UI: return QOpenGLTexture::TextureFormat::R32U; + case Framebuffer::ColourFormat::RG32UI: + return QOpenGLTexture::TextureFormat::RG32U; + case Framebuffer::ColourFormat::RGBA32UI: + return QOpenGLTexture::TextureFormat::RGBA32U; case Framebuffer::ColourFormat::RGBA32F: return QOpenGLTexture::TextureFormat::RGBA32F; } @@ -84,6 +88,10 @@ GLenum format(Framebuffer::ColourFormat f) // return GL_RGBA; case Framebuffer::ColourFormat::R32UI: return GL_RED_INTEGER; + case Framebuffer::ColourFormat::RG32UI: + return GL_RG_INTEGER; + case Framebuffer::ColourFormat::RGBA32UI: + return GL_RGBA_INTEGER; case Framebuffer::ColourFormat::RGBA32F: return GL_RGBA; } @@ -131,6 +139,8 @@ GLenum type(Framebuffer::ColourFormat f) // case Framebuffer::ColourFormat::RGBA16F: // return GL_HALF_FLOAT; case Framebuffer::ColourFormat::R32UI: + case Framebuffer::ColourFormat::RG32UI: + case Framebuffer::ColourFormat::RGBA32UI: return GL_UNSIGNED_INT; } Q_ASSERT(false); @@ -254,6 +264,18 @@ void Framebuffer::bind() f->glBindFramebuffer(GL_FRAMEBUFFER, m_frame_buffer); } +void Framebuffer::bind_for_drawing() +{ + QOpenGLFunctions* f = QOpenGLContext::currentContext()->functions(); + f->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_frame_buffer); +} + +void Framebuffer::bind_for_reading() +{ + QOpenGLFunctions* f = QOpenGLContext::currentContext()->functions(); + f->glBindFramebuffer(GL_READ_FRAMEBUFFER, m_frame_buffer); +} + void Framebuffer::bind_colour_texture(unsigned index, unsigned location) { Q_ASSERT(index < m_colour_textures.size()); @@ -314,6 +336,8 @@ T Framebuffer::read_colour_attachment_pixel(unsigned int index, const glm::dvec2 // case Framebuffer::ColourFormat::RGB16F: // case Framebuffer::ColourFormat::RGBA16F: case Framebuffer::ColourFormat::R32UI: // fails on linux firefox + case Framebuffer::ColourFormat::RG32UI: + case Framebuffer::ColourFormat::RGBA32UI: // unsupported or untested. // you really should add a unit test if you move something down to the supported section // as the support accross platforms (webassembly, android, ios?) is patchy diff --git a/gl_engine/Framebuffer.h b/gl_engine/Framebuffer.h index b63c755b7..26c1c641a 100644 --- a/gl_engine/Framebuffer.h +++ b/gl_engine/Framebuffer.h @@ -55,6 +55,8 @@ class Framebuffer // RGB16F, // NOT COLOR RENDERABLE ON OPENGLES // RGBA16F, // NOT COLOR RENDERABLE ON OPENGLES R32UI, + RG32UI, + RGBA32UI, // Float32, // NOT COLOR RENDERABLE ON OPENGLES RGBA32F, // NOT COLOR RENDERABLE ON OPENGLES (weirdly it works, maybe because of extension, that qt activates?) }; @@ -78,6 +80,8 @@ class Framebuffer ~Framebuffer(); void resize(const glm::uvec2& new_size); void bind(); + void bind_for_drawing(); + void bind_for_reading(); void bind_colour_texture(unsigned index = 0, unsigned location = 0); void bind_depth_texture(unsigned location = 0); diff --git a/gl_engine/ShaderProgram.cpp b/gl_engine/ShaderProgram.cpp index 9e9ac75d9..d72939f98 100644 --- a/gl_engine/ShaderProgram.cpp +++ b/gl_engine/ShaderProgram.cpp @@ -273,6 +273,15 @@ void ShaderProgram::set_uniform_array(const std::string& name, const std::vector m_q_shader_program->setUniformValueArray(uniform_location, reinterpret_cast(array.data()), int(array.size()), 3); } +void ShaderProgram::set_uniform_array(const std::string& name, const std::vector& array) +{ + if (!m_cached_uniforms.contains(name)) + m_cached_uniforms[name] = m_q_shader_program->uniformLocation(name.c_str()); + + const auto uniform_location = m_cached_uniforms.at(name); + m_q_shader_program->setUniformValueArray(uniform_location, array.data(), int(array.size())); +} + // Helper function because i get frustrated with the shader compile errors... // I want the actual line that an error relates to also outputed... void outputMeaningfullErrors(const QString& qtLog, const QString& code, const QString& file) diff --git a/gl_engine/ShaderProgram.h b/gl_engine/ShaderProgram.h index 79a149169..556549d66 100644 --- a/gl_engine/ShaderProgram.h +++ b/gl_engine/ShaderProgram.h @@ -102,6 +102,7 @@ class ShaderProgram { void set_uniform_array(const std::string& name, const std::vector& array); void set_uniform_array(const std::string& name, const std::vector& array); + void set_uniform_array(const std::string& name, const std::vector& array); static void reset_shader_cache(); diff --git a/gl_engine/Texture.cpp b/gl_engine/Texture.cpp index 7d613d23e..77a456061 100644 --- a/gl_engine/Texture.cpp +++ b/gl_engine/Texture.cpp @@ -19,9 +19,12 @@ #include "Texture.h" #include "nucleus/utils/ColourTexture.h" +#include #include #include #include +#include +#include #ifdef __EMSCRIPTEN__ #include #endif @@ -48,6 +51,8 @@ GlParams gl_tex_params(gl_engine::Texture::Format format) return { GLint(gl_engine::Texture::compressed_texture_format()), 0, 0, 0, 0, true }; case F::RGBA8: return { GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, 1, true }; + case F::RGB565: + return { GL_RGB565, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, 1, 2, true }; case F::SRGBA8: return { GL_SRGB8_ALPHA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, 1, true }; case F::RGBA8UI: @@ -114,24 +119,45 @@ void gl_engine::Texture::setParams(Filter min_filter, Filter mag_filter, bool an f->glTexParameterf(GLenum(m_target), max_anisotropy_param(), max_anisotropy()); } -void gl_engine::Texture::allocate_array(unsigned int width, unsigned int height, unsigned int n_layers) +void gl_engine::Texture::allocate_array(unsigned int width, unsigned int height, unsigned int n_layers, unsigned mip_levels) { Q_ASSERT(m_target == Target::_2dArray); Q_ASSERT(m_format != Format::Invalid); + Q_ASSERT(width > 0); + Q_ASSERT(height > 0); + Q_ASSERT(n_layers > 0); - auto mip_level_count = 1; - if (m_min_filter == Filter::MipMapLinear) - mip_level_count = GLsizei(1 + std::floor(std::log2(std::max(width, height)))); + const auto maximum_mip_levels = 1u + unsigned(std::floor(std::log2((std::max)(width, height)))); + Q_ASSERT(mip_levels <= maximum_mip_levels); + + auto mip_level_count = GLsizei(mip_levels); + if (mip_level_count == 0) { + mip_level_count = 1; + if (m_min_filter == Filter::MipMapLinear) + mip_level_count = GLsizei(1 + std::floor(std::log2(std::max(width, height)))); + } m_width = width; m_height = height; m_n_layers = n_layers; + m_mip_levels = unsigned(mip_level_count); auto* f = QOpenGLContext::currentContext()->extraFunctions(); f->glBindTexture(GLenum(m_target), m_id); f->glTexStorage3D(GLenum(m_target), mip_level_count, gl_tex_params(m_format).internal_format, GLsizei(width), GLsizei(height), GLsizei(n_layers)); } +void gl_engine::Texture::generate_mipmaps() +{ + Q_ASSERT(m_target == Target::_2dArray); + Q_ASSERT(m_mip_levels > 1); + Q_ASSERT(m_format != Format::CompressedRGBA8); + Q_ASSERT(gl_tex_params(m_format).is_texture_filterable); + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + f->glBindTexture(GLenum(m_target), m_id); + f->glGenerateMipmap(GLenum(m_target)); +} + void gl_engine::Texture::upload(const nucleus::utils::ColourTexture& texture) { QOpenGLFunctions* f = QOpenGLContext::currentContext()->functions(); diff --git a/gl_engine/Texture.h b/gl_engine/Texture.h index 41328a8e5..ad373ca11 100644 --- a/gl_engine/Texture.h +++ b/gl_engine/Texture.h @@ -28,11 +28,14 @@ #include namespace gl_engine { +class TextureCompressor; + class Texture { public: enum class Target : GLenum { _2d = GL_TEXTURE_2D, _2dArray = GL_TEXTURE_2D_ARRAY }; // no 1D textures in webgl enum class Format { RGBA8, // normalised on gpu + RGB565, // normalised on gpu SRGBA8, // normalised on gpu CompressedRGBA8, // normalised on gpu, compression format depends on desktop/mobile RGBA8UI, @@ -57,7 +60,8 @@ class Texture { void bind(unsigned texture_unit); void setParams(Filter min_filter, Filter mag_filter, bool anisotropic_filtering = false); - void allocate_array(unsigned width, unsigned height, unsigned n_layers); + void allocate_array(unsigned width, unsigned height, unsigned n_layers, unsigned mip_levels = 0); + void generate_mipmaps(); void upload(const nucleus::utils::ColourTexture& texture); void upload(const nucleus::utils::ColourTexture& texture, unsigned array_index); void upload(const nucleus::utils::MipmappedColourTexture& mipped_texture, unsigned array_index); @@ -72,6 +76,8 @@ class Texture { static float max_anisotropy(); private: + friend class TextureCompressor; + GLuint m_id = GLuint(-1); Target m_target = Target::_2d; Format m_format = Format::Invalid; @@ -80,6 +86,7 @@ class Texture { unsigned m_width = unsigned(-1); unsigned m_height = unsigned(-1); unsigned m_n_layers = unsigned(-1); + unsigned m_mip_levels = unsigned(-1); }; extern template void gl_engine::Texture::upload(const radix::Raster&); diff --git a/gl_engine/TextureCompressor.cpp b/gl_engine/TextureCompressor.cpp new file mode 100644 index 000000000..e0f9cd862 --- /dev/null +++ b/gl_engine/TextureCompressor.cpp @@ -0,0 +1,515 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + +#include "TextureCompressor.h" + +#include "Framebuffer.h" +#include "ShaderProgram.h" +#include "Texture.h" +#include "helpers.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr unsigned max_shader_mip_levels = 16; + +std::optional contract_error(bool condition, const char* message) +{ + if (condition) + return std::nullopt; + Q_ASSERT_X(false, "TextureCompressor", message); + return std::string(message); +} + +struct DrawState { + GLint draw_framebuffer = 0; + GLint viewport[4] = {}; + GLboolean colour_mask[4] = {}; + GLboolean blend_enabled = GL_FALSE; + GLboolean cull_enabled = GL_FALSE; + GLboolean depth_enabled = GL_FALSE; + GLboolean scissor_enabled = GL_FALSE; + + explicit DrawState(QOpenGLExtraFunctions* f) + { + f->glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw_framebuffer); + f->glGetIntegerv(GL_VIEWPORT, viewport); + f->glGetBooleanv(GL_COLOR_WRITEMASK, colour_mask); + blend_enabled = f->glIsEnabled(GL_BLEND); + cull_enabled = f->glIsEnabled(GL_CULL_FACE); + depth_enabled = f->glIsEnabled(GL_DEPTH_TEST); + scissor_enabled = f->glIsEnabled(GL_SCISSOR_TEST); + } + + void prepare(QOpenGLExtraFunctions* f) const + { + f->glDisable(GL_BLEND); + f->glDisable(GL_CULL_FACE); + f->glDisable(GL_DEPTH_TEST); + f->glDisable(GL_SCISSOR_TEST); + f->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + } + + void restore(QOpenGLExtraFunctions* f) const + { + if (blend_enabled) + f->glEnable(GL_BLEND); + else + f->glDisable(GL_BLEND); + if (cull_enabled) + f->glEnable(GL_CULL_FACE); + else + f->glDisable(GL_CULL_FACE); + if (depth_enabled) + f->glEnable(GL_DEPTH_TEST); + else + f->glDisable(GL_DEPTH_TEST); + if (scissor_enabled) + f->glEnable(GL_SCISSOR_TEST); + else + f->glDisable(GL_SCISSOR_TEST); + f->glColorMask(colour_mask[0], colour_mask[1], colour_mask[2], colour_mask[3]); + f->glViewport(viewport[0], viewport[1], viewport[2], viewport[3]); + f->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, GLuint(draw_framebuffer)); + } +}; +} + +gl_engine::TextureCompressor::TextureCompressor(std::weak_ptr scratch, std::weak_ptr destination) + : TextureCompressor(std::move(scratch), std::move(destination), Settings {}) +{ +} + +gl_engine::TextureCompressor::TextureCompressor(std::weak_ptr scratch, + std::weak_ptr destination, + Settings settings) + : m_scratch(std::move(scratch)) + , m_destination(std::move(destination)) + , m_settings(settings) +{ + m_initialisation_error = initialise(); +} + +gl_engine::TextureCompressor::~TextureCompressor() +{ + Q_ASSERT(QOpenGLContext::currentContext()); + m_program.reset(); + m_encoding_framebuffer.reset(); + m_copy_framebuffer.reset(); + m_screen_quad.reset(); + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + f->glDeleteBuffers(1, &m_encoded_buffer); +} + +std::optional gl_engine::TextureCompressor::initialise() +{ + auto scratch = m_scratch.lock(); + auto destination = m_destination.lock(); + if (!scratch || !destination) + return "Texture compressor input or destination expired during construction"; + if (auto error = validate_textures(*scratch, *destination)) + return error; + if (auto error = contract_error(m_settings.search_effort <= 10, "Texture compression search effort must be at most 10")) + return error; + + m_width = scratch->m_width; + m_height = scratch->m_height; + m_scratch_layers = scratch->m_n_layers; + m_destination_layers = destination->m_n_layers; + m_mip_levels = scratch->m_mip_levels; + m_screen_quad = std::make_unique(helpers::create_screen_quad_geometry()); + + if (destination->m_format == Texture::Format::SRGBA8) { + m_operation = Operation::Copy; + m_copy_framebuffer = std::make_unique( + Framebuffer::DepthFormat::None, + std::vector { Framebuffer::ColourFormat::RGBA8 }, + glm::uvec2 { m_width, m_height }); + m_program = std::make_unique("screen_pass.vert", "texture_copy.frag"); + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + f->glGenBuffers(1, &m_encoded_buffer); + f->glBindBuffer(GL_PIXEL_PACK_BUFFER, m_encoded_buffer); + f->glBufferData(GL_PIXEL_PACK_BUFFER, GLsizeiptr(size_t(m_width) * m_height * 4), nullptr, GL_STREAM_DRAW); + f->glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + return std::nullopt; + } + + const auto format = Texture::compression_algorithm(); + m_operation = format == nucleus::utils::ColourTexture::Format::DXT1 ? Operation::Dxt1 : Operation::Etc; + + size_t maximum_size = 0; + for (unsigned level = 0; level < m_mip_levels; ++level) { + maximum_size += compressed_level_size( + std::max(1u, m_width >> level), std::max(1u, m_height >> level)); + } + maximum_size *= m_scratch_layers; + + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + GLint maximum_texture_size = 0; + f->glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum_texture_size); + + const auto create_output = [&](ReadbackMode mode) -> std::optional { + const auto bytes_per_pixel = mode == ReadbackMode::RG32UI ? size_t(8) : size_t(16); + const auto pixels = (maximum_size + bytes_per_pixel - 1) / bytes_per_pixel; + m_atlas_width = GLsizei(std::min({ pixels, size_t(maximum_texture_size), size_t(256) })); + m_atlas_height = GLsizei((pixels + size_t(m_atlas_width) - 1) / size_t(m_atlas_width)); + if (m_atlas_width <= 0 || m_atlas_height <= 0 || m_atlas_height > maximum_texture_size) + return "Texture compression output atlas exceeds the maximum texture size"; + + const auto colour_format = mode == ReadbackMode::RG32UI + ? Framebuffer::ColourFormat::RG32UI + : Framebuffer::ColourFormat::RGBA32UI; + auto candidate = std::make_unique(Framebuffer::DepthFormat::None, + std::vector { colour_format }, + glm::uvec2 { unsigned(m_atlas_width), unsigned(m_atlas_height) }); + candidate->bind_for_reading(); + const auto framebuffer_status = f->glCheckFramebufferStatus(GL_READ_FRAMEBUFFER); + if (framebuffer_status != GL_FRAMEBUFFER_COMPLETE) + return mode == ReadbackMode::RG32UI + ? "RG32UI texture compression framebuffer is incomplete" + : "RGBA32UI texture compression framebuffer is incomplete"; + + if (mode == ReadbackMode::RG32UI) { + GLint implementation_read_format = 0; + GLint implementation_read_type = 0; + f->glReadBuffer(GL_COLOR_ATTACHMENT0); + f->glGetIntegerv(GL_IMPLEMENTATION_COLOR_READ_FORMAT, &implementation_read_format); + f->glGetIntegerv(GL_IMPLEMENTATION_COLOR_READ_TYPE, &implementation_read_type); + if (implementation_read_format != GL_RG_INTEGER || implementation_read_type != GL_UNSIGNED_INT) + return "RG32UI framebuffer readback is unavailable"; + } + m_encoding_framebuffer = std::move(candidate); + return std::nullopt; + }; + + GLint previous_draw_framebuffer = 0; + GLint previous_read_framebuffer = 0; + GLint previous_texture = 0; + f->glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &previous_draw_framebuffer); + f->glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &previous_read_framebuffer); + f->glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture); + + auto requested_mode = m_settings.readback_mode; + auto selected_mode = requested_mode == ReadbackMode::RGBA32UI ? ReadbackMode::RGBA32UI : ReadbackMode::RG32UI; + auto output_error = create_output(selected_mode); + if (output_error && requested_mode == ReadbackMode::Auto) { + selected_mode = ReadbackMode::RGBA32UI; + output_error = create_output(selected_mode); + if (!output_error) + qInfo() << "RG32UI texture compression readback is unavailable; using RGBA32UI"; + } + + f->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, GLuint(previous_draw_framebuffer)); + f->glBindFramebuffer(GL_READ_FRAMEBUFFER, GLuint(previous_read_framebuffer)); + f->glBindTexture(GL_TEXTURE_2D, GLuint(previous_texture)); + if (output_error) + return output_error; + m_effective_readback_mode = selected_mode; + + f->glGenBuffers(1, &m_encoded_buffer); + f->glBindBuffer(GL_PIXEL_PACK_BUFFER, m_encoded_buffer); + const auto bytes_per_pixel = selected_mode == ReadbackMode::RG32UI ? size_t(8) : size_t(16); + f->glBufferData(GL_PIXEL_PACK_BUFFER, + GLsizeiptr(size_t(m_atlas_width) * m_atlas_height * bytes_per_pixel), + nullptr, + GL_STREAM_DRAW); + f->glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + + std::vector defines; + if (selected_mode == ReadbackMode::RGBA32UI) + defines.push_back(QStringLiteral("#define ALP_COMPRESS_TWO_BLOCKS")); + if ((m_operation == Operation::Dxt1 && m_settings.dxt1_algorithm == Dxt1Algorithm::DebugChecksum) + || (m_operation == Operation::Etc && m_settings.etc_algorithm == EtcAlgorithm::DebugChecksum)) { + defines.push_back(QStringLiteral("#define ALP_COMPRESS_CHECKSUM")); + } else if (m_operation == Operation::Etc) { + defines.push_back(QStringLiteral("#define ALP_COMPRESS_ETC1")); + if (m_settings.etc_algorithm == EtcAlgorithm::Fastest || m_settings.etc_algorithm == EtcAlgorithm::Fast) + defines.push_back(QStringLiteral("#define ALP_COMPRESS_ETC1_SPLIT_FUSED")); + if (m_settings.etc_algorithm == EtcAlgorithm::Fast) + defines.push_back(QStringLiteral("#define ALP_COMPRESS_ETC1_REFINE_RESIDUAL")); + } + m_program = std::make_unique( + "screen_pass.vert", "texture_compress.frag", ShaderCodeSource::FILE, defines); + return std::nullopt; +} + +std::optional gl_engine::TextureCompressor::validate_textures( + const Texture& scratch, const Texture& destination) const +{ + if (auto error = contract_error(scratch.m_target == Texture::Target::_2dArray, + "Texture compressor scratch must be a 2D texture array")) + return error; + if (auto error = contract_error(destination.m_target == Texture::Target::_2dArray, + "Texture compressor destination must be a 2D texture array")) + return error; + if (auto error = contract_error(scratch.m_format == Texture::Format::RGBA8 || scratch.m_format == Texture::Format::RGB565, + "Texture compressor scratch must use non-sRGB RGBA8 or RGB565 storage")) + return error; + if (auto error = contract_error(destination.m_format == Texture::Format::CompressedRGBA8 + || destination.m_format == Texture::Format::SRGBA8, + "Texture compressor destination must use compressed sRGB or SRGBA8 storage")) + return error; + if (auto error = contract_error(scratch.m_width == destination.m_width && scratch.m_height == destination.m_height, + "Texture compressor scratch and destination sizes must agree")) + return error; + if (auto error = contract_error(scratch.m_mip_levels == destination.m_mip_levels, + "Texture compressor scratch and destination mip counts must agree")) + return error; + if (auto error = contract_error(scratch.m_width > 0 && scratch.m_height > 0 && scratch.m_n_layers > 0 + && destination.m_n_layers > 0 && scratch.m_mip_levels > 0, + "Texture compressor textures must have allocated storage")) + return error; + if (auto error = contract_error(scratch.m_mip_levels <= max_shader_mip_levels, + "Texture compressor supports at most 16 mip levels")) + return error; + return std::nullopt; +} + +std::expected gl_engine::TextureCompressor::compress( + std::span destination_layers) +{ + if (m_initialisation_error) + return std::unexpected(*m_initialisation_error); + auto scratch = m_scratch.lock(); + auto destination = m_destination.lock(); + if (!scratch || !destination) + return std::unexpected("Texture compressor input or destination has expired"); + if (auto error = validate_textures(*scratch, *destination)) + return std::unexpected(*error); + if (scratch->m_width != m_width || scratch->m_height != m_height || scratch->m_n_layers != m_scratch_layers + || scratch->m_mip_levels != m_mip_levels || destination->m_n_layers != m_destination_layers) { + Q_ASSERT_X(false, "TextureCompressor", "Texture storage changed after compressor construction"); + return std::unexpected("Texture storage changed after compressor construction"); + } + if (auto error = contract_error(!destination_layers.empty(), "Texture compressor requires at least one layer")) + return std::unexpected(*error); + if (auto error = contract_error(destination_layers.size() <= m_scratch_layers, + "Texture compressor batch exceeds the scratch layer count")) + return std::unexpected(*error); + for (const auto layer : destination_layers) { + if (auto error = contract_error(layer < m_destination_layers, + "Texture compressor destination layer is out of range")) + return std::unexpected(*error); + } + + if (m_operation == Operation::Copy) + return copy_srgb(*scratch, *destination, destination_layers); + return compress_blocks(*scratch, *destination, destination_layers); +} + +std::expected gl_engine::TextureCompressor::compress_blocks( + const Texture& scratch, Texture& destination, std::span destination_layers) +{ + Result result { + .bytes_written = 0, + .layers_written = unsigned(destination_layers.size()), + .mip_levels_written = m_mip_levels, + }; + std::vector level_offsets; + std::vector level_offsets_blocks; + std::vector level_blocks_x; + std::vector level_blocks_y; + level_offsets.reserve(m_mip_levels); + level_offsets_blocks.reserve(m_mip_levels); + level_blocks_x.reserve(m_mip_levels); + level_blocks_y.reserve(m_mip_levels); + for (unsigned level = 0; level < m_mip_levels; ++level) { + level_offsets.push_back(result.bytes_written); + const auto level_width = std::max(1u, m_width >> level); + const auto level_height = std::max(1u, m_height >> level); + level_offsets_blocks.push_back(int(result.bytes_written / 8)); + level_blocks_x.push_back(int(std::max(1u, (level_width + 3) / 4))); + level_blocks_y.push_back(int(std::max(1u, (level_height + 3) / 4))); + result.bytes_written += compressed_level_size(level_width, level_height) * destination_layers.size(); + } + + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + const DrawState draw_state(f); + draw_state.prepare(f); + const auto paired_blocks = *m_effective_readback_mode == ReadbackMode::RGBA32UI; + const auto total_blocks = result.bytes_written / 8; + const auto encoding_pixels = paired_blocks ? (total_blocks + 1) / 2 : total_blocks; + const auto encoding_width = GLsizei(std::min(encoding_pixels, size_t(m_atlas_width))); + const auto encoding_height = GLsizei((encoding_pixels + size_t(encoding_width) - 1) / size_t(encoding_width)); + + m_encoding_framebuffer->bind_for_drawing(); + f->glViewport(0, 0, encoding_width, encoding_height); + m_program->bind(); + m_program->set_uniform("source_texture", 7); + m_program->set_uniform("texture_width", int(m_width)); + m_program->set_uniform("texture_height", int(m_height)); + m_program->set_uniform("effort", int(m_settings.search_effort)); + m_program->set_uniform("atlas_width", int(encoding_width)); + m_program->set_uniform("total_blocks", int(total_blocks)); + m_program->set_uniform("mip_levels", int(m_mip_levels)); + m_program->set_uniform_array("level_offsets", level_offsets_blocks); + m_program->set_uniform_array("level_blocks_x", level_blocks_x); + m_program->set_uniform_array("level_blocks_y", level_blocks_y); + f->glActiveTexture(GL_TEXTURE7); + f->glBindTexture(GL_TEXTURE_2D_ARRAY, scratch.m_id); + m_screen_quad->draw(); + m_program->release(); + draw_state.restore(f); + + GLint previous_read_framebuffer = 0; + GLint previous_read_buffer = 0; + GLint previous_pack_alignment = 0; + f->glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &previous_read_framebuffer); + f->glGetIntegerv(GL_READ_BUFFER, &previous_read_buffer); + f->glGetIntegerv(GL_PACK_ALIGNMENT, &previous_pack_alignment); + m_encoding_framebuffer->bind_for_reading(); + f->glReadBuffer(GL_COLOR_ATTACHMENT0); + f->glPixelStorei(GL_PACK_ALIGNMENT, 1); + f->glBindBuffer(GL_PIXEL_PACK_BUFFER, m_encoded_buffer); + f->glReadPixels(0, + 0, + encoding_width, + encoding_height, + paired_blocks ? GL_RGBA_INTEGER : GL_RG_INTEGER, + GL_UNSIGNED_INT, + nullptr); + f->glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + f->glPixelStorei(GL_PACK_ALIGNMENT, previous_pack_alignment); + f->glBindFramebuffer(GL_READ_FRAMEBUFFER, GLuint(previous_read_framebuffer)); + f->glReadBuffer(GLenum(previous_read_buffer)); + + f->glBindTexture(GL_TEXTURE_2D_ARRAY, destination.m_id); + f->glBindBuffer(GL_PIXEL_UNPACK_BUFFER, m_encoded_buffer); + const auto format = Texture::compressed_texture_format(); + for (unsigned level = 0; level < m_mip_levels; ++level) { + const auto level_width = std::max(1u, m_width >> level); + const auto level_height = std::max(1u, m_height >> level); + const auto layer_size = compressed_level_size(level_width, level_height); + for (size_t layer = 0; layer < destination_layers.size(); ++layer) { + const auto offset = level_offsets[level] + layer_size * layer; + f->glCompressedTexSubImage3D(GL_TEXTURE_2D_ARRAY, + GLint(level), + 0, + 0, + GLint(destination_layers[layer]), + GLsizei(level_width), + GLsizei(level_height), + 1, + format, + GLsizei(layer_size), + reinterpret_cast(quintptr(offset))); + } + } + f->glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + f->glActiveTexture(GL_TEXTURE0); + return result; +} + +std::expected gl_engine::TextureCompressor::copy_srgb( + const Texture& scratch, Texture& destination, std::span destination_layers) +{ + Result result { + .bytes_written = 0, + .layers_written = unsigned(destination_layers.size()), + .mip_levels_written = m_mip_levels, + }; + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + const DrawState draw_state(f); + GLint previous_read_framebuffer = 0; + GLint previous_read_buffer = 0; + f->glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &previous_read_framebuffer); + f->glGetIntegerv(GL_READ_BUFFER, &previous_read_buffer); + draw_state.prepare(f); + m_copy_framebuffer->bind(); + m_program->bind(); + m_program->set_uniform("source_texture", 7); + f->glActiveTexture(GL_TEXTURE7); + f->glBindTexture(GL_TEXTURE_2D_ARRAY, scratch.m_id); + + GLint previous_pack_alignment = 0; + GLint previous_unpack_alignment = 0; + f->glGetIntegerv(GL_PACK_ALIGNMENT, &previous_pack_alignment); + f->glGetIntegerv(GL_UNPACK_ALIGNMENT, &previous_unpack_alignment); + f->glPixelStorei(GL_PACK_ALIGNMENT, 1); + f->glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + + for (unsigned level = 0; level < m_mip_levels; ++level) { + const auto level_width = std::max(1u, m_width >> level); + const auto level_height = std::max(1u, m_height >> level); + const auto layer_size = size_t(level_width) * level_height * 4; + f->glViewport(0, 0, GLsizei(level_width), GLsizei(level_height)); + m_program->set_uniform("source_level", int(level)); + for (size_t layer = 0; layer < destination_layers.size(); ++layer) { + m_program->set_uniform("source_layer", int(layer)); + m_screen_quad->draw(); + + f->glReadBuffer(GL_COLOR_ATTACHMENT0); + f->glBindBuffer(GL_PIXEL_PACK_BUFFER, m_encoded_buffer); + f->glReadPixels(0, + 0, + GLsizei(level_width), + GLsizei(level_height), + GL_RGBA, + GL_UNSIGNED_BYTE, + nullptr); + f->glActiveTexture(GL_TEXTURE0); + f->glBindTexture(GL_TEXTURE_2D_ARRAY, destination.m_id); + f->glBindBuffer(GL_PIXEL_UNPACK_BUFFER, m_encoded_buffer); + f->glTexSubImage3D(GL_TEXTURE_2D_ARRAY, + GLint(level), + 0, + 0, + GLint(destination_layers[layer]), + GLsizei(level_width), + GLsizei(level_height), + 1, + GL_RGBA, + GL_UNSIGNED_BYTE, + nullptr); + f->glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + f->glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + f->glActiveTexture(GL_TEXTURE7); + result.bytes_written += layer_size; + } + } + f->glPixelStorei(GL_PACK_ALIGNMENT, previous_pack_alignment); + f->glPixelStorei(GL_UNPACK_ALIGNMENT, previous_unpack_alignment); + m_program->release(); + draw_state.restore(f); + f->glBindFramebuffer(GL_READ_FRAMEBUFFER, GLuint(previous_read_framebuffer)); + f->glReadBuffer(GLenum(previous_read_buffer)); + f->glActiveTexture(GL_TEXTURE0); + return result; +} + +size_t gl_engine::TextureCompressor::compressed_level_size(unsigned width, unsigned height) +{ + return size_t(std::max(1u, (width + 3) / 4)) * std::max(1u, (height + 3) / 4) * 8; +} + +unsigned gl_engine::TextureCompressor::mip_level_count(unsigned width, unsigned height) +{ + Q_ASSERT(width > 0 && height > 0); + return 1u + unsigned(std::floor(std::log2(std::max(width, height)))); +} + +std::optional gl_engine::TextureCompressor::effective_readback_mode() const +{ + return m_effective_readback_mode; +} diff --git a/gl_engine/TextureCompressor.h b/gl_engine/TextureCompressor.h new file mode 100644 index 000000000..c1c808fa3 --- /dev/null +++ b/gl_engine/TextureCompressor.h @@ -0,0 +1,122 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace gl_engine { +class Framebuffer; +class ShaderProgram; +class Texture; +namespace helpers { +struct ScreenQuadGeometry; +} + +class TextureCompressor { +public: + enum class ReadbackMode { + Auto, + RG32UI, + RGBA32UI, + }; + + enum class Dxt1Algorithm { + SlowSearch, + DebugChecksum, + }; + + enum class EtcAlgorithm { + Fastest, + Fast, + SlowSearch, + DebugChecksum, + }; + + struct Settings { + ReadbackMode readback_mode = ReadbackMode::Auto; + Dxt1Algorithm dxt1_algorithm = Dxt1Algorithm::SlowSearch; + EtcAlgorithm etc_algorithm = EtcAlgorithm::Fast; + unsigned search_effort = 0; + }; + + struct Result { + size_t bytes_written = 0; + unsigned layers_written = 0; + unsigned mip_levels_written = 0; + }; + + TextureCompressor(std::weak_ptr scratch, + std::weak_ptr destination); + TextureCompressor(std::weak_ptr scratch, + std::weak_ptr destination, + Settings settings); + TextureCompressor(const TextureCompressor&) = delete; + TextureCompressor& operator=(const TextureCompressor&) = delete; + TextureCompressor(TextureCompressor&&) = delete; + TextureCompressor& operator=(TextureCompressor&&) = delete; + ~TextureCompressor(); + + [[nodiscard]] std::expected compress(std::span destination_layers); + + [[nodiscard]] static size_t compressed_level_size(unsigned width, unsigned height); + [[nodiscard]] static unsigned mip_level_count(unsigned width, unsigned height); + [[nodiscard]] std::optional effective_readback_mode() const; + +private: + enum class Operation { + Dxt1, + Etc, + Copy, + }; + + [[nodiscard]] std::optional initialise(); + [[nodiscard]] std::optional validate_textures(const Texture& scratch, const Texture& destination) const; + [[nodiscard]] std::expected compress_blocks( + const Texture& scratch, Texture& destination, std::span destination_layers); + [[nodiscard]] std::expected copy_srgb( + const Texture& scratch, Texture& destination, std::span destination_layers); + + std::weak_ptr m_scratch; + std::weak_ptr m_destination; + Settings m_settings; + Operation m_operation = Operation::Copy; + std::optional m_effective_readback_mode; + std::optional m_initialisation_error; + + unsigned m_width = 0; + unsigned m_height = 0; + unsigned m_scratch_layers = 0; + unsigned m_destination_layers = 0; + unsigned m_mip_levels = 0; + GLsizei m_atlas_width = 0; + GLsizei m_atlas_height = 0; + GLuint m_encoded_buffer = 0; + + std::unique_ptr m_program; + std::unique_ptr m_encoding_framebuffer; + std::unique_ptr m_copy_framebuffer; + std::unique_ptr m_screen_quad; +}; + +} // namespace gl_engine diff --git a/gl_engine/shaders/texture_compress.frag b/gl_engine/shaders/texture_compress.frag new file mode 100644 index 000000000..c811d8be5 --- /dev/null +++ b/gl_engine/shaders/texture_compress.frag @@ -0,0 +1,483 @@ +// GPU texture block encoder fragment shader. +uniform highp sampler2DArray source_texture; +uniform highp int texture_width; +uniform highp int texture_height; +const highp int max_mip_levels = 16; +uniform highp int atlas_width; +uniform highp int total_blocks; +uniform highp int mip_levels; +uniform highp int level_offsets[max_mip_levels]; +uniform highp int level_blocks_x[max_mip_levels]; +uniform highp int level_blocks_y[max_mip_levels]; +#ifdef ALP_COMPRESS_TWO_BLOCKS +layout(location = 0) out highp uvec4 encoded_blocks; +#else +layout(location = 0) out highp uvec2 encoded_block; +#endif +uniform highp int effort; + +highp uvec3 unpack_565(highp uint value) +{ + return uvec3(((value >> 11u) & 31u) * 255u / 31u, + ((value >> 5u) & 63u) * 255u / 63u, + (value & 31u) * 255u / 31u); +} + +highp uint pack_565(highp uvec3 value) +{ + return ((value.r * 31u + 127u) / 255u) << 11u | ((value.g * 63u + 127u) / 255u) << 5u | (value.b * 31u + 127u) / 255u; +} + +highp uint colour_error(highp uvec3 lhs, highp uvec3 rhs) +{ + highp ivec3 delta = ivec3(lhs) - ivec3(rhs); + return uint(delta.x * delta.x + delta.y * delta.y + delta.z * delta.z); +} + +highp uvec2 encode_dxt1(highp uvec3 pixels[16]) +{ + highp uvec3 minimum_colour = uvec3(255u); + highp uvec3 maximum_colour = uvec3(0u); + for (int i = 0; i < 16; ++i) { + minimum_colour = min(minimum_colour, pixels[i]); + maximum_colour = max(maximum_colour, pixels[i]); + } + + highp uint best_error = 0xffffffffu; + highp uint best_endpoints = 0u; + highp uint best_indices = 0u; + for (int candidate = 0; candidate <= 10; ++candidate) { + if (candidate > effort) + break; + highp uvec3 range = maximum_colour - minimum_colour; + highp uvec3 inset = range * uint(candidate) / 64u; + highp uint colour0 = pack_565(maximum_colour - inset); + highp uint colour1 = pack_565(minimum_colour + inset); + if (colour0 <= colour1) { + highp uint swap_value = colour0; + colour0 = colour1; + colour1 = swap_value; + } + if (colour0 == colour1) { + if (colour0 < 65535u) + ++colour0; + else + --colour1; + } + + highp uvec3 palette[4]; + palette[0] = unpack_565(colour0); + palette[1] = unpack_565(colour1); + palette[2] = (2u * palette[0] + palette[1]) / 3u; + palette[3] = (palette[0] + 2u * palette[1]) / 3u; + + highp uint total_error = 0u; + highp uint indices = 0u; + for (int i = 0; i < 16; ++i) { + highp uint selected = 0u; + highp uint selected_error = colour_error(pixels[i], palette[0]); + for (uint palette_index = 1u; palette_index < 4u; ++palette_index) { + highp uint error = colour_error(pixels[i], palette[palette_index]); + if (error < selected_error) { + selected = palette_index; + selected_error = error; + } + } + total_error += selected_error; + indices |= selected << uint(2 * i); + } + if (total_error < best_error) { + best_error = total_error; + best_endpoints = colour0 | colour1 << 16u; + best_indices = indices; + } + } + return uvec2(best_endpoints, best_indices); +} + +highp uint byte_swap(highp uint value) +{ + return value >> 24u | (value >> 8u & 0x0000ff00u) | (value << 8u & 0x00ff0000u) | value << 24u; +} + +highp int modifier(highp int table, highp int index) +{ + const highp ivec4 modifiers[8] = ivec4[8](ivec4(2, 8, -2, -8), + ivec4(5, 17, -5, -17), + ivec4(9, 29, -9, -29), + ivec4(13, 42, -13, -42), + ivec4(18, 60, -18, -60), + ivec4(24, 80, -24, -80), + ivec4(33, 106, -33, -106), + ivec4(47, 183, -47, -183)); + return modifiers[table][index]; +} + +highp int brightness(highp uvec3 colour) +{ + return int((colour.r + 2u * colour.g + colour.b + 2u) / 4u); +} + +highp int table_for_range(highp int range) +{ + if (range < 22) + return 0; + if (range < 44) + return 1; + if (range < 74) + return 2; + if (range < 106) + return 3; + if (range < 152) + return 4; + if (range < 182) + return 5; + if (range < 254) + return 6; + return 7; +} + +highp uint select_etc1_modifier_exact(highp uvec3 pixel, highp ivec3 decoded_base, highp int table) +{ + highp uint selected = 0u; + highp uint selected_error = 0xffffffffu; + for (int index = 0; index < 4; ++index) { + highp ivec3 reconstructed = clamp(decoded_base + ivec3(modifier(table, index)), ivec3(0), ivec3(255)); + highp ivec3 delta = ivec3(pixel) - reconstructed; + highp uint error = uint(delta.x * delta.x + delta.y * delta.y + delta.z * delta.z); + if (error < selected_error) { + selected = uint(index); + selected_error = error; + } + } + return selected; +} + +struct FastEtc1Subblock { + highp ivec3 colour; + highp int table; +}; + +FastEtc1Subblock fast_subblock_from_statistics(highp uvec3 minimum_colour, + highp uvec3 maximum_colour, + highp uvec3 sum) +{ + highp int minimum_brightness = brightness(minimum_colour); + highp int maximum_brightness = brightness(maximum_colour); + highp int range = max(8, maximum_brightness - minimum_brightness); + highp int middle = (minimum_brightness + maximum_brightness + 1) / 2; + highp ivec3 average = ivec3((sum + 4u) / 8u); + highp int correction = middle - brightness(uvec3(average)); + highp ivec3 colour = clamp(average + ivec3(correction), ivec3(0), ivec3(255)); + return FastEtc1Subblock(colour, table_for_range(range)); +} + +struct FastEtc1Bases { + highp uint header; + highp ivec3 first_decoded; + highp ivec3 second_decoded; + highp uint differential_bit; +}; + +FastEtc1Bases fast_split_bases(FastEtc1Subblock first, FastEtc1Subblock second) +{ + highp uvec3 first_base5 = (uvec3(first.colour) * 31u + 127u) / 255u; + highp uvec3 second_base5 = (uvec3(second.colour) * 31u + 127u) / 255u; + highp ivec3 base_delta = ivec3(second_base5) - ivec3(first_base5); + bool differential = all(greaterThanEqual(base_delta, ivec3(-4))) && all(lessThanEqual(base_delta, ivec3(3))); + if (differential) { + highp uvec3 delta3 = uvec3(base_delta) & 7u; + highp uint header = first_base5.r << 3u | delta3.r + | first_base5.g << 11u | delta3.g << 8u + | first_base5.b << 19u | delta3.b << 16u; + return FastEtc1Bases(header, + ivec3((first_base5 << 3u) | (first_base5 >> 2u)), + ivec3((second_base5 << 3u) | (second_base5 >> 2u)), + 2u); + } + + highp uvec3 first_base4 = (uvec3(first.colour) * 15u + 127u) / 255u; + highp uvec3 second_base4 = (uvec3(second.colour) * 15u + 127u) / 255u; + highp uint header = first_base4.r << 4u | second_base4.r + | first_base4.g << 12u | second_base4.g << 8u + | first_base4.b << 20u | second_base4.b << 16u; + return FastEtc1Bases(header, + ivec3((first_base4 << 4u) | first_base4), + ivec3((second_base4 << 4u) | second_base4), + 0u); +} + +struct FastEtc1Evaluation { + highp uint indices; + highp uint error; + highp ivec3 first_residual; + highp ivec3 second_residual; +}; + +FastEtc1Evaluation evaluate_fast_split(highp uvec3 pixels[16], + FastEtc1Subblock first, + FastEtc1Subblock second, + FastEtc1Bases bases, + bool horizontal) +{ + highp uint indices = 0u; + highp uint total_error = 0u; + highp ivec3 first_residual = ivec3(0); + highp ivec3 second_residual = ivec3(0); + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + highp int pixel_index = y * 4 + x; + highp uvec3 pixel = pixels[pixel_index]; + bool second_subblock = horizontal ? y >= 2 : x >= 2; + highp int table = second_subblock ? second.table : first.table; + highp ivec3 base = second_subblock ? bases.second_decoded : bases.first_decoded; + highp uint selected = select_etc1_modifier_exact(pixel, base, table); + highp ivec3 reconstructed = clamp(base + ivec3(modifier(table, int(selected))), ivec3(0), ivec3(255)); + highp ivec3 delta = ivec3(pixel) - reconstructed; + total_error += uint(delta.x * delta.x + delta.y * delta.y + delta.z * delta.z); +#ifdef ALP_COMPRESS_ETC1_REFINE_RESIDUAL + if (second_subblock) + second_residual += delta; + else + first_residual += delta; +#endif + + highp uint bit_position = uint(x * 4 + y); + indices |= (selected & 1u) << bit_position; + indices |= (selected >> 1u) << (bit_position + 16u); + } + } + return FastEtc1Evaluation(indices, total_error, first_residual, second_residual); +} + +highp uvec2 pack_fast_split(FastEtc1Evaluation evaluation, + FastEtc1Subblock first, + FastEtc1Subblock second, + FastEtc1Bases bases, + bool horizontal) +{ + highp uint control = uint(first.table) << 5u | uint(second.table) << 2u | bases.differential_bit; + if (horizontal) + control |= 1u; + return uvec2(bases.header | control << 24u, byte_swap(evaluation.indices)); +} + +FastEtc1Subblock refit_fast_subblock( + FastEtc1Subblock subblock, highp ivec3 decoded_base, highp ivec3 residual, highp int pixel_count) +{ + highp ivec3 rounded_residual = ivec3(0); + for (int channel = 0; channel < 3; ++channel) { + highp int value = residual[channel]; + highp int rounding = pixel_count / 2; + rounded_residual[channel] + = value >= 0 ? (value + rounding) / pixel_count : -((-value + rounding) / pixel_count); + } + return FastEtc1Subblock(clamp(decoded_base + rounded_residual, ivec3(0), ivec3(255)), subblock.table); +} + +highp uvec2 encode_etc1_fast_split_fused(highp uvec3 pixels[16]) +{ + highp uvec3 left_minimum = uvec3(255u); + highp uvec3 left_maximum = uvec3(0u); + highp uvec3 left_sum = uvec3(0u); + highp uvec3 right_minimum = uvec3(255u); + highp uvec3 right_maximum = uvec3(0u); + highp uvec3 right_sum = uvec3(0u); + highp uvec3 top_minimum = uvec3(255u); + highp uvec3 top_maximum = uvec3(0u); + highp uvec3 top_sum = uvec3(0u); + highp uvec3 bottom_minimum = uvec3(255u); + highp uvec3 bottom_maximum = uvec3(0u); + highp uvec3 bottom_sum = uvec3(0u); + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + highp uvec3 pixel = pixels[y * 4 + x]; + if (x < 2) { + left_minimum = min(left_minimum, pixel); + left_maximum = max(left_maximum, pixel); + left_sum += pixel; + } else { + right_minimum = min(right_minimum, pixel); + right_maximum = max(right_maximum, pixel); + right_sum += pixel; + } + if (y < 2) { + top_minimum = min(top_minimum, pixel); + top_maximum = max(top_maximum, pixel); + top_sum += pixel; + } else { + bottom_minimum = min(bottom_minimum, pixel); + bottom_maximum = max(bottom_maximum, pixel); + bottom_sum += pixel; + } + } + } + + FastEtc1Subblock left = fast_subblock_from_statistics(left_minimum, left_maximum, left_sum); + FastEtc1Subblock right = fast_subblock_from_statistics(right_minimum, right_maximum, right_sum); + FastEtc1Subblock top = fast_subblock_from_statistics(top_minimum, top_maximum, top_sum); + FastEtc1Subblock bottom = fast_subblock_from_statistics(bottom_minimum, bottom_maximum, bottom_sum); + FastEtc1Bases vertical_bases = fast_split_bases(left, right); + FastEtc1Bases horizontal_bases = fast_split_bases(top, bottom); + FastEtc1Evaluation vertical_evaluation = evaluate_fast_split(pixels, left, right, vertical_bases, false); + FastEtc1Evaluation horizontal_evaluation = evaluate_fast_split(pixels, top, bottom, horizontal_bases, true); + highp uvec2 vertical = pack_fast_split(vertical_evaluation, left, right, vertical_bases, false); + highp uvec2 horizontal = pack_fast_split(horizontal_evaluation, top, bottom, horizontal_bases, true); + +#ifndef ALP_COMPRESS_ETC1_REFINE_RESIDUAL + return horizontal_evaluation.error < vertical_evaluation.error ? horizontal : vertical; +#else + bool horizontal_wins = horizontal_evaluation.error < vertical_evaluation.error; + FastEtc1Subblock first = left; + FastEtc1Subblock second = right; + FastEtc1Bases best_bases = vertical_bases; + FastEtc1Evaluation best_evaluation = vertical_evaluation; + highp uvec2 best_block = vertical; + if (horizontal_wins) { + first = top; + second = bottom; + best_bases = horizontal_bases; + best_evaluation = horizontal_evaluation; + best_block = horizontal; + } + FastEtc1Subblock candidate_first + = refit_fast_subblock(first, best_bases.first_decoded, best_evaluation.first_residual, 8); + FastEtc1Subblock candidate_second + = refit_fast_subblock(second, best_bases.second_decoded, best_evaluation.second_residual, 8); + FastEtc1Bases candidate_bases = fast_split_bases(candidate_first, candidate_second); + FastEtc1Evaluation candidate_evaluation + = evaluate_fast_split(pixels, candidate_first, candidate_second, candidate_bases, horizontal_wins); + if (candidate_evaluation.error < best_evaluation.error) + best_block = pack_fast_split(candidate_evaluation, candidate_first, candidate_second, candidate_bases, horizontal_wins); + + return best_block; +#endif +} + +highp uvec2 encode_etc1(highp uvec3 pixels[16], highp int search_effort) +{ + highp uvec3 sum = uvec3(0u); + for (int i = 0; i < 16; ++i) + sum += pixels[i]; + highp ivec3 average = ivec3((sum + 8u) / 16u); + + highp uint best_error = 0xffffffffu; + highp uvec3 best_base = uvec3(0u); + highp uint best_table = 0u; + highp uint best_indices = 0u; + for (int candidate = 0; candidate <= 10; ++candidate) { + if (candidate > search_effort) + break; + highp int magnitude = ((candidate + 1) / 2) * 4; + highp int signed_offset = candidate == 0 ? 0 : ((candidate & 1) == 1 ? magnitude : -magnitude); + highp ivec3 adjusted = clamp(average + ivec3(signed_offset), ivec3(0), ivec3(255)); + highp uvec3 base5 = (uvec3(adjusted) * 31u + 127u) / 255u; + highp ivec3 decoded_base = ivec3((base5 << 3u) | (base5 >> 2u)); + + for (int table = 0; table < 8; ++table) { + highp uint total_error = 0u; + highp uint indices = 0u; + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + highp int pixel_index = y * 4 + x; + highp uint selected = 0u; + highp uint selected_error = 0xffffffffu; + for (int index = 0; index < 4; ++index) { + highp ivec3 reconstructed = clamp(decoded_base + ivec3(modifier(table, index)), ivec3(0), ivec3(255)); + highp ivec3 delta = ivec3(pixels[pixel_index]) - reconstructed; + highp uint error = uint(delta.x * delta.x + delta.y * delta.y + delta.z * delta.z); + if (error < selected_error) { + selected = uint(index); + selected_error = error; + } + } + total_error += selected_error; + highp uint bit_position = uint(x * 4 + y); + indices |= (selected & 1u) << bit_position; + indices |= (selected >> 1u) << (bit_position + 16u); + } + } + if (total_error < best_error) { + best_error = total_error; + best_base = base5; + best_table = uint(table); + best_indices = indices; + } + } + } + + highp uint control = best_table << 5u | best_table << 2u | 2u; + highp uint header = best_base.r << 3u | best_base.g << 11u | best_base.b << 19u | control << 24u; + return uvec2(header, byte_swap(best_indices)); +} + +highp uvec2 compress_block(highp ivec2 block, + highp int layer, + highp int level, + highp int level_width, + highp int level_height) +{ + highp ivec2 origin = block * 4; + highp uvec3 pixels[16]; + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + highp ivec2 position = min(origin + ivec2(x, y), ivec2(level_width - 1, level_height - 1)); + pixels[y * 4 + x] = uvec3(round(texelFetch(source_texture, ivec3(position, layer), level).rgb * 255.0)); + } + } + +#ifdef ALP_COMPRESS_CHECKSUM + highp uvec2 checksum = uvec2(0u); + for (int i = 0; i < 16; ++i) { + highp uint packed_value = pixels[i].r | pixels[i].g << 8u | pixels[i].b << 16u; + checksum.x = checksum.x * 33u ^ packed_value; + checksum.y = checksum.y + packed_value * uint(i + 1); + } + return checksum; +#elif defined(ALP_COMPRESS_ETC1) +#ifdef ALP_COMPRESS_ETC1_SPLIT_FUSED + return encode_etc1_fast_split_fused(pixels); +#else + return encode_etc1(pixels, effort); +#endif +#else + return encode_dxt1(pixels); +#endif +} + +highp uvec2 compress_block_at_index(highp int output_index) +{ + highp int level = 0; + for (int candidate = 1; candidate < max_mip_levels; ++candidate) { + if (candidate >= mip_levels || output_index < level_offsets[candidate]) + break; + level = candidate; + } + + highp int blocks_x_at_level = level_blocks_x[level]; + highp int blocks_y_at_level = level_blocks_y[level]; + highp int blocks_per_layer = blocks_x_at_level * blocks_y_at_level; + highp int level_index = output_index - level_offsets[level]; + highp int layer = level_index / blocks_per_layer; + highp int block_index = level_index - layer * blocks_per_layer; + highp ivec2 block = ivec2(block_index % blocks_x_at_level, block_index / blocks_x_at_level); + return compress_block(block, layer, level, max(1, texture_width >> level), max(1, texture_height >> level)); +} + +void main() +{ + highp int output_index = int(gl_FragCoord.y) * atlas_width + int(gl_FragCoord.x); +#ifdef ALP_COMPRESS_TWO_BLOCKS + output_index *= 2; +#endif + if (output_index >= total_blocks) + discard; + +#ifdef ALP_COMPRESS_TWO_BLOCKS + highp uvec2 first = compress_block_at_index(output_index); + highp uvec2 second = output_index + 1 < total_blocks ? compress_block_at_index(output_index + 1) : uvec2(0u); + encoded_blocks = uvec4(first, second); +#else + encoded_block = compress_block_at_index(output_index); +#endif +} diff --git a/gl_engine/shaders/texture_copy.frag b/gl_engine/shaders/texture_copy.frag new file mode 100644 index 000000000..38fa9d950 --- /dev/null +++ b/gl_engine/shaders/texture_copy.frag @@ -0,0 +1,9 @@ +uniform highp sampler2DArray source_texture; +uniform highp int source_layer; +uniform highp int source_level; +layout(location = 0) out highp vec4 out_color; + +void main() +{ + out_color = texelFetch(source_texture, ivec3(ivec2(gl_FragCoord.xy), source_layer), source_level); +} diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 83f9766a6..bc5a86d86 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -25,6 +25,7 @@ add_subdirectory(nucleus) if (TARGET gl_engine) add_subdirectory(gl_engine) + add_subdirectory(texture_compression_benchmark) endif() if (TARGET webgpu_engine) diff --git a/unittests/gl_engine/CMakeLists.txt b/unittests/gl_engine/CMakeLists.txt index 0899a1ca3..406add691 100644 --- a/unittests/gl_engine/CMakeLists.txt +++ b/unittests/gl_engine/CMakeLists.txt @@ -24,6 +24,7 @@ alp_add_unittest(unittests_gl_engine framebuffer.cpp uniformbuffer.cpp texture.cpp + texture_compressor.cpp ) target_sources(unittests_gl_engine @@ -32,4 +33,3 @@ target_sources(unittests_gl_engine ) target_link_libraries(unittests_gl_engine PUBLIC gl_engine) - diff --git a/unittests/gl_engine/UnittestGLContext.cpp b/unittests/gl_engine/UnittestGLContext.cpp index e9e7d1794..ac951af0d 100644 --- a/unittests/gl_engine/UnittestGLContext.cpp +++ b/unittests/gl_engine/UnittestGLContext.cpp @@ -37,6 +37,7 @@ UnittestGLContext::UnittestGLContext() // Request OpenGL 3.3 core or OpenGL ES 3.0. if (QOpenGLContext::openGLModuleType() == QOpenGLContext::LibGL) { qDebug("Requesting 3.3 core context"); + surface_format.setRenderableType(QSurfaceFormat::OpenGL); surface_format.setVersion(3, 3); surface_format.setProfile(QSurfaceFormat::CoreProfile); } else { @@ -47,6 +48,7 @@ UnittestGLContext::UnittestGLContext() QSurfaceFormat::setDefaultFormat(surface_format); m_context.setFormat(surface_format); + surface.setFormat(surface_format); surface.create(); const auto r = m_context.create(); Q_ASSERT(r); diff --git a/unittests/gl_engine/main.cpp b/unittests/gl_engine/main.cpp index 02be96777..2eadc5a95 100644 --- a/unittests/gl_engine/main.cpp +++ b/unittests/gl_engine/main.cpp @@ -58,24 +58,24 @@ CATCH_REGISTER_LISTENER(ProgressPrinter) int main( int argc, char* argv[] ) { std::fflush(stdout); - int argc_qt = 0; - QGuiApplication app = {argc_qt, argv}; - QSurfaceFormat fmt; fmt.setDepthBufferSize(24); fmt.setOption(QSurfaceFormat::DebugContext); - // Request OpenGL 3.3 core or OpenGL ES 3.0. - if (QOpenGLContext::openGLModuleType() == QOpenGLContext::LibGL) { - qDebug("Requesting 3.3 core context"); - fmt.setVersion(3, 3); - fmt.setProfile(QSurfaceFormat::CoreProfile); - } else { - qDebug("Requesting 3.0 context"); - fmt.setVersion(3, 0); - } +#if QT_CONFIG(opengles2) + qDebug("Requesting 3.0 context"); + fmt.setVersion(3, 0); +#else + qDebug("Requesting 3.3 core context"); + fmt.setRenderableType(QSurfaceFormat::OpenGL); + fmt.setVersion(3, 3); + fmt.setProfile(QSurfaceFormat::CoreProfile); +#endif QSurfaceFormat::setDefaultFormat(fmt); + int argc_qt = 0; + QGuiApplication app = {argc_qt, argv}; + // Catch::Session().run(m_argc, m_argv); is in UnittestGlWindow::initializeGL() // to my understanding this is necessary for webassembly, because stuff is started // asynchronously, and the gl context is not yet available when main is running. diff --git a/unittests/gl_engine/texture.cpp b/unittests/gl_engine/texture.cpp index 89f14b6ee..ec076d871 100644 --- a/unittests/gl_engine/texture.cpp +++ b/unittests/gl_engine/texture.cpp @@ -18,6 +18,9 @@ #include #include +#include +#include +#include #include #include "UnittestGLContext.h" diff --git a/unittests/gl_engine/texture_compressor.cpp b/unittests/gl_engine/texture_compressor.cpp new file mode 100644 index 000000000..c85e9a2a2 --- /dev/null +++ b/unittests/gl_engine/texture_compressor.cpp @@ -0,0 +1,326 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek + * SPDX-License-Identifier: GPL-3.0-or-later + *****************************************************************************/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { +using Raster = radix::Raster; +using Compressor = gl_engine::TextureCompressor; + +Raster test_raster(unsigned resolution) +{ + Raster result { glm::uvec2(resolution) }; + for (unsigned y = 0; y < resolution; ++y) { + for (unsigned x = 0; x < resolution; ++x) { + result.pixel({ x, y }) = glm::u8vec4( + uint8_t((x * 17 + y * 3) & 255), + uint8_t((x * 5 + y * 11) & 255), + uint8_t((x * 7 + y * 13) & 255), + 255); + } + } + return result; +} + +std::shared_ptr rgba_scratch(std::span sources, unsigned mip_levels) +{ + const auto width = unsigned(sources.front().width()); + const auto height = unsigned(sources.front().height()); + auto scratch = std::make_shared( + gl_engine::Texture::Target::_2dArray, gl_engine::Texture::Format::RGBA8); + scratch->setParams(gl_engine::Texture::Filter::Nearest, gl_engine::Texture::Filter::Nearest); + scratch->allocate_array(width, height, unsigned(sources.size()), mip_levels); + for (size_t layer = 0; layer < sources.size(); ++layer) + scratch->upload(sources[layer], unsigned(layer)); + if (mip_levels > 1) + scratch->generate_mipmaps(); + return scratch; +} + +std::shared_ptr destination( + gl_engine::Texture::Format format, unsigned resolution, unsigned layers, unsigned mip_levels) +{ + auto result = std::make_shared(gl_engine::Texture::Target::_2dArray, format); + result->setParams(mip_levels > 1 ? gl_engine::Texture::Filter::MipMapLinear : gl_engine::Texture::Filter::Linear, + gl_engine::Texture::Filter::Linear); + result->allocate_array(resolution, resolution, layers, mip_levels); + return result; +} + +QImage reconstruct_srgb(gl_engine::Texture& texture, unsigned resolution, unsigned layer, unsigned level = 0) +{ + const auto level_resolution = std::max(1u, resolution >> level); + gl_engine::Framebuffer framebuffer(gl_engine::Framebuffer::DepthFormat::None, + { gl_engine::Framebuffer::ColourFormat::RGBA8 }, + { level_resolution, level_resolution }); + framebuffer.bind(); + gl_engine::ShaderProgram shader(R"( + out highp vec2 texcoords; + void main() { + highp vec2 vertices[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(vertices[gl_VertexID], 0.0, 1.0); + texcoords = 0.5 * gl_Position.xy + vec2(0.5); + })", + R"( + uniform lowp sampler2DArray texture_sampler; + uniform highp int texture_layer; + uniform highp int mip_level; + in highp vec2 texcoords; + out lowp vec4 out_color; + highp vec3 linear_to_srgb(highp vec3 linear) { + return mix(12.92 * linear, + 1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055, + step(vec3(0.0031308), linear)); + } + void main() { + lowp vec4 colour = textureLod(texture_sampler, + vec3(texcoords.x, 1.0 - texcoords.y, float(texture_layer)), float(mip_level)); + out_color = vec4(linear_to_srgb(colour.rgb), colour.a); + })", + gl_engine::ShaderCodeSource::PLAINTEXT); + shader.bind(); + texture.bind(0); + shader.set_uniform("texture_sampler", 0); + shader.set_uniform("texture_layer", int(layer)); + shader.set_uniform("mip_level", int(level)); + gl_engine::helpers::create_screen_quad_geometry().draw(); + const auto result = framebuffer.read_colour_attachment(0); + gl_engine::Framebuffer::unbind(); + return result; +} + +double psnr(const QImage& image, const Raster& source) +{ + double squared_error = 0.0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const auto actual = image.pixel(x, y); + const auto expected = source.pixel({ x, y }); + const std::array delta { + qRed(actual) - int(expected.x), + qGreen(actual) - int(expected.y), + qBlue(actual) - int(expected.z), + }; + for (const auto value : delta) + squared_error += double(value * value); + } + } + const auto mse = squared_error / double(image.width() * image.height() * 3); + return mse == 0.0 ? std::numeric_limits::infinity() : 10.0 * std::log10(255.0 * 255.0 / mse); +} +} + +TEST_CASE("GPU texture compression processes external scratch layers and mipmaps") +{ + constexpr unsigned resolution = 64; + const auto mip_levels = Compressor::mip_level_count(resolution, resolution); + const std::vector sources { + test_raster(resolution), + Raster(glm::uvec2(resolution), glm::u8vec4(42, 142, 242, 255)), + }; + auto scratch = rgba_scratch(sources, mip_levels); + auto output = destination(gl_engine::Texture::Format::CompressedRGBA8, resolution, 3, mip_levels); + Compressor compressor(scratch, output, { .search_effort = 4 }); + const std::array layers { 2, 0 }; + + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + while (f->glGetError() != GL_NO_ERROR) { } + const auto result = compressor.compress(layers); + REQUIRE(result); + CHECK(f->glGetError() == GL_NO_ERROR); + CHECK(result->layers_written == 2); + CHECK(result->mip_levels_written == mip_levels); + size_t expected_size = 0; + for (unsigned level = 0; level < mip_levels; ++level) { + expected_size += Compressor::compressed_level_size( + std::max(1u, resolution >> level), std::max(1u, resolution >> level)); + } + CHECK(result->bytes_written == expected_size * sources.size()); + CHECK(psnr(reconstruct_srgb(*output, resolution, 2), sources[0]) > 10.0); + CHECK(psnr(reconstruct_srgb(*output, resolution, 0), sources[1]) > 20.0); + for (unsigned level = 1; level < mip_levels; ++level) + CHECK(psnr(reconstruct_srgb(*output, resolution, 0, level), + Raster(glm::uvec2(std::max(1u, resolution >> level)), glm::u8vec4(42, 142, 242, 255))) + > 20.0); +} + +TEST_CASE("GPU texture compression supports automatic and explicit readback modes") +{ + constexpr unsigned resolution = 16; + const std::vector sources { test_raster(resolution) }; + auto scratch = rgba_scratch(sources, 1); + const std::array layers { 0 }; + + auto auto_output = destination(gl_engine::Texture::Format::CompressedRGBA8, resolution, 1, 1); + Compressor automatic(scratch, auto_output, { .readback_mode = Compressor::ReadbackMode::Auto }); + REQUIRE(automatic.compress(layers)); + + auto paired_output = destination(gl_engine::Texture::Format::CompressedRGBA8, resolution, 1, 1); + Compressor paired(scratch, paired_output, { .readback_mode = Compressor::ReadbackMode::RGBA32UI }); + REQUIRE(paired.compress(layers)); + CHECK(paired.effective_readback_mode() == Compressor::ReadbackMode::RGBA32UI); + CHECK(reconstruct_srgb(*auto_output, resolution, 0) == reconstruct_srgb(*paired_output, resolution, 0)); + + auto direct_output = destination(gl_engine::Texture::Format::CompressedRGBA8, resolution, 1, 1); + Compressor direct(scratch, direct_output, { .readback_mode = Compressor::ReadbackMode::RG32UI }); + const auto direct_result = direct.compress(layers); + if (direct_result) { + CHECK(direct.effective_readback_mode() == Compressor::ReadbackMode::RG32UI); + CHECK(automatic.effective_readback_mode() == Compressor::ReadbackMode::RG32UI); + CHECK(reconstruct_srgb(*auto_output, resolution, 0) == reconstruct_srgb(*direct_output, resolution, 0)); + } else { + CHECK(direct_result.error().find("RG32UI") != std::string::npos); + CHECK(automatic.effective_readback_mode() == Compressor::ReadbackMode::RGBA32UI); + } +} + +TEST_CASE("GPU texture compressor is reusable and preserves framebuffer bindings") +{ + constexpr unsigned resolution = 16; + const std::vector sources { test_raster(resolution) }; + auto scratch = rgba_scratch(sources, 1); + auto output = destination(gl_engine::Texture::Format::CompressedRGBA8, resolution, 2, 1); + + auto* f = QOpenGLContext::currentContext()->extraFunctions(); + std::array expected_viewport {}; + f->glGetIntegerv(GL_VIEWPORT, expected_viewport.data()); + Compressor compressor(scratch, output, { .readback_mode = Compressor::ReadbackMode::RGBA32UI }); + std::array actual_viewport {}; + f->glGetIntegerv(GL_VIEWPORT, actual_viewport.data()); + CHECK(actual_viewport == expected_viewport); + + const std::array first_layer { 0 }; + const std::array second_layer { 1 }; + + gl_engine::Framebuffer draw_framebuffer( + gl_engine::Framebuffer::DepthFormat::None, { gl_engine::Framebuffer::ColourFormat::RGBA8 }); + gl_engine::Framebuffer read_framebuffer( + gl_engine::Framebuffer::DepthFormat::None, { gl_engine::Framebuffer::ColourFormat::RGBA8 }); + draw_framebuffer.bind_for_drawing(); + read_framebuffer.bind_for_reading(); + + GLint expected_draw_framebuffer = 0; + GLint expected_read_framebuffer = 0; + f->glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &expected_draw_framebuffer); + f->glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &expected_read_framebuffer); + REQUIRE(expected_draw_framebuffer != expected_read_framebuffer); + + const auto first_result = compressor.compress(first_layer); + REQUIRE(first_result); + const auto second_result = compressor.compress(second_layer); + REQUIRE(second_result); + CHECK(second_result->bytes_written == first_result->bytes_written); + CHECK(second_result->layers_written == 1); + CHECK(second_result->mip_levels_written == 1); + + GLint actual_draw_framebuffer = 0; + GLint actual_read_framebuffer = 0; + f->glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &actual_draw_framebuffer); + f->glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &actual_read_framebuffer); + CHECK(actual_draw_framebuffer == expected_draw_framebuffer); + CHECK(actual_read_framebuffer == expected_read_framebuffer); + gl_engine::Framebuffer::unbind(); + CHECK(psnr(reconstruct_srgb(*output, resolution, 1), sources.front()) > 10.0); +} + +TEST_CASE("GPU texture compressor exposes every platform algorithm") +{ + constexpr unsigned resolution = 8; + const std::vector sources { test_raster(resolution) }; + auto scratch = rgba_scratch(sources, 1); + const std::array layers { 0 }; + std::vector settings; + if (gl_engine::Texture::compression_algorithm() == nucleus::utils::ColourTexture::Format::DXT1) { + settings.push_back({ .dxt1_algorithm = Compressor::Dxt1Algorithm::SlowSearch }); + settings.push_back({ .dxt1_algorithm = Compressor::Dxt1Algorithm::DebugChecksum }); + } else { + settings.push_back({ .etc_algorithm = Compressor::EtcAlgorithm::Fastest }); + settings.push_back({ .etc_algorithm = Compressor::EtcAlgorithm::Fast }); + settings.push_back({ .etc_algorithm = Compressor::EtcAlgorithm::SlowSearch }); + settings.push_back({ .etc_algorithm = Compressor::EtcAlgorithm::DebugChecksum }); + } + + for (const auto& setting : settings) { + auto output = destination(gl_engine::Texture::Format::CompressedRGBA8, resolution, 1, 1); + Compressor compressor(scratch, output, setting); + CHECK(compressor.compress(layers)); + } +} + +TEST_CASE("GPU texture compressor copies sRGB bytes through an RGBA8 framebuffer") +{ + constexpr unsigned resolution = 16; + const auto mip_levels = Compressor::mip_level_count(resolution, resolution); + const std::vector sources { + test_raster(resolution), + Raster(glm::uvec2(resolution), glm::u8vec4(23, 101, 207, 255)), + }; + auto scratch = rgba_scratch(sources, mip_levels); + auto output = destination(gl_engine::Texture::Format::SRGBA8, resolution, 3, mip_levels); + Compressor compressor(scratch, output); + const std::array layers { 2, 0 }; + const auto result = compressor.compress(layers); + REQUIRE(result); + CHECK_FALSE(compressor.effective_readback_mode()); + size_t expected_size = 0; + for (unsigned level = 0; level < mip_levels; ++level) + expected_size += size_t(std::max(1u, resolution >> level)) * std::max(1u, resolution >> level) * 4 * sources.size(); + CHECK(result->bytes_written == expected_size); + CHECK(psnr(reconstruct_srgb(*output, resolution, 2), sources[0]) > 45.0); + CHECK(psnr(reconstruct_srgb(*output, resolution, 0), sources[1]) > 45.0); +} + +TEST_CASE("GPU texture compressor accepts RGB565 scratch storage") +{ + constexpr unsigned resolution = 4; + constexpr uint16_t packed = uint16_t((21u << 11u) | (37u << 5u) | 9u); + auto scratch = std::make_shared( + gl_engine::Texture::Target::_2dArray, gl_engine::Texture::Format::RGB565); + scratch->setParams(gl_engine::Texture::Filter::Nearest, gl_engine::Texture::Filter::Nearest); + scratch->allocate_array(resolution, resolution, 1, 1); + scratch->upload(radix::Raster(glm::uvec2(resolution), packed), 0); + auto output = destination(gl_engine::Texture::Format::SRGBA8, resolution, 1, 1); + Compressor compressor(scratch, output); + const std::array layers { 0 }; + REQUIRE(compressor.compress(layers)); + + const glm::u8vec4 expected( + uint8_t(21u * 255u / 31u), + uint8_t(37u * 255u / 63u), + uint8_t(9u * 255u / 31u), + 255); + CHECK(psnr(reconstruct_srgb(*output, resolution, 0), Raster(glm::uvec2(resolution), expected)) > 40.0); +} + +TEST_CASE("GPU texture compressor reports expired textures") +{ + constexpr unsigned resolution = 4; + const std::vector sources { test_raster(resolution) }; + auto scratch = rgba_scratch(sources, 1); + auto output = destination(gl_engine::Texture::Format::SRGBA8, resolution, 1, 1); + Compressor compressor(scratch, output); + scratch.reset(); + output.reset(); + const std::array layers { 0 }; + const auto result = compressor.compress(layers); + REQUIRE_FALSE(result); + CHECK(result.error().find("expired") != std::string::npos); +} diff --git a/unittests/texture_compression_benchmark/CMakeLists.txt b/unittests/texture_compression_benchmark/CMakeLists.txt new file mode 100644 index 000000000..3c53cd135 --- /dev/null +++ b/unittests/texture_compression_benchmark/CMakeLists.txt @@ -0,0 +1,38 @@ +############################################################################# +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek +# SPDX-License-Identifier: GPL-3.0-or-later +############################################################################# + +project(alpine-renderer-texture-compression-benchmark LANGUAGES CXX) + +qt_add_executable(texture_compression_benchmark + main.cpp + ${CMAKE_SOURCE_DIR}/apps/texture_compression_benchmark/TextureCompressionData.h +) + +target_include_directories(texture_compression_benchmark PRIVATE + ${CMAKE_SOURCE_DIR}/apps/texture_compression_benchmark +) +target_link_libraries(texture_compression_benchmark PUBLIC gl_engine Qt::Network Qt::OpenGL) +alp_configure_target(texture_compression_benchmark) + +if (ANDROID) + add_android_openssl_libraries(texture_compression_benchmark) +endif() + +if (EMSCRIPTEN) + install( + FILES + "$/texture_compression_benchmark.js" + "$/texture_compression_benchmark.wasm" + "$/texture_compression_benchmark.html" + "$/qtloader.js" + DESTINATION "${ALP_WWW_INSTALL_DIR}/texture_compression_benchmark" + ) + install( + FILES "$/texture_compression_benchmark.worker.js" + DESTINATION "${ALP_WWW_INSTALL_DIR}/texture_compression_benchmark" + OPTIONAL + ) +endif() diff --git a/unittests/texture_compression_benchmark/main.cpp b/unittests/texture_compression_benchmark/main.cpp new file mode 100644 index 000000000..16481ecc5 --- /dev/null +++ b/unittests/texture_compression_benchmark/main.cpp @@ -0,0 +1,880 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek + * SPDX-License-Identifier: GPL-3.0-or-later + *****************************************************************************/ + +#include "TextureCompressionData.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { +using Clock = std::chrono::steady_clock; +using Raster = radix::Raster; +using Format = nucleus::utils::ColourTexture::Format; + +constexpr unsigned resolution = 512; +constexpr unsigned max_batch_size = 4; +constexpr std::array batch_sizes { 1, 2, 4 }; +constexpr unsigned framebuffer_size = 32; +constexpr int warmup_batches = 2; +constexpr int measured_batches = 10; +constexpr int repetitions = 200; +constexpr uint32_t random_seed = 0x4a17c0deu; +constexpr int ssim_window_size = 11; +constexpr int ssim_window_radius = ssim_window_size / 2; + +enum class Operation { SamplingOnly, Compression }; + +struct Workload { + std::array source_indices {}; + uint32_t sampling_seed = 0; +}; + +struct QualityMetrics { + double psnr = 0.0; + double ssim = 0.0; +}; + +struct Algorithm { + std::string name; + Operation operation = Operation::Compression; + gl_engine::TextureCompressor::Settings settings; + bool checksum = false; + std::unique_ptr compressor; + std::vector samples; + std::optional quality; +}; + +struct QualityReference { + std::vector ssim_luma; + std::vector ssim_mean; + std::vector ssim_second_moment; +}; + +struct QualityAccumulator { + double squared_error = 0.0; + uint64_t channel_count = 0; + double ssim_sum = 0.0; + uint64_t ssim_count = 0; +}; + +struct Statistics { + double mean = 0.0; + double mean_standard_deviation = 0.0; + size_t sample_count = 0; +}; + +Statistics mean_statistics(std::span samples) +{ + if (samples.empty()) + return {}; + Q_ASSERT(samples.size() == size_t(repetitions * measured_batches)); + std::array repetition_means {}; + for (int repetition = 0; repetition < repetitions; ++repetition) { + const auto begin = samples.begin() + repetition * measured_batches; + repetition_means[size_t(repetition)] + = std::accumulate(begin, begin + measured_batches, 0.0) / double(measured_batches); + } + + const auto mean = std::accumulate(repetition_means.begin(), repetition_means.end(), 0.0) + / double(repetition_means.size()); + double squared_deviations = 0.0; + for (const auto repetition_mean : repetition_means) { + const auto deviation = repetition_mean - mean; + squared_deviations += deviation * deviation; + } + const auto repetition_variance = squared_deviations / double(repetition_means.size() - 1); + const auto mean_standard_deviation = std::sqrt(repetition_variance / double(repetition_means.size())); + return { mean, mean_standard_deviation, samples.size() }; +} + +double srgb_to_linear(uint8_t value) +{ + const auto normalised = double(value) / 255.0; + if (normalised <= 0.04045) + return normalised / 12.92; + return std::pow((normalised + 0.055) / 1.055, 2.4); +} + +double linear_to_srgb(double value) +{ + if (value <= 0.0031308) + return 12.92 * value; + return 1.055 * std::pow(value, 1.0 / 2.4) - 0.055; +} + +std::array ssim_kernel() +{ + constexpr double sigma = 1.5; + std::array kernel {}; + double sum = 0.0; + for (int i = -ssim_window_radius; i <= ssim_window_radius; ++i) { + const auto value = std::exp(-double(i * i) / (2.0 * sigma * sigma)); + kernel[size_t(i + ssim_window_radius)] = value; + sum += value; + } + for (auto& value : kernel) + value /= sum; + return kernel; +} + +std::vector gaussian_filter_valid(std::span input, int width, int height) +{ + Q_ASSERT(width >= ssim_window_size && height >= ssim_window_size); + Q_ASSERT(input.size() == size_t(width * height)); + static const auto kernel = ssim_kernel(); + const auto horizontal_width = width - 2 * ssim_window_radius; + const auto output_height = height - 2 * ssim_window_radius; + std::vector horizontal(size_t(horizontal_width * height)); + for (int y = 0; y < height; ++y) { + for (int x = ssim_window_radius; x < width - ssim_window_radius; ++x) { + double value = 0.0; + for (int offset = -ssim_window_radius; offset <= ssim_window_radius; ++offset) + value += kernel[size_t(offset + ssim_window_radius)] * input[size_t(y * width + x + offset)]; + horizontal[size_t(y * horizontal_width + x - ssim_window_radius)] = value; + } + } + + std::vector output(size_t(horizontal_width * output_height)); + for (int y = ssim_window_radius; y < height - ssim_window_radius; ++y) { + for (int x = 0; x < horizontal_width; ++x) { + double value = 0.0; + for (int offset = -ssim_window_radius; offset <= ssim_window_radius; ++offset) { + value += kernel[size_t(offset + ssim_window_radius)] + * horizontal[size_t((y + offset) * horizontal_width + x)]; + } + output[size_t((y - ssim_window_radius) * horizontal_width + x)] = value; + } + } + return output; +} + +QualityReference make_quality_reference(const Raster& source) +{ + QualityReference result; + result.ssim_luma.resize(size_t(source.width() * source.height())); + std::vector squared_luma(result.ssim_luma.size()); + for (unsigned y = 0; y < source.height(); ++y) { + for (unsigned x = 0; x < source.width(); ++x) { + const auto pixel = source.pixel({ x, y }); + const auto luma = 0.2126 * double(pixel.x) / 255.0 + + 0.7152 * double(pixel.y) / 255.0 + + 0.0722 * double(pixel.z) / 255.0; + const auto index = size_t(y * source.width() + x); + result.ssim_luma[index] = luma; + squared_luma[index] = luma * luma; + } + } + result.ssim_mean = gaussian_filter_valid(result.ssim_luma, int(source.width()), int(source.height())); + result.ssim_second_moment = gaussian_filter_valid(squared_luma, int(source.width()), int(source.height())); + return result; +} + +void accumulate_quality(QualityAccumulator& accumulator, + const QImage& reconstructed, + const Raster& source, + const QualityReference& reference) +{ + Q_ASSERT(reconstructed.size() == QSize(int(source.width()), int(source.height()))); + std::vector reconstructed_luma(size_t(source.width() * source.height())); + std::vector squared_luma(reconstructed_luma.size()); + std::vector cross_luma(reconstructed_luma.size()); + for (unsigned y = 0; y < source.height(); ++y) { + for (unsigned x = 0; x < source.width(); ++x) { + const auto actual = reconstructed.pixel(int(x), int(y)); + const auto expected = source.pixel({ x, y }); + const std::array actual_linear { + qRed(actual) / 255.0, + qGreen(actual) / 255.0, + qBlue(actual) / 255.0, + }; + const std::array expected_linear { + srgb_to_linear(expected.x), + srgb_to_linear(expected.y), + srgb_to_linear(expected.z), + }; + for (size_t channel = 0; channel < actual_linear.size(); ++channel) { + const auto difference = actual_linear[channel] - expected_linear[channel]; + accumulator.squared_error += difference * difference; + } + accumulator.channel_count += actual_linear.size(); + + const auto luma = 0.2126 * linear_to_srgb(actual_linear[0]) + + 0.7152 * linear_to_srgb(actual_linear[1]) + + 0.0722 * linear_to_srgb(actual_linear[2]); + const auto index = size_t(y * source.width() + x); + reconstructed_luma[index] = luma; + squared_luma[index] = luma * luma; + cross_luma[index] = luma * reference.ssim_luma[index]; + } + } + + const auto actual_mean = gaussian_filter_valid(reconstructed_luma, int(source.width()), int(source.height())); + const auto actual_second_moment = gaussian_filter_valid(squared_luma, int(source.width()), int(source.height())); + const auto cross_moment = gaussian_filter_valid(cross_luma, int(source.width()), int(source.height())); + Q_ASSERT(actual_mean.size() == reference.ssim_mean.size()); + constexpr double c1 = 0.01 * 0.01; + constexpr double c2 = 0.03 * 0.03; + for (size_t i = 0; i < actual_mean.size(); ++i) { + const auto reference_variance + = std::max(0.0, reference.ssim_second_moment[i] - reference.ssim_mean[i] * reference.ssim_mean[i]); + const auto actual_variance + = std::max(0.0, actual_second_moment[i] - actual_mean[i] * actual_mean[i]); + const auto covariance = cross_moment[i] - reference.ssim_mean[i] * actual_mean[i]; + const auto luminance = 2.0 * reference.ssim_mean[i] * actual_mean[i] + c1; + const auto contrast_structure = 2.0 * covariance + c2; + const auto denominator = (reference.ssim_mean[i] * reference.ssim_mean[i] + actual_mean[i] * actual_mean[i] + c1) + * (reference_variance + actual_variance + c2); + accumulator.ssim_sum += luminance * contrast_structure / denominator; + } + accumulator.ssim_count += actual_mean.size(); +} + +QualityMetrics quality_metrics(const QualityAccumulator& accumulator) +{ + Q_ASSERT(accumulator.channel_count > 0 && accumulator.ssim_count > 0); + const auto mse = accumulator.squared_error / double(accumulator.channel_count); + return { + mse == 0.0 ? std::numeric_limits::infinity() : 10.0 * std::log10(1.0 / mse), + accumulator.ssim_sum / double(accumulator.ssim_count), + }; +} + +std::string gl_string(GLenum name) +{ + const auto* value = QOpenGLContext::currentContext()->functions()->glGetString(name); + return value ? reinterpret_cast(value) : "unavailable"; +} + +const char* format_name(Format format) +{ + switch (format) { + case Format::DXT1: + return "DXT1"; + case Format::ETC1: + return "ETC1"; + case Format::Uncompressed_RGBA: + return "uncompressed"; + } + return "unknown"; +} + +const char* readback_mode_name(gl_engine::TextureCompressor::ReadbackMode mode) +{ + using ReadbackMode = gl_engine::TextureCompressor::ReadbackMode; + switch (mode) { + case ReadbackMode::Auto: + return "automatic"; + case ReadbackMode::RG32UI: + return "direct RG32UI"; + case ReadbackMode::RGBA32UI: + return "paired RGBA32UI"; + } + return "unknown"; +} + +std::vector supported_algorithms(Format format) +{ + using Compressor = gl_engine::TextureCompressor; + Compressor::Settings checksum_settings { + .dxt1_algorithm = Compressor::Dxt1Algorithm::DebugChecksum, + .etc_algorithm = Compressor::EtcAlgorithm::DebugChecksum, + }; + + std::vector result; + result.push_back({ "sampling only", Operation::SamplingOnly, checksum_settings }); + result.push_back({ "debug checksum", Operation::Compression, checksum_settings, true }); + if (format == Format::DXT1) { + result.push_back({ "DXT1 slow search", Operation::Compression, {} }); + } else if (format == Format::ETC1) { + result.push_back({ "ETC fastest", + Operation::Compression, + { .etc_algorithm = Compressor::EtcAlgorithm::Fastest } }); + result.push_back({ "ETC fast", + Operation::Compression, + { .etc_algorithm = Compressor::EtcAlgorithm::Fast } }); + result.push_back({ "ETC slow search", + Operation::Compression, + { .etc_algorithm = Compressor::EtcAlgorithm::SlowSearch } }); + } + return result; +} + +class BenchmarkWindow final : public QOpenGLWindow { +public: + BenchmarkWindow() + : m_downloaded_tiles(texture_compression_data::tile_groups.size() * 4) + { + resize(int(framebuffer_size), int(framebuffer_size)); + } + +protected: + void initializeGL() override + { + download_data(); + } + + void paintGL() override + { + if (!m_data_ready) + return; + const auto successful = m_benchmark_state ? advance_benchmark() : begin_benchmark(); + if (!successful) { + QTimer::singleShot(0, qApp, []() { QCoreApplication::exit(EXIT_FAILURE); }); + return; + } + if (m_batch_index == batch_sizes.size() && !m_benchmark_state) { +#if defined(__EMSCRIPTEN__) + qInfo().noquote() << QStringLiteral("Benchmark complete."); + m_data_ready = false; +#else + QTimer::singleShot(0, qApp, []() { QCoreApplication::exit(EXIT_SUCCESS); }); +#endif + return; + } + QTimer::singleShot(10, this, [this]() { update(); }); + } + +private: + struct BenchmarkState { + enum class Phase { Timing, QualitySetup, Quality, Report }; + + unsigned batch_size = 0; + Format format = Format::Uncompressed_RGBA; + std::vector algorithms; + std::shared_ptr scratch; + std::shared_ptr destination; + std::unique_ptr framebuffer; + std::unique_ptr sampling_shader; + gl_engine::helpers::ScreenQuadGeometry sampling_geometry; + std::array destination_layers { 0, 1, 2, 3 }; + std::mt19937 random_engine { random_seed }; + std::array, repetitions> workloads; + std::vector algorithm_order; + size_t startup_step = 0; + bool destination_initialised = false; + int repetition = 0; + size_t algorithm_position = 0; + Phase phase = Phase::Timing; + std::vector quality_references; + std::unique_ptr quality_framebuffer; + std::unique_ptr quality_shader; + size_t quality_algorithm = 0; + }; + + void download_data() + { + m_downloads_remaining = int(m_downloaded_tiles.size()); + qInfo().noquote() << QStringLiteral("Downloading %1 source tiles once...").arg(m_downloaded_tiles.size()); + for (size_t group_index = 0; group_index < texture_compression_data::tile_groups.size(); ++group_index) { + for (int y = 0; y < 2; ++y) { + for (int x = 0; x < 2; ++x) { + const auto tile_index = group_index * 4 + size_t(y * 2 + x); + const auto url = texture_compression_data::tile_url( + texture_compression_data::tile_groups[group_index], x, y); + auto* reply = m_network_manager.get(QNetworkRequest(QUrl(url))); + connect(reply, &QNetworkReply::finished, this, [this, reply, tile_index, url]() { + if (reply->error() == QNetworkReply::NoError) { + auto image = nucleus::utils::image_loader::rgba8(reply->readAll()); + if (image && image->size() == glm::uvec2(256u)) + m_downloaded_tiles[tile_index] = std::move(*image); + } + if (m_downloaded_tiles[tile_index].size() != glm::uvec2(256u) && m_download_error.isEmpty()) + m_download_error = QStringLiteral("Unable to download benchmark tile: %1").arg(url); + reply->deleteLater(); + if (--m_downloads_remaining == 0) + finish_downloads(); + }); + } + } + } + } + + void finish_downloads() + { + if (!m_download_error.isEmpty()) { + fail(m_download_error); + return; + } + + m_sources.clear(); + m_sources.reserve(texture_compression_data::tile_groups.size()); + for (size_t group_index = 0; group_index < texture_compression_data::tile_groups.size(); ++group_index) { + const auto tile_offset = group_index * 4; + auto top = radix::raster::concatenate_horizontally( + m_downloaded_tiles[tile_offset], m_downloaded_tiles[tile_offset + 1]); + auto bottom = radix::raster::concatenate_horizontally( + m_downloaded_tiles[tile_offset + 2], m_downloaded_tiles[tile_offset + 3]); + if (!top || !bottom) { + fail(QStringLiteral("Unable to stitch benchmark tile row.")); + return; + } + + auto stitched = radix::raster::concatenate_vertically(*top, *bottom); + if (!stitched) { + fail(QStringLiteral("Unable to stitch benchmark tile group.")); + return; + } + m_sources.push_back(std::move(*stitched)); + } + m_downloaded_tiles.clear(); + m_data_ready = true; + qInfo().noquote() << QStringLiteral("Prepared %1 stitched 512x512 textures.").arg(m_sources.size()); + update(); + } + + bool begin_benchmark() + { + Q_ASSERT(m_batch_index < batch_sizes.size()); + auto state = std::make_unique(); + state->batch_size = batch_sizes[m_batch_index]; + state->format = gl_engine::Texture::compression_algorithm(); + state->algorithms = supported_algorithms(state->format); + if (state->algorithms.size() <= 2) { + qInfo().noquote() << QStringLiteral("No supported GPU compression algorithm was found."); + return false; + } + qInfo().noquote() << QStringLiteral("Initialising batch size %1...").arg(state->batch_size); + m_benchmark_state = std::move(state); + return true; + } + + std::unique_ptr make_sampling_shader() + { + return std::make_unique(R"( + void main() { + highp vec2 vertices[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(vertices[gl_VertexID], 0.0, 1.0); + })", + R"( + uniform lowp sampler2DArray texture_sampler; + uniform highp uint sampling_seed; + uniform highp uint active_layers; + layout(location = 0) out lowp vec4 out_color; + + highp uint hash(highp uint value) { + value ^= value >> 16u; + value *= 0x7feb352du; + value ^= value >> 15u; + value *= 0x846ca68bu; + return value ^ (value >> 16u); + } + + void main() { + highp uvec2 pixel = uvec2(gl_FragCoord.xy); + highp uint pixel_index = pixel.y * 32u + pixel.x; + highp uint random_value = hash(sampling_seed ^ pixel_index); + highp float x = float(random_value & 0xffffu) / 65535.0; + random_value = hash(random_value); + highp float y = float(random_value & 0xffffu) / 65535.0; + random_value = hash(random_value); + highp float layer = float(random_value % active_layers); + random_value = hash(random_value); + highp float level = float(random_value % 10u); + lowp vec4 sampled = textureLod(texture_sampler, vec3(x, y, layer), level); + bool write_pixel = (random_value & 1u) != 0u || pixel_index == 0u; + if (!write_pixel) + discard; + out_color = sampled; + })", + gl_engine::ShaderCodeSource::PLAINTEXT); + } + + bool advance_startup(BenchmarkState& state) + { + switch (state.startup_step++) { + case 0: + state.scratch = std::make_shared( + gl_engine::Texture::Target::_2dArray, gl_engine::Texture::Format::RGBA8); + state.scratch->setParams(gl_engine::Texture::Filter::Nearest, gl_engine::Texture::Filter::Linear); + state.scratch->allocate_array(resolution, + resolution, + state.batch_size, + gl_engine::TextureCompressor::mip_level_count(resolution, resolution)); + state.destination = std::make_shared( + gl_engine::Texture::Target::_2dArray, gl_engine::Texture::Format::CompressedRGBA8); + state.destination->setParams(gl_engine::Texture::Filter::MipMapLinear, gl_engine::Texture::Filter::Linear); + state.destination->allocate_array(resolution, + resolution, + state.batch_size, + gl_engine::TextureCompressor::mip_level_count(resolution, resolution)); + return true; + case 1: + for (auto& algorithm : state.algorithms) { + if (algorithm.operation == Operation::Compression) { + algorithm.compressor = std::make_unique( + state.scratch, state.destination, algorithm.settings); + } + } + return true; + case 2: + state.framebuffer = std::make_unique( + gl_engine::Framebuffer::DepthFormat::None, + std::vector { gl_engine::Framebuffer::ColourFormat::RGBA8 }, + glm::uvec2 { framebuffer_size, framebuffer_size }); + return true; + case 3: + state.sampling_shader = make_sampling_shader(); + return true; + case 4: + state.sampling_geometry = gl_engine::helpers::create_screen_quad_geometry(); + return true; + case 5: + for (auto& repetition : state.workloads) { + for (auto& workload : repetition) { + std::array indices; + std::iota(indices.begin(), indices.end(), 0); + std::ranges::shuffle(indices, state.random_engine); + std::ranges::copy_n(indices.begin(), max_batch_size, workload.source_indices.begin()); + workload.sampling_seed = state.random_engine(); + } + } + state.algorithm_order.resize(state.algorithms.size()); + std::iota(state.algorithm_order.begin(), state.algorithm_order.end(), 0); + qInfo().noquote() << QStringLiteral("\nTexture compression benchmark\n" + "GL vendor: %1\n" + "GL renderer: %2\n" + "GL version: %3\n" + "Compression format: %4\n" + "Readback: %5\n" + "Random seed: 0x%6\n" + "Batch: %7 x 512x512 base-level textures, with mipmaps\n" + "Schedule: %8 repetitions, %9 warm-up + %10 measured batches per algorithm\n" + "Timer: steady-clock wall time through dependent one-pixel framebuffer readback\n") + .arg(QString::fromStdString(gl_string(GL_VENDOR))) + .arg(QString::fromStdString(gl_string(GL_RENDERER))) + .arg(QString::fromStdString(gl_string(GL_VERSION))) + .arg(QString::fromLatin1(format_name(state.format))) + .arg(QString::fromLatin1(readback_mode_name( + *state.algorithms[1].compressor->effective_readback_mode()))) + .arg(QString::number(random_seed, 16)) + .arg(state.batch_size) + .arg(repetitions) + .arg(warmup_batches) + .arg(measured_batches); + return true; + default: + std::vector initial_sources(m_sources.begin(), m_sources.begin() + state.batch_size); + for (size_t layer = 0; layer < initial_sources.size(); ++layer) + state.scratch->upload(initial_sources[layer], unsigned(layer)); + state.scratch->generate_mipmaps(); + const auto result = state.algorithms[1].compressor->compress( + std::span(state.destination_layers).first(state.batch_size)); + if (!result) { + fail(QString::fromStdString(result.error())); + return false; + } + state.destination_initialised = true; + return true; + } + } + + double run_timed_batch(BenchmarkState& state, const Algorithm& algorithm, const Workload& workload) + { + std::vector selected_sources; + if (algorithm.operation == Operation::Compression) { + selected_sources.reserve(state.batch_size); + for (unsigned layer = 0; layer < state.batch_size; ++layer) + selected_sources.push_back(m_sources[workload.source_indices[layer]]); + } + + const auto start = Clock::now(); + if (algorithm.operation == Operation::Compression) { + for (size_t layer = 0; layer < selected_sources.size(); ++layer) + state.scratch->upload(selected_sources[layer], unsigned(layer)); + state.scratch->generate_mipmaps(); + const auto result = algorithm.compressor->compress( + std::span(state.destination_layers).first(state.batch_size)); + if (!result) + qFatal("Texture compression failed: %s", result.error().c_str()); + } + + auto* functions = QOpenGLContext::currentContext()->extraFunctions(); + state.framebuffer->bind(); + functions->glViewport(0, 0, framebuffer_size, framebuffer_size); + functions->glDisable(GL_BLEND); + functions->glDisable(GL_CULL_FACE); + functions->glDisable(GL_DEPTH_TEST); + functions->glDisable(GL_SCISSOR_TEST); + functions->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + state.sampling_shader->bind(); + state.destination->bind(0); + state.sampling_shader->set_uniform("texture_sampler", 0); + state.sampling_shader->set_uniform("sampling_seed", workload.sampling_seed); + state.sampling_shader->set_uniform("active_layers", state.batch_size); + state.sampling_geometry.draw(); + state.sampling_shader->release(); + const auto pixel = state.framebuffer->read_colour_attachment_pixel(0, { -1.0, -1.0 }); + const auto end = Clock::now(); + m_pixel_checksum + += uint64_t(pixel.x) + 3u * uint64_t(pixel.y) + 5u * uint64_t(pixel.z) + 7u * uint64_t(pixel.w); + return std::chrono::duration(end - start).count(); + } + + void prepare_quality(BenchmarkState& state) + { + qInfo().noquote() << QStringLiteral("Computing untimed 512x512 quality metrics..."); + state.quality_references.reserve(m_sources.size()); + for (const auto& source : m_sources) + state.quality_references.push_back(make_quality_reference(source)); + state.quality_framebuffer = std::make_unique( + gl_engine::Framebuffer::DepthFormat::None, + std::vector { gl_engine::Framebuffer::ColourFormat::RGBA8 }, + glm::uvec2 { resolution, resolution }); + state.quality_shader = std::make_unique(R"( + out highp vec2 texcoords; + void main() { + highp vec2 vertices[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(vertices[gl_VertexID], 0.0, 1.0); + texcoords = 0.5 * gl_Position.xy + vec2(0.5); + })", + R"( + uniform lowp sampler2DArray texture_sampler; + uniform highp float texture_layer; + in highp vec2 texcoords; + out lowp vec4 out_color; + void main() { + out_color = textureLod(texture_sampler, vec3(texcoords.x, 1.0 - texcoords.y, texture_layer), 0.0); + })", + gl_engine::ShaderCodeSource::PLAINTEXT); + } + + QImage reconstruct(BenchmarkState& state, unsigned layer) + { + auto* functions = QOpenGLContext::currentContext()->extraFunctions(); + state.quality_framebuffer->bind(); + functions->glViewport(0, 0, resolution, resolution); + functions->glDisable(GL_BLEND); + functions->glDisable(GL_CULL_FACE); + functions->glDisable(GL_DEPTH_TEST); + functions->glDisable(GL_SCISSOR_TEST); + functions->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + state.quality_shader->bind(); + state.destination->bind(0); + state.quality_shader->set_uniform("texture_sampler", 0); + state.quality_shader->set_uniform("texture_layer", float(layer)); + state.sampling_geometry.draw(); + state.quality_shader->release(); + return state.quality_framebuffer->read_colour_attachment(0); + } + + void compute_quality(BenchmarkState& state, Algorithm& algorithm) + { + QualityAccumulator accumulator; + for (size_t source_offset = 0; source_offset < m_sources.size(); source_offset += state.batch_size) { + const auto active_batch_size + = std::min(state.batch_size, unsigned(m_sources.size() - source_offset)); + std::vector selected_sources; + selected_sources.reserve(active_batch_size); + for (size_t layer = 0; layer < active_batch_size; ++layer) + selected_sources.push_back(m_sources[source_offset + layer]); + for (size_t layer = 0; layer < selected_sources.size(); ++layer) + state.scratch->upload(selected_sources[layer], unsigned(layer)); + state.scratch->generate_mipmaps(); + const auto result = algorithm.compressor->compress( + std::span(state.destination_layers).first(active_batch_size)); + if (!result) + qFatal("Texture compression failed: %s", result.error().c_str()); + for (unsigned layer = 0; layer < active_batch_size; ++layer) { + accumulate_quality(accumulator, + reconstruct(state, layer), + selected_sources[layer], + state.quality_references[source_offset + layer]); + } + } + algorithm.quality = quality_metrics(accumulator); + qInfo().noquote() + << QStringLiteral("Completed quality metrics for %1").arg(QString::fromStdString(algorithm.name)); + } + + bool write_report(const BenchmarkState& state) + { + gl_engine::Framebuffer::unbind(); + const auto sampling_iterator = std::ranges::find_if( + state.algorithms, [](const Algorithm& algorithm) { return algorithm.operation == Operation::SamplingOnly; }); + if (sampling_iterator == state.algorithms.end()) + return false; + + std::ostringstream report; + report << "\nAll values are milliseconds per batch. Mean SD is estimated from " << repetitions + << " repetition means (" << measured_batches << " batches each): sample SD / sqrt(" + << repetitions << ").\n" + << "Quality is an untimed pass over all " << m_sources.size() + << " source textures at mip level 0 (512x512). PSNR uses linear RGB; SSIM uses sRGB luma.\n" + << std::left << std::setw(53) << "algorithm" + << std::right << std::setw(12) << "raw mean" << std::setw(14) << "raw mean SD" + << std::setw(8) << "n" << std::setw(17) << "minus sample" + << std::setw(18) << "adjusted mean SD" << std::setw(17) << "encoding only" + << std::setw(18) << "encoding mean SD" << std::setw(13) << "PSNR (dB)" + << std::setw(11) << "SSIM" << '\n'; + + for (const auto& algorithm : state.algorithms) { + const auto checksum_iterator + = std::ranges::find_if(state.algorithms, [](const Algorithm& candidate) { + return candidate.checksum; + }); + if (algorithm.operation == Operation::Compression && checksum_iterator == state.algorithms.end()) + return false; + std::vector sampling_subtracted; + std::vector encoding_only; + sampling_subtracted.reserve(algorithm.samples.size()); + encoding_only.reserve(algorithm.samples.size()); + for (size_t i = 0; i < algorithm.samples.size(); ++i) { + sampling_subtracted.push_back(algorithm.samples[i] - sampling_iterator->samples[i]); + if (algorithm.operation != Operation::SamplingOnly) + encoding_only.push_back(algorithm.samples[i] - checksum_iterator->samples[i]); + } + const auto raw = mean_statistics(algorithm.samples); + const auto adjusted = mean_statistics(sampling_subtracted); + const auto encoding = mean_statistics(encoding_only); + report << std::left << std::setw(53) << algorithm.name << std::right << std::fixed << std::setprecision(3) + << std::setw(12) << raw.mean << std::setw(14) << raw.mean_standard_deviation + << std::setw(8) << raw.sample_count << std::setw(17) << adjusted.mean + << std::setw(18) << adjusted.mean_standard_deviation; + if (encoding_only.empty()) { + report << std::setw(17) << "n/a" << std::setw(18) << "n/a"; + } else { + report << std::setw(17) << encoding.mean << std::setw(18) << encoding.mean_standard_deviation; + } + if (algorithm.quality) { + report << std::setw(13) << std::setprecision(3) << algorithm.quality->psnr + << std::setw(11) << std::setprecision(6) << algorithm.quality->ssim + << std::setprecision(3); + } else { + report << std::setw(13) << "n/a" << std::setw(11) << "n/a"; + } + report << '\n'; + } + report << "Readback checksum: " << m_pixel_checksum; + for (const auto& line : QString::fromStdString(report.str()).split('\n')) + qInfo().noquote() << line; + return true; + } + + bool advance_benchmark() + { + auto& state = *m_benchmark_state; + if (!state.destination_initialised) + return advance_startup(state); + if (state.phase == BenchmarkState::Phase::Timing) { + if (state.algorithm_position == 0) + std::ranges::shuffle(state.algorithm_order, state.random_engine); + auto& algorithm = state.algorithms[state.algorithm_order[state.algorithm_position]]; + for (int batch = 0; batch < warmup_batches; ++batch) + static_cast(run_timed_batch(state, algorithm, state.workloads[size_t(state.repetition)][size_t(batch)])); + for (int batch = 0; batch < measured_batches; ++batch) { + algorithm.samples.push_back(run_timed_batch( + state, algorithm, state.workloads[size_t(state.repetition)][size_t(warmup_batches + batch)])); + } + if (++state.algorithm_position == state.algorithm_order.size()) { + state.algorithm_position = 0; + qInfo().noquote() + << QStringLiteral("Completed repetition %1/%2").arg(state.repetition + 1).arg(repetitions); + if (++state.repetition == repetitions) + state.phase = BenchmarkState::Phase::QualitySetup; + } + return true; + } + + if (state.phase == BenchmarkState::Phase::QualitySetup) { + prepare_quality(state); + state.phase = BenchmarkState::Phase::Quality; + return true; + } + + if (state.phase == BenchmarkState::Phase::Quality) { + while (state.quality_algorithm < state.algorithms.size()) { + auto& algorithm = state.algorithms[state.quality_algorithm++]; + if (algorithm.operation == Operation::Compression && !algorithm.checksum) { + compute_quality(state, algorithm); + return true; + } + } + state.phase = BenchmarkState::Phase::Report; + return true; + } + + if (!write_report(state)) + return false; + m_benchmark_state.reset(); + ++m_batch_index; + return true; + } + + void fail(const QString& message) + { + qInfo().noquote() << message; + QTimer::singleShot(0, qApp, []() { QCoreApplication::exit(EXIT_FAILURE); }); + } + + QNetworkAccessManager m_network_manager; + std::vector m_downloaded_tiles; + std::vector m_sources; + QString m_download_error; + int m_downloads_remaining = 0; + bool m_data_ready = false; + size_t m_batch_index = 0; + std::unique_ptr m_benchmark_state; + uint64_t m_pixel_checksum = 0; +}; + +} // namespace + +int main(int argc, char* argv[]) +{ + QSurfaceFormat format; + format.setDepthBufferSize(24); + format.setOption(QSurfaceFormat::DebugContext); +#if QT_CONFIG(opengles2) + format.setVersion(3, 0); +#else + format.setRenderableType(QSurfaceFormat::OpenGL); + format.setVersion(3, 3); + format.setProfile(QSurfaceFormat::CoreProfile); +#endif + QSurfaceFormat::setDefaultFormat(format); + + QGuiApplication application(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("TextureCompressionBenchmark")); + QCoreApplication::setOrganizationName(QStringLiteral("AlpineMaps.org")); + + BenchmarkWindow window; + window.show(); + return application.exec(); +}