Skip to content

[Impeller][Windows] Significantly slower startup compared to Skia #191860

Open

Description

@EgorKoschel

Steps to reproduce

Steps to reproduce

  1. Use Flutter 3.47.1 stable
  2. Clone the minimal reproduction project: https://github.com/EgorKoschel/flutter_impeller_windows_test
  3. Run the application in release mode with the default Impeller renderer:
    flutter run -d windows --release
  4. Observe the startup timing output.
  5. In windows/runner/main.cpp, uncomment the following line to disable Impeller and use Skia:
    project.set_impeller_switch(flutter::ImpellerSwitch::Disabled);
  6. Run exactly the same command again:
    flutter run -d windows --release
  7. Compare the startup timings.

The project is a minimal Windows-only Flutter application without third-party dependencies or application-specific initialization.

With Impeller enabled, almost all of the additional startup time is spent while constructing flutter::FlutterViewController, before plugin registration.

Impeller

[STARTUP] DartProject created: 1 ms
[STARTUP] Win32Window OnCreate completed: 3 ms
[STARTUP] Before FlutterViewController: 3 ms
[IMPORTANT:flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc(126)] Using the Impeller rendering backend (OpenGLESSDF).
[STARTUP] FlutterViewController created: 1186 ms
[STARTUP] Plugins registered: 1186 ms
[STARTUP] Flutter view attached: 1190 ms
[STARTUP] Window/Create completed: 1190 ms
[STARTUP] First Flutter frame: 1232 ms

Skia

[STARTUP] DartProject created: 1 ms
[STARTUP] Win32Window OnCreate completed: 4 ms
[STARTUP] Before FlutterViewController: 4 ms
[STARTUP] FlutterViewController created: 58 ms
[STARTUP] Plugins registered: 58 ms
[STARTUP] Flutter view attached: 69 ms
[STARTUP] Window/Create completed: 69 ms
[STARTUP] First Flutter frame: 165 ms

The FlutterViewController constructor therefore takes approximately:

  • Impeller: 1183 ms
  • Skia: 54 ms

The time to the first Flutter frame is approximately:

  • Impeller: 1232 ms
  • Skia: 165 ms

I repeated the test multiple times with both renderers. The results were consistent across repeated launches: Impeller consistently spent roughly 1 second or more constructing FlutterViewController, while Skia completed the same step in only a few dozen milliseconds. The large difference was reproducible and was not limited to the first launch.

Main channel

The issue is also reproducible on the current Flutter main channel, although the performance characteristics are different from Flutter 3.47.1 stable.

Impeller

[STARTUP] DartProject created: 1 ms
[STARTUP] Win32Window OnCreate completed: 3 ms
[STARTUP] Before FlutterViewController: 3 ms
[IMPORTANT:flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc(127)] Using the Impeller rendering backend (OpenGLESSDF).
[STARTUP] FlutterViewController created: 101 ms
[STARTUP] Plugins registered: 101 ms
[STARTUP] Flutter view attached: 113 ms
[STARTUP] Window/Create completed: 113 ms
[STARTUP] First Flutter frame: 618 ms

Skia

[STARTUP] DartProject created: 1 ms
[STARTUP] Win32Window OnCreate completed: 3 ms
[STARTUP] Before FlutterViewController: 3 ms
[STARTUP] FlutterViewController created: 47 ms
[STARTUP] Plugins registered: 47 ms
[STARTUP] Flutter view attached: 55 ms
[STARTUP] Window/Create completed: 55 ms
[STARTUP] First Flutter frame: 134 ms

On the main channel, FlutterViewController creation is significantly faster than on Flutter 3.47.1 stable. However, startup with Impeller is still substantially slower overall.

Time to first Flutter frame:

  • Impeller: 618 ms
  • Skia: 134 ms

Impeller therefore takes approximately 4.6x longer to reach the first Flutter frame in this run.

On Flutter 3.47.1 stable, most of the delay occurs while constructing FlutterViewController. On the current main channel, that part has improved substantially, but a significant startup delay remains after FlutterViewController has been created and before the first Flutter frame is rendered.

Code sample

Minimal reproduction:

https://github.com/EgorKoschel/flutter_impeller_windows_test

The relevant timing instrumentation is placed immediately around the FlutterViewController constructor:

  PrintStartupTime("Before FlutterViewController");

  flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
      frame.right - frame.left,
      frame.bottom - frame.top,
      project_);

  PrintStartupTime("FlutterViewController created");

Impeller is enabled by default.

For comparison with Skia, the reproduction project contains the following commented-out line in windows/runner/main.cpp:

flutter::DartProject project(L"data");

// Uncomment to disable Impeller and use Skia.
// project.set_impeller_switch(flutter::ImpellerSwitch::Disabled);

No other code is changed between the Impeller and Skia measurements.

Performance profiling on master channel

  • The issue still persists on the master channel

Timeline Traces

The observed delay occurs during native Windows engine initialization, inside the flutter::FlutterViewController constructor and before plugin registration or the first Flutter frame.

The C++ timing instrumentation in the reproduction project was used to isolate this native startup delay.

Video demonstration

What target platforms are you seeing this bug on?

Windows

OS/Browser name and version | Device information

Windows 11 Pro 25H2, x64
OS build: 26200

GPU: Intel(R) UHD Graphics 770
GPU driver version: 32.0.101.7088
GPU driver date: 2026-06-17

Does the problem occur on emulator/simulator as well as on physical devices?

Unknown

Is the problem only reproducible with Impeller?

Yes

Logs

Flutter Doctor output

[√] Flutter (Channel stable, 3.47.1, on Microsoft Windows [Version 10.0.26200.9168], locale de-DE) [233ms]
• Flutter version 3.47.1 on channel stable at C:\src\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 6655482 (8 days ago), 2026-08-19 10:07:23 -0700
• Engine revision 5d53178
• Dart version 3.13.1
• DevTools version 2.60.0
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[√] Windows Version (11 Pro 64-bit, 25H2, 2009) [1.253ms]

[√] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1.466ms]
• Android SDK at C:\Users\EgorKoschel\AppData\Local\Android\sdk
• Emulator version 35.4.9.0 (build_id 13025442) (CL:N/A)
• Platform android-36, build-tools 36.0.0
• Java binary at: C:\Program Files\Android\Android Studio\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: flutter config --jdk-dir="path/to/jdk".
• Java version OpenJDK Runtime Environment (build 17.0.6+0-b2043.56-9586694)
• All Android licenses accepted.

[√] Chrome - develop for the web [60ms]
• Chrome at C:\Users\EgorKoschel\AppData\Local\Google\Chrome\Application\chrome.exe

[√] Visual Studio - develop Windows apps (Visual Studio Community 2022 17.6.5) [59ms]
• Visual Studio at C:\Program Files\Microsoft Visual Studio\2022\Community
• Visual Studio Community 2022 version 17.6.33829.357
• Windows 10 SDK version 10.0.22000.0

[√] Connected device (3 available) [139ms]
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [Version 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.174
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.107

[√] Network resources [362ms]
• All expected network resources are available.

• No issues found!

Activity

  1. puneetkukreja98 commented on Aug 27, 2026

    @puneetkukreja98
    Contributor

    @EgorKoschel Thanks for reporting this, routing this to team-engine for further investigation.
    Findings:
    Impeller:

    Launching lib\main.dart on Windows in release mode...
    Building Windows application...                                    68.7s
    √ Built build\windows\x64\runner\Release\flutter_impeller_windows_test.exe
    
    Flutter run key commands.
    h List all available interactive commands.
    c Clear the screen
    q Quit (terminate the application on the device).
    
    [STARTUP] DartProject created: 3 ms
    [STARTUP] Win32Window OnCreate completed: 8 ms
    [STARTUP] Before FlutterViewController: 8 ms
    [IMPORTANT:flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc(126)] Using the Impeller rendering backend (OpenGLESSDF).
    [STARTUP] FlutterViewController created: 2168 ms
    [STARTUP] Plugins registered: 2168 ms
    [STARTUP] Flutter view attached: 2213 ms
    [STARTUP] Window/Create completed: 2213 ms
    [STARTUP] First Flutter frame: 2317 ms
    
    Application finished.
    PS C:\Google\flutter_impeller_windows_test> 

    Impeller-disabled:

    Launching lib\main.dart on Windows in release mode...
    Building Windows application...                                    13.9s
    √ Built build\windows\x64\runner\Release\flutter_impeller_windows_test.exe
    
    Flutter run key commands.
    h List all available interactive commands.
    c Clear the screen
    q Quit (terminate the application on the device).
    
    [STARTUP] DartProject created: 3 ms
    [STARTUP] Win32Window OnCreate completed: 8 ms
    [STARTUP] Before FlutterViewController: 8 ms
    [STARTUP] FlutterViewController created: 129 ms
    [STARTUP] Plugins registered: 130 ms
    [STARTUP] Flutter view attached: 156 ms
    [STARTUP] Window/Create completed: 156 ms
    [STARTUP] First Flutter frame: 323 ms
    
    Application finished.
    PS C:\Google\flutter_impeller_windows_test> 

    Flutter doctor -v:

    [√] Flutter (Channel stable, 3.47.1, on Microsoft Windows [Version 10.0.26200.9168], locale en-IN) [571ms]
        • Flutter version 3.47.1 on channel stable at C:\Developer\flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 6655482ec0 (8 days ago), 2026-08-19 10:07:23 -0700
        • Engine revision 5d53178869
        • Dart version 3.13.1
        • DevTools version 2.60.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android,
          enable-ios, cli-animations, enable-native-assets, enable-record-use, enable-swift-package-manager,
          omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
    
    [√] Windows Version (Windows 11 or higher, 25H2, 2009) [3.1s]
    
    [√] Android toolchain - develop for Android devices (Android SDK version 37.0.0) [6.0s]
        • Android SDK at C:\Users\Puneet\AppData\Local\Android\sdk
        • Emulator version 36.6.11.0 (build_id 15507667) (CL:N/A)
        • Platform android-37.1, build-tools 37.0.0
        • Java binary at: C:\Program Files\Android\Android Studio1\jbr\bin\java
          This is the JDK bundled with the latest Android Studio installation on this machine.
          To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
        • Java version OpenJDK Runtime Environment (build 21.0.10+-14961533-b1163.108)
        • All Android licenses accepted.
    
    [√] Chrome - develop for the web [204ms]
        • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
    
    [√] Visual Studio - develop Windows apps (Visual Studio Community 2026 18.3.2) [203ms]
        • Visual Studio at C:\Program Files\Microsoft Visual Studio\18\Community
        • Visual Studio Community 2026 version 18.3.11520.95
        • Windows 10 SDK version 10.0.26100.0
    
    [√] Connected device (3 available) [486ms]
        • Windows (desktop) • windows • windows-x64    • Microsoft Windows [Version 10.0.26200.9168]
        • Chrome (web)      • chrome  • web-javascript • Google Chrome 151.0.7922.172
        • Edge (web)        • edge    • web-javascript • Microsoft Edge 151.0.4129.107
    
    [√] Network resources [1,184ms]
        • All expected network resources are available.
    
    • No issues found!
  2. added
    engineflutter/engine related. See also e: labels.
    platform-windowsBuilding on or for Windows specifically
    e: impellerImpeller rendering backend issues and features requests
    and removed
    in triagePresently being triaged by the triage team
    on Aug 27, 2026
  3. krll-kov commented on Aug 27, 2026

    @krll-kov
    Contributor

    Same for me

  4. SugarFatFree commented on Aug 29, 2026

    @SugarFatFree

    Confirming this on a different machine, with a real application rather than a minimal repro: MarkText Plus, a Flutter desktop Markdown editor (window_manager, file_picker, printing, etc.).

    Time from process start to first frame, from the app's own startup trace, Windows release builds:

    Flutter Renderer Startup
    3.44.9 Skia (Impeller not available) 284–306 ms
    3.47.2 Impeller (default) 2130–3330 ms
    3.47.2 Skia (set_impeller_switch(ImpellerSwitch::Disabled)) 291–634 ms

    Two things this adds to the thread:

    The 3.44.9 baseline. Same machine, same app, a version predating Impeller-by-default on Windows. It lands on the same numbers as 3.47.2-with-Skia, which rules out a general 3.47 startup regression — the cost tracks the renderer, not the version.

    A trap for anyone else trying to A/B this. My first attempt switched renderers with the FLUTTER_ENGINE_SWITCHES / FLUTTER_ENGINE_SWITCH_n environment variables. In a release build those are ignored: GetSwitchesFromEnvironment is compiled out behind #ifndef FLUTTER_RELEASE, so both arms of the experiment run Impeller and the measured difference is noise. I concluded from that run that Impeller was not the cause, and was wrong. project.set_impeller_switch(...) — as used in the report above — is the only thing that works in a release build, and it reproduces the difference immediately.

    The machine is a virtualised GPU on a corporate Windows host, which likely explains why the absolute numbers here are worse than the 1232 ms / 165 ms in the original report while the ratio is comparable.

  5. gaaclarke commented on Aug 31, 2026

    @gaaclarke
    Member

    A certain amount of this is expected because we are compiling shaders at launch to avoid jank while the app is running. The opengles backend doesn't let us precompile shaders like we can for metal and vulkan. Still this is a really high number that we can try to investigate. Ultimately we might have to just rely on a vulkan/dx12 backend though.

  6. added
    P2Important issues not at the top of the work list
    on Aug 31, 2026
  7. tocreator commented on Sep 6, 2026

    @tocreator

    This isn't just about the startup time increasing to 3 or 4 seconds; the overall runtime performance in production has also degraded by roughly 20% to 35%.

  8. krll-kov commented on Sep 17, 2026

    @krll-kov
    Contributor

    A certain amount of this is expected because we are compiling shaders at launch to avoid jank while the app is running. The opengles backend doesn't let us precompile shaders like we can for metal and vulkan. Still this is a really high number that we can try to investigate. Ultimately we might have to just rely on a vulkan/dx12 backend though.

    I measured startup on real hardware and tried two changes in a local engine build. I'm sharing the numbers and have two questions: are there known problems with a GL program cache on Windows, and is there a reason Impeller does not rely on GL_KHR_parallel_shader_compile?

    How it was measured

    • Windows 11 Pro, AMD Ryzen AI 9 HX 370 with Radeon 890M (driver 32.0.31035.1003), 2560x1440 at 120 Hz.
    • Release builds, x64. The engine builds were made with VS 2022 Build Tools from e619960004d (master) and from 3.47.4. The app was built with the matching SDK.
    • The app below draws 8 FragmentProgram tiles. It reads the process creation time with GetProcessTimes and prints how long it took to reach main and until waitUntilFirstFrameRasterized completes. BW_SHADERS=0 runs the same app without shaders.
    • windows/runner/main.cpp picks the renderer from an environment variable, so both renderers run the same build:
    wchar_t value[16];
    if (::GetEnvironmentVariableW(L"BW_RENDERER", value, 16) > 0) {
      project.set_impeller_switch(std::wstring(value) == L"skia"
                                      ? flutter::ImpellerSwitch::Disabled
                                      : flutter::ImpellerSwitch::Enabled);
    }
    • Every configuration ran several times in alternation, and the tables show the median. The cache was deleted before each "cache empty" run.
    lib/main.dart
    import 'dart:ffi' hide Size;
    import 'dart:io';
    import 'dart:ui' as ui;
    
    import 'package:flutter/material.dart';
    import 'package:flutter/scheduler.dart';
    
    const shaderNames = [
      'raymarch',
      'clouds',
      'voronoi',
      'mandelbrot',
      'julia',
      'tunnel',
      'starfield',
      'caustics',
    ];
    
    final _kernel32 = DynamicLibrary.open('kernel32.dll');
    final _getCurrentProcess = _kernel32
        .lookupFunction<IntPtr Function(), int Function()>('GetCurrentProcess');
    final _getProcessTimes = _kernel32.lookupFunction<
        Int32 Function(IntPtr, Pointer<Uint64>, Pointer<Uint64>, Pointer<Uint64>,
            Pointer<Uint64>),
        int Function(int, Pointer<Uint64>, Pointer<Uint64>, Pointer<Uint64>,
            Pointer<Uint64>)>('GetProcessTimes');
    final _getSystemTime = _kernel32.lookupFunction<Void Function(Pointer<Uint64>),
        void Function(Pointer<Uint64>)>('GetSystemTimePreciseAsFileTime');
    final _localAlloc = _kernel32.lookupFunction<
        Pointer<Uint64> Function(Uint32, IntPtr),
        Pointer<Uint64> Function(int, int)>('LocalAlloc');
    final _times = _localAlloc(0x40, 40);
    
    /// Milliseconds since Windows created this process, so the time before `main`
    /// is included
    double sinceProcessStart() {
      _getProcessTimes(
          _getCurrentProcess(), _times, _times + 1, _times + 2, _times + 3);
      _getSystemTime(_times + 4);
      return (_times[4] - _times[0]) / 10000;
    }
    
    String ms(double v) => v.toStringAsFixed(1);
    
    Future<void> main() async {
      final tMain = sinceProcessStart();
      WidgetsFlutterBinding.ensureInitialized();
      final tBinding = sinceProcessStart();
      final count = int.tryParse(Platform.environment['BW_SHADERS'] ?? '') ??
          shaderNames.length;
      final programs = await Future.wait([
        for (final name in shaderNames.take(count))
          ui.FragmentProgram.fromAsset('shaders/$name.frag'),
      ]);
      final tLoaded = sinceProcessStart();
    
      var frames = 0;
      var raster = 0;
      var firstReported = false;
      final watch = Stopwatch();
      SchedulerBinding.instance.addTimingsCallback((timings) {
        for (final t in timings) {
          if (!firstReported) {
            firstReported = true;
            print('FIRSTFRAME build=${ms(t.buildDuration.inMicroseconds / 1000)} '
                'raster=${ms(t.rasterDuration.inMicroseconds / 1000)} '
                'total=${ms(t.totalSpan.inMicroseconds / 1000)}');
            watch.start();
            continue;
          }
          frames++;
          raster += t.rasterDuration.inMicroseconds;
        }
        if (watch.elapsedMilliseconds >= 1000 && frames > 0) {
          print('FPS ${(frames / watch.elapsedMilliseconds * 1000).toStringAsFixed(1)} '
              'raster ${ms(raster / frames / 1000)}ms');
          frames = 0;
          raster = 0;
          watch.reset();
        }
      });
    
      runApp(ShaderApp(programs));
      await WidgetsBinding.instance.waitUntilFirstFrameRasterized;
      final tFirst = sinceProcessStart();
      print('BENCH shaders=${programs.length} main=${ms(tMain)} '
          'binding=${ms(tBinding)} loaded=${ms(tLoaded)} first_frame=${ms(tFirst)}');
    }
    
    class ShaderApp extends StatelessWidget {
      const ShaderApp(this.programs, {super.key});
    
      final List<ui.FragmentProgram> programs;
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: ShaderGrid(programs),
        );
      }
    }
    
    class ShaderGrid extends StatefulWidget {
      const ShaderGrid(this.programs, {super.key});
    
      final List<ui.FragmentProgram> programs;
    
      @override
      State<ShaderGrid> createState() => _ShaderGridState();
    }
    
    class _ShaderGridState extends State<ShaderGrid>
        with SingleTickerProviderStateMixin {
      late final Ticker _ticker;
      late final List<ui.FragmentShader> _shaders = [
        for (final p in widget.programs) p.fragmentShader(),
      ];
      final ValueNotifier<double> _time = ValueNotifier(0);
    
      @override
      void initState() {
        super.initState();
        _ticker = createTicker((elapsed) {
          _time.value = elapsed.inMicroseconds / 1e6;
        })
          ..start();
      }
    
      @override
      void dispose() {
        _ticker.dispose();
        _time.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        final tiles = [
          for (final s in _shaders)
            Expanded(child: CustomPaint(painter: _ShaderPainter(s, _time))),
        ];
        return Scaffold(
          backgroundColor: Colors.black,
          body: tiles.isEmpty
              ? const Center(child: Text('no shaders'))
              : Column(children: [
                  Expanded(child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: tiles.take(4).toList())),
                  Expanded(child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: tiles.skip(4).toList())),
                ]),
        );
      }
    }
    
    class _ShaderPainter extends CustomPainter {
      _ShaderPainter(this.shader, this.time) : super(repaint: time);
    
      final ui.FragmentShader shader;
      final ValueNotifier<double> time;
    
      @override
      void paint(Canvas canvas, Size size) {
        shader
          ..setFloat(0, size.width)
          ..setFloat(1, size.height)
          ..setFloat(2, time.value);
        canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
      }
    
      @override
      bool shouldRepaint(_ShaderPainter old) => false;
    }
    pubspec.yaml (flutter section) and the 8 shaders
    flutter:
      uses-material-design: true
      shaders:
        - shaders/raymarch.frag
        - shaders/clouds.frag
        - shaders/voronoi.frag
        - shaders/mandelbrot.frag
        - shaders/julia.frag
        - shaders/tunnel.frag
        - shaders/starfield.frag
        - shaders/caustics.frag

    shaders/raymarch.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    float smin(float a, float b, float k) {
      float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
      return mix(b, a, h) - k * h * (1.0 - h);
    }
    
    float sdBox(vec3 p, vec3 b) {
      vec3 q = abs(p) - b;
      return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
    }
    
    float sdTorus(vec3 p, vec2 t) {
      vec2 q = vec2(length(p.xz) - t.x, p.y);
      return length(q) - t.y;
    }
    
    float map(vec3 p) {
      float t = uTime;
      float d = p.y + 1.0;
      float s1 = length(p - vec3(sin(t) * 1.2, 0.2, cos(t) * 1.2)) - 0.6;
      float s2 = length(p - vec3(cos(t * 1.3), 0.5 + sin(t * 0.7) * 0.4, sin(t * 1.1))) - 0.5;
      float b = sdBox(p - vec3(0.0, -0.3, 0.0), vec3(0.5, 0.5, 0.5)) - 0.05;
      float to = sdTorus(p - vec3(0.0, 0.3, 0.0), vec2(1.4, 0.15));
      d = smin(d, s1, 0.4);
      d = smin(d, s2, 0.4);
      d = smin(d, b, 0.3);
      d = smin(d, to, 0.2);
      return d;
    }
    
    vec3 normalAt(vec3 p) {
      vec2 e = vec2(0.001, 0.0);
      return normalize(vec3(map(p + e.xyy) - map(p - e.xyy),
                            map(p + e.yxy) - map(p - e.yxy),
                            map(p + e.yyx) - map(p - e.yyx)));
    }
    
    float softShadow(vec3 ro, vec3 rd) {
      float res = 1.0;
      float t = 0.02;
      for (int i = 0; i < 24; i++) {
        float h = map(ro + rd * t);
        res = min(res, 8.0 * h / t);
        t += clamp(h, 0.02, 0.2);
      }
      return clamp(res, 0.0, 1.0);
    }
    
    float ambientOcclusion(vec3 p, vec3 n) {
      float occ = 0.0;
      float sca = 1.0;
      for (int i = 0; i < 5; i++) {
        float h = 0.01 + 0.12 * float(i);
        occ += (h - map(p + n * h)) * sca;
        sca *= 0.95;
      }
      return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
    }
    
    void main() {
      vec2 uv = (FlutterFragCoord().xy - 0.5 * uSize) / uSize.y;
      uv.y = -uv.y;
      vec3 ro = vec3(3.5 * sin(uTime * 0.2), 1.5, 3.5 * cos(uTime * 0.2));
      vec3 ta = vec3(0.0);
      vec3 ww = normalize(ta - ro);
      vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0)));
      vec3 vv = cross(uu, ww);
      vec3 rd = normalize(uv.x * uu + uv.y * vv + 1.5 * ww);
      float t = 0.0;
      float hit = 0.0;
      for (int i = 0; i < 96; i++) {
        float d = map(ro + rd * t);
        hit = max(hit, step(d, 0.001) * step(t, 20.0));
        t += d * (1.0 - hit);
      }
      vec3 col = vec3(0.6, 0.75, 0.95) - rd.y * 0.4;
      if (hit > 0.5) {
        vec3 p = ro + rd * t;
        vec3 n = normalAt(p);
        vec3 l = normalize(vec3(0.6, 0.8, 0.4));
        float dif = clamp(dot(n, l), 0.0, 1.0) * softShadow(p, l);
        float spe = pow(clamp(dot(reflect(rd, n), l), 0.0, 1.0), 32.0);
        float ao = ambientOcclusion(p, n);
        vec3 base = 0.5 + 0.5 * cos(vec3(0.0, 2.0, 4.0) + p.y * 2.0 + uTime);
        col = base * (0.2 * ao + dif) + spe;
        col = mix(col, vec3(0.6, 0.75, 0.95), 1.0 - exp(-0.02 * t * t));
      }
      fragColor = vec4(pow(col, vec3(0.4545)), 1.0);
    }

    shaders/clouds.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    float hash(vec2 p) {
      return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
    }
    
    float noise(vec2 p) {
      vec2 i = floor(p);
      vec2 f = fract(p);
      vec2 u = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
                 mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
    }
    
    float fbm(vec2 p) {
      float v = 0.0;
      float a = 0.5;
      mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
      for (int i = 0; i < 8; i++) {
        v += a * noise(p);
        p = m * p;
        a *= 0.5;
      }
      return v;
    }
    
    void main() {
      vec2 uv = FlutterFragCoord().xy / uSize.y;
      float t = uTime * 0.1;
      vec2 q = vec2(fbm(uv * 3.0 + t), fbm(uv * 3.0 + vec2(5.2, 1.3) - t));
      vec2 r = vec2(fbm(uv * 3.0 + 4.0 * q + vec2(1.7, 9.2) + 0.15 * uTime),
                    fbm(uv * 3.0 + 4.0 * q + vec2(8.3, 2.8) + 0.126 * uTime));
      float f = fbm(uv * 3.0 + 4.0 * r);
      vec3 col = mix(vec3(0.1, 0.3, 0.6), vec3(0.95, 0.95, 1.0), clamp(f * f * 4.0, 0.0, 1.0));
      col = mix(col, vec3(0.0, 0.1, 0.3), clamp(length(q), 0.0, 1.0) * 0.5);
      col = mix(col, vec3(1.0, 0.8, 0.6), clamp(r.x, 0.0, 1.0) * 0.3);
      fragColor = vec4(col * (f * f * f + 0.6 * f * f + 0.5 * f), 1.0);
    }

    shaders/voronoi.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    vec2 hash2(vec2 p) {
      p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
      return fract(sin(p) * 43758.5453);
    }
    
    vec3 voronoi(vec2 x) {
      vec2 n = floor(x);
      vec2 f = fract(x);
      vec2 mg = vec2(0.0);
      vec2 mr = vec2(0.0);
      float md = 8.0;
      for (int j = -1; j <= 1; j++) {
        for (int i = -1; i <= 1; i++) {
          vec2 g = vec2(float(i), float(j));
          vec2 o = 0.5 + 0.5 * sin(uTime + 6.2831 * hash2(n + g));
          vec2 r = g + o - f;
          float d = dot(r, r);
          float closer = step(d, md);
          md = mix(md, d, closer);
          mr = mix(mr, r, closer);
          mg = mix(mg, g, closer);
        }
      }
      md = 8.0;
      for (int j = -2; j <= 2; j++) {
        for (int i = -2; i <= 2; i++) {
          vec2 g = mg + vec2(float(i), float(j));
          vec2 o = 0.5 + 0.5 * sin(uTime + 6.2831 * hash2(n + g));
          vec2 r = g + o - f;
          float valid = step(0.00001, dot(mr - r, mr - r));
          float e = dot(0.5 * (mr + r), normalize(r - mr + 0.00001));
          md = mix(md, min(md, e), valid);
        }
      }
      return vec3(md, mr);
    }
    
    void main() {
      vec2 p = FlutterFragCoord().xy / uSize.y;
      vec3 c = voronoi(8.0 * p);
      vec3 c2 = voronoi(20.0 * p + 3.0);
      vec3 col = c.x * (0.5 + 0.5 * sin(64.0 * c.x)) * vec3(1.0);
      col = mix(vec3(1.0, 0.6, 0.0), col, smoothstep(0.04, 0.07, c.x));
      col *= 0.7 + 0.3 * smoothstep(0.02, 0.05, c2.x);
      float dd = length(c.yz);
      col = mix(vec3(1.0, 0.6, 0.1), col, smoothstep(0.0, 0.12, dd));
      fragColor = vec4(col, 1.0);
    }

    shaders/mandelbrot.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    void main() {
      vec3 col = vec3(0.0);
      // 2x2 supersampling
      for (int m = 0; m < 2; m++) {
        for (int n = 0; n < 2; n++) {
          vec2 p = (2.0 * (FlutterFragCoord().xy + vec2(float(m), float(n)) / 2.0) - uSize) / uSize.y;
          float zoom = 0.62 + 0.38 * cos(0.07 * uTime);
          float coa = cos(0.15 * (1.0 - zoom) * uTime);
          float sia = sin(0.15 * (1.0 - zoom) * uTime);
          zoom = pow(zoom, 8.0);
          vec2 xy = vec2(p.x * coa - p.y * sia, p.x * sia + p.y * coa);
          vec2 c = vec2(-0.745, 0.186) + xy * zoom;
          vec2 z = vec2(0.0);
          float l = 0.0;
          float escaped = 0.0;
          for (int i = 0; i < 256; i++) {
            z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
            float out_now = step(64.0, dot(z, z)) * (1.0 - escaped);
            escaped += out_now;
            l += 1.0 - escaped;
          }
          float sl = l - log2(log2(max(dot(z, z), 1.0001))) + 4.0;
          l = mix(l, sl, escaped);
          col += (0.5 + 0.5 * cos(3.0 + l * 0.15 + vec3(0.0, 0.6, 1.0))) * escaped;
        }
      }
      fragColor = vec4(col / 4.0, 1.0);
    }

    shaders/julia.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    void main() {
      vec2 p = (2.0 * FlutterFragCoord().xy - uSize) / uSize.y * 1.3;
      vec2 c = vec2(-0.8 + 0.1 * sin(uTime * 0.3), 0.156 + 0.05 * cos(uTime * 0.23));
      vec2 z = p;
      float trapPoint = 1e10;
      float trapLine = 1e10;
      float escaped = 0.0;
      float it = 0.0;
      for (int i = 0; i < 200; i++) {
        z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
        float alive = 1.0 - escaped;
        trapPoint = mix(trapPoint, min(trapPoint, dot(z - vec2(0.3, 0.2), z - vec2(0.3, 0.2))), alive);
        trapLine = mix(trapLine, min(trapLine, abs(z.y + 0.5 * sin(z.x * 3.0))), alive);
        escaped = max(escaped, step(16.0, dot(z, z)));
        it += 1.0 - escaped;
      }
      vec3 col = 0.5 + 0.5 * cos(vec3(0.0, 0.4, 0.8) + it * 0.05 + uTime * 0.2);
      col = mix(col, vec3(1.0, 0.8, 0.3), exp(-8.0 * trapPoint));
      col = mix(col, vec3(0.2, 0.6, 1.0), exp(-20.0 * trapLine));
      fragColor = vec4(col, 1.0);
    }

    shaders/tunnel.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    float hash(vec3 p) {
      p = fract(p * 0.3183099 + 0.1);
      p *= 17.0;
      return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
    }
    
    float noise(vec3 x) {
      vec3 i = floor(x);
      vec3 f = fract(x);
      f = f * f * (3.0 - 2.0 * f);
      return mix(mix(mix(hash(i + vec3(0, 0, 0)), hash(i + vec3(1, 0, 0)), f.x),
                     mix(hash(i + vec3(0, 1, 0)), hash(i + vec3(1, 1, 0)), f.x), f.y),
                 mix(mix(hash(i + vec3(0, 0, 1)), hash(i + vec3(1, 0, 1)), f.x),
                     mix(hash(i + vec3(0, 1, 1)), hash(i + vec3(1, 1, 1)), f.x), f.y), f.z);
    }
    
    float fbm(vec3 p) {
      float v = 0.0;
      float a = 0.5;
      for (int i = 0; i < 6; i++) {
        v += a * noise(p);
        p = p * 2.03 + vec3(1.7, 9.2, 3.1);
        a *= 0.5;
      }
      return v;
    }
    
    void main() {
      vec2 p = (2.0 * FlutterFragCoord().xy - uSize) / uSize.y;
      float r = length(p);
      float a = atan(p.y, p.x);
      vec3 q = vec3(cos(a) * 2.0, sin(a) * 2.0, 1.0 / r + uTime);
      float f = fbm(q);
      float g = fbm(q * 2.0 + f);
      vec3 col = mix(vec3(0.1, 0.0, 0.2), vec3(1.0, 0.4, 0.1), f);
      col = mix(col, vec3(0.2, 0.8, 1.0), g * g);
      col *= smoothstep(0.0, 0.6, r);
      fragColor = vec4(col, 1.0);
    }

    shaders/starfield.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    float hash(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * 0.1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
    }
    
    vec3 starLayer(vec2 uv, float seed) {
      vec3 col = vec3(0.0);
      vec2 gv = fract(uv) - 0.5;
      vec2 id = floor(uv);
      for (int y = -1; y <= 1; y++) {
        for (int x = -1; x <= 1; x++) {
          vec2 offs = vec2(float(x), float(y));
          float n = hash(id + offs + seed);
          float size = fract(n * 345.32);
          vec2 d = gv - offs - vec2(n, fract(n * 34.0)) + 0.5;
          float star = 0.05 / length(d) * size;
          float rays = max(0.0, 1.0 - abs(d.x * d.y * 1000.0)) * size;
          star += rays * 0.3;
          star *= smoothstep(1.0, 0.2, length(d));
          vec3 color = sin(vec3(0.2, 0.3, 0.9) * fract(n * 2345.2) * 123.2) * 0.5 + 0.5;
          col += star * color * (sin(uTime * 3.0 + n * 6.2831) * 0.5 + 1.0);
        }
      }
      return col;
    }
    
    void main() {
      vec2 uv = (FlutterFragCoord().xy - 0.5 * uSize) / uSize.y;
      float t = uTime * 0.05;
      float s = sin(t);
      float c = cos(t);
      uv = mat2(c, -s, s, c) * uv;
      vec3 col = vec3(0.0);
      for (int i = 0; i < 8; i++) {
        float depth = fract(float(i) / 8.0 + t);
        float scale = mix(20.0, 0.5, depth);
        float fade = depth * smoothstep(1.0, 0.9, depth);
        col += starLayer(uv * scale + float(i) * 453.2, float(i)) * fade;
      }
      fragColor = vec4(pow(col, vec3(0.4545)), 1.0);
    }

    shaders/caustics.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    void main() {
      float time = uTime * 0.5 + 23.0;
      vec2 uv = FlutterFragCoord().xy / uSize;
      vec2 p = mod(uv * 6.28318, 6.28318) - 250.0;
      vec2 i = p;
      float c = 1.0;
      float inten = 0.005;
      for (int n = 0; n < 12; n++) {
        float t = time * (1.0 - (3.5 / float(n + 1)));
        i = p + vec2(cos(t - i.x) + sin(t + i.y), sin(t - i.y) + cos(t + i.x));
        c += 1.0 / length(vec2(p.x / (sin(i.x + t) / inten), p.y / (cos(i.y + t) / inten)));
      }
      c /= 12.0;
      c = 1.17 - pow(c, 1.4);
      vec3 col = vec3(pow(abs(c), 8.0));
      col = clamp(col + vec3(0.0, 0.35, 0.5), 0.0, 1.0);
      float ripple = 0.0;
      for (int k = 0; k < 16; k++) {
        float fk = float(k);
        vec2 center = vec2(fract(sin(fk * 12.9898) * 43758.5), fract(sin(fk * 78.233) * 43758.5));
        float d = length(uv - center);
        ripple += sin(d * 60.0 - uTime * 4.0 + fk) * exp(-d * 8.0);
      }
      fragColor = vec4(col + ripple * 0.03, 1.0);
    }

    3.47.4, median of 5 runs

    Renderer main First frame
    Skia 150 ms 1303 ms
    Impeller 1994 ms 3090 ms

    A build with timestamps shows that Impeller links 80 GL programs in this app, 2.76 s in total. On 3.47.4 most of them are linked while ContentContext is constructed, before the root isolate starts: CreateDefault asks for async pipelines, but ReactorGLES::AddOperation reacts at once on the thread that holds the context.

    master e619960004d, median of 3 runs

    Build main First frame
    official, Skia 151 ms 1316 ms
    official, Impeller 184 ms 2613 ms
    local build, Impeller 171 ms 2229 ms

    The local build also has timestamp logging and some unrelated local patches, and without the experiments it was 15% faster than the official build; I did not look into why. With #185723 main starts as early as with Skia. The first frame still waits for the programs, which the IO thread now links one at a time: 79 programs, 2.97 s in total.

    Experiment 1: ANGLE program cache

    The Windows embedder calls eglSetBlobCacheFuncsANDROID after eglInitialize. ANGLE then stores its compiled programs as files, here in %LOCALAPPDATA%\FlutterAngleCache\<hash of the exe path>. Nothing else changes.

    Build main First frame Programs
    3.47.4, cache empty 2209 ms 3386 ms 80 written
    3.47.4, cache filled 392 ms 487 ms 80 loaded, 239 ms
    master, cache empty 174 ms 2372 ms 79 written
    master, cache filled 162 ms 328 ms 79 loaded, 269 ms

    Skia made no calls to the cache in these runs; I did not look into why.

    Diff against e619960
    --- a/engine/src/flutter/shell/pla
    +++ b/engine/src/flutter/shell/pla
    @@ -4,6 +4,8 @@
     
     #include "flutter/shell/platform/windows/egl/manager.h"
     
    +#include <cstring>
    +#include <string>
     #include <vector>
     
     #include "flutter/fml/logging.h"
    @@ -12,6 +14,126 @@
     namespace flutter {
     namespace egl {
     
    +namespace {
    +
    +// ANGLE compiles every GL program with the D3D compiler. The blob cache
    +// stores the compiled programs on disk, so the next launch loads them
    +// instead of compiling.
    +std::wstring& BlobCacheDirectory() {
    +  static std::wstring directory;
    +  return directory;
    +}
    +
    +std::wstring BlobCachePath(const void* key, EGLsizeiANDROID key_size) {
    +  // 64-bit FNV-1a of the key. The file also stores the key, so a collision
    +  // reads as a miss.
    +  uint64_t hash = 14695981039346656037ull;
    +  const auto* bytes = static_cast<const uint8_t*>(key);
    +  for (EGLsizeiANDROID i = 0; i < key_size; i++) {
    +    hash = (hash ^ bytes[i]) * 1099511628211ull;
    +  }
    +  wchar_t name[17];
    +  swprintf(name, 17, L"%016llx", static_cast<unsigned long long>(hash));
    +  return BlobCacheDirectory() + L"\\" + name;
    +}
    +
    +void SetBlob(const void* key,
    +             EGLsizeiANDROID key_size,
    +             const void* value,
    +             EGLsizeiANDROID value_size) {
    +  std::wstring path = BlobCachePath(key, key_size);
    +  std::wstring temp = path + L"." + std::to_wstring(::GetCurrentThreadId());
    +  HANDLE file = ::CreateFileW(temp.c_str(), GENERIC_WRITE, 0, nullptr,
    +                              CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
    +  if (file == INVALID_HANDLE_VALUE) {
    +    return;
    +  }
    +  const uint32_t stored_key_size = static_cast<uint32_t>(key_size);
    +  DWORD written = 0;
    +  bool ok = ::WriteFile(file, &stored_key_size, sizeof(stored_key_size),
    +                        &written, nullptr) &&
    +            ::WriteFile(file, key, static_cast<DWORD>(key_size), &written,
    +                        nullptr) &&
    +            ::WriteFile(file, value, static_cast<DWORD>(value_size), &written,
    +                        nullptr);
    +  ::CloseHandle(file);
    +  if (!ok || !::MoveFileExW(temp.c_str(), path.c_str(),
    +                            MOVEFILE_REPLACE_EXISTING)) {
    +    ::DeleteFileW(temp.c_str());
    +    return;
    +  }
    +}
    +
    +EGLsizeiANDROID GetBlob(const void* key,
    +                        EGLsizeiANDROID key_size,
    +                        void* value,
    +                        EGLsizeiANDROID value_size) {
    +  std::wstring path = BlobCachePath(key, key_size);
    +  HANDLE file = ::CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ,
    +                              nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL,
    +                              nullptr);
    +  if (file == INVALID_HANDLE_VALUE) {
    +    return 0;
    +  }
    +  LARGE_INTEGER file_size;
    +  std::vector<uint8_t> data;
    +  DWORD read = 0;
    +  if (::GetFileSizeEx(file, &file_size) && file_size.QuadPart > 4) {
    +    data.resize(static_cast<size_t>(file_size.QuadPart));
    +    if (!::ReadFile(file, data.data(), static_cast<DWORD>(data.size()), &read,
    +                    nullptr)) {
    +      read = 0;
    +    }
    +  }
    +  ::CloseHandle(file);
    +  if (read != data.size() || data.size() < 4) {
    +    return 0;
    +  }
    +  uint32_t stored_key_size = 0;
    +  memcpy(&stored_key_size, data.data(), sizeof(stored_key_size));
    +  size_t header = sizeof(stored_key_size) + stored_key_size;
    +  if (stored_key_size != static_cast<uint32_t>(key_size) ||
    +      data.size() < header ||
    +      memcmp(data.data() + sizeof(stored_key_size), key, key_size) != 0) {
    +    return 0;
    +  }
    +  const EGLsizeiANDROID stored_value_size =
    +      static_cast<EGLsizeiANDROID>(data.size() - header);
    +  if (value != nullptr && value_size >= stored_value_size) {
    +    memcpy(value, data.data() + header, stored_value_size);
    +  }
    +  return stored_value_size;
    +}
    +
    +void InitializeBlobCache(EGLDisplay display) {
    +  const char* extensions = ::eglQueryString(display, EGL_EXTENSIONS);
    +  if (extensions == nullptr ||
    +      strstr(extensions, "EGL_ANDROID_blob_cache") == nullptr) {
    +    return;
    +  }
    +  wchar_t local_app_data[MAX_PATH];
    +  wchar_t exe_path[MAX_PATH];
    +  if (::GetEnvironmentVariableW(L"LOCALAPPDATA", local_app_data, MAX_PATH) ==
    +          0 ||
    +      ::GetModuleFileNameW(nullptr, exe_path, MAX_PATH) == 0) {
    +    return;
    +  }
    +  // One directory per executable path
    +  uint64_t hash = 14695981039346656037ull;
    +  for (const wchar_t* c = exe_path; *c; c++) {
    +    hash = (hash ^ static_cast<uint16_t>(*c)) * 1099511628211ull;
    +  }
    +  wchar_t name[17];
    +  swprintf(name, 17, L"%016llx", static_cast<unsigned long long>(hash));
    +  std::wstring root = std::wstring(local_app_data) + L"\\FlutterAngleCache";
    +  ::CreateDirectoryW(root.c_str(), nullptr);
    +  BlobCacheDirectory() = root + L"\\" + name;
    +  ::CreateDirectoryW(BlobCacheDirectory().c_str(), nullptr);
    +  ::eglSetBlobCacheFuncsANDROID(display, SetBlob, GetBlob);
    +}
    +
    +}  // namespace
    +
     int Manager::instance_count_ = 0;
     
     std::unique_ptr<Manager> Manager::Create(GpuPreference gpu_preference) {
    @@ -178,6 +300,7 @@
           continue;
         }
     
    +    InitializeBlobCache(display_);
         return true;
       }

    Experiment 2: parallel link

    ANGLE on D3D11 reports GL_KHR_parallel_shader_compile, and its default thread count is the maximum. LinkProgram in pipeline_library_gles.cc queries the compile and link status right after each call, so each program waits for the previous one. In the experiment the IO thread first calls glCompileShader and glLinkProgram for every queued pipeline, then queries the results in separate tasks. glDetachShader and glDeleteShader move after the status query, since ANGLE waited for the link on those calls.

    Build main First frame
    master, parallel link 316 ms 1584 ms
    master, parallel link + cache empty 286 ms 1619 ms

    The first launch is about 30% faster. main starts 70-150 ms later because the batch occupies the IO thread. The 8 FragmentProgram shaders do not go through the compile queue, so they still link one at a time (1.02 s).

    Diff against e619960
    --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc
    @@ -7,6 +7,7 @@
     #include "flutter/fml/logging.h"
     #include "flutter/fml/trace_event.h"
     #include "impeller/base/validation.h"
    +#include "impeller/renderer/backend/gles/pipeline_library_gles.h"
     
     namespace impeller {
     
    @@ -56,7 +57,21 @@
       PostJob([weak_queue = weak_from_this()]() {
         if (auto queue = std::static_pointer_cast<PipelineCompileQueueGLES>(
                 weak_queue.lock())) {
    -      queue->DoOneJob();
    +      if (LabParallelLinkEnabled()) {
    +        LabBeginLinkBatch();
    +        while (true) {
    +          {
    +            Lock lock(queue->processing_mutex_);
    +            if (!queue->HasPendingJobs()) {
    +              break;
    +            }
    +          }
    +          queue->DoOneJob();
    +        }
    +        LabEndLinkBatch([&queue](const fml::closure& job) { queue->PostJob(job); });
    +      } else {
    +        queue->DoOneJob();
    +      }
           {
             Lock lock(queue->processing_mutex_);
             if (!queue->HasPendingJobs()) {
    --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc
    @@ -10,12 +10,56 @@
     #include "flutter/fml/trace_event.h"
     #include "fml/closure.h"
     #include "impeller/base/promise.h"
    +#include <cstdlib>
    +#include <utility>
    +#include <vector>
     #include "impeller/renderer/backend/gles/pipeline_gles.h"
     #include "impeller/renderer/backend/gles/shader_function_gles.h"
     #include "impeller/renderer/pipeline_descriptor.h"
     
     namespace impeller {
     
    +namespace {
    +
    +// Lab only: FLUTTER_LAB_PARALLEL_LINK=1 starts the compile and link of every
    +// queued program before any status query, so ANGLE links them on its worker
    +// threads, and queries the results afterwards.
    +thread_local bool lab_batch_active = false;
    +thread_local bool lab_defer_request = false;
    +thread_local std::vector<std::pair<std::shared_ptr<ReactorGLES>, fml::closure>>
    +    lab_finishes;
    +// Shaders of the last deferred link, detached and deleted after the status query
    +thread_local GLuint lab_last_program = 0;
    +thread_local GLuint lab_last_vert = 0;
    +thread_local GLuint lab_last_frag = 0;
    +
    +}  // namespace
    +
    +bool LabParallelLinkEnabled() {
    +  static const bool enabled = [] {
    +    const char* value = std::getenv("FLUTTER_LAB_PARALLEL_LINK");
    +    return value != nullptr && value[0] == '1';
    +  }();
    +  return enabled;
    +}
    +
    +void LabBeginLinkBatch() {
    +  lab_batch_active = true;
    +}
    +
    +void LabEndLinkBatch(const std::function<void(const fml::closure&)>& post) {
    +  lab_batch_active = false;
    +  auto items = std::move(lab_finishes);
    +  lab_finishes.clear();
    +  // One task per pipeline, so other IO thread tasks run in between
    +  for (auto& item : items) {
    +    post([reactor = item.first, finish = item.second]() {
    +      [[maybe_unused]] bool added = reactor->AddOperation(
    +          [finish](const ReactorGLES&) { finish(); });
    +    });
    +  }
    +}
    +
     PipelineLibraryGLES::PipelineLibraryGLES(
         std::shared_ptr<ReactorGLES> reactor,
         std::shared_ptr<fml::BasicTaskRunner> io_task_runner)
    @@ -79,6 +123,7 @@
     }
     
     static bool LinkProgram(
    +    bool defer_status,
         const ReactorGLES& reactor,
         const std::shared_ptr<PipelineGLES>& pipeline,
         const std::shared_ptr<const ShaderFunction>& vert_function,
    @@ -120,11 +165,13 @@
       gl.CompileShader(vert_shader);
       gl.CompileShader(frag_shader);
     
    -  GLint vert_status = GL_FALSE;
    -  GLint frag_status = GL_FALSE;
    +  GLint vert_status = GL_TRUE;
    +  GLint frag_status = GL_TRUE;
     
    -  gl.GetShaderiv(vert_shader, GL_COMPILE_STATUS, &vert_status);
    -  gl.GetShaderiv(frag_shader, GL_COMPILE_STATUS, &frag_status);
    +  if (!defer_status) {
    +    gl.GetShaderiv(vert_shader, GL_COMPILE_STATUS, &vert_status);
    +    gl.GetShaderiv(frag_shader, GL_COMPILE_STATUS, &frag_status);
    +  }
     
       if (vert_status != GL_TRUE) {
         LogShaderCompilationFailure(gl, vert_shader, descriptor.GetLabel(),
    @@ -165,6 +212,17 @@
       }
     
       gl.LinkProgram(*program);
    +  if (defer_status) {
    +    // Detach and delete after the status query, ANGLE waits for the link here
    +    delete_vert_shader.Release();
    +    delete_frag_shader.Release();
    +    detach_vert_shader.Release();
    +    detach_frag_shader.Release();
    +    lab_last_program = *program;
    +    lab_last_vert = vert_shader;
    +    lab_last_frag = frag_shader;
    +    return true;
    +  }
     
       GLint link_status = GL_FALSE;
       gl.GetProgramiv(*program, GL_LINK_STATUS, &link_status);
    @@ -238,7 +296,7 @@
         return nullptr;
       }
     
    -  const auto link_result = !has_cached_program ? LinkProgram(*reactor,       //
    +  const auto link_result = !has_cached_program ? LinkProgram(lab_defer_request, *reactor,       //
                                                                  pipeline,       //
                                                                  vert_function,  //
                                                                  frag_function   //
    @@ -250,6 +308,13 @@
         return nullptr;
       }
     
    +  if (lab_defer_request) {
    +    if (!has_cached_program) {
    +      library.SetProgramForKey(program_key, pipeline->GetSharedHandle());
    +    }
    +    return pipeline;
    +  }
    +
       if (!pipeline->BuildVertexDescriptor(reactor->GetProcTable(),
                                            program.value())) {
         VALIDATION_LOG << "Could not build pipeline vertex descriptors.";
    @@ -314,8 +379,49 @@
                                                    descriptor,     //
                                                    vert_function,  //
                                                    frag_function,  //
    -                                               threadsafe      //
    +                                               threadsafe, lab_reactor = reactor //
         ](const ReactorGLES& reactor) {
    +      if (lab_batch_active) {
    +        lab_defer_request = true;
    +        lab_last_program = 0;
    +        lab_last_vert = 0;
    +        lab_last_frag = 0;
    +        std::shared_ptr<PipelineGLES> pipeline = CreatePipeline(
    +            weak_this, descriptor, vert_function, frag_function, threadsafe);
    +        lab_defer_request = false;
    +        if (!pipeline) {
    +          promise->set_value(nullptr);
    +          return;
    +        }
    +        const GLuint lab_prog = lab_last_program;
    +        const GLuint lab_vs = lab_last_vert;
    +        const GLuint lab_fs = lab_last_frag;
    +        lab_finishes.emplace_back(lab_reactor, [promise, pipeline, lab_reactor, lab_prog, lab_vs, lab_fs]() {
    +          const auto& gl = lab_reactor->GetProcTable();
    +          auto program = lab_reactor->GetGLHandle(pipeline->GetProgramHandle());
    +          GLint link_status = GL_FALSE;
    +          if (program.has_value()) {
    +            gl.GetProgramiv(*program, GL_LINK_STATUS, &link_status);
    +          }
    +          if (lab_vs != 0) {
    +            gl.DetachShader(lab_prog, lab_vs);
    +            gl.DeleteShader(lab_vs);
    +          }
    +          if (lab_fs != 0) {
    +            gl.DetachShader(lab_prog, lab_fs);
    +            gl.DeleteShader(lab_fs);
    +          }
    +          if (link_status != GL_TRUE ||
    +              !pipeline->BuildVertexDescriptor(gl, program.value()) ||
    +              !pipeline->IsValid()) {
    +            VALIDATION_LOG << "Lab: deferred pipeline failed";
    +            promise->set_value(nullptr);
    +            return;
    +          }
    +          promise->set_value(pipeline);
    +        });
    +        return;
    +      }
           promise->set_value(CreatePipeline(weak_this, descriptor, vert_function,
                                             frag_function, threadsafe));
         });
    --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h
    @@ -22,6 +22,11 @@
     class ContextGLES;
     class PipelineGLES;
     
    +// Experiment: FLUTTER_LAB_PARALLEL_LINK=1
    +bool LabParallelLinkEnabled();
    +void LabBeginLinkBatch();
    +void LabEndLinkBatch(const std::function<void(const std::function<void()>&)>& post);
    +
     class PipelineLibraryGLES final
         : public PipelineLibrary,
           public BackendCast<PipelineLibraryGLES, PipelineLibrary> {

    Questions

    • Was a GL program cache for ANGLE considered for Windows, and are there known problems with it (stale or corrupted files, driver updates, where the files should go)? fml::paths::GetCachesDirectory() is not implemented on Windows, and Add embedder API to enable the Vulkan pipeline cache #186374 asks for a similar setting for the Vulkan pipeline cache.
    • Is there a reason not to use GL_KHR_parallel_shader_compile in the GLES backend, for example driver problems on Android?
  9. gaaclarke commented on Sep 17, 2026

    @gaaclarke
    Member

    Thanks @krll-kov both of those sound very promising.

    Questions

    • Was a GL program cache for ANGLE considered for Windows, and are there known problems with it (stale or corrupted files, driver updates, where the files should go)? fml::paths::GetCachesDirectory() is not implemented on Windows, and Add embedder API to enable the Vulkan pipeline cache #186374 asks for a similar setting for the Vulkan pipeline cache.
    • Is there a reason not to use GL_KHR_parallel_shader_compile in the GLES backend, for example driver problems on Android?

    First, there is no technical reason we can't use these techniques. As to why they haven't been used yet, it's a bit of an interesting story that I'll share. The history of the OpenGLES Impeller renderer started on Android where GL_KHR_parallel_shader_compile wouldn't have even been available. The OpenGLES renderer on Android was a legacy renderer, where the future was in Vulkan. That means an hour spent on Vulkan optimization was more valuable than an hour spent on OpenGLES optimization.

    The calculus changed when we shipped for Windows though, OpenGLES has been promoted to a first tier renderer. So, we are playing catch up on performance there. We have recently explored promoting Vulkan on Windows, but that's probably not happening in the foreseeable future.

    FWIW Vulkan's startup times are not as bad to push us to look into the cache yet. ANGLE is particularly bad for startup time because the shaders are in a higher level format and they need to be cross compiled to HLSL by ANGLE. So there is a lot of potential value there.

    Here is the benchmark for windows startup time, if you want to see officially what we use to measure it: https://flutter-flutter-perf.luci.app/e/?begin=1789574716&enable_chart_tooltip=true&end=1789661116&plotSummary=true&queries=test%3Dflutter_gallery_win_desktop__start_up&use_titles=true

  10. krll-kov commented on Sep 17, 2026

    @krll-kov
    Contributor

    Thanks @krll-kov both of those sound very promising.

    Questions

    • Was a GL program cache for ANGLE considered for Windows, and are there known problems with it (stale or corrupted files, driver updates, where the files should go)? fml::paths::GetCachesDirectory() is not implemented on Windows, and Add embedder API to enable the Vulkan pipeline cache #186374 asks for a similar setting for the Vulkan pipeline cache.
    • Is there a reason not to use GL_KHR_parallel_shader_compile in the GLES backend, for example driver problems on Android?

    First, there is no technical reason we can't use these techniques. As to why they haven't been used yet, it's a bit of an interesting story that I'll share. The history of the OpenGLES Impeller renderer started on Android where GL_KHR_parallel_shader_compile wouldn't have even been available. The OpenGLES renderer on Android was a legacy renderer, where the future was in Vulkan. That means an hour spent on Vulkan optimization was more valuable than an hour spent on OpenGLES optimization.

    The calculus changed when we shipped for Windows though, OpenGLES has been promoted to a first tier renderer. So, we are playing catch up on performance there. We have recently explored promoting Vulkan on Windows, but that's probably not happening in the foreseeable future.

    FWIW Vulkan's startup times are not as bad to push us to look into the cache yet. ANGLE is particularly bad for startup time because the shaders are in a higher level format and they need to be cross compiled to HLSL by ANGLE. So there is a lot of potential value there.

    Here is the benchmark for windows startup time, if you want to see officially what we use to measure it: https://flutter-flutter-perf.luci.app/e/?begin=1789574716&enable_chart_tooltip=true&end=1789661116&plotSummary=true&queries=test%3Dflutter_gallery_win_desktop__start_up&use_titles=true

    Thanks for the benchmark! I've tried to go further with the GL_KHR_parallel_shader_compile approach, which I like better for reasons explained beneath and here are my results with reworked code: Not only it became faster, it also removed significant junk on the first frame with example from my previous message, where multiple shaders are rendered on screen

    The change stays in the GLES backend. PipelineLibraryGLES splits a program link in two: a compile queue job starts the shaders and glLinkProgram without querying any status, and a later job on the same task runner checks the statuses. It runs only when the driver reports GL_KHR_parallel_shader_compile and DescriptionGLES::IsANGLE(), only for async requests, and only inside a compile queue job. Everything else, including the synchronous path and any other driver, keeps the current sequence of GL calls.

    Full diff, 6 files, +651 −33
    diff --git a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc
    index b895544..19b1aec 100644
    --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc
    @@ -4,12 +4,27 @@
     
     #include "impeller/renderer/backend/gles/pipeline_compile_queue_gles.h"
     
    +#include <atomic>
    +
     #include "flutter/fml/logging.h"
     #include "flutter/fml/trace_event.h"
     #include "impeller/base/validation.h"
     
     namespace impeller {
     
    +namespace {
    +thread_local bool tls_running_job = false;
    +std::atomic<int> g_running_jobs = 0;
    +}  // namespace
    +
    +int PipelineCompileQueueGLES::RunningJobCount() {
    +  return g_running_jobs.load();
    +}
    +
    +bool PipelineCompileQueueGLES::IsRunningJobOnCurrentThread() {
    +  return tls_running_job;
    +}
    +
     std::shared_ptr<PipelineCompileQueueGLES> PipelineCompileQueueGLES::Create(
         std::shared_ptr<fml::BasicTaskRunner> worker_task_runner) {
       if (!worker_task_runner) {
    @@ -49,7 +64,34 @@ void PipelineCompileQueueGLES::PostJob(const fml::closure& job) {
         return;
       }
     
    -  worker_task_runner_->PostTask(job);
    +  worker_task_runner_->PostTask([job]() {
    +    tls_running_job = true;
    +    g_running_jobs++;
    +    job();
    +    g_running_jobs--;
    +    tls_running_job = false;
    +  });
    +}
    +
    +bool PipelineCompileQueueGLES::IsProcessingJobs() {
    +  Lock lock(processing_mutex_);
    +  return is_processing_;
    +}
    +
    +void PipelineCompileQueueGLES::SetOnDrained(fml::closure on_drained) {
    +  Lock lock(on_drained_mutex_);
    +  on_drained_ = std::move(on_drained);
    +}
    +
    +void PipelineCompileQueueGLES::NotifyDrained() {
    +  fml::closure on_drained;
    +  {
    +    Lock lock(on_drained_mutex_);
    +    on_drained = on_drained_;
    +  }
    +  if (on_drained) {
    +    on_drained();
    +  }
     }
     
     void PipelineCompileQueueGLES::DrainPendingJobs() {
    @@ -57,13 +99,18 @@ void PipelineCompileQueueGLES::DrainPendingJobs() {
         if (auto queue = std::static_pointer_cast<PipelineCompileQueueGLES>(
                 weak_queue.lock())) {
           queue->DoOneJob();
    +      bool drained = false;
           {
             Lock lock(queue->processing_mutex_);
             if (!queue->HasPendingJobs()) {
               queue->is_processing_ = false;
    -          return;
    +          drained = true;
             }
           }
    +      if (drained) {
    +        queue->NotifyDrained();
    +        return;
    +      }
           queue->DrainPendingJobs();
         }
       });
    diff --git a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.h
    index cf013d6..2235da4 100644
    --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.h
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.h
    @@ -48,14 +48,52 @@ class PipelineCompileQueueGLES : public PipelineCompileQueue {
     
       void OnJobAdded() override;
     
    +  //----------------------------------------------------------------------------
    +  /// @brief      Returns true if the current thread is running a job that a
    +  ///             PipelineCompileQueueGLES posted to its worker task runner.
    +  ///
    +  ///             PipelineLibraryGLES starts a link in one job and checks it in
    +  ///             a later job on the same task runner, so it defers a link only
    +  ///             when this returns true.
    +  ///
    +  static bool IsRunningJobOnCurrentThread();
    +
    +  //----------------------------------------------------------------------------
    +  /// @brief      Returns how many jobs of any queue run right now.
    +  ///
    +  ///             The GL context belongs to one thread at a time, so
    +  ///             PipelineLibraryGLES checks in debug builds that its work
    +  ///             never runs next to another job.
    +  ///
    +  static int RunningJobCount();
    +
    +  //----------------------------------------------------------------------------
    +  /// @brief      Returns true while the queue works through its jobs.
    +  ///
    +  bool IsProcessingJobs();
    +
    +  //----------------------------------------------------------------------------
    +  /// @brief      Sets the callback that runs on the worker task runner once
    +  ///             the queue has no jobs left. Passing nullptr clears it.
    +  ///
    +  ///             PipelineLibraryGLES checks the links it started from this
    +  ///             callback, so the driver gets every queued program before the
    +  ///             first status query waits for one.
    +  ///
    +  void SetOnDrained(fml::closure on_drained);
    +
      private:
       explicit PipelineCompileQueueGLES(
           std::shared_ptr<fml::BasicTaskRunner> worker_task_runner);
       void DrainPendingJobs();
     
    +  void NotifyDrained();
    +
       std::shared_ptr<fml::BasicTaskRunner> worker_task_runner_;
       Mutex processing_mutex_;
       bool is_processing_ IPLR_GUARDED_BY(processing_mutex_) = false;
    +  Mutex on_drained_mutex_;
    +  fml::closure on_drained_ IPLR_GUARDED_BY(on_drained_mutex_);
     };
     
     }  // namespace impeller
    diff --git a/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_compile_queue_gles_unittests.cc b/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_compile_queue_gles_unittests.cc
    index ff726fa..caa5912 100644
    --- a/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_compile_queue_gles_unittests.cc
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_compile_queue_gles_unittests.cc
    @@ -9,6 +9,8 @@
     #include <vector>
     
     #include "flutter/fml/synchronization/count_down_latch.h"
    +#include "flutter/fml/synchronization/waitable_event.h"
    +#include "flutter/fml/time/time_delta.h"
     #include "flutter/fml/task_runner.h"
     #include "flutter/fml/task_runner_util.h"
     #include "flutter/fml/thread.h"
    @@ -195,5 +197,93 @@ TEST(PipelineCompileQueueGLESTest, DestroyQueueWithPendingTasks) {
       thread.Join();
     }
     
    +TEST(PipelineCompileQueueGLESTest, ReportsProcessingAndCallsOnDrained) {
    +  fml::Thread thread;
    +  auto queue = PipelineCompileQueueGLES::Create(CreateBasicTaskRunner(thread));
    +  ASSERT_NE(queue, nullptr);
    +
    +  fml::AutoResetWaitableEvent block_first_job;
    +  fml::CountDownLatch first_job_started(1);
    +  fml::AutoResetWaitableEvent drained;
    +  std::atomic<int> drained_count{0};
    +  std::atomic<bool> processing_while_running{false};
    +
    +  queue->SetOnDrained([&]() {
    +    drained_count++;
    +    drained.Signal();
    +  });
    +
    +  EXPECT_FALSE(queue->IsProcessingJobs());
    +
    +  PipelineDescriptor desc1;
    +  desc1.SetCullMode(CullMode::kNone);
    +  PipelineDescriptor desc2;
    +  desc2.SetCullMode(CullMode::kFrontFace);
    +
    +  queue->PostJobForDescriptor(desc1, [&]() {
    +    first_job_started.CountDown();
    +    block_first_job.Wait();
    +  });
    +  first_job_started.Wait();
    +  processing_while_running = queue->IsProcessingJobs();
    +
    +  queue->PostJobForDescriptor(desc2, [&]() {});
    +  block_first_job.Signal();
    +  // A broken callback would hang the test without the timeout.
    +  const bool timed_out =
    +      drained.WaitWithTimeout(fml::TimeDelta::FromSeconds(10));
    +  EXPECT_FALSE(timed_out);
    +
    +  EXPECT_TRUE(processing_while_running);
    +  EXPECT_EQ(drained_count, 1);
    +  EXPECT_FALSE(queue->IsProcessingJobs());
    +
    +  // A cleared callback is not called again.
    +  queue->SetOnDrained(nullptr);
    +  fml::CountDownLatch last_job(1);
    +  queue->PostJobForDescriptor(PipelineDescriptor{},
    +                              [&]() { last_job.CountDown(); });
    +  last_job.Wait();
    +  EXPECT_EQ(drained_count, 1);
    +
    +  thread.Join();
    +}
    +
    +TEST(PipelineCompileQueueGLESTest, ReportsRunningJobOnlyInsideJobs) {
    +  fml::Thread thread;
    +  auto queue = PipelineCompileQueueGLES::Create(CreateBasicTaskRunner(thread));
    +  ASSERT_NE(queue, nullptr);
    +
    +  std::atomic<bool> in_posted_job{false};
    +  std::atomic<bool> in_descriptor_job{false};
    +  std::atomic<bool> in_plain_task{true};
    +  fml::CountDownLatch latch(2);
    +
    +  queue->PostJob([&]() {
    +    in_posted_job = PipelineCompileQueueGLES::IsRunningJobOnCurrentThread();
    +    latch.CountDown();
    +  });
    +  queue->PostJobForDescriptor(PipelineDescriptor{}, [&]() {
    +    in_descriptor_job = PipelineCompileQueueGLES::IsRunningJobOnCurrentThread();
    +    latch.CountDown();
    +  });
    +  latch.Wait();
    +
    +  // A task posted to the same thread without the queue is not a job.
    +  fml::CountDownLatch plain_latch(1);
    +  thread.GetTaskRunner()->PostTask([&]() {
    +    in_plain_task = PipelineCompileQueueGLES::IsRunningJobOnCurrentThread();
    +    plain_latch.CountDown();
    +  });
    +  plain_latch.Wait();
    +
    +  EXPECT_TRUE(in_posted_job);
    +  EXPECT_TRUE(in_descriptor_job);
    +  EXPECT_FALSE(in_plain_task);
    +  EXPECT_FALSE(PipelineCompileQueueGLES::IsRunningJobOnCurrentThread());
    +
    +  thread.Join();
    +}
    +
     }  // namespace testing
     }  // namespace impeller
    diff --git a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc
    index f5643ee..3783f2f 100644
    --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc
    @@ -4,24 +4,56 @@
     
     #include "impeller/renderer/backend/gles/pipeline_library_gles.h"
     
    +#include <algorithm>
     #include <sstream>
     #include <string>
    +#include <thread>
    +#include <vector>
     
    +#include "flutter/fml/logging.h"
     #include "flutter/fml/trace_event.h"
     #include "fml/closure.h"
     #include "impeller/base/promise.h"
    +#include "impeller/renderer/backend/gles/description_gles.h"
     #include "impeller/renderer/backend/gles/pipeline_gles.h"
     #include "impeller/renderer/backend/gles/shader_function_gles.h"
     #include "impeller/renderer/pipeline_descriptor.h"
     
     namespace impeller {
     
    +static const constexpr char* kParallelShaderCompileExt =
    +    "GL_KHR_parallel_shader_compile";
    +
    +// How many links may run at the same time. Every link occupies a thread of
    +// the driver, so starting all of them at once takes the cores that the Dart
    +// isolate needs while it starts. This is the bound that
    +// ContextVK::ChooseThreadCountForWorkers uses for the same reason, see
    +// https://github.com/flutter/flutter/issues/143540
    +static size_t MaxPendingLinks() {
    +  return std::clamp<size_t>(std::thread::hardware_concurrency() / 2, 1, 4);
    +}
    +
    +// Only ANGLE is enabled for now. It cross compiles every program to HLSL,
    +// which is what makes a link expensive, and it is the only GLES driver the
    +// change was measured on.
    +static bool HasParallelShaderCompile(
    +    const std::shared_ptr<ReactorGLES>& reactor) {
    +  if (!reactor) {
    +    return false;
    +  }
    +  const DescriptionGLES* description = reactor->GetProcTable().GetDescription();
    +  return description != nullptr && description->IsANGLE() &&
    +         description->HasExtension(kParallelShaderCompileExt);
    +}
    +
     PipelineLibraryGLES::PipelineLibraryGLES(
         std::shared_ptr<ReactorGLES> reactor,
         std::shared_ptr<fml::BasicTaskRunner> io_task_runner)
         : reactor_(std::move(reactor)),
           compile_queue_(
    -          PipelineCompileQueueGLES::Create(std::move(io_task_runner))) {}
    +          PipelineCompileQueueGLES::Create(std::move(io_task_runner))),
    +      supports_parallel_shader_compile_(HasParallelShaderCompile(reactor_)),
    +      max_pending_links_(MaxPendingLinks()) {}
     
     static std::string GetShaderInfoLog(const ProcTableGLES& gl, GLuint shader) {
       GLint log_length = 0;
    @@ -78,11 +110,58 @@ static void LogShaderCompilationFailure(const ProcTableGLES& gl,
       VALIDATION_LOG << stream.str();
     }
     
    -static bool LinkProgram(
    +static bool CheckCompileStatus(
    +    const ProcTableGLES& gl,
    +    const PipelineDescriptor& descriptor,
    +    GLuint vert_shader,
    +    GLuint frag_shader,
    +    const std::shared_ptr<const ShaderFunction>& vert_function,
    +    const std::shared_ptr<const ShaderFunction>& frag_function) {
    +  GLint vert_status = GL_FALSE;
    +  GLint frag_status = GL_FALSE;
    +
    +  gl.GetShaderiv(vert_shader, GL_COMPILE_STATUS, &vert_status);
    +  gl.GetShaderiv(frag_shader, GL_COMPILE_STATUS, &frag_status);
    +
    +  if (vert_status != GL_TRUE) {
    +    LogShaderCompilationFailure(
    +        gl, vert_shader, descriptor.GetLabel(),
    +        *ShaderFunctionGLES::Cast(*vert_function).GetSourceMapping(),
    +        ShaderStage::kVertex);
    +    return false;
    +  }
    +
    +  if (frag_status != GL_TRUE) {
    +    LogShaderCompilationFailure(
    +        gl, frag_shader, descriptor.GetLabel(),
    +        *ShaderFunctionGLES::Cast(*frag_function).GetSourceMapping(),
    +        ShaderStage::kFragment);
    +    return false;
    +  }
    +  return true;
    +}
    +
    +// Shaders stay attached to their program until FinishProgramLink checks the
    +// link status.
    +struct ProgramShaders {
    +  GLuint vert = 0;
    +  GLuint frag = 0;
    +};
    +
    +// Compiles both shaders and starts the program link.
    +//
    +// With GL_KHR_parallel_shader_compile, a status query waits for the compiler,
    +// so a deferred link leaves every status query to FinishProgramLink. A
    +// deferred link also flags the shaders for deletion right away. GL frees them
    +// when they are detached or when the program is deleted, so they do not leak
    +// if FinishProgramLink never runs.
    +static bool StartProgramLink(
         const ReactorGLES& reactor,
         const std::shared_ptr<PipelineGLES>& pipeline,
         const std::shared_ptr<const ShaderFunction>& vert_function,
    -    const std::shared_ptr<const ShaderFunction>& frag_function) {
    +    const std::shared_ptr<const ShaderFunction>& frag_function,
    +    bool deferred,
    +    ProgramShaders* shaders) {
       TRACE_EVENT0("impeller", __FUNCTION__);
     
       const auto& descriptor = pipeline->GetDescriptor();
    @@ -120,21 +199,8 @@ static bool LinkProgram(
       gl.CompileShader(vert_shader);
       gl.CompileShader(frag_shader);
     
    -  GLint vert_status = GL_FALSE;
    -  GLint frag_status = GL_FALSE;
    -
    -  gl.GetShaderiv(vert_shader, GL_COMPILE_STATUS, &vert_status);
    -  gl.GetShaderiv(frag_shader, GL_COMPILE_STATUS, &frag_status);
    -
    -  if (vert_status != GL_TRUE) {
    -    LogShaderCompilationFailure(gl, vert_shader, descriptor.GetLabel(),
    -                                *vert_mapping, ShaderStage::kVertex);
    -    return false;
    -  }
    -
    -  if (frag_status != GL_TRUE) {
    -    LogShaderCompilationFailure(gl, frag_shader, descriptor.GetLabel(),
    -                                *frag_mapping, ShaderStage::kFragment);
    +  if (!deferred && !CheckCompileStatus(gl, descriptor, vert_shader, frag_shader,
    +                                       vert_function, frag_function)) {
         return false;
       }
     
    @@ -147,6 +213,60 @@ static bool LinkProgram(
       gl.AttachShader(*program, vert_shader);
       gl.AttachShader(*program, frag_shader);
     
    +  for (const auto& stage_input :
    +       descriptor.GetVertexDescriptor()->GetStageInputs()) {
    +    gl.BindAttribLocation(*program,                                   //
    +                          static_cast<GLuint>(stage_input.location),  //
    +                          stage_input.name                            //
    +    );
    +  }
    +
    +  gl.LinkProgram(*program);
    +
    +  if (deferred) {
    +    gl.DeleteShader(vert_shader);
    +    gl.DeleteShader(frag_shader);
    +  }
    +  delete_vert_shader.Release();
    +  delete_frag_shader.Release();
    +  shaders->vert = vert_shader;
    +  shaders->frag = frag_shader;
    +  return true;
    +}
    +
    +// Checks the result of StartProgramLink, then detaches the shaders. A shader
    +// of a deferred link is already flagged for deletion, so detaching frees it.
    +static bool FinishProgramLink(
    +    const ReactorGLES& reactor,
    +    const std::shared_ptr<PipelineGLES>& pipeline,
    +    const std::shared_ptr<const ShaderFunction>& vert_function,
    +    const std::shared_ptr<const ShaderFunction>& frag_function,
    +    const ProgramShaders& shaders,
    +    bool deferred) {
    +  TRACE_EVENT0("impeller", __FUNCTION__);
    +
    +  const auto& descriptor = pipeline->GetDescriptor();
    +  const auto& gl = reactor.GetProcTable();
    +  const GLuint vert_shader = shaders.vert;
    +  const GLuint frag_shader = shaders.frag;
    +
    +  fml::ScopedCleanupClosure delete_vert_shader([&gl, vert_shader, deferred]() {
    +    if (!deferred) {
    +      gl.DeleteShader(vert_shader);
    +    }
    +  });
    +  fml::ScopedCleanupClosure delete_frag_shader([&gl, frag_shader, deferred]() {
    +    if (!deferred) {
    +      gl.DeleteShader(frag_shader);
    +    }
    +  });
    +
    +  auto program = reactor.GetGLHandle(pipeline->GetProgramHandle());
    +  if (!program.has_value()) {
    +    VALIDATION_LOG << "Could not get program handle from reactor.";
    +    return false;
    +  }
    +
       fml::ScopedCleanupClosure detach_vert_shader(
           [&gl, program = *program, vert_shader]() {
             gl.DetachShader(program, vert_shader);
    @@ -156,16 +276,11 @@ static bool LinkProgram(
             gl.DetachShader(program, frag_shader);
           });
     
    -  for (const auto& stage_input :
    -       descriptor.GetVertexDescriptor()->GetStageInputs()) {
    -    gl.BindAttribLocation(*program,                                   //
    -                          static_cast<GLuint>(stage_input.location),  //
    -                          stage_input.name                            //
    -    );
    +  if (deferred && !CheckCompileStatus(gl, descriptor, vert_shader, frag_shader,
    +                                      vert_function, frag_function)) {
    +    return false;
       }
     
    -  gl.LinkProgram(*program);
    -
       GLint link_status = GL_FALSE;
       gl.GetProgramiv(*program, GL_LINK_STATUS, &link_status);
     
    @@ -180,6 +295,31 @@ static bool LinkProgram(
       return true;
     }
     
    +static bool LinkProgram(
    +    const ReactorGLES& reactor,
    +    const std::shared_ptr<PipelineGLES>& pipeline,
    +    const std::shared_ptr<const ShaderFunction>& vert_function,
    +    const std::shared_ptr<const ShaderFunction>& frag_function) {
    +  ProgramShaders shaders;
    +  if (!StartProgramLink(reactor, pipeline, vert_function, frag_function,
    +                        /*deferred=*/false, &shaders)) {
    +    return false;
    +  }
    +  return FinishProgramLink(reactor, pipeline, vert_function, frag_function,
    +                           shaders, /*deferred=*/false);
    +}
    +
    +static bool IsProgramLinked(const ReactorGLES& reactor,
    +                            const PipelineGLES& pipeline) {
    +  auto program = reactor.GetGLHandle(pipeline.GetProgramHandle());
    +  if (!program.has_value()) {
    +    return false;
    +  }
    +  GLint link_status = GL_FALSE;
    +  reactor.GetProcTable().GetProgramiv(*program, GL_LINK_STATUS, &link_status);
    +  return link_status == GL_TRUE;
    +}
    +
     // |PipelineLibrary|
     bool PipelineLibraryGLES::IsValid() const {
       return reactor_ != nullptr;
    @@ -190,7 +330,8 @@ std::shared_ptr<PipelineGLES> PipelineLibraryGLES::CreatePipeline(
         const PipelineDescriptor& desc,
         const std::shared_ptr<const ShaderFunction>& vert_function,
         const std::shared_ptr<const ShaderFunction>& frag_function,
    -    bool threadsafe) {
    +    bool threadsafe,
    +    std::shared_ptr<PipelinePromise> deferred_promise) {
       auto strong_library = weak_library.lock();
     
       if (!strong_library) {
    @@ -238,6 +379,39 @@ std::shared_ptr<PipelineGLES> PipelineLibraryGLES::CreatePipeline(
         return nullptr;
       }
     
    +  if (deferred_promise) {
    +    ProgramShaders shaders;
    +    if (!has_cached_program) {
    +      if (!StartProgramLink(*reactor, pipeline, vert_function, frag_function,
    +                            /*deferred=*/true, &shaders)) {
    +        VALIDATION_LOG << "Could not link pipeline program.";
    +        return nullptr;
    +      }
    +      // Other variants of this program reuse it while it links.
    +      library.SetProgramForKey(program_key, pipeline->GetSharedHandle());
    +    }
    +    library.WatchQueueForDrain();
    +    size_t pending_count = 0;
    +    {
    +      Lock lock(library.pending_mutex_);
    +      library.pending_pipelines_.push_back(PendingPipeline{
    +          .pipeline = pipeline,
    +          .promise = std::move(deferred_promise),
    +          .program_key = std::move(program_key),
    +          .vert_shader = shaders.vert,
    +          .frag_shader = shaders.frag,
    +      });
    +      pending_count = library.pending_pipelines_.size();
    +    }
    +    // The queue tells the library when it runs out of jobs. Without more jobs
    +    // coming, nothing would, so the links are checked here instead.
    +    if (pending_count >= library.max_pending_links_ ||
    +        !library.compile_queue_->IsProcessingJobs()) {
    +      library.FinishPendingPipelines(*reactor);
    +    }
    +    return pipeline;
    +  }
    +
       const auto link_result = !has_cached_program ? LinkProgram(*reactor,       //
                                                                  pipeline,       //
                                                                  vert_function,  //
    @@ -250,6 +424,14 @@ std::shared_ptr<PipelineGLES> PipelineLibraryGLES::CreatePipeline(
         return nullptr;
       }
     
    +  // A cached program can still be linking on another thread. The status
    +  // query waits for that link.
    +  if (has_cached_program && library.SupportsParallelShaderCompile() &&
    +      !IsProgramLinked(*reactor, *pipeline)) {
    +    VALIDATION_LOG << "Could not link pipeline program.";
    +    return nullptr;
    +  }
    +
       if (!pipeline->BuildVertexDescriptor(reactor->GetProcTable(),
                                            program.value())) {
         VALIDATION_LOG << "Could not build pipeline vertex descriptors.";
    @@ -268,6 +450,111 @@ std::shared_ptr<PipelineGLES> PipelineLibraryGLES::CreatePipeline(
       return pipeline;
     }
     
    +void PipelineLibraryGLES::WatchQueueForDrain() {
    +  {
    +    Lock lock(pending_mutex_);
    +    if (is_watching_queue_) {
    +      return;
    +    }
    +    is_watching_queue_ = true;
    +  }
    +  // The callback runs on the same task runner as the compile jobs, which runs
    +  // them one at a time, so a check never runs next to a link on another thread.
    +  std::weak_ptr<PipelineLibrary> weak_this = weak_from_this();
    +  compile_queue_->SetOnDrained([weak_this]() {
    +    auto thiz = weak_this.lock();
    +    if (!thiz) {
    +      return;
    +    }
    +    auto& library = PipelineLibraryGLES::Cast(*thiz);
    +    {
    +      Lock lock(library.pending_mutex_);
    +      if (library.pending_pipelines_.empty()) {
    +        return;
    +      }
    +    }
    +    const bool result =
    +        library.reactor_->AddOperation([weak_this](const ReactorGLES& reactor) {
    +          if (auto library = weak_this.lock()) {
    +            PipelineLibraryGLES::Cast(*library).FinishPendingPipelines(reactor);
    +          }
    +        });
    +    if (!result) {
    +      // The reactor is gone, so no thread can check the links anymore.
    +      library.FailPendingPipelines();
    +    }
    +  });
    +}
    +
    +void PipelineLibraryGLES::FinishPendingPipelines(const ReactorGLES& reactor) {
    +  TRACE_EVENT0("impeller", __FUNCTION__);
    +  // The compile queue runs its jobs one at a time, which is what keeps this
    +  // work off the thread that starts a link.
    +  FML_DCHECK(PipelineCompileQueueGLES::RunningJobCount() <= 1);
    +
    +  std::vector<PendingPipeline> pending;
    +  {
    +    Lock lock(pending_mutex_);
    +    pending.swap(pending_pipelines_);
    +  }
    +
    +  for (auto& item : pending) {
    +    item.promise->set_value(FinishPipeline(reactor, item));
    +  }
    +}
    +
    +std::shared_ptr<PipelineGLES> PipelineLibraryGLES::FinishPipeline(
    +    const ReactorGLES& reactor,
    +    const PendingPipeline& item) {
    +  const auto& pipeline = item.pipeline;
    +  // StartProgramLink returns both shaders or none.
    +  const bool owns_link = item.vert_shader != 0;
    +
    +  const bool link_result =
    +      owns_link
    +          ? FinishProgramLink(reactor, pipeline, item.program_key.vertex_shader,
    +                              item.program_key.fragment_shader,
    +                              ProgramShaders{.vert = item.vert_shader,
    +                                             .frag = item.frag_shader},
    +                              /*deferred=*/true)
    +          : IsProgramLinked(reactor, *pipeline);
    +
    +  auto fail = [&]() -> std::shared_ptr<PipelineGLES> {
    +    if (owns_link) {
    +      RemoveProgramForKey(item.program_key, pipeline->GetSharedHandle());
    +    }
    +    return nullptr;
    +  };
    +
    +  if (!link_result) {
    +    VALIDATION_LOG << "Could not link pipeline program.";
    +    return fail();
    +  }
    +
    +  auto program = reactor.GetGLHandle(pipeline->GetProgramHandle());
    +  if (!program.has_value()) {
    +    VALIDATION_LOG << "Could not obtain program handle.";
    +    return fail();
    +  }
    +
    +  if (!pipeline->BuildVertexDescriptor(reactor.GetProcTable(),
    +                                       program.value())) {
    +    VALIDATION_LOG << "Could not build pipeline vertex descriptors.";
    +    return fail();
    +  }
    +
    +  if (!pipeline->IsValid()) {
    +    VALIDATION_LOG << "Pipeline validation checks failed.";
    +    return fail();
    +  }
    +
    +  return pipeline;
    +}
    +
    +bool PipelineLibraryGLES::SupportsParallelShaderCompile() const {
    +  return supports_parallel_shader_compile_;
    +}
    +
     // |PipelineLibrary|
     PipelineFuture<PipelineDescriptor> PipelineLibraryGLES::GetPipeline(
         PipelineDescriptor descriptor,
    @@ -302,8 +589,13 @@ PipelineFuture<PipelineDescriptor> PipelineLibraryGLES::GetPipeline(
     
       std::weak_ptr<PipelineLibrary> weak_this = weak_from_this();
       std::shared_ptr<ReactorGLES> reactor = reactor_;
    +  // A synchronous caller waits for the future on this thread, so only an
    +  // async job may return before its program is linked.
    +  const bool can_defer_link =
    +      async && compile_queue_ && supports_parallel_shader_compile_;
       auto generation_task = [promise, weak_this, descriptor, vert_function,
    -                          frag_function, threadsafe, reactor]() {
    +                          frag_function, threadsafe, reactor,
    +                          can_defer_link]() {
         auto thiz = weak_this.lock();
         if (!thiz) {
           promise->set_value(nullptr);
    @@ -314,8 +606,18 @@ PipelineFuture<PipelineDescriptor> PipelineLibraryGLES::GetPipeline(
                                                    descriptor,     //
                                                    vert_function,  //
                                                    frag_function,  //
    -                                               threadsafe      //
    +                                               threadsafe,     //
    +                                               can_defer_link  //
         ](const ReactorGLES& reactor) {
    +      if (can_defer_link &&
    +          PipelineCompileQueueGLES::IsRunningJobOnCurrentThread()) {
    +        // The promise is set later unless the pipeline fails right away.
    +        if (!CreatePipeline(weak_this, descriptor, vert_function, frag_function,
    +                            threadsafe, promise)) {
    +          promise->set_value(nullptr);
    +        }
    +        return;
    +      }
           promise->set_value(CreatePipeline(weak_this, descriptor, vert_function,
                                             frag_function, threadsafe));
         });
    @@ -371,7 +673,20 @@ void PipelineLibraryGLES::RemovePipelinesWithEntryPoint(
     }
     
     // |PipelineLibrary|
    -PipelineLibraryGLES::~PipelineLibraryGLES() = default;
    +PipelineLibraryGLES::~PipelineLibraryGLES() {
    +  FailPendingPipelines();
    +}
    +
    +void PipelineLibraryGLES::FailPendingPipelines() {
    +  std::vector<PendingPipeline> pending;
    +  {
    +    Lock lock(pending_mutex_);
    +    pending.swap(pending_pipelines_);
    +  }
    +  for (auto& item : pending) {
    +    item.promise->set_value(nullptr);
    +  }
    +}
     
     const std::shared_ptr<ReactorGLES>& PipelineLibraryGLES::GetReactor() const {
       return reactor_;
    @@ -394,6 +709,16 @@ void PipelineLibraryGLES::SetProgramForKey(
       programs_[key] = std::move(program);
     }
     
    +void PipelineLibraryGLES::RemoveProgramForKey(
    +    const ProgramKey& key,
    +    const std::shared_ptr<UniqueHandleGLES>& program) {
    +  Lock lock(programs_mutex_);
    +  auto found = programs_.find(key);
    +  if (found != programs_.end() && found->second == program) {
    +    programs_.erase(found);
    +  }
    +}
    +
     PipelineCompileQueue* PipelineLibraryGLES::GetPipelineCompileQueue() const {
       return compile_queue_.get();
     }
    diff --git a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h
    index f2ce764..03baf3b 100644
    --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h
    @@ -5,6 +5,8 @@
     #ifndef FLUTTER_IMPELLER_RENDERER_BACKEND_GLES_PIPELINE_LIBRARY_GLES_H_
     #define FLUTTER_IMPELLER_RENDERER_BACKEND_GLES_PIPELINE_LIBRARY_GLES_H_
     
    +#include <future>
    +#include <memory>
     #include <unordered_map>
     #include <vector>
     
    @@ -89,11 +91,34 @@ class PipelineLibraryGLES final
                                             ProgramKey::Hash,
                                             ProgramKey::Equal>;
     
    +  using PipelinePromise =
    +      std::promise<std::shared_ptr<Pipeline<PipelineDescriptor>>>;
    +
    +  //----------------------------------------------------------------------------
    +  /// @brief      Holds a pipeline whose program link was started with
    +  ///             GL_KHR_parallel_shader_compile and not checked yet.
    +  ///
    +  struct PendingPipeline {
    +    std::shared_ptr<PipelineGLES> pipeline;
    +    std::shared_ptr<PipelinePromise> promise;
    +    ProgramKey program_key;
    +    /// The shaders are 0 if the pipeline reuses a program that another
    +    /// pipeline is linking.
    +    GLuint vert_shader = 0;
    +    GLuint frag_shader = 0;
    +  };
    +
       std::shared_ptr<ReactorGLES> reactor_;
       PipelineMap pipelines_;
       Mutex programs_mutex_;
       ProgramMap programs_ IPLR_GUARDED_BY(programs_mutex_);
       std::shared_ptr<PipelineCompileQueueGLES> compile_queue_;
    +  const bool supports_parallel_shader_compile_;
    +  const size_t max_pending_links_;
    +  Mutex pending_mutex_;
    +  std::vector<PendingPipeline> pending_pipelines_
    +      IPLR_GUARDED_BY(pending_mutex_);
    +  bool is_watching_queue_ IPLR_GUARDED_BY(pending_mutex_) = false;
     
       explicit PipelineLibraryGLES(
           std::shared_ptr<ReactorGLES> reactor,
    @@ -121,17 +146,52 @@ class PipelineLibraryGLES final
     
       const std::shared_ptr<ReactorGLES>& GetReactor() const;
     
    +  //----------------------------------------------------------------------------
    +  /// @brief      Creates a pipeline and links its program.
    +  ///
    +  ///             With a deferred_promise, this starts the link and returns
    +  ///             before it finishes. FinishPendingPipelines checks the link
    +  ///             later and sets deferred_promise. That requires
    +  ///             GL_KHR_parallel_shader_compile and a thread that runs a
    +  ///             PipelineCompileQueueGLES job.
    +  ///
       static std::shared_ptr<PipelineGLES> CreatePipeline(
           const std::weak_ptr<PipelineLibrary>& weak_library,
           const PipelineDescriptor& desc,
           const std::shared_ptr<const ShaderFunction>& vert_shader,
           const std::shared_ptr<const ShaderFunction>& frag_shader,
    -      bool threadsafe);
    +      bool threadsafe,
    +      std::shared_ptr<PipelinePromise> deferred_promise = nullptr);
    +
    +  //----------------------------------------------------------------------------
    +  /// @brief      Checks the link of every pending pipeline and sets its
    +  ///             promise. A status query here waits for the link.
    +  ///
    +  void FinishPendingPipelines(const ReactorGLES& reactor);
    +
    +  /// Sets the promise of every pending pipeline to null, so nothing waits for
    +  /// a link that no thread will check.
    +  void FailPendingPipelines();
    +
    +  std::shared_ptr<PipelineGLES> FinishPipeline(const ReactorGLES& reactor,
    +                                               const PendingPipeline& item);
    +
    +  //----------------------------------------------------------------------------
    +  /// @brief      Asks the compile queue to report when it runs out of jobs,
    +  ///             which is when the pending links are checked.
    +  ///
    +  void WatchQueueForDrain();
    +
    +  bool SupportsParallelShaderCompile() const;
     
       std::shared_ptr<UniqueHandleGLES> GetProgramForKey(const ProgramKey& key);
     
       void SetProgramForKey(const ProgramKey& key,
                             std::shared_ptr<UniqueHandleGLES> program);
    +
    +  /// Removes the entry for key only if it still holds program.
    +  void RemoveProgramForKey(const ProgramKey& key,
    +                           const std::shared_ptr<UniqueHandleGLES>& program);
       // |PipelineLibrary|
       PipelineCompileQueue* GetPipelineCompileQueue() const override;
     };
    diff --git a/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_library_gles_unittests.cc b/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_library_gles_unittests.cc
    index c5358e6..4f4fbb5 100644
    --- a/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_library_gles_unittests.cc
    +++ b/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_library_gles_unittests.cc
    @@ -2,6 +2,10 @@
     // Use of this source code is governed by a BSD-style license that can be
     // found in the LICENSE file.
     
    +#include <memory>
    +#include <optional>
    +#include <vector>
    +
     #include "impeller/fixtures/spec_constant.frag.h"
     #include "impeller/fixtures/spec_constant.vert.h"
     #include "impeller/playground/playground_test.h"
    @@ -95,6 +99,60 @@ TEST_P(PipelineLibraryGLESTest, ClearingPipelineWillAlsoClearProgramHandle) {
     
       EXPECT_FALSE(HandleGLES::Equal{}(handle, handle_2));
     }
    +
    +// Async requests may start the link of a program and check it later, which
    +// GL_KHR_parallel_shader_compile allows. More requests than links may run at
    +// the same time, and some of them share a program that is still linking.
    +TEST_P(PipelineLibraryGLESTest, AsyncPipelinesAreValid) {
    +  using VS = SpecConstantVertexShader;
    +  using FS = SpecConstantFragmentShader;
    +  std::shared_ptr<Context> context = GetContext();
    +  ASSERT_TRUE(context);
    +
    +  std::vector<PipelineFuture<PipelineDescriptor>> futures;
    +  for (Scalar constant : {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f}) {
    +    for (SampleCount samples : {SampleCount::kCount1, SampleCount::kCount4}) {
    +      std::optional<PipelineDescriptor> desc =
    +          PipelineBuilder<VS, FS>::MakeDefaultPipelineDescriptor(*context);
    +      ASSERT_TRUE(desc.has_value());
    +      desc->SetSpecializationConstants({constant});
    +      desc->SetSampleCount(samples);
    +      futures.push_back(
    +          context->GetPipelineLibrary()->GetPipeline(desc.value()));
    +    }
    +  }
    +
    +  for (PipelineFuture<PipelineDescriptor>& future : futures) {
    +    std::shared_ptr<Pipeline<PipelineDescriptor>> pipeline = future.Get();
    +    ASSERT_TRUE(pipeline && pipeline->IsValid());
    +  }
    +}
    +
    +TEST_P(PipelineLibraryGLESTest, SynchronousPipelineIsValid) {
    +  using VS = SpecConstantVertexShader;
    +  using FS = SpecConstantFragmentShader;
    +  std::shared_ptr<Context> context = GetContext();
    +  ASSERT_TRUE(context);
    +
    +  std::optional<PipelineDescriptor> desc =
    +      PipelineBuilder<VS, FS>::MakeDefaultPipelineDescriptor(*context);
    +  ASSERT_TRUE(desc.has_value());
    +
    +  std::shared_ptr<Pipeline<PipelineDescriptor>> pipeline =
    +      context->GetPipelineLibrary()
    +          ->GetPipeline(desc.value(), /*async=*/false)
    +          .Get();
    +  ASSERT_TRUE(pipeline && pipeline->IsValid());
    +
    +  // A variant reuses the program of the pipeline above.
    +  std::optional<PipelineDescriptor> variant = desc;
    +  variant->SetSampleCount(SampleCount::kCount4);
    +  std::shared_ptr<Pipeline<PipelineDescriptor>> variant_pipeline =
    +      context->GetPipelineLibrary()
    +          ->GetPipeline(variant.value(), /*async=*/false)
    +          .Get();
    +  ASSERT_TRUE(variant_pipeline && variant_pipeline->IsValid());
    +}
     // NOLINTEND(bugprone-unchecked-optional-access)
     
     }  // namespace impeller::testing

    Two things turned out to matter more than the parallelism itself:

    • Querying GL_COMPLETION_STATUS_KHR in a loop keeps the IO thread busy and delays the root isolate. On flutter_gallery that cost 94-104 ms of timeToFrameworkInit against 58 ms before the change. Letting the compile queue drain and then checking the statuses in one job removed that.
    • Starting all 79 links at once has the same effect, because every link occupies a driver thread. Capping the links in flight with std::clamp(hardware_concurrency / 2, 1, 4), the same bound as ContextVK::ChooseThreadCountForWorkers after [Impeller] Concurrent VkPipeline creation is slow on Mokey Device. #143540, brought timeToFrameworkInit back to 62-64 ms. Batches of 2 were measurably worse for the first frame (606 ms against 455 ms), batches of 8 no better.

    Measurements, one build in every row, old being the same binary with the new path disabled:

    flutter_gallery, profile, start_up_info.json, 5 rounds with a warmup, median:

    frameworkInit timeToFirstFrame timeToFirstFrameRasterized
    old 58 ms 219 ms 716 ms
    new 64 ms 237 ms 440 ms

    flutter_gallery on a Ryzen AI 9 HX 370 with a Radeon 890M, time from process creation until the runner shows the window, 3 rounds, median:

    window shown
    old 1119 ms
    new 666 ms
    old, 4 logical cores via affinity 883 ms
    new, 4 cores, cap of 2 links 706 ms

    An app with 8 FragmentProgram shaders, from process creation to the first rasterized frame, same machine: 2530 ms with the official engine, 2587 ms with the path disabled, 1826 ms with it on.

    The startup jank is the part I did not expect to move. On the same machine the second after the first frame runs at 113-114 FPS with 3.5 ms of raster on the new path, against 72-78 FPS and 7.6-8.6 ms before. From the second second on both are at 120 FPS.

    Also checked: flutter_gallery and 8 scenes of a local bench (fragment shader, BlendMode.multiply, saveLayer, BackdropFilter, ImageFiltered, ColorFiltered, drawRawAtlas, text) produce pixel identical screenshots on Skia and Impeller with the animation frozen; closing the window while programs are still linking exits with code 0 in 20 runs out of 20 and is faster than before (166 ms against 370 ms); clang-format and the engine clang-tidy are clean and impeller_unittests shows no regressions.

    Questions before I send it:

    • Would you take this shape of change? The diff is about 250 lines in pipeline_library_gles.cc plus a small addition to PipelineCompileQueueGLES, and it adds a pending list, a finish job and the cap.
    • Is the ANGLE restriction the right call for a first PR? The same code would work on any driver that reports the extension, but I have only measured ANGLE, and Android drivers vary enough that I would rather not enable it blindly. If you prefer it unrestricted I can run it on a Mali and an Adreno device first.
    • Should the cap be shared with Vulkan rather than duplicated? Right now the formula is copied into the GLES backend.

    I dropped the program cache for now. Correctness is not the problem: ANGLE keys entries with ANGLE_PROGRAM_VERSION, rejects a binary whose adapter id or feature level does not match, and the stored D3D bytecode survives driver updates. What stopped me is everything around it. fml::paths::GetCachesDirectory() is not implemented on Windows, so the embedder has to pick a folder, apps will want to move it or turn it off, which means a new embedder setting similar to what #186374 asks for Vulkan, and nothing would remove the entries that every Flutter update with a new ANGLE leaves behind. That is a bigger design discussion than a startup fix, and the parallel compilation gets most of the win without keeping any state on disk. If you would rather have the cache first, I can put the numbers for it with a warm cache the first frame on this app was 328 ms.

  11. 0nilinkz commented on Sep 28, 2026

    @0nilinkz

    Following up on @krll-kov's ANGLE program cache experiment with numbers from a
    second machine (Ryzen 7 2700X, GTX 1080, driver 32.0.15.8266, Windows 11), and
    a proposal for the three questions that stopped it: where the files go, how
    apps move or turn it off, and what removes old entries.

    Where the time goes on 3.47.2. In a profile build of a real app, engine
    start to main() is ~1.7 s: 72 impeller::LinkProgram calls on the raster
    thread before main(). A forwarding D3DCompiler_47.dll in a minimal app
    counts 146 D3DCompile calls (1.93–1.94 s of CPU; ANGLE compiles each
    vertex/pixel pair concurrently, so 1.50 s on the critical path). Every launch
    pays it again.

    With the blob cache (3.47.2 engine, local profile build, same app,
    alternating fresh launches, three each):

    engine start → main() links before main() D3DCompile calls window visible
    stock 1,702–1,760 ms 72 / 1,664–1,722 ms 146 2,502–2,553 ms
    cache empty (first launch) 1,760–1,859 ms 72 / 1,720–1,820 ms 146 2,584–2,664 ms
    cache filled 222–228 ms 72 / 186–189 ms 0 716–822 ms

    (D3DCompile counts from the minimal app, whose extra program adds 2 calls.)

    On main (2abcfdc6), where #185723 moved program setup to the IO
    thread, main() starts within 50 ms but the first frame still waits for about
    half of the compiles:

    main 2abcfdc6 engine start → first frame window visible programs from the cache
    official engine 1,641–1,683 ms 1,960–2,320 ms —
    with the cache, empty (first launch) 1,571–1,707 ms 2,019–2,060 ms 0
    with the cache, filled 167–176 ms 487–556 ms 72 of 72

    (Three alternating launches each; the app holds its first frame for one extra
    program of its own. --trace-startup stops at the first frame, which on main
    comes after 34–43 of the 72 links; the rest link on the IO thread afterwards.)

    Proposal for the open questions (implemented and unit-tested locally):

    • Location. Default to the app's cache folder the way path_provider does
      on Windows: %LOCALAPPDATA%\<CompanyName>\<ProductName>\flutter_program_cache
      from the executable's version resource, falling back to the executable name.
      Per user, stable across installs and updates, never shared between users.
    • Move or turn off. FlutterDesktopEngineProperties::program_cache_path and
      DartProject::set_program_cache_path(): unset uses the default, empty turns
      the cache off. In debug and profile builds a
      --program-cache-path=<folder> engine switch overrides it, for tests that
      need a cold cache.
    • Old entries. Entries live in a subfolder named after the engine version;
      the first write of a new version removes other versions' folders (only files
      the cache itself writes, and a folder only once empty). The total is bounded
      (32 MiB, least recently used first), and a single entry to 4 MiB.

    Correctness stays with ANGLE: its key covers the shader sources, ANGLE's
    program format version, GL_RENDERER (GPU, device id and driver version) and
    bindings, and it rejects a binary whose adapter or feature level differs; a
    rejected or undecodable entry is relinked and replaced. The store adds a
    checksum, writes through a temporary file and an atomic rename, reads the file
    once per hit, and writes on a background thread so a warm launch starts no
    thread.

    This is complementary to #193066: parallel links shorten the first launch,
    the cache removes the compile from later launches. I've opened #193419 with
    this change as a Windows embedder change, and I'm happy to adjust the API
    shape (default-on vs opt-in, naming) to what you prefer. @krll-kov, if you'd rather
    send your version, I can contribute these tests and numbers to it instead.

  12. krll-kov commented on Sep 28, 2026

    @krll-kov
    Contributor

    Following up on @krll-kov's ANGLE program cache experiment with numbers from a second machine (Ryzen 7 2700X, GTX 1080, driver 32.0.15.8266, Windows 11), and a proposal for the three questions that stopped it: where the files go, how apps move or turn it off, and what removes old entries.

    Where the time goes on 3.47.2. In a profile build of a real app, engine start to main() is ~1.7 s: 72 impeller::LinkProgram calls on the raster thread before main(). A forwarding D3DCompiler_47.dll in a minimal app counts 146 D3DCompile calls (1.93–1.94 s of CPU; ANGLE compiles each vertex/pixel pair concurrently, so 1.50 s on the critical path). Every launch pays it again.

    With the blob cache (3.47.2 engine, local profile build, same app, alternating fresh launches, three each):
    engine start → main() links before main() D3DCompile calls window visible
    stock 1,702–1,760 ms 72 / 1,664–1,722 ms 146 2,502–2,553 ms
    cache empty (first launch) 1,760–1,859 ms 72 / 1,720–1,820 ms 146 2,584–2,664 ms
    cache filled 222–228 ms 72 / 186–189 ms 0 716–822 ms

    (D3DCompile counts from the minimal app, whose extra program adds 2 calls.)

    On main (2abcfdc6), where #185723 moved program setup to the IO thread, main() starts within 50 ms but the first frame still waits for about half of the compiles:
    main 2abcfdc6 engine start → first frame window visible programs from the cache
    official engine 1,641–1,683 ms 1,960–2,320 ms —
    with the cache, empty (first launch) 1,571–1,707 ms 2,019–2,060 ms 0
    with the cache, filled 167–176 ms 487–556 ms 72 of 72

    (Three alternating launches each; the app holds its first frame for one extra program of its own. --trace-startup stops at the first frame, which on main comes after 34–43 of the 72 links; the rest link on the IO thread afterwards.)

    Proposal for the open questions (implemented and unit-tested locally):

    * _Location._ Default to the app's cache folder the way `path_provider` does
      on Windows: `%LOCALAPPDATA%\<CompanyName>\<ProductName>\flutter_program_cache`
      from the executable's version resource, falling back to the executable name.
      Per user, stable across installs and updates, never shared between users.
    
    * _Move or turn off._ `FlutterDesktopEngineProperties::program_cache_path` and
      `DartProject::set_program_cache_path()`: unset uses the default, empty turns
      the cache off. In debug and profile builds a
      `--program-cache-path=<folder>` engine switch overrides it, for tests that
      need a cold cache.
    
    * _Old entries._ Entries live in a subfolder named after the engine version;
      the first write of a new version removes other versions' folders (only files
      the cache itself writes, and a folder only once empty). The total is bounded
      (32 MiB, least recently used first), and a single entry to 4 MiB.
    

    Correctness stays with ANGLE: its key covers the shader sources, ANGLE's program format version, GL_RENDERER (GPU, device id and driver version) and bindings, and it rejects a binary whose adapter or feature level differs; a rejected or undecodable entry is relinked and replaced. The store adds a checksum, writes through a temporary file and an atomic rename, reads the file once per hit, and writes on a background thread so a warm launch starts no thread.

    This is complementary to #193066: parallel links shorten the first launch, the cache removes the compile from later launches. I've opened #193419 with this change as a Windows embedder change, and I'm happy to adjust the API shape (default-on vs opt-in, naming) to what you prefer. @krll-kov, if you'd rather send your version, I can contribute these tests and numbers to it instead.

    Thanks, my code was just a draft to demonstrate the time change and i was not sure because of many opened questions for potential issues so I decided to do parallel linking instead

  13. scalz commented on Oct 14, 2026

    @scalz

    Adding a data point from Flutter GPU, where the same cause (no persisted programs on the OpenGL ES backend) costs far more than at startup, and where the two fixes in progress would not help equally.

    What is different from the startup case. The programs here are not the engine's own: they are the ones a package builds through Flutter GPU (flutter_scene 0.24.3), compiled at run time when a feature is first drawn. Moving the engine's program setup to the IO thread (#185723) does not touch them. A persisted ANGLE blob cache (#193419) sits below Impeller, so it should; I have not tried a build with it.

    Numbers. Release builds, Ryzen 5 2600, Radeon RX 580 (driver 31.0.21923.11000), Windows 11 25H2. The sample below builds a scene of ten primitives with the standard lit material and calls Scene.warmUp three times, each time with one more feature, then once more with the last configuration. Each step only pays for what the previous ones did not compile. Three consecutive launches of the same build:

    Stable 3.47.6 (engine 692136c):

    Step Launch 1 Launch 2 Launch 3
    1. lit materials 1840 ms 1928 ms 1862 ms
    2. + directional shadows 5935 ms 6589 ms 5859 ms
    3. + ambient occlusion 15751 ms 15643 ms 16118 ms
    4. same as 3, again (same process) 9 ms 6 ms 2 ms
    Total 23.5 s 24.2 s 23.8 s

    main channel, 3.49.0-1.0.pre-418:

    Step Launch 1 Launch 2 Launch 3
    1. lit materials 3119 ms 3090 ms 3084 ms
    2. + directional shadows 4637 ms 4580 ms 4616 ms
    3. + ambient occlusion 12413 ms 12121 ms 12411 ms
    4. same as 3, again (same process) 1 ms 1 ms 1 ms
    Total 20.5 s 20.1 s 20.5 s

    Within a process the pipelines are cached (step 4), so the cost is the compile, and every new process pays it again: about 2 s for an application that only uses lit materials, 8 s with shadows, 20 to 24 s with ambient occlusion as well. The application is frozen during each step (the progress bar of the sample stops): on OpenGL ES, lib/gpu/render_pass.cc has the UI thread wait for a raster task that requests the pipeline and waits for its link. The same figures were first seen in a real application (a 3D floor plan), which I cannot share, hence the sample.

    Steps 1 to 3 build 3, 2 and 4 pipelines, and two programs carry most of it: the lit fragment shader with shadows links in 6 s, and its full variant (used once ambient occlusion is on) in 14 s, measured alone on ANGLE with the same sources. These are large physically based shaders, and part of the time comes from two patterns that D3DCompile handles slowly; a change on the flutter_scene side (bdero/flutter_scene#502) takes the total from 23.6 s to 9.5 s on its master, with identical frames. The remaining 9.5 s, for nine pipelines, is still paid at every launch, and that is the part only a persisted cache removes.

    Why it matters for the two changes in progress.

    Reproduction. https://github.com/scalz/issue_windows_impeller_no_pipeline_cache, which is a new Windows project with flutter_scene: 0.24.3 and vector_math, one line added in windows/runner/main.cpp:

    flutter::DartProject project(L"data");
    project.set_enable_flutter_gpu(true);

    then flutter run -d windows --release. Close and launch again: steps 1 to 3 cost the same. Each launch appends its timings to %TEMP%\pipeline_cache_repro.log.

    lib/main.dart
    // Minimal reproduction: on Windows, the render pipelines that flutter_scene
    // compiles are compiled again at every launch of the application.
    //
    // The app builds a tiny lit scene, then warms it up three times, each time
    // with one more feature (lit materials, sun shadows, ambient occlusion), and
    // measures how long each warm-up takes. It then warms the last configuration
    // again, to show that the cost is paid once per process.
    //
    // Every run appends its timings to a log file in the system temp directory,
    // so that two consecutive launches can be compared.
    //
    //   flutter run -d windows --release
    //   flutter run -d windows --release --dart-define=AUTO_EXIT=true
    
    import 'dart:io';
    
    import 'package:flutter/material.dart';
    import 'package:flutter_scene/scene.dart';
    import 'package:vector_math/vector_math.dart' as vm;
    
    /// Closes the app once the measurements are written (for scripted runs).
    const _autoExit = bool.fromEnvironment('AUTO_EXIT');
    
    void main() => runApp(const ReproApp());
    
    class ReproApp extends StatelessWidget {
      const ReproApp({super.key});
    
      @override
      Widget build(BuildContext context) => MaterialApp(
        debugShowCheckedModeBanner: false,
        theme: ThemeData.dark(useMaterial3: true),
        home: const ReproPage(),
      );
    }
    
    class ReproPage extends StatefulWidget {
      const ReproPage({super.key});
    
      @override
      State<ReproPage> createState() => _ReproPageState();
    }
    
    class _ReproPageState extends State<ReproPage> {
      final _lines = <String>[];
      Scene? _scene;
      bool _isDone = false;
    
      static final _logFile = File(
        '${Directory.systemTemp.path}${Platform.pathSeparator}pipeline_cache_repro.log',
      );
    
      @override
      void initState() {
        super.initState();
        // after the first frame, so that the engine and its surface exist
        WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
      }
    
      void _note(String line) {
        debugPrint(line);
        setState(() => _lines.add(line));
      }
    
      Future<void> _measure() async {
        try {
          final total = Stopwatch()..start();
          await Scene.initializeStaticResources();
          _note('static resources: ${total.elapsedMilliseconds} ms');
    
          final scene = Scene();
          final bounds = _populate(scene);
          final views = [RenderView(camera: PerspectiveCamera.framing(bounds))];
    
          Future<void> warm(String label) async {
            final clock = Stopwatch()..start();
            await scene.warmUp(views, includeOffscreen: true);
            _note('$label: ${clock.elapsedMilliseconds} ms');
          }
    
          _light(scene, hasShadows: false, hasOcclusion: false);
          await warm('1. lit materials');
    
          _light(scene, hasShadows: true, hasOcclusion: false);
          await warm('2. + directional shadows');
    
          _light(scene, hasShadows: true, hasOcclusion: true);
          await warm('3. + ambient occlusion');
    
          // Same configuration, same process: the pipelines are there already.
          await warm('4. same as 3, again');
    
          _note('total: ${total.elapsedMilliseconds} ms');
          final stamp = DateTime.now().toIso8601String();
          _logFile.writeAsStringSync(
            '--- launch at $stamp (${Platform.operatingSystemVersion})\n${_lines.join('\n')}\n',
            mode: FileMode.append,
          );
          _note('written to ${_logFile.path}');
          setState(() {
            _scene = scene;
            _isDone = true;
          });
          if (_autoExit) exit(0);
        } on Object catch (e, s) {
          _note('FAILED: $e');
          debugPrintStack(stackTrace: s);
          if (_autoExit) exit(1);
        }
      }
    
      /// A floor and a few boxes and spheres, all with the standard lit material.
      static vm.Aabb3 _populate(Scene scene) {
        PhysicallyBasedMaterial material(double r, double g, double b, double roughness) =>
            PhysicallyBasedMaterial()
              ..baseColorFactor = vm.Vector4(r, g, b, 1)
              ..roughnessFactor = roughness
              ..metallicFactor = 0;
    
        scene.add(
          Node(
            name: 'floor',
            mesh: Mesh(PlaneGeometry(width: 12, depth: 12), material(0.8, 0.8, 0.8, 0.9)),
          ),
        );
        for (var i = 0; i < 9; i++) {
          final x = (i % 3 - 1) * 3.0;
          final z = (i ~/ 3 - 1) * 3.0;
          final isBox = i.isEven;
          scene.add(
            Node(
              name: 'item $i',
              mesh: Mesh(
                isBox ? CuboidGeometry(vm.Vector3(1.2, 1.2, 1.2)) : SphereGeometry(radius: 0.7),
                material(0.2 + 0.08 * i, 0.5, 0.9 - 0.08 * i, 0.5),
              ),
              localTransform: vm.Matrix4.translationValues(x, 0.7, z),
            ),
          );
        }
        return vm.Aabb3.minMax(vm.Vector3(-6, 0, -6), vm.Vector3(6, 2, 6));
      }
    
      static void _light(Scene scene, {required bool hasShadows, required bool hasOcclusion}) {
        scene
          ..directionalLight = DirectionalLight(
            direction: vm.Vector3(-0.4, -1, -0.3),
            castsShadow: hasShadows,
          )
          ..environmentSettings = EnvironmentSettings(ambientOcclusionEnabled: hasOcclusion);
      }
    
      @override
      Widget build(BuildContext context) {
        final scene = _scene;
        return Scaffold(
          body: Row(
            children: [
              SizedBox(
                width: 380,
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Pipeline compile times, this launch',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 12),
                      for (final line in _lines) Text(line),
                      const SizedBox(height: 12),
                      if (!_isDone) const LinearProgressIndicator(),
                      if (_isDone)
                        const Text('Close the app and launch it again: steps 1 to 3 cost the same.'),
                    ],
                  ),
                ),
              ),
              Expanded(child: scene == null ? const SizedBox.shrink() : SceneView(scene)),
            ],
          ),
        );
      }
    }
    Console output of one launch (stable 3.47.6)
    [IMPORTANT:flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc(126)] Using the Impeller rendering backend (OpenGLESSDF).
    static resources: 4 ms
    1. lit materials: 1928 ms
    2. + directional shadows: 6589 ms
    3. + ambient occlusion: 15643 ms
    4. same as 3, again: 6 ms
    total: 24175 ms
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work liste: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.from: performance templateIssues created via a performance issue templateplatform-windowsBuilding on or for Windows specificallyteam-engineOwned by Engine teamtriaged-engineTriaged by Engine team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions