[Fix] (MG_State, DirectGLES): sample a mipmap-incomplete texture as black

A minification filter that reads the mip chain requires every level from the base down
to hold exactly half the previous one's size; a texture that does not is incomplete and
every lookup on it returns (0, 0, 0, 1) (GL 4.6 core 8.17). Nothing checked it.

The ES driver cannot catch this on MobileGL's behalf, which is why it has to be a
frontend rule here: the backend texture is immutable storage allocated from the level
set as it stood, so a level the application later redefined at a different size never
reaches the driver at all, and the ES texture stays complete. That is exactly what
KHR-GL40.texture_gather.incomplete-texture does - it redefines level 1 of a complete
chain as 1x1 - and it read the original contents back.

The check runs where the sampling bindings are established, and an incomplete texture
simply leaves its native target unbound: an unbound ES target samples as (0, 0, 0, 1),
which is the answer GL asks for, with no scratch texture to keep around.

An array texture's layer count is not one of the dimensions that halves, so the
comparison only shrinks the components that belong to the image itself - getting that
wrong turned eight *-2darray cases black.
This commit is contained in:
BZLZHH
2026-08-04 10:49:51 -04:00
parent 5437947240
commit ae6949e459
3 changed files with 74 additions and 0 deletions
@@ -1613,6 +1613,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
const GLenum targetGL = TextureImpl::ConvertTextureTargetToBackendGLEnum(target);
// A texture whose mip chain does not satisfy the filter's completeness
// rules samples as (0, 0, 0, 1). The ES driver cannot work that out for
// itself here: the backend texture is immutable storage, so a level the
// application redefined at the wrong size never reached it. Leaving the
// native target unbound produces exactly the incomplete-texture result.
const auto& effectiveSampler = textureUnit.GetSamplerObject()
? textureUnit.GetSamplerObject()
: textureObject->GetSamplerObject();
const Bool mipmappedFilter =
effectiveSampler && effectiveSampler->GetMipmapMode() != SamplerMipmapMode::None;
if (!MG_State::GLState::IsMipmapCompleteForFilter(textureObject.get(), mipmappedFilter)) {
continue;
}
// Bind texture object
const auto& backendTextureIt = TextureImpl::g_backendTextureObjects.find(textureObject.get());
if (backendTextureIt == TextureImpl::g_backendTextureObjects.end()) continue;
@@ -348,6 +348,58 @@ namespace MobileGL {
// TODO: add other texture types as needed
Bool IsMipmapCompleteForFilter(const ITextureObject* texture, Bool mipmapped) {
if (texture == nullptr) return true;
if (!texture->IsComplete()) return false;
if (!mipmapped) return true;
const auto* mipmapTexture = AsMipmapTexture(texture);
if (mipmapTexture == nullptr) return true; // no mip chain to be incomplete about
const UintVec2& levelRange = texture->GetLevelRange();
const Uint baseLevel = levelRange.x();
const Uint storedLevels = mipmapTexture->GetMipmapLevelCount();
if (baseLevel >= storedLevels) return false;
// An array texture's layer count is not a dimension of the image: it stays put all
// the way down the chain (GL 4.6 core 8.14.3). GetMipmapTexelSize reports it in the
// slot after the image's own dimensions.
const TextureTarget target = texture->GetTarget();
Int shrinkingComponents = 3;
if (target == TextureTarget::Texture1DArray) {
shrinkingComponents = 1;
} else if (target == TextureTarget::Texture2DArray || target == TextureTarget::TextureCubeMapArray) {
shrinkingComponents = 2;
}
for (const auto uploadTarget : texture->GetUploadTargets()) {
const IntVec3 baseSize = mipmapTexture->GetMipmapTexelSize(uploadTarget, baseLevel);
Int largest = 0;
for (Int component = 0; component < shrinkingComponents; ++component) {
largest = std::max(largest, baseSize[component]);
}
if (largest <= 0) return false;
// p = log2 of the largest base dimension: the last level the chain needs
// before every dimension has reached 1. TEXTURE_MAX_LEVEL can cut it short.
Uint p = 0;
for (Int extent = largest; extent > 1; extent >>= 1) ++p;
const Uint lastLevel = std::min(baseLevel + p, levelRange.y());
for (Uint level = baseLevel; level <= lastLevel; ++level) {
if (level >= storedLevels) return false;
const IntVec3 actual = mipmapTexture->GetMipmapTexelSize(uploadTarget, level);
for (Int component = 0; component < 3; ++component) {
const Int expected = component < shrinkingComponents
? std::max(1, baseSize[component] >> (level - baseLevel))
: baseSize[component];
if (actual[component] != expected) return false;
}
}
}
return true;
}
} // namespace GLState
} // namespace MG_State
} // namespace MobileGL
@@ -156,6 +156,14 @@ namespace MobileGL::MG_State::GLState {
? static_cast<TextureObjectMipmap*>(texture)
: nullptr;
}
// Whether the texture satisfies the mipmap-completeness rules a minification filter
// that samples the mip chain imposes (GL 4.6 core 8.17): every level from the base to
// the effective max must exist at exactly half the previous one's size. `mipmapped` is
// the effective sampler's answer to "does this filter read more than the base level" -
// when it is false only base-level completeness matters, which the ordinary
// IsComplete() already covers. Sampling an incomplete texture returns (0, 0, 0, 1).
Bool IsMipmapCompleteForFilter(const ITextureObject* texture, Bool mipmapped);
inline const TextureObjectMipmap* AsMipmapTexture(const ITextureObject* texture) {
return (texture && texture->GetStorageType() == TextureStorageType::Mipmap)
? static_cast<const TextureObjectMipmap*>(texture)