Spreading Liquid with three.js | mofu

· mofu

10 min read Original article ↗

Introduction

At Vue Fes Japan 2026, I talked about implementing a liquid foundation effect. In this article, I'll continue from my previous fluid simulation article and explain how I created that effect.

I developed this liquid-spreading effect for a Japanese cosmetics brand's liquid foundation promotional project released in 2021.

ちょっと前なんですが今年6月にprimavisaシリーズのファンデーション・下地のテクスチャをシミュレーションできるサイトが公開されました。 homunculusさんにお声かけいただいて実装担当しました。 いろんな種類とカラバリをお試しできるのでぜひ見てみてください。

https://t.co/RiK5wff7S9 https://t.co/dyNJNoizSY

— Misaki Nakano (@misaki_mofujp) December 18, 2021

Liquid Spreading Demo

I chose blue rather than a foundation shade for this demo, even though the original project was about makeup.
Try dragging your mouse or finger across the liquid below.

Where I Started

When I was asked to implement a foundation simulation in January 2021, I had no idea where to begin. I spent about a month repeatedly touching foundation and thinking about what makes it look and behave like foundation, or more generally, like a liquid.

I had already thought of using a height map to represent a physical quantity, but I wasn't sure how to move that height around. I wondered if there was a simpler approach.

In the end, I decided that a fluid algorithm was probably the best solution. When you drag a finger through liquid, it bulges up on the opposite side, and the surrounding material gets pulled along. To reproduce those movements, I started by implementing a fluid simulation.

How It Works

First, Some Background

This effect uses the results of the fluid simulation I introduced in an earlier article on this blog.
It uses the same velocity map to create the liquid-spreading movement.

Stable Fluids with three.jsmofu-dev.com

The raised liquid in the opening demo is rendered in a fragment shader, rather than by deforming the geometry.
I prepare another FBO, which I call a height map, to represent the thickness of the liquid.

What Is a Height Map?

Height maps are often used for terrain, but here I use one to represent liquid thickness.
The diagram below illustrates a height map recording the thickness of a liquid. The height value at each position represents how much liquid is present there.

Cross-section showing the thickness of liquid stored in a height map


Implementation Flow

The fluid simulation itself is explained in my previous article, so here I'll focus on updating thickness and opacity, and rendering the result.

  1. Use the velocity map to find the source UV and update the thickness.
  2. Update opacity in an alpha map using the current height map and the history of where liquid has been applied.
  3. Calculate normals from the height map, then render with a Matcap and the alpha map.

Processing flow from the velocity map through the height map and alpha map to rendering

1. Moving Thickness with the Velocity Map

First, I prepare the initial values for the height map.
I create a hemispherical blob, raised at the center, using a fragment shader and store its height in the height map's R channel.

initialHeight.frag

float calculateHeight(float value) {
  return sqrt((1.0 - value * value) / 12.0);
}

void main() {
  vec2 point = (vUv - 0.5) * 2.0 * resolution / min(resolution.x, resolution.y);
  float distanceFromCenter = length(point);
  float height = calculateHeight(smoothstep(0.0, 0.3, distanceFromCenter));
  height = smoothstep(0.0, 0.025, height) * height * 0.66;
  gl_FragColor = vec4(height, 0.0, 0.0, 1.0);
}

I render these initial values into an FBO, then update the thickness using the velocity map.

Thickness removed from the current position and added from a backtraced source position

The velocity map produced by the fluid simulation records the direction and magnitude of movement at each position on the screen.

First, I sample the thickness and velocity at the current pixel.
According to its velocity, I calculate how much thickness should move away and subtract that amount from the original thickness.

height.frag

float previousHeight = texture2D(heightMap, vUv).r;
vec2 flow = texture2D(velocity, vUv).xy;
float previousStrength = smoothstep(
  velocityThreshold.x,
  velocityThreshold.y,
  length(flow)
);
float removed = min(
  previousHeight,
  previousStrength * 0.3 * previousHeight
);

removed is the amount of thickness subtracted from the current pixel.

Next, I use advection, which also appeared in the previous fluid simulation article.
I trace the velocity backward from the current pixel and sample the thickness at the source position.

Here is the implementation of backtraceUv(), which calculates the source UV from the current UV, velocity, and elapsed time.

height.frag

vec2 backtraceUv(vec2 uv, vec2 flow, vec2 fboSize, float dt) {
  vec2 ratio = max(fboSize.x, fboSize.y) / fboSize;
  vec2 velocityRatio = fboSize / min(fboSize.x, fboSize.y);
  return uv - flow * velocityRatio * dt * ratio;
}

height.frag

vec2 sourceUv = backtraceUv(vUv, flow, fboSize, dt);
float sourceHeight = texture2D(heightMap, sourceUv).r;
float sourceStrength = smoothstep(
  velocityThreshold.x,
  velocityThreshold.y,
  length(texture2D(velocity, sourceUv).xy)
);
float added = min(
  sourceHeight,
  sourceStrength * 0.3 * sourceHeight
);

vUv is the current pixel's position. sourceUv is the position found by tracing the velocity backward.
The backtraceUv() function uses velocity and the time step dt to calculate that position.
added is the thickness brought from the source position to the current pixel.

Finally, I subtract removed from the current thickness and add added to update the height map.

height.frag

float nextHeight = max(0.0, previousHeight - removed + added);
gl_FragColor = vec4(nextHeight, 0.0, 0.0, 1.0);

The result is written to the R channel of gl_FragColor. The G and B channels are 0, and the A channel is 1.
The alpha map and rendering shaders later read this value using texture2D(heightMap, vUv).r.

In this way, each pixel loses some thickness and gains thickness from its backtraced source position.

I also use smoothstep() so that the thickness barely moves when velocity is small.
This prevents stationary areas from constantly flowing and focuses movement around the areas affected by dragging.

This is not a physically accurate, volume-conserving calculation. I adjusted it to achieve the appearance I wanted.
Each frame, I sample the previous height map, render the result into the next one, and alternate between two FBOs.

2. Keeping the Traces with an Alpha Map

How current thickness and the historical maximum determine opacity

A height map represents the raised shape of the liquid, but it doesn't fully reproduce the traces left when the liquid is spread thinly.
I wanted some color to remain even where the thickness had almost disappeared, so I added a separate alpha map.

This alpha map uses both a value derived from the current thickness and the maximum value recorded so far.

alpha.frag

float previousMaximum = texture2D(alphaMap, vUv).r;
float current = smoothstep(
  0.0,
  0.01 / 0.7,
  max(0.0, texture2D(heightMap, vUv).r)
);
float maximum = max(current, previousMaximum);
gl_FragColor = vec4(
  maximum,
  maximum * 0.5 + current * 0.5,
  0.0,
  0.0
);

current is calculated from the current frame's height map.
maximum keeps whichever is larger: the current value or the previous maximum. This records where the liquid has passed.

The R channel stores the historical maximum, while the G channel stores an equal blend of that maximum and the current value. The B channel is unused.
The final rendering pass samples the G channel.

As a result, opacity doesn't immediately fall to zero when the liquid moves away and the thickness decreases.
Some color remains where the liquid has passed, making it appear to adhere to the surface.

This isn't a physical model of drying or absorption. It is simply a history used to preserve the traces of the liquid.

Comparison showing the traces preserved by the height map and alpha map

3. Calculating Normals from the Height Map

Finally, I use the height map to add highlights to the liquid's surface.
This demo doesn't move actual geometry vertices to form the liquid's bumps and ridges.
Instead, I calculate surface normals from the height map inside the fragment shader.

For each pixel, I sample its height and the heights of its immediate neighbors in the X and Y directions.
These three points form two vectors, whose cross product gives the surface normal.

render.frag

float height = texture2D(heightMap, vUv).r;
vec2 stepSize = 1.0 / resolution;
vec2 uvX = vUv + vec2(stepSize.x, 0.0);
vec2 uvY = vUv + vec2(0.0, stepSize.y);

float heightScale = 0.2 / 0.66;
vec2 ratio = resolution / min(resolution.x, resolution.y);

vec3 position = vec3(
  (vUv * ratio).x,
  height * heightScale,
  (vUv * ratio).y
);
vec3 positionX = vec3(
  (uvX * ratio).x,
  texture2D(heightMap, uvX).r * heightScale,
  (uvX * ratio).y
);
vec3 positionY = vec3(
  (uvY * ratio).x,
  texture2D(heightMap, uvY).r * heightScale,
  (uvY * ratio).y
);

vec3 normal = -normalize(
  cross(positionX - position, positionY - position)
);

The normal has a different direction on flat areas and steeply raised areas.
This difference lets me render shading and highlights that make the flat surface appear to have raised liquid on it.

I use a Matcap texture for the color and highlights.
I convert the normal into Matcap UV coordinates, sample its color, and combine it with opacity from the alpha map.

render.frag

vec3 color = texture2D(
  matcap,
  normal.zx * 0.5 + 0.5
).rgb + 0.05;

float alphaValue = texture2D(alphaMap, vUv).g;
gl_FragColor = vec4(
  color,
  step(0.1, alphaValue) * alphaValue
);

The actual shader also adjusts the color based on the tilt of the normal.
The height map controls the surface shape, the Matcap controls the appearance, and the alpha map determines how the liquid's traces remain on the surface.

This Is Not a Physically Accurate Fluid Simulation

The thickness calculation described above is not physically accurate.

Fragment shaders process pixels in parallel, so a pixel cannot directly write its thickness into another pixel at the destination indicated by its velocity.
Instead, each destination pixel traces the velocity backward and samples the height map at the source position.

However, multiple pixels can sample approximately the same source region.
Because thickness isn't distributed precisely among destinations, the total amounts removed and added don't always match. Therefore, the total liquid volume isn't conserved.

Straight strokes keep the thickness relatively stable, while curved strokes can increase the amount of liquid

As shown above, the thickness remains relatively stable with straight movements.
When drawing circles, however, the sampling bias accumulates and the amount of liquid keeps increasing.
This method prioritizes visual appearance rather than accurately conserving the amount of liquid.

Bonus: Texture Precision

I also noticed something interesting during development.
The demo below shows grayscale lighting calculated from height-map normals. Use the toggle to switch between 8-bit and 32-bit float textures.

Stepped shading with an 8-bit texture compared with smooth shading using a 32-bit floating-point texture

A 32-bit floating-point texture can store height values with finer precision, producing smoother contours and shading.
An 8-bit texture, on the other hand, stores only 256 levels per channel. This quantizes the height map and produces visible steps in the normals and shading.
I found it interesting that the limitations of 8-bit precision, which I usually associate with color gradients, showed up here as visible steps in the shading.

Bonus: Mixing Three Colored Liquids

I also tried another experiment for fun.
I prepared three hemispherical blobs like the one in the first demo and gave each a different color. You can drag your mouse or finger to spread the liquids and mix their colors.

Each liquid has its own height map and alpha history, while all three share the same velocity map. Thickness and opacity are updated using the same calculations as in the first demo.
Finally, I mix the colors according to each liquid's thickness using a simplified Kubelka–Munk model, and calculate surface normals from their combined height. Where the thickness is almost zero, I use the remaining alpha history of each color to determine the mixing ratios.

A Final Note

I decided to write about this implementation now because I don't think I'll have many opportunities to use this method again, and because I wanted to document the method I worked out myself before AI becomes even more capable.

Over the past year alone, I feel AI has become much better at understanding coordinates and space, and at connecting the visuals I want to create with the calculations and implementation needed to achieve them.

Publishing this may mean the method is no longer something special. Even so, I thought I could leave a trace of what I was thinking and how I arrived at this visual effect.