Container Shapes
Confine the fluid to a geometric boundary. The simulation physically enforces the wall — velocity bounces and dye cannot escape.
The containerShape prop defines the boundary geometry. When set, the engine zeroes
velocity outside the shape after every physics pass and masks dye after advection. The fluid
is physically contained, not merely clipped visually.
Coordinates follow a normalized convention: cx/cy are in the range [0, 1]
(left-to-right, bottom-to-top). Radius values are normalized by canvas height, so radius: 0.45 gives a physical radius of 45% of the canvas height.
Five shape types are available:
circle— fluid inside a circleframe— fluid around a rectangular cutout (picture frame)roundedRect— fluid inside a rounded rectangleannulus— fluid inside a circular ringsvgPath— fluid inside an SVG path or text glyph
Separately, the obstructions prop adds interior obstacles
the fluid flows around — the inverse of a container, and orthogonal to containerShape.
circle
Fluid contained inside a circle. Everything outside is zeroed.
| Field | Type | Description |
|---|---|---|
type | 'circle' | Discriminant |
cx | number | Horizontal center (0 = left, 1 = right) |
cy | number | Vertical center (0 = bottom, 1 = top) |
radius | number | Circle radius, normalized by canvas height |
<Fluid containerShape={{ type: 'circle', cx: 0.5, cy: 0.5, radius: 0.45 }} /> The most common container shape. Aspect correction is applied so the circle appears round regardless of canvas aspect ratio. A radius of 0.45 fits comfortably inside landscape canvases.
frame
Fluid flows everywhere except inside a rectangular cutout — like a picture frame. The inner rectangle is empty, and the fluid fills the border region around it.
| Field | Type | Description |
|---|---|---|
type | 'frame' | Discriminant |
cx | number | Horizontal center of the inner cutout |
cy | number | Vertical center of the inner cutout |
halfW | number | Half-width of the inner rectangle in UV space (0–1) |
halfH | number | Half-height of the inner rectangle in UV space (0–1) |
innerCornerRadius | number? | Corner radius for the inner cutout. Default 0 (sharp corners). |
outerHalfW | number? | Half-width of the outer boundary. Default 0.5 (full canvas). |
outerHalfH | number? | Half-height of the outer boundary. Default 0.5 (full canvas). |
outerCornerRadius | number? | Corner radius for the outer boundary. Default 0 (sharp corners). |
<Fluid containerShape={{
type: 'frame',
cx: 0.5, cy: 0.5,
halfW: 0.25, halfH: 0.25,
innerCornerRadius: 0.03
}} /> Think of it as a picture frame: halfW: 0.25 means the inner rectangle extends 25%
of canvas width on each side of cx. The outer boundary defaults to the full canvas;
set outerHalfW/outerHalfH to constrain it. Both inner and outer
boundaries support rounded corners via their respective radius parameters. Rounded corners are
aspect-corrected so they appear circular (like CSS border-radius).
roundedRect
Fluid stays inside a rounded rectangle. Like frame but inverted — the rectangle is the containment region, not the cutout.
| Field | Type | Description |
|---|---|---|
type | 'roundedRect' | Discriminant |
cx | number | Horizontal center |
cy | number | Vertical center |
halfW | number | Half-width in UV space |
halfH | number | Half-height in UV space |
cornerRadius | number | Corner rounding radius in UV space |
<Fluid containerShape={{
type: 'roundedRect',
cx: 0.5, cy: 0.5,
halfW: 0.35, halfH: 0.4,
cornerRadius: 0.06
}} /> Uses the Inigo Quilez rounded-box SDF internally. Corner radius is aspect-corrected so corners
appear circular in physical space. The LavaLamp preset uses this shape with cornerRadius: 0.15 for a pill-like vessel.
annulus
Fluid contained within a circular ring between an inner and outer circle. Both the inner hole and the outer edge are physical walls.
| Field | Type | Description |
|---|---|---|
type | 'annulus' | Discriminant |
cx | number | Horizontal center |
cy | number | Vertical center |
innerRadius | number | Radius of the inner circle (hole), normalized by canvas height |
outerRadius | number | Radius of the outer circle, normalized by canvas height |
<Fluid containerShape={{
type: 'annulus',
cx: 0.5, cy: 0.5,
innerRadius: 0.15, outerRadius: 0.45
}} /> Creates a donut-shaped fluid region. Aspect correction is applied like circle. The
Toroidal preset uses this shape to create a violent storm circulating in a ring. Pair with autoSplatSwirl to sustain orbital motion.
svgPath
Fluid contained within the filled region of an SVG path string or Canvas 2D text. The shape is rasterized to a mask texture at construction time.
| Field | Type | Description |
|---|---|---|
type | 'svgPath' | Discriminant |
d | string? | SVG path data string (path mode). Uses Path2D(d) with viewBox mapping. |
text | string? | Text to rasterize (text mode). Uses ctx.fillText(). Centered in the mask. |
font | string? | CSS font string for text mode. Default 'bold 72px sans-serif'. |
viewBox | [number, number, number, number]? | viewBox for path mode. Default [0, 0, 100, 100]. |
fillRule | 'nonzero' | 'evenodd'? | Fill rule for path mode. Use 'evenodd' for font outlines with counters. Default 'nonzero'. |
maskResolution | number? | Rasterization resolution (longest dimension in pixels). Default 512. |
At least one of d or text must be provided. If both are given, d takes precedence.
Text mode
<Fluid containerShape={{
type: 'svgPath',
text: '&',
font: 'bold 200px Georgia, serif',
fillRule: 'evenodd'
}} /> Text mode uses ctx.fillText() to rasterize text into the mask. The text is automatically centered. Use fillRule: 'evenodd' for glyphs with counters (holes) like "A", "O", or "&".
Path mode
<Fluid containerShape={{
type: 'svgPath',
d: 'M50 10 L90 90 L10 90 Z',
viewBox: [0, 0, 100, 100]
}} /> Path mode uses Path2D(d) with viewBox mapping. The viewBox defines the coordinate space for the path data. Any valid SVG path data string works.
Interior Obstructions
Where containerShape marks where the fluid is contained, the obstructions prop marks where it is blocked — arbitrary interior
obstacles the fluid flows around. Think pillars in a current, letters the dye weaves
between, or walls forming a maze.
Each Obstruction reuses the same svgPath / text descriptor as the svgPath container variant — the filled region of the path
(or rasterized text) marks the blocked area.
| Field | Type | Description |
|---|---|---|
d | string? | SVG path data string (path mode). Takes precedence over text. |
text | string? | Text to rasterize as the obstruction (text mode). Centered in the mask. |
font | string? | CSS font string for text mode. Default 'bold 72px sans-serif'. |
viewBox | [number, number, number, number]? | viewBox for path mode. Default [0, 0, 100, 100]. |
fillRule | 'nonzero' | 'evenodd'? | Fill rule for path mode. Default 'nonzero'. |
offset | { x: number; y: number }? | UV-space translation applied after the base fit transform (0–1, bottom-to-top). Default { x: 0, y: 0 }. |
scale | number? | Multiplier on the base fit scale (1 = fit as-is, 2 = double, 0.5 = half). Default 1. |
fit | 'contain' | 'fill'? | How the viewBox maps onto a non-square canvas. 'contain' (default) uniform-fits and centers — shape-accurate but letterboxes, leaving open
margins; best for discrete obstacles placed with offset/scale. 'fill' stretches each axis to fill the canvas at any aspect (no margins);
best for canvas-spanning geometry like a maze or nozzle channel where the fluid must
be confined edge-to-edge. Path mode only. |
<Fluid obstructions={[{ d: 'M40 0 L60 0 L60 100 L40 100 Z' }]} /> You can pass several obstructions at once. Use offset and scale to
place and size each one independently:
<Fluid obstructions={[
{ text: 'A', offset: { x: -0.2, y: 0 }, scale: 0.6 },
{ text: 'B', offset: { x: 0.2, y: 0 }, scale: 0.6 }
]} /> Union behavior
All obstructions in the array rasterize into a single combined mask — their filled regions union together (overlapping fills accumulate). Cost is constant regardless of how many obstructions you pass: one extra texture sample per fragment.
Orthogonality to containerShape
Obstructions are fully orthogonal to containerShape. They compose
with any container — or with no container at all (a full-rectangle maze). The allowed fluid
region is container × (1 − obstruction): where a container says "fluid here" and an
obstruction says "blocked here", blocked wins.
<Fluid
containerShape={{ type: 'circle', cx: 0.5, cy: 0.5, radius: 0.45 }}
obstructions={[{ text: 'X', scale: 0.5 }]}
/> Minimum feature size
Like container shapes, obstructions use post-hoc mask penalisation: the boundary is roughly one
texel wide at the simulation resolution. The smallest reliably-resolved obstacle is about 2 / simResolution in UV units (~0.016 at the default simResolution: 128).
Walls thinner than that may leak — raise simResolution for finer mazes. The wall is
free-slip (it stops flow crossing it but applies no drag along it), and the pressure solver
produces emergent flow-around and venturi acceleration through gaps. See ADR-0034.
Note that obstructions are mutually exclusive with distortion mode (they share an
internal display texture unit).
Open Boundaries
By default, all boundaries are closed — fluid bounces off both the canvas edges
and the container shape walls. The openBoundary prop changes this behavior:
<Fluid
containerShape={{ type: 'circle', cx: 0.5, cy: 0.5, radius: 0.45 }}
openBoundary
/> When openBoundary is true, fluid flows freely instead of bouncing. The
divergence solver skips no-penetration enforcement at the canvas edges, and the container shape
becomes a visual crop rather than a physical wall — dye and velocity are not zeroed outside the
shape. The FluidReveal component defaults to openBoundary: true for
natural scratch behavior.
obstructions behave differently from container shapes
here: an obstruction is a physical wall regardless of openBoundary.
Velocity and dye are always zeroed inside obstacles, so fluid flows around them whether the
boundary is open or closed. Combining openBoundary with obstructions is the natural
setup for throughflow scenes (a nozzle, a venturi, flow past a body): fluid enters one side,
flows around the obstacles, and vents off the canvas edge instead of recirculating.
Mask Texture Approach
The circle, frame, roundedRect, and annulus types use analytical SDFs (signed distance functions) evaluated directly in the GLSL shader.
These are cheap to compute and produce perfectly smooth edges.
The svgPath type uses a mask texture approach instead. An OffscreenCanvas rasterizes the path or text at the configured maskResolution (default 512px), producing a grayscale alpha mask. This mask is
uploaded as a WebGL texture and sampled by the physics shaders to determine which regions contain fluid.
Random splat spawning uses rejection sampling against a CPU-side copy of the mask data,
ensuring splats only appear inside the shape. Increase maskResolution for finer
detail on complex paths; decrease it for better performance on simple shapes.