feat(Renderer): Allow GMs in testing to use Uber shader or not (#13626) f0c2d52b31 * Add files * Add GM * Add GMs * Clang format * Potential fix for pull request finding * Update pr_platform_tests.yaml * Address review comments * clang format * Update pr_platform_tests.yaml * Update render_context_vulkan_impl.cpp * Update render_context_d3d_impl.hpp * Fix for vulkan crashing * Add GM and exclude for bad platforms * More Gms * Update testing only func * Update async_pipeline_manager.hpp * Update async_pipeline_manager.hpp * Update async_pipeline_manager.hpp * Clang format * Create uber_parity.png * Temp code to see VK failures on CI * Delete uber_parity.png * Address comments * Update async_pipeline_manager.hpp * Update async_pipeline_manager.hpp * Create uber_parity_srcover.png * Create uber_parity_srcover.png * Gms * GMs * Gms * Update uber_parity.png * Update fiddle_context.hpp * Update fiddle_context.hpp * Update uber_parity.cpp * Clang format * Update Gms * Remove diagnostic code * Update pr_platform_tests.yaml * Update comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: John White <aliasbinman@gmail.com>
diff --git a/.rive_head b/.rive_head index 19585ff..e54968d 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -bcdfe4fe5fcee41b74d3bf96489bd67cac7236a8 +f0c2d52b31981b709abcb03d156887638a58e5a7
diff --git a/renderer/include/rive/renderer/async_pipeline_manager.hpp b/renderer/include/rive/renderer/async_pipeline_manager.hpp index 05b722a..5b016f3 100644 --- a/renderer/include/rive/renderer/async_pipeline_manager.hpp +++ b/renderer/include/rive/renderer/async_pipeline_manager.hpp
@@ -76,6 +76,17 @@ assert(!m_jobThread.joinable()); } +#ifdef WITH_RIVE_TOOLS + ShaderCompilationMode shaderCompilationMode() const { return m_mode; } + ShaderCompilationMode testingOnly_setShaderCompilationMode( + ShaderCompilationMode new_mode) + { + ShaderCompilationMode oldMode = m_mode; + m_mode = new_mode; + return oldMode; + } +#endif + const PipelineType* tryGetPipeline(const PipelineProps& propsIn, const PlatformFeatures& platformFeatures) { @@ -104,8 +115,7 @@ case ShaderCompilationMode::onlyUbershaders: // For ubershader-only loading, we'll always use the full - // ubershader - // feature flags and always load synchronously. + // ubershader feature flags and always load synchronously. props.shaderFeatures = ubershaderFeatures; [[fallthrough]]; @@ -674,7 +684,11 @@ bool m_isDone = false; uint32_t m_activePipelineCreationCount = 0; +#ifdef WITH_RIVE_TOOLS + ShaderCompilationMode m_mode = ShaderCompilationMode::standard; +#else const ShaderCompilationMode m_mode = ShaderCompilationMode::standard; +#endif std::thread m_jobThread; std::optional<PipelineKey> m_currentThreadPipelineKey; std::mutex m_mutex;
diff --git a/renderer/include/rive/renderer/d3d11/render_context_d3d_impl.hpp b/renderer/include/rive/renderer/d3d11/render_context_d3d_impl.hpp index 7b1413c..f5a5450 100644 --- a/renderer/include/rive/renderer/d3d11/render_context_d3d_impl.hpp +++ b/renderer/include/rive/renderer/d3d11/render_context_d3d_impl.hpp
@@ -250,6 +250,14 @@ D3D11PipelineManager m_pipelineManager; +#ifdef WITH_RIVE_TOOLS + ShaderCompilationMode testingOnly_setShaderCompilationMode( + ShaderCompilationMode mode) override + { + return m_pipelineManager.testingOnly_setShaderCompilationMode(mode); + } +#endif + const D3DCapabilities m_d3dCapabilities; ComPtr<ID3D11Device> m_gpu;
diff --git a/renderer/include/rive/renderer/d3d12/render_context_d3d12_impl.hpp b/renderer/include/rive/renderer/d3d12/render_context_d3d12_impl.hpp index fc89576..c466483 100644 --- a/renderer/include/rive/renderer/d3d12/render_context_d3d12_impl.hpp +++ b/renderer/include/rive/renderer/d3d12/render_context_d3d12_impl.hpp
@@ -237,6 +237,14 @@ D3D12PipelineManager m_pipelineManager; rcp<D3D12ResourceManager> m_resourceManager; +#ifdef WITH_RIVE_TOOLS + ShaderCompilationMode testingOnly_setShaderCompilationMode( + ShaderCompilationMode mode) override + { + return m_pipelineManager.testingOnly_setShaderCompilationMode(mode); + } +#endif + // Rive buffer pools. These don't need to be rcp<> because the destructor of // RenderContextVulkanImpl is already synchronized. D3D12VolatileBufferPool m_flushUniformBufferPool;
diff --git a/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp b/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp index 7368771..01ad6a5 100644 --- a/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp +++ b/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp
@@ -522,6 +522,14 @@ GLPipelineManager m_pipelineManager; +#ifdef WITH_RIVE_TOOLS + ShaderCompilationMode testingOnly_setShaderCompilationMode( + ShaderCompilationMode mode) override + { + return m_pipelineManager.testingOnly_setShaderCompilationMode(mode); + } +#endif + // Vertex/index buffers for drawing paths. glutils::VAO m_drawVAO; glutils::Buffer m_patchVerticesBuffer;
diff --git a/renderer/include/rive/renderer/render_context_impl.hpp b/renderer/include/rive/renderer/render_context_impl.hpp index 932ddae..bb6c2e6 100644 --- a/renderer/include/rive/renderer/render_context_impl.hpp +++ b/renderer/include/rive/renderer/render_context_impl.hpp
@@ -37,6 +37,19 @@ RenderBufferFlags, size_t) = 0; +#ifdef WITH_RIVE_TOOLS + // Changes how pipelines are selected and compiled for subsequent flushes, + // returning the previous mode. Backends that don't support runtime + // changes ignore the request (and echo the requested mode back). Testing + // only: lets the golden tests render individual frames through + // ubershaders. + virtual ShaderCompilationMode testingOnly_setShaderCompilationMode( + ShaderCompilationMode mode) + { + return mode; + } +#endif + // Use platform apis to decode the image bytes and creates a texture if // available. If not available leaving its default implementation will cause // rive decoders to be used instead
diff --git a/renderer/include/rive/renderer/vulkan/render_context_vulkan_impl.hpp b/renderer/include/rive/renderer/vulkan/render_context_vulkan_impl.hpp index d42e8c7..487e92b 100644 --- a/renderer/include/rive/renderer/vulkan/render_context_vulkan_impl.hpp +++ b/renderer/include/rive/renderer/vulkan/render_context_vulkan_impl.hpp
@@ -476,6 +476,11 @@ rcp<DescriptorSetPoolPool> m_descriptorSetPoolPool; std::unique_ptr<PipelineManagerVulkan> m_pipelineManager; + +#ifdef WITH_RIVE_TOOLS + ShaderCompilationMode testingOnly_setShaderCompilationMode( + ShaderCompilationMode mode) override; +#endif }; } // namespace rive::gpu
diff --git a/renderer/src/vulkan/render_context_vulkan_impl.cpp b/renderer/src/vulkan/render_context_vulkan_impl.cpp index 3f4ef16..e0dad19 100644 --- a/renderer/src/vulkan/render_context_vulkan_impl.cpp +++ b/renderer/src/vulkan/render_context_vulkan_impl.cpp
@@ -4346,4 +4346,13 @@ return std::make_unique<RenderContext>(std::move(impl)); } + +#ifdef WITH_RIVE_TOOLS +ShaderCompilationMode RenderContextVulkanImpl:: + testingOnly_setShaderCompilationMode(ShaderCompilationMode mode) +{ + return m_pipelineManager->testingOnly_setShaderCompilationMode(mode); +} +#endif + } // namespace rive::gpu
diff --git a/tests/common/testing_window.hpp b/tests/common/testing_window.hpp index 5de6d65..029a82d 100644 --- a/tests/common/testing_window.hpp +++ b/tests/common/testing_window.hpp
@@ -237,6 +237,8 @@ #ifdef WITH_RIVE_TOOLS rive::gpu::SynthesizedFailureType synthesizedFailureType = rive::gpu::SynthesizedFailureType::none; + rive::gpu::ShaderCompilationMode shaderCompilationMode = + rive::gpu::ShaderCompilationMode::standard; #endif }; virtual std::unique_ptr<rive::Renderer> beginFrame(const FrameOptions&) = 0;
diff --git a/tests/gm/gm.cpp b/tests/gm/gm.cpp index b51714e..045ba18 100644 --- a/tests/gm/gm.cpp +++ b/tests/gm/gm.cpp
@@ -5,6 +5,8 @@ #include "gm.hpp" #include "common/testing_window.hpp" +#include "rive/renderer/render_context.hpp" +#include "rive/renderer/render_context_impl.hpp" using namespace rivegm; @@ -16,9 +18,32 @@ .triangulationThresholds = DeterministicTriangulationThresholds, }; updateFrameOptions(&frameOptions); + + rive::gpu::RenderContext* renderContext = nullptr; + auto previousMode = rive::gpu::ShaderCompilationMode::standard; + if (frameOptions.shaderCompilationMode != + rive::gpu::ShaderCompilationMode::standard) + { + renderContext = TestingWindow::Get()->renderContext(); + if (renderContext != nullptr) + { + previousMode = + renderContext->impl()->testingOnly_setShaderCompilationMode( + rive::gpu::ShaderCompilationMode::onlyUbershaders); + } + } + auto renderer = TestingWindow::Get()->beginFrame(frameOptions); draw(renderer.get()); TestingWindow::Get()->endFrame(pixels); + + if ((frameOptions.shaderCompilationMode != + rive::gpu::ShaderCompilationMode::standard) && + renderContext != nullptr) + { + renderContext->impl()->testingOnly_setShaderCompilationMode( + previousMode); + } } void GM::draw(rive::Renderer* renderer)
diff --git a/tests/gm/gmmain.cpp b/tests/gm/gmmain.cpp index 3c2816d..39100cd 100644 --- a/tests/gm/gmmain.cpp +++ b/tests/gm/gmmain.cpp
@@ -203,6 +203,8 @@ MAKE_GM(negative_interior_triangles) MAKE_GM(negative_interior_triangles_as_clip) MAKE_GM(transparentclear_blendmode) + MAKE_GM(uber_parity) + MAKE_GM(uber_parity_srcover) MAKE_GM(emptystrokefeather) MAKE_GM(emptystroke) MAKE_GM(trickycubicstrokes)
diff --git a/tests/gm/uber_parity.cpp b/tests/gm/uber_parity.cpp new file mode 100644 index 0000000..4c4d2a5 --- /dev/null +++ b/tests/gm/uber_parity.cpp
@@ -0,0 +1,164 @@ +/* + * Copyright 2026 Rive + * + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "common/testing_window.hpp" +#include "rive/renderer.hpp" +#include "rive/renderer/render_context.hpp" +#include "rive/renderer/render_context_impl.hpp" + +#include "assets/nomoon.png.hpp" + +using namespace rivegm; +using namespace rive; + +// Renders the same scene twice, side by side: the LEFT cell through +// fully-featured ubershader pipelines and the RIGHT cell through specialized +// pipelines (via a render pass break between the two). The cells must match: +// a few LSB of rounding difference is expected on translucent content (the +// premultiplied and unmultiplied paint paths round differently), but any +// visible or structural divergence is an ubershader bug. The golden pins +// both halves, so a regression in either flavor diffs immediately. +// +// The scene is chosen to stress where the two pipeline flavors are most +// likely to diverge: ubershaders compile ENABLE_ADVANCED_BLEND, which flips +// the entire paint path to unmultiplied colors. So the scene leans on +// content whose premultiplied vs unmultiplied evaluation differs in rounding +// and codepath: +// - translucent gradients fading to very low alpha (premultiply rounding), +// - an image drawn at low opacity (the unmultiplied path round-trips image +// texels through unmultiply/premultiply), +// - an advanced blend (multiply) overlapping srcOver content (forces the +// dst-read machinery alongside the plain path), +// - thin strokes over the gradient (AA edge rounding). +// +// On backends that can't switch shader compilation mode at runtime, both +// cells render specialized and trivially match. +constexpr float CELL = 300.f; + +static void draw_scene(Renderer* renderer, float ox) +{ + Factory* factory = TestingWindow::Get()->factory(); + constexpr float stops[2] = {0.f, 1.f}; + + renderer->save(); + renderer->translate(ox, 0); + + // Opaque horizontal gradient base. + AABB base = {20.f, 20.f, 280.f, 280.f}; + ColorInt baseColors[2] = {0xff2060c0, 0xffc06020}; + Paint basePaint; + basePaint->shader(factory->makeLinearGradient(base.left(), + 0, + base.right(), + 0, + baseColors, + stops, + 2)); + renderer->drawPath(PathBuilder::Rect(base), basePaint); + + // Translucent vertical gradient fading to near-zero alpha: the + // premultiplied path rounds rgb*a per fragment, the unmultiplied path + // premultiplies after coverage; low-alpha texels expose the difference. + AABB overlay = {50.f, 50.f, 250.f, 250.f}; + ColorInt overlayColors[2] = {0xccff2020, 0x0dffff20}; + Paint overlayPaint; + overlayPaint->shader(factory->makeLinearGradient(0, + overlay.top(), + 0, + overlay.bottom(), + overlayColors, + stops, + 2)); + renderer->drawPath(PathBuilder::Rect(overlay), overlayPaint); + + // Multiply square overlapping both gradients: advanced blend content in + // the same scene as plain srcOver draws. + Paint multiplyPaint; + multiplyPaint->color(0xff90b0d0); + multiplyPaint->blendMode(BlendMode::multiply); + renderer->drawPath(PathBuilder::Rect({140.f, 80.f, 260.f, 200.f}), + multiplyPaint); + + // Image at low opacity: in the unmultiplied path, image texels round-trip + // through unmultiply_rgb and a later premultiply. + auto img = LoadImage(assets::nomoon_png()); + if (img != nullptr) + { + renderer->save(); + renderer->translate(60.f, 120.f); + float scale = 140.f / std::max(img->width(), img->height()); + renderer->scale(scale, scale); + renderer->drawImage(img.get(), + ImageSampler::LinearClamp(), + BlendMode::srcOver, + .35f); + renderer->restore(); + } + + // Thin translucent strokes over everything: AA edge rounding. + for (int i = 0; i < 3; ++i) + { + Paint strokePaint; + strokePaint->style(RenderPaintStyle::stroke); + strokePaint->thickness(1.5f); + strokePaint->color(0x8020ff80); + renderer->drawPath(PathBuilder::Circle(150.f, 150.f, 60.f + 25.f * i), + strokePaint); + } + + renderer->restore(); +} + +class uber_parity_GM : public rivegm::GM +{ +public: + uber_parity_GM() : GM(600, 300) {} + ColorInt clearColor() const override { return 0xff404040; } + + void updateFrameOptions(TestingWindow::FrameOptions* options) const override + { + // Makes GM::run restore the process-wide mode after endFrame(); the + // per-half modes below override it in between. + options->shaderCompilationMode = + rive::gpu::ShaderCompilationMode::onlyUbershaders; + } + + void onDraw(rive::Renderer* renderer) override + { + gpu::RenderContext* renderContext = + TestingWindow::Get()->renderContext(); + if (renderContext == nullptr) + { + // No runtime mode switching on this backend; both cells render + // the same way and trivially match. + draw_scene(renderer, 0); + draw_scene(renderer, CELL); + return; + } + + gpu::RenderContext::FrameDescriptor frameDescriptor = + renderContext->frameDescriptor(); + frameDescriptor.loadAction = gpu::LoadAction::preserveRenderTarget; + + for (int i = 0; i < 2; ++i) + { + // Flushes the previous half (and initially the clear) under the + // mode that was live when its draws were recorded. + TestingWindow::Get()->flushPLSContext(); + renderContext->impl()->testingOnly_setShaderCompilationMode( + i == 0 ? gpu::ShaderCompilationMode::onlyUbershaders + : gpu::ShaderCompilationMode::alwaysSynchronous); + renderContext->beginFrame(frameDescriptor); + draw_scene(renderer, i * CELL); + } + // endFrame() flushes the right half under alwaysSynchronous, then + // GM::run restores the process-wide mode. + } +}; +GMREGISTER(uber_parity, return new uber_parity_GM)
diff --git a/tests/gm/uber_parity_srcover.cpp b/tests/gm/uber_parity_srcover.cpp new file mode 100644 index 0000000..38dfea6 --- /dev/null +++ b/tests/gm/uber_parity_srcover.cpp
@@ -0,0 +1,163 @@ +/* + * Copyright 2026 Rive + * + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "common/testing_window.hpp" +#include "rive/renderer.hpp" +#include "rive/renderer/render_context.hpp" +#include "rive/renderer/render_context_impl.hpp" + +#include "assets/nomoon.png.hpp" + +using namespace rivegm; +using namespace rive; + +// Companion to uber_parity: the same left-uber / right-specialized layout, +// but with a srcOver-only scene and dithering disabled. Where uber_parity +// checks that the features an ubershader *uses* match specialized rendering, +// this GM checks that the features an ubershader compiles but does NOT use +// are true no-ops: +// +// - ENABLE_DITHER: specialized pipelines omit the feature entirely; the +// ubershader still runs the dither math and must neutralize it via +// 0-value uniforms (ditherMode == none zeroes ditherScale/ditherBias). +// This zero-uniform path has no other test coverage. +// - ENABLE_ADVANCED_BLEND: compiled in the ubershader (flipping the whole +// paint path to unmultiplied colors), but every draw here is srcOver. +// - ENABLE_EVEN_ODD, ENABLE_CLIP_RECT, ENABLE_CLIPPING: compiled in the +// ubershader; the scene contains no even-odd fills, no clipRect, and no +// clipping. +// +// The specialized half compiles none of the above. The halves must match +// within a few LSB of premult/unmult rounding on translucent content; any +// structural divergence means an enabled-but-unused ubershader feature is +// not a no-op. +// +// On backends that can't switch shader compilation mode at runtime, both +// cells render specialized and trivially match. +constexpr float CELL = 300.f; + +static void draw_srcover_scene(Renderer* renderer, float ox) +{ + Factory* factory = TestingWindow::Get()->factory(); + constexpr float stops[2] = {0.f, 1.f}; + + renderer->save(); + renderer->translate(ox, 0); + + // Opaque horizontal gradient base. Banding on this smooth ramp must be + // identical in both halves: neither half may dither (the ubershader half + // has the dither code compiled and must no-op it). + AABB base = {20.f, 20.f, 280.f, 280.f}; + ColorInt baseColors[2] = {0xff2060c0, 0xffc06020}; + Paint basePaint; + basePaint->shader(factory->makeLinearGradient(base.left(), + 0, + base.right(), + 0, + baseColors, + stops, + 2)); + renderer->drawPath(PathBuilder::Rect(base), basePaint); + + // Translucent vertical gradient fading to near-zero alpha: the + // premultiplied path rounds rgb*a per fragment, the unmultiplied path + // premultiplies after coverage; low-alpha texels expose the difference. + AABB overlay = {50.f, 50.f, 250.f, 250.f}; + ColorInt overlayColors[2] = {0xccff2020, 0x0dffff20}; + Paint overlayPaint; + overlayPaint->shader(factory->makeLinearGradient(0, + overlay.top(), + 0, + overlay.bottom(), + overlayColors, + stops, + 2)); + renderer->drawPath(PathBuilder::Rect(overlay), overlayPaint); + + // Image at low opacity: in the unmultiplied path, image texels round-trip + // through unmultiply_rgb and a later premultiply. + auto img = LoadImage(assets::nomoon_png()); + if (img != nullptr) + { + renderer->save(); + renderer->translate(60.f, 120.f); + float scale = 140.f / std::max(img->width(), img->height()); + renderer->scale(scale, scale); + renderer->drawImage(img.get(), + ImageSampler::LinearClamp(), + BlendMode::srcOver, + .35f); + renderer->restore(); + } + + // Thin translucent strokes over everything: AA edge rounding. + for (int i = 0; i < 3; ++i) + { + Paint strokePaint; + strokePaint->style(RenderPaintStyle::stroke); + strokePaint->thickness(1.5f); + strokePaint->color(0x8020ff80); + renderer->drawPath(PathBuilder::Circle(150.f, 150.f, 60.f + 25.f * i), + strokePaint); + } + + renderer->restore(); +} + +class uber_parity_srcover_GM : public rivegm::GM +{ +public: + uber_parity_srcover_GM() : GM(600, 300) {} + ColorInt clearColor() const override { return 0xff404040; } + + void updateFrameOptions(TestingWindow::FrameOptions* options) const override + { + // Makes GM::run restore the process-wide mode after endFrame(); the + // per-half modes below override it in between. + options->shaderCompilationMode = + rive::gpu::ShaderCompilationMode::onlyUbershaders; + } + + void onDraw(rive::Renderer* renderer) override + { + gpu::RenderContext* renderContext = + TestingWindow::Get()->renderContext(); + if (renderContext == nullptr) + { + // No runtime mode switching on this backend; both cells render + // the same way and trivially match. + draw_srcover_scene(renderer, 0); + draw_srcover_scene(renderer, CELL); + return; + } + + gpu::RenderContext::FrameDescriptor frameDescriptor = + renderContext->frameDescriptor(); + frameDescriptor.loadAction = gpu::LoadAction::preserveRenderTarget; + // Dither off: the specialized half drops ENABLE_DITHER from its + // pipelines; the ubershader half keeps the feature compiled and must + // neutralize it through the zeroed dither uniforms. + frameDescriptor.ditherMode = gpu::DitherMode::none; + + for (int i = 0; i < 2; ++i) + { + // Flushes the previous half (and initially the clear) under the + // mode that was live when its draws were recorded. + TestingWindow::Get()->flushPLSContext(); + renderContext->impl()->testingOnly_setShaderCompilationMode( + i == 0 ? gpu::ShaderCompilationMode::onlyUbershaders + : gpu::ShaderCompilationMode::alwaysSynchronous); + renderContext->beginFrame(frameDescriptor); + draw_srcover_scene(renderer, i * CELL); + } + // endFrame() flushes the right half under alwaysSynchronous, then + // GM::run restores the process-wide mode. + } +}; +GMREGISTER(uber_parity_srcover, return new uber_parity_srcover_GM)