tl;dr: get rid of GetYCoordScale() and handle the GLES NDC transformation internally, removing a hazardous abstraction leak that we don't need. Impeller internals, if you know you know.
This is a sad landmine that has regularly exploded on us and will continue to do so until the end of time. Chinmay and I landed the current solution years ago after we saw it in a Unity doc because neither of us could think of anything better at the time and we had much bigger fish to fry.
We don't need to leak this GLES-specific backend eccentricity into Impeller's 2D renderer (or FragmentProgram shaders, or Flutter GPU).
First off, to be more efficient than what we're doing today: we can inject a single uniform for performing the conditional flip to gl_Position.y in the vertex stage when the offscreen FBO itself is being rendered to, as opposed to Impeller's current solution of injecting one uniform per sampler in the fragment stage so that we can handle flipping offscreen textures that were already rendered upside-down. Then we just flip the winding order for the command in the RenderPass. This is the solution that wgpu/ANGLE/Filament all internally employ to great effect.
So, we'd handle this detail internally within the GLES backend, hiding this eccentricity from Impeller's 2D renderer (and Flutter GPU) entirely, by:
- Writing a special uniform into all GL vertex stage shaders in
impellerc & flipping gl_Position.y with it:
// Hidden uniform inserted into every vertex shader compiled for a GL target.
// Set by RenderPassGLES per pass.
uniform float _impeller_y_flip;
// Appended after the user's main():
gl_Position.y *= _impeller_y_flip;
- While encoding GLES commands set the special uniform to
-1.0 and flip the winding order.
Note that GLES 2 only has room for 16 vec4s in the fragment stage and has 128 vec4s in the vertex stage. So we're going from taking up 1 uniform per sampler in a pretty constrained environment to only ever using 1 in an environment where there's plenty of bandwidth to go around. So we don't have to worry about the uniform bandwidth as much. 😀 Running out of uniforms in the fragment stage was a real problem for us back when some of the contents were being initially implemented.
What other HALs do today
Surveying the five HALs that most closely match Impeller's position:
| HAL |
Where the compensation lives |
When applied |
Status |
| wgpu / naga |
Vertex-shader epilogue gl_Position.yz = vec2(-gl_Position.y, gl_Position.z*2-gl_Position.w); injected by the naga GLSL writer, plus front_face swap (Cw <-> Ccw) in the GLES HAL with an explicit cross-referencing comment |
Unconditional per backend (compile-time on the shader, pipeline-creation on the front-face) |
Production WebGPU runtime, shipping. Citation. |
| ANGLE |
ANGLETransformPosition(position) wrapper inserted by the translator, applying (swapXY ? position.yx : position.xy) * flipXY where flipXY and swapXY are driver uniforms; RotateAndFlipBuiltinVariable and FlipBuiltinVariable handle SPIR-V and MSL paths respectively |
Conditional at draw time via the uniform; same mechanism handles surface pre-rotation on Android |
The OpenGL-to-Vulkan/Metal/D3D translation layer shipping in Chrome, every Android device, etc. Citation. |
| Filament |
Anchored on GL-style NDC; the Vulkan backend uses VK_KHR_maintenance1 (negative-height viewport) to compensate at the driver level |
Conditional per backend, at draw time via the viewport |
Google's PBR engine; production in Android (Sceneform, Wear), etc. |
| bgfx |
App-side: HAL exposes bgfx::getCaps()->originBottomLeft / homogeneousDepth flags; the app's projection-matrix helper (bx::mtxProj(..., homogeneousDepth)) bakes the convention in; HAL unconditionally sets glFrontFace(GL_CW) on GL to match |
App authoring time |
Long-running cross-platform renderer; passes the burden to the application. |
| sokol_gfx |
App-side: HAL exposes sg_features.origin_top_left capability flag and documents the rule "if rendering to a texture for sampling, flip + swap winding direction"; everything else is up to the app |
App authoring time |
Used heavily in the indie / gamedev community; same shape as bgfx. |
Two clusters: HAL-absorbs-it (wgpu, ANGLE, Filament) and app-handles-it-via-caps (bgfx, sokol_gfx). Of the three that absorb it in the HAL, all three apply the compensation at the vertex output, not at texture sampling. Nobody else does what Impeller does today (per-sampler uniforms threaded through every sampling site).
Three of three Google-maintained HALs in this space (Dawn/Tint, ANGLE, Filament) absorb the work, with the only meaningful design choice being how (vertex-shader rewrite for ANGLE and Tint, a Vulkan extension for Filament). The pattern below is the ANGLE shape because it gives us the per-pass conditional control the Impeller use case actually needs.
Where Y-flip lives in Impeller today
| Layer |
File |
What |
| Core |
impeller/core/formats.h |
enum class TextureCoordinateSystem { kUploadFromHost, kRenderToTexture } |
| Core |
impeller/core/texture.h, texture.cc |
Texture::GetYCoordScale() virtual getter; base returns 1.0 |
| GLES backend |
impeller/renderer/backend/gles/texture_gles.cc |
TextureGLES::GetYCoordScale() returns -1.0 for kRenderToTexture, 1.0 for kUploadFromHost |
| GLES backend |
impeller/renderer/backend/gles/render_pass_gles.cc |
EncodeViewport flips Y (target_h - y - h) |
| GLES backend |
impeller/renderer/backend/gles/blit_command_gles.cc |
Calls FlipImage on byte-readback for kRenderToTexture sources; resets coord system on blit destinations |
| Entity contents |
impeller/entity/contents/texture_contents.cc |
Threads texture_sampler_y_coord_scale into texture_fill.vert (plus a < 0.0 branch in the snapshot path) |
| Entity contents |
impeller/entity/contents/tiled_texture_contents.cc |
Same pattern, into the tiled-texture shader |
| Entity contents |
impeller/entity/contents/vertices_contents.cc (2 sites) |
Same pattern, vertex-colored geometry |
| Entity contents |
impeller/entity/contents/atlas_contents.cc (4 sites) |
Same pattern across atlas, atlas-color, atlas-blend variants |
| Entity contents |
impeller/entity/contents/linear_gradient_contents.cc |
Gradient texture y-scale |
| Entity contents |
impeller/entity/contents/radial_gradient_contents.cc |
Gradient texture y-scale |
| Entity contents |
impeller/entity/contents/sweep_gradient_contents.cc |
Gradient texture y-scale |
| Entity contents |
impeller/entity/contents/conical_gradient_contents.cc |
Gradient texture y-scale |
| Entity contents |
impeller/entity/contents/framebuffer_blend_contents.cc |
src_y_coord_scale for the destination snapshot |
| Filters |
impeller/entity/contents/filters/color_matrix_filter_contents.cc |
Input snapshot y-scale |
| Filters |
impeller/entity/contents/filters/border_mask_blur_filter_contents.cc |
Input snapshot y-scale |
| Filters |
impeller/entity/contents/filters/morphology_filter_contents.cc |
Input snapshot y-scale |
| Filters |
impeller/entity/contents/filters/yuv_to_rgb_filter_contents.cc |
Input snapshot y-scale |
| Filters |
impeller/entity/contents/filters/srgb_to_linear_filter_contents.cc |
Input snapshot y-scale |
| Filters |
impeller/entity/contents/filters/linear_to_srgb_filter_contents.cc |
Input snapshot y-scale |
| Filters |
impeller/entity/contents/filters/gaussian_blur_filter_contents.cc |
Comment notes the < 0.0 semantics; blur direction depends on it |
| Shader source |
Various entity-content shaders |
Apply the flip in the shader: uv.y = y_coord_scale > 0.0 ? uv.y : 1.0 - uv.y |
| flutter_gpu (Dart) |
(nothing) |
No public yCoordScale getter; no backend query; not auto-applied. Callers re-invent the workaround. |
Roughly 20 sites threading the same value through the same pattern, with the per-shader application written by hand each time. Retiring them is the cleanup payoff of this proposal.
Bugs traceable to the current design
| Issue |
State |
Summary |
| #116901 |
closed Apr 2023 |
The original ask for a shader define for the Y coordinate scale. Closed in favour of the per-site uniform pattern this proposal now replaces. |
| #163315 |
closed Feb 2025 |
"[Impeller] GLES y axis adjustment can leak out of renderer when data is readback." A site that forgot to compensate. |
| #173162 |
closed Aug 2025 |
"DecorationImage with ColorFilter doesn't account for y-scale corrections in OpenGLES." Another site that forgot to compensate. |
(out of tree) flutter_scene bdero/renderer branch |
open |
A 3D renderer built on flutter_gpu landed up-side-down on GLES because the API has no way to apply the same fix Impeller uses internally; worked around with a doesSupportOffscreenMSAA heuristic and a flip_y uniform pumped through three shader paths. |
The pattern is the same each time: a new site samples a kRenderToTexture texture, nobody threads y_coord_scale, the result is upside-down on GLES. The cadence is roughly one a year inside Impeller and it has now started leaking into ecosystem code built on flutter_gpu. Removing the design takes the bug class out of the per-contributor-discipline category.
tl;dr: get rid of
GetYCoordScale()and handle the GLES NDC transformation internally, removing a hazardous abstraction leak that we don't need. Impeller internals, if you know you know.This is a sad landmine that has regularly exploded on us and will continue to do so until the end of time. Chinmay and I landed the current solution years ago after we saw it in a Unity doc because neither of us could think of anything better at the time and we had much bigger fish to fry.
We don't need to leak this GLES-specific backend eccentricity into Impeller's 2D renderer (or FragmentProgram shaders, or Flutter GPU).
First off, to be more efficient than what we're doing today: we can inject a single uniform for performing the conditional flip to
gl_Position.yin the vertex stage when the offscreen FBO itself is being rendered to, as opposed to Impeller's current solution of injecting one uniform per sampler in the fragment stage so that we can handle flipping offscreen textures that were already rendered upside-down. Then we just flip the winding order for the command in theRenderPass. This is the solution that wgpu/ANGLE/Filament all internally employ to great effect.So, we'd handle this detail internally within the GLES backend, hiding this eccentricity from Impeller's 2D renderer (and Flutter GPU) entirely, by:
impellerc& flippinggl_Position.ywith it:-1.0and flip the winding order.Note that GLES 2 only has room for 16 vec4s in the fragment stage and has 128 vec4s in the vertex stage. So we're going from taking up 1 uniform per sampler in a pretty constrained environment to only ever using 1 in an environment where there's plenty of bandwidth to go around. So we don't have to worry about the uniform bandwidth as much. 😀 Running out of uniforms in the fragment stage was a real problem for us back when some of the contents were being initially implemented.
What other HALs do today
Surveying the five HALs that most closely match Impeller's position:
gl_Position.yz = vec2(-gl_Position.y, gl_Position.z*2-gl_Position.w);injected by the naga GLSL writer, plusfront_faceswap (Cw <-> Ccw) in the GLES HAL with an explicit cross-referencing commentANGLETransformPosition(position)wrapper inserted by the translator, applying(swapXY ? position.yx : position.xy) * flipXYwhereflipXYandswapXYare driver uniforms;RotateAndFlipBuiltinVariableandFlipBuiltinVariablehandle SPIR-V and MSL paths respectivelyVK_KHR_maintenance1(negative-height viewport) to compensate at the driver levelbgfx::getCaps()->originBottomLeft/homogeneousDepthflags; the app's projection-matrix helper (bx::mtxProj(..., homogeneousDepth)) bakes the convention in; HAL unconditionally setsglFrontFace(GL_CW)on GL to matchsg_features.origin_top_leftcapability flag and documents the rule "if rendering to a texture for sampling, flip + swap winding direction"; everything else is up to the appTwo clusters: HAL-absorbs-it (wgpu, ANGLE, Filament) and app-handles-it-via-caps (bgfx, sokol_gfx). Of the three that absorb it in the HAL, all three apply the compensation at the vertex output, not at texture sampling. Nobody else does what Impeller does today (per-sampler uniforms threaded through every sampling site).
Three of three Google-maintained HALs in this space (Dawn/Tint, ANGLE, Filament) absorb the work, with the only meaningful design choice being how (vertex-shader rewrite for ANGLE and Tint, a Vulkan extension for Filament). The pattern below is the ANGLE shape because it gives us the per-pass conditional control the Impeller use case actually needs.
Where Y-flip lives in Impeller today
impeller/core/formats.henum class TextureCoordinateSystem { kUploadFromHost, kRenderToTexture }impeller/core/texture.h,texture.ccTexture::GetYCoordScale()virtual getter; base returns1.0impeller/renderer/backend/gles/texture_gles.ccTextureGLES::GetYCoordScale()returns-1.0forkRenderToTexture,1.0forkUploadFromHostimpeller/renderer/backend/gles/render_pass_gles.ccEncodeViewportflips Y (target_h - y - h)impeller/renderer/backend/gles/blit_command_gles.ccFlipImageon byte-readback forkRenderToTexturesources; resets coord system on blit destinationsimpeller/entity/contents/texture_contents.cctexture_sampler_y_coord_scaleintotexture_fill.vert(plus a< 0.0branch in the snapshot path)impeller/entity/contents/tiled_texture_contents.ccimpeller/entity/contents/vertices_contents.cc(2 sites)impeller/entity/contents/atlas_contents.cc(4 sites)impeller/entity/contents/linear_gradient_contents.ccimpeller/entity/contents/radial_gradient_contents.ccimpeller/entity/contents/sweep_gradient_contents.ccimpeller/entity/contents/conical_gradient_contents.ccimpeller/entity/contents/framebuffer_blend_contents.ccsrc_y_coord_scalefor the destination snapshotimpeller/entity/contents/filters/color_matrix_filter_contents.ccimpeller/entity/contents/filters/border_mask_blur_filter_contents.ccimpeller/entity/contents/filters/morphology_filter_contents.ccimpeller/entity/contents/filters/yuv_to_rgb_filter_contents.ccimpeller/entity/contents/filters/srgb_to_linear_filter_contents.ccimpeller/entity/contents/filters/linear_to_srgb_filter_contents.ccimpeller/entity/contents/filters/gaussian_blur_filter_contents.cc< 0.0semantics; blur direction depends on ituv.y = y_coord_scale > 0.0 ? uv.y : 1.0 - uv.yyCoordScalegetter; no backend query; not auto-applied. Callers re-invent the workaround.Roughly 20 sites threading the same value through the same pattern, with the per-shader application written by hand each time. Retiring them is the cleanup payoff of this proposal.
Bugs traceable to the current design
flutter_scenebdero/rendererbranchdoesSupportOffscreenMSAAheuristic and aflip_yuniform pumped through three shader paths.The pattern is the same each time: a new site samples a
kRenderToTexturetexture, nobody threadsy_coord_scale, the result is upside-down on GLES. The cadence is roughly one a year inside Impeller and it has now started leaking into ecosystem code built on flutter_gpu. Removing the design takes the bug class out of the per-contributor-discipline category.