Water Rendering - WaterRenderer
WaterRenderer renders large bodies of water such as pools, lakes and oceans. It generates a grid in the xz plane of its object, displaces it with Gerstner waves or FFT wind waves, and shades it with WaterMaterial: refraction, absorption, sky reflection and sun highlights. Together with the render settings it also provides caustics on the bottom, an underwater view and interactive ripples.
Enabling Water Support
WaterRenderer can draw a wavy surface on its own, but water depth, caustics, the underwater view, ripples and FFT waves rely on extra render passes. Turn on setting.render.water when initializing the engine:
import { Engine3D } from '@orillusion/core';
const engine = await Engine3D.init({
setting: { render: { water: true } }
});This adds the following passes to the render pipeline:
| Pass | Purpose |
|---|---|
SceneLinearDepthPass | Records the linear depth of the opaque scene, so the water knows the real distance to the bottom at every pixel for absorption and refraction |
CausticsPass | Projects the caustics formed by sunlight focused through the waves onto objects under water |
UnderwaterPass | When the camera is under water, absorbs and scatters the image along the view ray; a camera half in the water shows a waterline |
WaterSimulationPass | Advances the FFT waves and the interactive ripples on the GPU |
TIP
Without setting.render.water the water has no scene depth, so it uses waterMaterial.waterDepth (default 2) as a fixed depth for its color; caustics, the underwater view, addRipple() and spectrum have no effect.
Basic Usage
Add a WaterRenderer component to an Object3D to get a 10 × 10 water surface. The water level is the object's world y coordinate:
import { Object3D, WaterRenderer, Vector2 } from '@orillusion/core';
const waterObj = new Object3D();
const water = waterObj.addComponent(WaterRenderer);
// Size of the rectangle along local x and z; the object's scale applies
water.width = 20;
water.length = 14;
// Grid segments along the longer side; the waves need a few vertices per shortest wavelength
water.resolution = 200;
// Distance from the surface to the bottom, only used without setting.render.water
water.waterMaterial.waterDepth = 2;
scene.addChild(waterObj);The object can be moved, scaled and rotated about the y axis. The surface always stays horizontal and the waves are evaluated in world space, so several water objects line up seamlessly.
TIP
The water is drawn from inside the water body and refracts the color of the opaque scene, so it does not take part in the depth prepass, does not cast shadows and does not show up in reflection probes. The component sets this up automatically.
Example: Pool
Nearly still water over a tiled floor with caustics; a rubber duck floats on the surface using getHeight(). Click the water to splash, click the duck to push it under, and hold Shift while moving the mouse to drag a trail of ripples.
import { AtmosphericComponent, BitmapTexture2D, BoundUtil, BoxGeometry, Camera3D, CameraUtil, Color, ComponentBase, Context3D, DirectLight, Engine3D, GeometryBase, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, Object3D, PlaneGeometry, PointerEvent3D, Scene3D, Time, Vector2, Vector3, View3D, WaterRenderer } from '@orillusion/core';
import dat from 'dat.gui';
const POOL_WIDTH = 20;
const POOL_LENGTH = 14;
const POOL_DEPTH = 2;
/**
* Swimming pool: nearly still water over a tiled floor, with refraction,
* absorption, sky reflection and caustics. A rubber duck floats on the
* surface using WaterRenderer.getHeight.
*
* Click the water for a splash or the duck to push it under,
* hold Shift and move the mouse to drag a trail of ripples.
*/
class Sample_Pool {
async run() {
// setting.render.water enables real water depth, caustics, ripples and the underwater view
const engine = await Engine3D.init({ setting: { render: { water: true } } });
const scene = new Scene3D();
scene.addComponent(AtmosphericComponent);
const camera = CameraUtil.createCamera3DObject(scene);
camera.perspective(60, engine.aspect, 0.1, 1000);
camera.object3D.addComponent(HoverCameraController).setCamera(144.6, -26.6, 10.9, new Vector3(0, -1, 0));
const view = new View3D();
view.scene = scene;
view.camera = camera;
engine.startRenderView(view);
const light = new Object3D();
light.rotationX = 50;
light.rotationY = 30;
const dl = light.addComponent(DirectLight);
dl.lightColor = KelvinUtil.color_temperature_to_rgb(5500);
dl.intensity = 3;
dl.castShadow = true;
scene.addChild(light);
createPool(scene, engine.context3D);
const waterObj = new Object3D();
const water = waterObj.addComponent(WaterRenderer);
// Slightly larger than the pool: the waves also move the surface sideways, the walls hide the overlap
water.width = POOL_WIDTH + 0.6;
water.length = POOL_LENGTH + 0.6;
// Still water: short, low waves, about 1.5 cm high at most
water.waves = [
{ direction: new Vector2(1, 0.3), wavelength: 4, steepness: 0.025 },
{ direction: new Vector2(0.4, 1), wavelength: 2.6, steepness: 0.02 },
{ direction: new Vector2(-0.7, 0.6), wavelength: 1.7, steepness: 0.015 },
{ direction: new Vector2(0.2, -1), wavelength: 1.1, steepness: 0.012 }
];
water.resolution = 200;
water.waterMaterial.waterDepth = POOL_DEPTH;
// Faint ripples on the surface; the view of the floor stays clear
water.detailStrength = 0.2;
// From underwater, most of the surface shows the world above
water.waterMaterial.underwaterClarity = 0.8;
scene.addChild(waterObj);
const duckModel = await engine.res.loadGltf('https://cdn.orillusion.com/PBR/Duck/Duck.gltf');
const duck = floatingModel(scene, water, duckModel, 1.2, -3, 2);
addPointerRipples(engine, camera, water, [duck]);
addWaterGUI(water);
}
}
/**
* Floats its object on the water with WaterRenderer.getHeight, and starts ripples
* where it disturbs the surface: a splash when pushed, another as it bobs back up.
*/
class Floater extends ComponentBase {
private water!: WaterRenderer;
private x = 0;
private z = 0;
/** Height of the object's origin above the surface. */
private lift = 0;
private size = 1;
/** Bobbing about the resting height after a push: offset and its speed. */
private bob = 0;
private bobV = 0;
public init(param?: { water: WaterRenderer; x: number; z: number; lift: number; size: number }): void {
if (!param) return;
this.water = param.water;
this.x = param.x;
this.z = param.z;
this.lift = param.lift;
this.size = param.size;
}
public get radius(): number {
return this.size;
}
/** Pushes the object under; the water lifts it back, bobbing. */
public push(): void {
this.bobV = -4;
this.water.addRipple(this.x, this.z, 0.5, this.size * 1.2);
}
public onUpdate(): void {
if (!this.water) return;
const dt = Math.min(Time.delta * 0.001, 1 / 30);
// Buoyancy as a damped spring toward the resting height
if (this.bob !== 0 || this.bobV !== 0) {
const before = this.bob;
this.bobV += (-40 * this.bob - 4 * this.bobV) * dt;
this.bob += this.bobV * dt;
// Breaking back through the surface on the way up throws a smaller ring
if (before < -this.size * 0.3 && this.bob >= -this.size * 0.3 && this.bobV > 1) {
this.water.addRipple(this.x, this.z, 0.2, this.size);
}
if (Math.abs(this.bob) < 1e-3 && Math.abs(this.bobV) < 1e-2) this.bob = this.bobV = 0;
}
this.transform.x = this.x;
this.transform.y = this.water.getHeight(this.x, this.z) + this.lift + this.bob;
this.transform.z = this.z;
}
}
/** A model scaled to `length` across, centered on a holder, with `draft` of its height under water. */
function floatingModel(scene: Scene3D, water: WaterRenderer, model: Object3D, length: number, x: number, z: number, draft = 0.18): Floater {
const holder = new Object3D();
holder.addChild(model);
scene.addChild(holder);
const raw = BoundUtil.genMeshBounds(model);
const s = length / Math.max(raw.max.x - raw.min.x, raw.max.z - raw.min.z);
model.scaleX *= s;
model.scaleY *= s;
model.scaleZ *= s;
const b = BoundUtil.genMeshBounds(model);
model.x = -(b.min.x + b.max.x) / 2;
model.y = -(b.min.y + b.max.y) / 2;
model.z = -(b.min.z + b.max.z) / 2;
const height = b.max.y - b.min.y;
const radius = Math.max(b.max.x - b.min.x, height, b.max.z - b.min.z) / 2;
return holder.addComponent(Floater, { water, x, z, lift: height * (0.5 - draft), size: radius });
}
/**
* Pointer interaction: a click on a floater pushes it under; elsewhere on the water,
* where the view ray meets the undisturbed surface, it splashes. Shift + move drags a trail.
*/
function addPointerRipples(engine: Engine3D, camera: Camera3D, water: WaterRenderer, floaters: Floater[], splash = 0.4, radius = 0.4): void {
let lastTrail: Vector3 | null = null;
const hitWater = (e: PointerEvent3D): Vector3 | null => {
const ray = camera.screenPointToRay(e.mouseX, e.mouseY);
if (ray.direction.y >= 0) return null;
const t = (water.baseHeight - ray.origin.y) / ray.direction.y;
return new Vector3(ray.origin.x + ray.direction.x * t, water.baseHeight, ray.origin.z + ray.direction.z * t);
};
const hitFloater = (e: PointerEvent3D): Floater | null => {
const ray = camera.screenPointToRay(e.mouseX, e.mouseY);
let best: Floater | null = null;
let bestT = Infinity;
for (const f of floaters) {
const c = f.transform.worldPosition;
const ox = ray.origin.x - c.x,
oy = ray.origin.y - c.y,
oz = ray.origin.z - c.z;
const b = ox * ray.direction.x + oy * ray.direction.y + oz * ray.direction.z;
const disc = b * b - (ox * ox + oy * oy + oz * oz - f.radius * f.radius);
const t = -b - Math.sqrt(Math.max(disc, 0));
if (disc >= 0 && t > 0 && t < bestT) {
best = f;
bestT = t;
}
}
return best;
};
engine.inputSystem.addEventListener(
PointerEvent3D.POINTER_DOWN,
(e: PointerEvent3D) => {
const floater = hitFloater(e);
if (floater) return floater.push();
const p = hitWater(e);
if (p) water.addRipple(p.x, p.z, splash, radius);
},
null
);
engine.inputSystem.addEventListener(
PointerEvent3D.POINTER_MOVE,
(e: PointerEvent3D) => {
if (!e.shiftKey) {
lastTrail = null;
return;
}
const p = hitWater(e);
if (p && (!lastTrail || Vector3.distance(p, lastTrail) > 0.15)) {
water.addRipple(p.x, p.z, splash * 0.3, radius * 0.6);
lastTrail = p;
}
},
null
);
}
function addWaterGUI(water: WaterRenderer): void {
const m = water.waterMaterial;
const steepness = water.waves.map((w) => w.steepness);
const toRGB = (c: Color) => [c.r * 255, c.g * 255, c.b * 255];
const fromRGB = (v: number[]) => new Color(v[0] / 255, v[1] / 255, v[2] / 255);
const s = {
paused: false,
speed: water.timeScale,
waveHeight: 1,
absorptionColor: toRGB(m.absorptionColor),
underwaterColor: toRGB(m.underwaterColor)
};
const applyTime = () => (water.timeScale = s.paused ? 0 : s.speed);
const gui = new dat.GUI();
const f = gui.addFolder('Water');
f.add(s, 'paused').onChange(applyTime);
f.add(s, 'speed', 0, 3, 0.01).onChange(applyTime);
f.addColor(s, 'absorptionColor').onChange((v: number[]) => (m.absorptionColor = fromRGB(v)));
f.addColor(s, 'underwaterColor').onChange((v: number[]) => (m.underwaterColor = fromRGB(v)));
f.add(m, 'density', 0, 2, 0.01);
f.add(s, 'waveHeight', 0, 3, 0.01).onChange((v: number) => water.waves.forEach((w, i) => (w.steepness = steepness[i] * v)));
f.add(water, 'detailStrength', 0, 1, 0.01);
f.add(water, 'caustics');
f.add(water, 'causticsIntensity', 0, 2, 0.01);
f.add(m, 'underwaterClarity', 0, 1, 0.01);
f.open();
// Start collapsed so the panel does not cover the scene
gui.close();
}
/** Tiled pool around (0, -POOL_DEPTH .. 0, 0) with a white coping and a plain ground. */
function createPool(scene: Scene3D, ctx: Context3D) {
const t = 0.6;
const wallTop = 0.4;
// Ground around the pool opening (four strips, leaving the pool open)
const groundMat = solid(new Color(0.55, 0.53, 0.5), 0.9);
const G = 80,
hw = POOL_WIDTH / 2 + 1.5 * t,
hl = POOL_LENGTH / 2 + 1.5 * t;
const side = G / 2 - hl;
scene.addChild(mesh(new PlaneGeometry(G, side), groundMat, 0, wallTop, -(hl + side / 2)));
scene.addChild(mesh(new PlaneGeometry(G, side), groundMat, 0, wallTop, hl + side / 2));
scene.addChild(mesh(new PlaneGeometry(G / 2 - hw, 2 * hl), groundMat, -(hw + (G / 2 - hw) / 2), wallTop, 0));
scene.addChild(mesh(new PlaneGeometry(G / 2 - hw, 2 * hl), groundMat, hw + (G / 2 - hw) / 2, wallTop, 0));
const tiles = new LitMaterial();
tiles.baseMap = paintTiles(ctx);
tiles.roughness = 0.7;
tiles.metallic = 0;
scene.addChild(mesh(new BoxGeometry(POOL_WIDTH, t, POOL_LENGTH), tiles, 0, -POOL_DEPTH - t / 2, 0));
const wall = solid(new Color(0.55, 0.82, 0.88), 0.4);
const h = POOL_DEPTH + wallTop;
const cy = -POOL_DEPTH + h / 2;
scene.addChild(mesh(new BoxGeometry(POOL_WIDTH + 2 * t, h, t), wall, 0, cy, -POOL_LENGTH / 2 - t / 2));
scene.addChild(mesh(new BoxGeometry(POOL_WIDTH + 2 * t, h, t), wall, 0, cy, POOL_LENGTH / 2 + t / 2));
scene.addChild(mesh(new BoxGeometry(t, h, POOL_LENGTH), wall, -POOL_WIDTH / 2 - t / 2, cy, 0));
scene.addChild(mesh(new BoxGeometry(t, h, POOL_LENGTH), wall, POOL_WIDTH / 2 + t / 2, cy, 0));
const coping = solid(new Color(0.95, 0.95, 0.92), 0.6);
for (const [w, l, x, z] of [
[POOL_WIDTH + 4 * t, t, 0, -POOL_LENGTH / 2 - t],
[POOL_WIDTH + 4 * t, t, 0, POOL_LENGTH / 2 + t],
[t, POOL_LENGTH, -POOL_WIDTH / 2 - t, 0],
[t, POOL_LENGTH, POOL_WIDTH / 2 + t, 0]
]) {
scene.addChild(mesh(new BoxGeometry(w, 0.15, l), coping, x, wallTop + 0.075, z));
}
}
/** Light aqua mosaic with grout and dark lane lines along x. */
function paintTiles(ctx: Context3D): BitmapTexture2D {
const tile = 0.5;
const tx = Math.round(POOL_WIDTH / tile),
tz = Math.round(POOL_LENGTH / tile);
const px = Math.floor(2048 / Math.max(tx, tz));
const canvas = document.createElement('canvas');
canvas.width = tx * px;
canvas.height = tz * px;
const g = canvas.getContext('2d')!;
g.fillStyle = 'rgb(225, 242, 246)';
g.fillRect(0, 0, canvas.width, canvas.height);
const grout = Math.max(1, Math.round(px * 0.08));
for (let i = 0; i < tx; i++) {
for (let j = 0; j < tz; j++) {
const k = 0.94 + Math.random() * 0.1;
g.fillStyle = `rgb(${Math.round(110 * k)}, ${Math.round(200 * k)}, ${Math.round(218 * k)})`;
g.fillRect(i * px + grout, j * px + grout, px - 2 * grout, px - 2 * grout);
}
}
g.fillStyle = 'rgb(18, 42, 105)';
for (let l = 1; l <= 3; l++) {
const zc = Math.round((l * tz) / 4) * px;
g.fillRect(2 * px, zc - px / 2, canvas.width - 4 * px, px);
}
const tex = new BitmapTexture2D(true, ctx, 'srgb');
tex.source = canvas;
return tex;
}
function solid(color: Color, roughness: number): LitMaterial {
const m = new LitMaterial();
m.baseColor = color;
m.roughness = roughness;
m.metallic = 0;
return m;
}
function mesh(geometry: GeometryBase, material: LitMaterial, x: number, y: number, z: number): Object3D {
const obj = new Object3D();
obj.x = x;
obj.y = y;
obj.z = z;
const mr = obj.addComponent(MeshRenderer);
mr.geometry = geometry;
mr.material = material;
mr.receiveShadow = true;
return obj;
}
new Sample_Pool().run();Waves
The surface motion is the sum of the Gerstner waves in waves and the optional FFT wind waves in spectrum.
Gerstner Waves
waves is an array of GerstnerWave; up to 8 of them are used (WATER_MAX_WAVES). Edit the elements in place or assign a new array:
water.waves = [
{ direction: new Vector2(1, 0.3), wavelength: 4, steepness: 0.025 },
{ direction: new Vector2(0.4, 1), wavelength: 2.6, steepness: 0.02 },
{ direction: new Vector2(-0.7, 0.6), wavelength: 1.7, steepness: 0.015 }
];
// Animation speed; 0 freezes the surface
water.timeScale = 1;| Field | Description |
|---|---|
direction | Direction of travel in the xz plane (Vector2: x is world x, y is world z) |
wavelength | Crest-to-crest distance in world units; the wave speed follows from the deep-water dispersion relation |
steepness | 0 ~ 1. The wave height is about steepness × wavelength / 2π. Keep the sum over all waves below 1, otherwise the crests loop |
FFT Wind Waves
For open water, set spectrum: it simulates a complete sea on the GPU with FFTs from the wind speed and fetch, from swells hundreds of metres long down to centimetre ripples, with whitecap foam where the crests break. It adds to waves; an ocean usually clears waves:
import { WaterSpectrum, Vector2 } from '@orillusion/core';
const spectrum = new WaterSpectrum();
spectrum.windSpeed = 12; // wind speed 10 m above the water (m/s)
spectrum.windDirection = new Vector2(1, 0.3);
spectrum.fetch = 100000; // fetch: distance the wind has blown over open water (m)
water.spectrum = spectrum;
water.waves = [];| Property | Default | Description |
|---|---|---|
windSpeed | 10 | Wind speed (m/s); stronger wind gives higher, longer waves |
windDirection | (1, 0.3) | Direction the wind blows toward |
fetch | 100000 | Fetch in metres. A short fetch gives young, short waves; a long one a fully developed sea |
amplitude | 1 | Wave height scale |
choppiness | 1 | How far the water moves sideways toward the crests: 0 gives rounded waves, 1 sharp crests and troughs that whitecaps break from |
seed | 1 | Random seed of the wave phases |
water.spectrum = null removes the wind waves and releases their GPU resources.
Fading Distant Waves
The grid resolution is limited; where vertices are far apart, short waves alias. waveFadeDistance is the distance from the camera, in wavelengths, at which each wave has faded out (fading starts at half of it). The default 80 suits an even grid; lower it for a grid that gets coarser with distance, and 0 keeps every wave. It only affects the image: getHeight() always includes every wave.
Example: Ocean
An open sea made of FFT wind waves. The surface is a disc of rings around the buoy that gets sparser with distance, combined with waveFadeDistance = 14 to avoid aliasing in the distance; ripples are only simulated within 60 metres of the buoy (see rippleArea below). The Sea panel adjusts the wind speed, fetch, wave height and crest sharpness.
import { AtmosphericComponent, Camera3D, CameraUtil, Color, ComponentBase, DirectLight, Engine3D, GeometryBase, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, Object3D, PointerEvent3D, Scene3D, SphereGeometry, Time, Vector2, Vector3, VertexAttributeName, View3D, WaterRenderer, WaterSpectrum } from '@orillusion/core';
import dat from 'dat.gui';
/** Radius of the ocean disc around the buoy. */
const OCEAN_RADIUS = 3000;
/**
* Open ocean: long swells over deep water with no bottom in sight. The waves come from
* a wind spectrum simulated with FFTs (WaterRenderer.spectrum); whitecaps leave foam.
*
* The surface is a disc of rings around the buoy, centimetres apart near it and growing
* with distance; each wave fades out where the rings get too coarse for it
* (WaterRenderer.waveFadeDistance), so the distance does not alias.
*
* Ripples are simulated in a 60 m square around the buoy (WaterRenderer.rippleArea):
* click the water to splash, or the buoy to push it under.
*/
class Sample_Ocean {
async run() {
const engine = await Engine3D.init({ setting: { render: { water: true } } });
const scene = new Scene3D();
scene.addComponent(AtmosphericComponent);
const camera = CameraUtil.createCamera3DObject(scene);
camera.perspective(60, engine.aspect, 0.1, 5000);
// Low over the water, looking across toward the sun
camera.object3D.addComponent(HoverCameraController).setCamera(150, -8, 28, new Vector3(0, 0, 0));
const view = new View3D();
view.scene = scene;
view.camera = camera;
engine.startRenderView(view);
const light = new Object3D();
light.rotationX = 30;
light.rotationY = 150;
const dl = light.addComponent(DirectLight);
dl.lightColor = KelvinUtil.color_temperature_to_rgb(5500);
dl.intensity = 3;
scene.addChild(light);
const waterObj = new Object3D();
const water = waterObj.addComponent(WaterRenderer);
// 400 rings, 5 cm apart at the center; farther out the spacing is about 1.75% of the distance,
// so a wave gets four vertices per wavelength out to 14 wavelengths
const growth = 0.0175 * 400;
water.surfaceGeometry = radialGeometry(
() => OCEAN_RADIUS,
400,
512,
(t) => (Math.exp(growth * t) - 1) / (Math.exp(growth) - 1)
);
// Fade each wave out before the rings get too coarse for it
water.waveFadeDistance = 14;
// A rough sea from a 12 m/s wind over 100 km of open water, simulated with FFTs
const spectrum = new WaterSpectrum();
spectrum.windSpeed = 12;
spectrum.windDirection = new Vector2(1, 0.3);
spectrum.fetch = 100000;
water.spectrum = spectrum;
// No Gerstner waves: the spectrum makes all of them
water.waves = [];
// Clear, deep blue water; nothing below to catch caustics
const m = water.waterMaterial;
m.absorptionColor = new Color(0.1, 0.4, 0.6);
m.underwaterColor = new Color(0.0, 0.07, 0.14);
m.density = 0.08;
// Wind-roughened: the sky reflection is soft
m.reflectionRoughness = 0.08;
water.caustics = false;
// Wind chop a few metres long, gentle at this scale
water.detailScale = 2.5;
water.detailStrength = 0.4;
// A grid over the whole ocean would be metres per cell; ripples need a smaller area
water.rippleArea = 60;
scene.addChild(waterObj);
const buoy = floatingBall(scene, water, 1.2, 0, 0, new Color(0.9, 0.2, 0.15));
addPointerRipples(engine, camera, water, [buoy]);
const toRGB = (c: Color) => [c.r * 255, c.g * 255, c.b * 255];
const fromRGB = (v: number[]) => new Color(v[0] / 255, v[1] / 255, v[2] / 255);
const s = {
paused: false,
speed: water.timeScale,
absorptionColor: toRGB(m.absorptionColor),
underwaterColor: toRGB(m.underwaterColor)
};
const applyTime = () => (water.timeScale = s.paused ? 0 : s.speed);
const gui = new dat.GUI();
const sea = gui.addFolder('Sea');
sea.add(spectrum, 'windSpeed', 1, 25, 0.1);
sea.add(spectrum, 'fetch', 1000, 500000, 1000);
sea.add(spectrum, 'amplitude', 0, 3, 0.01);
sea.add(spectrum, 'choppiness', 0, 2, 0.01);
sea.open();
const f = gui.addFolder('Water');
f.add(s, 'paused').onChange(applyTime);
f.add(s, 'speed', 0, 3, 0.01).onChange(applyTime);
f.addColor(s, 'absorptionColor').onChange((v: number[]) => (m.absorptionColor = fromRGB(v)));
f.addColor(s, 'underwaterColor').onChange((v: number[]) => (m.underwaterColor = fromRGB(v)));
f.add(m, 'density', 0, 2, 0.01);
f.add(m, 'foamIntensity', 0, 1, 0.01);
f.add(m, 'reflectionRoughness', 0, 0.5, 0.01);
f.add(m, 'subsurfaceIntensity', 0, 3, 0.01);
f.open();
// Start collapsed so the panel does not cover the scene
gui.close();
}
}
/** Floats its object on the water with WaterRenderer.getHeight; a push sends it under and it bobs back up. */
class Floater extends ComponentBase {
private water!: WaterRenderer;
private x = 0;
private z = 0;
private lift = 0;
private size = 1;
private bob = 0;
private bobV = 0;
public init(param?: { water: WaterRenderer; x: number; z: number; lift: number; size: number }): void {
if (!param) return;
this.water = param.water;
this.x = param.x;
this.z = param.z;
this.lift = param.lift;
this.size = param.size;
}
public get radius(): number {
return this.size;
}
public push(): void {
this.bobV = -4;
this.water.addRipple(this.x, this.z, 0.5, this.size * 1.2);
}
public onUpdate(): void {
if (!this.water) return;
const dt = Math.min(Time.delta * 0.001, 1 / 30);
if (this.bob !== 0 || this.bobV !== 0) {
const before = this.bob;
this.bobV += (-40 * this.bob - 4 * this.bobV) * dt;
this.bob += this.bobV * dt;
if (before < -this.size * 0.3 && this.bob >= -this.size * 0.3 && this.bobV > 1) {
this.water.addRipple(this.x, this.z, 0.2, this.size);
}
if (Math.abs(this.bob) < 1e-3 && Math.abs(this.bobV) < 1e-2) this.bob = this.bobV = 0;
}
// With a spectrum, getHeight also queues the point for the GPU; the FFT part arrives a frame or two later
this.transform.x = this.x;
this.transform.y = this.water.getHeight(this.x, this.z) + this.lift + this.bob;
this.transform.z = this.z;
}
}
function floatingBall(scene: Scene3D, water: WaterRenderer, radius: number, x: number, z: number, color: Color): Floater {
const ball = new Object3D();
const mr = ball.addComponent(MeshRenderer);
mr.geometry = new SphereGeometry(radius, 32, 32);
const mat = new LitMaterial();
mat.baseColor = color;
mat.roughness = 0.4;
mat.metallic = 0;
mr.material = mat;
const floater = ball.addComponent(Floater, { water, x, z, lift: radius * 0.6, size: radius });
scene.addChild(ball);
return floater;
}
function addPointerRipples(engine: Engine3D, camera: Camera3D, water: WaterRenderer, floaters: Floater[], splash = 0.4, radius = 0.4): void {
engine.inputSystem.addEventListener(
PointerEvent3D.POINTER_DOWN,
(e: PointerEvent3D) => {
const ray = camera.screenPointToRay(e.mouseX, e.mouseY);
for (const f of floaters) {
const c = f.transform.worldPosition;
const ox = ray.origin.x - c.x,
oy = ray.origin.y - c.y,
oz = ray.origin.z - c.z;
const b = ox * ray.direction.x + oy * ray.direction.y + oz * ray.direction.z;
if (b * b - (ox * ox + oy * oy + oz * oz - f.radius * f.radius) >= 0 && b < 0) return f.push();
}
if (ray.direction.y >= 0) return;
const t = (water.baseHeight - ray.origin.y) / ray.direction.y;
// Points outside rippleArea are ignored
water.addRipple(ray.origin.x + ray.direction.x * t, ray.origin.z + ray.direction.z * t, splash, radius);
},
null
);
}
/**
* Flat mesh in the xz plane bounded by a closed curve around the origin:
* rings from the center out to `outline(angle)`, radii spaced by `ringRadius(t)` for t from 0 to 1.
*/
function radialGeometry(outline: (angle: number) => number, rings: number, segments: number, ringRadius: (t: number) => number = (t) => t): GeometryBase {
const count = 1 + rings * segments;
const pos = new Float32Array(count * 3);
const nor = new Float32Array(count * 3);
const uv = new Float32Array(count * 2);
nor[1] = 1;
uv[0] = uv[1] = 0.5;
for (let s = 0; s < segments; s++) {
const a = (s / segments) * Math.PI * 2;
const edge = outline(a);
for (let r = 1; r <= rings; r++) {
const i = 1 + (r - 1) * segments + s;
const rr = edge * ringRadius(r / rings);
pos[i * 3] = Math.cos(a) * rr;
pos[i * 3 + 2] = Math.sin(a) * rr;
nor[i * 3 + 1] = 1;
uv[i * 2] = 0.5 + (0.5 * Math.cos(a) * r) / rings;
uv[i * 2 + 1] = 0.5 + (0.5 * Math.sin(a) * r) / rings;
}
}
const idx: number[] = [];
for (let s = 0; s < segments; s++) idx.push(0, 1 + ((s + 1) % segments), 1 + s);
for (let r = 1; r < rings; r++) {
for (let s = 0; s < segments; s++) {
const a = 1 + (r - 1) * segments + s;
const b = 1 + (r - 1) * segments + ((s + 1) % segments);
idx.push(a, b, a + segments, b, b + segments, a + segments);
}
}
const g = new GeometryBase();
g.setIndices(count > 65535 ? new Uint32Array(idx) : new Uint16Array(idx));
g.setAttribute(VertexAttributeName.position, pos);
g.setAttribute(VertexAttributeName.normal, nor);
g.setAttribute(VertexAttributeName.uv, uv);
g.setAttribute(VertexAttributeName.TEXCOORD_1, uv);
g.addSubGeometry({ indexStart: 0, indexCount: idx.length, vertexStart: 0, vertexCount: 0, firstStart: 0, index: 0, topology: 0 });
return g;
}
new Sample_Ocean().run();Custom Water Shapes
The default rectangle is defined by width, length and resolution. For lakes, ponds and other irregular water, set surfaceGeometry to any flat mesh; the water surface, the underwater view, the caustics and the ripples all follow its outline:
// An indexed mesh in the object's local xz plane; vertex y is ignored
water.surfaceGeometry = lakeGeometry;
// null restores the rectangle
water.surfaceGeometry = null;Requirements for the mesh:
- flat in the object's local xz plane (vertex
yis ignored); - indexed geometry;
- fine enough for the shortest waves.
Usually the mesh reaches a little past the shoreline, under the terrain, so the sideways motion of the waves never opens a gap at the shore.
Example: Lake
A lake with an irregular shoreline; the water mesh extends one metre past the shoreline, buried under the rising terrain. The Buoy panel makes the buoy drift in a circle, or lifts it and drops it into the water.
import { AtmosphericComponent, BitmapTexture2D, Camera3D, CameraUtil, Color, ComponentBase, Context3D, DirectLight, Engine3D, GeometryBase, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, Object3D, PointerEvent3D, Scene3D, SphereGeometry, Time, Vector2, Vector3, VertexAttributeName, View3D, WaterRenderer } from '@orillusion/core';
import dat from 'dat.gui';
const LAKE_RADIUS = 22;
const LAKE_DEPTH = 4;
const TERRAIN_SIZE = 160;
/** Distance from the center to the shoreline in the direction `angle`. */
function shore(angle: number): number {
return LAKE_RADIUS * (1 + 0.22 * Math.sin(2 * angle + 0.6) + 0.12 * Math.sin(3 * angle + 1.9) + 0.07 * Math.sin(5 * angle + 0.3));
}
/** Ground height: a bowl down to LAKE_DEPTH inside the shoreline, rising into rolling grass outside. */
function groundHeight(x: number, z: number): number {
const r = Math.hypot(x, z);
const edge = shore(Math.atan2(z, x));
const s = r / edge;
if (s < 1) {
const bed = 1 - s * s;
return -LAKE_DEPTH * bed + 0.3 * Math.sin(x * 0.35) * Math.cos(z * 0.29) * bed;
}
const out = r - edge;
const hills = 3 * (Math.sin(x * 0.045 + 1) * Math.cos(z * 0.06) + 1) * smoothstep(6, 30, out);
return Math.min(out * 0.2, 1.5) + hills;
}
function smoothstep(a: number, b: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
/**
* Lake: water with an irregular outline (WaterRenderer.surfaceGeometry) in a bowl of terrain.
* The surface mesh follows the shoreline and reaches a metre past it, under the ground;
* the underwater view, caustics and ripples all follow the same outline.
*
* Click the water for a splash or the buoy to push it under, hold Shift and move the mouse
* to drag a trail; the buoy can drift or be dropped.
*/
class Sample_Lake {
async run() {
const engine = await Engine3D.init({ setting: { render: { water: true } } });
const scene = new Scene3D();
scene.addComponent(AtmosphericComponent);
const camera = CameraUtil.createCamera3DObject(scene);
camera.perspective(60, engine.aspect, 0.1, 2000);
camera.object3D.addComponent(HoverCameraController).setCamera(37.7, -30.3, 19.2, new Vector3(7.48, -0.5, 4.13));
const view = new View3D();
view.scene = scene;
view.camera = camera;
engine.startRenderView(view);
const light = new Object3D();
light.rotationX = 45;
light.rotationY = 40;
const dl = light.addComponent(DirectLight);
dl.lightColor = KelvinUtil.color_temperature_to_rgb(5800);
dl.intensity = 3;
dl.castShadow = true;
scene.addChild(light);
const ground = new LitMaterial();
ground.baseMap = paintGround(engine.context3D);
ground.roughness = 0.9;
ground.metallic = 0;
const terrain = new Object3D();
const terrainRenderer = terrain.addComponent(MeshRenderer);
terrainRenderer.geometry = heightfieldGeometry(TERRAIN_SIZE, 256, groundHeight);
terrainRenderer.material = ground;
terrainRenderer.receiveShadow = true;
scene.addChild(terrain);
const waterObj = new Object3D();
const water = waterObj.addComponent(WaterRenderer);
// Custom outline: the shoreline plus a metre, hidden under the rising ground
water.surfaceGeometry = radialGeometry((a) => shore(a) + 1, 56, 320);
water.waves = [
{ direction: new Vector2(1, 0.4), wavelength: 7, steepness: 0.06 },
{ direction: new Vector2(0.6, 1), wavelength: 4.3, steepness: 0.05 },
{ direction: new Vector2(-0.5, 0.8), wavelength: 2.6, steepness: 0.04 },
{ direction: new Vector2(0.9, -0.6), wavelength: 1.8, steepness: 0.03 }
];
// Green, murkier than a pool
const m = water.waterMaterial;
m.absorptionColor = new Color(0.25, 0.55, 0.42);
m.underwaterColor = new Color(0.05, 0.13, 0.1);
m.density = 0.45;
water.detailScale = 2.2;
water.detailStrength = 0.5;
water.rippleResolution = 384;
scene.addChild(waterObj);
const buoy = floatingBall(scene, water, 0.4, 4, 3, new Color(0.95, 0.75, 0.15));
addPointerRipples(engine, camera, water, [buoy]);
const gui = addWaterGUI(water);
const f = gui.addFolder('Buoy');
f.add(buoy, 'drift');
f.add({ drop: () => buoy.drop(4) }, 'drop');
f.open();
// Start collapsed so the panel does not cover the scene
gui.close();
}
}
/**
* Floats its object on the water with WaterRenderer.getHeight. It can drift in a circle,
* be dropped in or pushed under, and starts ripples where it disturbs the surface.
*/
class Floater extends ComponentBase {
/** Drift in a circle starting from the initial point. */
public drift = false;
private water!: WaterRenderer;
private x = 0;
private z = 0;
private cx = 0;
private cz = 0;
private lift = 0;
private size = 1;
private angle = 0;
/** Height while falling; NaN when floating. */
private fallY = NaN;
private vy = 0;
private wakeX = 0;
private wakeZ = 0;
private bob = 0;
private bobV = 0;
public init(param?: { water: WaterRenderer; x: number; z: number; lift: number; size: number }): void {
if (!param) return;
this.water = param.water;
this.x = this.cx = this.wakeX = param.x;
this.z = this.cz = this.wakeZ = param.z;
this.lift = param.lift;
this.size = param.size;
}
public get radius(): number {
return this.size;
}
public push(): void {
this.bobV = -4;
this.water.addRipple(this.x, this.z, 0.5, this.size * 1.2);
}
/** Lifts the object this far above the water and lets it fall. */
public drop(height: number): void {
this.fallY = this.water.getHeight(this.x, this.z) + height;
this.vy = 0;
}
public onUpdate(): void {
if (!this.water) return;
const dt = Math.min(Time.delta * 0.001, 1 / 30);
if (this.drift) {
// Two metres around (cx - 2, cz) at 0.8 m/s
this.angle += (dt * 0.8) / 2;
this.x = this.cx + 2 * Math.cos(this.angle) - 2;
this.z = this.cz + 2 * Math.sin(this.angle);
}
if (this.bob !== 0 || this.bobV !== 0) {
const before = this.bob;
this.bobV += (-40 * this.bob - 4 * this.bobV) * dt;
this.bob += this.bobV * dt;
if (before < -this.size * 0.3 && this.bob >= -this.size * 0.3 && this.bobV > 1) {
this.water.addRipple(this.x, this.z, 0.2, this.size);
}
if (Math.abs(this.bob) < 1e-3 && Math.abs(this.bobV) < 1e-2) this.bob = this.bobV = 0;
}
const rest = this.water.getHeight(this.x, this.z) + this.lift + this.bob;
let y = rest;
if (!Number.isNaN(this.fallY)) {
this.vy -= 9.81 * dt;
this.fallY += this.vy * dt;
if (this.fallY > rest) {
y = this.fallY;
} else {
// Splash: deeper the faster it hits
this.water.addRipple(this.x, this.z, Math.min(0.4, -this.vy * 0.05), this.size);
this.fallY = NaN;
}
}
this.transform.x = this.x;
this.transform.y = y;
this.transform.z = this.z;
// Wake: a small ripple every 0.2 m of travel
if (Math.hypot(this.x - this.wakeX, this.z - this.wakeZ) > 0.2) {
this.water.addRipple(this.x, this.z, 0.03, this.size * 0.7);
this.wakeX = this.x;
this.wakeZ = this.z;
}
}
}
/** A ball floating on the water, a little under half submerged. */
function floatingBall(scene: Scene3D, water: WaterRenderer, radius: number, x: number, z: number, color: Color): Floater {
const ball = new Object3D();
const mr = ball.addComponent(MeshRenderer);
mr.geometry = new SphereGeometry(radius, 32, 32);
const mat = new LitMaterial();
mat.baseColor = color;
mat.roughness = 0.4;
mat.metallic = 0;
mr.material = mat;
const floater = ball.addComponent(Floater, { water, x, z, lift: radius * 0.6, size: radius });
scene.addChild(ball);
return floater;
}
function addPointerRipples(engine: Engine3D, camera: Camera3D, water: WaterRenderer, floaters: Floater[], splash = 0.4, radius = 0.4): void {
let lastTrail: Vector3 | null = null;
const hitWater = (e: PointerEvent3D): Vector3 | null => {
const ray = camera.screenPointToRay(e.mouseX, e.mouseY);
if (ray.direction.y >= 0) return null;
const t = (water.baseHeight - ray.origin.y) / ray.direction.y;
return new Vector3(ray.origin.x + ray.direction.x * t, water.baseHeight, ray.origin.z + ray.direction.z * t);
};
const hitFloater = (e: PointerEvent3D): Floater | null => {
const ray = camera.screenPointToRay(e.mouseX, e.mouseY);
let best: Floater | null = null;
let bestT = Infinity;
for (const f of floaters) {
const c = f.transform.worldPosition;
const ox = ray.origin.x - c.x,
oy = ray.origin.y - c.y,
oz = ray.origin.z - c.z;
const b = ox * ray.direction.x + oy * ray.direction.y + oz * ray.direction.z;
const disc = b * b - (ox * ox + oy * oy + oz * oz - f.radius * f.radius);
const t = -b - Math.sqrt(Math.max(disc, 0));
if (disc >= 0 && t > 0 && t < bestT) {
best = f;
bestT = t;
}
}
return best;
};
engine.inputSystem.addEventListener(
PointerEvent3D.POINTER_DOWN,
(e: PointerEvent3D) => {
const floater = hitFloater(e);
if (floater) return floater.push();
const p = hitWater(e);
// addRipple ignores points off the water
if (p) water.addRipple(p.x, p.z, splash, radius);
},
null
);
engine.inputSystem.addEventListener(
PointerEvent3D.POINTER_MOVE,
(e: PointerEvent3D) => {
if (!e.shiftKey) {
lastTrail = null;
return;
}
const p = hitWater(e);
if (p && (!lastTrail || Vector3.distance(p, lastTrail) > 0.15)) {
water.addRipple(p.x, p.z, splash * 0.3, radius * 0.6);
lastTrail = p;
}
},
null
);
}
function addWaterGUI(water: WaterRenderer): dat.GUI {
const m = water.waterMaterial;
const steepness = water.waves.map((w) => w.steepness);
const toRGB = (c: Color) => [c.r * 255, c.g * 255, c.b * 255];
const fromRGB = (v: number[]) => new Color(v[0] / 255, v[1] / 255, v[2] / 255);
const s = {
paused: false,
speed: water.timeScale,
waveHeight: 1,
absorptionColor: toRGB(m.absorptionColor),
underwaterColor: toRGB(m.underwaterColor)
};
const applyTime = () => (water.timeScale = s.paused ? 0 : s.speed);
const gui = new dat.GUI();
const f = gui.addFolder('Water');
f.add(s, 'paused').onChange(applyTime);
f.add(s, 'speed', 0, 3, 0.01).onChange(applyTime);
f.addColor(s, 'absorptionColor').onChange((v: number[]) => (m.absorptionColor = fromRGB(v)));
f.addColor(s, 'underwaterColor').onChange((v: number[]) => (m.underwaterColor = fromRGB(v)));
f.add(m, 'density', 0, 2, 0.01);
f.add(s, 'waveHeight', 0, 3, 0.01).onChange((v: number) => water.waves.forEach((w, i) => (w.steepness = steepness[i] * v)));
f.add(water, 'caustics');
f.open();
return gui;
}
/**
* Flat mesh in the xz plane bounded by a closed curve around the origin:
* rings from the center out to `outline(angle)`.
*/
function radialGeometry(outline: (angle: number) => number, rings: number, segments: number): GeometryBase {
const count = 1 + rings * segments;
const pos = new Float32Array(count * 3);
const nor = new Float32Array(count * 3);
const uv = new Float32Array(count * 2);
nor[1] = 1;
uv[0] = uv[1] = 0.5;
for (let s = 0; s < segments; s++) {
const a = (s / segments) * Math.PI * 2;
const edge = outline(a);
for (let r = 1; r <= rings; r++) {
const i = 1 + (r - 1) * segments + s;
const rr = (edge * r) / rings;
pos[i * 3] = Math.cos(a) * rr;
pos[i * 3 + 2] = Math.sin(a) * rr;
nor[i * 3 + 1] = 1;
uv[i * 2] = 0.5 + (0.5 * Math.cos(a) * r) / rings;
uv[i * 2 + 1] = 0.5 + (0.5 * Math.sin(a) * r) / rings;
}
}
const idx: number[] = [];
for (let s = 0; s < segments; s++) idx.push(0, 1 + ((s + 1) % segments), 1 + s);
for (let r = 1; r < rings; r++) {
for (let s = 0; s < segments; s++) {
const a = 1 + (r - 1) * segments + s;
const b = 1 + (r - 1) * segments + ((s + 1) % segments);
idx.push(a, b, a + segments, b, b + segments, a + segments);
}
}
return indexedGeometry(pos, nor, uv, idx);
}
/** Square grid of `size` world units centered on the origin, raised by `height(x, z)`. */
function heightfieldGeometry(size: number, segments: number, height: (x: number, z: number) => number): GeometryBase {
const n = segments + 1;
const pos = new Float32Array(n * n * 3);
const nor = new Float32Array(n * n * 3);
const uv = new Float32Array(n * n * 2);
const step = size / segments;
for (let j = 0; j < n; j++) {
for (let i = 0; i < n; i++) {
const k = j * n + i;
const x = -size / 2 + i * step,
z = -size / 2 + j * step;
pos[k * 3] = x;
pos[k * 3 + 1] = height(x, z);
pos[k * 3 + 2] = z;
const e = step * 0.5;
const dx = (height(x + e, z) - height(x - e, z)) / (2 * e);
const dz = (height(x, z + e) - height(x, z - e)) / (2 * e);
const len = Math.hypot(dx, 1, dz);
nor[k * 3] = -dx / len;
nor[k * 3 + 1] = 1 / len;
nor[k * 3 + 2] = -dz / len;
uv[k * 2] = i / segments;
uv[k * 2 + 1] = j / segments;
}
}
const idx: number[] = [];
for (let j = 0; j < segments; j++) {
for (let i = 0; i < segments; i++) {
const a = j * n + i,
b = a + 1,
c = a + n,
d = c + 1;
idx.push(a, c, b, b, c, d);
}
}
return indexedGeometry(pos, nor, uv, idx);
}
function indexedGeometry(pos: Float32Array, nor: Float32Array, uv: Float32Array, idx: number[]): GeometryBase {
const g = new GeometryBase();
g.setIndices(pos.length / 3 > 65535 ? new Uint32Array(idx) : new Uint16Array(idx));
g.setAttribute(VertexAttributeName.position, pos);
g.setAttribute(VertexAttributeName.normal, nor);
g.setAttribute(VertexAttributeName.uv, uv);
g.setAttribute(VertexAttributeName.TEXCOORD_1, uv);
g.addSubGeometry({ indexStart: 0, indexCount: idx.length, vertexStart: 0, vertexCount: 0, firstStart: 0, index: 0, topology: 0 });
return g;
}
/** Ground colors by height: mud and sand under the water, a sandy beach, then grass. */
function paintGround(ctx: Context3D): BitmapTexture2D {
const size = 1024;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const g = canvas.getContext('2d')!;
const img = g.createImageData(size, size);
const mix = (a: number[], b: number[], t: number) => a.map((v, i) => v + (b[i] - v) * t);
const mud = [70, 64, 48],
sand = [196, 182, 140],
grass = [62, 120, 34],
dryGrass = [104, 136, 48];
for (let j = 0; j < size; j++) {
for (let i = 0; i < size; i++) {
const x = (i / size - 0.5) * TERRAIN_SIZE;
const z = (j / size - 0.5) * TERRAIN_SIZE;
const y = groundHeight(x, z);
const noise = 0.92 + 0.16 * Math.random();
const c = y < 0.4 ? mix(sand, mud, smoothstep(0.2, 3, -y)) : mix(sand, mix(grass, dryGrass, smoothstep(2, 6, y)), smoothstep(0.4, 1.2, y));
const k = (j * size + i) * 4;
img.data[k] = c[0] * noise;
img.data[k + 1] = c[1] * noise;
img.data[k + 2] = c[2] * noise;
img.data[k + 3] = 255;
}
}
g.putImageData(img, 0, 0);
const tex = new BitmapTexture2D(true, ctx, 'srgb');
tex.source = canvas;
return tex;
}
new Sample_Lake().run();Detail Ripples and Caustics
On top of the mesh displacement, the surface carries smaller ripples that only bend the normal. They break up highlights and reflections, and they are what focuses the sunlight into caustics:
| Property | Default | Description |
|---|---|---|
detailScale | 1.8 | Base wavelength of the detail ripples (world units) |
detailStrength | 0.35 | How much the detail ripples bend the normal, 0 ~ 1. Caustics always use the full strength; lower values only keep the view of the bottom through calm water clearer |
caustics | true | Whether to draw caustics on objects under water (requires setting.render.water) |
causticsIntensity | 0.6 | Caustics strength |
Interaction
Ripples
addRipple(x, z, strength, radius) pushes the surface down at world (x, z), as if something struck it; a ring then spreads out and reflects off the edge of the water. Mouse clicks, objects falling in or moving through the water can all start ripples this way:
import { PointerEvent3D } from '@orillusion/core';
engine.inputSystem.addEventListener(PointerEvent3D.POINTER_DOWN, (e: PointerEvent3D) => {
const ray = camera.screenPointToRay(e.mouseX, e.mouseY);
if (ray.direction.y >= 0) return;
// Where the view ray meets the undisturbed surface
const t = (water.baseHeight - ray.origin.y) / ray.direction.y;
const x = ray.origin.x + ray.direction.x * t;
const z = ray.origin.z + ray.direction.z * t;
// Crater depth 0.4, radius 0.4 (world units)
water.addRipple(x, z, 0.4, 0.4);
}, null);Points off the water are ignored, and at most 32 ripples start per frame (WATER_MAX_RIPPLES_PER_FRAME). The ripple simulation is created on the first addRipple() call.
| Property | Default | Description |
|---|---|---|
rippleSpeed | 1.2 | Ripple speed (world units per second) |
rippleDamping | 0.3 | Fraction of the ripples' motion lost per second |
rippleResolution | 256 | Ripple grid cells along the longer side of the water; changing it clears the ripples |
rippleArea | null | Simulate ripples only in a square of this side (local units) around the water's origin; null covers the whole surface. For large water such as an ocean, where one grid cannot resolve ripples |
Getting the Water Height
getHeight(x, z) returns the current world height of the surface above (x, z) on the CPU, for floating objects, boats, checking whether a character is in the water and so on:
class Floater extends ComponentBase {
public water: WaterRenderer;
public onUpdate(): void {
const p = this.transform;
// Keep the object's center 0.1 above the surface
p.y = this.water.getHeight(p.x, p.z) + 0.1;
}
}Height of FFT waves
Gerstner waves are evaluated on the CPU directly. FFT waves are computed on the GPU: getHeight() queues the point, and their part of the height comes from the read-back of the nearest point queried a frame or two earlier (0 on the first query). Call getHeight() every frame for moving objects.
Other helpers:
| Method | Description |
|---|---|
isUnderwater(point) | Whether the point is inside the water's outline and below the surface |
containsXZ(x, z, margin?) | Whether world (x, z) is inside the water's outline; margin grows the outline |
baseHeight | World height of the undisturbed surface, i.e. the object's world y |
time | Wave phase time in seconds; can be set to keep several water surfaces in sync |
Underwater View
With setting.render.water on, UnderwaterPass takes over automatically when the camera enters the water: the image is attenuated along the view ray by the water's absorption color and mixed with the water's own color, and a camera half in the water shows a waterline across the screen. Looking up from below, the world above shows through the surface within Snell's window (within about 48.6° of straight up), while the rest of the surface mirrors the water below; waterMaterial.underwaterClarity widens the window so the world above is easier to see.
Material Parameters
The look of the water is controlled by water.waterMaterial (WaterMaterial):
import { Color } from '@orillusion/core';
const m = water.waterMaterial;
m.absorptionColor = new Color(0.25, 0.55, 0.42); // color that objects seen through the water are tinted toward
m.underwaterColor = new Color(0.05, 0.13, 0.1); // color of deep water
m.density = 0.45; // higher is murkier| Property | Default | Description |
|---|---|---|
absorptionColor | (0.12, 0.62, 0.72) | Light passing through the water is tinted toward this color |
underwaterColor | (0.03, 0.16, 0.2) | Color of deep water; anything seen through the water, from above or below, fades into it with distance |
density | 0.15 | Absorption per world unit of path through the water |
refractionStrength | 1 | Strength of the distortion seen through the surface; 1 is physical, 0 disables it |
waterDepth | 2 | Fixed water depth used without setting.render.water |
reflectionIntensity | 1 | Environment reflection strength |
reflectionRoughness | 0.02 | Surface roughness below the scale of the detail ripples; blurs the reflection |
fresnelClamp | 1 | Upper bound of the fresnel reflection weight, 0 ~ 1 |
specularPower | 2000 | Sun highlight exponent; higher is smaller and sharper |
specularIntensity | 2 | Sun highlight strength |
foamIntensity | 1 | Whitecap foam where crests break, 0 ~ 1; calm water has none |
foamTexture | built-in | Tileable foam texture (r channel); null falls back to noise patches |
subsurfaceIntensity | 1 | Sunlight glowing through the crests of tall waves when looking toward the sun |
underwaterClarity | 0.5 | How clearly the world above shows from underwater, 0 ~ 1; 0 is the physical Snell's window |
See the API documentation of WaterRenderer, WaterMaterial and WaterSpectrum for more properties.

