Hokusai
A pure Rust brush engine inspired by libmypaint, designed for WebAssembly and native targets.
π¨ Try the live demo β draws in your browser using the real libmypaint brushes, with stylus pressure and tilt where the device supports it.
Goals
- π¦ Pure Rust, no
unsafeβ clean WASM (wasm32-unknown-unknown) story. - π¦ libmypaint
.mybJSON compatibility β brushes authored for MyPaint / Krita load and round-trip without translation. - π― Pixel-level parity with libmypaint β same fix15 math, same tile layout, same stroke math. Compatibility is the design priority; the "Hokusai" name does not imply behavioural divergence.
- π Pluggable surfaces via the
TiledSurfacetrait. Backends are split into feature-gated crates. - πΊοΈ Tile-based infinite canvas β 64Γ64 RGBA fix15 tiles, matching libmypaint exactly so dab traversal and rounding stay bit-identical.
Workspace layout
hokusai/
βββ crates/
β βββ hokusai-core/ # Brush types, stroke engine, fix15, tiles, brushmodes
β βββ hokusai-brush/ # libmypaint `.myb` JSON read / write
β βββ hokusai-tile-mem/ # Reference in-memory TiledSurface
β βββ hokusai-tiny-skia/ # Flatten TiledSurface tiles into a tiny-skia Pixmap
β βββ hokusai-compat/ # Snapshot regression harness (libmypaint parity track)
β βββ hokusai-wasm/ # wasm-bindgen bindings + browser demo
βββ hokusai/ # Umbrella crate that re-exports the above via features
βββ examples/ # stroke_to_png, myb_to_png (+ vendored .myb fixtures)
Quick look
use ;
use myb;
use MemSurface;
let json = read_to_string?;
let brush: Brush = from_str?;
let mut state = default;
let mut surface = new;
// First call seeds position only; subsequent calls emit dabs.
brush.stroke_to;
brush.stroke_to;
# Ok::
Run the bundled examples to render to PNG:
Cargo features (umbrella hokusai crate)
| Feature | Default | What it enables |
|---|---|---|
myb-json |
β | .myb JSON parser / serializer |
tile-mem |
β | Reference HashMap-backed TiledSurface |
tiny-skia |
β | tiny-skia Pixmap flattening helpers |
(hokusai-wasm ships as its own cdylib crate rather than an umbrella feature β point your wasm-pack at crates/hokusai-wasm directly.)
libmypaint parity
137 / 196 stock brushes (β 70 %) match libmypaint at MAD β€ 0.5 under the brush-pack parity harness, with another 41 inside MAD β€ 5. Measured against libmypaint v1.6.1 + the upstream mypaint-brushes pack via cargo xtask brush-pack-report (see CONTRIBUTING.md for the setup). Remaining gaps are tracked in TODO.
Features
ποΈ Brush data
- All ~50 libmypaint settings as a strongly-typed enum with canonical string keys
- All inputs (
pressure,speed1/2,random,stroke,direction,tilt,custom,gridmap_*,attack_angle,viewzoom,barrel_rotation,brush_radius,tilt_declinationx/y, β¦) .mybv3 JSON parse / serialize, round-trip safe (unknown top-level settings preserved verbatim)
βοΈ Stroke engine (libmypaint-faithful port of update_states_and_setting_values)
- Per-dab setting evaluation (
base_value + Ξ£ curve(input)) with per-dab interpolation of pressure / speed across each segment slow_tracking+slow_tracking_per_dabcursor lag, withcount_dabs_tore-counted after every dab- Speed low-pass (
speed1_slowness/speed2_slowness) andspeed1_gamma/speed2_gammalog mapping direction_filterlow-pass on stroke direction (with the 180Β°-folded variant for 1D direction curves)tracking_noisegaussian jitter (radius-scaled, distance-coalesced viaskip_distance)offset_by_random/offset_by_speedjitter; fulldirectional_offsetsport (offset_x/y,offset_angle*,offset_multiplier,STATE.FLIPmirroring)radius_by_randomper-dab radius jitter with libmypaint's(orig / new)Β²opacity correctionopaque_linearizeper-dab overlap compensation- Tilt inputs (
tilt,tilt_declination,tilt_ascension) with the libmypaint 90Β° declination default and ramp-from-zero seeding attack_angle(signed angular difference between pen ascension and stroke direction + 90Β°)Strokeinput withstroke_duration_logarithmic,stroke_holdtime,stroke_thresholdgating- Gridmap inputs (
gridmap_x,gridmap_y) sampled fromSTATE.ACTUAL_X/Yviagridmap_scale[_x/_y] - Custom input chain (
custom_input_slownesssmoothingSETTING(custom_input)intoINPUT(CUSTOM)) - Per-dab HSV / HSL drift (
change_color_h/_v/_hsv_s/_hsl_s/change_color_l) - Spectral pigment mix (
paint_mode) β 10-channel WGM viargb_to_spectral/spectral_to_rgbwith thespectral_blend_factorsigmoid - Smudge bucket sampling + mixing with lazy
smudge_length_log-gated resample (PREV_COL_RECENTNESS),apply_smudge/eraser_target_alphasource-alpha bias, andsmudge_transparencyopacity-gated rejection - Fresh-stroke / long-pause detection
π¨ Pixel blending (draw_dab)
- Normal + Eraser blend in linear sRGB fix15 (premultiplied alpha)
- Spectral
paint_modeblend (BlendMode_Normal_and_Eraser_Paint) with low-alpha additive fade - Colorize blend (replace hue/sat, keep value)
- Two-segment hardness falloff with
anti_aliasingsub-pixel edge feathering (port ofcalculate_rr_antialiased) - Elliptical dabs (
aspect_ratio,angle) lock_alphamasking,posterizequantization as its own post-pass (sopaint_mode = 1brushes still posterize)- Spectral
get_color(Surface2::get_color_pigment) for smudge sampling whenpaint_mode > 0
π¬ Compatibility
- Knuth lagged-Fibonacci PRNG port of libmypaint's
rng-double.c(TAOCP 3.6-15, KK=10 LL=7 TT=7, seed 1000) with the samerand_gaussscaling - libmypaint-sourced PNG goldens via a small C wrapper around
mypaint_brush_stroke_to_2(cargo xtask regenerate-goldens) - Brush-pack parity tool (
cargo xtask brush-pack-report) β drives all 196 stock brushes through a fixed pressure-ramp curve. Current state: 137 / 196 stock brushes pass MAD β€ 0.50, 41 amber (β€ 5.0), 18 red - Per-dab tracing (
HOKUSAI_TRACE_DABS=1) prints identical-format dab lines from both engines forpaste-diff debugging HOKUSAI_UPDATE_GOLDENS=1snapshot harness for in-tree regression
π§± Infrastructure / backends
- Tile-aware traversal across arbitrary canvas extents (64Γ64 RGBA fix15, libmypaint-identical)
hokusai-tiny-skiaβ flatten anyTiledSurfaceinto atiny_skia::Pixmaphokusai-wasmβwasm-bindgenJS bindings + browser demo- CI: fmt, clippy
-Dwarnings, test on Linux/macOS/Windows, wasm32 build check, MSRV 1.88
TODO
The brush-pack-report (cargo xtask brush-pack-report) is the source of truth
for what's left. As of the latest run, 18 brushes are red (MAD > 5) and 41 are
amber. The remaining gaps cluster into:
- RNG-divergent scatter brushes (
Tail_Feathers,Tail_Feathers2,Flight_Feathers,Fan#1,imp_details,impressionism,puantilism2,spray,spray2,particules_eraser,DNA_brush,Clouds,texture-06,oil-0{1,3}-paint,coarse_bulk_1). The individual dab formulas match; the dab sequence diverges because some upstream RNG consumer is offset by an unknown count. UseHOKUSAI_TRACE_DABS=1 paste-diff (seeCONTRIBUTING.md) β the first drifting column points at the missing consumer. - Surfacemap-input brushes (
Posterizer,Round#1). libmypaint's v1.6.1 dylib outputs all-white for these because the reference C wrapper rejects them via surfacemap; hokusai actually paints. Either teach the wrapper to accept these brushes, or skip them in the report. anti_aliasing > 1.0and full-dab feathering edge cases β landed recently but only covered by one fixture; needs sweep across more brushes.
Contributing
See CONTRIBUTING.md for build / test / snapshot workflow, fixture conventions, and commit-message style.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Vendored brush fixtures under hokusai/examples/fixtures/ are unmodified copies from mypaint-brushes (CC0 1.0).