diff --git a/Apps/Playground/Scripts/config.json b/Apps/Playground/Scripts/config.json index 93b43eaf7..609897f2a 100644 --- a/Apps/Playground/Scripts/config.json +++ b/Apps/Playground/Scripts/config.json @@ -833,8 +833,6 @@ { "title": "Volumetric Light Scattering Post Process with Morph Targets", "playgroundId": "#5E318S#7", - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails on Linux (large diff)", "referenceImage": "volumetricLightScatteringMorphTargets.png" }, { @@ -1092,8 +1090,6 @@ "title": "GUI Near Menu", "playgroundId": "#2YZFA0#302", "renderCount": 60, - "excludedGraphicsApis": ["OpenGL"], - "reason": "OpenGL: BGFX FATAL shader compile error in GUI fragment shader ('=' : cannot convert from 'highp float' to 'bool'). Re-enabled on D3D11 post BabylonJS/BabylonNative#1695 (original 'V8 D3D11 crash' no longer reproduces under Chakra; original 'hangs on OpenGL' is now this clean shader-compile failure surfaced by the BabylonJS/BabylonNative#1688 BgfxCallback).", "referenceImage": "guiNearMenu.png" }, { @@ -1843,9 +1839,9 @@ "title": "Prepass SSAO + particles", "playgroundId": "#65MUMZ#47", "renderCount": 50, + "referenceImage": "prepass-ssao-particles.png", "excludeFromAutomaticTesting": true, - "reason": "SSAO2 blur post-process shader fails to compile on desktop GL (samples uniform used as int loop bound); unrelated to instancing.", - "referenceImage": "prepass-ssao-particles.png" + "reason": "llvmpipe on the Ubuntu CI runner aborts the process with LLVM ERROR: Cannot emit physreg copy instruction while JIT-compiling the prepass SSAO shaders (Mesa/LLVM register allocator bug, thrown before pixel comparison, not a pixel-diff); the test passes on D3D11 and on ANGLE/GLES" }, { "title": "Prepass SSAO + instances", @@ -1858,9 +1854,9 @@ "title": "Prepass SSAO + instanced bones", "playgroundId": "#0K8EYN#197", "renderCount": 50, + "referenceImage": "prepass-ssao-instanced-bones.png", "excludeFromAutomaticTesting": true, - "reason": "Test crashes or hangs on Babylon Native", - "referenceImage": "prepass-ssao-instanced-bones.png" + "reason": "llvmpipe on the Ubuntu CI runner aborts the process with LLVM ERROR: Cannot emit physreg copy instruction while JIT-compiling the prepass SSAO shaders (Mesa/LLVM register allocator bug, thrown before pixel comparison, not a pixel-diff); the test passes on D3D11 and on ANGLE/GLES" }, { "title": "Prepass SSAO + depth of field", @@ -1937,10 +1933,10 @@ { "title": "Prepass SSAO + GUI", "playgroundId": "#LLVZ90#4", - "excludeFromAutomaticTesting": true, - "reason": "Order-dependent state leak: passes in isolation but produces ~6000-px diff (right at 2.5% errorRatio threshold, flaky in CI) when run after sibling Prepass-SSAO tests in the full sweep on Win32 D3D11. OpenGL also fails (BGFX FATAL 'mediump float' shader compile in PrePassRenderer fragment shader). Re-enable after the order-dependent SSAO state cleanup is investigated.", "renderCount": 10, - "referenceImage": "prepass-ssao-gui.png" + "referenceImage": "prepass-ssao-gui.png", + "excludeFromAutomaticTesting": true, + "reason": "llvmpipe on the Ubuntu CI runner aborts the process with LLVM ERROR: Cannot emit physreg copy instruction while JIT-compiling the prepass SSAO shaders (Mesa/LLVM register allocator bug, thrown before pixel comparison, not a pixel-diff); the test passes on D3D11 and on ANGLE/GLES" }, { "title": "Prepass SSAO + LOD", @@ -1962,9 +1958,9 @@ "title": "Prepass SSAO + highlight layer", "playgroundId": "#1KUJ0A#416", "renderCount": 10, + "referenceImage": "prepass-ssao-highlight-layer.png", "excludeFromAutomaticTesting": true, - "reason": "Test crashes or hangs on Babylon Native", - "referenceImage": "prepass-ssao-highlight-layer.png" + "reason": "llvmpipe on the Ubuntu CI runner aborts the process with LLVM ERROR: Cannot emit physreg copy instruction while JIT-compiling the prepass SSAO shaders (Mesa/LLVM register allocator bug, thrown before pixel comparison, not a pixel-diff); the test passes on D3D11 and on ANGLE/GLES" }, { "title": "Prepass SSAO + point light", @@ -1978,25 +1974,25 @@ "title": "Prepass SSAO + on/off post-process", "playgroundId": "#1VI6WV#20", "renderCount": 10, + "referenceImage": "prepass-ssao-on-off-pp.png", "excludeFromAutomaticTesting": true, - "reason": "Test crashes or hangs on Babylon Native", - "referenceImage": "prepass-ssao-on-off-pp.png" + "reason": "llvmpipe on the Ubuntu CI runner aborts the process with LLVM ERROR: Cannot emit physreg copy instruction while JIT-compiling the prepass SSAO shaders (Mesa/LLVM register allocator bug, thrown before pixel comparison, not a pixel-diff); the test passes on D3D11 and on ANGLE/GLES" }, { "title": "Prepass SSAO + thin instances", "playgroundId": "#V1JE4Z#25", "renderCount": 10, + "referenceImage": "prepass-ssao-thin-instances.png", "excludeFromAutomaticTesting": true, - "reason": "Test crashes or hangs on Babylon Native", - "referenceImage": "prepass-ssao-thin-instances.png" + "reason": "llvmpipe on the Ubuntu CI runner aborts the process with LLVM ERROR: Cannot emit physreg copy instruction while JIT-compiling the prepass SSAO shaders (Mesa/LLVM register allocator bug, thrown before pixel comparison, not a pixel-diff); the test passes on D3D11 and on ANGLE/GLES" }, { "title": "Prepass SSAO + depth renderer", "playgroundId": "#3HPMAA#1", "renderCount": 10, + "referenceImage": "prepass-ssao-depth-renderer.png", "excludeFromAutomaticTesting": true, - "reason": "Test crashes or hangs on Babylon Native", - "referenceImage": "prepass-ssao-depth-renderer.png" + "reason": "llvmpipe on the Ubuntu CI runner aborts the process with LLVM ERROR: Cannot emit physreg copy instruction while JIT-compiling the prepass SSAO shaders (Mesa/LLVM register allocator bug, thrown before pixel comparison, not a pixel-diff); the test passes on D3D11 and on ANGLE/GLES" }, { "title": "Prepass SSAO + visibility", @@ -2378,9 +2374,9 @@ "title": "Screen Space Reflections 2", "playgroundId": "#PIZ1GK#1500", "renderCount": 5, + "referenceImage": "Screen-Space-Reflections-2.png", "excludeFromAutomaticTesting": true, - "reason": "NativeEngine Screen Space Reflections do not render the wet/reflective floor surface; SSR effect produces less reflection than WebGL reference.", - "referenceImage": "Screen-Space-Reflections-2.png" + "reason": "OpenGL ES: bgfx FrameBufferGL::resolve calls glDrawBuffers(1, &GL_COLOR_ATTACHMENT0 + colorIdx), but GLES requires bufs[i] to be GL_NONE or GL_COLOR_ATTACHMENT0 + i, so resolving any attachment past the first raises GL_INVALID_OPERATION and asserts (bgfx bug, thrown before pixel comparison, not a pixel-diff); the test passes on D3D11" }, { "title": "MultiRenderTarget with different texture types", diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index 6f073b6ff..d0f292063 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -33,6 +33,7 @@ set(SOURCES "Source/Tests.NativeEngine.Teardown.cpp" "Source/Tests.ShaderCache.cpp" "Source/Tests.ShaderCompilation.cpp" + "Source/Tests.ShaderCompilation.FragCoord.cpp" "Source/Tests.UniformPadding.cpp" "Source/Helpers.h" "Source/Helpers.${GRAPHICS_API}.${BABYLON_NATIVE_PLATFORM_IMPL_EXT}") diff --git a/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp new file mode 100644 index 000000000..2a741b489 --- /dev/null +++ b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp @@ -0,0 +1,420 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Helpers.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern Babylon::Graphics::Configuration g_deviceConfig; + +// These tests pin down the orientation of gl_FragCoord.y. +// +// Babylon Native's shader model is "shader-visible coordinates are GL-logical, +// converted to physical at each sampler access". D3D, Metal and Vulkan rasterize +// with a top-left origin while GL uses bottom-left, and Babylon Native does not +// flip geometry, so gl_FragCoord.y arrives mirrored from the hardware and has to +// be corrected by the shader compiler (FragCoordYFlipTraverser). +// +// Both tests render a full-screen quad into a render target and read the result +// back. Helpers::ReadPixels returns rows in memory order, so row 0 is the top of +// the image on every backend. +namespace +{ + // Renders a full-screen quad into a width x height render target using the + // supplied fragment shader, and returns the RGBA8 pixels in memory order. + // + // The fragment shader may declare a `uniform vec2 targetSize` (set to the + // render target dimensions) and a `uniform sampler2D inputSampler` (bound to + // a raw texture whose row y is filled with the RGBA value produced by + // makeRow(y), when withInputTexture is true). + std::vector RenderFullScreenQuad( + uint32_t width, + uint32_t height, + const std::string& vertexShader, + const std::string& fragmentShader, + bool withInputTexture) + { + // Clip-space quad, so no projection matrix is involved and the geometry + // lines up with the render target exactly. uv follows the GL convention + // of (0,0) at the bottom-left corner. + Babylon::Graphics::Device device{g_deviceConfig}; + device.StartRenderingCurrentFrame(); + + auto outputTexture = Helpers::CreateTexture( + device.GetPlatformInfo().Device, width, height, 1, true); + Babylon::Plugins::ExternalTexture outputExternalTexture{outputTexture}; + + Babylon::AppRuntime::Options options{}; + options.UnhandledExceptionHandler = [](const Napi::Error& error) { + std::cerr << "[Uncaught Error] " << Napi::GetErrorString(error) << std::endl; + std::cerr.flush(); + }; + + Babylon::AppRuntime runtime{options}; + runtime.Dispatch([&device](Napi::Env env) { + env.Global().Set("globalThis", env.Global()); + device.AddToJavaScript(env); + + Babylon::Polyfills::Console::Initialize(env, [](const char* message, auto) { + std::cout << message << std::endl; + }); + Babylon::Polyfills::Window::Initialize(env); + Babylon::Plugins::NativeEngine::Initialize(env); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.LoadScript("app:///Assets/babylon.max.js"); + + const std::string script = R"( + (function () { + var vertexShader = VERTEX_SHADER_SOURCE; + + var fragmentShader = FRAGMENT_SHADER_SOURCE; + + globalThis.startup = function (outputNativeTexture, width, height) { + var engine = new BABYLON.NativeEngine(); + engine.getCaps().parallelShaderCompile = null; + var scene = new BABYLON.Scene(engine); + scene.autoClear = true; + scene.clearColor = new BABYLON.Color4(0, 0, 0, 1); + + var outputTexture = new BABYLON.RenderTargetTexture( + "output", + { width: width, height: height }, + scene, + { + colorAttachment: engine.wrapNativeTexture(outputNativeTexture), + generateDepthBuffer: true, + generateStencilBuffer: false + }); + + var camera = new BABYLON.FreeCamera("camera", new BABYLON.Vector3(0, 0, -1), scene); + camera.setTarget(BABYLON.Vector3.Zero()); + camera.mode = BABYLON.Camera.ORTHOGRAPHIC_CAMERA; + camera.orthoTop = 1; + camera.orthoBottom = -1; + camera.orthoLeft = -1; + camera.orthoRight = 1; + camera.outputRenderTarget = outputTexture; + + // Two triangles in clip space covering the whole target. The + // vertex shader passes position straight through, so no + // projection matrix is involved and the quad lines up exactly + // with the render target regardless of camera conventions. + var quad = new BABYLON.Mesh("quad", scene); + var vertexData = new BABYLON.VertexData(); + vertexData.positions = [ + -1, -1, 0, + 1, -1, 0, + 1, 1, 0, + -1, 1, 0 + ]; + vertexData.uvs = [ + 0, 0, + 1, 0, + 1, 1, + 0, 1 + ]; + vertexData.indices = [0, 1, 2, 0, 2, 3]; + vertexData.applyToMesh(quad); + quad.alwaysSelectAsActiveMesh = true; + + var material = new BABYLON.ShaderMaterial( + "fragCoordShader", + scene, + { vertexSource: vertexShader, fragmentSource: fragmentShader }, + { + attributes: vertexShader.indexOf("attribute vec2 uv") !== -1 + ? ["position", "uv"] + : ["position"], + uniforms: ["targetSize"], + samplers: WITH_INPUT_TEXTURE ? ["inputSampler"] : [] + }); + material.onError = function (_effect, errors) { + console.error("ShaderMaterial compilation error: " + errors); + }; + material.backFaceCulling = false; + material.depthFunction = BABYLON.Constants.ALWAYS; + material.setVector2("targetSize", new BABYLON.Vector2(width, height)); + + if (WITH_INPUT_TEXTURE) { + // Row y is filled with a monotonically decreasing red ramp so + // that a vertical mirror is unambiguous. Blue encodes the low + // bits of the row index to catch off-by-one errors. + var data = new Uint8Array(width * height * 4); + for (var y = 0; y < height; ++y) { + for (var x = 0; x < width; ++x) { + var i = (y * width + x) * 4; + data[i] = 255 - y * 4; + data[i + 1] = 0; + data[i + 2] = y * 4; + data[i + 3] = 255; + } + } + var raw = engine.createRawTexture( + data, + width, + height, + BABYLON.Constants.TEXTUREFORMAT_RGBA, + false /* generateMipMaps */, + false /* invertY */, + BABYLON.Constants.TEXTURE_NEAREST_SAMPLINGMODE); + var wrapper = new BABYLON.Texture(null, scene); + wrapper._texture = raw; + wrapper.wrapU = BABYLON.Constants.TEXTURE_CLAMP_ADDRESSMODE; + wrapper.wrapV = BABYLON.Constants.TEXTURE_CLAMP_ADDRESSMODE; + material.setTexture("inputSampler", wrapper); + } + + quad.material = material; + globalThis.__scene = scene; + }; + + globalThis.render = function () { + var scene = globalThis.__scene; + return scene.whenReadyAsync().then(function () { + scene.render(); + }); + }; + })(); + )"; + + // Inject the caller's shaders as JS string literals. + const auto toJsStringLiteral = [](const std::string& source) { + std::string result = "\""; + for (char c : source) + { + if (c == '\n') + { + result += "\\n"; + } + else if (c == '"') + { + result += "\\\""; + } + else if (c == '\\') + { + result += "\\\\"; + } + else + { + result += c; + } + } + result += "\""; + return result; + }; + + const auto replaceToken = [](std::string& text, const std::string& token, const std::string& value) { + for (size_t pos = text.find(token); pos != std::string::npos; pos = text.find(token, pos)) + { + text.replace(pos, token.size(), value); + pos += value.size(); + } + }; + + std::string finalScript = script; + replaceToken(finalScript, "VERTEX_SHADER_SOURCE", toJsStringLiteral(vertexShader)); + replaceToken(finalScript, "FRAGMENT_SHADER_SOURCE", toJsStringLiteral(fragmentShader)); + replaceToken(finalScript, "WITH_INPUT_TEXTURE", withInputTexture ? "true" : "false"); + + loader.Eval(finalScript, "frag_coord_orientation_test.js"); + + std::promise startupDone; + loader.Dispatch([&outputExternalTexture, &startupDone, width, height](Napi::Env env) { + auto jsOutput = outputExternalTexture.CreateForJavaScript(env); + env.Global().Get("startup").As().Call({ + jsOutput, + Napi::Number::New(env, width), + Napi::Number::New(env, height), + }); + startupDone.set_value(); + }); + startupDone.get_future().wait(); + + device.FinishRenderingCurrentFrame(); + device.StartRenderingCurrentFrame(); + + std::promise renderDone; + loader.Dispatch([&renderDone](Napi::Env env) { + auto jsPromise = env.Global().Get("render").As().Call({}).As(); + + auto jsOnFulfilled = Napi::Function::New(env, [&renderDone](const Napi::CallbackInfo&) { + renderDone.set_value(); + }); + auto jsOnRejected = Napi::Function::New(env, [&renderDone](const Napi::CallbackInfo& info) { + renderDone.set_exception(std::make_exception_ptr( + std::runtime_error{Napi::GetErrorString(info[0].As())})); + }); + + jsPromise.Get("then").As().Call(jsPromise, {jsOnFulfilled, jsOnRejected}); + }); + + auto renderFuture = renderDone.get_future(); + EXPECT_EQ(renderFuture.wait_for(std::chrono::seconds(30)), std::future_status::ready) + << "render timed out"; + EXPECT_NO_THROW(renderFuture.get()) << "render rejected"; + + device.FinishRenderingCurrentFrame(); + + auto pixels = Helpers::ReadPixels(device.GetPlatformInfo(), outputTexture, width, height); + Helpers::DestroyTexture(outputTexture); + return pixels; + } +} + +// gl_FragCoord.y must follow the GL convention of increasing towards +Y in clip +// space. The quad maps uv.y = 0 to clip y = -1 and uv.y = 1 to clip y = +1, so +// the interpolated vUV.y is a ground-truth ramp running in that same direction +// and normalized gl_FragCoord.y has to agree with it everywhere. Without the +// correction gl_FragCoord.y runs the other way on D3D/Metal/Vulkan and the two +// ramps become mirror images. +// +// The comparison is made between two channels of a single render rather than +// against absolute row indices on purpose: Helpers::ReadPixels is a plain +// glReadPixels on OpenGL, which returns the bottom scanline first, while the +// D3D11 path returns the top scanline first. An absolute check would therefore +// encode the readback convention of one backend rather than the shading +// language rule under test. +TEST(ShaderCompilation, FragCoordYMatchesInterpolatedUV) +{ +#if defined(SKIP_EXTERNAL_TEXTURE_TESTS) || defined(SKIP_RENDER_TESTS) + GTEST_SKIP(); +#else + constexpr uint32_t WIDTH = 8; + constexpr uint32_t HEIGHT = 64; + + const std::string vertexShader = + "precision highp float;\n" + "attribute vec3 position;\n" + "attribute vec2 uv;\n" + "varying vec2 vUV;\n" + "void main(void) { vUV = uv; gl_Position = vec4(position, 1.0); }\n"; + + const std::string fragmentShader = + "precision highp float;\n" + "uniform vec2 targetSize;\n" + "varying vec2 vUV;\n" + "void main(void) {\n" + " gl_FragColor = vec4(gl_FragCoord.y / targetSize.y, vUV.y, 0.0, 1.0);\n" + "}\n"; + + auto pixels = RenderFullScreenQuad(WIDTH, HEIGHT, vertexShader, fragmentShader, false); + ASSERT_EQ(pixels.size(), static_cast(WIDTH) * HEIGHT * 4); + + const auto texel = [&pixels](uint32_t row) { + const size_t offset = static_cast(row) * WIDTH * 4; + return std::make_pair(static_cast(pixels[offset]), static_cast(pixels[offset + 1])); + }; + + const auto first = texel(0); + const auto middle = texel(HEIGHT / 2); + const auto last = texel(HEIGHT - 1); + std::cout << "row 0 fragCoord=" << first.first << " uv=" << first.second + << ", row " << (HEIGHT / 2) << " fragCoord=" << middle.first << " uv=" << middle.second + << ", row " << (HEIGHT - 1) << " fragCoord=" << last.first << " uv=" << last.second + << std::endl; + + // Guard against the whole comparison passing vacuously: the reference ramp + // has to actually sweep the range rather than sitting at a constant. + ASSERT_GT(std::abs(first.second - last.second), 200) + << "vUV.y reference ramp did not vary across the target"; + + // Both channels are produced by the same fragment invocation, so they must + // agree row by row no matter which end of the image the readback starts at. + // The tolerance absorbs interpolation and 8-bit quantization only; a flipped + // gl_FragCoord.y misses by the full range of the ramp. + for (uint32_t row = 0; row < HEIGHT; ++row) + { + const auto values = texel(row); + ASSERT_LE(std::abs(values.first - values.second), 6) + << "gl_FragCoord.y disagrees with the interpolated vUV.y at row " << row + << " (gl_FragCoord=" << values.first << ", vUV=" << values.second << ")"; + } +#endif +} + +// Indexing a screen-sized texture with gl_FragCoord must give the same image as +// indexing it with the interpolated UVs of a full-screen quad. This is the +// pattern used by order-independent transparency, TAA and screen space +// curvature, and it only holds if the gl_FragCoord correction and +// FlipSamplerCoordinatesTraverser compose to a no-op. +// +// Comparing the two addressing modes against each other rather than against the +// source pixels keeps the test independent of how createRawTexture lays its data +// out in memory. +TEST(ShaderCompilation, FragCoordAndUVAddressATextureIdentically) +{ +#if defined(SKIP_EXTERNAL_TEXTURE_TESTS) || defined(SKIP_RENDER_TESTS) + GTEST_SKIP(); +#else + constexpr uint32_t WIDTH = 8; + constexpr uint32_t HEIGHT = 64; + + const std::string vertexShader = + "precision highp float;\n" + "attribute vec3 position;\n" + "attribute vec2 uv;\n" + "varying vec2 vUV;\n" + "void main(void) {\n" + " vUV = uv;\n" + " gl_Position = vec4(position, 1.0);\n" + "}\n"; + + const std::string uvShader = + "precision highp float;\n" + "varying vec2 vUV;\n" + "uniform vec2 targetSize;\n" + "uniform sampler2D inputSampler;\n" + "void main(void) {\n" + " gl_FragColor = texture2D(inputSampler, vUV);\n" + "}\n"; + + const std::string fragCoordShader = + "precision highp float;\n" + "varying vec2 vUV;\n" + "uniform vec2 targetSize;\n" + "uniform sampler2D inputSampler;\n" + "void main(void) {\n" + " gl_FragColor = texture2D(inputSampler, gl_FragCoord.xy / targetSize);\n" + "}\n"; + + auto uvPixels = RenderFullScreenQuad(WIDTH, HEIGHT, vertexShader, uvShader, true); + auto fragCoordPixels = RenderFullScreenQuad(WIDTH, HEIGHT, vertexShader, fragCoordShader, true); + + ASSERT_EQ(uvPixels.size(), static_cast(WIDTH) * HEIGHT * 4); + ASSERT_EQ(fragCoordPixels.size(), uvPixels.size()); + + const auto red = [](const std::vector& pixels, uint32_t row) { + return static_cast(pixels[static_cast(row) * WIDTH * 4]); + }; + + // Guard against a vacuous pass: the source must actually vary down the image, + // otherwise a vertical mirror would be undetectable. + ASSERT_GT(std::abs(red(uvPixels, 0) - red(uvPixels, HEIGHT - 1)), 200) + << "the source texture must vary from top to bottom for this test to mean anything"; + + std::cout << "uv rows: " << red(uvPixels, 0) << " .. " << red(uvPixels, HEIGHT - 1) << std::endl; + std::cout << "fragCoord rows: " << red(fragCoordPixels, 0) << " .. " << red(fragCoordPixels, HEIGHT - 1) << std::endl; + + for (uint32_t row = 0; row < HEIGHT; ++row) + { + ASSERT_EQ(red(fragCoordPixels, row), red(uvPixels, row)) + << "row " << row << " differs between gl_FragCoord and uv addressing"; + } +#endif +} diff --git a/Apps/package-lock.json b/Apps/package-lock.json index cdea158ce..f8c58d238 100644 --- a/Apps/package-lock.json +++ b/Apps/package-lock.json @@ -7,18 +7,19 @@ "": { "name": "BabylonNative", "version": "0.0.1", + "hasInstallScript": true, "workspaces": [ "UnitTests/JavaScript" ], "dependencies": { - "babylonjs": "^9.15.0", - "babylonjs-addons": "^9.15.0", - "babylonjs-gltf2interface": "^9.15.0", - "babylonjs-gui": "^9.15.0", - "babylonjs-loaders": "^9.15.0", - "babylonjs-materials": "^9.15.0", - "babylonjs-procedural-textures": "9.15.0", - "babylonjs-serializers": "^9.15.0", + "babylonjs": "^9.21.2", + "babylonjs-addons": "^9.21.2", + "babylonjs-gltf2interface": "^9.21.2", + "babylonjs-gui": "^9.21.2", + "babylonjs-loaders": "^9.21.2", + "babylonjs-materials": "^9.21.2", + "babylonjs-procedural-textures": "9.21.2", + "babylonjs-serializers": "^9.21.2", "earcut": "^2.2.4", "jsc-android": "^241213.1.0", "v8-android": "^7.8.2" @@ -2601,72 +2602,72 @@ } }, "node_modules/babylonjs": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/babylonjs/-/babylonjs-9.15.0.tgz", - "integrity": "sha512-IJQhrxsxxj4KCg4aSsB5chLidzAfbghr8rnzo6sRLFin6ipfeayCxg7MevjaMzqdVmc/BOMU6sj49nSNrBq+yQ==", + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/babylonjs/-/babylonjs-9.21.2.tgz", + "integrity": "sha512-49B14bmmZdtAwscLMoxBoajqo14cnnmYVRhvDqGTxEDxbze5ChnJTKgmGJfbaG+AyzTa0i3RNAc1qOSdp6c3+Q==", "hasInstallScript": true, "license": "Apache-2.0" }, "node_modules/babylonjs-addons": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/babylonjs-addons/-/babylonjs-addons-9.15.0.tgz", - "integrity": "sha512-7vxm0ffFXWHCZWDlYYleifb6mg1iSH7nNo+sA9706z7QIfpNnLS9+9cTYadWobvhURwaD/DHAKnm7Z8ckq9YAA==", + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/babylonjs-addons/-/babylonjs-addons-9.21.2.tgz", + "integrity": "sha512-K+vBdG0p4qKtfzlTeQviiBofB3SKzKMC0bihOeoOdQV1ODFUSlgDn0R8gk8aQdEGadXplCDGTn+ZfeDvjI0orw==", "license": "Apache-2.0", "dependencies": { - "babylonjs": "9.15.0" + "babylonjs": "9.21.2" } }, "node_modules/babylonjs-gltf2interface": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/babylonjs-gltf2interface/-/babylonjs-gltf2interface-9.15.0.tgz", - "integrity": "sha512-Yr/WvOvnsZFN2LoyNU/ZZbT+lXMdNA9OiZzE4RibI1tnYxQjpPKT6X1cxQ/ACJPVzKwudxzJxQ/f/Tdba4TLDg==", + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/babylonjs-gltf2interface/-/babylonjs-gltf2interface-9.21.2.tgz", + "integrity": "sha512-L4tPMoIgDz/lWQFYKkQMG0oIq5S24Z6Sc3aBX6nvczDLoFDs13auaC2ObOL2Tc24IiUVESjCnDa1DzrJq/m0NQ==", "license": "Apache-2.0" }, "node_modules/babylonjs-gui": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/babylonjs-gui/-/babylonjs-gui-9.15.0.tgz", - "integrity": "sha512-klF2ywhA040JMQ3Z5XaGkN4S0GIOshtnvtUd8kbYqlLYEu5i6/y/zvB8V3O4geslzV0kWcBe2mIthlr7M7p4og==", + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/babylonjs-gui/-/babylonjs-gui-9.21.2.tgz", + "integrity": "sha512-X9JJLcMyE2DkfoQAzMp/df/pvhpGjme5l/luKWUb9XdMRY0om6WoV/NetdHnZzFOI+zj3Q0xPKmE0hFVTiHmsg==", "license": "Apache-2.0", "dependencies": { - "babylonjs": "9.15.0" + "babylonjs": "9.21.2" } }, "node_modules/babylonjs-loaders": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/babylonjs-loaders/-/babylonjs-loaders-9.15.0.tgz", - "integrity": "sha512-k5Kg9wmuy0n4ZAWu9woFk3C1yEwSvQHRv0Ct2GOQtuRvIHUIyRcW4KEKOsW0GOiApSjXh9nQcabdJTnUo8IBNw==", + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/babylonjs-loaders/-/babylonjs-loaders-9.21.2.tgz", + "integrity": "sha512-0DymqrJrxg4HKsLB+0SJt6g0YNbPpt+BziDQKSP87dMg8ZPcHqroqXyT/pZsYc/dEUnOFeIT3hSrS21T2XHRWg==", "license": "Apache-2.0", "dependencies": { - "babylonjs": "9.15.0", - "babylonjs-gltf2interface": "9.15.0" + "babylonjs": "9.21.2", + "babylonjs-gltf2interface": "9.21.2" } }, "node_modules/babylonjs-materials": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/babylonjs-materials/-/babylonjs-materials-9.15.0.tgz", - "integrity": "sha512-E9C5EB0nZJrGvT7Mb38gypAnzi1q8vr/Gi+fTeqRQilIU8mdk+YzD5unfKOROEv8arRFCXGWrtBqkIN8k2PFxA==", + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/babylonjs-materials/-/babylonjs-materials-9.21.2.tgz", + "integrity": "sha512-+n2mxzvLyLF+cCsRVizbeWoG0ogILhet83UbXUfeyMEuD2qH0svOJF8sr9G0i0jFx67ZGS3J3m/3o3FrhtrjiQ==", "license": "Apache-2.0", "dependencies": { - "babylonjs": "9.15.0" + "babylonjs": "9.21.2" } }, "node_modules/babylonjs-procedural-textures": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/babylonjs-procedural-textures/-/babylonjs-procedural-textures-9.15.0.tgz", - "integrity": "sha512-sWMWq/TUDhleL6Bwu8kwV3+5kMwZQ6X4W4fNTgvzUaUARWn7uB0+3OEE5K2GoLFQ4UvUMv2ZxItv/2Xn06Mxww==", + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/babylonjs-procedural-textures/-/babylonjs-procedural-textures-9.21.2.tgz", + "integrity": "sha512-UW5fgZYp8GEVhQgHNpl4CBJRXnyYUks5dPnHbFgA4bW4qbAlkbTY6Fa27folmHjuHjkJl5Nz1NaYTGCLxV4FAg==", "license": "Apache-2.0", "dependencies": { - "babylonjs": "9.15.0" + "babylonjs": "9.21.2" } }, "node_modules/babylonjs-serializers": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/babylonjs-serializers/-/babylonjs-serializers-9.15.0.tgz", - "integrity": "sha512-iLYwPzZrUr2SnwHcsXjHJHYdJIVC+boW6z/olE0mE5GvqmfKXTtqFr9wRGrsq85BarhHQkfVlfQdFwhFq/JG4w==", + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/babylonjs-serializers/-/babylonjs-serializers-9.21.2.tgz", + "integrity": "sha512-WM8HihRGat2LdqVGL9tZzXNZkcyFkj6RgR2VcVsMJijp0Z5/axH/wHl8Gc84W9MLPES9OrcLd4CRGxYlRxtHrg==", "license": "Apache-2.0", "dependencies": { - "babylonjs": "9.15.0", - "babylonjs-gltf2interface": "9.15.0" + "babylonjs": "9.21.2", + "babylonjs-gltf2interface": "9.21.2" } }, "node_modules/balanced-match": { diff --git a/Apps/package.json b/Apps/package.json index c5a400f94..012641824 100644 --- a/Apps/package.json +++ b/Apps/package.json @@ -7,20 +7,21 @@ ], "scripts": { "getNightly": "node scripts/getNightly.js", - "downlevel:native-scripts": "node scripts/downlevelNativeScripts.mjs" + "downlevel:native-scripts": "node scripts/downlevelNativeScripts.mjs", + "postinstall": "node scripts/downlevelNativeScripts.mjs node_modules" }, "devDependencies": { "typescript": "^5.9.3" }, "dependencies": { - "babylonjs": "^9.15.0", - "babylonjs-addons": "^9.15.0", - "babylonjs-gltf2interface": "^9.15.0", - "babylonjs-gui": "^9.15.0", - "babylonjs-loaders": "^9.15.0", - "babylonjs-materials": "^9.15.0", - "babylonjs-procedural-textures": "9.15.0", - "babylonjs-serializers": "^9.15.0", + "babylonjs": "^9.21.2", + "babylonjs-addons": "^9.21.2", + "babylonjs-gltf2interface": "^9.21.2", + "babylonjs-gui": "^9.21.2", + "babylonjs-loaders": "^9.21.2", + "babylonjs-materials": "^9.21.2", + "babylonjs-procedural-textures": "9.21.2", + "babylonjs-serializers": "^9.21.2", "earcut": "^2.2.4", "jsc-android": "^241213.1.0", "v8-android": "^7.8.2" diff --git a/Core/Graphics/InternalInclude/Babylon/Graphics/BgfxShaderInfo.h b/Core/Graphics/InternalInclude/Babylon/Graphics/BgfxShaderInfo.h index 5e73c86d5..ac9f4f74e 100644 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/BgfxShaderInfo.h +++ b/Core/Graphics/InternalInclude/Babylon/Graphics/BgfxShaderInfo.h @@ -33,6 +33,17 @@ namespace Babylon::Graphics inline constexpr uint32_t BUILTIN_INSTANCE_DATA_SLOT_COUNT{5}; inline constexpr uint32_t BUILTIN_INSTANCE_DATA_LAST_LOCATION{INSTANCE_DATA_FIRST_LOCATION - (BUILTIN_INSTANCE_DATA_SLOT_COUNT - 1)}; + /// Name of the uniform the shader compiler declares in any fragment shader that reads + /// gl_FragCoord, so FragCoordYFlipTraverser can convert the hardware's top-left-origin value + /// into the bottom-left-origin one Babylon.js shaders are written against. Its .x/.y hold the + /// width/height of the bound framebuffer, which NativeEngine writes before each draw. + /// + /// This cannot be bgfx's predefined u_viewRect: that is the view rect, which + /// FrameBuffer::SetBgfxViewPortAndScissor narrows to the viewport whenever one is set, whereas + /// gl_FragCoord is relative to the whole render target. The name is deliberately outside the + /// u_ namespace Babylon.js uses for its own uniforms so it cannot collide with a shader uniform. + inline constexpr const char* FRAGCOORD_TARGET_SIZE_UNIFORM_NAME{"bnFragCoordTargetSize"}; + struct BgfxShaderInfo { std::vector VertexBytes{}; diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index d572b9b55..aa127c764 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -3005,6 +3005,21 @@ namespace Babylon encoder->setUniform({it.first}, value.Data.data(), value.ElementLength); } + // Resolve the gl_FragCoord Y flip the shader compiler injected (see + // ShaderCompilerTraversers::FlipFragCoordY). The height must be the bound framebuffer's, + // not the bgfx view rect's: FrameBuffer::SetBgfxViewPortAndScissor narrows the view rect to + // the viewport whenever one is set, while gl_FragCoord is relative to the whole target. + if (const UniformInfo* fragCoordTargetSize = m_currentProgram->FragCoordTargetSizeUniform()) + { + const Graphics::FrameBuffer& frameBuffer = GetBoundFrameBuffer(); + const float targetSize[4]{ + static_cast(frameBuffer.Width()), + static_cast(frameBuffer.Height()), + 0.0f, + 0.0f}; + encoder->setUniform(fragCoordTargetSize->Handle, targetSize, 1); + } + // Divisor-driven instancing: a consumer-instanced attribute (divisor==1) recorded at a // real per-vertex bgfx location was compiled to a per-vertex slot. bgfx can only feed // per-instance data into i_data slots (the top TEXCOORD semantics), so route those attributes diff --git a/Plugins/NativeEngine/Source/Program.cpp b/Plugins/NativeEngine/Source/Program.cpp index 3f930993d..8bd9f215b 100644 --- a/Plugins/NativeEngine/Source/Program.cpp +++ b/Plugins/NativeEngine/Source/Program.cpp @@ -66,6 +66,9 @@ namespace Babylon m_handle = bgfx::createProgram(vertexShader, fragmentShader, true); m_vertexAttributeLocations = shaderInfo->VertexAttributeLocations; + // Cached rather than looked up per draw: DrawInternal consults this on every single draw, + // and m_uniformInfos is stable for the lifetime of the program. + m_fragCoordTargetSizeUniform = GetUniformInfo(Graphics::FRAGCOORD_TARGET_SIZE_UNIFORM_NAME); } void Program::SetSources(std::string vertexSource, std::string fragmentSource) diff --git a/Plugins/NativeEngine/Source/Program.h b/Plugins/NativeEngine/Source/Program.h index d7e694a0d..c00059b48 100644 --- a/Plugins/NativeEngine/Source/Program.h +++ b/Plugins/NativeEngine/Source/Program.h @@ -69,6 +69,11 @@ namespace Babylon bgfx::ProgramHandle Handle() const { return m_handle; } const std::map& Uniforms() const { return m_uniforms; } const std::map& VertexAttributeLocations() const { return m_vertexAttributeLocations; } + // The uniform the shader compiler declares in fragment shaders that read gl_FragCoord, so + // the Y flip can be resolved against the bound framebuffer's size. Null for the shaders + // that never read gl_FragCoord (the compiler omits it there). Resolved once at + // initialization because it is consulted on every draw. + const UniformInfo* FragCoordTargetSizeUniform() const { return m_fragCoordTargetSizeUniform; } private: Graphics::DeviceContext& m_deviceContext; @@ -78,6 +83,7 @@ namespace Babylon std::map m_uniformNameToIndex; std::map m_uniformInfos; std::map m_vertexAttributeLocations; + const UniformInfo* m_fragCoordTargetSizeUniform{nullptr}; std::string m_vertexSource; std::string m_fragmentSource; std::map, bgfx::ProgramHandle> m_instancedVariants; diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerDXBC.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerDXBC.cpp index 1459e4a2d..d4e1f2591 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerDXBC.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerDXBC.cpp @@ -104,6 +104,9 @@ namespace Babylon::Plugins ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); + // Present gl_FragCoord in OpenGL's bottom-left-origin space. Must precede the uniform + // struct move so the target-size uniform it declares is collected with the others. + ShaderCompilerTraversers::FlipFragCoordY(program, ids); auto cutScope = ShaderCompilerTraversers::ChangeUniformTypes(program, ids); auto utstScope = ShaderCompilerTraversers::MoveNonSamplerUniformsIntoStruct(program, ids); std::map vertexAttributeRenaming = {}; diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerDXIL.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerDXIL.cpp index e18075ce0..26e2ac8f3 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerDXIL.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerDXIL.cpp @@ -180,6 +180,9 @@ namespace Babylon::Plugins ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); + // Present gl_FragCoord in OpenGL's bottom-left-origin space. Must precede the uniform + // struct move so the target-size uniform it declares is collected with the others. + ShaderCompilerTraversers::FlipFragCoordY(program, ids); auto cutScope = ShaderCompilerTraversers::ChangeUniformTypes(program, ids); auto utstScope = ShaderCompilerTraversers::MoveNonSamplerUniformsIntoStruct(program, ids); std::map vertexAttributeRenaming = {}; diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerMetal.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerMetal.cpp index 3f6aaf748..5d1fe8811 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerMetal.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerMetal.cpp @@ -105,6 +105,9 @@ namespace Babylon::Plugins ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); + // Present gl_FragCoord in OpenGL's bottom-left-origin space. Must precede the uniform + // struct move so the target-size uniform it declares is collected with the others. + ShaderCompilerTraversers::FlipFragCoordY(program, ids); auto cutScope = ShaderCompilerTraversers::ChangeUniformTypes(program, ids); auto utstScope = ShaderCompilerTraversers::MoveNonSamplerUniformsIntoStruct(program, ids); std::map vertexAttributeRenaming = {}; diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp index e1d2cfe67..61880f2fd 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp @@ -406,6 +406,36 @@ namespace Babylon::ShaderCompilerTraversers } }; + // Restores both the shape and the basic type that the consuming code expects. + // addShapeConversion only reconciles vector size: given a vec4 source and an int + // target it produces an EOpConstructInt aggregate whose operand is still a float, + // so the AST claims "int" while holding a float. glslang then emits SPIR-V in which + // the integer operation reads a float, and SPIRV-Cross renders it verbatim as + // `int i = -samples.x;`, which ESSL rejects. Shaping to float first and then asking + // glslang for a real conversion node yields the expected `int(...)`. + auto restoreOldType = [this](TIntermTyped* node, const TType& oldType) -> TIntermTyped* { + TPublicType shapeType{}; + shapeType.qualifier = oldType.getQualifier(); + shapeType.basicType = EbtFloat; + shapeType.setVector(oldType.getVectorSize()); + shapeType.arraySizes = nullptr; + + TType floatShape{shapeType}; + auto* converted = m_intermediate->addShapeConversion(floatShape, node); + + if (oldType.getBasicType() != EbtFloat) + { + auto* retyped = m_intermediate->addConversion(oldType.getBasicType(), converted); + if (retyped == nullptr) + { + throw std::runtime_error{"Cannot replace symbol: unsupported uniform basic type conversion"}; + } + converted = retyped; + } + + return converted; + }; + // Because we modified the original symbol, we don't need to do anything to linker objects. // The only further work we need to do is to handle reshaping. if (!IsLinkerObject(this->path)) @@ -438,7 +468,7 @@ namespace Babylon::ShaderCompilerTraversers auto* binType = newType.clone(); binType->clearArraySizes(); binary->setType(*binType); - auto shapeConversion = m_intermediate->addShapeConversion(*oldType, binary); + auto shapeConversion = restoreOldType(binary, *oldType); assert(this->path.size() > 1); auto* grandparent = this->path[this->path.size() - 2]; @@ -451,7 +481,7 @@ namespace Babylon::ShaderCompilerTraversers } else { - auto shapeConversion = m_intermediate->addShapeConversion(*oldType, symbol); + auto shapeConversion = restoreOldType(symbol, *oldType); injectShapeConversion(symbol, parent, shapeConversion); } } @@ -1966,6 +1996,151 @@ namespace Babylon::ShaderCompilerTraversers TIntermediate* m_intermediate{}; }; + + /// Presents gl_FragCoord to the shader in OpenGL's coordinate space on the backends that + /// render with a top-left origin (D3D, Metal, Vulkan). + /// + /// Babylon Native already normalizes the rest of that convention: FlipSamplerCoordinates + /// rewrites every texture()/texelFetch coordinate and InvertYDerivativeOperands negates + /// dFdy, so shader-visible coordinates are GL-space and the conversion to the physical + /// layout happens at each access. gl_FragCoord was the one input left in physical space, + /// which is why `texelFetch(tex, ivec2(gl_FragCoord.xy), 0)` read the vertically mirrored + /// row: the fetch coordinate was flipped by FlipSamplerCoordinates but the value feeding it + /// was not. Anything order-dependent on the row index -- a prefix sum, a neighbour offset, + /// a copy into a differently-oriented target -- came out inverted for the same reason. + /// + /// The flip is `targetHeight - gl_FragCoord.y`, with no -1 term: for physical row p the + /// hardware yields p + 0.5, and p == height - 1 - y for GL row y, so the incoming value is + /// height - y - 0.5 and the GL value y + 0.5 is exactly height minus that. + /// + /// The height cannot come from bgfx's predefined u_viewRect: that is the view *rect*, which + /// FrameBuffer::SetBgfxViewPortAndScissor narrows to the viewport whenever one is set, while + /// gl_FragCoord is relative to the whole render target. It is instead read from a uniform + /// that NativeEngine fills with the bound framebuffer's dimensions. + /// + /// The uniform is only declared in shaders that actually read gl_FragCoord, so shaders that + /// do not are left byte-for-byte unchanged. + class FragCoordYFlipTraverser final : private TIntermTraverser + { + public: + static void Traverse(TProgram& program, IdGenerator& ids) + { + auto* intermediate{program.getIntermediate(EShLangFragment)}; + if (intermediate == nullptr) + { + return; + } + + FragCoordYFlipTraverser traverser{intermediate}; + intermediate->getTreeRoot()->traverse(&traverser); + + if (traverser.m_symbolsToParents.empty()) + { + return; + } + + // Declared as a linker object before MoveNonSamplerUniformsIntoStruct runs, so the + // uniform is swept into the "Frame" struct with every other non-sampler uniform and + // is emitted under its own name in the bgfx uniform table like the rest. + TType targetSizeType{EbtFloat, EvqUniform, 4}; + TIntermSymbol* targetSize{intermediate->addSymbol(TIntermSymbol{ids.Next(), Graphics::FRAGCOORD_TARGET_SIZE_UNIFORM_NAME, targetSizeType})}; + + auto* linkerObjects = FindLinkerObjects(intermediate->getTreeRoot()->getAsAggregate()); + if (linkerObjects == nullptr) + { + throw std::runtime_error{"FragCoordYFlip: fragment stage has no linker objects sequence."}; + } + linkerObjects->getSequence().push_back(targetSize); + + traverser.ApplyReplacements(targetSize); + } + + protected: + void visitSymbol(TIntermSymbol* symbol) override + { + // Linker object references declare gl_FragCoord rather than read it, so rewriting + // them would replace the declaration itself with an expression. + if (symbol->getName() != "gl_FragCoord" || IsLinkerObject(path)) + { + return; + } + + m_symbolsToParents.emplace_back(symbol, getParentNode()); + } + + private: + FragCoordYFlipTraverser(TIntermediate* intermediate) + : TIntermTraverser{true, false, false} + , m_intermediate{intermediate} + { + } + + static TIntermAggregate* FindLinkerObjects(TIntermAggregate* root) + { + if (root == nullptr) + { + return nullptr; + } + + for (auto* node : root->getSequence()) + { + auto* aggregate = node != nullptr ? node->getAsAggregate() : nullptr; + if (aggregate != nullptr && aggregate->getOp() == EOpLinkerObjects) + { + return aggregate; + } + } + + return nullptr; + } + + void ApplyReplacements(TIntermSymbol* targetSize) + { + for (const auto& [symbol, parent] : m_symbolsToParents) + { + // MakeReplacements is deliberately not reused here: it maps one replacement node + // per symbol *name*, so every gl_FragCoord reference in the shader would share a + // single subtree and that node would end up with as many parents as there are + // references. A fresh subtree is built for each occurrence instead. + MakeReplacements({{"gl_FragCoord", BuildFlippedFragCoord(symbol, targetSize)}}, {{symbol, parent}}); + } + } + + /// Builds `vec4(gl_FragCoord.x, u_targetSize.y - gl_FragCoord.y, gl_FragCoord.z, gl_FragCoord.w)`. + /// + /// The whole vector is reconstructed rather than just patching .y because a reference may + /// be swizzled (.xy), indexed, or passed along whole, and the parent node is not + /// inspected here; rebuilding a vec4 keeps every one of those forms valid. + TIntermTyped* BuildFlippedFragCoord(TIntermSymbol* fragCoord, TIntermSymbol* targetSize) + { + const TSourceLoc& loc{fragCoord->getLoc()}; + TType floatType{EbtFloat, EvqTemporary, 1}; + TType vec4Type{EbtFloat, EvqTemporary, 4}; + + // Each component reads through its own copy of the symbol so that no node in the + // finished tree has more than one parent. + auto component = [&](int index) { + TIntermTyped* copy{m_intermediate->addSymbol(*fragCoord)}; + TIntermTyped* element{m_intermediate->addIndex(EOpIndexDirect, copy, m_intermediate->addConstantUnion(index, loc), loc)}; + element->setType(floatType); + return element; + }; + + TIntermTyped* height{m_intermediate->addIndex(EOpIndexDirect, m_intermediate->addSymbol(*targetSize), m_intermediate->addConstantUnion(1, loc), loc)}; + height->setType(floatType); + + TIntermTyped* flippedY{m_intermediate->addBinaryMath(EOpSub, height, component(1), loc)}; + + TIntermAggregate* constructed{m_intermediate->makeAggregate(component(0), loc)}; + constructed = m_intermediate->growAggregate(constructed, flippedY, loc); + constructed = m_intermediate->growAggregate(constructed, component(2), loc); + constructed = m_intermediate->growAggregate(constructed, component(3), loc); + return m_intermediate->setAggregateOperator(constructed, EOpConstructVec4, vec4Type, loc); + } + + TIntermediate* m_intermediate{}; + std::vector> m_symbolsToParents{}; + }; } ScopeT MoveNonSamplerUniformsIntoStruct(TProgram& program, IdGenerator& ids) @@ -2017,4 +2192,9 @@ namespace Babylon::ShaderCompilerTraversers { FlipSamplerCoordinatesTraverser::Traverse(program); } + + void FlipFragCoordY(TProgram& program, IdGenerator& ids) + { + FragCoordYFlipTraverser::Traverse(program, ids); + } } diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.h b/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.h index af0505420..eec540b17 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.h +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.h @@ -149,4 +149,23 @@ namespace Babylon::ShaderCompilerTraversers /// Must only be used on the backends that apply ProcessSamplerFlip (D3D, Metal, Vulkan); the /// OpenGL backend shares bgfx's V-orientation and must not flip. void FlipSamplerCoordinates(glslang::TProgram& program); + + /// Rewrite every read of gl_FragCoord in the fragment shader to + /// `vec4(gl_FragCoord.x, targetHeight - gl_FragCoord.y, gl_FragCoord.z, gl_FragCoord.w)`, + /// presenting it in OpenGL's bottom-left-origin space. + /// + /// The backends that need FlipSamplerCoordinates also rasterize with a top-left origin, so + /// gl_FragCoord arrives mirrored relative to what a WebGL-authored shader expects. Because the + /// sampler coordinate flip is already applied on top of it, `texelFetch(tex, + /// ivec2(gl_FragCoord.xy), 0)` reads the mirrored row, and any use that depends on the row + /// index rather than merely sampling at it (prefix sums, neighbour offsets, copies into a + /// differently-oriented target) comes out inverted. + /// + /// The target height is read from the Graphics::FRAGCOORD_TARGET_SIZE_UNIFORM_NAME uniform, + /// which this traverser declares -- only in shaders that actually read gl_FragCoord -- and + /// NativeEngine fills with the bound framebuffer's dimensions. Must run before + /// MoveNonSamplerUniformsIntoStruct so the new uniform is collected with all the others, and + /// only on the backends that apply FlipSamplerCoordinates (D3D, Metal, Vulkan); the OpenGL + /// backend already matches WebGL's origin and must not flip. + void FlipFragCoordY(glslang::TProgram& program, IdGenerator& ids); } diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp index c5ab91b1f..00153c9bc 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp @@ -80,6 +80,9 @@ namespace Babylon::Plugins ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); + // Present gl_FragCoord in OpenGL's bottom-left-origin space. Must precede the uniform + // struct move so the target-size uniform it declares is collected with the others. + ShaderCompilerTraversers::FlipFragCoordY(program, ids); auto cutScope = ShaderCompilerTraversers::ChangeUniformTypes(program, ids); auto utstScope = ShaderCompilerTraversers::MoveNonSamplerUniformsIntoStruct(program, ids); std::map vertexAttributeRenaming = {};