Skip to content

[Web] Disposing a shader before Picture.toImage changes sampled pixels in Chrome #193221

Closed

Description

@traison-diedrich

Steps to reproduce

  1. Create an otherwise empty Flutter project and add the files in the Code sample section.

  2. From the project root, start ChromeDriver in one terminal:

    chromedriver --port=4444
  3. In another terminal, run:

    flutter pub get
    flutter drive \
      --driver=test_driver/integration_test.dart \
      --target=integration_test/shader_dispose_probe_test.dart \
      -d chrome --release
  4. Check the printed center-pixel RGBA values for each lifecycle case.

Expected results

The same recorded red-shader rectangle should sample as opaque red in all three cases, including when the shader is disposed after drawRect but before Picture.toImage, if the recorded Picture still needs the shader to render.

The Shader.dispose API documentation says disposal releases the shader's resources, but underlying memory is not freed while another object still needs it. Its example describes a Picture using an ImageShader; it does not explicitly guarantee the same lifetime behavior for a FragmentShader. This report asks whether the observed FragmentShader behavior is expected.

Actual results

Verified with Flutter 3.44.8 and Chrome 150.0.7871.187 on macOS:

Lifecycle Center RGBA
Dispose before toImage (0, 0, 0, 255)
Dispose after toImage (255, 0, 0, 255)
Retain through sampling (255, 0, 0, 255)

Code sample

Code sample

pubspec.yaml

name: shader_dispose_repro
publish_to: none
environment:
  sdk: ^3.10.4
dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter
flutter:
  shaders:
    - shaders/dispose_probe.frag

shaders/dispose_probe.frag

#include <flutter/runtime_effect.glsl>
uniform vec4 color;
out vec4 fragColor;
void main() { fragColor = color; }

integration_test/shader_dispose_probe_test.dart

import 'dart:ui' as ui;

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('sample shader-backed pictures', (tester) async {
    final program = await ui.FragmentProgram.fromAsset(
      'shaders/dispose_probe.frag',
    );

    Future<List<int>> sample(String lifecycle) async {
      final shader = program.fragmentShader()..setFloat(0, 1);
      shader.setFloat(1, 0);
      shader.setFloat(2, 0);
      shader.setFloat(3, 1);
      final recorder = ui.PictureRecorder();
      ui.Canvas(recorder).drawRect(
        const ui.Rect.fromLTWH(0, 0, 32, 32),
        ui.Paint()..shader = shader,
      );
      final picture = recorder.endRecording();
      late ui.Image image;
      if (lifecycle == 'dispose-before-toImage') {
        shader.dispose();
        image = await picture.toImage(32, 32);
      } else {
        image = await picture.toImage(32, 32);
        if (lifecycle == 'dispose-after-toImage') shader.dispose();
      }
      final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
      final pixel = [
        for (var i = 0; i < 4; i++) data!.getUint8((16 * 32 + 16) * 4 + i),
      ];
      print('$lifecycle center RGBA: $pixel');
      image.dispose();
      picture.dispose();
      if (lifecycle == 'retain') shader.dispose();
      return pixel;
    }

    for (final lifecycle in [
      'dispose-before-toImage',
      'dispose-after-toImage',
      'retain',
    ]) {
      await sample(lifecycle);
    }
  });
}

test_driver/integration_test.dart

import 'package:integration_test/integration_test_driver.dart';

Future<void> main() => integrationDriver();

Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.44.8, on macOS 26.6.2 25G83 darwin-arm64, locale en-US) [447ms]
    • Flutter version 3.44.8 on channel stable at /Users/agent/repos/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 058e0af2c2 (9 weeks ago), 2026-07-23 10:56:21 -0700
    • Engine revision 0cd610717b
    • Dart version 3.12.2
    • DevTools version 2.57.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[✓] Chrome - develop for the web [5ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Network resources [256ms]
    • All expected network resources are available.

Activity

  1. added
    in triagePresently being triaged by the triage team
    platform-webWeb applications specifically
    engineflutter/engine related. See also e: labels.
    a: imagesLoading, displaying, rendering images
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    team-webOwned by Web platform team
    and removed
    in triagePresently being triaged by the triage team
    on Sep 23, 2026
  2. mbcorona commented on Sep 25, 2026

    @mbcorona
    Member

    Thanks for reporting @traison-diedrich .
    routing to the web team for further investigation.

  3. vi-k commented on Oct 19, 2026

    @vi-k

    This also happens on screen, not only with Picture.toImage, and I think I found the cause.

    Where it comes from. On CanvasKit, CkFragmentShader keeps its uniforms in WASM memory (mallocFloat32List) and passes that array to RuntimeEffect.makeShader in getSkShader. The canvaskit.js glue copies the uniforms only for an array that is not already in WASM memory (!g._ck is passed as the "own the uniforms" flag), so for this array the SkShader refers to the same memory. CkFragmentShader.dispose() then calls free(floats). The picture is rasterized after it has been recorded, so a shader disposed of right after the draw call reads freed memory, and whatever was allocated there next. The code is the same in 3.27.0 and 3.47.0.

    Smaller sample. Two boxes, red on the left and blue on the right, each filled by its own shader, which is disposed of right after drawRect:

    import 'dart:ui' as ui;
    
    import 'package:flutter/material.dart';
    
    // Build with --dart-define=DISPOSE=false to see the expected picture.
    const disposeAfterDraw = bool.fromEnvironment('DISPOSE', defaultValue: true);
    
    late ui.FragmentProgram program;
    
    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
      program = await ui.FragmentProgram.fromAsset('shaders/solid.frag');
      runApp(
        const MaterialApp(
          home: ColoredBox(
            color: Colors.white,
            child: Center(
              child: SizedBox(
                width: 400,
                height: 200,
                child: CustomPaint(painter: TwoBoxes()),
              ),
            ),
          ),
        ),
      );
    }
    
    /// A red box on the left and a blue one on the right, each filled by a
    /// shader of its own that is disposed of once it has been drawn.
    class TwoBoxes extends CustomPainter {
      const TwoBoxes();
    
      @override
      void paint(Canvas canvas, Size size) {
        final half = size.width / 2;
        _box(canvas, Rect.fromLTWH(0, 0, half, size.height), 1, 0, 0);
        _box(canvas, Rect.fromLTWH(half, 0, half, size.height), 0, 0, 1);
      }
    
      void _box(Canvas canvas, Rect rect, double r, double g, double b) {
        final shader = program.fragmentShader()
          ..setFloat(0, r)
          ..setFloat(1, g)
          ..setFloat(2, b)
          ..setFloat(3, 1);
        canvas.drawRect(rect, Paint()..shader = shader);
        // The draw call has returned; the docs say a shader can be disposed of
        // once it is no longer needed.
        if (disposeAfterDraw) shader.dispose();
      }
    
      @override
      bool shouldRepaint(TwoBoxes oldDelegate) => false;
    }

    shaders/solid.frag, listed under flutter: shaders: in pubspec.yaml:

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec4 uColor;
    
    out vec4 fragColor;
    
    void main() {
      fragColor = uColor;
    }

    flutter build web and open it: both boxes are blue, because the freed uniforms of the red shader are reused by the blue one. Built with --dart-define=DISPOSE=false, the boxes are red and blue. In flutter test the same painter draws red and blue with dispose().

    With dispose() Without dispose()
    With dispose: both boxes blue Without dispose: red and blue

    Flutter 3.47.0 (stable), Chrome 155.0.8059.80, macOS 26.6.2.

    In a real app this shows as flicker: a package of mine draws a shadow with a fragment shader in a CustomPainter and disposes of the shader after each paint. On the web the shadow changed colour from frame to frame, from whatever happened to be allocated in the freed block. The Shader.dispose docs say that the underlying memory is retained while another object that has not been disposed still needs it, so I'd expect the recorded picture to keep the uniforms alive, as it does off the web.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

a: imagesLoading, displaying, rendering imagesassigned for triageissue is assigned to a domain expert for further triagec: renderingUI glitches reported at the engine/skia or impeller rendering levelengineflutter/engine related. See also e: labels.platform-webWeb applications specificallyteam-webOwned by Web platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions