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)
-
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).
-
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.
Steps to reproduce
Call
Canvas.drawAtlaswith more than ~2000 transforms when running with--wasmand theskwasmrenderer. The wasm runtime traps withRuntimeError: memory access out of bounds.Real-world trigger: rendering a moderately large Tiled tilemap (64 × 64 tiles = 4096 entries) via
package:flameSpriteBatch.render, which is whatpackage:flame_tileduses internally.Minimal Dart-only reproduction:
Run:
flutter run -d chrome --wasmwith_flutter.loader.load({config: {renderer: "skwasm"}})inweb/flutter_bootstrap.js.Expected results
drawAtlasrenders the atlas without crashing.Actual results
Root cause
SkwasmCanvas.drawAtlaswrites theRSTransform,Rect, and (optional)Colorarrays into the wasm stack viawithStackScope+_emscripten_stack_alloc(lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart,_emscripten_stack_allocbinding inraw/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, sowithStackScopewrites 4096 × (16 + 16) = 131 072 bytes (128 KB) before callingpaint.toRawPaint().The skwasm wasm module is built without
-sSTACK_SIZEinengine/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_createcallingmalloc) traps because the heap allocator's bookkeeping is now corrupted — hence the crash appearing onpaintCreate, 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.drawAtlasis the most exposed because tilemap renderers (Flame, Forge2D, custom) routinely batch thousands of entries.Repro instrumentation
I patched
lib/_skwasm_impl/skwasm_impl/canvas.dartto log the call site and verified before crash:Inputs are sane (no NaN, no enormous values); only the cumulative stack-scope size is the problem.
Workarounds (verified)
Project side: pass
useAtlas: falsetoflame_tiled.TiledComponent.load(...)— switchesSpriteBatchto onedrawImageRectcall per tile. Boots fine but drops to ~6 FPS in debug / ~100 FPS in profile (vs. ~120 FPS withcanvaskit + useAtlas).SDK patch (with
useAtlas: true, the default): chunkSkwasmCanvas.drawAtlasitself into batches of 1024 entries (~32 KB each), so the upstream caller can keep usingdrawAtlasnormally. I rebuiltdart2wasm_platform.dillwith this patch viakernel_worker_aot.dart.snapshotand confirmed the game renders correctly at full FPS, no crash. Diff:The
sublistoverhead 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 toskwasm/BUILD.gnldflags. Handles 8 K+ entries comfortably but doesn't solve the general class.B. Dart-side chunking in
drawAtlas/drawPoints/drawRawAtlas/drawRawPoints(verified fordrawAtlas).C. Heap fallback in
StackScope: when the requested allocation would exceed available stack (emscripten_stack_get_free()), fall back tomallocand free it after thewithStackScopeclosure 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
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.