Build Google Ink — Google's freehand stroke generation library (the C++ engine behind Android's Jetpack Ink, and the same family of tech used by Google Keep / Chrome Canvas) — for WebAssembly.
Google Ink is a Bazel 7+/bzlmod C++20 project with no built-in JS bindings. The
Node scripts in scripts/ add an Emscripten toolchain to its Bazel build,
compile the core stroke pipeline to wasm, and expose it to JavaScript via Embind.
The TypeScript wrappers in src/ are then bundled together with the Emscripten
glue by tsdown (run through
Vite+) into three packages — ESM, UMD, and a wasm-free
UMD fallback for legacy browsers — with TypeScript types and a choice of
rendering backends (Canvas2D, WebGL, or CanvasKit/Skia).
The published package ships the prebuilt wasm — you do not need Bazel or Emscripten to use it, only to rebuild it.
pnpm add ink-wasm
# or: npm install ink-wasm / yarn add ink-wasmimport { createInk, createCanvas2dBackend } from "ink-wasm";
const ink = await createInk();
const mesh = ink.generateStrokeMesh(
[
{ x: 10, y: 10, t: 0 },
{ x: 60, y: 50, t: 0.04 },
{ x: 120, y: 60, t: 0.08 },
],
"marker", // "marker" | "pressure_pen" | "highlighter"
0.1,
0.4,
0.9,
1, // rgba, 0..1
4, // brush size
0.1, // simplification epsilon
);
const backend = createCanvas2dBackend(canvas);
backend.clear();
backend.drawMesh(mesh, { r: 0.1, g: 0.4, b: 0.9, a: 1 });
backend.present();createInk loads ink.wasm lazily relative to the bundle URL; with any modern
bundler (Vite, webpack, esbuild) or in Node it resolves automatically. If you
serve the wasm from a custom location pass locateFile, or hand it the bytes
directly with wasmBinary:
const ink = await createInk({ locateFile: (f) => `/assets/${f}` });Entry points:
| Import | What it is | Types |
|---|---|---|
ink-wasm |
stroke engine (createInk) + all three renderers (dist/index.js + dist/ink.wasm) |
dist/index.d.ts |
ink-wasm/renderer |
Canvas2D + WebGL + CanvasKit rendering backends only, no wasm (dist/renderer.js) |
dist/renderer.d.ts |
ink-wasm/ink.wasm |
the raw wasm binary (for bundler URL/asset handling) | — |
ink-wasm/umd |
same API as ink-wasm, as a UMD bundle (dist/umd/index.js + dist/umd/ink_umd.wasm) — for a plain <script> tag, CommonJS require(), or AMD, instead of ES modules |
dist/index.d.ts |
ink-wasm/umd/renderer |
renderer-only UMD bundle, no wasm (dist/umd/renderer.js) |
dist/renderer.d.ts |
ink-wasm/legacy |
same API again, but no WebAssembly at all — the whole engine compiled to asm.js (dist/legacy/index.js, no .wasm sibling) — for browsers that can't run wasm |
dist/index.d.ts |
The package also sets unpkg/jsdelivr to the UMD build, so a CDN <script>
tag works out of the box:
<script src="https://unpkg.com/ink-wasm/dist/umd/index.js"></script>
<script>
InkWasm.createInk().then((ink) => {
/* ... */
});
</script>ink-wasm/legacy exists specifically for browsers without WebAssembly
support: it ships the same Embind API (createInk, generateStrokeMesh,
the renderers) but the stroke pipeline runs as plain JS (asm.js) instead of
wasm. It's ~1.3 MB unminified-equivalent vs. ~30 KB + 580 KB wasm for the
other builds, and noticeably slower — use it only as a fallback, e.g. behind
an if (!window.WebAssembly) check:
<script>
if (!window.WebAssembly) document.write('<script src="/dist/legacy/index.js">\x3C/script>');
else document.write('<script src="/dist/umd/index.js">\x3C/script>');
</script>The rest of this document covers building the wasm from source.
- Node.js 18+
bazeliskonPATH(recommended) — the build is driven through Google Ink's own Bazel setup, and bazelisk downloads the exact version ink needs (Bazel 8.7.0, pinned intoink/.bazelversionbypnpm setup). A plainbazelworks only if it already is that version: ink ships a Bazel 8 lockfile that Bazel 7 refuses to read. Point the build at a specific binary withBAZEL=/path/to/bazel. (mise.tomlpins 8.7.0 for mise users; the build refuses to run against a mismatched major rather than failing later on an unreadable lockfile.)- git — Google Ink is a submodule, pinned to a known-good revision
- A C/C++ host toolchain (Google Ink registers its own LLVM toolchain; Bazel still needs basics)
Emscripten itself is downloaded by Bazel (via the emsdk module) — you do
not need a system emcc.
git clone --recurse-submodules https://github.com/playforge-coding/ink-wasm.git
# already cloned without it: git submodule update --init --depth 1 ink
pnpm install
pnpm setup # check out the ink submodule, register emsdk, stage the //wasm targets
pnpm build:wasm # bazel build -> wasm-build*/ink.{js,cjs} + .wasm + .d.ts (x3 variants)
pnpm build:js # vp pack (tsdown) -> dist/, dist/umd/; + dist/legacy/
pnpm test # run the pipeline in Node against the built ESM bundle
vp check # oxfmt + oxlint + type checkpnpm build runs all three build steps in order (checking out + patching ink
first if needed). The first build downloads the Emscripten toolchain plus the
abseil/protobuf dependency tree, so it takes a while; subsequent builds are
incremental — build:wasm compiles three variants (ESM, UMD, wasm-free
legacy) from the same C++ source in one bazel build invocation, sharing
cached compilation of the ink/abseil/skia dependency tree, so adding the UMD
and legacy variants costs one extra link step each, not a full rebuild.
pnpm clean removes dist/ and all wasm-build*/ dirs, and resets the ink/
submodule (git checkout + git clean -xfd inside it) rather than deleting it.
ink/ is a git submodule pinned to one upstream revision, so every checkout
builds the exact Google Ink tree this package was tested against — upstream
main moves, and has changed the Bazel version it requires mid-stream. To bump
it:
git -C ink fetch --depth 1 origin main && git -C ink checkout FETCH_HEAD
pnpm clean && pnpm build # re-apply the patches, rebuild, confirm it works
git add ink && git commit -m "bump ink"pnpm setup and pnpm build:wasm write inside the submodule (.bazelversion,
the MODULE.bazel patch, wasm/, the Bazel lockfile), so its working tree is
dirty by design; .gitmodules sets ignore = dirty so only the pinned revision
is tracked. Setting INK_REF=<ref> checks out another upstream ref for a
one-off test without moving the pin.
pnpm serve # copies dist/ into examples/ and serves itOpen http://localhost:3000/index.html.
Draw with the mouse — strokes are meshed by Google Ink in wasm and rendered to a canvas. Four demos are provided:
examples/index.html— renders with Canvas2D (zero dependencies).examples/webgl.html— renders with WebGL (zero dependencies), onedrawElementsper stroke; accumulates strokes and reports frame time.examples/canvaskit.html— renders with CanvasKit (Skia compiled to wasm), using Skia's antialiased GPUdrawVertices.examples/paint.html— a fuller paint app (palette, brushes, undo, save PNG) on Canvas2D.
- Checks out the pinned
inksubmodule (git submodule update --init --depth 1, skipped if already present). - Pins Bazel 8.7.0 — the version ink's lockfile and CI require — via
ink/.bazelversion, whichbazeliskhonors. - Appends an
emsdkbazel_dep(+ a single-version abseil override) toink/MODULE.bazel(idempotent, marked with comment fences). - Stages the
//wasmBazel package (wasm-src/BUILD.bazel,wasm-src/bindings.cc) intoink/wasm/.
Env: INK_REF (default: the pinned submodule revision), EMSDK_VERSION
(default 5.0.7), BAZEL_VERSION (default 8.7.0).
- Runs a single
bazel build -c opt --lockfile_mode=update //wasm:ink_wasm //wasm:ink_wasm_umd //wasm:ink_wasm_legacyinside./ink. (ink's.bazelrcpins--lockfile_mode=error; theemsdkbazel_depsetup.mjs adds is by definition missing from ink's checked-in lockfile, so the build overrides it toupdate.) - For each of the three variants, copies its emitted
.js/.wasmfrombazel-bininto its own./wasm-build*directory, normalizing the main glue file toink.js(ESM) orink.cjs(UMD/legacy — see "How it builds"). - Copies the TypeScript declarations (
wasm-src/ink.d.ts, identical across variants) alongside each asink.d.ts(+ink.d.ctsfor the.cjsones).
Env: BAZEL (binary to use), BAZEL_ARGS (extra Bazel flags).
Vite+ runs tsdown from the pack block, which holds one
entry per bundle:
- esm — bundles
src/index.ts+src/renderer.tswith the ESM glue intodist/index.js+dist/renderer.js, emits declaration files, and copieswasm-build/ink.wasm(plusLICENSE/NOTICE/README.md) alongside. - umd / umd:renderer — same API (
src/index.umd.ts,src/renderer.ts) bundled as UMD (globalName: "InkWasm") intodist/umd/, with the UMD glue's.wasmcopied alongside under its own name (dist/umd/ink_umd.wasm). Rolldown can't code-split a UMD bundle, so the two entries are separate configs writing into the sameoutDir.
The wasm-free fallback (src/index.legacy.ts → dist/legacy/index.js, UMD,
downleveled to es2015 — oxc's floor, and the asm.js glue is already ES5) is
not built by vp pack. Rolldown's tree-shaking miscompiles Emscripten's
wasm2js output — the bundle loads but abort()s partway through the first
stroke — and tsdown offers no way to switch tree-shaking off, so this script
drives Rolldown (the bundler Vite+ itself runs) directly with
treeshake: false, and with a minify that skips the compress pass because
it breaks that code the same way. Fold it back into the pack block once
tsdown honors treeshake: false.
Vite+ also drives oxlint and
oxfmt from the lint and fmt
blocks of the same vite.config.ts. vp check runs formatting, linting and
type checking in one pass; vp lint --fix and vp fmt write fixes. The
generated trees (dist/, wasm-build*/, the ink/ submodule) are excluded
via ignorePatterns.
wasm-src/BUILD.bazel defines three cc_binary targets, all linking the same
core Ink libraries (strokes, brush, geometry, color, types) with the
Embind glue in wasm-src/bindings.cc, differing only in Emscripten flags:
| Target | Flags | Used by |
|---|---|---|
ink |
EXPORT_ES6=1, ENVIRONMENT=web,worker,node |
ESM bundle |
ink_umd |
no EXPORT_ES6, ENVIRONMENT=web,worker |
UMD bundle |
ink_legacy |
same as ink_umd, plus WASM=0 (asm.js) |
legacy (no-wasm) bundle |
EXPORT_ES6 controls whether the glue is an ES module (import.meta.url-based
wasm resolution — required for the ESM bundle) or a classic-script/CommonJS
module (document.currentScript-based resolution). import.meta is a syntax
error outside ES modules, so the UMD and legacy bundles must use the
non-ES6 glue — that's also why their glue is shipped as ink.cjs, not
ink.js: this package is "type": "module", so a plain .js extension
would be parsed as ESM regardless of its actual CommonJS syntax. WASM=0
additionally makes Emscripten compile straight to asm.js instead of
WebAssembly, so ink_legacy's glue is fully self-contained JS with no .wasm
output at all. The wasm bytecode itself is unaffected by EXPORT_ES6/
ENVIRONMENT (confirmed: ink.wasm and ink_umd.wasm are byte-identical in
size), so only the JS wrapper differs between the ESM and UMD builds.
The wasm_cc_binary rule from the emsdk module transitions each target —
and its entire transitive dependency graph — onto the Emscripten toolchain and
emits the .js/.wasm.
Only the stroke-geometry core is compiled into wasm: the input → brush →
stroke → mesh pipeline, which produces GPU-ready vertex/index buffers directly.
Google Ink's native C++ rendering module depends on Skia and is not compiled
here; instead, rendering is done on the JS side, where you can feed the mesh to
Canvas2D, straight to the GPU via WebGL, or to Skia via CanvasKit (see
backends below).
import { createInk } from "ink-wasm";
const ink = await createInk();
ink.version(); // smoke test string
const mesh = ink.generateStrokeMesh(
[ { x, y, t, pressure? }, ... ], // input points; t = seconds since start
"marker", // "marker" | "pressure_pen" | "highlighter"
r, g, b, a, // color, 0..1
size, // brush size
epsilon, // simplification tolerance
);
// -> null on failure, else:
// { vertices: Float32Array /*x,y*/, indices: Uint32Array, vertexCount, triangleCount }vertices/indices map straight onto a WebGL drawElements call, a CanvasKit
MakeVertices call, or the Canvas2D triangle fill shown in the examples.
Full TypeScript declarations ship in dist/, so the import is fully typed.
All three backends implement the same interface, so you can swap renderers
without touching your stroke logic. They live behind the ink-wasm/renderer
subpath too (no wasm) for callers that bring their own geometry:
import {
createCanvas2dBackend,
createWebglBackend,
createCanvasKitBackend,
} from "ink-wasm/renderer";
// Canvas2D — zero dependencies:
const backend = createCanvas2dBackend(canvas);
// or WebGL — zero dependencies, one drawElements call per stroke:
const backend = createWebglBackend(canvas, { background: { r: 1, g: 1, b: 1, a: 1 } });
// or CanvasKit (Skia/wasm) — pass an initialized CanvasKit instance:
// const CanvasKit = await CanvasKitInit({ locateFile: ... });
const backend = createCanvasKitBackend(CanvasKit, canvas, { background: CanvasKit.WHITE });
const color = { r: 0.1, g: 0.4, b: 0.9, a: 0.85 };
backend.clear();
backend.drawMesh(mesh, color); // mesh from ink.generateStrokeMesh(...)
backend.present(); // flush (no-op for Canvas2D)Which one to pick:
| Backend | Deps | Antialiasing | Notes |
|---|---|---|---|
createCanvas2dBackend |
none | none (visible triangle seams) | simplest; CPU-bound on large drawings |
createWebglBackend |
none | MSAA only (context antialias) |
uploads the mesh as-is and draws it on the GPU; scales to hundreds of thousands of triangles per frame |
createCanvasKitBackend |
CanvasKit (~6 MB wasm) | full, Skia-quality | matches what Google Ink renders with natively |
createWebglBackend creates its own context (WebGL2, falling back to WebGL1)
unless you hand it one via gl, which also lets it share a context with your
own rendering — it sets the pipeline state it needs on every draw rather than
assuming it owns the context. The drawing buffer is preserved and premultiplied,
so strokes accumulate across frames like Canvas2D and canvas.toDataURL()
captures what's on screen. Context loss is handled: draws are dropped while the
context is gone and the program/buffers are rebuilt when it is restored.
createCanvasKitBackend takes an already-initialized CanvasKit so it stays
environment-agnostic; load CanvasKit from the canvaskit-wasm npm package or a
CDN (examples/canvaskit.html shows the CDN path).
scripts/
setup.mjs # clone + patch the ink checkout
build-wasm.mjs # bazel build -> wasm-build*/ (glue + .wasm + .d.ts, x3)
build.mjs # setup (if needed) -> build-wasm -> vp pack -> build-legacy
build-legacy.mjs # rolldown -> dist/legacy/ (asm.js fallback; see above)
common.mjs # shared paths + helpers (incl. WASM_VARIANTS)
clean.mjs
test.mjs
vite.config.ts # Vite+: pack (esm/umd -> dist/) + oxlint/oxfmt config
wasm-src/ # the Bazel package staged into ink/wasm
BUILD.bazel # 3 cc_binary targets: ink, ink_umd, ink_legacy
bindings.cc # Embind bindings over the Ink stroke pipeline
ink.d.ts # TypeScript declarations for the emitted glue (all 3 variants)
src/
index.ts # ESM public entry: createInk + renderers
index.umd.ts # same API, UMD entry (wasm-backed)
index.legacy.ts # same API, UMD entry (asm.js, no wasm)
ink.ts # typed wrapper around the ESM wasm module
ink.umd.ts # typed wrapper around the UMD wasm module
ink.legacy.ts # typed wrapper around the asm.js module
ink-types.ts # shared types (Ink, StrokeMesh, InitOptions, ...)
renderer.ts # Canvas2D + WebGL + CanvasKit (Skia) rendering backends
locate.ts # ESM-only default wasm URL resolution (import.meta.url)
examples/
node-test.mjs # Node smoke test
index.html # browser drawing demo (Canvas2D)
webgl.html # browser drawing demo (WebGL)
canvaskit.html # browser drawing demo (CanvasKit / Skia)
paint.html # fuller paint app demo (Canvas2D)
ink/ # Google Ink (git submodule, pinned revision)
wasm-build/ # ESM Emscripten glue + .wasm + d.ts (gitignored)
wasm-build-umd/ # UMD Emscripten glue + .wasm + d.ts (gitignored)
wasm-build-legacy/ # asm.js Emscripten glue + d.ts, no .wasm (gitignored)
dist/ # esm bundle: index.js + renderer.js + ink.wasm + d.ts
dist/umd/ # umd bundle: index.js + renderer.js + ink_umd.wasm
dist/legacy/ # legacy bundle: index.js only, no wasm