// The ground's shape, shared by the page and by `tools/bake_ground.mjs`, which turns it into the
// mesh and colour map in `assets/`. Change anything here and rerun the bake.
export const GROUND = 220; // metres across
export const POND = { x: -4.4, z: .6, rx: 3.8, rz: 2.8, depth: .9, level: -.25 };
export const STREAM = { x: 6.5, width: 1.6, depth: 1.3, level: -.35 };
const streamCentre = z => STREAM.x + 1.4 * Math.sin(z * .22);
/** Ground height at (x, z): a gentle swell, a sunken pond and a meandering stream bed. */
export function ground(x, z) {
let h = .05 * Math.sin(x * .4) * Math.cos(z * .35);
const d = ((x - POND.x) / POND.rx) ** 2 + ((z - POND.z) / POND.rz) ** 2;
if (d < 1) h = Math.min(h, -POND.depth * (1 - d) ** .8 + .1 * d);
const w = Math.abs(x - streamCentre(z)) / STREAM.width;
if (w < 1.6) h = Math.min(h, -STREAM.depth * Math.max(0, 1 - w * w * .6));
return h;
}
import { make_cylinder_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_cylinder_geometry.js";
import { make_torus_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_torus_geometry.js";
import { make_octahedron_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";
import { ShadeDrawSide } from "@woosh/meep-engine/src/shade/renderer/material/ShadeDrawSide.js";
import { createScene, fetchMeshlets, fetchTexture } from "./scene.js";
import { disk, flatGrid, tiles } from "./procedural.js";
import { GROUND, POND, STREAM, ground } from "./ground.js";
const FOUNTAIN = { x: 1.8, z: -2.6, radius: 1.8, level: .62 };
// The ground's mesh and colour map are baked from `ground.js` by `tools/bake_ground.mjs`; they
// download and decode while the engine starts.
const groundMesh = fetchMeshlets(new URL("../assets/ground.meshlets.gzip", import.meta.url));
const groundMap = fetchTexture(new URL("../assets/ground.avif", import.meta.url));
const scene = await createScene({ distance: 15, yaw: Math.PI + .25, pitch: .5, focus: [-1.6, -.3, .4], sun: [.45, -.5, .72] });
// Grass, turning to sand and then mud where the ground dips below the water.
scene.solid(await groundMesh, scene.material("#ffffff", { texture: await groundMap, roughness: .95 }));
// A raised stone fountain: an open wall, a rim, a tiled floor and a column.
const stone = scene.material("#c9c1b0", { roughness: .85 });
const wall = scene.material("#c9c1b0", { roughness: .85 });
wall.draw_side = ShadeDrawSide.Double;
scene.solid(make_cylinder_geometry(FOUNTAIN.radius, FOUNTAIN.radius, .78, 48, 1, true), wall, FOUNTAIN.x, .39, FOUNTAIN.z);
const rim = scene.solid(make_torus_geometry(FOUNTAIN.radius, .1, 12, 64), stone, FOUNTAIN.x, .78, FOUNTAIN.z);
rim.transform.setRotation(Math.SQRT1_2, 0, 0, Math.SQRT1_2);
scene.move(rim, FOUNTAIN.x, .78, FOUNTAIN.z);
scene.solid(disk(FOUNTAIN.radius), scene.material("#ffffff", { texture: await scene.canvasTexture(tiles(12, 12, "#3f8fb0", "#2b6f8f")), roughness: .4 }), FOUNTAIN.x, .15, FOUNTAIN.z);
scene.solid(make_cylinder_geometry(.18, .26, 1.5, 24), stone, FOUNTAIN.x, .75, FOUNTAIN.z);
scene.solid(make_octahedron_geometry(.3, 5), stone, FOUNTAIN.x, 1.6, FOUNTAIN.z);
// Three bodies of water, each its own entity with its own medium and level, under one wind.
const bodies = [
{
name: "Fountain",
...scene.waterBody(disk(FOUNTAIN.radius - .02, 64), { at: [FOUNTAIN.x, FOUNTAIN.level, FOUNTAIN.z], preset: "swimming_pool", shoreReflection: .9 }),
inside: (x, z) => Math.hypot(x - FOUNTAIN.x, z - FOUNTAIN.z) < FOUNTAIN.radius,
},
{
name: "Pond",
...scene.waterBody(flatGrid(POND.rx * 2 + 1, POND.rz * 2 + 1, 6), { at: [POND.x, POND.level, POND.z], preset: "tropical_lagoon", shoreReflection: .3 }),
inside: (x, z) => ground(x, z) < POND.level && Math.abs(x - POND.x) < POND.rx && Math.abs(z - POND.z) < POND.rz,
},
{
name: "Stream",
...scene.waterBody(flatGrid(STREAM.width * 3.4, GROUND, 6), { at: [STREAM.x, STREAM.level, 0], preset: "turbid_river", shoreReflection: .2 }),
inside: (x, z) => ground(x, z) < STREAM.level && Math.abs(x - STREAM.x) < STREAM.width * 1.7,
},
];
// A click splashes whichever body it lands on.
scene.onTap(position => {
const hit = scene.hitWater(position, bodies, (body, point) => body.inside(point[0], point[2]));
if (hit) scene.water.splash(hit.body.entity, hit.point, .5, 2.5);
});
// Along the stream, so it has the longest fetch and the tallest waves.
const WIND_DIRECTION = Math.PI / 2;
const wind = document.getElementById("wind");
function updateWind() {
document.getElementById("wind-value").value = Number(wind.value).toFixed(1);
scene.wind(Number(wind.value), WIND_DIRECTION);
}
wind.addEventListener("input", updateWind);
updateWind();
document.querySelector("fieldset").disabled = false;
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 { GPUFluidSystem } from "@woosh/meep-engine/src/engine/graphics3/GPUFluidSystem.js";
import { FluidEffectorsComponent } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidEffectorsComponent.js";
import { FluidEffectorsSystem } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidEffectorsSystem.js";
import { GlobalFluidEffector } from "@woosh/meep-engine/src/engine/physics/fluid/effector/GlobalFluidEffector.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);
// The scene's wind, which raises the waves: GlobalFluidEffectors folded into a far
// field and published for the GPU. With no GPUFluidComponent the far field is all of it.
config.addSystem(new FluidEffectorsSystem());
config.addSystem(new GPUFluidSystem(engine.graphics, scene));
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;
const weather = new GlobalFluidEffector();
weather.drag = 1;
const effectors = new FluidEffectorsComponent();
effectors.addEffector(weather);
new Entity().add(effectors).build(ecd);
/** The scene's wind 10 m up, metres per second, blowing `direction` radians from +X towards +Z. */
function wind(speed, direction = 0) {
weather.wind = [speed * Math.cos(direction), 0, speed * Math.sin(direction)];
}
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", roughness = .02, shoreReflection = .5 } = {}) {
const component = new Water();
component.medium = water_medium_preset({ preset });
component.roughness.set(roughness);
const waves = new WaterWaves();
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, wind, 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);
}
// Bake the ground into the two files the page loads, so the page computes none of it.
//
// node tools/bake_ground.mjs
//
// Run from this example's folder after `npm ci`. It also needs `sharp`, which the site installs at
// the repository root (elsewhere, `npm install --no-save sharp`). The outputs are committed and
// nothing runs this at build time.
//
// assets/ground.meshlets.gzip the ground as a gzipped MeshletGeometry, its ray-tracing BVH included
// assets/ground.avif the ground's 2048² colour map
//
// The mesh file is the engine's own meshlet layout and carries no version, so rerun this after an
// engine update as well as after changing `src/ground.js`.
//
// It is `.gzip` and not `.gz` on purpose. Vite's dev server sends a `.gz` file as gzip-encoded, so
// the browser inflates it before the page sees it and the page's own inflate then fails. The page
// inflates the file itself because the site's server does not compress binary files.
//
// The mesh starts as a 401 × 401 grid, a vertex every 0.55 m, which the banks of the pond and the
// stream need and the nearly flat ground around them does not. `bt_mesh_simplify_by_error` removes
// every vertex whose loss moves the surface by less than 2 cm, and never moves a vertex it keeps, so
// the mesh still samples the same `ground()` the page tests clicks against. It goes from 320k
// triangles to about 42k, which is cheaper to draw as well as to load.
//
// Building meshlets and a BVH for even the simplified mesh would hold the page's main thread for a
// few hundred milliseconds; done here, the page only inflates and copies the result.
import { mkdirSync, writeFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import sharp from "sharp";
import { BinaryTopology } from "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/BinaryTopology.js";
import { bt_mesh_from_indexed_geometry } from "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/io/bt_mesh_from_indexed_geometry.js";
import { bt_mesh_simplify_by_error } from "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify_by_error.js";
import { bt_mesh_to_indexed_geometry } from "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/io/bt_mesh_to_indexed_geometry.js";
import { seededRandom } from "@woosh/meep-engine/src/core/math/random/seededRandom.js";
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 { MeshoptClusterizer } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet/build/meshoptimizer/meshopt_clusterizer.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { meshlet_geometry_to_bytes } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_transfer.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
import { GROUND, ground } from "../src/ground.js";
import { flatGrid } from "../src/procedural.js";
const MAX_ERROR = .02; // metres the simplified surface may stray from the grid
const MAP = 2048; // texels across the colour map
const assets = new URL("../assets/", import.meta.url);
mkdirSync(assets, { recursive: true });
// The mesh: simplify the dense grid, then build its meshlets and BVH.
await MeshoptClusterizer.ready;
const grid = flatGrid(GROUND, GROUND, 400, ground);
const topology = new BinaryTopology();
bt_mesh_from_indexed_geometry(topology, grid.index.data,
grid.getAttribute(StandardAttributes.Position).data, grid.getAttribute(StandardAttributes.Normal).data);
bt_mesh_simplify_by_error(topology, MAX_ERROR);
// The vertices that survive have not moved, so they keep the grid's smooth normals.
const { positions, normals, indices } = bt_mesh_to_indexed_geometry(topology);
const uv = new Float32Array(positions.length / 3 * 2);
for (let i = 0; i < uv.length / 2; i++) {
uv[i * 2] = positions[i * 3] / GROUND + .5; // 0..1 across the ground, as the grid had
uv[i * 2 + 1] = positions[i * 3 + 2] / GROUND + .5;
}
const simplified = new Geometry();
simplified.index = Attribute.from(indices, 1, "index");
simplified.addAttribute(Attribute.from(positions, 3, StandardAttributes.Position));
simplified.addAttribute(Attribute.from(normals, 3, StandardAttributes.Normal));
simplified.addAttribute(Attribute.from(uv, 2, StandardAttributes.TextureCoordinates0));
const mesh = gzipSync(new Uint8Array(meshlet_geometry_to_bytes(meshlet_geometry_build_from_geometry(simplified))), { level: 9 });
writeFileSync(new URL("ground.meshlets.gzip", assets), mesh);
console.log(`ground.meshlets.gzip: ${grid.getPrimitiveCount()} → ${indices.length / 3} triangles, ${mesh.length} bytes`);
// The colour map: grass, turning to sand and then mud where the ground dips below the water, with a
// little grain. The grain is seeded so that rebaking an unchanged ground writes the same file.
function smooth(a, b, v) { // 0 at a, 1 at b, smoothly between
const t = Math.min(1, Math.max(0, (v - a) / (b - a)));
return t * t * (3 - 2 * t);
}
const GRASS = [92, 124, 66], SAND = [196, 176, 132], MUD = [128, 112, 86];
const random = seededRandom(1);
const pixels = new Uint8ClampedArray(MAP * MAP * 3);
for (let py = 0; py < MAP; py++) {
for (let px = 0; px < MAP; px++) {
const h = ground((px / MAP - .5) * GROUND, (py / MAP - .5) * GROUND);
const sand = smooth(-.05, -.2, h), mud = smooth(-.6, -1, h), n = random() * 12;
for (let k = 0; k < 3; k++) {
pixels[(py * MAP + px) * 3 + k] = GRASS[k] + (SAND[k] - GRASS[k]) * sand + (MUD[k] - SAND[k]) * mud + n;
}
}
}
// Quality 80 is the lowest that keeps the grain; below it the grass goes flat and the sand blocky.
const map = await sharp(pixels, { raw: { width: MAP, height: MAP, channels: 3 } }).avif({ quality: 80, effort: 9 }).toBuffer();
writeFileSync(new URL("ground.avif", assets), map);
console.log(`ground.avif: ${MAP}² texels, ${map.length} bytes`);
<!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>Many waters · 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: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 12px 0 6px; }
output { color: #6bedc2; font-variant-numeric: tabular-nums; }
input { accent-color: #6bedc2; }
input[type=range] { width: 100%; }
button { margin-top: 12px; background: #23314a; color: inherit; border: 1px solid #465675; padding: 7px 16px; border-radius: 8px; cursor: pointer; }
button:focus-visible, input: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="Many waters controls">
<fieldset disabled>
<label for="wind">Wind, m/s <output id="wind-value">3.0</output></label>
<input id="wind" type="range" min="0" max="8" step="0.1" value="3">
<button id="pause" type="button" aria-pressed="false">Pause</button>
</fieldset>
</section>
<p class="hint">Click water to splash · Drag to orbit · Scroll to zoom</p>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"name": "@meep-examples/water-bodies",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A fountain basin, a pond and a stream, each its own body of water with its own medium, level and wind.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "3.55.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# Many waters
A raised stone fountain, a sunken pond and a stream in one scene: three bodies of water, each its own entity with its own level and medium, under one wind. Click one to splash it; the slider sets the wind.
Run `npm ci` and `npm run dev`, then open `/demo.html`. Build with `npm run build`.
| Body | Mesh | Level | Medium |
|---|---|---|---|
| Fountain | a 1.78 m disk | +0.62 m | `swimming_pool` |
| Pond | a 8.6 × 6.6 m grid | −0.25 m | `tropical_lagoon` |
| Stream | a 5.4 × 220 m grid | −0.35 m | `turbid_river` |
Each body is a `Water` + `WaterWaves` + `MeshletGeometry` + `Transform64` entity, and `WaterSystem` renders them all through the same transparency pass. The pond's and stream's meshes are plain rectangles: the ground around them rises above their level and hides the rest, so the banks are the outline you see. The wind is the scene's: one `GlobalFluidEffector`, 3 m/s along the stream, folded by `FluidEffectorsSystem` and published by `GPUFluidSystem`. Each body's waves come from that wind over its own fetch, so the same wind raises ripples in the fountain and taller waves along the stream.
A click casts a ray, finds the nearest body whose surface it meets inside that body's outline, and calls `WaterSystem.splash` there. `vite.config.js` keeps the asserts in `WaterSystem.js` when it strips the rest: `splash` computes its world-to-local matrix inside one.
The ground is baked rather than built in the page. `src/ground.js` defines its shape, and `tools/bake_ground.mjs` turns that into `assets/ground.meshlets.gzip`, a gzipped `MeshletGeometry` with its BVH, and `assets/ground.avif`, its colour map. The bake simplifies the 320k-triangle grid to about 42k triangles within 2 cm of the surface, then builds the meshlets, so the page only fetches both files while the engine starts. The mesh file is in the engine's own meshlet layout, so rerun the bake after changing `ground.js` or updating the engine.
`src/main.js` places the fountain and the bodies; `src/scene.js` boots the engine, the sky and `WaterSystem` and loads baked files; `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-bodies");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/water-bodies"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// WaterSystem.splash computes its world-to-local matrix inside an assert, so stripping
// that module's asserts would put every splash at the body's origin.
...strip({ exclude: ["**/engine/graphics3/WaterSystem.js"] }),
apply: 'build'
}
],
},
target: "es2022",
},
});
ground.avif · 506.0 KB
thumbnail.png · 243.8 KB
ground.meshlets.gzip
Binary file.