ezu-paint
Rendering primitives + built-in node implementations for the
ezu workspace.
This crate sits between the low-level brush engine
(hokusai) / 2D rasterizer
(tiny-skia) / blur (libblur) and the graph evaluator
(ezu-graph).
Three things live here:
- Paint primitives — functions that take a
Canvasand feature data and produce pixels. Reusable on their own. nodesmodule —NodeFactoryimplementations for each built-in op, grouped intoraster,source,paint,geometry,scalar,utilsubmodules. Each op self-registers viaezu_graph::submit_node!;default_registry()just collects everything viaNodeRegistry::from_inventory().hostmodule — host-side glue: ready-madeAssetLoaderimplementations (BrushBankLoaderfor document-scoped images / brushes,TileLoaderfor per-tile feature overlays), and conversions fromRasterBufto PNG / straight RGBA.
How a style paints
An ezu style is a typed node DAG, not an ordered layer list. Every
operation below is a node, and ports are statically type-checked across
seven kinds — Features, Raster, Sprite, Brush, Scalar, Labels,
ScalarField (see ezu-graph for what each
carries). Ports list the kinds they accept, so polymorphic ops (e.g.
blur over Raster/Sprite) pass the input kind straight through, and
intermediate buffers are cached and reusable across tiles.
Tile-pyramid inputs go beyond vectors: dem sources feed elevation as a
ScalarField, and raster sources feed RGBA imagery (XYZ / TileJSON /
PMTiles; satellite photos, pre-rendered basemaps) as a seam-free padded
Raster — so any filter chain can post-process a photo basemap
(photo-pop.json posterizes 国土地理院 aerial imagery). External inputs
— images, brushes, fonts, per-tile MVT/GeoJSON feature layers — enter
through one uniform AssetLoader trait (see Host
glue); the schemes a style names them with are documented
in the ezu-style README.
Example: a watercolor water layer with a brushed road on top of an
earth-tone background, composited bottom-to-top with stack.
stack composites its layers with plain source-over; reach for blend
when you want a specific blend mode, a composite operator, or a clipping
mask:
"paper": ,
"shaded":
The full reference watercolor style is in
crates/ezu/examples/styles/watercolor.json.
Paint primitives
| Function | Op name | What it does |
|---|---|---|
paint_polygons |
fill-solid |
tiny-skia solid fill + optional outline + libblur gaussian blur |
paint_polygons_dabs |
fill-dabs |
hokusai scatter-dab fill with world-deterministic position / size / opacity jitter — same world coord → same dab regardless of tile |
paint_lines |
line |
hokusai::Brush::stroke_to per polyline vertex with world-seeded pressure jitter |
For fill-dabs the polygon is rasterized to a binary mask, then a
regular grid of candidate positions is iterated; no brush trajectory is
constructed, which is what keeps fills seamless across tile boundaries.
Stroke curves on line
line exposes four optional stroke curves that vary brush
behavior along each polyline, so strokes can simulate taper-in /
taper-out and speed dynamics rather than running at constant pressure
and rhythm:
| Field | Drives | y semantics |
|---|---|---|
radius-stroke-curve |
brush radius_logarithmic (stroke input) |
log-space offset added to base radius. y = -2.3 ≈ ×0.1, y = +0.69 ≈ ×2 |
opacity-stroke-curve |
brush opaque (stroke input) |
linear offset added to base opaque |
hardness-stroke-curve |
brush hardness (stroke input) |
linear offset added to base hardness |
dtime-stroke-curve |
per-vertex dtime |
multiplier on the base dtime. y = 3 slows the hand 3×, y = 0.3 speeds it up |
Each curve is a piecewise-linear [[t, y], ...] where t is normalized
progress along the polyline (t = 0 at the first vertex, t = 1 at
the last). t values must be non-decreasing; at least two points are
required. Evaluation matches libmypaint's InputMapping::eval
(clamps below the first knot, extrapolates from the last segment).
When any of the brush-side curves (radius / opacity /
hardness) is set, paint_lines clones the brush per polyline and
auto-sets stroke_duration_logarithmic = ln(line_length_px) so the
brush's internal stroke input ramps from 0 → 1 over the full polyline
length on the rendered canvas. dtime-stroke-curve doesn't need a
clone — it scales the per-vertex dtime directly.
Example: ink-style taper (thin → fat → thin, faster in the middle):
"roads_primary":
Built-in nodes
ezu_paint::nodes::default_registry() returns a
NodeRegistry preloaded with:
Raster utility (nodes::raster)
| Op | Inputs → Output | Notes |
|---|---|---|
solid |
() → Raster|Sprite |
Constant-color fill. kind: raster (default) fills the canvas; kind: sprite emits a Sprite at width-px × height-px |
circle |
() → Raster|Sprite |
Centered disk with optional edge falloff. Sprite mode anchors radius to the shorter sprite side |
noise |
() → Raster|ScalarField |
Procedural noise: type (white/value/perlin/simplex/worley), scale-px (number for isotropic, [x, y] for anisotropic — wood grain / wave streaks), fBm via octaves/lacunarity/gain, optional domain warp (warp-amp/warp-freq), anchor (world default — seamless across tile borders). kind: raster (default) maps the noise to RGBA via low-color/high-color/opacity; kind: scalar emits the raw fBm value as a ScalarField for downstream map-range / hillshade / color-ramp |
blur |
Raster|Sprite → same kind |
Gaussian (libblur); pass-through over Raster/Sprite — the output kind mirrors the input. Grows upstream pad by 3σ |
displace |
Raster|Sprite + Raster|Sprite → mirrors main input |
Photoshop-style displacement map. displacement raster's R/G channels (0.5 = no offset) drive per-pixel offsets up to amp-px. Output kind mirrors the main input. Grows upstream pad by amp-px; boundary (clamp/transparent/mirror) handles edge sampling |
warp |
Raster|Sprite → same kind |
Domain warp via internal noise (same dial as noise: type, scale-px, octaves, lacunarity, gain, seed) plus amp-px. Pass-through over Raster/Sprite. anchor: world default → seamless across tile borders; grows upstream pad by amp-px |
blend |
Raster|Sprite base + over [+ mask] → mirrors base |
W3C blend modes (normal/multiply/screen/overlay/darken/lighten/color-dodge/color-burn/hard-light/soft-light/difference/exclusion/hue/saturation/color/luminosity), composite operator (over default / destination-out for brush-eraser), clip (source-atop, PS clipping mask), optional alpha mask, opacity. All three inputs accept Raster or Sprite; output kind mirrors base |
stack |
[Raster|Sprite] → mirrors first |
Composite an ordered layers list bottom-to-top with plain source-over — the n-ary form of a blend chain, and the usual document output |
mix |
Raster|Sprite ×2 → mirrors first |
Tween two rasters by a scalar t in a selectable colour space — a straight colour blend, not a composite |
brightness-contrast |
Raster|Sprite → same kind |
Linear brightness shift + contrast slope around mid-gray; pass-through over Raster/Sprite |
levels |
Raster|Sprite → same kind |
Photoshop-style levels: remap [in-black, in-white] through gamma onto [out-black, out-white]; generalises brightness-contrast with a midtone curve |
erode / dilate |
Raster|Sprite → same kind |
Per-channel morphological min / max over a square kernel of radius-px. Classic mask cleanup after color-to-alpha. Grows upstream pad by radius-px |
edge-detect |
Raster|Sprite → same kind |
Sobel gradient magnitude per channel, scaled by strength and clamped. Grows upstream pad by 1 |
hsl |
Raster|Sprite → same kind |
Hue rotation (degrees) + saturation/lightness shift in [-1, 1]; pass-through over Raster/Sprite |
invert |
Raster|Sprite → same kind |
Negate RGB (alpha preserved); pass-through over Raster/Sprite |
color-to-alpha |
Raster|Sprite → same kind |
Chroma-key: pixels near color (Chebyshev distance) become transparent with threshold/softness ramp; pass-through over Raster/Sprite |
saturate / vibrance |
Raster|Sprite → same kind |
Scale CIELAB chroma preserving hue + lightness — saturate uniformly, vibrance adaptively boosting low-chroma pixels |
posterize |
Raster|Sprite → same kind |
Quantise each RGB channel into steps evenly-spaced levels (non-premultiplied sRGB). Alpha preserved |
quantize |
Raster|Sprite → same kind |
Snap every pixel to the nearest entry of a fixed palette, in perceptual CIELAB (default) or RGB — limited-palette / poster / pixel-art looks |
dither |
Raster → Raster |
Palette reduction with error diffusion (Floyd–Steinberg) or an ordered Bayer matrix — retro / print looks |
mosaic |
Raster → Raster |
Quantise into uniform block × block squares. mode: "average" (default) blends covered pixels into a mean colour — classic mosaic filter. mode: "nearest" samples each block's centre pixel verbatim, giving hard block edges without inter-colour blending (compose with posterize for indexed-palette / pixel-art looks). anchor: "world" (default) keeps the block grid seamless across tile borders by growing the upstream pad; anchor: "tile" restarts the grid per-tile |
place |
Raster|Sprite → Raster |
Composite one image at fixed canvas coordinates with fit: none / cover / contain / stretch |
tiling |
Raster|Sprite → Raster |
Repeat an image across the canvas, world-anchored so the pattern is seamless across tiles |
channel-shuffle |
Raster|Sprite → same kind |
Rearrange RGBA channels: each output r/g/b/a names which input channel (or constant 0/1) feeds it. Operates in non-premultiplied sRGB |
sharpen |
Raster|Sprite → same kind |
4-neighbour Laplacian sharpen with strength amount. Grows upstream pad by 1 |
gradient-linear |
() → Raster|Sprite |
Linear gradient between two points. start/end as [x, y] fractions, stops: [[t, "#hex"], …], optional anchor: "tile" | "world". kind: sprite switches to sprite-local [0, 1] coords at width-px × height-px |
gradient-radial |
() → Raster|Sprite |
Radial / elliptical gradient. center, radius, optional aspect. Sprite mode same as linear |
gradient-conic |
() → Raster|Sprite |
Sweep gradient around center starting at start-angle (degrees). Sprite mode same as linear |
gradient-diamond |
() → Raster|Sprite |
Manhattan-distance gradient. center, radius. Sprite mode same as linear. All four gradients interpolate their stops in a selectable space (rgb default, plus hsl / hsv / hcl / lab; hue-based spaces take the shortest path around the wheel), and take an anchor: "tile" | "world" — world keeps the pattern seamless across tile borders |
hillshade |
ScalarField → Raster |
Horn-method analytical hillshade. azimuth-deg / altitude-deg light angle, z-factor / exaggeration, optional ESRI multidirectional. mode: shade (grayscale) or mode: relief (transparent black for multiply-blend over a base map). Geographically accurate only when the input's geo_scale is populated (DEM source); otherwise produces pixel-space gradients (fine for stylization) |
slope |
ScalarField → Raster |
Per-pixel slope angle as grayscale, normalised to 0..1 against max-deg; optional invert. Same geo_scale caveat as hillshade |
color-ramp |
ScalarField → Raster |
Map scalar values to colour via a stops: [{value, color}] table; linear interp, end colours clamp out-of-range. Canonical use is hypsometric tinting over an elevation ScalarField (stops[i].value = metres) but works on any scalar field |
map-range |
ScalarField → ScalarField |
Linearly remap from [in-min, in-max] to [out-min, out-max] with optional clamp. Normalise a DEM or distance field into [0, 1] before color-ramp |
density |
Features → ScalarField |
Kernel-density estimate over point features — the MapLibre heatmap kernel. Pair with color-ramp for a heatmap |
threshold |
ScalarField → ScalarField |
Binarise against value: emit low for samples ≤ value, high otherwise; softness gives a linear ramp instead of a hard step |
Sources (nodes::source)
| Op | Inputs → Output | Notes |
|---|---|---|
features |
() → Features |
Samples a host-bound vector tile layer. source (optional, matches a mvt/pmtiles entry in the document's sources block; defaults to the single such entry) + layer (the MVT layer name). Looked up as <source>.<layer> on the AssetLoader |
dem |
() → ScalarField |
Samples a host-bound DEM mosaic. source (optional, matches a dem entry in sources; defaults to the single such entry) — looked up by bare source name. The host fetches + decodes raster-DEM tiles (terrarium / mapbox-rgb) and binds the stitched scalar field (with geo_scale populated) per render |
raster |
() → Raster |
Samples a host-bound RGBA imagery mosaic (satellite photos, pre-rendered basemaps). source (optional, matches a raster entry in sources; defaults to the single such entry) — looked up by bare source name. The host fetches PNG/WebP/JPEG tiles (XYZ / TileJSON / PMTiles), stitches the 3×3 neighbourhood onto the padded canvas, and binds it per render; unbound tiles (on-missing: empty) emit transparent pixels |
image |
() → Sprite |
Load a PNG / WebP asset from the document's sources block at its native dimensions |
icon |
() → Sprite |
Crop one named icon out of a sprite atlas — the feed for stamp (symbol icons), tiling (fill-pattern) and line-stamp (line-pattern) |
literal-geometry |
() → Features |
Inline points / lines / polygons from style fields |
tile-bounds |
() → Features |
Polygon covering the current tile |
point-grid |
() → Features |
Regular grid of points across the tile |
point-scatter |
() → Features |
Random points at a given mean spacing across the tile — a variable count per cell, so no lattice frequency survives |
Feature paint (nodes::paint)
| Op | Inputs → Output | Notes |
|---|---|---|
fill-solid |
Features → Raster |
wraps paint_polygons |
fill-dabs |
Features → Raster |
wraps paint_polygons_dabs |
line |
Features + Brush → Raster |
wraps paint_lines |
stroke |
Features → Raster |
Crisp constant-width tiny-skia vector stroke with cap / join, optional dasharray, and a gap-width that renders MapLibre's line-gap-width casing annulus — clean cartographic lines rather than brushwork |
line-stamp |
Features + Raster|Sprite → Raster |
Repeat a sprite along each polyline, tangent-rotated and fit to the line width — MapLibre line-pattern |
circles |
Features → Raster |
Crisp filled disks at feature points with per-feature radius / colour / stroke — the vector counterpart to MapLibre's circle |
stamp |
Features + Raster|Sprite → Raster |
Paint a sprite at every feature point, with world-deterministic jitter |
text |
Features → Raster |
SDF glyph labels with self-contained collision — see Text labels |
text-labels / label-placement / text-draw |
Features → Labels → Raster |
The shared-placement trio: every label layer's candidates collide in one index — see Text labels |
brush-file |
() → Brush |
Load a MyPaint .myb brush, resolved by the host's AssetLoader |
brush-solid |
() → Brush |
Synthesize a crisp constant-width brush without a .myb file |
Geometry ops (nodes::geometry) — turf.js-flavored transforms, mostly Features → Features
| Op | Inputs → Output | Notes |
|---|---|---|
centroid |
Features → Features |
Polygon / line centroids as points |
boundary |
Features → Features |
Polygon rings as lines |
simplify |
Features → Features |
Douglas–Peucker |
convex-hull |
Features → Features |
Convex hull over all input vertices |
buffer |
Features → Features |
Offset / Minkowski-style buffer |
hatch |
Features → Features |
Hatch-line fill of polygons |
voronoi |
Features → Features |
Voronoi diagram of input points → edge polylines (2-point each). Polygons/lines ignored — pipe centroid upstream to derive seeds |
voronoi-fracture |
(Features, Features) → Features |
Fracture each polygon in features into Voronoi sub-cells seeded by seeds' points; cells clipped to the source polygon |
medial-axis |
Features → Features |
Approximate medial axis (skeleton) of each input polygon as polylines. densify-px controls boundary sampling, min-branch-px prunes short branches. Useful for river / lake centrelines |
bbox |
Features → Features |
Axis-aligned bounding box of every input vertex as a single rectangular polygon |
transform |
Features → Features |
Translate / rotate / scale every vertex. Rotation around an optional pivot |
smooth |
Features → Features |
Chaikin corner-smoothing on polylines and polygon rings; iterations controls passes |
densify |
Features → Features |
Insert intermediate vertices so no segment exceeds target-px. Originals preserved |
resample |
Features → Features |
Evenly-spaced vertices at spacing-px along arc length on each polyline / ring |
feature-boolean |
(Features, Features) → Features |
Polygon set ops: mode: union/intersection/difference/symmetric-difference. Lines / points on either input are dropped |
triangulate |
Features → Features |
Delaunay triangulation of input points → triangles as polygons |
contour |
ScalarField → Features |
Isolines from a scalar field via marching squares — contour lines over a DEM, edges of a noise field |
dash |
Features → Features |
Cut polylines into dash / gap segments |
wave |
Features → Features |
Lateral sine displacement of polylines, for hand-drawn wobble |
Scalars (nodes::scalar) — computed values for any scalar field
| Op | Inputs → Output | Notes |
|---|---|---|
zoom |
() → Scalar |
The tile's zoom level, for zoom-dependent styling |
math |
Scalar… → Scalar |
Arithmetic over literals, $params, and @node scalar ports |
expr |
() → Scalar |
Evaluate a MapLibre expression once per tile (the tile's zoom in context) and emit the result as a Scalar |
Utility (nodes::util)
| Op | Inputs → Output | Notes |
|---|---|---|
switch |
(any, any) → mirrors selected |
Build-time pick between a and b via select ("a" / "b", or bool / 0/1). Both inputs accept any port kind; output mirrors the selected input's kind. Use for A/B variants and param-driven branching |
pick-channel |
Raster → ScalarField |
Extract one of r/g/b/a/luminance as a [0, 1] ScalarField (non-premultiplied RGB; Rec. 601 luma). Bridges the raster pipeline into map-range / threshold / color-ramp |
Each factory implements NodeFactory::schema() so editors picking up
the registry-derived JSON Schema get per-op autocomplete. Adding a new
op means dropping a file under the right category and ending it with
ezu_graph::submit_node!(MyFactory); — no central list to edit.
Downstream crates can register custom ops on
top of default_registry().
Text labels
The text node renders labels the way MapLibre's symbol layer does,
from vector features:
- Shaping —
rustybuzz(a pure-Rust HarfBuzz port) shapes each label;placement: pointlabels each feature point,placement: line/line-centerwalks each polyline with tangent-rotated glyphs. Layout knobs mirror MapLibre —justify,anchor/anchor-variants(variable anchor),offset-em,max-width-em(wrapping),letter-spacing-em,spacing-px,max-angle-deg,keep-upright. - Two glyph backends — the
fontfallback stack namesfontand/orglyphssources:- a
fontsource supplies outline font bytes (TTF / OTF / TTC), which ezu shapes and rasterises into an SDF itself. Itsurlis a font file (file:,http(s)://,data:) or an installed-font reference (system:, below). - a
glyphssource is a MapLibre glyph-PBF endpoint — a{fontstack}/{range}URL template serving pre-rendered 24 px SDF glyphs in 256-codepoint ranges, fetched lazily per range. This is the exact glyph data maplibre-gl-js itself draws, so a translated style can label with zero font files.
- a
- SDF drawing — glyphs are composited from signed-distance fields, so
the
size,color, and halo (halo-color,halo-width) are all cheap runtime parameters. Every paint property has an optional*-exprsibling (color-expr,size-expr,halo-width-expr, …) evaluated per feature. - Deterministic collision — collision is on by default and is
deterministic across tile boundaries: candidates come from this
tile plus its 8 neighbours (host-bound under
<source>.<layer>@dx,dy), deduped and placed greedily bysymbol-sort-key, so a label straddling a tile edge is placed or dropped identically in both tiles. Set the node'ssource/layer(the upstream feature source) to enable neighbour gathering; without them collision is centre-tile-only.allow-overlap/ignore-placement/padding-pxmirror MapLibre. - Shared cross-layer placement & icons — label layers can split into
text-labels(candidates) feeding onelabel-placementnode, with atext-drawper layer painting its winners: every layer's labels then collide in one index, placed top layer first with ties broken by tile feature order, as MapLibre does. A point symbol's icon places with its text as one unit —icon-size/-anchor/-offset/-padding, overlap flags,text-optional/icon-optional, andicon-text-fitwith nine-slice sprite stretching are all honoured.
ezu translate emits exactly this shape from a MapLibre symbol layer;
see the ezu-translate README for the property
mapping and the known divergences.
The system: font scheme
A font source can resolve a face from the machine's installed
fonts by family name instead of shipping bytes:
"sans": { "type": "font",
"url": "system:Arial Unicode MS?weight=700&style=italic" }
The family may contain literal spaces or be percent-encoded; weight
(100–900, default 400) and style (normal / italic / oblique)
are optional query params. A system: reference makes the recipe
machine-dependent — the same family resolves to whatever face that
machine has installed, so glyph shapes and character coverage can differ
across environments, and it is unavailable in the browser/wasm host
(supply font bytes there). Reference a font file for a fully portable,
reproducible recipe.
Brushes
Nothing is bundled into the library — a style references every brush it
uses through a src in its sources block, and the host loads it from
disk, HTTP, or an inline data: payload. Any MyPaint .myb brush
works; brush-solid synthesizes a crisp constant-width one when no file
is wanted.
The example styles ship their brushes alongside the style JSON in
crates/ezu/examples/styles/brushes/
and reference them by relative file: path (resolved against the style
file's directory); those are CC0 brushes by David Revoy from
mypaint/mypaint-brushes
(attribution in
brushes/CREDITS.md).
Canvas
The canvas paints into a padded buffer (tile + 2 * pad) so blurs
extend cleanly through the tile edge and MVT buffer geometry that
overflows [0, extent] lands inside the buffer. Internal node impls
construct a Canvas, paint into it, then into_pixmap().take() to hand
the pixel Vec<u8> to the graph layer without a memcpy.
Host glue
use ;
let mut assets = new.with_dir;
assets.insert;
// Per render, overlay tile-scoped feature layers on top of the base
// loader. `bind_mvt` registers every layer under
// `<source>.<layer-name>` so the style's `features` nodes
// (`source: "basemap", layer: "earth"`) resolve to it.
let mut tile_loader = new;
tile_loader.bind_mvt;
let ev = new;
let raster = ev.render?;
let png = raster_to_png?; // cropped + PNG
let webp = raster_to_webp?; // cropped + lossless WebP
let rgba = raster_to_rgba8; // cropped, straight RGBA
// `crop_to_png` / `crop_to_webp` / `crop_to_rgba8` take a width and a
// height, for a canvas that is not a square tile (a legend swatch).
BrushBankLoader implements AssetLoader for document-scoped images
and brushes (in-memory + disk fallback). TileLoader is a per-render
overlay that adds tile-scoped feature bindings on top of any base
loader. Both compose freely with custom AssetLoader impls.
Tile binding convention
Anything the style's features / dem node references is expected
to be bound by the host once per tile. TileLoader::bind_mvt("<source>", decoded) walks every layer in a decoded MVT and registers each one
under <source>.<layer-name>, matching the features node's
source + layer fields. DEM bindings use the bare source name to
match the dem node's source field. Custom bindings (GeoJSON, in-
memory synthesized data, …) go through bind_features(<key>, layer)
where <key> is whatever string the style references.
Names that look like asset srcs — those with a scheme: prefix
(builtin:, file:, http(s)://) — bypass the per-tile bindings
and flow through to the base loader, which is where document-scoped
image / brush assets live. Unbound names without a scheme surface
as NotFound; the features op treats that as an empty layer so
sparse / partial-layer tiles render cleanly.
raster_to_png / raster_to_webp / raster_to_rgba8 all crop the
padded buffer down to the central tile region before encoding /
demultiplying. WebP uses the pure-Rust image-webp codec (lossless
only) — no native deps. A pixmap_to_webp(&tiny_skia::Pixmap) helper
covers non-tile-sized outputs (e.g. CLI bbox mosaics).
DEM sources (feature http)
host::dem ports the same sources-driven pattern to raster-DEM
tiles. build_dem_sources(doc) walks the style's sources block,
building one fetcher (terrarium or mapbox-rgb, PNG or WebP) per
declared source; bind_dem_sources(&mut tile_loader, ®istry, tile, canvas) fetches the 3×3 neighbourhood (date-line-wrapping in X,
edge-clamping in Y), bilinear-resamples it onto the padded canvas,
and binds the resulting ScalarField under the bare source name so
the style's dem node (source: "<name>") picks it up. Requests beyond the source's
max-zoom upsample from the appropriate ancestor tile. Decoded tiles
are cached unboundedly per source — well-suited to single-tile and
modest-pyramid renders; swap in an LRU bound if working sets ever
outgrow memory.
Features
parallel— pull-through toezu-graph/parallel(Rayon within-tile evaluation). No effect on the paint primitives themselves; the hot loops insidehokusaiare still single-threaded.http— enablehost::prefetch_doc_assets(walks a parsedDocument'sassetsblock, fetches everyhttp(s)://srcwithreqwest, and stages the decoded brush / image into aBrushBankLoader) and thehost::demmodule (raster-DEM tile fetcher + 3×3 stitch + overzoom upsampling that feeds theScalarFieldport). Off by default sowasm32keeps its dep graph minimal (the JS host fetches assets directly there).
License
MIT or Apache-2.0, at your option.