Compare commits

...
22 Commits
Author SHA1 Message Date
swung0x48 90564aa82e [Test] (Review): give the geometry point-size case its own capability probe 2026-08-27 15:42:04 -04:00
swung0x48 7520607d47 [Fix] (Review): decline a point-size program the device cannot run, bound the compile-failure source dump, and correct the pass-through rationale 2026-08-27 15:42:04 -04:00
swung0x48 57635a9198 [Test] (Tessellation): unit and headless-GPU coverage for capturing a patch draw's per-vertex payload 2026-08-27 15:06:02 -04:00
swung0x48 c19d0f0b75 [Fix] (DirectVulkan): mirror gl_PointSize for capture and enable the feature its stages need 2026-08-27 15:06:02 -04:00
swung0x48 e42e7d00f5 [Fix] (DirectGLES): request the point-size extension a tessellation or geometry stage's ESSL needs 2026-08-27 15:06:01 -04:00
swung0x48 62695ee3c2 [Test] (Review): pin the single-sample sample mask, the completeness-flip sampled set, and the integer multisample placeholders 2026-08-27 13:17:56 -04:00
swung0x48 02cc0ce83c [Fix] (Review): scope the sample mask to a multisample target, version the sampled-set memo on completeness, clamp the mask word count, give the multisample placeholder every numeric domain, unwind the fixup revert one pass at a time, and bound the validator log 2026-08-27 13:03:51 -04:00
swung0x48 9e52a0b23e [Test] (DirectVulkan): pin the incomplete-default-texture draw, the unbound multisample sampler, and the unwritten redeclared gl_Position 2026-08-27 12:52:19 -04:00
swung0x48 9dee53337f [Fix] (DirectVulkan): give an unbound multisample sampler a placeholder, clear a multisample texture through a load-op pass, and plumb glSampleMaski into the pipeline 2026-08-27 12:52:19 -04:00
swung0x48 28c5badf8f [Fix] (SPIR-V): keep the entry-point interface consistent when the clip fixup and the XFB position mirror inject through gl_Position 2026-08-27 12:52:18 -04:00
swung0x48 01116f7b41 [Fix] (DirectVulkan): treat an incomplete sampled texture as unbound in the collect path too, and stop dereferencing a declined texture sync 2026-08-27 12:52:18 -04:00
swung0x48 05d627ba2d [Fix] (Xfb): never issue a transform feedback capture-point bind the application did not ask for, and error-check the driver span instead of losing it silently 2026-08-27 12:28:32 -04:00
swung0x48 ea5d52f126 [Test] (Tessellation): assert the patch-array cross-stage link outright now that the fork pin carries the guard 2026-08-27 10:52:00 -04:00
swung0x48 3c70b4fc0f [Fix] (Program): read an API colour index of zero as no override, matching the IO resolver and ProgramInterface 2026-08-27 10:52:00 -04:00
swung0x48 b6d6316333 [Fix] (Blend): decline a GL_SRC1_* factor on an incapable GLES driver even when blending is off 2026-08-27 10:51:59 -04:00
swung0x48 3c9ab5a68f [Test] (Blend): make the dual-source scenario's capability gate skip the case, not just the helper 2026-08-27 10:48:22 -04:00
swung0x48 532b5e9cc5 [Fix] (Program): let two fragment outputs share a colour number when their colour index differs 2026-08-27 10:48:22 -04:00
swung0x48 06605ed0ea [Test] (Tessellation): include the standard headers the link test uses directly 2026-08-27 10:48:21 -04:00
swung0x48 e1d5bdc4a5 [Fix] (Blend): decline an unsupported dual-source blend instead of throwing through the GL ABI 2026-08-27 10:48:21 -04:00
swung0x48 66867a41ba [Fix] (Program): count the tessellation control stage as a transform-feedback capture stage 2026-08-27 10:48:20 -04:00
swung0x48 ebff4b21f7 [Fix] (Query): tie the two tessellation pipeline-statistics targets to the GL_ARB_tessellation_shader advertisement 2026-08-27 10:48:20 -04:00
swung0x48 d4e7378868 [Chore] (ShaderTranspiler): pin glslang fork at d89cf443 (patch-array io-resize guard for the evaluation stage) 2026-08-27 10:22:37 -04:00
39 changed files with 5172 additions and 186 deletions
+317 -38
View File
@@ -393,6 +393,65 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// The capture points the CAPTURE PROGRAM uses, and nothing else.
//
// This used to go through SyncBufferBindingPoints, which walks the application's
// GLOBAL touched-binding-point high-water mark and binds 0 to every point with no
// frontend buffer. deqp/glcts permanently raises that mark to
// GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS by clearing all of them after each test
// case, so every capture using fewer points than that - i.e. every INTERLEAVED_ATTRIBS
// capture - had glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, i, 0) issued for the
// unused tail immediately before glBeginTransformFeedback. The Mali G1-Ultra driver
// then recorded NOTHING: no GL error, GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN 0, the
// application's buffer left holding its pre-draw bytes. Confirmed on device - the
// separate/interleaved split in KHR-GL46.transform_feedback follows exactly whether
// all four points were left bound.
//
// Those binds were never needed for correctness either. A capture only writes the
// points the program's buffer mode uses (GL 4.6 core 13.2.2), so a point past
// bufferCount cannot be written whatever is left bound there, and a point the program
// DOES use with no buffer bound is already an error the frontend raised at
// glBeginTransformFeedback. The rule this encodes: never issue a capture-point bind
// the application did not ask for.
//
// Scoping it to the program (rather than skipping redundant binds behind the shadow)
// is what makes it ORDER-INDEPENDENT: the shadow has to drop to unknown whenever a
// transform feedback OBJECT is bound, since the points belong to the object, and the
// clears came straight back for the next capture in the process.
void SyncTransformFeedbackBindingPoints(SizeT bufferCount) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
const SizeT pointCount = std::min<SizeT>(
bufferCount, MG_State::GLState::GLContext::MAX_TRANSFORM_FEEDBACK_BUFFERS);
for (SizeT i = 0; i < pointCount; ++i) {
auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, i);
const auto& obj = point.GetBoundObject();
// A stride-0 slot (two consecutive gl_NextBuffer entries) captures nothing and
// needs no binding; anything else with no buffer never got past the frontend.
if (!obj) continue;
auto* backendResource = EnsureBufferResource(obj);
if (!backendResource || backendResource->id == 0) {
MGLOG_E_ONCE("No backend buffer for GL_TRANSFORM_FEEDBACK_BUFFER capture point %zu; the capture "
"will not reach the application's buffer.",
i);
continue;
}
const auto& range = point.GetRange();
const auto backendBufferId = backendResource->id;
if (range.start == 0 && range.end >= obj->GetSize()) {
BindBufferBaseCached(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLuint>(i), backendBufferId);
} else {
const auto start = std::min(range.start, obj->GetSize());
const auto end = std::min(range.end, obj->GetSize());
BindBufferRangeCached(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLuint>(i), backendBufferId,
static_cast<GLintptr>(start), static_cast<GLsizeiptr>(end - start));
}
}
}
// Called once the storage-buffer points are bound and the draw/dispatch is about to
// go out: whatever the shader writes there lands in the ES driver's buffers, behind
// the frontend's CPU shadow. Flagging them makes the next MapBuffer/GetBufferSubData
@@ -654,6 +713,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint backendId = 0;
SizeT start = 0;
SizeT end = 0;
// WHICH capture buffer of the program this is. The list is COMPACTED - a
// capture buffer with no bound buffer object contributes no entry - so the
// position in the vector is not the program's buffer index, and everything
// that asks the program about a target (its stride, which varyings land in
// it) has to ask about this index instead. A capture list beginning with
// gl_NextBuffer is the shape that makes them differ: buffer 0 has stride 0
// and nothing bound, so target 0 describes buffer 1.
SizeT bufferIndex = 0;
};
// Per frontend transform feedback object. The default object (name 0) maps to
@@ -698,6 +765,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
return *g_currentXfbState;
}
// EVERY way this path can lose a capture used to be silent: three unlogged early
// returns before the driver Begin, an unchecked glBeginTransformFeedback, and two
// `continue`s in the readback. The application sees a buffer that kept its
// pre-draw bytes, GL_NO_ERROR, and GL_LINK_STATUS true - which is how one defect
// reached ~320 conformance bodies across four families before anyone could say
// which of the branches fired. Nothing below changes what MobileGL DOES on a
// healthy capture; it only makes a lost one name itself in /sdcard/MG/latest.log.
//
// MGLOG_E_ONCE (not _D) on purpose: these have to be readable in an INFO-level
// artifact, the same reason the backend link failure at Managers.cpp is MGLOG_E.
constexpr Int kMaxDrainedXfbErrors = 32;
// The ES error raised by the call just issued, GL_NO_ERROR if it succeeded. Drains
// the rest of the queue so the next probe cannot read this one as its own.
GLenum TakeXfbDriverError() {
const GLenum first = g_GLESFuncs.glGetError();
if (first == GL_NO_ERROR) return GL_NO_ERROR;
for (Int i = 0; i < kMaxDrainedXfbErrors && g_GLESFuncs.glGetError() != GL_NO_ERROR; ++i) {
}
return first;
}
Bool AreTransformFeedbackObjectsSupported() {
return g_GLESFuncs.glGenTransformFeedbacks != nullptr &&
g_GLESFuncs.glBindTransformFeedback != nullptr &&
@@ -712,6 +801,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the backend already owns (coherent persistent map) need nothing: reads resolve
// against that storage directly.
void ReadbackCapturedRanges(Vector<XfbCaptureTarget>& targets) {
if (g_GLESFuncs.glMapBufferRange == nullptr || g_GLESFuncs.glUnmapBuffer == nullptr) {
MGLOG_E_ONCE("EndTransformFeedback: the ES driver exposes no glMapBufferRange/glUnmapBuffer, so "
"captured data can never reach the application's buffers");
}
if (targets.empty()) {
// The span closed with nothing to mirror back. Either the deferred Begin
// never ran (a span with no draw - legal) or it ran and found no bound
// capture buffer, which is not.
MGLOG_D("EndTransformFeedback: capture span closed with no recorded targets");
}
if (g_GLESFuncs.glMapBufferRange != nullptr && g_GLESFuncs.glUnmapBuffer != nullptr) {
for (const auto& target : targets) {
if (!target.buffer || target.buffer->IsBackendPersistentMapped()) continue;
@@ -721,8 +820,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
static_cast<GLintptr>(target.start),
static_cast<GLsizeiptr>(size), GL_MAP_READ_BIT);
if (mapped == nullptr) {
MGLOG_E_ONCE("EndTransformFeedback: failed to map backend buffer %u for capture readback",
target.backendId);
// Silent before: the capture landed in the ES buffer and the
// application's next glMapBuffer read the untouched shadow, which
// is indistinguishable from "the draw wrote nothing".
MGLOG_E_ONCE("EndTransformFeedback: failed to map backend buffer %u [%zu, %zu) for "
"capture readback (ES error %s); the captured data will NOT be visible to "
"the application",
target.backendId, target.start, target.end,
MG_Util::ConvertGLEnumToString(TakeXfbDriverError()).c_str());
continue;
}
target.buffer->WritebackFromBackend({mapped, size}, target.start);
@@ -755,15 +860,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
GL_DYNAMIC_COPY);
g_scatterBufferSize = required;
}
// Point 0 carries every captured varying (the ES capture is INTERLEAVED); the
// other points must be cleared or the driver would still write the app's buffers.
// Point 0 carries every captured varying: the gl_NextBuffer / gl_SkipComponents
// entries are consumed at link time and never reach the driver, so the ES
// program is declared INTERLEAVED over a single buffer and point 0 is the only
// point it can write (GL 4.6 core 13.2.2).
//
// The other points are therefore left exactly as they are. Clearing them - which
// this used to do, across the application's whole touched high-water mark - is
// both unnecessary (the ES program cannot write an unused point) and the precise
// trigger for the Mali G1-Ultra capture loss: see
// SyncTransformFeedbackBindingPoints for the mechanism and the device evidence.
// KHR-GL46.transform_feedback.capture_special_interleaved_test is the case that
// reaches this path.
BufferImpl::BindBufferRangeCached(GL_TRANSFORM_FEEDBACK_BUFFER, 0, g_scatterBufferId, 0,
static_cast<GLsizeiptr>(required));
const SizeT pointCount =
MG_State::pGLContext->GetTouchedBufferBindingPointCount(BufferTarget::TransformFeedback);
for (SizeT i = 1; i < pointCount; ++i) {
BufferImpl::BindBufferBaseCached(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<Uint>(i), 0);
}
return true;
}
@@ -777,10 +887,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (g_GLESFuncs.glMapBufferRange == nullptr || g_GLESFuncs.glUnmapBuffer == nullptr) return;
const SizeT packedStride = program->GetTransformFeedbackPackedStride();
const SizeT vertices = std::min<SizeT>(
static_cast<SizeT>(MG_State::pGLContext->GetTransformFeedbackCapturedVertices()),
xfb.scatterCapacityVertices);
if (packedStride == 0 || vertices == 0) return;
const SizeT modelledVertices =
static_cast<SizeT>(MG_State::pGLContext->GetTransformFeedbackCapturedVertices());
const SizeT vertices = std::min<SizeT>(modelledVertices, xfb.scatterCapacityVertices);
if (packedStride == 0 || vertices == 0) {
// The scatter path redirected the DRIVER's capture into the scratch buffer,
// so bailing here leaves the application's buffers holding their pre-draw
// bytes - a total data loss, not a no-op. The vertex count is the CPU model
// (AccountTransformFeedbackPrimitives), which is 0 for any draw mode
// CountPrimitivesForDraw does not know and for the instanced/indirect entry
// points that never call it.
MGLOG_E_ONCE("EndTransformFeedback: scattered capture discarded - packedStride=%zu, "
"CPU-modelled captured vertices=%zu, scratch capacity=%zu. The capture buffers keep "
"their pre-draw contents.",
packedStride, modelledVertices, xfb.scatterCapacityVertices);
return;
}
BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, g_scatterBufferId);
const void* packed = g_GLESFuncs.glMapBufferRange(BufferImpl::TempBufferTarget, 0,
@@ -798,14 +920,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (SizeT targetIndex = 0; targetIndex < xfb.targets.size(); ++targetIndex) {
const auto& target = xfb.targets[targetIndex];
if (!target.buffer) continue;
const SizeT stride = program->GetTransformFeedbackStride(static_cast<Uint32>(targetIndex));
// By BUFFER index, not by position in the compacted list - see XfbCaptureTarget.
const SizeT stride = program->GetTransformFeedbackStride(static_cast<Uint32>(target.bufferIndex));
if (stride == 0) continue;
const SizeT rangeBytes = target.end - target.start;
Vector<Uint8> staged(rangeBytes);
Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes);
for (const auto& varying : program->GetTransformFeedbackVaryings()) {
if (varying.bufferIndex != targetIndex) continue;
if (varying.bufferIndex != target.bufferIndex) continue;
for (SizeT v = 0; v < vertices; ++v) {
const SizeT dstOffset = v * stride + varying.offsetBytes;
if (dstOffset + varying.byteSize > rangeBytes) break;
@@ -860,9 +983,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
// not captured, and opening the span would also subject it to the capture
// primitive-mode rule the paused draw is exempt from.
if (!xfb.pending || xfb.paused) return;
xfb.pending = false;
const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram();
if (!program) return;
if (!program) {
// The pending flag is deliberately NOT consumed here. It used to be cleared
// before this check, so a single draw that could not see the capture program
// retired the span permanently: every later draw of the same span found
// pending==false, the driver Begin never happened, and End found started==false
// and skipped the readback - a whole capture lost with no GL error anywhere.
// The frontend only reaches a draw with an active span after glBeginTransformFeedback
// stored a program, so this is a "cannot happen" that must stay recoverable.
MGLOG_E_ONCE("StartPendingTransformFeedback: an active capture span has no capture program; the "
"driver span stays closed and this draw is not captured");
return;
}
xfb.pending = false;
// Snapshot what the driver is about to capture into. GL forbids rebinding the
// capture buffers while the span is open, so this stays valid until End, and
@@ -879,10 +1013,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
const SizeT start = std::min(range.start, bufferObject->GetSize());
const SizeT end = std::min(range.end, bufferObject->GetSize());
if (end <= start) continue;
xfb.targets.push_back({bufferObject, backendResource->id, start, end});
xfb.targets.push_back({bufferObject, backendResource->id, start, end, i});
}
BufferImpl::SyncBufferBindingPoints(BufferTarget::TransformFeedback, GL_TRANSFORM_FEEDBACK_BUFFER);
BufferImpl::SyncTransformFeedbackBindingPoints(bufferCount);
// A layout with holes or several interleaved buffers is not expressible on ES:
// capture gap-free into scratch storage and place the records at End instead.
@@ -891,31 +1025,99 @@ namespace MobileGL::MG_Backend::DirectGLES {
xfb.scatterCapacityVertices = 0;
if (program->NeedsScatteredTransformFeedbackCapture()) {
SizeT capacityVertices = ~SizeT(0);
for (SizeT i = 0; i < xfb.targets.size(); ++i) {
const SizeT stride = program->GetTransformFeedbackStride(static_cast<Uint32>(i));
for (const auto& target : xfb.targets) {
// By BUFFER index. Reading the stride at the target's POSITION made a
// capture list beginning with gl_NextBuffer - buffer 0 has stride 0 and
// nothing bound, so target 0 describes buffer 1 - read stride 0, skip every
// target, and leave the capacity at zero.
const SizeT stride = program->GetTransformFeedbackStride(static_cast<Uint32>(target.bufferIndex));
if (stride == 0) continue;
capacityVertices =
std::min<SizeT>(capacityVertices, (xfb.targets[i].end - xfb.targets[i].start) / stride);
capacityVertices = std::min<SizeT>(capacityVertices, (target.end - target.start) / stride);
}
if (capacityVertices == ~SizeT(0)) capacityVertices = 0;
if (BindScatterCaptureBuffer(program->GetTransformFeedbackPackedStride(), capacityVertices)) {
xfb.scattered = true;
xfb.scatterProgram = program;
xfb.scatterCapacityVertices = capacityVertices;
} else {
// NO SPAN RATHER THAN A SPAN THAT WRITES SOMEWHERE ELSE. The ES program for a
// scattered capture is a single-buffer INTERLEAVED one (the gl_NextBuffer /
// gl_SkipComponents entries are consumed at link time and never reach the
// driver), so it writes capture point 0 and nothing else. Point 0 here is
// either unbound or - the dangerous case - still holds whatever an earlier
// capture in this process bound there, because the frontend's own
// glBindBufferBase is state-only and nothing else in the backend touches the
// indexed points. Opening the span would then have the driver capture over an
// application buffer that has nothing to do with this draw, and the frontend
// shadow would never learn of it.
//
// Leaving the span closed reproduces exactly what the old high-water clear
// loop achieved by binding 0 here and letting the driver refuse the Begin -
// the capture records nothing - without issuing a capture-point bind the
// application did not ask for, which is the thing that loses captures whole
// on Mali (see SyncTransformFeedbackBindingPoints).
MGLOG_E_ONCE("StartPendingTransformFeedback: no scratch storage for a scattered capture "
"(packed stride %zu, capacity %zu vertices); leaving the driver span CLOSED so the "
"capture cannot land in a stale binding. Nothing will be captured.",
program->GetTransformFeedbackPackedStride(), capacityVertices);
xfb.targets.clear();
return;
}
}
// A capture program with buffers bound must have produced at least one target;
// an empty list means End has nothing to mirror back and the application will
// read its buffer's pre-draw bytes however well the GPU captured.
if (xfb.targets.empty()) {
MGLOG_E_ONCE("StartPendingTransformFeedback: opening a capture span with NO capture targets "
"(program declares %zu capture buffer(s), none of them resolved to a bound backend "
"buffer with a non-empty range); nothing will be read back",
bufferCount);
}
g_GLESFuncs.glBeginTransformFeedback(xfb.primitiveMode);
// Unchecked before. Every ES error condition here (already active, a current
// program with no capture set, a capture point the program uses with no buffer)
// ends the same way: the driver records nothing, GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
// reads 0 and the application sees no error at all - MobileGL's own error state is
// separate from the driver's, so a driver rejection here is invisible to it.
if (const GLenum beginError = TakeXfbDriverError(); beginError != GL_NO_ERROR) {
// The mode is printed as a number as well as a name: GL_POINTS is 0, which the
// enum converter spells "GL_FALSE", and a reader chasing a lost capture should
// not have to know that.
MGLOG_E_ONCE("StartPendingTransformFeedback: the ES driver REJECTED "
"glBeginTransformFeedback(%s / 0x%04x) with %s - nothing will be captured. Backend "
"program %u, %zu capture buffer(s), %zu target(s), mode=%s.",
MG_Util::ConvertGLEnumToString(xfb.primitiveMode).c_str(),
static_cast<unsigned>(xfb.primitiveMode),
MG_Util::ConvertGLEnumToString(beginError).c_str(),
PrgramImpl::g_lastUsedBackendProgramId, bufferCount,
xfb.targets.size(),
MG_Util::ConvertGLEnumToString(program->GetTransformFeedbackBufferMode()).c_str());
}
xfb.started = true;
}
void EndTransformFeedback() {
auto& xfb = CurrentXfb();
const Bool wasPending = xfb.pending;
xfb.pending = false;
xfb.paused = false;
if (!xfb.started) return;
if (!xfb.started) {
// A span that never drew is legal and captures nothing by definition; one that
// is STILL pending here drew nothing the backend saw, which for a span the
// application expected data from is the whole bug in one line.
MGLOG_D("EndTransformFeedback: closing a span the driver never opened (pending=%d)",
wasPending ? 1 : 0);
return;
}
xfb.started = false;
g_GLESFuncs.glEndTransformFeedback();
if (const GLenum endError = TakeXfbDriverError(); endError != GL_NO_ERROR) {
MGLOG_E_ONCE("EndTransformFeedback: the ES driver rejected glEndTransformFeedback with %s - the "
"driver's capture state and MobileGL's have diverged",
MG_Util::ConvertGLEnumToString(endError).c_str());
}
if (xfb.scattered) {
ScatterCapturedRecords(xfb);
xfb.scattered = false;
@@ -943,6 +1145,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BindTransformFeedback(GLuint name) {
g_currentXfbState = nullptr; // name changes; operator[] below may also rehash
// The capture buffer bindings are the OBJECT's, not the context's: the bind below
// swaps all of them for whatever the target object holds, which the redundant-bind
// shadow has never seen.
BufferImpl::InvalidateTransformFeedbackBindingShadows();
if (!AreTransformFeedbackObjectsSupported()) {
// Without driver objects there is only the default span; keep the frontend
// name so the bookkeeping below stays consistent.
@@ -979,6 +1185,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_currentXfbName = 0;
g_scatterBufferId = 0;
g_scatterBufferSize = 0;
BufferImpl::InvalidateTransformFeedbackBindingShadows();
}
} // namespace XfbImpl
@@ -1772,6 +1979,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
// clear-then-draw pair on an unchanged parameter block early-outs and the draw inherits
// the clear's undoctored mask.
static Uint32 g_syncedColorMaskAlphaWidenMask = 0;
// Scratch for the dual-source-blend decline path in the blend block below. File-scope
// rather than a local so the ordinary draw pays nothing for it: it is written only on a
// driver with no GL_EXT_blend_func_extended that is also handed a GL_SRC1_* factor, and
// SyncRenderState runs on the GL thread only.
static Array<PerBufferBlendState, MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS>
g_dualSourceDeclinedBlendStates;
void InvalidateSyncedRenderState() {
g_forceFullRenderStateResync = true;
g_hasSyncedRenderState = false;
@@ -1931,31 +2144,85 @@ namespace MobileGL::MG_Backend::DirectGLES {
const auto& ToGLBoolean = [](Bool b) -> GLboolean { return b ? GL_TRUE : GL_FALSE; };
// Which draw buffers the blend block below DECLINED (see it for why). Needed again at
// the shadow write-back at the end of this function: the span memcpy there clones the
// FRONTEND block, which for a declined draw buffer is not what the driver was handed.
Uint32 dualSourceDeclinedMask = 0;
if (blendSpanDirty) { // Blend State
using FBO = MG_State::GLState::FramebufferObject;
const auto& targetStates = parameters.BlendStates;
auto& syncedStates = g_syncedRenderStateParameters.BlendStates;
// Dual-source blending (GL_SRC1_* factors from glBlendFunc paired with
// glBindFragDataLocationIndexed) needs GL_EXT_blend_func_extended; GLES core has none.
// Detected at load and surfaced in the POST. There is no fallback, so if a draw actually
// enables blending with a SRC1 factor on a driver that lacks it, hard-fail here at use
// time rather than let the driver reject glBlendFuncSeparate and silently mis-blend.
// Detected at load and surfaced in the POST. There is no fallback that BLENDS
// correctly, so a draw that asks for a SRC1 factor on a driver without the extension
// gets the blend DECLINED: that draw buffer is pushed with blending off and neutral
// One/Zero factors, and the loss is logged once. The two rejected alternatives are
// both worse - pushing GL_SRC1_* at glBlendFuncSeparate leaves the driver to raise
// GL_INVALID_ENUM and keep whatever factors were there before (a silent mis-blend
// against stale state), and throwing, which is what this did until now, takes the
// whole process down over one unsupported blend factor. Declining is defined,
// survivable and visible in the log.
//
// NOT gated on Enabled, deliberately, and the same way the Vulkan twin is not gated
// on effectiveBlendEnabled: what has to be kept away from the driver is the FACTOR
// ENUM, and the factor push below never consults Enabled - one glBlendFuncSeparate
// serves every draw buffer when they agree, and the per-index arm diffs factors
// alone. So `glDisable(GL_BLEND); glBlendFunc(GL_SRC1_ALPHA, ...)` followed by any
// draw OR clear would otherwise hand a GL_SRC1_ALPHA to a driver that answers
// GL_INVALID_ENUM, leaving a spurious error in ITS queue for the next internal
// no-error probe to read as its own, and leaving this shadow recording factors the
// ES context rejected. Blending being off makes the picture unaffected; it does not
// make the enum acceptable.
const auto* effectiveBlendStates = &parameters.BlendStates;
if (!g_GLESCapabilities.SupportsDualSourceBlend) {
Uint32 declinedWithBlendingOnMask = 0;
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
const auto& s = targetStates[i];
if (s.Enabled &&
(IsDualSourceBlendFactor(s.SrcFactorRGB) || IsDualSourceBlendFactor(s.DstFactorRGB) ||
IsDualSourceBlendFactor(s.SrcFactorAlpha) || IsDualSourceBlendFactor(s.DstFactorAlpha))) {
THROW_EXCEPTION(
"Dual-source blending (GL_SRC1_* blend factor) was used on draw buffer " +
std::to_string(i) +
", but the GLES driver does not expose GL_EXT_blend_func_extended (see the "
"dual-source blend row in the driver POST). No fallback exists; the draw "
"cannot proceed.");
const auto& s = parameters.BlendStates[i];
if (IsDualSourceBlendFactor(s.SrcFactorRGB) || IsDualSourceBlendFactor(s.DstFactorRGB) ||
IsDualSourceBlendFactor(s.SrcFactorAlpha) || IsDualSourceBlendFactor(s.DstFactorAlpha)) {
dualSourceDeclinedMask |= 1u << i;
if (s.Enabled) declinedWithBlendingOnMask |= 1u << i;
}
}
if (dualSourceDeclinedMask != 0) {
// Two masks in the message because they mean different things to whoever
// reads the log: the second one is where a PICTURE was lost. A draw buffer
// in the first mask but not the second had blending off anyway, so nothing
// was blended and nothing was dropped - only the unusable enum was kept out
// of the driver.
MGLOG_E_ONCE(
"SyncRenderState: a GL_SRC1_* (dual-source) blend factor was set on draw buffer "
"mask 0x%x, but the GLES driver does not expose GL_EXT_blend_func_extended (see "
"the dual-source blend row in the driver POST). Those draw buffers are pushed "
"with neutral One/Zero factors instead. Blending was actually ENABLED on mask "
"0x%x, and only there is anything lost: the fragment's first output is written "
"unblended and the second source is dropped.",
dualSourceDeclinedMask, declinedWithBlendingOnMask);
g_dualSourceDeclinedBlendStates = parameters.BlendStates;
for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) {
if ((dualSourceDeclinedMask & (1u << i)) == 0) continue;
auto& s = g_dualSourceDeclinedBlendStates[i];
// Both halves, for the same reason the Vulkan arm neutralises both: the
// enable so nothing blends against a source the driver cannot produce,
// the factors so no GL_SRC1_* enum is ever handed over. Clearing Enabled
// on a buffer that was already off is a no-op, which is what makes one
// ungated rule serve both cases.
s.Enabled = false;
s.SrcFactorRGB = BlendFactor::One;
s.DstFactorRGB = BlendFactor::Zero;
s.SrcFactorAlpha = BlendFactor::One;
s.DstFactorAlpha = BlendFactor::Zero;
}
effectiveBlendStates = &g_dualSourceDeclinedBlendStates;
}
}
// The rest of the block reads the EFFECTIVE state. The per-field writes it makes
// into `syncedStates` are provisional - the span memcpy at the end of this function
// overwrites the whole blend span with the frontend's own bytes - so the declined
// draw buffers are put back there, see the write-back below.
const auto& targetStates = *effectiveBlendStates;
Bool allEnabled = true;
Bool allDisabled = true;
@@ -2355,6 +2622,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (blendSpanDirty) {
std::memcpy(syncedBytesMut + kBlendSpanBegin, currentBytes + kBlendSpanBegin,
kBlendSpanEnd - kBlendSpanBegin);
// ...except for a draw buffer whose dual-source blend was DECLINED, where the
// frontend block is precisely what did NOT reach the driver. The shadow has to hold
// what was pushed or the next diff compares against state the ES context never got:
// going from a SRC1 factor to an ordinary one leaves Enabled equal on both sides,
// the enable block finds nothing to do, and blending stays off from the decline.
// The span stays permanently "dirty" against the frontend as a result, which costs
// one memcmp plus this block per render-state VERSION change - the top-of-function
// version early-out still skips repeat draws entirely.
for (Uint i = 0; i < MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS; ++i) {
if ((dualSourceDeclinedMask & (1u << i)) == 0) continue;
g_syncedRenderStateParameters.BlendStates[i] = g_dualSourceDeclinedBlendStates[i];
}
}
if (tailSpanDirty) {
std::memcpy(syncedBytesMut + kBlendSpanEnd, currentBytes + kBlendSpanEnd,
+176 -7
View File
@@ -1383,10 +1383,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
constexpr SizeT kMaxIndexedBufferBindings = 64;
IndexedBufferBinding g_indexedUBOBindings[kMaxIndexedBufferBindings];
IndexedBufferBinding g_indexedSSBOBindings[kMaxIndexedBufferBindings];
// Transform feedback gets a shadow for a reason the other two do not have: the
// capture points are synced from the application's TOUCHED high-water mark, which
// deqp/glcts permanently raises to GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS by
// clearing every point after each test case. Without a shadow every capture that
// uses fewer points than that (i.e. every INTERLEAVED_ATTRIBS capture) re-issued a
// redundant glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, i, 0) for the unused
// tail immediately before glBeginTransformFeedback - calls a plain GL application
// never makes there, and the only thing MobileGL does differently from one.
//
// Unlike the UBO/SSBO points these are NOT context state: they belong to the bound
// transform feedback OBJECT, so XfbImpl::BindTransformFeedback drops the whole
// shadow to unknown on every object switch (InvalidateTransformFeedbackBindingShadows).
IndexedBufferBinding g_indexedXFBBindings[kMaxIndexedBufferBindings];
IndexedBufferBinding* IndexedBindingShadow(GLenum glTarget, Uint index) {
if (index >= kMaxIndexedBufferBindings) return nullptr; // out of range: never cache
if (glTarget == GL_UNIFORM_BUFFER) return &g_indexedUBOBindings[index];
if (glTarget == GL_SHADER_STORAGE_BUFFER) return &g_indexedSSBOBindings[index];
if (glTarget == GL_TRANSFORM_FEEDBACK_BUFFER) return &g_indexedXFBBindings[index];
return nullptr;
}
@@ -1403,6 +1417,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (auto& binding : g_indexedSSBOBindings) {
if (binding.id == id) binding = {};
}
for (auto& binding : g_indexedXFBBindings) {
if (binding.id == id) binding = {};
}
if (g_boundPixelPackBufferKnown && g_boundPixelPackBufferId == id) {
g_boundPixelPackBufferId = 0;
}
@@ -1419,9 +1436,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (auto& binding : g_indexedSSBOBindings) {
if (binding.id == id) binding.known = false;
}
for (auto& binding : g_indexedXFBBindings) {
if (binding.id == id) binding.known = false;
}
}
} // namespace
// The capture points belong to the bound transform feedback object, so a bind (or a
// delete, which reverts to the default object) replaces all of them at once with
// state this shadow has never seen. Distrust rather than scrub: the driver's bindings
// are whatever the newly bound object holds, which is NOT necessarily base(0), and
// scrubbing would let a later bind of 0 be false-skipped.
void InvalidateTransformFeedbackBindingShadows() {
for (auto& binding : g_indexedXFBBindings) binding.known = false;
}
void BindBufferBaseCached(GLenum glTarget, Uint index, Uint id) {
auto* s = IndexedBindingShadow(glTarget, index);
if (s && s->known && s->isBase && s->id == id) return;
@@ -1439,6 +1468,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void InvalidateIndexedBufferBindingCache() {
for (auto& b : g_indexedUBOBindings) b = {};
for (auto& b : g_indexedSSBOBindings) b = {};
for (auto& b : g_indexedXFBBindings) b = {};
}
void TrimBufferPool() {
@@ -5753,6 +5783,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint g_fragColorBroadcastCount = 1;
Uint32 g_unormFallbackClampOutputMask = 0;
Uint g_lastUsedBackendProgramId = 0;
// Every error-queue drain in the program build path is bounded by this: a lost
// context never answers GL_NO_ERROR, and the build runs on the thread that would
// then spin forever.
constexpr Int kMaxDrainedProgramErrors = 32;
StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl> g_backendProgramObjects;
BackendProgramObjectImpl::BackendProgramObjectImpl() {
@@ -6812,10 +6846,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
const String outMembers =
ExtractPerVertexBlockMembers(tessEvalStageEssl, /*input=*/true).value_or(String());
const String source = BuildPassthroughTessControlEssl(ResolveBackendEsslVersion(), patchVertices,
inMembers, outMembers,
m_passthroughTessControlOuterLevel,
m_passthroughTessControlInnerLevel);
String source = BuildPassthroughTessControlEssl(ResolveBackendEsslVersion(), patchVertices,
inMembers, outMembers,
m_passthroughTessControlOuterLevel,
m_passthroughTessControlInnerLevel);
// The mirrored member lists can carry gl_PointSize - the neighbour stage declared it,
// so matching it is the whole point - and a redeclaration is exactly as illegal as a
// reference in ESSL without the extension. Same directive, same never-speculative
// rule as the per-stage loop; a driver with neither spelling gets nothing added and
// fails below with its own message, which is the honest outcome for a shape it
// cannot express.
const char* passthroughPointSizeExtension =
source.find("gl_PointSize") != String::npos
? PointSizeExtensionName(g_GLESCapabilities.TessellationPointSizeSupport, /*tessellation=*/true)
: nullptr;
source = RequestPointSizeExtension(Move(source), passthroughPointSizeExtension);
const GLuint backendShaderId = g_GLESFuncs.glCreateShader(GL_TESS_CONTROL_SHADER);
if (backendShaderId == 0) {
@@ -7339,6 +7384,40 @@ namespace MobileGL::MG_Backend::DirectGLES {
source.find("gl_ViewportIndex") != String::npos;
source = RequestViewportArrayExtension(std::move(source), needsViewportArrayExtension);
// The fourth header-level rewrite, and the same shape as the third: ESSL has no
// gl_PointSize in a tessellation or geometry stage at ANY version - 320 makes the
// stages core and still leaves the built-in behind EXT/OES_..._point_size - while
// SPIRV-Cross prints it bare. Without the directive the stage fails to compile
// with "`gl_PointSize' undeclared", which takes the whole program to program 0:
// the draw renders nothing AND glBeginTransformFeedback is rejected, so a capture
// of anything at all off that program silently comes back empty. The token probe
// keeps the line off every other program and PointSizeExtensionName returns
// nullptr - i.e. nothing is emitted - on a driver advertising neither spelling.
if (source.find("gl_PointSize") != String::npos) {
const Bool tessellationStage = glShaderType == GL_TESS_CONTROL_SHADER ||
glShaderType == GL_TESS_EVALUATION_SHADER;
if (tessellationStage || glShaderType == GL_GEOMETRY_SHADER) {
const auto tier = tessellationStage ? g_GLESCapabilities.TessellationPointSizeSupport
: g_GLESCapabilities.GeometryPointSizeSupport;
const char* pointSizeExtension = PointSizeExtensionName(tier, tessellationStage);
if (pointSizeExtension == nullptr) {
// Latched, and an ERROR rather than a warning: what follows is a
// driver compile failure whose text names a built-in the application
// never mis-spelled, and the reason is a missing driver capability
// rather than anything in the shader. Saying so here is the whole
// difference between a legible skip and an unexplained black draw.
MGLOG_E_ONCE("This driver advertises neither the EXT nor the OES %s_point_size "
"extension, so its ESSL has no gl_PointSize in a %s stage; program %u "
"will fail to compile. Point size from a non-vertex stage is not "
"available on this device.",
tessellationStage ? "tessellation" : "geometry",
tessellationStage ? "tessellation" : "geometry",
stateProgramObject->GetExternalIndex());
}
source = RequestPointSizeExtension(std::move(source), pointSizeExtension);
}
}
source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject);
// The completion half of the format bake, for the formats SPIRV-Cross throws on
// rather than prints (r8ui and the rest of its desktop-only set). Empty for every
@@ -7460,12 +7539,44 @@ namespace MobileGL::MG_Backend::DirectGLES {
// 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, and one line per refused shader is bounded by program count.
// never noise.
//
// A BOUNDED EXCERPT of the source goes with it. The driver log names a line
// and a column in text that exists nowhere but here, so without any source at
// all the only way to read "`gl_PointSize' undeclared" is to rebuild the whole
// library at DEBUG - but the full dump cannot go at E either. This is not
// "one line per refused shader": SyncToBackend's rebuild gate keys on
// per-draw state (the enabled-draw-buffer count among it), so a program used
// across passes with different draw-buffer counts re-transpiles, re-compiles
// and re-fails on every alternation, i.e. per frame. At E - live at the
// production INFO level - each of those records would push the whole
// post-SPIRV-Cross ESSL through the global log mutex with a forced flush onto
// /sdcard/MG/latest.log, the file users are asked to share. The excerpt keeps
// the record O(1); the full text is still there at D, printed against this
// same backend shader id by the "Setting shader source" line above, so
// nothing needs to be dumped twice.
constexpr SizeT kMaxLoggedSourceBytes = 2048;
String truncatedSource;
const char* sourceForLog = source.c_str();
if (source.size() > kMaxLoggedSourceBytes) {
// Back up to a line boundary when there is one inside the window, so the
// excerpt ends on a whole statement rather than mid-token. Built only on
// this branch: a stage that fits keeps its own buffer and is not copied.
SizeT cut = kMaxLoggedSourceBytes;
if (const SizeT lastNewline = source.rfind('\n', cut);
lastNewline != String::npos && lastNewline > 0) {
cut = lastNewline + 1;
}
truncatedSource = source.substr(0, cut);
truncatedSource += "... [" + std::to_string(source.size() - cut) +
" more bytes; the whole stage is printed at the DEBUG level]\n";
sourceForLog = truncatedSource.c_str();
}
MGLOG_E("Shader compilation failed. State program ID: %u, stage: %s, backend shader ID: "
"%u, driver log: %s",
"%u, driver log: %s\nSource:\n%s",
stateProgramObject->GetExternalIndex(),
MG_Util::ConvertGLEnumToString(glShaderType).c_str(), backendShaderId,
log.data());
log.data(), sourceForLog);
m_backendProgramUsable = false;
// Nothing will ever attach this one, so nothing else can free it.
g_GLESFuncs.glDeleteShader(backendShaderId);
@@ -7523,6 +7634,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// program before it links. SPIRV-Cross keeps user output names verbatim in
// the transpiled ESSL (`out vec4 result_0;` stays `result_0`), so the
// frontend's requested names carry over unchanged.
SizeT declaredXfbVaryingCount = 0;
if (stateProgramObject->GetTransformFeedbackVaryingCount() > 0 &&
g_GLESFuncs.glTransformFeedbackVaryings != nullptr) {
const auto& xfbVaryings = stateProgramObject->GetTransformFeedbackVaryings();
@@ -7548,9 +7660,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
MGLOG_D("Declaring %zu transform feedback varyings on program %u", xfbNames.size(),
m_backendProgramId);
// Bounded: a lost context never answers GL_NO_ERROR, and this runs on the
// thread that would then spin forever.
for (Int i = 0; i < kMaxDrainedProgramErrors && g_GLESFuncs.glGetError() != GL_NO_ERROR; ++i) {
}
g_GLESFuncs.glTransformFeedbackVaryings(m_backendProgramId, static_cast<GLsizei>(xfbNames.size()),
xfbNames.data(),
stateProgramObject->GetTransformFeedbackBufferMode());
// Unchecked before. A rejected capture set leaves the program linking happily
// with NO capture set at all, and then every draw of every span records
// nothing while the application reads its buffer's pre-draw bytes and
// GL_NO_ERROR - the signature four conformance families were stuck on.
if (const GLenum xfbError = g_GLESFuncs.glGetError(); xfbError != GL_NO_ERROR) {
String declared;
for (const auto& xfbName : rewrittenXfbNames) {
if (!declared.empty()) declared += ", ";
declared += xfbName;
}
MGLOG_E("The ES driver REJECTED the transform feedback capture set for backend program %u with "
"%s (mode %s): [%s]. Every capture made with GL program %u will record nothing.",
m_backendProgramId, MG_Util::ConvertGLEnumToString(xfbError).c_str(),
MG_Util::ConvertGLEnumToString(
stateProgramObject->GetTransformFeedbackBufferMode()).c_str(),
declared.c_str(), stateProgramObject->GetExternalIndex());
}
declaredXfbVaryingCount = xfbNames.size();
}
// Link program
@@ -7593,6 +7727,41 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
} else {
MGLOG_D("Program linked successfully. ID: %u", m_backendProgramId);
// A link that SUCCEEDS can still have dropped the capture set: ESSL rejects a
// requested name the transpiled shader does not actually declare by simply not
// capturing it, and a program whose last vertex-processing stage was rewritten
// by a SPIR-V pass (viewport-index lowering, gl_PerVertex handling, the
// synthesized pass-through tessellation control stage) can end up spelling its
// outputs differently from the frontend's request. Asking the driver what it
// ACTUALLY linked is the only way to tell that apart from a driver that just
// captures nothing - which is the whole ambiguity the empty-capture failures
// across geometry_shader / tessellation_shader / gpu_shader5 / DSA sat on.
if (declaredXfbVaryingCount > 0) {
GLint linkedXfbVaryings = 0;
GLint linkedXfbBufferMode = 0;
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_TRANSFORM_FEEDBACK_VARYINGS,
&linkedXfbVaryings);
g_GLESFuncs.glGetProgramiv(m_backendProgramId, GL_TRANSFORM_FEEDBACK_BUFFER_MODE,
&linkedXfbBufferMode);
for (Int i = 0; i < kMaxDrainedProgramErrors && g_GLESFuncs.glGetError() != GL_NO_ERROR; ++i) {
}
const GLenum requestedMode = stateProgramObject->GetTransformFeedbackBufferMode();
if (static_cast<SizeT>(std::max(linkedXfbVaryings, 0)) != declaredXfbVaryingCount ||
static_cast<GLenum>(linkedXfbBufferMode) != requestedMode) {
MGLOG_E("Backend program %u (GL program %u) linked with a capture set the driver does not "
"agree with: asked for %zu varying(s) in mode %s, the driver reports %d varying(s) "
"in mode %s. Captures made with it will be empty or wrongly laid out.",
m_backendProgramId, stateProgramObject->GetExternalIndex(), declaredXfbVaryingCount,
MG_Util::ConvertGLEnumToString(requestedMode).c_str(), linkedXfbVaryings,
MG_Util::ConvertGLEnumToString(
static_cast<GLenum>(linkedXfbBufferMode)).c_str());
} else {
MGLOG_D("Backend program %u capture set confirmed by the driver: %d varying(s), mode %s",
m_backendProgramId, linkedXfbVaryings,
MG_Util::ConvertGLEnumToString(
static_cast<GLenum>(linkedXfbBufferMode)).c_str());
}
}
}
// The driver program was relinked IN PLACE, so its GL name no longer identifies
// the executable behind it - and that name is exactly what Use()'s
+12 -3
View File
@@ -544,12 +544,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
// BackendVertexArrayObject::SyncToBackend.
extern Uint64 g_bufferBackendIdGeneration;
// Redundant-bind cache for INDEXED buffer bindings (glBindBufferBase/Range on
// GL_UNIFORM_BUFFER / GL_SHADER_STORAGE_BUFFER): skips the GL call when the
// (id, range) already at that index matches, like the array-buffer/texture/
// sampler caches already do. Invalidated on MakeCurrent (context may reset).
// GL_UNIFORM_BUFFER / GL_SHADER_STORAGE_BUFFER / GL_TRANSFORM_FEEDBACK_BUFFER):
// skips the GL call when the (id, range) already at that index matches, like the
// array-buffer/texture/sampler caches already do. Invalidated on MakeCurrent
// (context may reset).
// Binds the transform feedback capture points [0, bufferCount) from the frontend
// state, and touches nothing else - in particular it never binds a zero the
// application did not ask for. See the definition for why that matters on Mali.
void SyncTransformFeedbackBindingPoints(SizeT bufferCount);
void BindBufferBaseCached(GLenum glTarget, Uint index, Uint id);
void BindBufferRangeCached(GLenum glTarget, Uint index, Uint id, GLintptr offset, GLsizeiptr size);
void InvalidateIndexedBufferBindingCache();
// The transform feedback capture points are per-transform-feedback-OBJECT state, so
// every glBindTransformFeedback swaps all of them under the shadow above. XfbImpl
// calls this on each bind/delete.
void InvalidateTransformFeedbackBindingShadows();
// Re-issues the GL_ATOMIC_COUNTER_BUFFER binding points a program's shaders declare as
// GL_SHADER_STORAGE_BUFFER bindings at the reserved slots the transpiled ESSL was built
// against (BackendProgramObjectImpl::GetAtomicCounterBindings /
+41
View File
@@ -713,6 +713,47 @@ namespace MobileGL::MG_Backend::DirectGLES {
return glslCode;
}
const char* PointSizeExtensionName(MG_External::GLESCapabilities::PointSizeTier tier, Bool tessellation) {
using Tier = MG_External::GLESCapabilities::PointSizeTier;
switch (tier) {
case Tier::ExtensionEXT:
return tessellation ? "GL_EXT_tessellation_point_size" : "GL_EXT_geometry_point_size";
case Tier::ExtensionOES:
return tessellation ? "GL_OES_tessellation_point_size" : "GL_OES_geometry_point_size";
default:
return nullptr;
}
}
String RequestPointSizeExtension(String glslCode, const char* extensionName) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// The gl_ViewportIndex story, one built-in over: ESSL 320 makes the tessellation and
// geometry STAGES core but leaves gl_PointSize out of their gl_PerVertex entirely,
// and SPIRV-Cross - which only ever sees a SPIR-V BuiltIn PointSize decoration -
// prints the identifier with no directive behind it. Same hard rule as the two
// neighbours: never emitted speculatively, because `#extension` on a name the driver
// does not advertise is a compile error of its own.
if (extensionName == nullptr || glslCode.find(extensionName) != String::npos) {
return glslCode;
}
const String directive = String("#extension ") + extensionName + " : require\n";
// Right after the #version line, the one position that must stay first;
// ForceSupporterOutput's scan for the LAST #extension directive still finds
// whichever one that ends up being.
const SizeT versionPos = glslCode.find("#version");
if (versionPos == String::npos) {
return directive + glslCode;
}
const SizeT lineEnd = glslCode.find('\n', versionPos);
if (lineEnd == String::npos) {
return glslCode + "\n" + directive;
}
glslCode.insert(lineEnd + 1, directive);
return glslCode;
}
String BakeImageFormatQualifiers(String glslCode,
const UnorderedMap<String, String>& esslFormatByUniformName) {
#ifdef TRACY_ENABLE
+16
View File
@@ -273,6 +273,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// error, so this is never emitted speculatively. A no-op when not needed or already
// present.
String RequestViewportArrayExtension(String glslCode, Bool needed);
// Adds `#extension <extensionName> : require` when a TESSELLATION or GEOMETRY stage's
// emitted ESSL names gl_PointSize. Desktop GL has that built-in in gl_PerVertex for every
// vertex-processing stage; ESSL does NOT have it in those two at any version - not even
// 320, where the stages themselves are core - until EXT/OES_tessellation_point_size resp.
// EXT/OES_geometry_point_size is requested. SPIRV-Cross prints the identifier bare and
// asks for nothing, exactly as it does for gl_ViewportIndex, so without this the stage
// fails to compile with "`gl_PointSize' undeclared" and the WHOLE program is replaced by
// program 0 - the draw renders nothing and any transform-feedback capture it was carrying
// is rejected outright. `extensionName` is the caller's answer, nullptr when the driver
// advertises neither spelling, because requesting an unadvertised extension is itself a
// compile error. A no-op when nullptr or already present.
String RequestPointSizeExtension(String glslCode, const char* extensionName);
// The extension name RequestPointSizeExtension should be given for `tier`, or nullptr for
// PointSizeTier::None. `tessellation` picks the tessellation spellings over the geometry
// ones; the two extensions are separate and neither implies the other.
const char* PointSizeExtensionName(MG_External::GLESCapabilities::PointSizeTier tier, Bool tessellation);
// Writes a format layout qualifier into the image declarations named in
// `esslFormatByUniformName` that still have none. The completion half of the image-format
// bake, and ONLY that: the SPIR-V pass (BakeImageFormatsPass) is what normally puts the
@@ -203,6 +203,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.rasterizationSamples, sizeof(payload.rasterizationSamples)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.sampleShadingEnable, sizeof(payload.sampleShadingEnable)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.minSampleShading, sizeof(payload.minSampleShading)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.sampleMask, sizeof(payload.sampleMask)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.subpass, sizeof(payload.subpass)));
XXHASH_VERIFY(XXH64_update(m_hashState, &payload.topology, sizeof(payload.topology)));
XXHASH_VERIFY(
@@ -443,6 +444,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Ignored by Vulkan unless sampleShadingEnable is set, but written unconditionally so the
// struct's bytes match the hash the payload was keyed by.
ms.minSampleShading = payload.minSampleShading;
// GL_SAMPLE_MASK / glSampleMaski. Left at nullptr - which Vulkan reads as all-ones - until
// now, so glSampleMaski was a silent no-op on this backend while DirectGLES forwarded it.
// The pointer has to outlive the vkCreateGraphicsPipelines call, which the payload does.
ms.pSampleMask = payload.sampleMask;
VkPipelineDepthStencilStateCreateInfo depthStencil{VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO};
depthStencil.depthTestEnable = payload.depthTestEnable ? VK_TRUE : VK_FALSE;
@@ -44,6 +44,21 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// (VUID-VkPipelineMultisampleStateCreateInfo-sampleShadingEnable-00784).
Bool sampleShadingEnable = false;
Float minSampleShading = 0.0f;
// glEnable(GL_SAMPLE_MASK) + glSampleMaski, the fixed-function coverage mask, already
// reduced to what GL says this draw gets (VulkanRenderer::ResolveEffectiveSampleMask:
// all-ones unless the target is genuinely multisampled). Pipeline state like the two
// above - Vulkan has no dynamic sample mask before VK_EXT_extended_dynamic_state3 -
// so it is hashed with them, and all-ones has to keep producing the pipeline a null
// pSampleMask always did.
//
// TWO words, though GL only ever fills the first. GL_MAX_SAMPLE_MASK_WORDS is clamped
// to 1 on both backends, so glSampleMaski writes index 0 and nothing else - but the
// count Vulkan READS is ceil(rasterizationSamples / 32), which is 2 on a 64-sample
// target, and GetAdvertisedMaxSamples does not cap the driver's sample count. A
// single Uint32 here let such a pipeline read one word past the member (the next
// struct field). The second word is all-ones: full coverage for samples 32..63, which
// is the only honest answer when GL has no state describing them.
Uint32 sampleMask[2] = {0xffffffffu, 0xffffffffu};
Uint32 subpass = 0;
VkPrimitiveTopology topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
Bool primitiveRestartEnable = false;
@@ -77,6 +77,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
};
// Where a gl_PerVertex built-in output lives, resolved from the module's annotations.
// Named for gl_Position because the clip-space fixup is what it was written for, and it
// is still the only shape that pass accepts - but the transform-feedback capture pass
// resolves gl_PointSize through the same struct, in which case `vectorTypeId` /
// `vectorPtrTypeId` hold the SCALAR float type and its Output pointer rather than a vec4.
struct PositionTargetInfo {
Uint32 variableId = 0;
Uint32 vectorTypeId = 0;
@@ -102,6 +107,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return true;
}
// gl_PointSize's counterpart to IsVec4Float32. The two are the only shapes any
// gl_PerVertex member this file resolves can have, and each resolver takes whichever
// one its built-in is declared with, so a mismatched type declines rather than
// producing a mirror the driver would reject.
Bool IsFloat32Scalar(spvtools::opt::IRContext* context, Uint32 typeId, Uint32* outFloatTypeId) {
auto* floatInst = context->get_def_use_mgr()->GetDef(typeId);
if (!floatInst || floatInst->opcode() != spv::Op::OpTypeFloat) return false;
if (floatInst->GetSingleWordInOperand(0) != 32) return false;
if (outFloatTypeId) *outFloatTypeId = typeId;
return true;
}
// Which of the two shapes above a resolver should accept. A plain function pointer
// rather than a std::function: every call site is one of the two free functions.
using BuiltInTypeCheckFn = Bool (*)(spvtools::opt::IRContext*, Uint32, Uint32*);
spvc_basetype MapReflectInterfaceToSpvcBasetype(const SpvReflectInterfaceVariable& variable) {
if (variable.type_description == nullptr) {
return SPVC_BASETYPE_UNKNOWN;
@@ -381,9 +403,33 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return used;
}
void ValidateTransformedSpirv(const Vector<Uint>& spirv, ShaderStage shaderStage, Uint programExternalIndex) {
// What a failed validation says, for a caller that wants to put it in its own message.
struct SpirvValidationFailure {
String message;
Int result = 0;
SizeT index = 0;
};
// Returns whether the module validates. The result used to be discarded everywhere: the
// call was DEBUG-or-env gated and only logged, so an invalid module produced by a backend
// transform went straight to vkCreateShaderModule. That is not a survivable outcome on
// this hardware - Mali r54 SIGSEGVs building the pipeline instead of returning an error,
// the same "not a validating entry point" behaviour PipelineFactory already documents for
// vkCreateGraphicsPipelines - so the callers that feed the driver now act on it.
//
// This function does NOT log the failure at E any more. It used to, unlatched, on the
// stated grounds that "reaching here already requires the validation switch to be armed,
// which bounds the volume" - and that premise died when the two GetOrCreateProgram call
// sites became unconditional: MGLOG_E is live at the production INFO level, and Log.h's
// own rule is that anything at W or E on a repeatable path must be latched or demoted.
// The failure text now travels back through `outFailure` so the LATCHED call-site
// messages carry the VUID instead of an unlatched inner one repeating it; what stays here
// is the D-level detail and the process-wide counter the test lanes assert on.
Bool ValidateTransformedSpirv(const Vector<Uint>& spirv, ShaderStage shaderStage, Uint programExternalIndex,
SpirvValidationFailure* outFailure = nullptr) {
if (outFailure != nullptr) *outFailure = {};
if (spirv.empty()) {
return;
return true;
}
spv_const_binary_t binary = {spirv.data(), spirv.size()};
@@ -405,18 +451,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spv_diagnostic diagnostic = nullptr;
const spv_result_t result = spvValidateWithOptions(context, options, &binary, &diagnostic);
if (result != SPV_SUCCESS) {
// MGLOG_E, unlatched: reaching here already requires the validation switch to
// be armed, which bounds the volume, and each VUID names a different defect.
// (Parked at MGLOG_I until the Log.h level ordering was fixed, when E was
// compiled out of every INFO build.) The latch is what a test harness asserts on.
const char* message =
diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : "<null>";
const SizeT index = diagnostic != nullptr ? diagnostic->position.index : 0;
// The test-lane signal (ShaderCompiler.h documents harnesses snapshotting it and
// asserting on the delta). Bumped for every failed validation, including one a
// caller goes on to recover from: a transform that produced an invalid module is
// a real defect whether or not this run survived it.
MG_Util::ShaderTranspiler::ShaderCompiler::NoteSpirvValidationFailure();
MGLOG_E(
if (outFailure != nullptr) {
*outFailure = {String(message), static_cast<Int>(result), index};
}
MGLOG_D(
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d index=%zu msg=%s",
static_cast<Int>(shaderStage),
programExternalIndex,
static_cast<Int>(result),
diagnostic != nullptr ? diagnostic->position.index : 0,
diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : "<null>");
index,
message);
}
MOBILEGL_ASSERT(
result == SPV_SUCCESS,
@@ -432,6 +484,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spvDiagnosticDestroy(diagnostic);
spvValidatorOptionsDestroy(options);
spvContextDestroy(context);
return result == SPV_SUCCESS;
}
void ReflectStageInterfaceVariable(const SpvReflectInterfaceVariable& variable,
@@ -743,8 +796,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
Bool ResolveDirectPositionTarget(spvtools::opt::IRContext* context, Uint32 variableId,
PositionTargetInfo* outTarget) {
Bool ResolveDirectBuiltInTarget(spvtools::opt::IRContext* context, Uint32 variableId,
BuiltInTypeCheckFn typeCheck, PositionTargetInfo* outTarget) {
auto* varInst = context->get_def_use_mgr()->GetDef(variableId);
if (!varInst || varInst->opcode() != spv::Op::OpVariable) return false;
if (varInst->GetSingleWordInOperand(0) != static_cast<Uint32>(spv::StorageClass::Output)) return false;
@@ -756,7 +809,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
PositionTargetInfo target{};
target.variableId = variableId;
target.vectorTypeId = ptrTypeInst->GetSingleWordInOperand(1);
if (!IsVec4Float32(context, target.vectorTypeId, &target.floatTypeId)) return false;
if (!typeCheck(context, target.vectorTypeId, &target.floatTypeId)) return false;
target.vectorPtrTypeId = varInst->type_id();
target.isMember = false;
@@ -771,15 +824,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return context->get_type_mgr()->GetTypeInstruction(&ptrType);
}
Bool ResolveMemberPositionTarget(spvtools::opt::IRContext* context, Uint32 structTypeId, Uint32 memberIndex,
PositionTargetInfo* outTarget) {
Bool ResolveMemberBuiltInTarget(spvtools::opt::IRContext* context, Uint32 structTypeId, Uint32 memberIndex,
BuiltInTypeCheckFn typeCheck, PositionTargetInfo* outTarget) {
auto* structInst = context->get_def_use_mgr()->GetDef(structTypeId);
if (!structInst || structInst->opcode() != spv::Op::OpTypeStruct) return false;
if (memberIndex >= structInst->NumInOperands()) return false;
const Uint32 vectorTypeId = structInst->GetSingleWordInOperand(memberIndex);
Uint32 floatTypeId = 0;
if (!IsVec4Float32(context, vectorTypeId, &floatTypeId)) return false;
if (!typeCheck(context, vectorTypeId, &floatTypeId)) return false;
const Uint32 vectorPtrTypeId = FindOutputVectorPointerTypeId(context, vectorTypeId);
if (vectorPtrTypeId == 0) return false;
@@ -807,27 +860,130 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return false;
}
Bool FindPositionTarget(spvtools::opt::IRContext* context, PositionTargetInfo* outTarget) {
// The OUTPUT variable (or gl_PerVertex member) carrying `builtIn`, if the module
// declares one of the expected type. Annotations are the search space deliberately:
// they survive the link-time sanitize chain's interface delisting, which is the whole
// reason EnsureEntryPointInterface exists.
Bool FindBuiltInTarget(spvtools::opt::IRContext* context, spv::BuiltIn builtIn,
BuiltInTypeCheckFn typeCheck, PositionTargetInfo* outTarget) {
Vector<Pair<Uint32, Uint32>> memberCandidates;
constexpr auto kDecorationBuiltIn = static_cast<Uint32>(spv::Decoration::BuiltIn);
constexpr auto kBuiltInPosition = static_cast<Uint32>(spv::BuiltIn::Position);
const auto wantedBuiltIn = static_cast<Uint32>(builtIn);
for (auto& inst : context->module()->annotations()) {
if (inst.opcode() == spv::Op::OpDecorate) {
if (inst.NumInOperands() < 3) continue;
if (inst.GetSingleWordInOperand(1) != kDecorationBuiltIn) continue;
if (inst.GetSingleWordInOperand(2) != kBuiltInPosition) continue;
if (ResolveDirectPositionTarget(context, inst.GetSingleWordInOperand(0), outTarget)) return true;
if (inst.GetSingleWordInOperand(2) != wantedBuiltIn) continue;
if (ResolveDirectBuiltInTarget(context, inst.GetSingleWordInOperand(0), typeCheck, outTarget)) {
return true;
}
} else if (inst.opcode() == spv::Op::OpMemberDecorate) {
if (inst.NumInOperands() < 4) continue;
if (inst.GetSingleWordInOperand(2) != kDecorationBuiltIn) continue;
if (inst.GetSingleWordInOperand(3) != kBuiltInPosition) continue;
if (inst.GetSingleWordInOperand(3) != wantedBuiltIn) continue;
memberCandidates.emplace_back(inst.GetSingleWordInOperand(0), inst.GetSingleWordInOperand(1));
}
}
for (const auto& [structTypeId, memberIndex] : memberCandidates) {
if (ResolveMemberPositionTarget(context, structTypeId, memberIndex, outTarget)) return true;
if (ResolveMemberBuiltInTarget(context, structTypeId, memberIndex, typeCheck, outTarget)) return true;
}
return false;
}
Bool FindPositionTarget(spvtools::opt::IRContext* context, PositionTargetInfo* outTarget) {
return FindBuiltInTarget(context, spv::BuiltIn::Position, IsVec4Float32, outTarget);
}
// Put `variableId` back on `entryPoint`'s interface list if it is not already there.
//
// SPIR-V requires every Input/Output global an entry point statically uses to be listed on
// its OpEntryPoint, and spirv-val enforces it ("Interface variable id <N> is used by entry
// point 'main' id <M>, but is not listed as an interface"). The link-time sanitize chain
// DELISTS a variable nothing referenced yet - ShaderCompiler::SanitizeAndOptimizeBinary
// runs CreateAggressiveDCEPass(false), which may never delete an Output, followed by
// CreateRemoveUnusedInterfaceVariablesPass, which rebuilds the operand list from the
// variables actually referenced. A TES that redeclares `out gl_PerVertex { vec4
// gl_Position; }` and never writes it therefore reaches the backend with the OpVariable
// and its BuiltIn Position decoration intact and its interface slot gone. Any pass that
// then injects a reference has to put the slot back, or it hands the driver a module no
// validator accepts - and Mali r54 answers that with a SIGSEGV inside pipeline creation
// rather than an error return.
//
// No SPIR-V version gate here, unlike GlFragCoordYFlipPass's identical call for its
// injected PRIVATE global: Input and Output belong on the interface in every version,
// and only 1.4 widened it to the other storage classes.
Bool EnsureEntryPointInterface(spvtools::opt::IRContext* context, spvtools::opt::Instruction& entryPoint,
Uint32 variableId) {
// In-operands: 0 = execution model, 1 = entry function id, 2 = name, 3.. = interface.
constexpr Uint32 kFirstInterfaceOperand = 3;
if (variableId == 0) return false;
for (Uint32 operand = kFirstInterfaceOperand; operand < entryPoint.NumInOperands(); ++operand) {
if (entryPoint.GetSingleWordInOperand(operand) == variableId) return false;
}
entryPoint.AddOperand({SPV_OPERAND_TYPE_ID, {variableId}});
context->AnalyzeUses(&entryPoint);
return true;
}
// Is `pointerId` the position target itself, or an access chain rooted at it?
Bool PointerReachesPositionTarget(spvtools::opt::IRContext* context, Uint32 pointerId,
const PositionTargetInfo& target) {
auto* defUse = context->get_def_use_mgr();
for (Uint32 current = pointerId; current != 0;) {
if (current == target.variableId) return true;
const auto* inst = defUse->GetDef(current);
if (inst == nullptr) return false;
switch (inst->opcode()) {
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain:
case spv::Op::OpPtrAccessChain:
case spv::Op::OpInBoundsPtrAccessChain:
case spv::Op::OpCopyObject:
current = inst->GetSingleWordInOperand(0);
break;
default:
return false;
}
}
return false;
}
// Does anything in the module write the position target?
//
// Deliberately conservative - it answers "assume yes" for every shape it cannot read
// exactly, because a false "no" would silently drop the clip-space fixup from a shader
// that does write gl_Position, while a false "yes" only reinstates the behaviour this
// pass has always had. Scans every function rather than just the entry point's: a shader
// that assigns gl_Position inside a helper is still a shader that writes it, and passing
// the pointer to a call is a write as far as this can tell.
Bool ModuleWritesPositionTarget(spvtools::opt::IRContext* context, const PositionTargetInfo& target) {
for (auto& function : *context->module()) {
for (auto& block : function) {
for (const auto& inst : block) {
switch (inst.opcode()) {
case spv::Op::OpStore:
case spv::Op::OpCopyMemory:
case spv::Op::OpCopyMemorySized:
if (PointerReachesPositionTarget(context, inst.GetSingleWordInOperand(0), target)) {
return true;
}
break;
case spv::Op::OpFunctionCall:
// In-operand 0 is the callee; the rest are arguments.
for (Uint32 argument = 1; argument < inst.NumInOperands(); ++argument) {
if (PointerReachesPositionTarget(context, inst.GetSingleWordInOperand(argument),
target)) {
return true;
}
}
break;
default:
break;
}
}
}
}
return false;
}
@@ -914,6 +1070,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
PositionTargetInfo target{};
if (!FindPositionTarget(context(), &target)) return Status::SuccessWithoutChange;
// Nothing to remap in a Position the shader never writes. Declining is not just
// an optimisation: the fixup is load-modify-store, so on an unwritten Position it
// converts "undefined, never written" into "written with whatever the load
// returned", and the store is a reference to a variable the link-time sanitize
// chain has already delisted from the entry-point interface. glslang emits the
// OpVariable for every DECLARED interface block, so a redeclared-but-unwritten
// `out gl_PerVertex` is a shape real shaders have.
if (!ModuleWritesPositionTarget(context(), target)) {
MGLOG_D("gl-to-vulkan-position-fix: the shader never writes gl_Position; leaving it alone");
return Status::SuccessWithoutChange;
}
auto* floatType = context()->get_type_mgr()->GetType(target.floatTypeId);
if (!floatType) return Status::SuccessWithoutChange;
@@ -945,6 +1113,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto* function = context()->GetFunction(entryPoint.GetSingleWordInOperand(1));
if (!function) continue;
Bool modifiedThisEntryPoint = false;
for (auto& bb : *function) {
for (auto instIter = bb.begin(); instIter != bb.end(); ++instIter) {
auto* inst = &*instIter;
@@ -953,10 +1122,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
(model != spv::ExecutionModel::Geometry && inst->opcode() == spv::Op::OpReturn);
if (!needsFixup) continue;
modified |= InsertPositionFixup(context(), inst, target, halfConstId, doYFlip, doZRemap,
doSurfaceRotate90, doSurfaceRotate180, doSurfaceRotate270);
modifiedThisEntryPoint |=
InsertPositionFixup(context(), inst, target, halfConstId, doYFlip, doZRemap,
doSurfaceRotate90, doSurfaceRotate180, doSurfaceRotate270);
}
}
// Per entry point, and only for one this pass actually injected into: the
// injected load/store is a static use of the position variable, so the
// variable has to be on THIS entry point's interface list.
if (modifiedThisEntryPoint) {
EnsureEntryPointInterface(context(), entryPoint, target.variableId);
}
modified |= modifiedThisEntryPoint;
}
if (!modified) return Status::SuccessWithoutChange;
@@ -1235,6 +1412,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool needsPositionMirror = false;
Uint32 positionBufferIndex = 0;
Uint32 positionOffset = 0;
// gl_PointSize is a gl_PerVertex MEMBER, never a variable of its own, so the
// debug-name lookup below can never resolve it - it used to fall through to
// "no SPIR-V variable named 'gl_PointSize'" and leave the frontend's reserved
// slot unwritten, or, when it was the only capture, leave the module with no
// Xfb execution mode at all and the whole span declined.
Bool needsPointSizeMirror = false;
Uint32 pointSizeBufferIndex = 0;
Uint32 pointSizeOffset = 0;
for (const auto& varying : m_varyings) {
if (varying.name == "gl_Position") {
needsPositionMirror = true;
@@ -1242,6 +1427,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
positionOffset = varying.offsetBytes;
continue;
}
if (varying.name == "gl_PointSize") {
needsPointSizeMirror = true;
pointSizeBufferIndex = varying.bufferIndex;
pointSizeOffset = varying.offsetBytes;
continue;
}
if (varying.blockMemberIndex >= 0) {
// glslang names the block's instance variable and its struct type
// separately; an anonymous instance leaves only the type named, so
@@ -1306,8 +1497,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
if (needsPositionMirror) {
modified |= MirrorPositionForCapture(entryFunctionId, *entryPoint, positionBufferIndex,
positionOffset, decorateForXfb);
modified |= MirrorPerVertexBuiltInForCapture(entryFunctionId, *entryPoint,
spv::BuiltIn::Position, IsVec4Float32,
"gl_Position", positionBufferIndex, positionOffset,
decorateForXfb);
}
if (needsPointSizeMirror) {
modified |= MirrorPerVertexBuiltInForCapture(entryFunctionId, *entryPoint,
spv::BuiltIn::PointSize, IsFloat32Scalar,
"gl_PointSize", pointSizeBufferIndex,
pointSizeOffset, decorateForXfb);
}
if (!modified) return Status::SuccessWithoutChange;
@@ -1347,19 +1546,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return 0;
}
// gl_Position and gl_PointSize are captured the same way and differ only in which
// built-in is looked up and what type it has, so one injector serves both. Anything
// else in gl_PerVertex would need its own type check before it could be added here.
template <typename DecorateFn>
Bool MirrorPositionForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint,
Uint32 bufferIndex, Uint32 offsetBytes, const DecorateFn& decorateForXfb) {
Bool MirrorPerVertexBuiltInForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint,
spv::BuiltIn builtIn, BuiltInTypeCheckFn typeCheck,
const char* glslName, Uint32 bufferIndex, Uint32 offsetBytes,
const DecorateFn& decorateForXfb) {
const Uint32 entryPointModel = entryPoint.GetSingleWordInOperand(0);
using namespace spvtools::opt;
PositionTargetInfo target{};
if (!FindPositionTarget(context(), &target)) {
MGLOG_E("XfbCaptureDecoratePass: gl_Position capture requested but no position output found");
if (!FindBuiltInTarget(context(), builtIn, typeCheck, &target)) {
MGLOG_E("XfbCaptureDecoratePass: %s capture requested but no such output found", glslName);
return false;
}
if (!target.isMember) {
// Standalone gl_Position variable: decorate it directly.
// Standalone built-in variable: decorate it directly. It still has to be
// on the interface - a transform-feedback decoration on a variable the entry
// point does not list captures nothing, and the sanitize chain delists an
// unwritten one (see EnsureEntryPointInterface).
decorateForXfb(target.variableId, bufferIndex, offsetBytes);
EnsureEntryPointInterface(context(), entryPoint, target.variableId);
return true;
}
@@ -1413,6 +1621,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
injected = true;
}
}
// The mirror was listed on the entry point above, but the loop just added a READ
// of the SOURCE block through an access chain, and the interface rule covers
// reads exactly as it covers writes. A built-in capture on a shader whose
// block the sanitize chain delisted - a TES that redeclares `out gl_PerVertex`
// and never writes it, which is what the tessellation_control_to_tessellation_
// evaluation.gl_MaxPatchVertices_Position_PointSize bodies do - produced an
// invalid module here for the same reason the position fixup did.
if (injected) {
EnsureEntryPointInterface(context(), entryPoint, target.variableId);
}
return injected;
}
@@ -3238,9 +3456,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& spirv = program.GetGeneratedSpirv();
Vector<Vector<Uint>> moduleSpirvs(spirv.size());
const Bool enableSpirvValidation = program.GetSpirvValidationEnabled();
if (enableSpirvValidation) {
MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
}
// Unconditional now: the two ValidateTransformedSpirv calls below run in every build,
// not only when the switch is armed, so the validator's static tables have to be pinned
// against process exit in every build too.
MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
const ShaderStage fixupStage = PickClipFixupStage(stages);
@@ -3264,6 +3483,46 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
TransformSpirvForVulkanPositionFix(*fixupInput, moduleSpirvs[i], flags);
// These two passes INJECT references - a store for the clip fixup, an access
// chain and a load for the gl_Position capture mirror - and a reference to a
// variable the link-time sanitize chain delisted from the entry-point interface
// is invalid SPIR-V that Mali r54 turns into a SIGSEGV inside pipeline creation
// rather than an error return. EnsureEntryPointInterface keeps them honest; this
// is the backstop.
//
// The fallback UNWINDS ONE PASS AT A TIME, which matters because the two passes
// are not equally optional. Rewinding straight to `spv` would also throw away the
// XfbBuffer/XfbStride/Offset decorations, the TransformFeedback capability and the
// Xfb execution mode - while the renderer decides to call
// vkCmdBeginTransformFeedbackEXT purely from GL state and never looks at the
// module. That ships a pipeline whose last pre-rasterization stage has no Xfb mode
// into a transform-feedback span, violating
// VUID-vkCmdBeginTransformFeedbackEXT-None-04128 on exactly the driver class this
// guard exists for. So: try the post-XFB, pre-clip-fixup module first, which keeps
// capture working and costs only the clip-space remap.
//
// Once per program on a cache miss, and only for the single stage that carries the
// fixups - not per draw and not per module.
SpirvValidationFailure fixupFailure{};
if (!ValidateTransformedSpirv(moduleSpirvs[i], stages[i], program.GetExternalIndex(),
&fixupFailure)) {
SpirvValidationFailure xfbFailure{};
if (fixupInput != &spv &&
ValidateTransformedSpirv(*fixupInput, stages[i], program.GetExternalIndex(), &xfbFailure)) {
MGLOG_E_ONCE("ProgramFactory: the clip fixup produced an invalid module for program %u "
"stage %d (%s); keeping the capture-decorated one, so this program draws "
"without the clip-space remap",
program.GetExternalIndex(), static_cast<Int>(stages[i]),
fixupFailure.message.c_str());
moduleSpirvs[i] = *fixupInput;
} else {
MGLOG_E_ONCE("ProgramFactory: the clip/XFB fixups produced an invalid module for program %u "
"stage %d (%s); keeping the untransformed one",
program.GetExternalIndex(), static_cast<Int>(stages[i]),
fixupFailure.message.c_str());
moduleSpirvs[i] = spv;
}
}
} else {
moduleSpirvs[i] = spv;
}
@@ -3472,15 +3731,63 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& moduleSpv = moduleSpirvs[i];
if (moduleSpv.empty()) continue;
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex());
#else
// Final module the driver receives; also checked in the INFO-level CI/test
// lanes, where the DEBUG gate above is compiled out.
if (enableSpirvValidation) {
ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex());
// Last look at the exact bytes the driver receives, in EVERY build rather than only
// in DEBUG or with MOBILEGL_ENABLE_SPIRV_VALIDATION armed. This one only reports:
// by here the descriptor bindings have been remapped and the layout about to be
// reflected describes the remapped module, so there is no module left that is both
// valid and consistent with it to fall back to. The recovery lives one step earlier,
// at the clip/XFB fixups (see the revert there) - which is where a transform can
// introduce a reference to a delisted interface variable, the failure this whole
// guard exists for. Anything that reaches this line names itself in the log of a
// shipping build instead of dying anonymously inside the driver.
SpirvValidationFailure finalFailure{};
if (!ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex(), &finalFailure)) {
MGLOG_E_ONCE("ProgramFactory: handing vkCreateShaderModule an INVALID module for program %u stage %d - "
"a backend transform after the clip/XFB fixups broke it (%s)",
program.GetExternalIndex(), static_cast<Int>(stages[i]),
finalFailure.message.c_str());
}
// Does the stage the driver will treat as the last pre-rasterization one actually
// carry Xfb? Asked of the FINAL bytes, so it answers for whatever the whole transform
// chain produced - a rewound clip/XFB backstop, a capture pass that resolved no
// varying and changed nothing, anything later that might strip it. The renderer picks
// its capture commands from GL state alone and would otherwise open a span against a
// pipeline that cannot feed it.
if (stages[i] == fixupStage && (flags & ProgramFactory::CompileOptionBit::XfbCapture) &&
program.GetTransformFeedbackVaryingCount() > 0 &&
!MG_Util::ShaderTranspiler::ShaderCompiler::ModuleDeclaresTransformFeedback(moduleSpv)) {
MGLOG_E_ONCE("ProgramFactory: program %u was built as a transform-feedback capture variant but its "
"stage %d carries no Xfb execution mode; its capture spans will be declined rather "
"than recorded against a pipeline that cannot feed them",
program.GetExternalIndex(), static_cast<Int>(stages[i]));
entry.xfbCaptureDeclined = true;
}
// Does this stage need a device feature the device did not give us? Asked ONLY when
// the feature is off, so a device that has it - the common case - pays nothing: the
// whole test is short-circuited before the module is parsed.
//
// gl_PointSize is an ordinary per-vertex output in desktop GL and any
// vertex-processing stage may write it, but Vulkan puts the built-in behind
// shaderTessellationAndGeometryPointSize in the tessellation and geometry stages
// (VUID-RuntimeSpirv-PointSize-06439). glslang emits TessellationPointSize /
// GeometryPointSize from the application's own access, so this program is legal GL
// that this device cannot run - the same shape the DirectGLES arm reports when a
// driver advertises neither EXT nor OES point-size extension, and it deserves the
// same named message rather than a pipeline the driver may fault on.
if (!m_tessellationAndGeometryPointSizeEnabled &&
(stages[i] == ShaderStage::TessControl || stages[i] == ShaderStage::TessEval ||
stages[i] == ShaderStage::Geometry) &&
MG_Util::ShaderTranspiler::ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(
moduleSpv)) {
MGLOG_E_ONCE("ProgramFactory: program %u stage %d accesses gl_PointSize, but this device does not "
"support shaderTessellationAndGeometryPointSize; its draws are refused rather than "
"built into a pipeline the driver may fault on. Point size from a non-vertex stage "
"is not available on this device.",
program.GetExternalIndex(), static_cast<Int>(stages[i]));
entry.pointSizeCapabilityUnsupported = true;
}
#endif
VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
smci.codeSize = moduleSpv.size() * sizeof(Uint);
@@ -3758,11 +4065,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// PassthroughTessControlTest.MatchesTheFrontendPerVertexBlock is the latch, and it now
// links the program at both 430 and 460.
//
// Only gl_Position is written. gl_PointSize is declared but left alone deliberately:
// writing it from a tessellation stage requires the shaderTessellationAndGeometryPointSize
// feature, which this renderer does not enable, so a program whose evaluation stage reads
// gl_in[].gl_PointSize gets an undefined point size instead of the vertex stage's - a gap
// this trades for not making every tessellated pipeline depend on an optional feature.
// Only gl_Position is written, and gl_PointSize is declared without being forwarded. That
// is a KNOWN GAP, not a design: GL 4.6 core 11.2.2 says the fixed-function pass-through
// hands the input patch to the evaluation stage unmodified, so an evaluation stage
// reading gl_in[].gl_PointSize should see the vertex stage's value and instead sees
// whatever this stage left in gl_out[] - which is nothing. A capture of it (the mirror in
// XfbCaptureDecoratePass) faithfully records that nothing.
//
// The reason this comment used to give - "the renderer does not enable
// shaderTessellationAndGeometryPointSize" - stopped being true when
// VulkanRenderer::CreateLogicalDeviceAndQueues started taking the feature wherever the
// device advertises it. Closing the gap is therefore possible now, but it is not free:
// the forwarding store has to be gated on that feature, because on a device without it
// the store is exactly the invalid usage the build-time refusal
// (VkProgramObject::pointSizeCapabilityUnsupported) exists to keep away from the driver -
// and this synthesized stage is not the application's, so refusing the program because
// MobileGL's own pass-through named a built-in would be the wrong trade. Nothing pins
// the shape either: every case in TessellationXfbCaptureScenario builds an explicit
// control stage, so a TES-without-TCS test has to come with the fix.
const String perVertexBody = BuildPerVertexMemberDeclarations(perVertexMembers);
source += "in gl_PerVertex {\n" + perVertexBody + "} gl_in[gl_MaxPatchVertices];\n";
source += "out gl_PerVertex {\n" + perVertexBody + "} gl_out[];\n";
@@ -3862,14 +4182,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
const Vector<Uint>& spirv = binary.value().front();
{
// Still switch-gated, unlike the two in GetOrCreateProgram: this stage is synthesized
// by MobileGL from a fixed template rather than transformed from application SPIR-V,
// so a failure here is a MobileGL bug to catch in a validating lane, not something a
// shipping build can be handed by an application. The message is latched all the same
// - the pass-through cache is keyed on patchVertices, so a broken template would
// otherwise re-report once per distinct patch size.
Bool validateThisOne = false;
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(spirv, ShaderStage::TessControl, 0);
validateThisOne = true;
#else
if (m_enableSpirvValidation) {
MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
ValidateTransformedSpirv(spirv, ShaderStage::TessControl, 0);
}
validateThisOne = m_enableSpirvValidation;
if (validateThisOne) MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
#endif
SpirvValidationFailure passthroughFailure{};
if (validateThisOne &&
!ValidateTransformedSpirv(spirv, ShaderStage::TessControl, 0, &passthroughFailure)) {
MGLOG_E_ONCE("ProgramFactory: the synthesized pass-through tessellation control stage for "
"patchVertices=%u does not validate (%s)",
patchVertices, passthroughFailure.message.c_str());
}
}
VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
smci.codeSize = spirv.size() * sizeof(Uint);
@@ -202,6 +202,32 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// tessellation stages are present or neither
// (VUID-VkGraphicsPipelineCreateInfo-pStages-00730). So the draw path has to supply
// the pass-through stage GL describes; see GetOrCreatePassthroughTessControlStage.
// True when this program was built AS a transform-feedback capture variant but its
// last pre-rasterization module does NOT carry the Xfb execution mode - so the
// renderer must decline the capture span instead of issuing
// vkCmdBeginTransformFeedbackEXT against it
// (VUID-vkCmdBeginTransformFeedbackEXT-None-04128).
//
// Two ways to get here, and neither is visible from GL state, which is all
// BeginXfbCaptureForDraw otherwise consults: the clip/XFB validation backstop had to
// rewind past the capture decoration, or XfbCaptureDecoratePass resolved none of the
// requested varyings and returned without changing anything (its own MGLOG_E path)
// while its runner still reported success. Both used to ship a non-Xfb module under
// an Xfb-flagged cache entry - the flag and the layout are part of the program cache
// key, so it was sticky for every later captured draw of the program, not a glitch.
Bool xfbCaptureDeclined = false;
// The program has a tessellation or geometry module declaring TessellationPointSize /
// GeometryPointSize on a device whose shaderTessellationAndGeometryPointSize feature
// is off, so a pipeline built from it is invalid usage
// (VUID-RuntimeSpirv-PointSize-06439). Its draws are refused in SetupDraw rather than
// handed to the driver - the same contract PipelineFactory's half-tessellated refusal
// implements one level up, and the counterpart of the DirectGLES arm that reports a
// driver with neither point-size extension by name.
//
// Sticky by construction, which is what makes ONE log line honest: the flag lives on
// the cache entry, so every later draw of the same program variant reads the same
// answer instead of re-deciding it.
Bool pointSizeCapabilityUnsupported = false;
Bool needsPassthroughTessControl = false;
// ...and the pass-through this renderer can synthesize carries gl_Position and
// nothing else, so it is only correct when the evaluation stage's inputs are
@@ -426,12 +452,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
explicit ProgramFactory(VkDevice device, const VulkanRendererConfig& config, Uint32 maxBindings,
Bool shaderDrawParametersEnabled,
Bool unformattedFloatStorageImagesEnabled,
Bool tessellationAndGeometryPointSizeEnabled,
Bool enableSpirvValidation,
UpdateAfterBindLimits updateAfterBindLimits,
SubgroupLoweringPolicy subgroupPolicy)
: m_device(device), m_maxBindings(maxBindings), m_config(config),
m_shaderDrawParametersEnabled(shaderDrawParametersEnabled),
m_unformattedFloatStorageImagesEnabled(unformattedFloatStorageImagesEnabled),
m_tessellationAndGeometryPointSizeEnabled(tessellationAndGeometryPointSizeEnabled),
m_enableSpirvValidation(enableSpirvValidation),
m_updateAfterBindLimits(updateAfterBindLimits),
m_subgroupPolicy(subgroupPolicy) {
@@ -591,6 +619,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// True only when the logical device enabled both
// shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat.
Bool m_unformattedFloatStorageImagesEnabled = false;
// True when the logical device enabled shaderTessellationAndGeometryPointSize. When it is
// FALSE a program whose tessellation or geometry module declares TessellationPointSize /
// GeometryPointSize is refused at build time (see VkProgramObject::
// pointSizeCapabilityUnsupported) instead of being handed to the driver as invalid usage.
Bool m_tessellationAndGeometryPointSizeEnabled = false;
// Startup snapshot used only by internally synthesized shader modules, which do not
// originate from a ProgramLinkTask.
Bool m_enableSpirvValidation = false;
@@ -37,6 +37,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// glGenTextures ever hands this out, and nothing looks a placeholder up by name - so the
// id only has to stay clear of the application's, exactly like the sampled fallback's.
constexpr Uint kUnboundStorageImageExternalIndex = 0xFFFFFF01u;
// The multisample sampled fallbacks: one per (target, numeric domain), because unlike the
// single-sampled fallback they cannot be reinterpreted into another domain at view time
// (see GetFallbackMultisampleTexture). Six reserved ids, contiguous from this base for the
// same reason as the two above - they must not collide with anything glGenTextures can
// hand out.
constexpr Uint kFallbackMultisampleExternalIndexBase = 0xFFFFFF02u;
constexpr Uint kFallbackMultisampleExternalIndexCount = 6u;
// MobileGL's own stand-in textures, by the reserved ids above. Nothing an application can
// do reaches one, so anything keyed on the GL object an application bound - image-unit
// aliasing above all - has to leave them alone.
Bool IsPlaceholderTexture(const MG_State::GLState::ITextureObject* texture) {
if (texture == nullptr) return false;
const Uint index = static_cast<Uint>(texture->GetExternalIndex());
return index == kFallbackTexture2DExternalIndex || index == kUnboundStorageImageExternalIndex ||
(index >= kFallbackMultisampleExternalIndexBase &&
index < kFallbackMultisampleExternalIndexBase + kFallbackMultisampleExternalIndexCount);
}
// The R32 member of each numeric class. Every one of the three is a MANDATORY-support
// format for uniform texel buffers, storage texel buffers and storage images alike
@@ -360,6 +378,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_textureManager = nullptr;
m_samplerManager = nullptr;
m_fallbackTexture2D.reset();
m_fallbackMultisampleTextures.clear();
}
void UniformManager::BeginFrame(Uint32 frameIndex) {
@@ -496,7 +515,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
texture = nullptr;
}
if (texture == nullptr) {
fallbackHolder = GetFallbackTexture(preferredTarget);
// The binding's sampler class, read here rather than through the `numericDomain`
// local further down (it is declared after this point): the multisample placeholder
// has to be built in the class the shader will read it in.
fallbackHolder = GetFallbackTexture(preferredTarget, programObj.samplerNumericDomainByBinding[binding]);
texture = fallbackHolder.get();
if (texture == nullptr) {
MGLOG_E_ONCE("ResolveSamplerDescriptor: no fallback texture available for binding=%u ('%s') "
@@ -1367,18 +1389,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return outImageInfo.imageView != VK_NULL_HANDLE;
}
SharedPtr<MG_State::GLState::ITextureObject> UniformManager::GetFallbackTexture(TextureTarget target) const {
// The fallback is a single-sampled 2D image, so it can only stand in for a sampler that
// would accept one. A multisample sampler in particular cannot: its descriptor demands a
// multisample view, and handing it this one is invalid Vulkan, not a degraded picture.
// Report that there is no fallback and let the caller decline the draw - aborting the
// process over an unbound sampler is never the right answer.
SharedPtr<MG_State::GLState::ITextureObject> UniformManager::GetFallbackTexture(
TextureTarget target, SamplerNumericDomain numericDomain) const {
// A multisample sampler cannot be served by the single-sampled 2D image below - its
// descriptor demands a multisample view - so it gets its own placeholder rather than no
// placeholder at all. Without one, ResolveSamplerDescriptor declined and
// BindProgramUniformBuffers dropped the WHOLE draw, which is how every
// sample_variables.*.samples_0 body failed: the CTS's resolve program declares both a
// sampler2D and a sampler2DMS and deliberately points the unused one at an empty texture
// unit, and at samples_0 the unused one is the sampler2DMS. GL says sampling an
// incomplete texture is undefined, not fatal, so the draw has to happen.
if (target == TextureTarget::Texture2DMultisample ||
target == TextureTarget::Texture2DMultisampleArray) {
return GetFallbackMultisampleTexture(target, numericDomain);
}
if (target != TextureTarget::Texture2D && target != TextureTarget::TextureRectangle) {
MGLOG_E_ONCE("UniformManager::GetFallbackTexture: no fallback exists for target=%d",
static_cast<Int>(target));
return nullptr;
}
// The single-sampled fallback stays domain-agnostic: it is storage-image capable, so its
// image carries VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT and ResolveSampledImageViewFormat can
// hand an integer sampler an R8G8B8A8_UINT view of these same RGBA8 texels. A multisample
// image can never carry that bit, which is why the arm above needs one object per domain.
if (m_fallbackTexture2D == nullptr) {
auto fallbackTexture = MakeShared<MG_State::GLState::TextureObject2D>(kFallbackTexture2DExternalIndex);
fallbackTexture->SetInternalFormat(TextureInternalFormat::RGBA8);
@@ -1396,6 +1430,83 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return m_fallbackTexture2D;
}
SharedPtr<MG_State::GLState::ITextureObject> UniformManager::GetFallbackMultisampleTexture(
TextureTarget target, SamplerNumericDomain numericDomain) const {
// ONE PLACEHOLDER PER NUMERIC DOMAIN, unlike the single-sampled fallback.
//
// A descriptor whose image format is in a different numeric class than the sampler that
// reads it needs a format-reinterpreting view, and building one needs
// VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT on the image. A multisample image can never have it:
// SyncTextureResource computes storageImageCapable as `!isMultisampleTexture && ...`, and
// the only other source of the bit is the sRGB twin, which RGBA8 is not. So an RGBA8
// placeholder handed to a usampler2DMS made GetOrCreateSampledImageView bail with "needs
// mutable image format", ResolveSamplerDescriptor return false, and the draw be dropped -
// the exact outcome the placeholder exists to prevent, just reached later. Matching the
// image's own format to the sampler's class instead means no reinterpreting view is
// needed at all.
const Bool arrayed = target == TextureTarget::Texture2DMultisampleArray;
TextureInternalFormat internalFormat = TextureInternalFormat::RGBA8;
Uint32 domainSlot = 0;
switch (numericDomain) {
case SamplerNumericDomain::SignedInteger:
internalFormat = TextureInternalFormat::RGBA8I;
domainSlot = 1;
break;
case SamplerNumericDomain::UnsignedInteger:
internalFormat = TextureInternalFormat::RGBA8UI;
domainSlot = 2;
break;
case SamplerNumericDomain::Float:
case SamplerNumericDomain::Unknown:
default:
// Unknown reads as float, matching PlaceholderFormatForNumericDomain's own default:
// a shader whose sampler class could not be reflected is far likelier to be a plain
// sampler2DMS than an integer one, and a float view is the only one buildable without
// the mutable bit anyway.
break;
}
const Uint32 key = (arrayed ? kFallbackMultisampleExternalIndexCount / 2 : 0u) + domainSlot;
auto cached = m_fallbackMultisampleTextures.find(key);
if (cached != m_fallbackMultisampleTextures.end()) {
return cached->second;
}
const TextureUploadTarget uploadTarget = arrayed ? TextureUploadTarget::Texture2DMultisampleArray
: TextureUploadTarget::Texture2DMultisample;
const Uint externalIndex = kFallbackMultisampleExternalIndexBase + key;
SharedPtr<MG_State::GLState::TextureObjectMipmap> texture;
if (arrayed) {
texture = MakeShared<MG_State::GLState::TextureObject2DMultisampleArray>(externalIndex);
} else {
texture = MakeShared<MG_State::GLState::TextureObject2DMultisample>(externalIndex);
}
texture->SetInternalFormat(internalFormat);
// TWO samples, never one. VUID-RuntimeSpirv-samples-08726 forbids an OpTypeImage with
// MS = 1 from reading a VK_SAMPLE_COUNT_1_BIT image, which is exactly the hazard
// VkTextureManager::SyncTextureResource's one-sample floor exists to avoid; a placeholder
// that re-created it would be worse than none.
texture->SetSamples(2);
texture->SetFixedSampleLocations(true);
// No upload, and MarkStorageDirty(dirty = false) to say so: a multisample image cannot be
// written by a transfer at all - it deliberately carries no TRANSFER_DST usage - so unlike
// the 2D fallback this one cannot be given (0, 0, 0, 1) content. Its texels are undefined,
// which is precisely what GL 4.6 core 8.17 promises for a texelFetch on a multisample
// texture that is not complete. The point of the placeholder is that the DRAW happens.
texture->AllocateStorage(uploadTarget, 0, {.texelSize = {1, 1, 1}, .byteSize = 0});
texture->TruncateMipmapLevels(uploadTarget, 1);
texture->MarkStorageDirty(uploadTarget, 0, false);
// Worth knowing if it ever fires: an integer multisample format can legitimately support
// no count above one on a device (framebufferIntegerColorSampleCounts is allowed to be
// VK_SAMPLE_COUNT_1_BIT), and SyncTextureResource's round-down would then hand this
// placeholder a single-sampled image, which is the samples-08726 shape the SetSamples(2)
// above exists to avoid. It already warns from there; nothing better is available - a
// one-sample integer image is still a draw, and declining is the outcome this whole
// placeholder replaced.
MGLOG_D("UniformManager::GetFallbackMultisampleTexture: created placeholder target=%d domain=%d format=%d",
static_cast<Int>(target), static_cast<Int>(numericDomain), static_cast<Int>(internalFormat));
return m_fallbackMultisampleTextures.emplace(key, Move(texture)).first->second;
}
VkBufferView UniformManager::AcquireUnboundTexelBufferView(VkFormat declaredFormat,
SamplerNumericDomain numericDomain, Bool storage) {
MOBILEGL_ASSERT(m_bufferManager != nullptr, "AcquireUnboundTexelBufferView: buffer manager is null");
@@ -1551,25 +1662,49 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding];
MG_State::GLState::ITextureObject* texture =
textureUnit.GetBindingSlot(preferredTarget).GetBoundObject().get();
// The sampler in effect, resolved BEFORE the completeness test below rather than after:
// GL's completeness rules are a property of (texture, sampler in effect), so the test
// cannot be asked without it.
const auto& samplerOverride = textureUnit.GetSamplerObject();
const MG_State::GLState::SamplerObject* effectiveSampler =
samplerOverride ? samplerOverride.get()
: (texture != nullptr ? texture->GetSamplerObject().get() : nullptr);
// Undefined default texture (name 0, no image) resolves as "unbound", exactly
// like ResolveSamplerTextureRaw reports it.
if (MG_State::GLState::IsUndefinedDefaultTexture(texture)) {
texture = nullptr;
}
// ...and so does a texture that fails the completeness rules for the filter in effect,
// because that is precisely what ResolveSamplerDescriptor does with it. The two used to
// disagree: this one asked only whether the default texture was UNDEFINED, so a default
// texture that had been given a base level but no mip chain - which is what the GL-CTS
// state reset between test cases leaves behind, and what any application that uploads to
// texture 0 has - stayed in the sampled set while the descriptor path swapped it for the
// fallback. SetupDraw then synced a texture no descriptor would use, the sync declined
// (GL calls it incomplete), and the null it returned was dereferenced one line later.
// Keeping the two predicates identical is the invariant; CollectSampledTextures exists to
// pre-sync exactly the textures the descriptors will hold.
if (MG_State::GLState::SamplesAsIncompleteTexture(texture, effectiveSampler)) {
texture = nullptr;
}
if (texture == nullptr) {
// ResolveSamplerDescriptor will substitute the fallback texture for this binding;
// include it in the sampled set so the pre-render-pass sync/transition pass covers
// its first use instead of leaving that work to happen inside an active pass.
if (preferredTarget != TextureTarget::Texture2D &&
preferredTarget != TextureTarget::TextureRectangle) {
// Ask GetFallbackTexture rather than re-listing the targets it serves: that list grew
// a multisample arm and the two must not drift apart.
texture = GetFallbackTexture(preferredTarget, programObj.samplerNumericDomainByBinding[binding]).get();
if (texture == nullptr) {
return false;
}
texture = GetFallbackTexture(preferredTarget).get();
// The substitution changed the texture, so the "no override" arm of the effective
// sampler has to follow it to the fallback's own.
if (!samplerOverride) {
effectiveSampler = texture != nullptr ? texture->GetSamplerObject().get() : nullptr;
}
}
const auto& samplerOverride = textureUnit.GetSamplerObject();
outTexture = texture;
outSampler = samplerOverride ? samplerOverride.get()
: (texture != nullptr ? texture->GetSamplerObject().get() : nullptr);
outSampler = effectiveSampler;
return true;
}
@@ -1765,9 +1900,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (!ResolveSampledBinding(program, programObj, samplerBinding, samplerElement,
sampledTexture, sampledSampler) ||
sampledTexture == nullptr || sampledSampler == nullptr ||
MG_State::GLState::SamplesAsIncompleteTexture(sampledTexture, sampledSampler)) {
IsPlaceholderTexture(sampledTexture)) {
// ResolveSamplerDescriptor uses a fallback in these cases, which cannot
// alias the image-unit binding of the original texture.
// alias the image-unit binding of the original texture. The unbound and
// incomplete cases both arrive here AS that fallback now that
// ResolveSampledBinding applies the completeness rule itself, so the test is
// "is this one of ours" rather than a second completeness check.
continue;
}
// Multisample source images intentionally omit TRANSFER_SRC usage. Keep their existing
@@ -179,7 +179,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static MG_State::GLState::ITextureObject* ResolveSamplerTextureRaw(
const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element);
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackTexture(TextureTarget target) const;
// `numericDomain` is the sampler's class, and it matters only for the multisample arm -
// see GetFallbackMultisampleTexture for why the single-sampled fallback can ignore it.
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackTexture(
TextureTarget target, SamplerNumericDomain numericDomain) const;
// The multisample arm of GetFallbackTexture. One object per (target, numeric domain) and
// no upload path: a multisample image cannot be written by a transfer, so its texels stay
// undefined - which is what GL promises for a texelFetch on an incomplete multisample
// texture - and it cannot carry MUTABLE_FORMAT, so its format has to match the sampler's
// class outright rather than being reinterpreted at view time.
SharedPtr<MG_State::GLState::ITextureObject> GetFallbackMultisampleTexture(
TextureTarget target, SamplerNumericDomain numericDomain) const;
// ---- placeholders for UNBOUND image-backed descriptors -------------------------
// GL lets a program declare `samplerBuffer`, `imageBuffer` or `image2D` and bind nothing
// to the unit it names: the fetch is then undefined (GL 4.6 core 8.9 for an incomplete
@@ -293,6 +303,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkTextureManager* m_textureManager = nullptr;
VkSamplerManager* m_samplerManager = nullptr;
mutable SharedPtr<MG_State::GLState::ITextureObject> m_fallbackTexture2D;
// Keyed by (arrayed, numeric domain); see GetFallbackMultisampleTexture. Lazily populated,
// never evicted - at most six tiny 1x1 images - and torn down with the manager.
mutable UnorderedMap<Uint32, SharedPtr<MG_State::GLState::ITextureObject>> m_fallbackMultisampleTextures;
// See AcquireUnboundTexelBufferView / GetUnboundStorageImageTexture. Both are lazily
// populated, never evicted (a program's declared formats are a fixed, tiny set) and torn
// down with the manager. The texel views are keyed by format AND by storage-vs-sampled
@@ -3159,6 +3159,7 @@ void main() {
m_programFactory = MakeUnique<ProgramFactory>(m_device, m_config, maxProgramBindings,
m_shaderDrawParametersFeatureEnabled,
m_unformattedFloatStorageImagesEnabled,
m_tessellationAndGeometryPointSizeFeatureEnabled,
MG_Config::Features.EnableSpirvValidation,
m_updateAfterBindLimits, subgroupPolicy);
MOBILEGL_ASSERT(m_programFactory != nullptr, "ProgramFactory creation failed.");
@@ -4767,16 +4768,43 @@ void main() {
// build in GetOrCreatePipeline - any new GL-state read there must be added here:
// - capability bits: CullFace, DepthTest, PolygonOffsetFill (mode gating rides
// the memo's mode key), RasterizerDiscard, ColorLogicOp, StencilTest,
// PrimitiveRestart(+FixedIndex), SampleShading, plus the depth write mask
// PrimitiveRestart(+FixedIndex), SampleShading, SampleMask, plus the depth write mask
// - patch vertices, polygon mode, cull face mode, depth func, logic op,
// min sample shading
// min sample shading, the glSampleMaski word
// - front/back stencil ops + compare funcs (ref/mask are dynamic state)
// - per draw buffer up to the render pass's colour span: indexed blend enable,
// blend factors/equations, indexed colour write mask (broadcast from index 0
// when the device lacks independentBlend - the same read the payload does)
// FBO-derived payload inputs (attachment presence/formats/draw-buffer gating) are
// pinned by the render-pass hash key, exactly as the version-keyed memo relied on.
Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount) const {
// The fixed-function sample mask this draw actually gets, and the ONE place that decides it.
//
// GL 4.6 core 17.3.3 puts SAMPLE_MASK/SAMPLE_MASK_VALUE among the multisample fragment
// operations and says they make no change "if MULTISAMPLE is disabled, or if the value of
// SAMPLE_BUFFERS is not one" - so on a single-sample draw framebuffer the mask is a no-op.
// Vulkan has no such rule: pSampleMask is ANDed with coverage at every rasterizationSamples,
// and at one sample that coverage is bit 0 alone. Handing the raw GL word straight through
// therefore turned `glEnable(GL_SAMPLE_MASK); glSampleMaski(0, 0x2);` followed by a draw to
// the default framebuffer - the ordinary MSAA-render-then-present shape, and what dEQP's
// multisample cases leave enabled - into a fully discarded, black draw. All-ones restores
// the null-pSampleMask meaning the pipeline had before the mask was plumbed at all.
//
// SAMPLE_BUFFERS is the load-bearing half: MultisampleEnabled defaults to TRUE, so the
// capability check alone would gate nothing. It is here for spec completeness - GL lets
// glDisable(GL_MULTISAMPLE) switch the whole step off on a multisample target too.
//
// Both callers - the payload and ComputePipelineStateHash's memo word - go through this, so
// the memo key cannot describe a different mask than the pipeline was built with.
Uint32 VulkanRenderer::ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const {
constexpr Uint32 kFullCoverage = 0xffffffffu;
if (rasterizationSamples == VK_SAMPLE_COUNT_1_BIT) return kFullCoverage;
if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::Multisample)) return kFullCoverage;
if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleMask)) return kFullCoverage;
return MG_State::pGLContext->GetRenderStateParameters().SampleMaskValue;
}
Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount,
VkSampleCountFlagBits rasterizationSamples) const {
// One bulk fetch instead of ~17 per-field accessor calls into MG_State: every
// input below is a plain field of RenderStateParameters, and each accessor this
// replaces (IsCapabilityEnabled / Get*) is a verified pure read of that same
@@ -4795,6 +4823,13 @@ void main() {
capabilityBits |= p.PrimitiveRestartFixedIndexEnabled ? 1ull << 7 : 0;
capabilityBits |= p.DepthMask ? 1ull << 8 : 0;
capabilityBits |= p.SampleShadingEnabled ? 1ull << 9 : 0;
// The EFFECTIVE mask enable, not the raw GL bit: at one sample GL says the whole
// multisample fragment-operations step makes no change, so the pipeline is built with
// full coverage and the memo word has to say so too. Keying on the raw bit here while
// the payload gates on the sample count would let one FBO's cached pipeline answer for
// another whose sample count reads the mask differently.
const Bool sampleMaskEffective = ResolveEffectiveSampleMask(rasterizationSamples) != 0xffffffffu;
capabilityBits |= sampleMaskEffective ? 1ull << 10 : 0;
Uint64 hash = CombinePipelineStateWord(0x243F6A8885A308D3ull, capabilityBits);
// glMinSampleShading. Hashed by BITS, not by value: this memo compares hashes rather than
// versions, so an unhashed float would let a pipeline built at one rate be handed back
@@ -4804,6 +4839,13 @@ void main() {
std::memcpy(&minSampleShadingBits, &p.MinSampleShadingValue, sizeof(minSampleShadingBits));
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(minSampleShadingBits));
}
// glSampleMaski's word, for the same reason glMinSampleShading's bits are hashed above:
// this memo compares hashes, not versions, so a mask that moved between two otherwise
// identical draws has to key a different pipeline. Hashed unconditionally rather than only
// while GL_SAMPLE_MASK is enabled - the enable bit is already in capabilityBits, and
// folding one more word costs nothing on a path that only recomputes when the
// pipeline-state version moved.
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(ResolveEffectiveSampleMask(rasterizationSamples)));
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(p.PatchVertices));
// The default tessellation levels belong here for the same reason PatchVertices does:
// when a program has an evaluation stage and no control stage, both are compiled into the
@@ -4927,10 +4969,13 @@ void main() {
// The version only guards recomputing the hash - unchanged version, unchanged bytes.
const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion();
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount) {
m_pipelineStateHash = ComputePipelineStateHash(renderPassEntry.colorAttachmentCount);
m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount ||
m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) {
m_pipelineStateHash =
ComputePipelineStateHash(renderPassEntry.colorAttachmentCount, renderPassEntry.sampleCount);
m_pipelineStateHashVersion = renderStateVersion;
m_pipelineStateHashColorCount = renderPassEntry.colorAttachmentCount;
m_pipelineStateHashSampleCount = renderPassEntry.sampleCount;
m_pipelineStateHashValid = true;
}
const Uint64 pipelineStateHash = m_pipelineStateHash;
@@ -5199,6 +5244,8 @@ void main() {
.sampleShadingEnable = m_sampleRateShadingFeatureEnabled &&
MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleShading),
.minSampleShading = MG_State::pGLContext->GetMinSampleShadingValue(),
// Word 1 keeps its all-ones initialiser: GL has no state for samples 32..63.
.sampleMask = {ResolveEffectiveSampleMask(renderPassEntry.sampleCount), 0xffffffffu},
.subpass = 0,
.topology = vkTopology,
.primitiveRestartEnable = primitiveRestartEnabled,
@@ -5533,18 +5580,32 @@ void main() {
}
}
// Dual-source blending (GL_SRC1_* factors from glBlendFunc paired with
// glBindFragDataLocationIndexed) requires the dualSrcBlend device feature. It is detected at
// device creation and surfaced in the POST; if a shader actually issues a draw with a SRC1
// factor on a device that lacks it, there is no fallback, so hard-fail here at use time
// rather than silently mistranslating the blend equation.
if (effectiveBlendEnabled && !m_dualSrcBlendFeatureEnabled &&
// glBindFragDataLocationIndexed) requires the dualSrcBlend device feature. It is detected
// at device creation and surfaced in the POST; there is no fallback that BLENDS correctly,
// so a draw that asks for a SRC1 factor on a device without the feature gets the blend
// DECLINED - this attachment is baked with blending off and neutral One/Zero factors, and
// the loss is logged once. Both the factors AND the enable have to be neutralised:
// VUID-VkPipelineColorBlendAttachmentState-srcColorBlendFactor-00608 and its three
// siblings forbid a VK_BLEND_FACTOR_SRC1_* in the struct without the feature whatever
// blendEnable says, so clearing only the enable would still be invalid pipeline state.
// The previous behaviour, throwing, took the whole process down over one unsupported
// blend factor; this is defined, survivable and visible in the log, and it matches what
// the non-blendable-format arm above already does.
if (!m_dualSrcBlendFeatureEnabled &&
(IsDualSourceBlendFactor(srcRGB) || IsDualSourceBlendFactor(dstRGB) ||
IsDualSourceBlendFactor(srcAlpha) || IsDualSourceBlendFactor(dstAlpha))) {
THROW_EXCEPTION(
"Dual-source blending (GL_SRC1_* blend factor) was used on color attachment " +
std::to_string(i) +
", but the Vulkan device does not support the dualSrcBlend feature (see the "
"dualSrcBlend row in the driver POST). No fallback exists; the draw cannot proceed.");
MGLOG_E_ONCE(
"GetOrCreatePipeline: dual-source blending (GL_SRC1_* blend factor) was requested on "
"color attachment %u, but the Vulkan device does not support the dualSrcBlend feature "
"(see the dualSrcBlend row in the driver POST). Blending is DECLINED on that "
"attachment - the fragment's first output is written unblended and the second source "
"is dropped (program=%u)",
i, program.GetExternalIndex());
effectiveBlendEnabled = false;
srcRGB = BlendFactor::One;
dstRGB = BlendFactor::Zero;
srcAlpha = BlendFactor::One;
dstAlpha = BlendFactor::Zero;
}
payload.colorBlendAttachments[i] = MakeColorBlendAttachmentState(
effectiveBlendEnabled,
@@ -6047,6 +6108,16 @@ void main() {
snap.programFactoryEpoch = m_programFactory->GetCacheStructureEpoch();
}
const auto& programObj = *programObjPtr;
// Pinned for BeginXfbCaptureForDraw, which otherwise decides from GL state alone and has
// no way to know the bound pipeline's last pre-rasterization module lost (or never got)
// its Xfb execution mode. See VkProgramObject::xfbCaptureDeclined.
m_currentDrawXfbCaptureDeclined = programObj.xfbCaptureDeclined;
// A refused program cannot reach here today - the full path refuses before it ever
// records a snapshot - but declining the fast path costs one compare and means the
// refusal does not depend on that ordering staying true.
if (programObj.pointSizeCapabilityUnsupported) {
return false;
}
// The pipeline and the vertex-input pre-flight depend on the VAO only through
// its resolved LAYOUT (layoutHash folds the attribute formats, bindings and the
@@ -6209,10 +6280,13 @@ void main() {
// instead of missing forever on a monotonic version. A miss falls through
// to the full lookup.
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
m_pipelineStateHashColorCount != snap.renderPassColorCount) {
m_pipelineStateHash = ComputePipelineStateHash(snap.renderPassColorCount);
m_pipelineStateHashColorCount != snap.renderPassColorCount ||
m_pipelineStateHashSampleCount != snap.renderPassSampleCount) {
m_pipelineStateHash =
ComputePipelineStateHash(snap.renderPassColorCount, snap.renderPassSampleCount);
m_pipelineStateHashVersion = renderStateVersion;
m_pipelineStateHashColorCount = snap.renderPassColorCount;
m_pipelineStateHashSampleCount = snap.renderPassSampleCount;
m_pipelineStateHashValid = true;
}
const auto memoTransformFlags =
@@ -6447,6 +6521,16 @@ void main() {
}
}
const auto& programObj = *resolvedProgramObj;
// Pinned for BeginXfbCaptureForDraw, which otherwise decides from GL state alone and has
// no way to know the bound pipeline's last pre-rasterization module lost (or never got)
// its Xfb execution mode. See VkProgramObject::xfbCaptureDeclined.
m_currentDrawXfbCaptureDeclined = programObj.xfbCaptureDeclined;
// The build already said why, once, naming the program and the stage. Refusing here -
// before any pipeline is built from it - is what makes that message a decline rather
// than a note attached to invalid usage the driver still receives.
if (programObj.pointSizeCapabilityUnsupported) {
return false;
}
// For the snapshot's memoised entry pointer: if anything below inserts into the
// program cache (blit/aux program compiles), the epoch moves and the snapshot
// stores no pointer for this draw - the fast path then re-looks-up once.
@@ -6485,11 +6569,31 @@ void main() {
const Uint64 programLifetimeId = program.GetLifetimeId();
const Uint32 programVersion = program.GetBackendStateVersion();
const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration();
// The bind generation alone stopped covering this set the moment ResolveSampledBinding
// started asking SamplesAsIncompleteTexture: membership now depends on the effective
// sampler PARAMETERS (MIN_FILTER decides whether the mip chain is read at all) and on
// the texture SHAPE, and neither moves the bind generation. A texture that flips
// incomplete -> complete under a fixed binding - one glTexParameteri, one
// glSamplerParameteri, a BASE_LEVEL/MAX_LEVEL change, or an upload that fills the
// chain - would keep replaying the FALLBACK out of this memo, so the real texture
// never got its pre-pass sync, its pending-clear materialisation or its sampled-layout
// transition, and the descriptor path would then transition it from INSIDE the open
// render pass, which the subpass declares no self-dependency for.
//
// The sampling-resolution generation is exactly the counter for that family and is
// deliberately coarse (any texture, any sampler), so this one term covers every input
// the predicate reads that the bind generation does not: TextureObjectBase::
// BumpShapeVersion and SamplerObject::BumpVersion both bump it, while WHICH sampler
// object a unit carries goes through TextureUnit::SetSamplerObject and moves the bind
// generation instead. Same term the SetupDrawSnapshot fast path and the LOD memo
// already carry.
const Uint64 samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();
const Bool sampledSetUnchanged =
m_lastSampledSetValid && m_lastSampledSetProgramLifetimeId == programLifetimeId &&
m_lastSampledSetProgramVersion == programVersion &&
m_lastSampledSetTransformFlags == transformFlags &&
m_lastSampledSetBindGeneration == bindGeneration;
m_lastSampledSetBindGeneration == bindGeneration &&
m_lastSampledSetSamplingGeneration == samplingGeneration;
if (!sampledSetUnchanged) {
const Bool hasSampledTextures = m_uniformManager->CollectSampledTextures(
program, programObj, sampledTextures, &m_sampledBindingRecordsScratch);
@@ -6499,6 +6603,7 @@ void main() {
m_lastSampledSetProgramVersion = programVersion;
m_lastSampledSetTransformFlags = transformFlags;
m_lastSampledSetBindGeneration = bindGeneration;
m_lastSampledSetSamplingGeneration = samplingGeneration;
}
// Complete a freshly-made LOD decision (see above): its params sum
// can only be taken once the sampled set is known. A genuine
@@ -6544,9 +6649,21 @@ void main() {
}
auto* textureResource = m_textureManager->SyncTextureAndGetDescriptor(*sampledTexture);
MOBILEGL_ASSERT(textureResource != nullptr,
"%s: SyncTextureAndGetDescriptor failed for textureId=%d",
__func__, sampledTexture->GetExternalIndex());
if (textureResource == nullptr) {
// SyncTextureAndGetDescriptor has a real failure channel - an incomplete or
// otherwise unbackable texture declines and returns nullptr with its own log
// line - and the assert that used to be the only guard here is compiled out of
// every build past DEBUG. The next line dereferenced it, so a sampler left
// pointing at a texture GL calls incomplete was a SIGSEGV inside SetupDraw
// rather than a degraded draw. Leave the slot null and carry on: the descriptor
// resolve substitutes the fallback texture for exactly these bindings
// (ResolveSamplerDescriptor's SamplesAsIncompleteTexture branch), and the fast
// path at the top of SetupDraw already treats a null resource as "re-resolve".
MGLOG_E_ONCE("SetupDraw: no texture resource for sampled textureId=%d; leaving the binding to the "
"descriptor resolve's fallback",
sampledTexture->GetExternalIndex());
continue;
}
sampledResources[sampledIndex] = textureResource;
MGLOG_D("SetupDraw: sampled textureId=%d layout(before)=%s(%d)",
sampledTexture->GetExternalIndex(), VkImageLayoutToString(textureResource->layout),
@@ -6610,9 +6727,14 @@ void main() {
MOBILEGL_ASSERT(ready, "%s: TransitionTextureForSampling failed for textureId=%d",
__func__, sampledTexture->GetExternalIndex());
auto* transitionedResource = m_textureManager->SyncTextureAndGetDescriptor(*sampledTexture);
MOBILEGL_ASSERT(transitionedResource != nullptr,
"%s: post-transition SyncTextureAndGetDescriptor failed for textureId=%d",
__func__, sampledTexture->GetExternalIndex());
if (transitionedResource == nullptr) {
// Same declined-sync channel as the first loop, and the same reason not to
// dereference it: StampResourceRecordingUse below takes a reference.
MGLOG_E_ONCE("SetupDraw: no texture resource after transitioning sampled textureId=%d; leaving the "
"binding to the descriptor resolve's fallback",
sampledTexture->GetExternalIndex());
continue;
}
// Pre-pass stream bookkeeping: the draw about to be recorded reads
// this image, so later out-of-pass work on it can no longer jump
// ahead of the recording.
@@ -6780,6 +6902,7 @@ void main() {
snap.drawUsesDepthStencil = drawUsesDepthStencil;
snap.renderPassExtent = renderPassEntry->extent;
snap.renderPassColorCount = renderPassEntry->colorAttachmentCount;
snap.renderPassSampleCount = renderPassEntry->sampleCount;
snap.pipeline = pipeline;
// The layout identity the fast path's aux-memo compare answers against.
// A memo hit here, not a rebuild: the pre-flight above resolved this
@@ -7579,7 +7702,8 @@ void main() {
Bool VulkanRenderer::ClearDepthSliceWithRenderPass(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture, Uint32 mipLevel,
Uint32 depthSlice, const VkClearValue& clearValue) {
Uint32 depthSlice, const VkClearValue& clearValue,
VkImageLayout finalLayout) {
auto* resource = m_textureManager->SyncTextureAndGetDescriptor(texture);
if (resource == nullptr || resource->image == VK_NULL_HANDLE) return false;
if (m_frameContext.GetCurrentFrameIndex() >= m_deferredDepthMipmapCleanup.size()) return false;
@@ -7592,7 +7716,11 @@ void main() {
VkAttachmentDescription colorAttachment{};
colorAttachment.format = resource->format;
colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
// The image's own count, not a hardcoded one: a render-pass attachment must match the
// image it is given (VUID-VkFramebufferCreateInfo-pAttachments-00880), and this helper is
// now also the multisample path - a multisample image carries no TRANSFER_DST usage, so a
// load-op clear is the only legal way to clear it at all.
colorAttachment.samples = resource->sampleCount;
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
@@ -7600,7 +7728,7 @@ void main() {
colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
// Hand the slice back in the layout the caller already tracks for the whole image, so its
// closing barrier stays truthful and resource->layout is never touched from in here.
colorAttachment.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
colorAttachment.finalLayout = finalLayout;
VkAttachmentReference colorRef{};
colorRef.attachment = 0;
@@ -7668,9 +7796,26 @@ void main() {
"MaterializePendingClearForTexture requires no active render pass on the target buffer");
auto* resource = m_textureManager->SyncTextureAndGetDescriptor(texture);
MOBILEGL_ASSERT(resource != nullptr,
"MaterializePendingClearForTexture: SyncTextureAndGetDescriptor failed for textureId=%d",
texture.GetExternalIndex());
if (resource == nullptr) {
// Declined sync (an incomplete texture, say). Nothing to clear into, and every line
// below dereferences this - the assert that used to stand here is compiled out of
// every build past DEBUG.
MGLOG_E_ONCE("MaterializePendingClearForTexture: no texture resource for textureId=%d; the queued clears "
"stay queued",
texture.GetExternalIndex());
return false;
}
// A multisample image is not a transfer target: SyncTextureResource deliberately withholds
// TRANSFER_DST/TRANSFER_SRC from every one of them, so the vkCmdClearColorImage below -
// and the TRANSFER_DST transition ahead of it - are invalid usage
// (VUID-vkCmdClearColorImage-image-00002) on exactly the shape a
// glClearBufferfv-then-sample sequence produces. Clear it the one way that is legal at
// any sample count instead: a throwaway render pass whose whole content is its load-op
// clear, which is also what the 3D-slice case below already does.
if (resource->sampleCount != VK_SAMPLE_COUNT_1_BIT) {
return MaterializeMultisamplePendingClear(commandBuffer, texture, *resource, pendingClears);
}
VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
VkAccessFlags srcAccessMask = 0;
@@ -7810,6 +7955,77 @@ void main() {
return true;
}
Bool VulkanRenderer::MaterializeMultisamplePendingClear(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture,
VkTextureManager::TextureResource& resource,
const Vector<PendingClearEntry>& pendingClears) {
// Colour only. GL can queue a depth/stencil clear on a multisample texture too, and the
// load-op idiom would serve it just as well, but this helper attaches its view as a
// COLOUR attachment; declining is honest and leaves the queue intact for a later path.
if ((resource.aspect & VK_IMAGE_ASPECT_COLOR_BIT) == 0) {
MGLOG_E_ONCE("MaterializeMultisamplePendingClear: textureId=%d is a multisample depth/stencil texture; "
"its queued clear cannot be materialised out of a render pass yet",
texture.GetExternalIndex());
return false;
}
Bool allCleared = true;
for (const auto& pendingClear : pendingClears) {
if (pendingClear.key.mipLevel >= resource.mipLevels) {
MGLOG_E_ONCE("MaterializeMultisamplePendingClear: textureId=%d pending clear mip=%u out of range %u",
texture.GetExternalIndex(), pendingClear.key.mipLevel, resource.mipLevels);
allCleared = false;
continue;
}
auto clearPayload = pendingClear.payload;
PreCompensateSrgbClearColor(clearPayload, resource.format);
VkClearValue clearValue{};
clearValue.color = MakeVkClearColorValue(clearPayload, ColorFormatLacksAlpha(&texture));
// A multisample texture has exactly one level and, for the 2D target, one layer; the
// array target's layers are cleared one at a time, which is what this helper's
// per-layer view gives us.
const Uint32 firstLayer = pendingClear.key.baseArrayLayer;
const Uint32 layerCount = std::max(pendingClear.key.layerCount, 1u);
for (Uint32 layer = firstLayer; layer < firstLayer + layerCount; ++layer) {
if (layer >= resource.arrayLayers) break;
// COLOR_ATTACHMENT_OPTIMAL, not TRANSFER_DST: the image never has transfer usage,
// and a render target is where it came from and where it is going.
if (!ClearDepthSliceWithRenderPass(commandBuffer, texture, pendingClear.key.mipLevel, layer,
clearValue, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL)) {
MGLOG_E_ONCE("MaterializeMultisamplePendingClear: textureId=%d layer %u could not be cleared",
texture.GetExternalIndex(), layer);
allCleared = false;
}
}
}
if (!allCleared) {
return false;
}
// The load-op clear left every touched layer in COLOR_ATTACHMENT_OPTIMAL (each pass's
// finalLayout), so that - not the tracked layout on entry - is what the closing barrier
// has to start from.
// TransitionImageLayout takes the tracked layout by reference and updates it, so seeding
// it is both how the barrier learns its source and how resource->layout ends up right.
resource.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
const Bool ok = VkTextureManager::TransitionImageLayout(
commandBuffer, resource.image, resource.layout,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_ALL_GRAPHICS_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT,
resource.aspect, 0, resource.mipLevels);
if (!ok) {
MGLOG_E_ONCE("MaterializeMultisamplePendingClear: failed to transition textureId=%d to the sampled layout",
texture.GetExternalIndex());
return false;
}
m_clearManager->PopPendingClear(&texture);
MGLOG_D("MaterializeMultisamplePendingClear: textureId=%d pending clear materialised through a load-op pass",
texture.GetExternalIndex());
return true;
}
Bool VulkanRenderer::MaterializePendingClearForRenderbuffer(
VkCommandBuffer commandBuffer, const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbuffer) {
if (renderbuffer == nullptr) {
@@ -10919,6 +11135,19 @@ void main() {
if (!program || program->GetTransformFeedbackVaryingCount() == 0) {
return false;
}
// The bound pipeline's last pre-rasterization stage has to have been declared with Xfb
// (VUID-vkCmdBeginTransformFeedbackEXT-None-04128). Everything above this line reads GL
// state, which cannot answer that: a program can be built as a capture variant and still
// end up with a module carrying no Xfb mode - the clip/XFB validation backstop rewinding
// past the decoration, or XfbCaptureDecoratePass resolving none of the requested varyings
// and changing nothing. Declining the span leaves the capture buffers untouched, which is
// the same nothing the driver would have written, without the undefined behaviour.
if (m_currentDrawXfbCaptureDeclined) {
MGLOG_E_ONCE("BeginXfbCaptureForDraw: declining the capture span - the bound program's last "
"pre-rasterization stage carries no Xfb execution mode, so recording one would be "
"undefined behaviour rather than a capture");
return false;
}
const SizeT bufferCount = std::min<SizeT>(program->GetTransformFeedbackBufferCount(), 4);
if (bufferCount == 0) {
return false;
@@ -12977,6 +13206,18 @@ void main() {
: supportedDeviceFeatures.robustBufferAccess;
deviceFeatures.geometryShader = supportedDeviceFeatures.geometryShader;
deviceFeatures.tessellationShader = supportedDeviceFeatures.tessellationShader;
// gl_PointSize is an ORDINARY per-vertex output in desktop GL - a tessellation
// evaluation or geometry shader may write it, and a program may capture it by name -
// but in Vulkan the PointSize built-in is only usable from those two stages when this
// feature is on (VUID-RuntimeSpirv-PointSize-06439; SPIR-V spells the requirement as
// the TessellationPointSize / GeometryPointSize capabilities, which glslang emits from
// any such write). Left off, every one of those programs is invalid usage that a lenient
// driver silently gives an undefined point size and a strict one faults on. Nothing here
// asks for it speculatively: the feature is taken only where the device advertises it.
deviceFeatures.shaderTessellationAndGeometryPointSize =
supportedDeviceFeatures.shaderTessellationAndGeometryPointSize;
m_tessellationAndGeometryPointSizeFeatureEnabled =
deviceFeatures.shaderTessellationAndGeometryPointSize == VK_TRUE;
// Sampled-read barriers may only name the shader stages whose device feature is
// actually enabled (VUID-vkCmdPipelineBarrier-srcStageMask-04090/-04091), so the
// mask is assembled here, next to the feature decision, and handed to consumers.
@@ -584,6 +584,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// needs no feature). Both cached at device creation and drive a hard-fail-at-draw when absent.
Bool m_dualSrcBlendFeatureEnabled = false;
Bool m_primitiveTopologyListRestartFeatureEnabled = false;
// shaderTessellationAndGeometryPointSize gates the PointSize built-in in a tessellation
// or geometry stage, which desktop GL treats as an ordinary per-vertex output (writable,
// and capturable by name through transform feedback). Cached at device creation and
// handed to ProgramFactory, which refuses a program whose tessellation or geometry module
// declares the matching SPIR-V capability while this is false - SetupDraw then skips its
// draws (VkProgramObject::pointSizeCapabilityUnsupported) rather than building a pipeline
// that is invalid usage.
Bool m_tessellationAndGeometryPointSizeFeatureEnabled = false;
// VK_EXT_custom_border_color. Vulkan's four predefined VkBorderColor values cover only
// transparent/opaque black and opaque white; GL_TEXTURE_BORDER_COLOR is an arbitrary vec4 (or
// an arbitrary ivec4/uvec4 through the "I" entry points). Without this extension a border
@@ -768,9 +776,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// version: the version is monotonic and bumps on every pipeline-state
// change, so an unchanged (version, colorAttachmentCount) proves the state
// bytes are unchanged and the hash can be reused without re-reading them.
Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount) const;
Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount,
VkSampleCountFlagBits rasterizationSamples) const;
// The effective GL_SAMPLE_MASK word for a draw at this rasterization sample count; see
// the definition for the GL-vs-Vulkan rule it reconciles. Shared by the pipeline payload
// and the pipeline-state memo word so the two cannot disagree.
Uint32 ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const;
Uint m_pipelineStateHashVersion = 0;
Uint32 m_pipelineStateHashColorCount = 0;
// The sample count the cached hash was computed at. A pipeline-state input now depends on
// it (the effective sample mask), so a draw that changes only the target's sample count
// has to recompute rather than reuse.
VkSampleCountFlagBits m_pipelineStateHashSampleCount = VK_SAMPLE_COUNT_1_BIT;
Uint64 m_pipelineStateHash = 0;
Bool m_pipelineStateHashValid = false;
// GetShaderTransformFlags memo. NOT pure in the pre-transform alone: the
@@ -812,7 +829,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Skip the per-draw CollectSampledTextures walk (~5% of the render thread) when the sampled
// texture SET is provably unchanged from the previous draw: same program (lifetime id +
// backend-state version, which covers sampler-uniform reassignment / relink) and transform
// flags, and no texture bind/unbind/delete since (GetTextureBindGeneration). On a hit,
// flags, no texture bind/unbind/delete since (GetTextureBindGeneration), and nothing that
// moves a texture's shape or a sampler's parameters since (GetSamplingResolutionGeneration
// - membership depends on mipmap-completeness, which both of those decide). On a hit,
// m_sampledTexturesScratch still holds the previous draw's list and steps 2-4 (feedback /
// layout probe / transition) re-run on it, so layout correctness is unaffected - only the GL
// walk is skipped. The program lifetime id (never reused, unlike the GL name) and the
@@ -823,6 +842,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 m_lastSampledSetProgramVersion = 0;
ProgramFactory::CompileOptionFlags m_lastSampledSetTransformFlags = {};
Uint64 m_lastSampledSetBindGeneration = 0;
Uint64 m_lastSampledSetSamplingGeneration = 0;
// Set from the draw's resolved VkProgramObject on both the full and the fast setup paths;
// read by BeginXfbCaptureForDraw, which has only GL state otherwise. See
// VkProgramObject::xfbCaptureDeclined.
Bool m_currentDrawXfbCaptureDeclined = false;
// Memo for the per-draw explicit-LOD-0 eligibility probe
// (ProgramSamplesOnlySingleLevelTextures): same key family as the
@@ -921,6 +945,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// probe the pipeline memo after a state change without re-fetching the
// render-pass entry (the pass itself is pinned by renderPassHash above).
Uint32 renderPassColorCount = 0;
// Pinned with the colour count and for the same reason: the fast path recomputes the
// pipeline-state value hash from the snapshot, and that hash reads the sample count.
VkSampleCountFlagBits renderPassSampleCount = VK_SAMPLE_COUNT_1_BIT;
VkPipeline pipeline = VK_NULL_HANDLE;
// layoutHash of the snapshotting draw's vertex-input state. The pipeline and
// the vertex-input pre-flight depend on the VAO only through this (plus the
@@ -1279,13 +1306,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1,
GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1,
GLenum filter);
// Clears one z slice of a VK_IMAGE_TYPE_3D colour image. See the call site in
// MaterializePendingClearForTexture for why a transfer clear cannot do this.
// Clears one layer of a colour image through a throwaway render pass whose entire content
// is its LOAD_OP_CLEAR. Two callers, both of which a transfer clear cannot serve: a z
// slice of a VK_IMAGE_TYPE_3D image (vkCmdClearColorImage cannot name one), and a
// MULTISAMPLE image (which carries no TRANSFER_DST usage at all). `finalLayout` is the
// layout the caller already tracks for the whole image, so this never has to touch
// resource->layout.
Bool ClearDepthSliceWithRenderPass(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture, Uint32 mipLevel,
Uint32 depthSlice, const VkClearValue& clearValue);
Uint32 depthSlice, const VkClearValue& clearValue,
VkImageLayout finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
Bool MaterializePendingClearForTexture(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture);
// The multisample arm of the above. Split out rather than branched inline because it
// shares none of the transfer path: a multisample image carries no TRANSFER_DST usage, so
// neither the TRANSFER_DST transition nor vkCmdClearColorImage is legal on one.
Bool MaterializeMultisamplePendingClear(VkCommandBuffer commandBuffer,
MG_State::GLState::ITextureObject& texture,
VkTextureManager::TextureResource& resource,
const Vector<PendingClearEntry>& pendingClears);
Bool MaterializePendingClearForRenderbuffer(
VkCommandBuffer commandBuffer,
const SharedPtr<MG_State::GLState::RenderbufferObject>& renderbuffer);
+32 -2
View File
@@ -146,6 +146,20 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_TRIANGLES: return static_cast<Uint64>(count / 3);
case GL_TRIANGLE_STRIP:
case GL_TRIANGLE_FAN: return count >= 3 ? static_cast<Uint64>(count - 2) : 0;
// Adjacency primitives (GL 4.6 core table 10.1). Only a geometry stage can consume
// them, and it is the ADJACENT-free primitive count that reaches it: 4 vertices per
// line, 6 per triangle, one per step for the strips. Answering 0 here - which is what
// the default arm did - made AccountTransformFeedbackPrimitives bail before it had
// recorded anything, so an adjacency capture advanced neither the captured-vertex
// counter the scattered-capture path is bounded by nor the geometry-capture-draw flag
// that routes the transform feedback queries to the driver's own counter.
case GL_LINES_ADJACENCY: return static_cast<Uint64>(count / 4);
case GL_LINE_STRIP_ADJACENCY: return count >= 4 ? static_cast<Uint64>(count - 3) : 0;
case GL_TRIANGLES_ADJACENCY: return static_cast<Uint64>(count / 6);
case GL_TRIANGLE_STRIP_ADJACENCY: return count >= 6 ? static_cast<Uint64>((count - 4) / 2) : 0;
// GL_PATCHES is deliberately absent: the tessellator's amplification is not knowable
// on the CPU, and answering 0 is what defers GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
// to the driver's own counter, which is the only correct source for a patch capture.
default: return 0;
}
}
@@ -172,11 +186,17 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_LINES:
case GL_LINE_STRIP:
case GL_LINE_LOOP:
// An adjacency primitive delivers the same line/triangle to the geometry stage; the
// adjacent vertices are context, not part of the primitive.
case GL_LINES_ADJACENCY:
case GL_LINE_STRIP_ADJACENCY:
verticesPerPrimitive = 2;
break;
case GL_TRIANGLES:
case GL_TRIANGLE_STRIP:
case GL_TRIANGLE_FAN:
case GL_TRIANGLES_ADJACENCY:
case GL_TRIANGLE_STRIP_ADJACENCY:
verticesPerPrimitive = 3;
break;
default:
@@ -381,11 +401,21 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_POINTS:
compatible = mode == GL_POINTS;
break;
// The adjacency modes belong here too (GL 4.6 core table 13.1, ES 3.2 table 12.1).
// This arm is only reached when the program has NO geometry or tessellation
// evaluation stage, and without a geometry stage the adjacent vertices are simply
// ignored (GL 4.6 core 10.1) - the primitive assembled IS a plain line or triangle,
// so the combination is legal and must capture. Omitting them raised a spurious
// GL_INVALID_OPERATION and dropped the draw entirely, leaving the capture buffer
// with its pre-draw bytes. The geometry-stage input table above already carries the
// same four arms; this is the second table catching up with it.
case GL_LINES:
compatible = mode == GL_LINES || mode == GL_LINE_STRIP || mode == GL_LINE_LOOP;
compatible = mode == GL_LINES || mode == GL_LINE_STRIP || mode == GL_LINE_LOOP ||
mode == GL_LINES_ADJACENCY || mode == GL_LINE_STRIP_ADJACENCY;
break;
case GL_TRIANGLES:
compatible = mode == GL_TRIANGLES || mode == GL_TRIANGLE_STRIP || mode == GL_TRIANGLE_FAN;
compatible = mode == GL_TRIANGLES || mode == GL_TRIANGLE_STRIP || mode == GL_TRIANGLE_FAN ||
mode == GL_TRIANGLES_ADJACENCY || mode == GL_TRIANGLE_STRIP_ADJACENCY;
break;
default:
break;
+34 -9
View File
@@ -64,21 +64,43 @@ namespace MobileGL::MG_Impl::GLImpl {
// and none of them has any state beyond "which object is counting".
UnorderedMap<GLenum, GLuint> g_activePipelineStatisticsQueryIds;
// Whether MobileGL puts GL_ARB_tessellation_shader in its extension string. Read from the
// ADVERTISED list rather than from a capability bit for the same reason
// BackendSupportsTextureViews does (GL_Texture.cpp): it makes "MobileGL claims tessellation
// support" and "the tessellation-conditional API surface is open" the same fact by
// construction, so the day a backend starts advertising the string the surface below opens
// with it and no second edit is owed.
Bool AdvertisesTessellationShaderExtension() {
const auto& activeBackendObject = MG_Backend::pActiveBackendObject;
if (!activeBackendObject) return false;
const auto& extensions = activeBackendObject->GetRendererInfo().RendererGLInfo.Extensions;
return std::find(extensions.begin(), extensions.end(), E_GL_ARB_tessellation_shader) != extensions.end();
}
// The eleven pipeline-statistics counters (GL 4.6 core table 4.3 / ARB_pipeline_statistics_query).
// A 4.6 core context has to ACCEPT all of them at glBeginQuery - the extension is core
// since 4.6 and there is no query by which an application could learn otherwise before
// calling. MobileGL instruments none of them, and says so the way GL 4.6 core 4.2.1
// provides for: GL_QUERY_COUNTER_BITS answers zero for these targets, which is the
// spec's own signal that the counter is unsupported and its results indeterminate. That
// is an honest zero, not an advertised capability - the alternative, GL_INVALID_ENUM on a
// core entry point, is both non-conformant AND less informative.
// A 4.6 core context ACCEPTS the nine unconditional ones at glBeginQuery - there is no query
// by which an application could learn otherwise before calling. MobileGL instruments none of
// them, and says so the way GL 4.6 core 4.2.1 provides for: GL_QUERY_COUNTER_BITS answers
// zero for these targets, which is the spec's own signal that the counter is unsupported and
// its results indeterminate. That is an honest zero, not an advertised capability - the
// alternative, GL_INVALID_ENUM on a core entry point, is both non-conformant AND less
// informative.
//
// The two TESSELLATION targets are the exception, because ARB_pipeline_statistics_query
// makes them conditional on tessellation support rather than unconditional, and the only
// thing an application (or the conformance suite) can read to decide whether an
// implementation has it is the GL_ARB_tessellation_shader string. MobileGL does not emit it
// today, so these two answer GL_INVALID_ENUM: an API surface that accepts a
// tessellation-conditional token while withholding the string that announces the condition
// is self-contradictory, and it is the contradiction the suite catches
// (KHR-GL46.pipeline_statistics_query_tests_ARB.api_coverage_unsupported_calls, whose
// support probe is gl4cPipelineStatisticsQueryTests.cpp:1166-1176). The gate is the
// advertisement itself, not a hardcoded "no", so this is one switch and not two.
Bool IsPipelineStatisticsQueryTarget(GLenum target) {
switch (target) {
case GL_VERTICES_SUBMITTED:
case GL_PRIMITIVES_SUBMITTED:
case GL_VERTEX_SHADER_INVOCATIONS:
case GL_TESS_CONTROL_SHADER_PATCHES:
case GL_TESS_EVALUATION_SHADER_INVOCATIONS:
case GL_GEOMETRY_SHADER_INVOCATIONS:
case GL_GEOMETRY_SHADER_PRIMITIVES_EMITTED:
case GL_FRAGMENT_SHADER_INVOCATIONS:
@@ -86,6 +108,9 @@ namespace MobileGL::MG_Impl::GLImpl {
case GL_CLIPPING_INPUT_PRIMITIVES:
case GL_CLIPPING_OUTPUT_PRIMITIVES:
return true;
case GL_TESS_CONTROL_SHADER_PATCHES:
case GL_TESS_EVALUATION_SHADER_INVOCATIONS:
return AdvertisesTessellationShaderExtension();
default:
return false;
}
@@ -58,6 +58,9 @@ add_executable(MobileGLIntegrationTest
Scenarios/DrawParametersScenario.cpp
Scenarios/AsyncCompileScenario.cpp
Scenarios/XfbAfterClipDistanceScenario.cpp
Scenarios/UnwrittenPositionOutputScenario.cpp
Scenarios/SampleMaskScopeScenario.cpp
Scenarios/SampledSetStalenessScenario.cpp
Scenarios/ThreeChannelAttachmentScenario.cpp
Scenarios/SnormAttachmentScenario.cpp
Scenarios/PipelineFailureScenario.cpp
@@ -97,6 +100,8 @@ add_executable(MobileGLIntegrationTest
Scenarios/VertexAttribBindingScenario.cpp
Scenarios/XfbCaptureBufferReuseScenario.cpp
Scenarios/XfbPrimitiveQueryScenario.cpp
Scenarios/XfbRepeatedCaptureScenario.cpp
Scenarios/TessellationXfbCaptureScenario.cpp
Scenarios/VertexArrayEnableDisableScenario.cpp
Scenarios/CopyImageLevelRangeScenario.cpp
Scenarios/CopyImageLayeredScenario.cpp
@@ -115,6 +120,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/IntegerBorderColorScenario.cpp
Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp
Scenarios/RenderbufferBlendFormatScenario.cpp
Scenarios/DualSourceBlendScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -0,0 +1,238 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DualSourceBlendScenario.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 DUAL-SOURCE BLEND DRAW HAS TO SURVIVE ON EVERY DRIVER.
//
// GL_SRC1_COLOR / GL_ONE_MINUS_SRC1_COLOR / GL_SRC1_ALPHA / GL_ONE_MINUS_SRC1_ALPHA
// (ARB_blend_func_extended, core since 3.3) need a backend capability that not every device has:
// GL_EXT_blend_func_extended on the ES driver, or the dualSrcBlend device feature on Vulkan. When
// the capability IS there both backends translate the factors properly, and that has always
// worked. When it is NOT, both backends used to THROW_EXCEPTION at draw time - and
// MG_Util/Types.h's THROW_EXCEPTION is a plain `throw`, with no catch anywhere in MG_Impl or
// MG_Backend, so the exception unwound out through the C GL ABI and killed the process. An
// application asking for a blend factor the device cannot do is a picture problem, never a reason
// to take the process down.
//
// Both are now a DECLINE: the attachment is drawn with blending off and neutral One/Zero factors,
// and the loss is logged once. So a dual-source draw has exactly two defined outcomes, and this
// scenario pins that it lands on one of them and never on a crash:
//
// capability present - src0 * src1 + dst * (1 - src1)
// capability absent - src0, written straight through
//
// What each CI lane actually reaches: lavapipe has dualSrcBlend, so the DirectVulkan lane runs the
// whole sequence and measures the blend. Mesa's GLES front end on llvmpipe has no
// GL_EXT_blend_func_extended, so the ESSL stage carrying `layout(index = 1)` never compiles and the
// program renders nothing - the DirectGLES lane therefore SKIPS on the capability probe in SetUp
// rather than measuring a picture the driver never produced. The DECLINE arm itself - the path this
// scenario exists for - is unit-tested against stubbed capabilities in
// MG_Test/Framebuffer/FramebufferTest.cpp (DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensionIsMissing),
// which is the only place it can be reached without a driver that lacks the extension.
//
// The Vulkan half has a second edge the last case covers: the dual-source VUIDs
// (VUID-VkPipelineColorBlendAttachmentState-srcColorBlendFactor-00608 and its three siblings)
// forbid a VK_BLEND_FACTOR_SRC1_* anywhere in VkPipelineColorBlendAttachmentState without the
// feature, whatever blendEnable says - so leaving the factors in place while clearing the enable
// would still be invalid pipeline state.
#include <cstdint>
#include <string>
#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 kExtent = 16;
constexpr const char* kVertexSource = R"(#version 330 core
void main()
{
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0,-1.0, 0.0, 1.0); break;
case 3: gl_Position = vec4( 1.0,-1.0, 0.0, 1.0); break;
}
}
)";
// Two outputs on the SAME location, indices 0 and 1: the shader-side spelling of
// dual-source output (GLSL 3.30 4.4.2, the `index` layout qualifier). No
// glBindFragDataLocationIndexed needed, which keeps the program buildable through the
// harness's compile-and-link helper.
constexpr const char* kDualSourceFragmentSource = R"(#version 330 core
uniform vec4 uSrc0;
uniform vec4 uSrc1;
layout(location = 0, index = 0) out vec4 fragColor0;
layout(location = 0, index = 1) out vec4 fragColor1;
void main()
{
fragColor0 = uSrc0;
fragColor1 = uSrc1;
}
)";
class DualSourceBlendScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glGenRenderbuffers(1, &m_renderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kExtent, kExtent);
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, m_renderbuffer);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
std::string error;
m_program = CompileProgram(kVertexSource, kDualSourceFragmentSource, &error);
m_programError = error;
glViewport(0, 0, kExtent, kExtent);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
// Capability probe, not an assertion. A GL link that succeeded is not proof that
// the BACKEND can run the program: DirectGLES transpiles to ESSL lazily at first
// use, and GLSL ES has no `index` layout qualifier outside
// GL_EXT_blend_func_extended, so on a driver without it the stage never compiles
// and the draw renders nothing. One unblended white draw tells the two apart, and
// the cases skip rather than measure a picture the driver never produced.
if (m_program != 0) {
glDisable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ZERO);
Draw(/*src0=*/1.0f, /*src1=*/1.0f);
glFinish();
const Image probe = ReadPixels(kExtent, kExtent);
m_programRenders =
!probe.Empty() && static_cast<int>(probe.At(kExtent / 2, kExtent / 2).r) > 245;
}
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
void TearDown() override {
if (!Ready()) return;
glDisable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ZERO);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo);
if (m_renderbuffer != 0) glDeleteRenderbuffers(1, &m_renderbuffer);
if (m_program != 0) glDeleteProgram(m_program);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
}
void Draw(float src0, float src1) {
glUseProgram(m_program);
glUniform4f(glGetUniformLocation(m_program, "uSrc0"), src0, src0, src0, 1.0f);
glUniform4f(glGetUniformLocation(m_program, "uSrc1"), src1, src1, src1, 1.0f);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glUseProgram(0);
}
// Both cases share this gate: nothing below can be measured on a backend that cannot
// run a dual-source fragment program at all. Returns the skip reason, empty when the
// program runs - NOT a void helper that calls GTEST_SKIP itself, because GTEST_SKIP
// expands to a `return` and would leave only the HELPER, letting the case run its
// assertions anyway and report Failed instead of Skipped.
std::string WhyTheProgramCannotRun() const {
if (m_program == 0) {
return "this driver cannot build a dual-source fragment shader: " + m_programError;
}
if (!m_programRenders) {
return "this backend links a dual-source fragment program but renders nothing with it "
"(GLSL ES has no `index` layout qualifier without GL_EXT_blend_func_extended)";
}
return {};
}
GLuint m_renderbuffer = 0;
GLuint m_fbo = 0;
GLuint m_vao = 0;
unsigned int m_program = 0;
bool m_programRenders = false;
std::string m_programError;
};
} // namespace
// The whole point of the scenario: this sequence used to be a process kill on any device
// without the capability, and it has to be a picture either way.
//
// dst is black, src0 is white and src1 is mid-grey, with SRC1_COLOR / ONE_MINUS_SRC1_COLOR.
// blended = 1.0 * 0.5 + 0.0 * 0.5 = 0.5 -> ~128
// declined = 1.0 -> 255
// Anything else means the factors were mistranslated rather than either honoured or declined.
TEST_F(DualSourceBlendScenario, DualSourceBlendDrawProducesOneOfTheTwoDefinedResults) {
if (!Ready()) GTEST_SKIP();
if (const std::string reason = WhyTheProgramCannotRun(); !reason.empty()) GTEST_SKIP() << reason;
glDisable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ZERO);
Draw(/*src0=*/0.0f, /*src1=*/0.0f);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR);
EXPECT_EQ(FirstGLError(), 0u) << "glBlendFunc must accept the GL_SRC1_* factors - they are core since 3.3";
Draw(/*src0=*/1.0f, /*src1=*/0.5f);
glFinish();
glDisable(GL_BLEND);
EXPECT_EQ(FirstGLError(), 0u) << "the dual-source draw left a GL error behind";
const Image image = ReadPixels(kExtent, kExtent);
ASSERT_FALSE(image.Empty());
const Rgba8 centre = image.At(kExtent / 2, kExtent / 2);
const int red = static_cast<int>(centre.r);
const bool blended = red > 100 && red < 160;
const bool declined = red > 245;
EXPECT_TRUE(blended || declined)
<< "got " << centre << ", which is neither the dual-source blend (~128) nor the declined "
<< "straight-through source (255) - the SRC1 factors were mistranslated";
Gl().EndFrame();
}
// The same factors with blending DISABLED. Nothing may blend, and on the Vulkan side nothing
// may reach VkPipelineColorBlendAttachmentState carrying a VK_BLEND_FACTOR_SRC1_* on a device
// without dualSrcBlend - the VUIDs bind to the struct, not to blendEnable. The picture is the
// source either way, so this case is really "no crash, no error, no surprise".
TEST_F(DualSourceBlendScenario, DualSourceFactorsWithBlendingDisabledJustWriteTheSource) {
if (!Ready()) GTEST_SKIP();
if (const std::string reason = WhyTheProgramCannotRun(); !reason.empty()) GTEST_SKIP() << reason;
glDisable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ZERO);
Draw(/*src0=*/0.0f, /*src1=*/0.0f);
glBlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA);
Draw(/*src0=*/1.0f, /*src1=*/0.25f);
glFinish();
EXPECT_EQ(FirstGLError(), 0u) << "a draw with SRC1 factors and blending off left a GL error behind";
const Image image = ReadPixels(kExtent, kExtent);
ASSERT_FALSE(image.Empty());
const Rgba8 centre = image.At(kExtent / 2, kExtent / 2);
EXPECT_GT(static_cast<int>(centre.r), 245)
<< "got " << centre << ": blending is disabled, so the source has to be written straight through";
Gl().EndFrame();
}
} // namespace MGITest
@@ -0,0 +1,178 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SampleMaskScopeScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - GL_SAMPLE_MASK IS A MULTISAMPLE FRAGMENT OPERATION, SO IT DOES NOTHING AT ONE SAMPLE.
//
// GL 4.6 core 17.3.3 groups alpha-to-coverage, sample coverage and the sample mask together and
// says they make no change "if MULTISAMPLE is disabled, or if the value of SAMPLE_BUFFERS is not
// one". SAMPLE_BUFFERS is 0 for a single-sample framebuffer, so on one the mask is inert whatever
// glSampleMaski last wrote.
//
// Vulkan has no such rule. VkPipelineMultisampleStateCreateInfo::pSampleMask is ANDed with
// rasterization coverage at every rasterizationSamples, and at one sample that coverage is bit 0
// alone - so a mask with bit 0 clear discards every fragment of every primitive. Plumbing
// glSampleMaski straight into pSampleMask therefore turned an ordinary and legal GL sequence into
// a fully black draw:
//
// glEnable(GL_SAMPLE_MASK); glSampleMaski(0, 0x2); // while an MSAA target is bound
// ... render ...
// glBindFramebuffer(GL_FRAMEBUFFER, 0); draw a fullscreen quad to present
//
// Neither piece of state is per-framebuffer, so nothing resets it when the target changes, and
// dEQP/GL-CTS multisample cases leave exactly these masks behind. That is the MSAA-then-present
// shape every application uses.
//
// The cases below are single-sample by construction (the scenario harness's colour FBO), so each
// one asserts that the mask changed nothing.
#include <string>
#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 kFboSize = 32;
constexpr const char* kQuadVertexSource = R"(#version 430 core
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
constexpr const char* kGreenFragmentSource = R"(#version 430 core
out vec4 o_color;
void main() {
o_color = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
class SampleMaskScopeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_target = MakeColorFbo(kFboSize, kFboSize);
ASSERT_NE(m_target.fbo, 0u) << "could not create the render target";
glGenVertexArrays(1, &m_vao);
std::string error;
m_program = CompileProgram(kQuadVertexSource, kGreenFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
}
void TearDown() override {
if (!Ready()) return;
// Process-wide GL state: leaving it set would hand the next scenario in this
// process the very bug under test.
glDisable(GL_SAMPLE_MASK);
glSampleMaski(0, 0xFFFFFFFFu);
glBindVertexArray(0);
glUseProgram(0);
if (m_program != 0) glDeleteProgram(m_program);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
DestroyColorFbo(m_target);
ScenarioTest::TearDown();
}
void ExpectQuadStillPaints(const char* what) {
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindVertexArray(m_vao);
glUseProgram(m_program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
EXPECT_EQ(FirstGLError(), 0u) << what << ": the draw raised a GL error";
const Image image = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(image.Empty()) << what << ": the readback came back empty";
EXPECT_TRUE(RegionIsMostly(image, 0, kFboSize - 1, 0, kFboSize - 1, "green", 0.0, what))
<< what << ": an all-black target means the sample mask discarded every fragment, "
<< "which GL says it cannot do on a single-sample framebuffer";
}
ColorFbo m_target{};
GLuint m_vao = 0;
unsigned int m_program = 0;
};
} // namespace
// The exact reported shape: bit 0 clear, so the single sample of a single-sample target is
// masked off if the mask is applied at all.
TEST_F(SampleMaskScopeScenario, AMaskWithBitZeroClearDoesNotDiscardASingleSampleDraw) {
if (!Ready() || IsSkipped()) return;
glEnable(GL_SAMPLE_MASK);
glSampleMaski(0, 0x2);
ASSERT_EQ(FirstGLError(), 0u) << "setting the sample mask raised a GL error";
ExpectQuadStillPaints("GL_SAMPLE_MASK enabled with mask 0x2");
}
// Zero is the strongest form of the same thing, and the mask value the CTS's mask_zero cases
// set.
TEST_F(SampleMaskScopeScenario, AZeroMaskDoesNotDiscardASingleSampleDraw) {
if (!Ready() || IsSkipped()) return;
glEnable(GL_SAMPLE_MASK);
glSampleMaski(0, 0x0);
ASSERT_EQ(FirstGLError(), 0u) << "setting the sample mask raised a GL error";
ExpectQuadStillPaints("GL_SAMPLE_MASK enabled with mask 0");
}
// Control: the same mask word with the capability disabled has never had any effect, so this
// one passed before the fix too. It is here so a regression that ignores the enable bit
// instead of the sample count is still caught.
TEST_F(SampleMaskScopeScenario, ADisabledSampleMaskDoesNotDiscardASingleSampleDraw) {
if (!Ready() || IsSkipped()) return;
glDisable(GL_SAMPLE_MASK);
glSampleMaski(0, 0x0);
ASSERT_EQ(FirstGLError(), 0u) << "setting the sample mask raised a GL error";
ExpectQuadStillPaints("GL_SAMPLE_MASK disabled with mask 0");
}
// The mask is state, not a draw parameter, so a second draw after the first must not inherit
// a pipeline built while the memo word and the payload disagreed. Two draws either side of a
// mask change, both to the same single-sample target, both required to paint.
TEST_F(SampleMaskScopeScenario, ChangingTheMaskBetweenSingleSampleDrawsKeepsBothPainting) {
if (!Ready() || IsSkipped()) return;
glEnable(GL_SAMPLE_MASK);
glSampleMaski(0, 0xFFFFFFFFu);
ExpectQuadStillPaints("first draw, full mask");
glSampleMaski(0, 0x2);
ASSERT_EQ(FirstGLError(), 0u) << "changing the sample mask raised a GL error";
ExpectQuadStillPaints("second draw, mask 0x2");
}
// GL_MAX_SAMPLE_MASK_WORDS must be 1 on both backends: MobileGL stores one word and
// SampleMaski_State raises GL_INVALID_VALUE for any maskNumber above 0, so advertising more
// makes dEQP's per-case gluStateReset - which issues glSampleMaski up to the advertised count
// - fail every case. DirectGLES clamped; DirectVulkan forwarded the raw device limit.
TEST_F(SampleMaskScopeScenario, TheAdvertisedSampleMaskWordCountMatchesWhatSampleMaskiAccepts) {
if (!Ready() || IsSkipped()) return;
GLint words = 0;
glGetIntegerv(GL_MAX_SAMPLE_MASK_WORDS, &words);
ASSERT_EQ(FirstGLError(), 0u) << "querying GL_MAX_SAMPLE_MASK_WORDS raised a GL error";
EXPECT_EQ(words, 1) << "every word below the advertised count must be writable, and only word 0 is";
for (GLint word = 0; word < words; ++word) {
glSampleMaski(static_cast<GLuint>(word), 0xFFFFFFFFu);
EXPECT_EQ(FirstGLError(), 0u) << "glSampleMaski(" << word << ", ...) was refused although "
<< "GL_MAX_SAMPLE_MASK_WORDS advertises " << words << " words";
}
}
} // namespace MGITest
@@ -0,0 +1,231 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SampledSetStalenessScenario.cpp
// Copyright (c) 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 TEXTURE THAT BECOMES COMPLETE WITHOUT A REBIND MUST RE-ENTER THE SAMPLED SET.
//
// DirectVulkan does not bind a texture GL calls incomplete: it substitutes a fallback so the
// sampler reads (0,0,0,1) instead of losing the draw. That decision is made twice per draw - once
// by CollectSampledTextures, which builds the list SetupDraw syncs, materialises pending clears
// for and transitions to a sampled layout BEFORE the render pass opens, and once by the descriptor
// resolve inside the pass. Both ask SamplesAsIncompleteTexture.
//
// The per-draw memo that lets the first of those be skipped was keyed only on the program, the
// transform flags and the texture BIND generation. Completeness is not a function of any of them:
// it moves on a filter change (glTexParameteri / glSamplerParameteri), on a level-range change,
// and on an upload that fills the mip chain - none of which bind anything. So a texture that went
// incomplete -> complete under a fixed binding kept being answered out of the memo as "not in the
// set", and the work SetupDraw does for the set never happened for it:
//
// * its queued clear was never materialised, so the draw sampled pre-clear content - wrong
// pixels, no validation layer needed, which is what the case below detects; and
// * its layout transition moved into the descriptor resolve, which records
// vkCmdPipelineBarrier inside an already-open render pass whose subpass declares no
// self-dependency - the exact hazard CollectSampledTextures exists to prevent.
//
// The fix adds the sampling-resolution generation to that memo key, which is the counter the
// codebase already maintains for "what a unit resolves to changed without a bind" and which both
// TextureObjectBase::BumpShapeVersion and SamplerObject::BumpVersion move.
#include <cstddef>
#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 kFboSize = 32;
constexpr int kTexSize = 8;
constexpr const char* kQuadVertexSource = R"(#version 430 core
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
// texelFetch, not texture(): the point is WHICH image is sampled, and a fetch cannot be
// explained away by filtering.
constexpr const char* kSampleFragmentSource = R"(#version 430 core
uniform sampler2D u_tex;
out vec4 o_color;
void main() {
o_color = texelFetch(u_tex, ivec2(0, 0), 0);
}
)";
class SampledSetStalenessScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_target = MakeColorFbo(kFboSize, kFboSize);
ASSERT_NE(m_target.fbo, 0u) << "could not create the render target";
glGenVertexArrays(1, &m_vao);
std::string error;
m_program = CompileProgram(kQuadVertexSource, kSampleFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
// The sampled texture: ONE level, and no glTexParameteri at all, so MIN_FILTER
// keeps its initial GL_NEAREST_MIPMAP_LINEAR and GL calls it mipmap-incomplete.
glGenTextures(1, &m_texture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
std::vector<unsigned char> green(static_cast<std::size_t>(kTexSize * kTexSize * 4), 0);
for (std::size_t i = 0; i < green.size(); i += 4) {
green[i + 1] = 255;
green[i + 3] = 255;
}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kTexSize, kTexSize, 0, GL_RGBA, GL_UNSIGNED_BYTE,
green.data());
ASSERT_EQ(FirstGLError(), 0u) << "defining the sampled texture raised a GL error";
}
void TearDown() override {
if (!Ready()) return;
glBindVertexArray(0);
glUseProgram(0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, 0);
if (m_texture != 0) glDeleteTextures(1, &m_texture);
if (m_program != 0) glDeleteProgram(m_program);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
DestroyColorFbo(m_target);
ScenarioTest::TearDown();
}
// One draw of the fullscreen quad sampling texel (0,0) of whatever unit 0 holds, and
// NO readback. That matters: a readback submits and waits, which ends the command
// buffer and resets the per-draw memos with it - so a case that read back between its
// two draws would never leave a stale entry to catch. The two draws here have to land
// in one recording.
void DrawOnly() {
BindFbo(m_target);
glBindVertexArray(m_vao);
glUseProgram(m_program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
const GLint location = glGetUniformLocation(m_program, "u_tex");
if (location != -1) glUniform1i(location, 0);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
EXPECT_EQ(FirstGLError(), 0u) << "the sampling draw raised a GL error";
}
ColorFbo m_target{};
GLuint m_vao = 0;
GLuint m_texture = 0;
unsigned int m_program = 0;
};
} // namespace
// The full sequence, ordered so the ONLY state change between the two draws is the filter.
TEST_F(SampledSetStalenessScenario, AQueuedClearIsMaterialisedWhenAFilterChangeCompletesTheTexture) {
if (!Ready() || IsSkipped()) return;
// 1. Queue a clear on the texture through an FBO and take it straight back out, with no
// draw in between - the "attach -> clear -> detach" shape that leaves the clear
// pending for whoever samples the texture next.
GLuint clearFbo = 0;
glGenFramebuffers(1, &clearFbo);
glBindFramebuffer(GL_FRAMEBUFFER, clearFbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
const GLfloat red[4] = {1.0f, 0.0f, 0.0f, 1.0f};
glClearBufferfv(GL_COLOR, 0, red);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &clearFbo);
ASSERT_EQ(FirstGLError(), 0u) << "queueing the clear raised a GL error";
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
// 2. Draw while the texture is still incomplete. The backend substitutes its fallback,
// and the per-draw memo records the resulting sampled set.
DrawOnly();
// 3. Make it complete. No bind, no upload, no program change - one filter write, which is
// exactly the state the old memo key could not see.
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
ASSERT_EQ(FirstGLError(), 0u) << "changing the filter raised a GL error";
// 4. Draw again, into the same recording, and only now read back. The texture is in the
// sampled set now, so its queued clear has to be materialised before the pass opens and
// the fetch has to see RED. Reading the green the texture was uploaded with means the
// clear was never materialised, i.e. the texture never entered the set - the stale-memo
// bug. Black means the fallback was still being handed out.
DrawOnly();
const Image afterFlip = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(afterFlip.Empty()) << "the readback came back empty";
EXPECT_TRUE(RegionIsMostly(afterFlip, 0, kFboSize - 1, 0, kFboSize - 1, "red", 0.0,
"the draw after the completeness flip"))
<< "green means the queued clear was never materialised, so the texture never re-entered "
"the sampled set after the filter change; blue means the draw did not happen at all";
}
// The same flip driven from a SAMPLER OBJECT rather than the texture's own parameters. It is
// the other half of what feeds the completeness predicate, it moves the same generation, and
// it likewise binds nothing.
TEST_F(SampledSetStalenessScenario, AQueuedClearIsMaterialisedWhenASamplerObjectCompletesTheTexture) {
if (!Ready() || IsSkipped()) return;
GLuint sampler = 0;
glGenSamplers(1, &sampler);
// Bound BEFORE the first draw, still carrying the mipmapping default, so binding it is
// not what changes between the two draws.
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_LINEAR);
glBindSampler(0, sampler);
ASSERT_EQ(FirstGLError(), 0u) << "binding the sampler object raised a GL error";
GLuint clearFbo = 0;
glGenFramebuffers(1, &clearFbo);
glBindFramebuffer(GL_FRAMEBUFFER, clearFbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
const GLfloat red[4] = {1.0f, 0.0f, 0.0f, 1.0f};
glClearBufferfv(GL_COLOR, 0, red);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &clearFbo);
ASSERT_EQ(FirstGLError(), 0u) << "queueing the clear raised a GL error";
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
DrawOnly();
// One parameter write on an ALREADY-BOUND sampler object.
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
ASSERT_EQ(FirstGLError(), 0u) << "changing the sampler filter raised a GL error";
DrawOnly();
const Image afterFlip = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(afterFlip.Empty()) << "the readback came back empty";
EXPECT_TRUE(RegionIsMostly(afterFlip, 0, kFboSize - 1, 0, kFboSize - 1, "red", 0.0,
"the draw after the sampler-object flip"))
<< "green means the queued clear was never materialised after the sampler parameter change";
glBindSampler(0, 0);
glDeleteSamplers(1, &sampler);
}
} // namespace MGITest
@@ -0,0 +1,896 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/TessellationXfbCaptureScenario.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 - WHAT A TESSELLATION EVALUATION STAGE OWES A TRANSFORM FEEDBACK CAPTURE.
//
// XfbRepeatedCaptureScenario already pins that a capture from a GL_PATCHES draw records
// AT ALL. Everything below is the part of the same pipeline it does not reach, and every
// case here is the reduced form of a conformance body that fails on a device:
//
// * CAPTURING THE BUILT-INS BY NAME. glTransformFeedbackVaryings("gl_Position") /
// ("gl_PointSize") on a program whose last vertex-processing stage is the evaluation
// shader. Nothing in the tree captured a built-in from a tessellation stage, and the
// two backends reach it by completely different routes - DirectGLES has to name a
// real ESSL output on the driver's own glTransformFeedbackVaryings, DirectVulkan has
// to decorate a SPIR-V built-in that lives inside gl_PerVertex.
//
// * THE PER-VERTEX PAYLOAD THE CONTROL STAGE HANDS OVER. gl_PointSize and a
// user-declared per-vertex interface block, both read back out of gl_in[] by the
// evaluation stage and only then captured. This is the shape of
// KHR-GL4x.tessellation_shader.tessellation_control_to_tessellation_evaluation.
// gl_MaxPatchVertices_Position_PointSize, which is 216 of the ~240 conformance bodies
// the family still fails: gl_Position arrives, and everything travelling beside it in
// the same patch does not.
//
// The assertions are on the captured BYTES against a CPU-computed reference, never on the
// absence of a GL error: every failure this guards against is silent.
#include <cmath>
#include <cstring>
#include <string>
#include <utility>
#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 {
// Nothing a capture can legitimately produce, so a component that still reads it
// names the failure instead of looking like an ordinary numeric mismatch.
constexpr float kPoison = -987654.0f;
const char* const kFragmentSource = R"(#version 420 core
out vec4 fragColor;
void main()
{
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
)";
class TessellationXfbCaptureScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
ScenarioTest::TearDown();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
static GLint MaxPatchVertices() {
GLint value = 0;
glGetIntegerv(GL_MAX_PATCH_VERTICES, &value);
DrainErrors();
return value;
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, std::string>>& stages,
const std::vector<const char*>& varyings) {
m_buildLog.clear();
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (compiled == GL_FALSE) {
m_buildLog = InfoLog(shader, true) + "\n--- source ---\n" + source;
ok = false;
break;
}
}
GLuint program = 0;
if (ok) {
program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()), varyings.data(),
GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
program = 0;
}
}
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (program != 0) m_programs.push_back(program);
return program;
}
// One capture span over a single patch. Returns the capture buffer read back as
// floats; `capturedFloats` is the whole buffer, poison-filled beforehand.
std::vector<float> RunPatchCaptureSpan(GLuint program, GLenum captureMode, std::size_t capturedFloats) {
const std::vector<float> poison(capturedFloats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedFloats * sizeof(float)), poison.data(),
GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(captureMode);
glDrawArrays(GL_PATCHES, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> readback(capturedFloats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedFloats * sizeof(float)), readback.data());
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
return readback;
}
static ::testing::AssertionResult ComponentIs(const std::vector<float>& data, std::size_t index,
float expected, float epsilon = 1e-4f) {
if (index >= data.size()) {
return ::testing::AssertionFailure() << "component " << index << " is past the capture buffer";
}
const float actual = data[index];
if (actual == kPoison) {
return ::testing::AssertionFailure()
<< "component " << index << " still holds the poison value - the capture never reached "
<< "these bytes (expected " << expected << ")";
}
if (std::isnan(actual) || std::abs(actual - expected) > epsilon) {
return ::testing::AssertionFailure()
<< "component " << index << " is " << actual << ", expected " << expected;
}
return ::testing::AssertionSuccess();
}
// Defined below the shader builders it uses. `withPointSize` is the conformance
// body's own should_pass_pointsize_data axis.
void RunPerVertexPayloadCase(bool withPointSize);
// Why the gl_PointSize cases cannot be run here, or empty when they can.
//
// gl_PointSize from a tessellation stage is a real DRIVER capability on both
// targets - GL_EXT/OES_tessellation_point_size on an ES driver, the
// shaderTessellationAndGeometryPointSize feature on a Vulkan device - and desktop GL
// has no query that reports either, so this probes for it by running a program.
//
// The probe is deliberately NOT a gl_PointSize capture: it captures an ordinary user
// varying out of a tessellation evaluation stage that ALSO writes gl_PointSize, and
// compares that against the identical program without the write. A backend that
// cannot express the built-in loses the whole stage (DirectGLES fails to compile it
// and binds program 0; DirectVulkan cannot build the pipeline), so the plain varying
// comes back untouched too - which is a capability answer, not a capture answer. If
// BOTH come back untouched the probe itself is meaningless and it returns empty, so
// the cases run and FAIL rather than skipping on an unrelated breakage.
//
// Returns the reason as a string instead of skipping directly: GTEST_SKIP expands to
// a `return`, so a void helper would leave only the helper and let the case run its
// assertions anyway and report Failed instead of Skipped.
std::string WhyPointSizeCasesCannotRun();
// The geometry stage's own answer, and it has to BE its own answer: the two ESSL
// extensions are independent (Loader models them as two PointSizeTier fields fed by
// four distinct strings, and neither implies the other), so a driver with
// tessellation point size and no geometry point size passes the probe above and
// still cannot run the case below. Same two-program shape, one stage over.
//
// It also replaces a guard that could never fire: GL_MAX_GEOMETRY_OUTPUT_VERTICES is
// a hardcoded frontend constant (256) with no capability behind it, so "does this
// stack have a geometry stage at all" can only be answered by trying to build one -
// which is what this does, exactly as IoBlockNameCollisionScenario does for the same
// reason.
std::string WhyGeometryPointSizeCaseCannotRun();
std::vector<GLuint> m_programs;
std::string m_buildLog;
GLuint m_vao = 0;
};
// ---------------------------------------------------------------------------------
// Built-ins captured BY NAME from the evaluation stage.
// ---------------------------------------------------------------------------------
const char* const kMinimalVertexSource = R"(#version 420 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kMinimalTessControlSource = R"(#version 420 core
layout(vertices = 1) out;
void main()
{
gl_out[gl_InvocationID].gl_Position = gl_in[0].gl_Position;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
// Values no stale buffer would hold by accident. The two sources differ ONLY by
// gl_PointSize, so the pair isolates it: on a backend that lowers to ESSL the
// built-in is not even declared in a tessellation stage without
// GL_EXT_tessellation_point_size, and the whole shader then fails to compile.
const char* const kPositionTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw, point_mode) in;
void main()
{
gl_Position = vec4(11.0, 12.0, 13.0, 14.0);
}
)";
const char* const kPositionAndPointSizeTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw, point_mode) in;
void main()
{
gl_Position = vec4(11.0, 12.0, 13.0, 14.0);
gl_PointSize = 5.0;
}
)";
// The two probe programs. They differ by one statement; both capture `probe_value`,
// which has nothing to do with point size.
const char* const kPointSizeProbeTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw, point_mode) in;
out float probe_value;
void main()
{
probe_value = 42.0;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 3.0;
}
)";
const char* const kPointSizeFreeProbeTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw, point_mode) in;
out float probe_value;
void main()
{
probe_value = 42.0;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
std::string TessellationXfbCaptureScenario::WhyPointSizeCasesCannotRun() {
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const auto probeCaptures = [&](const char* tessEvalSource) {
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_TESS_CONTROL_SHADER, kMinimalTessControlSource},
{GL_TESS_EVALUATION_SHADER, tessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"probe_value"});
if (program == 0) return false;
const std::vector<float> captured = RunPatchCaptureSpan(program, GL_POINTS, 3);
DrainErrors();
return captured[0] == 42.0f;
};
const bool withPointSize = probeCaptures(kPointSizeProbeTessEvalSource);
if (withPointSize) return {};
if (!probeCaptures(kPointSizeFreeProbeTessEvalSource)) {
// The control failed too, so nothing here is about point size.
return {};
}
return "this backend cannot express gl_PointSize in a tessellation stage at all - the same "
"program captures an ordinary varying with the gl_PointSize write removed and captures "
"nothing with it present (an ES driver without GL_EXT/OES_tessellation_point_size, or a "
"Vulkan device without shaderTessellationAndGeometryPointSize)";
}
TEST_F(TessellationXfbCaptureScenario, CapturesGlPositionByNameFromTheEvaluationStage) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_TESS_CONTROL_SHADER, kMinimalTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPositionTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_Position"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
// point_mode with every level at 1 emits three points, all carrying the same
// constant; only the first record has to be right for the mechanism to be proven.
const std::vector<float> captured = RunPatchCaptureSpan(program, GL_POINTS, 4 * 3);
EXPECT_TRUE(ComponentIs(captured, 0, 11.0f));
EXPECT_TRUE(ComponentIs(captured, 1, 12.0f));
EXPECT_TRUE(ComponentIs(captured, 2, 13.0f));
EXPECT_TRUE(ComponentIs(captured, 3, 14.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
TEST_F(TessellationXfbCaptureScenario, CapturesGlPositionAndGlPointSizeByNameFromTheEvaluationStage) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
if (const std::string reason = WhyPointSizeCasesCannotRun(); !reason.empty()) GTEST_SKIP() << reason;
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_TESS_CONTROL_SHADER, kMinimalTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPositionAndPointSizeTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_Position", "gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
const std::vector<float> captured = RunPatchCaptureSpan(program, GL_POINTS, 5 * 3);
EXPECT_TRUE(ComponentIs(captured, 0, 11.0f));
EXPECT_TRUE(ComponentIs(captured, 1, 12.0f));
EXPECT_TRUE(ComponentIs(captured, 2, 13.0f));
EXPECT_TRUE(ComponentIs(captured, 3, 14.0f));
EXPECT_TRUE(ComponentIs(captured, 4, 5.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// ---------------------------------------------------------------------------------
// The per-vertex payload the control stage hands to the evaluation stage.
// ---------------------------------------------------------------------------------
// The conformance body's own shapes, reduced to one patch and parameterised by the
// output patch size so the caller can run the real GL_MAX_PATCH_VERTICES. The
// `withPointSize` axis is the conformance body's own `should_pass_pointsize_data`,
// which it varies together with point_mode - and which decides whether the whole
// program even involves the per-vertex built-in that ESSL gates behind an extension.
std::string PayloadVertexSource(bool withPointSize) {
return R"(#version 420 core
out gl_PerVertex {
vec4 gl_Position;
)" + std::string(withPointSize ? " float gl_PointSize;\n" : "") +
R"(};
void main()
{
}
)";
}
std::string PayloadTessControlSource(int outputVertices, bool withPointSize) {
const std::string perVertexTail = withPointSize ? " float gl_PointSize;\n" : "";
return R"(#version 420 core
layout(vertices = )" + std::to_string(outputVertices) +
R"() out;
in gl_PerVertex {
vec4 gl_Position;
)" + perVertexTail +
R"(} gl_in[gl_MaxPatchVertices];
out gl_PerVertex {
vec4 gl_Position;
)" + perVertexTail +
R"(} gl_out[];
out OUT_TC
{
vec2 value1;
ivec4 value2;
} result[];
void main()
{
)" + std::string(withPointSize
? " gl_out[gl_InvocationID].gl_PointSize = 1.0 / float(gl_InvocationID + 1);\n"
: "") +
R"( gl_out[gl_InvocationID].gl_Position = vec4(float(gl_InvocationID * 4 + 0), float(gl_InvocationID * 4 + 1),
float(gl_InvocationID * 4 + 2), float(gl_InvocationID * 4 + 3));
result[gl_InvocationID].value1 = vec2(1.0 / float(gl_InvocationID + 1), 1.0 / float(gl_InvocationID + 2));
result[gl_InvocationID].value2 = ivec4(gl_InvocationID + 1, gl_InvocationID + 2,
gl_InvocationID + 3, gl_InvocationID + 4);
gl_TessLevelInner[0] = 1.0;
gl_TessLevelInner[1] = 1.0;
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelOuter[3] = 1.0;
}
)";
}
// Deliberately NEVER writes gl_Position, exactly as the conformance shader does not:
// the redeclared block is there so the evaluation stage can READ gl_in[], and an
// output nothing stores is what UnwrittenPositionOutputScenario pins separately.
std::string PayloadTessEvalSource(int inputVertices, bool withPointSize) {
const std::string perVertexTail = withPointSize ? " float gl_PointSize;\n" : "";
return R"(#version 420 core
layout(isolines, equal_spacing, ccw, point_mode) in;
in gl_PerVertex {
vec4 gl_Position;
)" + perVertexTail +
R"(} gl_in[gl_MaxPatchVertices];
out gl_PerVertex {
vec4 gl_Position;
)" + perVertexTail +
R"(};
in OUT_TC
{
vec2 value1;
ivec4 value2;
} tc_data[];
)" + std::string(withPointSize ? "out float te_pointsize;\n" : "") +
R"(out vec4 te_position;
out vec2 te_value1;
out flat ivec4 te_value2;
void main()
{
)" + std::string(withPointSize ? " te_pointsize = 0.0;\n" : "") +
R"( te_position = vec4 (0.0);
te_value1 = vec2 (0.0);
te_value2 = ivec4(0);
for (int n = 0; n < )" + std::to_string(inputVertices) +
R"(; ++n)
{
)" + std::string(withPointSize ? " te_pointsize += gl_in [n].gl_PointSize;\n" : "") +
R"( te_position += gl_in [n].gl_Position;
te_value1 += tc_data[n].value1;
te_value2 += tc_data[n].value2;
}
}
)";
}
// The reduced conformance body. `withPointSize` selects between its two halves;
// everything else - one input vertex, an output patch of GL_MAX_PATCH_VERTICES, a
// user per-vertex block travelling beside gl_PerVertex, the capture taken off the
// evaluation stage - is the same on both.
void TessellationXfbCaptureScenario::RunPerVertexPayloadCase(bool withPointSize) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
if (withPointSize) {
if (const std::string reason = WhyPointSizeCasesCannotRun(); !reason.empty()) GTEST_SKIP() << reason;
}
const GLint patchVertices = MaxPatchVertices();
ASSERT_GE(patchVertices, 32) << "GL_MAX_PATCH_VERTICES is below the guaranteed minimum";
// One input vertex per patch, an output patch of GL_MAX_PATCH_VERTICES vertices:
// the control stage runs that many invocations and every one of them contributes.
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
std::vector<const char*> varyings = {"te_position", "te_value1", "te_value2"};
if (withPointSize) varyings.push_back("te_pointsize");
const GLuint program =
BuildCaptureProgram({{GL_VERTEX_SHADER, PayloadVertexSource(withPointSize)},
{GL_TESS_CONTROL_SHADER, PayloadTessControlSource(patchVertices, withPointSize)},
{GL_TESS_EVALUATION_SHADER, PayloadTessEvalSource(patchVertices, withPointSize)},
{GL_FRAGMENT_SHADER, kFragmentSource}},
varyings);
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
float referencePointSize = 0.0f;
float referencePosition[4] = {0.0f, 0.0f, 0.0f, 0.0f};
float referenceValue1[2] = {0.0f, 0.0f};
int referenceValue2[4] = {0, 0, 0, 0};
for (int n = 0; n < patchVertices; ++n) {
referencePointSize += 1.0f / static_cast<float>(n + 1);
for (int c = 0; c < 4; ++c) {
referencePosition[c] += static_cast<float>(n * 4 + c);
referenceValue2[c] += n + 1 + c;
}
referenceValue1[0] += 1.0f / static_cast<float>(n + 1);
referenceValue1[1] += 1.0f / static_cast<float>(n + 2);
}
// isolines with every level at 1 emits two points; the record stride is
// vec4 + vec2 + ivec4 [+ float] components.
const std::size_t stride = withPointSize ? 11 : 10;
const std::vector<float> captured = RunPatchCaptureSpan(program, GL_POINTS, stride * 4);
for (int c = 0; c < 4; ++c) {
EXPECT_TRUE(ComponentIs(captured, static_cast<std::size_t>(c), referencePosition[c], 1e-2f))
<< "te_position." << c << " (gl_in[].gl_Position)";
}
for (int c = 0; c < 2; ++c) {
EXPECT_TRUE(ComponentIs(captured, static_cast<std::size_t>(4 + c), referenceValue1[c], 1e-3f))
<< "te_value1." << c << " (the user per-vertex block the control stage wrote)";
}
for (int c = 0; c < 4; ++c) {
const std::size_t index = static_cast<std::size_t>(6 + c);
ASSERT_LT(index, captured.size());
int actual = 0;
std::memcpy(&actual, &captured[index], sizeof(actual));
EXPECT_EQ(actual, referenceValue2[c])
<< "te_value2." << c << " (the user per-vertex block's integer member)";
}
if (withPointSize) {
EXPECT_TRUE(ComponentIs(captured, 10, referencePointSize, 1e-3f))
<< "te_pointsize (gl_in[].gl_PointSize)";
}
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
TEST_F(TessellationXfbCaptureScenario, TheEvaluationStageSeesTheUserPerVertexBlockOfItsPatch) {
RunPerVertexPayloadCase(false);
}
// ---------------------------------------------------------------------------------
// The same built-in, one stage over.
// ---------------------------------------------------------------------------------
// ESSL gates gl_PointSize behind a per-stage extension in BOTH non-vertex
// vertex-processing stages - EXT/OES_tessellation_point_size for the two tessellation
// stages, EXT/OES_geometry_point_size for the geometry one - and they are separate
// extensions that do not imply each other, so the geometry arm is a second code path
// rather than the same one. Nothing else in the tree writes gl_PointSize from a geometry
// shader, so without this case the arm ships untested.
const char* const kPointSizeGeometrySource = R"(#version 420 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float gs_value;
void main()
{
gs_value = 7.0;
gl_Position = gl_in[0].gl_Position;
gl_PointSize = 4.0;
EmitVertex();
}
)";
// The control: identical but for the gl_PointSize write, so the pair answers "can this
// stack host a geometry stage that names the built-in" without asking anything about
// capture.
const char* const kPointSizeFreeGeometrySource = R"(#version 420 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float gs_value;
void main()
{
gs_value = 7.0;
gl_Position = gl_in[0].gl_Position;
EmitVertex();
}
)";
std::string TessellationXfbCaptureScenario::WhyGeometryPointSizeCaseCannotRun() {
const auto probeCaptures = [&](const char* geometrySource) {
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_GEOMETRY_SHADER, geometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gs_value"});
if (program == 0) return false;
const std::vector<float> poison(1, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(sizeof(float)), poison.data(),
GL_STATIC_DRAW);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
float captured = kPoison;
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, static_cast<GLsizeiptr>(sizeof(float)),
&captured);
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
DrainErrors();
return captured == 7.0f;
};
if (probeCaptures(kPointSizeGeometrySource)) return {};
if (!probeCaptures(kPointSizeFreeGeometrySource)) {
// The control failed too, so this stack cannot run a capturing geometry stage at
// all - which is not what this case is about, and is the question the dead
// GL_MAX_GEOMETRY_OUTPUT_VERTICES guard was trying to ask. Skipping rather than
// failing loses nothing: XfbRepeatedCaptureScenario pins plain geometry capture
// and goes red on its own if that is what actually broke.
return "this backend cannot capture from a geometry stage at all, with or without gl_PointSize";
}
return "this backend cannot express gl_PointSize in a geometry stage - the same program captures an "
"ordinary varying with the gl_PointSize write removed and captures nothing with it present "
"(an ES driver without GL_EXT/OES_geometry_point_size, which is a SEPARATE extension from the "
"tessellation one, or a Vulkan device without shaderTessellationAndGeometryPointSize)";
}
TEST_F(TessellationXfbCaptureScenario, CapturesGlPointSizeByNameFromTheGeometryStage) {
if (!Ready()) GTEST_SKIP();
if (const std::string reason = WhyGeometryPointSizeCaseCannotRun(); !reason.empty()) {
GTEST_SKIP() << reason << " (" << Gl().BackendName() << ", " << Gl().RendererString() << ")";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_GEOMETRY_SHADER, kPointSizeGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gs_value", "gl_PointSize"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
const std::vector<float> poison(2, kPoison);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(poison.size() * sizeof(float)),
poison.data(), GL_STATIC_DRAW);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> captured(2, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(captured.size() * sizeof(float)), captured.data());
EXPECT_TRUE(ComponentIs(captured, 0, 7.0f)) << "gs_value - an ordinary varying, which is lost too when "
"the stage carrying it fails to compile";
EXPECT_TRUE(ComponentIs(captured, 1, 4.0f)) << "gl_PointSize";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
}
// ---------------------------------------------------------------------------------
// The conformance body's own READBACK, which is not glGetBufferSubData.
// ---------------------------------------------------------------------------------
// Every case above reads the capture back with glGetBufferSubData because that is the
// shortest path to the bytes. The conformance bodies do something else: they respecify
// the buffer through the GENERIC GL_TRANSFORM_FEEDBACK_BUFFER binding with glBufferData
// while it is simultaneously bound to indexed capture point 0, and then read it with
// glMapBufferRange / glUnmapBuffer - twice, once per iteration of the same case, with no
// fresh buffer in between. On a device the tessellation bodies stop at exactly that map
// call, so the sequence itself is worth pinning: none of the map path's error conditions
// may fire, and the mapped bytes must be the captured ones.
TEST_F(TessellationXfbCaptureScenario, MapsTheCaptureBufferAfterEachOfTwoPatchDraws) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kMinimalVertexSource},
{GL_TESS_CONTROL_SHADER, kMinimalTessControlSource},
{GL_TESS_EVALUATION_SHADER, kPositionTessEvalSource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
{"gl_Position"});
ASSERT_NE(program, 0u) << "program failed to build: " << m_buildLog;
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "binding the capture point";
constexpr std::size_t kFloats = 4 * 3;
constexpr GLsizeiptr kBytes = static_cast<GLsizeiptr>(kFloats * sizeof(float));
for (int iteration = 0; iteration < 2; ++iteration) {
// Respecified through the generic binding, exactly as the conformance body does,
// while the same buffer is still bound to capture point 0.
const std::vector<float> poison(kFloats, kPoison);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kBytes, poison.data(), GL_STATIC_DRAW);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glBufferData, iteration " << iteration;
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glBeginTransformFeedback, iteration " << iteration;
glDrawArrays(GL_PATCHES, 0, 1);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glDrawArrays, iteration " << iteration;
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glEndTransformFeedback, iteration " << iteration;
const auto* mapped =
static_cast<const float*>(glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, kBytes,
GL_MAP_READ_BIT));
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "glMapBufferRange, iteration " << iteration;
ASSERT_NE(mapped, nullptr) << "iteration " << iteration;
const std::vector<float> captured(mapped, mapped + kFloats);
EXPECT_EQ(glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER), GL_TRUE) << "iteration " << iteration;
EXPECT_EQ(glGetError(), GL_NO_ERROR) << "glUnmapBuffer, iteration " << iteration;
EXPECT_TRUE(ComponentIs(captured, 0, 11.0f)) << "iteration " << iteration;
EXPECT_TRUE(ComponentIs(captured, 1, 12.0f)) << "iteration " << iteration;
EXPECT_TRUE(ComponentIs(captured, 2, 13.0f)) << "iteration " << iteration;
EXPECT_TRUE(ComponentIs(captured, 3, 14.0f)) << "iteration " << iteration;
glUseProgram(0);
}
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// The same patch with gl_PointSize travelling in gl_PerVertex beside gl_Position.
// In ESSL gl_PointSize does not EXIST in a tessellation stage unless
// GL_EXT_tessellation_point_size is requested, so a backend that lowers to ESSL
// without asking for it does not merely lose the value - the stage fails to compile
// and the whole program is replaced by program 0.
TEST_F(TessellationXfbCaptureScenario, TheEvaluationStageSeesGlPointSizeAcrossItsPatch) {
RunPerVertexPayloadCase(true);
}
// ---------------------------------------------------------------------------------
// The same capture through a PROGRAM PIPELINE OBJECT.
// ---------------------------------------------------------------------------------
// The conformance body runs each of its configurations twice: once with a monolithic
// program object and once with a pipeline of four separable programs, the capture
// declared on the separable EVALUATION program. That second shape goes through the
// hidden composite the pipeline object builds for the draw, and it is the only place a
// tessellation capture and the composite meet - so the capture list has to survive being
// taken from a program that is not the one bound.
TEST_F(TessellationXfbCaptureScenario, CapturesFromASeparableEvaluationProgramInAPipelineObject) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
// One separable program per stage. Only the evaluation program carries the capture
// list, because it is the one whose outputs are captured.
const auto buildSeparable = [&](GLenum stage, const char* source,
const std::vector<const char*>& varyings) -> GLuint {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
m_buildLog = InfoLog(shader, true);
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);
glAttachShader(program, shader);
if (!varyings.empty()) {
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()), varyings.data(),
GL_INTERLEAVED_ATTRIBS);
}
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
glDeleteShader(shader);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
};
m_buildLog.clear();
const GLuint vertexProgram = buildSeparable(GL_VERTEX_SHADER, kMinimalVertexSource, {});
ASSERT_NE(vertexProgram, 0u) << "separable vertex program: " << m_buildLog;
const GLuint controlProgram = buildSeparable(GL_TESS_CONTROL_SHADER, kMinimalTessControlSource, {});
ASSERT_NE(controlProgram, 0u) << "separable control program: " << m_buildLog;
const GLuint evalProgram =
buildSeparable(GL_TESS_EVALUATION_SHADER, kPositionTessEvalSource, {"gl_Position"});
ASSERT_NE(evalProgram, 0u) << "separable evaluation program: " << m_buildLog;
const GLuint fragmentProgram = buildSeparable(GL_FRAGMENT_SHADER, kFragmentSource, {});
ASSERT_NE(fragmentProgram, 0u) << "separable fragment program: " << m_buildLog;
GLuint pipeline = 0;
glGenProgramPipelines(1, &pipeline);
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vertexProgram);
glUseProgramStages(pipeline, GL_TESS_CONTROL_SHADER_BIT, controlProgram);
glUseProgramStages(pipeline, GL_TESS_EVALUATION_SHADER_BIT, evalProgram);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fragmentProgram);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "assembling the pipeline object";
constexpr std::size_t kFloats = 4 * 3;
const std::vector<float> poison(kFloats, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLsizeiptr>(kFloats * sizeof(float)),
poison.data(), GL_STATIC_DRAW);
glBindVertexArray(m_vao);
glUseProgram(0);
glBindProgramPipeline(pipeline);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
EXPECT_EQ(glGetError(), GL_NO_ERROR) << "glBeginTransformFeedback on a pipeline object";
glDrawArrays(GL_PATCHES, 0, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<float> captured(kFloats, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(kFloats * sizeof(float)), captured.data());
EXPECT_TRUE(ComponentIs(captured, 0, 11.0f));
EXPECT_TRUE(ComponentIs(captured, 1, 12.0f));
EXPECT_TRUE(ComponentIs(captured, 2, 13.0f));
EXPECT_TRUE(ComponentIs(captured, 3, 14.0f));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
glBindProgramPipeline(0);
glDeleteProgramPipelines(1, &pipeline);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glDeleteBuffers(1, &xfbBuffer);
}
} // namespace
} // namespace MGITest
@@ -119,6 +119,73 @@ void main() {
imageStore(u_unbound, int(index), uvec4(7u));
g_data[index] = index + 1u;
}
)";
// A plain sampler2D on a unit the test leaves alone. Two cases point at it: a unit with
// nothing bound at all, and a unit whose DEFAULT texture (name 0) has been given a base
// level and no mip chain - GL calls the second one incomplete for the initial
// NEAREST_MIPMAP_LINEAR filter, and both must resolve to the fallback rather than to a
// texture the backend then fails to back.
constexpr const char* kSampler2DFragmentSource = R"(#version 430 core
uniform sampler2D u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = texture(u_unbound, vec2(0.0));
}
o_color = color;
}
)";
// The multisample spelling of the same thing. GL_ARB_sample_variables' own conformance
// cases declare a sampler2D and a sampler2DMS side by side and deliberately point the
// unused one at an empty unit, so whichever of the two is unused has to have a
// placeholder - a multisample descriptor demands a multisample view, so the 2D fallback
// cannot stand in for it.
constexpr const char* kSampler2DMSFragmentSource = R"(#version 430 core
uniform sampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = texelFetch(u_unbound, ivec2(0), 0);
}
o_color = color;
}
)";
// The integer spellings of the same thing. These are the ones a plain RGBA8 multisample
// placeholder cannot serve: a multisample image can never carry MUTABLE_FORMAT, so the
// reinterpreting view an integer sampler would need over UNORM texels is unbuildable and
// the descriptor resolve used to fail, losing the draw after the placeholder had already
// been created.
constexpr const char* kUsampler2DMSFragmentSource = R"(#version 430 core
uniform usampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = vec4(texelFetch(u_unbound, ivec2(0), 0));
}
o_color = color;
}
)";
constexpr const char* kIsampler2DMSFragmentSource = R"(#version 430 core
uniform isampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = vec4(texelFetch(u_unbound, ivec2(0), 0));
}
o_color = color;
}
)";
constexpr const char* kImage2DFragmentSource = R"(#version 430 core
@@ -369,6 +436,65 @@ void main() {
ExpectDrawStillRuns(kImage2DFragmentSource, "image2D");
}
// ---- sampler2D / sampler2DMS (VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) ----------------
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSampler2DDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kSampler2DFragmentSource, "sampler2D");
}
// The regression this file exists for, in its sharpest form: a sampler pointing at a texture
// unit whose DEFAULT texture object has an image but no mip chain.
//
// DirectVulkan resolved such a binding twice, through two different predicates that
// disagreed. The collect pass (CollectSampledTextures -> ResolveSampledBinding), which
// pre-syncs and transitions every texture the draw will sample, asked only whether the
// default texture was UNDEFINED - texture 0 with an image is not - and kept it. The
// descriptor pass (ResolveSamplerDescriptor) asked the real GL question, whether it
// SAMPLES AS INCOMPLETE for the filter in effect, and swapped it for the fallback. So the
// collect pass synced a texture no descriptor would ever hold, VkTextureManager declined it
// ("mipmap not complete") and returned nullptr, and SetupDraw dereferenced that nullptr -
// a SIGSEGV inside the draw, not a degraded picture.
//
// The GL-CTS reaches this on its own: its between-case state reset gives the default 2D
// texture a base level, so the FIRST case in a process survived and every later one with an
// unbound sampler2D died. That is the whole of the 380-record sample_variables crash family
// on Mali-G1-Ultra. Any application that uploads to texture 0 has the same shape.
TEST_F(UnboundImageDescriptorScenario, ASamplerOnAUnitWhoseDefaultTextureIsIncompleteDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
// Unit 0 is where the sampler's default uniform value points. Give the DEFAULT texture
// object bound there a FORMAT and a zero-sized level - which is what a bare
// glTexImage2D(..., 0, 0, ...) with no data does, and what the GL-CTS's between-case
// state reset issues for every texture target. That combination is the whole point:
// * it is DEFINED, so IsUndefinedDefaultTexture (the collect path's old test) is false
// and the texture stays in the sampled set;
// * it is INCOMPLETE, so SamplesAsIncompleteTexture (the descriptor path's test) is
// true and the descriptor holds the fallback instead;
// * and it has no valid mip level, so the sync declines and hands back nullptr.
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
ASSERT_EQ(FirstGLError(), 0u) << "defining a zero-sized level 0 on the default texture raised a GL error";
ExpectDrawStillRuns(kSampler2DFragmentSource, "sampler2D on an incomplete default texture");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kSampler2DMSFragmentSource, "sampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundUnsignedSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kUsampler2DMSFragmentSource, "usampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSignedSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kIsampler2DMSFragmentSource, "isampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, AFormatlessWriteonlyImage2DLeftUnboundDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_IMAGE_UNIFORMS)) {
@@ -0,0 +1,276 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UnwrittenPositionOutputScenario.cpp
// Copyright (c) 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 SHADER REDECLARES gl_PerVertex AND NEVER WRITES gl_Position.
//
// Legal, ordinary GLSL, and until now a process kill on DirectVulkan. The chain, all of it
// inside MobileGL's own SPIR-V plumbing:
//
// 1. glslang emits every DECLARED interface variable, used or not, and lists it on
// OpEntryPoint. So `out gl_PerVertex { vec4 gl_Position; };` with no write still produces
// the OpVariable, the OpMemberDecorate BuiltIn Position, and an interface slot.
// 2. At link, ShaderCompiler::SanitizeAndOptimizeBinary runs AggressiveDCE(remove_outputs =
// false) - which may never delete an Output - and then RemoveUnusedInterfaceVariables,
// which rebuilds the interface list from the variables instructions actually reference.
// The OpVariable and its BuiltIn decoration SURVIVE; the interface slot is DELISTED.
// 3. At pipeline build, ProgramFactory picks the last pre-rasterisation stage and runs two
// passes over it. GlToVulkanPositionFixPass finds the position target through the
// surviving ANNOTATION and injects a load-modify-STORE through it. When gl_Position is in
// the transform-feedback capture list, XfbCaptureDecoratePass::MirrorPositionForCapture
// also injects an access chain and a LOAD through it.
// 4. Either injection is a static use of a variable that is no longer on the entry point's
// interface, which is invalid SPIR-V ("Interface variable id <N> is used by entry point
// 'main' id <M>, but is not listed as an interface"). Mali r54 does not reject such a
// module - it faults inside pipeline creation and takes the process down.
//
// Measured on a Mali-G1-Ultra as 216 KHR-GL44/45/46.tessellation_shader.tessellation_control_
// to_tessellation_evaluation.gl_MaxPatchVertices_Position_PointSize_* crashes; the CTS's TES
// there is exactly the shape below. It is not tessellation-specific and not XFB-specific: a
// vertex shader is enough, which is what these cases use.
//
// Every test captures a USER varying through transform feedback under GL_RASTERIZER_DISCARD.
// Position is undefined in the first two by construction, so it is never asserted on - what is
// asserted is that the capture came back at all, which it can only do if the driver accepted
// the module and built a pipeline.
#include <cstddef>
#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 std::size_t kCaptureFloats = 4;
constexpr GLsizeiptr kCaptureBytes = static_cast<GLsizeiptr>(kCaptureFloats * sizeof(float));
// The defect's shape: gl_PerVertex redeclared, gl_Position never assigned.
constexpr const char* kUnwrittenPositionVertexSource = R"(#version 430 core
layout(location = 0) in vec4 vs_in_value;
out gl_PerVertex {
vec4 gl_Position;
};
out vec4 vs_out_value;
void main() {
vs_out_value = vs_in_value;
}
)";
// The control that isolates the redeclaration: identical but for the one assignment.
// This one keeps its interface slot through the sanitize chain, so both injections were
// always legal on it - it must stay working.
constexpr const char* kWrittenPositionVertexSource = R"(#version 430 core
layout(location = 0) in vec4 vs_in_value;
out gl_PerVertex {
vec4 gl_Position;
};
out vec4 vs_out_value;
void main() {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
vs_out_value = vs_in_value;
}
)";
// The second control, and the one the CTS calls data_pass_through: no gl_PerVertex
// redeclaration at all, so there is no Position annotation for the passes to find and
// nothing to delist. It was never affected and proves the crash needs the redeclaration.
constexpr const char* kNoPositionBlockVertexSource = R"(#version 430 core
layout(location = 0) in vec4 vs_in_value;
out vec4 vs_out_value;
void main() {
vs_out_value = vs_in_value;
}
)";
GLuint CompileVertexShader(const std::string& source, std::string* log) {
const GLuint shader = glCreateShader(GL_VERTEX_SHADER);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
// `captureNames` is what goes to glTransformFeedbackVaryings. Passing gl_Position in it
// is what puts MirrorPositionForCapture on the path.
GLuint BuildCaptureProgram(const char* vertexSource, const std::vector<const char*>& captureNames,
std::string* log) {
const GLuint vertexShader = CompileVertexShader(vertexSource, log);
if (vertexShader == 0) return 0;
const GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glTransformFeedbackVaryings(program, static_cast<GLsizei>(captureNames.size()), captureNames.data(),
GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
glDeleteShader(vertexShader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteProgram(program);
return 0;
}
return program;
}
class UnwrittenPositionOutputScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
const float vertex[kCaptureFloats] = {1.0f, 2.0f, 3.0f, 4.0f};
glBufferData(GL_ARRAY_BUFFER, kCaptureBytes, vertex, GL_STATIC_DRAW);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
}
void TearDown() override {
if (!Ready()) return;
glBindVertexArray(0);
glUseProgram(0);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
ScenarioTest::TearDown();
}
// Links `vertexSource` with `captureNames`, runs one captured point, and checks that
// the USER varying came back. `captureStride` is how many floats one captured vertex
// occupies, so the user varying can be read out from behind a captured gl_Position.
void ExpectUserVaryingIsCaptured(const char* vertexSource, const std::vector<const char*>& captureNames,
std::size_t captureStride, std::size_t userVaryingOffset,
const char* what) {
std::string log;
const GLuint program = BuildCaptureProgram(vertexSource, captureNames, &log);
ASSERT_NE(program, 0u) << what << ": the capture program failed to build: " << log;
const GLsizeiptr captureBytes = static_cast<GLsizeiptr>(captureStride * sizeof(float));
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffer);
// Pre-fill with a value the shader cannot produce, so "captured nothing" is
// distinguishable from "captured the wrong thing".
const std::vector<float> poison(captureStride, -1.0f);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, captureBytes, poison.data(), GL_DYNAMIC_READ);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
ASSERT_EQ(FirstGLError(), 0u) << what << ": setting up the capture buffer raised a GL error";
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(program);
glBindVertexArray(m_vao);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
glBindVertexArray(0);
glUseProgram(0);
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(FirstGLError(), 0u) << what << ": the captured draw raised a GL error";
std::vector<float> readback(captureStride, -2.0f);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffer);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBytes, readback.data());
for (std::size_t i = 0; i < kCaptureFloats; ++i) {
EXPECT_FLOAT_EQ(readback[userVaryingOffset + i], static_cast<float>(i + 1))
<< what << ": captured float " << i << " came back as "
<< readback[userVaryingOffset + i]
<< "; the pre-fill value means the draw never produced a vertex, which is what an "
"invalid shader module looks like from out here";
}
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0);
glDeleteBuffers(1, &xfbBuffer);
glDeleteProgram(program);
}
GLuint m_vao = 0;
GLuint m_vbo = 0;
};
} // namespace
// The clip fixup's half: PositionZRemap is on for every draw, so the fixup runs on this
// program and used to inject a store through the delisted block.
TEST_F(UnwrittenPositionOutputScenario, ARedeclaredButUnwrittenPositionStillDraws) {
if (!Ready() || IsSkipped()) return;
ExpectUserVaryingIsCaptured(kUnwrittenPositionVertexSource, {"vs_out_value"}, kCaptureFloats, 0,
"redeclared, never written");
}
// The XFB half: capturing gl_Position adds an access chain and a LOAD through the same
// delisted block, which the interface rule covers exactly as it covers the store. Position
// itself is undefined here - only the user varying behind it is asserted.
TEST_F(UnwrittenPositionOutputScenario, CapturingAnUnwrittenPositionStillDraws) {
if (!Ready() || IsSkipped()) return;
// DirectVulkan only, and not because the defect was backend-specific in principle - the
// injection this pins lives in DirectVulkan's ProgramFactory, and DirectGLES cannot
// reach the case at all: capturing gl_Position BY NAME off a shader that never writes it
// comes back empty there, because the ESSL the transpiler emits has no such output for
// the capture list to name. That is a known, separate DirectGLES gap (the same one that
// blocks gl_Position/gl_PointSize capture in the tessellation capture segment), tracked
// outside this scenario; asserting it here would only re-report it.
if (Gl().BackendName() != "DirectVulkan") {
GTEST_SKIP() << "capturing an unwritten gl_Position by name is a separate, known "
<< "DirectGLES gap; this case pins the DirectVulkan injection";
}
ExpectUserVaryingIsCaptured(kUnwrittenPositionVertexSource, {"gl_Position", "vs_out_value"},
kCaptureFloats * 2, kCaptureFloats, "capturing an unwritten gl_Position");
}
// Control: the same shader with the one assignment restored. Its block is never delisted,
// so it exercises the path the fixup is actually for and must keep working.
TEST_F(UnwrittenPositionOutputScenario, AWrittenRedeclaredPositionStillDraws) {
if (!Ready() || IsSkipped()) return;
ExpectUserVaryingIsCaptured(kWrittenPositionVertexSource, {"vs_out_value"}, kCaptureFloats, 0,
"redeclared and written");
}
TEST_F(UnwrittenPositionOutputScenario, CapturingAWrittenPositionStillDraws) {
if (!Ready() || IsSkipped()) return;
ExpectUserVaryingIsCaptured(kWrittenPositionVertexSource, {"gl_Position", "vs_out_value"},
kCaptureFloats * 2, kCaptureFloats, "capturing a written gl_Position");
}
// Control: no gl_PerVertex redeclaration, so no Position annotation and nothing to delist.
TEST_F(UnwrittenPositionOutputScenario, AShaderWithNoPositionBlockStillDraws) {
if (!Ready() || IsSkipped()) return;
ExpectUserVaryingIsCaptured(kNoPositionBlockVertexSource, {"vs_out_value"}, kCaptureFloats, 0,
"no gl_PerVertex block");
}
} // namespace MGITest
@@ -0,0 +1,657 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/XfbRepeatedCaptureScenario.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 CAPTURE MUST STILL RECORD WHEN IT IS NOT THE FIRST ONE IN THE PROCESS,
// AND THE CAPTURE STAGE MAY BE ANY OF THE FOUR THAT CAN BE THE LAST ONE.
//
// The conformance suite exposed a whole family of transform feedback failures that no
// existing scenario could reproduce, because every one of them ran ONE capture, from a
// VERTEX stage, in a freshly initialised process. What the suite actually does is
// different in three ways at once, and each of them turned out to matter:
//
// * it runs case after case in ONE GL context, resetting state between them - and the
// reset is not a fresh context. Its transform feedback part
// (framework/opengl/gluStateReset.cpp resetStateGLCore) unbinds the generic
// GL_TRANSFORM_FEEDBACK_BUFFER and then clears every indexed capture point from 0 to
// GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS, which permanently raises MobileGL's
// touched-binding-point high-water mark. Every later capture that uses fewer points
// than that - i.e. every INTERLEAVED_ATTRIBS capture - then had the unused tail
// re-cleared on the driver immediately before glBeginTransformFeedback.
// ReplayDeqpStateReset below is that reset, reduced to the calls that touch capture
// state, so a defect that only appears from the second capture onwards is reachable
// here instead of only on a device.
//
// * the capture stage is frequently a GEOMETRY or a TESSELLATION EVALUATION shader,
// never a plain vertex shader. The tree had zero coverage for either: none of the
// Xfb* scenarios mentioned tessellation and neither TessellationDrawModeScenario nor
// GeometryDrawModeScenario mentioned transform feedback.
//
// * the capture program frequently has NO FRAGMENT STAGE at all, because it draws
// under GL_RASTERIZER_DISCARD and never rasterises anything. That is legal in
// desktop GL and the shape most "use transform feedback as a readback channel"
// tests are built on.
//
// Every case here asserts the captured BYTES, never just the absence of a GL error: the
// failure this guards against writes nothing and raises nothing, so a buffer that kept
// its poison is the only thing that distinguishes it from success.
#include <cmath>
#include <string>
#include <utility>
#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 {
// Nothing a capture can legitimately produce, so a component that still reads it
// names the failure ("the capture never reached these bytes") instead of looking
// like an ordinary numeric mismatch.
constexpr int kPoison = -987654;
const char* const kPassthroughVertexSource = R"(#version 420 core
layout(location = 0) in int vs_in_value;
flat out int vs_out_value;
void main()
{
vs_out_value = vs_in_value;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
// The primitive_counter shape: one flat int per emitted vertex, several vertices
// per input primitive, so the capture is geometry-AMPLIFIED and the CPU-side
// primitive model cannot predict its length.
const char* const kPointAmplifyingGeometrySource = R"(#version 420 core
layout(points) in;
layout(points, max_vertices = 2) out;
flat in int vs_out_value[];
flat out int gs_out_value;
void main()
{
for (int i = 0; i < 2; ++i)
{
gs_out_value = vs_out_value[0];
gl_Position = gl_in[0].gl_Position;
EmitVertex();
EndPrimitive();
}
}
)";
// Adjacency input. Only a geometry stage can consume it, and CountPrimitivesForDraw
// used to answer 0 for every adjacency mode, which silently excluded the whole draw
// from the capture accounting.
const char* const kAdjacencyGeometrySource = R"(#version 420 core
layout(lines_adjacency) in;
layout(points, max_vertices = 1) out;
flat in int vs_out_value[];
flat out int gs_out_value;
void main()
{
gs_out_value = vs_out_value[1];
gl_Position = gl_in[1].gl_Position;
EmitVertex();
EndPrimitive();
}
)";
const char* const kTessControlSource = R"(#version 420 core
layout(vertices = 1) out;
flat in int vs_out_value[];
patch out int tcs_out_value;
void main()
{
tcs_out_value = vs_out_value[0];
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
gl_out[gl_InvocationID].gl_Position = gl_in[0].gl_Position;
}
)";
const char* const kTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, cw) in;
patch in int tcs_out_value;
flat out int tes_out_value;
void main()
{
tes_out_value = tcs_out_value;
gl_Position = gl_in[0].gl_Position;
}
)";
const char* const kFragmentSource = R"(#version 420 core
flat in int gs_out_value;
out vec4 fragColor;
void main()
{
fragColor = vec4(float(gs_out_value), 0.0, 0.0, 1.0);
}
)";
class XfbRepeatedCaptureScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
const int values[kInputVertices] = {10, 11, 12, 13};
glBufferData(GL_ARRAY_BUFFER, sizeof(values), values, GL_STATIC_DRAW);
glVertexAttribIPointer(0, 1, GL_INT, 0, nullptr);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vbo = 0;
m_vao = 0;
ScenarioTest::TearDown();
}
static constexpr int kInputVertices = 4;
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static bool BackendHostsGeometry() {
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
DrainErrors();
return maxGeometryOutputVertices >= 2;
}
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
// The transform-feedback-relevant half of deqp's resetStateGLCore, in its order.
// It runs between EVERY pair of conformance cases, and running one capture
// through it is the difference between "the first capture in the process" and
// every other one.
static void ReplayDeqpStateReset() {
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
GLint maxSeparateAttribs = 0;
glGetIntegerv(GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS, &maxSeparateAttribs);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0);
for (GLint index = 0; index < maxSeparateAttribs; ++index) {
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, static_cast<GLuint>(index), 0);
}
DrainErrors();
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, const char*>>& stages,
const char* varying) {
return BuildCaptureProgram(stages, std::vector<const char*>{varying});
}
// Builds a capture program out of `stages` capturing `varyings` interleaved.
// Returns 0 and fills m_buildLog on failure.
GLuint BuildCaptureProgram(const std::vector<std::pair<GLenum, const char*>>& stages,
const std::vector<const char*>& varyings) {
m_buildLog.clear();
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (compiled == GL_FALSE) {
m_buildLog = InfoLog(shader, true);
ok = false;
break;
}
}
GLuint program = 0;
if (ok) {
program = glCreateProgram();
for (const GLuint shader : shaders) {
glAttachShader(program, shader);
}
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()), varyings.data(),
GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
program = 0;
}
}
for (const GLuint shader : shaders) {
glDeleteShader(shader);
}
if (program != 0) m_programs.push_back(program);
return program;
}
// One capture span. `captureMode` is the transform feedback primitive mode,
// `drawMode`/`count` the draw. Returns the capture buffer's contents.
std::vector<int> RunCaptureSpan(GLuint program, GLenum captureMode, GLenum drawMode, GLsizei count,
std::size_t capturedInts) {
std::vector<int> poison(capturedInts, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedInts * sizeof(int)), poison.data(),
GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
// The capture point is the ONLY thing bound; the generic
// GL_TRANSFORM_FEEDBACK_BUFFER binding comes along for the ride, exactly as
// the conformance tests rely on (GL 4.6 core 6.1.1).
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glBindVertexArray(m_vao);
glUseProgram(program);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(captureMode);
glDrawArrays(drawMode, 0, count);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<int> readback(capturedInts, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedInts * sizeof(int)), readback.data());
glUseProgram(0);
glDeleteBuffers(1, &xfbBuffer);
return readback;
}
static ::testing::AssertionResult CapturedNothing(const std::vector<int>& data) {
for (std::size_t i = 0; i < data.size(); ++i) {
if (data[i] != kPoison) {
return ::testing::AssertionFailure() << "component " << i << " is " << data[i];
}
}
return ::testing::AssertionSuccess();
}
static ::testing::AssertionResult CapturedIs(const std::vector<int>& data,
const std::vector<int>& expected) {
if (data.size() != expected.size()) {
return ::testing::AssertionFailure()
<< "captured " << data.size() << " value(s), expected " << expected.size();
}
for (std::size_t i = 0; i < data.size(); ++i) {
if (data[i] != expected[i]) {
::testing::AssertionResult failure = ::testing::AssertionFailure();
failure << "component " << i << " is " << data[i] << ", expected " << expected[i];
if (data[i] == kPoison) {
failure << " (the capture never reached these bytes)";
}
return failure;
}
}
return ::testing::AssertionSuccess();
}
std::vector<GLuint> m_programs;
std::string m_buildLog;
GLuint m_vao = 0;
GLuint m_vbo = 0;
};
// THE REGRESSION GUARD FOR THE WHOLE FAMILY. Two geometry-stage captures in one
// process with the conformance suite's own state reset between them; the assertion
// that matters is on the SECOND one, which is the one every device run failed while
// whichever body happened to land first in its process passed.
TEST_F(XfbRepeatedCaptureScenario, ASecondGeometryCaptureAfterADeqpStateResetStillRecords) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString() << ")";
}
// Two vertices emitted per input point, so the capture is amplified beyond what
// the CPU primitive model can predict from the draw alone.
const std::vector<int> expected = {10, 10, 11, 11, 12, 12, 13, 13};
for (int capture = 0; capture < 3; ++capture) {
// A fresh program per capture, because that is what a fresh conformance case
// builds - and it is what makes the driver recycle program and buffer names.
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_GEOMETRY_SHADER, kPointAmplifyingGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
"gs_out_value");
ASSERT_NE(program, 0u) << "capture " << capture << " program failed to build: " << m_buildLog;
const std::vector<int> captured =
RunCaptureSpan(program, GL_POINTS, GL_POINTS, kInputVertices, expected.size());
EXPECT_TRUE(CapturedIs(captured, expected))
<< "capture " << capture << " of 3 in this process"
<< (capture == 0 ? "" : " (every earlier one was followed by a deqp-shaped state reset)");
EXPECT_EQ(glGetError(), GL_NO_ERROR) << "capture " << capture;
glDeleteProgram(program);
m_programs.pop_back();
ReplayDeqpStateReset();
glBindVertexArray(m_vao);
}
}
// The tessellation half, which had no coverage anywhere in the tree: a capture taken
// from a GL_PATCHES draw, whose last vertex-processing stage is the evaluation shader
// and whose record count only the tessellator knows.
TEST_F(XfbRepeatedCaptureScenario, ACaptureFromAPatchesDrawRecords) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< ")";
}
// One input patch of one vertex, all levels at 1: the tessellator emits exactly
// one triangle, so three captured vertices all carrying the first input value.
glPatchParameteri(GL_PATCH_VERTICES, 1);
DrainErrors();
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_TESS_CONTROL_SHADER, kTessControlSource},
{GL_TESS_EVALUATION_SHADER, kTessEvalSource}},
"tes_out_value");
ASSERT_NE(program, 0u) << "patch capture program failed to build: " << m_buildLog;
const std::vector<int> expected = {10, 10, 10};
const std::vector<int> captured = RunCaptureSpan(program, GL_TRIANGLES, GL_PATCHES, 1, expected.size());
EXPECT_TRUE(CapturedIs(captured, expected));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// A capture program with NO FRAGMENT STAGE, drawn under GL_RASTERIZER_DISCARD. Legal
// in desktop GL, and the shape most transform-feedback-as-readback tests use; the
// program above only differs from it by the fragment shader, so a failure here is
// specifically about the missing stage.
TEST_F(XfbRepeatedCaptureScenario, ACaptureFromAFragmentlessProgramRecords) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString() << ")";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_GEOMETRY_SHADER, kPointAmplifyingGeometrySource}},
"gs_out_value");
ASSERT_NE(program, 0u) << "fragmentless capture program failed to build: " << m_buildLog;
const std::vector<int> expected = {10, 10, 11, 11, 12, 12, 13, 13};
const std::vector<int> captured =
RunCaptureSpan(program, GL_POINTS, GL_POINTS, kInputVertices, expected.size());
EXPECT_TRUE(CapturedIs(captured, expected));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// An ADJACENCY draw feeding the capture. CountPrimitivesForDraw answered 0 for all
// four adjacency modes, which made the transform feedback accounting skip the draw
// entirely - so neither the captured-vertex counter nor the geometry-capture-draw
// flag moved, and anything downstream of either was working from "nothing happened".
TEST_F(XfbRepeatedCaptureScenario, ACaptureFromAnAdjacencyDrawRecords) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString() << ")";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_GEOMETRY_SHADER, kAdjacencyGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
"gs_out_value");
ASSERT_NE(program, 0u) << "adjacency capture program failed to build: " << m_buildLog;
// Four vertices of GL_LINES_ADJACENCY are one line primitive; the shader emits
// the second vertex of the four, which is the line's first real endpoint.
const std::vector<int> expected = {11};
const std::vector<int> captured =
RunCaptureSpan(program, GL_POINTS, GL_LINES_ADJACENCY, kInputVertices, expected.size());
EXPECT_TRUE(CapturedIs(captured, expected));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
// An adjacency draw with NO geometry stage. GL 4.6 core table 13.1 admits
// GL_LINES_ADJACENCY and GL_LINE_STRIP_ADJACENCY under capture mode GL_LINES (and the
// triangle pair under GL_TRIANGLES): without a geometry shader the adjacent vertices
// are ignored and the primitive assembled is a plain line, so the combination is legal
// and must capture. MobileGL's active-capture primitive-mode table listed only the
// non-adjacency modes, so this raised GL_INVALID_OPERATION and dropped the draw
// entirely - the buffer kept its pre-draw bytes and the application saw an error the
// spec does not allow. Distinct from ACaptureFromAnAdjacencyDrawRecords above, which
// HAS a geometry stage and therefore bypasses that table completely.
TEST_F(XfbRepeatedCaptureScenario, AVertexOnlyAdjacencyCaptureRecords) {
if (!Ready()) GTEST_SKIP();
const GLuint program =
BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource}}, "vs_out_value");
ASSERT_NE(program, 0u) << "vertex-only capture program failed to build: " << m_buildLog;
// Four vertices of GL_LINES_ADJACENCY are one line whose real endpoints are the
// middle pair, so the capture is those two vertices in order.
const std::vector<int> expected = {11, 12};
const std::vector<int> captured =
RunCaptureSpan(program, GL_LINES, GL_LINES_ADJACENCY, kInputVertices, expected.size());
// THE GUARD FOR THE DEFECT ITSELF, and it is backend-independent: the frontend
// validator must not reject the combination. It used to record
// GL_INVALID_OPERATION and return before the draw was ever issued.
EXPECT_EQ(glGetError(), GL_NO_ERROR)
<< "a capture-mode/draw-mode pair GL 4.6 core table 13.1 admits must raise no error";
// Whether the capture then RECORDS is a backend question, and the two answer it
// differently. ES 3.2 (10.1) supports the adjacency primitive types only for a
// pipeline with a geometry shader, so DirectGLES has nothing to forward this draw
// to; desktop GL and Vulkan both assemble the plain line and capture it. Asserting
// the data unconditionally would be asserting that DirectGLES emulates a whole ES
// restriction away, which is a separate piece of work and not what this guards.
if (Gl().BackendName() == "DirectGLES") {
GTEST_SKIP() << "DirectGLES cannot forward a geometry-shader-less adjacency draw: ES 3.2 10.1 "
"supports the adjacency primitive types only with a geometry stage. The frontend "
"no longer rejects the draw (checked above), which is the defect this covers.";
}
EXPECT_TRUE(CapturedIs(captured, expected));
}
// A CAPTURE MUST NEVER LAND IN A BUFFER THE APPLICATION DID NOT BIND FOR IT.
//
// A capture list may legally begin with gl_NextBuffer, which leaves capture buffer 0
// with stride 0 and nothing to capture - so glBeginTransformFeedback does not require a
// buffer at point 0 and the application binds only point 1. The driver-side program is
// a single-buffer interleaved capture (the pseudo-varyings are consumed at link time),
// so it writes capture point 0, and MobileGL redirects that into scratch storage and
// scatters the records afterwards.
//
// Two ways that went wrong, both fixed here: the scratch was sized by reading each
// target's stride at its POSITION in a list that skips unbound buffers, which for this
// layout read stride 0 for everything and produced a zero capacity; and when the
// scratch then failed to bind, the span opened anyway onto whatever capture point 0
// still held from an earlier capture in the process - silently overwriting an unrelated
// application buffer. The first span below exists purely to leave such a binding behind.
TEST_F(XfbRepeatedCaptureScenario, ACaptureListBeginningWithGlNextBufferSparesTheEarlierBuffer) {
if (!Ready()) GTEST_SKIP();
const std::size_t capturedInts = 4;
const GLsizeiptr captureBytes = static_cast<GLsizeiptr>(capturedInts * sizeof(int));
// Span A: an ordinary capture, so capture point 0 is left holding bufferA.
const GLuint programA =
BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource}}, "vs_out_value");
ASSERT_NE(programA, 0u) << "plain capture program failed to build: " << m_buildLog;
std::vector<int> poison(capturedInts, kPoison);
GLuint bufferA = 0;
glGenBuffers(1, &bufferA);
glBindBuffer(GL_ARRAY_BUFFER, bufferA);
glBufferData(GL_ARRAY_BUFFER, captureBytes, poison.data(), GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, bufferA);
glBindVertexArray(m_vao);
glUseProgram(programA);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, kInputVertices);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
std::vector<int> afterA(capturedInts, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBytes, afterA.data());
const std::vector<int> spanAExpected = {10, 11, 12, 13};
ASSERT_TRUE(CapturedIs(afterA, spanAExpected)) << "the setup span itself did not capture";
// Span B: gl_NextBuffer first, so buffer 0 captures nothing and only point 1 is bound.
const GLuint programB = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource}},
{"gl_NextBuffer", "vs_out_value"});
if (programB == 0) {
GTEST_SKIP() << "gl_NextBuffer capture lists are not linkable on " << Gl().BackendName() << " ("
<< Gl().RendererString() << "): " << m_buildLog;
}
GLuint bufferB = 0;
glGenBuffers(1, &bufferB);
glBindBuffer(GL_ARRAY_BUFFER, bufferB);
glBufferData(GL_ARRAY_BUFFER, captureBytes, poison.data(), GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
// Point 0 released, point 1 is the only destination this capture asks for.
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1, bufferB);
glUseProgram(programB);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, kInputVertices);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
// THE ASSERTION THAT MATTERS: bufferA was not a destination of this capture, so it
// must still read exactly what span A left in it. A failure here is the corruption.
std::vector<int> bufferAAfterB(capturedInts, 0);
glBindBuffer(GL_ARRAY_BUFFER, bufferA);
glGetBufferSubData(GL_ARRAY_BUFFER, 0, captureBytes, bufferAAfterB.data());
glBindBuffer(GL_ARRAY_BUFFER, 0);
EXPECT_TRUE(CapturedIs(bufferAAfterB, spanAExpected))
<< "the gl_NextBuffer capture wrote into the buffer the PREVIOUS span had bound";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
// ...and, where the backend places this layout at all, the buffer it WAS asked to
// write gets the records. That placement is the DirectGLES scatter path, whose
// scratch sizing used to read each target's stride at its POSITION in a list that
// skips unbound capture buffers - which for a leading gl_NextBuffer read stride 0
// for every target and sized the scratch at zero. DirectVulkan does not implement a
// leading-gl_NextBuffer layout at all (it captures nothing into bufferB); that is a
// pre-existing gap of its own, and the assertion above - that it corrupts nothing
// while declining - is what matters for it.
const bool backendPlacesLeadingNextBuffer = Gl().BackendName() != "DirectVulkan";
if (backendPlacesLeadingNextBuffer) {
std::vector<int> bufferBAfter(capturedInts, kPoison);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBytes, bufferBAfter.data());
EXPECT_TRUE(CapturedIs(bufferBAfter, spanAExpected));
}
// Unbound and deleted BEFORE any skip: a capture point left pointing at a buffer
// this test deleted would follow the process into the next scenario.
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1, 0);
glDeleteBuffers(1, &bufferA);
glDeleteBuffers(1, &bufferB);
if (!backendPlacesLeadingNextBuffer) {
GTEST_SKIP() << "DirectVulkan does not place a capture list beginning with gl_NextBuffer; it "
"captures nothing, which the no-corruption assertion above has already covered.";
}
}
// The control for all of the above: a span that never draws must leave the capture
// buffer alone. Without it "the buffer kept its poison" could be read as the correct
// outcome of some path rather than as the bug, and the tightened early returns in
// StartPendingTransformFeedback have to keep this legal case legal.
TEST_F(XfbRepeatedCaptureScenario, ASpanThatNeverDrawsLeavesTheCaptureBufferAlone) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString() << ")";
}
const GLuint program = BuildCaptureProgram({{GL_VERTEX_SHADER, kPassthroughVertexSource},
{GL_GEOMETRY_SHADER, kPointAmplifyingGeometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}},
"gs_out_value");
ASSERT_NE(program, 0u) << "capture program failed to build: " << m_buildLog;
const std::size_t capturedInts = 8;
std::vector<int> poison(capturedInts, kPoison);
GLuint xfbBuffer = 0;
glGenBuffers(1, &xfbBuffer);
glBindBuffer(GL_ARRAY_BUFFER, xfbBuffer);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(capturedInts * sizeof(int)), poison.data(),
GL_STATIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, xfbBuffer);
glUseProgram(program);
glBeginTransformFeedback(GL_POINTS);
glEndTransformFeedback();
glUseProgram(0);
std::vector<int> readback(capturedInts, 0);
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0,
static_cast<GLsizeiptr>(capturedInts * sizeof(int)), readback.data());
EXPECT_TRUE(CapturedNothing(readback));
EXPECT_EQ(glGetError(), GL_NO_ERROR);
glDeleteBuffers(1, &xfbBuffer);
}
} // namespace
} // namespace MGITest
+8 -1
View File
@@ -710,8 +710,15 @@ namespace MobileGL::MG_State {
// must not, or failing the link and killing every draw. A capture stage with an
// empty list is not a reason to look further down: it is the answer, and
// glBeginTransformFeedback's INVALID_OPERATION is the correct consequence.
//
// The order is the pipeline read backwards and includes the tessellation CONTROL
// stage, which is a vertex-processing stage too (GL 4.6 core 11): it can only be
// the last one in a pipeline that has a TCS but no evaluation or geometry stage,
// which is why it sits after TessEval. Same four stages, same order, as
// ProgramLinkTask::ResolveTransformFeedbackVaryings - see rule (2).
for (const ShaderStage captureStage:
{ShaderStage::Geometry, ShaderStage::TessEval, ShaderStage::Vertex}) {
{ShaderStage::Geometry, ShaderStage::TessEval, ShaderStage::TessControl,
ShaderStage::Vertex}) {
if (!compositeHasStage[static_cast<SizeT>(captureStage)]) continue;
const auto& captureProgram = pipeline->GetStageProgram(captureStage);
if (!captureProgram) continue;
@@ -1856,7 +1856,13 @@ namespace MobileGL::MG_State::GLState {
// them to the draw-buffer range fails the link of every such program.
if (artifacts.program->getIntermediate(EShLangFragment) == nullptr) return true;
UnorderedMap<Int, String> colorNumberOwners;
// Keyed on (colour number, COLOUR INDEX), not on the colour number alone. Two fragment
// outputs may share a location as long as their index differs - that pair IS dual-source
// blending (GL 4.6 core 11.1.3 / ARB_blend_func_extended, core since 3.3), spelled either
// `layout(location = 0, index = 0)` + `layout(location = 0, index = 1)` in the shader or
// through two glBindFragDataLocationIndexed calls. Aliasing on the number alone made every
// such program fail to link with "alias color number 0", which is the whole feature.
UnorderedMap<Int64, String> colorSlotOwners;
const Int outputCount = artifacts.program->getNumPipeOutputs();
for (Int index = 0; index < outputCount; ++index) {
const auto& output = artifacts.program->getPipeOutput(index);
@@ -1869,6 +1875,46 @@ namespace MobileGL::MG_State::GLState {
const Int location = explicitLocation != in.explicitFragDataLocation.end()
? static_cast<Int>(explicitLocation->second)
: static_cast<Int>(output.layoutLocation());
// The colour INDEX, under the one precedence rule the whole codebase uses: a NON-ZERO
// glBindFragDataLocationIndexed index wins, and a zero (or absent) one falls back to
// the shader's own layout(index = N).
//
// Zero has to mean "no override" rather than "index 0", because glBindFragDataLocation
// IS glBindFragDataLocationIndexed with index 0 (GL_Program.cpp) and writes a real 0
// into this map. Reading that 0 as an override made a blanket
// `glBindFragDataLocation(prog, 0, "b")` over a shader that declares
// `layout(location = 0, index = 1) out vec4 b;` collapse b onto slot (0,0) next to the
// index-0 output and fail the link as an alias - while the IO resolver had left b's
// qualifier at 1, the SPIR-V still carried Index 1, and glGetProgramResourceLocationIndex
// still answered 1. Validation was rejecting a program the backend had already emitted
// correctly, which is the one case where this branch can change the answer at all: this
// runs AFTER ShaderCompiler::LinkProgram/mapIO, so for every other shape the qualifier
// already carries the resolver's verdict.
//
// The two other consumers spell the same rule: TMglGlslIoResolver only writes the API
// index into the qualifier when it is non-zero, and ProgramInterface falls back to
// type.layoutIndex when GetFragmentDataIndex answers 0. All three now agree.
//
// Against the spec (GL 4.6 core 15.2.3): where a fragment output's index is given by a
// shader layout qualifier, that value is used and anything bound through
// BindFragDataLocation(Indexed) is IGNORED - the same precedence layout(location) has
// over glBindAttribLocation. That is stricter than "non-zero API wins", and the two
// differ in exactly one shape: an explicit `index = 0` in the shader against an API
// index of 1, where the spec keeps 0 and this codebase takes 1. That divergence lives
// in the resolver (it decides what is emitted); it is pre-existing, out of scope here,
// and deliberately not re-litigated in a third place - matching the resolver is what
// keeps validation checking what was actually built.
Int colorIndex = 0;
if (const auto explicitIndex = in.explicitFragDataIndex.find(outputName);
explicitIndex != in.explicitFragDataIndex.end()) {
colorIndex = static_cast<Int>(explicitIndex->second);
}
if (colorIndex == 0) {
if (const glslang::TType* outputType = output.getType();
outputType != nullptr && outputType->getQualifier().hasIndex()) {
colorIndex = static_cast<Int>(outputType->getQualifier().layoutIndex);
}
}
const Int span = std::max<Int>(output.size, 1);
if (location < 0 || location + span > in.maxFragmentOutputColorNumber) {
@@ -1881,10 +1927,16 @@ namespace MobileGL::MG_State::GLState {
}
for (Int colorNumber = location; colorNumber < location + span; ++colorNumber) {
auto [owner, inserted] = colorNumberOwners.emplace(colorNumber, outputName);
const Int64 slot = (static_cast<Int64>(colorIndex) << 32) |
static_cast<Int64>(static_cast<Uint32>(colorNumber));
auto [owner, inserted] = colorSlotOwners.emplace(slot, outputName);
if (!inserted) {
artifacts.infoLog = std::format("Fragment outputs '{}' and '{}' alias color number {}.",
owner->second, outputName, colorNumber);
artifacts.infoLog =
colorIndex == 0
? std::format("Fragment outputs '{}' and '{}' alias color number {}.", owner->second,
outputName, colorNumber)
: std::format("Fragment outputs '{}' and '{}' alias color number {} at index {}.",
owner->second, outputName, colorNumber, colorIndex);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
ProgramObject::ResetLinkArtifacts(artifacts);
return false;
@@ -1910,10 +1962,18 @@ namespace MobileGL::MG_State::GLState {
return true;
}
// Capture happens at the last vertex-processing stage (geometry, then
// tessellation evaluation, then vertex).
// Capture happens at the last vertex-processing stage (geometry, then tessellation
// evaluation, then tessellation CONTROL, then vertex). All four are vertex-processing
// stages in GL 4.6 core 11 - the control shader included - and in a separable program
// whose only stage is a TCS it is the last one that exists, so it is the capture stage
// and such a program MUST link (GL 4.6 core 7.3/11.1.2.1; the conformance suite spells
// the API split out at esextcTessellationShaderXFB.cpp:390-416, where a non-ES context
// takes should_succeed=true). TessControl sits AFTER TessEvaluation so a complete
// pipeline still captures at the evaluation stage and only a TCS-only program falls
// through to it. If MobileGL ever serves an ES context this arm has to be gated on the
// advertised API: ES requires the very same link to FAIL.
const glslang::TIntermediate* captureIntermediate = nullptr;
for (EShLanguage stage : {EShLangGeometry, EShLangTessEvaluation, EShLangVertex}) {
for (EShLanguage stage : {EShLangGeometry, EShLangTessEvaluation, EShLangTessControl, EShLangVertex}) {
captureIntermediate = artifacts.program->getIntermediate(stage);
if (captureIntermediate != nullptr) {
break;
@@ -554,7 +554,8 @@ namespace MobileGL::MG_State::GLState {
// asked for is the safer of the two readings.
if (task->in.requestedXfbVaryings.empty()) {
for (const ShaderStage captureStage:
{ShaderStage::Geometry, ShaderStage::TessEval, ShaderStage::Vertex}) {
{ShaderStage::Geometry, ShaderStage::TessEval, ShaderStage::TessControl,
ShaderStage::Vertex}) {
Bool stagePresent = false;
for (const auto& shader : m_shaders) {
if (!shader || shader->GetShaderStage() != captureStage) continue;
@@ -99,9 +99,14 @@ namespace MobileGL::MG_State::GLState {
// is deliberately left unbound so it samples as (0,0,0,1)). Deliberately coarse - ANY
// texture, ANY sampler - so that no mutation can slip past a per-unit binding memo; the
// setters that feed it all early-out when the value is unchanged, so the redundant
// glTexParameteri calls applications issue every frame do not churn it. Kept separate
// from the bind generation because the sampled texture SET is unaffected by these, and
// the Vulkan backend's set memo keys on that one.
// glTexParameteri calls applications issue every frame do not churn it.
//
// Kept separate from the bind generation because the two answer different questions, NOT
// because the sampled texture SET is immune to this one - it is not, and the claim that
// it was is what this comment used to say. DirectVulkan leaves an incomplete texture out
// of the set entirely and substitutes a fallback, so completeness decides membership, and
// its sampled-set memo carries THIS generation alongside the bind one. Any memo of a
// resolved per-unit binding - or of which textures a draw samples at all - needs both.
Uint64 GetSamplingResolutionGeneration() const { return m_samplingResolutionGeneration; }
void BumpSamplingResolutionGeneration() { ++m_samplingResolutionGeneration; }
@@ -29,7 +29,9 @@ using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITEONLY_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageArrayUnitPlan;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemapImageArrayElementUnits;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::PointSizeExtensionName;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestPointSizeExtension;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestExtendedImageFormats;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestViewportArrayExtension;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::SplitReadWriteImageUniforms;
@@ -1289,6 +1291,80 @@ void main() { gl_ViewportIndex = 1; imageStore(uni_image, ivec2(0), uvec4(1u));
EXPECT_TRUE(Contains(out, "#extension GL_OES_viewport_array : require\n")) << out;
}
// --- tessellation / geometry gl_PointSize directive ---------------------------------------------
//
// ESSL 320 makes the tessellation and geometry STAGES core and still leaves gl_PointSize out of
// their gl_PerVertex entirely - it is only there under EXT/OES_tessellation_point_size resp.
// EXT/OES_geometry_point_size. SPIRV-Cross only ever sees a SPIR-V BuiltIn PointSize decoration
// and prints the identifier bare, so without this directive the stage fails to compile with
// "`gl_PointSize' undeclared", which takes the WHOLE program to program 0: the draw renders
// nothing and glBeginTransformFeedback on that program is rejected outright, so a capture of
// anything at all off it silently comes back empty. That is the shape of the 108 conformance
// bodies (36 per API tree) in tessellation_control_to_tessellation_evaluation.gl_MaxPatch-
// Vertices_Position_PointSize whose point_mode half puts gl_PointSize in the patch.
TEST(PointSizeExtensionNameTest, NamesBothSpellingsOfBothExtensions) {
using Tier = MG_External::GLESCapabilities::PointSizeTier;
EXPECT_STREQ(PointSizeExtensionName(Tier::ExtensionEXT, true), "GL_EXT_tessellation_point_size");
EXPECT_STREQ(PointSizeExtensionName(Tier::ExtensionOES, true), "GL_OES_tessellation_point_size");
EXPECT_STREQ(PointSizeExtensionName(Tier::ExtensionEXT, false), "GL_EXT_geometry_point_size");
EXPECT_STREQ(PointSizeExtensionName(Tier::ExtensionOES, false), "GL_OES_geometry_point_size");
}
// The two extensions are separate and neither implies the other, so the tessellation answer must
// never be handed to a geometry stage or the other way round - an `#extension` naming a string
// the driver does not advertise is itself a compile error on a strict compiler.
TEST(PointSizeExtensionNameTest, NoTierMeansNoDirective) {
using Tier = MG_External::GLESCapabilities::PointSizeTier;
EXPECT_EQ(PointSizeExtensionName(Tier::None, true), nullptr);
EXPECT_EQ(PointSizeExtensionName(Tier::None, false), nullptr);
}
TEST(RequestPointSizeExtensionTest, TheDirectiveGoesRightAfterTheVersionLine) {
const String source = R"(#version 320 es
layout(triangles, point_mode, cw, equal_spacing) in;
void main() { gl_Position = vec4(0.0); gl_PointSize = 5.0; }
)";
const String out = RequestPointSizeExtension(source, "GL_EXT_tessellation_point_size");
EXPECT_TRUE(Contains(out, "#version 320 es\n#extension GL_EXT_tessellation_point_size : require\n")) << out;
}
// The nullptr contract, and the reason it exists: a driver that advertises neither spelling gets
// NOTHING added rather than a directive it would reject on top of the error it already has.
TEST(RequestPointSizeExtensionTest, ANullNameMeansNotEmitted) {
const String source = R"(#version 320 es
layout(triangles, point_mode, cw, equal_spacing) in;
void main() { gl_Position = vec4(0.0); gl_PointSize = 5.0; }
)";
EXPECT_EQ(RequestPointSizeExtension(source, nullptr), source);
}
TEST(RequestPointSizeExtensionTest, AnAlreadyPresentDirectiveIsNotDuplicated) {
const String source = R"(#version 320 es
#extension GL_OES_tessellation_point_size : require
layout(triangles, point_mode, cw, equal_spacing) in;
void main() { gl_PointSize = 5.0; }
)";
const String out = RequestPointSizeExtension(source, "GL_OES_tessellation_point_size");
EXPECT_EQ(out, source);
EXPECT_EQ(CountOf(out, "GL_OES_tessellation_point_size"), 1u) << out;
}
// Shares its insertion point with the viewport-array and image-format directives, so a stage
// needing more than one must end up with all of them and with #version still first.
TEST(RequestPointSizeExtensionTest, CoexistsWithTheOtherHeaderDirectives) {
const String source = R"(#version 320 es
layout(points) in;
layout(points, max_vertices = 1) out;
void main() { gl_ViewportIndex = 1; gl_PointSize = 2.0; EmitVertex(); }
)";
const String out = RequestPointSizeExtension(RequestViewportArrayExtension(source, true),
"GL_EXT_geometry_point_size");
EXPECT_EQ(out.find("#version 320 es"), 0u) << out;
EXPECT_TRUE(Contains(out, "#extension GL_OES_viewport_array : require\n")) << out;
EXPECT_TRUE(Contains(out, "#extension GL_EXT_geometry_point_size : require\n")) << out;
}
// --- pass-through tessellation control stage --------------------------------------------------
//
// Desktop GL makes the tessellation control stage optional and takes the levels from
@@ -1303,6 +1379,20 @@ namespace {
const FloatVec2 kDefaultInner(1.0f, 1.0f);
} // namespace
// The synthesized stage mirrors its neighbours' gl_PerVertex, so it can be the thing that
// declares gl_PointSize - and in ESSL a redeclaration is exactly as illegal as a reference
// without the extension. The directive has to survive being applied to its output.
TEST(RequestPointSizeExtensionTest, CoversAMirroredPassthroughControlStage) {
const String out = RequestPointSizeExtension(
BuildPassthroughTessControlEssl(320, 4, " highp vec4 gl_Position; highp float gl_PointSize; ",
" highp vec4 gl_Position; highp float gl_PointSize; ", kDefaultOuter,
kDefaultInner),
"GL_EXT_tessellation_point_size");
EXPECT_EQ(out.find("#version 320 es"), 0u) << out;
EXPECT_TRUE(Contains(out, "#extension GL_EXT_tessellation_point_size : require\n")) << out;
EXPECT_TRUE(Contains(out, "float gl_PointSize")) << out;
}
TEST(PassthroughTessControlEsslTest, DeclaresThePatchSizeAndWritesEveryTessLevel) {
const String out = BuildPassthroughTessControlEssl(320, 4, "", "", kDefaultOuter, kDefaultInner);
EXPECT_EQ(out.find("#version 320 es"), 0u) << out;
@@ -1034,14 +1034,69 @@ namespace {
if (index < kRecordedDrawBuffers) g_driverIndexedColorMasks[index] = {true, r, g, b, a};
}
// What the blend block of SyncRenderState pushed. Enough to answer the two questions the
// dual-source cases ask: is blending on for a draw buffer, and which factor enums reached
// the driver.
struct RecordedBlend {
Bool enabled = false;
Bool enableSeen = false;
Bool factorsSeen = false;
GLenum srcRGB = 0, dstRGB = 0, srcAlpha = 0, dstAlpha = 0;
};
RecordedBlend g_driverBlend[kRecordedDrawBuffers];
void ResetRecordedBlend() {
for (auto& recorded : g_driverBlend) recorded = {};
}
void RecordBlendEnable(Bool enabled) {
for (auto& recorded : g_driverBlend) {
recorded.enabled = enabled;
recorded.enableSeen = true;
}
}
void RecordBlendFactors(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) {
for (auto& recorded : g_driverBlend) {
recorded.factorsSeen = true;
recorded.srcRGB = srcRGB;
recorded.dstRGB = dstRGB;
recorded.srcAlpha = srcAlpha;
recorded.dstAlpha = dstAlpha;
}
}
void StubViewport(GLint, GLint, GLsizei, GLsizei) {}
void StubScissor(GLint, GLint, GLsizei, GLsizei) {}
void StubEnable(GLenum) {}
void StubDisable(GLenum) {}
void StubEnablei(GLenum, GLuint) {}
void StubDisablei(GLenum, GLuint) {}
void StubBlendFuncSeparate(GLenum, GLenum, GLenum, GLenum) {}
void StubBlendFuncSeparatei(GLuint, GLenum, GLenum, GLenum, GLenum) {}
void StubEnable(GLenum cap) {
if (cap == GL_BLEND) RecordBlendEnable(true);
}
void StubDisable(GLenum cap) {
if (cap == GL_BLEND) RecordBlendEnable(false);
}
void StubEnablei(GLenum cap, GLuint index) {
if (cap == GL_BLEND && index < kRecordedDrawBuffers) {
g_driverBlend[index].enabled = true;
g_driverBlend[index].enableSeen = true;
}
}
void StubDisablei(GLenum cap, GLuint index) {
if (cap == GL_BLEND && index < kRecordedDrawBuffers) {
g_driverBlend[index].enabled = false;
g_driverBlend[index].enableSeen = true;
}
}
void StubBlendFuncSeparate(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) {
RecordBlendFactors(srcRGB, dstRGB, srcAlpha, dstAlpha);
}
void StubBlendFuncSeparatei(GLuint index, GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) {
if (index >= kRecordedDrawBuffers) return;
g_driverBlend[index].factorsSeen = true;
g_driverBlend[index].srcRGB = srcRGB;
g_driverBlend[index].dstRGB = dstRGB;
g_driverBlend[index].srcAlpha = srcAlpha;
g_driverBlend[index].dstAlpha = dstAlpha;
}
void StubBlendEquationSeparate(GLenum, GLenum) {}
void StubBlendEquationSeparatei(GLuint, GLenum, GLenum) {}
void StubBlendColor(GLfloat, GLfloat, GLfloat, GLfloat) {}
@@ -1066,7 +1121,9 @@ namespace {
// into a driver that this process never made current.
class ScopedRenderStateDriverStubs {
public:
ScopedRenderStateDriverStubs():
// dualSourceBlendSupported models GL_EXT_blend_func_extended on the ES driver, which is
// the one capability in here that a real device is commonly WITHOUT.
explicit ScopedRenderStateDriverStubs(Bool dualSourceBlendSupported = true):
m_funcs(MG_Backend::DirectGLES::g_GLESFuncs), m_caps(MG_Backend::DirectGLES::g_GLESCapabilities) {
auto& gl = MG_Backend::DirectGLES::g_GLESFuncs;
gl = MG_External::GLESFunctionsTable{};
@@ -1102,9 +1159,10 @@ namespace {
caps.SupportsIndexedColorMask = true;
caps.SupportsSrgbWriteControl = false;
caps.SupportsPolygonMode = false;
caps.SupportsDualSourceBlend = true;
caps.SupportsDualSourceBlend = dualSourceBlendSupported;
ResetRecordedColorMasks();
ResetRecordedBlend();
// The viewport and scissor blocks fall back to querying the surface size when the
// frontend's rectangle is degenerate, and there is no surface in this process.
MG_Impl::GLImpl::Viewport(0, 0, 4, 4);
@@ -1119,6 +1177,11 @@ namespace {
// The shadow now describes pushes that went to the stubs, not to any driver.
MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState();
MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
// Blend state is per-CONTEXT and the context outlives the fixture, so a case that
// enabled blending or asked for an exotic factor has to put it back or every later
// case in this binary inherits it.
MG_Impl::GLImpl::Disable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_ONE, GL_ZERO);
}
private:
@@ -1253,6 +1316,143 @@ TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffe
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// --- Dual-source blending without GL_EXT_blend_func_extended ------------------------------------
//
// GL_SRC1_* blend factors are core GL since 3.3, GLES core has nothing equivalent, and the ES
// driver may or may not carry GL_EXT_blend_func_extended. When it does, the factors translate and
// blend properly - the positive case below. When it does not, the blend block used to
// THROW_EXCEPTION, which is a plain `throw` (MG_Util/Types.h) with no catch anywhere in MG_Impl or
// MG_Backend, so it unwound out through the C GL ABI and killed the process over one unsupported
// blend factor. It now DECLINES: the draw buffer is pushed with blending off and neutral One/Zero
// factors, and the loss is logged once.
//
// Both halves are asserted at the seam that matters - what the ES driver is actually handed -
// because a GL_SRC1_* enum reaching a driver without the extension is the other failure mode: the
// driver answers GL_INVALID_ENUM, keeps whatever factors were set before, and mis-blends silently.
TEST_F(FramebufferTest, DualSourceBlendFactorsReachTheDriverWhenTheExtensionIsThere) {
ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/true);
MG_Impl::GLImpl::Enable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "GL_SRC1_* is core since 3.3; glBlendFunc must take it";
ResetRecordedBlend();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_TRUE(g_driverBlend[0].factorsSeen);
EXPECT_TRUE(g_driverBlend[0].enabled) << "nothing may decline a blend the driver can do";
EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast<GLenum>(GL_SRC1_COLOR));
EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_COLOR));
EXPECT_EQ(g_driverBlend[0].srcAlpha, static_cast<GLenum>(GL_SRC1_COLOR));
EXPECT_EQ(g_driverBlend[0].dstAlpha, static_cast<GLenum>(GL_ONE_MINUS_SRC1_COLOR));
}
TEST_F(FramebufferTest, DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensionIsMissing) {
ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/false);
MG_Impl::GLImpl::Enable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR)
<< "the FRONTEND accepts the factor whatever the driver can do - the decline is a backend decision";
ResetRecordedBlend();
// The whole point: this used to be `throw std::runtime_error` straight through the GL ABI.
ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false));
ASSERT_TRUE(g_driverBlend[0].enableSeen) << "the blend enable still has to be pushed";
EXPECT_FALSE(g_driverBlend[0].enabled) << "a blend the driver cannot do is declined, not attempted";
for (Uint i = 0; i < kRecordedDrawBuffers; ++i) {
EXPECT_NE(g_driverBlend[i].srcRGB, static_cast<GLenum>(GL_SRC1_ALPHA))
<< "draw buffer " << i << ": no GL_SRC1_* enum may reach a driver without the extension";
EXPECT_NE(g_driverBlend[i].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].srcAlpha, static_cast<GLenum>(GL_SRC1_ALPHA)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].dstAlpha, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i;
}
// The decline is scoped to the offending factor, not to blending as a whole: an ordinary
// blend on the same driver still goes through, and the SAME sync that declined the first one
// is what has to push it.
MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
ResetRecordedBlend();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_TRUE(g_driverBlend[0].factorsSeen);
EXPECT_TRUE(g_driverBlend[0].enabled);
EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast<GLenum>(GL_SRC_ALPHA));
EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC_ALPHA));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// The half the first version of the decline missed: the FACTOR push is not gated on Enabled, so
// GL_BLEND being OFF does not keep a GL_SRC1_* enum away from a driver that cannot parse it. This
// is the sequence - `glDisable(GL_BLEND); glBlendFunc(GL_SRC1_ALPHA, ...)` then any draw or clear -
// and it needs no dual-source shader at all, which is why it survived both the enabled-path unit
// case above and the integration scenario (that one skips on exactly the extension-less lanes this
// concerns, because its probe needs a dual-source program to render).
//
// What a leaked enum costs: the driver answers GL_INVALID_ENUM and keeps its previous factors, so
// the error sits in the ES context's own queue for the next internal `glGetError() == GL_NO_ERROR`
// probe to read as its own failure, and this backend's shadow records factors the context rejected.
TEST_F(FramebufferTest, DualSourceFactorsAreDeclinedEvenWithBlendingDisabled) {
ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/false);
MG_Impl::GLImpl::Disable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA);
ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
ResetRecordedBlend();
ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false));
for (Uint i = 0; i < kRecordedDrawBuffers; ++i) {
EXPECT_FALSE(g_driverBlend[i].enabled) << "draw buffer " << i << ": blending was never enabled";
EXPECT_NE(g_driverBlend[i].srcRGB, static_cast<GLenum>(GL_SRC1_ALPHA))
<< "draw buffer " << i
<< ": a GL_SRC1_* enum must not reach a driver without the extension even with GL_BLEND off";
EXPECT_NE(g_driverBlend[i].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].srcAlpha, static_cast<GLenum>(GL_SRC1_ALPHA)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].dstAlpha, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i;
}
// A clear reaches the same block by the same route (SyncRenderState(forColorClear=true)), and
// the flag only steers the alpha-widen colour mask, so it must not reopen this either.
MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR);
ResetRecordedBlend();
ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true));
for (Uint i = 0; i < kRecordedDrawBuffers; ++i) {
EXPECT_NE(g_driverBlend[i].srcRGB, static_cast<GLenum>(GL_SRC1_COLOR)) << "draw buffer " << i;
EXPECT_NE(g_driverBlend[i].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_COLOR)) << "draw buffer " << i;
}
// And the shadow records what was PUSHED, not what the frontend holds - otherwise the next
// switch to an ordinary factor diffs against state the ES context never received.
MG_Impl::GLImpl::Enable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
ResetRecordedBlend();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_TRUE(g_driverBlend[0].factorsSeen);
EXPECT_TRUE(g_driverBlend[0].enabled) << "the enable has to be pushed - the shadow said 'off' because it was";
EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast<GLenum>(GL_SRC_ALPHA));
EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC_ALPHA));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// The capable driver is unaffected by the ungating: GL_BLEND off with SRC1 factors set is a state
// an application may legitimately hold, and the factors still have to reach a driver that parses
// them - otherwise the next glEnable(GL_BLEND) would blend against neutralised state.
TEST_F(FramebufferTest, DualSourceFactorsWithBlendingDisabledStillReachACapableDriver) {
ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/true);
MG_Impl::GLImpl::Disable(GL_BLEND);
MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA);
ResetRecordedBlend();
MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false);
ASSERT_TRUE(g_driverBlend[0].factorsSeen);
EXPECT_FALSE(g_driverBlend[0].enabled);
EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast<GLenum>(GL_SRC1_ALPHA));
EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast<GLenum>(GL_ONE_MINUS_SRC1_ALPHA));
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
}
// --- glFramebufferTexture error conditions (GL 4.6 core 9.2.8) ---------------------------------
//
// Four of them were missing from the bound-target path while its DSA sibling
+17
View File
@@ -180,6 +180,22 @@ target_link_libraries(
${LINK_LIBRARIES}
)
add_executable(
TessellationLinkTest
TessellationLinkTest.cpp
)
target_include_directories(TessellationLinkTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
TessellationLinkTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
add_executable(
ProgramPipelineCompositeTest
ProgramPipelineCompositeTest.cpp
@@ -229,6 +245,7 @@ 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)
gtest_discover_tests(TessellationLinkTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# Heavier than the rest of the unit suite by design: several cases deliberately saturate the
# compile pool so there is something in flight to race against.
gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
+125
View File
@@ -4023,6 +4023,131 @@ void main() { mgColor = vec4(1.0); }
)";
} // namespace
// Two fragment outputs on ONE location with DIFFERENT colour indices is not an aliasing error -
// it is dual-source blending (GL 4.6 core 11.1.3 / ARB_blend_func_extended, core since 3.3), and
// the GL_SRC1_* blend factors have nothing to read without it. The link-time aliasing check keyed
// on the colour number alone, so every such program failed to link with "alias color number 0"
// and the whole feature was unreachable from shader-side GLSL.
TEST_F(ProgramTest, FragmentOutputsMayShareALocationWhenTheirColorIndexDiffers) {
constexpr const char* dualSourceFs = R"(#version 460 core
layout(location = 0, index = 0) out vec4 fragColor0;
layout(location = 0, index = 1) out vec4 fragColor1;
void main() { fragColor0 = vec4(1.0); fragColor1 = vec4(0.5); }
)";
const GLuint program = LinkStages({{GL_VERTEX_SHADER, kPassthroughVs}, {GL_FRAGMENT_SHADER, dualSourceFs}});
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
ASSERT_EQ(linkStatus, GL_TRUE) << [&] {
char log[512] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}();
// Both outputs are active and both sit on colour number 0 - which is the shape that used to be
// refused. (glGetFragDataIndex still answers 0 for the index-1 output: it reports only what
// glBindFragDataLocationIndexed bound, and reflecting the shader-side qualifier is a separate
// gap, so it is deliberately not asserted here.)
EXPECT_EQ(GetFragDataLocation(program, "fragColor0"), 0);
EXPECT_EQ(GetFragDataLocation(program, "fragColor1"), 0);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The check it must NOT stop making: two outputs on the same colour number AND the same index
// really do alias, and that link has to fail. Aliased through glBindFragDataLocation rather than
// through two `layout(location = 0)` qualifiers on purpose - the qualifier form is caught by
// glslang at COMPILE time, so it would never reach the link-time rule this pins.
TEST_F(ProgramTest, FragmentOutputsSharingAColorNumberAtTheSameIndexStillFailToLink) {
constexpr const char* twoOutputFs = R"(#version 460 core
out vec4 fragColorA;
out vec4 fragColorB;
void main() { fragColorA = vec4(1.0); fragColorB = vec4(0.5); }
)";
const GLuint program = CreateProgram();
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
ShaderSource(vs, 1, &kPassthroughVs, nullptr);
CompileShader(vs);
AttachShader(program, vs);
DeleteShader(vs);
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &twoOutputFs, nullptr);
CompileShader(fs);
AttachShader(program, fs);
DeleteShader(fs);
BindFragDataLocation(program, 0, "fragColorA");
BindFragDataLocation(program, 0, "fragColorB");
LinkProgram(program);
GLint linkStatus = GL_TRUE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
EXPECT_EQ(linkStatus, GL_FALSE);
char infoLog[512] = "";
GetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
EXPECT_NE(std::string(infoLog).find("alias color number"), std::string::npos) << infoLog;
// ...and the same pair separated by the colour INDEX links, which is the whole point of the
// key being a pair.
BindFragDataLocationIndexed(program, 0, 1, "fragColorB");
LinkProgram(program);
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
EXPECT_EQ(linkStatus, GL_TRUE) << [&] {
char log[512] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}();
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
// An API colour index of ZERO is "no override", not "index 0". glBindFragDataLocation is
// glBindFragDataLocationIndexed with index 0 (GL_Program.cpp), so the blanket-bind pattern -
// portable code that binds every output name it knows about, without caring about dual-source -
// writes a real 0 into the frag-data index map for an output whose shader qualifier says 1.
// Reading that 0 as an override collapsed both outputs onto slot (0,0) and failed the link as an
// alias, while the IO resolver had left the qualifier at 1 and the emitted SPIR-V still carried
// Index 1 - validation rejecting a program the backend had already built correctly.
//
// The rule pinned here is the codebase's (non-zero API index wins, zero falls back to the shader
// qualifier), which is also what GL 4.6 core 15.2.3 gives for THIS shape: a shader layout
// qualifier is used and the bound value ignored.
TEST_F(ProgramTest, AnApiColorIndexOfZeroDoesNotOverrideTheShaderIndexQualifier) {
constexpr const char* dualSourceFs = R"(#version 460 core
layout(location = 0, index = 0) out vec4 fragColor0;
layout(location = 0, index = 1) out vec4 fragColor1;
void main() { fragColor0 = vec4(1.0); fragColor1 = vec4(0.5); }
)";
const GLuint program = CreateProgram();
const GLuint vs = CreateShader(GL_VERTEX_SHADER);
ShaderSource(vs, 1, &kPassthroughVs, nullptr);
CompileShader(vs);
AttachShader(program, vs);
DeleteShader(vs);
const GLuint fs = CreateShader(GL_FRAGMENT_SHADER);
ShaderSource(fs, 1, &dualSourceFs, nullptr);
CompileShader(fs);
AttachShader(program, fs);
DeleteShader(fs);
// The blanket bind: colour number 0, index 0, on the output the shader put at index 1.
BindFragDataLocation(program, 0, "fragColor0");
BindFragDataLocation(program, 0, "fragColor1");
LinkProgram(program);
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
EXPECT_EQ(linkStatus, GL_TRUE) << [&] {
char log[512] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}();
// The explicit indexed form with a NON-zero index is still an override, and still links.
BindFragDataLocationIndexed(program, 0, 1, "fragColor1");
LinkProgram(program);
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
EXPECT_EQ(linkStatus, GL_TRUE);
EXPECT_EQ(GetFragDataIndex(program, "fragColor1"), 1);
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
TEST_F(ProgramTest, GetProgramivReportsTheGeometryStageLinkProperties) {
constexpr const char* gs = R"(#version 460 core
layout(triangles, invocations = 3) in;
@@ -0,0 +1,285 @@
// MobileGL - MobileGL/MG_Test/Program/TessellationLinkTest.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
// Link-time properties of programs that carry a tessellation control stage. GPU-free:
// everything asserted here is a property of the link, not of any driver.
//
// Two independent defects live here, both found by KHR-GL4x.tessellation_shader:
//
// (1) The transform-feedback capture stage. GL 4.6 core 11 makes the tessellation CONTROL
// shader a vertex-processing stage like the other three, so in a separable program whose
// only stage is a TCS it is the LAST vertex-processing stage and therefore the capture
// stage - such a program must link with transform-feedback varyings requested. MobileGL
// searched {geometry, tessellation evaluation, vertex} only and refused the link with
// "Transform feedback varyings requested but the program has no vertex-processing stage",
// failing KHR-GL4x.tessellation_shader.single.xfb_captures_data_from_correct_stage on all
// three API versions (esextcTessellationShaderXFB.cpp:390-416 passes should_succeed=true
// for a non-ES context; ES demands the opposite, which is why the new arm is documented as
// desktop-GL-only at the search site).
//
// (2) `patch out T name[N]` against `patch in T name[N]`. Legal, identically spelled on both
// sides, and rejected until the glslang fork was re-pinned at d89cf443 - see the last case,
// which carries the diagnosis and now guards the pin.
#include <gtest/gtest.h>
#include <ios>
#include <string>
#include <utility>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_State/GLState/Core.h"
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
namespace {
class TessellationLinkTest: public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
};
std::string ShaderLog(GLuint shader) {
char log[4096] = "";
GetShaderInfoLog(shader, sizeof(log), nullptr, log);
return std::string(log);
}
std::string LinkLog(GLuint program) {
char log[4096] = "";
GetProgramInfoLog(program, sizeof(log), nullptr, log);
return std::string(log);
}
GLint Programiv(GLuint program, GLenum pname) {
GLint value = -1;
GetProgramiv(program, pname, &value);
return value;
}
// Attaches one compiled shader of each requested stage. Compilation is asserted, so a
// failure here is a shader bug in the test rather than a link result.
GLuint MakeProgram(const std::vector<std::pair<GLenum, const char*>>& stages, Bool separable) {
const GLuint program = CreateProgram();
if (separable) {
ProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);
}
for (const auto& [type, source]: stages) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
CompileShader(shader);
GLint compiled = GL_FALSE;
GetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
EXPECT_EQ(compiled, GL_TRUE) << "stage 0x" << std::hex << type << "\n" << ShaderLog(shader);
AttachShader(program, shader);
}
return program;
}
// The conformance suite's own tessellation control shader
// (esextcTessellationShaderXFB.cpp:360-381), with the ES-only ${...} expansions dropped -
// on a desktop context they expand to nothing.
constexpr const char* kCtsTessControl = R"(#version 460 core
layout (vertices=4) out;
in BLOCK_INOUT { vec4 value; } user_in[];
out BLOCK_INOUT { vec4 value; } user_out[];
void main()
{
gl_out [gl_InvocationID].gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
user_out [gl_InvocationID].value = vec4(2.0, 3.0, 4.0, 5.0);
gl_TessLevelOuter[0] = 1.0;
gl_TessLevelOuter[1] = 1.0;
}
)";
// A tessellation control shader IS a vertex-processing stage (GL 4.6 core 11), and in a
// TCS-only separable program it is the last one - so it is the capture stage and the link
// must succeed with the block member resolved against ITS outputs.
TEST_F(TessellationLinkTest, TcsOnlySeparableProgramWithXfbVaryingsLinks) {
const GLuint program = MakeProgram({{GL_TESS_CONTROL_SHADER, kCtsTessControl}}, /*separable=*/true);
const GLchar* const varyings[1] = {"BLOCK_INOUT.value"};
TransformFeedbackVaryings(program, 1, varyings, GL_SEPARATE_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
// The request resolved rather than being quietly dropped: the interface reports it back.
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS), 1);
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_BUFFER_MODE), GL_SEPARATE_ATTRIBS);
GLchar name[128] = {'\0'};
GLsizei length = 0;
GLsizei size = 0;
GLenum type = 0;
GetTransformFeedbackVarying(program, 0, sizeof(name), &length, &size, &type, name);
EXPECT_EQ(std::string(name, name + (length < 0 ? 0 : length)), "BLOCK_INOUT.value");
EXPECT_EQ(type, static_cast<GLenum>(GL_FLOAT_VEC4));
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
}
// Control: the same program with no capture request. It linked before the fix too, which is
// what keeps this case honest about WHICH half of the link moved.
TEST_F(TessellationLinkTest, TcsOnlySeparableProgramWithoutXfbVaryingsLinks) {
const GLuint program = MakeProgram({{GL_TESS_CONTROL_SHADER, kCtsTessControl}}, /*separable=*/true);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS), 0);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
}
// A program with no vertex-processing stage at all still has to be refused - the fix widened
// the search, it did not remove the check.
TEST_F(TessellationLinkTest, FragmentOnlySeparableProgramWithXfbVaryingsStillFailsToLink) {
constexpr const char* fs = R"(#version 460 core
out vec4 color;
void main() { color = vec4(1.0); }
)";
const GLuint program = MakeProgram({{GL_FRAGMENT_SHADER, fs}}, /*separable=*/true);
const GLchar* const varyings[1] = {"color"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
EXPECT_EQ(Programiv(program, GL_LINK_STATUS), GL_FALSE);
EXPECT_NE(LinkLog(program).find("no vertex-processing stage"), std::string::npos) << LinkLog(program);
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
constexpr const char* kPassthroughVs = R"(#version 460 core
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";
constexpr const char* kTcsWithPatchScalar = R"(#version 460 core
layout (vertices = 3) out;
patch out vec4 tcs_patch;
out vec4 tcs_per_vertex[];
void main() {
tcs_patch = vec4(1.0);
tcs_per_vertex[gl_InvocationID] = vec4(2.0);
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_TessLevelOuter[0] = 1.0; gl_TessLevelOuter[1] = 1.0; gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
constexpr const char* kTesWithPatchScalar = R"(#version 460 core
layout (triangles) in;
patch in vec4 tcs_patch;
in vec4 tcs_per_vertex[];
out vec4 tes_out;
void main() {
tes_out = tcs_patch + tcs_per_vertex[0];
gl_Position = gl_in[0].gl_Position;
}
)";
// The capture stage of a COMPLETE pipeline is unchanged by the widened search: tessellation
// control sits AFTER tessellation evaluation in the order, so a program that has both still
// resolves its capture names against the EVALUATION stage's outputs. Both halves are pinned -
// an evaluation output resolves, a control output does not.
TEST_F(TessellationLinkTest, CompletePipelineStillCapturesAtTheEvaluationStage) {
{
const GLuint program = MakeProgram({{GL_VERTEX_SHADER, kPassthroughVs},
{GL_TESS_CONTROL_SHADER, kTcsWithPatchScalar},
{GL_TESS_EVALUATION_SHADER, kTesWithPatchScalar}},
/*separable=*/true);
const GLchar* const varyings[1] = {"tes_out"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(Programiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS), 1);
}
{
// tcs_per_vertex is an output of the CONTROL stage, which is not the capture stage
// here. Resolving it would mean capturing at the wrong stage, so the link must fail.
const GLuint program = MakeProgram({{GL_VERTEX_SHADER, kPassthroughVs},
{GL_TESS_CONTROL_SHADER, kTcsWithPatchScalar},
{GL_TESS_EVALUATION_SHADER, kTesWithPatchScalar}},
/*separable=*/true);
const GLchar* const varyings[1] = {"tcs_per_vertex"};
TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS);
LinkProgram(program);
EXPECT_EQ(Programiv(program, GL_LINK_STATUS), GL_FALSE) << LinkLog(program);
}
for (int i = 0; i < 32 && GetError() != GL_NO_ERROR; ++i) {
}
}
// A patch-qualified SCALAR crosses the TCS/TES boundary today. It is the control for the
// array case below: same qualifier, same stages, only the arrayness differs.
TEST_F(TessellationLinkTest, PatchQualifiedScalarLinksAcrossTheTessellationStages) {
const GLuint program = MakeProgram({{GL_VERTEX_SHADER, kPassthroughVs},
{GL_TESS_CONTROL_SHADER, kTcsWithPatchScalar},
{GL_TESS_EVALUATION_SHADER, kTesWithPatchScalar}},
/*separable=*/true);
LinkProgram(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE) << LinkLog(program);
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
}
// `patch out int a[N]` against `patch in int a[N]`: legal GLSL, identical spellings, and until
// the glslang fork was re-pinned at d89cf443 refused with "Array sizes must be compatible"
// while printing the two sides as the same type. That is what failed
// KHR-GL4x.tessellation_shader.tessellation_shader_tc_barriers.* on all three API versions.
//
// The defect was one asymmetric clause in glslang, never in MobileGL:
// 3rdparty/glslang/glslang/MachineIndependent/linkValidate.cpp, TIntermediate::isIoResizeArray.
// The TessControl arm is guarded with `&& ! type.getQualifier().patch`; the TessEvaluation arm
// was not. A patch-qualified array therefore answered false on the control side and true on the
// evaluation side, and the caller's dimension arithmetic (linkValidate.cpp:1200-1218) computed
// (numDim - firstDim) == (unitNumDim - unitFirstDim) as (1 - 0) == (1 - 1), i.e. false. The
// fork now mirrors the control arm, so both sides answer false, the comparison falls to
// sameArrayness, and identical int[16] declarations match.
//
// A hard assertion, with no escape hatch: this case carried a message-matched GTEST_SKIP while
// the fix was outstanding, and leaving it in after the pin moved would turn a rolled-back fork
// into a silent skip instead of the failure it should be.
TEST_F(TessellationLinkTest, PatchQualifiedArrayLinksAcrossTheTessellationStages) {
constexpr const char* tcs = R"(#version 460 core
layout (vertices = 3) out;
patch out int tcs_patch_result[16];
void main() {
for (int i = 0; i < 16; ++i) { tcs_patch_result[i] = i; }
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
gl_TessLevelOuter[0] = 1.0; gl_TessLevelOuter[1] = 1.0; gl_TessLevelOuter[2] = 1.0;
gl_TessLevelInner[0] = 1.0;
}
)";
constexpr const char* tes = R"(#version 460 core
layout (triangles) in;
patch in int tcs_patch_result[16];
out vec4 tes_out;
void main() {
tes_out = vec4(float(tcs_patch_result[0] + tcs_patch_result[15]));
gl_Position = gl_in[0].gl_Position;
}
)";
const GLuint program = MakeProgram({{GL_VERTEX_SHADER, kPassthroughVs},
{GL_TESS_CONTROL_SHADER, tcs},
{GL_TESS_EVALUATION_SHADER, tes}},
/*separable=*/true);
LinkProgram(program);
const std::string log = LinkLog(program);
ASSERT_EQ(Programiv(program, GL_LINK_STATUS), GL_TRUE)
<< "a patch-qualified array must cross the TCS/TES boundary; if this says \"Array sizes "
"must be compatible\" the glslang fork pin has lost the isIoResizeArray patch guard.\n"
<< log;
EXPECT_EQ(GetError(), static_cast<GLenum>(GL_NO_ERROR));
}
} // namespace
+70 -2
View File
@@ -827,10 +827,11 @@ TEST_F(QueryTest, DisableTimerQueryFeatureMatchesEnvironment) {
// ---------------------------------------------------------------------------------------------
TEST_F(QueryTest, PipelineStatisticsTargetsAreAcceptedAndReportZeroCounterBits) {
// The NINE unconditional targets. The two tessellation ones are conditional on tessellation
// support and have their own test below.
static constexpr GLenum kTargets[] = {
GL_VERTICES_SUBMITTED, GL_PRIMITIVES_SUBMITTED,
GL_VERTEX_SHADER_INVOCATIONS, GL_TESS_CONTROL_SHADER_PATCHES,
GL_TESS_EVALUATION_SHADER_INVOCATIONS, GL_GEOMETRY_SHADER_INVOCATIONS,
GL_VERTEX_SHADER_INVOCATIONS, GL_GEOMETRY_SHADER_INVOCATIONS,
GL_GEOMETRY_SHADER_PRIMITIVES_EMITTED, GL_FRAGMENT_SHADER_INVOCATIONS,
GL_COMPUTE_SHADER_INVOCATIONS, GL_CLIPPING_INPUT_PRIMITIVES,
GL_CLIPPING_OUTPUT_PRIMITIVES,
@@ -872,6 +873,73 @@ TEST_F(QueryTest, PipelineStatisticsTargetsAreAcceptedAndReportZeroCounterBits)
}
}
// GL_TESS_CONTROL_SHADER_PATCHES / GL_TESS_EVALUATION_SHADER_INVOCATIONS are the two
// pipeline-statistics targets ARB_pipeline_statistics_query makes CONDITIONAL on tessellation
// support, and the extension string is the only thing an application can read to decide whether
// an implementation has it. So the target and the string have to move together: accepting a
// tessellation-conditional token while withholding the string that announces the condition is
// self-contradictory, and the conformance suite catches exactly that contradiction
// (KHR-GL46.pipeline_statistics_query_tests_ARB.api_coverage_unsupported_calls demands
// GL_INVALID_ENUM for every target its own probe calls unsupported, and its probe for these two
// is `compatibility(4,0) || GL_ARB_tessellation_shader` - a CORE context fails the first half).
//
// Written against the advertisement rather than against today's answer on purpose: the day a
// backend starts emitting GL_ARB_tessellation_shader this test keeps passing and keeps pinning
// the coupling, and it fails loudly if only one of the two halves moves.
TEST_F(QueryTest, TessellationPipelineStatisticsTargetsFollowTheTessellationShaderAdvertisement) {
const auto* extensionsString =
reinterpret_cast<const char*>(MG_Impl::GLImpl::GetString(GL_EXTENSIONS));
ASSERT_NE(extensionsString, nullptr);
const Bool advertised = String(extensionsString).find("GL_ARB_tessellation_shader") != String::npos;
const GLenum expectedError = advertised ? GL_NO_ERROR : GL_INVALID_ENUM;
static constexpr GLenum kTessTargets[] = {
GL_TESS_CONTROL_SHADER_PATCHES,
GL_TESS_EVALUATION_SHADER_INVOCATIONS,
};
for (const GLenum target: kTessTargets) {
GLuint id = 0;
MG_Impl::GLImpl::GenQueries(1, &id);
ASSERT_NE(id, 0u);
MG_Impl::GLImpl::BeginQuery(target, id);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), expectedError)
<< "glBeginQuery on tessellation pipeline-statistics target 0x" << std::hex << target
<< " must agree with the GL_ARB_tessellation_shader advertisement";
if (advertised) {
MG_Impl::GLImpl::EndQuery(target);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
GLint counterBits = -1;
MG_Impl::GLImpl::GetQueryiv(target, GL_QUERY_COUNTER_BITS, &counterBits);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_EQ(counterBits, 0);
} else {
// Refused at glEndQuery too, not just at glBeginQuery: a target the implementation
// does not have is not half-accepted.
MG_Impl::GLImpl::EndQuery(target);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_ENUM);
// GL_QUERY_COUNTER_BITS still answers the honest zero rather than an error - the
// getter has never validated its target, and zero is what "no such counter" reads as
// (GL 4.6 core 4.2.1), so the refusal costs no information.
GLint counterBits = -1;
MG_Impl::GLImpl::GetQueryiv(target, GL_QUERY_COUNTER_BITS, &counterBits);
EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR);
EXPECT_EQ(counterBits, 0);
// And GL_CURRENT_QUERY reads as "no query" rather than tracking a slot that was
// never opened.
GLint current = -1;
MG_Impl::GLImpl::GetQueryiv(target, GL_CURRENT_QUERY, &current);
EXPECT_EQ(current, 0);
}
MG_Impl::GLImpl::DeleteQueries(1, &id);
while (MG_Impl::GLImpl::GetError() != GL_NO_ERROR) {
}
}
}
// The negative half the conformance case actually asserts: an object already latched onto one
// pipeline-statistics target must refuse a different one with GL_INVALID_OPERATION. This is what
// per-target active slots buy - a single shared slot would have reported "a query is already
@@ -984,6 +984,20 @@ namespace MobileGL::MG_Util::BackendLoader {
if (std::strcmp(extension, "GL_OES_viewport_array") == 0) {
caps.SupportsViewportArray = true;
}
// EXT wins where both are advertised: it is the spelling the Android Extension
// Pack mandates, so it is the one a driver is most likely to have tested.
if (std::strcmp(extension, "GL_EXT_tessellation_point_size") == 0) {
caps.TessellationPointSizeSupport = MG_External::GLESCapabilities::PointSizeTier::ExtensionEXT;
} else if (std::strcmp(extension, "GL_OES_tessellation_point_size") == 0 &&
caps.TessellationPointSizeSupport == MG_External::GLESCapabilities::PointSizeTier::None) {
caps.TessellationPointSizeSupport = MG_External::GLESCapabilities::PointSizeTier::ExtensionOES;
}
if (std::strcmp(extension, "GL_EXT_geometry_point_size") == 0) {
caps.GeometryPointSizeSupport = MG_External::GLESCapabilities::PointSizeTier::ExtensionEXT;
} else if (std::strcmp(extension, "GL_OES_geometry_point_size") == 0 &&
caps.GeometryPointSizeSupport == MG_External::GLESCapabilities::PointSizeTier::None) {
caps.GeometryPointSizeSupport = MG_External::GLESCapabilities::PointSizeTier::ExtensionOES;
}
}
}
// The pointer check on top of the extension check makes each flag sufficient on its own
@@ -1055,6 +1069,19 @@ namespace MobileGL::MG_Util::BackendLoader {
MGLOG_I(" clip distances (EXT_clip_cull_distance): %s", caps.SupportsClipDistance ? "yes" : "no");
MGLOG_I(" viewport array (OES_viewport_array; gl_ViewportIndex collapses to viewport 0 when absent): %s",
caps.SupportsViewportArray ? "yes" : "no");
{
const auto pointSizeTierName = [](MG_External::GLESCapabilities::PointSizeTier tier) {
switch (tier) {
case MG_External::GLESCapabilities::PointSizeTier::ExtensionEXT: return "EXT";
case MG_External::GLESCapabilities::PointSizeTier::ExtensionOES: return "OES";
default: return "no";
}
};
MGLOG_I(" tessellation gl_PointSize (EXT/OES_tessellation_point_size): %s",
pointSizeTierName(caps.TessellationPointSizeSupport));
MGLOG_I(" geometry gl_PointSize (EXT/OES_geometry_point_size): %s",
pointSizeTierName(caps.GeometryPointSizeSupport));
}
// LOAD-BEARING STRING, not just a banner. android-plugin/trace-replay-ci.sh's
// is_angle_surface_lost() greps mobilegl.log for exactly "OpenGL ES capabilities:" to
@@ -1118,6 +1118,23 @@ namespace MobileGL {
ExtensionOES, // GL_OES_texture_buffer; ESSL below 320 must say GL_OES_texture_buffer
};
TextureBufferTier TextureBufferSupport = TextureBufferTier::None;
// Which spelling of per-vertex point size a NON-VERTEX stage has, if any. In desktop
// GL gl_PointSize is an ordinary gl_PerVertex member that any vertex-processing stage
// may write and any program may capture by name; in ESSL it does not EXIST in a
// tessellation or geometry stage until GL_EXT/OES_tessellation_point_size (resp.
// ..._geometry_point_size) is requested - not even at 320, where the stages
// themselves are core. SPIRV-Cross prints the identifier bare and asks for nothing,
// exactly as it does for gl_ViewportIndex, so the directive has to be inserted into
// the emitted source (RequestPointSizeExtension) and a driver with neither spelling
// cannot compile such a stage at all. Extension string only: these add no entry
// points, so there is no pointer to require.
enum class PointSizeTier : Uint8 {
None = 0, // neither spelling; the stage cannot name gl_PointSize
ExtensionEXT, // GL_EXT_tessellation_point_size / GL_EXT_geometry_point_size
ExtensionOES, // GL_OES_tessellation_point_size / GL_OES_geometry_point_size
};
PointSizeTier TessellationPointSizeSupport = PointSizeTier::None;
PointSizeTier GeometryPointSizeSupport = PointSizeTier::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
@@ -9,6 +9,7 @@
#include "Loader.h"
#include <Config.h>
#include <algorithm>
#include <cmath>
#include <limits>
@@ -177,7 +178,15 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(p.limits);
caps.MaxIntegerSamples = MaxSampleCountFromFlags(p.limits.sampledImageIntegerSampleCounts);
caps.MaxSamples = caps.MaxFramebufferSamples;
caps.MaxSampleMaskWords = SaturateToInt(p.limits.maxSampleMaskWords);
// Clamped to one word, exactly as the GLES loader clamps the driver's value and for the
// same reason: MobileGL's sample-mask state IS a single 32-bit word
// (RenderState::SampleMaskValue) and SampleMaski_State() raises GL_INVALID_VALUE for any
// maskNumber other than 0. dEQP's per-case gluStateReset issues glSampleMaski up to
// GL_MAX_SAMPLE_MASK_WORDS, so advertising a device's real 2 would abort the whole glcts
// process after every single case - the failure da6f75dbd added the GLES clamp to stop,
// reproduced on this backend. One word is the spec minimum and therefore always legal.
// It is also what PipelineCreatePayload::sampleMask is sized for.
caps.MaxSampleMaskWords = std::min(SaturateToInt(p.limits.maxSampleMaskWords), 1);
caps.MaxTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
caps.MaxVertexTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
caps.MaxComputeTextureImageUnits = SaturateToInt(p.limits.maxPerStageDescriptorSampledImages);
@@ -300,7 +309,15 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.MaxFramebufferSamples = ResolveConservativeFramebufferSampleLimit(properties.limits);
caps.MaxIntegerSamples = MaxSampleCountFromFlags(properties.limits.sampledImageIntegerSampleCounts);
caps.MaxSamples = caps.MaxFramebufferSamples;
caps.MaxSampleMaskWords = SaturateToInt(properties.limits.maxSampleMaskWords);
// Clamped to one word, exactly as the GLES loader clamps the driver's value and for the
// same reason: MobileGL's sample-mask state IS a single 32-bit word
// (RenderState::SampleMaskValue) and SampleMaski_State() raises GL_INVALID_VALUE for any
// maskNumber other than 0. dEQP's per-case gluStateReset issues glSampleMaski up to
// GL_MAX_SAMPLE_MASK_WORDS, so advertising a device's real 2 would abort the whole glcts
// process after every single case - the failure da6f75dbd added the GLES clamp to stop,
// reproduced on this backend. One word is the spec minimum and therefore always legal.
// It is also what PipelineCreatePayload::sampleMask is sized for.
caps.MaxSampleMaskWords = std::min(SaturateToInt(properties.limits.maxSampleMaskWords), 1);
caps.MaxTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
caps.MaxVertexTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
caps.MaxComputeTextureImageUnits = SaturateToInt(properties.limits.maxPerStageDescriptorSampledImages);
@@ -758,6 +758,63 @@ namespace MobileGL {
return false;
}
Bool ShaderCompiler::ModuleDeclaresTransformFeedback(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
return false;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ModuleDeclaresTransformFeedback"),
spirv.data(), spirv.size());
if (!context) {
// Unparseable is not a capture verdict; say no, which makes the caller decline
// the span rather than issue transform-feedback commands against it.
return false;
}
// The exact question VUID-vkCmdBeginTransformFeedbackEXT-None-04128 asks of the
// bound pipeline's last pre-rasterization stage: was it declared with the Xfb
// execution mode. Reading the execution modes rather than the TransformFeedback
// capability because the capability can legally be declared by a module that has
// no Xfb entry point, and the VUID is about the mode.
for (const spvtools::opt::Instruction& mode : context->module()->execution_modes()) {
if (mode.NumInOperands() >= 2 &&
static_cast<spv::ExecutionMode>(mode.GetSingleWordInOperand(1)) ==
spv::ExecutionMode::Xfb) {
return true;
}
}
return false;
}
Bool ShaderCompiler::ModuleDeclaresTessellationOrGeometryPointSize(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
return false;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1,
MakeSpirvMessageConsumer("ModuleDeclaresTessellationOrGeometryPointSize"), spirv.data(),
spirv.size());
if (!context) {
// Unparseable is not a verdict about point size. Say no, so the caller keeps
// building the program: the module is already broken for other reasons and
// the diagnostics that own that failure are better placed than this one.
return false;
}
// The CAPABILITY, not the BuiltIn decoration, because the capability is exactly
// what the feature gates: a module may declare gl_PerVertex with a PointSize
// member and never access it, and glslang then emits no capability
// (GlslangToSpv defers it to actual use) - such a module is legal without the
// feature and must not be declined.
for (const spvtools::opt::Instruction& capability : context->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared = static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize ||
declared == spv::Capability::GeometryPointSize) {
return true;
}
}
return false;
}
Bool ShaderCompiler::ModuleDeclaresFloat64(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
// Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced
@@ -532,6 +532,21 @@ namespace MobileGL {
// 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);
// Does this module carry the Xfb execution mode - i.e. would a
// vkCmdBeginTransformFeedbackEXT against a pipeline whose last pre-rasterization
// stage is this module satisfy VUID-vkCmdBeginTransformFeedbackEXT-None-04128?
// Asked of the FINAL bytes, so it answers for whatever the backend transform
// chain actually produced rather than for what it was asked to produce.
static Bool ModuleDeclaresTransformFeedback(const Vector<Uint32>& spirv);
// Does this module declare TessellationPointSize or GeometryPointSize - i.e. does
// it need VkPhysicalDeviceFeatures::shaderTessellationAndGeometryPointSize before
// a pipeline built from it is legal usage (VUID-RuntimeSpirv-PointSize-06439)?
// glslang emits either capability from any access to the PointSize built-in in a
// tessellation or geometry stage, which desktop GL treats as an ordinary
// per-vertex output, so a program that is perfectly legal in GL can need a Vulkan
// feature the device does not have. Callers only ask when the feature is OFF, so
// the module parse costs nothing on a device that has it.
static Bool ModuleDeclaresTessellationOrGeometryPointSize(const Vector<Uint32>& spirv);
// True when the module still declares a 64-bit float type. After
// SanitizeAndOptimizeBinary that can only mean DemoteFloat64Pass declined the