Faking sloshing liquid in Godot
August 24, 2026
While working on Teufelskreis, I made the very reasonable decision to spend several evenings on the water inside a single bottle.
Sometimes an effect does not need to be a hero feature of a game to feel like one. In Half-Life: Alyx there are bottles with liquid inside them. Nobody would ever describe it as “the game with the liquid shader in the bottles”, but I remember picking up every goddamn bottle I found, holding it against the light and turning it around just to watch the liquid move.
I cannot quite put my finger on why details like this mesmerize me so much. Maybe it is because they make a world feel as though somebody cared about it beyond the things the player was actually supposed to notice. Either way, when I put a bottle into Teufelskreis, I wanted that too.
The bottle can be picked up, carried around and thrown. A static blue mesh looked fine from a distance, but the moment I held it in front of the camera it became painfully obvious that this was not water. It was blue plastic inside slightly less blue plastic.
The first moving version already got most of the way there. Then I showed it to people and the most requested addition was bubbles. This was fair, but it also exposed every shortcut I had taken with the liquid surface and transparent rendering.
The final effect is still fake. There is no fluid simulation and the liquid mesh itself never changes shape. It is a slightly smaller copy of the bottle, cut by a moving surface equation. A GDScript component supplies the movement and three shaders agree on where the water ends.
Starting with the cheapest possible liquid
The core idea is based around some old X posts I found from MinionsArt about doing this in Unity. I liked how little the effect actually needed, so I started rebuilding the same trick in Godot and went from there.
The setup is beautifully cheap:
- Duplicate the container mesh.
- Scale the duplicate down slightly so it sits inside the glass or plastic shell.
- Discard every fragment above a world-space fill plane.
- Color the revealed backfaces as the top of the liquid.
For my bottle the inner copy is scaled to 0.94. The shader then passes each vertex’s position relative to the centre of that volume into the fragment stage:
shader_type spatial;
render_mode blend_mix, cull_disabled, depth_draw_always;
instance uniform float fill_height = 0.03;
instance uniform vec3 volume_center = vec3(0.0);
instance uniform float wobble_x = 0.0;
instance uniform float wobble_z = 0.0;
varying vec3 rel_pos;
void vertex() {
vec3 center_world = (MODEL_MATRIX * vec4(volume_center, 1.0)).xyz;
rel_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz - center_world;
}
void fragment() {
float fill_edge = rel_pos.y;
fill_edge += rel_pos.x * wobble_x;
fill_edge += rel_pos.z * wobble_z;
if (fill_edge > fill_height) {
discard;
}
}
Because rel_pos is expressed in world axes, its Y component remains vertical even when the bottle rotates. Translating the bottle also translates its centre, so the subtraction keeps the fill level attached to the container.
wobble_x and wobble_z tilt the clipping plane. They basically describe how steep the surface should be on each axis.
Despite calling the editor control fill_ratio, this is not a volume simulation. I only move the plane up and down. If you turn an irregular bottle on its side, the amount below that plane will not remain perfectly constant. It is tuned for this bottle and the range in which it appears in the game.
Movement takes similar shortcuts. World Y is always up, sideways movement kicks the spring and rotation makes the water more agitated. Free fall and other complicated cases are not simulated.
The top that does not exist
There is no lid mesh closing the cut. With culling disabled, the backfaces on the far side of the volume become visible through the opening. Painting those backfaces like a horizontal surface is enough to convince the eye that the volume is closed.
Godot exposes this through FRONT_FACING. My first version branched between the body and the implied surface inside one material. This stopped working once bubbles were added because transparent faces from the same mesh cannot be reliably placed on both sides of another transparent object.
The solution was to draw the liquid mesh twice:
uniform bool surface_pass = false;
void fragment() {
if ((surface_pass && FRONT_FACING) ||
(!surface_pass && !FRONT_FACING)) {
discard;
}
// The shared clipping and shading follows here.
}
The first pass contains only the front faces of the liquid body. The second contains only the backfaces used for the surface. This gives me a slot between both passes where the bubbles can be rendered.
Making the surface lag behind
A shader has no memory of the previous frame, so the wobble is calculated in GDScript. The component measures the container’s velocity and converts changes in that velocity into an impulse for a damped spring.
func _process(delta: float) -> void:
var velocity := (global_position - _last_position) / delta
var velocity_change := velocity - _last_velocity
_last_position = global_position
_last_velocity = velocity
var kick := Vector2(velocity_change.x, velocity_change.z) * impact_scale
_surface_velocity += kick
var frequency := TAU * wobble_speed
_surface_velocity -= _surface_slope * frequency * frequency * delta
_surface_velocity *= exp(-2.0 * recovery * delta)
_surface_slope += _surface_velocity * delta
_surface_slope = _surface_slope.limit_length(max_wobble)
Accelerating the bottle to the left adds a negative kick. The clipping equation then raises the water on the right, which is the first detail that makes the liquid feel detached from its container.
The latest version substeps this spring at 1 / 60 seconds. Without that, one unfortunate frame hitch can put enough energy into the spring to launch the waterline into the bottle cap. I think there might be a better way to solve this, but I do not know it yet.
The two slope values are sent to the mesh using per-instance uniforms:
liquid_mesh.set_instance_shader_parameter("wobble_x", _surface_slope.x)
liquid_mesh.set_instance_shader_parameter("wobble_z", _surface_slope.y)
This lets multiple containers share one material without also sharing their current fill level and motion.
A tilted line still looks like a tilted line
The spring made the liquid react, but the waterline remained suspiciously perfect. No matter how hard I moved the bottle it was always a straight cut through the mesh. A lot of the feedback on Reddit and elsewhere pointed out exactly that. The water moved, but the perfectly straight line made the trick very obvious.
I ended up using two different kinds of surface motion.
The first is a small three-octave gradient noise. It only appears while the liquid is agitated and gives the edge its turbulent detail. A scrolling noise texture should work just as well if you do not want to put an fBm implementation in the shader, I think.
This noise currently uses Godot’s built-in TIME, which resets after an hour by default. That means the noise can jump once at the reset. It has not mattered for this one bottle so far, but for the reusable version I would give it its own timer.
The second is a much broader sine wave running along the latest direction of movement. It appears quickly when the bottle is shaken, keeps moving for a while and then settles away again.
The wave state is packed into two vec4 instance uniforms:
// direction angle, amplitude, energy, phase
instance uniform vec4 wave_state = vec4(0.0);
// scale, harmonic weight, noise warp, refraction influence
instance uniform vec4 wave_shape = vec4(7.0, 0.3, 0.45, 0.35);
Godot has a practical limit of 16 instance uniforms per shader. I found this out in the least surprising way possible: by adding number 17.
The actual wave is two sine waves layered on top of each other. One is broader and the other stops the surface from looking like a perfect ribbon:
float directional_wave(vec2 position, float noise_warp) {
vec2 direction = vec2(cos(wave_state.x), sin(wave_state.x));
float coordinate = dot(position, direction) * wave_shape.x;
coordinate += noise_warp * wave_shape.z * wave_state.z;
float harmonic = max(wave_shape.y, 0.0);
float wave = sin(coordinate + wave_state.w);
wave += sin(
coordinate * 1.83 + wave_state.w * 2.0 + 1.7
) * harmonic;
return wave / (1.0 + harmonic);
}
That second wave caused one of the most annoying bugs in the entire effect. Whenever I reset the timer, the two waves did not quite line up and the whole surface visibly ticked every 0.8 seconds. Making the second one run exactly twice as fast fixed it. That is the entire reason for the suspiciously neat 2.0 in the code above.
The noise and wave are simply added to the same clipping equation:
float ripple = 0.0;
if (agitation > 0.0001) {
ripple = ripple_noise(rel_pos.xz, TIME * ripple_speed) * agitation;
}
float wave = 0.0;
if (wave_state.z > 0.0001) {
wave = directional_wave(rel_pos.xz, ripple);
}
float fill_edge = rel_pos.y;
fill_edge += rel_pos.x * wobble_x + rel_pos.z * wobble_z;
fill_edge += ripple * ripple_height;
fill_edge += wave * wave_state.y;
if (fill_edge > fill_height) {
discard;
}
For still water, agitation and the wave amplitude both settle to exactly zero. The uniform branches shown above then skip the gradient-noise octaves and directional-wave calculation while the bottle is sitting on the floor.
Refraction, or where transparency gets annoying
The clipped and moving volume now looked like liquid, but still not like water. For that I sample the rendered scene behind the bottle and offset the sample using the surface motion. On the body pass I add a second offset from the bottle mesh’s view-space normal.
Godot makes this available through hint_screen_texture:
uniform sampler2D screen_texture:
hint_screen_texture, filter_linear_mipmap, repeat_disable;
uniform float refraction_strength = 0.09;
uniform float refraction_ripple = 0.6;
uniform float blur_lod = 1.5;
float ndv = clamp(dot(normalize(NORMAL), VIEW), 0.0, 1.0);
float motion = ripple + wave * wave_state.z * wave_shape.w;
vec2 offset = vec2(motion, motion * -0.7) * refraction_ripple;
if (!surface_pass) {
offset += vec2(NORMAL.x, -NORMAL.y) * (1.35 - ndv);
}
vec2 screen_uv = clamp(
SCREEN_UV + offset * refraction_strength,
vec2(0.001),
vec2(0.999)
);
vec3 seen = textureLod(screen_texture, screen_uv, blur_lod).rgb;
ALBEDO = water_color.rgb * water_opacity;
EMISSION = seen * water_color.rgb * (1.0 - water_opacity);
The backfaces are still parts of the bottle wall, so their normals point in completely unhelpful directions. I do not use those normals for the top refraction. That part only follows the ripple and wave. For lighting I replace the normal with world-up:
if (surface_pass) {
NORMAL = normalize((VIEW_MATRIX * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
}
This is not the real normal of the wavy surface. It just stops the top from being lit like the inside wall it actually is.
The higher mip level gives the transmitted image a slight blur without requiring my own blur pass. It is not free: Godot still generates the mip levels and samples them. Mixing the sample with a solid tint stops bright scenery behind the bottle from completely washing out the water.
There is one important catch. In 3D, Godot copies the screen after the opaque pass and before transparent geometry. Transparent materials therefore do not appear in the screen texture used by other transparent materials.
This is why my first bubbles looked as if they were pasted in front of the water distortion. The bubble shader could sample the room behind the bottle, but not the liquid that had already been drawn.
I solved this by making the bubble shader reproduce the relevant water optics. It receives the same ripple, wave and refraction settings, offsets its screen position by the water’s distortion, and applies the water tint to its own screen sample. This is not physically correct, but the bubbles finally looked as though they were inside the water.
Transparent render priority puts the layers in this order:
| Priority | Layer |
|---|---|
-2 | Liquid body/front faces |
-1 | Bubble spheres |
0 | Implied liquid surface/backfaces |
1 | Wet film on the inner wall |
2 | Plastic bottle shell |
Priority is only half of the setup. These are the render and depth modes used by the three custom materials:
// Liquid body and implied-surface passes
render_mode blend_mix, cull_disabled, depth_draw_always;
// Bubbles
render_mode blend_mix, cull_back, depth_draw_never,
depth_test_disabled, unshaded;
// Wet film
render_mode blend_mix, cull_disabled, depth_draw_never, unshaded;
The liquid writes depth. The bubbles are physically behind its near face, so drawing them later is not enough by itself: disabling their depth test is what prevents that liquid depth from rejecting them. The implied-surface pass keeps normal depth testing and appears in the opening where the body pass discarded its fragments.
depth_test_disabled is a hack that works for this bottle. With lots of containers or opaque objects passing in front of it, I would need a proper mask.
Godot sorts transparent objects by their Node3D position, not their individual vertices or triangles. Splitting the body and surface into separate objects gave the renderer something it could order explicitly and fixed the long triangular streaks from interleaved front- and backface triangles.
Bubbles without a fluid simulation
The bubbles are real sphere meshes, just not very many of them. I allocate a pool of 28 spheres in a MultiMesh and hide inactive entries by setting their scale to zero.
Still water should not produce a snow globe of bubbles whenever the player picks up the bottle. Bubble spawning therefore starts only once agitation passes 0.88. A separate nucleation rate can bypass that threshold for carbonated liquids and spawn smaller spheres near the bottom.
The bubbles also felt unnatural when they simply appeared at full size. I kept pouring myself glasses of sparkling water and staring at the nucleation sites, and the obvious difference was that real bubbles seem to grow out of a tiny point. Mine were just popping into existence.
Each new bubble therefore begins at zero scale and grows with a short ease-out:
func bubble_spawn_scale(age: float) -> float:
var progress := clampf(age / spawn_duration, 0.0, 1.0)
return 1.0 - pow(1.0 - progress, 3.0)
It is a tiny animation, but it makes the bubbles feel as though they are actually forming inside the water.
Their upward motion accelerates toward a size-dependent terminal speed:
var size_mix := clampf(
inverse_lerp(minimum_radius, maximum_radius, bubble.radius),
0.0,
1.0
)
var rise_ratio := lerpf(minimum_rise_ratio, maximum_rise_ratio, size_mix)
var terminal_speed := volume_height * rise_ratio
var upward_speed := bubble_velocity.dot(local_up)
upward_speed = move_toward(
upward_speed,
terminal_speed,
volume_height * buoyancy * delta
)
Several out-of-sync sine waves add a small sideways drift. I originally wanted heavy turbulence here, but subtle movement looked much better. Large zig-zags made the bubbles read as insects trapped in the bottle.
The final problem was keeping them below a liquid surface that only exists inside a shader. The CPU does a cheaper version of the waterline just to decide when a bubble has reached the top. It first converts the bubble position into the same centre-relative world space, then checks the tilt and broad wave:
func surface_distance(local_position: Vector3) -> float:
var world_position := global_transform * local_position
var center_world := volume_mesh.global_transform * volume_center
var relative := world_position - center_world
var distance := relative.y
distance += relative.x * surface_slope.x
distance += relative.z * surface_slope.y
distance += directional_wave(relative.xz) * wave_amplitude
return distance - fill_height
I leave the tiny noise ripples out on the CPU. Following every small crest was not useful for deciding whether a bubble had generally reached the top.
When the bubble centre approaches the approximate surface it is removed from the pool. The bubble fragment shader evaluates the complete visual surface, including the ripple and warped directional wave, per pixel and fades the sphere over its last few millimetres. This prevents a large bubble from poking through the implied top while the bottle is tilted or shaken.
The last few lies
Three smaller details ended up doing more work than their code size suggests.
The bright line around the water level is a narrow smoothstep band around fill_height. Adding fwidth(fill_edge) to its softness keeps it from cracking into staircase-shaped pixels when the liquid moves.
The wet film is another slightly enlarged copy of the liquid mesh. The component remembers the highest recent slope on the positive and negative X/Z sides, then releases those four values more slowly than the live surface. Its shader fills the gap with a broken noise pattern, leaving short sheets and droplets on the inside wall.
Finally, every visible part receives the same position, tilt and wave values. The liquid and bubble shaders draw the complete surface. The CPU uses its cheaper version, but still agrees on the big movements. Most of the spectacular bugs in this effect came from one part being just slightly out of sync with the others.
Steal this
If you want the short version, build it in this order:
- Put a slightly shrunken copy of your container mesh inside its transparent shell.
- Clip it against a world-space horizontal plane relative to the mesh bounds centre.
- Use the backfaces as an implied top surface.
- Drive two clipping-plane slopes with a damped spring fed by velocity changes.
- Add small agitation noise and a slower directional sine wave to the clipping equation.
- Sample
hint_screen_texturewith a normal and wave offset for refraction. - If you add transparent contents, split the body and surface into separate passes and choose the render order explicitly.
- Make every visual clipping pass reuse the complete surface equation. If the CPU uses a cheaper version, make sure it still agrees with the shader on the big movements.
The values worth exposing first are fill height, maximum slope, spring speed, recovery, wave height, agitation threshold and refraction strength. Everything after that is taste.
The result is not water. It is one mesh being discarded in the right places, plus a small spring convincing every part of the effect to tell the same lie. For one bottle in the corner of a room, that is more than enough simulation for me.