{"slug": "show-hn-inkwash-a-watercolor-sketching-app-and-explanation", "title": "Show HN: Inkwash, a watercolor sketching app and explanation", "summary": "A developer used Anthropic's Claude Fable 5 model to create Inkwash, a WebGL2-based watercolor sketching app that simulates pen-and-brush nature journaling in a browser. The app uses a stack of floating-point textures and a fluid simulation algorithm to render realistic ink and water behavior without storing color data until the final display shader.", "body_md": "## 01inspiration\n\nI love [nature journaling](https://johnmuirlaws.com/nature-journaling-starting-growing/). Over time I've developed a style and approach that I like for capturing sketches quickly, using a Pilot G2 pen in combination with a waterbrush. This lets me add linework and shading simultaneously (I dual wield with the brush in my left hand) and forces me not to be too precious about the final result - there will inevitably be smudges and imperfections, and there is no undo with pen!\n\nThis project began as a test of Anthropic's new model Claude Fable 5, and grew once I saw the potential to actually recreate that experience in a browser. I love the final result!\n\nOf course, I'm left in a rather funny position: I've 'created' this app, but I haven't actually touched the code! I can read it (it's a rather nice, self-contained [single HTML file](https://github.com/johnowhitaker/inkwash/blob/main/index.html)) since I have experience with the underlying technologies. But I'm hoping that this app is interesting to many people who *aren't* webGL nerds, and so for the sake of all of our collective understanding I've had Fable spin up some interactive demos to illustrate the concepts. You are also welcome to check out the [prompts](https://github.com/johnowhitaker/inkwash/blob/main/prompts.md) I used to conjure this app into being.\n\n**Disclaimer: While I've tidied up a bit, the rest of this article contains plenty of AI-witten prose. I don't like AI writing as a rule, especially undisclosed! Hopefully you can forgive me in this case, since (with some iteration) the AI actually did a pretty good job showing all the key pieces.** Over time I might refactor and reorganise it to be more in line with my personal sensibilities, but no promises :)\n\n## 02three sheets of state\n\nUnder the canvas, the painting is not pixels — it’s a small stack of floating-point textures, ping-ponged through about a dozen WebGL2 fragment shaders every frame. Think of them as transparent sheets laid over each other:\n\n| field | format | resolution | meaning |\n|---|---|---|---|\n| ink | RGBA16F | up to 2048, matches screen | mobile pigment. RGB is optical density (how much each light channel\nis absorbed), not color. Alpha is white gouache. |\n| fixed | RGBA16F | same as ink | pigment that has settled into the paper and no longer moves\n(\n|\n\nEach frame: the stroke engine stamps gaussian splats into these fields\n([section 05](#marks)), the simulation advances them\n([03](#water)–[04](#paper)), and a final display shader turns\ndensity into paper-and-ink color ([08](#display)). Nothing in the pipeline\never stores “a color” — color only exists for the one shader that draws\nthe screen.\n\nYou can see the sheets directly. The demo below paints a stroke and washes through it; the buttons switch which field you’re looking at.\n\nThe two resolutions matter. Velocity lives on a coarse ~256-cell grid because fluid motion is inherently smooth, and the pressure solve (the expensive part) scales with cell count. Pigment and wetness live at up to 2048 — near screen resolution — because that’s where edges, granulation and fine linework live. The sleight of hand of the whole app is sampling a blurry, cheap flow field to push around a sharp, expensive ink field.\n\n## 03water that moves\n\nThe flow is Jos Stam’s *Stable Fluids* (1999), the algorithm behind\nnearly every realtime smoke, ink and fire toy on the GPU. It earns the\n“stable” in its name from one idea: **don’t push, pull**.\n\nA naive simulation moves each parcel of fluid forward along its velocity — and\nexplodes the moment a parcel overshoots a grid cell. Stam’s *semi-Lagrangian\nadvection* flips the question. Each grid cell asks: *if the fluid here arrived\nfrom somewhere, where was it one timestep ago?* It traces backward along the\nvelocity, samples the old field at that point (bilinearly, between the four nearest\ncells), and adopts the value. No overshoot is possible, because every cell ends up with\na weighted average of values that already existed. Big timestep, lazy frame rate,\ndoesn’t matter — it cannot blow up.\n\nIn GLSL the whole maneuver is two lines:\n\n```\nvec2 coord = vUv - uDt * texture(uVelocity, vUv).xy * uTexel;\nvec2 vel   = texture(uVelocity, coord).xy * uDissipation;\n```\n\nAdvection alone gives you syrup, not water. Two more passes give it character:\n\n**Pressure projection** makes the water incompressible. After advection\nthe velocity field has places where flow piles up (positive divergence) or tears apart\n(negative). A real liquid refuses both — push it and it must go *around*. The\nsolver computes the divergence, relaxes a pressure field against it with ~22 Jacobi\niterations, and subtracts the pressure gradient from the velocity. What that buys,\nvisibly, is swirl: pushes turn into eddies and curls instead of sprays.\n\n**Vorticity confinement** fights numerical mush. All that bilinear\nsampling acts like a low-pass filter — little whirlpools blur away within seconds. So\nthe solver measures the curl that remains, finds its ridges, and applies a small force\nthat spins them back up. It’s a knob for liveliness: inkwash ties it (along with\nthe push strength and how slowly velocity decays) to the **flow**\nslider.\n\n## 04paper that decides\n\nA fluid solver on its own makes smoke — everything drifts forever. What makes this\nfeel like *paper* is that the wetness field acts as a permission system over the\nwhole simulation. Three gates, all reading the same little texture:\n\n**Velocity is confined to wet paper.** After advecting, the velocity is\nmultiplied by `smoothstep(0.005, 0.2, wet)`\n\n— flow simply cannot\nexist on dry ground. This is why a wash stops at its own boundary instead of smearing\nacross the page.\n\n**Pigment mobility is earned, not assumed.** The ink pass computes\n`mob = smoothstep(0.02, 0.45, wet)`\n\nand scales both its\nadvection distance and its bleed rate by it. Damp paper lets ink creep; soaked paper\nlets it run. Bone-dry paper is a museum — the shader returns the old value untouched\nand the pixel costs almost nothing.\n\n**Water itself moves reluctantly.** The wet field is advected at only\n0.6× the flow speed, blurred a little into its neighbors each frame (capillary\ncreep — a puddle’s edge slowly widens), and decays exponentially. The\n**dry** slider sets that time constant, from about 2 to about 18 seconds.\nDrying is what turns a fluid sim into a painting: every wash is a closing window.\n\nDrying in inkwash is honest in one more way: water doesn’t take the pigment with it when it goes. Wherever ink happens to be when its puddle evaporates, that’s where it stays — mid-bloom, mid-streak, mid-swirl. Most of the textures that read as “watercolor” are just the flow field’s last words, frozen.\n\n## 05making marks\n\nBetween your hand and the fields sits a small stroke engine, and it draws everything\nwith a single primitive: a **gaussian splat** — a fuzzy radial stamp,\n`exp(-d²/r²)`\n\n, blended into a field. A pen stroke is a chain of\nink splats; a brush stroke is a chain of water splats plus velocity impulses pointing\nalong the motion. The stamps are spaced at 0.6 of the radius so their overlap sums to\na smooth ribbon:\n\nHow the stamps are blended matters as much as their shape. **Ink is\nadditive** — densities sum, which (as section 08 will make precise) is exactly\nhow real glazes deepen. **Water uses MAX blending** instead: wetness\nsaturates rather than accumulates, so scrubbing the brush in place makes paper\n*wet*, not impossibly flooded. One blend-equation flag, and it’s the\ndifference between a watercolor and a swamp.\n\nThe hand data feeding those splats gets shaped, too:\n\n**Pressure and speed set the nib.** For the pen, radius and density\nboth grow with stylus pressure and shrink with speed — a fast flick gives a thin, dry\nline; a slow, heavy drag gives a dark, swelling one. On a trackpad, Force Touch stands\nin for pressure; with a mouse or finger, the engine fakes pressure from speed (slow\n≈ deliberate ≈ heavy), which is wrong in theory and convincing in\npractice.\n\n**The cursor is chased, not obeyed.** The brush position relaxes toward\nthe pointer exponentially (`k = 1 - exp(-14·dt)`\n\n).\nThat few-millisecond lag is the cheapest line-quality trick in graphics: jitter is\nabsorbed, corners round off, and strokes get the slight follow-through of a real\nbrush.\n\n**Stillness is a mark.** If the pen dwells in place, ink keeps\nfeeding in at a trickle and the spot blooms — pooling, like resting a real nib on damp\npaper. A dwelling brush gently stirs the water beneath it instead.\n\n## 06black that isn’t black\n\nHere is the trick the whole app was built around. Put a drop of water on a line of cheap black ink and watch the edge: the black stays close, but a blue-violet ghost walks out ahead of it. Black inks are dye cocktails, and on wet paper they chromatograph — each dye travels at its own speed.\n\nInkwash gets this almost for free because of a decision from section 02: pigment is\nstored as **per-channel optical density**, and the bleed step — which\neach frame nudges ink toward the average of its neighbors, where wet — runs at a\n**different rate per channel**:\n\n```\n// red-absorbing dye escapes fastest, blue-absorbing dye drags behind\nuChroma = vec3(1.0 + 0.85*C,  1.0 + 0.15*C,  max(0.25, 1.0 - 0.65*C));\nvec4 bleedAmt = clamp(uBleed * (0.25 + 1.3*brush) * mob * vec4(uChroma, 1.05), 0., 0.92);\nvec4 mixed = mix(advected, neighborhood, bleedAmt);\n```\n\nRead that as chemistry: the component that absorbs red light (and therefore\n*looks* cyan-blue) diffuses outward fastest; the component that absorbs blue\nhangs back in the line. A few seconds of this and any wet edge sorts itself into a\ndark core with a cool halo — no halo is ever drawn, it *separates*. The\n**color** slider is `C`\n\nin that snippet: at 0 the channels\nmove in lockstep and the ink behaves like lamp black; pushed up, the dyes split\napart.\n\nOne more term worth noticing: `brush`\n\nis a gaussian around the brush tip,\nso bleeding runs ~5× faster right under the bristles. Scrubbing doesn’t\njust wet the ink — it actively works it loose, which is exactly what scrubbing should\ndo.\n\n## 07pressing fix\n\nReal watercolor layers because dried pigment bonds to the paper — you can glaze over\nyesterday’s wash without reviving it. A single mobile ink field can’t give\nyou that, which is why inkwash keeps two pigment sheets: `ink`\n\n(mobile) and\n`fixed`\n\n(settled). Pressing **fix** (or `d`) runs a\n1.2-second settling pass: each frame a fraction of the mobile pigment transfers to the\nfixed layer, the velocity field is braked hard, and the wetness flash-dries. The\npainting looks identical before and after — but it has changed state, from liquid to\nlaminate.\n\nWhite ink is the other half of the layering story, and it’s sneakier than it\nlooks. White gouache rides in the pigment texture’s alpha channel and composites\n*over* the dark ink on screen. But paint white over black, fix it, then draw\ndark on top — physically you’re drawing on a fresh white ground, so the new line\nmust read dark. If white stayed “a layer on top” forever, it would bleach\neverything drawn after it.\n\nSo baking white is destructive, the way real gouache is opaque. At fix time, white\ncoverage *bleaches the density underneath it* — the dark ink it hides is\ngenuinely removed, in transmittance space — and then the white itself dissolves into\nthe paper:\n\n```\n// coverage c of white gouache erases what it hides, then becomes paper\nfloat c  = (1.0 - exp(-2.2 * whiteAmount)) * uSettle;\nvec3  T  = exp(-density);                       // current transmittance\ndensity  = -log(clamp(T * (1.0 - c) + c, 1e-4, 1.0));\n```\n\n## 08drawing the paper\n\nEverything so far has been bookkeeping in density-land. The display shader is where\nit becomes a painting, and its core is one physical law. **Beer–Lambert**:\nlight passing through pigment is attenuated exponentially, per channel.\n\n```\nvec3 color = paper * exp(-density * uInkStrength);\n```\n\nThis is why pigment is stored as density. Overlapping strokes *add* densities,\nwhich multiplies transmittances — and an exponential through a slightly tinted\nabsorption spectrum behaves the way paint does: the first pass is a luminous gray, the\nfourth is a deep charcoal that still leans cool, and nothing ever clips into flat\nblack. Naive alpha-blending, the default in every drawing API, converges on mud\ninstead:\n\nAround that one law, the display pass layers the things that make paper paper — all generated, nothing sampled from an image:\n\n**Fiber and tooth.** Two octaves-apart value noises (an fbm at large\nscale, a fine one at pixel scale) tint the blank sheet so it’s never a flat\nhex code.\n\n**Granulation.** A third noise field modulates ink density — but only\nwhere pigment is. That’s the speckle real pigments leave as particles settle\ninto the paper’s valleys.\n\n**Edge darkening.** The shader measures the local gradient of density\nand multiplies absorption by `1 + 1.35·|∇|`\n\n. In real\nwatercolor, pigment migrates to a drying wash’s boundary and leaves a dark rim —\nthe single most recognizable watercolor signature. Here it’s a cheap screen-space\nfake of that, and it’s doing an enormous amount of the look.\n\n**Wet sheen.** Wherever the wet field is high, the paper darkens\nslightly and coolly — so you can *see* your working window, watch a wash\nvisibly dry, and know where a new stroke will bloom.\n\n## 09ways to paint with it\n\nThe instrument has more registers than its two modes suggest. A few that fall out of the physics:\n\n**Line, then wash** is the native idiom: draw, press `d` to\nfix, then brush freely — your shading can’t destroy your drawing, but fresh ink\non top still moves. **Wet-on-wet** inverts the order: lay clean water\nfirst, then drop the pen into it; ink hitting a standing puddle blooms outward instead\nof holding a line. **Dry brush against the clock**: with the dry slider\nhigh, a wash gives you two seconds of movement and then commits — closer to sumi-e\nthan to watercolor. And the **brush ink** slider quietly turns the water\nbrush into a loaded watercolor brush, for when you want broad pigment without drawing\nten thousand pen lines.\n\nThe input mapping carries the same pen-and-waterbrush metaphor across devices:\n\n**On iPad,** the Apple Pencil draws and\n*your finger is the water brush* — no mode switch, just two different things\ntouching the paper, which is the most honest version of the idea. (A side benefit:\nstrokes are single-pointer, so a resting palm is simply ignored.)\n**On a tablet,** the stylus barrel button momentarily swaps pen for brush,\nlike flipping a pencil to its eraser. **On a Mac trackpad,** Force Touch\npressure drives line weight. Failing all of that, keys:\n\n`b` pen / brush `w` white ink\n`d` fix `c` clear `s` save png\n`f` fullscreen\n\n## 10colophon\n\nInkwash is a single HTML file — under a thousand lines, no dependencies, no build\nstep. It needs WebGL2 and renderable half-float textures\n(`EXT_color_buffer_float`\n\n), which is everything from roughly 2021 onward.\nThe full pipeline — twelve shader passes including the 22 Jacobi iterations — runs\ncomfortably at 60 fps on a phone, mostly because the expensive solve happens on\nthe coarse grid.\n\nThis page is the same engine, refactored just enough to run many instances at once:\nevery demo shares *one* WebGL context and one set of compiled shaders, keeps its\nown little stack of field textures, renders to a hidden canvas and copies out — so\nthirteen simulations coexist without tripping the browser’s context limit, and\nonly the ones on screen actually step. There is also a testing trick inherited from the\napp: load any of these files with `?demo`\n\nand the scripted strokes run\nsynchronously at startup, so a headless browser can screenshot a finished painting —\nwhich is how an AI assistant and I checked our work while building all of this.\n\nback to Johno, the human author:\n\nI can't help contrast this project with my first foray into artistic generative webGL stuff - a slime mold sim [called dotswarm](https://observablehq.com/@johnowhitaker/dotswarm-exploring-slime-mould-inspired-shaders). That project was a lot of fun, but involved multiple nights tearing my hair out fighting obscure shader bugs. This time around I had the idea, spoke to a computer about it, refined it over time as I zeroed in on what I actually wanted, and ended up with one of my favourite pieces of software ever. All it took was some english language chit-chat! And with a few more turns I ended up with this lovely interactive explainer too.\n\nOf course, this isn't exactly rocket science - e.g. the fluid sim piece is a well-known and well-used piece of tech at this point. But still - I'm excited to see things get to the point where such wonderous personal software creation is available to so many people. (Well, right now the model that did this is not available to anyone, thanks to the USG slamming it with export controls - but that's temporary).\n\nAnyway, definitely [go paint with the app](index.html). Check out the [source code](https://github.com/johnowhitaker/inkwash). But also, think through your backlog of ideas for software you wish existed - there's a chance you can make it now. Good luck :)\n\nPS: Here are a few of my sketches done over the course of testing. If you make anything, pretty or not, I'd love to see it! Tag me @johnowhitaker on X.", "url": "https://wpnews.pro/news/show-hn-inkwash-a-watercolor-sketching-app-and-explanation", "canonical_source": "https://johnowhitaker.github.io/inkwash/about", "published_at": "2026-06-14 02:14:43+00:00", "updated_at": "2026-06-14 02:30:00.484559+00:00", "lang": "en", "topics": ["ai-tools", "generative-ai", "computer-vision"], "entities": ["Anthropic", "Claude Fable 5", "WebGL2", "Inkwash", "Pilot G2", "John Muir Laws", "Jos Stam"], "alternates": {"html": "https://wpnews.pro/news/show-hn-inkwash-a-watercolor-sketching-app-and-explanation", "markdown": "https://wpnews.pro/news/show-hn-inkwash-a-watercolor-sketching-app-and-explanation.md", "text": "https://wpnews.pro/news/show-hn-inkwash-a-watercolor-sketching-app-and-explanation.txt", "jsonld": "https://wpnews.pro/news/show-hn-inkwash-a-watercolor-sketching-app-and-explanation.jsonld"}}