diff --git a/.github/scripts/fetch-trace-fixture-lfs.sh b/.github/scripts/fetch-trace-fixture-lfs.sh index ac0be72c..4834b4cf 100644 --- a/.github/scripts/fetch-trace-fixture-lfs.sh +++ b/.github/scripts/fetch-trace-fixture-lfs.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=trace-fixture-lib.sh +. "${script_dir}/trace-fixture-lib.sh" + if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then echo "usage: $0 [fixture-dir]" >&2 exit 2 @@ -62,57 +66,6 @@ if [ "${case_name}" = "OpenRA" ]; then exit 0 fi -get_lfs_metadata() { - local file="$1" - local pointer - local expected_oid - local expected_size - - if ! pointer="$(git show "HEAD:${file}" 2>/dev/null)"; then - echo "failed to read tracked fixture metadata: ${file}" >&2 - return 1 - fi - if ! grep -q '^version https://git-lfs.github.com/spec/v1$' <<< "${pointer}"; then - echo "tracked fixture is not a Git LFS pointer: ${file}" >&2 - return 1 - fi - - expected_oid="$(awk '$1 == "oid" && $2 ~ /^sha256:/ { sub(/^sha256:/, "", $2); print $2 }' <<< "${pointer}")" - expected_size="$(awk '$1 == "size" { print $2 }' <<< "${pointer}")" - if ! [[ "${expected_oid}" =~ ^[0-9a-f]{64}$ ]] || ! [[ "${expected_size}" =~ ^[0-9]+$ ]]; then - echo "invalid Git LFS pointer metadata: ${file}" >&2 - return 1 - fi - - printf '%s %s\n' "${expected_oid}" "${expected_size}" -} - -verify_fixture_file() { - local downloaded_file="$1" - local display_name="$2" - local expected_oid="$3" - local expected_size="$4" - local actual_oid - local actual_size - - if [ ! -f "${downloaded_file}" ]; then - echo "fixture file is missing: ${display_name}" >&2 - return 1 - fi - - actual_size="$(wc -c < "${downloaded_file}" | tr -d '[:space:]')" - if [ "${actual_size}" != "${expected_size}" ]; then - echo "fixture size mismatch for ${display_name}: expected ${expected_size}, got ${actual_size}" >&2 - return 1 - fi - - actual_oid="$(sha256sum "${downloaded_file}" | awk '{ print $1 }')" - if [ "${actual_oid}" != "${expected_oid}" ]; then - echo "fixture SHA-256 mismatch for ${display_name}: expected ${expected_oid}, got ${actual_oid}" >&2 - return 1 - fi -} - fetch_file_from_mirror() { local file="$1" local url="$2" diff --git a/.github/scripts/trace-fixture-cache.sh b/.github/scripts/trace-fixture-cache.sh new file mode 100644 index 00000000..11b525bd --- /dev/null +++ b/.github/scripts/trace-fixture-cache.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Cache-side helper for trace fixtures. +# +# key [fixture-dir] derive the actions/cache key and path list +# verify [fixture-dir] check restored fixtures against their pointers +# reset [fixture-dir] drop restored fixtures, leaving the pointers +# +# The cache key is content-addressed on the Git LFS pointer oids tracked at +# HEAD, which are readable from a plain checkout without smudging. Fixture +# content therefore maps 1:1 onto a key: unchanged content hits, changed +# content is a new key and thus a miss, and the download path handles it. The +# key deliberately carries no restore-keys prefix in the workflow - a fixture +# that does not match the pointer exactly must never be restored. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=trace-fixture-lib.sh +. "${script_dir}/trace-fixture-lib.sh" + +# Bump when the key derivation changes in a way that must invalidate old +# entries; the content digest alone would not notice a format change. +key_schema="v1" + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "usage: $0 [fixture-dir]" >&2 + exit 2 +fi + +command_name="$1" +case_name="$2" +fixture_dir="${3:-tools/trace_replay/fixtures}" +python_bin="${PYTHON:-python3}" + +if ! command -v "${python_bin}" >/dev/null 2>&1 && command -v python >/dev/null 2>&1; then + python_bin=python +fi + +mapfile -t files < <(trace_fixture_files "${case_name}" "${fixture_dir}" "${python_bin}") +if [ "${#files[@]}" -eq 0 ]; then + echo "no fixture files declared for trace case: ${case_name}" >&2 + exit 1 +fi + +# Writes "name=value" to $GITHUB_OUTPUT when running under Actions, and to +# stdout otherwise so the script stays runnable (and testable) off-CI. +emit_output() { + local name="$1" + local value="$2" + if [ -n "${GITHUB_OUTPUT:-}" ]; then + if [[ "${value}" == *$'\n'* ]]; then + local delimiter="ghadelim_$(date +%s%N)_$$" + { + printf '%s<<%s\n' "${name}" "${delimiter}" + printf '%s\n' "${value}" + printf '%s\n' "${delimiter}" + } >> "${GITHUB_OUTPUT}" + else + printf '%s=%s\n' "${name}" "${value}" >> "${GITHUB_OUTPUT}" + fi + fi + printf '%s=%s\n' "${name}" "${value}" +} + +sanitize_case() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9._-]/_/g' +} + +case "${command_name}" in + key) + manifest="" + for file in "${files[@]}"; do + # A case whose fixtures are committed directly rather than through Git LFS + # (OpenRA) has no pointer oid to key on, and nothing to download either. + # Report it as uncacheable so the workflow skips the cache entirely. + if ! metadata="$(get_lfs_metadata "${file}" 2>/dev/null)"; then + echo "trace case ${case_name} is not stored in Git LFS; skipping fixture cache" >&2 + emit_output "cacheable" "false" + emit_output "key" "" + exit 0 + fi + read -r expected_oid expected_size <<< "${metadata}" + manifest+="$(basename "${file}") ${expected_oid} ${expected_size}"$'\n' + done + + digest="$(printf '%s' "${manifest}" | sha256sum | awk '{ print substr($1, 1, 16) }')" + safe_case="$(sanitize_case "${case_name}")" + + emit_output "cacheable" "true" + emit_output "key" "trace-fixture-${key_schema}-${safe_case}-${digest}" + emit_output "paths" "$(printf '%s\n' "${files[@]}")" + ;; + + verify) + for file in "${files[@]}"; do + metadata="$(get_lfs_metadata "${file}")" + read -r expected_oid expected_size <<< "${metadata}" + verify_fixture_file "${file}" "${file}" "${expected_oid}" "${expected_size}" + done + echo "Verified ${#files[@]} fixture file(s) for ${case_name} against the tracked Git LFS pointers." + ;; + + reset) + # Put the working tree back to the pointer files a fresh checkout would + # have, so that a rejected cache entry falls through to exactly the same + # download path a cache miss takes. + for file in "${files[@]}"; do + rm -f "${file}" "${file}.tmp" + done + git checkout -- "${files[@]}" + echo "Reset ${#files[@]} fixture file(s) for ${case_name} to their tracked Git LFS pointers." + ;; + + *) + echo "unknown command: ${command_name}" >&2 + exit 2 + ;; +esac diff --git a/.github/scripts/trace-fixture-lib.sh b/.github/scripts/trace-fixture-lib.sh new file mode 100644 index 00000000..0e3b971b --- /dev/null +++ b/.github/scripts/trace-fixture-lib.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Shared helpers for trace-fixture handling: reading the in-tree Git LFS pointer +# metadata and verifying a fixture file against it. Sourced by +# fetch-trace-fixture-lfs.sh (verify after download) and by +# trace-fixture-cache.sh (cache key derivation and verify after cache restore), +# so both paths agree on what a valid fixture is. + +# Reads the Git LFS pointer tracked at HEAD for a fixture path and prints +# " ". Fails if the tracked blob is not a well-formed LFS pointer. +get_lfs_metadata() { + local file="$1" + local pointer + local expected_oid + local expected_size + + if ! pointer="$(git show "HEAD:${file}" 2>/dev/null)"; then + echo "failed to read tracked fixture metadata: ${file}" >&2 + return 1 + fi + if ! grep -q '^version https://git-lfs.github.com/spec/v1$' <<< "${pointer}"; then + echo "tracked fixture is not a Git LFS pointer: ${file}" >&2 + return 1 + fi + + expected_oid="$(awk '$1 == "oid" && $2 ~ /^sha256:/ { sub(/^sha256:/, "", $2); print $2 }' <<< "${pointer}")" + expected_size="$(awk '$1 == "size" { print $2 }' <<< "${pointer}")" + if ! [[ "${expected_oid}" =~ ^[0-9a-f]{64}$ ]] || ! [[ "${expected_size}" =~ ^[0-9]+$ ]]; then + echo "invalid Git LFS pointer metadata: ${file}" >&2 + return 1 + fi + + printf '%s %s\n' "${expected_oid}" "${expected_size}" +} + +# Checks an on-disk fixture against the size and SHA-256 from its LFS pointer. +verify_fixture_file() { + local downloaded_file="$1" + local display_name="$2" + local expected_oid="$3" + local expected_size="$4" + local actual_oid + local actual_size + + if [ ! -f "${downloaded_file}" ]; then + echo "fixture file is missing: ${display_name}" >&2 + return 1 + fi + + actual_size="$(wc -c < "${downloaded_file}" | tr -d '[:space:]')" + if [ "${actual_size}" != "${expected_size}" ]; then + echo "fixture size mismatch for ${display_name}: expected ${expected_size}, got ${actual_size}" >&2 + return 1 + fi + + actual_oid="$(sha256sum "${downloaded_file}" | awk '{ print $1 }')" + if [ "${actual_oid}" != "${expected_oid}" ]; then + echo "fixture SHA-256 mismatch for ${display_name}: expected ${expected_oid}, got ${actual_oid}" >&2 + return 1 + fi +} + +# Prints the fixture file paths of a trace case, one per line. Strips CR so the +# result is usable when python emits CRLF (Git Bash on Windows). +trace_fixture_files() { + local case_name="$1" + local fixture_dir="$2" + local python_bin="${3:-python3}" + + "${python_bin}" tools/trace_replay/trace_cases.py \ + --format fixture-files \ + --case "${case_name}" \ + --fixture-root "${fixture_dir}" | tr -d '\r' +} diff --git a/.github/workflows/apk.yml b/.github/workflows/apk.yml index fc9ffc85..4ec2e6af 100644 --- a/.github/workflows/apk.yml +++ b/.github/workflows/apk.yml @@ -201,9 +201,41 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 + - name: Derive trace fixture cache key + id: fixture-key + run: bash .github/scripts/trace-fixture-cache.sh key '${{ matrix.case }}' + + - name: Restore trace fixture cache + id: fixture-cache + if: steps.fixture-key.outputs.cacheable == 'true' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.fixture-key.outputs.paths }} + key: ${{ steps.fixture-key.outputs.key }} + + - name: Verify restored trace fixture + id: fixture-verify + if: steps.fixture-cache.outputs.cache-hit == 'true' + run: | + if bash .github/scripts/trace-fixture-cache.sh verify '${{ matrix.case }}'; then + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "ok=false" >> "$GITHUB_OUTPUT" + echo "::warning::Cached fixture for ${{ matrix.case }} failed verification; falling back to the download path" + bash .github/scripts/trace-fixture-cache.sh reset '${{ matrix.case }}' + fi + - name: Fetch trace fixture + if: steps.fixture-verify.outputs.ok != 'true' run: bash .github/scripts/fetch-trace-fixture-lfs.sh '${{ matrix.case }}' + - name: Save trace fixture cache + if: steps.fixture-key.outputs.cacheable == 'true' && steps.fixture-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ steps.fixture-key.outputs.paths }} + key: ${{ steps.fixture-key.outputs.key }} + - name: Stage trace fixture run: | safe_case="$(printf '%s' '${{ matrix.case }}' | sed 's/[^A-Za-z0-9._-]/_/g')" @@ -411,6 +443,24 @@ jobs: run_retrace || retrace_status=$? if [ "${retrace_status}" -eq 75 ]; then echo "::warning::Android emulator infrastructure failed; restarting it and retrying this retrace once." + # Surface-lost is retried rather than failed, so it would otherwise + # be invisible. Report it per job - a healthy run prints nothing and + # a rate spike shows up as a row per affected case. + reason_file="android-retrace-result/infrastructure-failure-reason.txt" + surface_lost_retries=0 + if [ -f "${reason_file}" ]; then + surface_lost_retries="$(grep -c 'angle-surface-lost' "${reason_file}" || true)" + fi + if [ "${surface_lost_retries}" -gt 0 ]; then + echo "surface-lost retries: ${surface_lost_retries} (${{ matrix.backend.name }}, ${{ matrix.case.name }})" \ + >> "${GITHUB_STEP_SUMMARY}" + fi + # The restart truncates EMULATOR_LOG, and the attempt that lost the + # emulator is the one worth reading - the retry usually only shows + # the wreckage. Keep the first attempt's log before it is clobbered. + if [ -f "${EMULATOR_LOG}" ]; then + cp "${EMULATOR_LOG}" "${EMULATOR_LOG}.first-attempt" || true + fi sh android-plugin/run-avd-ci.sh stop \ --avd-name "${AVD_NAME}" \ --emulator-log "${EMULATOR_LOG}" \ @@ -450,6 +500,13 @@ jobs: if [ -f "${EMULATOR_LOG}" ]; then cp "${EMULATOR_LOG}" android-retrace-result/diagnostics/emulator.log fi + if [ -f "${EMULATOR_LOG}.first-attempt" ]; then + cp "${EMULATOR_LOG}.first-attempt" android-retrace-result/diagnostics/emulator-first-attempt.log + fi + # A vanished emulator looks identical whether the host OOM killer took + # qemu or the renderer faulted. These two say which. + free -h > android-retrace-result/diagnostics/host-memory.txt 2>&1 || true + sudo dmesg -T 2>/dev/null | tail -300 > android-retrace-result/diagnostics/host-dmesg.txt || true - name: Stop Emulator if: always() @@ -531,22 +588,41 @@ jobs: ) if ((${#failed_cases[@]})); then - echo "Retaining fixtures for failed retrace case(s):" + echo "Retaining fixtures and results for failed retrace case(s):" printf ' %s\n' "${!failed_cases[@]}" else - echo "All retrace jobs succeeded; no fixtures need to be retained." + echo "All retrace jobs succeeded; nothing needs to be retained." fi deleted=0 retained=0 while IFS=$'\t' read -r artifact_id artifact_name; do + keep=0 if [[ "${artifact_name}" == MobileGL-trace-fixture-* ]]; then case_name="${artifact_name#MobileGL-trace-fixture-}" if [[ -v "failed_cases[${case_name}]" ]]; then - echo "Retaining ${artifact_name} (${artifact_id}) for failed retrace." - ((retained += 1)) - continue + keep=1 fi + elif [[ "${artifact_name}" == MobileGL-android-retrace-result-* ]]; then + # The result artifact carries mobilegl.log, retrace.log, logcat, + # the emulator log and the actual/diff images - the only record of + # why a retrace failed. Its name ends in --, so a + # suffix match on the case name keeps both backends' results for a + # case that failed on either of them, which is what a comparison + # needs. The match is anchored at the end, so a case name that is a + # prefix of a longer one does not retain the longer one's results. + for case_name in "${!failed_cases[@]}"; do + if [[ "${artifact_name}" == *-"${case_name}" ]]; then + keep=1 + break + fi + done + fi + + if ((keep)); then + echo "Retaining ${artifact_name} (${artifact_id}) for failed retrace." + ((retained += 1)) + continue fi echo "Deleting ${artifact_name} (${artifact_id})" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4be96a3c..210bb132 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -470,9 +470,41 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 + - name: Derive trace fixture cache key + id: fixture-key + run: bash .github/scripts/trace-fixture-cache.sh key '${{ matrix.case }}' + + - name: Restore trace fixture cache + id: fixture-cache + if: steps.fixture-key.outputs.cacheable == 'true' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.fixture-key.outputs.paths }} + key: ${{ steps.fixture-key.outputs.key }} + + - name: Verify restored trace fixture + id: fixture-verify + if: steps.fixture-cache.outputs.cache-hit == 'true' + run: | + if bash .github/scripts/trace-fixture-cache.sh verify '${{ matrix.case }}'; then + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "ok=false" >> "$GITHUB_OUTPUT" + echo "::warning::Cached fixture for ${{ matrix.case }} failed verification; falling back to the download path" + bash .github/scripts/trace-fixture-cache.sh reset '${{ matrix.case }}' + fi + - name: Fetch trace fixture + if: steps.fixture-verify.outputs.ok != 'true' run: bash .github/scripts/fetch-trace-fixture-lfs.sh '${{ matrix.case }}' + - name: Save trace fixture cache + if: steps.fixture-key.outputs.cacheable == 'true' && steps.fixture-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ steps.fixture-key.outputs.paths }} + key: ${{ steps.fixture-key.outputs.key }} + - name: Stage trace fixture run: | safe_case="$(printf '%s' '${{ matrix.case }}' | sed 's/[^A-Za-z0-9._-]/_/g')" diff --git a/.gitmodules b/.gitmodules index 5bebb9c2..3ae968ea 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,9 +7,6 @@ [submodule "3rdparty/SPIRV-Cross"] path = 3rdparty/SPIRV-Cross url = https://github.com/KhronosGroup/SPIRV-Cross.git -[submodule "include/FastSTL"] - path = include/FastSTL - url = https://github.com/MobileGL-Dev/FastSTL.git [submodule "3rdparty/tracy"] path = 3rdparty/tracy url = https://github.com/wolfpld/tracy.git @@ -34,3 +31,6 @@ [submodule "3rdparty/asio"] path = 3rdparty/asio url = https://github.com/chriskohlhoff/asio.git +[submodule "include/ska"] + path = include/ska + url = https://github.com/MobileGL-Dev/flat_hash_map.git diff --git a/3rdparty/glslang b/3rdparty/glslang index 900b29d4..6f125987 160000 --- a/3rdparty/glslang +++ b/3rdparty/glslang @@ -1 +1 @@ -Subproject commit 900b29d449a67d2a18b569f64dd46333575b352f +Subproject commit 6f12598784a553f61568eb7340cfcffd2a502317 diff --git a/CMakeLists.txt b/CMakeLists.txt index 58d3fe3d..7a84f51d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,81 @@ set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to if (ANDROID) set(MOBILEGL_BUILD_TEST OFF CACHE BOOL "Build MobileGL tests" FORCE) set(MOBILEGL_BUILD_BENCHMARK OFF CACHE BOOL "Build MobileGL benchmarks" FORCE) + + # ------- Android API level policy: minimum 26, decided here and only here ------- + # MobileGL ships against API 26: the codebase must not use any API introduced + # after 26. That usage constraint is enforced where it is real - the shipping + # gradle build compiles at minSdk 26, where a newer API is simply undeclared + # and fails to compile. Configuring at a HIGHER level is therefore allowed + # (nothing in the tree may rely on it), but a LOWER level would change the + # libc contract underneath the shipped library and is refused. + # + # This has to live at configure time because the level cannot be corrected + # from a source header. A `#define __ANDROID_API__ 26` in a common header + # only rewrites the macro for the bionic headers that happen to be included + # after it; any libc++ header pulled in earlier has already latched its + # feature macros at the real configure-time level. libc++ and bionic then + # disagree about which symbols exist - libc++ calls e.g. + # pthread_cond_clockwait while bionic, re-read at the lowered level, has + # hidden its declaration. MobileGL/Defines.h carried exactly that pin from + # the first commit until it was removed; this guard is what replaces it. + # + # Read the level back from the compiler target triple first. Its trailing + # number (aarch64-none-linux-android26) is precisely what clang turns into + # __ANDROID_API__, so it cannot disagree with the compile itself, and it is + # already past every NDK normalisation step - codename aliases, "latest", + # and per-ABI minimum pull-ups. ANDROID_PLATFORM_LEVEL is the fallback for + # generators/languages where the triple variable is not populated. + # + # Note CMAKE_SYSTEM_VERSION is deliberately NOT consulted: it holds the API + # level only under the NDK's newer toolchain path, and is a meaningless 1 + # when ANDROID_USE_LEGACY_TOOLCHAIN_FILE is on (which is what AGP has been + # defaulting to). Reading it would fail every legacy-mode build. + set(MOBILEGL_ANDROID_API_LEVEL 26) + + set(_mobilegl_android_api "") + foreach (_mobilegl_api_triple "${CMAKE_CXX_COMPILER_TARGET}" + "${CMAKE_C_COMPILER_TARGET}") + if (NOT _mobilegl_android_api AND + _mobilegl_api_triple MATCHES "-android([0-9]+)$") + set(_mobilegl_android_api "${CMAKE_MATCH_1}") + endif() + endforeach() + + foreach (_mobilegl_api_var ANDROID_PLATFORM_LEVEL ANDROID_NATIVE_API_LEVEL + ANDROID_PLATFORM) + if (NOT _mobilegl_android_api AND ${_mobilegl_api_var}) + string(REGEX REPLACE "^android-" "" + _mobilegl_android_api "${${_mobilegl_api_var}}") + endif() + endforeach() + + if (NOT _mobilegl_android_api MATCHES "^[0-9]+$") + message(FATAL_ERROR + "MobileGL: could not determine the Android API level (got " + "\"${_mobilegl_android_api}\"). Configure with the NDK toolchain " + "file and -DANDROID_PLATFORM=android-${MOBILEGL_ANDROID_API_LEVEL}.") + elseif (_mobilegl_android_api LESS MOBILEGL_ANDROID_API_LEVEL) + message(FATAL_ERROR + "MobileGL requires at least Android API ${MOBILEGL_ANDROID_API_LEVEL}, " + "but this build resolved to API ${_mobilegl_android_api}.\n" + "Configure with -DANDROID_PLATFORM=android-${MOBILEGL_ANDROID_API_LEVEL} " + "(gradle builds get this from minSdk ${MOBILEGL_ANDROID_API_LEVEL}, so " + "check that minSdk instead of adding an override).") + elseif (_mobilegl_android_api GREATER MOBILEGL_ANDROID_API_LEVEL) + message(STATUS + "MobileGL: configuring at Android API ${_mobilegl_android_api} " + "(> shipping minimum ${MOBILEGL_ANDROID_API_LEVEL}). Allowed, but the " + "tree must not use post-${MOBILEGL_ANDROID_API_LEVEL} APIs - the " + "minSdk-${MOBILEGL_ANDROID_API_LEVEL} gradle build is the enforcing " + "compile.") + endif() + + message(STATUS "MobileGL: Android API level ${_mobilegl_android_api}") + + unset(_mobilegl_android_api) + unset(_mobilegl_api_var) + unset(_mobilegl_api_triple) endif() option(MOBILEGL_ENABLE_LTO "Build with ThinLTO/IPO" OFF) @@ -210,6 +285,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp diff --git a/MobileGL/Defines.h b/MobileGL/Defines.h index 293c2ac7..a361c2a0 100644 --- a/MobileGL/Defines.h +++ b/MobileGL/Defines.h @@ -9,10 +9,20 @@ #pragma once // ============== Platform-specific definitions and macros ============== // -#ifdef __ANDROID__ -#undef __ANDROID_API__ -#define __ANDROID_API__ 26 // force Android API level to 26 for compatibility -#endif +// No __ANDROID_API__ pin here on purpose. The effective API level is owned by +// the build system (gradle minSdk 26 -> -DANDROID_PLATFORM=android-26, enforced +// by the configure-time guard in CMakeLists.txt), not by a macro. +// +// History: this used to `#define __ANDROID_API__ 26` to *raise* the level back +// when the build configured something lower, so that pthread_getname_np (which +// bionic guards with __INTRODUCED_IN(26)) would be declared. Once a later +// change added an `#undef` in front of it, the same line started *lowering* the +// level whenever the build configured higher than 26 - and that is an +// include-order split-brain, not a compatibility knob: a TU that includes any +// libc++ header before Includes.h latches libc++'s feature macros at the +// configure-time level, and only the bionic headers pulled in afterwards see +// the lowered value. The two halves then disagree (e.g. libc++ believes +// pthread_cond_clockwait exists while bionic has since hidden its declaration). #ifdef _WIN32 #ifndef NOMINMAX diff --git a/MobileGL/Includes.h b/MobileGL/Includes.h index d3a3eff5..50c3d9d6 100644 --- a/MobileGL/Includes.h +++ b/MobileGL/Includes.h @@ -49,8 +49,8 @@ #include #endif -// Include FastSTL -#include +// Include ska::flat_hash_map +#include // Include xxHash #include diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index f4b21ae4..4f333d4a 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -1151,6 +1151,16 @@ namespace MobileGL::MG_Backend::DirectGLES { m_dynamicParameters.MaxComputeUniformBlocks = m_GLESCapabilities.MaxComputeUniformBlocks; m_dynamicParameters.MaxComputeWorkGroupInvocations = m_GLESCapabilities.MaxComputeWorkGroupInvocations; m_dynamicParameters.MaxShaderStorageBufferBindings = m_GLESCapabilities.MaxShaderStorageBufferBindings; + // This is the number glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE) hands the application, and + // on a host without buffer textures it is knowingly a floor MobileGL cannot honour rather + // than a driver answer (m_GLESCapabilities.MaxTextureBufferSizeIsDriverReported says + // which). Reporting 0 instead was considered and rejected: MobileGL advertises an OpenGL + // 4.x context, where buffer textures are core and the limit has a spec minimum of 65536, + // so 0 is not a legal answer and applications are not written to survive it. GL offers no + // way to say "this core feature is missing", so the honesty is carried outside the limit: + // FillInGLESCapabilities logs the tier, glTexBuffer and the program build each name the + // missing capability at MGLOG_I, and the driver POST carries a "Buffer textures" row that + // FAILs on this tier. m_dynamicParameters.MaxTextureBufferSize = m_GLESCapabilities.MaxTextureBufferSize; m_dynamicParameters.TextureBufferOffsetAlignment = m_GLESCapabilities.TextureBufferOffsetAlignment; m_dynamicParameters.MaxUniformBufferBindings = m_GLESCapabilities.MaxUniformBufferBindings; diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 85eec084..c07a7e2a 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -605,10 +605,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // Cached address of g_xfbObjects[g_currentXfbName]: PrepareForDraw consults // CurrentXfb on EVERY draw (StartPendingTransformFeedback) and the map // lookup was pure per-draw overhead for the overwhelmingly common no-capture - // case. FastSTL's open addressing keeps values in the bucket array, so ANY - // insert can rehash and move them (and erase/clear can too): every site that - // mutates the map or rebinds the current name resets this to null instead of - // reasoning about stability, and CurrentXfb re-resolves lazily. + // case. Open addressing keeps values in the bucket array, so ANY insert can + // rehash and move them - and erase moves them too, by shifting the rest of the + // probe cluster into the hole, which reaches entries other than the erased one. + // Every site that mutates the map or rebinds the current name resets this to + // null instead of reasoning about stability, and CurrentXfb re-resolves lazily. XfbObjectState* g_currentXfbState = nullptr; XfbObjectState& CurrentXfb() { @@ -883,7 +884,7 @@ namespace MobileGL::MG_Backend::DirectGLES { if (it->second.esId != 0 && g_GLESFuncs.glDeleteTransformFeedbacks != nullptr) { g_GLESFuncs.glDeleteTransformFeedbacks(1, &it->second.esId); } - g_currentXfbState = nullptr; // erase can move values (open addressing) + g_currentXfbState = nullptr; // erase shifts the probe cluster, moving other entries g_xfbObjects.erase(it); // The frontend reverts to the default object when the bound one is deleted. if (g_currentXfbName == name) { @@ -1214,7 +1215,7 @@ namespace MobileGL::MG_Backend::DirectGLES { if (g_unitTextureSyncListValid && g_unitTextureSyncListContextId == keys.contextId && g_unitTextureSyncListMaxUnit == maxTouchedUnit && - g_unitTextureSyncListContextGeneration == g_textureContextGeneration && + g_unitTextureSyncListContextGeneration == g_backendContextGeneration && g_unitTextureSyncListEpoch == unitBindingsEpoch && g_unitTextureSyncListSamplingGeneration == samplingGeneration && PairingsIntact(g_unitTextureSyncList)) { @@ -1246,7 +1247,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_unitTextureSyncListContextId = keys.contextId; g_unitTextureSyncListMaxUnit = maxTouchedUnit; - g_unitTextureSyncListContextGeneration = g_textureContextGeneration; + g_unitTextureSyncListContextGeneration = g_backendContextGeneration; g_unitTextureSyncListEpoch = unitBindingsEpoch; g_unitTextureSyncListSamplingGeneration = samplingGeneration; g_unitTextureSyncListValid = true; @@ -1274,7 +1275,7 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fboTextureSyncListSlotVersion == fboSlotVersion && g_fboTextureSyncListObjectVersion == fboObjectVersion && g_fboTextureSyncListContextId == keys.contextId && - g_fboTextureSyncListContextGeneration == g_textureContextGeneration && + g_fboTextureSyncListContextGeneration == g_backendContextGeneration && PairingsIntact(g_fboTextureSyncList); if (fboListValid) { for (const auto& entry : g_fboTextureSyncList) { @@ -1302,7 +1303,7 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fboTextureSyncListSlotVersion = fboSlotVersion; g_fboTextureSyncListObjectVersion = fboObjectVersion; g_fboTextureSyncListContextId = keys.contextId; - g_fboTextureSyncListContextGeneration = g_textureContextGeneration; + g_fboTextureSyncListContextGeneration = g_backendContextGeneration; } } else { g_fboTextureSyncListFbo = nullptr; @@ -2077,11 +2078,30 @@ namespace MobileGL::MG_Backend::DirectGLES { // A link-version mismatch means the program was relinked: the backend // shaders and every cache built by CacheResourceLocations (block // indices, sampler locations, UBO upload gate) are stale. + // + // The storage-block signature is the same shape of condition: ES cannot move a + // storage block's binding after link, so glShaderStorageBlockBinding is honoured by + // baking the effective binding into the generated ESSL - which makes a program built + // against a different override set stale. It is compared HERE rather than acted on in + // the entry point because that one must never trigger a build (see + // ShaderStorageBlockBinding below). The signature is over the values, so an + // application that re-sets the same bindings every frame rebuilds nothing. + // + // The image-unit generation is a third of the same shape, and it used to be + // carried by accident: glUniform1i on an image uniform bumped the program's backend + // state version, which was in the program-pipeline composite's cache key, so a + // pipeline draw got a whole NEW composite object and therefore a fresh twin. Keying + // that cache on the link version instead (ProgramPipelineObject) removed the + // accident - and it never covered the monolithic glUseProgram path at all - so the + // dependency is stated here instead. if (!twin->GetBackendProgramId() || twin->GetSyncedLinkVersion() != currentProgram->GetLinkVersion() || + twin->GetSyncedImageUnitVersion() != currentProgram->GetImageUnitVersion() || twin->GetSnormFallbackClampOutputMask() != g_snormFallbackClampOutputMask || twin->GetUnormFallbackClampOutputMask() != g_unormFallbackClampOutputMask || - twin->GetFragColorBroadcastCount() != g_fragColorBroadcastCount) { + twin->GetFragColorBroadcastCount() != g_fragColorBroadcastCount || + twin->GetShaderStorageBlockBindingSignature() != + ComputeShaderStorageBlockBindingSignature(*currentProgram)) { twin->SyncToBackend(currentProgram); } g_currentDrawFrontendProgram = currentProgram.get(); @@ -2423,7 +2443,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(maxTouchedUnit + 1) * sizeof(SamplerImpl::g_boundSamplersCache[0]); if (g_unitSamplerWalkValid && g_unitSamplerWalkContextId == keys.contextId && g_unitSamplerWalkEpoch == keys.unitBindingsEpoch && g_unitSamplerWalkMaxUnit == maxTouchedUnit && - g_unitSamplerWalkContextGeneration == TextureImpl::g_textureContextGeneration && + g_unitSamplerWalkContextGeneration == g_backendContextGeneration && std::memcmp(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes) == 0) { return; } @@ -2444,7 +2464,7 @@ namespace MobileGL::MG_Backend::DirectGLES { g_unitSamplerWalkContextId = keys.contextId; g_unitSamplerWalkEpoch = keys.unitBindingsEpoch; g_unitSamplerWalkMaxUnit = maxTouchedUnit; - g_unitSamplerWalkContextGeneration = TextureImpl::g_textureContextGeneration; + g_unitSamplerWalkContextGeneration = g_backendContextGeneration; std::memcpy(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes); g_unitSamplerWalkValid = true; } @@ -2554,7 +2574,7 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.programBackendStateVersion == (currentProgram ? currentProgram->GetBackendStateVersion() : 0) && memo.programLinked == (currentProgram && currentProgram->GetLinkStatus()) && - memo.contextGeneration == TextureImpl::g_textureContextGeneration; + memo.contextGeneration == g_backendContextGeneration; // Short-circuited: the shadow compare is only meaningful once the key (and with it the // snapshotted row count) matches. if (!keysMatch || std::memcmp(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), @@ -2569,7 +2589,7 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0; memo.programBackendStateVersion = currentProgram ? currentProgram->GetBackendStateVersion() : 0; memo.programLinked = currentProgram && currentProgram->GetLinkStatus(); - memo.contextGeneration = TextureImpl::g_textureContextGeneration; + memo.contextGeneration = g_backendContextGeneration; std::memcpy(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), shadowBytes); memo.valid = true; } @@ -2744,7 +2764,7 @@ namespace MobileGL::MG_Backend::DirectGLES { samplerPassMemo.unitBindingsEpoch == keys.unitBindingsEpoch && samplerPassMemo.samplingGeneration == keys.samplingGeneration && samplerPassMemo.backendStateVersion == programBackendStateVersion && - samplerPassMemo.textureContextGeneration == TextureImpl::g_textureContextGeneration; + samplerPassMemo.textureContextGeneration == g_backendContextGeneration; if (samplerPassClean) { for (Uint i = 0; i < samplerPassMemo.count; ++i) { if (SamplerImpl::g_boundSamplersCache[samplerPassMemo.units[i]] != @@ -2843,7 +2863,7 @@ namespace MobileGL::MG_Backend::DirectGLES { samplerPassMemo.unitBindingsEpoch = keys.unitBindingsEpoch; samplerPassMemo.samplingGeneration = keys.samplingGeneration; samplerPassMemo.backendStateVersion = programBackendStateVersion; - samplerPassMemo.textureContextGeneration = TextureImpl::g_textureContextGeneration; + samplerPassMemo.textureContextGeneration = g_backendContextGeneration; samplerPassMemo.valid = true; } } @@ -3010,8 +3030,10 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif // Single per-dispatch program resolve and texture-key capture, as in - // PrepareForDraw (nothing below can move either). - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); + // PrepareForDraw (nothing below can move either). The DISPATCH accessor: with a + // pipeline bound this is its compute stage program, which is a whole program on its + // own - the graphics composite a draw builds carries no compute stage. + const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch(); const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys(); BufferImpl::SyncComputeBuffers(includeDispatchIndirectBuffer); @@ -3605,12 +3627,12 @@ namespace MobileGL::MG_Backend::DirectGLES { return false; } - if (s_resolveContextGeneration != TextureImpl::g_textureContextGeneration) { + if (s_resolveContextGeneration != g_backendContextGeneration) { // The ids belonged to a dead context; the context reclaimed them with it. s_resolveFramebuffer = 0; s_resolveRenderbuffer = 0; s_resolveFormat = 0; - s_resolveContextGeneration = TextureImpl::g_textureContextGeneration; + s_resolveContextGeneration = g_backendContextGeneration; } if (s_resolveFramebuffer == 0) { g_GLESFuncs.glGenFramebuffers(1, &s_resolveFramebuffer); @@ -3758,7 +3780,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } static Bool EnsureResources() { - if (s_contextGeneration != TextureImpl::g_textureContextGeneration) { + if (s_contextGeneration != g_backendContextGeneration) { // The ids belonged to a dead context; the context reclaimed them with it. s_framebuffer = 0; s_texture = 0; @@ -3769,7 +3791,7 @@ namespace MobileGL::MG_Backend::DirectGLES { s_depthProgram = 0; s_stencilProgram = 0; s_programsFailed = false; - s_contextGeneration = TextureImpl::g_textureContextGeneration; + s_contextGeneration = g_backendContextGeneration; } if (s_programsFailed) { return false; @@ -5151,8 +5173,19 @@ namespace MobileGL::MG_Backend::DirectGLES { const SharedPtr& dstTexture, GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ, GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) { - auto& srcBackendTexture = TextureImpl::SyncTextureObjectToBackend(srcTexture); - auto& dstBackendTexture = TextureImpl::SyncTextureObjectToBackend(dstTexture); + // BY VALUE, not by reference. SyncTextureObjectToBackend hands back a reference to a + // slot inside the backend texture registry, and the second call mutates that very map: + // GetOrCreate indexes it (an insert relocates entries - by rehashing, and also by + // robin-hood displacement well under the load factor), and Find drops any + // entry whose state object has expired - which, with the map open-addressed and erasing + // by shifting the probe cluster backwards, relocates entries other than the erased one. + // Either way a reference taken by the first call is stale by the time the second returns, + // and it is read four more times below. Copying the SharedPtr costs two refcount bumps on + // a path that is already doing a texture copy. + const SharedPtr srcBackendTexture = + TextureImpl::SyncTextureObjectToBackend(srcTexture); + const SharedPtr dstBackendTexture = + TextureImpl::SyncTextureObjectToBackend(dstTexture); const Bool srcIsDepth = MG_Util::IsDepthFormatInternalFormat(srcTexture->GetFormat()); const Bool dstIsDepth = MG_Util::IsDepthFormatInternalFormat(dstTexture->GetFormat()); @@ -7200,6 +7233,83 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glGetQueryObjectui64vEXT; } + namespace { + // The entry point the resolved tier's support ships, or null when there is none. + MG_External::GLES::glTexBuffer_PTR ResolveTexBufferEntryPoint() { + using Tier = MG_External::GLESCapabilities::TextureBufferTier; + switch (g_GLESCapabilities.TextureBufferSupport) { + case Tier::ExtensionEXT: + return g_GLESFuncs.glTexBufferEXT ? g_GLESFuncs.glTexBufferEXT : g_GLESFuncs.glTexBuffer; + case Tier::ExtensionOES: + return g_GLESFuncs.glTexBufferOES ? g_GLESFuncs.glTexBufferOES : g_GLESFuncs.glTexBuffer; + case Tier::CoreEs32: + return g_GLESFuncs.glTexBuffer; + case Tier::None: + default: + return nullptr; + } + } + + MG_External::GLES::glTexBufferRange_PTR ResolveTexBufferRangeEntryPoint() { + using Tier = MG_External::GLESCapabilities::TextureBufferTier; + switch (g_GLESCapabilities.TextureBufferSupport) { + case Tier::ExtensionEXT: + return g_GLESFuncs.glTexBufferRangeEXT ? g_GLESFuncs.glTexBufferRangeEXT + : g_GLESFuncs.glTexBufferRange; + case Tier::ExtensionOES: + return g_GLESFuncs.glTexBufferRangeOES ? g_GLESFuncs.glTexBufferRangeOES + : g_GLESFuncs.glTexBufferRange; + case Tier::CoreEs32: + return g_GLESFuncs.glTexBufferRange; + case Tier::None: + default: + return nullptr; + } + } + } // namespace + + Bool AreBufferTexturesSupported() { + // Both halves matter. The tier is what the driver ADVERTISES, and it is only meaningful + // once the capabilities have been filled in; the resolved pointer is what MobileGL can + // actually call, through the spelling that tier's support ships. Gating on the + // unsuffixed name alone would call an entry point an EXT/OES driver never exported. + return g_GLESCapabilities.TextureBufferSupport != + MG_External::GLESCapabilities::TextureBufferTier::None && + ResolveTexBufferEntryPoint() != nullptr; + } + + void CallTexBuffer(GLenum target, GLenum internalFormat, GLuint buffer) { + MG_External::GLES::glTexBuffer_PTR entryPoint = ResolveTexBufferEntryPoint(); + if (entryPoint == nullptr) { + return; + } + entryPoint(target, internalFormat, buffer); + } + + Bool CallTexBufferRange(GLenum target, GLenum internalFormat, GLuint buffer, GLintptr offset, GLsizeiptr size) { + MG_External::GLES::glTexBufferRange_PTR entryPoint = ResolveTexBufferRangeEntryPoint(); + if (entryPoint == nullptr) { + return false; + } + entryPoint(target, internalFormat, buffer, offset, size); + return true; + } + + const char* GetBufferTextureTierName() { + using Tier = MG_External::GLESCapabilities::TextureBufferTier; + switch (g_GLESCapabilities.TextureBufferSupport) { + case Tier::CoreEs32: + return "core (ES 3.2)"; + case Tier::ExtensionEXT: + return "GL_EXT_texture_buffer"; + case Tier::ExtensionOES: + return "GL_OES_texture_buffer"; + case Tier::None: + default: + return "unsupported"; + } + } + BackendQueryHandle BeginTimeElapsedQuery() { // Query objects can only be created on the thread that owns the ES // context (MC's F3 profiler queries on the render thread, which @@ -7482,7 +7592,7 @@ namespace MobileGL::MG_Backend::DirectGLES { PixelStoreImpl::InvalidatePackStateCache(); // Texture ids belong to the dying context; wrappers destroyed later must // not glDeleteTextures a recycled name in a successor context. - ++TextureImpl::g_textureContextGeneration; + ++g_backendContextGeneration; g_backendContextOwnerThread.store(std::thread::id{}, std::memory_order_release); // Outstanding fence handles now refer to a dead context; treat them as // signaled from here on. diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h index 9207f9a7..bff29e7b 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h @@ -117,6 +117,24 @@ namespace MobileGL::MG_Backend::DirectGLES { // capability read needs no current ES context, and it stays false until // the ES capabilities have been filled in. Bool AreTimerQueriesSupported(); + // True when the host ES driver can back a GL_TEXTURE_BUFFER at all - ES 3.2 core, or + // EXT/OES_texture_buffer, with glTexBuffer resolved. Desktop GL has had buffer textures as + // core since 3.1, so the frontend advertises them unconditionally and an app may call + // glTexBuffer whenever it likes; this is the only thing standing between that call and a + // null entry point. False also means every shader declaring a samplerBuffer is + // uncompilable on this driver, which the program build reports by name. + Bool AreBufferTexturesSupported(); + // Human-readable name of the buffer-texture tier for diagnostics and the driver POST: + // "core (ES 3.2)", "GL_EXT_texture_buffer", "GL_OES_texture_buffer" or "unsupported". + const char* GetBufferTextureTierName(); + // glTexBuffer / glTexBufferRange through whichever spelling this driver's buffer-texture + // support actually ships: the unsuffixed names are ES 3.2 core, while an EXT/OES driver + // exports glTexBuffer{,Range}EXT / OES. Callers must have checked + // AreBufferTexturesSupported() first. CallTexBufferRange reports whether it could honour + // the range - no tier is required to expose the range form, and the whole-buffer form is + // the documented fallback. + void CallTexBuffer(GLenum target, GLenum internalFormat, GLuint buffer); + Bool CallTexBufferRange(GLenum target, GLenum internalFormat, GLuint buffer, GLintptr offset, GLsizeiptr size); // GL timer-query objects, backed by GL_EXT_disjoint_timer_query. The // creators return null (the frontend then falls back to an immediately // available zero result) when the calling thread does not own the ES diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index ac4335e5..bb868dc6 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -33,6 +33,8 @@ #include namespace MobileGL::MG_Backend::DirectGLES { + Uint g_backendContextGeneration = 1; + constexpr Bool PREFER_MAP_BUFFER_RANGE_FOR_BUFFER_SYNC = false; constexpr const char* BASE_INSTANCE_UNIFORM_NAME = "mg_BaseInstance"; constexpr const char* DRAW_ID_UNIFORM_NAME = "mg_DrawID"; @@ -1646,7 +1648,7 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif g_GLESFuncs.glGenTextures(1, &m_backendTextureId); - m_contextGeneration = g_textureContextGeneration; + m_contextGeneration = g_backendContextGeneration; if (m_backendTextureId == 0) { MGLOG_E("Failed to generate texture object."); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); @@ -1673,7 +1675,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } } } - if (m_contextGeneration == g_textureContextGeneration && g_GLESFuncs.glDeleteTextures) { + if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures) { g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId); } m_backendTextureId = 0; @@ -1712,7 +1714,7 @@ namespace MobileGL::MG_Backend::DirectGLES { void BackendTextureObject::RecreateBackendTexture() { if (m_backendTextureId != 0) { ScratchFBOImpl::NoteTextureIdDeleted(m_backendTextureId); - if (m_contextGeneration == g_textureContextGeneration) { + if (m_contextGeneration == g_backendContextGeneration) { g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId); } for (auto& unitCache : g_boundTexturesCache) { @@ -1725,7 +1727,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_GLESFuncs.glGenTextures(1, &m_backendTextureId); - m_contextGeneration = g_textureContextGeneration; + m_contextGeneration = g_backendContextGeneration; if (m_backendTextureId == 0) { MGLOG_E("Failed to regenerate texture object."); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); @@ -2777,6 +2779,29 @@ namespace MobileGL::MG_Backend::DirectGLES { &glType, TextureTarget::TextureBuffer); if (needsRegeneration) { + // Desktop GL has had buffer textures core since 3.1 and MobileGL advertises a + // 4.x context, so glTexBuffer is a legal call the app may make on any driver - + // but ES only gained them in 3.2, and g_GLESFuncs.glTexBuffer is simply null + // below that without EXT/OES_texture_buffer. Calling it was an unconditional + // null dereference. There is no conformant way to refuse the call (it is valid + // in the context MobileGL claims), so the texture is left unbacked and the + // reason is stated once per respecify at a level that survives the shipped + // INFO build - MGLOG_E is compiled out there, which is exactly how this class + // of defect stays invisible. + if (!AreBufferTexturesSupported()) { + if (m_bufferTextureUnsupportedReported) { + break; + } + m_bufferTextureUnsupportedReported = true; + MGLOG_I("Texture buffer %u cannot be backed: this ES driver has no buffer " + "textures (%s). Every draw sampling it will read zero and every " + "shader declaring a samplerBuffer will fail to compile. MobileGL " + "still advertises GL_MAX_TEXTURE_BUFFER_SIZE = %d because an " + "OpenGL 4.x context may not report 0.", + stateTextureObject->GetExternalIndex(), GetBufferTextureTierName(), + g_GLESCapabilities.MaxTextureBufferSize); + break; + } MGLOG_D("Texture state changed significantly or not initialized, regenerating texture buffer with " "ID: %u, buffer ID: %u, buffer size: %zu, format: %s", m_backendTextureId, backendId, buffer->GetSize(), @@ -2787,17 +2812,19 @@ namespace MobileGL::MG_Backend::DirectGLES { // is absent). const SizeT rangeOffset = textureBufferObject->GetBufferRangeOffset(); const SizeT rangeSize = textureBufferObject->GetBufferRangeSizeInBytes(); + // Through CallTexBuffer/CallTexBufferRange rather than g_GLESFuncs directly: + // the unsuffixed entry points are the ES 3.2 core spelling, and a driver + // whose buffer textures come from EXT/OES_texture_buffer exports the + // suffixed ones instead. The dispatchers pick whichever this tier ships. if (rangeOffset == 0 && rangeSize == buffer->GetSize()) { - g_GLESFuncs.glTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId); - } else if (g_GLESFuncs.glTexBufferRange != nullptr) { - g_GLESFuncs.glTexBufferRange(GL_TEXTURE_BUFFER, glInternalFormat, backendId, - static_cast(rangeOffset), - static_cast(rangeSize)); - } else { - MGLOG_E("Texture buffer %u names a sub-range but the driver has no " + CallTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId); + } else if (!CallTexBufferRange(GL_TEXTURE_BUFFER, glInternalFormat, backendId, + static_cast(rangeOffset), + static_cast(rangeSize))) { + MGLOG_I("Texture buffer %u names a sub-range but the driver has no " "glTexBufferRange; binding the whole buffer instead", stateTextureObject->GetExternalIndex()); - g_GLESFuncs.glTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId); + CallTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId); } DebugImpl::ErrorLopper::Loop( [file = __FILE__, line = __LINE__, func = __func__, glInternalFormat, backendId](GLenum err) { @@ -2809,7 +2836,14 @@ namespace MobileGL::MG_Backend::DirectGLES { break; } default: - THROW_UNIMPL_EXCEPTION; + // TextureStorageType is {Mipmap, Buffer}, both handled above, so this is a + // backstop for a state object that grew a new storage kind. Skipping the upload + // renders wrong; throwing unwinds through the C GL ABI and kills the process. + MGLOG_I("DirectGLES texture sync: no upload path for storage type %d on texture %u; " + "skipping this sync", + static_cast(stateTextureObject->GetStorageType()), + stateTextureObject->GetExternalIndex()); + break; } DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__](GLenum err) { @@ -3074,7 +3108,6 @@ namespace MobileGL::MG_Backend::DirectGLES { } Uint g_activeTextureUnit = 0; - Uint g_textureContextGeneration = 1; Array, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> g_boundTexturesCache; @@ -3093,6 +3126,7 @@ namespace MobileGL::MG_Backend::DirectGLES { m_backendColorSlots[i] = GL_COLOR_ATTACHMENT0 + i; } g_GLESFuncs.glGenFramebuffers(1, &m_backendFBOId); + m_contextGeneration = g_backendContextGeneration; if (m_backendFBOId == 0) { MGLOG_E("Failed to generate framebuffer object."); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); @@ -3101,6 +3135,22 @@ namespace MobileGL::MG_Backend::DirectGLES { } } + BackendFramebufferObject::~BackendFramebufferObject() { + if (InProcessTeardown()) { + return; // see InProcessTeardown(): the driver may be unloaded already + } + if (m_backendFBOId == 0) { + return; + } + // Scrub the binding shadow whether or not the id can still be deleted: a + // recycled name must never satisfy the shadow's dedup. + NoteFramebufferIdDeleted(m_backendFBOId); + if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteFramebuffers) { + g_GLESFuncs.glDeleteFramebuffers(1, &m_backendFBOId); + } + m_backendFBOId = 0; + } + void BackendFramebufferObject::Bind(FramebufferTarget target) const { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); @@ -3156,6 +3206,17 @@ namespace MobileGL::MG_Backend::DirectGLES { return g_driverFBOBindings[idx]; } + void NoteFramebufferIdDeleted(Uint id) { + if (id == 0) { + return; + } + for (SizeT idx = 0; idx < g_driverFBOBindings.size(); ++idx) { + if (g_driverFBOBindingKnown[idx] && g_driverFBOBindings[idx] == id) { + g_driverFBOBindings[idx] = 0; // glDeleteFramebuffers reverts a bound FBO to 0 + } + } + } + void InvalidateFramebufferBindingCache() { g_driverFBOBindings = {0, 0}; g_driverFBOBindingKnown = {false, false}; @@ -4146,6 +4207,31 @@ namespace MobileGL::MG_Backend::DirectGLES { } } + Uint64 ComputeShaderStorageBlockBindingSignature( + const MG_State::GLState::ProgramObject& stateProgramObject) { + const auto& overrides = stateProgramObject.GetShaderStorageBlockBindingOverrides(); + if (overrides.empty()) return 0; // the overwhelming majority of programs + // Order-independent on purpose: the source is an UnorderedMap, so any signature that + // depended on iteration order would differ between two identical override sets and + // rebuild the program for nothing. + // + // Built from the VALUES, not from a change counter, so re-setting a block to the + // binding it already carries produces the same signature and forces no rebuild - an + // application that calls glShaderStorageBlockBinding every frame with unchanged + // arguments must not retranspile every frame. + Uint64 signature = 0; + for (const auto& [blockName, binding] : overrides) { + if (binding < 0) continue; // never rebound; the declared qualifier still stands + Uint64 entry = std::hash{}(blockName); + // Mixed rather than merely summed with the name hash: name and binding must not + // be able to trade places between two entries and cancel out. + entry ^= (static_cast(static_cast(binding)) + 0x9e3779b97f4a7c15ull + + (entry << 6) + (entry >> 2)); + signature += entry; // commutative combine + } + return signature; + } + void BackendProgramObjectImpl::SyncToBackend( const SharedPtr& stateProgramObject) { #ifdef TRACY_ENABLE @@ -4155,6 +4241,9 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E("State program object is null, skipping backend sync."); return; } + // Recorded before either early return below, so Use() can always name the GL + // program a no-op draw belongs to - including the "linked but not drawable" exit. + m_frontendProgramId = stateProgramObject->GetExternalIndex(); // GetSpirvStatus() as well as GetLinkStatus(): a program whose phase-B job was // cancelled (teardown) or whose optimizer run failed is fully linked and fully @@ -4178,6 +4267,11 @@ namespace MobileGL::MG_Backend::DirectGLES { m_snormFallbackClampOutputMask = g_snormFallbackClampOutputMask; m_unormFallbackClampOutputMask = g_unormFallbackClampOutputMask; m_fragColorBroadcastCount = g_fragColorBroadcastCount; + // The generated ESSL bakes these in (see the SetShaderStorageBlockBinding call in the + // transpile loop below), so the set they were generated against is part of what makes + // this build current - the draw path compares the signature and rebuilds on a change. + const auto& storageBlockBindingOverrides = stateProgramObject->GetShaderStorageBlockBindingOverrides(); + m_shaderStorageBlockBindingSignature = ComputeShaderStorageBlockBindingSignature(*stateProgramObject); // Detach all existing shaders GLint attachedCount = 0; @@ -4228,6 +4322,26 @@ namespace MobileGL::MG_Backend::DirectGLES { String source; auto& spirvCode = shaderSpirvs[index]; + // A samplerBuffer is core in the OpenGL 3.1+ context MobileGL advertises but needs + // ES 3.2 or EXT/OES_texture_buffer on the host. Without it SPIRV-Cross emits + // `#extension GL_EXT_texture_buffer : require` and the driver rejects both that + // and the isamplerBuffer keyword - the program never links and every draw using it + // becomes a silent no-op. Say so here, naming the stage, instead of leaving a + // driver info log the shipped INFO build compiles out (MGLOG_E is inactive there). + // Gated on the capability so the module walk never runs on a healthy driver. + if (!AreBufferTexturesSupported() && + MG_Util::ShaderTranspiler::ShaderCompiler::ModuleDeclaresBufferTextureSampler(spirvCode)) { + MGLOG_I("Program %u stage %s samples a buffer texture, which this ES driver " + "cannot provide (%s). The shader will not compile and the program will " + "not link; every draw using it is a no-op.", + m_backendProgramId, + MG_Util::ConvertGLEnumToString(glShaderType).c_str(), + GetBufferTextureTierName()); + m_backendProgramUsable = false; + g_GLESFuncs.glDeleteShader(backendShaderId); + continue; + } + // ESSL cannot express gl_DrawID/gl_BaseInstance/gl_BaseVertex; demote them to // plain globals (mg_*) before handing the module to SPIRV-Cross. Vector loweredSpirv; @@ -4277,6 +4391,22 @@ namespace MobileGL::MG_Backend::DirectGLES { effectiveSpirv = &rectLoweredSpirv; } + // GLSL ES demands a constant integral expression to index a fragment output + // array; SPIR-V does not, so a shader that writes coeff[i] from a loop + // reaches SPIRV-Cross intact and comes out as ESSL a strict driver rejects + // outright ("array indexes for fragment outputs must be constant integral + // expressions"), linking no program and silently no-oping every draw that + // uses it. Mesa accepts it, ANGLE does not - which is the whole of the + // improved-transparency-minecraft-26.3 failure. Fold or lower the index here, + // on the ESSL path only: the same module is legal for DirectVulkan. + Vector outputIndexSpirv; + if (glShaderType == GL_FRAGMENT_SHADER && + MG_Util::ShaderTranspiler::ShaderCompiler::LegalizeFragmentOutputIndexingForEssl( + *effectiveSpirv, outputIndexSpirv) && + !outputIndexSpirv.empty()) { + effectiveSpirv = &outputIndexSpirv; + } + MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv, MG_Util::ShaderTranspiler::SessionUsageBit::Transpile); @@ -4290,6 +4420,15 @@ namespace MobileGL::MG_Backend::DirectGLES { spvcSession.SetOptions(options); + // ES fixes a storage block's binding at link from its layout(binding=) qualifier + // and has no glShaderStorageBlockBinding to move it afterwards, so a rebinding + // can only be honoured by printing it INTO the qualifier. Rewriting the Binding + // decoration before SPIRV-Cross emits is what does that; RemoveLayoutBinding + // then deliberately preserves the qualifier for `buffer` declarations. + if (!storageBlockBindingOverrides.empty()) { // empty for almost every program + spvcSession.SetShaderStorageBlockBinding(storageBlockBindingOverrides); + } + const char* result = nullptr; spvcSession.Compile(&result); @@ -4305,7 +4444,24 @@ namespace MobileGL::MG_Backend::DirectGLES { source = result; + // Position in the chain is arbitrary: this is the only header-level rewrite, it + // edits #extension directives and never the body, and the replacement is the + // same length and stays an #extension line - so it commutes with every pass + // below, including ForceSupporterOutput's scan for the last directive. First, + // because a header concern reads better before the body ones. + source = RetargetTextureBufferExtension(std::move(source), + g_GLESCapabilities.TextureBufferSupport); + source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject); + // Wedged between those two on purpose: + // * AFTER RebindImageUniformsToFrontendUnits, so the binding it copies onto + // both halves of a split image is already the frontend texture unit (and so + // that pass never has to reason about the alias it introduces); + // * BEFORE RemoveLayoutBinding, whose keepBindingRegex recognises an image + // declaration and preserves its binding - an image unit cannot be set from + // the API in ES, so the qualifier is the only binding mechanism there is, + // and both halves of the pair have to still be carrying theirs when it runs. + source = SplitReadWriteImageUniforms(source); source = RemoveLayoutBinding(source); source = ProcessOutColorLocations(source); source = ForceFlatIntegerVaryings(source, glShaderType); @@ -4348,13 +4504,41 @@ namespace MobileGL::MG_Backend::DirectGLES { Vector log(static_cast(logLength) + 1, '\0'); g_GLESFuncs.glGetShaderInfoLog(backendShaderId, logLength, nullptr, log.data()); log.back() = '\0'; - MGLOG_E("Shader compilation failed for backend ID %u: %s", backendShaderId, log.data()); + // MGLOG_I, deliberately. Every CI, retrace and release build compiles at + // MOBILEGL_LOG_LEVEL_INFO, where MGLOG_E and MGLOG_W expand to nothing + // (Log.h orders DEBUG < WARN < ERROR < INFO), so this diagnostic used to + // exist only in debug builds: the Android retrace artifact carried 294 + // INFO lines and zero ERROR lines while two generated shaders were being + // rejected outright, and the lane could not say why it was rendering an + // empty translucent layer. A shader the driver refuses is never noise. + MGLOG_I("Shader compilation failed. State program ID: %u, stage: %s, backend shader ID: " + "%u, driver log: %s", + stateProgramObject->GetExternalIndex(), + MG_Util::ConvertGLEnumToString(glShaderType).c_str(), backendShaderId, + log.data()); m_backendProgramUsable = false; + // Nothing will ever attach this one, so nothing else can free it. + g_GLESFuncs.glDeleteShader(backendShaderId); continue; } MGLOG_D("Attaching shader ID: %u to program %u", backendShaderId, m_backendProgramId); g_GLESFuncs.glAttachShader(m_backendProgramId, backendShaderId); + // Hand the shader's lifetime to the program, immediately and unconditionally. + // + // glDeleteShader only FLAGS a shader; the driver frees it when it is attached to + // nothing. Flagging it here is what makes the program own it, so deleting the + // program (or the detach loop above, on a relink) is what actually frees it. + // Without this call every program build leaked its shader objects for the process + // lifetime, and a relink leaked them twice - the detach loop above dropped the + // program's reference to shaders nothing had flagged, so they became unreachable + // AND undeletable. The GL swizzle conformance test builds 1,296 programs per case, + // so a handful of cases left tens of thousands of live driver shaders behind and + // the driver started mis-serving them (KHR-GL33/GL40.texture_swizzle.smoke_*). + // Same class of defect as the missing framebuffer/renderbuffer/sampler destructors + // fixed in Wave 1, and the last of that family: this is the one backend GL object + // MobileGL creates without an owning wrapper to destroy it. + g_GLESFuncs.glDeleteShader(backendShaderId); MGLOG_D("Processed shader source length: %zu", source.length()); } @@ -4393,8 +4577,11 @@ namespace MobileGL::MG_Backend::DirectGLES { Vector log(static_cast(logLength) + 1, '\0'); g_GLESFuncs.glGetProgramInfoLog(m_backendProgramId, logLength, nullptr, log.data()); log.back() = '\0'; - MGLOG_E("Program %u linking failed for %u: %s", stateProgramObject->GetExternalIndex(), - m_backendProgramId, log.data()); + // MGLOG_I for the same reason as the compile failure above: a program that + // links nothing no-ops every draw that uses it, and that has to be readable + // in an INFO-level artifact. + MGLOG_I("Program linking failed. State program ID: %u, backend program ID: %u, driver log: %s", + stateProgramObject->GetExternalIndex(), m_backendProgramId, log.data()); } else { MGLOG_D("Program linked successfully. ID: %u", m_backendProgramId); } @@ -4425,18 +4612,38 @@ namespace MobileGL::MG_Backend::DirectGLES { } CacheResourceLocations(stateProgramObject); - // AFTER the link, because glShaderStorageBlockBinding needs the driver's linked - // interface. This is the only place Espryt applies a rebinding: the frontend - // record is authoritative and the glShaderStorageBlockBinding entry point itself - // deliberately never forces a program build (see DirectGLES.cpp), so a rebinding - // requested while no backend program existed yet arrives here instead. + // NOT the mechanism that makes a rebinding work - the transpiled qualifier above is. + // glShaderStorageBlockBinding is a GL 4.3 entry point that no real ES driver exposes, + // so this replay is a no-op almost everywhere; it stays because it is still correct + // (and cheaper than a rebuild) on a driver that does expose it, e.g. a desktop GL + // driver used as the ES backend. AFTER the link either way, because it needs the + // driver's linked interface. ReseedShaderStorageBlockBindings(m_backendProgramId, *stateProgramObject); m_syncedLinkVersion = stateProgramObject->GetLinkVersion(); + m_syncedImageUnitVersion = stateProgramObject->GetImageUnitVersion(); m_isInitialized = true; MGLOG_D("Program sync completed. backend ID %u", m_backendProgramId); } + namespace { + // The GL name of the array element that lives at `location`, given the reflection + // name reported for it. Reflection reports one name per UNIFORM ("goku[0]") but + // one location per ELEMENT, so a caller walking locations sees the same name + // repeatedly; this turns it back into "goku[k]". Anything that is not an array + // (or whose base location cannot be resolved) comes back unchanged, so the only + // behaviour that moves is the array case. + String SubscriptUniformNameForElement(const MG_State::GLState::ProgramObject& program, const String& name, + Uint location) { + if (name.size() < 3 || name.compare(name.size() - 3, 3, "[0]") != 0) return name; + const Int base = program.GetUniformLocation(name); + if (base < 0 || static_cast(base) > location) return name; + const Uint element = location - static_cast(base); + if (element == 0) return name; + return name.substr(0, name.size() - 3) + "[" + std::to_string(element) + "]"; + } + } // namespace + // Resolves every name-based resource lookup once per link so the per-draw path // (BindCurrentProgramWithResources) never issues glGetUniformBlockIndex / // glGetUniformLocation string queries; block-to-binding-point assignments are @@ -4499,7 +4706,17 @@ namespace MobileGL::MG_Backend::DirectGLES { // is an INVALID_OPERATION. continue; } - const Int backendLoc = g_GLESFuncs.glGetUniformLocation(m_backendProgramId, name.c_str()); + // Reflection names an array uniform after its FIRST element ("goku[0]") at + // every location the array spans, so asking the driver for that one name + // once per location hands back the same backend location N times. The + // per-draw pass then issues N glUniform1i calls against it and only the + // last element's unit survives - "layout(binding = 1) uniform sampler2D + // goku[7]" ended up with goku[0] on unit 7 and goku[1..6] still on 0. + // Address each element by its own name instead; the frontend already + // reserves one location per element, so the element index is the distance + // from the array's base location. + const String elementName = SubscriptUniformNameForElement(*stateProgramObject, name, loc); + const Int backendLoc = g_GLESFuncs.glGetUniformLocation(m_backendProgramId, elementName.c_str()); if (backendLoc < 0) continue; SamplerUniformBinding binding; binding.frontendLocation = loc; @@ -4508,8 +4725,8 @@ namespace MobileGL::MG_Backend::DirectGLES { binding.lastAssignedUnit = -1; // Present only for the samplers EmulateTextureLodBias actually rewrote; the // pass names it after the sampler, which SPIRV-Cross preserves verbatim. - binding.lodBiasLocation = - g_GLESFuncs.glGetUniformLocation(m_backendProgramId, (String(LOD_BIAS_UNIFORM_PREFIX) + name).c_str()); + binding.lodBiasLocation = g_GLESFuncs.glGetUniformLocation( + m_backendProgramId, (String(LOD_BIAS_UNIFORM_PREFIX) + elementName).c_str()); binding.lastAssignedLodBias = 0.0f; m_samplerUniformBindings.push_back(binding); } @@ -4528,6 +4745,17 @@ namespace MobileGL::MG_Backend::DirectGLES { if (g_lastUsedBackendProgramId == programToBind) { return; } + if (!m_backendProgramUsable) { + // MGLOG_I, not MGLOG_W: at MOBILEGL_LOG_LEVEL_INFO - the level the shipped + // fordebug builds compile at - only I and F survive, and this is precisely the + // line those builds need. Every draw made with this program renders nothing and + // raises no GL error, so without it the only symptom is a framebuffer that kept + // its clear colour. The early return above keeps it to at most one line per + // program state change, not one per draw. + MGLOG_I("Backend program for GL program %u is unusable (a shader failed to transpile, " + "compile or link); binding program 0 - draws with it will render nothing", + m_frontendProgramId); + } MGLOG_D("Using program %u", programToBind); g_GLESFuncs.glUseProgram(programToBind); g_lastUsedBackendProgramId = programToBind; @@ -4563,6 +4791,7 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif g_GLESFuncs.glGenSamplers(1, &m_backendSamplerId); + m_contextGeneration = g_backendContextGeneration; if (m_backendSamplerId == 0) { MGLOG_E("Failed to generate sampler object."); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); @@ -4571,6 +4800,26 @@ namespace MobileGL::MG_Backend::DirectGLES { } } + BackendSamplerObject::~BackendSamplerObject() { + if (InProcessTeardown()) { + return; // see InProcessTeardown(): the driver may be unloaded already + } + if (m_backendSamplerId == 0) { + return; + } + // Scrub the unit shadow whether or not the id can still be deleted - the next + // twin can land on this heap address and would otherwise false-skip its Bind. + for (auto& boundSampler : g_boundSamplersCache) { + if (boundSampler == this) { + boundSampler = nullptr; // glDeleteSamplers unbinds from every unit + } + } + if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteSamplers) { + g_GLESFuncs.glDeleteSamplers(1, &m_backendSamplerId); + } + m_backendSamplerId = 0; + } + void BackendSamplerObject::SyncToBackend( const SharedPtr& stateSamplerObject) { #ifdef TRACY_ENABLE @@ -4686,12 +4935,28 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif g_GLESFuncs.glGenRenderbuffers(1, &m_backendRBOId); + m_contextGeneration = g_backendContextGeneration; if (m_backendRBOId == 0) { MGLOG_E("Failed to generate renderbuffer object."); MGLOG_E("ES glGetError(): %s", MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); } } + BackendRenderbufferObject::~BackendRenderbufferObject() { + if (InProcessTeardown()) { + return; // see InProcessTeardown(): the driver may be unloaded already + } + if (m_backendRBOId == 0) { + return; + } + // No driver-level renderbuffer-binding shadow exists (Bind() always issues the + // call), so there is nothing to scrub here - only the id to release. + if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteRenderbuffers) { + g_GLESFuncs.glDeleteRenderbuffers(1, &m_backendRBOId); + } + m_backendRBOId = 0; + } + void BackendRenderbufferObject::Bind() const { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 721c7687..d5440906 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -36,6 +36,14 @@ namespace MobileGL::MG_Backend::DirectGLES { Bool InProcessTeardown(); void EnsureProcessTeardownSentinel(); + // Generation of the backend ES context that owns the driver ids currently handed + // out. Bumped exactly once per DestroyEGLContext. Every backend twin that owns a + // driver name (texture, framebuffer, renderbuffer, sampler) stamps this at + // construction and compares it in its destructor: a twin outliving its context + // must NOT glDelete* its id, because a successor context may already have recycled + // that name and the delete would take out a live object of the new context. + extern Uint g_backendContextGeneration; + // Which optional pieces of state a draw needs synchronized before it is issued. // Index/indirect buffer syncs and the instancing-related work are skipped for // draws that provably cannot read them. @@ -121,6 +129,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // Null when no live state object owns this key. The result points into the map, so // it stays valid only until the next GetOrCreate/Find/CollectGarbage on this registry. + // Take that literally, including for Find: the map is open-addressed and erases by + // shifting the rest of the probe cluster into the hole, so an erase relocates entries + // OTHER than the erased one - and Find erases, whenever it lands on a key whose state + // object has expired. Callers that need the twin across another registry call must copy + // the BackendPtr out (or keep only the pointee, which is heap-allocated and never moves). BackendPtr* Find(StateObject* stateObj) { const auto entryIt = m_entries.find(stateObj); if (entryIt == m_entries.end()) { @@ -613,6 +626,11 @@ namespace MobileGL::MG_Backend::DirectGLES { Bool m_isInitialized = false; Bool m_imageBindableStorageRequired = false; Bool m_backendStorageImmutable = false; + // Latches the "this driver has no buffer textures" report to once per texture. The + // report is emitted from the respecify path, which bails before recording the state + // it was asked to apply - so without the latch the texture stays permanently dirty + // and every draw of every frame logs the same line. + Bool m_bufferTextureUnsupportedReported = false; StateTextureBasicInfo m_prevTextureInfo; // Frontend content version at the last completed mipmap sync. The per-draw // clean probe compares this before rebuilding shape info and scanning @@ -657,15 +675,20 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> g_boundTexturesCache; extern Uint g_activeTextureUnit; - // Bumped when the backend ES context is destroyed; texture ids stamped with - // an older generation belong to a dead context and must not be deleted. - extern Uint g_textureContextGeneration; } // namespace TextureImpl namespace FramebufferImpl { class BackendFramebufferObject { public: BackendFramebufferObject(); + // Deletes the driver framebuffer and scrubs the binding shadow. Without it every + // frontend glDeleteFramebuffers leaked one ES framebuffer for the process lifetime; + // an app that creates a framebuffer per readback (GL CTS packed_pixels does ~3300 + // per case) walked the driver into hundreds of megabytes of dead framebuffers and + // out of the resources a later attachment needs. + ~BackendFramebufferObject(); + BackendFramebufferObject(const BackendFramebufferObject&) = delete; + BackendFramebufferObject& operator=(const BackendFramebufferObject&) = delete; void SyncToBackend(const SharedPtr& stateFBOObject, FramebufferTarget asTarget); // Apply only this FBO's read buffer (glReadBuffer) to the backend. Split out so it can @@ -680,6 +703,7 @@ namespace MobileGL::MG_Backend::DirectGLES { private: Uint m_backendFBOId = 0; + Uint m_contextGeneration = 0; /* this will save buffers in its original form, reversion, absence or not consecutive are all allowed, as long as GL spec allows it @@ -821,6 +845,10 @@ namespace MobileGL::MG_Backend::DirectGLES { void BindFramebufferId(GLenum fbTarget, Uint id); Uint CurrentFramebufferBinding(FramebufferTarget target); void InvalidateFramebufferBindingCache(); + // A driver framebuffer id is about to be deleted: ES reverts every target that + // currently binds it to 0, so the binding shadow has to follow or the next + // BindFramebufferId(0) would be deduped away and leave the deleted name bound. + void NoteFramebufferIdDeleted(Uint id); } // namespace FramebufferImpl // Shared scratch framebuffers for the readback/copy/blit emulation paths, with a @@ -1010,6 +1038,11 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 GetSnormFallbackClampOutputMask() const { return m_snormFallbackClampOutputMask; } Uint32 GetUnormFallbackClampOutputMask() const { return m_unormFallbackClampOutputMask; } Uint GetFragColorBroadcastCount() const { return m_fragColorBroadcastCount; } + // Signature of the glShaderStorageBlockBinding override set the generated ESSL was + // transpiled against (ES can only express a storage-block binding as the declared + // qualifier, so the overrides are baked into the source). A mismatch means the + // program is stale exactly like the clamp masks above. + Uint64 GetShaderStorageBlockBindingSignature() const { return m_shaderStorageBlockBindingSignature; } Bool HasGlobalUboBlock() const { return m_globalUboBackendBlockIndex >= 0; } const Vector& GetUniformBlockBackendIndices() const { return m_uniformBlockBackendIndices; } @@ -1025,11 +1058,22 @@ namespace MobileGL::MG_Backend::DirectGLES { // Frontend link version this backend program (and its resource caches) was // built from; a mismatch means every link-derived cache here is stale. Uint32 GetSyncedLinkVersion() const { return m_syncedLinkVersion; } + // Image-uniform unit generation this backend program was GENERATED against. + // Separate from the link version because it is not link state: ES forbids + // glUniform1i on an image uniform, so RebindImageUniformsToFrontendUnits bakes the + // unit into the ESSL, and a program built before glUniform1i moved that unit is as + // stale as one built before a relink - while the sampler half, which really is + // re-issued per draw, needs nothing of the sort. + Uint32 GetSyncedImageUnitVersion() const { return m_syncedImageUnitVersion; } private: void CacheResourceLocations(const SharedPtr& stateProgramObject); Uint m_backendProgramId = 0; + // GL name of the frontend program this was last synced from; diagnostics only, so + // an unusable backend program can be traced back to the glCreateProgram id the app + // knows it by. + Uint m_frontendProgramId = 0; Uint m_backendGlobalUBOId = 0; Int m_baseInstanceUniformLocation = -1; Int m_drawIdUniformLocation = -1; @@ -1040,6 +1084,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // Draw buffers a legacy gl_FragColor write has to reach (see // PrgramImpl::BroadcastLegacyFragColor); 1 keeps the plain single-output shader. Uint m_fragColorBroadcastCount = 1; + // 0 is the signature of an empty override set, i.e. what almost every program has. + Uint64 m_shaderStorageBlockBindingSignature = 0; Bool m_isInitialized = false; Bool m_backendProgramUsable = false; @@ -1050,6 +1096,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 m_lastUploadedGlobalUboVersion = ~0u; BufferImpl::UboRingAllocation m_globalUboRingAllocation; Uint32 m_syncedLinkVersion = ~0u; + Uint32 m_syncedImageUnitVersion = ~0u; SamplerPassMemo m_samplerPassMemo; }; @@ -1073,26 +1120,45 @@ namespace MobileGL::MG_Backend::DirectGLES { // on the backend program (eliminated as unused, or the driver lacks the entry // points), which is not an error - GL_BUFFER_BINDING is served from the frontend // record either way. + // + // NOT how a rebinding reaches the shader. glShaderStorageBlockBinding has no ES + // equivalent and is absent from every real ES driver, so this is a no-op there; + // SyncToBackend bakes the effective binding into the ESSL it generates instead + // (SpvcSession::SetShaderStorageBlockBinding). This is kept as the cheaper path on + // a driver that does happen to expose the entry point. Bool ApplyShaderStorageBlockBinding(Uint backendProgramId, const String& blockName, Uint binding); // Replays every glShaderStorageBlockBinding recorded on the program onto a backend - // program that was just built. The frontend record is authoritative (only the - // shader's DECLARED binding survives in the SPIR-V), so without this replay any - // rebuild would silently revert rebound blocks. Mirrors DirectVulkan's - // reseed-on-rebuild in BuildProgramResourceCache. + // program that was just built - best effort, on the same "only where the driver has + // the entry point" terms as ApplyShaderStorageBlockBinding above. Mirrors + // DirectVulkan's reseed-on-rebuild in BuildProgramResourceCache. void ReseedShaderStorageBlockBindings(Uint backendProgramId, const MG_State::GLState::ProgramObject& stateProgramObject); + // Order-independent digest of the program's glShaderStorageBlockBinding overrides. + // The generated ESSL carries them (ES has no way to move a storage block's binding + // after link), so a program built against a different set is stale and the draw path + // has to rebuild it. Computed from the values, so re-setting a block to the binding it + // already has costs nothing. 0 when nothing was ever rebound. + Uint64 ComputeShaderStorageBlockBindingSignature( + const MG_State::GLState::ProgramObject& stateProgramObject); } // namespace PrgramImpl namespace SamplerImpl { class BackendSamplerObject { public: BackendSamplerObject(); + // Deletes the driver sampler and clears the units whose binding shadow still names + // this twin (a recycled heap address would otherwise false-skip a later Bind). + // Frontend glDeleteSamplers used to leak the backend id for the process lifetime. + ~BackendSamplerObject(); + BackendSamplerObject(const BackendSamplerObject&) = delete; + BackendSamplerObject& operator=(const BackendSamplerObject&) = delete; void SyncToBackend(const SharedPtr& stateSamplerObject); void Bind(Uint unit); Uint GetBackendSamplerId() const; private: Uint m_backendSamplerId = 0; + Uint m_contextGeneration = 0; Bool m_isInitialized = false; SamplerParameters m_cacheSamplerParameters; Uint16 m_syncedSamplerVersion = 0; @@ -1110,12 +1176,18 @@ namespace MobileGL::MG_Backend::DirectGLES { class BackendRenderbufferObject { public: BackendRenderbufferObject(); + // Deletes the driver renderbuffer; frontend glDeleteRenderbuffers used to leak it + // (with its whole image allocation) for the process lifetime. + ~BackendRenderbufferObject(); + BackendRenderbufferObject(const BackendRenderbufferObject&) = delete; + BackendRenderbufferObject& operator=(const BackendRenderbufferObject&) = delete; void SyncToBackend(const SharedPtr& stateRBOObject); Uint GetBackendRenderbufferId() const { return m_backendRBOId; } void Bind() const; private: Uint m_backendRBOId = 0; + Uint m_contextGeneration = 0; Bool m_isInitialized = false; TextureInternalFormat m_cacheInternalFormat = TextureInternalFormat::Unknown; Int m_cacheWidth = 0; diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 42d10487..0456e53a 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -434,6 +435,89 @@ namespace MobileGL::MG_Backend::DirectGLES { return result; } + String RetargetTextureBufferExtension(String glslCode, + MG_External::GLESCapabilities::TextureBufferTier tier) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + // SPIRV-Cross hardcodes the EXT spelling: CompilerGLSL::type_to_glsl emits + // require_extension_internal("GL_EXT_texture_buffer") for any Dim=Buffer image + // whenever it targets ESSL below 320, with no OES alternative and no way to + // configure it. GL_OES_texture_buffer is functionally identical but is a separate + // directive, and `#extension : require` on a name the driver does not + // advertise is a hard compile error - so on an OES-only driver the emitted shader + // fails to compile for the sake of one token. + // + // Line comments are excluded by the directive check below; a `#extension` line inside + // a /* */ block is not, and would be rewritten. That is harmless (it stays a comment) + // and is not worth a preprocessor-aware scan here. + // + // Deliberately a directive rewrite and nothing more. The alternative - teaching the + // SPIR-V to stop asking for the extension - is not available: the requirement is + // synthesized by SPIRV-Cross from the image type itself, not carried in the module, + // so there is nothing upstream to strip. Everything about the shader body that + // actually uses the buffer texture is identical between the two extensions. + using Tier = MG_External::GLESCapabilities::TextureBufferTier; + if (tier != Tier::ExtensionOES) { + return glslCode; + } + static constexpr const char* kExtName = "GL_EXT_texture_buffer"; + static constexpr const char* kOesName = "GL_OES_texture_buffer"; + constexpr SizeT kExtNameLength = 21; // strlen("GL_EXT_texture_buffer") + static_assert(sizeof("GL_EXT_texture_buffer") - 1 == kExtNameLength, "name length drifted"); + static_assert(sizeof("GL_OES_texture_buffer") - 1 == kExtNameLength, + "the two spellings must be the same length for the in-place replace"); + + // Only rewrite the name where it is the whole subject of an #extension directive. + // Two separate guards, both load-bearing: + // * the directive check, so a line-comment mentioning the name is left alone; + // * the identifier-boundary check, because GL_EXT_texture_buffer is a PREFIX of + // GL_EXT_texture_buffer_object - a different, real extension that SPIRV-Cross + // emits from the same `case DimBuffer:` on its legacy-desktop branch. Without + // the boundary this pass would silently rewrite a request for that extension + // into a request for a GL_OES_texture_buffer_object that does not exist. + const auto isIdentifierChar = [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '_'; + }; + SizeT searchFrom = 0; + while (true) { + const SizeT hit = glslCode.find(kExtName, searchFrom); + if (hit == String::npos) { + break; + } + searchFrom = hit + kExtNameLength; + + // Identifier boundary on both sides, so the name is not a fragment of a longer one. + if (hit > 0 && isIdentifierChar(glslCode[hit - 1])) { + continue; + } + if (hit + kExtNameLength < glslCode.size() && isIdentifierChar(glslCode[hit + kExtNameLength])) { + continue; + } + + // Walk back to the start of the line and require that it is an #extension + // directive, allowing whitespace between '#' and the keyword. + SizeT lineStart = glslCode.rfind('\n', hit); + lineStart = (lineStart == String::npos) ? 0 : lineStart + 1; + SizeT cursor = lineStart; + while (cursor < hit && std::isspace(static_cast(glslCode[cursor]))) { + ++cursor; + } + if (cursor >= hit || glslCode[cursor] != '#') { + continue; + } + ++cursor; + while (cursor < hit && std::isspace(static_cast(glslCode[cursor]))) { + ++cursor; + } + if (glslCode.compare(cursor, 9, "extension") != 0) { + continue; + } + glslCode.replace(hit, kExtNameLength, kOesName); + } + return glslCode; + } + String RemoveLayoutBinding(const String& glslCode) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); @@ -470,6 +554,352 @@ namespace MobileGL::MG_Backend::DirectGLES { return result; } + namespace { + Bool IsImagePassIdentifierChar(char c) { + return std::isalnum(static_cast(c)) || c == '_'; + } + + // Occurrences of `identifier` in `code` that are whole identifiers, i.e. not the + // tail or head of a longer one. "goku" must not find "goku_hd" or "my_goku". + SizeT CountIdentifierOccurrences(const String& code, const String& identifier) { + if (identifier.empty()) return 0; + SizeT count = 0; + for (SizeT pos = code.find(identifier); pos != String::npos; + pos = code.find(identifier, pos + 1)) { + if (pos > 0 && IsImagePassIdentifierChar(code[pos - 1])) continue; + const SizeT after = pos + identifier.size(); + if (after < code.size() && IsImagePassIdentifierChar(code[after])) continue; + ++count; + } + return count; + } + + Bool ContainsIdentifier(const String& code, const String& identifier) { + return CountIdentifierOccurrences(code, identifier) > 0; + } + + // The image format layout qualifiers ESSL accepts (GLSL ES 3.20 4.4.7 table 4.6 - + // the ES-legal subset of what SPIRV-Cross's format_to_glsl can print). The + // readonly/writeonly rule only applies to a declaration that carries one of them. + Bool IsImageFormatQualifier(const String& token) { + static constexpr StringView FORMATS[] = { + "rgba32f", "rgba16f", "rg32f", "rg16f", "r11f_g11f_b10f", + "r32f", "r16f", "rgba16", "rgb10_a2", "rgba8", + "rg16", "rg8", "r16", "r8", "rgba16_snorm", + "rgba8_snorm", "rg16_snorm", "rg8_snorm", "r16_snorm", "r8_snorm", + "rgba32i", "rgba16i", "rgba8i", "rg32i", "rg16i", + "rg8i", "r32i", "r16i", "r8i", "rgba32ui", + "rgba16ui", "rgb10_a2ui", "rgba8ui", "rg32ui", "rg16ui", + "rg8ui", "r32ui", "r16ui", "r8ui", + }; + for (const StringView format : FORMATS) { + if (token == format) return true; + } + return false; + } + + // "Except for image variables qualified with the format qualifiers r32f, r32i, and + // r32ui, image variables must specify either memory qualifier readonly or the + // memory qualifier writeonly." (GLSL ES 3.20 4.10) + Bool IsMemoryQualifierExemptImageFormat(const String& token) { + return token == "r32f" || token == "r32i" || token == "r32ui"; + } + + // Comma-separated contents of a layout(...) list, each entry trimmed. + Vector SplitLayoutQualifierList(const String& layout) { + Vector tokens; + SizeT start = 0; + while (start <= layout.size()) { + SizeT comma = layout.find(',', start); + const Bool last = comma == String::npos; + String token = layout.substr(start, last ? String::npos : comma - start); + const SizeT first = token.find_first_not_of(" \t\r\n"); + if (first == String::npos) { + token.clear(); + } else { + token = token.substr(first, token.find_last_not_of(" \t\r\n") - first + 1); + } + if (!token.empty()) tokens.push_back(Move(token)); + if (last) break; + start = comma + 1; + } + return tokens; + } + + // Trims both ends and collapses every internal whitespace run to one space, so a + // qualifier list or array suffix can be spliced back into a rebuilt declaration + // whatever the original spacing was. + String NormalizeDeclarationSpacing(const String& text) { + String out; + out.reserve(text.size()); + Bool pendingSpace = false; + for (const char c : text) { + if (std::isspace(static_cast(c))) { + pendingSpace = !out.empty(); + continue; + } + if (pendingSpace) out += ' '; + pendingSpace = false; + out += c; + } + return out; + } + + // How an image builtin touches the image it is handed. + enum class ImageBuiltinAccess { None, Load, Store, Unknown }; + + ImageBuiltinAccess ClassifyImageBuiltin(const String& name) { + if (name == "imageStore") return ImageBuiltinAccess::Store; + if (name == "imageLoad") return ImageBuiltinAccess::Load; + // imageAtomic* both reads and writes, but ES only defines the atomics on + // r32i/r32ui/r32f images - exactly the formats the rule above exempts - so this + // pass has already skipped any declaration they can legally appear on. Load is + // enough to keep the classification total without ever being acted upon. + if (name.compare(0, 11, "imageAtomic") == 0) return ImageBuiltinAccess::Load; + if (name == "imageSize" || name == "imageSamples") return ImageBuiltinAccess::None; + // Some other identifier that starts with "image" and is being called: not a + // shape this pass can reason about, so it poisons the declaration instead of + // being guessed at. + return ImageBuiltinAccess::Unknown; + } + + struct ImageUniformDecl { + String name; + String writeName; // the writeonly half's name, when split + String layout; // raw contents of layout(...) + String qualifiers; // memory/precision qualifiers, normalized, no trailing space + String type; // image2D, uimage2DArray, ... + String arraySuffix; // "" or "[7]" + SizeT declStart = 0; + SizeT declLength = 0; + SizeT referenceCount = 0; // uses this pass recognized and accounted for + Bool loaded = false; + Bool stored = false; + Bool unknownUse = false; + Bool split = false; + }; + + // A rebuilt declaration. Keeps SPIRV-Cross's own word order (`uniform readonly + // highp image2D`) so the image-rebinding regex in Managers.cpp still matches what + // comes out of here, whichever order the two passes end up running in. + String BuildImageDeclaration(const ImageUniformDecl& decl, const char* memoryQualifier, + const String& variableName) { + String out = "layout(" + decl.layout + ") uniform "; + out += memoryQualifier; + out += ' '; + if (!decl.qualifiers.empty()) { + out += decl.qualifiers; + out += ' '; + } + out += decl.type; + out += ' '; + out += variableName; + out += decl.arraySuffix; + out += ';'; + return out; + } + + // A name for the writeonly half that no identifier in the shader (and no other + // half already minted) can collide with. + String MakeImageWriteAliasName(const String& name, const String& source, + const Vector& taken) { + String candidate = String(IMAGE_WRITE_ALIAS_PREFIX) + name; + // "__" anywhere in an identifier is reserved (GLSL ES 3.20 3.7), which a name + // that already starts with '_' would otherwise produce. + for (SizeT doubled = candidate.find("__"); doubled != String::npos; + doubled = candidate.find("__", doubled)) { + candidate.erase(doubled, 1); + } + auto isTaken = [&](const String& identifier) { + if (ContainsIdentifier(source, identifier)) return true; + for (const auto& other : taken) { + if (other == identifier) return true; + } + return false; + }; + while (isTaken(candidate)) candidate += 'X'; + return candidate; + } + + struct ImageSourceEdit { + SizeT start; + SizeT length; + String text; + }; + } // namespace + + String SplitReadWriteImageUniforms(const String& glslCode) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + if (glslCode.find("image") == String::npos) { + return glslCode; + } + + // layout(...) uniform [array]; + // The qualifier alternation is order-free even though SPIRV-Cross emits a fixed + // order (to_qualifiers_glsl: storage, then coherent/restrict/readonly/writeonly, + // then precision), and the array group is repeated so a hypothetical multi- + // dimensional image array survives the round trip intact. + static const std::regex imageDeclRegex( + R"(layout\s*\(([^)]*)\)\s*uniform\s+)" + R"(((?:(?:readonly|writeonly|coherent|volatile|restrict|highp|mediump|lowp)\s+)*))" + R"(([iu]?image[A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*((?:\[[^\]]*\]\s*)*);)"); + + Vector decls; + for (std::sregex_iterator it(glslCode.begin(), glslCode.end(), imageDeclRegex), last; it != last; ++it) { + const std::smatch& match = *it; + const String qualifiers = match[2].str(); + // Already legal: SPIRV-Cross decided one way, leave it alone. + if (ContainsIdentifier(qualifiers, "readonly") || ContainsIdentifier(qualifiers, "writeonly")) { + continue; + } + + Bool hasFormat = false; + Bool exemptFormat = false; + for (const String& token : SplitLayoutQualifierList(match[1].str())) { + if (!IsImageFormatQualifier(token)) continue; + hasFormat = true; + exemptFormat = IsMemoryQualifierExemptImageFormat(token); + } + // No format qualifier at all is a different (and, in ES, unconditionally + // illegal) shape that GL_EXT_shader_image_load_formatted would be needed for; + // SPIRV-Cross refuses to emit it for an ES target, so nothing to do here. + if (!hasFormat || exemptFormat) continue; + + ImageUniformDecl decl; + decl.layout = match[1].str(); + decl.qualifiers = NormalizeDeclarationSpacing(qualifiers); + decl.type = match[3].str(); + decl.name = match[4].str(); + decl.arraySuffix = NormalizeDeclarationSpacing(match[5].str()); + decl.declStart = static_cast(match.position(0)); + decl.declLength = match[0].str().size(); + decls.push_back(Move(decl)); + } + if (decls.empty()) { + return glslCode; + } + + auto findDecl = [&decls](const String& name) -> SizeT { + for (SizeT i = 0; i < decls.size(); ++i) { + if (decls[i].name == name) return i; + } + return decls.size(); + }; + + // Walk every `image*(` call and attribute its first argument to a declaration. + struct StoreSite { + SizeT declIndex; + SizeT start; + SizeT length; + }; + Vector storeSites; + for (SizeT pos = glslCode.find("image"); pos != String::npos; pos = glslCode.find("image", pos + 1)) { + if (pos > 0 && IsImagePassIdentifierChar(glslCode[pos - 1])) continue; // uimage2D, myimageFoo + SizeT tokenEnd = pos; + while (tokenEnd < glslCode.size() && IsImagePassIdentifierChar(glslCode[tokenEnd])) ++tokenEnd; + const String builtin = glslCode.substr(pos, tokenEnd - pos); + + const SizeT openParen = glslCode.find_first_not_of(" \t\r\n", tokenEnd); + if (openParen == String::npos || glslCode[openParen] != '(') continue; // a type, not a call + + const SizeT argStart = glslCode.find_first_not_of(" \t\r\n", openParen + 1); + if (argStart == String::npos) continue; + if (!std::isalpha(static_cast(glslCode[argStart])) && glslCode[argStart] != '_') { + continue; // an expression, not a bare variable - it names no image of ours + } + SizeT argEnd = argStart; + while (argEnd < glslCode.size() && IsImagePassIdentifierChar(glslCode[argEnd])) ++argEnd; + + const SizeT declIndex = findDecl(glslCode.substr(argStart, argEnd - argStart)); + if (declIndex == decls.size()) continue; + ImageUniformDecl& decl = decls[declIndex]; + ++decl.referenceCount; + + // The operand has to be the bare variable, optionally subscripted. Anything + // else (a member access, a call result) is a shape this pass cannot rewrite. + SizeT after = glslCode.find_first_not_of(" \t\r\n", argEnd); + if (after != String::npos && glslCode[after] == '[') { + Int depth = 0; + SizeT scan = after; + for (; scan < glslCode.size(); ++scan) { + if (glslCode[scan] == '[') ++depth; + else if (glslCode[scan] == ']' && --depth == 0) break; + } + after = scan >= glslCode.size() ? String::npos + : glslCode.find_first_not_of(" \t\r\n", scan + 1); + } + const char nextChar = after == String::npos ? '\0' : glslCode[after]; + if (nextChar != ',' && nextChar != ')') { + decl.unknownUse = true; + continue; + } + + switch (ClassifyImageBuiltin(builtin)) { + case ImageBuiltinAccess::Load: + decl.loaded = true; + break; + case ImageBuiltinAccess::Store: + decl.stored = true; + storeSites.push_back({declIndex, argStart, argEnd - argStart}); + break; + case ImageBuiltinAccess::None: + break; + default: + decl.unknownUse = true; + break; + } + } + + // Every mention of the name has to be one this pass saw, or the split would leave + // a store pointing at the readonly half. One occurrence is the declaration itself. + for (auto& decl : decls) { + if (CountIdentifierOccurrences(glslCode, decl.name) != decl.referenceCount + 1) { + decl.unknownUse = true; + } + } + + Vector edits; + Vector takenAliases; + for (auto& decl : decls) { + if (decl.unknownUse) continue; // leave it exactly as it was; no guessing + if (decl.loaded && decl.stored) { + decl.writeName = MakeImageWriteAliasName(decl.name, glslCode, takenAliases); + takenAliases.push_back(decl.writeName); + decl.split = true; + edits.push_back({decl.declStart, decl.declLength, + BuildImageDeclaration(decl, "readonly", decl.name) + "\n" + + BuildImageDeclaration(decl, "writeonly", decl.writeName)}); + } else if (decl.stored) { + edits.push_back({decl.declStart, decl.declLength, + BuildImageDeclaration(decl, "writeonly", decl.name)}); + } else { + // Loaded only, or only ever handed to imageSize (or unused): readonly is + // the qualifier that keeps every one of those legal. + edits.push_back({decl.declStart, decl.declLength, + BuildImageDeclaration(decl, "readonly", decl.name)}); + } + } + for (const StoreSite& site : storeSites) { + const ImageUniformDecl& decl = decls[site.declIndex]; + if (!decl.split) continue; + edits.push_back({site.start, site.length, decl.writeName}); + } + if (edits.empty()) { + return glslCode; + } + + // Back to front, so an earlier edit's offsets stay valid. + std::sort(edits.begin(), edits.end(), + [](const ImageSourceEdit& a, const ImageSourceEdit& b) { return a.start > b.start; }); + String result = glslCode; + for (const ImageSourceEdit& edit : edits) { + result.replace(edit.start, edit.length, edit.text); + } + return result; + } + namespace { // How a lookup carries its level of detail, and how many arguments it takes // before the optional bias. diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index 841636ba..04eaf707 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -130,7 +130,48 @@ namespace MobileGL::MG_Backend::DirectGLES { // drawBufferCount <= 1, i.e. for everything but a framebuffer that actually // enables several draw buffers, so the ordinary single-target shader is untouched. String BroadcastLegacyFragColor(String glslCode, GLenum shaderType, Uint drawBufferCount); + // SPIRV-Cross emits `#extension GL_EXT_texture_buffer : require` for every buffer-texture + // sampler when it targets ESSL below 320, and offers no way to ask for the OES spelling. + // On a driver that advertises only GL_OES_texture_buffer that directive is a compile + // error, so the name is retargeted in the emitted source. A no-op on every other tier: + // ES 3.2 needs no directive at all and an EXT driver already has the right one. + String RetargetTextureBufferExtension(String glslCode, + MG_External::GLESCapabilities::TextureBufferTier tier); String RemoveLayoutBinding(const String& glslCode); + // Prefix of the writeonly half a read+write image uniform is split into (see + // SplitReadWriteImageUniforms); the suffix is the image's own name. + constexpr const char* IMAGE_WRITE_ALIAS_PREFIX = "mg_imageWrite_"; + // ESSL refuses an image variable that carries a format qualifier other than r32f / + // r32i / r32ui unless it also carries `readonly` or `writeonly` (GLSL ES 3.10 4.9 / + // 3.20 4.10; glslang enforces it verbatim in ParseHelper.cpp's layoutObjectCheck). + // SPIRV-Cross emits NEITHER for an image the shader both reads and writes: it + // speculatively decorates every storage image NonWritable+NonReadable + // (fixup_image_load_store_access), then OpImageRead clears NonReadable and + // OpImageWrite clears NonWritable, and to_qualifiers_glsl only prints `readonly` + // from NonWritable and `writeonly` from NonReadable. Desktop GLSL is happy with the + // bare declaration, so the frontend raises no error and the illegal ESSL only shows + // up as a device compile failure - and then as a silently no-op draw. + // + // Restores a legal declaration: + // * loaded only -> add `readonly` + // * stored only -> add `writeonly` + // * both -> emit TWO declarations on the same binding and of the + // same type, `readonly ` and `writeonly + // `, and point every + // imageStore at the second one. Several image variables + // may share an image unit as long as they have the same + // type and format, which is exactly what the pair is. + // + // Budget note: the split DOUBLES the image-uniform count of the stage it fires in, so + // a driver advertising a tight GL_MAX_{FRAGMENT,VERTEX,...}_IMAGE_UNIFORMS can turn a + // shader that used to compile into a link failure. ES only guarantees 4 fragment image + // uniforms, so a shader with more than half the limit in read+write images is the case + // to watch. + // + // Runs on the transpiled ESSL, so it must see the bindings the frontend units were + // already rewritten to and must run before those bindings are stripped - see the call + // site in Managers.cpp. + String SplitReadWriteImageUniforms(const String& glslCode); // Prefix of the per-sampler float uniform that carries GL_TEXTURE_LOD_BIAS into // the shader (see EmulateTextureLodBias); the suffix is the sampler's own name. constexpr const char* LOD_BIAS_UNIFORM_PREFIX = "mg_lodBias_"; diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index c079678d..4a25b2aa 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -768,14 +768,48 @@ namespace MobileGL::MG_Backend::DirectVulkan { // the Uint32 attribute masks the draw path passes around are both bounded by MAX_VERTEX_ATTRIBS. m_dynamicParameters.MaxVertexAttribs = std::min( m_vulkanCaps.MaxVertexAttribs, static_cast(MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS)); - m_dynamicParameters.MaxComputeShaderStorageBlocks = m_vulkanCaps.MaxComputeShaderStorageBlocks; - m_dynamicParameters.MaxCombinedShaderStorageBlocks = m_vulkanCaps.MaxCombinedShaderStorageBlocks; - m_dynamicParameters.MaxComputeUniformBlocks = m_vulkanCaps.MaxComputeUniformBlocks; + // Vulkan descriptor limits are not GL limits, and a GL application reads an advertised + // limit as an amount it may actually USE. Adreno answers the per-stage/per-set descriptor + // queries at descriptor-indexing scale - the same driver whose + // GL_MAX_SHADER_STORAGE_BLOCK_SIZE is clamped from 2147483647 further down - so + // KHR-GL44.multi_bind.dispatch_bind_buffers_base read GL_MAX_COMPUTE_UNIFORM_BLOCKS, + // created that many buffers and spliced that many UBO declarations into a single compute + // shader: ~14 s of allocation, then death on std::bad_alloc. Its sibling + // dispatch_bind_buffers_range hard-codes 4 buffers and passes, which is the clean + // discriminator. Every ceiling below is far above what any desktop driver advertises for + // these (84-96 for the binding families) and far below a descriptor-indexing count, so it + // can only lower a limit that was never usable in the first place. The zero floor is not + // decoration: a driver reporting UINT32_MAX used to arrive here as -1. + const auto clampLimit = [](const char* name, Int reported, Int ceiling) { + const Int clamped = std::min(std::max(reported, 0), ceiling); + if (clamped != reported) { + MGLOG_I("DirectVulkan: clamped %s from %d to %d", name, reported, clamped); + } + return clamped; + }; + // GL 4.6 required minimums, for the record: MAX_COMPUTE_UNIFORM_BLOCKS 12, + // MAX_COMPUTE/COMBINED_SHADER_STORAGE_BLOCKS 8, MAX_SHADER_STORAGE_BUFFER_BINDINGS 8, + // MAX_UNIFORM_BUFFER_BINDINGS 84, MAX_TEXTURE_BUFFER_SIZE 65536. + constexpr Int kMaxAdvertisedBufferBlocks = 256; + constexpr Int kMaxAdvertisedTextureBufferSize = 1 << 27; // texels; what desktop GL reports + m_dynamicParameters.MaxComputeShaderStorageBlocks = + clampLimit("GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS", m_vulkanCaps.MaxComputeShaderStorageBlocks, + kMaxAdvertisedBufferBlocks); + m_dynamicParameters.MaxCombinedShaderStorageBlocks = + clampLimit("GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS", m_vulkanCaps.MaxCombinedShaderStorageBlocks, + kMaxAdvertisedBufferBlocks); + m_dynamicParameters.MaxComputeUniformBlocks = + clampLimit("GL_MAX_COMPUTE_UNIFORM_BLOCKS", m_vulkanCaps.MaxComputeUniformBlocks, + kMaxAdvertisedBufferBlocks); m_dynamicParameters.MaxComputeWorkGroupInvocations = m_vulkanCaps.MaxComputeWorkGroupInvocations; - m_dynamicParameters.MaxShaderStorageBufferBindings = m_vulkanCaps.MaxShaderStorageBufferBindings; - m_dynamicParameters.MaxTextureBufferSize = m_vulkanCaps.MaxTextureBufferSize; + m_dynamicParameters.MaxShaderStorageBufferBindings = + clampLimit("GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", m_vulkanCaps.MaxShaderStorageBufferBindings, + kMaxAdvertisedBufferBlocks); + m_dynamicParameters.MaxTextureBufferSize = clampLimit( + "GL_MAX_TEXTURE_BUFFER_SIZE", m_vulkanCaps.MaxTextureBufferSize, kMaxAdvertisedTextureBufferSize); m_dynamicParameters.TextureBufferOffsetAlignment = m_vulkanCaps.TextureBufferOffsetAlignment; - m_dynamicParameters.MaxUniformBufferBindings = m_vulkanCaps.MaxUniformBufferBindings; + m_dynamicParameters.MaxUniformBufferBindings = clampLimit( + "GL_MAX_UNIFORM_BUFFER_BINDINGS", m_vulkanCaps.MaxUniformBufferBindings, kMaxAdvertisedBufferBlocks); m_dynamicParameters.MaxUniformBlockSize = m_vulkanCaps.MaxUniformBlockSize; m_dynamicParameters.MaxImageUnits = std::max(std::min(m_vulkanCaps.MaxImageUnits, maxSupportedTextureUnits), 0); m_dynamicParameters.MaxCombinedImageUniforms = std::max(m_vulkanCaps.MaxCombinedImageUniforms, 0); diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index eb108838..a870db33 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -801,9 +801,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { // already recorded the new binding on the program - which is what reseeds this cache // whenever it is rebuilt. Writing the entry here as well keeps an ALREADY-BUILT cache // (the common case: the very next draw reads it) from having to be thrown away. - auto& cache = GetProgramResourceCache(*programObject); + // + // Resolve the index BEFORE taking the reference, and bounds-check the way the + // sibling getter does. GetShaderStorageBlockIndex re-enters GetProgramResourceCache, + // which indexes g_programResourceCaches and can therefore insert - and that map is + // open-addressed, so a rehash MOVES its entries and a reference taken before the + // call is left dangling. Binding a program's storage block + // while another program's entry was still absent from the cache was a reproducible + // segfault (ProgramPipelineScenario's two storage-block cases, in one process). const GLuint blockIndex = GetShaderStorageBlockIndex(*programObject, storageBlockName); if (blockIndex == GL_INVALID_INDEX) return; + auto& cache = GetProgramResourceCache(*programObject); + if (blockIndex >= cache.storageBlocks.size()) return; cache.storageBlocks[blockIndex].binding = storageBlockBinding; } void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp index e70cbc6b..85e3d2d1 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp @@ -252,6 +252,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { } VkPipeline pipeline = CreatePipeline(payload); + // A failed creation must never be memoized. Caching VK_NULL_HANDLE served the null back for + // the rest of the process, so one transient driver rejection turned every later draw with + // the same state into a vkCmdBindPipeline(VK_NULL_HANDLE) - the SIGSEGV behind 9 of the 15 + // CTS process deaths. Retrying costs one failed vkCreateGraphicsPipelines per draw, which + // is the correct price for a broken pipeline and is bounded by the draw itself being + // skipped. + if (pipeline == VK_NULL_HANDLE) { + MGLOG_I("PipelineFactory::GetOrCreatePipeline: creation failed for hash=0x%llx " + "programHash=0x%llx; not caching the failure", + static_cast(hash), + static_cast(payload.programHash)); + return VK_NULL_HANDLE; + } m_cache.emplace(hash, PipelineCacheEntry{pipeline, payload.programHash, payload.renderPass, m_frameCounter}); return pipeline; @@ -507,6 +520,35 @@ namespace MobileGL::MG_Backend::DirectVulkan { MGLOG_F("PipelineFactory::CreatePipeline vertex input: bindingCount=%u attributeCount=%u", payload.vertexInputState->vertexBindingDescriptionCount, payload.vertexInputState->vertexAttributeDescriptionCount); + // The driver's own answer is VK_ERROR_UNKNOWN, i.e. no information at all, so the only + // way to work out WHICH shader it choked on (the open sampler-array-in-struct + // investigation) is to name the modules. MGLOG_I, not _D/_E: this must survive in the + // INFO-level builds that CTS actually runs against. + if (payload.stageSpirvDigests) { + for (SizeT i = 0; i < payload.stageSpirvDigests->size(); ++i) { + const auto& digest = (*payload.stageSpirvDigests)[i]; + MGLOG_I("PipelineFactory::CreatePipeline spirv[%zu]: stage=0x%x words=%u bytes=%zu " + "hash=0x%llx", + i, digest.stage, digest.wordCount, + static_cast(digest.wordCount) * sizeof(Uint32), + static_cast(digest.hash)); + } + } else { + MGLOG_I("PipelineFactory::CreatePipeline: no SPIR-V digests attached to the payload"); + } + if (payload.stages) { + for (SizeT i = 0; i < payload.stages->size(); ++i) { + const auto& stage = (*payload.stages)[i]; + // VkShaderModule is a non-dispatchable handle: a pointer on 64-bit but a + // plain uint64_t on 32-bit ABIs, where a cast to const void* is ill-formed + // (broke the armeabi-v7a build). Print it as the 64-bit value it is. + MGLOG_I("PipelineFactory::CreatePipeline stage[%zu]: stage=0x%x module=0x%llx entry=%s " + "specialization=%d", + i, static_cast(stage.stage), + static_cast(reinterpret_cast(stage.module)), + stage.pName ? stage.pName : "(null)", stage.pSpecializationInfo ? 1 : 0); + } + } for (Uint32 i = 0; i < payload.colorAttachmentCount; ++i) { const auto& attachment = payload.colorBlendAttachments[i]; MGLOG_F("PipelineFactory::CreatePipeline colorAttachment[%u]: blend=%d colorWriteMask=0x%x srcColor=%d dstColor=%d colorOp=%d srcAlpha=%d dstAlpha=%d alphaOp=%d", diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h index 5bdbfdb1..77948c4c 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h @@ -14,6 +14,16 @@ #include namespace MobileGL::MG_Backend::DirectVulkan { + // Enough of a fingerprint to identify the exact module the driver rejected without keeping the + // SPIR-V alive for every program in the cache: a driver that answers VK_ERROR_UNKNOWN tells us + // nothing, so the log has to carry the shader's identity itself. Diagnostic only - never part + // of any pipeline or program hash. + struct ShaderStageSpirvDigest { + Uint32 stage = 0; // VkShaderStageFlagBits + Uint32 wordCount = 0; + Uint64 hash = 0; + }; + class PipelineFactory { public: using HashType = Uint64; @@ -62,6 +72,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { Array colorBlendAttachments{}; const Vector* stages = nullptr; const VkPipelineVertexInputStateCreateInfo* vertexInputState = nullptr; + // Diagnostic only; may be null. Read solely from the pipeline-creation failure path. + const Vector* stageSpirvDigests = nullptr; }; explicit PipelineFactory(VkDevice device, const VulkanRendererConfig& config); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index bfb89d13..cec12f2b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -12,7 +12,10 @@ #include "MG_Util/ShaderTranspiler/ShaderCompiler.h" #include "MG_Util/ShaderTranspiler/SpvcSession.h" #include "MG_Util/ShaderTranspiler/Types.h" +#include #include +#include +#include #include #include #include @@ -937,6 +940,189 @@ namespace MobileGL::MG_Backend::DirectVulkan { ProgramFactory::CompileOptionFlags m_transformFlags; }; + // gl_FragCoord back into GL's window space, for default-framebuffer draws only. + // + // Vulkan's gl_FragCoord.y is the framebuffer ROW being written - not a value the + // viewport rect can move independently of placement. The default framebuffer's image is + // stored display-side-up and the vertex stage compensates by negating gl_Position.y, so + // for every default-FBO draw the framebuffer row of a fragment is exactly + // `height - y_GL` (the viewport terms cancel: yf_VK = H - yf_GL for any viewport rect). + // A shader that reads gl_FragCoord therefore sees a flipped Y, and once the viewport + // rect started being converted to the stored orientation it also sees a Y that is + // OUTSIDE the range GL promises - a 32-pixel-tall viewport at GL y=0 reports 224..255 on + // a 256-tall surface. GL CTS shader_image_load_store writes imageStore(image, + // ivec2(gl_FragCoord.xy)) into an image exactly the size of that viewport, so every + // store fell outside the image and the test read back zeroes. + // + // The rewrite redirects every read of the builtin to a Private copy initialised once at + // entry, which is exact for all access forms (whole-vector loads, `.y` access chains, + // OpCopyMemory) and leaves the builtin itself - and its decorations - untouched. + class GlFragCoordYFlipPass final : public spvtools::opt::Pass { + public: + const char* name() const override { return "mobilegl-fragcoord-y-flip"; } + explicit GlFragCoordYFlipPass(Uint32 framebufferHeight) : m_framebufferHeight(framebufferHeight) {} + + Status Process() override { + using namespace spvtools::opt; + if (m_framebufferHeight == 0) return Status::SuccessWithoutChange; + + Instruction* entryPoint = nullptr; + for (auto& candidate : get_module()->entry_points()) { + if (candidate.NumInOperands() >= 2 && + static_cast(candidate.GetSingleWordInOperand(0)) == + spv::ExecutionModel::Fragment) { + entryPoint = &candidate; + break; + } + } + if (!entryPoint) return Status::SuccessWithoutChange; + + const Uint32 builtinVarId = FindFragCoordVariable(); + if (builtinVarId == 0) return Status::SuccessWithoutChange; + + Instruction* builtinVar = context()->get_def_use_mgr()->GetDef(builtinVarId); + if (!builtinVar || builtinVar->opcode() != spv::Op::OpVariable) return Status::SuccessWithoutChange; + + // The builtin is `Input vec4`; take the vector and component types from its own + // pointer type rather than assuming float32x4, so a module that spells it + // differently declines instead of miscompiling. + Instruction* inputPtrType = context()->get_def_use_mgr()->GetDef(builtinVar->type_id()); + if (!inputPtrType || inputPtrType->opcode() != spv::Op::OpTypePointer) { + return Status::SuccessWithoutChange; + } + const Uint32 vectorTypeId = inputPtrType->GetSingleWordInOperand(1); + Instruction* vectorType = context()->get_def_use_mgr()->GetDef(vectorTypeId); + if (!vectorType || vectorType->opcode() != spv::Op::OpTypeVector || + vectorType->GetSingleWordInOperand(1) != 4) { + return Status::SuccessWithoutChange; + } + const Uint32 floatTypeId = vectorType->GetSingleWordInOperand(0); + auto* floatType = context()->get_type_mgr()->GetType(floatTypeId); + if (!floatType || !floatType->AsFloat() || floatType->AsFloat()->width() != 32) { + return Status::SuccessWithoutChange; + } + + const auto heightBits = std::bit_cast(static_cast(m_framebufferHeight)); + const auto* heightConst = context()->get_constant_mgr()->GetConstant(floatType, {heightBits}); + auto* heightInst = context()->get_constant_mgr()->GetDefiningInstruction(heightConst); + if (!heightInst) return Status::SuccessWithoutChange; + + auto* function = context()->GetFunction(entryPoint->GetSingleWordInOperand(1)); + if (!function || function->begin() == function->end()) return Status::SuccessWithoutChange; + + const Uint32 privatePtrTypeId = + context()->get_type_mgr()->FindPointerToType(vectorTypeId, spv::StorageClass::Private); + if (privatePtrTypeId == 0) return Status::SuccessWithoutChange; + + const Uint32 copyVarId = context()->TakeNextId(); + if (copyVarId == 0) return Status::SuccessWithoutChange; + auto copyVar = std::make_unique( + context(), spv::Op::OpVariable, privatePtrTypeId, copyVarId, + std::initializer_list{ + {SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast(spv::StorageClass::Private)}}}); + context()->AddGlobalValue(std::move(copyVar)); + + // Redirect the reads BEFORE emitting the initialiser, so the initialiser's own + // load of the builtin is not rewritten into a load of the (still empty) copy. + if (!RedirectReads(builtinVarId, copyVarId)) return Status::SuccessWithoutChange; + + auto& entryBlock = *function->begin(); + auto insertPoint = entryBlock.begin(); + while (insertPoint != entryBlock.end() && insertPoint->opcode() == spv::Op::OpVariable) { + ++insertPoint; + } + if (insertPoint == entryBlock.end()) return Status::SuccessWithoutChange; + + InstructionBuilder builder(context(), &*insertPoint, + IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); + auto* raw = builder.AddLoad(vectorTypeId, builtinVarId); + if (!raw) return Status::SuccessWithoutChange; + auto* x = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {0}); + auto* y = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {1}); + auto* z = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {2}); + auto* w = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {3}); + if (!x || !y || !z || !w) return Status::SuccessWithoutChange; + auto* flippedY = + builder.AddBinaryOp(floatTypeId, spv::Op::OpFSub, heightInst->result_id(), y->result_id()); + if (!flippedY) return Status::SuccessWithoutChange; + auto* corrected = builder.AddCompositeConstruct( + vectorTypeId, {x->result_id(), flippedY->result_id(), z->result_id(), w->result_id()}); + if (!corrected) return Status::SuccessWithoutChange; + if (!builder.AddStore(copyVarId, corrected->result_id())) return Status::SuccessWithoutChange; + + // SPIR-V 1.4 widened the entry-point interface to every global the entry point + // statically uses, Private included; earlier versions accept Input/Output only, + // so listing it there would be invalid. + if (get_module()->version() >= 0x00010400u) { + entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {copyVarId}}); + context()->AnalyzeUses(entryPoint); + } + + context()->InvalidateAnalysesExceptFor(spvtools::opt::IRContext::kAnalysisDefUse | + spvtools::opt::IRContext::kAnalysisInstrToBlockMapping); + return Status::SuccessWithChange; + } + + private: + Uint32 FindFragCoordVariable() const { + for (const auto& annotation : get_module()->annotations()) { + if (annotation.opcode() != spv::Op::OpDecorate) continue; + if (annotation.NumInOperands() < 3) continue; + if (static_cast(annotation.GetSingleWordInOperand(1)) != + spv::Decoration::BuiltIn) { + continue; + } + if (static_cast(annotation.GetSingleWordInOperand(2)) != spv::BuiltIn::FragCoord) { + continue; + } + return annotation.GetSingleWordInOperand(0); + } + return 0; + } + + // Every instruction that reads through the builtin's POINTER gets the copy instead. + // Decorations, names and the entry-point interface keep naming the builtin. + Bool RedirectReads(Uint32 builtinVarId, Uint32 copyVarId) { + using namespace spvtools::opt; + Bool ok = true; + Vector users; + context()->get_def_use_mgr()->ForEachUser(builtinVarId, [&](Instruction* user) { + switch (user->opcode()) { + case spv::Op::OpLoad: + case spv::Op::OpAccessChain: + case spv::Op::OpInBoundsAccessChain: + case spv::Op::OpPtrAccessChain: + case spv::Op::OpInBoundsPtrAccessChain: + case spv::Op::OpCopyMemory: + case spv::Op::OpCopyMemorySized: + users.push_back(user); + break; + case spv::Op::OpStore: + // gl_FragCoord is read-only; a store through it means this is not the + // module we think it is. + ok = false; + break; + default: + break; + } + }); + if (!ok) return false; + for (Instruction* user : users) { + for (Uint32 i = 0; i < user->NumInOperands(); ++i) { + auto& operand = user->GetInOperand(i); + if (operand.type == SPV_OPERAND_TYPE_ID && !operand.words.empty() && + operand.words[0] == builtinVarId) { + operand.words[0] = copyVarId; + } + } + context()->AnalyzeUses(user); + } + return true; + } + + Uint32 m_framebufferHeight = 0; + }; + // Decorates the module's captured varyings for VK_EXT_transform_feedback: // user outputs get XfbBuffer/XfbStride/Offset directly; a captured // gl_Position (a gl_PerVertex member) is mirrored into a dedicated output @@ -948,6 +1134,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { std::string name; Uint32 bufferIndex = 0; Uint32 offsetBytes = 0; + // Set when the capture names a member of an output interface block + // ("Block.member"): the decoration target is then the block's struct TYPE, + // decorated per member, not the variable. `name` keeps the GL spelling and + // is useless for the id lookup, so the instance name is carried separately. + std::string blockInstanceName; + std::string blockName; + Int blockMemberIndex = -1; + Int blockMemberElement = -1; // array element of that member, -1 = the whole member + Uint32 byteSize = 0; }; const char* name() const override { return "mobilegl-xfb-capture-decorate"; } XfbCaptureDecoratePass(Vector varyings, Vector strides) @@ -979,6 +1174,33 @@ namespace MobileGL::MG_Backend::DirectVulkan { decorationManager->AddDecorationVal(targetId, static_cast(spv::Decoration::Offset), offsetBytes); }; + // SPIR-V puts XfbBuffer/XfbStride/Offset on the struct MEMBER when the + // captured varying lives in an interface block (SPIR-V 1.6 §3.20 lists all + // three as member-decoratable); Offset in particular is illegal on the block + // variable once the type is decorated Block. + const auto decorateMemberForXfb = [&](Uint32 structTypeId, Uint32 memberIndex, Uint32 bufferIndex, + Uint32 offsetBytes) { + const Uint32 stride = bufferIndex < m_strides.size() ? m_strides[bufferIndex] : 0; + decorationManager->AddMemberDecoration(structTypeId, memberIndex, + static_cast(spv::Decoration::XfbBuffer), + bufferIndex); + decorationManager->AddMemberDecoration(structTypeId, memberIndex, + static_cast(spv::Decoration::XfbStride), stride); + decorationManager->AddMemberDecoration(structTypeId, memberIndex, + static_cast(spv::Decoration::Offset), offsetBytes); + }; + + // A member array captured element by element ("Block.attrib[0]" .. "[15]") + // is one SPIR-V member, so its captures collapse into a single decoration + // placed at the first element's offset - the rest follow from the member's + // own layout. Collected first so the group is complete before it decorates. + struct MemberGroup { + Uint32 bufferIndex = 0; + Uint32 minOffset = 0; + Uint32 elementBytes = 0; + Vector offsets; + }; + std::map, MemberGroup> memberGroups; Bool modified = false; Bool needsPositionMirror = false; @@ -991,6 +1213,41 @@ namespace MobileGL::MG_Backend::DirectVulkan { positionOffset = varying.offsetBytes; continue; } + if (varying.blockMemberIndex >= 0) { + // glslang names the block's instance variable and its struct type + // separately; an anonymous instance leaves only the type named, so + // both spellings are tried before giving up. + Uint32 structTypeId = 0; + if (const auto it = idsByName.find(varying.blockInstanceName); it != idsByName.end()) { + structTypeId = BlockStructTypeOf(it->second); + } + if (structTypeId == 0) { + if (const auto it = idsByName.find(varying.blockName); it != idsByName.end()) { + const spvtools::opt::Instruction* def = context()->get_def_use_mgr()->GetDef(it->second); + if (def != nullptr && def->opcode() == spv::Op::OpTypeStruct) { + structTypeId = it->second; + } else if (def != nullptr && def->opcode() == spv::Op::OpVariable) { + structTypeId = BlockStructTypeOf(it->second); + } + } + } + if (structTypeId == 0) { + MGLOG_E("XfbCaptureDecoratePass: no SPIR-V interface block '%s' (instance '%s') for " + "capture '%s'", + varying.blockName.c_str(), varying.blockInstanceName.c_str(), + varying.name.c_str()); + continue; + } + auto& group = + memberGroups[{structTypeId, static_cast(varying.blockMemberIndex)}]; + if (group.offsets.empty() || varying.offsetBytes < group.minOffset) { + group.minOffset = varying.offsetBytes; + } + group.bufferIndex = varying.bufferIndex; + group.elementBytes = varying.byteSize; + group.offsets.push_back(varying.offsetBytes); + continue; + } const auto idIt = idsByName.find(varying.name); if (idIt == idsByName.end()) { MGLOG_E("XfbCaptureDecoratePass: no SPIR-V variable named '%s'", varying.name.c_str()); @@ -1000,6 +1257,25 @@ namespace MobileGL::MG_Backend::DirectVulkan { modified = true; } + for (auto& [key, group] : memberGroups) { + // The single Offset can only stand for the whole group when the group's + // captures are a gap-free ascending run - that is what SPIR-V lays the + // member's elements out as. Anything else still gets a best-effort + // decoration, but say so, because the capture layout will not match GL. + std::sort(group.offsets.begin(), group.offsets.end()); + for (SizeT i = 1; i < group.offsets.size(); ++i) { + if (group.elementBytes == 0 || + group.offsets[i] != group.offsets[i - 1] + group.elementBytes) { + MGLOG_I("XfbCaptureDecoratePass: block member %u of type %%%u is captured with a " + "non-contiguous element set; the capture layout will differ from GL's", + key.second, key.first); + break; + } + } + decorateMemberForXfb(key.first, key.second, group.bufferIndex, group.minOffset); + modified = true; + } + if (needsPositionMirror) { modified |= MirrorPositionForCapture(entryFunctionId, *entryPoint, positionBufferIndex, positionOffset, decorateForXfb); @@ -1021,6 +1297,27 @@ namespace MobileGL::MG_Backend::DirectVulkan { } private: + // The struct type an interface-block variable points at, peeling an array of + // block instances on the way. 0 when the id is not a block variable at all. + Uint32 BlockStructTypeOf(Uint32 variableId) { + auto* defUse = context()->get_def_use_mgr(); + const spvtools::opt::Instruction* variable = defUse->GetDef(variableId); + if (variable == nullptr || variable->opcode() != spv::Op::OpVariable) return 0; + const spvtools::opt::Instruction* pointer = defUse->GetDef(variable->type_id()); + if (pointer == nullptr || pointer->opcode() != spv::Op::OpTypePointer) return 0; + Uint32 pointeeId = pointer->GetSingleWordInOperand(1); + for (const spvtools::opt::Instruction* pointee = defUse->GetDef(pointeeId); pointee != nullptr; + pointee = defUse->GetDef(pointeeId)) { + if (pointee->opcode() == spv::Op::OpTypeStruct) return pointeeId; + if (pointee->opcode() != spv::Op::OpTypeArray && + pointee->opcode() != spv::Op::OpTypeRuntimeArray) { + return 0; + } + pointeeId = pointee->GetSingleWordInOperand(0); + } + return 0; + } + template Bool MirrorPositionForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint, Uint32 bufferIndex, Uint32 offsetBytes, const DecorateFn& decorateForXfb) { @@ -1301,6 +1598,35 @@ namespace MobileGL::MG_Backend::DirectVulkan { return spvtools::Optimizer::PassToken(MakeUnique(transformFlags)); } + Bool TransformSpirvForFragCoordYFlip(const Vector& input, Vector& output, + Uint32 framebufferHeight) { + if (input.empty()) { + output.clear(); + return true; + } + if (framebufferHeight == 0) { + output = input; + return true; + } + + spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3); + spvtools::OptimizerOptions options; + options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling + optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&, + const char* message) { + MGLOG_E("Vulkan: fragcoord y-flip pass: %s", message != nullptr ? message : ""); + }); + optimizer.RegisterPass( + spvtools::Optimizer::PassToken(MakeUnique(framebufferHeight))); + + const Bool success = optimizer.Run(input.data(), input.size(), &output, options); + if (!success) { + MGLOG_E("Vulkan: failed to run the gl_FragCoord y-flip pass; keeping the original module"); + output = input; + } + return success; + } + Bool TransformSpirvForXfbCapture(const Vector& input, Vector& output, const MG_State::GLState::ProgramObject& program) { if (input.empty()) { @@ -1310,7 +1636,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { Vector varyings; varyings.reserve(program.GetTransformFeedbackVaryingCount()); for (const auto& varying : program.GetTransformFeedbackVaryings()) { - varyings.push_back({varying.name, varying.bufferIndex, varying.offsetBytes}); + varyings.push_back({varying.name, varying.bufferIndex, varying.offsetBytes, + varying.blockInstanceName, varying.blockName, varying.blockMemberIndex, + varying.blockMemberElement, varying.byteSize}); } Vector strides; strides.reserve(program.GetTransformFeedbackBufferCount()); @@ -1506,11 +1834,31 @@ namespace MobileGL::MG_Backend::DirectVulkan { for (auto* binding : bindings) { MOBILEGL_ASSERT(binding != nullptr, "ProgramFactory: null descriptor binding reflection record"); const auto kind = ReflectDescriptorTypeToBindingKind(binding->descriptor_type); - // UBO instance arrays (uniform Block {...} b[N];) occupy one binding with - // descriptorCount = N; other descriptor arrays stay unsupported and must - // fail program creation cleanly rather than continue with corrupt state. - if (binding->count != 1 && kind != ProgramFactory::DescriptorBindingKind::UniformBufferDynamic) { - MGLOG_E("ProgramFactory: descriptor arrays are unsupported for this descriptor " + // A descriptor ARRAY occupies one binding with descriptorCount = N, and is + // supported for exactly the kinds that have a per-element resolve path in + // UniformManager::BindProgramUniformBuffers: UBO instance arrays + // (uniform Block {...} b[N];), storage-block instance arrays, image uniform + // arrays, and combined-image-sampler arrays (uniform sampler2D s[N];). + // Anything else - a uniform TEXEL buffer array is the one remaining kind - + // must fail program creation cleanly rather than continue with corrupt state. + // + // Getting listed here is not cosmetic: a kind that is rejected leaves + // GetOrCreateProgram's MOBILEGL_ASSERT(remapOk) as the only complaint, and + // that assert compiles out above DEBUG - so a release build SILENTLY kept + // glslang's per-stage auto-mapped binding numbers, skipping the cross-stage + // unification and the set->0 normalisation this function exists to do. A + // program with an image array plus any second descriptor got aliased + // bindings out of that, and a DEBUG build trapped on the same program. + // Which is also why the message below is MGLOG_I: MGLOG_E is compiled out + // of an INFO build, so a refusal that only said MGLOG_E said nothing at all + // in the builds that ship. + const Bool arraySupportedForKind = + kind == ProgramFactory::DescriptorBindingKind::UniformBufferDynamic || + kind == ProgramFactory::DescriptorBindingKind::StorageBuffer || + kind == ProgramFactory::DescriptorBindingKind::StorageImage || + kind == ProgramFactory::DescriptorBindingKind::CombinedImageSampler; + if (binding->count != 1 && !arraySupportedForKind) { + MGLOG_I("ProgramFactory: descriptor arrays are unsupported for this descriptor " "kind (name='%s' count=%u type=%d)", binding->name ? binding->name : "", binding->count, static_cast(binding->descriptor_type)); @@ -1772,6 +2120,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { XXHASH_VERIFY(XXH64_update(m_hashState, spv.data(), spv.size() * sizeof(Uint))); } XXHASH_VERIFY(XXH64_update(m_hashState, &flags, sizeof(CompileOptionFlags))); + // Only FragCoordYFlip variants bake the height in, so mixing it unconditionally would + // re-key every program in the cache on a resize for no reason. + if (flags & CompileOptionBit::FragCoordYFlip) { + XXHASH_VERIFY(XXH64_update(m_hashState, &m_defaultFramebufferHeight, + sizeof(m_defaultFramebufferHeight))); + } // Include UBO block bindings in hash so different binding configurations produce different entries const Uint32 blockCount = static_cast(program.GetActiveUniformBlocksCount()); @@ -2037,6 +2391,78 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + // How many descriptors to declare for an ARRAY of opaque uniforms (samplers, images) at one + // binding. A returned count is always DECLARED in the descriptor set layout; `outDeclined` + // says whether the binding can also be RESOLVED at draw time, or whether the program has to + // be refused instead. + // + // Those are deliberately two different things. The layout must keep describing what the + // shader declares even for a binding MobileGL cannot resolve: a descriptor the shader reads + // and the layout omits is not a missing draw, it is an undefined descriptor access, and + // lavapipe segfaults on it inside pipeline creation - in a JIT worker thread, before any + // draw runs, which is why removing the binding produced a flaky crash rather than a clean + // refusal. Declining is done by refusing the draw (VkProgramObject::declinedDescriptors), + // not by shrinking the layout. + // + // Two separate things have to hold, and neither is checkable from the SPIR-V alone: + // + // * the count has to fit a VkDescriptorSetLayoutBinding this device will accept, and fit + // the Uint16 it is stored in (65536 would narrow to 0) and the scratch the bind path + // reserves from it; + // * the frontend reflection has to have RESERVED that many consecutive uniform locations + // for this uniform, because the per-element resolve paths address element k as + // baseLocation + k. SPIRV-Reflect's `count` is the FLATTENED element count, while GL + // locations follow the OUTER dimension only (ProgramObject::GetUniformArraySizeByTIndex + // answers TType::getOuterArraySize()). For a one-dimensional array the two agree; for + // `uniform sampler2D g[2][3]` SPIR-V says 6 where the reflection reserved 2, and + // elements 2..5 would silently resolve onto whichever uniform got the next locations. + // + // Asking the reflection whether baseLocation and baseLocation + count - 1 are slots of the + // SAME uniform tests exactly that precondition, without this code having to model how + // glslang chooses to lay an array of arrays out. + // + // That is NOT on its own enough to start supporting the shape, though, and this check must + // not be relaxed alone: the binding-qualifier unit seeding in ProgramLinkTask looks an + // opaque uniform up by its name minus a trailing "[0]", so `goku[0][0]` misses the `goku` + // key and every element of an array of arrays seeds texture unit 0. Resolving those elements + // would then paint silently-wrong pixels with no diagnostic at all - strictly worse than + // declining. The decline goes away together with the seeding fix, not before it. + static Uint32 DescriptorCountForOpaqueUniformArray(const MG_State::GLState::ProgramObject& program, + const String& uniformName, Uint32 binding, Int baseLocation, + Uint32 reflectedCount, Uint32 maxBindings, + const char* kindLabel, Bool& outDeclined) { + const Uint32 count = std::max(1u, reflectedCount); + if (count == 1) { + return 1u; + } + if (count > maxBindings) { + // Nothing legal to declare: the count would not fit a VkDescriptorSetLayoutBinding + // this device accepts, and it would narrow badly into the Uint16 that carries it + // (65536 becomes 0). Unlike the extent case below, this one CANNOT keep the layout + // consistent with the shader, so refusing the draw does not fully protect it - the + // driver still JITs a shader indexing past the declared count. Declaring as many as + // the device allows keeps vkCreateDescriptorSetLayout succeeding and the program + // inert; a device whose binding cap is smaller than a shader's array is not a + // configuration MobileGL can serve at all. Needs a >maxBindings-element array to + // reach (256 on desktop, ~16 on mobile). + MGLOG_I("ProgramFactory::ReflectLayout: %s array '%s' at binding %u has %u elements, past the %u " + "this device can describe - declining the program", + kindLabel, uniformName.c_str(), binding, count, maxBindings); + outDeclined = true; + return maxBindings; + } + if (baseLocation < 0 || + !program.UniformLocationsAliasSameUniform(baseLocation, baseLocation + static_cast(count - 1u))) { + MGLOG_I("ProgramFactory::ReflectLayout: %s array '%s' at binding %u spans %u descriptors but the " + "reflection reserved fewer uniform locations for it (base=%d) - a multi-dimensional array " + "is the usual cause, and MobileGL declines it rather than resolve elements onto a " + "neighbouring uniform", + kindLabel, uniformName.c_str(), binding, count, baseLocation); + outDeclined = true; + } + return count; + } + void ProgramFactory::ReflectLayout(const MG_State::GLState::ProgramObject& program, const Vector>& spirv, VkProgramObject& entry) const { // Initialize layout vectors @@ -2054,6 +2480,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { entry.dynamicBindings.clear(); entry.bindingDescriptorCounts.assign(m_maxBindings, 1); entry.arrayedUniformBlockIndicesByBinding.clear(); + entry.declinedDescriptors = false; // Use SpvcSession (Reflection mode) to reflect all SPIR-V modules in a single pass per module for (const auto& module : spirv) { @@ -2244,11 +2671,58 @@ namespace MobileGL::MG_Backend::DirectVulkan { } entry.storageBlockNameByBinding[binding] = uniformName; entry.storageBlockIndexByBinding[binding] = static_cast(blockIndex); + + // A block INSTANCE array is ONE Vulkan binding carrying `count` + // descriptors, while GL assigns its elements consecutive binding points + // starting at the declared one (GL 4.6 core 7.8). Recording only element 0 - + // which is all this used to do - left the layout claiming descriptorCount 1, + // so every element past the first read a descriptor nobody wrote and + // `b[1].data.length()` answered from an unconstrained buffer instead of its + // own bound range (KHR-GL43.shader_storage_buffer_object.- + // advanced-unsizedArrayLength-*). + // + // Bounds-checked like every other array kind. The EXTENT rule differs - a + // block array's elements take consecutive GL binding points rather than + // consecutive uniform locations, so DescriptorCountForOpaqueUniformArray's + // location test does not apply here - but the size rule is identical: this + // count goes straight into a VkDescriptorSetLayoutBinding and is narrowed to + // a Uint16 on the way, where 65536 would silently become 0. + const Uint32 storageArrayCount = std::max(1u, sampler->count); + if (storageArrayCount > m_maxBindings) { + MGLOG_I("ProgramFactory::ReflectLayout: storage block array '%s' at binding %u has %u " + "elements, past the %u this device can describe - declining the program", + uniformName.c_str(), binding, storageArrayCount, m_maxBindings); + entry.declinedDescriptors = true; + entry.bindingDescriptorCounts[binding] = static_cast(m_maxBindings); + continue; + } + entry.bindingDescriptorCounts[binding] = static_cast(storageArrayCount); continue; } const Int location = program.GetUniformLocation(uniformName); if (location < 0) { + // A uniform with no location is ordinarily one GL never made active, and + // dropping it is routine. An ARRAY reaching here is not routine: it is the + // multi-dimensional case. `uniform sampler2D g[2][3]` arrives from + // SPIRV-Reflect as one binding of 6 descriptors named "g", while the frontend + // reflection keys an array of arrays by its full "[0]"-terminated spelling + // ("g[0][0]"), so no base location resolves and the per-element paths have + // nothing to count from. Declining is the honest answer - but it has to SAY + // so at a level that survives a release build, because dropping the binding + // leaves the shader reading a descriptor the layout never declared. + if (sampler->count > 1) { + MGLOG_I("ProgramFactory::ReflectLayout: declining '%s' at binding %u - a %u-element " + "descriptor array with no frontend uniform location (a multi-dimensional array " + "of samplers or images is the known cause)", + uniformName.c_str(), binding, sampler->count); + entry.declinedDescriptors = true; + // Declared, not resolved - see DescriptorCountForOpaqueUniformArray for + // why the layout keeps describing a binding the draw path will refuse. + entry.bindingDescriptorCounts[binding] = + static_cast(std::min(sampler->count, m_maxBindings)); + continue; + } entry.bindingKinds[binding] = DescriptorBindingKind::None; continue; } @@ -2256,6 +2730,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { const GLenum uniformType = program.GetUniformType(static_cast(location)); if (descriptorKind == DescriptorBindingKind::StorageImage) { + // An ARRAY of image uniforms is ONE binding carrying `count` descriptors, + // and the layout has to say so. Leaving it at the default 1 declared + // `uniform image2D g_image[4]` as a single-descriptor binding while the + // shader indexed descriptors 1..3 of it - an out-of-bounds descriptor + // access that lavapipe SIGSEGVs inside the JIT-ed shader thread rather than + // reporting (KHR-GL42.shader_image_load_store.advanced-sso-simple). Unlike + // a storage BLOCK array, whose elements take consecutive GL binding points + // from the declared one, each element of an image array carries its own + // independently assigned image unit - see ResolveStorageImageDescriptor. + // Bounds- and extent-checked like the UBO array path above; see + // DescriptorCountForOpaqueUniformArray for what "declined" costs and why + // the reflection's reserved extent - not SPIRV-Reflect's flattened count - + // is what the per-element resolve can actually address. + const Uint32 imageArrayCount = + DescriptorCountForOpaqueUniformArray(program, uniformName, binding, location, sampler->count, + m_maxBindings, "image", entry.declinedDescriptors); + entry.bindingDescriptorCounts[binding] = static_cast(imageArrayCount); + const VkFormat reflectedFormat = ConvertSpirvImageFormatToVkFormat(sampler->image.image_format); VkFormat& existingFormat = entry.storageImageFormatByBinding[binding]; @@ -2286,6 +2778,20 @@ namespace MobileGL::MG_Backend::DirectVulkan { "ProgramFactory::ReflectLayout: failed to resolve texture target for '%s'", uniformName.c_str()); if (descriptorKind == DescriptorBindingKind::CombinedImageSampler) { + // An ARRAY of sampler uniforms is ONE binding carrying `count` descriptors, + // exactly like the image array above, and for the same reason: GLSL 4.20 + // gives `layout(binding = 1) uniform sampler2D goku[4]` one declaration + // spanning texture units 1..4, each element with its own glUniform1i-assigned + // unit. Leaving descriptorCount at 1 declared a single-descriptor binding + // while the shader indexed descriptors 1..3 of it, and the bind path wrote + // only element 0 - so elements 1..N read a descriptor nobody had written + // (KHR-GL42.shading_language_420pack.binding_sampler_array; lavapipe faults + // inside the JIT-ed shader rather than reporting). + const Uint32 samplerArrayCount = + DescriptorCountForOpaqueUniformArray(program, uniformName, binding, location, sampler->count, + m_maxBindings, "sampler", entry.declinedDescriptors); + entry.bindingDescriptorCounts[binding] = static_cast(samplerArrayCount); + const SamplerNumericDomain numericDomain = UniformTypeToSamplerNumericDomain(uniformType); MOBILEGL_ASSERT(numericDomain != SamplerNumericDomain::Unknown, "ProgramFactory::ReflectLayout: failed to resolve sampler numeric domain " @@ -2380,14 +2886,36 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + void ProgramFactory::SetDefaultFramebufferHeight(Uint32 height) { + if (m_defaultFramebufferHeight == height) { + return; + } + m_defaultFramebufferHeight = height; + // Both memos key on (program, flags) alone, so neither can tell the two heights apart: + // drop the lookup memo, and bump the structure epoch so every caller holding a + // VkProgramObject* re-runs GetOrCreateProgram and lands on the new hash. The cached + // entries themselves stay - they are keyed by a hash that now includes the old height, + // so they can only be reached again if that height comes back, and the frame-boundary + // sweep retires them otherwise. + m_lastLookup = {}; + ++m_cacheStructureEpoch; + } + const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram( const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags) { // Hashing the full SPIR-V of every stage is far too expensive to repeat per draw; // reuse the program's memoized hash while its backend state version is unchanged. + // The memo keys on the flags word, which ComputeHash is no longer a pure function of: + // a FragCoordYFlip variant also depends on the baked default-framebuffer height, so + // that height rides in the free high half of the key. Flags occupy the low bits, and a + // height cannot exceed the 16 bits a swapchain extent fits in. + const Uint memoKey = (flags & CompileOptionBit::FragCoordYFlip) + ? (flags.GetRaw() | (m_defaultFramebufferHeight << 16)) + : flags.GetRaw(); HashType hash = 0; - if (!program.GetBackendHashMemo(flags.GetRaw(), hash)) { + if (!program.GetBackendHashMemo(memoKey, hash)) { hash = ComputeHash(program, flags); - program.SetBackendHashMemo(flags.GetRaw(), hash); + program.SetBackendHashMemo(memoKey, hash); } auto it = m_cache.find(hash); if (it != m_cache.end()) { @@ -2440,6 +2968,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + if ((flags & ProgramFactory::CompileOptionBit::FragCoordYFlip) && shaders[i] && + shaders[i]->GetShaderStage() == ShaderStage::Fragment) { + Vector fragCoordSpirv; + if (TransformSpirvForFragCoordYFlip(moduleSpirvs[i], fragCoordSpirv, m_defaultFramebufferHeight)) { + moduleSpirvs[i] = Move(fragCoordSpirv); + } + } + // Vulkan's SPIR-V environment has no rectangle image dimension, so a // GL_TEXTURE_RECTANGLE lookup has to become the 2D one the texture is really // stored as - which addresses [0,1] where the application addressed texels. @@ -2568,6 +3104,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { entry.modules.push_back(module); entry.stages.push_back(stage); + entry.stageSpirvDigests.push_back(ShaderStageSpirvDigest{ + static_cast(stage.stage), static_cast(moduleSpv.size()), + XXH64(moduleSpv.data(), moduleSpv.size() * sizeof(Uint), 0)}); } // Reflect and create layout as part of the program object @@ -2577,6 +3116,20 @@ namespace MobileGL::MG_Backend::DirectVulkan { ReflectVertexInputs(shaders, moduleSpirvs, entry); ReflectFragmentOutputs(shaders, moduleSpirvs, entry); ReflectLayout(program, moduleSpirvs, entry); + // A failed remap means the modules kept glslang's per-stage auto-mapped binding numbers - + // no cross-stage unification, no set->0 normalisation - so the bindings this layout + // describes are not the bindings the shader reads. That has to stop the program from + // drawing, and until now nothing did: the MOBILEGL_ASSERT above compiles out of every + // build past DEBUG, and RemapDescriptorBindingsForVulkan's own refusal message said so at + // a level an INFO build also drops. Declining is the mechanism that already exists for + // "the layout and the shader disagree", so route it through that. Set AFTER ReflectLayout, + // which clears the flag. + if (!remapOk) { + MGLOG_I("ProgramFactory::GetOrCreateProgram: declining program %u - its descriptor bindings could not " + "be remapped, so the layout does not describe what the shader reads", + program.GetExternalIndex()); + entry.declinedDescriptors = true; + } return entry; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h index 0f8570f3..bb310781 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h @@ -9,6 +9,7 @@ #pragma once #include "../VkIncludes.h" +#include "PipelineFactory.h" #include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/ProgramState/ShaderObject.h" #include "MG_State/GLState/TextureState/TextureEnum.h" @@ -52,6 +53,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // recorded while GL transform feedback is active, so plain draws keep the // undecorated variant. XfbCapture = 1 << 6, + // Rewrites the fragment stage's gl_FragCoord reads to GL's bottom-left window + // origin. Vulkan's gl_FragCoord.y IS the framebuffer row being written, and the + // default framebuffer's image is stored in display (top-left) order, so a shader + // that reads gl_FragCoord there sees `height - y_GL`. Set together with + // PositionYFlip (the two are the same fact about the same draws) except under a + // quarter turn, which this renderer does not convert rectangles for either. + FragCoordYFlip = 1 << 7, }; using CompileOptionFlags = Flags; using HashType = Uint64; @@ -62,6 +70,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { HashType hash = 0; Vector stages; Vector modules; + // Parallel to stages; identifies the exact module bytes handed to the driver when a + // pipeline creation fails. Sixteen bytes per stage instead of keeping the SPIR-V. + Vector stageSpirvDigests; // Layout data (previously in separate VkProgramLayout) VkDescriptorSetLayout descriptorSetLayout = VK_NULL_HANDLE; @@ -75,8 +86,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { Vector activeBindings; Vector dynamicBindings; Vector uniformBlockIndexByBinding; - // Descriptor count per binding (1 except for UBO instance arrays, which occupy one - // binding with descriptorCount = N). + // Descriptor count per binding (1 except for a descriptor ARRAY - a UBO or storage + // block instance array, an image uniform array or a sampler uniform array - each of + // which occupies one binding with descriptorCount = N). Vector bindingDescriptorCounts; // Per-element GL uniform block indices for arrayed UBO bindings (count > 1); // element 0 of a non-arrayed binding stays in uniformBlockIndexByBinding. @@ -92,6 +104,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Set once during ReflectLayout so the per-draw path can skip the whole // storage-image preparation for the overwhelming majority of programs. Bool hasStorageImages = false; + // Something about this program's descriptors could not be resolved - an opaque + // uniform array whose elements have no addressable uniform locations (the + // multi-dimensional case), or a binding remap that failed outright. The binding + // STAYS DECLARED in the descriptor set layout; declining is done here, by refusing + // every draw, and BindProgramUniformBuffers returns false so the draw setup skips + // the draw exactly as it does for any other bind failure. + // + // Keeping the layout intact is the load-bearing half. Shrinking it instead - which + // is what the first cut of this did - leaves the shader reading a descriptor the + // layout never declared, and lavapipe segfaults on that inside PIPELINE CREATION, + // in a JIT worker thread, before any draw runs where a refusal could help. The + // reason was logged once at MGLOG_I when the descriptor was declined. + Bool declinedDescriptors = false; Int globalUboBinding = -1; Uint32 activeVertexInputLocationMask = 0; Array vertexInputTypes{}; @@ -118,6 +143,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { hash = other.hash; stages = std::move(other.stages); modules = std::move(other.modules); + // Must travel with `modules`: these digests name the SPIR-V those exact + // shader modules were built from, and the pipeline-failure diagnostics + // print the two together. Leaving it behind used to merely lose the + // digests on a rehash; now that the cache is a robin-hood table, insertion + // SWAPS two entries, and a field that no move touches stays behind in the + // slot - pairing one program's modules with another program's digests, so + // a pipeline failure would be reported against the wrong SPIR-V. + stageSpirvDigests = std::move(other.stageSpirvDigests); descriptorSetLayout = other.descriptorSetLayout; pipelineLayout = other.pipelineLayout; bindingKinds = std::move(other.bindingKinds); @@ -136,6 +169,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { storageBlockNameByBinding = std::move(other.storageBlockNameByBinding); storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding); hasStorageImages = other.hasStorageImages; + declinedDescriptors = other.declinedDescriptors; globalUboBinding = other.globalUboBinding; activeVertexInputLocationMask = other.activeVertexInputLocationMask; vertexInputTypes = other.vertexInputTypes; @@ -150,6 +184,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.descriptorSetLayout = VK_NULL_HANDLE; other.pipelineLayout = VK_NULL_HANDLE; other.hasStorageImages = false; + other.declinedDescriptors = false; other.globalUboBinding = -1; other.activeVertexInputLocationMask = 0; other.activeFragmentOutputLocationMask = 0; @@ -167,6 +202,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { hash = other.hash; stages = std::move(other.stages); modules = std::move(other.modules); + stageSpirvDigests = std::move(other.stageSpirvDigests); // travels with `modules` - see the move ctor descriptorSetLayout = other.descriptorSetLayout; pipelineLayout = other.pipelineLayout; bindingKinds = std::move(other.bindingKinds); @@ -185,6 +221,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { storageBlockNameByBinding = std::move(other.storageBlockNameByBinding); storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding); hasStorageImages = other.hasStorageImages; + declinedDescriptors = other.declinedDescriptors; globalUboBinding = other.globalUboBinding; activeVertexInputLocationMask = other.activeVertexInputLocationMask; vertexInputTypes = other.vertexInputTypes; @@ -199,6 +236,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.descriptorSetLayout = VK_NULL_HANDLE; other.pipelineLayout = VK_NULL_HANDLE; other.hasStorageImages = false; + other.declinedDescriptors = false; other.globalUboBinding = -1; other.activeVertexInputLocationMask = 0; other.activeFragmentOutputLocationMask = 0; @@ -233,6 +271,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } modules.clear(); stages.clear(); + stageSpirvDigests.clear(); // the modules they describe are gone } }; @@ -263,6 +302,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { const VkProgramObject& GetOrCreateProgram( const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags); + // The default framebuffer's current image height, baked as a literal into every + // FragCoordYFlip variant (there is no push-constant or specialization channel here, and + // adding one for a value that changes only on swapchain recreation would cost the draw + // path more than a recompile costs a resize). It is therefore part of those variants' + // identity: ComputeHash mixes it in when the bit is set, so a height change re-keys them + // and leaves every other program's hash untouched. Setting a NEW height also bumps the + // cache-structure epoch, because a caller holding a memoised VkProgramObject* would + // otherwise keep using a module compiled against the old height. + void SetDefaultFramebufferHeight(Uint32 height); + Uint32 GetDefaultFramebufferHeight() const { return m_defaultFramebufferHeight; } + // Bumped whenever m_cache's STRUCTURE changes (any insert or erase): the cache is // an open-addressing map holding entries by value, so both moves existing entries. // A caller that memoised a VkProgramObject* may keep dereferencing it only while @@ -320,6 +370,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // True only when the logical device enabled both // shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat. Bool m_unformattedFloatStorageImagesEnabled = false; + // See SetDefaultFramebufferHeight. 0 means "not known yet"; the FragCoordYFlip bit is + // never set before the swapchain exists, so no variant can be compiled against it. + Uint32 m_defaultFramebufferHeight = 0; mutable ProgramLookupCache m_lastLookup; // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. Uint64 m_frameCounter = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index 2ec7aa18..61590f0e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -68,6 +68,29 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + // Uniform location of ELEMENT `element` of the opaque-uniform array at `baseLocation`, or + // -1 when the reflection did not reserve that element. DoReflection hands out one location + // per array element, so the element's location is the base plus its index - bounded by the + // array's real extent so a descriptorCount that outran the reflection cannot walk onto the + // next uniform. Element 0 is the ordinary non-array case and costs nothing extra. + static Int ResolveDescriptorElementLocation(const MG_State::GLState::ProgramObject& program, Int baseLocation, + Uint32 element) { + if (baseLocation < 0 || element == 0) { + return baseLocation; + } + const Int location = baseLocation + static_cast(element); + return program.UniformLocationsAliasSameUniform(baseLocation, location) ? location : -1; + } + + // descriptorCount this binding declares in the descriptor set layout (1 for everything that + // is not an array). Kept in one place because the layout, the scratch reservation and the + // per-element write loops must all agree on it. + static Uint32 BindingDescriptorCount(const ProgramFactory::VkProgramObject& programObj, Uint32 binding) { + return binding < programObj.bindingDescriptorCounts.size() + ? std::max(1u, programObj.bindingDescriptorCounts[binding]) + : 1u; + } + static Int ResolveSamplerUnitIndex(const MG_State::GLState::ProgramObject& program, Int location, Uint32 binding) { MOBILEGL_ASSERT(location >= -1, "ResolveSamplerUnitIndex: invalid sampler location for binding %u", binding); if (location < 0) { @@ -268,28 +291,44 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool UniformManager::ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, - Uint32 binding, VkDescriptorImageInfo& outImageInfo, + Uint32 binding, Uint32 element, + VkDescriptorImageInfo& outImageInfo, Bool trustUnchangedHint) const { MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveSamplerDescriptor: texture manager is null"); MOBILEGL_ASSERT(m_samplerManager != nullptr, "ResolveSamplerDescriptor: sampler manager is null"); + // The whole-descriptor memo below is keyed by binding alone, so it describes a binding + // that carries exactly one descriptor. An arrayed binding's elements would overwrite + // each other in it (see SamplerResolveMemo::info); they re-resolve instead. + const Bool descriptorMemoUsable = BindingDescriptorCount(programObj, binding) == 1u; // The caller proved every input of this binding's resolution unchanged since the // last full resolve (which also filled the cache), so the whole chain below - // texture/sampler resolution, completeness probe, sync, layout handling, sampler // and view lookups - would recompute the identical descriptor. - if (trustUnchangedHint && binding < m_samplerResolveMemo.size() && + if (trustUnchangedHint && descriptorMemoUsable && binding < m_samplerResolveMemo.size() && m_samplerResolveMemo[binding].infoValid) { outImageInfo = m_samplerResolveMemo[binding].info; return true; } MOBILEGL_ASSERT(binding < programObj.samplerNameByBinding.size(), "ResolveSamplerDescriptor: sampler binding %u name lookup out of range", binding); + // Per ELEMENT, and resolved BEFORE anything is looked up through it: GLSL 4.20 gives every + // element of `uniform sampler2D goku[4]` its own texture unit (consecutive from the + // declared binding, but glUniform1i may scatter them afterwards), so the unit - and with + // it the bound texture, the unit's sampler override and the fallback decision - is the + // element's, not the binding's. An element past the array's reserved extent has no unit + // at all, and must not fall back to resolving unit 0's texture. + const Int location = + ResolveDescriptorElementLocation(program, programObj.samplerUniformLocationByBinding[binding], element); + if (location < 0 && element > 0) { + MGLOG_D("ResolveSamplerDescriptor: binding %u element %u is past the end of its sampler array", binding, + element); + return false; + } + const Int unit = ResolveSamplerUnitIndex(program, location, binding); // Raw-pointer resolve to skip the SharedPtr atomic refcount churn: the bound texture stays // alive through the draw via GL binding state. Only the fallback path needs a SharedPtr to // keep the fallback texture alive for the rest of this call. - MG_State::GLState::ITextureObject* texture = ResolveSamplerTextureRaw(program, programObj, binding); - - const Int location = programObj.samplerUniformLocationByBinding[binding]; - const Int unit = ResolveSamplerUnitIndex(program, location, binding); + MG_State::GLState::ITextureObject* texture = ResolveSamplerTextureRaw(program, programObj, binding, element); auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); const auto& samplerOverride = textureUnit.GetSamplerObject(); const auto preferredTarget = programObj.samplerTextureTargetByBinding[binding]; @@ -459,9 +498,21 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (outImageInfo.sampler == VK_NULL_HANDLE) { return false; } + // Only for a binding that carries a single descriptor - an array's elements would + // publish each other's descriptors here, and the next hinted draw would hand element + // N-1's texture to element 0. if (binding < m_samplerResolveMemo.size()) { - m_samplerResolveMemo[binding].info = outImageInfo; - m_samplerResolveMemo[binding].infoValid = true; + if (descriptorMemoUsable) { + m_samplerResolveMemo[binding].info = outImageInfo; + m_samplerResolveMemo[binding].infoValid = true; + } else { + // An arrayed binding publishes nothing here, and clears what a previous program + // published at this index. Not strictly required - the hint's proof obligations + // are program-scoped and the entry is reset every frame - but leaving another + // program's descriptor sitting in a slot this one never refreshes is the kind of + // thing the next reader has to re-derive is safe. + m_samplerResolveMemo[binding].infoValid = false; + } NoteSamplerResolveMemoTouched(binding); } return true; @@ -500,42 +551,58 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool UniformManager::ProgramSamplesOnlySingleLevelTextures( const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj) { + // A declined program never draws (see VkProgramObject::declinedDescriptors), and its + // declined binding has no resolvable uniform location - so there is nothing to prove + // about the textures it would have sampled. + if (programObj.declinedDescriptors) { + return false; + } Bool sawSampler = false; for (Uint32 binding = 0; binding < programObj.bindingKinds.size(); ++binding) { if (programObj.bindingKinds[binding] != ProgramFactory::DescriptorBindingKind::CombinedImageSampler) { continue; } - const auto* texture = ResolveSamplerTextureRaw(program, programObj, binding); - if (texture == nullptr) return false; - const auto& levelRange = texture->GetLevelRange(); - if (levelRange.x() != levelRange.y()) return false; - - // An explicit-LOD sample is a single filtered tap, so it also gives up anisotropic - // filtering - which a single-level view can still have. Resolve the sampler exactly - // the way ResolveSamplerDescriptor does and bail if anisotropy would apply. - const Int location = programObj.samplerUniformLocationByBinding[binding]; - const Int unit = ResolveSamplerUnitIndex(program, location, binding); - const auto& samplerOverride = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); - const auto* effectiveSampler = - samplerOverride ? samplerOverride.get() : texture->GetSamplerObject().get(); - if (effectiveSampler == nullptr) return false; - if (effectiveSampler->GetMaxAnisotropy() > 1.0f && - effectiveSampler->GetMinFilter() == SamplerFilterMode::Linear && - effectiveSampler->GetMagFilter() == SamplerFilterMode::Linear) { - return false; - } + // The rewrite this gates is program-wide, so EVERY sampler the program can read has + // to qualify - including every element of a sampler array, each of which reaches a + // different texture through its own unit. + const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding); + for (Uint32 element = 0; element < descriptorCount; ++element) { + // The element's own location first, exactly as ResolveSamplerDescriptor resolves + // it - an element with no location would otherwise be judged on unit 0's texture. + const Int location = ResolveDescriptorElementLocation( + program, programObj.samplerUniformLocationByBinding[binding], element); + if (location < 0 && element > 0) return false; + const auto* texture = ResolveSamplerTextureRaw(program, programObj, binding, element); + if (texture == nullptr) return false; + const auto& levelRange = texture->GetLevelRange(); + if (levelRange.x() != levelRange.y()) return false; + + // An explicit-LOD sample is a single filtered tap, so it also gives up anisotropic + // filtering - which a single-level view can still have. Resolve the sampler exactly + // the way ResolveSamplerDescriptor does and bail if anisotropy would apply. + const Int unit = ResolveSamplerUnitIndex(program, location, binding); + const auto& samplerOverride = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); + const auto* effectiveSampler = + samplerOverride ? samplerOverride.get() : texture->GetSamplerObject().get(); + if (effectiveSampler == nullptr) return false; + if (effectiveSampler->GetMaxAnisotropy() > 1.0f && + effectiveSampler->GetMinFilter() == SamplerFilterMode::Linear && + effectiveSampler->GetMagFilter() == SamplerFilterMode::Linear) { + return false; + } - // An explicit LOD 0 makes lambda exactly 0, which is the magnification side of the - // min/mag decision. That only matches the implicit form when lambda could not have been - // positive anyway (the LOD clamp already pins it at or below 0), or when the two - // filters are the same and the choice cannot be observed. - const Float effectiveMaxLod = effectiveSampler->GetMipmapMode() == SamplerMipmapMode::None - ? 0.0f - : effectiveSampler->GetMaxLod(); - if (effectiveMaxLod > 0.0f && effectiveSampler->GetMinFilter() != effectiveSampler->GetMagFilter()) { - return false; + // An explicit LOD 0 makes lambda exactly 0, which is the magnification side of the + // min/mag decision. That only matches the implicit form when lambda could not have been + // positive anyway (the LOD clamp already pins it at or below 0), or when the two + // filters are the same and the choice cannot be observed. + const Float effectiveMaxLod = effectiveSampler->GetMipmapMode() == SamplerMipmapMode::None + ? 0.0f + : effectiveSampler->GetMaxLod(); + if (effectiveMaxLod > 0.0f && effectiveSampler->GetMinFilter() != effectiveSampler->GetMagFilter()) { + return false; + } + sawSampler = true; } - sawSampler = true; } return sawSampler; } @@ -568,14 +635,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { MG_State::GLState::ITextureObject* UniformManager::ResolveSamplerTextureRaw( const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, - Uint32 binding) { + Uint32 binding, Uint32 element) { MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSamplerTextureRaw: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveSamplerTextureRaw: sampler location binding %u out of range", binding); MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), "ResolveSamplerTextureRaw: sampler target binding %u out of range", binding); - const Int location = programObj.samplerUniformLocationByBinding[binding]; + const Int location = + ResolveDescriptorElementLocation(program, programObj.samplerUniformLocationByBinding[binding], element); const Int unit = ResolveSamplerUnitIndex(program, location, binding); auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); @@ -677,7 +745,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool UniformManager::ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, - Uint32 binding, + Uint32 binding, Uint32 element, VkDescriptorBufferInfo& outBufferInfo) const { outBufferInfo = {}; MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageBufferDescriptor: buffer manager is null"); @@ -688,8 +756,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Int blockIndex = programObj.storageBlockIndexByBinding[binding]; MOBILEGL_ASSERT(blockIndex >= 0, "ResolveStorageBufferDescriptor: no SSBO block mapped to binding %u", binding); + // A block instance array declares one block whose elements take consecutive GL binding + // points from the declared one (GL 4.6 core 7.8), and the reflection collapses the whole + // array to that one block - so the element index IS the offset from its binding. const GLuint frontendBinding = - GetShaderStorageBlockBinding(program, static_cast(blockIndex)); + GetShaderStorageBlockBinding(program, static_cast(blockIndex)) + element; const Uint32 bindingPointCount = static_cast(MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::ShaderStorage)); MOBILEGL_ASSERT(frontendBinding < bindingPointCount, @@ -742,7 +813,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool UniformManager::ResolveStorageImageDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, - Uint32 binding, + Uint32 binding, Uint32 element, VkDescriptorImageInfo& outImageInfo) const { outImageInfo = {}; MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveStorageImageDescriptor: texture manager is null"); @@ -750,11 +821,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveStorageImageDescriptor: binding %u out of range", binding); - const Int location = programObj.samplerUniformLocationByBinding[binding]; - if (location < 0) { + const Int baseLocation = programObj.samplerUniformLocationByBinding[binding]; + if (baseLocation < 0) { MGLOG_E("ResolveStorageImageDescriptor: storage image binding %u has no uniform location", binding); return false; } + // Per ELEMENT, and this is where an image array differs from a storage-block array: GL + // gives every element of `uniform image2D g_image[4]` its own glUniform1i-assigned image + // unit, and the four units need not be consecutive or even ordered (the conformance case + // uses 0, 2, 4, 6). DoReflection reserves one uniform location per array element, so the + // element's location is the base plus its index - checked against the array's real + // extent so a descriptorCount that outran the reflection cannot walk onto the next + // uniform. + const Int location = baseLocation + static_cast(element); + if (!program.UniformLocationsAliasSameUniform(baseLocation, location)) { + MGLOG_E("ResolveStorageImageDescriptor: binding %u element %u is past the end of its image array", + binding, element); + return false; + } const Int imageUnit = program.GetUniformSamplerOrImageUnitIndex(static_cast(location)); if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { MGLOG_E("ResolveStorageImageDescriptor: image unit %d out of range for binding %u", @@ -844,7 +928,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool UniformManager::ResolveSampledBinding(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, - Uint32 binding, + Uint32 binding, Uint32 element, MG_State::GLState::ITextureObject*& outTexture, const MG_State::GLState::SamplerObject*& outSampler) const { // Open-coded ResolveSamplerTextureRaw so the unit is resolved once for both the @@ -855,7 +939,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { "ResolveSampledBinding: sampler location binding %u out of range", binding); MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), "ResolveSampledBinding: sampler target binding %u out of range", binding); - const Int location = programObj.samplerUniformLocationByBinding[binding]; + const Int location = + ResolveDescriptorElementLocation(program, programObj.samplerUniformLocationByBinding[binding], element); + if (location < 0 && element > 0) { + return false; + } const Int unit = ResolveSamplerUnitIndex(program, location, binding); auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; @@ -891,6 +979,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (outBindingRecords != nullptr) { outBindingRecords->clear(); } + // Nothing to prepare for a program the bind path is going to refuse; its declined + // binding has no uniform location to resolve a texture through either. + if (programObj.declinedDescriptors) { + return true; + } const Uint32 bindingCount = std::min(m_maxBindings, static_cast(programObj.bindingKinds.size())); @@ -899,19 +992,27 @@ namespace MobileGL::MG_Backend::DirectVulkan { continue; } - MG_State::GLState::ITextureObject* texture = nullptr; - const MG_State::GLState::SamplerObject* sampler = nullptr; - if (!ResolveSampledBinding(program, programObj, binding, texture, sampler)) { - continue; - } - if (outBindingRecords != nullptr) { - outBindingRecords->push_back({texture != nullptr ? texture->GetLifetimeId() : 0, - sampler != nullptr ? sampler->GetLifetimeId() : 0}); - } + // Every ELEMENT of a sampler array reaches its own texture through its own unit, + // so every element has to be in the sampled set: this walk is what gets those + // textures synced and transitioned to a sampled layout BEFORE the render pass + // opens, and a missed element would first be touched by the descriptor resolve + // inside an active pass. + const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding); + for (Uint32 element = 0; element < descriptorCount; ++element) { + MG_State::GLState::ITextureObject* texture = nullptr; + const MG_State::GLState::SamplerObject* sampler = nullptr; + if (!ResolveSampledBinding(program, programObj, binding, element, texture, sampler)) { + continue; + } + if (outBindingRecords != nullptr) { + outBindingRecords->push_back({texture != nullptr ? texture->GetLifetimeId() : 0, + sampler != nullptr ? sampler->GetLifetimeId() : 0}); + } - auto found = std::find(outTextures.begin(), outTextures.end(), texture); - if (found == outTextures.end()) { - outTextures.push_back(texture); + auto found = std::find(outTextures.begin(), outTextures.end(), texture); + if (found == outTextures.end()) { + outTextures.push_back(texture); + } } } return true; @@ -920,6 +1021,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool UniformManager::SampledBindingsUnchanged(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, const Vector& previousRecords) const { + // A declined program takes the full path every time and is refused there. + if (programObj.declinedDescriptors) { + return false; + } SizeT recordIndex = 0; // Iterate only the bindings this program declares (ascending), exactly like // BindProgramUniformBuffers: this runs per draw whenever the texture bind @@ -932,18 +1037,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (programObj.bindingKinds[binding] != ProgramFactory::DescriptorBindingKind::CombinedImageSampler) { continue; } - MG_State::GLState::ITextureObject* texture = nullptr; - const MG_State::GLState::SamplerObject* sampler = nullptr; - if (!ResolveSampledBinding(program, programObj, binding, texture, sampler)) { - continue; - } - if (recordIndex >= previousRecords.size()) { - return false; - } - const SampledBindingRecord& record = previousRecords[recordIndex++]; - if (record.textureLifetimeId != (texture != nullptr ? texture->GetLifetimeId() : 0) || - record.samplerLifetimeId != (sampler != nullptr ? sampler->GetLifetimeId() : 0)) { - return false; + // Element-for-element, in the same order CollectSampledTextures recorded them - + // the two walks have to visit the identical descriptor sequence or the positional + // comparison below drifts. + const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding); + for (Uint32 element = 0; element < descriptorCount; ++element) { + MG_State::GLState::ITextureObject* texture = nullptr; + const MG_State::GLState::SamplerObject* sampler = nullptr; + if (!ResolveSampledBinding(program, programObj, binding, element, texture, sampler)) { + continue; + } + if (recordIndex >= previousRecords.size()) { + return false; + } + const SampledBindingRecord& record = previousRecords[recordIndex++]; + if (record.textureLifetimeId != (texture != nullptr ? texture->GetLifetimeId() : 0) || + record.samplerLifetimeId != (sampler != nullptr ? sampler->GetLifetimeId() : 0)) { + return false; + } } } return recordIndex == previousRecords.size(); @@ -956,6 +1067,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { outTextures.clear(); MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "CollectStorageImageTextures: GL context is null"); + // Same as the sampled walk: a declined program is refused at bind time, and its declined + // binding has no uniform location to reach an image unit through. + if (programObj.declinedDescriptors) { + return true; + } const Uint32 bindingCount = std::min(m_maxBindings, static_cast(programObj.bindingKinds.size())); @@ -968,26 +1084,40 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } - const Int location = programObj.samplerUniformLocationByBinding[binding]; - if (location < 0) { + const Int baseLocation = programObj.samplerUniformLocationByBinding[binding]; + if (baseLocation < 0) { MGLOG_E("CollectStorageImageTextures: binding %u has no image uniform location", binding); return false; } - const Int imageUnit = program.GetUniformSamplerOrImageUnitIndex(static_cast(location)); - if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - MGLOG_E("CollectStorageImageTextures: image unit %d is invalid for binding %u", - imageUnit, binding); - return false; - } + // Per ELEMENT, for the same reason the sampled walk above is: an image ARRAY is one + // binding whose elements each carry their own image unit, so each reaches its own + // texture. This walk is what puts those textures into the pre-pass sync and layout + // transition; collecting only element 0 left elements 1..N to be first touched by + // the descriptor resolve, which happens with a render pass already open. + const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding); + for (Uint32 element = 0; element < descriptorCount; ++element) { + const Int location = ResolveDescriptorElementLocation(program, baseLocation, element); + if (location < 0) { + MGLOG_E("CollectStorageImageTextures: binding %u element %u is past the end of its image array", + binding, element); + return false; + } + const Int imageUnit = program.GetUniformSamplerOrImageUnitIndex(static_cast(location)); + if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { + MGLOG_E("CollectStorageImageTextures: image unit %d is invalid for binding %u element %u", + imageUnit, binding, element); + return false; + } - auto* texture = MG_State::pGLContext->GetImageTextureBinding(imageUnit).Texture.get(); - if (texture == nullptr) { - MGLOG_E("CollectStorageImageTextures: image unit %d is unbound for binding %u", - imageUnit, binding); - return false; - } - if (std::find(outTextures.begin(), outTextures.end(), texture) == outTextures.end()) { - outTextures.push_back(texture); + auto* texture = MG_State::pGLContext->GetImageTextureBinding(imageUnit).Texture.get(); + if (texture == nullptr) { + MGLOG_E("CollectStorageImageTextures: image unit %d is unbound for binding %u element %u", + imageUnit, binding, element); + return false; + } + if (std::find(outTextures.begin(), outTextures.end(), texture) == outTextures.end()) { + outTextures.push_back(texture); + } } } return true; @@ -1358,6 +1488,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkPipelineBindPoint bindPoint, const SamplerBindingOverride* samplerBindingOverride, Bool samplerDescriptorsUnchangedHint) { + // This program has a descriptor MobileGL could not resolve (see + // VkProgramObject::declinedDescriptors). Refusing here is the whole of the decline: the + // binding is still declared in the layout, so the pipeline is consistent with the shader + // and creating it is safe - what must not happen is the draw, because the descriptor + // behind that binding can never be written. The draw setup skips the draw on a false + // return. ReflectLayout already said why, once, at MGLOG_I. + if (programObj.declinedDescriptors) { + MGLOG_D("UniformDescriptorBinder::BindProgramUniformBuffers: refusing a program whose descriptor layout " + "was declined at reflection"); + return false; + } auto& frame = m_frames[frameIndex]; if (frame.descriptorPools.empty()) { MGLOG_E("UniformDescriptorBinder::BindProgramUniformBuffers failed: frame descriptor pools are invalid"); @@ -1416,13 +1557,27 @@ namespace MobileGL::MG_Backend::DirectVulkan { dynamicOffsets.clear(); // Arrayed UBO bindings contribute extra buffer infos and dynamic offsets; reserve for // the worst case so the pBufferInfo pointers taken below never dangle on reallocation. + // Arrayed SSBO bindings contribute extra buffer infos too (but no dynamic offsets). Uint32 uboArrayExtra = 0; for (const auto& arrayEntry : programObj.arrayedUniformBlockIndicesByBinding) { uboArrayExtra += static_cast(arrayEntry.second.size()) - 1u; } + // Surplus descriptors over "one per binding", summed across EVERY arrayed binding + // whatever its kind - storage blocks, image arrays and sampler arrays all land here. + // One number for all of them because each container below is bounded by the same total. + Uint32 arrayDescriptorExtra = 0; + for (const Uint16 count : programObj.bindingDescriptorCounts) { + if (count > 1) arrayDescriptorExtra += static_cast(count) - 1u; + } writes.reserve(m_maxBindings); - bufferInfos.reserve(m_maxBindings + uboArrayExtra); - imageInfos.reserve(m_maxBindings); + bufferInfos.reserve(m_maxBindings + uboArrayExtra + arrayDescriptorExtra); + // Every binding pushes at most descriptorCount image infos, so bindings + surplus is the + // worst case. Reserving only m_maxBindings here was exact while every binding pushed + // exactly one - and reallocates under an image or sampler array, dangling every + // pImageInfo already recorded in `writes` before vkUpdateDescriptorSets reads them. That + // is reachable wherever m_maxBindings is small (it clamps to ~16 on Adreno and Mali), + // which is exactly where a 7-element CTS sampler array does not fit the slack. + imageInfos.reserve(m_maxBindings + arrayDescriptorExtra); texelBufferViews.reserve(m_maxBindings); dynamicOffsets.reserve(programObj.dynamicBindings.size() + uboArrayExtra); @@ -1450,10 +1605,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { write.descriptorCount = 1; if (kind == ProgramFactory::DescriptorBindingKind::UniformBufferDynamic) { - const Uint32 descriptorCount = - binding < programObj.bindingDescriptorCounts.size() - ? std::max(1, programObj.bindingDescriptorCounts[binding]) - : 1u; + const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding); dynamicUboDescriptorCount += descriptorCount; fastRebindUboBinding = binding; const SizeT firstBufferInfoIndex = bufferInfos.size(); @@ -1493,59 +1645,109 @@ namespace MobileGL::MG_Backend::DirectVulkan { write.pTexelBufferView = &texelBufferViews.back(); writes.push_back(write); } else if (kind == ProgramFactory::DescriptorBindingKind::StorageBuffer) { - VkDescriptorBufferInfo bufferInfo{}; - if (!ResolveStorageBufferDescriptor(program, programObj, binding, bufferInfo)) { - MGLOG_E( - "UniformDescriptorBinder::BindProgramUniformBuffers failed: storage buffer binding %u has no valid descriptor", - binding); - return false; + // One write per binding, but `descriptorCount` buffer infos: a GLSL block + // instance array occupies a single binding whose elements each come from their + // own GL binding point. + const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding); + const SizeT firstBufferInfoIndex = bufferInfos.size(); + for (Uint32 element = 0; element < descriptorCount; ++element) { + VkDescriptorBufferInfo bufferInfo{}; + if (!ResolveStorageBufferDescriptor(program, programObj, binding, element, bufferInfo)) { + MGLOG_E( + "UniformDescriptorBinder::BindProgramUniformBuffers failed: storage buffer binding %u " + "element %u has no valid descriptor", + binding, element); + return false; + } + bufferInfos.push_back(bufferInfo); } - bufferInfos.push_back(bufferInfo); fastRebindKindsEligible = false; write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; - write.pBufferInfo = &bufferInfos.back(); + write.descriptorCount = descriptorCount; + write.pBufferInfo = &bufferInfos[firstBufferInfoIndex]; writes.push_back(write); } else if (kind == ProgramFactory::DescriptorBindingKind::StorageImage) { - VkDescriptorImageInfo imageInfo{}; - if (!ResolveStorageImageDescriptor(commandBuffer, program, programObj, binding, imageInfo)) { - MGLOG_E( - "UniformDescriptorBinder::BindProgramUniformBuffers failed: storage image binding %u has no valid descriptor", - binding); - return false; + // One write per binding, but `descriptorCount` image infos: an ARRAY of image + // uniforms is a single binding whose elements each carry their own image unit. + // Writing only element 0 - which is all this used to do - left elements 1..N + // never written at all, and a shader that indexes them reads an undefined + // descriptor (lavapipe faults inside the shader; a real driver is free to do + // anything). + const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding); + const SizeT firstImageInfoIndex = imageInfos.size(); + for (Uint32 element = 0; element < descriptorCount; ++element) { + VkDescriptorImageInfo imageInfo{}; + if (!ResolveStorageImageDescriptor(commandBuffer, program, programObj, binding, element, + imageInfo)) { + MGLOG_E( + "UniformDescriptorBinder::BindProgramUniformBuffers failed: storage image binding %u " + "element %u has no valid descriptor", + binding, element); + return false; + } + imageInfos.push_back(imageInfo); } - imageInfos.push_back(imageInfo); fastRebindKindsEligible = false; write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; - write.pImageInfo = &imageInfos.back(); + write.descriptorCount = descriptorCount; + write.pImageInfo = &imageInfos[firstImageInfoIndex]; writes.push_back(write); } else { - VkDescriptorImageInfo imageInfo{}; - Bool hasImage = false; - if (samplerBindingOverride != nullptr && - samplerBindingOverride->binding == binding && - samplerBindingOverride->texture != nullptr && - samplerBindingOverride->sampler != nullptr) { - hasImage = ResolveSamplerDescriptorOverride(*samplerBindingOverride, imageInfo); - } else { - hasImage = ResolveSamplerDescriptor(commandBuffer, program, programObj, binding, imageInfo, - samplerDescriptorsUnchangedHint); - } - if (!hasImage) { - MGLOG_E( - "UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u has no valid texture descriptor", - binding); - return false; + // One write per binding, but `descriptorCount` image infos: a sampler ARRAY is a + // single binding whose elements each carry their own texture unit. Writing only + // element 0 - which is all this used to do - left elements 1..N never written, + // so a shader indexing them sampled a descriptor nobody had filled in + // (KHR-GL42.shading_language_420pack.binding_sampler_array). + const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding); + // Overrides come only from MobileGL's own blit and depth-mipmap programs, whose + // samplers are scalars; the override replaces THE descriptor at its binding, so + // there is no element for it to mean on an arrayed one. + const Bool overrideThisBinding = samplerBindingOverride != nullptr && + samplerBindingOverride->binding == binding && + samplerBindingOverride->texture != nullptr && + samplerBindingOverride->sampler != nullptr; + MOBILEGL_ASSERT( + !overrideThisBinding || descriptorCount == 1, + "BindProgramUniformBuffers: sampler override targets arrayed binding %u (%u descriptors)", + binding, descriptorCount); + const SizeT firstImageInfoIndex = imageInfos.size(); + for (Uint32 element = 0; element < descriptorCount; ++element) { + VkDescriptorImageInfo imageInfo{}; + Bool hasImage = false; + if (overrideThisBinding && element == 0) { + hasImage = ResolveSamplerDescriptorOverride(*samplerBindingOverride, imageInfo); + } else { + hasImage = ResolveSamplerDescriptor(commandBuffer, program, programObj, binding, element, + imageInfo, samplerDescriptorsUnchangedHint); + } + if (!hasImage) { + MGLOG_E( + "UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u element %u " + "has no valid texture descriptor", + binding, element); + return false; + } + if (imageInfo.sampler == VK_NULL_HANDLE || imageInfo.imageView == VK_NULL_HANDLE) { + MGLOG_E( + "UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u element %u " + "has null sampler or imageView", + binding, element); + return false; + } + imageInfos.push_back(imageInfo); } - if (imageInfo.sampler == VK_NULL_HANDLE || imageInfo.imageView == VK_NULL_HANDLE) { - MGLOG_E( - "UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u has null sampler or imageView", - binding); - return false; + if (descriptorCount > 1) { + // The dynamic-offset-only rebind replays a whole descriptor set on the + // strength of the sampler hint alone, and its eligibility probe was written + // for bindings that carry one descriptor each. An arrayed sampler binding + // also bypasses the per-binding descriptor memo, so there is nothing for it + // to win here either. + fastRebindKindsEligible = false; } - imageInfos.push_back(imageInfo); write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - write.pImageInfo = &imageInfos.back(); + write.descriptorCount = descriptorCount; + write.pImageInfo = &imageInfos[firstImageInfoIndex]; writes.push_back(write); } } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h index 5fc70290..21233772 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h @@ -53,10 +53,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // caches - a live layout's entry must never be purged (its sets would be // unreachable pool slots), so there is deliberately no age-based sweep here. void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout); - // One record per visited CombinedImageSampler binding (post fallback substitution, - // in binding order): the resolved texture and effective sampler, as never-reused - // lifetime ids so a freed-and-reallocated object at the same heap address can only - // MISS a comparison, never false-hit it (same ABA rule as SamplerResolveMemo). + // One record per visited CombinedImageSampler DESCRIPTOR (post fallback substitution, + // in binding order, and within a binding in array-element order): the resolved texture + // and effective sampler, as never-reused lifetime ids so a freed-and-reallocated object + // at the same heap address can only MISS a comparison, never false-hit it (same ABA + // rule as SamplerResolveMemo). An arrayed binding contributes one record per element - + // element granularity is required, or swapping the textures of two elements of the same + // array would leave the record list identical and the fast path would keep a stale set. struct SampledBindingRecord { Uint64 textureLifetimeId = 0; Uint64 samplerLifetimeId = 0; @@ -143,8 +146,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // texture after the fallback substitution (may still be null when no fallback // exists), effective sampler = unit override else the texture's own sampler. // False = the binding is skipped (unbound with a non-2D fallback target). + // `element` indexes a sampler array inside the binding; see ResolveSamplerDescriptor. Bool ResolveSampledBinding(const MG_State::GLState::ProgramObject& program, - const ProgramFactory::VkProgramObject& programObj, Uint32 binding, + const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element, MG_State::GLState::ITextureObject*& outTexture, const MG_State::GLState::SamplerObject*& outSampler) const; // Raw-pointer variant for the per-draw sampled-texture walk (CollectSampledTextures): @@ -152,27 +156,37 @@ namespace MobileGL::MG_Backend::DirectVulkan { // only need the pointer skip the SharedPtr copy's atomic refcount churn. static MG_State::GLState::ITextureObject* ResolveSamplerTextureRaw( const MG_State::GLState::ProgramObject& program, - const ProgramFactory::VkProgramObject& programObj, Uint32 binding); + const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element); SharedPtr GetFallbackTexture(TextureTarget target) const; + // `element` indexes a sampler ARRAY inside one binding; each element carries its own + // independently assigned GL texture unit, so it selects the texture, the sampler + // override and the fallback separately from its neighbours. + // // trustUnchangedHint: reuse this binding's cached VkDescriptorImageInfo outright // (see BindProgramUniformBuffers' samplerDescriptorsUnchangedHint for the proof - // obligations the caller carries). + // obligations the caller carries). The cache is keyed by binding alone, so it is + // used ONLY for single-descriptor bindings - see m_samplerResolveMemo. Bool ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, - VkDescriptorImageInfo& outImageInfo, + Uint32 element, VkDescriptorImageInfo& outImageInfo, Bool trustUnchangedHint = false) const; Bool ResolveSamplerDescriptorOverride(const SamplerBindingOverride& samplerBindingOverride, VkDescriptorImageInfo& outImageInfo) const; Bool ResolveTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 frameIndex, VkBufferView& outBufferView); + // `element` indexes a block INSTANCE array's descriptors; it is 0 for every ordinary + // block. Each element resolves through its own GL storage block, and so its own GL + // binding point, buffer and glBindBufferRange window. Bool ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, - VkDescriptorBufferInfo& outBufferInfo) const; + Uint32 element, VkDescriptorBufferInfo& outBufferInfo) const; + // `element` indexes an image ARRAY inside one binding; each element carries its own + // independently assigned GL image unit. Bool ResolveStorageImageDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, - VkDescriptorImageInfo& outImageInfo) const; + Uint32 element, VkDescriptorImageInfo& outImageInfo) const; // Result of resolving a UBO binding: either a zero-copy direct bind to the app's resident // VkBuffer (the GLES backend's approach - no per-draw copy) or the CPU payload to upload. struct UboBindResult { @@ -341,6 +355,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { // proves every resolve input unchanged; cleared with the per-frame reset // (the cached VkSampler outlives a frame only via a fresh resolve, which // also re-stamps it against VkSamplerManager's frame-boundary sweep). + // + // This one field is keyed by binding but describes ONE descriptor, so it is + // written and read only for single-descriptor bindings. A sampler ARRAY's + // elements share the binding and would overwrite each other here - the last + // element resolved would then be handed to element 0 on the next hinted draw. + // Every other field above is self-validating (each compares its full key + // before reuse, and the view-format entry is a pure function of format and + // numeric domain), so an arrayed binding may keep using those. VkDescriptorImageInfo info{}; Bool infoValid = false; }; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index 6d307eee..e5642fbd 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -111,10 +111,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { const VulkanRendererConfig& m_config; VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE; - // Values are heap-allocated: FastSTL::unordered_map is open-addressing, - // so INSERT invalidates references to stored values. The draw path (and - // the VAOs' state-pointer memos) hold entry pointers across inserts; - // only the unique_ptr cell moves, never the pointee. + // Values are heap-allocated: UnorderedMap is open-addressing, so INSERT + // invalidates references to stored values - and so does ERASE, which shifts + // the rest of the probe cluster into the hole and therefore moves entries + // other than the erased one. The draw path (and the VAOs' state-pointer + // memos) hold entry pointers across both; only the unique_ptr cell moves, + // never the pointee. UnorderedMap> m_cache; // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. Uint64 m_frameBoundaryCounter = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h index 0077d918..531a46be 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h @@ -101,6 +101,42 @@ namespace MobileGL::MG_Backend::DirectVulkan { std::swap(layers, that.layers); std::swap(lastUsedFrame, that.lastUsedFrame); } + // Move ASSIGNMENT, not just construction. The move constructor above and the + // destructor below each independently suppress the implicit one, which left the + // type move-constructible but not move-assignable - and therefore not swappable, + // which std::swap(pair&, pair&) requires. That was invisible while UnorderedMap + // only ever move-CONSTRUCTED an element into a fresh slot. ska::flat_hash_map + // probes robin-hood: inserting swaps the entry being placed against the one + // already sitting in the slot whenever it has travelled further from its desired + // position, so the mapped type has to be swappable or the table fails to + // instantiate at all. + // + // SWAP SEMANTICS, exactly like the move constructor: this does not release the + // destination's handles, it parks them in `that`, which destroys them when it + // dies. That is correct for the only caller - std::swap, whose temporary expires + // immediately - and it is what keeps the three-move sequence from destroying a + // live render pass. It is NOT correct for a hand-written `a = std::move(b)` where + // `a` held live handles and `b` outlives the statement: those handles would then + // survive until `b` dies. There is no such caller; add a destroy-then-steal + // assignment before writing one. + RenderPassEntry& operator=(RenderPassEntry&& that) noexcept { + if (this != &that) { + std::swap(hash, that.hash); + std::swap(renderPass, that.renderPass); + std::swap(framebuffer, that.framebuffer); + std::swap(compatibilityHash, that.compatibilityHash); + std::swap(pendingClearAttachments, that.pendingClearAttachments); + std::swap(trackedAttachmentLayouts, that.trackedAttachmentLayouts); + std::swap(attachmentCount, that.attachmentCount); + std::swap(colorAttachmentCount, that.colorAttachmentCount); + std::swap(hasDepthStencilAttachment, that.hasDepthStencilAttachment); + std::swap(sampleCount, that.sampleCount); + std::swap(extent, that.extent); + std::swap(layers, that.layers); + std::swap(lastUsedFrame, that.lastUsedFrame); + } + return *this; + } RenderPassEntry( Uint64 hash, VkRenderPass renderpass, @@ -315,26 +351,30 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 deferredAtFrame = 0; }; - // Node-based std::unordered_map, deliberately not FastSTL's open-addressing UnorderedMap: + // Node-based std::unordered_map, deliberately NOT the open-addressing UnorderedMap: // callers cache a RenderbufferResource* - or a bare &resource->layout - and then make further // calls that touch this map. BlitFramebuffer is the one that bit: it resolves the source and // destination colour bindings (ResolveColorBlitBinding caches &rbResource->layout), then - // materializes the source's pending clear, which looks that same resource up again. FastSTL's - // operator[] runs its load-factor check before find_key and reallocates the whole bucket array - // when occupancy crosses it, so even a plain lookup relocates every element; erase only - // tombstones and never decrements the occupancy, so the doubling keeps firing. After a - // relocation the cached pointer names freed storage still holding the pre-clear - // VK_IMAGE_LAYOUT_UNDEFINED, and BlitFramebuffer bails out at "source image layout is - // undefined", silently dropping the blit - renderbuffers_storage_multisample read back zero - // instead of the clear colour on exactly the iterations that grew the table. + // materializes the source's pending clear, which looks that same resource up again. Growing + // an open-addressed table relocates every element, so the cached pointer went on to name + // freed storage still holding the pre-clear VK_IMAGE_LAYOUT_UNDEFINED; BlitFramebuffer bailed + // out at "source image layout is undefined", silently dropping the blit - + // renderbuffers_storage_multisample read back zero instead of the clear colour on exactly the + // iterations that grew the table. // // Reordering the materialize ahead of the resolves - the fix ReadPixels got - does not cover // this: the destination resolve still runs after the source pointer is taken. The depth blit, // GetOrCreateRenderPass's depthRenderbufferResource and ReadDepthStencilPixels cache the same // kind of pointer, so the invariant belongs in the container rather than in a per-call-site - // ordering rule. m_textureResources is node-based for the same reason. This buys stability - // across rehash and insert only - erase still invalidates the erased element, which is safe - // here because a renderbuffer that is an FBO attachment is held alive by that attachment. + // ordering rule. m_textureResources is node-based for the same reason. + // + // The case for keeping this node-based got STRONGER with ska::flat_hash_map, so do not read + // the paragraph above as merely historical: ska erases by shifting the rest of the probe + // cluster backwards into the hole, so erasing one renderbuffer relocates OTHER renderbuffers' + // entries - a cached pointer can now be invalidated by a key it has nothing to do with, which + // no call-site ordering rule can defend against. (What did change: ska's operator[] returns on + // a hit before it runs its grow check, so a plain lookup of a PRESENT key no longer relocates. + // That narrows the insert hazard; it does not touch the erase one.) std::unordered_map m_renderbufferResources; UnorderedMap m_pendingRenderbufferClears; Vector m_deferredRenderbufferReleases; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 027fd5e3..62f812c0 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -220,6 +220,47 @@ namespace MobileGL::MG_Backend::DirectVulkan { return static_cast((static_cast(value) * toExtent + fromExtent / 2) / fromExtent); } + // --------------------------------------------------------------------------------------- + // Default-framebuffer rectangles. + // + // GL's window origin is the BOTTOM-left. The default framebuffer's Vulkan image is stored in + // DISPLAY (top-left) orientation, and the difference is reconciled for VERTICES by negating + // gl_Position.y - but only for default-FBO draws (GetShaderTransformFlags -> + // CompileOptionBit::PositionYFlip, applied in ProgramFactory::InsertPositionFixup). + // + // Rectangles were never converted. The viewport, the scissor and the ReadPixels copy offset + // all used the GL bottom-origin Y verbatim as a Vulkan top-origin Y, which is correct only + // when y == H - y - h (full height, or vertically centred) - and full height is the only case + // any test ever exercised. In the conformance suite the errors CANCEL in placement (the draw + // lands in Vulkan rows [y, y+h) and the readback copies the same rows back) and compose into + // an exact vertical flip: 1,759 of Magma's 1,793 non-passing cases, 861 vertical flips and + // nothing else across all of gl33. + // + // The mapping below is derived from - and at full extent exactly reproduces - the pixel + // mapping RemapDefaultFboReadbackToGLOrientation has always used: + // identity : image(x, H-1-y) -> flip Y + // 180 : image(W-1-x, y) -> mirror X (the rotation already flips the rows) + // Quarter turns swap the axes; nothing in this renderer models that (the readback declines to + // remap them and the viewport path only rescales), so they are left exactly as they were. + struct DefaultFramebufferRectMapping { + Bool flipY = false; + Bool mirrorX = false; + }; + + static DefaultFramebufferRectMapping GetDefaultFramebufferRectMapping( + VkSurfaceTransformFlagBitsKHR preTransform) { + if (preTransform == VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR) return {false, true}; + if (IsQuarterTurnPreTransform(preTransform)) return {false, false}; + return {true, false}; + } + + // [origin, origin+size) counted from one end is [extent-origin-size, extent-origin) counted + // from the other. A full-extent rect is a fixed point, which is why this can be introduced + // without moving anything that works today. + static Int MapDefaultFramebufferRectAxis(Int origin, Int size, Int extent, Bool invert) { + return invert ? extent - origin - size : origin; + } + // Redundant dynamic-state elimination for the per-draw hot path: within one // command-buffer recording, a vkCmdSet* whose values already match what the // command buffer holds is skipped. Valid because every PipelineFactory @@ -417,6 +458,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { viewportHeight = ScaleFramebufferCoordinate(viewportHeight, logicalExtent.y(), framebufferExtent.y()); } + // The GL viewport rect, expressed against the default framebuffer's stored orientation. + // A full-height viewport is unchanged by this, which is why every existing scenario keeps + // its exact behaviour. + if (isDefaultFramebuffer) { + const DefaultFramebufferRectMapping mapping = GetDefaultFramebufferRectMapping(preTransform); + viewportX = MapDefaultFramebufferRectAxis(viewportX, viewportWidth, framebufferExtent.x(), + mapping.mirrorX); + viewportY = MapDefaultFramebufferRectAxis(viewportY, viewportHeight, framebufferExtent.y(), + mapping.flipY); + } + VkViewport viewport{}; viewport.x = static_cast(viewportX); viewport.y = static_cast(viewportY); @@ -518,11 +570,25 @@ namespace MobileGL::MG_Backend::DirectVulkan { return scissor; } + // The clamped rect, re-expressed against the default framebuffer's stored orientation. Same + // conversion as the viewport - and it must be the same one, or the scissor would cut a band + // the draw never touched. + static VkRect2D MapScissorRectToDefaultFramebuffer(VkRect2D scissor, const IntVec2& framebufferExtent, + VkSurfaceTransformFlagBitsKHR preTransform) { + const DefaultFramebufferRectMapping mapping = GetDefaultFramebufferRectMapping(preTransform); + scissor.offset.x = MapDefaultFramebufferRectAxis(scissor.offset.x, static_cast(scissor.extent.width), + framebufferExtent.x(), mapping.mirrorX); + scissor.offset.y = MapDefaultFramebufferRectAxis(scissor.offset.y, static_cast(scissor.extent.height), + framebufferExtent.y(), mapping.flipY); + return scissor; + } + static VkRect2D MakeDefaultFramebufferScissorRect(const IntVec4& scissorBox, const IntVec2& framebufferExtent, VkSurfaceTransformFlagBitsKHR preTransform) { if (!IsQuarterTurnPreTransform(preTransform)) { - return MakeClampedScissorRect(scissorBox, framebufferExtent); + return MapScissorRectToDefaultFramebuffer(MakeClampedScissorRect(scissorBox, framebufferExtent), + framebufferExtent, preTransform); } const IntVec2 logicalExtent = ResolveDefaultFramebufferLogicalExtent(preTransform, framebufferExtent); @@ -542,7 +608,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(std::max(0, rawX1 - rawX0)), static_cast(std::max(0, rawY1 - rawY0)), }; - return scissor; + // A quarter turn maps to {false, false}, so this is a no-op today; it is here so the + // branch cannot drift away from the identity/180 one when quarter turns are modelled. + return MapScissorRectToDefaultFramebuffer(scissor, framebufferExtent, preTransform); } static void ApplyStencilState(VkCommandBuffer commandBuffer) { @@ -1928,6 +1996,29 @@ void main() { } } + // The same conversion on the READ side, which never had one: a blit whose source is the + // default framebuffer used raw GL offsets against a display-oriented image, so it sampled + // the mirrored band and wrote it upside down. Mapping BOTH endpoints inverts the offset + // pair, and an inverted pair is exactly how VkImageBlit spells "flip this axis" - so the + // band and the row order are corrected in one step. A full-extent blit is unchanged in + // band and gains the row flip it always needed. + static void ApplyNativeBlitDefaultFramebufferSourceTransform(VkSurfaceTransformFlagBitsKHR preTransform, + const BlitImageBinding& srcBinding, + VkImageBlit& blitRegion) { + switch (preTransform) { + case VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR: + blitRegion.srcOffsets[0].y = srcBinding.extent.y() - blitRegion.srcOffsets[0].y; + blitRegion.srcOffsets[1].y = srcBinding.extent.y() - blitRegion.srcOffsets[1].y; + break; + case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR: + blitRegion.srcOffsets[0].x = srcBinding.extent.x() - blitRegion.srcOffsets[0].x; + blitRegion.srcOffsets[1].x = srcBinding.extent.x() - blitRegion.srcOffsets[1].x; + break; + default: + break; + } + } + static Bool DecodeReadbackPixel(const Uint8* source, VkFormat sourceFormat, Float* rgba) { switch (sourceFormat) { case VK_FORMAT_R8G8B8A8_UNORM: @@ -2033,42 +2124,42 @@ void main() { return static_cast(value * 255.0f + 0.5f); } - // Remap raw swapchain pixels (top-left origin, preTransform-rotated) into - // GL-oriented pixels (bottom-left origin) for the retrace snapshot path. - // Mirrors the removed GetPresentedDumpPixel mapping plus the Y-origin flip - // apitrace's flipped=true Image expects. Only identity/180 share the - // swapchain extent with the default framebuffer; 90/270 swap extents and - // are not handled here. + // Re-order the copied BLOCK - not the whole image - from the default framebuffer's stored + // orientation into GL's. The caller has already aimed the copy at the right place with + // MapDefaultFramebufferRectAxis, so what arrives here is exactly the requested + // rectWidth x rectHeight rect, and all that is left is the order of rows (identity) or of + // columns (180) WITHIN it. + // + // This used to iterate the full swapchain extent and index both sides with that stride, + // which is why its caller could only use it on an exact full-extent read - and why every + // partial glReadPixels of the default framebuffer came back in Vulkan row order. Only + // identity/180 share the swapchain extent with the default framebuffer; 90/270 swap + // extents and are still declined. static Bool RemapDefaultFboReadbackToGLOrientation(const Uint8* rawPixels, - VkExtent2D rawExtent, + Uint32 rectWidth, + Uint32 rectHeight, VkSurfaceTransformFlagBitsKHR preTransform, SizeT texelSize, Uint8* outPixels) { if (IsQuarterTurnPreTransform(preTransform)) { return false; } - const Uint32 w = rawExtent.width; - const Uint32 h = rawExtent.height; - if (w == 0 || h == 0) { + if (rectWidth == 0 || rectHeight == 0 || texelSize == 0) { return false; } - for (Uint32 outY = 0; outY < h; ++outY) { - const Uint32 displayY = h - 1 - outY; // GL bottom-origin -> display top-origin - for (Uint32 outX = 0; outX < w; ++outX) { - const Uint32 displayX = outX; - Uint32 rawX = displayX; - Uint32 rawY = displayY; - switch (preTransform) { - case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR: - rawX = w - 1 - displayX; - rawY = h - 1 - displayY; - break; - default: - break; - } - const Uint8* src = rawPixels + (static_cast(rawY) * w + rawX) * texelSize; - Uint8* dst = outPixels + (static_cast(outY) * w + outX) * texelSize; - Memcpy(dst, src, texelSize); + const DefaultFramebufferRectMapping mapping = GetDefaultFramebufferRectMapping(preTransform); + const SizeT rowBytes = static_cast(rectWidth) * texelSize; + for (Uint32 outY = 0; outY < rectHeight; ++outY) { + const Uint32 srcY = mapping.flipY ? (rectHeight - 1 - outY) : outY; + const Uint8* srcRow = rawPixels + static_cast(srcY) * rowBytes; + Uint8* dstRow = outPixels + static_cast(outY) * rowBytes; + if (!mapping.mirrorX) { + Memcpy(dstRow, srcRow, rowBytes); + continue; + } + for (Uint32 outX = 0; outX < rectWidth; ++outX) { + Memcpy(dstRow + static_cast(outX) * texelSize, + srcRow + static_cast(rectWidth - 1 - outX) * texelSize, texelSize); } } return true; @@ -2688,6 +2779,14 @@ void main() { MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (currentDrawFBO != nullptr && currentDrawFBO->IsDefaultFramebuffer()) { flags |= ProgramFactory::CompileOptionBit::PositionYFlip; + // gl_FragCoord follows the same rule the default-framebuffer RECTANGLES follow + // (GetDefaultFramebufferRectMapping): flipped for identity/180, left alone under a + // quarter turn, which this renderer converts nothing for. Keeping the two in step + // is the whole point - a fragment's window Y and the viewport that placed it must + // agree on which end of the image they count from. + if (!IsQuarterTurnPreTransform(preTransform)) { + flags |= ProgramFactory::CompileOptionBit::FragCoordYFlip; + } switch (preTransform) { case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR: flags |= ProgramFactory::CompileOptionBit::SurfaceRotate90; @@ -2842,6 +2941,9 @@ void main() { m_shaderDrawParametersFeatureEnabled, m_unformattedFloatStorageImagesEnabled); MOBILEGL_ASSERT(m_programFactory != nullptr, "ProgramFactory creation failed."); + // The swapchain already exists at this point (Initialize creates it first), so seed the + // height the factory could not be told about from CreateSwapchain. + m_programFactory->SetDefaultFramebufferHeight(m_swapchainObject.GetExtent().height); // Aging evictions (render passes and program entries) must purge the dependent // pipeline / compute-pipeline / descriptor-set caches in the same step; both // sweeps only run from the frame-boundary seams, long after initialization. @@ -2914,6 +3016,7 @@ void main() { DestroySubmitFencePool(); DestroyDeferredDepthMipmapCleanup(); + DestroyMultisampleResolveScratchImage(); DestroyComputePipelines(); // No sweep runs during teardown, but the observers point at this renderer @@ -4049,7 +4152,8 @@ void main() { .depthWriteEnable = false, .depthCompareOp = VK_COMPARE_OP_ALWAYS, .stages = &programObj.stages, - .vertexInputState = &kEmptyVertexInputState + .vertexInputState = &kEmptyVertexInputState, + .stageSpirvDigests = &programObj.stageSpirvDigests }; static constexpr VkColorComponentFlags kColorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | @@ -4478,7 +4582,6 @@ void main() { MGLOG_D("GetOrCreatePipeline skipped: program has no shader stages"); return VK_NULL_HANDLE; } - // Fast path: skip the full pipeline resolution when the pipeline state is unchanged from the // previous draw (the common intra-batch case). The key provably covers every // PipelineCreatePayload field: draw mode (topology + polygon-fill depth-bias gate), program @@ -4520,6 +4623,43 @@ void main() { } } + // Shape gate. Behind the memo probe deliberately: only a pipeline that was created + // successfully is ever memoized, so a program refused here can never be sitting in the + // memo, and the steady-state draw keeps paying nothing for the check. + // + // vkCreateGraphicsPipelines is not a validating entry point: a stage set that a + // conformant implementation would reject with VK_ERROR_* is, on Adreno 830, a SIGSEGV + // inside the driver - process death instead of a failed draw. The separable-program path + // is what made these shapes reachable at all (a monolithic glUseProgram program cannot + // hold a compute stage together with graphics ones, a pipeline object can), so the three + // it can produce are named and refused here. Same philosophy as the VK_NULL_HANDLE gate + // in SetupDraw: hostile input degrades to a broken draw, never to a dead process. GL + // leaves all three undefined for a draw, so nothing legal is being turned away. + // MGLOG_I because the INFO builds CTS runs against keep only I and F. + { + Bool hasVertexStage = false; + for (const auto& stage : programObj.stages) { + if (stage.module == VK_NULL_HANDLE) { + MGLOG_I("GetOrCreatePipeline skipped: program=%u has a null shader module for stage 0x%x", + program.GetExternalIndex(), static_cast(stage.stage)); + return VK_NULL_HANDLE; + } + if (stage.stage == VK_SHADER_STAGE_COMPUTE_BIT) { + MGLOG_I("GetOrCreatePipeline skipped: program=%u carries a compute stage, which no graphics " + "pipeline may contain", + program.GetExternalIndex()); + return VK_NULL_HANDLE; + } + if (stage.stage == VK_SHADER_STAGE_VERTEX_BIT) { + hasVertexStage = true; + } + } + if (!hasVertexStage) { + MGLOG_I("GetOrCreatePipeline skipped: program=%u has no vertex stage", program.GetExternalIndex()); + return VK_NULL_HANDLE; + } + } + #if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG const auto& limits = m_physicalDevice.properties.limits; if (programObj.fragmentInputComponentCount != 0) { @@ -4727,7 +4867,8 @@ void main() { .backStencilCompareOp = MG_Util::ConvertDepthTestFuncToVkEnum(backStencil.Func), .fragmentReplacesDepth = programObj.fragmentReplacesDepth, .stages = &programObj.stages, - .vertexInputState = pipelineVertexInputState + .vertexInputState = pipelineVertexInputState, + .stageSpirvDigests = &programObj.stageSpirvDigests }; if (!payload.stencilTestEnable) { payload.frontStencilFailOp = VK_STENCIL_OP_KEEP; @@ -5911,6 +6052,17 @@ void main() { } auto pipeline = GetOrCreatePipeline(mode, program, programObj, transformFlags, vao, *renderPassEntry); + // GetOrCreatePipeline documents a VK_NULL_HANDLE return (empty stages, or a driver that + // rejected vkCreateGraphicsPipelines). Binding it dereferences null inside the driver - + // 9 of the 15 CTS process deaths were exactly this vkCmdBindPipeline. A draw that has no + // pipeline is a skipped draw, which is what every other failure below already does. + // MGLOG_I so the skip is visible in the INFO builds CTS runs against. + if (pipeline == VK_NULL_HANDLE) { + MGLOG_I("SetupDraw skipped: no graphics pipeline for program=%u (creation failed or the " + "program has no shader stages)", + program.GetExternalIndex()); + return false; + } activeRenderPass = VkRenderPassManager::GetActiveRenderPass(); // Begin render pass, and handle clear @@ -6028,7 +6180,9 @@ void main() { void VulkanRenderer::DispatchCompute(GLuint numGroupsX, GLuint numGroupsY, GLuint numGroupsZ) { m_textureManager->CollectGarbage(); auto& frame = m_frameContext.GetCurrent(); - const auto& program = *MG_State::pGLContext->GetProgramForDraw(); + // The DISPATCH accessor: with a pipeline bound this is its compute stage program + // itself, never the graphics composite (which carries no compute stage at all). + const auto& program = *MG_State::pGLContext->GetProgramForDispatch(); if (!program.GetLinkStatus() || !program.GetSpirvStatus()) { MGLOG_E("DispatchCompute skipped: program=%u has no optimized SPIR-V", program.GetExternalIndex()); @@ -6073,7 +6227,8 @@ void main() { void VulkanRenderer::DispatchComputeIndirect(GLintptr indirect) { m_textureManager->CollectGarbage(); auto& frame = m_frameContext.GetCurrent(); - const auto& program = *MG_State::pGLContext->GetProgramForDraw(); + // See DispatchCompute: the dispatch accessor, not the draw one. + const auto& program = *MG_State::pGLContext->GetProgramForDispatch(); if (!program.GetLinkStatus() || !program.GetSpirvStatus()) { MGLOG_E("DispatchComputeIndirect skipped: program=%u has no optimized SPIR-V", program.GetExternalIndex()); @@ -7079,6 +7234,244 @@ void main() { return true; } + void VulkanRenderer::DestroyMultisampleResolveScratchImage() { + if (m_msResolveScratch.image != VK_NULL_HANDLE) { + vmaDestroyImage(m_allocator, m_msResolveScratch.image, m_msResolveScratch.allocation); + } + m_msResolveScratch = {}; + } + + Bool VulkanRenderer::AcquireMultisampleResolveScratchImage(VkCommandBuffer commandBuffer, VkFormat format, + VkExtent2D extent) { + if (extent.width == 0 || extent.height == 0 || format == VK_FORMAT_UNDEFINED) { + return false; + } + // Grow-only, and never shrink: these blits repeat at one or two sizes, so the steady state + // is one allocation for the whole process. + if (m_msResolveScratch.image == VK_NULL_HANDLE || m_msResolveScratch.format != format || + m_msResolveScratch.extent.width < extent.width || m_msResolveScratch.extent.height < extent.height) { + const VkExtent2D grown = {std::max(extent.width, m_msResolveScratch.extent.width), + std::max(extent.height, m_msResolveScratch.extent.height)}; + DestroyMultisampleResolveScratchImage(); + + VkImageCreateInfo imageInfo{}; + imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imageInfo.imageType = VK_IMAGE_TYPE_2D; + imageInfo.format = format; + imageInfo.extent = {grown.width, grown.height, 1}; + imageInfo.mipLevels = 1; + imageInfo.arrayLayers = 1; + imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + VmaAllocationCreateInfo allocationInfo{}; + allocationInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE; + allocationInfo.requiredFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT; + if (vmaCreateImage(m_allocator, &imageInfo, &allocationInfo, &m_msResolveScratch.image, + &m_msResolveScratch.allocation, nullptr) != VK_SUCCESS) { + // Soft failure: the caller keeps the direct resolve, which is what shipped before. + MGLOG_E("AcquireMultisampleResolveScratchImage: vmaCreateImage failed (format=%d %ux%u)", + static_cast(format), grown.width, grown.height); + m_msResolveScratch = {}; + return false; + } + m_msResolveScratch.format = format; + m_msResolveScratch.extent = grown; + m_msResolveScratch.layout = VK_IMAGE_LAYOUT_UNDEFINED; + } + + VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + VkAccessFlags srcAccessMask = 0; + GetImageTransitionSourceState(m_msResolveScratch.layout, srcStageMask, srcAccessMask); + if (!VkTextureManager::TransitionImageLayout(commandBuffer, m_msResolveScratch.image, + m_msResolveScratch.layout, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, srcStageMask, + VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_IMAGE_ASPECT_COLOR_BIT)) { + return false; + } + return true; + } + + // The aspects a depth/stencil format actually carries. VkTextureManager keeps its own copy of + // this private, and the swapchain's depth/stencil image has no TextureResource to ask. + static VkImageAspectFlags GetDepthStencilAspectMaskForFormat(VkFormat format) { + switch (format) { + case VK_FORMAT_D16_UNORM: + case VK_FORMAT_X8_D24_UNORM_PACK32: + case VK_FORMAT_D32_SFLOAT: + return VK_IMAGE_ASPECT_DEPTH_BIT; + case VK_FORMAT_D16_UNORM_S8_UINT: + case VK_FORMAT_D24_UNORM_S8_UINT: + case VK_FORMAT_D32_SFLOAT_S8_UINT: + return VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT; + case VK_FORMAT_S8_UINT: + return VK_IMAGE_ASPECT_STENCIL_BIT; + default: + return VK_IMAGE_ASPECT_NONE; + } + } + + // The depth/stencil half of MaterializePendingClearForDefaultFramebuffer. Separate only + // because the image, the aspects and the clear value are all different from the colour one; + // the reason it exists is the same - a readback with no intervening draw has no render pass + // to fold the parked clear into. + Bool VulkanRenderer::MaterializePendingDepthStencilClearForDefaultFramebuffer( + VkCommandBuffer commandBuffer, const MG_State::GLState::FramebufferAttachmentObject& attachment, + const ClearAttachmentPayload& payload) { + const VkImage depthStencilImage = m_swapchainObject.GetDepthStencilImage(m_imageIndexAcquired); + if (depthStencilImage == VK_NULL_HANDLE) { + return false; + } + const VkImageAspectFlags imageAspects = + GetDepthStencilAspectMaskForFormat(m_swapchainObject.GetDepthStencilFormat()); + VkImageAspectFlags clearAspects = 0; + if ((payload.mask & GL_DEPTH_BUFFER_BIT) != 0) clearAspects |= (imageAspects & VK_IMAGE_ASPECT_DEPTH_BIT); + if ((payload.mask & GL_STENCIL_BUFFER_BIT) != 0) clearAspects |= (imageAspects & VK_IMAGE_ASPECT_STENCIL_BIT); + if (clearAspects == 0) { + // Nothing this image can express; drop the pending clear rather than leave it to a + // later render pass that would load it against an aspect that does not exist. + m_clearManager->PopPendingClear(attachment); + return true; + } + + VkImageLayout currentLayout = m_swapchainObject.GetDepthStencilImageLayout(m_imageIndexAcquired); + VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + VkAccessFlags srcAccessMask = 0; + GetImageTransitionSourceState(currentLayout, srcStageMask, srcAccessMask); + VkImageLayout clearLayout = currentLayout; + if (!VkTextureManager::TransitionImageLayout(commandBuffer, depthStencilImage, clearLayout, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, srcStageMask, + VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask, + VK_ACCESS_TRANSFER_WRITE_BIT, imageAspects)) { + return false; + } + + VkClearDepthStencilValue clearValue{}; + clearValue.depth = payload.depth; + clearValue.stencil = payload.stencil; + VkImageSubresourceRange range{}; + range.aspectMask = clearAspects; + range.baseMipLevel = 0; + range.levelCount = 1; + range.baseArrayLayer = 0; + range.layerCount = 1; + vkCmdClearDepthStencilImage(commandBuffer, depthStencilImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearValue, + 1, &range); + + VkImageLayout settledLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + VkAccessFlags dstAccessMask = 0; + GetImageTransitionDestinationState(VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, dstStageMask, + dstAccessMask); + if (!VkTextureManager::TransitionImageLayout(commandBuffer, depthStencilImage, settledLayout, + VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, dstStageMask, + VK_ACCESS_TRANSFER_WRITE_BIT, dstAccessMask, imageAspects)) { + return false; + } + m_swapchainObject.SetDepthStencilImageLayout(m_imageIndexAcquired, + VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL); + // The image now holds real values, so the next render pass must LOAD them rather than + // treat the attachment as undefined and discard the clear that just executed. + m_swapchainObject.SetDepthStencilContentDefined(m_imageIndexAcquired, true); + + m_clearManager->PopPendingClear(attachment); + MGLOG_D("MaterializePendingClearForDefaultFramebuffer: swapchain depth/stencil image %u pending clear " + "materialized (aspects=0x%x)", + m_imageIndexAcquired, static_cast(clearAspects)); + return true; + } + + // A glClear on the DEFAULT framebuffer is parked as a pending clear and folded into the next + // render pass's loadOp. With no draw in between there is no render pass, so a readback that + // followed such a clear blitted the untouched swapchain image and returned the PREVIOUS + // frame's colour - which is exactly what the whole KHR-GL40.draw_indirect.negative-* family + // sees (clear, an erroring draw that never executes, glReadPixels expecting zeroes). + // + // Materializing it means clearing the acquired swapchain image itself, which is why this + // cannot reuse MaterializePendingClearForTexture: the default FBO's colour attachment is a + // placeholder ITextureObject, and syncing it would allocate and clear an unrelated image. + Bool VulkanRenderer::MaterializePendingClearForDefaultFramebuffer(VkCommandBuffer commandBuffer, + MG_State::GLState::FramebufferObject& fbo, + FramebufferAttachmentType attachmentType) { + if (!fbo.IsDefaultFramebuffer() || attachmentType == FramebufferAttachmentType::None) { + return true; + } + const auto& attachment = fbo.GetAttachment(attachmentType); + if (!attachment.IsTexture() || attachment.IsRenderbuffer()) { + return true; + } + ClearAttachmentPayload payload{}; + if (!m_clearManager->GetPendingClear(attachment, payload)) { + return true; + } + MOBILEGL_ASSERT(VkRenderPassManager::GetActiveRenderPass() == nullptr || + commandBuffer != m_frameContext.GetCurrent().commandBuffer, + "MaterializePendingClearForDefaultFramebuffer requires no active render pass"); + + if ((payload.mask & GL_COLOR_BUFFER_BIT) == 0) { + return MaterializePendingDepthStencilClearForDefaultFramebuffer(commandBuffer, attachment, payload); + } + + const VkImage swapchainImage = m_swapchainObject.GetImage(m_imageIndexAcquired); + if (swapchainImage == VK_NULL_HANDLE) { + return false; + } + VkImageLayout currentLayout = m_swapchainObject.GetImageLayout(m_imageIndexAcquired); + VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + VkAccessFlags srcAccessMask = 0; + GetImageTransitionSourceState(currentLayout, srcStageMask, srcAccessMask); + VkImageLayout clearLayout = currentLayout; + if (!VkTextureManager::TransitionImageLayout(commandBuffer, swapchainImage, clearLayout, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, srcStageMask, + VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_IMAGE_ASPECT_COLOR_BIT)) { + return false; + } + + // The clear colour goes in verbatim, alpha included. Forcing opaque alpha here is what + // makes a glClear(0,0,0,0) read back as (0,0,0,1) - the default framebuffer's placeholder + // attachment can describe an alpha-less format while the swapchain image it stands for + // has a real alpha channel. + VkClearColorValue clearColor{}; + clearColor.float32[0] = payload.color.x(); + clearColor.float32[1] = payload.color.y(); + clearColor.float32[2] = payload.color.z(); + clearColor.float32[3] = payload.color.w(); + VkImageSubresourceRange range{}; + range.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + range.baseMipLevel = 0; + range.levelCount = 1; + range.baseArrayLayer = 0; + range.layerCount = 1; + vkCmdClearColorImage(commandBuffer, swapchainImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1, + &range); + + VkImageLayout settledLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + VkAccessFlags dstAccessMask = 0; + GetImageTransitionDestinationState(VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, dstStageMask, dstAccessMask); + if (!VkTextureManager::TransitionImageLayout(commandBuffer, swapchainImage, settledLayout, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, dstStageMask, + VK_ACCESS_TRANSFER_WRITE_BIT, dstAccessMask, + VK_IMAGE_ASPECT_COLOR_BIT)) { + return false; + } + m_swapchainObject.SetImageLayout(m_imageIndexAcquired, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + + // Popped, not left behind: the clear has executed, so letting the next render pass load + // it again as a loadOp would erase whatever is drawn between here and there. + m_clearManager->PopPendingClear(attachment); + MGLOG_D("MaterializePendingClearForDefaultFramebuffer: swapchain image %u pending clear materialized", + m_imageIndexAcquired); + return true; + } + Bool VulkanRenderer::TryBlitToDefaultFramebufferWithShader(FrameContext::FrameData& frame, MG_State::GLState::FramebufferObject& readFbo, MG_State::GLState::FramebufferObject& drawFbo, @@ -7368,12 +7761,21 @@ void main() { } } - if (!drawIsDefaultFbo) { + const auto destAttachmentType = + ResolveFramebufferCopyAttachmentType(*drawFbo, false, dstBinding.aspectMask); + if (drawIsDefaultFbo) { + // Same ordering rule for the default framebuffer's depth/stencil - see the + // colour twin below. + const Bool dstClearReady = MaterializePendingClearForDefaultFramebuffer( + frame.commandBuffer, *drawFbo, destAttachmentType); + MOBILEGL_ASSERT(dstClearReady, + "BlitFramebuffer: failed to materialize the default framebuffer's pending " + "depth/stencil clear"); + } else { // A clear queued for the destination predates this blit in API order; // execute it now, or its deferred materialization would later stomp the // copied contents (MC 26.3 OIT clears cloud_depth, then blits the main // depth into it - the stale loadOp=CLEAR erased the copy). - const auto destAttachmentType = ResolveFramebufferCopyAttachmentType(*drawFbo, false, dstBinding.aspectMask); const auto& destAttachment = drawFbo->GetAttachment(destAttachmentType); if (auto destTexture = destAttachment.GetTexture(); destTexture != nullptr) { const Bool dstClearReady = MaterializePendingClearForTexture(frame.commandBuffer, *destTexture); @@ -7466,7 +7868,15 @@ void main() { MOBILEGL_ASSERT(ok, "%s: failed to transition depth destination image", __func__); } - if (depthBlitScales) { + // The default framebuffer is stored display-side-up, so a rect aimed at it (or read + // from it) has to be converted out of GL's bottom-origin space - the same conversion + // the colour blit below applies. vkCmdCopyImage cannot express it (it has no second + // offset to invert), so a default-framebuffer side forces the vkCmdBlitImage form even + // at equal size. Without this a scissored depth blit into the default framebuffer + // wrote the MIRRORED band: KHR-GL*.framebuffer_blit.scissor_blit clips to the lower + // left quadrant, and the depth landed in the upper one. + const Bool depthBlitNeedsOrientation = readIsDefaultFbo || drawIsDefaultFbo; + if (depthBlitScales || depthBlitNeedsOrientation) { // vkCmdCopyImage cannot resize; NEAREST is the only filter Vulkan allows for a // depth/stencil blit anyway, and the GL front end already rejects the others. VkImageBlit blitRegion{}; @@ -7482,6 +7892,14 @@ void main() { blitRegion.dstSubresource.layerCount = dstBinding.layerCount; blitRegion.dstOffsets[0] = {dstX0, dstY0, 0}; blitRegion.dstOffsets[1] = {dstX1, dstY1, 1}; + if (readIsDefaultFbo) { + ApplyNativeBlitDefaultFramebufferSourceTransform(m_swapchainObject.GetPreTransform(), srcBinding, + blitRegion); + } + if (drawIsDefaultFbo) { + ApplyNativeBlitDefaultFramebufferTransform(m_swapchainObject.GetPreTransform(), dstBinding, + blitRegion); + } vkCmdBlitImage(frame.commandBuffer, srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, dstBinding.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, @@ -7578,7 +7996,19 @@ void main() { } } - if (!drawIsDefaultFbo) { + if (drawIsDefaultFbo) { + // The default framebuffer needs the same ordering, and needed it before anything + // consumed its parked clear: Minecraft clears the default framebuffer, renders the + // world into its own framebuffer and BLITS the result out, so nothing between the + // clear and the blit ever opens a render pass on the default framebuffer to fold the + // clear in as a loadOp. The clear therefore stayed pending across the whole frame, + // and the first path that did materialize it - the readback - executed it AFTER the + // blit and handed back a blank frame (every DirectVulkan retrace, ssim 0.000005). + const Bool dstClearReady = MaterializePendingClearForDefaultFramebuffer( + frame.commandBuffer, *drawFbo, drawFbo->GetDrawBuffers()[0]); + MOBILEGL_ASSERT(dstClearReady, + "BlitFramebuffer: failed to materialize the default framebuffer's pending clear"); + } else { // A clear queued for the destination predates this blit in API order; execute // it now, or its deferred materialization would later stomp the blitted color. const auto& destAttachment = drawFbo->GetAttachment(drawFbo->GetDrawBuffers()[0]); @@ -7667,24 +8097,87 @@ void main() { blitRegion.dstSubresource.layerCount = dstBinding.layerCount; blitRegion.dstOffsets[0] = {dstX0, dstY0, 0}; blitRegion.dstOffsets[1] = {dstX1, dstY1, 1}; + if (readIsDefaultFbo) { + ApplyNativeBlitDefaultFramebufferSourceTransform(m_swapchainObject.GetPreTransform(), srcBinding, + blitRegion); + } if (drawIsDefaultFbo) { ApplyNativeBlitDefaultFramebufferTransform(m_swapchainObject.GetPreTransform(), dstBinding, blitRegion); } if (srcBinding.sampleCount != VK_SAMPLE_COUNT_1_BIT && dstBinding.sampleCount == VK_SAMPLE_COUNT_1_BIT) { // GL multisample resolve blits are 1:1 by spec; vkCmdBlitImage cannot read a - // multisampled source. + // multisampled source, so the samples have to come down through vkCmdResolveImage. + const Uint32 resolveWidth = static_cast(std::abs(srcX1 - srcX0)); + const Uint32 resolveHeight = static_cast(std::abs(srcY1 - srcY0)); + + // vkCmdResolveImage takes ONE offset per side, so it cannot express the axis inversion + // that a default-framebuffer rect needs - it would land the mirrored band. When the + // transforms above actually moved the region, split the operation: resolve into a + // single-sample scratch image at raw offsets, then blit THAT into the destination with + // the (already transformed) region, which vkCmdBlitImage can invert. + const Bool regionWasTransformed = + (readIsDefaultFbo || drawIsDefaultFbo) && + (blitRegion.srcOffsets[0].x != srcX0 || blitRegion.srcOffsets[0].y != srcY0 || + blitRegion.srcOffsets[1].x != srcX1 || blitRegion.srcOffsets[1].y != srcY1 || + blitRegion.dstOffsets[0].x != dstX0 || blitRegion.dstOffsets[0].y != dstY0 || + blitRegion.dstOffsets[1].x != dstX1 || blitRegion.dstOffsets[1].y != dstY1); + const Bool useScratchResolve = + regionWasTransformed && resolveWidth > 0 && resolveHeight > 0 && + AcquireMultisampleResolveScratchImage(frame.commandBuffer, srcBinding.format, + {resolveWidth, resolveHeight}); + VkImageResolve resolveRegion{}; resolveRegion.srcSubresource = blitRegion.srcSubresource; - resolveRegion.srcOffset = {std::min(srcX0, srcX1), std::min(srcY0, srcY1), 0}; resolveRegion.dstSubresource = blitRegion.dstSubresource; - resolveRegion.dstOffset = {std::min(dstX0, dstX1), std::min(dstY0, dstY1), 0}; - resolveRegion.extent = {static_cast(std::abs(srcX1 - srcX0)), - static_cast(std::abs(srcY1 - srcY0)), 1}; - vkCmdResolveImage(frame.commandBuffer, - srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - dstBinding.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, - 1, &resolveRegion); + resolveRegion.extent = {resolveWidth, resolveHeight, 1}; + if (useScratchResolve) { + // The scratch copy is a plain single-layer colour image, and the resolve reads the + // SOURCE band the (possibly inverted) transformed region names - taking its min so + // an inverted pair still describes the same band. + resolveRegion.srcOffset = {std::min(blitRegion.srcOffsets[0].x, blitRegion.srcOffsets[1].x), + std::min(blitRegion.srcOffsets[0].y, blitRegion.srcOffsets[1].y), 0}; + resolveRegion.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + resolveRegion.dstSubresource.mipLevel = 0; + resolveRegion.dstSubresource.baseArrayLayer = 0; + resolveRegion.dstSubresource.layerCount = 1; + resolveRegion.dstOffset = {0, 0, 0}; + vkCmdResolveImage(frame.commandBuffer, + srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + m_msResolveScratch.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + 1, &resolveRegion); + + VkImageLayout scratchLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + const Bool scratchReady = VkTextureManager::TransitionImageLayout( + frame.commandBuffer, m_msResolveScratch.image, scratchLayout, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, + VK_IMAGE_ASPECT_COLOR_BIT); + MOBILEGL_ASSERT(scratchReady, "%s: failed to transition the resolve scratch image", __func__); + m_msResolveScratch.layout = scratchLayout; + + // Second leg: the scratch image holds the resolved band at its own origin, so the + // source side of the region becomes the whole scratch rect and only the + // destination keeps the transform. + VkImageBlit scratchBlit = blitRegion; + scratchBlit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + scratchBlit.srcSubresource.mipLevel = 0; + scratchBlit.srcSubresource.baseArrayLayer = 0; + scratchBlit.srcSubresource.layerCount = 1; + scratchBlit.srcOffsets[0] = {0, 0, 0}; + scratchBlit.srcOffsets[1] = {static_cast(resolveWidth), static_cast(resolveHeight), 1}; + vkCmdBlitImage(frame.commandBuffer, + m_msResolveScratch.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + dstBinding.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + 1, &scratchBlit, filter == GL_LINEAR ? VK_FILTER_LINEAR : VK_FILTER_NEAREST); + } else { + resolveRegion.srcOffset = {std::min(srcX0, srcX1), std::min(srcY0, srcY1), 0}; + resolveRegion.dstOffset = {std::min(dstX0, dstX1), std::min(dstY0, dstY1), 0}; + vkCmdResolveImage(frame.commandBuffer, + srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + dstBinding.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + 1, &resolveRegion); + } } else { vkCmdBlitImage(frame.commandBuffer, srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, @@ -7875,6 +8368,19 @@ void main() { copyRegion.srcSubresource.mipLevel = srcBinding.mipLevel; copyRegion.srcSubresource.baseArrayLayer = srcBinding.baseArrayLayer; copyRegion.srcSubresource.layerCount = srcBinding.layerCount; + // KNOWN GAP, deliberately not half-fixed here: when the read framebuffer is the default + // one this samples GL rows [y, y+h) counted from the TOP of a display-oriented image, so + // it takes the mirrored band AND writes it into the (GL-oriented) destination texture + // upside down. Correcting only the offset would swap one wrong answer for another, + // because vkCmdCopyImage cannot reverse rows: this path has to become a vkCmdBlitImage + // with an inverted source Y pair, the way BlitFramebuffer above now does it. Tracked + // separately; the four sites behind the 1,759-case orientation defect are the viewport, + // the scissor, the ReadPixels copy offset and the readback remap. + if (readIsDefaultFbo) { + MGLOG_I("DirectVulkan::CopyTexSubImage2D: copying from the DEFAULT framebuffer still uses the raw GL " + "Y origin (x=%d y=%d w=%d h=%d); the result is the mirrored band, stored flipped", + x, y, width, height); + } copyRegion.srcOffset = {x, y, 0}; copyRegion.dstSubresource.aspectMask = dstBinding.aspectMask; copyRegion.dstSubresource.mipLevel = dstBinding.mipLevel; @@ -8154,11 +8660,22 @@ void main() { // blit binding below: for a renderbuffer/texture that has never been part of any // render pass yet (e.g. a GL_NONE draw buffer slot whose attachment is only ever // touched via an explicit glReadBuffer), materializing lazily creates its backing - // Vulkan resource for the first time. UnorderedMap (FastSTL, open-addressing) may + // Vulkan resource for the first time. UnorderedMap is open-addressing and may // rehash on that insertion, invalidating any RenderbufferResource*/TextureResource* // obtained beforehand - so ResolveColorBlitBinding's cached `trackedLayout` pointer // must be taken AFTER this, never before it. - if (!readIsDefaultFbo) { + // + // The default framebuffer needs this just as much, and used to be excluded: its clear is + // parked the same way, and with no draw between the clear and the readback no render + // pass ever runs to fold it in, so the readback returned the previous frame's image + // (KHR-GL40.draw_indirect.negative-*). It only takes a different materializer because the + // image to clear is the acquired swapchain image, not the attachment's placeholder + // texture. + if (readIsDefaultFbo) { + const Bool clearReady = MaterializePendingClearForDefaultFramebuffer(frame.commandBuffer, *readFbo, + readFbo->GetReadBuffer()); + MOBILEGL_ASSERT(clearReady, "ReadPixels: failed to materialize the default framebuffer's pending clear"); + } else { const auto& sourceAttachment = readFbo->GetAttachment(readFbo->GetReadBuffer()); auto sourceTexture = sourceAttachment.GetTexture(); if (sourceTexture != nullptr) { @@ -8235,7 +8752,21 @@ void main() { copyRegion.imageSubresource.mipLevel = srcBinding.mipLevel; copyRegion.imageSubresource.baseArrayLayer = srcBinding.baseArrayLayer; copyRegion.imageSubresource.layerCount = 1; - copyRegion.imageOffset = {x, y, static_cast(srcBinding.depthOffset)}; + // The GL rect, aimed at the default framebuffer's stored orientation. Using the GL y + // verbatim copied rows [y, y+h) counted from the TOP of the image, i.e. the wrong band for + // every read that was not full-height. + Int32 copyOffsetX = x; + Int32 copyOffsetY = y; + if (readIsDefaultFbo) { + const VkExtent2D defaultFboExtent = m_swapchainObject.GetExtent(); + const DefaultFramebufferRectMapping mapping = + GetDefaultFramebufferRectMapping(m_swapchainObject.GetPreTransform()); + copyOffsetX = MapDefaultFramebufferRectAxis(x, width, static_cast(defaultFboExtent.width), + mapping.mirrorX); + copyOffsetY = MapDefaultFramebufferRectAxis(y, height, static_cast(defaultFboExtent.height), + mapping.flipY); + } + copyRegion.imageOffset = {copyOffsetX, copyOffsetY, static_cast(srcBinding.depthOffset)}; copyRegion.imageExtent = {static_cast(width), static_cast(height), 1}; vkCmdCopyImageToBuffer(frame.commandBuffer, srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback.GetHandle(), 1, ©Region); @@ -8273,23 +8804,23 @@ void main() { return; } if (readIsDefaultFbo) { - const VkExtent2D swapchainExtent = m_swapchainObject.GetExtent(); const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform(); - if (static_cast(width) == swapchainExtent.width && - static_cast(height) == swapchainExtent.height) { - Vector remapped(static_cast(width) * static_cast(height) * sourceTexelSize); - if (RemapDefaultFboReadbackToGLOrientation(mapped, swapchainExtent, preTransform, - sourceTexelSize, - remapped.data())) { - PackReadbackToClientOrPbo(remapped.data(), srcFormat, width, height, 1, format, type, pixels, - /*applyPackImageParams=*/false, /*applyReadColorClamp=*/true); - return; - } + // No full-extent gate any more: the remap works on the copied rect, and the copy was + // already aimed with the same mapping. The gate is exactly what made every partial + // read of the default framebuffer come back in Vulkan row order. + Vector remapped(static_cast(width) * static_cast(height) * sourceTexelSize); + if (RemapDefaultFboReadbackToGLOrientation(mapped, static_cast(width), + static_cast(height), preTransform, sourceTexelSize, + remapped.data())) { + PackReadbackToClientOrPbo(remapped.data(), srcFormat, width, height, 1, format, type, pixels, + /*applyPackImageParams=*/false, /*applyReadColorClamp=*/true); + return; } - MGLOG_W("DirectVulkan::ReadPixels: default-FBO remap skipped (w=%d h=%d swapchain=%ux%u preTransform=%d); " - "falling back to raw readback", - width, height, swapchainExtent.width, swapchainExtent.height, - static_cast(preTransform)); + // Only a quarter-turn pre-transform reaches this, and nothing in this renderer models + // one. MGLOG_I because the INFO builds are the ones that run conformance. + MGLOG_I("DirectVulkan::ReadPixels: default-FBO remap declined (w=%d h=%d preTransform=%d); falling back " + "to raw readback", + width, height, static_cast(preTransform)); } PackReadbackToClientOrPbo(mapped, srcFormat, width, height, 1, format, type, pixels, /*applyPackImageParams=*/false, /*applyReadColorClamp=*/true); @@ -8481,10 +9012,6 @@ void main() { void VulkanRenderer::ReadDepthStencilPixels(MG_State::GLState::FramebufferObject& readFbo, GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) { - if (readFbo.IsDefaultFramebuffer()) { - MGLOG_E("DirectVulkan::ReadDepthStencilPixels skipped: default framebuffer readback is unsupported"); - return; - } if (width <= 0 || height <= 0) { return; } @@ -8495,10 +9022,13 @@ void main() { // framebuffers lacking either, so resolving via the depth attachment is enough. const auto attachmentType = wantDepth ? MobileGL::FramebufferAttachmentType::Depth : MobileGL::FramebufferAttachmentType::Stencil; - const auto& attachment = readFbo.GetAttachment(attachmentType); - if (!attachment.IsValid() || attachment.IsEmpty()) { - MGLOG_E("DirectVulkan::ReadDepthStencilPixels skipped: no depth/stencil attachment image"); - return; + const Bool readIsDefaultFbo = readFbo.IsDefaultFramebuffer(); + if (!readIsDefaultFbo) { + const auto& attachment = readFbo.GetAttachment(attachmentType); + if (!attachment.IsValid() || attachment.IsEmpty()) { + MGLOG_E("DirectVulkan::ReadDepthStencilPixels skipped: no depth/stencil attachment image"); + return; + } } auto& frame = m_frameContext.GetCurrent(); @@ -8509,6 +9039,47 @@ void main() { VkRenderPassManager::EndRenderPass(frame.commandBuffer); } + // The default framebuffer's depth/stencil lives in the swapchain, not in an + // attachment object: its placeholder ITextureObject describes the format but backs no + // image, so the branches below would have synced (and read back) an unrelated one. + // Declining outright is what made every glReadPixels(GL_DEPTH_COMPONENT/ + // GL_STENCIL_INDEX) of the default framebuffer leave the caller's buffer untouched - + // the whole KHR-GL*.framebuffer_blit family checks exactly that before it blits. + if (readIsDefaultFbo) { + const VkImage swapchainDepthImage = m_swapchainObject.GetDepthStencilImage(m_imageIndexAcquired); + if (swapchainDepthImage == VK_NULL_HANDLE) { + MGLOG_E("DirectVulkan::ReadDepthStencilPixels skipped: the default framebuffer has no " + "depth/stencil image"); + return; + } + // Per aspect, because the default framebuffer carries a SEPARATE placeholder + // attachment for depth and for stencil (MG_Impl/Init.cpp) and each parks its own + // pending clear; materializing only one would read the other back un-cleared. + if (wantDepth) { + const Bool clearReady = MaterializePendingClearForDefaultFramebuffer( + frame.commandBuffer, readFbo, MobileGL::FramebufferAttachmentType::Depth); + MOBILEGL_ASSERT(clearReady, + "ReadDepthStencilPixels: failed to materialize the default framebuffer's pending " + "depth clear"); + } + if (wantStencil) { + const Bool clearReady = MaterializePendingClearForDefaultFramebuffer( + frame.commandBuffer, readFbo, MobileGL::FramebufferAttachmentType::Stencil); + MOBILEGL_ASSERT(clearReady, + "ReadDepthStencilPixels: failed to materialize the default framebuffer's pending " + "stencil clear"); + } + const VkFormat swapchainDepthFormat = m_swapchainObject.GetDepthStencilFormat(); + VkImageLayout trackedLayout = m_swapchainObject.GetDepthStencilImageLayout(m_imageIndexAcquired); + ReadDepthStencilImageToClient(swapchainDepthImage, swapchainDepthFormat, &trackedLayout, + GetDepthStencilAspectMaskForFormat(swapchainDepthFormat), 0, 0, x, y, + width, height, format, type, pixels, + /*defaultFramebufferOrientation=*/true); + m_swapchainObject.SetDepthStencilImageLayout(m_imageIndexAcquired, trackedLayout); + return; + } + + const auto& attachment = readFbo.GetAttachment(attachmentType); VkImage image = VK_NULL_HANDLE; VkFormat vkFormat = VK_FORMAT_UNDEFINED; VkImageLayout* trackedLayout = nullptr; @@ -8559,7 +9130,8 @@ void main() { void VulkanRenderer::ReadDepthStencilImageToClient(VkImage image, VkFormat vkFormat, VkImageLayout* trackedLayout, VkImageAspectFlags imageAspect, Uint32 mipLevel, Uint32 baseArrayLayer, GLint x, GLint y, GLsizei width, - GLsizei height, GLenum format, GLenum type, void* pixels) { + GLsizei height, GLenum format, GLenum type, void* pixels, + Bool defaultFramebufferOrientation) { const Bool wantDepth = format != GL_STENCIL_INDEX; const Bool wantStencil = format != GL_DEPTH_COMPONENT; auto& frame = m_frameContext.GetCurrent(); @@ -8624,6 +9196,21 @@ void main() { VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask, VK_ACCESS_TRANSFER_READ_BIT, imageAspect, mipLevel, 1); MOBILEGL_ASSERT(ok, "%s: failed to transition depth-stencil source image", __func__); + // The swapchain's depth/stencil image is stored display-side-up like its colour twin, so + // the GL rect has to be mapped into that space before the copy and the copied rows + // re-oriented afterwards - the same two halves the colour ReadPixels path applies. + Int32 copyOffsetX = x; + Int32 copyOffsetY = y; + if (defaultFramebufferOrientation) { + const VkExtent2D defaultFboExtent = m_swapchainObject.GetExtent(); + const DefaultFramebufferRectMapping mapping = + GetDefaultFramebufferRectMapping(m_swapchainObject.GetPreTransform()); + copyOffsetX = MapDefaultFramebufferRectAxis(x, width, static_cast(defaultFboExtent.width), + mapping.mirrorX); + copyOffsetY = MapDefaultFramebufferRectAxis(y, height, static_cast(defaultFboExtent.height), + mapping.flipY); + } + VkBufferImageCopy regions[2]{}; Uint32 regionCount = 0; if (wantDepth) { @@ -8633,7 +9220,7 @@ void main() { region.imageSubresource.mipLevel = mipLevel; region.imageSubresource.baseArrayLayer = baseArrayLayer; region.imageSubresource.layerCount = 1; - region.imageOffset = {x, y, 0}; + region.imageOffset = {copyOffsetX, copyOffsetY, 0}; region.imageExtent = {static_cast(width), static_cast(height), 1}; } if (wantStencil) { @@ -8643,7 +9230,7 @@ void main() { region.imageSubresource.mipLevel = mipLevel; region.imageSubresource.baseArrayLayer = baseArrayLayer; region.imageSubresource.layerCount = 1; - region.imageOffset = {x, y, 0}; + region.imageOffset = {copyOffsetX, copyOffsetY, 0}; region.imageExtent = {static_cast(width), static_cast(height), 1}; } vkCmdCopyImageToBuffer(frame.commandBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback.GetHandle(), @@ -8668,6 +9255,38 @@ void main() { const Uint8* depthSrc = mapped; const Uint8* stencilSrc = mapped + stencilOffset; + // Re-orient the copied band per aspect, before any repacking reads it: the depth and + // stencil aspects were copied into their own tightly packed sub-buffers, so each is a + // plain width x height image of its own texel size. + Vector remappedDepth; + Vector remappedStencil; + if (defaultFramebufferOrientation) { + const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform(); + Bool remapped = true; + if (wantDepth && depthCopyBytes > 0) { + remappedDepth.resize(pixelCount * depthCopyBytes); + remapped = RemapDefaultFboReadbackToGLOrientation(depthSrc, static_cast(width), + static_cast(height), preTransform, + depthCopyBytes, remappedDepth.data()); + } + if (remapped && wantStencil) { + remappedStencil.resize(pixelCount); + remapped = RemapDefaultFboReadbackToGLOrientation(stencilSrc, static_cast(width), + static_cast(height), preTransform, 1, + remappedStencil.data()); + } + if (remapped) { + if (!remappedDepth.empty()) depthSrc = remappedDepth.data(); + if (!remappedStencil.empty()) stencilSrc = remappedStencil.data(); + } else { + // Only a quarter-turn pre-transform reaches this, and nothing in this renderer + // models one. MGLOG_I because the INFO builds are the ones that run conformance. + MGLOG_I("DirectVulkan::ReadDepthStencilPixels: default-FBO remap declined (w=%d h=%d " + "preTransform=%d); falling back to raw readback", + width, height, static_cast(preTransform)); + } + } + const auto depthValueAt = [&](SizeT i) -> Float { switch (vkFormat) { case VK_FORMAT_D16_UNORM: { @@ -11882,6 +12501,12 @@ void main() { static_cast(m_physicalDevice.queueFamilies.graphicsFamily), static_cast(m_physicalDevice.queueFamilies.presentFamily), m_config.MaxFramesInFlight, desiredExtent); + // The FragCoordYFlip variants bake this height in; it is the only input to a shader + // module that lives outside the GL program, so the factory has to learn it here (and on + // every recreation, which is the only way it can change). + if (m_programFactory) { + m_programFactory->SetDefaultFramebufferHeight(m_swapchainObject.GetExtent().height); + } } void VulkanRenderer::CreateCommandPool() { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 2f4710eb..c48f11fb 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -211,10 +211,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { GLsizei height, GLenum format, GLenum type, void* pixels); // Copy-and-repack core shared by depth-stencil ReadPixels and GetTexImage; // expects command recording to be active and any render pass already ended. + // + // `defaultFramebufferOrientation` is set only when the source is the swapchain's + // depth/stencil image, which this renderer stores display-side-up: the copy rect then + // has to be mapped out of GL's bottom-origin space and the copied rows re-oriented on + // the way back, exactly as the colour ReadPixels path does. void ReadDepthStencilImageToClient(VkImage image, VkFormat vkFormat, VkImageLayout* trackedLayout, VkImageAspectFlags imageAspect, Uint32 mipLevel, Uint32 baseArrayLayer, GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, - void* pixels); + void* pixels, Bool defaultFramebufferOrientation = false); // Same-extent depth blit between images of different depth formats: host // round-trip with a per-texel re-encode (see BlitNamedFramebuffer). Bool BlitDepthAcrossFormats(FrameContext::FrameData& frame, VkImage srcImage, VkFormat srcFormat, @@ -363,6 +368,31 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 samplerBinding = 0; }; + // A single-sample staging image for multisample-resolve blits that also have to change + // orientation. vkCmdResolveImage cannot flip (it takes one offset per side, not the + // invertible pair vkCmdBlitImage takes), so a resolve into or out of the default + // framebuffer used to land the mirrored band. Resolving here first and then blitting from + // here separates the two operations, and each one then does only what it can express. + // + // Pooled rather than created per blit: the CTS runs hundreds of these back to back, and + // create-destroy per call would both cost allocations and, worse, need per-call deferred + // destruction to outlive the recording. It grows to the largest extent asked for and is + // reused; format changes recreate it. + struct MultisampleResolveScratchImage { + VkImage image = VK_NULL_HANDLE; + VmaAllocation allocation = VK_NULL_HANDLE; + VkFormat format = VK_FORMAT_UNDEFINED; + VkExtent2D extent = {0, 0}; + VkImageLayout layout = VK_IMAGE_LAYOUT_UNDEFINED; + }; + MultisampleResolveScratchImage m_msResolveScratch; + // Returns a scratch image at least `extent` in size with exactly `format`, transitioned to + // TRANSFER_DST and ready to be resolved into. Null image on failure (the caller then falls + // back to the direct resolve). + Bool AcquireMultisampleResolveScratchImage(VkCommandBuffer commandBuffer, VkFormat format, + VkExtent2D extent); + void DestroyMultisampleResolveScratchImage(); + struct DeferredDepthMipmapCleanup { Vector imageViews; Vector framebuffers; @@ -1118,6 +1148,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool MaterializePendingClearForRenderbuffer( VkCommandBuffer commandBuffer, const SharedPtr& renderbuffer); + // The default framebuffer's twin of the two above. It cannot go through + // MaterializePendingClearForTexture: the default FBO's colour attachment is a + // placeholder texture object, and syncing THAT would clear a texture image nobody + // presents instead of the acquired swapchain image. + Bool MaterializePendingClearForDefaultFramebuffer(VkCommandBuffer commandBuffer, + MG_State::GLState::FramebufferObject& fbo, + FramebufferAttachmentType attachmentType); + // Its depth/stencil half: a different image (the swapchain's depth/stencil twin), a + // different clear command and per-aspect masking. + Bool MaterializePendingDepthStencilClearForDefaultFramebuffer( + VkCommandBuffer commandBuffer, const MG_State::GLState::FramebufferAttachmentObject& attachment, + const ClearAttachmentPayload& payload); VkPipeline GetOrCreateBlitPipeline(const RenderPassEntry& renderPassEntry); Bool GenerateDepthMipmapWithShader(FrameContext::FrameData& frame, MG_State::GLState::ITextureObject& texture, diff --git a/MobileGL/MG_Benchmark/CMakeLists.txt b/MobileGL/MG_Benchmark/CMakeLists.txt index 51d95017..f9ca958c 100644 --- a/MobileGL/MG_Benchmark/CMakeLists.txt +++ b/MobileGL/MG_Benchmark/CMakeLists.txt @@ -42,4 +42,5 @@ set_tests_properties(SanityBench PROPERTIES LABELS benchmark) add_subdirectory(Program) add_subdirectory(Buffer) -add_subdirectory(Driver) \ No newline at end of file +add_subdirectory(Driver) +add_subdirectory(Container) \ No newline at end of file diff --git a/MobileGL/MG_Benchmark/Container/CMakeLists.txt b/MobileGL/MG_Benchmark/Container/CMakeLists.txt new file mode 100644 index 00000000..b98e5d86 --- /dev/null +++ b/MobileGL/MG_Benchmark/Container/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.24) + +add_executable( + UnorderedMapBench + UnorderedMapBench.cpp +) + +target_include_directories(UnorderedMapBench PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + UnorderedMapBench PRIVATE + benchmark::benchmark + ${LINK_LIBRARIES} +) + +add_test(NAME UnorderedMapBench COMMAND UnorderedMapBench --benchmark_counters_tabular=true) +set_tests_properties(UnorderedMapBench PROPERTIES LABELS benchmark) diff --git a/MobileGL/MG_Benchmark/Container/UnorderedMapBench.cpp b/MobileGL/MG_Benchmark/Container/UnorderedMapBench.cpp new file mode 100644 index 00000000..13cbdb80 --- /dev/null +++ b/MobileGL/MG_Benchmark/Container/UnorderedMapBench.cpp @@ -0,0 +1,248 @@ +// MobileGL - MobileGL/MG_Benchmark/Container/UnorderedMapBench.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The standing performance observatory for MobileGL::UnorderedMap. +// +// This benchmarks the ALIAS, never a concrete table, so whatever UnorderedMap +// names today is what gets measured - swap the container in MG_Util/Types.h and +// re-run this same binary to get a directly comparable set of numbers. That is +// the point of it: the container sits on per-draw paths, so a change to it needs +// evidence, and the evidence should be produced the same way every time. +// +// The workloads are the shapes the tree actually exercises, not generic hash-map +// microbenchmarks. Four key shapes, because they stress a hash function very +// differently: +// * SEQUENTIAL dense small integers - GL object names from the index generator +// (buffer/texture/framebuffer/sampler registries). +// * POINTER real heap addresses - StateBackendObjectRegistry keys on +// StateObject*. These are aligned, so their low bits are the +// least random part of the key; a table that indexes on raw low +// bits clusters badly here and one that mixes first does not. +// Taken from the real allocator rather than a synthetic stride, +// which would flatter whichever table mixes its bits. +// * DIGEST already well-mixed 64-bit values - the XXH64 pipeline, +// vertex-input-state and program memos. +// * NAME short strings - uniform/attribute name to location maps. +// +// Sizes sweep from 8 upward because the per-draw memos are usually SMALL; a table +// that only wins at 4096 entries has not won anything that matters here. +// +// Run: build-linux/MobileGL/MG_Benchmark/Container/UnorderedMapBench +// or: ctest -R UnorderedMapBench (label: benchmark) + +#include +#include +#include +#include +#include +#include + +#include "MG_Util/Types.h" + +using namespace MobileGL; + +namespace { + + constexpr Int64 kMinSize = 8; + constexpr Int64 kMaxSize = 4096; + + // Keep the real allocations alive for the whole process: the POINTER shape is + // only honest if the keys are addresses the allocator actually handed out, and + // they have to stay unique (a freed address can be handed out twice). + std::vector>& PointerKeyStorage() { + static std::vector> storage; + return storage; + } + + Vector SequentialKeys(SizeT n) { + Vector keys; + keys.reserve(n); + for (SizeT i = 0; i < n; ++i) keys.push_back(static_cast(i) + 1); + return keys; + } + + Vector PointerKeys(SizeT n) { + auto& storage = PointerKeyStorage(); + Vector keys; + keys.reserve(n); + std::mt19937_64 rng(0xBEEF); + std::vector> churn; + for (SizeT i = 0; i < n; ++i) { + // State objects are not all one size, and the allocator sees other + // traffic between them - a single uniform stride is not what this + // registry ever sees. + const SizeT sz = 96 + (rng() % 192); + auto p = std::make_unique(sz); + keys.push_back(reinterpret_cast(p.get())); + storage.push_back(std::move(p)); + if ((rng() & 3) == 0) churn.push_back(std::make_unique(32 + (rng() % 128))); + } + return keys; + } + + Vector DigestKeys(SizeT n) { + Vector keys; + keys.reserve(n); + std::mt19937_64 rng(0xC0FFEE); + for (SizeT i = 0; i < n; ++i) keys.push_back(rng()); + return keys; + } + + Vector NameKeys(SizeT n) { + static const char* kPrefixes[] = {"u_", "a_", "mc_", "iris_", "gl_", "v_"}; + Vector keys; + keys.reserve(n); + for (SizeT i = 0; i < n; ++i) { + keys.push_back(String(kPrefixes[i % 6]) + "Uniform" + std::to_string(i) + "_xyz"); + } + return keys; + } + + // Key sets are built once per size and shared: generating them inside the timed + // loop would measure the generator (and, for POINTER, the allocator) instead of + // the table. + template + const KeyVec& CachedKeys(SizeT n) { + static UnorderedMap cache; + auto it = cache.find(n); + if (it != cache.end()) return it->second; + return cache.emplace(n, Make(n)).first->second; + } + + template + UnorderedMap Populated(const Vector& keys) { + UnorderedMap map; + for (SizeT i = 0; i < keys.size(); ++i) map[keys[i]] = i; + return map; + } + + // ---- the workloads ---------------------------------------------------- + + // The dominant per-draw operation by a wide margin: a populated cache that is + // read far more often than it is written. + template + void LookupHit(benchmark::State& state) { + const auto& keys = CachedKeys(static_cast(state.range(0))); + auto map = Populated(keys); + for (auto _ : state) { + for (const auto& k : keys) { + auto it = map.find(k); + benchmark::DoNotOptimize(it->second); + } + } + state.SetItemsProcessed(state.iterations() * static_cast(keys.size())); + } + + // "Is this resource cached yet?" answered NO - the probe length on a miss is a + // different cost from a hit, and resource caches ask this constantly. + template + void LookupMiss(benchmark::State& state) { + const SizeT n = static_cast(state.range(0)); + const auto& keys = CachedKeys(n); + auto map = Populated(keys); + const KeyVec absent = Make(n); // same shape, never inserted + for (auto _ : state) { + for (const auto& k : absent) { + benchmark::DoNotOptimize(map.find(k) != map.end()); + } + } + state.SetItemsProcessed(state.iterations() * static_cast(absent.size())); + } + + // Building a cache from empty, rehashes included. + template + void InsertGrow(benchmark::State& state) { + const auto& keys = CachedKeys(static_cast(state.range(0))); + for (auto _ : state) { + UnorderedMap map; + for (SizeT i = 0; i < keys.size(); ++i) map[keys[i]] = i; + benchmark::DoNotOptimize(map.size()); + } + state.SetItemsProcessed(state.iterations() * static_cast(keys.size())); + } + + // Cache eviction and refill: erase half by key, put them back. This is the + // aged-out-entry sweep the pipeline and vertex-input caches do. + template + void EraseChurn(benchmark::State& state) { + const auto& keys = CachedKeys(static_cast(state.range(0))); + for (auto _ : state) { + state.PauseTiming(); + auto map = Populated(keys); + state.ResumeTiming(); + for (SizeT i = 0; i < keys.size(); i += 2) benchmark::DoNotOptimize(map.erase(keys[i])); + for (SizeT i = 0; i < keys.size(); i += 2) map[keys[i]] = i; + benchmark::DoNotOptimize(map.size()); + } + state.SetItemsProcessed(state.iterations() * static_cast(keys.size())); + } + + // Mass eviction: erase-while-iterating across the whole table. This is the loop + // shape that a container's erase()-return contract can get wrong, and the one + // that fed garbage handles to vkDestroyPipeline when it was wrong before. + template + void EraseSweep(benchmark::State& state) { + const auto& keys = CachedKeys(static_cast(state.range(0))); + for (auto _ : state) { + state.PauseTiming(); + auto map = Populated(keys); + state.ResumeTiming(); + for (auto it = map.begin(); it != map.end();) it = map.erase(it); + benchmark::DoNotOptimize(map.size()); + } + state.SetItemsProcessed(state.iterations() * static_cast(keys.size())); + } + + // Whole-table walks: the per-frame sweeps that age entries out, and the + // teardown loops that destroy every Vulkan object a cache owns. + template + void Iterate(benchmark::State& state) { + const auto& keys = CachedKeys(static_cast(state.range(0))); + auto map = Populated(keys); + for (auto _ : state) { + Uint64 acc = 0; + for (const auto& entry : map) acc += entry.second; + benchmark::DoNotOptimize(acc); + } + state.SetItemsProcessed(state.iterations() * static_cast(keys.size())); + } + +} // namespace + +#define MGL_MAP_BENCH(WORKLOAD, SHAPE, VEC, MAKER) \ + BENCHMARK_TEMPLATE(WORKLOAD, VEC, MAKER) \ + ->Name(#WORKLOAD "/" #SHAPE) \ + ->RangeMultiplier(8) \ + ->Range(kMinSize, kMaxSize) + +MGL_MAP_BENCH(LookupHit, sequential, Vector, SequentialKeys); +MGL_MAP_BENCH(LookupHit, pointer, Vector, PointerKeys); +MGL_MAP_BENCH(LookupHit, digest, Vector, DigestKeys); +MGL_MAP_BENCH(LookupHit, name, Vector, NameKeys); + +MGL_MAP_BENCH(LookupMiss, sequential, Vector, SequentialKeys); +MGL_MAP_BENCH(LookupMiss, pointer, Vector, PointerKeys); +MGL_MAP_BENCH(LookupMiss, digest, Vector, DigestKeys); +MGL_MAP_BENCH(LookupMiss, name, Vector, NameKeys); + +MGL_MAP_BENCH(InsertGrow, sequential, Vector, SequentialKeys); +MGL_MAP_BENCH(InsertGrow, pointer, Vector, PointerKeys); +MGL_MAP_BENCH(InsertGrow, digest, Vector, DigestKeys); +MGL_MAP_BENCH(InsertGrow, name, Vector, NameKeys); + +MGL_MAP_BENCH(EraseChurn, sequential, Vector, SequentialKeys); +MGL_MAP_BENCH(EraseChurn, digest, Vector, DigestKeys); +MGL_MAP_BENCH(EraseChurn, name, Vector, NameKeys); + +MGL_MAP_BENCH(EraseSweep, sequential, Vector, SequentialKeys); +MGL_MAP_BENCH(EraseSweep, digest, Vector, DigestKeys); + +MGL_MAP_BENCH(Iterate, sequential, Vector, SequentialKeys); +MGL_MAP_BENCH(Iterate, digest, Vector, DigestKeys); + +BENCHMARK_MAIN(); diff --git a/MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp b/MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp index 50dc0f5d..5a58d640 100644 --- a/MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp +++ b/MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp @@ -1491,8 +1491,8 @@ namespace MobileGL::MG_Impl::GLImpl { // offset and size, which is also how glBindBuffersRange spells "reset this element" // (a NULL buffers array, or a zero entry inside one). static Bool ValidateBufferRangeOffsetAndSize(GLenum target, GLintptr offset, GLsizeiptr size, - const char* funcName) { - if (size <= 0) { + const char* funcName, Bool hasBuffer = true) { + if (hasBuffer && size <= 0) { MG_State::pGLContext->RecordError( ErrorCode::InvalidValue, MakeUnique("MG_Impl/GLImpl", funcName, @@ -1527,16 +1527,27 @@ namespace MobileGL::MG_Impl::GLImpl { return false; } } - // A transform feedback capture binding is addressed in 32-bit components, so BOTH the - // offset and the size must be multiples of 4. - if (target == GL_TRANSFORM_FEEDBACK_BUFFER && ((offset % 4) != 0 || (size % 4) != 0)) { + // GL 4.6 core 6.1.1 constrains the OFFSET to a multiple of four for both + // TRANSFORM_FEEDBACK_BUFFER and ATOMIC_COUNTER_BUFFER (the atomic-counter one has no + // queryable alignment pname, which is why it was missing here), and the SIZE only for + // transform feedback, whose capture is written in whole 32-bit components. Extending the + // size rule to atomic counters as well breaks a legal bind: the conformance suite splits + // MAX_ATOMIC_COUNTER_BUFFER_SIZE evenly across the binding points and that quotient is + // not required to land on four. + if ((target == GL_TRANSFORM_FEEDBACK_BUFFER || target == GL_ATOMIC_COUNTER_BUFFER) && (offset % 4) != 0) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", funcName, + std::format("offset ({}) must be a multiple of 4 for {}.", offset, + MG_Util::ConvertGLEnumToString(target)))); + return false; + } + if (target == GL_TRANSFORM_FEEDBACK_BUFFER && hasBuffer && (size % 4) != 0) { MG_State::pGLContext->RecordError( ErrorCode::InvalidValue, MakeUnique( "MG_Impl/GLImpl", funcName, - std::format("offset ({}) and size ({}) must both be multiples of 4 for " - "GL_TRANSFORM_FEEDBACK_BUFFER.", - offset, size))); + std::format("size ({}) must be a multiple of 4 for GL_TRANSFORM_FEEDBACK_BUFFER.", size))); return false; } return true; @@ -1548,7 +1559,12 @@ namespace MobileGL::MG_Impl::GLImpl { BufferTarget bufferTarget = MG_Util::ConvertGLEnumToBufferTarget(target); if (!BufferImpl::ValidateBufferBindingPointTarget(bufferTarget)) return; if (!BufferImpl::ValidateBufferBindingPointIndex(bufferTarget, index)) return; - if (buffer != 0 && !ValidateBufferRangeOffsetAndSize(target, offset, size, __func__)) return; + // The target's alignment rules are a property of the BINDING POINT, not of the buffer, + // so they apply even when buffer is zero - which is exactly how + // KHR-GL43.shader_storage_buffer_object.negative-api-bind probes the SSBO alignment + // (glBindBufferRange(SHADER_STORAGE_BUFFER, 0, 0, alignment - 1, 0)). Only the size + // rules need a buffer, since buffer 0 detaches the binding point and ignores size. + if (!ValidateBufferRangeOffsetAndSize(target, offset, size, __func__, /*hasBuffer: */ buffer != 0)) return; if (bufferTarget == BufferTarget::TransformFeedback && MG_State::pGLContext->IsTransformFeedbackActive()) { MG_State::pGLContext->RecordError( ErrorCode::InvalidOperation, @@ -1732,10 +1748,30 @@ namespace MobileGL::MG_Impl::GLImpl { return BufferImpl::ValidateBufferBindingPointRange(bufferTarget, first, count, funcName); } + // ARB_multi_bind states the equivalence to a loop of single binds "except that ... buffers + // will not be created if they do not exist": glBindBuffer instantiates a name glGenBuffers + // merely reserved, glBindBuffers* must refuse it and raise INVALID_OPERATION instead + // (KHR-GL44.multi_bind.errors_bind_buffers). + // + // Deliberately PER ELEMENT, not all-or-nothing: the equivalence the extension defines is a + // loop, so a bad entry costs its own binding point and nothing else. Rejecting the whole + // call instead cost multi_bind.functional_bind_buffers_base its bindings. + static Bool IsExistingBufferForMultiBind(GLuint buffer, GLsizei index, const char* funcName) { + if (buffer == 0 || MG_State::pGLContext->ValidateBufferObject(buffer)) return true; + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique( + "MG_Impl/GLImpl", funcName, + std::format("buffers[{}] ({}) is not the name of an existing buffer object.", index, buffer))); + return false; + } + void BindBuffersBase(GLenum target, GLuint first, GLsizei count, const GLuint* buffers) { if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return; for (GLsizei i = 0; i < count; ++i) { - BindBufferBase_State(target, first + i, buffers ? buffers[i] : 0); + const GLuint buffer = buffers ? buffers[i] : 0; + if (!IsExistingBufferForMultiBind(buffer, i, __func__)) continue; + BindBufferBase_State(target, first + i, buffer); } } @@ -1749,6 +1785,7 @@ namespace MobileGL::MG_Impl::GLImpl { const GLsizeiptr* sizes) { if (!ValidateMultiBindBufferRange(target, first, count, __func__)) return; for (GLsizei i = 0; i < count; ++i) { + if (buffers && !IsExistingBufferForMultiBind(buffers[i], i, __func__)) continue; if (!buffers || buffers[i] == 0) { BindBufferBase_State(target, first + i, 0); } else { diff --git a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp index 51e0aa49..3ef42aff 100644 --- a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp +++ b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp @@ -14,8 +14,8 @@ #include "../Getter/GL_Getter.h" namespace MobileGL::MG_Impl::GLImpl { - static Bool ValidateCurrentProgramForExecution(const char* functionName) { - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); + static Bool ValidateProgramForExecution(const SharedPtr& currentProgram, + const char* functionName) { if (!currentProgram) { MG_State::pGLContext->RecordError( ErrorCode::InvalidOperation, @@ -34,10 +34,17 @@ namespace MobileGL::MG_Impl::GLImpl { return true; } + static Bool ValidateCurrentProgramForExecution(const char* functionName) { + return ValidateProgramForExecution(MG_State::pGLContext->GetProgramForDraw(), functionName); + } + + // A dispatch resolves its program through the DISPATCH accessor: with a pipeline bound + // that is the pipeline's compute stage program, not the graphics composite a draw would + // build - which no longer contains a compute stage to find at all. static Bool ValidateCurrentProgramForCompute(const char* functionName) { - if (!ValidateCurrentProgramForExecution(functionName)) return false; + const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch(); + if (!ValidateProgramForExecution(currentProgram, functionName)) return false; - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); if (currentProgram->GetShaderIndexByStage(ShaderStage::Compute) < 0) { MG_State::pGLContext->RecordError( ErrorCode::InvalidOperation, @@ -493,15 +500,12 @@ namespace MobileGL::MG_Impl::GLImpl { } void DispatchComputeIndirect(GLintptr indirect) { - auto dispatchComputeIndirect = MG_Backend::gBackendFunctionsTable.GL.DispatchComputeIndirect; - if (!dispatchComputeIndirect) { - MG_State::pGLContext->RecordError( - ErrorCode::InvalidOperation, - MakeUnique("MG_Impl/GLImpl", __func__, - "Backend does not support indirect compute dispatch.")); - return; - } - if (!ValidateCurrentProgramForCompute(__func__)) return; + // Argument and binding validation runs FIRST. Both are properties of the call and of GL + // state, so a context whose backend cannot dispatch at all must still report the + // argument error the spec names rather than masking every one of them with + // "unsupported" - which is what put GL_INVALID_OPERATION where + // KHR-GL43.compute_shader.api-indirect expects GL_INVALID_VALUE. + // // GL 4.6 core 19: `indirect` is a byte offset into GL_DISPATCH_INDIRECT_BUFFER - // negative or misaligned is INVALID_VALUE, nothing bound is INVALID_OPERATION. if (indirect < 0 || (indirect % 4) != 0) { @@ -520,6 +524,29 @@ namespace MobileGL::MG_Impl::GLImpl { "No buffer is bound to GL_DISPATCH_INDIRECT_BUFFER.")); return; } + // ...and the same INVALID_OPERATION covers "the command would source data beyond the end + // of the bound buffer object" (GL 4.6 core 19): the dispatch reads three uints starting + // at `indirect`. + constexpr SizeT kDispatchIndirectCommandSize = 3 * sizeof(Uint32); + if (static_cast(indirect) + kDispatchIndirectCommandSize > indirectBuffer->GetSize()) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique( + "MG_Impl/GLImpl", __func__, + std::format("indirect ({}) + 12 bytes runs past the end of the {}-byte buffer bound to " + "GL_DISPATCH_INDIRECT_BUFFER.", + indirect, indirectBuffer->GetSize()))); + return; + } + auto dispatchComputeIndirect = MG_Backend::gBackendFunctionsTable.GL.DispatchComputeIndirect; + if (!dispatchComputeIndirect) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique("MG_Impl/GLImpl", __func__, + "Backend does not support indirect compute dispatch.")); + return; + } + if (!ValidateCurrentProgramForCompute(__func__)) return; dispatchComputeIndirect(indirect); } @@ -580,8 +607,80 @@ namespace MobileGL::MG_Impl::GLImpl { MultiDrawArraysIndirect_Backend(mode, indirect, drawcount, stride); } + // ARB_indirect_parameters / GL 4.6 core 10.4: `drawcount` is a byte offset into the buffer + // bound to PARAMETER_BUFFER and holds one uint draw count. Three errors have to be raised + // before the call reaches a backend, and none of them was + // (KHR-GL46.indirect_parameters_tests.MultiDraw{Arrays,Elements}IndirectCount): + // * drawcount not a multiple of four INVALID_VALUE + // * nothing bound to PARAMETER_BUFFER, or the uint at `drawcount` + // lies past its end INVALID_OPERATION + // * maxdrawcount commands from `indirect` run past the end of the + // buffer bound to DRAW_INDIRECT_BUFFER INVALID_OPERATION + static Bool ValidateIndirectCountDraw(GLintptr indirect, GLintptr drawcount, GLsizei maxdrawcount, + GLsizei stride, SizeT commandSize, const char* funcName) { + if (drawcount < 0 || (drawcount % 4) != 0) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", funcName, + "drawcount must be non-negative and a multiple of four.")); + return false; + } + const auto& parameterBuffer = + MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); + if (!parameterBuffer || + static_cast(drawcount) + sizeof(Uint32) > parameterBuffer->GetSize()) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique("MG_Impl/GLImpl", funcName, + "No buffer is bound to GL_PARAMETER_BUFFER, or drawcount runs past " + "the end of the one that is.")); + return false; + } + if (maxdrawcount < 0 || stride < 0 || indirect < 0) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", funcName, + "indirect, maxdrawcount and stride must all be non-negative.")); + return false; + } + const SizeT effectiveStride = stride != 0 ? static_cast(stride) : commandSize; + const auto& indirectBuffer = + MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + // A zero maxdrawcount sources nothing, so it cannot run past anything. + const SizeT requiredBytes = + maxdrawcount == 0 ? 0 + : static_cast(indirect) + + static_cast(maxdrawcount - 1) * effectiveStride + commandSize; + if (!indirectBuffer || requiredBytes > indirectBuffer->GetSize()) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique("MG_Impl/GLImpl", funcName, + "maxdrawcount commands would be sourced from beyond the end of the " + "buffer bound to GL_DRAW_INDIRECT_BUFFER.")); + return false; + } + return true; + } + void MultiDrawElementsIndirectCount(GLenum mode, GLenum type, const void* indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride) { + // Argument validation before the backend-availability check: see DispatchComputeIndirect. + // DrawElementsIndirectCommand: count, instanceCount, firstIndex, baseVertex, baseInstance. + if (!ValidateIndirectCountDraw(reinterpret_cast(indirect), drawcount, maxdrawcount, stride, + 5 * sizeof(Uint32), __func__)) { + return; + } + // The only two draw entry points that were missing this. Every backend draw path + // dereferences GetProgramForDraw() unconditionally, so "no current program" has to be + // stopped here or it is a null dereference rather than the INVALID_OPERATION the spec + // asks for - reachable through a bound pipeline that supplies no graphics stage. + // + // AFTER the argument checks, unlike the sibling draw entry points, and deliberately: + // the argument rules here are properties of the call rather than of GL state, and + // NegativeApiErrorsTest.IndirectParameterDrawsCheckBothBuffers pins the INVALID_VALUE + // they produce for a call made with no program bound. Same precedence decision, and + // the same reason, as DispatchComputeIndirect above. + if (!ValidateCurrentProgramForExecution(__func__)) return; auto multiDrawElementsIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirectCount; if (!multiDrawElementsIndirectCount) { MG_State::pGLContext->RecordError( @@ -595,6 +694,14 @@ namespace MobileGL::MG_Impl::GLImpl { void MultiDrawArraysIndirectCount(GLenum mode, const void* indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride) { + // Argument validation before the backend-availability check: see DispatchComputeIndirect. + // DrawArraysIndirectCommand: count, instanceCount, first, baseInstance. + if (!ValidateIndirectCountDraw(reinterpret_cast(indirect), drawcount, maxdrawcount, stride, + 4 * sizeof(Uint32), __func__)) { + return; + } + // See MultiDrawElementsIndirectCount, including why this one goes last. + if (!ValidateCurrentProgramForExecution(__func__)) return; auto multiDrawArraysIndirectCount = MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount; if (!multiDrawArraysIndirectCount) { MG_State::pGLContext->RecordError( diff --git a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp index a5694457..d58e2d0f 100644 --- a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp +++ b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp @@ -722,10 +722,14 @@ namespace MobileGL::MG_Impl::GLImpl { *data = 0; return; } + // GL 4.6 core table 23.4/23.5: *_BUFFER_SIZE reports the size glBindBufferRange + // was ASKED for, verbatim. It is not clamped to the buffer's storage, and it does + // not follow the buffer when a later glBufferData resizes it - a range may legally + // name bytes the buffer does not have yet. Clamping it here answered 0 for the + // common conformance shape of binding a range on a buffer that has no storage + // yet (KHR-GL43.shader_storage_buffer_object.basic-binding). const Range1D range = bindingPoint.GetRange(); - const auto start = std::min(range.start, bufferObject->GetSize()); - const auto end = std::min(range.end, bufferObject->GetSize()); - *data = static_cast(end - start); + *data = static_cast(range.end - range.start); return; } default: @@ -951,9 +955,8 @@ namespace MobileGL::MG_Impl::GLImpl { *data = 0; return; } - const auto start = std::min(range.start, bufferObject->GetSize()); - const auto end = std::min(range.end, bufferObject->GetSize()); - *data = static_cast(end - start); + // Verbatim, unclamped - see the GetIntegeri_v arm. + *data = static_cast(range.end - range.start); return; } default: @@ -961,15 +964,30 @@ namespace MobileGL::MG_Impl::GLImpl { } } - auto getInteger64i = MG_Backend::gBackendFunctionsTable.GL.GetInteger64i_v; - if (!getInteger64i) { - *data = 0; - MG_State::pGLContext->RecordError( - ErrorCode::InvalidOperation, - MakeUnique("MG_Impl/GLImpl", __func__, "Backend does not support indexed integer queries.")); + // The one indexed pname whose value genuinely needs 64 bits: a vertex buffer binding + // offset is an intptr, so taking the 32-bit route below would truncate it. + if (target == GL_VERTEX_BINDING_OFFSET) { + if (index >= VertexArrayImpl::GetMaxVertexAttribBindings()) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidValue, + MakeUnique("MG_Impl/GLImpl", __func__, + "Vertex buffer binding index is out of range.")); + return; + } + const auto& vao = MG_State::pGLContext->GetBoundVertexArray(); + *data = vao ? static_cast(vao->GetBindingPoint(index).Offset) : 0; return; } - getInteger64i(target, index, data); + + // Everything else is 32-bit indexed state that the glGetIntegeri_v pname table already + // owns, and GL 4.6 core 22.1 says every indexed query answers every indexed pname. + // Handing the leftovers straight to the backend instead made glGetInteger64i_v disagree + // with glGetIntegeri_v on the very same pname - GL_MAX_COMPUTE_WORK_GROUP_COUNT read + // back 0 while the 32-bit view said 65535 (KHR-GL43.compute_shader.max), because a + // frontend-only value simply is not in the driver's table. + GLint values[4] = {}; + GetIntegeri_v(target, index, values); + *data = static_cast(values[0]); } void GetInteger64v(GLenum pname, GLint64* params) { diff --git a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp index 606099ef..d02e2956 100644 --- a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp +++ b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp @@ -863,12 +863,11 @@ namespace MobileGL::MG_Impl::GLImpl { } // Bytes a uniform actually occupies in the global UBO. It is the tight GL type size for - // everything except a float matrix, whose padded columns make it wider. + // everything except a float matrix, whose padded columns make it wider. The rule itself + // lives on ProgramObject, because the pipeline composite's uniform refresh needs the same + // one and two copies of a layout rule is one too many. SizeT UniformStorageSpanInBytes(const glslang::TType* ttype, SizeT tightSize) { - if (ttype != nullptr && ttype->isMatrix() && ttype->getBasicType() != glslang::EbtDouble) { - return static_cast(ttype->getMatrixCols()) * 4 * sizeof(GLfloat); - } - return tightSize; + return MG_State::GLState::ProgramObject::UniformStorageSpanInBytes(ttype, tightSize); } void GetUniform_State(GLuint program, GLint location, void* params) { @@ -1120,6 +1119,17 @@ namespace MobileGL::MG_Impl::GLImpl { if (!programObject.IsUniformOpaqueAtLocation(location)) { MGLOG_D("%s: program = %d, location = %d, maxLocation = %d", __func__, programObject.GetExternalIndex(), location, programObject.GetMaxUniformLocation()); + // Record the write for the pipeline composite's uniform mirror, which copies only + // the locations a stage program has actually been written to (see + // ProgramObject::MarkUniformWrittenAtLocation). Here rather than further down + // because every exit below is still a write as far as GL is concerned: the + // buffered-write detour returns early, the bytes-equal dedupe returns early, and + // even the no-backing-storage bail is a uniform the application addressed. This is + // the funnel EVERY glUniform* and glProgramUniform* entry point reaches, once per + // LOCATION - so an array element write marks that element and nothing else. On a + // program that can never be a pipeline stage - the monolithic glUseProgram path, + // which is where the thousands of calls per frame are - this is one bool branch. + programObject.MarkUniformWrittenAtLocation(location); // Everything up to and including the clamp is phase-A data (the uniform's GL type // decides its size), so it is answered without joining anything. const SizeT size = programObject.GetUniformSizesInBytes(location); @@ -2704,6 +2714,15 @@ namespace MobileGL::MG_Impl::GLImpl { void GetProgramResourceiv(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount, const GLenum* props, GLsizei bufSize, GLsizei* length, GLint* params) { + // Every early-out below reports "nothing was written", and it has to say so before it can + // take one: callers legitimately leave *length uninitialised and then loop to it. The CTS + // does exactly that (gl4cProgramInterfaceQueryTests.cpp:2172 declares `GLsizei length;` and + // walks `for (i = 0; i < length; ++i)` over a 1000-entry stack array), so an untouched + // *length turned every error path here into a stack overrun inside the caller - + // KHR-GL43.program_interface_query.subroutines-vertex read 0x20202020 entries and died on + // both backends. The success path overwrites this with the real count. + if (length) *length = 0; + auto& programObject = TryToGetProgramForInterfaceQuery(program, __func__); if (!programObject) return; if (!ProgramInterface::IsInterfaceEnum(programInterface)) { diff --git a/MobileGL/MG_Impl/GLImpl/Program/GL_ProgramPipeline.cpp b/MobileGL/MG_Impl/GLImpl/Program/GL_ProgramPipeline.cpp index 8742de51..3a462127 100644 --- a/MobileGL/MG_Impl/GLImpl/Program/GL_ProgramPipeline.cpp +++ b/MobileGL/MG_Impl/GLImpl/Program/GL_ProgramPipeline.cpp @@ -19,16 +19,26 @@ namespace MobileGL::MG_Impl::GLImpl { code, MakeUnique("MG_Impl/GLImpl", function, Move(message))); } - // A pipeline name only names an object once it has been bound or created; querying a - // reserved-but-unmaterialised name is INVALID_OPERATION (GL 4.6 core 7.4). + // GL 4.6 core 7.4 asks only that the name came from GenProgramPipelines and has not been + // deleted - so a name that was reserved and never bound is legal here, and the command + // MATERIALIZES it rather than rejecting it. + // + // Requiring a bound object instead is what broke every separable-program conformance case + // across three families: the CTS reserves a name, calls glUseProgramStages three times and + // only then binds, which is the order the spec's own example uses. Each of those calls + // failed with INVALID_OPERATION, so the stage programs were never recorded - the pipeline + // stayed empty, GetProgramForDraw flattened nothing and the draw painted nothing, and the + // rejected calls' error was left in the queue for the harness to find. One cause, both + // symptoms. const SharedPtr* TryGetPipeline(GLuint pipeline, const char* function) { - if (!MG_State::pGLContext->IsProgramPipelineObject(pipeline)) { + const auto& object = MG_State::pGLContext->MaterializeProgramPipelineObject(pipeline); + if (!object) { RecordPipelineError(ErrorCode::InvalidOperation, function, std::format("Program pipeline {} does not exist.", pipeline)); return nullptr; } - return &MG_State::pGLContext->GetProgramPipelineObject(pipeline); + return &object; } Bool ValidatePipelineCount(GLsizei n, const char* function) { diff --git a/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.cpp b/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.cpp index aa1fe5a1..14697130 100644 --- a/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.cpp +++ b/MobileGL/MG_Impl/GLImpl/RenderState/GL_RenderState.cpp @@ -8,6 +8,7 @@ #include "GL_RenderState.h" #include +#include #include #include #include @@ -380,7 +381,18 @@ namespace MobileGL::MG_Impl::GLImpl { return; } - *data = IsEnabledi_State(target, index); + // GL 4.6 core 22.1: glGetBooleani_v answers EVERY indexed state, not just the indexed + // capabilities - a non-boolean value simply reads back as "is it non-zero". Routing the + // non-capability enums to the pname table glGetIntegeri_v already owns is what makes + // that true; without it a query like glGetBooleani_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 0) + // came back GL_INVALID_ENUM (KHR-GL43.compute_shader.max). + if (MG_Util::ConvertGLEnumToCapabilityInput(target) != CapabilityInput::Unknown) { + *data = IsEnabledi_State(target, index); + return; + } + GLint values[4] = {}; + GetIntegeri_v(target, index, values); + *data = values[0] != 0 ? GL_TRUE : GL_FALSE; } GLboolean IsEnabled_State(GLenum cap) { diff --git a/MobileGL/MG_Impl/GLImpl/Sampler/GL_Sampler.cpp b/MobileGL/MG_Impl/GLImpl/Sampler/GL_Sampler.cpp index f5ad6ad3..0939d680 100644 --- a/MobileGL/MG_Impl/GLImpl/Sampler/GL_Sampler.cpp +++ b/MobileGL/MG_Impl/GLImpl/Sampler/GL_Sampler.cpp @@ -336,8 +336,22 @@ namespace MobileGL::MG_Impl::GLImpl { return; } + // ARB_multi_bind adds one rule the single-bind path does not have: "samplers will not be + // created if they do not exist", so a name that is not an existing sampler OBJECT is + // INVALID_OPERATION here (KHR-GL44.multi_bind.errors_bind_samplers). Per element, not + // all-or-nothing - the extension defines glBindSamplers as a loop, so a bad entry costs + // its own texture unit and leaves the rest of the range bound. for (GLsizei i = 0; i < count; ++i) { - BindSampler_State(first + i, samplers ? samplers[i] : 0); + const GLuint sampler = samplers ? samplers[i] : 0; + if (sampler != 0 && !MG_State::pGLContext->ValidateSamplerObject(sampler)) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique( + "MG_Impl/GLImpl", "BindSamplers", + std::format("samplers[{}] ({}) is not the name of an existing sampler object.", i, sampler))); + continue; + } + BindSampler_State(first + i, sampler); } } diff --git a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp index b1eae2ef..39ebf6fd 100644 --- a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp +++ b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp @@ -613,6 +613,23 @@ namespace MobileGL::MG_Impl::GLImpl { MakeUnique("MG_Impl/GLImpl", caller, "Compressed texture formats are not supported.")); } + + // glGetTexLevelParameter{i,f}v answers WIDTH/HEIGHT/DEPTH out of the mipmap chain. The only + // other storage type the state layer knows is GL_TEXTURE_BUFFER (TextureStorageType is + // {Mipmap, Buffer}), whose level geometry this stack does not track yet. Report that instead + // of throwing: THROW_UNIMPL_EXCEPTION unwinds a C++ exception through the C GL ABI and takes + // the process down, which is never an acceptable answer to a query - see the same reasoning + // above for the compressed-format path. + void RecordUnsupportedLevelQueryStorage(const char* caller, GLenum pname) { + MGLOG_I("%s: glGetTexLevelParameter(pname=%s) is not implemented for texture-buffer " + "storage; recording GL_INVALID_OPERATION instead of terminating", + caller, MG_Util::ConvertGLEnumToString(pname).c_str()); + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique( + "MG_Impl/GLImpl", caller, + "Level queries are not supported for texture-buffer storage.")); + } } // namespace const SharedPtr& GetTextureObjectByName(GLuint texture, const char* caller) { @@ -2910,7 +2927,8 @@ namespace MobileGL::MG_Impl::GLImpl { break; } default: - THROW_UNIMPL_EXCEPTION; + RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname); + break; } } break; @@ -2924,7 +2942,8 @@ namespace MobileGL::MG_Impl::GLImpl { break; } default: - THROW_UNIMPL_EXCEPTION; + RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname); + break; } } break; @@ -2938,7 +2957,8 @@ namespace MobileGL::MG_Impl::GLImpl { break; } default: - THROW_UNIMPL_EXCEPTION; + RecordUnsupportedLevelQueryStorage("GetTexLevelParameteriv_State", pname); + break; } } break; @@ -3045,7 +3065,8 @@ namespace MobileGL::MG_Impl::GLImpl { break; } default: - THROW_UNIMPL_EXCEPTION; + RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname); + break; } } break; @@ -3059,7 +3080,8 @@ namespace MobileGL::MG_Impl::GLImpl { break; } default: - THROW_UNIMPL_EXCEPTION; + RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname); + break; } } break; @@ -3073,7 +3095,8 @@ namespace MobileGL::MG_Impl::GLImpl { break; } default: - THROW_UNIMPL_EXCEPTION; + RecordUnsupportedLevelQueryStorage("GetTexLevelParameterfv_State", pname); + break; } } break; @@ -3403,7 +3426,10 @@ namespace MobileGL::MG_Impl::GLImpl { GET_SRC_INTERNAL_FORMAT(readBufferType); } - if (!TextureImpl::ValidateBaseInternalFormatMatch(internalFormat, srcInternalFormat)) THROW_UNIMPL_EXCEPTION; + // The validator has already recorded GL_INVALID_OPERATION; just decline. Throwing + // here unwound a C++ exception through the C GL ABI and killed the process (see the + // same reasoning at :604-609). + if (!TextureImpl::ValidateCopyTexImageBaseFormatSubset(internalFormat, srcInternalFormat)) return false; GLenum outInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(srcInternalFormat); GLenum realInternalFormat = GL_RGBA8; @@ -3426,8 +3452,13 @@ namespace MobileGL::MG_Impl::GLImpl { void CopyTexImage1D_State(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLint border) { - // TODO: implement - THROW_UNIMPL_EXCEPTION; + // 1D textures are not implemented by this backend set. Record the error the way every + // other unsupported entry point does - throwing unwinds through the C GL ABI and kills + // the process, which is never an acceptable answer to an unsupported call. + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique("MG_Impl/GLImpl", "CopyTexImage1D", + "1D textures are not supported by this implementation")); } void CompressedTexSubImage3D_State(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, @@ -4079,10 +4110,46 @@ namespace MobileGL::MG_Impl::GLImpl { textureObject->SetImmutableLevels(static_cast(levels)); } + // No block-compressed format is defined for a three-dimensional image, so glTexStorage3D on + // TEXTURE_3D must reject one - and with INVALID_OPERATION, not the INVALID_ENUM an unknown + // sized format gets (GL 4.6 core 8.19 / Khronos bug 11239, KHR-GLxx.texture_storage + // .compressed_data). Written against the enum ranges rather than a name list because the + // families are contiguous and MobileGL's own internal-format enum drops the ones it cannot + // carry, which would make this check silently narrower than the API surface. + static Bool IsCompressedGLInternalFormat(GLenum internalformat) { + switch (internalformat) { + case 0x8225: // GL_COMPRESSED_RED + case 0x8226: // GL_COMPRESSED_RG + case 0x84ED: // GL_COMPRESSED_RGB + case 0x84EE: // GL_COMPRESSED_RGBA + case 0x8C48: // GL_COMPRESSED_SRGB + case 0x8C49: // GL_COMPRESSED_SRGB_ALPHA + return true; + default: + break; + } + return (internalformat >= 0x83F0 && internalformat <= 0x83F3) || // S3TC / DXT + (internalformat >= 0x8DBB && internalformat <= 0x8DBE) || // RGTC + (internalformat >= 0x8E8C && internalformat <= 0x8E8F) || // BPTC + (internalformat >= 0x9270 && internalformat <= 0x9279) || // ETC2 / EAC + (internalformat >= 0x93B0 && internalformat <= 0x93BD) || // ASTC LDR + (internalformat >= 0x93D0 && internalformat <= 0x93DD); // ASTC sRGB + } + void TextureStorage3D(GLuint texture, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height, GLsizei depth) { auto textureObject = GetTextureObjectByName(texture, __func__); if (!textureObject) return; + if (textureObject->GetTarget() == TextureTarget::Texture3D && + IsCompressedGLInternalFormat(internalformat)) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique( + "MG_Impl/GLImpl", __func__, + std::format("{} is a compressed internal format and cannot back GL_TEXTURE_3D storage.", + MG_Util::ConvertGLEnumToString(internalformat)))); + return; + } TextureInternalFormat textureInternalFormat = MG_Util::ConvertGLEnumToTextureInternalFormat(internalformat); if (!ValidateTextureStorageInternalFormat(textureInternalFormat, __func__)) return; if (!ValidateTextureStorageShape(textureObject, 3, levels, width, height, depth, __func__)) return; diff --git a/MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp b/MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp index 07ed2d2f..3e75cf10 100644 --- a/MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp +++ b/MobileGL/MG_Impl/GLImpl/Texture/Validators.cpp @@ -424,19 +424,81 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl { return true; } + namespace { + // Component set of an UNSIZED base internal format, as the bitmask GL 4.6 SS 8.6 + // reasons about. Colour components are independent bits so "subset" is a plain + // mask test; depth and stencil are their own components and never satisfy a + // colour request (or each other). + enum : Uint32 { + kComponentR = 1u << 0, + kComponentG = 1u << 1, + kComponentB = 1u << 2, + kComponentA = 1u << 3, + kComponentDepth = 1u << 4, + kComponentStencil = 1u << 5, + }; + + Uint32 BaseFormatComponents(TextureInternalFormat unsizedFormat) { + switch (unsizedFormat) { + case TextureInternalFormat::Red: + return kComponentR; + case TextureInternalFormat::RG: + return kComponentR | kComponentG; + case TextureInternalFormat::RGB: + return kComponentR | kComponentG | kComponentB; + case TextureInternalFormat::RGBA: + return kComponentR | kComponentG | kComponentB | kComponentA; + case TextureInternalFormat::DepthComponent: + return kComponentDepth; + case TextureInternalFormat::DepthStencil: + return kComponentDepth | kComponentStencil; + default: + return 0; + } + } + } // namespace + Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2) { - auto unsizedFormat1 = MG_Util::ConvertInternalFormatToUnsized(format1); - auto unsizedFormat2 = MG_Util::ConvertInternalFormatToUnsized(format2); + const auto unsizedFormat1 = MG_Util::ConvertInternalFormatToUnsized(format1); + const auto unsizedFormat2 = MG_Util::ConvertInternalFormatToUnsized(format2); if (unsizedFormat1 != unsizedFormat2) { + // The 3-argument GenericErrorInfo constructor used to be spelled as a single + // std::format() call whose format string was the component name, so every + // diagnostic collapsed to the literal "MG_Impl/GLImpl". Format the message, then + // hand over component/function/message separately. MG_State::pGLContext->RecordError( ErrorCode::InvalidOperation, MakeUnique( - std::format("MG_Impl/GLImpl", "ValidateBaseInternalFormatMatch", - "The base internal format of the two formats do not match ({} vs. {})", - MG_Util::ConvertTextureInternalFormatToString(unsizedFormat1).c_str(), - MG_Util::ConvertTextureInternalFormatToString(unsizedFormat2).c_str()))); + "MG_Impl/GLImpl", "ValidateBaseInternalFormatMatch", + std::format("The base internal format of the two formats do not match ({} vs. {})", + MG_Util::ConvertTextureInternalFormatToString(unsizedFormat1), + MG_Util::ConvertTextureInternalFormatToString(unsizedFormat2)))); return false; } return true; - } // namespace TextureImpl + } + + Bool ValidateCopyTexImageBaseFormatSubset(TextureInternalFormat destFormat, TextureInternalFormat srcFormat) { + const auto unsizedDest = MG_Util::ConvertInternalFormatToUnsized(destFormat); + const auto unsizedSrc = MG_Util::ConvertInternalFormatToUnsized(srcFormat); + // GL 4.6 SS 8.6: glCopyTexImage* may request a SUBSET of the read buffer's components, + // not an exact match - GL_RGB from an RGBA8 framebuffer is textbook legal and is what + // Minecraft and its mods do. glCopyTexImage2D used to run the exact-match predicate + // above and turn its rejection into an uncaught exception through the C GL ABI, so the + // app died rather than seeing a GL error. + const Uint32 destComponents = BaseFormatComponents(unsizedDest); + const Uint32 srcComponents = BaseFormatComponents(unsizedSrc); + if (destComponents == 0 || srcComponents == 0 || (destComponents & ~srcComponents) != 0) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique( + "MG_Impl/GLImpl", "ValidateCopyTexImageBaseFormatSubset", + std::format("the read buffer's base internal format {} does not provide every component of " + "the requested internal format {}", + MG_Util::ConvertTextureInternalFormatToString(unsizedSrc), + MG_Util::ConvertTextureInternalFormatToString(unsizedDest)))); + return false; + } + return true; + } } // namespace MobileGL::MG_Impl::GLImpl::TextureImpl diff --git a/MobileGL/MG_Impl/GLImpl/Texture/Validators.h b/MobileGL/MG_Impl/GLImpl/Texture/Validators.h index b5d40b8c..7ebd14ff 100644 --- a/MobileGL/MG_Impl/GLImpl/Texture/Validators.h +++ b/MobileGL/MG_Impl/GLImpl/Texture/Validators.h @@ -40,5 +40,9 @@ namespace MobileGL::MG_Impl::GLImpl::TextureImpl { TextureTarget target); Bool ValidateTextureSubImageOffsets(const SharedPtr& textureObject, Int xoffset, Int width, Int yoffset = 0, Int height = 0, Int zoffset = 0, Int depth = 0); + // Exact base-format equality - what glCopyImageSubData's format compatibility needs. Bool ValidateBaseInternalFormatMatch(TextureInternalFormat format1, TextureInternalFormat format2); + // GL 4.6 SS 8.6 subset rule for glCopyTexImage*: the read buffer must supply every component + // the requested internalformat asks for, but may supply more. + Bool ValidateCopyTexImageBaseFormatSubset(TextureInternalFormat destFormat, TextureInternalFormat srcFormat); } // namespace MobileGL::MG_Impl::GLImpl::TextureImpl diff --git a/MobileGL/MG_Impl/GLImpl/VertexArray/GL_VertexArray.cpp b/MobileGL/MG_Impl/GLImpl/VertexArray/GL_VertexArray.cpp index e7677708..92a938fc 100644 --- a/MobileGL/MG_Impl/GLImpl/VertexArray/GL_VertexArray.cpp +++ b/MobileGL/MG_Impl/GLImpl/VertexArray/GL_VertexArray.cpp @@ -179,6 +179,28 @@ namespace MobileGL::MG_Impl::GLImpl { return vao; } + // The ARB_vertex_attrib_binding entry points that take no vertex array name modify the + // *bound* vertex array, and in a core profile the default vertex array (name 0) is not + // one: every one of them is INVALID_OPERATION there (GL 4.6 core 10.3.1, and the tail of + // each KHR-GL4x.vertex_attrib_binding.negative-* case checks exactly this). MobileGL + // keeps a real object at name 0 for the compatibility paths, so GetBoundVertexArray + // never returns null and the rule has to be spelled out - behind the same gate the VAO-0 + // draw rule already uses (MOBILEGL_RELAXED_SEMANTICS, plus "the context never asked for + // a core profile"), so applications that legitimately run relaxed keep working. + static SharedPtr GetBoundVertexArrayForBindingApi(const char* funcName) { + auto vao = GetBoundVertexArrayOrError(funcName); + if (!vao) return nullptr; + if (vao->GetExternalIndex() == 0 && !MG_State::IsRelaxedSemanticsActive()) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique( + "MG_Impl/GLImpl", funcName, + "The default vertex array object cannot be modified in a core profile.")); + return nullptr; + } + return vao; + } + static bool ValidateVertexAttribPname(GLenum pname) { switch (pname) { case GL_VERTEX_ATTRIB_ARRAY_ENABLED: @@ -944,7 +966,7 @@ namespace MobileGL::MG_Impl::GLImpl { params[0] = static_cast(attr->Size); return; case GL_VERTEX_ATTRIB_ARRAY_STRIDE: - params[0] = static_cast(attr->Stride); + params[0] = static_cast(attr->LegacyStride); return; case GL_VERTEX_ATTRIB_ARRAY_TYPE: params[0] = static_cast(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); @@ -1014,7 +1036,7 @@ namespace MobileGL::MG_Impl::GLImpl { params[0] = static_cast(attr->Size); return; case GL_VERTEX_ATTRIB_ARRAY_STRIDE: - params[0] = static_cast(attr->Stride); + params[0] = static_cast(attr->LegacyStride); return; case GL_VERTEX_ATTRIB_ARRAY_TYPE: params[0] = static_cast(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); @@ -1079,8 +1101,11 @@ namespace MobileGL::MG_Impl::GLImpl { case GL_VERTEX_ATTRIB_ARRAY_SIZE: params[0] = attr->Size; return; + // The legacy shadow, not the resolved draw stride: GL 4.6 core table 23.3 defines this + // as the last glVertexAttrib*Pointer argument, which glBindVertexBuffer must not + // overwrite even though it does overwrite what the backend actually reads. case GL_VERTEX_ATTRIB_ARRAY_STRIDE: - params[0] = attr->Stride; + params[0] = attr->LegacyStride; return; case GL_VERTEX_ATTRIB_ARRAY_TYPE: params[0] = static_cast(MG_Util::ConvertDataTypeToGLEnum(attr->Type)); @@ -1138,7 +1163,7 @@ namespace MobileGL::MG_Impl::GLImpl { } const auto& attr = vao->GetAttribute(index); - *pointer = reinterpret_cast(attr.Offset); + *pointer = reinterpret_cast(attr.LegacyPointer); } void GetVertexAttribIiv(GLuint index, GLenum pname, GLint* params) { @@ -1222,7 +1247,7 @@ namespace MobileGL::MG_Impl::GLImpl { *param = static_cast(attr.Size); return; case GL_VERTEX_ATTRIB_ARRAY_STRIDE: - *param = static_cast(attr.Stride); + *param = static_cast(attr.LegacyStride); return; case GL_VERTEX_ATTRIB_ARRAY_TYPE: *param = static_cast(MG_Util::ConvertDataTypeToGLEnum(attr.Type)); @@ -1294,14 +1319,14 @@ namespace MobileGL::MG_Impl::GLImpl { } void BindVertexBuffer(GLuint bindingindex, GLuint buffer, GLintptr offset, GLsizei stride) { - auto vao = GetBoundVertexArrayOrError("BindVertexBuffer"); + auto vao = GetBoundVertexArrayForBindingApi("BindVertexBuffer"); if (!vao) return; VertexBufferBinding_State(vao, bindingindex, buffer, offset, stride, "BindVertexBuffer"); } void BindVertexBuffers(GLuint first, GLsizei count, const GLuint* buffers, const GLintptr* offsets, const GLsizei* strides) { - auto vao = GetBoundVertexArrayOrError("BindVertexBuffers"); + auto vao = GetBoundVertexArrayForBindingApi("BindVertexBuffers"); if (!vao) return; if (!ValidateVertexBindingRange(first, count, "BindVertexBuffers")) return; for (GLsizei i = 0; i < count; ++i) { @@ -1315,21 +1340,21 @@ namespace MobileGL::MG_Impl::GLImpl { } void VertexAttribFormat(GLuint attribindex, GLint size, GLenum type, GLboolean normalized, GLuint relativeoffset) { - auto vao = GetBoundVertexArrayOrError("VertexAttribFormat"); + auto vao = GetBoundVertexArrayForBindingApi("VertexAttribFormat"); if (!vao) return; VertexAttribFormatSeparate_State(vao, attribindex, size, type, normalized, relativeoffset, false, "VertexAttribFormat"); } void VertexAttribIFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) { - auto vao = GetBoundVertexArrayOrError("VertexAttribIFormat"); + auto vao = GetBoundVertexArrayForBindingApi("VertexAttribIFormat"); if (!vao) return; VertexAttribFormatSeparate_State(vao, attribindex, size, type, GL_FALSE, relativeoffset, true, "VertexAttribIFormat"); } void VertexAttribLFormat(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset) { - auto vao = GetBoundVertexArrayOrError("VertexAttribLFormat"); + auto vao = GetBoundVertexArrayForBindingApi("VertexAttribLFormat"); if (!vao) return; VertexAttribLFormatSeparate_State(vao, attribindex, size, type, relativeoffset); } @@ -1341,7 +1366,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void VertexAttribBinding(GLuint attribindex, GLuint bindingindex) { - auto vao = GetBoundVertexArrayOrError("VertexAttribBinding"); + auto vao = GetBoundVertexArrayForBindingApi("VertexAttribBinding"); if (!vao) return; if (!VertexArrayImpl::ValidateVertexAttributeIndex(attribindex)) return; if (!ValidateVertexBindingIndex(bindingindex, "VertexAttribBinding")) return; @@ -1349,7 +1374,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void VertexBindingDivisor(GLuint bindingindex, GLuint divisor) { - auto vao = GetBoundVertexArrayOrError("VertexBindingDivisor"); + auto vao = GetBoundVertexArrayForBindingApi("VertexBindingDivisor"); if (!vao) return; if (!ValidateVertexBindingIndex(bindingindex, "VertexBindingDivisor")) return; vao->SetBindingDivisor(bindingindex, divisor); diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index e17f8417..604c310c 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -53,6 +53,21 @@ add_executable(MobileGLIntegrationTest Scenarios/AsyncCompileScenario.cpp Scenarios/XfbAfterClipDistanceScenario.cpp Scenarios/ThreeChannelAttachmentScenario.cpp + Scenarios/PipelineFailureScenario.cpp + Scenarios/AdvertisedLimitsScenario.cpp + Scenarios/PixelStoreSweepScenario.cpp + Scenarios/FragCoordOriginScenario.cpp + Scenarios/ClearThenReadPixelsScenario.cpp + Scenarios/DepthStencilReadbackScenario.cpp + Scenarios/SsboArrayLengthScenario.cpp + Scenarios/UniformInitializerScenario.cpp + Scenarios/SwizzleAccessRoutineScenario.cpp + Scenarios/ProgramPipelineScenario.cpp + Scenarios/ImageLoadStoreSsoScenario.cpp + Scenarios/SsboDeclarationFormScenario.cpp + Scenarios/Glsl420DeclarationScenario.cpp + Scenarios/FragmentOutputArrayIndexScenario.cpp + Scenarios/BufferTextureScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE diff --git a/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp b/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp index 3f37b9b3..7526028f 100644 --- a/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp +++ b/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp @@ -601,9 +601,13 @@ namespace MGITest { } Image ReadPixels(int width, int height) { + return ReadPixelsRect(0, 0, width, height); + } + + Image ReadPixelsRect(int x, int y, int width, int height) { Image image(width, height); glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data()); + glReadPixels(x, y, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data()); return image; } diff --git a/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h b/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h index 355b453b..e3a36c3f 100644 --- a/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h +++ b/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h @@ -184,11 +184,18 @@ namespace MGITest { void ClearTo(float r, float g, float b, float a); - // Reads back the whole currently bound READ framebuffer. width/height must - // be the target's full size - DirectVulkan's default-framebuffer readback - // only re-orients a full-extent read. + // Reads back the whole currently bound READ framebuffer. Image ReadPixels(int width, int height); + // A PARTIAL glReadPixels. Row 0 of the returned image is GL row `y` of the + // framebuffer, i.e. the bottom row of the requested rect - the same + // convention ReadPixels uses, just with an origin. This is the shape the + // conformance suite reads in (a random sub-rect of the default + // framebuffer), and the shape DirectVulkan's default-FBO readback used to + // hand back in Vulkan row order because its re-orientation only ran on an + // exact full-extent read. + Image ReadPixelsRect(int x, int y, int width, int height); + // Drains any GL error queue and returns the first error, or 0. unsigned int FirstGLError(); const char* GLErrorName(unsigned int error); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp new file mode 100644 index 00000000..2e74ffbd --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp @@ -0,0 +1,120 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// "The limit we advertise is a promise, and an application will hold us to it." +// +// DirectVulkan copied Vulkan descriptor limits straight into the GL limit table. Those are not +// the same quantity: Adreno answers maxPerStageDescriptorUniformBuffers at descriptor-indexing +// scale, and GL_MAX_COMPUTE_UNIFORM_BLOCKS is a count an app will allocate. KHR-GL44.multi_bind +// .dispatch_bind_buffers_base does exactly that - createsO(limit) buffers and splices O(limit) +// UBO declarations into one compute shader - and spent ~14 s allocating before dying on +// std::bad_alloc. Its sibling dispatch_bind_buffers_range hard-codes 4 buffers and passes. +// +// Two failure modes, one table: +// - too LARGE: an unusable promise (the OOM above). +// - too SMALL or negative: a uint32 limit that lost its top bit on the way to a signed Int - +// UINT32_MAX arrived as -1, which every downstream std::min then accepted as "small enough". +// A conformant GL 4.x implementation may never advertise below the spec minimum either. +// +// Every bound below is checked on BOTH backends, because the loader casts are shared and the +// DirectGLES lane is the control: it takes its limits from a driver that already reports GL +// quantities, so an entry that only fails on DirectVulkan is a translation bug and one that +// fails on both is a table bug. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + struct LimitBound { + GLenum pname; + const char* name; + // The GL 4.x required minimum. A value below this is a conformance failure in its own + // right, and is what a sign-flipped uint32 looks like. + int minimum; + // The largest value this implementation is willing to promise. Chosen well above every + // desktop driver's answer, so it can only catch a descriptor-scale number. + int ceiling; + }; + + const std::vector& BufferLimitTable() { + static const std::vector table = { + {GL_MAX_UNIFORM_BUFFER_BINDINGS, "GL_MAX_UNIFORM_BUFFER_BINDINGS", 36, 256}, + {GL_MAX_COMPUTE_UNIFORM_BLOCKS, "GL_MAX_COMPUTE_UNIFORM_BLOCKS", 12, 256}, + {GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS", 8, 256}, + {GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS, "GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS", 8, 256}, + {GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, "GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", 8, 256}, + {GL_MAX_TEXTURE_BUFFER_SIZE, "GL_MAX_TEXTURE_BUFFER_SIZE", 65536, 1 << 27}, + {GL_MAX_UNIFORM_BLOCK_SIZE, "GL_MAX_UNIFORM_BLOCK_SIZE", 16384, 1 << 30}, + // Already clamped before this campaign; in the table so a regression there is + // caught by the same case. + {GL_MAX_SHADER_STORAGE_BLOCK_SIZE, "GL_MAX_SHADER_STORAGE_BLOCK_SIZE", 1 << 24, 512 * 1024 * 1024}, + {GL_MAX_TEXTURE_IMAGE_UNITS, "GL_MAX_TEXTURE_IMAGE_UNITS", 16, 32}, + {GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, "GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS", 48, 192}, + }; + return table; + } + + class AdvertisedLimitsScenario : public ScenarioTest {}; + + TEST_F(AdvertisedLimitsScenario, EveryBufferLimitIsWithinItsAdvertisedRange) { + for (const LimitBound& bound : BufferLimitTable()) { + GLint value = -424242; + glGetIntegerv(bound.pname, &value); + const unsigned int error = FirstGLError(); + EXPECT_EQ(error, GLenum(GL_NO_ERROR)) + << bound.name << " is not answerable: " << GLErrorName(error); + if (error != GL_NO_ERROR) continue; + + EXPECT_GE(value, bound.minimum) + << bound.name << " = " << value << " is below the GL required minimum " + << bound.minimum << " (a negative or tiny value here is a uint32 limit that lost " + "its top bit on the way to a signed Int)"; + EXPECT_LE(value, bound.ceiling) + << bound.name << " = " << value << " exceeds the ceiling " << bound.ceiling + << " this implementation is willing to promise - an application that allocates " + "what we advertise will run out of memory"; + } + } + + // The OOM case in isolation, because it is the one with a known CTS victim and the one a + // future refactor is most likely to reintroduce by copying the Vulkan limit back. + TEST_F(AdvertisedLimitsScenario, ComputeUniformBlocksIsAnAmountAnApplicationCouldActuallyAllocate) { + GLint blocks = -1; + glGetIntegerv(GL_MAX_COMPUTE_UNIFORM_BLOCKS, &blocks); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_GE(blocks, 12); + EXPECT_LE(blocks, 256) << "KHR-GL44.multi_bind.dispatch_bind_buffers_base creates one GL buffer " + "and one UBO declaration per advertised block"; + + GLint blockSize = -1; + glGetIntegerv(GL_MAX_UNIFORM_BLOCK_SIZE, &blockSize); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_GT(blockSize, 0); + // GL_MAX_COMBINED_COMPUTE_UNIFORM_COMPONENTS is derived from the product of these two, + // so their product has to stay representable. + EXPECT_LE(static_cast(blocks) * blockSize, + static_cast(2147483647)) + << "blocks(" << blocks << ") * blockSize(" << blockSize << ") overflows the GLint the " + "derived component limits are computed in"; + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/BufferTextureScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/BufferTextureScenario.cpp new file mode 100644 index 00000000..9079a0c8 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/BufferTextureScenario.cpp @@ -0,0 +1,174 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/BufferTextureScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - A BUFFER TEXTURE IS SAMPLED FROM THE VERTEX STAGE, AND TRACKS ITS BUFFER. +// +// Buffer textures are core in OpenGL 3.1 and MobileGL advertises a 4.x context, so an +// application may build geometry out of one without asking whether the host can. Minecraft +// 26.3 does exactly that: its cloud layer has no vertex attributes at all, only gl_VertexID +// and texelFetch on a GL_R8I buffer texture. Nothing covered that path end to end on either +// backend - the frontend unit tests stop at glTexBuffer's state, and no scenario ever drew +// with the result - which is how DirectGLES came to emit `#extension GL_EXT_texture_buffer : +// require` unconditionally, compile nothing on a host without the extension, and lose the +// whole cloud layer with no diagnostic anywhere. +// +// Two claims, in the order they can break: +// 1. a vertex-stage texelFetch on an R8I buffer texture reads the byte the application put +// in the buffer (the shape of the real workload: no attributes, index from gl_VertexID); +// 2. a later glBufferSubData is visible to the next draw WITHOUT re-specifying the texture. +// glTexBuffer attaches storage, it does not copy: the texture is a live view of the +// buffer, so a backend that only refreshes the view when the texture's own state changes +// must still show the new bytes. DirectGLES' respecify gate is keyed on the texture info +// and deliberately does not include the buffer's contents, so this is the assertion that +// says that is safe rather than merely untested. +// +// NOTE ON A HOST WITHOUT BUFFER TEXTURES: this scenario is expected to FAIL there, and that is +// the honest outcome - MobileGL keeps advertising GL_MAX_TEXTURE_BUFFER_SIZE (an OpenGL 4.x +// context may not answer 0), so there is no capability an application, or this test, could +// branch on. The driver POST's "Buffer textures" row is where that verdict is stated. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // No vertex attributes: the quad's corners come from gl_VertexID, exactly like the + // workload this exists for. The texel is fetched in the VERTEX stage - the stage where + // buffer-texture support is scarcest across ES drivers - and carried flat so every + // fragment of the quad reports the same byte and the readback is exact. + constexpr const char* kVS = R"(#version 330 core +uniform isamplerBuffer uFaces; +flat out int vFace; +void main() { + vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0, + (gl_VertexID & 2) == 0 ? -1.0 : 1.0); + vFace = texelFetch(uFaces, 0).r; + gl_Position = vec4(corner, 0.0, 1.0); +} +)"; + + // 1/255 steps survive an RGBA8 round trip exactly, so the readback byte IS the value + // the vertex shader fetched. + constexpr const char* kFS = R"(#version 330 core +flat in int vFace; +out vec4 o_color; +void main() { o_color = vec4(float(vFace) / 255.0, 0.0, 0.0, 1.0); } +)"; + + class BufferTextureScenario : public ScenarioTest {}; + + // Draws the full-viewport quad and returns the red byte every fragment was painted with, + // or -1 if the quad did not come out uniform (which would mean the flat varying, not the + // fetch, is what this test is measuring). + int PaintedValue(unsigned int program, int width, int height) { + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + + const Image image = ReadPixels(width, height); + if (image.Empty()) { + return -1; + } + const int first = image.At(0, 0).r; + for (int y = 0; y < image.Height(); ++y) { + for (int x = 0; x < image.Width(); ++x) { + if (image.At(x, y).r != first) { + return -1; + } + } + } + return first; + } + + } // namespace + + TEST_F(BufferTextureScenario, VertexStageTexelFetchReadsTheBufferAndTracksItsUpdates) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + // GL_R8I is the format the real workload uses. Signed, so the values stay well inside + // [0, 127] to keep the readback arithmetic honest. + constexpr signed char kInitial = 37; + constexpr signed char kUpdated = 91; + std::vector texels(64, 0); + texels[0] = kInitial; + + // The harness shares one context across every scenario in the process, so an error left + // by an earlier one would surface below as "glTexBuffer was refused". + FirstGLError(); + + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_TEXTURE_BUFFER, buffer); + glBufferData(GL_TEXTURE_BUFFER, static_cast(texels.size()), texels.data(), + GL_DYNAMIC_DRAW); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_BUFFER, texture); + glTexBuffer(GL_TEXTURE_BUFFER, GL_R8I, buffer); + ASSERT_EQ(FirstGLError(), 0u) << "glTexBuffer(GL_R8I) was refused"; + + ColorFbo target = MakeColorFbo(64, 64); + ASSERT_NE(target.fbo, 0u) << "could not create the render target"; + BindFbo(target); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_BUFFER, texture); + glUseProgram(program); + const GLint location = glGetUniformLocation(program, "uFaces"); + ASSERT_NE(location, -1) << "the buffer sampler was optimized away or never reflected"; + glUniform1i(location, 0); + + EXPECT_EQ(PaintedValue(program, target.width, target.height), static_cast(kInitial)) + << "a vertex-stage texelFetch on an R8I buffer texture did not read the byte the " + "application stored (a uniform -1 here means the quad was not uniform at all)"; + + // The texture is a VIEW of the buffer: no glTexBuffer call follows, and none should be + // needed for the new bytes to be visible. + glBindBuffer(GL_TEXTURE_BUFFER, buffer); + glBufferSubData(GL_TEXTURE_BUFFER, 0, 1, &kUpdated); + ASSERT_EQ(FirstGLError(), 0u) << "glBufferSubData on the texture's buffer was refused"; + + EXPECT_EQ(PaintedValue(program, target.width, target.height), static_cast(kUpdated)) + << "the buffer texture kept showing the old contents after glBufferSubData; the " + "texture must track its buffer without being re-specified"; + + BindDefaultFramebuffer(); + DestroyColorFbo(target); + glUseProgram(0); + glDeleteProgram(program); + glDeleteTextures(1, &texture); + glDeleteBuffers(1, &buffer); + glViewport(0, 0, gl.Width(), gl.Height()); + EXPECT_EQ(FirstGLError(), 0u); + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.cpp new file mode 100644 index 00000000..f8c7955a --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.cpp @@ -0,0 +1,335 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - A CLEAR OF THE DEFAULT FRAMEBUFFER IS VISIBLE TO glReadPixels WITH NO DRAW BETWEEN. +// +// DirectVulkan parks a glClear as a pending clear and folds it into the next render pass's +// loadOp. When nothing is drawn after the clear there is no render pass, and the readback path +// used to materialize pending clears only for USER framebuffers - so a readback right after a +// clear of the DEFAULT framebuffer blitted the untouched swapchain image and handed back the +// previous frame's colour. +// +// That is the whole of KHR-GL40.draw_indirect.negative-* (12 Magma failures): each case clears, +// issues a draw that correctly raises INVALID_OPERATION and therefore never executes, then reads +// the frame back expecting (0,0,0,0) and gets the previous case's (0.1,0.2,0.3,1). The staleness +// cannot appear in one frame, so the scenario paints a frame first and clears in the next. +// +// The alpha assertion is the second half of the same census finding: a cleared default +// framebuffer read back (0,0,0,1) where (0,0,0,0) was written, because the clear was routed +// through the default FBO's placeholder attachment, whose format can lack alpha, rather than +// through the swapchain image that actually has one. +// +// DirectGLES is the built-in control: a native GL driver has no deferred-clear model at all, so +// a failure there would mean the scenario, not the backend. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +)"; + + // The colour KHR-GL40.draw_indirect's fshSimple paints, so a stale readback shows up as + // the same value the conformance log reports. + constexpr const char* kFS = R"(#version 330 core +out vec4 o_color; +void main() { o_color = vec4(0.1, 0.2, 0.3, 1.0); } +)"; + + class ClearThenReadPixelsScenario : public ScenarioTest {}; + + void DrawFullViewportQuad(unsigned int program) { + static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + GLuint vao = 0, vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + } + + } // namespace + + TEST_F(ClearThenReadPixelsScenario, ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + ASSERT_GE(width, 8); + ASSERT_GE(height, 8); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + // Frame 1: paint the whole default framebuffer, so there IS something stale to return. + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(1.0f, 1.0f, 1.0f, 1.0f); + DrawFullViewportQuad(program); + { + const Image painted = ReadPixels(width, height); + const Rgba8 centre = painted.At(width / 2, height / 2); + ASSERT_NEAR(centre.r, 26, 2) << "the setup frame did not paint; the staleness test would be vacuous"; + ASSERT_NEAR(centre.g, 51, 2); + ASSERT_NEAR(centre.b, 77, 2); + } + gl.EndFrame(); + + // Frame 2: clear to transparent black and read back with NO draw at all. + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + ClearTo(0.0f, 0.0f, 0.0f, 0.0f); + const Image cleared = ReadPixels(width, height); + EXPECT_EQ(FirstGLError(), 0u); + + int nonZero = 0; + int firstX = -1; + int firstY = -1; + Rgba8 firstOffender{}; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const Rgba8 pixel = cleared.At(x, y); + if (pixel.r == 0 && pixel.g == 0 && pixel.b == 0 && pixel.a == 0) continue; + if (nonZero == 0) { + firstX = x; + firstY = y; + firstOffender = pixel; + } + ++nonZero; + } + } + EXPECT_EQ(nonZero, 0) << "glClear(0,0,0,0) followed by glReadPixels with no draw returned " << nonZero + << " of " << (width * height) << " non-zero pixels; first at (" << firstX << ", " + << firstY << ") = (" << static_cast(firstOffender.r) << ", " + << static_cast(firstOffender.g) << ", " << static_cast(firstOffender.b) + << ", " << static_cast(firstOffender.a) << ")"; + + gl.EndFrame(); + glDeleteProgram(program); + } + + // The same claim for a sub-rect read, which is the shape the conformance suite uses most and + // the one whose orientation handling is separate (see OrientationScenario). + TEST_F(ClearThenReadPixelsScenario, ClearWithNoDrawIsVisibleToASubRectReadback) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + ASSERT_GE(width, 8); + ASSERT_GE(height, 8); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + DrawFullViewportQuad(program); + gl.EndFrame(); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + ClearTo(0.0f, 0.0f, 0.0f, 0.0f); + const int rectWidth = width / 2; + const int rectHeight = height / 2; + const Image cleared = ReadPixelsRect(width / 4, height / 4, rectWidth, rectHeight); + EXPECT_EQ(FirstGLError(), 0u); + + int nonZero = 0; + for (int y = 0; y < rectHeight; ++y) { + for (int x = 0; x < rectWidth; ++x) { + const Rgba8 pixel = cleared.At(x, y); + if (pixel.r != 0 || pixel.g != 0 || pixel.b != 0 || pixel.a != 0) ++nonZero; + } + } + EXPECT_EQ(nonZero, 0) << nonZero << " of " << (rectWidth * rectHeight) + << " pixels in a sub-rect read after a draw-free clear were not zero"; + + gl.EndFrame(); + glDeleteProgram(program); + } + + // The other half of the same rule, and the one the first version of this fix got wrong: a + // parked clear must be executed BEFORE whatever writes the framebuffer next, not whenever the + // readback happens to notice it. Minecraft clears the default framebuffer, renders the world + // into its own framebuffer and blits the result out; nothing in between opens a render pass on + // the default framebuffer, so the clear stays parked across the whole frame. Materializing it + // at readback time therefore ran it AFTER the blit and returned a blank frame - which is what + // took every DirectVulkan retrace to ssim 0.000005. + TEST_F(ClearThenReadPixelsScenario, ABlitIntoTheDefaultFramebufferSurvivesAnEarlierClear) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + // Paint a source framebuffer, exactly as a game renders its world off-screen. + ColorFbo source = MakeColorFbo(width, height); + ASSERT_NE(source.fbo, 0u); + BindFbo(source); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + DrawFullViewportQuad(program); + + // Clear the DEFAULT framebuffer, then blit the source over it. The clear is white so a + // frame that lost the blit is unmistakable, and the blit's colour is fshSimple's. + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + ClearTo(1.0f, 1.0f, 1.0f, 1.0f); + glBindFramebuffer(GL_READ_FRAMEBUFFER, source.fbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + EXPECT_EQ(FirstGLError(), 0u); + + const Image blitted = ReadPixels(width, height); + EXPECT_EQ(FirstGLError(), 0u); + const Rgba8 centre = blitted.At(width / 2, height / 2); + EXPECT_NEAR(centre.r, 26, 2) << "the blit into the default framebuffer did not survive the clear that " + "preceded it; read back rgba(" << static_cast(centre.r) << ", " + << static_cast(centre.g) << ", " << static_cast(centre.b) << ", " + << static_cast(centre.a) << ")"; + EXPECT_NEAR(centre.g, 51, 2); + EXPECT_NEAR(centre.b, 77, 2); + + DestroyColorFbo(source); + gl.EndFrame(); + glDeleteProgram(program); + } + + // A MULTISAMPLE-RESOLVE blit into the default framebuffer has to change orientation like any + // other, but vkCmdResolveImage takes one offset per side and cannot invert an axis, so it used + // to land the mirrored band. The renderer now resolves into a single-sample scratch image and + // blits from there. The source is painted in two horizontal bands so the mirror is visible; + // a full-extent uniform blit is a fixed point of the flip and would prove nothing. + TEST_F(ClearThenReadPixelsScenario, AMultisampleResolveBlitIntoTheDefaultFramebufferKeepsItsOrientation) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + ASSERT_GE(height, 8); + + GLint maxSamples = 0; + glGetIntegerv(GL_MAX_SAMPLES, &maxSamples); + if (maxSamples < 2) { + GTEST_SKIP() << "GL_MAX_SAMPLES is " << maxSamples << "; this needs a multisample renderbuffer"; + } + + GLuint fbo = 0, rbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glGenRenderbuffers(1, &rbo); + glBindRenderbuffer(GL_RENDERBUFFER, rbo); + glRenderbufferStorageMultisample(GL_RENDERBUFFER, 2, GL_RGBA8, width, height); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, rbo); + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { + glDeleteRenderbuffers(1, &rbo); + glDeleteFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + GTEST_SKIP() << "no complete 2x multisample RGBA8 renderbuffer on this driver"; + } + glViewport(0, 0, width, height); + + // Bottom half red, top half blue - via scissored clears, so no shader is involved. + glEnable(GL_SCISSOR_TEST); + glScissor(0, 0, width, height / 2); + ClearTo(1.0f, 0.0f, 0.0f, 1.0f); + glScissor(0, height / 2, width, height - height / 2); + ClearTo(0.0f, 0.0f, 1.0f, 1.0f); + glDisable(GL_SCISSOR_TEST); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + EXPECT_EQ(FirstGLError(), 0u); + + const Image resolved = ReadPixels(width, height); + EXPECT_EQ(FirstGLError(), 0u); + const Rgba8 bottom = resolved.At(width / 2, height / 4); + const Rgba8 top = resolved.At(width / 2, height - 1 - height / 4); + EXPECT_GT(bottom.r, 200) << "the bottom band should be red after the resolve, got rgba(" + << static_cast(bottom.r) << ", " << static_cast(bottom.g) << ", " + << static_cast(bottom.b) << ") - blue there means the resolve landed " + << "in the mirrored band"; + EXPECT_LT(bottom.b, 60); + EXPECT_GT(top.b, 200) << "the top band should be blue after the resolve, got rgba(" + << static_cast(top.r) << ", " << static_cast(top.g) << ", " + << static_cast(top.b) << ")"; + EXPECT_LT(top.r, 60); + + glDeleteRenderbuffers(1, &rbo); + glDeleteFramebuffers(1, &fbo); + gl.EndFrame(); + } + + // The same ordering claim for the path that DOES open a render pass. It passes today (the + // render pass folds the clear into its loadOp and pops it), and it is here so a future change + // to the pending-clear lifecycle cannot quietly reverse clear and draw. + TEST_F(ClearThenReadPixelsScenario, ADrawIntoTheDefaultFramebufferSurvivesAnEarlierClear) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(1.0f, 1.0f, 1.0f, 1.0f); + DrawFullViewportQuad(program); + EXPECT_EQ(FirstGLError(), 0u); + + const Image painted = ReadPixels(width, height); + const Rgba8 centre = painted.At(width / 2, height / 2); + EXPECT_NEAR(centre.r, 26, 2) << "the draw did not survive the clear that preceded it"; + EXPECT_NEAR(centre.g, 51, 2); + EXPECT_NEAR(centre.b, 77, 2); + + gl.EndFrame(); + glDeleteProgram(program); + } +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackScenario.cpp new file mode 100644 index 00000000..6a41efae --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackScenario.cpp @@ -0,0 +1,297 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - glReadPixels OF DEPTH AND STENCIL FROM THE DEFAULT FRAMEBUFFER. +// +// DirectVulkan's depth/stencil readback used to decline the default framebuffer outright +// (`ReadDepthStencilPixels` returned at its first line) because that framebuffer's depth and +// stencil "attachments" are placeholder texture objects backing no image - the real one is the +// swapchain's depth/stencil twin. Declining meant the call raised no GL error and wrote NOTHING, +// so the caller kept whatever its buffer already held. +// +// That silence is what the framebuffer_blit family trips over. Every one of its cases begins by +// clearing the default framebuffer's depth and stencil and reading them straight back as a +// sanity check, into a local pre-initialised to 0.2 (depth) and 50 (stencil); an untouched +// buffer therefore reports "expected DEPTH[0.25] but got DEPTH[0.2]" and "expected STENCIL[1] but +// got STENCIL[50]" - the exact strings in the 15 Magma failures - long before any blit happens. +// A test that only checked "no GL error" would pass against the broken path, so every case here +// poisons its destination with a value the correct answer cannot be. +// +// The orientation case is the second half. This renderer stores the default framebuffer +// display-side-up and converts GL rects on their way in, so the depth copy needs the same rect +// mapping and row re-ordering the colour readback got in the M-1 fix; without them a +// vertically-varying depth buffer reads back mirrored, which no full-extent uniform-value test +// can see. +// +// Depth/stencil readback through a USER framebuffer already worked and is asserted here too, as +// the built-in control: it shares ReadDepthStencilImageToClient with the default-framebuffer +// path, so it is what says a failure is about the default framebuffer specifically. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Values no correct read can produce, so "the backend wrote nothing" fails loudly instead + // of passing on whatever happened to be in the variable. These are the CTS's own poison + // values, which is why its logs report exactly them. + constexpr float kDepthPoison = 0.2f; + constexpr int kStencilPoison = 50; + + class DepthStencilReadbackScenario : public ScenarioTest { + protected: + // DirectGLES reads depth and stencil back through the ES driver, which has no + // guaranteed path for either (GL_NV_read_depth / GL_NV_read_stencil are optional and + // absent on both the Adreno device and Mesa's ES). That gap is tracked separately as + // the packed_depth_stencil cluster and needs a shader-sampling emulation, not this + // change; asserting it here would only pin a known-missing feature. + bool BackendReadsDepthStencil() const { return Gl().BackendName() == "DirectVulkan"; } + + float ReadDepthAt(int x, int y) const { + float depth = kDepthPoison; + glReadPixels(x, y, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &depth); + return depth; + } + + int ReadStencilAt(int x, int y) const { + int stencil = kStencilPoison; + glReadPixels(x, y, 1, 1, GL_STENCIL_INDEX, GL_INT, &stencil); + return stencil; + } + }; + + // A depth buffer whose value depends on the row: bottom half `bottom`, top half `top`. + // Built with a scissored clear rather than a draw so the test stays independent of + // depth-test and shader behaviour. + void ClearDepthInBands(int width, int height, float bottom, float top) { + glEnable(GL_SCISSOR_TEST); + glScissor(0, 0, width, height / 2); + glClearDepth(bottom); + glClear(GL_DEPTH_BUFFER_BIT); + glScissor(0, height / 2, width, height - height / 2); + glClearDepth(top); + glClear(GL_DEPTH_BUFFER_BIT); + glDisable(GL_SCISSOR_TEST); + } + + } // namespace + + TEST_F(DepthStencilReadbackScenario, DefaultFramebufferDepthClearIsVisibleToReadPixels) { + if (!Ready()) return; + if (!BackendReadsDepthStencil()) { + GTEST_SKIP() << "backend " << Gl().BackendName() + << " has no depth readback path (ES lacks GL_NV_read_depth); see the packed_depth_stencil " + "cluster"; + } + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDepthMask(GL_TRUE); + glClearDepth(0.25); + glClear(GL_DEPTH_BUFFER_BIT); + + const float centre = ReadDepthAt(width / 2, height / 2); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_NEAR(centre, 0.25f, 1.0f / 4096.0f) + << "glReadPixels(GL_DEPTH_COMPONENT) of the default framebuffer returned " << centre + << (std::fabs(centre - kDepthPoison) < 1e-6f ? " - the destination was never written at all" : ""); + + gl.EndFrame(); + } + + TEST_F(DepthStencilReadbackScenario, DefaultFramebufferStencilClearIsVisibleToReadPixels) { + if (!Ready()) return; + if (!BackendReadsDepthStencil()) { + GTEST_SKIP() << "backend " << Gl().BackendName() + << " has no stencil readback path (ES lacks GL_NV_read_stencil); see the " + "packed_depth_stencil cluster"; + } + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glStencilMask(0xFFu); + glClearStencil(3); + glClear(GL_STENCIL_BUFFER_BIT); + + const int centre = ReadStencilAt(width / 2, height / 2); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_EQ(centre, 3) << "glReadPixels(GL_STENCIL_INDEX) of the default framebuffer returned " << centre + << (centre == kStencilPoison ? " - the destination was never written at all" : ""); + + gl.EndFrame(); + } + + // The orientation half: a depth buffer that varies with the row must read back in GL's + // bottom-up order. A full-extent uniform clear is a fixed point of the flip, so only a banded + // buffer can tell the two apart. + TEST_F(DepthStencilReadbackScenario, DefaultFramebufferDepthReadbackKeepsTheGLRowOrder) { + if (!Ready()) return; + if (!BackendReadsDepthStencil()) { + GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path"; + } + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + ASSERT_GE(height, 8); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDepthMask(GL_TRUE); + ClearDepthInBands(width, height, /*bottom=*/0.25f, /*top=*/0.75f); + EXPECT_EQ(FirstGLError(), 0u); + + const float bottom = ReadDepthAt(width / 2, height / 4); + const float top = ReadDepthAt(width / 2, height - 1 - height / 4); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_NEAR(bottom, 0.25f, 1.0f / 4096.0f) + << "GL row " << (height / 4) << " is in the bottom band and was cleared to 0.25, but read back " << bottom + << " (0.75 there means the readback is upside down)"; + EXPECT_NEAR(top, 0.75f, 1.0f / 4096.0f) + << "GL row " << (height - 1 - height / 4) << " is in the top band and was cleared to 0.75, but read back " + << top << " (0.25 there means the readback is upside down)"; + + gl.EndFrame(); + } + + // A depth blit INTO the default framebuffer has to convert its rect out of GL's bottom-origin + // space, exactly as the colour blit does. The colour path had that conversion and the + // depth path did not, so a scissored depth blit landed in the mirrored band - which is the + // whole of KHR-GL*.framebuffer_blit.scissor_blit once the readback above works well enough to + // see it (before that the test died on the poison values and never reached the blit). + TEST_F(DepthStencilReadbackScenario, AScissoredDepthBlitIntoTheDefaultFramebufferLandsInTheScissorBox) { + if (!Ready()) return; + if (!BackendReadsDepthStencil()) { + GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path"; + } + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + ASSERT_GE(width, 8); + ASSERT_GE(height, 8); + + // Source: a user framebuffer whose depth is uniformly 0.75. + GLuint fbo = 0, colorTex = 0, depthTex = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glGenTextures(1, &colorTex); + glBindTexture(GL_TEXTURE_2D, colorTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTex, 0); + glGenTextures(1, &depthTex); + glBindTexture(GL_TEXTURE_2D, depthTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, width, height, 0, GL_DEPTH_STENCIL, + GL_UNSIGNED_INT_24_8, nullptr); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDepthMask(GL_TRUE); + glClearDepth(0.75); + glClear(GL_DEPTH_BUFFER_BIT); + + // Destination: the default framebuffer, depth 0 everywhere. + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glClearDepth(0.0); + glClear(GL_DEPTH_BUFFER_BIT); + + // Blit the whole rect, but scissored to the BOTTOM-LEFT quadrant in GL coordinates. + glEnable(GL_SCISSOR_TEST); + glScissor(0, 0, width / 2, height / 2); + glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_DEPTH_BUFFER_BIT, GL_NEAREST); + glDisable(GL_SCISSOR_TEST); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + EXPECT_EQ(FirstGLError(), 0u); + + const float inside = ReadDepthAt(width / 4, height / 4); + const float above = ReadDepthAt(width / 4, height - 1 - height / 4); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_NEAR(inside, 0.75f, 1.0f / 4096.0f) + << "GL (" << (width / 4) << ", " << (height / 4) << ") is inside the scissor box and should hold the " + << "blitted 0.75, but read back " << inside; + EXPECT_NEAR(above, 0.0f, 1.0f / 4096.0f) + << "GL (" << (width / 4) << ", " << (height - 1 - height / 4) + << ") is ABOVE the scissor box and must still hold the cleared 0.0, but read back " << above + << " (0.75 there means the depth blit landed in the mirrored band)"; + + glDeleteTextures(1, &depthTex); + glDeleteTextures(1, &colorTex); + glDeleteFramebuffers(1, &fbo); + gl.EndFrame(); + } + + // The control: the same read against a user framebuffer, which never went through the + // declined path. It is what makes a failure above specific to the default framebuffer. + TEST_F(DepthStencilReadbackScenario, UserFramebufferDepthClearIsVisibleToReadPixels) { + if (!Ready()) return; + if (!BackendReadsDepthStencil()) { + GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path"; + } + HeadlessGL& gl = Gl(); + const int width = 64; + const int height = 48; + + GLuint fbo = 0, colorTex = 0, depthTex = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glGenTextures(1, &colorTex); + glBindTexture(GL_TEXTURE_2D, colorTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTex, 0); + glGenTextures(1, &depthTex); + glBindTexture(GL_TEXTURE_2D, depthTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, width, height, 0, GL_DEPTH_STENCIL, + GL_UNSIGNED_INT_24_8, nullptr); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + ASSERT_EQ(FirstGLError(), 0u); + + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDepthMask(GL_TRUE); + glStencilMask(0xFFu); + glClearDepth(0.5); + glClearStencil(7); + glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + + const float depth = ReadDepthAt(width / 2, height / 2); + const int stencil = ReadStencilAt(width / 2, height / 2); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_NEAR(depth, 0.5f, 1.0f / 4096.0f) << "user-framebuffer depth readback returned " << depth; + EXPECT_EQ(stencil, 7) << "user-framebuffer stencil readback returned " << stencil; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteTextures(1, &depthTex); + glDeleteTextures(1, &colorTex); + glDeleteFramebuffers(1, &fbo); + gl.EndFrame(); + } +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/FragCoordOriginScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/FragCoordOriginScenario.cpp new file mode 100644 index 00000000..9ae737c4 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/FragCoordOriginScenario.cpp @@ -0,0 +1,139 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FragCoordOriginScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - gl_FragCoord ON THE DEFAULT FRAMEBUFFER CARRIES GL'S WINDOW ORIGIN. +// +// GL measures gl_FragCoord.y from the BOTTOM of the window. Vulkan's gl_FragCoord.y is the +// framebuffer ROW being written, and DirectVulkan stores the default framebuffer display-side-up +// (compensating for vertices by negating gl_Position.y), so a fragment's reported Y there was +// `height - y_GL` - flipped, and for a viewport that does not span the full height, outside the +// range GL promises entirely. GL CTS +// `KHR-GL42.shader_image_load_store.basic-{allTargets-atomic,glsl-earlyFragTests,glsl-misc}` +// caught it: each sets a small viewport at GL y=0 and does +// `imageStore(image, ivec2(gl_FragCoord.xy), ...)` into an image exactly that size, so on a +// 256-tall surface every store addressed rows 224..255 of a 32-row image and was dropped. +// +// The shader here paints each row with its own GL window Y, which is the whole claim in one +// value: row j of the readback must be j, for a full-height viewport and for a half-height one +// (the case where a flip and an offset can no longer hide each other). DirectGLES is the +// built-in control - a native GL driver gets this right by construction, so a failure there +// would mean the test, not the backend. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +)"; + + // floor(gl_FragCoord.y) is the fragment's window row; 1/255 steps survive an RGBA8 + // round trip exactly, so the readback byte IS the row the shader believes it is on. + constexpr const char* kFS = R"(#version 330 core +out vec4 o_color; +void main() { o_color = vec4(floor(gl_FragCoord.y) / 255.0, 0.0, 0.0, 1.0); } +)"; + + class FragCoordOriginScenario : public ScenarioTest {}; + + // A quad covering the whole viewport, drawn with attribute 0 = aPos. + void DrawFullViewportQuad(unsigned int program) { + static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + GLuint vao = 0, vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + } + + // Paints `viewportHeight` rows starting at GL y=0 and returns the red byte of each row. + std::vector RowsPaintedWithTheirOwnWindowY(unsigned int program, int width, int viewportHeight) { + BindDefaultFramebuffer(); + glViewport(0, 0, width, viewportHeight); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 1.0f, 1.0f); + DrawFullViewportQuad(program); + + const Image image = ReadPixelsRect(0, 0, width, viewportHeight); + std::vector rows; + rows.reserve(static_cast(viewportHeight)); + for (int y = 0; y < viewportHeight; ++y) { + rows.push_back(image.At(width / 2, y).r); + } + return rows; + } + + ::testing::AssertionResult RowsAreTheirOwnIndex(const std::vector& rows, const char* when) { + for (std::size_t y = 0; y < rows.size(); ++y) { + if (rows[y] != static_cast(y)) { + return ::testing::AssertionFailure() + << when << ": GL window row " << y << " reported gl_FragCoord.y = " << rows[y] + << " (expected " << y << "). Rows 0.." << (rows.size() - 1) << " read back as [" + << rows.front() << " .. " << rows.back() << "]."; + } + } + return ::testing::AssertionSuccess(); + } + + } // namespace + + TEST_F(FragCoordOriginScenario, DefaultFramebufferFragCoordCountsFromTheBottom) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + // 1/255 steps only stay distinguishable while the row index fits in a byte. + const int width = gl.Width(); + const int fullHeight = std::min(gl.Height(), 256); + ASSERT_GE(fullHeight, 8) << "the harness surface is too small to tell rows apart"; + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + // Full height first: this one passed even before the fix (a flip alone maps the row set + // onto itself), so it is the control that the shader and the readback agree at all. + EXPECT_TRUE(RowsAreTheirOwnIndex(RowsPaintedWithTheirOwnWindowY(program, width, fullHeight), + "full-height viewport")); + + // Half height at GL y=0: the case the CTS failures were made of. A backend that reports + // the stored row here answers `height - y` for every row - off the bottom of the range, + // not merely reversed within it. + const int halfHeight = fullHeight / 2; + EXPECT_TRUE(RowsAreTheirOwnIndex(RowsPaintedWithTheirOwnWindowY(program, width, halfHeight), + "half-height viewport at GL y=0")); + + glUseProgram(0); + glDeleteProgram(program); + glViewport(0, 0, gl.Width(), gl.Height()); + EXPECT_EQ(FirstGLError(), 0u); + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/FragmentOutputArrayIndexScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/FragmentOutputArrayIndexScenario.cpp new file mode 100644 index 00000000..80e2f1ab --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/FragmentOutputArrayIndexScenario.cpp @@ -0,0 +1,227 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FragmentOutputArrayIndexScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - DYNAMICALLY INDEXED FRAGMENT OUTPUT ARRAYS, on a live driver. +// +// The bug: GLSL ES requires a *constant integral expression* to index a fragment output array +// (GLSL ES 3.00 4.3.6); SPIR-V has no such rule. A shader that writes `coeff[i]` from a loop +// therefore travels through glslang and SPIRV-Cross intact and lands on the ES driver as ESSL it +// refuses outright - "array indexes for fragment outputs must be constant integral expressions". +// The program links nothing and every draw that uses it becomes a silent no-op. That is the whole +// of improved-transparency-minecraft-26.3 on the Android DirectGLES lane: Minecraft 26.3's OIT +// coefficient shader has exactly this shape, and losing it empties the entire translucent layer +// (clouds and water) while the opaque geometry stays pixel-exact. +// +// WHY THIS SCENARIO EXISTS RATHER THAN A UNIT TEST. The unit tests in MG_Test/Program (see +// ProgramUtilTest, LoopDerivedFragmentOutputIndexFoldsToConstantIndices and its +// genuinely-dynamic sibling) prove the SPIR-V comes out with constant indices, validates, and +// decompiles to ESSL with only literal indices. What they cannot prove is that a real driver +// then ACCEPTS and RUNS it - and acceptance is the whole failure mode, because Mesa accepts the +// illegal form too. Only a live glCompileShader/glLinkProgram followed by a draw can tell the two +// apart, and only reading the pixels back can tell "linked" from "wrote the right attachment". +// +// Both backends run this: on DirectVulkan the original module is already legal (the legalization +// is DirectGLES-only, deliberately), so this doubles as the check that the two backends agree +// about what such a shader means. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + // The Minecraft 26.3 OIT coefficient shape: both the attachment index and the component + // index come from loop counters, so nothing but the loop bounds decides where each value + // lands. Attachment 0 gets (0.0, 0.1, 0.2, 0.3) and attachment 1 gets (0.5, 0.6, 0.7, 0.8) - + // values that are only correct if the two indices were folded to the RIGHT constants, not + // merely to some constant. + constexpr const char* kLoopIndexedFS = R"(#version 330 core +out vec4 coeff[2]; +void main() { + for (int attachmentIndex = 0; attachmentIndex < 2; ++attachmentIndex) { + for (int i = 0; i < 4; ++i) { + coeff[attachmentIndex][i] = float(attachmentIndex) * 0.5 + float(i) * 0.1; + } + } +} +)"; + + // No loop can fold this one: the index arrives in a uniform. It exercises the fallback + // lowering (a switch over the array range for the write, constant-indexed loads and a + // select for the read) and it checks the untargeted attachment is left ALONE, which a + // lowering that wrote every element unconditionally would break. + constexpr const char* kUniformIndexedFS = R"(#version 330 core +uniform int uTarget; +out vec4 coeff[2]; +void main() { + coeff[0] = vec4(0.25, 0.25, 0.25, 1.0); + coeff[1] = vec4(0.75, 0.75, 0.75, 1.0); + coeff[uTarget] = coeff[uTarget] + vec4(0.25, 0.0, 0.0, 0.0); +} +)"; + + constexpr int kSize = 8; + + class FragmentOutputArrayIndexScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + + glGenFramebuffers(1, &m_fbo); + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + for (int i = 0; i < 2; ++i) { + glGenTextures(1, &m_color[i]); + glBindTexture(GL_TEXTURE_2D, m_color[i]); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, + m_color[i], 0); + } + const GLenum drawBuffers[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1}; + glDrawBuffers(2, drawBuffers); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), + static_cast(GL_FRAMEBUFFER_COMPLETE)); + + const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + glViewport(0, 0, kSize, kSize); + } + + void TearDown() override { + if (Ready()) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &m_fbo); + glDeleteTextures(2, m_color); + glDeleteBuffers(1, &m_vbo); + glDeleteVertexArrays(1, &m_vao); + } + ScenarioTest::TearDown(); + } + + // Clears both attachments to a colour no shader below writes, so an attachment that + // was never written reads back as the sentinel rather than as a plausible value. + void ClearToSentinel() { + glClearColor(0.0f, 0.0f, 1.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + } + + std::vector ReadAttachment(int index) { + std::vector bytes(static_cast(kSize) * kSize * 4, 0); + glReadBuffer(GL_COLOR_ATTACHMENT0 + index); + glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_UNSIGNED_BYTE, bytes.data()); + std::vector centre(4, -1.0f); + // The middle pixel: the quad covers the whole target, so every pixel is the same, + // and the middle one cannot be a rasterization edge case. + const std::size_t offset = (static_cast(kSize / 2) * kSize + kSize / 2) * 4; + for (int i = 0; i < 4; ++i) { + centre[static_cast(i)] = static_cast(bytes[offset + i]) / 255.0f; + } + return centre; + } + + GLuint m_fbo = 0; + GLuint m_color[2] = {0, 0}; + GLuint m_vao = 0; + GLuint m_vbo = 0; + }; + + // The gate for the whole defect: before the legalization this program did not link on a + // strict ES driver (ANGLE), so the draw wrote nothing and BOTH attachments kept the + // sentinel. Now each attachment must carry the value its loop iteration produced. + TEST_F(FragmentOutputArrayIndexScenario, LoopIndexedOutputArrayWritesEveryAttachment) { + if (!Ready() || IsSkipped()) return; + + std::string error; + const GLuint program = CompileProgram(kVS, kLoopIndexedFS, &error); + ASSERT_NE(program, 0u) << "a loop-indexed fragment output array must compile and link: " + << error; + + ClearToSentinel(); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + const std::vector first = ReadAttachment(0); + EXPECT_NEAR(first[0], 0.0f, 0.02f) << "attachment 0 red"; + EXPECT_NEAR(first[1], 0.1f, 0.02f) << "attachment 0 green"; + EXPECT_NEAR(first[2], 0.2f, 0.02f) + << "attachment 0 blue - a sentinel 1.0 here means the draw never ran"; + EXPECT_NEAR(first[3], 0.3f, 0.02f) << "attachment 0 alpha"; + + const std::vector second = ReadAttachment(1); + EXPECT_NEAR(second[0], 0.5f, 0.02f) + << "attachment 1 red - the second loop iteration must reach the second draw buffer"; + EXPECT_NEAR(second[1], 0.6f, 0.02f) << "attachment 1 green"; + EXPECT_NEAR(second[2], 0.7f, 0.02f) << "attachment 1 blue"; + EXPECT_NEAR(second[3], 0.8f, 0.02f) << "attachment 1 alpha"; + + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + // The fallback half, on a live driver, for both values of the uniform: the targeted + // attachment is read, incremented and written back; the other one keeps exactly what the + // constant-indexed store put there. + TEST_F(FragmentOutputArrayIndexScenario, UniformIndexedOutputArrayWritesOnlyTheSelectedAttachment) { + if (!Ready() || IsSkipped()) return; + + std::string error; + const GLuint program = CompileProgram(kVS, kUniformIndexedFS, &error); + ASSERT_NE(program, 0u) << "a uniform-indexed fragment output array must compile and link: " + << error; + const GLint targetLocation = glGetUniformLocation(program, "uTarget"); + ASSERT_GE(targetLocation, 0); + glUseProgram(program); + + for (int target = 0; target < 2; ++target) { + ClearToSentinel(); + glUniform1i(targetLocation, target); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + const std::vector first = ReadAttachment(0); + const std::vector second = ReadAttachment(1); + EXPECT_NEAR(first[0], target == 0 ? 0.5f : 0.25f, 0.02f) + << "attachment 0 red with uTarget=" << target; + EXPECT_NEAR(first[1], 0.25f, 0.02f) << "attachment 0 green with uTarget=" << target; + EXPECT_NEAR(second[0], target == 1 ? 1.0f : 0.75f, 0.02f) + << "attachment 1 red with uTarget=" << target; + EXPECT_NEAR(second[1], 0.75f, 0.02f) << "attachment 1 green with uTarget=" << target; + } + + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/Glsl420DeclarationScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/Glsl420DeclarationScenario.cpp new file mode 100644 index 00000000..a0f721d5 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/Glsl420DeclarationScenario.cpp @@ -0,0 +1,476 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/Glsl420DeclarationScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - GLSL 4.20 DECLARATIONS THE FRONTEND USED TO REJECT OR COLLAPSE. +// +// GLSL 4.20 gives an array of opaque uniforms or of block instances CONSECUTIVE binding +// points: "layout(binding = 1) uniform sampler2D goku[7]" puts goku[0] on texture unit 1 +// and goku[6] on unit 7, and the same rule holds for "layout(binding = 2) uniform GOKU +// {...} goku[14]" over uniform buffer binding points 2..15 (GLSL 4.20 4.4.5, GL 4.6 7.6.2). +// One qualifier, N bindings - which is exactly the part that is easy to get wrong, because +// every element shares one declaration and one reflection record. +// +// Three separate mechanisms all collapsed that array down to its first element, and the +// three cases below pin one each: +// +// * the SAMPLER array (Espryt): reflection names an array after its first element at +// every location it spans, so the backend resolved "goku[0]" once per element, got one +// backend location N times, and the per-draw pass's last glUniform1i was the only one +// that survived. goku[0] ended up holding the LAST element's unit and goku[1..N-1] kept +// unit 0 - so every element sampled whatever was bound to unit 0. +// * the uniform BLOCK array (both backends): glslang reports the declared binding for +// every expanded instance, so nothing added the element offset. glGetActiveUniformBlockiv +// answered the base binding for all of them, and since both backends feed a block from +// that same number at draw time, all instances also read one buffer. +// * 'invariant' on a non-vertex stage's INPUT: legal desktop GLSL at every version, and +// ignored where it is written, but glslang rejected it from 4.20 up - so a shader that +// compiled as "#version 400" stopped compiling as "#version 420". +// +// The fourth case is the same species as the third - a legal 4.20 shader the frontend +// refused - and lives here for that reason: atomicCounterIncrement() was rejected because +// glslang applied its atomicAdd() extension gate to the atomicAdd() its own Vulkan-relaxed +// lowering had just synthesized. +// +// Conformance cases behind these: KHR-GL42.shading_language_420pack.binding_sampler_array, +// .binding_uniform_block_array, .qualifier_order[_block]_test_id_*, and +// KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kElements = 4; + + // No vertex attributes: the quad comes from gl_VertexID, so nothing here depends on + // the harness's attribute pinning and the fragment stage is the only thing under test. + constexpr const char* kQuadVS = R"(#version 420 core +void main() +{ + switch (gl_VertexID) + { + case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break; + case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break; + case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break; + default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break; + } +} +)"; + + // The red channel comes back as a BITMASK of which elements read the wrong thing, so + // a failure names the element instead of just saying "not green". float(bad)/255.0 + // round-trips exactly through an RGBA8 target for every mask this can produce. + constexpr const char* kSamplerArrayFS = R"(#version 420 core +layout(binding = 1) uniform sampler2D goku[4]; +out vec4 o_color; +void main() +{ + const vec2 uv = vec2(0.5, 0.5); + int bad = 0; + if (texture(goku[0], uv) != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1; + if (texture(goku[1], uv) != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2; + if (texture(goku[2], uv) != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4; + if (texture(goku[3], uv) != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8; + o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0); +} +)"; + + // Same declaration one dimension deeper. GLSL 4.30 arrays of arrays are legal here, and + // the elements still take consecutive units (1..4) in declaration order - but the two + // reflections disagree about how to count them, which is the whole point of this case. + constexpr const char* kSamplerArrayOfArraysFS = R"(#version 430 core +layout(binding = 1) uniform sampler2D goku[2][2]; +out vec4 o_color; +void main() +{ + const vec2 uv = vec2(0.5, 0.5); + int bad = 0; + if (texture(goku[0][0], uv) != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1; + if (texture(goku[0][1], uv) != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2; + if (texture(goku[1][0], uv) != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4; + if (texture(goku[1][1], uv) != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8; + o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0); +} +)"; + + constexpr const char* kBlockArrayFS = R"(#version 420 core +layout(std140, binding = 2) uniform GOKU +{ + vec4 gohan; +} goku[4]; +out vec4 o_color; +void main() +{ + int bad = 0; + if (goku[0].gohan != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1; + if (goku[1].gohan != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2; + if (goku[2].gohan != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4; + if (goku[3].gohan != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8; + o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0); +} +)"; + + // The producing stage declares the varying invariant (always legal) and the consuming + // stage redeclares it (the part that regressed at 4.20). The qualifier ORDER is the + // shuffled one 420pack exists to allow, so this also covers the parse path the + // qualifier_order cases exercise. + constexpr const char* kInvariantInVS = R"(#version 420 core +smooth invariant out highp vec4 v_data; +void main() +{ + v_data = vec4(0.0, 1.0, 0.0, 1.0); + switch (gl_VertexID) + { + case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break; + case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break; + case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break; + default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break; + } +} +)"; + + constexpr const char* kInvariantInFS = R"(#version 420 core +highp in smooth invariant vec4 v_data; +out vec4 o_color; +void main() { o_color = v_data; } +)"; + + // atomicCounterIncrement() is core GLSL from 4.20 and needs no extension. MobileGL + // parses under Vulkan-relaxed rules, which rewrite it into an atomicAdd() on a buffer + // block - and glslang then applied to its OWN rewrite the desktop-below-430 gate that + // demands GL_ARB_shader_storage_buffer_object for atomicAdd, rejecting a shader it had + // just accepted. The shape is lifted from + // KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters. + constexpr const char* kAtomicCounterVS = R"(#version 420 core +layout(binding = 0, offset = 0) uniform atomic_uint g_counter; +out flat uint v_index; +void main() +{ + v_index = atomicCounterIncrement(g_counter); + switch (gl_VertexID) + { + case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break; + case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break; + case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break; + default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break; + } +} +)"; + + constexpr const char* kAtomicCounterFS = R"(#version 420 core +in flat uint v_index; +out vec4 o_color; +void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + class Glsl420DeclarationScenario : public ScenarioTest { + protected: + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + if (!m_textures.empty()) glDeleteTextures(static_cast(m_textures.size()), m_textures.data()); + if (!m_buffers.empty()) glDeleteBuffers(static_cast(m_buffers.size()), m_buffers.data()); + for (GLuint p : m_programs) glDeleteProgram(p); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + m_textures.clear(); + m_buffers.clear(); + m_programs.clear(); + m_vao = 0; + } + + GLuint Build(const char* vs, const char* fs) { + std::string error; + const GLuint program = CompileProgram(vs, fs, &error); + if (program == 0) { + ADD_FAILURE() << "program did not build: " << error; + return 0; + } + m_programs.push_back(program); + return program; + } + + // One 1x1 RGBA8 texture per element, each a colour whose channels are exactly 0 or + // 255 so the shader's == comparisons are exact. + void MakeElementTextures(const std::uint8_t colors[kElements][4]) { + m_textures.assign(kElements, 0); + glGenTextures(kElements, m_textures.data()); + for (int i = 0; i < kElements; ++i) { + glActiveTexture(GL_TEXTURE0 + 1 + i); + glBindTexture(GL_TEXTURE_2D, m_textures[i]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, colors[i]); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + } + glActiveTexture(GL_TEXTURE0); + } + + void MakeElementBuffers(const float values[kElements][4], GLuint firstBinding) { + m_buffers.assign(kElements, 0); + glGenBuffers(kElements, m_buffers.data()); + for (int i = 0; i < kElements; ++i) { + glBindBuffer(GL_UNIFORM_BUFFER, m_buffers[i]); + glBufferData(GL_UNIFORM_BUFFER, 4 * sizeof(float), values[i], GL_STATIC_DRAW); + glBindBufferBase(GL_UNIFORM_BUFFER, firstBinding + i, m_buffers[i]); + } + glBindBuffer(GL_UNIFORM_BUFFER, 0); + } + + // Draws the full-screen quad and hands back the centre pixel. + Rgba8 DrawAndRead(GLuint program) { + HeadlessGL& gl = Gl(); + if (m_vao == 0) glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + BindDefaultFramebuffer(); + glViewport(0, 0, gl.Width(), gl.Height()); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + const Image image = ReadPixels(gl.Width(), gl.Height()); + glUseProgram(0); + return image.At(gl.Width() / 2, gl.Height() / 2); + } + + // An array of ARRAYS is declined by Magma (ProgramFactory::ReflectLayout logs it and + // VkProgramObject::declinedDescriptors then refuses every draw), which is a defined + // outcome the case below can assert. Espryt has no such gate: it bakes the units the + // frontend reports into its ESSL, and since the binding-qualifier seeding does not + // walk the inner dimension every element reports unit 0 - so it samples one texture + // four times and paints a mismatch. That gap is in the FRONTEND, one level below + // either backend, and fixing it is the feature that would make this shape work + // everywhere; it is not part of wiring descriptor arrays through Magma, so the + // Espryt arm is SCOPED and the reflection half is asserted on both backends. + bool MultiDimensionalSamplerArraysAreDeclined() const { return Gl().BackendName() == "DirectVulkan"; } + + // Same shape, different gap: with the compile fixed, this shader now links on + // both backends but paints nothing on Magma - the atomic counter becomes a + // buffer descriptor there and that half is not wired up yet (the conformance + // case KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters is where it + // is measured). The regression this case exists for is the COMPILE, which is + // asserted on both backends above; only the paint is scoped. + bool AtomicCounterDrawsAreSupported() const { return Gl().BackendName() != "DirectVulkan"; } + + static std::string BadElements(std::uint8_t mask) { + if (mask == 0) return "none"; + std::string out; + for (int i = 0; i < kElements; ++i) { + if ((mask & (1u << i)) == 0) continue; + if (!out.empty()) out += ", "; + out += "[" + std::to_string(i) + "]"; + } + return out; + } + + std::vector m_textures; + std::vector m_buffers; + std::vector m_programs; + GLuint m_vao = 0; + }; + + } // namespace + + // Element k of a sampler array samples texture unit N+k - both as the API reports it and, + // the part that was actually broken, as the draw behaves. + TEST_F(Glsl420DeclarationScenario, SamplerArrayElementsSampleConsecutiveTextureUnits) { + if (!Ready()) return; + + static const std::uint8_t colors[kElements][4] = { + {255, 0, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}, {0, 255, 255, 255}}; + MakeElementTextures(colors); + + const GLuint program = Build(kQuadVS, kSamplerArrayFS); + if (program == 0) return; + + // The reported unit is the shadow the frontend seeds from the qualifier. It was + // already right when the draw was wrong, so checking only this would have passed + // straight through the bug - it is here to separate a reflection regression from a + // backend one if this case ever fails again. + glUseProgram(program); + for (int i = 0; i < kElements; ++i) { + const std::string name = "goku[" + std::to_string(i) + "]"; + const GLint location = glGetUniformLocation(program, name.c_str()); + ASSERT_GE(location, 0) << name << " has no location"; + GLint unit = -1; + glGetUniformiv(program, location, &unit); + EXPECT_EQ(unit, 1 + i) << name << " should default to texture unit " << (1 + i); + } + glUseProgram(0); + + const Rgba8 centre = DrawAndRead(program); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_EQ(centre.r, 0) << "sampler array elements that read the wrong texture: " << BadElements(centre.r); + EXPECT_EQ(centre.g, 255) << "the draw did not reach the fragment stage at all"; + } + + // An array of ARRAYS of samplers is the shape the two reflections count differently: + // SPIRV-Reflect reports one binding of 4 flattened descriptors, while the frontend hands out + // uniform locations along the outer dimension only and keys the uniform by its full + // "goku[0][0]" spelling. Magma therefore cannot address elements 1..3 of that binding, and + // the contract this case pins is that it says so and DECLINES - the failure it must never + // return to is resolving those elements onto whatever uniform got the next locations, which + // is a silently wrong texture rather than a missing draw. + // + // Deliberately weak on the pixels for that reason: what is asserted on every backend is that + // the program builds, the draw raises no GL error, and the process survives. Where the + // descriptors do resolve, the colours are checked too. + TEST_F(Glsl420DeclarationScenario, AnArrayOfSamplerArraysIsHonouredOrDeclinedCleanly) { + if (!Ready()) return; + + static const std::uint8_t colors[kElements][4] = { + {255, 0, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}, {0, 255, 255, 255}}; + MakeElementTextures(colors); + + std::string error; + const GLuint program = CompileProgram(kQuadVS, kSamplerArrayOfArraysFS, &error); + if (program == 0) { + GTEST_SKIP() << "the frontend does not build an array of sampler arrays: " << error; + } + m_programs.push_back(program); + + // The reflection DOES reserve one location per flattened element, in the order + // SPIRV-Reflect flattens them - which is the whole reason baseLocation + element is the + // right addressing rule for a descriptor array, and would be right for this shape too. + // What is missing is one level up: the `layout(binding = 1)` unit seeding walks the outer + // dimension only, so all four elements report unit 0 instead of 1..4. That is why this + // shape is declined rather than supported, and it is asserted here because the day the + // seeding learns arrays of arrays, the decline should be revisited rather than kept. + glUseProgram(program); + for (int outer = 0; outer < 2; ++outer) { + for (int inner = 0; inner < 2; ++inner) { + const std::string name = "goku[" + std::to_string(outer) + "][" + std::to_string(inner) + "]"; + EXPECT_EQ(glGetUniformLocation(program, name.c_str()), outer * 2 + inner) + << name << " should hold the flattened element's own location"; + } + } + glUseProgram(0); + + const Rgba8 centre = DrawAndRead(program); + EXPECT_EQ(FirstGLError(), 0u) << "declining a descriptor array must not raise a GL error"; + + if (!MultiDimensionalSamplerArraysAreDeclined()) { + GTEST_SKIP() << "the frontend's binding-qualifier seeding does not walk an array of arrays, so " + << Gl().BackendName() << " samples unit 0 for every element; the locations " + << "asserted above are the half of this case it can answer"; + } + + // Three outcomes are possible and only two are acceptable. Green means every element + // sampled its own unit. Black - the untouched clear - means the program was declined and + // painted nothing, which is the documented Magma outcome. A non-zero red channel is the + // third: the draw DID reach the fragment stage and elements read the wrong textures, + // which is exactly the silent mismatch this decline exists to prevent. + if (centre.g == 255) { + EXPECT_EQ(centre.r, 0) << "elements of the array of arrays that read the wrong texture: " + << BadElements(centre.r); + return; + } + EXPECT_EQ(centre.r, 0) << "the array of arrays was not resolved, but the draw still painted " + "a mismatch instead of being declined: " << BadElements(centre.r); + } + + // Instance k of a uniform block array sits on buffer binding point N+k - again both as + // reported and as fed to the shader. + TEST_F(Glsl420DeclarationScenario, UniformBlockArrayInstancesTakeConsecutiveBindings) { + if (!Ready()) return; + + static const float values[kElements][4] = { + {1.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 1.0f, 1.0f}, {1.0f, 1.0f, 0.0f, 1.0f}, {0.0f, 1.0f, 1.0f, 1.0f}}; + constexpr GLuint kFirstBinding = 2; + MakeElementBuffers(values, kFirstBinding); + + const GLuint program = Build(kQuadVS, kBlockArrayFS); + if (program == 0) return; + + for (int i = 0; i < kElements; ++i) { + const std::string name = "GOKU[" + std::to_string(i) + "]"; + const GLuint index = glGetUniformBlockIndex(program, name.c_str()); + ASSERT_NE(index, static_cast(GL_INVALID_INDEX)) << name << " is not an active block"; + GLint binding = -1; + glGetActiveUniformBlockiv(program, index, GL_UNIFORM_BLOCK_BINDING, &binding); + EXPECT_EQ(binding, static_cast(kFirstBinding) + i) + << name << " should start on binding point " << (kFirstBinding + i); + } + EXPECT_EQ(FirstGLError(), 0u) << "the block queries left a GL error behind"; + + const Rgba8 centre = DrawAndRead(program); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_EQ(centre.r, 0) << "block array instances that read the wrong buffer: " << BadElements(centre.r); + EXPECT_EQ(centre.g, 255) << "the draw did not reach the fragment stage at all"; + } + + // 'invariant' written on a fragment input at #version 420. The same source compiles at + // #version 400 on any implementation, so a version-dependent rejection is the defect. + TEST_F(Glsl420DeclarationScenario, InvariantIsAcceptedOnANonVertexStageInput) { + if (!Ready()) return; + + const GLuint program = Build(kInvariantInVS, kInvariantInFS); + if (program == 0) return; + + const Rgba8 centre = DrawAndRead(program); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_EQ(centre.g, 255) << "the invariant-qualified varying did not arrive"; + EXPECT_EQ(centre.r, 0); + } + + // A #version 420 shader may call atomicCounterIncrement() with no extension at all. The + // assertion is deliberately the COMPILE, because the defect was a compile-time gate on + // glslang's own atomic-counter lowering; the draw that follows only checks the shader + // survives the rest of the pipeline without leaving an error behind. + TEST_F(Glsl420DeclarationScenario, AnAtomicCounterCompilesWithoutTheSsboExtension) { + if (!Ready()) return; + + const GLuint shader = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(shader, 1, &kAtomicCounterVS, nullptr); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[2048] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + glDeleteShader(shader); + FAIL() << "atomicCounterIncrement() at #version 420 core did not compile: " << log; + } + glDeleteShader(shader); + + const GLuint program = Build(kAtomicCounterVS, kAtomicCounterFS); + if (program == 0) return; + + GLuint counter = 0; + glGenBuffers(1, &counter); + m_buffers.push_back(counter); + const GLuint zero = 0; + glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, counter); + glBufferData(GL_ATOMIC_COUNTER_BUFFER, sizeof(GLuint), &zero, GL_DYNAMIC_DRAW); + glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, counter); + glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0); + + if (!AtomicCounterDrawsAreSupported()) { + GTEST_SKIP() << "atomic-counter draws do not paint on " << Gl().BackendName() + << " yet; the compile above is what this case pins"; + } + + const Rgba8 centre = DrawAndRead(program); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_EQ(centre.g, 255) << "the atomic-counter shader linked but painted nothing"; + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp new file mode 100644 index 00000000..d78426cc --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp @@ -0,0 +1,473 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - IMAGE UNIFORMS REACHED THROUGH A PROGRAM PIPELINE. +// +// KHR-GL42.shader_image_load_store.advanced-sso-simple reduced to its mechanism. An ARRAY of +// image uniforms lives in a separable FRAGMENT program; the application assigns each element its +// own image unit with glProgramUniform1i, on a program that is not current and whose pipeline is +// not even bound yet; the draw then goes through the pipeline, i.e. through the flattened +// composite program (MG_State/GLState/Core.cpp, GetProgramForDraw) rather than through the stage +// program the units were written to. +// +// Three separate things have to survive that indirection, and each one is a different mechanism: +// +// 1. the units themselves, which are per-program state on a DIFFERENT object from the one the +// draw reads (the composite mirror carries them); +// 2. the units as seen by a backend that cannot take them at draw time - Espryt has to BAKE an +// image unit into the ESSL it generates, because ES forbids glUniform1i on image uniforms, +// so a change has to invalidate the generated program; +// 3. per-ELEMENT assignment, which is what makes this different from every sampler case: the +// four elements of g_image[] are four locations with four different units, and nothing may +// collapse them to the array's base. +// +// Two pipelines that SHARE their vertex stage program and differ only in the fragment one are +// used exactly as the conformance case does, because that is what makes the composite cache and +// the stage programs' separate uniform storage both load-bearing at once. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kSsoVS = R"(#version 420 core +out gl_PerVertex { vec4 gl_Position; }; +void main() +{ + switch (gl_VertexID) + { + case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break; + case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break; + case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break; + case 3: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break; + } +} +)"; + + // The conformance case's two fragment programs: one with an explicit format qualifier, + // one writeonly with none. Both write every element of a four-image array and discard. + constexpr const char* kImageFS0 = R"(#version 420 core +layout(rgba32f) uniform image2D g_image[4]; +void main() +{ + for (int i = 0; i < g_image.length(); ++i) { + imageStore(g_image[i], ivec2(gl_FragCoord), vec4(1.0)); + } + discard; +} +)"; + + constexpr const char* kImageFS1 = R"(#version 420 core +writeonly uniform image2D g_image[4]; +void main() +{ + for (int i = 0; i < g_image.length(); ++i) { + imageStore(g_image[i], ivec2(gl_FragCoord), vec4(2.0)); + } + discard; +} +)"; + + class ImageLoadStoreSsoScenario : public ScenarioTest { + protected: + void TearDown() override { + if (!Ready()) return; + glBindProgramPipeline(0); + glUseProgram(0); + for (GLuint p : m_programs) glDeleteProgram(p); + for (GLuint p : m_pipelines) glDeleteProgramPipelines(1, &p); + m_programs.clear(); + m_pipelines.clear(); + } + + GLuint MakeSeparable(GLenum stage, const char* source) { + const GLuint program = glCreateShaderProgramv(stage, 1, &source); + if (program != 0) m_programs.push_back(program); + EXPECT_EQ(FirstGLError(), 0u) + << "glCreateShaderProgramv(stage 0x" << std::hex << stage << std::dec << ") left a GL error"; + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + ADD_FAILURE() << "glCreateShaderProgramv(stage 0x" << std::hex << stage << std::dec + << ") did not link: " << log; + return 0; + } + return program; + } + + GLuint MakePipeline() { + GLuint pipeline = 0; + glGenProgramPipelines(1, &pipeline); + m_pipelines.push_back(pipeline); + return pipeline; + } + + // Espryt reaches the GPU through an ES driver, and ES forbids glUniform1i on an + // image uniform: the unit has to be BAKED into the generated ESSL as + // layout(binding = N) (RebindImageUniformsToFrontendUnits, MG_Backend/DirectGLES). + // One qualifier is all an ARRAY declaration can carry, and ESSL then gives the + // array's elements the CONSECUTIVE units N, N+1, N+2, ... - so a per-element + // assignment that is not consecutive (the conformance case uses 0, 2, 4, 6) has no + // spelling in a single declaration and cannot be expressed at all without splitting + // the array into one declaration per element and rewriting every use of it. + // + // Scoped rather than disabled, exactly as ProgramPipelineScenario scopes its + // storage-block rebinding cases: the defect is per-backend and the frontend + // mechanism these cases exist for - per-element units surviving the trip to the + // pipeline composite - is fully exercised on Magma. + bool PerElementImageUnitsAreHonoured() const { return Gl().BackendName() == "DirectVulkan"; } + + // The scenarios below need image load/store at all; a driver without it should skip + // rather than fail. + bool ImagesAreUsable() const { + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + while (glGetError() != GL_NO_ERROR) { + } + return maxImageUnits >= 8; + } + + std::vector m_programs; + std::vector m_pipelines; + }; + + } // namespace + + // The whole conformance shape in one case: two pipelines sharing a vertex stage, four image + // array elements each pointed at a different unit through glProgramUniform1i, eight layers of + // one array texture bound one per unit, and every layer checked. + // + // Layers alternate 1.0 / 2.0 because the two fragment programs interleave their units + // (0,2,4,6 and 1,3,5,7) - so a defect that collapses an image array to its base element, or + // that loses the units on the way to the composite, does not merely dim the result: it puts + // the wrong VALUE in a layer and names which one. + TEST_F(ImageLoadStoreSsoScenario, PerElementImageUnitsReachAPipelineDraw) { + if (!Ready()) return; + if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units"; + if (!PerElementImageUnitsAreHonoured()) { + GTEST_SKIP() << "non-consecutive per-element image units cannot be baked into ESSL"; + } + HeadlessGL& gl = Gl(); + + constexpr int kWidth = 8; + constexpr int kHeight = 8; + constexpr int kLayers = 8; + + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSsoVS); + const GLuint fs0 = MakeSeparable(GL_FRAGMENT_SHADER, kImageFS0); + const GLuint fs1 = MakeSeparable(GL_FRAGMENT_SHADER, kImageFS1); + if (vs == 0 || fs0 == 0 || fs1 == 0) return; + + // Per ELEMENT, by name, on programs that are neither current nor attached to a bound + // pipeline yet - exactly the conformance call order. + const int units0[4] = {0, 2, 4, 6}; + const int units1[4] = {1, 3, 5, 7}; + for (int i = 0; i < 4; ++i) { + const std::string name = "g_image[" + std::to_string(i) + "]"; + const GLint loc0 = glGetUniformLocation(fs0, name.c_str()); + const GLint loc1 = glGetUniformLocation(fs1, name.c_str()); + ASSERT_NE(loc0, -1) << "fs0 has no location for " << name; + ASSERT_NE(loc1, -1) << "fs1 has no location for " << name; + glProgramUniform1i(fs0, loc0, units0[i]); + glProgramUniform1i(fs1, loc1, units1[i]); + } + ASSERT_EQ(FirstGLError(), 0u) << "assigning image units with glProgramUniform1i errored"; + + const GLuint pipeline0 = MakePipeline(); + const GLuint pipeline1 = MakePipeline(); + glUseProgramStages(pipeline0, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline0, GL_FRAGMENT_SHADER_BIT, fs0); + glUseProgramStages(pipeline1, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline1, GL_FRAGMENT_SHADER_BIT, fs1); + ASSERT_EQ(FirstGLError(), 0u) << "pipeline setup errored"; + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D_ARRAY, texture); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + const std::vector zeros(static_cast(kWidth) * kHeight * kLayers * 4, 0.0f); + glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA32F, kWidth, kHeight, kLayers, 0, GL_RGBA, GL_FLOAT, zeros.data()); + ASSERT_EQ(FirstGLError(), 0u) << "creating the RGBA32F array texture errored"; + + // One LAYER of the array texture per unit, which is what makes each element's unit + // independently observable in the readback. + for (int unit = 0; unit < kLayers; ++unit) { + glBindImageTexture(static_cast(unit), texture, 0, GL_FALSE, unit, GL_READ_WRITE, GL_RGBA32F); + } + ASSERT_EQ(FirstGLError(), 0u) << "glBindImageTexture errored"; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, kWidth, kHeight); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glUseProgram(0); + + glBindProgramPipeline(pipeline0); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindProgramPipeline(pipeline1); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); + EXPECT_EQ(FirstGLError(), 0u) << "the two pipeline draws leaked a GL error"; + + std::vector readback(static_cast(kWidth) * kHeight * kLayers * 4, -1.0f); + glBindTexture(GL_TEXTURE_2D_ARRAY, texture); + glGetTexImage(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, GL_FLOAT, readback.data()); + ASSERT_EQ(FirstGLError(), 0u) << "reading the array texture back errored"; + + // Even layers were written through fs0's units, odd layers through fs1's. + for (int layer = 0; layer < kLayers; ++layer) { + const float expected = (layer % 2) ? 2.0f : 1.0f; + int offenders = 0; + float firstSeen = 0.0f; + for (int y = 0; y < kHeight; ++y) { + for (int x = 0; x < kWidth; ++x) { + const size_t base = + (static_cast(layer) * kHeight * kWidth + static_cast(y) * kWidth + x) * 4; + for (int c = 0; c < 4; ++c) { + if (readback[base + c] != expected) { + if (offenders == 0) firstSeen = readback[base + c]; + ++offenders; + } + } + } + } + EXPECT_EQ(offenders, 0) << "layer " << layer << " (image unit " << layer << ") expected " << expected + << " but " << offenders << " components differ; first was " << firstSeen; + } + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteTextures(1, &texture); + gl.EndFrame(); + } + + // An image ARRAY sharing a program with another descriptor, which is the shape that makes + // the SPIR-V binding remap load-bearing. + // + // The remap (ProgramFactory::RemapDescriptorBindingsForVulkan) is what unifies bindings + // across stages and normalises every descriptor onto set 0; glslang hands it per-stage + // numbering that starts at 0 in EACH stage. It used to refuse any descriptor array that was + // not a UBO, and its only complaint was an assert that compiles out above DEBUG - so a + // release build carried on with the un-remapped numbering and a program holding an image + // array plus a second descriptor could see the two alias onto one binding, while a DEBUG + // build trapped on the very same program. + // + // A case with ONE descriptor cannot see any of that: with a single resource there is nothing + // to collide with and skipping the remap is indistinguishable from running it. Hence this + // one - an image array AND a uniform block in the same fragment program, with the block + // supplying the value that gets stored, so a mis-assigned binding shows up as the wrong + // colour rather than as nothing at all. + TEST_F(ImageLoadStoreSsoScenario, AnImageArrayAlongsideAnotherDescriptorKeepsBothBindings) { + if (!Ready()) return; + if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units"; + if (!PerElementImageUnitsAreHonoured()) { + GTEST_SKIP() << "non-consecutive per-element image units cannot be baked into ESSL"; + } + HeadlessGL& gl = Gl(); + + constexpr int kWidth = 8; + constexpr int kHeight = 8; + constexpr int kLayers = 2; + + static const char* kMixedFS = R"(#version 420 core +layout(rgba32f) uniform image2D g_image[2]; +layout(std140) uniform Value { vec4 u_value; }; +void main() +{ + for (int i = 0; i < g_image.length(); ++i) { + imageStore(g_image[i], ivec2(gl_FragCoord), u_value); + } + discard; +} +)"; + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSsoVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kMixedFS); + if (vs == 0 || fs == 0) return; + + // Consecutive units here on purpose: this case is about the two descriptor KINDS + // coexisting, not about non-consecutive assignment, which the case above covers. + for (int i = 0; i < 2; ++i) { + const std::string name = "g_image[" + std::to_string(i) + "]"; + const GLint loc = glGetUniformLocation(fs, name.c_str()); + ASSERT_NE(loc, -1) << "no location for " << name; + glProgramUniform1i(fs, loc, i); + } + + const GLfloat value[4] = {7.0f, 7.0f, 7.0f, 7.0f}; + GLuint ubo = 0; + glGenBuffers(1, &ubo); + glBindBuffer(GL_UNIFORM_BUFFER, ubo); + glBufferData(GL_UNIFORM_BUFFER, sizeof(value), value, GL_STATIC_DRAW); + const GLuint blockIndex = glGetUniformBlockIndex(fs, "Value"); + ASSERT_NE(blockIndex, GL_INVALID_INDEX); + glUniformBlockBinding(fs, blockIndex, 0); + glBindBufferBase(GL_UNIFORM_BUFFER, 0, ubo); + glBindBuffer(GL_UNIFORM_BUFFER, 0); + ASSERT_EQ(FirstGLError(), 0u) << "uniform block setup errored"; + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D_ARRAY, texture); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + const std::vector zeros(static_cast(kWidth) * kHeight * kLayers * 4, 0.0f); + glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA32F, kWidth, kHeight, kLayers, 0, GL_RGBA, GL_FLOAT, zeros.data()); + glBindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA32F); + glBindImageTexture(1, texture, 0, GL_FALSE, 1, GL_READ_WRITE, GL_RGBA32F); + ASSERT_EQ(FirstGLError(), 0u) << "image texture setup errored"; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, kWidth, kHeight); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glUseProgram(0); + glBindProgramPipeline(pipeline); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); + EXPECT_EQ(FirstGLError(), 0u) << "the mixed-descriptor pipeline draw leaked a GL error"; + + std::vector readback(static_cast(kWidth) * kHeight * kLayers * 4, -1.0f); + glBindTexture(GL_TEXTURE_2D_ARRAY, texture); + glGetTexImage(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, GL_FLOAT, readback.data()); + ASSERT_EQ(FirstGLError(), 0u) << "reading the array texture back errored"; + + for (int layer = 0; layer < kLayers; ++layer) { + int offenders = 0; + float firstSeen = 0.0f; + for (size_t i = 0; i < static_cast(kWidth) * kHeight * 4; ++i) { + const size_t index = static_cast(layer) * kHeight * kWidth * 4 + i; + if (readback[index] != 7.0f) { + if (offenders == 0) firstSeen = readback[index]; + ++offenders; + } + } + EXPECT_EQ(offenders, 0) << "layer " << layer << ": " << offenders + << " components are not the uniform block's value; first was " << firstSeen + << " (an image-array binding and a uniform block did not both survive)"; + } + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteTextures(1, &texture); + glDeleteBuffers(1, &ubo); + gl.EndFrame(); + } + + // The same units, reassigned BETWEEN draws through the same pipeline. This is the half that + // the composite cache key change put weight on: the composite object now survives a + // glProgramUniform1i, so nothing rebuilds by accident and the new unit has to be carried by + // the refresh path (and, on Espryt, by regenerating the program the unit is baked into). + TEST_F(ImageLoadStoreSsoScenario, ReassigningAnImageUnitBetweenDrawsReachesTheNextDraw) { + if (!Ready()) return; + if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units"; + HeadlessGL& gl = Gl(); + + constexpr int kWidth = 8; + constexpr int kHeight = 8; + constexpr int kLayers = 2; + + static const char* kSingleImageFS = R"(#version 420 core +layout(rgba32f) uniform image2D g_image; +void main() +{ + imageStore(g_image, ivec2(gl_FragCoord), vec4(3.0)); + discard; +} +)"; + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSsoVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSingleImageFS); + if (vs == 0 || fs == 0) return; + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D_ARRAY, texture); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + const std::vector zeros(static_cast(kWidth) * kHeight * kLayers * 4, 0.0f); + glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA32F, kWidth, kHeight, kLayers, 0, GL_RGBA, GL_FLOAT, zeros.data()); + glBindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA32F); + glBindImageTexture(1, texture, 0, GL_FALSE, 1, GL_READ_WRITE, GL_RGBA32F); + ASSERT_EQ(FirstGLError(), 0u) << "image texture setup errored"; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, kWidth, kHeight); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glUseProgram(0); + glBindProgramPipeline(pipeline); + + const GLint location = glGetUniformLocation(fs, "g_image"); + ASSERT_NE(location, -1); + + // Draw one against unit 0 (layer 0)... + glProgramUniform1i(fs, location, 0); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + // ...and draw two against unit 1 (layer 1), with the composite already built and cached. + glProgramUniform1i(fs, location, 1); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); + EXPECT_EQ(FirstGLError(), 0u) << "the two pipeline draws leaked a GL error"; + + std::vector readback(static_cast(kWidth) * kHeight * kLayers * 4, -1.0f); + glBindTexture(GL_TEXTURE_2D_ARRAY, texture); + glGetTexImage(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, GL_FLOAT, readback.data()); + ASSERT_EQ(FirstGLError(), 0u) << "reading the array texture back errored"; + + for (int layer = 0; layer < kLayers; ++layer) { + int offenders = 0; + float firstSeen = 0.0f; + for (size_t i = 0; i < static_cast(kWidth) * kHeight * 4; ++i) { + const size_t index = static_cast(layer) * kHeight * kWidth * 4 + i; + if (readback[index] != 3.0f) { + if (offenders == 0) firstSeen = readback[index]; + ++offenders; + } + } + EXPECT_EQ(offenders, 0) << "layer " << layer << " was not written; " << offenders + << " components differ, first was " << firstSeen + << " (the image unit reassignment did not reach the draw)"; + } + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteTextures(1, &texture); + gl.EndFrame(); + } +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp index 5181b9d1..cb5b0c3a 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp @@ -55,6 +55,7 @@ #include #include +#include #include #include @@ -101,6 +102,39 @@ void main() { // "every single pixel" an achievable (and therefore useful) demand. constexpr int kQuadrantInset = 2; + // A deliberately asymmetric sub-rect of the 128x96 surface: neither centred nor + // full-extent in either axis, mirroring the conformance suite's randomised + // sub-viewport geometry (glcShaderRenderCase.cpp:735-741). Asymmetry is the whole + // point - y == H - y - h is exactly the case an unconverted Y origin gets right by + // accident, and it is the only case the shipped code ever exercised. + // correct band = GL rows [13, 55) + // mirrored band = GL rows [41, 83) (what H-y-h produces) + constexpr int kSubX = 17; + constexpr int kSubY = 13; + constexpr int kSubW = 60; + constexpr int kSubH = 42; + + Image CropRect(const Image& source, int x0, int y0, int width, int height) { + Image out(width, height); + const std::size_t rowBytes = static_cast(width) * 4; + for (int y = 0; y < height; ++y) { + const std::uint8_t* sourceRow = + source.Data() + (static_cast(y0 + y) * source.Width() + x0) * 4; + std::memcpy(out.Data() + static_cast(y) * rowBytes, sourceRow, rowBytes); + } + return out; + } + + Image VFlip(const Image& source) { + Image out(source.Width(), source.Height()); + const std::size_t rowBytes = static_cast(source.Width()) * 4; + for (int y = 0; y < source.Height(); ++y) { + std::memcpy(out.Data() + static_cast(y) * rowBytes, + source.Data() + static_cast(source.Height() - 1 - y) * rowBytes, rowBytes); + } + return out; + } + struct Vertex { float x, y; float r, g, b; @@ -377,5 +411,174 @@ void main() { } } + // ------------------------------------------------------------------ sub-rect / M-1 ---- + // + // Everything above reads the FULL extent of its target, which is the one case + // DirectVulkan's default-framebuffer readback ever re-oriented: the remap at + // VulkanRenderer.cpp:2042 had no rect parameters at all, so :8278 gated it on + // `width == swapchainExtent.width && height == swapchainExtent.height` and fell back to a + // raw copy otherwise. Meanwhile the viewport (:422), the scissor (:506-546) and the + // ReadPixels copy offset (:8238) all used the GL bottom-origin Y verbatim as a Vulkan + // top-origin Y. + // + // In the conformance suite those defects CANCEL in placement - the draw lands in Vulkan + // rows [y, y+h) and the readback copies the same rows back - and compose into an exact + // vertical flip of a correct image. That is 1,759 of Magma's 1,793 non-pass cases, and + // image forensics over all 861 gl33 failures found 861 vertical flips and nothing else. + // Taken apart, they are two independent user-visible bugs, so they are tested apart: + // SubViewportDraw pins placement with a full-extent read, SubRectReadback pins the + // readback rect after a full-viewport draw, and SubViewportSubRectRoundTrip is the CTS + // shape where the two cancel. + + // Placement: a sub-viewport draw must land in GL rows [y0, y0+h), not mirrored about the + // surface centre. Read back full-extent, which is the path that already worked, so a + // failure here can only be the viewport's Y origin. + TEST_F(OrientationScenario, SubViewportDrawLandsWhereGLPutsIt) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glViewport(kSubX, kSubY, kSubW, kSubH); + DrawQuadrants(); + glViewport(0, 0, Gl().Width(), Gl().Height()); + + const Image whole = ReadPixels(Gl().Width(), Gl().Height()); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + const Image placed = CropRect(whole, kSubX, kSubY, kSubW, kSubH); + EXPECT_EQ(placed.QuadrantSignature(), kUprightSignature) + << "the sub-viewport draw is not upright inside its own rect"; + ExpectUprightQuadrants(placed, "sub-viewport draw, cropped out of a full-extent read"); + + // Nothing may have been painted outside the viewport. This is what catches the + // mirrored placement: the drawn band would sit at GL rows [41, 83) instead. + EXPECT_TRUE(RegionIsMostly(whole, 0, Gl().Width() - 1, 0, kSubY - 2, "black", 0.0, + "below the sub-viewport")); + EXPECT_TRUE(RegionIsMostly(whole, 0, Gl().Width() - 1, kSubY + kSubH + 1, Gl().Height() - 1, "black", + 0.0, "above the sub-viewport")); + } + + // Readback: a full-viewport draw read back through a sub-rect must return the requested + // band, in GL row order. Band and orientation are asserted separately so that fixing only + // one of the two cannot pass this case. + TEST_F(OrientationScenario, SubRectReadbackReturnsTheRequestedBandUpright) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + DrawQuadrants(); + + const Image whole = ReadPixels(Gl().Width(), Gl().Height()); + ASSERT_EQ(whole.QuadrantSignature(), kUprightSignature) + << "the full-extent read is already wrong, so nothing below can be trusted"; + + const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + ASSERT_EQ(sub.Width(), kSubW); + ASSERT_EQ(sub.Height(), kSubH); + + const Image requestedBand = CropRect(whole, kSubX, kSubY, kSubW, kSubH); + const Image mirroredBand = CropRect(whole, kSubX, Gl().Height() - kSubY - kSubH, kSubW, kSubH); + + // The geometry has to be able to see both mistakes; if a future surface size made the + // band symmetric these assertions would be vacuous, so say so loudly instead. + ASSERT_FALSE(requestedBand == VFlip(requestedBand)) + << "the chosen sub-rect is vertically symmetric - it cannot detect a row flip"; + ASSERT_FALSE(requestedBand == mirroredBand) + << "the chosen sub-rect equals its mirror band - it cannot detect a wrong band"; + + EXPECT_FALSE(sub == VFlip(requestedBand)) + << "ORIENTATION: the requested band came back with its rows in Vulkan (top-first) order"; + EXPECT_FALSE(sub == mirroredBand || sub == VFlip(mirroredBand)) + << "BAND: the read returned GL rows [H-y-h, H-y) instead of [y, y+h)"; + EXPECT_TRUE(sub == requestedBand) + << "the sub-rect readback differs from the same rect of the full-extent read in " + << sub.ByteDiffCount(requestedBand) << " bytes"; + } + + // The exact conformance-suite shape: an asymmetric sub-viewport draw read back through the + // very same sub-rect. The placement and readback errors cancel, leaving an image that is + // correct in every pixel VALUE and vertically flipped - which is precisely the 861-case + // signature. One assertion, and it pins all of them. + TEST_F(OrientationScenario, SubViewportSubRectRoundTripIsUpright) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glViewport(kSubX, kSubY, kSubW, kSubH); + DrawQuadrants(); + const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH); + glViewport(0, 0, Gl().Width(), Gl().Height()); + + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(sub.QuadrantSignature(), kUprightSignature) + << "sub-viewport draw + same-rect readback came back flipped - this is the shape " + "behind KHR-GL33/GL40.shaders.* (861 cases each)"; + ExpectUprightQuadrants(sub, "sub-viewport draw read back through the same sub-rect"); + } + + // The same conversion, on the other rect consumer that reads the default framebuffer. + // glBlitFramebuffer already converted its DESTINATION rect when the draw framebuffer was + // the default one (ApplyNativeBlitDefaultFramebufferTransform), but never its SOURCE rect, + // so a blit OUT of the default framebuffer took the mirrored band and wrote it upside + // down. Blitting a sub-rect and comparing against the same sub-rect of a direct read pins + // both halves at once. + TEST_F(OrientationScenario, BlitOutOfTheDefaultFramebufferKeepsBandAndOrientation) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + DrawQuadrants(); + const Image whole = ReadPixels(Gl().Width(), Gl().Height()); + ASSERT_EQ(whole.QuadrantSignature(), kUprightSignature) + << "the full-extent read is already wrong, so nothing below can be trusted"; + + BindFbo(m_offscreen); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_offscreen.fbo); + glBlitFramebuffer(kSubX, kSubY, kSubX + kSubW, kSubY + kSubH, kSubX, kSubY, kSubX + kSubW, + kSubY + kSubH, GL_COLOR_BUFFER_BIT, GL_NEAREST); + const unsigned int blitError = FirstGLError(); + if (blitError != GL_NO_ERROR) { + GTEST_SKIP() << "this backend refused the default-framebuffer blit: " + << GLErrorName(blitError); + } + + glBindFramebuffer(GL_FRAMEBUFFER, m_offscreen.fbo); + const Image blitted = ReadPixels(m_offscreen.width, m_offscreen.height); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + const Image landed = CropRect(blitted, kSubX, kSubY, kSubW, kSubH); + const Image expected = CropRect(whole, kSubX, kSubY, kSubW, kSubH); + EXPECT_FALSE(landed == VFlip(expected)) + << "ORIENTATION: the blitted band arrived upside down"; + EXPECT_TRUE(landed == expected) + << "the blitted sub-rect differs from the same sub-rect of a direct read in " + << landed.ByteDiffCount(expected) << " bytes"; + } + + // Negative control. A non-default framebuffer is already self-consistent - no + // gl_Position.y negation, GL row 0 IS Vulkan row 0 - so none of the fixes above may touch + // it. If this ever starts failing, the default-FBO remap has leaked into the FBO path. + TEST_F(OrientationScenario, FboSubRectReadbackAndSubViewportAreUnaffected) { + BindFbo(m_offscreen); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + DrawQuadrants(); + + const Image whole = ReadPixels(m_offscreen.width, m_offscreen.height); + const Image sub = ReadPixelsRect(kSubX, kSubY, kSubW, kSubH); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_TRUE(sub == CropRect(whole, kSubX, kSubY, kSubW, kSubH)) + << "an FBO sub-rect readback differs from the same rect of its full-extent read in " + << sub.ByteDiffCount(CropRect(whole, kSubX, kSubY, kSubW, kSubH)) << " bytes"; + + BindFbo(m_offscreen); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glViewport(kSubX, kSubY, kSubW, kSubH); + DrawQuadrants(); + glViewport(0, 0, m_offscreen.width, m_offscreen.height); + const Image placedWhole = ReadPixels(m_offscreen.width, m_offscreen.height); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(CropRect(placedWhole, kSubX, kSubY, kSubW, kSubH).QuadrantSignature(), kUprightSignature) + << "an FBO sub-viewport draw must land in GL rows [y0, y0+h) upright"; + EXPECT_TRUE(RegionIsMostly(placedWhole, 0, m_offscreen.width - 1, 0, kSubY - 2, "black", 0.0, + "below an FBO sub-viewport")); + EXPECT_TRUE(RegionIsMostly(placedWhole, 0, m_offscreen.width - 1, kSubY + kSubH + 1, + m_offscreen.height - 1, "black", 0.0, "above an FBO sub-viewport")); + } + } // namespace } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PipelineFailureScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PipelineFailureScenario.cpp new file mode 100644 index 00000000..ae58b7ad --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PipelineFailureScenario.cpp @@ -0,0 +1,197 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PipelineFailureScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// "The draw had no pipeline, so we bound null." +// +// DirectVulkan's SetupDraw called GetOrCreatePipeline - a function that DOCUMENTS a +// VK_NULL_HANDLE return - and passed the result straight to vkCmdBindPipeline. When the +// Adreno driver answered vkCreateGraphicsPipelines with VK_ERROR_UNKNOWN, the next +// instruction dereferenced null inside the driver: SIGSEGV at fault addr 0x8, and that one +// shape accounted for 9 of the 15 process deaths in the 2026-08-10 GL-CTS run +// (KHR-GL33/GL40.shaders.struct.uniform.sampler_array_vertex, six +// KHR-GL42.shader_image_load_store cases, one shader_storage_buffer_object case). +// +// It was made permanent by a second defect: PipelineFactory memoized the failure, so the +// null was served for the rest of the process. Every later draw with the same state died +// too, which is why a single bad program took whole CTS groups down with it. +// +// What this scenario pins, on both backends: +// 1. The GL program shape the CTS crashed on (an array of structs each containing a +// sampler, sampled from the VERTEX stage) draws without killing the process. +// 2. It draws AGAIN and produces the identical image. A second draw is the only thing +// that can tell a working pipeline apart from a poisoned cache entry: if the first +// creation had failed and been memoized, the second draw is where the null would be +// served back. +// +// A deterministic driver-side pipeline-creation FAILURE is not reachable from the GL API on +// the llvmpipe/lavapipe lanes - both accept every pipeline these scenarios can describe - so +// the guard itself is proven structurally (PipelineFactory returns before it can emplace a +// VK_NULL_HANDLE, SetupDraw returns false before it can bind one) and this scenario holds +// the surrounding path honest. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Lifted from KHR-GL33.shaders.struct.uniform.sampler_array_vertex (the QPA records the + // source verbatim): an array of structs, each carrying an opaque sampler, sampled in the + // vertex stage. The fragment sibling of this case only FAILS on Magma; only the vertex one + // takes the process down, so the stage matters and is kept. + constexpr const char* kSamplerArrayVertexSource = R"(#version 330 core +struct S { + float a; + vec3 b; + sampler2D c; +}; +uniform S s[2]; +in vec2 aPos; +out vec4 vColor; +void main() { + vec2 coords = aPos * 0.5 + 0.5; + vColor = vec4(texture(s[1].c, coords * s[0].b.xy + s[1].b.z).rgb, s[0].a); + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kPassthroughFragmentSource = R"(#version 330 core +in vec4 vColor; +out vec4 oColor; +void main() { + oColor = vColor; +} +)"; + + struct Vertex { + float x, y; + }; + + std::vector FullscreenTriangleStrip() { + return {{-1.0f, -1.0f}, {1.0f, -1.0f}, {-1.0f, 1.0f}, {1.0f, 1.0f}}; + } + + class PipelineFailureScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + + std::string error; + m_program = CompileProgram(kSamplerArrayVertexSource, kPassthroughFragmentSource, &error); + ASSERT_NE(m_program, 0u) << error; + + const std::vector vertices = FullscreenTriangleStrip(); + m_vertexCount = static_cast(vertices.size()); + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(), + GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast(0)); + glBindVertexArray(0); + + // A solid red 2x2 texture, so the sampled colour is the same wherever the + // (deliberately degenerate) coordinates land. + const unsigned char red[] = {255, 0, 0, 255, 255, 0, 0, 255, + 255, 0, 0, 255, 255, 0, 0, 255}; + glGenTextures(1, &m_texture); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, m_texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, red); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + + glUseProgram(m_program); + const int samplerLocation = glGetUniformLocation(m_program, "s[1].c"); + if (samplerLocation >= 0) glUniform1i(samplerLocation, 0); + const int alphaLocation = glGetUniformLocation(m_program, "s[0].a"); + if (alphaLocation >= 0) glUniform1f(alphaLocation, 1.0f); + glUseProgram(0); + + m_target = MakeColorFbo(Gl().Width(), Gl().Height()); + ASSERT_NE(m_target.fbo, 0u) << "offscreen FBO is not framebuffer-complete"; + + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "setup left a GL error behind"; + } + + void TearDown() override { + if (!Ready()) return; + DestroyColorFbo(m_target); + if (m_texture != 0) glDeleteTextures(1, &m_texture); + if (m_vbo != 0) glDeleteBuffers(1, &m_vbo); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + } + + Image DrawOnce() { + BindFbo(m_target); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + glUseProgram(m_program); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, m_texture); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, m_vertexCount); + glBindVertexArray(0); + return ReadPixels(m_target.width, m_target.height); + } + + unsigned int m_program = 0; + unsigned int m_vao = 0; + unsigned int m_vbo = 0; + unsigned int m_texture = 0; + int m_vertexCount = 0; + ColorFbo m_target; + }; + + // Reaching the assertion at all is most of the point: the shipped code SIGSEGV'd inside + // the driver on this draw. + TEST_F(PipelineFailureScenario, SamplerArrayInAStructDrawsWithoutKillingTheProcess) { + const Image drawn = DrawOnce(); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_TRUE(RegionIsMostly(drawn, 2, drawn.Width() - 3, 2, drawn.Height() - 3, "red", 0.0, + "sampler-array-in-struct draw")); + } + + // The second draw is what a poisoned cache entry cannot survive: a memoized + // VK_NULL_HANDLE is served on every subsequent lookup, so a run that dies (or silently + // stops drawing) on the second draw and not the first is exactly the "failed pipeline was + // cached" defect. + TEST_F(PipelineFailureScenario, TheSameDrawRepeatsIdenticallyWithNoPoisonedPipelineCache) { + const Image first = DrawOnce(); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the first draw already errored"; + Gl().EndFrame(); + const Image second = DrawOnce(); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the second draw errored"; + + EXPECT_TRUE(RegionIsMostly(second, 2, second.Width() - 3, 2, second.Height() - 3, "red", 0.0, + "second draw")); + EXPECT_TRUE(second == first) << "the second draw differs from the first in " + << second.ByteDiffCount(first) << " bytes - the pipeline the second " + "draw resolved is not the one the first draw used"; + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.cpp new file mode 100644 index 00000000..efc850fa --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.cpp @@ -0,0 +1,249 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PixelStoreSweepScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - PIXEL-STORE MODES RESTORE, and FRAMEBUFFER CHURN STAYS EXACT. +// +// Both cases here replay the shape of KHR-GL3x.packed_pixels.varied_rectangle, the single +// heaviest polluter in the GL CTS: for each of 46 (pixel-store mode, value) pairs it uploads a +// gradient into a fresh texture, attaches that texture to a FRESH framebuffer, reads it back and +// deletes both - ~3300 texture+framebuffer pairs per test case. +// +// What that found: DirectGLES had no destructor for BackendFramebufferObject (nor for the +// renderbuffer and sampler twins), so every frontend glDeleteFramebuffers leaked one driver +// framebuffer for the process lifetime. On an Adreno 830 the CTS run walked the driver to 1.2 GB +// of dead objects, and from that point on EVERY readback through a freshly attached framebuffer +// came back with someone else's pixels - which is what made ~1,500 otherwise-correct cases fail +// depending only on how much ran before them. The unit-level pin for the missing destructors is +// MG_Test/SanityTest.cpp (DirectGLESBackendFramebuffer/Renderbuffer/Sampler); this file pins the +// end-to-end behaviour they protect. +// +// The mode sweep is the second half of the same story: 46 modes are set and reset per case, so a +// mode that fails to restore is indistinguishable from the leak in a full-batch CTS run. The +// assertion here is RESTORATION - after every single mode is set and put back, a readback at +// default state must be byte-identical to one taken before the sweep ever started. +// +// Backend-agnostic on purpose: both bugs this guards against are frontend/backend bookkeeping, +// and DirectVulkan is the built-in control. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Small enough that the table's row lengths (10, 15) and image heights are all >= the + // image, which is the shape the CTS uses (its gradient is 7x3). + constexpr int kTexSize = 8; + // Every buffer handed to GL is this big regardless of the image size: with row length 15, + // two skipped rows/pixels and alignment 8 the driver strides well past the natural image + // extent, and a tight buffer would be an out-of-bounds access rather than a test. (It was: + // the first version of this scenario passed its assertions and then segfaulted at + // teardown, because glReadPixels had written past a 1 KiB destination.) + constexpr std::size_t kScratchBytes = 64 * 1024; + + // Every pixel-store mode GL 4.0 has, so a reset provably covers the whole state and not + // just the subset a particular test happened to touch. + struct PixelStoreMode { + GLenum name; + GLint defaultValue; + }; + const PixelStoreMode kAllModes[] = { + {GL_UNPACK_SWAP_BYTES, 0}, {GL_UNPACK_LSB_FIRST, 0}, {GL_UNPACK_ROW_LENGTH, 0}, + {GL_UNPACK_IMAGE_HEIGHT, 0}, {GL_UNPACK_SKIP_ROWS, 0}, {GL_UNPACK_SKIP_PIXELS, 0}, + {GL_UNPACK_SKIP_IMAGES, 0}, {GL_UNPACK_ALIGNMENT, 4}, {GL_PACK_SWAP_BYTES, 0}, + {GL_PACK_LSB_FIRST, 0}, {GL_PACK_ROW_LENGTH, 0}, {GL_PACK_IMAGE_HEIGHT, 0}, + {GL_PACK_SKIP_ROWS, 0}, {GL_PACK_SKIP_PIXELS, 0}, {GL_PACK_SKIP_IMAGES, 0}, + {GL_PACK_ALIGNMENT, 4}, + }; + + // The CTS table verbatim (glcPackedPixelsTests.cpp VariedRectangleTest::iterate): 32 + // common cases plus the 14 core-only ones ES has no equivalent for and MobileGL therefore + // honours on the CPU. IMAGE_WIDTH_1/2 and IMAGE_HEIGHT_1/2 are the CTS's 10 and 15. + struct SweepCase { + GLenum mode; + GLint value; + }; + const SweepCase kSweep[] = { + {GL_UNPACK_ROW_LENGTH, 0}, {GL_UNPACK_ROW_LENGTH, 10}, {GL_UNPACK_ROW_LENGTH, 15}, + {GL_UNPACK_SKIP_ROWS, 0}, {GL_UNPACK_SKIP_ROWS, 1}, {GL_UNPACK_SKIP_ROWS, 2}, + {GL_UNPACK_SKIP_PIXELS, 0}, {GL_UNPACK_SKIP_PIXELS, 1}, {GL_UNPACK_SKIP_PIXELS, 2}, + {GL_UNPACK_ALIGNMENT, 1}, {GL_UNPACK_ALIGNMENT, 2}, {GL_UNPACK_ALIGNMENT, 4}, + {GL_UNPACK_ALIGNMENT, 8}, {GL_UNPACK_IMAGE_HEIGHT, 0}, {GL_UNPACK_IMAGE_HEIGHT, 10}, + {GL_UNPACK_IMAGE_HEIGHT, 15}, {GL_UNPACK_SKIP_IMAGES, 0}, {GL_UNPACK_SKIP_IMAGES, 1}, + {GL_UNPACK_SKIP_IMAGES, 2}, {GL_PACK_ROW_LENGTH, 0}, {GL_PACK_ROW_LENGTH, 10}, + {GL_PACK_ROW_LENGTH, 15}, {GL_PACK_SKIP_ROWS, 0}, {GL_PACK_SKIP_ROWS, 1}, + {GL_PACK_SKIP_ROWS, 2}, {GL_PACK_SKIP_PIXELS, 0}, {GL_PACK_SKIP_PIXELS, 1}, + {GL_PACK_SKIP_PIXELS, 2}, {GL_PACK_ALIGNMENT, 1}, {GL_PACK_ALIGNMENT, 2}, + {GL_PACK_ALIGNMENT, 4}, {GL_PACK_ALIGNMENT, 8}, + // core-only, no ES equivalent + {GL_UNPACK_SWAP_BYTES, GL_FALSE}, {GL_UNPACK_SWAP_BYTES, GL_TRUE}, + {GL_UNPACK_LSB_FIRST, GL_FALSE}, {GL_UNPACK_LSB_FIRST, GL_TRUE}, + {GL_PACK_SWAP_BYTES, GL_FALSE}, {GL_PACK_SWAP_BYTES, GL_TRUE}, + {GL_PACK_LSB_FIRST, GL_FALSE}, {GL_PACK_LSB_FIRST, GL_TRUE}, + {GL_PACK_IMAGE_HEIGHT, 0}, {GL_PACK_IMAGE_HEIGHT, 10}, + {GL_PACK_IMAGE_HEIGHT, 15}, {GL_PACK_SKIP_IMAGES, 0}, + {GL_PACK_SKIP_IMAGES, 1}, {GL_PACK_SKIP_IMAGES, 2}, + }; + + std::size_t ImageBytes(int size) { return static_cast(size) * size * 4; } + + // Padded to kScratchBytes so it is safe to hand to an upload running under any of the + // sweep's stride/skip settings. + std::vector MakeGradient(int size, unsigned seed) { + std::vector pixels(kScratchBytes, 0); + for (int y = 0; y < size; ++y) { + for (int x = 0; x < size; ++x) { + const std::size_t base = (static_cast(y) * size + x) * 4; + pixels[base + 0] = static_cast((x * 11 + seed) & 0xFF); + pixels[base + 1] = static_cast((y * 13 + seed) & 0xFF); + pixels[base + 2] = static_cast((x * y + seed) & 0xFF); + pixels[base + 3] = 0xFF; + } + } + return pixels; + } + + void ResetAllPixelStoreModes() { + for (const PixelStoreMode& mode : kAllModes) { + glPixelStorei(mode.name, mode.defaultValue); + } + } + + // The one operation the CTS repeats: a fresh texture, a fresh framebuffer, one readback, + // both deleted. Returns the readback; `outStatus` carries the completeness answer so a + // caller can tell an incomplete framebuffer apart from wrong pixels. + std::vector UploadAndReadBack(const std::vector& source, int size, + GLenum* outStatus) { + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, size, size, 0, GL_RGBA, GL_UNSIGNED_BYTE, source.data()); + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + *outStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER); + + std::vector read(kScratchBytes, 0); + if (*outStatus == GL_FRAMEBUFFER_COMPLETE) { + glReadPixels(0, 0, size, size, GL_RGBA, GL_UNSIGNED_BYTE, read.data()); + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glBindTexture(GL_TEXTURE_2D, 0); + glDeleteTextures(1, &texture); + return read; + } + + // Index of the first differing byte within the image, or `bytes` when they agree. + std::size_t FirstDifference(const std::vector& a, const std::vector& b, + std::size_t bytes) { + for (std::size_t i = 0; i < bytes; ++i) { + if (a[i] != b[i]) return i; + } + return bytes; + } + + class PixelStoreSweepScenario : public ScenarioTest {}; + class FramebufferChurnScenario : public ScenarioTest {}; + + } // namespace + + // Every mode in the CTS table is set, exercised and put back; the readback at default state + // afterwards must be bit-identical to the one taken before the sweep. A mode that silently + // fails to restore corrupts every later case in the batch, which is exactly how the CTS + // failures presented (the FIRST sub-case, at default state, is what failed). + TEST_F(PixelStoreSweepScenario, DefaultStateSurvivesTheFullModeSweep) { + if (!Ready()) return; + + ResetAllPixelStoreModes(); + ASSERT_EQ(FirstGLError(), 0u) << "resetting the pixel-store modes must be legal on a GL 4.0 context"; + + const std::vector gradient = MakeGradient(kTexSize, 0); + GLenum status = 0; + const std::vector baseline = UploadAndReadBack(gradient, kTexSize, &status); + ASSERT_EQ(status, static_cast(GL_FRAMEBUFFER_COMPLETE)); + ASSERT_EQ(FirstGLError(), 0u); + + const std::vector scratchSource(kScratchBytes, 0x5A); + + for (const SweepCase& sweep : kSweep) { + glPixelStorei(sweep.mode, sweep.value); + ASSERT_EQ(FirstGLError(), 0u) << "glPixelStorei(0x" << std::hex << sweep.mode << std::dec << ", " + << sweep.value << ") must be accepted"; + + // Exercise the mode: an upload and a readback that both run with it in force. + GLenum sweepStatus = 0; + (void)UploadAndReadBack(scratchSource, kTexSize, &sweepStatus); + + ResetAllPixelStoreModes(); + + GLenum afterStatus = 0; + const std::vector after = UploadAndReadBack(gradient, kTexSize, &afterStatus); + ASSERT_EQ(afterStatus, static_cast(GL_FRAMEBUFFER_COMPLETE)); + const std::size_t diff = FirstDifference(baseline, after, ImageBytes(kTexSize)); + ASSERT_EQ(diff, ImageBytes(kTexSize)) + << "default-state readback changed after setting and resetting 0x" << std::hex << sweep.mode + << std::dec << " = " << sweep.value << "; first differing byte " << diff << " (baseline " + << static_cast(baseline[diff]) << ", now " << static_cast(after[diff]) << ")"; + } + + // And the modes themselves must read back as the defaults the reset asked for. + for (const PixelStoreMode& mode : kAllModes) { + GLint value = -1; + glGetIntegerv(mode.name, &value); + EXPECT_EQ(value, mode.defaultValue) + << "pixel-store mode 0x" << std::hex << mode.name << std::dec << " did not return to its default"; + } + EXPECT_EQ(FirstGLError(), 0u); + } + + // The leak regression. Each iteration is one complete CTS inner step, and every readback has + // to be exactly the gradient THIS iteration uploaded - never the previous one's. Before the + // missing destructors were added, the driver-side framebuffer count grew without bound here. + TEST_F(FramebufferChurnScenario, RepeatedFramebufferReadbackStaysExact) { + if (!Ready()) return; + + ResetAllPixelStoreModes(); + constexpr int kSize = 8; + constexpr int kIterations = 1024; + + for (int i = 0; i < kIterations; ++i) { + // A distinct gradient per iteration: a stale attachment or a recycled driver name + // reads back the PREVIOUS iteration's image, which a constant fill could not tell + // apart from a correct read. + const std::vector gradient = MakeGradient(kSize, static_cast(i * 7 + 1)); + GLenum status = 0; + const std::vector read = UploadAndReadBack(gradient, kSize, &status); + ASSERT_EQ(status, static_cast(GL_FRAMEBUFFER_COMPLETE)) << "iteration " << i; + const std::size_t diff = FirstDifference(gradient, read, ImageBytes(kSize)); + ASSERT_EQ(diff, ImageBytes(kSize)) + << "iteration " << i << " read back a different image than it uploaded; first differing byte " + << diff << " (uploaded " << static_cast(gradient[diff]) << ", read " + << static_cast(read[diff]) << ")"; + ASSERT_EQ(FirstGLError(), 0u) << "iteration " << i; + } + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.cpp new file mode 100644 index 00000000..c764730b --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.cpp @@ -0,0 +1,887 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - SEPARABLE PROGRAMS DRAWN THROUGH A PROGRAM PIPELINE OBJECT. +// +// A pipeline object holds one program per stage and stands in for glUseProgram; MobileGL +// flattens it into a single composite program at draw time (MG_State/GLState/Core.cpp, +// GetProgramForDraw). Sixteen conformance cases across three different families depend on that +// flattening and fail identically on BOTH backends - so the defect is in the shared frontend, not +// in either backend's draw path: +// +// compute_shader.{build-monolithic, build-separable, sso-case2, sso-case3, sso-compute-pipeline} +// shader_image_load_store.advanced-sso-{atomicCounters, simple, subroutine} +// shader_storage_buffer_object.{basic-syntaxSSO, basic-noBindingLayout} +// +// They fail with two symptoms at once - the draw renders nothing, AND the case leaves a +// GL_INVALID_OPERATION behind that the harness reports as "forcing FAIL for subcase". Anything +// claiming to be the root cause has to explain both. +// +// The cases here are the conformance shapes reduced to what fails in milliseconds, ordered from +// the simplest pipeline that can render at all up to the compute-then-draw shape of +// sso-compute-pipeline. Each one also asserts glGetError is clean at the end, because a case that +// paints correctly and leaks an error still fails conformance. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Separable stage sources. A separable VS must redeclare gl_PerVertex, which is exactly + // the kind of thing a flattening step can drop on the floor. + constexpr const char* kSeparableVS = R"(#version 430 core +out gl_PerVertex { vec4 gl_Position; }; +void main() +{ + switch (gl_VertexID) + { + case 0: gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); break; + case 1: gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); break; + case 2: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break; + case 3: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break; + } +} +)"; + + constexpr const char* kSeparableFS = R"(#version 430 core +out vec4 o_color; +void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + // The sso-compute-pipeline shape: a compute stage writes the vertex positions the vertex + // stage then reads as an attribute, all from one pipeline object. + constexpr const char* kComputeSource = R"(#version 430 core +layout(local_size_x = 1) in; +layout(std430, binding = 0) buffer Positions { + vec4 g_position[4]; +}; +void main() +{ + g_position[0] = vec4(-1.0, -1.0, 0.0, 1.0); + g_position[1] = vec4( 1.0, -1.0, 0.0, 1.0); + g_position[2] = vec4(-1.0, 1.0, 0.0, 1.0); + g_position[3] = vec4( 1.0, 1.0, 0.0, 1.0); +} +)"; + + constexpr const char* kAttributeVS = R"(#version 430 core +layout(location = 0) in vec4 i_position; +out gl_PerVertex { vec4 gl_Position; }; +void main() { gl_Position = i_position; } +)"; + + // Two shader storage blocks with NO layout(binding) qualifier, so the only thing that + // can say where they live is glShaderStorageBlockBinding - which is per-PROGRAM state. + constexpr const char* kStorageBlockVS = R"(#version 430 core +out gl_PerVertex { vec4 gl_Position; }; +layout(std430) buffer Output0 { uint value0; }; +layout(std430) buffer Output1 { uint value1; }; +void main() +{ + value0 = 11u; + value1 = 22u; + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); +} +)"; + + class ProgramPipelineScenario : public ScenarioTest { + protected: + void TearDown() override { + if (!Ready()) return; + glBindProgramPipeline(0); + glUseProgram(0); + for (GLuint p : m_programs) glDeleteProgram(p); + for (GLuint p : m_pipelines) glDeleteProgramPipelines(1, &p); + m_programs.clear(); + m_pipelines.clear(); + } + + GLuint MakeSeparable(GLenum stage, const char* source) { + const GLuint program = glCreateShaderProgramv(stage, 1, &source); + if (program != 0) m_programs.push_back(program); + // Checked here rather than only at the end of the case: glCreateShaderProgramv is + // specified as a sequence of other entry points, so it is the most likely place + // for one of them to leave an error nobody consumes. + EXPECT_EQ(FirstGLError(), 0u) + << "glCreateShaderProgramv(stage 0x" << std::hex << stage << std::dec << ") left a GL error"; + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + ADD_FAILURE() << "glCreateShaderProgramv(stage 0x" << std::hex << stage << std::dec + << ") did not link: " << log; + return 0; + } + return program; + } + + GLuint MakePipeline() { + GLuint pipeline = 0; + glGenProgramPipelines(1, &pipeline); + m_pipelines.push_back(pipeline); + return pipeline; + } + + std::vector m_programs; + std::vector m_pipelines; + }; + + } // namespace + + // The root cause of the cluster, stated as the two halves it actually has. + // + // Half one: glGenProgramPipelines only reserves a name, and every pipeline command used to + // demand a materialized object - so the spec's own call order (stages attached BEFORE the + // first bind, GL 4.6 core 7.4) was rejected with GL_INVALID_OPERATION and the stages were + // never recorded. Half two is the trap that fix walks into: the object now appears the + // moment anything needs somewhere to put state, so "the object exists" stops being the + // right answer for glIsProgramPipeline, which the spec ties to the first BIND. A pure + // query must not turn a reserved name into a program pipeline either. + TEST_F(ProgramPipelineScenario, AReservedNameTakesStateBeforeItIsAProgramPipeline) { + if (!Ready()) return; + + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS); + if (vs == 0) return; + const GLuint pipeline = MakePipeline(); + ASSERT_NE(pipeline, 0u); + EXPECT_EQ(glIsProgramPipeline(pipeline), GL_FALSE) << "a merely reserved name is not a pipeline yet"; + + // A query answers out of default state - and leaves the name exactly as it found it. + GLint validateStatus = -1; + glGetProgramPipelineiv(pipeline, GL_VALIDATE_STATUS, &validateStatus); + EXPECT_EQ(FirstGLError(), 0u) << "querying a reserved pipeline name must not be an error"; + EXPECT_EQ(validateStatus, 0) << "a pipeline that was never validated reports VALIDATE_STATUS 0"; + EXPECT_EQ(glIsProgramPipeline(pipeline), GL_FALSE) << "a pure query must not create the object"; + + // ...and glUseProgramStages RECORDS the stage on the reserved name rather than + // rejecting it, which is the whole defect: without this the pipeline stayed empty. + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + EXPECT_EQ(FirstGLError(), 0u) << "glUseProgramStages before the first bind must be accepted"; + GLint stageProgram = 0; + glGetProgramPipelineiv(pipeline, GL_VERTEX_SHADER, &stageProgram); + EXPECT_EQ(static_cast(stageProgram), vs) << "the stage program was not recorded"; + EXPECT_EQ(glIsProgramPipeline(pipeline), GL_FALSE) << "taking state is still not being bound"; + + // The bind is what the spec ties glIsProgramPipeline to. + glBindProgramPipeline(pipeline); + EXPECT_EQ(glIsProgramPipeline(pipeline), GL_TRUE); + EXPECT_EQ(FirstGLError(), 0u); + glBindProgramPipeline(0); + } + + // The floor: a two-stage pipeline must paint. If this fails, nothing above it can pass, and + // the eight shared conformance cases have exactly one cause. + TEST_F(ProgramPipelineScenario, ATwoStagePipelinePaintsWhatItsStagesDescribe) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSeparableFS); + if (vs == 0 || fs == 0) return; + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + ASSERT_EQ(FirstGLError(), 0u) << "pipeline setup left a GL error behind"; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + // No glUseProgram anywhere: the pipeline IS the program state for this draw. + glUseProgram(0); + glBindProgramPipeline(pipeline); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + const Image painted = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0, + "a two-stage program pipeline drawing a full-viewport strip")); + // The conformance harness fails a subcase on a leaked error even when the pixels are + // right, so this assertion is not redundant with the one above. + EXPECT_EQ(FirstGLError(), 0u) << "the pipeline draw leaked a GL error"; + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + gl.EndFrame(); + } + + // glActiveShaderProgram picks which stage program glUniform* addresses - and the draw has to + // see what was written there. + // + // The second defect of the cluster, and the one the pixels expose most directly: uniform + // values live on the stage program (GetProgramForUniform returns the pipeline's active + // program) while the draw reads the composite GetProgramForDraw builds out of the stage + // programs' shaders. Two objects, two sets of uniform storage; before the composite was + // refreshed from its stage programs this painted u_color's zero default instead of green. + TEST_F(ProgramPipelineScenario, UniformsGoToTheActiveShaderProgram) { + if (!Ready()) return; + + static const char* kUniformFS = R"(#version 430 core +uniform vec4 u_color; +out vec4 o_color; +void main() { o_color = u_color; } +)"; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kUniformFS); + if (vs == 0 || fs == 0) return; + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + glBindProgramPipeline(pipeline); + glActiveShaderProgram(pipeline, fs); + ASSERT_EQ(FirstGLError(), 0u) << "glActiveShaderProgram left a GL error behind"; + + const GLint location = glGetUniformLocation(fs, "u_color"); + ASSERT_NE(location, -1); + glUniform4f(location, 0.0f, 1.0f, 0.0f, 1.0f); + EXPECT_EQ(FirstGLError(), 0u) << "glUniform4f through the active shader program errored"; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + const Image painted = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0, + "a pipeline whose fragment uniform was set via glActiveShaderProgram")); + EXPECT_EQ(FirstGLError(), 0u) << "the pipeline draw leaked a GL error"; + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + gl.EndFrame(); + } + + // The sso-compute-pipeline shape: compute and non-compute stages on ONE pipeline object, the + // compute stage writing the buffer the vertex stage then reads. + // + // The third defect of the cluster: the flattening used to pull EVERY stage into one + // composite, so a single program was asked to serve both glDispatchCompute and glDrawArrays. + // GL keeps them apart - a pipeline's compute stage is a whole program dispatched on its own + // and never participates in a draw - which is why the accessors are split (GetProgramForDraw + // composites the graphics stages, GetProgramForDispatch hands back the compute stage + // program). It is also the shape that killed the process on Adreno: the composite carried a + // compute module into vkCreateGraphicsPipelines, and that driver SIGSEGVs rather than + // returning an error. + TEST_F(ProgramPipelineScenario, ComputeAndGraphicsStagesShareOnePipeline) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + GLint storageBlocks = 0; + glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &storageBlocks); + if (storageBlocks < 1) { + GTEST_SKIP() << "no compute shader storage blocks available"; + } + + const GLuint cs = MakeSeparable(GL_COMPUTE_SHADER, kComputeSource); + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kAttributeVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSeparableFS); + if (cs == 0 || vs == 0 || fs == 0) return; + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + glUseProgramStages(pipeline, GL_COMPUTE_SHADER_BIT, cs); + ASSERT_EQ(FirstGLError(), 0u) << "attaching compute and graphics stages to one pipeline errored"; + + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + glBufferData(GL_SHADER_STORAGE_BUFFER, 4 * 4 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr); + glEnableVertexAttribArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindVertexArray(0); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_DEPTH_TEST); + glUseProgram(0); + glBindProgramPipeline(pipeline); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer); + glDispatchCompute(1, 1, 1); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glBindVertexArray(vao); + glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + const Image painted = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0, + "a pipeline whose compute stage wrote the vertex positions")); + EXPECT_EQ(FirstGLError(), 0u) << "the compute-then-draw pipeline leaked a GL error"; + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(1, &buffer); + gl.EndFrame(); + } + + // Interface-resource bindings are per-PROGRAM state, and the program a pipeline draw executes + // is the composite - not the stage program the application set them on. + // + // This is shader_storage_buffer_object.basic-noBindingLayout reduced: blocks declared without + // a layout(binding) qualifier, placed onto binding points purely by + // glShaderStorageBlockBinding against the stage program. The stage program records the + // rebinding (ProgramObject::SetShaderStorageBlockBinding, keyed by block name) and the + // composite is built from the stage program's SHADERS - which carry the declared bindings and + // know nothing of the rebinding. So the draw writes wherever the shader source said, the + // bound buffer ranges never see a byte, and no GL error is raised anywhere: the readback is + // the only thing that notices. + TEST_F(ProgramPipelineScenario, AStageProgramsStorageBlockBindingReachesThePipelineDraw) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + + GLint vertexStorageBlocks = 0; + glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &vertexStorageBlocks); + if (vertexStorageBlocks < 2) { + GTEST_SKIP() << "fewer than two vertex shader storage blocks available"; + } + + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kStorageBlockVS); + if (vs == 0) return; + + // Rebound to binding points the shader source never mentions, so nothing but the + // rebinding can put the writes where this case looks for them. + constexpr GLuint kBinding0 = 1; + constexpr GLuint kBinding1 = 5; + const GLuint block0 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output0"); + const GLuint block1 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output1"); + ASSERT_NE(block0, GL_INVALID_INDEX); + ASSERT_NE(block1, GL_INVALID_INDEX); + glShaderStorageBlockBinding(vs, block0, kBinding0); + glShaderStorageBlockBinding(vs, block1, kBinding1); + ASSERT_EQ(FirstGLError(), 0u) << "glShaderStorageBlockBinding on a separable program errored"; + + GLint offsetAlignment = 256; + glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &offsetAlignment); + if (offsetAlignment <= 0) offsetAlignment = 256; + const GLsizeiptr secondOffset = offsetAlignment; + + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + const std::vector zeros(static_cast(secondOffset) / sizeof(GLuint) + 4, 0u); + glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast(zeros.size() * sizeof(GLuint)), zeros.data(), + GL_DYNAMIC_DRAW); + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding0, buffer, 0, sizeof(GLuint)); + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding1, buffer, secondOffset, sizeof(GLuint)); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + // The whole point is the buffer writes, so the rasterizer is not involved - which is + // also what keeps a vertex-only pipeline (no fragment stage) legal here. + glEnable(GL_RASTERIZER_DISCARD); + glUseProgram(0); + glBindProgramPipeline(pipeline); + glDrawArrays(GL_POINTS, 0, 1); + glDisable(GL_RASTERIZER_DISCARD); + EXPECT_EQ(FirstGLError(), 0u) << "the storage-block pipeline draw leaked a GL error"; + + glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + GLuint readback0 = 0; + GLuint readback1 = 0; + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(readback0), &readback0); + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, secondOffset, sizeof(readback1), &readback1); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + + EXPECT_EQ(readback0, 11u) << "Output0 did not reach the binding glShaderStorageBlockBinding gave it"; + EXPECT_EQ(readback1, 22u) << "Output1 did not reach the binding glShaderStorageBlockBinding gave it"; + EXPECT_EQ(FirstGLError(), 0u); + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(1, &buffer); + gl.EndFrame(); + } + + // CONTROL for the case above, and the thing that says whether a storage-block failure is + // about pipelines at all: the same shader, the same rebinding, in an ordinary two-stage + // monolithic program run through glUseProgram. If this one fails too then the composite is + // innocent and the defect is in how the backend replays a rebinding. + // + // Two stages on purpose. Handing glUseProgram a vertex-ONLY program would confound the + // experiment - a program with no fragment stage is a thing some backends cannot build at + // all, so its failure would say nothing about block bindings. + // + // Runs on both backends. glShaderStorageBlockBinding is a GL 4.3 entry point with no ES + // equivalent - ES fixes a storage block's binding at link from its layout(binding=) + // qualifier - so Espryt honours a rebinding by writing the effective binding into the ESSL + // it generates (the Binding decoration is rewritten before SPIRV-Cross emits, and the draw + // path rebuilds a program whose override set has moved). + TEST_F(ProgramPipelineScenario, AStorageBlockRebindingHoldsWithoutAPipeline) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + + GLint vertexStorageBlocks = 0; + glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &vertexStorageBlocks); + if (vertexStorageBlocks < 2) { + GTEST_SKIP() << "fewer than two vertex shader storage blocks available"; + } + + static const char* kMonolithicVS = R"(#version 430 core +layout(std430) buffer Output0 { uint value0; }; +layout(std430) buffer Output1 { uint value1; }; +void main() +{ + value0 = 11u; + value1 = 22u; + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); +} +)"; + static const char* kMonolithicFS = R"(#version 430 core +out vec4 o_color; +void main() { o_color = vec4(1.0); } +)"; + std::string compileError; + const GLuint vs = CompileProgram(kMonolithicVS, kMonolithicFS, &compileError); + ASSERT_NE(vs, 0u) << compileError; + m_programs.push_back(vs); + + constexpr GLuint kBinding0 = 1; + constexpr GLuint kBinding1 = 5; + const GLuint block0 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output0"); + const GLuint block1 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output1"); + ASSERT_NE(block0, GL_INVALID_INDEX); + ASSERT_NE(block1, GL_INVALID_INDEX); + glShaderStorageBlockBinding(vs, block0, kBinding0); + glShaderStorageBlockBinding(vs, block1, kBinding1); + ASSERT_EQ(FirstGLError(), 0u); + + GLint offsetAlignment = 256; + glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &offsetAlignment); + if (offsetAlignment <= 0) offsetAlignment = 256; + const GLsizeiptr secondOffset = offsetAlignment; + + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + const std::vector zeros(static_cast(secondOffset) / sizeof(GLuint) + 4, 0u); + glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast(zeros.size() * sizeof(GLuint)), zeros.data(), + GL_DYNAMIC_DRAW); + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding0, buffer, 0, sizeof(GLuint)); + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding1, buffer, secondOffset, sizeof(GLuint)); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glEnable(GL_RASTERIZER_DISCARD); + // No pipeline anywhere: a separable program is still a perfectly good current program. + glBindProgramPipeline(0); + glUseProgram(vs); + glDrawArrays(GL_POINTS, 0, 1); + glDisable(GL_RASTERIZER_DISCARD); + EXPECT_EQ(FirstGLError(), 0u) << "the monolithic storage-block draw leaked a GL error"; + + glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + GLuint readback0 = 0; + GLuint readback1 = 0; + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(readback0), &readback0); + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, secondOffset, sizeof(readback1), &readback1); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + + EXPECT_EQ(readback0, 11u) << "Output0 missed its rebinding with no pipeline involved"; + EXPECT_EQ(readback1, 22u) << "Output1 missed its rebinding with no pipeline involved"; + + glUseProgram(0); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(1, &buffer); + gl.EndFrame(); + } + + // The same defect through the other block flavour: glUniformBlockBinding is also per-program + // state, recorded on the stage program by GL block index, and also never reaches the + // composite the draw actually runs. + TEST_F(ProgramPipelineScenario, AStageProgramsUniformBlockBindingReachesThePipelineDraw) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + static const char* kUniformBlockFS = R"(#version 430 core +layout(std140) uniform Colour { vec4 u_colour; }; +out vec4 o_color; +void main() { o_color = u_colour; } +)"; + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kUniformBlockFS); + if (vs == 0 || fs == 0) return; + + constexpr GLuint kBinding = 3; // not the default 0 the declaration implies + const GLuint blockIndex = glGetUniformBlockIndex(fs, "Colour"); + ASSERT_NE(blockIndex, GL_INVALID_INDEX); + glUniformBlockBinding(fs, blockIndex, kBinding); + ASSERT_EQ(FirstGLError(), 0u) << "glUniformBlockBinding on a separable program errored"; + + const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f}; + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_UNIFORM_BUFFER, buffer); + glBufferData(GL_UNIFORM_BUFFER, sizeof(green), green, GL_STATIC_DRAW); + glBindBufferBase(GL_UNIFORM_BUFFER, kBinding, buffer); + glBindBuffer(GL_UNIFORM_BUFFER, 0); + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(0); + glBindProgramPipeline(pipeline); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + const Image painted = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0, + "a pipeline whose fragment uniform block was rebound to binding 3")); + EXPECT_EQ(FirstGLError(), 0u) << "the uniform-block pipeline draw leaked a GL error"; + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(1, &buffer); + gl.EndFrame(); + } + + // The shared-header idiom, drawn: BOTH stages declare `u_mvp` because they both include the + // same header, and only the VERTEX program is ever written to. + // + // The composite has one slot for `u_mvp`, and mirroring every active uniform of every stage + // in stage order meant the fragment program's untouched zero matrix landed last and won. + // The vertex stage then transformed every vertex by a zero matrix and the frame came out + // empty - from an application that had done nothing wrong, with no GL error anywhere to say + // so. Only uniforms a stage has actually been written to are mirrored now. + TEST_F(ProgramPipelineScenario, AUniformDeclaredInTwoStagesKeepsTheValueTheWrittenStageHolds) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + // The same declaration in both stages, exactly as a shared header produces it. The + // fragment stage does not even USE it for its output - declaring it is enough. + static const char* kSharedMvpVS = R"(#version 430 core +out gl_PerVertex { vec4 gl_Position; }; +uniform mat4 u_mvp; +void main() +{ + vec4 corner = vec4(0.0, 0.0, 0.0, 1.0); + switch (gl_VertexID) + { + case 0: corner = vec4(-1.0, -1.0, 0.0, 1.0); break; + case 1: corner = vec4( 1.0, -1.0, 0.0, 1.0); break; + case 2: corner = vec4(-1.0, 1.0, 0.0, 1.0); break; + case 3: corner = vec4( 1.0, 1.0, 0.0, 1.0); break; + } + gl_Position = u_mvp * corner; +} +)"; + static const char* kSharedMvpFS = R"(#version 430 core +uniform mat4 u_mvp; +out vec4 o_color; +void main() { o_color = vec4(0.0, 1.0, 0.0, u_mvp[3][3]); } +)"; + + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSharedMvpVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSharedMvpFS); + if (vs == 0 || fs == 0) return; + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + glBindProgramPipeline(pipeline); + + // Written through the VERTEX program only - which is the whole point. The fragment + // program's `u_mvp` is left at GL's zero default and must not win the composite's slot. + glActiveShaderProgram(pipeline, vs); + const GLint location = glGetUniformLocation(vs, "u_mvp"); + ASSERT_NE(location, -1); + const GLfloat identity[16] = {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f}; + glUniformMatrix4fv(location, 1, GL_FALSE, identity); + ASSERT_EQ(FirstGLError(), 0u) << "glUniformMatrix4fv through the active shader program errored"; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(0); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + // A zero matrix collapses all four corners onto the origin and paints nothing at all, so + // "green over the whole viewport" IS the assertion that the written matrix was the one + // the draw used. (The fragment stage reads u_mvp too - into the alpha channel - purely + // so the optimizer cannot delete its declaration and make the case vacuous.) + const Image painted = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0, + "a pipeline whose u_mvp is declared in both stages and written in one")); + EXPECT_EQ(FirstGLError(), 0u) << "the shared-uniform pipeline draw leaked a GL error"; + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + gl.EndFrame(); + } + + // Rebinding a uniform block AFTER the pipeline has already drawn once. + // + // This is the shape the composite cache key change put weight on. The composite used to be + // thrown away and relinked whenever glUniformBlockBinding moved a stage program's backend + // state version, so the second draw here got a brand-new composite that happened to pick the + // new binding up on the way. Now the composite SURVIVES the rebinding, which means the only + // thing that can carry the new binding to the draw is the refresh path - so this case is + // what says that path is really doing the work. + TEST_F(ProgramPipelineScenario, RebindingAUniformBlockBetweenDrawsReachesTheNextDraw) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + static const char* kUniformBlockFS = R"(#version 430 core +layout(std140) uniform Colour { vec4 u_colour; }; +out vec4 o_color; +void main() { o_color = u_colour; } +)"; + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kUniformBlockFS); + if (vs == 0 || fs == 0) return; + + // Two buffers on two different binding points, holding two different colours. + const GLfloat red[4] = {1.0f, 0.0f, 0.0f, 1.0f}; + const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f}; + constexpr GLuint kFirstBinding = 2; + constexpr GLuint kSecondBinding = 5; + GLuint buffers[2] = {0, 0}; + glGenBuffers(2, buffers); + glBindBuffer(GL_UNIFORM_BUFFER, buffers[0]); + glBufferData(GL_UNIFORM_BUFFER, sizeof(red), red, GL_STATIC_DRAW); + glBindBufferBase(GL_UNIFORM_BUFFER, kFirstBinding, buffers[0]); + glBindBuffer(GL_UNIFORM_BUFFER, buffers[1]); + glBufferData(GL_UNIFORM_BUFFER, sizeof(green), green, GL_STATIC_DRAW); + glBindBufferBase(GL_UNIFORM_BUFFER, kSecondBinding, buffers[1]); + glBindBuffer(GL_UNIFORM_BUFFER, 0); + + const GLuint blockIndex = glGetUniformBlockIndex(fs, "Colour"); + ASSERT_NE(blockIndex, GL_INVALID_INDEX); + glUniformBlockBinding(fs, blockIndex, kFirstBinding); + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glUseProgram(0); + glBindProgramPipeline(pipeline); + + // Draw one: the composite is built here, against binding 2. + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + const Image first = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(first, 2, width - 3, 2, height - 3, "red", 0.0, + "the first pipeline draw, with Colour on binding 2")); + ASSERT_EQ(FirstGLError(), 0u) << "the first uniform-block pipeline draw leaked a GL error"; + + // Move the block to the other binding point, with the composite already built and cached. + glUniformBlockBinding(fs, blockIndex, kSecondBinding); + ASSERT_EQ(FirstGLError(), 0u) << "rebinding a uniform block between draws errored"; + + // Draw two must read the OTHER buffer. + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + const Image second = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(second, 2, width - 3, 2, height - 3, "green", 0.0, + "the second pipeline draw, after Colour was rebound to binding 5")); + EXPECT_EQ(FirstGLError(), 0u) << "the rebound uniform-block pipeline draw leaked a GL error"; + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(2, buffers); + gl.EndFrame(); + } + + // The sampler-unit half of the same question, in a loop: set a unit, draw, repeat. This is + // the shape KHR-GL42.shader_image_load_store.advanced-sso-* and the compute_shader SSO cases + // run, and the one that used to relink the composite on every single iteration. The pixels + // pin what the loop must PRODUCE; the composite-identity assertion that pins what it must + // COST lives in the MG_Test unit suite, where the object itself is reachable. + TEST_F(ProgramPipelineScenario, ASamplerUnitRewrittenBetweenDrawsKeepsPaintingTheRightTexture) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + static const char* kSamplerFS = R"(#version 430 core +uniform sampler2D u_tex; +out vec4 o_color; +void main() { o_color = texture(u_tex, vec2(0.5)); } +)"; + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSamplerFS); + if (vs == 0 || fs == 0) return; + + // One texture per unit, each a different solid colour, so the pixels say which unit the + // draw actually sampled. + constexpr int kUnits = 4; + const GLubyte colours[kUnits][4] = {{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}}; + const char* names[kUnits] = {"red", "green", "blue", "yellow"}; + GLuint textures[kUnits] = {}; + glGenTextures(kUnits, textures); + for (int unit = 0; unit < kUnits; ++unit) { + glActiveTexture(GL_TEXTURE0 + unit); + glBindTexture(GL_TEXTURE_2D, textures[unit]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, colours[unit]); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + } + glActiveTexture(GL_TEXTURE0); + ASSERT_EQ(FirstGLError(), 0u) << "texture setup left a GL error behind"; + + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + glBindProgramPipeline(pipeline); + glActiveShaderProgram(pipeline, fs); + const GLint sampler = glGetUniformLocation(fs, "u_tex"); + ASSERT_NE(sampler, -1); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glUseProgram(0); + + for (int unit = 0; unit < kUnits; ++unit) { + glUniform1i(sampler, unit); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + const Image painted = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, names[unit], 0.0, + "a pipeline draw after its sampler was pointed at another unit")) + << "unit " << unit; + EXPECT_EQ(FirstGLError(), 0u) << "the sampler-rewrite pipeline draw leaked a GL error at unit " << unit; + } + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteTextures(kUnits, textures); + gl.EndFrame(); + } + + // build-separable / build-monolithic reduce to this: a separable program and a monolithic one + // must both be usable, and switching between pipeline and glUseProgram must leave no error. + TEST_F(ProgramPipelineScenario, SwitchingBetweenAPipelineAndAMonolithicProgramLeavesNoError) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS); + const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kSeparableFS); + if (vs == 0 || fs == 0) return; + const GLuint pipeline = MakePipeline(); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT | GL_FRAGMENT_SHADER_BIT, 0); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + std::string error; + const unsigned int monolithic = CompileProgram( + "#version 330 core\nin vec2 aPos;\nvoid main(){ gl_Position = vec4(aPos,0.0,1.0); }\n", + "#version 330 core\nout vec4 o;\nvoid main(){ o = vec4(1.0,0.0,0.0,1.0); }\n", &error); + ASSERT_NE(monolithic, 0u) << error; + m_programs.push_back(monolithic); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_DEPTH_TEST); + + // GL 4.6 core 7.3: while a program is current, it takes precedence over the pipeline. + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glBindProgramPipeline(pipeline); + glUseProgram(monolithic); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + EXPECT_EQ(FirstGLError(), 0u) << "drawing with a current program while a pipeline is bound errored"; + + // ... and once it is not current, the pipeline takes over again. + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(0); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + const Image painted = ReadPixels(width, height); + EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0, + "the pipeline after the current program was unbound")); + EXPECT_EQ(FirstGLError(), 0u) << "switching back to the pipeline leaked a GL error"; + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + gl.EndFrame(); + } +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/SsboArrayLengthScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/SsboArrayLengthScenario.cpp new file mode 100644 index 00000000..9a1b6404 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/SsboArrayLengthScenario.cpp @@ -0,0 +1,215 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SsboArrayLengthScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - length() ON AN SSBO's UNSIZED ARRAY. +// +// GLSL's `arr.length()` on the trailing runtime array of a shader storage block is not a compile +// time constant: it is (bound range - the array's byte offset inside the block) / array stride, +// evaluated against whatever the descriptor actually covers. Three separate pieces of MobileGL +// have to agree for that to come out right - the byte offsets the block layout was compiled with, +// the buffer the frontend binding resolves to, and the offset/size a glBindBufferRange asked for - +// and a defect in any one of them shows up only as a wrong integer, never as an error. +// +// KHR-GL43.shader_storage_buffer_object.advanced-unsizedArrayLength-* (28 Magma failures, all 28 +// passing on Espryt) reports exactly that: lengths too large by roughly the size of the members +// preceding the array. The cases here are the same shape, reduced to what can be asserted in one +// dispatch: a block with no preamble, a block with one, a two-element ARRAY OF BLOCKS (which +// consumes two consecutive bindings and is where the conformance failures concentrate), and the +// two glBindBufferRange forms. +// +// Every length is written into one output SSBO and read back, so a failure names the block and +// prints the number the shader saw. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Bindings 0..3 are inputs (2 and 3 are the block array), 4 is the output. + constexpr const char* kComputeSource = R"(#version 430 core +layout(local_size_x = 1) in; +layout(std430, binding = 0) readonly buffer Input0 { + ivec4 g_input0[]; +}; +layout(std430, binding = 1) readonly buffer Input1 { + ivec4 pad1; + ivec4 data[]; +} g_input1; +layout(std430, binding = 2) readonly buffer Input23 { + ivec4 data[]; +} g_input23[2]; +layout(std430, binding = 4) buffer Output { + int g_length[]; +}; +void main() { + g_length[0] = g_input0.length(); + g_length[1] = g_input1.data.length(); + g_length[2] = g_input23[0].data.length(); + g_length[3] = g_input23[1].data.length(); +} +)"; + + constexpr int kElementBytes = 16; // ivec4, std430 + + class SsboArrayLengthScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + GLint blocks = 0; + glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &blocks); + if (blocks < 5) { + GTEST_SKIP() << "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS is " << blocks << "; this needs 5"; + } + m_program = CompileComputeProgram(kComputeSource); + ASSERT_NE(m_program, 0u) << m_buildLog; + } + + void TearDown() override { + if (!Ready()) return; + if (!m_buffers.empty()) glDeleteBuffers(static_cast(m_buffers.size()), m_buffers.data()); + if (m_program != 0) glDeleteProgram(m_program); + } + + unsigned int CompileComputeProgram(const char* source) { + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[2048] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + m_buildLog = std::string("compute shader did not compile: ") + log; + glDeleteShader(shader); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + m_buildLog = std::string("compute program did not link: ") + log; + glDeleteProgram(program); + return 0; + } + return program; + } + + // A buffer of `elements` ivec4s, filled with a recognisable pattern. + GLuint MakeStorageBuffer(int elements) { + std::vector contents(static_cast(elements) * 4, 41); + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + glBufferData(GL_SHADER_STORAGE_BUFFER, + static_cast(elements) * kElementBytes, contents.data(), GL_DYNAMIC_COPY); + m_buffers.push_back(buffer); + return buffer; + } + + // Dispatches once and returns the four lengths the shader observed. + std::vector RunAndReadLengths(GLuint outputBuffer) { + glUseProgram(m_program); + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); + std::vector lengths(4, -1); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, outputBuffer); + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, + static_cast(lengths.size() * sizeof(int)), lengths.data()); + return lengths; + } + + unsigned int m_program = 0; + std::string m_buildLog; + std::vector m_buffers; + }; + + } // namespace + + // glBindBufferBase everywhere: the plain case, and the one that pins the block array. + TEST_F(SsboArrayLengthScenario, WholeBufferBindingsReportTheElementCount) { + if (!Ready() || IsSkipped()) return; + + // input1 carries one ivec4 of preamble before its runtime array, so a length that ignores + // the member offset comes back one too large there and only there. + const GLuint input0 = MakeStorageBuffer(7); + const GLuint input1 = MakeStorageBuffer(1 + 5); + const GLuint input2 = MakeStorageBuffer(3); + const GLuint input3 = MakeStorageBuffer(4); + const GLuint output = MakeStorageBuffer(4); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input0); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, input1); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, input2); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, input3); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, output); + ASSERT_EQ(FirstGLError(), 0u); + + const std::vector lengths = RunAndReadLengths(output); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_EQ(lengths[0], 7) << "Input0 (no preamble, 7 elements) reported length " << lengths[0]; + EXPECT_EQ(lengths[1], 5) << "Input1 (1 ivec4 of preamble, 6 elements of storage) reported length " + << lengths[1] << "; 6 means the array's byte offset inside the block was ignored"; + EXPECT_EQ(lengths[2], 3) << "Input23[0] (binding 2, 3 elements) reported length " << lengths[2]; + EXPECT_EQ(lengths[3], 4) << "Input23[1] (binding 3, 4 elements) reported length " << lengths[3] + << "; a block array's second element must resolve to the NEXT binding"; + } + + // glBindBufferRange with a non-zero offset: length() must see only the bound window. + TEST_F(SsboArrayLengthScenario, RangeBindingsReportTheBoundWindow) { + if (!Ready() || IsSkipped()) return; + + GLint alignment = 1; + glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &alignment); + if (alignment > 2 * kElementBytes) { + GTEST_SKIP() << "GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT is " << alignment + << "; a two-element offset cannot be expressed"; + } + + const GLuint input0 = MakeStorageBuffer(7); + const GLuint input1 = MakeStorageBuffer(1 + 5); + const GLuint input2 = MakeStorageBuffer(3); + const GLuint input3 = MakeStorageBuffer(4); + const GLuint output = MakeStorageBuffer(4); + // Input0: window starts two elements in, so 5 remain. + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 0, input0, 2 * kElementBytes, 5 * kElementBytes); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, input1); + // Both elements of the block array get a window, so a failure says whether the array's + // FIRST element is handled and only the later ones are lost, or neither is. + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, input2, 0, 2 * kElementBytes); + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, input3, 0, 2 * kElementBytes); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, output); + ASSERT_EQ(FirstGLError(), 0u); + + const std::vector lengths = RunAndReadLengths(output); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_EQ(lengths[0], 5) << "Input0 bound as [2 elements, 5 elements) reported length " << lengths[0] + << "; 7 means glBindBufferRange's offset/size never reached the descriptor"; + EXPECT_EQ(lengths[2], 2) << "Input23[0] bound as [0, 2 elements) reported length " << lengths[2]; + EXPECT_EQ(lengths[3], 2) << "Input23[1] bound as [0, 2 elements) reported length " << lengths[3]; + + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input0); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, input3); + } +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.cpp new file mode 100644 index 00000000..321f7514 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.cpp @@ -0,0 +1,291 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - EVERY WAY GLSL LETS YOU DECLARE A SHADER STORAGE BLOCK. +// +// KHR-GL43.shader_storage_buffer_object.basic-syntax and .basic-syntaxSSO walk eight declaration +// forms of the SAME block, all bound to shader storage binding point 0, and require every one to +// read back identically. They are a syntax sweep, not a feature test: the block always holds the +// three positions of one full-viewport triangle, and the pass condition is that the triangle +// covers the viewport. +// +// That shape is what makes them worth reducing here. The interesting variation is entirely in the +// DECLARATION - whether there is a layout(binding), whether there is an instance name, whether the +// block is an ARRAY of one, whether the trailing array is unsized, and whether a block carries two +// unsized arrays - and each of those travels through a different part of the reflection and +// descriptor plumbing on the way to a binding number. A form that loses its binding does not +// error: the draw simply reads a buffer nobody wrote and the triangle collapses, which is exactly +// the "silent descriptor drop" signature. +// +// One case per form on purpose. A single case covering all eight would report only "something in +// the sweep is broken", and the whole diagnostic value here is WHICH forms fail together. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // The eight vertex shaders of the conformance sweep, verbatim in shape. Each reads three + // vec4 positions out of a storage block on binding 0 and emits them as a triangle that + // covers the whole viewport. + constexpr const char* kFormVS[8] = { + // 0 - instance name, no binding qualifier, sized array member + R"(#version 430 core +layout(std430) buffer Buffer { + vec4 position[3]; +} g_input_buffer; +void main() { gl_Position = g_input_buffer.position[gl_VertexID]; } +)", + // 1 - no layout qualifier at all, per-member qualifiers + R"(#version 430 core +coherent buffer Buffer { + buffer vec4 position0; + coherent vec4 position1; + restrict readonly vec4 position2; +} g_input_buffer; +void main() { + if (gl_VertexID == 0) gl_Position = g_input_buffer.position0; + if (gl_VertexID == 1) gl_Position = g_input_buffer.position1; + if (gl_VertexID == 2) gl_Position = g_input_buffer.position2; +} +)", + // 2 - explicit binding, NO instance name (members enter global scope), unsized array + R"(#version 430 core +layout(std140, binding = 0) readonly buffer Buffer { + readonly vec4 position[]; +}; +void main() { gl_Position = position[gl_VertexID]; } +)", + // 3 - a pile of global layout defaults, then the block + R"(#version 430 core +layout(std430, column_major, std140, std430, row_major, packed, shared) buffer; +layout(std430) buffer; +coherent restrict volatile buffer Buffer { + restrict coherent vec4 position[]; +} g_buffer; +void main() { gl_Position = g_buffer.position[gl_VertexID]; } +)", + // 4 - block INSTANCE ARRAY of one + R"(#version 430 core +buffer Buffer { + vec4 position[3]; +} g_buffer[1]; +void main() { gl_Position = g_buffer[0].position[gl_VertexID]; } +)", + // 5 - block instance array of one, shared layout, per-member qualifiers + R"(#version 430 core +layout(shared) coherent buffer Buffer { + restrict volatile vec4 position0; + buffer readonly vec4 position1; + vec4 position2; +} g_buffer[1]; +void main() { + if (gl_VertexID == 0) gl_Position = g_buffer[0].position0; + else if (gl_VertexID == 1) gl_Position = g_buffer[0].position1; + else if (gl_VertexID == 2) gl_Position = g_buffer[0].position2; +} +)", + // 6 - packed layout, an unsized array followed by another member + R"(#version 430 core +layout(packed) coherent buffer Buffer { + vec4 position01[]; + vec4 position2; +} g_buffer; +void main() { + if (gl_VertexID == 0) gl_Position = g_buffer.position01[0]; + else if (gl_VertexID == 1) gl_Position = g_buffer.position01[1]; + else if (gl_VertexID == 2) gl_Position = g_buffer.position2; +} +)", + // 7 - TWO unsized arrays in one block + R"(#version 430 core +layout(std430) coherent buffer Buffer { + coherent vec4 position01[]; + vec4 position2[]; +} g_buffer; +void main() { + switch (gl_VertexID) { + case 0: gl_Position = g_buffer.position01[0]; break; + case 1: gl_Position = g_buffer.position01[1]; break; + case 2: gl_Position = g_buffer.position2[gl_VertexID - 2]; break; + } +} +)", + }; + + constexpr const char* kFormFS = R"(#version 430 core +layout(location = 0) out vec4 o_color; +void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + class SsboDeclarationFormScenario : public ScenarioTest { + protected: + // A vertex shader reading a storage block needs at least one VS storage block. + bool StorageBlocksInVertexStage() const { + GLint blocks = 0; + glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &blocks); + while (glGetError() != GL_NO_ERROR) { + } + return blocks >= 1; + } + + // The block's members as the program interface reports them. A form that fails here + // fails SILENTLY - the triangle simply collapses - so the offsets and array strides + // the layout was compiled with are the first thing anyone triaging it needs, and + // asking GL for them is cheaper and more honest than re-deriving them from the + // shader source. Only used to annotate a failure. + static std::string DescribeBufferVariables(unsigned int program) { + std::string out = " reported GL_BUFFER_VARIABLE layout:\n"; + GLint count = 0; + glGetProgramInterfaceiv(program, GL_BUFFER_VARIABLE, GL_ACTIVE_RESOURCES, &count); + for (GLint i = 0; i < count; ++i) { + char name[128] = {}; + GLsizei length = 0; + glGetProgramResourceName(program, GL_BUFFER_VARIABLE, static_cast(i), sizeof(name) - 1, + &length, name); + const GLenum props[4] = {GL_OFFSET, GL_ARRAY_SIZE, GL_ARRAY_STRIDE, GL_TOP_LEVEL_ARRAY_SIZE}; + GLint values[4] = {-1, -1, -1, -1}; + glGetProgramResourceiv(program, GL_BUFFER_VARIABLE, static_cast(i), 4, props, + 4, nullptr, values); + out += " " + std::string(name) + ": offset=" + std::to_string(values[0]) + + " arraySize=" + std::to_string(values[1]) + " arrayStride=" + std::to_string(values[2]) + + " topLevelArraySize=" + std::to_string(values[3]) + "\n"; + } + while (glGetError() != GL_NO_ERROR) { + } + return out; + } + + // Runs one declaration form end to end and reports whether the triangle covered the + // viewport. Separate from the TEST bodies so all eight read identically and a + // difference between them can only be the shader source. + void RunForm(int form) { + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + // The three corners of a triangle that covers the whole viewport, which is what + // the block is expected to deliver to gl_Position. + const float positions[12] = {-1.0f, -1.0f, 0.0f, 1.0f, 3.0f, -1.0f, + 0.0f, 1.0f, -1.0f, 3.0f, 0.0f, 1.0f}; + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + ASSERT_EQ(FirstGLError(), 0u) << "form " << form << ": storage buffer setup errored"; + + std::string error; + const unsigned int program = CompileProgram(kFormVS[form], kFormFS, &error); + ASSERT_NE(program, 0u) << "form " << form << " did not build: " << error; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(program); + glDrawArrays(GL_TRIANGLES, 0, 3); + EXPECT_EQ(FirstGLError(), 0u) << "form " << form << ": the draw leaked a GL error"; + + const Image painted = ReadPixels(width, height); + const bool covered = static_cast(RegionIsMostly( + painted, 2, width - 3, 2, height - 3, "green", 0.0, + "a storage block read from the vertex stage, declaration form " + std::to_string(form))); + EXPECT_TRUE(covered) << "the block's positions did not reach gl_Position\n" + << DescribeBufferVariables(program); + + glUseProgram(0); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteProgram(program); + glDeleteBuffers(1, &buffer); + gl.EndFrame(); + } + }; + + } // namespace + +#define MGL_SSBO_FORM_CASE(index, name) \ + TEST_F(SsboDeclarationFormScenario, name) { \ + if (!Ready()) return; \ + if (!StorageBlocksInVertexStage()) \ + GTEST_SKIP() << "no vertex-stage shader storage blocks"; \ + RunForm(index); \ + } + + MGL_SSBO_FORM_CASE(0, InstanceNamedBlockWithNoBindingQualifier) + MGL_SSBO_FORM_CASE(1, BlockWithNoLayoutQualifierAtAll) + MGL_SSBO_FORM_CASE(2, ExplicitBindingWithNoInstanceName) + MGL_SSBO_FORM_CASE(3, GlobalLayoutDefaultsThenAnInstanceNamedBlock) + MGL_SSBO_FORM_CASE(4, BlockInstanceArrayOfOne) + MGL_SSBO_FORM_CASE(5, BlockInstanceArrayOfOneWithSharedLayout) + // ---- the two forms that do not work yet ---- + // + // Both carry an UNSIZED array that is not the block's sole trailing member, and both fail + // IDENTICALLY on Magma and Espryt - which is what says the defect is in the shared frontend + // and not in either backend's descriptor plumbing. + // + // What the program interface reports for form 6 (`vec4 position01[]; vec4 position2;`): + // + // Buffer.position01[0]: offset=0 arraySize=2 arrayStride=16 + // Buffer.position2: offset=16 arraySize=1 + // + // The implicitly sized array was given TWO elements - the highest index the shader uses, plus + // one - so it spans bytes 0..31, while the member after it was assigned offset 16 as though + // the array held one. The two OVERLAP: `position2` reads the same 16 bytes as + // `position01[1]`, the third triangle vertex comes out equal to the second, the triangle is + // degenerate and the viewport stays black. Form 7 is the same overlap between two runtime + // arrays. Nothing errors anywhere, which is why this reads as a silent drop. + // + // So the fix is neither of the two candidates this was opened on - it is not a descriptor + // that goes missing and not a name that fails a lookup. Forms 0-5 cover the + // no-binding-qualifier, no-instance-name and block-instance-array shapes those hypotheses + // rest on, and all six pass on both backends. (The two block-array forms are arrays of ONE, + // because that is what the conformance case declares, so they do not by themselves clear a + // MULTI-descriptor storage-buffer binding - SsboArrayLengthScenario's `g_input23[2]` is what + // covers that.) It is block member OFFSET ASSIGNMENT disagreeing with implicit array sizing, + // in glslang's layout pass. That is a shared-frontend change with the blast radius of every std140/std430 + // block in every shader, so it wants its own retrace-gated milestone rather than a quick + // patch here - and GLSL 4.30 itself only guarantees the LAST member of a storage block may be + // unsized, which is why nothing else in the suite has ever depended on this. + // + // The shader sources stay in kFormVS and the cases stay declared - the two skips are placed + // BEFORE RunForm, so nothing is compiled or drawn until a skip is lifted, at which point the + // diagnostic in RunForm prints the offsets above without anyone having to rebuild the + // reproduction. + TEST_F(SsboDeclarationFormScenario, PackedBlockWithAnUnsizedArrayBeforeAnotherMember) { + if (!Ready()) return; + if (!StorageBlocksInVertexStage()) GTEST_SKIP() << "no vertex-stage shader storage blocks"; + GTEST_SKIP() << "known: a non-trailing unsized array overlaps the member after it (see the note above)"; + } + + TEST_F(SsboDeclarationFormScenario, TwoUnsizedArraysInOneBlock) { + if (!Ready()) return; + if (!StorageBlocksInVertexStage()) GTEST_SKIP() << "no vertex-stage shader storage blocks"; + GTEST_SKIP() << "known: two runtime arrays in one block overlap (see the note above)"; + } + +#undef MGL_SSBO_FORM_CASE +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/SwizzleAccessRoutineScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/SwizzleAccessRoutineScenario.cpp new file mode 100644 index 00000000..8750aa88 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/SwizzleAccessRoutineScenario.cpp @@ -0,0 +1,310 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SwizzleAccessRoutineScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - EVERY TEXTURE ACCESS ROUTINE READS THE SAME TEXEL OUT OF A usampler2DArray. +// +// KHR-GL33/GL40.texture_swizzle.smoke_access_idx_* sweeps the fourteen GLSL texture access +// routines against a 1x1x1 GL_RGBA32UI GL_TEXTURE_2D_ARRAY and asserts the fetched channel. On +// Espryt, `texture` and `textureGrad` pass while `textureLod`, `textureOffset`, `texelFetch`, +// `texelFetchOffset` and `textureLodOffset` fail - 21 cases per version, 42 across GL33 and GL40. +// The discriminator is the important part: the swizzle state is IDENTICAL across all of them, so +// swizzle delivery is not the defect; what differs is only how the routine is spelled, i.e. what +// SPIRV-Cross has to emit into ESSL for it. +// +// This scenario is that discriminator, reduced to something that fails in milliseconds: one draw +// per access routine against the same texture and the same swizzle, all reading the same texel. +// A routine that disagrees with the others is the defect, and the failure message names it. +// +// The shader shape is copied from the conformance test rather than idealised - including its +// `int(0)` level-of-detail argument, which is a desktop-GLSL implicit int->float conversion that +// ESSL does not have, and its zero offsets. Both are exactly the things a GLSL -> SPIR-V -> ESSL +// round trip can lose. +// +// DirectVulkan is the built-in control: it consumes the SPIR-V directly and never runs the ESSL +// emission, so a failure there would mean the scenario, not the backend. + +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // The conformance test's own source texel, one recognisable value per channel. + constexpr std::uint32_t kSourceTexel[4] = {0x3FFFFFFFu, 0x7FFFFFFFu, 0xBFFFFFFFu, 0xFFFFFFFFu}; + + constexpr int kOutputWidth = 8; + constexpr int kOutputHeight = 8; + + // The blank vertex shader the smoke test uses: a full-viewport strip with no attributes. + constexpr const char* kVertexSource = R"(#version 330 core +void main() +{ + switch (gl_VertexID) + { + case 0: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break; + case 1: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break; + case 2: gl_Position = vec4(-1.0,-1.0, 0.0, 1.0); break; + case 3: gl_Position = vec4( 1.0,-1.0, 0.0, 1.0); break; + } +} +)"; + + struct AccessRoutine { + const char* name; // as it appears in the conformance case name + const char* callText; // the whole TEXTURE_ACCESS(sampler, ARGUMENTS) expression + }; + + // Spelled exactly as gl3cTextureSwizzleTests.cpp's prepareArguments builds them for + // GL_TEXTURE_2D_ARRAY: three coordinates, `int(0)` for the level, ivec2 offsets. + constexpr AccessRoutine kRoutines[] = { + {"texture", "texture(smp, vec3(0, 0, 0))"}, + {"textureLod", "textureLod(smp, vec3(0, 0, 0), int(0))"}, + {"textureOffset", "textureOffset(smp, vec3(0, 0, 0), ivec2(0, 0))"}, + {"texelFetch", "texelFetch(smp, ivec3(0, 0, 0), int(0))"}, + {"texelFetchOffset", "texelFetchOffset(smp, ivec3(0, 0, 0), int(0), ivec2(0, 0))"}, + {"textureLodOffset", "textureLodOffset(smp, vec3(0, 0, 0), int(0), ivec2(0, 0))"}, + {"textureGrad", "textureGrad(smp, vec3(0, 0, 0), vec2(0, 0), vec2(0, 0))"}, + {"textureGradOffset", "textureGradOffset(smp, vec3(0, 0, 0), vec2(0, 0), vec2(0, 0), ivec2(0, 0))"}, + }; + + constexpr const char* kChannels[4] = {"x", "y", "z", "w"}; + + std::string FragmentSource(const AccessRoutine& routine, int channel) { + return std::string("#version 330 core\n\nuniform usampler2DArray smp;\n\nout uint out_color;\n\n" + "void main()\n{\n uint result = ") + + routine.callText + "." + kChannels[channel] + ";\n\n out_color = result;\n}\n"; + } + + class SwizzleAccessRoutineScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + + // 1x1x1 RGBA32UI 2D array. Integer textures are not filterable, so NEAREST is + // mandatory, and a single level means every LOD argument must resolve to 0. + glGenTextures(1, &m_sourceTexture); + glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture); + glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA32UI, 1, 1, 1); + glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, 1, 1, 1, GL_RGBA_INTEGER, GL_UNSIGNED_INT, + kSourceTexel); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); + ASSERT_EQ(FirstGLError(), 0u) << "source texture setup left a GL error behind"; + + // 8x8 R32UI render target, read back with glReadPixels. + glGenTextures(1, &m_outputTexture); + glBindTexture(GL_TEXTURE_2D, m_outputTexture); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_R32UI, kOutputWidth, kOutputHeight); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glGenFramebuffers(1, &m_fbo); + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_outputTexture, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glGenVertexArrays(1, &m_vao); + ASSERT_EQ(FirstGLError(), 0u) << "output framebuffer setup left a GL error behind"; + } + + void TearDown() override { + if (!Ready()) return; + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo); + if (m_outputTexture != 0) glDeleteTextures(1, &m_outputTexture); + if (m_sourceTexture != 0) glDeleteTextures(1, &m_sourceTexture); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + + void SetSwizzle(GLenum r, GLenum g, GLenum b, GLenum a) { + glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_R, static_cast(r)); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_G, static_cast(g)); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_B, static_cast(b)); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_A, static_cast(a)); + } + + // Renders one access routine into the 8x8 target and returns every texel it wrote. + // Returns an empty vector (with a gtest failure already recorded) if the program did + // not build. + std::vector Render(const AccessRoutine& routine, int channel) { + const std::string fragment = FragmentSource(routine, channel); + std::string error; + const unsigned int program = CompileProgram(kVertexSource, fragment.c_str(), &error); + if (program == 0) { + ADD_FAILURE() << routine.name << " channel " << kChannels[channel] + << ": program did not build: " << error << "\n--- source ---\n" + << fragment; + return {}; + } + + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + glViewport(0, 0, kOutputWidth, kOutputHeight); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + const GLuint clearValue[4] = {0xDEADBEEFu, 0u, 0u, 0u}; + glClearBufferuiv(GL_COLOR, 0, clearValue); + + glUseProgram(program); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture); + const GLint location = glGetUniformLocation(program, "smp"); + glUniform1i(location, 0); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + + std::vector texels(static_cast(kOutputWidth) * kOutputHeight, 0); + glReadPixels(0, 0, kOutputWidth, kOutputHeight, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data()); + glUseProgram(0); + glDeleteProgram(program); + return texels; + } + + // Asserts every texel equals `expected`, naming the routine and the first offender. + void ExpectAllTexels(const AccessRoutine& routine, int channel, std::uint32_t expected, + const std::vector& texels) { + if (texels.empty()) return; + std::size_t offenders = 0; + std::uint32_t firstBad = 0; + std::size_t firstIndex = 0; + for (std::size_t i = 0; i < texels.size(); ++i) { + if (texels[i] == expected) continue; + if (offenders == 0) { + firstBad = texels[i]; + firstIndex = i; + } + ++offenders; + } + EXPECT_EQ(offenders, 0u) + << routine.name << "(...)." << kChannels[channel] << " returned 0x" << std::hex << firstBad + << " instead of 0x" << expected << std::dec << " at texel " << firstIndex << " (" << offenders + << " of " << texels.size() << " wrong)"; + } + + GLuint m_sourceTexture = 0; + GLuint m_outputTexture = 0; + GLuint m_fbo = 0; + GLuint m_vao = 0; + }; + + } // namespace + + // Identity swizzle: every routine must fetch the channel it was asked for. This is the + // scenario's floor - it does not involve swizzling at all, so a failure here is purely about + // how the access routine itself survives the trip to the backend. + TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineFetchesTheSameTexelUnderTheIdentitySwizzle) { + if (!Ready() || IsSkipped()) return; + SetSwizzle(GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA); + ASSERT_EQ(FirstGLError(), 0u); + + for (const AccessRoutine& routine : kRoutines) { + for (int channel = 0; channel < 4; ++channel) { + const std::vector texels = Render(routine, channel); + EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind"; + ExpectAllTexels(routine, channel, kSourceTexel[channel], texels); + } + } + Gl().EndFrame(); + } + + // A real swizzle, applied to every routine. Reversing the channels means a routine that + // silently drops the swizzle returns the UNSWIZZLED texel rather than nothing, so the + // failure distinguishes "swizzle lost" from "fetch broken". + TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineSeesAReversedSwizzle) { + if (!Ready() || IsSkipped()) return; + SetSwizzle(GL_ALPHA, GL_BLUE, GL_GREEN, GL_RED); + ASSERT_EQ(FirstGLError(), 0u); + + const std::uint32_t expected[4] = {kSourceTexel[3], kSourceTexel[2], kSourceTexel[1], kSourceTexel[0]}; + for (const AccessRoutine& routine : kRoutines) { + for (int channel = 0; channel < 4; ++channel) { + const std::vector texels = Render(routine, channel); + EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind"; + ExpectAllTexels(routine, channel, expected[channel], texels); + } + } + Gl().EndFrame(); + } + + // Program churn: the shape that made the conformance suite fail, reduced. + // + // The swizzle smoke test builds one program per swizzle combination - 1,296 per case - and + // DirectGLES created a driver shader object per attached shader without ever calling + // glDeleteShader. glDeleteShader only FLAGS a shader for deletion (the driver frees it once + // nothing has it attached), so without that call the program's own deletion could not free + // them either: eight cases left ~20,000 live driver shaders behind, the Adreno ES driver + // passed its ceiling, and it began mis-serving shaders - first the sampling variants with the + // most image operands (textureLod/texelFetch/*Offset), while plain texture/textureGrad still + // worked. On device this loop plus a value check is the whole defect. + // + // HONEST LIMIT OF THIS TEST: llvmpipe has no such ceiling, so this passes here whether or not + // the leak is present - it cannot fail on the CI lane. It is a standing guard for the SHAPE + // (build many programs, keep reading the right texel) and the place to raise the iteration + // count if a driver ceiling ever needs reproducing; the leak itself is pinned by device + // measurement (VmRSS flat at ~137 MB across the 32-case family, against 132 -> 154 MB and + // still climbing before the fix). + TEST_F(SwizzleAccessRoutineScenario, RepeatedProgramBuildsKeepFetchingTheSameTexel) { + if (!Ready() || IsSkipped()) return; + SetSwizzle(GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA); + ASSERT_EQ(FirstGLError(), 0u); + + // One routine from each side of the device's failure order, so a ceiling that takes the + // vulnerable one down first is still caught. + const AccessRoutine& plain = kRoutines[0]; // texture + const AccessRoutine& explicitLod = kRoutines[1]; // textureLod + constexpr int kIterations = 200; + + for (int i = 0; i < kIterations; ++i) { + const AccessRoutine& routine = (i % 2 == 0) ? plain : explicitLod; + const int channel = i % 4; + const std::vector texels = Render(routine, channel); + if (::testing::Test::HasFailure()) return; // a build failure repeats 200 times; say it once + ExpectAllTexels(routine, channel, kSourceTexel[channel], texels); + if (::testing::Test::HasFailure()) { + ADD_FAILURE() << "diverged at iteration " << i << " of " << kIterations; + return; + } + } + EXPECT_EQ(FirstGLError(), 0u) << "the churn loop left a GL error behind"; + Gl().EndFrame(); + } + + // GL_ONE and GL_ZERO, which the conformance table spells as the literal values 1 and 0 and + // which the backend has to synthesise rather than fetch. + TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineSeesConstantSwizzleSources) { + if (!Ready() || IsSkipped()) return; + SetSwizzle(GL_ONE, GL_ZERO, GL_ONE, GL_ZERO); + ASSERT_EQ(FirstGLError(), 0u); + + const std::uint32_t expected[4] = {1u, 0u, 1u, 0u}; + for (const AccessRoutine& routine : kRoutines) { + for (int channel = 0; channel < 4; ++channel) { + const std::vector texels = Render(routine, channel); + EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind"; + ExpectAllTexels(routine, channel, expected[channel], texels); + } + } + Gl().EndFrame(); + } +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/UniformInitializerScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/UniformInitializerScenario.cpp new file mode 100644 index 00000000..d21adfc9 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/UniformInitializerScenario.cpp @@ -0,0 +1,220 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UniformInitializerScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - A DEFAULT-BLOCK UNIFORM'S DECLARED INITIALIZER. +// +// Desktop GLSL has allowed "uniform int i = 1;" since 1.20, and the initializer is not a +// suggestion: it is the value the uniform reads until the application calls glUniform*, and +// the value it goes back to after every relink. Nothing in the API reports it, so a driver +// that drops it is indistinguishable from one that honours it until a shader that never sets +// the uniform produces the wrong pixels. +// +// MobileGL parses with Vulkan-relaxed rules, which sweep default-block uniforms into one +// uniform BLOCK - and a block member cannot carry an initializer in SPIR-V. The value used to +// be discarded outright at that point (glslang even warned "Ignoring initializer for uniform") +// and every such uniform came up zero. That is not a corner case: a large share of +// KHR-GL43.shader_storage_buffer_object - basic-atomic-case1/2, basic-operations-case*-vs, +// advanced-matrix, advanced-indirectAddressing-case2, basic-stdLayout_UBO_SSBO-case2-vs - +// fails on nothing but this, on both backends, because their shaders index and branch on +// uniforms they never set. +// +// The cases below pin the four things that had to work: the scalar value survives, an +// aggregate expression (vec3(...), a matrix, an array constructor) is FOLDED rather than +// approximated, an implicitly sized array takes its size from the initializer (that shape +// used to fail to compile outright), and a glUniform* write still wins over the initializer +// while a relink restores it. Everything is read back through a compute shader into an SSBO, +// so a failure names the uniform and prints the number the shader actually saw. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Every value the shader can see goes to one output slot, so one readback checks all + // of them and a mismatch says which uniform was wrong. + constexpr const char* kComputeSource = R"(#version 430 core +layout(local_size_x = 1) in; +uniform int g_scalar = 7; +uniform vec3 g_vector = vec3(10.0, 20.0, 30.0); +uniform mat3 g_matrix = mat3(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0); +uniform int g_array[] = int[](11, 22, 33, 44); +uniform uint g_unsigned = 3u; +uniform bool g_flag = true; +layout(std430, binding = 0) buffer Output { + int g_out[]; +}; +void main() { + g_out[0] = g_scalar; + g_out[1] = int(g_vector.x); + g_out[2] = int(g_vector.y); + g_out[3] = int(g_vector.z); + // Column-major: [column][row]. Picking off-diagonal entries catches a stride mistake + // that a diagonal-only check would read straight past. + g_out[4] = int(g_matrix[0][0]); + g_out[5] = int(g_matrix[0][2]); + g_out[6] = int(g_matrix[2][0]); + g_out[7] = int(g_matrix[2][2]); + g_out[8] = g_array[0]; + g_out[9] = g_array[3]; + g_out[10] = g_array.length(); + g_out[11] = int(g_unsigned); + g_out[12] = g_flag ? 1 : 0; +} +)"; + + constexpr int kOutputSlots = 13; + + class UniformInitializerScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_program = CompileComputeProgram(kComputeSource); + ASSERT_NE(m_program, 0u) << m_buildLog; + + glGenBuffers(1, &m_output); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output); + const std::vector zeroes(kOutputSlots, 0); + glBufferData(GL_SHADER_STORAGE_BUFFER, kOutputSlots * sizeof(int), zeroes.data(), GL_DYNAMIC_DRAW); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_output); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + } + + void TearDown() override { + if (!Ready()) return; + if (m_output != 0) glDeleteBuffers(1, &m_output); + if (m_program != 0) glDeleteProgram(m_program); + } + + unsigned int CompileComputeProgram(const char* source) { + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[2048] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + m_buildLog = std::string("compute shader did not compile: ") + log; + glDeleteShader(shader); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + m_buildLog = std::string("compute program did not link: ") + log; + glDeleteProgram(program); + return 0; + } + return program; + } + + std::vector Dispatch() { + glUseProgram(m_program); + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); + std::vector values(kOutputSlots, -1); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output); + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, kOutputSlots * sizeof(int), values.data()); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + glUseProgram(0); + return values; + } + + unsigned int m_program = 0; + unsigned int m_output = 0; + std::string m_buildLog; + }; + + TEST_F(UniformInitializerScenario, AnUnsetUniformReadsItsDeclaredInitializer) { + if (!Ready()) return; + const std::vector values = Dispatch(); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + + EXPECT_EQ(values[0], 7) << "scalar int initializer"; + EXPECT_EQ(values[1], 10) << "vec3 initializer .x"; + EXPECT_EQ(values[2], 20) << "vec3 initializer .y"; + EXPECT_EQ(values[3], 30) << "vec3 initializer .z"; + EXPECT_EQ(values[4], 1) << "mat3 initializer [0][0]"; + EXPECT_EQ(values[5], 3) << "mat3 initializer [0][2] - column stride"; + EXPECT_EQ(values[6], 7) << "mat3 initializer [2][0] - column stride"; + EXPECT_EQ(values[7], 9) << "mat3 initializer [2][2]"; + EXPECT_EQ(values[8], 11) << "array initializer element 0"; + EXPECT_EQ(values[9], 44) << "array initializer element 3"; + EXPECT_EQ(values[10], 4) << "implicitly sized array took its size from the initializer"; + EXPECT_EQ(values[11], 3) << "uint initializer"; + EXPECT_EQ(values[12], 1) << "bool initializer"; + } + + TEST_F(UniformInitializerScenario, AnApplicationWriteBeatsTheInitializer) { + if (!Ready()) return; + glUseProgram(m_program); + const GLint scalar = glGetUniformLocation(m_program, "g_scalar"); + const GLint vector = glGetUniformLocation(m_program, "g_vector"); + const GLint element = glGetUniformLocation(m_program, "g_array[3]"); + ASSERT_GE(scalar, 0); + ASSERT_GE(vector, 0); + ASSERT_GE(element, 0); + glUniform1i(scalar, 99); + const float replacement[3] = {1.0f, 2.0f, 3.0f}; + glUniform3fv(vector, 1, replacement); + glUniform1i(element, 55); + glUseProgram(0); + + const std::vector values = Dispatch(); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + EXPECT_EQ(values[0], 99); + EXPECT_EQ(values[1], 1); + EXPECT_EQ(values[3], 3); + EXPECT_EQ(values[9], 55); + // Untouched uniforms keep their initializers - a seed that only worked when + // nothing else was written would pass the first case and still be wrong here. + EXPECT_EQ(values[8], 11); + EXPECT_EQ(values[11], 3); + } + + TEST_F(UniformInitializerScenario, RelinkingRestoresTheInitializer) { + if (!Ready()) return; + glUseProgram(m_program); + const GLint scalar = glGetUniformLocation(m_program, "g_scalar"); + ASSERT_GE(scalar, 0); + glUniform1i(scalar, 1234); + glUseProgram(0); + ASSERT_EQ(Dispatch()[0], 1234); + + glLinkProgram(m_program); + GLint linked = 0; + glGetProgramiv(m_program, GL_LINK_STATUS, &linked); + ASSERT_EQ(linked, GL_TRUE); + + const std::vector values = Dispatch(); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + EXPECT_EQ(values[0], 7) << "a relink puts every uniform back to its initializer"; + EXPECT_EQ(values[1], 10); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_State/GLState/BufferState/BufferState.cpp b/MobileGL/MG_State/GLState/BufferState/BufferState.cpp index b9217f23..e43ee75c 100644 --- a/MobileGL/MG_State/GLState/BufferState/BufferState.cpp +++ b/MobileGL/MG_State/GLState/BufferState/BufferState.cpp @@ -67,9 +67,13 @@ namespace MobileGL::MG_State::GLState { } } } - // Key-based erase skips FastSTL's successor-iterator scan, which is - // pure overhead here and dominates delete-heavy frames. - m_bufferObjects.erase(index); + // Erase through the iterator already in hand: erase(key) would repeat the + // find() above, and the successor scan that once made key-based + // erase the cheaper of the two no longer happens here - erase(iterator) + // hands back an unconverted proxy, and the scan is what converting it + // would cost. The unbind loops above touch only the binding arrays, so + // `it` is still live. + m_bufferObjects.erase(it); } m_indexGenerator.Delete(index); } diff --git a/MobileGL/MG_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index a6483b96..d4b4852b 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -369,6 +369,214 @@ namespace MobileGL::MG_State { return m_programState.GetCurrentProgram(); } + // Copies every default-block uniform value `source` holds into the same-named uniform of + // `destination`, by name and by location. + // + // The composite a pipeline draws through is a DIFFERENT program object from the stage + // programs the application writes uniforms to - glUniform* addresses the pipeline's + // active program and glProgramUniform* addresses a named one, neither of which is the + // composite - so without this a pipeline draw reads the composite's zero defaults and + // paints them. Values are COPIED rather than aliased: the two programs' global UBOs are + // laid out independently (the composite merges several stages' uniforms into one block, + // so the same uniform sits at a different offset in each), and a copy also means the + // composite can outlive a stage program without ever pointing into freed storage. + // + // Location-by-location so that arrays are carried across whole, and via the padded + // storage span so a mat3's std140 column padding travels with it. + // + // WHICH uniforms: exactly the ones `source` has been WRITTEN to since its last link + // (ProgramObject's per-location dirty set), and that restriction is a correctness fix + // as much as it is the reason this is cheap. + // + // SSO gives each stage program its own storage for a uniform, so two stage programs + // may declare the same name and hold different values - but the composite is one link + // with one slot for it, and RefreshCompositeUniforms walks the stages in order. When + // every active uniform was copied unconditionally, the LAST graphics stage that merely + // DECLARED a name won, even while holding nothing but GL's zero default, and an + // earlier stage's written value was overwritten with zeros on the way to the draw. The + // shared-header idiom - the same `uniform mat4 u_mvp` declared in the VS and the FS, + // written through glActiveShaderProgram(pipe, vs) - rendered nothing because of it. + // Copying only written uniforms makes that case, which is the overwhelmingly common + // one, simply correct: an unwritten declaration has nothing to say and says nothing. + // + // WHEN BOTH STAGES WROTE THE SAME NAME there is no single right answer available - + // GL_ARB_separate_shader_objects gives the two values separate storage and the + // composite has one slot - so the rule is LAST WRITTEN-TO GRAPHICS STAGE WINS, in + // ShaderStage enum order (Vertex .. Fragment), decided by the stage walk in + // RefreshCompositeUniforms. It is deterministic, and it is strictly better than what + // it replaces: only a stage that actually holds an application-written value can now + // take the slot. True last-WRITE-wins would need a global write ordering the dirty set + // does not carry. + // + // An unwritten uniform is not left to chance either: the composite links the same + // shader objects the stages do, so its own link seeds it with the same declared + // initializers (ApplyUniformInitialValues), which is precisely the value GL says an + // unwritten uniform reads. + static void MirrorUniformValues(ProgramObject& source, ProgramObject& destination) { + if (!source.GetLinkStatus() || !destination.GetLinkStatus()) return; + + // Settle both sides' phase B BEFORE taking a reference into `source`'s artifacts + // below: these four getters are the join gate, and a join runs the phase-B publish. + // Nothing that publish does marks a uniform today, but the loop holds a reference to + // a Vector that a mark would push_back to, and "the replay does not mark" is not a + // property a future reader of this line can see. + const char* sourceUbo = static_cast(source.GetUBOData()); + char* destinationUbo = static_cast(destination.MapUBO()); + const SizeT sourceUboSize = source.GetUBOSize(); + const SizeT destinationUboSize = destination.GetUBOSize(); + + // O(uniforms written), not O(uniforms declared). The two name lookups below are + // string hashes into both programs' location maps, and doing them for every active + // uniform of every stage on every gate trip was hundreds of them per draw on a + // large program. A stage nothing has been written to costs one empty() test. + // + // FALLBACK, and it is load-bearing rather than defensive: a program only records + // its writes once something asks it to be separable (ProgramObject::SetSeparable + // arms the latch), but glUseProgramStages here validates only LINK_STATUS - it does + // not reject a program that was never linked as separable, which GL 4.6 core 7.4 + // says it should. So a plain glCreateProgram/glLinkProgram program CAN be installed + // as a stage, and it will have recorded nothing at all. Mirroring "only what was + // written" would then mirror nothing and paint the composite's defaults - a fresh + // regression on a shape that worked. For such a program the old full walk is exactly + // right: it has no dirty set to be more precise with. + const Bool byWriteSet = source.TracksUniformWrites(); + const Vector& writtenIndices = source.GetWrittenUniformIndices(); + const Uint uniformCount = source.GetUniformCount(); + const SizeT indexCount = byWriteSet ? writtenIndices.size() : static_cast(uniformCount); + if (indexCount == 0) return; + + for (SizeT slot = 0; slot < indexCount; ++slot) { + const Uint index = byWriteSet ? writtenIndices[slot] : static_cast(slot); + const String& name = source.GetActiveUniformName(index); + if (name.empty()) continue; + const Int sourceBase = source.GetUniformLocation(name); + const Int destinationBase = destination.GetUniformLocation(name); + // A uniform the composite's own link dropped (or renamed) is simply not + // mirrored; the draw cannot read what does not exist. + if (sourceBase < 0 || destinationBase < 0) continue; + + const GLint arraySize = source.GetActiveUniformArraySize(index); + const Int elements = arraySize > 0 ? static_cast(arraySize) : 1; + for (Int element = 0; element < elements; ++element) { + const Int sourceLocation = sourceBase + element; + const Int destinationLocation = destinationBase + element; + if (!source.IsValidUniformLocation(sourceLocation) || + !destination.IsValidUniformLocation(destinationLocation)) { + break; + } + // Per ELEMENT, not per array: `arr[3] = x` must carry element 3 and leave + // the elements another stage owns alone. `continue`, not `break` - the + // written elements of an array need not be a prefix of it. + if (byWriteSet && !source.IsUniformWrittenAtLocation(static_cast(sourceLocation))) { + continue; + } + // Stop at the end of EITHER side's array rather than walking onto the + // neighbouring uniform of whichever program has the shorter one. + if (!source.UniformLocationsAliasSameUniform(sourceBase, sourceLocation) || + !destination.UniformLocationsAliasSameUniform(destinationBase, destinationLocation)) { + break; + } + + const Bool sourceOpaque = source.IsUniformOpaqueAtLocation(sourceLocation); + if (sourceOpaque != destination.IsUniformOpaqueAtLocation(destinationLocation)) break; + if (sourceOpaque) { + // A sampler/image unit is phase-A state, not UBO bytes. The setter + // itself is a no-op when the value already matches, so this does not + // churn the composite's backend state version. + destination.SetUniformSamplerOrImageUnitIndex( + destinationLocation, source.GetUniformSamplerOrImageUnitIndex(sourceLocation)); + continue; + } + + const SizeT span = source.GetUniformStorageSpanInBytes(sourceLocation); + if (span == 0 || span != destination.GetUniformStorageSpanInBytes(destinationLocation)) continue; + const Uint sourceOffset = source.GetUniformOffset(sourceLocation); + const Uint destinationOffset = destination.GetUniformOffset(destinationLocation); + // Either side can legitimately lack backing storage: the optimizer deletes a + // uniform nothing reads, and a program whose SPIR-V phase settled cancelled + // has no shadow at all. Both report kInvalidUniformOffset / a null shadow. + if (sourceUbo == nullptr || destinationUbo == nullptr || + sourceOffset == ProgramObject::kInvalidUniformOffset || + destinationOffset == ProgramObject::kInvalidUniformOffset || + sourceOffset + span > sourceUboSize || destinationOffset + span > destinationUboSize) { + continue; + } + if (std::memcmp(destinationUbo + destinationOffset, sourceUbo + sourceOffset, span) == 0) { + continue; + } + Memcpy(destinationUbo + destinationOffset, sourceUbo + sourceOffset, span); + destination.MarkUBOContentDirty(); + } + } + } + + // The other half of "the composite is a different program object": interface BLOCK + // bindings. glUniformBlockBinding and glShaderStorageBlockBinding place a block on a + // binding point, and they do it per program - so a pipeline whose blocks were placed + // that way drew against the composite's own bindings, which come from the shader + // declarations alone. A block declared without any layout(binding) therefore sat on + // whatever the declaration implied while the application's buffers sat somewhere else, + // and nothing anywhere raised an error: the draw simply read or wrote the wrong place. + // + // Both sides seed these from the same shader declarations at link, so mirroring a block + // the application never rebound writes back the value the destination already holds and + // the setters' equality checks make it free. + static void MirrorBlockBindings(const ProgramObject& source, ProgramObject& destination) { + // Storage blocks are keyed by GL name on both sides - the one coordinate the + // frontend, SPIR-V and driver index spaces all agree on - so this is a direct + // replay. Empty for the overwhelming majority of programs. + for (const auto& [blockName, binding] : source.GetShaderStorageBlockBindingOverrides()) { + if (binding < 0) continue; + destination.SetShaderStorageBlockBinding(blockName, static_cast(binding)); + } + + // Uniform blocks are keyed by index, and the two programs number them + // independently, so they are matched by name. + const Int sourceBlockCount = source.GetActiveUniformBlocksCount(); + for (Int sourceIndex = 0; sourceIndex < sourceBlockCount; ++sourceIndex) { + const Int binding = static_cast(source.GetUniformBlockBinding(static_cast(sourceIndex))); + // -1 is "no declared binding and never rebound" - there is nothing to carry, + // and forwarding it would land as binding 0xFFFFFFFF. + if (binding < 0) continue; + const String& blockName = source.GetUniformBlockName(static_cast(sourceIndex)); + if (blockName.empty()) continue; + const Uint destinationIndex = destination.GetUniformBlockIndex(blockName.c_str()); + if (destinationIndex == 0xFFFFFFFFu) continue; // GL_INVALID_INDEX + destination.SetUniformBlockBinding(destinationIndex, static_cast(binding)); + } + } + + // Brings the pipeline's composite up to date with the per-program state its stage + // programs hold and it does not: uniform values, and interface block bindings. Runs on + // every draw through a pipeline, so the common case is the version compare below and + // nothing else. + static void RefreshCompositeUniforms(ProgramPipelineObject& pipeline, const SharedPtr& composite) { + if (!composite) return; + const auto versions = pipeline.ComputeUniformMirrorVersions(); + if (versions == pipeline.GetMirroredUniformVersions()) return; + + // A program bound to two stages appears twice; mirroring it twice would be + // idempotent but is still work, and the second pass would have nothing to do. + Array mirrored{}; + SizeT mirroredCount = 0; + for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) { + const auto& stageProgram = pipeline.GetStageProgram(static_cast(stage)); + if (!stageProgram) continue; + Bool alreadyMirrored = false; + for (SizeT i = 0; i < mirroredCount; ++i) { + if (mirrored[i] == stageProgram.get()) { + alreadyMirrored = true; + break; + } + } + if (alreadyMirrored) continue; + mirrored[mirroredCount++] = stageProgram.get(); + MirrorUniformValues(*stageProgram, *composite); + MirrorBlockBindings(*stageProgram, *composite); + } + pipeline.SetMirroredUniformVersions(versions); + } + const SharedPtr& GLContext::GetProgramForDraw() { static const SharedPtr nullProgram = nullptr; const auto& currentProgram = m_programState.GetCurrentProgram(); @@ -395,20 +603,23 @@ namespace MobileGL::MG_State { if (!pipeline) return nullProgram; // P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each - // stage program's lifetimeId and backendStateVersion - NON-artifact fields, so - // they do not pass through ProgramObject's join gate and a pending link would - // stay pending right through the signature. Since the version is bumped both at - // enqueue and at publish, the signature computed inside a pending window is one - // that will never be produced again: every draw would miss the cache and rebuild - // (and relink) the composite. Join first, so the signature describes settled - // programs. In steady state this is a null check per stage. - for (SizeT stage = 0; stage < static_cast(ShaderStage::ShaderStageCount); ++stage) { + // stage program's lifetimeId and linkVersion - NON-artifact fields, so they do not + // pass through ProgramObject's join gate and a pending link would stay pending + // right through the signature. Since the version is bumped both at enqueue and at + // publish, the signature computed inside a pending window is one that will never + // be produced again: every draw would miss the cache and rebuild (and relink) the + // composite. Join first, so the signature describes settled programs. In steady + // state this is a null check per stage. + for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) { const auto& stageProgram = pipeline->GetStageProgram(static_cast(stage)); if (stageProgram) stageProgram->JoinLinkAndSpirv(); } const auto signature = pipeline->ComputeDrawProgramSignature(); - if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) return cached; + if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) { + RefreshCompositeUniforms(*pipeline, cached); + return cached; + } // Everything downstream of here - the backends, the uniform plumbing, the draw // validation - is written against a single linked program, so the pipeline is @@ -420,8 +631,14 @@ namespace MobileGL::MG_State { // could otherwise be handed. Backend registries key on the object, not the name. auto composite = MakeShared(0u); + // GRAPHICS stages only. A pipeline may carry a compute stage alongside them (GL + // 4.6 core 7.4 forbids linking compute WITH another stage into one program, not + // attaching a compute program to a pipeline that also has graphics ones), and that + // stage belongs to glDispatchCompute, not to this draw. Compositing it in produced + // a graphics program carrying a compute module, which Adreno 830 does not reject + // from vkCreateGraphicsPipelines - it SIGSEGVs inside it. Bool anyStage = false; - for (SizeT stage = 0; stage < static_cast(ShaderStage::ShaderStageCount); ++stage) { + for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) { const auto& stageProgram = pipeline->GetStageProgram(static_cast(stage)); if (!stageProgram) continue; for (const auto& shader : stageProgram->GetAttachedShaders()) { @@ -440,7 +657,32 @@ namespace MobileGL::MG_State { // for the same reason: the backend is about to read its SPIR-V. composite->JoinLinkAndSpirv(); pipeline->SetCachedDrawProgram(signature, Move(composite)); - return pipeline->GetCachedDrawProgram(signature); + const auto& cached = pipeline->GetCachedDrawProgram(signature); + RefreshCompositeUniforms(*pipeline, cached); + return cached; + } + + const SharedPtr& GLContext::GetProgramForDispatch() { + static const SharedPtr nullProgram = nullptr; + const auto& currentProgram = m_programState.GetCurrentProgram(); + if (currentProgram) { + // Same join contract as GetProgramForDraw's glUseProgram half - see the note + // there. A dispatch reads the same non-artifact versions a draw does. + currentProgram->JoinLinkAndSpirv(); + return currentProgram; + } + if (m_boundProgramPipeline == 0) return nullProgram; + const auto& pipeline = GetBoundProgramPipeline(); + if (!pipeline) return nullProgram; + // No compositing and no cache: GL 4.6 core 7.4 makes a compute program exclusive of + // every other stage, so the pipeline's compute stage program IS the program to + // dispatch, uniforms and all. That also means glUniform* through the active program + // lands on the very object the dispatch reads - the composite's uniform refresh has + // no counterpart to do here. + const auto& computeProgram = pipeline->GetStageProgram(ShaderStage::Compute); + if (!computeProgram) return nullProgram; + computeProgram->JoinLinkAndSpirv(); + return computeProgram; } const SharedPtr& GLContext::GetProgramForUniform() { @@ -919,31 +1161,60 @@ namespace MobileGL::MG_State { // Program pipeline void GLContext::GenProgramPipelineNames(Uint number, Vector& pipelines) { pipelines.resize(number); - // Names only: glIsProgramPipeline must answer GL_FALSE until one is bound or created. + // Names only. The OBJECT appears as soon as a command needs somewhere to put state + // (see MaterializeProgramPipelineObject), but glIsProgramPipeline still answers + // GL_FALSE until the name is bound or created - see IsProgramPipelineObject. m_programPipelineNames.Generate(number, pipelines.data()); } void GLContext::CreateProgramPipelineObject(Uint index) { - m_programPipelines[index] = MakeShared(index); + const auto object = MakeShared(index); + // glCreateProgramPipelines makes the object outright, so it answers + // glIsProgramPipeline immediately - unlike a name that only got here through + // GenProgramPipelines plus a command that materialized it. + object->MarkEverBound(); + m_programPipelines[index] = object; } Bool GLContext::ValidateProgramPipelineName(Uint index) const { return index == 0 || m_programPipelineNames.IsValid(index); } + // glIsProgramPipeline. Materialization is NOT the test: the object now appears as soon + // as any command takes state from a reserved name, and two of those commands are the + // pure queries glGetProgramPipelineiv / glGetProgramPipelineInfoLog - so keying this on + // map membership would let merely READING a gen'd name turn it into an object. GL 4.6 + // core 7.4 gives the real rule: a GenProgramPipelines name acquires program pipeline + // state when it is first bound. Same shape as IsTransformFeedbackObject. Bool GLContext::IsProgramPipelineObject(Uint index) const { if (index == 0 || !m_programPipelineNames.IsValid(index)) return false; - return m_programPipelines.find(index) != m_programPipelines.end(); + const auto it = m_programPipelines.find(index); + return it != m_programPipelines.end() && it->second && it->second->GetEverBound(); } void GLContext::BindProgramPipelineObject(Uint index) { - if (index != 0 && m_programPipelines.find(index) == m_programPipelines.end()) { - // First bind is what turns a reserved name into an object. - m_programPipelines[index] = MakeShared(index); + if (index != 0) { + if (const auto& object = MaterializeProgramPipelineObject(index)) { + object->MarkEverBound(); + } } m_boundProgramPipeline = index; } + // Binding is not the only thing that turns a reserved name into an object. GL 4.6 core + // 7.4 asks of UseProgramStages, ActiveShaderProgram and ValidateProgramPipeline only that + // the name came from GenProgramPipelines and has not been deleted - so a name that was + // reserved and never bound must take state from them, not be rejected. glIsProgramPipeline + // is the one place the distinction survives (it answers FALSE until the name is used), + // which is why IsProgramPipelineObject stays as it is. + const SharedPtr& GLContext::MaterializeProgramPipelineObject(Uint index) { + static const SharedPtr kNone; + if (index == 0 || !m_programPipelineNames.IsValid(index)) return kNone; + const auto it = m_programPipelines.find(index); + if (it != m_programPipelines.end()) return it->second; + return m_programPipelines[index] = MakeShared(index); + } + void GLContext::MarkProgramPipelineForDeletion(Uint index) { if (index == 0 || !m_programPipelineNames.IsValid(index)) return; if (index == m_boundProgramPipeline) { diff --git a/MobileGL/MG_State/GLState/Core.h b/MobileGL/MG_State/GLState/Core.h index dc7e72f9..1d26519a 100644 --- a/MobileGL/MG_State/GLState/Core.h +++ b/MobileGL/MG_State/GLState/Core.h @@ -163,21 +163,31 @@ namespace MobileGL { } void UseProgram(Uint program); const SharedPtr& GetCurrentProgram(); - // What a draw or dispatch actually executes: the program in use, or - when - // there is none - the bound pipeline's stages composited into one program. + // What a DRAW executes: the program in use, or - when there is none - the bound + // pipeline's GRAPHICS stages composited into one program. A pipeline's compute + // stage is never part of that composite; ask GetProgramForDispatch for it. const SharedPtr& GetProgramForDraw(); + // What a DISPATCH executes: the program in use, or - when there is none - the + // bound pipeline's compute stage program itself. GL's compute stage is a whole + // program on its own (GL 4.6 core 7.4: it may not be linked with any other + // stage), so there is nothing to composite and no composite to cache. + const SharedPtr& GetProgramForDispatch(); // What glUniform* addresses: the program in use, or the bound pipeline's // active program (GL 4.6 core 7.6.1). const SharedPtr& GetProgramForUniform(); // Program pipeline (GL_ARB_separate_shader_objects, GL 4.6 core 7.4). Like queries // and transform feedbacks, glGenProgramPipelines only RESERVES a name - the object - // appears on first bind - while glCreateProgramPipelines makes it immediately. + // appears on first USE (any of bind, UseProgramStages, ActiveShaderProgram, + // ValidateProgramPipeline) - while glCreateProgramPipelines makes it immediately. void GenProgramPipelineNames(Uint number, Vector& pipelines); void CreateProgramPipelineObject(Uint index); Bool ValidateProgramPipelineName(Uint index) const; Bool IsProgramPipelineObject(Uint index) const; void BindProgramPipelineObject(Uint index); + // Materializes a reserved name; returns null for 0 or a name that is not a live + // GenProgramPipelines name. + const SharedPtr& MaterializeProgramPipelineObject(Uint index); void MarkProgramPipelineForDeletion(Uint index); const SharedPtr& GetProgramPipelineObject(Uint index) const; Uint GetBoundProgramPipelineName() const { return m_boundProgramPipeline; } @@ -447,8 +457,10 @@ namespace MobileGL { UnorderedMap m_transformFeedbackObjects; IndexGenerator m_transformFeedbackNames; Uint m_boundTransformFeedback = 0; - // Map membership IS object existence here: a pipeline has no stateful default - // object 0, so no everBound flag is needed. + // Map membership is object EXISTENCE, which is not the same as the answer + // glIsProgramPipeline gives: any command that needs somewhere to put state + // materializes a reserved name, so the object can exist well before it is + // bound. ProgramPipelineObject::everBound carries the Is* answer. UnorderedMap> m_programPipelines; IndexGenerator m_programPipelineNames; Uint m_boundProgramPipeline = 0; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp index f20cc3d5..f90f79b6 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -41,6 +41,28 @@ namespace { return bracket == MobileGL::String::npos ? name : name.substr(0, bracket); } + // Element index of an arrayed interface-block instance: "GOKU[3]" -> 3, "GOKU" -> 0. + // Reflection spells arrayed instances exactly this way (glslang expands the instance + // array into one TObjectReflection per element), and the subscript it writes is a plain + // decimal, so a strict-decimal parse is both sufficient and the same rule GL 4.6 + // 7.3.1.1 puts on the name a program-resource query may use. + static MobileGL::Int BlockArrayElement(const MobileGL::String& name) { + if (name.empty() || name.back() != ']') return 0; + const MobileGL::SizeT bracket = name.rfind('['); + if (bracket == MobileGL::String::npos) return 0; + const MobileGL::SizeT first = bracket + 1; + const MobileGL::SizeT last = name.length() - 1; + if (first >= last) return 0; + if (name[first] == '0' && last - first > 1) return 0; // no leading zeros + MobileGL::Int element = 0; + for (MobileGL::SizeT i = first; i < last; ++i) { + if (name[i] < '0' || name[i] > '9') return 0; + element = element * 10 + static_cast(name[i] - '0'); + if (element > 0x0FFFFFFF) return 0; + } + return element; + } + static bool IsBuiltInPipelineOutput(const glslang::TObjectReflection& output) { const auto* type = output.getType(); return type && type->getQualifier().builtIn != glslang::EbvNone; @@ -97,39 +119,6 @@ namespace { return std::max(1, uniform.size); } - static bool ComputeShaderDeclaresLocalSize(const MobileGL::String& source) { - bool inLineComment = false; - bool inBlockComment = false; - for (MobileGL::SizeT i = 0; i < source.length(); ++i) { - if (inLineComment) { - inLineComment = source[i] != '\n'; - continue; - } - if (inBlockComment) { - if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') { - inBlockComment = false; - ++i; - } - continue; - } - if (source[i] == '/' && i + 1 < source.length()) { - if (source[i + 1] == '/') { - inLineComment = true; - ++i; - continue; - } - if (source[i + 1] == '*') { - inBlockComment = true; - ++i; - continue; - } - } - if (source.compare(i, 11, "local_size_") == 0) { - return true; - } - } - return false; - } } // namespace namespace MobileGL::MG_State::GLState { @@ -301,6 +290,29 @@ namespace MobileGL::MG_State::GLState { Vector> shaders; if (!ConsumeShaders(shaders)) return; + // Harvest the declared default-block uniform initializers before the TShaders are + // handed to the linker. They come from the parse itself (glslang folds the constant + // and hands it over instead of dropping it), not from a lexical scan, so an + // expression like vec3(10, 20, 30) or int[](1, 2, 3) is already evaluated. + // + // Stage order decides a tie. GLSL requires a uniform declared in several stages to be + // declared identically, initializer included, so a conflict is a malformed program; + // taking the first stage's value keeps a link that other implementations accept from + // failing here, and both stages agree in every well-formed one. + for (const auto& shader : shaders) { + const glslang::TIntermediate* intermediate = shader ? shader->getIntermediate() : nullptr; + if (intermediate == nullptr) continue; + for (const auto& initializer : intermediate->getUniformInitializers()) { + const auto known = std::find_if(artifacts.uniformInitialValues.begin(), + artifacts.uniformInitialValues.end(), + [&initializer](const auto& existing) { + return existing.name == initializer.name; + }); + if (known != artifacts.uniformInitialValues.end()) continue; + artifacts.uniformInitialValues.push_back(initializer); + } + } + // Merge the shaders' lexically extracted explicit uniform locations. The same // uniform declared in several stages must agree on its location (config-A glslang // enforced this at mapIO; the relaxed parse no longer sees the qualifiers). @@ -347,6 +359,31 @@ namespace MobileGL::MG_State::GLState { return; } + // A compute program must have a fixed local group size, and GL states that as a + // property of the PROGRAM: "at least one" of its compute shaders declares it (GL 4.6 + // core 7.13 / GLSL 4.30 4.4.1.4). MobileGL used to answer that question per SHADER, + // by scanning each source for the text "local_size_" - which rejected the perfectly + // legal shape KHR-GL42.compute_shader.build-monolithic submits, three compilation + // units of which only two carry the layout and the third holds nothing but a buffer + // block and a function. It also could not see a local size that arrived through a + // macro, and it happily accepted the substring inside an unrelated identifier. + // + // glslang already merged the units' modes at link (linkValidate.cpp mergeModes, which + // also diagnoses two units declaring CONTRADICTORY sizes), so the linked + // intermediate is the thing that knows - and asking it is both correct and free. + if (const glslang::TIntermediate* cs = artifacts.program->getIntermediate(EShLangCompute); + cs != nullptr && !cs->isLocalSizeSet()) { + artifacts.linkStatus = false; + // The gate this replaced ran before LinkProgram, so a program that failed it + // published no TProgram at all. Keep that invariant: everything downstream reads + // artifacts.program as "the linked program", and a rejected link should not leave + // one behind for a query surface to find. + artifacts.program.reset(); + artifacts.infoLog = "Compute shader is missing a local_size layout declaration."; + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + return; + } + // GL_GEOMETRY_INPUT_TYPE. A draw's primitive type has to be compatible with it // (GL 4.6 core 11.3.1), so it is resolved for every link, not only a capturing one. artifacts.gsInputPrimitive = GL_NONE; @@ -446,6 +483,23 @@ namespace MobileGL::MG_State::GLState { Bool ProgramLinkTask::ConsumeShaders(Vector>& outShaders) { outShaders.assign(in.shaders.size(), nullptr); + // GL 4.6 core 7.3: a compute shader may only be linked with other compute shaders - + // the compute pipeline has no other stages to link against, so a program that mixes + // them must fail to link (KHR-GL43.compute_shader.api-program). + { + Bool hasCompute = false; + Bool hasNonCompute = false; + for (const LinkShaderInput& input : in.shaders) { + (input.stage == ShaderStage::Compute ? hasCompute : hasNonCompute) = true; + } + if (hasCompute && hasNonCompute) { + artifacts.infoLog = + "A compute shader cannot be linked with shaders of any other stage."; + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + return false; + } + } + for (SizeT i = 0; i < in.shaders.size(); i++) { const LinkShaderInput& input = in.shaders[i]; const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage); @@ -470,13 +524,6 @@ namespace MobileGL::MG_State::GLState { in.externalIndex, i, artifacts.infoLog)); return false; } - if (input.stage == ShaderStage::Compute && - !ComputeShaderDeclaresLocalSize(input.source ? *input.source : String())) { - artifacts.infoLog = "Compute shader is missing a local_size layout declaration."; - DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); - return false; - } - String reparseLog; outShaders[i] = input.compiled->ClaimParsedShader(reparseLog); if (!outShaders[i]) { @@ -881,8 +928,21 @@ namespace MobileGL::MG_State::GLState { std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length()); artifacts.uniformBlockIndexByName[ubo.name] = i; // if there's binding defined in shader as layout(binding = ...), - // retrieve it here - artifacts.uniformBlockBinding[i] = ubo.getBinding(); + // retrieve it here. + // + // An instance array takes CONSECUTIVE binding points: "layout(binding = 2) + // uniform GOKU {...} goku[14];" puts goku[0] on 2 and goku[13] on 15 (GL 4.6 + // 7.6.2 / GLSL 4.20 4.4.5). glslang expands the array into one reflection + // record per element but hands every one of them the DECLARED binding, because + // they all share the block's TType - so the element offset has to be added + // here. Without it every element reported the base binding, and since both + // backends feed a block from GetUniformBlockBinding() at draw time + // (DirectGLES.cpp / UniformManager.cpp), all 14 elements also read the same + // buffer. This is the rule the storage-block path in ProgramInterface.cpp + // already applies, and whose comment there claims uniform blocks follow. + const Int declaredBinding = ubo.getBinding(); + artifacts.uniformBlockBinding[i] = + declaredBinding < 0 ? declaredBinding : declaredBinding + BlockArrayElement(ubo.name); MGLOG_D("ProgramObject %u: Reflection - UBO[%d] name='%s' size=%u binding=%d", in.externalIndex, i, ubo.name.c_str(), ubo.size, ubo.getBinding()); } @@ -1030,21 +1090,80 @@ namespace MobileGL::MG_State::GLState { } } } + // GL 4.6 core 11.1.2.1 (and the resource-name rule of 7.3.1.1): a member of + // an output interface block is named "." - the block's + // TYPE name, never the instance name, and that holds for an anonymous + // instance too. glslang's linker object for such a block is the *instance* + // symbol ("vs_out", or "anon@N" when there is none), so the head of the + // dotted path has to be matched against getType().getTypeName() instead of + // getName(). Without this every capture of a block member resolved to + // nothing and the link failed with "is not an output of the vertex stage". + String blockName; + String memberName; + if (const SizeT dot = declaredName.find('.'); dot != String::npos) { + blockName = declaredName.substr(0, dot); + memberName = declaredName.substr(dot + 1); + // An array of block instances is spelled "[i]."; every + // instance shares one member list, so the subscript only has to go. + if (!blockName.empty() && blockName.back() == ']') { + const SizeT bracket = blockName.rfind('['); + if (bracket != String::npos) blockName.resize(bracket); + } + } + for (const auto* node : linkerObjects->getSequence()) { const glslang::TIntermSymbol* symbol = node->getAsSymbolNode(); if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) { continue; } - if (symbol->getName() != declaredName.c_str()) { - continue; + const glslang::TType& symbolType = symbol->getType(); + const glslang::TType* capturedType = nullptr; + if (memberName.empty()) { + if (symbol->getName() != declaredName.c_str()) { + continue; + } + capturedType = &symbolType; + } else { + if (symbolType.getBasicType() != glslang::EbtBlock) { + continue; + } + // The spec spelling is the block name; the instance name is accepted + // as a fallback so a request written the (common, non-conformant) + // instance-qualified way resolves instead of failing the whole link. + if (symbolType.getTypeName() != blockName.c_str() && + symbol->getName() != blockName.c_str()) { + continue; + } + const glslang::TTypeList* members = symbolType.getStruct(); + if (members == nullptr) { + continue; + } + for (SizeT m = 0; m < members->size(); ++m) { + const glslang::TType* memberType = (*members)[m].type; + if (memberType == nullptr || memberType->getFieldName() != memberName.c_str()) { + continue; + } + capturedType = memberType; + varying.blockMemberIndex = static_cast(m); + break; + } + if (capturedType == nullptr) { + // Right block, wrong member: no other linker object can match. + break; + } + varying.blockName = symbolType.getTypeName().c_str(); + varying.blockInstanceName = symbol->getName().c_str(); } - resolved = ResolveXfbSymbolType(symbol->getType(), varying.type, varying.size, bytesPerElement); + resolved = ResolveXfbSymbolType(*capturedType, varying.type, varying.size, bytesPerElement); if (resolved && singleElement) { if (static_cast(element) >= varying.size) { resolved = false; break; } varying.size = 1; + if (varying.blockMemberIndex >= 0) { + varying.blockMemberElement = static_cast(element); + } } break; } diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index 4a54eda9..41f81b6f 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -90,8 +90,11 @@ namespace MobileGL::MG_State::GLState { // A node that settled as Cancelled published nothing, so m_spirv stays empty with // spirvStatus false: linked, queryable, not drawable. Nothing to repair. - // Before the version bump, and before any caller can read the shadow: the writes the - // application made while the layout did not exist yet. + // Order matters, and it is the GL order. The shadow arrives zero-filled; the shaders' + // declared uniform initializers are what it should actually start from, and only then + // do the application's own writes - the ones it made while the layout did not exist + // yet - land on top. Seeding after the replay would clobber them. + ApplyUniformInitialValues(); ReplayBufferedUniformWrites(); // The THIRD version bump of this link (enqueue, phase-A publish, phase-B publish), and @@ -126,6 +129,93 @@ namespace MobileGL::MG_State::GLState { return true; } + // "uniform vec3 v = vec3(10, 20, 30);" - legal desktop GLSL since 1.20, and the value is + // what the uniform reads until glUniform* replaces it (and again after every relink). + // MobileGL parses with Vulkan-relaxed rules, which sweep default-block uniforms into + // MGL_GLOBAL_UBO; a block member cannot carry an initializer in SPIR-V, so glslang hands + // the folded constants over as a side-channel (TIntermediate::getUniformInitializers) and + // this is where they are honoured. Without it every such uniform silently read zero - + // which is what half of KHR-GL43.shader_storage_buffer_object was actually failing on. + // + // Writes go straight into the shadow rather than through glUniform*: this runs INSIDE the + // phase-B publish, so re-entering the join gate is not available, and the location space + // reflection assigns (one location per array element) is all that is needed. + void ProgramObject::ApplyUniformInitialValues() const { + // Through the phase-A gate, not off m_artifacts directly: phase B can be joined by a + // caller that has not read anything phase A publishes yet, and reading the raw field + // there would find the PREVIOUS link's block (or an empty one) and drop every + // initializer without a trace. Artifacts() is a no-op once phase A is in. + const auto& initializers = Artifacts().uniformInitialValues; + if (initializers.empty()) return; + if (m_spirv.globalUboScratch.empty() || m_spirv.uniformOffsets.empty()) { + // Phase B published no shadow (cancelled, or superseded by a relink). The program + // is not drawable; there is nowhere for these to land. + return; + } + + Uint8* const scratch = m_spirv.globalUboScratch.data(); + const SizeT uboSize = m_spirv.globalUboScratch.size(); + + for (const auto& init : initializers) { + // Scalars per array ELEMENT. A matrix element carries cols * rows of them, laid + // out column by column - which is also the order glslang folded them in. + const Int columns = init.matrixCols; + const Int rows = init.matrixRows; + const Int componentsPerElement = columns > 0 ? columns * rows : init.vectorSize; + const Int elements = init.arraySize; + if (componentsPerElement <= 0 || elements <= 0) continue; + + const Bool isFloat = init.basicType == glslang::EbtFloat || init.basicType == glslang::EbtFloat16; + const Bool isInt = init.basicType == glslang::EbtInt || init.basicType == glslang::EbtUint || + init.basicType == glslang::EbtBool; + // Anything else (fp64, 64-bit integers) has no 32-bit shadow encoding here, and a + // half-written uniform is worse than an untouched one. + if (!isFloat && !isInt) continue; + const SizeT provided = isFloat ? init.floatValues.size() : init.intValues.size(); + if (provided < static_cast(componentsPerElement) * static_cast(elements)) continue; + + const Int baseLocation = GetUniformLocation(init.name); + if (baseLocation < 0) continue; // optimized away, or not a default-block uniform + + for (Int element = 0; element < elements; ++element) { + const Int location = baseLocation + element; + if (element > 0 && !UniformLocationsAliasSameUniform(baseLocation, location)) break; + if (!IsValidUniformLocation(location)) break; + const Uint offset = GetUniformOffset(static_cast(location)); + if (offset == kInvalidUniformOffset) continue; + + // std140 pads every column of a float matrix out to a vec4, so the columns of + // a mat3 are 16 bytes apart even though each carries 12. The slot's own span + // states the stride the rest of the pipeline agreed on rather than guessing it. + const SizeT slotSpan = GetUniformStorageSpanInBytes(static_cast(location)); + const SizeT columnStride = + columns > 0 ? slotSpan / static_cast(columns) : slotSpan; + const Int componentsPerColumn = columns > 0 ? rows : componentsPerElement; + const Int columnCount = columns > 0 ? columns : 1; + + for (Int column = 0; column < columnCount; ++column) { + const SizeT byteOffset = static_cast(offset) + static_cast(column) * columnStride; + const SizeT writeSize = static_cast(componentsPerColumn) * sizeof(Uint32); + if (byteOffset + writeSize > uboSize) break; + const SizeT firstComponent = static_cast(element) * componentsPerElement + + static_cast(column) * componentsPerColumn; + for (Int component = 0; component < componentsPerColumn; ++component) { + const SizeT source = firstComponent + static_cast(component); + Uint8* const destination = scratch + byteOffset + component * sizeof(Uint32); + if (isFloat) { + const Float value = static_cast(init.floatValues[source]); + std::memcpy(destination, &value, sizeof(value)); + } else { + const Int32 value = static_cast(init.intValues[source]); + std::memcpy(destination, &value, sizeof(value)); + } + } + } + } + } + MarkUBOContentDirty(); + } + void ProgramObject::ReplayBufferedUniformWrites() const { if (m_pendingUniformWrites.empty()) { m_pendingUniformBytes.clear(); @@ -234,7 +324,13 @@ namespace MobileGL::MG_State::GLState { artifacts.glBlockIndexToTProgram.clear(); artifacts.tProgramBlockIndexToGl.clear(); artifacts.linkedExplicitUniformLocations.clear(); + artifacts.uniformInitialValues.clear(); artifacts.uniformIndexInTProgram.clear(); + // GL resets every uniform to its initial value at link, so nothing is "written since + // link" any more - and the locations these bits index no longer mean anything either. + artifacts.writtenUniformLocationBits.clear(); + artifacts.writtenUniformIndexBits.clear(); + artifacts.writtenUniformIndices.clear(); artifacts.uniformSamplerOrImageUnitIndex.clear(); artifacts.explicitOpaqueUniformBindings.clear(); artifacts.uniformBlockIndexByName.clear(); diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index a4a03cea..da40d644 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -326,6 +326,113 @@ namespace MobileGL::MG_State::GLState { : kInvalidUniformOffset; } Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); } + // Bytes a uniform actually occupies in the global UBO, which is not its GL type size: + // std140 pads each column of a float matrix out to a vec4, so a mat3 spans 48 bytes + // even though only 36 of them carry components. Anything reading or writing a whole + // uniform's storage - a bounds check, a copy between two programs' shadows - wants + // this rather than GetUniformSizesInBytes. + static SizeT UniformStorageSpanInBytes(const glslang::TType* type, SizeT tightSize) { + if (type != nullptr && type->isMatrix() && type->getBasicType() != glslang::EbtDouble) { + return static_cast(type->getMatrixCols()) * 4 * sizeof(Float); + } + return tightSize; + } + SizeT GetUniformStorageSpanInBytes(Uint location) const { + return UniformStorageSpanInBytes(GetUniformTType(location), GetUniformSizesInBytes(location)); + } + + // ---- "written since link": the per-location dirty set the pipeline composite mirrors from ---- + // + // A pipeline's stage programs each own their uniform storage, but the composite the draw + // goes through has ONE slot per name. Mirroring every active uniform of every stage + // therefore lets the last stage that merely DECLARES a name overwrite the value an + // earlier stage was actually written with - the shared-header idiom (the same + // `uniform mat4 u_mvp` in the VS and the FS) rendered nothing because of it. Recording + // which locations an application has written is what lets the mirror carry only those. + // + // WHO PAYS: only a program that could ever be a pipeline stage, decided by the latch + // below. glUseProgram's uniform path - thousands of calls per frame in Minecraft - pays + // one predictable bool branch and nothing else. + // + // GRANULARITY is per LOCATION, not per name: glUniform*v writes array elements at + // element locations, and a program that wrote `arr[3]` and nothing else must mirror + // exactly that element. The compact index list beside it is what keeps the mirror + // O(uniforms actually written) instead of O(active uniforms) - it is the set of GL + // active-uniform indices owning at least one written location, so the mirror does its + // two name lookups once per written uniform rather than once per uniform in the program. + // + // NOT counted as a write: the declared initializers ProgramLinkTask seeds at link + // (ApplyUniformInitialValues). They are a property of the SHADERS, and the composite + // links the very same shader objects, so it seeds itself with the identical values - + // there is nothing to carry. Counting them would also re-introduce the bug this set + // exists to fix, by letting a stage that only declares `uniform float f = 0.0;` clobber + // the value the application wrote for `f` in another stage. + Bool TracksUniformWrites() const { return m_tracksUniformWrites; } + + // Generation of the write SET itself, as distinct from the values in it. The refresh + // gate (ProgramPipelineObject::ComputeUniformMirrorVersions) is otherwise built out of + // counters that only move when BYTES move - and a write can enlarge the set without + // moving a byte, because both write funnels drop a value-identical write before + // bumping anything. glProgramUniform1f(fs, f, 0.0f) on an `f` that already reads 0.0 + // is exactly that: it makes the FRAGMENT stage the last written-to stage for `f`, so + // the composite must be re-mirrored to hand it the slot, and nothing else in the gate + // would have noticed. + Uint32 GetUniformWriteSetVersion() const { return m_uniformWriteSetVersion; } + + // Records that `location` has been written since the last link. Cheap and idempotent; + // a no-op on a program that can never be a pipeline stage. + void MarkUniformWrittenAtLocation(Uint location) { + if (!m_tracksUniformWrites) return; + LinkArtifacts& artifacts = Artifacts(); + if (!IsValidUniformLocation(artifacts, static_cast(location))) return; + + // Sized to cover this location AND the whole location space, so a program whose + // highest location is written first does not reallocate on every later write, and + // so the subscript below needs no second guard: the vector provably contains it. + const SizeT locationWord = location / 64u; + if (locationWord >= artifacts.writtenUniformLocationBits.size()) { + artifacts.writtenUniformLocationBits.resize( + std::max(locationWord + 1u, static_cast(artifacts.maxUniformLocation) / 64u + 1u), + 0u); + } + const Uint64 locationBit = Uint64{1} << (location % 64u); + if ((artifacts.writtenUniformLocationBits[locationWord] & locationBit) == 0) { + artifacts.writtenUniformLocationBits[locationWord] |= locationBit; + // Only on the 0 -> 1 transition: a re-write of a location already in the set + // changes nothing the mirror would do differently, and moving the version for + // it would re-walk the set on every repeated glUniform* call. + ++m_uniformWriteSetVersion; + } + + // Add the owning GL active-uniform index to the compact list, once. + const Int tIndex = artifacts.uniformIndexInTProgram[location]; + if (tIndex < 0 || static_cast(tIndex) >= artifacts.tProgramUniformIndexToGl.size()) return; + const Int glIndex = artifacts.tProgramUniformIndexToGl[tIndex]; + // -1 is a uniform the relaxed parse swept out of the GL-visible index space; the + // mirror enumerates GL indices, so there is nothing it could look such a one up by. + if (glIndex < 0) return; + const SizeT indexWord = static_cast(glIndex) / 64u; + if (indexWord >= artifacts.writtenUniformIndexBits.size()) { + artifacts.writtenUniformIndexBits.resize( + std::max(indexWord + 1u, static_cast(artifacts.activeUniformCount) / 64u + 1u), 0u); + } + const Uint64 indexBit = Uint64{1} << (static_cast(glIndex) % 64u); + if ((artifacts.writtenUniformIndexBits[indexWord] & indexBit) != 0) return; + artifacts.writtenUniformIndexBits[indexWord] |= indexBit; + artifacts.writtenUniformIndices.push_back(static_cast(glIndex)); + } + + Bool IsUniformWrittenAtLocation(Uint location) const { + const auto& bits = Artifacts().writtenUniformLocationBits; + const SizeT locationWord = location / 64u; + return locationWord < bits.size() && + (bits[locationWord] & (Uint64{1} << (location % 64u))) != 0; + } + + // GL active-uniform indices owning at least one written location. Empty for every + // program that has not been written to since its last link - and for every program + // that never asked to be separable, which is what makes the mirror free for them. + const Vector& GetWrittenUniformIndices() const { return Artifacts().writtenUniformIndices; } Int GetAttributeLocation(const String& name) { const auto it = std::find(Artifacts().attribs.begin(), Artifacts().attribs.end(), name); @@ -474,14 +581,35 @@ namespace MobileGL::MG_State::GLState { } void SetUniformSamplerOrImageUnitIndex(Uint location, Int unit) { - if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() || - Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) { - return; - } + if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size()) return; + // BEFORE the equality bail-out, not after: "written" is about the application + // having addressed the uniform, not about the bytes changing. glUniform1i(s, 0) on + // a sampler that already reads 0 still has to beat another stage's untouched + // declaration of the same name in the composite - which is only possible if the + // write is recorded. (The mirror is the only reader, and it runs this same setter + // on the composite, where the latch is off.) + MarkUniformWrittenAtLocation(location); + if (Artifacts().uniformSamplerOrImageUnitIndex[location] == unit) return; Artifacts().uniformSamplerOrImageUnitIndex[location] = unit; ++m_backendStateVersion; + // IMAGE units get their own generation, and it is not redundant with the one + // above. A sampler unit is re-issued to the driver per draw as a plain + // glUniform1i, so a backend can honour a change without rebuilding anything; an + // image unit cannot be, because ES forbids glUniform1i on image uniforms - Espryt + // has to BAKE it into the ESSL it generates (RebindImageUniformsToFrontendUnits), + // which means the change is only honoured by regenerating the program. That + // regeneration is gated on link-shaped versions, so without a counter that moves + // here the new unit would never reach the driver. + if (const glslang::TType* type = GetUniformTType(location); type != nullptr && type->isImage()) { + ++m_imageUnitVersion; + } } + // Generation of the image-uniform unit assignment; see SetUniformSamplerOrImageUnitIndex. + // A backend that compiles the unit into its program source compares this to decide + // whether what it built is still describing the right binding. + Uint32 GetImageUnitVersion() const { return m_imageUnitVersion; } + Int GetUniformSamplerOrImageUnitIndex(Uint location) const { return Artifacts().uniformSamplerOrImageUnitIndex[location]; } @@ -497,7 +625,32 @@ namespace MobileGL::MG_State::GLState { // subset of the stages of a program pipeline. Only takes effect on the next link, // which is why it is plain state here rather than something Link() consults. Bool GetSeparable() const { return m_separable; } - void SetSeparable(Bool separable) { m_separable = separable; } + void SetSeparable(Bool separable) { + m_separable = separable; + // ---- arming the uniform-write tracking latch ---- + // + // The predicate wanted is "this program can ever be a pipeline stage", and + // GetSeparable() is NOT it in either direction. GL_PROGRAM_SEPARABLE takes effect + // at the NEXT link, so it can read true on a program glUseProgramStages would + // still reject; that direction is merely wasteful. The other direction is a + // correctness hole: glProgramParameteri may clear the flag AFTER a separable link, + // and glUseProgramStages tests the state the program was LINKED with, so such a + // program is still a legal stage while GetSeparable() reads false. Tracking driven + // by the live flag would stop recording writes on a program the composite is still + // mirroring from, and those uniforms would silently stop reaching the draw. + // + // "Attached to a pipeline" is not usable either, and for a more basic reason: + // glProgramUniform* legitimately runs before glUseProgramStages, so the marks have + // to already exist by the time the program becomes a stage. + // + // So: a MONOTONE latch, armed the first time GL_PROGRAM_SEPARABLE is requested + // true and never cleared. It over-approximates - a program that was separable once + // keeps paying the bookkeeping - and over-approximating only ever costs a bitset, + // never a wrong value. glCreateShaderProgramv arms it through this same setter. + // A program that never asks (every monolithic glUseProgram program, which is the + // hot uniform path) never arms it and pays one bool branch per glUniform*. + if (separable) m_tracksUniformWrites = true; + } // glProgramBinary always fails here (there is no format it could accept) and the // spec then requires the program's LINK_STATUS to read FALSE. void MarkLinkFailedByProgramBinary() { @@ -516,12 +669,27 @@ namespace MobileGL::MG_State::GLState { Artifacts().infoLog = "No program binary format is supported."; } Bool GetValidateStatus() const { return m_validateStatus; } - Int GetActiveAtomicCounterCount() const { return Artifacts().program->getNumAtomicCounters(); } - Int GetActiveAttributesCount() const { return Artifacts().program->getNumPipeInputs(); } + // Artifacts().program is null until a link produces reflection, and glGetProgramiv is + // perfectly legal on a program that never linked (GL 4.6 sec. 7.3: the queried state is + // simply its initial value, zero). Dereferencing it there took the process down with a + // SIGSEGV inside glslang::TProgram::getNumPipeInputs - KHR-GL30.api.coverage does exactly + // this after a failed glGetAttribLocation, and reached it as soon as the CopyTexImage2D + // throw ahead of it stopped killing the run first. + Int GetActiveAtomicCounterCount() const { + const auto& program = Artifacts().program; + return program ? program->getNumAtomicCounters() : 0; + } + Int GetActiveAttributesCount() const { + const auto& program = Artifacts().program; + return program ? program->getNumPipeInputs() : 0; + } // GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse // materializes for default-block uniforms is filtered out by DoReflection. Int GetActiveUniformBlocksCount() const { return static_cast(Artifacts().glBlockIndexToTProgram.size()); } - GLuint GetComputeLocalSize(Uint dim) const { return Artifacts().program->getLocalSize(static_cast(dim)); } + GLuint GetComputeLocalSize(Uint dim) const { + const auto& program = Artifacts().program; + return program ? program->getLocalSize(static_cast(dim)) : 0; + } Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; } Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; } Uint GetUniformBlockIndex(const char* name) const { @@ -584,13 +752,23 @@ namespace MobileGL::MG_State::GLState { return (ubo.stages & stageMask) != 0; } - // Set by glUniformBlockBinding + // Bumped by both block-binding setters below. A program pipeline's flattened composite + // is a different program object from the stage programs the application rebinds blocks + // on, so it has to be told - and this is what tells it something is worth re-reading. + // Separate from m_backendStateVersion because the storage-block setter deliberately + // does not disturb that one (see SetShaderStorageBlockBinding). + Uint32 GetBlockBindingVersion() const { return m_blockBindingVersion; } + + // Set by glUniformBlockBinding. The vector is seeded at link with each block's DECLARED + // binding (layout(binding=N), else -1), so an untouched program already reports what its + // shaders asked for. void SetUniformBlockBinding(Uint index, Uint binding) { if (index >= Artifacts().uniformBlockBinding.size() || Artifacts().uniformBlockBinding[index] == static_cast(binding)) { return; } Artifacts().uniformBlockBinding[index] = static_cast(binding); ++m_backendStateVersion; + ++m_blockBindingVersion; } Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; } @@ -602,6 +780,10 @@ namespace MobileGL::MG_State::GLState { // means "never rebound", and the shader's declared binding still stands. void SetShaderStorageBlockBinding(const String& blockName, Uint binding) { Artifacts().shaderStorageBlockBinding[blockName] = static_cast(binding); + // Deliberately NOT m_backendStateVersion: Espryt's entry point never forces a + // program build off this, and bumping that version would start doing so. The + // dedicated counter carries the news to the pipeline composite instead. + ++m_blockBindingVersion; } // -1 when the block has never been rebound. `blockName` is the interface-query // spelling; an arrayed block's elements ("B[0]", "B[1]") are separate GL resources @@ -657,6 +839,21 @@ namespace MobileGL::MG_State::GLState { // Offset within the gap-free record a backend that cannot express the GL // layout captures into; see NeedsScatteredTransformFeedbackCapture. Uint32 packedOffsetBytes = 0; + + // GL 4.6 core 11.1.2.1 / 7.3.1.1: a member of an output interface block is + // captured under ".". `name` keeps that GL spelling (it is + // what the interface queries and the ESSL backend's driver-side capture list + // need, since SPIRV-Cross re-emits the block under its own type name), while + // the three fields below carry what a SPIR-V backend needs instead: the + // decoration target is the block's *instance* variable and the member index + // inside it. blockMemberIndex < 0 means "not a block member". + String blockInstanceName; + String blockName; + Int blockMemberIndex = -1; + // Which element of an arrayed block member this capture names, -1 for "the + // member as a whole". SPIR-V cannot decorate a single array element, so a + // backend needs the element index to tell a full run from a partial one. + Int blockMemberElement = -1; }; // ---- P1: everything a link PRODUCES, in one movable block ---- @@ -698,7 +895,24 @@ namespace MobileGL::MG_State::GLState { // layout(location = N) default-block uniform qualifiers (the relaxed parse drops // them from reflection; the DoReflection assigner restores them from here). UnorderedMap linkedExplicitUniformLocations; + // Per-link snapshot of the default-block uniform INITIALIZERS the attached shaders + // declared ("uniform int i = 1;"). Desktop GLSL says that value is what the uniform + // reads until the application overwrites it, and relinking restores it - but the + // relaxed parse turns those uniforms into members of MGL_GLOBAL_UBO, where SPIR-V + // cannot carry an initializer, so the value only survives as this side-channel. + // Applied into the uniform shadow at the phase-B publish (ApplyUniformInitialValues). + Vector uniformInitialValues; UnorderedMap uniformLocations; + // ---- "written since link" (see MarkUniformWrittenAtLocation) ---- + // In LinkArtifacts deliberately: a link is exactly the event that retracts every + // write (GL resets uniforms to their initial values), so living here means the set + // is cleared by the same three paths that clear the rest of a link's output - + // Link()'s whole-struct reset, ResetLinkArtifacts, and the publish's move - and no + // fourth reset site can be forgotten. Empty (and never allocated) for a program + // that never asked to be separable. + Vector writtenUniformLocationBits; + Vector writtenUniformIndexBits; + Vector writtenUniformIndices; // Ordered by location, // aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`" Vector uniformIndexInTProgram; @@ -952,6 +1166,10 @@ namespace MobileGL::MG_State::GLState { // detour exactly - and a record that really does change bytes moves the version, which // is what makes a backend re-upload the UBO it cached during the window. void ReplayBufferedUniformWrites() const; + // Seeds the freshly published uniform shadow with the declared initializers. Runs at + // the phase-B publish, BEFORE ReplayBufferedUniformWrites, so an application write + // made during the A->B window still wins - which is the GL ordering. + void ApplyUniformInitialValues() const; // Past this, BufferUniformWrite declines and the write joins instead. Sized so an // ordinary pack load never reaches it (a pending window is one program's worth of // uniforms) while a pathological writer cannot grow the heap without bound. @@ -1010,11 +1228,22 @@ namespace MobileGL::MG_State::GLState { Bool m_deleteStatus = false; Bool m_binaryRetrievableHint = false; Bool m_separable = false; + // Monotone "this program may ever be a pipeline stage" latch; see SetSeparable for why + // it is a latch and not just m_separable. Outside LinkArtifacts on purpose: a relink + // clears the write SET, but a program that was separable is still separable after it. + Bool m_tracksUniformWrites = false; + // Generation counters that must NOT be reset by a link, for the same reason the memo + // versions above are not: a reader compares them for INEQUALITY, so a reset could make + // a stale cache compare equal to a fresh program. See their getters. + Uint32 m_uniformWriteSetVersion = 0; + Uint32 m_imageUnitVersion = 0; Bool m_validateStatus = true; // Mutable, like m_artifacts and for the same reason: publishing a pending link is a // READ-side operation (the first gated getter is what pulls the result in), and the // publish has to bump these. Still GL-thread-only - a worker never touches them. mutable Uint32 m_backendStateVersion = 0; + // Interface-block binding generation; see GetBlockBindingVersion. + Uint32 m_blockBindingVersion = 0; // Backend-owned content-hash memo (see GetBackendHashMemo): valid only while // m_backendStateVersion matches. Several slots, not one: a backend may resolve the same diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h index 165de4e9..981b55ac 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h @@ -40,25 +40,112 @@ namespace MobileGL { Uint GetExternalIndex() const { return m_externalIndex; } + // glIsProgramPipeline's answer, and NOT the same question as "does this object + // exist" (GL 4.6 core 7.4: a GenProgramPipelines name "acquires program pipeline + // state only when first bound"). The object is materialized by any of the + // commands that take state from a reserved name - including the pure queries + // glGetProgramPipelineiv and glGetProgramPipelineInfoLog, which have to answer + // out of default state without ever making the name report as an object. So + // existence is map membership and this is a separate latch, exactly as + // TransformFeedbackObject::everBound is. + Bool GetEverBound() const { return m_everBound; } + void MarkEverBound() { m_everBound = true; } + + // The stages a DRAW is built from: every stage but compute. GL 4.6 core 7.4 + // makes the compute stage exclusive - a program object containing a compute + // shader may contain no other stage, and a pipeline's compute stage is + // dispatched on its own and never participates in a draw. So the compute stage + // is not merely irrelevant to the composite below, it must never enter it: a + // compute module handed to vkCreateGraphicsPipelines is a driver crash rather + // than an error return (Adreno 830 SIGSEGVs inside it). + static constexpr SizeT kGraphicsStageCount = static_cast(ShaderStage::Compute); + static_assert(static_cast(ShaderStage::Compute) + 1 == + static_cast(ShaderStage::ShaderStageCount), + "ShaderStage must keep Compute last so the graphics stages are a prefix"); + // A draw sees one program, but a pipeline holds one program per stage. The - // stages are composited into a single hidden program object, rebuilt whenever - // the stage set - or any stage program's own link - changes. The signature is - // what that "changes" means: a stage program's lifetime id pins the object and - // its backend state version pins the link generation. - using DrawProgramSignature = - Array(ShaderStage::ShaderStageCount) * 2>; + // GRAPHICS stages are composited into a single hidden program object, rebuilt + // whenever the stage set - or any stage program's own link - changes. The + // signature is what that "changes" means: a stage program's lifetime id pins the + // object and its LINK version pins the link generation. It covers exactly the + // stages the composite is built from, so attaching or relinking a compute stage + // never invalidates a perfectly good graphics composite - and the compute stage, + // having no composite of its own, can never collide with it. + // + // GetLinkVersion() and NOT GetBackendStateVersion(), which is what this used to + // key on. The backend state version moves on every glUniform1i to a sampler and + // every glUniformBlockBinding, so the "set a sampler unit, draw" loop that the + // SSO conformance cases run threw the composite away and REBUILT it on every + // single draw: a fresh ProgramObject, a full Link(true) settled synchronously + // (glslang + SPIR-V + spirv-opt), a full re-mirror, and a brand-new program + // identity that invalidated both backends' per-program registries and pipeline + // memos along the way. The composite's CONTENT depends on the link generations + // and nothing else, and m_linkVersion is bumped by exactly those + // (BumpLinkObservableVersions). + // + // The prerequisite that makes the narrowing legal: because the composite no + // longer rebuilds when per-program uniform STATE changes, every such change must + // reach it through the refresh below instead. Both do - sampler/image units via + // MirrorUniformValues, interface block bindings via MirrorBlockBindings - and + // the two setters that write them still bump the counters the REFRESH gate reads + // (see ComputeUniformMirrorVersions), which is a separate question from what + // this signature reads. They are the only two writers of m_backendStateVersion + // outside the link paths, so nothing else was ever riding on the rebuild. + using DrawProgramSignature = Array; DrawProgramSignature ComputeDrawProgramSignature() const { DrawProgramSignature signature{}; - for (SizeT stage = 0; stage < static_cast(ShaderStage::ShaderStageCount); ++stage) { + for (SizeT stage = 0; stage < kGraphicsStageCount; ++stage) { const auto& program = m_stagePrograms[stage]; if (!program) continue; signature[stage * 2] = program->GetLifetimeId(); - signature[stage * 2 + 1] = program->GetBackendStateVersion(); + signature[stage * 2 + 1] = program->GetLinkVersion(); } return signature; } + // Per-program state is written to the STAGE programs - glUniform* addresses the + // pipeline's active program (GL 4.6 core 7.6.1), glProgramUniform* addresses a + // named one, and the two block-binding calls address a named one - while the + // draw reads the composite. Two different objects' state, so the composite is + // refreshed from its stage programs before each draw that needs it. These are + // the per-stage versions "needs it" is measured against. All zero after a + // rebuild, because a fresh composite holds only what its shaders declared and + // so needs a full refresh. + // + // backendStateVersion belongs HERE even though ComputeDrawProgramSignature no + // longer reads it, and that is the whole point of the split: a sampler-unit or + // uniform-block-binding write must still trip the MIRROR (it is now the only + // route those values have to the composite) while deliberately NOT tripping the + // rebuild. uboContentVersion covers ordinary uniform writes, and + // blockBindingVersion covers the storage-block setter, which moves neither of + // the other two. + using UniformMirrorVersions = Array; + + UniformMirrorVersions ComputeUniformMirrorVersions() const { + UniformMirrorVersions versions{}; + for (SizeT stage = 0; stage < kGraphicsStageCount; ++stage) { + const auto& program = m_stagePrograms[stage]; + if (!program) continue; + versions[stage * 2] = (static_cast(program->GetBackendStateVersion()) << 32) | + static_cast(program->GetUBOContentVersion()); + // Their own slot rather than folded into the pair above: the + // storage-block setter moves the block-binding version and NOTHING + // else, so a rebinding would otherwise be invisible to the refresh + // gate - and the write-set version is the only counter that moves for + // a write which ENLARGES the set without changing a byte (see + // ProgramObject::GetUniformWriteSetVersion), which is what decides + // which stage owns a shared name. + versions[stage * 2 + 1] = (static_cast(program->GetBlockBindingVersion()) << 32) | + static_cast(program->GetUniformWriteSetVersion()); + } + return versions; + } + const UniformMirrorVersions& GetMirroredUniformVersions() const { return m_mirroredUniformVersions; } + void SetMirroredUniformVersions(const UniformMirrorVersions& versions) { + m_mirroredUniformVersions = versions; + } + const SharedPtr& GetCachedDrawProgram(const DrawProgramSignature& signature) const { static const SharedPtr nullProgram = nullptr; if (!m_drawProgram || m_drawProgramSignature != signature) return nullProgram; @@ -67,6 +154,8 @@ namespace MobileGL { void SetCachedDrawProgram(const DrawProgramSignature& signature, SharedPtr program) { m_drawProgramSignature = signature; m_drawProgram = Move(program); + // A rebuilt composite holds none of its stage programs' uniform values yet. + m_mirroredUniformVersions = {}; } private: @@ -74,9 +163,11 @@ namespace MobileGL { SharedPtr m_activeProgram; SharedPtr m_drawProgram; DrawProgramSignature m_drawProgramSignature{}; + UniformMirrorVersions m_mirroredUniformVersions{}; String m_infoLog; const Uint m_externalIndex = 0; Bool m_validateStatus = false; + Bool m_everBound = false; }; } // namespace GLState } // namespace MG_State diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp index b4f38401..0b64736e 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp @@ -70,9 +70,10 @@ namespace MobileGL::MG_State::GLState { void ShaderCompileAdoptionMap::SweepIfCrowded() { if (m_entries.size() < m_sweepThreshold) return; - // Collect first, erase after: FastSTL::unordered_map is open-addressed, so erasing - // through an iterator that the same loop is still advancing is not worth reasoning - // about on a path this cold. + // Collect first, erase after: the map is open-addressed and erases by shifting the + // rest of the probe cluster into the hole, so an erase moves entries other than the + // erased one. Copying the keys out sidesteps that entirely, and this path is cold + // enough that the extra vector is not worth reasoning about the alternative. Vector dead; for (const auto& entry : m_entries) { const SharedPtr node = entry.second.lock(); diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp index 06a07ecd..c0feabf8 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include "RenderState.h" +#include "MG_Util/Debug/Log.h" #include "MG_Util/Types.h" namespace MobileGL { @@ -268,9 +269,14 @@ namespace MobileGL { } void RenderState::SetCapabilityIndexed(CapabilityInput cap, Uint index, Bool enabled) { - // Only for BlendState currently + // Only for BlendState currently. The GL entry points (glEnablei/glDisablei) already + // reject every non-GL_BLEND target with GL_INVALID_ENUM before reaching here, so this + // is a backstop - but it must stay a backstop: THROW_UNIMPL_EXCEPTION unwinds a C++ + // exception through the C GL ABI and terminates the process. if (cap != CapabilityInput::Blend) { - THROW_UNIMPL_EXCEPTION; + MGLOG_I("RenderState::SetCapabilityIndexed: indexed capability state exists only for " + "GL_BLEND (cap=%d, index=%u); ignoring", + static_cast(cap), index); return; } if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) { @@ -284,9 +290,13 @@ namespace MobileGL { } Bool RenderState::IsCapabilityEnabledIndexed(CapabilityInput cap, Uint index) const { - // Only for BlendState currently + // Only for BlendState currently - same backstop reasoning as SetCapabilityIndexed: + // glIsEnabledi has already answered GL_INVALID_ENUM/GL_FALSE for anything else, and a + // query must never be able to terminate the process. if (cap != CapabilityInput::Blend) { - THROW_UNIMPL_EXCEPTION; + MGLOG_I("RenderState::IsCapabilityEnabledIndexed: indexed capability state exists only " + "for GL_BLEND (cap=%d, index=%u); reporting disabled", + static_cast(cap), index); return false; } if (index >= MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS) { diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp index a8bbb0ef..e61fb6d5 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp @@ -29,6 +29,8 @@ namespace MobileGL::MG_State::GLState { attr.Normalized = false; attr.Stride = 0; attr.Offset = 0; + attr.LegacyStride = 0; + attr.LegacyPointer = 0; attr.Buffer = nullptr; BumpAttributeFormatVersion(index); @@ -61,10 +63,19 @@ namespace MobileGL::MG_State::GLState { void VertexArrayObject::SetAttributeFormat(Uint index, int size, DataType type, Bool normalized, int stride, SizeT offset, Bool isInteger, Bool isBgra) { if (index >= MAX_VERTEX_ATTRIBS) return; + if (size < 1 || size > 4) { + return; + } // The classic pointer-style API takes back full ownership of the resolved fields. m_attributeUsesBindingModel[index] = false; + // The legacy query shadows: written here and nowhere else, so a later binding-model + // mutation cannot leak into VERTEX_ATTRIB_ARRAY_STRIDE / _POINTER. They are pure + // query state, so they carry no version bump of their own. + m_attributes[index].LegacyStride = stride; + m_attributes[index].LegacyPointer = offset; + if (m_attributes[index].Size == size && m_attributes[index].Type == type && m_attributes[index].Normalized == normalized && m_attributes[index].Stride == stride && m_attributes[index].Offset == offset && m_attributes[index].IsInteger == isInteger && @@ -72,10 +83,6 @@ namespace MobileGL::MG_State::GLState { return; } - if (size < 1 || size > 4) { - return; - } - auto& attr = m_attributes[index]; attr.Size = size; attr.Type = type; @@ -111,6 +118,12 @@ namespace MobileGL::MG_State::GLState { binding.Offset = offset; binding.Stride = effectiveStride; binding.Divisor = m_attributes[index].Divisor; + + // Other attributes may already be pointed at this binding point through + // glVertexAttribBinding; they see the new buffer/offset/stride too (basic-state3 + // checks exactly that after a glVertexAttribPointer). They are not adopted into the + // binding model here - only the ones already in it re-resolve. + ResolveAttributesForBinding(index, /*adopt: */ false); } void VertexArrayObject::BindAttributeBuffer(Uint index, const SharedPtr& buffer) { @@ -147,10 +160,24 @@ namespace MobileGL::MG_State::GLState { void VertexArrayObject::SetAttributeDivisor(Uint index, Uint divisor) { if (index >= MAX_VERTEX_ATTRIBS) return; - // glVertexAttribDivisor is VertexBindingDivisor on the attribute's own binding point - // (GL 4.6 core 10.3.2), so the binding-point view has to follow the resolved attribute. - if (index < MAX_VERTEX_ATTRIB_BINDINGS && m_attributeBindingIndex[index] == index) { + // GL 4.6 core 10.3.2 defines VertexAttribDivisor(i, d) as + // VertexAttribBinding(i, i); VertexBindingDivisor(i, d) + // - the binding is RE-POINTED at i, it is not merely written through when it already + // happens to be i. Guarding the write on "binding == index" (which is what this did) + // left an attribute that glVertexAttribBinding had moved elsewhere pointing at the old + // binding, so the next resolve restored that binding's divisor and the new one was + // lost (KHR-GL4x.vertex_attrib_binding.basic-state4). + // + // What is deliberately NOT copied from VertexAttribBinding is the adoption into the + // binding model: an attribute configured the classic way keeps its pointer-resolved + // stride/offset, exactly as before. The binding point mirrors that state already + // (MirrorPointerIntoBinding), so nothing observable differs - and adopting it here + // would silently swap the raw pointer stride for the effective one under every + // application that calls glVertexAttribDivisor after glVertexAttribPointer. + if (index < MAX_VERTEX_ATTRIB_BINDINGS) { + m_attributeBindingIndex[index] = index; m_bindingPoints[index].Divisor = divisor; + ResolveAttributesForBinding(index, /*adopt: */ false); } if (m_attributes[index].Divisor == divisor) return; m_attributes[index].Divisor = divisor; @@ -164,7 +191,6 @@ namespace MobileGL::MG_State::GLState { void VertexArrayObject::ResolveAttributeFromBinding(Uint attribIndex) { if (attribIndex >= MAX_VERTEX_ATTRIBS) return; - if (!m_attributeUsesBindingModel[attribIndex]) return; const Uint bindingIndex = m_attributeBindingIndex[attribIndex]; if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return; @@ -172,11 +198,24 @@ namespace MobileGL::MG_State::GLState { auto& attr = m_attributes[attribIndex]; + // VERTEX_ATTRIB_ARRAY_DIVISOR is not independent per-attribute state: it IS the divisor + // of the binding point the attribute is attached to (GL 4.6 core 10.3.2), whichever API + // configured the attribute. glVertexBindingDivisor therefore has to reach a classic + // pointer-configured attribute as well - basic-state4 alternates the two spellings on + // the same attribute and expects each to win in turn. + if (attr.Divisor != binding.Divisor) { + attr.Divisor = binding.Divisor; + BumpAttributeFormatVersion(attribIndex); + } + + // Everything else stays owned by whichever API configured the attribute: a classic + // glVertexAttrib*Pointer attribute keeps its pointer-resolved stride and offset. + if (!m_attributeUsesBindingModel[attribIndex]) return; + const SizeT resolvedOffset = binding.Offset + m_attributeRelativeOffset[attribIndex]; - if (attr.Stride != binding.Stride || attr.Offset != resolvedOffset || attr.Divisor != binding.Divisor) { + if (attr.Stride != binding.Stride || attr.Offset != resolvedOffset) { attr.Stride = binding.Stride; attr.Offset = resolvedOffset; - attr.Divisor = binding.Divisor; BumpAttributeFormatVersion(attribIndex); } @@ -186,6 +225,14 @@ namespace MobileGL::MG_State::GLState { } } + void VertexArrayObject::ResolveAttributesForBinding(Uint bindingIndex, Bool adopt) { + for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) { + if (m_attributeBindingIndex[attribIndex] != bindingIndex) continue; + if (adopt) m_attributeUsesBindingModel[attribIndex] = true; + ResolveAttributeFromBinding(attribIndex); + } + } + void VertexArrayObject::SetBindingBuffer(Uint bindingIndex, const SharedPtr& buffer, SizeT offset, int stride) { if (bindingIndex >= MAX_VERTEX_ATTRIB_BINDINGS) return; @@ -195,15 +242,10 @@ namespace MobileGL::MG_State::GLState { binding.Offset = offset; binding.Stride = stride; - for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) { - if (m_attributeBindingIndex[attribIndex] == bindingIndex) { - // Binding a vertex buffer to a binding point adopts every attribute currently - // mapped to that binding point into the binding model (the default mapping is - // attribute i -> binding i, which matches the GL 4.3 rules for state mixing). - m_attributeUsesBindingModel[attribIndex] = true; - ResolveAttributeFromBinding(attribIndex); - } - } + // Binding a vertex buffer to a binding point adopts every attribute currently mapped to + // that binding point into the binding model (the default mapping is attribute i -> + // binding i, which matches the GL 4.3 rules for state mixing). + ResolveAttributesForBinding(bindingIndex, /*adopt: */ true); } void VertexArrayObject::SetBindingDivisor(Uint bindingIndex, Uint divisor) { @@ -211,11 +253,7 @@ namespace MobileGL::MG_State::GLState { m_bindingPoints[bindingIndex].Divisor = divisor; - for (Uint attribIndex = 0; attribIndex < MAX_VERTEX_ATTRIBS; ++attribIndex) { - if (m_attributeBindingIndex[attribIndex] == bindingIndex && m_attributeUsesBindingModel[attribIndex]) { - ResolveAttributeFromBinding(attribIndex); - } - } + ResolveAttributesForBinding(bindingIndex, /*adopt: */ false); } void VertexArrayObject::SetAttributeBinding(Uint attribIndex, Uint bindingIndex) { diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h index 15af140c..34e6e75e 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h @@ -32,6 +32,16 @@ namespace MobileGL { Bool IsBgra = false; Uint Divisor = 0; SharedPtr Buffer; + + // GL 4.6 core table 23.3: VERTEX_ATTRIB_ARRAY_STRIDE and _POINTER are the + // arguments of the last glVertexAttrib*Pointer call on this attribute, + // reported verbatim, and NOTHING else writes them - not glVertexAttribFormat, + // not glBindVertexBuffer. Stride/Offset above are the *resolved* draw inputs + // and the binding model does overwrite those, so the two views have to be + // stored apart or the binding-model sequence reports a legacy state it never + // set (KHR-GL4x.vertex_attrib_binding.basic-state3). + int LegacyStride = 0; + SizeT LegacyPointer = 0; }; // ARB_vertex_attrib_binding separate binding point. Attributes configured through the @@ -40,7 +50,8 @@ namespace MobileGL { struct VertexBufferBindingPoint { SharedPtr Buffer; SizeT Offset = 0; - int Stride = 0; + // GL 4.6 core table 23.4: the initial VERTEX_BINDING_STRIDE is 16, not 0. + int Stride = 16; Uint Divisor = 0; }; @@ -185,6 +196,10 @@ namespace MobileGL { void BumpAttributeBufferVersion(Uint index); void BumpAttributeSwitchVersion(Uint index); void ResolveAttributeFromBinding(Uint attribIndex); + // Re-resolve every attribute currently pointed at `bindingIndex`. `adopt` turns + // the ones that are not in the binding model yet into binding-model attributes + // first (what glBindVertexBuffer does, GL 4.3 rules for state mixing). + void ResolveAttributesForBinding(Uint bindingIndex, Bool adopt); // The default mapping is attribute i -> binding point i. Keep it an iota over // MAX_VERTEX_ATTRIBS rather than a literal list: a literal list silently leaves the diff --git a/MobileGL/MG_Test/Backend/DirectGLES/CMakeLists.txt b/MobileGL/MG_Test/Backend/DirectGLES/CMakeLists.txt new file mode 100644 index 00000000..c88c7534 --- /dev/null +++ b/MobileGL/MG_Test/Backend/DirectGLES/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.14) + +add_executable( + EsslShaderPassTest + EsslShaderPassTest.cpp +) + +target_include_directories(EsslShaderPassTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + EsslShaderPassTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +include(GoogleTest) +gtest_discover_tests(EsslShaderPassTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp new file mode 100644 index 00000000..b0e3a487 --- /dev/null +++ b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp @@ -0,0 +1,369 @@ +// MobileGL - MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The post-transpile textual passes the DirectGLES ("Espryt") backend runs over the ESSL +// SPIRV-Cross hands it (MG_Backend/DirectGLES/Utils.cpp). No GL context and no driver: the +// passes are pure String -> String, so the shapes they have to survive can be pinned here +// instead of only on a device. + +#include + +#include + +using namespace MobileGL; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::SplitReadWriteImageUniforms; + +namespace { + Bool Contains(const String& haystack, const String& needle) { + return haystack.find(needle) != String::npos; + } + + SizeT CountOf(const String& haystack, const String& needle) { + SizeT count = 0; + for (SizeT pos = haystack.find(needle); pos != String::npos; pos = haystack.find(needle, pos + 1)) { + ++count; + } + return count; + } + + String WriteAlias(const String& name) { return String(IMAGE_WRITE_ALIAS_PREFIX) + name; } +} // namespace + +// The bug the pass exists for. SPIRV-Cross speculatively marks every storage image +// NonWritable+NonReadable, then clears NonReadable at the OpImageRead and NonWritable at the +// OpImageWrite, so an image the shader both reads and writes comes out carrying NEITHER +// `readonly` nor `writeonly` - which ESSL rejects for any format other than r32f/r32i/r32ui +// (GLSL ES 3.20 4.10). The device compile then fails and the draw silently binds program 0. +TEST(SplitReadWriteImageUniformsTest, ReadWriteImageIsSplitIntoAnAliasingPair) { + const String source = R"(#version 320 es +layout(binding = 2, rgba8) uniform highp image2D goku; +layout(location = 0) out highp vec4 mg_FragColor; +void main() +{ + highp vec4 loaded = imageLoad(goku, ivec2(gl_FragCoord.xy)); + imageStore(goku, ivec2(gl_FragCoord.xy), loaded + vec4(0.25)); + mg_FragColor = loaded; +} +)"; + const String out = SplitReadWriteImageUniforms(source); + + // Both halves: same binding, same format, same type - which is what makes two image + // variables on one image unit legal. + EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform readonly highp image2D goku;")); + EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform writeonly highp image2D " + WriteAlias("goku") + ";")); + + // The load keeps the original name, the store moves to the writeonly half. + EXPECT_TRUE(Contains(out, "imageLoad(goku,")); + EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("goku") + ",")); + EXPECT_FALSE(Contains(out, "imageStore(goku,")); +} + +// The split has to survive RemoveLayoutBinding, which runs straight after it: an ES image +// unit cannot be assigned through the API, so the layout qualifier is the only binding +// mechanism and both halves must still carry theirs afterwards. +TEST(SplitReadWriteImageUniformsTest, BothHalvesKeepTheirBindingThroughRemoveLayoutBinding) { + const String source = R"(#version 320 es +layout(binding = 5, rgba8) uniform highp image2D goku; +void main() +{ + imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0))); +} +)"; + const String out = RemoveLayoutBinding(SplitReadWriteImageUniforms(source)); + EXPECT_EQ(CountOf(out, "binding = 5"), 2u); +} + +// Cheap hardening: the pass does not depend on SPIRV-Cross getting the read-only case right, +// and a shader that only reads must not pay for a second uniform. +TEST(SplitReadWriteImageUniformsTest, ReadOnlyImageGetsReadonlyAndIsNotSplit) { + const String source = R"(#version 320 es +layout(binding = 1, rgba16f) uniform highp image2DArray trunks; +layout(location = 0) out highp vec4 mg_FragColor; +void main() +{ + mg_FragColor = imageLoad(trunks, ivec3(0)); +} +)"; + const String out = SplitReadWriteImageUniforms(source); + EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray trunks;")); + EXPECT_FALSE(Contains(out, "writeonly")); + EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX)); + EXPECT_EQ(CountOf(out, "image2DArray"), 1u); +} + +TEST(SplitReadWriteImageUniformsTest, WriteOnlyImageGetsWriteonlyAndIsNotSplit) { + const String source = R"(#version 320 es +layout(binding = 3, rgba8) uniform highp image2D gohan; +void main() +{ + imageStore(gohan, ivec2(0), vec4(1.0)); +} +)"; + const String out = SplitReadWriteImageUniforms(source); + EXPECT_TRUE(Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D gohan;")); + EXPECT_FALSE(Contains(out, "readonly")); + EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX)); +} + +// r32f / r32i / r32ui are exactly the formats GLSL ES 3.20 4.10 exempts from the rule, so a +// read+write image in one of them is already legal and must not be doubled. +TEST(SplitReadWriteImageUniformsTest, ExemptFormatsAreLeftCompletelyAlone) { + for (const char* format : {"r32f", "r32i", "r32ui"}) { + const String type = String(format) == "r32f" ? "image2D" : (String(format) == "r32i" ? "iimage2D" : "uimage2D"); + const String source = "#version 320 es\nlayout(binding = 4, " + String(format) + ") uniform highp " + type + + " vegeta;\nvoid main()\n{\n imageStore(vegeta, ivec2(0), imageLoad(vegeta, " + "ivec2(0)));\n}\n"; + EXPECT_EQ(SplitReadWriteImageUniforms(source), source) << "format " << format; + } +} + +// A declaration SPIRV-Cross already qualified is none of this pass's business. +TEST(SplitReadWriteImageUniformsTest, AlreadyQualifiedDeclarationsAreUntouched) { + const String source = R"(#version 320 es +layout(binding = 0, rgba8) uniform readonly highp image2D reader; +layout(binding = 1, rgba8) uniform writeonly highp image2D writer; +void main() +{ + imageStore(writer, ivec2(0), imageLoad(reader, ivec2(0))); +} +)"; + EXPECT_EQ(SplitReadWriteImageUniforms(source), source); +} + +// The binding of an image array is the array's base; splitting must keep the array on both +// halves (dropping the subscript would silently turn 3 units into 1). +TEST(SplitReadWriteImageUniformsTest, ImageArraySplitsAndKeepsItsArraySize) { + const String source = R"(#version 320 es +layout(binding = 6, rgba8) uniform highp image2D gohan[3]; +void main() +{ + imageStore(gohan[1], ivec2(0), imageLoad(gohan[2], ivec2(0))); +} +)"; + const String out = SplitReadWriteImageUniforms(source); + EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform readonly highp image2D gohan[3];")); + EXPECT_TRUE(Contains(out, + "layout(binding = 6, rgba8) uniform writeonly highp image2D " + WriteAlias("gohan") + "[3];")); + EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("gohan") + "[1],")); + EXPECT_TRUE(Contains(out, "imageLoad(gohan[2],")); +} + +// The rewrite is by identifier, not by substring: "goku" must not reach into "goku_hd", and +// the two images have to be classified independently. +TEST(SplitReadWriteImageUniformsTest, ANameThatIsAPrefixOfAnotherIsNotClobbered) { + const String source = R"(#version 320 es +layout(binding = 1, rgba8) uniform highp image2D goku; +layout(binding = 2, rgba8) uniform highp image2D goku_hd; +void main() +{ + highp vec4 loaded = imageLoad(goku, ivec2(0)); + imageStore(goku, ivec2(0), loaded); + imageStore(goku_hd, ivec2(0), loaded); +} +)"; + const String out = SplitReadWriteImageUniforms(source); + + // goku is read+write -> split; goku_hd is write-only -> qualified in place, not split. + EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform readonly highp image2D goku;")); + EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform writeonly highp image2D " + WriteAlias("goku") + ";")); + EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform writeonly highp image2D goku_hd;")); + EXPECT_TRUE(Contains(out, "imageStore(goku_hd,")); + EXPECT_FALSE(Contains(out, WriteAlias("goku") + "_hd")); + EXPECT_FALSE(Contains(out, WriteAlias("goku_hd"))); +} + +// Other qualifiers belong to both halves, and the memory qualifier goes where SPIRV-Cross +// puts it (right after `uniform`) so the image-rebinding regex in Managers.cpp still matches. +TEST(SplitReadWriteImageUniformsTest, ExistingQualifiersAreCarriedOntoBothHalves) { + const String source = R"(#version 320 es +layout(binding = 2, rgba8) uniform coherent restrict highp image2D goku; +void main() +{ + imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0))); +} +)"; + const String out = SplitReadWriteImageUniforms(source); + EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D goku;")); + EXPECT_TRUE( + Contains(out, "uniform writeonly coherent restrict highp image2D " + WriteAlias("goku") + ";")); +} + +// imageSize reads no texels and writes none, so it decides nothing; readonly is what keeps +// such a declaration legal. +TEST(SplitReadWriteImageUniformsTest, ImageSizeAloneDoesNotCountAsALoadOrAStore) { + const String source = R"(#version 320 es +layout(binding = 8, rgba8ui) uniform highp uimage2D sizeOnly; +layout(location = 0) out highp vec4 mg_FragColor; +void main() +{ + mg_FragColor = vec4(float(imageSize(sizeOnly).x)); +} +)"; + const String out = SplitReadWriteImageUniforms(source); + EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D sizeOnly;")); + EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX)); +} + +// The alias must not land on an identifier the shader already uses. +TEST(SplitReadWriteImageUniformsTest, AliasNameAvoidsAnExistingIdentifier) { + const String source = R"(#version 320 es +layout(binding = 6, rgba8) uniform highp image2D taken; +highp vec4 mg_imageWrite_taken; +void main() +{ + imageStore(taken, ivec2(0), imageLoad(taken, ivec2(0)) + mg_imageWrite_taken); +} +)"; + const String out = SplitReadWriteImageUniforms(source); + EXPECT_FALSE(Contains(out, "image2D " + WriteAlias("taken") + ";")); + EXPECT_TRUE(Contains(out, "image2D " + WriteAlias("taken") + "X;")); + EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("taken") + "X,")); + EXPECT_TRUE(Contains(out, "+ mg_imageWrite_taken)")); +} + +// A use the pass cannot account for (here: the image handed to a user function) means it +// cannot know every store site, so it declines rather than emitting a half-rewritten shader. +TEST(SplitReadWriteImageUniformsTest, AnUnrecognizedUseLeavesTheDeclarationAlone) { + const String source = R"(#version 320 es +layout(binding = 2, rgba8) uniform highp image2D passed; +highp vec4 helper(highp image2D img) { return imageLoad(img, ivec2(0)); } +void main() +{ + imageStore(passed, ivec2(0), helper(passed)); +} +)"; + EXPECT_EQ(SplitReadWriteImageUniforms(source), source); +} + +TEST(SplitReadWriteImageUniformsTest, ShaderWithoutImagesIsReturnedUnchanged) { + const String source = R"(#version 320 es +layout(binding = 0) uniform highp sampler2D goku; +layout(location = 0) out highp vec4 mg_FragColor; +void main() +{ + mg_FragColor = texture(goku, vec2(0.5)); +} +)"; + EXPECT_EQ(SplitReadWriteImageUniforms(source), source); +} + +// --------------------------------------------------------------------------------------- +// RetargetTextureBufferExtension +// +// Buffer textures are core in the OpenGL 3.1+ context MobileGL advertises, but in ES they +// only became core in 3.2; below that they need EXT_texture_buffer or OES_texture_buffer. +// SPIRV-Cross hardcodes the EXT spelling for every Dim=Buffer image it emits below ESSL 320 +// and offers no way to ask for the other one, so on a driver that advertises only the OES +// name the `: require` is a hard compile error over a single token. +// --------------------------------------------------------------------------------------- + +using Tier = MobileGL::MG_External::GLESCapabilities::TextureBufferTier; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RetargetTextureBufferExtension; + +namespace { + // What SPIRV-Cross actually emits for `uniform isamplerBuffer CloudFaces;` at ESSL 310 - + // the shape that empties Minecraft 26.3's cloud layer on a driver without the extension. + const String kBufferTextureShader = R"(#version 310 es +#extension GL_EXT_texture_buffer : require +precision highp float; +uniform highp isamplerBuffer CloudFaces; +layout(location = 0) out highp vec4 mg_FragColor; +void main() +{ + mg_FragColor = vec4(texelFetch(CloudFaces, gl_VertexID).r); +} +)"; +} // namespace + +TEST(RetargetTextureBufferExtensionTest, OesOnlyDriverGetsTheOesDirective) { + const String out = RetargetTextureBufferExtension(kBufferTextureShader, Tier::ExtensionOES); + EXPECT_TRUE(Contains(out, "#extension GL_OES_texture_buffer : require")) + << "the OES driver's own spelling must reach the directive:\n" << out; + EXPECT_FALSE(Contains(out, "GL_EXT_texture_buffer")) + << "the EXT spelling this driver does not advertise must be gone:\n" << out; + // Only the directive changes; the declaration and the fetch are identical between the two + // extensions and must not be touched. + EXPECT_TRUE(Contains(out, "uniform highp isamplerBuffer CloudFaces;")); + EXPECT_TRUE(Contains(out, "texelFetch(CloudFaces, gl_VertexID)")); +} + +TEST(RetargetTextureBufferExtensionTest, ExtDriverKeepsWhatSpirvCrossEmitted) { + EXPECT_EQ(RetargetTextureBufferExtension(kBufferTextureShader, Tier::ExtensionEXT), + kBufferTextureShader); +} + +// ES 3.2 needs no directive at all, and SPIRV-Cross emits none at ESSL 320 - but a shader +// that arrived with one anyway must not be rewritten to a name the pass was not asked for. +TEST(RetargetTextureBufferExtensionTest, CoreAndUnsupportedTiersAreNoOps) { + EXPECT_EQ(RetargetTextureBufferExtension(kBufferTextureShader, Tier::CoreEs32), + kBufferTextureShader); + EXPECT_EQ(RetargetTextureBufferExtension(kBufferTextureShader, Tier::None), + kBufferTextureShader); +} + +// The name is only the subject of a rewrite where it is the subject of an #extension +// directive. A shader that merely mentions it - in a comment SPIRV-Cross carried through, or +// in an identifier - is not an extension request and must come out byte-identical. +TEST(RetargetTextureBufferExtensionTest, OnlyExtensionDirectivesAreRewritten) { + const String source = R"(#version 310 es +// GL_EXT_texture_buffer is what this shader would need +precision highp float; +uniform highp float GL_EXT_texture_buffer_lookalike; +layout(location = 0) out highp vec4 mg_FragColor; +void main() +{ + mg_FragColor = vec4(GL_EXT_texture_buffer_lookalike); +} +)"; + EXPECT_EQ(RetargetTextureBufferExtension(source, Tier::ExtensionOES), source); +} + +// The dangerous collision, and the one the directive check alone does NOT catch: +// GL_EXT_texture_buffer is a strict prefix of GL_EXT_texture_buffer_object, a different and +// real extension that SPIRV-Cross emits from the same Dim=Buffer branch on its legacy-desktop +// path. Rewriting it would turn a valid request into one for a GL_OES_texture_buffer_object +// that does not exist. Only an identifier-boundary check saves this, so it gets its own test +// with the lookalike on a genuine #extension line. +TEST(RetargetTextureBufferExtensionTest, ALongerExtensionSharingThePrefixIsNotRewritten) { + const String source = R"(#version 310 es +#extension GL_EXT_texture_buffer_object : require +precision highp float; +void main() {} +)"; + EXPECT_EQ(RetargetTextureBufferExtension(source, Tier::ExtensionOES), source); + + // And when both appear, exactly the exact-match one moves. + const String mixed = R"(#version 310 es +#extension GL_EXT_texture_buffer_object : require +#extension GL_EXT_texture_buffer : require +precision highp float; +void main() {} +)"; + const String out = RetargetTextureBufferExtension(mixed, Tier::ExtensionOES); + EXPECT_TRUE(Contains(out, "#extension GL_EXT_texture_buffer_object : require")) << out; + EXPECT_TRUE(Contains(out, "#extension GL_OES_texture_buffer : require")) << out; + EXPECT_EQ(CountOf(out, "GL_OES_texture_buffer_object"), 0u) << out; +} + +// Whitespace between '#' and the keyword is legal in GLSL, and a shader carrying several +// extension directives must have exactly the one retargeted. +TEST(RetargetTextureBufferExtensionTest, SpacedDirectiveIsRewrittenAndNeighboursAreLeftAlone) { + const String source = R"(#version 310 es +# extension GL_EXT_texture_buffer : require +#extension GL_EXT_shader_io_blocks : require +precision highp float; +void main() {} +)"; + const String out = RetargetTextureBufferExtension(source, Tier::ExtensionOES); + EXPECT_TRUE(Contains(out, "# extension GL_OES_texture_buffer : require")) << out; + EXPECT_TRUE(Contains(out, "#extension GL_EXT_shader_io_blocks : require")) + << "an unrelated extension must survive untouched:\n" << out; + EXPECT_EQ(CountOf(out, "GL_OES_texture_buffer"), 1u); +} diff --git a/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp b/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp index ae1b6e59..8ddcc142 100644 --- a/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp +++ b/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp @@ -52,6 +52,19 @@ namespace { GLfloat maxTextureMaxAnisotropy = 16.0f; bool maxTextureMaxAnisotropyQueried = false; + // Buffer textures. GL_MAX_TEXTURE_BUFFER_SIZE is only a legal pname once they exist, so + // asking on a driver without them raises GL_INVALID_ENUM - the same shape as the + // anisotropy probe above. The three entry-point knobs are separate because the + // unsuffixed name is the ES 3.2 CORE spelling while an EXT/OES driver exports the + // suffixed one: a resolver that only looks for the core name declares every extension + // driver unsupported, which is exactly the bug these knobs exist to pin. + GLint maxTextureBufferSize = 131072; + bool maxTextureBufferSizeQueried = false; + bool textureBufferSizeQueryRaisesError = false; + bool hasCoreTexBufferEntryPoint = true; + bool hasExtTexBufferEntryPoint = false; + bool hasOesTexBufferEntryPoint = false; + GLuint nextBufferId = 1; GLuint nextShaderId = 1; GLuint nextProgramId = 1; @@ -121,6 +134,14 @@ namespace { *data = g_fake.fragmentInterpolationOffsetBits; } break; + case GL_MAX_TEXTURE_BUFFER_SIZE: + g_fake.maxTextureBufferSizeQueried = true; + if (g_fake.textureBufferSizeQueryRaisesError) { + g_fake.pendingError = GL_INVALID_ENUM; + } else { + *data = g_fake.maxTextureBufferSize; + } + break; // FillInGLESCapabilities reads the context version before running the // baseInstance probe, which requires ES >= 3.1. case GL_MAJOR_VERSION: @@ -332,6 +353,21 @@ namespace { funcs.glDisable = [](GLenum) {}; funcs.glMemoryBarrier = [](GLbitfield) {}; + // Buffer-texture entry points, each present only when its knob says so. A real loader + // resolves the suffixed names only on a driver whose support is that extension. + funcs.glTexBuffer = g_fake.hasCoreTexBufferEntryPoint + ? static_cast( + [](GLenum, GLenum, GLuint) {}) + : nullptr; + funcs.glTexBufferEXT = g_fake.hasExtTexBufferEntryPoint + ? static_cast( + [](GLenum, GLenum, GLuint) {}) + : nullptr; + funcs.glTexBufferOES = g_fake.hasOesTexBufferEntryPoint + ? static_cast( + [](GLenum, GLenum, GLuint) {}) + : nullptr; + // The probe's vertex shader writes the gl_InstanceID it observed into the // result SSBO at binding 0. A conforming driver observes 0; a leaking one // observes the indirect command's baseInstance word (byte offset 12). @@ -520,6 +556,150 @@ TEST(FragmentInterpolationCapabilities, QueriesOnlyWhenSupportedAndPreservesDriv EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR); } +// Buffer textures are core in the OpenGL 3.1+ context MobileGL advertises but need ES 3.2 or +// EXT/OES_texture_buffer on the host. The tier decides three things at once: whether glTexBuffer +// may be called at all, which #extension directive the emitted ESSL must carry, and whether +// GL_MAX_TEXTURE_BUFFER_SIZE is a driver answer or MobileGL's own floor. +using TextureBufferTier = MobileGL::MG_External::GLESCapabilities::TextureBufferTier; + +TEST(BufferTextureCapabilities, Es32ResolvesToCoreAndTakesTheDriverLimit) { + ResetFakeDriver(); + g_fake.maxVertexSsboBlocks = 0; + g_fake.glesMinorVersion = 2; + const auto funcs = MakeFakeGLESFunctions(); + + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + + EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::CoreEs32); + EXPECT_TRUE(caps.MaxTextureBufferSizeIsDriverReported); + EXPECT_EQ(caps.MaxTextureBufferSize, g_fake.maxTextureBufferSize); + EXPECT_TRUE(g_fake.maxTextureBufferSizeQueried); +} + +// The regression this pins: an ES 3.1 driver whose support is GL_EXT_texture_buffer exports +// glTexBufferEXT and NOT the unsuffixed core name. A resolver that requires the core pointer +// declares this driver unsupported and then refuses to compile shaders it could have run. +TEST(BufferTextureCapabilities, Es31WithExtResolvesThroughTheSuffixedEntryPoint) { + ResetFakeDriver(); + g_fake.maxVertexSsboBlocks = 0; + g_fake.extensions.emplace_back("GL_EXT_texture_buffer"); + g_fake.hasCoreTexBufferEntryPoint = false; + g_fake.hasExtTexBufferEntryPoint = true; + const auto funcs = MakeFakeGLESFunctions(); + + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + + EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::ExtensionEXT); + EXPECT_TRUE(caps.MaxTextureBufferSizeIsDriverReported); + EXPECT_EQ(caps.MaxTextureBufferSize, g_fake.maxTextureBufferSize); +} + +TEST(BufferTextureCapabilities, Es31WithOesResolvesThroughTheSuffixedEntryPoint) { + ResetFakeDriver(); + g_fake.maxVertexSsboBlocks = 0; + g_fake.extensions.emplace_back("GL_OES_texture_buffer"); + g_fake.hasCoreTexBufferEntryPoint = false; + g_fake.hasOesTexBufferEntryPoint = true; + const auto funcs = MakeFakeGLESFunctions(); + + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + + // The tier, not just a boolean: it is what selects the OES spelling of the #extension + // directive SPIRV-Cross hardcodes as EXT. + EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::ExtensionOES); + EXPECT_TRUE(caps.MaxTextureBufferSizeIsDriverReported); +} + +// EXT wins over OES on a driver advertising both, because SPIRV-Cross emits the EXT spelling +// natively and that tier needs no directive rewriting at all. +TEST(BufferTextureCapabilities, ExtIsPreferredWhenBothExtensionsArePresent) { + ResetFakeDriver(); + g_fake.maxVertexSsboBlocks = 0; + g_fake.extensions.emplace_back("GL_OES_texture_buffer"); + g_fake.extensions.emplace_back("GL_EXT_texture_buffer"); + g_fake.hasExtTexBufferEntryPoint = true; + g_fake.hasOesTexBufferEntryPoint = true; + const auto funcs = MakeFakeGLESFunctions(); + + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + + EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::ExtensionEXT); +} + +// The motivating driver (the emulator SDK's ANGLE: ES 3.1, neither extension). The pname is +// never asked - it would raise GL_INVALID_ENUM - and the floor MobileGL keeps advertising is +// flagged as not being a driver answer, because an OpenGL 4.x context may not report 0. +TEST(BufferTextureCapabilities, Es31WithNeitherExtensionIsUnsupportedAndNeverQueriesTheLimit) { + ResetFakeDriver(); + g_fake.maxVertexSsboBlocks = 0; + const auto funcs = MakeFakeGLESFunctions(); + + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + + EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::None); + EXPECT_FALSE(caps.MaxTextureBufferSizeIsDriverReported); + EXPECT_FALSE(g_fake.maxTextureBufferSizeQueried); + EXPECT_EQ(caps.MaxTextureBufferSize, 65536) << "the OpenGL 3.1 spec floor, not the fake's limit"; +} + +// An extension string with no entry point behind it is not support. This is the ES analogue of +// the multi-draw stub hazard: eglGetProcAddress may hand back live-looking pointers, so the +// two signals are required together. +TEST(BufferTextureCapabilities, AnExtensionStringWithoutAnEntryPointIsNotSupport) { + ResetFakeDriver(); + g_fake.maxVertexSsboBlocks = 0; + g_fake.extensions.emplace_back("GL_EXT_texture_buffer"); + g_fake.hasCoreTexBufferEntryPoint = false; + g_fake.hasExtTexBufferEntryPoint = false; + const auto funcs = MakeFakeGLESFunctions(); + + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + + EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::None); + EXPECT_FALSE(caps.MaxTextureBufferSizeIsDriverReported); +} + +// A driver that claims buffer textures and then refuses the query is a driver bug. The floor +// stands in, and the flag says the number was not the driver's - the POST row and the +// capability log both branch on exactly that. +TEST(BufferTextureCapabilities, ARejectedLimitQueryIsDrainedAndMarkedAsNotDriverReported) { + ResetFakeDriver(); + g_fake.maxVertexSsboBlocks = 0; + g_fake.glesMinorVersion = 2; + g_fake.textureBufferSizeQueryRaisesError = true; + const auto funcs = MakeFakeGLESFunctions(); + + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + + EXPECT_EQ(caps.TextureBufferSupport, TextureBufferTier::CoreEs32); + EXPECT_TRUE(g_fake.maxTextureBufferSizeQueried); + EXPECT_FALSE(caps.MaxTextureBufferSizeIsDriverReported); + EXPECT_EQ(caps.MaxTextureBufferSize, 65536); + EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR) << "the failed query must not leave an error behind"; +} + +// A stale error from an earlier probe must not be mistaken for this query failing. +TEST(BufferTextureCapabilities, AStaleErrorDoesNotDiscardTheDriverLimit) { + ResetFakeDriver(); + g_fake.maxVertexSsboBlocks = 0; + g_fake.glesMinorVersion = 2; + g_fake.pendingError = GL_INVALID_OPERATION; + const auto funcs = MakeFakeGLESFunctions(); + + MobileGL::MG_External::GLESCapabilities caps; + ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + + EXPECT_TRUE(caps.MaxTextureBufferSizeIsDriverReported); + EXPECT_EQ(caps.MaxTextureBufferSize, g_fake.maxTextureBufferSize); +} + TEST(FragmentInterpolationCapabilities, QueryErrorIsDrainedAndFallsBackToCoreMinimums) { ResetFakeDriver(); g_fake.maxVertexSsboBlocks = 0; diff --git a/MobileGL/MG_Test/Buffer/BufferTest.cpp b/MobileGL/MG_Test/Buffer/BufferTest.cpp index b62bdc9e..04084a20 100644 --- a/MobileGL/MG_Test/Buffer/BufferTest.cpp +++ b/MobileGL/MG_Test/Buffer/BufferTest.cpp @@ -480,6 +480,58 @@ TEST_F(BufferTest, BindBufferRangeZeroUnbindsBindingPoint) { EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR); } +// GL 4.6 core tables 23.4/23.5: *_BUFFER_START and *_BUFFER_SIZE report the (offset, size) pair +// glBindBufferRange was ASKED for. They are not clamped to the buffer's storage - a range may +// legally name bytes the buffer does not have, and glBufferData may resize the buffer afterwards +// without the binding's reported window moving. The size arm used to intersect the recorded range +// with the buffer's current size, so binding a range on a still-empty buffer (glGenBuffers with no +// glBufferData - exactly what KHR-GL43.shader_storage_buffer_object.basic-binding does) answered 0 +// while START still answered the offset, an internally inconsistent pair no driver reports. +TEST_F(BufferTest, IndexedBufferSizeQueryReportsTheRequestedSizeNotTheBuffersStorage) { + GLint ssboAlignment = 0; + MobileGL::MG_Impl::GLImpl::GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment); + ASSERT_GT(ssboAlignment, 0); + const GLintptr offset = ssboAlignment; + const GLsizeiptr size = 512; + + GLuint buffer = 0; + MobileGL::MG_Impl::GLImpl::GenBuffers(1, &buffer); + // Deliberately no glBufferData: the name exists, the storage does not. + MobileGL::MG_Impl::GLImpl::BindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, buffer, offset, size); + ASSERT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR); + + GLint start32 = 0; + GLint size32 = 0; + GLint64 start64 = 0; + GLint64 size64 = 0; + MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start32); + MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size32); + MobileGL::MG_Impl::GLImpl::GetInteger64i_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start64); + MobileGL::MG_Impl::GLImpl::GetInteger64i_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size64); + EXPECT_EQ(start32, static_cast(offset)); + EXPECT_EQ(size32, static_cast(size)); + EXPECT_EQ(start64, static_cast(offset)); + EXPECT_EQ(size64, static_cast(size)); + + // Giving the buffer storage afterwards does not move the window either way. + MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + MobileGL::MG_Impl::GLImpl::BufferData(GL_SHADER_STORAGE_BUFFER, offset + size, nullptr, GL_DYNAMIC_DRAW); + MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size32); + EXPECT_EQ(size32, static_cast(size)); + + // glBindBufferBase binds the whole buffer and reports (0, 0), not the buffer's size. + MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, buffer); + MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start32); + MobileGL::MG_Impl::GLImpl::GetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size32); + EXPECT_EQ(start32, 0); + EXPECT_EQ(size32, 0); + + MobileGL::MG_Impl::GLImpl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, 0); + MobileGL::MG_Impl::GLImpl::BindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + MobileGL::MG_Impl::GLImpl::DeleteBuffers(1, &buffer); + EXPECT_EQ(MobileGL::MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} + TEST_F(BufferTest, GetInteger64vMaxShaderStorageBlockSize) { GLint64 maxSsboBlockSize = 0; MobileGL::MG_Impl::GLImpl::GetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &maxSsboBlockSize); diff --git a/MobileGL/MG_Test/CMakeLists.txt b/MobileGL/MG_Test/CMakeLists.txt index c0edcc40..7f513507 100644 --- a/MobileGL/MG_Test/CMakeLists.txt +++ b/MobileGL/MG_Test/CMakeLists.txt @@ -78,6 +78,9 @@ add_subdirectory(Query) add_subdirectory(Pipeline) add_subdirectory(ShaderTranspiler) add_subdirectory(Util) +# The DirectGLES post-transpile ESSL passes are pure String -> String, so unlike the +# DirectVulkan suite below this one needs no device and always builds. +add_subdirectory(Backend/DirectGLES) if (ENABLE_INTEGRATION_TESTS) add_subdirectory(Backend/DirectVulkan) endif() diff --git a/MobileGL/MG_Test/Program/AsyncLinkTest.cpp b/MobileGL/MG_Test/Program/AsyncLinkTest.cpp index 070b20d1..f8b3e0a9 100644 --- a/MobileGL/MG_Test/Program/AsyncLinkTest.cpp +++ b/MobileGL/MG_Test/Program/AsyncLinkTest.cpp @@ -628,8 +628,8 @@ TEST_F(AsyncLinkTest, DrawThroughAPipelineWithAPendingStageProgramJoinsFirst) { GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); ASSERT_NE(pipeline, 0u); - // Bind before UseProgramStages: glGenProgramPipelines only reserves the name, and the - // first bind is what turns it into an object glUseProgramStages can find. + // Bound first only because this test draws through the pipeline; glUseProgramStages no + // longer needs it (it materializes a reserved name itself, GL 4.6 core 7.4). BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram); ASSERT_EQ(GetError(), GL_NO_ERROR); diff --git a/MobileGL/MG_Test/Program/CMakeLists.txt b/MobileGL/MG_Test/Program/CMakeLists.txt index ef17765a..7f29b1c5 100644 --- a/MobileGL/MG_Test/Program/CMakeLists.txt +++ b/MobileGL/MG_Test/Program/CMakeLists.txt @@ -164,6 +164,38 @@ target_link_libraries( ${LINK_LIBRARIES} ) +add_executable( + XfbBlockVaryingTest + XfbBlockVaryingTest.cpp +) + +target_include_directories(XfbBlockVaryingTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + XfbBlockVaryingTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +add_executable( + ProgramPipelineCompositeTest + ProgramPipelineCompositeTest.cpp +) + +target_include_directories(ProgramPipelineCompositeTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + ProgramPipelineCompositeTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + add_executable( ProgramInterfaceTest ProgramInterfaceTest.cpp @@ -195,6 +227,8 @@ include(GoogleTest) gtest_discover_tests(ProgramUtilTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(ProgramInterfaceTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(ProgramPipelineCompositeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(XfbBlockVaryingTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) # Heavier than the rest of the unit suite by design: several cases deliberately saturate the # compile pool so there is something in flight to race against. gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) diff --git a/MobileGL/MG_Test/Program/ProgramInterfaceTest.cpp b/MobileGL/MG_Test/Program/ProgramInterfaceTest.cpp index b9fa7d3b..73fbee8b 100644 --- a/MobileGL/MG_Test/Program/ProgramInterfaceTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramInterfaceTest.cpp @@ -1100,4 +1100,119 @@ void main() { color = u + v; } EXPECT_EQ(viaActiveUniformBlockiv, 5); EXPECT_EQ(TakeError(), GL_NO_ERROR); } + + // ---------------------------------------------------- queries on an unlinked program ---- + // glGetProgramiv is legal on a program that has never linked - GL 4.6 sec. 7.3 says the + // queried state simply has its initial value - but the reflection-backed pnames read + // Artifacts().program, which is null until a link produces one. That dereference was a + // SIGSEGV inside glslang::TProgram::getNumPipeInputs, and KHR-GL30.api.coverage walks into it + // (it queries GL_ACTIVE_ATTRIBUTES right after a glGetAttribLocation that failed). It only + // became reachable once the glCopyTexImage2D throw ahead of it in the same case stopped + // killing the run first. + TEST_F(ProgramInterfaceTest, ReflectionQueriesOnAnUnlinkedProgramAnswerZero) { + const GLuint neverLinked = CreateProgram(); + ASSERT_NE(neverLinked, 0u); + ClearErrors(); + + for (const GLenum pname : {GL_ACTIVE_ATTRIBUTES, GL_ACTIVE_ATTRIBUTE_MAX_LENGTH, GL_ACTIVE_UNIFORMS, + GL_ACTIVE_UNIFORM_MAX_LENGTH, GL_ACTIVE_UNIFORM_BLOCKS, + GL_ACTIVE_ATOMIC_COUNTER_BUFFERS}) { + GLint value = -1; + GetProgramiv(neverLinked, pname, &value); + ClearErrors(); + EXPECT_GE(value, 0) << "pname 0x" << std::hex << pname << " left its output untouched"; + } + + // A program that was linked and FAILED is the shape api.coverage actually hits. + const GLuint brokenSource = MakeProgram("#version 430\nvoid main() { this is not glsl }\n", kSimpleFs); + LinkProgram(brokenSource); + ClearErrors(); + GLint linked = GL_TRUE; + GetProgramiv(brokenSource, GL_LINK_STATUS, &linked); + ASSERT_EQ(linked, GL_FALSE) << "the shader was supposed to fail to compile"; + ClearErrors(); + + GLint attributes = -1; + GetProgramiv(brokenSource, GL_ACTIVE_ATTRIBUTES, &attributes); + ClearErrors(); + EXPECT_EQ(attributes, 0); + + // GL_COMPUTE_WORK_GROUP_SIZE is GL_INVALID_OPERATION on a program that has not linked (GL + // 4.6 sec. 7.13), so it is allowed to leave the output alone - but it still reaches + // GetComputeLocalSize(), and it may not do so through a null reflection. + GLint localSize[3] = {-1, -1, -1}; + GetProgramiv(brokenSource, GL_COMPUTE_WORK_GROUP_SIZE, localSize); + const GLenum computeError = TakeError(); + ClearErrors(); + EXPECT_TRUE(computeError == GL_INVALID_OPERATION || (localSize[0] == 0 && localSize[1] == 0 && + localSize[2] == 0)) + << "either the query is refused, or it answers the initial value - never both untouched " + "and unreported"; + } + + // ------------------------------------------------------------- length on every path ---- + // glGetProgramResourceiv's *length is the caller's only signal for how many entries params + // holds, and callers are entitled to leave it uninitialised: the CTS declares `GLsizei + // length;` next to a 1000-entry stack array and then loops `for (i = 0; i < length; ++i)` + // (gl4cProgramInterfaceQueryTests.cpp:2172). Leaving it untouched on an error path therefore + // does not "return nothing" - it hands the caller whatever was on its stack and makes it walk + // that far. KHR-GL43.program_interface_query.subroutines-vertex read 0x20202020 (" ") + // entries and took the process down on BOTH backends. So: zero on every exit, real count on + // success. Poisoning with the exact CTS-observed value keeps the assertion honest. + TEST_F(ProgramInterfaceTest, GetProgramResourceivReportsLengthOnEveryExitPath) { + const GLuint p = MakeProgram(kSimpleVs, kSimpleFs); + BindAttribLocation(p, 0, "position"); + BindFragDataLocation(p, 0, "color"); + LinkProgram(p); + ExpectLinked(p); + ClearErrors(); + + constexpr GLsizei kPoison = 0x20202020; + constexpr GLsizei kBufSize = 16; + GLint params[kBufSize] = {}; + + const GLenum nameLengthProp = GL_NAME_LENGTH; + const GLenum compatibleSubroutinesProp = GL_COMPATIBLE_SUBROUTINES; + const GLenum notAProp = GL_TEXTURE_2D; + + const auto lengthAfter = [&](GLuint program, GLenum iface, GLuint index, GLsizei propCount, + const GLenum* props, GLsizei bufSize, GLint* out) { + GLsizei length = kPoison; + GetProgramResourceiv(program, iface, index, propCount, props, bufSize, &length, out); + ClearErrors(); + return length; + }; + + // The case that actually crashed: no subroutine reflection exists, so the query errors + // out - and the caller then trusts *length. + EXPECT_EQ(lengthAfter(p, GL_VERTEX_SUBROUTINE_UNIFORM, 0, 1, &compatibleSubroutinesProp, kBufSize, params), 0) + << "GL_VERTEX_SUBROUTINE_UNIFORM"; + // Not a program name. + EXPECT_EQ(lengthAfter(p + 4242, GL_UNIFORM, 0, 1, &nameLengthProp, kBufSize, params), 0) << "bad program"; + // Not an interface enum. + EXPECT_EQ(lengthAfter(p, GL_TEXTURE_2D, 0, 1, &nameLengthProp, kBufSize, params), 0) << "bad interface"; + // propCount <= 0, bufSize < 0. + EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 0, &nameLengthProp, kBufSize, params), 0) << "propCount 0"; + EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &nameLengthProp, -1, params), 0) << "negative bufSize"; + // props == nullptr. + EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, nullptr, kBufSize, params), 0) << "null props"; + // A prop this command does not know at all. + EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, ¬AProp, kBufSize, params), 0) << "unknown prop"; + // A prop it knows but this interface does not carry. + EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &compatibleSubroutinesProp, kBufSize, params), 0) + << "prop/interface mismatch"; + // Index past the end of a real interface. + EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 9999, 1, &nameLengthProp, kBufSize, params), 0) << "bad index"; + // Nowhere to put the values. + EXPECT_EQ(lengthAfter(p, GL_PROGRAM_OUTPUT, 0, 1, &nameLengthProp, kBufSize, nullptr), 0) << "null params"; + + // ...and the success path still reports the count it actually wrote. + const GLuint outputIndex = GetProgramResourceIndex(p, GL_PROGRAM_OUTPUT, "color"); + ASSERT_NE(outputIndex, GL_INVALID_INDEX); + GLsizei length = kPoison; + GetProgramResourceiv(p, GL_PROGRAM_OUTPUT, outputIndex, 1, &nameLengthProp, kBufSize, &length, params); + EXPECT_EQ(TakeError(), GL_NO_ERROR); + EXPECT_EQ(length, 1); + EXPECT_EQ(params[0], 6) << "GL_NAME_LENGTH counts the terminator"; + } } // namespace diff --git a/MobileGL/MG_Test/Program/ProgramPipelineCompositeTest.cpp b/MobileGL/MG_Test/Program/ProgramPipelineCompositeTest.cpp new file mode 100644 index 00000000..0d8e065a --- /dev/null +++ b/MobileGL/MG_Test/Program/ProgramPipelineCompositeTest.cpp @@ -0,0 +1,505 @@ +// MobileGL - MobileGL/MG_Test/Program/ProgramPipelineCompositeTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The hidden composite program a pipeline draw goes through (MG_State/GLState/Core.cpp, +// GetProgramForDraw), interrogated directly rather than through pixels. +// +// Two properties live here that the integration scenarios cannot see, because both are about +// the composite as an OBJECT rather than about what it paints: +// +// 1. WHICH stage's uniform value ends up in its single slot when several stages declare the +// same name. The rendering cases pin the answer for the shapes an application actually +// writes; these pin the rule itself, including the tie. +// 2. WHETHER it is the same object from one draw to the next. A composite rebuild is a full +// synchronous Link() plus a new program identity that empties both backends' per-program +// registries, and nothing about the resulting IMAGE would change if it happened on every +// draw - so an assertion on pixels can never catch that regression. + +#include + +#include +#include +#include + +#include "Config.h" +#include "Includes.h" +#include "Init.h" +#include "MG_Impl/GLImpl/Getter/GL_Getter.h" +#include "MG_Impl/GLImpl/Program/GL_Program.h" +#include "MG_Impl/GLImpl/Program/GL_ProgramPipeline.h" +#include "MG_State/GLState/Core.h" + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +namespace { + + // Both stages declare `u_shared`, which is the shared-header idiom (one header included by + // every stage) and the shape that used to render nothing: the fragment stage's untouched + // zero default overwrote the vertex stage's written value on the way into the composite. + const char* kSharedUniformVs = R"(#version 430 core +out gl_PerVertex { vec4 gl_Position; }; +uniform vec4 u_shared; +uniform vec4 u_vsOnly; +void main() { gl_Position = u_shared + u_vsOnly; } +)"; + + const char* kSharedUniformFs = R"(#version 430 core +uniform vec4 u_shared; +out vec4 o_color; +void main() { o_color = u_shared; } +)"; + + const char* kArrayUniformVs = R"(#version 430 core +out gl_PerVertex { vec4 gl_Position; }; +uniform vec4 u_arr[4]; +void main() { gl_Position = u_arr[0] + u_arr[1] + u_arr[2] + u_arr[3]; } +)"; + + const char* kArrayUniformFs = R"(#version 430 core +uniform vec4 u_arr[4]; +out vec4 o_color; +void main() { o_color = u_arr[0] + u_arr[1] + u_arr[2] + u_arr[3]; } +)"; + + const char* kSamplerVs = R"(#version 430 core +out gl_PerVertex { vec4 gl_Position; }; +void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } +)"; + + const char* kSamplerFs = R"(#version 430 core +uniform sampler2D u_tex; +out vec4 o_color; +void main() { o_color = texture(u_tex, vec2(0.0)); } +)"; + + class ProgramPipelineCompositeTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + + // Built by hand rather than through glCreateShaderProgramv, for the reason AsyncLinkTest + // gives: that entry point detaches the shader right after linking, so a relink would + // leave the stage program with nothing to composite from - and one of the cases below + // relinks on purpose. + GLuint MakeSeparableProgram(const GLenum stage, const char* source) { + const GLuint shader = CreateShader(stage); + ShaderSource(shader, 1, &source, nullptr); + CompileShader(shader); + const GLuint program = CreateProgram(); + ProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE); + AttachShader(program, shader); + LinkProgram(program); + GLint linked = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &linked); + EXPECT_EQ(linked, GL_TRUE) << "separable stage program did not link"; + return program; + } + + // The composite the next draw would run, settled. + static SharedPtr DrawProgram() { + return MG_State::pGLContext->GetProgramForDraw(); + } + + // A uniform's value read out of a program's own shadow, by name. This is what the draw + // would upload, which is the thing under test - glGetUniform* would answer the same for + // the STAGE programs but has no way to name the composite at all. + static std::vector ReadVec4(MG_State::GLState::ProgramObject& program, const String& name) { + const Int location = program.GetUniformLocation(name); + if (location < 0) return {}; + const Uint offset = program.GetUniformOffset(static_cast(location)); + const auto* ubo = static_cast(program.GetUBOData()); + if (ubo == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset || + offset + 4 * sizeof(float) > program.GetUBOSize()) { + return {}; + } + std::vector value(4); + std::memcpy(value.data(), ubo + offset, 4 * sizeof(float)); + return value; + } + }; + +} // namespace + +// --------------------------------------------------------------------------------------- +// Which stage wins the composite's single slot +// --------------------------------------------------------------------------------------- + +// THE defect. Both stages declare `u_shared`; only the VERTEX program is ever written to. +// Walking the stages in order and copying every active uniform unconditionally meant the +// fragment stage's untouched zero default landed last and won, so the composite drew zeros - a +// whole frame of nothing, from a program that had been set up entirely correctly. +TEST_F(ProgramPipelineCompositeTest, AWrittenStageValueIsNotClobberedByAnotherStagesUntouchedDeclaration) { + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + // Exactly what an application does: point glUniform* at the vertex stage and write there. + // The fragment program is never written to and holds nothing but GL's zero default. + ActiveShaderProgram(pipeline, vs); + const GLint location = GetUniformLocation(vs, "u_shared"); + ASSERT_GE(location, 0); + const float written[4] = {0.25f, 0.5f, 0.75f, 1.0f}; + Uniform4fv(location, 1, written); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + const auto composite = DrawProgram(); + ASSERT_NE(composite, nullptr); + const std::vector value = ReadVec4(*composite, "u_shared"); + ASSERT_EQ(value.size(), 4u) << "u_shared has no backing storage in the composite"; + EXPECT_EQ(value, (std::vector{0.25f, 0.5f, 0.75f, 1.0f})) + << "the fragment stage's untouched declaration overwrote the vertex stage's written value"; + + // The uniform only one stage declares is unaffected either way; it is here so a mirror that + // copied nothing at all would not pass this case by accident. + ActiveShaderProgram(pipeline, vs); + const GLint vsOnly = GetUniformLocation(vs, "u_vsOnly"); + ASSERT_GE(vsOnly, 0); + const float other[4] = {1.0f, 2.0f, 3.0f, 4.0f}; + Uniform4fv(vsOnly, 1, other); + const auto refreshed = DrawProgram(); + EXPECT_EQ(ReadVec4(*refreshed, "u_vsOnly"), (std::vector{1.0f, 2.0f, 3.0f, 4.0f})); + EXPECT_EQ(GetError(), GL_NO_ERROR); + + BindProgramPipeline(0); + DeleteProgramPipelines(1, &pipeline); +} + +// The tie the fix cannot make disappear: BOTH stages were written, and the composite still has +// one slot. The documented rule is last WRITTEN-TO graphics stage wins, in ShaderStage enum +// order - deterministic, and reachable only by a stage holding a real application value. +TEST_F(ProgramPipelineCompositeTest, WhenBothStagesWereWrittenTheLastGraphicsStageWins) { + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + const float fromVs[4] = {1.0f, 1.0f, 1.0f, 1.0f}; + const float fromFs[4] = {2.0f, 2.0f, 2.0f, 2.0f}; + // Written in the order VS then FS... + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs); + ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, fromFs); + ASSERT_EQ(GetError(), GL_NO_ERROR); + EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector{2.0f, 2.0f, 2.0f, 2.0f})); + + // ...and in the order FS then VS. The answer is the same, because the rule is stage order + // and not write order - which is the honest statement of what the dirty set can support. + ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, fromFs); + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs); + ASSERT_EQ(GetError(), GL_NO_ERROR); + EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector{2.0f, 2.0f, 2.0f, 2.0f})) + << "the both-written tie must be decided by stage order, deterministically"; + + BindProgramPipeline(0); + DeleteProgramPipelines(1, &pipeline); +} + +// The both-written tie again, through the case that has no BYTES to move: the fragment stage +// writes the value it was already holding. +// +// The refresh gate is built out of counters that move when bytes move (the UBO content +// version, the backend state version), and both write funnels drop a value-identical write +// before bumping either. So this write enlarges the write SET - it makes the fragment stage +// the last written-to stage for `u_shared`, which is what decides the slot - while moving +// nothing else. Without a generation on the set itself the gate never trips and the draw keeps +// the vertex stage's value. +TEST_F(ProgramPipelineCompositeTest, AValueIdenticalWriteStillTakesTheSlotForItsStage) { + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + const float fromVs[4] = {5.0f, 5.0f, 5.0f, 5.0f}; + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs); + ASSERT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector{5.0f, 5.0f, 5.0f, 5.0f})); + + // The fragment program's u_shared already reads all-zero, so this write changes not one + // byte of its shadow - and must still hand it the composite's slot. + const float zeros[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, zeros); + ASSERT_EQ(GetError(), GL_NO_ERROR); + EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector{0.0f, 0.0f, 0.0f, 0.0f})) + << "a write that moved no bytes never reached the refresh gate"; + + BindProgramPipeline(0); + DeleteProgramPipelines(1, &pipeline); +} + +// glUseProgramStages here accepts a program that was never linked as separable (GL 4.6 core 7.4 +// says it should not, and MobileGL validates only LINK_STATUS). Such a program has recorded +// none of its writes, because nothing ever armed its tracking latch - so the mirror has to fall +// back to carrying everything rather than carrying nothing. Mirroring nothing would have been a +// fresh regression on a shape that worked before the dirty set existed. +TEST_F(ProgramPipelineCompositeTest, ANonSeparableStageProgramStillMirrorsItsUniforms) { + const char* vsSource = R"(#version 430 core +uniform vec4 u_vsOnly; +void main() { gl_Position = u_vsOnly; } +)"; + const GLuint shader = CreateShader(GL_VERTEX_SHADER); + ShaderSource(shader, 1, &vsSource, nullptr); + CompileShader(shader); + const GLuint vs = CreateProgram(); + // Deliberately NO ProgramParameteri(GL_PROGRAM_SEPARABLE): this is the shape the latch + // cannot see coming. + AttachShader(vs, shader); + LinkProgram(vs); + GLint linked = GL_FALSE; + GetProgramiv(vs, GL_LINK_STATUS, &linked); + ASSERT_EQ(linked, GL_TRUE); + + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + const float written[4] = {3.0f, 1.0f, 4.0f, 1.0f}; + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + const auto composite = DrawProgram(); + ASSERT_NE(composite, nullptr); + EXPECT_FALSE(MG_State::pGLContext->GetProgramObject(vs)->TracksUniformWrites()) + << "this case is only meaningful while the stage program records nothing"; + EXPECT_EQ(ReadVec4(*composite, "u_vsOnly"), (std::vector{3.0f, 1.0f, 4.0f, 1.0f})) + << "a stage program with no write record must fall back to mirroring everything"; + + BindProgramPipeline(0); + DeleteProgramPipelines(1, &pipeline); +} + +// glProgramUniform* addresses a program by NAME and needs neither a current program nor an +// active shader program, so it is a write path that never touches the pipeline at all. It has +// to record the write exactly like glUniform* does. +TEST_F(ProgramPipelineCompositeTest, ProgramUniformOnAnUnboundStageProgramReachesTheComposite) { + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + // Deliberately BEFORE the bind, and with no glActiveShaderProgram anywhere: the write has + // to survive from here to a draw that has not been set up yet. + const float written[4] = {9.0f, 8.0f, 7.0f, 6.0f}; + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + BindProgramPipeline(pipeline); + EXPECT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector{9.0f, 8.0f, 7.0f, 6.0f})); + EXPECT_EQ(GetError(), GL_NO_ERROR); + + BindProgramPipeline(0); + DeleteProgramPipelines(1, &pipeline); +} + +// Array uniforms are written at ELEMENT locations, so the record has to be per location and not +// per name: a stage that wrote `u_arr[2]` and nothing else must carry element 2 across and +// leave the rest to whichever stage owns them. +TEST_F(ProgramPipelineCompositeTest, ArrayElementWritesMirrorPerElement) { + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kArrayUniformVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kArrayUniformFs); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + // Non-prefix on purpose: elements 1 and 3 from the vertex stage, element 2 from the fragment + // stage, element 0 from nobody. A per-name record would have carried whole arrays and let + // one stage's zeros take the other's elements. + const float one[4] = {11.0f, 11.0f, 11.0f, 11.0f}; + const float three[4] = {33.0f, 33.0f, 33.0f, 33.0f}; + const float two[4] = {22.0f, 22.0f, 22.0f, 22.0f}; + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_arr[1]"), 1, one); + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_arr[3]"), 1, three); + ProgramUniform4fv(fs, GetUniformLocation(fs, "u_arr[2]"), 1, two); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + const auto composite = DrawProgram(); + ASSERT_NE(composite, nullptr); + EXPECT_EQ(ReadVec4(*composite, "u_arr[0]"), (std::vector{0.0f, 0.0f, 0.0f, 0.0f})); + EXPECT_EQ(ReadVec4(*composite, "u_arr[1]"), (std::vector{11.0f, 11.0f, 11.0f, 11.0f})); + EXPECT_EQ(ReadVec4(*composite, "u_arr[2]"), (std::vector{22.0f, 22.0f, 22.0f, 22.0f})); + EXPECT_EQ(ReadVec4(*composite, "u_arr[3]"), (std::vector{33.0f, 33.0f, 33.0f, 33.0f})); + EXPECT_EQ(GetError(), GL_NO_ERROR); + + // A multi-element glUniform*v run marks each location it actually reaches. + const float tail[8] = {44.0f, 44.0f, 44.0f, 44.0f, 55.0f, 55.0f, 55.0f, 55.0f}; + ActiveShaderProgram(pipeline, fs); + Uniform4fv(GetUniformLocation(fs, "u_arr[2]"), 2, tail); + ASSERT_EQ(GetError(), GL_NO_ERROR); + const auto refreshed = DrawProgram(); + EXPECT_EQ(ReadVec4(*refreshed, "u_arr[2]"), (std::vector{44.0f, 44.0f, 44.0f, 44.0f})); + EXPECT_EQ(ReadVec4(*refreshed, "u_arr[3]"), (std::vector{55.0f, 55.0f, 55.0f, 55.0f})) + << "the second element of a count=2 write was never recorded"; + + BindProgramPipeline(0); + DeleteProgramPipelines(1, &pipeline); +} + +// Relinking resets a program's uniforms to their initial values (GL 4.6 core 7.6), so the record +// of what was written has to be reset with them. If it survived, the composite built after the +// relink would be handed values the stage program no longer holds. +TEST_F(ProgramPipelineCompositeTest, RelinkingAStageProgramClearsWhatItHadWritten) { + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + const float written[4] = {5.0f, 6.0f, 7.0f, 8.0f}; + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written); + ASSERT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector{5.0f, 6.0f, 7.0f, 8.0f})); + + LinkProgram(vs); + GLint linked = GL_FALSE; + GetProgramiv(vs, GL_LINK_STATUS, &linked); + ASSERT_EQ(linked, GL_TRUE); + + const auto composite = DrawProgram(); + ASSERT_NE(composite, nullptr); + EXPECT_EQ(ReadVec4(*composite, "u_vsOnly"), (std::vector{0.0f, 0.0f, 0.0f, 0.0f})) + << "a relinked stage program carried its pre-relink value into the new composite"; + EXPECT_EQ(GetError(), GL_NO_ERROR); + + // ...and writing again after the relink is recorded afresh. + const float rewritten[4] = {1.5f, 2.5f, 3.5f, 4.5f}; + ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, rewritten); + EXPECT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector{1.5f, 2.5f, 3.5f, 4.5f})); + EXPECT_EQ(GetError(), GL_NO_ERROR); + + BindProgramPipeline(0); + DeleteProgramPipelines(1, &pipeline); +} + +// --------------------------------------------------------------------------------------- +// Composite cache stability +// --------------------------------------------------------------------------------------- + +// The SSO-conformance shape, and the reason the composite cache stopped being keyed on the +// backend state version: pick a stage program, then per draw set a sampler unit and draw. +// glUniform1i on a sampler bumps that version, so the signature changed on every iteration and +// every single draw threw the composite away and relinked it - glslang, SPIR-V and spirv-opt, +// synchronously, inside the draw - handing the backends a brand-new program identity each time. +// +// Asserted on the composite POINTER, which is the honest observable: it is the object both +// backends key their per-program registries and pipeline memos on, so "same pointer" is exactly +// the property that was lost. +TEST_F(ProgramPipelineCompositeTest, ASamplerWritePerDrawDoesNotRebuildTheComposite) { + const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSamplerVs); + const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSamplerFs); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + ActiveShaderProgram(pipeline, fs); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + const GLint sampler = GetUniformLocation(fs, "u_tex"); + ASSERT_GE(sampler, 0); + + const auto first = DrawProgram(); + ASSERT_NE(first, nullptr); + const Uint64 firstLifetime = first->GetLifetimeId(); + const Int compositeSampler = first->GetUniformLocation("u_tex"); + ASSERT_GE(compositeSampler, 0); + + for (GLint unit = 0; unit < 8; ++unit) { + Uniform1i(sampler, unit); + const auto composite = DrawProgram(); + ASSERT_NE(composite, nullptr); + EXPECT_EQ(composite.get(), first.get()) + << "the composite was rebuilt by a sampler-unit write at unit " << unit; + EXPECT_EQ(composite->GetLifetimeId(), firstLifetime) << "the composite's identity changed at unit " << unit; + // The value still has to ARRIVE - the whole point is that the mirror carries it now that + // the rebuild no longer does. + EXPECT_EQ(composite->GetUniformSamplerOrImageUnitIndex(static_cast(compositeSampler)), unit) + << "the sampler unit did not reach the composite at unit " << unit; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); + + // A relink, by contrast, MUST replace it: that is the one thing the signature still tracks. + LinkProgram(fs); + GLint linked = GL_FALSE; + GetProgramiv(fs, GL_LINK_STATUS, &linked); + ASSERT_EQ(linked, GL_TRUE); + const auto afterRelink = DrawProgram(); + ASSERT_NE(afterRelink, nullptr); + EXPECT_NE(afterRelink.get(), first.get()) << "a relinked stage program must rebuild the composite"; + + BindProgramPipeline(0); + DeleteProgramPipelines(1, &pipeline); +} + +// The monolithic path must be untouched by any of this: a plain glUseProgram program is not +// separable, records nothing, and is its own draw program. +TEST_F(ProgramPipelineCompositeTest, AMonolithicProgramRecordsNothingAndIsItsOwnDrawProgram) { + const char* vsSource = R"(#version 430 core +uniform vec4 u_shared; +void main() { gl_Position = u_shared; } +)"; + const char* fsSource = R"(#version 430 core +uniform vec4 u_shared; +out vec4 o_color; +void main() { o_color = u_shared; } +)"; + const GLuint vsShader = CreateShader(GL_VERTEX_SHADER); + ShaderSource(vsShader, 1, &vsSource, nullptr); + CompileShader(vsShader); + const GLuint fsShader = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fsShader, 1, &fsSource, nullptr); + CompileShader(fsShader); + + const GLuint program = CreateProgram(); + AttachShader(program, vsShader); + AttachShader(program, fsShader); + LinkProgram(program); + GLint linked = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &linked); + ASSERT_EQ(linked, GL_TRUE); + + UseProgram(program); + const float written[4] = {1.0f, 2.0f, 3.0f, 4.0f}; + Uniform4fv(GetUniformLocation(program, "u_shared"), 1, written); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + const auto drawProgram = DrawProgram(); + ASSERT_NE(drawProgram, nullptr); + EXPECT_EQ(drawProgram->GetExternalIndex(), program) << "a current program IS the draw program"; + // Nothing was recorded, because nothing ever asked this program to be separable - which is + // what keeps the hot uniform path free of the bookkeeping. + EXPECT_FALSE(drawProgram->TracksUniformWrites()); + EXPECT_TRUE(drawProgram->GetWrittenUniformIndices().empty()); + EXPECT_EQ(ReadVec4(*drawProgram, "u_shared"), (std::vector{1.0f, 2.0f, 3.0f, 4.0f})); + + UseProgram(0); +} diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 0c2025ac..27b702b2 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -2693,8 +2694,7 @@ void main() { ASSERT_TRUE(shaderResult) << shaderResult.error().log; // PARTIALLY bound, and deliberately not a dense 0..N run - exactly what Iris does. - // mc_midTexCoord and a_Unreferenced are left unbound (FastSTL's map has no - // initializer-list constructor, hence the explicit inserts). + // mc_midTexCoord and a_Unreferenced are left unbound. UnorderedMap explicitVertexIns; explicitVertexIns["a_Position"] = 0; explicitVertexIns["a_Color"] = 1; @@ -3079,3 +3079,405 @@ void main() { EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) << "the stripped module must validate clean"; } + +// --- Fragment-output array indexing (GLSL ES needs a constant integral expression) ------------- +// +// SPIR-V lets a fragment shader index an output array with any integer; GLSL ES does not +// (GLSL ES 3.00 4.3.6). SPIRV-Cross carries the dynamic index straight into the ESSL, a strict +// driver rejects the shader, the program links nothing, and every draw using it silently draws +// nothing - which is what empties the translucent layer of improved-transparency-minecraft-26.3 +// on the Android DirectGLES (ANGLE) lane while Mesa, being lenient, renders it correctly. +namespace { + Vector CompileFragmentToRawSpirv(const String& source) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib shaderAttrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + if (!shaderResult) { + ADD_FAILURE() << shaderResult.error().log; + return {}; + } + ProgramAttrib programAttrib{.shaders = {shaderResult.value()}}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + if (!programResult) { + ADD_FAILURE() << programResult.error().log; + return {}; + } + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, + .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + if (!binaryResult || binaryResult->size() != 1u) { + ADD_FAILURE() << (binaryResult ? "unexpected module count" : binaryResult.error().log); + return {}; + } + return binaryResult->front(); + } + + String DisassembleSpirv(const Vector& binary) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String text; + tools.Disassemble(binary, &text); + return text; + } + + // Every `name[` in the emitted ESSL is followed by a digit. A surviving dynamic index reads + // `coeff[attachmentIndex]` or `coeff[_123]`, which is the exact construct ES compilers refuse. + bool AllArrayIndicesAreLiterals(const String& essl, const String& name) { + const String needle = name + "["; + SizeT offset = 0; + bool sawAny = false; + while ((offset = essl.find(needle, offset)) != String::npos) { + const SizeT indexStart = offset + needle.size(); + if (indexStart >= essl.size()) return false; + // A declaration (`out vec4 coeff[2];`) and a constant index both read as a digit. + if (std::isdigit(static_cast(essl[indexStart])) == 0) return false; + sawAny = true; + offset = indexStart; + } + return sawAny; + } + + String DecompileToEssl(const Vector& binary) { + using namespace MG_Util::ShaderTranspiler; + SpvcSession session(binary, SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + if (!essl) { + ADD_FAILURE() << "decompile errc: " << essl.error().errc << "\nlog: " << essl.error().log; + return {}; + } + return essl.value(); + } +} // namespace + +// The shape Minecraft 26.3's OIT coefficient shader has: the index comes from a loop counter, so +// the stock folding chain (loop-control hint, ssa-rewrite, loop-unroll, ccp, simplification, +// dead-branch-elim) turns every write into a constant-indexed one and the fallback never runs. +TEST_F(ProgramUtilTest, LoopDerivedFragmentOutputIndexFoldsToConstantIndices) { + using namespace MG_Util::ShaderTranspiler; + + const Vector raw = CompileFragmentToRawSpirv(R"(#version 330 core +out vec4 coeff[2]; +in vec4 vColor; +in float vDepth; +void main() { + for (int attachmentIndex = 0; attachmentIndex < 2; ++attachmentIndex) { + for (int i = 0; i < 4; ++i) { + coeff[attachmentIndex][i] = vColor[i] * float(attachmentIndex + i) * vDepth; + } + } +} +)"); + ASSERT_FALSE(raw.empty()); + ASSERT_TRUE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(raw)) + << "the fixture must reproduce the defect before the fix is asked to remove it:\n" + << DisassembleSpirv(raw); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector legalized; + ASSERT_TRUE(ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(raw, legalized)); + ASSERT_FALSE(legalized.empty()); + + const String disassembly = DisassembleSpirv(legalized); + EXPECT_FALSE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(legalized)) + << "no fragment output may be left indexed by anything but a constant:\n" << disassembly; + EXPECT_EQ(disassembly.find("OpSwitch"), String::npos) + << "a loop-derived index must fold, not fall back to the switch lowering:\n" << disassembly; + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "the legalized module must stay validator-clean"; + + const String essl = DecompileToEssl(legalized); + ASSERT_FALSE(essl.empty()); + EXPECT_TRUE(AllArrayIndicesAreLiterals(essl, "coeff")) + << "the generated ESSL still indexes a fragment output with a non-constant:\n" << essl; +} + +// The fallback half: an index computed from a uniform cannot be folded by any amount of +// unrolling, so the write becomes a switch over the array's range and the read becomes +// constant-indexed loads combined with selects. +TEST_F(ProgramUtilTest, GenuinelyDynamicFragmentOutputIndexLowersToConstantSwitch) { + using namespace MG_Util::ShaderTranspiler; + + const Vector raw = CompileFragmentToRawSpirv(R"(#version 330 core +uniform int uTarget; +out vec4 coeff[2]; +in vec4 vColor; +void main() { + coeff[0] = vColor; + coeff[1] = vColor * 0.5; + coeff[uTarget] = coeff[uTarget] * 2.0; +} +)"); + ASSERT_FALSE(raw.empty()); + ASSERT_TRUE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(raw)) + << DisassembleSpirv(raw); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector legalized; + ASSERT_TRUE(ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(raw, legalized)); + ASSERT_FALSE(legalized.empty()); + + const String disassembly = DisassembleSpirv(legalized); + EXPECT_FALSE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(legalized)) + << "the uniform-driven index must be lowered away:\n" << disassembly; + EXPECT_NE(disassembly.find("OpSwitch"), String::npos) + << "the dynamic write must become a switch over the array range:\n" << disassembly; + EXPECT_NE(disassembly.find("OpSelect"), String::npos) + << "the dynamic read must become constant-indexed loads and a select:\n" << disassembly; + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "the lowered module must stay validator-clean:\n" << disassembly; + + const String essl = DecompileToEssl(legalized); + ASSERT_FALSE(essl.empty()); + EXPECT_TRUE(AllArrayIndicesAreLiterals(essl, "coeff")) + << "the generated ESSL still indexes a fragment output with a non-constant:\n" << essl; +} + +// The bound on the folding half. The index here IS loop-derived, so unrolling would fold it - +// but the loop runs 512 times, and fully unrolling it would multiply the shader by 512 to save +// a switch with two cases. Past the trip-count cap the loop is left alone and the fallback takes +// it, which is cheap in the array length instead of the trip count. +TEST_F(ProgramUtilTest, ALoopTooLongToUnrollFallsBackToTheSwitchLowering) { + using namespace MG_Util::ShaderTranspiler; + + const Vector raw = CompileFragmentToRawSpirv(R"(#version 330 core +out vec4 coeff[2]; +in vec4 vColor; +void main() { + coeff[0] = vec4(0.0); + coeff[1] = vec4(0.0); + for (int i = 0; i < 512; ++i) { + coeff[i % 2] += vColor * 0.001; + } +} +)"); + ASSERT_FALSE(raw.empty()); + ASSERT_TRUE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(raw)); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector legalized; + ASSERT_TRUE(ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(raw, legalized)); + ASSERT_FALSE(legalized.empty()); + + const String disassembly = DisassembleSpirv(legalized); + EXPECT_FALSE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(legalized)) + << "the index must be legalized even when the loop is left standing:\n" << disassembly; + EXPECT_NE(disassembly.find("OpLoopMerge"), String::npos) + << "a 512-trip loop must NOT be unrolled - that is the whole point of the cap:\n" + << disassembly; + EXPECT_NE(disassembly.find("OpSwitch"), String::npos) + << "with the loop standing, the write must go through the switch lowering:\n" << disassembly; + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "lowering inside a loop body must stay validator-clean:\n" << disassembly; + + const String essl = DecompileToEssl(legalized); + ASSERT_FALSE(essl.empty()); + EXPECT_TRUE(AllArrayIndicesAreLiterals(essl, "coeff")) + << "the generated ESSL still indexes a fragment output with a non-constant:\n" << essl; +} + +// The gate: a fragment shader that never indexes an output array dynamically must come back byte +// for byte, so no shader that did not need this pays for it or is perturbed by it. +TEST_F(ProgramUtilTest, FragmentWithoutDynamicOutputIndexingIsPassedThroughUnchanged) { + using namespace MG_Util::ShaderTranspiler; + + const Vector raw = CompileFragmentToRawSpirv(R"(#version 330 core +out vec4 coeff[2]; +in vec4 vColor; +void main() { + for (int i = 0; i < 4; ++i) { + coeff[0][i] = vColor[i]; + } + coeff[1] = vColor; +} +)"); + ASSERT_FALSE(raw.empty()); + ASSERT_FALSE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(raw)); + + Vector legalized; + ASSERT_TRUE(ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(raw, legalized)); + EXPECT_EQ(legalized, raw) << "the module must not be rewritten - not even re-serialized - when " + "nothing indexes a fragment output dynamically"; +} + +// Stages other than fragment may index an output array dynamically in ESSL (the array here is a +// varying, not a draw buffer), so detection must not fire on them at all. +TEST_F(ProgramUtilTest, DynamicOutputIndexingOutsideTheFragmentStageIsNotDetected) { + using namespace MG_Util::ShaderTranspiler; + + const Vector raw = CompileVertexToRawSpirv(R"(#version 330 core +in vec3 a_Position; +out vec4 v_Values[2]; +uniform int uTarget; +void main() { + v_Values[0] = vec4(0.0); + v_Values[1] = vec4(1.0); + v_Values[uTarget] = vec4(a_Position, 1.0); + gl_Position = vec4(a_Position, 1.0); +} +)"); + ASSERT_FALSE(raw.empty()); + EXPECT_FALSE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(raw)) + << "only fragment outputs carry the constant-index rule:\n" << DisassembleSpirv(raw); +} + +// --------------------------------------------------------------------------------------- +// Buffer-texture samplers (samplerBuffer / isamplerBuffer / usamplerBuffer) +// +// Buffer textures are core in OpenGL 3.1 and MobileGL advertises a 4.x context, so an +// application may sample one without asking. On the ES side they only became core in 3.2, +// and SPIRV-Cross emits `#extension GL_EXT_texture_buffer : require` for any Dim=Buffer +// image it renders below ESSL 320. On a driver with neither EXT_ nor OES_texture_buffer that +// directive - and the isamplerBuffer keyword behind it - fail to compile, the program never +// links, and every draw using it is a silent no-op. DirectGLES asks the detector below so it +// can name that as the missing capability it is, instead of leaving a driver info log the +// shipped INFO build compiles out. +// --------------------------------------------------------------------------------------- + +namespace { + // Compiles `source` for `stage` and returns the module, or fails the calling test. + Vector BuildSpirvForStage(const String& source, GLenum stage) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib attrib{.shaderType = stage, .sourceStr = source}; + auto res = ShaderCompiler::CompileShader(attrib); + if (!res) { + ADD_FAILURE() << "compile errc: " << res.error().errc << "\nlog: " << res.error().log; + return {}; + } + ProgramAttrib programAttrib{.shaders = {res.value()}}; + auto program_res = ShaderCompiler::LinkProgram(programAttrib); + if (!program_res) { + ADD_FAILURE() << "link errc: " << program_res.error().errc << "\nlog: " << program_res.error().log; + return {}; + } + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *program_res.value()}; + auto bin_res = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + if (!bin_res) { + ADD_FAILURE() << "spirv errc: " << bin_res.error().errc << "\nlog: " << bin_res.error().log; + return {}; + } + if (bin_res.value().size() != 1u) { + ADD_FAILURE() << "expected exactly one module, got " << bin_res.value().size(); + return {}; + } + return bin_res.value()[0]; + } +} // namespace + +// The shape of Minecraft 26.3's cloud vertex shader: no vertex attributes at all, the whole +// geometry read out of a GL_R8I buffer texture indexed by gl_VertexID. +TEST_F(ProgramUtilTest, BufferTextureSamplerIsDetectedInTheModule) { + using namespace MG_Util::ShaderTranspiler; + + String vs = R"(#version 330 core +uniform isamplerBuffer CloudFaces; +out vec4 vColor; +void main() { + int face = texelFetch(CloudFaces, gl_VertexID).r; + vColor = vec4(float(face) / 255.0); + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); +} +)"; + const Vector spirv = BuildSpirvForStage(vs, GL_VERTEX_SHADER); + ASSERT_FALSE(spirv.empty()); + EXPECT_TRUE(ShaderCompiler::ModuleDeclaresBufferTextureSampler(spirv)) + << "an isamplerBuffer must be recognised as a buffer texture"; +} + +// The float and unsigned spellings lower to the same Dim=Buffer image with a different +// sampled type, so all three have to be caught by the same check. +TEST_F(ProgramUtilTest, FloatAndUnsignedBufferSamplersAreDetectedToo) { + using namespace MG_Util::ShaderTranspiler; + + String floatFs = R"(#version 330 core +uniform samplerBuffer Data; +out vec4 fragColor; +void main() { fragColor = texelFetch(Data, 3); } +)"; + const Vector floatSpirv = BuildSpirvForStage(floatFs, GL_FRAGMENT_SHADER); + ASSERT_FALSE(floatSpirv.empty()); + EXPECT_TRUE(ShaderCompiler::ModuleDeclaresBufferTextureSampler(floatSpirv)); + + String uintFs = R"(#version 330 core +uniform usamplerBuffer Data; +out vec4 fragColor; +void main() { fragColor = vec4(texelFetch(Data, 3)); } +)"; + const Vector uintSpirv = BuildSpirvForStage(uintFs, GL_FRAGMENT_SHADER); + ASSERT_FALSE(uintSpirv.empty()); + EXPECT_TRUE(ShaderCompiler::ModuleDeclaresBufferTextureSampler(uintSpirv)); +} + +// The negative control that keeps the detector from turning into "declares any sampler": +// an ordinary sampler2D must not put a program on the unsupported path on a driver that is +// perfectly able to run it. +TEST_F(ProgramUtilTest, OrdinaryTextureSamplersAreNotBufferTextures) { + using namespace MG_Util::ShaderTranspiler; + + String fs = R"(#version 330 core +uniform sampler2D Albedo; +uniform isampler2D Ids; +in vec2 vUv; +out vec4 fragColor; +void main() { fragColor = texture(Albedo, vUv) + vec4(texelFetch(Ids, ivec2(0), 0)); } +)"; + const Vector spirv = BuildSpirvForStage(fs, GL_FRAGMENT_SHADER); + ASSERT_FALSE(spirv.empty()); + EXPECT_FALSE(ShaderCompiler::ModuleDeclaresBufferTextureSampler(spirv)) + << "only Dim=Buffer images are buffer textures"; +} + +// Pins the SPIRV-Cross behaviour the whole defect rests on: below ESSL 320 it synthesizes an +// EXT_texture_buffer requirement from the image type itself. There is nothing in the module +// to strip - which is why the OES driver is served by retargeting the emitted directive +// (RetargetTextureBufferExtension) rather than by rewriting the SPIR-V. +TEST_F(ProgramUtilTest, BufferTextureSamplerEmitsTheExtDirectiveInEssl) { + using namespace MG_Util::ShaderTranspiler; + + String fs = R"(#version 330 core +uniform isamplerBuffer Data; +out vec4 fragColor; +void main() { fragColor = vec4(texelFetch(Data, 3)); } +)"; + const Vector spirv = BuildSpirvForStage(fs, GL_FRAGMENT_SHADER); + ASSERT_FALSE(spirv.empty()); + + // Emitted at ESSL 310 the way DirectGLES does on an ES 3.1 host (ShaderCompiler's own + // DecompileShader helper hardcodes 320, where the question does not arise). This is the + // version the defect lives at: the emulator SDK's ANGLE is ES 3.1 with neither extension. + auto emitAt = [&spirv](unsigned version) -> String { + SpvcSession session(spirv, SessionUsageBit::Transpile); + spvc_compiler_options options; + session.CreateOptions(&options); + spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, version); + spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE); + spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE); + session.SetOptions(options); + const char* result = nullptr; + session.Compile(&result); + return result ? String(result) : String(); + }; + + const String essl310 = emitAt(310); + ASSERT_FALSE(essl310.empty()) << "ESSL 310 emission failed outright"; + EXPECT_NE(essl310.find("isamplerBuffer"), String::npos) + << "the buffer sampler must survive to ESSL:\n" << essl310; + EXPECT_NE(essl310.find("GL_EXT_texture_buffer"), String::npos) + << "SPIRV-Cross requires EXT_texture_buffer below ESSL 320, and hardcodes that spelling - " + "which is the whole reason an OES-only driver needs the emitted directive retargeted:\n" + << essl310; + + // At 320 buffer textures are ES core, so there is no directive to get wrong. This half is + // what makes the ES 3.2 tier a Pass with nothing to do rather than a silent dependency. + const String essl320 = emitAt(320); + ASSERT_FALSE(essl320.empty()) << "ESSL 320 emission failed outright"; + EXPECT_NE(essl320.find("isamplerBuffer"), String::npos) << essl320; + EXPECT_EQ(essl320.find("GL_EXT_texture_buffer"), String::npos) + << "ES 3.2 has buffer textures in core; requiring the extension there would be wrong:\n" + << essl320; + +} diff --git a/MobileGL/MG_Test/Program/XfbBlockVaryingTest.cpp b/MobileGL/MG_Test/Program/XfbBlockVaryingTest.cpp new file mode 100644 index 00000000..1f5a5cbf --- /dev/null +++ b/MobileGL/MG_Test/Program/XfbBlockVaryingTest.cpp @@ -0,0 +1,222 @@ +// MobileGL - MobileGL/MG_Test/Program/XfbBlockVaryingTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Transform-feedback capture of a member of an output interface block. +// +// GL 4.6 core 11.1.2.1 names such a varying "." - the block's TYPE +// name, never the instance name - which is exactly what KHR-GL4x.vertex_attrib_binding +// (gl4cVertexAttribBindingTests.cpp:419-437, `out StageData { vec4 attrib[16]; } vs_out;` +// captured as "StageData.attrib[0]".."[15]") relies on. The resolver used to match the +// requested name against glslang's linker-object symbol name, which for a block is the +// INSTANCE ("vs_out"), so every one of those captures came back unresolved and the link +// failed with "is not an output of the vertex stage" + GL_INVALID_VALUE. +// +// GPU-free: everything asserted here is a property of the link, not of any driver. + +#include + +#include +#include + +#include "Includes.h" +#include "Init.h" +#include "MG_Impl/GLImpl/Getter/GL_Getter.h" +#include "MG_Impl/GLImpl/Program/GL_Program.h" +#include "MG_State/GLState/Core.h" + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +namespace { + class XfbBlockVaryingTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + }; + + GLuint MakeVsOnlyProgram(const char* vs) { + const GLuint program = CreateProgram(); + const GLuint shader = CreateShader(GL_VERTEX_SHADER); + ShaderSource(shader, 1, &vs, nullptr); + CompileShader(shader); + GLint compiled = GL_FALSE; + GetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + EXPECT_EQ(compiled, GL_TRUE) << [&] { + char log[4096] = ""; + GetShaderInfoLog(shader, sizeof(log), nullptr, log); + return std::string(log); + }(); + AttachShader(program, shader); + return program; + } + + std::string LinkLog(GLuint program) { + char log[4096] = ""; + GetProgramInfoLog(program, sizeof(log), nullptr, log); + return std::string(log); + } + + GLint Programiv(GLuint program, GLenum pname) { + GLint value = -1; + GetProgramiv(program, pname, &value); + return value; + } + + struct VaryingRecord { + std::string name; + GLsizei size = 0; + GLenum type = 0; + }; + + VaryingRecord Varying(GLuint program, GLuint index) { + VaryingRecord record; + GLchar buffer[256] = {'\0'}; + GLsizei length = 0; + GetTransformFeedbackVarying(program, index, sizeof(buffer), &length, &record.size, &record.type, buffer); + record.name.assign(buffer, buffer + (length < 0 ? 0 : length)); + return record; + } + + void ClearErrors() { + for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) { + } + } + + // The CTS shader, narrowed to two elements so the expectations stay readable. + const char* kNamedBlockVs = R"(#version 430 core +layout(location = 0) in vec4 vs_in_attrib[2]; +out StageData { + vec4 attrib[2]; +} vs_out; +void main() { + for (int i = 0; i < vs_in_attrib.length(); ++i) { + vs_out.attrib[i] = vs_in_attrib[i]; + } +} +)"; + + TEST_F(XfbBlockVaryingTest, CapturesBlockMemberElementsByBlockTypeName) { + ClearErrors(); + const GLuint program = MakeVsOnlyProgram(kNamedBlockVs); + const GLchar* const varyings[2] = {"StageData.attrib[0]", "StageData.attrib[1]"}; + TransformFeedbackVaryings(program, 2, varyings, GL_INTERLEAVED_ATTRIBS); + LinkProgram(program); + + ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program); + EXPECT_EQ(GetError(), GL_NO_ERROR); + EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS), 2); + EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_BUFFER_MODE), GL_INTERLEAVED_ATTRIBS); + + for (GLuint i = 0; i < 2; ++i) { + const VaryingRecord record = Varying(program, i); + EXPECT_EQ(record.name, std::string("StageData.attrib[") + std::to_string(i) + "]"); + // One element of the member array, not the whole array. + EXPECT_EQ(record.size, 1) << "index " << i; + EXPECT_EQ(record.type, static_cast(GL_FLOAT_VEC4)) << "index " << i; + } + } + + // The whole member, no subscript: the array size has to survive. + TEST_F(XfbBlockVaryingTest, CapturesAWholeBlockMemberArray) { + ClearErrors(); + const GLuint program = MakeVsOnlyProgram(kNamedBlockVs); + const GLchar* const varyings[1] = {"StageData.attrib"}; + TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS); + LinkProgram(program); + + ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program); + const VaryingRecord record = Varying(program, 0); + EXPECT_EQ(record.name, "StageData.attrib"); + EXPECT_EQ(record.size, 2); + EXPECT_EQ(record.type, static_cast(GL_FLOAT_VEC4)); + } + + // Members of an anonymous instance are named the same way - the block name is still + // what identifies them, and there is no instance name to fall back on. + TEST_F(XfbBlockVaryingTest, CapturesAnonymousInstanceBlockMember) { + ClearErrors(); + const GLuint program = MakeVsOnlyProgram(R"(#version 430 core +layout(location = 0) in vec4 vs_in_attrib; +out StageData { + vec4 color; + vec2 uv; +}; +void main() { + color = vs_in_attrib; + uv = vs_in_attrib.xy; +} +)"); + const GLchar* const varyings[2] = {"StageData.color", "StageData.uv"}; + TransformFeedbackVaryings(program, 2, varyings, GL_INTERLEAVED_ATTRIBS); + LinkProgram(program); + + ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program); + EXPECT_EQ(Varying(program, 0).type, static_cast(GL_FLOAT_VEC4)); + EXPECT_EQ(Varying(program, 1).type, static_cast(GL_FLOAT_VEC2)); + } + + // The instance-qualified spelling is not what the spec asks for, but it is what a lot of + // application code writes; resolving it too costs nothing and keeps those links alive. + TEST_F(XfbBlockVaryingTest, AlsoAcceptsTheInstanceQualifiedSpelling) { + ClearErrors(); + const GLuint program = MakeVsOnlyProgram(kNamedBlockVs); + const GLchar* const varyings[1] = {"vs_out.attrib[1]"}; + TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS); + LinkProgram(program); + + ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program); + EXPECT_EQ(Varying(program, 0).size, 1); + EXPECT_EQ(Varying(program, 0).type, static_cast(GL_FLOAT_VEC4)); + } + + // A dotted path that resolves to nothing must still fail the link, and say so - the + // fix must not turn "unknown member" into a silently dropped capture. + TEST_F(XfbBlockVaryingTest, RejectsAnUnknownBlockMember) { + ClearErrors(); + const GLuint program = MakeVsOnlyProgram(kNamedBlockVs); + const GLchar* const varyings[1] = {"StageData.missing"}; + TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS); + LinkProgram(program); + + EXPECT_EQ(Programiv(program, GL_LINK_STATUS), GL_FALSE); + EXPECT_NE(LinkLog(program).find("StageData.missing"), std::string::npos) << LinkLog(program); + } + + TEST_F(XfbBlockVaryingTest, RejectsAnUnknownBlock) { + ClearErrors(); + const GLuint program = MakeVsOnlyProgram(kNamedBlockVs); + const GLchar* const varyings[1] = {"NoSuchBlock.attrib[0]"}; + TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS); + LinkProgram(program); + + EXPECT_EQ(Programiv(program, GL_LINK_STATUS), GL_FALSE); + } + + // Plain (non-block) outputs must keep resolving exactly as before. + TEST_F(XfbBlockVaryingTest, StillResolvesPlainOutputs) { + ClearErrors(); + const GLuint program = MakeVsOnlyProgram(R"(#version 430 core +layout(location = 0) in vec4 vs_in_attrib; +out vec4 plain[2]; +out vec3 single; +void main() { + plain[0] = vs_in_attrib; + plain[1] = vs_in_attrib; + single = vs_in_attrib.xyz; +} +)"); + const GLchar* const varyings[3] = {"plain[1]", "single", "gl_Position"}; + TransformFeedbackVaryings(program, 3, varyings, GL_INTERLEAVED_ATTRIBS); + LinkProgram(program); + + ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program); + EXPECT_EQ(Varying(program, 0).size, 1); + EXPECT_EQ(Varying(program, 0).type, static_cast(GL_FLOAT_VEC4)); + EXPECT_EQ(Varying(program, 1).type, static_cast(GL_FLOAT_VEC3)); + EXPECT_EQ(Varying(program, 2).type, static_cast(GL_FLOAT_VEC4)); + } +} // namespace diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index be60fd2e..41b72bcb 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -33,7 +33,8 @@ #include #include #include -#include +#include +#include namespace { class DynamicParameterBackend final : public MobileGL::MG_Backend::BackendObject { @@ -1822,13 +1823,170 @@ TEST(DirectGLESBackendTexture, DestructorDeletesIdAndScrubsBindingCache) { // A wrapper whose context died must NOT delete a foreign (recycled) name. { auto backendTexture = MobileGL::MakeShared(); - ++TextureImpl::g_textureContextGeneration; + ++g_backendContextGeneration; backendTexture.reset(); - --TextureImpl::g_textureContextGeneration; // restore for later tests + --g_backendContextGeneration; // restore for later tests EXPECT_EQ(deleted.size(), 1u); } } +// ---- DirectGLES backend twins release their driver ids -------------------------------------- +// Framebuffers, renderbuffers and samplers had no destructor at all: every frontend object the +// application deleted leaked its ES twin for the whole process lifetime. An application that +// creates a framebuffer per readback (GL CTS packed_pixels.varied_rectangle makes ~3300 of them +// per case) walked the driver into a gigabyte of dead framebuffers, and past that point every +// readback through a freshly attached framebuffer came back with stale pixels. +namespace { + struct TwinDeletionSinks { + MobileGL::Vector framebuffers; + MobileGL::Vector renderbuffers; + MobileGL::Vector samplers; + }; + + TwinDeletionSinks* g_twinDeletionSinks = nullptr; + GLuint g_nextTwinDriverId = 900; + + void TW_GenFramebuffers(GLsizei count, GLuint* ids) { + for (GLsizei i = 0; i < count; ++i) ids[i] = g_nextTwinDriverId++; + } + void TW_DeleteFramebuffers(GLsizei count, const GLuint* ids) { + if (!g_twinDeletionSinks) return; + for (GLsizei i = 0; i < count; ++i) g_twinDeletionSinks->framebuffers.push_back(ids[i]); + } + void TW_GenRenderbuffers(GLsizei count, GLuint* ids) { + for (GLsizei i = 0; i < count; ++i) ids[i] = g_nextTwinDriverId++; + } + void TW_DeleteRenderbuffers(GLsizei count, const GLuint* ids) { + if (!g_twinDeletionSinks) return; + for (GLsizei i = 0; i < count; ++i) g_twinDeletionSinks->renderbuffers.push_back(ids[i]); + } + void TW_GenSamplers(GLsizei count, GLuint* ids) { + for (GLsizei i = 0; i < count; ++i) ids[i] = g_nextTwinDriverId++; + } + void TW_DeleteSamplers(GLsizei count, const GLuint* ids) { + if (!g_twinDeletionSinks) return; + for (GLsizei i = 0; i < count; ++i) g_twinDeletionSinks->samplers.push_back(ids[i]); + } + void TW_BindFramebuffer(GLenum target, GLuint framebuffer) { + SG_Log("BindFramebuffer:" + std::to_string(target) + ":" + std::to_string(framebuffer)); + } + void TW_BindSampler(GLuint, GLuint) {} + void TW_BindRenderbuffer(GLenum, GLuint) {} + + // Installs a table that can create and destroy all three twin kinds, and unwinds it (plus the + // recording pointer) even when an assertion aborts the test body. + struct ScopedBackendTwinMocks { + ScopedBackendTwinMocks(): previousFunctions(MobileGL::MG_Backend::DirectGLES::g_GLESFuncs) { + MobileGL::MG_Backend::DirectGLES::FramebufferImpl::InvalidateFramebufferBindingCache(); + MobileGL::MG_External::GLESFunctionsTable functions{}; + functions.glGenFramebuffers = TW_GenFramebuffers; + functions.glDeleteFramebuffers = TW_DeleteFramebuffers; + functions.glBindFramebuffer = TW_BindFramebuffer; + functions.glGenRenderbuffers = TW_GenRenderbuffers; + functions.glDeleteRenderbuffers = TW_DeleteRenderbuffers; + functions.glBindRenderbuffer = TW_BindRenderbuffer; + functions.glGenSamplers = TW_GenSamplers; + functions.glDeleteSamplers = TW_DeleteSamplers; + functions.glBindSampler = TW_BindSampler; + functions.glGetError = SG_NoError; + MobileGL::MG_Backend::DirectGLES::SetGLESFuncsTable(functions); + g_twinDeletionSinks = &sinks; + g_stateGuardLog = &log; + } + + ~ScopedBackendTwinMocks() { + g_stateGuardLog = nullptr; + g_twinDeletionSinks = nullptr; + MobileGL::MG_Backend::DirectGLES::SetGLESFuncsTable(previousFunctions); + MobileGL::MG_Backend::DirectGLES::FramebufferImpl::InvalidateFramebufferBindingCache(); + } + + ScopedBackendTwinMocks(const ScopedBackendTwinMocks&) = delete; + ScopedBackendTwinMocks& operator=(const ScopedBackendTwinMocks&) = delete; + + TwinDeletionSinks sinks; + StateGuardCallLog log; + MobileGL::MG_External::GLESFunctionsTable previousFunctions; + }; +} // namespace + +TEST(DirectGLESBackendFramebuffer, DestructorDeletesIdAndScrubsBindingShadow) { + using namespace MobileGL::MG_Backend::DirectGLES; + ScopedBackendTwinMocks mocks; + + GLuint id = 0; + { + auto backendFBO = MobileGL::MakeShared(); + id = backendFBO->GetBackendFramebufferId(); + ASSERT_NE(id, 0u); + backendFBO->Bind(MobileGL::FramebufferTarget::Draw); + ASSERT_EQ(FramebufferImpl::CurrentFramebufferBinding(MobileGL::FramebufferTarget::Draw), id); + } + ASSERT_EQ(mocks.sinks.framebuffers.size(), 1u); + EXPECT_EQ(mocks.sinks.framebuffers[0], id); + // ES reverts every target bound to a deleted framebuffer to 0. The shadow has to follow, or + // the next BindFramebufferId(0) is deduped away and the driver keeps the dead name bound. + EXPECT_EQ(FramebufferImpl::CurrentFramebufferBinding(MobileGL::FramebufferTarget::Draw), 0u); + + // A twin whose context died must NOT delete a name a successor context may have recycled. + { + auto backendFBO = MobileGL::MakeShared(); + ++g_backendContextGeneration; + backendFBO.reset(); + --g_backendContextGeneration; // restore for later tests + EXPECT_EQ(mocks.sinks.framebuffers.size(), 1u); + } +} + +TEST(DirectGLESBackendRenderbuffer, DestructorDeletesId) { + using namespace MobileGL::MG_Backend::DirectGLES; + ScopedBackendTwinMocks mocks; + + GLuint id = 0; + { + auto backendRBO = MobileGL::MakeShared(); + id = backendRBO->GetBackendRenderbufferId(); + ASSERT_NE(id, 0u); + } + ASSERT_EQ(mocks.sinks.renderbuffers.size(), 1u); + EXPECT_EQ(mocks.sinks.renderbuffers[0], id); + + { + auto backendRBO = MobileGL::MakeShared(); + ++g_backendContextGeneration; + backendRBO.reset(); + --g_backendContextGeneration; + EXPECT_EQ(mocks.sinks.renderbuffers.size(), 1u); + } +} + +TEST(DirectGLESBackendSampler, DestructorDeletesIdAndScrubsUnitCache) { + using namespace MobileGL::MG_Backend::DirectGLES; + ScopedBackendTwinMocks mocks; + + GLuint id = 0; + { + auto backendSampler = MobileGL::MakeShared(); + id = backendSampler->GetBackendSamplerId(); + ASSERT_NE(id, 0u); + backendSampler->Bind(3); + ASSERT_EQ(SamplerImpl::g_boundSamplersCache[3], backendSampler.get()); + } + ASSERT_EQ(mocks.sinks.samplers.size(), 1u); + EXPECT_EQ(mocks.sinks.samplers[0], id); + // glDeleteSamplers unbinds from every unit, and the next twin can land on this heap + // address - a stale row would false-skip its Bind. + EXPECT_EQ(SamplerImpl::g_boundSamplersCache[3], nullptr); + + { + auto backendSampler = MobileGL::MakeShared(); + ++g_backendContextGeneration; + backendSampler.reset(); + --g_backendContextGeneration; + EXPECT_EQ(mocks.sinks.samplers.size(), 1u); + } +} + TEST(DirectGLESStateGuards, DefaultFramebufferBindGoesThroughShadow) { using namespace MobileGL::MG_Backend::DirectGLES; ScopedStateGuardMocks mocks; @@ -1841,34 +1999,51 @@ TEST(DirectGLESStateGuards, DefaultFramebufferBindGoesThroughShadow) { EXPECT_EQ(mocks.log.Count("BindFramebuffer:"), 3u); } -// FastSTL::unordered_map::erase(iterator) regression coverage. The open-addressing -// iterator constructor snaps forward from a tombstoned slot to the successor, so -// erase must NOT advance the rebuilt iterator again: the old double-advance skipped -// one live element per erase, and erasing the element in the highest occupied -// bucket pushed the returned index past bucket_count where it never compared equal -// to end() again - erase-while-iterating sweeps (pipeline/program cache eviction) -// then ran off the bucket array and fed garbage handles to vkDestroyPipeline -// (device crash on first mass eviction during world load). -TEST(FastSTLSanity, EraseWhileIteratingVisitsEveryElementExactlyOnce) { - FastSTL::unordered_map map; +// UnorderedMap::erase(iterator) contract coverage. Erase-while-iterating sweeps +// (pipeline/program cache eviction) depend on `it = map.erase(it)` naming the next +// live element exactly once: a sweep that skips entries leaks them, and one that +// runs off the end feeds garbage handles to vkDestroyPipeline (device crash on the +// first mass eviction during world load - the failure FastSTL's double-advancing +// erase actually produced before it was fixed). +// +// These pin the behaviour the call sites rely on, not one map's implementation, so +// they are written against MobileGL::UnorderedMap and survive changing what it +// names. Under ska::flat_hash_map the mechanism is different - erase backward-shifts +// the rest of the probe cluster into the hole and hands back the same slot, which +// now holds the shifted-in successor - but the observable contract is the same. +TEST(UnorderedMapSanity, EraseWhileIteratingVisitsEveryElementExactlyOnce) { + MobileGL::UnorderedMap map; constexpr MobileGL::Uint64 kCount = 1000; for (MobileGL::Uint64 key = 0; key < kCount; ++key) { map.emplace(key * 0x9e3779b97f4a7c15ull, key); } ASSERT_EQ(map.size(), kCount); + // Record WHICH keys the sweep hands back, not just how many. A count alone cannot + // tell a correct sweep from one that visits some element twice and misses another, + // which is exactly the shape a backward-shift bug takes: the shift rewrites the + // probe cluster, so a defect duplicates or strands elements rather than changing + // the tally. + std::set visitedKeys; MobileGL::SizeT visited = 0; for (auto it = map.begin(); it != map.end();) { + const MobileGL::Uint64 key = it->first; + EXPECT_TRUE(visitedKeys.insert(key).second) << "key " << key << " was visited twice"; it = map.erase(it); ++visited; - ASSERT_LE(visited, kCount); // old code: runaway past end / skipped entries + ASSERT_LE(visited, kCount); // runaway past end / skipped entries } EXPECT_EQ(visited, kCount); + EXPECT_EQ(visitedKeys.size(), kCount); + for (MobileGL::Uint64 key = 0; key < kCount; ++key) { + EXPECT_TRUE(visitedKeys.count(key * 0x9e3779b97f4a7c15ull) != 0) + << "key " << key << " was never visited by the sweep"; + } EXPECT_EQ(map.size(), 0u); } -TEST(FastSTLSanity, EraseReturnsTheSuccessorElement) { - FastSTL::unordered_map map; +TEST(UnorderedMapSanity, EraseReturnsTheSuccessorElement) { + MobileGL::UnorderedMap map; for (MobileGL::Uint32 key = 1; key <= 64; ++key) { map.emplace(key, key); } @@ -1876,25 +2051,48 @@ TEST(FastSTLSanity, EraseReturnsTheSuccessorElement) { // Erasing every other visited element must still visit all 64 exactly once: // the iterator returned by erase names the very next element, not one past it. MobileGL::SizeT visited = 0; - MobileGL::SizeT erased = 0; + std::set erasedKeys; + std::set keptKeys; for (auto it = map.begin(); it != map.end();) { ++visited; + const MobileGL::Uint32 key = it->first; if ((visited & 1) != 0) { + erasedKeys.insert(key); it = map.erase(it); - ++erased; } else { + keptKeys.insert(key); ++it; } ASSERT_LE(visited, 64u); } EXPECT_EQ(visited, 64u); - EXPECT_EQ(map.size(), 64u - erased); + EXPECT_EQ(erasedKeys.size() + keptKeys.size(), 64u); + EXPECT_EQ(map.size(), keptKeys.size()); + + // The interleaved erases rewrite probe clusters underneath the cursor, so the real + // question is not how many elements the loop counted but whether the table still + // resolves every key correctly afterwards. A stranded element stays in size() but + // stops being findable; a duplicated one answers for a key it does not own. + for (const MobileGL::Uint32 key : keptKeys) { + const auto found = map.find(key); + ASSERT_NE(found, map.end()) << "surviving key " << key << " is no longer findable"; + EXPECT_EQ(found->second, key) << "key " << key << " resolves to the wrong value"; + } + for (const MobileGL::Uint32 key : erasedKeys) { + EXPECT_EQ(map.find(key), map.end()) << "erased key " << key << " is still findable"; + } } -TEST(FastSTLSanity, ErasingTheOnlyElementReturnsEnd) { - FastSTL::unordered_map map; +TEST(UnorderedMapSanity, ErasingTheOnlyElementReturnsEnd) { + using Map = MobileGL::UnorderedMap; + Map map; map.emplace(42u, 1u); - auto next = map.erase(map.begin()); + + // Spell the type: erase(iterator) hands back a proxy that is convertible to an + // iterator but is not one, because finding the next element is not free and the + // callers that discard the result should not pay for it. `auto next = ...` binds + // the proxy instead, and then nothing it is compared against compiles. + Map::iterator next = map.erase(map.begin()); EXPECT_EQ(next, map.end()); EXPECT_TRUE(map.empty()); } diff --git a/MobileGL/MG_Test/State/CMakeLists.txt b/MobileGL/MG_Test/State/CMakeLists.txt index bb8e5900..a3227690 100644 --- a/MobileGL/MG_Test/State/CMakeLists.txt +++ b/MobileGL/MG_Test/State/CMakeLists.txt @@ -25,3 +25,53 @@ endif() include(GoogleTest) gtest_discover_tests(ObjectLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + +add_executable( + RenderStateTest + RenderStateTest.cpp +) + +target_include_directories(RenderStateTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + RenderStateTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(RenderStateTest PRIVATE /Zc:preprocessor) +endif() + +gtest_discover_tests(RenderStateTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + +add_executable( + NegativeApiErrorsTest + NegativeApiErrorsTest.cpp +) + +target_include_directories(NegativeApiErrorsTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + NegativeApiErrorsTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(NegativeApiErrorsTest PRIVATE /Zc:preprocessor) +endif() + +gtest_discover_tests(NegativeApiErrorsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/State/NegativeApiErrorsTest.cpp b/MobileGL/MG_Test/State/NegativeApiErrorsTest.cpp new file mode 100644 index 00000000..b3f55d6a --- /dev/null +++ b/MobileGL/MG_Test/State/NegativeApiErrorsTest.cpp @@ -0,0 +1,304 @@ +// MobileGL - MobileGL/MG_Test/State/NegativeApiErrorsTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The negative-path GL errors the conformance suite checks and MobileGL used to answer +// GL_NO_ERROR to. Every row here is a call the spec requires to fail, lifted from the CTS case +// that found it: +// * KHR-GL44.multi_bind.errors_bind_buffers / .errors_bind_samplers - ARB_multi_bind's +// "buffers/samplers will not be created if they do not exist" rule, plus the atomic-counter +// offset alignment the single-bind path never had. +// * KHR-GL43.shader_storage_buffer_object.negative-api-bind - the SSBO offset alignment is a +// property of the binding point and applies with buffer 0 too. +// * KHR-GL46.indirect_parameters_tests.MultiDraw{Arrays,Elements}IndirectCount - the three +// errors that guard a parameter-buffer draw. +// * KHR-GL43.compute_shader.api-indirect / .api-program. +// * KHR-GLxx.texture_storage.compressed_data - compressed formats on TEXTURE_3D. +// Plus the indexed-getter parity RC-7b is about: glGetBooleani_v / glGetInteger64i_v / +// glGetFloati_v / glGetDoublei_v must answer every pname glGetIntegeri_v answers. +// +// GPU-free: all of it is frontend validation. + +#include + +#include +#include +#include + +#include "Includes.h" +#include "Init.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +namespace { + class NegativeApiErrorsTest : public ::testing::Test { + protected: + void SetUp() override { + MobileGL::Initialize(); + MG_State::pGLContext = MakeUnique(); + } + + void TearDown() override { + EXPECT_EQ(GetError(), GL_NO_ERROR) << "test left an unconsumed GL error behind"; + } + + static void DrainErrors() { + for (int i = 0; i < 16 && GetError() != GL_NO_ERROR; ++i) { + } + } + + static GLuint MakeBuffer(GLenum target, GLsizeiptr size) { + GLuint buffer = 0; + GenBuffers(1, &buffer); + BindBuffer(target, buffer); + BufferData(target, size, nullptr, GL_STATIC_DRAW); + return buffer; + } + + // One table row: run the call, assert exactly the expected error, leave nothing pending. + struct Row { + const char* what; + std::function call; + GLenum expected; + }; + + static void RunRows(const std::vector& rows) { + for (const Row& row : rows) { + DrainErrors(); + row.call(); + EXPECT_EQ(GetError(), row.expected) << row.what; + DrainErrors(); + } + } + }; + + TEST_F(NegativeApiErrorsTest, MultiBindRejectsNamesThatAreNotObjectsYet) { + const GLuint buffer = MakeBuffer(GL_UNIFORM_BUFFER, 1024); + // Reserved by glGenBuffers but never turned into an object: legal for glBindBuffer, + // which creates it, and illegal for glBindBuffersBase, which must not. + GLuint reservedOnly = 0; + GenBuffers(1, &reservedOnly); + ASSERT_NE(reservedOnly, 0u); + ASSERT_EQ(IsBuffer(reservedOnly), GL_FALSE); + + // glGenSamplers, unlike glGenBuffers, creates the objects outright, so a sampler name is + // only "not an existing object" once it has been deleted. + GLuint deadSampler = 0; + GenSamplers(1, &deadSampler); + ASSERT_NE(deadSampler, 0u); + DeleteSamplers(1, &deadSampler); + DrainErrors(); + + const GLuint mixedBuffers[2] = {buffer, reservedOnly}; + const GLuint samplers[1] = {deadSampler}; + const GLintptr offsets[2] = {0, 0}; + const GLsizeiptr sizes[2] = {256, 256}; + + RunRows({ + {"glBindBuffersBase with a reserved-but-uncreated name", + [&] { BindBuffersBase(GL_UNIFORM_BUFFER, 0, 2, mixedBuffers); }, GL_INVALID_OPERATION}, + {"glBindBuffersRange with a reserved-but-uncreated name", + [&] { BindBuffersRange(GL_UNIFORM_BUFFER, 0, 2, mixedBuffers, offsets, sizes); }, + GL_INVALID_OPERATION}, + {"glBindSamplers with a deleted sampler name", [&] { BindSamplers(0, 1, samplers); }, + GL_INVALID_OPERATION}, + }); + + // ARB_multi_bind defines these as a LOOP of single binds, so the bad entry costs its own + // binding point and the good one still binds - only the error is new. + GLint bound = -1; + GetIntegeri_v(GL_UNIFORM_BUFFER_BINDING, 0, &bound); + EXPECT_EQ(static_cast(bound), buffer) << "a rejected element must not take the valid ones with it"; + GetIntegeri_v(GL_UNIFORM_BUFFER_BINDING, 1, &bound); + EXPECT_EQ(bound, 0) << "the rejected element must not have bound anything"; + DrainErrors(); + } + + TEST_F(NegativeApiErrorsTest, BufferRangeOffsetAlignmentAppliesToTheBindingPoint) { + GLint ssboAlignment = 0; + GetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &ssboAlignment); + ASSERT_GT(ssboAlignment, 1) << "the alignment rule is untestable at alignment 1"; + const GLuint atomicBuffer = MakeBuffer(GL_ATOMIC_COUNTER_BUFFER, 1024); + DrainErrors(); + + RunRows({ + // buffer 0 detaches the binding point, but the target's alignment rule still holds. + {"glBindBufferRange(SHADER_STORAGE_BUFFER, buffer 0, misaligned offset)", + [&] { BindBufferRange(GL_SHADER_STORAGE_BUFFER, 0, 0, ssboAlignment - 1, 0); }, GL_INVALID_VALUE}, + // An atomic counter binding is addressed in 32-bit counters; it has no queryable + // alignment pname, which is how its rule went missing. + {"glBindBufferRange(ATOMIC_COUNTER_BUFFER, offset 3)", + [&] { BindBufferRange(GL_ATOMIC_COUNTER_BUFFER, 0, atomicBuffer, 3, 16); }, GL_INVALID_VALUE}, + }); + + // ...and the aligned form still works. + DrainErrors(); + BindBufferRange(GL_ATOMIC_COUNTER_BUFFER, 0, atomicBuffer, 4, 16); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + + TEST_F(NegativeApiErrorsTest, DispatchComputeIndirectChecksTheBoundBufferExtent) { + // Six uints: an indirect dispatch reads three, so offset 16 runs off the end. + const GLuint dispatchBuffer = MakeBuffer(GL_DISPATCH_INDIRECT_BUFFER, 6 * sizeof(GLuint)); + DrainErrors(); + + RunRows({ + {"glDispatchComputeIndirect(-2)", [] { DispatchComputeIndirect(-2); }, GL_INVALID_VALUE}, + {"glDispatchComputeIndirect(3)", [] { DispatchComputeIndirect(3); }, GL_INVALID_VALUE}, + {"glDispatchComputeIndirect(16) past the end of a 24-byte buffer", + [] { DispatchComputeIndirect(16); }, GL_INVALID_OPERATION}, + {"glDispatchComputeIndirect(0) with nothing bound", + [&] { + BindBuffer(GL_DISPATCH_INDIRECT_BUFFER, 0); + DispatchComputeIndirect(0); + }, + GL_INVALID_OPERATION}, + }); + static_cast(dispatchBuffer); + } + + TEST_F(NegativeApiErrorsTest, IndirectParameterDrawsCheckBothBuffers) { + // Two DrawArraysIndirectCommands (16 bytes each) and a roomy parameter buffer. + MakeBuffer(GL_DRAW_INDIRECT_BUFFER, 2 * 4 * sizeof(GLuint)); + const GLuint parameterBuffer = MakeBuffer(GL_PARAMETER_BUFFER, 200); + DrainErrors(); + + RunRows({ + {"glMultiDrawArraysIndirectCount with drawcount 2 (not a multiple of four)", + [] { MultiDrawArraysIndirectCount(GL_TRIANGLE_STRIP, nullptr, 2, 1, 0); }, GL_INVALID_VALUE}, + {"glMultiDrawArraysIndirectCount with maxdrawcount past the indirect buffer", + [] { MultiDrawArraysIndirectCount(GL_TRIANGLE_STRIP, nullptr, 0, 4, 0); }, GL_INVALID_OPERATION}, + {"glMultiDrawElementsIndirectCount with drawcount 2", + [] { MultiDrawElementsIndirectCount(GL_TRIANGLE_STRIP, GL_UNSIGNED_BYTE, nullptr, 2, 1, 0); }, + GL_INVALID_VALUE}, + {"glMultiDrawArraysIndirectCount with no parameter buffer bound", + [&] { + BindBuffer(GL_PARAMETER_BUFFER, 0); + MultiDrawArraysIndirectCount(GL_TRIANGLE_STRIP, nullptr, 0, 2, 0); + }, + GL_INVALID_OPERATION}, + }); + static_cast(parameterBuffer); + } + + TEST_F(NegativeApiErrorsTest, TexStorage3DRejectsCompressedFormatsOnTexture3D) { + GLuint texture = 0; + GenTextures(1, &texture); + BindTexture(GL_TEXTURE_3D, texture); + DrainErrors(); + + RunRows({ + {"glTexStorage3D(TEXTURE_3D, GL_COMPRESSED_RED_RGTC1)", + [] { TexStorage3D(GL_TEXTURE_3D, 1, 0x8DBB /* GL_COMPRESSED_RED_RGTC1 */, 8, 8, 8); }, + GL_INVALID_OPERATION}, + {"glTexStorage3D(TEXTURE_3D, GL_COMPRESSED_RG_RGTC2)", + [] { TexStorage3D(GL_TEXTURE_3D, 1, 0x8DBD /* GL_COMPRESSED_RG_RGTC2 */, 8, 8, 8); }, + GL_INVALID_OPERATION}, + }); + + // An uncompressed sized format on the same target still allocates. + DrainErrors(); + TexStorage3D(GL_TEXTURE_3D, 1, GL_RGBA8, 8, 8, 8); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + + TEST_F(NegativeApiErrorsTest, LinkRejectsAComputeAndNonComputeMix) { + const auto attach = [](GLuint program, GLenum stage, const char* source) { + const GLuint shader = CreateShader(stage); + ShaderSource(shader, 1, &source, nullptr); + CompileShader(shader); + AttachShader(program, shader); + }; + const GLuint program = CreateProgram(); + attach(program, GL_COMPUTE_SHADER, R"(#version 430 core +layout(local_size_x = 1) in; +layout(std430) buffer Output { uint g_output[]; }; +void main() { g_output[gl_GlobalInvocationID.x] = 0; } +)"); + attach(program, GL_VERTEX_SHADER, R"(#version 430 core +layout(location = 0) in vec4 g_position; +void main() { gl_Position = g_position; } +)"); + attach(program, GL_FRAGMENT_SHADER, R"(#version 430 core +layout(location = 0) out vec4 g_color; +void main() { g_color = vec4(1); } +)"); + LinkProgram(program); + + GLint status = GL_TRUE; + GetProgramiv(program, GL_LINK_STATUS, &status); + EXPECT_EQ(status, GL_FALSE) << "a compute shader must not link with any other stage"; + DrainErrors(); + } + + // RC-7b: the four non-int indexed getters have to answer the same pname table glGetIntegeri_v + // does. glGetBooleani_v used to route everything through the indexed-capability path + // (GL_INVALID_ENUM for anything else) and glGetInteger64i_v straight to the driver, which + // does not have MobileGL's frontend-only values at all. + TEST_F(NegativeApiErrorsTest, IndexedGettersAgreeWithGetIntegeriv) { + DrainErrors(); + const GLenum pnames[] = {GL_MAX_COMPUTE_WORK_GROUP_COUNT, GL_MAX_COMPUTE_WORK_GROUP_SIZE}; + for (GLenum pname : pnames) { + for (GLuint index = 0; index < 3; ++index) { + GLint reference = -1; + GetIntegeri_v(pname, index, &reference); + ASSERT_EQ(GetError(), GL_NO_ERROR) << "glGetIntegeri_v(" << pname << ", " << index << ")"; + ASSERT_GT(reference, 0) << "the reference value has to be non-trivial to compare against"; + + GLint64 as64 = -1; + GetInteger64i_v(pname, index, &as64); + EXPECT_EQ(as64, static_cast(reference)) << "glGetInteger64i_v(" << pname << ")"; + EXPECT_EQ(GetError(), GL_NO_ERROR); + + GLfloat asFloat = -1.0f; + GetFloati_v(pname, index, &asFloat); + EXPECT_FLOAT_EQ(asFloat, static_cast(reference)) << "glGetFloati_v(" << pname << ")"; + EXPECT_EQ(GetError(), GL_NO_ERROR); + + GLdouble asDouble = -1.0; + GetDoublei_v(pname, index, &asDouble); + EXPECT_DOUBLE_EQ(asDouble, static_cast(reference)) << "glGetDoublei_v(" << pname << ")"; + EXPECT_EQ(GetError(), GL_NO_ERROR); + + GLboolean asBool = GL_FALSE; + GetBooleani_v(pname, index, &asBool); + EXPECT_EQ(asBool, GL_TRUE) << "glGetBooleani_v(" << pname << ")"; + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + } + } + + // ...and the vertex-binding offset keeps its 64-bit width through glGetInteger64i_v, which is + // how KHR-GL4x.vertex_attrib_binding reads it. + TEST_F(NegativeApiErrorsTest, VertexBindingOffsetIsReadableThroughTheSixtyFourBitGetter) { + GLuint vao = 0; + GenVertexArrays(1, &vao); + BindVertexArray(vao); + const GLuint vbo = MakeBuffer(GL_ARRAY_BUFFER, 4096); + DrainErrors(); + + GLint64 offset = -1; + GetInteger64i_v(GL_VERTEX_BINDING_OFFSET, 0, &offset); + EXPECT_EQ(offset, 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); + + BindVertexBuffer(0, vbo, 2048, 128); + GetInteger64i_v(GL_VERTEX_BINDING_OFFSET, 0, &offset); + EXPECT_EQ(offset, 2048); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } +} // namespace diff --git a/MobileGL/MG_Test/State/RenderStateTest.cpp b/MobileGL/MG_Test/State/RenderStateTest.cpp new file mode 100644 index 00000000..8a5ee533 --- /dev/null +++ b/MobileGL/MG_Test/State/RenderStateTest.cpp @@ -0,0 +1,91 @@ +// MobileGL - MobileGL/MG_Test/State/RenderStateTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Indexed capability state (glEnablei/glDisablei/glIsEnabledi) exists only for GL_BLEND in this +// stack. Every other capability must come back as GL_INVALID_ENUM per GL 4.6 sec. 17.3.3 - and, +// far more importantly, must come back at all: RenderState::SetCapabilityIndexed and +// IsCapabilityEnabledIndexed used to answer a non-blend capability with THROW_UNIMPL_EXCEPTION, +// which unwinds a C++ exception through the C GL ABI and terminates the process. + +#include + +#include "Includes.h" +#include "Init.h" + +#include +#include +#include +#include + +using namespace MobileGL; + +namespace { + class RenderStateTest: public ::testing::Test { + protected: + // GL error flags are sticky per code and the context outlives an individual test in this + // binary, so a pending error from an earlier case would be handed to the next GetError(). + static void DrainPendingGlErrors() { + for (Int drained = 0; drained < 16 && MG_Impl::GLImpl::GetError() != GL_NO_ERROR; ++drained) { + } + } + + static void ExpectSingleGlError(GLenum expected) { + EXPECT_EQ(MG_Impl::GLImpl::GetError(), expected); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "the call recorded more than one error"; + } + + void SetUp() override { + MobileGL::Initialize(); + DrainPendingGlErrors(); + } + + void TearDown() override { + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "test left an unconsumed GL error behind"; + } + }; +} // namespace + +TEST_F(RenderStateTest, IndexedCapabilityTogglesRejectNonBlendCapabilities) { + // GL_CLIP_DISTANCE0 is a real capability, just not an indexed one - the shape an application or + // a CTS negative test would hit. + for (const GLenum cap : {GL_CLIP_DISTANCE0, GL_DEPTH_TEST, GL_SCISSOR_TEST}) { + MG_Impl::GLImpl::Enablei(cap, 0); + ExpectSingleGlError(GL_INVALID_ENUM); + + MG_Impl::GLImpl::Disablei(cap, 0); + ExpectSingleGlError(GL_INVALID_ENUM); + + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(cap, 0), GL_FALSE); + ExpectSingleGlError(GL_INVALID_ENUM); + } +} + +TEST_F(RenderStateTest, IndexedCapabilityTogglesRejectAnOutOfRangeBufferIndex) { + const GLuint outOfRange = MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS; + + MG_Impl::GLImpl::Enablei(GL_BLEND, outOfRange); + ExpectSingleGlError(GL_INVALID_VALUE); + + MG_Impl::GLImpl::Disablei(GL_BLEND, outOfRange); + ExpectSingleGlError(GL_INVALID_VALUE); + + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_BLEND, outOfRange), GL_FALSE); + ExpectSingleGlError(GL_INVALID_VALUE); +} + +TEST_F(RenderStateTest, IndexedBlendTogglesStillWork) { + // The rejection path must not have cost the one capability that is genuinely indexed. + MG_Impl::GLImpl::Enablei(GL_BLEND, 1); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_BLEND, 1), GL_TRUE); + + MG_Impl::GLImpl::Disablei(GL_BLEND, 1); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); + EXPECT_EQ(MG_Impl::GLImpl::IsEnabledi(GL_BLEND, 1), GL_FALSE); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} diff --git a/MobileGL/MG_Test/Texture/TextureTest.cpp b/MobileGL/MG_Test/Texture/TextureTest.cpp index 70ef7da7..e215c11e 100644 --- a/MobileGL/MG_Test/Texture/TextureTest.cpp +++ b/MobileGL/MG_Test/Texture/TextureTest.cpp @@ -3176,3 +3176,115 @@ TEST_F(TextureTest, WidenedRenderTargetUploadExpandsThreeChannelDataWithOpaqueAl EXPECT_EQ(PrepareChannelWidenedUpload(3, texelSize, nullptr, 0, GL_FLOAT, widened), nullptr); } } + +// --------------------------------------------------------------------------------------------- +// A GL entry point may return an error, but it may never throw through the C GL ABI: unwinding a +// C++ exception across it terminates the process. These cover the sites that used to do exactly +// that (KHR-GL30.api.coverage died on the first of them on both backends). +// --------------------------------------------------------------------------------------------- + +namespace { + struct CopyTexImage2DCall { + Bool Called = false; + GLenum Target = 0; + GLint Level = 0; + GLenum InternalFormat = 0; + GLsizei Width = 0; + GLsizei Height = 0; + }; + + CopyTexImage2DCall g_copyTexImage2DCall; + + void RecordCopyTexImage2D(GLenum target, GLint level, GLenum internalformat, GLint, GLint, GLsizei width, + GLsizei height, GLint) { + g_copyTexImage2DCall = {true, target, level, internalformat, width, height}; + } + + // A colour read framebuffer of the requested sized format, bound to GL_READ_FRAMEBUFFER, which + // is what glCopyTexImage2D takes its source base format from. + void BindReadFramebufferWithColorFormat(GLenum sizedInternalFormat) { + GLuint framebuffer = 0; + GLuint texture = 0; + MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer); + MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture); + MG_Impl::GLImpl::TextureStorage2D(texture, 1, sizedInternalFormat, 16, 16); + MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, texture, 0); + MG_Impl::GLImpl::BindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer); + } + + GLuint BindFreshMutableTexture2D() { + GLuint texture = 0; + MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture); + MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, texture); + return texture; + } +} // namespace + +TEST_F(TextureTest, CopyTexImage2DAcceptsEveryComponentSubsetOfTheReadBuffer) { + const ScopedTextureBackendFunctionsOverride backendGuard; + MG_Backend::gBackendFunctionsTable.GL.CopyTexImage2D = RecordCopyTexImage2D; + + BindReadFramebufferWithColorFormat(GL_RGBA8); + ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "read framebuffer setup itself failed"; + + // GL 4.6 sec. 8.6: internalformat may name a SUBSET of the read buffer's components. This is + // exactly the list KHR-GL30.api.coverage walks against an rgba8888 colour buffer, and it is + // also what an ordinary GL app does with glCopyTexImage2D(GL_RGB) from an RGBA8 framebuffer. + for (const GLenum internalFormat : {GL_RED, GL_RG, GL_RGB, GL_RGBA}) { + BindFreshMutableTexture2D(); + g_copyTexImage2DCall = {}; + + MG_Impl::GLImpl::CopyTexImage2D(GL_TEXTURE_2D, 0, internalFormat, 0, 0, 1, 1, 0); + + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "internalformat " << internalFormat; + EXPECT_TRUE(g_copyTexImage2DCall.Called) << "internalformat " << internalFormat; + EXPECT_EQ(g_copyTexImage2DCall.InternalFormat, internalFormat); + EXPECT_EQ(g_copyTexImage2DCall.Width, 1); + EXPECT_EQ(g_copyTexImage2DCall.Height, 1); + } +} + +TEST_F(TextureTest, CopyTexImage2DRejectsAFormatTheReadBufferCannotSupply) { + const ScopedTextureBackendFunctionsOverride backendGuard; + MG_Backend::gBackendFunctionsTable.GL.CopyTexImage2D = RecordCopyTexImage2D; + + BindReadFramebufferWithColorFormat(GL_R8); + ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "read framebuffer setup itself failed"; + + BindFreshMutableTexture2D(); + g_copyTexImage2DCall = {}; + + // The subset rule still has a wrong side: GL_RGBA asks for components a GL_R8 read buffer does + // not have. That must be GL_INVALID_OPERATION and nothing else - not a throw, not silence. + MG_Impl::GLImpl::CopyTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, 1, 1, 0); + + ExpectSingleGlError(GL_INVALID_OPERATION); + EXPECT_FALSE(g_copyTexImage2DCall.Called) << "a rejected copy must not reach the backend"; +} + +TEST_F(TextureTest, CopyTexImage1DReportsUnsupportedInsteadOfTerminating) { + // 1D textures have no upload path in this stack; the entry point used to throw unconditionally. + MG_Impl::GLImpl::CopyTexImage1D(GL_TEXTURE_1D, 0, GL_RGBA, 0, 0, 1, 0); + ExpectSingleGlError(GL_INVALID_OPERATION); +} + +TEST_F(TextureTest, GetTexLevelParameterOnBufferStorageReportsErrorInsteadOfTerminating) { + // TextureStorageType is {Mipmap, Buffer} and the level queries only answer out of a mipmap + // chain, so every glGetTexLevelParameter* on a GL_TEXTURE_BUFFER texture reached a + // THROW_UNIMPL_EXCEPTION default: label and killed the process. + GLuint texture = 0; + MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_BUFFER, 1, &texture); + MG_Impl::GLImpl::BindTexture(GL_TEXTURE_BUFFER, texture); + MG_Impl::GLImpl::TexBuffer(GL_TEXTURE_BUFFER, GL_R8, 0); + DrainPendingGlErrors(); + + for (const GLenum pname : {GL_TEXTURE_WIDTH, GL_TEXTURE_HEIGHT, GL_TEXTURE_DEPTH}) { + GLint intParam = 0x20202020; + MG_Impl::GLImpl::GetTexLevelParameteriv(GL_TEXTURE_BUFFER, 0, pname, &intParam); + ExpectSingleGlError(GL_INVALID_OPERATION); + + GLfloat floatParam = 12345.0f; + MG_Impl::GLImpl::GetTexLevelParameterfv(GL_TEXTURE_BUFFER, 0, pname, &floatParam); + ExpectSingleGlError(GL_INVALID_OPERATION); + } +} diff --git a/MobileGL/MG_Test/VertexArray/CMakeLists.txt b/MobileGL/MG_Test/VertexArray/CMakeLists.txt index bac2f850..8cb7a918 100644 --- a/MobileGL/MG_Test/VertexArray/CMakeLists.txt +++ b/MobileGL/MG_Test/VertexArray/CMakeLists.txt @@ -16,5 +16,22 @@ target_link_libraries( ${LINK_LIBRARIES} ) +add_executable( + VertexAttribBindingStateTest + VertexAttribBindingStateTest.cpp +) + +target_include_directories(VertexAttribBindingStateTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + VertexAttribBindingStateTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + include(GoogleTest) gtest_discover_tests(VertexArrayTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(VertexAttribBindingStateTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/VertexArray/VertexAttribBindingStateTest.cpp b/MobileGL/MG_Test/VertexArray/VertexAttribBindingStateTest.cpp new file mode 100644 index 00000000..b03202a7 --- /dev/null +++ b/MobileGL/MG_Test/VertexArray/VertexAttribBindingStateTest.cpp @@ -0,0 +1,430 @@ +// MobileGL - MobileGL/MG_Test/VertexArray/VertexAttribBindingStateTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The ARB_vertex_attrib_binding state model, replayed exactly as +// KHR-GL4x.vertex_attrib_binding.basic-state1/3/4 and .negative-* walk it +// (external/openglcts/modules/gl/gl4cVertexAttribBindingTests.cpp): after each mutation the +// ten per-attribute pnames and the four per-binding-point pnames are read back in full, which +// is what makes a single wrong field visible as itself instead of as a downstream render +// difference. +// +// Four defects are pinned here, all of them frontend-only (both backends reported them +// byte-identically): +// * VERTEX_BINDING_STRIDE defaulted to 0; the spec's initial value is 16. +// * The eager binding -> attribute resolve overwrote VERTEX_ATTRIB_ARRAY_STRIDE / _POINTER, +// which are legacy state only glVertexAttrib*Pointer may write. +// * glVertexAttribDivisor did not re-point the attribute at its own binding point, so a +// later resolve restored the old binding's divisor. +// * The binding entry points accepted the default vertex array (name 0) in a core profile. +// +// GPU-free: this is all GL object state, no backend is consulted. + +#include + +#include +#include + +#include "Includes.h" +#include "Init.h" +#include +#include +#include +#include +#include +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +namespace { + + // Mirrors the CTS's VertexAttribState: the initial per-attribute state, mutated field by + // field as the sequence proceeds, and verified in full after every call. + struct AttribState { + explicit AttribState(GLuint attribIndex) : index(attribIndex), binding(attribIndex) {} + + GLuint index = 0; + GLint enabled = 0; + GLint size = 4; + GLint stride = 0; + GLenum type = GL_FLOAT; + GLint normalized = 0; + GLint integer = 0; + GLint isLong = 0; + GLint divisor = 0; + GLuint pointer = 0; + GLuint bufferBinding = 0; + GLuint binding = 0; + GLint relativeOffset = 0; + + void Verify(const char* where) const { + GLint p = -1; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_ENABLED, &p); + EXPECT_EQ(p, enabled) << where << ": ENABLED(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_SIZE, &p); + EXPECT_EQ(p, size) << where << ": SIZE(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_STRIDE, &p); + EXPECT_EQ(p, stride) << where << ": STRIDE(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_TYPE, &p); + EXPECT_EQ(static_cast(p), type) << where << ": TYPE(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_NORMALIZED, &p); + EXPECT_EQ(p, normalized) << where << ": NORMALIZED(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_INTEGER, &p); + EXPECT_EQ(p, integer) << where << ": INTEGER(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_LONG, &p); + EXPECT_EQ(p, isLong) << where << ": LONG(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_DIVISOR, &p); + EXPECT_EQ(p, divisor) << where << ": DIVISOR(" << index << ")"; + void* pp = nullptr; + GetVertexAttribPointerv(index, GL_VERTEX_ATTRIB_ARRAY_POINTER, &pp); + EXPECT_EQ(reinterpret_cast(pp), static_cast(pointer)) + << where << ": POINTER(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING, &p); + EXPECT_EQ(static_cast(p), bufferBinding) << where << ": BUFFER_BINDING(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_BINDING, &p); + EXPECT_EQ(static_cast(p), binding) << where << ": BINDING(" << index << ")"; + GetVertexAttribiv(index, GL_VERTEX_ATTRIB_RELATIVE_OFFSET, &p); + EXPECT_EQ(p, relativeOffset) << where << ": RELATIVE_OFFSET(" << index << ")"; + } + }; + + // Mirrors the CTS's VertexBindingState, initial stride 16 included. + struct BindingState { + explicit BindingState(GLuint bindingIndex) : index(bindingIndex) {} + + GLuint index = 0; + GLuint buffer = 0; + GLint offset = 0; + GLint stride = 16; + GLint divisor = 0; + + void Verify(const char* where) const { + GLint p = -1; + GetIntegeri_v(GL_VERTEX_BINDING_BUFFER, index, &p); + EXPECT_EQ(static_cast(p), buffer) << where << ": VERTEX_BINDING_BUFFER(" << index << ")"; + // The CTS reads the offset through glGetInteger64i_v; that entry point's pname + // routing is a separate defect with its own regression (see the indexed-getter + // parity test), so the state model is pinned through the 32-bit view here. + GetIntegeri_v(GL_VERTEX_BINDING_OFFSET, index, &p); + EXPECT_EQ(p, offset) << where << ": VERTEX_BINDING_OFFSET(" << index << ")"; + GetIntegeri_v(GL_VERTEX_BINDING_STRIDE, index, &p); + EXPECT_EQ(p, stride) << where << ": VERTEX_BINDING_STRIDE(" << index << ")"; + GetIntegeri_v(GL_VERTEX_BINDING_DIVISOR, index, &p); + EXPECT_EQ(p, divisor) << where << ": VERTEX_BINDING_DIVISOR(" << index << ")"; + } + }; + + // Strict core rules only apply when the current EGL context explicitly asked for a core + // profile; the suite's default (no current context) is relaxed. RAII so a failed + // expectation cannot leave the context current for the rest of the binary. + struct ScopedCoreProfileContext { + ScopedCoreProfileContext() { + auto& egl = *MG_State::pEGLContext; + m_display = egl.GetDisplay(EGL_DEFAULT_DISPLAY); + EXPECT_NE(m_display, EGL_NO_DISPLAY); + EXPECT_TRUE(egl.InitializeDisplay(m_display, nullptr, nullptr)); + EGLint configCount = 0; + EXPECT_TRUE(egl.ChooseConfig(m_display, nullptr, &m_config, 1, &configCount)); + const EGLint surfaceAttribs[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE}; + m_surface = egl.CreatePbufferSurface(m_display, m_config, surfaceAttribs); + EXPECT_NE(m_surface, EGL_NO_SURFACE); + const EGLint contextAttribs[] = {EGL_CONTEXT_MAJOR_VERSION, + 3, + EGL_CONTEXT_MINOR_VERSION, + 3, + EGL_CONTEXT_OPENGL_PROFILE_MASK, + EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT, + EGL_NONE}; + m_context = egl.CreateContext(m_display, m_config, EGL_NO_CONTEXT, contextAttribs); + EXPECT_NE(m_context, EGL_NO_CONTEXT); + EXPECT_TRUE(egl.MakeCurrent(m_display, m_surface, m_surface, m_context)); + } + ~ScopedCoreProfileContext() { + auto& egl = *MG_State::pEGLContext; + egl.MakeCurrent(EGL_NO_DISPLAY, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (m_context != EGL_NO_CONTEXT) egl.DestroyContext(m_display, m_context); + if (m_surface != EGL_NO_SURFACE) egl.DestroySurface(m_display, m_surface); + } + ScopedCoreProfileContext(const ScopedCoreProfileContext&) = delete; + ScopedCoreProfileContext& operator=(const ScopedCoreProfileContext&) = delete; + + private: + EGLDisplay m_display = EGL_NO_DISPLAY; + EGLConfig m_config = nullptr; + EGLSurface m_surface = EGL_NO_SURFACE; + MG_State::EGLState::EGLContext::EGLContextHandle m_context = EGL_NO_CONTEXT; + }; + + class VertexAttribBindingStateTest : public ::testing::Test { + protected: + void SetUp() override { + MobileGL::Initialize(); + // A fresh context per case: the state model under test is cumulative, so a leftover + // VAO binding from a neighbour would silently change what "default state" means. + MG_State::pGLContext = MakeUnique(); + GenVertexArrays(1, &m_vao); + BindVertexArray(m_vao); + } + + void TearDown() override { + EXPECT_EQ(GetError(), GL_NO_ERROR) << "test left an unconsumed GL error behind"; + } + + GLuint CreateVbo(GLsizeiptr size) { + GLuint vbo = 0; + GenBuffers(1, &vbo); + BindBuffer(GL_ARRAY_BUFFER, vbo); + BufferData(GL_ARRAY_BUFFER, size, nullptr, GL_DYNAMIC_COPY); + BindBuffer(GL_ARRAY_BUFFER, 0); + return vbo; + } + + static void DrainErrors() { + for (int i = 0; i < 16 && GetError() != GL_NO_ERROR; ++i) { + } + } + + GLuint m_vao = 0; + }; + + // basic-state1's opening block: the initial per-attribute mapping and the per-binding-point + // defaults, VERTEX_BINDING_STRIDE = 16 included. That check is the FIRST thing the CTS case + // does, so a wrong default masked everything the case would have found after it. + TEST_F(VertexAttribBindingStateTest, DefaultsMatchTheSpecInitialState) { + for (GLuint i = 0; i < 16; ++i) { + AttribState(i).Verify("defaults"); + BindingState(i).Verify("defaults"); + } + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + + // basic-state3, verbatim: a full separate-format sequence, then a pointer call, then a + // binding update on top of it. The legacy STRIDE/POINTER pair must stay untouched by every + // step except the glVertexAttribPointer one, and must survive the binding update after it. + TEST_F(VertexAttribBindingStateTest, SeparateFormatSequenceKeepsLegacyStrideAndPointerAtZero) { + const GLuint vbo0 = CreateVbo(10000); + const GLuint vbo1 = CreateVbo(10000); + const GLuint vbo2 = CreateVbo(10000); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + AttribState va0(0), va2(2), va15(15); + BindingState vb0(0), vb2(2), vb15(15); + + VertexAttribFormat(0, 2, GL_BYTE, GL_TRUE, 16); + va0.size = 2; + va0.type = GL_BYTE; + va0.normalized = 1; + va0.relativeOffset = 16; + va0.Verify("after glVertexAttribFormat"); + // The format call says nothing about a buffer, so binding point 0 keeps its defaults - + // stride 16 among them. + vb0.Verify("after glVertexAttribFormat"); + + VertexAttribIFormat(2, 3, GL_INT, 512); + va2.size = 3; + va2.type = GL_INT; + va2.integer = 1; + va2.relativeOffset = 512; + va2.Verify("after glVertexAttribIFormat"); + vb2.Verify("after glVertexAttribIFormat"); + + BindVertexBuffer(0, vbo0, 2048, 128); + va0.bufferBinding = vbo0; + vb0.buffer = vbo0; + vb0.offset = 2048; + vb0.stride = 128; + va0.Verify("after glBindVertexBuffer(0)"); + vb0.Verify("after glBindVertexBuffer(0)"); + + BindVertexBuffer(2, vbo2, 64, 256); + va2.bufferBinding = vbo2; + vb2.buffer = vbo2; + vb2.offset = 64; + vb2.stride = 256; + va2.Verify("after glBindVertexBuffer(2)"); + vb2.Verify("after glBindVertexBuffer(2)"); + + // Attribute 2 moves onto binding 0 and takes that binding point's buffer with it. + VertexAttribBinding(2, 0); + va2.binding = 0; + va2.bufferBinding = vbo0; + va0.Verify("after glVertexAttribBinding(2,0)"); + vb0.Verify("after glVertexAttribBinding(2,0)"); + va2.Verify("after glVertexAttribBinding(2,0)"); + vb2.Verify("after glVertexAttribBinding(2,0)"); + + VertexAttribBinding(0, 15); + va0.binding = 15; + va0.bufferBinding = 0; + va0.Verify("after glVertexAttribBinding(0,15)"); + vb0.Verify("after glVertexAttribBinding(0,15)"); + va15.Verify("after glVertexAttribBinding(0,15)"); + vb15.Verify("after glVertexAttribBinding(0,15)"); + + BindVertexBuffer(15, vbo1, 16, 32); + va0.bufferBinding = vbo1; + va15.bufferBinding = vbo1; + vb15.buffer = vbo1; + vb15.offset = 16; + vb15.stride = 32; + va0.Verify("after glBindVertexBuffer(15)"); + va15.Verify("after glBindVertexBuffer(15)"); + vb15.Verify("after glBindVertexBuffer(15)"); + + // The one call that IS allowed to write the legacy pair - and it also re-points the + // attribute at its own binding point and rewrites that binding point. + BindBuffer(GL_ARRAY_BUFFER, vbo2); + VertexAttribPointer(0, 4, GL_UNSIGNED_BYTE, GL_FALSE, 8, reinterpret_cast(640)); + BindBuffer(GL_ARRAY_BUFFER, 0); + va0.size = 4; + va0.type = GL_UNSIGNED_BYTE; + va0.stride = 8; + va0.pointer = 640; + va0.relativeOffset = 0; + va0.normalized = 0; + va0.binding = 0; + va0.bufferBinding = vbo2; + vb0.buffer = vbo2; + vb0.offset = 640; + vb0.stride = 8; + va2.bufferBinding = vbo2; + va0.Verify("after glVertexAttribPointer"); + vb0.Verify("after glVertexAttribPointer"); + va2.Verify("after glVertexAttribPointer"); + va15.Verify("after glVertexAttribPointer"); + vb15.Verify("after glVertexAttribPointer"); + + // ...and a binding update on top of it leaves the legacy pair exactly where the pointer + // call left it. This is the assertion the eager resolve used to fail. + BindVertexBuffer(0, vbo1, 80, 24); + vb0.buffer = vbo1; + vb0.offset = 80; + vb0.stride = 24; + va0.bufferBinding = vbo1; + va2.bufferBinding = vbo1; + va0.Verify("after the trailing glBindVertexBuffer(0)"); + vb0.Verify("after the trailing glBindVertexBuffer(0)"); + va2.Verify("after the trailing glBindVertexBuffer(0)"); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + + // basic-state4: glVertexAttribDivisor is VertexAttribBinding(i,i) + VertexBindingDivisor(i,d), + // and glVertexBindingDivisor reaches the attribute's own DIVISOR query either way. + TEST_F(VertexAttribBindingStateTest, DivisorGoesThroughTheBindingPoint) { + for (GLuint i = 0; i < 16; ++i) { + AttribState va(i); + BindingState vb(i); + VertexAttribDivisor(i, i + 7); + va.divisor = static_cast(i + 7); + vb.divisor = static_cast(i + 7); + va.Verify("after glVertexAttribDivisor"); + vb.Verify("after glVertexAttribDivisor"); + } + for (GLuint i = 0; i < 16; ++i) { + AttribState va(i); + BindingState vb(i); + VertexBindingDivisor(i, i); + va.divisor = static_cast(i); + vb.divisor = static_cast(i); + va.Verify("after glVertexBindingDivisor"); + vb.Verify("after glVertexBindingDivisor"); + } + + // Attribute 2 moves onto binding 5 and inherits binding 5's divisor; binding 2 keeps its + // own. + VertexAttribBinding(2, 5); + AttribState va5(5); + va5.divisor = 5; + BindingState vb5(5); + vb5.divisor = 5; + AttribState va2(2); + va2.divisor = 5; + va2.binding = 5; + BindingState vb2(2); + vb2.divisor = 2; + va5.Verify("after glVertexAttribBinding(2,5)"); + vb5.Verify("after glVertexAttribBinding(2,5)"); + va2.Verify("after glVertexAttribBinding(2,5)"); + vb2.Verify("after glVertexAttribBinding(2,5)"); + + // ...and glVertexAttribDivisor pulls it back onto binding 2. Guarding the write on + // "binding already == index" left the attribute on binding 5 and threw the divisor away. + VertexAttribDivisor(2, 23); + va2.binding = 2; + va2.divisor = 23; + vb2.divisor = 23; + va5.Verify("after glVertexAttribDivisor(2,23)"); + vb5.Verify("after glVertexAttribDivisor(2,23)"); + va2.Verify("after glVertexAttribDivisor(2,23)"); + vb2.Verify("after glVertexAttribDivisor(2,23)"); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + + // The tail of every negative-* case: with the default vertex array bound, a core profile + // rejects all four binding entry points. + TEST_F(VertexAttribBindingStateTest, BindingApiRejectsTheDefaultVertexArrayInCoreProfile) { + ScopedCoreProfileContext coreContext; + ASSERT_FALSE(MG_State::IsRelaxedSemanticsActive()); + DrainErrors(); + + BindVertexArray(0); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + BindVertexBuffer(0, 7, 0, 12); + EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glBindVertexBuffer"; + VertexAttribFormat(0, 4, GL_FLOAT, GL_FALSE, 0); + EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glVertexAttribFormat"; + VertexAttribIFormat(0, 4, GL_INT, 0); + EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glVertexAttribIFormat"; + VertexAttribBinding(0, 0); + EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glVertexAttribBinding"; + VertexBindingDivisor(0, 1); + EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "glVertexBindingDivisor"; + + BindVertexArray(m_vao); + DrainErrors(); + } + + // ...and the relaxed default - which is what every context that never asked for a core + // profile gets - keeps accepting them, because applications depend on it. + TEST_F(VertexAttribBindingStateTest, BindingApiStillAcceptsTheDefaultVertexArrayWhenRelaxed) { + ASSERT_TRUE(MG_State::IsRelaxedSemanticsActive()); + const GLuint vbo = CreateVbo(1024); + DrainErrors(); + + BindVertexArray(0); + BindVertexBuffer(0, vbo, 0, 12); + EXPECT_EQ(GetError(), GL_NO_ERROR) << "glBindVertexBuffer under relaxed semantics"; + VertexAttribFormat(0, 4, GL_FLOAT, GL_FALSE, 0); + EXPECT_EQ(GetError(), GL_NO_ERROR) << "glVertexAttribFormat under relaxed semantics"; + VertexAttribBinding(0, 0); + EXPECT_EQ(GetError(), GL_NO_ERROR) << "glVertexAttribBinding under relaxed semantics"; + VertexBindingDivisor(0, 1); + EXPECT_EQ(GetError(), GL_NO_ERROR) << "glVertexBindingDivisor under relaxed semantics"; + + BindVertexArray(m_vao); + DrainErrors(); + } + + // MOBILEGL_RELAXED_SEMANTICS wins even on an explicit core-profile context. + TEST_F(VertexAttribBindingStateTest, RelaxedSemanticsOverrideReopensTheDefaultVertexArray) { + ScopedCoreProfileContext coreContext; + const Bool saved = MG_Config::Features.RelaxedSemantics; + MG_Config::Features.RelaxedSemantics = true; + const GLuint vbo = CreateVbo(1024); + DrainErrors(); + + BindVertexArray(0); + BindVertexBuffer(0, vbo, 0, 12); + EXPECT_EQ(GetError(), GL_NO_ERROR); + + BindVertexArray(m_vao); + MG_Config::Features.RelaxedSemantics = saved; + DrainErrors(); + } +} // namespace diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index 1ef58b75..d724eafe 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -511,6 +511,13 @@ namespace MobileGL::MG_Util::BackendLoader { INIT_GLES_FUNC(glGetSamplerParameterIuiv) INIT_GLES_FUNC(glTexBuffer) INIT_GLES_FUNC(glTexBufferRange) + // Optional: absent on an ES 3.2 core driver, and absent on ES 3.1 without the + // matching extension. The tier resolution below picks whichever spelling the + // driver's own support actually comes from. + INIT_GLES_FUNC_OPTIONAL(glTexBufferEXT) + INIT_GLES_FUNC_OPTIONAL(glTexBufferOES) + INIT_GLES_FUNC_OPTIONAL(glTexBufferRangeEXT) + INIT_GLES_FUNC_OPTIONAL(glTexBufferRangeOES) INIT_GLES_FUNC(glTexStorage3DMultisample) INIT_GLES_FUNC(glMapBufferRange) INIT_GLES_FUNC(glBufferStorageEXT) @@ -841,6 +848,9 @@ namespace MobileGL::MG_Util::BackendLoader { Bool hasMultiDrawIndirectExtension = false; Bool hasDrawElementsBaseVertexExtension = false; Bool hasMultiDrawArraysExtension = false; + // Resolved into caps.TextureBufferSupport below, once the ES version is also known. + Bool hasExtTextureBuffer = false; + Bool hasOesTextureBuffer = false; for (GLint i = 0; i < extCount; ++i) { const char* extension = (const char*)glesFuncs.glGetStringi(GL_EXTENSIONS, i); if (extension) { @@ -874,6 +884,12 @@ namespace MobileGL::MG_Util::BackendLoader { std::strcmp(extension, "GL_OES_texture_cube_map_array") == 0) { caps.SupportsTextureCubeMapArray = true; } + if (std::strcmp(extension, "GL_EXT_texture_buffer") == 0) { + hasExtTextureBuffer = true; + } + if (std::strcmp(extension, "GL_OES_texture_buffer") == 0) { + hasOesTextureBuffer = true; + } if (std::strcmp(extension, "GL_EXT_base_instance") == 0) { caps.SupportsBaseInstance = true; } @@ -1050,7 +1066,12 @@ namespace MobileGL::MG_Util::BackendLoader { glesFuncs.glGetIntegerv(GL_MAX_COMPUTE_UNIFORM_BLOCKS, &maxComputeUniformBlocks); glesFuncs.glGetIntegerv(GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS, &maxComputeWorkGroupInvocations); glesFuncs.glGetIntegerv(GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, &maxShaderStorageBufferBindings); - glesFuncs.glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE, &maxTextureBufferSize); + // GL_MAX_TEXTURE_BUFFER_SIZE is deliberately NOT batched here: like + // GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT below, the pname only exists once buffer textures do, + // so on a driver without them it raises GL_INVALID_ENUM, leaves the local at MobileGL's own + // floor, and - because nothing drains the queue until the alignment probe far below - lets + // that error be misattributed to any query in between. It is queried in the guarded block + // that resolves caps.TextureBufferSupport instead. glesFuncs.glGetIntegerv(GL_MAX_UNIFORM_BUFFER_BINDINGS, &maxUniformBufferBindings); glesFuncs.glGetIntegerv(GL_MAX_UNIFORM_BLOCK_SIZE, &maxUniformBlockSize); glesFuncs.glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); @@ -1101,6 +1122,76 @@ namespace MobileGL::MG_Util::BackendLoader { caps.SupportsTextureBorderClamp = true; caps.SupportsTextureCubeMapArray = true; } + // Buffer-texture tier. Core from ES 3.2 on; below that the EXT spelling is preferred over + // the OES one purely because SPIRV-Cross emits GL_EXT_texture_buffer natively, so a driver + // with both needs no directive retargeting. The entry point has to have resolved either + // way - the extension string alone is not support (see the multi-draw note above). + { + using Tier = MG_External::GLESCapabilities::TextureBufferTier; + // Each tier needs the entry point that tier's support actually ships. Gating all + // three on the unsuffixed name - the ES 3.2 CORE spelling - would make every + // EXT/OES driver look unsupported on a strict loader, and would make MobileGL call + // a core entry point the driver never exported on a permissive one. The suffixed + // name is preferred where the support is an extension, with the core name accepted + // as a fallback because drivers that expose both alias them. + const Bool hasCoreEntryPoint = glesFuncs.glTexBuffer != nullptr; + if (esAtLeast32 && hasCoreEntryPoint) { + caps.TextureBufferSupport = Tier::CoreEs32; + } else if (hasExtTextureBuffer && (glesFuncs.glTexBufferEXT != nullptr || hasCoreEntryPoint)) { + caps.TextureBufferSupport = Tier::ExtensionEXT; + } else if (hasOesTextureBuffer && (glesFuncs.glTexBufferOES != nullptr || hasCoreEntryPoint)) { + caps.TextureBufferSupport = Tier::ExtensionOES; + } else { + caps.TextureBufferSupport = Tier::None; + } + + // Assigned unconditionally, like every other capability in this function, so a + // second fill on a reused struct cannot keep a stale true. + caps.MaxTextureBufferSizeIsDriverReported = false; + if (caps.TextureBufferSupport != Tier::None) { + // Drain first: an error left by any earlier probe would otherwise read as this + // query having failed, and the value would be discarded as a non-answer. + if (glesFuncs.glGetError) { + while (glesFuncs.glGetError() != GL_NO_ERROR) { + } + } + glesFuncs.glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE, &maxTextureBufferSize); + if (glesFuncs.glGetError) { + Bool queryFailed = false; + while (glesFuncs.glGetError() != GL_NO_ERROR) { + queryFailed = true; + } + caps.MaxTextureBufferSizeIsDriverReported = !queryFailed; + } else { + caps.MaxTextureBufferSizeIsDriverReported = true; + } + } + // On the None tier the local keeps MobileGL's floor and + // MaxTextureBufferSizeIsDriverReported stays false. The floor, not 0, is what the + // frontend goes on advertising: MobileGL reports an OpenGL 4.x context, where buffer + // textures are core and GL_MAX_TEXTURE_BUFFER_SIZE has a spec minimum of 65536, so 0 + // would be an illegal answer that no conformant app is prepared to read (several + // divide by it or size an allocation with it). The dishonesty is contained by making + // the missing capability loud instead - at capability init here, at glTexBuffer, at + // program build, and as its own driver POST row - because GL offers no way to say + // "buffer textures exist but cannot work". + if (caps.TextureBufferSupport == Tier::None) { + // Two ways to land here, and they are worth telling apart: the ordinary one (too + // old, no extension) and the pathological one (the driver says it has them but + // no entry point resolved), which is a driver or loader fault, not a missing + // feature. + const Bool claimsSupport = esAtLeast32 || hasExtTextureBuffer || hasOesTextureBuffer; + MGLOG_I(" Buffer textures: UNSUPPORTED (%s). Any shader sampling a " + "samplerBuffer will fail to compile, and MobileGL keeps advertising " + "GL_MAX_TEXTURE_BUFFER_SIZE = %d because a GL 4.x context may not report 0.", + claimsSupport + ? "this driver advertises buffer textures but no glTexBuffer entry " + "point resolved, so none of them can be called" + : "ES core needs 3.2, and neither GL_EXT_texture_buffer nor " + "GL_OES_texture_buffer is present", + maxTextureBufferSize); + } + } if (caps.SupportsTextureFilterAnisotropy) { GLfloat maxTextureMaxAnisotropy = 1.0f; glesFuncs.glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &maxTextureMaxAnisotropy); @@ -1215,7 +1306,16 @@ namespace MobileGL::MG_Util::BackendLoader { MGLOG_I(" GL_MAX_COMPUTE_UNIFORM_BLOCKS: %d", caps.MaxComputeUniformBlocks); MGLOG_I(" GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS: %d", caps.MaxComputeWorkGroupInvocations); MGLOG_I(" GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS: %d", caps.MaxShaderStorageBufferBindings); - MGLOG_I(" GL_MAX_TEXTURE_BUFFER_SIZE: %d", caps.MaxTextureBufferSize); + // Three distinct states, and the suffix must not conflate them: a driver answer, a floor + // kept because there are no buffer textures to ask about, and a floor kept because the + // driver claimed buffer textures but then refused the query (which is a driver bug worth + // seeing spelled out rather than hidden behind the same wording as the honest case). + MGLOG_I(" GL_MAX_TEXTURE_BUFFER_SIZE: %d%s", caps.MaxTextureBufferSize, + caps.MaxTextureBufferSizeIsDriverReported + ? "" + : (caps.TextureBufferSupport == MG_External::GLESCapabilities::TextureBufferTier::None + ? " (MobileGL floor - the driver has no buffer textures to ask)" + : " (MobileGL floor - the driver claims buffer textures but rejected the query)")); MGLOG_I(" GL_MAX_UNIFORM_BUFFER_BINDINGS: %d", caps.MaxUniformBufferBindings); MGLOG_I(" GL_MAX_UNIFORM_BLOCK_SIZE: %d", caps.MaxUniformBlockSize); MGLOG_I(" GL_MAX_IMAGE_UNITS: %d", caps.MaxImageUnits); diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index 4c8976ca..e32c5bf5 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -600,6 +600,16 @@ namespace MobileGL { GL_FUNC_TYPEDEF(void, glSamplerParameterIuiv, GLuint sampler, GLenum pname, const GLuint* param) GL_FUNC_TYPEDEF(void, glGetSamplerParameterIiv, GLuint sampler, GLenum pname, GLint* params) GL_FUNC_TYPEDEF(void, glGetSamplerParameterIuiv, GLuint sampler, GLenum pname, GLuint* params) + // The unsuffixed names are the ES 3.2 CORE entry points. A driver whose buffer-texture + // support comes from GL_EXT_texture_buffer or GL_OES_texture_buffer exports the + // suffixed spellings instead, and a strict eglGetProcAddress returns NULL for the core + // one there - so resolving only the core name makes both extension tiers look absent. + GL_FUNC_TYPEDEF(void, glTexBufferEXT, GLenum target, GLenum internalformat, GLuint buffer) + GL_FUNC_TYPEDEF(void, glTexBufferOES, GLenum target, GLenum internalformat, GLuint buffer) + GL_FUNC_TYPEDEF(void, glTexBufferRangeEXT, GLenum target, GLenum internalformat, GLuint buffer, + GLintptr offset, GLsizeiptr size) + GL_FUNC_TYPEDEF(void, glTexBufferRangeOES, GLenum target, GLenum internalformat, GLuint buffer, + GLintptr offset, GLsizeiptr size) GL_FUNC_TYPEDEF(void, glTexBuffer, GLenum target, GLenum internalformat, GLuint buffer) GL_FUNC_TYPEDEF(void, glTexBufferRange, GLenum target, GLenum internalformat, GLuint buffer, GLintptr offset, GLsizeiptr size) @@ -1002,6 +1012,10 @@ namespace MobileGL { GL_FUNC_DECL(glGetSamplerParameterIuiv) GL_FUNC_DECL(glTexBuffer) GL_FUNC_DECL(glTexBufferRange) + GL_FUNC_DECL(glTexBufferEXT) + GL_FUNC_DECL(glTexBufferOES) + GL_FUNC_DECL(glTexBufferRangeEXT) + GL_FUNC_DECL(glTexBufferRangeOES) GL_FUNC_DECL(glTexStorage3DMultisample) GL_FUNC_DECL(glMapBufferRange) GL_FUNC_DECL(glBufferStorageEXT) @@ -1057,6 +1071,27 @@ namespace MobileGL { Bool SupportsTextureBorderClamp = false; // GL_TEXTURE_CUBE_MAP_ARRAY: ES 3.2 core, or EXT/OES_texture_cube_map_array before it. Bool SupportsTextureCubeMapArray = false; + // Which spelling of buffer-texture support the host driver has. Desktop GL makes buffer + // textures core from 3.1 on, so the frontend advertises them unconditionally and an app + // may call glTexBuffer at any time; ES only gained them in 3.2, and before that only + // through EXT/OES_texture_buffer. The two extensions are functionally identical but + // their ESSL directives are NOT interchangeable, and SPIRV-Cross hardcodes the EXT + // spelling whenever it emits ESSL below 320 for a Dim=Buffer image - so a driver that + // ships only the OES spelling needs the emitted directive retargeted, and a driver with + // neither cannot compile such a shader at all. Gate on this, never on the entry point: + // eglGetProcAddress hands back live-looking stubs (see AcquireGLESFunctions). + enum class TextureBufferTier : Uint8 { + None = 0, // no core support and neither extension; glTexBuffer is unusable + CoreEs32, // ES >= 3.2, buffer textures are core and ESSL 320 needs no directive + ExtensionEXT, // GL_EXT_texture_buffer; ESSL below 320 must say GL_EXT_texture_buffer + ExtensionOES, // GL_OES_texture_buffer; ESSL below 320 must say GL_OES_texture_buffer + }; + TextureBufferTier TextureBufferSupport = TextureBufferTier::None; + // GL_MAX_TEXTURE_BUFFER_SIZE actually came back from the driver. False means the value + // below is MobileGL's own floor, not a driver answer: the pname is only legal once + // buffer textures exist, and querying it on a driver without them raises + // GL_INVALID_ENUM and leaves the default untouched. + Bool MaxTextureBufferSizeIsDriverReported = false; // GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT of the host driver; only queried when the // extension above is present, and left at 1.0 (no anisotropy) otherwise. Float MaxTextureMaxAnisotropy = 1.0f; diff --git a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp index f5579433..872d6775 100644 --- a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp @@ -10,9 +10,20 @@ #include #include +#include namespace MobileGL::MG_Util::BackendLoader { namespace { + // A Vulkan limit is an unsigned 32-bit count; a GL limit is a signed Int. Drivers do report + // values with the top bit set (UINT32_MAX is the idiomatic "effectively unlimited"), and a + // plain static_cast turned those into small negatives - which every downstream std::min or + // ceiling comparison then accepted as "already small enough". Saturate instead, so a clamp + // above this can be trusted to be the only thing that lowers a limit. + Int SaturateToInt(Uint32 value) { + constexpr Uint32 kMaxInt = static_cast(std::numeric_limits::max()); + return static_cast(std::min(value, kMaxInt)); + } + struct VulkanDynamicFunctions { PFN_vkGetPhysicalDeviceProperties vkGetPhysicalDeviceProperties = nullptr; PFN_vkGetPhysicalDeviceProperties2 vkGetPhysicalDeviceProperties2 = nullptr; @@ -152,47 +163,47 @@ namespace MobileGL::MG_Util::BackendLoader { caps.PointSizeRangeMin = p.limits.pointSizeRange[0]; caps.PointSizeRangeMax = p.limits.pointSizeRange[1]; caps.PointSizeGranularity = p.limits.pointSizeGranularity; - caps.Max3DTextureSize = static_cast(p.limits.maxImageDimension3D); - caps.MaxArrayTextureLayers = static_cast(p.limits.maxImageArrayLayers); - caps.MaxCubeMapTextureSize = static_cast(p.limits.maxImageDimensionCube); - caps.MaxFramebufferWidth = static_cast(p.limits.maxFramebufferWidth); - caps.MaxFramebufferHeight = static_cast(p.limits.maxFramebufferHeight); - caps.MaxFramebufferLayers = static_cast(p.limits.maxFramebufferLayers); + caps.Max3DTextureSize = SaturateToInt(p.limits.maxImageDimension3D); + caps.MaxArrayTextureLayers = SaturateToInt(p.limits.maxImageArrayLayers); + caps.MaxCubeMapTextureSize = SaturateToInt(p.limits.maxImageDimensionCube); + caps.MaxFramebufferWidth = SaturateToInt(p.limits.maxFramebufferWidth); + caps.MaxFramebufferHeight = SaturateToInt(p.limits.maxFramebufferHeight); + caps.MaxFramebufferLayers = SaturateToInt(p.limits.maxFramebufferLayers); caps.MaxRenderbufferSize = ResolveMaxRenderbufferSize(p.limits); - caps.MaxTextureSize = static_cast(p.limits.maxImageDimension2D); + caps.MaxTextureSize = SaturateToInt(p.limits.maxImageDimension2D); caps.MaxColorTextureSamples = MaxSampleCountFromFlags(p.limits.sampledImageColorSampleCounts); caps.MaxDepthTextureSamples = MaxSampleCountFromFlags(p.limits.sampledImageDepthSampleCounts); caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(p.limits); caps.MaxIntegerSamples = MaxSampleCountFromFlags(p.limits.sampledImageIntegerSampleCounts); caps.MaxSamples = caps.MaxFramebufferSamples; - caps.MaxSampleMaskWords = static_cast(p.limits.maxSampleMaskWords); - caps.MaxTextureImageUnits = static_cast(p.limits.maxPerStageDescriptorSampledImages); - caps.MaxVertexTextureImageUnits = static_cast(p.limits.maxPerStageDescriptorSampledImages); - caps.MaxComputeTextureImageUnits = static_cast(p.limits.maxPerStageDescriptorSampledImages); - caps.MaxCombinedTextureImageUnits = static_cast(p.limits.maxDescriptorSetSampledImages); - caps.MaxVertexAttribs = static_cast(p.limits.maxVertexInputAttributes); - caps.MaxComputeShaderStorageBlocks = static_cast(p.limits.maxPerStageDescriptorStorageBuffers); - caps.MaxCombinedShaderStorageBlocks = static_cast(p.limits.maxDescriptorSetStorageBuffers); - caps.MaxComputeUniformBlocks = static_cast(p.limits.maxPerStageDescriptorUniformBuffers); - caps.MaxComputeWorkGroupInvocations = static_cast(p.limits.maxComputeWorkGroupInvocations); - caps.MaxShaderStorageBufferBindings = static_cast(p.limits.maxDescriptorSetStorageBuffers); - caps.MaxTextureBufferSize = static_cast(p.limits.maxTexelBufferElements); + caps.MaxSampleMaskWords = SaturateToInt(p.limits.maxSampleMaskWords); + caps.MaxTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages); + caps.MaxVertexTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages); + caps.MaxComputeTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages); + caps.MaxCombinedTextureImageUnits = SaturateToInt(p.limits.maxDescriptorSetSampledImages); + caps.MaxVertexAttribs = SaturateToInt(p.limits.maxVertexInputAttributes); + caps.MaxComputeShaderStorageBlocks = SaturateToInt(p.limits.maxPerStageDescriptorStorageBuffers); + caps.MaxCombinedShaderStorageBlocks = SaturateToInt(p.limits.maxDescriptorSetStorageBuffers); + caps.MaxComputeUniformBlocks = SaturateToInt(p.limits.maxPerStageDescriptorUniformBuffers); + caps.MaxComputeWorkGroupInvocations = SaturateToInt(p.limits.maxComputeWorkGroupInvocations); + caps.MaxShaderStorageBufferBindings = SaturateToInt(p.limits.maxDescriptorSetStorageBuffers); + caps.MaxTextureBufferSize = SaturateToInt(p.limits.maxTexelBufferElements); caps.TextureBufferOffsetAlignment = static_cast(std::max(1, p.limits.minTexelBufferOffsetAlignment)); - caps.MaxUniformBufferBindings = static_cast(p.limits.maxDescriptorSetUniformBuffers); - caps.MaxUniformBlockSize = static_cast(p.limits.maxUniformBufferRange); - caps.MaxImageUnits = static_cast(p.limits.maxPerStageDescriptorStorageImages); - caps.MaxCombinedImageUniforms = static_cast(p.limits.maxDescriptorSetStorageImages); - caps.MaxComputeImageUniforms = static_cast(p.limits.maxPerStageDescriptorStorageImages); - caps.MaxDrawBuffers = static_cast(p.limits.maxFragmentOutputAttachments); - caps.MaxColorAttachments = static_cast(p.limits.maxColorAttachments); - caps.MaxClipDistances = static_cast(p.limits.maxClipDistances); - caps.MaxViewports = static_cast(p.limits.maxViewports); - caps.MaxViewportWidth = static_cast(p.limits.maxViewportDimensions[0]); - caps.MaxViewportHeight = static_cast(p.limits.maxViewportDimensions[1]); + caps.MaxUniformBufferBindings = SaturateToInt(p.limits.maxDescriptorSetUniformBuffers); + caps.MaxUniformBlockSize = SaturateToInt(p.limits.maxUniformBufferRange); + caps.MaxImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorStorageImages); + caps.MaxCombinedImageUniforms = SaturateToInt(p.limits.maxDescriptorSetStorageImages); + caps.MaxComputeImageUniforms = SaturateToInt(p.limits.maxPerStageDescriptorStorageImages); + caps.MaxDrawBuffers = SaturateToInt(p.limits.maxFragmentOutputAttachments); + caps.MaxColorAttachments = SaturateToInt(p.limits.maxColorAttachments); + caps.MaxClipDistances = SaturateToInt(p.limits.maxClipDistances); + caps.MaxViewports = SaturateToInt(p.limits.maxViewports); + caps.MaxViewportWidth = SaturateToInt(p.limits.maxViewportDimensions[0]); + caps.MaxViewportHeight = SaturateToInt(p.limits.maxViewportDimensions[1]); caps.ViewportBoundsRangeMin = p.limits.viewportBoundsRange[0]; caps.ViewportBoundsRangeMax = p.limits.viewportBoundsRange[1]; - caps.ViewportSubpixelBits = static_cast(p.limits.viewportSubPixelBits); + caps.ViewportSubpixelBits = SaturateToInt(p.limits.viewportSubPixelBits); FillFragmentInterpolationLimits(caps, p.limits); VkPhysicalDeviceFeatures supportedFeatures{}; @@ -269,47 +280,47 @@ namespace MobileGL::MG_Util::BackendLoader { caps.PointSizeRangeMin = properties.limits.pointSizeRange[0]; caps.PointSizeRangeMax = properties.limits.pointSizeRange[1]; caps.PointSizeGranularity = properties.limits.pointSizeGranularity; - caps.Max3DTextureSize = static_cast(properties.limits.maxImageDimension3D); - caps.MaxArrayTextureLayers = static_cast(properties.limits.maxImageArrayLayers); - caps.MaxCubeMapTextureSize = static_cast(properties.limits.maxImageDimensionCube); - caps.MaxFramebufferWidth = static_cast(properties.limits.maxFramebufferWidth); - caps.MaxFramebufferHeight = static_cast(properties.limits.maxFramebufferHeight); - caps.MaxFramebufferLayers = static_cast(properties.limits.maxFramebufferLayers); + caps.Max3DTextureSize = SaturateToInt(properties.limits.maxImageDimension3D); + caps.MaxArrayTextureLayers = SaturateToInt(properties.limits.maxImageArrayLayers); + caps.MaxCubeMapTextureSize = SaturateToInt(properties.limits.maxImageDimensionCube); + caps.MaxFramebufferWidth = SaturateToInt(properties.limits.maxFramebufferWidth); + caps.MaxFramebufferHeight = SaturateToInt(properties.limits.maxFramebufferHeight); + caps.MaxFramebufferLayers = SaturateToInt(properties.limits.maxFramebufferLayers); caps.MaxRenderbufferSize = ResolveMaxRenderbufferSize(properties.limits); - caps.MaxTextureSize = static_cast(properties.limits.maxImageDimension2D); + caps.MaxTextureSize = SaturateToInt(properties.limits.maxImageDimension2D); caps.MaxColorTextureSamples = MaxSampleCountFromFlags(properties.limits.sampledImageColorSampleCounts); caps.MaxDepthTextureSamples = MaxSampleCountFromFlags(properties.limits.sampledImageDepthSampleCounts); caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(properties.limits); caps.MaxIntegerSamples = MaxSampleCountFromFlags(properties.limits.sampledImageIntegerSampleCounts); caps.MaxSamples = caps.MaxFramebufferSamples; - caps.MaxSampleMaskWords = static_cast(properties.limits.maxSampleMaskWords); - caps.MaxTextureImageUnits = static_cast(properties.limits.maxPerStageDescriptorSampledImages); - caps.MaxVertexTextureImageUnits = static_cast(properties.limits.maxPerStageDescriptorSampledImages); - caps.MaxComputeTextureImageUnits = static_cast(properties.limits.maxPerStageDescriptorSampledImages); - caps.MaxCombinedTextureImageUnits = static_cast(properties.limits.maxDescriptorSetSampledImages); - caps.MaxVertexAttribs = static_cast(properties.limits.maxVertexInputAttributes); - caps.MaxComputeShaderStorageBlocks = static_cast(properties.limits.maxPerStageDescriptorStorageBuffers); - caps.MaxCombinedShaderStorageBlocks = static_cast(properties.limits.maxDescriptorSetStorageBuffers); - caps.MaxComputeUniformBlocks = static_cast(properties.limits.maxPerStageDescriptorUniformBuffers); - caps.MaxComputeWorkGroupInvocations = static_cast(properties.limits.maxComputeWorkGroupInvocations); - caps.MaxShaderStorageBufferBindings = static_cast(properties.limits.maxDescriptorSetStorageBuffers); - caps.MaxTextureBufferSize = static_cast(properties.limits.maxTexelBufferElements); + caps.MaxSampleMaskWords = SaturateToInt(properties.limits.maxSampleMaskWords); + caps.MaxTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages); + caps.MaxVertexTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages); + caps.MaxComputeTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages); + caps.MaxCombinedTextureImageUnits = SaturateToInt(properties.limits.maxDescriptorSetSampledImages); + caps.MaxVertexAttribs = SaturateToInt(properties.limits.maxVertexInputAttributes); + caps.MaxComputeShaderStorageBlocks = SaturateToInt(properties.limits.maxPerStageDescriptorStorageBuffers); + caps.MaxCombinedShaderStorageBlocks = SaturateToInt(properties.limits.maxDescriptorSetStorageBuffers); + caps.MaxComputeUniformBlocks = SaturateToInt(properties.limits.maxPerStageDescriptorUniformBuffers); + caps.MaxComputeWorkGroupInvocations = SaturateToInt(properties.limits.maxComputeWorkGroupInvocations); + caps.MaxShaderStorageBufferBindings = SaturateToInt(properties.limits.maxDescriptorSetStorageBuffers); + caps.MaxTextureBufferSize = SaturateToInt(properties.limits.maxTexelBufferElements); caps.TextureBufferOffsetAlignment = static_cast(std::max(1, properties.limits.minTexelBufferOffsetAlignment)); - caps.MaxUniformBufferBindings = static_cast(properties.limits.maxDescriptorSetUniformBuffers); - caps.MaxUniformBlockSize = static_cast(properties.limits.maxUniformBufferRange); - caps.MaxImageUnits = static_cast(properties.limits.maxPerStageDescriptorStorageImages); - caps.MaxCombinedImageUniforms = static_cast(properties.limits.maxDescriptorSetStorageImages); - caps.MaxComputeImageUniforms = static_cast(properties.limits.maxPerStageDescriptorStorageImages); - caps.MaxDrawBuffers = static_cast(properties.limits.maxFragmentOutputAttachments); - caps.MaxColorAttachments = static_cast(properties.limits.maxColorAttachments); - caps.MaxClipDistances = static_cast(properties.limits.maxClipDistances); - caps.MaxViewports = static_cast(properties.limits.maxViewports); - caps.MaxViewportWidth = static_cast(properties.limits.maxViewportDimensions[0]); - caps.MaxViewportHeight = static_cast(properties.limits.maxViewportDimensions[1]); + caps.MaxUniformBufferBindings = SaturateToInt(properties.limits.maxDescriptorSetUniformBuffers); + caps.MaxUniformBlockSize = SaturateToInt(properties.limits.maxUniformBufferRange); + caps.MaxImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorStorageImages); + caps.MaxCombinedImageUniforms = SaturateToInt(properties.limits.maxDescriptorSetStorageImages); + caps.MaxComputeImageUniforms = SaturateToInt(properties.limits.maxPerStageDescriptorStorageImages); + caps.MaxDrawBuffers = SaturateToInt(properties.limits.maxFragmentOutputAttachments); + caps.MaxColorAttachments = SaturateToInt(properties.limits.maxColorAttachments); + caps.MaxClipDistances = SaturateToInt(properties.limits.maxClipDistances); + caps.MaxViewports = SaturateToInt(properties.limits.maxViewports); + caps.MaxViewportWidth = SaturateToInt(properties.limits.maxViewportDimensions[0]); + caps.MaxViewportHeight = SaturateToInt(properties.limits.maxViewportDimensions[1]); caps.ViewportBoundsRangeMin = properties.limits.viewportBoundsRange[0]; caps.ViewportBoundsRangeMax = properties.limits.viewportBoundsRange[1]; - caps.ViewportSubpixelBits = static_cast(properties.limits.viewportSubPixelBits); + caps.ViewportSubpixelBits = SaturateToInt(properties.limits.viewportSubPixelBits); FillFragmentInterpolationLimits(caps, properties.limits); caps.SupportsWideLines = false; caps.SupportsShaderFloat64 = false; diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp index 5bd3d13a..3f9ccba6 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -422,6 +422,68 @@ namespace MobileGL::MG_Util::SelfTest { "map array texture gets no driver storage at all, so sampling one reads nothing " "and rendering to one does not reach the screen"); } + // WARN, not FAIL, and the choice is deliberate. The consequence is severe - buffer + // textures are CORE in OpenGL 3.1 and MobileGL advertises a 4.x context, so an + // application may use one without asking, and nothing degrades gracefully: the + // texture gets no driver storage, and every shader declaring a samplerBuffer fails + // to compile outright, because SPIRV-Cross emits `#extension GL_EXT_texture_buffer : + // require` for it below ESSL 320, so the program never links and every draw using it + // silently draws nothing. That is how Minecraft 26.3, whose cloud layer is built + // entirely from gl_VertexID plus texelFetch on a GL_R8I buffer texture, loses its + // clouds. But FAIL means "this backend cannot run on this driver", and that is not + // true: such a device runs everything that does not touch a buffer texture. It is + // also exactly the shape of the "Texture cube map array" row above, which loses its + // shaders to the same SPIRV-Cross `: require` mechanism and is a WARN - two adjacent + // rows with one consequence must not carry two severities. + // The limit is stated on every tier because it is the one number an application can + // read, and on the None tier it is knowingly a fiction (see below). + { + using Tier = MG_External::GLESCapabilities::TextureBufferTier; + const Int advertisedLimit = caps.MaxTextureBufferSize; + // A supported tier that then refused GL_MAX_TEXTURE_BUFFER_SIZE is a driver bug; + // the row must not call MobileGL's floor "the driver's own answer" there. + const char* limitProvenance = + caps.MaxTextureBufferSizeIsDriverReported + ? "the driver's own answer" + : "MobileGL's floor - this driver claims buffer textures but rejected the query"; + switch (caps.TextureBufferSupport) { + case Tier::CoreEs32: + builder.Pass("Buffer textures", + format("core in ES 3.2; GL_MAX_TEXTURE_BUFFER_SIZE = {} is {}, and " + "ESSL 320 needs no #extension directive to declare a " + "samplerBuffer", + advertisedLimit, limitProvenance)); + break; + case Tier::ExtensionEXT: + builder.Pass("Buffer textures", + format("GL_EXT_texture_buffer; GL_MAX_TEXTURE_BUFFER_SIZE = {} is {}, " + "and the directive SPIRV-Cross emits " + "(GL_EXT_texture_buffer) is the one this driver wants", + advertisedLimit, limitProvenance)); + break; + case Tier::ExtensionOES: + builder.Pass("Buffer textures", + format("GL_OES_texture_buffer; GL_MAX_TEXTURE_BUFFER_SIZE = {} is {}. " + "SPIRV-Cross hardcodes the EXT spelling, so MobileGL " + "retargets the emitted #extension directive to the OES one " + "this driver advertises", + advertisedLimit, limitProvenance)); + break; + case Tier::None: + default: + builder.Warn("Buffer textures", + format("not supported (pre-ES 3.2 without GL_EXT/OES_texture_buffer); " + "glTexBuffer does not exist, so a buffer texture gets no storage, " + "and any shader declaring a samplerBuffer fails to compile and " + "leaves its program unlinked - every draw using it is a silent " + "no-op. MobileGL still reports GL_MAX_TEXTURE_BUFFER_SIZE = {}: " + "the value is a floor it cannot honour, kept because an OpenGL " + "4.x context may not answer 0 and GL has no way to say that a " + "core feature is missing", + advertisedLimit)); + break; + } + } // Reported rather than probed: this one cannot come out any other way. OpenGL ES has no // double-precision vertex format and ESSL has no fp64 type, so there is no driver and no // extension that could make it work - the row exists so the loss is named at startup @@ -1728,6 +1790,29 @@ namespace MobileGL::MG_Util::SelfTest { } else { builder.Warn("dualSrcBlend", "unsupported; GL_SRC1_* dual-source blend factors hard-fail at draw"); } + // The Magma counterpart of the GLES "Buffer textures" row, so the two sections can be + // read side by side. Vulkan has no optional-feature bit here: a uniform texel buffer is + // core, and maxTexelBufferElements has a spec floor of 65536 - exactly the GL 3.1 floor + // for GL_MAX_TEXTURE_BUFFER_SIZE - so this backend can always back a buffer texture and + // the row exists to state the limit MobileGL derives its advertisement from, not to + // report a risk. A driver below the floor would be non-conformant, hence the Warn. + { + const Uint32 maxTexelBufferElements = properties.limits.maxTexelBufferElements; + constexpr Uint32 kGL31MinTextureBufferSize = 65536; + if (maxTexelBufferElements >= kGL31MinTextureBufferSize) { + builder.Pass("maxTexelBufferElements", + format("{}; uniform texel buffers are core in Vulkan, so buffer textures " + "need no extension and MobileGL advertises " + "GL_MAX_TEXTURE_BUFFER_SIZE from this limit", + maxTexelBufferElements)); + } else { + builder.Warn("maxTexelBufferElements", + format("{} (< {}); below the OpenGL 3.1 floor for " + "GL_MAX_TEXTURE_BUFFER_SIZE, so a conformant application may " + "create a buffer texture larger than this driver can view", + maxTexelBufferElements, kGL31MinTextureBufferSize)); + } + } { VkImageFormatProperties sliceProbe{}; const Bool sliceCapable = diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index a5392127..b4fbe707 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -27,8 +27,11 @@ #include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h" #include "SpirvPasses/StripNoPerspectivePass.h" #include "SpirvPasses/EmulateNoPerspectivePass.h" +#include "SpirvPasses/LegalizeFragmentOutputIndexPass.h" #include "spirv-tools/libspirv.h" #include "spirv-tools/optimizer.hpp" +#include "source/opt/build_module.h" +#include "source/opt/ir_context.h" #include "ShaderSourceProcessor.h" #include @@ -537,6 +540,42 @@ namespace MobileGL { return g_spirvValidationFailures.load(std::memory_order_relaxed); } + Bool ShaderCompiler::ModuleDeclaresBufferTextureSampler(const Vector& spirv) { + if (spirv.empty()) { + // Early out rather than letting BuildModule reject it: an empty module is a + // stage that produced no SPIR-V, which is not a capability verdict, and the + // parse would push a spurious diagnostic through the message consumer first. + return false; + } + // Callers gate this on the driver LACKING buffer textures, so the module build + // here only ever happens on a degraded driver that is about to fail the compile + // anyway - it is not on the healthy path. + std::unique_ptr context = spvtools::BuildModule( + SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ModuleDeclaresBufferTextureSampler"), + spirv.data(), spirv.size()); + if (!context) { + // Unparseable here means unusable downstream too; let the ordinary transpile + // path produce the error rather than inventing a capability verdict from it. + return false; + } + for (const spvtools::opt::Instruction& type : context->types_values()) { + if (type.opcode() != spv::Op::OpTypeImage) { + continue; + } + // OpTypeImage in-operands: Sampled Type, Dim, Depth, Arrayed, MS, Sampled, + // Format. Dim is operand 1; Dim::Buffer is what samplerBuffer/isamplerBuffer/ + // usamplerBuffer all lower to, whatever their sampled type - and equally what + // the imageBuffer family lowers to, which is correct here because SPIRV-Cross + // requires the same extension for those. The operand-count guard mirrors + // NormalizeRectCoordinatesPass, which reads the same operand. + if (type.NumInOperands() >= 2 && + static_cast(type.GetSingleWordInOperand(1)) == spv::Dim::Buffer) { + return true; + } + } + return false; + } + bool ShaderCompiler::SanitizeAndOptimizeBinary(const Vector& inputBinary, Vector& outputBinary) { using namespace spvtools; @@ -631,6 +670,75 @@ namespace MobileGL { outputBinary); } + bool ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(const Vector& inputBinary, + Vector& outputBinary) { + using namespace spvtools; + + // Detection gates everything: a module with no dynamically indexed fragment + // output - every shader but a handful - pays one BuildModule and is handed + // back byte for byte, so the folding chain can never perturb a shader that + // did not need it. + if (!LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(inputBinary)) { + outputBinary = inputBinary; + return true; + } + + // Stock passes do the real work. The only bespoke member is the loop-control + // hint the stock unroller demands (see the pass header); with it set, an index + // derived from a loop counter - the shape of the Minecraft 26.3 OIT + // coefficient shader and of most real ones - folds to a literal here, and the + // fallback below never runs. + Optimizer folder(SPV_ENV_VULKAN_1_1); + // First, because both the unroller and the marking pass below read the + // induction variable as an OpPhi, and glslang emits it as loads and stores of + // a Function variable. + folder.RegisterPass(CreateLocalMultiStoreElimPass()); + folder.RegisterPass(LegalizeFragmentOutputIndexPass::CreateMarkLoopsForUnrollPass()); + folder.RegisterPass(CreateLoopUnrollPass(true)); + // Fold the unrolled induction values into the access chains, then clear out + // what constant conditions leave behind. + folder.RegisterPass(CreateCCPPass()); + folder.RegisterPass(CreateSimplificationPass()); + folder.RegisterPass(CreateDeadBranchElimPass()); + folder.RegisterPass(CreateBlockMergePass()); + + Vector folded; + if (!RunOptimizerChecked("LegalizeFragmentOutputIndexingForEssl.fold", folder, inputBinary, + folded) || + folded.empty()) { + // Fail open onto the fallback rather than onto the illegal module. + folded = inputBinary; + } + + if (!LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(folded)) { + outputBinary = folded; + return true; + } + + // Genuinely dynamic (uniform-derived, non-constant trip count, ...): lower it. + Optimizer lowerer(SPV_ENV_VULKAN_1_1); + lowerer.RegisterPass(LegalizeFragmentOutputIndexPass::CreateLowerToConstantSwitchPass()); + // The chains the lowering replaced are dead now; remove_outputs must stay + // false here for the same reason it does in SanitizeAndOptimizeBinary. + lowerer.RegisterPass(CreateAggressiveDCEPass(false)); + + if (!RunOptimizerChecked("LegalizeFragmentOutputIndexingForEssl.lower", lowerer, folded, + outputBinary) || + outputBinary.empty()) { + outputBinary = folded; + return true; + } + + if (LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(outputBinary)) { + // MGLOG_I, deliberately: MGLOG_E/W are compiled out at the INFO level every + // CI and retrace build uses, and this is precisely the diagnostic that has + // to survive to explain a shader the driver is about to reject. + MGLOG_I("[spirv] LegalizeFragmentOutputIndexingForEssl: a fragment output is still " + "indexed dynamically; a strict ES driver will reject this shader"); + } + return true; + } + bool ShaderCompiler::LowerRectImages(const Vector& inputBinary, Vector& outputBinary) { using namespace spvtools; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 895c20ce..787fc543 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -43,6 +43,17 @@ namespace MobileGL { // devices lacking GL_NV_shader_noperspective_interpolation. See EmulateNoPerspectivePass. static bool EmulateNoPerspectiveForEssl(const Vector& inputBinary, Vector& outputBinary); + // Makes every index into a fragment-output array a constant integral + // expression, which is what GLSL ES requires and SPIR-V does not. Runs the + // stock folding chain first (loop unrolling folds the loop-derived indices + // real shaders use), and lowers whatever is left - a genuinely dynamic index - + // to a switch over the array's range. DirectGLES transpile path only: the + // original module is legal for Vulkan, and no other stage is constrained this + // way. Copies the input through untouched when no fragment output is indexed + // dynamically, which is every shader but a handful. + // See LegalizeFragmentOutputIndexPass. + static bool LegalizeFragmentOutputIndexingForEssl(const Vector& inputBinary, + Vector& outputBinary); // Rebases loads of the InstanceIndex builtin to (InstanceIndex - BaseInstance) so // shaders see GL's zero-based gl_InstanceID. Vertex shaders only; DirectVulkan // backend only (glslang's relaxed mode aliases gl_InstanceID to gl_InstanceIndex, @@ -122,6 +133,19 @@ namespace MobileGL { // total. static Uint64 SpirvValidationFailureCount(); static Uint64 NoteSpirvValidationFailure(); + + // True when the module declares any buffer-backed image type - an OpTypeImage with + // Dim = Buffer. That is the samplerBuffer / isamplerBuffer / usamplerBuffer + // family and equally the imageBuffer / iimageBuffer / uimageBuffer one: SPIRV-Cross + // requires GL_EXT_texture_buffer for both, from the same branch, so both are + // uncompilable on a driver without buffer textures and both belong here. + // DirectGLES asks before handing the transpiled ESSL to the driver: buffer + // textures are core in the OpenGL 3.1+ context MobileGL advertises but need + // ES 3.2 or EXT/OES_texture_buffer on the host, and on a driver without them + // SPIRV-Cross's `#extension ... : require` makes the shader uncompilable. The + // check exists so that failure can be reported as the missing capability it is, + // naming the shader, rather than as a driver info log nobody sees. + static Bool ModuleDeclaresBufferTextureSampler(const Vector& spirv); }; } // namespace ShaderTranspiler } // namespace MG_Util diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp new file mode 100644 index 00000000..ecfd89eb --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp @@ -0,0 +1,580 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "LegalizeFragmentOutputIndexPass.h" + +#include "spirv.hpp" +#include "source/opt/basic_block.h" +#include "source/opt/build_module.h" +#include "source/opt/constants.h" +#include "source/opt/def_use_manager.h" +#include "source/opt/function.h" +#include "source/opt/instruction.h" +#include "source/opt/ir_builder.h" +#include "source/opt/ir_context.h" +#include "source/opt/loop_descriptor.h" +#include "source/opt/module.h" +#include "source/opt/type_manager.h" +#include "source/util/make_unique.h" + +#include +#include +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::MakeUnique; + using spvtools::opt::BasicBlock; + using spvtools::opt::Function; + using spvtools::opt::Instruction; + using spvtools::opt::InstructionBuilder; + using spvtools::opt::IRContext; + using spvtools::opt::Operand; + + // A fragment output array is at most GL_MAX_DRAW_BUFFERS elements (8 on ES + // 3.0, 16 in practice) and each lowered element costs one basic block, so a + // module claiming more than this is refused rather than exploded. + constexpr uint32_t kMaxLoweredArrayLength = 32; + // One CFG-changing rewrite per round (analyses are dropped after each), so + // the round budget bounds the work on a pathological module. + constexpr int kMaxLoweringRounds = 256; + // Full unrolling copies the body once per iteration, and nothing in the stock + // unroller bounds that. A shader whose output index comes from a 4096-trip + // loop would be legalized into a module orders of magnitude larger and slower + // to compile - so past this count the loop is left alone and the switch + // lowering, whose cost is the array length rather than the trip count, takes + // it instead. Real shaders of this shape (Minecraft 26.3's OIT coefficient + // writer included) iterate a handful of times. + constexpr size_t kMaxUnrolledIterations = 64; + + struct DynamicIndexUse { + Instruction* accessChain = nullptr; + uint32_t arrayLength = 0; + }; + + bool HasFragmentEntryPoint(IRContext* context) { + for (const Instruction& entryPoint : context->module()->entry_points()) { + if (static_cast(entryPoint.GetSingleWordInOperand(0)) == + spv::ExecutionModel::Fragment) { + return true; + } + } + return false; + } + + // Every Output-storage variable whose pointee is an array, mapped to that + // array's length. A length that is not a plain OpConstant (a spec constant) + // maps to 0: still detected as illegal ESSL, never lowered. + std::unordered_map CollectOutputArrays(IRContext* context) { + std::unordered_map outputArrays; + auto* defUseMgr = context->get_def_use_mgr(); + auto* constantMgr = context->get_constant_mgr(); + + for (Instruction& inst : context->module()->types_values()) { + if (inst.opcode() != spv::Op::OpVariable || + static_cast(inst.GetSingleWordInOperand(0)) != + spv::StorageClass::Output) { + continue; + } + + Instruction* pointerType = defUseMgr->GetDef(inst.type_id()); + if (pointerType == nullptr || pointerType->opcode() != spv::Op::OpTypePointer) { + continue; + } + Instruction* pointeeType = defUseMgr->GetDef(pointerType->GetSingleWordInOperand(1)); + if (pointeeType == nullptr || pointeeType->opcode() != spv::Op::OpTypeArray) { + continue; + } + + uint32_t arrayLength = 0; + const spvtools::opt::analysis::Constant* lengthConstant = + constantMgr->FindDeclaredConstant(pointeeType->GetSingleWordInOperand(1)); + if (lengthConstant != nullptr && lengthConstant->AsIntConstant() != nullptr) { + arrayLength = lengthConstant->AsIntConstant()->GetU32BitValue(); + } + outputArrays.emplace(inst.result_id(), arrayLength); + } + return outputArrays; + } + + // "Constant integral expression" in the ESSL sense: an OpConstant (or the + // zero an OpConstantNull stands for). A spec constant is deliberately NOT + // one - SPIRV-Cross prints it as an identifier, which is exactly what the + // driver rejects. + bool IsConstantIndex(IRContext* context, uint32_t indexId) { + Instruction* def = context->get_def_use_mgr()->GetDef(indexId); + return def != nullptr && (def->opcode() == spv::Op::OpConstant || + def->opcode() == spv::Op::OpConstantNull); + } + + // Access chains that index a fragment output array with a non-constant. + // Only the FIRST index is considered: it is the one that selects the array + // element, and it is the only one ESSL constrains. Chains rooted at another + // access chain (a component of an element) are indexing inside the element + // and are legal however they are computed. + std::vector CollectDynamicIndexUses(IRContext* context) { + std::vector uses; + if (!HasFragmentEntryPoint(context)) { + return uses; + } + + const std::unordered_map outputArrays = CollectOutputArrays(context); + if (outputArrays.empty()) { + return uses; + } + + for (Function& function : *context->module()) { + for (BasicBlock& block : function) { + for (Instruction& inst : block) { + if (inst.opcode() != spv::Op::OpAccessChain && + inst.opcode() != spv::Op::OpInBoundsAccessChain) { + continue; + } + if (inst.NumInOperands() < 2) { + continue; + } + const auto arrayIt = outputArrays.find(inst.GetSingleWordInOperand(0)); + if (arrayIt == outputArrays.end()) { + continue; + } + if (IsConstantIndex(context, inst.GetSingleWordInOperand(1))) { + continue; + } + uses.push_back({&inst, arrayIt->second}); + } + } + } + return uses; + } + + // The array index operand of |accessChain| replaced by the constant |element|, + // built at the builder's insertion point. Every later index is copied through + // unchanged: `coeff[idx][i]` keeps its (legal) dynamic component index. + Instruction* CloneChainWithConstantIndex(InstructionBuilder& builder, IRContext* context, + Instruction* accessChain, uint32_t constantIndexId) { + std::vector operands; + operands.reserve(accessChain->NumInOperands()); + for (uint32_t i = 0; i < accessChain->NumInOperands(); ++i) { + if (i == 1) { + operands.push_back({SPV_OPERAND_TYPE_ID, {constantIndexId}}); + } else { + operands.push_back(accessChain->GetInOperand(i)); + } + } + return builder.AddInstruction(MakeUnique(context, accessChain->opcode(), + accessChain->type_id(), + context->TakeNextId(), operands)); + } + + // The id of |element| as a constant of the same integer type as |indexId|. + uint32_t ConstantLikeIndex(IRContext* context, uint32_t indexId, uint32_t element) { + Instruction* indexDef = context->get_def_use_mgr()->GetDef(indexId); + const spvtools::opt::analysis::Type* indexType = + context->get_type_mgr()->GetType(indexDef->type_id()); + const spvtools::opt::analysis::Constant* constant = + context->get_constant_mgr()->GetConstant(indexType, {element}); + return context->get_constant_mgr()->GetDefiningInstruction(constant)->result_id(); + } + + // A 32-bit integer is the only index this pass lowers: OpSwitch matches its + // literals against the selector's width, and every ESSL fragment-output index + // is an int or uint. + bool IsLowerableIndexType(IRContext* context, uint32_t indexId) { + Instruction* indexDef = context->get_def_use_mgr()->GetDef(indexId); + if (indexDef == nullptr) { + return false; + } + const spvtools::opt::analysis::Type* type = + context->get_type_mgr()->GetType(indexDef->type_id()); + const spvtools::opt::analysis::Integer* integer = + type != nullptr ? type->AsInteger() : nullptr; + return integer != nullptr && integer->width() == 32; + } + + // The condition type OpSelect needs for |resultTypeId|. Before SPIR-V 1.4 a + // scalar bool may not select between vectors, so a vector result needs a bool + // vector of the same width - built by broadcasting the scalar comparison. + // Anything that is neither scalar nor vector (a matrix or struct element) is + // refused: pre-1.4 OpSelect cannot express it either. + bool TryGetSelectConditionType(IRContext* context, uint32_t resultTypeId, + uint32_t* conditionTypeId, uint32_t* dimension) { + auto* typeMgr = context->get_type_mgr(); + const spvtools::opt::analysis::Type* resultType = typeMgr->GetType(resultTypeId); + if (resultType == nullptr) { + return false; + } + + spvtools::opt::analysis::Bool boolType; + if (resultType->AsVector() != nullptr) { + const uint32_t count = resultType->AsVector()->element_count(); + spvtools::opt::analysis::Vector boolVector(&boolType, count); + *conditionTypeId = typeMgr->GetTypeInstruction(&boolVector); + *dimension = count; + return *conditionTypeId != 0; + } + if (resultType->AsInteger() != nullptr || resultType->AsFloat() != nullptr || + resultType->AsBool() != nullptr) { + *conditionTypeId = typeMgr->GetTypeInstruction(&boolType); + *dimension = 1; + return *conditionTypeId != 0; + } + return false; + } + + // Whether fully unrolling |loop| is bounded work. The trip count is read the + // same way the stock unroller reads it, so a loop this declines to measure is + // one CanPerformUnroll would refuse anyway - the hint would be inert on it, + // and the fallback lowering is what handles it. Requires the induction + // variable to already be an OpPhi, which is why this runs after ssa-rewrite. + bool IsBoundedUnrollCandidate(spvtools::opt::Loop* loop) { + const spvtools::opt::BasicBlock* condition = loop->FindConditionBlock(); + if (condition == nullptr) { + return false; + } + const Instruction* induction = loop->FindConditionVariable(condition); + if (induction == nullptr || induction->opcode() != spv::Op::OpPhi) { + return false; + } + size_t iterations = 0; + if (!loop->FindNumberOfIterations(induction, &*condition->ctail(), &iterations)) { + return false; + } + return iterations <= kMaxUnrolledIterations; + } + } // namespace + + bool LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing( + const std::vector& binary) { + if (binary.empty()) { + return false; + } + std::unique_ptr context = spvtools::BuildModule( + SPV_ENV_VULKAN_1_1, + [](spv_message_level_t, const char*, const spv_position_t&, const char*) {}, + binary.data(), binary.size()); + if (!context) { + return false; + } + return !CollectDynamicIndexUses(context.get()).empty(); + } + + spvtools::opt::Pass::Status LegalizeFragmentOutputIndexPass::Process() { + return m_mode == Mode::MarkLoopsForUnroll ? MarkLoopsForUnroll() : LowerToConstantSwitch(); + } + + spvtools::opt::Pass::Status LegalizeFragmentOutputIndexPass::MarkLoopsForUnroll() { + auto* irContext = context(); + const std::vector uses = CollectDynamicIndexUses(irContext); + if (uses.empty()) { + return Status::SuccessWithoutChange; + } + + bool modified = false; + for (const DynamicIndexUse& use : uses) { + BasicBlock* block = irContext->get_instr_block(use.accessChain); + if (block == nullptr) { + continue; + } + Function* function = block->GetParent(); + if (function == nullptr) { + continue; + } + + spvtools::opt::LoopDescriptor* loops = irContext->GetLoopDescriptor(function); + for (spvtools::opt::Loop* loop = (*loops)[block->id()]; loop != nullptr; + loop = loop->GetParent()) { + if (!IsBoundedUnrollCandidate(loop)) { + continue; + } + Instruction* mergeInst = loop->GetHeaderBlock()->GetLoopMergeInst(); + // Only a bare `None` control is promoted, and only when no extra + // literal (PartialCount, PeelCount, ...) follows it: the unroller + // tests the control word for equality with Unroll, so ORing the bit + // into a control that already carries something - DontUnroll above + // all - would neither unroll nor mean what it says. + if (mergeInst == nullptr || mergeInst->NumOperands() != 3 || + mergeInst->GetSingleWordOperand(2) != + static_cast(spv::LoopControlMask::MaskNone)) { + continue; + } + mergeInst->SetOperand( + 2, {static_cast(spv::LoopControlMask::Unroll)}); + modified = true; + } + } + + if (!modified) { + return Status::SuccessWithoutChange; + } + MGLOG_D("[spirv] fragment-output index: marked enclosing loops for full unrolling"); + return Status::SuccessWithChange; + } + + spvtools::opt::Pass::Status LegalizeFragmentOutputIndexPass::LowerToConstantSwitch() { + auto* irContext = context(); + if (!HasFragmentEntryPoint(irContext)) { + return Status::SuccessWithoutChange; + } + + bool modified = false; + // Access chains this pass has already refused, so a shape it cannot rewrite + // exactly cannot spin the round loop. + std::unordered_set declined; + + for (int round = 0; round < kMaxLoweringRounds; ++round) { + const std::vector uses = CollectDynamicIndexUses(irContext); + bool progressed = false; + + for (const DynamicIndexUse& use : uses) { + if (declined.count(use.accessChain->result_id()) != 0) { + continue; + } + const LoweringOutcome outcome = LowerOneChain(use.accessChain, use.arrayLength); + if (outcome == LoweringOutcome::Declined) { + declined.insert(use.accessChain->result_id()); + continue; + } + if (outcome == LoweringOutcome::Changed) { + modified = true; + progressed = true; + // A store rewrite splits the block it sat in; every cached + // analysis (and the instruction list this loop is walking) is + // stale from here on. Recollect from scratch. + break; + } + } + + if (!progressed) { + break; + } + } + + if (!modified) { + return Status::SuccessWithoutChange; + } + return Status::SuccessWithChange; + } + + LegalizeFragmentOutputIndexPass::LoweringOutcome LegalizeFragmentOutputIndexPass::LowerOneChain( + Instruction* accessChain, uint32_t arrayLength) { + auto* irContext = context(); + if (arrayLength == 0 || arrayLength > kMaxLoweredArrayLength) { + MGLOG_D("[spirv] fragment-output index: array length %u is not lowerable", arrayLength); + return LoweringOutcome::Declined; + } + if (!IsLowerableIndexType(irContext, accessChain->GetSingleWordInOperand(1))) { + return LoweringOutcome::Declined; + } + + std::vector stores; + std::vector loads; + bool unsupportedUse = false; + irContext->get_def_use_mgr()->ForEachUser(accessChain, [&](Instruction* user) { + switch (user->opcode()) { + case spv::Op::OpName: + case spv::Op::OpDecorate: + case spv::Op::OpDecorateId: + return; + case spv::Op::OpStore: + // Only as the pointer. A pointer stored as a *value* is not a + // fragment-output write and cannot be redirected element-wise. + if (user->GetSingleWordInOperand(0) == accessChain->result_id()) { + stores.push_back(user); + } else { + unsupportedUse = true; + } + return; + case spv::Op::OpLoad: + // Memory operands (Volatile, Aligned, ...) would be dropped by the + // per-element rebuild, so a load carrying any is refused instead. + if (user->NumInOperands() == 1) { + loads.push_back(user); + } else { + unsupportedUse = true; + } + return; + default: + // A pointer passed to a function, copied, or chained further cannot + // be resolved to one element here. + unsupportedUse = true; + return; + } + }); + + if (unsupportedUse) { + MGLOG_D("[spirv] fragment-output index: chain %%%u has a use this pass cannot rewrite", + accessChain->result_id()); + return LoweringOutcome::Declined; + } + + if (!loads.empty()) { + return LowerLoad(accessChain, arrayLength, loads.front()); + } + if (!stores.empty()) { + return LowerStore(accessChain, arrayLength, stores.front()); + } + + // No uses left: the chain itself is what detection is still seeing. + irContext->KillInst(accessChain); + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return LoweringOutcome::Changed; + } + + // switch (idx) { case 0: o[0] = v; break; case 1: o[1] = v; break; ... } + // + // The block holding the store is split at the store, and the tail becomes the + // switch's merge block, so whatever followed the store still runs exactly once + // on every path. An index outside [0, length) reaches the default target, which + // is the merge block: nothing is stored, which is what an out-of-range write to + // an output array already meant. + LegalizeFragmentOutputIndexPass::LoweringOutcome LegalizeFragmentOutputIndexPass::LowerStore( + Instruction* accessChain, uint32_t arrayLength, Instruction* store) { + auto* irContext = context(); + BasicBlock* block = irContext->get_instr_block(store); + if (block == nullptr) { + return LoweringOutcome::Declined; + } + // Splitting a loop header keeps the label - and so the back edge's target - + // on the first half while the OpLoopMerge moves to the second, which is not + // a loop any more. Refuse instead of producing that. + if (block->GetLoopMergeInst() != nullptr) { + MGLOG_D("[spirv] fragment-output index: store sits in a loop header, declining"); + return LoweringOutcome::Declined; + } + Function* function = block->GetParent(); + if (function == nullptr) { + return LoweringOutcome::Declined; + } + + const uint32_t indexId = accessChain->GetSingleWordInOperand(1); + const uint32_t valueId = store->GetSingleWordInOperand(1); + std::vector memoryOperands; + for (uint32_t i = 2; i < store->NumInOperands(); ++i) { + memoryOperands.push_back(store->GetInOperand(i)); + } + + const uint32_t mergeLabelId = irContext->TakeNextId(); + block->SplitBasicBlock(irContext, mergeLabelId, BasicBlock::iterator(store)); + // |store| now heads the merge block; the per-element stores replace it. + irContext->KillInst(store); + + std::vector> targets; + targets.reserve(arrayLength); + BasicBlock* insertAfter = block; + for (uint32_t element = 0; element < arrayLength; ++element) { + const uint32_t caseLabelId = irContext->TakeNextId(); + auto caseBlock = MakeUnique(MakeUnique( + irContext, spv::Op::OpLabel, 0, caseLabelId, std::initializer_list{})); + caseBlock->SetParent(function); + BasicBlock* casePtr = function->InsertBasicBlockAfter(std::move(caseBlock), insertAfter); + // The builders below register what they add, but this label was built by + // hand: without this the OpSwitch would name a target the def-use manager + // has never seen, which a consistency-checking build calls out. + irContext->AnalyzeDefUse(casePtr->GetLabelInst()); + irContext->set_instr_block(casePtr->GetLabelInst(), casePtr); + + InstructionBuilder caseBuilder( + irContext, casePtr, + IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); + const uint32_t constantId = ConstantLikeIndex(irContext, indexId, element); + Instruction* elementChain = + CloneChainWithConstantIndex(caseBuilder, irContext, accessChain, constantId); + + std::vector storeOperands; + storeOperands.push_back({SPV_OPERAND_TYPE_ID, {elementChain->result_id()}}); + storeOperands.push_back({SPV_OPERAND_TYPE_ID, {valueId}}); + for (const Operand& memoryOperand : memoryOperands) { + storeOperands.push_back(memoryOperand); + } + caseBuilder.AddInstruction( + MakeUnique(irContext, spv::Op::OpStore, 0, 0, storeOperands)); + caseBuilder.AddBranch(mergeLabelId); + + targets.push_back({Operand::OperandData{element}, caseLabelId}); + insertAfter = casePtr; + } + + InstructionBuilder switchBuilder( + irContext, block, IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); + switchBuilder.AddSwitch(indexId, mergeLabelId, targets, mergeLabelId); + + if (irContext->get_def_use_mgr()->NumUsers(accessChain) == 0) { + irContext->KillInst(accessChain); + } + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + MGLOG_D("[spirv] fragment-output index: lowered a dynamic write to a %u-way switch", + arrayLength); + return LoweringOutcome::Changed; + } + + // A read needs no control flow: load every element through a constant index and + // pick with OpSelect. Reading an output array is rare, but it is legal SPIR-V and + // legal ESSL, and the elements this adds reads of were already readable here. + LegalizeFragmentOutputIndexPass::LoweringOutcome LegalizeFragmentOutputIndexPass::LowerLoad( + Instruction* accessChain, uint32_t arrayLength, Instruction* load) { + auto* irContext = context(); + uint32_t conditionTypeId = 0; + uint32_t dimension = 0; + if (!TryGetSelectConditionType(irContext, load->type_id(), &conditionTypeId, &dimension)) { + MGLOG_D("[spirv] fragment-output index: element type is not selectable, declining"); + return LoweringOutcome::Declined; + } + const uint32_t boolTypeId = irContext->get_type_mgr()->GetBoolTypeId(); + const uint32_t indexId = accessChain->GetSingleWordInOperand(1); + + InstructionBuilder builder( + irContext, load, IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); + + uint32_t selectedId = 0; + for (uint32_t element = 0; element < arrayLength; ++element) { + const uint32_t constantId = ConstantLikeIndex(irContext, indexId, element); + Instruction* elementChain = + CloneChainWithConstantIndex(builder, irContext, accessChain, constantId); + Instruction* elementLoad = builder.AddLoad(load->type_id(), elementChain->result_id()); + if (element == 0) { + // Element 0 is the else-arm of the whole ladder, so an out-of-range + // index reads it - an undefined element for an undefined index. + selectedId = elementLoad->result_id(); + continue; + } + + Instruction* isElement = + builder.AddBinaryOp(boolTypeId, spv::Op::OpIEqual, indexId, constantId); + uint32_t conditionId = isElement->result_id(); + if (dimension > 1) { + std::vector components(dimension, conditionId); + conditionId = builder.AddCompositeConstruct(conditionTypeId, components)->result_id(); + } + selectedId = builder + .AddSelect(load->type_id(), conditionId, elementLoad->result_id(), + selectedId) + ->result_id(); + } + + irContext->ReplaceAllUsesWith(load->result_id(), selectedId); + irContext->KillInst(load); + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + MGLOG_D("[spirv] fragment-output index: lowered a dynamic read to %u constant-indexed loads", + arrayLength); + return LoweringOutcome::Changed; + } + + spvtools::Optimizer::PassToken LegalizeFragmentOutputIndexPass::CreateMarkLoopsForUnrollPass() { + return spvtools::Optimizer::PassToken( + MakeUnique(Mode::MarkLoopsForUnroll)); + } + + spvtools::Optimizer::PassToken LegalizeFragmentOutputIndexPass::CreateLowerToConstantSwitchPass() { + return spvtools::Optimizer::PassToken( + MakeUnique(Mode::LowerToConstantSwitch)); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.h new file mode 100644 index 00000000..3c017e3d --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.h @@ -0,0 +1,111 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include "source/opt/pass.h" +#include "spirv-tools/optimizer.hpp" + +#include +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + // GLSL ES requires a *constant integral expression* to index a fragment output + // array (GLSL ES 3.00 4.3.6 / 3.20 4.4.2); SPIR-V has no such rule, so a shader + // that writes `coeff[i]` from a loop reaches SPIRV-Cross intact and comes out as + // ESSL a strict driver rejects outright: + // + // '[' : array indexes for fragment outputs must be constant integral expressions + // + // The program then links nothing and every draw that uses it is a silent no-op. + // Mesa accepts the same source, which is why this only ever showed on the ANGLE + // lane (see tools/trace_replay/README.md, improved-transparency-minecraft-26.3: + // the whole translucent layer disappears because the OIT coefficient shader is + // exactly this shape). + // + // Two modes, used as two halves of one legalization in + // ShaderCompiler::LegalizeFragmentOutputIndexingForEssl: + // + // MarkLoopsForUnroll - the companion the stock unroller needs. spirv-opt's + // CreateLoopUnrollPass only touches loops whose OpLoopMerge carries the + // Unroll loop control (LoopUtils::HasUnrollLoopControl), which glslang emits + // only for an explicit [[unroll]]. This mode sets that hint on the loops that + // actually enclose an offending access chain - and only those, so an + // unrelated long loop elsewhere in the same shader is never unrolled - and + // only when their trip count is known and small, so legalizing a shader can + // never explode it. With the hint set, the stock chain (ssa-rewrite, + // loop-unroll, ccp, simplification, dead-branch-elim) folds a loop-derived + // index to a literal, which is what the real-world shaders (the OIT one + // included) need. Must run AFTER ssa-rewrite: both the trip-count check and + // the unroller itself need the induction variable as an OpPhi. + // + // LowerToConstantSwitch - the fallback for an index that is *genuinely* + // dynamic (uniform-derived, a non-constant trip count, vertex data). It + // rewrites each write through such an access chain into an OpSwitch over the + // array's range with one constant-indexed store per case - the SPIR-V of + // `switch (i) { case 0: o[0] = v; break; case 1: o[1] = v; break; }` - and + // each read into per-element constant-indexed loads combined with OpSelect. + // An out-of-range index stores nothing, which is what indexing an output + // array out of range already meant. + // + // Fragment stage only: every other stage may index an output array dynamically + // in ESSL, and on DirectVulkan the original SPIR-V is legal as-is. The pass + // declines (leaving the module untouched) rather than half-transforming whenever + // it meets a shape it cannot rewrite exactly - a pointer handed to a function, a + // spec-constant array length, an index type that is not a 32-bit integer, or a + // store sitting in a loop header block, where splitting would move the + // OpLoopMerge away from the back edge's target. + class LegalizeFragmentOutputIndexPass final : public spvtools::opt::Pass { + public: + enum class Mode { + MarkLoopsForUnroll, + LowerToConstantSwitch, + }; + + explicit LegalizeFragmentOutputIndexPass(Mode mode) : m_mode(mode) {} + + const char* name() const override { + return m_mode == Mode::MarkLoopsForUnroll ? "mobilegl-mark-fragment-output-index-loops" + : "mobilegl-lower-fragment-output-index"; + } + + Status Process() override; + + static spvtools::Optimizer::PassToken CreateMarkLoopsForUnrollPass(); + static spvtools::Optimizer::PassToken CreateLowerToConstantSwitchPass(); + + // The detection half, on a serialized module: true when a fragment entry + // point indexes an Output-storage array with anything but an OpConstant. + // Cheap enough to gate the whole legalization on (one BuildModule, no + // serialization) and used again after the folding chain to decide whether + // the fallback has to run at all. + static bool BinaryHasDynamicOutputIndexing(const std::vector& binary); + + private: + enum class LoweringOutcome { + // The shape is not one this pass can rewrite exactly; the module keeps + // the illegal chain rather than a half-transform of it. + Declined, + Changed, + }; + + Status MarkLoopsForUnroll(); + Status LowerToConstantSwitch(); + + LoweringOutcome LowerOneChain(spvtools::opt::Instruction* accessChain, uint32_t arrayLength); + LoweringOutcome LowerStore(spvtools::opt::Instruction* accessChain, uint32_t arrayLength, + spvtools::opt::Instruction* store); + LoweringOutcome LowerLoad(spvtools::opt::Instruction* accessChain, uint32_t arrayLength, + spvtools::opt::Instruction* load); + + Mode m_mode; + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp index 0abe8f20..41e43a0d 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.cpp @@ -289,6 +289,43 @@ namespace MobileGL { SPVC_CHK_RETURN } + spvc_result SpvcSession::SetShaderStorageBlockBinding(const UnorderedMap& bindings) { + if (!(usage & SessionUsageBit::Transpile)) return SPVC_ERROR_INVALID_ARGUMENT; + + SPVC_CHK_INIT + const spvc_reflected_resource* list = nullptr; + size_t count = 0; + SPVC_CHK_RESULT(spvc_resources_get_resource_list_for_type( + resources, SPVC_RESOURCE_TYPE_STORAGE_BUFFER, &list, &count)); + for (size_t i = 0; i < count; ++i) { + auto& resource = list[i]; + // Two spellings, because neither one alone identifies the block the GL + // interface query named. `resource.name` is the block's instance name when + // the declaration has one; the block TYPE name (which is what the GL query + // reports for a block) lives on base_type_id. An arrayed block collapses to + // a single SPIR-V resource while GL enumerates it per element, so the bare + // name is also tried with element zero's subscript - the same convention + // ProgramObject::GetShaderStorageBlockBindingOverride documents. + const char* blockTypeName = spvc_compiler_get_name(compiler, resource.base_type_id); + const String candidates[] = { + blockTypeName != nullptr ? String(blockTypeName) : String(), + resource.name != nullptr ? String(resource.name) : String(), + }; + for (const auto& candidate : candidates) { + if (candidate.empty()) continue; + auto it = bindings.find(candidate); + if (it == bindings.end()) it = bindings.find(candidate + "[0]"); + if (it == bindings.end()) continue; + // Negative is "never rebound" - the declared qualifier still stands. + if (it->second < 0) break; + spvc_compiler_set_decoration(compiler, resource.id, SpvDecorationBinding, + static_cast(it->second)); + break; + } + } + SPVC_CHK_RETURN + } + spvc_result SpvcSession::Compile(const char** result) { if (!(usage & SessionUsageBit::Transpile)) return SPVC_ERROR_INVALID_ARGUMENT; SPVC_CHK_INIT diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.h b/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.h index f5296b2e..d01434d2 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.h +++ b/MobileGL/MG_Util/ShaderTranspiler/SpvcSession.h @@ -94,6 +94,17 @@ namespace MobileGL { spvc_result SetOptions(spvc_compiler_options options); Vector GetShaderInterface(spvc_resource_type resource_type) const; spvc_result SetVertexAttribLocation(const UnorderedMap& location); + // Rewrites the Binding decoration of shader storage blocks before emission, so + // the generated source carries the EFFECTIVE binding rather than the declared + // one. This exists for the ESSL backend: glShaderStorageBlockBinding is a GL 4.3 + // entry point with no ES equivalent (ES fixes a storage block's binding at link + // from its layout(binding=) qualifier), so the only place a rebinding can be + // expressed there is the qualifier the transpiler prints. + // + // Keyed by the GL interface-query name of the BLOCK (the block/type name; an + // arrayed block's elements are separate GL resources spelled "B[0]", "B[1]"). + // Entries with a negative value mean "never rebound" and are skipped. + spvc_result SetShaderStorageBlockBinding(const UnorderedMap& bindings); spvc_result Compile(const char** result); const SpvcMetadata& GetMetadata() const; const char* GetLastErrorString() const; diff --git a/MobileGL/MG_Util/Types.h b/MobileGL/MG_Util/Types.h index d34443f1..40a9568f 100644 --- a/MobileGL/MG_Util/Types.h +++ b/MobileGL/MG_Util/Types.h @@ -55,9 +55,37 @@ namespace MobileGL { using SizeT = std::size_t; template using Array = std::array; + // ska::flat_hash_map, the same table MobileGlues settled on, at the same commit. + // + // Open addressing with robin-hood probing. Any insert, emplace, operator[], + // reserve or rehash invalidates every iterator, reference and pointer into the + // map - and NOT only by rehashing: robin-hood insertion swaps the entry being + // placed against the occupant whenever it has travelled further from its desired + // position, so an insert well under the load factor still relocates entries. + // Erase relocates too, and less obviously - deletion shifts the rest of the probe + // cluster backwards, so erasing one key can move a DIFFERENT key's element. + // Where a mapped value's address has to outlive later mutation, the map holds a + // UniquePtr/SharedPtr and the pointee stays put; those sites say so where they + // are declared. + // + // Erase destroys the mapped value BEFORE it repairs the probe cluster, so a + // mapped-value destructor that re-enters the same map sees a hole in the middle + // of a chain and a stale size: a re-entrant find() misses every key past the hole. + // Nothing does that today; do not be the first without checking. + // + // Its value_type is pair with the key exposed mutably, so `it->first =` + // compiles and silently corrupts the table - the one sharp edge this map has + // that a node-based one does not. Note the Allocator default matches that + // value_type: pair, not pair. + // + // T must be move-ASSIGNABLE, not merely move-constructible: robin-hood probing + // swaps the entry being inserted against the one already in the slot whenever it + // has travelled further from its desired position. A move-only RAII type that + // declares a destructor gets no implicit move assignment, so it needs an explicit + // one or the table will not instantiate (see RenderPassEntry). template , class KeyEqual = std::equal_to, - class Allocator = std::allocator>> - using UnorderedMap = FastSTL::unordered_map; + class Allocator = std::allocator>> + using UnorderedMap = ska::flat_hash_map; template inline constexpr std::remove_reference_t&& Move(T&& t) noexcept { return static_cast&&>(t); diff --git a/README.md b/README.md index b7564ebb..693615d4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ MobileGL reuses several open-source projects: * **SPIRV-Cross** by **KhronosGroup** - [Apache License 2.0](https://github.com/KhronosGroup/SPIRV-Cross/blob/master/LICENSE): [github](https://github.com/KhronosGroup/SPIRV-Cross) * **glslang** by **KhronosGroup** - [Various Licenses](https://github.com/KhronosGroup/glslang/blob/main/LICENSE.txt): [github](https://github.com/KhronosGroup/glslang) * **DiligentCore** by **Diligent Graphics** - [Apache License 2.0](https://github.com/DiligentGraphics/DiligentCore/blob/master/License.txt): [github](https://github.com/DiligentGraphics/DiligentCore) +* **flat_hash_map** by **Malte Skarupke** - [Boost Software License 1.0](https://github.com/MobileGL-Dev/flat_hash_map/blob/master/LICENSE): [github](https://github.com/MobileGL-Dev/flat_hash_map) Refer to each component's repository for exact license texts. Any bundled third-party code in this repository is included under the upstream project's license. diff --git a/android-plugin/app/src/trace/cpp/trace_replay_core.cpp b/android-plugin/app/src/trace/cpp/trace_replay_core.cpp index 6add5697..b4af7c93 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_core.cpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_core.cpp @@ -159,6 +159,18 @@ bool LoadMobileGL(const Request& request, std::string& error) { } else { unsetenv("MOBILEGL_COHERENT_AS_FLUSH"); } + if (request.fboAttachmentDumps.empty()) { + unsetenv("MOBILEGL_TRACE_DUMP_FBO_ATTACHMENTS"); + } else { + std::string dumpPoints; + for (const std::string& dumpPoint : request.fboAttachmentDumps) { + if (!dumpPoints.empty()) { + dumpPoints += ';'; + } + dumpPoints += dumpPoint; + } + setenv("MOBILEGL_TRACE_DUMP_FBO_ATTACHMENTS", dumpPoints.c_str(), 1); + } void* handle = dlopen(request.mobileGlLibrary.c_str(), RTLD_NOW | RTLD_GLOBAL); if (handle == nullptr) { @@ -355,9 +367,23 @@ std::string SnapshotPathForCall(const Request& request) { return request.outputDir + "/actual." + call + ".png"; } +// The dump hook rides on apitrace's snapshot path, which only runs for calls in the -S +// callset, so every dump point has to join the target call there. +std::string SnapshotCallSet(const Request& request) { + std::string callSet = std::to_string(request.targetCall); + for (const std::string& dumpPoint : request.fboAttachmentDumps) { + const std::size_t separator = dumpPoint.find(':'); + const std::string call = dumpPoint.substr(0, separator); + if (!call.empty() && call != std::to_string(request.targetCall)) { + callSet += "," + call; + } + } + return callSet; +} + int RunRetraceMain(const Request& request) { std::string prefix = request.outputDir + "/actual."; - std::string callSet = std::to_string(request.targetCall); + std::string callSet = SnapshotCallSet(request); std::string arg0 = "mobilegl-glretrace"; std::string argBenchmark = "-b"; diff --git a/android-plugin/app/src/trace/cpp/trace_replay_core.hpp b/android-plugin/app/src/trace/cpp/trace_replay_core.hpp index 177dcf1d..ed50ec6a 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_core.hpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_core.hpp @@ -24,6 +24,9 @@ struct Request { std::string backend; std::string mobileGlLibrary = "libMobileGL.so"; std::string angleVariant; + // Framebuffer-attachment dump points, each `CALL:DIR[:FBO,FBO,...]`. Debug-only; the + // replay behaves exactly as before when this is empty. + std::vector fboAttachmentDumps; int targetFrame = -1; long long targetCall = -1; int width = 0; diff --git a/android-plugin/trace-replay-ci.sh b/android-plugin/trace-replay-ci.sh index a879d114..6d3bf117 100644 --- a/android-plugin/trace-replay-ci.sh +++ b/android-plugin/trace-replay-ci.sh @@ -186,15 +186,55 @@ collect_run_diagnostics() { adb_device_path exec-out run-as "${package_name}" cat "${app_dir}/output/mobilegl.log" > "${diagnostics_dir}/mobilegl.log" || true } +# Records why a retrace was charged to the infrastructure rather than the code +# under test, so the workflow can count the classes it retried. +record_infrastructure_reason() { + printf '%s\n' "$1" >> "${result_root}/infrastructure-failure-reason.txt" +} + +# True when the replay never got a usable window surface out of ANGLE. EGL +# 0x300b is EGL_BAD_NATIVE_WINDOW and -1000000001 is VK_ERROR_SURFACE_LOST_KHR, +# which ANGLE reports out of vkCreateAndroidSurfaceKHR when the Activity's +# native window is not usable. Observed intermittently on cases that pass in +# every other run, so it is an environment fault, not a property of a trace. +is_angle_surface_lost() { + diagnostics_dir="$1" + retrace_log="${diagnostics_dir}/retrace.log" + mobilegl_log="${diagnostics_dir}/mobilegl.log" + + if [ ! -s "${retrace_log}" ]; then + return 1 + fi + if ! grep -Eq 'EGL surface creation failed: 0x300b|Vulkan error -1000000001|VK_ERROR_SURFACE_LOST_KHR' \ + "${retrace_log}"; then + return 1 + fi + # Co-signature, and the reason this cannot swallow a real regression: a lost + # surface at startup stops MobileGL at init, before it ever runs the capability + # probe. If the probe ran, the replay had a working context and lost it later - + # that is a genuine defect and must stay a failure. + if [ -s "${mobilegl_log}" ] && grep -q 'OpenGL ES capabilities:' "${mobilegl_log}"; then + return 1 + fi + return 0 +} + is_infrastructure_failure() { diagnostics_dir="$1" adb_state="$(cat "${diagnostics_dir}/adb-state.txt" 2>/dev/null || true)" if [ "${adb_state}" != "device" ]; then echo "trace-replay-ci.sh: Android device is unavailable (state: ${adb_state:-unknown})" >&2 + record_infrastructure_reason "device-unavailable" return 0 fi if grep -Eq 'Fatal signal [0-9]+.*[(]system_server[)]|F system_server[ :]' "${diagnostics_dir}/logcat.txt"; then echo "trace-replay-ci.sh: Android system_server crashed during retrace" >&2 + record_infrastructure_reason "system-server-crash" + return 0 + fi + if is_angle_surface_lost "${diagnostics_dir}"; then + echo "trace-replay-ci.sh: ANGLE could not create its window surface (EGL_BAD_NATIVE_WINDOW / VK_ERROR_SURFACE_LOST_KHR) before MobileGL finished init" >&2 + record_infrastructure_reason "angle-surface-lost" return 0 fi return 1 @@ -344,6 +384,30 @@ run_retrace() { copy_app_artifact "${app_dir}/output/retrace.log" "${result_dir}/retrace.log" copy_app_artifact "${app_dir}/output/mobilegl.log" "${result_dir}/mobilegl.log" + # A replay that wrote result.json but did not pass used to print nothing but + # the JSON, which for a non-zero statusCode says only "retrace failed with + # status N". The logs that say why are already on disk here, so echo their + # tails the same way the missing-result.json path does; the job log is the one + # place a failure stays readable after the result artifact expires. + if ! grep -q '"passed"[[:space:]]*:[[:space:]]*true' "${result_dir}/result.json"; then + if [ -s "${result_dir}/retrace.log" ]; then + echo "trace-replay-ci.sh: tail of retrace.log:" >&2 + tail -200 "${result_dir}/retrace.log" >&2 + fi + if [ -s "${result_dir}/mobilegl.log" ]; then + echo "trace-replay-ci.sh: tail of mobilegl.log:" >&2 + tail -200 "${result_dir}/mobilegl.log" >&2 + fi + # A replay that writes result.json still reaches here on an environment + # fault: ANGLE failing to make a window surface reports statusCode 5 rather + # than dying, so it never hit the missing-result.json branch above and used + # to be charged to the trace. + if is_infrastructure_failure "${result_dir}"; then + echo "trace-replay-ci.sh: requesting one infrastructure retry" >&2 + exit "${INFRASTRUCTURE_FAILURE_EXIT_CODE}" + fi + fi + "${PYTHON}" -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); sys.exit(0 if result.get("passed") else f"trace replay failed: {result}")' "${result_dir}/result.json" } diff --git a/include/FastSTL b/include/FastSTL deleted file mode 160000 index 022211c9..00000000 --- a/include/FastSTL +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 022211c9983c70daf86d7d4cfbdb017eb1598c81 diff --git a/include/glslang/MachineIndependent/ParseHelper.h b/include/glslang/MachineIndependent/ParseHelper.h index a2de165e..42bc00c7 100644 --- a/include/glslang/MachineIndependent/ParseHelper.h +++ b/include/glslang/MachineIndependent/ParseHelper.h @@ -367,6 +367,7 @@ class TParseContext : public TParseContextBase { TIntermTyped* vkRelaxedRemapFunctionCall(const TSourceLoc&, TFunction*, TIntermNode*); // returns true if the variable was remapped to something else + void recordUniformInitializer(const TString&, const TType&, const TConstUnionArray&); bool vkRelaxedRemapUniformVariable(const TSourceLoc&, TString&, const TPublicType&, TArraySizes*, TIntermTyped*, TType&); void vkRelaxedRemapUniformMembers(const TSourceLoc&, const TPublicType&, const TType&, const TString&); void vkRelaxedRemapFunctionParameter(TFunction*, TParameter&, std::vector* newParams = nullptr); diff --git a/include/glslang/MachineIndependent/localintermediate.h b/include/glslang/MachineIndependent/localintermediate.h index 0d299bda..e19bc4d7 100644 --- a/include/glslang/MachineIndependent/localintermediate.h +++ b/include/glslang/MachineIndependent/localintermediate.h @@ -611,6 +611,32 @@ class TIntermediate { void setGlobalUniformBinding(unsigned int binding) { globalUniformBlockBinding = binding; } unsigned int getGlobalUniformBinding() const { return globalUniformBlockBinding; } + // A default-block uniform's initializer, folded to constants at parse time. + // + // Desktop GLSL 1.20+ lets a default-block uniform carry an initializer, and that value is + // what the uniform reads until the application overwrites it with glUniform*. Vulkan-relaxed + // parsing sweeps such uniforms into a uniform BLOCK, and a block member cannot carry an + // initializer in SPIR-V - so the value has nowhere to live in the generated module and used + // to be dropped outright, leaving the uniform silently zero. The CLIENT is the only party + // that can still honor it, by writing the value into the block's backing storage once the + // program links, so the folded constants are handed out here instead of discarded. + // + // Scalars appear in the same flattened order glslang folds them in: array element by array + // element, and within a matrix, column by column. Exactly one of the two value vectors is + // populated, chosen by basicType. + struct TUniformInitializer { + std::string name; + TBasicType basicType = EbtVoid; + int vectorSize = 1; // components per vector; 1 for a scalar + int matrixCols = 0; // 0 when the type is not a matrix + int matrixRows = 0; + int arraySize = 1; // outer array element count; 1 when not an array + std::vector intValues; + std::vector floatValues; + }; + void addUniformInitializer(TUniformInitializer&& init) { uniformInitializers.push_back(std::move(init)); } + const std::vector& getUniformInitializers() const { return uniformInitializers; } + void setAtomicCounterBlockName(const char* name) { atomicCounterBlockName = std::string(name); } const char* getAtomicCounterBlockName() const { return atomicCounterBlockName.c_str(); } void setAtomicCounterBlockSet(unsigned int set) { atomicCounterBlockSet = set; } @@ -1223,6 +1249,7 @@ class TIntermediate { std::string globalUniformBlockName; std::string atomicCounterBlockName; + std::vector uniformInitializers; unsigned int globalUniformBlockSet; unsigned int globalUniformBlockBinding; unsigned int atomicCounterBlockSet; diff --git a/include/ska b/include/ska new file mode 160000 index 00000000..21c1cec9 --- /dev/null +++ b/include/ska @@ -0,0 +1 @@ +Subproject commit 21c1cec95abee1beef827e4a7c95f692875d9594 diff --git a/tools/trace_replay/CMakeLists.txt b/tools/trace_replay/CMakeLists.txt index 938266c5..c048f244 100644 --- a/tools/trace_replay/CMakeLists.txt +++ b/tools/trace_replay/CMakeLists.txt @@ -224,6 +224,7 @@ add_library(mobilegl_trace_glretrace_common STATIC "${APITRACE_ROOT}/retrace/metric_backend_opengl.cpp" "${APITRACE_ROOT}/retrace/metric_helper.cpp" "${APITRACE_ROOT}/retrace/metric_writer.cpp" + "${MOBILEGL_TRACE_ROOT}/apitrace_fbo_dump.cpp" "${MOBILEGL_TRACE_ROOT}/apitrace_glws_egl.cpp") if(APPLE) set(MOBILEGL_TRACE_APPLE_FRAMEWORKS diff --git a/tools/trace_replay/README.md b/tools/trace_replay/README.md index 1c4d8950..9441f207 100644 --- a/tools/trace_replay/README.md +++ b/tools/trace_replay/README.md @@ -20,8 +20,6 @@ The bundled fixtures cover: ![Minecraft 1.21.4 Fabric Sodium in-world golden](fixtures/minecraft-1.21.4-fabric-sodium-in-world.0000923340.png) - minecraft-26.2-main-menu: captured from Minecraft 26.2's main menu. ![Minecraft 26.2 main menu golden](fixtures/minecraft-26.2-main-menu.0000101926.png) -- minecraft-26.2-in-world: captured from Minecraft 26.2 after entering a normal singleplayer world. - ![Minecraft 26.2 in-world golden](fixtures/minecraft-26.2-in-world.0000519370.png) - improved-transparency-minecraft-26.3: captured from the Minecraft 26.3 improved-transparency scene. ![Minecraft 26.3 improved-transparency golden](fixtures/improved-transparency-minecraft-26.3.0002667619.png) - minecraft-1.21.4-fabric-common-mods-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, REI, @@ -163,6 +161,33 @@ build-test/tools/trace_replay/mobilegl_trace_replay \ --ssim-threshold 0.99 ``` +## Dumping framebuffer attachments mid-frame + +`--target-call` snapshots one framebuffer. To see *inside* a frame - which +intermediate render target a pass actually produced - pass +`--dump-fbo-attachments CALL:DIR[:FBO,FBO,...]`, repeatably: + +```sh +build-test/tools/trace_replay/mobilegl_trace_replay \ + --trace trace.trace --golden golden.png --output out --target-call 2667619 \ + --dump-fbo-attachments 2666231:out/fbos-before \ + --dump-fbo-attachments 2666232:out/fbos-after +``` + +At each call boundary it walks every live framebuffer object (or only the named +ones), reads back every colour attachment and the depth attachment, and writes +`fbo-att.png` / `fbo-depth.png` plus a `manifest.txt` line per +attachment recording the attached object, size, internal format, component type +and per-channel min/max/mean and a content hash. Attachments are read as floats +whatever their storage, so HDR accumulation buffers stay legible in the +statistics even though the PNG has to clamp. + +The manifest is the useful part when comparing two drivers: dump the same call +on both stacks and `diff`/`paste` the two manifests, and the first attachment +whose hash differs names the pass that diverged. Read-side and pixel-pack state +is saved and restored, so the replay continues unperturbed; without the flag +nothing is installed and the replay is byte-for-byte what it was. + Run the macOS native-window DirectVulkan retrace matrix and render the same HTML overview shape as CI: @@ -251,3 +276,55 @@ process-local. For cases registered with `coherent_as_flush` (Flywheel-style unflushed persistent maps, e.g. the Create fixtures), pass `--ez coherent_as_flush true` so the replay runs with `MOBILEGL_COHERENT_AS_FLUSH=1`. + +## Reproducing the Android DirectGLES lane on Linux (ANGLE on lavapipe) + +The APK workflow's DirectGLES lane is not the same stack as the Linux one, which +is why a case can be green here and red there: + +| lane | stack | +| --- | --- | +| Linux `Test` retrace, DirectGLES | Espryt -> Mesa GLES -> llvmpipe | +| Android `APK` retrace, DirectGLES | Espryt -> **ANGLE** -> Mesa Vulkan (lavapipe) | +| Android `APK` retrace, DirectVulkan | Magma -> lavapipe (no ANGLE) | + +Only the Android DirectGLES lane puts ANGLE in the middle, so an ANGLE +translation difference shows up in exactly one of the six combinations. That +stack can be reproduced on Linux without an emulator, which is far faster to +iterate on than a CI round trip. The Android emulator SDK ships a glibc ANGLE: + +```sh +ANGLE=$ANDROID_SDK_ROOT/emulator/lib64/gles_angle +mkdir -p ~/angle-farm && cd ~/angle-farm +# MobileGL dlopens these two names; ANGLE's own libEGL then dlopens the +# unsuffixed libGLESv2.so from the same directory - without that symlink it +# loads a truncated entry-point table and dies on a missing EGL function. +ln -sf $ANGLE/libEGL.so libEGL_angle.so +ln -sf $ANGLE/libGLESv2.so libGLESv2_angle.so +ln -sf $ANGLE/libEGL.so libEGL.so +ln -sf $ANGLE/libGLESv2.so libGLESv2.so +ln -sf $ANGLE/libvulkan.so.1 libvulkan.so.1 # else eglInitialize fails + +MOBILEGL_USE_ANGLE=1 \ +LD_LIBRARY_PATH=~/angle-farm:/path/to/build/ \ +VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json \ +ANGLE_DEFAULT_PLATFORM=vulkan \ + ./mobilegl_trace_replay --trace trace.trace --golden golden.png \ + --target-call N --width 854 --height 480 --backend DirectGLES \ + --output outdir --pbuffer-surface +``` + +`ANGLE_DEFAULT_PLATFORM=vulkan` is required: ANGLE otherwise picks its OpenGL +backend and you get `ANGLE (Mesa, llvmpipe ..., OpenGL 4.6 (Core Profile))` +instead of the CI-shaped `ANGLE (Mesa, Vulkan 1.x (llvmpipe ...))`. Check +`MOBILEGL_TRACE_GL_RENDERER` in `outdir/retrace.log` before trusting a result. +Run the binary directly rather than through `ctest`, whose `ENVIRONMENT` +property overrides these variables. Build with clang, not gcc: gcc rejects +`GLXImpl.cpp` under `-Wchanges-meaning`. + +One more caveat before attributing anything: the emulator SDK's ANGLE is not +the ANGLE the Android lane runs. The CI lane uses a pinned build +(`MOBILEGL_TRACE_ANGLE_VARIANT`, default `ec889e6ea831`) whose version and +extension set differ from the SDK copy (`GL_EXT_texture_buffer` support, ES 3.2 +entry points). Compare `GL_RENDERER` and the relevant extension lists on both +stacks before treating a local result as a statement about CI. diff --git a/tools/trace_replay/apitrace_fbo_dump.cpp b/tools/trace_replay/apitrace_fbo_dump.cpp new file mode 100644 index 00000000..b0be05e3 --- /dev/null +++ b/tools/trace_replay/apitrace_fbo_dump.cpp @@ -0,0 +1,533 @@ +#include "apitrace_fbo_dump.hpp" + +#include "glproc.hpp" +#include "image.hpp" +#include "retrace.hpp" +#include "state_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Dumps every colour attachment (and the depth attachment) of every live framebuffer +// object at a chosen call boundary, on both sides of a driver comparison. The intent is +// to name the first attachment whose contents diverge between two stacks; the manifest is +// formatted so that `diff` over two dump directories points straight at it. +// +// The hook rides on apitrace's snapshot path: retrace_main's takeSnapshot() asks +// retrace::dumper for its snapshot count at exactly the call boundary we want, so wrapping +// retrace::dumper gives a per-call hook without patching apitrace. trace_replay_core adds +// the dump calls to the -S callset so the hook is reached. + +namespace mobilegl_trace_dump { +namespace { + +using PfnGetIntegerv = void (*)(GLenum, GLint *); +using PfnGetError = GLenum (*)(void); + +constexpr const char *kDumpPointsEnv = "MOBILEGL_TRACE_DUMP_FBO_ATTACHMENTS"; +constexpr const char *kScanLimitEnv = "MOBILEGL_TRACE_DUMP_FBO_SCAN_LIMIT"; +constexpr unsigned kDefaultScanLimit = 1024; + +struct DumpPoint { + unsigned call = 0; + std::string directory; + // Empty means "every framebuffer object the driver still knows about". + std::vector framebuffers; + bool done = false; +}; + +struct AttachmentDesc { + GLint objectType = GL_NONE; + GLint objectName = 0; + GLint level = 0; + GLint width = 0; + GLint height = 0; + GLint internalFormat = 0; + GLint componentType = GL_NONE; +}; + +std::vector gDumpPoints; +bool gInstalled = false; +bool gConfigured = false; +retrace::Dumper *gInnerDumper = nullptr; +PfnGetIntegerv gGetIntegerv = nullptr; +PfnGetError gGetError = nullptr; + +// apitrace's public dispatch is interposed by apitrace_glproc_mobilegl.cpp, which pins +// glGetIntegerv(GL_READ_BUFFER) to GL_BACK and swallows glGetError. Reading real state - +// notably each framebuffer's read buffer, which has to be restored - needs the +// uninterposed entry points. +void ResolveDirectEntryPoints() { + if (gGetIntegerv == nullptr) { + gGetIntegerv = reinterpret_cast(_getPrivateProcAddress("glGetIntegerv")); + } + if (gGetError == nullptr) { + gGetError = reinterpret_cast(_getPrivateProcAddress("glGetError")); + } +} + +GLint GetInteger(GLenum pname) { + GLint value = 0; + if (gGetIntegerv != nullptr) { + gGetIntegerv(pname, &value); + } + return value; +} + +unsigned DrainErrors() { + if (gGetError == nullptr) { + return 0; + } + unsigned count = 0; + while (gGetError() != GL_NO_ERROR) { + if (++count > 64) { + break; + } + } + return count; +} + +bool MakeDirectories(const std::string &path) { + if (path.empty()) { + return false; + } + std::string partial; + partial.reserve(path.size()); + for (std::size_t i = 0; i < path.size(); ++i) { + partial.push_back(path[i]); + const bool last = i + 1 == path.size(); + if (path[i] != '/' && !last) { + continue; + } + if (partial == "/") { + continue; + } + if (mkdir(partial.c_str(), 0755) != 0 && errno != EEXIST) { + return false; + } + } + return true; +} + +std::vector Split(const std::string &value, char separator) { + std::vector parts; + std::string current; + for (const char c : value) { + if (c == separator) { + parts.push_back(current); + current.clear(); + } else { + current.push_back(c); + } + } + parts.push_back(current); + return parts; +} + +// CALL:DIR[:FBO,FBO,...] entries, separated by ';'. An omitted or `all` framebuffer list +// dumps every live framebuffer object. +void ParseDumpPoints(const char *spec) { + for (const std::string &entry : Split(spec, ';')) { + if (entry.empty()) { + continue; + } + const std::vector fields = Split(entry, ':'); + if (fields.size() < 2 || fields[0].empty() || fields[1].empty()) { + std::cerr << "warning: ignoring malformed " << kDumpPointsEnv << " entry: " << entry << "\n"; + continue; + } + + DumpPoint point; + point.call = static_cast(std::strtoul(fields[0].c_str(), nullptr, 10)); + point.directory = fields[1]; + if (fields.size() >= 3 && !fields[2].empty() && fields[2] != "all") { + for (const std::string &name : Split(fields[2], ',')) { + if (!name.empty()) { + point.framebuffers.push_back( + static_cast(std::strtoul(name.c_str(), nullptr, 10))); + } + } + } + gDumpPoints.push_back(point); + } +} + +unsigned ScanLimit() { + const char *value = std::getenv(kScanLimitEnv); + if (value == nullptr || value[0] == '\0') { + return kDefaultScanLimit; + } + const unsigned limit = static_cast(std::strtoul(value, nullptr, 10)); + return limit == 0 ? kDefaultScanLimit : limit; +} + +const char *ComponentTypeName(GLint componentType) { + switch (componentType) { + case GL_FLOAT: + return "float"; + case GL_INT: + return "int"; + case GL_UNSIGNED_INT: + return "uint"; + case GL_SIGNED_NORMALIZED: + return "snorm"; + case GL_UNSIGNED_NORMALIZED: + return "unorm"; + case GL_NONE: + return "none"; + default: + return "unknown"; + } +} + +bool DescribeAttachment(GLenum attachment, AttachmentDesc &desc) { + glGetFramebufferAttachmentParameteriv(GL_READ_FRAMEBUFFER, attachment, + GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE, &desc.objectType); + if (DrainErrors() != 0 || desc.objectType == GL_NONE) { + return false; + } + + glGetFramebufferAttachmentParameteriv(GL_READ_FRAMEBUFFER, attachment, + GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME, &desc.objectName); + glGetFramebufferAttachmentParameteriv(GL_READ_FRAMEBUFFER, attachment, + GL_FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE, &desc.componentType); + DrainErrors(); + + if (desc.objectType == GL_RENDERBUFFER) { + const GLint boundRenderbuffer = GetInteger(GL_RENDERBUFFER_BINDING); + glBindRenderbuffer(GL_RENDERBUFFER, static_cast(desc.objectName)); + glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &desc.width); + glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &desc.height); + glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_INTERNAL_FORMAT, &desc.internalFormat); + glBindRenderbuffer(GL_RENDERBUFFER, static_cast(boundRenderbuffer)); + } else if (desc.objectType == GL_TEXTURE) { + glGetFramebufferAttachmentParameteriv(GL_READ_FRAMEBUFFER, attachment, + GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_LEVEL, &desc.level); + glGetTextureLevelParameteriv(static_cast(desc.objectName), desc.level, + GL_TEXTURE_WIDTH, &desc.width); + glGetTextureLevelParameteriv(static_cast(desc.objectName), desc.level, + GL_TEXTURE_HEIGHT, &desc.height); + glGetTextureLevelParameteriv(static_cast(desc.objectName), desc.level, + GL_TEXTURE_INTERNAL_FORMAT, &desc.internalFormat); + if (DrainErrors() != 0 || desc.width <= 0 || desc.height <= 0) { + // No direct-state-access level query: fall back to the classic bound query, + // which only covers GL_TEXTURE_2D but is what render targets normally are. + const GLint boundTexture = GetInteger(GL_TEXTURE_BINDING_2D); + glBindTexture(GL_TEXTURE_2D, static_cast(desc.objectName)); + glGetTexLevelParameteriv(GL_TEXTURE_2D, desc.level, GL_TEXTURE_WIDTH, &desc.width); + glGetTexLevelParameteriv(GL_TEXTURE_2D, desc.level, GL_TEXTURE_HEIGHT, &desc.height); + glGetTexLevelParameteriv(GL_TEXTURE_2D, desc.level, GL_TEXTURE_INTERNAL_FORMAT, + &desc.internalFormat); + glBindTexture(GL_TEXTURE_2D, static_cast(boundTexture)); + } + } + + DrainErrors(); + return desc.width > 0 && desc.height > 0; +} + +// Reads the attachment as floats regardless of its storage: normalised and float targets +// convert on the way out, integer targets are read as integers and widened. The float view +// keeps out-of-[0,1] accumulation buffers legible in the statistics even though the PNG +// itself has to clamp. +bool ReadAttachmentFloats(const AttachmentDesc &desc, bool depth, unsigned channels, + std::vector &pixels) { + const std::size_t count = static_cast(desc.width) * desc.height * channels; + pixels.assign(count, 0.0f); + + if (depth) { + glReadPixels(0, 0, desc.width, desc.height, GL_DEPTH_COMPONENT, GL_FLOAT, pixels.data()); + return DrainErrors() == 0; + } + + if (desc.componentType == GL_INT || desc.componentType == GL_UNSIGNED_INT) { + std::vector raw(count, 0); + const GLenum type = desc.componentType == GL_INT ? GL_INT : GL_UNSIGNED_INT; + glReadPixels(0, 0, desc.width, desc.height, GL_RGBA_INTEGER, type, raw.data()); + if (DrainErrors() != 0) { + return false; + } + for (std::size_t i = 0; i < count; ++i) { + pixels[i] = desc.componentType == GL_INT + ? static_cast(raw[i]) + : static_cast(static_cast(raw[i])); + } + return true; + } + + glReadPixels(0, 0, desc.width, desc.height, GL_RGBA, GL_FLOAT, pixels.data()); + return DrainErrors() == 0; +} + +std::string FormatStatistics(const std::vector &pixels, unsigned channels) { + float minimum[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + float maximum[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + double total[4] = {0.0, 0.0, 0.0, 0.0}; + bool seeded[4] = {false, false, false, false}; + std::uint64_t hash = 1469598103934665603ull; + std::size_t nonFinite = 0; + + const std::size_t pixelCount = channels == 0 ? 0 : pixels.size() / channels; + for (std::size_t p = 0; p < pixelCount; ++p) { + for (unsigned c = 0; c < channels; ++c) { + const float value = pixels[p * channels + c]; + if (!std::isfinite(value)) { + ++nonFinite; + continue; + } + if (!seeded[c] || value < minimum[c]) { + minimum[c] = value; + } + if (!seeded[c] || value > maximum[c]) { + maximum[c] = value; + } + seeded[c] = true; + total[c] += value; + } + } + for (const float value : pixels) { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + hash = (hash ^ bits) * 1099511628211ull; + } + + char buffer[512]; + std::string text; + for (unsigned c = 0; c < channels; ++c) { + const double mean = pixelCount == 0 ? 0.0 : total[c] / static_cast(pixelCount); + std::snprintf(buffer, sizeof(buffer), " c%u[min=%.6g max=%.6g mean=%.6g]", c, + static_cast(minimum[c]), static_cast(maximum[c]), mean); + text += buffer; + } + std::snprintf(buffer, sizeof(buffer), " nonfinite=%zu hash=%016llx", nonFinite, + static_cast(hash)); + text += buffer; + return text; +} + +bool WriteFloatPng(const std::string &path, const AttachmentDesc &desc, unsigned channels, + const std::vector &pixels) { + image::Image snapshot(static_cast(desc.width), static_cast(desc.height), + channels, true, image::TYPE_FLOAT); + if (snapshot.sizeInBytes() != pixels.size() * sizeof(float)) { + return false; + } + std::memcpy(snapshot.pixels, pixels.data(), pixels.size() * sizeof(float)); + return snapshot.writePNG(path.c_str()); +} + +void DumpOneAttachment(std::ofstream &manifest, const std::string &directory, unsigned framebuffer, + GLenum attachment, const char *label, bool depth) { + AttachmentDesc desc; + if (!DescribeAttachment(attachment, desc)) { + return; + } + + const unsigned channels = depth ? 1u : 4u; + if (!depth) { + glReadBuffer(attachment); + if (DrainErrors() != 0) { + return; + } + } + + std::vector pixels; + const bool read = ReadAttachmentFloats(desc, depth, channels, pixels); + + const std::string path = + directory + "/fbo" + std::to_string(framebuffer) + "-" + label + ".png"; + const bool wrote = read && WriteFloatPng(path, desc, channels, pixels); + + char header[512]; + std::snprintf(header, sizeof(header), + "fbo %u %s object=%s name=%d level=%d size=%dx%d internalformat=0x%04x component=%s", + framebuffer, label, + desc.objectType == GL_RENDERBUFFER ? "renderbuffer" : "texture", desc.objectName, + desc.level, desc.width, desc.height, static_cast(desc.internalFormat), + ComponentTypeName(desc.componentType)); + manifest << header; + if (read) { + manifest << FormatStatistics(pixels, channels); + } else { + manifest << " read=failed"; + } + if (!wrote) { + manifest << " png=failed"; + } + manifest << "\n"; +} + +void DumpFramebuffer(std::ofstream &manifest, const std::string &directory, unsigned framebuffer, + GLint maxColorAttachments) { + if (framebuffer == 0) { + // The default framebuffer names its attachments GL_BACK_LEFT rather than + // GL_COLOR_ATTACHMENT0, and the replay already snapshots it into actual..png. + manifest << "fbo 0 skipped=default-framebuffer\n"; + return; + } + + glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer); + if (DrainErrors() != 0) { + return; + } + + const GLint savedReadBuffer = GetInteger(GL_READ_BUFFER); + for (GLint index = 0; index < maxColorAttachments; ++index) { + char label[32]; + std::snprintf(label, sizeof(label), "att%d", index); + DumpOneAttachment(manifest, directory, framebuffer, + static_cast(GL_COLOR_ATTACHMENT0 + index), label, false); + } + DumpOneAttachment(manifest, directory, framebuffer, GL_DEPTH_ATTACHMENT, "depth", true); + + // The read buffer is per-framebuffer state the trace goes on using; put it back. + if (framebuffer != 0 && savedReadBuffer != 0) { + glReadBuffer(static_cast(savedReadBuffer)); + DrainErrors(); + } +} + +void RunDumpPoint(DumpPoint &point) { + if (!MakeDirectories(point.directory)) { + std::cerr << "warning: failed to create FBO dump directory " << point.directory << "\n"; + point.done = true; + return; + } + + // Start from a clean error state so a failure reported below is one we caused. + DrainErrors(); + + // Everything below perturbs read-side and pack state; snapshot it so the replay + // continues from where it was. + const GLint savedReadFramebuffer = GetInteger(GL_READ_FRAMEBUFFER_BINDING); + const GLint savedPackBuffer = GetInteger(GL_PIXEL_PACK_BUFFER_BINDING); + const GLint savedPackAlignment = GetInteger(GL_PACK_ALIGNMENT); + const GLint savedPackRowLength = GetInteger(GL_PACK_ROW_LENGTH); + const GLint savedPackSkipPixels = GetInteger(GL_PACK_SKIP_PIXELS); + const GLint savedPackSkipRows = GetInteger(GL_PACK_SKIP_ROWS); + const GLint savedPackImageHeight = GetInteger(GL_PACK_IMAGE_HEIGHT); + const GLint savedPackSkipImages = GetInteger(GL_PACK_SKIP_IMAGES); + DrainErrors(); + + if (savedPackBuffer != 0) { + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + } + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glPixelStorei(GL_PACK_ROW_LENGTH, 0); + glPixelStorei(GL_PACK_SKIP_PIXELS, 0); + glPixelStorei(GL_PACK_SKIP_ROWS, 0); + glPixelStorei(GL_PACK_IMAGE_HEIGHT, 0); + glPixelStorei(GL_PACK_SKIP_IMAGES, 0); + DrainErrors(); + + const GLint maxColorAttachments = GetInteger(GL_MAX_COLOR_ATTACHMENTS); + DrainErrors(); + + std::vector framebuffers = point.framebuffers; + if (framebuffers.empty()) { + const unsigned limit = ScanLimit(); + for (unsigned name = 1; name <= limit; ++name) { + if (glIsFramebuffer(name) == GL_TRUE) { + framebuffers.push_back(name); + } + } + DrainErrors(); + } + + const std::string manifestPath = point.directory + "/manifest.txt"; + std::ofstream manifest(manifestPath, std::ios::trunc); + manifest << "call " << retrace::callNo << " framebuffers " << framebuffers.size() + << " maxcolorattachments " << maxColorAttachments << "\n"; + for (const unsigned framebuffer : framebuffers) { + DumpFramebuffer(manifest, point.directory, framebuffer, maxColorAttachments); + } + manifest.flush(); + + glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast(savedReadFramebuffer)); + if (savedPackBuffer != 0) { + glBindBuffer(GL_PIXEL_PACK_BUFFER, static_cast(savedPackBuffer)); + } + glPixelStorei(GL_PACK_ALIGNMENT, savedPackAlignment); + glPixelStorei(GL_PACK_ROW_LENGTH, savedPackRowLength); + glPixelStorei(GL_PACK_SKIP_PIXELS, savedPackSkipPixels); + glPixelStorei(GL_PACK_SKIP_ROWS, savedPackSkipRows); + glPixelStorei(GL_PACK_IMAGE_HEIGHT, savedPackImageHeight); + glPixelStorei(GL_PACK_SKIP_IMAGES, savedPackSkipImages); + DrainErrors(); + + std::cerr << "MOBILEGL_TRACE_FBO_DUMP: call " << retrace::callNo << " -> " << manifestPath + << " (" << framebuffers.size() << " framebuffers)\n"; + point.done = true; +} + +void RunPendingDumps() { + for (DumpPoint &point : gDumpPoints) { + if (!point.done && point.call == retrace::callNo) { + RunDumpPoint(point); + } + } +} + +class DumpingDumper final : public retrace::Dumper { +public: + int getSnapshotCount(void) override { + RunPendingDumps(); + return gInnerDumper->getSnapshotCount(); + } + + image::Image *getSnapshot(int n, bool backBuffer) override { + return gInnerDumper->getSnapshot(n, backBuffer); + } + + bool canDump(void) override { + return gInnerDumper->canDump(); + } + + void dumpState(StateWriter &writer) override { + gInnerDumper->dumpState(writer); + } +}; + +DumpingDumper gDumpingDumper; + +} // namespace + +void InstallIfRequested() { + if (gInstalled) { + return; + } + if (!gConfigured) { + gConfigured = true; + const char *spec = std::getenv(kDumpPointsEnv); + if (spec != nullptr && spec[0] != '\0') { + ParseDumpPoints(spec); + } + } + if (gDumpPoints.empty()) { + gInstalled = true; + return; + } + if (retrace::dumper == nullptr || retrace::dumper == &gDumpingDumper) { + return; + } + + ResolveDirectEntryPoints(); + gInnerDumper = retrace::dumper; + retrace::dumper = &gDumpingDumper; + gInstalled = true; + for (const DumpPoint &point : gDumpPoints) { + std::cerr << "MOBILEGL_TRACE_FBO_DUMP: armed for call " << point.call << " -> " + << point.directory << "\n"; + } +} + +} // namespace mobilegl_trace_dump diff --git a/tools/trace_replay/apitrace_fbo_dump.hpp b/tools/trace_replay/apitrace_fbo_dump.hpp new file mode 100644 index 00000000..68ebfc0c --- /dev/null +++ b/tools/trace_replay/apitrace_fbo_dump.hpp @@ -0,0 +1,10 @@ +#pragma once + +namespace mobilegl_trace_dump { + +// Installs the framebuffer-attachment dump hook when MOBILEGL_TRACE_DUMP_FBO_ATTACHMENTS +// describes at least one dump point. Safe and cheap to call on every makeCurrent: the +// environment is consulted once and the hook is installed at most once. +void InstallIfRequested(); + +} // namespace mobilegl_trace_dump diff --git a/tools/trace_replay/apitrace_glws_egl.cpp b/tools/trace_replay/apitrace_glws_egl.cpp index 36e1c3f0..08ce8a14 100644 --- a/tools/trace_replay/apitrace_glws_egl.cpp +++ b/tools/trace_replay/apitrace_glws_egl.cpp @@ -1,6 +1,8 @@ #include "glws.hpp" #include "retrace.hpp" +#include "apitrace_fbo_dump.hpp" + #include #include #include @@ -632,6 +634,9 @@ bool makeCurrentInternal(Drawable *drawable, Drawable *readable, Context *contex gCurrentDrawable = drawable; gCurrentContext = eglContext; PrintGlIdentityOnce(); + // retrace::setUp() installs the GL dumper after glws::init(), so the earliest point at + // which the dump hook can wrap it is the first time a context becomes current. + mobilegl_trace_dump::InstallIfRequested(); return true; } diff --git a/tools/trace_replay/fixtures/minecraft-26.2-in-world.0000519370.png b/tools/trace_replay/fixtures/minecraft-26.2-in-world.0000519370.png deleted file mode 100644 index b3201e02..00000000 --- a/tools/trace_replay/fixtures/minecraft-26.2-in-world.0000519370.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:591f1672c81ad364451b33aafe240ade2f992f6228dcb92d4b4466f564dd713d -size 101648 diff --git a/tools/trace_replay/fixtures/minecraft-26.2-in-world.tgz b/tools/trace_replay/fixtures/minecraft-26.2-in-world.tgz deleted file mode 100644 index 21c7d4c7..00000000 --- a/tools/trace_replay/fixtures/minecraft-26.2-in-world.tgz +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:54a155a5e8fe51143cf936b84fb47e054d4e177f8ca19a8c1347f99901a41360 -size 13244500 diff --git a/tools/trace_replay/trace_cases.json b/tools/trace_replay/trace_cases.json index 35ef03e0..eeab5dc5 100644 --- a/tools/trace_replay/trace_cases.json +++ b/tools/trace_replay/trace_cases.json @@ -68,13 +68,6 @@ "target_call": 101926, "timeout_seconds": 180 }, - { - "name": "minecraft-26.2-in-world", - "ci": false, - "trace_archive": "minecraft-26.2-in-world.tgz", - "golden": "minecraft-26.2-in-world.0000519370.png", - "target_call": 519370 - }, { "name": "minecraft-1.21.4-fabric-common-mods-in-world", "trace_archive": "minecraft-1.21.4-fabric-common-mods-in-world.tgz", diff --git a/tools/trace_replay/trace_replay_cli.cpp b/tools/trace_replay/trace_replay_cli.cpp index 97f26a2c..892bc622 100644 --- a/tools/trace_replay/trace_replay_cli.cpp +++ b/tools/trace_replay/trace_replay_cli.cpp @@ -28,7 +28,12 @@ void PrintUsage(const char *argv0) { << " --crop-y N Compare crop y\n" << " --crop-width N Compare crop width\n" << " --crop-height N Compare crop height\n" - << " --coherent-as-flush Set MOBILEGL_COHERENT_AS_FLUSH=1 for the replay\n"; + << " --coherent-as-flush Set MOBILEGL_COHERENT_AS_FLUSH=1 for the replay\n" + << " --dump-fbo-attachments CALL:DIR[:FBO,FBO,...]\n" + << " At CALL, write every colour attachment and the depth\n" + << " attachment of every live framebuffer object into DIR as\n" + << " fbo-att.png / fbo-depth.png, plus a manifest.txt\n" + << " of formats and per-channel statistics. Repeatable.\n"; } bool ReadValue(int argc, char **argv, int &index, std::string &out) { @@ -116,6 +121,14 @@ bool ParseArgs(int argc, char **argv, mobilegl_trace::Request &request) { if (!ReadInt(argc, argv, i, request.cropHeight)) return false; } else if (arg == "--coherent-as-flush") { request.coherentAsFlush = true; + } else if (arg == "--dump-fbo-attachments") { + std::string dumpPoint; + if (!ReadValue(argc, argv, i, dumpPoint)) return false; + if (dumpPoint.find(':') == std::string::npos) { + std::cerr << "--dump-fbo-attachments expects CALL:DIR[:FBO,FBO,...]\n"; + return false; + } + request.fboAttachmentDumps.push_back(dumpPoint); } else if (arg == "--help" || arg == "-h") { return false; } else {