Fluid simulation and rendering is one of the most visually captivating challenges in real-time graphics. While true fluid physics is computationally expensive, clever shader tricks can create the illusion of sloshing, wobbling, and rippling liquids—perfect for interactive 3D scenes on the web.
In this blog, we'll take a deep technical dive into a custom GLSL shader powering a realistic chai cup liquid effect in a React Three Fiber (R3F) scene. We'll understand every line of the shader, how it's animated from React, and how to integrate it with a 3D model and text in your scene.
What you'll learn:
How to write and understand a custom Three.js shader for animated liquid
How to connect React state and animation to shader uniforms
How to render 3D models and text in R3F
How to create the illusion of fluid physics with only math and shaders
If you are looking forward to hire a talent for such animation, this is your guy. Give him a chance.
@Hiteshdotcom sir ji agar hero section ko redesign karno ho toh karna yaad hamme 😉
Project Overview
Our goal is to create a visually convincing chai cup with animated liquid, using only Three.js, React Three Fiber, and a custom GLSL shader. The effect should:
Simulate the liquid surface tilting, wobbling, and rippling as the cup moves
Be performant and interactive in the browser
Integrate seamlessly with React state and controls
We'll focus on two files:
liquidRefractionMaterial.js
: The custom shader materialmodel.jsx
: The React Three Fiber scene setup
The Chai Cup Scene: model.jsx
Let's start by understanding how the 3D scene is set up in React Three Fiber. Here's a simplified version of the relevant code:
1import React, { useRef, useMemo, useState, useCallback } from 'react';
2
3import { MeshTransmissionMaterial, useGLTF, Text } from '@react-three/drei';
4
5import { useFrame, useThree } from '@react-three/fiber';
6
7import { useControls } from 'leva';
8
9import * as THREE from 'three';
10
11import lerp from 'lerp';
12
13import './materials/liquidRefractionMaterial';
14
15export default function Model() {
16
17 const { nodes } = useGLTF('/models/chai.glb');
18
19 const { viewport, size } = useThree();
20
21 const chai = useRef(null);
22
23 const liquidBody = useRef(null);
24
25 // ... animation state refs ...
26
27 // Leva controls for scale, rotation, position, liquid, etc.
28
29 // ...
30
31 // Memoized uniforms for the shader
32
33 const uniforms = useMemo(() => ({
34
35 resolution: new THREE.Vector2(size.width, size.height),
36
37 fillAmount: { value: 0.07 },
38
39 wobbleX: { value: 0 },
40
41 wobbleZ: { value: 0 },
42
43 tiltX: { value: 0 },
44
45 tiltZ: { value: 0 },
46
47 rippleAmplitude: { value: 0 },
48
49 ripplePhase: { value: 0 },
50
51 tint: { value: new THREE.Vector4(1, 0.8, 0.5, 0.85) }
52
53 }), [size]);
54
55 // Animation logic (see next section)
56
57 useFrame(({ clock }) => {
58
59 // ... update uniforms based on cup movement ...
60
61 });
62
63 return (
64
65 <group scale={viewport.width / 3.75}>
66
67 {/* Render text behind the cup */}
68
69 <Text
70
71 font={'/fonts/PPNeueMontreal-Bold.otf'}
72
73 position={[0, 0, -1]}
74
75 fontSize={0.5}
76
77 color="white"
78
79 anchorX="center"
80
81 anchorY="middle"
82
83 >
84
85 ChaiCode
86
87 </Text>
88
89 {/* Chai cup group */}
90
91 <group ref={chai}>
92
93 <mesh {...nodes.Cylinder001_1}>
94
95 <MeshTransmissionMaterial /* glass material */ />
96
97 </mesh>
98
99 <mesh ref={liquidBody} {...nodes.Cylinder001_1} scale={[0.98, 0.98, 0.98]}>
100
101 <liquidRefractionMaterial {...uniforms} />
102
103 </mesh>
104
105 </group>
106
107 </group>
108
109 );
110
111}
112
113
Key Points:
The cup model is loaded from a GLTF file.
The glass uses a transmission material for realism.
The liquid is a slightly smaller mesh, using our custom shader.
The
<Text>
component renders "ChaiCode" behind the cup.All animation and physics are handled in the
useFrame
loop, which updates the shader uniforms.
Rendering Text Behind the Cup
To add a label or logo behind your 3D object, use the <Text>
component from @react-three/drei
:
1<Text
2
3 font={'/fonts/PPNeueMontreal-Bold.otf'}
4
5 position={[0, 0, -1]}
6
7 fontSize={0.5}
8
9 color="white"
10
11 anchorX="center"
12
13 anchorY="middle"
14
15>
16
17 ChaiCode
18
19</Text>
font: Path to your font file (OTF/TTF).
position: Place the text slightly behind the cup (z = -1).
fontSize: Controls the size of the text.
color: Any CSS color.
anchorX/anchorY: Center the text.
This is a powerful way to add branding, labels, or UI elements to your 3D scene.
The Liquid Shader:
Now, let's dive into the heart of the effect: the custom GLSL shader. This is where the magic happens!
Shader Overview
The shader is a subclass of THREE.ShaderMaterial
, with custom vertex and fragment shaders. It simulates:
A dynamic liquid surface that tilts, wobbles, and ripples
Realistic lighting and transparency
The illusion of fluid physics, all in real time
Let's break down every part, line by line.
Vertex Shader
varying vec2 vUv;
varying vec3 vPosition;
varying vec3 vNormal;
varying vec3 vWorldPosition;
void main() {
vUv = uv;
vPosition = position;
vNormal = normal;
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix modelViewMatrix vec4(position, 1.0);
}
Line-by-Line Explanation:
varying vec2 vUv;
— Passes the mesh's UV coordinates to the fragment shader (not used here, but useful for texturing).varying vec3 vPosition;
— The local-space position of each vertex.varying vec3 vNormal;
— The local-space normal vector at each vertex.varying vec3 vWorldPosition;
— The world-space position of each vertex (used for lighting and view calculations).
In main()
:
vUv = uv;
— Store the UVs for the fragment shader.vPosition = position;
— Store the local position.vNormal = normal;
— Store the normal.vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
— Transform the vertex to world space.gl_Position = projectionMatrix
modelViewMatrix
vec4(position, 1.0);
— Standard transformation to clip space for rendering.
Why do we need all these varyings?
They allow the fragment shader to know the position, normal, and world position of each pixel, which is essential for simulating the liquid surface and lighting.
Fragment Shader
uniform vec2 resolution;
uniform float fillAmount;
uniform float wobbleX;
uniform float wobbleZ;
uniform float tiltX;
uniform float tiltZ;
uniform float rippleAmplitude;
uniform float ripplePhase;
uniform vec4 tint;
varying vec2 vUv;
varying vec3 vPosition;
varying vec3 vNormal;
varying vec3 vWorldPosition;
void main() {
// Simulate a wobbly and tilting liquid surface with ripples
float wobbleStrength = 0.15;
float tiltStrength = 1.0;
float ripple = rippleAmplitude sin(8.0 vPosition.x + ripplePhase) cos(8.0 vPosition.z + ripplePhase);
float surface = fillAmount
+ tiltStrength (tiltX vPosition.x + tiltZ * vPosition.z)
+ wobbleStrength (wobbleX vPosition.x + wobbleZ * vPosition.z)
+ ripple;
if (vPosition.y > surface) discard;
// Simple diffuse lighting
vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3));
float diff = max(dot(normalize(vNormal), lightDir), 0.0);
vec3 baseColor = tint.rgb * diff;
gl_FragColor = vec4(baseColor, tint.a);
}
Line-by-Line Explanation:
Uniforms:
resolution
: The screen size (not used in this shader, but could be for advanced effects).fillAmount
: The base height of the liquid surface (controlled from React).wobbleX
,wobbleZ
: Wobble amounts in X and Z, animated from React to simulate sloshing.tiltX
,tiltZ
: Tilt amounts, so the liquid surface can lag behind the cup's rotation (inertia).rippleAmplitude
,ripplePhase
: Control the amplitude and phase of ripples on the surface.
tint
: The RGBA color of the liquid.
Varyings:
vUv
,vPosition
,vNormal
,vWorldPosition
: Passed from the vertex shader.
Main Function:
float wobbleStrength = 0.15;
float tiltStrength = 1.0;
These control how much the wobble and tilt affect the surface. You can tweak these for more/less dramatic motion.
float ripple = rippleAmplitude sin(8.0 vPosition.x + ripplePhase) cos(8.0 vPosition.z + ripplePhase);
This creates a grid of sine/cosine waves for the ripple effect. The frequency (8.0) controls how many ripples fit across the cup.
rippleAmplitude
andripplePhase
are animated from React to make the ripples move and fade.
float surface = fillAmount
+ tiltStrength (tiltX vPosition.x + tiltZ * vPosition.z)
+ wobbleStrength (wobbleX vPosition.x + wobbleZ * vPosition.z)
+ ripple;
This is the core of the fake fluid physics. The surface height at each pixel is:
The base fill level
Plus tilt (so the surface can lag/lead as the cup rotates)
Plus wobble (sloshing from movement)
Plus ripples (from impacts or quick movement)
if (vPosition.y > surface) discard;
If the current pixel is above the liquid surface, don't render it (make it transparent). This creates the visible surface of the liquid.
// Simple diffuse lighting
vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3));
float diff = max(dot(normalize(vNormal), lightDir), 0.0);
vec3 baseColor = tint.rgb * diff;
gl_FragColor = vec4(baseColor, tint.a);
This is a basic Lambertian (diffuse) lighting model. It makes the liquid look 3D and respond to light direction.
tint
is the color and alpha of the liquid, set from React.
Uniforms and Material Setup
In the JavaScript file:
1export class LiquidRefractionMaterial extends THREE.ShaderMaterial {
2
3 constructor() {
4
5 super({
6
7 vertexShader: ...,
8
9 fragmentShader: ...,
10
11 uniforms: {
12
13 resolution: { value: new THREE.Vector2() },
14
15 fillAmount: { value: 0 },
16
17 wobbleX: { value: 0 },
18
19 wobbleZ: { value: 0 },
20
21 tiltX: { value: 0 },
22
23 tiltZ: { value: 0 },
24
25 rippleAmplitude: { value: 0 },
26
27 ripplePhase: { value: 0 },
28
29 tint: { value: new THREE.Vector4(1, 0.8, 0.5, 0.85) }
30
31 },
32
33 transparent: true
34
35 })
36
37 }
38
39}
40
41extend({ LiquidRefractionMaterial })
The shader is registered as a JSX component for use in R3F.
All uniforms are initialized and can be updated from React.
transparent: true
allows the liquid to blend with the glass and background.
Animating the Liquid: Connecting React and GLSL
The real magic is how the React code animates the shader uniforms to create the illusion of fluid physics.
Spring-Damper Inertia
The liquid surface doesn't instantly follow the cup's rotation. Instead, it lags behind and overshoots, like real fluid.
This is done with a spring-damper system:
1const targetTiltX = rotationControls.autoRotate ? torus.current.rotation.x : 0;
2
3const targetTiltZ = rotationControls.autoRotate ? torus.current.rotation.z : 0;
4
5const stiffness = 8;
6
7const damping = 1.5;
8
9liquidTilt.current.vx += (targetTiltX - liquidTilt.current.x) stiffness delta;
10
11liquidTilt.current.vx = Math.exp(-damping delta);
12
13liquidTilt.current.vz += (targetTiltZ - liquidTilt.current.z) stiffness delta;
14
15liquidTilt.current.vz = Math.exp(-damping delta);
16
17liquidTilt.current.x += liquidTilt.current.vx * delta;
18
19liquidTilt.current.z += liquidTilt.current.vz * delta;
This simulates inertia and settling, so the liquid feels "heavy" and natural.
Wobble and Ripples
When the cup is moved or rotated quickly, the code adds to the wobble and ripple uniforms:
1const movement = velocity.length() + rotationChange.length();
2
3if (movement > 0.05) {
4
5 ripple.current.amplitude = Math.min(ripple.current.amplitude + movement * 0.1, 0.2);
6
7}
8
9ripple.current.phase += delta * 6.0;
10
11ripple.current.amplitude = lerp(ripple.current.amplitude, 0, delta * 2.5);
This makes the liquid surface react to impacts and sloshing, with ripples that fade over time.
Updating the Shader
Every frame, the React code updates the shader uniforms:
1if (liquidControls.showLiquid && liquidBody.current) {
2
3 liquidBody.current.material.uniforms.wobbleX.value = wobbleAmountX;
4
5 liquidBody.current.material.uniforms.wobbleZ.value = wobbleAmountZ;
6
7 liquidBody.current.material.uniforms.fillAmount.value = liquidControls.fillAmount;
8
9 liquidBody.current.material.uniforms.tiltX.value = liquidTilt.current.x;
10
11 liquidBody.current.material.uniforms.tiltZ.value = liquidTilt.current.z;
12
13 liquidBody.current.material.uniforms.rippleAmplitude.value = ripple.current.amplitude;
14
15 liquidBody.current.material.uniforms.ripplePhase.value = ripple.current.phase;
16
17 const color = new THREE.Color(liquidControls.color);
18
19 liquidBody.current.material.uniforms.tint.value = new THREE.Vector4(color.r, color.g, color.b, liquidControls.opacity);
20
21}
This tight integration between React state and GLSL uniforms is what makes the effect interactive and real-time.
The Math Behind the Animation
To create realistic fluid motion, we use several mathematical techniques. Let's break down each one:
Linear Interpolation (Lerp)
1ripple.current.amplitude = lerp(ripple.current.amplitude, 0, delta * 2.5);
The lerp
function (linear interpolation) smoothly transitions between two values. In this case:
First argument: Current value
ripple.current.amplitude
)Second argument: Target value (0)
Third argument: Interpolation factor
delta * 2.5
)
The formula is:
1lerp(a, b, t) = a + (b - a) * t
We use lerp
for the ripple amplitude because:
It creates smooth transitions (no sudden jumps)
The
delta * 2.5
factor makes the decay time-consistent (same speed regardless of frame rate)It's computationally efficient compared to more complex easing functions
Spring-Damper System
1const stiffness = 8;
2
3const damping = 1.5;
4
5liquidTilt.current.vx += (targetTiltX - liquidTilt.current.x) stiffness delta;
6
7liquidTilt.current.vx = Math.exp(-damping delta);
This is a simplified version of a mass-spring-damper system, which simulates:
Spring Force:
(targetTiltX - liquidTilt.current.x) * stiffness
Pulls the liquid toward the target angle
Stronger when further from target
stiffness
controls how "stiff" the spring is
Damping Force:
Math.exp(-damping * delta)
Reduces velocity over time
Prevents infinite oscillation
damping
controls how quickly oscillations settle
The exponential decay Math.exp(-damping * delta)
) is crucial because:
It creates natural-feeling deceleration
It's frame-rate independent
It prevents the system from becoming unstable
Sine/Cosine Waves for Ripples
float ripple = rippleAmplitude sin(8.0 vPosition.x + ripplePhase) cos(8.0 vPosition.z + ripplePhase);
We use sine and cosine waves because:
They create natural-looking circular ripples
They're periodic (repeat smoothly)
They're computationally efficient
The multiplication of sine and cosine creates a grid pattern that looks like water ripples
The ripplePhase
uniform is animated in React:
1ripple.current.phase += delta * 6.0;
This creates the illusion of ripples moving outward, with:
delta * 6.0
controlling the speed of the ripple movementThe phase being added to both x and z coordinates to create circular motion
Velocity-Based Effects
1const movement = velocity.length() + rotationChange.length();
2
3if (movement > 0.05) {
4
5 ripple.current.amplitude = Math.min(ripple.current.amplitude + movement * 0.1, 0.2);
6
7}
This code:
Calculates total movement (translation + rotation)
Only triggers ripples above a threshold (0.05)
Scales ripple amplitude by movement speed
Caps the maximum amplitude (0.2)
The Math.min()
function ensures the ripples don't become too extreme, while still responding to movement intensity.
Why These Techniques Work Together
The combination of these mathematical techniques creates the illusion of real fluid physics:
lerp
provides smooth transitionsSpring-damper system creates inertia and settling
Sine/cosine waves create natural-looking ripples
Velocity-based effects make the liquid respond to movement
This approach is much more efficient than true fluid simulation while still creating a convincing effect.
Conclusion and Further Exploration
With just a custom shader and some clever math, you can create the illusion of realistic, animated fluid in a 3D scene no heavy physics simulation required!
Ideas for further exploration:
Add foam or bubbles to the liquid surface
Use environment mapping for more realistic refraction
Simulate pouring or splashing
Add sound or haptic feedback for even more immersion
Full Source Code
You can find the full source code for the shader and scene setup in the files:
src/components/scene/materials/liquidRefractionMaterial.js
src/components/scene/model.jsx
Github link: https://github.com/Montekkundan/chaiaurcode_hero_section
Happy coding, cheers!