Show HN: Inkwash, a watercolor sketching app and explanation
Show HN: Inkwash — A Watercolor Simulation & Technical Breakdown
Inkwash is a watercolor sketching application contained entirely within a single HTML file. It blends fluid dynamics with clever rendering tricks to simulate the organic behavior of ink, water, and paper.
Fig 1: The continuous loop of creating pen lines and using a water-loaded brush to pull hidden colors and washes across the page.
01 The Inspiration
The project is rooted in a specific physical art style: the combination of a Pilot G2 pen and a waterbrush (a nylon brush with a built-in water reservoir). This setup is far more portable than traditional jars and brushes.
The Philosophy of the Sketch
Using these tools encourages a specific workflow:
- Simultaneity: Linework and shading happen at once (often dual-wielding the brush in the left hand).
- Acceptance: The process embraces
perfectionimperfections. Smudges are inevitable. - Permanence: There is
no undo buttonwith a permanent pen.
Development Note: This app was born as a test of Claude Fable 5. While I can read the code due to my background in WebGL, I didn't actually write it—the AI did. I've provided the prompts used to create it for those interested.
Disclaimer: Parts of the original explanation were AI-generated; I have tidied them up, but the core technical descriptions remain largely as the AI presented them.
02 Three Sheets of State
Under the hood, Inkwash doesn't treat the painting as a simple grid of pixels. Instead, it utilizes a stack of floating-point textures that are "ping-ponged" through roughly a dozen WebGL2 fragment shaders every frame.
Texture Architecture
Think of these as transparent overlays. The system separates the physical properties of the paint from the visual color.
| Field | Format | Resolution | Purpose |
|---|---|---|---|
| Ink | RGBA16F | Up to 2048 | Optical density (absorption), not actual color. |
| Pigment | RGBA16F | Up to 2048 | Ink that has settled/dried into the paper. |
| Wet | R16F | Up to 2048 | The volume of water present at each point. |
| Velocity | RG16F | cells | The direction and speed of water motion. |
| Pressure | R16F | cells | Scratch space to ensure incompressible flow. |
The Rendering Pipeline
The app follows a strict sequence to transform input into an image:
- Step 1: The stroke engine stamps Gaussian splats into the fields.
- Step 2: The simulation advances the fluid state.
- Step 3: The display shader converts density into paper-and-ink colors.
Key Optimization: To avoid the diagonal seam common in quad-based fullscreen passes, the app draws one massive triangle covering the screen: [-1,-1, 3,-1, -1,3].
The "Sleight of Hand": The app samples a blurry, low-resolution flow field (256px) to move a sharp, high-resolution ink field (2048px). This keeps the expensive pressure calculations fast while maintaining crisp edges.
03 Water That Moves
The fluid simulation is based on Jos Stam’s Stable Fluids (1999). The core innovation is the shift from "pushing" to "pulling."
Advection: The "Pull" Method
In a naive simulation, you move a parcel of fluid forward. If the timestep is too large, the parcel overshoots its cell, and the simulation "explodes." Inkwash uses back-tracing:
- Each cell looks at its current velocity.
- It asks: "If I am here now, where was I one timestep ago?"
- It samples the previous field at that backward location using bilinear interpolation.
Mathematically, the coordinate lookup looks like this:
In GLSL, this is achieved in just two lines:
vec2 coord = vUv - uDt * texture(uVelocity, vUv).xy * uTexel;
vec2 vel = texture(uVelocity, coord).xy * uDissipation;
Adding Character: Pressure and Vorticity
Advection alone feels like syrup. To make it feel like water, two more processes are added:
- Pressure Projection: This makes the water incompressible. It calculates divergence (where flow piles up or gaps open) and uses Jacobi iterations to relax the pressure field. This transforms simple sprays into complex eddies and curls.
- Vorticity Confinement: Bilinear sampling acts as a low-pass filter, blurring out small whirlpools. The solver identifies these remaining "ridges" of curl and applies a force to spin them back up, keeping the simulation lively.