From 149ec03bc1fb3b08b62e558c3dd8b91d78eda7ab Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Mon, 17 Aug 2026 16:35:09 -0700 Subject: [PATCH 1/5] Fix gl_FragCoord Y orientation on D3D/Metal/Vulkan Babylon Native's shader model is "shader-visible coordinates are GL-logical; convert to physical at each sampler access". This is implemented by FlipSamplerCoordinatesTraverser (texture() v -> 1-v, texelFetch y -> h-1-y) and InvertYDerivativeOperandsTraverser (negate dFdy), which run for DXBC/DXIL/Metal/Vulkan but not OpenGL. gl_FragCoord was the one shader input left in physical space. D3D, Metal and Vulkan rasterize with a top-left origin while GL uses bottom-left, and BN does not flip geometry (ProcessShaderCoordinates only remaps depth). So for GL row y the hardware yields height - y - 0.5 instead of y + 0.5, i.e. gl_FragCoord.y arrives mirrored. Shaders using the symmetric "sample at my own position" pattern are unaffected because the physical/physical pairing is self-consistent. The mismatch only shows up where the row index itself is meaningful: prefix sums (iblCdfy), neighbour offsets, and copies into a differently-oriented target (copyTexture3DLayerToTexture). That is why 39 shaders reference gl_FragCoord but only a handful render incorrectly. Add FragCoordYFlipTraverser, which rewrites every gl_FragCoord read in the fragment stage to vec4(fc.x, targetHeight - fc.y, fc.z, fc.w). The correction is exactly `height - y` with no -1 term (see derivation above). Shaders that never read gl_FragCoord are left byte-for-byte unchanged. The target height comes from a new vec4 uniform, bnFragCoordTargetSize, declared as a linker object so MoveNonSamplerUniformsIntoStruct sweeps it into the "Frame" struct like every other uniform and it is emitted by name into the bgfx uniform table. NativeEngine sets it in DrawInternal from the bound framebuffer's dimensions. bgfx's predefined u_viewRect is deliberately not used: it is narrowed to the viewport by FrameBuffer::SetBgfxViewPortAndScissor whenever one is set, whereas gl_FragCoord is relative to the whole render target. FlipFragCoordY must run before ChangeUniformTypes / MoveNonSamplerUniformsIntoStruct so the uniform is collected with the rest. A fresh replacement subtree is built per occurrence rather than reusing MakeReplacements, which maps one node per symbol name and would give that node multiple parents - something later traversers do not expect. OpenGL is intentionally left alone, as with the other flip traversers. Validated on D3D11: 149 tests validated with 0 pixel-diff failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- .../Babylon/Graphics/BgfxShaderInfo.h | 4 + Plugins/NativeEngine/Source/NativeEngine.cpp | 14 ++ Plugins/NativeEngine/Source/Program.cpp | 3 + Plugins/NativeEngine/Source/Program.h | 3 + .../Source/ShaderCompilerDXBC.cpp | 2 + .../Source/ShaderCompilerDXIL.cpp | 2 + .../Source/ShaderCompilerMetal.cpp | 2 + .../Source/ShaderCompilerTraversers.cpp | 132 ++++++++++++++++++ .../Source/ShaderCompilerTraversers.h | 6 + .../Source/ShaderCompilerVulkan.cpp | 2 + 10 files changed, 170 insertions(+) diff --git a/Core/Graphics/InternalInclude/Babylon/Graphics/BgfxShaderInfo.h b/Core/Graphics/InternalInclude/Babylon/Graphics/BgfxShaderInfo.h index d3ebf41465..29b0c47565 100644 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/BgfxShaderInfo.h +++ b/Core/Graphics/InternalInclude/Babylon/Graphics/BgfxShaderInfo.h @@ -62,6 +62,10 @@ namespace Babylon::Graphics } return false; } + /// Uniform declared in fragment shaders that read gl_FragCoord, holding the bound + /// framebuffer's width/height in .x/.y. Deliberately outside the u_ namespace Babylon.js + /// uses so it cannot collide with a shader uniform. + inline constexpr const char* FRAGCOORD_TARGET_SIZE_UNIFORM_NAME{"bnFragCoordTargetSize"}; struct BgfxShaderInfo { diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index 92c35a801f..32bd657e04 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -3066,6 +3066,20 @@ namespace Babylon encoder->setUniform({it.first}, value.Data.data(), value.ElementLength); } + // Resolves the gl_FragCoord Y flip injected by ShaderCompilerTraversers::FlipFragCoordY. + // Must be the framebuffer's height, not the bgfx view rect's, which + // SetBgfxViewPortAndScissor narrows to the viewport when one is set. + 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); + } + // Generic divisor-driven attributes are per-vertex inputs in the base shader. Recompile a // variant that routes each one to the exact i_data slot used by the instance buffer. // Built-ins keep the compiler-assigned base slots carried by Program. diff --git a/Plugins/NativeEngine/Source/Program.cpp b/Plugins/NativeEngine/Source/Program.cpp index 8cac28c484..68867766ee 100644 --- a/Plugins/NativeEngine/Source/Program.cpp +++ b/Plugins/NativeEngine/Source/Program.cpp @@ -103,6 +103,9 @@ namespace Babylon throw std::runtime_error{"Multiple built-in instance attributes use the same shader location."}; } } + + // Cached because DrawInternal consults it on every draw. + 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 51c1101b69..669c36b76f 100644 --- a/Plugins/NativeEngine/Source/Program.h +++ b/Plugins/NativeEngine/Source/Program.h @@ -69,6 +69,8 @@ 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; } + // Null for shaders that never read gl_FragCoord; the compiler omits the uniform there. + const UniformInfo* FragCoordTargetSizeUniform() const { return m_fragCoordTargetSizeUniform; } // Compiler-assigned i_data slot for each built-in attribute location. const std::map& BuiltInInstanceDataSlots() const { return m_builtInInstanceDataSlots; } @@ -82,6 +84,7 @@ namespace Babylon std::map m_uniformInfos; std::map m_vertexAttributeLocations; std::map m_builtInInstanceDataSlots; + 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 2224dda7ff..81b8471b81 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerDXBC.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerDXBC.cpp @@ -104,6 +104,8 @@ namespace Babylon::Plugins ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); + // Must precede the uniform struct move, which collects the uniform this declares. + 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 4381ab851e..6cc12206a8 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerDXIL.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerDXIL.cpp @@ -180,6 +180,8 @@ namespace Babylon::Plugins ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); + // Must precede the uniform struct move, which collects the uniform this declares. + 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 863ff5d0dd..ff55ad54d9 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerMetal.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerMetal.cpp @@ -109,6 +109,8 @@ namespace Babylon::Plugins ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); + // Must precede the uniform struct move, which collects the uniform this declares. + 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 33beddbf82..5b2d1ba343 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp @@ -2354,6 +2354,133 @@ namespace Babylon::ShaderCompilerTraversers TIntermediate* m_intermediate{}; }; + + /// Presents gl_FragCoord in OpenGL's coordinate space on the top-left-origin backends + /// (D3D, Metal, Vulkan). FlipSamplerCoordinates already flips every sample coordinate, so + /// gl_FragCoord was the one input left in physical space -- making + /// `texelFetch(tex, ivec2(gl_FragCoord.xy), 0)` read the mirrored row. + /// + /// The flip is `targetHeight - gl_FragCoord.y`, with no -1 term: the hardware yields + /// p + 0.5 for physical row p, and p == height - 1 - y, so the GL value y + 0.5 is exactly + /// height minus the incoming value. + /// + /// The height cannot come from bgfx's u_viewRect, which SetBgfxViewPortAndScissor narrows + /// to the viewport, while gl_FragCoord is relative to the whole render target. + 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 so MoveNonSamplerUniformsIntoStruct sweeps it into + // the "Frame" struct with every other non-sampler uniform. + 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. + 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) + { + // Not batched into one MakeReplacements call: that maps one replacement per + // symbol *name*, so every gl_FragCoord reference would share one subtree and + // that node would end up with multiple parents. + MakeReplacements({{"gl_FragCoord", BuildFlippedFragCoord(symbol, targetSize)}}, {{symbol, parent}}); + } + } + + /// Builds `vec4(gl_FragCoord.x, targetSize.y - gl_FragCoord.y, .z, .w)`. The whole + /// vector is rebuilt rather than patching .y because a reference may be swizzled, + /// indexed, or passed along whole, and the parent node is not inspected here. + TIntermTyped* BuildFlippedFragCoord(TIntermSymbol* fragCoord, TIntermSymbol* targetSize) + { + const TSourceLoc& loc{fragCoord->getLoc()}; + TType floatType{EbtFloat, EvqTemporary, 1}; + TType vec4Type{EbtFloat, EvqTemporary, 4}; + + // Each component gets its own symbol copy so no node ends up with two parents. + 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) @@ -2410,4 +2537,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 6e5eb8abf1..ba25d36a35 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.h +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.h @@ -179,4 +179,10 @@ 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 to present it in OpenGL's bottom-left-origin space. + /// Must run before MoveNonSamplerUniformsIntoStruct so the target-size uniform it declares is + /// collected with the others, and only on the backends that apply FlipSamplerCoordinates + /// (D3D, Metal, Vulkan); OpenGL already matches WebGL's origin. + void FlipFragCoordY(glslang::TProgram& program, IdGenerator& ids); } diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp index c2e6faa371..6f959333a4 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp @@ -277,6 +277,8 @@ namespace Babylon::Plugins ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); + // Must precede the uniform struct move, which collects the uniform this declares. + ShaderCompilerTraversers::FlipFragCoordY(program, ids); auto cutScope = ShaderCompilerTraversers::ChangeUniformTypes(program, ids); auto utstScope = ShaderCompilerTraversers::MoveNonSamplerUniformsIntoStruct(program, ids); std::map vertexAttributeRenaming = {}; From b1604954416782fde958b2ff1d8f756da3d9cd1b Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Mon, 17 Aug 2026 17:57:22 -0700 Subject: [PATCH 2/5] Add unit tests pinning down gl_FragCoord orientation Two render-and-readback tests in UnitTests, both gated off where the existing render tests are (D3D12, noop Metal device). FragCoordYIncreasesUpwards writes gl_FragCoord.y / height into a render target and checks the ramp is brightest at the top row, matching GL's bottom-left origin. Values come out as 253 / 126 / 2 for the top, middle and bottom rows of a 64-row target, exactly the (height - row - 0.5) / height ramp the correction is derived from. FragCoordAndUVAddressATextureIdentically samples one texture twice, once through the interpolated UVs of a full-screen quad and once through gl_FragCoord.xy / targetSize, and requires the two images to match. That is the addressing 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 orients its upload. Both fail without FlipFragCoordY: the first ramp inverts to 2 / 129 / 253 and the second renders vertically mirrored (255..3 against 3..255). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- Apps/UnitTests/CMakeLists.txt | 1 + .../Tests.ShaderCompilation.FragCoord.cpp | 376 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index 9e42083c32..1da47150db 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -37,6 +37,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 0000000000..6b0914a7b0 --- /dev/null +++ b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp @@ -0,0 +1,376 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Helpers.h" + +#include +#include +#include +#include +#include +#include +#include + +extern Babylon::Graphics::Configuration g_deviceConfig; + +// These tests pin down the orientation of gl_FragCoord.y. +// +// 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 and +// is corrected by the shader compiler (FragCoordYFlipTraverser). +namespace +{ + // Renders a full-screen quad into a width x height render target, returning RGBA8 + // pixels in memory order. The fragment shader may declare `uniform vec2 targetSize` + // and, when withInputTexture is set, `uniform sampler2D inputSampler` bound to a + // texture whose row y holds makeRow(y). + std::vector RenderFullScreenQuad( + uint32_t width, + uint32_t height, + const std::string& vertexShader, + const std::string& fragmentShader, + bool withInputTexture) + { + 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(); + delete engine.getCaps().parallelShaderCompile; + 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; + + // Clip-space quad passed straight through the vertex shader, so no + // projection matrix is involved and it lines up with the target exactly. + 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) { + // Decreasing red ramp makes a vertical mirror 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 upwards, so the top +// row of the image (row 0 in memory) has to hold the largest value. Without the +// gl_FragCoord correction the ramp comes out upside down on D3D/Metal/Vulkan. +TEST(ShaderCompilation, FragCoordYIncreasesUpwards) +{ +#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" + "void main(void) { gl_Position = vec4(position, 1.0); }\n"; + + const std::string fragmentShader = + "precision highp float;\n" + "uniform vec2 targetSize;\n" + "void main(void) {\n" + " gl_FragColor = vec4(gl_FragCoord.y / targetSize.y, 0.0, 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 red = [&pixels](uint32_t row) { + return static_cast(pixels[static_cast(row) * WIDTH * 4]); + }; + + std::cout << "row 0 red=" << red(0) + << ", row " << (HEIGHT / 2) << " red=" << red(HEIGHT / 2) + << ", row " << (HEIGHT - 1) << " red=" << red(HEIGHT - 1) << std::endl; + + // The ramp must run bright at the top to dark at the bottom. + EXPECT_GT(red(0), 200) << "top row should hold the largest gl_FragCoord.y"; + EXPECT_LT(red(HEIGHT - 1), 55) << "bottom row should hold the smallest gl_FragCoord.y"; + + // Monotonicity is checked instead of exact values so the test stays valid + // under any monotonic transfer function the backend may apply. + for (uint32_t row = 1; row < HEIGHT; ++row) + { + ASSERT_LE(red(row), red(row - 1)) + << "gl_FragCoord.y ramp is not monotonically decreasing at row " << row; + } +#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 only holds if the +// gl_FragCoord correction and FlipSamplerCoordinatesTraverser compose to a no-op. +// The two addressing modes are compared against each other rather than against the +// source pixels so the test does not depend on createRawTexture's memory layout. +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. + 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 +} From a0c25c93fcc52e303f49096bffb49fd9e1b187a5 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Mon, 17 Aug 2026 18:29:28 -0700 Subject: [PATCH 3/5] Use null instead of delete to disable parallel shader compile Matches Tests.ShaderCompilation.cpp and Tests.UniformPadding.cpp, and avoids relying on the caps property being configurable. --- Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp index 6b0914a7b0..ff2bc0970b 100644 --- a/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp +++ b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp @@ -74,7 +74,7 @@ namespace globalThis.startup = function (outputNativeTexture, width, height) { var engine = new BABYLON.NativeEngine(); - delete engine.getCaps().parallelShaderCompile; + engine.getCaps().parallelShaderCompile = null; var scene = new BABYLON.Scene(engine); scene.autoClear = true; scene.clearColor = new BABYLON.Color4(0, 0, 0, 1); From eff3a0877d77e9fd3c3f48cbc190a686d0d28708 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Tue, 18 Aug 2026 07:14:35 -0700 Subject: [PATCH 4/5] Make the gl_FragCoord orientation test backend-agnostic Helpers::ReadPixels is a plain glReadPixels on OpenGL, which returns the bottom scanline first, whereas the D3D11 path returns the top scanline first. The test asserted an absolute ramp direction over readback rows, so it encoded the D3D11 readback convention and failed on Linux even though gl_FragCoord.y was correct there. Compare normalized gl_FragCoord.y against the interpolated vUV.y written by the same fragment invocation instead. The quad maps uv.y to clip y, so the two ramps must agree on every backend regardless of readback row order, and a flipped gl_FragCoord.y still misses by the full range of the ramp. Renamed to FragCoordYMatchesInterpolatedUV to match what it now checks. --- .../Tests.ShaderCompilation.FragCoord.cpp | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp index ff2bc0970b..0f8713d9b0 100644 --- a/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp +++ b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include extern Babylon::Graphics::Configuration g_deviceConfig; @@ -261,10 +262,14 @@ namespace } } -// gl_FragCoord.y must follow the GL convention of increasing upwards, so the top -// row of the image (row 0 in memory) has to hold the largest value. Without the -// gl_FragCoord correction the ramp comes out upside down on D3D/Metal/Vulkan. -TEST(ShaderCompilation, FragCoordYIncreasesUpwards) +// gl_FragCoord.y must increase towards +Y in clip space, like the interpolated vUV.y +// the quad supplies. Without the correction the two ramps become mirror images. +// +// Two channels of a single render are compared rather than absolute row indices +// because Helpers::ReadPixels returns the bottom scanline first on OpenGL and the top +// first on D3D11; an absolute check would encode one backend's readback convention +// instead of the shading language rule under test. +TEST(ShaderCompilation, FragCoordYMatchesInterpolatedUV) { #if defined(SKIP_EXTERNAL_TEXTURE_TESTS) || defined(SKIP_RENDER_TESTS) GTEST_SKIP(); @@ -275,36 +280,47 @@ TEST(ShaderCompilation, FragCoordYIncreasesUpwards) const std::string vertexShader = "precision highp float;\n" "attribute vec3 position;\n" - "void main(void) { gl_Position = vec4(position, 1.0); }\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, 0.0, 0.0, 1.0);\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 red = [&pixels](uint32_t row) { - return static_cast(pixels[static_cast(row) * WIDTH * 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])); }; - std::cout << "row 0 red=" << red(0) - << ", row " << (HEIGHT / 2) << " red=" << red(HEIGHT / 2) - << ", row " << (HEIGHT - 1) << " red=" << red(HEIGHT - 1) << std::endl; - - // The ramp must run bright at the top to dark at the bottom. - EXPECT_GT(red(0), 200) << "top row should hold the largest gl_FragCoord.y"; - EXPECT_LT(red(HEIGHT - 1), 55) << "bottom row should hold the smallest gl_FragCoord.y"; - - // Monotonicity is checked instead of exact values so the test stays valid - // under any monotonic transfer function the backend may apply. - for (uint32_t row = 1; row < HEIGHT; ++row) + 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 a vacuous pass: the reference ramp must actually sweep the range. + ASSERT_GT(std::abs(first.second - last.second), 200) + << "vUV.y reference ramp did not vary across the target"; + + // Both channels come from the same fragment invocation, so they must agree row by + // row whichever end of the image the readback starts at. The tolerance absorbs + // interpolation and 8-bit quantization only. + for (uint32_t row = 0; row < HEIGHT; ++row) { - ASSERT_LE(red(row), red(row - 1)) - << "gl_FragCoord.y ramp is not monotonically decreasing at row " << 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 } From 5918e6c8fc0bf86fed0ae5934560f9a11a93919b Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 3 Sep 2026 16:12:24 -0700 Subject: [PATCH 5/5] Address review comments on the gl_FragCoord Y flip Bump the shader cache version so serialized v5 entries recompile with FlipFragCoordY, wait on render with get() like the other tests, replace gl_FragCoord when it is a TIntermBranch expression, and add a direct-return orientation regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da74bc94-a7dc-4817-bd81-59b5c6b123fc --- .../Tests.ShaderCompilation.FragCoord.cpp | 55 +++++++++++++++++-- .../ShaderCache/Source/ShaderCacheImpl.cpp | 4 +- .../Source/ShaderCompilerTraversers.cpp | 19 +++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp index 0f8713d9b0..a841407506 100644 --- a/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp +++ b/Apps/UnitTests/Source/Tests.ShaderCompilation.FragCoord.cpp @@ -10,7 +10,6 @@ #include "Helpers.h" -#include #include #include #include @@ -249,10 +248,7 @@ namespace 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"; + renderDone.get_future().get(); device.FinishRenderingCurrentFrame(); @@ -325,6 +321,55 @@ TEST(ShaderCompilation, FragCoordYMatchesInterpolatedUV) #endif } +// `return gl_FragCoord;` parents the symbol on TIntermBranch, which MakeReplacements +// must handle; without that the compiler throws "Cannot replace symbol". +TEST(ShaderCompilation, FragCoordDirectReturnMatchesInterpolatedUV) +{ +#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" + "vec4 fragCoord() { return gl_FragCoord; }\n" + "void main(void) {\n" + " gl_FragColor = vec4(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 last = texel(HEIGHT - 1); + ASSERT_GT(std::abs(first.second - last.second), 200) + << "vUV.y reference ramp did not vary across the target"; + + 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 + << " after a direct return (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 only holds if the // gl_FragCoord correction and FlipSamplerCoordinatesTraverser compose to a no-op. diff --git a/Plugins/ShaderCache/Source/ShaderCacheImpl.cpp b/Plugins/ShaderCache/Source/ShaderCacheImpl.cpp index fa3e708abf..b36f4e2cce 100644 --- a/Plugins/ShaderCache/Source/ShaderCacheImpl.cpp +++ b/Plugins/ShaderCache/Source/ShaderCacheImpl.cpp @@ -41,7 +41,9 @@ namespace Babylon::Plugins::ShaderCache // 4: store the compiler-assigned built-in instance-data slots. // 5: shader binary packaging bumped to bgfx BGFX_SHADER_BIN_VERSION 12 (raw // SRV/UAV masks + tex meta); stale v4 cache entries would be rejected by bgfx. - static const uint32_t CACHE_VERSION = 5; + // 6: FlipFragCoordY rewrites gl_FragCoord and injects bnFragCoordTargetSize; + // version-5 entries for those shaders skip compilation and omit the uniform. + static const uint32_t CACHE_VERSION = 6; void ShaderCacheImpl::Clear() { diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp index 5b2d1ba343..563a768006 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp @@ -86,6 +86,16 @@ namespace Babylon::ShaderCompilerTraversers branch->setFalseBlock(replacement); } } + else if (auto* flow = parent->getAsBranchNode()) + { + // `return gl_FragCoord;` (and similar) parents the symbol on TIntermBranch. + if (flow->getExpression() != symbol) + { + throw std::runtime_error{"Cannot replace symbol: unexpected branch expression"}; + } + RemoveAllTreeNodes(flow->getExpression()); + flow->setExpression(replacement); + } else { throw std::runtime_error{"Cannot replace symbol: node type handler unimplemented"}; @@ -1422,6 +1432,15 @@ namespace Babylon::ShaderCompilerTraversers selection->setFalseBlock(replacement); } } + else if (auto* flow = parent->getAsBranchNode()) + { + if (flow->getExpression() != oldSymbol) + { + throw std::runtime_error{ + "SamplerFunctionParameterSplitter: unexpected branch expression when rewriting body sampler reference"}; + } + flow->setExpression(replacement); + } else { throw std::runtime_error{