Skip to content

水面渲染 - WaterRenderer ​

WaterRenderer 用于渲染泳池、湖泊、海洋等大面积水体。它在所属物体的 xz 平面上生成一张网格,用 Gerstner 波或 FFT 风浪让网格起伏,再由 WaterMaterial 完成折射、吸收、天空反射与阳光高光。配合渲染设置,还可以得到水底焦散、水下视角、可交互的涟漪等效果。

开启水面支持 ​

WaterRenderer 本身就能绘制带波浪的水面,但水深、焦散、水下视角、涟漪与 FFT 风浪依赖额外的渲染通道,需要在初始化引擎时打开 setting.render.water:

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

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

开启后渲染管线会增加以下通道:

通道作用
SceneLinearDepthPass记录不透明场景的线性深度,水面据此得到每个像素到水底的真实距离,用于吸收与折射
CausticsPass把阳光透过波面聚焦形成的焦散投射到水下物体上
UnderwaterPass相机位于水下时,对画面做沿视线的吸收与散射;相机半入水时会出现水线
WaterSimulationPass在 GPU 上推进 FFT 风浪与交互涟漪

TIP

未开启 setting.render.water 时,水面没有场景深度可用,会把 waterMaterial.waterDepth(默认 2)当作固定水深来计算颜色;焦散、水下视角、addRipple() 与 spectrum 均不生效。

基本用法 ​

给一个 Object3D 添加 WaterRenderer 组件即可得到一块默认 10 × 10 的水面,水面高度就是物体的世界坐标 y:

ts
import { Object3D, WaterRenderer, Vector2 } from '@orillusion/core';

const waterObj = new Object3D();
const water = waterObj.addComponent(WaterRenderer);
// 矩形水面的尺寸(沿本地 x、z),会受物体缩放影响
water.width = 20;
water.length = 14;
// 网格沿长边的分段数,每个最短波长内需要有若干个顶点
water.resolution = 200;
// 水底到水面的距离,仅在未开启 setting.render.water 时使用
water.waterMaterial.waterDepth = 2;
scene.addChild(waterObj);

物体可以平移、缩放,以及绕 y 轴旋转;水面始终保持水平,波浪方向按世界坐标计算,因此多块水面可以无缝拼接。

TIP

水面从水体内部绘制、并依赖不透明场景的颜色做折射,所以它不参与深度预渲染、不投射阴影、不出现在反射探针中,这些已由组件自动设置。

示例:泳池 ​

几乎静止的水面、平铺瓷砖的池底与焦散,鸭子通过 getHeight() 浮在水面上。点击水面会溅起涟漪,点击鸭子会把它按入水中,按住 Shift 移动鼠标可拖出一串涟漪。

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 中的 Gerstner 波,以及可选的 FFT 风浪 spectrum。

Gerstner 波 ​

waves 是一个 GerstnerWave 数组,最多生效 8 个(WATER_MAX_WAVES),可以直接修改元素,也可以整体重新赋值:

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 }
];
// 动画速度,0 表示冻结水面
water.timeScale = 1;
字段说明
direction波在 xz 平面内的传播方向(Vector2,x 对应世界 x,y 对应世界 z)
wavelength相邻波峰的距离(世界单位);波速由波长按深水色散关系决定
steepness陡度 0 ~ 1,波高约为 steepness × wavelength / 2π。所有波的陡度之和应小于 1,否则波峰会打结

FFT 风浪 ​

开阔水面可以设置 spectrum,根据风速、风区在 GPU 上用 FFT 模拟出从数百米长的涌浪到厘米级细波的完整海面,并在浪尖破碎处生成白沫。它与 waves 叠加,海面通常把 waves 清空:

ts
import { WaterSpectrum, Vector2 } from '@orillusion/core';

const spectrum = new WaterSpectrum();
spectrum.windSpeed = 12;                     // 海面 10 米高处的风速(m/s)
spectrum.windDirection = new Vector2(1, 0.3);
spectrum.fetch = 100000;                     // 风区:风在开阔水面上吹过的距离(米)
water.spectrum = spectrum;
water.waves = [];
属性默认值说明
windSpeed10风速(m/s),越大浪越高越长
windDirection(1, 0.3)风吹向的方向
fetch100000风区长度(米)。风区短得到年轻、短小的浪;风区长得到充分成长的海浪
amplitude1浪高缩放
choppiness1水体向浪尖的水平挤压程度:0 为圆润的浪,1 为尖锐的浪峰与浪谷,白沫从尖锐处产生
seed1波浪相位的随机种子

water.spectrum = null 会移除风浪并释放其 GPU 资源。

远处波浪的淡出 ​

网格分辨率有限,远处顶点间距过大时短波会产生走样。waveFadeDistance 指定每个波在距离相机多少个波长处完全淡出(从一半距离开始淡出),默认 80,适合均匀网格;网格随距离变稀时应调小,0 表示不淡出。它只影响画面,getHeight() 始终包含全部波浪。

示例:海洋 ​

由 FFT 风浪构成的开阔海面。水面是一张以浮标为中心、越往外越稀疏的圆盘网格,配合 waveFadeDistance = 14 避免远处走样;涟漪只在浮标周围 60 米的范围内模拟(见下文 rippleArea)。在 Sea 面板中可以调节风速、风区、浪高与浪尖锐度。

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();

自定义水面形状 ​

默认的矩形由 width、length、resolution 决定。对于湖泊、池塘等不规则水域,可以把 surfaceGeometry 设为任意平面网格,水面、水下视角、焦散与涟漪都会沿用它的轮廓:

ts
// 一张位于本地 xz 平面、带索引的网格;其顶点的 y 会被忽略
water.surfaceGeometry = lakeGeometry;
// 设为 null 恢复矩形
water.surfaceGeometry = null;

对网格的要求:

  • 平铺在所属物体的本地 xz 平面上,顶点 y 会被忽略;
  • 必须是带索引的几何体;
  • 足够细密,能表现最短的波浪。

通常让网格略微越过岸线、藏在地形之下,这样波浪的水平位移不会在岸边露出缝隙。

示例:湖泊 ​

岸线不规则的湖泊,水面网格沿岸线向外多延伸 1 米,埋在逐渐升高的地形下。Buoy 面板可以让浮标绕圈漂移,或把它提起后落入水中。

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();

细节波纹与焦散 ​

在网格位移之外,水面还叠加了一层尺度更小、只扰动法线的细波纹,它们让高光与反射更细碎,也是焦散的来源:

属性默认值说明
detailScale1.8细波纹的基础波长(世界单位)
detailStrength0.35细波纹对法线的扰动程度 0 ~ 1。焦散始终使用完整强度,调低只是让透过平静水面看到的水底更清晰
causticstrue是否在水下物体上绘制焦散(需 setting.render.water)
causticsIntensity0.6焦散强度

交互 ​

涟漪 ​

addRipple(x, z, strength, radius) 在世界坐标 (x, z) 处把水面向下压出一个坑,随后一圈波纹向外扩散,并在水面边缘反射。鼠标点击、物体落水或在水中移动都可以用它产生涟漪:

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;
    // 视线与未扰动水面的交点
    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;
    // 坑深 0.4、半径 0.4(世界单位)
    water.addRipple(x, z, 0.4, 0.4);
}, null);

水面以外的点会被忽略,每帧最多新增 32 个涟漪(WATER_MAX_RIPPLES_PER_FRAME)。涟漪模拟在第一次调用 addRipple() 时才会创建。

属性默认值说明
rippleSpeed1.2涟漪传播速度(世界单位 / 秒)
rippleDamping0.3每秒损失的波动比例
rippleResolution256涟漪网格沿水面长边的格子数,修改会清空当前涟漪
rippleAreanull以水面原点为中心、边长为该值(本地单位)的正方形范围内模拟涟漪;null 表示覆盖整个水面。海洋等大水面无法用一张网格分辨涟漪时使用

获取水面高度 ​

getHeight(x, z) 在 CPU 上返回世界坐标 (x, z) 处当前的水面高度,可用于漂浮物、船只、判断角色是否入水等:

ts
class Floater extends ComponentBase {
    public water: WaterRenderer;

    public onUpdate(): void {
        const p = this.transform;
        // 让物体中心比水面高 0.1
        p.y = this.water.getHeight(p.x, p.z) + 0.1;
    }
}

FFT 风浪的高度

Gerstner 波在 CPU 上直接计算;FFT 风浪在 GPU 上计算,getHeight() 会把查询点排入队列,其 FFT 部分取自前一两帧中最近的查询点的回读结果(首次查询时为 0)。对移动的物体应每帧都调用 getHeight()。

其他辅助方法:

方法说明
isUnderwater(point)点是否位于水面轮廓内且在水面以下
containsXZ(x, z, margin?)世界坐标 (x, z) 是否位于水面轮廓内,margin 向外扩展
baseHeight未扰动水面的世界高度,即物体的世界坐标 y
time波浪相位时间(秒),可手动设置以同步多个水面

水下视角 ​

开启 setting.render.water 后,相机进入水中时会自动启用 UnderwaterPass:画面沿视线按水的吸收颜色衰减并叠加水体自身的颜色,相机半入水时屏幕上会出现一条水线。从水下仰望时,水面在斯涅尔窗(与竖直方向夹角约 48.6° 以内)内透出水上的世界,窗外则反射水下景物,waterMaterial.underwaterClarity 可以放宽这一效果,让水上世界更清楚。

材质参数 ​

水的外观由 water.waterMaterial(WaterMaterial)控制:

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

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;                                // 越大越浑浊
属性默认值说明
absorptionColor(0.12, 0.62, 0.72)光穿过水体时被染向的颜色
underwaterColor(0.03, 0.16, 0.2)深水颜色,无论从水上还是水下,透过水看到的物体都随距离逐渐融入该颜色
density0.15每世界单位路径的吸收强度
refractionStrength1透过水面看到的折射扭曲程度,1 为物理值,0 关闭
waterDepth2未开启 setting.render.water 时使用的固定水深
reflectionIntensity1环境反射强度
reflectionRoughness0.02小于细波纹尺度的表面粗糙度,使反射变模糊
fresnelClamp1菲涅尔反射权重上限 0 ~ 1
specularPower2000阳光高光指数,越大越小越锐利
specularIntensity2阳光高光强度
foamIntensity1浪尖破碎处的白沫强度 0 ~ 1,平静水面不产生白沫
foamTexture内置可平铺的白沫纹理(r 通道),设为 null 时退化为噪声斑块
subsurfaceIntensity1迎着阳光看时,高大浪尖透出的次表面散射光
underwaterClarity0.5从水下看水上世界的清晰程度 0 ~ 1,0 为物理的斯涅尔窗

更多属性请参考 WaterRenderer、WaterMaterial、WaterSpectrum 的 API 文档。