import { water_medium_preset } from "@woosh/meep-engine/src/shade/renderer/medium/water/water_presets.js";
import { createScene } from "./scene.js";
import { flatGrid, tiles } from "./procedural.js";
// The bed falls from a dry beach at +x to eight metres, then on to the open sea.
const SIZE = 320, SHORE = 6, DEEPEST = -8, SLOPE = 22;
const MARKERS = [.5, 1, 2, 4, 8]; // depths of the plates on the bed, metres
function bed(x) {
const t = (SHORE - x) / SLOPE;
if (t <= 0) return .6 - .02 * t; // the beach keeps rising inland
if (t <= 1) return .6 - (.6 - DEEPEST) * Math.pow(t, 1.6);
return DEEPEST - 4 * (t - 1);
}
// where along x the bed is at a depth
function at(depth) {
let lo = SHORE - 2 * SLOPE, hi = SHORE;
for (let i = 0; i < 40; i++) {
const mid = (lo + hi) / 2;
if (bed(mid) < -depth) lo = mid; else hi = mid;
}
return lo;
}
const scene = await createScene({ distance: 19, yaw: -Math.PI / 2 + .3, pitch: .52, focus: [-7, -1.5, -.5], sun: [-.25, -.55, .8] });
// One-metre squares on the sand, so distance and fading read at a glance.
const sand = scene.material("#ffffff", { texture: await scene.canvasTexture(tiles(4, 4, "#dccca6", "#cdbb94")), roughness: .95 });
// The bed changes only across x, so it needs a vertex every 2 m that way and only every 10 m along z.
scene.solid(flatGrid(SIZE, SIZE, [160, 32], x => bed(x) + .04 * Math.sin(x * 2.1) * Math.sin(x * .7), 4), sand);
// A plate lying on the bed at each depth, labelled. Nothing breaks the surface, so the
// only thing between the eye and each plate is water.
for (const [i, depth] of MARKERS.entries()) {
const x = at(depth), z = 2.5 - i * 1.6;
const slope = (bed(x + .01) - bed(x - .01)) / .02;
const plate = scene.solid(flatGrid(1.4, 2.4), scene.material("#ffffff", { texture: await scene.canvasTexture(label(`${depth} m`)), roughness: .7 }), x, bed(x) + .03, z);
// turn the text to face the beach, then tilt the plate onto the slope
plate.transform.setRotation(...multiply(axisAngle([0, 0, 1], Math.atan(slope)), axisAngle([0, 1, 0], Math.PI / 2)));
scene.move(plate, x, bed(x) + .03, z);
}
// The water's outline only has to reach past the shore; the waves supply the detail.
const sea = scene.waterBody(flatGrid(SIZE, SIZE, 8), { preset: "tropical_lagoon", wind: 4, windDirection: Math.PI });
const preset = document.getElementById("preset");
// Fitting a preset to RGB is an authoring step that takes some milliseconds: once per change.
preset.addEventListener("change", () => { sea.water.medium = water_medium_preset({ preset: preset.value }); });
document.querySelector("fieldset").disabled = false;
function label(text) {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 300;
const g = canvas.getContext("2d");
g.fillStyle = "#c4553b";
g.fillRect(0, 0, 512, 300);
g.fillStyle = "#f4f1e8";
g.fillRect(16, 16, 480, 268);
g.fillStyle = "#1d2330";
g.font = "bold 150px system-ui, sans-serif";
g.textAlign = "center";
g.textBaseline = "middle";
g.fillText(text, 256, 158);
return canvas;
}
function axisAngle(axis, angle) {
const s = Math.sin(angle / 2);
return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(angle / 2)];
}
function multiply(a, b) {
return [
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
];
}
import { Attribute } from "@woosh/meep-engine/src/shade/renderer/geometry/Attribute.js";
import { Geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/Geometry.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
const FLAT = () => 0;
/**
* A flat grid in local XZ facing up. Water uses it as the outline of a body; floors use it with a
* texture, whose coordinates run 0..1 across the grid, or repeat every `texel` metres when given.
* @param {number|number[]} [segments] cells along the shorter side, with the longer side getting
* as many more as keeps them square; or [across the width, along the length]
* @param {function(number, number): number} [height] y at (x, z)
*/
export function flatGrid(width, length, segments = 1, height = FLAT, texel = 0) {
const [sx, sz] = Array.isArray(segments) ? segments : [
Math.max(1, Math.round(segments * Math.max(1, width / length))),
Math.max(1, Math.round(segments * Math.max(1, length / width))),
];
const position = [], normal = [], uv = [], index = [];
for (let j = 0; j <= sz; j++) {
for (let i = 0; i <= sx; i++) {
const x = (i / sx - .5) * width, z = (j / sz - .5) * length;
position.push(x, height(x, z), z);
normal.push(0, 1, 0);
uv.push(texel > 0 ? x / texel : i / sx, texel > 0 ? z / texel : j / sz);
}
}
for (let j = 0; j < sz; j++) {
for (let i = 0; i < sx; i++) {
const a = j * (sx + 1) + i, b = a + 1, c = a + sx + 1, d = c + 1;
index.push(a, c, b, b, c, d); // counter-clockwise seen from above
}
}
const geometry = new Geometry();
geometry.index = Attribute.from(new Uint32Array(index), 1, "index");
geometry.addAttribute(Attribute.from(new Float32Array(position), 3, StandardAttributes.Position));
geometry.addAttribute(Attribute.from(new Float32Array(normal), 3, StandardAttributes.Normal));
geometry.addAttribute(Attribute.from(new Float32Array(uv), 2, StandardAttributes.TextureCoordinates0));
if (height !== FLAT) recomputeNormals(geometry);
return geometry;
}
function recomputeNormals(geometry) {
const p = geometry.getAttribute(StandardAttributes.Position).data;
const n = geometry.getAttribute(StandardAttributes.Normal).data;
const index = geometry.index.data;
n.fill(0);
for (let t = 0; t < index.length; t += 3) {
const a = index[t] * 3, b = index[t + 1] * 3, c = index[t + 2] * 3;
const ux = p[b] - p[a], uy = p[b + 1] - p[a + 1], uz = p[b + 2] - p[a + 2];
const vx = p[c] - p[a], vy = p[c + 1] - p[a + 1], vz = p[c + 2] - p[a + 2];
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
for (const v of [a, b, c]) { n[v] += nx; n[v + 1] += ny; n[v + 2] += nz; }
}
for (let i = 0; i < n.length; i += 3) {
const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1;
n[i] /= l; n[i + 1] /= l; n[i + 2] /= l;
}
}
/** A disk in local XZ facing up, with texture coordinates 0..1 across its square. */
export function disk(radius, segments = 48) {
const position = [0, 0, 0], normal = [0, 1, 0], uv = [.5, .5], index = [];
for (let i = 0; i < segments; i++) {
const angle = i / segments * Math.PI * 2, x = Math.cos(angle), z = Math.sin(angle);
position.push(x * radius, 0, z * radius);
normal.push(0, 1, 0);
uv.push(.5 + x / 2, .5 + z / 2);
index.push(0, 1 + (i + 1) % segments, 1 + i); // counter-clockwise seen from above
}
const geometry = new Geometry();
geometry.index = Attribute.from(new Uint32Array(index), 1, "index");
geometry.addAttribute(Attribute.from(new Float32Array(position), 3, StandardAttributes.Position));
geometry.addAttribute(Attribute.from(new Float32Array(normal), 3, StandardAttributes.Normal));
geometry.addAttribute(Attribute.from(new Float32Array(uv), 2, StandardAttributes.TextureCoordinates0));
return geometry;
}
/** Square tiles with grout lines; `lane` adds a dark stripe down the middle, as a pool floor has. */
export function tiles(across, down, face, grout, lane = false) {
const cell = 64, canvas = document.createElement("canvas");
canvas.width = across * cell;
canvas.height = down * cell;
const g = canvas.getContext("2d");
g.fillStyle = grout;
g.fillRect(0, 0, canvas.width, canvas.height);
g.fillStyle = face;
for (let y = 0; y < down; y++) for (let x = 0; x < across; x++) g.fillRect(x * cell + 3, y * cell + 3, cell - 6, cell - 6);
if (lane) {
g.fillStyle = "#1d4f78";
g.fillRect(cell, (down / 2 - .5) * cell + 8, (across - 2) * cell, cell - 16);
}
return canvas;
}
/** Bands of colour across v, or across u when `vertical`. */
export function stripes(colours, count, vertical = false) {
const canvas = document.createElement("canvas");
canvas.width = vertical ? 512 : 8;
canvas.height = vertical ? 8 : 512;
const g = canvas.getContext("2d"), size = 512 / count;
for (let i = 0; i < count; i++) {
g.fillStyle = colours[i % colours.length];
if (vertical) g.fillRect(i * size, 0, size, 8);
else g.fillRect(0, i * size, 8, size);
}
return canvas;
}
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import TopDownCameraController from "@woosh/meep-engine/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { System } from "@woosh/meep-engine/src/engine/ecs/System.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import Water from "@woosh/meep-engine/src/engine/graphics/ecs/water/Water.js";
import WaterWaves from "@woosh/meep-engine/src/engine/graphics/ecs/water/WaterWaves.js";
import { WaterSystem } from "@woosh/meep-engine/src/engine/graphics3/WaterSystem.js";
import { water_medium_preset } from "@woosh/meep-engine/src/shade/renderer/medium/water/water_presets.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { ColorSpace } from "@woosh/meep-engine/src/shade/renderer/texture/ColorSpace.js";
import { ShadeImage } from "@woosh/meep-engine/src/shade/renderer/texture/source/ShadeImage.js";
import { ShadeTexture } from "@woosh/meep-engine/src/shade/renderer/texture/ShadeTexture.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { meshlet_geometry_from_bytes } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_transfer.js";
import { make_sky_hosek } from "@woosh/meep-engine/src/engine/graphics/sh3/path_tracer/make_sky_hosek.js";
import { Sampler2D } from "@woosh/meep-engine/src/engine/graphics/texture/sampler/Sampler2D.js";
import { sampler2d_to_f16 } from "@woosh/meep-engine/src/engine/graphics/texture/sampler/sampler2d_to_f16.js";
import { octahedral_uv_to_direction } from "@woosh/meep-engine/src/shade/renderer/light/environment/octahedral_uv_to_direction.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
import Vector2 from "@woosh/meep-engine/src/core/geom/Vector2.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
/**
* Engine, sky and water system for a water example.
* @param {object} options
* @param {number[]} options.sun direction the sunlight travels
*/
export async function createScene({ distance = 12, yaw = .4, pitch = .45, focus = [0, 0, 0], sun = [-.45, -.5, -.75], sunIntensity = 5 } = {}) {
let water, frame = () => {}, playing = true;
// Pause hands the water and the example a zero time step; the camera keeps moving.
class PausableWaterSystem extends WaterSystem {
update(dt) { super.update(playing ? dt : 0); }
}
class FrameSystem extends System {
dependencies = [Transform64];
update(dt) { frame(playing ? dt : 0); }
}
const engine = await EngineHarness.bootstrap({
configuration(config, engine) {
const scene = EngineHarness.shadeScene(engine);
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
water = new PausableWaterSystem(engine.graphics, scene);
config.addSystem(water);
config.addSystem(new FrameSystem());
},
});
await EngineHarness.buildBasics({
engine, enableTerrain: false, enableWater: false, enableLights: false,
focus: new Vector3(...focus), distance, pitch, yaw,
// Shade spreads the sun's shadow cascades over the camera's whole near..far range, so
// far is three opening distances and zooming out stops at two: the scene stays inside
// far at any zoom, and the cascades are not stretched over empty ground.
cameraFarDistance: distance * 3, showFps: false,
});
engine.entityManager.dataset.getAnyComponent(TopDownCameraController).component.distanceMax = distance * 2;
await EngineHarness.buildLights({
engine, sunIntensity, sunDirection: new Vector3(...sun).normalize(), shadowmapResolution: 2048, castShadow: true,
});
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
engine.graphics.set_environment_map(skyTexture(sun.map(v => -v)));
const ecd = engine.entityManager.dataset;
function material(hex, { texture, roughness = .8 } = {}) {
const m = new StandardShadeMaterial();
m.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hex)));
m.roughness_factor = roughness;
if (texture) m.texture_albedo = texture;
return m;
}
async function canvasTexture(canvas) {
const image = ShadeImage.fromImageBitmap(await createImageBitmap(canvas));
image.color_space = ColorSpace.SRGB;
return ShadeTexture.from(image);
}
// A Geometry is built into meshlets here; a MeshletGeometry baked offline is used as it is.
function solid(geometry, surface, x = 0, y = 0, z = 0) {
const transform = new Transform64();
transform.setTranslation(x, y, z);
const meshlets = geometry.isMeshletGeometry ? geometry : meshlet_geometry_build_from_geometry(geometry);
const entity = new Entity().add(transform)
.add(ShadedGeometry.from(meshlets, typeof surface === "string" ? material(surface) : surface))
.build(ecd);
return { entity, transform };
}
function move(object, x, y, z) {
object.transform.setTranslation(x, y, z);
object.transform.updateMatrix();
t64_announce_change(ecd, object.entity);
}
/** A body of water: its mesh is the outline, the transform places it, the medium fills it. */
function waterBody(geometry, { at = [0, 0, 0], preset = "clear_ocean", wind = 0, windDirection = 0, roughness = .02, shoreReflection = .5 } = {}) {
const component = new Water();
component.medium = water_medium_preset({ preset });
component.roughness.set(roughness);
const waves = new WaterWaves();
waves.windSpeed.set(wind);
waves.windDirection.set(windDirection);
waves.shoreReflection.set(shoreReflection);
const transform = new Transform64();
transform.setTranslation(...at);
transform.updateMatrix();
const entity = new Entity().add(transform).add(meshlet_geometry_build_from_geometry(geometry)).add(component).add(waves).build(ecd);
return { entity, water: component, waves, level: at[1] };
}
// Where a click meets a body's surface at rest, or nothing.
const tap = new Vector2(), ndc = new Vector2(), origin = new Vector3(), direction = new Vector3();
function hitWater(position, bodies, inside) {
tap.set(position.x, position.y);
engine.graphics.normalizeViewportPoint(tap, ndc);
engine.graphics.viewportProjectionRay(ndc.x, ndc.y, origin, direction);
let best = null;
for (const body of bodies) {
if (Math.abs(direction.y) < 1e-6) continue;
const t = (body.level - origin.y) / direction.y;
if (!(t > 0) || (best && t >= best.t)) continue;
const point = [origin.x + direction.x * t, body.level, origin.z + direction.z * t];
if (inside(body, point)) best = { body, point, t };
}
return best;
}
const pause = document.getElementById("pause");
pause?.addEventListener("click", () => {
playing = !playing;
pause.textContent = playing ? "Pause" : "Resume";
pause.setAttribute("aria-pressed", String(!playing));
});
return {
engine, ecd, water, material, canvasTexture, solid, move, waterBody, hitWater,
onFrame: callback => { frame = callback; },
onTap: callback => engine.devices.pointer.on.tap.add(callback),
};
}
async function fetchOk(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url}: ${response.status} ${response.statusText}`);
return response;
}
/** A gzipped `MeshletGeometry` baked offline; loading it is an inflate and a copy, with no geometry work. */
export async function fetchMeshlets(url) {
const response = await fetchOk(url);
return meshlet_geometry_from_bytes(await new Response(response.body.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer());
}
/** An image file as a colour texture; the browser decodes it off the main thread. */
export async function fetchTexture(url) {
const image = ShadeImage.fromImageBitmap(await createImageBitmap(await (await fetchOk(url)).blob(), { premultiplyAlpha: "none" }));
image.color_space = ColorSpace.SRGB;
return ShadeTexture.from(image);
}
// A clear afternoon sky (Hosek-Wilkie) as the environment, so the water has a sky to reflect.
function skyTexture(toSun) {
const size = 256, sky = make_sky_hosek(toSun, 2.5, 0, [.25, .23, .2]);
const sampler = Sampler2D.float32(4, size, size), direction = [0, 0, 0];
for (let v = 0; v < size; v++) {
for (let u = 0; u < size; u++) {
octahedral_uv_to_direction(direction, (u + .5) / size, (v + .5) / size);
const offset = (v * size + u) * 4;
sky(sampler.data, offset, direction, 0);
sampler.data[offset + 3] = 1;
}
}
const image = ShadeImage.fromSampler2D(sampler2d_to_f16(sampler));
image.color_space = ColorSpace.LinearSRGB;
return ShadeTexture.from(image);
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<link rel="icon" href="data:,">
<title>Shallow to deep · Meep</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #080c16; color: #edf3ff; font: 14px system-ui, sans-serif; }
.panel { position: fixed; z-index: 100; left: 20px; bottom: 20px; width: 290px; padding: 16px; background: #080c16de; border: 1px solid #29344b; border-radius: 14px; backdrop-filter: blur(12px); }
fieldset { border: 0; padding: 0; margin: 0; }
fieldset > :first-child { margin-top: 0; }
label { display: block; margin: 12px 0 6px; }
select { width: 100%; padding: 6px 8px; background: #141c2c; color: inherit; border: 1px solid #465675; border-radius: 8px; }
button { margin-top: 12px; background: #23314a; color: inherit; border: 1px solid #465675; padding: 7px 16px; border-radius: 8px; cursor: pointer; }
button:focus-visible, select:focus-visible { outline: 2px solid #6bedc2; outline-offset: 3px; }
:disabled { opacity: .5; }
.hint { position: fixed; z-index: 100; bottom: 20px; right: 20px; margin: 0; color: #a8b6cd; font-size: 12px; pointer-events: none; }
@media (max-width: 650px) { .panel { left: 10px; bottom: 10px; width: 250px; padding: 12px; } .hint { display: none; } }
</style>
</head>
<body>
<section class="panel" aria-label="Shallow to deep controls">
<fieldset disabled>
<label for="preset">Water</label>
<select id="preset">
<option value="tropical_lagoon" selected>Tropical lagoon</option>
<option value="clear_ocean">Clear ocean</option>
<option value="open_ocean">Open ocean</option>
<option value="coastal">Coastal</option>
<option value="green_lake">Green lake</option>
<option value="turbid_river">Turbid river</option>
</select>
<button id="pause" type="button" aria-pressed="false">Pause</button>
</fieldset>
</section>
<p class="hint">Drag to orbit · Scroll to zoom</p>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"name": "@meep-examples/water-depth",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "One body of water over a bed that runs from a sandy beach to deep water; the same water looks clear in the shallows and dark over the depths.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "3.46.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# Shallow to deep
One body of water over a bed that falls from a sandy beach to eight metres and on into open sea. Labelled plates lie on the bed at 0.5, 1, 2, 4 and 8 m. The deeper a plate lies, the more water light crosses to reach it and come back, so the plates and the sand fade into the water's own colour.
Run `npm ci` and `npm run dev`, then open `/demo.html`. Build with `npm run build`.
The water's colour is not authored. `Water.medium` holds absorption and scattering coefficients in inverse metres, and Shade integrates them along each refracted view ray down to the bed, so the depth sets the colour. The panel switches between the presets of `water_medium_preset`: a tropical lagoon stays clear for metres, while a green lake or turbid river hides the bed within one.
The body is a single `Water` + `WaterWaves` + `MeshletGeometry` + `Transform64` entity. Its mesh is a flat 320 m square; where the sand rises above the surface, the opaque bed hides it, so the shoreline is wherever the two meet. The waves come from a 4 m/s wind over the body's own fetch. The wave simulation assumes deep water everywhere, so depth changes the light but not the waves.
`src/main.js` builds the bed, the plates and the water; `src/scene.js` boots the engine, the sky and `WaterSystem`; `src/procedural.js` makes the grids and canvas textures.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: __dirname,
publicDir: false,
// Keep the engine's imported asset URLs intact during development.
optimizeDeps: {
exclude: ["@woosh/meep-engine"],
include: ["dat.gui", "opentype.js"],
},
plugins: [
{
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/water-depth");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/water-depth"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});
thumbnail.png · 248.7 KB