Skip to content

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:

ts
import { Engine3D } from '@orillusion/core';

const engine = await Engine3D.init({
    setting: { render: { water: true } }
});

This adds the following passes to the render pipeline:

PassPurpose
SceneLinearDepthPassRecords the linear depth of the opaque scene, so the water knows the real distance to the bottom at every pixel for absorption and refraction
CausticsPassProjects the caustics formed by sunlight focused through the waves onto objects under water
UnderwaterPassWhen the camera is under water, absorbs and scatters the image along the view ray; a camera half in the water shows a waterline
WaterSimulationPassAdvances 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:

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

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
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:

ts
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;
FieldDescription
directionDirection of travel in the xz plane (Vector2: x is world x, y is world z)
wavelengthCrest-to-crest distance in world units; the wave speed follows from the deep-water dispersion relation
steepness0 ~ 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:

ts
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 = [];
PropertyDefaultDescription
windSpeed10Wind speed (m/s); stronger wind gives higher, longer waves
windDirection(1, 0.3)Direction the wind blows toward
fetch100000Fetch in metres. A short fetch gives young, short waves; a long one a fully developed sea
amplitude1Wave height scale
choppiness1How far the water moves sideways toward the crests: 0 gives rounded waves, 1 sharp crests and troughs that whitecaps break from
seed1Random 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.

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
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:

ts
// 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 y is 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.

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
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:

PropertyDefaultDescription
detailScale1.8Base wavelength of the detail ripples (world units)
detailStrength0.35How 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
causticstrueWhether to draw caustics on objects under water (requires setting.render.water)
causticsIntensity0.6Caustics 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:

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

PropertyDefaultDescription
rippleSpeed1.2Ripple speed (world units per second)
rippleDamping0.3Fraction of the ripples' motion lost per second
rippleResolution256Ripple grid cells along the longer side of the water; changing it clears the ripples
rippleAreanullSimulate 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:

ts
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:

MethodDescription
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
baseHeightWorld height of the undisturbed surface, i.e. the object's world y
timeWave 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):

ts
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
PropertyDefaultDescription
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
density0.15Absorption per world unit of path through the water
refractionStrength1Strength of the distortion seen through the surface; 1 is physical, 0 disables it
waterDepth2Fixed water depth used without setting.render.water
reflectionIntensity1Environment reflection strength
reflectionRoughness0.02Surface roughness below the scale of the detail ripples; blurs the reflection
fresnelClamp1Upper bound of the fresnel reflection weight, 0 ~ 1
specularPower2000Sun highlight exponent; higher is smaller and sharper
specularIntensity2Sun highlight strength
foamIntensity1Whitecap foam where crests break, 0 ~ 1; calm water has none
foamTexturebuilt-inTileable foam texture (r channel); null falls back to noise patches
subsurfaceIntensity1Sunlight glowing through the crests of tall waves when looking toward the sun
underwaterClarity0.5How 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.

Released under the MIT License