Skip to content

Miso.Canvas: direct JavaScript imports for canvas operations and rgb()/rgba() colours - #1673

Merged
dmjio merged 9 commits into
masterfrom
canvas-ffi
Oct 7, 2026
Merged

dmjio merged 9 commits into
masterfrom
canvas-ffi

Conversation

@dmjio

@dmjio dmjio commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Every void CanvasRenderingContext2D operation in Miso.Canvas (fillRect, arc, moveTo, globalAlpha, fillText, …) now calls a dedicated foreign import in the new Miso.Canvas.FFI instead of the generic (#)/setField path, which marshalled the method name, built an argument array and allocated a JSVal per argument on every call.

fillStyle, strokeStyle and shadowColor with an RGB or RGBA colour (this includes the named colours) pass the channels as numbers to an import that builds the rgb(…) string in JavaScript. Before, renderColor built it in Haskell, and on the GHC backends every ms and <> on a JSString is an FFI call: about 5.7 µs per colour, eight times a fillRect. Other colours, gradients and patterns keep the generic path.

The public API is unchanged. Native GHC (no JavaScript FFI) keeps the generic calls.

Commits

  • One direct import per straight-line canvas operation (GHC JS, GHC wasm, MicroHs; native GHC keeps the generic path)
  • Direct rgb()/rgba() imports for fillStyle, strokeStyle, shadowColor
  • Build fixes: JSString (..) import for GHC wasm (it failed with unknown type name 'HsJSString'), OverloadedStrings for the native GHC arm, GHCJS 8.6 arm dropped after Drop GHCJS 8.6 and the legacy nixpkgs pin #1672
  • sample-app-canvas (new package canvas-bench): a render intensive 2D canvas benchmark with an FPS counter (?rects=N), bench.mjs, and Makefile targets for GHC wasm, the GHC JavaScript backend and MicroHs. It has its own cabal.project that builds miso without the native flag. sample-app is unchanged from master.

Built on GHC JS 9.14.1, GHC wasm 9.14, native GHC 9.14.1 and MicroHs 0.16.7.

Benchmarks

Headless Chromium 152 with --disable-gpu-vsync --disable-frame-rate-limit (so nothing is capped at 60 fps). Each figure is the median of 3 rounds with master and this branch interleaved.

sample-app-canvas: N rectangles per frame, one fillStyle + fillRect each

fps, master → this branch:

Backend 1000 rects 4000 rects 10000 rects Speedup
GHC JS 90.9 → 361.8 24.5 → 112.5 9.4 → 47.5 4.0–5.1×
GHC wasm 60.9 → 340.8 15.5 → 117.4 5.3 → 46.0 5.6–8.7×
MicroHs (-tbrowser) 20.4 → 98.4 5.2 → 27.8 1.9 → 10.1 4.8–5.3×

Against the same scene written directly in JavaScript, in one run at 10000 rects:

fps ms/frame
Hand written JavaScript (an rgb() string per rect per frame) 53.3 18.8
GHC JS, this branch 48.3 20.7
GHC wasm, this branch 49.1 20.4

haskell-miso/canvas2d, unmodified

N canvases, each animating 8 planets per frame through canvasSub; fps and main-thread script time per frame:

Backend Canvases master fps this branch fps Speedup Script ms/frame
GHC JS 1 910 1071 1.18× 0.39 → 0.15
GHC JS 8 162 190 1.17× 2.78 → 0.90
GHC JS 24 64 100 1.56× 8.13 → 2.42
GHC wasm 1 815 1106 1.36× 0.55 → 0.14
GHC wasm 8 132 192 1.46× 4.07 → 0.85
GHC wasm 24 54 100 1.85× 11.62 → 2.09

Script time per frame drops 2.6–5.6×. With this branch both backends reach the same frame rate, which is then limited by the browser rasterising several canvases in software rather than by miso.

Orbital Breakdown, unmodified

The game builds against this branch without changes (GHC JavaScript backend, as deployed). Its own replay tool (tools/replay) plays fixed input scripts with a fake clock and seeded randomness, so both builds do exactly the same work, and records main-thread time per frame through the Chrome DevTools Protocol. The game makes about 700–1300 canvas calls per frame.

Replay master ms/frame (mean / p95) this branch ms/frame (mean / p95) Change
Sector 1 gameplay, 1200 frames 10.77 / 13.54 9.43 / 12.42 −12%
Hyperspace warp, 4000 frames 8.74 / 12.64 7.47 / 10.36 −15%
Title screen, 1500 frames 6.71 / 8.30 6.02 / 7.51 −10%

This branch was faster in all 9 paired runs. Script execution time on its own is about the same on both (within ±3%), so the saving is in the rest of the main-thread work per frame. The gain is smaller than in the benchmarks above because the game sets its colours as strings through Miso.Canvas.set and builds its gravity grid and gradients with Miso.DSL calls, which this branch does not change. The game traces are identical on master and this branch in every run.

dmjio added 7 commits October 6, 2026 14:57
Draws N coloured rectangles per frame through canvasSub, one fillStyle
and one fillRect each, with the layout computed once up front (the shape
of webcc's canvas benchmark). The frame rate is measured once a second,
drawn on the canvas and logged as "fps: N ms/frame: T rects: M";
?rects=N sets the count. bench.mjs drives it with Playwright and reports
the median.

The per-frame arithmetic is kept out of the loop on purpose: on MicroHs
fromIntegral, mod and floor on Double cost 15 to 300 us each (floor goes
through Integer), which would hide the canvas cost being measured.

Baseline, MicroHs -tbrowser in headless Chromium on this machine
(ms/frame, median): 1000 rects 78, 2000 rects 133, 4000 rects 207,
10000 rects 478; about 50 to 80 us per rectangle.
Every void canvas operation (fillRect, moveTo, fillStyle, ...) now calls a
dedicated foreign import in Miso.Canvas.FFI instead of going through the
generic (#)/setField path, which marshalled the method name to a JS
string, looked the method up, built an argument array and allocated a
JSVal handle per argument and result on every call.

Miso.Canvas.FFI has the 43 imports once in GHCJS arrow syntax, once in
statement syntax (GHCJS 8.6, GHC wasm, MicroHs), and a native GHC arm
that keeps the generic calls, since native GHC has no JavaScript FFI.
Operations that return a value (gradients, patterns, image data) and
addColorStop stay on the generic path. The public API is unchanged.

sample-app canvas benchmark, MicroHs -tbrowser (jsval-2ltt build),
headless Chromium, ms/frame (median), master -> this:
   1000 rects   49 ->  20
   4000 rects  194 ->  79
  10000 rects  489 -> 200
about 2.5x. Only the MicroHs backend has been built and measured here;
the GHCJS, wasm and native arms are untested.
GHC wasm: JSString was imported without its constructor, so GHC could
not unwrap the newtype in the foreign imports that take one and fell
back to a C stub with an unknown HsJSString type ("unknown type name
'HsJSString'", "call to undeclared function 'rts_getJSString'").
Import JSString (..); the JS backend exports it abstractly, so this is
a no-op there.

Native GHC: the generic arm passes method names as string literals to
(#) and setField, which take a MisoString (Text), so the module needs
OverloadedStrings.
startSub/stopSub take any ToMisoString key, so "canvas" was ambiguous
under GHC's OverloadedStrings; annotate it. ?rects=N was only read on
MicroHs; read it on GHC wasm and the GHC JavaScript backend too.
fillStyle, strokeStyle and shadowColor with an RGB or RGBA colour (which
includes the named colours) now pass the channels as numbers to a direct
import that builds the rgb(...) string in JavaScript. renderColor built
it in Haskell, and on the GHC backends every ms and <> on a JSString is
an FFI call, then the result needed a JSVal handle: about 5.7 us per
colour, eight times a fillRect. Other colours, gradients and patterns
keep the generic path. Native GHC keeps setField with renderColor.

At 10000 rects the colour was 57 of 72 ms per frame on GHC wasm; the
fills themselves are about 9 ms.

sample-app canvas benchmark, headless Chromium with the frame rate
limiter off, fps (median of 3 interleaved rounds), master -> this branch:

                1000 rects     4000 rects     10000 rects
  GHC JS        90.9 -> 361.8  24.5 -> 112.5  9.4 -> 47.5   (4.0-5.1x)
  GHC wasm      60.9 -> 340.8  15.5 -> 117.4  5.3 -> 46.0   (5.6-8.7x)
  MicroHs       20.4 ->  98.4   5.2 ->  27.8  1.9 -> 10.1   (4.8-5.3x)

Hand written JavaScript drawing the same scene (an rgb() string per rect
per frame) runs at about 68 fps at 10000 rects on the same machine.
master no longer supports GHCJS 8.6 (#1672), so ghcjs_HOST_OS is never
defined; the statement-syntax imports are for GHC wasm and MicroHs.
@dmjio dmjio changed the title Miso.Canvas: direct JavaScript imports for canvas operations and rgb()/rgba() colours Miso.Canvas: direct JavaScript imports for canvas operations and rgb()/rgba() colours Oct 7, 2026
dmjio added 2 commits October 7, 2026 02:03
sample-app is the counter app again (as on master), with its INTERACTIVE
branches: 'live' for hot reload and no hs_start export under GHCi, so
make watch and make repl work again.

The benchmark is now its own package, canvas-bench, in sample-app-canvas
with a Makefile for GHC wasm (build optim), the GHC JavaScript backend
(build-js) and MicroHs (mhs). It has its own cabal.project that builds
miso without the 'native' flag the top-level project sets: the
benchmark runs in the browser, and native mode's startApp needs JSON
instances for the model and action, which a DOMRef cannot have.

bench.mjs now defaults to http://localhost:8080/, where make serve and
make serve-mhs listen (it pointed at 8123).
sample-app-canvas (the canvas-bench package, bench.mjs and its Makefile
targets) is kept on the canvas-bench branch.
@dmjio
dmjio merged commit 7d5402d into master Oct 7, 2026
6 checks passed
@dmjio
dmjio deleted the canvas-ffi branch October 7, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant