import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import { PlaneShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/PlaneShape3D.js";
import { ClothDynamics } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothDynamics.js";
import { ClothDynamicsFlags } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothDynamicsFlags.js";
import { make_uv_sphere_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_uv_sphere_geometry.js";
import { createScene } from "./scene.js";
import { createSheet } from "./sheet.js";
// The spinning-sphere test of Bridson, Fedkiw and Anderson (2002), in the proportions of
// its C-IPC reproduction (Li et al. 2021): a 1 m square cloth dropped onto a 0.152 m sphere
// that sits on the floor, the sphere turning at 180°/s after a second. The floor has the
// published friction of 0.4; the sphere grips at 1.
const GRID = 41, SIZE = 1;
const RADIUS = .152, CENTRE = [0, .16, 0];
const DROP_HEIGHT = CENTRE[1] + .16; // just clear of the top of the sphere
const FLOOR_FRICTION = .4, SPHERE_FRICTION = 1;
const SPIN = Math.PI; // radians per second
const SPIN_START = 1; // seconds after the drop
const TURN = Math.PI * 2; // one full turn, then the sphere stops
const scene = await createScene({ distance: 1.15, yaw: .7, pitch: .42, focus: [0, .1, 0] });
// A flat square sheet above the sphere, split along alternating diagonals.
const position = new Float32Array(GRID * GRID * 3), uv = new Float32Array(GRID * GRID * 2);
const triangle = [];
for (let j = 0; j < GRID; j++) {
for (let i = 0; i < GRID; i++) {
const v = j * GRID + i;
position.set([(i / (GRID - 1) - .5) * SIZE, DROP_HEIGHT, (j / (GRID - 1) - .5) * SIZE], v * 3);
uv.set([i / (GRID - 1), j / (GRID - 1)], v * 2);
if (i < GRID - 1 && j < GRID - 1) {
const a = v, b = v + 1, c = v + GRID, d = c + 1;
triangle.push(...((i + j) % 2 ? [a, c, b, b, c, d] : [a, c, d, a, d, b]));
}
}
}
const ball = scene.collide(scene.decor(make_uv_sphere_geometry(RADIUS - .004, 48, 24), scene.material("#ffffff", {
texture: await scene.canvasTexture(stripes(["#2a9db5", "#f4f1e8", "#f2b33d", "#f4f1e8"], 8)),
roughness: .5,
}), ...CENTRE), SphereShape3D.from(RADIUS), { friction: SPHERE_FRICTION });
scene.collide(scene.point(), PlaneShape3D.GROUND, { friction: FLOOR_FRICTION });
// Nothing holds the sheet. The bend and damping are those of the earlier hand-written driver,
// expressed as ClothDynamics' normalised coordinates; self-collision keeps the folds apart.
const sheet = createSheet(scene.ecd, {
position, uv, triangle,
material: scene.material("#ffffff", { texture: await scene.canvasTexture(checker("#efe6d2", "#c4553b", 10)) }),
dynamics: ClothDynamics.from({
bend: .81, damping: .01, slack: 1, thickness: .004, substeps: 2, iterations: 4,
flags: ClothDynamicsFlags.SelfCollision,
}),
});
let time = 0, angle = 0;
document.getElementById("drop").addEventListener("click", () => {
scene.cloth.reseed(sheet.entity);
time = angle = 0;
scene.place(ball, ...CENTRE);
});
scene.onStep(dt => {
time += dt;
if (time > SPIN_START && angle < TURN) {
angle = Math.min(TURN, angle + SPIN * dt);
scene.move(ball, ...CENTRE, 0, Math.sin(angle / 2), 0, Math.cos(angle / 2));
}
});
await scene.start();
document.querySelector("fieldset").disabled = false;
function checker(a, b, cells) {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 512;
const g = canvas.getContext("2d"), size = 512 / cells;
for (let y = 0; y < cells; y++) {
for (let x = 0; x < cells; x++) {
g.fillStyle = (x + y) % 2 ? b : a;
g.fillRect(x * size, y * size, size, size);
}
}
return canvas;
}
function stripes(colours, count) {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 8;
const g = canvas.getContext("2d"), size = 512 / count;
for (let i = 0; i < count; i++) {
g.fillStyle = colours[i % colours.length];
g.fillRect(i * size, 0, size, 8);
}
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 { ParentEntitySystem } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntitySystem.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 { TRANSFORM64_EVENT_PLACED } from "@woosh/meep-engine/src/engine/ecs/transform/TRANSFORM64_EVENT_PLACED.js";
import { TransformAttachmentSystem } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";
import { MeshSkinSystem } from "@woosh/meep-engine/src/engine/graphics3/MeshSkinSystem.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 { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { ClothCollider } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothCollider.js";
import { ClothColliderSystem } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothColliderSystem.js";
import { ClothPin } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothPin.js";
import { ClothRig } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothRig.js";
import { ClothSystem } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothSystem.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { ShadeDrawSide } from "@woosh/meep-engine/src/shade/renderer/material/ShadeDrawSide.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 { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
export async function createScene({ distance = 6.5, yaw = .25, pitch = .18, focus = [0, 1.5, 0], stepRate = 60 } = {}) {
// Held until start(): see there.
let tick = () => {}, playing = false;
// Moves the example's holders and colliders, then steps every cloth, on the engine's fixed step.
class ExampleClothSystem extends ClothSystem {
fixedUpdate(dt) {
if (!playing) return;
tick(dt);
super.fixedUpdate(dt);
}
}
const cloth = new ExampleClothSystem();
const colliders = new ClothColliderSystem();
const engine = await EngineHarness.bootstrap({
configuration(config, engine) {
const scene = EngineHarness.shadeScene(engine);
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
config.addSystem(new MeshSkinSystem(engine.graphics, scene));
config.addSystem(new ParentEntitySystem());
config.addSystem(new TransformAttachmentSystem());
config.addSystem(colliders);
config.addSystem(cloth);
},
});
await EngineHarness.buildBasics({
engine, enableTerrain: false, enableWater: false, enableLights: true,
enableShadows: true, shadowmapResolution: 2048,
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 floor.
cameraFarDistance: distance * 3, showFps: false,
});
engine.entityManager.dataset.getAnyComponent(TopDownCameraController).component.distanceMax = distance * 2;
engine.entityManager.fixedUpdateStepSize = 1 / stepRate;
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
const ecd = engine.entityManager.dataset;
ecd.registerManyComponentTypes([ClothRig, ClothPin]);
ecd.traverseEntities([Light], light => {
if (light.type.getValue() === LightType.DIRECTION) light.intensity.set(6);
});
function material(hex, { texture, roughness = .9 } = {}) {
const m = new StandardShadeMaterial();
m.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hex)));
m.roughness_factor = roughness;
m.draw_side = ShadeDrawSide.Double;
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);
}
// An entity with nothing but a place: a holder, or the ground's collider.
function point(x = 0, y = 0, z = 0) {
const transform = new Transform64();
transform.setTranslation(x, y, z);
transform.updateMatrix();
return { entity: new Entity().add(transform).build(ecd), transform };
}
function decor(geometry, color, x, y, z) {
const transform = new Transform64();
transform.setTranslation(x, y, z);
const entity = new Entity().add(transform)
.add(ShadedGeometry.from(meshlet_geometry_build_from_geometry(geometry), typeof color === "string" ? material(color) : color))
.build(ecd);
return { entity, transform };
}
function move(object, x, y, z, qx = 0, qy = 0, qz = 0, qw = 1) {
object.transform.setTranslation(x, y, z);
object.transform.setRotation(qx, qy, qz, qw);
object.transform.updateMatrix();
t64_announce_change(ecd, object.entity);
}
// A move that is a jump: colliders start again from here rather than sweeping across.
function place(object, ...pose) {
move(object, ...pose);
ecd.sendEvent(object.entity, TRANSFORM64_EVENT_PLACED);
}
// Cloth collides with an entity that carries a Collider and a ClothCollider.
function collide(object, shape, { friction = .5, inflation = 0 } = {}) {
const collider = new Collider();
collider.shape = shape;
collider.friction = friction;
ecd.addComponentToEntity(object.entity, collider);
ecd.addComponentToEntity(object.entity, ClothCollider.from({ inflation }));
return { ...object, collider };
}
decor(make_box_geometry(14, .2, 14), "#1b2433", 0, -.1, 0);
const pause = document.getElementById("pause");
pause.addEventListener("click", () => {
playing = !playing;
pause.textContent = playing ? "Pause" : "Resume";
pause.setAttribute("aria-pressed", String(!playing));
});
// Start the cloth once a frame showing the scene has finished on the GPU. Shade builds the
// scene's pipelines over its first frames, which take seconds; stepping through them would
// bank those seconds as fixed steps the engine then has to catch up on.
async function start() {
await new Promise(resolve => {
const rendered = () => {
engine.graphics.on.postRender.remove(rendered);
resolve();
};
engine.graphics.on.postRender.add(rendered);
});
await engine.graphics.renderer.device.queue.onSubmittedWorkDone();
playing = true;
}
return {
engine, ecd, cloth, colliders, material, canvasTexture, point, decor, move, place, collide, start,
onStep: callback => { tick = callback; },
};
}
// A sheet of cloth for ClothSystem: a triangle mesh skinned with one joint per vertex.
//
// The sheet is built here as a model, the way a loader describes one: the mesh, a root joint,
// and a joint per vertex hanging from the root at the vertex's rest position. Placed like any
// model, it becomes entities, and `cloth_proxy_from_mesh` reads its joints and triangles into a
// cloth: a hard stretch row per edge and a hinge per interior edge. ClothSystem then moves every
// joint, both its position and the turn of the surface around it, and GPU skinning moves and
// lights the mesh.
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { prefab_compile } from "@woosh/meep-engine/src/engine/graphics3/prefab/prefab_compile.js";
import { prefab_instantiate } from "@woosh/meep-engine/src/engine/graphics3/prefab/prefab_instantiate.js";
import { cloth_proxy_from_mesh } from "@woosh/meep-engine/src/engine/physics/cloth/build/cloth_proxy_from_mesh.js";
import { Cloth } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/Cloth.js";
import { ClothRig } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothRig.js";
import { Skin } from "@woosh/meep-engine/src/shade/renderer/animation/Skin.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 { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { SceneBundle } from "@woosh/meep-engine/src/shade/renderer/loader/SceneBundle.js";
import { SceneNode } from "@woosh/meep-engine/src/shade/renderer/loader/SceneNode.js";
/**
* @param {EntityComponentDataset} ecd
* @param {object} options
* @param {Float32Array} options.position rest positions, 3 per vertex, world space
* @param {Float32Array} options.uv 2 per vertex
* @param {number[]} options.triangle vertex triples; particle `i` of the cloth is vertex `i`
* @param {StandardShadeMaterial} options.material
* @param {ClothDynamics} options.dynamics
* @returns {{entity: number, cloth: Cloth}} the entity carrying the cloth, which a `ClothPin` names
*/
export function createSheet(ecd, { position, uv, triangle, material, dynamics }) {
const count = position.length / 3;
// Vertex i is weighted wholly to joint i; the bind normals are the rest surface's.
const joints = new Uint16Array(count * 4), weights = new Float32Array(count * 4);
for (let i = 0; i < count; i++) {
joints[i * 4] = i;
weights[i * 4] = 1;
}
const geometry = new Geometry();
geometry.addAttribute(Attribute.from(Float32Array.from(position), 3, StandardAttributes.Position));
geometry.addAttribute(Attribute.from(restNormals(position, triangle), 3, StandardAttributes.Normal));
geometry.addAttribute(Attribute.from(Float32Array.from(uv), 2, StandardAttributes.TextureCoordinates0));
geometry.addAttribute(Attribute.from(joints, 4, StandardAttributes.SkinningJoints));
geometry.addAttribute(Attribute.from(weights, 4, StandardAttributes.SkinningWeights));
geometry.index = Attribute.from(Uint32Array.from(triangle), 1, "index");
const bundle = new SceneBundle();
const mesh = bundle.add_node(SceneNode.from({ name: "Sheet", geometry: meshlet_geometry_build_from_geometry(geometry), material }));
const rig = bundle.add_node(SceneNode.from({ name: "Rig" }));
const names = [], vertexJoints = [];
for (let i = 0; i < count; i++) {
names.push(`Vertex_${i}`);
vertexJoints.push(bundle.add_node(SceneNode.from({
name: names[i], parent: rig, translation: [position[i * 3], position[i * 3 + 1], position[i * 3 + 2]],
})));
}
// The root joint comes last, so joint i stays vertex i. It is the frame the cloth is simulated in.
const inverseBind = new Float32Array((count + 1) * 16);
for (let j = 0; j <= count; j++) {
const m = j * 16;
inverseBind[m] = inverseBind[m + 5] = inverseBind[m + 10] = inverseBind[m + 15] = 1;
if (j < count) for (let k = 0; k < 3; k++) inverseBind[m + 12 + k] = -position[j * 3 + k];
}
bundle.skins = [Skin.from({ joints: [...vertexJoints, rig], inverse_bind_matrices: inverseBind, meshes: [mesh] })];
const prefab = prefab_compile(bundle);
const { proxy, error } = cloth_proxy_from_mesh(prefab, prefab.skins[0], names, triangle, { name: "sheet" });
if (proxy === null) throw new Error(error);
const entity = new Entity().add(new Transform64()).build(ecd);
const instance = prefab_instantiate(prefab, ecd, entity);
const cloth = Cloth.from(dynamics);
// The rig names the mesh whose MeshSkin the proxy's joints index.
ecd.addComponentToEntity(entity, ClothRig.from(proxy, instance.mesh_entities[0]));
ecd.addComponentToEntity(entity, cloth);
return { entity, cloth };
}
// Area-weighted vertex normals of the rest surface.
function restNormals(p, triangle) {
const normal = new Float32Array(p.length);
for (let t = 0; t < triangle.length; t += 3) {
const a = triangle[t] * 3, b = triangle[t + 1] * 3, c = triangle[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]) {
normal[v] += nx;
normal[v + 1] += ny;
normal[v + 2] += nz;
}
}
for (let i = 0; i < normal.length; i += 3) {
const l = Math.hypot(normal[i], normal[i + 1], normal[i + 2]) || 1;
for (let k = 0; k < 3; k++) normal[i + k] /= l;
}
return normal;
}
<!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>Sphere drape · 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: 280px; 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; margin-right: 6px; 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="Sphere drape controls">
<fieldset disabled>
<button id="drop" type="button">Drop again</button><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/cloth-sphere",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "The spinning-sphere cloth test: a square cloth dropped onto a sphere that makes one full turn.",
"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"
}
}
# Sphere drape
The spinning-sphere test of Bridson, Fedkiw and Anderson ("Robust Treatment of Collisions, Contact and Friction for Cloth Animation", SIGGRAPH 2002), in the proportions of its reproduction in C-IPC (Li, Kaufman and Jiang, SIGGRAPH 2021, Figure 6): a 1 m square cloth is dropped onto a 0.152 m sphere resting on the floor, with friction 0.4 on the floor and 1 on the sphere. A second after the drop the sphere turns about its vertical axis at 180°/s. It makes one full turn and stops, and friction winds the cloth round it into a spiral of folds.
Run `npm ci` and `npm run dev`, then open `/demo.html`. Build with `npm run build`.
The sheet is 41 × 41 vertices, one every 2.5 cm, and `ClothSystem` simulates it. `src/sheet.js` builds it in code as a skinned model with one joint per vertex, all hanging from a root joint, and places it like any model. `cloth_proxy_from_mesh` turns the joints and triangles into a cloth proxy: a hard stretch row per edge and a hinge per interior edge. A `Cloth` and a `ClothRig` naming the mesh go on the sheet's entity. Each fixed step, `ClothSystem` writes every joint's position and the turn of the surface around it, and GPU skinning moves and lights the mesh. Nothing holds the sheet, so it has no `ClothPin`.
The fabric is a `ClothDynamics`: bend 0.81 and damping 0.01 on its normalised scales, 4 mm thick, 2 substeps × 4 iterations a step, self-collision on. The sphere and the floor each carry a `Collider` and a `ClothCollider`. Friction keeps each contact's sticking point in the collider's own frame, so turning the sphere carries the fabric with it. **Drop again** calls `ClothSystem#reseed`, which puts the sheet back in its rest pose above the sphere.
`src/main.js` sets up the scene. `src/scene.js` boots the engine, and holds the cloth until a frame showing the scene has finished on the GPU; Shade's first frames take seconds, and stepping through them would leave the engine catching up for seconds more.
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/cloth-sphere");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/cloth-sphere"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});
thumbnail.png · 188.7 KB