Skip to content

[skwasm] Canvas.drawAtlas crashes "memory access out of bounds" when batch exceeds ~2000 entries (wasm stack overflow in withStackScope) #185995

Description

@Ortes

Steps to reproduce

Call Canvas.drawAtlas with more than ~2000 transforms when running with --wasm and the skwasm renderer. The wasm runtime traps with RuntimeError: memory access out of bounds.

Real-world trigger: rendering a moderately large Tiled tilemap (64 × 64 tiles = 4096 entries) via package:flame SpriteBatch.render, which is what package:flame_tiled uses internally.

Minimal Dart-only reproduction:

import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/widgets.dart';

void main() => runApp(const ReproApp());

class ReproApp extends StatefulWidget {
  const ReproApp({super.key});

  @override
  State<ReproApp> createState() => _ReproAppState();
}

class _ReproAppState extends State<ReproApp> {
  ui.Image? _image;

  @override
  void initState() {
    super.initState();
    _decode();
  }

  Future<void> _decode() async {
    final completer = Completer<ui.Image>();
    ui.decodeImageFromPixels(
      Uint8List.fromList(List<int>.filled(4 * 32 * 32, 255)),
      32,
      32,
      ui.PixelFormat.rgba8888,
      completer.complete,
    );
    final image = await completer.future;
    if (!mounted) return;
    setState(() => _image = image);
  }

  @override
  Widget build(BuildContext context) {
    final image = _image;
    return WidgetsApp(
      color: const Color(0xFF000000),
      builder: (_, _) => image == null
          ? const SizedBox()
          : CustomPaint(
              painter: _AtlasPainter(image),
              child: const SizedBox.expand(),
            ),
    );
  }
}

class _AtlasPainter extends CustomPainter {
  _AtlasPainter(this.image);

  final ui.Image image;

  @override
  void paint(Canvas canvas, Size size) {
    const n = 4096; // ~2000+ also triggers it
    final transforms = List<RSTransform>.generate(
      n,
      (i) => RSTransform(1, 0, (i % 64) * 32.0, (i ~/ 64) * 32.0),
    );
    final rects = List<Rect>.generate(
      n,
      (_) => const Rect.fromLTWH(0, 0, 32, 32),
    );
    canvas.drawAtlas(image, transforms, rects, null, null, null, Paint());
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

Run: flutter run -d chrome --wasm with _flutter.loader.load({config: {renderer: "skwasm"}}) in web/flutter_bootstrap.js.

Expected results

drawAtlas renders the atlas without crashing.

Actual results

Uncaught RuntimeError: memory access out of bounds
  at $malloc                       (skwasm.wasm)
  at $func5792                     (skwasm.wasm)
  at $func222                      (skwasm.wasm)
  at $paint_create                 (skwasm.wasm)
  at $paintCreate                  (main.dart.wasm)
  at $SkwasmPaint.toRawPaint       (paint.dart:16)
  at $SkwasmCanvas.drawAtlas       closure inside withStackScope (canvas.dart:331)
  at $withStackScope               (raw_memory.dart:228)
  at $SkwasmCanvas.drawAtlas       (canvas.dart:324)

Root cause

SkwasmCanvas.drawAtlas writes the RSTransform, Rect, and (optional) Color arrays into the wasm stack via withStackScope + _emscripten_stack_alloc (lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart, _emscripten_stack_alloc binding in raw/raw_memory.dart).

Each entry costs:

  • RSTransform → 16 bytes (4 × float32)
  • Rect → 16 bytes (4 × float32)
  • Color (if non-null) → 4 bytes (uint32)

For our 64 × 64 tilemap, transforms.length == rects.length == 4096, so withStackScope writes 4096 × (16 + 16) = 131 072 bytes (128 KB) before calling paint.toRawPaint().

The skwasm wasm module is built without -sSTACK_SIZE in engine/src/flutter/skwasm/BUILD.gn (ldflags), so it uses the Emscripten default of 64 KB. The 128 KB stack-scope allocation overflows into the heap. The conversions themselves don't trap (they just memcpy into linear memory), but the next allocation (the C++ paint_create calling malloc) traps because the heap allocator's bookkeeping is now corrupted — hence the crash appearing on paintCreate, downstream of the actual overflow.

The same hazard exists for any withStackScope-using API that operates on user-supplied lists: drawPoints, drawRawPoints, drawVertices (less common because Vertices is preconverted), etc. drawAtlas is the most exposed because tilemap renderers (Flame, Forge2D, custom) routinely batch thousands of entries.

Repro instrumentation

I patched lib/_skwasm_impl/skwasm_impl/canvas.dart to log the call site and verified before crash:

[SKWASM-DBG] drawAtlas: transforms=4096 rects=4096 colors=null
             bytesOnStack=131072 blend=null atlas=800x32 cullRect=null
[SKWASM-DBG]  first transform scos=1.0 ssin=0.0 tx=0.0 ty=0.0
[SKWASM-DBG]  last transform  scos=1.0 ssin=0.0 tx=2016.0 ty=2016.0
[SKWASM-DBG]  first rect Rect.fromLTRB(224.0, 0.0, 256.0, 32.0)
[SKWASM-DBG]  last rect  Rect.fromLTRB(704.0, 0.0, 736.0, 32.0)
[SKWASM-DBG]  about to call paint.toRawPaint()
                                              ← crash

Inputs are sane (no NaN, no enormous values); only the cumulative stack-scope size is the problem.

Workarounds (verified)

  1. Project side: pass useAtlas: false to flame_tiled.TiledComponent.load(...) — switches SpriteBatch to one drawImageRect call per tile. Boots fine but drops to ~6 FPS in debug / ~100 FPS in profile (vs. ~120 FPS with canvaskit + useAtlas).

  2. SDK patch (with useAtlas: true, the default): chunk SkwasmCanvas.drawAtlas itself into batches of 1024 entries (~32 KB each), so the upstream caller can keep using drawAtlas normally. I rebuilt dart2wasm_platform.dill with this patch via kernel_worker_aot.dart.snapshot and confirmed the game renders correctly at full FPS, no crash. Diff:

    void drawAtlas(...) {
      const int maxBatch = 1024;
      final int n = transforms.length;
      if (n <= maxBatch) return _drawAtlasChunk(...);
      for (int i = 0; i < n; i += maxBatch) {
        final int end = i + maxBatch < n ? i + maxBatch : n;
        _drawAtlasChunk(
          atlas,
          transforms.sublist(i, end),
          rects.sublist(i, end),
          colors?.sublist(i, end),
          blendMode, cullRect, paint,
        );
      }
    }
    void _drawAtlasChunk(...) => withStackScope((scope) { /* original body */ });

    The sublist overhead is negligible compared to the per-batch GPU draw call.

Suggested upstream fix

Open to maintainer preference. Three options ordered by invasiveness:

A. Engine-side (smallest diff, kicks the can up): add -sSTACK_SIZE=262144 (256 KB) or larger to skwasm/BUILD.gn ldflags. Handles 8 K+ entries comfortably but doesn't solve the general class.

B. Dart-side chunking in drawAtlas / drawPoints / drawRawAtlas / drawRawPoints (verified for drawAtlas).

C. Heap fallback in StackScope: when the requested allocation would exceed available stack (emscripten_stack_get_free()), fall back to malloc and free it after the withStackScope closure returns. Most correct, addresses any future caller automatically.

I'm happy to send a PR for whichever direction is preferred. (B) is what we ship locally today.

Logs

$ flutter --version
Flutter 3.44.0-0.3.pre • channel [user-branch] • unknown source
Framework • revision 22533d1211 (5 days ago) • 2026-04-29 15:13:02 -0700
Engine • hash 285ae52077f0a82b1d845a3649eeab4e47a1340d (revision 73dc1ccd62)
Tools • Dart 3.12.0 (build 3.12.0-327.4.beta) • DevTools 2.57.0

Reproducible on macOS 15 (darwin 25.3.0, arm64) with Chrome stable.

Flutter Doctor output

I can attach if useful — bug is renderer-internal, not platform-specific.

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listhas reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-webWeb applications specificallyteam-webOwned by Web platform teamtriaged-webTriaged by Web platform team

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions