Repository navigation
Design fluid media
Status: notes, not a plan. Nothing here is built. This is the brief for a
future pass on BrushEngine, written down so it survives the gap. Recorded
2026-08-02 from a working note; the reasoning has been checked against this
engine rather than copied wholesale, and where it collides with an invariant
that is said plainly below.
Updated 2026-08-02 with the answer to Q10 — wet paint survives for a bounded window of strokes — which turns the document's central open question into a constraint the rest has to satisfy, and which turns out to cost the record nothing. One prediction the original note made has since been tested and was wrong; it is kept below rather than quietly removed.
Stated before the five pieces, because it is the thing that decides how far each one goes.
This is a drawing application, not a physics paper. We are not recreating watercolour. We are giving an artist the part of watercolour that makes a mark say something — the pooling at an edge, the drag of a loaded brush, the tear when it runs out — and stopping there. Where a cheap approximation and an accurate simulation look the same to a person, the cheap one is correct and the accurate one is a defect that costs frames.
Performance is a constraint on this work, not a consideration after it. The
budgets in CHARTER.md are the ceiling; a piece that cannot meet them is not
shipped in a slower form, it is redesigned or dropped. Realism is worth nothing
if the canvas stutters, because the thing being defended is the artist's
attention.
Visual variation, never logical randomness. Marks should differ the way real media differ — because of where they are, what they are on, how fast the hand moved — and never because of a die roll. That is invariant 2 read from the artist's side rather than a rule fighting them, and it is also what makes the variety reproducible.
Where the edge is. Krita's brush engine pushes further than most and still leaves this on the table. The advantage is in expression, not fidelity — so spending the frame budget chasing accuracy spends the advantage rather than earning it.
The consequence for everything below: these are opt-in options on presets. Most brushes do not use them and must not start to. The picker badges the ones that do, derived from the settings rather than declared, and every simulated medium ships a fast counterpart — a medium nobody can afford is a trap rather than a feature.
Our wet media vary the mark with noise: opacity jitter, scatter, granulation,
all seeded from dab position via Hash01. That gets texture and misses
fluidity, and the reason is structural rather than a matter of tuning. Noise
treats every pixel independently. Real water and viscous paint link a pixel to
its neighbours through surface tension, capillary flow and the momentum of the
stroke, so what a dab does depends on what is already on the paper and which
way the brush was travelling. No amount of better noise produces that, because
the missing thing is the coupling, not the variety.
Symptom, in our own terms: a soft-round wash reads as a tube of circles with speckle on it.
A prediction this document made, and got wrong. The original note tied this to bug M16b (stamping arcs) as "the same absence seen from a different angle". It was not. M16b was measured and fixed on 2026-08-02 and the cause was chord error — the dab walk followed the straight lines between recorded pen samples, putting the path up to 6 px inside a curve the artist drew, with a facet wider than the brush on the outside of every bend. It had nothing to do with fluid coupling and needed no simulation to fix; the path is a centripetal Catmull–Rom through the recorded points now.
Worth keeping rather than deleting, because it narrows what this pass is for. "The marks look like separate stamps" had two causes, and the geometric one was the cheaper and the larger. What is left for fluid coupling is the part that survived the fix: a wash still reads as flat, because the pigment does not know what it landed on. Measure before assuming the remainder is what this document says it is.
We are a stamp engine, and the note is explicit that a stamp engine can get there without becoming a grid fluid simulation. Five pieces, roughly in the order they would pay off:
Stop rendering straight to one RGBA buffer. Per stroke, carry:
| Buffer | What it holds |
|---|---|
| Pigment (RGB) | The colour particles |
| Moisture (1 channel) | Water or solvent present |
| Height (1 channel) | Physical volume of paint |
Everything below reads at least two of these. This is the piece the other four depend on, and it is the one with real memory cost — a per-stroke bounded region, not a full-canvas set of buffers, or invariant 6 goes.
Fluid migrates to the perimeter of the footprint as the tip compresses it. Instead of a flat radial falloff, the stamp's alpha curve peaks just before the edge and then drops. That deposits more pigment and moisture at the rim: the watercolour wet edge, rather than a soft digital glow.
Cheapest of the five, and visible on its own — see Where to start, which argues for beginning here rather than with (1).
Each stamp pulls from the canvas along the stroke vector rather than sitting down independently:
- Vector between this dab and the previous one.
- Sample the canvas slightly behind the dab along that vector.
- Blend a share of that sample into the dab's pigment before drawing.
This is what makes paint drag and fold rather than composite, and it is the single change that most removes the isolated look. Note the overlap with our existing smudge tool: that samples and re-deposits already, so the machinery is partly there and the work is making it a property of a brush rather than a tool of its own.
Every overlapping dab adds to the height buffer. Run a Sobel over it for normals, then a Blinn-Phong pass in screen space. A little directional light on those normals is what makes oil and heavy gouache read as thick rather than merely opaque. Impasto for free once the buffer from (1) exists.
Tie transfer to stroke speed rather than to a random draw:
- Slow — tighter spacing, more fluid deposited: pooling, heavier build-up.
- Fast — wider spacing, less fluid: the medium starves and tears, which is a real dry-brush break instead of artificial grain.
We already vary spacing; what is missing is the fluid volume moving with it.
This is the part the source note could not know, and it is the part that decides whether any of it can ship.
Invariant 2 — no randomness in rendering — is not threatened. Everything above is deterministic: advection is a function of the stroke's own geometry, capillary flow of the footprint, deposition of speed computed from recorded timestamps. That is better than what we have, not worse: it replaces hash-driven variety with variety that has a cause. Nothing here needs an RNG, and nothing here may acquire one.
Invariant 1 — the stroke record is the document — is the hard one. A simulation whose result depends on the order dabs were laid and on what was already wet is still reproducible, but only if the whole stroke sequence is replayed identically from a known start state. Two consequences:
- The state must live in the stroke record or be derivable from it. A moisture buffer that persists between strokes and is not saved makes a reload render differently, which is the definition of a broken record. Q10's answer takes the second branch — see below; the window is bounded, so the state is recomputed from strokes already in the record and nothing new is stored.
- Every part of the loop must be exactly reproducible in floating point, including iteration order. A parallel diffusion step that reduces in non-deterministic order would break re-renders and make AI inbetweens diverge. This is the one that will bite.
Invariant 6 — painting is bounded work — constrains (3) and (4). Sampling behind each dab and running a Sobel per frame are both per-dab or per-region costs, which is fine; a diffusion pass over the whole canvas per event is not. Any cellular-automata or bleed step has to be confined to the stroke's dirty region.
Q10, answered 2026-08-02: yes, for a bounded window, and the size of the
window is a brush setting. BrushSettings.WetStrokes: 0 means the paint is
dry the moment the pen lifts, N means the mark stays wet for the next N
strokes and anything laid within that window can pick it up.
The paint declares its own life, not the brush that arrives later. A stroke
made with WetStrokes = 3 stays available for three strokes whatever is
painted next; a stroke made dry is dry immediately even if the following brush
is soaking. That is the physical reading and it is what "0 is directly dry"
means. Strokes with different windows simply expire at different times — there
is nothing to reconcile.
The thing that made this hard was the assumption that persistent wetness means persistent state, and therefore a moisture buffer in the file. A bounded window means it does not:
The wet state at stroke k is a pure function of strokes k−N … k−1, and those are already in the record.
So nothing new is saved, and invariant 1 is untouched. Doc gains no buffer,
the file gains no pixels, and a reload reproduces the painting by doing what it
already does — replaying strokes in order. Unbounded wetness (option (b)) would
have needed either the whole history or a stored buffer; that is the whole
reason (c) beat it, and it is worth restating whenever somebody proposes
"just keep the buffer".
Default 0 keeps every existing document byte-identical, and every existing
render bit-identical. Absent by default, the camera's rule again.
Three things, all of them bounded, none of them free:
A full frame render is free. Materialize already replays every stroke in
order from an empty surface, so the moisture channel simply rides along. This
is the case that needs no new thinking at all.
The incremental append is where the work is. FrameRasterizer.Append
stamps one new stroke onto the cached RGBA bitmap — and RGBA does not carry
moisture, so a wet stroke cannot be appended from it. Two ways out, and the
choice wants a measured spike rather than an argument:
- Keep a moisture channel beside the cached frame, bounded to the region the
last
Nstrokes touched, and append against that. - Keep nothing, and replay the last
Nstrokes into a scratch buffer to reconstruct the moisture before appending.
The first costs memory that scales with the wet region, the second costs time
that scales with N and with those strokes' size. N is small in practice —
an artist working wet-into-wet means two or three strokes, not forty — which is
what makes either affordable and is also the argument for capping N low.
An edit invalidates forward, by N. Change or undo stroke j and the
renders of j … j+N are stale, not just j. Bounded and predictable, which
is exactly what (b) could not promise — there, an edit anywhere invalidated
everything after it. The frame cache and the scoped-edit path both have to know
this before any of the five pieces below ship.
Two things the answer does not settle. Both are in QUESTIONS.md.
Both now answered.
-
Q13 — what counts as the same sheet of paper: (c). The window is per
frame and per layer, and generated strokes never carry wetness — the
inbetweener and the MCP surface write
WetStrokes = 0whatever the source said. The first half matches Q6; the second is a determinism guard, because an inbetween whose look depended on how many strokes the generator emitted would diverge between runs. -
Q14 — what an eraser does: (a). An eraser is a stroke like any other. It
spends one of the window's
Nand removes pigment, and the moisture goes with the pigment it belonged to. An eraser that smears wet paint is the physical answer and is a brush somebody builds later on the advection loop — if that turns out to matter, the fix is a new brush, not a change to the eraser.
An artist's report — "ink wash and watercolour have no pooling spots, gouache and oil have no height nor edges that catch light, it still looks like texture made from noise, and nothing spreads or drags like a wet medium" — turned into numbers. Every part of it was right, and two parts are worse than the report suggested. Logged as B23, B24 and B25.
| Claim | Measurement | Verdict |
|---|---|---|
| No height, no light-catching edges |
Body/Relief/BristleDrag/Pickup at 0 versus 1: 0 of 41 600 pixels differ
|
Not implemented at all |
| No pooling | Rim/interior density by EdgePull: 0.0 → 1.22, 0.4 → 1.83, 0.8 → 0.68, 1.0 → 0.65. Watercolor ships 0.7
|
Implemented, but the response inverts above ~0.5 and the preset is past it |
| Looks like noise | Interior density 0.66 without a medium, 0.13–0.25 with one | The pigment is mostly gone, so texture is all that is left |
| Nothing spreads | Mark height 45 px flat, 50 px at 24 flow steps | ~10% — effectively no bleed |
Two things this changes about the plan below.
The five pieces are not all unbuilt, and not all built. FluidLattice is a
real Curtis-style shallow-water model and it does run. What is missing is
narrower and more fixable than "build a simulation": conserve the pigment
(B25), make the capillary term monotonic (B24), and implement the four settings
that already have controls (B23). Piece (1) — the channels — is what B23's
height half needs; the rest is repair.
Impasto may not need the buffer. Piece (4) assumes a height channel from piece (1), but a first pass can take normals from the stroke's own alpha coverage, which already exists. That gets a light-catching edge on gouache and oil without the memory, and it is worth trying before committing to the buffer.
The chamfer-distance walk that fixed B24 had a flaw the measurement only found
once the seam was looked for: the cells on a stroke's medial axis give pigment
to both sides and receive none, so every wash had a channel down its exact
middle at any pull above zero. That is why B35 could only turn EdgePull down to
0.06, where nothing pools. The term now drains the whole interior evenly into
the fringe band — the physics of a drying wash, evaporation at the contact line
pulling from everywhere — and the Watercolor preset pools again at 0.45. See
FluidLattice.CapillaryPull and HollowCentreTests.
Two numbers worth keeping beside it. The rate is set against the deposit, not
on its own: Deposit binds a quarter of the suspension in place every step,
so a pull of 0.12 per step at full slider moves roughly a third of the
interior's pigment before it is pinned, which reads as a rim 1.3× the core on
a dried disc and a stroke that keeps 55 % of its middle. And the B24 bounds
were re-derived rather than relaxed: the old walk's rim/core of 4 was mostly
the core being carved, so "above 3" measured the defect; "rim darker than
core" is what a ring is.
Raised as a possible answer, and it is a partial one — worth being precise about which part.
What tip textures do reach: the shape of a mark, its transparency
variation, and — with a directional tip and AngleFollowsDirection, both of
which exist — a convincing bristle streak. That is BristleDrag in appearance
if not in mechanism, and it is much cheaper than the advection loop. They are
also the honest answer to "it looks like noise": a scanned bristle or spatter
tip is structured where Hash01 is not, and structure is what reads as a tool
rather than as grain.
What they cannot reach, and the reason they are not a substitute for the pass: edge pooling, flow, surface tension and pickup are all couplings — each depends on what is already on the paper — and a tip is a stamp that knows nothing about the canvas. Neither can they produce relief, which needs shading from a surface normal rather than a shape.
So: tip textures are a cheap, real improvement to two of the four complaints and no help at all with the other two. Worth doing early for that reason, provided the doc does not then claim the medium problem is solved.
Not with the channels, despite (1) being what the other four depend on.
Start with (2), the wet edge, because it is the only one of the five that is visible on its own, needs no buffer, no window and no new state, and can be measured against the existing budgets in an afternoon. If a peaked alpha curve does not make a wash read better, that is worth knowing before building three buffers to find out.
Then (5), velocity-dependent deposition — with one correction to what this
paragraph used to say. It claimed the stroke already carries the timestamps.
It does not. StrokePoint is (X, Y, Pressure), so there is no time
anywhere and speed can only be inferred from point spacing — which after
Densify and the smoothing filters is a resampling artifact rather than a
speed. So (5) does touch the record, and it touches it in the same place and
in the same way as tilt: see the widening problem in
docs/DESIGN-brush-tips.md, which is the reason the two should land together.
Dry-brush tearing that comes from drawing fast is still a real gain; it is just
not free.
Only then (1), and with it (3) and (4), which are the ones that need the channels and the wet window and the invalidation rule. That is the expensive half and it should be entered deliberately, with the two open questions answered and a spike behind it.
Whether this is a new medium alongside the existing ones or a replacement for
them; the buffer format; whether (4) belongs in the brush engine or the
compositor; and how to hold the moisture channel for the incremental append —
buffer beside the cache, or replay the last N. There is also a cap on N to
choose, and the argument for a low one is above rather than measured.
All of it wants a spike against the charter's budgets before anything is promised. G7 in particular: three of the five pieces add per-dab or per-region work to the paint path, and every serious stall this project has had was in a path with no budget covering it. A budget for the wet path goes in with the first piece that touches it, not after.
A working note from the user, 2026-08-02, on moving from noise to physics for paint and ink. Read it as a brief that has been checked, not as a specification: the five pieces and their reasoning are theirs; the invariant analysis, the Q10 consequences, the order of work and the two remaining questions are this repository's.
- Getting started
- Documents and projects
- Tools and strokes
- Brushes
- Colour
- Layers, selections and guides
- The timeline
- Onion skin, references and the camera
- Symbols
- Saving and recovery
- Exporting to a game engine
- AI assistance
- Keyboard, performance and what is planned
- Bones and rigging
- Effects — fire, smoke and water
- A simplified 3D space to draw in: planes, parallax first, orbit second
- Brush performance beyond 4k: what the GPU can and cannot buy us
- The add-on boundary
- Making the AI trustworthy
- What an AI request actually costs
- Bones and spline deformers for 2D — design
- Brush tips — a generated library, not a live calculation
- Cloud and SaaS: what the architecture already fits, and what it does not
- Colour mixing — paint that picks up what it is laid on
- DESIGN: Effects
- DESIGN: Fluid effects — fire, smoke and water as drawn line and fill
- Fluid and viscous media — notes for the next brush-engine pass
- Compositing on the GPU
- Decomposing the two big files: which pieces come out, and with which tool
- One memory limit for pictures
- .NET 10: what is safe, what is unverified, and what it narrows
- One picture cache: the paused canvas on tiles
- The paint path: one mark, made once, under the pen
- Pen dynamics: tilt and speed
- A pencil is graphite on the paper's tooth
- Performance: the ratchet and the map
- Playback's first loop
- Pressure curves — a shape, not an exponent
- Importing Procreate brushes
- A project as a game's asset set
- One hierarchy: folders scope everything
- Puppet pins — design and plan
- The raster checkpoint: making a painting cheap to reopen
- Scoped resources — palettes, references and everything else shared
- Sensitive topics, and the three-track flow
- Design: pose-assisted frame-by-frame animation (rigid bones + AI gap filling)
- Stroke groups: why a merge should never turn strokes into pixels
- The project window: what you do between drawings
- Reading the subject before drawing on it
- The ceiling of a swept soft mark (B349)
- Q171 — a symbol owns a layer stack
- Pillar 3 — reusable assets, as live symbols
- What a template is
- Text, and where fonts sit
- The tiled compositor, and getting it off the CPU
- Design tokens
- Transform tool design (M14)
- Why a brush stamps twice, and when it does not
- Migrating to the designed UI
- Undo stores only what changed
- Vector tooling: making the lines you already drew editable
- The smudge/blur family, and aligning brush settings with Photoshop
Generated from the repository. Edit the files under docs/, not these pages.