Skip to content

Add stroke_path for whole-path offline strokes - #135

Merged
TheTechromancer merged 4 commits into
darkly-art:devfrom
daghack:offline-stroke-path
Oct 6, 2026
Merged

TheTechromancer merged 4 commits into
darkly-art:devfrom
daghack:offline-stroke-path

Conversation

@daghack

@daghack daghack commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

I use Darkly headless, as a library, to render journal pages offline from strokes generated in code. Every stroke is fully known before it is drawn, but the only way to paint it was to replay it point by point through begin_stroke / stroke_to / end_stroke, as if a pen were drawing it live. Profiling my renderer showed 94% of the main thread inside stroke_to, with nearly half of that blocked waiting on Metal command buffers: the time was going to per-event submissions, rewinds and full-layer commits, not to drawing.

This adds a way to hand Darkly a whole stroke at once and have it drawn in a single pass. It should help anyone driving Darkly without a live pen, such as offline or batch rendering, stroke replay, or generated art, and it keeps output reproducible by letting the caller pin the random seed.


Summary

Adds DarklyEngine::stroke_path(layer, &[StrokeOp]) -> Result<(), String>, which paints a brush stroke whose samples are all known up front (offline rendering, replay) in one pass. The live path treats every stroke_to as a pen event: stabilize, rewind to a checkpoint, re-render the tip with its new lookahead, and composite the whole layer, which is about 3 submissions per event and up to about 10 when the stabilizer diverges. stroke_path stabilizes all samples in one batch, renders the final polyline once from the stroke-start state, and commits once: one submission per dab phase. Undo is recorded as for any stroke.

Also fixes a crash that any dab phase over MAX_DABS_PER_PHASE (16384) hits today, reachable now through the brush preview.

Changes

  • stroke_path (engine/painting.rs): refuses if a stroke is open, or if any op is not StrokeOp::BrushStroke, before touching state. Opens with begin_stroke, runs every op's growth and undo prelude first (extracted from gpu_stroke_to as prepare_stroke_op), so the existing lazy init creates the stroke buffer once at the final extent. Then it renders through brush_stroke_to with a private BrushRender::Path, and closes with end_stroke. brush_stroke_to takes BrushRender::Event | Path in place of eight raw pen fields; the Path arm returns early, so the per-event body is not re-indented. Prediction is never applied to a path.
  • StrokeEngine::render_whole: begin_stroke, every dab of the stabilized polyline, commit, into one context. The brush preview renderer uses it in place of its three hand-written phases, so preview and whole-path strokes share one render.
  • StabilizerAlgorithm::push_all with a looping default. LaplacianStabilizer overrides it to relax once: O(NL) instead of O(NL^2) for an L-point path, with a bit-identical polyline. StrokeEngine::stabilize_all feeds it.
  • Dab-cap phase split (StrokeEngine::place_dab): when a dab-batching terminal's queue reaches MAX_DABS_PER_PHASE, it flushes the terminals and submits through BrushGpuContext::submit_and_continue, which is factored out of flush_if_needed. Over-cap phases used to trip queue_dab's debug assert, and in release they fail wgpu validation (a panic natively, a dropped stroke on the web).
  • DarklyEngine::set_stroke_seed(Option<u32>): a session setting that seeds random nodes for reproducible renders. The default None keeps the wall-clock seed.
  • StrokeOp derives Clone, Copy and gains pen_sample(), the one conversion to PaintInformation for both paths.
  • stroke_replay_bench --whole-path: times the recording live against one stroke_path call, through GPU completion.
  • Docs: a "Whole-path strokes" section and the dab cap in docs/brush/architecture.md; whole-path strokes in the per-frame flow in docs/brush/stabilization.md.

Output equivalence

  • stroke_path is byte-identical to the live path forced through a full re-render on every event (test_set_full_rerender), at every stabilizer strength, with prediction off.

  • Against the default live path it is byte-identical at stabilize = 0 when no dab clips at the layer edge before a later growth. Tested for every builtin brush.

  • At stabilize > 0 the default live path keeps segments drawn against lookahead points that later moved, so output differs. Measured on a 204-event recorded stroke at 1024x512:

    Brush @ strength Pixels differing Nature of the change
    Ink Pen @ 0.6 about 25% mostly antialiased edges of 8 levels or less; total ink within 0.1%
    Calligraphy @ 0.6 about 3% same kind of edge change
    Hair @ 0.5 90% a visibly different bristle texture, since bristles follow direction history; two live runs are byte-identical

Tests

  • tests/preview_dab_cap.rs (regression): a 16k+ dab zig-zag through BrushStrokePreviewRenderer::render_stroke. It fails on dev with the queue_dab overflow assert and passes with the split.
  • tests/stroke_rewind.rs: the harness is generalized (per-side oracle flag, prediction pinned off, stroke seed pinned). New tests:
    • every builtin against the oracle at 0 and 0.6, plus default live at 0;
    • the recorded stroke through Ink Pen at 0.6;
    • growth past the plane origin with a cropped window, with one and two grounds;
    • erase;
    • a dab-cap split matching live;
    • submissions bounded by 1 + dabs / MAX_DABS_PER_PHASE;
    • refusals: a non-brush op, a locked layer, and an open stroke left undisturbed;
    • a mask target.
  • Laplacian unit test: push_all matches sequential push bit for bit.

Performance

stroke_replay_bench --whole-path, recorded 204-event stroke, 1024x512, release, Apple GPU, timed through GPU completion:

Brush Live stroke_path Speedup
Ink Pen 136.8 ms, 1308 submits 20.1 ms, 1 submit 6.8x
Calligraphy 204.3 ms 39.1 ms 5.2x
Rough Ink 173.1 ms 28.4 ms 6.1x

Denser input (for example 1 px samples) gains more, since the live cost is per event.

Notes

  • stroke_path is a plain pub fn, not a #[handler]; there is no frontend caller yet.
  • Undo still runs a full-layer snapshot and diff per stroke. Making that cheaper for back-to-back offline strokes is left to a follow-up.
  • Pre-existing and unrelated: tests/watercolor.rs::watercolor_mark_is_invariant_to_flush_grouping fails identically on dev on the machine this was tested on.

🤖 Generated with Claude Code

DarklyEngine::stroke_path(layer, &[StrokeOp]) paints a stroke whose
samples are all known up front. It stabilizes them in one batch and
renders the final polyline once (one submission per dab phase), instead
of the live path's per-event rewind, re-render and full-layer commit.
Output equals the live path's full re-render at every stabilizer
strength, and the default live path at strength 0. Undo is recorded as
for any stroke.

- StrokeEngine::render_whole, shared with the brush preview renderer
- StabilizerAlgorithm::push_all; the Laplacian override relaxes once
  (O(N*L) instead of O(N*L^2)) with a bit-identical result
- place_dab flushes and submits when a phase reaches
  MAX_DABS_PER_PHASE; a longer phase overflowed the dab buffer
  (regression test: tests/preview_dab_cap.rs)
- DarklyEngine::set_stroke_seed for reproducible random-node brushes
- stroke_replay_bench --whole-path compares live against stroke_path
@TheTechromancer

Copy link
Copy Markdown
Member

@daghack thanks for your work on this. It's a good feature to have.

I just did a bunch of work on the stabilizer including some performance improvements which should get us about halfway there. I'll make a PR to your PR and you can let me know if it looks good.

Merge dev into offline-stroke-path

dev's per-frame stroke flush (darkly-art#134) already renders a stroke no frame
ran during in one pass at pen-up, so stroke_path, BrushRender,
StrokeOp::pen_sample and StrokeEngine::stabilize_all are dropped, along
with the stroke_path tests. push_all no longer fits dev's windowed
Laplacian and is dropped here; batching returns in a later change.

Kept from this branch and adapted to dev:

- the dab-cap phase split in place_dab and submit_and_continue, now
  reachable from a headless stroke's single pen-up phase; regression
  tests preview_dab_cap.rs and headless_stroke_splits_phases_at_the_dab_cap
- set_stroke_seed, with stroke_seed_pins_random_nodes
- render_whole for the brush preview
- the bench comparison, as stroke_replay_bench --no-frames (a flush per
  event against no frames)
A stroke's events were each pushed through the stabilizer as they
arrived, and the Laplacian relaxed an N + 2 vertex window per resampled
vertex. A stroke no frame ran during, as a headless embedder paints,
computed every intermediate polyline and rendered only the last: at
stabilize 0.6 that was 27.5 ms of a 52.5 ms stroke.

stroke_to now only records the event. take_divergence hands the
stabilizer every event since the last flush through push_all, now the
required trait method (push wraps it). The resampler retracts its
provisional tip once and hands the algorithm one batch; prediction
rebuilds its tail once. The Laplacian relaxes a batch once, from the
recorded edge to the tip, at N x (M + N) vertex updates for M vertices:
never more than one vertex at a time, and bit-identical to it. It keeps
one edge trajectory, vertex len - N - 2, which no later point or tip
retract changes; retract_tip drops the weight that read the retracted
point. MAX_COMMITS_PER_PUSH is renamed MAX_COMMITS_PER_SAMPLE.

The headless recorded stroke at 0.6 drops from 52.5 to 26.9 ms
(stroke_path in PR darkly-art#135: 22.2 ms). Live painting relaxes once per frame
instead of once per vertex.

Tests: a_batch_costs_one_relaxation and headless_stroke_relaxes_once
fail on the per-vertex path; batches_are_bit_exact_with_from_scratch,
a_batch_reaches_the_inner_as_one_push, batched_stack_matches_sequential.

Based on the batching in PR darkly-art#135 by daghack.
@TheTechromancer

Copy link
Copy Markdown
Member

@daghack PR here: daghack#1

Merge dev and finish offline strokes in the normal stroke path
@daghack

daghack commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

@TheTechromancer Excellent! I've merged and updated the PR.

@TheTechromancer
TheTechromancer merged commit bc07abf into darkly-art:dev Oct 6, 2026
14 checks passed
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.

2 participants