Real-Time Fluid Shaders in React Three Fiber: A Deep Dive into Chai Cup Liquid

Development

May 23, 2025 montek.dev

0 views

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


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 material

  • model.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
jsx

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>
jsx
  • 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 and ripplePhase 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 })
javascript
  • 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;
javascript
  • 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);
javascript
  • 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}
javascript
  • 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);
javascript

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
javascript

We use lerp for the ripple amplitude because:

  1. It creates smooth transitions (no sudden jumps)

  2. The delta * 2.5 factor makes the decay time-consistent (same speed regardless of frame rate)

  3. 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);
javascript

This is a simplified version of a mass-spring-damper system, which simulates:

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

  1. 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:

  1. They create natural-looking circular ripples

  2. They're periodic (repeat smoothly)

  3. They're computationally efficient

  4. 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;
javascript

This creates the illusion of ripples moving outward, with:

  • delta * 6.0 controlling the speed of the ripple movement

  • The 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}
javascript

This code:

  1. Calculates total movement (translation + rotation)

  2. Only triggers ripples above a threshold (0.05)

  3. Scales ripple amplitude by movement speed

  4. 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 transitions

  • Spring-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!


May 23, 2025 montek.dev

0 views

Comments

Join the discussion! Share your thoughts and engage with other readers.

Leave comment