mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-11 21:58:31 +09:00
Compare commits
58
Commits
cc34d34706
...
64a0ea397c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64a0ea397c | ||
|
|
205d837942 | ||
|
|
b5e9339c66 | ||
|
|
e310e3e9ff | ||
|
|
a0bf4a83bc | ||
|
|
97facf777b | ||
|
|
5cfbb716c0 | ||
|
|
64c3411d70 | ||
|
|
f3a0d9e0a3 | ||
|
|
068786e812 | ||
|
|
8e7cc62c24 | ||
|
|
d2a36d65a3 | ||
|
|
a020de76e3 | ||
|
|
574634adfa | ||
|
|
e71d715e1a | ||
|
|
7b946fd527 | ||
|
|
8f3ce5f5b7 | ||
|
|
21ec744ef2 | ||
|
|
faa7b17da3 | ||
|
|
f6849fc0b3 | ||
|
|
4d1d4f6225 | ||
|
|
cef81df73f | ||
|
|
c4e6ea1f23 | ||
|
|
b4e07ce651 | ||
|
|
ff324057ad | ||
|
|
ef4c6dbe0a | ||
|
|
19fc7346c5 | ||
|
|
bcb0e894ef | ||
|
|
794c10e56c | ||
|
|
28390667d7 | ||
|
|
90dd9bec77 | ||
|
|
7994ca31d3 | ||
|
|
5705e05156 | ||
|
|
ab62f81545 | ||
|
|
f6cf04d6d7 | ||
|
|
38eb9589f9 | ||
|
|
99ebf67a3d | ||
|
|
2292e99476 | ||
|
|
4c5afecc71 | ||
|
|
1c5744f2be | ||
|
|
22859b0958 | ||
|
|
7aa958fbc9 | ||
|
|
a4bd4e04a1 | ||
|
|
09459edb6b | ||
|
|
8af6ebc174 | ||
|
|
577cd8c670 | ||
|
|
5267243404 | ||
|
|
33c2715912 | ||
|
|
3068cdadf8 | ||
|
|
6dd0201bf2 | ||
|
|
05bef7118b | ||
|
|
94e75fef79 | ||
|
|
43bcd03dca | ||
|
|
2ce0595fab | ||
|
|
ba9af18033 | ||
|
|
f7d63f88fa | ||
|
|
6cf5a7744e | ||
|
|
ca3d24f5ea |
@@ -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 <trace-case> [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"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cache-side helper for trace fixtures.
|
||||
#
|
||||
# key <case> [fixture-dir] derive the actions/cache key and path list
|
||||
# verify <case> [fixture-dir] check restored fixtures against their pointers
|
||||
# reset <case> [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 <key|verify|reset> <trace-case> [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
|
||||
@@ -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
|
||||
# "<oid> <size>". 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'
|
||||
}
|
||||
+111
-8
@@ -11,6 +11,9 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
env:
|
||||
CCACHE_BASEDIR: ${{ github.workspace }}
|
||||
CCACHE_COMPRESS: "true"
|
||||
@@ -41,12 +44,11 @@ jobs:
|
||||
gradle-version: 8.10.2
|
||||
|
||||
- name: Restore ccache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: .ccache
|
||||
key: ${{ runner.os }}-apk-${{ github.job }}-ccache-${{ github.ref_name }}-${{ github.run_id }}
|
||||
key: ${{ runner.os }}-apk-${{ github.job }}-ccache-v1
|
||||
restore-keys: |
|
||||
${{ runner.os }}-apk-${{ github.job }}-ccache-${{ github.ref_name }}-
|
||||
${{ runner.os }}-apk-${{ github.job }}-ccache-
|
||||
|
||||
- name: Install ccache
|
||||
@@ -125,6 +127,28 @@ jobs:
|
||||
if: always()
|
||||
run: ccache --show-stats
|
||||
|
||||
# Rewrite one rolling entry per job on the default branch. The upload stays
|
||||
# cumulative - it carries every object restored at the top of this run plus
|
||||
# the few TUs that actually changed - but Actions cache keys are immutable,
|
||||
# so the superseded blob has to be released before the same key can be
|
||||
# re-uploaded. Running after the build means a failed build leaves the
|
||||
# existing entry untouched. The other trigger branches restore this entry
|
||||
# rather than each writing a ~4 GB one of their own.
|
||||
- name: Release superseded ccache entry
|
||||
if: github.ref_name == github.event.repository.default_branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CACHE_KEY: ${{ runner.os }}-apk-${{ github.job }}-ccache-v1
|
||||
run: gh cache delete "${CACHE_KEY}" || true
|
||||
|
||||
- name: Save ccache
|
||||
if: github.ref_name == github.event.repository.default_branch
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: .ccache
|
||||
key: ${{ runner.os }}-apk-${{ github.job }}-ccache-v1
|
||||
|
||||
- name: Verify APK metadata and packaging
|
||||
run: |
|
||||
AAPT2="$(find "$ANDROID_HOME/build-tools" -name aapt2 -type f | sort -V | tail -n 1)"
|
||||
@@ -201,9 +225,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')"
|
||||
@@ -379,6 +435,9 @@ jobs:
|
||||
if [ "${{ matrix.backend.name }}" = "DirectGLES" ] && [ "${{ matrix.case.name }}" = "minecraft-1.21.4-fabric-iris-bliss-in-world" ]; then
|
||||
extra_retrace_args+=(--avoid-angle-llvmpipe-sampler-mipmap-min-filter)
|
||||
fi
|
||||
if [ "${{ matrix.backend.name }}" = "DirectGLES" ] && [ "${{ matrix.case.avoid_angle_llvmpipe_explicit_lod_bias || false }}" = "true" ]; then
|
||||
extra_retrace_args+=(--avoid-angle-llvmpipe-explicit-lod-bias)
|
||||
fi
|
||||
if [ "${{ matrix.case.coherent_as_flush || false }}" = "true" ]; then
|
||||
extra_retrace_args+=(--coherent-as-flush)
|
||||
fi
|
||||
@@ -411,6 +470,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 +527,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 +615,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 -<backend>-<case>, 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})"
|
||||
|
||||
@@ -11,6 +11,9 @@ on:
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
env:
|
||||
BUILD_DIR: build-linux
|
||||
CCACHE_BASEDIR: ${{ github.workspace }}
|
||||
@@ -34,12 +37,11 @@ jobs:
|
||||
uses: lukka/get-cmake@v4.3.3
|
||||
|
||||
- name: Restore ccache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: .ccache
|
||||
key: ${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-${{ github.run_id }}
|
||||
key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
|
||||
restore-keys: |
|
||||
${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-
|
||||
${{ runner.os }}-test-${{ github.job }}-ccache-
|
||||
|
||||
- name: Prepare Vulkan SDK
|
||||
@@ -97,6 +99,28 @@ jobs:
|
||||
if: always()
|
||||
run: ccache --show-stats
|
||||
|
||||
# Rewrite one rolling entry per job on the default branch. The upload stays
|
||||
# cumulative - it carries every object restored at the top of this run plus
|
||||
# the few TUs that actually changed - but Actions cache keys are immutable,
|
||||
# so the superseded blob has to be released before the same key can be
|
||||
# re-uploaded. Running after the build means a failed build leaves the
|
||||
# existing entry untouched. The other trigger branches restore this entry
|
||||
# rather than each writing one of their own.
|
||||
- name: Release superseded ccache entry
|
||||
if: github.ref_name == github.event.repository.default_branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CACHE_KEY: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
|
||||
run: gh cache delete "${CACHE_KEY}" || true
|
||||
|
||||
- name: Save ccache
|
||||
if: github.ref_name == github.event.repository.default_branch
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: .ccache
|
||||
key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
|
||||
|
||||
- name: Package Linux runtime
|
||||
run: |
|
||||
mkdir -p ci-artifacts
|
||||
@@ -320,6 +344,9 @@ jobs:
|
||||
- build-linux
|
||||
- test
|
||||
- benchmark
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
env:
|
||||
BUILD_DIR: build-retrace
|
||||
CCACHE_BASEDIR: ${{ github.workspace }}
|
||||
@@ -344,12 +371,11 @@ jobs:
|
||||
uses: lukka/get-cmake@v4.3.3
|
||||
|
||||
- name: Restore ccache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: .ccache
|
||||
key: ${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-${{ github.run_id }}
|
||||
key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
|
||||
restore-keys: |
|
||||
${{ runner.os }}-test-${{ github.job }}-ccache-${{ github.ref_name }}-
|
||||
${{ runner.os }}-test-${{ github.job }}-ccache-
|
||||
|
||||
- name: Prepare Vulkan SDK
|
||||
@@ -415,6 +441,21 @@ jobs:
|
||||
if: always()
|
||||
run: ccache --show-stats
|
||||
|
||||
- name: Release superseded ccache entry
|
||||
if: github.ref_name == github.event.repository.default_branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CACHE_KEY: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
|
||||
run: gh cache delete "${CACHE_KEY}" || true
|
||||
|
||||
- name: Save ccache
|
||||
if: github.ref_name == github.event.repository.default_branch
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: .ccache
|
||||
key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1
|
||||
|
||||
- name: Normalize CTest command paths
|
||||
run: |
|
||||
python - <<'PY'
|
||||
@@ -470,9 +511,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')"
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
Vendored
+1
-1
Submodule 3rdparty/glslang updated: 900b29d449...6f12598784
@@ -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
|
||||
|
||||
@@ -89,6 +89,13 @@ namespace MobileGL::MG_Config {
|
||||
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers,
|
||||
// resolves certain rendering bugs on ANGLE + llvmpipe.
|
||||
Bool AvoidSamplerMipmapMinFilter = false;
|
||||
// MOBILEGL_AVOID_EXPLICIT_LOD_BIAS: leave an already-explicit LOD argument alone when
|
||||
// emulating GL_TEXTURE_LOD_BIAS, instead of adding the bias uniform to it. Injecting
|
||||
// the uniform turns a compile-time-constant LOD into a runtime expression, which
|
||||
// sends ANGLE + llvmpipe down a mip-selection path that dereferences a NULL
|
||||
// descriptor and kills the process. Deviates from spec (Vulkan adds the bias to
|
||||
// OpImageSampleExplicitLod), so it is an avoidance for that stack only.
|
||||
Bool AvoidExplicitLodBias = false;
|
||||
// MOBILEGL_COHERENT_AS_FLUSH: app-compat for engines (e.g. Flywheel) that write
|
||||
// GPU-read data through persistent GL_MAP_FLUSH_EXPLICIT_BIT maps they never
|
||||
// flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent
|
||||
|
||||
@@ -171,6 +171,7 @@ namespace MobileGL::MG_ConfigLoader {
|
||||
features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64);
|
||||
features.AvoidSamplerMipmapMinFilter =
|
||||
QueryEnvFlag("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
|
||||
features.AvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS");
|
||||
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
|
||||
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
|
||||
features.DisableUboRing = QueryEnvFlag("MOBILEGL_DISABLE_UBO_RING");
|
||||
|
||||
+14
-4
@@ -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
|
||||
|
||||
+2
-2
@@ -49,8 +49,8 @@
|
||||
#include <stacktrace>
|
||||
#endif
|
||||
|
||||
// Include FastSTL
|
||||
#include <FastSTL/UnorderedMap.h>
|
||||
// Include ska::flat_hash_map
|
||||
#include <ska/flat_hash_map.hpp>
|
||||
|
||||
// Include xxHash
|
||||
#include <xxhash.h>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -5151,8 +5173,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
const SharedPtr<MG_State::GLState::ITextureObject>& 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<TextureImpl::BackendTextureObject> srcBackendTexture =
|
||||
TextureImpl::SyncTextureObjectToBackend(srcTexture);
|
||||
const SharedPtr<TextureImpl::BackendTextureObject> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,6 +55,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
return g_GLESCapabilities.AvoidSamplerMipmapMinFilter;
|
||||
}
|
||||
|
||||
static Bool ShouldAvoidExplicitLodBiasOnAngleLlvmpipe() {
|
||||
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_AVOID_EXPLICIT_LOD_BIAS
|
||||
// feature toggle, both resolved in FillInGLESCapabilities.
|
||||
return g_GLESCapabilities.AvoidExplicitLodBias;
|
||||
}
|
||||
|
||||
static GLenum ResolveBackendMinFilter(const SamplerParameters& samplerParams,
|
||||
Bool avoidMipmapMinFilter) {
|
||||
GLenum filter = MG_Util::ConvertSamplerFilterModeToGLEnum(samplerParams.minFilter,
|
||||
@@ -2779,6 +2785,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(),
|
||||
@@ -2789,17 +2818,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<GLintptr>(rangeOffset),
|
||||
static_cast<GLsizeiptr>(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<GLintptr>(rangeOffset),
|
||||
static_cast<GLsizeiptr>(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) {
|
||||
@@ -4182,6 +4213,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<String>{}(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<Uint64>(static_cast<Uint32>(binding)) + 0x9e3779b97f4a7c15ull +
|
||||
(entry << 6) + (entry >> 2));
|
||||
signature += entry; // commutative combine
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
void BackendProgramObjectImpl::SyncToBackend(
|
||||
const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) {
|
||||
#ifdef TRACY_ENABLE
|
||||
@@ -4191,6 +4247,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
|
||||
@@ -4214,6 +4273,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;
|
||||
@@ -4264,6 +4328,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<unsigned int> loweredSpirv;
|
||||
@@ -4313,6 +4397,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<unsigned int> 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);
|
||||
|
||||
@@ -4326,6 +4426,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);
|
||||
|
||||
@@ -4341,12 +4450,29 @@ 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);
|
||||
source = BroadcastLegacyFragColor(std::move(source), glShaderType, m_fragColorBroadcastCount);
|
||||
source = EmulateTextureLodBias(source);
|
||||
source = EmulateTextureLodBias(source, ShouldAvoidExplicitLodBiasOnAngleLlvmpipe());
|
||||
source = EmulateBaseInstanceInVertexShader(std::move(source), glShaderType);
|
||||
source = PromoteDrawParameterGlobalsToUniforms(std::move(source), glShaderType);
|
||||
source = ForceSupporterOutput(source);
|
||||
@@ -4384,7 +4510,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Vector<GLchar> log(static_cast<SizeT>(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);
|
||||
@@ -4446,8 +4583,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
Vector<GLchar> log(static_cast<SizeT>(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);
|
||||
}
|
||||
@@ -4478,18 +4618,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<Uint>(base) > location) return name;
|
||||
const Uint element = location - static_cast<Uint>(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
|
||||
@@ -4552,7 +4712,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;
|
||||
@@ -4561,8 +4731,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);
|
||||
}
|
||||
@@ -4581,6 +4751,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;
|
||||
|
||||
@@ -129,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()) {
|
||||
@@ -621,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
|
||||
@@ -1028,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<Int>& GetUniformBlockBackendIndices() const { return m_uniformBlockBackendIndices; }
|
||||
@@ -1043,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<MG_State::GLState::ProgramObject>& 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;
|
||||
@@ -1058,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;
|
||||
|
||||
@@ -1068,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;
|
||||
};
|
||||
|
||||
@@ -1091,14 +1120,26 @@ 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 {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <MG_Util/Math/HalfFloat.h>
|
||||
#include <MG_Util/Math/SmallFloat.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
@@ -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 <name> : 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<unsigned char>(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<unsigned char>(glslCode[cursor]))) {
|
||||
++cursor;
|
||||
}
|
||||
if (cursor >= hit || glslCode[cursor] != '#') {
|
||||
continue;
|
||||
}
|
||||
++cursor;
|
||||
while (cursor < hit && std::isspace(static_cast<unsigned char>(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<unsigned char>(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<String> SplitLayoutQualifierList(const String& layout) {
|
||||
Vector<String> 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<unsigned char>(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<String>& 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 <memory/precision qualifiers> <image type> <name>[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<ImageUniformDecl> 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<SizeT>(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<StoreSite> 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<unsigned char>(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<ImageSourceEdit> edits;
|
||||
Vector<String> 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.
|
||||
@@ -527,7 +957,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
String EmulateTextureLodBias(const String& glslCode) {
|
||||
String EmulateTextureLodBias(const String& glslCode, Bool avoidExplicitLodBias) {
|
||||
#ifdef TRACY_ENABLE
|
||||
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||||
#endif
|
||||
@@ -588,6 +1018,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
if (samplerIt == samplerNames.end()) continue;
|
||||
|
||||
const String& biasName = samplerIt->second;
|
||||
if (form->explicitLodArg >= 0 && avoidExplicitLodBias) {
|
||||
// The lookup already names its level; leaving it alone keeps a constant
|
||||
// LOD constant. Costs the bias on explicit-LOD lookups only.
|
||||
continue;
|
||||
}
|
||||
if (form->explicitLodArg >= 0) {
|
||||
// Explicit LOD: the bias adds to it, as Vulkan does for
|
||||
// OpImageSampleExplicitLod and as the CTS reference expects.
|
||||
|
||||
@@ -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 <name>` and `writeonly
|
||||
// <IMAGE_WRITE_ALIAS_PREFIX><name>`, 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_";
|
||||
@@ -142,7 +183,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
|
||||
// the bound texture's (or sampler object's) value into it; a shader whose samplers
|
||||
// all have a zero bias is therefore unaffected. Returns the source unchanged when
|
||||
// there is nothing to rewrite.
|
||||
String EmulateTextureLodBias(const String& glslCode);
|
||||
//
|
||||
// avoidExplicitLodBias leaves lookups that already carry an explicit LOD untouched,
|
||||
// so their constant level stays constant; only the implicit-LOD forms take the bias.
|
||||
// Off by default and only ever set on ANGLE + llvmpipe, where injecting the uniform
|
||||
// into a constant LOD crashes the driver (MOBILEGL_AVOID_EXPLICIT_LOD_BIAS).
|
||||
String EmulateTextureLodBias(const String& glslCode, Bool avoidExplicitLodBias = false);
|
||||
} // namespace PrgramImpl
|
||||
|
||||
namespace Utils {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1834,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 : "<null>", binding->count,
|
||||
static_cast<Int>(binding->descriptor_type));
|
||||
@@ -2371,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<Uint32>(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<Int>(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<Vector<Uint>>& spirv, VkProgramObject& entry) const {
|
||||
// Initialize layout vectors
|
||||
@@ -2388,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) {
|
||||
@@ -2587,12 +2680,49 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// `b[1].data.length()` answered from an unconstrained buffer instead of its
|
||||
// own bound range (KHR-GL43.shader_storage_buffer_object.-
|
||||
// advanced-unsizedArrayLength-*).
|
||||
entry.bindingDescriptorCounts[binding] = static_cast<Uint16>(std::max<Uint32>(1u, sampler->count));
|
||||
//
|
||||
// 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<Uint32>(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<Uint16>(m_maxBindings);
|
||||
continue;
|
||||
}
|
||||
entry.bindingDescriptorCounts[binding] = static_cast<Uint16>(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<Uint16>(std::min<Uint32>(sampler->count, m_maxBindings));
|
||||
continue;
|
||||
}
|
||||
entry.bindingKinds[binding] = DescriptorBindingKind::None;
|
||||
continue;
|
||||
}
|
||||
@@ -2600,6 +2730,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const GLenum uniformType = program.GetUniformType(static_cast<Uint>(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<Uint16>(imageArrayCount);
|
||||
|
||||
const VkFormat reflectedFormat =
|
||||
ConvertSpirvImageFormatToVkFormat(sampler->image.image_format);
|
||||
VkFormat& existingFormat = entry.storageImageFormatByBinding[binding];
|
||||
@@ -2630,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<Uint16>(samplerArrayCount);
|
||||
|
||||
const SamplerNumericDomain numericDomain = UniformTypeToSamplerNumericDomain(uniformType);
|
||||
MOBILEGL_ASSERT(numericDomain != SamplerNumericDomain::Unknown,
|
||||
"ProgramFactory::ReflectLayout: failed to resolve sampler numeric domain "
|
||||
@@ -2954,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;
|
||||
}
|
||||
|
||||
@@ -86,8 +86,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Vector<Uint32> activeBindings;
|
||||
Vector<Uint32> dynamicBindings;
|
||||
Vector<Int> 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<Uint16> bindingDescriptorCounts;
|
||||
// Per-element GL uniform block indices for arrayed UBO bindings (count > 1);
|
||||
// element 0 of a non-arrayed binding stays in uniformBlockIndexByBinding.
|
||||
@@ -103,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<GLenum, kMaxVertexInputLocations> vertexInputTypes{};
|
||||
@@ -129,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);
|
||||
@@ -147,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;
|
||||
@@ -161,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;
|
||||
@@ -178,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);
|
||||
@@ -196,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;
|
||||
@@ -210,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;
|
||||
@@ -244,6 +271,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
modules.clear();
|
||||
stages.clear();
|
||||
stageSpirvDigests.clear(); // the modules they describe are gone
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Int>(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<Uint32>(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;
|
||||
// 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 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;
|
||||
}
|
||||
// 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);
|
||||
@@ -745,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");
|
||||
@@ -753,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<Int>(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<Uint>(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",
|
||||
@@ -847,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
|
||||
@@ -858,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];
|
||||
@@ -894,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<Uint32>(m_maxBindings, static_cast<Uint32>(programObj.bindingKinds.size()));
|
||||
@@ -902,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;
|
||||
@@ -923,6 +1021,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool UniformManager::SampledBindingsUnchanged(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj,
|
||||
const Vector<SampledBindingRecord>& 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
|
||||
@@ -935,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();
|
||||
@@ -959,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<Uint32>(m_maxBindings, static_cast<Uint32>(programObj.bindingKinds.size()));
|
||||
@@ -971,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<Uint>(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<Uint>(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;
|
||||
@@ -1361,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");
|
||||
@@ -1424,13 +1562,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
for (const auto& arrayEntry : programObj.arrayedUniformBlockIndicesByBinding) {
|
||||
uboArrayExtra += static_cast<Uint32>(arrayEntry.second.size()) - 1u;
|
||||
}
|
||||
Uint32 ssboArrayExtra = 0;
|
||||
// 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) ssboArrayExtra += static_cast<Uint32>(count) - 1u;
|
||||
if (count > 1) arrayDescriptorExtra += static_cast<Uint32>(count) - 1u;
|
||||
}
|
||||
writes.reserve(m_maxBindings);
|
||||
bufferInfos.reserve(m_maxBindings + uboArrayExtra + ssboArrayExtra);
|
||||
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);
|
||||
|
||||
@@ -1458,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<Uint32>(1, programObj.bindingDescriptorCounts[binding])
|
||||
: 1u;
|
||||
const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
|
||||
dynamicUboDescriptorCount += descriptorCount;
|
||||
fastRebindUboBinding = binding;
|
||||
const SizeT firstBufferInfoIndex = bufferInfos.size();
|
||||
@@ -1504,10 +1648,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// 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 =
|
||||
binding < programObj.bindingDescriptorCounts.size()
|
||||
? std::max<Uint32>(1, programObj.bindingDescriptorCounts[binding])
|
||||
: 1u;
|
||||
const Uint32 descriptorCount = BindingDescriptorCount(programObj, binding);
|
||||
const SizeT firstBufferInfoIndex = bufferInfos.size();
|
||||
for (Uint32 element = 0; element < descriptorCount; ++element) {
|
||||
VkDescriptorBufferInfo bufferInfo{};
|
||||
@@ -1527,45 +1668,86 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
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);
|
||||
// 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 (!hasImage) {
|
||||
MGLOG_E(
|
||||
"UniformDescriptorBinder::BindProgramUniformBuffers failed: sampler binding %u has no valid texture descriptor",
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 +156,19 @@ 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<MG_State::GLState::ITextureObject> 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;
|
||||
@@ -172,10 +181,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Bool ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program,
|
||||
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
|
||||
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 {
|
||||
@@ -344,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;
|
||||
};
|
||||
|
||||
@@ -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<HashType, UniquePtr<BackendVertexInputState>> m_cache;
|
||||
// Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging.
|
||||
Uint64 m_frameBoundaryCounter = 0;
|
||||
|
||||
@@ -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<MG_State::GLState::RenderbufferObject*, RenderbufferResource> m_renderbufferResources;
|
||||
UnorderedMap<MG_State::GLState::RenderbufferObject*, PendingRenderbufferClear> m_pendingRenderbufferClears;
|
||||
Vector<DeferredRenderbufferRelease> m_deferredRenderbufferReleases;
|
||||
|
||||
@@ -4582,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
|
||||
@@ -4624,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<unsigned>(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) {
|
||||
@@ -6144,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());
|
||||
@@ -6189,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());
|
||||
@@ -8621,7 +8660,7 @@ 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.
|
||||
|
||||
@@ -42,4 +42,5 @@ set_tests_properties(SanityBench PROPERTIES LABELS benchmark)
|
||||
|
||||
add_subdirectory(Program)
|
||||
add_subdirectory(Buffer)
|
||||
add_subdirectory(Driver)
|
||||
add_subdirectory(Driver)
|
||||
add_subdirectory(Container)
|
||||
@@ -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)
|
||||
@@ -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 <cstdint>
|
||||
#include <memory>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <benchmark/benchmark.h>
|
||||
|
||||
#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<std::unique_ptr<char[]>>& PointerKeyStorage() {
|
||||
static std::vector<std::unique_ptr<char[]>> storage;
|
||||
return storage;
|
||||
}
|
||||
|
||||
Vector<Uint64> SequentialKeys(SizeT n) {
|
||||
Vector<Uint64> keys;
|
||||
keys.reserve(n);
|
||||
for (SizeT i = 0; i < n; ++i) keys.push_back(static_cast<Uint64>(i) + 1);
|
||||
return keys;
|
||||
}
|
||||
|
||||
Vector<Uint64> PointerKeys(SizeT n) {
|
||||
auto& storage = PointerKeyStorage();
|
||||
Vector<Uint64> keys;
|
||||
keys.reserve(n);
|
||||
std::mt19937_64 rng(0xBEEF);
|
||||
std::vector<std::unique_ptr<char[]>> 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<char[]>(sz);
|
||||
keys.push_back(reinterpret_cast<Uint64>(p.get()));
|
||||
storage.push_back(std::move(p));
|
||||
if ((rng() & 3) == 0) churn.push_back(std::make_unique<char[]>(32 + (rng() % 128)));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
Vector<Uint64> DigestKeys(SizeT n) {
|
||||
Vector<Uint64> keys;
|
||||
keys.reserve(n);
|
||||
std::mt19937_64 rng(0xC0FFEE);
|
||||
for (SizeT i = 0; i < n; ++i) keys.push_back(rng());
|
||||
return keys;
|
||||
}
|
||||
|
||||
Vector<String> NameKeys(SizeT n) {
|
||||
static const char* kPrefixes[] = {"u_", "a_", "mc_", "iris_", "gl_", "v_"};
|
||||
Vector<String> 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 <typename KeyVec, KeyVec (*Make)(SizeT)>
|
||||
const KeyVec& CachedKeys(SizeT n) {
|
||||
static UnorderedMap<SizeT, KeyVec> cache;
|
||||
auto it = cache.find(n);
|
||||
if (it != cache.end()) return it->second;
|
||||
return cache.emplace(n, Make(n)).first->second;
|
||||
}
|
||||
|
||||
template <typename Key>
|
||||
UnorderedMap<Key, Uint64> Populated(const Vector<Key>& keys) {
|
||||
UnorderedMap<Key, Uint64> 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 <typename KeyVec, KeyVec (*Make)(SizeT)>
|
||||
void LookupHit(benchmark::State& state) {
|
||||
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(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<Int64>(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 <typename KeyVec, KeyVec (*Make)(SizeT)>
|
||||
void LookupMiss(benchmark::State& state) {
|
||||
const SizeT n = static_cast<SizeT>(state.range(0));
|
||||
const auto& keys = CachedKeys<KeyVec, Make>(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<Int64>(absent.size()));
|
||||
}
|
||||
|
||||
// Building a cache from empty, rehashes included.
|
||||
template <typename KeyVec, KeyVec (*Make)(SizeT)>
|
||||
void InsertGrow(benchmark::State& state) {
|
||||
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(state.range(0)));
|
||||
for (auto _ : state) {
|
||||
UnorderedMap<typename KeyVec::value_type, Uint64> map;
|
||||
for (SizeT i = 0; i < keys.size(); ++i) map[keys[i]] = i;
|
||||
benchmark::DoNotOptimize(map.size());
|
||||
}
|
||||
state.SetItemsProcessed(state.iterations() * static_cast<Int64>(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 <typename KeyVec, KeyVec (*Make)(SizeT)>
|
||||
void EraseChurn(benchmark::State& state) {
|
||||
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(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<Int64>(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 <typename KeyVec, KeyVec (*Make)(SizeT)>
|
||||
void EraseSweep(benchmark::State& state) {
|
||||
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(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<Int64>(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 <typename KeyVec, KeyVec (*Make)(SizeT)>
|
||||
void Iterate(benchmark::State& state) {
|
||||
const auto& keys = CachedKeys<KeyVec, Make>(static_cast<SizeT>(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<Int64>(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<Uint64>, SequentialKeys);
|
||||
MGL_MAP_BENCH(LookupHit, pointer, Vector<Uint64>, PointerKeys);
|
||||
MGL_MAP_BENCH(LookupHit, digest, Vector<Uint64>, DigestKeys);
|
||||
MGL_MAP_BENCH(LookupHit, name, Vector<String>, NameKeys);
|
||||
|
||||
MGL_MAP_BENCH(LookupMiss, sequential, Vector<Uint64>, SequentialKeys);
|
||||
MGL_MAP_BENCH(LookupMiss, pointer, Vector<Uint64>, PointerKeys);
|
||||
MGL_MAP_BENCH(LookupMiss, digest, Vector<Uint64>, DigestKeys);
|
||||
MGL_MAP_BENCH(LookupMiss, name, Vector<String>, NameKeys);
|
||||
|
||||
MGL_MAP_BENCH(InsertGrow, sequential, Vector<Uint64>, SequentialKeys);
|
||||
MGL_MAP_BENCH(InsertGrow, pointer, Vector<Uint64>, PointerKeys);
|
||||
MGL_MAP_BENCH(InsertGrow, digest, Vector<Uint64>, DigestKeys);
|
||||
MGL_MAP_BENCH(InsertGrow, name, Vector<String>, NameKeys);
|
||||
|
||||
MGL_MAP_BENCH(EraseChurn, sequential, Vector<Uint64>, SequentialKeys);
|
||||
MGL_MAP_BENCH(EraseChurn, digest, Vector<Uint64>, DigestKeys);
|
||||
MGL_MAP_BENCH(EraseChurn, name, Vector<String>, NameKeys);
|
||||
|
||||
MGL_MAP_BENCH(EraseSweep, sequential, Vector<Uint64>, SequentialKeys);
|
||||
MGL_MAP_BENCH(EraseSweep, digest, Vector<Uint64>, DigestKeys);
|
||||
|
||||
MGL_MAP_BENCH(Iterate, sequential, Vector<Uint64>, SequentialKeys);
|
||||
MGL_MAP_BENCH(Iterate, digest, Vector<Uint64>, DigestKeys);
|
||||
|
||||
BENCHMARK_MAIN();
|
||||
@@ -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<MG_State::GLState::ProgramObject>& 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,
|
||||
@@ -663,6 +670,17 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
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(
|
||||
@@ -682,6 +700,8 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
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(
|
||||
|
||||
@@ -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<GLint>(end - start);
|
||||
*data = static_cast<GLint>(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<GLint64>(end - start);
|
||||
// Verbatim, unclamped - see the GetIntegeri_v arm.
|
||||
*data = static_cast<GLint64>(range.end - range.start);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -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<SizeT>(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);
|
||||
|
||||
@@ -19,16 +19,26 @@ namespace MobileGL::MG_Impl::GLImpl {
|
||||
code, MakeUnique<GenericErrorInfo>("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<MG_State::GLState::ProgramPipelineObject>* 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) {
|
||||
|
||||
@@ -60,7 +60,14 @@ add_executable(MobileGLIntegrationTest
|
||||
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
|
||||
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#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<signed char> 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<GLsizeiptr>(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<int>(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<int>(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
|
||||
@@ -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 <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#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<GLenum>(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<float> ReadAttachment(int index) {
|
||||
std::vector<unsigned char> bytes(static_cast<std::size_t>(kSize) * kSize * 4, 0);
|
||||
glReadBuffer(GL_COLOR_ATTACHMENT0 + index);
|
||||
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_UNSIGNED_BYTE, bytes.data());
|
||||
std::vector<float> 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<std::size_t>(kSize / 2) * kSize + kSize / 2) * 4;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
centre[static_cast<std::size_t>(i)] = static_cast<float>(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<float> 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<float> 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<float> first = ReadAttachment(0);
|
||||
const std::vector<float> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#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<GLsizei>(m_textures.size()), m_textures.data());
|
||||
if (!m_buffers.empty()) glDeleteBuffers(static_cast<GLsizei>(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<GLuint> m_textures;
|
||||
std::vector<GLuint> m_buffers;
|
||||
std::vector<GLuint> 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<GLuint>(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<GLint>(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
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#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<GLuint> m_programs;
|
||||
std::vector<GLuint> 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<float> zeros(static_cast<size_t>(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<GLuint>(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<float> readback(static_cast<size_t>(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<size_t>(layer) * kHeight * kWidth + static_cast<size_t>(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<float> zeros(static_cast<size_t>(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<float> readback(static_cast<size_t>(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<size_t>(kWidth) * kHeight * 4; ++i) {
|
||||
const size_t index = static_cast<size_t>(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<float> zeros(static_cast<size_t>(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<float> readback(static_cast<size_t>(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<size_t>(kWidth) * kHeight * 4; ++i) {
|
||||
const size_t index = static_cast<size_t>(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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#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<GLuint> m_programs;
|
||||
std::vector<GLuint> 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<GLuint>(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<GLuint> zeros(static_cast<std::size_t>(secondOffset) / sizeof(GLuint) + 4, 0u);
|
||||
glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(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<GLuint> zeros(static_cast<std::size_t>(secondOffset) / sizeof(GLuint) + 4, 0u);
|
||||
glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(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
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#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<GLuint>(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<GLuint>(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<bool>(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
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#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<int> 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<int> Dispatch() {
|
||||
glUseProgram(m_program);
|
||||
glDispatchCompute(1, 1, 1);
|
||||
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
|
||||
std::vector<int> 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<int> values = Dispatch();
|
||||
EXPECT_EQ(glGetError(), static_cast<GLenum>(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<int> values = Dispatch();
|
||||
EXPECT_EQ(glGetError(), static_cast<GLenum>(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<int> values = Dispatch();
|
||||
EXPECT_EQ(glGetError(), static_cast<GLenum>(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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<const char*>(source.GetUBOData());
|
||||
char* destinationUbo = static_cast<char*>(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<Uint>& writtenIndices = source.GetWrittenUniformIndices();
|
||||
const Uint uniformCount = source.GetUniformCount();
|
||||
const SizeT indexCount = byWriteSet ? writtenIndices.size() : static_cast<SizeT>(uniformCount);
|
||||
if (indexCount == 0) return;
|
||||
|
||||
for (SizeT slot = 0; slot < indexCount; ++slot) {
|
||||
const Uint index = byWriteSet ? writtenIndices[slot] : static_cast<Uint>(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<Int>(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<Uint>(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<Uint>(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<Int>(source.GetUniformBlockBinding(static_cast<Uint>(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<Uint>(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<Uint>(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<ProgramObject>& 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<ProgramObject*, ProgramPipelineObject::kGraphicsStageCount> mirrored{};
|
||||
SizeT mirroredCount = 0;
|
||||
for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) {
|
||||
const auto& stageProgram = pipeline.GetStageProgram(static_cast<ShaderStage>(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<ProgramObject>& GLContext::GetProgramForDraw() {
|
||||
static const SharedPtr<ProgramObject> 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<SizeT>(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<ShaderStage>(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<ProgramObject>(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<SizeT>(ShaderStage::ShaderStageCount); ++stage) {
|
||||
for (SizeT stage = 0; stage < ProgramPipelineObject::kGraphicsStageCount; ++stage) {
|
||||
const auto& stageProgram = pipeline->GetStageProgram(static_cast<ShaderStage>(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<ProgramObject>& GLContext::GetProgramForDispatch() {
|
||||
static const SharedPtr<ProgramObject> 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<ProgramObject>& GLContext::GetProgramForUniform() {
|
||||
@@ -919,31 +1161,60 @@ namespace MobileGL::MG_State {
|
||||
// Program pipeline
|
||||
void GLContext::GenProgramPipelineNames(Uint number, Vector<Uint>& 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<ProgramPipelineObject>(index);
|
||||
const auto object = MakeShared<ProgramPipelineObject>(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<ProgramPipelineObject>(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<ProgramPipelineObject>& GLContext::MaterializeProgramPipelineObject(Uint index) {
|
||||
static const SharedPtr<ProgramPipelineObject> 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<ProgramPipelineObject>(index);
|
||||
}
|
||||
|
||||
void GLContext::MarkProgramPipelineForDeletion(Uint index) {
|
||||
if (index == 0 || !m_programPipelineNames.IsValid(index)) return;
|
||||
if (index == m_boundProgramPipeline) {
|
||||
|
||||
@@ -163,21 +163,31 @@ namespace MobileGL {
|
||||
}
|
||||
void UseProgram(Uint program);
|
||||
const SharedPtr<ProgramObject>& 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<ProgramObject>& 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<ProgramObject>& GetProgramForDispatch();
|
||||
// What glUniform* addresses: the program in use, or the bound pipeline's
|
||||
// active program (GL 4.6 core 7.6.1).
|
||||
const SharedPtr<ProgramObject>& 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<Uint>& 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<ProgramPipelineObject>& MaterializeProgramPipelineObject(Uint index);
|
||||
void MarkProgramPipelineForDeletion(Uint index);
|
||||
const SharedPtr<ProgramPipelineObject>& GetProgramPipelineObject(Uint index) const;
|
||||
Uint GetBoundProgramPipelineName() const { return m_boundProgramPipeline; }
|
||||
@@ -447,8 +457,10 @@ namespace MobileGL {
|
||||
UnorderedMap<Uint, TransformFeedbackObjectState> m_transformFeedbackObjects;
|
||||
IndexGenerator<Uint> 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<Uint, SharedPtr<ProgramPipelineObject>> m_programPipelines;
|
||||
IndexGenerator<Uint> m_programPipelineNames;
|
||||
Uint m_boundProgramPipeline = 0;
|
||||
|
||||
@@ -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<MobileGL::Int>(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<SharedPtr<glslang::TShader>> 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;
|
||||
@@ -487,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]) {
|
||||
@@ -898,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());
|
||||
}
|
||||
|
||||
@@ -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<SizeT>(componentsPerElement) * static_cast<SizeT>(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<Uint>(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<Uint>(location));
|
||||
const SizeT columnStride =
|
||||
columns > 0 ? slotSpan / static_cast<SizeT>(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<SizeT>(offset) + static_cast<SizeT>(column) * columnStride;
|
||||
const SizeT writeSize = static_cast<SizeT>(componentsPerColumn) * sizeof(Uint32);
|
||||
if (byteOffset + writeSize > uboSize) break;
|
||||
const SizeT firstComponent = static_cast<SizeT>(element) * componentsPerElement +
|
||||
static_cast<SizeT>(column) * componentsPerColumn;
|
||||
for (Int component = 0; component < componentsPerColumn; ++component) {
|
||||
const SizeT source = firstComponent + static_cast<SizeT>(component);
|
||||
Uint8* const destination = scratch + byteOffset + component * sizeof(Uint32);
|
||||
if (isFloat) {
|
||||
const Float value = static_cast<Float>(init.floatValues[source]);
|
||||
std::memcpy(destination, &value, sizeof(value));
|
||||
} else {
|
||||
const Int32 value = static_cast<Int32>(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();
|
||||
|
||||
@@ -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<SizeT>(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<Int>(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<SizeT>(locationWord + 1u, static_cast<SizeT>(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<SizeT>(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<SizeT>(glIndex) / 64u;
|
||||
if (indexWord >= artifacts.writtenUniformIndexBits.size()) {
|
||||
artifacts.writtenUniformIndexBits.resize(
|
||||
std::max<SizeT>(indexWord + 1u, static_cast<SizeT>(artifacts.activeUniformCount) / 64u + 1u), 0u);
|
||||
}
|
||||
const Uint64 indexBit = Uint64{1} << (static_cast<SizeT>(glIndex) % 64u);
|
||||
if ((artifacts.writtenUniformIndexBits[indexWord] & indexBit) != 0) return;
|
||||
artifacts.writtenUniformIndexBits[indexWord] |= indexBit;
|
||||
artifacts.writtenUniformIndices.push_back(static_cast<Uint>(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<Uint>& 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() {
|
||||
@@ -599,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<Int>(binding)) {
|
||||
return;
|
||||
}
|
||||
Artifacts().uniformBlockBinding[index] = static_cast<Int>(binding);
|
||||
++m_backendStateVersion;
|
||||
++m_blockBindingVersion;
|
||||
}
|
||||
|
||||
Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; }
|
||||
@@ -617,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<Int>(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
|
||||
@@ -728,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<String, Int> 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<glslang::TIntermediate::TUniformInitializer> uniformInitialValues;
|
||||
UnorderedMap<String, Uint> 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<Uint64> writtenUniformLocationBits;
|
||||
Vector<Uint64> writtenUniformIndexBits;
|
||||
Vector<Uint> writtenUniformIndices;
|
||||
// Ordered by location,
|
||||
// aka. uniformIndexInTProgram[loc] == "uniform index of TProgram at location `loc`"
|
||||
Vector<Int> uniformIndexInTProgram;
|
||||
@@ -982,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.
|
||||
@@ -1040,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
|
||||
|
||||
@@ -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<SizeT>(ShaderStage::Compute);
|
||||
static_assert(static_cast<SizeT>(ShaderStage::Compute) + 1 ==
|
||||
static_cast<SizeT>(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<Uint64, static_cast<SizeT>(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<Uint64, kGraphicsStageCount * 2>;
|
||||
|
||||
DrawProgramSignature ComputeDrawProgramSignature() const {
|
||||
DrawProgramSignature signature{};
|
||||
for (SizeT stage = 0; stage < static_cast<SizeT>(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<Uint64, kGraphicsStageCount * 2>;
|
||||
|
||||
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<Uint64>(program->GetBackendStateVersion()) << 32) |
|
||||
static_cast<Uint64>(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<Uint64>(program->GetBlockBindingVersion()) << 32) |
|
||||
static_cast<Uint64>(program->GetUniformWriteSetVersion());
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
const UniformMirrorVersions& GetMirroredUniformVersions() const { return m_mirroredUniformVersions; }
|
||||
void SetMirroredUniformVersions(const UniformMirrorVersions& versions) {
|
||||
m_mirroredUniformVersions = versions;
|
||||
}
|
||||
|
||||
const SharedPtr<ProgramObject>& GetCachedDrawProgram(const DrawProgramSignature& signature) const {
|
||||
static const SharedPtr<ProgramObject> nullProgram = nullptr;
|
||||
if (!m_drawProgram || m_drawProgramSignature != signature) return nullProgram;
|
||||
@@ -67,6 +154,8 @@ namespace MobileGL {
|
||||
void SetCachedDrawProgram(const DrawProgramSignature& signature, SharedPtr<ProgramObject> 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<ProgramObject> m_activeProgram;
|
||||
SharedPtr<ProgramObject> 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
|
||||
|
||||
@@ -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<ShaderSourceKey> dead;
|
||||
for (const auto& entry : m_entries) {
|
||||
const SharedPtr<ShaderCompileTask> node = entry.second.lock();
|
||||
|
||||
@@ -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)
|
||||
@@ -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 <gtest/gtest.h>
|
||||
|
||||
#include <MG_Backend/DirectGLES/Utils.h>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<MobileGL::MG_External::GLES::glTexBuffer_PTR>(
|
||||
[](GLenum, GLenum, GLuint) {})
|
||||
: nullptr;
|
||||
funcs.glTexBufferEXT = g_fake.hasExtTexBufferEntryPoint
|
||||
? static_cast<MobileGL::MG_External::GLES::glTexBufferEXT_PTR>(
|
||||
[](GLenum, GLenum, GLuint) {})
|
||||
: nullptr;
|
||||
funcs.glTexBufferOES = g_fake.hasOesTexBufferEntryPoint
|
||||
? static_cast<MobileGL::MG_External::GLES::glTexBufferOES_PTR>(
|
||||
[](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;
|
||||
|
||||
@@ -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<GLint>(offset));
|
||||
EXPECT_EQ(size32, static_cast<GLint>(size));
|
||||
EXPECT_EQ(start64, static_cast<GLint64>(offset));
|
||||
EXPECT_EQ(size64, static_cast<GLint64>(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<GLint>(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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -180,6 +180,22 @@ target_link_libraries(
|
||||
${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
|
||||
@@ -211,6 +227,7 @@ 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.
|
||||
|
||||
@@ -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 <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<MG_State::GLState::ProgramObject> 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<float> 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<Uint>(location));
|
||||
const auto* ubo = static_cast<const char*>(program.GetUBOData());
|
||||
if (ubo == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
|
||||
offset + 4 * sizeof(float) > program.GetUBOSize()) {
|
||||
return {};
|
||||
}
|
||||
std::vector<float> 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<float> value = ReadVec4(*composite, "u_shared");
|
||||
ASSERT_EQ(value.size(), 4u) << "u_shared has no backing storage in the composite";
|
||||
EXPECT_EQ(value, (std::vector<float>{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<float>{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<float>{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<float>{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<float>{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<float>{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<float>{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<float>{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<float>{0.0f, 0.0f, 0.0f, 0.0f}));
|
||||
EXPECT_EQ(ReadVec4(*composite, "u_arr[1]"), (std::vector<float>{11.0f, 11.0f, 11.0f, 11.0f}));
|
||||
EXPECT_EQ(ReadVec4(*composite, "u_arr[2]"), (std::vector<float>{22.0f, 22.0f, 22.0f, 22.0f}));
|
||||
EXPECT_EQ(ReadVec4(*composite, "u_arr[3]"), (std::vector<float>{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<float>{44.0f, 44.0f, 44.0f, 44.0f}));
|
||||
EXPECT_EQ(ReadVec4(*refreshed, "u_arr[3]"), (std::vector<float>{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<float>{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<float>{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<float>{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<Uint>(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<float>{1.0f, 2.0f, 3.0f, 4.0f}));
|
||||
|
||||
UseProgram(0);
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
|
||||
#include <MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.h>
|
||||
#include <MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.h>
|
||||
#include <MG_Util/ShaderTranspiler/Types.h>
|
||||
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h>
|
||||
@@ -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<String, Uint> 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<Uint32> 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<Uint32>& 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<unsigned char>(essl[indexStart])) == 0) return false;
|
||||
sawAny = true;
|
||||
offset = indexStart;
|
||||
}
|
||||
return sawAny;
|
||||
}
|
||||
|
||||
String DecompileToEssl(const Vector<Uint32>& 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> 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;
|
||||
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
||||
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
|
||||
#include <MG_Util/Debug/Log.h>
|
||||
#include <FastSTL/UnorderedMap.h>
|
||||
#include <MG_Util/Types.h>
|
||||
#include <set>
|
||||
|
||||
namespace {
|
||||
class DynamicParameterBackend final : public MobileGL::MG_Backend::BackendObject {
|
||||
@@ -1998,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<MobileGL::Uint64, MobileGL::Uint64> 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<MobileGL::Uint64, MobileGL::Uint64> 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<MobileGL::Uint64> 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<MobileGL::Uint32, MobileGL::Uint32> map;
|
||||
TEST(UnorderedMapSanity, EraseReturnsTheSuccessorElement) {
|
||||
MobileGL::UnorderedMap<MobileGL::Uint32, MobileGL::Uint32> map;
|
||||
for (MobileGL::Uint32 key = 1; key <= 64; ++key) {
|
||||
map.emplace(key, key);
|
||||
}
|
||||
@@ -2033,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<MobileGL::Uint32> erasedKeys;
|
||||
std::set<MobileGL::Uint32> 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<MobileGL::Uint32, MobileGL::Uint32> map;
|
||||
TEST(UnorderedMapSanity, ErasingTheOnlyElementReturnsEnd) {
|
||||
using Map = MobileGL::UnorderedMap<MobileGL::Uint32, MobileGL::Uint32>;
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -1243,6 +1343,8 @@ namespace MobileGL::MG_Util::BackendLoader {
|
||||
caps.IsAngleRenderer && caps.GLESRendererString.find("llvmpipe") != String::npos;
|
||||
caps.AvoidSamplerMipmapMinFilter =
|
||||
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.AvoidSamplerMipmapMinFilter;
|
||||
caps.AvoidExplicitLodBias =
|
||||
caps.IsAngleLlvmpipeRenderer && MG_Config::Features.AvoidExplicitLodBias;
|
||||
MGLOG_I(" GL_EXT_disjoint_timer_query supported: %s",
|
||||
caps.SupportsDisjointTimerQuery ? "true" : "false");
|
||||
MGLOG_I(" GL_KHR_parallel_shader_compile supported: %s",
|
||||
@@ -1251,6 +1353,7 @@ namespace MobileGL::MG_Util::BackendLoader {
|
||||
MGLOG_I(" ANGLE llvmpipe renderer: %s", caps.IsAngleLlvmpipeRenderer ? "true" : "false");
|
||||
MGLOG_I(" Avoid sampler mipmap min filter: %s",
|
||||
caps.AvoidSamplerMipmapMinFilter ? "true" : "false");
|
||||
MGLOG_I(" Avoid explicit LOD bias: %s", caps.AvoidExplicitLodBias ? "true" : "false");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1121,6 +1156,9 @@ namespace MobileGL {
|
||||
// MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER feature toggle:
|
||||
// sampler min filters should drop their mipmap component.
|
||||
Bool AvoidSamplerMipmapMinFilter = false;
|
||||
// IsAngleLlvmpipeRenderer combined with the MOBILEGL_AVOID_EXPLICIT_LOD_BIAS
|
||||
// feature toggle: LOD-bias emulation should not touch explicit-LOD lookups.
|
||||
Bool AvoidExplicitLodBias = false;
|
||||
// True when indirect draws leak the command's baseInstance word ("reserved,
|
||||
// must be zero" in unextended ES) into gl_InstanceID. Conforming ES drivers
|
||||
// keep gl_InstanceID zero-based; ANGLE's Vulkan backend hands the command
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 <MG_Backend/BackendObjects.h>
|
||||
@@ -537,6 +540,42 @@ namespace MobileGL {
|
||||
return g_spirvValidationFailures.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
Bool ShaderCompiler::ModuleDeclaresBufferTextureSampler(const Vector<Uint32>& 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<spvtools::opt::IRContext> 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<spv::Dim>(type.GetSingleWordInOperand(1)) == spv::Dim::Buffer) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ShaderCompiler::SanitizeAndOptimizeBinary(const Vector<Uint32>& inputBinary,
|
||||
Vector<uint32_t>& outputBinary) {
|
||||
using namespace spvtools;
|
||||
@@ -631,6 +670,75 @@ namespace MobileGL {
|
||||
outputBinary);
|
||||
}
|
||||
|
||||
bool ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(const Vector<Uint32>& inputBinary,
|
||||
Vector<uint32_t>& 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<uint32_t> 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<Uint32>& inputBinary,
|
||||
Vector<uint32_t>& outputBinary) {
|
||||
using namespace spvtools;
|
||||
|
||||
@@ -43,6 +43,17 @@ namespace MobileGL {
|
||||
// devices lacking GL_NV_shader_noperspective_interpolation. See EmulateNoPerspectivePass.
|
||||
static bool EmulateNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
|
||||
Vector<uint32_t>& 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<Uint32>& inputBinary,
|
||||
Vector<uint32_t>& 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<Uint32>& spirv);
|
||||
};
|
||||
} // namespace ShaderTranspiler
|
||||
} // namespace MG_Util
|
||||
|
||||
@@ -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 <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
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<spv::ExecutionModel>(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<uint32_t, uint32_t> CollectOutputArrays(IRContext* context) {
|
||||
std::unordered_map<uint32_t, uint32_t> 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<spv::StorageClass>(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<DynamicIndexUse> CollectDynamicIndexUses(IRContext* context) {
|
||||
std::vector<DynamicIndexUse> uses;
|
||||
if (!HasFragmentEntryPoint(context)) {
|
||||
return uses;
|
||||
}
|
||||
|
||||
const std::unordered_map<uint32_t, uint32_t> 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<Operand> 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<Instruction>(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<uint32_t>& binary) {
|
||||
if (binary.empty()) {
|
||||
return false;
|
||||
}
|
||||
std::unique_ptr<IRContext> 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<DynamicIndexUse> 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<uint32_t>(spv::LoopControlMask::MaskNone)) {
|
||||
continue;
|
||||
}
|
||||
mergeInst->SetOperand(
|
||||
2, {static_cast<uint32_t>(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<uint32_t> declined;
|
||||
|
||||
for (int round = 0; round < kMaxLoweringRounds; ++round) {
|
||||
const std::vector<DynamicIndexUse> 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<Instruction*> stores;
|
||||
std::vector<Instruction*> 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<Operand> 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<std::pair<Operand::OperandData, uint32_t>> targets;
|
||||
targets.reserve(arrayLength);
|
||||
BasicBlock* insertAfter = block;
|
||||
for (uint32_t element = 0; element < arrayLength; ++element) {
|
||||
const uint32_t caseLabelId = irContext->TakeNextId();
|
||||
auto caseBlock = MakeUnique<BasicBlock>(MakeUnique<Instruction>(
|
||||
irContext, spv::Op::OpLabel, 0, caseLabelId, std::initializer_list<Operand>{}));
|
||||
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<Operand> 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<Instruction>(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<uint32_t> 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<LegalizeFragmentOutputIndexPass>(Mode::MarkLoopsForUnroll));
|
||||
}
|
||||
|
||||
spvtools::Optimizer::PassToken LegalizeFragmentOutputIndexPass::CreateLowerToConstantSwitchPass() {
|
||||
return spvtools::Optimizer::PassToken(
|
||||
MakeUnique<LegalizeFragmentOutputIndexPass>(Mode::LowerToConstantSwitch));
|
||||
}
|
||||
} // namespace ShaderTranspiler
|
||||
} // namespace MG_Util
|
||||
} // namespace MobileGL
|
||||
@@ -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 <Includes.h>
|
||||
#include <vector>
|
||||
|
||||
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<uint32_t>& 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
|
||||
@@ -289,6 +289,43 @@ namespace MobileGL {
|
||||
SPVC_CHK_RETURN
|
||||
}
|
||||
|
||||
spvc_result SpvcSession::SetShaderStorageBlockBinding(const UnorderedMap<String, Int>& 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<unsigned>(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
|
||||
|
||||
@@ -94,6 +94,17 @@ namespace MobileGL {
|
||||
spvc_result SetOptions(spvc_compiler_options options);
|
||||
Vector<InterfaceVariable> GetShaderInterface(spvc_resource_type resource_type) const;
|
||||
spvc_result SetVertexAttribLocation(const UnorderedMap<String, Uint>& 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<String, Int>& bindings);
|
||||
spvc_result Compile(const char** result);
|
||||
const SpvcMetadata& GetMetadata() const;
|
||||
const char* GetLastErrorString() const;
|
||||
|
||||
@@ -55,9 +55,37 @@ namespace MobileGL {
|
||||
using SizeT = std::size_t;
|
||||
template <typename T, SizeT N>
|
||||
using Array = std::array<T, N>;
|
||||
// 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<Key, T> 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<Key, T>, not pair<const Key, T>.
|
||||
//
|
||||
// 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 <typename Key, typename T, class Hash = std::hash<Key>, class KeyEqual = std::equal_to<Key>,
|
||||
class Allocator = std::allocator<std::pair<const Key, T>>>
|
||||
using UnorderedMap = FastSTL::unordered_map<Key, T, Hash, KeyEqual, Allocator>;
|
||||
class Allocator = std::allocator<std::pair<Key, T>>>
|
||||
using UnorderedMap = ska::flat_hash_map<Key, T, Hash, KeyEqual, Allocator>;
|
||||
template <typename T>
|
||||
inline constexpr std::remove_reference_t<T>&& Move(T&& t) noexcept {
|
||||
return static_cast<std::remove_reference_t<T>&&>(t);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -154,11 +154,28 @@ bool LoadMobileGL(const Request& request, std::string& error) {
|
||||
} else {
|
||||
unsetenv("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
|
||||
}
|
||||
if (request.avoidAngleLlvmpipeExplicitLodBias) {
|
||||
setenv("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS", "1", 1);
|
||||
} else {
|
||||
unsetenv("MOBILEGL_AVOID_EXPLICIT_LOD_BIAS");
|
||||
}
|
||||
if (request.coherentAsFlush) {
|
||||
setenv("MOBILEGL_COHERENT_AS_FLUSH", "1", 1);
|
||||
} 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 +372,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";
|
||||
@@ -766,6 +797,8 @@ bool WriteResultJson(const Request& request, const Result& result) {
|
||||
file << " \"usePbuffer\": " << (request.usePbuffer ? "true" : "false") << ",\n";
|
||||
file << " \"avoidAngleLlvmpipeSamplerMipmapMinFilter\": "
|
||||
<< (request.avoidAngleLlvmpipeSamplerMipmapMinFilter ? "true" : "false") << ",\n";
|
||||
file << " \"avoidAngleLlvmpipeExplicitLodBias\": "
|
||||
<< (request.avoidAngleLlvmpipeExplicitLodBias ? "true" : "false") << ",\n";
|
||||
file << " \"holdMs\": " << request.holdMs << ",\n";
|
||||
file << " \"mismatchPixels\": " << result.mismatchPixels << "\n";
|
||||
file << "}\n";
|
||||
|
||||
@@ -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<std::string> fboAttachmentDumps;
|
||||
int targetFrame = -1;
|
||||
long long targetCall = -1;
|
||||
int width = 0;
|
||||
@@ -36,6 +39,7 @@ struct Request {
|
||||
bool useAngle = false;
|
||||
bool usePbuffer = true;
|
||||
bool avoidAngleLlvmpipeSamplerMipmapMinFilter = false;
|
||||
bool avoidAngleLlvmpipeExplicitLodBias = false;
|
||||
bool coherentAsFlush = false;
|
||||
int holdMs = 0;
|
||||
};
|
||||
|
||||
@@ -102,6 +102,7 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
|
||||
jboolean useAngle,
|
||||
jboolean usePbuffer,
|
||||
jboolean avoidAngleLlvmpipeSamplerMipmapMinFilter,
|
||||
jboolean avoidAngleLlvmpipeExplicitLodBias,
|
||||
jboolean coherentAsFlush) {
|
||||
mobilegl_trace::Request request;
|
||||
request.tracePath = ToString(env, tracePath);
|
||||
@@ -127,6 +128,7 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
|
||||
request.usePbuffer = usePbuffer == JNI_TRUE;
|
||||
request.avoidAngleLlvmpipeSamplerMipmapMinFilter =
|
||||
avoidAngleLlvmpipeSamplerMipmapMinFilter == JNI_TRUE;
|
||||
request.avoidAngleLlvmpipeExplicitLodBias = avoidAngleLlvmpipeExplicitLodBias == JNI_TRUE;
|
||||
request.coherentAsFlush = coherentAsFlush == JNI_TRUE;
|
||||
|
||||
ScopedTraceReplayState replayState;
|
||||
|
||||
@@ -114,6 +114,7 @@ public final class TraceReplayActivity extends Activity {
|
||||
request.useAngle,
|
||||
request.usePbuffer,
|
||||
request.avoidAngleLlvmpipeSamplerMipmapMinFilter,
|
||||
request.avoidAngleLlvmpipeExplicitLodBias,
|
||||
request.coherentAsFlush
|
||||
);
|
||||
Log.i(TAG, result.toString());
|
||||
@@ -145,6 +146,7 @@ public final class TraceReplayActivity extends Activity {
|
||||
boolean useAngle,
|
||||
boolean usePbuffer,
|
||||
boolean avoidAngleLlvmpipeSamplerMipmapMinFilter,
|
||||
boolean avoidAngleLlvmpipeExplicitLodBias,
|
||||
boolean coherentAsFlush
|
||||
);
|
||||
|
||||
@@ -168,6 +170,7 @@ public final class TraceReplayActivity extends Activity {
|
||||
final boolean useAngle;
|
||||
final boolean usePbuffer;
|
||||
final boolean avoidAngleLlvmpipeSamplerMipmapMinFilter;
|
||||
final boolean avoidAngleLlvmpipeExplicitLodBias;
|
||||
final boolean coherentAsFlush;
|
||||
|
||||
private TraceReplayRequest(
|
||||
@@ -190,6 +193,7 @@ public final class TraceReplayActivity extends Activity {
|
||||
boolean useAngle,
|
||||
boolean usePbuffer,
|
||||
boolean avoidAngleLlvmpipeSamplerMipmapMinFilter,
|
||||
boolean avoidAngleLlvmpipeExplicitLodBias,
|
||||
boolean coherentAsFlush
|
||||
) {
|
||||
this.tracePath = tracePath;
|
||||
@@ -211,6 +215,7 @@ public final class TraceReplayActivity extends Activity {
|
||||
this.useAngle = useAngle;
|
||||
this.usePbuffer = usePbuffer;
|
||||
this.avoidAngleLlvmpipeSamplerMipmapMinFilter = avoidAngleLlvmpipeSamplerMipmapMinFilter;
|
||||
this.avoidAngleLlvmpipeExplicitLodBias = avoidAngleLlvmpipeExplicitLodBias;
|
||||
this.coherentAsFlush = coherentAsFlush;
|
||||
}
|
||||
|
||||
@@ -237,6 +242,7 @@ public final class TraceReplayActivity extends Activity {
|
||||
intent.getBooleanExtra("use_angle", false),
|
||||
intent.getBooleanExtra("use_pbuffer", false),
|
||||
intent.getBooleanExtra("avoid_angle_llvmpipe_sampler_mipmap_min_filter", false),
|
||||
intent.getBooleanExtra("avoid_angle_llvmpipe_explicit_lod_bias", false),
|
||||
intent.getBooleanExtra("coherent_as_flush", false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ Usage:
|
||||
--crop-height N \
|
||||
[--use-pbuffer] \
|
||||
[--avoid-angle-llvmpipe-sampler-mipmap-min-filter] \
|
||||
[--avoid-angle-llvmpipe-explicit-lod-bias] \
|
||||
[--coherent-as-flush] \
|
||||
--timeout-seconds N
|
||||
|
||||
@@ -40,6 +41,9 @@ Set MOBILEGL_RETRACE_USE_PBUFFER=1 or pass --use-pbuffer to run DirectGLES
|
||||
against an offscreen EGL pbuffer instead of the Activity surface.
|
||||
Pass --avoid-angle-llvmpipe-sampler-mipmap-min-filter for DirectGLES traces that
|
||||
need ANGLE llvmpipe sampler mipmap filters downgraded to avoid driver stalls.
|
||||
Pass --avoid-angle-llvmpipe-explicit-lod-bias for DirectGLES traces whose shaders
|
||||
sample with an explicit LOD that ANGLE llvmpipe cannot take a LOD bias on
|
||||
(MOBILEGL_AVOID_EXPLICIT_LOD_BIAS=1).
|
||||
Pass --coherent-as-flush for traces whose engine writes persistent
|
||||
GL_MAP_FLUSH_EXPLICIT_BIT maps it never flushes (MOBILEGL_COHERENT_AS_FLUSH=1).
|
||||
EOF
|
||||
@@ -98,6 +102,7 @@ crop_width=""
|
||||
crop_height=""
|
||||
use_pbuffer=0
|
||||
avoid_angle_llvmpipe_sampler_mipmap_min_filter=0
|
||||
avoid_angle_llvmpipe_explicit_lod_bias=0
|
||||
coherent_as_flush=0
|
||||
timeout_seconds=""
|
||||
|
||||
@@ -134,6 +139,10 @@ while [ "$#" -gt 0 ]; do
|
||||
avoid_angle_llvmpipe_sampler_mipmap_min_filter=1
|
||||
shift 1
|
||||
;;
|
||||
--avoid-angle-llvmpipe-explicit-lod-bias)
|
||||
avoid_angle_llvmpipe_explicit_lod_bias=1
|
||||
shift 1
|
||||
;;
|
||||
--coherent-as-flush) coherent_as_flush=1; shift 1 ;;
|
||||
--timeout-seconds) timeout_seconds="$(next_arg "$@")"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
@@ -186,15 +195,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
|
||||
@@ -280,6 +329,9 @@ run_retrace() {
|
||||
if [ "${avoid_angle_llvmpipe_sampler_mipmap_min_filter}" -eq 1 ] && [ "${backend}" = "DirectGLES" ]; then
|
||||
set -- "$@" --ez avoid_angle_llvmpipe_sampler_mipmap_min_filter true
|
||||
fi
|
||||
if [ "${avoid_angle_llvmpipe_explicit_lod_bias}" -eq 1 ] && [ "${backend}" = "DirectGLES" ]; then
|
||||
set -- "$@" --ez avoid_angle_llvmpipe_explicit_lod_bias true
|
||||
fi
|
||||
if [ "${coherent_as_flush}" -eq 1 ]; then
|
||||
set -- "$@" --ez coherent_as_flush true
|
||||
fi
|
||||
@@ -344,6 +396,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"
|
||||
}
|
||||
|
||||
|
||||
Submodule include/FastSTL deleted from 022211c998
@@ -367,6 +367,7 @@ public:
|
||||
|
||||
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<int>* newParams = nullptr);
|
||||
|
||||
@@ -611,6 +611,32 @@ public:
|
||||
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<long long> intValues;
|
||||
std::vector<double> floatValues;
|
||||
};
|
||||
void addUniformInitializer(TUniformInitializer&& init) { uniformInitializers.push_back(std::move(init)); }
|
||||
const std::vector<TUniformInitializer>& 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 @@ protected:
|
||||
|
||||
std::string globalUniformBlockName;
|
||||
std::string atomicCounterBlockName;
|
||||
std::vector<TUniformInitializer> uniformInitializers;
|
||||
unsigned int globalUniformBlockSet;
|
||||
unsigned int globalUniformBlockBinding;
|
||||
unsigned int atomicCounterBlockSet;
|
||||
|
||||
Submodule
+1
Submodule include/ska added at 21c1cec95a
@@ -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
|
||||
|
||||
@@ -20,8 +20,6 @@ The bundled fixtures cover:
|
||||

|
||||
- minecraft-26.2-main-menu: captured from Minecraft 26.2's main menu.
|
||||

|
||||
- minecraft-26.2-in-world: captured from Minecraft 26.2 after entering a normal singleplayer world.
|
||||

|
||||
- improved-transparency-minecraft-26.3: captured from the Minecraft 26.3 improved-transparency scene.
|
||||

|
||||
- 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<N>-att<M>.png` / `fbo<N>-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:
|
||||
|
||||
@@ -250,4 +275,59 @@ default; pass `--ez use_pbuffer true` to use the offscreen pbuffer path. Always
|
||||
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`.
|
||||
`MOBILEGL_COHERENT_AS_FLUSH=1`. For cases registered with
|
||||
`avoid_angle_llvmpipe_explicit_lod_bias` (DirectGLES on ANGLE llvmpipe, e.g. the
|
||||
sundial-lite fixture), pass `--ez avoid_angle_llvmpipe_explicit_lod_bias true` so
|
||||
the replay runs with `MOBILEGL_AVOID_EXPLICIT_LOD_BIAS=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.
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
#include "apitrace_fbo_dump.hpp"
|
||||
|
||||
#include "glproc.hpp"
|
||||
#include "image.hpp"
|
||||
#include "retrace.hpp"
|
||||
#include "state_writer.hpp"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <sys/stat.h>
|
||||
#include <vector>
|
||||
|
||||
// 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<unsigned> 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<DumpPoint> 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<PfnGetIntegerv>(_getPrivateProcAddress("glGetIntegerv"));
|
||||
}
|
||||
if (gGetError == nullptr) {
|
||||
gGetError = reinterpret_cast<PfnGetError>(_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<std::string> Split(const std::string &value, char separator) {
|
||||
std::vector<std::string> 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<std::string> 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<unsigned>(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<unsigned>(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<unsigned>(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<GLuint>(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<GLuint>(boundRenderbuffer));
|
||||
} else if (desc.objectType == GL_TEXTURE) {
|
||||
glGetFramebufferAttachmentParameteriv(GL_READ_FRAMEBUFFER, attachment,
|
||||
GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_LEVEL, &desc.level);
|
||||
glGetTextureLevelParameteriv(static_cast<GLuint>(desc.objectName), desc.level,
|
||||
GL_TEXTURE_WIDTH, &desc.width);
|
||||
glGetTextureLevelParameteriv(static_cast<GLuint>(desc.objectName), desc.level,
|
||||
GL_TEXTURE_HEIGHT, &desc.height);
|
||||
glGetTextureLevelParameteriv(static_cast<GLuint>(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<GLuint>(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<GLuint>(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<float> &pixels) {
|
||||
const std::size_t count = static_cast<std::size_t>(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<std::int32_t> 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<float>(raw[i])
|
||||
: static_cast<float>(static_cast<std::uint32_t>(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<float> &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<double>(pixelCount);
|
||||
std::snprintf(buffer, sizeof(buffer), " c%u[min=%.6g max=%.6g mean=%.6g]", c,
|
||||
static_cast<double>(minimum[c]), static_cast<double>(maximum[c]), mean);
|
||||
text += buffer;
|
||||
}
|
||||
std::snprintf(buffer, sizeof(buffer), " nonfinite=%zu hash=%016llx", nonFinite,
|
||||
static_cast<unsigned long long>(hash));
|
||||
text += buffer;
|
||||
return text;
|
||||
}
|
||||
|
||||
bool WriteFloatPng(const std::string &path, const AttachmentDesc &desc, unsigned channels,
|
||||
const std::vector<float> &pixels) {
|
||||
image::Image snapshot(static_cast<unsigned>(desc.width), static_cast<unsigned>(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<float> 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<unsigned>(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.<call>.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<GLenum>(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<GLenum>(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<unsigned> 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<GLuint>(savedReadFramebuffer));
|
||||
if (savedPackBuffer != 0) {
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, static_cast<GLuint>(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
|
||||
@@ -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
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "glws.hpp"
|
||||
#include "retrace.hpp"
|
||||
|
||||
#include "apitrace_fbo_dump.hpp"
|
||||
|
||||
#include <EGL/egl.h>
|
||||
#include <EGL/eglext.h>
|
||||
#include <algorithm>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -184,6 +184,8 @@ def run_case(case, backend):
|
||||
command.append("--use-pbuffer")
|
||||
if backend_info["use_angle"] and case["name"] == BLISS_CASE:
|
||||
command.append("--avoid-angle-llvmpipe-sampler-mipmap-min-filter")
|
||||
if backend_info["use_angle"] and case.get("avoid_angle_llvmpipe_explicit_lod_bias"):
|
||||
command.append("--avoid-angle-llvmpipe-explicit-lod-bias")
|
||||
if case.get("coherent_as_flush"):
|
||||
command.append("--coherent-as-flush")
|
||||
env = dict(**__import__("os").environ)
|
||||
|
||||
@@ -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",
|
||||
@@ -188,7 +181,8 @@
|
||||
"name": "minecraft-1.21.4-fabric-iris-sundial-lite-in-world",
|
||||
"trace_archive": "minecraft-1.21.4-fabric-iris-sundial-lite-in-world.tgz",
|
||||
"golden": "minecraft-1.21.4-fabric-iris-sundial-lite-in-world.0000150023.png",
|
||||
"target_call": 150023
|
||||
"target_call": 150023,
|
||||
"avoid_angle_llvmpipe_explicit_lod_bias": true
|
||||
},
|
||||
{
|
||||
"name": "minecraft-1.21.4-fabric-iris-complementary-reimagined-in-world",
|
||||
|
||||
@@ -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<N>-att<M>.png / fbo<N>-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 {
|
||||
|
||||
Reference in New Issue
Block a user