import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
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 { createScene } from "./scene.js";
import { flatGrid, stripes, tiles } from "./procedural.js";
const WIDTH = 8, LENGTH = 5, DEPTH = 1.5, LEVEL = -.12; // the pool, in metres
const scene = await createScene({ distance: 8, yaw: .3, pitch: .24, focus: [0, -.1, .4], sun: [-.35, -.55, .75] });
// Deck, tiled pool walls and floor.
const stone = scene.material("#b9ae9b", { roughness: .9 });
const tile = scene.material("#ffffff", { texture: await scene.canvasTexture(tiles(16, 10, "#e9f1f4", "#bcd3dc", true)), roughness: .4 });
const wallTile = scene.material("#ffffff", { texture: await scene.canvasTexture(tiles(16, 3, "#e9f1f4", "#bcd3dc", false)), roughness: .4 });
const deck = 14;
for (const [w, d, x, z] of [
[deck, LENGTH + 2 * deck, -(WIDTH + deck) / 2, 0], [deck, LENGTH + 2 * deck, (WIDTH + deck) / 2, 0],
[WIDTH, deck, 0, (LENGTH + deck) / 2], [WIDTH, deck, 0, -(LENGTH + deck) / 2],
]) scene.solid(make_box_geometry(w, DEPTH + .1, d), stone, x, -(DEPTH + .1) / 2, z);
for (const [w, d, x, z] of [
[.02, LENGTH, -WIDTH / 2 + .01, 0], [.02, LENGTH, WIDTH / 2 - .01, 0],
[WIDTH, .02, 0, LENGTH / 2 - .01], [WIDTH, .02, 0, -LENGTH / 2 + .01],
]) scene.solid(make_box_geometry(w, DEPTH, d), wallTile, x, -DEPTH / 2, z);
scene.solid(flatGrid(WIDTH, LENGTH), tile, 0, -DEPTH, 0);
// Things to look at through the surface, and in it.
const striped = scene.material("#ffffff", { texture: await scene.canvasTexture(stripes(["#d8433b", "#f4f1e8"], 24)), roughness: .5 });
for (const x of [-2.6, -1.4]) {
scene.solid(make_cylinder_geometry(.06, .06, 2.4, 20), striped, x, -DEPTH + 1.2, -.9);
}
// A straight pole leaning through the surface looks broken where it enters the water.
const leaning = scene.solid(make_cylinder_geometry(.06, .06, 3.2, 20), striped, 1.4, -DEPTH + 1.15, .6);
leaning.transform.setRotation(0, 0, Math.sin(-.3), Math.cos(-.3));
scene.move(leaning, 1.4, -DEPTH + 1.15, .6);
// A ladder down the far wall.
const steel = scene.material("#c9ced6", { roughness: .25 });
for (const x of [-.35, .35]) scene.solid(make_cylinder_geometry(.03, .03, 2.1, 12), steel, -2.4 + x, -.45, LENGTH / 2 - .18);
for (let y = -1.3; y < 0; y += .32) {
const rung = scene.solid(make_cylinder_geometry(.025, .025, .7, 12), steel, -2.4, y, LENGTH / 2 - .18);
rung.transform.setRotation(0, 0, Math.SQRT1_2, Math.SQRT1_2);
scene.move(rung, -2.4, y, LENGTH / 2 - .18);
}
// A lifebuoy floating, half in and half out, and a ball resting on the deck.
const buoy = scene.solid(make_torus_geometry(.32, .1, 16, 40), scene.material("#ffffff", {
texture: await scene.canvasTexture(stripes(["#e4572e", "#f4f1e8"], 8, true)), roughness: .6,
}), .4, LEVEL, -1.1);
buoy.transform.setRotation(Math.SQRT1_2, 0, 0, Math.SQRT1_2);
scene.move(buoy, .4, LEVEL, -1.1);
scene.solid(make_octahedron_geometry(.35, 6), scene.material("#2a9db5", { roughness: .35 }), 3.2, .35, LENGTH / 2 + .7);
scene.solid(make_box_geometry(.6, 1.6, .6), scene.material("#f2b33d", { roughness: .7 }), -3.4, .8, LENGTH / 2 + .9);
const pool = scene.waterBody(waterGrid(WIDTH, LENGTH), { at: [0, LEVEL, 0], preset: "swimming_pool" });
const WIND_DIRECTION = .6;
scene.wind(1.5, WIND_DIRECTION);
const controls = {
ior: value => pool.water.ior.set(value),
roughness: value => pool.water.roughness.set(value),
wind: value => scene.wind(value, WIND_DIRECTION),
};
for (const [id, apply] of Object.entries(controls)) {
const input = document.getElementById(id), output = document.getElementById(`${id}-value`);
input.addEventListener("input", () => {
apply(Number(input.value));
output.value = Number(input.value).toFixed(id === "wind" ? 1 : 2);
});
}
document.getElementById("reflections").addEventListener("change", event => {
scene.water.settings.reflections = event.target.checked ? 1 : 0;
});
document.querySelector("fieldset").disabled = false;
function waterGrid(width, length) {
return flatGrid(width, length, 8);
}
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);
}
<!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>Refraction and reflection · 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; }
label.toggle { justify-content: flex-start; }
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="Refraction and reflection controls">
<fieldset disabled>
<label for="ior">Index of refraction <output id="ior-value">1.33</output></label>
<input id="ior" type="range" min="1" max="1.8" step="0.01" value="1.333">
<label for="roughness">Roughness <output id="roughness-value">0.02</output></label>
<input id="roughness" type="range" min="0" max="0.3" step="0.01" value="0.02">
<label for="wind">Wind, m/s <output id="wind-value">1.5</output></label>
<input id="wind" type="range" min="0" max="6" step="0.1" value="1.5">
<label class="toggle"><input id="reflections" type="checkbox" checked>Screen-space reflections</label>
<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-optics",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A swimming pool that bends what lies below its surface and mirrors what stands above it.",
"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"
}
}
# Refraction and reflection
A tiled swimming pool with striped poles standing in it, a ladder, a floating lifebuoy, and a ball and a yellow block on the deck. Looking into the water, everything below the surface is displaced: the leaning pole looks broken where it enters, and the floor looks shallower than it is. The deck props reflect in the surface.
Run `npm ci` and `npm run dev`, then open `/demo.html`. Build with `npm run build`.
| Control | What it changes |
|---|---|
| Index of refraction | `Water.ior`. At 1 the surface neither bends nor reflects, and the poles straighten. Higher values bend more and reflect more. |
| Roughness | `Water.roughness`: blurs the reflection. |
| Wind | The scene's wind, a `GlobalFluidEffector`'s `wind`: ripples distort what you see through and in the surface. |
| Screen-space reflections | `WaterSystem.settings.reflections`. Off, reflections fall back to the sky's environment map, so the deck props drop out of the surface. |
Refraction traces each view ray, bent at the surface, into the opaque scene; reflection traces the screen and falls back to the environment, here a clear sky generated with the Hosek-Wilkie model. Both are screen-space: a refracted or reflected ray that leaves the screen, or lands behind something nearer, uses a fallback instead.
`src/main.js` builds the pool and its props; `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-optics");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/water-optics"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});
thumbnail.png · 205.0 KB