Compare commits

..
8 Commits
Author SHA1 Message Date
swung0x48 3049c4b82b [Fix] (ShaderTranspiler): stop blanking block comments in the source handed to glslang
- BlankBlockComments replaced comment chars with spaces but preserved interior newlines, so a block comment spanning a newline inside a #define truncated the macro body (VALUE became empty)
- glslang has a conformant preprocessor and collapses a block comment to one space across newlines, so the delivered source now keeps comments intact and lets glslang handle them
- Fixes KHR-GL3x.shaders.preprocessor multiline_comment_define / redefine_object_multiline_comment / function_redefinition_3 (6 cases, both devices)
- FilterUnsupportedGpuShaderInt64 relied on the blanking to skip commented-out #extension lines; it now masks comments locally (MaskCommentsAndQuotedText) like the sibling passes, collecting edits and applying them back-to-front
- conditional_inclusion.basic_2 (defined() via macro expansion) stays failing by design: glslang rejects it as UB and working around it would mean re-running preprocessing MobileGL defers to glslang
2026-07-21 05:27:01 -04:00
swung0x48 2b3850b76b [Fix] (DirectGLES): upload RGB565/RGB5_A1 shadow data as packed 16-bit types
- The 8-bit unorm shadow was uploaded as GL_UNSIGNED_BYTE, leaving the 8->5/6-bit requantization to the driver
- That rounding direction is implementation-defined: Adreno rounds to nearest (lossless round trip), Mali floors
- On Mali mid-range texels drifted one 5-bit step down, failing all 20 KHR-GL33.pixelstoragemodes.teximage3d rgb565/rgb5a1 cases (eps 1/32); layers with exact values (0.125/0.25/1.0-ish) passed, matching the observed 0,1,7-valid pattern
- PreparePackedNormUpload repacks shadow rows to GL_UNSIGNED_SHORT_5_6_5 / 5_5_5_1 with round-to-nearest, which exactly recovers the original 5/6-bit values (the shadow expansion is injective), so the driver stores them verbatim
- Idempotent across each region's level loop (glType is shared); RGBA4 exempt since its 8-bit expansion (v*17) is exact under either rounding
- Wired at all four SyncMipmapsToBackend upload regions (append-mipmaps, immutable TexSubImage, mutable full, dirty-level update)
2026-07-21 04:30:46 -04:00
swung0x48 2a0ae743a0 [Fix] (DirectGLES): glFinish before the glGetTexImage temp-FBO readback
- Mali (tile-based) does not resolve a texture's render into memory when it is read back through a different (temp) FBO than the one it was rendered with
- The cross-FBO glReadPixels raced the deferred tile resolve and returned pre-render clear contents
- Distinct render targets read back byte-identical, so KHR-GLxx.glsl_noperspective failed on Mali-G715 (all four programs read as the clear colour)
- glGetTexImage is already a CPU/GPU sync point so the extra drain is negligible; Adreno resolves eagerly and was unaffected
2026-07-21 03:34:33 -04:00
swung0x48 6839219c10 [Fix] (GLImpl): report GL_NO_ERROR from glGetGraphicsResetStatus
- The generic export stub returned (GLenum)1; dEQP reads any non-zero status as a lost device
- It is polled after every case (gl3cTestPackages.cpp:121) and sets QP_TEST_RESULT_DEVICE_LOST
- Under the default --deqp-terminate-on-device-lost=enable that tears the whole CTS run down
- MobileGL tracks no GPU resets, so GL_NO_ERROR ("no reset detected") is the honest, spec-correct answer
- Routed through GLImpl::GetGraphicsResetStatus like every other entry point, no inline body in Definitions.cpp
2026-07-21 02:40:15 -04:00
swung0x48 52ddb440ca [Feat] (SelfTest/DriverPost): add a noperspective correctness check to the GLES POST - render a strong-perspective quad and read the centre texel to verify the varying interpolates screen-linear (not perspective-correct), carried through the native GL_NV_shader_noperspective_interpolation path when present or MobileGL's exact gl_Position.w/gl_FragCoord.w emulation when absent. PASS = native and correct; WARN = emulated and correct (the fallback path shipping packs hit on such devices); FAIL = interpolation wrong/perspective-correct, or the program will not build. The ESSL header matches the device version because noperspective is rejected at #version 300 es on some drivers even with the extension enabled 2026-07-21 02:09:17 -04:00
swung0x48 79feeffd25 [Feat] (ShaderTranspiler, DirectGLES): emulate noperspective on GLES devices lacking GL_NV_shader_noperspective_interpolation - EmulateNoPerspectivePass pre-multiplies each NoPerspective output by gl_Position.w in the vertex stage and recovers each input via gl_FragCoord.w in the fragment stage (exact screen-linear L = P(a*w)*gl_FragCoord.w, handling whole-variable and component/access-chain reads, scalar and vector varyings), forces highp on emulated varyings, and strips what it cannot emulate; replaces the smooth-strip fallback so no NV extension is ever required. Restricts the vertex pre-multiply to the entry function so a non-inlined helper cannot double-scale 2026-07-20 23:48:48 -04:00
swung0x48 202037b5a3 [Fix] (DirectVulkan): shrink the blended depth-write quirk to MIN/MAX extremum blends only - a fixture-wide trace sweep showed the additive ONE+ONE arm never fires on the 26.3 OIT chain (its accumulation passes disable depth writes themselves) and only hit unrelated additive glow content; also fail reflection toward the gl_FragDepth exemption and zero phantom default-FBO blend slots so stale indexed state cannot trigger the strip 2026-07-20 23:39:28 -04:00
swung0x48 bce9c48c8e [Feat] (ShaderTranspiler, DirectGLES): support noperspective conformantly instead of stripping it - let the qualifier reach glslang as the core SPIR-V NoPerspective decoration (native on DirectVulkan; SPIRV-Cross emits ESSL noperspective + GL_NV_shader_noperspective_interpolation on DirectGLES), and for GLES devices lacking that extension add StripNoPerspectivePass to drop the decoration and fall back to smooth; the old naked substring erase discarded the interpolation shader packs need and mangled identifiers containing the word 2026-07-20 22:59:43 -04:00
22 changed files with 1434 additions and 59 deletions
+2
View File
@@ -192,6 +192,8 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
@@ -4002,6 +4002,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
GLuint backendTexId = backendTextureIt->second->GetBackendTextureId(); GLuint backendTexId = backendTextureIt->second->GetBackendTextureId();
MGLOG_D("GetTexImage: backend texture id = %u", backendTexId); MGLOG_D("GetTexImage: backend texture id = %u", backendTexId);
// Force pending rendering to complete before reading the texture back through the temp READ FBO.
// Tile-based GPUs (Mali) do not guarantee that a render into this texture through its own FBO has
// been resolved to memory when it is subsequently sampled through a *different* (temp) FBO: the
// cross-FBO glReadPixels below races the deferred tile resolve and returns the pre-render (clear)
// contents, so distinct render targets read back byte-identical (e.g. KHR-GLxx.glsl_noperspective
// fails on Mali-G715, all four programs reading as the clear colour). glGetTexImage is already a
// CPU/GPU sync point, so the extra drain is negligible; Adreno resolves eagerly and is unaffected.
g_GLESFuncs.glFinish();
MGLOG_D("GetTexImage: Binding temporary FBO"); MGLOG_D("GetTexImage: Binding temporary FBO");
TempFBOBinder tempFBOBinder(true); TempFBOBinder tempFBOBinder(true);
@@ -1563,6 +1563,56 @@ namespace MobileGL::MG_Backend::DirectGLES {
return convertedData.data(); return convertedData.data();
} }
// RGB565/RGB5_A1 shadow data is stored as 8-bit unorm; uploading it as GL_UNSIGNED_BYTE
// leaves the 8-bit -> 5/6-bit requantization to the driver, whose rounding direction is
// implementation-defined: Adreno rounds to nearest (lossless round trip) but Mali floors,
// drifting mid-range texels one 5-bit step down and failing the KHR-GL3x
// pixelstoragemodes.teximage3d rgb565/rgb5a1 1/32-eps checks. Repack the shadow rows into
// the packed 16-bit client type with round-to-nearest instead - that recovers the original
// 5/6-bit values exactly (the shadow expansion round(v * 255 / max) is injective), so the
// driver stores them verbatim with no requantization left to its discretion. 4-bit formats
// (RGBA4) are exempt: their 8-bit expansion (v * 17) is exact under either rounding.
// Always retargets *inOutType for these formats (even for null data) so every upload of a
// level uses the same client type.
static const void* PreparePackedNormUpload(TextureInternalFormat format, const IntVec3& texelSize,
const void* data, SizeT byteSize, GLenum* inOutType,
Vector<Uint8>& packedData) {
if (format != TextureInternalFormat::RGB5 && format != TextureInternalFormat::RGB5A1) {
return data;
}
const Bool hasAlpha = format == TextureInternalFormat::RGB5A1;
const GLenum packedType = hasAlpha ? GL_UNSIGNED_SHORT_5_5_5_1 : GL_UNSIGNED_SHORT_5_6_5;
// Idempotent across a region's level loop: glType is shared, so later levels arrive with
// the already-retargeted packed type and must still be converted.
if (*inOutType != GL_UNSIGNED_BYTE && *inOutType != packedType) {
return data;
}
*inOutType = packedType;
if (data == nullptr || byteSize == 0) {
return data;
}
const SizeT srcPixelBytes = hasAlpha ? 4 : 3;
const SizeT texelCount = std::min(static_cast<SizeT>(std::max(texelSize.x(), 0)) *
static_cast<SizeT>(std::max(texelSize.y(), 0)) *
static_cast<SizeT>(std::max(texelSize.z(), 1)),
byteSize / srcPixelBytes);
packedData.resize(texelCount * sizeof(Uint16));
const Uint8* src = static_cast<const Uint8*>(data);
auto* dst = reinterpret_cast<Uint16*>(packedData.data());
for (SizeT i = 0; i < texelCount; ++i, src += srcPixelBytes) {
const Uint32 r = (static_cast<Uint32>(src[0]) * 31u + 127u) / 255u;
const Uint32 b = (static_cast<Uint32>(src[2]) * 31u + 127u) / 255u;
if (hasAlpha) {
const Uint32 g = (static_cast<Uint32>(src[1]) * 31u + 127u) / 255u;
dst[i] = static_cast<Uint16>((r << 11) | (g << 6) | (b << 1) | (src[3] >= 128 ? 1u : 0u));
} else {
const Uint32 g = (static_cast<Uint32>(src[1]) * 63u + 127u) / 255u;
dst[i] = static_cast<Uint16>((r << 11) | (g << 5) | b);
}
}
return packedData.data();
}
void BackendTextureObject::SyncMipmapsToBackend( void BackendTextureObject::SyncMipmapsToBackend(
const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject) { const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject) {
if (!stateTextureObject) { if (!stateTextureObject) {
@@ -1706,6 +1756,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
const void* uploadData = PrepareNormFloatFallbackUpload( const void* uploadData = PrepareNormFloatFallbackUpload(
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
convertedUploadData); convertedUploadData);
Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
uploadData, levelByteSize, &glType, packedUploadData);
DebugImpl::ErrorLopper::Clear(); DebugImpl::ErrorLopper::Clear();
g_GLESFuncs.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); g_GLESFuncs.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
@@ -1831,6 +1884,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
const void* uploadData = PrepareNormFloatFallbackUpload( const void* uploadData = PrepareNormFloatFallbackUpload(
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
convertedUploadData); convertedUploadData);
Vector<Uint8> packedUploadData;
uploadData =
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
uploadData, levelByteSize, &glType, packedUploadData);
DebugImpl::ErrorLopper::Clear(); DebugImpl::ErrorLopper::Clear();
g_GLESFuncs.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); g_GLESFuncs.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
@@ -1885,6 +1942,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
const void* uploadData = PrepareNormFloatFallbackUpload( const void* uploadData = PrepareNormFloatFallbackUpload(
textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType,
convertedUploadData); convertedUploadData);
Vector<Uint8> packedUploadData;
uploadData =
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
uploadData, levelByteSize, &glType, packedUploadData);
MGLOG_D("%s: target: %s: syncing mip %d: %dx%dx%d, byteSize = %d, pData = %p, " MGLOG_D("%s: target: %s: syncing mip %d: %dx%dx%d, byteSize = %d, pData = %p, "
"levelDirty = %s", "levelDirty = %s",
__func__, MG_Util::ConvertTextureUploadTargetToString(uploadTarget).c_str(), __func__, MG_Util::ConvertTextureUploadTargetToString(uploadTarget).c_str(),
@@ -1990,6 +2051,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
const void* uploadData = PrepareNormFloatFallbackUpload( const void* uploadData = PrepareNormFloatFallbackUpload(
textureMipmapObject->GetFormat(), texelSize, mipData, byteSize, glType, textureMipmapObject->GetFormat(), texelSize, mipData, byteSize, glType,
convertedUploadData); convertedUploadData);
Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize,
uploadData, byteSize, &glType, packedUploadData);
const IntVec3 uploadSize = const IntVec3 uploadSize =
GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize); GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize);
switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) { switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) {
@@ -2790,6 +2854,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &uboPrecisionSpirv; effectiveSpirv = &uboPrecisionSpirv;
} }
// noperspective is core desktop GLSL and reaches here as the SPIR-V NoPerspective
// decoration. SPIRV-Cross renders it as ESSL `noperspective` + `#extension
// GL_NV_shader_noperspective_interpolation : require`; a driver without that extension
// rejects the require. So on such devices emulate screen-linear interpolation instead
// (pre-multiply outputs by gl_Position.w, recover inputs via gl_FragCoord.w) and drop
// the decoration - exact, extension-free. Devices that have the extension keep the
// decoration and let the hardware do it natively.
Vector<unsigned int> noperspectiveSpirv;
if (!g_GLESCapabilities.SupportsNoperspectiveInterpolation &&
MG_Util::ShaderTranspiler::ShaderCompiler::EmulateNoPerspectiveForEssl(
*effectiveSpirv, noperspectiveSpirv) &&
!noperspectiveSpirv.empty()) {
effectiveSpirv = &noperspectiveSpirv;
}
MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv, MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv,
MG_Util::ShaderTranspiler::SessionUsageBit::Transpile); MG_Util::ShaderTranspiler::SessionUsageBit::Transpile);
@@ -131,25 +131,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
} }
namespace { namespace {
// Order-independent accumulation blending: the write order of overlapping fragments // MIN/MAX extremum blending: the signature of a depth-bounds accumulation pass
// does not change the result, which is what lets multi-pass chains re-rasterize the // (MC 26.3 OIT writes vec4(-linD, linD, deviceZ, 0) under GL_MAX while writing
// same geometry and combine per-pass contributions (MC 26.3 OIT: GL_MAX depth // depth for its equality chain). MIN/MAX ignore blend factors per the Vulkan spec.
// bounds, additive ONE+ONE transmittance/accumulate). Sorted-transparency "over"
// compositing (SRC_ALPHA-style factors) is order-dependent, drawn once per surface,
// and relies on its depth writes for occlusion - it must not be treated as hazardous.
// MIN/MAX ignore blend factors entirely per the Vulkan spec.
// //
// Deliberately color-channel only. A separate-alpha accumulation // Deliberately the ONLY shape stripped. A quirk should touch as little unrelated
// (glBlendEquationSeparate(GL_FUNC_ADD, GL_MAX)) whose color channel is an ordinary // content as possible, and a trace sweep of every fixture showed the wider
// over-blend is not treated as hazardous: no known content pairs that shape with a // alternatives all cost more than they fix:
// depth-equality chain, and widening the test would re-capture sorted transparency. // - additive ONE+ONE with a depth write matched zero draws of the 26.3 chain
// (its transmittance/accumulate passes disable depth writes themselves) - the
// only real content it caught was harmless additive glow effects (Create);
// - sorted-transparency "over" blends (SRC_ALPHA-style) are order-dependent,
// drawn once per surface, and rely on their depth writes for occlusion;
// - separate-alpha accumulation over an over-blending color channel has no
// known pairing with a depth-equality chain (color channel only, see tests).
// If a future workload pairs another blend shape with an equality chain, widen
// this with that evidence in hand rather than pre-emptively.
Bool IsAccumulationBlend(const VkPipelineColorBlendAttachmentState& attachment) { Bool IsAccumulationBlend(const VkPipelineColorBlendAttachmentState& attachment) {
if (attachment.colorBlendOp == VK_BLEND_OP_MIN || attachment.colorBlendOp == VK_BLEND_OP_MAX) { return attachment.colorBlendOp == VK_BLEND_OP_MIN ||
return true; attachment.colorBlendOp == VK_BLEND_OP_MAX;
}
return attachment.colorBlendOp == VK_BLEND_OP_ADD &&
attachment.srcColorBlendFactor == VK_BLEND_FACTOR_ONE &&
attachment.dstColorBlendFactor == VK_BLEND_FACTOR_ONE;
} }
} // namespace } // namespace
@@ -70,11 +70,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// with an equality-inclusive compare on the re-rasterized geometry) requires // with an equality-inclusive compare on the re-rasterized geometry) requires
// cross-pipeline position invariance that some mobile compilers do not provide, even // cross-pipeline position invariance that some mobile compilers do not provide, even
// with the SPIR-V Invariant decoration; whole primitives then drop out of the later // with the SPIR-V Invariant decoration; whole primitives then drop out of the later
// passes. Only order-independent accumulation blends (MIN/MAX, additive ONE+ONE) are // passes. Only MIN/MAX extremum blends are stripped - the signature of such a
// stripped - that is the signature of such equality chains (MC 26.3 OIT) - while // chain's depth-bounds pass (MC 26.3 OIT), and per a fixture-wide trace sweep the
// sorted-transparency "over" compositing (e.g. vanilla MC water, SRC_ALPHA factors), // only depth-writing shape the chain actually uses - so every other blend
// which draws each surface once and depends on its depth writes to occlude later // (sorted-transparency "over" like vanilla MC water, additive glows, ...) keeps
// passes, keeps them. Set at renderer initialization based on the active driver. // its depth writes. Set at renderer initialization based on the active driver.
static void SetSuppressBlendedDepthWrite(Bool enabled); static void SetSuppressBlendedDepthWrite(Bool enabled);
static Bool IsSuppressBlendedDepthWriteEnabled() { return s_suppressBlendedDepthWrite; } static Bool IsSuppressBlendedDepthWriteEnabled() { return s_suppressBlendedDepthWrite; }
// Device gate for the quirk: ForceOn/ForceOff bypass detection, Auto enables it on // Device gate for the quirk: ForceOn/ForceOff bypass detection, Auto enables it on
@@ -1547,6 +1547,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
"ProgramFactory::ReflectFragmentOutputs: failed to create reflection module (result=%d)", "ProgramFactory::ReflectFragmentOutputs: failed to create reflection module (result=%d)",
static_cast<Int>(createResult)); static_cast<Int>(createResult));
if (createResult != SPV_REFLECT_RESULT_SUCCESS) { if (createResult != SPV_REFLECT_RESULT_SUCCESS) {
// Fail toward the exemption: stripping a genuine gl_FragDepth writer would
// corrupt its depth output outright, while wrongly exempting an accumulation
// pass merely reverts that one program to the pre-quirk behavior.
entry.fragmentReplacesDepth = true;
continue; continue;
} }
@@ -3552,6 +3552,17 @@ void main() {
(bufferMask.a() ? VK_COLOR_COMPONENT_A_BIT : 0u)); (bufferMask.a() ? VK_COLOR_COMPONENT_A_BIT : 0u));
Bool effectiveBlendEnabled = blendEnabled; Bool effectiveBlendEnabled = blendEnabled;
MG_State::GLState::ITextureObject* colorAttachmentTexture = nullptr; MG_State::GLState::ITextureObject* colorAttachmentTexture = nullptr;
if (isDefaultDrawFbo && i < drawBuffers.size() &&
drawBuffers[i] == FramebufferAttachmentType::None) {
// The default framebuffer spans the same MAX_DRAW_BUFFERS slots as an FBO
// (slot 0 is the back buffer, or None after glDrawBuffer(GL_NONE); slots
// 1+ are always None). Discard writes and blend state for the None slots
// like the FBO path below does, so stale indexed blend state on phantom
// slots cannot leak into the pipeline - most notably into the blended
// depth-write quirk's accumulation scan.
attachmentColorWriteMask = 0;
effectiveBlendEnabled = false;
}
if (!isDefaultDrawFbo && i < drawBuffers.size()) { if (!isDefaultDrawFbo && i < drawBuffers.size()) {
const auto drawBuffer = drawBuffers[i]; const auto drawBuffer = drawBuffers[i];
colorAttachmentTexture = resolveCompleteColorAttachmentTexture(i); colorAttachmentTexture = resolveCompleteColorAttachmentTexture(i);
@@ -424,7 +424,7 @@ DECLARE_GL_FUNCTION_HEAD(void, DrawRangeElementsBaseVertex, GLenum mode, GLuint
DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseVertex, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseVertex, mode, count, type, indices, instancecount, basevertex) DECLARE_GL_FUNCTION_HEAD(void, DrawElementsInstancedBaseVertex, GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElementsInstancedBaseVertex, mode, count, type, indices, instancecount, basevertex)
DECLARE_GL_FUNCTION_HEAD(void, FramebufferTexture, GLenum target, GLenum attachment, GLuint texture, GLint level) DECLARE_GL_FUNCTION_END_NO_RETURN(void, FramebufferTexture, target, attachment, texture, level) DECLARE_GL_FUNCTION_HEAD(void, FramebufferTexture, GLenum target, GLenum attachment, GLuint texture, GLint level) DECLARE_GL_FUNCTION_END_NO_RETURN(void, FramebufferTexture, target, attachment, texture, level)
DECLARE_GL_FUNCTION_STUB_HEAD(void, PrimitiveBoundingBox, GLfloat minX, GLfloat minY, GLfloat minZ, GLfloat minW, GLfloat maxX, GLfloat maxY, GLfloat maxZ, GLfloat maxW) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PrimitiveBoundingBox, minX, minY, minZ, minW, maxX, maxY, maxZ, maxW) DECLARE_GL_FUNCTION_STUB_HEAD(void, PrimitiveBoundingBox, GLfloat minX, GLfloat minY, GLfloat minZ, GLfloat minW, GLfloat maxX, GLfloat maxY, GLfloat maxZ, GLfloat maxW) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, PrimitiveBoundingBox, minX, minY, minZ, minW, maxX, maxY, maxZ, maxW)
DECLARE_GL_FUNCTION_STUB_HEAD(GLenum, GetGraphicsResetStatus) DECLARE_GL_FUNCTION_STUB_END(GLenum, GetGraphicsResetStatus) DECLARE_GL_FUNCTION_HEAD(GLenum, GetGraphicsResetStatus) DECLARE_GL_FUNCTION_END(GLenum, GetGraphicsResetStatus)
DECLARE_GL_FUNCTION_STUB_HEAD(void, ReadnPixels, GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, GLsizei bufSize, void* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ReadnPixels, x, y, width, height, format, type, bufSize, data) DECLARE_GL_FUNCTION_STUB_HEAD(void, ReadnPixels, GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, GLsizei bufSize, void* data) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, ReadnPixels, x, y, width, height, format, type, bufSize, data)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetnUniformfv, GLuint program, GLint location, GLsizei bufSize, GLfloat* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetnUniformfv, program, location, bufSize, params) DECLARE_GL_FUNCTION_STUB_HEAD(void, GetnUniformfv, GLuint program, GLint location, GLsizei bufSize, GLfloat* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetnUniformfv, program, location, bufSize, params)
DECLARE_GL_FUNCTION_STUB_HEAD(void, GetnUniformiv, GLuint program, GLint location, GLsizei bufSize, GLint* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetnUniformiv, program, location, bufSize, params) DECLARE_GL_FUNCTION_STUB_HEAD(void, GetnUniformiv, GLuint program, GLint location, GLsizei bufSize, GLint* params) DECLARE_GL_FUNCTION_STUB_END_NO_RETURN(void, GetnUniformiv, program, location, bufSize, params)
@@ -1996,4 +1996,12 @@ namespace MobileGL::MG_Impl::GLImpl {
} }
return MG_Util::ConvertErrorCodeToGLEnum(error->get()->code); return MG_Util::ConvertErrorCodeToGLEnum(error->get()->code);
} }
GLenum GetGraphicsResetStatus() {
// MobileGL does not implement robustness reset notification, so report GL_NO_ERROR
// ("no reset detected"). Returning the generic stub's (GLenum)1 makes dEQP read a lost
// device after every case (gl3cTestPackages.cpp:121) and, under the default
// --deqp-terminate-on-device-lost=enable, tear the whole CTS run down.
return GL_NO_ERROR;
}
} // namespace MobileGL::MG_Impl::GLImpl } // namespace MobileGL::MG_Impl::GLImpl
@@ -21,4 +21,5 @@ namespace MobileGL::MG_Impl::GLImpl {
void GetIntegeri_v(GLenum target, GLuint index, GLint* data); void GetIntegeri_v(GLenum target, GLuint index, GLint* data);
void GetInteger64i_v(GLenum target, GLuint index, GLint64* data); void GetInteger64i_v(GLenum target, GLuint index, GLint64* data);
GLenum GetError(); GLenum GetError();
GLenum GetGraphicsResetStatus();
} // namespace MobileGL::MG_Impl::GLImpl } // namespace MobileGL::MG_Impl::GLImpl
+16 -13
View File
@@ -258,11 +258,14 @@ TEST(PipelineQuirkStripDecision, MinBlendIsStripped) {
EXPECT_TRUE(PipelineFactory::ShouldSuppressDepthWrite(payload)); EXPECT_TRUE(PipelineFactory::ShouldSuppressDepthWrite(payload));
} }
TEST(PipelineQuirkStripDecision, AdditiveOnePlusOneIsStripped) { TEST(PipelineQuirkStripDecision, AdditiveOnePlusOneIsNotStripped) {
// MC 26.3 OIT transmittance/accumulate: ONE+ONE additive accumulation writing depth. // ONE+ONE additive with a depth write matched zero draws of the 26.3 chain in the
// fixture sweep (transmittance/accumulate disable depth writes themselves); the only
// real content with this shape was harmless additive glow effects (Create). A quirk
// touches as little unrelated content as possible, so the shape stays exempt.
const auto payload = MakeDepthWritingPayload(MakeBlendAttachment( const auto payload = MakeDepthWritingPayload(MakeBlendAttachment(
true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_ADD, kFullColorWriteMask)); true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_ADD, kFullColorWriteMask));
EXPECT_TRUE(PipelineFactory::ShouldSuppressDepthWrite(payload)); EXPECT_FALSE(PipelineFactory::ShouldSuppressDepthWrite(payload));
} }
TEST(PipelineQuirkStripDecision, SortedTransparencyOverBlendIsNotStripped) { TEST(PipelineQuirkStripDecision, SortedTransparencyOverBlendIsNotStripped) {
@@ -285,9 +288,10 @@ TEST(PipelineQuirkStripDecision, EffectivelyOpaqueBlendIsNotStripped) {
TEST(PipelineQuirkStripDecision, FullyMaskedAccumulationBlendIsNotStripped) { TEST(PipelineQuirkStripDecision, FullyMaskedAccumulationBlendIsNotStripped) {
// Depth-prepass pattern: colorMask(0,0,0,0) with blending left enabled - blending is // Depth-prepass pattern: colorMask(0,0,0,0) with blending left enabled - blending is
// moot, and stripping would delete the entire prepass. // moot, and stripping would delete the entire prepass. MAX so the exemption, not the
// blend-op filter, is what keeps the depth write.
const auto payload = MakeDepthWritingPayload(MakeBlendAttachment( const auto payload = MakeDepthWritingPayload(MakeBlendAttachment(
true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_ADD, 0)); true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_MAX, 0));
EXPECT_FALSE(PipelineFactory::ShouldSuppressDepthWrite(payload)); EXPECT_FALSE(PipelineFactory::ShouldSuppressDepthWrite(payload));
} }
@@ -314,8 +318,8 @@ TEST(PipelineQuirkStripDecision, FragDepthWriterIsExempt) {
} }
TEST(PipelineQuirkStripDecision, AccumulationOnSecondaryAttachmentIsStripped) { TEST(PipelineQuirkStripDecision, AccumulationOnSecondaryAttachmentIsStripped) {
// The hazard is not limited to attachment 0: the 26.3 transmittance pass accumulates // The scan is not limited to attachment 0: an extremum accumulation on any live
// into a 2-target MRT. // attachment marks the pipeline.
PipelineFactory::PipelineCreatePayload payload{}; PipelineFactory::PipelineCreatePayload payload{};
payload.colorAttachmentCount = 2; payload.colorAttachmentCount = 2;
payload.depthTestEnable = true; payload.depthTestEnable = true;
@@ -323,21 +327,20 @@ TEST(PipelineQuirkStripDecision, AccumulationOnSecondaryAttachmentIsStripped) {
payload.colorBlendAttachments[0] = MakeBlendAttachment( payload.colorBlendAttachments[0] = MakeBlendAttachment(
false, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ZERO, VK_BLEND_OP_ADD, kFullColorWriteMask); false, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ZERO, VK_BLEND_OP_ADD, kFullColorWriteMask);
payload.colorBlendAttachments[1] = MakeBlendAttachment( payload.colorBlendAttachments[1] = MakeBlendAttachment(
true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_ADD, kFullColorWriteMask); true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_MAX, kFullColorWriteMask);
EXPECT_TRUE(PipelineFactory::ShouldSuppressDepthWrite(payload)); EXPECT_TRUE(PipelineFactory::ShouldSuppressDepthWrite(payload));
} }
TEST(PipelineQuirkStripDecision, AlphaWeightedAdditiveIsNotStripped) { TEST(PipelineQuirkStripDecision, AlphaWeightedAdditiveIsNotStripped) {
// SRC_ALPHA,ONE additive is order-independent in the color channel but is the classic // SRC_ALPHA,ONE additive: the classic *sorted* particle/glow blend. Kept exempt like
// *sorted* particle/glow blend, not an OIT accumulation pass. Pins the src==ONE clause: // every other ADD-op shape now that the strip is extremum-only.
// without it this state would be stripped.
const auto payload = MakeDepthWritingPayload(MakeBlendAttachment( const auto payload = MakeDepthWritingPayload(MakeBlendAttachment(
true, VK_BLEND_FACTOR_SRC_ALPHA, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_ADD, kFullColorWriteMask)); true, VK_BLEND_FACTOR_SRC_ALPHA, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_ADD, kFullColorWriteMask));
EXPECT_FALSE(PipelineFactory::ShouldSuppressDepthWrite(payload)); EXPECT_FALSE(PipelineFactory::ShouldSuppressDepthWrite(payload));
} }
TEST(PipelineQuirkStripDecision, ReverseSubtractIsNotStripped) { TEST(PipelineQuirkStripDecision, ReverseSubtractIsNotStripped) {
// Deliberate narrowing: only MIN/MAX and ONE+ONE ADD carry the equality-chain // Deliberate narrowing: only the MIN/MAX extremum ops carry the depth-bounds
// signature. SUBTRACT-class ops stay outside the quirk until content demands them. // signature. SUBTRACT-class ops stay outside the quirk until content demands them.
const auto payload = MakeDepthWritingPayload(MakeBlendAttachment( const auto payload = MakeDepthWritingPayload(MakeBlendAttachment(
true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_REVERSE_SUBTRACT, true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_REVERSE_SUBTRACT,
@@ -348,7 +351,7 @@ TEST(PipelineQuirkStripDecision, ReverseSubtractIsNotStripped) {
TEST(PipelineQuirkStripDecision, PartiallyMaskedAccumulationIsStripped) { TEST(PipelineQuirkStripDecision, PartiallyMaskedAccumulationIsStripped) {
// Only a fully masked attachment is exempt; a live alpha channel still accumulates. // Only a fully masked attachment is exempt; a live alpha channel still accumulates.
const auto payload = MakeDepthWritingPayload(MakeBlendAttachment( const auto payload = MakeDepthWritingPayload(MakeBlendAttachment(
true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_ADD, VK_COLOR_COMPONENT_A_BIT)); true, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE, VK_BLEND_OP_MAX, VK_COLOR_COMPONENT_A_BIT));
EXPECT_TRUE(PipelineFactory::ShouldSuppressDepthWrite(payload)); EXPECT_TRUE(PipelineFactory::ShouldSuppressDepthWrite(payload));
} }
@@ -238,6 +238,77 @@ void main() {
} }
} }
// KHR-GL33.shaders.preprocessor.* — a block comment is one preprocessing token that the C/GLSL
// preprocessor replaces with a single space, even when it spans newlines inside a directive. glslang
// handles this natively, so MobileGL must not mangle it. These reproduce the CTS cases that failed
// because comment blanking preserved the interior newline, truncating multi-line #define bodies.
static void ExpectCompiles(MobileGL::ShaderStage stage, GLenum glStage, MobileGL::String source) {
using namespace MG_Util::ShaderTranspiler;
PreprocessShaderSource(stage, source);
ShaderAttrib attrib{.shaderType = glStage, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
TEST_F(ProgramUtilTest, PreprocessMultilineCommentInDefineBodyCompiles) {
ExpectCompiles(ShaderStage::Fragment, GL_FRAGMENT_SHADER,
R"(#version 330
precision mediump float;
out float out0;
#define VALUE /* current
value */ 4.2
void main()
{
out0 = VALUE;
})");
}
TEST_F(ProgramUtilTest, PreprocessRedefineObjectMultilineCommentCompiles) {
ExpectCompiles(ShaderStage::Fragment, GL_FRAGMENT_SHADER,
R"(#version 330
precision mediump float;
out float out0;
# define VAL1 1.0
#define VAL2 2.0
#define RES2 /* fdsjklfdsjkl
dsfjkhfdsjkh
fdsjklhfdsjkh */ (RES1 * VAL2)
#define RES1 (VAL2 / VAL1)
#define RES2 /* ewrlkjhsadf */ (RES1 * VAL2)
#define VALUE (RES2 + RES1)
void main()
{
out0 = VALUE;
})");
}
TEST_F(ProgramUtilTest, PreprocessFunctionMacroRedefinitionMultilineCommentCompiles) {
ExpectCompiles(ShaderStage::Fragment, GL_FRAGMENT_SHADER,
R"(#version 330
precision mediump float;
out float out0;
# define FUNC(a,b) (a +b)
# define FUNC(a,b)(a /* comment
*/ +b)
void main()
{
out0 = FUNC(1.0, 2.0);
})");
}
// Note: KHR-GL3x.shaders.preprocessor.conditional_inclusion.basic_2 (`#define AAA defined(BBB)` used
// in `#if !AAA`) is intentionally NOT handled here. Generating the `defined` operator via macro
// expansion is undefined per the C/GLSL preprocessor spec, and glslang deliberately rejects it
// ("'defined' : cannot use in preprocessor expression when expanded from macros"). Making it pass
// would require MobileGL to run its own macro expansion ahead of glslang, which is exactly the
// preprocessing we defer to glslang; the two cases stay failing by design.
TEST_F(ProgramUtilTest, PreprocessLegacyFragmentShaderModernizesGlmarkStyleSource) { TEST_F(ProgramUtilTest, PreprocessLegacyFragmentShaderModernizesGlmarkStyleSource) {
using namespace MG_Util::ShaderTranspiler; using namespace MG_Util::ShaderTranspiler;
@@ -1089,6 +1160,353 @@ TEST_F(ProgramUtilTest, CompileFragmentShaderWithDiscard) {
} }
} }
// noperspective is core desktop GLSL (1.30+) and maps to the SPIR-V NoPerspective decoration. It must
// reach glslang (not be stripped as text) so the SPIR-V carries the decoration; SPIRV-Cross then emits
// ESSL `noperspective` + the GL_NV_shader_noperspective_interpolation extension. Shader packs
// (Iris/Complementary) depend on it, and KHR-GL33.glsl_noperspective fails if the result matches
// smooth. This is the DirectGLES path with the NV extension available (SPIRV-Cross's default).
TEST_F(ProgramUtilTest, NoperspectiveInterpolationSurvivesToEssl) {
using namespace MG_Util::ShaderTranspiler;
String fs = R"(#version 330 core
noperspective in vec4 vColor;
out vec4 fragColor;
void main() { fragColor = vColor; }
)";
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = fs};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) FAIL() << "compile errc: " << res.error().errc << "\nlog: " << res.error().log;
ProgramAttrib programAttrib{.shaders = {res.value()}};
auto program_res = ShaderCompiler::LinkProgram(programAttrib);
if (!program_res) FAIL() << "link errc: " << program_res.error().errc << "\nlog: " << program_res.error().log;
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *program_res.value()};
auto bin_res = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!bin_res) FAIL() << "spirv errc: " << bin_res.error().errc << "\nlog: " << bin_res.error().log;
ASSERT_EQ(bin_res.value().size(), 1u);
SpvcSession session(bin_res.value()[0], SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
if (!essl) FAIL() << "decompile errc: " << essl.error().errc << "\nlog: " << essl.error().log;
EXPECT_NE(essl.value().find("noperspective"), String::npos)
<< "noperspective was lost before it reached SPIR-V:\n" << essl.value();
EXPECT_NE(essl.value().find("GL_NV_shader_noperspective_interpolation"), String::npos)
<< "SPIRV-Cross must require the NV extension for ES noperspective:\n" << essl.value();
}
// The old handling was a naked substring erase of "noperspective", so any identifier that merely
// contained those characters (a uniform named noperspectiveBlend, say) got mangled. Removing the
// strip fixes it - glslang, which is identifier-aware, is the only thing that should see the keyword.
TEST_F(ProgramUtilTest, PreprocessDoesNotCorruptIdentifiersContainingNoperspective) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 330 core
uniform float noperspectiveBlend;
out vec4 fragColor;
void main() { fragColor = vec4(noperspectiveBlend); }
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
EXPECT_NE(source.find("noperspectiveBlend"), String::npos)
<< "identifier was corrupted by substring stripping:\n" << source;
}
// The DirectGLES fallback for devices without GL_NV_shader_noperspective_interpolation: stripping the
// NoPerspective decoration makes SPIRV-Cross emit a plain smooth varying with no `#extension … :
// require`, so the shader still compiles (rendering as smooth) instead of being rejected by the driver.
TEST_F(ProgramUtilTest, StripNoPerspectiveFallbackProducesPlainEssl) {
using namespace MG_Util::ShaderTranspiler;
String fs = R"(#version 330 core
noperspective in vec4 vColor;
out vec4 fragColor;
void main() { fragColor = vColor; }
)";
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = fs};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) FAIL() << "compile errc: " << res.error().errc << "\nlog: " << res.error().log;
ProgramAttrib programAttrib{.shaders = {res.value()}};
auto program_res = ShaderCompiler::LinkProgram(programAttrib);
if (!program_res) FAIL() << "link errc: " << program_res.error().errc << "\nlog: " << program_res.error().log;
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *program_res.value()};
auto bin_res = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!bin_res) FAIL() << "spirv errc: " << bin_res.error().errc << "\nlog: " << bin_res.error().log;
ASSERT_EQ(bin_res.value().size(), 1u);
// Precondition: with the decoration present the default decompile requires the NV extension.
{
SpvcSession session(bin_res.value()[0], SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
if (!essl) FAIL() << "decompile errc: " << essl.error().errc;
ASSERT_NE(essl.value().find("noperspective"), String::npos) << essl.value();
}
// The fallback strips the decoration -> plain smooth ESSL, no extension require.
Vector<Uint32> stripped;
ASSERT_TRUE(ShaderCompiler::StripNoPerspectiveForEssl(bin_res.value()[0], stripped));
ASSERT_FALSE(stripped.empty());
SpvcSession session(stripped, SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
if (!essl) FAIL() << "decompile errc: " << essl.error().errc << "\nlog: " << essl.error().log;
EXPECT_EQ(essl.value().find("noperspective"), String::npos)
<< "the decoration should be gone:\n" << essl.value();
EXPECT_EQ(essl.value().find("GL_NV_shader_noperspective_interpolation"), String::npos)
<< "no extension require without the decoration:\n" << essl.value();
}
// Directly exercises BOTH decoration forms StripNoPerspectivePass handles: a plain-variable
// OpDecorate NoPerspective (in-operand 1) and an interface-block-member OpMemberDecorate NoPerspective
// (in-operand 2). The ESSL round-trip tests above use only a scalar input, so they never reach the
// member-decorate branch, which a block varying like `in Block { noperspective vec4 c; }` (common in
// shader packs) produces. Unrelated decorations (Flat, Location) must survive untouched.
TEST_F(ProgramUtilTest, StripNoPerspectivePassRemovesBothDecorateForms) {
using namespace MG_Util::ShaderTranspiler;
const String spirvText = R"(
OpCapability Shader
OpMemoryModel Logical GLSL450
OpEntryPoint Fragment %main "main" %plainVar %blockVar %flatVar
OpExecutionMode %main OriginUpperLeft
OpName %main "main"
OpDecorate %plainVar Location 0
OpDecorate %plainVar NoPerspective
OpMemberDecorate %Block 0 NoPerspective
OpDecorate %blockVar Location 1
OpDecorate %flatVar Location 2
OpDecorate %flatVar Flat
%void = OpTypeVoid
%mainFn = OpTypeFunction %void
%float = OpTypeFloat 32
%v4float = OpTypeVector %float 4
%int = OpTypeInt 32 1
%inV4Ptr = OpTypePointer Input %v4float
%plainVar = OpVariable %inV4Ptr Input
%Block = OpTypeStruct %v4float
%inBlockPtr = OpTypePointer Input %Block
%blockVar = OpVariable %inBlockPtr Input
%inIntPtr = OpTypePointer Input %int
%flatVar = OpVariable %inIntPtr Input
%main = OpFunction %void None %mainFn
%mainBody = OpLabel
OpReturn
OpFunctionEnd
)";
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<uint32_t> inputBinary;
ASSERT_TRUE(tools.Assemble(spirvText, &inputBinary));
const auto countNoPerspective = [](const String& text) {
SizeT count = 0, offset = 0;
while ((offset = text.find("NoPerspective", offset)) != String::npos) {
++count;
offset += std::strlen("NoPerspective");
}
return count;
};
String inputText;
ASSERT_TRUE(tools.Disassemble(inputBinary, &inputText));
ASSERT_EQ(countNoPerspective(inputText), 2u)
<< "fixture must carry both a plain and a member NoPerspective:\n" << inputText;
Vector<uint32_t> outputBinary;
ASSERT_TRUE(ShaderCompiler::StripNoPerspectiveForEssl(inputBinary, outputBinary));
ASSERT_FALSE(outputBinary.empty());
String outputText;
ASSERT_TRUE(tools.Disassemble(outputBinary, &outputText));
EXPECT_EQ(countNoPerspective(outputText), 0u)
<< "both NoPerspective decorations (OpDecorate and OpMemberDecorate) must be stripped:\n" << outputText;
EXPECT_NE(outputText.find("Flat"), String::npos)
<< "the unrelated Flat decoration must survive:\n" << outputText;
EXPECT_NE(outputText.find("Location"), String::npos)
<< "Location decorations must survive:\n" << outputText;
}
// Phase 2 emulation - fragment side. On a device without the NV extension the NoPerspective input is
// recovered as `load * gl_FragCoord.w` and the decoration removed; gl_FragCoord is synthesized because
// the shader did not otherwise use it. The emulated SPIR-V must validate and decompile without the
// extension require.
TEST_F(ProgramUtilTest, EmulateNoperspectiveFragmentRecoversWithFragCoordW) {
using namespace MG_Util::ShaderTranspiler;
String fs = R"(#version 330 core
noperspective in vec4 vColor;
out vec4 f;
void main() { f = vColor; }
)";
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = fs};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) FAIL() << "compile: " << res.error().log;
ProgramAttrib pa{.shaders = {res.value()}};
auto pr = ShaderCompiler::LinkProgram(pa);
if (!pr) FAIL() << "link: " << pr.error().log;
ProgramBinaryAttrib ba{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *pr.value()};
auto br = ShaderCompiler::GetSpirvBinaryFromProgram(ba);
if (!br) FAIL() << "spirv: " << br.error().log;
ASSERT_EQ(br.value().size(), 1u);
Vector<uint32_t> emulated;
ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(br.value()[0], emulated));
ASSERT_FALSE(emulated.empty());
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String dis;
ASSERT_TRUE(tools.Disassemble(emulated, &dis));
ASSERT_TRUE(tools.Validate(emulated)) << "emulated SPIR-V must be valid:\n" << dis;
EXPECT_EQ(dis.find("NoPerspective"), String::npos) << "decoration must be stripped:\n" << dis;
EXPECT_NE(dis.find("FragCoord"), String::npos) << "gl_FragCoord must be synthesized:\n" << dis;
EXPECT_NE(dis.find("OpVectorTimesScalar"), String::npos) << "the recovery multiply must be present:\n" << dis;
SpvcSession session(emulated, SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
if (!essl) FAIL() << "decompile: " << essl.error().log;
EXPECT_EQ(essl.value().find("noperspective"), String::npos) << essl.value();
EXPECT_EQ(essl.value().find("GL_NV_shader_noperspective_interpolation"), String::npos) << essl.value();
EXPECT_NE(essl.value().find("gl_FragCoord"), String::npos) << "recovery must reference gl_FragCoord:\n" << essl.value();
}
// Phase 2 emulation - vertex side. The NoPerspective output is pre-multiplied by gl_Position.w before
// return and the decoration removed. Emulated SPIR-V must validate and decompile without the extension.
TEST_F(ProgramUtilTest, EmulateNoperspectiveVertexPreMultipliesByPositionW) {
using namespace MG_Util::ShaderTranspiler;
String vs = R"(#version 330 core
in vec4 pos;
noperspective out vec4 vColor;
void main() { gl_Position = pos; vColor = pos; }
)";
ShaderAttrib attrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = vs};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) FAIL() << "compile: " << res.error().log;
ProgramAttrib pa{.shaders = {res.value()}};
auto pr = ShaderCompiler::LinkProgram(pa);
if (!pr) FAIL() << "link: " << pr.error().log;
ProgramBinaryAttrib ba{.shaderTypes = {GL_VERTEX_SHADER}, .program = *pr.value()};
auto br = ShaderCompiler::GetSpirvBinaryFromProgram(ba);
if (!br) FAIL() << "spirv: " << br.error().log;
ASSERT_EQ(br.value().size(), 1u);
Vector<uint32_t> emulated;
ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(br.value()[0], emulated));
ASSERT_FALSE(emulated.empty());
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String dis;
ASSERT_TRUE(tools.Disassemble(emulated, &dis));
ASSERT_TRUE(tools.Validate(emulated)) << "emulated SPIR-V must be valid:\n" << dis;
EXPECT_EQ(dis.find("NoPerspective"), String::npos) << "decoration must be stripped:\n" << dis;
EXPECT_NE(dis.find("OpVectorTimesScalar"), String::npos) << "the pre-multiply must be present:\n" << dis;
SpvcSession session(emulated, SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
if (!essl) FAIL() << "decompile: " << essl.error().log;
EXPECT_EQ(essl.value().find("noperspective"), String::npos) << essl.value();
EXPECT_NE(essl.value().find("gl_Position"), String::npos) << "pre-multiply must reference gl_Position:\n" << essl.value();
}
namespace {
// Compiles one shader stage through the full pipeline and returns its SPIR-V, or fails the test.
MobileGL::Vector<uint32_t> CompileStageSpirv(GLenum type, const char* src) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib attrib{.shaderType = type, .sourceStr = src};
auto res = ShaderCompiler::CompileShader(attrib);
EXPECT_TRUE(static_cast<bool>(res)) << (res ? "" : res.error().log);
if (!res) return {};
ProgramAttrib pa{.shaders = {res.value()}};
auto pr = ShaderCompiler::LinkProgram(pa);
EXPECT_TRUE(static_cast<bool>(pr)) << (pr ? "" : pr.error().log);
if (!pr) return {};
ProgramBinaryAttrib ba{.shaderTypes = {type}, .program = *pr.value()};
auto br = ShaderCompiler::GetSpirvBinaryFromProgram(ba);
EXPECT_TRUE(static_cast<bool>(br)) << (br ? "" : br.error().log);
if (!br || br.value().empty()) return {};
return br.value()[0];
}
} // namespace
// Regression: the vertex pre-multiply must be applied exactly once (in main), not once per function.
// glslang does not inline, so a helper function survives as its own OpFunction; instrumenting its
// return too would scale the varying by gl_Position.w twice (w^2).
TEST_F(ProgramUtilTest, EmulateNoperspectiveVertexWithHelperScalesExactlyOnce) {
using namespace MG_Util::ShaderTranspiler;
// helper() returns via OpReturnValue and adds (no vector*scalar), so the ONLY OpVectorTimesScalar
// in the module is the emulation's pre-multiply. The old all-functions code injected it at both
// helper's and main's return -> count 2; restricted to the entry function it is 1.
auto spirv = CompileStageSpirv(GL_VERTEX_SHADER, R"(#version 330 core
in vec4 pos;
noperspective out vec4 vColor;
vec4 helper(vec4 x) { return x + vec4(1.0); }
void main() { gl_Position = pos; vColor = helper(pos); }
)");
ASSERT_FALSE(spirv.empty());
Vector<uint32_t> emulated;
ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(spirv, emulated));
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String dis;
ASSERT_TRUE(tools.Disassemble(emulated, &dis));
ASSERT_TRUE(tools.Validate(emulated)) << dis;
SizeT count = 0, off = 0;
while ((off = dis.find("OpVectorTimesScalar", off)) != String::npos) {
++count;
off += std::strlen("OpVectorTimesScalar");
}
EXPECT_EQ(count, 1u) << "the gl_Position.w pre-multiply must happen exactly once, not per function:\n" << dis;
}
// Regression: a single-component read (vColor.x), which glslang lowers via OpAccessChain, must still be
// recovered with gl_FragCoord.w - not silently left un-scaled.
TEST_F(ProgramUtilTest, EmulateNoperspectiveFragmentComponentReadIsRecovered) {
using namespace MG_Util::ShaderTranspiler;
auto spirv = CompileStageSpirv(GL_FRAGMENT_SHADER, R"(#version 330 core
noperspective in vec4 vColor;
out vec4 f;
void main() { f = vec4(vColor.x); }
)");
ASSERT_FALSE(spirv.empty());
Vector<uint32_t> emulated;
ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(spirv, emulated));
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String dis;
ASSERT_TRUE(tools.Disassemble(emulated, &dis));
ASSERT_TRUE(tools.Validate(emulated)) << dis;
EXPECT_EQ(dis.find("NoPerspective"), String::npos) << dis;
EXPECT_NE(dis.find("FragCoord"), String::npos)
<< "the component read must still be recovered via gl_FragCoord.w:\n" << dis;
}
// Coverage: a scalar float varying exercises the OpFMul path; a vector varying the OpVectorTimesScalar
// path; multiple noperspective varyings in one stage are all handled.
TEST_F(ProgramUtilTest, EmulateNoperspectiveHandlesScalarAndMultipleVaryings) {
using namespace MG_Util::ShaderTranspiler;
auto spirv = CompileStageSpirv(GL_FRAGMENT_SHADER, R"(#version 330 core
noperspective in float a;
noperspective in vec2 b;
out vec4 f;
void main() { f = vec4(a, b, 1.0); }
)");
ASSERT_FALSE(spirv.empty());
Vector<uint32_t> emulated;
ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(spirv, emulated));
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String dis;
ASSERT_TRUE(tools.Disassemble(emulated, &dis));
ASSERT_TRUE(tools.Validate(emulated)) << dis;
EXPECT_EQ(dis.find("NoPerspective"), String::npos) << dis;
EXPECT_NE(dis.find("OpFMul"), String::npos) << "the scalar varying must scale with OpFMul:\n" << dis;
EXPECT_NE(dis.find("OpVectorTimesScalar"), String::npos)
<< "the vector varying must scale with OpVectorTimesScalar:\n" << dis;
}
const char* vs_location = R"(#version 460 const char* vs_location = R"(#version 460
in vec4 Position; in vec4 Position;
@@ -811,6 +811,9 @@ namespace MobileGL::MG_Util::BackendLoader {
if (std::strcmp(extension, "GL_EXT_blend_func_extended") == 0) { if (std::strcmp(extension, "GL_EXT_blend_func_extended") == 0) {
caps.SupportsDualSourceBlend = true; caps.SupportsDualSourceBlend = true;
} }
if (std::strcmp(extension, "GL_NV_shader_noperspective_interpolation") == 0) {
caps.SupportsNoperspectiveInterpolation = true;
}
} }
} }
@@ -1050,6 +1050,11 @@ namespace MobileGL {
// factors and layout(index = 1) fragment outputs. GLES core has no dual-source blending, // factors and layout(index = 1) fragment outputs. GLES core has no dual-source blending,
// so without this a draw using a SRC1 factor cannot proceed. // so without this a draw using a SRC1 factor cannot proceed.
Bool SupportsDualSourceBlend = false; Bool SupportsDualSourceBlend = false;
// GL_NV_shader_noperspective_interpolation is present: the driver accepts the
// `noperspective` interpolation qualifier in ESSL. GLES core has none, so without this
// SPIRV-Cross's `#extension ... : require` would fail to compile and MobileGL falls back
// to stripping the NoPerspective decoration (smooth interpolation) via StripNoPerspectivePass.
Bool SupportsNoperspectiveInterpolation = false;
// GL_RENDERER contains "ANGLE". // GL_RENDERER contains "ANGLE".
Bool IsAngleRenderer = false; Bool IsAngleRenderer = false;
// GL_RENDERER contains both "ANGLE" and "llvmpipe". // GL_RENDERER contains both "ANGLE" and "llvmpipe".
+225
View File
@@ -401,6 +401,230 @@ namespace MobileGL::MG_Util::SelfTest {
disabledNote); disabledNote);
} }
// Compiles + links a two-stage program on the probe context. Returns 0 on failure and writes a
// human-readable reason into |detail|.
GLuint CompileLinkProgram(const MG_External::GLESFunctionsTable& g, const char* vs, const char* fs,
String& detail) {
const auto compile = [&](GLenum stage, const char* src, GLuint& out) -> bool {
out = g.glCreateShader(stage);
if (out == 0) {
detail = "glCreateShader returned 0";
return false;
}
g.glShaderSource(out, 1, &src, nullptr);
g.glCompileShader(out);
GLint ok = GL_FALSE;
g.glGetShaderiv(out, GL_COMPILE_STATUS, &ok);
if (ok != GL_TRUE) {
GLchar log[512] = {};
GLsizei len = 0;
g.glGetShaderInfoLog(out, static_cast<GLsizei>(sizeof(log) - 1), &len, log);
detail = format("{} shader compile failed: {}",
stage == GL_VERTEX_SHADER ? "vertex" : "fragment",
len > 0 ? log : "(no info log)");
return false;
}
return true;
};
GLuint v = 0, f = 0;
const ScopeGuard delV([&]() { if (v) g.glDeleteShader(v); });
const ScopeGuard delF([&]() { if (f) g.glDeleteShader(f); });
if (!compile(GL_VERTEX_SHADER, vs, v) || !compile(GL_FRAGMENT_SHADER, fs, f)) {
return 0;
}
const GLuint prog = g.glCreateProgram();
if (prog == 0) {
detail = "glCreateProgram returned 0";
return 0;
}
g.glAttachShader(prog, v);
g.glAttachShader(prog, f);
g.glLinkProgram(prog);
GLint linked = GL_FALSE;
g.glGetProgramiv(prog, GL_LINK_STATUS, &linked);
if (linked != GL_TRUE) {
detail = "program link failed";
g.glDeleteProgram(prog);
return 0;
}
return prog;
}
// "noperspective interpolation" row - a real correctness render, not just a compile. A viewport-
// filling quad is drawn with strong perspective (left clip-w 1, right clip-w 8) and a varying that
// runs 0..1 across it. At the screen centre screen-linear interpolation gives 0.5 while perspective-
// correct gives 1/(w+1) ~= 0.11, so reading the centre texel tells the two apart. The varying is
// carried either through the native `noperspective` qualifier (extension present) or through the
// exact gl_Position.w / gl_FragCoord.w rewrite MobileGL applies when it is absent. Verdict:
// PASS - extension present and the native noperspective result is screen-linear;
// WARN - extension absent but the gl_Position.w/gl_FragCoord.w emulation renders screen-linear
// (correct, just the fallback path shipping shader packs hit on such devices);
// FAIL - either path renders perspective-correct / wrong (noperspective does not actually work),
// or the program will not compile/link, or the render errors.
// Requires the probe context to still be current.
void ProbeGlesNoperspective(ReportBuilder& builder, const MG_External::GLESCapabilities& caps,
const MG_External::GLESFunctionsTable& g) {
const Bool native = caps.SupportsNoperspectiveInterpolation;
const String pathNote = native ? "GL_NV_shader_noperspective_interpolation present (native path)"
: "GL_NV_shader_noperspective_interpolation absent (gl_Position.w / "
"gl_FragCoord.w emulation path)";
const auto fail = [&](const String& detail) {
builder.Fail("noperspective interpolation", pathNote + "; " + detail);
};
if (!g.glCreateShader || !g.glShaderSource || !g.glCompileShader || !g.glGetShaderiv ||
!g.glGetShaderInfoLog || !g.glDeleteShader || !g.glCreateProgram || !g.glAttachShader ||
!g.glLinkProgram || !g.glGetProgramiv || !g.glUseProgram || !g.glDeleteProgram ||
!g.glGenFramebuffers || !g.glBindFramebuffer || !g.glDeleteFramebuffers ||
!g.glGenRenderbuffers || !g.glBindRenderbuffer || !g.glRenderbufferStorage ||
!g.glFramebufferRenderbuffer || !g.glDeleteRenderbuffers || !g.glCheckFramebufferStatus ||
!g.glGenBuffers || !g.glBindBuffer || !g.glBufferData || !g.glDeleteBuffers ||
!g.glGetAttribLocation || !g.glVertexAttribPointer || !g.glEnableVertexAttribArray ||
!g.glViewport || !g.glClearColor || !g.glClear || !g.glDrawArrays || !g.glReadPixels ||
!g.glFinish || !g.glGetError) {
fail("the render entry points did not resolve through eglGetProcAddress");
return;
}
// Match MobileGL's own ESSL target (the device's version). At #version 300 es some drivers
// (Adreno) still treat `noperspective` as reserved even with the extension enabled; the ES 3.2
// form the backend actually emits compiles. Emulated shaders are version-agnostic but use the
// same header for consistency.
const Int esslVer = caps.GLESVersion.Major * 100 + caps.GLESVersion.Minor * 10;
const String header = format("#version {} es\n", esslVer >= 300 ? esslVer : 300);
static const char* const kVsNativeBody =
"#extension GL_NV_shader_noperspective_interpolation : require\n"
"in vec4 a_pos;\n"
"in float a_v;\n"
"noperspective out highp float v_out;\n"
"void main() { gl_Position = a_pos; v_out = a_v; }\n";
static const char* const kFsNativeBody =
"#extension GL_NV_shader_noperspective_interpolation : require\n"
"precision highp float;\n"
"noperspective in highp float v_out;\n"
"out vec4 fragColor;\n"
"void main() { fragColor = vec4(v_out, 0.0, 0.0, 1.0); }\n";
// Exactly MobileGL's emulation (verified against EmulateNoPerspectivePass output): pre-multiply
// the varying by clip-w in the vertex stage, recover with gl_FragCoord.w in the fragment stage,
// no noperspective qualifier (so the driver interpolates it perspective-correct).
static const char* const kVsEmuBody =
"in vec4 a_pos;\n"
"in float a_v;\n"
"out highp float v_out;\n"
"void main() { gl_Position = a_pos; v_out = a_v * gl_Position.w; }\n";
static const char* const kFsEmuBody =
"precision highp float;\n"
"in highp float v_out;\n"
"out vec4 fragColor;\n"
"void main() { fragColor = vec4(v_out * gl_FragCoord.w, 0.0, 0.0, 1.0); }\n";
while (g.glGetError() != GL_NO_ERROR) {
}
const String vsSrc = header + (native ? kVsNativeBody : kVsEmuBody);
const String fsSrc = header + (native ? kFsNativeBody : kFsEmuBody);
String linkDetail;
const GLuint prog = CompileLinkProgram(g, vsSrc.c_str(), fsSrc.c_str(), linkDetail);
if (prog == 0) {
fail(native ? "a noperspective program failed to build though the extension is advertised: " +
linkDetail
: "the emulation program failed to build: " + linkDetail);
return;
}
const ScopeGuard delProg([&]() { g.glDeleteProgram(prog); });
// 9x9 so the centre texel (4,4) sits exactly at NDC (0,0).
constexpr GLsizei kDim = 9;
GLuint rbo = 0, fbo = 0, vbo = 0;
g.glGenRenderbuffers(1, &rbo);
const ScopeGuard delRbo([&]() { if (rbo) g.glDeleteRenderbuffers(1, &rbo); });
g.glBindRenderbuffer(GL_RENDERBUFFER, rbo);
g.glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kDim, kDim);
g.glGenFramebuffers(1, &fbo);
const ScopeGuard delFbo([&]() {
if (fbo) {
g.glBindFramebuffer(GL_FRAMEBUFFER, 0);
g.glDeleteFramebuffers(1, &fbo);
}
});
g.glBindFramebuffer(GL_FRAMEBUFFER, fbo);
g.glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, rbo);
if (g.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
fail("the probe framebuffer is incomplete");
return;
}
// Interleaved [vec4 clip-pos, float v]. Left w=1, right w=8; x/y pre-multiplied by w so the quad
// still fills NDC after the perspective divide.
const GLfloat verts[] = {
-1.f, -1.f, 0.f, 1.f, 0.f, //
8.f, -8.f, 0.f, 8.f, 1.f, //
-1.f, 1.f, 0.f, 1.f, 0.f, //
8.f, 8.f, 0.f, 8.f, 1.f, //
};
g.glGenBuffers(1, &vbo);
const ScopeGuard delVbo([&]() { if (vbo) g.glDeleteBuffers(1, &vbo); });
g.glBindBuffer(GL_ARRAY_BUFFER, vbo);
g.glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW);
g.glUseProgram(prog);
const GLint posLoc = g.glGetAttribLocation(prog, "a_pos");
const GLint vLoc = g.glGetAttribLocation(prog, "a_v");
if (posLoc < 0 || vLoc < 0) {
fail("the probe vertex attributes did not resolve");
return;
}
g.glEnableVertexAttribArray(static_cast<GLuint>(posLoc));
g.glVertexAttribPointer(static_cast<GLuint>(posLoc), 4, GL_FLOAT, GL_FALSE, 5 * sizeof(GLfloat),
reinterpret_cast<const void*>(0));
g.glEnableVertexAttribArray(static_cast<GLuint>(vLoc));
g.glVertexAttribPointer(static_cast<GLuint>(vLoc), 1, GL_FLOAT, GL_FALSE, 5 * sizeof(GLfloat),
reinterpret_cast<const void*>(4 * sizeof(GLfloat)));
g.glViewport(0, 0, kDim, kDim);
g.glClearColor(0.f, 0.f, 0.f, 1.f);
g.glClear(GL_COLOR_BUFFER_BIT);
g.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
g.glFinish();
const GLenum drawError = g.glGetError();
if (drawError != GL_NO_ERROR) {
fail(format("GL error 0x{:x} while rendering the probe quad", drawError));
return;
}
GLubyte center[4] = {};
g.glReadPixels(kDim / 2, kDim / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, center);
const GLenum readError = g.glGetError();
if (readError != GL_NO_ERROR) {
fail(format("GL error 0x{:x} while reading the probe pixel back", readError));
return;
}
// At the centre: screen-linear -> 0.5 (~128); perspective-correct -> 1/(8+1) ~= 0.111 (~28).
const float observed = static_cast<float>(center[0]) / 255.0f;
const int observedByte = center[0];
constexpr float kScreenLinear = 0.5f;
const bool screenLinear = observed > 0.5f * (kScreenLinear + 1.0f / 9.0f); // midpoint ~= 0.306
if (!screenLinear) {
fail(format("the centre texel read {} (~{:.3f}); expected the screen-linear ~0.5 - "
"interpolation came out perspective-correct, so noperspective does not work here",
observedByte, observed));
return;
}
if (native) {
builder.Pass("noperspective interpolation",
pathNote + format("; native noperspective renders screen-linear (centre {} ~= 0.5)",
observedByte));
} else {
builder.Warn("noperspective interpolation",
pathNote +
format("; the emulation renders screen-linear correctly (centre {} ~= 0.5), "
"but this is the fallback path with less driver coverage",
observedByte));
}
}
// Everything the "MobileGL reported ..." rows need from the GLES device probe. // Everything the "MobileGL reported ..." rows need from the GLES device probe.
struct GlesProbeSummary { struct GlesProbeSummary {
Bool capsValid = false; Bool capsValid = false;
@@ -527,6 +751,7 @@ namespace MobileGL::MG_Util::SelfTest {
builder.report.rendererInfo = format("{} ({})", caps.GLESRendererString, caps.GLESVersionString); builder.report.rendererInfo = format("{} ({})", caps.GLESRendererString, caps.GLESVersionString);
EvaluateGlesChecklist(builder, caps, glesFuncs); EvaluateGlesChecklist(builder, caps, glesFuncs);
ProbeGlesTimerQuery(builder, caps, glesFuncs); ProbeGlesTimerQuery(builder, caps, glesFuncs);
ProbeGlesNoperspective(builder, caps, glesFuncs);
builder.report.formatCapabilities.emplace(); builder.report.formatCapabilities.emplace();
MG_Backend::DirectGLES::PopulateFormatCapabilities( MG_Backend::DirectGLES::PopulateFormatCapabilities(
glesFuncs, caps, builder.report.formatCapabilities.value()); glesFuncs, caps, builder.report.formatCapabilities.value());
@@ -20,6 +20,8 @@
#include "SpirvPasses/LowerDrawParametersPass.h" #include "SpirvPasses/LowerDrawParametersPass.h"
#include "SpirvPasses/RebaseInstanceIndexPass.h" #include "SpirvPasses/RebaseInstanceIndexPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h" #include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
#include "spirv-tools/libspirv.h" #include "spirv-tools/libspirv.h"
#include "spirv-tools/optimizer.hpp" #include "spirv-tools/optimizer.hpp"
@@ -334,6 +336,30 @@ namespace MobileGL {
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options); return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
} }
bool ShaderCompiler::StripNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(StripNoPerspectivePass::CreateStripNoPerspectivePass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
}
bool ShaderCompiler::EmulateNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(EmulateNoPerspectivePass::CreateEmulateNoPerspectivePass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
}
bool ShaderCompiler::RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary, bool ShaderCompiler::RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) { Vector<uint32_t>& outputBinary) {
using namespace spvtools; using namespace spvtools;
@@ -33,6 +33,16 @@ namespace MobileGL {
// Only for the DirectGLES transpile path. // Only for the DirectGLES transpile path.
static bool StripUboMemberRelaxedPrecisionForEssl(const Vector<Uint32>& inputBinary, static bool StripUboMemberRelaxedPrecisionForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary); Vector<uint32_t>& outputBinary);
// Removes NoPerspective decorations so SPIRV-Cross emits plain (smooth) ESSL varyings.
// DirectGLES fallback only, for devices lacking GL_NV_shader_noperspective_interpolation
// (SPIRV-Cross would otherwise require that extension and the driver would reject it).
static bool StripNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary);
// Emulates noperspective (screen-linear) interpolation via gl_Position.w / gl_FragCoord.w
// so no NV extension is needed; strips what it cannot emulate. DirectGLES fallback for
// devices lacking GL_NV_shader_noperspective_interpolation. See EmulateNoPerspectivePass.
static bool EmulateNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary);
// Rebases loads of the InstanceIndex builtin to (InstanceIndex - BaseInstance) so // Rebases loads of the InstanceIndex builtin to (InstanceIndex - BaseInstance) so
// shaders see GL's zero-based gl_InstanceID. Vertex shaders only; DirectVulkan // shaders see GL's zero-based gl_InstanceID. Vertex shaders only; DirectVulkan
// backend only (glslang's relaxed mode aliases gl_InstanceID to gl_InstanceIndex, // backend only (glslang's relaxed mode aliases gl_InstanceID to gl_InstanceIndex,
@@ -956,15 +956,28 @@ namespace {
return; return;
} }
// Detect the directive on a comment/string-masked copy so a commented-out
// "#extension GL_ARB_gpu_shader_int64" is never turned into a synthesized #error. Comments are
// no longer blanked in the delivered source (glslang handles them), so this pass must mask
// locally like its siblings. Masking preserves offsets, so edits collected against the scan
// apply verbatim to `source`; they are applied back-to-front to keep earlier offsets valid.
const MobileGL::String scan = MaskCommentsAndQuotedText(source);
struct DirectiveEdit {
SizeT pos;
SizeT len;
MobileGL::String replacement;
};
Vector<DirectiveEdit> edits;
SizeT lineStart = 0; SizeT lineStart = 0;
while (lineStart < source.size()) { while (lineStart < scan.size()) {
SizeT lineEnd = source.find('\n', lineStart); SizeT lineEnd = scan.find('\n', lineStart);
const bool hasLineBreak = lineEnd != MobileGL::String::npos; const bool hasLineBreak = lineEnd != MobileGL::String::npos;
if (!hasLineBreak) { if (!hasLineBreak) {
lineEnd = source.size(); lineEnd = scan.size();
} }
const MobileGL::String line = source.substr(lineStart, lineEnd - lineStart); const MobileGL::String line = scan.substr(lineStart, lineEnd - lineStart);
SizeT probe = 0; SizeT probe = 0;
while (probe < line.size() && std::isspace(static_cast<unsigned char>(line[probe]))) { while (probe < line.size() && std::isspace(static_cast<unsigned char>(line[probe]))) {
probe++; probe++;
@@ -1006,16 +1019,12 @@ namespace {
const MobileGL::String behavior = TrimDirectiveToken(line.substr(probe)); const MobileGL::String behavior = TrimDirectiveToken(line.substr(probe));
const SizeT replaceLen = lineEnd - lineStart + (hasLineBreak ? 1 : 0); const SizeT replaceLen = lineEnd - lineStart + (hasLineBreak ? 1 : 0);
if (behavior == "require") { if (behavior == "require") {
const MobileGL::String replacement = edits.push_back({lineStart, replaceLen,
"#error GL_ARB_gpu_shader_int64 is not advertised by MobileGL\n"; "#error GL_ARB_gpu_shader_int64 is not advertised by MobileGL\n"});
source.replace(lineStart, replaceLen, replacement);
lineStart += replacement.size();
} else if (behavior == "enable" || behavior == "warn") { } else if (behavior == "enable" || behavior == "warn") {
source.replace(lineStart, replaceLen, "\n"); edits.push_back({lineStart, replaceLen, "\n"});
lineStart++;
} else {
lineStart = lineEnd + (hasLineBreak ? 1 : 0);
} }
lineStart = lineEnd + (hasLineBreak ? 1 : 0);
continue; continue;
} }
} }
@@ -1025,6 +1034,10 @@ namespace {
lineStart = lineEnd + (hasLineBreak ? 1 : 0); lineStart = lineEnd + (hasLineBreak ? 1 : 0);
} }
for (auto it = edits.rbegin(); it != edits.rend(); ++it) {
source.replace(it->pos, it->len, it->replacement);
}
ReplaceIdentifier(source, "GL_ARB_gpu_shader_int64", "MG_DISABLED_GL_ARB_gpu_shader_int64"); ReplaceIdentifier(source, "GL_ARB_gpu_shader_int64", "MG_DISABLED_GL_ARB_gpu_shader_int64");
} }
@@ -1247,19 +1260,22 @@ namespace MobileGL {
const ShaderLanguageInfo originalLanguage = InspectShaderLanguage(source); const ShaderLanguageInfo originalLanguage = InspectShaderLanguage(source);
NormalizeVersionDirective(source, originalLanguage); NormalizeVersionDirective(source, originalLanguage);
BlankBlockComments(source); // Comments are left intact for glslang's own preprocessor: a block comment is a single
// preprocessing token that collapses to one space even across newlines and inside a
// directive, so blanking it here (which preserved the interior newlines) truncated
// multi-line #define bodies and broke otherwise-valid shaders (KHR-GL3x.shaders.
// preprocessor multiline_comment_define / redefine_object / function_redefinition).
// Every MobileGL pass that must ignore comment/string text already masks them locally
// via MaskCommentsAndQuotedText/TokenizeCode, so the source we hand glslang keeps them.
NormalizeLineDirectives(source); NormalizeLineDirectives(source);
// remove "noperspective" // noperspective is intentionally NOT touched here. It is core in desktop GLSL (1.30+)
const char* str_np = "noperspective"; // and maps to the core SPIR-V NoPerspective decoration, which DirectVulkan renders
const SizeT len_np = strlen(str_np); // natively and SPIRV-Cross turns into ESSL `noperspective` + the
SizeT noperspectivePos = source.find(str_np); // GL_NV_shader_noperspective_interpolation extension. The old naked substring erase
while (noperspectivePos != String::npos) { // both discarded that interpolation (shader packs need it) and corrupted any
// + length of "\n" // identifier that merely contained the word. The GLES fallback for devices without
source = source.replace(noperspectivePos, len_np, ""); // the extension lives in the backend, where device capabilities are known.
noperspectivePos = source.find(str_np);
}
FilterUnsupportedGpuShaderInt64(source); FilterUnsupportedGpuShaderInt64(source);
CoerceUniformBlockPackingToStd140(source); CoerceUniformBlockPackingToStd140(source);
@@ -0,0 +1,407 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "EmulateNoPerspectivePass.h"
#include "spirv.hpp"
#include "source/opt/constants.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/opt/types.h"
#include "source/util/make_unique.h"
#include <algorithm>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
namespace analysis = spvtools::opt::analysis;
spv::ExecutionModel EntryExecutionModel(IRContext* ctx) {
for (Instruction& ep : ctx->module()->entry_points()) {
return static_cast<spv::ExecutionModel>(ep.GetSingleWordInOperand(0));
}
return spv::ExecutionModel::Max;
}
uint32_t VariablePointeeType(IRContext* ctx, Instruction* var) {
Instruction* ptrType = ctx->get_def_use_mgr()->GetDef(var->type_id());
// OpTypePointer <storage-class> <pointee>
return ptrType->GetSingleWordInOperand(1);
}
// If |typeId| is float or a vector of float, returns true and reports the scalar float
// type and whether it is a vector. Matrices, structs, ints etc. are not emulatable.
bool IsFloatScalarOrVector(IRContext* ctx, uint32_t typeId, uint32_t& floatTypeId, bool& isVector) {
Instruction* t = ctx->get_def_use_mgr()->GetDef(typeId);
if (t == nullptr) return false;
if (t->opcode() == spv::Op::OpTypeFloat) {
floatTypeId = typeId;
isVector = false;
return true;
}
if (t->opcode() == spv::Op::OpTypeVector) {
const uint32_t comp = t->GetSingleWordInOperand(0);
Instruction* ct = ctx->get_def_use_mgr()->GetDef(comp);
if (ct != nullptr && ct->opcode() == spv::Op::OpTypeFloat) {
floatTypeId = comp;
isVector = true;
return true;
}
}
return false;
}
uint32_t PointerTypeTo(IRContext* ctx, uint32_t pointeeId, spv::StorageClass sc) {
analysis::Type* pointee = ctx->get_type_mgr()->GetType(pointeeId);
analysis::Pointer ptr(pointee, sc);
return ctx->get_type_mgr()->GetTypeInstruction(&ptr);
}
uint32_t V4FloatType(IRContext* ctx) {
analysis::Float f(32);
analysis::Type* freg = ctx->get_type_mgr()->GetRegisteredType(&f);
analysis::Vector v(freg, 4);
return ctx->get_type_mgr()->GetTypeInstruction(&v);
}
uint32_t FloatType(IRContext* ctx) {
analysis::Float f(32);
return ctx->get_type_mgr()->GetTypeInstruction(&f);
}
uint32_t SignedIntConstant(IRContext* ctx, int32_t value) {
analysis::Integer i(32, true);
analysis::Type* reg = ctx->get_type_mgr()->GetRegisteredType(&i);
const analysis::Constant* c =
ctx->get_constant_mgr()->GetConstant(reg, {static_cast<uint32_t>(value)});
return ctx->get_constant_mgr()->GetDefiningInstruction(c)->result_id();
}
// Multiply |valueId| (of type |valueTypeId|) by the scalar |scalarId|, inserting the op
// before |before|. Returns the product's id.
uint32_t InsertScale(IRContext* ctx, Instruction* before, uint32_t valueTypeId,
uint32_t valueId, uint32_t scalarId, bool isVector) {
const uint32_t productId = ctx->TakeNextId();
const spv::Op op = isVector ? spv::Op::OpVectorTimesScalar : spv::Op::OpFMul;
before->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, op, valueTypeId, productId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {valueId}},
{SPV_OPERAND_TYPE_ID, {scalarId}}}));
return productId;
}
// --- Vertex stage: gl_Position discovery ------------------------------------------
// Finds gl_Position as member |memberIndex| of a gl_PerVertex-style block whose Output
// variable is |blockVarId|; |v4floatTypeId| is that member's (vec4) type. Returns false
// if gl_Position is not a block member (older plain-variable form is left to the strip).
bool FindPositionBlock(IRContext* ctx, uint32_t& blockVarId, uint32_t& memberIndex,
uint32_t& v4floatTypeId) {
uint32_t structId = 0;
uint32_t member = 0;
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 4 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(2)) ==
spv::Decoration::BuiltIn &&
static_cast<spv::BuiltIn>(ann.GetSingleWordInOperand(3)) ==
spv::BuiltIn::Position) {
structId = ann.GetSingleWordInOperand(0);
member = ann.GetSingleWordInOperand(1);
break;
}
}
if (structId == 0) return false;
Instruction* structType = ctx->get_def_use_mgr()->GetDef(structId);
if (structType == nullptr || member >= structType->NumInOperands()) return false;
v4floatTypeId = structType->GetSingleWordInOperand(member);
for (Instruction& inst : ctx->module()->types_values()) {
if (inst.opcode() == spv::Op::OpVariable &&
static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0)) ==
spv::StorageClass::Output &&
VariablePointeeType(ctx, &inst) == structId) {
blockVarId = inst.result_id();
memberIndex = member;
return true;
}
}
return false;
}
// --- Fragment stage: gl_FragCoord discovery/synthesis -----------------------------
Instruction* FindBuiltinInput(IRContext* ctx, spv::BuiltIn builtin) {
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() != spv::Op::OpDecorate || ann.NumInOperands() < 3) continue;
if (static_cast<spv::Decoration>(ann.GetSingleWordInOperand(1)) !=
spv::Decoration::BuiltIn)
continue;
if (static_cast<spv::BuiltIn>(ann.GetSingleWordInOperand(2)) != builtin) continue;
Instruction* var = ctx->get_def_use_mgr()->GetDef(ann.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable &&
static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0)) ==
spv::StorageClass::Input) {
return var;
}
}
return nullptr;
}
uint32_t SynthesizeFragCoord(IRContext* ctx, uint32_t v4floatTypeId) {
const uint32_t ptrType = PointerTypeTo(ctx, v4floatTypeId, spv::StorageClass::Input);
const uint32_t varId = ctx->TakeNextId();
ctx->AddGlobalValue(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpVariable, ptrType, varId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS,
{static_cast<uint32_t>(spv::StorageClass::Input)}}}));
ctx->AddAnnotationInst(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpDecorate, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {varId}},
{SPV_OPERAND_TYPE_DECORATION,
{static_cast<uint32_t>(spv::Decoration::BuiltIn)}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER,
{static_cast<uint32_t>(spv::BuiltIn::FragCoord)}}}));
for (Instruction& ep : ctx->module()->entry_points()) {
ep.AddOperand({SPV_OPERAND_TYPE_ID, {varId}});
}
return varId;
}
} // namespace
spvtools::opt::Pass::Status EmulateNoPerspectivePass::Process() {
auto* ctx = context();
const spv::ExecutionModel model = EntryExecutionModel(ctx);
const bool isVertex = model == spv::ExecutionModel::Vertex;
const bool isFragment = model == spv::ExecutionModel::Fragment;
// Collect NoPerspective-decorated plain variables and every NoPerspective annotation.
std::vector<uint32_t> plainVarIds;
std::vector<Instruction*> decorationsToKill;
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 2 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(1)) ==
spv::Decoration::NoPerspective) {
plainVarIds.push_back(ann.GetSingleWordInOperand(0));
decorationsToKill.push_back(&ann);
} else if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 3 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(2)) ==
spv::Decoration::NoPerspective) {
// Block-member noperspective is not emulated here; the decoration is stripped
// (smooth fallback) so SPIRV-Cross does not require the NV extension.
decorationsToKill.push_back(&ann);
}
}
if (decorationsToKill.empty()) {
return Status::SuccessWithoutChange;
}
const spv::StorageClass wantStorage =
isVertex ? spv::StorageClass::Output : spv::StorageClass::Input;
// Emulatable = plain variable of the stage's interface direction, float or floatN.
struct Target {
Instruction* var;
uint32_t typeId;
uint32_t floatTypeId;
bool isVector;
};
std::vector<Target> targets;
if (isVertex || isFragment) {
for (const uint32_t id : plainVarIds) {
Instruction* var = ctx->get_def_use_mgr()->GetDef(id);
if (var == nullptr || var->opcode() != spv::Op::OpVariable) continue;
if (static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0)) != wantStorage)
continue;
const uint32_t pointee = VariablePointeeType(ctx, var);
uint32_t floatTypeId = 0;
bool isVector = false;
if (IsFloatScalarOrVector(ctx, pointee, floatTypeId, isVector)) {
targets.push_back({var, pointee, floatTypeId, isVector});
}
}
}
// Force highp on the varyings we emulate: the a*w round-trip overflows a mediump (fp16)
// varying at large clip-space w. Dropping RelaxedPrecision makes SPIRV-Cross emit them
// highp on both stages, keeping the emulation exact. Only touches emulated variables.
if (!targets.empty()) {
std::vector<uint32_t> targetIds;
targetIds.reserve(targets.size());
for (const Target& t : targets) targetIds.push_back(t.var->result_id());
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 2 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(1)) ==
spv::Decoration::RelaxedPrecision &&
std::find(targetIds.begin(), targetIds.end(),
ann.GetSingleWordInOperand(0)) != targetIds.end()) {
decorationsToKill.push_back(&ann);
}
}
}
if (isVertex && !targets.empty()) {
uint32_t blockVarId = 0;
uint32_t memberIndex = 0;
uint32_t v4floatTypeId = 0;
if (FindPositionBlock(ctx, blockVarId, memberIndex, v4floatTypeId)) {
const uint32_t ptrOutV4 =
PointerTypeTo(ctx, v4floatTypeId, spv::StorageClass::Output);
const uint32_t memberConst = SignedIntConstant(ctx, static_cast<int32_t>(memberIndex));
const uint32_t floatTy = FloatType(ctx);
uint32_t entryFuncId = 0;
for (Instruction& ep : ctx->module()->entry_points()) {
// OpEntryPoint <model> <function> "name" <interface...>
entryFuncId = ep.GetSingleWordInOperand(1);
break;
}
// Pre-multiply every target output by gl_Position.w before each return of the
// ENTRY function only. glslang does not inline, so a called helper survives as
// its own OpFunction; instrumenting its returns too would scale the varying
// more than once (w^2), breaking the identity.
for (auto funcIt = ctx->module()->begin(); funcIt != ctx->module()->end(); ++funcIt) {
if (funcIt->result_id() != entryFuncId) continue;
funcIt->ForEachInst([&](Instruction* inst) {
if (inst->opcode() != spv::Op::OpReturn &&
inst->opcode() != spv::Op::OpReturnValue) {
return;
}
const uint32_t posPtrId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpAccessChain, ptrOutV4, posPtrId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {blockVarId}},
{SPV_OPERAND_TYPE_ID, {memberConst}}}));
const uint32_t posId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, v4floatTypeId, posId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {posPtrId}}}));
const uint32_t wId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpCompositeExtract, floatTy, wId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {posId}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {3u}}}));
for (const Target& t : targets) {
const uint32_t valId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, t.typeId, valId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {t.var->result_id()}}}));
const uint32_t scaledId =
InsertScale(ctx, inst, t.typeId, valId, wId, t.isVector);
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpStore, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {t.var->result_id()}},
{SPV_OPERAND_TYPE_ID, {scaledId}}}));
}
});
}
}
}
if (isFragment && !targets.empty()) {
Instruction* fragCoord = FindBuiltinInput(ctx, spv::BuiltIn::FragCoord);
uint32_t fragCoordId = 0;
uint32_t v4floatTypeId = 0;
if (fragCoord != nullptr) {
fragCoordId = fragCoord->result_id();
v4floatTypeId = VariablePointeeType(ctx, fragCoord);
} else {
v4floatTypeId = V4FloatType(ctx);
fragCoordId = SynthesizeFragCoord(ctx, v4floatTypeId);
}
const uint32_t floatTy = FloatType(ctx);
auto* defUse = ctx->get_def_use_mgr();
for (const Target& t : targets) {
// Collect every load that reads the varying. glslang lowers a whole-variable
// read to OpLoad(var), but a single-component read (v.x) to
// OpAccessChain(var) + OpLoad(chain). Both must be scaled; the identity is
// per-component, so scaling one loaded component by gl_FragCoord.w is valid.
std::vector<Instruction*> loads;
defUse->ForEachUser(t.var, [&](Instruction* user) {
if (user->opcode() == spv::Op::OpLoad &&
user->GetSingleWordInOperand(0) == t.var->result_id()) {
loads.push_back(user);
} else if (user->opcode() == spv::Op::OpAccessChain &&
user->GetSingleWordInOperand(0) == t.var->result_id()) {
const uint32_t chainId = user->result_id();
defUse->ForEachUser(user, [&](Instruction* chainUser) {
if (chainUser->opcode() == spv::Op::OpLoad &&
chainUser->GetSingleWordInOperand(0) == chainId) {
loads.push_back(chainUser);
}
});
}
});
// Rewrite `%r = OpLoad %ty %ptr` into
// %orig = OpLoad %ty %ptr
// %fc = OpLoad %v4float %fragCoord
// %w = OpCompositeExtract %float %fc 3
// %r = OpVectorTimesScalar/OpFMul %ty %orig %w (reuse %r: uses stay intact)
// The op is chosen from the LOAD's own result type: a whole-vector load scales
// with OpVectorTimesScalar, a scalar component load with OpFMul.
for (Instruction* load : loads) {
const uint32_t loadType = load->type_id();
uint32_t componentFloat = 0;
bool loadIsVector = false;
if (!IsFloatScalarOrVector(ctx, loadType, componentFloat, loadIsVector)) {
continue;
}
const uint32_t ptrId = load->GetSingleWordInOperand(0);
const uint32_t origId = ctx->TakeNextId();
load->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, loadType, origId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {ptrId}}}));
const uint32_t fcId = ctx->TakeNextId();
load->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, v4floatTypeId, fcId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {fragCoordId}}}));
const uint32_t wId = ctx->TakeNextId();
load->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpCompositeExtract, floatTy, wId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {fcId}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {3u}}}));
load->SetOpcode(loadIsVector ? spv::Op::OpVectorTimesScalar : spv::Op::OpFMul);
load->SetInOperands(Instruction::OperandList{
{SPV_OPERAND_TYPE_ID, {origId}}, {SPV_OPERAND_TYPE_ID, {wId}}});
}
}
}
// Strip every NoPerspective decoration: emulated varyings now transport smooth, and
// non-emulatable ones fall back to smooth.
for (Instruction* dec : decorationsToKill) {
ctx->KillInst(dec);
}
ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken EmulateNoPerspectivePass::CreateEmulateNoPerspectivePass() {
return spvtools::Optimizer::PassToken(MakeUnique<EmulateNoPerspectivePass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,41 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Emulates 'noperspective' (screen-linear) interpolation on GLES devices that lack
// GL_NV_shader_noperspective_interpolation, so no NV extension is required. The hardware
// interpolates perspective-correct; screen-linear L(a) is recovered from the identity
// L(a) = P(a * w) * gl_FragCoord.w
// where P is perspective-correct interpolation and w is the vertex clip-space w. So each
// NoPerspective-decorated output is pre-multiplied by gl_Position.w in the vertex stage
// and each NoPerspective-decorated input is multiplied by gl_FragCoord.w in the fragment
// stage; the decoration is then removed so the varying transports smooth. This is exact
// (modulo float precision - the emulated varyings want highp).
//
// Scope: plain interface variables of float or floatN type. Anything it cannot emulate
// (interface-block members, matrices, or a stage lacking the needed builtin) has its
// NoPerspective decoration stripped instead, degrading to smooth - the same result the
// extension-less fallback produced before, and never invalid SPIR-V. DirectGLES only.
class EmulateNoPerspectivePass : public spvtools::opt::Pass {
public:
const char* name() const override { return "emulate-noperspective"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateEmulateNoPerspectivePass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,72 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "StripNoPerspectivePass.h"
#include "spirv.hpp"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/util/make_unique.h"
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
// OpDecorate <target-id> <decoration> [literals...]
// OpMemberDecorate <struct-id> <member> <decoration> [literals...]
constexpr uint32_t kDecorateDecorationOperand = 1;
constexpr uint32_t kMemberDecorateDecorationOperand = 2;
} // namespace
spvtools::opt::Pass::Status StripNoPerspectivePass::Process() {
auto* irContext = context();
// Collect first: KillInst mutates the annotation list being walked.
std::vector<Instruction*> toKill;
for (Instruction& annotation : irContext->annotations()) {
uint32_t decorationOperand = 0;
if (annotation.opcode() == spv::Op::OpDecorate) {
decorationOperand = kDecorateDecorationOperand;
} else if (annotation.opcode() == spv::Op::OpMemberDecorate) {
decorationOperand = kMemberDecorateDecorationOperand;
} else {
continue;
}
if (annotation.NumInOperands() <= decorationOperand) {
continue;
}
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(decorationOperand)) ==
spv::Decoration::NoPerspective) {
toKill.push_back(&annotation);
}
}
if (toKill.empty()) {
return Status::SuccessWithoutChange;
}
for (Instruction* inst : toKill) {
irContext->KillInst(inst);
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken StripNoPerspectivePass::CreateStripNoPerspectivePass() {
return spvtools::Optimizer::PassToken(MakeUnique<StripNoPerspectivePass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,35 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Removes the NoPerspective decoration from every interface variable and block member.
// DirectGLES fallback only, for devices that lack GL_NV_shader_noperspective_interpolation:
// SPIRV-Cross renders a NoPerspective-decorated varying as ESSL `noperspective` plus
// `#extension GL_NV_shader_noperspective_interpolation : require`, which such a driver
// rejects. Dropping the decoration falls the varying back to smooth (perspective-correct)
// interpolation - the same visible result the old text-level strip produced, but without
// corrupting identifiers and without touching DirectVulkan, where NoPerspective is native.
// (The exact screen-linear emulation via gl_Position.w / gl_FragCoord.w is a later step.)
class StripNoPerspectivePass : public spvtools::opt::Pass {
public:
const char* name() const override { return "strip-noperspective"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateStripNoPerspectivePass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL