Skip to content

Toon water with a floating crate

Stylized water with flat colors, foam, ripples, and a crate that really floats. Includes code for Three.js, Unity, and Godot.

date:
category:
Development
read:
14 min
likes:

This is the kind of water I'd use in a small stylized game. Grab the crate and throw it, or hold it under and let go. Tap the water to make ripples, and watch them push the crate around.

Throw the crate · Tap the water · Drag to orbit
Water preview controls

How it works

There are four small ideas here, and each one works on its own.

Flat colors. Two sine waves move the surface up and down. The shader then picks one of four colors based on height, with no blending between them. Those hard steps are most of what makes it look like a toon.

Ripples. A grid of numbers holds how far each point of water is displaced. Every step, each point is pulled toward the average of its neighbors, and that's enough for ripples to spread and bounce off walls. The result goes to the shader along with a second value for foam.

Foam. Foam comes from two places. A band follows the line where the water touches the crate, found by measuring how far each water pixel is from a box around it. Fast-moving water also leaves foam behind and it fades over a second or two, which gives you wakes and splashes.

Floating. Each corner of the crate checks how deep it is. A corner underwater gets pushed up, and the deeper it goes, the harder the push. Because the push happens at the corners, the crate tips and rights itself without any animation.

The code

Pick your engine. Each tab has everything you need: the shader, plus a script that runs the ripples and floats the crate.

All three assume the water is a 6 × 6 grid sitting flat at the origin, and a crate about one unit wide. The foam around the crate uses half-widths of 0.55, so change that value if your object is a different size. The island is a circle set by its X, Z, and radius. It adds a beach ring and blocks ripples, so move it off the grid if you don't want one.

Save the shaders next to your code. In a Vite project, the ?raw suffix imports them as strings.

water.vert.glsl
uniform float uTime;
uniform float uWaves;
attribute float aRipple;
attribute float aFoam;
varying vec2 vPosition;
varying float vHeight;
varying float vFoam;
varying float vWall;
14 collapsed lines
void main() {
vec3 p = position;
vPosition = p.xz;
vFoam = aFoam;
// Rim vertices hang down to the sea floor and form the side walls.
vWall = step(p.y, -0.01);
if (vWall < 0.5) {
p.y = aRipple + uWaves * (sin(p.x * 2.2 + uTime * 1.3) * 0.65
+ cos(p.z * 2.8 - uTime) * 0.35);
}
vHeight = p.y;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
water.frag.glsl
uniform float uTime;
uniform float uFoam;
uniform vec3 uIsland;
uniform mat4 uWaterToCrate;
varying vec2 vPosition;
varying float vHeight;
varying float vFoam;
varying float vWall;
81 collapsed lines
vec2 hash(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453);
}
// Voronoi border distance: the wobbling net of light on stylized water.
float caustics(vec2 p) {
vec2 cell = floor(p);
vec2 f = fract(p);
float nearest = 8.0;
float second = 8.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 g = vec2(float(x), float(y));
vec2 o = 0.5 + 0.5 * sin(uTime * 0.7 + 6.2831 * hash(cell + g));
float d = length(g + o - f);
if (d < nearest) {
second = nearest;
nearest = d;
} else if (d < second) {
second = d;
}
}
}
return second - nearest;
}
void main() {
vec2 p = vPosition;
if (vWall > 0.0) {
// Side walls: stepped depth, with a bright lip at the surface.
vec3 wall = mix(vec3(0.05, 0.36, 0.72), vec3(0.04, 0.55, 0.82), step(-0.95, vHeight));
wall = mix(wall, vec3(0.1, 0.72, 0.85), step(-0.4, vHeight));
wall = mix(wall, vec3(0.92, 1.0, 0.97), step(vWall, 0.03));
gl_FragColor = vec4(wall, mix(0.82, 1.0, step(vWall, 0.03)));
return;
}
// Hard color steps give the surface its illustrated look.
float band = vHeight / 0.1;
vec3 color = mix(vec3(0.03, 0.5, 0.82), vec3(0.04, 0.66, 0.86), step(-0.5, band));
color = mix(color, vec3(0.12, 0.8, 0.84), step(0.5, band));
color = mix(color, vec3(0.36, 0.91, 0.84), step(1.3, band));
// The sea floor shelves up around the island.
float shore = length(p - uIsland.xy) - uIsland.z;
color = mix(color, vec3(0.42, 0.93, 0.8), 1.0 - step(0.42, shore));
float alpha = mix(0.56, 0.8, smoothstep(0.0, 0.8, shore));
// A thin net of light drifts across everything.
vec2 warp = p + 0.2 * sin(p.yx * 2.4 + uTime * 0.5);
float net = 1.0 - step(0.055, caustics(warp * 1.7));
color = mix(color, vec3(0.74, 0.98, 0.92), net * 0.55);
// The crate shades the water straight below it.
vec3 local = (uWaterToCrate * vec4(p.x, vHeight, p.y, 1.0)).xyz;
vec2 under = abs(local.xz) - 0.5;
color *= 1.0 - 0.2 * step(max(under.x, under.y), 0.0) * step(local.y, -0.5);
// Measure each displaced water fragment against the oriented crate box,
// so a band of foam hugs the waterline wherever the two actually meet.
vec3 q = abs(local) - vec3(0.55);
float distanceToCrate = length(max(q, 0.0))
+ min(max(q.x, max(q.y, q.z)), 0.0);
float noise = sin(p.x * 17.0 + uTime * 1.6) * sin(p.y * 15.0 - uTime * 1.1);
float enabled = step(0.001, uFoam);
float contact = 1.0 - step(uFoam * (0.14 + 0.05 * noise), distanceToCrate);
// Foam left by the simulation: wakes, splashes, and breaking crests.
float trail = step(0.62 - 0.62 * caustics(p * 3.4), vFoam * uFoam * 1.25);
// Two rings lap at the beach.
float lap = step(0.8, sin(shore * 13.0 - uTime * 1.4 + noise)) * (1.0 - step(0.34, shore));
float beach = 1.0 - step(0.07 + 0.035 * noise, shore);
float foam = max(contact, max(trail, max(lap, beach))) * enabled;
color = mix(color, vec3(0.95, 1.0, 0.97), foam);
gl_FragColor = vec4(color, max(alpha, foam));
}

This is the simulation behind the preview: ripples, foam, and the crate's movement. It only needs three for vectors and quaternions.

physics.ts
import * as THREE from "three";
export const SIZE = 6;
export const FLOOR = -1.5;
// Grid cells per side. The extra ring of vertices forms the side walls.
const N = 96;
export const SEGMENTS = N + 2;
const W = N + 3;
317 collapsed lines
const DX = SIZE / N;
const STEP = 1 / 120;
const SPEED = (2.2 * STEP) / DX;
const GRAVITY = 9.8;
const LIFT = 5.2;
const WATER_DRAG = 0.9;
const CONTACT = 420;
const CONTACT_DAMPING = 11;
const GRAB = 95;
const GRAB_DAMPING = 11;
const INERTIA = 0.2;
const HALF = 0.5;
export const ISLAND = { x: -1.55, z: -1.55, radius: 1.1 };
export const isIsland = (x: number, z: number) =>
Math.hypot(x - ISLAND.x, z - ISLAND.z) < ISLAND.radius;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export function createPhysics(onSplash: (x: number, z: number, strength: number) => void) {
const height = new Float32Array(W * W);
const velocity = new Float32Array(W * W);
const foam = new Float32Array(W * W);
const land = new Uint8Array(W * W);
const worldAt = (i: number) => (i - 1) * DX - SIZE / 2;
let wetCells = 0;
for (let j = 0; j < W; j++) {
for (let i = 0; i < W; i++) {
const outside = i === 0 || j === 0 || i === W - 1 || j === W - 1;
land[j * W + i] = isIsland(worldAt(i), worldAt(j)) ? 1 : 0;
if (!land[j * W + i] && !outside) wetCells++;
}
}
const position = new THREE.Vector3();
const linear = new THREE.Vector3();
const angular = new THREE.Vector3();
const rotation = new THREE.Quaternion();
const corners = [-HALF, HALF].flatMap((x) =>
[-HALF, HALF].flatMap((y) => [-HALF, HALF].map((z) => new THREE.Vector3(x, y, z))),
);
const grabPoint = new THREE.Vector3();
const grabTarget = new THREE.Vector3();
let grabbed = false;
const force = new THREE.Vector3();
const torque = new THREE.Vector3();
const point = new THREE.Vector3();
const arm = new THREE.Vector3();
const pointVelocity = new THREE.Vector3();
const contact = new THREE.Vector3();
const spin = new THREE.Quaternion();
let wasWet = false;
let cooldown = 0;
const state = { time: 0, waves: 0.14 };
const baseWave = (x: number, z: number) =>
state.waves *
(Math.sin(x * 2.2 + state.time * 1.3) * 0.65 + Math.cos(z * 2.8 - state.time) * 0.35);
const rippleAt = (x: number, z: number) => {
const fx = clamp((x + SIZE / 2) / DX + 1, 1, N);
const fz = clamp((z + SIZE / 2) / DX + 1, 1, N);
const i = Math.floor(fx);
const j = Math.floor(fz);
const u = fx - i;
const v = fz - j;
const a = height[j * W + i] * (1 - u) + height[j * W + i + 1] * u;
const b = height[(j + 1) * W + i] * (1 - u) + height[(j + 1) * W + i + 1] * u;
return a * (1 - v) + b * v;
};
const surface = (x: number, z: number) => baseWave(x, z) + rippleAt(x, z);
// Visits every cell within `radius` of a point, skipping the island.
const forCells = (
x: number,
z: number,
radius: number,
visit: (index: number, dx: number, dz: number, distance: number) => void,
) => {
const cx = (x + SIZE / 2) / DX + 1;
const cz = (z + SIZE / 2) / DX + 1;
const reach = Math.ceil(radius / DX);
for (let j = Math.max(1, Math.floor(cz) - reach); j <= Math.min(N + 1, cz + reach); j++) {
for (let i = Math.max(1, Math.floor(cx) - reach); i <= Math.min(N + 1, cx + reach); i++) {
const index = j * W + i;
if (land[index]) continue;
const dx = worldAt(i) - x;
const dz = worldAt(j) - z;
const distance = Math.hypot(dx, dz);
if (distance <= radius) visit(index, dx, dz, distance);
}
}
};
const poke = (x: number, z: number, strength: number, radius = 0.28) => {
forCells(x, z, radius, (index, _dx, _dz, distance) => {
const falloff = 1 - distance / radius;
velocity[index] -= strength * falloff * falloff;
foam[index] = Math.min(1, foam[index] + strength * 50 * falloff);
});
};
const stepWater = () => {
let total = 0;
for (let j = 1; j <= N + 1; j++) {
for (let i = 1; i <= N + 1; i++) {
const index = j * W + i;
if (land[index]) continue;
const neighbors =
(land[index - 1] ? height[index] : height[index - 1]) +
(land[index + 1] ? height[index] : height[index + 1]) +
(land[index - W] ? height[index] : height[index - W]) +
(land[index + W] ? height[index] : height[index + W]);
velocity[index] += SPEED * SPEED * (neighbors - 4 * height[index]);
velocity[index] *= 0.997;
}
}
for (let j = 1; j <= N + 1; j++) {
for (let i = 1; i <= N + 1; i++) {
const index = j * W + i;
if (land[index]) continue;
height[index] = clamp(height[index] + velocity[index], -0.7, 0.7);
total += height[index];
// Fast-moving crests whip themselves into foam.
const churn = Math.max(0, Math.abs(velocity[index]) - 0.012) * 9;
foam[index] = Math.min(1, foam[index] * 0.994 + churn);
}
}
// Pokes and the crate add or remove water, so hold the mean level at zero.
const mean = total / wetCells;
for (let index = 0; index < height.length; index++) if (!land[index]) height[index] -= mean;
// The ghost ring mirrors the edge, so ripples bounce off the walls.
for (let k = 0; k < W; k++) {
height[k] = height[k + W];
height[(W - 1) * W + k] = height[(W - 2) * W + k];
height[k * W] = height[k * W + 1];
height[k * W + W - 1] = height[k * W + W - 2];
}
};
const applyForce = (applied: THREE.Vector3) => {
force.add(applied);
torque.add(contact.copy(arm).cross(applied));
};
const pointState = (local: THREE.Vector3) => {
arm.copy(local).applyQuaternion(rotation);
point.copy(position).add(arm);
pointVelocity.copy(angular).cross(arm).add(linear);
};
const stepCrate = () => {
force.set(0, -GRAVITY, 0);
torque.set(0, 0, 0);
let wet = 0;
const applied = new THREE.Vector3();
for (const corner of corners) {
pointState(corner);
const { x, y, z } = point;
const depth = baseWave(x, z) + rippleAt(x, z) * 0.5 - y;
applied.set(0, 0, 0);
if (depth > 0) {
const submerged = Math.min(depth, 0.85);
applied.copy(pointVelocity).multiplyScalar(-WATER_DRAG * submerged);
applied.y += LIFT * submerged;
wet++;
}
if (y < FLOOR) {
applied.y += CONTACT * (FLOOR - y) - CONTACT_DAMPING * Math.min(pointVelocity.y, 0);
applied.x -= pointVelocity.x * 4;
applied.z -= pointVelocity.z * 4;
}
const wall = SIZE / 2 - 0.04;
if (Math.abs(x) > wall) {
const side = Math.sign(x);
applied.x -=
side *
(CONTACT * (Math.abs(x) - wall) + CONTACT_DAMPING * Math.max(side * pointVelocity.x, 0));
}
if (Math.abs(z) > wall) {
const side = Math.sign(z);
applied.z -=
side *
(CONTACT * (Math.abs(z) - wall) + CONTACT_DAMPING * Math.max(side * pointVelocity.z, 0));
}
// The island narrows toward its peak, so a sliding crate rolls off the beach.
const offsetX = x - ISLAND.x;
const offsetZ = z - ISLAND.z;
const distance = Math.hypot(offsetX, offsetZ) || 1;
const reach = ISLAND.radius - y * (y > 0 ? 1.6 : 0.35);
if (y < 0.45 && distance < reach) {
const nx = offsetX / distance;
const nz = offsetZ / distance;
const push =
CONTACT * (reach - distance) -
CONTACT_DAMPING * Math.min(pointVelocity.x * nx + pointVelocity.z * nz, 0);
applied.x += nx * push;
applied.z += nz * push;
}
applyForce(applied);
}
if (grabbed) {
pointState(grabPoint);
applied
.copy(grabTarget)
.sub(point)
.multiplyScalar(GRAB)
.addScaledVector(pointVelocity, -GRAB_DAMPING)
.clampLength(0, 90);
applyForce(applied);
}
linear.addScaledVector(force, STEP);
angular.addScaledVector(torque, STEP / INERTIA);
angular.multiplyScalar(1 - (0.5 + wet * 0.35) * STEP);
position.addScaledVector(linear, STEP);
spin.set(angular.x, angular.y, angular.z, 0).multiply(rotation);
rotation.set(
rotation.x + spin.x * STEP * 0.5,
rotation.y + spin.y * STEP * 0.5,
rotation.z + spin.z * STEP * 0.5,
rotation.w + spin.w * STEP * 0.5,
);
rotation.normalize();
// Waves shove the crate downhill while it floats.
if (wet > 0) {
const slopeX = surface(position.x + DX, position.z) - surface(position.x - DX, position.z);
const slopeZ = surface(position.x, position.z + DX) - surface(position.x, position.z - DX);
const shove = (GRAVITY * 0.6 * (wet / 8) * STEP) / (2 * DX);
linear.x -= slopeX * shove;
linear.z -= slopeZ * shove;
// A faint current brings a drifting crate back to open water.
if (!grabbed) {
linear.x -= (position.x - 0.5) * 0.35 * STEP;
linear.z -= (position.z - 0.5) * 0.35 * STEP;
}
}
const level = surface(position.x, position.z);
if (Math.abs(position.y - level) < 0.85) {
// The crate pushes the water it moves through: down under it as it
// sinks, up in front and down behind as it slides.
// Slow drift stays out of this, or the crate and water feed each other.
const moving = Math.hypot(linear.x, linear.y, linear.z);
const push = Math.max(0, moving - 0.7) / (moving || 1);
if (push > 0) {
forCells(position.x, position.z, 0.72, (index, dx, dz, distance) => {
const lead = (dx * linear.x + dz * linear.z) * 1.6;
velocity[index] += (linear.y + lead) * push * STEP * 0.012;
if (distance > 0.5) foam[index] = Math.min(1, foam[index] + push * moving * 0.01);
});
}
}
cooldown -= STEP;
const isWet = wet > 0;
const speed = linear.length();
if (cooldown <= 0 && isWet !== wasWet && speed > (isWet ? 1.6 : 2.6)) {
const strength = clamp(speed / 8, 0.2, 1);
poke(position.x, position.z, strength * 0.012, 0.8);
onSplash(position.x, position.z, strength);
cooldown = 0.25;
}
wasWet = isWet;
};
const place = (x: number, y: number, z: number) => {
position.set(x, y, z);
linear.set(0, 0, 0);
angular.set(0, 0, 0);
rotation.setFromEuler(new THREE.Euler(0, 0.2, 0));
grabbed = false;
};
return {
STEP,
height,
foam,
position,
rotation,
state,
surface,
poke,
place,
step() {
state.time += STEP;
stepCrate();
stepWater();
},
drop(x: number, z: number) {
place(x, 3, z);
angular.set(Math.random() * 3 - 1.5, Math.random() * 2 - 1, Math.random() * 3 - 1.5);
},
grab(worldPoint: THREE.Vector3) {
grabPoint.copy(worldPoint).sub(position).applyQuaternion(rotation.clone().invert());
grabTarget.copy(worldPoint);
grabbed = true;
},
moveGrab(worldPoint: THREE.Vector3) {
grabTarget.set(
clamp(worldPoint.x, -2.4, 2.4),
clamp(worldPoint.y, FLOOR + 0.2, 3.4),
clamp(worldPoint.z, -2.4, 2.4),
);
},
release() {
grabbed = false;
},
};
}

Then connect it to your scene. Your crate mesh copies the position and rotation from the simulation each frame.

main.ts
import * as THREE from "three";
import vertexShader from "./water.vert.glsl?raw";
import fragmentShader from "./water.frag.glsl?raw";
import { createPhysics, ISLAND, SEGMENTS, SIZE } from "./physics";
const physics = createPhysics(() => {});
physics.drop(0.7, 0.8);
37 collapsed lines
const geometry = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS);
geometry.rotateX(-Math.PI / 2);
const ripple = new THREE.BufferAttribute(physics.height, 1);
const foam = new THREE.BufferAttribute(physics.foam, 1);
geometry.setAttribute("aRipple", ripple);
geometry.setAttribute("aFoam", foam);
const uniforms = {
uTime: { value: 0 },
uWaves: { value: 0.14 },
uFoam: { value: 0.8 },
uIsland: { value: new THREE.Vector3(ISLAND.x, ISLAND.z, ISLAND.radius) },
uWaterToCrate: { value: new THREE.Matrix4() },
};
const water = new THREE.Mesh(
geometry,
new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, transparent: true }),
);
scene.add(water);
let pending = 0;
function update(deltaSeconds: number) {
pending += Math.min(deltaSeconds, 0.05);
while (pending >= physics.STEP) {
physics.step();
pending -= physics.STEP;
}
crate.position.copy(physics.position);
crate.quaternion.copy(physics.rotation);
crate.updateMatrixWorld(true);
water.updateMatrixWorld(true);
uniforms.uTime.value = physics.state.time;
uniforms.uWaterToCrate.value.copy(crate.matrixWorld).invert().multiply(water.matrixWorld);
ripple.needsUpdate = true;
foam.needsUpdate = true;
}

Call physics.poke(x, z, 0.014) to make a ripple, for example where the player clicks.

The Three.js tab is exactly what runs in the preview. The Unity and Godot versions are hand ports of the same shader and physics, simplified a little: the ripple grid is coarser, and the side walls are left out.

More snippets