Examples

Water

The open sea under three winds, a planar reflection, and caustics under a pier.

Starts examples/water in this page, on WebGPU where your browser has it.

Open on its own · Source

Water is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /water/.

Source

main.ts

examples/water/main.ts
/**
 * The open sea under three winds, a stone pier on legs, and the light the waves throw up onto the
 * underside of its deck.
 *
 * The waves are built by the wind: in a calm the sea barely breathes, in a gale the crests steepen
 * and break. The pier is mirrored in the water by a planar reflection, and the caustics under its
 * deck follow the same crests the surface shows.
 */
import {
  MeshBuilder,
  SceneNode,
  advanceWindField,
  computeLightMatrix,
  createEnvironment,
  createWindField,
} from '@driftengine/core';
import type { ShadowCasters, SkyColors, Vec3, WaterBody, WindProfile } from '@driftengine/core';
import { controls, flag, openStage } from '../common/stage';

const SPEEDS: Record<string, number> = { calm: 0.8, breeze: 4, gale: 10 };

// #region quality
const stage = await openStage({
  water: true,
  waterReflections: true,
  directionalShadows: true,
  outputTransform: 'aces',
  sceneSamples: 4,
});
// #endregion
const { renderer, camera } = stage;

/** The switch sets where the wind is heading; the sea follows it over a few seconds. */
let targetSpeed = SPEEDS[flag('wind', 'breeze')] ?? 4;
controls([
  {
    key: 'wind',
    label: 'wind',
    value: flag('wind', 'breeze'),
    options: ['calm', 'breeze', 'gale'].map((w) => ({ text: w, value: w })),
    change: (value) => {
      targetSpeed = SPEEDS[value] ?? 4;
    },
  },
]);

const HORIZON: Vec3 = [0.62, 0.7, 0.78];
const env = createEnvironment({
  directionalDir: [-0.5, 0.45, -0.7],
  directionalColor: [2.2, 2.05, 1.85],
  ambient: [0.3, 0.36, 0.45],
  ambientGround: [0.12, 0.13, 0.14],
  fogColor: HORIZON,
  fogDensity: 0.004,
  fogHeightFalloff: 0.02,
});
const sky: SkyColors = {
  top: [0.2, 0.36, 0.66],
  horizon: HORIZON,
  deep: [0.3, 0.36, 0.42],
  sunDir: env.directionalDir,
  sunColor: [1.8, 1.6, 1.3],
  sunAngularRadius: 0.02,
  moonDir: [0, -1, 0],
  moonColor: [0, 0, 0],
  moonAngularRadius: 0.03,
  moonPhase: 0,
  nightFactor: 0,
  cloudOffsetX: 0,
  cloudOffsetZ: 0,
};

// #region sea
/** The sea: no bounds, so it is endless and follows the camera; dense, so it hides its floor. */
const sea = renderer.createWater();
const seaBody: WaterBody = {
  level: 0,
  deepColor: [0.03, 0.07, 0.1],
  shallowColor: [0.08, 0.16, 0.18],
  density: 0.95,
};
// #endregion

// #region caustics
/** The underside of the deck, two centimetres below it, as a sheet the water lights from below. */
const caustics = renderer.createCaustics([
  {
    spans: [
      { x0: -3, z0: 2, x1: 3, z1: 2, y: 1.58 },
      { x0: -3, z0: -40, x1: 3, z1: -40, y: 1.58 },
    ],
    waterY: 0,
  },
]);
// #endregion

/** The pier: a deck on legs, running out to sea, with a hut at the end. */
const pier = new MeshBuilder();
pier.addBox([0, 1.8, -19], [3, 0.2, 21], [0.5, 0.46, 0.4]);
for (let z = 0; z >= -38; z -= 6)
  for (const x of [-2.6, 2.6]) pier.addBox([x, -1, z], [0.25, 2.6, 0.25], [0.32, 0.28, 0.24]);
pier.addBox([0, 3.4, -36], [2, 1.4, 2], [0.7, 0.66, 0.6]);
pier.addBox([0, -4, 30], [60, 4, 20], [0.55, 0.5, 0.4]);
const pierMesh = renderer.createMesh(pier.build());
const still = new SceneNode();
still.updateWorld();
const casters: ShadowCasters = (sink) => sink.mesh(pierMesh, still.worldMatrix);
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.85;

const profile: WindProfile = {
  directionX: -0.6,
  directionZ: -1,
  baseSpeed: targetSpeed,
  gustSpeed: targetSpeed * 0.3,
  directionWander: 0.2,
  cycleSeconds: 30,
  phase: 0,
};
const wind = createWindField();

let time = 0;
let shownAt = 0;

/** Everything a camera sees above the water: the pier and the sky. */
function drawWorld(view: typeof camera): void {
  renderer.bindMeshPass(view, env);
  renderer.drawMesh(pierMesh, still.worldMatrix);
  renderer.drawSky(view, sky, env);
}

stage.run({
  simulate(dt) {
    time += dt;
  },
  render() {
    const dt = time - shownAt;
    /* Wind builds a sea; it does not switch one. Ease toward the asked speed. */
    profile.baseSpeed += (targetSpeed - profile.baseSpeed) * Math.min(1, dt * 0.5);
    profile.gustSpeed = profile.baseSpeed * 0.3;
    advanceWindField(wind, profile, time, dt, 1);
    shownAt = time;
    sky.cloudOffsetX = wind.driftX;
    sky.cloudOffsetZ = wind.driftZ;

    camera.fovYDeg = 55;
    camera.far = 800;
    /* Low beside the pier, so the underside of the deck is in view. */
    camera.position[0] = 5 + Math.sin(time * 0.07) * 1.5;
    camera.position[1] = 0.7;
    camera.position[2] = -4;
    camera.lookAt(-1, 1.7, -14);

    env.shadowDepthSpan = computeLightMatrix(
      env.directionalDir,
      0,
      0,
      -18,
      30,
      renderer.shadowMapSize,
      lightMatrix,
    );
    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters(casters);
    renderer.endShadowPass();

    // #region frame
    renderer.beginFrame(HORIZON);
    /* The world again, mirrored across the water's plane, for the next water draw to sample. */
    const mirrored = renderer.beginPlanarReflection(camera, seaBody.level, HORIZON);
    if (mirrored !== null) {
      drawWorld(mirrored);
      renderer.endPlanarReflection();
    }
    drawWorld(camera);
    renderer.drawWater(sea, camera, time, seaBody, env, wind.velocityX, wind.velocityZ);
    renderer.drawCaustics(caustics, camera, time, env, wind.velocityX, wind.velocityZ);
    renderer.endFrame();
    // #endregion
  },
});