Skip to content
HN On Hacker News ↗

Spreading Liquid with three.js | mofu

▲ 169 points • 23 comments • by mooreds • 3d ago • HN discussion ↗

Pangram verdict · v3.3

We believe that this text is a mix of AI, AI-assisted, and human-written content.

49 %

AI likelihood · overall

Mixed
49% human-written 47% AI-generated
SEGMENTS · HUMAN 16 of 24
SEGMENTS · AI 8 of 24
WORD COUNT 1,381
PEAK AI % 100% · §4
Analyzed
Nov 5
backend: pangram/v3.3
Segments scanned
24 windows
avg 58 words each
Distribution
49 / 47%
human / AI fraction
Verdict
Mixed
Pangram v3.3

Article text · 1,381 words · 24 segments analyzed

Human AI-generated
§1 Human · 0%

IntroductionAt Vue Fes Japan 2026, I talked about implementing a liquid foundation effect.

§2 Human · 0%

In this article, I'll continue from my previous fluid simulation article and explain how I created that effect.

§3 Human · 0%

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, 2021Liquid Spreading DemoI chose blue rather than a foundation shade for this demo, even though the original project was about makeup.

§4 AI · 100%

Try dragging your mouse or finger across the liquid below.Where I StartedWhen 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.

§5 AI · 100%

To reproduce those movements, I started by implementing a fluid simulation.How It WorksFirst, Some BackgroundThis effect uses the results of the fluid simulation I introduced in an earlier article on this blog.

§6 Human · 0%

It uses the same velocity map to create the liquid-spreading movement.Stable Fluids with three.jsmofu-dev.comThe raised liquid in the opening demo is rendered in a fragment shader, rather than by deforming the geometry.

§7 Human · 5%

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.

§8 Human · 4%

The diagram below illustrates a height map recording the thickness of a liquid.

§9 Human · 0%

The height value at each position represents how much liquid is present there.Implementation FlowThe fluid simulation itself is explained in my previous article, so here I'll focus on updating thickness and opacity, and rendering the result.Use the velocity map to find the source UV and update the thickness.Update opacity in an alpha map using the current height map and the history of where liquid has been applied.Calculate normals from the height map, then render with a Matcap and the alpha map.1.

§10 Human · 0%

Moving Thickness with the Velocity MapFirst, 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.fragfloat 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.The velocity map produced by the fluid simulation records the direction and magnitude of movement at each position on the screen.

§11 Human · 0%

First, I sample the thickness and velocity at the current pixel.

§12 Human · 0%

According to its velocity, I calculate how much thickness should move away and subtract that amount from the original thickness.height.fragfloat 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.

§13 Human · 0%

I trace the velocity backward from the current pixel and sample the thickness at the source position.

§14 Human · 0%

Here is the implementation of backtraceUv(), which calculates the source UV from the current UV, velocity, and elapsed time.height.fragvec2 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.fragvec2 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.

§15 AI · 100%

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.fragfloat 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.

§16 AI · 100%

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 MapA height map represents the raised shape of the liquid, but it doesn't fully reproduce the traces left when the liquid is spread thinly.

§17 Human · 0%

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.fragfloat 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.

§18 AI · 100%

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.

§19 AI · 100%

The B channel is unused.

§20 AI · 100%

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.3.

§21 AI · 100%

Calculating Normals from the Height MapFinally, 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.

§22 Human · 0%

These three points form two vectors, whose cross product gives the surface normal.render.fragfloat 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.

§23 Human · 0%

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.

§24 Human · 0%

I convert the normal into Matcap UV coordinates, sample its color, and combine it with opacity from the alpha map.render.fragvec3 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.