mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
[Fix, Test] (MG_Backend/DirectVulkan): re-land the gl_FragCoord default-framebuffer origin fix - the suspected slowdown was a mismeasurement, paired timings are within 1%
This commit is contained in:
@@ -937,6 +937,189 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
ProgramFactory::CompileOptionFlags m_transformFlags;
|
||||
};
|
||||
|
||||
// gl_FragCoord back into GL's window space, for default-framebuffer draws only.
|
||||
//
|
||||
// Vulkan's gl_FragCoord.y is the framebuffer ROW being written - not a value the
|
||||
// viewport rect can move independently of placement. The default framebuffer's image is
|
||||
// stored display-side-up and the vertex stage compensates by negating gl_Position.y, so
|
||||
// for every default-FBO draw the framebuffer row of a fragment is exactly
|
||||
// `height - y_GL` (the viewport terms cancel: yf_VK = H - yf_GL for any viewport rect).
|
||||
// A shader that reads gl_FragCoord therefore sees a flipped Y, and once the viewport
|
||||
// rect started being converted to the stored orientation it also sees a Y that is
|
||||
// OUTSIDE the range GL promises - a 32-pixel-tall viewport at GL y=0 reports 224..255 on
|
||||
// a 256-tall surface. GL CTS shader_image_load_store writes imageStore(image,
|
||||
// ivec2(gl_FragCoord.xy)) into an image exactly the size of that viewport, so every
|
||||
// store fell outside the image and the test read back zeroes.
|
||||
//
|
||||
// The rewrite redirects every read of the builtin to a Private copy initialised once at
|
||||
// entry, which is exact for all access forms (whole-vector loads, `.y` access chains,
|
||||
// OpCopyMemory) and leaves the builtin itself - and its decorations - untouched.
|
||||
class GlFragCoordYFlipPass final : public spvtools::opt::Pass {
|
||||
public:
|
||||
const char* name() const override { return "mobilegl-fragcoord-y-flip"; }
|
||||
explicit GlFragCoordYFlipPass(Uint32 framebufferHeight) : m_framebufferHeight(framebufferHeight) {}
|
||||
|
||||
Status Process() override {
|
||||
using namespace spvtools::opt;
|
||||
if (m_framebufferHeight == 0) return Status::SuccessWithoutChange;
|
||||
|
||||
Instruction* entryPoint = nullptr;
|
||||
for (auto& candidate : get_module()->entry_points()) {
|
||||
if (candidate.NumInOperands() >= 2 &&
|
||||
static_cast<spv::ExecutionModel>(candidate.GetSingleWordInOperand(0)) ==
|
||||
spv::ExecutionModel::Fragment) {
|
||||
entryPoint = &candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!entryPoint) return Status::SuccessWithoutChange;
|
||||
|
||||
const Uint32 builtinVarId = FindFragCoordVariable();
|
||||
if (builtinVarId == 0) return Status::SuccessWithoutChange;
|
||||
|
||||
Instruction* builtinVar = context()->get_def_use_mgr()->GetDef(builtinVarId);
|
||||
if (!builtinVar || builtinVar->opcode() != spv::Op::OpVariable) return Status::SuccessWithoutChange;
|
||||
|
||||
// The builtin is `Input vec4`; take the vector and component types from its own
|
||||
// pointer type rather than assuming float32x4, so a module that spells it
|
||||
// differently declines instead of miscompiling.
|
||||
Instruction* inputPtrType = context()->get_def_use_mgr()->GetDef(builtinVar->type_id());
|
||||
if (!inputPtrType || inputPtrType->opcode() != spv::Op::OpTypePointer) {
|
||||
return Status::SuccessWithoutChange;
|
||||
}
|
||||
const Uint32 vectorTypeId = inputPtrType->GetSingleWordInOperand(1);
|
||||
Instruction* vectorType = context()->get_def_use_mgr()->GetDef(vectorTypeId);
|
||||
if (!vectorType || vectorType->opcode() != spv::Op::OpTypeVector ||
|
||||
vectorType->GetSingleWordInOperand(1) != 4) {
|
||||
return Status::SuccessWithoutChange;
|
||||
}
|
||||
const Uint32 floatTypeId = vectorType->GetSingleWordInOperand(0);
|
||||
auto* floatType = context()->get_type_mgr()->GetType(floatTypeId);
|
||||
if (!floatType || !floatType->AsFloat() || floatType->AsFloat()->width() != 32) {
|
||||
return Status::SuccessWithoutChange;
|
||||
}
|
||||
|
||||
const auto heightBits = std::bit_cast<Uint32>(static_cast<float>(m_framebufferHeight));
|
||||
const auto* heightConst = context()->get_constant_mgr()->GetConstant(floatType, {heightBits});
|
||||
auto* heightInst = context()->get_constant_mgr()->GetDefiningInstruction(heightConst);
|
||||
if (!heightInst) return Status::SuccessWithoutChange;
|
||||
|
||||
auto* function = context()->GetFunction(entryPoint->GetSingleWordInOperand(1));
|
||||
if (!function || function->begin() == function->end()) return Status::SuccessWithoutChange;
|
||||
|
||||
const Uint32 privatePtrTypeId =
|
||||
context()->get_type_mgr()->FindPointerToType(vectorTypeId, spv::StorageClass::Private);
|
||||
if (privatePtrTypeId == 0) return Status::SuccessWithoutChange;
|
||||
|
||||
const Uint32 copyVarId = context()->TakeNextId();
|
||||
if (copyVarId == 0) return Status::SuccessWithoutChange;
|
||||
auto copyVar = std::make_unique<Instruction>(
|
||||
context(), spv::Op::OpVariable, privatePtrTypeId, copyVarId,
|
||||
std::initializer_list<Operand>{
|
||||
{SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast<Uint32>(spv::StorageClass::Private)}}});
|
||||
context()->AddGlobalValue(std::move(copyVar));
|
||||
|
||||
// Redirect the reads BEFORE emitting the initialiser, so the initialiser's own
|
||||
// load of the builtin is not rewritten into a load of the (still empty) copy.
|
||||
if (!RedirectReads(builtinVarId, copyVarId)) return Status::SuccessWithoutChange;
|
||||
|
||||
auto& entryBlock = *function->begin();
|
||||
auto insertPoint = entryBlock.begin();
|
||||
while (insertPoint != entryBlock.end() && insertPoint->opcode() == spv::Op::OpVariable) {
|
||||
++insertPoint;
|
||||
}
|
||||
if (insertPoint == entryBlock.end()) return Status::SuccessWithoutChange;
|
||||
|
||||
InstructionBuilder builder(context(), &*insertPoint,
|
||||
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
|
||||
auto* raw = builder.AddLoad(vectorTypeId, builtinVarId);
|
||||
if (!raw) return Status::SuccessWithoutChange;
|
||||
auto* x = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {0});
|
||||
auto* y = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {1});
|
||||
auto* z = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {2});
|
||||
auto* w = builder.AddCompositeExtract(floatTypeId, raw->result_id(), {3});
|
||||
if (!x || !y || !z || !w) return Status::SuccessWithoutChange;
|
||||
auto* flippedY =
|
||||
builder.AddBinaryOp(floatTypeId, spv::Op::OpFSub, heightInst->result_id(), y->result_id());
|
||||
if (!flippedY) return Status::SuccessWithoutChange;
|
||||
auto* corrected = builder.AddCompositeConstruct(
|
||||
vectorTypeId, {x->result_id(), flippedY->result_id(), z->result_id(), w->result_id()});
|
||||
if (!corrected) return Status::SuccessWithoutChange;
|
||||
if (!builder.AddStore(copyVarId, corrected->result_id())) return Status::SuccessWithoutChange;
|
||||
|
||||
// SPIR-V 1.4 widened the entry-point interface to every global the entry point
|
||||
// statically uses, Private included; earlier versions accept Input/Output only,
|
||||
// so listing it there would be invalid.
|
||||
if (get_module()->version() >= 0x00010400u) {
|
||||
entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {copyVarId}});
|
||||
context()->AnalyzeUses(entryPoint);
|
||||
}
|
||||
|
||||
context()->InvalidateAnalysesExceptFor(spvtools::opt::IRContext::kAnalysisDefUse |
|
||||
spvtools::opt::IRContext::kAnalysisInstrToBlockMapping);
|
||||
return Status::SuccessWithChange;
|
||||
}
|
||||
|
||||
private:
|
||||
Uint32 FindFragCoordVariable() const {
|
||||
for (const auto& annotation : get_module()->annotations()) {
|
||||
if (annotation.opcode() != spv::Op::OpDecorate) continue;
|
||||
if (annotation.NumInOperands() < 3) continue;
|
||||
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) !=
|
||||
spv::Decoration::BuiltIn) {
|
||||
continue;
|
||||
}
|
||||
if (static_cast<spv::BuiltIn>(annotation.GetSingleWordInOperand(2)) != spv::BuiltIn::FragCoord) {
|
||||
continue;
|
||||
}
|
||||
return annotation.GetSingleWordInOperand(0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Every instruction that reads through the builtin's POINTER gets the copy instead.
|
||||
// Decorations, names and the entry-point interface keep naming the builtin.
|
||||
Bool RedirectReads(Uint32 builtinVarId, Uint32 copyVarId) {
|
||||
using namespace spvtools::opt;
|
||||
Bool ok = true;
|
||||
Vector<Instruction*> users;
|
||||
context()->get_def_use_mgr()->ForEachUser(builtinVarId, [&](Instruction* user) {
|
||||
switch (user->opcode()) {
|
||||
case spv::Op::OpLoad:
|
||||
case spv::Op::OpAccessChain:
|
||||
case spv::Op::OpInBoundsAccessChain:
|
||||
case spv::Op::OpPtrAccessChain:
|
||||
case spv::Op::OpInBoundsPtrAccessChain:
|
||||
case spv::Op::OpCopyMemory:
|
||||
case spv::Op::OpCopyMemorySized:
|
||||
users.push_back(user);
|
||||
break;
|
||||
case spv::Op::OpStore:
|
||||
// gl_FragCoord is read-only; a store through it means this is not the
|
||||
// module we think it is.
|
||||
ok = false;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (!ok) return false;
|
||||
for (Instruction* user : users) {
|
||||
for (Uint32 i = 0; i < user->NumInOperands(); ++i) {
|
||||
auto& operand = user->GetInOperand(i);
|
||||
if (operand.type == SPV_OPERAND_TYPE_ID && !operand.words.empty() &&
|
||||
operand.words[0] == builtinVarId) {
|
||||
operand.words[0] = copyVarId;
|
||||
}
|
||||
}
|
||||
context()->AnalyzeUses(user);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Uint32 m_framebufferHeight = 0;
|
||||
};
|
||||
|
||||
// Decorates the module's captured varyings for VK_EXT_transform_feedback:
|
||||
// user outputs get XfbBuffer/XfbStride/Offset directly; a captured
|
||||
// gl_Position (a gl_PerVertex member) is mirrored into a dedicated output
|
||||
@@ -1301,6 +1484,35 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
return spvtools::Optimizer::PassToken(MakeUnique<GlToVulkanPositionFixPass>(transformFlags));
|
||||
}
|
||||
|
||||
Bool TransformSpirvForFragCoordYFlip(const Vector<Uint>& input, Vector<Uint>& output,
|
||||
Uint32 framebufferHeight) {
|
||||
if (input.empty()) {
|
||||
output.clear();
|
||||
return true;
|
||||
}
|
||||
if (framebufferHeight == 0) {
|
||||
output = input;
|
||||
return true;
|
||||
}
|
||||
|
||||
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
|
||||
spvtools::OptimizerOptions options;
|
||||
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
|
||||
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
|
||||
const char* message) {
|
||||
MGLOG_E("Vulkan: fragcoord y-flip pass: %s", message != nullptr ? message : "");
|
||||
});
|
||||
optimizer.RegisterPass(
|
||||
spvtools::Optimizer::PassToken(MakeUnique<GlFragCoordYFlipPass>(framebufferHeight)));
|
||||
|
||||
const Bool success = optimizer.Run(input.data(), input.size(), &output, options);
|
||||
if (!success) {
|
||||
MGLOG_E("Vulkan: failed to run the gl_FragCoord y-flip pass; keeping the original module");
|
||||
output = input;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
Bool TransformSpirvForXfbCapture(const Vector<Uint>& input, Vector<Uint>& output,
|
||||
const MG_State::GLState::ProgramObject& program) {
|
||||
if (input.empty()) {
|
||||
@@ -1772,6 +1984,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, spv.data(), spv.size() * sizeof(Uint)));
|
||||
}
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &flags, sizeof(CompileOptionFlags)));
|
||||
// Only FragCoordYFlip variants bake the height in, so mixing it unconditionally would
|
||||
// re-key every program in the cache on a resize for no reason.
|
||||
if (flags & CompileOptionBit::FragCoordYFlip) {
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &m_defaultFramebufferHeight,
|
||||
sizeof(m_defaultFramebufferHeight)));
|
||||
}
|
||||
|
||||
// Include UBO block bindings in hash so different binding configurations produce different entries
|
||||
const Uint32 blockCount = static_cast<Uint32>(program.GetActiveUniformBlocksCount());
|
||||
@@ -2380,14 +2598,36 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
}
|
||||
|
||||
void ProgramFactory::SetDefaultFramebufferHeight(Uint32 height) {
|
||||
if (m_defaultFramebufferHeight == height) {
|
||||
return;
|
||||
}
|
||||
m_defaultFramebufferHeight = height;
|
||||
// Both memos key on (program, flags) alone, so neither can tell the two heights apart:
|
||||
// drop the lookup memo, and bump the structure epoch so every caller holding a
|
||||
// VkProgramObject* re-runs GetOrCreateProgram and lands on the new hash. The cached
|
||||
// entries themselves stay - they are keyed by a hash that now includes the old height,
|
||||
// so they can only be reached again if that height comes back, and the frame-boundary
|
||||
// sweep retires them otherwise.
|
||||
m_lastLookup = {};
|
||||
++m_cacheStructureEpoch;
|
||||
}
|
||||
|
||||
const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram(
|
||||
const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags) {
|
||||
// Hashing the full SPIR-V of every stage is far too expensive to repeat per draw;
|
||||
// reuse the program's memoized hash while its backend state version is unchanged.
|
||||
// The memo keys on the flags word, which ComputeHash is no longer a pure function of:
|
||||
// a FragCoordYFlip variant also depends on the baked default-framebuffer height, so
|
||||
// that height rides in the free high half of the key. Flags occupy the low bits, and a
|
||||
// height cannot exceed the 16 bits a swapchain extent fits in.
|
||||
const Uint memoKey = (flags & CompileOptionBit::FragCoordYFlip)
|
||||
? (flags.GetRaw() | (m_defaultFramebufferHeight << 16))
|
||||
: flags.GetRaw();
|
||||
HashType hash = 0;
|
||||
if (!program.GetBackendHashMemo(flags.GetRaw(), hash)) {
|
||||
if (!program.GetBackendHashMemo(memoKey, hash)) {
|
||||
hash = ComputeHash(program, flags);
|
||||
program.SetBackendHashMemo(flags.GetRaw(), hash);
|
||||
program.SetBackendHashMemo(memoKey, hash);
|
||||
}
|
||||
auto it = m_cache.find(hash);
|
||||
if (it != m_cache.end()) {
|
||||
@@ -2440,6 +2680,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
}
|
||||
}
|
||||
|
||||
if ((flags & ProgramFactory::CompileOptionBit::FragCoordYFlip) && shaders[i] &&
|
||||
shaders[i]->GetShaderStage() == ShaderStage::Fragment) {
|
||||
Vector<Uint> fragCoordSpirv;
|
||||
if (TransformSpirvForFragCoordYFlip(moduleSpirvs[i], fragCoordSpirv, m_defaultFramebufferHeight)) {
|
||||
moduleSpirvs[i] = Move(fragCoordSpirv);
|
||||
}
|
||||
}
|
||||
|
||||
// Vulkan's SPIR-V environment has no rectangle image dimension, so a
|
||||
// GL_TEXTURE_RECTANGLE lookup has to become the 2D one the texture is really
|
||||
// stored as - which addresses [0,1] where the application addressed texels.
|
||||
|
||||
@@ -53,6 +53,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// recorded while GL transform feedback is active, so plain draws keep the
|
||||
// undecorated variant.
|
||||
XfbCapture = 1 << 6,
|
||||
// Rewrites the fragment stage's gl_FragCoord reads to GL's bottom-left window
|
||||
// origin. Vulkan's gl_FragCoord.y IS the framebuffer row being written, and the
|
||||
// default framebuffer's image is stored in display (top-left) order, so a shader
|
||||
// that reads gl_FragCoord there sees `height - y_GL`. Set together with
|
||||
// PositionYFlip (the two are the same fact about the same draws) except under a
|
||||
// quarter turn, which this renderer does not convert rectangles for either.
|
||||
FragCoordYFlip = 1 << 7,
|
||||
};
|
||||
using CompileOptionFlags = Flags<CompileOptionBit>;
|
||||
using HashType = Uint64;
|
||||
@@ -267,6 +274,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const VkProgramObject& GetOrCreateProgram(
|
||||
const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags);
|
||||
|
||||
// The default framebuffer's current image height, baked as a literal into every
|
||||
// FragCoordYFlip variant (there is no push-constant or specialization channel here, and
|
||||
// adding one for a value that changes only on swapchain recreation would cost the draw
|
||||
// path more than a recompile costs a resize). It is therefore part of those variants'
|
||||
// identity: ComputeHash mixes it in when the bit is set, so a height change re-keys them
|
||||
// and leaves every other program's hash untouched. Setting a NEW height also bumps the
|
||||
// cache-structure epoch, because a caller holding a memoised VkProgramObject* would
|
||||
// otherwise keep using a module compiled against the old height.
|
||||
void SetDefaultFramebufferHeight(Uint32 height);
|
||||
Uint32 GetDefaultFramebufferHeight() const { return m_defaultFramebufferHeight; }
|
||||
|
||||
// Bumped whenever m_cache's STRUCTURE changes (any insert or erase): the cache is
|
||||
// an open-addressing map holding entries by value, so both moves existing entries.
|
||||
// A caller that memoised a VkProgramObject* may keep dereferencing it only while
|
||||
@@ -324,6 +342,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
// True only when the logical device enabled both
|
||||
// shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat.
|
||||
Bool m_unformattedFloatStorageImagesEnabled = false;
|
||||
// See SetDefaultFramebufferHeight. 0 means "not known yet"; the FragCoordYFlip bit is
|
||||
// never set before the swapchain exists, so no variant can be compiled against it.
|
||||
Uint32 m_defaultFramebufferHeight = 0;
|
||||
mutable ProgramLookupCache m_lastLookup;
|
||||
// Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging.
|
||||
Uint64 m_frameCounter = 0;
|
||||
|
||||
@@ -2779,6 +2779,14 @@ void main() {
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
if (currentDrawFBO != nullptr && currentDrawFBO->IsDefaultFramebuffer()) {
|
||||
flags |= ProgramFactory::CompileOptionBit::PositionYFlip;
|
||||
// gl_FragCoord follows the same rule the default-framebuffer RECTANGLES follow
|
||||
// (GetDefaultFramebufferRectMapping): flipped for identity/180, left alone under a
|
||||
// quarter turn, which this renderer converts nothing for. Keeping the two in step
|
||||
// is the whole point - a fragment's window Y and the viewport that placed it must
|
||||
// agree on which end of the image they count from.
|
||||
if (!IsQuarterTurnPreTransform(preTransform)) {
|
||||
flags |= ProgramFactory::CompileOptionBit::FragCoordYFlip;
|
||||
}
|
||||
switch (preTransform) {
|
||||
case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR:
|
||||
flags |= ProgramFactory::CompileOptionBit::SurfaceRotate90;
|
||||
@@ -2933,6 +2941,9 @@ void main() {
|
||||
m_shaderDrawParametersFeatureEnabled,
|
||||
m_unformattedFloatStorageImagesEnabled);
|
||||
MOBILEGL_ASSERT(m_programFactory != nullptr, "ProgramFactory creation failed.");
|
||||
// The swapchain already exists at this point (Initialize creates it first), so seed the
|
||||
// height the factory could not be told about from CreateSwapchain.
|
||||
m_programFactory->SetDefaultFramebufferHeight(m_swapchainObject.GetExtent().height);
|
||||
// Aging evictions (render passes and program entries) must purge the dependent
|
||||
// pipeline / compute-pipeline / descriptor-set caches in the same step; both
|
||||
// sweeps only run from the frame-boundary seams, long after initialization.
|
||||
@@ -12021,6 +12032,12 @@ void main() {
|
||||
static_cast<Uint32>(m_physicalDevice.queueFamilies.graphicsFamily),
|
||||
static_cast<Uint32>(m_physicalDevice.queueFamilies.presentFamily),
|
||||
m_config.MaxFramesInFlight, desiredExtent);
|
||||
// The FragCoordYFlip variants bake this height in; it is the only input to a shader
|
||||
// module that lives outside the GL program, so the factory has to learn it here (and on
|
||||
// every recreation, which is the only way it can change).
|
||||
if (m_programFactory) {
|
||||
m_programFactory->SetDefaultFramebufferHeight(m_swapchainObject.GetExtent().height);
|
||||
}
|
||||
}
|
||||
|
||||
void VulkanRenderer::CreateCommandPool() {
|
||||
|
||||
@@ -56,6 +56,7 @@ add_executable(MobileGLIntegrationTest
|
||||
Scenarios/PipelineFailureScenario.cpp
|
||||
Scenarios/AdvertisedLimitsScenario.cpp
|
||||
Scenarios/PixelStoreSweepScenario.cpp
|
||||
Scenarios/FragCoordOriginScenario.cpp
|
||||
)
|
||||
|
||||
target_include_directories(MobileGLIntegrationTest PRIVATE
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FragCoordOriginScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario - gl_FragCoord ON THE DEFAULT FRAMEBUFFER CARRIES GL'S WINDOW ORIGIN.
|
||||
//
|
||||
// GL measures gl_FragCoord.y from the BOTTOM of the window. Vulkan's gl_FragCoord.y is the
|
||||
// framebuffer ROW being written, and DirectVulkan stores the default framebuffer display-side-up
|
||||
// (compensating for vertices by negating gl_Position.y), so a fragment's reported Y there was
|
||||
// `height - y_GL` - flipped, and for a viewport that does not span the full height, outside the
|
||||
// range GL promises entirely. GL CTS
|
||||
// `KHR-GL42.shader_image_load_store.basic-{allTargets-atomic,glsl-earlyFragTests,glsl-misc}`
|
||||
// caught it: each sets a small viewport at GL y=0 and does
|
||||
// `imageStore(image, ivec2(gl_FragCoord.xy), ...)` into an image exactly that size, so on a
|
||||
// 256-tall surface every store addressed rows 224..255 of a 32-row image and was dropped.
|
||||
//
|
||||
// The shader here paints each row with its own GL window Y, which is the whole claim in one
|
||||
// value: row j of the readback must be j, for a full-height viewport and for a half-height one
|
||||
// (the case where a flip and an offset can no longer hide each other). DirectGLES is the
|
||||
// built-in control - a native GL driver gets this right by construction, so a failure there
|
||||
// would mean the test, not the backend.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kVS = R"(#version 330 core
|
||||
in vec2 aPos;
|
||||
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
|
||||
)";
|
||||
|
||||
// floor(gl_FragCoord.y) is the fragment's window row; 1/255 steps survive an RGBA8
|
||||
// round trip exactly, so the readback byte IS the row the shader believes it is on.
|
||||
constexpr const char* kFS = R"(#version 330 core
|
||||
out vec4 o_color;
|
||||
void main() { o_color = vec4(floor(gl_FragCoord.y) / 255.0, 0.0, 0.0, 1.0); }
|
||||
)";
|
||||
|
||||
class FragCoordOriginScenario : public ScenarioTest {};
|
||||
|
||||
// A quad covering the whole viewport, drawn with attribute 0 = aPos.
|
||||
void DrawFullViewportQuad(unsigned int program) {
|
||||
static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
|
||||
GLuint vao = 0, vbo = 0;
|
||||
glGenVertexArrays(1, &vao);
|
||||
glBindVertexArray(vao);
|
||||
glGenBuffers(1, &vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW);
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
|
||||
glUseProgram(program);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
glBindVertexArray(0);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
}
|
||||
|
||||
// Paints `viewportHeight` rows starting at GL y=0 and returns the red byte of each row.
|
||||
std::vector<int> RowsPaintedWithTheirOwnWindowY(unsigned int program, int width, int viewportHeight) {
|
||||
BindDefaultFramebuffer();
|
||||
glViewport(0, 0, width, viewportHeight);
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
|
||||
DrawFullViewportQuad(program);
|
||||
|
||||
const Image image = ReadPixelsRect(0, 0, width, viewportHeight);
|
||||
std::vector<int> rows;
|
||||
rows.reserve(static_cast<std::size_t>(viewportHeight));
|
||||
for (int y = 0; y < viewportHeight; ++y) {
|
||||
rows.push_back(image.At(width / 2, y).r);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
::testing::AssertionResult RowsAreTheirOwnIndex(const std::vector<int>& rows, const char* when) {
|
||||
for (std::size_t y = 0; y < rows.size(); ++y) {
|
||||
if (rows[y] != static_cast<int>(y)) {
|
||||
return ::testing::AssertionFailure()
|
||||
<< when << ": GL window row " << y << " reported gl_FragCoord.y = " << rows[y]
|
||||
<< " (expected " << y << "). Rows 0.." << (rows.size() - 1) << " read back as ["
|
||||
<< rows.front() << " .. " << rows.back() << "].";
|
||||
}
|
||||
}
|
||||
return ::testing::AssertionSuccess();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_F(FragCoordOriginScenario, DefaultFramebufferFragCoordCountsFromTheBottom) {
|
||||
if (!Ready()) return;
|
||||
HeadlessGL& gl = Gl();
|
||||
// 1/255 steps only stay distinguishable while the row index fits in a byte.
|
||||
const int width = gl.Width();
|
||||
const int fullHeight = std::min(gl.Height(), 256);
|
||||
ASSERT_GE(fullHeight, 8) << "the harness surface is too small to tell rows apart";
|
||||
|
||||
std::string error;
|
||||
const unsigned int program = CompileProgram(kVS, kFS, &error);
|
||||
ASSERT_NE(program, 0u) << error;
|
||||
|
||||
// Full height first: this one passed even before the fix (a flip alone maps the row set
|
||||
// onto itself), so it is the control that the shader and the readback agree at all.
|
||||
EXPECT_TRUE(RowsAreTheirOwnIndex(RowsPaintedWithTheirOwnWindowY(program, width, fullHeight),
|
||||
"full-height viewport"));
|
||||
|
||||
// Half height at GL y=0: the case the CTS failures were made of. A backend that reports
|
||||
// the stored row here answers `height - y` for every row - off the bottom of the range,
|
||||
// not merely reversed within it.
|
||||
const int halfHeight = fullHeight / 2;
|
||||
EXPECT_TRUE(RowsAreTheirOwnIndex(RowsPaintedWithTheirOwnWindowY(program, width, halfHeight),
|
||||
"half-height viewport at GL y=0"));
|
||||
|
||||
glUseProgram(0);
|
||||
glDeleteProgram(program);
|
||||
glViewport(0, 0, gl.Width(), gl.Height());
|
||||
EXPECT_EQ(FirstGLError(), 0u);
|
||||
}
|
||||
|
||||
} // namespace MGITest
|
||||
Reference in New Issue
Block a user